nexo-brain 2.7.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -12
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +295 -7
  6. package/src/cli.py +111 -0
  7. package/src/client_preferences.py +99 -1
  8. package/src/client_sync.py +207 -3
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +141 -1
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/protocol.html +199 -0
  14. package/src/db/__init__.py +23 -1
  15. package/src/db/_learnings.py +31 -4
  16. package/src/db/_personal_scripts.py +12 -0
  17. package/src/db/_protocol.py +303 -0
  18. package/src/db/_schema.py +248 -0
  19. package/src/db/_watchers.py +173 -0
  20. package/src/db/_workflow.py +952 -0
  21. package/src/doctor/providers/boot.py +45 -19
  22. package/src/doctor/providers/runtime.py +923 -8
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/requirements.txt +1 -0
  38. package/src/script_registry.py +142 -0
  39. package/src/scripts/deep-sleep/apply_findings.py +204 -0
  40. package/src/scripts/deep-sleep/collect.py +49 -4
  41. package/src/scripts/nexo-agent-run.py +2 -0
  42. package/src/scripts/nexo-daily-self-audit.py +843 -5
  43. package/src/scripts/nexo-evolution-run.py +343 -1
  44. package/src/server.py +92 -6
  45. package/src/skills_runtime.py +151 -0
  46. package/src/state_watchers_runtime.py +334 -0
  47. package/src/tools_learnings.py +345 -7
  48. package/src/tools_sessions.py +183 -0
  49. package/templates/CLAUDE.md.template +9 -1
  50. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -19,6 +19,7 @@ Runs via launchd at 7:00 AM daily.
19
19
  import json
20
20
  import hashlib
21
21
  import os
22
+ import re
22
23
  import sqlite3
23
24
  import subprocess
24
25
  import sys
@@ -37,6 +38,8 @@ from agent_runner import AutomationBackendUnavailableError, run_automation_promp
37
38
 
38
39
  LOG_DIR = NEXO_HOME / "logs"
39
40
  LOG_DIR.mkdir(parents=True, exist_ok=True)
41
+ AUDIT_HISTORY_DIR = LOG_DIR / "self-audit"
42
+ AUDIT_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
40
43
  LOG_FILE = LOG_DIR / "self-audit.log"
41
44
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
42
45
  # Configure your main project repo to check for uncommitted changes (optional)
@@ -85,6 +88,314 @@ def finding(severity, area, msg):
85
88
  log(f" [{severity}] {area}: {msg}")
86
89
 
87
90
 
