nexo-brain 2.7.0 → 3.0.1

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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -12
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +295 -7
  6. package/src/cli.py +111 -0
  7. package/src/client_preferences.py +99 -1
  8. package/src/client_sync.py +207 -3
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +141 -1
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/protocol.html +199 -0
  14. package/src/db/__init__.py +23 -1
  15. package/src/db/_learnings.py +31 -4
  16. package/src/db/_personal_scripts.py +12 -0
  17. package/src/db/_protocol.py +303 -0
  18. package/src/db/_schema.py +248 -0
  19. package/src/db/_watchers.py +173 -0
  20. package/src/db/_workflow.py +952 -0
  21. package/src/doctor/providers/boot.py +45 -19
  22. package/src/doctor/providers/runtime.py +923 -8
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/requirements.txt +1 -0
  38. package/src/script_registry.py +142 -0
  39. package/src/scripts/deep-sleep/apply_findings.py +204 -0
  40. package/src/scripts/deep-sleep/collect.py +49 -4
  41. package/src/scripts/nexo-agent-run.py +2 -0
  42. package/src/scripts/nexo-daily-self-audit.py +843 -5
  43. package/src/scripts/nexo-evolution-run.py +343 -1
  44. package/src/server.py +92 -6
  45. package/src/skills_runtime.py +151 -0
  46. package/src/state_watchers_runtime.py +334 -0
  47. package/src/tools_learnings.py +345 -7
  48. package/src/tools_sessions.py +183 -0
  49. package/templates/CLAUDE.md.template +9 -1
  50. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -7,12 +7,18 @@ import os
7
7
  import platform
8
8
  import plistlib
9
9
  import re
10
+ import shlex
11
+ import sqlite3
10
12
  import subprocess
11
13
  import sys
12
14
  import time
13
- import tomllib
14
15
  from pathlib import Path
15
16
 
