nexo-brain 7.13.3 → 7.13.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.13.3",
3
+ "version": "7.13.5",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.13.3` is the current packaged-runtime line. Unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI parity is release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
21
+ Version `7.13.5` is the current packaged-runtime line. Corrective release — D.5 correction-learning enforcement is durable, doctor `--fix` explicitly repairs orphan personal schedule metadata, G5 now warns on protected `com.nexo.*.plist` unload/remove flows with the three-layer recovery path, and Codex now gets a managed `PreToolUse` guard for shell/exec_command calls plus `installation_live.codex_protocol_compliance` drift checks. Result: coordinated Desktop bundles can ship the fixed Brain without changing the Mac/Windows installation contract.
22
+
23
+ Previously in `7.13.3`: unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI config/default checks are release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
22
24
 
23
25
  Previously in `7.12.15`: patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
24
26
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.13.3",
3
+ "version": "7.13.5",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
- "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
5
+ "description": "NEXO Brain Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
7
7
  "bin": {
8
8
  "nexo-brain": "bin/nexo-brain.js",
@@ -527,6 +527,11 @@ def _codex_config_path(home: Path | None = None) -> Path:
527
527
  return base / ".codex" / "config.toml"
528
528
 
529
529
 
530
+ def _codex_hooks_path(home: Path | None = None) -> Path:
531
+ base = home or _user_home()
532
+ return base / ".codex" / "hooks.json"
533
+
534
+
530
535
  def _toml_key(key: str) -> str:
531
536
  if key.replace("_", "").replace("-", "").isalnum():
532
537
  return key
@@ -632,6 +637,12 @@ def _sync_codex_managed_config(
632
637
  if "reasoning_effort" in runtime_profile:
633
638
  payload["model_reasoning_effort"] = runtime_profile.get("reasoning_effort") or ""
634
639
 
640
+ features = payload.setdefault("features", {})
641
+ if isinstance(features, dict):
642
+ features["codex_hooks"] = True
643
+ else:
644
+ payload["features"] = {"codex_hooks": True}
645
+
635
646
  payload["initial_messages"] = [
636
647
  {
637
648
  "role": "system",
@@ -643,6 +654,7 @@ def _sync_codex_managed_config(
643
654
  codex_table = nexo_table.setdefault("codex", {})
644
655
  codex_table["bootstrap_managed"] = True
645
656
  codex_table["mcp_managed"] = True
657
+ codex_table["hooks_managed"] = True
646
658
  codex_table["bootstrap_bytes"] = len(bootstrap_prompt.encode("utf-8")) if bootstrap_prompt else 0
647
659
  if runtime_profile.get("model"):
648
660
  # Record the healed/actual model in use, not the raw (possibly Claude) profile.
@@ -670,11 +682,127 @@ def _sync_codex_managed_config(
670
682
  "path": str(path),
671
683
  "bootstrap_managed": True,
672
684
  "mcp_managed": True,
685
+ "hooks_enabled": True,
673
686
  "model": runtime_profile.get("model", ""),
674
687
  "reasoning_effort": runtime_profile.get("reasoning_effort", "") or "",
675
688
  }
676
689
 
677
690
 
691
+ CODEX_SUPPORTED_HOOK_EVENTS = {"PreToolUse"}
692
+ CODEX_PRETOOL_MATCHER = r"^(Bash|shell_command|exec_command)$"
693
+
694
+
695
+ def _codex_core_hook_specs(runtime_root: Path) -> list[dict]:
696
+ specs: list[dict] = []
697
+ for spec in _core_hook_specs(runtime_root):
698
+ if spec.get("event") not in CODEX_SUPPORTED_HOOK_EVENTS:
699
+ continue
700
+ item = dict(spec)
701
+ if item.get("event") == "PreToolUse":
702
+ item["matcher"] = CODEX_PRETOOL_MATCHER
703
+ item["statusMessage"] = "NEXO guard"
704
+ specs.append(item)
705
+ return specs
706
+
707
+
708
+ def _merge_codex_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
709
+ hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
710
+ hooks_dir = _resolve_hook_source_dir(runtime_root)
711
+ managed_count = 0
712
+ core_hook_specs = _codex_core_hook_specs(runtime_root)
713
+ current_identities_by_event = _current_core_hook_identities_by_event(core_hook_specs)
714
+ managed_identities_by_event = _managed_core_hook_identities_by_event(current_identities_by_event)
715
+
716
+ for event, managed_identities in managed_identities_by_event.items():
717
+ sections = _normalize_hook_sections(hooks_payload.get(event))
718
+ desired_identities = current_identities_by_event.get(event, set())
719
+ cleaned_sections: list[dict] = []
720
+ for section in sections:
721
+ cleaned_hooks = []
722
+ for hook in section["hooks"]:
723
+ identity = _hook_identity(hook.get("command", ""))
724
+ if identity in managed_identities and identity not in desired_identities:
725
+ continue
726
+ cleaned_hooks.append(hook)
727
+ if cleaned_hooks:
728
+ cleaned_sections.append(
729
+ {
730
+ "matcher": section.get("matcher", "*") or "*",
731
+ "hooks": cleaned_hooks,
732
+ }
733
+ )
734
+ if cleaned_sections:
735
+ hooks_payload[event] = cleaned_sections
736
+ elif event in hooks_payload and event in current_identities_by_event:
737
+ hooks_payload.pop(event, None)
738
+
739
+ for spec in core_hook_specs:
740
+ event = spec["event"]
741
+ sections = _normalize_hook_sections(hooks_payload.get(event))
742
+ hooks_payload[event] = sections
743
+ command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
744
+ identity = spec["identity"]
745
+ matcher = spec.get("matcher") or "*"
746
+
747
+ found = False
748
+ for section in sections:
749
+ for hook in section["hooks"]:
750
+ if _hook_identity(hook.get("command", "")) != identity:
751
+ continue
752
+ section["matcher"] = matcher
753
+ hook["type"] = "command"
754
+ hook["command"] = command
755
+ if spec.get("timeout"):
756
+ hook["timeout"] = spec["timeout"]
757
+ if spec.get("statusMessage"):
758
+ hook["statusMessage"] = spec["statusMessage"]
759
+ found = True
760
+ managed_count += 1
761
+ break
762
+ if found:
763
+ break
764
+
765
+ if found:
766
+ continue
767
+
768
+ target = None
769
+ for section in sections:
770
+ if section.get("matcher", "*") == matcher:
771
+ target = section
772
+ break
773
+ if target is None:
774
+ target = {"matcher": matcher, "hooks": []}
775
+ sections.append(target)
776
+
777
+ new_hook = {"type": "command", "command": command}
778
+ if spec.get("timeout"):
779
+ new_hook["timeout"] = spec["timeout"]
780
+ if spec.get("statusMessage"):
781
+ new_hook["statusMessage"] = spec["statusMessage"]
782
+ target["hooks"].append(new_hook)
783
+ managed_count += 1
784
+
785
+ return hooks_payload, managed_count
786
+
787
+
788
+ def _sync_codex_hooks(path: Path, *, runtime_root: Path, nexo_home: Path) -> dict:
789
+ payload = _load_json_object(path)
790
+ action = "updated" if payload else "created"
791
+ payload["hooks"], managed_count = _merge_codex_hooks(
792
+ payload.get("hooks", {}),
793
+ runtime_root=runtime_root,
794
+ nexo_home=nexo_home,
795
+ )
796
+ _write_json_object(path, payload)
797
+ return {
798
+ "ok": True,
799
+ "action": action,
800
+ "path": str(path),
801
+ "managed_hook_count": managed_count,
802
+ "pretool_matcher": CODEX_PRETOOL_MATCHER,
803
+ }
804
+
805
+
678
806
  def _load_json_object(path: Path) -> dict:
679
807
  if not path.is_file():
680
808
  return {}
@@ -1187,6 +1315,7 @@ def sync_codex(
1187
1315
  )
1188
1316
  codex_bin = shutil.which("codex")
1189
1317
  config_path = _codex_config_path(home_path)
1318
+ hooks_path = _codex_hooks_path(home_path)
1190
1319
  if not codex_bin:
1191
1320
  result = {
1192
1321
  "ok": True,
@@ -1210,6 +1339,11 @@ def sync_codex(
1210
1339
  runtime_profile=runtime_profile,
1211
1340
  server_config=server_config,
1212
1341
  )
1342
+ result["hooks"] = _sync_codex_hooks(
1343
+ hooks_path,
1344
+ runtime_root=Path(runtime_root).expanduser() if runtime_root else _resolve_runtime_root(nexo_home_path),
1345
+ nexo_home=nexo_home_path,
1346
+ )
1213
1347
  return result
1214
1348
 
1215
1349
  cmd = [codex_bin, "mcp", "add", "nexo"]
@@ -1248,6 +1382,11 @@ def sync_codex(
1248
1382
  runtime_profile=runtime_profile,
1249
1383
  server_config=server_config,
1250
1384
  )
1385
+ sync_result["hooks"] = _sync_codex_hooks(
1386
+ hooks_path,
1387
+ runtime_root=Path(runtime_root).expanduser() if runtime_root else _resolve_runtime_root(nexo_home_path),
1388
+ nexo_home=nexo_home_path,
1389
+ )
1251
1390
  if result.returncode != 0:
1252
1391
  sync_result["warning"] = (result.stderr or result.stdout or "codex mcp add failed").strip()
1253
1392
  return sync_result
@@ -169,6 +169,9 @@ from db._protocol import (
169
169
  create_protocol_task, get_protocol_task, close_protocol_task,
170
170
  set_protocol_task_guard_acknowledged,
171
171
  create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
172
+ record_session_correction_requirement, list_session_correction_requirements,
173
+ session_has_open_correction_requirement, resolve_session_correction_requirements,
174
+ correction_requirement_summary,
172
175
  protocol_compliance_summary,
173
176
  create_cortex_evaluation, get_cortex_evaluation, list_cortex_evaluations,
174
177
  cortex_evaluation_summary,
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
  """NEXO DB — Protocol discipline runtime."""
3
3
 
4
4
  import json
5
+ import hashlib
5
6
  import secrets
6
7
  import time
7
8
 
@@ -34,6 +35,12 @@ def _row_to_dict(row):
34
35
  return dict(row) if row else None
35
36
 
36
37
 
38
+ def _correction_context_hash(session_id: str, text: str) -> str:
39
+ clean = " ".join(str(text or "").strip().split())[:1200]
40
+ digest = hashlib.sha1(f"{session_id.strip()}\0{clean}".encode("utf-8"), usedforsecurity=False)
41
+ return digest.hexdigest()[:20]
42
+
43
+
37
44
  def validate_task_type(task_type: str) -> str:
38
45
  clean_type = (task_type or "").strip()
39
46
  if clean_type not in VALID_TASK_TYPES:
@@ -529,6 +536,155 @@ def list_protocol_debts(
529
536
  return [dict(row) for row in rows]
530
537
 
531
538
 
539
+ def record_session_correction_requirement(
540
+ session_id: str,
541
+ correction_text: str,
542
+ *,
543
+ source: str = "heartbeat",
544
+ ) -> dict:
545
+ """Persist that a detected user correction requires a learning_add."""
546
+ conn = get_db()
547
+ clean_sid = str(session_id or "").strip()
548
+ if not clean_sid:
549
+ return {"ok": False, "error": "session_id is required"}
550
+ clean_text = " ".join(str(correction_text or "").strip().split())
551
+ context_hash = _correction_context_hash(clean_sid, clean_text)
552
+ conn.execute(
553
+ """INSERT OR IGNORE INTO session_correction_requirements
554
+ (session_id, context_hash, correction_text, source)
555
+ VALUES (?, ?, ?, ?)""",
556
+ (clean_sid, context_hash, clean_text[:4000], str(source or "heartbeat").strip()[:80]),
557
+ )
558
+ conn.commit()
559
+ row = conn.execute(
560
+ """SELECT *
561
+ FROM session_correction_requirements
562
+ WHERE session_id = ? AND context_hash = ?
563
+ LIMIT 1""",
564
+ (clean_sid, context_hash),
565
+ ).fetchone()
566
+ out = _row_to_dict(row) or {}
567
+ out["ok"] = True
568
+ return out
569
+
570
+
571
+ def list_session_correction_requirements(
572
+ *,
573
+ session_id: str = "",
574
+ status: str = "open",
575
+ limit: int = 50,
576
+ ) -> list[dict]:
577
+ conn = get_db()
578
+ clauses: list[str] = []
579
+ params: list[object] = []
580
+ if session_id:
581
+ clauses.append("session_id = ?")
582
+ params.append(str(session_id).strip())
583
+ if status:
584
+ clauses.append("status = ?")
585
+ params.append(str(status).strip())
586
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
587
+ rows = conn.execute(
588
+ f"""SELECT *
589
+ FROM session_correction_requirements
590
+ {where}
591
+ ORDER BY detected_at DESC, id DESC
592
+ LIMIT ?""",
593
+ params + [max(1, int(limit or 50))],
594
+ ).fetchall()
595
+ return [dict(row) for row in rows]
596
+
597
+
598
+ def session_has_open_correction_requirement(session_id: str) -> bool:
599
+ if not str(session_id or "").strip():
600
+ return False
601
+ conn = get_db()
602
+ row = conn.execute(
603
+ """SELECT 1
604
+ FROM session_correction_requirements
605
+ WHERE session_id = ? AND status = 'open'
606
+ LIMIT 1""",
607
+ (str(session_id).strip(),),
608
+ ).fetchone()
609
+ return bool(row)
610
+
611
+
612
+ def resolve_session_correction_requirements(
613
+ *,
614
+ session_id: str = "",
615
+ learning_id: int | None = None,
616
+ ) -> int:
617
+ """Resolve open correction requirements after a real learning_add."""
618
+ conn = get_db()
619
+ clean_sid = str(session_id or "").strip()
620
+ if not clean_sid:
621
+ rows = conn.execute(
622
+ """SELECT session_id, COUNT(*) AS total
623
+ FROM session_correction_requirements
624
+ WHERE status = 'open'
625
+ GROUP BY session_id
626
+ ORDER BY MAX(detected_at) DESC"""
627
+ ).fetchall()
628
+ if len(rows) == 1:
629
+ clean_sid = str(rows[0]["session_id"] or "").strip()
630
+ else:
631
+ try:
632
+ row = conn.execute(
633
+ """SELECT r.session_id
634
+ FROM session_correction_requirements r
635
+ LEFT JOIN sessions s ON s.sid = r.session_id
636
+ WHERE r.status = 'open'
637
+ ORDER BY COALESCE(s.last_heartbeat_ts, s.last_update_epoch, s.started_epoch, 0) DESC,
638
+ r.detected_at DESC
639
+ LIMIT 1"""
640
+ ).fetchone()
641
+ except Exception:
642
+ row = None
643
+ clean_sid = str(row["session_id"] or "").strip() if row else ""
644
+ if not clean_sid:
645
+ return 0
646
+ cursor = conn.execute(
647
+ """UPDATE session_correction_requirements
648
+ SET status = 'resolved',
649
+ resolved_at = datetime('now'),
650
+ resolved_learning_id = ?
651
+ WHERE session_id = ? AND status = 'open'""",
652
+ (int(learning_id) if learning_id else None, clean_sid),
653
+ )
654
+ conn.commit()
655
+ if cursor.rowcount:
656
+ resolve_protocol_debts(
657
+ session_id=clean_sid,
658
+ debt_types=["missing_learning_after_correction"],
659
+ resolution=f"Learning #{learning_id} persisted after user correction.",
660
+ )
661
+ return int(cursor.rowcount or 0)
662
+
663
+
664
+ def correction_requirement_summary(session_id: str = "") -> dict:
665
+ conn = get_db()
666
+ clauses: list[str] = []
667
+ params: list[object] = []
668
+ if session_id:
669
+ clauses.append("session_id = ?")
670
+ params.append(str(session_id).strip())
671
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
672
+ rows = conn.execute(
673
+ f"""SELECT status, COUNT(*) AS total
674
+ FROM session_correction_requirements
675
+ {where}
676
+ GROUP BY status"""
677
+ ).fetchall()
678
+ counts = {str(row["status"]): int(row["total"] or 0) for row in rows}
679
+ return {
680
+ "session_id": str(session_id or "").strip(),
681
+ "corrections_detected": sum(counts.values()),
682
+ "learnings_persisted": counts.get("resolved", 0),
683
+ "open_requirements": counts.get("open", 0),
684
+ "by_status": counts,
685
+ }
686
+
687
+
532
688
  def protocol_compliance_summary(days: int = 7) -> dict:
533
689
  conn = get_db()
534
690
  window = f"-{max(1, int(days))} days"
package/src/db/_schema.py CHANGED
@@ -923,6 +923,28 @@ def _m55_cortex_critique_trace(conn):
923
923
  _migrate_add_column(conn, "cortex_evaluations", "decision_mode", "TEXT DEFAULT 'heuristic'")
924
924
 
925
925
 
926
+ def _m56_session_correction_requirements(conn):
927
+ """Track user corrections that must be turned into durable learnings."""
928
+ conn.execute(
929
+ """CREATE TABLE IF NOT EXISTS session_correction_requirements (
930
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
931
+ session_id TEXT NOT NULL,
932
+ context_hash TEXT NOT NULL,
933
+ correction_text TEXT DEFAULT '',
934
+ source TEXT DEFAULT 'heartbeat',
935
+ status TEXT NOT NULL DEFAULT 'open',
936
+ detected_at TEXT DEFAULT (datetime('now')),
937
+ resolved_at TEXT DEFAULT NULL,
938
+ resolved_learning_id INTEGER DEFAULT NULL,
939
+ followup_id TEXT DEFAULT '',
940
+ UNIQUE(session_id, context_hash)
941
+ )"""
942
+ )
943
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_session ON session_correction_requirements(session_id)")
944
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_status ON session_correction_requirements(status)")
945
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_detected ON session_correction_requirements(detected_at)")
946
+
947
+
926
948
  def _m39_hook_runs(conn):
927
949
  """Persist hook lifecycle observability — closes Fase 3 item 7.
928
950
 
@@ -1494,6 +1516,7 @@ MIGRATIONS = [
1494
1516
  (53, "session_conversation_identity", _m53_session_conversation_identity),
1495
1517
  (54, "continuity_snapshots", _m54_continuity_snapshots),
1496
1518
  (55, "cortex_critique_trace", _m55_cortex_critique_trace),
1519
+ (56, "session_correction_requirements", _m56_session_correction_requirements),
1497
1520
  ]
1498
1521
 
1499
1522
 
@@ -185,6 +185,52 @@ def _codex_bootstrap_config_status() -> dict:
185
185
  }
186
186
 
187
187
 
188
+ def _codex_hooks_config_status() -> dict:
189
+ path = Path.home() / ".codex" / "hooks.json"
190
+ if not path.is_file():
191
+ return {
192
+ "exists": False,
193
+ "path": str(path),
194
+ "pretool_managed": False,
195
+ "pretool_command": "",
196
+ }
197
+ try:
198
+ payload = json.loads(path.read_text())
199
+ except Exception as exc:
200
+ return {
201
+ "exists": True,
202
+ "path": str(path),
203
+ "pretool_managed": False,
204
+ "pretool_command": "",
205
+ "error": str(exc),
206
+ }
207
+ hooks = payload.get("hooks") if isinstance(payload, dict) else {}
208
+ pretool = hooks.get("PreToolUse") if isinstance(hooks, dict) else []
209
+ for section in pretool or []:
210
+ if not isinstance(section, dict):
211
+ continue
212
+ matcher = str(section.get("matcher") or "")
213
+ for hook in section.get("hooks") or []:
214
+ if not isinstance(hook, dict):
215
+ continue
216
+ command = str(hook.get("command") or "")
217
+ if "pre_tool_use.py" not in command:
218
+ continue
219
+ return {
220
+ "exists": True,
221
+ "path": str(path),
222
+ "pretool_managed": True,
223
+ "pretool_command": command,
224
+ "pretool_matcher": matcher,
225
+ }
226
+ return {
227
+ "exists": True,
228
+ "path": str(path),
229
+ "pretool_managed": False,
230
+ "pretool_command": "",
231
+ }
232
+
233
+
188
234
  def _claude_desktop_shared_brain_status() -> dict:
189
235
  path = Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
190
236
  if not path.is_file():
@@ -1860,9 +1906,11 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
1860
1906
  """Check the DB-backed personal script registry against filesystem/plists."""
1861
1907
  try:
1862
1908
  from db import init_db, get_personal_script_health_report
1863
- from script_registry import sync_personal_scripts
1909
+ from script_registry import repair_orphan_personal_schedule_metadata, sync_personal_scripts
1864
1910
 
1865
1911
  init_db()
1912
+ if fix:
1913
+ repair_orphan_personal_schedule_metadata(dry_run=False)
1866
1914
  sync_personal_scripts(prune_missing=True)
1867
1915
  report = get_personal_script_health_report(fix=fix)
1868
1916
  except Exception as e:
@@ -2128,6 +2176,7 @@ def check_client_bootstrap_parity(fix: bool = False) -> DoctorCheck:
2128
2176
  repair_plan.append("Refresh bootstrap files from the current NEXO templates")
2129
2177
  if client_key == "codex":
2130
2178
  codex_config = _codex_bootstrap_config_status()
2179
+ codex_hooks = _codex_hooks_config_status()
2131
2180
  if codex_config.get("error"):
2132
2181
  status = "degraded"
2133
2182
  severity = "warn"
@@ -2150,6 +2199,20 @@ def check_client_bootstrap_parity(fix: bool = False) -> DoctorCheck:
2150
2199
  f" ({codex_config.get('model') or 'default'}, {codex_config.get('reasoning_effort') or 'default'})"
2151
2200
  )
2152
2201
  )
2202
+ if codex_hooks.get("error"):
2203
+ status = "degraded"
2204
+ severity = "warn"
2205
+ evidence.append(f"codex hooks JSON invalid at {codex_hooks.get('path')}: {codex_hooks.get('error')}")
2206
+ repair_plan.append("Repair ~/.codex/hooks.json so NEXO can manage Codex PreToolUse enforcement")
2207
+ elif not codex_hooks.get("pretool_managed"):
2208
+ status = "degraded"
2209
+ severity = "warn"
2210
+ evidence.append(f"codex PreToolUse hook missing at {codex_hooks.get('path')}")
2211
+ repair_plan.append("Run `nexo clients sync --client codex` to install the managed PreToolUse guard")
2212
+ else:
2213
+ evidence.append(
2214
+ f"codex PreToolUse hook managed ({codex_hooks.get('pretool_matcher') or '*'})"
2215
+ )
2153
2216
 
2154
2217
  if fix and status != "healthy":
2155
2218
  try:
@@ -2425,6 +2488,120 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2425
2488
  )
2426
2489
 
2427
2490
 
2491
+ def check_codex_protocol_compliance() -> DoctorCheck:
2492
+ try:
2493
+ schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
2494
+ except Exception:
2495
+ schedule = {}
2496
+ prefs = normalize_client_preferences(schedule)
2497
+ wants_codex = bool(
2498
+ prefs.get("interactive_clients", {}).get("codex")
2499
+ or prefs.get("default_terminal_client") == "codex"
2500
+ or (prefs.get("automation_enabled", True) and prefs.get("automation_backend") == "codex")
2501
+ )
2502
+ if not wants_codex:
2503
+ return DoctorCheck(
2504
+ id="installation_live.codex_protocol_compliance",
2505
+ tier="runtime",
2506
+ status="healthy",
2507
+ severity="info",
2508
+ summary="Codex protocol compliance skipped (Codex not selected)",
2509
+ )
2510
+
2511
+ hooks = _codex_hooks_config_status()
2512
+ if hooks.get("error"):
2513
+ return DoctorCheck(
2514
+ id="installation_live.codex_protocol_compliance",
2515
+ tier="runtime",
2516
+ status="critical",
2517
+ severity="error",
2518
+ summary="Codex live protocol enforcement is not readable",
2519
+ evidence=[f"codex hooks JSON invalid at {hooks.get('path')}: {hooks.get('error')}"],
2520
+ repair_plan=["Repair ~/.codex/hooks.json or run `nexo clients sync --client codex`"],
2521
+ escalation_prompt=(
2522
+ "Codex cannot be treated as protocol-compliant while its live PreToolUse hook config is unreadable."
2523
+ ),
2524
+ )
2525
+ if not hooks.get("pretool_managed"):
2526
+ return DoctorCheck(
2527
+ id="installation_live.codex_protocol_compliance",
2528
+ tier="runtime",
2529
+ status="critical",
2530
+ severity="error",
2531
+ summary="Codex live PreToolUse enforcement is not installed",
2532
+ evidence=[f"missing managed PreToolUse hook at {hooks.get('path')}"],
2533
+ repair_plan=["Run `nexo clients sync --client codex` so shell/exec_command calls are checked before execution"],
2534
+ escalation_prompt=(
2535
+ "Codex can still execute shell commands without the live NEXO guard; this is not functional parity."
2536
+ ),
2537
+ )
2538
+
2539
+ startup = _recent_codex_session_parity_status(days=1)
2540
+ conditioned = _recent_codex_conditioned_file_discipline_status(days=1)
2541
+ sessions = int(startup.get("files") or conditioned.get("files") or 0)
2542
+ if sessions <= 0:
2543
+ return DoctorCheck(
2544
+ id="installation_live.codex_protocol_compliance",
2545
+ tier="runtime",
2546
+ status="degraded",
2547
+ severity="warn",
2548
+ summary="No Codex sessions in the last 24h to verify protocol compliance",
2549
+ repair_plan=[
2550
+ "Run Codex through the managed NEXO bootstrap so doctor can verify live protocol compliance",
2551
+ ],
2552
+ )
2553
+
2554
+ startup_violation_sessions = 0
2555
+ for sample in startup.get("samples", []):
2556
+ if not sample.get("bootstrap") or not sample.get("startup") or not sample.get("heartbeat"):
2557
+ startup_violation_sessions += 1
2558
+ conditioned_violations = (
2559
+ int(conditioned.get("read_without_protocol") or 0)
2560
+ + int(conditioned.get("write_without_protocol") or 0)
2561
+ + int(conditioned.get("write_without_guard_ack") or 0)
2562
+ + int(conditioned.get("delete_without_protocol") or 0)
2563
+ + int(conditioned.get("delete_without_guard_ack") or 0)
2564
+ )
2565
+ violation_units = startup_violation_sessions + conditioned_violations
2566
+ violation_rate = round((violation_units / max(1, sessions)) * 100, 1)
2567
+ status = "critical" if violation_rate > 5.0 else "healthy"
2568
+ severity = "error" if status == "critical" else "info"
2569
+ evidence = [
2570
+ f"codex PreToolUse hook: managed ({hooks.get('pretool_matcher') or '*'})",
2571
+ f"codex sessions inspected 24h: {sessions}",
2572
+ f"startup/protocol violating sessions: {startup_violation_sessions}",
2573
+ f"conditioned-file violations: {conditioned_violations}",
2574
+ f"violation rate: {violation_rate}%",
2575
+ "threshold: 5.0%",
2576
+ ]
2577
+ for sample in (startup.get("samples") or [])[:3]:
2578
+ if not sample.get("bootstrap") or not sample.get("startup") or not sample.get("heartbeat"):
2579
+ evidence.append(f"startup drift: {sample.get('file')}")
2580
+ for sample in (conditioned.get("samples") or [])[:3]:
2581
+ evidence.append(f"conditioned drift: {sample.get('kind')} {sample.get('file')}")
2582
+
2583
+ return DoctorCheck(
2584
+ id="installation_live.codex_protocol_compliance",
2585
+ tier="runtime",
2586
+ status=status,
2587
+ severity=severity,
2588
+ summary=(
2589
+ "Codex protocol compliance is within the 5% live threshold"
2590
+ if status == "healthy"
2591
+ else "Codex protocol compliance exceeds the 5% live violation threshold"
2592
+ ),
2593
+ evidence=evidence,
2594
+ repair_plan=[
2595
+ "Start Codex via `nexo chat` so SessionStart bootstrap is injected",
2596
+ "Keep nexo_startup and nexo_heartbeat visible in every Codex session",
2597
+ "Run nexo_guard_check before conditioned-file reads/writes and acknowledge blocking rules before mutation",
2598
+ ] if status != "healthy" else [],
2599
+ escalation_prompt=(
2600
+ "Codex CLI parity is not clean: recent sessions miss startup/heartbeat or bypass conditioned-file guard discipline."
2601
+ ) if status != "healthy" else "",
2602
+ )
2603
+
2604
+
2428
2605
  def check_claude_desktop_shared_brain() -> DoctorCheck:
2429
2606
  try:
2430
2607
  schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
@@ -3415,6 +3592,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
3415
3592
  safe_check(check_client_bootstrap_parity, fix=fix),
3416
3593
  safe_check(check_codex_session_parity),
3417
3594
  safe_check(check_codex_conditioned_file_discipline),
3595
+ safe_check(check_codex_protocol_compliance),
3418
3596
  safe_check(check_claude_desktop_shared_brain),
3419
3597
  safe_check(check_transcript_source_parity),
3420
3598
  safe_check(check_client_assumption_regressions),
@@ -261,6 +261,14 @@ def _short_tool_name(tool_name: str) -> str:
261
261
  return clean.rsplit("__", 1)[-1] if "__" in clean else clean
262
262
 
263
263
 
264
+ def _canonical_hook_tool_name(tool_name: str) -> str:
265
+ clean = _short_tool_name(tool_name)
266
+ lowered = clean.strip().lower()
267
+ if lowered in {"bash", "shell", "shell_command", "exec_command", "local_shell"}:
268
+ return "Bash"
269
+ return clean
270
+
271
+
264
272
  def _normalize_file_path(path: str) -> str:
265
273
  return _normalize_path_token(str(Path(path)))
266
274
 
@@ -1177,6 +1185,15 @@ def _collect_runtime_core_write_blocks(
1177
1185
  _LAUNCHAGENT_PLIST_RE = re.compile(
1178
1186
  r"library/launchagents/com\.nexo\.[^/]+\.plist$"
1179
1187
  )
1188
+ _LAUNCHAGENT_PLIST_TOKEN_RE = re.compile(
1189
+ r"library/launchagents/com\.nexo\.[^/\s]+\.plist(?:\*|$)"
1190
+ )
1191
+ _LAUNCHAGENT_SERVICE_RE = re.compile(r"\bcom\.nexo\.[A-Za-z0-9_.-]+\b")
1192
+ _LAUNCHAGENT_3_LAYER_FLOW = (
1193
+ "Use the 3-layer schedule removal flow: launchctl unload/bootout the service, "
1194
+ "remove `# nexo: schedule_required=true` and `# nexo: cron_id=...` markers from "
1195
+ "the source script, then verify with `nexo scripts reconcile --dry-run`."
1196
+ )
1180
1197
 