91
+ def _parse_iso_dt(value: str | None) -> datetime | None:
92
+ text = str(value or "").strip()
93
+ if not text:
94
+ return None
95
+ try:
96
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ def _area_summary_from_daily_summaries(summaries: list[dict]) -> tuple[list[dict], list[str]]:
102
+ per_area: dict[str, dict] = {}
103
+ area_days: dict[str, set[str]] = {}
104
+ for item in summaries:
105
+ day = str(item.get("date_label") or item.get("timestamp") or "")[:10]
106
+ for finding_item in item.get("findings", []):
107
+ area = str(finding_item.get("area") or "unknown").strip() or "unknown"
108
+ severity = str(finding_item.get("severity") or "INFO").strip().upper()
109
+ bucket = per_area.setdefault(area, {"area": area, "count": 0, "error": 0, "warn": 0, "info": 0})
110
+ bucket["count"] += 1
111
+ if severity == "ERROR":
112
+ bucket["error"] += 1
113
+ elif severity == "WARN":
114
+ bucket["warn"] += 1
115
+ else:
116
+ bucket["info"] += 1
117
+ if day:
118
+ area_days.setdefault(area, set()).add(day)
119
+ top_areas = sorted(
120
+ per_area.values(),
121
+ key=lambda item: (-item["count"], -item["error"], item["area"]),
122
+ )[:10]
123
+ repeated = sorted(area for area, days in area_days.items() if len(days) >= 2)
124
+ return top_areas, repeated
125
+
126
+
127
+ def _load_recent_daily_summaries(reference_dt: datetime, window_days: int) -> list[dict]:
128
+ summaries: list[dict] = []
129
+ cutoff = reference_dt - timedelta(days=window_days - 1)
130
+ for path in sorted(AUDIT_HISTORY_DIR.glob("*-daily-summary.json")):
131
+ try:
132
+ payload = json.loads(path.read_text())
133
+ except Exception:
134
+ continue
135
+ ts = _parse_iso_dt(payload.get("timestamp"))
136
+ if not ts:
137
+ continue
138
+ if ts.date() < cutoff.date() or ts.date() > reference_dt.date():
139
+ continue
140
+ summaries.append(payload)
141
+ summaries.sort(key=lambda item: str(item.get("timestamp") or ""))
142
+ return summaries
143
+
144
+
145
+ def write_horizon_summaries(summary_payload: dict, *, now: datetime | None = None) -> dict:
146
+ now = now or datetime.now()
147
+ daily_payload = dict(summary_payload)
148
+ daily_payload.setdefault("date_label", now.strftime("%Y-%m-%d"))
149
+ daily_file = AUDIT_HISTORY_DIR / f"{daily_payload['date_label']}-daily-summary.json"
150
+ daily_file.write_text(json.dumps(daily_payload, indent=2))
151
+
152
+ outputs = {
153
+ "daily_file": str(daily_file),
154
+ "weekly_file": "",
155
+ "weekly_latest": "",
156
+ "monthly_file": "",
157
+ "monthly_latest": "",
158
+ }
159
+ for kind, window_days in (("weekly", 7), ("monthly", 30)):
160
+ recent = _load_recent_daily_summaries(now, window_days)
161
+ total_counts = {"error": 0, "warn": 0, "info": 0}
162
+ for item in recent:
163
+ counts = item.get("counts") or {}
164
+ for key in total_counts:
165
+ total_counts[key] += int(counts.get(key) or 0)
166
+ top_areas, repeated_areas = _area_summary_from_daily_summaries(recent)
167
+ if kind == "weekly":
168
+ year, week, _ = now.isocalendar()
169
+ label = f"{year}-W{week:02d}"
170
+ else:
171
+ label = now.strftime("%Y-%m")
172
+ rollup = {
173
+ "timestamp": now.isoformat(),
174
+ "label": label,
175
+ "horizon": kind,
176
+ "window_days": window_days,
177
+ "source_daily_summaries": len(recent),
178
+ "days": [item.get("date_label") for item in recent if item.get("date_label")],
179
+ "counts": total_counts,
180
+ "top_areas": top_areas,
181
+ "repeated_areas": repeated_areas,
182
+ }
183
+ dated_file = AUDIT_HISTORY_DIR / f"{label}-{kind}-summary.json"
184
+ latest_file = LOG_DIR / f"self-audit-{kind}-summary.json"
185
+ dated_file.write_text(json.dumps(rollup, indent=2))
186
+ latest_file.write_text(json.dumps(rollup, indent=2))
187
+ outputs[f"{kind}_file"] = str(dated_file)
188
+ outputs[f"{kind}_latest"] = str(latest_file)
189
+ return outputs
190
+
191
+
192
+ def _protocol_debt_table_exists(conn: sqlite3.Connection) -> bool:
193
+ row = conn.execute(
194
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='protocol_debt'"
195
+ ).fetchone()
196
+ return bool(row)
197
+
198
+
199
+ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
200
+ row = conn.execute(
201
+ "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
202
+ (table_name,),
203
+ ).fetchone()
204
+ return bool(row)
205
+
206
+
207
+ def _ensure_protocol_debt(conn: sqlite3.Connection, *, debt_type: str, severity: str, evidence: str) -> bool:
208
+ existing = conn.execute(
209
+ """SELECT id
210
+ FROM protocol_debt
211
+ WHERE status = 'open' AND debt_type = ? AND evidence = ?
212
+ LIMIT 1""",
213
+ (debt_type, evidence),
214
+ ).fetchone()
215
+ if existing:
216
+ return False
217
+ conn.execute(
218
+ """INSERT INTO protocol_debt (session_id, task_id, debt_type, severity, evidence)
219
+ VALUES ('', '', ?, ?, ?)""",
220
+ (debt_type, severity, evidence),
221
+ )
222
+ return True
223
+
224
+
225
+ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
226
+ verification: str, reasoning: str, priority: str = "high") -> str:
227
+ if not _table_exists(conn, "followups"):
228
+ return ""
229
+ existing = conn.execute(
230
+ """SELECT id FROM followups
231
+ WHERE status NOT LIKE 'COMPLETED%'
232
+ AND status NOT IN ('DELETED','archived','blocked','waiting')
233
+ AND description = ?
234
+ LIMIT 1""",
235
+ (description,),
236
+ ).fetchone()
237
+ if existing:
238
+ return str(existing["id"])
239
+
240
+ followup_id = f"NF-{prefix}-{hashlib.sha1(description.encode('utf-8')).hexdigest()[:8].upper()}"
241
+ now_epoch = int(datetime.now().timestamp())
242
+ columns = {row["name"] for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
243
+ values = {
244
+ "id": followup_id,
245
+ "date": "",
246
+ "description": description,
247
+ "verification": verification,
248
+ "status": "PENDING",
249
+ "reasoning": reasoning,
250
+ "recurrence": None,
251
+ "created_at": now_epoch,
252
+ "updated_at": now_epoch,
253
+ }
254
+ if "priority" in columns:
255
+ values["priority"] = priority
256
+
257
+ ordered_columns = [name for name in values.keys() if name in columns]
258
+ placeholders = ", ".join("?" for _ in ordered_columns)
259
+ conn.execute(
260
+ f"INSERT INTO followups ({', '.join(ordered_columns)}) VALUES ({placeholders})",
261
+ [values[name] for name in ordered_columns],
262
+ )
263
+ return followup_id
264
+
265
+
266
+ TOPIC_STOPWORDS = {
267
+ "the", "and", "for", "with", "from", "that", "this", "into", "about", "after",
268
+ "before", "again", "need", "needs", "task", "tasks", "work", "working",
269
+ "continue", "continuing", "review", "check", "checks", "make", "making",
270
+ "fix", "fixes", "build", "create", "created", "update", "updates", "ship",
271
+ "prepare", "finish", "open", "another", "around", "must",
272
+ }
273
+
274
+
275
+ def _topic_signature(text: str) -> str:
276
+ tokens = [
277
+ token for token in re.findall(r"[a-z0-9]+", (text or "").lower())
278
+ if len(token) >= 3 and token not in TOPIC_STOPWORDS
279
+ ]
280
+ return " ".join(tokens[:2])
281
+
282
+
283
+ REPAIR_KEYWORDS = {
284
+ "fix", "fixed", "bug", "bugs", "regression", "regressions", "repair", "repaired",
285
+ "correct", "corrected", "correction", "typo", "hotfix", "patch", "patched",
286
+ "resolve", "resolved", "failure", "error", "issue", "broken", "broke",
287
+ }
288
+
289
+
290
+ def _split_changed_files(raw: str) -> list[str]:
291
+ text = str(raw or "").strip()
292
+ if not text:
293
+ return []
294
+ if text.startswith("["):
295
+ try:
296
+ value = json.loads(text)
297
+ except Exception:
298
+ value = []
299
+ if isinstance(value, list):
300
+ return [str(item).strip() for item in value if str(item).strip()]
301
+ parts = re.split(r"[\n,;]+", text)
302
+ return [part.strip() for part in parts if part.strip()]
303
+
304
+
305
+ def _looks_like_repair_change(text: str) -> bool:
306
+ tokens = {token for token in re.findall(r"[a-z0-9]+", (text or "").lower()) if len(token) >= 3}
307
+ return bool(tokens & REPAIR_KEYWORDS)
308
+
309
+
310
+ def _parse_mixed_datetime(value) -> datetime | None:
311
+ if value in (None, ""):
312
+ return None
313
+ if isinstance(value, (int, float)):
314
+ try:
315
+ return datetime.fromtimestamp(float(value))
316
+ except Exception:
317
+ return None
318
+ text = str(value).strip()
319
+ if not text:
320
+ return None
321
+ try:
322
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None)
323
+ except Exception:
324
+ return None
325
+
326
+
327
+ def _learning_matches_change(row: sqlite3.Row, files: list[str], change_text: str, created_at: datetime | None) -> bool:
328
+ learning_text = " ".join(
329
+ str(row[key] or "")
330
+ for key in ("title", "content", "reasoning", "prevention")
331
+ if key in row.keys()
332
+ )
333
+ applies_to = str(row["applies_to"] or "").strip() if "applies_to" in row.keys() else ""
334
+ if files and applies_to:
335
+ applies_tokens = {item for item in _split_changed_files(applies_to)}
336
+ if any(file_path in applies_tokens or Path(file_path).name in applies_to for file_path in files):
337
+ return True
338
+ change_signature = _topic_signature(change_text)
339
+ learning_signature = _topic_signature(learning_text)
340
+ if change_signature and learning_signature and change_signature == learning_signature:
341
+ return True
342
+ if change_signature and change_signature in learning_text.lower():
343
+ return True
344
+
345
+ updated_at = _parse_mixed_datetime(row["updated_at"] if "updated_at" in row.keys() else None)
346
+ if created_at and updated_at:
347
+ delta = updated_at - created_at
348
+ if timedelta(hours=-1) <= delta <= timedelta(days=3):
349
+ return True
350
+ return False
351
+
352
+
353
+ def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
354
+ try:
355
+ from tools_learnings import find_conflicting_active_learning, handle_learning_add
356
+ except Exception as exc:
357
+ return {"ok": False, "error": f"learning runtime unavailable: {exc}"}
358
+
359
+ files = _split_changed_files(str(row["files"] or ""))
360
+ title_seed = str(row["what_changed"] or row["why"] or "").strip() or f"Repair change #{row['id']}"
361
+ title = title_seed[:120]
362
+ content_parts = [
363
+ str(row["what_changed"] or "").strip(),
364
+ str(row["why"] or "").strip(),
365
+ ]
366
+ if files:
367
+ content_parts.append(f"Affected files: {', '.join(files[:5])}")
368
+ content = " ".join(part for part in content_parts if part).strip()
369
+ if not content:
370
+ content = f"Repair-oriented change log entry #{row['id']} required a canonical learning."
371
+ applies_to = ",".join(files)
372
+ conflicting = find_conflicting_active_learning(
373
+ category="nexo-ops",
374
+ title=title,
375
+ content=content,
376
+ applies_to=applies_to,
377
+ )
378
+ supersedes_id = int(conflicting["id"]) if conflicting else 0
379
+ response = handle_learning_add(
380
+ category="nexo-ops",
381
+ title=title,
382
+ content=content,
383
+ reasoning=f"Auto-captured by daily self-audit from repair change #{row['id']}.",
384
+ prevention="Review the canonical repair learning before touching the affected file again." if applies_to else "",
385
+ applies_to=applies_to,
386
+ priority="high",
387
+ supersedes_id=supersedes_id,
388
+ )
389
+ match = re.search(r"Learning #(\d+)", response)
390
+ if match and "ERROR:" not in response:
391
+ return {
392
+ "ok": True,
393
+ "learning_id": int(match.group(1)),
394
+ "response": response,
395
+ }
396
+ return {"ok": False, "error": response}
397
+
398
+
88
399
  # ═══════════════════════════════════════════════════════════════════════════════
