nexo-brain 2.7.0 → 3.0.0

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