1181
1198
 
1182
1199
  def _is_protected_launchagent_path(filepath: str) -> bool:
@@ -1194,6 +1211,112 @@ def _is_protected_launchagent_path(filepath: str) -> bool:
1194
1211
  return _LAUNCHAGENT_PLIST_RE.search(normalized) is not None
1195
1212
 
1196
1213
 
1214
+ def _is_protected_launchagent_token(value: str) -> bool:
1215
+ normalized = _normalize_file_path(value)
1216
+ return bool(_LAUNCHAGENT_PLIST_TOKEN_RE.search(normalized))
1217
+
1218
+
1219
+ def _launchagent_operation_kind(command: str) -> str:
1220
+ tokens = _shell_tokens(command)
1221
+ if not tokens:
1222
+ return ""
1223
+ base = Path(tokens[0]).name.lower()
1224
+ command_text = str(command or "")
1225
+ if base == "launchctl":
1226
+ if any(token in {"unload", "bootout"} for token in tokens[1:]):
1227
+ if any(_is_protected_launchagent_token(token) for token in tokens[1:]):
1228
+ return "launchctl_plist"
1229
+ if _LAUNCHAGENT_SERVICE_RE.search(command_text):
1230
+ return "launchctl_service"
1231
+ if base in {"rm", "unlink", "mv"}:
1232
+ if any(_is_protected_launchagent_token(token) for token in tokens[1:]):
1233
+ return base
1234
+ return ""
1235
+
1236
+
1237
+ def _collect_launchagent_operation_warnings(
1238
+ conn,
1239
+ *,
1240
+ sid: str,
1241
+ tool_name: str,
1242
+ command: str,
1243
+ ) -> list[dict]:
1244
+ if core_writes_allowed():
1245
+ return []
1246
+ kind = _launchagent_operation_kind(command)
1247
+ if not kind:
1248
+ return []
1249
+ debt = _ensure_protocol_debt(
1250
+ conn,
1251
+ session_id=sid,
1252
+ task_id="",
1253
+ debt_type="launchagent_plist_protected_operation",
1254
+ severity="warn",
1255
+ evidence=(
1256
+ f"{tool_name} requested {kind} on a NEXO-managed LaunchAgent. "
1257
+ f"{_LAUNCHAGENT_3_LAYER_FLOW} Command head: {str(command or '')[:180]}"
1258
+ ),
1259
+ file_token="launchagent_plist_protected",
1260
+ )
1261
+ return [
1262
+ {
1263
+ "file": "com.nexo.*.plist",
1264
+ "task_id": "",
1265
+ "debt_id": debt.get("id"),
1266
+ "debt_type": "launchagent_plist_protected_operation",
1267
+ "reason_code": "launchagent_plist_protected",
1268
+ "severity": "warn",
1269
+ "message": _LAUNCHAGENT_3_LAYER_FLOW,
1270
+ "operation_kind": kind,
1271
+ }
1272
+ ]
1273
+
1274
+
1275
+ def _is_scheduled_personal_script(filepath: str) -> bool:
1276
+ normalized = _normalize_file_path(filepath)
1277
+ if "/.nexo/personal/scripts/" not in normalized or not normalized.endswith(".py"):
1278
+ return False
1279
+ try:
1280
+ path = Path(filepath).expanduser()
1281
+ if not path.exists() or not path.is_file():
1282
+ return False
1283
+ head = "".join(path.read_text(errors="ignore").splitlines(keepends=True)[:40])
1284
+ except Exception:
1285
+ return False
1286
+ return "# nexo: schedule_required=true" in head
1287
+
1288
+
1289
+ def _collect_scheduled_personal_script_warnings(conn, *, sid: str, tool_name: str, files: list[str]) -> list[dict]:
1290
+ warnings: list[dict] = []
1291
+ for filepath in files:
1292
+ if not _is_scheduled_personal_script(filepath):
1293
+ continue
1294
+ debt = _ensure_protocol_debt(
1295
+ conn,
1296
+ session_id=sid,
1297
+ task_id="",
1298
+ debt_type="scheduled_personal_script_conditioned",
1299
+ severity="warn",
1300
+ evidence=(
1301
+ f"{tool_name} touched scheduled personal script {filepath}. "
1302
+ "Run nexo_guard_check and keep LaunchAgent metadata in sync before editing schedule markers."
1303
+ ),
1304
+ file_token=filepath,
1305
+ )
1306
+ warnings.append(
1307
+ {
1308
+ "file": filepath,
1309
+ "task_id": "",
1310
+ "debt_id": debt.get("id"),
1311
+ "debt_type": "scheduled_personal_script_conditioned",
1312
+ "reason_code": "scheduled_personal_script_conditioned",
1313
+ "severity": "warn",
1314
+ "message": "Scheduled personal script: run nexo_guard_check and keep schedule metadata/plist in sync.",
1315
+ }
1316
+ )
1317
+ return warnings
1318
+
1319
+
1197
1320
  def _collect_launchagent_write_blocks(
1198
1321
  conn,
1199
1322
  *,
@@ -1277,7 +1400,7 @@ def _read_claude_session_id_from_coordination() -> str:
1277
1400
 
1278
1401
 
1279
1402
  def process_pre_tool_event(payload: dict) -> dict:
1280
- tool_name = str(payload.get("tool_name", "")).strip()
1403
+ tool_name = _canonical_hook_tool_name(str(payload.get("tool_name", "")).strip())
1281
1404
  tool_input = payload.get("tool_input")
1282
1405
  op = _operation_kind(tool_name)
1283
1406
  shell_files: list[str] = []
@@ -1321,6 +1444,10 @@ def process_pre_tool_event(payload: dict) -> dict:
1321
1444
  _shell_cmd_ssh = _extract_bash_command(tool_input)
1322
1445
  if _classify_ssh_remote_write(_shell_cmd_ssh):
1323
1446
  op = "write" # force the main gate to keep evaluating
1447
+ if tool_name == "Bash" and op not in {"write", "delete"}:
1448
+ _shell_cmd_launchagent = _extract_bash_command(tool_input)
1449
+ if _launchagent_operation_kind(_shell_cmd_launchagent):
1450
+ op = "delete"
1324
1451
  if op not in {"write", "delete"}:
1325
1452
  return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
1326
1453
 
@@ -1353,6 +1480,32 @@ def process_pre_tool_event(payload: dict) -> dict:
1353
1480
  claude_sid = _read_claude_session_id_from_coordination()
1354
1481
  sid = _resolve_nexo_sid(conn, claude_sid)
1355
1482
  open_task = _find_any_open_task(conn, sid) if sid else None
1483
+ warnings: list[dict] = []
1484
+ if tool_name == "Bash":
1485
+ launchagent_operation_warnings = _collect_launchagent_operation_warnings(
1486
+ conn,
1487
+ sid=sid,
1488
+ tool_name=tool_name,
1489
+ command=_extract_bash_command(tool_input),
1490
+ )
1491
+ if launchagent_operation_warnings:
1492
+ return {
1493
+ "ok": True,
1494
+ "session_id": sid,
1495
+ "tool_name": tool_name,
1496
+ "operation": op,
1497
+ "strictness": strictness,
1498
+ "warnings": launchagent_operation_warnings,
1499
+ "status": "warn",
1500
+ }
1501
+ warnings.extend(
1502
+ _collect_scheduled_personal_script_warnings(
1503
+ conn,
1504
+ sid=sid,
1505
+ tool_name=tool_name,
1506
+ files=files,
1507
+ )
1508
+ )
1356
1509
  automation_blocks = _collect_automation_live_repo_blocks(
1357
1510
  conn,
1358
1511
  sid=sid,
@@ -1367,6 +1520,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1367
1520
  "operation": op,
1368
1521
  "strictness": strictness,
1369
1522
  "blocks": automation_blocks,
1523
+ "warnings": warnings,
1370
1524
  "status": "blocked",
1371
1525
  }
1372
1526
 
@@ -1384,6 +1538,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1384
1538
  "operation": op,
1385
1539
  "strictness": strictness,
1386
1540
  "blocks": core_blocks,
1541
+ "warnings": warnings,
1387
1542
  "status": "blocked",
1388
1543
  }
