nexo-brain 5.3.20 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- 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)
|