nexo-brain 2.6.21 → 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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -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 +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  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/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -6,6 +6,9 @@ import json
6
6
  import os
7
7
  import platform
8
8
  import plistlib
9
+ import re
10
+ import shlex
11
+ import sqlite3
9
12
  import subprocess
10
13
  import sys
11
14
  import time
@@ -38,6 +41,53 @@ SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
38
41
  SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
39
42
  OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
40
43
  SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
44
+ PACKAGE_JSON = NEXO_CODE / "package.json"
45
+ CHANGELOG_FILE = NEXO_CODE / "CHANGELOG.md"
46
+
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"
41
91
 
42
92
 
43
93
  def _codex_bootstrap_config_status() -> dict:
@@ -184,12 +234,398 @@ def _recent_codex_session_parity_status(*, days: int = 7, max_files: int = 24) -
184
234
  return status
185
235
 
186
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
+
187
620
  def _client_assumption_regressions() -> list[str]:
188
- src_root = NEXO_CODE / "src"
621
+ src_root = NEXO_CODE if (NEXO_CODE / "server.py").is_file() else (NEXO_CODE / "src")
189
622
  if not src_root.is_dir():
190
623
  return []
624
+ backup_root = (NEXO_HOME / "backups").resolve()
625
+ contrib_root = (NEXO_HOME / "contrib").resolve()
191
626
  allowed_claude_projects = {
192
627
  (src_root / "scripts" / "deep-sleep" / "collect.py").resolve(),
628
+ Path(__file__).resolve(),
193
629
  }
194
630
  offenders: list[str] = []
195
631
  for path in src_root.rglob("*.py"):
@@ -198,6 +634,16 @@ def _client_assumption_regressions() -> list[str]:
198
634
  except Exception:
199
635
  continue
200
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
201
647
  if ".claude/projects" in text and resolved not in allowed_claude_projects:
202
648
  offenders.append(f"{path.relative_to(NEXO_CODE)} hardcodes ~/.claude/projects")
203
649
  collect_path = src_root / "scripts" / "deep-sleep" / "collect.py"
@@ -224,6 +670,44 @@ def _load_json(path: Path) -> dict:
224
670
  return json.loads(path.read_text())
225
671
 
226
672
 
673
+ def _latest_periodic_summary(kind: str) -> dict | None:
674
+ pattern = f"*-{kind}-summary.json"
675
+ candidates: list[tuple[str, Path]] = []
676
+ for path in (NEXO_HOME / "operations" / "deep-sleep").glob(pattern):
677
+ try:
678
+ payload = json.loads(path.read_text())
679
+ except Exception:
680
+ continue
681
+ label = str(payload.get("label", "") or "")
682
+ if label:
683
+ candidates.append((label, path))
684
+ if not candidates:
685
+ return None
686
+ _, path = sorted(candidates, key=lambda item: item[0])[-1]
687
+ try:
688
+ payload = json.loads(path.read_text())
689
+ except Exception:
690
+ return None
691
+ return payload if isinstance(payload, dict) else None
692
+
693
+
694
+ def _package_version() -> str:
695
+ try:
696
+ payload = json.loads(_package_json_path().read_text())
697
+ except Exception:
698
+ return ""
699
+ return str(payload.get("version", "") or "").strip()
700
+
701
+
702
+ def _top_changelog_version() -> str:
703
+ try:
704
+ text = _changelog_path().read_text(encoding="utf-8")
705
+ except Exception:
706
+ return ""
707
+ match = re.search(r"^## \[([^\]]+)\]", text, flags=re.MULTILINE)
708
+ return match.group(1).strip() if match else ""
709
+
710
+
227
711
  def _count_checks(checks) -> int:
228
712
  if isinstance(checks, list):
229
713
  return len(checks)
@@ -239,15 +723,23 @@ def _count_checks(checks) -> int:
239
723
 
240
724
 
241
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"
242
731
  for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
243
732
  try:
244
- return dt.datetime.strptime(value, fmt)
733
+ return dt.datetime.strptime(text, fmt)
245
734
  except ValueError:
246
735
  continue
247
736
  try:
248
- return dt.datetime.fromisoformat(value)
737
+ parsed = dt.datetime.fromisoformat(text)
249
738
  except ValueError:
