nexo-brain 5.3.13 → 5.3.15

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 (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -1
  3. package/package.json +1 -1
  4. package/src/crons/sync.py +18 -4
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_entities.py +1 -1
  43. package/src/db/_episodic 2.py +762 -0
  44. package/src/db/_evolution 2.py +54 -0
  45. package/src/db/_fts 2.py +406 -0
  46. package/src/db/_goal_profiles 2.py +376 -0
  47. package/src/db/_hot_context 2.py +660 -0
  48. package/src/db/_outcomes 2.py +800 -0
  49. package/src/db/_personal_scripts 2.py +582 -0
  50. package/src/db/_sessions 2.py +330 -0
  51. package/src/db/_tasks 2.py +91 -0
  52. package/src/db/_watchers 2.py +173 -0
  53. package/src/doctor/formatters 2.py +52 -0
  54. package/src/doctor/models 2.py +69 -0
  55. package/src/doctor/planes 2.py +87 -0
  56. package/src/doctor/providers/__init__ 2.py +1 -0
  57. package/src/doctor/providers/deep 2.py +367 -0
  58. package/src/evolution_cycle 2.py +519 -0
  59. package/src/hooks/auto_capture 2.py +208 -0
  60. package/src/hooks/caffeinate-guard 2.sh +8 -0
  61. package/src/hooks/capture-session 2.sh +21 -0
  62. package/src/hooks/capture-tool-logs 2.sh +158 -0
  63. package/src/hooks/daily-briefing-check 2.sh +33 -0
  64. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  65. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  66. package/src/hooks/inbox-hook 2.sh +76 -0
  67. package/src/hooks/post-compact 2.sh +152 -0
  68. package/src/hooks/pre-compact 2.sh +169 -0
  69. package/src/hooks/protocol-guardrail 2.sh +10 -0
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  71. package/src/hooks/session-stop 2.sh +52 -0
  72. package/src/kg_populate 2.py +292 -0
  73. package/src/maintenance 2.py +53 -0
  74. package/src/memory_backends 2.py +71 -0
  75. package/src/migrate_embeddings 2.py +124 -0
  76. package/src/nexo_sdk 2.py +103 -0
  77. package/src/observability 2.py +199 -0
  78. package/src/plugin_loader 2.py +217 -0
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/agents.py +10 -3
  81. package/src/plugins/artifact_registry 2.py +450 -0
  82. package/src/plugins/backup 2.py +127 -0
  83. package/src/plugins/claims_tools 2.py +119 -0
  84. package/src/plugins/cognitive_memory 2.py +609 -0
  85. package/src/plugins/core_rules 2.py +252 -0
  86. package/src/plugins/cortex 2.py +1155 -0
  87. package/src/plugins/entities 2.py +67 -0
  88. package/src/plugins/episodic_memory 2.py +560 -0
  89. package/src/plugins/evolution 2.py +167 -0
  90. package/src/plugins/goal_engine 2.py +142 -0
  91. package/src/plugins/guard 2.py +862 -0
  92. package/src/plugins/impact 2.py +29 -0
  93. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  94. package/src/plugins/media_memory_tools 2.py +98 -0
  95. package/src/plugins/memory_export 2.py +196 -0
  96. package/src/plugins/outcomes 2.py +130 -0
  97. package/src/plugins/personal_scripts 2.py +117 -0
  98. package/src/plugins/preferences 2.py +47 -0
  99. package/src/plugins/protocol 2.py +1449 -0
  100. package/src/plugins/schedule.py +2 -1
  101. package/src/plugins/simple_api 2.py +106 -0
  102. package/src/plugins/skills 2.py +341 -0
  103. package/src/plugins/state_watchers 2.py +79 -0
  104. package/src/plugins/update 2.py +986 -0
  105. package/src/plugins/user_state_tools 2.py +43 -0
  106. package/src/plugins/workflow 2.py +588 -0
  107. package/src/protocol_settings 2.py +59 -0
  108. package/src/public_contribution 2.py +466 -0
  109. package/src/public_evolution_queue 2.py +241 -0
  110. package/src/requirements 2.txt +14 -0
  111. package/src/requirements.txt +1 -1
  112. package/src/retroactive_learnings 2.py +373 -0
  113. package/src/rules/__init__ 2.py +0 -0
  114. package/src/rules/core-rules 2.json +331 -0
  115. package/src/rules/migrate 2.py +207 -0
  116. package/src/runtime_power 2.py +874 -0
  117. package/src/runtime_power.py +18 -1
  118. package/src/script_registry 2.py +1559 -0
  119. package/src/scripts/check-context 2.py +272 -0
  120. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  121. package/src/scripts/deep-sleep/collect 2.py +928 -0
  122. package/src/scripts/deep-sleep/extract 2.py +330 -0
  123. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  124. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  125. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  126. package/src/scripts/nexo-agent-run 2.py +75 -0
  127. package/src/scripts/nexo-auto-update 2.py +6 -0
  128. package/src/scripts/nexo-backup 2.sh +25 -0
  129. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  130. package/src/scripts/nexo-catchup 2.py +300 -0
  131. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  132. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  133. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  134. package/src/scripts/nexo-cron-wrapper.sh +7 -0
  135. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  136. package/src/scripts/nexo-dashboard 2.sh +29 -0
  137. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  138. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  139. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  140. package/src/scripts/nexo-hook-record 2.py +42 -0
  141. package/src/scripts/nexo-immune 2.py +936 -0
  142. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  143. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  144. package/src/scripts/nexo-install 2.py +6 -0
  145. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  146. package/src/scripts/nexo-learning-validator 2.py +266 -0
  147. package/src/scripts/nexo-migrate 2.py +260 -0
  148. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  149. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  150. package/src/scripts/nexo-pre-commit 2.py +120 -0
  151. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  152. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  153. package/src/scripts/nexo-reflection 2.py +256 -0
  154. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  155. package/src/scripts/nexo-sleep 2.py +631 -0
  156. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  157. package/src/scripts/nexo-sync-clients 2.py +16 -0
  158. package/src/scripts/nexo-synthesis 2.py +475 -0
  159. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  160. package/src/scripts/nexo-update 2.sh +306 -0
  161. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  162. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  163. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  164. package/src/server 2.py +1296 -0
  165. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  166. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  167. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  168. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  169. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  170. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  171. package/src/skills/run-release-final-audit/script 2.py +259 -0
  172. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  173. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  174. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  175. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  176. package/src/skills_runtime 2.py +932 -0
  177. package/src/state_watchers_runtime 2.py +475 -0
  178. package/src/storage_router 2.py +32 -0
  179. package/src/system_catalog 2.py +786 -0
  180. package/src/tools_coordination 2.py +103 -0
  181. package/src/tools_credentials 2.py +68 -0
  182. package/src/tools_drive 2.py +487 -0
  183. package/src/tools_hot_context 2.py +163 -0
  184. package/src/tools_learnings 2.py +612 -0
  185. package/src/tools_menu 2.py +229 -0
  186. package/src/tools_reminders 2.py +88 -0
  187. package/src/tools_reminders_crud 2.py +363 -0
  188. package/src/tools_sessions 2.py +1054 -0
  189. package/src/tools_system_catalog 2.py +19 -0
  190. package/src/tools_task_history 2.py +57 -0
  191. package/src/tools_transcripts 2.py +98 -0
  192. package/src/transcript_utils 2.py +412 -0
  193. package/src/user_context 2.py +46 -0
  194. package/src/user_data_portability 2.py +328 -0
  195. package/src/user_state_model 2.py +170 -0
  196. package/templates/CLAUDE.md 2.template +108 -0
  197. package/templates/CODEX.AGENTS.md 2.template +66 -0
  198. package/templates/launchagents/README 2.md +132 -0
  199. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  200. package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
  201. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  202. package/templates/launchagents/com.nexo.catchup.plist +1 -1
  203. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  204. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  205. package/templates/launchagents/com.nexo.dashboard.plist +1 -1
  206. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  207. package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
  208. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  209. package/templates/launchagents/com.nexo.evolution.plist +1 -1
  210. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  211. package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
  212. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  213. package/templates/launchagents/com.nexo.immune.plist +1 -1
  214. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  215. package/templates/launchagents/com.nexo.postmortem.plist +1 -1
  216. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  217. package/templates/launchagents/com.nexo.self-audit.plist +1 -1
  218. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  219. package/templates/launchagents/com.nexo.synthesis.plist +1 -1
  220. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  221. package/templates/launchagents/com.nexo.watchdog.plist +1 -1
  222. package/templates/nexo_helper 2.py +301 -0
  223. package/templates/openclaw 2.json +13 -0
  224. package/templates/plugin-template 2.py +40 -0
  225. package/templates/script-template 2.py +59 -0
  226. package/templates/script-template 2.sh +13 -0
  227. package/templates/script-template.py +5 -4
  228. package/templates/skill-script-template 2.py +48 -0
  229. package/templates/skill-script-template.py +2 -1
  230. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,1559 @@
1
+ """NEXO Script Registry — discovery, metadata, validation for personal scripts.
2
+
3
+ Scripts live in NEXO_HOME/scripts/. Core scripts (from manifest) are filtered by default.
4
+ Personal scripts use CLI as stable interface, never direct DB access.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import platform
12
+ import plistlib
13
+ import re
14
+ import shutil
15
+ import stat
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from runtime_home import export_resolved_nexo_home
20
+
21
+ NEXO_HOME = export_resolved_nexo_home()
22
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
23
+
24
+ # Internal artifacts to always ignore
25
+ _IGNORED_FILES = {
26
+ ".watchdog-hashes",
27
+ ".watchdog-fails",
28
+ ".watchdog-nexo-repair.lock",
29
+ "nexo-cron-wrapper.sh",
30
+ "nexo-dashboard.sh",
31
+ "nexo-prevent-sleep.sh",
32
+ "nexo-proactive-dashboard.py",
33
+ "nexo-tcc-approve.sh",
34
+ }
35
+ _IGNORED_DIRS = {"deep-sleep", "__pycache__"}
36
+
37
+ _LEGACY_WAKE_RECOVERY_METADATA = [
38
+ "# nexo: name=nexo-wake-recovery",
39
+ "# nexo: description=Recover interval LaunchAgents after macOS sleep/wake gaps",
40
+ "# nexo: runtime=shell",
41
+ "# nexo: cron_id=wake-recovery",
42
+ "# nexo: schedule_required=true",
43
+ "# nexo: recovery_policy=restart_daemon",
44
+ "# nexo: run_on_boot=true",
45
+ ]
46
+
47
+ _LEGACY_CORE_RUNTIME_FILES = {
48
+ "capture-tool-logs.sh",
49
+ "daily-briefing-check.sh",
50
+ "heartbeat-enforcement.py",
51
+ "heartbeat-posttool.sh",
52
+ "heartbeat-user-msg.sh",
53
+ "nexo-memory-precompact.sh",
54
+ "nexo-memory-stop.sh",
55
+ "nexo-postcompact.sh",
56
+ "nexo-session-briefing.sh",
57
+ }
58
+
59
+ # Forbidden patterns — direct DB access from personal scripts
60
+ _FORBIDDEN_PATTERNS = [
61
+ re.compile(r"\bsqlite3\b"),
62
+ re.compile(r"\bnexo\.db\b"),
63
+ re.compile(r"\bcognitive\.db\b"),
64
+ re.compile(r"/data/nexo\.db"),
65
+ re.compile(r"/data/cognitive\.db"),
66
+ re.compile(r"\bimport\s+db\b"),
67
+ re.compile(r"\bfrom\s+db\s+import\b"),
68
+ re.compile(r"\bimport\s+cognitive\b"),
69
+ re.compile(r"\bfrom\s+cognitive\s+import\b"),
70
+ ]
71
+
72
+ METADATA_KEYS = {
73
+ "name",
74
+ "description",
75
+ "runtime",
76
+ "timeout",
77
+ "requires",
78
+ "tools",
79
+ "hidden",
80
+ "category",
81
+ "cron_id",
82
+ "schedule",
83
+ "interval_seconds",
84
+ "schedule_required",
85
+ "recovery_policy",
86
+ "run_on_boot",
87
+ "run_on_wake",
88
+ "idempotent",
89
+ "max_catchup_age",
90
+ "doctor_allow_db",
91
+ }
92
+ SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
93
+ PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
94
+ SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
95
+ PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
96
+ _LEGACY_CORE_SCRIPT_ALIASES = {
97
+ "nexo-postcompact.sh": "post-compact.sh",
98
+ "nexo-memory-precompact.sh": "pre-compact.sh",
99
+ "nexo-memory-stop.sh": "session-stop.sh",
100
+ "nexo-session-briefing.sh": "session-start.sh",
101
+ }
102
+
103
+
104
+ def get_nexo_home() -> Path:
105
+ return NEXO_HOME
106
+
107
+
108
+ def get_scripts_dir() -> Path:
109
+ return NEXO_HOME / "scripts"
110
+
111
+
112
+ def _apply_legacy_personal_script_backfills() -> None:
113
+ """Backfill metadata for known legacy personal scripts shipped before the registry existed."""
114
+ scripts_dir = get_scripts_dir()
115
+ wake_recovery = scripts_dir / "nexo-wake-recovery.sh"
116
+ if not wake_recovery.is_file():
117
+ return
118
+
119
+ try:
120
+ text = wake_recovery.read_text()
121
+ except Exception:
122
+ return
123
+
124
+ if "# nexo:" in "\n".join(text.splitlines()[:25]):
125
+ return
126
+ if "Wake Recovery" not in text:
127
+ return
128
+
129
+ lines = text.splitlines(keepends=True)
130
+ head: list[str] = []
131
+ start = 0
132
+ if lines and lines[0].startswith("#!"):
133
+ head.append(lines[0])
134
+ start = 1
135
+ head.extend([line + "\n" for line in _LEGACY_WAKE_RECOVERY_METADATA])
136
+ wake_recovery.write_text("".join(head + lines[start:]))
137
+
138
+
139
+ def _add_runtime_artifact_names(names: set[str], artifact_path: Path) -> None:
140
+ try:
141
+ data = json.loads(artifact_path.read_text())
142
+ except Exception:
143
+ return
144
+ for key in ("script_names", "hook_names"):
145
+ for item in data.get(key, []):
146
+ if isinstance(item, str) and item.strip():
147
+ names.add(Path(item).name)
148
+
149
+
150
+ def _add_filenames_from_dir(names: set[str], directory: Path, *, skip_if_scripts_dir: bool = False) -> None:
151
+ if not directory.is_dir():
152
+ return
153
+ if skip_if_scripts_dir:
154
+ try:
155
+ if directory.resolve() == get_scripts_dir().resolve():
156
+ return
157
+ except Exception:
158
+ pass
159
+ for item in directory.iterdir():
160
+ if item.is_file() and not item.name.startswith("."):
161
+ names.add(item.name)
162
+
163
+
164
+ def _find_packaged_core_source_dir() -> Path | None:
165
+ repo_root = NEXO_CODE.parent
166
+ if (repo_root / ".git").exists() or (repo_root / ".git").is_file():
167
+ return None
168
+
169
+ with contextlib.suppress(Exception):
170
+ result = subprocess.run(
171
+ ["npm", "root", "-g"],
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=10,
175
+ )
176
+ if result.returncode == 0:
177
+ candidate = Path(result.stdout.strip()) / "nexo-brain" / "src"
178
+ if candidate.is_dir():
179
+ return candidate
180
+ return None
181
+
182
+
183
+ def load_core_script_names() -> set[str]:
184
+ """Load every core-managed runtime artifact name that must never be treated as personal."""
185
+ names: set[str] = set()
186
+ packaged_src = _find_packaged_core_source_dir()
187
+
188
+ manifest_candidates = []
189
+ if packaged_src is not None:
190
+ manifest_candidates.append(packaged_src / "crons" / "manifest.json")
191
+ manifest_candidates.extend([NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"])
192
+
193
+ for manifest_path in manifest_candidates:
194
+ if manifest_path.exists():
195
+ try:
196
+ data = json.loads(manifest_path.read_text())
197
+ for cron in data.get("crons", []):
198
+ script = cron.get("script", "")
199
+ # script is like "scripts/nexo-immune.py" — extract filename
200
+ names.add(Path(script).name)
201
+ break
202
+ except Exception:
203
+ continue
204
+
205
+ if packaged_src is not None:
206
+ _add_filenames_from_dir(names, packaged_src / "hooks")
207
+ _add_filenames_from_dir(names, packaged_src / "scripts")
208
+ else:
209
+ for artifact_path in (
210
+ NEXO_HOME / "config" / "runtime-core-artifacts.json",
211
+ NEXO_CODE / "config" / "runtime-core-artifacts.json",
212
+ NEXO_CODE.parent / "config" / "runtime-core-artifacts.json",
213
+ ):
214
+ if artifact_path.exists():
215
+ _add_runtime_artifact_names(names, artifact_path)
216
+
217
+ _add_filenames_from_dir(names, NEXO_HOME / "hooks")
218
+ _add_filenames_from_dir(names, NEXO_CODE / "hooks")
219
+ _add_filenames_from_dir(names, NEXO_CODE / "scripts", skip_if_scripts_dir=True)
220
+
221
+ for legacy_name, canonical_name in _LEGACY_CORE_SCRIPT_ALIASES.items():
222
+ if canonical_name in names:
223
+ names.add(legacy_name)
224
+ names.update(_LEGACY_CORE_RUNTIME_FILES)
225
+ return names
226
+
227
+
228
+ def parse_inline_metadata(path: Path) -> dict:
229
+ """Parse inline metadata from first 25 lines.
230
+
231
+ Supported comment prefixes:
232
+ - # nexo:
233
+ - // nexo:
234
+ """
235
+ meta: dict[str, str] = {}
236
+ try:
237
+ lines = path.read_text(errors="ignore").splitlines()[:25]
238
+ except Exception:
239
+ return meta
240
+
241
+ for line in lines:
242
+ stripped = line.strip()
243
+ payload = ""
244
+ if stripped.startswith("# nexo:"):
245
+ payload = stripped[len("# nexo:"):].strip()
246
+ elif stripped.startswith("// nexo:"):
247
+ payload = stripped[len("// nexo:"):].strip()
248
+ else:
249
+ continue
250
+ if "=" not in payload:
251
+ continue
252
+ key, value = payload.split("=", 1)
253
+ k = key.strip()
254
+ if k in METADATA_KEYS:
255
+ meta[k] = value.strip()
256
+ return meta
257
+
258
+
259
+ def _detect_shebang(path: Path) -> str | None:
260
+ """Read first line for shebang."""
261
+ try:
262
+ first = path.read_text(errors="ignore").split("\n", 1)[0]
263
+ if first.startswith("#!"):
264
+ return first
265
+ except Exception:
266
+ pass
267
+ return None
268
+
269
+
270
+ def classify_runtime(path: Path, metadata: dict) -> str:
271
+ """Detect script runtime: python, shell, node, php, or unknown."""
272
+ # 1. Metadata
273
+ rt = metadata.get("runtime", "").lower()
274
+ if rt in ("python", "shell", "node", "php"):
275
+ return rt
276
+
277
+ # 2. Shebang
278
+ shebang = _detect_shebang(path)
279
+ if shebang:
280
+ if "python" in shebang:
281
+ return "python"
282
+ if "bash" in shebang or "/sh" in shebang:
283
+ return "shell"
284
+ if "node" in shebang:
285
+ return "node"
286
+ if "php" in shebang:
287
+ return "php"
288
+
289
+ # 3. Extension
290
+ ext = path.suffix.lower()
291
+ if ext == ".py":
292
+ return "python"
293
+ if ext == ".sh":
294
+ return "shell"
295
+ if ext == ".js":
296
+ return "node"
297
+ if ext == ".php":
298
+ return "php"
299
+
300
+ return "unknown"
301
+
302
+
303
+ def _is_ignored(path: Path) -> bool:
304
+ """Check if file should be ignored entirely."""
305
+ if path.name in _IGNORED_FILES:
306
+ return True
307
+ if path.name.startswith("."):
308
+ return True
309
+ try:
310
+ relative_path = path.resolve().relative_to(get_scripts_dir().resolve())
311
+ except Exception:
312
+ return False
313
+ for parent in relative_path.parents:
314
+ if parent.name in _IGNORED_DIRS:
315
+ return True
316
+ return False
317
+
318
+
319
+ def _is_script_candidate(path: Path, metadata: dict | None = None) -> bool:
320
+ metadata = metadata or {}
321
+ runtime = classify_runtime(path, metadata)
322
+ if runtime != "unknown":
323
+ return True
324
+ if _detect_shebang(path):
325
+ return True
326
+ try:
327
+ return os.access(path, os.X_OK)
328
+ except Exception:
329
+ return False
330
+
331
+
332
+ def _truthy(value: str | bool | None) -> bool:
333
+ if isinstance(value, bool):
334
+ return value
335
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
336
+
337
+
338
+ def _safe_slug(value: str) -> str:
339
+ chars: list[str] = []
340
+ for ch in value.lower():
341
+ if ch.isalnum():
342
+ chars.append(ch)
343
+ elif ch in {"-", "_", " "}:
344
+ chars.append("-")
345
+ slug = "".join(chars).strip("-")
346
+ return slug or "script"
347
+
348
+
349
+ def has_personal_script_filename_prefix(value: str) -> bool:
350
+ return _safe_slug(value).startswith(PERSONAL_SCRIPT_FILENAME_PREFIX)
351
+
352
+
353
+ def _logical_personal_script_name(name: str) -> str:
354
+ slug = _safe_slug(name)
355
+ if slug.startswith(PERSONAL_SCRIPT_FILENAME_PREFIX):
356
+ slug = slug[len(PERSONAL_SCRIPT_FILENAME_PREFIX):]
357
+ return slug or "personal-script"
358
+
359
+
360
+ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
361
+ """Parse desired schedule metadata from inline script metadata."""
362
+ explicit_name = metadata.get("name", "").strip()
363
+ explicit_runtime = metadata.get("runtime", "").strip().lower()
364
+ explicit_cron_id = metadata.get("cron_id", "").strip()
365
+ cron_id = explicit_cron_id or _safe_slug(default_name or explicit_name or "script")
366
+ interval_raw = metadata.get("interval_seconds", "").strip()
367
+ schedule_raw = metadata.get("schedule", "").strip()
368
+ schedule_required = _truthy(metadata.get("schedule_required"))
369
+ recovery_policy_raw = metadata.get("recovery_policy", "").strip().lower()
370
+ run_on_boot = _truthy(metadata.get("run_on_boot"))
371
+ run_on_wake = _truthy(metadata.get("run_on_wake"))
372
+ idempotent = _truthy(metadata.get("idempotent"))
373
+ max_catchup_age_raw = metadata.get("max_catchup_age", "").strip()
374
+ required = schedule_required or bool(interval_raw or schedule_raw)
375
+
376
+ if recovery_policy_raw and recovery_policy_raw not in SUPPORTED_RECOVERY_POLICIES:
377
+ return {
378
+ "required": required,
379
+ "valid": False,
380
+ "error": f"Invalid recovery_policy: {recovery_policy_raw}",
381
+ "cron_id": cron_id,
382
+ }
383
+
384
+ max_catchup_age = 0
385
+ if max_catchup_age_raw:
386
+ try:
387
+ max_catchup_age = int(max_catchup_age_raw)
388
+ except ValueError:
389
+ return {
390
+ "required": required,
391
+ "valid": False,
392
+ "error": f"Invalid max_catchup_age: {max_catchup_age_raw}",
393
+ "cron_id": cron_id,
394
+ }
395
+ if max_catchup_age < 0:
396
+ return {
397
+ "required": required,
398
+ "valid": False,
399
+ "error": f"max_catchup_age must be >= 0 (got {max_catchup_age_raw})",
400
+ "cron_id": cron_id,
401
+ }
402
+
403
+ if required:
404
+ missing = []
405
+ if not explicit_name:
406
+ missing.append("name")
407
+ if not explicit_runtime:
408
+ missing.append("runtime")
409
+ elif explicit_runtime not in SUPPORTED_RUNTIMES - {"unknown"}:
410
+ return {
411
+ "required": required,
412
+ "valid": False,
413
+ "error": f"Invalid runtime metadata for scheduled script: {explicit_runtime}",
414
+ "cron_id": cron_id,
415
+ }
416
+ if not explicit_cron_id:
417
+ missing.append("cron_id")
418
+ if not schedule_required:
419
+ missing.append("schedule_required=true")
420
+ if missing:
421
+ return {
422
+ "required": required,
423
+ "valid": False,
424
+ "error": f"Scheduled scripts must declare {', '.join(missing)}",
425
+ "cron_id": cron_id,
426
+ }
427
+
428
+ def _effective_run_on_boot(policy: str) -> bool:
429
+ if "run_on_boot" in metadata:
430
+ return run_on_boot
431
+ return policy == "restart_daemon"
432
+
433
+ def _effective_run_on_wake(policy: str) -> bool:
434
+ if "run_on_wake" in metadata:
435
+ return run_on_wake
436
+ return policy in {"catchup", "run_once_on_wake"}
437
+
438
+ def _effective_idempotent(policy: str) -> bool:
439
+ if "idempotent" in metadata:
440
+ return idempotent
441
+ return policy in {"catchup", "run_once_on_wake", "restart", "restart_daemon"}
442
+
443
+ if interval_raw and schedule_raw:
444
+ return {
445
+ "required": required,
446
+ "valid": False,
447
+ "error": "Both schedule and interval_seconds are set; choose one.",
448
+ "cron_id": cron_id,
449
+ }
450
+
451
+ if interval_raw:
452
+ try:
453
+ interval = int(interval_raw)
454
+ except ValueError:
455
+ return {
456
+ "required": required,
457
+ "valid": False,
458
+ "error": f"Invalid interval_seconds: {interval_raw}",
459
+ "cron_id": cron_id,
460
+ }
461
+ if interval <= 0:
462
+ return {
463
+ "required": required,
464
+ "valid": False,
465
+ "error": f"interval_seconds must be > 0 (got {interval_raw})",
466
+ "cron_id": cron_id,
467
+ }
468
+ return {
469
+ "required": required,
470
+ "valid": True,
471
+ "cron_id": cron_id,
472
+ "schedule_type": "interval",
473
+ "schedule_value": str(interval),
474
+ "schedule_label": f"every {interval}s",
475
+ "schedule": "",
476
+ "interval_seconds": interval,
477
+ "recovery_policy": recovery_policy_raw or "run_once_on_wake",
478
+ "run_on_boot": run_on_boot,
479
+ "run_on_wake": _effective_run_on_wake(recovery_policy_raw or "run_once_on_wake"),
480
+ "idempotent": _effective_idempotent(recovery_policy_raw or "run_once_on_wake"),
481
+ "max_catchup_age": max_catchup_age or max(interval * 4, interval + 900),
482
+ }
483
+
484
+ if schedule_raw:
485
+ parts = schedule_raw.split(":")
486
+ if len(parts) not in {2, 3}:
487
+ return {
488
+ "required": required,
489
+ "valid": False,
490
+ "error": f"Invalid schedule format: {schedule_raw}",
491
+ "cron_id": cron_id,
492
+ }
493
+ try:
494
+ hour = int(parts[0])
495
+ minute = int(parts[1])
496
+ weekday = int(parts[2]) if len(parts) == 3 else None
497
+ except ValueError:
498
+ return {
499
+ "required": required,
500
+ "valid": False,
501
+ "error": f"Invalid schedule format: {schedule_raw}",
502
+ "cron_id": cron_id,
503
+ }
504
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
505
+ return {
506
+ "required": required,
507
+ "valid": False,
508
+ "error": f"Invalid schedule time: {schedule_raw}",
509
+ "cron_id": cron_id,
510
+ }
511
+ if weekday is not None and not (0 <= weekday <= 6):
512
+ return {
513
+ "required": required,
514
+ "valid": False,
515
+ "error": f"Invalid schedule weekday: {schedule_raw}",
516
+ "cron_id": cron_id,
517
+ }
518
+ label = f"{hour:02d}:{minute:02d}"
519
+ if weekday is not None:
520
+ label += f" weekday={weekday}"
521
+ else:
522
+ label += " daily"
523
+ return {
524
+ "required": required,
525
+ "valid": True,
526
+ "cron_id": cron_id,
527
+ "schedule_type": "calendar",
528
+ "schedule_value": schedule_raw,
529
+ "schedule_label": label,
530
+ "schedule": schedule_raw,
531
+ "interval_seconds": 0,
532
+ "recovery_policy": recovery_policy_raw or "catchup",
533
+ "run_on_boot": _effective_run_on_boot(recovery_policy_raw or "catchup"),
534
+ "run_on_wake": _effective_run_on_wake(recovery_policy_raw or "catchup"),
535
+ "idempotent": _effective_idempotent(recovery_policy_raw or "catchup"),
536
+ "max_catchup_age": max_catchup_age or (14 * 86400 if weekday is not None else 48 * 3600),
537
+ }
538
+
539
+ if required and recovery_policy_raw == "restart_daemon":
540
+ return {
541
+ "required": required,
542
+ "valid": True,
543
+ "cron_id": cron_id,
544
+ "schedule_type": "keep_alive",
545
+ "schedule_value": "true",
546
+ "schedule_label": "keep alive",
547
+ "schedule": "",
548
+ "interval_seconds": 0,
549
+ "recovery_policy": "restart_daemon",
550
+ "run_on_boot": _effective_run_on_boot("restart_daemon"),
551
+ "run_on_wake": _effective_run_on_wake("restart_daemon"),
552
+ "idempotent": _effective_idempotent("restart_daemon"),
553
+ "max_catchup_age": max_catchup_age,
554
+ }
555
+
556
+ return {
557
+ "required": required,
558
+ "valid": not required,
559
+ "error": "" if not required else "schedule_required=true but no schedule metadata was provided.",
560
+ "cron_id": cron_id,
561
+ "recovery_policy": recovery_policy_raw or "none",
562
+ "run_on_boot": run_on_boot,
563
+ "run_on_wake": run_on_wake,
564
+ "idempotent": idempotent,
565
+ "max_catchup_age": max_catchup_age,
566
+ }
567
+
568
+
569
+ def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str, reason: str = "") -> dict:
570
+ runtime = classify_runtime(path, meta)
571
+ name = meta.get("name", path.stem)
572
+ entry = {
573
+ "name": name,
574
+ "runtime": runtime,
575
+ "description": meta.get("description", ""),
576
+ "path": str(path),
577
+ "core": is_core,
578
+ "metadata": meta,
579
+ "classification": classification,
580
+ "reason": reason,
581
+ "declared_schedule": get_declared_schedule(meta, name),
582
+ "filename_prefixed": has_personal_script_filename_prefix(path.stem),
583
+ }
584
+ if classification == "personal":
585
+ entry["naming_policy"] = "preferred" if entry["filename_prefixed"] else "legacy-nonprefixed"
586
+ return entry
587
+
588
+
589
+ def classify_scripts_dir() -> dict:
590
+ """Classify every file in NEXO_HOME/scripts into personal/core/ignored/non-script buckets."""
591
+ _apply_legacy_personal_script_backfills()
592
+ scripts_dir = get_scripts_dir()
593
+ if not scripts_dir.is_dir():
594
+ return {"scripts_dir": str(scripts_dir), "entries": [], "summary": {}}
595
+
596
+ core_names = load_core_script_names()
597
+ entries: list[dict] = []
598
+ for f in sorted(scripts_dir.iterdir()):
599
+ if not f.is_file():
600
+ continue
601
+
602
+ meta = parse_inline_metadata(f)
603
+ if _is_ignored(f):
604
+ entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
605
+ continue
606
+
607
+ if not _is_script_candidate(f, meta):
608
+ entries.append(_script_entry(f, meta, is_core=False, classification="non-script", reason="not an executable/script candidate"))
609
+ continue
610
+
611
+ is_core = f.name in core_names
612
+ classification = "core" if is_core else "personal"
613
+ entries.append(_script_entry(f, meta, is_core=is_core, classification=classification))
614
+
615
+ summary: dict[str, int] = {}
616
+ for entry in entries:
617
+ summary[entry["classification"]] = summary.get(entry["classification"], 0) + 1
618
+ return {"scripts_dir": str(scripts_dir), "entries": entries, "summary": summary}
619
+
620
+
621
+ def list_scripts(include_core: bool = False) -> list[dict]:
622
+ """List scripts in NEXO_HOME/scripts/.
623
+
624
+ By default only personal scripts. With include_core=True, also shows core/cron scripts.
625
+ """
626
+ results = []
627
+ for entry in classify_scripts_dir()["entries"]:
628
+ if entry["classification"] not in {"personal", "core"}:
629
+ continue
630
+ if entry["core"] and not include_core:
631
+ continue
632
+ hidden = _truthy(entry.get("metadata", {}).get("hidden"))
633
+ if hidden and not include_core:
634
+ continue
635
+ results.append(entry)
636
+ return results
637
+
638
+
639
+ def _within_scripts_dir(path: Path) -> bool:
640
+ try:
641
+ path.resolve().relative_to(get_scripts_dir().resolve())
642
+ return True
643
+ except Exception:
644
+ return False
645
+
646
+
647
+ def resolve_script(name: str) -> dict | None:
648
+ """Find a script by name (metadata name or filename stem)."""
649
+ scripts_dir = get_scripts_dir()
650
+ if not scripts_dir.is_dir():
651
+ return None
652
+
653
+ for f in scripts_dir.iterdir():
654
+ if not f.is_file() or _is_ignored(f):
655
+ continue
656
+ meta = parse_inline_metadata(f)
657
+ if not _is_script_candidate(f, meta):
658
+ continue
659
+ script_name = meta.get("name", f.stem)
660
+ if script_name == name or f.stem == name:
661
+ runtime = classify_runtime(f, meta)
662
+ return {
663
+ "name": script_name,
664
+ "runtime": runtime,
665
+ "description": meta.get("description", ""),
666
+ "path": str(f),
667
+ "core": f.name in load_core_script_names(),
668
+ "metadata": meta,
669
+ }
670
+ return None
671
+
672
+
673
+ def resolve_script_reference(ref: str) -> dict | None:
674
+ """Resolve a script by name or by direct filesystem path."""
675
+ direct = Path(ref)
676
+ if direct.is_file():
677
+ meta = parse_inline_metadata(direct)
678
+ return {
679
+ "name": meta.get("name", direct.stem),
680
+ "runtime": classify_runtime(direct, meta),
681
+ "description": meta.get("description", ""),
682
+ "path": str(direct),
683
+ "core": direct.name in load_core_script_names(),
684
+ "metadata": meta,
685
+ }
686
+ return resolve_script(ref)
687
+
688
+
689
+ def _extract_script_path_from_program_args(program_args: list) -> Path | None:
690
+ candidate = _extract_script_path_candidate(program_args)
691
+ if candidate is None:
692
+ return None
693
+ if not candidate.is_file():
694
+ return None
695
+ if not _within_scripts_dir(candidate):
696
+ return None
697
+ if _is_ignored(candidate):
698
+ return None
699
+ return candidate
700
+
701
+
702
+ def _extract_script_path_candidate(program_args: list) -> Path | None:
703
+ candidates: list[Path] = []
704
+ for arg in program_args or []:
705
+ if not isinstance(arg, str):
706
+ continue
707
+ candidate = Path(arg).expanduser()
708
+ if not str(candidate).startswith("/") and not str(arg).startswith("~"):
709
+ continue
710
+ candidates.append(candidate)
711
+ if not candidates:
712
+ return None
713
+ return candidates[-1]
714
+
715
+
716
+ def _format_schedule_from_plist(plist_data: dict) -> tuple[str, str, str]:
717
+ if plist_data.get("KeepAlive") is True:
718
+ return "keep_alive", "true", "keep alive"
719
+ if plist_data.get("RunAtLoad") is True and "StartInterval" not in plist_data and "StartCalendarInterval" not in plist_data:
720
+ return "run_at_load", "true", "run at load"
721
+
722
+ if "StartInterval" in plist_data:
723
+ interval = int(plist_data["StartInterval"])
724
+ return "interval", str(interval), f"every {interval}s"
725
+
726
+ cal = plist_data.get("StartCalendarInterval")
727
+ if cal:
728
+ if isinstance(cal, list):
729
+ value = json.dumps(cal, ensure_ascii=False)
730
+ return "calendar", value, "calendar"
731
+ hour = cal.get("Hour")
732
+ minute = cal.get("Minute")
733
+ weekday = cal.get("Weekday")
734
+ if weekday is not None and hour is not None and minute is not None:
735
+ return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} weekday={weekday}"
736
+ if hour is not None and minute is not None:
737
+ return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} daily"
738
+ return "calendar", json.dumps(cal, ensure_ascii=False), "calendar"
739
+
740
+ return "manual", "", ""
741
+
742
+
743
+ def _calendar_payload_from_declared(schedule_value: str) -> dict | list | None:
744
+ if not schedule_value:
745
+ return None
746
+ if schedule_value.lstrip().startswith("{") or schedule_value.lstrip().startswith("["):
747
+ try:
748
+ parsed = json.loads(schedule_value)
749
+ except Exception:
750
+ return None
751
+ return parsed if isinstance(parsed, (dict, list)) else None
752
+
753
+ parts = schedule_value.split(":")
754
+ if len(parts) not in {2, 3}:
755
+ return None
756
+ try:
757
+ hour = int(parts[0])
758
+ minute = int(parts[1])
759
+ weekday = int(parts[2]) if len(parts) == 3 else None
760
+ except ValueError:
761
+ return None
762
+
763
+ payload = {"Hour": hour, "Minute": minute}
764
+ if weekday is not None:
765
+ payload["Weekday"] = weekday
766
+ return payload
767
+
768
+
769
+ def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | list) -> str:
770
+ if schedule_type == "calendar":
771
+ payload = _calendar_payload_from_declared(str(schedule_value)) if isinstance(schedule_value, str) else schedule_value
772
+ if payload is None:
773
+ return str(schedule_value or "")
774
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
775
+ return str(schedule_value or "")
776
+
777
+
778
+ def _extract_launchctl_value(output: str, prefixes: str | tuple[str, ...]) -> str | None:
779
+ if isinstance(prefixes, str):
780
+ prefixes = (prefixes,)
781
+ for raw_line in output.splitlines():
782
+ line = raw_line.strip()
783
+ for prefix in prefixes:
784
+ if line.startswith(prefix):
785
+ return line[len(prefix):].strip()
786
+ return None
787
+
788
+
789
+ def _launchctl_service_state(label: str) -> dict:
790
+ state = {
791
+ "loaded": None,
792
+ "pid": "",
793
+ "state": "",
794
+ "last_exit_status": "",
795
+ "error": "",
796
+ }
797
+ if platform.system() != "Darwin":
798
+ return state
799
+
800
+ try:
801
+ result = subprocess.run(
802
+ ["launchctl", "print", f"gui/{os.getuid()}/{label}"],
803
+ capture_output=True,
804
+ text=True,
805
+ timeout=3,
806
+ )
807
+ except Exception as exc:
808
+ return {**state, "loaded": False, "error": str(exc)}
809
+
810
+ output = (result.stdout or "") + (result.stderr or "")
811
+ if result.returncode != 0 or "Could not find service" in output:
812
+ return {**state, "loaded": False, "error": output.strip() or "not loaded"}
813
+
814
+ return {
815
+ "loaded": True,
816
+ "pid": _extract_launchctl_value(output, ("pid = ", "PID = ")) or "",
817
+ "state": _extract_launchctl_value(output, "state = ") or "",
818
+ "last_exit_status": _extract_launchctl_value(
819
+ output,
820
+ ("last exit code = ", "last exit status = ", "LastExitStatus = "),
821
+ ) or "",
822
+ "error": "",
823
+ }
824
+
825
+
826
+ def _keep_alive_runtime_snapshot(record: dict) -> dict:
827
+ if record.get("schedule_type") != "keep_alive":
828
+ return {
829
+ "runtime_state": "unknown",
830
+ "runtime_summary": "",
831
+ "runtime_problems": [],
832
+ }
833
+
834
+ label = record.get("launchd_label") or f"com.nexo.{record.get('cron_id', '')}"
835
+ service = _launchctl_service_state(str(label))
836
+ problems: list[str] = []
837
+
838
+ if service.get("loaded") is False:
839
+ problems.append("keep_alive service not loaded in launchd")
840
+ return {
841
+ "runtime_state": "stale",
842
+ "runtime_summary": "keep_alive service not loaded",
843
+ "runtime_problems": problems,
844
+ }
845
+
846
+ pid = str(service.get("pid", "") or "").strip()
847
+ service_state = str(service.get("state", "") or "").strip().lower()
848
+ last_exit = str(service.get("last_exit_status", "") or "").strip()
849
+ if pid:
850
+ return {
851
+ "runtime_state": "alive",
852
+ "runtime_summary": f"running with pid {pid}",
853
+ "runtime_problems": [],
854
+ }
855
+ if service_state in {"running", "spawned"}:
856
+ return {
857
+ "runtime_state": "alive",
858
+ "runtime_summary": f"launchd state {service_state}",
859
+ "runtime_problems": [],
860
+ }
861
+ if last_exit and last_exit != "0":
862
+ problems.append(f"keep_alive daemon exited with status {last_exit}")
863
+ return {
864
+ "runtime_state": "degraded",
865
+ "runtime_summary": f"last exit {last_exit}",
866
+ "runtime_problems": problems,
867
+ }
868
+
869
+ problems.append("keep_alive service is loaded but has no active pid")
870
+ return {
871
+ "runtime_state": "degraded",
872
+ "runtime_summary": "loaded but not running",
873
+ "runtime_problems": problems,
874
+ }
875
+
876
+
877
+ def _discover_personal_schedule_records() -> list[dict]:
878
+ """Inspect macOS LaunchAgents and return raw personal schedule records."""
879
+ if platform.system() != "Darwin":
880
+ return []
881
+
882
+ results = []
883
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
884
+ if not launch_agents_dir.is_dir():
885
+ return results
886
+
887
+ core_names = load_core_script_names()
888
+ for plist_path in sorted(launch_agents_dir.glob("com.nexo.*.plist")):
889
+ try:
890
+ with plist_path.open("rb") as fh:
891
+ plist_data = plistlib.load(fh)
892
+ except Exception:
893
+ continue
894
+
895
+ env = plist_data.get("EnvironmentVariables") or {}
896
+ if env.get("NEXO_MANAGED_CORE_CRON") == "1":
897
+ continue
898
+
899
+ program_args = plist_data.get("ProgramArguments") or []
900
+ candidate = _extract_script_path_candidate(program_args)
901
+ label = str(plist_data.get("Label", plist_path.stem))
902
+ cron_id = label.replace("com.nexo.", "", 1)
903
+ script_path = candidate.expanduser() if candidate is not None else None
904
+ in_scripts_dir = bool(script_path and _within_scripts_dir(script_path))
905
+ exists = bool(script_path and script_path.is_file())
906
+ ignored = bool(script_path and in_scripts_dir and _is_ignored(script_path))
907
+ is_core = bool(script_path and exists and script_path.name in core_names)
908
+ if is_core or ignored:
909
+ continue
910
+
911
+ schedule_type, schedule_value, schedule_label = _format_schedule_from_plist(plist_data)
912
+ results.append({
913
+ "cron_id": cron_id,
914
+ "script_path": str(script_path) if script_path else "",
915
+ "schedule_type": schedule_type,
916
+ "schedule_value": schedule_value,
917
+ "schedule_label": schedule_label,
918
+ "run_at_load": bool(plist_data.get("RunAtLoad")),
919
+ "launchd_label": label,
920
+ "plist_path": str(plist_path),
921
+ "enabled": True,
922
+ "description": "",
923
+ "managed_marker": env.get(PERSONAL_SCHEDULE_MANAGED_ENV) == "1",
924
+ "script_exists": exists,
925
+ "script_within_scripts_dir": in_scripts_dir,
926
+ })
927
+
928
+ return results
929
+
930
+
931
+ def audit_personal_schedules() -> dict:
932
+ """Return semantic schedule audit for personal LaunchAgents.
933
+
934
+ Only schedules created/repaired through the official flow count as managed.
935
+ Manual plists are discovered for visibility and repair, but never blessed.
936
+ """
937
+ classification = classify_scripts_dir()
938
+ personal_scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
939
+ scripts_by_path = {
940
+ str(Path(entry["path"]).expanduser().resolve(strict=False)): entry
941
+ for entry in personal_scripts
942
+ }
943
+
944
+ audited: list[dict] = []
945
+ summary = {
946
+ "declared_managed": 0,
947
+ "discovered_manual": 0,
948
+ "orphan_schedule": 0,
949
+ "healthy": 0,
950
+ "problems": 0,
951
+ "managed_registered": 0,
952
+ "keep_alive": 0,
953
+ "runtime_alive": 0,
954
+ "runtime_degraded": 0,
955
+ "runtime_duplicated": 0,
956
+ "runtime_stale": 0,
957
+ "runtime_unknown": 0,
958
+ }
959
+
960
+ for record in _discover_personal_schedule_records():
961
+ script_path = record.get("script_path", "")
962
+ resolved_path = str(Path(script_path).expanduser().resolve(strict=False)) if script_path else ""
963
+ script = scripts_by_path.get(resolved_path)
964
+ declared = script.get("declared_schedule", {}) if script else {}
965
+ declared_valid = bool(script and declared.get("required") and declared.get("valid"))
966
+ matches = declared_valid and _schedule_matches(record, declared)
967
+
968
+ if record.get("managed_marker") and declared_valid:
969
+ schedule_origin = "declared_managed"
970
+ elif declared_valid:
971
+ schedule_origin = "discovered_manual"
972
+ else:
973
+ schedule_origin = "orphan_schedule"
974
+
975
+ problems: list[str] = []
976
+ if not record.get("script_within_scripts_dir"):
977
+ problems.append("schedule points outside NEXO_HOME/scripts")
978
+ elif not record.get("script_path"):
979
+ problems.append("schedule does not resolve a script path")
980
+ elif not record.get("script_exists"):
981
+ problems.append(f"scheduled script missing: {record['script_path']}")
982
+ elif not script:
983
+ problems.append("schedule points to a script that is not a registered personal script")
984
+
985
+ if script and not declared.get("required"):
986
+ problems.append("personal schedule exists without declared inline metadata")
987
+ elif script and declared.get("required") and not declared.get("valid"):
988
+ problems.append(declared.get("error", "invalid declared schedule metadata"))
989
+ elif declared_valid and not matches:
990
+ problems.append(
991
+ f"schedule drift: actual {record.get('schedule_label') or record.get('schedule_value') or record.get('schedule_type')} "
992
+ f"!= declared {declared.get('schedule_label') or declared.get('cron_id')}"
993
+ )
994
+
995
+ if declared_valid and not record.get("managed_marker"):
996
+ problems.append("schedule was discovered manually and must be recreated via nexo scripts reconcile")
997
+
998
+ schedule_managed = bool(schedule_origin == "declared_managed" and matches and not problems)
999
+ if schedule_managed:
1000
+ schedule_state = "healthy"
1001
+ elif schedule_origin == "declared_managed":
1002
+ schedule_state = "drifted"
1003
+ elif schedule_origin == "discovered_manual" and matches:
1004
+ schedule_state = "manual_matching_declared"
1005
+ elif schedule_origin == "discovered_manual":
1006
+ schedule_state = "manual_drift"
1007
+ else:
1008
+ schedule_state = "orphaned"
1009
+
1010
+ audited_record = dict(record)
1011
+ runtime_snapshot = _keep_alive_runtime_snapshot(record)
1012
+ audited_record.update({
1013
+ "schedule_origin": schedule_origin,
1014
+ "schedule_declared": declared_valid,
1015
+ "schedule_managed": schedule_managed,
1016
+ "schedule_matches_declared": matches,
1017
+ "schedule_state": schedule_state,
1018
+ "problems": problems,
1019
+ "script_name": script.get("name", "") if script else "",
1020
+ "declared_schedule": declared if script else {},
1021
+ **runtime_snapshot,
1022
+ })
1023
+ audited.append(audited_record)
1024
+ summary[schedule_origin] += 1
1025
+ if schedule_managed:
1026
+ summary["healthy"] += 1
1027
+ summary["managed_registered"] += 1
1028
+ else:
1029
+ summary["problems"] += 1
1030
+
1031
+ duplicate_cron_ids: dict[str, int] = {}
1032
+ duplicate_script_paths: dict[str, int] = {}
1033
+ for record in audited:
1034
+ if record.get("schedule_type") != "keep_alive":
1035
+ continue
1036
+ cron_id = str(record.get("cron_id", "") or "")
1037
+ script_path = str(record.get("script_path", "") or "")
1038
+ if cron_id:
1039
+ duplicate_cron_ids[cron_id] = duplicate_cron_ids.get(cron_id, 0) + 1
1040
+ if script_path:
1041
+ duplicate_script_paths[script_path] = duplicate_script_paths.get(script_path, 0) + 1
1042
+
1043
+ for record in audited:
1044
+ if record.get("schedule_type") == "keep_alive":
1045
+ cron_id = str(record.get("cron_id", "") or "")
1046
+ script_path = str(record.get("script_path", "") or "")
1047
+ duplicated = (
1048
+ (cron_id and duplicate_cron_ids.get(cron_id, 0) > 1)
1049
+ or (script_path and duplicate_script_paths.get(script_path, 0) > 1)
1050
+ )
1051
+ if duplicated:
1052
+ runtime_problems = list(record.get("runtime_problems", []))
1053
+ runtime_problems.append("duplicate keep_alive schedules discovered for the same cron/script")
1054
+ record["runtime_state"] = "duplicated"
1055
+ record["runtime_summary"] = "multiple keep_alive schedules discovered"
1056
+ record["runtime_problems"] = runtime_problems
1057
+
1058
+ if record.get("schedule_type") == "keep_alive":
1059
+ summary["keep_alive"] += 1
1060
+ runtime_state = str(record.get("runtime_state", "unknown") or "unknown")
1061
+ key = f"runtime_{runtime_state}"
1062
+ if key not in summary:
1063
+ summary[key] = 0
1064
+ summary[key] += 1
1065
+
1066
+ return {
1067
+ "schedules": audited,
1068
+ "summary": summary,
1069
+ }
1070
+
1071
+
1072
+ def discover_personal_schedules() -> list[dict]:
1073
+ """Return only healthy managed personal schedules."""
1074
+ managed: list[dict] = []
1075
+ for record in audit_personal_schedules()["schedules"]:
1076
+ if record.get("schedule_managed"):
1077
+ managed.append({
1078
+ "cron_id": record["cron_id"],
1079
+ "script_path": record["script_path"],
1080
+ "schedule_type": record["schedule_type"],
1081
+ "schedule_value": record["schedule_value"],
1082
+ "schedule_label": record["schedule_label"],
1083
+ "launchd_label": record["launchd_label"],
1084
+ "plist_path": record["plist_path"],
1085
+ "enabled": record.get("enabled", True),
1086
+ "description": record.get("description", ""),
1087
+ })
1088
+ return managed
1089
+
1090
+
1091
+ def sync_personal_scripts(prune_missing: bool = True) -> dict:
1092
+ """Sync filesystem + scheduler state into the DB-backed personal scripts registry."""
1093
+ from db import init_db, sync_personal_scripts_registry
1094
+
1095
+ init_db()
1096
+ classification = classify_scripts_dir()
1097
+ scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
1098
+ schedule_audit = audit_personal_schedules()
1099
+ schedules = [record for record in schedule_audit["schedules"] if record.get("schedule_managed")]
1100
+ result = sync_personal_scripts_registry(scripts, schedules, prune_missing=prune_missing)
1101
+ result["classification"] = classification["summary"]
1102
+ missing_declared = []
1103
+ managed_by_path: dict[str, list[dict]] = {}
1104
+ for schedule in schedules:
1105
+ managed_by_path.setdefault(schedule["script_path"], []).append(schedule)
1106
+ schedules_by_path: dict[str, list[dict]] = {}
1107
+ for schedule in schedule_audit["schedules"]:
1108
+ schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
1109
+ for script in scripts:
1110
+ declared = script.get("declared_schedule", {})
1111
+ if not declared.get("required"):
1112
+ continue
1113
+ healthy = managed_by_path.get(script["path"], [])
1114
+ if healthy:
1115
+ continue
1116
+ attached = schedules_by_path.get(script["path"], [])
1117
+ if not attached:
1118
+ missing_declared.append({
1119
+ "name": script["name"],
1120
+ "path": script["path"],
1121
+ "declared_schedule": declared,
1122
+ "reason": "no schedule discovered",
1123
+ })
1124
+ continue
1125
+ attached_states = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in attached]
1126
+ missing_declared.append({
1127
+ "name": script["name"],
1128
+ "path": script["path"],
1129
+ "declared_schedule": declared,
1130
+ "reason": f"schedule discovered but not managed ({', '.join(attached_states)})",
1131
+ })
1132
+ result["schedule_audit"] = schedule_audit
1133
+ result["missing_declared_schedules"] = missing_declared
1134
+ return result
1135
+
1136
+
1137
+ def _schedule_matches(existing: dict, declared: dict) -> bool:
1138
+ if not existing or not declared.get("valid"):
1139
+ return False
1140
+ if existing.get("cron_id") != declared.get("cron_id"):
1141
+ return False
1142
+ if existing.get("schedule_type") != declared.get("schedule_type"):
1143
+ return False
1144
+ existing_value = _canonical_schedule_value(existing.get("schedule_type", ""), existing.get("schedule_value", ""))
1145
+ declared_value = _canonical_schedule_value(declared.get("schedule_type", ""), declared.get("schedule_value", ""))
1146
+ if existing_value != declared_value:
1147
+ return False
1148
+ if bool(existing.get("run_at_load")) != bool(declared.get("run_on_boot")):
1149
+ return False
1150
+ return True
1151
+
1152
+
1153
+ def _remove_schedule_file(*, cron_id: str, plist_path: str) -> dict:
1154
+ removed = {
1155
+ "cron_id": cron_id,
1156
+ "plist_path": plist_path,
1157
+ "deleted": False,
1158
+ }
1159
+ plist = Path(plist_path) if plist_path else None
1160
+ if plist and platform.system() == "Darwin" and plist.exists():
1161
+ subprocess.run(
1162
+ ["launchctl", "bootout", f"gui/{os.getuid()}", str(plist)],
1163
+ capture_output=True,
1164
+ )
1165
+ with contextlib.suppress(FileNotFoundError):
1166
+ plist.unlink()
1167
+ removed["deleted"] = True
1168
+ return removed
1169
+
1170
+
1171
+ def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
1172
+ """Create or repair personal schedules declared in inline script metadata."""
1173
+ classification = classify_scripts_dir()
1174
+ scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
1175
+ schedule_audit = audit_personal_schedules()
1176
+ schedules_by_path: dict[str, list[dict]] = {}
1177
+ for schedule in schedule_audit["schedules"]:
1178
+ schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
1179
+
1180
+ report = {
1181
+ "ok": True,
1182
+ "dry_run": dry_run,
1183
+ "created": [],
1184
+ "repaired": [],
1185
+ "already_present": [],
1186
+ "skipped": [],
1187
+ "invalid": [],
1188
+ }
1189
+
1190
+ for script in scripts:
1191
+ declared = script.get("declared_schedule", {})
1192
+ if not declared.get("required"):
1193
+ report["skipped"].append({
1194
+ "name": script["name"],
1195
+ "reason": "no declared schedule",
1196
+ })
1197
+ continue
1198
+ if not declared.get("valid"):
1199
+ report["invalid"].append({
1200
+ "name": script["name"],
1201
+ "path": script["path"],
1202
+ "error": declared.get("error", "invalid schedule metadata"),
1203
+ })
1204
+ continue
1205
+
1206
+ existing = schedules_by_path.get(script["path"], [])
1207
+ matching = next((item for item in existing if item.get("schedule_managed") and _schedule_matches(item, declared)), None)
1208
+ if matching:
1209
+ report["already_present"].append({
1210
+ "name": script["name"],
1211
+ "cron_id": matching["cron_id"],
1212
+ "schedule_label": matching.get("schedule_label", ""),
1213
+ })
1214
+ continue
1215
+
1216
+ repair_reasons = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in existing]
1217
+ if dry_run:
1218
+ report["repaired" if existing else "created"].append({
1219
+ "name": script["name"],
1220
+ "cron_id": declared["cron_id"],
1221
+ "schedule_label": declared["schedule_label"],
1222
+ "dry_run": True,
1223
+ "reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
1224
+ })
1225
+ continue
1226
+
1227
+ removed = []
1228
+ if existing:
1229
+ for item in existing:
1230
+ removed.append(_remove_schedule_file(cron_id=item["cron_id"], plist_path=item.get("plist_path", "")))
1231
+ from db import delete_personal_script_schedule
1232
+
1233
+ for item in existing:
1234
+ delete_personal_script_schedule(item["cron_id"])
1235
+
1236
+ from plugins.schedule import handle_schedule_add
1237
+
1238
+ response = handle_schedule_add(
1239
+ cron_id=declared["cron_id"],
1240
+ script=script["path"],
1241
+ schedule=declared.get("schedule", ""),
1242
+ interval_seconds=declared.get("interval_seconds", 0),
1243
+ description=script.get("description", ""),
1244
+ script_type=script.get("runtime", "auto"),
1245
+ keep_alive=declared.get("schedule_type") == "keep_alive",
1246
+ )
1247
+ target = report["repaired" if existing else "created"]
1248
+ target.append({
1249
+ "name": script["name"],
1250
+ "cron_id": declared["cron_id"],
1251
+ "schedule_label": declared["schedule_label"],
1252
+ "reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
1253
+ "removed": removed,
1254
+ "result": response,
1255
+ })
1256
+
1257
+ sync_result = sync_personal_scripts()
1258
+ report["sync"] = sync_result
1259
+ report["classification"] = classification["summary"]
1260
+ return report
1261
+
1262
+
1263
+ def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
1264
+ """Full lifecycle reconciliation: classify, sync registry, ensure declared schedules."""
1265
+ sync_result = sync_personal_scripts()
1266
+ ensure_result = ensure_personal_schedules(dry_run=dry_run)
1267
+ return {
1268
+ "ok": True,
1269
+ "dry_run": dry_run,
1270
+ "sync": sync_result,
1271
+ "ensure_schedules": ensure_result,
1272
+ "classification": ensure_result.get("classification", sync_result.get("classification", {})),
1273
+ }
1274
+
1275
+
1276
+ def _template_path(filename: str) -> Path | None:
1277
+ candidates = [
1278
+ NEXO_HOME / "templates" / filename,
1279
+ NEXO_CODE.parent / "templates" / filename,
1280
+ NEXO_CODE / "templates" / filename,
1281
+ ]
1282
+ for candidate in candidates:
1283
+ if candidate.is_file():
1284
+ return candidate
1285
+ return None
1286
+
1287
+
1288
+ def _script_filename_from_name(name: str, runtime: str) -> str:
1289
+ stem = _safe_slug(name) or "personal-script"
1290
+ ext = {
1291
+ "python": ".py",
1292
+ "shell": ".sh",
1293
+ "node": ".js",
1294
+ "php": ".php",
1295
+ }.get(runtime, ".py")
1296
+ return stem + ext
1297
+
1298
+
1299
+ def _personal_script_filename_from_name(name: str, runtime: str) -> str:
1300
+ logical_name = _logical_personal_script_name(name)
1301
+ return _script_filename_from_name(f"{PERSONAL_SCRIPT_FILENAME_PREFIX}{logical_name}", runtime)
1302
+
1303
+
1304
+ def create_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
1305
+ runtime = runtime if runtime in SUPPORTED_RUNTIMES else "python"
1306
+ if runtime == "unknown":
1307
+ runtime = "python"
1308
+
1309
+ scripts_dir = get_scripts_dir()
1310
+ scripts_dir.mkdir(parents=True, exist_ok=True)
1311
+ logical_name = _logical_personal_script_name(name)
1312
+ filename = _personal_script_filename_from_name(name, runtime)
1313
+ path = scripts_dir / filename
1314
+ if path.exists() and not force:
1315
+ raise FileExistsError(f"Script already exists: {path}")
1316
+
1317
+ if runtime == "shell":
1318
+ template_path = _template_path("script-template.sh")
1319
+ else:
1320
+ template_path = _template_path("script-template.py")
1321
+
1322
+ if template_path:
1323
+ content = template_path.read_text()
1324
+ elif runtime == "shell":
1325
+ content = (
1326
+ "#!/usr/bin/env bash\n"
1327
+ "# nexo: name=example-script\n"
1328
+ "# nexo: description=Example shell script using NEXO\n"
1329
+ "# nexo: runtime=shell\n"
1330
+ "set -euo pipefail\n"
1331
+ "echo \"Hello from NEXO personal script\"\n"
1332
+ )
1333
+ else:
1334
+ content = (
1335
+ "#!/usr/bin/env python3\n"
1336
+ "# nexo: name=example-script\n"
1337
+ "# nexo: description=Example personal script using NEXO\n"
1338
+ "# nexo: runtime=python\n"
1339
+ "print('hello')\n"
1340
+ )
1341
+
1342
+ content = content.replace("example-script", logical_name)
1343
+ content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {logical_name}")
1344
+ content = content.replace("Example shell script using NEXO", description or f"Personal script: {logical_name}")
1345
+
1346
+ path.write_text(content)
1347
+ if runtime in {"shell", "python"}:
1348
+ path.chmod(0o755)
1349
+ sync_result = sync_personal_scripts()
1350
+ return {
1351
+ "ok": True,
1352
+ "name": logical_name,
1353
+ "requested_name": name,
1354
+ "path": str(path),
1355
+ "filename": filename,
1356
+ "runtime": runtime,
1357
+ "description": description,
1358
+ "sync": sync_result,
1359
+ }
1360
+
1361
+
1362
+ def unschedule_personal_script(name_or_path: str) -> dict:
1363
+ """Remove all personal schedules attached to a script and prune registry entries."""
1364
+ from db import (
1365
+ init_db,
1366
+ get_personal_script,
1367
+ delete_personal_script_schedule,
1368
+ )
1369
+
1370
+ init_db()
1371
+ sync_personal_scripts()
1372
+ script = get_personal_script(name_or_path)
1373
+ if not script:
1374
+ resolved = resolve_script(name_or_path)
1375
+ if not resolved or resolved.get("core"):
1376
+ return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
1377
+ script = resolved
1378
+
1379
+ removed: list[dict] = []
1380
+ audited = audit_personal_schedules()
1381
+ discovered = [
1382
+ item for item in audited["schedules"]
1383
+ if item.get("script_path") == script.get("path")
1384
+ ]
1385
+ for schedule in discovered:
1386
+ removed.append(_remove_schedule_file(cron_id=schedule["cron_id"], plist_path=schedule.get("plist_path", "")))
1387
+
1388
+ for schedule in script.get("schedules", []):
1389
+ delete_personal_script_schedule(schedule["cron_id"])
1390
+ if not any(item["cron_id"] == schedule["cron_id"] for item in removed):
1391
+ removed.append({
1392
+ "cron_id": schedule["cron_id"],
1393
+ "plist_path": schedule.get("plist_path", ""),
1394
+ "deleted": False,
1395
+ })
1396
+
1397
+ sync_result = sync_personal_scripts()
1398
+ return {
1399
+ "ok": True,
1400
+ "script": script["name"],
1401
+ "removed_schedules": removed,
1402
+ "sync": sync_result,
1403
+ }
1404
+
1405
+
1406
+ def remove_personal_script(name_or_path: str, *, keep_file: bool = False) -> dict:
1407
+ """Remove a personal script from the runtime and registry."""
1408
+ from db import init_db, get_personal_script, delete_personal_script
1409
+
1410
+ init_db()
1411
+ sync_personal_scripts()
1412
+ script = get_personal_script(name_or_path)
1413
+ if not script:
1414
+ resolved = resolve_script(name_or_path)
1415
+ if not resolved or resolved.get("core"):
1416
+ return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
1417
+ script = resolved
1418
+
1419
+ if script.get("core"):
1420
+ return {"ok": False, "error": "Refusing to remove a core script via personal scripts lifecycle."}
1421
+
1422
+ unschedule_result = unschedule_personal_script(script["path"])
1423
+ deleted_file = False
1424
+ path = Path(script["path"])
1425
+ if not keep_file and path.is_file() and _within_scripts_dir(path):
1426
+ path.unlink()
1427
+ deleted_file = True
1428
+ delete_personal_script(script["path"])
1429
+ sync_result = sync_personal_scripts()
1430
+ return {
1431
+ "ok": True,
1432
+ "script": script["name"],
1433
+ "path": script["path"],
1434
+ "deleted_file": deleted_file,
1435
+ "keep_file": keep_file,
1436
+ "unschedule": unschedule_result,
1437
+ "sync": sync_result,
1438
+ }
1439
+
1440
+
1441
+ def doctor_script(path_or_name: str) -> dict:
1442
+ """Validate a single script. Returns dict with pass/warn/fail items."""
1443
+ # Resolve
1444
+ p = Path(path_or_name)
1445
+ if not p.is_file():
1446
+ info = resolve_script(path_or_name)
1447
+ if not info:
1448
+ return {"status": "fail", "items": [{"level": "fail", "msg": f"Script not found: {path_or_name}"}]}
1449
+ p = Path(info["path"])
1450
+
1451
+ items: list[dict] = []
1452
+ meta = parse_inline_metadata(p)
1453
+ runtime = classify_runtime(p, meta)
1454
+ core_names = load_core_script_names()
1455
+ is_core = p.name in core_names
1456
+
1457
+ # File exists
1458
+ if p.is_file():
1459
+ items.append({"level": "pass", "msg": f"File exists: {p.name}"})
1460
+ else:
1461
+ items.append({"level": "fail", "msg": f"File missing: {p.name}"})
1462
+ return {"status": "fail", "items": items}
1463
+
1464
+ # Name collision with core
1465
+ name = meta.get("name", p.stem)
1466
+ if not is_core:
1467
+ for core in core_names:
1468
+ core_stem = Path(core).stem
1469
+ if name == core_stem:
1470
+ items.append({"level": "fail", "msg": f"Name collision with core script: {core}"})
1471
+
1472
+ # Runtime recognized
1473
+ if runtime == "unknown":
1474
+ items.append({"level": "warn", "msg": "Runtime not recognized (no shebang, no extension match)"})
1475
+ else:
1476
+ items.append({"level": "pass", "msg": f"Runtime: {runtime}"})
1477
+
1478
+ # Shebang for shell scripts
1479
+ if runtime == "shell":
1480
+ shebang = _detect_shebang(p)
1481
+ if not shebang:
1482
+ items.append({"level": "warn", "msg": "Shell script without shebang"})
1483
+ else:
1484
+ items.append({"level": "pass", "msg": f"Shebang: {shebang}"})
1485
+
1486
+ # Executable bit for shell scripts
1487
+ if runtime == "shell":
1488
+ mode = p.stat().st_mode
1489
+ if not (mode & stat.S_IXUSR):
1490
+ items.append({"level": "warn", "msg": "Shell script missing executable bit"})
1491
+ else:
1492
+ items.append({"level": "pass", "msg": "Executable bit set"})
1493
+
1494
+ # Timeout parse
1495
+ timeout_str = meta.get("timeout", "")
1496
+ if timeout_str:
1497
+ try:
1498
+ int(timeout_str)
1499
+ items.append({"level": "pass", "msg": f"Timeout: {timeout_str}s"})
1500
+ except ValueError:
1501
+ items.append({"level": "fail", "msg": f"Invalid timeout value: {timeout_str}"})
1502
+
1503
+ declared = get_declared_schedule(meta, name)
1504
+ if declared.get("required"):
1505
+ if declared.get("valid"):
1506
+ items.append({"level": "pass", "msg": f"Declared schedule: {declared['schedule_label']}"})
1507
+ else:
1508
+ items.append({"level": "fail", "msg": declared.get("error", "Invalid declared schedule metadata")})
1509
+
1510
+ if runtime == "node" and not shutil.which("node"):
1511
+ items.append({"level": "fail", "msg": "Node runtime not found in PATH"})
1512
+ if runtime == "php" and not shutil.which("php"):
1513
+ items.append({"level": "fail", "msg": "PHP runtime not found in PATH"})
1514
+
1515
+ # Requires check
1516
+ requires = meta.get("requires", "")
1517
+ if requires:
1518
+ for cmd in requires.split(","):
1519
+ cmd = cmd.strip()
1520
+ if cmd and not shutil.which(cmd):
1521
+ items.append({"level": "fail", "msg": f"Required command not in PATH: {cmd}"})
1522
+ elif cmd:
1523
+ items.append({"level": "pass", "msg": f"Required command found: {cmd}"})
1524
+
1525
+ allow_db_access = str(meta.get("doctor_allow_db", "")).strip().lower() in {"1", "true", "yes", "on"}
1526
+ if allow_db_access:
1527
+ items.append({"level": "pass", "msg": "Doctor DB access explicitly allowed"})
1528
+
1529
+ # Forbidden patterns (only for personal scripts)
1530
+ if not is_core:
1531
+ try:
1532
+ content = p.read_text(errors="ignore")
1533
+ if not allow_db_access:
1534
+ for pat in _FORBIDDEN_PATTERNS:
1535
+ match = pat.search(content)
1536
+ if match:
1537
+ items.append({"level": "fail", "msg": f"Forbidden DB pattern found: {match.group()}"})
1538
+ except Exception:
1539
+ pass
1540
+
1541
+ # Determine overall status
1542
+ levels = [i["level"] for i in items]
1543
+ if "fail" in levels:
1544
+ status = "fail"
1545
+ elif "warn" in levels:
1546
+ status = "warn"
1547
+ else:
1548
+ status = "pass"
1549
+
1550
+ return {"status": status, "items": items, "name": name, "path": str(p)}
1551
+
1552
+
1553
+ def doctor_all_scripts() -> list[dict]:
1554
+ """Run doctor on all personal scripts."""
1555
+ results = []
1556
+ for script in list_scripts(include_core=False):
1557
+ result = doctor_script(script["path"])
1558
+ results.append(result)
1559
+ return results