1389
1544
 
@@ -1401,6 +1556,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1401
1556
  "operation": op,
1402
1557
  "strictness": strictness,
1403
1558
  "blocks": launchagent_blocks,
1559
+ "warnings": warnings,
1404
1560
  "status": "blocked",
1405
1561
  }
1406
1562
 
@@ -1602,7 +1758,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1602
1758
  _shadow_cache[sid] = g4_warnings
1603
1759
 
1604
1760
  if strictness == "lenient":
1605
- return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
1761
+ return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness, "warnings": warnings, "status": "warn" if warnings else "clean"}
1606
1762
 
1607
1763
  blocks: list[dict] = []
1608
1764
 
@@ -1632,6 +1788,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1632
1788
  "operation": op,
1633
1789
  "strictness": strictness,
1634
1790
  "blocks": blocks,
1791
+ "warnings": warnings,
1635
1792
  "status": "blocked",
1636
1793
  }
1637
1794
 
@@ -1683,7 +1840,8 @@ def process_pre_tool_event(payload: dict) -> dict:
1683
1840
  "strictness": strictness,
1684
1841
  "blocks": blocks,
1685
1842
  "auto_opened_task": auto_opened_task,
1686
- "status": "blocked" if blocks else "clean",
1843
+ "warnings": warnings,
1844
+ "status": "blocked" if blocks else ("warn" if warnings else "clean"),
1687
1845
  }
