nexo-brain 7.12.15 → 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.12.15",
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,11 @@
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.12.15` is the current packaged-runtime line. 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.
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.
24
+
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.
22
26
 
23
27
  Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
24
28
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.15",
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",
@@ -152,6 +152,36 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
152
152
  delete_diary_draft(sid)
153
153
 
154
154
 
155
+ def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
156
+ """Close stale open protocol tasks as partial when their session is reaped."""
157
+ rows = conn.execute(
158
+ """SELECT task_id, goal
159
+ FROM protocol_tasks
160
+ WHERE session_id = ? AND status = 'open'
161
+ ORDER BY opened_at ASC""",
162
+ (sid,),
163
+ ).fetchall()
164
+ closed: list[str] = []
165
+ for row in rows:
166
+ task_id = row["task_id"]
167
+ goal = str(row["goal"] or "")
168
+ evidence = (
169
+ f"Auto-closed as partial because session {sid} became stale before an explicit nexo_task_close. "
170
+ f"Session task: {task or 'unknown'}. Open goal: {goal[:240]}"
171
+ )
172
+ conn.execute(
173
+ """UPDATE protocol_tasks
174
+ SET status = 'partial',
175
+ close_evidence = ?,
176
+ outcome_notes = 'auto-close: stale session ended without explicit task_close',
177
+ closed_at = datetime('now')
178
+ WHERE task_id = ? AND status = 'open'""",
179
+ (evidence[:4000], task_id),
180
+ )
181
+ closed.append(task_id)
182
+ return closed
183
+
184
+
155
185
  def main():
156
186
  init_db()
157
187
  conn = get_db()
@@ -161,9 +191,12 @@ def main():
161
191
  print(f"[{datetime.datetime.now().isoformat(timespec='seconds')}] No stale sessions")
162
192
  return
163
193
 
194
+ closed_task_ids: list[str] = []
164
195
  for session in orphans:
165
196
  sid = session["sid"]
166
197
  draft = get_diary_draft(sid)
198
+ closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
199
+ closed_task_ids.extend(closed_tasks)
167
200
 
168
201
  if draft:
169
202
  promote_draft_to_diary(sid, draft, task=session.get("task", ""))
@@ -196,7 +229,10 @@ def main():
196
229
  os.makedirs(os.path.dirname(AUTO_CLOSE_LOG), exist_ok=True)
197
230
  with open(AUTO_CLOSE_LOG, "a") as f:
198
231
  ts = datetime.datetime.now().isoformat(timespec="seconds")
199
- f.write(f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]}\n")
232
+ f.write(
233
+ f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]} "
234
+ f"and {len(closed_task_ids)} protocol task(s): {closed_task_ids}\n"
235
+ )
200
236
 
201
237
 
202
238
  if __name__ == "__main__":
@@ -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