17
+ try:
18
+ import tomllib
19
+ except ModuleNotFoundError: # Python < 3.11
20
+ import tomli as tomllib
21
+
16
22
  from client_preferences import (
17
23
  detect_installed_clients,
18
24
  normalize_client_preferences,
@@ -43,6 +49,51 @@ PACKAGE_JSON = NEXO_CODE / "package.json"
43
49
  CHANGELOG_FILE = NEXO_CODE / "CHANGELOG.md"
44
50
 
45
51
 
52
+ def _recorded_source_root() -> Path | None:
53
+ version_file = NEXO_HOME / "version.json"
54
+ try:
55
+ payload = json.loads(version_file.read_text())
56
+ except Exception:
57
+ return None
58
+ source = payload.get("source")
59
+ if not source:
60
+ return None
61
+ candidate = Path(str(source)).expanduser()
62
+ if (candidate / "package.json").is_file() and (candidate / "CHANGELOG.md").is_file():
63
+ return candidate
64
+ return None
65
+
66
+
67
+ def _release_root() -> Path:
68
+ source_root = _recorded_source_root()
69
+ candidates = [
70
+ source_root,
71
+ PACKAGE_JSON.parent,
72
+ CHANGELOG_FILE.parent,
73
+ NEXO_CODE,
74
+ NEXO_CODE.parent,
75
+ ]
76
+ for candidate in candidates:
77
+ if not candidate:
78
+ continue
79
+ candidate = Path(candidate)
80
+ if (candidate / "package.json").is_file() and (candidate / "CHANGELOG.md").is_file():
81
+ return candidate
82
+ return Path(NEXO_CODE)
83
+
84
+
85
+ def _package_json_path() -> Path:
86
+ if PACKAGE_JSON.is_file():
87
+ return PACKAGE_JSON
88
+ return _release_root() / "package.json"
89
+
90
+
91
+ def _changelog_path() -> Path:
92
+ if CHANGELOG_FILE.is_file():
93
+ return CHANGELOG_FILE
94
+ return _release_root() / "CHANGELOG.md"
95
+
96
+
46
97
  def _codex_bootstrap_config_status() -> dict:
47
98
  path = Path.home() / ".codex" / "config.toml"
48
99
  if not path.is_file():
@@ -187,10 +238,395 @@ def _recent_codex_session_parity_status(*, days: int = 7, max_files: int = 24) -
187
238
  return status
188
239
 
189
240
 
241
+ def _normalize_path_token(value: str) -> str:
242
+ return str(value or "").replace("\\", "/").rstrip("/").lower()
243
+
244
+
245
+ def _split_applies_to(applies_to: str) -> list[str]:
246
+ return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
247
+
248
+
249
+ def _applies_to_matches_file(applies_to: str, filepath: str) -> bool:
250
+ file_path = Path(filepath)
251
+ file_norm = _normalize_path_token(str(file_path))
252
+ parent_norm = _normalize_path_token(str(file_path.parent))
253
+ filename = file_path.name.lower()
254
+ stem = file_path.stem.lower()
255
+ parent_name = file_path.parent.name.lower()
256
+
257
+ for raw in _split_applies_to(applies_to):
258
+ token_norm = _normalize_path_token(raw)
259
+ if not token_norm:
260
+ continue
261
+ if "/" in token_norm:
262
+ if (
263
+ file_norm == token_norm
264
+ or file_norm.endswith(f"/{token_norm}")
265
+ or file_norm.startswith(f"{token_norm}/")
266
+ or parent_norm == token_norm
267
+ or parent_norm.endswith(f"/{token_norm}")
268
+ ):
269
+ return True
270
+ continue
271
+ if token_norm in {filename, stem, parent_name}:
272
+ return True
273
+ return False
274
+
275
+
276
+ def _parse_jsonish_arguments(arguments) -> dict:
277
+ if isinstance(arguments, dict):
278
+ return arguments
279
+ if isinstance(arguments, str):
280
+ try:
281
+ parsed = json.loads(arguments)
282
+ except Exception:
283
+ return {}
284
+ return parsed if isinstance(parsed, dict) else {}
285
+ return {}
286
+
287
+
288
+ def _resolve_candidate_path(token: str, cwd: str) -> str:
289
+ token = str(token or "").strip()
290
+ if not token:
291
+ return ""
292
+ if token.startswith("~"):
293
+ token = str(Path(token).expanduser())
294
+ path = Path(token)
295
+ if not path.is_absolute():
296
+ if not cwd.strip():
297
+ return ""
298
+ path = Path(cwd).expanduser() / path
299
+ return str(path.resolve())
300
+
301
+
302
+ def _extract_shell_file_candidates(command: str, cwd: str) -> list[str]:
303
+ if not command.strip():
304
+ return []
305
+ try:
306
+ tokens = shlex.split(command)
307
+ except Exception:
308
+ tokens = command.split()
309
+
310
+ candidates: list[str] = []
311
+ seen = set()
312
+ shell_noise = {"&&", "||", "|", ";", ">", ">>", "<", "<<<"}
313
+ suffixes = {
314
+ ".py", ".md", ".json", ".jsonl", ".sh", ".txt", ".toml", ".yaml", ".yml",
315
+ ".js", ".ts", ".tsx", ".jsx", ".php", ".sql", ".rs", ".go", ".c", ".cpp",
316
+ ".h", ".css", ".html",
317
+ }
318
+ for token in tokens:
319
+ if token in shell_noise or token.startswith("-"):
320
+ continue
321
+ if not token.startswith(("/", "~", ".")) and "/" not in token and Path(token).suffix.lower() not in suffixes:
322
+ continue
323
+ resolved = _resolve_candidate_path(token, cwd)
324
+ normalized = _normalize_path_token(resolved)
325
+ if resolved and normalized not in seen:
326
+ seen.add(normalized)
327
+ candidates.append(resolved)
328
+ return candidates
329
+
330
+
331
+ def _classify_shell_operation(command: str) -> str:
332
+ if not command.strip():
333
+ return "read"
334
+ try:
335
+ tokens = shlex.split(command)
336
+ except Exception:
337
+ tokens = command.split()
338
+ if not tokens:
339
+ return "read"
340
+ base = Path(tokens[0]).name.lower()
341
+ if base in {"rm", "unlink", "rmdir"}:
342
+ return "delete"
343
+ if base in {"mv", "cp", "touch", "install"}:
344
+ return "write"
345
+ if base == "sed" and "-i" in tokens:
346
+ return "write"
347
+ if base == "perl" and any(token == "-i" or token.startswith("-i") for token in tokens[1:]):
348
+ return "write"
349
+ return "read"
350
+
351
+
352
+ def _extract_shell_file_touches(command: str, cwd: str) -> list[tuple[str, str]]:
353
+ operation = _classify_shell_operation(command)
354
+ return [(candidate, operation) for candidate in _extract_shell_file_candidates(command, cwd)]
355
+
356
+
357
+ def _extract_apply_patch_targets(patch_text: str, cwd: str) -> list[tuple[str, str]]:
358
+ targets: list[tuple[str, str]] = []
359
+ seen = set()
360
+ for raw_line in str(patch_text or "").splitlines():
361
+ line = raw_line.strip()
362
+ prefix = None
363
+ operation = "write"
364
+ if line.startswith("*** Update File: "):
365
+ prefix = "*** Update File: "
366
+ elif line.startswith("*** Add File: "):
367
+ prefix = "*** Add File: "
368
+ elif line.startswith("*** Delete File: "):
369
+ prefix = "*** Delete File: "
370
+ operation = "delete"
371
+ if not prefix:
372
+ continue
373
+ resolved = _resolve_candidate_path(line[len(prefix):].strip(), cwd)
374
+ normalized = _normalize_path_token(resolved)
375
+ if resolved and normalized not in seen:
376
+ seen.add(normalized)
377
+ targets.append((resolved, operation))
378
+ return targets
379
+
380
+
381
+ def _extract_declared_file_targets(args: dict, cwd: str) -> set[str]:
382
+ raw_items: list[str] = []
383
+ for key in ("files", "paths", "file_paths"):
384
+ value = args.get(key)
385
+ if isinstance(value, str):
386
+ raw_items.extend(part.strip() for part in value.split(",") if part.strip())
387
+ elif isinstance(value, list):
388
+ raw_items.extend(str(item).strip() for item in value if str(item).strip())
389
+ for key in ("file_path", "path"):
390
+ value = args.get(key)
391
+ if isinstance(value, str) and value.strip():
392
+ raw_items.append(value.strip())
393
+ resolved = set()
394
+ for item in raw_items:
395
+ candidate = _resolve_candidate_path(item, cwd)
396
+ if candidate:
397
+ resolved.add(_normalize_path_token(candidate))
398
+ return resolved
399
+
400
+
401
+ def _load_active_conditioned_learnings() -> list[dict]:
402
+ db_path = NEXO_HOME / "data" / "nexo.db"
403
+ if not db_path.is_file():
404
+ return []
405
+ try:
406
+ import sqlite3
407
+
408
+ conn = sqlite3.connect(str(db_path), timeout=2)
409
+ conn.row_factory = sqlite3.Row
410
+ table = conn.execute(
411
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='learnings'"
412
+ ).fetchone()
413
+ if not table:
414
+ conn.close()
415
+ return []
416
+ rows = conn.execute(
417
+ """SELECT id, title, applies_to
418
+ FROM learnings
419
+ WHERE status = 'active' AND COALESCE(applies_to, '') != ''
420
+ ORDER BY updated_at DESC, id DESC"""
421
+ ).fetchall()
422
+ conn.close()
423
+ return [dict(row) for row in rows]
424
+ except Exception:
425
+ return []
426
+
427
+
428
+ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files: int = 24) -> dict:
429
+ conditioned = _load_active_conditioned_learnings()
430
+ status = {
431
+ "files": 0,
432
+ "conditioned_rules": len(conditioned),
433
+ "conditioned_sessions": 0,
434
+ "conditioned_touches": 0,
435
+ "read_without_protocol": 0,
436
+ "write_without_protocol": 0,
437
+ "write_without_guard_ack": 0,
438
+ "delete_without_protocol": 0,
439
+ "delete_without_guard_ack": 0,
440
+ "latest_violation_age_seconds": None,
441
+ "samples": [],
442
+ }
443
+ if not conditioned:
444
+ return status
445
+
446
+ roots = [
447
+ Path.home() / ".codex" / "sessions",
448
+ Path.home() / ".codex" / "archived_sessions",
449
+ ]
450
+ cutoff = time.time() - (days * 86400)
451
+ candidates: list[tuple[float, Path]] = []
452
+ for root in roots:
453
+ if not root.exists():
454
+ continue
455
+ for path in root.rglob("*.jsonl"):
456
+ try:
457
+ mtime = path.stat().st_mtime
458
+ except OSError:
459
+ continue
460
+ if mtime >= cutoff:
461
+ candidates.append((mtime, path))
462
+ candidates.sort(key=lambda item: item[0], reverse=True)
463
+ files = candidates[:max_files]
464
+ status["files"] = len(files)
465
+
466
+ for file_mtime, path in files:
467
+ cwd = ""
468
+ protocol_files: set[str] = set()
469
+ guard_files: set[str] = set()
470
+ guard_ack = False
471
+ session_touches = 0
472
+ session_samples: list[dict] = []
473
+
474
+ try:
475
+ with path.open() as fh:
476
+ for raw in fh:
477
+ try:
478
+ event = json.loads(raw)
479
+ except Exception:
480
+ continue
481
+ event_age_seconds = None
482
+ event_ts = _parse_timestamp(str(event.get("timestamp", "") or ""))
483
+ if event_ts is not None:
484
+ event_age_seconds = max(0.0, time.time() - event_ts.timestamp())
485
+ payload = event.get("payload", {})
486
+ if event.get("type") == "session_meta" and isinstance(payload, dict):
487
+ cwd = str(payload.get("cwd", "") or "")
488
+ continue
489
+ if event.get("type") != "response_item" or not isinstance(payload, dict):
490
+ continue
491
+ if payload.get("type") != "function_call":
492
+ continue
493
+
494
+ name = str(payload.get("name", "") or "")
495
+ args = _parse_jsonish_arguments(payload.get("arguments"))
496
+
497
+ if name in {"mcp__nexo__nexo_task_open", "nexo_task_open"}:
498
+ protocol_files.update(_extract_declared_file_targets(args, cwd))
499
+ continue
500
+ if name in {"mcp__nexo__nexo_guard_check", "nexo_guard_check"}:
501
+ guard_files.update(_extract_declared_file_targets(args, cwd))
502
+ continue
503
+ if name in {"mcp__nexo__nexo_task_acknowledge_guard", "nexo_task_acknowledge_guard"}:
504
+ guard_ack = True
505
+ continue
506
+
507
+ touched_files: list[tuple[str, str]] = []
508
+ if name in {"exec_command", "functions.exec_command"}:
509
+ touched_files = _extract_shell_file_touches(str(args.get("cmd", "") or ""), cwd)
510
+ elif name in {"apply_patch", "functions.apply_patch"}:
511
+ patch_text = payload.get("arguments", "")
512
+ touched_files = _extract_apply_patch_targets(str(patch_text or ""), cwd)
513
+
514
+ if not touched_files:
515
+ continue
516
+
517
+ for touched, operation in touched_files:
518
+ matches = [row for row in conditioned if _applies_to_matches_file(str(row.get("applies_to", "")), touched)]
519
+ if not matches:
520
+ continue
521
+ session_touches += 1
522
+ status["conditioned_touches"] += 1
523
+ normalized = _normalize_path_token(touched)
524
+ if operation == "read":
525
+ if normalized not in protocol_files and normalized not in guard_files:
526
+ status["read_without_protocol"] += 1
527
+ age_seconds = (
528
+ event_age_seconds
529
+ if event_age_seconds is not None
530
+ else max(0.0, time.time() - float(file_mtime))
531
+ )
532
+ current_latest = status.get("latest_violation_age_seconds")
533
+ if current_latest is None or age_seconds < float(current_latest):
534
+ status["latest_violation_age_seconds"] = round(age_seconds, 1)
535
+ session_samples.append(
536
+ {"kind": "read_without_protocol", "file": touched, "tool": name}
537
+ )
538
+ elif operation in {"write", "delete"}:
539
+ if normalized not in protocol_files:
540
+ status["write_without_protocol"] += 1
541
+ if operation == "delete":
542
+ status["delete_without_protocol"] += 1
543
+ age_seconds = (
544
+ event_age_seconds
545
+ if event_age_seconds is not None
546
+ else max(0.0, time.time() - float(file_mtime))
547
+ )
548
+ current_latest = status.get("latest_violation_age_seconds")
549
+ if current_latest is None or age_seconds < float(current_latest):
550
+ status["latest_violation_age_seconds"] = round(age_seconds, 1)
551
+ session_samples.append(
552
+ {
553
+ "kind": f"{operation}_without_protocol",
554
+ "file": touched,
555
+ "tool": name,
556
+ }
557
+ )
558
+ elif not guard_ack:
559
+ status["write_without_guard_ack"] += 1
560
+ if operation == "delete":
561
+ status["delete_without_guard_ack"] += 1
562
+ age_seconds = (
563
+ event_age_seconds
564
+ if event_age_seconds is not None
565
+ else max(0.0, time.time() - float(file_mtime))
566
+ )
567
+ current_latest = status.get("latest_violation_age_seconds")
568
+ if current_latest is None or age_seconds < float(current_latest):
569
+ status["latest_violation_age_seconds"] = round(age_seconds, 1)
570
+ session_samples.append(
571
+ {
572
+ "kind": f"{operation}_without_guard_ack",
573
+ "file": touched,
574
+ "tool": name,
575
+ }
576
+ )
577
+ except Exception:
578
+ continue
579
+
580
+ if session_touches:
581
+ status["conditioned_sessions"] += 1
582
+ for sample in session_samples[:3]:
583
+ if len(status["samples"]) >= 6:
584
+ break
585
+ status["samples"].append({"session_file": str(path), **sample})
586
+
587
+ return status
588
+
589
+
590
+ def _open_protocol_debt_summary(*debt_types: str) -> dict:
591
+ db_path = NEXO_HOME / "data" / "nexo.db"
592
+ summary = {"available": False, "open_total": 0, "counts": {}}
593
+ if not db_path.is_file() or not debt_types:
594
+ return summary
595
+
596
+ try:
597
+ conn = sqlite3.connect(str(db_path), timeout=2)
598
+ conn.row_factory = sqlite3.Row
599
+ table = conn.execute(
600
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='protocol_debt'"
601
+ ).fetchone()
602
+ if not table:
603
+ conn.close()
604
+ return summary
605
+ placeholders = ",".join("?" for _ in debt_types)
606
+ rows = conn.execute(
607
+ f"""SELECT debt_type, COUNT(*) AS total
608
+ FROM protocol_debt
609
+ WHERE status = 'open' AND debt_type IN ({placeholders})
610
+ GROUP BY debt_type""",
611
+ tuple(debt_types),
612
+ ).fetchall()
613
+ conn.close()
614
+ except Exception:
615
+ return summary
616
+
617
+ counts = {str(row["debt_type"]): int(row["total"] or 0) for row in rows}
618
+ summary["available"] = True
619
+ summary["counts"] = counts
620
+ summary["open_total"] = sum(counts.values())
621
+ return summary
622
+
623
+
190
624
  def _client_assumption_regressions() -> list[str]:
