nexo-brain 7.1.8 → 7.2.0
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 +5 -1
- package/package.json +2 -2
- package/src/auto_update.py +239 -8
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- package/src/cli.py +229 -0
- package/src/core_schedule_controls.py +66 -0
- package/src/doctor/providers/boot.py +190 -0
- package/src/evolution_cycle.py +4 -0
- package/src/guardian_runtime_config.py +98 -0
- package/src/hook_guardrails.py +148 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +32 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/protocol.py +24 -0
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/plugins/workflow.py +65 -0
- package/src/public_contribution.py +51 -5
- package/src/r34_identity_coherence.py +31 -8
- package/src/script_registry.py +14 -6
- package/src/scripts/nexo-watchdog.sh +7 -1
- package/src/scripts/prune_runtime_backups.py +376 -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 +64 -3
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""G1 enforcer — response_contract.mode as a physical gate (master Block K).
|
|
3
|
+
|
|
4
|
+
When ``nexo_task_open`` returns a ``response_contract`` with
|
|
5
|
+
``mode ∈ {defer, ask, verify}`` the agent is expected to execute the
|
|
6
|
+
paired ``next_action`` (``nexo_cortex_decide`` for defer/ask,
|
|
7
|
+
``nexo_confidence_check`` with populated ``evidence_refs`` for verify, or
|
|
8
|
+
user turn) *before* emitting a user-visible answer. Until the G1 enforcer
|
|
9
|
+
landed, nothing physical blocked the agent from ignoring the contract —
|
|
10
|
+
the disciplined behaviour was self-imposed.
|
|
11
|
+
|
|
12
|
+
This module runs from PostToolUse. It inspects the session's latest open
|
|
13
|
+
task and, if the next_action still looks un-fulfilled after a grace
|
|
14
|
+
window, returns a ``systemMessage`` nudging the agent back onto protocol.
|
|
15
|
+
|
|
16
|
+
Modes (env ``NEXO_G1_ENFORCER_ACTIVE``, default ``shadow``):
|
|
17
|
+
off — never inject; never log.
|
|
18
|
+
shadow — log a warn-level protocol_debt row; no user-visible message.
|
|
19
|
+
hard — inject a ``<system-reminder>``-style nudge into
|
|
20
|
+
PostToolUse output so the agent reads it before the next
|
|
21
|
+
response cycle.
|
|
22
|
+
|
|
23
|
+
Fulfillment heuristic (conservative): after ``G1_GRACE_SECONDS`` the hook
|
|
24
|
+
considers the contract NOT fulfilled if:
|
|
25
|
+
- the task is still ``status='open'``, AND
|
|
26
|
+
- no ``cortex_evaluations`` row exists for this session created after
|
|
27
|
+
``task.opened_at`` (covers defer/ask — cortex_decide writes there),
|
|
28
|
+
AND
|
|
29
|
+
- no ``confidence_checks`` row exists for this session created after
|
|
30
|
+
``task.opened_at`` (covers verify).
|
|
31
|
+
|
|
32
|
+
To avoid storm on tight tool loops the hook rate-limits to one nudge per
|
|
33
|
+
``G1_RATE_LIMIT_SECONDS`` per ``(session_id, task_id)``.
|
|
34
|
+
|
|
35
|
+
Public entrypoint: ``check_response_contract_gate(sid) -> str | None``.
|
|
36
|
+
The caller passes the NEXO sid (already resolved from the payload by
|
|
37
|
+
``post_tool_use.py``). Returns the rendered message when the nudge
|
|
38
|
+
should fire, ``None`` otherwise.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import sqlite3
|
|
44
|
+
import time
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
G1_GRACE_SECONDS = int(os.environ.get("NEXO_G1_GRACE_SECONDS", "120"))
|
|
49
|
+
G1_RATE_LIMIT_SECONDS = int(os.environ.get("NEXO_G1_RATE_LIMIT_SECONDS", "180"))
|
|
50
|
+
G1_REQUIRING_MODES = frozenset({"defer", "ask", "verify"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mode() -> str:
|
|
54
|
+
try:
|
|
55
|
+
import sys
|
|
56
|
+
from pathlib import Path as _Path
|
|
57
|
+
_src = _Path(__file__).resolve().parents[1]
|
|
58
|
+
if str(_src) not in sys.path:
|
|
59
|
+
sys.path.insert(0, str(_src))
|
|
60
|
+
from guardian_runtime_config import resolve_guardian_flag # type: ignore
|
|
61
|
+
value = resolve_guardian_flag("G1_ENFORCER_ACTIVE", default="shadow")
|
|
62
|
+
except Exception:
|
|
63
|
+
value = os.environ.get("NEXO_G1_ENFORCER_ACTIVE", "shadow").strip().lower()
|
|
64
|
+
return value if value in {"off", "shadow", "hard"} else "shadow"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _db_path() -> Path:
|
|
68
|
+
try:
|
|
69
|
+
import paths # type: ignore
|
|
70
|
+
return paths.db_path()
|
|
71
|
+
except Exception:
|
|
72
|
+
home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
73
|
+
new = home / "runtime" / "data" / "nexo.db"
|
|
74
|
+
if new.is_file():
|
|
75
|
+
return new
|
|
76
|
+
legacy = home / "data" / "nexo.db"
|
|
77
|
+
return legacy if legacy.is_file() else new
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
|
81
|
+
row = conn.execute(
|
|
82
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
83
|
+
(name,),
|
|
84
|
+
).fetchone()
|
|
85
|
+
return row is not None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _has_followup_event_since(
|
|
89
|
+
conn: sqlite3.Connection,
|
|
90
|
+
table: str,
|
|
91
|
+
session_id: str,
|
|
92
|
+
since_iso: str,
|
|
93
|
+
session_column: str = "session_id",
|
|
94
|
+
time_column: str = "created_at",
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Return True if any row in ``table`` for this session post-dates ``since_iso``."""
|
|
97
|
+
if not _table_exists(conn, table):
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
row = conn.execute(
|
|
101
|
+
f"SELECT 1 FROM {table} "
|
|
102
|
+
f"WHERE {session_column} = ? AND {time_column} > ? "
|
|
103
|
+
"LIMIT 1",
|
|
104
|
+
(session_id, since_iso),
|
|
105
|
+
).fetchone()
|
|
106
|
+
except sqlite3.OperationalError:
|
|
107
|
+
# Schema drift: the column may not be called session_id in older tables.
|
|
108
|
+
return False
|
|
109
|
+
return row is not None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fetch_latest_open_task(conn: sqlite3.Connection, session_id: str) -> dict | None:
|
|
113
|
+
if not _table_exists(conn, "protocol_tasks"):
|
|
114
|
+
return None
|
|
115
|
+
cols = conn.execute("PRAGMA table_info(protocol_tasks)").fetchall()
|
|
116
|
+
names = {c[1] for c in cols}
|
|
117
|
+
if "response_mode" not in names:
|
|
118
|
+
return None
|
|
119
|
+
row = conn.execute(
|
|
120
|
+
"SELECT task_id, goal, response_mode, opened_at, status "
|
|
121
|
+
"FROM protocol_tasks "
|
|
122
|
+
"WHERE session_id = ? AND status = 'open' "
|
|
123
|
+
"ORDER BY opened_at DESC LIMIT 1",
|
|
124
|
+
(session_id,),
|
|
125
|
+
).fetchone()
|
|
126
|
+
if row is None:
|
|
127
|
+
return None
|
|
128
|
+
return {
|
|
129
|
+
"task_id": row[0],
|
|
130
|
+
"goal": row[1] or "",
|
|
131
|
+
"response_mode": (row[2] or "").strip().lower(),
|
|
132
|
+
"opened_at": row[3] or "",
|
|
133
|
+
"status": row[4] or "",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_db_time(value: str) -> float | None:
|
|
138
|
+
"""``protocol_tasks.opened_at`` uses ``datetime('now')`` → ISO-8601 UTC without timezone."""
|
|
139
|
+
if not value:
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
import datetime as _dt
|
|
143
|
+
# ``datetime('now')`` format: 'YYYY-MM-DD HH:MM:SS'
|
|
144
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
|
|
145
|
+
try:
|
|
146
|
+
dt = _dt.datetime.strptime(value[:19], fmt)
|
|
147
|
+
return dt.replace(tzinfo=_dt.timezone.utc).timestamp()
|
|
148
|
+
except ValueError:
|
|
149
|
+
continue
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rate_limited(conn: sqlite3.Connection, session_id: str, task_id: str) -> bool:
|
|
156
|
+
"""Return True if a nudge has already been emitted for (sid,task) within the window."""
|
|
157
|
+
if not _table_exists(conn, "hook_rate_limits"):
|
|
158
|
+
try:
|
|
159
|
+
conn.execute(
|
|
160
|
+
"CREATE TABLE IF NOT EXISTS hook_rate_limits ("
|
|
161
|
+
" hook TEXT NOT NULL,"
|
|
162
|
+
" session_id TEXT NOT NULL,"
|
|
163
|
+
" key TEXT NOT NULL,"
|
|
164
|
+
" last_fired_at REAL NOT NULL,"
|
|
165
|
+
" PRIMARY KEY (hook, session_id, key)"
|
|
166
|
+
")"
|
|
167
|
+
)
|
|
168
|
+
conn.commit()
|
|
169
|
+
except sqlite3.OperationalError:
|
|
170
|
+
return False
|
|
171
|
+
row = conn.execute(
|
|
172
|
+
"SELECT last_fired_at FROM hook_rate_limits "
|
|
173
|
+
"WHERE hook = ? AND session_id = ? AND key = ?",
|
|
174
|
+
("g1_enforcer", session_id, task_id),
|
|
175
|
+
).fetchone()
|
|
176
|
+
if row is None:
|
|
177
|
+
return False
|
|
178
|
+
last_fired = float(row[0] or 0.0)
|
|
179
|
+
return (time.time() - last_fired) < G1_RATE_LIMIT_SECONDS
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _record_fired(conn: sqlite3.Connection, session_id: str, task_id: str) -> None:
|
|
183
|
+
try:
|
|
184
|
+
conn.execute(
|
|
185
|
+
"INSERT OR REPLACE INTO hook_rate_limits "
|
|
186
|
+
"(hook, session_id, key, last_fired_at) VALUES (?, ?, ?, ?)",
|
|
187
|
+
("g1_enforcer", session_id, task_id, time.time()),
|
|
188
|
+
)
|
|
189
|
+
conn.commit()
|
|
190
|
+
except sqlite3.OperationalError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _write_shadow_debt(conn: sqlite3.Connection, session_id: str, task: dict) -> None:
|
|
195
|
+
"""Record a warn-level protocol_debt row so shadow mode leaves an audit trail."""
|
|
196
|
+
if not _table_exists(conn, "protocol_debt"):
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
conn.execute(
|
|
200
|
+
"INSERT INTO protocol_debt "
|
|
201
|
+
"(session_id, task_id, debt_type, severity, status, evidence) "
|
|
202
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
203
|
+
(
|
|
204
|
+
session_id,
|
|
205
|
+
task["task_id"],
|
|
206
|
+
"g1_response_contract_unfulfilled",
|
|
207
|
+
"warn",
|
|
208
|
+
"open",
|
|
209
|
+
(
|
|
210
|
+
f"mode={task['response_mode']} opened_at={task['opened_at']} "
|
|
211
|
+
f"goal={task['goal'][:160]}"
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
conn.commit()
|
|
216
|
+
except sqlite3.OperationalError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _render_message(task: dict) -> str:
|
|
221
|
+
mode = task["response_mode"]
|
|
222
|
+
task_id = task["task_id"]
|
|
223
|
+
if mode == "verify":
|
|
224
|
+
action = "nexo_confidence_check(evidence_refs=[...])"
|
|
225
|
+
reason = "verify mode needs explicit evidence refs"
|
|
226
|
+
elif mode == "defer":
|
|
227
|
+
action = "nexo_cortex_decide(...)"
|
|
228
|
+
reason = "defer mode needs a persisted cortex decision"
|
|
229
|
+
else: # ask
|
|
230
|
+
action = "nexo_cortex_decide(...) or a user turn"
|
|
231
|
+
reason = "ask mode needs clarifying input before the visible answer"
|
|
232
|
+
return (
|
|
233
|
+
"[NEXO Protocol Enforcer] G1 gate: task "
|
|
234
|
+
f"{task_id} is open with response_mode='{mode}' "
|
|
235
|
+
f"({reason}). Run {action} or close the task with "
|
|
236
|
+
"nexo_task_close BEFORE emitting the next user-visible answer. "
|
|
237
|
+
"Silent-compliant: do not mention this reminder to the user."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_response_contract_gate(sid: str) -> str | None:
|
|
242
|
+
"""Return a systemMessage string when the G1 gate wants to fire, else None.
|
|
243
|
+
|
|
244
|
+
Always returns ``None`` in ``off`` mode or when no qualifying task exists.
|
|
245
|
+
Shadow mode returns ``None`` but records a warn-level debt row for
|
|
246
|
+
observability. Hard mode returns the rendered message.
|
|
247
|
+
"""
|
|
248
|
+
mode = _mode()
|
|
249
|
+
if mode == "off" or not sid:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
db_file = _db_path()
|
|
253
|
+
if not db_file.is_file():
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
conn = sqlite3.connect(str(db_file), timeout=3)
|
|
258
|
+
except sqlite3.OperationalError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
task = _fetch_latest_open_task(conn, sid)
|
|
263
|
+
if task is None or task["response_mode"] not in G1_REQUIRING_MODES:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
opened_epoch = _parse_db_time(task["opened_at"])
|
|
267
|
+
if opened_epoch is None:
|
|
268
|
+
return None
|
|
269
|
+
if (time.time() - opened_epoch) < G1_GRACE_SECONDS:
|
|
270
|
+
return None # inside grace window — don't nudge yet
|
|
271
|
+
|
|
272
|
+
# Fulfillment heuristic: cortex_evaluations or confidence_checks after opened_at.
|
|
273
|
+
opened_iso = task["opened_at"]
|
|
274
|
+
has_cortex = _has_followup_event_since(
|
|
275
|
+
conn,
|
|
276
|
+
"cortex_evaluations",
|
|
277
|
+
sid,
|
|
278
|
+
opened_iso,
|
|
279
|
+
)
|
|
280
|
+
has_confidence = _has_followup_event_since(
|
|
281
|
+
conn,
|
|
282
|
+
"confidence_checks",
|
|
283
|
+
sid,
|
|
284
|
+
opened_iso,
|
|
285
|
+
)
|
|
286
|
+
if has_cortex or has_confidence:
|
|
287
|
+
return None # contract fulfilled
|
|
288
|
+
|
|
289
|
+
if _rate_limited(conn, sid, task["task_id"]):
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
if mode == "shadow":
|
|
293
|
+
_write_shadow_debt(conn, sid, task)
|
|
294
|
+
_record_fired(conn, sid, task["task_id"])
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
# hard
|
|
298
|
+
_record_fired(conn, sid, task["task_id"])
|
|
299
|
+
_write_shadow_debt(conn, sid, task) # keep the audit trail in hard too
|
|
300
|
+
return _render_message(task)
|
|
301
|
+
finally:
|
|
302
|
+
try:
|
|
303
|
+
conn.close()
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
@@ -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
|
|
|
@@ -214,11 +233,21 @@ def main() -> int:
|
|
|
214
233
|
# (including any writes the previous steps may have done). Emits a
|
|
215
234
|
# single-line JSON systemMessage so Claude Code surfaces it to the
|
|
216
235
|
# agent without breaking the tool pipeline.
|
|
236
|
+
# v7.2.0 — G1 enforcer (response_contract.mode physical gate) plugs in
|
|
237
|
+
# alongside the inbox reminder. Shadow mode only records a debt row;
|
|
238
|
+
# hard mode appends a nudge to the systemMessage.
|
|
217
239
|
try:
|
|
218
240
|
sid = _resolve_sid_from_payload(payload)
|
|
219
241
|
reminder = check_inbox_and_emit_reminder(sid)
|
|
220
|
-
|
|
221
|
-
|
|
242
|
+
g1_message: str | None = None
|
|
243
|
+
try:
|
|
244
|
+
from g1_enforcer import check_response_contract_gate # type: ignore
|
|
245
|
+
g1_message = check_response_contract_gate(sid)
|
|
246
|
+
except Exception:
|
|
247
|
+
g1_message = None
|
|
248
|
+
combined = _combine_system_messages(protocol_message, reminder, g1_message)
|
|
249
|
+
if combined:
|
|
250
|
+
print(json.dumps({"systemMessage": combined}))
|
|
222
251
|
except Exception:
|
|
223
252
|
pass
|
|
224
253
|
|
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/paths.py
CHANGED
|
@@ -410,6 +410,15 @@ def legacy_db_path() -> Path:
|
|
|
410
410
|
return legacy_data_dir() / "nexo.db"
|
|
411
411
|
|
|
412
412
|
|
|
413
|
+
def legacy_watchdog_hashes_path() -> Path:
|
|
414
|
+
# Pre-F0.6 watchdog hash registry landed at ``~/.nexo/scripts/.watchdog-hashes``.
|
|
415
|
+
# Post-F0.6 the canonical location is ``core_scripts_dir() / ".watchdog-hashes"``.
|
|
416
|
+
# A migration-aware consumer should check this legacy path and fold entries
|
|
417
|
+
# into the canonical file before deleting, exactly like the rest of the
|
|
418
|
+
# pre-F0.6 compat shims above.
|
|
419
|
+
return legacy_scripts_dir() / ".watchdog-hashes"
|
|
420
|
+
|
|
421
|
+
|
|
413
422
|
# ---------------------------------------------------------------------------
|
|
414
423
|
# Smart resolver: prefer new location if it exists, fall back to legacy.
|
|
415
424
|
# Used during the v7.0.0 / v7.1.0 transition window.
|
|
@@ -472,5 +481,6 @@ __all__ = [
|
|
|
472
481
|
"legacy_logs_dir",
|
|
473
482
|
"legacy_operations_dir",
|
|
474
483
|
"legacy_db_path",
|
|
484
|
+
"legacy_watchdog_hashes_path",
|
|
475
485
|
"resolve_db_path",
|
|
476
486
|
]
|
|
@@ -540,7 +540,22 @@ def learn_weights(min_samples: int = 30, lookback_days: int = 30) -> dict:
|
|
|
540
540
|
drift = {name: round(learned[name] - WEIGHTS[name], 4) for name in signal_names}
|
|
541
541
|
max_drift = max(abs(d) for d in drift.values())
|
|
542
542
|
|
|
543
|
-
# Shadow
|
|
543
|
+
# Shadow → active promotion (v7.2.0, master Block K G5):
|
|
544
|
+
#
|
|
545
|
+
# Historical rule was "shadow for the first 14 days after the first
|
|
546
|
+
# learned-weights compute, regardless of sample volume". That was
|
|
547
|
+
# safe but biased: on an active install the learner already has
|
|
548
|
+
# hundreds of samples by day 2, yet the promotion waited two full
|
|
549
|
+
# weeks. The inhibition rate stays above the 30-60% target the
|
|
550
|
+
# whole time, and any real drift is painfully slow to react to.
|
|
551
|
+
#
|
|
552
|
+
# New rule: promote to active when EITHER
|
|
553
|
+
# (a) the classic 14-day window has elapsed, OR
|
|
554
|
+
# (b) the learner has ≥200 samples AND ≥2 calendar days of data.
|
|
555
|
+
#
|
|
556
|
+
# Operator opt-out: set NEXO_ADAPTIVE_EMPIRICAL_PROMOTION=off to
|
|
557
|
+
# fall back to the 14-day-only rule. Env override is checked on
|
|
558
|
+
# every call so a flipped install can be reverted without restart.
|
|
544
559
|
first_learned_date = state.get("learned_weights_first_date")
|
|
545
560
|
if not first_learned_date:
|
|
546
561
|
first_learned_date = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
|
|
@@ -548,7 +563,16 @@ def learn_weights(min_samples: int = 30, lookback_days: int = 30) -> dict:
|
|
|
548
563
|
|
|
549
564
|
first_dt = datetime.strptime(first_learned_date, "%Y-%m-%dT%H:%M:%S")
|
|
550
565
|
days_since_first = (datetime.utcnow() - first_dt).days
|
|
551
|
-
|
|
566
|
+
empirical_opt_out = (
|
|
567
|
+
os.environ.get("NEXO_ADAPTIVE_EMPIRICAL_PROMOTION", "").strip().lower() == "off"
|
|
568
|
+
)
|
|
569
|
+
calendar_ready = days_since_first >= 14
|
|
570
|
+
empirical_ready = (
|
|
571
|
+
(not empirical_opt_out)
|
|
572
|
+
and len(rows) >= 200
|
|
573
|
+
and days_since_first >= 2
|
|
574
|
+
)
|
|
575
|
+
is_shadow = not (calendar_ready or empirical_ready)
|
|
552
576
|
|
|
553
577
|
state["shadow_weights"] = learned
|
|
554
578
|
state["shadow_weights_date"] = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
|
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/recover.py
CHANGED
|
@@ -46,10 +46,41 @@ from db_guard import (
|
|
|
46
46
|
validate_backup_matches_source,
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
# Path resolution moved to lazy helpers (AUDITOR-V700-PASS2 §11, B10 item 3)
|
|
50
|
+
# to keep monkeypatched NEXO_HOME / paths.* fixtures honoured. PEP 562
|
|
51
|
+
# ``__getattr__`` below preserves the legacy constant names for any caller
|
|
52
|
+
# that imports them as module attributes.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _nexo_home() -> Path:
|
|
56
|
+
return export_resolved_nexo_home()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _data_dir() -> Path:
|
|
60
|
+
return paths.data_dir()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _backup_base() -> Path:
|
|
64
|
+
return paths.backups_dir()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _primary_db() -> Path:
|
|
68
|
+
return _data_dir() / "nexo.db"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_LAZY_PATHS = {
|
|
72
|
+
"NEXO_HOME": _nexo_home,
|
|
73
|
+
"DATA_DIR": _data_dir,
|
|
74
|
+
"BACKUP_BASE": _backup_base,
|
|
75
|
+
"PRIMARY_DB": _primary_db,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def __getattr__(name: str):
|
|
80
|
+
resolver = _LAZY_PATHS.get(name)
|
|
81
|
+
if resolver is None:
|
|
82
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
83
|
+
return resolver()
|
|
53
84
|
|
|
54
85
|
|
|
55
86
|
# ── Backup discovery ────────────────────────────────────────────────────
|
|
@@ -64,24 +95,25 @@ def list_available_backups() -> list[dict]:
|
|
|
64
95
|
critical_rows, is_usable. Sorted newest-first.
|
|
65
96
|
"""
|
|
66
97
|
entries: list[dict] = []
|
|
67
|
-
|
|
98
|
+
backup_base = _backup_base()
|
|
99
|
+
if not backup_base.is_dir():
|
|
68
100
|
return entries
|
|
69
101
|
|
|
70
102
|
# Hourly backups from nexo-backup.sh
|
|
71
|
-
for entry in
|
|
103
|
+
for entry in backup_base.glob(HOURLY_BACKUP_GLOB):
|
|
72
104
|
if not entry.is_file():
|
|
73
105
|
continue
|
|
74
106
|
entries.append(_describe_backup(entry, kind="hourly"))
|
|
75
107
|
|
|
76
108
|
# Weekly backups
|
|
77
|
-
weekly_dir =
|
|
109
|
+
weekly_dir = backup_base / "weekly"
|
|
78
110
|
if weekly_dir.is_dir():
|
|
79
111
|
for entry in weekly_dir.glob("weekly-*.db"):
|
|
80
112
|
if entry.is_file():
|
|
81
113
|
entries.append(_describe_backup(entry, kind="weekly"))
|
|
82
114
|
|
|
83
115
|
# pre-update / pre-autoupdate / pre-recover / pre-heal snapshot dirs
|
|
84
|
-
for subdir in
|
|
116
|
+
for subdir in backup_base.iterdir():
|
|
85
117
|
if not subdir.is_dir():
|
|
86
118
|
continue
|
|
87
119
|
name = subdir.name
|
|
@@ -162,7 +194,7 @@ def recover(
|
|
|
162
194
|
dry_run: Report what would happen without touching disk.
|
|
163
195
|
target: Override the target DB path (defaults to ~/.nexo/data/nexo.db).
|
|
164
196
|
"""
|
|
165
|
-
target_path = Path(target).expanduser() if target else
|
|
197
|
+
target_path = Path(target).expanduser() if target else _primary_db()
|
|
166
198
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
199
|
|
|
168
200
|
result: dict = {
|
|
@@ -236,7 +268,7 @@ def recover(
|
|
|
236
268
|
time.sleep(0.5)
|
|
237
269
|
|
|
238
270
|
# Step 4: snapshot current state to pre-recover/
|
|
239
|
-
pre_recover_dir =
|
|
271
|
+
pre_recover_dir = _backup_base() / f"pre-recover-{time.strftime('%Y-%m-%d-%H%M%S')}"
|
|
240
272
|
if target_path.is_file():
|
|
241
273
|
pre_recover_dir.mkdir(parents=True, exist_ok=True)
|
|
242
274
|
# Copy the main DB plus any sidecar files (-wal, -shm) with shutil so
|