1688
1846
 
1689
1847
  for filepath in files:
@@ -1767,7 +1925,8 @@ def process_pre_tool_event(payload: dict) -> dict:
1767
1925
  "strictness": strictness,
1768
1926
  "blocks": blocks,
1769
1927
  "auto_opened_task": auto_opened_task,
1770
- "status": "blocked" if blocks else "clean",
1928
+ "warnings": warnings,
1929
+ "status": "blocked" if blocks else ("warn" if warnings else "clean"),
1771
1930
  }
1772
1931
 
1773
1932
 
@@ -1918,11 +2077,14 @@ def format_hook_message(result: dict) -> str:
1918
2077
 
1919
2078
  def format_pretool_block_message(result: dict) -> str:
1920
2079
  blocks = result.get("blocks") or []
1921
- if not blocks:
2080
+ warnings = result.get("warnings") or []
2081
+ if not blocks and not warnings:
1922
2082
  return ""
1923
2083
  strictness = str(result.get("strictness") or "strict")
1924
2084
  if any(item.get("reason_code") == "automation_live_repo" for item in blocks):
1925
2085
  header = "NEXO AUTOMATION SAFETY BLOCKED THIS EDIT:"
2086
+ elif warnings and not blocks:
2087
+ header = "NEXO SAFETY WARNING:"
1926
2088
  else:
