nexo-brain 3.2.0 → 4.0.0

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.
@@ -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 {"mcp__nexo__nexo_guard_check", "nexo_guard_check"}:
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 normalized not in protocol_files and normalized not in guard_files:
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 normalized not in protocol_files:
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(SPECIAL_LAUNCHAGENT_IDS)
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
- audit["read_without_protocol"] > 0
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
- and debt_summary["available"]
1967
- and debt_summary["open_total"] == 0
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"]) >= 7200
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
- status = "critical"
1974
- severity = "error"
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),
@@ -14,6 +14,8 @@ 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
17
19
 
18
20
 
19
21
  def _operation_kind(tool_name: str) -> str:
@@ -30,6 +32,37 @@ def _normalize_file_path(path: str) -> str:
30
32
  return _normalize_path_token(str(Path(path)))
31
33
 
32
34
 
35
+ def _resolve_runtime_path(path: str) -> Path:
36
+ candidate = Path(str(path or "")).expanduser()
37
+ if not candidate.is_absolute():
38
+ candidate = Path.cwd() / candidate
39
+ return candidate.resolve()
40
+
41
+
42
+ def _is_relative_to(candidate: Path, root: Path) -> bool:
43
+ try:
44
+ candidate.relative_to(root)
45
+ return True
46
+ except ValueError:
47
+ return False
48
+
49
+
50
+ def _automation_live_repo_guard_enabled() -> bool:
51
+ return (
52
+ os.environ.get("NEXO_AUTOMATION", "").strip() == "1"
53
+ and os.environ.get("NEXO_PUBLIC_CONTRIBUTION", "").strip() != "1"
54
+ )
55
+
56
+
57
+ def _is_live_repo_path(path: str) -> bool:
58
+ if not str(path or "").strip():
59
+ return False
60
+ try:
61
+ return _is_relative_to(_resolve_runtime_path(path), LIVE_REPO_ROOT)
62
+ except Exception:
63
+ return False
64
+
65
+
33
66
  def _extract_touched_files(tool_input) -> list[str]:
34
67
  files: list[str] = []
35
68
  if not isinstance(tool_input, dict):
@@ -166,19 +199,74 @@ def _ensure_protocol_debt(
166
199
  )
167
200
 
168
201
 
202
+ def _collect_automation_live_repo_blocks(
203
+ conn,
204
+ *,
205
+ sid: str,
206
+ tool_name: str,
207
+ files: list[str],
208
+ ) -> list[dict]:
209
+ if not _automation_live_repo_guard_enabled():
210
+ return []
211
+ blocks: list[dict] = []
212
+ for filepath in files:
213
+ if not _is_live_repo_path(filepath):
214
+ continue
215
+ debt = _ensure_protocol_debt(
216
+ conn,
217
+ session_id=sid,
218
+ task_id="",
219
+ debt_type="automation_live_repo_write_blocked",
220
+ severity="error",
221
+ evidence=(
222
+ f"{tool_name} attempted on {filepath} from an automation session against the live NEXO repo. "
223
+ "Use an isolated checkout/worktree or the public contribution Draft PR flow instead."
224
+ ),
225
+ file_token=filepath,
226
+ )
227
+ blocks.append(
228
+ {
229
+ "file": filepath,
230
+ "task_id": "",
231
+ "debt_id": debt.get("id"),
232
+ "debt_type": "automation_live_repo_write_blocked",
233
+ "reason_code": "automation_live_repo",
234
+ }
235
+ )
236
+ return blocks
237
+
238
+
169
239
  def process_pre_tool_event(payload: dict) -> dict:
170
- strictness = get_protocol_strictness()
171
240
  tool_name = str(payload.get("tool_name", "")).strip()
172
241
  op = _operation_kind(tool_name)
173
- if strictness == "lenient":
174
- return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
175
242
  if op not in {"write", "delete"}:
176
- return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": strictness}
243
+ return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
177
244
 
178
245
  tool_input = payload.get("tool_input")
179
246
  files = _extract_touched_files(tool_input)
247
+ strictness = get_protocol_strictness()
180
248
  conn = get_db()
181
249
  sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
250
+ automation_blocks = _collect_automation_live_repo_blocks(
251
+ conn,
252
+ sid=sid,
253
+ tool_name=tool_name,
254
+ files=files,
255
+ )
256
+ if automation_blocks:
257
+ return {
258
+ "ok": True,
259
+ "session_id": sid,
260
+ "tool_name": tool_name,
261
+ "operation": op,
262
+ "strictness": strictness,
263
+ "blocks": automation_blocks,
264
+ "status": "blocked",
265
+ }
266
+
267
+ if strictness == "lenient":
268
+ return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
269
+
182
270
  blocks: list[dict] = []
183
271
 
184
272
  if not sid:
@@ -438,11 +526,14 @@ def format_pretool_block_message(result: dict) -> str:
438
526
  if not blocks:
439
527
  return ""
440
528
  strictness = str(result.get("strictness") or "strict")
441
- header = (
442
- "NEXO LEARNING MODE BLOCKED THIS EDIT:"
443
- if strictness == "learning"
444
- else "NEXO STRICT MODE BLOCKED THIS EDIT:"
445
- )
529
+ if any(item.get("reason_code") == "automation_live_repo" for item in blocks):
530
+ header = "NEXO AUTOMATION SAFETY BLOCKED THIS EDIT:"
531
+ else:
532
+ header = (
533
+ "NEXO LEARNING MODE BLOCKED THIS EDIT:"
534
+ if strictness == "learning"
535
+ else "NEXO STRICT MODE BLOCKED THIS EDIT:"
536
+ )
446
537
  lines = [header]
447
538
  for item in blocks:
448
539
  file_note = item["file"] or "(unknown target)"
@@ -450,6 +541,11 @@ def format_pretool_block_message(result: dict) -> str:
450
541
  lines.append(
451
542
  f"- Start the shared-brain session first: call `nexo_startup`, then `nexo_task_open`, before editing {file_note}."
452
543
  )
544
+ elif item.get("reason_code") == "automation_live_repo":
545
+ lines.append(
546
+ f"- {file_note}: automation sessions cannot write to the live NEXO repo. "
547
+ "Use an isolated checkout/worktree or the public contribution Draft PR flow."
548
+ )
453
549
  elif item.get("reason_code") == "guard_unacknowledged":
454
550
  lines.append(
455
551
  f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
@@ -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