nexo-brain 7.1.0 → 7.1.2

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.
Files changed (176) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/bin/nexo-brain.js +198 -92
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +10 -8
  6. package/src/auto_close_sessions.py +19 -2
  7. package/src/auto_update.py +305 -42
  8. package/src/autonomy_mandate.py +260 -0
  9. package/src/bootstrap_docs.py +22 -1
  10. package/src/cli.py +181 -1
  11. package/src/cli_email.py +104 -73
  12. package/src/client_sync.py +22 -1
  13. package/src/cognitive/_core.py +5 -3
  14. package/src/core_prompts.py +50 -0
  15. package/src/cron_recovery.py +81 -7
  16. package/src/crons/manifest.json +57 -0
  17. package/src/crons/sync.py +95 -26
  18. package/src/dashboard/app.py +59 -0
  19. package/src/dashboard/templates/base.html +2 -0
  20. package/src/dashboard/templates/feature-disabled.html +27 -0
  21. package/src/db/_email_accounts.py +67 -18
  22. package/src/db/_fts.py +5 -5
  23. package/src/db/_personal_scripts.py +1 -1
  24. package/src/db/_skills.py +3 -3
  25. package/src/doctor/providers/runtime.py +35 -20
  26. package/src/email_config.py +18 -9
  27. package/src/enforcement_classifier.py +3 -12
  28. package/src/evolution_cycle.py +37 -149
  29. package/src/guardian_telemetry.py +3 -2
  30. package/src/hook_guardrails.py +61 -0
  31. package/src/hooks/capture-tool-logs.sh +11 -3
  32. package/src/hooks/daily-briefing-check.sh +7 -2
  33. package/src/hooks/heartbeat-enforcement.py +14 -1
  34. package/src/hooks/heartbeat-posttool.sh +2 -0
  35. package/src/hooks/heartbeat-user-msg.sh +2 -0
  36. package/src/hooks/inbox-hook.sh +6 -2
  37. package/src/hooks/post-compact.sh +12 -4
  38. package/src/hooks/pre-compact.sh +12 -4
  39. package/src/migrate_embeddings.py +5 -3
  40. package/src/nexo_migrate.py +3 -1
  41. package/src/plugin_loader.py +14 -5
  42. package/src/plugins/adaptive_mode.py +4 -1
  43. package/src/plugins/backup.py +32 -20
  44. package/src/plugins/evolution.py +2 -0
  45. package/src/plugins/memory_export.py +6 -1
  46. package/src/plugins/personal_plugins.py +17 -7
  47. package/src/plugins/personal_scripts.py +64 -3
  48. package/src/presets/entities_universal.json +67 -4
  49. package/src/product_mode.py +201 -0
  50. package/src/r14_correction_learning.py +5 -20
  51. package/src/r15_project_context.py +4 -10
  52. package/src/r16_declared_done.py +3 -16
  53. package/src/r17_promise_debt.py +3 -16
  54. package/src/r18_followup_autocomplete.py +5 -7
  55. package/src/r19_project_grep.py +5 -8
  56. package/src/r20_constant_change.py +5 -15
  57. package/src/r21_legacy_path.py +5 -7
  58. package/src/r22_personal_script.py +4 -8
  59. package/src/r23_ssh_without_atlas.py +4 -11
  60. package/src/r23b_deploy_vhost.py +7 -6
  61. package/src/r23c_cwd_mismatch.py +7 -6
  62. package/src/r23d_chown_chmod_recursive.py +6 -6
  63. package/src/r23e_force_push_main.py +5 -6
  64. package/src/r23f_db_no_where.py +5 -6
  65. package/src/r23g_secrets_in_output.py +5 -5
  66. package/src/r23h_shebang_mismatch.py +6 -5
  67. package/src/r23i_auto_deploy_ignored.py +5 -6
  68. package/src/r23j_global_install.py +5 -6
  69. package/src/r23k_script_duplicates_skill.py +7 -6
  70. package/src/r23l_resource_collision.py +7 -6
  71. package/src/r23m_message_duplicate.py +6 -5
  72. package/src/r24_stale_memory.py +4 -9
  73. package/src/r25_nora_maria_read_only.py +5 -10
  74. package/src/r34_identity_coherence.py +6 -13
  75. package/src/r_catalog.py +3 -7
  76. package/src/resonance_map.py +13 -13
  77. package/src/runtime_power.py +29 -80
  78. package/src/script_registry.py +236 -6
  79. package/src/scripts/check-context.py +8 -25
  80. package/src/scripts/deep-sleep/extract.py +6 -10
  81. package/src/scripts/nexo-auto-update.py +27 -4
  82. package/src/scripts/nexo-catchup.py +9 -19
  83. package/src/scripts/nexo-cognitive-decay.py +26 -3
  84. package/src/scripts/nexo-daily-self-audit.py +50 -51
  85. package/src/scripts/nexo-email-migrate-config.py +30 -11
  86. package/src/scripts/nexo-email-monitor.py +97 -238
  87. package/src/scripts/nexo-followup-runner.py +70 -133
  88. package/src/scripts/nexo-hook-record.py +1 -1
  89. package/src/scripts/nexo-immune.py +6 -31
  90. package/src/scripts/nexo-impact-scorer.py +27 -4
  91. package/src/scripts/nexo-learning-housekeep.py +26 -3
  92. package/src/scripts/nexo-learning-validator.py +34 -32
  93. package/src/scripts/nexo-migrate.py +28 -12
  94. package/src/scripts/nexo-morning-agent.py +9 -23
  95. package/src/scripts/nexo-outcome-checker.py +27 -4
  96. package/src/scripts/nexo-postmortem-consolidator.py +30 -62
  97. package/src/scripts/nexo-pre-commit.py +28 -0
  98. package/src/scripts/nexo-proactive-dashboard.py +27 -0
  99. package/src/scripts/nexo-reflection.py +33 -3
  100. package/src/scripts/nexo-runtime-preflight.py +27 -2
  101. package/src/scripts/nexo-send-reply.py +10 -8
  102. package/src/scripts/nexo-sleep.py +11 -25
  103. package/src/scripts/nexo-synthesis.py +7 -40
  104. package/src/scripts/nexo-watchdog-smoke.py +30 -1
  105. package/src/scripts/nexo-watchdog.sh +23 -17
  106. package/src/scripts/phase_guardian_analysis.py +27 -4
  107. package/src/server.py +14 -3
  108. package/src/storage_router.py +8 -6
  109. package/src/tools_drive.py +5 -13
  110. package/src/tools_guardian.py +3 -4
  111. package/src/tools_menu.py +2 -2
  112. package/src/tools_reminders_crud.py +17 -0
  113. package/src/tools_sessions.py +1 -4
  114. package/src/user_context.py +3 -6
  115. package/src/user_data_portability.py +31 -23
  116. package/templates/CLAUDE.md.template +11 -3
  117. package/templates/CODEX.AGENTS.md.template +11 -3
  118. package/templates/core-prompts/catchup-assessment.md +19 -0
  119. package/templates/core-prompts/check-context.md +24 -0
  120. package/templates/core-prompts/daily-self-audit.md +42 -0
  121. package/templates/core-prompts/daily-synthesis.md +40 -0
  122. package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
  123. package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
  124. package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
  125. package/templates/core-prompts/email-monitor.md +202 -0
  126. package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
  127. package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
  128. package/templates/core-prompts/evolution-public-contribution.md +32 -0
  129. package/templates/core-prompts/evolution-public-pr-review.md +38 -0
  130. package/templates/core-prompts/evolution-weekly.md +71 -0
  131. package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
  132. package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
  133. package/templates/core-prompts/followup-runner.md +74 -0
  134. package/templates/core-prompts/immune-triage.md +31 -0
  135. package/templates/core-prompts/interactive-startup.md +1 -0
  136. package/templates/core-prompts/json-object-only.md +1 -0
  137. package/templates/core-prompts/learning-validator.md +25 -0
  138. package/templates/core-prompts/morning-agent-json-output.md +1 -0
  139. package/templates/core-prompts/morning-agent.md +23 -0
  140. package/templates/core-prompts/postmortem-consolidator.md +60 -0
  141. package/templates/core-prompts/r-catalog.md +1 -0
  142. package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
  143. package/templates/core-prompts/r14-correction-learning-question.md +1 -0
  144. package/templates/core-prompts/r15-project-context-injection.md +1 -0
  145. package/templates/core-prompts/r16-declared-done-injection.md +1 -0
  146. package/templates/core-prompts/r16-declared-done-question.md +1 -0
  147. package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
  148. package/templates/core-prompts/r17-promise-debt-question.md +1 -0
  149. package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
  150. package/templates/core-prompts/r19-project-grep-injection.md +1 -0
  151. package/templates/core-prompts/r20-constant-change-injection.md +1 -0
  152. package/templates/core-prompts/r20-constant-change-question.md +1 -0
  153. package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
  154. package/templates/core-prompts/r22-personal-script-injection.md +1 -0
  155. package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
  156. package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
  157. package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
  158. package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
  159. package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
  160. package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
  161. package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
  162. package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
  163. package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
  164. package/templates/core-prompts/r23j-global-install-injection.md +1 -0
  165. package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
  166. package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
  167. package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
  168. package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
  169. package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
  170. package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
  171. package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
  172. package/templates/core-prompts/sleep.md +25 -0
  173. package/templates/email-template.md +55 -0
  174. package/templates/nexo_helper.py +31 -13
  175. package/templates/plugin-template.py +3 -3
  176. package/templates/skill-template.md +2 -1