89
400
  # Stage A: Mechanical checks (UNCHANGED from v1 — all 18 checks)
90
401
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -253,6 +564,396 @@ def check_memory_reviews():
253
564
  finding("INFO", "memory", f"{total} reviews due")
254
565
 
255
566
 
567
+ def check_learning_contradictions():
568
+ if not NEXO_DB.exists():
569
+ return
570
+ conn = sqlite3.connect(str(NEXO_DB))
571
+ conn.row_factory = sqlite3.Row
572
+ if not _table_exists(conn, "learnings"):
573
+ conn.close()
574
+ return
575
+
576
+ from tools_learnings import _applies_overlap, _looks_contradictory
577
+
578
+ rows = conn.execute(
579
+ """SELECT id, title, content, applies_to
580
+ FROM learnings
581
+ WHERE status = 'active' AND COALESCE(applies_to, '') != ''
582
+ ORDER BY updated_at DESC, id DESC
583
+ LIMIT 200"""
584
+ ).fetchall()
585
+ contradictions: list[tuple[sqlite3.Row, sqlite3.Row]] = []
586
+ for index, left in enumerate(rows):
587
+ for right in rows[index + 1:]:
588
+ if not _applies_overlap(left["applies_to"], right["applies_to"]):
589
+ continue
590
+ if not _looks_contradictory(
591
+ f"{left['title']} {left['content']}",
592
+ f"{right['title']} {right['content']}",
593
+ ):
594
+ continue
595
+ contradictions.append((left, right))
596
+
597
+ if contradictions:
598
+ finding("ERROR", "contradictions", f"{len(contradictions)} contradictory active learning pair(s)")
599
+ for left, right in contradictions[:5]:
600
+ description = (
601
+ f"Resolve contradictory active learnings #{left['id']} and #{right['id']} "
602
+ f"for {left['applies_to'] or right['applies_to']}"
603
+ )
604
+ reasoning = (
605
+ "Daily self-audit found two active canonical rules that contradict each other. "
606
+ "One rule must be superseded or reconciled before the next edit repeats the error."
607
+ )
608
+ _ensure_followup(
609
+ conn,
610
+ prefix="CONTRADICTION",
611
+ description=description,
612
+ verification="One canonical learning remains active and the conflicting rule is superseded or archived",
613
+ reasoning=reasoning,
614
+ priority="critical",
615
+ )
616
+ conn.commit()
617
+ conn.close()
618
+
619
+
620
+ def check_error_memory_loop():
621
+ if not NEXO_DB.exists():
622
+ return
623
+ conn = sqlite3.connect(str(NEXO_DB))
624
+ conn.row_factory = sqlite3.Row
625
+ if not _table_exists(conn, "protocol_tasks"):
626
+ conn.close()
627
+ return
628
+
629
+ rows = conn.execute(
630
+ """SELECT task_id, goal, area, files, status, learning_id
631
+ FROM protocol_tasks
632
+ WHERE status IN ('failed', 'blocked')
633
+ AND (learning_id IS NULL OR learning_id = 0)
634
+ AND opened_at >= datetime('now', '-30 days')
635
+ ORDER BY opened_at DESC"""
636
+ ).fetchall()
637
+
638
+ grouped: dict[str, list[sqlite3.Row]] = {}
639
+ for row in rows:
640
+ files = str(row["files"] or "").strip()
641
+ signature = files if files and files != "[]" else (row["area"] or row["goal"] or "general")
642
+ grouped.setdefault(signature[:220], []).append(row)
643
+
644
+ repeated = {signature: items for signature, items in grouped.items() if len(items) >= 2}
645
+ if repeated:
646
+ finding("WARN", "prevention", f"{len(repeated)} repeated failure cluster(s) still lack canonical prevention learnings")
647
+ for signature, items in list(repeated.items())[:5]:
648
+ description = (
649
+ f"Mine a canonical prevention learning from repeated failed/blocked protocol tasks around {signature}"
650
+ )
651
+ reasoning = (
652
+ f"Daily self-audit found {len(items)} failed/blocked protocol tasks without a linked learning. "
653
+ "Turn the repeated failure into a prevention rule before it repeats again."
654
+ )
655
+ _ensure_followup(
656
+ conn,
657
+ prefix="PREVENTION",
658
+ description=description,
659
+ verification="Canonical prevention learning captured and linked to the repeated failure pattern",
660
+ reasoning=reasoning,
661
+ priority="high",
662
+ )
663
+ conn.commit()
664
+ conn.close()
665
+
666
+
667
+ def check_repair_changes_missing_learning_capture():
668
+ if not NEXO_DB.exists():
669
+ return
670
+ conn = sqlite3.connect(str(NEXO_DB))
671
+ conn.row_factory = sqlite3.Row
672
+ if not _table_exists(conn, "change_log") or not _table_exists(conn, "learnings"):
673
+ conn.close()
674
+ return
675
+
676
+ learning_rows = conn.execute(
677
+ """SELECT *
678
+ FROM learnings
679
+ WHERE COALESCE(status, 'active') != 'deleted'
680
+ ORDER BY updated_at DESC, created_at DESC
681
+ LIMIT 300"""
682
+ ).fetchall()
683
+ if not learning_rows:
684
+ learning_rows = []
685
+
686
+ rows = conn.execute(
687
+ """SELECT id, files, what_changed, why, created_at
688
+ FROM change_log
689
+ WHERE created_at >= datetime('now', '-14 days')
690
+ ORDER BY created_at DESC
691
+ LIMIT 200"""
692
+ ).fetchall()
693
+ missing: list[sqlite3.Row] = []
694
+ for row in rows:
695
+ change_text = f"{row['what_changed'] or ''} {row['why'] or ''}".strip()
696
+ if not _looks_like_repair_change(change_text):
697
+ continue
698
+ files = _split_changed_files(str(row["files"] or ""))
699
+ created_at = _parse_mixed_datetime(row["created_at"])
700
+ if any(_learning_matches_change(learning, files, change_text, created_at) for learning in learning_rows):
701
+ continue
702
+ missing.append(row)
703
+
704
+ if missing:
705
+ auto_captured = 0
706
+ unresolved: list[sqlite3.Row] = []
707
+ for row in missing:
708
+ captured = _attempt_repair_learning_auto_capture(row)
709
+ if captured.get("ok"):
710
+ auto_captured += 1
711
+ continue
712
+ unresolved.append(row)
713
+
714
+ if unresolved:
715
+ finding(
716
+ "WARN",
717
+ "learning-capture",
718
+ f"{len(unresolved)} repair/logged fix change(s) still lack linked learnings "
719
+ f"after {auto_captured} self-audit auto-capture(s)",
720
+ )
721
+ else:
722
+ finding(
723
+ "INFO",
724
+ "learning-capture",
725
+ f"Self-audit auto-captured {auto_captured} missing repair learning(s)",
726
+ )
727
+
728
+ for row in unresolved[:5]:
729
+ files = _split_changed_files(str(row["files"] or ""))
730
+ target = files[0] if files else str(row["what_changed"] or "recent repair")[:120]
731
+ evidence = (
732
+ f"Repair-oriented change log entry #{row['id']} on {target} has no nearby linked learning capture."
733
+ )
734
+ _ensure_protocol_debt(
735
+ conn,
736
+ debt_type="repair_change_without_learning_capture",
737
+ severity="warn",
738
+ evidence=evidence,
739
+ )
740
+ _ensure_followup(
741
+ conn,
742
+ prefix="LEARNCAP",
743
+ description=f"Capture canonical learning for repair change touching {target}",
744
+ verification="A learning exists with applies_to/topic linked to the repair change",
745
+ reasoning="Daily self-audit found a repair/fix change log entry with no durable learning attached.",
746
+ priority="high",
747
+ )
748
+ conn.commit()
749
+ conn.close()
750
+
751
+
752
+ def check_unformalized_mentions():
753
+ if not NEXO_DB.exists():
754
+ return
755
+ conn = sqlite3.connect(str(NEXO_DB))
756
+ conn.row_factory = sqlite3.Row
757
+ if not _table_exists(conn, "protocol_tasks"):
758
+ conn.close()
759
+ return
760
+
761
+ rows = conn.execute(
762
+ """SELECT goal, area, learning_id, followup_id
763
+ FROM protocol_tasks
764
+ WHERE opened_at >= datetime('now', '-30 days')
765
+ AND COALESCE(goal, '') != ''
766
+ ORDER BY opened_at DESC"""
767
+ ).fetchall()
768
+ if not rows:
769
+ conn.close()
770
+ return
771
+
772
+ formalized_topics: set[str] = set()
773
+ if _table_exists(conn, "workflow_goals"):
774
+ goal_rows = conn.execute(
775
+ """SELECT title, objective
776
+ FROM workflow_goals
777
+ WHERE status NOT IN ('abandoned', 'cancelled')"""
778
+ ).fetchall()
779
+ for row in goal_rows:
780
+ for candidate in (row["title"], row["objective"]):
781
+ signature = _topic_signature(str(candidate or ""))
782
+ if signature:
783
+ formalized_topics.add(signature)
784
+
785
+ repeated: dict[tuple[str, str], list[sqlite3.Row]] = {}
786
+ for row in rows:
787
+ if row["learning_id"] or str(row["followup_id"] or "").strip():
788
+ continue
789
+ signature = _topic_signature(str(row["goal"] or ""))
790
+ if not signature or signature in formalized_topics:
791
+ continue
792
+ area = str(row["area"] or "general").strip() or "general"
793
+ repeated.setdefault((area, signature), []).append(row)
794
+
795
+ loose_topics = {
796
+ key: items
797
+ for key, items in repeated.items()
798
+ if len(items) >= 2
799
+ }
800
+ if loose_topics:
801
+ finding("WARN", "formalization", f"{len(loose_topics)} repeated topic(s) keep being mentioned without durable formalization")
802
+ for (area, signature), items in list(loose_topics.items())[:5]:
803
+ sample_goal = str(items[0]["goal"] or "").strip()[:120]
804
+ description = (
805
+ f"Formalize repeated unresolved theme in {area}: '{sample_goal}' "
806
+ f"appears {len(items)} times without a durable goal, followup, or learning."
807
+ )
808
+ reasoning = (
809
+ "Daily self-audit found the same theme recurring across protocol tasks without being "
810
+ "converted into a workflow goal, followup, or learning. Formalize it before it keeps resurfacing."
811
+ )
812
+ _ensure_followup(
813
+ conn,
814
+ prefix="FORMALIZE",
815
+ description=description,
816
+ verification="Theme converted into a durable goal, followup, or canonical learning",
817
+ reasoning=reasoning,
818
+ priority="high",
819
+ )
820
+ conn.commit()
821
+ conn.close()
822
+
823
+
824
+ def check_automation_opportunities():
825
+ if not NEXO_DB.exists():
826
+ return
827
+ conn = sqlite3.connect(str(NEXO_DB))
828
+ conn.row_factory = sqlite3.Row
829
+ if not _table_exists(conn, "protocol_tasks"):
830
+ conn.close()
831
+ return
832
+
833
+ rows = conn.execute(
834
+ """SELECT goal, area, files
835
+ FROM protocol_tasks
836
+ WHERE status = 'done'
837
+ AND closed_at >= datetime('now', '-30 days')
838
+ ORDER BY closed_at DESC"""
839
+ ).fetchall()
840
+ if not rows:
841
+ conn.close()
842
+ return
843
+
844
+ grouped: dict[tuple[str, str], list[sqlite3.Row]] = {}
845
+ for row in rows:
846
+ signature = str(row["files"] or "").strip() or _topic_signature(str(row["goal"] or ""))
847
+ if not signature:
848
+ continue
849
+ area = str(row["area"] or "general").strip() or "general"
850
+ grouped.setdefault((area, signature[:220]), []).append(row)
851
+
852
+ repeated = {
853
+ key: items
854
+ for key, items in grouped.items()
855
+ if len(items) >= 3
856
+ }
857
+ if repeated:
858
+ finding("INFO", "opportunities", f"{len(repeated)} repeated manual pattern(s) are good candidates for skills/scripts")
859
+ for (area, signature), items in list(repeated.items())[:5]:
860
+ sample_goal = str(items[0]["goal"] or "").strip()[:120]
861
+ description = (
862
+ f"Extract a reusable automation for repeated {area} work around '{sample_goal}' "
863
+ f"(seen {len(items)} successful protocol tasks in 30 days)."
864
+ )
865
+ reasoning = (
866
+ "Daily self-audit found repeated successful manual work. Convert it into a skill, script, "
867
+ "or reusable workflow before it keeps consuming operator time."
868
+ )
869
+ _ensure_followup(
870
+ conn,
871
+ prefix="OPPORTUNITY",
872
+ description=description,
873
+ verification="A reusable skill, script, or workflow now covers the repeated manual pattern",
874
+ reasoning=reasoning,
875
+ priority="medium",
876
+ )
877
+ conn.commit()
878
+ conn.close()
879
+
880
+
881
+ def check_state_watchers():
882
+ try:
883
+ import importlib
884
+ import db as _db
885
+ import state_watchers_runtime as _state_watchers_runtime
886
+ except Exception as exc:
887
+ finding("WARN", "watchers", f"state watchers runtime unavailable: {exc}")
888
+ return
889
+ importlib.reload(_db)
890
+ runtime = importlib.reload(_state_watchers_runtime)
891
+ summary = runtime.run_state_watchers(persist=True)
892
+ counts = summary.get("counts") or {}
893
+ if int(counts.get("critical") or 0) > 0:
894
+ finding("ERROR", "watchers", f"{counts.get('critical')} critical state watcher(s)")
895
+ elif int(counts.get("degraded") or 0) > 0:
896
+ finding("WARN", "watchers", f"{counts.get('degraded')} degraded state watcher(s)")
897
+ elif int(summary.get("watcher_count") or 0) > 0:
898
+ finding("INFO", "watchers", f"{summary.get('watcher_count')} state watcher(s) healthy")
899
+
900
+
901
+ def check_memory_quality_scores():
902
+ if not NEXO_DB.exists():
903
+ return
904
+ conn = sqlite3.connect(str(NEXO_DB))
905
+ conn.row_factory = sqlite3.Row
906
+ if not _table_exists(conn, "learnings"):
907
+ conn.close()
908
+ return
909
+ try:
910
+ from tools_learnings import score_learning_quality
911
+ except Exception:
912
+ conn.close()
913
+ return
914
+
915
+ rows = conn.execute(
916
+ """SELECT *
917
+ FROM learnings
918
+ WHERE status = 'active'
919
+ ORDER BY updated_at DESC, id DESC
920
+ LIMIT 200"""
921
+ ).fetchall()
922
+ if not rows:
923
+ conn.close()
924
+ return
925
+
926
+ normalized = [dict(row) for row in rows]
927
+ scored = [(row, score_learning_quality(row, conn)) for row in normalized]
928
+ weak = [(row, quality) for row, quality in scored if quality["score"] < 60]
929
+ fragile_conditioned = [
930
+ (row, quality)
931
+ for row, quality in weak
932
+ if str(row.get("applies_to") or "").strip()
933
+ ]
934
+ if weak:
935
+ finding("WARN", "memory-quality", f"{len(weak)} active learning(s) have low quality scores")
936
+ if fragile_conditioned:
937
+ sample = fragile_conditioned[0][0]
938
+ description = (
939
+ f"Refresh low-quality conditioned learnings; first weak rule is #{sample['id']} "
940
+ f"for {sample['applies_to']}"
941
+ )
942
+ else:
943
+ sample = weak[0][0]
944
+ description = f"Refresh low-quality learnings; first weak rule is #{sample['id']} {sample['title']}"
945
+ _ensure_followup(
946
+ conn,
947
+ prefix="MEMQ",
948
+ description=description,
949
+ verification="Weak active learnings refreshed with stronger reasoning/prevention/applies_to coverage",
950
+ reasoning="Daily self-audit found active learnings with weak quality scores that may mislead retrieval or guard.",
951
+ priority="high" if fragile_conditioned else "medium",
952
+ )
953
+ conn.commit()
954
+ conn.close()
955
+
956
+
256
957
  def _sha256(path):
