nexo-brain 5.3.19 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/nexo-brain.js +52 -10
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- 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
|
+
]
|