1927
2089
  header = (
1928
2090
  "NEXO LEARNING MODE BLOCKED THIS EDIT:"
@@ -1930,6 +2092,11 @@ def format_pretool_block_message(result: dict) -> str:
1930
2092
  else "NEXO STRICT MODE BLOCKED THIS EDIT:"
1931
2093
  )
1932
2094
  lines = [header]
2095
+ for item in warnings:
2096
+ message = item.get("message") or "Review this operation before continuing."
2097
+ debt_id = item.get("debt_id")
2098
+ suffix = f" (debt_id={debt_id})" if debt_id else ""
2099
+ lines.append(f"- WARN {item.get('reason_code') or item.get('debt_type')}: {message}{suffix}")
1933
2100
  for item in blocks:
1934
2101
  file_note = item["file"] or "(unknown target)"
1935
2102
  if item.get("reason_code") == "missing_startup":
@@ -25,6 +25,7 @@ from db import (
25
25
  get_db,
26
26
  get_followups,
27
27
  get_protocol_task,
28
+ list_session_correction_requirements,
28
29
  set_protocol_task_guard_acknowledged,
29
30
  list_workflow_goals,
30
31
  list_workflow_runs,
@@ -1370,6 +1371,38 @@ def handle_task_close(
1370
1371
  high_stakes=bool(task.get("response_high_stakes")),
1371
1372
  )
1372
1373
 
1374
+ pending_corrections = list_session_correction_requirements(
1375
+ session_id=task["session_id"],
1376
+ status="open",
1377
+ limit=3,
1378
+ )
1379
+ if pending_corrections:
1380
+ debt = _ensure_open_debt(
1381
+ task["session_id"],
1382
+ task_id,
1383
+ "missing_learning_after_correction",
1384
+ severity="error",
1385
+ evidence=(
1386
+ "User correction was detected for this session and has not "
1387
+ "been resolved by nexo_learning_add. task_close is blocked "
1388
+ "until a durable learning is persisted."
1389
+ ),
1390
+ debts=debts_created,
1391
+ )
1392
+ return json.dumps(
1393
+ {
1394
+ "ok": False,
1395
+ "error": "Cannot close task while a detected user correction has no durable nexo_learning_add.",
1396
+ "hint": "Call nexo_learning_add with the reusable rule learned from the correction, then retry nexo_task_close.",
1397
+ "task_id": task_id,
1398
+ "blocked_by": "d5_correction_learning_required",
1399
+ "debt_id": debt.get("id"),
1400
+ "pending_corrections": len(pending_corrections),
1401
+ },
1402
+ ensure_ascii=False,
1403
+ indent=2,
1404
+ )
1405
+
1373
1406
  # ── Evidence enforcement: reject 'done' without proof ──
1374
1407
  # G1 hardening: "done" is no longer allowed to degrade into a debt-only
1375
1408
  # close when verify evidence is missing. Keep the task open, open/dedupe
@@ -1807,6 +1807,59 @@ def check_codex_conditioned_file_discipline():
1807
1807
  finding(severity, "codex-discipline", message)
1808
1808
 
1809
1809
 
1810
+ def check_correction_learning_requirements():
1811
+ if not NEXO_DB.exists():
1812
+ return
1813
+ conn = sqlite3.connect(str(NEXO_DB))
1814
+ conn.row_factory = sqlite3.Row
1815
+ try:
1816
+ if not _table_exists(conn, "session_correction_requirements"):
1817
+ return
1818
+ rows = conn.execute(
1819
+ """SELECT id, session_id, correction_text, detected_at, followup_id
1820
+ FROM session_correction_requirements
1821
+ WHERE status = 'open'
1822
+ ORDER BY detected_at ASC
1823
+ LIMIT 25"""
1824
+ ).fetchall()
1825
+ if not rows:
1826
+ return
1827
+ refreshed = 0
1828
+ for row in rows:
1829
+ snippet = " ".join(str(row["correction_text"] or "").split())[:240]
1830
+ description = (
1831
+ "Persist learning for detected user correction "
1832
+ f"in session {row['session_id']}: {snippet or '(no snippet)'}"
1833
+ )
1834
+ followup_id = _ensure_followup(
1835
+ conn,
1836
+ prefix="D5-CORRECTION",
1837
+ description=description,
1838
+ verification="Run nexo_learning_add, then confirm session_correction_requirements.status='resolved'.",
1839
+ reasoning=(
1840
+ "Deep Sleep/self-audit found a correction detection with no durable learning_add. "
1841
+ "D.5 requires a reusable learning before session closure."
1842
+ ),
1843
+ priority="high",
1844
+ )
1845
+ if followup_id:
1846
+ conn.execute(
1847
+ """UPDATE session_correction_requirements
1848
+ SET followup_id = ?
1849
+ WHERE id = ? AND COALESCE(followup_id, '') = ''""",
1850
+ (followup_id, int(row["id"])),
1851
+ )
1852
+ refreshed += 1
1853
+ conn.commit()
1854
+ finding(
1855
+ "ERROR",
1856
+ "correction-learning",
1857
+ f"{len(rows)} correction(s) detected without learning_add; opened/refreshed {refreshed} followup(s)",
1858
+ )
1859
+ finally:
1860
+ conn.close()
1861
+
1862
+
1810
1863
  def check_codex_startup_discipline():
1811
1864
  try:
1812
1865
  from doctor.providers.runtime import _recent_codex_session_parity_status
@@ -2155,6 +2208,7 @@ def main():
2155
2208
  check_automation_opportunities()
2156
2209
  check_state_watchers()
2157
2210
  check_memory_quality_scores()
2211
+ check_correction_learning_requirements()
2158
2212
  check_codex_startup_discipline()
2159
2213
  check_codex_conditioned_file_discipline()
2160
2214
  check_watchdog_registry()
@@ -4,7 +4,8 @@ import re
4
4
  from datetime import datetime
5
5
 
6
6
  from db import (create_learning, update_learning, delete_learning, search_learnings,
7
- list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning, extract_keywords)
7
+ list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning, extract_keywords,
8
+ resolve_session_correction_requirements)
8
9
 
