nexo-brain 5.3.20 → 5.3.21

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 (210) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/auto_update.py +11 -8
  4. package/src/dashboard/static/favicon 2.svg +32 -0
  5. package/src/dashboard/static/nexo-logo 2.png +0 -0
  6. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  7. package/src/dashboard/static/style 2.css +2458 -0
  8. package/src/dashboard/templates/adaptive 2.html +118 -0
  9. package/src/dashboard/templates/artifacts 2.html +133 -0
  10. package/src/dashboard/templates/backups 2.html +136 -0
  11. package/src/dashboard/templates/base 2.html +417 -0
  12. package/src/dashboard/templates/calendar 2.html +591 -0
  13. package/src/dashboard/templates/chat 2.html +356 -0
  14. package/src/dashboard/templates/claims 2.html +259 -0
  15. package/src/dashboard/templates/cortex 2.html +321 -0
  16. package/src/dashboard/templates/credentials 2.html +128 -0
  17. package/src/dashboard/templates/crons 2.html +370 -0
  18. package/src/dashboard/templates/dashboard 2.html +494 -0
  19. package/src/dashboard/templates/dreams 2.html +252 -0
  20. package/src/dashboard/templates/email 2.html +160 -0
  21. package/src/dashboard/templates/evolution 2.html +189 -0
  22. package/src/dashboard/templates/feed 2.html +249 -0
  23. package/src/dashboard/templates/followup_health 2.html +170 -0
  24. package/src/dashboard/templates/graph 2.html +201 -0
  25. package/src/dashboard/templates/guard 2.html +259 -0
  26. package/src/dashboard/templates/inbox 2.html +251 -0
  27. package/src/dashboard/templates/memory 2.html +420 -0
  28. package/src/dashboard/templates/operations 2.html +608 -0
  29. package/src/dashboard/templates/plugins 2.html +185 -0
  30. package/src/dashboard/templates/protocol 2.html +199 -0
  31. package/src/dashboard/templates/rules 2.html +246 -0
  32. package/src/dashboard/templates/sentiment 2.html +247 -0
  33. package/src/dashboard/templates/sessions 2.html +218 -0
  34. package/src/dashboard/templates/skills 2.html +329 -0
  35. package/src/dashboard/templates/somatic 2.html +73 -0
  36. package/src/dashboard/templates/triggers 2.html +133 -0
  37. package/src/dashboard/templates/trust 2.html +360 -0
  38. package/src/db/__init__ 2.py +259 -0
  39. package/src/db/_core 2.py +437 -0
  40. package/src/db/_credentials 2.py +124 -0
  41. package/src/db/_episodic 2.py +762 -0
  42. package/src/db/_evolution 2.py +54 -0
  43. package/src/db/_fts 2.py +406 -0
  44. package/src/db/_goal_profiles 2.py +376 -0
  45. package/src/db/_hot_context 2.py +660 -0
  46. package/src/db/_outcomes 2.py +800 -0
  47. package/src/db/_personal_scripts 2.py +582 -0
  48. package/src/db/_sessions 2.py +330 -0
  49. package/src/db/_tasks 2.py +91 -0
  50. package/src/db/_watchers 2.py +173 -0
  51. package/src/doctor/formatters 2.py +52 -0
  52. package/src/doctor/models 2.py +69 -0
  53. package/src/doctor/planes 2.py +87 -0
  54. package/src/doctor/providers/__init__ 2.py +1 -0
  55. package/src/doctor/providers/deep 2.py +367 -0
  56. package/src/evolution_cycle 2.py +519 -0
  57. package/src/hooks/auto_capture 2.py +208 -0
  58. package/src/hooks/caffeinate-guard 2.sh +8 -0
  59. package/src/hooks/capture-session 2.sh +21 -0
  60. package/src/hooks/capture-tool-logs 2.sh +158 -0
  61. package/src/hooks/daily-briefing-check 2.sh +33 -0
  62. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  63. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  64. package/src/hooks/inbox-hook 2.sh +76 -0
  65. package/src/hooks/post-compact 2.sh +152 -0
  66. package/src/hooks/pre-compact 2.sh +169 -0
  67. package/src/hooks/protocol-guardrail 2.sh +10 -0
  68. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  69. package/src/hooks/session-stop 2.sh +52 -0
  70. package/src/kg_populate 2.py +292 -0
  71. package/src/maintenance 2.py +53 -0
  72. package/src/memory_backends 2.py +71 -0
  73. package/src/migrate_embeddings 2.py +124 -0
  74. package/src/nexo_sdk 2.py +103 -0
  75. package/src/observability 2.py +199 -0
  76. package/src/plugin_loader 2.py +217 -0
  77. package/src/plugins/__init__ 2.py +0 -0
  78. package/src/plugins/artifact_registry 2.py +450 -0
  79. package/src/plugins/backup 2.py +127 -0
  80. package/src/plugins/claims_tools 2.py +119 -0
  81. package/src/plugins/cognitive_memory 2.py +609 -0
  82. package/src/plugins/core_rules 2.py +252 -0
  83. package/src/plugins/cortex 2.py +1155 -0
  84. package/src/plugins/entities 2.py +67 -0
  85. package/src/plugins/episodic_memory 2.py +560 -0
  86. package/src/plugins/evolution 2.py +167 -0
  87. package/src/plugins/goal_engine 2.py +142 -0
  88. package/src/plugins/guard 2.py +862 -0
  89. package/src/plugins/impact 2.py +29 -0
  90. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  91. package/src/plugins/media_memory_tools 2.py +98 -0
  92. package/src/plugins/memory_export 2.py +196 -0
  93. package/src/plugins/outcomes 2.py +130 -0
  94. package/src/plugins/personal_scripts 2.py +117 -0
  95. package/src/plugins/preferences 2.py +47 -0
  96. package/src/plugins/protocol 2.py +1449 -0
  97. package/src/plugins/simple_api 2.py +106 -0
  98. package/src/plugins/skills 2.py +341 -0
  99. package/src/plugins/state_watchers 2.py +79 -0
  100. package/src/plugins/update 2.py +986 -0
  101. package/src/plugins/user_state_tools 2.py +43 -0
  102. package/src/plugins/workflow 2.py +588 -0
  103. package/src/protocol_settings 2.py +59 -0
  104. package/src/public_contribution 2.py +466 -0
  105. package/src/public_evolution_queue 2.py +241 -0
  106. package/src/requirements 2.txt +14 -0
  107. package/src/retroactive_learnings 2.py +373 -0
  108. package/src/rules/__init__ 2.py +0 -0
  109. package/src/rules/core-rules 2.json +331 -0
  110. package/src/rules/migrate 2.py +207 -0
  111. package/src/runtime_power 2.py +874 -0
  112. package/src/script_registry 2.py +1559 -0
  113. package/src/scripts/check-context 2.py +272 -0
  114. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  115. package/src/scripts/deep-sleep/collect 2.py +928 -0
  116. package/src/scripts/deep-sleep/extract 2.py +330 -0
  117. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  118. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  119. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  120. package/src/scripts/nexo-agent-run 2.py +75 -0
  121. package/src/scripts/nexo-auto-update 2.py +6 -0
  122. package/src/scripts/nexo-backup 2.sh +25 -0
  123. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  124. package/src/scripts/nexo-catchup 2.py +300 -0
  125. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  126. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  127. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  128. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  129. package/src/scripts/nexo-dashboard 2.sh +29 -0
  130. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  131. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  132. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  133. package/src/scripts/nexo-hook-record 2.py +42 -0
  134. package/src/scripts/nexo-immune 2.py +936 -0
  135. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  136. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  137. package/src/scripts/nexo-install 2.py +6 -0
  138. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  139. package/src/scripts/nexo-learning-validator 2.py +266 -0
  140. package/src/scripts/nexo-migrate 2.py +260 -0
  141. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  142. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  143. package/src/scripts/nexo-pre-commit 2.py +120 -0
  144. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  145. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  146. package/src/scripts/nexo-reflection 2.py +256 -0
  147. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  148. package/src/scripts/nexo-sleep 2.py +631 -0
  149. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  150. package/src/scripts/nexo-sync-clients 2.py +16 -0
  151. package/src/scripts/nexo-synthesis 2.py +475 -0
  152. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  153. package/src/scripts/nexo-update 2.sh +306 -0
  154. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  155. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  156. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  157. package/src/server 2.py +1296 -0
  158. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  159. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  160. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  161. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  162. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  163. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  164. package/src/skills/run-release-final-audit/script 2.py +259 -0
  165. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  166. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  167. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  168. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  169. package/src/skills_runtime 2.py +932 -0
  170. package/src/state_watchers_runtime 2.py +475 -0
  171. package/src/storage_router 2.py +32 -0
  172. package/src/system_catalog 2.py +786 -0
  173. package/src/tools_coordination 2.py +103 -0
  174. package/src/tools_credentials 2.py +68 -0
  175. package/src/tools_drive 2.py +487 -0
  176. package/src/tools_hot_context 2.py +163 -0
  177. package/src/tools_learnings 2.py +612 -0
  178. package/src/tools_menu 2.py +229 -0
  179. package/src/tools_reminders 2.py +88 -0
  180. package/src/tools_reminders_crud 2.py +363 -0
  181. package/src/tools_sessions 2.py +1054 -0
  182. package/src/tools_system_catalog 2.py +19 -0
  183. package/src/tools_task_history 2.py +57 -0
  184. package/src/tools_transcripts 2.py +98 -0
  185. package/src/transcript_utils 2.py +412 -0
  186. package/src/user_context 2.py +46 -0
  187. package/src/user_data_portability 2.py +328 -0
  188. package/src/user_state_model 2.py +170 -0
  189. package/templates/CLAUDE.md 2.template +108 -0
  190. package/templates/CODEX.AGENTS.md 2.template +66 -0
  191. package/templates/launchagents/README 2.md +132 -0
  192. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  193. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  195. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  196. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  198. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  199. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  200. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  201. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  202. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  203. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  204. package/templates/nexo_helper 2.py +301 -0
  205. package/templates/openclaw 2.json +13 -0
  206. package/templates/plugin-template 2.py +40 -0
  207. package/templates/script-template 2.py +59 -0
  208. package/templates/script-template 2.sh +13 -0
  209. package/templates/skill-script-template 2.py +48 -0
  210. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,1449 @@
