nexo-brain 7.1.1 → 7.1.3

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 (185) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/bin/nexo-brain.js +198 -92
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +10 -8
  6. package/src/auto_close_sessions.py +19 -2
  7. package/src/auto_update.py +242 -28
  8. package/src/autonomy_mandate.py +260 -0
  9. package/src/bootstrap_docs.py +22 -1
  10. package/src/cli.py +211 -1
  11. package/src/cli_email.py +104 -73
  12. package/src/client_sync.py +22 -1
  13. package/src/cognitive/_core.py +5 -3
  14. package/src/core_prompts.py +50 -0
  15. package/src/cron_recovery.py +81 -7
  16. package/src/crons/manifest.json +57 -0
  17. package/src/crons/sync.py +95 -26
  18. package/src/dashboard/app.py +59 -0
  19. package/src/dashboard/templates/base.html +2 -0
  20. package/src/dashboard/templates/feature-disabled.html +27 -0
  21. package/src/db/_email_accounts.py +67 -18
  22. package/src/db/_fts.py +5 -5
  23. package/src/db/_personal_scripts.py +1 -1
  24. package/src/db/_skills.py +3 -3
  25. package/src/doctor/providers/runtime.py +35 -20
  26. package/src/email_config.py +18 -9
  27. package/src/enforcement_classifier.py +3 -12
  28. package/src/enforcement_engine.py +6 -9
  29. package/src/evolution_cycle.py +37 -149
  30. package/src/guardian_telemetry.py +3 -2
  31. package/src/hook_guardrails.py +61 -0
  32. package/src/hooks/capture-tool-logs.sh +11 -3
  33. package/src/hooks/daily-briefing-check.sh +7 -2
  34. package/src/hooks/heartbeat-enforcement.py +14 -1
  35. package/src/hooks/heartbeat-posttool.sh +2 -0
  36. package/src/hooks/heartbeat-user-msg.sh +2 -0
  37. package/src/hooks/inbox-hook.sh +6 -2
  38. package/src/hooks/post-compact.sh +12 -4
  39. package/src/hooks/pre-compact.sh +12 -4
  40. package/src/migrate_embeddings.py +5 -3
  41. package/src/nexo_migrate.py +3 -1
  42. package/src/plugin_loader.py +14 -5
  43. package/src/plugins/adaptive_mode.py +4 -1
  44. package/src/plugins/backup.py +32 -20
  45. package/src/plugins/evolution.py +2 -0
  46. package/src/plugins/memory_export.py +6 -1
  47. package/src/plugins/personal_plugins.py +17 -7
  48. package/src/plugins/personal_scripts.py +64 -3
  49. package/src/plugins/update.py +34 -14
  50. package/src/presets/entities_universal.json +67 -4
  51. package/src/product_mode.py +201 -0
  52. package/src/r14_correction_learning.py +5 -20
  53. package/src/r15_project_context.py +4 -10
  54. package/src/r16_declared_done.py +3 -16
  55. package/src/r17_promise_debt.py +3 -16
  56. package/src/r18_followup_autocomplete.py +5 -7
  57. package/src/r19_project_grep.py +5 -8
  58. package/src/r20_constant_change.py +5 -15
  59. package/src/r21_legacy_path.py +5 -7
  60. package/src/r22_personal_script.py +4 -8
  61. package/src/r23_ssh_without_atlas.py +4 -11
  62. package/src/r23b_deploy_vhost.py +7 -6
  63. package/src/r23c_cwd_mismatch.py +7 -6
  64. package/src/r23d_chown_chmod_recursive.py +6 -6
  65. package/src/r23e_force_push_main.py +5 -6
  66. package/src/r23f_db_no_where.py +5 -6
  67. package/src/r23g_secrets_in_output.py +5 -5
  68. package/src/r23h_shebang_mismatch.py +6 -5
  69. package/src/r23i_auto_deploy_ignored.py +5 -6
  70. package/src/r23j_global_install.py +5 -6
  71. package/src/r23k_script_duplicates_skill.py +7 -6
  72. package/src/r23l_resource_collision.py +7 -6
  73. package/src/r23m_message_duplicate.py +6 -5
  74. package/src/r24_stale_memory.py +4 -9
  75. package/src/r25_nora_maria_read_only.py +5 -10
  76. package/src/r34_identity_coherence.py +6 -13
  77. package/src/r_catalog.py +3 -7
  78. package/src/resonance_map.py +13 -13
  79. package/src/runtime_power.py +29 -80
  80. package/src/script_registry.py +236 -6
  81. package/src/scripts/check-context.py +8 -25
  82. package/src/scripts/deep-sleep/extract.py +9 -16
  83. package/src/scripts/nexo-auto-update.py +27 -4
  84. package/src/scripts/nexo-catchup.py +9 -19
  85. package/src/scripts/nexo-cognitive-decay.py +26 -3
  86. package/src/scripts/nexo-daily-self-audit.py +50 -51
  87. package/src/scripts/nexo-email-migrate-config.py +30 -11
  88. package/src/scripts/nexo-email-monitor.py +97 -238
  89. package/src/scripts/nexo-followup-runner.py +70 -133
  90. package/src/scripts/nexo-hook-record.py +1 -1
  91. package/src/scripts/nexo-immune.py +6 -31
  92. package/src/scripts/nexo-impact-scorer.py +27 -4
  93. package/src/scripts/nexo-learning-housekeep.py +26 -3
  94. package/src/scripts/nexo-learning-validator.py +34 -32
  95. package/src/scripts/nexo-migrate.py +28 -12
  96. package/src/scripts/nexo-morning-agent.py +9 -23
  97. package/src/scripts/nexo-outcome-checker.py +27 -4
  98. package/src/scripts/nexo-postmortem-consolidator.py +30 -62
  99. package/src/scripts/nexo-pre-commit.py +28 -0
  100. package/src/scripts/nexo-proactive-dashboard.py +27 -0
  101. package/src/scripts/nexo-reflection.py +33 -3
  102. package/src/scripts/nexo-runtime-preflight.py +27 -2
  103. package/src/scripts/nexo-send-reply.py +10 -8
  104. package/src/scripts/nexo-sleep.py +11 -25
  105. package/src/scripts/nexo-synthesis.py +7 -40
  106. package/src/scripts/nexo-watchdog-smoke.py +30 -1
  107. package/src/scripts/nexo-watchdog.sh +23 -17
  108. package/src/scripts/phase_guardian_analysis.py +27 -4
  109. package/src/server.py +14 -3
  110. package/src/storage_router.py +8 -6
  111. package/src/t4_llm_gate.py +17 -84
  112. package/src/tools_drive.py +5 -13
  113. package/src/tools_guardian.py +3 -4
  114. package/src/tools_menu.py +2 -2
  115. package/src/tools_reminders_crud.py +17 -0
  116. package/src/tools_sessions.py +1 -4
  117. package/src/user_context.py +3 -6
  118. package/src/user_data_portability.py +141 -44
  119. package/templates/CLAUDE.md.template +11 -3
  120. package/templates/CODEX.AGENTS.md.template +11 -3
  121. package/templates/core-prompts/catchup-assessment.md +19 -0
  122. package/templates/core-prompts/check-context.md +24 -0
  123. package/templates/core-prompts/daily-self-audit.md +42 -0
  124. package/templates/core-prompts/daily-synthesis.md +40 -0
  125. package/templates/core-prompts/deep-sleep-extract-json-conversion.md +6 -0
  126. package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
  127. package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
  128. package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
  129. package/templates/core-prompts/email-monitor.md +202 -0
  130. package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
  131. package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
  132. package/templates/core-prompts/evolution-public-contribution.md +32 -0
  133. package/templates/core-prompts/evolution-public-pr-review.md +38 -0
  134. package/templates/core-prompts/evolution-weekly.md +71 -0
  135. package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
  136. package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
  137. package/templates/core-prompts/followup-runner.md +74 -0
  138. package/templates/core-prompts/immune-triage.md +31 -0
  139. package/templates/core-prompts/interactive-startup.md +1 -0
  140. package/templates/core-prompts/json-object-only.md +1 -0
  141. package/templates/core-prompts/learning-validator.md +25 -0
  142. package/templates/core-prompts/morning-agent-json-output.md +1 -0
  143. package/templates/core-prompts/morning-agent.md +23 -0
  144. package/templates/core-prompts/postmortem-consolidator.md +60 -0
  145. package/templates/core-prompts/r-catalog.md +1 -0
  146. package/templates/core-prompts/r13-pre-edit-guard-injection.md +1 -0
  147. package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
  148. package/templates/core-prompts/r14-correction-learning-question.md +1 -0
  149. package/templates/core-prompts/r15-project-context-injection.md +1 -0
  150. package/templates/core-prompts/r16-declared-done-injection.md +1 -0
  151. package/templates/core-prompts/r16-declared-done-question.md +1 -0
  152. package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
  153. package/templates/core-prompts/r17-promise-debt-question.md +1 -0
  154. package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
  155. package/templates/core-prompts/r19-project-grep-injection.md +1 -0
  156. package/templates/core-prompts/r20-constant-change-injection.md +1 -0
  157. package/templates/core-prompts/r20-constant-change-question.md +1 -0
  158. package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
  159. package/templates/core-prompts/r22-personal-script-injection.md +1 -0
  160. package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
  161. package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
  162. package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
  163. package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
  164. package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
  165. package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
  166. package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
  167. package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
  168. package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
  169. package/templates/core-prompts/r23j-global-install-injection.md +1 -0
  170. package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
  171. package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
  172. package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
  173. package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
  174. package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
  175. package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
  176. package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
  177. package/templates/core-prompts/sleep.md +25 -0
  178. package/templates/core-prompts/t4-r15-project-context-gate.md +14 -0
  179. package/templates/core-prompts/t4-r23e-force-push-gate.md +14 -0
  180. package/templates/core-prompts/t4-r23f-db-no-where-gate.md +14 -0
  181. package/templates/core-prompts/t4-r23h-shebang-mismatch-gate.md +14 -0
  182. package/templates/email-template.md +55 -0
  183. package/templates/nexo_helper.py +31 -13
  184. package/templates/plugin-template.py +3 -3
  185. package/templates/skill-template.md +2 -1
