nexo-brain 7.1.3 → 7.1.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.
Files changed (89) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/bin/postinstall.js +2 -2
  4. package/package.json +2 -2
  5. package/src/agent_runner.py +4 -32
  6. package/src/auto_update.py +120 -1
  7. package/src/automation_controls.py +2 -1
  8. package/src/autonomy_mandate.py +17 -2
  9. package/src/bootstrap_docs.py +2 -1
  10. package/src/calibration_runtime.py +46 -0
  11. package/src/claude_cli.py +151 -0
  12. package/src/cli.py +114 -0
  13. package/src/client_preferences.py +84 -1
  14. package/src/client_sync.py +48 -2
  15. package/src/cognitive/_search.py +109 -10
  16. package/src/cognitive/_trust.py +117 -0
  17. package/src/core_schedule_controls.py +494 -0
  18. package/src/cron_recovery.py +11 -2
  19. package/src/crons/sync.py +10 -1
  20. package/src/db/__init__.py +1 -0
  21. package/src/db/_learnings.py +21 -9
  22. package/src/db/_protocol.py +33 -2
  23. package/src/db/_reminders.py +23 -13
  24. package/src/db/_schema.py +4 -0
  25. package/src/db/_semantic_similarity.py +98 -0
  26. package/src/db/_skills.py +28 -18
  27. package/src/doctor/providers/runtime.py +54 -9
  28. package/src/email_config.py +5 -0
  29. package/src/enforcement_engine.py +151 -2
  30. package/src/guard_verbal_ack.py +66 -0
  31. package/src/hook_guardrails.py +240 -27
  32. package/src/hooks/auto_capture.py +32 -7
  33. package/src/hooks/post_tool_use.py +8 -5
  34. package/src/paths.py +9 -0
  35. package/src/plugin_loader.py +65 -27
  36. package/src/plugins/core_rules.py +30 -1
  37. package/src/plugins/cortex.py +4 -1
  38. package/src/plugins/doctor.py +15 -0
  39. package/src/plugins/episodic_memory.py +52 -2
  40. package/src/plugins/evolution.py +22 -0
  41. package/src/plugins/guard.py +282 -26
  42. package/src/plugins/personal_scripts.py +116 -15
  43. package/src/plugins/protocol.py +358 -55
  44. package/src/plugins/skills.py +11 -2
  45. package/src/plugins/update.py +9 -6
  46. package/src/product_mode.py +28 -6
  47. package/src/scripts/check-context.py +3 -2
  48. package/src/scripts/nexo-catchup.py +6 -25
  49. package/src/scripts/nexo-daily-self-audit.py +0 -21
  50. package/src/scripts/nexo-evolution-run.py +5 -22
  51. package/src/scripts/nexo-postmortem-consolidator.py +0 -21
  52. package/src/scripts/nexo-send-reply.py +40 -0
  53. package/src/scripts/nexo-sleep.py +0 -20
  54. package/src/scripts/nexo-synthesis.py +0 -21
  55. package/src/scripts/nexo-watchdog.sh +28 -30
  56. package/src/server.py +4 -86
  57. package/src/session_end_intent.py +31 -0
  58. package/src/skills/create-nexo-primitive/guide.md +87 -0
  59. package/src/skills/create-nexo-primitive/skill.json +62 -0
  60. package/src/tools_drive.py +277 -12
  61. package/src/tools_email_guard.py +74 -0
  62. package/src/tools_hot_context.py +11 -2
  63. package/src/tools_sessions.py +61 -9
  64. package/src/tools_system_catalog.py +2 -2
  65. package/src/user_context.py +2 -1
  66. package/templates/CLAUDE.md.template +1 -1
  67. package/templates/CODEX.AGENTS.md.template +1 -1
  68. package/templates/core-prompts/automation-backend-probe.md +1 -0
  69. package/templates/core-prompts/autonomy-mandate-question.md +6 -0
  70. package/templates/core-prompts/codex-protocol-contract.md +7 -0
  71. package/templates/core-prompts/drive-area-classifier-system.md +4 -0
  72. package/templates/core-prompts/drive-area-classifier-user.md +6 -0
  73. package/templates/core-prompts/guard-verbal-ack-question.md +1 -0
  74. package/templates/core-prompts/heartbeat-diary-overdue.md +1 -0
  75. package/templates/core-prompts/heartbeat-guard-reminder.md +1 -0
  76. package/templates/core-prompts/heartbeat-learning-reminder.md +1 -0
  77. package/templates/core-prompts/hook-protocol-warning-guard-required.md +1 -0
  78. package/templates/core-prompts/hook-protocol-warning-heartbeat-close-evidence.md +1 -0
  79. package/templates/core-prompts/hook-protocol-warning-startup-required.md +1 -0
  80. package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -0
  81. package/templates/core-prompts/hook-protocol-warning-task-open-guard-note.md +1 -0
  82. package/templates/core-prompts/hook-protocol-warning-task-open-required.md +1 -0
  83. package/templates/core-prompts/hook-protocol-warning-workflow-required.md +1 -0
  84. package/templates/core-prompts/post-tool-inbox-reminder.md +1 -0
  85. package/templates/core-prompts/server-mcp-instructions.md +38 -0
  86. package/templates/core-prompts/session-end-intent-question.md +1 -0
  87. package/templates/core-prompts/watchdog-repair.md +25 -0
  88. package/templates/launchagents/README.md +1 -1
  89. package/templates/launchagents/com.nexo.synthesis.plist +8 -3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.3",
