nexo-brain 6.1.0 → 6.3.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.
@@ -63,6 +63,16 @@ except ImportError: # pragma: no cover
63
63
  _R17_PROMPT = "" # type: ignore
64
64
  _R17_WINDOW = 2
65
65
 
66
+ try:
67
+ from r_catalog import should_inject_r_catalog as _r_catalog_should
68
+ except ImportError: # pragma: no cover
69
+ _r_catalog_should = None # type: ignore
70
+
71
+ try:
72
+ from r34_identity_coherence import should_inject_r34 as _r34_should
73
+ except ImportError: # pragma: no cover
74
+ _r34_should = None # type: ignore
75
+
66
76
  try:
67
77
  from r20_constant_change import (
68
78
  should_inject_r20 as _r20_should,
@@ -812,6 +822,15 @@ class HeadlessEnforcer:
812
822
  decision = _r15_should(text or "", project_list, records)
813
823
  if not decision:
814
824
  return
825
+ # T4.2 — LLM gate: if the classifier says the turn is
826
+ # conversational / off-topic, skip the injection. Regex wins on
827
+ # "unknown" so legitimate R15 hits still fire without a working
828
+ # classifier.
829
+ if self._t4_gate_says_no("R15", span=(text or "")[:400]):
830
+ _logger.info(
831
+ "[R15 T4] gate=no, skipping project=%s", decision["project"]
832
+ )
833
+ return
815
834
  prompt = _R15_PROMPT.format(project=decision["project"])
816
835
  if mode == "shadow":
817
836
  _logger.info("[R15 SHADOW] would inject: project=%s", decision["project"])
@@ -819,6 +838,70 @@ class HeadlessEnforcer:
819
838
  self._enqueue(prompt, decision["tag"], rule_id="R15_project_context")
820
839
  _logger.info("[R15 %s] enqueued project=%s", mode.upper(), decision["project"])
821
840
 
841
+ # ------------------------------------------------------------------
842
+ # T4 LLM gate — central helper (Plan Consolidado T4.2-T4.6).
843
+ # ------------------------------------------------------------------
844
+ def _t4_gate_says_no(self, rule_id: str, *, span: str, context: str = "") -> bool:
845
+ """Return True ONLY when the T4 classifier explicitly votes "no"
846
+ for this rule hit. "yes" or "unknown" (classifier unavailable,
847
+ import error, rate limit, parse failure) fall through to regex
848
+ behaviour — never silently suppress a rule on infra flakiness.
849
+
850
+ Every unavailable-path logs a WARNING once per (rule_id, reason)
851
+ via ``_t4_gate_warned`` so degradations surface in the console
852
+ without flooding it.
853
+ """
854
+ if not hasattr(self, "_t4_gate_warned"):
855
+ self._t4_gate_warned = set()
856
+ try:
857
+ from t4_llm_gate import build_prompt, classify_with_llm
858
+ from enforcement_classifier import classify as _classifier_raw
859
+ except Exception as exc:
860
+ key = (rule_id, f"import:{exc.__class__.__name__}")
861
+ if key not in self._t4_gate_warned:
862
+ self._t4_gate_warned.add(key)
863
+ _logger.warning("[T4 gate] import failed for %s: %s", rule_id, exc)
864
+ return False
865
+
866
+ # Auditor H1 fix: the legacy bool contract of `classify` collapses
867
+ # "classifier said no" and "classifier response unparseable after
868
+ # two retries — conservative fallback" into the same False, which
869
+ # would silently suppress destructive rules (R23e/R23f/R23h) when
870
+ # the backend responds with garbage. Force the tristate path so
871
+ # "unknown" falls through to regex behaviour instead of becoming
872
+ # a silent rule disable.
873
+ def _classifier_tristate(q: str, ctx: str) -> str:
874
+ return _classifier_raw(q, ctx, tristate=True)
875
+
876
+ prompt = build_prompt(rule_id, span=span, context=context)
877
+ if not prompt:
878
+ key = (rule_id, "no-prompt")
879
+ if key not in self._t4_gate_warned:
880
+ self._t4_gate_warned.add(key)
881
+ _logger.warning(
882
+ "[T4 gate] no prompt template for rule_id=%s (check PROMPTS)",
883
+ rule_id,
884
+ )
885
+ return False
886
+ try:
887
+ verdict = classify_with_llm(
888
+ rule_id,
889
+ prompt=prompt,
890
+ context=context,
891
+ classifier=_classifier_tristate,
892
+ )
893
+ except Exception as exc:
894
+ key = (rule_id, f"classify:{exc.__class__.__name__}")
895
+ if key not in self._t4_gate_warned:
896
+ self._t4_gate_warned.add(key)
897
+ _logger.warning(
898
+ "[T4 gate] classify failed for %s: %s — regex fallback active",
899
+ rule_id,
900
+ exc,
901
+ )
902
+ return False
903
+ return verdict == "no"
904
+
822
905
  def _check_r23(self, tool_name: str, tool_input):
823
906
  """R23 — ssh/scp/rsync/curl towards an unregistered host."""
824
907
  if _r23_should is None:
@@ -986,6 +1069,10 @@ class HeadlessEnforcer:
986
1069
  should, prompt = _r23e_should(tool_name, tool_input)
987
1070
  if not should:
988
1071
  return
1072
+ span = (tool_input or {}).get("command", "") if isinstance(tool_input, dict) else ""
1073
+ if self._t4_gate_says_no("R23e", span=span):
1074
+ _logger.info("[R23e T4] gate=no, skipping")
1075
+ return
989
1076
  if mode == "shadow":
990
1077
  _logger.info("[R23e SHADOW] would inject")
991
1078
  return
@@ -1003,6 +1090,10 @@ class HeadlessEnforcer:
1003
1090
  should, prompt = _r23f_should(tool_name, tool_input, production_markers=markers or None)
1004
1091
  if not should:
1005
1092
  return
1093
+ span = (tool_input or {}).get("command", "") if isinstance(tool_input, dict) else ""
1094
+ if self._t4_gate_says_no("R23f", span=span):
1095
+ _logger.info("[R23f T4] gate=no, skipping")
1096
+ return
1006
1097
  if mode == "shadow":
1007
1098
  _logger.info("[R23f SHADOW] would inject")
1008
1099
  return
@@ -1252,6 +1343,12 @@ class HeadlessEnforcer:
1252
1343
  should, prompt = _r23h_should(tool_name, tool_input)
1253
1344
  if not should:
1254
1345
  return
1346
+ span = ""
1347
+ if isinstance(tool_input, dict):
1348
+ span = tool_input.get("content") or tool_input.get("new_string") or ""
1349
+ if self._t4_gate_says_no("R23h", span=str(span)[:500]):
1350
+ _logger.info("[R23h T4] gate=no, skipping")
1351
+ return
1255
1352
  if mode == "shadow":
1256
1353
  _logger.info("[R23h SHADOW] would inject")
1257
1354
  return
@@ -1341,6 +1438,30 @@ class HeadlessEnforcer:
1341
1438
  self._enqueue(prompt, decision["tag"], rule_id="R22_personal_script")
1342
1439
  _logger.info("[R22 %s] enqueued path=%s missing=%s", mode.upper(), decision["path"], decision["missing"])
1343
1440
 
1441
+ def _check_r_catalog(self, tool_name: str):
1442
+ """R-CATALOG (Plan Consolidado 0.X.2) — pre-create discovery probe."""
1443
+ if _r_catalog_should is None:
1444
+ return
1445
+ mode = self._guardian_rule_mode("R_CATALOG_before_artifact_create")
1446
+ if mode == "off":
1447
+ return
1448
+ # The trigger tool was just appended to recent_tool_records so we
1449
+ # inspect the preceding window (strip the current call).
1450
+ window = 60.0
1451
+ now = time.time()
1452
+ names = [
1453
+ r.tool for r in self.recent_tool_records[:-1]
1454
+ if (now - getattr(r, "ts", now)) <= window
1455
+ ]
1456
+ should, prompt = _r_catalog_should(tool_name, recent_tool_names=names)
1457
+ if not should:
1458
+ return
1459
+ if mode == "shadow":
1460
+ _logger.info("[R_CATALOG SHADOW] would inject for %s", tool_name)
1461
+ return
1462
+ self._enqueue(prompt, f"R_CATALOG:{tool_name}", rule_id="R_CATALOG_before_artifact_create")
1463
+ _logger.info("[R_CATALOG %s] enqueued tool=%s", mode.upper(), tool_name)
1464
+
1344
1465
  def _check_r18(self, tool_name: str, tool_input):
1345
1466
  """R18 — suggest followup_complete on closure-class actions."""
1346
1467
  if _r18_should is None or _r18_format is None:
@@ -1361,6 +1482,40 @@ class HeadlessEnforcer:
1361
1482
  self._enqueue(prompt, decision["tag"], rule_id="R18_followup_autocomplete")
1362
1483
  _logger.info("[R18 %s] enqueued %d matches", mode.upper(), decision["count"])
1363
1484
 
1485
+ def on_assistant_message(self, text: str, *, classifier=None):
1486
+ """R34 entry point — called when an assistant message is complete.
1487
+
1488
+ Plan Consolidado T5. If the message is a past-tense denial of an
1489
+ action (ES/EN patterns) and no shared-brain tool was called in the
1490
+ current turn, the rule fires a reminder to consult the shared brain
1491
+ before asserting what happened.
1492
+
1493
+ Args:
1494
+ text: assistant output text.
1495
+ classifier: optional LLM yes/no callable used to disambiguate
1496
+ regex matches. Tests pass a fake.
1497
+ """
1498
+ if _r34_should is None or not text:
1499
+ return
1500
+ mode = self._guardian_rule_mode("R34_identity_coherence")
1501
+ if mode == "off":
1502
+ return
1503
+ recent_names = [r.tool for r in self.recent_tool_records]
1504
+ try:
1505
+ inject, prompt, matched = _r34_should(
1506
+ text, recent_tool_names=recent_names, classifier=classifier,
1507
+ )
1508
+ except Exception as exc: # noqa: BLE001
1509
+ _logger.warning("R34 probe failed (%s); staying silent", exc)
1510
+ return
1511
+ if not inject:
1512
+ return
1513
+ if mode == "shadow":
1514
+ _logger.info("[R34 SHADOW] would inject matched=%r", matched)
1515
+ return
1516
+ self._enqueue(prompt, f"R34:{matched[:40]}", rule_id="R34_identity_coherence")
1517
+ _logger.info("[R34 %s] enqueued matched=%r", mode.upper(), matched)
1518
+
1364
1519
  def notify_stale_memory_cited(self):
1365
1520
  """External hook for R24 — caller (handle_cognitive_retrieve post-
1366
1521
  processing) flags when a stale memory entered the context. Opens
@@ -1510,6 +1665,10 @@ class HeadlessEnforcer:
1510
1665
  # R22 — personal script create without prior context probes.
1511
1666
  self._check_r22(name, tool_input)
1512
1667
 
1668
+ # R-CATALOG (Plan 0.X.2) — nudge if we are about to create/open/add
1669
+ # without having consulted the live inventory in the last 60 s.
1670
+ self._check_r_catalog(name)
1671
+
1513
1672
  # R18 — retroactive followup-complete suggestion on closure actions.
1514
1673
  self._check_r18(name, tool_input)
1515
1674
 
@@ -0,0 +1,194 @@
1
+ """Plan Consolidado F.2/F.5/F.6 — Fase F telemetry loops.
2
+
3
+ Consumes `guardian-telemetry.ndjson` (item 0.18) and produces:
4
+ - per-rule aggregate metrics (F.2)
5
+ - false-positive grouping (F.5)
6
+ - false-negative candidates for new-rule promotion (F.6)
7
+
8
+ These are pure functions with no side effects on the live runtime —
9
+ `src/scripts/phase_guardian_analysis.py` (Deep Sleep phase) calls them
10
+ and persists summaries to `~/.nexo/reports/guardian-fase-f-*.json`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+ from typing import Iterable
20
+
21
+
22
+ DEFAULT_TELEMETRY_PATH = Path.home() / ".nexo" / "logs" / "guardian-telemetry.ndjson"
23
+ DEFAULT_FP_GROUP_MIN_OCCURRENCES = 3
24
+ DEFAULT_FN_PROMOTION_THRESHOLD = 3
25
+ DEFAULT_FN_WINDOW_DAYS = 14
26
+
27
+
28
+ def load_telemetry_events(path: Path | str = DEFAULT_TELEMETRY_PATH) -> list[dict]:
29
+ """Return events persisted by the guardian_engine, oldest-first.
30
+
31
+ Ignores lines that fail to parse so a malformed append does not
32
+ blow up Deep Sleep.
33
+ """
34
+ p = Path(path)
35
+ if not p.exists():
36
+ return []
37
+ out: list[dict] = []
38
+ for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
39
+ line = line.strip()
40
+ if not line:
41
+ continue
42
+ try:
43
+ out.append(json.loads(line))
44
+ except Exception:
45
+ continue
46
+ return out
47
+
48
+
49
+ def aggregate_per_rule(events: Iterable[dict]) -> dict[str, dict]:
50
+ """F.2 — produce the per-rule metrics table.
51
+
52
+ Each rule_id gets:
53
+ - trigger_count
54
+ - injection_count
55
+ - followed_through_count (engine observed the agent then called
56
+ the gating tool within the dedup window)
57
+ - false_positive_count (operator flagged the injection as noise)
58
+ - avg_response_latency (ms between injection emit and agent act)
59
+ """
60
+ agg: dict[str, dict] = defaultdict(lambda: {
61
+ "trigger_count": 0,
62
+ "injection_count": 0,
63
+ "followed_through_count": 0,
64
+ "false_positive_count": 0,
65
+ "latencies_ms": [],
66
+ })
67
+ for e in events:
68
+ rid = e.get("rule_id") or ""
69
+ if not rid:
70
+ continue
71
+ etype = e.get("event") or e.get("type") or "injection"
72
+ bucket = agg[rid]
73
+ if etype in ("trigger", "scan"):
74
+ bucket["trigger_count"] += 1
75
+ elif etype in ("injection", "inject"):
76
+ bucket["injection_count"] += 1
77
+ elif etype == "followed_through":
78
+ bucket["followed_through_count"] += 1
79
+ elif etype in ("false_positive", "fp"):
80
+ bucket["false_positive_count"] += 1
81
+ latency = e.get("response_latency_ms") or e.get("latency_ms")
82
+ if isinstance(latency, (int, float)):
83
+ bucket["latencies_ms"].append(float(latency))
84
+
85
+ out: dict[str, dict] = {}
86
+ for rid, bucket in agg.items():
87
+ latencies = bucket.pop("latencies_ms")
88
+ avg = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
89
+ efficacy = 0.0
90
+ inj = bucket["injection_count"]
91
+ if inj > 0:
92
+ efficacy = round(bucket["followed_through_count"] / inj, 3)
93
+ fp_rate = 0.0
94
+ if inj > 0:
95
+ fp_rate = round(bucket["false_positive_count"] / inj, 3)
96
+ out[rid] = {
97
+ **bucket,
98
+ "avg_response_latency_ms": avg,
99
+ "efficacy": efficacy,
100
+ "false_positive_rate": fp_rate,
101
+ }
102
+ return out
103
+
104
+
105
+ def group_false_positives(
106
+ events: Iterable[dict],
107
+ min_occurrences: int = DEFAULT_FP_GROUP_MIN_OCCURRENCES,
108
+ ) -> list[dict]:
109
+ """F.5 — cluster FP events by (rule_id, trigger_context_hash).
110
+
111
+ Returns groups that appear >= min_occurrences times, ordered by
112
+ frequency desc. Consumers propose threshold/scope adjustments on the
113
+ top groups.
114
+ """
115
+ groups: dict[tuple, list[dict]] = defaultdict(list)
116
+ for e in events:
117
+ etype = e.get("event") or e.get("type") or ""
118
+ if etype not in ("false_positive", "fp"):
119
+ continue
120
+ key = (
121
+ e.get("rule_id") or "",
122
+ e.get("trigger_context_hash") or e.get("trigger_context") or "",
123
+ )
124
+ groups[key].append(e)
125
+
126
+ out: list[dict] = []
127
+ for (rid, ctx), items in groups.items():
128
+ if len(items) < min_occurrences:
129
+ continue
130
+ out.append({
131
+ "rule_id": rid,
132
+ "trigger_context": ctx,
133
+ "occurrences": len(items),
134
+ "first_seen": min((it.get("ts") or 0) for it in items),
135
+ "last_seen": max((it.get("ts") or 0) for it in items),
136
+ "sample": items[:3],
137
+ })
138
+ out.sort(key=lambda r: r["occurrences"], reverse=True)
139
+ return out
140
+
141
+
142
+ def collect_false_negative_candidates(
143
+ corrections: Iterable[dict],
144
+ injections: Iterable[dict],
145
+ *,
146
+ window_days: int = DEFAULT_FN_WINDOW_DAYS,
147
+ threshold: int = DEFAULT_FN_PROMOTION_THRESHOLD,
148
+ ) -> list[dict]:
149
+ """F.6 — user corrections with no matching guardian injection are
150
+ candidates for a new rule in shadow.
151
+
152
+ corrections: events that represent a user correction (from
153
+ nexo_cognitive_sentiment `is_correction=True` or `trust_event=correction`).
154
+ injections: guardian injection events (event=injection).
155
+
156
+ Corrections older than window_days are ignored. Returns candidates
157
+ grouped by a fingerprint of the preceding assistant action, ordered
158
+ by count desc, filtered by count >= threshold.
159
+ """
160
+ now = time.time()
161
+ cutoff = now - (window_days * 86400)
162
+
163
+ injection_keys = {
164
+ (i.get("rule_id") or "", i.get("trigger_fingerprint") or "")
165
+ for i in injections
166
+ }
167
+
168
+ buckets: dict[str, list[dict]] = defaultdict(list)
169
+ for c in corrections:
170
+ ts = c.get("ts") or c.get("at") or 0
171
+ if ts and ts < cutoff:
172
+ continue
173
+ fp = c.get("fingerprint") or c.get("assistant_action_fingerprint") or ""
174
+ if not fp:
175
+ continue
176
+ # Already covered by an existing guardian injection → not a
177
+ # false-negative, it's just noise the operator could not suppress.
178
+ if any(k[1] == fp for k in injection_keys):
179
+ continue
180
+ buckets[fp].append(c)
181
+
182
+ out: list[dict] = []
183
+ for fp, items in buckets.items():
184
+ if len(items) < threshold:
185
+ continue
186
+ out.append({
187
+ "fingerprint": fp,
188
+ "count": len(items),
189
+ "first_seen": min((it.get("ts") or 0) for it in items),
190
+ "last_seen": max((it.get("ts") or 0) for it in items),
191
+ "sample": items[:3],
192
+ })
193
+ out.sort(key=lambda r: r["count"], reverse=True)
194
+ return out
@@ -557,6 +557,20 @@ def process_pre_tool_event(payload: dict) -> dict:
557
557
  if op not in {"write", "delete"}:
558
558
  return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": get_protocol_strictness()}
559
559
 
560
+ # Plan Consolidado F0.0.4 — skip hook-level strict blocking while a
561
+ # structure migration is in flight. NEXO_MIGRATING=1 is set by
562
+ # nexo_migrate.run_structure_migration while it moves files and
563
+ # re-paths the runtime. Without this bypass a legitimate migration
564
+ # cannot edit anything without having opened task_open for each
565
+ # individual moved file, which defeats the whole migration flow.
566
+ if os.environ.get("NEXO_MIGRATING") == "1":
567
+ return {
568
+ "ok": True,
569
+ "skipped": True,
570
+ "reason": "structure migration in progress (NEXO_MIGRATING=1)",
571
+ "strictness": get_protocol_strictness(),
572
+ }
573
+
560
574
  tool_input = payload.get("tool_input")
561
575
  files = _extract_touched_files(tool_input)
562
576
  strictness = get_protocol_strictness()
@@ -149,11 +149,66 @@ def _dedup_record(
149
149
  # ---------------------------------------------------------------------------
150
150
 
151
151
 
152
+ # Labels used when the local zero-shot classifier is consulted. Plan
153
+ # 0.21 wave-2: classifier decides *semantically* between the four
154
+ # buckets; regex stays as a fast prefilter.
155
+ _ZS_LABELS = ("decision", "correction", "explicit", "noise")
156
+ _ZS_CONFIDENCE_FLOOR = 0.65
157
+ _ZS_MIN_LEN_FOR_LLM = 40 # short lines stay regex-only
158
+
159
+ # Module-level classifier handle, constructed lazily on first use so
160
+ # importing the hook never pays the transformers load cost.
161
+ _zs_classifier = None
162
+
163
+
164
+ def _get_zs_classifier():
165
+ """Return a LocalZeroShotClassifier or None if unavailable."""
166
+ global _zs_classifier
167
+ if _zs_classifier is not None:
168
+ return _zs_classifier
169
+ try:
170
+ from classifier_local import LocalZeroShotClassifier # type: ignore
171
+ except Exception:
172
+ _zs_classifier = False # type: ignore[assignment]
173
+ return None
174
+ try:
175
+ _zs_classifier = LocalZeroShotClassifier()
176
+ except Exception:
177
+ _zs_classifier = False # type: ignore[assignment]
178
+ return None
179
+ return _zs_classifier
180
+
181
+
182
+ def _zero_shot_classify(line: str) -> tuple[str, float] | None:
183
+ """Plan 0.21 — ask the local zero-shot classifier to bucket the line.
184
+
185
+ Returns ``(label, confidence)`` when the classifier is available and
186
+ its top confidence clears ``_ZS_CONFIDENCE_FLOOR``. Returns None
187
+ otherwise (classifier missing, pipeline load failed, low confidence,
188
+ or any exception). Callers must fall back to the regex decision in
189
+ that case — the classifier is a pre-filter / tie-breaker, never the
190
+ exclusive decider.
191
+ """
192
+ if len(line) < _ZS_MIN_LEN_FOR_LLM:
193
+ return None
194
+ clf = _get_zs_classifier()
195
+ if not clf:
196
+ return None
197
+ try:
198
+ result = clf.classify(line, _ZS_LABELS)
199
+ except Exception:
200
+ return None
201
+ if result is None or result.confidence < _ZS_CONFIDENCE_FLOOR:
202
+ return None
203
+ return result.label, float(result.confidence)
204
+
205
+
152
206
  def _classify_line(line: str) -> list[tuple[str, str]]:
153
207
  line = line.strip()
154
208
  if len(line) < _MIN_LINE_LENGTH:
155
209
  return []
156
210
 
211
+ # 1. Regex fast-path — cheap and deterministic.
157
212
  facts: list[tuple[str, str]] = []
158
213
 
159
214
  for pattern in _DECISION_PATTERNS:
@@ -171,6 +226,18 @@ def _classify_line(line: str) -> list[tuple[str, str]]:
171
226
  facts.append(("explicit", line))
172
227
  break
173
228
 
229
+ # 2. If regex produced no facts, ask the local zero-shot classifier
230
+ # for a semantic opinion. This catches multilingual correction
231
+ # shapes the regex does not know ("la ruta estaba mal en realidad",
232
+ # "that last paragraph is backwards"). The floor + min-length
233
+ # guard keep noise / short chatter off the learning pipeline.
234
+ if not facts:
235
+ zs = _zero_shot_classify(line)
236
+ if zs is not None:
237
+ label, _ = zs
238
+ if label in {"decision", "correction", "explicit"}:
239
+ facts.append((label, line))
240
+
174
241
  return facts
175
242
 
176
243
 
@@ -0,0 +1,158 @@
1
+ """nexo_migrate — Plan Consolidado F0.0 migration helper.
2
+
3
+ Pre-requisite for F0.1→F0.6 (the scripts-classification / core-vs-personal
4
+ reshuffle). This module owns:
5
+
6
+ - ``apply_migration(version, fn, notes="")`` — idempotent runner that
7
+ records success in ``migrations_applied`` and writes the matching
8
+ ``~/.nexo/.structure-version`` file so ``doctor`` and the CLI can
9
+ detect where the runtime is in the migration ladder.
10
+ - ``get_structure_version()`` — reader for the .structure-version file.
11
+ - ``ensure_migrations_table(conn)`` — idempotent ``CREATE TABLE IF NOT
12
+ EXISTS``.
13
+ - ``is_applied(version, conn=None)`` — check before re-running.
14
+
15
+ The guardian hook (``hooks/protocol-pretool-guardrail.sh`` /
16
+ ``hook_guardrails.py``) already recognises the ``NEXO_MIGRATING=1``
17
+ environment variable; this helper sets it for the duration of
18
+ ``apply_migration`` so live-repo writes during a fase are not blocked
19
+ by learnings that guard those paths.
20
+
21
+ Fail-closed: a migration that throws leaves the old structure-version
22
+ file in place and does NOT record the version as applied — rollback is
23
+ implicit.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sqlite3
29
+ import time
30
+ from pathlib import Path
31
+ from typing import Callable
32
+
33
+
34
+ def _nexo_home() -> Path:
35
+ env = os.environ.get("NEXO_HOME")
36
+ if env:
37
+ return Path(env)
38
+ return Path.home() / ".nexo"
39
+
40
+
41
+ def _db_path() -> Path:
42
+ env = os.environ.get("NEXO_DB_PATH")
43
+ if env:
44
+ return Path(env)
45
+ return _nexo_home() / "data" / "nexo.db"
46
+
47
+
48
+ def _structure_version_path() -> Path:
49
+ return _nexo_home() / ".structure-version"
50
+
51
+
52
+ def ensure_migrations_table(conn: sqlite3.Connection) -> None:
53
+ conn.execute(
54
+ """
55
+ CREATE TABLE IF NOT EXISTS migrations_applied (
56
+ version TEXT PRIMARY KEY,
57
+ applied_at TEXT NOT NULL,
58
+ notes TEXT
59
+ )
60
+ """
61
+ )
62
+ conn.commit()
63
+
64
+
65
+ def is_applied(version: str, *, conn: sqlite3.Connection | None = None) -> bool:
66
+ owned = False
67
+ if conn is None:
68
+ path = _db_path()
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ conn = sqlite3.connect(str(path))
71
+ owned = True
72
+ try:
73
+ ensure_migrations_table(conn)
74
+ cur = conn.execute(
75
+ "SELECT 1 FROM migrations_applied WHERE version = ?",
76
+ (version,),
77
+ )
78
+ return cur.fetchone() is not None
79
+ finally:
80
+ if owned:
81
+ conn.close()
82
+
83
+
84
+ def _record_applied(version: str, notes: str, conn: sqlite3.Connection) -> None:
85
+ ensure_migrations_table(conn)
86
+ conn.execute(
87
+ "INSERT OR REPLACE INTO migrations_applied(version, applied_at, notes) VALUES (?, ?, ?)",
88
+ (version, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), notes or ""),
89
+ )
90
+ conn.commit()
91
+
92
+
93
+ def apply_migration(
94
+ version: str,
95
+ fn: Callable[[sqlite3.Connection], None],
96
+ *,
97
+ notes: str = "",
98
+ db_path: Path | None = None,
99
+ ) -> dict:
100
+ """Run ``fn(conn)`` under the NEXO_MIGRATING flag and record success.
101
+
102
+ Idempotent: already-applied versions return early with
103
+ ``{"applied": False, "reason": "already_applied"}``. A failing ``fn``
104
+ propagates its exception AFTER rolling back the transaction and
105
+ clears the NEXO_MIGRATING flag even on error.
106
+ """
107
+ path = Path(db_path) if db_path is not None else _db_path()
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ prev_flag = os.environ.get("NEXO_MIGRATING")
110
+ os.environ["NEXO_MIGRATING"] = "1"
111
+ try:
112
+ with sqlite3.connect(str(path)) as conn:
113
+ if is_applied(version, conn=conn):
114
+ return {"applied": False, "version": version, "reason": "already_applied"}
115
+ try:
116
+ fn(conn)
117
+ _record_applied(version, notes, conn)
118
+ _structure_version_path().parent.mkdir(parents=True, exist_ok=True)
119
+ _structure_version_path().write_text(version + "\n", encoding="utf-8")
120
+ return {"applied": True, "version": version, "notes": notes}
121
+ except Exception:
122
+ conn.rollback()
123
+ raise
124
+ finally:
125
+ if prev_flag is None:
126
+ os.environ.pop("NEXO_MIGRATING", None)
127
+ else:
128
+ os.environ["NEXO_MIGRATING"] = prev_flag
129
+
130
+
131
+ def get_structure_version() -> str:
132
+ try:
133
+ return _structure_version_path().read_text(encoding="utf-8").strip()
134
+ except OSError:
135
+ return ""
136
+
137
+
138
+ def bootstrap_f00(*, db_path: Path | None = None) -> dict:
139
+ """Convenience: install the migrations_applied table + F0.0 marker.
140
+
141
+ Safe to call repeatedly; follows the idempotent apply_migration path.
142
+ """
143
+ def _noop(_conn):
144
+ # F0.0 is a bootstrap marker — the side-effect is just "the
145
+ # migrations_applied table exists and we recorded F0.0". The
146
+ # real schema ALTERs live in later versions (F0.1+).
147
+ pass
148
+
149
+ return apply_migration("F0.0", _noop, notes="bootstrap migrations_applied", db_path=db_path)
150
+
151
+
152
+ __all__ = [
153
+ "apply_migration",
154
+ "bootstrap_f00",
155
+ "ensure_migrations_table",
156
+ "get_structure_version",
157
+ "is_applied",
158
+ ]