250
739
  return None
740
+ if parsed.tzinfo is None:
741
+ parsed = parsed.replace(tzinfo=dt.UTC)
742
+ return parsed
251
743
 
252
744
 
253
745
  def _enabled_optionals() -> dict[str, bool]:
@@ -1031,6 +1523,11 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
1031
1523
  f"({report.get('scripts', 0)} scripts, {report.get('schedules', 0)} schedules"
1032
1524
  f", {audit.get('healthy', report.get('schedules', 0))} managed)"
1033
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
+ )
1034
1531
  if fix:
1035
1532
  summary += " (fixed)"
1036
1533
  return DoctorCheck(
@@ -1362,6 +1859,136 @@ def check_codex_session_parity() -> DoctorCheck:
1362
1859
  )
1363
1860
 
1364
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
+
1365
1992
  def check_claude_desktop_shared_brain() -> DoctorCheck:
1366
1993
  try:
1367
1994
  schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
@@ -1529,6 +2156,466 @@ def check_client_assumption_regressions() -> DoctorCheck:
1529
2156
  )
1530
2157
 
1531
2158
 
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
+
2265
+ summary = _latest_periodic_summary("weekly")
2266
+ if not summary:
2267
+ return DoctorCheck(
2268
+ id="runtime.protocol_compliance",
2269
+ tier="runtime",
2270
+ status="degraded",
2271
+ severity="warn",
2272
+ summary="No weekly Deep Sleep protocol summary found",
2273
+ repair_plan=[
2274
+ "Run the Deep Sleep pipeline so weekly summaries include protocol compliance again",
2275
+ ],
2276
+ escalation_prompt=(
2277
+ "NEXO cannot verify heartbeat / guard_check / change_log compliance because the latest weekly Deep Sleep summary is missing."
2278
+ ),
2279
+ )
2280
+
2281
+ protocol = summary.get("protocol_summary") or {}
2282
+ overall = protocol.get("overall_compliance_pct")
2283
+ guard = protocol.get("guard_check") or {}
2284
+ heartbeat = protocol.get("heartbeat") or {}
2285
+ change_log = protocol.get("change_log") or {}
2286
+ evidence = [f"weekly summary: {summary.get('label', 'unknown')}"]
2287
+ if overall is not None:
2288
+ evidence.append(f"overall protocol compliance: {overall:.1f}%")
2289
+ if guard.get("compliance_pct") is not None:
2290
+ evidence.append(
2291
+ f"guard_check: {guard.get('executed', 0)}/{guard.get('required', 0)} ({guard['compliance_pct']:.1f}%)"
2292
+ )
2293
+ if heartbeat.get("compliance_pct") is not None:
2294
+ evidence.append(
2295
+ f"heartbeat with context: {heartbeat.get('with_context', 0)}/{heartbeat.get('total', 0)} ({heartbeat['compliance_pct']:.1f}%)"
2296
+ )
2297
+ if change_log.get("compliance_pct") is not None:
2298
+ evidence.append(
2299
+ f"change_log after edits: {change_log.get('logged', 0)}/{change_log.get('edits', 0)} ({change_log['compliance_pct']:.1f}%)"
2300
+ )
2301
+
2302
+ status = "healthy"
2303
+ severity = "info"
2304
+ repair_plan: list[str] = []
2305
+ if overall is None:
2306
+ status = "degraded"
2307
+ severity = "warn"
2308
+ repair_plan.append("Ensure Deep Sleep extractions keep writing protocol_summary data")
2309
+ elif overall < 45:
2310
+ status = "critical"
2311
+ severity = "error"
2312
+ elif overall < 70:
2313
+ status = "degraded"
2314
+ severity = "warn"
2315
+
2316
+ if status != "healthy":
2317
+ repair_plan.extend(
2318
+ [
2319
+ "Reinforce heartbeat discipline on every user message",
2320
+ "Call nexo_guard_check before production/shared edits",
2321
+ "Record production changes with nexo_change_log after editing",
2322
+ ]
2323
+ )
2324
+
2325
+ return DoctorCheck(
2326
+ id="runtime.protocol_compliance",
2327
+ tier="runtime",
2328
+ status=status,
2329
+ severity=severity,
2330
+ summary="Protocol compliance looks healthy" if status == "healthy" else "Protocol compliance needs hardening",
2331
+ evidence=evidence,
2332
+ repair_plan=repair_plan,
2333
+ escalation_prompt=(
2334
+ "Heartbeat / guard_check / change_log discipline is drifting. NEXO is at risk of repeating known errors and hiding change history."
2335
+ ) if status != "healthy" else "",
2336
+ )
2337
+
2338
+
2339
+ def check_release_artifact_sync() -> DoctorCheck:
2340
+ version = _package_version()
2341
+ changelog_version = _top_changelog_version()
2342
+ evidence = []
2343
+ status = "healthy"
2344
+ severity = "info"
2345
+ repair_plan: list[str] = []
2346
+
2347
+ if version:
2348
+ evidence.append(f"package version: {version}")
2349
+ if changelog_version:
2350
+ evidence.append(f"top changelog version: {changelog_version}")
2351
+
2352
+ if version and changelog_version and version != changelog_version:
2353
+ status = "critical"
2354
+ severity = "error"
2355
+ evidence.append("package/changelog release version mismatch")
2356
+ repair_plan.append("Bump or align CHANGELOG.md before publishing")
2357
+
2358
+ release_root = _release_root()
2359
+ sync_script = release_root / "scripts" / "sync_release_artifacts.py"
2360
+ if not sync_script.is_file():
2361
+ status = "critical"
2362
+ severity = "error"
2363
+ evidence.append(f"missing release artifact sync script at {sync_script}")
2364
+ repair_plan.append("Restore scripts/sync_release_artifacts.py")
2365
+ else:
2366
+ try:
2367
+ result = subprocess.run(
2368
+ [sys.executable, str(sync_script), "--check"],
2369
+ cwd=str(release_root),
2370
+ capture_output=True,
2371
+ text=True,
2372
+ )
2373
+ except Exception as exc:
2374
+ status = "degraded" if status == "healthy" else status
2375
+ severity = "warn" if severity == "info" else severity
2376
+ evidence.append(f"artifact sync check failed to run: {exc}")
2377
+ repair_plan.append("Run scripts/sync_release_artifacts.py manually and inspect the local environment")
2378
+ else:
2379
+ if result.returncode != 0:
2380
+ status = "degraded" if status == "healthy" else status
2381
+ severity = "warn" if severity == "info" else severity
2382
+ detail = result.stderr.strip() or result.stdout.strip() or "artifact sync check failed"
2383
+ evidence.append(detail.splitlines()[0])
2384
+ repair_plan.append("Run scripts/sync_release_artifacts.py before publishing")
2385
+ else:
2386
+ evidence.append("release artifacts in sync")
2387
+
2388
+ return DoctorCheck(
2389
+ id="runtime.release_artifacts",
2390
+ tier="runtime",
2391
+ status=status,
2392
+ severity=severity,
2393
+ summary="Release artifact discipline OK" if status == "healthy" else "Release artifact discipline needs attention",
2394
+ evidence=evidence,
2395
+ repair_plan=repair_plan,
2396
+ escalation_prompt=(
2397
+ "Release-facing artifacts drifted away from the source version contract. Publishing now risks another hotfix release."
2398
+ ) if status != "healthy" else "",
2399
+ )
2400
+
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
+
1532
2619
  def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
1533
2620
  """Run all runtime-tier checks. Read-only by default."""
1534
2621
  return [
@@ -1539,9 +2626,14 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
1539
2626
  check_client_backend_preferences(),
1540
2627
  check_client_bootstrap_parity(fix=fix),
1541
2628
  check_codex_session_parity(),
2629
+ check_codex_conditioned_file_discipline(),
1542
2630
  check_claude_desktop_shared_brain(),
1543
2631
  check_transcript_source_parity(),
1544
2632
  check_client_assumption_regressions(),
2633
+ check_protocol_compliance(),
2634
+ check_automation_telemetry(),
2635
+ check_state_watchers(),
2636
+ check_release_artifact_sync(),
1545
2637
  check_launchagent_integrity(fix=fix),
1546
2638
  check_personal_script_registry(fix=fix),
1547
2639
  check_skill_health(fix=fix),