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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.8",
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.8` is the current packaged-runtime line. It batches 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). All new gates ship in shadow mode with env-flag rollout to `hard`. No coordinated Desktop release is required for this patch; Desktop consumers continue against the same CLI / MCP contract.
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.8",
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",
@@ -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)
@@ -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
- exits.append(_run(cmd, timeout))
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
- if reminder:
221
- print(json.dumps({"systemMessage": reminder}))
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
 
@@ -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
 
@@ -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
 
@@ -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. If no runner exists, the skill reports the skip explicitly instead of pretending it ran.
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():
@@ -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 it with `nexo_task_close(...)` plus evidence before saying it is resolved.[[change_note]]
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.