191
- src_root = NEXO_CODE / "src"
625
+ src_root = NEXO_CODE if (NEXO_CODE / "server.py").is_file() else (NEXO_CODE / "src")
192
626
  if not src_root.is_dir():
193
627
  return []
628
+ backup_root = (NEXO_HOME / "backups").resolve()
629
+ contrib_root = (NEXO_HOME / "contrib").resolve()
194
630
  allowed_claude_projects = {
195
631
  (src_root / "scripts" / "deep-sleep" / "collect.py").resolve(),
196
632
  Path(__file__).resolve(),
@@ -202,6 +638,16 @@ def _client_assumption_regressions() -> list[str]:
202
638
  except Exception:
203
639
  continue
204
640
  resolved = path.resolve()
641
+ try:
642
+ if resolved.is_relative_to(backup_root):
643
+ continue
644
+ except Exception:
645
+ pass
646
+ try:
647
+ if resolved.is_relative_to(contrib_root):
648
+ continue
649
+ except Exception:
650
+ pass
205
651
  if ".claude/projects" in text and resolved not in allowed_claude_projects:
206
652
  offenders.append(f"{path.relative_to(NEXO_CODE)} hardcodes ~/.claude/projects")
207
653
  collect_path = src_root / "scripts" / "deep-sleep" / "collect.py"
@@ -251,7 +697,7 @@ def _latest_periodic_summary(kind: str) -> dict | None:
251
697
 
252
698
  def _package_version() -> str:
253
699
  try:
254
- payload = json.loads(PACKAGE_JSON.read_text())
700
+ payload = json.loads(_package_json_path().read_text())
255
701
  except Exception:
256
702
  return ""
257
703
  return str(payload.get("version", "") or "").strip()
@@ -259,7 +705,7 @@ def _package_version() -> str:
259
705
 
260
706
  def _top_changelog_version() -> str:
261
707
  try:
262
- text = CHANGELOG_FILE.read_text(encoding="utf-8")
708
+ text = _changelog_path().read_text(encoding="utf-8")
263
709
  except Exception:
264
710
  return ""
265
711
  match = re.search(r"^## \[([^\]]+)\]", text, flags=re.MULTILINE)
@@ -281,15 +727,23 @@ def _count_checks(checks) -> int:
281
727
 
282
728
 
283
729
  def _parse_timestamp(value: str) -> dt.datetime | None:
730
+ text = str(value or "").strip()
731
+ if not text:
732
+ return None
733
+ if text.endswith("Z"):
734
+ text = text[:-1] + "+00:00"
284
735
  for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
285
736
  try:
286
- return dt.datetime.strptime(value, fmt)
737
+ return dt.datetime.strptime(text, fmt)
287
738
  except ValueError:
288
739
  continue
289
740
  try:
290
- return dt.datetime.fromisoformat(value)
741
+ parsed = dt.datetime.fromisoformat(text)
291
742
  except ValueError:
292
743
  return None
744
+ if parsed.tzinfo is None:
745
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
746
+ return parsed
293
747
 
294
748
 
295
749
  def _enabled_optionals() -> dict[str, bool]:
@@ -1073,6 +1527,11 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
1073
1527
  f"({report.get('scripts', 0)} scripts, {report.get('schedules', 0)} schedules"
1074
1528
  f", {audit.get('healthy', report.get('schedules', 0))} managed)"
1075
1529
  )