9
10
  NEGATION_PATTERNS = (
10
11
  "do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
@@ -137,6 +138,14 @@ def find_conflicting_active_learning(*, category: str, title: str, content: str,
137
138
  )
138
139
 
139
140
 
141
+ def _resolve_pending_correction_learning(learning_id: int) -> int:
142
+ """Best-effort D.5 bridge: a real learning_add resolves one open correction window."""
143
+ try:
144
+ return resolve_session_correction_requirements(learning_id=int(learning_id))
145
+ except Exception:
146
+ return 0
147
+
148
+
140
149
  def _priority_score(priority: str) -> float:
141
150
  return {
142
151
  "critical": 1.0,
@@ -286,6 +295,7 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
286
295
  (title.strip(), category)
287
296
  ).fetchone()
288
297
  if existing:
298
+ _resolve_pending_correction_learning(int(existing["id"]))
289
299
  return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
290
300
 
291
301
  # ── R05 (Fase 2 Protocol Enforcer): auto-merge on high Jaccard similarity ──
@@ -322,6 +332,7 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
322
332
  (new_weight, now_epoch(), existing_id),
323
333
  )
324
334
  conn.commit()
335
+ _resolve_pending_correction_learning(int(existing_id))
325
336
  return (
326
337
  f"Learning #{existing_id} matched new content at Jaccard {similarity:.2f} "
327
338
  f">= R05 merge threshold ({R05_MERGE_THRESHOLD:.2f}). No duplicate created. "
@@ -465,6 +476,7 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
465
476
  pass # Best-effort surfacing only
466
477
 
467
478
  meta = []
479
+ resolved_corrections = _resolve_pending_correction_learning(int(result["id"]))
468
480
  if prevention:
469
481
  meta.append("with prevention")
470
482
  if applies_to:
@@ -472,7 +484,11 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
472
484
  if supersedes_id:
473
485
  meta.append(f"supersedes={int(supersedes_id)}")
474
486
  meta_str = f" ({', '.join(meta)})" if meta else ""
475
- return f"Learning #{result['id']} added in {category}: {title}{meta_str} ✓verified{repetition_msg}{retro_meta_msg}"
487
+ correction_msg = (
488
+ f"\nD.5: resolved {resolved_corrections} pending correction learning requirement(s)."
489
+ if resolved_corrections else ""
490
+ )
491
+ return f"Learning #{result['id']} added in {category}: {title}{meta_str} ✓verified{repetition_msg}{retro_meta_msg}{correction_msg}"
476
492
 
477
493
 
478
494
  def handle_learning_search(query: str, category: str = '') -> str:
@@ -829,9 +829,40 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
829
829
 
830
830
  if context_hint and _hint_suggests_correction(context_hint):
831
831
  try:
832
+ from db import (
833
+ create_protocol_debt,
834
+ list_protocol_debts,
835
+ record_session_correction_requirement,
836
+ )
837
+
838
+ record_session_correction_requirement(
839
+ sid,
840
+ context_hint,
841
+ source="heartbeat",
842
+ )
832
843
  if not _recent_learning_capture_exists(conn, sid, window_seconds=300):
844
+ existing_debt = list_protocol_debts(
845
+ status="open",
846
+ session_id=sid,
847
+ debt_type="missing_learning_after_correction",
848
+ limit=1,
849
+ )
850
+ if not existing_debt:
851
+ create_protocol_debt(
852
+ sid,
853
+ "missing_learning_after_correction",
854
+ severity="error",
855
+ evidence=(
856
+ "Detected user correction in heartbeat context. "
857
+ "A durable nexo_learning_add is required before "
858
+ "nexo_task_close or nexo_stop may close this session."
859
+ ),
860
+ )
833
861
  parts.append("")
834
862
  parts.append(render_core_prompt("heartbeat-learning-reminder"))
863
+ parts.append(
864
+ "LEARNING REQUIRED: call nexo_learning_add for this correction before nexo_task_close or nexo_stop."
865
+ )
835
866
  except Exception:
836
867
  pass # Best-effort reminder only
837
868
 
@@ -1288,6 +1319,18 @@ def _toolbox_summary(conn) -> str:
1288
1319
 
1289
1320
  def handle_stop(sid: str) -> str:
1290
1321
  """Cleanly close a session, removing it from active sessions immediately."""
1322
+ try:
1323
+ from db import list_session_correction_requirements
1324
+
1325
+ pending = list_session_correction_requirements(session_id=sid, status="open", limit=3)
1326
+ if pending:
1327
+ return (
1328
+ "ERROR: session has user correction(s) without durable learning_add. "
1329
+ "Call nexo_learning_add for the correction before nexo_stop. "
1330
+ f"pending={len(pending)}"
1331
+ )
1332
+ except Exception:
1333
+ pass
1291
1334
  _stop_keepalive(sid)
1292
1335
  complete_session(sid)
1293
1336
  return f"Session {sid} closed."