@@ -25,7 +25,7 @@ from client_preferences import (
25
25
  normalize_client_preferences,
26
26
  resolve_client_runtime_profile,
27
27
  )
28
- from cron_recovery import resolve_declared_schedule, should_run_at_load
28
+ from cron_recovery import is_cron_enabled, resolve_declared_schedule, should_run_at_load
29
29
  from doctor.models import DoctorCheck, safe_check
30
30
 
31
31
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
@@ -43,15 +43,20 @@ IMMUNE_FRESHNESS = 3600 # 1 hour (runs every 30 min)
43
43
  WATCHDOG_FRESHNESS = 3600 # 1 hour (runs every 30 min)
44
44
  DEFAULT_CRON_THRESHOLD = 7200 # Fallback when manifest data is unavailable
45
45
  LIVE_PROTOCOL_SESSION_FRESHNESS = 1800 # 30 minutes
46
- AUXILIARY_CORE_LAUNCHAGENT_IDS = {"backup", "dashboard", "prevent-sleep", "tcc-approve"}
47
- SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
48
- SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
46
+ SPECIAL_ENV_NORMALIZE_IDS = {"prevent-sleep", "tcc-approve"}
49
47
  OPTIONALS_FILE = paths.config_dir() / "optionals.json"
50
48
  SCHEDULE_FILE = paths.config_dir() / "schedule.json"