1530
+ keep_alive = int(audit.get("keep_alive", 0) or 0)
1531
+ if keep_alive:
1532
+ summary += (
1533
+ f", keep_alive {int(audit.get('runtime_alive', 0) or 0)}/{keep_alive} alive"
1534
+ )
1076
1535
  if fix:
1077
1536
  summary += " (fixed)"
1078
1537
  return DoctorCheck(
@@ -1404,6 +1863,136 @@ def check_codex_session_parity() -> DoctorCheck:
1404
1863
  )
1405
1864
 
1406
1865
 
1866
+ def check_codex_conditioned_file_discipline() -> DoctorCheck:
1867
+ try:
1868
+ schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
1869
+ except Exception:
1870
+ schedule = {}
1871
+ prefs = normalize_client_preferences(schedule)
1872
+ wants_codex = bool(
1873
+ prefs.get("interactive_clients", {}).get("codex")
1874
+ or prefs.get("default_terminal_client") == "codex"
1875
+ or (prefs.get("automation_enabled", True) and prefs.get("automation_backend") == "codex")
1876
+ )
1877
+ if not wants_codex:
1878
+ return DoctorCheck(
1879
+ id="runtime.codex_conditioned_files",
1880
+ tier="runtime",
1881
+ status="healthy",
1882
+ severity="info",
1883
+ summary="Codex conditioned-file discipline check skipped (Codex not selected)",
1884
+ )
1885
+
1886
+ audit = _recent_codex_conditioned_file_discipline_status()
1887
+ debt_summary = _open_protocol_debt_summary(
1888
+ "codex_conditioned_read_without_protocol",
1889
+ "codex_conditioned_write_without_protocol",
1890
+ "codex_conditioned_write_without_guard_ack",
1891
+ "codex_conditioned_delete_without_protocol",
1892
+ "codex_conditioned_delete_without_guard_ack",
1893
+ )
1894
+ evidence = [
1895
+ f"active conditioned file rules: {audit['conditioned_rules']}",
1896
+ f"recent codex sessions inspected: {audit['files']}",
1897
+ ]
1898
+
1899
+ if audit["conditioned_rules"] == 0:
1900
+ return DoctorCheck(
1901
+ id="runtime.codex_conditioned_files",
1902
+ tier="runtime",
1903
+ status="healthy",
1904
+ severity="info",
1905
+ summary="No active conditioned-file learnings defined for Codex session audits",
1906
+ evidence=evidence,
1907
+ )
1908
+
1909
+ if audit["files"] == 0 or audit["conditioned_sessions"] == 0:
1910
+ return DoctorCheck(
1911
+ id="runtime.codex_conditioned_files",
1912
+ tier="runtime",
1913
+ status="healthy",
1914
+ severity="info",
1915
+ summary="No conditioned-file touches seen in recent Codex sessions",
1916
+ evidence=evidence + [f"conditioned touches: {audit['conditioned_touches']}"],
1917
+ )
1918
+
1919
+ evidence.extend([
1920
+ f"conditioned sessions: {audit['conditioned_sessions']}",
1921
+ f"conditioned touches: {audit['conditioned_touches']}",
1922
+ f"read touches without protocol/guard review: {audit['read_without_protocol']}",
1923
+ f"write touches without protocol task: {audit['write_without_protocol']}",
1924
+ f"write touches without guard acknowledgement: {audit['write_without_guard_ack']}",
1925
+ f"delete touches without protocol task: {audit['delete_without_protocol']}",
1926
+ f"delete touches without guard acknowledgement: {audit['delete_without_guard_ack']}",
1927
+ ])
1928
+ if audit.get("latest_violation_age_seconds") is not None:
1929
+ age_hours = round(float(audit["latest_violation_age_seconds"]) / 3600, 2)
1930
+ evidence.append(f"latest violation age hours: {age_hours}")
1931
+ if debt_summary["available"]:
1932
+ evidence.append(f"open conditioned protocol debt: {debt_summary['open_total']}")
1933
+ for sample in audit["samples"][:5]:
1934
+ evidence.append(f"{sample['kind']}: {sample['file']} via {sample['tool']}")
1935
+
1936
+ repair_plan: list[str] = []
1937
+ if audit["read_without_protocol"]:
1938
+ repair_plan.append("Run nexo_task_open or nexo_guard_check before reading conditioned files in Codex sessions")
1939
+ if audit["write_without_protocol"]:
1940
+ repair_plan.append("Open work with nexo_task_open before editing conditioned files from Codex")
1941
+ if audit["write_without_guard_ack"]:
1942
+ repair_plan.append("Acknowledge blocking guard rules before writing conditioned files from Codex")
1943
+ if audit["delete_without_protocol"]:
1944
+ repair_plan.append("Open work with nexo_task_open before deleting conditioned files from Codex")
1945
+ if audit["delete_without_guard_ack"]:
1946
+ repair_plan.append("Acknowledge blocking guard rules before deleting conditioned files from Codex")
1947
+ if not repair_plan:
1948
+ repair_plan.append("Keep using managed Codex bootstrap so conditioned-file discipline remains visible in transcripts")
1949
+
1950
+ historical_read_only = (
1951
+ audit["read_without_protocol"] > 0
1952
+ and audit["write_without_protocol"] == 0
1953
+ and audit["write_without_guard_ack"] == 0
1954
+ and audit["delete_without_protocol"] == 0
1955
+ and audit["delete_without_guard_ack"] == 0
1956
+ and debt_summary["available"]
1957
+ and debt_summary["open_total"] == 0
1958
+ and audit.get("latest_violation_age_seconds") is not None
1959
+ and float(audit["latest_violation_age_seconds"]) >= 7200
1960
+ )
1961
+
1962
+ if audit["write_without_protocol"] or audit["write_without_guard_ack"]:
1963
+ status = "critical"
1964
+ severity = "error"
1965
+ elif historical_read_only:
1966
+ status = "healthy"
1967
+ severity = "info"
1968
+ elif audit["read_without_protocol"]:
1969
+ status = "degraded"
1970
+ severity = "warn"
1971
+ else:
1972
+ status = "healthy"
1973
+ severity = "info"
1974
+
1975
+ return DoctorCheck(
1976
+ id="runtime.codex_conditioned_files",
1977
+ tier="runtime",
1978
+ status=status,
1979
+ severity=severity,
1980
+ summary=(
1981
+ "Historical Codex conditioned-file drift has no open protocol debt"
1982
+ if historical_read_only
1983
+ else "Recent Codex sessions respect conditioned-file discipline"
1984
+ if status == "healthy"
1985
+ else "Recent Codex sessions are bypassing conditioned-file discipline"
1986
+ ),
1987
+ evidence=evidence,
1988
+ repair_plan=repair_plan,
1989
+ escalation_prompt=(
1990
+ "Codex sessions are touching conditioned files without the expected protocol/guard sequence. "
1991
+ "Until this is clean, parity with Claude hooks is still incomplete."
1992
+ ) if status != "healthy" else "",
1993
+ )
1994
+
1995
+
1407
1996
  def check_claude_desktop_shared_brain() -> DoctorCheck:
