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