51
49
  PACKAGE_JSON = NEXO_CODE / "package.json"
52
50
  CHANGELOG_FILE = NEXO_CODE / "CHANGELOG.md"
53
51
 
54
52
 
53
+ def _expected_runtime_code_dir() -> Path:
54
+ packaged = NEXO_HOME / "core"
55
+ if packaged.exists() or not (NEXO_HOME / "server.py").is_file():
56
+ return packaged
57
+ return NEXO_HOME
58
+
59
+
55
60
  def _recorded_source_root() -> Path | None:
56
61
  version_file = NEXO_HOME / "version.json"
57
62
  try:
@@ -832,15 +837,16 @@ def _enabled_optionals() -> dict[str, bool]:
832
837
  def _enabled_manifest_crons() -> list[dict]:
833
838
  manifest_candidates = [
834
839
  paths.crons_dir() / "manifest.json",
840
+ NEXO_HOME / "crons" / "manifest.json",
835
841
  NEXO_CODE / "crons" / "manifest.json",
836
842
  ]
837
843
  optionals = _enabled_optionals()
838
- automation_default = True
844
+ schedule = {}
839
845
  try:
840
846
  if SCHEDULE_FILE.is_file():
841
- schedule = _load_json(SCHEDULE_FILE)
842
- if isinstance(schedule, dict):
843
- automation_default = bool(schedule.get("automation_enabled", True))
847
+ loaded = _load_json(SCHEDULE_FILE)
848
+ if isinstance(loaded, dict):
849
+ schedule = loaded
844
850
  except Exception:
845
851
  pass
846
852
  for manifest_path in manifest_candidates:
@@ -856,12 +862,12 @@ def _enabled_manifest_crons() -> list[dict]:
856
862
  cron_id = cron.get("id")
857
863
  if not cron_id:
858
864
  continue
859
- optional_key = cron.get("optional")
860
- if optional_key == "automation":
861
- optional_enabled = optionals.get(optional_key, automation_default)
862
- else:
863
- optional_enabled = optionals.get(optional_key, False)
864
- if optional_key and not optional_enabled:
865
+ if not is_cron_enabled(
866
+ cron,
867
+ optionals=optionals,
868
+ schedule_data=schedule,
869
+ system=os.environ.get("NEXO_PLATFORM") or platform.system(),
870
+ ):
865
871
  continue
866
872
  enabled.append(cron)
867
873
  return enabled
@@ -919,6 +925,7 @@ def _launchagent_schedule_expectations() -> dict[str, dict]:
919
925
  "StartCalendarInterval": None,
920
926
  "RunAtLoad": None,
921
927
  "KeepAlive": None,
928
+ "WatchPaths": None,
922
929
  "schedule_configured": False,
923
930
  }
924
931
  if cron.get("keep_alive"):
@@ -944,12 +951,17 @@ def _launchagent_schedule_expectations() -> dict[str, dict]:
944
951
  elif should_run_at_load(cron):
945
952
  expected["RunAtLoad"] = True
946
953
  expected["schedule_configured"] = True
954
+ if cron.get("watch_paths"):
955
+ expected["WatchPaths"] = [
956
+ str(Path(str(path)).expanduser()) if str(path).startswith("~") else str(path)
957
+ for path in cron.get("watch_paths", [])
958
+ ]
947
959
  expectations[cron_id] = expected
948
960
  return expectations
949
961
 
950
962
 
951
963
  def _managed_launchagent_plists() -> list[tuple[str, Path]]:
952
- ids = set(AUXILIARY_CORE_LAUNCHAGENT_IDS)
964
+ ids = set()
953
965
  for cron_id, expected in _launchagent_schedule_expectations().items():
954
966
  if expected.get("schedule_configured"):
955
967
  ids.add(cron_id)
@@ -963,7 +975,7 @@ def _managed_launchagent_plists() -> list[tuple[str, Path]]:
963
975
 
964
976
 
965
977
  def _known_nexo_launchagent_ids() -> set[str]:
966
- ids = set(AUXILIARY_CORE_LAUNCHAGENT_IDS)
978
+ ids = set()
967
979
  for cron_id, expected in _launchagent_schedule_expectations().items():
968
980
  if expected.get("schedule_configured"):
969
981
  ids.add(cron_id)
