nexo-brain 5.3.26 → 5.3.27

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 (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/server.py +3 -0
  4. package/src/tools_sessions.py +6 -1
  5. package/src/dashboard/static/favicon 2.svg +0 -32
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +0 -40
  8. package/src/dashboard/static/style 2.css +0 -2458
  9. package/src/dashboard/templates/adaptive 2.html +0 -118
  10. package/src/dashboard/templates/artifacts 2.html +0 -133
  11. package/src/dashboard/templates/backups 2.html +0 -136
  12. package/src/dashboard/templates/base 2.html +0 -417
  13. package/src/dashboard/templates/calendar 2.html +0 -591
  14. package/src/dashboard/templates/chat 2.html +0 -356
  15. package/src/dashboard/templates/claims 2.html +0 -259
  16. package/src/dashboard/templates/cortex 2.html +0 -321
  17. package/src/dashboard/templates/credentials 2.html +0 -128
  18. package/src/dashboard/templates/crons 2.html +0 -370
  19. package/src/dashboard/templates/dashboard 2.html +0 -494
  20. package/src/dashboard/templates/dreams 2.html +0 -252
  21. package/src/dashboard/templates/email 2.html +0 -160
  22. package/src/dashboard/templates/evolution 2.html +0 -189
  23. package/src/dashboard/templates/feed 2.html +0 -249
  24. package/src/dashboard/templates/followup_health 2.html +0 -170
  25. package/src/dashboard/templates/graph 2.html +0 -201
  26. package/src/dashboard/templates/guard 2.html +0 -259
  27. package/src/dashboard/templates/inbox 2.html +0 -251
  28. package/src/dashboard/templates/memory 2.html +0 -420
  29. package/src/dashboard/templates/operations 2.html +0 -608
  30. package/src/dashboard/templates/plugins 2.html +0 -185
  31. package/src/dashboard/templates/protocol 2.html +0 -199
  32. package/src/dashboard/templates/rules 2.html +0 -246
  33. package/src/dashboard/templates/sentiment 2.html +0 -247
  34. package/src/dashboard/templates/sessions 2.html +0 -218
  35. package/src/dashboard/templates/skills 2.html +0 -329
  36. package/src/dashboard/templates/somatic 2.html +0 -73
  37. package/src/dashboard/templates/triggers 2.html +0 -133
  38. package/src/dashboard/templates/trust 2.html +0 -360
  39. package/src/db/__init__ 2.py +0 -259
  40. package/src/db/_core 2.py +0 -437
  41. package/src/db/_credentials 2.py +0 -124
  42. package/src/db/_episodic 2.py +0 -762
  43. package/src/db/_evolution 2.py +0 -54
  44. package/src/db/_fts 2.py +0 -406
  45. package/src/db/_goal_profiles 2.py +0 -376
  46. package/src/db/_hot_context 2.py +0 -660
  47. package/src/db/_outcomes 2.py +0 -800
  48. package/src/db/_personal_scripts 2.py +0 -582
  49. package/src/db/_sessions 2.py +0 -330
  50. package/src/db/_tasks 2.py +0 -91
  51. package/src/db/_watchers 2.py +0 -173
  52. package/src/doctor/formatters 2.py +0 -52
  53. package/src/doctor/models 2.py +0 -69
  54. package/src/doctor/planes 2.py +0 -87
  55. package/src/doctor/providers/__init__ 2.py +0 -1
  56. package/src/doctor/providers/deep 2.py +0 -367
  57. package/src/evolution_cycle 2.py +0 -519
  58. package/src/hooks/auto_capture 2.py +0 -208
  59. package/src/hooks/caffeinate-guard 2.sh +0 -8
  60. package/src/hooks/capture-session 2.sh +0 -21
  61. package/src/hooks/capture-tool-logs 2.sh +0 -158
  62. package/src/hooks/daily-briefing-check 2.sh +0 -33
  63. package/src/hooks/heartbeat-enforcement 2.py +0 -90
  64. package/src/hooks/heartbeat-posttool 2.sh +0 -18
  65. package/src/hooks/inbox-hook 2.sh +0 -76
  66. package/src/hooks/post-compact 2.sh +0 -152
  67. package/src/hooks/pre-compact 2.sh +0 -169
  68. package/src/hooks/protocol-guardrail 2.sh +0 -10
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
  70. package/src/hooks/session-stop 2.sh +0 -52
  71. package/src/kg_populate 2.py +0 -292
  72. package/src/maintenance 2.py +0 -53
  73. package/src/memory_backends 2.py +0 -71
  74. package/src/migrate_embeddings 2.py +0 -124
  75. package/src/nexo_sdk 2.py +0 -103
  76. package/src/observability 2.py +0 -199
  77. package/src/plugin_loader 2.py +0 -217
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +0 -450
  80. package/src/plugins/backup 2.py +0 -127
  81. package/src/plugins/claims_tools 2.py +0 -119
  82. package/src/plugins/cognitive_memory 2.py +0 -609
  83. package/src/plugins/core_rules 2.py +0 -252
  84. package/src/plugins/cortex 2.py +0 -1155
  85. package/src/plugins/entities 2.py +0 -67
  86. package/src/plugins/episodic_memory 2.py +0 -560
  87. package/src/plugins/evolution 2.py +0 -167
  88. package/src/plugins/goal_engine 2.py +0 -142
  89. package/src/plugins/guard 2.py +0 -862
  90. package/src/plugins/impact 2.py +0 -29
  91. package/src/plugins/knowledge_graph_tools 2.py +0 -137
  92. package/src/plugins/media_memory_tools 2.py +0 -98
  93. package/src/plugins/memory_export 2.py +0 -196
  94. package/src/plugins/outcomes 2.py +0 -130
  95. package/src/plugins/personal_scripts 2.py +0 -117
  96. package/src/plugins/preferences 2.py +0 -47
  97. package/src/plugins/protocol 2.py +0 -1449
  98. package/src/plugins/simple_api 2.py +0 -106
  99. package/src/plugins/skills 2.py +0 -341
  100. package/src/plugins/state_watchers 2.py +0 -79
  101. package/src/plugins/update 2.py +0 -986
  102. package/src/plugins/user_state_tools 2.py +0 -43
  103. package/src/plugins/workflow 2.py +0 -588
  104. package/src/protocol_settings 2.py +0 -59
  105. package/src/public_contribution 2.py +0 -466
  106. package/src/public_evolution_queue 2.py +0 -241
  107. package/src/requirements 2.txt +0 -14
  108. package/src/retroactive_learnings 2.py +0 -373
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +0 -331
  111. package/src/rules/migrate 2.py +0 -207
  112. package/src/runtime_power 2.py +0 -874
  113. package/src/script_registry 2.py +0 -1559
  114. package/src/scripts/check-context 2.py +0 -272
  115. package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
  116. package/src/scripts/deep-sleep/collect 2.py +0 -928
  117. package/src/scripts/deep-sleep/extract 2.py +0 -330
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
  119. package/src/scripts/deep-sleep/synthesize 2.py +0 -312
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
  121. package/src/scripts/nexo-agent-run 2.py +0 -75
  122. package/src/scripts/nexo-auto-update 2.py +0 -6
  123. package/src/scripts/nexo-backup 2.sh +0 -25
  124. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  125. package/src/scripts/nexo-catchup 2.py +0 -300
  126. package/src/scripts/nexo-cognitive-decay 2.py +0 -257
  127. package/src/scripts/nexo-cortex-cycle 2.py +0 -293
  128. package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
  129. package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
  130. package/src/scripts/nexo-dashboard 2.sh +0 -29
  131. package/src/scripts/nexo-deep-sleep 2.sh +0 -86
  132. package/src/scripts/nexo-evolution-run 2.py +0 -1664
  133. package/src/scripts/nexo-followup-hygiene 2.py +0 -139
  134. package/src/scripts/nexo-hook-record 2.py +0 -42
  135. package/src/scripts/nexo-immune 2.py +0 -936
  136. package/src/scripts/nexo-impact-scorer 2.py +0 -117
  137. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  138. package/src/scripts/nexo-install 2.py +0 -6
  139. package/src/scripts/nexo-learning-housekeep 2.py +0 -401
  140. package/src/scripts/nexo-learning-validator 2.py +0 -266
  141. package/src/scripts/nexo-migrate 2.py +0 -260
  142. package/src/scripts/nexo-outcome-checker 2.py +0 -127
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
  144. package/src/scripts/nexo-pre-commit 2.py +0 -120
  145. package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
  146. package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
  147. package/src/scripts/nexo-reflection 2.py +0 -256
  148. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  149. package/src/scripts/nexo-sleep 2.py +0 -631
  150. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  151. package/src/scripts/nexo-sync-clients 2.py +0 -16
  152. package/src/scripts/nexo-synthesis 2.py +0 -475
  153. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  154. package/src/scripts/nexo-update 2.sh +0 -306
  155. package/src/scripts/nexo-watchdog 2.sh +0 -1207
  156. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
  158. package/src/server 2.py +0 -1296
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
  164. package/src/skills/run-release-final-audit/guide 2.md +0 -16
  165. package/src/skills/run-release-final-audit/script 2.py +0 -259
  166. package/src/skills/run-release-final-audit/skill 2.json +0 -77
  167. package/src/skills/run-runtime-doctor/guide 2.md +0 -12
  168. package/src/skills/run-runtime-doctor/script 2.py +0 -21
  169. package/src/skills/run-runtime-doctor/skill 2.json +0 -25
  170. package/src/skills_runtime 2.py +0 -932
  171. package/src/state_watchers_runtime 2.py +0 -475
  172. package/src/storage_router 2.py +0 -32
  173. package/src/system_catalog 2.py +0 -786
  174. package/src/tools_coordination 2.py +0 -103
  175. package/src/tools_credentials 2.py +0 -68
  176. package/src/tools_drive 2.py +0 -487
  177. package/src/tools_hot_context 2.py +0 -163
  178. package/src/tools_learnings 2.py +0 -612
  179. package/src/tools_menu 2.py +0 -229
  180. package/src/tools_reminders 2.py +0 -88
  181. package/src/tools_reminders_crud 2.py +0 -363
  182. package/src/tools_sessions 2.py +0 -1054
  183. package/src/tools_system_catalog 2.py +0 -19
  184. package/src/tools_task_history 2.py +0 -57
  185. package/src/tools_transcripts 2.py +0 -98
  186. package/src/transcript_utils 2.py +0 -412
  187. package/src/user_context 2.py +0 -46
  188. package/src/user_data_portability 2.py +0 -328
  189. package/src/user_state_model 2.py +0 -170
  190. package/templates/CLAUDE.md 2.template +0 -108
  191. package/templates/CODEX.AGENTS.md 2.template +0 -66
  192. package/templates/launchagents/README 2.md +0 -132
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
  194. package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
  198. package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
  200. package/templates/launchagents/com.nexo.immune 2.plist +0 -41
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
  205. package/templates/nexo_helper 2.py +0 -301
  206. package/templates/openclaw 2.json +0 -13
  207. package/templates/plugin-template 2.py +0 -40
  208. package/templates/script-template 2.py +0 -59
  209. package/templates/script-template 2.sh +0 -13
  210. package/templates/skill-script-template 2.py +0 -48
  211. package/templates/skill-template 2.md +0 -33
@@ -1,932 +0,0 @@
1
- from __future__ import annotations
2
- """Runtime helpers for Skills v2.
3
-
4
- This module is the single execution gate for skills. It decides:
5
- - guide vs execute vs hybrid mode
6
- - whether a skill is allowed to run
7
- - how parameters are validated and rendered
8
- - how execution is routed through the stable `nexo scripts run` CLI
9
- """
10
-
11
- import json
12
- import os
13
- import re
14
- import subprocess
15
- import sys
16
- from pathlib import Path
17
-
18
- from db import (
19
- approve_skill,
20
- capture_outcome_pattern,
21
- collect_skill_improvement_candidates,
22
- collect_scriptable_skill_candidates,
23
- create_skill,
24
- get_featured_skills,
25
- get_skill,
26
- get_skill_outcome_evidence,
27
- get_skill_execution_spec,
28
- init_db,
29
- list_outcome_pattern_candidates,
30
- list_skill_outcome_reviews,
31
- materialize_personal_skill_definition,
32
- record_skill_usage,
33
- render_command_template,
34
- sync_skill_directories,
35
- update_skill,
36
- )
37
- from script_registry import doctor_script
38
-
39
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
40
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
41
- VALID_LEVELS = {"trace", "draft", "published", "stable", "archived"}
42
-
43
-
44
- def _parse_params(params) -> dict:
45
- if isinstance(params, dict):
46
- return params
47
- if isinstance(params, str):
48
- text = params.strip()
49
- if not text:
50
- return {}
51
- return json.loads(text)
52
- return {}
53
-
54
-
55
- def _ensure_ready():
56
- init_db()
57
-
58
-
59
- def _resolve_mode(requested: str, skill: dict) -> str:
60
- mode = (requested or "auto").strip().lower()
61
- if mode in {"guide", "execute", "hybrid"}:
62
- return mode
63
- effective = str(skill.get("mode", "") or "").strip().lower()
64
- if effective in {"guide", "execute", "hybrid"}:
65
- return effective
66
- if skill.get("file_path") and skill.get("content"):
67
- return "hybrid"
68
- if skill.get("file_path"):
69
- return "execute"
70
- return "guide"
71
-
72
-
73
- def _summarize_skill(skill: dict) -> str:
74
- steps = []
75
- gotchas = []
76
- try:
77
- steps = json.loads(skill.get("steps", "[]"))
78
- except json.JSONDecodeError:
79
- pass
80
- try:
81
- gotchas = json.loads(skill.get("gotchas", "[]"))
82
- except json.JSONDecodeError:
83
- pass
84
-
85
- lines = [
86
- f"[{skill['id']}] {skill['name']}",
87
- skill.get("description", "") or "(no description)",
88
- ]
89
- if steps:
90
- lines.append("Steps:")
91
- for index, step in enumerate(steps[:6], 1):
92
- lines.append(f"{index}. {step}")
93
- elif skill.get("content"):
94
- lines.append(skill["content"][:800])
95
- if gotchas:
96
- lines.append("Gotchas:")
97
- for gotcha in gotchas[:4]:
98
- lines.append(f"- {gotcha}")
99
- return "\n".join(lines).strip()
100
-
101
-
102
- def _resolve_cli_command() -> list[str]:
103
- installed = NEXO_HOME / "bin" / "nexo"
104
- if installed.is_file():
105
- return [str(installed)]
106
- return [sys.executable, str(NEXO_CODE / "cli.py")]
107
-
108
-
109
- def _run_skill_script(skill: dict, argv: list[str], timeout: int = 300) -> dict:
110
- if not argv:
111
- return {"returncode": 1, "stdout": "", "stderr": "No command to execute"}
112
-
113
- env = {
114
- **os.environ,
115
- "NEXO_HOME": str(NEXO_HOME),
116
- "NEXO_CODE": str(NEXO_CODE),
117
- "NEXO_SKILL_ID": skill["id"],
118
- "NEXO_SKILL_NAME": skill["name"],
119
- }
120
-
121
- cli_cmd = _resolve_cli_command()
122
- cmd = [*cli_cmd, "scripts", "run", argv[0], *argv[1:]]
123
- try:
124
- result = subprocess.run(
125
- cmd,
126
- capture_output=True,
127
- text=True,
128
- timeout=timeout,
129
- env=env,
130
- )
131
- return {
132
- "returncode": result.returncode,
133
- "stdout": result.stdout,
134
- "stderr": result.stderr,
135
- "command": cmd,
136
- }
137
- except subprocess.TimeoutExpired:
138
- return {
139
- "returncode": 124,
140
- "stdout": "",
141
- "stderr": f"Skill execution timed out after {timeout}s",
142
- "command": cmd,
143
- }
144
-
145
-
146
- def get_featured_skill_summaries(limit: int = 5) -> list[dict]:
147
- _ensure_ready()
148
- sync_skill_directories()
149
- featured = []
150
- for skill in get_featured_skills(limit=limit):
151
- triggers = []
152
- try:
153
- triggers = json.loads(skill.get("trigger_patterns", "[]"))
154
- except json.JSONDecodeError:
155
- pass
156
- featured.append(
157
- {
158
- "id": skill["id"],
159
- "name": skill["name"],
160
- "mode": skill.get("mode", "guide"),
161
- "execution_level": skill.get("execution_level", "none"),
162
- "source_kind": skill.get("source_kind", "personal"),
163
- "trust_score": skill.get("trust_score", 0),
164
- "trigger_patterns": triggers[:3],
165
- "outcome_review": {
166
- "has_evidence": bool((skill.get("_outcome_review") or {}).get("has_evidence")),
167
- "recommended_action": (skill.get("_outcome_review") or {}).get("recommended_action", "observe"),
168
- "success_rate": (skill.get("_outcome_review") or {}).get("success_rate"),
169
- "resolved_outcomes": (skill.get("_outcome_review") or {}).get("resolved_outcomes", 0),
170
- "ranking_weight": float(skill.get("_outcome_rank", 0.0)),
171
- },
172
- }
173
- )
174
- return featured
175
-
176
-
177
- def _skill_json_list(skill: dict, key: str) -> list:
178
- try:
179
- value = json.loads(skill.get(key, "[]"))
180
- except json.JSONDecodeError:
181
- value = []
182
- return value if isinstance(value, list) else []
183
-
184
-
185
- def _outcome_skill_id(candidate: dict) -> str:
186
- raw_parts = [
187
- candidate.get("area", ""),
188
- candidate.get("task_type", ""),
189
- candidate.get("goal_profile_id", ""),
190
- candidate.get("selected_choice", ""),
191
- ]
192
- chunks = []
193
- for part in raw_parts:
194
- cleaned = re.sub(r"[^A-Z0-9]+", "-", str(part or "").upper()).strip("-")
195
- if cleaned:
196
- chunks.append(cleaned[:12])
197
- suffix = "-".join(chunks[:4]) or "GENERAL"
198
- return f"SK-OUTCOME-{suffix}"
199
-
200
-
201
- def _outcome_pattern_to_skill_payload(candidate: dict, learning_id: int) -> dict:
202
- selected_choice = candidate.get("selected_choice", "")
203
- context_label = candidate.get("context_label", "contexto general")
204
- evidence = candidate.get("evidence") or []
205
- evidence_summary = ", ".join(
206
- f"eval#{item.get('evaluation_id')}/outcome#{item.get('outcome_id')}:{item.get('status')}"
207
- for item in evidence[:5]
208
- )
209
- description = (
210
- f"Draft skill candidate derived from {candidate.get('resolved_outcomes', 0)} resolved outcomes "
211
- f"for '{selected_choice}' in {context_label}."
212
- )
213
- steps = [
214
- f"Confirm that the current case matches {context_label}.",
215
- f"Use '{selected_choice}' as the default starting strategy.",
216
- "Check fresh constraints or evidence that could invalidate the historical pattern.",
217
- "Link and evaluate the resulting outcome so the evidence base keeps improving.",
218
- ]
219
- gotchas = [
220
- "Do not apply this skill if current evidence contradicts the pattern.",
221
- "Keep outcomes linked; a pattern without fresh feedback should not gain trust forever.",
222
- ]
223
- content = "\n".join(
224
- [
225
- f"# Outcome pattern skill candidate — {selected_choice}",
226
- "",
227
- description,
228
- "",
229
- "## Evidence",
230
- f"- Success rate: {candidate.get('success_rate', 0.0):.3f}",
231
- f"- Resolved outcomes: {candidate.get('resolved_outcomes', 0)}",
232
- f"- Context: {context_label}",
233
- f"- Evidence refs: {evidence_summary or 'none'}",
234
- "",
235
- "## Steps",
236
- *(f"{index}. {step}" for index, step in enumerate(steps, 1)),
237
- "",
238
- "## Gotchas",
239
- *(f"- {gotcha}" for gotcha in gotchas),
240
- "",
241
- ]
242
- )
243
- tags = [
244
- "outcomes-derived",
245
- str(candidate.get("area") or "").strip(),
246
- str(candidate.get("task_type") or "").strip(),
247
- str(candidate.get("goal_profile_id") or "").strip(),
248
- ]
249
- trigger_patterns = [
250
- str(candidate.get("selected_choice") or "").strip(),
251
- str(candidate.get("area") or "").strip(),
252
- str(candidate.get("task_type") or "").strip(),
253
- ]
254
- return {
255
- "id": _outcome_skill_id(candidate),
256
- "name": f"Outcome Pattern: {selected_choice}",
257
- "description": description,
258
- "level": "draft",
259
- "mode": "guide",
260
- "tags": [item for item in tags if item],
261
- "trigger_patterns": [item for item in trigger_patterns if item],
262
- "linked_learnings": [int(learning_id)],
263
- "steps": steps,
264
- "gotchas": gotchas,
265
- "content": content,
266
- }
267
-
268
-
269
- def test_skill(skill_id: str, params=None, mode: str = "auto", context: str = "") -> dict:
270
- result = apply_skill(skill_id, params=params, mode=mode, dry_run=True, context=context or "skill_test")
271
- result["tested"] = True
272
- result["test_kind"] = "dry_run"
273
- return result
274
-
275
-
276
- def promote_skill(skill_id: str, target_level: str = "published", reason: str = "") -> dict:
277
- _ensure_ready()
278
- sync_skill_directories()
279
- skill = get_skill(skill_id)
280
- if not skill:
281
- return {"ok": False, "error": f"Skill {skill_id} not found"}
282
- clean_target = str(target_level or "published").strip().lower()
283
- if clean_target not in VALID_LEVELS:
284
- return {"ok": False, "error": f"Unsupported target_level: {target_level}"}
285
- if clean_target == "archived":
286
- return {"ok": False, "error": "Use retire_skill to archive skills explicitly"}
287
- outcome_review = get_skill_outcome_evidence(skill_id)
288
- if outcome_review.get("has_evidence"):
289
- recommended = outcome_review.get("recommended_action")
290
- if clean_target == "published" and recommended not in {"promote_published", "promote_stable"}:
291
- return {
292
- "ok": False,
293
- "error": "Outcome evidence does not yet support promotion to published",
294
- "outcome_review": outcome_review,
295
- }
296
- if clean_target == "stable" and recommended != "promote_stable":
297
- return {
298
- "ok": False,
299
- "error": "Outcome evidence does not yet support promotion to stable",
300
- "outcome_review": outcome_review,
301
- }
302
- updated = update_skill(skill_id, level=clean_target)
303
- if "error" in updated:
304
- return {"ok": False, "error": updated["error"]}
305
- return {
306
- "ok": True,
307
- "skill_id": skill_id,
308
- "previous_level": skill.get("level", ""),
309
- "level": updated.get("level", clean_target),
310
- "reason": str(reason or "").strip(),
311
- "outcome_review": outcome_review,
312
- }
313
-
314
-
315
- def retire_skill(skill_id: str, replacement_id: str = "", reason: str = "") -> dict:
316
- _ensure_ready()
317
- sync_skill_directories()
318
- skill = get_skill(skill_id)
319
- if not skill:
320
- return {"ok": False, "error": f"Skill {skill_id} not found"}
321
- replacement = None
322
- clean_replacement = str(replacement_id or "").strip()
323
- if clean_replacement:
324
- replacement = get_skill(clean_replacement)
325
- if not replacement:
326
- return {"ok": False, "error": f"Replacement skill {clean_replacement} not found"}
327
- updated = update_skill(skill_id, level="archived")
328
- if "error" in updated:
329
- return {"ok": False, "error": updated["error"]}
330
- return {
331
- "ok": True,
332
- "skill_id": skill_id,
333
- "level": updated.get("level", "archived"),
334
- "replacement_id": clean_replacement,
335
- "reason": str(reason or "").strip(),
336
- "outcome_review": get_skill_outcome_evidence(skill_id),
337
- }
338
-
339
-
340
- def review_skill_outcomes(skill_id: str, auto_apply: bool = False) -> dict:
341
- _ensure_ready()
342
- sync_skill_directories()
343
- skill = get_skill(skill_id)
344
- if not skill:
345
- return {"ok": False, "error": f"Skill {skill_id} not found"}
346
-
347
- review = get_skill_outcome_evidence(skill_id)
348
- if "error" in review:
349
- return {"ok": False, "error": review["error"]}
350
-
351
- result = {
352
- "ok": True,
353
- "skill_id": skill_id,
354
- "level": skill.get("level", ""),
355
- "review": review,
356
- "auto_applied": False,
357
- "applied_action": "",
358
- }
359
- if not auto_apply:
360
- return result
361
-
362
- action = review.get("recommended_action")
363
- target_level = ""
364
- if action == "promote_published":
365
- target_level = "published"
366
- elif action == "promote_stable":
367
- target_level = "stable"
368
- elif action == "retire":
369
- target_level = "archived"
370
- if not target_level:
371
- return result
372
-
373
- updated = update_skill(skill_id, level=target_level)
374
- if "error" in updated:
375
- return {"ok": False, "error": updated["error"], "review": review}
376
- result["auto_applied"] = True
377
- result["applied_action"] = action
378
- result["level"] = updated.get("level", target_level)
379
- result["skill"] = updated
380
- return result
381
-
382
-
383
- def compose_skills(
384
- *,
385
- new_skill_id: str,
386
- name: str,
387
- component_ids: list[str],
388
- description: str = "",
389
- level: str = "draft",
390
- mode: str = "guide",
391
- tags: list[str] | None = None,
392
- trigger_patterns: list[str] | None = None,
393
- ) -> dict:
394
- _ensure_ready()
395
- sync_skill_directories()
396
- if get_skill(new_skill_id):
397
- return {"ok": False, "error": f"Skill {new_skill_id} already exists"}
398
- components = []
399
- for skill_id in component_ids:
400
- skill = get_skill(skill_id)
401
- if not skill:
402
- return {"ok": False, "error": f"Component skill {skill_id} not found"}
403
- components.append(skill)
404
- if not components:
405
- return {"ok": False, "error": "At least one component skill is required"}
406
-
407
- merged_steps: list[str] = []
408
- merged_gotchas: list[str] = []
409
- merged_tags = set(tags or [])
410
- merged_triggers = set(trigger_patterns or [])
411
- linked_learnings = set()
412
- source_sessions = set()
413
- content_lines = [f"# {name}", "", description or "Composite skill built from existing NEXO skills.", "", "## Components"]
414
- for skill in components:
415
- content_lines.append(f"- {skill['id']}: {skill['name']}")
416
- for step in _skill_json_list(skill, "steps"):
417
- if step and step not in merged_steps:
418
- merged_steps.append(step)
419
- for gotcha in _skill_json_list(skill, "gotchas"):
420
- if gotcha and gotcha not in merged_gotchas:
421
- merged_gotchas.append(gotcha)
422
- for trigger in _skill_json_list(skill, "trigger_patterns"):
423
- if trigger:
424
- merged_triggers.add(trigger)
425
- for tag in _skill_json_list(skill, "tags"):
426
- if tag:
427
- merged_tags.add(tag)
428
- for item in _skill_json_list(skill, "linked_learnings"):
429
- if item:
430
- linked_learnings.add(item)
431
- for item in _skill_json_list(skill, "source_sessions"):
432
- if item:
433
- source_sessions.add(item)
434
-
435
- if merged_steps:
436
- content_lines.extend(["", "## Steps"])
437
- for index, step in enumerate(merged_steps, 1):
438
- content_lines.append(f"{index}. {step}")
439
- if merged_gotchas:
440
- content_lines.extend(["", "## Gotchas"])
441
- for gotcha in merged_gotchas:
442
- content_lines.append(f"- {gotcha}")
443
-
444
- created = create_skill(
445
- skill_id=new_skill_id,
446
- name=name,
447
- description=description or f"Composite skill built from {', '.join(component_ids)}",
448
- level=level,
449
- tags=sorted(merged_tags),
450
- trigger_patterns=sorted(merged_triggers),
451
- source_sessions=sorted(source_sessions),
452
- linked_learnings=sorted(linked_learnings),
453
- steps=merged_steps,
454
- gotchas=merged_gotchas,
455
- content="\n".join(content_lines).strip() + "\n",
456
- mode=mode,
457
- source_kind="personal",
458
- )
459
- if "error" in created:
460
- return {"ok": False, "error": created["error"]}
461
- return {
462
- "ok": True,
463
- "skill_id": new_skill_id,
464
- "component_ids": component_ids,
465
- "level": created.get("level", level),
466
- "mode": created.get("mode", mode),
467
- }
468
-
469
-
470
- def apply_skill(skill_id: str, params=None, mode: str = "auto", dry_run: bool = False, context: str = "") -> dict:
471
- _ensure_ready()
472
- sync_skill_directories()
473
- skill = get_skill(skill_id)
474
- if not skill:
475
- return {"ok": False, "error": f"Skill {skill_id} not found"}
476
-
477
- effective_mode = _resolve_mode(mode, skill)
478
- response = {
479
- "ok": True,
480
- "skill_id": skill["id"],
481
- "skill_name": skill["name"],
482
- "requested_mode": mode,
483
- "resolved_mode": effective_mode,
484
- "approval_state": {
485
- "approval_required": bool(skill.get("approval_required", 0)),
486
- "approved_at": skill.get("approved_at", ""),
487
- "execution_level": skill.get("execution_level", "none"),
488
- },
489
- }
490
-
491
- if effective_mode in {"guide", "hybrid"}:
492
- response["guide_summary"] = _summarize_skill(skill)
493
-
494
- if effective_mode in {"execute", "hybrid"}:
495
- exec_spec = get_skill_execution_spec(skill_id)
496
- if "error" in exec_spec:
497
- response["ok"] = False
498
- response["error"] = exec_spec["error"]
499
- return response
500
-
501
- if not skill.get("file_path"):
502
- response["ok"] = False
503
- response["error"] = f"Skill {skill_id} has no executable script"
504
- return response
505
-
506
- if exec_spec["execution_level"] in {"read-only", "local", "remote"} and not skill.get("approved_at"):
507
- skill = approve_skill(skill_id, execution_level=exec_spec["execution_level"], approved_by="system:auto")
508
- response["approval_state"] = {
509
- "approval_required": bool(skill.get("approval_required", 0)),
510
- "approved_at": skill.get("approved_at", ""),
511
- "execution_level": skill.get("execution_level", exec_spec["execution_level"]),
512
- }
513
-
514
- doctor = doctor_script(skill["file_path"])
515
- response["script_doctor"] = doctor
516
- if doctor["status"] == "fail":
517
- response["ok"] = False
518
- response["error"] = "Skill script failed validation"
519
- return response
520
-
521
- rendered = render_command_template(skill, _parse_params(params))
522
- if not rendered.get("ok"):
523
- response["ok"] = False
524
- response["error"] = "Invalid skill parameters"
525
- response["param_errors"] = rendered.get("errors", [])
526
- return response
527
-
528
- argv = rendered["argv"] or [skill["file_path"]]
529
- response["resolved_params"] = rendered["params"]
530
- response["script_command"] = argv
531
- if dry_run:
532
- response["dry_run"] = True
533
- return response
534
-
535
- execution = _run_skill_script(skill, argv)
536
- response["execution_result"] = execution
537
- success = execution["returncode"] == 0
538
- record = record_skill_usage(
539
- skill_id=skill_id,
540
- success=success,
541
- context=context or skill["name"],
542
- notes=(execution["stderr"] or execution["stdout"])[:500],
543
- )
544
- response["usage_recorded"] = {
545
- "success": success,
546
- "trust_score": record.get("trust_score"),
547
- "level": record.get("level"),
548
- "promotion": record.get("_promotion"),
549
- }
550
- if not success:
551
- response["ok"] = False
552
- response["error"] = f"Skill execution failed with exit {execution['returncode']}"
553
-
554
- return response
555
-
556
-
557
- def sync_skills() -> dict:
558
- _ensure_ready()
559
- return sync_skill_directories()
560
-
561
-
562
- def approve_skill_execution(skill_id: str, execution_level: str = "", approved_by: str = "") -> dict:
563
- _ensure_ready()
564
- return approve_skill(skill_id, execution_level=execution_level, approved_by=approved_by)
565
-
566
-
567
- def list_evolution_candidates() -> dict:
568
- _ensure_ready()
569
- sync_skill_directories()
570
- outcome_patterns = [
571
- candidate
572
- for candidate in list_outcome_pattern_candidates(limit=20)
573
- if candidate.get("candidate_type") == "reinforce_strategy" and candidate.get("suggested_skill_candidate")
574
- ]
575
- return {
576
- "scriptable": collect_scriptable_skill_candidates(),
577
- "improvements": collect_skill_improvement_candidates(),
578
- "outcome_patterns": outcome_patterns,
579
- "outcome_lifecycle": list_skill_outcome_reviews(limit=20, actionable_only=True),
580
- }
581
-
582
-
583
- def materialize_outcome_pattern_skill(pattern_key: str) -> dict:
584
- _ensure_ready()
585
- sync_skill_directories()
586
- candidates = list_outcome_pattern_candidates(limit=200)
587
- candidate = next((item for item in candidates if item.get("pattern_key") == pattern_key), None)
588
- if not candidate:
589
- return {"ok": False, "error": "Outcome pattern candidate not found"}
590
- if candidate.get("candidate_type") != "reinforce_strategy":
591
- return {"ok": False, "error": "Only reinforce_strategy patterns can seed a skill"}
592
- if not candidate.get("suggested_skill_candidate"):
593
- return {"ok": False, "error": "Pattern is not strong enough yet to seed a skill draft"}
594
-
595
- learning_result = capture_outcome_pattern(pattern_key, target="learning", category="outcomes")
596
- if "error" in learning_result:
597
- return {"ok": False, "error": learning_result["error"]}
598
- learning = learning_result.get("learning") or {}
599
- skill_id = _outcome_skill_id(candidate)
600
- existing = get_skill(skill_id)
601
- if existing:
602
- return {
603
- "ok": True,
604
- "created": False,
605
- "skill": existing,
606
- "candidate": candidate,
607
- "learning": learning,
608
- }
609
-
610
- payload = _outcome_pattern_to_skill_payload(candidate, int(learning.get("id", 0) or 0))
611
- created = materialize_personal_skill_definition(payload)
612
- if "error" in created:
613
- return {"ok": False, "error": created["error"]}
614
- return {
615
- "ok": True,
616
- "created": True,
617
- "skill": created,
618
- "candidate": candidate,
619
- "learning": learning,
620
- }
621
-
622
-
623
- def auto_promote_outcome_patterns_to_skills(
624
- *,
625
- min_success_rate: float = 0.8,
626
- max_promotions: int = 3,
627
- ) -> dict:
628
- """Promote mature outcome patterns to draft skills without manual approval.
629
-
630
- Closes Fase 2 item 2 of NEXO-AUDIT-2026-04-11. Until this function ran
631
- automatically, every outcome pattern that crossed the suggested-skill
632
- threshold sat in the candidates table waiting for an explicit
633
- nexo_skill_seed_from_outcome_pattern call. The materialization helper
634
- already existed (see materialize_outcome_pattern_skill above), but no
635
- process invoked it on a schedule.
636
-
637
- This wrapper:
638
- - Lists current outcome pattern candidates (top 20).
639
- - Filters to reinforce_strategy candidates that the analyzer already
640
- flagged as suggested_skill_candidate=True (resolved >= 4) AND whose
641
- success_rate is at or above min_success_rate (default 0.8).
642
- - Calls materialize_outcome_pattern_skill() per qualifying candidate,
643
- capped at max_promotions per invocation so a sudden flood does not
644
- materialize dozens of skills in one cycle.
645
- - materialize_outcome_pattern_skill() is itself idempotent: if the
646
- target skill id already exists it returns created=False without
647
- re-creating, so this function is safe to run repeatedly.
648
-
649
- Returns a stats dict:
650
- {
651
- "promoted": [list of {pattern_key, skill_id, created}],
652
- "skipped": [list of {pattern_key, reason}],
653
- "errors": [list of {pattern_key, error}],
654
- "scanned": int, # number of candidates inspected
655
- }
656
-
657
- Best-effort: never raises. A single failing pattern logs an error entry
658
- but lets the loop continue, so one bad row never blocks the queue.
659
- """
660
- _ensure_ready()
661
- sync_skill_directories()
662
-
663
- promoted: list[dict] = []
664
- skipped: list[dict] = []
665
- errors: list[dict] = []
666
-
667
- try:
668
- candidates = list_outcome_pattern_candidates(limit=20)
669
- except Exception as e:
670
- return {
671
- "promoted": [],
672
- "skipped": [],
673
- "errors": [{"pattern_key": "*", "error": f"list_outcome_pattern_candidates raised: {e}"}],
674
- "scanned": 0,
675
- }
676
-
677
- scanned = 0
678
- promote_budget = max(0, int(max_promotions))
679
- for candidate in candidates:
680
- scanned += 1
681
- pattern_key = (candidate.get("pattern_key") or "").strip()
682
- if not pattern_key:
683
- skipped.append({"pattern_key": "", "reason": "missing pattern_key"})
684
- continue
685
- if candidate.get("candidate_type") != "reinforce_strategy":
686
- skipped.append({"pattern_key": pattern_key, "reason": "not reinforce_strategy"})
687
- continue
688
- if not candidate.get("suggested_skill_candidate"):
689
- skipped.append({"pattern_key": pattern_key, "reason": "below suggested_skill_candidate threshold"})
690
- continue
691
- success_rate = float(candidate.get("success_rate") or 0.0)
692
- if success_rate < float(min_success_rate):
693
- skipped.append({
694
- "pattern_key": pattern_key,
695
- "reason": f"success_rate {success_rate:.3f} < {min_success_rate:.3f}",
696
- })
697
- continue
698
- if promote_budget <= 0:
699
- skipped.append({"pattern_key": pattern_key, "reason": "promotion budget exhausted"})
700
- continue
701
-
702
- try:
703
- result = materialize_outcome_pattern_skill(pattern_key)
704
- except Exception as e:
705
- errors.append({"pattern_key": pattern_key, "error": str(e)})
706
- continue
707
-
708
- if not result.get("ok"):
709
- errors.append({
710
- "pattern_key": pattern_key,
711
- "error": result.get("error", "materialize_outcome_pattern_skill failed"),
712
- })
713
- continue
714
-
715
- skill = result.get("skill") or {}
716
- promoted.append({
717
- "pattern_key": pattern_key,
718
- "skill_id": skill.get("id") or skill.get("skill_id"),
719
- "created": bool(result.get("created")),
720
- "success_rate": success_rate,
721
- "resolved_outcomes": int(candidate.get("resolved_outcomes") or 0),
722
- })
723
- # Only newly-created skills consume the budget. Idempotent re-use of
724
- # an existing skill costs nothing because it didn't materialize a new
725
- # one — letting other candidates through.
726
- if result.get("created"):
727
- promote_budget -= 1
728
-
729
- return {
730
- "promoted": promoted,
731
- "skipped": skipped,
732
- "errors": errors,
733
- "scanned": scanned,
734
- }
735
-
736
-
737
- def detect_skill_coactivation_candidates(
738
- *,
739
- min_co_occurrence: int = 3,
740
- min_success_rate: float = 0.6,
741
- limit: int = 20,
742
- ) -> list[dict]:
743
- """Detect pairs of skills that fire together and could be composed.
744
-
745
- Closes Fase 5 item 5 of NEXO-AUDIT-2026-04-11. NEXO already has
746
- compose_skills (manual composer), auto_promote_outcome_patterns_to_skills
747
- (Fase 2 item 2), and auto_promote_skill_evolution (guide → executable
748
- draft). What it lacked is the Voyager-style observation pass: when
749
- the same two skills are used inside the same session multiple times,
750
- a composite skill probably wants to exist.
751
-
752
- This function reads skill_usage rows, groups by session_id to get
753
- the set of skills per session, builds a co-occurrence count over
754
- all unordered pairs, and returns candidates whose count is at or
755
- above min_co_occurrence and whose joint success rate is at or
756
- above min_success_rate.
757
-
758
- Args:
759
- min_co_occurrence: minimum number of sessions both skills must
760
- have co-occurred in. Default 3 — same threshold the
761
- outcome_pattern detector uses.
762
- min_success_rate: minimum joint success rate (success rows /
763
- total rows for the pair). Default 0.6.
764
- limit: max candidates to return. Default 20.
765
-
766
- Returns a list (newest co-occurrence first):
767
- [
768
- {
769
- "skill_a": str, "skill_b": str,
770
- "co_occurrence": int,
771
- "joint_success_rate": float,
772
- "sessions": [session_id, ...],
773
- "suggested_skill_id": str, # canonical id for the composite
774
- },
775
- ...
776
- ]
777
-
778
- Pure DB read, never raises. Empty list when skill_usage table is
779
- missing or no candidates qualify.
780
- """
781
- _ensure_ready()
782
- try:
783
- from db import get_db
784
- conn = get_db()
785
- # Sanity check the table exists.
786
- conn.execute("SELECT 1 FROM skill_usage LIMIT 1").fetchone()
787
- except Exception:
788
- return []
789
-
790
- try:
791
- rows = conn.execute(
792
- "SELECT skill_id, session_id, success FROM skill_usage "
793
- "WHERE session_id IS NOT NULL AND session_id != '' "
794
- "ORDER BY created_at DESC LIMIT 5000"
795
- ).fetchall()
796
- except Exception:
797
- return []
798
-
799
- by_session: dict[str, list[tuple[str, int]]] = {}
800
- for row in rows:
801
- sid = (row["session_id"] or "").strip() if hasattr(row, "keys") else (row[1] or "").strip()
802
- if not sid:
803
- continue
804
- skill_id = row["skill_id"] if hasattr(row, "keys") else row[0]
805
- success = row["success"] if hasattr(row, "keys") else row[2]
806
- by_session.setdefault(sid, []).append((skill_id, int(success or 0)))
807
-
808
- pair_stats: dict[tuple[str, str], dict] = {}
809
- for sid, usages in by_session.items():
810
- # Distinct skills with their best (any) success in this session.
811
- seen_in_session: dict[str, int] = {}
812
- for skill_id, success in usages:
813
- seen_in_session[skill_id] = max(seen_in_session.get(skill_id, 0), success)
814
- skills_in_session = sorted(seen_in_session.keys())
815
- if len(skills_in_session) < 2:
816
- continue
817
- for i in range(len(skills_in_session)):
818
- for j in range(i + 1, len(skills_in_session)):
819
- pair = (skills_in_session[i], skills_in_session[j])
820
- stats = pair_stats.setdefault(
821
- pair,
822
- {"co_occurrence": 0, "joint_success": 0, "sessions": []},
823
- )
824
- stats["co_occurrence"] += 1
825
- # Joint success: both skills succeeded in this session.
826
- if seen_in_session[pair[0]] and seen_in_session[pair[1]]:
827
- stats["joint_success"] += 1
828
- stats["sessions"].append(sid)
829
-
830
- candidates: list[dict] = []
831
- for (skill_a, skill_b), stats in pair_stats.items():
832
- co = stats["co_occurrence"]
833
- if co < int(min_co_occurrence):
834
- continue
835
- rate = stats["joint_success"] / co if co > 0 else 0.0
836
- if rate < float(min_success_rate):
837
- continue
838
- # Deterministic suggested id derived from the pair so re-running
839
- # the detector points at the same composite skill.
840
- suggested_id = "SK-COMPOSE-" + "+".join(sorted([skill_a, skill_b]))
841
- candidates.append({
842
- "skill_a": skill_a,
843
- "skill_b": skill_b,
844
- "co_occurrence": co,
845
- "joint_success_rate": round(rate, 3),
846
- "sessions": stats["sessions"][:5],
847
- "suggested_skill_id": suggested_id,
848
- })
849
-
850
- candidates.sort(key=lambda c: (-c["co_occurrence"], -c["joint_success_rate"]))
851
- return candidates[: max(1, int(limit))]
852
-
853
-
854
- def auto_promote_skill_evolution(approved_by: str = "system:auto") -> dict:
855
- """Convert mature guide skills into executable drafts without manual approval."""
856
- _ensure_ready()
857
- sync_skill_directories()
858
- promoted = []
859
- skipped = []
860
- for candidate in collect_scriptable_skill_candidates():
861
- skill = get_skill(candidate["id"])
862
- if not skill or skill.get("file_path"):
863
- continue
864
-
865
- steps = candidate.get("steps") or []
866
- gotchas = candidate.get("gotchas") or []
867
- description = candidate.get("description", "") or "Automated skill generated from repeated successful usage."
868
- lines = [
869
- "#!/usr/bin/env python3",
870
- '"""Auto-generated executable skill draft."""',
871
- "import json",
872
- "import sys",
873
- "",
874
- "def main() -> int:",
875
- " payload = {",
876
- f" 'skill_id': {json.dumps(candidate['id'])},",
877
- f" 'skill_name': {json.dumps(candidate['name'])},",
878
- f" 'description': {json.dumps(description)},",
879
- f" 'steps': {json.dumps(steps, ensure_ascii=False)},",
880
- f" 'gotchas': {json.dumps(gotchas, ensure_ascii=False)},",
881
- " 'argv': sys.argv[1:],",
882
- " }",
883
- " print(json.dumps(payload, ensure_ascii=False))",
884
- " return 0",
885
- "",
886
- 'if __name__ == "__main__":',
887
- " raise SystemExit(main())",
888
- "",
889
- ]
890
- update = update_skill(
891
- candidate["id"],
892
- mode=candidate.get("suggested_mode", "hybrid"),
893
- execution_level=candidate.get("suggested_execution_level", "read-only"),
894
- approval_required=0,
895
- approved_by=approved_by,
896
- )
897
- if "error" in update:
898
- skipped.append({"id": candidate["id"], "reason": update["error"]})
899
- continue
900
-
901
- materialized = materialize_personal_skill_definition(
902
- {
903
- "id": candidate["id"],
904
- "name": candidate["name"],
905
- "description": description,
906
- "level": skill.get("level", "published"),
907
- "mode": candidate.get("suggested_mode", "hybrid"),
908
- "execution_level": candidate.get("suggested_execution_level", "read-only"),
909
- "approved_by": approved_by,
910
- "tags": json.loads(skill.get("tags", "[]")) if skill.get("tags") else [],
911
- "trigger_patterns": candidate.get("trigger_patterns", []),
912
- "source_sessions": candidate.get("source_sessions", []),
913
- "steps": steps,
914
- "gotchas": gotchas,
915
- "content": skill.get("content", ""),
916
- "command_template": {"argv": ["{{file_path}}"]},
917
- "executable_entry": "script.py",
918
- "script_body": "\n".join(lines),
919
- }
920
- )
921
- if "error" in materialized:
922
- skipped.append({"id": candidate["id"], "reason": materialized["error"]})
923
- continue
924
-
925
- promoted.append(
926
- {
927
- "id": candidate["id"],
928
- "mode": candidate.get("suggested_mode", "hybrid"),
929
- "execution_level": candidate.get("suggested_execution_level", "read-only"),
930
- }
931
- )
932
- return {"promoted": promoted, "skipped": skipped}