1408
1997
  try:
1409
1998
  schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
@@ -1572,6 +2161,111 @@ def check_client_assumption_regressions() -> DoctorCheck:
1572
2161
 
1573
2162
 
1574
2163
  def check_protocol_compliance() -> DoctorCheck:
2164
+ try:
2165
+ import sqlite3
2166
+
2167
+ db_path = NEXO_HOME / "data" / "nexo.db"
2168
+ if db_path.is_file():
2169
+ conn = sqlite3.connect(str(db_path), timeout=2)
2170
+ conn.row_factory = sqlite3.Row
2171
+ tables = {
2172
+ row["name"]
2173
+ for row in conn.execute(
2174
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('protocol_tasks', 'protocol_debt')"
2175
+ ).fetchall()
2176
+ }
2177
+ if {"protocol_tasks", "protocol_debt"}.issubset(tables):
2178
+ window = "-7 days"
2179
+ tasks = conn.execute(
2180
+ """SELECT * FROM protocol_tasks
2181
+ WHERE opened_at >= datetime('now', ?)
2182
+ ORDER BY opened_at DESC""",
2183
+ (window,),
2184
+ ).fetchall()
2185
+ debt_rows = conn.execute(
2186
+ """SELECT severity, debt_type, COUNT(*) AS total
2187
+ FROM protocol_debt
2188
+ WHERE status = 'open' AND created_at >= datetime('now', ?)
2189
+ GROUP BY severity, debt_type
2190
+ ORDER BY total DESC, debt_type ASC""",
2191
+ (window,),
2192
+ ).fetchall()
2193
+ conn.close()
2194
+
2195
+ if tasks or debt_rows:
2196
+ closed_tasks = [row for row in tasks if row["status"] != "open"]
2197
+ verify_required = [row for row in closed_tasks if row["must_verify"] and row["status"] == "done"]
2198
+ verify_ok = [row for row in verify_required if (row["close_evidence"] or "").strip()]
2199
+ change_required = [row for row in closed_tasks if row["must_change_log"]]
2200
+ change_ok = [row for row in change_required if row["change_log_id"]]
2201
+ learning_required = [row for row in closed_tasks if row["correction_happened"]]
2202
+ learning_ok = [row for row in learning_required if row["learning_id"]]
2203
+ action_tasks = [row for row in tasks if row["task_type"] in ("edit", "execute", "delegate")]
2204
+ cortex_ok = [row for row in action_tasks if row["cortex_mode"] == "act"]
2205
+
2206
+ score_parts = []
2207
+ if verify_required:
2208
+ score_parts.append((len(verify_ok) / len(verify_required)) * 100)
2209
+ if change_required:
2210
+ score_parts.append((len(change_ok) / len(change_required)) * 100)
2211
+ if learning_required:
2212
+ score_parts.append((len(learning_ok) / len(learning_required)) * 100)
2213
+ if action_tasks:
2214
+ score_parts.append((len(cortex_ok) / len(action_tasks)) * 100)
2215
+
2216
+ base_score = (sum(score_parts) / len(score_parts)) if score_parts else (100.0 if tasks else 0.0)
2217
+ warn_debt = sum(row["total"] for row in debt_rows if row["severity"] == "warn")
2218
+ error_debt = sum(row["total"] for row in debt_rows if row["severity"] == "error")
2219
+ overall = max(0.0, round(base_score - min(60, (warn_debt * 5) + (error_debt * 20)), 1))
2220
+
2221
+ evidence = [f"live protocol window: 7d", f"protocol tasks: {len(tasks)} total / {len(closed_tasks)} closed"]
2222
+ evidence.append(f"overall live protocol compliance: {overall:.1f}%")
2223
+ if verify_required:
2224
+ evidence.append(f"verified closures: {len(verify_ok)}/{len(verify_required)}")
2225
+ if change_required:
2226
+ evidence.append(f"change_log coverage: {len(change_ok)}/{len(change_required)}")
2227
+ if learning_required:
2228
+ evidence.append(f"learning-after-correction: {len(learning_ok)}/{len(learning_required)}")
2229
+ if action_tasks:
2230
+ evidence.append(f"action tasks Cortex-cleared: {len(cortex_ok)}/{len(action_tasks)}")
2231
+ for row in debt_rows[:5]:
2232
+ evidence.append(f"open {row['severity']} debt — {row['debt_type']}: {row['total']}")
2233
+
2234
+ repair_plan: list[str] = []
2235
+ if verify_required and len(verify_ok) != len(verify_required):
2236
+ repair_plan.append("Close tasks with nexo_task_close evidence before claiming completion")
2237
+ if change_required and len(change_ok) != len(change_required):
2238
+ repair_plan.append("Use nexo_task_close or nexo_change_log for edit/execute tasks")
2239
+ if learning_required and len(learning_ok) != len(learning_required):
2240
+ repair_plan.append("Capture reusable learnings whenever a correction happened")
2241
+ if error_debt or warn_debt:
2242
+ repair_plan.append("Resolve open protocol debt before treating the runtime as healthy")
2243
+
2244
+ if error_debt > 0 or overall < 45:
2245
+ status = "critical"
2246
+ severity = "error"
2247
+ elif warn_debt > 0 or overall < 70:
2248
+ status = "degraded"
2249
+ severity = "warn"
2250
+ else:
2251
+ status = "healthy"
2252
+ severity = "info"
2253
+
2254
+ return DoctorCheck(
2255
+ id="runtime.protocol_compliance",
2256
+ tier="runtime",
2257
+ status=status,
2258
+ severity=severity,
2259
+ summary="Live protocol compliance looks healthy" if status == "healthy" else "Live protocol compliance needs hardening",
2260
+ evidence=evidence,
2261
+ repair_plan=repair_plan,
2262
+ escalation_prompt=(
2263
+ "Task discipline is drifting in live runtime data. NEXO is still skipping verification, change logging, or correction capture."
2264
+ ) if status != "healthy" else "",
2265
+ )
2266
+ except Exception:
2267
+ pass
2268
+
1575
2269
  summary = _latest_periodic_summary("weekly")