@@ -1088,6 +1100,7 @@ def _repair_launchagents(items: list[tuple[str, Path]]) -> tuple[bool, list[str]
1088
1100
  def _repair_special_launchagent_plists(items: list[tuple[str, Path]]) -> tuple[bool, list[str]]:
1089
1101
  evidence: list[str] = []
1090
1102
  ok = True
1103
+ expected_code = _expected_runtime_code_dir()
1091
1104
  for cron_id, plist_path in items:
1092
1105
  if cron_id not in SPECIAL_ENV_NORMALIZE_IDS:
1093
1106
  continue
@@ -1096,8 +1109,8 @@ def _repair_special_launchagent_plists(items: list[tuple[str, Path]]) -> tuple[b
1096
1109
  plist_data = plistlib.load(fh)
1097
1110
  env = plist_data.setdefault("EnvironmentVariables", {})
1098
1111
  changed = False
1099
- if env.get("NEXO_CODE") != str(NEXO_HOME):
1100
- env["NEXO_CODE"] = str(NEXO_HOME)
1112
+ if env.get("NEXO_CODE") != str(expected_code):
1113
+ env["NEXO_CODE"] = str(expected_code)
1101
1114
  changed = True
1102
1115
  if env.get("NEXO_HOME") != str(NEXO_HOME):
1103
1116
  env["NEXO_HOME"] = str(NEXO_HOME)
@@ -1533,12 +1546,14 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
1533
1546
  "StartCalendarInterval": plist_data.get("StartCalendarInterval"),
1534
1547
  "RunAtLoad": plist_data.get("RunAtLoad"),
1535
1548
  "KeepAlive": plist_data.get("KeepAlive"),
1549
+ "WatchPaths": plist_data.get("WatchPaths"),
1536
1550
  }
1537
1551
  target_schedule = {
1538
1552
  "StartInterval": expected_schedule.get("StartInterval"),
1539
1553
  "StartCalendarInterval": expected_schedule.get("StartCalendarInterval"),
1540
1554
  "RunAtLoad": expected_schedule.get("RunAtLoad"),
1541
1555
  "KeepAlive": expected_schedule.get("KeepAlive"),
1556
+ "WatchPaths": expected_schedule.get("WatchPaths"),
1542
1557
  }
1543
1558
  if actual_schedule != target_schedule:
1544
1559
  problems.append(
@@ -1764,12 +1779,12 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
1764
1779
  "Run nexo scripts sync to reconcile filesystem scripts and personal LaunchAgents",
1765
1780
  "Run nexo scripts reconcile so declared schedules are recreated through the official flow",
1766
1781
  "Use nexo doctor --tier runtime --fix to apply the safe reconcile path for declared schedules",
1767
- "Keep personal scripts in NEXO_HOME/scripts so updates do not collide with core",
1782
+ "Keep personal scripts in NEXO_HOME/personal/scripts so updates do not collide with core",
1768
1783
  "Prefer ps- prefixed filenames for new personal scripts so ownership stays obvious at a glance",
1769
1784
  ],
1770
1785
  escalation_prompt=(
1771
1786
  "Personal script metadata, files, and personal cron schedules are out of sync. "
1772
- "Reconcile NEXO_HOME/scripts with personal LaunchAgents without treating them as core crons."
1787
+ "Reconcile NEXO_HOME/personal/scripts with personal LaunchAgents without treating them as core crons."
1773
1788
  ),
1774
1789
  )
1775
1790
 
@@ -4,7 +4,7 @@
4
4
  The loader prefers the `email_accounts` table. When the table is empty
