nexo-brain 5.3.5 → 5.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client_sync.py +18 -1
- package/src/db/_cron_runs.py +8 -3
- package/src/plugins/schedule.py +84 -6
- package/src/retroactive_learnings.py +9 -6
- package/src/script_registry.py +48 -20
- package/src/skills/run-nexo-audit-phase/guide.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill.json +59 -0
- package/src/skills/run-release-final-audit/script.py +82 -9
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.6",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
|
|
|
89
89
|
- when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
|
|
90
90
|
- NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
|
|
91
91
|
|
|
92
|
-
Version `5.3.5` keeps CLI version visibility honest right after `nexo update`: if the cached npm version lags behind the runtime you just installed, `nexo` / `nexo chat` now clamp `Latest` to the installed version and refresh the cache instead of showing a stale older release. Version `5.3.4` already cleaned up legacy core alias leakage and added the version-status banner. Version `5.3.3` closed the remaining packaged-runtime doctor mismatch: the built-in hourly backup helper is now inventoried as a core LaunchAgent, so clean installs no longer get a false unknown-LaunchAgent warning. Version `5.3.2` already hardened the runtime boundary by persisting which runtime scripts/hooks are core product artifacts, keeping `nexo scripts` from mixing those into the personal bucket, and migrating the legacy Claude Code heartbeat wrappers into managed core hooks.
|
|
92
|
+
Version `5.3.6` hardens the Claude Code bootstrap path and related runtime hygiene: managed client sync now writes the NEXO MCP server where current Claude Code actually reads it (`~/.claude.json`), script classification is stricter about core-vs-personal runtime artifacts, schedule status distinguishes genuinely running jobs from broken ones, and retroactive learnings stop opening keyword-only false positives outside their declared `applies_to` scope. Version `5.3.5` already keeps CLI version visibility honest right after `nexo update`: if the cached npm version lags behind the runtime you just installed, `nexo` / `nexo chat` now clamp `Latest` to the installed version and refresh the cache instead of showing a stale older release. Version `5.3.4` already cleaned up legacy core alias leakage and added the version-status banner. Version `5.3.3` closed the remaining packaged-runtime doctor mismatch: the built-in hourly backup helper is now inventoried as a core LaunchAgent, so clean installs no longer get a false unknown-LaunchAgent warning. Version `5.3.2` already hardened the runtime boundary by persisting which runtime scripts/hooks are core product artifacts, keeping `nexo scripts` from mixing those into the personal bucket, and migrating the legacy Claude Code heartbeat wrappers into managed core hooks.
|
|
93
93
|
|
|
94
94
|
Version `5.3.1` normalizes packaged npm installs so they behave like packaged npm installs: `nexo update` now keeps the runtime anchored to `~/.nexo`, refreshes packaged bootstrap/client artifacts after upgrade, avoids repo-only release-artifact drift in installed runtimes, and keeps personal scripts on the canonical packaged path.
|
|
95
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.6",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
package/src/client_sync.py
CHANGED
|
@@ -160,6 +160,11 @@ def _claude_code_settings_path(home: Path | None = None) -> Path:
|
|
|
160
160
|
return base / ".claude" / "settings.json"
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
def _claude_code_mcp_path(home: Path | None = None) -> Path:
|
|
164
|
+
base = home or _user_home()
|
|
165
|
+
return base / ".claude.json"
|
|
166
|
+
|
|
167
|
+
|
|
163
168
|
def _claude_desktop_config_path(home: Path | None = None) -> Path:
|
|
164
169
|
base = home or _user_home()
|
|
165
170
|
if sys.platform == "darwin":
|
|
@@ -627,10 +632,22 @@ def sync_claude_code(
|
|
|
627
632
|
python_path=python_path,
|
|
628
633
|
operator_name=operator_name,
|
|
629
634
|
)
|
|
635
|
+
home_path = Path(user_home).expanduser() if user_home else None
|
|
630
636
|
result = _sync_claude_code_settings(
|
|
631
|
-
_claude_code_settings_path(
|
|
637
|
+
_claude_code_settings_path(home_path),
|
|
632
638
|
server_config,
|
|
633
639
|
)
|
|
640
|
+
# Claude Code 2.1.x reads user-scoped MCP servers from ~/.claude.json.
|
|
641
|
+
# Keep settings.json in sync for hooks/runtime preferences, but also write
|
|
642
|
+
# the managed NEXO MCP server to the root user config so `claude mcp list`
|
|
643
|
+
# and interactive sessions see the same server.
|
|
644
|
+
mcp_result = _sync_json_client(
|
|
645
|
+
_claude_code_mcp_path(home_path),
|
|
646
|
+
server_config,
|
|
647
|
+
"claude_code",
|
|
648
|
+
)
|
|
649
|
+
result["mcp"] = mcp_result
|
|
650
|
+
result["mcp_path"] = mcp_result.get("path", "")
|
|
634
651
|
bootstrap_result = sync_client_bootstrap(
|
|
635
652
|
"claude_code",
|
|
636
653
|
nexo_home=nexo_home,
|
package/src/db/_cron_runs.py
CHANGED
|
@@ -56,14 +56,19 @@ def cron_runs_summary(hours: int = 24) -> list[dict]:
|
|
|
56
56
|
cron_id,
|
|
57
57
|
COUNT(*) as total_runs,
|
|
58
58
|
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) as succeeded,
|
|
59
|
-
SUM(CASE WHEN exit_code
|
|
59
|
+
SUM(CASE WHEN exit_code IS NOT NULL AND ended_at IS NOT NULL THEN 1 ELSE 0 END) as completed_runs,
|
|
60
|
+
SUM(CASE WHEN exit_code IS NOT NULL AND exit_code != 0 THEN 1 ELSE 0 END) as failed,
|
|
61
|
+
SUM(CASE WHEN exit_code IS NULL OR ended_at IS NULL THEN 1 ELSE 0 END) as open_runs,
|
|
60
62
|
ROUND(AVG(duration_secs), 1) as avg_duration,
|
|
61
63
|
MAX(started_at) as last_run,
|
|
62
64
|
(SELECT exit_code FROM cron_runs cr2
|
|
63
65
|
WHERE cr2.cron_id = cron_runs.cron_id
|
|
64
66
|
ORDER BY started_at DESC LIMIT 1) as last_exit_code,
|
|
65
|
-
(SELECT
|
|
66
|
-
WHERE cr3.cron_id = cron_runs.cron_id
|
|
67
|
+
(SELECT ended_at FROM cron_runs cr3
|
|
68
|
+
WHERE cr3.cron_id = cron_runs.cron_id
|
|
69
|
+
ORDER BY started_at DESC LIMIT 1) as last_ended_at,
|
|
70
|
+
(SELECT summary FROM cron_runs cr4
|
|
71
|
+
WHERE cr4.cron_id = cron_runs.cron_id AND cr4.summary != ''
|
|
67
72
|
ORDER BY started_at DESC LIMIT 1) as last_summary
|
|
68
73
|
FROM cron_runs
|
|
69
74
|
WHERE started_at >= datetime('now', ?)
|
package/src/plugins/schedule.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""NEXO Schedule — Cron execution history, status, and management tools."""
|
|
2
2
|
|
|
3
|
+
from datetime import datetime, timezone
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import platform
|
|
@@ -34,7 +35,13 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
|
34
35
|
schedule_meta = get_personal_script_schedule(cron_id) or {}
|
|
35
36
|
lines = [f"CRON RUNS — {cron_id} (last {hours}h): {len(runs)} executions"]
|
|
36
37
|
for r in runs:
|
|
37
|
-
status, detail = _run_status_marker(
|
|
38
|
+
status, detail = _run_status_marker(
|
|
39
|
+
r.get("exit_code"),
|
|
40
|
+
r.get("summary"),
|
|
41
|
+
schedule_meta=schedule_meta,
|
|
42
|
+
started_at=r.get("started_at"),
|
|
43
|
+
ended_at=r.get("ended_at"),
|
|
44
|
+
)
|
|
38
45
|
if schedule_meta.get("schedule_type") == "keep_alive" and r.get("exit_code") is None:
|
|
39
46
|
dur = "daemon active"
|
|
40
47
|
else:
|
|
@@ -53,11 +60,20 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
|
53
60
|
lines = [f"CRON STATUS (last {hours}h):"]
|
|
54
61
|
for s in summary:
|
|
55
62
|
schedule_meta = get_personal_script_schedule(s["cron_id"]) or {}
|
|
56
|
-
status, detail = _run_status_marker(
|
|
63
|
+
status, detail = _run_status_marker(
|
|
64
|
+
s.get("last_exit_code"),
|
|
65
|
+
s.get("last_summary"),
|
|
66
|
+
schedule_meta=schedule_meta,
|
|
67
|
+
started_at=s.get("last_run"),
|
|
68
|
+
ended_at=s.get("last_ended_at"),
|
|
69
|
+
)
|
|
57
70
|
if schedule_meta.get("schedule_type") == "keep_alive" and s.get("last_exit_code") is None:
|
|
58
71
|
rate = "daemon active"
|
|
59
72
|
else:
|
|
60
|
-
|
|
73
|
+
completed_runs = s.get("completed_runs")
|
|
74
|
+
if completed_runs is None:
|
|
75
|
+
completed_runs = s["total_runs"]
|
|
76
|
+
rate = f"{s['succeeded']}/{completed_runs}"
|
|
61
77
|
dur = f"{s['avg_duration']:.0f}s avg" if s.get("avg_duration") else ""
|
|
62
78
|
summary_txt = f" — {s['last_summary'][:80]}" if s.get("last_summary") else ""
|
|
63
79
|
suffix = f" [{detail}]" if detail else ""
|
|
@@ -87,16 +103,78 @@ def _summary_has_warning(summary: str = "") -> bool:
|
|
|
87
103
|
return any(token in lowered for token in warning_tokens)
|
|
88
104
|
|
|
89
105
|
|
|
90
|
-
def
|
|
106
|
+
def _now_utc() -> datetime:
|
|
107
|
+
return datetime.now(timezone.utc)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_db_timestamp(value: str | None) -> datetime | None:
|
|
111
|
+
if not value:
|
|
112
|
+
return None
|
|
113
|
+
text = str(value).strip()
|
|
114
|
+
if not text:
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
return datetime.strptime(text[:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
|
118
|
+
except ValueError:
|
|
119
|
+
pass
|
|
120
|
+
try:
|
|
121
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
122
|
+
except ValueError:
|
|
123
|
+
return None
|
|
124
|
+
if parsed.tzinfo is None:
|
|
125
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
126
|
+
return parsed.astimezone(timezone.utc)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _format_age(seconds: float) -> str:
|
|
130
|
+
if seconds < 60:
|
|
131
|
+
return "<1m"
|
|
132
|
+
if seconds < 3600:
|
|
133
|
+
return f"{int(round(seconds / 60))}m"
|
|
134
|
+
return f"{seconds / 3600:.1f}h"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _open_run_stale_after(schedule_meta: dict | None = None) -> int:
|
|
138
|
+
schedule_meta = schedule_meta or {}
|
|
139
|
+
if schedule_meta.get("schedule_type") == "interval":
|
|
140
|
+
try:
|
|
141
|
+
interval = int(schedule_meta.get("interval_seconds") or schedule_meta.get("schedule_value") or 0)
|
|
142
|
+
except (TypeError, ValueError):
|
|
143
|
+
interval = 0
|
|
144
|
+
if interval > 0:
|
|
145
|
+
return max(300, interval * 2)
|
|
146
|
+
if schedule_meta.get("schedule_type") == "calendar":
|
|
147
|
+
return 3600
|
|
148
|
+
return 1800
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _open_run_marker(started_at: str | None, *, schedule_meta: dict | None = None) -> tuple[str, str]:
|
|
152
|
+
started = _parse_db_timestamp(started_at)
|
|
153
|
+
if started is None:
|
|
154
|
+
return "⚠", "open run"
|
|
155
|
+
age_secs = max(0.0, (_now_utc() - started).total_seconds())
|
|
156
|
+
if age_secs <= _open_run_stale_after(schedule_meta):
|
|
157
|
+
return "⏳", f"running {_format_age(age_secs)}"
|
|
158
|
+
return "⚠", f"open run {_format_age(age_secs)}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_status_marker(
|
|
162
|
+
exit_code,
|
|
163
|
+
summary: str = "",
|
|
164
|
+
*,
|
|
165
|
+
schedule_meta: dict | None = None,
|
|
166
|
+
started_at: str | None = None,
|
|
167
|
+
ended_at: str | None = None,
|
|
168
|
+
) -> tuple[str, str]:
|
|
91
169
|
schedule_meta = schedule_meta or {}
|
|
92
170
|
if schedule_meta.get("schedule_type") == "keep_alive" and exit_code is None:
|
|
93
171
|
return "🟢", "keep_alive daemon active"
|
|
172
|
+
if exit_code is None:
|
|
173
|
+
return _open_run_marker(started_at, schedule_meta=schedule_meta)
|
|
94
174
|
if exit_code == 0 and _summary_has_warning(summary):
|
|
95
175
|
return "⚠", "exit 0 with warnings"
|
|
96
176
|
if exit_code == 0:
|
|
97
177
|
return "✅", "exit 0"
|
|
98
|
-
if exit_code is None:
|
|
99
|
-
return "❌", "missing exit code"
|
|
100
178
|
return "❌", f"exit {exit_code}"
|
|
101
179
|
|
|
102
180
|
|
|
@@ -29,7 +29,10 @@ Matching strategy:
|
|
|
29
29
|
with significant tokens from the decision's
|
|
30
30
|
decision + based_on + alternatives + context_ref. Score is
|
|
31
31
|
intersection_size / max(1, learning_token_count) clipped to 1.0.
|
|
32
|
-
|
|
32
|
+
Guardrail: if a learning defines `applies_to` and that anchor scores
|
|
33
|
+
below 0.3, auto-dismiss the match even if keyword overlap is high.
|
|
34
|
+
This blocks keyword-only false positives outside the learning's
|
|
35
|
+
actual blast radius.
|
|
33
36
|
Default match threshold: 0.4. Default cap: 5 matches per learning.
|
|
34
37
|
|
|
35
38
|
Anti-spam guards:
|
|
@@ -129,16 +132,16 @@ def _score_match(
|
|
|
129
132
|
keyword_hits = set()
|
|
130
133
|
keyword_score = 0.0
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
score = max(applies_to_score, keyword_score)
|
|
135
|
+
gated_by_applies_to = bool(learning_applies_to and applies_to_score < 0.3)
|
|
136
|
+
# When a learning explicitly scopes its blast radius via applies_to,
|
|
137
|
+
# keyword overlap alone is too noisy to justify a retroactive review.
|
|
138
|
+
score = applies_to_score if gated_by_applies_to else max(applies_to_score, keyword_score)
|
|
137
139
|
breakdown = {
|
|
138
140
|
"applies_to_score": round(applies_to_score, 3),
|
|
139
141
|
"applies_to_hits": sorted(applies_to_hits),
|
|
140
142
|
"keyword_score": round(keyword_score, 3),
|
|
141
143
|
"keyword_hits": sorted(keyword_hits),
|
|
144
|
+
"gated_by_applies_to": gated_by_applies_to,
|
|
142
145
|
}
|
|
143
146
|
return round(score, 3), breakdown
|
|
144
147
|
|
package/src/script_registry.py
CHANGED
|
@@ -93,6 +93,12 @@ SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
|
|
|
93
93
|
PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
|
|
94
94
|
SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
|
|
95
95
|
PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
|
|
96
|
+
_LEGACY_CORE_SCRIPT_ALIASES = {
|
|
97
|
+
"nexo-postcompact.sh": "post-compact.sh",
|
|
98
|
+
"nexo-memory-precompact.sh": "pre-compact.sh",
|
|
99
|
+
"nexo-memory-stop.sh": "session-stop.sh",
|
|
100
|
+
"nexo-session-briefing.sh": "session-start.sh",
|
|
101
|
+
}
|
|
96
102
|
|
|
97
103
|
|
|
98
104
|
def get_nexo_home() -> Path:
|
|
@@ -130,8 +136,33 @@ def _apply_legacy_personal_script_backfills() -> None:
|
|
|
130
136
|
wake_recovery.write_text("".join(head + lines[start:]))
|
|
131
137
|
|
|
132
138
|
|
|
139
|
+
def _add_runtime_artifact_names(names: set[str], artifact_path: Path) -> None:
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(artifact_path.read_text())
|
|
142
|
+
except Exception:
|
|
143
|
+
return
|
|
144
|
+
for key in ("script_names", "hook_names"):
|
|
145
|
+
for item in data.get(key, []):
|
|
146
|
+
if isinstance(item, str) and item.strip():
|
|
147
|
+
names.add(Path(item).name)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _add_filenames_from_dir(names: set[str], directory: Path, *, skip_if_scripts_dir: bool = False) -> None:
|
|
151
|
+
if not directory.is_dir():
|
|
152
|
+
return
|
|
153
|
+
if skip_if_scripts_dir:
|
|
154
|
+
try:
|
|
155
|
+
if directory.resolve() == get_scripts_dir().resolve():
|
|
156
|
+
return
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
for item in directory.iterdir():
|
|
160
|
+
if item.is_file() and not item.name.startswith("."):
|
|
161
|
+
names.add(item.name)
|
|
162
|
+
|
|
163
|
+
|
|
133
164
|
def load_core_script_names() -> set[str]:
|
|
134
|
-
"""Load
|
|
165
|
+
"""Load every core-managed runtime artifact name that must never be treated as personal."""
|
|
135
166
|
names: set[str] = set()
|
|
136
167
|
for manifest_path in [NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"]:
|
|
137
168
|
if manifest_path.exists():
|
|
@@ -144,25 +175,22 @@ def load_core_script_names() -> set[str]:
|
|
|
144
175
|
break
|
|
145
176
|
except Exception:
|
|
146
177
|
continue
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
names.add(item.name)
|
|
164
|
-
except Exception:
|
|
165
|
-
pass
|
|
178
|
+
|
|
179
|
+
for artifact_path in (
|
|
180
|
+
NEXO_HOME / "config" / "runtime-core-artifacts.json",
|
|
181
|
+
NEXO_CODE / "config" / "runtime-core-artifacts.json",
|
|
182
|
+
NEXO_CODE.parent / "config" / "runtime-core-artifacts.json",
|
|
183
|
+
):
|
|
184
|
+
if artifact_path.exists():
|
|
185
|
+
_add_runtime_artifact_names(names, artifact_path)
|
|
186
|
+
|
|
187
|
+
_add_filenames_from_dir(names, NEXO_HOME / "hooks")
|
|
188
|
+
_add_filenames_from_dir(names, NEXO_CODE / "hooks")
|
|
189
|
+
_add_filenames_from_dir(names, NEXO_CODE / "scripts", skip_if_scripts_dir=True)
|
|
190
|
+
|
|
191
|
+
for legacy_name, canonical_name in _LEGACY_CORE_SCRIPT_ALIASES.items():
|
|
192
|
+
if canonical_name in names:
|
|
193
|
+
names.add(legacy_name)
|
|
166
194
|
names.update(_LEGACY_CORE_RUNTIME_FILES)
|
|
167
195
|
return names
|
|
168
196
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Run NEXO Audit Phase
|
|
2
|
+
|
|
3
|
+
Usa esta skill cuando haya que ejecutar una fase de auditoria de NEXO y el cuello de botella sea decidir el alcance de `evolution_apply` y arrancar una tanda de items con disciplina empirica.
|
|
4
|
+
|
|
5
|
+
## Pasos
|
|
6
|
+
1. Abre `goal + workflow + task` y fija el terreno real antes de interpretar el informe:
|
|
7
|
+
- repo/runtime activo
|
|
8
|
+
- DB real
|
|
9
|
+
- mecanismo de update
|
|
10
|
+
- tests y estado git
|
|
11
|
+
2. Fija la regla de autonomia antes de empezar:
|
|
12
|
+
- Francisco no quiere checkpoints uno-a-uno para trabajo mecanico
|
|
13
|
+
- NEXO hace branches, PRs, merge y reporta despues con evidencia
|
|
14
|
+
- solo un blast radius arquitectonico enorme merece checkpoint
|
|
15
|
+
3. Trata `evolution_apply` como una decision tecnica de implementacion, no como permiso humano:
|
|
16
|
+
- el camino de apply ya existe via `evolution_log` + `_apply_accepted_proposals`
|
|
17
|
+
- el sandbox/snapshot/rollback protege la materializacion del cambio aceptado
|
|
18
|
+
- no dupliques ese mecanismo en deep sleep ni en el runner de auditoria
|
|
19
|
+
4. Lanza la verificacion empirica de todos los items en paralelo:
|
|
20
|
+
- `grep + read` del codigo
|
|
21
|
+
- SQL/schema real
|
|
22
|
+
- AST/tests/imports/logs cuando aplique
|
|
23
|
+
- asume FP hasta que la evidencia lo contradiga
|
|
24
|
+
5. Clasifica cada item:
|
|
25
|
+
- `real_gap`
|
|
26
|
+
- `casi_fp`
|
|
27
|
+
- `fp`
|
|
28
|
+
6. Ordena solo los `real_gap` por riesgo/blast radius y ejecutalos con worktree aislado si tocan core.
|
|
29
|
+
7. Por cada `real_gap`:
|
|
30
|
+
- `guard_check`
|
|
31
|
+
- `track`
|
|
32
|
+
- branch propia
|
|
33
|
+
- implementacion minima
|
|
34
|
+
- tests adyacentes
|
|
35
|
+
- PR + auto-merge squash
|
|
36
|
+
- seguir al siguiente sin esperar CI salvo bloqueo real
|
|
37
|
+
8. Para `fp` o `casi_fp`, captura learning/patron reusable en vez de reimplementar.
|
|
38
|
+
9. Cierra la fase con evidencia real: PRs, tests, merge status y resultados de verificacion.
|
|
39
|
+
|
|
40
|
+
## Gotchas
|
|
41
|
+
- Learning #198: no confundas "como trabaja NEXO" con "que puede aplicar evolution_apply". Lo primero ya esta resuelto: autonomia total.
|
|
42
|
+
- `apply_findings.py` ya stagea `code_change` en `evolution_log`; `nexo-evolution-run.py` ya consume `accepted` con sandbox/snapshot/rollback. Si el item pide eso, primero verifica si ya existe.
|
|
43
|
+
- En Fase 1+2 la auditoria automatica sobreestimo ~70% de gaps. Si no hay evidencia dura, no abras codigo.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "SK-RUN-NEXO-AUDIT-PHASE",
|
|
3
|
+
"name": "Run NEXO Audit Phase",
|
|
4
|
+
"description": "Workflow para ejecutar una fase de auditoria de NEXO con autonomia total y verificacion empirica. Cubre el patron repetido de decidir el scope de evolution_apply y arrancar una tanda de items sin checkpoints manuales.",
|
|
5
|
+
"level": "published",
|
|
6
|
+
"mode": "guide",
|
|
7
|
+
"source_kind": "core",
|
|
8
|
+
"execution_level": "none",
|
|
9
|
+
"approval_required": false,
|
|
10
|
+
"tags": [
|
|
11
|
+
"nexo",
|
|
12
|
+
"audit",
|
|
13
|
+
"evolution",
|
|
14
|
+
"sandbox",
|
|
15
|
+
"verification",
|
|
16
|
+
"core"
|
|
17
|
+
],
|
|
18
|
+
"trigger_patterns": [
|
|
19
|
+
"evolution_apply sandbox scope",
|
|
20
|
+
"fase 2 item 1",
|
|
21
|
+
"arrancar verificacion empirica",
|
|
22
|
+
"ejecutar fase de auditoria nexo",
|
|
23
|
+
"audit phase nexo",
|
|
24
|
+
"7 items audit"
|
|
25
|
+
],
|
|
26
|
+
"steps": [
|
|
27
|
+
"Abrir goal, workflow y task antes de tocar la auditoria; resolver repo/runtime real, DB real, tests y estado git antes de interpretar el informe.",
|
|
28
|
+
"Fijar la regla de autonomia: NEXO hace branches, PRs, merge y progreso periodico; no pedir checkpoints uno-a-uno a Francisco para trabajo mecanico.",
|
|
29
|
+
"Para `evolution_apply`, separar dos planos: el sandbox es para materializar/aplicar cambios aceptados con snapshot/rollback; no es un freno para el modo de trabajo autonomo del agente.",
|
|
30
|
+
"Lanzar verificacion empirica de TODOS los items en paralelo: grep+read, SQL/schema real, AST/tests, logs. Partir de la hipotesis de FP hasta tener evidencia.",
|
|
31
|
+
"Clasificar cada item en `real_gap`, `casi_fp` o `fp` y ordenar solo los reales por blast radius/riesgo.",
|
|
32
|
+
"Si hay que tocar core, usar worktree aislado; por item real: guard_check -> track -> branch -> implement -> tests adyacentes -> PR -> auto-merge squash.",
|
|
33
|
+
"Para items FP o casi-FP, capturar learning o ajustar el patron reusable en vez de reimplementar.",
|
|
34
|
+
"Cerrar con evidencia de PRs, tests y merge status reales; no usar diarios o workflow text como sustituto."
|
|
35
|
+
],
|
|
36
|
+
"gotchas": [
|
|
37
|
+
"No pedir aprobacion manual a Francisco para cada PR: learning #198 confirma autonomia total con transparencia y reportes periodicos.",
|
|
38
|
+
"No duplicar el sandbox de evolution en otros sitios: el apply de cambios aceptados ya reutiliza evolution_log + sandbox/snapshot/rollback.",
|
|
39
|
+
"El ratio historico de FPs en esta clase de auditoria es alto; si no hay evidencia concreta, el item no se toca."
|
|
40
|
+
],
|
|
41
|
+
"params_schema": {
|
|
42
|
+
"audit_file": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"required": true
|
|
45
|
+
},
|
|
46
|
+
"phase_label": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"required": false,
|
|
49
|
+
"default": ""
|
|
50
|
+
},
|
|
51
|
+
"repo_path": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"required": false,
|
|
54
|
+
"default": ""
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"command_template": {},
|
|
58
|
+
"stable_after_uses": 5
|
|
59
|
+
}
|
|
@@ -9,15 +9,74 @@ import sys
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def _is_repo_root(candidate: Path) -> bool:
|
|
13
|
+
return (
|
|
14
|
+
(candidate / "package.json").is_file()
|
|
15
|
+
and (candidate / "release-contracts").is_dir()
|
|
16
|
+
and (candidate / "scripts" / "verify_release_readiness.py").is_file()
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_candidate(raw: str | Path) -> Path:
|
|
21
|
+
candidate = Path(raw).expanduser().resolve()
|
|
22
|
+
if candidate.is_file():
|
|
23
|
+
candidate = candidate.parent
|
|
24
|
+
if candidate.name == "src" and (candidate / "server.py").is_file():
|
|
25
|
+
candidate = candidate.parent
|
|
26
|
+
return candidate
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_repo_root_from_atlas() -> Path | None:
|
|
30
|
+
homes = []
|
|
31
|
+
env_home = os.environ.get("NEXO_HOME", "").strip()
|
|
32
|
+
if env_home:
|
|
33
|
+
homes.append(Path(env_home).expanduser())
|
|
34
|
+
homes.extend((Path.home() / ".nexo", Path.home() / "claude"))
|
|
35
|
+
|
|
36
|
+
seen = set()
|
|
37
|
+
for home in homes:
|
|
38
|
+
key = str(home)
|
|
39
|
+
if key in seen:
|
|
40
|
+
continue
|
|
41
|
+
seen.add(key)
|
|
42
|
+
atlas_path = home / "brain" / "project-atlas.json"
|
|
43
|
+
if not atlas_path.is_file():
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
payload = json.loads(atlas_path.read_text(encoding="utf-8"))
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
continue
|
|
49
|
+
nexo = payload.get("nexo") if isinstance(payload, dict) else None
|
|
50
|
+
locations = nexo.get("locations") if isinstance(nexo, dict) else None
|
|
51
|
+
source = locations.get("mcp_server", "") if isinstance(locations, dict) else ""
|
|
52
|
+
if not isinstance(source, str) or not source.strip():
|
|
53
|
+
continue
|
|
54
|
+
candidate = _normalize_candidate(source)
|
|
55
|
+
if _is_repo_root(candidate):
|
|
56
|
+
return candidate
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
12
60
|
def _resolve_repo_root() -> Path:
|
|
13
61
|
env_code = os.environ.get("NEXO_CODE", "").strip()
|
|
14
62
|
if env_code:
|
|
15
|
-
candidate =
|
|
16
|
-
if candidate
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
63
|
+
candidate = _normalize_candidate(env_code)
|
|
64
|
+
if _is_repo_root(candidate):
|
|
65
|
+
return candidate
|
|
66
|
+
|
|
67
|
+
cwd = Path.cwd().resolve()
|
|
68
|
+
for candidate in (cwd, *cwd.parents):
|
|
69
|
+
if _is_repo_root(candidate):
|
|
70
|
+
return candidate
|
|
71
|
+
|
|
72
|
+
atlas_repo = _resolve_repo_root_from_atlas()
|
|
73
|
+
if atlas_repo is not None:
|
|
74
|
+
return atlas_repo
|
|
75
|
+
|
|
76
|
+
script_root = _normalize_candidate(Path(__file__).resolve().parents[3])
|
|
77
|
+
if _is_repo_root(script_root):
|
|
78
|
+
return script_root
|
|
79
|
+
return script_root
|
|
21
80
|
|
|
22
81
|
|
|
23
82
|
ROOT = _resolve_repo_root()
|
|
@@ -77,7 +136,7 @@ def _env(nexo_home: str) -> dict[str, str]:
|
|
|
77
136
|
env["PYTHONPATH"] = (
|
|
78
137
|
f"{src_path}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else src_path
|
|
79
138
|
)
|
|
80
|
-
env
|
|
139
|
+
env["NEXO_CODE"] = src_path
|
|
81
140
|
if nexo_home.strip():
|
|
82
141
|
env["NEXO_HOME"] = str(Path(nexo_home).expanduser())
|
|
83
142
|
return env
|
|
@@ -128,13 +187,27 @@ def _run(cmd: list[str], *, env: dict[str, str]) -> None:
|
|
|
128
187
|
raise SystemExit(result.returncode)
|
|
129
188
|
|
|
130
189
|
|
|
190
|
+
def _looks_like_nexo_home(raw: str) -> bool:
|
|
191
|
+
if not raw.strip():
|
|
192
|
+
return False
|
|
193
|
+
candidate = Path(raw).expanduser()
|
|
194
|
+
return (candidate / "skills-runtime").is_dir() or (candidate / "operations" / "tool-logs").is_dir()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_optional_paths(argv: list[str]) -> tuple[str, str]:
|
|
198
|
+
if len(argv) > 6:
|
|
199
|
+
return argv[5], argv[6]
|
|
200
|
+
if len(argv) > 5:
|
|
201
|
+
return ("", argv[5]) if _looks_like_nexo_home(argv[5]) else (argv[5], "")
|
|
202
|
+
return "", ""
|
|
203
|
+
|
|
204
|
+
|
|
131
205
|
def main() -> int:
|
|
132
206
|
contract_arg = sys.argv[1] if len(sys.argv) > 1 else "auto"
|
|
133
207
|
require_contract_complete = _parse_bool(sys.argv[2] if len(sys.argv) > 2 else "true", True)
|
|
134
208
|
include_smoke = _parse_bool(sys.argv[3] if len(sys.argv) > 3 else "true", True)
|
|
135
209
|
ci = _parse_bool(sys.argv[4] if len(sys.argv) > 4 else "false", False)
|
|
136
|
-
website_root =
|
|
137
|
-
nexo_home = sys.argv[6] if len(sys.argv) > 6 else ""
|
|
210
|
+
website_root, nexo_home = _parse_optional_paths(sys.argv)
|
|
138
211
|
|
|
139
212
|
version = _package_version()
|
|
140
213
|
contract_path = _resolve_contract(version, contract_arg)
|