257
958
  return hashlib.sha256(path.read_bytes()).hexdigest()
258
959
 
@@ -435,6 +1136,131 @@ def check_cognitive_health():
435
1136
  finding("WARN", "cognitive", f"Weekly GC failed: {e}")
436
1137
 
437
1138
 
1139
+ def check_codex_conditioned_file_discipline():
1140
+ try:
1141
+ from doctor.providers.runtime import _recent_codex_conditioned_file_discipline_status
1142
+ except Exception as e:
1143
+ finding("WARN", "codex-discipline", f"Codex discipline audit unavailable: {e}")
1144
+ return
1145
+
1146
+ audit = _recent_codex_conditioned_file_discipline_status()
1147
+ if not audit.get("conditioned_rules"):
1148
+ return
1149
+
1150
+ read_violations = int(audit.get("read_without_protocol") or 0)
1151
+ write_without_protocol = int(audit.get("write_without_protocol") or 0)
1152
+ write_without_guard_ack = int(audit.get("write_without_guard_ack") or 0)
1153
+ delete_without_protocol = int(audit.get("delete_without_protocol") or 0)
1154
+ delete_without_guard_ack = int(audit.get("delete_without_guard_ack") or 0)
1155
+ total_violations = (
1156
+ read_violations
1157
+ + write_without_protocol
1158
+ + write_without_guard_ack
1159
+ + delete_without_protocol
1160
+ + delete_without_guard_ack
1161
+ )
1162
+ if total_violations <= 0:
1163
+ return
1164
+
1165
+ created_debts = 0
1166
+ if NEXO_DB.exists():
1167
+ conn = sqlite3.connect(str(NEXO_DB))
1168
+ if _protocol_debt_table_exists(conn):
1169
+ debt_type_map = {
1170
+ "read_without_protocol": ("codex_conditioned_read_without_protocol", "warn"),
1171
+ "write_without_protocol": ("codex_conditioned_write_without_protocol", "error"),
1172
+ "write_without_guard_ack": ("codex_conditioned_write_without_guard_ack", "error"),
1173
+ "delete_without_protocol": ("codex_conditioned_delete_without_protocol", "error"),
1174
+ "delete_without_guard_ack": ("codex_conditioned_delete_without_guard_ack", "error"),
1175
+ }
1176
+ for sample in audit.get("samples", []):
1177
+ debt_info = debt_type_map.get(sample.get("kind"))
1178
+ if not debt_info:
1179
+ continue
1180
+ debt_type, severity = debt_info
1181
+ evidence = (
1182
+ "Codex conditioned-file transcript audit: "
1183
+ f"{sample.get('kind')} {sample.get('file')} via {sample.get('tool')} "
1184
+ f"in {sample.get('session_file')}"
1185
+ )
1186
+ if _ensure_protocol_debt(conn, debt_type=debt_type, severity=severity, evidence=evidence):
1187
+ created_debts += 1
1188
+ conn.commit()
1189
+ conn.close()
1190
+
1191
+ severity = "ERROR" if (write_without_protocol or write_without_guard_ack) else "WARN"
1192
+ message = (
1193
+ "Codex conditioned-file discipline drift: "
1194
+ f"{read_violations} read(s) without protocol/guard, "
1195
+ f"{write_without_protocol} write(s) without protocol, "
1196
+ f"{write_without_guard_ack} write(s) without guard ack, "
1197
+ f"{delete_without_protocol} delete(s) without protocol, "
1198
+ f"{delete_without_guard_ack} delete(s) without guard ack"
1199
+ )
1200
+ if created_debts:
1201
+ message += f" | opened {created_debts} protocol debt item(s)"
1202
+ finding(severity, "codex-discipline", message)
1203
+
1204
+
1205
+ def check_codex_startup_discipline():
1206
+ try:
1207
+ from doctor.providers.runtime import _recent_codex_session_parity_status
1208
+ except Exception as e:
1209
+ finding("WARN", "codex-startup", f"Codex startup audit unavailable: {e}")
1210
+ return
1211
+
1212
+ audit = _recent_codex_session_parity_status()
1213
+ if not audit.get("files"):
1214
+ return
1215
+
1216
+ samples = audit.get("samples", [])
1217
+ missing_startup = [sample for sample in samples if not sample.get("startup")]
1218
+ missing_heartbeat = [sample for sample in samples if sample.get("startup") and not sample.get("heartbeat")]
1219
+ missing_bootstrap = [
1220
+ sample for sample in samples
1221
+ if sample.get("startup") and sample.get("heartbeat") and not sample.get("bootstrap")
1222
+ ]
1223
+ if not missing_startup and not missing_heartbeat and not missing_bootstrap:
1224
+ return
1225
+
1226
+ created_debts = 0
1227
+ if NEXO_DB.exists():
1228
+ conn = sqlite3.connect(str(NEXO_DB))
1229
+ if _protocol_debt_table_exists(conn):
1230
+ for sample in samples:
1231
+ debt_type = ""
1232
+ severity = "warn"
1233
+ if not sample.get("startup"):
1234
+ debt_type = "codex_session_missing_startup"
1235
+ severity = "error"
1236
+ elif not sample.get("heartbeat"):
1237
+ debt_type = "codex_session_missing_heartbeat"
1238
+ elif not sample.get("bootstrap"):
1239
+ debt_type = "codex_session_missing_bootstrap"
1240
+ if not debt_type:
1241
+ continue
1242
+ evidence = (
1243
+ "Codex session parity audit: "
1244
+ f"{debt_type} in {sample.get('file')} "
1245
+ f"(origin={sample.get('origin') or 'unknown'})"
1246
+ )
1247
+ if _ensure_protocol_debt(conn, debt_type=debt_type, severity=severity, evidence=evidence):
1248
+ created_debts += 1
1249
+ conn.commit()
1250
+ conn.close()
1251
+
1252
+ severity = "ERROR" if missing_startup else "WARN"
1253
+ message = (
1254
+ "Codex startup discipline drift: "
1255
+ f"{len(missing_bootstrap)} session(s) missing bootstrap marker, "
1256
+ f"{len(missing_startup)} missing startup, "
1257
+ f"{len(missing_heartbeat)} missing heartbeat"
1258
+ )
1259
+ if created_debts:
1260
+ message += f" | opened {created_debts} protocol debt item(s)"
1261
+ finding(severity, "codex-startup", message)
1262
+
1263
+
438
1264
  # ═══════════════════════════════════════════════════════════════════════════════