5
5
  (fresh install that hasn't run `nexo email setup` yet) it falls back
6
6
  to the legacy JSON for backwards compatibility — no crons stall while
7
- Francisco migrates.
7
+ the operator migrates.
8
8
 
9
9
  Usage from any script:
10
10
 
@@ -117,6 +117,8 @@ def _account_to_legacy_shape(
117
117
  "password": runtime_account["password"],
118
118
  "operator_email": default_operator_email,
119
119
  "operator_aliases": list(extra_operator_emails or []),
120
+ # Transitional compatibility for older scripts that still read the
121
+ # pre-F2 alias key directly.
120
122
  "francisco_emails": list(extra_operator_emails or []),
121
123
  "trusted_domains": runtime_account["trusted_domains"],
122
124
  "sender_policy": runtime_account["sender_policy"],
@@ -205,19 +207,26 @@ def load_email_runtime_snapshot() -> dict[str, Any] | None:
205
207
  }
206
208
 
207
209
 
208
- def load_email_config(label: str | None = None) -> dict | None:
209
- """Return the email config for a given label (or the primary account).
210
+ def load_email_config(label: str | None = None, account_id: int | str | None = None) -> dict | None:
211
+ """Return the email config for a given selector (or the primary account).
210
212
 
211
213
  Preference order:
212
- 1. email_accounts table (via label or get_primary_email_account).
214
+ 1. email_accounts table (via id, label, or get_primary_email_account).
213
215
  2. ~/.nexo/nexo-email/config.json legacy file.
214
216
  3. None if neither is available.
215
217
  """
216
218
  account: dict | None = None
217
219
  operator_accounts: list[dict] = []
218
220
  try:
219
- from db._email_accounts import get_email_account, get_primary_email_account, list_email_accounts
220
- if label:
221
+ from db._email_accounts import (
222
+ get_email_account,
223
+ get_email_account_by_id,
224
+ get_primary_email_account,
225
+ list_email_accounts,
226
+ )
227
+ if account_id is not None:
228
+ account = get_email_account_by_id(account_id)
229
+ elif label:
221
230
  account = get_email_account(label)
222
231
  else:
223
232
  account = get_primary_email_account()
@@ -231,9 +240,9 @@ def load_email_config(label: str | None = None) -> dict | None:
231
240
  value = str(op.get("email") or "").strip().lower()
232
241
  if value and value not in extra:
233
242
  extra.append(value)
234
- # F1/F2 — operator aliases are canonical now. Keep the legacy
235
- # `francisco_emails` compatibility shape in the returned payload
236
- # so old code paths do not break during transition.
243
+ # F1/F2 — operator aliases are canonical now. Keep the legacy alias
244
+ # compatibility key in the returned payload so old code paths do not
245
+ # break during transition.
237
246
  aliases = (account.get("metadata") or {}).get("operator_aliases") or []
238
247
  for a in aliases:
239
248
  if a and a not in extra:
@@ -40,24 +40,15 @@ import time
40
40
  from typing import Callable
41
41
 
42
42
  from call_model_raw import ClassifierUnavailableError, call_model_raw
43
+ from core_prompts import render_core_prompt
43
44
 
44
45
 
45
46
  _logger = logging.getLogger("nexo.enforcement_classifier")
46
47
 
47
48
 
48
- _STRICT_SYSTEM_PROMPT = (
49
- "You are a binary classifier for the NEXO Protocol Enforcer. "
50
- "Respond with EXACTLY ONE WORD: yes OR no. "
51
- "No explanation. No preface. No punctuation. No quotes. "
52
- "Only 'yes' or 'no', lowercase, no surrounding text."
53
- )
49
+ _STRICT_SYSTEM_PROMPT = render_core_prompt("enforcement-classifier-strict")
54
50
 
55
- _RETRY_SYSTEM_PROMPT = (
56
- "Your previous response was not valid. "
57
- "Answer with only the single word 'yes' or the single word 'no'. "
58
- "Any other output is rejected. Do not explain. Do not apologise. "
59
- "Do not repeat the question. Emit 'yes' or 'no' and stop."
60
- )
51
+ _RETRY_SYSTEM_PROMPT = render_core_prompt("enforcement-classifier-retry")
61
52
 
62
53
  _PARSER_REGEX = re.compile(r"^\s*(yes|no)\b", flags=re.IGNORECASE)
63
54
 
@@ -14,6 +14,7 @@ import sqlite3
14
14
  import time
15
15
  from datetime import datetime, date, timedelta
16
16
  from pathlib import Path
17
+ from core_prompts import render_core_prompt
17
18
 
18
19
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
20
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(NEXO_HOME)))
@@ -230,7 +231,7 @@ def dry_run_restore_test() -> bool:
230
231
 
231
232
  test_file.write_text("modified_content")
232
233
 
233
- # Find restore script: NEXO_CODE/scripts/ first, then NEXO_HOME/scripts/
234
+ # Find restore script: repo scripts/ first, then installed core/scripts/.
234
235
  _nexo_code = Path(os.environ.get("NEXO_CODE", ""))
235
236
  restore_script = None
236
237
  for candidate in [_nexo_code / "scripts" / "nexo-snapshot-restore.sh",
@@ -290,11 +291,11 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
290
291
  max_auto = max_auto_changes(total)
291
292
  if mode == "review":
292
293
  mode_desc = "review-only, nothing executes automatically"
293
- safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/"
294
+ safe_zones = "~/.nexo/personal/scripts/, ~/.nexo/personal/plugins/, ~/.nexo/personal/brain/"
294
295
  immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md"
295
296
  elif mode == "managed":
296
297
  mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
297
- safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
298
+ safe_zones = "~/.nexo/personal/scripts/, ~/.nexo/personal/plugins/, ~/.nexo/personal/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
298
299
  immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md, personality.md, user-profile.md"
299
300
  elif mode == "public_core":
300
301
  mode_desc = "public core contribution via isolated checkout and Draft PR"
@@ -302,82 +303,25 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
302
303
  immutable_files = "personal runtime, ~/.nexo/**, local DBs/logs, CLAUDE.md, AGENTS.md, user-profile.md"
303
304
  else:
304
305
  mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
305
- safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/"
306
+ safe_zones = "~/.nexo/personal/scripts/, ~/.nexo/personal/plugins/"
306
307
  immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md"
307
308
 
308
- prompt = f"""You are NEXO Evolution — the weekly self-improvement cycle.
309
-
310
- YOUR JOB: Analyze the past week and propose concrete improvements to NEXO's codebase.
311
-
312
- WEEK SUMMARY:
313
- - {stats['learnings_this_week']} new learnings
314
- - {stats['decisions_this_week']} decisions made
315
- - {stats['changes_this_week']} code changes deployed
316
- - {stats['diaries_this_week']} session diaries
317
- - {stats['evolution_history']} past evolution proposals
318
- - Current scores: {json.dumps(stats['current_scores'])}
319
-
320
- MODE: {mode} ({mode_desc})
321
- CYCLE: #{total + 1}
322
-
323
- INVESTIGATE using these tools:
324
- 1. Bash: sqlite3 {NEXO_DB} "SELECT category, title FROM learnings WHERE created_at > {time.time() - 7*86400} ORDER BY created_at DESC LIMIT 30"
325
- 2. Bash: sqlite3 {NEXO_DB} "SELECT area, COUNT(*) as cnt FROM error_repetitions GROUP BY area ORDER BY cnt DESC LIMIT 10"
326
- 3. Read ~/.nexo/coordination/daily-synthesis.md — today's context
327
- 4. Read ~/.nexo/coordination/postmortem-daily.md — self-critique patterns
328
- 5. Read ~/.nexo/logs/self-audit-summary.json — system health
329
- 6. Glob ~/.nexo/scripts/*.py — existing scripts
330
- 7. Glob ~/.nexo/plugins/*.py — existing plugins
331
-
332
- LOOK FOR:
333
- - Repeated errors that guard isn't preventing
334
- - Scripts or processes that are failing or underperforming
335
- - Missing functionality that session diaries keep asking for
336
- - Redundant code or config that could be simplified
337
- - Patterns in self-critique that suggest systemic issues
338
-
339
- SAFETY:
340
- - Safe zones for this mode: {safe_zones}
341
- - IMMUTABLE files (never touch in this mode): {immutable_files}
342
- - Every change needs: what file, what to change, why, risk, how to verify
343
- - AUTO changes must be deterministic. If the edit is ambiguous, risky, or needs human taste, mark it as "propose".
344
- - In managed mode, failed AUTO changes will be rolled back automatically and turned into followups with evidence.
345
-
346
- OUTPUT FORMAT (JSON):
347
- {{
348
- "analysis": "one paragraph summary of what you found",
349
- "dimension_scores": {{
350
- "episodic_memory": 0,
351
- "autonomy": 0,
352
- "proactivity": 0,
353
- "self_improvement": 0,
354
- "agi": 0
355
- }},
356
- "score_evidence": {{
357
- "episodic_memory": "why this score changed or stayed flat",
358
- "autonomy": "why this score changed or stayed flat",
359
- "proactivity": "why this score changed or stayed flat",
360
- "self_improvement": "why this score changed or stayed flat",
361
- "agi": "why this score changed or stayed flat"
362
- }},
363
- "patterns": [{{"type": "...", "description": "...", "frequency": "..."}}],
364
- "proposals": [
365
- {{
366
- "classification": "auto" or "propose",
367
- "dimension": "reliability|proactivity|efficiency|safety|learning",
368
- "action": "what to do",
369
- "reasoning": "why",
370
- "scope": "local",
371
- "changes": [{{"file": "path", "operation": "create|replace|append", "search": "text to find", "content": "new text"}}]
372
- }}
373
- ]
374
- }}
375
-
376
- Always include all five canonical keys in `dimension_scores` and `score_evidence`.
377
- Scores must be integers in the 0-100 range and reflect the current week, not targets.
378
- Max 3 proposals. Quality over quantity. If nothing needs improving, say so."""
379
-
380
- return prompt
309
+ return render_core_prompt(
310
+ "evolution-weekly",
311
+ learnings_this_week=stats["learnings_this_week"],
312
+ decisions_this_week=stats["decisions_this_week"],
313
+ changes_this_week=stats["changes_this_week"],
314
+ diaries_this_week=stats["diaries_this_week"],
315
+ evolution_history=stats["evolution_history"],
316
+ current_scores_json=json.dumps(stats["current_scores"]),
317
+ mode=mode,
318
+ mode_desc=mode_desc,
319
+ cycle_number=total + 1,
320
+ nexo_db=NEXO_DB,
321
+ week_cutoff_ts=time.time() - 7 * 86400,
322
+ safe_zones=safe_zones,
323
+ immutable_files=immutable_files,
324
+ )
381
325
 
382
326
 
383
327
  def build_public_contribution_prompt(
@@ -414,39 +358,12 @@ needs the same change and port it if necessary. If the repo is already correct,
414
358
  make the smallest validating change that captures the same gap.
415
359
  """
416
360
 
417
- return f"""You are NEXO Public Evolution.
418
-
419
- You are running inside an isolated checkout of the public NEXO repository.
420
- Your job is to make one technically coherent improvement to the public core and
421
- prepare it for a Draft PR.
422
-
423
- STRICT RULES:
424
- - Work only inside this repository checkout: {repo_root}
425
- - You may modify only public core surfaces: src/, bin/, tests/, templates/, hooks/, migrations/, .claude-plugin/
426
- - Do not read or use ~/.nexo, local DBs, personal scripts, emails, logs, prompts, secrets, or any user-identifying paths
427
- - Do not push, open PRs, or change git remotes yourself
428
- - Do not touch README, website, gh-pages, changelog, or release metadata in this mode
429
- - Focus on one concrete improvement only
430
- - Run validation for the files you touched
431
-
432
- What to do:
433
- 1. Inspect the repo and find a real, self-contained improvement in reliability, install/update behavior, cron recovery, diagnostics, hooks, tests, or other core infrastructure.
434
- 2. Implement the change directly in this checkout.
435
- 3. Run the smallest relevant validation commands.
436
- 4. Return ONLY valid JSON with this shape:
437
-
438
- {{
439
- "title": "type: short title",
440
- "problem": "what was wrong",
441
- "summary": "what you changed",
442
- "tests": ["command 1", "command 2"],
443
- "risks": ["risk 1", "risk 2"]
444
- }}
445
-
446
- Cycle: #{cycle_number}
447
- Quality over quantity. One strong improvement is better than three weak ones.
448
- {queued_section}
449
- """
361
+ return render_core_prompt(
362
+ "evolution-public-contribution",
363
+ repo_root=repo_root,
364
+ cycle_number=cycle_number,
365
+ queued_section=queued_section,
366
+ )
450
367
 
451
368
 
452
369
  def build_public_pr_review_prompt(
@@ -470,45 +387,16 @@ def build_public_pr_review_prompt(
470
387
  if len(trimmed_diff) > 80000:
471
388
  trimmed_diff = trimmed_diff[:80000] + "\n\n[diff truncated by NEXO]"
472
389
 
473
- return f"""You are NEXO Public Evolution Review.
474
-
475
- You are reviewing another opt-in public evolution PR. You must NOT merge, rebase,
476
- push, or edit the PR. Your only job is to decide whether it deserves an approval
477
- or whether it should receive a review comment without approval.
478
-
479
- STRICT RULES:
480
- - Review only this PR:
481
- - Number: #{pr_number}
482
- - Author: {author}
483
- - URL: {url}
484
- - Base the review only on the provided title, body, file list, and diff
485
- - Do not assume hidden context
486
- - If confidence is not strong, choose `comment`, not `approve`
487
- - If the diff is too incomplete, too risky, or too ambiguous, choose `skip`
488
- - Never suggest merge authority; maintainers decide that later
489
- - Keep the review concise, technical, and useful
490
-
491
- PR TITLE:
492
- {title}
493
-
494
- PR BODY:
495
- {body or "(empty)"}
496
-
497
- FILES CHANGED:
498
- {rendered_files}
499
-
500
- DIFF:
501
- ```diff
502
- {trimmed_diff or "(empty diff)"}
503
- ```
504
-
505
- Return ONLY valid JSON:
506
- {{
507
- "decision": "approve|comment|skip",
508
- "summary": "one-line verdict",
509
- "body": "the exact markdown text to post as the review body"
510
- }}
511
- """
390
+ return render_core_prompt(
391
+ "evolution-public-pr-review",
392
+ pr_number=pr_number,
393
+ author=author,
394
+ url=url,
395
+ title=title,
396
+ body=body or "(empty)",
397
+ rendered_files=rendered_files,
398
+ trimmed_diff=trimmed_diff or "(empty diff)",
399
+ )
512
400
 
513
401
 
514
402
  def max_auto_changes(total_evolutions: int) -> int:
@@ -29,14 +29,15 @@ import threading
29
29
  import time
30
30
  from typing import Any
31
31
 
32
+ from paths import logs_dir
33
+
32
34
 
33
35
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per file before rotation
34
36
  _LOCK = threading.Lock()
35
37
 
36
38
 
37
39
  def _telemetry_path() -> pathlib.Path:
38
- home = pathlib.Path(os.environ.get("NEXO_HOME") or (pathlib.Path.home() / ".nexo"))
39
- return home / "logs" / "guardian-telemetry.ndjson"
40
+ return logs_dir() / "guardian-telemetry.ndjson"
40
41
 
41
42
 
42
43
  def _rotate_if_needed(path: pathlib.Path, max_bytes: int) -> None:
@@ -12,6 +12,7 @@ import paths
12
12
  from db import create_protocol_debt, get_db
13
13
  from plugins.guard import _load_conditioned_learnings, _normalize_path_token
14
14
  from protocol_settings import get_protocol_strictness
15
+ from product_mode import core_writes_allowed, is_protected_runtime_core_path
15
16
 
16
17
  READ_LIKE_TOOLS = {"Read"}
17
18
  WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
@@ -520,6 +521,44 @@ def _collect_automation_live_repo_blocks(
520
521
  return blocks
521
522
 
522
523
 
524
+ def _collect_runtime_core_write_blocks(
525
+ conn,
526
+ *,
527
+ sid: str,
528
+ tool_name: str,
529
+ files: list[str],
530
+ ) -> list[dict]:
531
+ if core_writes_allowed():
532
+ return []
533
+ blocks: list[dict] = []
534
+ for filepath in files:
535
+ if not is_protected_runtime_core_path(filepath):
536
+ continue
537
+ debt = _ensure_protocol_debt(
538
+ conn,
539
+ session_id=sid,
540
+ task_id="",
541
+ debt_type="runtime_core_write_blocked",
542
+ severity="error",
543
+ evidence=(
544
+ f"{tool_name} attempted on protected runtime core path {filepath}. "
545
+ "Install-time core files must be changed through the source repo/release flow, "
546
+ "not by editing ~/.nexo/core in place."
547
+ ),
548
+ file_token=filepath,
549
+ )
550
+ blocks.append(
551
+ {
552
+ "file": filepath,
553
+ "task_id": "",
554
+ "debt_id": debt.get("id"),
555
+ "debt_type": "runtime_core_write_blocked",
556
+ "reason_code": "runtime_core_protected",
557
+ }
558
+ )
559
+ return blocks
560
+
561
+
523
562
  def _read_claude_session_id_from_coordination() -> str:
524
563
  """Fallback claude_session_id when Claude Code's PreToolUse payload omits it.
525
564
 
@@ -598,6 +637,23 @@ def process_pre_tool_event(payload: dict) -> dict:
598
637
  "status": "blocked",
599
638
  }
600
639
 
640
+ core_blocks = _collect_runtime_core_write_blocks(
641
+ conn,
642
+ sid=sid,
643
+ tool_name=tool_name,
644
+ files=files,
645
+ )
646
+ if core_blocks:
647
+ return {
648
+ "ok": True,
649
+ "session_id": sid,
650
+ "tool_name": tool_name,
651
+ "operation": op,
652
+ "strictness": strictness,
653
+ "blocks": core_blocks,
654
+ "status": "blocked",
655
+ }
656
+
601
657
  if strictness == "lenient":
602
658
  return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
603
659
 
@@ -904,6 +960,11 @@ def format_pretool_block_message(result: dict) -> str:
904
960
  f"- {file_note}: automation sessions cannot write to the live NEXO repo. "
905
961
  "Use an isolated checkout/worktree or the public contribution Draft PR flow."
906
962
  )
963
+ elif item.get("reason_code") == "runtime_core_protected":
964
+ lines.append(
965
+ f"- {file_note}: `~/.nexo/core` is a protected install surface. "
966
+ "Route the change through the source repo + release/update flow instead of editing the live installed core."
967
+ )
907
968
  elif item.get("reason_code") == "guard_unacknowledged":
908
969
  lines.append(
909
970
  f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
@@ -17,7 +17,15 @@ case "$TOOL_NAME" in
17
17
  esac
18
18
 
19
19
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
20
- LOG_DIR="$NEXO_HOME/operations/tool-logs"
20
+ OPERATIONS_DIR="$NEXO_HOME/runtime/operations"
21
+ if [ ! -d "$OPERATIONS_DIR" ] && [ -d "$NEXO_HOME/operations" ]; then
22
+ OPERATIONS_DIR="$NEXO_HOME/operations"
23
+ fi
24
+ DATA_DIR="$NEXO_HOME/runtime/data"
25
+ if [ ! -d "$DATA_DIR" ] && [ -d "$NEXO_HOME/data" ]; then
26
+ DATA_DIR="$NEXO_HOME/data"
27
+ fi
28
+ LOG_DIR="$OPERATIONS_DIR/tool-logs"
21
29
  mkdir -p "$LOG_DIR"
22
30
 
23
31
  TODAY=$(date +%Y-%m-%d)
@@ -58,10 +66,10 @@ print(json.dumps(record))
58
66
  # ── Layer 1: Auto-diary every 10 tool calls (session-scoped) ─────────
59
67
  # Extract session_id for per-session counters (prevents cross-terminal contamination)
60
68
  SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','global'))" 2>/dev/null || echo "global")
61
- COUNTER_DIR="$NEXO_HOME/operations/counters"
69
+ COUNTER_DIR="$OPERATIONS_DIR/counters"
62
70
  mkdir -p "$COUNTER_DIR"
63
71
  COUNTER_FILE="$COUNTER_DIR/.tool-call-count-${SESSION_ID}"
64
- NEXO_DB="$NEXO_HOME/data/nexo.db"
72
+ NEXO_DB="$DATA_DIR/nexo.db"
65
73
 
66
74
  # Increment counter (atomic: read+write in one step)
67
75
  COUNT=1
@@ -6,8 +6,13 @@
6
6
  # Frequency: Monday, Wednesday, Friday (3x/week)
7
7
 
8
8
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
- BRIEFING_FILE="$NEXO_HOME/operations/.briefing-last-sent"
10
- FLAG_FILE="$NEXO_HOME/operations/.briefing-pending"
9
+ OPERATIONS_DIR="$NEXO_HOME/runtime/operations"
10
+ if [ ! -d "$OPERATIONS_DIR" ] && [ -d "$NEXO_HOME/operations" ]; then
11
+ OPERATIONS_DIR="$NEXO_HOME/operations"
12
+ fi
13
+ mkdir -p "$OPERATIONS_DIR"
14
+ BRIEFING_FILE="$OPERATIONS_DIR/.briefing-last-sent"
15
+ FLAG_FILE="$OPERATIONS_DIR/.briefing-pending"
11
16
  TODAY=$(date +%Y-%m-%d)
12
17
  HOUR=$(date +%H)
13
18
  DOW=$(date +%u) # 1=Monday, 7=Sunday
@@ -18,7 +18,20 @@ import sys
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
- STATE_FILE = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations" / ".heartbeat-state.json"
21
+ try:
22
+ import paths
23
+ except ModuleNotFoundError as exc:
24
+ if getattr(exc, "name", "") != "paths":
25
+ raise
26
+
27
+ class _PathsFallback:
28
+ @staticmethod
29
+ def operations_dir():
30
+ return Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations"
31
+
32
+ paths = _PathsFallback()
33
+
34
+ STATE_FILE = paths.operations_dir() / ".heartbeat-state.json"
22
35
  THRESHOLD = 2
23
36
  HEARTBEAT_TOOL = "nexo_heartbeat"
24
37
  SKIP_TOOLS = {"nexo_startup", "nexo_stop", "nexo_smart_startup"}
@@ -9,6 +9,8 @@ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
9
  HELPER=""
10
10
  if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
11
11
  HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
12
+ elif [ -f "$NEXO_HOME/core/hooks/heartbeat-enforcement.py" ]; then
13
+ HELPER="$NEXO_HOME/core/hooks/heartbeat-enforcement.py"
12
14
  elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
13
15
  HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
14
16
  fi