nexo-brain 5.3.19 → 5.3.21

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 (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,1664 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ NEXO Evolution — Standalone weekly runner with real execution.
4
+ Cron: 0 3 * * 0 (Sundays 3:00 AM)
5
+
6
+ Runs independently of Cortex. Calls the configured NEXO automation backend
7
+ to analyze the past week and generate improvement proposals.
8
+
9
+ AUTO proposals are executed: snapshot → apply → validate → commit/rollback.
10
+ PROPOSE proposals are logged for the user's review.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import py_compile
16
+ import re
17
+ import sqlite3
18
+ import subprocess
19
+ import sys
20
+ from datetime import datetime, date, timedelta
21
+ from pathlib import Path
22
+
23
+
24
+ try:
25
+ from client_preferences import resolve_user_model as _resolve_user_model
26
+ _USER_MODEL = _resolve_user_model()
27
+ except Exception:
28
+ _USER_MODEL = ""
29
+
30
+
31
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
32
+ # Auto-detect: if running from repo (src/scripts/), use src/ as NEXO_CODE
33
+ _script_dir = Path(__file__).resolve().parent
34
+ _repo_src = _script_dir.parent # src/scripts/ -> src/
35
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
36
+
37
+ # ── Paths ────────────────────────────────────────────────────────────────
38
+ CLAUDE_DIR = NEXO_HOME
39
+ NEXO_DB = CLAUDE_DIR / "data" / "nexo.db"
40
+ LOG_DIR = CLAUDE_DIR / "logs"
41
+ SNAPSHOTS_DIR = CLAUDE_DIR / "snapshots"
42
+ SANDBOX_DIR = CLAUDE_DIR / "sandbox" / "workspace"
43
+ MAX_CONSECUTIVE_FAILURES = 3
44
+ MAX_SNAPSHOTS = 8
45
+
46
+ # ── Immutable files — split by risk tier ────────────────────────────────
47
+ # These remain locked even in managed mode because they can break bootstrap,
48
+ # persistence, or the evolution engine itself.
49
+ GLOBAL_IMMUTABLE_FILES = {
50
+ "db.py",
51
+ "server.py",
52
+ "plugin_loader.py",
53
+ "nexo-watchdog.sh",
54
+ "cortex-wrapper.py",
55
+ "CLAUDE.md",
56
+ "AGENTS.md",
57
+ "personality.md",
58
+ "user-profile.md",
59
+ "evolution_cycle.py",
60
+ "storage_router.py",
61
+ }
62
+
63
+ # Managed mode may autoevolve behavior/tooling modules, but auto/review keep
64
+ # these guarded to stay conservative for public installs.
65
+ STANDARD_MODE_IMMUTABLE_FILES = {
66
+ "cognitive.py",
67
+ "knowledge_graph.py",
68
+ "tools_sessions.py",
69
+ "tools_coordination.py",
70
+ "tools_reminders.py",
71
+ "tools_reminders_crud.py",
72
+ "tools_learnings.py",
73
+ "tools_credentials.py",
74
+ "tools_task_history.py",
75
+ "tools_menu.py",
76
+ }
77
+
78
+
79
+ def _repo_root() -> Path | None:
80
+ candidate = NEXO_CODE.parent
81
+ if (candidate / "package.json").exists():
82
+ return candidate
83
+ return None
84
+
85
+
86
+ def _public_safe_prefixes() -> list[str]:
87
+ return [
88
+ str(CLAUDE_DIR / "scripts") + "/",
89
+ str(CLAUDE_DIR / "plugins") + "/",
90
+ str(CLAUDE_DIR / "skills") + "/",
91
+ str(CLAUDE_DIR / "skills-runtime") + "/",
92
+ ]
93
+
94
+
95
+ def _managed_safe_prefixes() -> list[str]:
96
+ prefixes = [
97
+ str(CLAUDE_DIR / "scripts") + "/",
98
+ str(CLAUDE_DIR / "plugins") + "/",
99
+ str(CLAUDE_DIR / "brain") + "/",
100
+ str(CLAUDE_DIR / "coordination") + "/",
101
+ str(CLAUDE_DIR / "logs") + "/",
102
+ str(CLAUDE_DIR / "skills") + "/",
103
+ str(CLAUDE_DIR / "skills-core") + "/",
104
+ str(CLAUDE_DIR / "skills-runtime") + "/",
105
+ str(NEXO_CODE) + "/",
106
+ ]
107
+ repo_root = _repo_root()
108
+ if repo_root:
109
+ for rel in ("bin", "docs", "templates", "tests"):
110
+ prefixes.append(str(repo_root / rel) + "/")
111
+ return prefixes
112
+
113
+
114
+ def _normalize_mode(mode: str) -> str:
115
+ value = str(mode or "auto").strip().lower()
116
+ aliases = {
117
+ "owner": "managed",
118
+ "core": "managed",
119
+ "hybrid": "managed",
120
+ "manual": "review",
121
+ "public": "public_core",
122
+ "contributor": "public_core",
123
+ "draft_prs": "public_core",
124
+ }
125
+ return aliases.get(value, value if value in {"auto", "review", "managed", "public_core"} else "auto")
126
+
127
+
128
+ def _immutable_files_for_mode(mode: str) -> set[str]:
129
+ normalized = _normalize_mode(mode)
130
+ if normalized == "managed":
131
+ return set(GLOBAL_IMMUTABLE_FILES)
132
+ return set(GLOBAL_IMMUTABLE_FILES) | set(STANDARD_MODE_IMMUTABLE_FILES)
133
+
134
+ # ── Automation backend pathing ───────────────────────────────────────────
135
+ def _resolve_claude_cli() -> Path:
136
+ """Find claude CLI: saved path > PATH > common locations."""
137
+ import shutil as _shutil
138
+ saved = NEXO_HOME / "config" / "claude-cli-path"
139
+ if saved.exists():
140
+ p = Path(saved.read_text().strip())
141
+ if p.exists():
142
+ return p
143
+ found = _shutil.which("claude")
144
+ if found:
145
+ return Path(found)
146
+ for candidate in [
147
+ Path.home() / ".local" / "bin" / "claude",
148
+ Path.home() / ".npm-global" / "bin" / "claude",
149
+ Path("/usr/local/bin/claude"),
150
+ ]:
151
+ if candidate.exists():
152
+ return candidate
153
+ return Path.home() / ".local" / "bin" / "claude"
154
+
155
+ CLAUDE_CLI = _resolve_claude_cli()
156
+ PUBLIC_ALLOWED_PREFIXES = (
157
+ "src/",
158
+ "bin/",
159
+ "tests/",
160
+ "templates/",
161
+ "hooks/",
162
+ "migrations/",
163
+ ".claude-plugin/",
164
+ )
165
+
166
+ # ── Logging ──────────────────────────────────────────────────────────────
167
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
168
+ LOG_FILE = LOG_DIR / "evolution.log"
169
+
170
+
171
+ def log(msg: str):
172
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
173
+ line = f"[{ts}] {msg}"
174
+ print(line, flush=True)
175
+ with open(LOG_FILE, "a") as f:
176
+ f.write(line + "\n")
177
+
178
+
179
+ # ── Import from evolution_cycle.py (lives in NEXO_CODE, i.e. src/) ──────
180
+ sys.path.insert(0, str(NEXO_CODE))
181
+ from agent_runner import probe_automation_backend, run_automation_prompt
182
+ from evolution_cycle import (
183
+ load_objective, save_objective, get_week_data, build_evolution_prompt,
184
+ dry_run_restore_test, max_auto_changes, create_snapshot,
185
+ build_public_contribution_prompt, build_public_pr_review_prompt,
186
+ )
187
+ from public_contribution import (
188
+ CONTRIB_ARTIFACTS_DIR,
189
+ CONTRIB_REPO_DIR,
190
+ CONTRIB_WORKTREES_DIR,
191
+ UPSTREAM_REPO,
192
+ can_run_public_contribution,
193
+ load_public_contribution_config,
194
+ mark_active_pr,
195
+ mark_public_contribution_result,
196
+ STATUS_PAUSED_OPEN_PR,
197
+ )
198
+ from public_evolution_queue import (
199
+ list_pending_public_port_candidates,
200
+ update_public_port_candidate,
201
+ )
202
+
203
+
204
+ # ── Consecutive failure tracking ─────────────────────────────────────────
205
+ def get_consecutive_failures() -> int:
206
+ obj = load_objective()
207
+ return obj.get("consecutive_failures", 0)
208
+
209
+
210
+ def set_consecutive_failures(count: int):
211
+ obj = load_objective()
212
+ obj["consecutive_failures"] = count
213
+ save_objective(obj)
214
+
215
+
216
+ # ── Automation backend call ──────────────────────────────────────────────
217
+ CLI_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
218
+
219
+
220
+ def verify_claude_cli() -> bool:
221
+ """Check the configured automation backend is available and authenticated."""
222
+ return bool(probe_automation_backend(timeout=30).get("ok"))
223
+
224
+
225
+ def call_claude_cli(prompt: str) -> str:
226
+ """Call the configured automation backend for the managed evolution prompt."""
227
+ result = run_automation_prompt(
228
+ prompt,
229
+ model=_USER_MODEL or "opus",
230
+ timeout=CLI_TIMEOUT,
231
+ output_format="text",
232
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
233
+ )
234
+ if result.returncode != 0:
235
+ raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:500]}")
236
+ return result.stdout
237
+
238
+
239
+ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
240
+ """Run the configured automation backend in an isolated public repo checkout."""
241
+ result = run_automation_prompt(
242
+ prompt,
243
+ cwd=cwd,
244
+ env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
245
+ model=_USER_MODEL or "opus",
246
+ timeout=CLI_TIMEOUT,
247
+ output_format="text",
248
+ allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
249
+ )
250
+ if result.returncode != 0:
251
+ raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:500]}")
252
+ return result.stdout
253
+
254
+
255
+ def _git(cwd: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
256
+ return subprocess.run(
257
+ ["git", *args],
258
+ cwd=str(cwd),
259
+ capture_output=True,
260
+ text=True,
261
+ timeout=timeout,
262
+ )
263
+
264
+
265
+ def _gh(*args: str, cwd: Path | None = None, timeout: int = 60) -> subprocess.CompletedProcess:
266
+ return subprocess.run(
267
+ ["gh", *args],
268
+ cwd=str(cwd) if cwd else None,
269
+ capture_output=True,
270
+ text=True,
271
+ timeout=timeout,
272
+ )
273
+
274
+
275
+ def _branch_slug(text: str) -> str:
276
+ raw = re.sub(r"[^a-z0-9._-]+", "-", text.lower()).strip("-")
277
+ return raw[:48] or "proposal"
278
+
279
+
280
+ def _ensure_public_repo_cache(config: dict) -> None:
281
+ CONTRIB_REPO_DIR.parent.mkdir(parents=True, exist_ok=True)
282
+ if not (CONTRIB_REPO_DIR / ".git").exists():
283
+ clone = _git(CONTRIB_REPO_DIR.parent, "clone", f"https://github.com/{config['upstream_repo']}.git", str(CONTRIB_REPO_DIR), timeout=180)
284
+ if clone.returncode != 0:
285
+ raise RuntimeError(clone.stderr.strip() or clone.stdout.strip() or "git clone failed")
286
+ fetch = _git(CONTRIB_REPO_DIR, "fetch", "origin", timeout=120)
287
+ if fetch.returncode != 0:
288
+ raise RuntimeError(fetch.stderr.strip() or fetch.stdout.strip() or "git fetch failed")
289
+
290
+ remote_url = f"https://github.com/{config['fork_repo']}.git"
291
+ current = _git(CONTRIB_REPO_DIR, "remote", "get-url", "fork", timeout=10)
292
+ if current.returncode != 0:
293
+ add = _git(CONTRIB_REPO_DIR, "remote", "add", "fork", remote_url, timeout=10)
294
+ if add.returncode != 0:
295
+ raise RuntimeError(add.stderr.strip() or add.stdout.strip() or "git remote add fork failed")
296
+ elif current.stdout.strip() != remote_url:
297
+ set_url = _git(CONTRIB_REPO_DIR, "remote", "set-url", "fork", remote_url, timeout=10)
298
+ if set_url.returncode != 0:
299
+ raise RuntimeError(set_url.stderr.strip() or set_url.stdout.strip() or "git remote set-url failed")
300
+
301
+
302
+ def _prepare_public_worktree(config: dict, title_hint: str = "evolution") -> tuple[Path, str]:
303
+ _ensure_public_repo_cache(config)
304
+ CONTRIB_WORKTREES_DIR.mkdir(parents=True, exist_ok=True)
305
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
306
+ branch_name = f"contrib/{config['machine_id']}/{timestamp}-{_branch_slug(title_hint)}"
307
+ worktree_dir = CONTRIB_WORKTREES_DIR / f"{timestamp}-{_branch_slug(title_hint)}"
308
+ add = _git(CONTRIB_REPO_DIR, "worktree", "add", "--detach", str(worktree_dir), "origin/main", timeout=120)
309
+ if add.returncode != 0:
310
+ raise RuntimeError(add.stderr.strip() or add.stdout.strip() or "git worktree add failed")
311
+ return worktree_dir, branch_name
312
+
313
+
314
+ def _prime_public_git_identity(worktree_dir: Path, config: dict) -> None:
315
+ github_user = str(config.get("github_user") or "nexo-public-evolution").strip() or "nexo-public-evolution"
316
+ email = f"{github_user}@users.noreply.github.com"
317
+ name = f"{github_user} via NEXO Public Evolution"
318
+ for key, value in (("user.name", name), ("user.email", email)):
319
+ result = _git(worktree_dir, "config", key, value, timeout=15)
320
+ if result.returncode != 0:
321
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"git config {key} failed")
322
+
323
+
324
+ def _remove_public_worktree(worktree_dir: Path) -> None:
325
+ if not worktree_dir.exists():
326
+ return
327
+ _git(CONTRIB_REPO_DIR, "worktree", "remove", str(worktree_dir), "--force", timeout=60)
328
+
329
+
330
+ def _parse_summary_json(text: str) -> dict:
331
+ payload = text.strip()
332
+ if "```json" in payload:
333
+ payload = payload.split("```json", 1)[1].split("```", 1)[0]
334
+ elif "```" in payload:
335
+ payload = payload.split("```", 1)[1].split("```", 1)[0]
336
+ try:
337
+ summary = json.loads(payload.strip())
338
+ if isinstance(summary, dict):
339
+ return summary
340
+ except Exception:
341
+ pass
342
+ return {}
343
+
344
+
345
+ def _changed_public_files(worktree_dir: Path) -> list[str]:
346
+ result = _git(worktree_dir, "status", "--porcelain", timeout=30)
347
+ if result.returncode != 0:
348
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "git status failed")
349
+ changed: list[str] = []
350
+ for line in result.stdout.splitlines():
351
+ if not line:
352
+ continue
353
+ path_text = line[3:].strip()
354
+ if " -> " in path_text:
355
+ path_text = path_text.split(" -> ", 1)[1].strip()
356
+ if path_text:
357
+ changed.append(path_text)
358
+ return changed
359
+
360
+
361
+ def _is_allowed_public_path(rel_path: str) -> bool:
362
+ return any(rel_path.startswith(prefix) for prefix in PUBLIC_ALLOWED_PREFIXES)
363
+
364
+
365
+ def _sanitize_public_diff(worktree_dir: Path, changed_files: list[str]) -> tuple[bool, str]:
366
+ if not changed_files:
367
+ return False, "No repository changes were produced."
368
+ for rel_path in changed_files:
369
+ if not _is_allowed_public_path(rel_path):
370
+ return False, f"Changed path is not allowed for public contribution: {rel_path}"
371
+
372
+ diff = _git(worktree_dir, "diff", "--no-ext-diff", "--", *changed_files, timeout=60)
373
+ if diff.returncode != 0:
374
+ return False, diff.stderr.strip() or diff.stdout.strip() or "git diff failed"
375
+ diff_text = diff.stdout
376
+ private_markers = [
377
+ str(Path.home()),
378
+ str(NEXO_HOME),
379
+ "CLAUDE.md",
380
+ "AGENTS.md",
381
+ ".nexo/",
382
+ ".codex/",
383
+ ]
384
+ for marker in private_markers:
385
+ if marker and marker in diff_text:
386
+ return False, f"Sanitization blocked private marker in diff: {marker}"
387
+ private_path_patterns = [
388
+ re.compile(r"/Users/[^/\s\"']+/"),
389
+ re.compile(r"/home/[^/\s\"']+/"),
390
+ ]
391
+ for pattern in private_path_patterns:
392
+ match = pattern.search(diff_text)
393
+ if match:
394
+ return False, f"Sanitization blocked private path in diff: {match.group(0)}"
395
+ return True, ""
396
+
397
+
398
+ def _run_public_validation(worktree_dir: Path, changed_files: list[str]) -> list[str]:
399
+ validations: list[str] = []
400
+ py_files = [str(worktree_dir / rel_path) for rel_path in changed_files if rel_path.endswith(".py")]
401
+ if py_files:
402
+ result = subprocess.run(
403
+ [sys.executable, "-m", "py_compile", *py_files],
404
+ cwd=str(worktree_dir),
405
+ capture_output=True,
406
+ text=True,
407
+ timeout=120,
408
+ )
409
+ if result.returncode != 0:
410
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "py_compile failed")
411
+ validations.append("python3 -m py_compile " + " ".join(changed_files))
412
+
413
+ js_files = [str(worktree_dir / rel_path) for rel_path in changed_files if rel_path.endswith(".js")]
414
+ for js_file in js_files:
415
+ result = subprocess.run(
416
+ ["node", "--check", js_file],
417
+ cwd=str(worktree_dir),
418
+ capture_output=True,
419
+ text=True,
420
+ timeout=60,
421
+ )
422
+ if result.returncode != 0:
423
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"node --check failed for {js_file}")
424
+ if js_files:
425
+ validations.append("node --check " + " ".join(changed_files))
426
+
427
+ tests = subprocess.run(
428
+ ["pytest", "-q", "tests"],
429
+ cwd=str(worktree_dir),
430
+ capture_output=True,
431
+ text=True,
432
+ timeout=900,
433
+ env={**os.environ, "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"},
434
+ )
435
+ if tests.returncode != 0:
436
+ raise RuntimeError(tests.stderr.strip() or tests.stdout.strip() or "pytest failed")
437
+ validations.append("PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q tests")
438
+ return validations
439
+
440
+
441
+ def _write_public_artifacts(worktree_dir: Path, branch_name: str, summary: dict) -> Path:
442
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
443
+ artifact_dir = CONTRIB_ARTIFACTS_DIR / timestamp
444
+ artifact_dir.mkdir(parents=True, exist_ok=True)
445
+ diff = _git(worktree_dir, "diff", "--no-ext-diff", "origin/main...HEAD", timeout=60)
446
+ patch_text = diff.stdout if diff.returncode == 0 else ""
447
+ (artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n")
448
+ (artifact_dir / "branch.txt").write_text(branch_name + "\n")
449
+ (artifact_dir / "diff.patch").write_text(patch_text)
450
+ return artifact_dir
451
+
452
+
453
+ def _review_state(review: dict) -> str:
454
+ return str(review.get("state") or review.get("reviewState") or "").strip().upper()
455
+
456
+
457
+ def _review_author(review: dict) -> str:
458
+ author = review.get("author") or {}
459
+ if isinstance(author, dict):
460
+ return str(author.get("login") or "").strip().lower()
461
+ return ""
462
+
463
+
464
+ def _is_public_evolution_pr(details: dict) -> bool:
465
+ body = str(details.get("body") or "")
466
+ return "Source: automated public core evolution from an opt-in machine." in body
467
+
468
+
469
+ def _review_already_left_by_user(details: dict, login: str) -> bool:
470
+ login = str(login or "").strip().lower()
471
+ if not login:
472
+ return False
473
+ for review in details.get("reviews") or []:
474
+ if _review_author(review) == login and _review_state(review) in {"APPROVED", "COMMENTED", "CHANGES_REQUESTED"}:
475
+ return True
476
+ return False
477
+
478
+
479
+ def _candidate_paths(details: dict) -> list[str]:
480
+ paths = []
481
+ for item in details.get("files") or []:
482
+ if isinstance(item, dict):
483
+ path = str(item.get("path") or item.get("name") or "").strip()
484
+ if path:
485
+ paths.append(path)
486
+ return paths
487
+
488
+
489
+ def _list_reviewable_public_prs(config: dict, limit: int = 3) -> list[dict]:
490
+ result = _gh(
491
+ "pr",
492
+ "list",
493
+ "--repo",
494
+ config["upstream_repo"],
495
+ "--state",
496
+ "open",
497
+ "--json",
498
+ "number,title,url,isDraft,author",
499
+ "--limit",
500
+ str(max(1, limit * 4)),
501
+ timeout=30,
502
+ )
503
+ if result.returncode != 0:
504
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr list failed")
505
+
506
+ github_user = str(config.get("github_user") or "").strip().lower()
507
+ active_pr_number = config.get("active_pr_number")
508
+ candidates: list[dict] = []
509
+ for item in json.loads(result.stdout or "[]"):
510
+ if not item.get("isDraft", False):
511
+ continue
512
+ number = int(item.get("number") or 0)
513
+ if not number or number == active_pr_number:
514
+ continue
515
+ author = item.get("author") or {}
516
+ author_login = str(author.get("login") or "").strip().lower()
517
+ if github_user and author_login == github_user:
518
+ continue
519
+
520
+ details_result = _gh(
521
+ "pr",
522
+ "view",
523
+ str(number),
524
+ "--repo",
525
+ config["upstream_repo"],
526
+ "--json",
527
+ "number,title,body,url,isDraft,author,reviews,files",
528
+ timeout=30,
529
+ )
530
+ if details_result.returncode != 0:
531
+ continue
532
+ details = json.loads(details_result.stdout or "{}")
533
+ if not details.get("isDraft", False):
534
+ continue
535
+ if not _is_public_evolution_pr(details):
536
+ continue
537
+ if _review_already_left_by_user(details, github_user):
538
+ continue
539
+ paths = _candidate_paths(details)
540
+ if not paths or any(not _is_allowed_public_path(path) for path in paths):
541
+ continue
542
+
543
+ diff_result = _gh(
544
+ "pr",
545
+ "diff",
546
+ str(number),
547
+ "--repo",
548
+ config["upstream_repo"],
549
+ timeout=60,
550
+ )
551
+ if diff_result.returncode != 0:
552
+ continue
553
+ details["files_changed"] = paths
554
+ details["diff_text"] = diff_result.stdout or ""
555
+ candidates.append(details)
556
+ if len(candidates) >= limit:
557
+ break
558
+ return candidates
559
+
560
+
561
+ _DEDUP_STOPWORDS = {
562
+ "the", "and", "for", "with", "from", "into", "after", "before", "public",
563
+ "core", "nexo", "fix", "feat", "chore", "docs", "tests", "runtime", "system",
564
+ }
565
+
566
+
567
+ def _proposal_tokens(text: str) -> set[str]:
568
+ return {
569
+ token
570
+ for token in re.findall(r"[a-z0-9]+", (text or "").lower())
571
+ if len(token) >= 3 and token not in _DEDUP_STOPWORDS
572
+ }
573
+
574
+
575
+ def _public_pr_duplicate_candidate(config: dict, *, title: str, changed_files: list[str]) -> dict | None:
576
+ try:
577
+ candidates = _list_reviewable_public_prs(config, limit=12)
578
+ except Exception:
579
+ return None
580
+ wanted_files = {str(path).strip().lower() for path in (changed_files or []) if str(path).strip()}
581
+ wanted_tokens = _proposal_tokens(title)
582
+ best_match = None
583
+ best_score = 0.0
584
+ for candidate in candidates:
585
+ candidate_files = {
586
+ str(path).strip().lower() for path in (candidate.get("files_changed") or []) if str(path).strip()
587
+ }
588
+ shared_files = wanted_files & candidate_files
589
+ candidate_tokens = _proposal_tokens(str(candidate.get("title") or ""))
590
+ shared_tokens = wanted_tokens & candidate_tokens
591
+ token_score = 0.0
592
+ if wanted_tokens and candidate_tokens:
593
+ token_score = len(shared_tokens) / max(1, min(len(wanted_tokens), len(candidate_tokens)))
594
+ score = 0.0
595
+ if shared_files and token_score >= 0.34:
596
+ score = 1.0
597
+ elif shared_files:
598
+ score = 0.75
599
+ elif token_score >= 0.8:
600
+ score = 0.7
601
+ if score > best_score:
602
+ best_score = score
603
+ best_match = {
604
+ "number": candidate.get("number"),
605
+ "title": candidate.get("title"),
606
+ "url": candidate.get("url"),
607
+ "score": round(score, 2),
608
+ "shared_files": sorted(shared_files),
609
+ "shared_tokens": sorted(shared_tokens),
610
+ }
611
+ return best_match if best_score >= 0.75 else None
612
+
613
+
614
+ def _parse_public_review_json(text: str) -> dict:
615
+ payload = text.strip()
616
+ if "```json" in payload:
617
+ payload = payload.split("```json", 1)[1].split("```", 1)[0]
618
+ elif "```" in payload:
619
+ payload = payload.split("```", 1)[1].split("```", 1)[0]
620
+ try:
621
+ data = json.loads(payload.strip())
622
+ except Exception:
623
+ data = {}
624
+ return data if isinstance(data, dict) else {}
625
+
626
+
627
+ def _submit_public_pr_review(config: dict, pr_number: int, decision: str, body: str) -> str:
628
+ clean_decision = str(decision or "").strip().lower()
629
+ clean_body = str(body or "").strip()
630
+ if clean_decision == "approve":
631
+ result = _gh(
632
+ "pr",
633
+ "review",
634
+ str(pr_number),
635
+ "--repo",
636
+ config["upstream_repo"],
637
+ "--approve",
638
+ "--body",
639
+ clean_body or "Scoped public-core change looks correct from automated peer review.",
640
+ timeout=60,
641
+ )
642
+ if result.returncode != 0:
643
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr review --approve failed")
644
+ return "approved_review"
645
+ if clean_decision == "comment":
646
+ result = _gh(
647
+ "pr",
648
+ "review",
649
+ str(pr_number),
650
+ "--repo",
651
+ config["upstream_repo"],
652
+ "--comment",
653
+ "--body",
654
+ clean_body or "Automated peer review left a note but did not approve.",
655
+ timeout=60,
656
+ )
657
+ if result.returncode != 0:
658
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr review --comment failed")
659
+ return "commented_review"
660
+ return "review_skipped"
661
+
662
+
663
+ def _write_public_review_artifacts(pr_number: int, candidate: dict, review: dict) -> Path:
664
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
665
+ artifact_dir = CONTRIB_ARTIFACTS_DIR / f"review-{timestamp}-pr{pr_number}"
666
+ artifact_dir.mkdir(parents=True, exist_ok=True)
667
+ (artifact_dir / "candidate.json").write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n")
668
+ (artifact_dir / "review.json").write_text(json.dumps(review, indent=2, ensure_ascii=False) + "\n")
669
+ (artifact_dir / "diff.patch").write_text(str(candidate.get("diff_text") or ""))
670
+ return artifact_dir
671
+
672
+
673
+ def run_public_pr_validation_cycle(*, objective: dict, cycle_num: int, config: dict | None = None) -> int:
674
+ config = config or load_public_contribution_config()
675
+ if not verify_claude_cli():
676
+ log("Automation backend not available or not authenticated. Skipping peer PR validation.")
677
+ mark_public_contribution_result(result="skipped:peer_review_cli_unavailable", config=config)
678
+ return 0
679
+
680
+ _ensure_public_repo_cache(config)
681
+ candidates = _list_reviewable_public_prs(config, limit=3)
682
+ if not candidates:
683
+ log("No reviewable peer public-evolution PRs found.")
684
+ mark_public_contribution_result(result="skipped:no_peer_prs", config=config)
685
+ return 0
686
+
687
+ repo_root = str(CONTRIB_REPO_DIR if CONTRIB_REPO_DIR.exists() else Path.cwd())
688
+ conn = sqlite3.connect(str(NEXO_DB), timeout=10)
689
+ conn.execute("PRAGMA busy_timeout=5000")
690
+ reviewed = 0
691
+ try:
692
+ for candidate in candidates:
693
+ pr_number = int(candidate.get("number") or 0)
694
+ prompt = build_public_pr_review_prompt(
695
+ pr_number=pr_number,
696
+ title=str(candidate.get("title") or "").strip(),
697
+ author=str((candidate.get("author") or {}).get("login") or "").strip(),
698
+ url=str(candidate.get("url") or "").strip(),
699
+ body=str(candidate.get("body") or ""),
700
+ files=candidate.get("files_changed") or [],
701
+ diff_text=str(candidate.get("diff_text") or ""),
702
+ )
703
+ raw_review = call_public_claude_cli(prompt, cwd=Path(repo_root))
704
+ review = _parse_public_review_json(raw_review)
705
+ decision = str(review.get("decision") or "skip").strip().lower()
706
+ review_status = _submit_public_pr_review(config, pr_number, decision, str(review.get("body") or ""))
707
+ artifact_dir = _write_public_review_artifacts(pr_number, candidate, review)
708
+ conn.execute(
709
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
710
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
711
+ (
712
+ cycle_num,
713
+ "public_core",
714
+ f"Review PR #{pr_number}: {str(candidate.get('title') or '').strip()}",
715
+ "public_review",
716
+ str(review.get("summary") or "Peer PR validation").strip(),
717
+ review_status,
718
+ json.dumps(candidate.get("files_changed") or []),
719
+ json.dumps(
720
+ {
721
+ "pr_url": candidate.get("url"),
722
+ "decision": decision,
723
+ "artifact_dir": str(artifact_dir),
724
+ }
725
+ ),
726
+ ),
727
+ )
728
+ conn.commit()
729
+ reviewed += 1
730
+
731
+ if reviewed:
732
+ objective["last_evolution"] = str(date.today())
733
+ objective["total_evolutions"] = cycle_num
734
+ objective.setdefault("history", []).insert(0, {
735
+ "cycle": cycle_num,
736
+ "date": str(date.today()),
737
+ "mode": "public_core_review",
738
+ "proposals": 0,
739
+ "auto_count": 0,
740
+ "auto_applied": 0,
741
+ "analysis": f"Reviewed {reviewed} peer public-evolution PR(s).",
742
+ })
743
+ objective["history"] = objective["history"][:12]
744
+ save_objective(objective)
745
+ mark_public_contribution_result(result=f"peer_reviewed:{reviewed}", config=config)
746
+ return reviewed
747
+ finally:
748
+ conn.close()
749
+
750
+
751
+ def _create_draft_pr(worktree_dir: Path, config: dict, branch_name: str, summary: dict) -> tuple[str, int | None]:
752
+ title = str(summary.get("title") or "chore: public evolution contribution").strip()
753
+ body_lines = [
754
+ summary.get("problem", "Problem: see diff."),
755
+ "",
756
+ "Summary:",
757
+ str(summary.get("summary") or "See diff."),
758
+ "",
759
+ "Tests:",
760
+ ]
761
+ tests = summary.get("tests") or []
762
+ if isinstance(tests, list) and tests:
763
+ body_lines.extend(f"- {item}" for item in tests)
764
+ else:
765
+ body_lines.append("- See CI / local validation")
766
+ risks = summary.get("risks") or []
767
+ if isinstance(risks, list) and risks:
768
+ body_lines.extend(["", "Risks:"])
769
+ body_lines.extend(f"- {item}" for item in risks)
770
+ body_lines.extend(["", "Source: automated public core evolution from an opt-in machine."])
771
+ body_file = worktree_dir / ".nexo-public-pr-body.md"
772
+ body_file.write_text("\n".join(body_lines) + "\n")
773
+ head = f"{config['github_user']}:{branch_name}"
774
+ result = _gh(
775
+ "pr",
776
+ "create",
777
+ "--repo",
778
+ config["upstream_repo"],
779
+ "--head",
780
+ head,
781
+ "--base",
782
+ "main",
783
+ "--title",
784
+ title,
785
+ "--body-file",
786
+ str(body_file),
787
+ "--draft",
788
+ cwd=worktree_dir,
789
+ timeout=120,
790
+ )
791
+ if result.returncode != 0:
792
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr create failed")
793
+ pr_url = (result.stdout or "").strip().splitlines()[-1].strip()
794
+ match = re.search(r"/pull/(\d+)", pr_url)
795
+ pr_number = int(match.group(1)) if match else None
796
+ return pr_url, pr_number
797
+
798
+
799
+ def run_public_contribution_cycle(*, objective: dict, cycle_num: int) -> None:
800
+ config = load_public_contribution_config()
801
+ ready, reason, config = can_run_public_contribution(config)
802
+ if not ready:
803
+ if config.get("status") == STATUS_PAUSED_OPEN_PR:
804
+ log(f"Public core contribution paused: {reason}. Switching to peer PR validation.")
805
+ reviewed = run_public_pr_validation_cycle(objective=objective, cycle_num=cycle_num, config=config)
806
+ if reviewed:
807
+ log(f"Peer public PR validation complete: reviewed {reviewed} PR(s).")
808
+ return
809
+ log(f"Public core contribution paused: {reason}")
810
+ mark_public_contribution_result(result=f"skipped:{reason}", config=config)
811
+ return
812
+
813
+ if not verify_claude_cli():
814
+ log("Automation backend not available or not authenticated. Skipping public contribution run.")
815
+ mark_public_contribution_result(result="skipped:claude_cli_unavailable", config=config)
816
+ return
817
+
818
+ worktree_dir: Path | None = None
819
+ branch_name = ""
820
+ summary: dict = {}
821
+ conn = sqlite3.connect(str(NEXO_DB), timeout=10)
822
+ conn.row_factory = sqlite3.Row
823
+ queued_candidate: dict | None = None
824
+ try:
825
+ pending_candidates = list_pending_public_port_candidates(conn, limit=1)
826
+ if pending_candidates:
827
+ queued_candidate = pending_candidates[0]
828
+ worktree_dir, branch_name = _prepare_public_worktree(config, title_hint="public-core")
829
+ _prime_public_git_identity(worktree_dir, config)
830
+ prompt = build_public_contribution_prompt(
831
+ repo_root=str(worktree_dir),
832
+ cycle_number=cycle_num,
833
+ queued_candidate=queued_candidate,
834
+ )
835
+ raw_response = call_public_claude_cli(prompt, cwd=worktree_dir)
836
+ summary = _parse_summary_json(raw_response)
837
+ changed_files = _changed_public_files(worktree_dir)
838
+ ok, reason = _sanitize_public_diff(worktree_dir, changed_files)
839
+ if not ok:
840
+ raise RuntimeError(reason)
841
+
842
+ tests_run = _run_public_validation(worktree_dir, changed_files)
843
+ existing_tests = summary.get("tests")
844
+ summary["tests"] = existing_tests if isinstance(existing_tests, list) and existing_tests else tests_run
845
+ commit_title = str(summary.get("title") or "chore: public evolution contribution").strip()
846
+ duplicate = _public_pr_duplicate_candidate(config, title=commit_title, changed_files=changed_files)
847
+ if duplicate:
848
+ artifact_dir = _write_public_artifacts(
849
+ worktree_dir,
850
+ branch_name,
851
+ {
852
+ **summary,
853
+ "duplicate_of": duplicate,
854
+ "tests": summary.get("tests", []),
855
+ "changed_files": changed_files,
856
+ },
857
+ )
858
+ conn.execute(
859
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
860
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
861
+ (
862
+ cycle_num,
863
+ "public_core",
864
+ commit_title,
865
+ "draft_pr_dedup",
866
+ f"Duplicate of open opt-in public PR #{duplicate.get('number')}: {duplicate.get('title')}",
867
+ "skipped_duplicate_existing_pr",
868
+ json.dumps(changed_files),
869
+ json.dumps({"duplicate_of": duplicate, "artifact_dir": str(artifact_dir)}),
870
+ ),
871
+ )
872
+ conn.commit()
873
+ if queued_candidate:
874
+ update_public_port_candidate(
875
+ conn,
876
+ queued_candidate["id"],
877
+ status="skipped_duplicate_existing_pr",
878
+ metadata_patch={"duplicate_of": duplicate},
879
+ )
880
+ conn.commit()
881
+ mark_public_contribution_result(
882
+ result=f"skipped:duplicate_pr:{duplicate.get('number')}",
883
+ config=config,
884
+ )
885
+ log(
886
+ "Public core contribution deduplicated against existing opt-in PR "
887
+ f"#{duplicate.get('number')} ({duplicate.get('url')})."
888
+ )
889
+ return
890
+
891
+ add = _git(worktree_dir, "add", "--", *changed_files, timeout=60)
892
+ if add.returncode != 0:
893
+ raise RuntimeError(add.stderr.strip() or add.stdout.strip() or "git add failed")
894
+ commit = _git(worktree_dir, "commit", "-m", commit_title, timeout=120)
895
+ if commit.returncode != 0:
896
+ raise RuntimeError(commit.stderr.strip() or commit.stdout.strip() or "git commit failed")
897
+ push = _git(worktree_dir, "push", "fork", f"HEAD:refs/heads/{branch_name}", "--force-with-lease", timeout=180)
898
+ if push.returncode != 0:
899
+ raise RuntimeError(push.stderr.strip() or push.stdout.strip() or "git push failed")
900
+
901
+ pr_url, pr_number = _create_draft_pr(worktree_dir, config, branch_name, summary)
902
+ artifact_dir = _write_public_artifacts(worktree_dir, branch_name, summary)
903
+ config = mark_active_pr(pr_url=pr_url, pr_number=pr_number, branch=branch_name, config=config)
904
+
905
+ conn.execute(
906
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
907
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
908
+ (
909
+ cycle_num,
910
+ "public_core",
911
+ commit_title,
912
+ "draft_pr",
913
+ summary.get("problem", "Public core contribution"),
914
+ "draft_pr_created",
915
+ json.dumps(changed_files),
916
+ json.dumps({"tests": summary.get("tests", []), "pr_url": pr_url, "artifact_dir": str(artifact_dir)}),
917
+ ),
918
+ )
919
+ conn.commit()
920
+ if queued_candidate:
921
+ update_public_port_candidate(
922
+ conn,
923
+ queued_candidate["id"],
924
+ status="draft_pr_created",
925
+ metadata_patch={
926
+ "pr_url": pr_url,
927
+ "pr_number": pr_number,
928
+ "branch": branch_name,
929
+ "ported_via_cycle": cycle_num,
930
+ },
931
+ )
932
+ conn.commit()
933
+
934
+ objective["last_evolution"] = str(date.today())
935
+ objective["total_evolutions"] = cycle_num
936
+ objective["total_proposals_made"] = objective.get("total_proposals_made", 0) + 1
937
+ objective.setdefault("history", []).insert(0, {
938
+ "cycle": cycle_num,
939
+ "date": str(date.today()),
940
+ "mode": "public_core",
941
+ "proposals": 1,
942
+ "auto_count": 0,
943
+ "auto_applied": 0,
944
+ "analysis": (summary.get("summary") or commit_title)[:200],
945
+ "pr_url": pr_url,
946
+ })
947
+ objective["history"] = objective["history"][:12]
948
+ save_objective(objective)
949
+ mark_public_contribution_result(result=f"draft_pr_created:{pr_url}", config=config)
950
+ log(f"Public core contribution complete: Draft PR created at {pr_url}")
951
+ except Exception as exc:
952
+ mark_public_contribution_result(result=f"failed:{exc}", config=config)
953
+ raise
954
+ finally:
955
+ conn.close()
956
+ if worktree_dir is not None:
957
+ _remove_public_worktree(worktree_dir)
958
+
959
+
960
+ # ── File safety validation ───────────────────────────────────────────────
961
+ def is_safe_path(filepath: str, mode: str = "auto") -> bool:
962
+ """Check if a file path is within safe zones and not immutable.
963
+ mode='auto' (public): restricted to personal automation surfaces.
964
+ mode='managed' (owner): broader repo/core surfaces with rollback.
965
+ mode='review': broader zones for proposal validation, but no execution.
966
+ """
967
+ expanded = str(Path(filepath).expanduser().resolve())
968
+ filename = Path(expanded).name
969
+ mode = _normalize_mode(mode)
970
+
971
+ if filename in _immutable_files_for_mode(mode):
972
+ return False
973
+
974
+ prefixes = _managed_safe_prefixes() if mode in {"managed", "review"} else _public_safe_prefixes()
975
+ for prefix in prefixes:
976
+ resolved_prefix = str(Path(prefix).expanduser().resolve())
977
+ if expanded.startswith(resolved_prefix):
978
+ return True
979
+
980
+ return False
981
+
982
+
983
+ def validate_syntax(filepath: str) -> tuple[bool, str]:
984
+ """Basic syntax validation for known file types."""
985
+ path = Path(filepath)
986
+ ext = path.suffix
987
+
988
+ if ext == ".py":
989
+ try:
990
+ py_compile.compile(str(path), doraise=True)
991
+ return True, "Python syntax OK"
992
+ except Exception as e:
993
+ return False, f"Validation error: {e}"
994
+
995
+ elif ext == ".sh":
996
+ try:
997
+ result = subprocess.run(
998
+ ["bash", "-n", str(path)],
999
+ capture_output=True, text=True, timeout=10
1000
+ )
1001
+ if result.returncode == 0:
1002
+ return True, "Bash syntax OK"
1003
+ return False, f"Bash syntax error: {result.stderr[:200]}"
1004
+ except Exception as e:
1005
+ return False, f"Validation error: {e}"
1006
+
1007
+ elif ext == ".json":
1008
+ try:
1009
+ json.loads(Path(filepath).read_text())
1010
+ return True, "JSON valid"
1011
+ except Exception as e:
1012
+ return False, f"JSON error: {e}"
1013
+
1014
+ elif ext == ".md":
1015
+ return True, "Markdown (no validation needed)"
1016
+
1017
+ return True, f"No validator for {ext} (accepted)"
1018
+
1019
+
1020
+ # ── Apply a single change operation ──────────────────────────────────────
1021
+ def apply_change(change: dict, mode: str = "auto") -> tuple[bool, str]:
1022
+ """Apply a single file change operation. Returns (success, message)."""
1023
+ filepath = str(Path(change["file"]).expanduser())
1024
+ operation = change.get("operation", "")
1025
+ content = change.get("content", "")
1026
+
1027
+ if not is_safe_path(filepath, mode=mode):
1028
+ return False, f"BLOCKED: {filepath} is outside safe zones or immutable"
1029
+
1030
+ try:
1031
+ if operation == "create":
1032
+ if Path(filepath).exists():
1033
+ return False, f"BLOCKED: {filepath} already exists (create requires new file)"
1034
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
1035
+ Path(filepath).write_text(content)
1036
+ # Make scripts executable
1037
+ if filepath.endswith(".sh") or filepath.endswith(".py"):
1038
+ os.chmod(filepath, 0o755)
1039
+ return True, f"Created {filepath}"
1040
+
1041
+ elif operation == "replace":
1042
+ search = change.get("search", "")
1043
+ if not search:
1044
+ return False, "BLOCKED: replace operation requires 'search' field"
1045
+ if not Path(filepath).exists():
1046
+ return False, f"BLOCKED: {filepath} does not exist"
1047
+ original = Path(filepath).read_text()
1048
+ count = original.count(search)
1049
+ if count == 0:
1050
+ return False, f"BLOCKED: search text not found in {filepath}"
1051
+ if count > 1:
1052
+ return False, f"BLOCKED: search text matches {count} times (must be unique)"
1053
+ new_content = original.replace(search, content, 1)
1054
+ Path(filepath).write_text(new_content)
1055
+ return True, f"Replaced in {filepath}"
1056
+
1057
+ elif operation == "append":
1058
+ if not Path(filepath).exists():
1059
+ return False, f"BLOCKED: {filepath} does not exist"
1060
+ with open(filepath, "a") as f:
1061
+ f.write(content)
1062
+ return True, f"Appended to {filepath}"
1063
+
1064
+ else:
1065
+ return False, f"BLOCKED: unknown operation '{operation}'"
1066
+
1067
+ except Exception as e:
1068
+ return False, f"ERROR: {e}"
1069
+
1070
+
1071
+ # ── Execute AUTO proposals ───────────────────────────────────────────────
1072
+ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection, mode: str = "auto") -> dict:
1073
+ """Execute an AUTO proposal with snapshot/apply/validate/rollback."""
1074
+ changes = proposal.get("changes", [])
1075
+ if not changes:
1076
+ return {"status": "skipped", "reason": "No changes array in proposal"}
1077
+
1078
+ # Validate all paths first
1079
+ for change in changes:
1080
+ filepath = str(Path(change["file"]).expanduser())
1081
+ if not is_safe_path(filepath, mode=mode):
1082
+ return {"status": "blocked", "reason": f"Unsafe path: {filepath}"}
1083
+
1084
+ # Collect files to snapshot (existing files only)
1085
+ files_to_backup = []
1086
+ for change in changes:
1087
+ filepath = str(Path(change["file"]).expanduser())
1088
+ if Path(filepath).exists():
1089
+ files_to_backup.append(filepath)
1090
+
1091
+ # Create snapshot
1092
+ snapshot_ref = None
1093
+ if files_to_backup:
1094
+ snapshot_ref = create_snapshot(files_to_backup)
1095
+ log(f" Snapshot created: {snapshot_ref}")
1096
+
1097
+ # Apply changes
1098
+ applied_files = []
1099
+ all_results = []
1100
+ try:
1101
+ for change in changes:
1102
+ success, msg = apply_change(change, mode=mode)
1103
+ all_results.append(msg)
1104
+ log(f" {msg}")
1105
+ if not success:
1106
+ raise RuntimeError(f"Change failed: {msg}")
1107
+ filepath = str(Path(change["file"]).expanduser())
1108
+ applied_files.append(filepath)
1109
+
1110
+ # Validate all modified/created files
1111
+ for filepath in applied_files:
1112
+ valid, vmsg = validate_syntax(filepath)
1113
+ all_results.append(vmsg)
1114
+ log(f" Validate: {vmsg}")
1115
+ if not valid:
1116
+ raise RuntimeError(f"Validation failed: {vmsg}")
1117
+
1118
+ return {
1119
+ "status": "applied",
1120
+ "snapshot_ref": snapshot_ref,
1121
+ "files_changed": applied_files,
1122
+ "test_result": "; ".join(all_results),
1123
+ }
1124
+
1125
+ except RuntimeError as e:
1126
+ # Rollback
1127
+ log(f" ROLLBACK: {e}")
1128
+ if snapshot_ref:
1129
+ try:
1130
+ restore_script = CLAUDE_DIR / "scripts" / "nexo-snapshot-restore.sh"
1131
+ subprocess.run(
1132
+ [str(restore_script), snapshot_ref],
1133
+ capture_output=True, timeout=15, check=True
1134
+ )
1135
+ log(f" Restored from snapshot {snapshot_ref}")
1136
+ except Exception as re:
1137
+ log(f" CRITICAL: Restore failed: {re}")
1138
+ else:
1139
+ # Remove created files that didn't exist before
1140
+ for filepath in applied_files:
1141
+ if filepath not in files_to_backup:
1142
+ Path(filepath).unlink(missing_ok=True)
1143
+ log(f" Removed created file: {filepath}")
1144
+
1145
+ return {
1146
+ "status": "rolled_back",
1147
+ "snapshot_ref": snapshot_ref,
1148
+ "files_changed": [],
1149
+ "test_result": f"ROLLBACK: {e}; " + "; ".join(all_results),
1150
+ }
1151
+
1152
+
1153
+ # ── Followups for managed/review modes ──────────────────────────────────
1154
+ def _insert_followup(conn: sqlite3.Connection, followup_id: str, description: str,
1155
+ verification: str, due_date: str | None = None):
1156
+ now_epoch = datetime.now().timestamp()
1157
+ conn.execute(
1158
+ "INSERT OR REPLACE INTO followups (id, description, date, status, verification, created_at, updated_at) "
1159
+ "VALUES (?, ?, ?, 'PENDING', ?, ?, ?)",
1160
+ (followup_id, description, due_date, verification, now_epoch, now_epoch)
1161
+ )
1162
+ conn.commit()
1163
+
1164
+
1165
+ def _create_cycle_followup(conn: sqlite3.Connection, cycle_num: int,
1166
+ items: list[dict], analysis: str, mode: str):
1167
+ """Create a followup summarizing pending proposals or owner review items."""
1168
+ tomorrow = (date.today() + timedelta(days=1)).isoformat()
1169
+ followup_id = f"NF-EVO-C{cycle_num}"
1170
+
1171
+ public_items = [i for i in items if i.get("scope") == "public"]
1172
+ local_items = [i for i in items if i.get("scope") != "public"]
1173
+
1174
+ title = "proposals to review" if mode == "review" else "items needing attention"
1175
+ lines = [f"Evolution Cycle #{cycle_num} — {len(items)} {title}."]
1176
+ lines.append(f"Analysis: {analysis[:200]}")
1177
+ lines.append("")
1178
+
1179
+ if public_items:
1180
+ lines.append(f"FOR EVERYONE ({len(public_items)}):")
1181
+ for i, item in enumerate(public_items, 1):
1182
+ status = item.get("status", "proposed").upper()
1183
+ lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
1184
+ lines.append(f" Why: {item['reasoning'][:100]}")
1185
+ if item.get("detail"):
1186
+ lines.append(f" Detail: {item['detail'][:160]}")
1187
+ lines.append("")
1188
+
1189
+ if local_items:
1190
+ lines.append(f"FOR YOU ONLY ({len(local_items)}):")
1191
+ for i, item in enumerate(local_items, 1):
1192
+ status = item.get("status", "proposed").upper()
1193
+ lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
1194
+ lines.append(f" Why: {item['reasoning'][:100]}")
1195
+ if item.get("detail"):
1196
+ lines.append(f" Detail: {item['detail'][:160]}")
1197
+
1198
+ description = "\n".join(lines)
1199
+
1200
+ try:
1201
+ _insert_followup(
1202
+ conn,
1203
+ followup_id,
1204
+ description,
1205
+ f"SELECT * FROM evolution_log WHERE cycle_number={cycle_num}",
1206
+ due_date=tomorrow,
1207
+ )
1208
+ log(f" Followup {followup_id} created for {tomorrow}")
1209
+ except Exception as e:
1210
+ log(f" WARN: Failed to create followup: {e}")
1211
+
1212
+
1213
+ def _create_failure_followup(conn: sqlite3.Connection, cycle_num: int, log_id: int,
1214
+ proposal: dict, result: dict):
1215
+ """Create an incident-style followup for a failed or blocked AUTO proposal."""
1216
+ followup_id = f"NF-EVO-L{log_id}"
1217
+ lines = [
1218
+ f"Evolution AUTO proposal failed in cycle #{cycle_num}.",
1219
+ f"Action: {proposal.get('action', '')[:200]}",
1220
+ f"Dimension: {proposal.get('dimension', 'other')}",
1221
+ f"Status: {result.get('status', 'failed')}",
1222
+ f"Reason: {(result.get('reason') or result.get('test_result') or 'unknown')[:400]}",
1223
+ ]
1224
+ snapshot_ref = result.get("snapshot_ref")
1225
+ if snapshot_ref:
1226
+ lines.append(f"Snapshot: {snapshot_ref}")
1227
+ description = "\n".join(lines)
1228
+
1229
+ try:
1230
+ _insert_followup(
1231
+ conn,
1232
+ followup_id,
1233
+ description,
1234
+ f"SELECT * FROM evolution_log WHERE id={log_id}",
1235
+ due_date=(date.today() + timedelta(days=1)).isoformat(),
1236
+ )
1237
+ log(f" Failure followup {followup_id} created")
1238
+ except Exception as e:
1239
+ log(f" WARN: Failed to create failure followup: {e}")
1240
+
1241
+
1242
+ # ── Apply user-approved proposals from prior cycles ─────────────────────
1243
+ def _apply_accepted_proposals(
1244
+ conn: sqlite3.Connection,
1245
+ cycle_num: int,
1246
+ max_to_apply: int,
1247
+ evolution_mode: str,
1248
+ ) -> dict:
1249
+ """Apply evolution_log rows that the user marked as `accepted`.
1250
+
1251
+ Reads up to `max_to_apply` rows where `status = 'accepted'` and
1252
+ `proposal_payload IS NOT NULL`, deserializes the original proposal dict,
1253
+ and runs each one through `execute_auto_proposal()` (same path as live
1254
+ AUTO proposals: snapshot, apply, validate, rollback on failure).
1255
+
1256
+ Updates each row's status to one of: 'applied', 'rolled_back', 'blocked',
1257
+ 'skipped'. Failed rows get an `NF-EVO-L<id>` followup so they remain
1258
+ visible after the cycle. The cycle continues even if individual rows
1259
+ fail — one bad proposal does not block the queue.
1260
+
1261
+ Pre-m38 rows have NULL proposal_payload and are intentionally skipped:
1262
+ we cannot reconstruct their `changes` array.
1263
+
1264
+ Returns: dict with attempted/applied/rolled_back/blocked/skipped/failed counts.
1265
+ """
1266
+ rows = conn.execute(
1267
+ "SELECT id, dimension, proposal, reasoning, proposal_payload "
1268
+ "FROM evolution_log WHERE status = 'accepted' AND proposal_payload IS NOT NULL "
1269
+ "ORDER BY id ASC LIMIT ?",
1270
+ (max(1, int(max_to_apply)),),
1271
+ ).fetchall()
1272
+
1273
+ stats = {
1274
+ "attempted": 0,
1275
+ "applied": 0,
1276
+ "rolled_back": 0,
1277
+ "blocked": 0,
1278
+ "skipped": 0,
1279
+ "failed": 0,
1280
+ }
1281
+
1282
+ for row in rows:
1283
+ log_id = row["id"]
1284
+ raw_payload = row["proposal_payload"]
1285
+ try:
1286
+ payload = json.loads(raw_payload)
1287
+ except Exception as e:
1288
+ log(f" ACCEPTED #{log_id} skipped: invalid payload ({e})")
1289
+ conn.execute(
1290
+ "UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
1291
+ ("skipped", f"Invalid proposal_payload JSON: {e}", log_id),
1292
+ )
1293
+ stats["skipped"] += 1
1294
+ continue
1295
+
1296
+ if not isinstance(payload, dict) or not payload.get("changes"):
1297
+ log(f" ACCEPTED #{log_id} skipped: payload missing changes array")
1298
+ conn.execute(
1299
+ "UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
1300
+ ("skipped", "Payload missing or empty changes array", log_id),
1301
+ )
1302
+ stats["skipped"] += 1
1303
+ continue
1304
+
1305
+ action = (payload.get("action") or row["proposal"] or "")[:80]
1306
+ log(f" ACCEPTED #{log_id} applying: {action}")
1307
+ stats["attempted"] += 1
1308
+
1309
+ try:
1310
+ result = execute_auto_proposal(payload, cycle_num, conn, mode=evolution_mode)
1311
+ except Exception as e:
1312
+ log(f" FAILED execute_auto_proposal: {e}")
1313
+ conn.execute(
1314
+ "UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
1315
+ ("blocked", f"execute_auto_proposal raised: {e}", log_id),
1316
+ )
1317
+ stats["failed"] += 1
1318
+ try:
1319
+ _create_failure_followup(
1320
+ conn, cycle_num, log_id, payload, {"status": "failed", "reason": str(e)}
1321
+ )
1322
+ except Exception:
1323
+ pass
1324
+ continue
1325
+
1326
+ status = str(result.get("status") or "failed")
1327
+ update_sets = ["status = ?"]
1328
+ update_vals: list[object] = [status]
1329
+ if "test_result" in result:
1330
+ update_sets.append("test_result = ?")
1331
+ update_vals.append(str(result.get("test_result", ""))[:2000])
1332
+ if result.get("snapshot_ref"):
1333
+ update_sets.append("snapshot_ref = ?")
1334
+ update_vals.append(result["snapshot_ref"])
1335
+ if result.get("files_changed"):
1336
+ update_sets.append("files_changed = ?")
1337
+ update_vals.append(json.dumps(result["files_changed"]))
1338
+ update_vals.append(log_id)
1339
+ conn.execute(
1340
+ f"UPDATE evolution_log SET {', '.join(update_sets)} WHERE id = ?",
1341
+ update_vals,
1342
+ )
1343
+
1344
+ if status == "applied":
1345
+ stats["applied"] += 1
1346
+ log(f" APPLIED")
1347
+ elif status == "rolled_back":
1348
+ stats["rolled_back"] += 1
1349
+ log(f" ROLLED BACK: {str(result.get('test_result', ''))[:100]}")
1350
+ try:
1351
+ _create_failure_followup(conn, cycle_num, log_id, payload, result)
1352
+ except Exception:
1353
+ pass
1354
+ elif status == "blocked":
1355
+ stats["blocked"] += 1
1356
+ log(f" BLOCKED: {str(result.get('reason') or result.get('test_result', ''))[:100]}")
1357
+ try:
1358
+ _create_failure_followup(conn, cycle_num, log_id, payload, result)
1359
+ except Exception:
1360
+ pass
1361
+ elif status == "skipped":
1362
+ stats["skipped"] += 1
1363
+ log(f" SKIPPED: {result.get('reason', '')}")
1364
+ else:
1365
+ stats["failed"] += 1
1366
+ log(f" UNKNOWN STATUS: {status}")
1367
+
1368
+ conn.commit()
1369
+ return stats
1370
+
1371
+
1372
+ # ── Main run ─────────────────────────────────────────────────────────────
1373
+ def run():
1374
+ log("=" * 60)
1375
+ log("NEXO Evolution cycle starting (standalone, v2 — real execution)")
1376
+
1377
+ # Check objective
1378
+ objective = load_objective()
1379
+ if not objective:
1380
+ log("ERROR: No evolution-objective.json found")
1381
+ sys.exit(1)
1382
+ if not objective.get("evolution_enabled", True):
1383
+ log(f"Evolution DISABLED: {objective.get('disabled_reason', 'unknown')}")
1384
+ return
1385
+
1386
+ # Circuit breaker: consecutive failures
1387
+ failures = get_consecutive_failures()
1388
+ if failures >= MAX_CONSECUTIVE_FAILURES:
1389
+ log(f"CIRCUIT BREAKER: {failures} consecutive failures. Disabling evolution.")
1390
+ objective["evolution_enabled"] = False
1391
+ objective["disabled_reason"] = f"Circuit breaker: {failures} consecutive failures at {datetime.now().isoformat()}"
1392
+ save_objective(objective)
1393
+ return
1394
+
1395
+ public_config = load_public_contribution_config()
1396
+ if str(public_config.get("mode") or "").strip().lower() in {"draft_prs", "pending_auth"}:
1397
+ cycle_num = objective.get("total_evolutions", 0) + 1
1398
+ try:
1399
+ run_public_contribution_cycle(objective=objective, cycle_num=cycle_num)
1400
+ set_consecutive_failures(0)
1401
+ except Exception as e:
1402
+ log(f"Public core contribution failed: {e}")
1403
+ set_consecutive_failures(failures + 1)
1404
+ return
1405
+
1406
+ # Dry-run restore test
1407
+ log("Running restore dry-run test...")
1408
+ if not dry_run_restore_test():
1409
+ log("CRITICAL: Restore test failed — aborting")
1410
+ set_consecutive_failures(failures + 1)
1411
+ sys.exit(1)
1412
+ log("Restore test PASSED")
1413
+
1414
+ # Apply user-approved proposals from prior cycles BEFORE generating new ones.
1415
+ # nexo_evolution_approve marks proposals as 'accepted' but until m38 there
1416
+ # was no consumer for that status. This step closes the loop so the user's
1417
+ # explicit approvals actually run on the next cycle, with the same sandbox
1418
+ # / snapshot / rollback safety as live AUTO proposals.
1419
+ log("Checking for user-approved proposals to apply...")
1420
+ cycle_num_for_apply = objective.get("total_evolutions", 0) + 1
1421
+ evolution_mode_for_apply = _normalize_mode(objective.get("evolution_mode", "auto"))
1422
+ max_to_apply = max_auto_changes(objective.get("total_evolutions", 0))
1423
+ apply_conn = sqlite3.connect(str(NEXO_DB), timeout=10)
1424
+ apply_conn.row_factory = sqlite3.Row
1425
+ apply_conn.execute("PRAGMA busy_timeout=5000")
1426
+ try:
1427
+ apply_stats = _apply_accepted_proposals(
1428
+ apply_conn,
1429
+ cycle_num_for_apply,
1430
+ max_to_apply,
1431
+ evolution_mode_for_apply,
1432
+ )
1433
+ if apply_stats["attempted"]:
1434
+ log(
1435
+ f" Applied {apply_stats['applied']}/{apply_stats['attempted']} accepted proposals "
1436
+ f"({apply_stats['rolled_back']} rolled back, "
1437
+ f"{apply_stats['blocked']} blocked, "
1438
+ f"{apply_stats['skipped']} skipped, "
1439
+ f"{apply_stats['failed']} failed)"
1440
+ )
1441
+ else:
1442
+ log(" No user-approved proposals pending")
1443
+ except Exception as e:
1444
+ log(f" WARN: apply_accepted_proposals raised: {e}")
1445
+ finally:
1446
+ apply_conn.close()
1447
+
1448
+ # Gather data
1449
+ log("Gathering week data from nexo.db...")
1450
+ week_data = get_week_data(str(NEXO_DB))
1451
+ log(f" Learnings: {len(week_data.get('learnings', []))}")
1452
+ log(f" Decisions: {len(week_data.get('decisions', []))}")
1453
+ log(f" Changes: {len(week_data.get('changes', []))}")
1454
+ log(f" Diaries: {len(week_data.get('diaries', []))}")
1455
+
1456
+ # Build prompt
1457
+ prompt = build_evolution_prompt(week_data, objective)
1458
+ log(f"Prompt built: {len(prompt)} chars")
1459
+
1460
+ # Verify the configured automation backend is available before calling
1461
+ if not verify_claude_cli():
1462
+ log("Automation backend not available or not authenticated. Skipping evolution run.")
1463
+ return
1464
+
1465
+ # Call the configured automation backend with the legacy opus task profile
1466
+ log("Calling automation backend with the opus task profile...")
1467
+ try:
1468
+ raw_response = call_claude_cli(prompt)
1469
+ except Exception as e:
1470
+ log(f"Automation backend call failed: {e}")
1471
+ set_consecutive_failures(failures + 1)
1472
+ return
1473
+
1474
+ log(f"Response received: {len(raw_response)} chars")
1475
+
1476
+ # Parse JSON
1477
+ try:
1478
+ text = raw_response
1479
+ if "```json" in text:
1480
+ text = text.split("```json")[1].split("```")[0]
1481
+ elif "```" in text:
1482
+ text = text.split("```")[1].split("```")[0]
1483
+ response = json.loads(text.strip())
1484
+ except Exception as e:
1485
+ log(f"JSON parse failed: {e}")
1486
+ log(f"Raw (first 500): {raw_response[:500]}")
1487
+ set_consecutive_failures(failures + 1)
1488
+ return
1489
+
1490
+ # Reset consecutive failures on successful parse
1491
+ set_consecutive_failures(0)
1492
+
1493
+ log(f"Analysis: {response.get('analysis', 'N/A')[:200]}")
1494
+
1495
+ # Log patterns
1496
+ for p in response.get("patterns", []):
1497
+ log(f" Pattern [{p.get('type', '?')}]: {p.get('description', '')[:100]} (freq: {p.get('frequency', '?')})")
1498
+
1499
+ # Process proposals
1500
+ proposals = response.get("proposals", [])
1501
+ cycle_num = objective.get("total_evolutions", 0) + 1
1502
+ max_auto = max_auto_changes(objective.get("total_evolutions", 0))
1503
+ auto_count = 0
1504
+ auto_applied = 0
1505
+ evolution_mode = _normalize_mode(objective.get("evolution_mode", "auto"))
1506
+
1507
+ conn = sqlite3.connect(str(NEXO_DB), timeout=10)
1508
+ conn.execute("PRAGMA busy_timeout=5000")
1509
+
1510
+ followup_items = []
1511
+
1512
+ for p in proposals:
1513
+ classification = p.get("classification", "propose")
1514
+ dimension = p.get("dimension", "other")
1515
+ action = p.get("action", "")
1516
+ reasoning = p.get("reasoning", "")
1517
+ scope = p.get("scope", "local") # "public" or "local"
1518
+
1519
+ if evolution_mode == "review":
1520
+ log(f" QUEUED [{scope}]: {action[:80]}")
1521
+ conn.execute(
1522
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
1523
+ "reasoning, status, proposal_payload) VALUES (?, ?, ?, ?, ?, ?, ?)",
1524
+ (cycle_num, dimension, action, classification, reasoning, "pending_review",
1525
+ json.dumps(p, ensure_ascii=False))
1526
+ )
1527
+ followup_items.append({
1528
+ "dimension": dimension,
1529
+ "action": action,
1530
+ "reasoning": reasoning,
1531
+ "scope": scope,
1532
+ "classification": classification,
1533
+ "status": "pending_review",
1534
+ })
1535
+
1536
+ elif classification == "auto" and auto_count < max_auto:
1537
+ auto_count += 1
1538
+ log(f" AUTO #{auto_count}/{max_auto}: {action[:80]}")
1539
+
1540
+ result = execute_auto_proposal(p, cycle_num, conn, mode=evolution_mode)
1541
+ status = result["status"]
1542
+
1543
+ cur = conn.execute(
1544
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
1545
+ "reasoning, status, files_changed, snapshot_ref, test_result, proposal_payload) "
1546
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1547
+ (cycle_num, dimension, action, "auto", reasoning, status,
1548
+ json.dumps(result.get("files_changed", [])),
1549
+ result.get("snapshot_ref", ""),
1550
+ result.get("test_result", ""),
1551
+ json.dumps(p, ensure_ascii=False))
1552
+ )
1553
+ log_id = cur.lastrowid
1554
+
1555
+ if status == "applied":
1556
+ auto_applied += 1
1557
+ log(f" APPLIED successfully")
1558
+ elif status == "blocked":
1559
+ detail = result.get("reason") or result.get("test_result", "")
1560
+ log(f" BLOCKED: {detail[:100]}")
1561
+ _create_failure_followup(conn, cycle_num, log_id, p, result)
1562
+ elif status == "skipped":
1563
+ log(f" SKIPPED: {result.get('reason', '')}")
1564
+ else:
1565
+ log(f" ROLLED BACK: {result.get('test_result', '')[:100]}")
1566
+ _create_failure_followup(conn, cycle_num, log_id, p, result)
1567
+
1568
+ else:
1569
+ # PROPOSE or over auto limit
1570
+ if classification == "auto" and auto_count >= max_auto:
1571
+ log(f" AUTO→PROPOSE (over limit {max_auto}): {action[:80]}")
1572
+ classification = "propose"
1573
+ else:
1574
+ log(f" PROPOSE: {action[:80]}")
1575
+
1576
+ conn.execute(
1577
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
1578
+ "reasoning, status, proposal_payload) VALUES (?, ?, ?, ?, ?, ?, ?)",
1579
+ (cycle_num, dimension, action, classification, reasoning, "proposed",
1580
+ json.dumps(p, ensure_ascii=False))
1581
+ )
1582
+ if evolution_mode in {"review", "managed"}:
1583
+ followup_items.append({
1584
+ "dimension": dimension,
1585
+ "action": action,
1586
+ "reasoning": reasoning,
1587
+ "scope": scope,
1588
+ "classification": classification,
1589
+ "status": "proposed",
1590
+ })
1591
+
1592
+ conn.commit()
1593
+
1594
+ if evolution_mode in {"review", "managed"} and followup_items:
1595
+ _create_cycle_followup(conn, cycle_num, followup_items, response.get("analysis", ""), evolution_mode)
1596
+
1597
+ # Update metrics
1598
+ scores = response.get("dimension_scores", {})
1599
+ evidence = response.get("score_evidence", {})
1600
+ current = week_data.get("current_metrics", {})
1601
+
1602
+ for dim, score in scores.items():
1603
+ if isinstance(score, (int, float)) and 0 <= score <= 100:
1604
+ prev = current.get(dim, {}).get("score", 0)
1605
+ delta = int(score) - prev
1606
+ conn.execute(
1607
+ "INSERT INTO evolution_metrics (dimension, score, evidence, delta) VALUES (?, ?, ?, ?)",
1608
+ (dim, int(score), json.dumps(evidence.get(dim, "")), delta)
1609
+ )
1610
+
1611
+ conn.commit()
1612
+ conn.close()
1613
+
1614
+ # Update objective
1615
+ objective["last_evolution"] = str(date.today())
1616
+ objective["total_evolutions"] = cycle_num
1617
+ objective["total_proposals_made"] = objective.get("total_proposals_made", 0) + len(proposals)
1618
+ objective["total_auto_applied"] = objective.get("total_auto_applied", 0) + auto_applied
1619
+ for dim, score in scores.items():
1620
+ if dim in objective.get("dimensions", {}) and isinstance(score, (int, float)):
1621
+ objective["dimensions"][dim]["current"] = int(score)
1622
+
1623
+ objective.setdefault("history", []).insert(0, {
1624
+ "cycle": cycle_num,
1625
+ "date": str(date.today()),
1626
+ "mode": evolution_mode,
1627
+ "proposals": len(proposals),
1628
+ "auto_count": auto_count,
1629
+ "auto_applied": auto_applied,
1630
+ "analysis": response.get("analysis", "")[:200]
1631
+ })
1632
+ objective["history"] = objective["history"][:12]
1633
+
1634
+ save_objective(objective)
1635
+
1636
+ log(f"Evolution cycle #{cycle_num} COMPLETE: {len(proposals)} proposals "
1637
+ f"({auto_count} auto, {auto_applied} applied, "
1638
+ f"{len(proposals) - auto_count} propose)")
1639
+ log("=" * 60)
1640
+
1641
+
1642
+ def _update_catchup_state():
1643
+ """Register successful run for catch-up."""
1644
+ try:
1645
+ import json as _json
1646
+ from pathlib import Path as _Path
1647
+
1648
+ _state_file = NEXO_HOME / "operations" / ".catchup-state.json"
1649
+ _state = _json.loads(_state_file.read_text()) if _state_file.exists() else {}
1650
+ _state["evolution"] = datetime.now().isoformat()
1651
+ _state_file.write_text(_json.dumps(_state, indent=2))
1652
+ except Exception:
1653
+ pass
1654
+
1655
+
1656
+ if __name__ == "__main__":
1657
+ try:
1658
+ run()
1659
+ _update_catchup_state()
1660
+ except Exception as e:
1661
+ log(f"FATAL: {e}")
1662
+ import traceback
1663
+ log(traceback.format_exc())
1664
+ sys.exit(1)