3
+ "version": "7.1.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
@@ -18,16 +18,16 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.1.3` is the current packaged-runtime line. It closes the remaining post-`7.1.2` product/runtime gap for packaged Desktop-managed installs: the Brain updater can reuse Desktop's bundled npm runtime, portable user-data restores are inspectable before import, product-mode detection is tighter on real packaged machines, and the public release surfaces once again match the runtime line that actually ships. The companion NEXO Desktop client (v0.22.3, closed-source distributed separately) embeds the same release line for its guided bootstrap, repair, and restore flow.
21
+ Version `7.1.6` is the current packaged-runtime line. It closes the remaining schedule-governance gap between Brain and Desktop: structural core crons now expose dedicated cadence overrides via `schedule-overrides.json`, explicit-home product checks stay isolated from the operator machine's global Desktop install markers, and the shipped `synthesis` launchagent template/docs are realigned with the live 06:00 daily schedule again. The companion NEXO Desktop client (v0.22.6, closed-source distributed separately) adds a dedicated Core schedules surface over the same coordinated contract.
22
22
 
23
23
  Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
24
24
 
25
25
  Previously in `6.5.0`: Plan Consolidado fase F0.2: operators can now `nexo scripts enable|disable|status <name>` any personal automation. The cron wrapper honours the flag at every tick (`exit 0` with `summary='[disabled]'` while the LaunchAgent stays loaded). The companion NEXO Desktop client (a closed-source product, distributed separately) wires the same toggle into its Automatizaciones panel. See [CHANGELOG](CHANGELOG.md) for the full diff.
26
26
 
27
- > **About NEXO Desktop.** NEXO Desktop is a separate closed-source companion app distributed at [systeam.es/nexo-desktop](https://systeam.es/nexo-desktop) — its source does not live in this repo. When release notes mention Desktop they describe a coordinated client release that consumes the Brain's CLI / MCP contract; the Brain itself is fully usable on its own (terminal, Codex, Claude Code, or any MCP client). If you want the product edition rather than the open-source Brain alone, contact `info@wazion.com` and ask about NEXO Desktop.
27
+ > **About NEXO Desktop.** NEXO Desktop is a separate closed-source companion app distributed at [nexo-desktop.com](https://nexo-desktop.com/) — its source does not live in this repo. When release notes mention Desktop they describe a coordinated client release that consumes the Brain's CLI / MCP contract; the Brain itself is fully usable on its own (terminal, Codex, Claude Code, or any MCP client). If you want the product edition rather than the open-source Brain alone, contact `info@wazion.com` and ask about NEXO Desktop.
28
28
 
29
29
 
30
- Previously in `6.4.0`: Plan Consolidado fase F1 — multi-tenant email accounts (`email_accounts` table, `nexo email setup` interactive wizard, `nexo email add --password-stdin --json` for machine consumers, idempotent migrator from legacy `~/.nexo/nexo-email/config.json`).
30
+ Previously in `6.4.0`: Plan Consolidado fase F1 — multi-tenant email accounts (`email_accounts` table, `nexo email setup` interactive wizard, `nexo email add --password-stdin --json` for machine consumers, idempotent migrator from legacy `~/.nexo/nexo-email/config.json`). On post-F0.6 installs that legacy-looking path is only a compatibility alias/shim into `~/.nexo/runtime/nexo-email/config.json`; it should never be treated as a second source of truth.
31
31
 
32
32
  Previously in `6.3.1`: privacy hotfix over v6.3.0. The nightly auditor caught that `src/presets/entities_universal.json` in v6.3.0 shipped operator-specific `vhost_mapping` entries (private IPs, hostnames, tenant names). v6.3.1 pulls those out into `src/presets/entities_local.sample.json` (template) + `.gitignore`'d `~/.nexo/brain/presets/entities_local.json` (operator copy), and the installer drops the sample at `nexo init`. No behaviour change on the Guardian side.
33
33
 
@@ -32,9 +32,9 @@ if (fs.existsSync(VERSION_FILE)) {
32
32
  // Run the main installer in --yes mode (non-interactive)
33
33
  // It will detect the existing version and do migration only
34
34
  // Let errors propagate so npm reports the failure correctly
35
- const { execSync } = require("child_process");
35
+ const { execFileSync } = require("child_process");
36
36
  try {
37
- execSync(`node ${path.join(__dirname, "nexo-brain.js")} --yes`, {
37
+ execFileSync(process.execPath, [path.join(__dirname, "nexo-brain.js"), "--yes"], {
38
38
  stdio: "inherit",
39
39
  env: { ...process.env, NEXO_POSTINSTALL: "1", NEXO_HOME: NEXO_HOME }
40
40
  });
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.3",
3
+ "version": "7.1.6",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
- "description": "NEXO Brain \u2014 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.",
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",
7
7
  "bin": {
8
8
  "nexo-brain": "./bin/nexo-brain.js",
@@ -30,6 +30,7 @@ from client_preferences import (
30
30
  resolve_client_runtime_profile,
31
31
  resolve_terminal_client,
32
32
  )
33
+ from claude_cli import resolve_claude_cli as _shared_resolve_claude_cli
33
34
  from core_prompts import render_core_prompt
34
35
 
35
36
 
@@ -359,25 +360,7 @@ def _record_automation_run(
359
360
 
360
361
 
361
362
  def _resolve_claude_cli() -> str:
362
- saved = paths.config_dir() / "claude-cli-path"
363
- if saved.exists():
364
- candidate = saved.read_text().strip()
365
- if candidate and Path(candidate).exists():
366
- return candidate
367
- env_path = os.environ.get("CLAUDE_BIN", "").strip()
368
- if env_path and Path(env_path).exists():
369
- return env_path
370
- discovered = shutil.which("claude")
371
- if discovered:
372
- return discovered
373
- for candidate in (
374
- Path.home() / ".local" / "bin" / "claude",
375
- Path.home() / ".npm-global" / "bin" / "claude",
376
- Path("/usr/local/bin/claude"),
377
- ):
378
- if candidate.exists():
379
- return str(candidate)
380
- return ""
363
+ return _shared_resolve_claude_cli()
381
364
 
382
365
 
383
366
  def _resolve_codex_cli() -> str:
@@ -781,18 +764,7 @@ def _build_codex_prompt(
781
764
  append_system_prompt: str = "",
782
765
  allowed_tools: str = "",
783
766
  ) -> str:
784
- protocol_contract = (
785
- "NEXO PROTOCOL (MANDATORY):\n"
786
- "- Before non-trivial analyze/edit/execute/delegate work, call `nexo_task_open(...)`. "
787
- "If that tool is unavailable, call `nexo_guard_check(...)` and `nexo_cortex_check(...)` first.\n"
788
- "- For long multi-step or cross-session work, call `nexo_workflow_open(...)` and keep it updated with "
789
- "`nexo_workflow_update(...)` so resume/replay use durable state instead of guesswork.\n"
790
- "- Before diagnosing NEXO, explicitly fix the plane first: `product_public`, `runtime_personal`, `installation_live`, `database_real`, or `cooperator`. "
791
- "Do not mix planes inside the same diagnosis.\n"
792
- "- If a target file has conditioned learnings or blocking guard rules, review them before any read/edit/delete step, and acknowledge guard before any edit/delete step.\n"
793
- "- Do not claim done without explicit verification evidence. Close with `nexo_task_close(...)`; if unavailable, capture the change log and state the evidence explicitly.\n"
794
- "- When a correction changes the canonical rule, capture or supersede the learning instead of leaving contradictory active rules behind."
795
- )
767
+ protocol_contract = render_core_prompt("codex-protocol-contract")
796
768
  instructions: list[str] = []
797
769
  instructions.append(protocol_contract)
798
770
  if append_system_prompt:
@@ -1217,7 +1189,7 @@ def probe_automation_backend(
1217
1189
  }
1218
1190
  try:
1219
1191
  result = run_automation_prompt(
1220
- "Reply exactly OK.",
1192
+ render_core_prompt("automation-backend-probe"),
1221
1193
  backend=selected_backend,
1222
1194
  cwd=cwd,
1223
1195
  timeout=timeout,
@@ -2229,6 +2229,7 @@ def _f06_legacy_shim_map() -> list[tuple[str, Path]]:
2229
2229
  ("data", NEXO_HOME / "runtime" / "data"),
2230
2230
  ("logs", NEXO_HOME / "runtime" / "logs"),
2231
2231
  ("operations", NEXO_HOME / "runtime" / "operations"),
2232
+ ("state", NEXO_HOME / "runtime" / "state"),
2232
2233
  ("backups", NEXO_HOME / "runtime" / "backups"),
2233
2234
  ("memory", NEXO_HOME / "runtime" / "memory"),
2234
2235
  ("coordination", NEXO_HOME / "runtime" / "coordination"),
@@ -2236,6 +2237,14 @@ def _f06_legacy_shim_map() -> list[tuple[str, Path]]:
2236
2237
  ("nexo-email", NEXO_HOME / "runtime" / "nexo-email"),
2237
2238
  ("snapshots", NEXO_HOME / "runtime" / "snapshots"),
2238
2239
  ("crons", NEXO_HOME / "runtime" / "crons"),
2240
+ ("workdir", paths.personal_lib_dir() / "workdir"),
2241
+ ("working", paths.personal_lib_dir() / "working"),
2242
+ ]
2243
+
2244
+
2245
+ def _f06_legacy_file_shim_map() -> list[tuple[str, Path]]:
2246
+ return [
2247
+ ("CLAUDE.md.generated", paths.personal_lib_dir() / "generated" / "CLAUDE.md.generated"),
2239
2248
  ]
2240
2249
 
2241
2250
 
@@ -2270,6 +2279,7 @@ def _f06_live_legacy_paths() -> list[Path]:
2270
2279
  "brain",
2271
2280
  "data",
2272
2281
  "operations",
2282
+ "state",
2273
2283
  "logs",
2274
2284
  "backups",
2275
2285
  "memory",
@@ -2287,6 +2297,8 @@ def _f06_live_legacy_paths() -> list[Path]:
2287
2297
  "db",
2288
2298
  "dashboard",
2289
2299
  "skills-core",
2300
+ "workdir",
2301
+ "working",
2290
2302
  )
2291
2303
  ]
2292
2304
  present = [p for p in legacy_anchors if p.exists() and not p.is_symlink()]
@@ -2297,6 +2309,10 @@ def _f06_live_legacy_paths() -> list[Path]:
2297
2309
  candidate = NEXO_HOME / legacy_name
2298
2310
  if candidate.exists() and not candidate.is_symlink():
2299
2311
  live_files.append(candidate)
2312
+ for legacy_name, _canonical in _f06_legacy_file_shim_map():
2313
+ candidate = NEXO_HOME / legacy_name
2314
+ if candidate.exists() and not candidate.is_symlink():
2315
+ live_files.append(candidate)
2300
2316
  return live_files
2301
2317
 
2302
2318
 
@@ -2554,6 +2570,51 @@ def _ensure_f06_legacy_shims() -> None:
2554
2570
  except Exception as exc:
2555
2571
  _log(f"[F0.6 shim] file symlink create failed for {legacy_name}: {exc}")
2556
2572
 
2573
+ for legacy_name, canonical in _f06_legacy_file_shim_map():
2574
+ legacy = NEXO_HOME / legacy_name
2575
+ canonical.parent.mkdir(parents=True, exist_ok=True)
2576
+
2577
+ if legacy.is_symlink():
2578
+ try:
2579
+ if legacy.resolve(strict=False) == canonical.resolve(strict=False):
2580
+ continue
2581
+ except Exception:
2582
+ pass
2583
+ try:
2584
+ legacy.unlink()
2585
+ except Exception as exc:
2586
+ _log(f"[F0.6 shim] could not replace misc file symlink {legacy_name}: {exc}")
2587
+ continue
2588
+
2589
+ if legacy.is_file():
2590
+ if canonical.exists():
2591
+ if _same_file(legacy, canonical):
2592
+ try:
2593
+ legacy.unlink()
2594
+ except Exception:
2595
+ pass
2596
+ else:
2597
+ backup_target = _conflict_dir() / "legacy-files"
2598
+ backup_target.mkdir(parents=True, exist_ok=True)
2599
+ shutil.move(str(legacy), str(backup_target / legacy.name))
2600
+ else:
2601
+ try:
2602
+ shutil.move(str(legacy), str(canonical))
2603
+ except Exception as exc:
2604
+ _log(f"[F0.6 shim] move failed for misc flat file {legacy_name}: {exc}")
2605
+ continue
2606
+ elif legacy.exists():
2607
+ _log(f"[F0.6 shim] legacy misc flat path {legacy_name} is not a file; skipped")
2608
+ continue
2609
+
2610
+ if not canonical.exists():
2611
+ continue
2612
+ try:
2613
+ relative = os.path.relpath(str(canonical), str(legacy.parent))
2614
+ legacy.symlink_to(relative)
2615
+ except Exception as exc:
2616
+ _log(f"[F0.6 shim] misc file symlink create failed for {legacy_name}: {exc}")
2617
+
2557
2618
  marker = NEXO_HOME / ".structure-version"
2558
2619
  try:
2559
2620
  marker.write_text("F0.6\n", encoding="utf-8")
@@ -2613,6 +2674,52 @@ def _cleanup_f06_root_residue() -> None:
2613
2674
  _log(f"[F0.6] residue cleanup skipped for {target}: {exc}")
2614
2675
 
2615
2676
 
2677
+ def _heal_misplaced_personal_watchdog_runtime_files() -> list[str]:
2678
+ """Move/remove watchdog runtime artifacts that ended up in personal/scripts.
2679
+
2680
+ These files are runtime state for the core watchdog and must live under
2681
+ ``core/scripts``. Older migrations or manual copies could leave stale
2682
+ duplicates under ``personal/scripts``; that pollutes the personal surface
2683
+ and makes clean installs look dirtier than they are.
2684
+ """
2685
+ actions: list[str] = []
2686
+ personal_scripts = paths.personal_scripts_dir()
2687
+ core_scripts = paths.core_scripts_dir()
2688
+ try:
2689
+ core_scripts.mkdir(parents=True, exist_ok=True)
2690
+ except Exception:
2691
+ return actions
2692
+
2693
+ for name in sorted(_CORE_SCRIPT_RUNTIME_FILES):
2694
+ personal = personal_scripts / name
2695
+ if not personal.exists():
2696
+ continue
2697
+ core = core_scripts / name
2698
+ try:
2699
+ if not core.exists():
2700
+ shutil.move(str(personal), str(core))
2701
+ actions.append(f"watchdog-runtime-file-moved:{name}")
2702
+ continue
2703
+ if name == ".watchdog-nexo-repair.lock":
2704
+ personal.unlink(missing_ok=True)
2705
+ actions.append(f"watchdog-runtime-file-pruned:{name}")
2706
+ continue
2707
+ try:
2708
+ personal_mtime = personal.stat().st_mtime
2709
+ core_mtime = core.stat().st_mtime
2710
+ except OSError:
2711
+ personal_mtime = core_mtime = 0
2712
+ if personal_mtime > core_mtime:
2713
+ shutil.move(str(personal), str(core))
2714
+ actions.append(f"watchdog-runtime-file-promoted:{name}")
2715
+ else:
2716
+ personal.unlink(missing_ok=True)
2717
+ actions.append(f"watchdog-runtime-file-pruned:{name}")
2718
+ except Exception as exc:
2719
+ actions.append(f"watchdog-runtime-file-warning:{name}:{exc.__class__.__name__}")
2720
+ return actions
2721
+
2722
+
2616
2723
  def _maybe_migrate_to_f06_layout() -> None:
2617
2724
  """Plan F0.6 — one-shot physical layout migration. Idempotent.
2618
2725
 
@@ -2674,8 +2781,9 @@ def _maybe_migrate_to_f06_layout() -> None:
2674
2781
  for sub in ("core/scripts", "core-dev/scripts", "personal/scripts",
2675
2782
  "core/db", "core/cognitive", "core/doctor", "core/dashboard", "core/skills",
2676
2783
  "personal/brain", "personal/skills", "personal/config",
2784
+ "personal/lib", "personal/lib/generated",
2677
2785
  "core/plugins", "core/hooks", "core/rules",
2678
- "runtime/data", "runtime/logs", "runtime/operations",
2786
+ "runtime/data", "runtime/logs", "runtime/operations", "runtime/state",
2679
2787
  "runtime/backups", "runtime/memory",
2680
2788
  "runtime/coordination", "runtime/exports",
2681
2789
  "runtime/nexo-email",
@@ -2744,6 +2852,7 @@ def _maybe_migrate_to_f06_layout() -> None:
2744
2852
  ("data", NEXO_HOME / "runtime" / "data"),
2745
2853
  ("logs", NEXO_HOME / "runtime" / "logs"),
2746
2854
  ("operations", NEXO_HOME / "runtime" / "operations"),
2855
+ ("state", NEXO_HOME / "runtime" / "state"),
2747
2856
  ("backups", NEXO_HOME / "runtime" / "backups"),
2748
2857
  ("memory", NEXO_HOME / "runtime" / "memory"),
2749
2858
  ("cognitive", paths.core_cognitive_dir(allow_legacy_fallback=False)),
@@ -2757,6 +2866,8 @@ def _maybe_migrate_to_f06_layout() -> None:
2757
2866
  ("plugins", NEXO_HOME / "core" / "plugins"),
2758
2867
  ("hooks", NEXO_HOME / "core" / "hooks"),
2759
2868
  ("rules", NEXO_HOME / "core" / "rules"),
2869
+ ("workdir", paths.personal_lib_dir() / "workdir"),
2870
+ ("working", paths.personal_lib_dir() / "working"),
2760
2871
  ]
2761
2872
  for legacy_name, new_dir in TREE_MAP:
2762
2873
  legacy = NEXO_HOME / legacy_name
@@ -4419,6 +4530,14 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4419
4530
  except Exception as exc:
4420
4531
  actions.append(f"deep-sleep-heal-warning:{exc.__class__.__name__}")
4421
4532
 
4533
+ try:
4534
+ _emit_progress(progress_fn, "Cleaning misplaced watchdog runtime files...")
4535
+ misplaced_actions = _heal_misplaced_personal_watchdog_runtime_files()
4536
+ for action in misplaced_actions:
4537
+ actions.append(action)
4538
+ except Exception as exc:
4539
+ actions.append(f"watchdog-runtime-file-warning:{exc.__class__.__name__}")
4540
+
4422
4541
  # Recover the user's legacy reasoning_effort preference into the new
4423
4542
  # default_resonance knob. v5.10.0 left this gap — users who had
4424
4543
  # `reasoning_effort="max"` in schedule.json silently degraded to
@@ -732,11 +732,12 @@ def get_operator_profile() -> dict[str, Any]:
732
732
  operator_accounts: list[dict] = []
733
733
 
734
734
  try:
735
+ from calibration_runtime import load_runtime_calibration
735
736
  from paths import brain_dir
736
737
 
737
738
  cal_path = brain_dir() / "calibration.json"
738
739
  if cal_path.exists():
739
- payload = json.loads(cal_path.read_text())
740
+ payload = load_runtime_calibration(cal_path)
740
741
  user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
741
742
  operator_name = (
742
743
  str(user.get("name") or "").strip()
@@ -33,9 +33,12 @@ from datetime import date, datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Optional
35
35
 
36
+ from core_prompts import render_core_prompt
37
+
36
38
 
37
39
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
38
40
  STATE_PATH = NEXO_HOME / "runtime" / "data" / "autonomy_mandate.json"
41
+ CLASSIFIER_QUESTION = render_core_prompt("autonomy-mandate-question")
39
42
 
40
43
  # Marker list per NF-DS-45569A27. Case-insensitive substring match.
41
44
  MARKERS = (
@@ -48,6 +51,7 @@ MARKERS = (
48
51
  "llevo 3 veces",
49
52
  "lo quiero ya",
50
53
  )
54
+ _SEMANTIC_MARKER = "semantic-autonomy-mandate"
51
55
 
52
56
  # Default mandate TTL. Longer than a normal working session but short enough
53
57
  # that a stale state does not block followups weeks later.
@@ -93,13 +97,23 @@ class MandateState:
93
97
  }
94
98
 
95
99
 
96
- def _detect_marker(text: str) -> Optional[str]:
100
+ def _detect_marker(text: str, *, classifier=None) -> Optional[str]:
97
101
  if not text:
98
102
  return None
99
103
  lowered = text.lower()
100
104
  for marker in MARKERS:
101
105
  if marker in lowered:
102
106
  return marker
107
+ if classifier is None:
108
+ try:
109
+ from enforcement_classifier import classify as classifier # type: ignore
110
+ except Exception:
111
+ return None
112
+ try:
113
+ if bool(classifier(question=CLASSIFIER_QUESTION, context=text.strip()[:1200])):
114
+ return _SEMANTIC_MARKER
115
+ except Exception:
116
+ return None
103
117
  return None
104
118
 
105
119
 
@@ -163,6 +177,7 @@ def maybe_ingest_from_text(
163
177
  session_id: str,
164
178
  source: str = "auto",
165
179
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
180
+ classifier=None,
166
181
  ) -> Optional[MandateState]:
167
182
  """Scan free-form text for a mandate marker and persist if found.
168
183
 
@@ -170,7 +185,7 @@ def maybe_ingest_from_text(
170
185
  mandate can be set transparently, without a separate explicit tool.
171
186
  Returns the new state when a marker was detected, otherwise None.
172
187
  """
173
- marker = _detect_marker(text or "")
188
+ marker = _detect_marker(text or "", classifier=classifier)
174
189
  if not marker:
175
190
  return None
176
191
  return set_mandate(
@@ -7,6 +7,7 @@ import os
7
7
  import re
8
8
  from pathlib import Path
9
9
 
10
+ from calibration_runtime import load_runtime_calibration
10
11
  from client_preferences import (
11
12
  BACKEND_NONE,
12
13
  CLIENT_CLAUDE_CODE,
@@ -82,7 +83,7 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
82
83
  env_name = os.environ.get("NEXO_NAME", "").strip()
83
84
  if env_name:
84
85
  return env_name
85
- calibration = _read_json_file(nexo_home / "personal" / "brain" / "calibration.json")
86
+ calibration = load_runtime_calibration(nexo_home / "personal" / "brain" / "calibration.json")
86
87
  user_payload = calibration.get("user")
87
88
  if isinstance(user_payload, dict):
88
89
  candidate = str(user_payload.get("assistant_name", "")).strip()
@@ -0,0 +1,46 @@
1
+ """Runtime-safe calibration view.
2
+
3
+ Reads the full on-disk ``calibration.json`` but returns only the subset
4
+ that runtime consumers actually need for identity, preferences, and
5
+ profile decisions. Heavy historical arrays stay on disk untouched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from paths import brain_dir
15
+
16
+ _TOP_LEVEL_RUNTIME_KEYS = {
17
+ "user",
18
+ "preferences",
19
+ "meta",
20
+ "assistant_name",
21
+ "operator_name",
22
+ "identity",
23
+ "language",
24
+ "lang",
25
+ "user_name",
26
+ "name",
27
+ "version",
28
+ "created",
29
+ }
30
+
31
+
32
+ def load_runtime_calibration(path: Path | None = None) -> dict:
33
+ target = path or (brain_dir() / "calibration.json")
34
+ if not target.is_file():
35
+ return {}
36
+ try:
37
+ payload = json.loads(target.read_text())
38
+ except Exception:
39
+ return {}
40
+ if not isinstance(payload, dict):
41
+ return {}
42
+ view: dict = {}
43
+ for key in _TOP_LEVEL_RUNTIME_KEYS:
44
+ if key in payload:
45
+ view[key] = copy.deepcopy(payload[key])
46
+ return view
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared Claude CLI resolution helpers.
4
+
5
+ Desktop-managed installs must never silently escape to a global `claude`
6
+ binary from PATH. This helper centralises that contract so automation
7
+ surfaces can share the same resolution policy.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+
16
+ def _user_home(user_home: Path | None = None) -> Path:
17
+ if user_home is not None:
18
+ return Path(user_home).expanduser()
19
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
20
+
21
+
22
+ def _desktop_install_markers(home: Path, *, include_global_markers: bool) -> list[Path]:
23
+ markers: list[Path] = [
24
+ home / "Applications" / "NEXO Desktop.app",
25
+ home / "Library" / "Application Support" / "NEXO Desktop",
26
+ home / ".local" / "share" / "NEXO Desktop",
27
+ home / ".config" / "NEXO Desktop",
28
+ ]
29
+ # Treat the global app bundle path as a stable install marker even when
30
+ # tests run on non-macOS CI hosts. Explicit homes still suppress it.
31
+ if include_global_markers:
32
+ markers.insert(0, Path("/Applications/NEXO Desktop.app"))
33
+ if os.name == "nt":
34
+ local = Path(os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local")))
35
+ roaming = Path(os.environ.get("APPDATA", str(home / "AppData" / "Roaming")))
36
+ markers.extend(
37
+ [
38
+ local / "Programs" / "NEXO Desktop",
39
+ roaming / "NEXO Desktop",
40
+ ]
41
+ )
42
+ return markers
43
+
44
+
45
+ def desktop_product_requested(user_home: Path | None = None) -> bool:
46
+ if str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1":
47
+ return True
48
+ explicit_home = user_home is not None
49
+ home = _user_home(user_home)
50
+ mode_paths = (
51
+ home / ".nexo" / "personal" / "config" / "product-mode.json",
52
+ home / ".nexo" / "config" / "product-mode.json",
53
+ )
54
+ for mode_path in mode_paths:
55
+ try:
56
+ payload = json.loads(mode_path.read_text())
57
+ except Exception:
58
+ continue
59
+ if not isinstance(payload, dict):
60
+ continue
61
+ if payload.get("desktop_managed") is True:
62
+ return True
63
+ if str(payload.get("product_mode") or "").strip().lower() == "desktop_closed_product":
64
+ return True
65
+ for marker in _desktop_install_markers(home, include_global_markers=not explicit_home):
66
+ try:
67
+ if marker.exists():
68
+ return True
69
+ except Exception:
70
+ continue
71
+ return False
72
+
73
+
74
+ def managed_claude_prefix(user_home: Path | None = None) -> Path:
75
+ explicit = str(os.environ.get("NEXO_CLAUDE_PREFIX", "")).strip()
76
+ if explicit:
77
+ return Path(explicit).expanduser()
78
+ return _user_home(user_home) / ".nexo" / "runtime" / "bootstrap" / "npm-global"
79
+
80
+
81
+ def _path_within(candidate: Path, parent: Path) -> bool:
82
+ try:
83
+ candidate.expanduser().resolve(strict=False).relative_to(parent.expanduser().resolve(strict=False))
84
+ return True
85
+ except Exception:
86
+ return False
87
+
88
+
89
+ def managed_claude_binary(user_home: Path | None = None) -> str:
90
+ home = _user_home(user_home)
91
+ managed_prefix = managed_claude_prefix(home)
92
+ persisted_paths = (
93
+ home / ".nexo" / "config" / "claude-cli-path",
94
+ home / ".nexo" / "personal" / "config" / "claude-cli-path",
95
+ )
96
+ candidates: list[Path] = []
97
+ for persisted in persisted_paths:
98
+ try:
99
+ raw = persisted.read_text(encoding="utf-8").strip()
100
+ except Exception:
101
+ raw = ""
102
+ if raw:
103
+ candidates.append(Path(raw))
104
+ env_path = str(os.environ.get("CLAUDE_BIN", "")).strip()
105
+ if env_path:
106
+ candidates.append(Path(env_path))
107
+ candidates.append(managed_prefix / "bin" / "claude")
108
+ for candidate in candidates:
109
+ try:
110
+ if not candidate.exists():
111
+ continue
112
+ except Exception:
113
+ continue
114
+ if _path_within(candidate, managed_prefix):
115
+ return str(candidate)
116
+ return ""
117
+
118
+
119
+ def resolve_claude_cli(user_home: Path | None = None) -> str:
120
+ home = _user_home(user_home)
121
+ if desktop_product_requested(home):
122
+ return managed_claude_binary(home)
123
+
124
+ persisted_paths = (
125
+ home / ".nexo" / "config" / "claude-cli-path",
126
+ home / ".nexo" / "personal" / "config" / "claude-cli-path",
127
+ )
128
+ for persisted in persisted_paths:
129
+ try:
130
+ candidate = persisted.read_text(encoding="utf-8").strip()
131
+ except Exception:
132
+ candidate = ""
133
+ if candidate and Path(candidate).exists():
134
+ return candidate
135
+
136
+ env_path = str(os.environ.get("CLAUDE_BIN", "")).strip()
137
+ if env_path and Path(env_path).exists():
138
+ return env_path
139
+
140
+ discovered = shutil.which("claude")
141
+ if discovered:
142
+ return discovered
143
+
144
+ for candidate in (
145
+ home / ".local" / "bin" / "claude",
146
+ home / ".npm-global" / "bin" / "claude",
147
+ Path("/usr/local/bin/claude"),
148
+ ):
149
+ if candidate.exists():
150
+ return str(candidate)
151
+ return ""