439
1265
  # Stage B: Interpretation (automation backend) — NEW in v2
440
1266
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -533,6 +1359,15 @@ def main():
533
1359
  check_repetition_rate()
534
1360
  check_unused_learnings()
535
1361
  check_memory_reviews()
1362
+ check_learning_contradictions()
1363
+ check_error_memory_loop()
1364
+ check_repair_changes_missing_learning_capture()
1365
+ check_unformalized_mentions()
1366
+ check_automation_opportunities()
1367
+ check_state_watchers()
1368
+ check_memory_quality_scores()
1369
+ check_codex_startup_discipline()
1370
+ check_codex_conditioned_file_discipline()
536
1371
  check_watchdog_registry()
537
1372
  check_snapshot_sync()
538
1373
  check_restore_activity()
@@ -547,13 +1382,16 @@ def main():
547
1382
  infos = sum(1 for f in findings if f["severity"] == "INFO")
548
1383
  log(f"Stage A complete: {errors} errors, {warns} warnings, {infos} info")
549
1384
 
550
- # Write raw summary (backward compatible)
551
- summary_file = LOG_DIR / "self-audit-summary.json"
552
- summary_file.write_text(json.dumps({
1385
+ # Write raw summary (backward compatible) + horizon rollups
1386
+ summary_payload = {
553
1387
  "timestamp": datetime.now().isoformat(),
554
1388
  "findings": findings,
555
- "counts": {"error": errors, "warn": warns, "info": infos}
556
- }, indent=2))
1389
+ "counts": {"error": errors, "warn": warns, "info": infos},
1390
+ "date_label": datetime.now().strftime("%Y-%m-%d"),
1391
+ }
1392
+ summary_file = LOG_DIR / "self-audit-summary.json"
1393
+ summary_file.write_text(json.dumps(summary_payload, indent=2))
1394
+ write_horizon_summaries(summary_payload)
557
1395
 
558
1396
  # Stage B: CLI interpretation (graceful fallback if CLI unavailable)
559
1397
  cli_ok = interpret_findings(findings)