nexo-brain 5.3.19 → 5.3.21
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/bin/nexo-brain.js +52 -10
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,2161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
NEXO Daily Self-Audit v2
|
|
4
|
+
|
|
5
|
+
Stage A — Mechanical checks (Python pure, unchanged):
|
|
6
|
+
18 checks: overdue reminders, disk space, DB size, stale sessions, guard stats,
|
|
7
|
+
cognitive health, snapshot drift, etc. All pure queries, no intelligence needed.
|
|
8
|
+
|
|
9
|
+
Stage B — Interpretation (automation backend):
|
|
10
|
+
Takes the raw findings from Stage A and UNDERSTANDS them:
|
|
11
|
+
- Groups related findings
|
|
12
|
+
- Identifies root causes
|
|
13
|
+
- Prioritizes what actually matters
|
|
14
|
+
- Suggests specific actions
|
|
15
|
+
- Writes actionable summary
|
|
16
|
+
|
|
17
|
+
Runs via launchd at 7:00 AM daily.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import py_compile
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import sqlite3
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timedelta
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
32
|
+
# Auto-detect: if running from repo (src/scripts/), use src/ as NEXO_CODE
|
|
33
|
+
_script_dir = Path(__file__).resolve().parent
|
|
34
|
+
_repo_src = _script_dir.parent # src/scripts/ -> src/
|
|
35
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
|
|
36
|
+
if str(NEXO_CODE) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(NEXO_CODE))
|
|
38
|
+
|
|
39
|
+
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
40
|
+
import db as nexo_db
|
|
41
|
+
from public_evolution_queue import queue_public_port_candidate
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
45
|
+
_USER_MODEL = _resolve_user_model()
|
|
46
|
+
except Exception:
|
|
47
|
+
_USER_MODEL = ""
|
|
48
|
+
|
|
49
|
+
LOG_DIR = NEXO_HOME / "logs"
|
|
50
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
AUDIT_HISTORY_DIR = LOG_DIR / "self-audit"
|
|
52
|
+
AUDIT_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
LOG_FILE = LOG_DIR / "self-audit.log"
|
|
54
|
+
NEXO_DB = NEXO_HOME / "data" / "nexo.db"
|
|
55
|
+
# Configure your main project repo to check for uncommitted changes (optional)
|
|
56
|
+
PROJECT_REPO_DIR = None # e.g., Path.home() / "projects" / "my-repo"
|
|
57
|
+
HASH_REGISTRY = NEXO_HOME / "scripts" / ".watchdog-hashes"
|
|
58
|
+
SNAPSHOT_GOLDEN = NEXO_HOME / "snapshots" / "golden" / "files" / "claude"
|
|
59
|
+
RUNTIME_PREFLIGHT_SUMMARY = LOG_DIR / "runtime-preflight-summary.json"
|
|
60
|
+
WATCHDOG_SMOKE_SUMMARY = LOG_DIR / "watchdog-smoke-summary.json"
|
|
61
|
+
RESTORE_LOG = LOG_DIR / "snapshot-restores.log"
|
|
62
|
+
CORTEX_LOG_DIR = NEXO_HOME / "brain" / "logs"
|
|
63
|
+
def _resolve_claude_cli() -> Path:
|
|
64
|
+
"""Find claude CLI: saved path > PATH > common locations."""
|
|
65
|
+
import shutil as _shutil
|
|
66
|
+
saved = NEXO_HOME / "config" / "claude-cli-path"
|
|
67
|
+
if saved.exists():
|
|
68
|
+
p = Path(saved.read_text().strip())
|
|
69
|
+
if p.exists():
|
|
70
|
+
return p
|
|
71
|
+
found = _shutil.which("claude")
|
|
72
|
+
if found:
|
|
73
|
+
return Path(found)
|
|
74
|
+
for candidate in [
|
|
75
|
+
Path.home() / ".local" / "bin" / "claude",
|
|
76
|
+
Path.home() / ".npm-global" / "bin" / "claude",
|
|
77
|
+
Path("/usr/local/bin/claude"),
|
|
78
|
+
]:
|
|
79
|
+
if candidate.exists():
|
|
80
|
+
return candidate
|
|
81
|
+
return Path.home() / ".local" / "bin" / "claude"
|
|
82
|
+
|
|
83
|
+
CLAUDE_CLI = _resolve_claude_cli()
|
|
84
|
+
|
|
85
|
+
findings = []
|
|
86
|
+
|
|
87
|
+
AUDIT_GOAL_NEXT_ACTION = "Convert the recurring theme into an explicit workflow or close it as intentional noise."
|
|
88
|
+
AUDIT_GOAL_OWNER = "system:self-audit"
|
|
89
|
+
AUDIT_GOAL_STALE_HOURS = 36
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def log(msg):
|
|
93
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
94
|
+
line = f"[{ts}] {msg}"
|
|
95
|
+
print(line, flush=True)
|
|
96
|
+
with open(LOG_FILE, "a") as f:
|
|
97
|
+
f.write(line + "\n")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def finding(severity, area, msg):
|
|
101
|
+
findings.append({"severity": severity, "area": area, "msg": msg})
|
|
102
|
+
log(f" [{severity}] {area}: {msg}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_iso_dt(value: str | None) -> datetime | None:
|
|
106
|
+
text = str(value or "").strip()
|
|
107
|
+
if not text:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _area_summary_from_daily_summaries(summaries: list[dict]) -> tuple[list[dict], list[str]]:
|
|
116
|
+
per_area: dict[str, dict] = {}
|
|
117
|
+
area_days: dict[str, set[str]] = {}
|
|
118
|
+
for item in summaries:
|
|
119
|
+
day = str(item.get("date_label") or item.get("timestamp") or "")[:10]
|
|
120
|
+
for finding_item in item.get("findings", []):
|
|
121
|
+
area = str(finding_item.get("area") or "unknown").strip() or "unknown"
|
|
122
|
+
severity = str(finding_item.get("severity") or "INFO").strip().upper()
|
|
123
|
+
bucket = per_area.setdefault(area, {"area": area, "count": 0, "error": 0, "warn": 0, "info": 0})
|
|
124
|
+
bucket["count"] += 1
|
|
125
|
+
if severity == "ERROR":
|
|
126
|
+
bucket["error"] += 1
|
|
127
|
+
elif severity == "WARN":
|
|
128
|
+
bucket["warn"] += 1
|
|
129
|
+
else:
|
|
130
|
+
bucket["info"] += 1
|
|
131
|
+
if day:
|
|
132
|
+
area_days.setdefault(area, set()).add(day)
|
|
133
|
+
top_areas = sorted(
|
|
134
|
+
per_area.values(),
|
|
135
|
+
key=lambda item: (-item["count"], -item["error"], item["area"]),
|
|
136
|
+
)[:10]
|
|
137
|
+
repeated = sorted(area for area, days in area_days.items() if len(days) >= 2)
|
|
138
|
+
return top_areas, repeated
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_recent_daily_summaries(reference_dt: datetime, window_days: int) -> list[dict]:
|
|
142
|
+
summaries: list[dict] = []
|
|
143
|
+
cutoff = reference_dt - timedelta(days=window_days - 1)
|
|
144
|
+
for path in sorted(AUDIT_HISTORY_DIR.glob("*-daily-summary.json")):
|
|
145
|
+
try:
|
|
146
|
+
payload = json.loads(path.read_text())
|
|
147
|
+
except Exception:
|
|
148
|
+
continue
|
|
149
|
+
ts = _parse_iso_dt(payload.get("timestamp"))
|
|
150
|
+
if not ts:
|
|
151
|
+
continue
|
|
152
|
+
if ts.date() < cutoff.date() or ts.date() > reference_dt.date():
|
|
153
|
+
continue
|
|
154
|
+
summaries.append(payload)
|
|
155
|
+
summaries.sort(key=lambda item: str(item.get("timestamp") or ""))
|
|
156
|
+
return summaries
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def write_horizon_summaries(summary_payload: dict, *, now: datetime | None = None) -> dict:
|
|
160
|
+
now = now or datetime.now()
|
|
161
|
+
daily_payload = dict(summary_payload)
|
|
162
|
+
daily_payload.setdefault("date_label", now.strftime("%Y-%m-%d"))
|
|
163
|
+
daily_file = AUDIT_HISTORY_DIR / f"{daily_payload['date_label']}-daily-summary.json"
|
|
164
|
+
daily_file.write_text(json.dumps(daily_payload, indent=2))
|
|
165
|
+
|
|
166
|
+
outputs = {
|
|
167
|
+
"daily_file": str(daily_file),
|
|
168
|
+
"weekly_file": "",
|
|
169
|
+
"weekly_latest": "",
|
|
170
|
+
"monthly_file": "",
|
|
171
|
+
"monthly_latest": "",
|
|
172
|
+
}
|
|
173
|
+
for kind, window_days in (("weekly", 7), ("monthly", 30)):
|
|
174
|
+
recent = _load_recent_daily_summaries(now, window_days)
|
|
175
|
+
total_counts = {"error": 0, "warn": 0, "info": 0}
|
|
176
|
+
for item in recent:
|
|
177
|
+
counts = item.get("counts") or {}
|
|
178
|
+
for key in total_counts:
|
|
179
|
+
total_counts[key] += int(counts.get(key) or 0)
|
|
180
|
+
top_areas, repeated_areas = _area_summary_from_daily_summaries(recent)
|
|
181
|
+
if kind == "weekly":
|
|
182
|
+
year, week, _ = now.isocalendar()
|
|
183
|
+
label = f"{year}-W{week:02d}"
|
|
184
|
+
else:
|
|
185
|
+
label = now.strftime("%Y-%m")
|
|
186
|
+
rollup = {
|
|
187
|
+
"timestamp": now.isoformat(),
|
|
188
|
+
"label": label,
|
|
189
|
+
"horizon": kind,
|
|
190
|
+
"window_days": window_days,
|
|
191
|
+
"source_daily_summaries": len(recent),
|
|
192
|
+
"days": [item.get("date_label") for item in recent if item.get("date_label")],
|
|
193
|
+
"counts": total_counts,
|
|
194
|
+
"top_areas": top_areas,
|
|
195
|
+
"repeated_areas": repeated_areas,
|
|
196
|
+
}
|
|
197
|
+
dated_file = AUDIT_HISTORY_DIR / f"{label}-{kind}-summary.json"
|
|
198
|
+
latest_file = LOG_DIR / f"self-audit-{kind}-summary.json"
|
|
199
|
+
dated_file.write_text(json.dumps(rollup, indent=2))
|
|
200
|
+
latest_file.write_text(json.dumps(rollup, indent=2))
|
|
201
|
+
outputs[f"{kind}_file"] = str(dated_file)
|
|
202
|
+
outputs[f"{kind}_latest"] = str(latest_file)
|
|
203
|
+
return outputs
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _protocol_debt_table_exists(conn: sqlite3.Connection) -> bool:
|
|
207
|
+
row = conn.execute(
|
|
208
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='protocol_debt'"
|
|
209
|
+
).fetchone()
|
|
210
|
+
return bool(row)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
|
|
214
|
+
row = conn.execute(
|
|
215
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
|
216
|
+
(table_name,),
|
|
217
|
+
).fetchone()
|
|
218
|
+
return bool(row)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ensure_protocol_debt(conn: sqlite3.Connection, *, debt_type: str, severity: str, evidence: str) -> bool:
|
|
222
|
+
existing = conn.execute(
|
|
223
|
+
"""SELECT id
|
|
224
|
+
FROM protocol_debt
|
|
225
|
+
WHERE status = 'open' AND debt_type = ? AND evidence = ?
|
|
226
|
+
LIMIT 1""",
|
|
227
|
+
(debt_type, evidence),
|
|
228
|
+
).fetchone()
|
|
229
|
+
if existing:
|
|
230
|
+
return False
|
|
231
|
+
conn.execute(
|
|
232
|
+
"""INSERT INTO protocol_debt (session_id, task_id, debt_type, severity, evidence)
|
|
233
|
+
VALUES ('', '', ?, ?, ?)""",
|
|
234
|
+
(debt_type, severity, evidence),
|
|
235
|
+
)
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
|
|
240
|
+
verification: str, reasoning: str, priority: str = "high") -> str:
|
|
241
|
+
if not _table_exists(conn, "followups"):
|
|
242
|
+
return ""
|
|
243
|
+
# Content fingerprint, not security-sensitive.
|
|
244
|
+
followup_id = f"NF-{prefix}-{hashlib.sha1(description.encode('utf-8'), usedforsecurity=False).hexdigest()[:8].upper()}"
|
|
245
|
+
existing = conn.execute(
|
|
246
|
+
"""SELECT id FROM followups
|
|
247
|
+
WHERE status NOT LIKE 'COMPLETED%'
|
|
248
|
+
AND status NOT IN ('DELETED','archived','blocked','waiting')
|
|
249
|
+
AND description = ?
|
|
250
|
+
LIMIT 1""",
|
|
251
|
+
(description,),
|
|
252
|
+
).fetchone()
|
|
253
|
+
if existing:
|
|
254
|
+
return str(existing["id"])
|
|
255
|
+
now_epoch = int(datetime.now().timestamp())
|
|
256
|
+
columns = {row["name"] for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
|
|
257
|
+
existing_id_row = conn.execute(
|
|
258
|
+
"SELECT id, status FROM followups WHERE id = ? LIMIT 1",
|
|
259
|
+
(followup_id,),
|
|
260
|
+
).fetchone()
|
|
261
|
+
if existing_id_row:
|
|
262
|
+
update_fields = {
|
|
263
|
+
"description": description,
|
|
264
|
+
"verification": verification,
|
|
265
|
+
"reasoning": reasoning,
|
|
266
|
+
}
|
|
267
|
+
if "priority" in columns:
|
|
268
|
+
update_fields["priority"] = priority
|
|
269
|
+
closed_status = str(existing_id_row["status"] or "").upper()
|
|
270
|
+
if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
|
|
271
|
+
update_fields["status"] = "PENDING"
|
|
272
|
+
conn.commit()
|
|
273
|
+
result = nexo_db.update_followup(
|
|
274
|
+
followup_id,
|
|
275
|
+
history_actor="self-audit",
|
|
276
|
+
history_event="updated",
|
|
277
|
+
history_note="Daily self-audit refreshed canonical followup coverage.",
|
|
278
|
+
**update_fields,
|
|
279
|
+
)
|
|
280
|
+
if result.get("error"):
|
|
281
|
+
return ""
|
|
282
|
+
return followup_id
|
|
283
|
+
|
|
284
|
+
conn.commit()
|
|
285
|
+
result = nexo_db.create_followup(
|
|
286
|
+
id=followup_id,
|
|
287
|
+
description=description,
|
|
288
|
+
date=None,
|
|
289
|
+
verification=verification,
|
|
290
|
+
reasoning=reasoning,
|
|
291
|
+
recurrence=None,
|
|
292
|
+
priority=priority,
|
|
293
|
+
)
|
|
294
|
+
if result.get("error"):
|
|
295
|
+
return ""
|
|
296
|
+
return followup_id
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
|
|
300
|
+
try:
|
|
301
|
+
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
|
|
302
|
+
except Exception:
|
|
303
|
+
return set()
|
|
304
|
+
columns: set[str] = set()
|
|
305
|
+
for row in rows:
|
|
306
|
+
if isinstance(row, sqlite3.Row):
|
|
307
|
+
columns.add(str(row["name"]))
|
|
308
|
+
elif len(row) > 1:
|
|
309
|
+
columns.add(str(row[1]))
|
|
310
|
+
return columns
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _append_note(existing: str, note: str) -> str:
|
|
314
|
+
current = str(existing or "").strip()
|
|
315
|
+
extra = str(note or "").strip()
|
|
316
|
+
if not extra:
|
|
317
|
+
return current
|
|
318
|
+
if not current:
|
|
319
|
+
return extra
|
|
320
|
+
if extra in current:
|
|
321
|
+
return current
|
|
322
|
+
return f"{current}\n{extra}"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _complete_matching_followup(conn: sqlite3.Connection, description: str, note: str) -> int:
|
|
326
|
+
if not _table_exists(conn, "followups"):
|
|
327
|
+
return 0
|
|
328
|
+
rows = conn.execute(
|
|
329
|
+
"""SELECT id, verification, reasoning
|
|
330
|
+
FROM followups
|
|
331
|
+
WHERE description = ?
|
|
332
|
+
AND status NOT LIKE 'COMPLETED%'
|
|
333
|
+
AND status NOT IN ('DELETED','archived','blocked','waiting')""",
|
|
334
|
+
(description,),
|
|
335
|
+
).fetchall()
|
|
336
|
+
completed = 0
|
|
337
|
+
conn.commit()
|
|
338
|
+
for row in rows:
|
|
339
|
+
result = nexo_db.complete_followup(str(row["id"]), note)
|
|
340
|
+
if not result.get("error"):
|
|
341
|
+
completed += 1
|
|
342
|
+
return completed
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _upsert_inline_learning(
|
|
346
|
+
conn: sqlite3.Connection,
|
|
347
|
+
*,
|
|
348
|
+
category: str,
|
|
349
|
+
title: str,
|
|
350
|
+
content: str,
|
|
351
|
+
reasoning: str = "",
|
|
352
|
+
prevention: str = "",
|
|
353
|
+
applies_to: str = "",
|
|
354
|
+
priority: str = "high",
|
|
355
|
+
) -> dict:
|
|
356
|
+
if not _table_exists(conn, "learnings"):
|
|
357
|
+
return {"ok": False, "reason": "learnings_missing"}
|
|
358
|
+
|
|
359
|
+
columns = _table_columns(conn, "learnings")
|
|
360
|
+
rows = conn.execute(
|
|
361
|
+
"SELECT * FROM learnings WHERE COALESCE(status, 'active') != 'superseded' ORDER BY updated_at DESC, id DESC LIMIT 200"
|
|
362
|
+
).fetchall()
|
|
363
|
+
target_signature = _topic_signature(f"{title} {content}")
|
|
364
|
+
existing = None
|
|
365
|
+
for row in rows:
|
|
366
|
+
row_title = str(row["title"] or "").strip() if "title" in columns else ""
|
|
367
|
+
row_content = str(row["content"] or "").strip() if "content" in columns else ""
|
|
368
|
+
row_applies = str(row["applies_to"] or "").strip() if "applies_to" in columns else ""
|
|
369
|
+
row_category = str(row["category"] or "").strip() if "category" in columns else ""
|
|
370
|
+
if applies_to and row_applies and row_applies == applies_to:
|
|
371
|
+
existing = row
|
|
372
|
+
break
|
|
373
|
+
if row_title == title:
|
|
374
|
+
existing = row
|
|
375
|
+
break
|
|
376
|
+
if target_signature and _topic_signature(f"{row_title} {row_content}") == target_signature:
|
|
377
|
+
if not row_category or row_category == category:
|
|
378
|
+
existing = row
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
now_epoch = datetime.now().timestamp()
|
|
382
|
+
weight_map = {"critical": 0.9, "high": 0.7, "medium": 0.5, "low": 0.3}
|
|
383
|
+
if existing:
|
|
384
|
+
updates: dict[str, object] = {}
|
|
385
|
+
if "category" in columns and category:
|
|
386
|
+
updates["category"] = category
|
|
387
|
+
if "title" in columns:
|
|
388
|
+
updates["title"] = title
|
|
389
|
+
if "content" in columns:
|
|
390
|
+
updates["content"] = content
|
|
391
|
+
if "reasoning" in columns and reasoning:
|
|
392
|
+
updates["reasoning"] = _append_note(existing["reasoning"], reasoning)
|
|
393
|
+
if "prevention" in columns and prevention:
|
|
394
|
+
updates["prevention"] = prevention
|
|
395
|
+
if "applies_to" in columns and applies_to:
|
|
396
|
+
updates["applies_to"] = applies_to
|
|
397
|
+
if "priority" in columns and priority:
|
|
398
|
+
updates["priority"] = priority
|
|
399
|
+
if "weight" in columns and priority:
|
|
400
|
+
updates["weight"] = weight_map.get(priority, 0.5)
|
|
401
|
+
if "status" in columns:
|
|
402
|
+
updates["status"] = "active"
|
|
403
|
+
if "updated_at" in columns:
|
|
404
|
+
updates["updated_at"] = now_epoch
|
|
405
|
+
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
406
|
+
conn.execute(
|
|
407
|
+
f"UPDATE learnings SET {assignments} WHERE id = ?",
|
|
408
|
+
[updates[column] for column in updates] + [existing["id"]],
|
|
409
|
+
)
|
|
410
|
+
return {"ok": True, "action": "updated", "learning_id": int(existing["id"])}
|
|
411
|
+
|
|
412
|
+
values: dict[str, object] = {}
|
|
413
|
+
if "category" in columns:
|
|
414
|
+
values["category"] = category or "nexo-ops"
|
|
415
|
+
if "title" in columns:
|
|
416
|
+
values["title"] = title
|
|
417
|
+
if "content" in columns:
|
|
418
|
+
values["content"] = content
|
|
419
|
+
if "reasoning" in columns:
|
|
420
|
+
values["reasoning"] = reasoning
|
|
421
|
+
if "prevention" in columns:
|
|
422
|
+
values["prevention"] = prevention
|
|
423
|
+
if "applies_to" in columns and applies_to:
|
|
424
|
+
values["applies_to"] = applies_to
|
|
425
|
+
if "priority" in columns and priority:
|
|
426
|
+
values["priority"] = priority
|
|
427
|
+
if "weight" in columns and priority:
|
|
428
|
+
values["weight"] = weight_map.get(priority, 0.5)
|
|
429
|
+
if "status" in columns:
|
|
430
|
+
values["status"] = "active"
|
|
431
|
+
if "created_at" in columns:
|
|
432
|
+
values["created_at"] = now_epoch
|
|
433
|
+
if "updated_at" in columns:
|
|
434
|
+
values["updated_at"] = now_epoch
|
|
435
|
+
placeholders = ", ".join("?" for _ in values)
|
|
436
|
+
conn.execute(
|
|
437
|
+
f"INSERT INTO learnings ({', '.join(values)}) VALUES ({placeholders})",
|
|
438
|
+
list(values.values()),
|
|
439
|
+
)
|
|
440
|
+
learning_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
441
|
+
return {"ok": True, "action": "created", "learning_id": int(learning_id)}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _supersede_learning_inline(conn: sqlite3.Connection, *, keep_id: int, retire_id: int, note: str) -> bool:
|
|
445
|
+
if not _table_exists(conn, "learnings"):
|
|
446
|
+
return False
|
|
447
|
+
columns = _table_columns(conn, "learnings")
|
|
448
|
+
now_epoch = datetime.now().timestamp()
|
|
449
|
+
retire_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (retire_id,)).fetchone()
|
|
450
|
+
keep_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (keep_id,)).fetchone()
|
|
451
|
+
if not retire_row or not keep_row:
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
retire_updates: dict[str, object] = {}
|
|
455
|
+
if "status" in columns:
|
|
456
|
+
retire_updates["status"] = "superseded"
|
|
457
|
+
if "reasoning" in columns:
|
|
458
|
+
retire_updates["reasoning"] = _append_note(retire_row["reasoning"], note)
|
|
459
|
+
if "updated_at" in columns:
|
|
460
|
+
retire_updates["updated_at"] = now_epoch
|
|
461
|
+
if retire_updates:
|
|
462
|
+
retire_assignments = ", ".join(f"{column} = ?" for column in retire_updates)
|
|
463
|
+
conn.execute(
|
|
464
|
+
f"UPDATE learnings SET {retire_assignments} WHERE id = ?",
|
|
465
|
+
[retire_updates[column] for column in retire_updates] + [retire_id],
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
keep_updates: dict[str, object] = {}
|
|
469
|
+
if "supersedes_id" in columns:
|
|
470
|
+
keep_updates["supersedes_id"] = retire_id
|
|
471
|
+
if "updated_at" in columns:
|
|
472
|
+
keep_updates["updated_at"] = now_epoch
|
|
473
|
+
if keep_updates:
|
|
474
|
+
keep_assignments = ", ".join(f"{column} = ?" for column in keep_updates)
|
|
475
|
+
conn.execute(
|
|
476
|
+
f"UPDATE learnings SET {keep_assignments} WHERE id = ?",
|
|
477
|
+
[keep_updates[column] for column in keep_updates] + [keep_id],
|
|
478
|
+
)
|
|
479
|
+
return True
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_goal: str, count: int) -> dict:
|
|
483
|
+
if not _table_exists(conn, "workflow_goals"):
|
|
484
|
+
return {"ok": False, "reason": "workflow_goals_missing"}
|
|
485
|
+
|
|
486
|
+
columns = _table_columns(conn, "workflow_goals")
|
|
487
|
+
signature = _topic_signature(sample_goal)
|
|
488
|
+
goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8'), usedforsecurity=False).hexdigest()[:8].upper()}"
|
|
489
|
+
|
|
490
|
+
def _write_goal(existing_row: sqlite3.Row, *, reactivated: bool) -> dict:
|
|
491
|
+
updates: dict[str, object] = {}
|
|
492
|
+
if "title" in columns:
|
|
493
|
+
updates["title"] = sample_goal[:140]
|
|
494
|
+
if "objective" in columns:
|
|
495
|
+
updates["objective"] = objective
|
|
496
|
+
if "priority" in columns:
|
|
497
|
+
updates["priority"] = "high"
|
|
498
|
+
if "owner" in columns:
|
|
499
|
+
updates["owner"] = AUDIT_GOAL_OWNER
|
|
500
|
+
if "next_action" in columns:
|
|
501
|
+
updates["next_action"] = next_action
|
|
502
|
+
if "success_signal" in columns:
|
|
503
|
+
updates["success_signal"] = success_signal
|
|
504
|
+
if "shared_state" in columns:
|
|
505
|
+
updates["shared_state"] = json.dumps({"area": area, "signature": signature, "source": "self-audit"})
|
|
506
|
+
if reactivated and "status" in columns:
|
|
507
|
+
updates["status"] = "active"
|
|
508
|
+
if reactivated and "blocker_reason" in columns:
|
|
509
|
+
updates["blocker_reason"] = ""
|
|
510
|
+
if reactivated and "closed_at" in columns:
|
|
511
|
+
updates["closed_at"] = None
|
|
512
|
+
if "updated_at" in columns:
|
|
513
|
+
updates["updated_at"] = now_iso
|
|
514
|
+
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
515
|
+
conn.execute(
|
|
516
|
+
f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
|
|
517
|
+
[updates[column] for column in updates] + [existing_row["goal_id"]],
|
|
518
|
+
)
|
|
519
|
+
return {
|
|
520
|
+
"ok": True,
|
|
521
|
+
"action": "reactivated" if reactivated else "updated",
|
|
522
|
+
"goal_id": str(existing_row["goal_id"]),
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
rows = conn.execute(
|
|
526
|
+
"""SELECT * FROM workflow_goals
|
|
527
|
+
WHERE status NOT IN ('completed', 'cancelled', 'abandoned')
|
|
528
|
+
ORDER BY updated_at DESC"""
|
|
529
|
+
).fetchall()
|
|
530
|
+
existing = None
|
|
531
|
+
for row in rows:
|
|
532
|
+
title = str(row["title"] or "")
|
|
533
|
+
objective = str(row["objective"] or "")
|
|
534
|
+
if signature and signature == _topic_signature(f"{title} {objective}"):
|
|
535
|
+
existing = row
|
|
536
|
+
break
|
|
537
|
+
|
|
538
|
+
objective = (
|
|
539
|
+
f"Recurring {area} theme detected by daily self-audit. "
|
|
540
|
+
f"The theme '{sample_goal}' appeared {count} times without a durable goal, learning, or resolved workflow."
|
|
541
|
+
)
|
|
542
|
+
next_action = AUDIT_GOAL_NEXT_ACTION
|
|
543
|
+
success_signal = "The theme stops resurfacing in unresolved protocol tasks."
|
|
544
|
+
now_iso = datetime.now().isoformat(timespec="seconds")
|
|
545
|
+
exact = conn.execute(
|
|
546
|
+
"SELECT * FROM workflow_goals WHERE goal_id = ? LIMIT 1",
|
|
547
|
+
(goal_id,),
|
|
548
|
+
).fetchone()
|
|
549
|
+
if exact is not None:
|
|
550
|
+
exact_status = str(exact["status"] or "").lower()
|
|
551
|
+
return _write_goal(
|
|
552
|
+
exact,
|
|
553
|
+
reactivated=exact_status in {"completed", "cancelled", "abandoned"},
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
if existing:
|
|
557
|
+
return _write_goal(existing, reactivated=False)
|
|
558
|
+
|
|
559
|
+
# Content fingerprint, not security-sensitive.
|
|
560
|
+
values: dict[str, object] = {"goal_id": goal_id}
|
|
561
|
+
if "session_id" in columns:
|
|
562
|
+
values["session_id"] = ""
|
|
563
|
+
if "title" in columns:
|
|
564
|
+
values["title"] = sample_goal[:140]
|
|
565
|
+
if "objective" in columns:
|
|
566
|
+
values["objective"] = objective
|
|
567
|
+
if "parent_goal_id" in columns:
|
|
568
|
+
values["parent_goal_id"] = ""
|
|
569
|
+
if "status" in columns:
|
|
570
|
+
values["status"] = "active"
|
|
571
|
+
if "priority" in columns:
|
|
572
|
+
values["priority"] = "high"
|
|
573
|
+
if "owner" in columns:
|
|
574
|
+
values["owner"] = AUDIT_GOAL_OWNER
|
|
575
|
+
if "next_action" in columns:
|
|
576
|
+
values["next_action"] = next_action
|
|
577
|
+
if "success_signal" in columns:
|
|
578
|
+
values["success_signal"] = success_signal
|
|
579
|
+
if "shared_state" in columns:
|
|
580
|
+
values["shared_state"] = json.dumps({"area": area, "signature": signature, "source": "self-audit"})
|
|
581
|
+
if "opened_at" in columns:
|
|
582
|
+
values["opened_at"] = now_iso
|
|
583
|
+
if "updated_at" in columns:
|
|
584
|
+
values["updated_at"] = now_iso
|
|
585
|
+
placeholders = ", ".join("?" for _ in values)
|
|
586
|
+
conn.execute(
|
|
587
|
+
f"INSERT INTO workflow_goals ({', '.join(values)}) VALUES ({placeholders})",
|
|
588
|
+
list(values.values()),
|
|
589
|
+
)
|
|
590
|
+
return {"ok": True, "action": "created", "goal_id": goal_id}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _retire_stale_audit_goals_inline(
|
|
594
|
+
conn: sqlite3.Connection, *, max_age_hours: int = AUDIT_GOAL_STALE_HOURS
|
|
595
|
+
) -> dict:
|
|
596
|
+
if not _table_exists(conn, "workflow_goals"):
|
|
597
|
+
return {"ok": False, "reason": "workflow_goals_missing"}
|
|
598
|
+
|
|
599
|
+
has_runs = _table_exists(conn, "workflow_runs")
|
|
600
|
+
if has_runs:
|
|
601
|
+
rows = conn.execute(
|
|
602
|
+
"""SELECT g.goal_id, g.title, g.status, g.owner, g.next_action, g.opened_at, g.updated_at,
|
|
603
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id), 0) AS run_count,
|
|
604
|
+
COALESCE((SELECT COUNT(*) FROM workflow_runs r WHERE r.goal_id = g.goal_id
|
|
605
|
+
AND r.status NOT IN ('completed', 'failed', 'cancelled')), 0) AS open_run_count
|
|
606
|
+
FROM workflow_goals g
|
|
607
|
+
WHERE g.status = 'active'
|
|
608
|
+
AND g.goal_id LIKE 'WG-AUDIT-%'
|
|
609
|
+
ORDER BY g.updated_at DESC, g.opened_at DESC"""
|
|
610
|
+
).fetchall()
|
|
611
|
+
else:
|
|
612
|
+
rows = conn.execute(
|
|
613
|
+
"""SELECT g.goal_id, g.title, g.status, g.owner, g.next_action, g.opened_at, g.updated_at,
|
|
614
|
+
0 AS run_count,
|
|
615
|
+
0 AS open_run_count
|
|
616
|
+
FROM workflow_goals g
|
|
617
|
+
WHERE g.status = 'active'
|
|
618
|
+
AND g.goal_id LIKE 'WG-AUDIT-%'
|
|
619
|
+
ORDER BY g.updated_at DESC, g.opened_at DESC"""
|
|
620
|
+
).fetchall()
|
|
621
|
+
|
|
622
|
+
if not rows:
|
|
623
|
+
return {"ok": True, "retired": 0}
|
|
624
|
+
|
|
625
|
+
now = datetime.now()
|
|
626
|
+
now_iso = now.isoformat(timespec="seconds")
|
|
627
|
+
retired = 0
|
|
628
|
+
for row in rows:
|
|
629
|
+
if str(row["next_action"] or "").strip() != AUDIT_GOAL_NEXT_ACTION:
|
|
630
|
+
continue
|
|
631
|
+
owner = str(row["owner"] or "").strip()
|
|
632
|
+
if owner and owner != AUDIT_GOAL_OWNER:
|
|
633
|
+
continue
|
|
634
|
+
if int(row["open_run_count"] or 0) > 0:
|
|
635
|
+
continue
|
|
636
|
+
updated_at = _parse_mixed_datetime(row["updated_at"]) or _parse_mixed_datetime(row["opened_at"])
|
|
637
|
+
if not updated_at:
|
|
638
|
+
continue
|
|
639
|
+
age_hours = (now - updated_at).total_seconds() / 3600
|
|
640
|
+
if age_hours < max_age_hours:
|
|
641
|
+
continue
|
|
642
|
+
conn.execute(
|
|
643
|
+
"""UPDATE workflow_goals
|
|
644
|
+
SET status = 'abandoned',
|
|
645
|
+
next_action = ?,
|
|
646
|
+
blocker_reason = ?,
|
|
647
|
+
updated_at = ?,
|
|
648
|
+
closed_at = ?
|
|
649
|
+
WHERE goal_id = ?""",
|
|
650
|
+
(
|
|
651
|
+
"Ninguna. Placeholder stale retirado automáticamente; el self-audit lo recreará si el patrón reaparece.",
|
|
652
|
+
f"Self-audit placeholder stale >{max_age_hours}h sin workflow runs abiertos.",
|
|
653
|
+
now_iso,
|
|
654
|
+
now_iso,
|
|
655
|
+
row["goal_id"],
|
|
656
|
+
),
|
|
657
|
+
)
|
|
658
|
+
retired += 1
|
|
659
|
+
return {"ok": True, "retired": retired}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _queue_public_core_handoff(
|
|
663
|
+
conn: sqlite3.Connection,
|
|
664
|
+
*,
|
|
665
|
+
title: str,
|
|
666
|
+
reasoning: str,
|
|
667
|
+
files_changed: list[str],
|
|
668
|
+
metadata: dict | None = None,
|
|
669
|
+
) -> dict:
|
|
670
|
+
return queue_public_port_candidate(
|
|
671
|
+
conn,
|
|
672
|
+
title=title,
|
|
673
|
+
reasoning=reasoning,
|
|
674
|
+
files_changed=files_changed,
|
|
675
|
+
source="self-audit",
|
|
676
|
+
metadata=metadata or {},
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
TOPIC_STOPWORDS = {
|
|
681
|
+
"the", "and", "for", "with", "from", "that", "this", "into", "about", "after",
|
|
682
|
+
"before", "again", "need", "needs", "task", "tasks", "work", "working",
|
|
683
|
+
"continue", "continuing", "review", "check", "checks", "make", "making",
|
|
684
|
+
"fix", "fixes", "build", "create", "created", "update", "updates", "ship",
|
|
685
|
+
"prepare", "finish", "open", "another", "around", "must",
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _topic_signature(text: str) -> str:
|
|
690
|
+
tokens = [
|
|
691
|
+
token for token in re.findall(r"[a-z0-9]+", (text or "").lower())
|
|
692
|
+
if len(token) >= 3 and token not in TOPIC_STOPWORDS
|
|
693
|
+
]
|
|
694
|
+
return " ".join(tokens[:2])
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
REPAIR_KEYWORDS = {
|
|
698
|
+
"fix", "fixed", "bug", "bugs", "regression", "regressions", "repair", "repaired",
|
|
699
|
+
"correct", "corrected", "correction", "typo", "hotfix", "patch", "patched",
|
|
700
|
+
"resolve", "resolved", "failure", "error", "issue", "broken", "broke",
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _split_changed_files(raw: str) -> list[str]:
|
|
705
|
+
text = str(raw or "").strip()
|
|
706
|
+
if not text:
|
|
707
|
+
return []
|
|
708
|
+
if text.startswith("["):
|
|
709
|
+
try:
|
|
710
|
+
value = json.loads(text)
|
|
711
|
+
except Exception:
|
|
712
|
+
value = []
|
|
713
|
+
if isinstance(value, list):
|
|
714
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
715
|
+
parts = re.split(r"[\n,;]+", text)
|
|
716
|
+
return [part.strip() for part in parts if part.strip()]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _looks_like_repair_change(text: str) -> bool:
|
|
720
|
+
tokens = {token for token in re.findall(r"[a-z0-9]+", (text or "").lower()) if len(token) >= 3}
|
|
721
|
+
return bool(tokens & REPAIR_KEYWORDS)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _parse_mixed_datetime(value) -> datetime | None:
|
|
725
|
+
if value in (None, ""):
|
|
726
|
+
return None
|
|
727
|
+
if isinstance(value, (int, float)):
|
|
728
|
+
try:
|
|
729
|
+
return datetime.fromtimestamp(float(value))
|
|
730
|
+
except Exception:
|
|
731
|
+
return None
|
|
732
|
+
text = str(value).strip()
|
|
733
|
+
if not text:
|
|
734
|
+
return None
|
|
735
|
+
try:
|
|
736
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None)
|
|
737
|
+
except Exception:
|
|
738
|
+
return None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _learning_matches_change(row: sqlite3.Row, files: list[str], change_text: str, created_at: datetime | None) -> bool:
|
|
742
|
+
learning_text = " ".join(
|
|
743
|
+
str(row[key] or "")
|
|
744
|
+
for key in ("title", "content", "reasoning", "prevention")
|
|
745
|
+
if key in row.keys()
|
|
746
|
+
)
|
|
747
|
+
applies_to = str(row["applies_to"] or "").strip() if "applies_to" in row.keys() else ""
|
|
748
|
+
if files and applies_to:
|
|
749
|
+
applies_tokens = {item for item in _split_changed_files(applies_to)}
|
|
750
|
+
if any(file_path in applies_tokens or Path(file_path).name in applies_to for file_path in files):
|
|
751
|
+
return True
|
|
752
|
+
change_signature = _topic_signature(change_text)
|
|
753
|
+
learning_signature = _topic_signature(learning_text)
|
|
754
|
+
if change_signature and learning_signature and change_signature == learning_signature:
|
|
755
|
+
return True
|
|
756
|
+
if change_signature and change_signature in learning_text.lower():
|
|
757
|
+
return True
|
|
758
|
+
|
|
759
|
+
updated_at = _parse_mixed_datetime(row["updated_at"] if "updated_at" in row.keys() else None)
|
|
760
|
+
if created_at and updated_at:
|
|
761
|
+
delta = updated_at - created_at
|
|
762
|
+
if timedelta(hours=-1) <= delta <= timedelta(days=3):
|
|
763
|
+
return True
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
|
|
768
|
+
try:
|
|
769
|
+
from tools_learnings import find_conflicting_active_learning, handle_learning_add, handle_learning_update
|
|
770
|
+
from db._learnings import search_learnings
|
|
771
|
+
except Exception as exc:
|
|
772
|
+
return {"ok": False, "error": f"learning runtime unavailable: {exc}"}
|
|
773
|
+
|
|
774
|
+
files = _split_changed_files(str(row["files"] or ""))
|
|
775
|
+
title_seed = str(row["what_changed"] or row["why"] or "").strip() or f"Repair change #{row['id']}"
|
|
776
|
+
title = title_seed[:120]
|
|
777
|
+
content_parts = [
|
|
778
|
+
str(row["what_changed"] or "").strip(),
|
|
779
|
+
str(row["why"] or "").strip(),
|
|
780
|
+
]
|
|
781
|
+
if files:
|
|
782
|
+
content_parts.append(f"Affected files: {', '.join(files[:5])}")
|
|
783
|
+
content = " ".join(part for part in content_parts if part).strip()
|
|
784
|
+
if not content:
|
|
785
|
+
content = f"Repair-oriented change log entry #{row['id']} required a canonical learning."
|
|
786
|
+
applies_to = ",".join(files)
|
|
787
|
+
|
|
788
|
+
# --- Search-then-supersede: find existing same-topic learnings first ---
|
|
789
|
+
search_query = _topic_signature(f"{title} {content}")
|
|
790
|
+
existing_same_topic = None
|
|
791
|
+
if search_query:
|
|
792
|
+
candidates = search_learnings(search_query, category="nexo-ops")
|
|
793
|
+
for candidate in candidates:
|
|
794
|
+
if candidate.get("status") != "active":
|
|
795
|
+
continue
|
|
796
|
+
# Check if it covers the same files or topic
|
|
797
|
+
candidate_applies = str(candidate.get("applies_to") or "")
|
|
798
|
+
candidate_text = f"{candidate.get('title', '')} {candidate.get('content', '')}"
|
|
799
|
+
candidate_sig = _topic_signature(candidate_text)
|
|
800
|
+
if candidate_sig == search_query:
|
|
801
|
+
existing_same_topic = candidate
|
|
802
|
+
break
|
|
803
|
+
if applies_to and candidate_applies and any(
|
|
804
|
+
f in candidate_applies for f in files
|
|
805
|
+
):
|
|
806
|
+
existing_same_topic = candidate
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
# If a same-topic learning already exists, update it instead of creating a duplicate
|
|
810
|
+
if existing_same_topic:
|
|
811
|
+
existing_id = int(existing_same_topic["id"])
|
|
812
|
+
updated_content = existing_same_topic.get("content", "") + f"\n\n[Audit {datetime.now().strftime('%Y-%m-%d')}] {content}"
|
|
813
|
+
response = handle_learning_update(
|
|
814
|
+
id=existing_id,
|
|
815
|
+
content=updated_content[:2000],
|
|
816
|
+
reasoning=f"Updated by daily self-audit with evidence from repair change #{row['id']}.",
|
|
817
|
+
)
|
|
818
|
+
if "ERROR:" not in response:
|
|
819
|
+
return {
|
|
820
|
+
"ok": True,
|
|
821
|
+
"learning_id": existing_id,
|
|
822
|
+
"response": response,
|
|
823
|
+
"action": "updated_existing",
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
# Fall back to conflict check + new learning only if no same-topic match
|
|
827
|
+
conflicting = find_conflicting_active_learning(
|
|
828
|
+
category="nexo-ops",
|
|
829
|
+
title=title,
|
|
830
|
+
content=content,
|
|
831
|
+
applies_to=applies_to,
|
|
832
|
+
)
|
|
833
|
+
supersedes_id = int(conflicting["id"]) if conflicting else 0
|
|
834
|
+
response = handle_learning_add(
|
|
835
|
+
category="nexo-ops",
|
|
836
|
+
title=title,
|
|
837
|
+
content=content,
|
|
838
|
+
reasoning=f"Auto-captured by daily self-audit from repair change #{row['id']}.",
|
|
839
|
+
prevention="Review the canonical repair learning before touching the affected file again." if applies_to else "",
|
|
840
|
+
applies_to=applies_to,
|
|
841
|
+
priority="high",
|
|
842
|
+
supersedes_id=supersedes_id,
|
|
843
|
+
)
|
|
844
|
+
match = re.search(r"Learning #(\d+)", response)
|
|
845
|
+
if match and "ERROR:" not in response:
|
|
846
|
+
return {
|
|
847
|
+
"ok": True,
|
|
848
|
+
"learning_id": int(match.group(1)),
|
|
849
|
+
"response": response,
|
|
850
|
+
"action": "created_new",
|
|
851
|
+
}
|
|
852
|
+
return {"ok": False, "error": response}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
856
|
+
# Stage A: Mechanical checks (UNCHANGED from v1 — all 18 checks)
|
|
857
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
858
|
+
|
|
859
|
+
def check_overdue_reminders():
|
|
860
|
+
if not NEXO_DB.exists():
|
|
861
|
+
return
|
|
862
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
863
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
864
|
+
rows = conn.execute(
|
|
865
|
+
"SELECT description, date FROM reminders WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
|
|
866
|
+
(today,)
|
|
867
|
+
).fetchall()
|
|
868
|
+
conn.close()
|
|
869
|
+
if rows:
|
|
870
|
+
finding("WARN", "reminders", f"{len(rows)} overdue: {', '.join(r[0][:40] for r in rows[:5])}")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def check_overdue_followups():
|
|
874
|
+
if not NEXO_DB.exists():
|
|
875
|
+
return
|
|
876
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
877
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
878
|
+
rows = conn.execute(
|
|
879
|
+
"SELECT description, date FROM followups WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
|
|
880
|
+
(today,)
|
|
881
|
+
).fetchall()
|
|
882
|
+
conn.close()
|
|
883
|
+
if rows:
|
|
884
|
+
finding("WARN", "followups", f"{len(rows)} overdue: {', '.join(r[0][:40] for r in rows[:5])}")
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def check_uncommitted_changes():
|
|
888
|
+
if not PROJECT_REPO_DIR or not PROJECT_REPO_DIR.exists():
|
|
889
|
+
return
|
|
890
|
+
result = subprocess.run(
|
|
891
|
+
["git", "status", "--porcelain"],
|
|
892
|
+
cwd=str(PROJECT_REPO_DIR), capture_output=True, text=True
|
|
893
|
+
)
|
|
894
|
+
lines = [l for l in result.stdout.strip().split("\n") if l.strip()]
|
|
895
|
+
if len(lines) > 10:
|
|
896
|
+
finding("WARN", "git", f"{len(lines)} uncommitted changes in project repo")
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def check_cron_errors():
|
|
900
|
+
if not NEXO_DB.exists():
|
|
901
|
+
return
|
|
902
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
903
|
+
yesterday = (datetime.now() - timedelta(days=1)).isoformat()
|
|
904
|
+
rows = conn.execute(
|
|
905
|
+
"SELECT category, title FROM learnings WHERE category='cron_error' AND created_at > ? ORDER BY created_at DESC",
|
|
906
|
+
(yesterday,)
|
|
907
|
+
).fetchall()
|
|
908
|
+
conn.close()
|
|
909
|
+
if rows:
|
|
910
|
+
finding("ERROR", "crons", f"{len(rows)} cron errors in last 24h")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def check_evolution_health():
|
|
914
|
+
# Check brain/ (canonical) first, fall back to cortex/ (legacy)
|
|
915
|
+
obj_file = NEXO_HOME / "brain" / "evolution-objective.json"
|
|
916
|
+
if not obj_file.exists():
|
|
917
|
+
obj_file = NEXO_HOME / "cortex" / "evolution-objective.json"
|
|
918
|
+
if not obj_file.exists():
|
|
919
|
+
return
|
|
920
|
+
obj = json.loads(obj_file.read_text())
|
|
921
|
+
failures = obj.get("consecutive_failures", 0)
|
|
922
|
+
if failures >= 2:
|
|
923
|
+
finding("WARN", "evolution", f"{failures} consecutive failures — circuit breaker at 3")
|
|
924
|
+
if not obj.get("evolution_enabled", True):
|
|
925
|
+
finding("ERROR", "evolution", f"Evolution DISABLED: {obj.get('disabled_reason', 'unknown')}")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def check_disk_space():
|
|
929
|
+
result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True)
|
|
930
|
+
for line in result.stdout.strip().split("\n")[1:]:
|
|
931
|
+
parts = line.split()
|
|
932
|
+
if len(parts) >= 5:
|
|
933
|
+
usage_pct = int(parts[4].replace("%", ""))
|
|
934
|
+
if usage_pct > 90:
|
|
935
|
+
finding("ERROR", "disk", f"Root disk at {usage_pct}% capacity")
|
|
936
|
+
elif usage_pct > 80:
|
|
937
|
+
finding("WARN", "disk", f"Root disk at {usage_pct}% capacity")
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def check_db_size():
|
|
941
|
+
if NEXO_DB.exists():
|
|
942
|
+
size_mb = NEXO_DB.stat().st_size / (1024 * 1024)
|
|
943
|
+
if size_mb > 100:
|
|
944
|
+
finding("WARN", "database", f"nexo.db is {size_mb:.1f} MB — consider cleanup")
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def check_stale_sessions():
|
|
948
|
+
if not NEXO_DB.exists():
|
|
949
|
+
return
|
|
950
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
951
|
+
cutoff = (datetime.now() - timedelta(hours=2)).timestamp()
|
|
952
|
+
day_ago = (datetime.now() - timedelta(days=1)).timestamp()
|
|
953
|
+
rows = conn.execute(
|
|
954
|
+
"SELECT sid, task FROM sessions WHERE last_update_epoch < ? AND last_update_epoch > ?",
|
|
955
|
+
(cutoff, day_ago)
|
|
956
|
+
).fetchall()
|
|
957
|
+
conn.close()
|
|
958
|
+
if rows:
|
|
959
|
+
finding("INFO", "sessions", f"{len(rows)} stale sessions (no heartbeat >2h)")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def check_repetition_rate():
|
|
963
|
+
if not NEXO_DB.exists():
|
|
964
|
+
return
|
|
965
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
966
|
+
cutoff_epoch = (datetime.now() - timedelta(days=3)).timestamp()
|
|
967
|
+
cutoff_3d = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")
|
|
968
|
+
new_learnings = conn.execute(
|
|
969
|
+
"SELECT COUNT(*) FROM learnings WHERE created_at > ?", (cutoff_epoch,)
|
|
970
|
+
).fetchone()[0]
|
|
971
|
+
repetitions = conn.execute(
|
|
972
|
+
"SELECT COUNT(*) FROM error_repetitions WHERE created_at > ?", (cutoff_3d,)
|
|
973
|
+
).fetchone()[0]
|
|
974
|
+
conn.close()
|
|
975
|
+
if new_learnings > 0:
|
|
976
|
+
rate = repetitions / new_learnings
|
|
977
|
+
if rate > 0.30:
|
|
978
|
+
finding("ERROR", "guard", f"Repetition rate {rate:.0%} ({repetitions}/{new_learnings})")
|
|
979
|
+
elif rate > 0.20:
|
|
980
|
+
finding("WARN", "guard", f"Repetition rate {rate:.0%} ({repetitions}/{new_learnings})")
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def check_unused_learnings():
|
|
984
|
+
if not NEXO_DB.exists():
|
|
985
|
+
return
|
|
986
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
987
|
+
cutoff_epoch = (datetime.now() - timedelta(days=7)).timestamp()
|
|
988
|
+
old_learnings = conn.execute(
|
|
989
|
+
"SELECT COUNT(*) FROM learnings WHERE created_at < ?", (cutoff_epoch,)
|
|
990
|
+
).fetchone()[0]
|
|
991
|
+
total_checks = conn.execute("SELECT COUNT(*) FROM guard_checks").fetchone()[0]
|
|
992
|
+
conn.close()
|
|
993
|
+
if total_checks == 0 and old_learnings > 10:
|
|
994
|
+
finding("WARN", "guard", f"Guard never used — {old_learnings} learnings idle")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def check_memory_reviews():
|
|
998
|
+
if not NEXO_DB.exists():
|
|
999
|
+
return
|
|
1000
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1001
|
+
now_epoch = datetime.now().timestamp()
|
|
1002
|
+
now_iso = datetime.now().isoformat(timespec="seconds")
|
|
1003
|
+
try:
|
|
1004
|
+
due_learnings = conn.execute(
|
|
1005
|
+
"SELECT COUNT(*) FROM learnings WHERE review_due_at IS NOT NULL AND status != 'superseded' AND review_due_at <= ?",
|
|
1006
|
+
(now_epoch,)
|
|
1007
|
+
).fetchone()[0]
|
|
1008
|
+
due_decisions = conn.execute(
|
|
1009
|
+
"SELECT COUNT(*) FROM decisions WHERE review_due_at IS NOT NULL AND status != 'reviewed' AND review_due_at <= ?",
|
|
1010
|
+
(now_iso,)
|
|
1011
|
+
).fetchone()[0]
|
|
1012
|
+
except sqlite3.OperationalError:
|
|
1013
|
+
conn.close()
|
|
1014
|
+
return
|
|
1015
|
+
conn.close()
|
|
1016
|
+
total = due_learnings + due_decisions
|
|
1017
|
+
if total >= 10:
|
|
1018
|
+
finding("WARN", "memory", f"{total} reviews due ({due_decisions} decisions, {due_learnings} learnings)")
|
|
1019
|
+
elif total > 0:
|
|
1020
|
+
finding("INFO", "memory", f"{total} reviews due")
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def check_learning_contradictions():
|
|
1024
|
+
if not NEXO_DB.exists():
|
|
1025
|
+
return
|
|
1026
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1027
|
+
conn.row_factory = sqlite3.Row
|
|
1028
|
+
if not _table_exists(conn, "learnings"):
|
|
1029
|
+
conn.close()
|
|
1030
|
+
return
|
|
1031
|
+
|
|
1032
|
+
from tools_learnings import _applies_overlap, _looks_contradictory
|
|
1033
|
+
|
|
1034
|
+
rows = conn.execute(
|
|
1035
|
+
"""SELECT id, title, content, applies_to
|
|
1036
|
+
FROM learnings
|
|
1037
|
+
WHERE status = 'active' AND COALESCE(applies_to, '') != ''
|
|
1038
|
+
ORDER BY updated_at DESC, id DESC
|
|
1039
|
+
LIMIT 200"""
|
|
1040
|
+
).fetchall()
|
|
1041
|
+
contradictions: list[tuple[sqlite3.Row, sqlite3.Row]] = []
|
|
1042
|
+
for index, left in enumerate(rows):
|
|
1043
|
+
for right in rows[index + 1:]:
|
|
1044
|
+
if not _applies_overlap(left["applies_to"], right["applies_to"]):
|
|
1045
|
+
continue
|
|
1046
|
+
if not _looks_contradictory(
|
|
1047
|
+
f"{left['title']} {left['content']}",
|
|
1048
|
+
f"{right['title']} {right['content']}",
|
|
1049
|
+
):
|
|
1050
|
+
continue
|
|
1051
|
+
contradictions.append((left, right))
|
|
1052
|
+
|
|
1053
|
+
if contradictions:
|
|
1054
|
+
resolved = 0
|
|
1055
|
+
completed_followups = 0
|
|
1056
|
+
retired_ids: set[int] = set()
|
|
1057
|
+
for left, right in contradictions:
|
|
1058
|
+
keep, retire = left, right
|
|
1059
|
+
if int(retire["id"]) in retired_ids or int(keep["id"]) in retired_ids:
|
|
1060
|
+
continue
|
|
1061
|
+
description = (
|
|
1062
|
+
f"Resolve contradictory active learnings #{left['id']} and #{right['id']} "
|
|
1063
|
+
f"for {left['applies_to'] or right['applies_to']}"
|
|
1064
|
+
)
|
|
1065
|
+
note = (
|
|
1066
|
+
f"Resolved inline by daily self-audit: learning #{retire['id']} was superseded by "
|
|
1067
|
+
f"canonical learning #{keep['id']}."
|
|
1068
|
+
)
|
|
1069
|
+
if _supersede_learning_inline(conn, keep_id=int(keep["id"]), retire_id=int(retire["id"]), note=note):
|
|
1070
|
+
resolved += 1
|
|
1071
|
+
retired_ids.add(int(retire["id"]))
|
|
1072
|
+
applies_to = str(keep["applies_to"] or retire["applies_to"] or "").strip()
|
|
1073
|
+
if applies_to:
|
|
1074
|
+
_queue_public_core_handoff(
|
|
1075
|
+
conn,
|
|
1076
|
+
title=f"Reconcile contradictory rule coverage for {applies_to[:120]}",
|
|
1077
|
+
reasoning=note,
|
|
1078
|
+
files_changed=_split_changed_files(applies_to),
|
|
1079
|
+
metadata={
|
|
1080
|
+
"kept_learning_id": int(keep["id"]),
|
|
1081
|
+
"retired_learning_id": int(retire["id"]),
|
|
1082
|
+
},
|
|
1083
|
+
)
|
|
1084
|
+
completed_followups += _complete_matching_followup(conn, description, note)
|
|
1085
|
+
conn.commit()
|
|
1086
|
+
if resolved:
|
|
1087
|
+
message = f"{resolved} contradictory active learning pair(s) resolved inline"
|
|
1088
|
+
if completed_followups:
|
|
1089
|
+
message += f" | completed {completed_followups} legacy followup(s)"
|
|
1090
|
+
finding("INFO", "contradictions", message)
|
|
1091
|
+
remaining = max(0, len(contradictions) - resolved)
|
|
1092
|
+
if remaining:
|
|
1093
|
+
finding("WARN", "contradictions", f"{remaining} contradictory active learning pair(s) still need manual review")
|
|
1094
|
+
conn.close()
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def check_error_memory_loop():
|
|
1098
|
+
if not NEXO_DB.exists():
|
|
1099
|
+
return
|
|
1100
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1101
|
+
conn.row_factory = sqlite3.Row
|
|
1102
|
+
if not _table_exists(conn, "protocol_tasks"):
|
|
1103
|
+
conn.close()
|
|
1104
|
+
return
|
|
1105
|
+
|
|
1106
|
+
rows = conn.execute(
|
|
1107
|
+
"""SELECT task_id, goal, area, files, status, learning_id
|
|
1108
|
+
FROM protocol_tasks
|
|
1109
|
+
WHERE status IN ('failed', 'blocked')
|
|
1110
|
+
AND (learning_id IS NULL OR learning_id = 0)
|
|
1111
|
+
AND opened_at >= datetime('now', '-30 days')
|
|
1112
|
+
ORDER BY opened_at DESC"""
|
|
1113
|
+
).fetchall()
|
|
1114
|
+
|
|
1115
|
+
grouped: dict[str, list[sqlite3.Row]] = {}
|
|
1116
|
+
for row in rows:
|
|
1117
|
+
files = str(row["files"] or "").strip()
|
|
1118
|
+
signature = files if files and files != "[]" else (row["area"] or row["goal"] or "general")
|
|
1119
|
+
grouped.setdefault(signature[:220], []).append(row)
|
|
1120
|
+
|
|
1121
|
+
repeated = {signature: items for signature, items in grouped.items() if len(items) >= 2}
|
|
1122
|
+
if repeated:
|
|
1123
|
+
resolved = 0
|
|
1124
|
+
completed_followups = 0
|
|
1125
|
+
for signature, items in list(repeated.items())[:5]:
|
|
1126
|
+
description = (
|
|
1127
|
+
f"Mine a canonical prevention learning from repeated failed/blocked protocol tasks around {signature}"
|
|
1128
|
+
)
|
|
1129
|
+
reasoning = (
|
|
1130
|
+
f"Daily self-audit found {len(items)} failed/blocked protocol tasks without a linked learning. "
|
|
1131
|
+
"Turn the repeated failure into a prevention rule before it repeats again."
|
|
1132
|
+
)
|
|
1133
|
+
sample = items[0]
|
|
1134
|
+
area = str(sample["area"] or "nexo-ops").strip() or "nexo-ops"
|
|
1135
|
+
applies_to = signature if "/" in signature else ""
|
|
1136
|
+
title = f"Prevention: repeated failures around {signature[:120]}"
|
|
1137
|
+
clustered_tasks = "; ".join(
|
|
1138
|
+
f"{str(item['task_id'])}: {str(item['goal'] or '').strip()[:80]}"
|
|
1139
|
+
for item in items[:5]
|
|
1140
|
+
)
|
|
1141
|
+
content = (
|
|
1142
|
+
f"Repeated failed/blocked protocol tasks detected around {signature}. "
|
|
1143
|
+
f"Examples: {clustered_tasks}."
|
|
1144
|
+
)
|
|
1145
|
+
prevention = (
|
|
1146
|
+
f"Before working around {signature}, review this cluster and capture the prevention rule in the task contract."
|
|
1147
|
+
)
|
|
1148
|
+
result = _upsert_inline_learning(
|
|
1149
|
+
conn,
|
|
1150
|
+
category=area,
|
|
1151
|
+
title=title,
|
|
1152
|
+
content=content,
|
|
1153
|
+
reasoning=reasoning,
|
|
1154
|
+
prevention=prevention,
|
|
1155
|
+
applies_to=applies_to,
|
|
1156
|
+
priority="high",
|
|
1157
|
+
)
|
|
1158
|
+
if result.get("ok"):
|
|
1159
|
+
resolved += 1
|
|
1160
|
+
if applies_to:
|
|
1161
|
+
_queue_public_core_handoff(
|
|
1162
|
+
conn,
|
|
1163
|
+
title=f"Port prevention guard for {signature[:120]}",
|
|
1164
|
+
reasoning=reasoning,
|
|
1165
|
+
files_changed=_split_changed_files(applies_to),
|
|
1166
|
+
metadata={
|
|
1167
|
+
"learning_id": result.get("learning_id"),
|
|
1168
|
+
"cluster_size": len(items),
|
|
1169
|
+
"signature": signature,
|
|
1170
|
+
},
|
|
1171
|
+
)
|
|
1172
|
+
completed_followups += _complete_matching_followup(
|
|
1173
|
+
conn,
|
|
1174
|
+
description,
|
|
1175
|
+
f"Resolved inline by daily self-audit via learning #{result.get('learning_id')}.",
|
|
1176
|
+
)
|
|
1177
|
+
conn.commit()
|
|
1178
|
+
if resolved:
|
|
1179
|
+
message = f"{resolved} repeated failure cluster(s) converted into canonical prevention learnings inline"
|
|
1180
|
+
if completed_followups:
|
|
1181
|
+
message += f" | completed {completed_followups} legacy followup(s)"
|
|
1182
|
+
finding("INFO", "prevention", message)
|
|
1183
|
+
remaining = max(0, len(repeated) - resolved)
|
|
1184
|
+
if remaining:
|
|
1185
|
+
finding("WARN", "prevention", f"{remaining} repeated failure cluster(s) still lack inline prevention learnings")
|
|
1186
|
+
conn.close()
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def check_repair_changes_missing_learning_capture():
|
|
1190
|
+
if not NEXO_DB.exists():
|
|
1191
|
+
return
|
|
1192
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1193
|
+
conn.row_factory = sqlite3.Row
|
|
1194
|
+
if not _table_exists(conn, "change_log") or not _table_exists(conn, "learnings"):
|
|
1195
|
+
conn.close()
|
|
1196
|
+
return
|
|
1197
|
+
|
|
1198
|
+
learning_rows = conn.execute(
|
|
1199
|
+
"""SELECT *
|
|
1200
|
+
FROM learnings
|
|
1201
|
+
WHERE COALESCE(status, 'active') != 'deleted'
|
|
1202
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
1203
|
+
LIMIT 300"""
|
|
1204
|
+
).fetchall()
|
|
1205
|
+
if not learning_rows:
|
|
1206
|
+
learning_rows = []
|
|
1207
|
+
|
|
1208
|
+
rows = conn.execute(
|
|
1209
|
+
"""SELECT id, files, what_changed, why, created_at
|
|
1210
|
+
FROM change_log
|
|
1211
|
+
WHERE created_at >= datetime('now', '-14 days')
|
|
1212
|
+
ORDER BY created_at DESC
|
|
1213
|
+
LIMIT 200"""
|
|
1214
|
+
).fetchall()
|
|
1215
|
+
missing: list[sqlite3.Row] = []
|
|
1216
|
+
for row in rows:
|
|
1217
|
+
change_text = f"{row['what_changed'] or ''} {row['why'] or ''}".strip()
|
|
1218
|
+
if not _looks_like_repair_change(change_text):
|
|
1219
|
+
continue
|
|
1220
|
+
files = _split_changed_files(str(row["files"] or ""))
|
|
1221
|
+
created_at = _parse_mixed_datetime(row["created_at"])
|
|
1222
|
+
if any(_learning_matches_change(learning, files, change_text, created_at) for learning in learning_rows):
|
|
1223
|
+
continue
|
|
1224
|
+
missing.append(row)
|
|
1225
|
+
|
|
1226
|
+
if missing:
|
|
1227
|
+
auto_captured = 0
|
|
1228
|
+
unresolved: list[sqlite3.Row] = []
|
|
1229
|
+
for row in missing:
|
|
1230
|
+
captured = _attempt_repair_learning_auto_capture(row)
|
|
1231
|
+
if captured.get("ok"):
|
|
1232
|
+
auto_captured += 1
|
|
1233
|
+
continue
|
|
1234
|
+
unresolved.append(row)
|
|
1235
|
+
|
|
1236
|
+
if unresolved:
|
|
1237
|
+
finding(
|
|
1238
|
+
"WARN",
|
|
1239
|
+
"learning-capture",
|
|
1240
|
+
f"{len(unresolved)} repair/logged fix change(s) still lack linked learnings "
|
|
1241
|
+
f"after {auto_captured} self-audit auto-capture(s)",
|
|
1242
|
+
)
|
|
1243
|
+
else:
|
|
1244
|
+
finding(
|
|
1245
|
+
"INFO",
|
|
1246
|
+
"learning-capture",
|
|
1247
|
+
f"Self-audit auto-captured {auto_captured} missing repair learning(s)",
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
for row in unresolved[:5]:
|
|
1251
|
+
files = _split_changed_files(str(row["files"] or ""))
|
|
1252
|
+
target = files[0] if files else str(row["what_changed"] or "recent repair")[:120]
|
|
1253
|
+
evidence = (
|
|
1254
|
+
f"Repair-oriented change log entry #{row['id']} on {target} has no nearby linked learning capture."
|
|
1255
|
+
)
|
|
1256
|
+
_ensure_protocol_debt(
|
|
1257
|
+
conn,
|
|
1258
|
+
debt_type="repair_change_without_learning_capture",
|
|
1259
|
+
severity="warn",
|
|
1260
|
+
evidence=evidence,
|
|
1261
|
+
)
|
|
1262
|
+
_ensure_followup(
|
|
1263
|
+
conn,
|
|
1264
|
+
prefix="LEARNCAP",
|
|
1265
|
+
description=f"Capture canonical learning for repair change touching {target}",
|
|
1266
|
+
verification="A learning exists with applies_to/topic linked to the repair change",
|
|
1267
|
+
reasoning="Daily self-audit found a repair/fix change log entry with no durable learning attached.",
|
|
1268
|
+
priority="high",
|
|
1269
|
+
)
|
|
1270
|
+
conn.commit()
|
|
1271
|
+
conn.close()
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def check_unformalized_mentions():
|
|
1275
|
+
if not NEXO_DB.exists():
|
|
1276
|
+
return
|
|
1277
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1278
|
+
conn.row_factory = sqlite3.Row
|
|
1279
|
+
if not _table_exists(conn, "protocol_tasks"):
|
|
1280
|
+
conn.close()
|
|
1281
|
+
return
|
|
1282
|
+
|
|
1283
|
+
retired_result = _retire_stale_audit_goals_inline(conn)
|
|
1284
|
+
retired_count = int(retired_result.get("retired") or 0)
|
|
1285
|
+
if retired_count:
|
|
1286
|
+
finding("INFO", "formalization", f"retired {retired_count} stale self-audit workflow goals")
|
|
1287
|
+
|
|
1288
|
+
rows = conn.execute(
|
|
1289
|
+
"""SELECT goal, area, learning_id, followup_id
|
|
1290
|
+
FROM protocol_tasks
|
|
1291
|
+
WHERE opened_at >= datetime('now', '-30 days')
|
|
1292
|
+
AND COALESCE(goal, '') != ''
|
|
1293
|
+
ORDER BY opened_at DESC"""
|
|
1294
|
+
).fetchall()
|
|
1295
|
+
if not rows:
|
|
1296
|
+
conn.close()
|
|
1297
|
+
return
|
|
1298
|
+
|
|
1299
|
+
formalized_topics: set[str] = set()
|
|
1300
|
+
if _table_exists(conn, "workflow_goals"):
|
|
1301
|
+
goal_rows = conn.execute(
|
|
1302
|
+
"""SELECT title, objective
|
|
1303
|
+
FROM workflow_goals
|
|
1304
|
+
WHERE status NOT IN ('abandoned', 'cancelled')"""
|
|
1305
|
+
).fetchall()
|
|
1306
|
+
for row in goal_rows:
|
|
1307
|
+
for candidate in (row["title"], row["objective"]):
|
|
1308
|
+
signature = _topic_signature(str(candidate or ""))
|
|
1309
|
+
if signature:
|
|
1310
|
+
formalized_topics.add(signature)
|
|
1311
|
+
|
|
1312
|
+
repeated: dict[tuple[str, str], list[sqlite3.Row]] = {}
|
|
1313
|
+
for row in rows:
|
|
1314
|
+
if row["learning_id"] or str(row["followup_id"] or "").strip():
|
|
1315
|
+
continue
|
|
1316
|
+
signature = _topic_signature(str(row["goal"] or ""))
|
|
1317
|
+
if not signature or signature in formalized_topics:
|
|
1318
|
+
continue
|
|
1319
|
+
area = str(row["area"] or "general").strip() or "general"
|
|
1320
|
+
repeated.setdefault((area, signature), []).append(row)
|
|
1321
|
+
|
|
1322
|
+
loose_topics = {
|
|
1323
|
+
key: items
|
|
1324
|
+
for key, items in repeated.items()
|
|
1325
|
+
if len(items) >= 2
|
|
1326
|
+
}
|
|
1327
|
+
if loose_topics:
|
|
1328
|
+
resolved = 0
|
|
1329
|
+
completed_followups = 0
|
|
1330
|
+
for (area, signature), items in list(loose_topics.items())[:5]:
|
|
1331
|
+
sample_goal = str(items[0]["goal"] or "").strip()[:120]
|
|
1332
|
+
description = (
|
|
1333
|
+
f"Formalize repeated unresolved theme in {area}: '{sample_goal}' "
|
|
1334
|
+
f"appears {len(items)} times without a durable goal, followup, or learning."
|
|
1335
|
+
)
|
|
1336
|
+
reasoning = (
|
|
1337
|
+
"Daily self-audit found the same theme recurring across protocol tasks without being "
|
|
1338
|
+
"converted into a workflow goal, followup, or learning. Formalize it before it keeps resurfacing."
|
|
1339
|
+
)
|
|
1340
|
+
goal_result = _upsert_workflow_goal_inline(
|
|
1341
|
+
conn,
|
|
1342
|
+
area=area,
|
|
1343
|
+
sample_goal=sample_goal,
|
|
1344
|
+
count=len(items),
|
|
1345
|
+
)
|
|
1346
|
+
if goal_result.get("ok"):
|
|
1347
|
+
resolved += 1
|
|
1348
|
+
completed_followups += _complete_matching_followup(
|
|
1349
|
+
conn,
|
|
1350
|
+
description,
|
|
1351
|
+
f"Resolved inline by daily self-audit via workflow goal {goal_result.get('goal_id')}.",
|
|
1352
|
+
)
|
|
1353
|
+
continue
|
|
1354
|
+
learning_result = _upsert_inline_learning(
|
|
1355
|
+
conn,
|
|
1356
|
+
category=area,
|
|
1357
|
+
title=f"Formalized recurring theme: {sample_goal}",
|
|
1358
|
+
content=(
|
|
1359
|
+
f"Recurring unresolved theme in {area}: '{sample_goal}' appeared {len(items)} times "
|
|
1360
|
+
"without a durable goal or learning."
|
|
1361
|
+
),
|
|
1362
|
+
reasoning=reasoning,
|
|
1363
|
+
prevention="Convert recurring themes into an explicit workflow goal before they keep resurfacing.",
|
|
1364
|
+
priority="high",
|
|
1365
|
+
)
|
|
1366
|
+
if learning_result.get("ok"):
|
|
1367
|
+
resolved += 1
|
|
1368
|
+
completed_followups += _complete_matching_followup(
|
|
1369
|
+
conn,
|
|
1370
|
+
description,
|
|
1371
|
+
f"Resolved inline by daily self-audit via learning #{learning_result.get('learning_id')}.",
|
|
1372
|
+
)
|
|
1373
|
+
conn.commit()
|
|
1374
|
+
if resolved:
|
|
1375
|
+
message = f"{resolved} repeated unresolved theme(s) formalized inline"
|
|
1376
|
+
if completed_followups:
|
|
1377
|
+
message += f" | completed {completed_followups} legacy followup(s)"
|
|
1378
|
+
finding("INFO", "formalization", message)
|
|
1379
|
+
remaining = max(0, len(loose_topics) - resolved)
|
|
1380
|
+
if remaining:
|
|
1381
|
+
finding("WARN", "formalization", f"{remaining} repeated topic(s) still lack durable inline formalization")
|
|
1382
|
+
conn.close()
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def check_automation_opportunities():
|
|
1386
|
+
if not NEXO_DB.exists():
|
|
1387
|
+
return
|
|
1388
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1389
|
+
conn.row_factory = sqlite3.Row
|
|
1390
|
+
if not _table_exists(conn, "protocol_tasks"):
|
|
1391
|
+
conn.close()
|
|
1392
|
+
return
|
|
1393
|
+
|
|
1394
|
+
rows = conn.execute(
|
|
1395
|
+
"""SELECT goal, area, files
|
|
1396
|
+
FROM protocol_tasks
|
|
1397
|
+
WHERE status = 'done'
|
|
1398
|
+
AND closed_at >= datetime('now', '-30 days')
|
|
1399
|
+
ORDER BY closed_at DESC"""
|
|
1400
|
+
).fetchall()
|
|
1401
|
+
if not rows:
|
|
1402
|
+
conn.close()
|
|
1403
|
+
return
|
|
1404
|
+
|
|
1405
|
+
grouped: dict[tuple[str, str], list[sqlite3.Row]] = {}
|
|
1406
|
+
for row in rows:
|
|
1407
|
+
signature = str(row["files"] or "").strip() or _topic_signature(str(row["goal"] or ""))
|
|
1408
|
+
if not signature:
|
|
1409
|
+
continue
|
|
1410
|
+
area = str(row["area"] or "general").strip() or "general"
|
|
1411
|
+
grouped.setdefault((area, signature[:220]), []).append(row)
|
|
1412
|
+
|
|
1413
|
+
repeated = {
|
|
1414
|
+
key: items
|
|
1415
|
+
for key, items in grouped.items()
|
|
1416
|
+
if len(items) >= 3
|
|
1417
|
+
}
|
|
1418
|
+
if repeated:
|
|
1419
|
+
finding("INFO", "opportunities", f"{len(repeated)} repeated manual pattern(s) are good candidates for skills/scripts")
|
|
1420
|
+
for (area, signature), items in list(repeated.items())[:5]:
|
|
1421
|
+
sample_goal = str(items[0]["goal"] or "").strip()[:120]
|
|
1422
|
+
description = (
|
|
1423
|
+
f"Extract a reusable automation for repeated {area} work around '{sample_goal}' "
|
|
1424
|
+
f"(seen {len(items)} successful protocol tasks in 30 days)."
|
|
1425
|
+
)
|
|
1426
|
+
reasoning = (
|
|
1427
|
+
"Daily self-audit found repeated successful manual work. Convert it into a skill, script, "
|
|
1428
|
+
"or reusable workflow before it keeps consuming operator time."
|
|
1429
|
+
)
|
|
1430
|
+
_ensure_followup(
|
|
1431
|
+
conn,
|
|
1432
|
+
prefix="OPPORTUNITY",
|
|
1433
|
+
description=description,
|
|
1434
|
+
verification="A reusable skill, script, or workflow now covers the repeated manual pattern",
|
|
1435
|
+
reasoning=reasoning,
|
|
1436
|
+
priority="medium",
|
|
1437
|
+
)
|
|
1438
|
+
conn.commit()
|
|
1439
|
+
conn.close()
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def check_state_watchers():
|
|
1443
|
+
try:
|
|
1444
|
+
import importlib
|
|
1445
|
+
import db as _db
|
|
1446
|
+
import state_watchers_runtime as _state_watchers_runtime
|
|
1447
|
+
except Exception as exc:
|
|
1448
|
+
finding("WARN", "watchers", f"state watchers runtime unavailable: {exc}")
|
|
1449
|
+
return
|
|
1450
|
+
importlib.reload(_db)
|
|
1451
|
+
runtime = importlib.reload(_state_watchers_runtime)
|
|
1452
|
+
summary = runtime.run_state_watchers(persist=True)
|
|
1453
|
+
counts = summary.get("counts") or {}
|
|
1454
|
+
if int(counts.get("critical") or 0) > 0:
|
|
1455
|
+
finding("ERROR", "watchers", f"{counts.get('critical')} critical state watcher(s)")
|
|
1456
|
+
elif int(counts.get("degraded") or 0) > 0:
|
|
1457
|
+
finding("WARN", "watchers", f"{counts.get('degraded')} degraded state watcher(s)")
|
|
1458
|
+
elif int(summary.get("watcher_count") or 0) > 0:
|
|
1459
|
+
finding("INFO", "watchers", f"{summary.get('watcher_count')} state watcher(s) healthy")
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def check_memory_quality_scores():
|
|
1463
|
+
if not NEXO_DB.exists():
|
|
1464
|
+
return
|
|
1465
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1466
|
+
conn.row_factory = sqlite3.Row
|
|
1467
|
+
if not _table_exists(conn, "learnings"):
|
|
1468
|
+
conn.close()
|
|
1469
|
+
return
|
|
1470
|
+
try:
|
|
1471
|
+
from tools_learnings import score_learning_quality
|
|
1472
|
+
except Exception:
|
|
1473
|
+
conn.close()
|
|
1474
|
+
return
|
|
1475
|
+
|
|
1476
|
+
rows = conn.execute(
|
|
1477
|
+
"""SELECT *
|
|
1478
|
+
FROM learnings
|
|
1479
|
+
WHERE status = 'active'
|
|
1480
|
+
ORDER BY updated_at DESC, id DESC
|
|
1481
|
+
LIMIT 200"""
|
|
1482
|
+
).fetchall()
|
|
1483
|
+
if not rows:
|
|
1484
|
+
conn.close()
|
|
1485
|
+
return
|
|
1486
|
+
|
|
1487
|
+
normalized = [dict(row) for row in rows]
|
|
1488
|
+
scored = [(row, score_learning_quality(row, conn)) for row in normalized]
|
|
1489
|
+
weak = [(row, quality) for row, quality in scored if quality["score"] < 60]
|
|
1490
|
+
fragile_conditioned = [
|
|
1491
|
+
(row, quality)
|
|
1492
|
+
for row, quality in weak
|
|
1493
|
+
if str(row.get("applies_to") or "").strip()
|
|
1494
|
+
]
|
|
1495
|
+
if weak:
|
|
1496
|
+
finding("WARN", "memory-quality", f"{len(weak)} active learning(s) have low quality scores")
|
|
1497
|
+
if fragile_conditioned:
|
|
1498
|
+
sample = fragile_conditioned[0][0]
|
|
1499
|
+
description = (
|
|
1500
|
+
f"Refresh low-quality conditioned learnings; first weak rule is #{sample['id']} "
|
|
1501
|
+
f"for {sample['applies_to']}"
|
|
1502
|
+
)
|
|
1503
|
+
else:
|
|
1504
|
+
sample = weak[0][0]
|
|
1505
|
+
description = f"Refresh low-quality learnings; first weak rule is #{sample['id']} {sample['title']}"
|
|
1506
|
+
_ensure_followup(
|
|
1507
|
+
conn,
|
|
1508
|
+
prefix="MEMQ",
|
|
1509
|
+
description=description,
|
|
1510
|
+
verification="Weak active learnings refreshed with stronger reasoning/prevention/applies_to coverage",
|
|
1511
|
+
reasoning="Daily self-audit found active learnings with weak quality scores that may mislead retrieval or guard.",
|
|
1512
|
+
priority="high" if fragile_conditioned else "medium",
|
|
1513
|
+
)
|
|
1514
|
+
conn.commit()
|
|
1515
|
+
conn.close()
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _sha256(path):
|
|
1519
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def check_watchdog_registry():
|
|
1523
|
+
if not HASH_REGISTRY.exists():
|
|
1524
|
+
return
|
|
1525
|
+
text = HASH_REGISTRY.read_text(errors="ignore")
|
|
1526
|
+
forbidden = ["CLAUDE.md", "AGENTS.md", "server.py", "plugin_loader.py"]
|
|
1527
|
+
bad = [name for name in forbidden if name in text]
|
|
1528
|
+
if bad:
|
|
1529
|
+
finding("ERROR", "watchdog", f"mutable files still protected: {', '.join(bad)}")
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def check_snapshot_sync():
|
|
1533
|
+
pairs = [
|
|
1534
|
+
(NEXO_CODE / "db" / "__init__.py", SNAPSHOT_GOLDEN / "db" / "__init__.py"),
|
|
1535
|
+
(NEXO_CODE / "evolution_cycle.py", SNAPSHOT_GOLDEN / "evolution_cycle.py"),
|
|
1536
|
+
]
|
|
1537
|
+
drift = [live.name for live, snap in pairs
|
|
1538
|
+
if not live.exists() or not snap.exists() or _sha256(live) != _sha256(snap)]
|
|
1539
|
+
if drift:
|
|
1540
|
+
finding("WARN", "snapshots", f"golden snapshot drift: {', '.join(drift)}")
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def check_restore_activity():
|
|
1544
|
+
if not RESTORE_LOG.exists():
|
|
1545
|
+
return
|
|
1546
|
+
cutoff_day = datetime.now() - timedelta(days=1)
|
|
1547
|
+
current_hour_prefix = datetime.now().strftime("%Y-%m-%d %H")
|
|
1548
|
+
recent_day = 0
|
|
1549
|
+
recent_hour = 0
|
|
1550
|
+
for line in RESTORE_LOG.read_text(errors="ignore").splitlines():
|
|
1551
|
+
if not line.startswith("[") or "/.codex/memories/nexo-" in line:
|
|
1552
|
+
continue
|
|
1553
|
+
try:
|
|
1554
|
+
ts = datetime.strptime(line[1:20], "%Y-%m-%d %H:%M:%S")
|
|
1555
|
+
except ValueError:
|
|
1556
|
+
continue
|
|
1557
|
+
if ts >= cutoff_day:
|
|
1558
|
+
recent_day += 1
|
|
1559
|
+
if line[1:14] == current_hour_prefix:
|
|
1560
|
+
recent_hour += 1
|
|
1561
|
+
if recent_hour > 2:
|
|
1562
|
+
finding("ERROR", "restore", f"{recent_hour} restores in last hour")
|
|
1563
|
+
elif recent_day > 5:
|
|
1564
|
+
finding("WARN", "restore", f"{recent_day} restores in last 24h")
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def check_bad_responses():
|
|
1568
|
+
if not CORTEX_LOG_DIR.exists():
|
|
1569
|
+
return
|
|
1570
|
+
cutoff = datetime.now() - timedelta(days=1)
|
|
1571
|
+
bad = [p for p in CORTEX_LOG_DIR.glob("bad-response-*.json")
|
|
1572
|
+
if datetime.fromtimestamp(p.stat().st_mtime) >= cutoff]
|
|
1573
|
+
if bad:
|
|
1574
|
+
finding("WARN", "cortex", f"{len(bad)} bad model responses in last 24h")
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def check_runtime_preflight():
|
|
1578
|
+
if not RUNTIME_PREFLIGHT_SUMMARY.exists():
|
|
1579
|
+
return
|
|
1580
|
+
data = json.loads(RUNTIME_PREFLIGHT_SUMMARY.read_text())
|
|
1581
|
+
ts = data.get("timestamp")
|
|
1582
|
+
try:
|
|
1583
|
+
when = datetime.fromisoformat(ts)
|
|
1584
|
+
except Exception:
|
|
1585
|
+
return
|
|
1586
|
+
if when < datetime.now() - timedelta(days=1):
|
|
1587
|
+
finding("WARN", "preflight", "runtime preflight older than 24h")
|
|
1588
|
+
if not data.get("ok", False):
|
|
1589
|
+
finding("ERROR", "preflight", "runtime preflight failing")
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def run_watchdog_smoke():
|
|
1593
|
+
"""Run the watchdog smoke test so its summary is fresh before we check it."""
|
|
1594
|
+
smoke_script = Path(__file__).resolve().parent / "nexo-watchdog-smoke.py"
|
|
1595
|
+
if not smoke_script.exists():
|
|
1596
|
+
finding("WARN", "watchdog", f"smoke script not found at {smoke_script}")
|
|
1597
|
+
return
|
|
1598
|
+
try:
|
|
1599
|
+
result = subprocess.run(
|
|
1600
|
+
[sys.executable, str(smoke_script)],
|
|
1601
|
+
capture_output=True, text=True, timeout=60
|
|
1602
|
+
)
|
|
1603
|
+
if result.returncode != 0:
|
|
1604
|
+
finding("WARN", "watchdog", f"smoke test exited {result.returncode}")
|
|
1605
|
+
except subprocess.TimeoutExpired:
|
|
1606
|
+
finding("ERROR", "watchdog", "smoke test timed out (60s)")
|
|
1607
|
+
except Exception as e:
|
|
1608
|
+
finding("WARN", "watchdog", f"smoke test failed: {e}")
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def check_watchdog_smoke():
|
|
1612
|
+
if not WATCHDOG_SMOKE_SUMMARY.exists():
|
|
1613
|
+
return
|
|
1614
|
+
data = json.loads(WATCHDOG_SMOKE_SUMMARY.read_text())
|
|
1615
|
+
ts = data.get("timestamp")
|
|
1616
|
+
try:
|
|
1617
|
+
when = datetime.fromisoformat(ts)
|
|
1618
|
+
except Exception:
|
|
1619
|
+
return
|
|
1620
|
+
if when < datetime.now() - timedelta(days=1):
|
|
1621
|
+
finding("WARN", "watchdog", "watchdog smoke older than 24h")
|
|
1622
|
+
if not data.get("ok", False):
|
|
1623
|
+
finding("ERROR", "watchdog", "watchdog smoke failing")
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def check_cognitive_health():
|
|
1627
|
+
cognitive_db = NEXO_HOME / "data" / "cognitive.db"
|
|
1628
|
+
if not cognitive_db.exists():
|
|
1629
|
+
finding("WARN", "cognitive", "cognitive.db not found")
|
|
1630
|
+
return
|
|
1631
|
+
|
|
1632
|
+
conn = sqlite3.connect(str(cognitive_db))
|
|
1633
|
+
stm_count = conn.execute("SELECT COUNT(*) FROM stm_memories WHERE promoted_to_ltm = 0").fetchone()[0]
|
|
1634
|
+
ltm_active = conn.execute("SELECT COUNT(*) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0]
|
|
1635
|
+
ltm_dormant = conn.execute("SELECT COUNT(*) FROM ltm_memories WHERE is_dormant = 1").fetchone()[0]
|
|
1636
|
+
avg_stm_str = conn.execute("SELECT AVG(strength) FROM stm_memories WHERE promoted_to_ltm = 0").fetchone()[0] or 0.0
|
|
1637
|
+
sensory_count = conn.execute("SELECT COUNT(*) FROM stm_memories WHERE source_type = 'sensory' AND promoted_to_ltm = 0").fetchone()[0]
|
|
1638
|
+
conn.close()
|
|
1639
|
+
|
|
1640
|
+
size_mb = cognitive_db.stat().st_size / (1024 * 1024)
|
|
1641
|
+
finding("INFO", "cognitive", f"STM: {stm_count} (sensory: {sensory_count}) | LTM: {ltm_active} active, {ltm_dormant} dormant | {size_mb:.1f} MB")
|
|
1642
|
+
|
|
1643
|
+
if avg_stm_str < 0.3 and stm_count > 20:
|
|
1644
|
+
finding("WARN", "cognitive", f"STM average strength very low ({avg_stm_str:.2f})")
|
|
1645
|
+
|
|
1646
|
+
# Metrics
|
|
1647
|
+
try:
|
|
1648
|
+
sys.path.insert(0, str(NEXO_CODE))
|
|
1649
|
+
import cognitive as cog
|
|
1650
|
+
metrics = cog.get_metrics(days=7)
|
|
1651
|
+
if metrics["total_retrievals"] > 0:
|
|
1652
|
+
finding("INFO", "cognitive-metrics",
|
|
1653
|
+
f"7d: {metrics['total_retrievals']} retrievals, relevance={metrics['retrieval_relevance_pct']}%")
|
|
1654
|
+
if metrics["retrieval_relevance_pct"] < 50 and metrics["total_retrievals"] >= 5:
|
|
1655
|
+
finding("ERROR", "cognitive-metrics", f"Relevance critically low: {metrics['retrieval_relevance_pct']}%")
|
|
1656
|
+
|
|
1657
|
+
repeats = cog.check_repeat_errors()
|
|
1658
|
+
if repeats["new_count"] > 0 and repeats["repeat_rate_pct"] > 30:
|
|
1659
|
+
finding("WARN", "cognitive-metrics", f"Repeat rate {repeats['repeat_rate_pct']}% > 30%")
|
|
1660
|
+
|
|
1661
|
+
# Save metrics
|
|
1662
|
+
metrics_file = LOG_DIR / "cognitive-metrics.json"
|
|
1663
|
+
metrics_file.write_text(json.dumps({
|
|
1664
|
+
"timestamp": datetime.now().isoformat(),
|
|
1665
|
+
"retrieval": metrics,
|
|
1666
|
+
"repeats": {k: v for k, v in repeats.items() if k != "duplicates"},
|
|
1667
|
+
}, indent=2))
|
|
1668
|
+
|
|
1669
|
+
# Track history for phase triggers
|
|
1670
|
+
history_file = LOG_DIR / "cognitive-metrics-history.json"
|
|
1671
|
+
try:
|
|
1672
|
+
history = json.loads(history_file.read_text()) if history_file.exists() else []
|
|
1673
|
+
except Exception:
|
|
1674
|
+
history = []
|
|
1675
|
+
m1 = cog.get_metrics(days=1)
|
|
1676
|
+
if m1["total_retrievals"] > 0:
|
|
1677
|
+
history.append({"date": datetime.now().strftime("%Y-%m-%d"),
|
|
1678
|
+
"relevance": m1["retrieval_relevance_pct"],
|
|
1679
|
+
"retrievals": m1["total_retrievals"]})
|
|
1680
|
+
history = history[-60:]
|
|
1681
|
+
history_file.write_text(json.dumps(history, indent=2))
|
|
1682
|
+
|
|
1683
|
+
except Exception as e:
|
|
1684
|
+
finding("WARN", "cognitive-metrics", f"Metrics failed: {e}")
|
|
1685
|
+
|
|
1686
|
+
# Weekly GC on Sundays
|
|
1687
|
+
if datetime.now().weekday() == 6:
|
|
1688
|
+
try:
|
|
1689
|
+
sys.path.insert(0, str(NEXO_CODE))
|
|
1690
|
+
import cognitive as cog
|
|
1691
|
+
gc_stm = cog.gc_stm()
|
|
1692
|
+
gc_sensory = cog.gc_sensory(max_age_hours=48)
|
|
1693
|
+
gc_ltm = cog.gc_ltm_dormant(min_age_days=30)
|
|
1694
|
+
if gc_stm + gc_sensory + gc_ltm > 0:
|
|
1695
|
+
finding("INFO", "cognitive", f"Weekly GC: {gc_stm} STM + {gc_sensory} sensory + {gc_ltm} dormant")
|
|
1696
|
+
except Exception as e:
|
|
1697
|
+
finding("WARN", "cognitive", f"Weekly GC failed: {e}")
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def check_codex_conditioned_file_discipline():
|
|
1701
|
+
try:
|
|
1702
|
+
from doctor.providers.runtime import _recent_codex_conditioned_file_discipline_status
|
|
1703
|
+
except Exception as e:
|
|
1704
|
+
finding("WARN", "codex-discipline", f"Codex discipline audit unavailable: {e}")
|
|
1705
|
+
return
|
|
1706
|
+
|
|
1707
|
+
audit = _recent_codex_conditioned_file_discipline_status()
|
|
1708
|
+
if not audit.get("conditioned_rules"):
|
|
1709
|
+
return
|
|
1710
|
+
|
|
1711
|
+
read_violations = int(audit.get("read_without_protocol") or 0)
|
|
1712
|
+
write_without_protocol = int(audit.get("write_without_protocol") or 0)
|
|
1713
|
+
write_without_guard_ack = int(audit.get("write_without_guard_ack") or 0)
|
|
1714
|
+
delete_without_protocol = int(audit.get("delete_without_protocol") or 0)
|
|
1715
|
+
delete_without_guard_ack = int(audit.get("delete_without_guard_ack") or 0)
|
|
1716
|
+
total_violations = (
|
|
1717
|
+
read_violations
|
|
1718
|
+
+ write_without_protocol
|
|
1719
|
+
+ write_without_guard_ack
|
|
1720
|
+
+ delete_without_protocol
|
|
1721
|
+
+ delete_without_guard_ack
|
|
1722
|
+
)
|
|
1723
|
+
if total_violations <= 0:
|
|
1724
|
+
return
|
|
1725
|
+
|
|
1726
|
+
created_debts = 0
|
|
1727
|
+
if NEXO_DB.exists():
|
|
1728
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1729
|
+
if _protocol_debt_table_exists(conn):
|
|
1730
|
+
debt_type_map = {
|
|
1731
|
+
"read_without_protocol": ("codex_conditioned_read_without_protocol", "warn"),
|
|
1732
|
+
"write_without_protocol": ("codex_conditioned_write_without_protocol", "error"),
|
|
1733
|
+
"write_without_guard_ack": ("codex_conditioned_write_without_guard_ack", "error"),
|
|
1734
|
+
"delete_without_protocol": ("codex_conditioned_delete_without_protocol", "error"),
|
|
1735
|
+
"delete_without_guard_ack": ("codex_conditioned_delete_without_guard_ack", "error"),
|
|
1736
|
+
}
|
|
1737
|
+
for sample in audit.get("samples", []):
|
|
1738
|
+
debt_info = debt_type_map.get(sample.get("kind"))
|
|
1739
|
+
if not debt_info:
|
|
1740
|
+
continue
|
|
1741
|
+
debt_type, severity = debt_info
|
|
1742
|
+
evidence = (
|
|
1743
|
+
"Codex conditioned-file transcript audit: "
|
|
1744
|
+
f"{sample.get('kind')} {sample.get('file')} via {sample.get('tool')} "
|
|
1745
|
+
f"in {sample.get('session_file')}"
|
|
1746
|
+
)
|
|
1747
|
+
if _ensure_protocol_debt(conn, debt_type=debt_type, severity=severity, evidence=evidence):
|
|
1748
|
+
created_debts += 1
|
|
1749
|
+
conn.commit()
|
|
1750
|
+
conn.close()
|
|
1751
|
+
|
|
1752
|
+
severity = "ERROR" if (write_without_protocol or write_without_guard_ack) else "WARN"
|
|
1753
|
+
message = (
|
|
1754
|
+
"Codex conditioned-file discipline drift: "
|
|
1755
|
+
f"{read_violations} read(s) without protocol/guard, "
|
|
1756
|
+
f"{write_without_protocol} write(s) without protocol, "
|
|
1757
|
+
f"{write_without_guard_ack} write(s) without guard ack, "
|
|
1758
|
+
f"{delete_without_protocol} delete(s) without protocol, "
|
|
1759
|
+
f"{delete_without_guard_ack} delete(s) without guard ack"
|
|
1760
|
+
)
|
|
1761
|
+
if created_debts:
|
|
1762
|
+
message += f" | opened {created_debts} protocol debt item(s)"
|
|
1763
|
+
finding(severity, "codex-discipline", message)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def check_codex_startup_discipline():
|
|
1767
|
+
try:
|
|
1768
|
+
from doctor.providers.runtime import _recent_codex_session_parity_status
|
|
1769
|
+
except Exception as e:
|
|
1770
|
+
finding("WARN", "codex-startup", f"Codex startup audit unavailable: {e}")
|
|
1771
|
+
return
|
|
1772
|
+
|
|
1773
|
+
audit = _recent_codex_session_parity_status()
|
|
1774
|
+
if not audit.get("files"):
|
|
1775
|
+
return
|
|
1776
|
+
|
|
1777
|
+
samples = audit.get("samples", [])
|
|
1778
|
+
missing_startup = [sample for sample in samples if not sample.get("startup")]
|
|
1779
|
+
missing_heartbeat = [sample for sample in samples if sample.get("startup") and not sample.get("heartbeat")]
|
|
1780
|
+
missing_bootstrap = [
|
|
1781
|
+
sample for sample in samples
|
|
1782
|
+
if sample.get("startup") and sample.get("heartbeat") and not sample.get("bootstrap")
|
|
1783
|
+
]
|
|
1784
|
+
if not missing_startup and not missing_heartbeat and not missing_bootstrap:
|
|
1785
|
+
return
|
|
1786
|
+
|
|
1787
|
+
created_debts = 0
|
|
1788
|
+
if NEXO_DB.exists():
|
|
1789
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1790
|
+
if _protocol_debt_table_exists(conn):
|
|
1791
|
+
for sample in samples:
|
|
1792
|
+
debt_type = ""
|
|
1793
|
+
severity = "warn"
|
|
1794
|
+
if not sample.get("startup"):
|
|
1795
|
+
debt_type = "codex_session_missing_startup"
|
|
1796
|
+
severity = "error"
|
|
1797
|
+
elif not sample.get("heartbeat"):
|
|
1798
|
+
debt_type = "codex_session_missing_heartbeat"
|
|
1799
|
+
elif not sample.get("bootstrap"):
|
|
1800
|
+
debt_type = "codex_session_missing_bootstrap"
|
|
1801
|
+
if not debt_type:
|
|
1802
|
+
continue
|
|
1803
|
+
evidence = (
|
|
1804
|
+
"Codex session parity audit: "
|
|
1805
|
+
f"{debt_type} in {sample.get('file')} "
|
|
1806
|
+
f"(origin={sample.get('origin') or 'unknown'})"
|
|
1807
|
+
)
|
|
1808
|
+
if _ensure_protocol_debt(conn, debt_type=debt_type, severity=severity, evidence=evidence):
|
|
1809
|
+
created_debts += 1
|
|
1810
|
+
conn.commit()
|
|
1811
|
+
conn.close()
|
|
1812
|
+
|
|
1813
|
+
severity = "ERROR" if missing_startup else "WARN"
|
|
1814
|
+
message = (
|
|
1815
|
+
"Codex startup discipline drift: "
|
|
1816
|
+
f"{len(missing_bootstrap)} session(s) missing bootstrap marker, "
|
|
1817
|
+
f"{len(missing_startup)} missing startup, "
|
|
1818
|
+
f"{len(missing_heartbeat)} missing heartbeat"
|
|
1819
|
+
)
|
|
1820
|
+
if created_debts:
|
|
1821
|
+
message += f" | opened {created_debts} protocol debt item(s)"
|
|
1822
|
+
finding(severity, "codex-startup", message)
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _clear_findings(area: str, contains: str = "") -> int:
|
|
1826
|
+
removed = 0
|
|
1827
|
+
keep: list[dict] = []
|
|
1828
|
+
for item in findings:
|
|
1829
|
+
same_area = item.get("area") == area
|
|
1830
|
+
same_fragment = not contains or contains in str(item.get("msg") or "")
|
|
1831
|
+
if same_area and same_fragment:
|
|
1832
|
+
removed += 1
|
|
1833
|
+
continue
|
|
1834
|
+
keep.append(item)
|
|
1835
|
+
if removed:
|
|
1836
|
+
findings[:] = keep
|
|
1837
|
+
return removed
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _sync_managed_bootstraps_inline() -> list[str]:
|
|
1841
|
+
try:
|
|
1842
|
+
from bootstrap_docs import sync_client_bootstrap
|
|
1843
|
+
from client_preferences import CLIENT_CLAUDE_CODE, CLIENT_CODEX
|
|
1844
|
+
except Exception:
|
|
1845
|
+
return []
|
|
1846
|
+
|
|
1847
|
+
results: list[str] = []
|
|
1848
|
+
for client in (CLIENT_CLAUDE_CODE, CLIENT_CODEX):
|
|
1849
|
+
try:
|
|
1850
|
+
outcome = sync_client_bootstrap(client, nexo_home=NEXO_HOME)
|
|
1851
|
+
except Exception:
|
|
1852
|
+
continue
|
|
1853
|
+
if not outcome.get("ok"):
|
|
1854
|
+
continue
|
|
1855
|
+
action = str(outcome.get("action") or "")
|
|
1856
|
+
if action and action != "unchanged":
|
|
1857
|
+
results.append(f"{client}:{action}")
|
|
1858
|
+
return results
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def _sanitize_watchdog_registry_inline() -> dict:
|
|
1862
|
+
if not HASH_REGISTRY.exists():
|
|
1863
|
+
return {"ok": False, "removed": []}
|
|
1864
|
+
forbidden = ["CLAUDE.md", "AGENTS.md", "server.py", "plugin_loader.py"]
|
|
1865
|
+
original_lines = HASH_REGISTRY.read_text(errors="ignore").splitlines()
|
|
1866
|
+
kept_lines = []
|
|
1867
|
+
removed: set[str] = set()
|
|
1868
|
+
for line in original_lines:
|
|
1869
|
+
if any(name in line for name in forbidden):
|
|
1870
|
+
for name in forbidden:
|
|
1871
|
+
if name in line:
|
|
1872
|
+
removed.add(name)
|
|
1873
|
+
continue
|
|
1874
|
+
kept_lines.append(line)
|
|
1875
|
+
if not removed:
|
|
1876
|
+
return {"ok": False, "removed": []}
|
|
1877
|
+
new_text = "\n".join(kept_lines)
|
|
1878
|
+
if kept_lines:
|
|
1879
|
+
new_text += "\n"
|
|
1880
|
+
HASH_REGISTRY.write_text(new_text)
|
|
1881
|
+
return {"ok": True, "removed": sorted(removed)}
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def _refresh_golden_snapshots_inline() -> dict:
|
|
1885
|
+
pairs = [
|
|
1886
|
+
(NEXO_CODE / "db" / "__init__.py", SNAPSHOT_GOLDEN / "db" / "__init__.py"),
|
|
1887
|
+
(NEXO_CODE / "evolution_cycle.py", SNAPSHOT_GOLDEN / "evolution_cycle.py"),
|
|
1888
|
+
]
|
|
1889
|
+
refreshed: list[str] = []
|
|
1890
|
+
for live, snap in pairs:
|
|
1891
|
+
if not live.exists():
|
|
1892
|
+
continue
|
|
1893
|
+
if snap.exists() and _sha256(live) == _sha256(snap):
|
|
1894
|
+
continue
|
|
1895
|
+
snap.parent.mkdir(parents=True, exist_ok=True)
|
|
1896
|
+
shutil.copy2(live, snap)
|
|
1897
|
+
refreshed.append(live.name)
|
|
1898
|
+
return {"ok": bool(refreshed), "refreshed": refreshed}
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _disable_broken_personal_plugins_inline(conn: sqlite3.Connection | None) -> dict:
|
|
1902
|
+
plugins_dir = NEXO_HOME / "plugins"
|
|
1903
|
+
if not plugins_dir.exists():
|
|
1904
|
+
return {"disabled": [], "registry_pruned": 0}
|
|
1905
|
+
|
|
1906
|
+
disabled: list[str] = []
|
|
1907
|
+
registry_pruned = 0
|
|
1908
|
+
personal_filenames: set[str] = set()
|
|
1909
|
+
if conn is not None and _table_exists(conn, "plugins"):
|
|
1910
|
+
try:
|
|
1911
|
+
rows = conn.execute(
|
|
1912
|
+
"SELECT filename, created_by FROM plugins WHERE created_by = 'personal'"
|
|
1913
|
+
).fetchall()
|
|
1914
|
+
personal_filenames = {str(row["filename"] or "").strip() for row in rows if str(row["filename"] or "").strip()}
|
|
1915
|
+
except Exception:
|
|
1916
|
+
personal_filenames = set()
|
|
1917
|
+
|
|
1918
|
+
for plugin_file in sorted(plugins_dir.glob("*.py")):
|
|
1919
|
+
try:
|
|
1920
|
+
py_compile.compile(str(plugin_file), doraise=True)
|
|
1921
|
+
except Exception:
|
|
1922
|
+
disabled_path = plugin_file.with_name(plugin_file.name + ".disabled")
|
|
1923
|
+
plugin_file.rename(disabled_path)
|
|
1924
|
+
disabled.append(plugin_file.name)
|
|
1925
|
+
if conn is not None and _table_exists(conn, "plugins"):
|
|
1926
|
+
conn.execute("DELETE FROM plugins WHERE filename = ?", (plugin_file.name,))
|
|
1927
|
+
registry_pruned += 1
|
|
1928
|
+
|
|
1929
|
+
if conn is not None and _table_exists(conn, "plugins"):
|
|
1930
|
+
for filename in sorted(personal_filenames):
|
|
1931
|
+
if not filename:
|
|
1932
|
+
continue
|
|
1933
|
+
if not (plugins_dir / filename).exists():
|
|
1934
|
+
conn.execute("DELETE FROM plugins WHERE filename = ?", (filename,))
|
|
1935
|
+
registry_pruned += 1
|
|
1936
|
+
return {"disabled": disabled, "registry_pruned": registry_pruned}
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def run_mechanical_autofixes():
|
|
1940
|
+
conn = None
|
|
1941
|
+
try:
|
|
1942
|
+
if NEXO_DB.exists():
|
|
1943
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
1944
|
+
conn.row_factory = sqlite3.Row
|
|
1945
|
+
|
|
1946
|
+
bootstrap_actions = _sync_managed_bootstraps_inline()
|
|
1947
|
+
if bootstrap_actions:
|
|
1948
|
+
finding("INFO", "autofix", f"Managed bootstraps refreshed inline: {', '.join(bootstrap_actions)}")
|
|
1949
|
+
|
|
1950
|
+
registry_result = _sanitize_watchdog_registry_inline()
|
|
1951
|
+
if registry_result.get("ok"):
|
|
1952
|
+
_clear_findings("watchdog", "mutable files still protected")
|
|
1953
|
+
finding(
|
|
1954
|
+
"INFO",
|
|
1955
|
+
"watchdog",
|
|
1956
|
+
"Self-audit sanitized watchdog registry inline: "
|
|
1957
|
+
+ ", ".join(registry_result.get("removed") or []),
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
snapshot_result = _refresh_golden_snapshots_inline()
|
|
1961
|
+
if snapshot_result.get("ok"):
|
|
1962
|
+
_clear_findings("snapshots", "golden snapshot drift")
|
|
1963
|
+
finding(
|
|
1964
|
+
"INFO",
|
|
1965
|
+
"snapshots",
|
|
1966
|
+
"Self-audit refreshed golden snapshots inline: "
|
|
1967
|
+
+ ", ".join(snapshot_result.get("refreshed") or []),
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1970
|
+
plugin_result = _disable_broken_personal_plugins_inline(conn)
|
|
1971
|
+
disabled = plugin_result.get("disabled") or []
|
|
1972
|
+
pruned = int(plugin_result.get("registry_pruned") or 0)
|
|
1973
|
+
if disabled or pruned:
|
|
1974
|
+
details: list[str] = []
|
|
1975
|
+
if disabled:
|
|
1976
|
+
details.append(f"disabled {len(disabled)} personal plugin(s): {', '.join(disabled)}")
|
|
1977
|
+
if pruned:
|
|
1978
|
+
details.append(f"pruned {pruned} stale plugin registry entrie(s)")
|
|
1979
|
+
finding("INFO", "autofix", "Self-audit plugin autofix: " + " | ".join(details))
|
|
1980
|
+
|
|
1981
|
+
if conn is not None:
|
|
1982
|
+
conn.commit()
|
|
1983
|
+
finally:
|
|
1984
|
+
if conn is not None:
|
|
1985
|
+
conn.close()
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1989
|
+
# Stage B: Interpretation (automation backend) — NEW in v2
|
|
1990
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1991
|
+
|
|
1992
|
+
def interpret_findings(raw_findings: list) -> bool:
|
|
1993
|
+
"""CLI interprets the raw findings with real understanding."""
|
|
1994
|
+
|
|
1995
|
+
errors = [f for f in raw_findings if f["severity"] == "ERROR"]
|
|
1996
|
+
warns = [f for f in raw_findings if f["severity"] == "WARN"]
|
|
1997
|
+
|
|
1998
|
+
# Don't invoke CLI if everything is clean
|
|
1999
|
+
if not errors and not warns:
|
|
2000
|
+
log("Stage B: All clean, no interpretation needed.")
|
|
2001
|
+
return True
|
|
2002
|
+
|
|
2003
|
+
findings_json = json.dumps(raw_findings, ensure_ascii=False, indent=1)
|
|
2004
|
+
|
|
2005
|
+
prompt = f"""FIRST: Call nexo_startup(task='daily self-audit') to register this session.
|
|
2006
|
+
|
|
2007
|
+
You are NEXO's morning self-audit interpreter. The mechanical checks found
|
|
2008
|
+
{len(errors)} errors and {len(warns)} warnings. Your job is to UNDERSTAND what's
|
|
2009
|
+
actually wrong, not just list findings.
|
|
2010
|
+
|
|
2011
|
+
CRITICAL — SEARCH BEFORE CREATING LEARNINGS:
|
|
2012
|
+
Before calling nexo_learning_add, you MUST call nexo_learning_search with keywords
|
|
2013
|
+
from the finding's area and topic. If a matching active learning already exists:
|
|
2014
|
+
- Call nexo_learning_update(id=<existing_id>, ...) to refresh it with the new
|
|
2015
|
+
evidence/date instead of creating a duplicate.
|
|
2016
|
+
- Only use nexo_learning_add (with supersedes_id=<old_id>) when the existing
|
|
2017
|
+
learning is materially wrong or outdated, not just to add another observation.
|
|
2018
|
+
If no existing learning matches, then nexo_learning_add is appropriate.
|
|
2019
|
+
The same applies to nexo_followup_create — search existing followups first.
|
|
2020
|
+
|
|
2021
|
+
RAW FINDINGS:
|
|
2022
|
+
{findings_json}
|
|
2023
|
+
|
|
2024
|
+
Write an actionable audit report to {LOG_DIR}/self-audit-interpreted.md:
|
|
2025
|
+
|
|
2026
|
+
# NEXO Self-Audit — {datetime.now().strftime('%Y-%m-%d')}
|
|
2027
|
+
|
|
2028
|
+
## Critical (needs immediate action)
|
|
2029
|
+
[Group related findings, identify ROOT CAUSE, suggest specific fix]
|
|
2030
|
+
|
|
2031
|
+
## Warnings (should address today)
|
|
2032
|
+
[Same: group, root cause, specific action]
|
|
2033
|
+
|
|
2034
|
+
## Observations
|
|
2035
|
+
[Trends, things getting worse, things improving]
|
|
2036
|
+
|
|
2037
|
+
## Recommended Actions (priority order)
|
|
2038
|
+
1. [Most important action with specific command/steps]
|
|
2039
|
+
2. ...
|
|
2040
|
+
|
|
2041
|
+
Be specific. "Fix the DB" is useless. "Archive learnings >90 days in category X
|
|
2042
|
+
via sqlite3 nexo.db 'UPDATE...'" is useful.
|
|
2043
|
+
|
|
2044
|
+
Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
|
|
2045
|
+
|
|
2046
|
+
Execute without asking."""
|
|
2047
|
+
|
|
2048
|
+
log("Stage B: Invoking automation backend for interpretation...")
|
|
2049
|
+
try:
|
|
2050
|
+
result = run_automation_prompt(
|
|
2051
|
+
prompt,
|
|
2052
|
+
model=_USER_MODEL or "opus",
|
|
2053
|
+
timeout=21600,
|
|
2054
|
+
output_format="text",
|
|
2055
|
+
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
2056
|
+
)
|
|
2057
|
+
|
|
2058
|
+
if result.returncode != 0:
|
|
2059
|
+
log(f"Stage B: CLI error ({result.returncode})")
|
|
2060
|
+
return False
|
|
2061
|
+
|
|
2062
|
+
log(f"Stage B: Interpretation complete ({len(result.stdout or '')} chars)")
|
|
2063
|
+
return True
|
|
2064
|
+
|
|
2065
|
+
except AutomationBackendUnavailableError as e:
|
|
2066
|
+
log(f"Stage B: automation backend unavailable: {e}")
|
|
2067
|
+
return False
|
|
2068
|
+
except subprocess.TimeoutExpired:
|
|
2069
|
+
log("Stage B: CLI timed out")
|
|
2070
|
+
return False
|
|
2071
|
+
except Exception as e:
|
|
2072
|
+
log(f"Stage B: {e}")
|
|
2073
|
+
return False
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2077
|
+
# Main
|
|
2078
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2079
|
+
|
|
2080
|
+
def main():
|
|
2081
|
+
log("=" * 60)
|
|
2082
|
+
log("NEXO Daily Self-Audit v2 starting")
|
|
2083
|
+
|
|
2084
|
+
# Stage A: Run all mechanical checks (unchanged)
|
|
2085
|
+
check_overdue_reminders()
|
|
2086
|
+
check_overdue_followups()
|
|
2087
|
+
check_uncommitted_changes()
|
|
2088
|
+
check_cron_errors()
|
|
2089
|
+
check_evolution_health()
|
|
2090
|
+
check_disk_space()
|
|
2091
|
+
check_db_size()
|
|
2092
|
+
check_stale_sessions()
|
|
2093
|
+
check_repetition_rate()
|
|
2094
|
+
check_unused_learnings()
|
|
2095
|
+
check_memory_reviews()
|
|
2096
|
+
check_learning_contradictions()
|
|
2097
|
+
check_error_memory_loop()
|
|
2098
|
+
check_repair_changes_missing_learning_capture()
|
|
2099
|
+
check_unformalized_mentions()
|
|
2100
|
+
check_automation_opportunities()
|
|
2101
|
+
check_state_watchers()
|
|
2102
|
+
check_memory_quality_scores()
|
|
2103
|
+
check_codex_startup_discipline()
|
|
2104
|
+
check_codex_conditioned_file_discipline()
|
|
2105
|
+
check_watchdog_registry()
|
|
2106
|
+
check_snapshot_sync()
|
|
2107
|
+
check_restore_activity()
|
|
2108
|
+
check_bad_responses()
|
|
2109
|
+
check_runtime_preflight()
|
|
2110
|
+
run_watchdog_smoke()
|
|
2111
|
+
check_watchdog_smoke()
|
|
2112
|
+
check_cognitive_health()
|
|
2113
|
+
run_mechanical_autofixes()
|
|
2114
|
+
|
|
2115
|
+
errors = sum(1 for f in findings if f["severity"] == "ERROR")
|
|
2116
|
+
warns = sum(1 for f in findings if f["severity"] == "WARN")
|
|
2117
|
+
infos = sum(1 for f in findings if f["severity"] == "INFO")
|
|
2118
|
+
log(f"Stage A complete: {errors} errors, {warns} warnings, {infos} info")
|
|
2119
|
+
|
|
2120
|
+
# Write raw summary (backward compatible) + horizon rollups
|
|
2121
|
+
summary_payload = {
|
|
2122
|
+
"timestamp": datetime.now().isoformat(),
|
|
2123
|
+
"findings": findings,
|
|
2124
|
+
"counts": {"error": errors, "warn": warns, "info": infos},
|
|
2125
|
+
"date_label": datetime.now().strftime("%Y-%m-%d"),
|
|
2126
|
+
}
|
|
2127
|
+
summary_file = LOG_DIR / "self-audit-summary.json"
|
|
2128
|
+
summary_file.write_text(json.dumps(summary_payload, indent=2))
|
|
2129
|
+
write_horizon_summaries(summary_payload)
|
|
2130
|
+
|
|
2131
|
+
# Stage B: CLI interpretation (graceful fallback if CLI unavailable)
|
|
2132
|
+
cli_ok = interpret_findings(findings)
|
|
2133
|
+
if not cli_ok:
|
|
2134
|
+
log("Stage B: CLI unavailable or failed. Stage A results saved to self-audit-summary.json.")
|
|
2135
|
+
|
|
2136
|
+
# Register for catch-up
|
|
2137
|
+
try:
|
|
2138
|
+
state_file = NEXO_HOME / "operations" / ".catchup-state.json"
|
|
2139
|
+
st = json.loads(state_file.read_text()) if state_file.exists() else {}
|
|
2140
|
+
st["self-audit"] = datetime.now().isoformat()
|
|
2141
|
+
state_file.write_text(json.dumps(st, indent=2))
|
|
2142
|
+
except Exception:
|
|
2143
|
+
pass
|
|
2144
|
+
|
|
2145
|
+
if errors or warns:
|
|
2146
|
+
log(
|
|
2147
|
+
f"Self-audit completed with findings: {errors} errors, {warns} warnings, {infos} info. "
|
|
2148
|
+
f"Summary written to {summary_file}."
|
|
2149
|
+
)
|
|
2150
|
+
else:
|
|
2151
|
+
log(
|
|
2152
|
+
f"Self-audit completed cleanly: {errors} errors, {warns} warnings, {infos} info. "
|
|
2153
|
+
f"Summary written to {summary_file}."
|
|
2154
|
+
)
|
|
2155
|
+
|
|
2156
|
+
log("=" * 60)
|
|
2157
|
+
return 0
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
if __name__ == "__main__":
|
|
2161
|
+
sys.exit(main())
|