1
+ """Protocol discipline plugin — persistent task contracts for NEXO."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import os
8
+ import re
9
+ import secrets
10
+ import time
11
+
12
+ from db import (
13
+ VALID_TASK_TYPES,
14
+ VALID_CLOSE_OUTCOMES,
15
+ close_protocol_task,
16
+ create_followup,
17
+ latest_cortex_evaluation_for_task,
18
+ create_protocol_debt,
19
+ create_protocol_task,
20
+ build_pre_action_context,
21
+ capture_context_event,
22
+ format_pre_action_context_bundle,
23
+ get_db,
24
+ get_followups,
25
+ get_protocol_task,
26
+ list_workflow_goals,
27
+ list_workflow_runs,
28
+ list_protocol_debts,
29
+ log_change,
30
+ resolve_protocol_debts,
31
+ search_learnings,
32
+ task_has_cortex_evaluation,
33
+ validate_close_outcome,
34
+ validate_task_type,
35
+ )
36
+ from plugins.cortex import evaluate_cortex_state
37
+ from plugins.guard import handle_guard_check
38
+ from protocol_settings import get_protocol_strictness
39
+ from tools_sessions import handle_heartbeat
40
+
41
+
42
+ ACTION_TASKS = {"edit", "execute", "delegate"}
43
+ RESPONSE_TASKS = {"answer", "analyze"}
44
+ HIGH_STAKES_KEYWORDS = {
45
+ "medical",
46
+ "legal",
47
+ "financial",
48
+ "billing",
49
+ "invoice",
50
+ "payment",
51
+ "credential",
52
+ "password",
53
+ "security",
54
+ "production",
55
+ "deploy",
56
+ "release",
57
+ "launch",
58
+ "delete",
59
+ "migration",
60
+ "pricing",
61
+ "refund",
62
+ "customer",
63
+ "public",
64
+ "brand",
65
+ "reputation",
66
+ "reputational",
67
+ "roadmap",
68
+ "revenue",
69
+ "cost",
70
+ }
71
+ # v5.2.0: Spanish high-stakes keywords. Parity with the English set so a
72
+ # goal written in Spanish ("migrar producción a nuevo servidor") trips
73
+ # the same high-stakes gate as its English twin. Accented and unaccented
74
+ # variants are both listed because user prompts mix both freely.
75
+ HIGH_STAKES_KEYWORDS_ES = {
76
+ "crítico",
77
+ "critico",
78
+ "crítica",
79
+ "critica",
80
+ "producción",
81
+ "produccion",
82
+ "cliente",
83
+ "clientes",
84
+ "despliegue",
85
+ "desplegar",
86
+ "pago",
87
+ "pagos",
88
+ "facturación",
89
+ "facturacion",
90
+ "factura",
91
+ "credencial",
92
+ "credenciales",
93
+ "contraseña",
94
+ "seguridad",
95
+ "legal",
96
+ "médico",
97
+ "medico",
98
+ "financiero",
99
+ "financiera",
100
+ "privacidad",
101
+ "marca",
102
+ "reputación",
103
+ "reputacion",
104
+ "ingresos",
105
+ "borrar",
106
+ "eliminar",
107
+ "migración",
108
+ "migracion",
109
+ "migrar",
110
+ "lanzamiento",
111
+ "lanzar",
112
+ "precio",
113
+ "precios",
114
+ "reembolso",
115
+ "público",
116
+ "publico",
117
+ "riesgo",
118
+ "riesgos",
119
+ "coste",
120
+ "costes",
121
+ "ventas",
122
+ "pedido",
123
+ "pedidos",
124
+ }
125
+ # v5.2.0: Negation patterns that should SUPPRESS the high-stakes flag.
126
+ # Without this, a user message like "sin afectar producción" or
127
+ # "no tocar prod" triggers a false positive just because the keyword
128
+ # is physically present. Bilingual and conservative on purpose.
129
+ NEGATION_PATTERNS = (
130
+ re.compile(r"\bno\s+tocar\s+prod(?:ucci[oó]n|uccion)?\b", re.IGNORECASE),
131
+ re.compile(r"\bsin\s+(?:tocar|afectar|romper|modificar)\b", re.IGNORECASE),
132
+ re.compile(r"\bnunca\s+(?:borrar|eliminar|tocar)\b", re.IGNORECASE),
133
+ re.compile(r"\bno\s+(?:borrar|eliminar|tocar|modificar)\b", re.IGNORECASE),
134
+ re.compile(r"\bevitar\s+(?:borrar|eliminar|tocar|romper)\b", re.IGNORECASE),
135
+ re.compile(r"\bavoid\s+(?:deleting|touching|breaking|modifying)\b", re.IGNORECASE),
136
+ re.compile(r"\bdon'?t\s+(?:touch|break|modify|delete)\b", re.IGNORECASE),
137
+ re.compile(r"\bwithout\s+(?:touching|breaking|affecting)\b", re.IGNORECASE),
138
+ )
139
+
140
+
141
+ def _parse_list(value) -> list[str]:
142
+ if isinstance(value, list):
143
+ return [str(item).strip() for item in value if str(item).strip()]
144
+ if not value:
145
+ return []
146
+ if isinstance(value, str):
147
+ stripped = value.strip()
148
+ if not stripped:
149
+ return []
150
+ try:
151
+ parsed = json.loads(stripped)
152
+ except json.JSONDecodeError:
153
+ parsed = None
154
+ if isinstance(parsed, list):
155
+ return [str(item).strip() for item in parsed if str(item).strip()]
156
+ return [item.strip() for item in stripped.split(",") if item.strip()]
157
+ return [str(value).strip()]
158
+
159
+
160
+ def _parse_bool(value) -> bool:
161
+ if isinstance(value, bool):
162
+ return value
163
+ if isinstance(value, str):
164
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
165
+ return bool(value)
166
+
167
+
168
+ def _parse_int_list(value) -> list[int]:
169
+ items = _parse_list(value)
170
+ parsed: list[int] = []
171
+ for item in items:
172
+ try:
173
+ parsed.append(int(item))
174
+ except (TypeError, ValueError):
175
+ continue
176
+ return parsed
177
+
178
+
179
+ def _has_negation_context(text: str) -> bool:
180
+ """Return True when the text explicitly disclaims touching the sensitive area.
181
+
182
+ Used to suppress high-stakes false positives where the user is stating
183
+ the *boundary* of safe work ("without touching production") rather than
184
+ the *target* of a risky action ("migrate production").
185
+ """
186
+ if not text:
187
+ return False
188
+ return any(pattern.search(text) for pattern in NEGATION_PATTERNS)
189
+
190
+
191
+ def _detect_high_stakes(*parts: str) -> bool:
192
+ combined = " ".join((part or "").strip().lower() for part in parts if part)
193
+ if not combined:
194
+ return False
195
+ # Negation override: "sin afectar producción" / "don't touch prod" / etc.
196
+ # Explicit disclaimers suppress the flag even if a high-stakes keyword
197
+ # is physically present, otherwise boundary statements get miscategorised
198
+ # as action targets.
199
+ if _has_negation_context(combined):
200
+ return False
201
+ return any(
202
+ keyword in combined
203
+ for keyword in HIGH_STAKES_KEYWORDS | HIGH_STAKES_KEYWORDS_ES
204
+ )
205
+
206
+
207
+ def _decision_support_required(*, task_type: str, high_stakes: bool) -> bool:
208
+ return task_type in ACTION_TASKS and high_stakes
209
+
210
+
211
+ def evaluate_response_confidence(
212
+ *,
213
+ goal: str,
214
+ task_type: str,
215
+ area: str = "",
216
+ context_hint: str = "",
217
+ constraints=None,
218
+ evidence_refs=None,
219
+ unknowns=None,
220
+ verification_step: str = "",
221
+ stakes: str = "",
222
+ pre_action_context_hits: int = 0,
223
+ area_has_atlas_entry: bool = False,
224
+ ) -> dict:
225
+ evidence_refs = _parse_list(evidence_refs)
226
+ unknowns = _parse_list(unknowns)
227
+ constraints = _parse_list(constraints)
228
+ explicit_stakes = (stakes or "").strip().lower()
229
+ high_stakes = explicit_stakes == "high" or _detect_high_stakes(
230
+ goal,
231
+ area,
232
+ context_hint,
233
+ " ".join(constraints),
234
+ explicit_stakes,
235
+ )
236
+
237
+ reasons: list[str] = []
238
+ score = 85
239
+ if unknowns:
240
+ score -= 35
241
+ reasons.append(f"{len(unknowns)} unknown(s) still unresolved")
242
+ if not evidence_refs:
243
+ score -= 25
244
+ reasons.append("no evidence_refs supplied")
245
+ if not verification_step.strip():
246
+ score -= 10
247
+ reasons.append("no verification_step defined")
248
+ if high_stakes:
249
+ score -= 20
250
+ reasons.append("high-stakes context detected")
251
+
252
+ # v5.2.0: Positive signals. Before this release the score was purely
253
+ # a penalty accumulator — there was no way to reward tasks that had
254
+ # meaningful prior context loaded or that sat inside a known area.
255
+ # Cap at +10 and +5 so these can never override a real risk signal.
256
+ if pre_action_context_hits > 0:
257
+ boost = min(10, pre_action_context_hits * 2)
258
+ score += boost
259
+ reasons.append(
260
+ f"+{boost} from {pre_action_context_hits} pre-action context hit(s)"
261
+ )
262
+ if area_has_atlas_entry:
263
+ score += 5
264
+ reasons.append("+5 from known project-atlas area")
265
+
266
+ final_score = max(0, min(100, score))
267
+
268
+ mode = "answer"
269
+ if task_type in RESPONSE_TASKS:
270
+ if high_stakes and (unknowns or not evidence_refs):
271
+ mode = "defer"
272
+ elif unknowns:
273
+ mode = "ask"
274
+ elif high_stakes or not evidence_refs or not verification_step.strip():
275
+ mode = "verify"
276
+
277
+ # v5.2.0: Numeric safeguard. The boolean decision tree above
278
+ # covers every obvious case, but tasks can accumulate soft
279
+ # penalties without tripping any single rule. When the final
280
+ # score is critically low, downgrade the mode by one step.
281
+ # This catches edge cases and is monotonic — it can only make
282
+ # the response discipline stricter, never looser.
283
+ if mode == "answer" and final_score < 50:
284
+ mode = "verify"
285
+ reasons.append(
286
+ f"numeric safeguard: score {final_score} < 50 forces verify"
287
+ )
288
+ elif mode == "verify" and final_score < 30 and high_stakes:
289
+ mode = "defer"
290
+ reasons.append(
291
+ f"numeric safeguard: high-stakes with score {final_score} forces defer"
292
+ )
293
+
294
+ next_action = {
295
+ "answer": "You may answer directly, but stay within the evidence you actually have.",
296
+ "verify": "Verify the claim with concrete evidence before answering.",
297
+ "ask": "Ask for the missing information instead of guessing.",
298
+ "defer": "Do not answer yet. Defer until you have evidence and a verification path.",
299
+ }[mode]
300
+
301
+ return {
302
+ "mode": mode,
303
+ "confidence": final_score,
304
+ "high_stakes": high_stakes,
305
+ "reasons": reasons,
306
+ "next_action": next_action,
307
+ }
308
+
309
+
310
+ def _guard_excerpt(text: str, max_lines: int = 12) -> str:
311
+ lines = [line for line in (text or "").splitlines() if line.strip()]
312
+ return "\n".join(lines[:max_lines])
313
+
314
+
315
+ def _extract_guard_blocking_ids(guard_summary: str) -> list[int]:
316
+ ids: list[int] = []
317
+ in_blocking = False
318
+ for raw_line in (guard_summary or "").splitlines():
319
+ line = raw_line.strip()
320
+ if line.startswith("BLOCKING RULES"):
321
+ in_blocking = True
322
+ continue
323
+ if in_blocking and not line:
324
+ break
325
+ if in_blocking:
326
+ match = re.search(r"#(\d+)", line)
327
+ if match:
328
+ ids.append(int(match.group(1)))
329
+ return ids
330
+
331
+
332
+ def _auto_followup_id() -> str:
333
+ return f"NF-PROTOCOL-{int(time.time())}-{secrets.randbelow(100000)}"
334
+
335
+
336
+ def _ensure_followup(description: str, *, verification: str = "", reasoning: str = "") -> dict:
337
+ conn = get_db()
338
+ row = conn.execute(
339
+ """SELECT id
340
+ FROM followups
341
+ WHERE status NOT LIKE 'COMPLETED%'
342
+ AND status NOT IN ('DELETED', 'archived', 'blocked', 'waiting')
343
+ AND description = ?
344
+ LIMIT 1""",
345
+ (description,),
346
+ ).fetchone()
347
+ if row:
348
+ return {"id": row["id"], "created": False}
349
+ # Content fingerprint for deterministic followup id — not security-sensitive.
350
+ followup_id = f"NF-PROTOCOL-{hashlib.sha1(description.encode('utf-8'), usedforsecurity=False).hexdigest()[:10].upper()}"
351
+ result = create_followup(
352
+ followup_id,
353
+ description,
354
+ verification=verification,
355
+ reasoning=reasoning,
356
+ )
357
+ if result and "error" not in result:
358
+ return {"id": result.get("id", followup_id), "created": True}
359
+ return {"id": "", "created": False, "error": result.get("error", "followup create failed") if isinstance(result, dict) else "followup create failed"}
360
+
361
+
362
+ def _attention_snapshot(session_id: str) -> dict:
363
+ goals = [goal for goal in list_workflow_goals(include_closed=False, limit=50) if goal.get("session_id") == session_id]
364
+ runs = [run for run in list_workflow_runs(include_closed=False, limit=50) if run.get("session_id") == session_id]
365
+
366
+ active_goals = [goal for goal in goals if goal.get("status") == "active"]
367
+ blocked_goals = [goal for goal in goals if goal.get("status") == "blocked"]
368
+ waiting_runs = [run for run in runs if run.get("status") in {"blocked", "waiting_approval"}]
369
+
370
+ status = "focused"
371
+ warnings: list[str] = []
372
+ recommended_action = "Current focus load is acceptable."
373
+
374
+ if len(active_goals) >= 4 or len(runs) >= 5:
375
+ status = "overloaded"
376
+ warnings.append("Too many active goals or open workflow runs are competing for attention.")
377
+ recommended_action = "Finish, block, or abandon one active goal before opening more execution work."
378
+ elif len(active_goals) >= 2 or len(runs) >= 3 or len(waiting_runs) >= 2:
379
+ status = "split"
380
+ warnings.append("Attention is split across multiple active goals or waiting workflow runs.")
381
+ recommended_action = "Narrow focus and make one next action explicit before expanding scope."
382
+
383
+ return {
384
+ "status": status,
385
+ "active_goals": len(active_goals),
386
+ "blocked_goals": len(blocked_goals),
387
+ "open_runs": len(runs),
388
+ "waiting_runs": len(waiting_runs),
389
+ "warnings": warnings,
390
+ "recommended_action": recommended_action,
391
+ "top_goal_titles": [goal.get("title", "") for goal in active_goals[:3]],
392
+ }
393
+
394
+
395
+ def _preview_prospective_triggers(goal: str, context_hint: str, files_list: list[str]) -> list[dict]:
396
+ text = " | ".join(part for part in [goal, context_hint, " ".join(files_list)] if part).strip()
397
+ if not text:
398
+ return []
399
+ try:
400
+ import cognitive
401
+ except Exception:
402
+ return []
403
+ try:
404
+ matches = cognitive.preview_triggers(text, use_semantic=False)
405
+ except Exception:
406
+ return []
407
+ return [
408
+ {
409
+ "id": match["id"],
410
+ "pattern": match["pattern"],
411
+ "action": match["action"],
412
+ "context": match.get("context", ""),
413
+ "match_type": match.get("match_type", "keyword"),
414
+ }
415
+ for match in matches
416
+ ]
417
+
418
+
419
+ ATLAS_PATH = os.path.join(
420
+ os.environ.get("NEXO_HOME", os.path.join(os.path.expanduser("~"), ".nexo")),
421
+ "brain",
422
+ "project-atlas.json",
423
+ )
424
+
425
+
426
+ def _build_area_context(area: str) -> dict:
427
+ """Build a pre-reading context block for a known area.
428
+
429
+ Returns project-atlas entry, recent area learnings, and active area followups
430
+ so the agent never starts 'cold' on a known project.
431
+ """
432
+ clean_area = (area or "").strip().lower()
433
+ if not clean_area:
434
+ return {"has_context": False}
435
+
436
+ # 1. Project-atlas lookup
437
+ atlas_entry = None
438
+ try:
439
+ with open(ATLAS_PATH, "r", encoding="utf-8") as f:
440
+ atlas = json.load(f)
441
+ for key, entry in atlas.items():
442
+ if key == "_meta":
443
+ continue
444
+ aliases = [a.lower() for a in entry.get("aliases", [])]
445
+ if clean_area == key.lower() or clean_area in aliases:
446
+ atlas_entry = {
447
+ "project_key": key,
448
+ "description": entry.get("description", ""),
449
+ "locations": entry.get("locations", {}),
450
+ "servers": {k: {sk: sv for sk, sv in v.items() if sk != "credential_key"} for k, v in entry.get("servers", {}).items()} if isinstance(entry.get("servers"), dict) else {},
451
+ }
452
+ break
453
+ except Exception:
454
+ pass
455
+
456
+ # 2. Recent area learnings (top 5)
457
+ area_learnings = []
458
+ try:
459
+ results = search_learnings(clean_area, category=clean_area)
460
+ if not results:
461
+ results = search_learnings(clean_area)
462
+ for learning in results[:5]:
463
+ area_learnings.append({
464
+ "id": learning.get("id"),
465
+ "title": (learning.get("title") or "")[:120],
466
+ "priority": learning.get("priority", "medium"),
467
+ })
468
+ except Exception:
469
+ pass
470
+
471
+ # 3. Active followups for the area (keyword match on description)
472
+ area_followups = []
473
+ try:
474
+ all_active = get_followups("active")
475
+ for followup in all_active:
476
+ desc = (followup.get("description") or "").lower()
477
+ fid = (followup.get("id") or "").lower()
478
+ if clean_area in desc or clean_area in fid:
479
+ area_followups.append({
480
+ "id": followup.get("id"),
481
+ "description": (followup.get("description") or "")[:120],
482
+ "date": followup.get("date"),
483
+ "priority": followup.get("priority", "medium"),
484
+ })
485
+ if len(area_followups) >= 5:
486
+ break
487
+ except Exception:
488
+ pass
489
+
490
+ has_context = bool(atlas_entry or area_learnings or area_followups)
491
+ return {
492
+ "has_context": has_context,
493
+ "area": clean_area,
494
+ "atlas_entry": atlas_entry,
495
+ "learnings_count": len(area_learnings),
496
+ "learnings": area_learnings,
497
+ "followups_count": len(area_followups),
498
+ "followups": area_followups,
499
+ }
500
+
501
+
502
+ def _create_preventive_followup(goal: str, *, attention: dict, warnings: list[dict]) -> dict | None:
503
+ warning_lines: list[str] = []
504
+ for match in warnings[:2]:
505
+ action = str(match.get("action") or "").strip()
506
+ if action:
507
+ warning_lines.append(action[:120])
508
+ if attention.get("warnings"):
509
+ warning_lines.append(str(attention["warnings"][0])[:120])
510
+ warning_lines = [line for idx, line in enumerate(warning_lines) if line and line not in warning_lines[:idx]]
511
+ if not warning_lines:
512
+ return None
513
+ description = (
514
+ f"Preventive followup before continuing '{goal[:90]}': "
515
+ + " | ".join(warning_lines[:3])
516
+ )
517
+ reasoning = (
518
+ "Created automatically during task_open because NEXO detected pre-failure warning signals "
519
+ "before execution started."
520
+ )
521
+ verification = (
522
+ "Pre-failure warning resolved or explicitly acknowledged through durable goals/workflows before continuing"
523
+ )
524
+ return _ensure_followup(description, verification=verification, reasoning=reasoning)
525
+
526
+
527
+ def _create_missing_learning_followup(task: dict, task_id: str, effective_files: list[str]) -> dict:
528
+ target = ", ".join(effective_files[:3]) if effective_files else (task.get("goal", "")[:120] or task_id)
529
+ description = (
530
+ f"Capture reusable learning from corrected task {task_id}: "
531
+ f"turn the fix around {target} into one canonical learning and supersede conflicting rules if needed."
532
+ )
533
+ reasoning = (
534
+ f"Protocol task {task_id} was marked as corrected but closed without a reusable learning. "
535
+ f"Prevent losing the fix or leaving contradictory active rules behind."
536
+ )
537
+ return create_followup(
538
+ (_auto_followup_id()).strip(),
539
+ description,
540
+ verification="Learning captured and conflicting rule lifecycle resolved",
541
+ reasoning=reasoning,
542
+ )
543
+
544
+
545
+ def _capture_learning(
546
+ task: dict,
547
+ task_id: str,
548
+ effective_files: list[str],
549
+ *,
550
+ category: str,
551
+ title: str,
552
+ content: str,
553
+ reasoning: str,
554
+ priority: str = "high",
555
+ ) -> dict:
556
+ from tools_learnings import find_conflicting_active_learning, handle_learning_add
557
+
558
+ clean_title = (title or "").strip()[:120]
559
+ clean_content = (content or "").strip()
560
+ clean_reasoning = (reasoning or f"Captured from protocol task {task_id}").strip()
561
+ applies_to = ",".join(effective_files)
562
+ if not clean_title or not clean_content:
563
+ return {"ok": False, "error": "insufficient context for learning capture"}
564
+
565
+ conflicting = find_conflicting_active_learning(
566
+ category=category,
567
+ title=clean_title,
568
+ content=clean_content,
569
+ applies_to=applies_to,
570
+ )
571
+ supersedes_id = int(conflicting["id"]) if conflicting else 0
572
+ response = handle_learning_add(
573
+ category=category,
574
+ title=clean_title,
575
+ content=clean_content,
576
+ reasoning=clean_reasoning,
577
+ applies_to=applies_to,
578
+ priority=priority,
579
+ supersedes_id=supersedes_id,
580
+ )
581
+ match = re.search(r"Learning #(\d+) added", response)
582
+ if match:
583
+ return {
584
+ "ok": True,
585
+ "id": int(match.group(1)),
586
+ "response": response,
587
+ "superseded_id": supersedes_id or None,
588
+ }
589
+ return {
590
+ "ok": False,
591
+ "error": response,
592
+ "conflicting_learning_id": supersedes_id or None,
593
+ }
594
+
595
+
596
+ def _auto_capture_learning(task: dict, task_id: str, effective_files: list[str], *,
597
+ clean_evidence: str, change_summary: str, change_why: str,
598
+ outcome_notes: str) -> dict:
599
+ title_seed = (change_summary or task.get("goal") or f"Protocol correction {task_id}").strip()
600
+ content_parts = []
601
+ if change_why.strip():
602
+ content_parts.append(change_why.strip())
603
+ elif task.get("goal"):
604
+ content_parts.append(str(task.get("goal", "")).strip())
605
+ if outcome_notes.strip():
606
+ content_parts.append(outcome_notes.strip())
607
+ if clean_evidence.strip():
608
+ content_parts.append(f"Verification evidence: {clean_evidence.strip()}")
609
+ if effective_files:
610
+ content_parts.append(f"Affected files: {', '.join(effective_files[:5])}")
611
+
612
+ title = title_seed[:120]
613
+ content = " ".join(part for part in content_parts if part).strip()
614
+ return _capture_learning(
615
+ task,
616
+ task_id,
617
+ effective_files,
618
+ category=(task.get("area") or "nexo-ops"),
619
+ title=title,
620
+ content=content,
621
+ reasoning=f"Auto-captured from corrected protocol task {task_id}.",
622
+ priority="high",
623
+ )
624
+
625
+
626
+ def _record_debt(session_id: str, task_id: str, debt_type: str, *, severity: str, evidence: str, debts: list[dict]):
627
+ debt = create_protocol_debt(
628
+ session_id,
629
+ debt_type,
630
+ severity=severity,
631
+ task_id=task_id,
632
+ evidence=evidence,
633
+ )
634
+ debts.append(
635
+ {
636
+ "id": debt.get("id"),
637
+ "debt_type": debt_type,
638
+ "severity": severity,
639
+ }
640
+ )
641
+
642
+
643
+ def handle_confidence_check(
644
+ goal: str,
645
+ task_type: str = "answer",
646
+ area: str = "",
647
+ context_hint: str = "",
648
+ constraints: str = "[]",
649
+ evidence_refs: str = "[]",
650
+ unknowns: str = "[]",
651
+ verification_step: str = "",
652
+ stakes: str = "",
653
+ ) -> str:
654
+ """Return the metacognitive response mode: answer, verify, ask, or defer."""
655
+ clean_goal = (goal or "").strip()
656
+ if not clean_goal:
657
+ return json.dumps({"ok": False, "error": "goal is required"}, ensure_ascii=False, indent=2)
658
+ try:
659
+ clean_type = validate_task_type(task_type)
660
+ except ValueError as exc:
661
+ return json.dumps(
662
+ {
663
+ "ok": False,
664
+ "error": str(exc),
665
+ "valid_task_types": sorted(VALID_TASK_TYPES),
666
+ },
667
+ ensure_ascii=False,
668
+ indent=2,
669
+ )
670
+ result = evaluate_response_confidence(
671
+ goal=clean_goal,
672
+ task_type=clean_type,
673
+ area=(area or "").strip(),
674
+ context_hint=(context_hint or "").strip(),
675
+ constraints=_parse_list(constraints),
676
+ evidence_refs=_parse_list(evidence_refs),
677
+ unknowns=_parse_list(unknowns),
678
+ verification_step=(verification_step or "").strip(),
679
+ stakes=(stakes or "").strip(),
680
+ )
681
+ return json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2)
682
+
683
+
684
+ def handle_task_open(
685
+ sid: str,
686
+ goal: str,
687
+ task_type: str = "answer",
688
+ area: str = "",
689
+ files: str = "",
690
+ project_hint: str = "",
691
+ plan: str = "[]",
692
+ known_facts: str = "[]",
693
+ unknowns: str = "[]",
694
+ constraints: str = "[]",
695
+ evidence_refs: str = "[]",
696
+ verification_step: str = "",
697
+ stakes: str = "",
698
+ context_hint: str = "",
699
+ ) -> str:
700
+ """Open a protocol task with heartbeat, guard, rules, and Cortex already captured.
701
+
702
+ Use this as the default entry point for any non-trivial work. For edit/execute/delegate
703
+ tasks it becomes the contract that later must be closed with `nexo_task_close`.
704
+ """
705
+ clean_goal = (goal or "").strip()
706
+ if not sid.strip():
707
+ return json.dumps({"ok": False, "error": "sid is required"}, ensure_ascii=False, indent=2)
708
+ if not clean_goal:
709
+ return json.dumps({"ok": False, "error": "goal is required"}, ensure_ascii=False, indent=2)
710
+
711
+ try:
712
+ clean_type = validate_task_type(task_type)
713
+ except ValueError as exc:
714
+ return json.dumps(
715
+ {
716
+ "ok": False,
717
+ "error": str(exc),
718
+ "valid_task_types": sorted(VALID_TASK_TYPES),
719
+ },
720
+ ensure_ascii=False,
721
+ indent=2,
722
+ )
723
+ files_list = _parse_list(files)
724
+ protocol_strictness = get_protocol_strictness()
725
+ if protocol_strictness in {"strict", "learning"} and clean_type == "edit" and not files_list:
726
+ note = (
727
+ "Strict protocol mode requires explicit `files` for edit tasks."
728
+ if protocol_strictness == "strict"
729
+ else "Learning mode requires explicit `files` on edit tasks so NEXO can match the write against the open protocol task."
730
+ )
731
+ return json.dumps(
732
+ {"ok": False, "error": note, "protocol_strictness": protocol_strictness},
733
+ ensure_ascii=False,
734
+ indent=2,
735
+ )
736
+ state = {
737
+ "goal": clean_goal,
738
+ "task_type": clean_type,
739
+ "plan": _parse_list(plan),
740
+ "known_facts": _parse_list(known_facts),
741
+ "unknowns": _parse_list(unknowns),
742
+ "constraints": _parse_list(constraints),
743
+ "evidence_refs": _parse_list(evidence_refs),
744
+ "verification_step": (verification_step or "").strip(),
745
+ }
746
+ response_contract = evaluate_response_confidence(
747
+ goal=clean_goal,
748
+ task_type=clean_type,
749
+ area=area.strip(),
750
+ context_hint=context_hint.strip(),
751
+ constraints=state["constraints"],
752
+ evidence_refs=state["evidence_refs"],
753
+ unknowns=state["unknowns"],
754
+ verification_step=state["verification_step"],
755
+ stakes=stakes,
756
+ )
757
+ recent_bundle = build_pre_action_context(
758
+ query=" | ".join(part for part in [clean_goal, context_hint.strip()] if part),
759
+ session_id=sid.strip(),
760
+ hours=24,
761
+ limit=4,
762
+ )
763
+ area_context = _build_area_context(area.strip()) if area.strip() else {"has_context": False}
764
+ heartbeat_result = handle_heartbeat(sid, clean_goal[:120], context_hint=context_hint[:500])
765
+ attention = _attention_snapshot(sid.strip())
766
+ anticipatory_warnings = _preview_prospective_triggers(clean_goal, context_hint.strip(), files_list)
767
+ preventive_followup = None
768
+
769
+ guard_summary = ""
770
+ guard_has_blocking = False
771
+ opened_with_guard = False
772
+ debts_created: list[dict] = []
773
+ if clean_type in ACTION_TASKS and (files_list or area.strip()):
774
+ opened_with_guard = True
775
+ guard_summary = handle_guard_check(files=",".join(files_list), area=area.strip())
776
+ guard_has_blocking = (
777
+ "[BLOCKING]" in guard_summary
778
+ or "WARNINGS — resolve before editing" in guard_summary
779
+ or "BLOCKING RULES" in guard_summary
780
+ )
781
+
782
+ cortex = evaluate_cortex_state(state)
783
+ decision_support = {
784
+ "required": _decision_support_required(
785
+ task_type=clean_type,
786
+ high_stakes=response_contract["high_stakes"],
787
+ ),
788
+ "tool": "nexo_cortex_decide",
789
+ "reason": (
790
+ "High-stakes action task detected. Rank at least 2 alternatives before acting."
791
+ if clean_type in ACTION_TASKS and response_contract["high_stakes"]
792
+ else "Alternative ranking not required for this task."
793
+ ),
794
+ }
795
+ must_verify = clean_type in ACTION_TASKS or response_contract["mode"] == "verify"
796
+ must_change_log = clean_type in {"edit", "execute"} and bool(files_list)
797
+ must_learning_if_corrected = True
798
+ must_write_diary_on_close = clean_type in ACTION_TASKS
799
+
800
+ task = create_protocol_task(
801
+ sid,
802
+ clean_goal,
803
+ task_type=clean_type,
804
+ area=area.strip(),
805
+ project_hint=project_hint.strip(),
806
+ context_hint=context_hint.strip(),
807
+ files=files_list,
808
+ plan=state["plan"],
809
+ known_facts=state["known_facts"],
810
+ unknowns=state["unknowns"],
811
+ constraints=state["constraints"],
812
+ evidence_refs=state["evidence_refs"],
813
+ verification_step=state["verification_step"],
814
+ cortex_mode=cortex["mode"],
815
+ cortex_check_id=cortex["check_id"],
816
+ cortex_blocked_reason=cortex.get("blocked_reason") or "",
817
+ cortex_warnings=cortex.get("warnings") or [],
818
+ cortex_rules=cortex.get("injected_rules") or [],
819
+ opened_with_guard=opened_with_guard,
820
+ opened_with_rules=True,
821
+ guard_has_blocking=guard_has_blocking,
822
+ guard_summary=guard_summary,
823
+ must_verify=must_verify,
824
+ must_change_log=must_change_log,
825
+ must_learning_if_corrected=must_learning_if_corrected,
826
+ must_write_diary_on_close=must_write_diary_on_close,
827
+ response_mode=response_contract["mode"],
828
+ response_confidence=response_contract["confidence"],
829
+ response_reasons=response_contract["reasons"],
830
+ response_high_stakes=response_contract["high_stakes"],
831
+ )
832
+ protocol_context_key = f"protocol_task:{task['task_id']}"
833
+ capture_context_event(
834
+ event_type="protocol_task_opened",
835
+ title=clean_goal[:160],
836
+ summary=(context_hint or clean_goal)[:600],
837
+ body="\n".join(state["plan"][:5])[:1600] if state["plan"] else "",
838
+ context_key=protocol_context_key,
839
+ context_title=clean_goal[:160],
840
+ context_summary=(context_hint or clean_goal)[:600],
841
+ context_type="protocol_task",
842
+ state="active",
843
+ owner="nexo",
844
+ actor=sid,
845
+ source_type="protocol_task",
846
+ source_id=task["task_id"],
847
+ session_id=sid,
848
+ metadata={
849
+ "task_type": clean_type,
850
+ "area": area.strip(),
851
+ "files": files_list[:8],
852
+ },
853
+ ttl_hours=24,
854
+ )
855
+ blocking_rule_ids = _extract_guard_blocking_ids(guard_summary) if guard_has_blocking else []
856
+ if guard_has_blocking:
857
+ _record_debt(
858
+ task["session_id"],
859
+ task["task_id"],
860
+ "unacknowledged_guard_blocking",
861
+ severity="error",
862
+ evidence=_guard_excerpt(guard_summary),
863
+ debts=debts_created,
864
+ )
865
+ elif clean_type in ACTION_TASKS and (anticipatory_warnings or attention["status"] in {"split", "overloaded"}):
866
+ preventive_followup = _create_preventive_followup(
867
+ clean_goal,
868
+ attention=attention,
869
+ warnings=anticipatory_warnings,
870
+ )
871
+
872
+ if guard_has_blocking:
873
+ next_action = "Resolve the blocking guard warnings before editing."
874
+ elif response_contract["mode"] == "defer":
875
+ next_action = response_contract["next_action"]
876
+ elif response_contract["mode"] == "ask" and clean_type in RESPONSE_TASKS:
877
+ next_action = response_contract["next_action"]
878
+ elif response_contract["mode"] == "verify" and clean_type in RESPONSE_TASKS:
879
+ next_action = response_contract["next_action"]
880
+ elif attention["status"] == "overloaded":
881
+ next_action = attention["recommended_action"]
882
+ elif anticipatory_warnings:
883
+ next_action = "Review the anticipatory warnings before proceeding."
884
+ elif decision_support["required"]:
885
+ next_action = "Generate 2-3 concrete alternatives and run nexo_cortex_decide before acting."
886
+ elif cortex["mode"] == "ask":
887
+ next_action = "Ask for the missing information before acting."
888
+ elif cortex["mode"] == "propose":
889
+ next_action = "Propose the plan or verification path before acting."
890
+ else:
891
+ next_action = "Proceed with the task and close it with nexo_task_close before claiming completion."
892
+
893
+ response = {
894
+ "ok": True,
895
+ "task_id": task["task_id"],
896
+ "session_id": sid,
897
+ "goal": clean_goal,
898
+ "task_type": clean_type,
899
+ "protocol_strictness": protocol_strictness,
900
+ "mode": cortex["mode"],
901
+ "check_id": cortex["check_id"],
902
+ "blocked_reason": cortex.get("blocked_reason"),
903
+ "warnings": cortex.get("warnings") or [],
904
+ "applicable_rules": cortex.get("injected_rules") or [],
905
+ "guard": {
906
+ "ran": opened_with_guard,
907
+ "has_blocking": guard_has_blocking,
908
+ "blocking_rule_ids": blocking_rule_ids,
909
+ "summary_excerpt": _guard_excerpt(guard_summary),
910
+ },
911
+ "attention": attention,
912
+ "anticipation": {
913
+ "warning_count": len(anticipatory_warnings),
914
+ "warnings": anticipatory_warnings,
915
+ "recommended_action": (
916
+ "Review these anticipatory warnings before proceeding."
917
+ if anticipatory_warnings
918
+ else "No anticipatory warnings."
919
+ ),
920
+ },
921
+ "response_contract": response_contract,
922
+ "decision_support": decision_support,
923
+ "recent_context": {
924
+ "has_matches": bool(recent_bundle.get("has_matches")),
925
+ "excerpt": format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else "",
926
+ },
927
+ "area_context": area_context if area_context.get("has_context") else None,
928
+ "contract": {
929
+ "must_verify": must_verify,
930
+ "must_change_log": must_change_log,
931
+ "must_learning_if_corrected": must_learning_if_corrected,
932
+ "must_write_diary_on_close": must_write_diary_on_close,
933
+ "protocol_strictness": protocol_strictness,
934
+ },
935
+ "session_touch": heartbeat_result.splitlines()[0] if heartbeat_result else "",
936
+ "open_debts": debts_created,
937
+ "preventive_followup": preventive_followup,
938
+ "next_action": next_action,
939
+ }
940
+ return json.dumps(response, ensure_ascii=False, indent=2)
941
+
942
+
943
+ def handle_task_close(
944
+ sid: str,
945
+ task_id: str,
946
+ outcome: str,
947
+ evidence: str = "",
948
+ files_changed: str = "",
949
+ correction_happened: bool = False,
950
+ change_summary: str = "",
951
+ change_why: str = "",
952
+ change_risks: str = "",
953
+ change_verify: str = "",
954
+ triggered_by: str = "",
955
+ followup_needed: bool = False,
956
+ followup_id: str = "",
957
+ followup_description: str = "",
958
+ followup_date: str = "",
959
+ followup_verification: str = "",
960
+ followup_reasoning: str = "",
961
+ learning_category: str = "",
962
+ learning_title: str = "",
963
+ learning_content: str = "",
964
+ learning_reasoning: str = "",
965
+ outcome_notes: str = "",
966
+ ) -> str:
967
+ """Close a protocol task and automatically record the required discipline artifacts."""
968
+ task = get_protocol_task(task_id.strip())
969
+ if not task:
970
+ return json.dumps({"ok": False, "error": f"Unknown task_id: {task_id}"}, ensure_ascii=False, indent=2)
971
+ if sid.strip() and task.get("session_id") and task["session_id"] != sid.strip():
972
+ return json.dumps(
973
+ {"ok": False, "error": f"Task {task_id} belongs to {task['session_id']}, not {sid}"},
974
+ ensure_ascii=False,
975
+ indent=2,
976
+ )
977
+
978
+ try:
979
+ clean_outcome = validate_close_outcome(outcome)
980
+ except ValueError as exc:
981
+ return json.dumps(
982
+ {
983
+ "ok": False,
984
+ "error": str(exc),
985
+ "task_id": task_id,
986
+ "valid_outcomes": sorted(VALID_CLOSE_OUTCOMES),
987
+ },
988
+ ensure_ascii=False,
989
+ indent=2,
990
+ )
991
+ clean_evidence = (evidence or "").strip()
992
+ files_changed_list = _parse_list(files_changed)
993
+ planned_files = _parse_list(task.get("files") or "[]")
994
+ effective_files = files_changed_list or planned_files
995
+ correction = _parse_bool(correction_happened)
996
+ followup_required = _parse_bool(followup_needed)
997
+
998
+ change_log_id = None
999
+ learning_id = None
1000
+ created_followup_id = ""
1001
+ debts_created: list[dict] = []
1002
+ requires_decision_support = _decision_support_required(
1003
+ task_type=task.get("task_type", ""),
1004
+ high_stakes=bool(task.get("response_high_stakes")),
1005
+ )
1006
+
1007
+ # ── Evidence enforcement: reject 'done' without proof in strict mode ──
1008
+ if task.get("must_verify") and clean_outcome == "done":
1009
+ if clean_evidence:
1010
+ resolve_protocol_debts(
1011
+ task_id=task_id,
1012
+ debt_types=["claimed_done_without_evidence"],
1013
+ resolution="Verification evidence supplied during task_close",
1014
+ )
1015
+ else:
1016
+ protocol_strictness = get_protocol_strictness()
1017
+ if protocol_strictness == "strict":
1018
+ return json.dumps(
1019
+ {
1020
+ "ok": False,
1021
+ "error": "Cannot close task as 'done' without evidence.",
1022
+ "hint": "Provide the `evidence` parameter with verifiable proof: test output, curl response, screenshot path, or real command output.",
1023
+ "task_id": task_id,
1024
+ "protocol_strictness": protocol_strictness,
1025
+ },
1026
+ ensure_ascii=False,
1027
+ indent=2,
1028
+ )
1029
+ _record_debt(
1030
+ task["session_id"],
1031
+ task_id,
1032
+ "claimed_done_without_evidence",
1033
+ severity="error",
1034
+ evidence=f"Task closed as done without evidence. Goal: {task.get('goal','')}",
1035
+ debts=debts_created,
1036
+ )
1037
+
1038
+ # ── Release checklist: require channel alignment evidence for release tasks ──
1039
+ RELEASE_KEYWORDS = {"release", "deploy", "version", "launch", "ship"}
1040
+ task_goal_lower = (task.get("goal") or "").lower()
1041
+ is_release = any(kw in task_goal_lower for kw in RELEASE_KEYWORDS)
1042
+ if is_release and clean_outcome == "done" and clean_evidence:
1043
+ missing_channels: list[str] = []
1044
+ evidence_lower = clean_evidence.lower()
1045
+ for channel in ["test", "staging", "production", "changelog", "version"]:
1046
+ if channel not in evidence_lower:
1047
+ missing_channels.append(channel)
1048
+ if missing_channels:
1049
+ _record_debt(
1050
+ task["session_id"],
1051
+ task_id,
1052
+ "release_channel_alignment_incomplete",
1053
+ severity="warn",
1054
+ evidence=f"Release task evidence missing channel references: {', '.join(missing_channels)}. Evidence provided: {clean_evidence[:200]}",
1055
+ debts=debts_created,
1056
+ )
1057
+
1058
+ if task.get("must_change_log") and clean_outcome in {"done", "partial", "failed"}:
1059
+ if effective_files:
1060
+ change = log_change(
1061
+ task["session_id"],
1062
+ ", ".join(effective_files),
1063
+ (change_summary or f"Protocol task {task_id}: {task.get('goal', '')}")[:500],
1064
+ (change_why or task.get("goal", ""))[:500],
1065
+ (triggered_by or task_id)[:200],
1066
+ task.get("area", "")[:200],
1067
+ (change_risks or "")[:500],
1068
+ (change_verify or clean_evidence)[:500],
1069
+ )
1070
+ if "error" in change:
1071
+ _record_debt(
1072
+ task["session_id"],
1073
+ task_id,
1074
+ "missing_change_log",
1075
+ severity="warn",
1076
+ evidence=f"change_log failed: {change['error']}",
1077
+ debts=debts_created,
1078
+ )
1079
+ else:
1080
+ change_log_id = change.get("id")
1081
+ resolve_protocol_debts(
1082
+ task_id=task_id,
1083
+ debt_types=["missing_change_log"],
1084
+ resolution="Change log created by nexo_task_close",
1085
+ )
1086
+ else:
1087
+ _record_debt(
1088
+ task["session_id"],
1089
+ task_id,
1090
+ "missing_change_log",
1091
+ severity="warn",
1092
+ evidence="Task required change_log but no changed files were supplied or recorded.",
1093
+ debts=debts_created,
1094
+ )
1095
+
1096
+ if correction:
1097
+ if (learning_title or "").strip() and (learning_content or "").strip():
1098
+ learning = _capture_learning(
1099
+ task,
1100
+ task_id,
1101
+ effective_files,
1102
+ category=(learning_category or task.get("area") or "nexo-ops"),
1103
+ title=learning_title.strip(),
1104
+ content=learning_content.strip(),
1105
+ reasoning=(learning_reasoning or f"Captured from protocol task {task_id}").strip(),
1106
+ priority="high",
1107
+ )
1108
+ if not learning.get("ok"):
1109
+ _record_debt(
1110
+ task["session_id"],
1111
+ task_id,
1112
+ "missing_learning_after_correction",
1113
+ severity="warn",
1114
+ evidence=f"learning_add failed: {learning.get('error', 'unknown error')}",
1115
+ debts=debts_created,
1116
+ )
1117
+ else:
1118
+ learning_id = learning.get("id")
1119
+ resolve_protocol_debts(
1120
+ task_id=task_id,
1121
+ debt_types=["missing_learning_after_correction"],
1122
+ resolution="Learning captured during task_close",
1123
+ )
1124
+ if learning.get("superseded_id"):
1125
+ resolve_protocol_debts(
1126
+ task_id=task_id,
1127
+ debt_types=["unacknowledged_guard_blocking"],
1128
+ resolution=f"Guard blocking rule superseded by canonical learning #{learning_id}",
1129
+ )
1130
+ else:
1131
+ auto_learning = _auto_capture_learning(
1132
+ task,
1133
+ task_id,
1134
+ effective_files,
1135
+ clean_evidence=clean_evidence,
1136
+ change_summary=change_summary,
1137
+ change_why=change_why,
1138
+ outcome_notes=outcome_notes,
1139
+ )
1140
+ if auto_learning.get("ok"):
1141
+ learning_id = auto_learning.get("id")
1142
+ resolve_protocol_debts(
1143
+ task_id=task_id,
1144
+ debt_types=["missing_learning_after_correction"],
1145
+ resolution="Learning auto-captured during task_close",
1146
+ )
1147
+ if auto_learning.get("superseded_id"):
1148
+ resolve_protocol_debts(
1149
+ task_id=task_id,
1150
+ debt_types=["unacknowledged_guard_blocking"],
1151
+ resolution=f"Guard blocking rule superseded by canonical learning #{learning_id}",
1152
+ )
1153
+ else:
1154
+ _record_debt(
1155
+ task["session_id"],
1156
+ task_id,
1157
+ "missing_learning_after_correction",
1158
+ severity="warn",
1159
+ evidence=f"Task was marked as corrected but reusable learning capture failed: {auto_learning.get('error', 'missing payload')}",
1160
+ debts=debts_created,
1161
+ )
1162
+ auto_followup = _create_missing_learning_followup(task, task_id, effective_files)
1163
+ if "error" not in auto_followup and not created_followup_id:
1164
+ created_followup_id = auto_followup.get("id", "")
1165
+
1166
+ if followup_required:
1167
+ description = (followup_description or "").strip()
1168
+ if description:
1169
+ followup = create_followup(
1170
+ (followup_id or _auto_followup_id()).strip(),
1171
+ description,
1172
+ date=(followup_date or None),
1173
+ verification=(followup_verification or "").strip(),
1174
+ reasoning=(followup_reasoning or f"Created from protocol task {task_id}").strip(),
1175
+ )
1176
+ if "error" in followup:
1177
+ _record_debt(
1178
+ task["session_id"],
1179
+ task_id,
1180
+ "missing_followup_payload",
1181
+ severity="warn",
1182
+ evidence=f"followup create failed: {followup['error']}",
1183
+ debts=debts_created,
1184
+ )
1185
+ else:
1186
+ created_followup_id = followup.get("id", "")
1187
+ else:
1188
+ _record_debt(
1189
+ task["session_id"],
1190
+ task_id,
1191
+ "missing_followup_payload",
1192
+ severity="warn",
1193
+ evidence="followup_needed=true but no followup_description was supplied.",
1194
+ debts=debts_created,
1195
+ )
1196
+
1197
+ if requires_decision_support and clean_outcome in {"done", "partial", "failed"}:
1198
+ if task_has_cortex_evaluation(task_id):
1199
+ resolve_protocol_debts(
1200
+ task_id=task_id,
1201
+ debt_types=["missing_cortex_evaluation"],
1202
+ resolution="High-stakes action task has a persisted Cortex evaluation.",
1203
+ )
1204
+ else:
1205
+ _record_debt(
1206
+ task["session_id"],
1207
+ task_id,
1208
+ "missing_cortex_evaluation",
1209
+ severity="error",
1210
+ evidence="High-stakes action task closed without nexo_cortex_decide / persisted evaluation.",
1211
+ debts=debts_created,
1212
+ )
1213
+
1214
+ task = close_protocol_task(
1215
+ task_id,
1216
+ outcome=clean_outcome,
1217
+ evidence=clean_evidence,
1218
+ files_changed=effective_files,
1219
+ correction_happened=correction,
1220
+ change_log_id=change_log_id,
1221
+ learning_id=learning_id,
1222
+ followup_id=created_followup_id,
1223
+ outcome_notes=outcome_notes,
1224
+ )
1225
+ capture_context_event(
1226
+ event_type=f"protocol_task_{clean_outcome}",
1227
+ title=(task.get("goal") or task_id)[:160],
1228
+ summary=(outcome_notes or clean_evidence or clean_outcome)[:600],
1229
+ body=(change_summary or change_why or "")[:1600],
1230
+ context_key=f"protocol_task:{task_id}",
1231
+ context_title=(task.get("goal") or task_id)[:160],
1232
+ context_summary=(task.get("context_hint") or task.get("goal") or "")[:600],
1233
+ context_type="protocol_task",
1234
+ state="resolved" if clean_outcome in {"done", "cancelled"} else ("abandoned" if clean_outcome == "failed" else "blocked"),
1235
+ owner="nexo",
1236
+ actor=sid or task.get("session_id") or "nexo",
1237
+ source_type="protocol_task",
1238
+ source_id=task_id,
1239
+ session_id=task.get("session_id") or sid,
1240
+ metadata={
1241
+ "outcome": clean_outcome,
1242
+ "change_log_id": change_log_id,
1243
+ "learning_id": learning_id,
1244
+ "followup_id": created_followup_id,
1245
+ },
1246
+ ttl_hours=24,
1247
+ )
1248
+ # ── Drive/Curiosity: detect signals from task evidence (best-effort) ──
1249
+ try:
1250
+ _drive_text = " ".join(filter(None, [
1251
+ outcome_notes, clean_evidence, change_summary, change_why,
1252
+ ]))
1253
+ if _drive_text and len(_drive_text.strip()) >= 15:
1254
+ from tools_drive import detect_drive_signal as _detect_drive
1255
+ _detect_drive(
1256
+ _drive_text[:600],
1257
+ source="task_close",
1258
+ source_id=task_id,
1259
+ area=task.get("area", ""),
1260
+ )
1261
+ except Exception:
1262
+ pass # Drive detection is best-effort
1263
+
1264
+ open_debts = list_protocol_debts(status="open", task_id=task_id, limit=20)
1265
+
1266
+ response = {
1267
+ "ok": True,
1268
+ "task_id": task_id,
1269
+ "outcome": clean_outcome,
1270
+ "change_log_id": change_log_id,
1271
+ "learning_id": learning_id,
1272
+ "followup_id": created_followup_id,
1273
+ "cortex_evaluation": latest_cortex_evaluation_for_task(task_id) if requires_decision_support else None,
1274
+ "debts_created": debts_created,
1275
+ "open_debts": [
1276
+ {
1277
+ "id": debt.get("id"),
1278
+ "debt_type": debt.get("debt_type"),
1279
+ "severity": debt.get("severity"),
1280
+ }
1281
+ for debt in open_debts
1282
+ ],
1283
+ "status": "clean" if not open_debts else "debt-open",
1284
+ "next_action": (
1285
+ "Do not claim completion yet. Resolve the open protocol debt first."
1286
+ if open_debts else
1287
+ "Task closed cleanly."
1288
+ ),
1289
+ }
1290
+ return json.dumps(response, ensure_ascii=False, indent=2)
1291
+
1292
+
1293
+ def handle_task_acknowledge_guard(
1294
+ sid: str,
1295
+ task_id: str,
1296
+ learning_ids: str = "",
1297
+ note: str = "",
1298
+ ) -> str:
1299
+ """Acknowledge blocking guard rules for an open protocol task."""
1300
+ task = get_protocol_task(task_id.strip())
1301
+ if not task:
1302
+ return json.dumps({"ok": False, "error": f"Unknown task_id: {task_id}"}, ensure_ascii=False, indent=2)
1303
+ if sid.strip() and task.get("session_id") and task["session_id"] != sid.strip():
1304
+ return json.dumps(
1305
+ {"ok": False, "error": f"Task {task_id} belongs to {task['session_id']}, not {sid}"},
1306
+ ensure_ascii=False,
1307
+ indent=2,
1308
+ )
1309
+ if not task.get("guard_has_blocking"):
1310
+ return json.dumps(
1311
+ {"ok": False, "error": f"Task {task_id} has no blocking guard rules to acknowledge."},
1312
+ ensure_ascii=False,
1313
+ indent=2,
1314
+ )
1315
+
1316
+ expected = _extract_guard_blocking_ids(task.get("guard_summary") or "")
1317
+ provided = sorted({int(item) for item in _parse_list(learning_ids) if str(item).strip().isdigit()})
1318
+ if expected and sorted(expected) != provided:
1319
+ return json.dumps(
1320
+ {
1321
+ "ok": False,
1322
+ "error": "learning_ids must acknowledge every blocking rule on the task.",
1323
+ "expected_ids": expected,
1324
+ "provided_ids": provided,
1325
+ },
1326
+ ensure_ascii=False,
1327
+ indent=2,
1328
+ )
1329
+
1330
+ resolved = resolve_protocol_debts(
1331
+ task_id=task_id,
1332
+ debt_types=["unacknowledged_guard_blocking"],
1333
+ resolution=(note or f"Guard rules acknowledged: {provided}").strip(),
1334
+ )
1335
+ return json.dumps(
1336
+ {
1337
+ "ok": True,
1338
+ "task_id": task_id,
1339
+ "acknowledged_rule_ids": provided,
1340
+ "resolved_debts": resolved,
1341
+ "next_action": "Proceed with the task and close it with nexo_task_close once evidence is available.",
1342
+ },
1343
+ ensure_ascii=False,
1344
+ indent=2,
1345
+ )
1346
+
1347
+
1348
+ def handle_protocol_debt_list(
1349
+ status: str = "open",
1350
+ task_id: str = "",
1351
+ session_id: str = "",
1352
+ debt_type: str = "",
1353
+ severity: str = "",
1354
+ limit: str = "50",
1355
+ ) -> str:
1356
+ rows = list_protocol_debts(
1357
+ status=status.strip() if isinstance(status, str) else "open",
1358
+ task_id=(task_id or "").strip(),
1359
+ session_id=(session_id or "").strip(),
1360
+ debt_type=(debt_type or "").strip(),
1361
+ severity=(severity or "").strip(),
1362
+ limit=max(1, min(500, int(limit or 50))),
1363
+ )
1364
+ summary: dict[str, int] = {}
1365
+ for row in rows:
1366
+ debt_key = str(row.get("debt_type") or "unknown")
1367
+ summary[debt_key] = summary.get(debt_key, 0) + 1
1368
+ return json.dumps(
1369
+ {
1370
+ "ok": True,
1371
+ "count": len(rows),
1372
+ "summary": summary,
1373
+ "items": rows,
1374
+ },
1375
+ ensure_ascii=False,
1376
+ indent=2,
1377
+ )
1378
+
1379
+
1380
+ def handle_protocol_debt_resolve(
1381
+ debt_ids: str = "",
1382
+ task_id: str = "",
1383
+ session_id: str = "",
1384
+ debt_types: str = "",
1385
+ resolution: str = "",
1386
+ ) -> str:
1387
+ parsed_ids = _parse_int_list(debt_ids)
1388
+ parsed_types = _parse_list(debt_types)
1389
+ if not parsed_ids and not (task_id or "").strip() and not (session_id or "").strip() and not parsed_types:
1390
+ return json.dumps(
1391
+ {
1392
+ "ok": False,
1393
+ "error": "Provide `debt_ids`, `task_id`, `session_id`, or `debt_types` to select protocol debt.",
1394
+ },
1395
+ ensure_ascii=False,
1396
+ indent=2,
1397
+ )
1398
+
1399
+ matched: list[dict] = []
1400
+ if parsed_ids:
1401
+ conn = get_db()
1402
+ placeholders = ",".join("?" for _ in parsed_ids)
1403
+ rows = conn.execute(
1404
+ f"""SELECT * FROM protocol_debt
1405
+ WHERE status = 'open' AND id IN ({placeholders})
1406
+ ORDER BY created_at DESC""",
1407
+ tuple(parsed_ids),
1408
+ ).fetchall()
1409
+ matched = [dict(row) for row in rows]
1410
+ else:
1411
+ matched = list_protocol_debts(
1412
+ status="open",
1413
+ task_id=(task_id or "").strip(),
1414
+ session_id=(session_id or "").strip(),
1415
+ limit=500,
1416
+ )
1417
+ if parsed_types:
1418
+ allowed = set(parsed_types)
1419
+ matched = [row for row in matched if str(row.get("debt_type") or "") in allowed]
1420
+
1421
+ normalized_resolution = (resolution or "Resolved during protocol debt maintenance audit.").strip()
1422
+ resolved = resolve_protocol_debts(
1423
+ task_id=(task_id or "").strip(),
1424
+ session_id=(session_id or "").strip(),
1425
+ debt_ids=parsed_ids or None,
1426
+ debt_types=parsed_types or None,
1427
+ resolution=normalized_resolution,
1428
+ )
1429
+ return json.dumps(
1430
+ {
1431
+ "ok": True,
1432
+ "resolved": resolved,
1433
+ "matched_ids": [int(row["id"]) for row in matched],
1434
+ "matched_debt_types": sorted({str(row.get("debt_type") or "") for row in matched if row.get("debt_type")}),
1435
+ "resolution": normalized_resolution,
1436
+ },
1437
+ ensure_ascii=False,
1438
+ indent=2,
1439
+ )
1440
+
1441
+
1442
+ TOOLS = [
1443
+ (handle_confidence_check, "nexo_confidence_check", "Decide whether a non-trivial answer should be answered, verified, asked, or deferred before replying."),
1444
+ (handle_task_open, "nexo_task_open", "Open a non-trivial task with heartbeat, guard, rules, and Cortex captured as one protocol contract."),
1445
+ (handle_task_acknowledge_guard, "nexo_task_acknowledge_guard", "Acknowledge blocking guard rules on an open protocol task before proceeding."),
1446
+ (handle_task_close, "nexo_task_close", "Close a protocol task, auto-record evidence/change-log/followup artifacts, and open protocol debt when discipline is missing."),
1447
+ (handle_protocol_debt_list, "nexo_protocol_debt_list", "List protocol debt records with optional status, session, task, type, or severity filters."),
1448
+ (handle_protocol_debt_resolve, "nexo_protocol_debt_resolve", "Resolve protocol debt records by id or filters once the debt has been audited and cleared."),
1449
+ ]