nexo-brain 3.2.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/auto_update.py +53 -0
- package/src/claim_graph.py +128 -15
- package/src/cognitive/_trust.py +2 -2
- package/src/compaction_memory.py +227 -0
- package/src/dashboard/app.py +15 -12
- package/src/doctor/providers/runtime.py +140 -11
- package/src/hook_guardrails.py +147 -9
- package/src/hooks/pre-compact.sh +18 -0
- package/src/media_memory.py +303 -0
- package/src/memory_backends.py +71 -0
- package/src/plugins/claims_tools.py +119 -0
- package/src/plugins/cognitive_memory.py +16 -1
- package/src/plugins/media_memory_tools.py +98 -0
- package/src/plugins/memory_export.py +196 -0
- package/src/plugins/user_state_tools.py +43 -0
- package/src/script_registry.py +31 -14
- package/src/scripts/deep-sleep/collect.py +6 -1
- package/src/server.py +1 -0
- package/src/system_catalog.py +383 -16
- package/src/tools_sessions.py +69 -0
- package/src/user_state_model.py +170 -0
|
@@ -41,6 +41,7 @@ PROTECTED_MACOS_ROOTS = (
|
|
|
41
41
|
IMMUNE_FRESHNESS = 3600 # 1 hour (runs every 30 min)
|
|
42
42
|
WATCHDOG_FRESHNESS = 3600 # 1 hour (runs every 30 min)
|
|
43
43
|
DEFAULT_CRON_THRESHOLD = 7200 # Fallback when manifest data is unavailable
|
|
44
|
+
AUXILIARY_CORE_LAUNCHAGENT_IDS = {"dashboard", "prevent-sleep", "tcc-approve"}
|
|
44
45
|
SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
|
|
45
46
|
SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
|
|
46
47
|
OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
|
|
@@ -466,6 +467,7 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
|
|
|
466
467
|
|
|
467
468
|
for file_mtime, path in files:
|
|
468
469
|
cwd = ""
|
|
470
|
+
protocol_active = False
|
|
469
471
|
protocol_files: set[str] = set()
|
|
470
472
|
guard_files: set[str] = set()
|
|
471
473
|
guard_ack = False
|
|
@@ -496,9 +498,15 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
|
|
|
496
498
|
args = _parse_jsonish_arguments(payload.get("arguments"))
|
|
497
499
|
|
|
498
500
|
if name in {"mcp__nexo__nexo_task_open", "nexo_task_open"}:
|
|
501
|
+
protocol_active = True
|
|
499
502
|
protocol_files.update(_extract_declared_file_targets(args, cwd))
|
|
500
503
|
continue
|
|
501
|
-
if name in {
|
|
504
|
+
if name in {
|
|
505
|
+
"mcp__nexo__nexo_guard_check",
|
|
506
|
+
"nexo_guard_check",
|
|
507
|
+
"mcp__nexo__nexo_guard_file_check",
|
|
508
|
+
"nexo_guard_file_check",
|
|
509
|
+
}:
|
|
502
510
|
guard_files.update(_extract_declared_file_targets(args, cwd))
|
|
503
511
|
continue
|
|
504
512
|
if name in {"mcp__nexo__nexo_task_acknowledge_guard", "nexo_task_acknowledge_guard"}:
|
|
@@ -522,8 +530,10 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
|
|
|
522
530
|
session_touches += 1
|
|
523
531
|
status["conditioned_touches"] += 1
|
|
524
532
|
normalized = _normalize_path_token(touched)
|
|
533
|
+
has_protocol = protocol_active or normalized in protocol_files
|
|
534
|
+
has_guard_review = normalized in guard_files
|
|
525
535
|
if operation == "read":
|
|
526
|
-
if
|
|
536
|
+
if not has_protocol and not has_guard_review:
|
|
527
537
|
status["read_without_protocol"] += 1
|
|
528
538
|
age_seconds = (
|
|
529
539
|
event_age_seconds
|
|
@@ -537,7 +547,7 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
|
|
|
537
547
|
{"kind": "read_without_protocol", "file": touched, "tool": name}
|
|
538
548
|
)
|
|
539
549
|
elif operation in {"write", "delete"}:
|
|
540
|
-
if
|
|
550
|
+
if not has_protocol and not has_guard_review:
|
|
541
551
|
status["write_without_protocol"] += 1
|
|
542
552
|
if operation == "delete":
|
|
543
553
|
status["delete_without_protocol"] += 1
|
|
@@ -884,7 +894,7 @@ def _launchagent_schedule_expectations() -> dict[str, dict]:
|
|
|
884
894
|
|
|
885
895
|
|
|
886
896
|
def _managed_launchagent_plists() -> list[tuple[str, Path]]:
|
|
887
|
-
ids = set(
|
|
897
|
+
ids = set(AUXILIARY_CORE_LAUNCHAGENT_IDS)
|
|
888
898
|
for cron_id, expected in _launchagent_schedule_expectations().items():
|
|
889
899
|
if expected.get("schedule_configured"):
|
|
890
900
|
ids.add(cron_id)
|
|
@@ -897,6 +907,60 @@ def _managed_launchagent_plists() -> list[tuple[str, Path]]:
|
|
|
897
907
|
return plists
|
|
898
908
|
|
|
899
909
|
|
|
910
|
+
def _known_nexo_launchagent_ids() -> set[str]:
|
|
911
|
+
ids = set(AUXILIARY_CORE_LAUNCHAGENT_IDS)
|
|
912
|
+
for cron_id, expected in _launchagent_schedule_expectations().items():
|
|
913
|
+
if expected.get("schedule_configured"):
|
|
914
|
+
ids.add(cron_id)
|
|
915
|
+
try:
|
|
916
|
+
from db import init_db, list_personal_script_schedules
|
|
917
|
+
|
|
918
|
+
init_db()
|
|
919
|
+
for schedule in list_personal_script_schedules():
|
|
920
|
+
cron_id = str(schedule.get("cron_id", "") or "").strip()
|
|
921
|
+
if cron_id:
|
|
922
|
+
ids.add(cron_id)
|
|
923
|
+
except Exception:
|
|
924
|
+
pass
|
|
925
|
+
return ids
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _discover_actual_nexo_launchagent_ids() -> tuple[set[str], dict[str, str]]:
|
|
929
|
+
ids: set[str] = set()
|
|
930
|
+
evidence: dict[str, str] = {}
|
|
931
|
+
|
|
932
|
+
if LAUNCH_AGENTS_DIR.is_dir():
|
|
933
|
+
for plist_path in sorted(LAUNCH_AGENTS_DIR.glob("com.nexo.*.plist")):
|
|
934
|
+
cron_id = plist_path.stem.removeprefix("com.nexo.")
|
|
935
|
+
ids.add(cron_id)
|
|
936
|
+
evidence.setdefault(cron_id, f"plist on disk: {plist_path}")
|
|
937
|
+
|
|
938
|
+
if platform.system() != "Darwin":
|
|
939
|
+
return ids, evidence
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
result = subprocess.run(
|
|
943
|
+
["launchctl", "list"],
|
|
944
|
+
capture_output=True,
|
|
945
|
+
text=True,
|
|
946
|
+
timeout=3,
|
|
947
|
+
)
|
|
948
|
+
except Exception:
|
|
949
|
+
return ids, evidence
|
|
950
|
+
|
|
951
|
+
for raw_line in (result.stdout or "").splitlines():
|
|
952
|
+
parts = raw_line.split()
|
|
953
|
+
if not parts:
|
|
954
|
+
continue
|
|
955
|
+
label = parts[-1].strip()
|
|
956
|
+
if not label.startswith("com.nexo."):
|
|
957
|
+
continue
|
|
958
|
+
cron_id = label.removeprefix("com.nexo.")
|
|
959
|
+
ids.add(cron_id)
|
|
960
|
+
evidence.setdefault(cron_id, f"loaded in launchctl: {label}")
|
|
961
|
+
return ids, evidence
|
|
962
|
+
|
|
963
|
+
|
|
900
964
|
def _extract_launchctl_value(output: str, prefix: str) -> str | None:
|
|
901
965
|
for line in output.splitlines():
|
|
902
966
|
stripped = line.strip()
|
|
@@ -1462,6 +1526,58 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
1462
1526
|
return check
|
|
1463
1527
|
|
|
1464
1528
|
|
|
1529
|
+
def check_launchagent_inventory() -> DoctorCheck:
|
|
1530
|
+
"""Check that every discovered com.nexo LaunchAgent is known to core or the personal registry."""
|
|
1531
|
+
if platform.system() != "Darwin":
|
|
1532
|
+
return DoctorCheck(
|
|
1533
|
+
id="runtime.launchagent_inventory",
|
|
1534
|
+
tier="runtime",
|
|
1535
|
+
status="healthy",
|
|
1536
|
+
severity="info",
|
|
1537
|
+
summary="LaunchAgent inventory check skipped on non-macOS",
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
actual_ids, actual_evidence = _discover_actual_nexo_launchagent_ids()
|
|
1541
|
+
if not actual_ids:
|
|
1542
|
+
return DoctorCheck(
|
|
1543
|
+
id="runtime.launchagent_inventory",
|
|
1544
|
+
tier="runtime",
|
|
1545
|
+
status="healthy",
|
|
1546
|
+
severity="info",
|
|
1547
|
+
summary="No com.nexo LaunchAgents discovered on this Mac",
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1550
|
+
known_ids = _known_nexo_launchagent_ids()
|
|
1551
|
+
unknown_ids = sorted(actual_ids - known_ids)
|
|
1552
|
+
if not unknown_ids:
|
|
1553
|
+
return DoctorCheck(
|
|
1554
|
+
id="runtime.launchagent_inventory",
|
|
1555
|
+
tier="runtime",
|
|
1556
|
+
status="healthy",
|
|
1557
|
+
severity="info",
|
|
1558
|
+
summary=f"LaunchAgent inventory aligned ({len(actual_ids)} discovered, all known to NEXO)",
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
evidence = [actual_evidence.get(cron_id, f"com.nexo.{cron_id}") for cron_id in unknown_ids[:10]]
|
|
1562
|
+
return DoctorCheck(
|
|
1563
|
+
id="runtime.launchagent_inventory",
|
|
1564
|
+
tier="runtime",
|
|
1565
|
+
status="degraded",
|
|
1566
|
+
severity="warn",
|
|
1567
|
+
summary=f"Unknown com.nexo LaunchAgents detected ({len(unknown_ids)})",
|
|
1568
|
+
evidence=evidence,
|
|
1569
|
+
repair_plan=[
|
|
1570
|
+
"If it is a personal automation, register/sync it through nexo scripts sync/reconcile",
|
|
1571
|
+
"If it is a core helper, add it to the core LaunchAgent inventory instead of leaving it implicit",
|
|
1572
|
+
"If it is retired, boot it out and remove its plist through the owning flow rather than editing plists by hand",
|
|
1573
|
+
],
|
|
1574
|
+
escalation_prompt=(
|
|
1575
|
+
"There are active or installed com.nexo LaunchAgents that NEXO cannot explain from the core manifest, "
|
|
1576
|
+
"auxiliary core services, or the personal script registry."
|
|
1577
|
+
),
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
|
|
1465
1581
|
def check_skill_health(fix: bool = False) -> DoctorCheck:
|
|
1466
1582
|
"""Check executable skill consistency and approval state."""
|
|
1467
1583
|
try:
|
|
@@ -1567,6 +1683,7 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
|
|
|
1567
1683
|
"Run nexo scripts reconcile so declared schedules are recreated through the official flow",
|
|
1568
1684
|
"Use nexo doctor --tier runtime --fix to apply the safe reconcile path for declared schedules",
|
|
1569
1685
|
"Keep personal scripts in NEXO_HOME/scripts so updates do not collide with core",
|
|
1686
|
+
"Prefer ps- prefixed filenames for new personal scripts so ownership stays obvious at a glance",
|
|
1570
1687
|
],
|
|
1571
1688
|
escalation_prompt=(
|
|
1572
1689
|
"Personal script metadata, files, and personal cron schedules are out of sync. "
|
|
@@ -1957,21 +2074,32 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
|
|
|
1957
2074
|
if not repair_plan:
|
|
1958
2075
|
repair_plan.append("Keep using managed Codex bootstrap so conditioned-file discipline remains visible in transcripts")
|
|
1959
2076
|
|
|
2077
|
+
no_open_conditioned_debt = debt_summary["available"] and debt_summary["open_total"] == 0
|
|
1960
2078
|
historical_read_only = (
|
|
1961
|
-
|
|
2079
|
+
no_open_conditioned_debt
|
|
2080
|
+
and audit["read_without_protocol"] > 0
|
|
1962
2081
|
and audit["write_without_protocol"] == 0
|
|
1963
2082
|
and audit["write_without_guard_ack"] == 0
|
|
1964
2083
|
and audit["delete_without_protocol"] == 0
|
|
1965
2084
|
and audit["delete_without_guard_ack"] == 0
|
|
1966
|
-
|
|
1967
|
-
|
|
2085
|
+
)
|
|
2086
|
+
historical_write_drift = (
|
|
2087
|
+
no_open_conditioned_debt
|
|
1968
2088
|
and audit.get("latest_violation_age_seconds") is not None
|
|
1969
|
-
and float(audit["latest_violation_age_seconds"]) >=
|
|
2089
|
+
and float(audit["latest_violation_age_seconds"]) >= 172800
|
|
2090
|
+
and audit["write_without_protocol"] > 0
|
|
2091
|
+
and audit["write_without_guard_ack"] == 0
|
|
2092
|
+
and audit["delete_without_protocol"] == 0
|
|
2093
|
+
and audit["delete_without_guard_ack"] == 0
|
|
1970
2094
|
)
|
|
1971
2095
|
|
|
1972
2096
|
if audit["write_without_protocol"] or audit["write_without_guard_ack"]:
|
|
1973
|
-
|
|
1974
|
-
|
|
2097
|
+
if historical_write_drift:
|
|
2098
|
+
status = "healthy"
|
|
2099
|
+
severity = "info"
|
|
2100
|
+
else:
|
|
2101
|
+
status = "critical"
|
|
2102
|
+
severity = "error"
|
|
1975
2103
|
elif historical_read_only:
|
|
1976
2104
|
status = "healthy"
|
|
1977
2105
|
severity = "info"
|
|
@@ -1989,7 +2117,7 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
|
|
|
1989
2117
|
severity=severity,
|
|
1990
2118
|
summary=(
|
|
1991
2119
|
"Historical Codex conditioned-file drift has no open protocol debt"
|
|
1992
|
-
if historical_read_only
|
|
2120
|
+
if historical_read_only or historical_write_drift
|
|
1993
2121
|
else "Recent Codex sessions respect conditioned-file discipline"
|
|
1994
2122
|
if status == "healthy"
|
|
1995
2123
|
else "Recent Codex sessions are bypassing conditioned-file discipline"
|
|
@@ -2655,6 +2783,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
2655
2783
|
safe_check(check_automation_telemetry),
|
|
2656
2784
|
safe_check(check_state_watchers),
|
|
2657
2785
|
safe_check(check_release_artifact_sync),
|
|
2786
|
+
safe_check(check_launchagent_inventory),
|
|
2658
2787
|
safe_check(check_launchagent_integrity, fix=fix),
|
|
2659
2788
|
safe_check(check_personal_script_registry, fix=fix),
|
|
2660
2789
|
safe_check(check_skill_health, fix=fix),
|
package/src/hook_guardrails.py
CHANGED
|
@@ -14,6 +14,30 @@ from protocol_settings import get_protocol_strictness
|
|
|
14
14
|
READ_LIKE_TOOLS = {"Read"}
|
|
15
15
|
WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
|
|
16
16
|
DELETE_LIKE_TOOLS = {"Delete"}
|
|
17
|
+
NEXO_CODE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent))).expanduser().resolve()
|
|
18
|
+
LIVE_REPO_ROOT = NEXO_CODE_ROOT.parent if NEXO_CODE_ROOT.name == "src" else NEXO_CODE_ROOT
|
|
19
|
+
PUBLIC_REPO_DIRS = {
|
|
20
|
+
".claude-plugin",
|
|
21
|
+
".github",
|
|
22
|
+
"bin",
|
|
23
|
+
"clawhub-skill",
|
|
24
|
+
"community",
|
|
25
|
+
"docs",
|
|
26
|
+
"hooks",
|
|
27
|
+
"openclaw-plugin",
|
|
28
|
+
"src",
|
|
29
|
+
"templates",
|
|
30
|
+
"tests",
|
|
31
|
+
}
|
|
32
|
+
PUBLIC_REPO_FILES = {
|
|
33
|
+
".mcp.json",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md",
|
|
37
|
+
"docker-compose.yml",
|
|
38
|
+
"package-lock.json",
|
|
39
|
+
"package.json",
|
|
40
|
+
}
|
|
17
41
|
|
|
18
42
|
|
|
19
43
|
def _operation_kind(tool_name: str) -> str:
|
|
@@ -30,6 +54,57 @@ def _normalize_file_path(path: str) -> str:
|
|
|
30
54
|
return _normalize_path_token(str(Path(path)))
|
|
31
55
|
|
|
32
56
|
|
|
57
|
+
def _resolve_runtime_path(path: str) -> Path:
|
|
58
|
+
candidate = Path(str(path or "")).expanduser()
|
|
59
|
+
if not candidate.is_absolute():
|
|
60
|
+
candidate = Path.cwd() / candidate
|
|
61
|
+
return candidate.resolve()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_relative_to(candidate: Path, root: Path) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
candidate.relative_to(root)
|
|
67
|
+
return True
|
|
68
|
+
except ValueError:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _automation_live_repo_guard_enabled() -> bool:
|
|
73
|
+
return (
|
|
74
|
+
os.environ.get("NEXO_AUTOMATION", "").strip() == "1"
|
|
75
|
+
and os.environ.get("NEXO_PUBLIC_CONTRIBUTION", "").strip() != "1"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has_git_marker(root: Path) -> bool:
|
|
80
|
+
return (root / ".git").exists()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_public_repo_surface(candidate: Path) -> bool:
|
|
84
|
+
try:
|
|
85
|
+
relative = candidate.relative_to(LIVE_REPO_ROOT)
|
|
86
|
+
except ValueError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
parts = relative.parts
|
|
90
|
+
if not parts:
|
|
91
|
+
return False
|
|
92
|
+
if parts[0] in PUBLIC_REPO_DIRS:
|
|
93
|
+
return True
|
|
94
|
+
return len(parts) == 1 and parts[0] in PUBLIC_REPO_FILES
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_live_repo_path(path: str) -> bool:
|
|
98
|
+
if not str(path or "").strip():
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
if not _has_git_marker(LIVE_REPO_ROOT):
|
|
102
|
+
return False
|
|
103
|
+
return _is_public_repo_surface(_resolve_runtime_path(path))
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
33
108
|
def _extract_touched_files(tool_input) -> list[str]:
|
|
34
109
|
files: list[str] = []
|
|
35
110
|
if not isinstance(tool_input, dict):
|
|
@@ -166,19 +241,74 @@ def _ensure_protocol_debt(
|
|
|
166
241
|
)
|
|
167
242
|
|
|
168
243
|
|
|
244
|
+
def _collect_automation_live_repo_blocks(
|
|
245
|
+
conn,
|
|
246
|
+
*,
|
|
247
|
+
sid: str,
|
|
248
|
+
tool_name: str,
|
|
249
|
+
files: list[str],
|
|
250
|
+
) -> list[dict]:
|
|
251
|
+
if not _automation_live_repo_guard_enabled():
|
|
252
|
+
return []
|
|
253
|
+
blocks: list[dict] = []
|
|
254
|
+
for filepath in files:
|
|
255
|
+
if not _is_live_repo_path(filepath):
|
|
256
|
+
continue
|
|
257
|
+
debt = _ensure_protocol_debt(
|
|
258
|
+
conn,
|
|
259
|
+
session_id=sid,
|
|
260
|
+
task_id="",
|
|
261
|
+
debt_type="automation_live_repo_write_blocked",
|
|
262
|
+
severity="error",
|
|
263
|
+
evidence=(
|
|
264
|
+
f"{tool_name} attempted on {filepath} from an automation session against the live NEXO repo. "
|
|
265
|
+
"Use an isolated checkout/worktree or the public contribution Draft PR flow instead."
|
|
266
|
+
),
|
|
267
|
+
file_token=filepath,
|
|
268
|
+
)
|
|
269
|
+
blocks.append(
|
|
270
|
+
{
|
|
271
|
+
"file": filepath,
|
|
272
|
+
"task_id": "",
|
|
273
|
+
"debt_id": debt.get("id"),
|
|
274
|
+
"debt_type": "automation_live_repo_write_blocked",
|
|
275
|
+
"reason_code": "automation_live_repo",
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
return blocks
|
|
279
|
+
|
|
280
|
+
|
|
169
281
|
def process_pre_tool_event(payload: dict) -> dict:
|
|
170
|
-
strictness = get_protocol_strictness()
|
|
171
282
|
tool_name = str(payload.get("tool_name", "")).strip()
|
|
172
283
|
op = _operation_kind(tool_name)
|
|
173
|
-
if strictness == "lenient":
|
|
174
|
-
return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
|
|
175
284
|
if op not in {"write", "delete"}:
|
|
176
|
-
return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness":
|
|
285
|
+
return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
|
|
177
286
|
|
|
178
287
|
tool_input = payload.get("tool_input")
|
|
179
288
|
files = _extract_touched_files(tool_input)
|
|
289
|
+
strictness = get_protocol_strictness()
|
|
180
290
|
conn = get_db()
|
|
181
291
|
sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
|
|
292
|
+
automation_blocks = _collect_automation_live_repo_blocks(
|
|
293
|
+
conn,
|
|
294
|
+
sid=sid,
|
|
295
|
+
tool_name=tool_name,
|
|
296
|
+
files=files,
|
|
297
|
+
)
|
|
298
|
+
if automation_blocks:
|
|
299
|
+
return {
|
|
300
|
+
"ok": True,
|
|
301
|
+
"session_id": sid,
|
|
302
|
+
"tool_name": tool_name,
|
|
303
|
+
"operation": op,
|
|
304
|
+
"strictness": strictness,
|
|
305
|
+
"blocks": automation_blocks,
|
|
306
|
+
"status": "blocked",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if strictness == "lenient":
|
|
310
|
+
return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
|
|
311
|
+
|
|
182
312
|
blocks: list[dict] = []
|
|
183
313
|
|
|
184
314
|
if not sid:
|
|
@@ -438,11 +568,14 @@ def format_pretool_block_message(result: dict) -> str:
|
|
|
438
568
|
if not blocks:
|
|
439
569
|
return ""
|
|
440
570
|
strictness = str(result.get("strictness") or "strict")
|
|
441
|
-
|
|
442
|
-
"NEXO
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
571
|
+
if any(item.get("reason_code") == "automation_live_repo" for item in blocks):
|
|
572
|
+
header = "NEXO AUTOMATION SAFETY BLOCKED THIS EDIT:"
|
|
573
|
+
else:
|
|
574
|
+
header = (
|
|
575
|
+
"NEXO LEARNING MODE BLOCKED THIS EDIT:"
|
|
576
|
+
if strictness == "learning"
|
|
577
|
+
else "NEXO STRICT MODE BLOCKED THIS EDIT:"
|
|
578
|
+
)
|
|
446
579
|
lines = [header]
|
|
447
580
|
for item in blocks:
|
|
448
581
|
file_note = item["file"] or "(unknown target)"
|
|
@@ -450,6 +583,11 @@ def format_pretool_block_message(result: dict) -> str:
|
|
|
450
583
|
lines.append(
|
|
451
584
|
f"- Start the shared-brain session first: call `nexo_startup`, then `nexo_task_open`, before editing {file_note}."
|
|
452
585
|
)
|
|
586
|
+
elif item.get("reason_code") == "automation_live_repo":
|
|
587
|
+
lines.append(
|
|
588
|
+
f"- {file_note}: automation sessions cannot write to the live NEXO repo. "
|
|
589
|
+
"Use an isolated checkout/worktree or the public contribution Draft PR flow."
|
|
590
|
+
)
|
|
453
591
|
elif item.get("reason_code") == "guard_unacknowledged":
|
|
454
592
|
lines.append(
|
|
455
593
|
f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
|
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# 2. Injects a systemMessage telling the operator to save any WIP via MCP tools
|
|
6
6
|
set -uo pipefail
|
|
7
7
|
|
|
8
|
+
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
9
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
9
10
|
NEXO_DB="$NEXO_HOME/data/nexo.db"
|
|
10
11
|
mkdir -p "$NEXO_HOME/data"
|
|
@@ -140,6 +141,23 @@ conn.execute('''
|
|
|
140
141
|
VALUES (?, ?, '', ?, ?, 'auto-generated', 'auto', '', ?, 'pre-compact-hook')
|
|
141
142
|
''', (sid, decisions, pending, context_next, summary))
|
|
142
143
|
conn.commit()
|
|
144
|
+
|
|
145
|
+
# Layer 3: structured auto-flush for continuity and inspectability
|
|
146
|
+
try:
|
|
147
|
+
import os
|
|
148
|
+
sys.path.insert(0, os.path.abspath(os.path.join('$HOOK_DIR', '..')))
|
|
149
|
+
import compaction_memory
|
|
150
|
+
compaction_memory.record_auto_flush(
|
|
151
|
+
session_id=sid,
|
|
152
|
+
task=task,
|
|
153
|
+
current_goal='',
|
|
154
|
+
log_file=log_file,
|
|
155
|
+
last_diary_ts=last_diary_ts,
|
|
156
|
+
source='pre-compact-hook',
|
|
157
|
+
)
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
143
161
|
conn.close()
|
|
144
162
|
" 2>/dev/null || true
|
|
145
163
|
fi
|