nexo-brain 5.3.20 → 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.
Files changed (210) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/auto_update.py +11 -8
  4. package/src/dashboard/static/favicon 2.svg +32 -0
  5. package/src/dashboard/static/nexo-logo 2.png +0 -0
  6. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  7. package/src/dashboard/static/style 2.css +2458 -0
  8. package/src/dashboard/templates/adaptive 2.html +118 -0
  9. package/src/dashboard/templates/artifacts 2.html +133 -0
  10. package/src/dashboard/templates/backups 2.html +136 -0
  11. package/src/dashboard/templates/base 2.html +417 -0
  12. package/src/dashboard/templates/calendar 2.html +591 -0
  13. package/src/dashboard/templates/chat 2.html +356 -0
  14. package/src/dashboard/templates/claims 2.html +259 -0
  15. package/src/dashboard/templates/cortex 2.html +321 -0
  16. package/src/dashboard/templates/credentials 2.html +128 -0
  17. package/src/dashboard/templates/crons 2.html +370 -0
  18. package/src/dashboard/templates/dashboard 2.html +494 -0
  19. package/src/dashboard/templates/dreams 2.html +252 -0
  20. package/src/dashboard/templates/email 2.html +160 -0
  21. package/src/dashboard/templates/evolution 2.html +189 -0
  22. package/src/dashboard/templates/feed 2.html +249 -0
  23. package/src/dashboard/templates/followup_health 2.html +170 -0
  24. package/src/dashboard/templates/graph 2.html +201 -0
  25. package/src/dashboard/templates/guard 2.html +259 -0
  26. package/src/dashboard/templates/inbox 2.html +251 -0
  27. package/src/dashboard/templates/memory 2.html +420 -0
  28. package/src/dashboard/templates/operations 2.html +608 -0
  29. package/src/dashboard/templates/plugins 2.html +185 -0
  30. package/src/dashboard/templates/protocol 2.html +199 -0
  31. package/src/dashboard/templates/rules 2.html +246 -0
  32. package/src/dashboard/templates/sentiment 2.html +247 -0
  33. package/src/dashboard/templates/sessions 2.html +218 -0
  34. package/src/dashboard/templates/skills 2.html +329 -0
  35. package/src/dashboard/templates/somatic 2.html +73 -0
  36. package/src/dashboard/templates/triggers 2.html +133 -0
  37. package/src/dashboard/templates/trust 2.html +360 -0
  38. package/src/db/__init__ 2.py +259 -0
  39. package/src/db/_core 2.py +437 -0
  40. package/src/db/_credentials 2.py +124 -0
  41. package/src/db/_episodic 2.py +762 -0
  42. package/src/db/_evolution 2.py +54 -0
  43. package/src/db/_fts 2.py +406 -0
  44. package/src/db/_goal_profiles 2.py +376 -0
  45. package/src/db/_hot_context 2.py +660 -0
  46. package/src/db/_outcomes 2.py +800 -0
  47. package/src/db/_personal_scripts 2.py +582 -0
  48. package/src/db/_sessions 2.py +330 -0
  49. package/src/db/_tasks 2.py +91 -0
  50. package/src/db/_watchers 2.py +173 -0
  51. package/src/doctor/formatters 2.py +52 -0
  52. package/src/doctor/models 2.py +69 -0
  53. package/src/doctor/planes 2.py +87 -0
  54. package/src/doctor/providers/__init__ 2.py +1 -0
  55. package/src/doctor/providers/deep 2.py +367 -0
  56. package/src/evolution_cycle 2.py +519 -0
  57. package/src/hooks/auto_capture 2.py +208 -0
  58. package/src/hooks/caffeinate-guard 2.sh +8 -0
  59. package/src/hooks/capture-session 2.sh +21 -0
  60. package/src/hooks/capture-tool-logs 2.sh +158 -0
  61. package/src/hooks/daily-briefing-check 2.sh +33 -0
  62. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  63. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  64. package/src/hooks/inbox-hook 2.sh +76 -0
  65. package/src/hooks/post-compact 2.sh +152 -0
  66. package/src/hooks/pre-compact 2.sh +169 -0
  67. package/src/hooks/protocol-guardrail 2.sh +10 -0
  68. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  69. package/src/hooks/session-stop 2.sh +52 -0
  70. package/src/kg_populate 2.py +292 -0
  71. package/src/maintenance 2.py +53 -0
  72. package/src/memory_backends 2.py +71 -0
  73. package/src/migrate_embeddings 2.py +124 -0
  74. package/src/nexo_sdk 2.py +103 -0
  75. package/src/observability 2.py +199 -0
  76. package/src/plugin_loader 2.py +217 -0
  77. package/src/plugins/__init__ 2.py +0 -0
  78. package/src/plugins/artifact_registry 2.py +450 -0
  79. package/src/plugins/backup 2.py +127 -0
  80. package/src/plugins/claims_tools 2.py +119 -0
  81. package/src/plugins/cognitive_memory 2.py +609 -0
  82. package/src/plugins/core_rules 2.py +252 -0
  83. package/src/plugins/cortex 2.py +1155 -0
  84. package/src/plugins/entities 2.py +67 -0
  85. package/src/plugins/episodic_memory 2.py +560 -0
  86. package/src/plugins/evolution 2.py +167 -0
  87. package/src/plugins/goal_engine 2.py +142 -0
  88. package/src/plugins/guard 2.py +862 -0
  89. package/src/plugins/impact 2.py +29 -0
  90. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  91. package/src/plugins/media_memory_tools 2.py +98 -0
  92. package/src/plugins/memory_export 2.py +196 -0
  93. package/src/plugins/outcomes 2.py +130 -0
  94. package/src/plugins/personal_scripts 2.py +117 -0
  95. package/src/plugins/preferences 2.py +47 -0
  96. package/src/plugins/protocol 2.py +1449 -0
  97. package/src/plugins/simple_api 2.py +106 -0
  98. package/src/plugins/skills 2.py +341 -0
  99. package/src/plugins/state_watchers 2.py +79 -0
  100. package/src/plugins/update 2.py +986 -0
  101. package/src/plugins/user_state_tools 2.py +43 -0
  102. package/src/plugins/workflow 2.py +588 -0
  103. package/src/protocol_settings 2.py +59 -0
  104. package/src/public_contribution 2.py +466 -0
  105. package/src/public_evolution_queue 2.py +241 -0
  106. package/src/requirements 2.txt +14 -0
  107. package/src/retroactive_learnings 2.py +373 -0
  108. package/src/rules/__init__ 2.py +0 -0
  109. package/src/rules/core-rules 2.json +331 -0
  110. package/src/rules/migrate 2.py +207 -0
  111. package/src/runtime_power 2.py +874 -0
  112. package/src/script_registry 2.py +1559 -0
  113. package/src/scripts/check-context 2.py +272 -0
  114. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  115. package/src/scripts/deep-sleep/collect 2.py +928 -0
  116. package/src/scripts/deep-sleep/extract 2.py +330 -0
  117. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  118. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  119. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  120. package/src/scripts/nexo-agent-run 2.py +75 -0
  121. package/src/scripts/nexo-auto-update 2.py +6 -0
  122. package/src/scripts/nexo-backup 2.sh +25 -0
  123. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  124. package/src/scripts/nexo-catchup 2.py +300 -0
  125. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  126. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  127. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  128. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  129. package/src/scripts/nexo-dashboard 2.sh +29 -0
  130. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  131. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  132. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  133. package/src/scripts/nexo-hook-record 2.py +42 -0
  134. package/src/scripts/nexo-immune 2.py +936 -0
  135. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  136. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  137. package/src/scripts/nexo-install 2.py +6 -0
  138. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  139. package/src/scripts/nexo-learning-validator 2.py +266 -0
  140. package/src/scripts/nexo-migrate 2.py +260 -0
  141. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  142. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  143. package/src/scripts/nexo-pre-commit 2.py +120 -0
  144. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  145. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  146. package/src/scripts/nexo-reflection 2.py +256 -0
  147. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  148. package/src/scripts/nexo-sleep 2.py +631 -0
  149. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  150. package/src/scripts/nexo-sync-clients 2.py +16 -0
  151. package/src/scripts/nexo-synthesis 2.py +475 -0
  152. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  153. package/src/scripts/nexo-update 2.sh +306 -0
  154. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  155. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  156. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  157. package/src/server 2.py +1296 -0
  158. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  159. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  160. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  161. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  162. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  163. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  164. package/src/skills/run-release-final-audit/script 2.py +259 -0
  165. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  166. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  167. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  168. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  169. package/src/skills_runtime 2.py +932 -0
  170. package/src/state_watchers_runtime 2.py +475 -0
  171. package/src/storage_router 2.py +32 -0
  172. package/src/system_catalog 2.py +786 -0
  173. package/src/tools_coordination 2.py +103 -0
  174. package/src/tools_credentials 2.py +68 -0
  175. package/src/tools_drive 2.py +487 -0
  176. package/src/tools_hot_context 2.py +163 -0
  177. package/src/tools_learnings 2.py +612 -0
  178. package/src/tools_menu 2.py +229 -0
  179. package/src/tools_reminders 2.py +88 -0
  180. package/src/tools_reminders_crud 2.py +363 -0
  181. package/src/tools_sessions 2.py +1054 -0
  182. package/src/tools_system_catalog 2.py +19 -0
  183. package/src/tools_task_history 2.py +57 -0
  184. package/src/tools_transcripts 2.py +98 -0
  185. package/src/transcript_utils 2.py +412 -0
  186. package/src/user_context 2.py +46 -0
  187. package/src/user_data_portability 2.py +328 -0
  188. package/src/user_state_model 2.py +170 -0
  189. package/templates/CLAUDE.md 2.template +108 -0
  190. package/templates/CODEX.AGENTS.md 2.template +66 -0
  191. package/templates/launchagents/README 2.md +132 -0
  192. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  193. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  195. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  196. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  198. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  199. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  200. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  201. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  202. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  203. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  204. package/templates/nexo_helper 2.py +301 -0
  205. package/templates/openclaw 2.json +13 -0
  206. package/templates/plugin-template 2.py +40 -0
  207. package/templates/script-template 2.py +59 -0
  208. package/templates/script-template 2.sh +13 -0
  209. package/templates/skill-script-template 2.py +48 -0
  210. 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())