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.
@@ -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
- 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
 
@@ -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
- if reminder:
221
- print(json.dumps({"systemMessage": reminder}))
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
 
@@ -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 mode: first 2 weeks, only LOG without activating
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
- is_shadow = days_since_first < 14
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")
@@ -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
 
@@ -46,10 +46,41 @@ from db_guard import (
46
46
  validate_backup_matches_source,
47
47
  )
48
48
 
49
- NEXO_HOME = export_resolved_nexo_home()
50
- DATA_DIR = paths.data_dir()
51
- BACKUP_BASE = paths.backups_dir()
52
- PRIMARY_DB = DATA_DIR / "nexo.db"
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
- if not BACKUP_BASE.is_dir():
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 BACKUP_BASE.glob(HOURLY_BACKUP_GLOB):
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 = BACKUP_BASE / "weekly"
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 BACKUP_BASE.iterdir():
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 PRIMARY_DB
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 = BACKUP_BASE / f"pre-recover-{time.strftime('%Y-%m-%d-%H%M%S')}"
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