nexo-brain 7.1.8 → 7.1.10
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/README.md +3 -1
- package/package.json +1 -1
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- package/src/hook_guardrails.py +6 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +23 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/plugins/protocol.py +24 -0
- package/src/plugins/workflow.py +65 -0
- package/src/skills/run-release-final-audit/guide.md +3 -1
- package/src/skills/run-release-final-audit/script.py +2 -0
- package/src/tools_sessions.py +31 -0
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.10",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.1.
|
|
21
|
+
Version `7.1.10` is the current packaged-runtime line. It is a follow-up over v7.1.8 that ships two rescue batches of WIP that were stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expands the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), adds three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wires post/pre-compact hooks that read those flags, surfaces them through protocol/workflow handlers and session payload, and introduces the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gains a smoke-artifact contract pass that validates `release-contracts/smoke/v<version>.json` before any tag push, the release-final audit skill references the new contract, `src/hook_guardrails.py` + `src/hooks/post_tool_use.py` refine the post-tool protocol reminder path with a new contract test, and a couple of core prompts (task-close evidence, r14 correction learning) get wording polish. Both rescues are orthogonal to v7.1.8 and fully covered by tests.
|
|
22
|
+
|
|
23
|
+
Previously in `7.1.8`: batch release over v7.1.7 consolidating the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core).
|
|
22
24
|
|
|
23
25
|
Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.10",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
package/src/autonomy_mandate.py
CHANGED
|
@@ -45,8 +45,15 @@ MARKERS = (
|
|
|
45
45
|
"autonomía total",
|
|
46
46
|
"autonomia total",
|
|
47
47
|
"sin esperas",
|
|
48
|
+
"hazlo todo",
|
|
48
49
|
"todo ya",
|
|
50
|
+
"no pares",
|
|
51
|
+
"estás al mando",
|
|
52
|
+
"estas al mando",
|
|
53
|
+
"te dejo al mando",
|
|
49
54
|
"no esperes",
|
|
55
|
+
"sigue sin parar",
|
|
56
|
+
"haz el plan completo",
|
|
50
57
|
"no te escondas",
|
|
51
58
|
"llevo 3 veces",
|
|
52
59
|
"lo quiero ya",
|
|
@@ -81,6 +88,9 @@ class MandateState:
|
|
|
81
88
|
expires_at: float
|
|
82
89
|
marker: str
|
|
83
90
|
source: str
|
|
91
|
+
execute_until_blocker: bool = True
|
|
92
|
+
suppress_mid_task_menus: bool = True
|
|
93
|
+
revalidate_after_compaction: bool = True
|
|
84
94
|
|
|
85
95
|
def remaining_seconds(self, now: Optional[float] = None) -> float:
|
|
86
96
|
now = time.time() if now is None else now
|
|
@@ -94,6 +104,9 @@ class MandateState:
|
|
|
94
104
|
"expires_at": self.expires_at,
|
|
95
105
|
"marker": self.marker,
|
|
96
106
|
"source": self.source,
|
|
107
|
+
"execute_until_blocker": self.execute_until_blocker,
|
|
108
|
+
"suppress_mid_task_menus": self.suppress_mid_task_menus,
|
|
109
|
+
"revalidate_after_compaction": self.revalidate_after_compaction,
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
|
|
@@ -137,6 +150,9 @@ def load_state() -> Optional[MandateState]:
|
|
|
137
150
|
expires_at=float(raw.get("expires_at", 0.0)),
|
|
138
151
|
marker=str(raw.get("marker", "")),
|
|
139
152
|
source=str(raw.get("source", "")),
|
|
153
|
+
execute_until_blocker=bool(raw.get("execute_until_blocker", True)),
|
|
154
|
+
suppress_mid_task_menus=bool(raw.get("suppress_mid_task_menus", True)),
|
|
155
|
+
revalidate_after_compaction=bool(raw.get("revalidate_after_compaction", True)),
|
|
140
156
|
)
|
|
141
157
|
except (TypeError, ValueError):
|
|
142
158
|
return None
|
|
@@ -150,6 +166,9 @@ def set_mandate(
|
|
|
150
166
|
marker: str,
|
|
151
167
|
source: str = "manual",
|
|
152
168
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
169
|
+
execute_until_blocker: bool = True,
|
|
170
|
+
suppress_mid_task_menus: bool = True,
|
|
171
|
+
revalidate_after_compaction: bool = True,
|
|
153
172
|
) -> MandateState:
|
|
154
173
|
_ensure_dir()
|
|
155
174
|
now = time.time()
|
|
@@ -160,6 +179,9 @@ def set_mandate(
|
|
|
160
179
|
expires_at=now + max(60, int(ttl_seconds)),
|
|
161
180
|
marker=marker,
|
|
162
181
|
source=source,
|
|
182
|
+
execute_until_blocker=bool(execute_until_blocker),
|
|
183
|
+
suppress_mid_task_menus=bool(suppress_mid_task_menus),
|
|
184
|
+
revalidate_after_compaction=bool(revalidate_after_compaction),
|
|
163
185
|
)
|
|
164
186
|
STATE_PATH.write_text(json.dumps(st.to_dict(), ensure_ascii=False, indent=2))
|
|
165
187
|
return st
|
|
@@ -196,6 +218,46 @@ def maybe_ingest_from_text(
|
|
|
196
218
|
)
|
|
197
219
|
|
|
198
220
|
|
|
221
|
+
def _state_applies_to_session(state: MandateState, session_id: str = "") -> bool:
|
|
222
|
+
clean_sid = str(session_id or "").strip()
|
|
223
|
+
if not clean_sid:
|
|
224
|
+
return True
|
|
225
|
+
return not state.session_id or state.session_id == clean_sid
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_execute_until_blocker_active(
|
|
229
|
+
session_id: str = "",
|
|
230
|
+
state: Optional[MandateState] = None,
|
|
231
|
+
) -> bool:
|
|
232
|
+
st = state if state is not None else load_state()
|
|
233
|
+
if st is None or not _state_applies_to_session(st, session_id):
|
|
234
|
+
return False
|
|
235
|
+
return bool(st.active and st.execute_until_blocker)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def format_execution_latch_notice(
|
|
239
|
+
session_id: str = "",
|
|
240
|
+
state: Optional[MandateState] = None,
|
|
241
|
+
) -> str:
|
|
242
|
+
st = state if state is not None else load_state()
|
|
243
|
+
if st is None or not _state_applies_to_session(st, session_id):
|
|
244
|
+
return ""
|
|
245
|
+
if not st.active or not st.execute_until_blocker:
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
lines = [
|
|
249
|
+
(
|
|
250
|
+
"EXECUTION MODE: execute-until-blocker active "
|
|
251
|
+
f"(marker='{st.marker}', source={st.source})."
|
|
252
|
+
),
|
|
253
|
+
"Do not stop for option menus, reprioritization, summaries, or audits.",
|
|
254
|
+
"Pause only for a real blocker, an explicit approval gate, or a requested report.",
|
|
255
|
+
]
|
|
256
|
+
if st.revalidate_after_compaction:
|
|
257
|
+
lines.append("Re-validate this latch after compaction and continue from the stored next step.")
|
|
258
|
+
return "\n".join(lines)
|
|
259
|
+
|
|
260
|
+
|
|
199
261
|
def _description_has_exception(description: str, exception: str) -> bool:
|
|
200
262
|
haystack = f"{description or ''}\n{exception or ''}".lower()
|
|
201
263
|
return any(kw in haystack for kw in EXCEPTION_KEYWORDS)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Durable checkpoint policy for long-running multi-step work.
|
|
2
|
+
|
|
3
|
+
This module turns task/workflow milestones into a small persistent state file
|
|
4
|
+
and periodically flushes that state into ``session_checkpoints`` so compaction
|
|
5
|
+
and phase switches recover a richer next-step snapshot than the heartbeat-only
|
|
6
|
+
goal stub.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
19
|
+
STATE_PATH = NEXO_HOME / "runtime" / "data" / "durable_checkpoint_state.json"
|
|
20
|
+
DEFAULT_MILESTONE_INTERVAL = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _now_iso() -> str:
|
|
24
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_dir() -> None:
|
|
28
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_all() -> dict[str, dict[str, Any]]:
|
|
32
|
+
try:
|
|
33
|
+
raw = json.loads(STATE_PATH.read_text())
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return {}
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return {}
|
|
38
|
+
return raw if isinstance(raw, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_all(payload: dict[str, dict[str, Any]]) -> None:
|
|
42
|
+
_ensure_dir()
|
|
43
|
+
STATE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _blank_session_state(session_id: str) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"session_id": session_id,
|
|
49
|
+
"milestone_count": 0,
|
|
50
|
+
"updated_at": _now_iso(),
|
|
51
|
+
"last_reason": "",
|
|
52
|
+
"task": "",
|
|
53
|
+
"task_status": "active",
|
|
54
|
+
"active_files": [],
|
|
55
|
+
"current_goal": "",
|
|
56
|
+
"decisions_summary": "",
|
|
57
|
+
"blockers": "",
|
|
58
|
+
"reasoning_thread": "",
|
|
59
|
+
"next_step": "",
|
|
60
|
+
"last_flushed_at": "",
|
|
61
|
+
"last_flush_reason": "",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _coalesce_text(new_value: str, old_value: str = "") -> str:
|
|
66
|
+
clean = str(new_value or "").strip()
|
|
67
|
+
return clean or str(old_value or "").strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_active_files(active_files: Any) -> list[str]:
|
|
71
|
+
if active_files is None:
|
|
72
|
+
return []
|
|
73
|
+
if isinstance(active_files, str):
|
|
74
|
+
stripped = active_files.strip()
|
|
75
|
+
if not stripped:
|
|
76
|
+
return []
|
|
77
|
+
if stripped.startswith("["):
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(stripped)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
parsed = [part.strip() for part in stripped.split(",") if part.strip()]
|
|
82
|
+
else:
|
|
83
|
+
parsed = [part.strip() for part in stripped.split(",") if part.strip()]
|
|
84
|
+
elif isinstance(active_files, (list, tuple, set)):
|
|
85
|
+
parsed = list(active_files)
|
|
86
|
+
else:
|
|
87
|
+
parsed = [str(active_files).strip()]
|
|
88
|
+
|
|
89
|
+
seen: list[str] = []
|
|
90
|
+
for item in parsed:
|
|
91
|
+
clean = str(item or "").strip()
|
|
92
|
+
if clean and clean not in seen:
|
|
93
|
+
seen.append(clean)
|
|
94
|
+
return seen
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _session_state(all_state: dict[str, dict[str, Any]], session_id: str) -> dict[str, Any]:
|
|
98
|
+
existing = all_state.get(session_id)
|
|
99
|
+
if not isinstance(existing, dict):
|
|
100
|
+
return _blank_session_state(session_id)
|
|
101
|
+
base = _blank_session_state(session_id)
|
|
102
|
+
base.update(existing)
|
|
103
|
+
base["session_id"] = session_id
|
|
104
|
+
base["active_files"] = _normalize_active_files(base.get("active_files"))
|
|
105
|
+
try:
|
|
106
|
+
base["milestone_count"] = max(0, int(base.get("milestone_count", 0)))
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
base["milestone_count"] = 0
|
|
109
|
+
return base
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_active_files_from_payload(payload: dict[str, Any] | None) -> list[str]:
|
|
113
|
+
if not isinstance(payload, dict):
|
|
114
|
+
return []
|
|
115
|
+
for key in ("active_files", "files", "tracked_files"):
|
|
116
|
+
files = _normalize_active_files(payload.get(key))
|
|
117
|
+
if files:
|
|
118
|
+
return files
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _flush_state(
|
|
123
|
+
all_state: dict[str, dict[str, Any]],
|
|
124
|
+
session_id: str,
|
|
125
|
+
state: dict[str, Any],
|
|
126
|
+
*,
|
|
127
|
+
reason: str,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
from db import save_checkpoint
|
|
130
|
+
|
|
131
|
+
decisions_summary = _coalesce_text(state.get("decisions_summary", ""))
|
|
132
|
+
if reason:
|
|
133
|
+
reason_note = f"checkpoint_reason={reason}"
|
|
134
|
+
decisions_summary = f"{decisions_summary} | {reason_note}".strip(" |")
|
|
135
|
+
|
|
136
|
+
active_files = _normalize_active_files(state.get("active_files"))
|
|
137
|
+
save_result = save_checkpoint(
|
|
138
|
+
sid=session_id,
|
|
139
|
+
task=_coalesce_text(state.get("task", ""), state.get("current_goal", "")),
|
|
140
|
+
task_status=_coalesce_text(state.get("task_status", ""), "active"),
|
|
141
|
+
active_files=json.dumps(active_files, ensure_ascii=False),
|
|
142
|
+
current_goal=_coalesce_text(state.get("current_goal", ""), state.get("task", "")),
|
|
143
|
+
decisions_summary=decisions_summary,
|
|
144
|
+
errors_found=_coalesce_text(state.get("blockers", "")),
|
|
145
|
+
reasoning_thread=_coalesce_text(state.get("reasoning_thread", "")),
|
|
146
|
+
next_step=_coalesce_text(state.get("next_step", "")),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
state["active_files"] = active_files
|
|
150
|
+
state["milestone_count"] = 0
|
|
151
|
+
state["last_flushed_at"] = _now_iso()
|
|
152
|
+
state["last_flush_reason"] = reason
|
|
153
|
+
state["updated_at"] = _now_iso()
|
|
154
|
+
all_state[session_id] = state
|
|
155
|
+
_save_all(all_state)
|
|
156
|
+
return {
|
|
157
|
+
"ok": True,
|
|
158
|
+
"checkpoint_written": True,
|
|
159
|
+
"session_id": session_id,
|
|
160
|
+
"milestone_count": 0,
|
|
161
|
+
"last_flush_reason": reason,
|
|
162
|
+
"compaction_count": save_result.get("compaction_count", 0),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def record_milestone(
|
|
167
|
+
session_id: str,
|
|
168
|
+
*,
|
|
169
|
+
reason: str,
|
|
170
|
+
task: str = "",
|
|
171
|
+
task_status: str = "active",
|
|
172
|
+
active_files: Any = None,
|
|
173
|
+
current_goal: str = "",
|
|
174
|
+
decisions_summary: str = "",
|
|
175
|
+
blockers: str = "",
|
|
176
|
+
reasoning_thread: str = "",
|
|
177
|
+
next_step: str = "",
|
|
178
|
+
interval: int = DEFAULT_MILESTONE_INTERVAL,
|
|
179
|
+
force_flush: bool = False,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
clean_sid = str(session_id or "").strip()
|
|
182
|
+
if not clean_sid:
|
|
183
|
+
return {"ok": False, "error": "session_id is required"}
|
|
184
|
+
|
|
185
|
+
all_state = _load_all()
|
|
186
|
+
state = _session_state(all_state, clean_sid)
|
|
187
|
+
|
|
188
|
+
state["last_reason"] = str(reason or "").strip()
|
|
189
|
+
state["updated_at"] = _now_iso()
|
|
190
|
+
state["task"] = _coalesce_text(task, state.get("task", ""))
|
|
191
|
+
state["task_status"] = _coalesce_text(task_status, state.get("task_status", "active"))
|
|
192
|
+
state["current_goal"] = _coalesce_text(current_goal, state.get("current_goal", ""))
|
|
193
|
+
state["decisions_summary"] = _coalesce_text(decisions_summary, state.get("decisions_summary", ""))
|
|
194
|
+
state["blockers"] = _coalesce_text(blockers, state.get("blockers", ""))
|
|
195
|
+
state["reasoning_thread"] = _coalesce_text(reasoning_thread, state.get("reasoning_thread", ""))
|
|
196
|
+
state["next_step"] = _coalesce_text(next_step, state.get("next_step", ""))
|
|
197
|
+
|
|
198
|
+
files = _normalize_active_files(active_files)
|
|
199
|
+
if files:
|
|
200
|
+
state["active_files"] = files
|
|
201
|
+
|
|
202
|
+
state["milestone_count"] = max(0, int(state.get("milestone_count", 0))) + 1
|
|
203
|
+
all_state[clean_sid] = state
|
|
204
|
+
|
|
205
|
+
flush_every = max(1, int(interval or DEFAULT_MILESTONE_INTERVAL))
|
|
206
|
+
if force_flush or state["milestone_count"] >= flush_every:
|
|
207
|
+
return _flush_state(all_state, clean_sid, state, reason=str(reason or "").strip())
|
|
208
|
+
|
|
209
|
+
_save_all(all_state)
|
|
210
|
+
return {
|
|
211
|
+
"ok": True,
|
|
212
|
+
"checkpoint_written": False,
|
|
213
|
+
"session_id": clean_sid,
|
|
214
|
+
"milestone_count": state["milestone_count"],
|
|
215
|
+
"flush_interval": flush_every,
|
|
216
|
+
"pending_reason": state["last_reason"],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def force_runtime_checkpoint(session_id: str, *, reason: str = "pre-compact") -> dict[str, Any]:
|
|
221
|
+
clean_sid = str(session_id or "").strip()
|
|
222
|
+
if not clean_sid:
|
|
223
|
+
return {"ok": False, "error": "session_id is required"}
|
|
224
|
+
|
|
225
|
+
from db import get_db, read_checkpoint
|
|
226
|
+
|
|
227
|
+
all_state = _load_all()
|
|
228
|
+
state = _session_state(all_state, clean_sid)
|
|
229
|
+
conn = get_db()
|
|
230
|
+
|
|
231
|
+
session_row = conn.execute(
|
|
232
|
+
"SELECT task FROM sessions WHERE sid = ? LIMIT 1",
|
|
233
|
+
(clean_sid,),
|
|
234
|
+
).fetchone()
|
|
235
|
+
existing_checkpoint = read_checkpoint(clean_sid) or {}
|
|
236
|
+
workflow_row = conn.execute(
|
|
237
|
+
"""SELECT goal, status, current_step_key, next_action, shared_state
|
|
238
|
+
FROM workflow_runs
|
|
239
|
+
WHERE session_id = ?
|
|
240
|
+
ORDER BY updated_at DESC LIMIT 1""",
|
|
241
|
+
(clean_sid,),
|
|
242
|
+
).fetchone()
|
|
243
|
+
|
|
244
|
+
workflow_state: dict[str, Any] = {}
|
|
245
|
+
if workflow_row and workflow_row["shared_state"]:
|
|
246
|
+
try:
|
|
247
|
+
parsed = json.loads(workflow_row["shared_state"])
|
|
248
|
+
if isinstance(parsed, dict):
|
|
249
|
+
workflow_state = parsed
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
workflow_state = {}
|
|
252
|
+
|
|
253
|
+
workflow_blocker = ""
|
|
254
|
+
if workflow_row and workflow_row["status"] in {"blocked", "waiting_approval"}:
|
|
255
|
+
workflow_blocker = (
|
|
256
|
+
f"Workflow {workflow_row['status']} at "
|
|
257
|
+
f"{workflow_row['current_step_key'] or 'current-step'}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
merged_files = (
|
|
261
|
+
_normalize_active_files(state.get("active_files"))
|
|
262
|
+
or _extract_active_files_from_payload(workflow_state)
|
|
263
|
+
or _normalize_active_files(existing_checkpoint.get("active_files"))
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
state["task"] = _coalesce_text(
|
|
267
|
+
state.get("task", ""),
|
|
268
|
+
(session_row["task"] if session_row else "") or existing_checkpoint.get("task", ""),
|
|
269
|
+
)
|
|
270
|
+
state["current_goal"] = _coalesce_text(
|
|
271
|
+
state.get("current_goal", ""),
|
|
272
|
+
(workflow_row["goal"] if workflow_row else "") or existing_checkpoint.get("current_goal", "") or state.get("task", ""),
|
|
273
|
+
)
|
|
274
|
+
state["decisions_summary"] = _coalesce_text(
|
|
275
|
+
state.get("decisions_summary", ""),
|
|
276
|
+
existing_checkpoint.get("decisions_summary", "") or f"Forced durable checkpoint before {reason}.",
|
|
277
|
+
)
|
|
278
|
+
current_blockers = _coalesce_text(
|
|
279
|
+
state.get("blockers", ""),
|
|
280
|
+
existing_checkpoint.get("errors_found", ""),
|
|
281
|
+
)
|
|
282
|
+
if workflow_blocker:
|
|
283
|
+
if workflow_blocker not in current_blockers:
|
|
284
|
+
current_blockers = f"{workflow_blocker} | {current_blockers}".strip(" |")
|
|
285
|
+
state["blockers"] = current_blockers
|
|
286
|
+
state["reasoning_thread"] = _coalesce_text(
|
|
287
|
+
state.get("reasoning_thread", ""),
|
|
288
|
+
existing_checkpoint.get("reasoning_thread", "") or f"Auto-flushed by checkpoint_policy ({reason}).",
|
|
289
|
+
)
|
|
290
|
+
state["next_step"] = _coalesce_text(
|
|
291
|
+
state.get("next_step", ""),
|
|
292
|
+
(workflow_row["next_action"] if workflow_row else "") or existing_checkpoint.get("next_step", ""),
|
|
293
|
+
)
|
|
294
|
+
state["task_status"] = _coalesce_text(
|
|
295
|
+
state.get("task_status", ""),
|
|
296
|
+
(workflow_row["status"] if workflow_row else "") or existing_checkpoint.get("task_status", "") or "active",
|
|
297
|
+
)
|
|
298
|
+
state["active_files"] = merged_files
|
|
299
|
+
state["updated_at"] = _now_iso()
|
|
300
|
+
state["last_reason"] = reason
|
|
301
|
+
all_state[clean_sid] = state
|
|
302
|
+
return _flush_state(all_state, clean_sid, state, reason=reason)
|
package/src/hook_guardrails.py
CHANGED
|
@@ -701,12 +701,18 @@ def _collect_protocol_warnings(conn, *, sid: str, tool_name: str) -> list[dict]:
|
|
|
701
701
|
if task.get("must_change_log")
|
|
702
702
|
else ""
|
|
703
703
|
)
|
|
704
|
+
closeout_note = (
|
|
705
|
+
" If this edit wave came from a user correction or you are leaving a blocker unresolved, "
|
|
706
|
+
"include `correction_happened=true` with a reusable learning, or `followup_needed=true`, "
|
|
707
|
+
"when you call `nexo_task_close(...)`."
|
|
708
|
+
)
|
|
704
709
|
_append_protocol_warning(
|
|
705
710
|
warnings,
|
|
706
711
|
render_core_prompt(
|
|
707
712
|
"hook-protocol-warning-task-close-evidence",
|
|
708
713
|
task_id=task_id,
|
|
709
714
|
change_note=change_note,
|
|
715
|
+
closeout_note=closeout_note,
|
|
710
716
|
),
|
|
711
717
|
)
|
|
712
718
|
|
|
@@ -91,6 +91,36 @@ if [ -f "$NEXO_DB" ]; then
|
|
|
91
91
|
LAST_HINT=$(echo "$DRAFT" | cut -d'|' -f2)
|
|
92
92
|
fi
|
|
93
93
|
|
|
94
|
+
EXECUTION_LATCH=""
|
|
95
|
+
AUTONOMY_STATE_FILE="$DATA_DIR/autonomy_mandate.json"
|
|
96
|
+
if [ -f "$AUTONOMY_STATE_FILE" ]; then
|
|
97
|
+
EXECUTION_LATCH=$(
|
|
98
|
+
TARGET_SID="$SID" AUTONOMY_STATE_FILE="$AUTONOMY_STATE_FILE" python3 -c "
|
|
99
|
+
import json, os, time
|
|
100
|
+
try:
|
|
101
|
+
raw = json.loads(open(os.environ['AUTONOMY_STATE_FILE']).read())
|
|
102
|
+
except Exception:
|
|
103
|
+
raise SystemExit(0)
|
|
104
|
+
session_id = str(raw.get('session_id', '') or '').strip()
|
|
105
|
+
target_sid = str(os.environ.get('TARGET_SID', '') or '').strip()
|
|
106
|
+
if not raw.get('active'):
|
|
107
|
+
raise SystemExit(0)
|
|
108
|
+
try:
|
|
109
|
+
expires_at = float(raw.get('expires_at', 0))
|
|
110
|
+
except Exception:
|
|
111
|
+
expires_at = 0.0
|
|
112
|
+
if expires_at <= time.time():
|
|
113
|
+
raise SystemExit(0)
|
|
114
|
+
if session_id and target_sid and session_id != target_sid:
|
|
115
|
+
raise SystemExit(0)
|
|
116
|
+
if not bool(raw.get('execute_until_blocker', True)):
|
|
117
|
+
raise SystemExit(0)
|
|
118
|
+
print('**Execution mode:** execute-until-blocker still active after compaction.')
|
|
119
|
+
print('**Guardrail:** skip option menus, reprioritization, summaries, and audits unless a real blocker or approval gate appears.')
|
|
120
|
+
" 2>/dev/null || true
|
|
121
|
+
)
|
|
122
|
+
fi
|
|
123
|
+
|
|
94
124
|
# Build Core Memory Block
|
|
95
125
|
BLOCK="## SESSION CONTINUITY [auto-injected post-compaction #$((COMPACT_COUNT + 1))]"
|
|
96
126
|
BLOCK="$BLOCK\n**Session:** $SID"
|
|
@@ -128,6 +158,10 @@ if [ -f "$NEXO_DB" ]; then
|
|
|
128
158
|
BLOCK="$BLOCK\n**Session tasks so far:** $TASKS_SEEN"
|
|
129
159
|
fi
|
|
130
160
|
|
|
161
|
+
if [ -n "$EXECUTION_LATCH" ]; then
|
|
162
|
+
BLOCK="$BLOCK\n$EXECUTION_LATCH"
|
|
163
|
+
fi
|
|
164
|
+
|
|
131
165
|
BLOCK="$BLOCK\n**Tool logs:** ${OPERATIONS_DIR}/tool-logs/${TODAY}.jsonl ($LOG_LINES entries)"
|
|
132
166
|
BLOCK="$BLOCK\n\n**POST-COMPACTION INSTRUCTIONS:**"
|
|
133
167
|
BLOCK="$BLOCK\n1. Call nexo_heartbeat with the SID above to reconnect with the session"
|
|
@@ -140,6 +140,21 @@ def _run(cmd: list[str], timeout: int) -> int:
|
|
|
140
140
|
return 1
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def _run_step(cmd: list[str], timeout: int) -> tuple[int, str]:
|
|
144
|
+
try:
|
|
145
|
+
result = subprocess.run(cmd, timeout=timeout, capture_output=True, text=True)
|
|
146
|
+
return result.returncode, (result.stdout or "").strip()
|
|
147
|
+
except Exception:
|
|
148
|
+
return 1, ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _combine_system_messages(*messages: str | None) -> str | None:
|
|
152
|
+
parts = [str(item).strip() for item in messages if str(item or "").strip()]
|
|
153
|
+
if not parts:
|
|
154
|
+
return None
|
|
155
|
+
return "\n\n".join(parts)
|
|
156
|
+
|
|
157
|
+
|
|
143
158
|
def _read_stdin_json() -> dict:
|
|
144
159
|
"""Read the Claude Code hook payload from stdin. Never raises."""
|
|
145
160
|
if sys.stdin.isatty():
|
|
@@ -202,11 +217,15 @@ def main() -> int:
|
|
|
202
217
|
(["bash", str(_DIR / "heartbeat-posttool.sh")],3),
|
|
203
218
|
]
|
|
204
219
|
exits = []
|
|
220
|
+
protocol_message = ""
|
|
205
221
|
for cmd, timeout in steps:
|
|
206
222
|
script = Path(cmd[-1])
|
|
207
223
|
if not script.is_file():
|
|
208
224
|
continue
|
|
209
|
-
|
|
225
|
+
exit_code, stdout = _run_step(cmd, timeout)
|
|
226
|
+
exits.append(exit_code)
|
|
227
|
+
if script.name == "protocol-guardrail.sh" and stdout:
|
|
228
|
+
protocol_message = stdout
|
|
210
229
|
|
|
211
230
|
exits.append(_run_auto_capture(payload))
|
|
212
231
|
|
|
@@ -217,8 +236,9 @@ def main() -> int:
|
|
|
217
236
|
try:
|
|
218
237
|
sid = _resolve_sid_from_payload(payload)
|
|
219
238
|
reminder = check_inbox_and_emit_reminder(sid)
|
|
220
|
-
|
|
221
|
-
|
|
239
|
+
combined = _combine_system_messages(protocol_message, reminder)
|
|
240
|
+
if combined:
|
|
241
|
+
print(json.dumps({"systemMessage": combined}))
|
|
222
242
|
except Exception:
|
|
223
243
|
pass
|
|
224
244
|
|
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -49,6 +49,20 @@ if [ -f "$NEXO_DB" ]; then
|
|
|
49
49
|
END,
|
|
50
50
|
updated_at = datetime('now')
|
|
51
51
|
" 2>/dev/null || true
|
|
52
|
+
|
|
53
|
+
# Flush the richer durable checkpoint state if milestone data exists.
|
|
54
|
+
NEXO_PRECOMPACT_SID="$LATEST_SID" HOOK_DIR="$HOOK_DIR" python3 -c "
|
|
55
|
+
import os, sys
|
|
56
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.environ['HOOK_DIR'], '..')))
|
|
57
|
+
try:
|
|
58
|
+
import checkpoint_policy
|
|
59
|
+
checkpoint_policy.force_runtime_checkpoint(
|
|
60
|
+
os.environ['NEXO_PRECOMPACT_SID'],
|
|
61
|
+
reason='pre-compact-hook',
|
|
62
|
+
)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
" 2>/dev/null || true
|
|
52
66
|
fi
|
|
53
67
|
fi
|
|
54
68
|
|
package/src/plugins/protocol.py
CHANGED
|
@@ -1648,6 +1648,28 @@ def handle_task_close(
|
|
|
1648
1648
|
status = "debt-open"
|
|
1649
1649
|
next_action = "Resolve the open protocol debt next."
|
|
1650
1650
|
|
|
1651
|
+
durable_checkpoint = None
|
|
1652
|
+
try:
|
|
1653
|
+
from checkpoint_policy import record_milestone
|
|
1654
|
+
|
|
1655
|
+
checkpoint_blockers = ""
|
|
1656
|
+
if clean_outcome in {"partial", "failed"}:
|
|
1657
|
+
checkpoint_blockers = (outcome_notes or clean_evidence or next_action).strip()
|
|
1658
|
+
durable_checkpoint = record_milestone(
|
|
1659
|
+
task.get("session_id") or sid,
|
|
1660
|
+
reason=f"task_close:{clean_outcome}",
|
|
1661
|
+
task=task.get("goal", ""),
|
|
1662
|
+
task_status=("blocked" if clean_outcome in {"partial", "failed"} else "active"),
|
|
1663
|
+
active_files=effective_files,
|
|
1664
|
+
current_goal=task.get("goal", ""),
|
|
1665
|
+
decisions_summary=(clean_change_summary or clean_outcome),
|
|
1666
|
+
blockers=checkpoint_blockers,
|
|
1667
|
+
reasoning_thread=(clean_evidence or outcome_notes or "")[:800],
|
|
1668
|
+
next_step=(next_action if clean_outcome != "done" else ""),
|
|
1669
|
+
)
|
|
1670
|
+
except Exception:
|
|
1671
|
+
durable_checkpoint = None
|
|
1672
|
+
|
|
1651
1673
|
response = {
|
|
1652
1674
|
"ok": True,
|
|
1653
1675
|
"task_id": task_id,
|
|
@@ -1668,6 +1690,8 @@ def handle_task_close(
|
|
|
1668
1690
|
"status": status,
|
|
1669
1691
|
"next_action": next_action,
|
|
1670
1692
|
}
|
|
1693
|
+
if durable_checkpoint:
|
|
1694
|
+
response["durable_checkpoint"] = durable_checkpoint
|
|
1671
1695
|
return json.dumps(response, ensure_ascii=False, indent=2)
|
|
1672
1696
|
|
|
1673
1697
|
|
package/src/plugins/workflow.py
CHANGED
|
@@ -71,6 +71,43 @@ def _parse_json_object(value: str) -> dict | None:
|
|
|
71
71
|
return parsed if isinstance(parsed, dict) else None
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _checkpoint_active_files(*payloads: dict | None) -> list[str]:
|
|
75
|
+
seen: list[str] = []
|
|
76
|
+
for payload in payloads:
|
|
77
|
+
if not isinstance(payload, dict):
|
|
78
|
+
continue
|
|
79
|
+
for key in ("active_files", "files", "tracked_files"):
|
|
80
|
+
raw = payload.get(key)
|
|
81
|
+
if raw is None:
|
|
82
|
+
continue
|
|
83
|
+
if isinstance(raw, str):
|
|
84
|
+
items = [item.strip() for item in raw.split(",") if item.strip()]
|
|
85
|
+
elif isinstance(raw, (list, tuple, set)):
|
|
86
|
+
items = [str(item).strip() for item in raw if str(item).strip()]
|
|
87
|
+
else:
|
|
88
|
+
items = [str(raw).strip()] if str(raw).strip() else []
|
|
89
|
+
for item in items:
|
|
90
|
+
if item and item not in seen:
|
|
91
|
+
seen.append(item)
|
|
92
|
+
return seen
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _workflow_blocker_text(
|
|
96
|
+
*,
|
|
97
|
+
step_status: str,
|
|
98
|
+
run_status: str,
|
|
99
|
+
summary: str,
|
|
100
|
+
next_action: str,
|
|
101
|
+
requires_approval: bool,
|
|
102
|
+
) -> str:
|
|
103
|
+
status_tokens = {str(step_status or "").strip().lower(), str(run_status or "").strip().lower()}
|
|
104
|
+
if "blocked" in status_tokens:
|
|
105
|
+
return summary or next_action or "Workflow blocked."
|
|
106
|
+
if requires_approval or "waiting_approval" in status_tokens:
|
|
107
|
+
return summary or next_action or "Workflow waiting for approval."
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
74
111
|
def handle_workflow_open(
|
|
75
112
|
sid: str,
|
|
76
113
|
goal: str,
|
|
@@ -387,6 +424,34 @@ def handle_workflow_update(
|
|
|
387
424
|
"attempt_count": resume["next_step"].get("attempt_count", 0),
|
|
388
425
|
"max_retries": resume["next_step"].get("max_retries", 0),
|
|
389
426
|
}
|
|
427
|
+
try:
|
|
428
|
+
from checkpoint_policy import record_milestone
|
|
429
|
+
|
|
430
|
+
durable_checkpoint = record_milestone(
|
|
431
|
+
run.get("session_id", ""),
|
|
432
|
+
reason=f"workflow:{(step_key or run.get('current_step_key') or 'update')}",
|
|
433
|
+
task=run.get("goal", ""),
|
|
434
|
+
task_status=("blocked" if run["status"] in {"blocked", "waiting_approval"} else "active"),
|
|
435
|
+
active_files=_checkpoint_active_files(
|
|
436
|
+
_parse_json_object(shared_state) if str(shared_state).strip() else None,
|
|
437
|
+
_parse_json_object(state_patch) if str(state_patch).strip() else None,
|
|
438
|
+
run.get("shared_state") if isinstance(run.get("shared_state"), dict) else None,
|
|
439
|
+
),
|
|
440
|
+
current_goal=run.get("goal", ""),
|
|
441
|
+
decisions_summary=(summary or checkpoint_label or step_title or step_key or run.get("last_checkpoint_label", "")),
|
|
442
|
+
blockers=_workflow_blocker_text(
|
|
443
|
+
step_status=step_status,
|
|
444
|
+
run_status=run["status"],
|
|
445
|
+
summary=summary,
|
|
446
|
+
next_action=next_action or run.get("next_action", ""),
|
|
447
|
+
requires_approval=requires_approval,
|
|
448
|
+
),
|
|
449
|
+
reasoning_thread=(evidence or compensation or "").strip(),
|
|
450
|
+
next_step=(next_action or run.get("next_action", "")),
|
|
451
|
+
)
|
|
452
|
+
response["durable_checkpoint"] = durable_checkpoint
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
390
455
|
return json.dumps(response, ensure_ascii=False, indent=2)
|
|
391
456
|
|
|
392
457
|
|
|
@@ -10,7 +10,9 @@ Use this before claiming a release/publication is closed when you need a live ch
|
|
|
10
10
|
|
|
11
11
|
## Gotchas
|
|
12
12
|
- A missing auto-resolved contract is a real blocker for the final release audit.
|
|
13
|
-
- Smoke is version-line scoped.
|
|
13
|
+
- Smoke is version-line scoped. The audit now requires a passing smoke artifact for the current version during final closeout, and the contract can tighten it further with `smoke.required_groups` and `smoke.max_age_hours`.
|
|
14
|
+
- Contracts may declare `critical_surfaces` to open installed/user-facing files or directories and verify markers before publication is considered closed.
|
|
15
|
+
- Contracts may declare `publication.status`, `publication.checklist_complete`, and `publication.blockers`; any open `high`/`critical` blocker now keeps publication blocked.
|
|
14
16
|
- The script is read-only except for the optional official `nexo update` step during `final_closeout`; it still does not bump versions, tag, publish, or edit website worktrees.
|
|
15
17
|
- `final_closeout` is intentionally stricter than the repo-only readiness pass: it fails if the release task was not closed with evidence or if its `change_log` row is missing.
|
|
16
18
|
- If the touched area includes bootstrap, startup, or public claims, finish with the manual watchpoints in `docs/client-parity-checklist.md`.
|
|
@@ -245,6 +245,8 @@ def main() -> int:
|
|
|
245
245
|
readiness_cmd.append("--require-contract-complete")
|
|
246
246
|
elif require_contract_complete:
|
|
247
247
|
print("[release-final-audit] require_contract_complete ignored because contract=none")
|
|
248
|
+
if include_smoke or final_closeout:
|
|
249
|
+
readiness_cmd.append("--require-smoke")
|
|
248
250
|
if final_closeout:
|
|
249
251
|
readiness_cmd.append("--final-closeout")
|
|
250
252
|
if protocol_task_id.strip():
|
package/src/tools_sessions.py
CHANGED
|
@@ -556,6 +556,27 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
|
|
|
556
556
|
def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
|
|
557
557
|
"""Inner body of handle_heartbeat — wrapped by tool_span above."""
|
|
558
558
|
from db import get_db, update_last_heartbeat_ts
|
|
559
|
+
|
|
560
|
+
mandate_state = None
|
|
561
|
+
if context_hint:
|
|
562
|
+
try:
|
|
563
|
+
from autonomy_mandate import maybe_ingest_from_text
|
|
564
|
+
|
|
565
|
+
mandate_state = maybe_ingest_from_text(
|
|
566
|
+
context_hint,
|
|
567
|
+
session_id=sid,
|
|
568
|
+
source="heartbeat",
|
|
569
|
+
)
|
|
570
|
+
except Exception:
|
|
571
|
+
mandate_state = None
|
|
572
|
+
if mandate_state is None:
|
|
573
|
+
try:
|
|
574
|
+
from autonomy_mandate import load_state
|
|
575
|
+
|
|
576
|
+
mandate_state = load_state()
|
|
577
|
+
except Exception:
|
|
578
|
+
mandate_state = None
|
|
579
|
+
|
|
559
580
|
update_session(sid, task)
|
|
560
581
|
# v6.0.1 — stamp last_heartbeat_ts so the PostToolUse hook can
|
|
561
582
|
# decide whether to surface a pending-inbox reminder on autopilot
|
|
@@ -602,6 +623,16 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
|
|
|
602
623
|
except Exception:
|
|
603
624
|
pass
|
|
604
625
|
|
|
626
|
+
try:
|
|
627
|
+
from autonomy_mandate import format_execution_latch_notice
|
|
628
|
+
|
|
629
|
+
latch_notice = format_execution_latch_notice(sid, state=mandate_state)
|
|
630
|
+
if latch_notice:
|
|
631
|
+
parts.append("")
|
|
632
|
+
parts.append(latch_notice)
|
|
633
|
+
except Exception:
|
|
634
|
+
pass
|
|
635
|
+
|
|
605
636
|
# Incremental diary draft — accumulate every heartbeat, full UPSERT every 5
|
|
606
637
|
_hb_count = 0 # Hoisted for Layer 3 DIARY_OVERDUE signal
|
|
607
638
|
try:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Protocol reminder for [[task_id]]: keep `nexo_heartbeat(...)` current and close
|
|
1
|
+
Protocol reminder for [[task_id]]: keep `nexo_heartbeat(...)` current and do not close the conversation optimistically. Finish with `nexo_task_close(...)` plus concrete evidence before saying it is resolved.[[change_note]][[closeout_note]]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R14 post-user-correction: the last user message was classified as a correction (or carried strongly negative valence) and three tool calls have elapsed without a nexo_learning_add. Capture the rule you just learned NOW via nexo_learning_add(category=..., title=..., content=..., reasoning=..., prevention=...). The auto_capture hook fires in parallel, but this reminder stays active until you either call learning_add or acknowledge the correction via nexo_cognitive_trust(event='correction'). Do not produce visible text for this reminder — act on it. After handling the silent action, continue with your previous task; this reminder is silent context, not a new instruction.
|
|
1
|
+
R14 post-user-correction: the last user message was classified as a correction (or carried strongly negative valence) and three tool calls have elapsed without a nexo_learning_add. Capture the rule you just learned NOW via nexo_learning_add(category=..., title=..., content=..., reasoning=..., prevention=...). If the correction led to real edits, close the task with `nexo_task_close(...)` plus concrete evidence and let that closeout capture the `change_log`; if you cannot finish cleanly in this turn, use `followup_needed=true` on the closeout instead of ending the conversation loosely. The auto_capture hook fires in parallel, but this reminder stays active until you either call learning_add or acknowledge the correction via nexo_cognitive_trust(event='correction'). Do not produce visible text for this reminder — act on it. After handling the silent action, continue with your previous task; this reminder is silent context, not a new instruction.
|