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/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)
@@ -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({
@@ -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:
@@ -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"