nexo-brain 7.1.10 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/hooks/hooks.json +12 -0
- package/package.json +3 -2
- package/src/auto_update.py +350 -8
- package/src/cli.py +229 -0
- package/src/client_sync.py +3 -0
- package/src/core_schedule_controls.py +66 -0
- package/src/crons/manifest.json +12 -0
- package/src/doctor/providers/boot.py +190 -0
- package/src/evolution_cycle.py +4 -0
- package/src/guardian_runtime_config.py +98 -0
- package/src/hook_guardrails.py +163 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/manifest.json +1 -0
- package/src/hooks/post_tool_use.py +10 -1
- package/src/hooks/pre_tool_use.py +161 -0
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/presets/entities_universal.json +74 -0
- package/src/public_contribution.py +51 -5
- package/src/r34_identity_coherence.py +31 -8
- package/src/script_registry.py +14 -6
- package/src/scripts/guardian_metrics_aggregate.py +177 -0
- package/src/scripts/nexo-watchdog.sh +7 -1
- package/src/scripts/prune_runtime_backups.py +376 -0
- package/src/tools_sessions.py +33 -3
- package/tool-enforcement-map.json +3714 -0
package/src/cli.py
CHANGED
|
@@ -1168,6 +1168,199 @@ def _recover(args):
|
|
|
1168
1168
|
return _recover_cli_main(argv)
|
|
1169
1169
|
|
|
1170
1170
|
|
|
1171
|
+
def _rollback_f06(args):
|
|
1172
|
+
"""Revert the F0.6 layout migration using ``~/.nexo-pre-f06-snapshot``.
|
|
1173
|
+
|
|
1174
|
+
Safe two-stage swap: the current ``~/.nexo`` tree is renamed to a dated
|
|
1175
|
+
backup (``~/.nexo-rollback-backup-YYYYMMDDHHMMSS``) BEFORE the snapshot is
|
|
1176
|
+
restored in its place, so the operation never destroys state if it is
|
|
1177
|
+
interrupted mid-way. LaunchAgents are booted out before the swap and
|
|
1178
|
+
reloaded after (skip via ``--keep-agents-running``).
|
|
1179
|
+
"""
|
|
1180
|
+
import json as _json
|
|
1181
|
+
import shutil as _shutil
|
|
1182
|
+
import subprocess as _subprocess
|
|
1183
|
+
from datetime import datetime as _datetime
|
|
1184
|
+
from pathlib import Path as _Path
|
|
1185
|
+
|
|
1186
|
+
nexo_home = _Path(os.environ.get("NEXO_HOME", str(_Path.home() / ".nexo")))
|
|
1187
|
+
snapshot = _Path(str(nexo_home) + "-pre-f06-snapshot")
|
|
1188
|
+
now_stamp = _datetime.now().strftime("%Y%m%d%H%M%S")
|
|
1189
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
1190
|
+
emit_json = bool(getattr(args, "json", False))
|
|
1191
|
+
assume_yes = bool(getattr(args, "yes", False))
|
|
1192
|
+
keep_agents = bool(getattr(args, "keep_agents_running", False))
|
|
1193
|
+
|
|
1194
|
+
report: dict[str, object] = {
|
|
1195
|
+
"nexo_home": str(nexo_home),
|
|
1196
|
+
"snapshot": str(snapshot),
|
|
1197
|
+
"snapshot_exists": snapshot.exists(),
|
|
1198
|
+
"snapshot_is_dir": snapshot.is_dir() if snapshot.exists() else False,
|
|
1199
|
+
"dry_run": dry_run,
|
|
1200
|
+
"steps": [],
|
|
1201
|
+
"status": "planned",
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if not snapshot.exists() or not snapshot.is_dir():
|
|
1205
|
+
report["status"] = "error_no_snapshot"
|
|
1206
|
+
report["error"] = f"Pre-F0.6 snapshot not found at {snapshot}"
|
|
1207
|
+
if emit_json:
|
|
1208
|
+
print(_json.dumps(report, indent=2))
|
|
1209
|
+
else:
|
|
1210
|
+
print(f"ERROR: no pre-F0.6 snapshot at {snapshot}", file=sys.stderr)
|
|
1211
|
+
print(" Rollback is only available immediately after a migration.", file=sys.stderr)
|
|
1212
|
+
return 1
|
|
1213
|
+
|
|
1214
|
+
if nexo_home.exists():
|
|
1215
|
+
backup_target = _Path(str(nexo_home) + f"-rollback-backup-{now_stamp}")
|
|
1216
|
+
# Avoid collision if the operator retries in the same second.
|
|
1217
|
+
collision_suffix = 0
|
|
1218
|
+
while backup_target.exists():
|
|
1219
|
+
collision_suffix += 1
|
|
1220
|
+
backup_target = _Path(str(nexo_home) + f"-rollback-backup-{now_stamp}-{collision_suffix}")
|
|
1221
|
+
else:
|
|
1222
|
+
backup_target = None
|
|
1223
|
+
|
|
1224
|
+
agents_to_restart: list[_Path] = []
|
|
1225
|
+
if not keep_agents:
|
|
1226
|
+
agents_dir = _Path.home() / "Library" / "LaunchAgents"
|
|
1227
|
+
if agents_dir.is_dir():
|
|
1228
|
+
agents_to_restart = sorted(agents_dir.glob("com.nexo.*.plist"))
|
|
1229
|
+
|
|
1230
|
+
plan_steps: list[dict] = []
|
|
1231
|
+
if agents_to_restart:
|
|
1232
|
+
plan_steps.append({
|
|
1233
|
+
"step": "bootout_launchagents",
|
|
1234
|
+
"count": len(agents_to_restart),
|
|
1235
|
+
"samples": [p.name for p in agents_to_restart[:5]],
|
|
1236
|
+
})
|
|
1237
|
+
if backup_target is not None:
|
|
1238
|
+
plan_steps.append({
|
|
1239
|
+
"step": "move_current_nexo_home_to_backup",
|
|
1240
|
+
"from": str(nexo_home),
|
|
1241
|
+
"to": str(backup_target),
|
|
1242
|
+
})
|
|
1243
|
+
plan_steps.append({
|
|
1244
|
+
"step": "move_snapshot_to_nexo_home",
|
|
1245
|
+
"from": str(snapshot),
|
|
1246
|
+
"to": str(nexo_home),
|
|
1247
|
+
})
|
|
1248
|
+
if agents_to_restart:
|
|
1249
|
+
plan_steps.append({
|
|
1250
|
+
"step": "reload_launchagents",
|
|
1251
|
+
"count": len(agents_to_restart),
|
|
1252
|
+
})
|
|
1253
|
+
report["steps"] = plan_steps
|
|
1254
|
+
|
|
1255
|
+
if dry_run:
|
|
1256
|
+
report["status"] = "dry_run"
|
|
1257
|
+
if emit_json:
|
|
1258
|
+
print(_json.dumps(report, indent=2))
|
|
1259
|
+
else:
|
|
1260
|
+
print(f"nexo rollback f06 (DRY-RUN)")
|
|
1261
|
+
print(f" NEXO_HOME: {nexo_home}")
|
|
1262
|
+
print(f" snapshot: {snapshot}")
|
|
1263
|
+
if backup_target is not None:
|
|
1264
|
+
print(f" backup → {backup_target}")
|
|
1265
|
+
if agents_to_restart:
|
|
1266
|
+
print(f" LaunchAgents to restart: {len(agents_to_restart)}")
|
|
1267
|
+
print(" (no filesystem or launchctl changes were made)")
|
|
1268
|
+
return 0
|
|
1269
|
+
|
|
1270
|
+
if not assume_yes:
|
|
1271
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
1272
|
+
print("ERROR: interactive confirmation required. Pass --yes to proceed non-interactively.", file=sys.stderr)
|
|
1273
|
+
return 1
|
|
1274
|
+
print("This will replace the current NEXO_HOME with the pre-F0.6 snapshot.")
|
|
1275
|
+
print(f" Current ~/.nexo → {backup_target if backup_target else '(nothing to back up, current missing)'}")
|
|
1276
|
+
print(f" Restored from snapshot → {nexo_home}")
|
|
1277
|
+
if agents_to_restart:
|
|
1278
|
+
print(f" LaunchAgents affected: {len(agents_to_restart)}")
|
|
1279
|
+
answer = input("Type 'ROLLBACK' to proceed: ").strip()
|
|
1280
|
+
if answer != "ROLLBACK":
|
|
1281
|
+
print("Aborted — exact token not typed.")
|
|
1282
|
+
return 1
|
|
1283
|
+
|
|
1284
|
+
def _run_launchctl(cmd: list[str]) -> tuple[int, str]:
|
|
1285
|
+
try:
|
|
1286
|
+
proc = _subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
1287
|
+
return proc.returncode, (proc.stderr or "").strip()
|
|
1288
|
+
except _subprocess.TimeoutExpired as exc:
|
|
1289
|
+
return 124, f"timeout: {exc}"
|
|
1290
|
+
except FileNotFoundError:
|
|
1291
|
+
return 127, "launchctl not found"
|
|
1292
|
+
except Exception as exc: # noqa: BLE001 — launchctl errors are varied
|
|
1293
|
+
return 1, str(exc)
|
|
1294
|
+
|
|
1295
|
+
executed: list[dict] = []
|
|
1296
|
+
|
|
1297
|
+
if agents_to_restart and not keep_agents:
|
|
1298
|
+
for plist in agents_to_restart:
|
|
1299
|
+
rc, err = _run_launchctl(["launchctl", "unload", str(plist)])
|
|
1300
|
+
executed.append({"op": "unload", "plist": str(plist), "rc": rc, "err": err})
|
|
1301
|
+
|
|
1302
|
+
if backup_target is not None:
|
|
1303
|
+
try:
|
|
1304
|
+
nexo_home.rename(backup_target)
|
|
1305
|
+
executed.append({"op": "rename_home", "to": str(backup_target), "rc": 0})
|
|
1306
|
+
except OSError as exc:
|
|
1307
|
+
executed.append({"op": "rename_home", "to": str(backup_target), "rc": 1, "err": str(exc)})
|
|
1308
|
+
report["status"] = "error_rename_home"
|
|
1309
|
+
report["executed"] = executed
|
|
1310
|
+
if emit_json:
|
|
1311
|
+
print(_json.dumps(report, indent=2))
|
|
1312
|
+
else:
|
|
1313
|
+
print(f"ERROR: failed to move {nexo_home} → {backup_target}: {exc}", file=sys.stderr)
|
|
1314
|
+
print(" No changes to snapshot. Current NEXO_HOME is intact.", file=sys.stderr)
|
|
1315
|
+
return 1
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
snapshot.rename(nexo_home)
|
|
1319
|
+
executed.append({"op": "rename_snapshot", "to": str(nexo_home), "rc": 0})
|
|
1320
|
+
except OSError as exc:
|
|
1321
|
+
executed.append({"op": "rename_snapshot", "to": str(nexo_home), "rc": 1, "err": str(exc)})
|
|
1322
|
+
# Best-effort rollback of the backup rename so the user isn't left without NEXO_HOME.
|
|
1323
|
+
if backup_target is not None and backup_target.exists() and not nexo_home.exists():
|
|
1324
|
+
try:
|
|
1325
|
+
backup_target.rename(nexo_home)
|
|
1326
|
+
executed.append({"op": "rollback_rename_home", "rc": 0})
|
|
1327
|
+
except OSError as rexc:
|
|
1328
|
+
executed.append({"op": "rollback_rename_home", "rc": 1, "err": str(rexc)})
|
|
1329
|
+
report["status"] = "error_rename_snapshot"
|
|
1330
|
+
report["executed"] = executed
|
|
1331
|
+
if emit_json:
|
|
1332
|
+
print(_json.dumps(report, indent=2))
|
|
1333
|
+
else:
|
|
1334
|
+
print(f"ERROR: failed to move snapshot → NEXO_HOME: {exc}", file=sys.stderr)
|
|
1335
|
+
return 1
|
|
1336
|
+
|
|
1337
|
+
if agents_to_restart and not keep_agents:
|
|
1338
|
+
for plist in agents_to_restart:
|
|
1339
|
+
if not plist.exists():
|
|
1340
|
+
# The snapshot may not have the same plist set; skip silently.
|
|
1341
|
+
executed.append({"op": "load_skip_missing", "plist": str(plist), "rc": 0})
|
|
1342
|
+
continue
|
|
1343
|
+
rc, err = _run_launchctl(["launchctl", "load", str(plist)])
|
|
1344
|
+
executed.append({"op": "load", "plist": str(plist), "rc": rc, "err": err})
|
|
1345
|
+
|
|
1346
|
+
report["status"] = "done"
|
|
1347
|
+
report["executed"] = executed
|
|
1348
|
+
if backup_target is not None:
|
|
1349
|
+
report["backup_target"] = str(backup_target)
|
|
1350
|
+
|
|
1351
|
+
if emit_json:
|
|
1352
|
+
print(_json.dumps(report, indent=2))
|
|
1353
|
+
else:
|
|
1354
|
+
print("nexo rollback f06: done")
|
|
1355
|
+
print(f" restored: {nexo_home}")
|
|
1356
|
+
if backup_target is not None:
|
|
1357
|
+
print(f" prior home saved at: {backup_target}")
|
|
1358
|
+
print(f" review and rm -rf {backup_target} when you are sure.")
|
|
1359
|
+
if agents_to_restart:
|
|
1360
|
+
print(f" LaunchAgents reloaded: {len(agents_to_restart)}")
|
|
1361
|
+
return 0
|
|
1362
|
+
|
|
1363
|
+
|
|
1171
1364
|
def _update(args):
|
|
1172
1365
|
"""Update the installed runtime.
|
|
1173
1366
|
|
|
@@ -2699,6 +2892,37 @@ def main():
|
|
|
2699
2892
|
help="Skip the interactive confirmation prompt")
|
|
2700
2893
|
recover_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2701
2894
|
|
|
2895
|
+
# -- rollback --
|
|
2896
|
+
rollback_parser = sub.add_parser(
|
|
2897
|
+
"rollback",
|
|
2898
|
+
help="Reverse a structural migration using a pre-change snapshot",
|
|
2899
|
+
)
|
|
2900
|
+
rollback_sub = rollback_parser.add_subparsers(dest="rollback_command")
|
|
2901
|
+
rollback_f06_p = rollback_sub.add_parser(
|
|
2902
|
+
"f06",
|
|
2903
|
+
help="Revert the F0.6 layout migration using ~/.nexo-pre-f06-snapshot",
|
|
2904
|
+
)
|
|
2905
|
+
rollback_f06_p.add_argument(
|
|
2906
|
+
"--dry-run",
|
|
2907
|
+
action="store_true",
|
|
2908
|
+
help="Show what would happen, do not mutate filesystem or LaunchAgents.",
|
|
2909
|
+
)
|
|
2910
|
+
rollback_f06_p.add_argument(
|
|
2911
|
+
"--yes",
|
|
2912
|
+
action="store_true",
|
|
2913
|
+
help="Skip the interactive confirmation prompt.",
|
|
2914
|
+
)
|
|
2915
|
+
rollback_f06_p.add_argument(
|
|
2916
|
+
"--json",
|
|
2917
|
+
action="store_true",
|
|
2918
|
+
help="Emit machine-readable JSON instead of text output.",
|
|
2919
|
+
)
|
|
2920
|
+
rollback_f06_p.add_argument(
|
|
2921
|
+
"--keep-agents-running",
|
|
2922
|
+
action="store_true",
|
|
2923
|
+
help="Do not bootout / reload LaunchAgents. Advanced use; leaves stale services.",
|
|
2924
|
+
)
|
|
2925
|
+
|
|
2702
2926
|
# -- clients --
|
|
2703
2927
|
clients_parser = sub.add_parser("clients", help="Shared client config management")
|
|
2704
2928
|
clients_sub = clients_parser.add_subparsers(dest="clients_command")
|
|
@@ -2998,6 +3222,11 @@ def main():
|
|
|
2998
3222
|
return _update(args)
|
|
2999
3223
|
elif args.command == "recover":
|
|
3000
3224
|
return _recover(args)
|
|
3225
|
+
elif args.command == "rollback":
|
|
3226
|
+
if args.rollback_command == "f06":
|
|
3227
|
+
return _rollback_f06(args)
|
|
3228
|
+
rollback_parser.print_help()
|
|
3229
|
+
return 0
|
|
3001
3230
|
elif args.command == "clients":
|
|
3002
3231
|
if args.clients_command == "sync":
|
|
3003
3232
|
return _clients_sync(args)
|
package/src/client_sync.py
CHANGED
|
@@ -87,6 +87,9 @@ HOOK_TIMEOUTS_BY_EVENT = {
|
|
|
87
87
|
"PreCompact": 15,
|
|
88
88
|
"PostCompact": 15,
|
|
89
89
|
"UserPromptSubmit": 5,
|
|
90
|
+
# PreToolUse is synchronous on every tool call — keep low. 8s gives room
|
|
91
|
+
# for DB lookups under load without stalling routine Edit/Write/Bash.
|
|
92
|
+
"PreToolUse": 8,
|
|
90
93
|
"PostToolUse": 20,
|
|
91
94
|
"Notification": 3,
|
|
92
95
|
"SubagentStop": 10,
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import platform
|
|
8
|
+
from datetime import datetime, timezone
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -209,6 +210,51 @@ def _save_core_schedule_overrides(overrides: dict[str, dict[str, Any]]) -> Path:
|
|
|
209
210
|
return path
|
|
210
211
|
|
|
211
212
|
|
|
213
|
+
def _audit_log_path() -> Path:
|
|
214
|
+
home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
215
|
+
# Prefer the F0.6 runtime/logs location with a legacy fallback so audit
|
|
216
|
+
# entries remain contiguous across installs that have not yet migrated.
|
|
217
|
+
new = home / "runtime" / "logs" / "core-schedule-overrides.log"
|
|
218
|
+
legacy = home / "logs" / "core-schedule-overrides.log"
|
|
219
|
+
if new.parent.is_dir() or not legacy.parent.is_dir():
|
|
220
|
+
return new
|
|
221
|
+
return legacy
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _append_override_audit(
|
|
225
|
+
*,
|
|
226
|
+
name: str,
|
|
227
|
+
action: str,
|
|
228
|
+
previous: dict[str, Any],
|
|
229
|
+
current: dict[str, Any],
|
|
230
|
+
warning: str,
|
|
231
|
+
actor: str,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Append a single-line JSON audit record for a schedule override change.
|
|
234
|
+
|
|
235
|
+
Writes to ``~/.nexo/runtime/logs/core-schedule-overrides.log`` (or the
|
|
236
|
+
legacy location on pre-F0.6 installs). Best-effort only: a failed log
|
|
237
|
+
write never blocks the override itself.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
log_path = _audit_log_path()
|
|
241
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
record = {
|
|
243
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
244
|
+
"name": name,
|
|
245
|
+
"action": action,
|
|
246
|
+
"previous": previous,
|
|
247
|
+
"current": current,
|
|
248
|
+
"warning": warning or "",
|
|
249
|
+
"actor": actor or "cli",
|
|
250
|
+
}
|
|
251
|
+
with log_path.open("a", encoding="utf-8") as fh:
|
|
252
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
253
|
+
except Exception:
|
|
254
|
+
# Audit logging is best-effort — never fail the operator action.
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
|
|
212
258
|
def _apply_calendar_override(base_cron: dict[str, Any], start_hour: str) -> dict[str, Any]:
|
|
213
259
|
parsed = _parse_daily_at(start_hour)
|
|
214
260
|
schedule = base_cron.get("schedule")
|
|
@@ -417,6 +463,7 @@ def set_core_schedule(
|
|
|
417
463
|
interval_seconds: int | None = None,
|
|
418
464
|
daily_at: str | None = None,
|
|
419
465
|
clear: bool = False,
|
|
466
|
+
actor: str = "cli",
|
|
420
467
|
) -> dict[str, Any]:
|
|
421
468
|
clean_name = _normalize_name(name)
|
|
422
469
|
if clean_name in _TOGGLEABLE_AUTOMATIONS:
|
|
@@ -437,6 +484,7 @@ def set_core_schedule(
|
|
|
437
484
|
}
|
|
438
485
|
|
|
439
486
|
overrides = load_core_schedule_overrides()
|
|
487
|
+
previous_snapshot = dict(overrides.get(clean_name) or {})
|
|
440
488
|
changed = False
|
|
441
489
|
warning = ""
|
|
442
490
|
if clear:
|
|
@@ -482,6 +530,24 @@ def set_core_schedule(
|
|
|
482
530
|
}
|
|
483
531
|
|
|
484
532
|
config_path = _save_core_schedule_overrides(overrides)
|
|
533
|
+
|
|
534
|
+
if changed:
|
|
535
|
+
current_snapshot = dict(overrides.get(clean_name) or {})
|
|
536
|
+
if clear:
|
|
537
|
+
audit_action = "clear"
|
|
538
|
+
elif not previous_snapshot:
|
|
539
|
+
audit_action = "set"
|
|
540
|
+
else:
|
|
541
|
+
audit_action = "update"
|
|
542
|
+
_append_override_audit(
|
|
543
|
+
name=clean_name,
|
|
544
|
+
action=audit_action,
|
|
545
|
+
previous=previous_snapshot,
|
|
546
|
+
current=current_snapshot,
|
|
547
|
+
warning=warning,
|
|
548
|
+
actor=actor,
|
|
549
|
+
)
|
|
550
|
+
|
|
485
551
|
sync_result = _sync_core_crons_runtime()
|
|
486
552
|
refreshed = get_core_schedule_status(clean_name)
|
|
487
553
|
refreshed.update({
|
package/src/crons/manifest.json
CHANGED
|
@@ -192,6 +192,18 @@
|
|
|
192
192
|
"run_on_boot": true,
|
|
193
193
|
"run_on_wake": true
|
|
194
194
|
},
|
|
195
|
+
{
|
|
196
|
+
"id": "guardian-metrics",
|
|
197
|
+
"script": "scripts/guardian_metrics_aggregate.py",
|
|
198
|
+
"schedule": {"hour": 2, "minute": 15},
|
|
199
|
+
"description": "Plan Consolidado 0.25 — daily aggregation of Guardian KPIs (capture rate, core rule violations, declared-done without evidence, false-positive correction, minutes between guard_check failures) from guardian-telemetry.ndjson to guardian-metrics.ndjson. Feeds Fase C gate + Guardian Proposals panel.",
|
|
200
|
+
"core": true,
|
|
201
|
+
"recovery_policy": "catchup",
|
|
202
|
+
"idempotent": true,
|
|
203
|
+
"max_catchup_age": 172800,
|
|
204
|
+
"run_on_boot": false,
|
|
205
|
+
"run_on_wake": false
|
|
206
|
+
},
|
|
195
207
|
{
|
|
196
208
|
"id": "auto-close-sessions",
|
|
197
209
|
"script": "auto_close_sessions.py",
|
|
@@ -220,6 +220,193 @@ def check_config_parse() -> DoctorCheck:
|
|
|
220
220
|
)
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
def check_core_dev_packaged_install() -> DoctorCheck:
|
|
224
|
+
"""Warn when ``~/.nexo/core-dev/`` exists on a packaged (non-dev) install.
|
|
225
|
+
|
|
226
|
+
Contract (see ``docs/f06-layout-contract.md`` §3): ``core-dev/`` is a
|
|
227
|
+
developer opt-in and MUST be absent on production installs. Its presence
|
|
228
|
+
on a packaged install is almost always a leftover from a dev environment
|
|
229
|
+
that was later repackaged, and silently keeps parallel code paths
|
|
230
|
+
discoverable through ``_classify_script_dir``. Doctor surfaces it so the
|
|
231
|
+
operator can confirm and remove.
|
|
232
|
+
"""
|
|
233
|
+
import paths
|
|
234
|
+
core_dev = paths.core_dev_dir()
|
|
235
|
+
if not core_dev.exists():
|
|
236
|
+
return DoctorCheck(
|
|
237
|
+
id="boot.core_dev_absent_on_packaged",
|
|
238
|
+
tier="boot",
|
|
239
|
+
status="healthy",
|
|
240
|
+
severity="info",
|
|
241
|
+
summary="core-dev/ absent (expected on packaged installs)",
|
|
242
|
+
)
|
|
243
|
+
is_packaged = paths.core_dir().is_dir() and not (NEXO_HOME / "src").is_dir()
|
|
244
|
+
if not is_packaged:
|
|
245
|
+
return DoctorCheck(
|
|
246
|
+
id="boot.core_dev_absent_on_packaged",
|
|
247
|
+
tier="boot",
|
|
248
|
+
status="healthy",
|
|
249
|
+
severity="info",
|
|
250
|
+
summary="core-dev/ present on a dev install (contract allows this)",
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
payload = [p.name for p in core_dev.iterdir()][:5]
|
|
254
|
+
except OSError:
|
|
255
|
+
payload = []
|
|
256
|
+
return DoctorCheck(
|
|
257
|
+
id="boot.core_dev_absent_on_packaged",
|
|
258
|
+
tier="boot",
|
|
259
|
+
status="degraded",
|
|
260
|
+
severity="warn",
|
|
261
|
+
summary="core-dev/ present on a packaged install — contract forbids this",
|
|
262
|
+
evidence=[f"Location: {core_dev}"] + [f"Entry: {n}" for n in payload],
|
|
263
|
+
repair_plan=[
|
|
264
|
+
f"Confirm with operator, then: rm -rf {core_dev}",
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def check_dashboard_desktop_contract() -> DoctorCheck:
|
|
270
|
+
"""Flag Dashboard LaunchAgent contradicting Desktop product surface.
|
|
271
|
+
|
|
272
|
+
Contract (see ``docs/f06-layout-contract.md`` §4):
|
|
273
|
+
- Terminal-only install → ``com.nexo.dashboard`` loaded.
|
|
274
|
+
- Desktop-managed install → ``com.nexo.dashboard`` unloaded.
|
|
275
|
+
Both signals disagreeing with the chosen product mode is a warn.
|
|
276
|
+
"""
|
|
277
|
+
if sys.platform != "darwin":
|
|
278
|
+
return DoctorCheck(
|
|
279
|
+
id="boot.dashboard_desktop_contract",
|
|
280
|
+
tier="boot",
|
|
281
|
+
status="healthy",
|
|
282
|
+
severity="info",
|
|
283
|
+
summary="Non-darwin host — dashboard LaunchAgent contract does not apply",
|
|
284
|
+
)
|
|
285
|
+
agent_path = Path.home() / "Library" / "LaunchAgents" / "com.nexo.dashboard.plist"
|
|
286
|
+
agent_installed = agent_path.exists()
|
|
287
|
+
try:
|
|
288
|
+
from product_mode import enforce_desktop_product_contract # type: ignore
|
|
289
|
+
desktop_contract = bool(enforce_desktop_product_contract())
|
|
290
|
+
except Exception:
|
|
291
|
+
desktop_contract = False
|
|
292
|
+
|
|
293
|
+
if desktop_contract and agent_installed:
|
|
294
|
+
return DoctorCheck(
|
|
295
|
+
id="boot.dashboard_desktop_contract",
|
|
296
|
+
tier="boot",
|
|
297
|
+
status="degraded",
|
|
298
|
+
severity="warn",
|
|
299
|
+
summary="Desktop product surface active but standalone dashboard LaunchAgent is installed",
|
|
300
|
+
evidence=[f"Plist: {agent_path}"],
|
|
301
|
+
repair_plan=[
|
|
302
|
+
f"launchctl unload {agent_path}",
|
|
303
|
+
f"rm {agent_path}",
|
|
304
|
+
],
|
|
305
|
+
)
|
|
306
|
+
if not desktop_contract and not agent_installed:
|
|
307
|
+
return DoctorCheck(
|
|
308
|
+
id="boot.dashboard_desktop_contract",
|
|
309
|
+
tier="boot",
|
|
310
|
+
status="degraded",
|
|
311
|
+
severity="warn",
|
|
312
|
+
summary="Terminal-only install without a dashboard LaunchAgent",
|
|
313
|
+
evidence=["Expected plist missing: com.nexo.dashboard.plist"],
|
|
314
|
+
repair_plan=["nexo update # re-materialize com.nexo.dashboard"],
|
|
315
|
+
)
|
|
316
|
+
return DoctorCheck(
|
|
317
|
+
id="boot.dashboard_desktop_contract",
|
|
318
|
+
tier="boot",
|
|
319
|
+
status="healthy",
|
|
320
|
+
severity="info",
|
|
321
|
+
summary="Dashboard LaunchAgent state matches the product surface contract",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def check_f06_migration_consistency() -> DoctorCheck:
|
|
326
|
+
"""Detect half-migrated F0.6 installs.
|
|
327
|
+
|
|
328
|
+
Contract (``docs/f06-layout-contract.md`` §6 rule 5):
|
|
329
|
+
- F0.6 marker + legacy runtime dirs populated → half-migration.
|
|
330
|
+
- No marker but canonical ``core/`` already populated → half-migration.
|
|
331
|
+
- Marker F0.6 with no legacy residue → healthy.
|
|
332
|
+
- No marker, no canonical ``core/``, pure legacy layout → healthy
|
|
333
|
+
(pre-F0.6 install waiting for ``nexo update``).
|
|
334
|
+
|
|
335
|
+
Half-migration is the scenario where ``paths.coordination_dir()`` (and
|
|
336
|
+
siblings) silently fall back to the legacy path on an install that
|
|
337
|
+
*should* be on F0.6. Doctor surfaces it so ``nexo update`` can be
|
|
338
|
+
asked to finish the job instead of the operator discovering later that
|
|
339
|
+
half their state lives in the wrong place.
|
|
340
|
+
"""
|
|
341
|
+
import paths
|
|
342
|
+
marker = NEXO_HOME / ".structure-version"
|
|
343
|
+
marker_text = ""
|
|
344
|
+
if marker.is_file():
|
|
345
|
+
try:
|
|
346
|
+
marker_text = marker.read_text().strip().upper().split()[0]
|
|
347
|
+
except (OSError, IndexError):
|
|
348
|
+
marker_text = ""
|
|
349
|
+
is_f06_marked = marker_text.startswith("F0.6")
|
|
350
|
+
|
|
351
|
+
core_dir = paths.core_dir()
|
|
352
|
+
core_populated = core_dir.is_dir() and any(core_dir.iterdir()) if core_dir.exists() else False
|
|
353
|
+
|
|
354
|
+
# Legacy runtime dirs that MUST be gone (or be symlinks into canonical F0.6)
|
|
355
|
+
# once the migration has finished physically.
|
|
356
|
+
legacy_runtime_names = ("coordination", "data", "logs", "operations")
|
|
357
|
+
legacy_stragglers: list[str] = []
|
|
358
|
+
for name in legacy_runtime_names:
|
|
359
|
+
legacy_path = NEXO_HOME / name
|
|
360
|
+
if not legacy_path.exists():
|
|
361
|
+
continue
|
|
362
|
+
if legacy_path.is_symlink():
|
|
363
|
+
# A symlink pointing at the canonical runtime/<name> is the
|
|
364
|
+
# compat shim contract; that is acceptable.
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
has_content = any(legacy_path.iterdir())
|
|
368
|
+
except OSError:
|
|
369
|
+
has_content = False
|
|
370
|
+
if has_content:
|
|
371
|
+
legacy_stragglers.append(name)
|
|
372
|
+
|
|
373
|
+
if is_f06_marked and legacy_stragglers:
|
|
374
|
+
return DoctorCheck(
|
|
375
|
+
id="boot.f06_migration_consistency",
|
|
376
|
+
tier="boot",
|
|
377
|
+
status="critical",
|
|
378
|
+
severity="error",
|
|
379
|
+
summary="Half-migrated F0.6 install: marker present but legacy runtime dirs still populated",
|
|
380
|
+
evidence=[f"Marker: {marker_text}"] + [f"Legacy with content: {NEXO_HOME / n}" for n in legacy_stragglers],
|
|
381
|
+
repair_plan=[
|
|
382
|
+
"nexo update # finish the F0.6 migration",
|
|
383
|
+
"# if update refuses, inspect manifest and consider: nexo rollback f06",
|
|
384
|
+
],
|
|
385
|
+
)
|
|
386
|
+
if (not is_f06_marked) and core_populated:
|
|
387
|
+
return DoctorCheck(
|
|
388
|
+
id="boot.f06_migration_consistency",
|
|
389
|
+
tier="boot",
|
|
390
|
+
status="critical",
|
|
391
|
+
severity="error",
|
|
392
|
+
summary="Half-migrated F0.6 install: core/ populated but marker absent",
|
|
393
|
+
evidence=[f"Marker: {marker_text or '(absent)'}", f"core/ path: {core_dir}"],
|
|
394
|
+
repair_plan=[
|
|
395
|
+
"nexo update # re-run migration to write the marker",
|
|
396
|
+
],
|
|
397
|
+
)
|
|
398
|
+
return DoctorCheck(
|
|
399
|
+
id="boot.f06_migration_consistency",
|
|
400
|
+
tier="boot",
|
|
401
|
+
status="healthy",
|
|
402
|
+
severity="info",
|
|
403
|
+
summary=(
|
|
404
|
+
f"F0.6 marker consistent with layout (marker={marker_text or 'absent'}, "
|
|
405
|
+
f"legacy_stragglers={len(legacy_stragglers)})"
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
223
410
|
def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
224
411
|
"""Run all boot-tier checks."""
|
|
225
412
|
checks = [
|
|
@@ -229,6 +416,9 @@ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
229
416
|
safe_check(check_wrapper_scripts),
|
|
230
417
|
safe_check(check_python_runtime),
|
|
231
418
|
safe_check(check_config_parse),
|
|
419
|
+
safe_check(check_core_dev_packaged_install),
|
|
420
|
+
safe_check(check_dashboard_desktop_contract),
|
|
421
|
+
safe_check(check_f06_migration_consistency),
|
|
232
422
|
]
|
|
233
423
|
|
|
234
424
|
if fix:
|
package/src/evolution_cycle.py
CHANGED
|
@@ -19,6 +19,10 @@ from core_prompts import render_core_prompt
|
|
|
19
19
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
20
20
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(NEXO_HOME)))
|
|
21
21
|
NEXO_DB = paths.db_path()
|
|
22
|
+
# Evolution sandbox lives under the runtime root (equivalent to
|
|
23
|
+
# ``paths.runtime_dir() / "sandbox"``). Kept as ``NEXO_HOME / sandbox /
|
|
24
|
+
# workspace`` for backwards compatibility with existing installs that already
|
|
25
|
+
# have a populated sandbox at this path. Do NOT relocate without a migration.
|
|
22
26
|
SANDBOX_DIR = NEXO_HOME / "sandbox" / "workspace"
|
|
23
27
|
SNAPSHOTS_DIR = paths.snapshots_dir()
|
|
24
28
|
RESTORE_LOG = paths.logs_dir() / "snapshot-restores.log"
|