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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/client_sync.py +139 -0
- package/src/db/__init__.py +3 -0
- package/src/db/_protocol.py +156 -0
- package/src/db/_schema.py +23 -0
- package/src/doctor/providers/runtime.py +179 -1
- package/src/hook_guardrails.py +172 -5
- package/src/plugins/protocol.py +33 -0
- package/src/scripts/nexo-daily-self-audit.py +54 -0
- package/src/tools_learnings.py +18 -2
- package/src/tools_sessions.py +43 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.13.
|
|
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.
|
|
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
|
+
"version": "7.13.5",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
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",
|
package/src/client_sync.py
CHANGED
|
@@ -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
|
package/src/db/__init__.py
CHANGED
|
@@ -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,
|
package/src/db/_protocol.py
CHANGED
|
@@ -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),
|
package/src/hook_guardrails.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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":
|
package/src/plugins/protocol.py
CHANGED
|
@@ -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()
|
package/src/tools_learnings.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/src/tools_sessions.py
CHANGED
|
@@ -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."
|