@@ -0,0 +1,260 @@
1
+ """Runtime state + enforcement for operator autonomy mandates.
2
+
3
+ When the operator states `autonomía total`, `sin esperas`, `todo ya`, or an
4
+ equivalent marker, the agent must stop deferring in-scope work to the future
5
+ via `nexo_followup_create`. This module captures that mandate as a small
6
+ JSON state file under `$NEXO_HOME/runtime/data/autonomy_mandate.json`, and
7
+ exposes helpers the CRUD layer uses before creating a followup.
8
+
9
+ Design notes:
10
+
11
+ * State is session-scoped, not global — the mandate decays when the session
12
+ ends or after `DEFAULT_TTL_SECONDS` whichever comes first.
13
+ * Detection is intentionally simple (substring match on a marker list). The
14
+ S4 incident that motivated this guardrail only needed a coarse trigger.
15
+ * The block is surgical: a followup is rejected only when the call pattern
16
+ matches the procrastination shape the operator flagged (owner=user or
17
+ date within 7 days). Long-horizon commitments (>7 days) and shared/agent
18
+ followups pass through, because those are not what the operator was
19
+ pushing back on.
20
+ * Three explicit exceptions stay allowed even under the mandate:
21
+ (a) a download >1GB, (b) a credential the operator must physically enter,
22
+ (c) a presence-dependent session with María or Nora. These are declared
23
+ by keywords inside `description` or the explicit `exception` kwarg.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import time
31
+ from dataclasses import dataclass
32
+ from datetime import date, datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+
37
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
38
+ STATE_PATH = NEXO_HOME / "runtime" / "data" / "autonomy_mandate.json"
39
+
40
+ # Marker list per NF-DS-45569A27. Case-insensitive substring match.
41
+ MARKERS = (
42
+ "autonomía total",
43
+ "autonomia total",
44
+ "sin esperas",
45
+ "todo ya",
46
+ "no esperes",
47
+ "no te escondas",
48
+ "llevo 3 veces",
49
+ "lo quiero ya",
50
+ )
51
+
52
+ # Default mandate TTL. Longer than a normal working session but short enough
53
+ # that a stale state does not block followups weeks later.
54
+ DEFAULT_TTL_SECONDS = 6 * 60 * 60 # 6h
55
+
56
+ # Exception keywords — when any appear in the description (or the explicit
57
+ # `exception` parameter), the followup is allowed even under an active
58
+ # mandate.
59
+ EXCEPTION_KEYWORDS = (
60
+ ">1gb", ">1 gb", "> 1gb", "> 1 gb",
61
+ "large download", "descarga grande",
62
+ "credential", "credencial", "operator must enter",
63
+ "el operador introduce", "api key rotation",
64
+ "maría", "maria",
65
+ "nora",
66
+ "sesión presencial", "sesion presencial", "in-person",
67
+ )
68
+
69
+ PROCRASTINATION_WINDOW_DAYS = 7
70
+
71
+
72
+ @dataclass
73
+ class MandateState:
74
+ active: bool
75
+ session_id: str
76
+ set_at: float
77
+ expires_at: float
78
+ marker: str
79
+ source: str
80
+
81
+ def remaining_seconds(self, now: Optional[float] = None) -> float:
82
+ now = time.time() if now is None else now
83
+ return max(0.0, self.expires_at - now)
84
+
85
+ def to_dict(self) -> dict:
86
+ return {
87
+ "active": self.active,
88
+ "session_id": self.session_id,
89
+ "set_at": self.set_at,
90
+ "expires_at": self.expires_at,
91
+ "marker": self.marker,
92
+ "source": self.source,
93
+ }
94
+
95
+
96
+ def _detect_marker(text: str) -> Optional[str]:
97
+ if not text:
98
+ return None
99
+ lowered = text.lower()
100
+ for marker in MARKERS:
101
+ if marker in lowered:
102
+ return marker
103
+ return None
104
+
105
+
106
+ def _ensure_dir() -> None:
107
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
108
+
109
+
110
+ def load_state() -> Optional[MandateState]:
111
+ """Return the current mandate state or None if absent/expired."""
112
+ try:
113
+ raw = json.loads(STATE_PATH.read_text())
114
+ except FileNotFoundError:
115
+ return None
116
+ except (OSError, json.JSONDecodeError):
117
+ return None
118
+ try:
119
+ st = MandateState(
120
+ active=bool(raw.get("active")),
121
+ session_id=str(raw.get("session_id", "")),
122
+ set_at=float(raw.get("set_at", 0.0)),
123
+ expires_at=float(raw.get("expires_at", 0.0)),
124
+ marker=str(raw.get("marker", "")),
125
+ source=str(raw.get("source", "")),
126
+ )
127
+ except (TypeError, ValueError):
128
+ return None
129
+ if not st.active or st.expires_at <= time.time():
130
+ return None
131
+ return st
132
+
133
+
134
+ def set_mandate(
135
+ session_id: str,
136
+ marker: str,
137
+ source: str = "manual",
138
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
139
+ ) -> MandateState:
140
+ _ensure_dir()
141
+ now = time.time()
142
+ st = MandateState(
143
+ active=True,
144
+ session_id=str(session_id or "").strip(),
145
+ set_at=now,
146
+ expires_at=now + max(60, int(ttl_seconds)),
147
+ marker=marker,
148
+ source=source,
149
+ )
150
+ STATE_PATH.write_text(json.dumps(st.to_dict(), ensure_ascii=False, indent=2))
151
+ return st
152
+
153
+
154
+ def clear_mandate() -> None:
155
+ try:
156
+ STATE_PATH.unlink()
157
+ except FileNotFoundError:
158
+ pass
159
+
160
+
161
+ def maybe_ingest_from_text(
162
+ text: str,
163
+ session_id: str,
164
+ source: str = "auto",
165
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
166
+ ) -> Optional[MandateState]:
167
+ """Scan free-form text for a mandate marker and persist if found.
168
+
169
+ Used by heartbeat / hook paths that capture operator messages so the
170
+ mandate can be set transparently, without a separate explicit tool.
171
+ Returns the new state when a marker was detected, otherwise None.
172
+ """
173
+ marker = _detect_marker(text or "")
174
+ if not marker:
175
+ return None
176
+ return set_mandate(
177
+ session_id=session_id,
178
+ marker=marker,
179
+ source=source,
180
+ ttl_seconds=ttl_seconds,
181
+ )
182
+
183
+
184
+ def _description_has_exception(description: str, exception: str) -> bool:
185
+ haystack = f"{description or ''}\n{exception or ''}".lower()
186
+ return any(kw in haystack for kw in EXCEPTION_KEYWORDS)
187
+
188
+
189
+ def _parse_target_date(raw: str) -> Optional[date]:
190
+ if not raw:
191
+ return None
192
+ m = re.match(r"(\d{4})-(\d{2})-(\d{2})", raw.strip())
193
+ if not m:
194
+ return None
195
+ try:
196
+ return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
197
+ except ValueError:
198
+ return None
199
+
200
+
201
+ def _days_until(target: date, today: Optional[date] = None) -> int:
202
+ today = today or datetime.now(timezone.utc).date()
203
+ return (target - today).days
204
+
205
+
206
+ def check_followup_against_mandate(
207
+ *,
208
+ owner: str = "",
209
+ date: str = "",
210
+ description: str = "",
211
+ exception: str = "",
212
+ state: Optional[MandateState] = None,
213
+ ) -> Optional[str]:
214
+ """Return a human-readable error string when the followup should be
215
+ rejected under an active mandate, otherwise None.
216
+
217
+ The caller is responsible for turning the string into a tool-level
218
+ error. `state` is injectable for tests; production callers leave it
219
+ None so the current on-disk state is consulted.
220
+ """
221
+ st = state if state is not None else load_state()
222
+ if st is None:
223
+ return None
224
+
225
+ if _description_has_exception(description, exception):
226
+ return None
227
+
228
+ owner_norm = (owner or "").strip().lower()
229
+ offending_owner = owner_norm == "user"
230
+
231
+ target = _parse_target_date(date)
232
+ within_window = False
233
+ if target is not None:
234
+ days = _days_until(target)
235
+ if 0 <= days < PROCRASTINATION_WINDOW_DAYS:
236
+ within_window = True
237
+
238
+ if not (offending_owner or within_window):
239
+ return None
240
+
241
+ reasons = []
242
+ if offending_owner:
243
+ reasons.append("owner=user")
244
+ if within_window:
245
+ reasons.append(f"date within {PROCRASTINATION_WINDOW_DAYS} days")
246
+
247
+ lines = [
248
+ "ERROR: Autonomy mandate active — nexo_followup_create blocked.",
249
+ f"Marker: '{st.marker}' (source={st.source}, session={st.session_id or 'n/a'}).",
250
+ f"Reason: {' + '.join(reasons)}.",
251
+ "This followup looks like procrastination inside the current scope.",
252
+ "Do the work now via nexo_task_open, or promote it to a goal/workflow.",
253
+ "Exceptions that stay allowed even under the mandate:",
254
+ " - Download >1GB that must finish before the next step.",
255
+ " - Credential the operator must physically enter.",
256
+ " - Presence-dependent session with María or Nora.",
257
+ "Pass exception='<reason>' or reference one of the keywords in the",
258
+ "description to override. Do not use force='true' for this.",
259
+ ]
260
+ return "\n".join(lines)
@@ -30,6 +30,7 @@ def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
30
30
 
31
31
 
32
32
  TEMPLATES_DIR = _resolve_templates_dir(__file__)
33
+ DEFAULT_ASSISTANT_NAME = "Nova"
33
34
 
34
35
  CORE_LABEL = "******CORE******"
35
36
  USER_LABEL = "******USER******"
@@ -64,6 +65,16 @@ def _default_nexo_home() -> Path:
64
65
  return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
65
66
 
66
67
 
68
+ def _read_json_file(path: Path) -> dict:
69
+ if not path.is_file():
70
+ return {}
71
+ try:
72
+ payload = json.loads(path.read_text())
73
+ except Exception:
74
+ return {}
75
+ return payload if isinstance(payload, dict) else {}
76
+
77
+
67
78
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
68
79
  explicit = (explicit or "").strip()
69
80
  if explicit:
@@ -71,6 +82,16 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
71
82
  env_name = os.environ.get("NEXO_NAME", "").strip()
72
83
  if env_name:
73
84
  return env_name
85
+ calibration = _read_json_file(nexo_home / "personal" / "brain" / "calibration.json")
86
+ user_payload = calibration.get("user")
87
+ if isinstance(user_payload, dict):
88
+ candidate = str(user_payload.get("assistant_name", "")).strip()
89
+ if candidate:
90
+ return candidate
91
+ for key in ("assistant_name", "identity", "operator_name"):
92
+ candidate = str(calibration.get(key, "")).strip()
93
+ if candidate:
94
+ return candidate
74
95
  version_file = nexo_home / "version.json"
75
96
  if version_file.is_file():
76
97
  try:
@@ -79,7 +100,7 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
79
100
  return candidate
80
101
  except Exception:
81
102
  pass
82
- return "NEXO"
103
+ return DEFAULT_ASSISTANT_NAME
83
104
 
84
105
 
85
106
  def _read_version(text: str, pattern: str) -> str:
package/src/cli.py CHANGED
@@ -4,6 +4,7 @@
4
4
  Entry points:
5
5
  nexo chat [PATH]
6
6
  nexo export [PATH] [--json]
7
+ nexo import-inspect PATH [--json]
7
8
  nexo import PATH [--json]
8
9
  nexo scripts list [--all] [--json]
9
10
  nexo scripts create NAME [--runtime python|shell] [--description TEXT]
@@ -576,6 +577,129 @@ def _scripts_status(args):
576
577
  return 0
577
578
 
578
579
 
580
+ def _automations_list(args):
581
+ from script_registry import list_operator_automations
582
+
583
+ rows = list_operator_automations(include_all=bool(getattr(args, "all", False)))
584
+ if args.json:
585
+ print(json.dumps({"ok": True, "automations": rows}, indent=2, ensure_ascii=False))
586
+ return 0
587
+ if not rows:
588
+ print("No automations registered.")
589
+ return 0
590
+ for row in rows:
591
+ state = "enabled" if row.get("enabled", True) else "disabled"
592
+ availability = "ready" if row.get("available", True) else "setup required"
593
+ schedule = str(row.get("effective_schedule_label") or row.get("first_schedule_label") or "—")
594
+ print(f"{row.get('name')} [{state}] · {availability} · {schedule}")
595
+ return 0
596
+
597
+
598
+ def _automations_set_enabled(args, enabled):
599
+ from script_registry import set_automation_enabled
600
+
601
+ result = set_automation_enabled(args.name, enabled)
602
+ if args.json:
603
+ print(json.dumps(result, indent=2, ensure_ascii=False))
604
+ return 0 if result.get("ok") else 1
605
+ if not result.get("ok"):
606
+ print(result.get("error", "Failed to toggle automation"), file=sys.stderr)
607
+ return 1
608
+ verb = "enabled" if enabled else "disabled"
609
+ print(f"Automation {result['name']} {verb}.")
610
+ return 0
611
+
612
+
613
+ def _automations_status(args):
614
+ from script_registry import get_automation_status
615
+
616
+ result = get_automation_status(args.name)
617
+ if args.json:
618
+ print(json.dumps(result, indent=2, ensure_ascii=False))
619
+ return 0 if result.get("ok") else 1
620
+ if not result.get("ok"):
621
+ print(result.get("error", "Failed to read automation status"), file=sys.stderr)
622
+ return 1
623
+ state = "enabled" if result.get("enabled") else "DISABLED"
624
+ print(f"{result.get('name')} [{result.get('classification')}] -> {state}")
625
+ blocked_reason = (result.get("blocked_reason") or "").strip()
626
+ if blocked_reason:
627
+ print(f" blocked: {blocked_reason}")
628
+ schedule_label = (result.get("effective_schedule_label") or "").strip()
629
+ if schedule_label:
630
+ schedule_source = (result.get("schedule_source") or "manifest").strip()
631
+ print(f" schedule: {schedule_label} ({schedule_source})")
632
+ extra = (result.get("operator_extra_instructions") or "").strip()
633
+ if extra:
634
+ print(f" extra instructions: {extra[:160]}")
635
+ return 0
636
+
637
+
638
+ def _automations_set_instructions(args):
639
+ from script_registry import set_automation_instructions
640
+
641
+ if getattr(args, "clear", False):
642
+ text = ""
643
+ elif getattr(args, "stdin", False):
644
+ try:
645
+ text = sys.stdin.read()
646
+ except Exception as exc:
647
+ msg = f"Could not read instructions from stdin: {exc}"
648
+ if args.json:
649
+ print(json.dumps({"ok": False, "error": msg}, ensure_ascii=False))
650
+ else:
651
+ print(msg, file=sys.stderr)
652
+ return 1
653
+ else:
654
+ text = getattr(args, "text", None) or ""
655
+
656
+ result = set_automation_instructions(args.name, text)
657
+ if args.json:
658
+ print(json.dumps(result, indent=2, ensure_ascii=False))
659
+ return 0 if result.get("ok") else 1
660
+ if not result.get("ok"):
661
+ print(result.get("error", "Failed to update automation instructions"), file=sys.stderr)
662
+ return 1
663
+ if result.get("cleared"):
664
+ print(f"Extra instructions cleared for {result['name']}.")
665
+ else:
666
+ print(f"Extra instructions updated for {result['name']}.")
667
+ return 0
668
+
669
+
670
+ def _automations_set_schedule(args):
671
+ from script_registry import set_automation_schedule
672
+
673
+ interval_seconds = None
674
+ daily_at = None
675
+ if getattr(args, "every_minutes", None) is not None:
676
+ interval_seconds = int(args.every_minutes) * 60
677
+ elif getattr(args, "every_seconds", None) is not None:
678
+ interval_seconds = int(args.every_seconds)
679
+ elif getattr(args, "daily_at", None):
680
+ daily_at = str(args.daily_at).strip()
681
+
682
+ result = set_automation_schedule(
683
+ args.name,
684
+ interval_seconds=interval_seconds,
685
+ daily_at=daily_at,
686
+ clear=bool(getattr(args, "reset", False)),
687
+ )
688
+ if args.json:
689
+ print(json.dumps(result, indent=2, ensure_ascii=False))
690
+ return 0 if result.get("ok") else 1
691
+ if not result.get("ok"):
692
+ print(result.get("error", "Failed to update automation cadence"), file=sys.stderr)
693
+ return 1
694
+ label = str(result.get("effective_schedule_label") or "").strip()
695
+ source = str(result.get("schedule_source") or "manifest").strip()
696
+ if label:
697
+ print(f"Schedule updated for {result['name']}: {label} ({source})")
698
+ else:
699
+ print(f"Schedule updated for {result['name']}.")
700
+ return 0
701
+
702
+
579
703
  def _scripts_remove(args):
580
704
  from script_registry import remove_personal_script
581
705
 
@@ -734,6 +858,27 @@ def _import_bundle(args):
734
858
  return 0 if result.get("ok") else 1
735
859
 
736
860
 
861
+ def _inspect_bundle(args):
862
+ from user_data_portability import inspect_user_bundle
863
+
864
+ result = inspect_user_bundle(args.path)
865
+ if args.json:
866
+ print(json.dumps(result, indent=2, ensure_ascii=False))
867
+ else:
868
+ if not result.get("ok"):
869
+ print(result.get("error", "Inspect failed"), file=sys.stderr)
870
+ return 1
871
+ sections = result.get("section_names", [])
872
+ print(f"Bundle ready: {result['path']}")
873
+ print(f" Bundle version: {result.get('bundle_version', '?')}")
874
+ print(f" Current runtime: {result.get('current_version', '?')}")
875
+ print(f" Sections: {', '.join(sections) if sections else '(none)'}")
876
+ warnings = result.get("warning_codes", [])
877
+ if warnings:
878
+ print(f" Warnings: {', '.join(warnings)}")
879
+ return 0 if result.get("ok") else 1
880
+
881
+
737
882
  def _runtime_python_candidates() -> list[str]:
738
883
  candidates: list[str] = []
739
884
  seen: set[str] = set()
@@ -2198,6 +2343,7 @@ def _print_help():
2198
2343
  Commands:
2199
2344
  nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
2200
2345
  nexo export [path] Export a portable user-data bundle
2346
+ nexo import-inspect PATH Inspect a portable user-data bundle
2201
2347
  nexo import PATH Import a portable user-data bundle
2202
2348
  nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
2203
2349
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|schedule|run|doctor|call|unschedule|remove
@@ -2241,6 +2387,11 @@ def main():
2241
2387
  export_parser.add_argument("path", nargs="?", default="", help="Output bundle path (default: NEXO_HOME/exports/...)")
2242
2388
  export_parser.add_argument("--json", action="store_true", help="JSON output")
2243
2389
 
2390
+ # -- import inspect --
2391
+ inspect_parser = sub.add_parser("import-inspect", help="Inspect a portable user-data bundle")
2392
+ inspect_parser.add_argument("path", help="Bundle path created by `nexo export`")
2393
+ inspect_parser.add_argument("--json", action="store_true", help="JSON output")
2394
+
2244
2395
  # -- import --
2245
2396
  import_parser = sub.add_parser("import", help="Import a portable user-data bundle")
2246
2397
  import_parser.add_argument("path", help="Bundle path created by `nexo export`")
@@ -2264,7 +2415,7 @@ def main():
2264
2415
  create_p.add_argument("--json", action="store_true", help="JSON output")
2265
2416
 
2266
2417
  # scripts classify
2267
- classify_p = scripts_sub.add_parser("classify", help="Classify all files in NEXO_HOME/scripts")
2418
+ classify_p = scripts_sub.add_parser("classify", help="Classify all files in NEXO_HOME/personal/scripts")
2268
2419
  classify_p.add_argument("--json", action="store_true", help="JSON output")
2269
2420
 
2270
2421
  # scripts sync
@@ -2348,6 +2499,48 @@ def main():
2348
2499
  call_p.add_argument("--input", default="{}", help="JSON input payload")
2349
2500
  call_p.add_argument("--json-output", action="store_true", help="Force JSON output")
2350
2501
 
2502
+ automations_parser = sub.add_parser("automations", help="Manage Desktop-facing automations")
2503
+ automations_sub = automations_parser.add_subparsers(dest="automations_command")
2504
+
2505
+ automations_list_p = automations_sub.add_parser("list", help="List operator-facing automations")
2506
+ automations_list_p.add_argument("--all", action="store_true", help="Include support/debug automation rows as well")
2507
+ automations_list_p.add_argument("--json", action="store_true", help="JSON output")
2508
+
2509
+ automations_enable_p = automations_sub.add_parser("enable", help="Enable an automation")
2510
+ automations_enable_p.add_argument("name", help="Automation name or path")
2511
+ automations_enable_p.add_argument("--json", action="store_true", help="JSON output")
2512
+
2513
+ automations_disable_p = automations_sub.add_parser("disable", help="Disable an automation")
2514
+ automations_disable_p.add_argument("name", help="Automation name or path")
2515
+ automations_disable_p.add_argument("--json", action="store_true", help="JSON output")
2516
+
2517
+ automations_status_p = automations_sub.add_parser("status", help="Read automation status")
2518
+ automations_status_p.add_argument("name", help="Automation name or path")
2519
+ automations_status_p.add_argument("--json", action="store_true", help="JSON output")
2520
+
2521
+ automations_instructions_p = automations_sub.add_parser(
2522
+ "instructions",
2523
+ help="Set or clear operator extra instructions for an automation",
2524
+ )
2525
+ automations_instructions_p.add_argument("name", help="Automation name or path")
2526
+ automations_instructions_group = automations_instructions_p.add_mutually_exclusive_group(required=True)
2527
+ automations_instructions_group.add_argument("--text", help="Instruction text to persist")
2528
+ automations_instructions_group.add_argument("--stdin", action="store_true", help="Read instruction text from stdin")
2529
+ automations_instructions_group.add_argument("--clear", action="store_true", help="Clear any persisted extra instructions")
2530
+ automations_instructions_p.add_argument("--json", action="store_true", help="JSON output")
2531
+
2532
+ automations_schedule_p = automations_sub.add_parser(
2533
+ "schedule",
2534
+ help="Change the cadence of an operator-facing automation",
2535
+ )
2536
+ automations_schedule_p.add_argument("name", help="Automation name or path")
2537
+ automations_schedule_group = automations_schedule_p.add_mutually_exclusive_group(required=True)
2538
+ automations_schedule_group.add_argument("--every-minutes", type=int, help="Run the automation every N minutes")
2539
+ automations_schedule_group.add_argument("--every-seconds", type=int, help="Run the automation every N seconds")
2540
+ automations_schedule_group.add_argument("--daily-at", type=str, help="Run the automation every day at HH:MM (24h)")
2541
+ automations_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
2542
+ automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
2543
+
2351
2544
  # -- update --
2352
2545
  update_parser = sub.add_parser("update", help="Update installed runtime")
2353
2546
  update_parser.add_argument("--json", action="store_true", help="JSON output")
@@ -2638,10 +2831,27 @@ def main():
2638
2831
  else:
2639
2832
  scripts_parser.print_help()
2640
2833
  return 0
2834
+ elif args.command == "automations":
2835
+ if args.automations_command == "list":
2836
+ return _automations_list(args)
2837
+ elif args.automations_command == "enable":
2838
+ return _automations_set_enabled(args, True)
2839
+ elif args.automations_command == "disable":
2840
+ return _automations_set_enabled(args, False)
2841
+ elif args.automations_command == "status":
2842
+ return _automations_status(args)
2843
+ elif args.automations_command == "instructions":
2844
+ return _automations_set_instructions(args)
2845
+ elif args.automations_command == "schedule":
2846
+ return _automations_set_schedule(args)
2847
+ automations_parser.print_help()
2848
+ return 0
2641
2849
  elif args.command == "chat":
2642
2850
  return _chat(args)
2643
2851
  elif args.command == "export":
2644
2852
  return _export_bundle(args)
2853
+ elif args.command == "import-inspect":
2854
+ return _inspect_bundle(args)
2645
2855
  elif args.command == "import":
2646
2856
  return _import_bundle(args)
2647
2857
  elif args.command == "update":