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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +72 -20
- package/hooks/hooks.json +79 -0
- package/package.json +1 -1
- package/src/agent_runner.py +296 -8
- package/src/cli.py +209 -4
- package/src/client_preferences.py +115 -0
- package/src/client_sync.py +202 -2
- package/src/cognitive/__init__.py +1 -1
- package/src/cognitive/_search.py +39 -19
- package/src/dashboard/app.py +264 -0
- package/src/dashboard/templates/base.html +4 -0
- package/src/dashboard/templates/dashboard.html +59 -1
- package/src/dashboard/templates/protocol.html +199 -0
- package/src/db/__init__.py +23 -1
- package/src/db/_learnings.py +31 -4
- package/src/db/_personal_scripts.py +12 -0
- package/src/db/_protocol.py +303 -0
- package/src/db/_schema.py +248 -0
- package/src/db/_watchers.py +173 -0
- package/src/db/_workflow.py +952 -0
- package/src/doctor/providers/runtime.py +1095 -3
- package/src/evolution_cycle.py +62 -0
- package/src/hook_guardrails.py +308 -0
- package/src/hooks/protocol-guardrail.sh +10 -0
- package/src/nexo_sdk.py +103 -0
- package/src/plugins/cognitive_memory.py +18 -0
- package/src/plugins/cortex.py +55 -35
- package/src/plugins/guard.py +132 -16
- package/src/plugins/protocol.py +911 -0
- package/src/plugins/schedule.py +40 -6
- package/src/plugins/simple_api.py +103 -0
- package/src/plugins/skills.py +67 -0
- package/src/plugins/state_watchers.py +79 -0
- package/src/plugins/workflow.py +588 -0
- package/src/public_contribution.py +86 -12
- package/src/script_registry.py +142 -0
- package/src/scripts/deep-sleep/apply_findings.py +482 -2
- package/src/scripts/deep-sleep/collect.py +49 -4
- package/src/scripts/nexo-agent-run.py +2 -0
- package/src/scripts/nexo-daily-self-audit.py +843 -5
- package/src/scripts/nexo-evolution-run.py +343 -1
- package/src/server.py +92 -6
- package/src/skills_runtime.py +151 -0
- package/src/state_watchers_runtime.py +334 -0
- package/src/tools_learnings.py +345 -7
- package/src/tools_sessions.py +183 -0
- package/templates/CLAUDE.md.template +9 -1
- 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(
|
|
733
|
+
return dt.datetime.strptime(text, fmt)
|
|
245
734
|
except ValueError:
|
|
246
735
|
continue
|
|
247
736
|
try:
|
|
248
|
-
|
|
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),
|