1576
2270
  if not summary:
1577
2271
  return DoctorCheck(
@@ -1665,7 +2359,8 @@ def check_release_artifact_sync() -> DoctorCheck:
1665
2359
  evidence.append("package/changelog release version mismatch")
1666
2360
  repair_plan.append("Bump or align CHANGELOG.md before publishing")
1667
2361
 
1668
- sync_script = NEXO_CODE / "scripts" / "sync_release_artifacts.py"
2362
+ release_root = _release_root()
2363
+ sync_script = release_root / "scripts" / "sync_release_artifacts.py"
1669
2364
  if not sync_script.is_file():
1670
2365
  status = "critical"
1671
2366
  severity = "error"
@@ -1675,7 +2370,7 @@ def check_release_artifact_sync() -> DoctorCheck:
1675
2370
  try:
1676
2371
  result = subprocess.run(
1677
2372
  [sys.executable, str(sync_script), "--check"],
1678
- cwd=str(NEXO_CODE),
2373
+ cwd=str(release_root),
1679
2374
  capture_output=True,
1680
2375
  text=True,
1681
2376
  )
@@ -1708,6 +2403,223 @@ def check_release_artifact_sync() -> DoctorCheck:
1708
2403
  )
1709
2404
 
1710
2405
 
2406
+ def check_state_watchers() -> DoctorCheck:
2407
+ db_path = NEXO_HOME / "data" / "nexo.db"
2408
+ summary_path = NEXO_HOME / "operations" / "state-watchers-status.json"
2409
+ active_watchers = 0
2410
+ if db_path.is_file():
2411
+ try:
2412
+ conn = sqlite3.connect(str(db_path))
2413
+ row = conn.execute(
2414
+ "SELECT COUNT(*) FROM state_watchers WHERE status = 'active'"
2415
+ ).fetchone()
2416
+ conn.close()
2417
+ active_watchers = int(row[0] or 0) if row else 0
2418
+ except Exception:
2419
+ active_watchers = 0
2420
+
2421
+ if active_watchers == 0:
2422
+ return DoctorCheck(
2423
+ id="runtime.state_watchers",
2424
+ tier="runtime",
2425
+ status="healthy",
2426
+ severity="info",
2427
+ summary="No active state watchers configured",
2428
+ evidence=[],
2429
+ repair_plan=[],
2430
+ escalation_prompt="",
2431
+ )
2432
+
2433
+ if not summary_path.is_file():
2434
+ return DoctorCheck(
2435
+ id="runtime.state_watchers",
2436
+ tier="runtime",
2437
+ status="degraded",
2438
+ severity="warn",
2439
+ summary="State watchers configured but no fresh summary exists",
2440
+ evidence=[f"active_watchers={active_watchers}", str(summary_path)],
2441
+ repair_plan=["Run nexo_state_watcher_run or wait for daily self-audit to refresh watcher status"],
2442
+ escalation_prompt="State watchers exist but their health summary is missing, so drift and expiry signals may be going dark.",
2443
+ )
2444
+
2445
+ try:
2446
+ payload = json.loads(summary_path.read_text())
2447
+ except Exception as exc:
2448
+ return DoctorCheck(
2449
+ id="runtime.state_watchers",
2450
+ tier="runtime",
2451
+ status="degraded",
2452
+ severity="warn",
2453
+ summary="State watchers summary is unreadable",
2454
+ evidence=[str(exc)],
2455
+ repair_plan=["Re-run nexo_state_watcher_run to regenerate operations/state-watchers-status.json"],
2456
+ escalation_prompt="State watcher health cannot be trusted until the summary is readable again.",
2457
+ )
2458
+
2459
+ generated_at = payload.get("generated_at")
2460
+ evidence = [f"active_watchers={active_watchers}", f"generated_at={generated_at or 'missing'}"]
2461
+ counts = payload.get("counts") or {}
2462
+ if counts:
2463
+ evidence.append(
2464
+ "counts="
2465
+ + ",".join(f"{key}:{int(value)}" for key, value in sorted(counts.items()))
2466
+ )
2467
+
2468
+ status = "healthy"
2469
+ severity = "info"
2470
+ repair_plan: list[str] = []
2471
+ generated_dt = None
2472
+ if generated_at:
2473
+ try:
2474
+ generated_dt = dt.datetime.fromisoformat(str(generated_at).replace("Z", "+00:00"))
2475
+ except Exception:
2476
+ generated_dt = None
2477
+ if not generated_dt or (dt.datetime.now(dt.timezone.utc) - generated_dt).total_seconds() > 36 * 3600:
2478
+ status = "degraded"
2479
+ severity = "warn"
2480
+ repair_plan.append("Refresh state watchers daily so repo/API/expiry drift stays explicit")
2481
+
2482
+ if int(counts.get("critical") or 0) > 0:
2483
+ status = "critical"
2484
+ severity = "error"
2485
+ repair_plan.append("Resolve the critical state watchers immediately")
2486
+ elif int(counts.get("degraded") or 0) > 0 and status == "healthy":
2487
+ status = "degraded"
2488
+ severity = "warn"
2489
+ repair_plan.append("Resolve degraded state watchers before they become hard failures")
2490
+
2491
+ return DoctorCheck(
2492
+ id="runtime.state_watchers",
2493
+ tier="runtime",
2494
+ status=status,
2495
+ severity=severity,
2496
+ summary="State watchers look healthy" if status == "healthy" else "State watchers need attention",
2497
+ evidence=evidence,
2498
+ repair_plan=repair_plan,
2499
+ escalation_prompt=(
2500
+ "State watchers detected live drift or expiry risk across repo/cron/API/environment surfaces."
2501
+ ) if status != "healthy" else "",
2502
+ )
2503
+
2504
+
2505
+ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2506
+ db_path = NEXO_HOME / "data" / "nexo.db"
2507
+ if not db_path.is_file():
2508
+ return DoctorCheck(
2509
+ id="runtime.automation_telemetry",
2510
+ tier="runtime",
2511
+ status="degraded",
2512
+ severity="warn",
2513
+ summary="Automation telemetry DB is missing",
2514
+ evidence=[str(db_path)],
2515
+ repair_plan=["Run NEXO once so migrations create the shared runtime DB"],
2516
+ escalation_prompt="Cost and parity telemetry cannot be trusted until the runtime DB exists.",
2517
+ )
2518
+
2519
+ try:
2520
+ conn = sqlite3.connect(str(db_path), timeout=2)
2521
+ conn.row_factory = sqlite3.Row
2522
+ table = conn.execute(
2523
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='automation_runs'"
2524
+ ).fetchone()
2525
+ if not table:
2526
+ conn.close()
2527
+ return DoctorCheck(
2528
+ id="runtime.automation_telemetry",
2529
+ tier="runtime",
2530
+ status="degraded",
2531
+ severity="warn",
2532
+ summary="Automation telemetry schema is missing",
2533
+ evidence=["table automation_runs not found"],
2534
+ repair_plan=["Run NEXO migrations before trusting automation cost/parity metrics"],
2535
+ escalation_prompt="Shared automation runs are happening without the telemetry table that release metrics depend on.",
2536
+ )
2537
+
2538
+ row = conn.execute(
2539
+ """
2540
+ SELECT
2541
+ COUNT(*) AS runs,
2542
+ SUM(CASE WHEN (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
2543
+ SUM(CASE WHEN total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
2544
+ SUM(CASE WHEN cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
2545
+ GROUP_CONCAT(DISTINCT backend) AS backends
2546
+ FROM automation_runs
2547
+ WHERE created_at >= datetime('now', ?)
2548
+ """,
2549
+ (f"-{days} days",),
2550
+ ).fetchone()
2551
+ conn.close()
2552
+ except Exception as exc:
2553
+ return DoctorCheck(
2554
+ id="runtime.automation_telemetry",
2555
+ tier="runtime",
2556
+ status="degraded",
2557
+ severity="warn",
2558
+ summary="Automation telemetry is unreadable",
2559
+ evidence=[str(exc)],
2560
+ repair_plan=["Inspect the runtime DB and restore the automation_runs table"],
2561
+ escalation_prompt="Automation cost and parity metrics are unreadable, so release numbers may be lying by omission.",
2562
+ )
2563
+
2564
+ total_runs = int((row["runs"] if row else 0) or 0)
2565
+ if total_runs == 0:
2566
+ return DoctorCheck(
2567
+ id="runtime.automation_telemetry",
2568
+ tier="runtime",
2569
+ status="healthy",
2570
+ severity="info",
2571
+ summary="No recent automation runs to score",
2572
+ evidence=[f"window={days}d", "runs=0"],
2573
+ repair_plan=[],
2574
+ escalation_prompt="",
2575
+ )
2576
+
2577
+ usage_runs = int((row["usage_runs"] if row else 0) or 0)
2578
+ cost_runs = int((row["cost_runs"] if row else 0) or 0)
2579
+ pricing_gaps = int((row["pricing_gaps"] if row else 0) or 0)
2580
+ usage_coverage = round((usage_runs / total_runs) * 100, 1)
2581
+ cost_coverage = round((cost_runs / total_runs) * 100, 1)
2582
+ evidence = [
2583
+ f"window={days}d",
2584
+ f"runs={total_runs}",
2585
+ f"usage_coverage={usage_coverage}%",
2586
+ f"cost_coverage={cost_coverage}%",
2587
+ f"pricing_gaps={pricing_gaps}",
2588
+ ]
2589
+ backends = str((row["backends"] if row else "") or "").strip()
2590
+ if backends:
2591
+ evidence.append(f"backends={backends}")
2592
+
2593
+ status = "healthy"
2594
+ severity = "info"
2595
+ repair_plan: list[str] = []
2596
+ if usage_coverage < 100.0:
2597
+ status = "degraded"
2598
+ severity = "warn"
2599
+ repair_plan.append("Restore backend usage parsing so automation runs always emit token telemetry")
2600
+ if cost_coverage < 90.0:
2601
+ status = "critical" if total_runs >= 3 else "degraded"
2602
+ severity = "error" if status == "critical" else "warn"
2603
+ repair_plan.append("Restore explicit backend cost or pricing coverage before trusting cost-per-task metrics")
2604
+ if pricing_gaps:
2605
+ status = "critical" if status != "critical" and total_runs >= 3 else status
2606
+ severity = "error" if status == "critical" else severity
2607
+ repair_plan.append("Add pricing coverage for new automation models or switch to backend-reported cost")
2608
+
2609
+ return DoctorCheck(
2610
+ id="runtime.automation_telemetry",
2611
+ tier="runtime",
2612
+ status=status,
2613
+ severity=severity,
2614
+ summary="Automation telemetry looks healthy" if status == "healthy" else "Automation telemetry needs attention",
2615
+ evidence=evidence,
2616
+ repair_plan=repair_plan,
2617
+ escalation_prompt=(
2618
+ "Shared automation is running without enough telemetry coverage to defend parity/cost claims."
2619
+ ) if status != "healthy" else "",
2620
+ )
2621
+
2622
+
1711
2623
  def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
1712
2624
  """Run all runtime-tier checks. Read-only by default."""
1713
2625
  return [
@@ -1718,10 +2630,13 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
1718
2630
  check_client_backend_preferences(),
1719
2631
  check_client_bootstrap_parity(fix=fix),
1720
2632
  check_codex_session_parity(),
2633
+ check_codex_conditioned_file_discipline(),
1721
2634
  check_claude_desktop_shared_brain(),
1722
2635
  check_transcript_source_parity(),
1723
2636
  check_client_assumption_regressions(),
1724
2637
  check_protocol_compliance(),
2638
+ check_automation_telemetry(),
2639
+ check_state_watchers(),
1725
2640
  check_release_artifact_sync(),
1726
2641
  check_launchagent_integrity(fix=fix),
1727
2642
  check_personal_script_registry(fix=fix),