nexo-brain 3.0.2 → 3.1.1

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.
@@ -3,11 +3,13 @@ from __future__ import annotations
3
3
  """Post-tool guardrails for conditioned file learnings."""
4
4
 
5
5
  import json
6
+ import os
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
9
10
  from db import create_protocol_debt, get_db
10
11
  from plugins.guard import _load_conditioned_learnings, _normalize_path_token
12
+ from protocol_settings import get_protocol_strictness
11
13
 
12
14
  READ_LIKE_TOOLS = {"Read"}
13
15
  WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
@@ -94,6 +96,18 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
94
96
  return None
95
97
 
96
98
 
99
+ def _find_any_open_task(conn, sid: str) -> dict | None:
100
+ row = conn.execute(
101
+ """SELECT task_id, files, guard_has_blocking
102
+ FROM protocol_tasks
103
+ WHERE session_id = ? AND status = 'open'
104
+ ORDER BY opened_at DESC
105
+ LIMIT 1""",
106
+ (sid,),
107
+ ).fetchone()
108
+ return dict(row) if row else None
109
+
110
+
97
111
  def _find_open_debt(conn, *, session_id: str, task_id: str, debt_type: str, file_token: str) -> dict | None:
98
112
  row = conn.execute(
99
113
  """SELECT *
@@ -152,6 +166,136 @@ def _ensure_protocol_debt(
152
166
  )
153
167
 
154
168
 
169
+ def process_pre_tool_event(payload: dict) -> dict:
170
+ strictness = get_protocol_strictness()
171
+ tool_name = str(payload.get("tool_name", "")).strip()
172
+ op = _operation_kind(tool_name)
173
+ if strictness == "lenient":
174
+ return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
175
+ if op not in {"write", "delete"}:
176
+ return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": strictness}
177
+
178
+ tool_input = payload.get("tool_input")
179
+ files = _extract_touched_files(tool_input)
180
+ conn = get_db()
181
+ sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
182
+ blocks: list[dict] = []
183
+
184
+ if not sid:
185
+ debt = _ensure_protocol_debt(
186
+ conn,
187
+ session_id="",
188
+ task_id="",
189
+ debt_type="strict_protocol_write_without_startup",
190
+ severity="error",
191
+ evidence=f"{tool_name} attempted before nexo_startup/session mapping.",
192
+ file_token="startup",
193
+ )
194
+ blocks.append(
195
+ {
196
+ "file": "",
197
+ "task_id": "",
198
+ "debt_id": debt.get("id"),
199
+ "debt_type": "strict_protocol_write_without_startup",
200
+ "reason_code": "missing_startup",
201
+ }
202
+ )
203
+ return {
204
+ "ok": True,
205
+ "session_id": sid,
206
+ "tool_name": tool_name,
207
+ "operation": op,
208
+ "strictness": strictness,
209
+ "blocks": blocks,
210
+ "status": "blocked",
211
+ }
212
+
213
+ if not files:
214
+ task = _find_any_open_task(conn, sid)
215
+ if not task:
216
+ debt = _ensure_protocol_debt(
217
+ conn,
218
+ session_id=sid,
219
+ task_id="",
220
+ debt_type="strict_protocol_write_without_task",
221
+ severity="error",
222
+ evidence=f"{tool_name} attempted without a detectable file path and without an open protocol task.",
223
+ file_token="unknown-target",
224
+ )
225
+ blocks.append(
226
+ {
227
+ "file": "",
228
+ "task_id": "",
229
+ "debt_id": debt.get("id"),
230
+ "debt_type": "strict_protocol_write_without_task",
231
+ "reason_code": "missing_task",
232
+ }
233
+ )
234
+ return {
235
+ "ok": True,
236
+ "session_id": sid,
237
+ "tool_name": tool_name,
238
+ "operation": op,
239
+ "strictness": strictness,
240
+ "blocks": blocks,
241
+ "status": "blocked" if blocks else "clean",
242
+ }
243
+
244
+ for filepath in files:
245
+ task = _find_open_task_for_file(conn, sid, filepath)
246
+ if not task:
247
+ debt = _ensure_protocol_debt(
248
+ conn,
249
+ session_id=sid,
250
+ task_id="",
251
+ debt_type="strict_protocol_write_without_task",
252
+ severity="error",
253
+ evidence=f"{tool_name} attempted on {filepath} without an open protocol task for that file.",
254
+ file_token=filepath,
255
+ )
256
+ blocks.append(
257
+ {
258
+ "file": filepath,
259
+ "task_id": "",
260
+ "debt_id": debt.get("id"),
261
+ "debt_type": "strict_protocol_write_without_task",
262
+ "reason_code": "missing_task",
263
+ }
264
+ )
265
+ continue
266
+
267
+ guard_debt = _find_task_guard_blocking_debt(conn, task["task_id"])
268
+ if guard_debt:
269
+ debt = _ensure_protocol_debt(
270
+ conn,
271
+ session_id=sid,
272
+ task_id=task["task_id"],
273
+ debt_type="strict_protocol_write_without_guard_ack",
274
+ severity="error",
275
+ evidence=f"{tool_name} attempted on {filepath} before acknowledging guard debt for task {task['task_id']}.",
276
+ file_token=filepath,
277
+ )
278
+ blocks.append(
279
+ {
280
+ "file": filepath,
281
+ "task_id": task["task_id"],
282
+ "debt_id": debt.get("id"),
283
+ "debt_type": "strict_protocol_write_without_guard_ack",
284
+ "reason_code": "guard_unacknowledged",
285
+ }
286
+ )
287
+
288
+ return {
289
+ "ok": True,
290
+ "session_id": sid,
291
+ "tool_name": tool_name,
292
+ "operation": op,
293
+ "strictness": strictness,
294
+ "blocks": blocks,
295
+ "status": "blocked" if blocks else "clean",
296
+ }
297
+
298
+
155
299
  def process_tool_event(payload: dict) -> dict:
156
300
  tool_name = str(payload.get("tool_name", "")).strip()
157
301
  op = _operation_kind(tool_name)
@@ -289,6 +433,38 @@ def format_hook_message(result: dict) -> str:
289
433
  return "\n".join(lines)
290
434
 
291
435
 
436
+ def format_pretool_block_message(result: dict) -> str:
437
+ blocks = result.get("blocks") or []
438
+ if not blocks:
439
+ return ""
440
+ strictness = str(result.get("strictness") or "strict")
441
+ header = (
442
+ "NEXO LEARNING MODE BLOCKED THIS EDIT:"
443
+ if strictness == "learning"
444
+ else "NEXO STRICT MODE BLOCKED THIS EDIT:"
445
+ )
446
+ lines = [header]
447
+ for item in blocks:
448
+ file_note = item["file"] or "(unknown target)"
449
+ if item.get("reason_code") == "missing_startup":
450
+ lines.append(
451
+ f"- Start the shared-brain session first: call `nexo_startup`, then `nexo_task_open`, before editing {file_note}."
452
+ )
453
+ elif item.get("reason_code") == "guard_unacknowledged":
454
+ lines.append(
455
+ f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
456
+ )
457
+ elif strictness == "learning":
458
+ lines.append(
459
+ f"- {file_note}: open `nexo_task_open(task_type='edit', files=['{file_note}'])` first, then rerun the edit."
460
+ )
461
+ else:
462
+ lines.append(
463
+ f"- {file_note}: open `nexo_task_open(... files=['{file_note}'])` before editing."
464
+ )
465
+ return "\n".join(lines)
466
+
467
+
292
468
  def main() -> int:
293
469
  raw = sys.stdin.read()
294
470
  if not raw.strip():
@@ -297,6 +473,12 @@ def main() -> int:
297
473
  payload = json.loads(raw)
298
474
  except Exception:
299
475
  return 0
476
+ if os.environ.get("NEXO_HOOK_PHASE", "").strip().lower() == "pre":
477
+ result = process_pre_tool_event(payload)
478
+ message = format_pretool_block_message(result)
479
+ if message:
480
+ print(message, file=sys.stderr)
481
+ return 2 if result.get("status") == "blocked" else 0
300
482
  result = process_tool_event(payload)
301
483
  message = format_hook_message(result)
302
484
  if message:
@@ -5,6 +5,6 @@ INPUT=$(cat || true)
5
5
  [ -z "$INPUT" ] && exit 0
6
6
 
7
7
  NEXO_CODE="${NEXO_CODE:-${HOME}/.nexo}"
8
- python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT" 2>/dev/null || true
8
+ NEXO_HOOK_PHASE=post python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT" 2>/dev/null || true
9
9
 
10
10
  exit 0
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ # NEXO PreToolUse hook — strict protocol blocking before writes/deletes
3
+
4
+ INPUT=$(cat || true)
5
+ [ -z "$INPUT" ] && exit 0
6
+
7
+ NEXO_CODE="${NEXO_CODE:-${HOME}/.nexo}"
8
+ NEXO_HOOK_PHASE=pre python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT"
9
+ exit $?
@@ -23,6 +23,7 @@ from db import (
23
23
  )
24
24
  from plugins.cortex import evaluate_cortex_state
25
25
  from plugins.guard import handle_guard_check
26
+ from protocol_settings import get_protocol_strictness
26
27
  from tools_sessions import handle_heartbeat
27
28
 
28
29
 
@@ -450,6 +451,18 @@ def handle_task_open(
450
451
 
451
452
  clean_type = task_type if task_type in {"answer", "analyze", "edit", "execute", "delegate"} else "answer"
452
453
  files_list = _parse_list(files)
454
+ protocol_strictness = get_protocol_strictness()
455
+ if protocol_strictness in {"strict", "learning"} and clean_type == "edit" and not files_list:
456
+ note = (
457
+ "Strict protocol mode requires explicit `files` for edit tasks."
458
+ if protocol_strictness == "strict"
459
+ else "Learning mode requires explicit `files` on edit tasks so NEXO can match the write against the open protocol task."
460
+ )
461
+ return json.dumps(
462
+ {"ok": False, "error": note, "protocol_strictness": protocol_strictness},
463
+ ensure_ascii=False,
464
+ indent=2,
465
+ )
453
466
  state = {
454
467
  "goal": clean_goal,
455
468
  "task_type": clean_type,
@@ -569,6 +582,7 @@ def handle_task_open(
569
582
  "session_id": sid,
570
583
  "goal": clean_goal,
571
584
  "task_type": clean_type,
585
+ "protocol_strictness": protocol_strictness,
572
586
  "mode": cortex["mode"],
573
587
  "check_id": cortex["check_id"],
574
588
  "blocked_reason": cortex.get("blocked_reason"),
@@ -596,6 +610,7 @@ def handle_task_open(
596
610
  "must_change_log": must_change_log,
597
611
  "must_learning_if_corrected": must_learning_if_corrected,
598
612
  "must_write_diary_on_close": must_write_diary_on_close,
613
+ "protocol_strictness": protocol_strictness,
599
614
  },
600
615
  "session_touch": heartbeat_result.splitlines()[0] if heartbeat_result else "",
601
616
  "open_debts": debts_created,
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared protocol-discipline settings loaded from calibration.json."""
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ DEFAULT_PROTOCOL_STRICTNESS = "lenient"
11
+ VALID_PROTOCOL_STRICTNESS = {"lenient", "strict", "learning"}
12
+
13
+
14
+ def _nexo_home() -> Path:
15
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
16
+
17
+
18
+ def _calibration_path() -> Path:
19
+ return _nexo_home() / "brain" / "calibration.json"
20
+
21
+
22
+ def normalize_protocol_strictness(value: str | None) -> str:
23
+ candidate = str(value or "").strip().lower()
24
+ aliases = {
25
+ "default": "lenient",
26
+ "normal": "lenient",
27
+ "off": "lenient",
28
+ "warn": "lenient",
29
+ "soft": "lenient",
30
+ "hard": "strict",
31
+ "guided": "learning",
32
+ }
33
+ candidate = aliases.get(candidate, candidate)
34
+ if candidate in VALID_PROTOCOL_STRICTNESS:
35
+ return candidate
36
+ return DEFAULT_PROTOCOL_STRICTNESS
37
+
38
+
39
+ def get_protocol_strictness() -> str:
40
+ env_override = os.environ.get("NEXO_PROTOCOL_STRICTNESS", "").strip()
41
+ if env_override:
42
+ return normalize_protocol_strictness(env_override)
43
+
44
+ cal_path = _calibration_path()
45
+ if not cal_path.is_file():
46
+ return DEFAULT_PROTOCOL_STRICTNESS
47
+
48
+ try:
49
+ payload = json.loads(cal_path.read_text())
50
+ except Exception:
51
+ return DEFAULT_PROTOCOL_STRICTNESS
52
+
53
+ preferences = payload.get("preferences") if isinstance(payload, dict) else {}
54
+ candidate = ""
55
+ if isinstance(preferences, dict):
56
+ candidate = str(preferences.get("protocol_strictness", "") or "").strip()
57
+ if not candidate and isinstance(payload, dict):
58
+ candidate = str(payload.get("protocol_strictness", "") or "").strip()
59
+ return normalize_protocol_strictness(candidate)
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ """Durable queue for public-core ports discovered outside the public runner.
4
+
5
+ Managed flows such as self-audit may apply a local/core fix inline. When that
6
+ fix belongs in the public repository as well, we persist a normalized queue
7
+ entry in ``evolution_log`` so the weekly public contribution cycle can port it
8
+ later instead of losing the improvement inside one machine.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sqlite3
14
+ from pathlib import Path
15
+
16
+
17
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
18
+ QUEUE_CLASSIFICATION = "public_port_queue"
19
+ QUEUE_STATUS_PENDING = "pending_public_port"
20
+ PUBLIC_ALLOWED_PREFIXES = (
21
+ "src/",
22
+ "bin/",
23
+ "tests/",
24
+ "templates/",
25
+ "hooks/",
26
+ "migrations/",
27
+ ".claude-plugin/",
28
+ )
29
+
30
+
31
+ def resolve_repo_root(nexo_code: str | os.PathLike[str] | None = None) -> Path | None:
32
+ raw = Path(
33
+ nexo_code
34
+ or os.environ.get("NEXO_CODE")
35
+ or str(NEXO_HOME)
36
+ ).expanduser()
37
+ candidates = []
38
+ if raw.name == "src":
39
+ candidates.append(raw.parent)
40
+ candidates.append(raw)
41
+ for candidate in candidates:
42
+ if (candidate / "package.json").exists():
43
+ return candidate.resolve()
44
+ return None
45
+
46
+
47
+ def normalize_public_path(
48
+ filepath: str,
49
+ *,
50
+ repo_root: Path | None = None,
51
+ ) -> str:
52
+ text = str(filepath or "").strip()
53
+ if not text:
54
+ return ""
55
+
56
+ normalized_raw = text.replace("\\", "/").lstrip("./")
57
+ if any(
58
+ normalized_raw == prefix.rstrip("/")
59
+ or normalized_raw.startswith(prefix)
60
+ for prefix in PUBLIC_ALLOWED_PREFIXES
61
+ ):
62
+ return normalized_raw
63
+
64
+ repo_root = repo_root or resolve_repo_root()
65
+ if not repo_root:
66
+ return ""
67
+
68
+ candidate = Path(text).expanduser()
69
+ if not candidate.is_absolute():
70
+ candidate = repo_root / candidate
71
+ try:
72
+ rel = candidate.resolve().relative_to(repo_root.resolve()).as_posix()
73
+ except Exception:
74
+ for prefix in PUBLIC_ALLOWED_PREFIXES:
75
+ marker = normalized_raw.find(prefix)
76
+ if marker >= 0:
77
+ return normalized_raw[marker:]
78
+ return ""
79
+ if any(rel == prefix.rstrip("/") or rel.startswith(prefix) for prefix in PUBLIC_ALLOWED_PREFIXES):
80
+ return rel
81
+ return ""
82
+
83
+
84
+ def is_public_core_path(filepath: str, *, repo_root: Path | None = None) -> bool:
85
+ return bool(normalize_public_path(filepath, repo_root=repo_root))
86
+
87
+
88
+ def queue_public_port_candidate(
89
+ conn: sqlite3.Connection,
90
+ *,
91
+ title: str,
92
+ reasoning: str,
93
+ files_changed: list[str],
94
+ source: str,
95
+ metadata: dict | None = None,
96
+ ) -> dict:
97
+ row = conn.execute(
98
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='evolution_log'"
99
+ ).fetchone()
100
+ if not row:
101
+ return {"ok": False, "reason": "evolution_log_missing"}
102
+
103
+ repo_root = resolve_repo_root()
104
+ normalized_files: list[str] = []
105
+ seen: set[str] = set()
106
+ for filepath in files_changed:
107
+ rel = normalize_public_path(filepath, repo_root=repo_root)
108
+ if rel and rel not in seen:
109
+ normalized_files.append(rel)
110
+ seen.add(rel)
111
+ if not normalized_files:
112
+ return {"ok": False, "reason": "no_public_files"}
113
+
114
+ proposal = str(title or "").strip()[:300] or "Managed core autofix queued for public port"
115
+ clean_reasoning = str(reasoning or "").strip()[:4000] or "Queued for public-core port."
116
+ payload = dict(metadata or {})
117
+ payload.setdefault("source", source)
118
+ payload["files"] = normalized_files
119
+
120
+ existing = conn.execute(
121
+ """SELECT id, status
122
+ FROM evolution_log
123
+ WHERE classification = ?
124
+ AND proposal = ?
125
+ AND files_changed = ?
126
+ AND status IN (?, 'draft_pr_created', 'skipped_duplicate_existing_pr')
127
+ ORDER BY id DESC
128
+ LIMIT 1""",
129
+ (
130
+ QUEUE_CLASSIFICATION,
131
+ proposal,
132
+ json.dumps(normalized_files),
133
+ QUEUE_STATUS_PENDING,
134
+ ),
135
+ ).fetchone()
136
+ if existing:
137
+ return {
138
+ "ok": True,
139
+ "queued": False,
140
+ "log_id": int(existing["id"]),
141
+ "status": str(existing["status"] or ""),
142
+ "files_changed": normalized_files,
143
+ }
144
+
145
+ cur = conn.execute(
146
+ """INSERT INTO evolution_log (
147
+ cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result
148
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
149
+ (
150
+ 0,
151
+ "public_core",
152
+ proposal,
153
+ QUEUE_CLASSIFICATION,
154
+ clean_reasoning,
155
+ QUEUE_STATUS_PENDING,
156
+ json.dumps(normalized_files),
157
+ json.dumps(payload, ensure_ascii=False),
158
+ ),
159
+ )
160
+ return {
161
+ "ok": True,
162
+ "queued": True,
163
+ "log_id": int(cur.lastrowid),
164
+ "status": QUEUE_STATUS_PENDING,
165
+ "files_changed": normalized_files,
166
+ }
167
+
168
+
169
+ def list_pending_public_port_candidates(
170
+ conn: sqlite3.Connection,
171
+ *,
172
+ limit: int = 3,
173
+ ) -> list[dict]:
174
+ rows = conn.execute(
175
+ """SELECT id, created_at, proposal, reasoning, status, files_changed, test_result
176
+ FROM evolution_log
177
+ WHERE classification = ?
178
+ AND status = ?
179
+ ORDER BY created_at ASC, id ASC
180
+ LIMIT ?""",
181
+ (QUEUE_CLASSIFICATION, QUEUE_STATUS_PENDING, max(1, int(limit))),
182
+ ).fetchall()
183
+ results: list[dict] = []
184
+ for row in rows:
185
+ metadata = {}
186
+ raw_payload = str(row["test_result"] or "").strip()
187
+ if raw_payload:
188
+ try:
189
+ parsed = json.loads(raw_payload)
190
+ if isinstance(parsed, dict):
191
+ metadata = parsed
192
+ except Exception:
193
+ metadata = {"raw": raw_payload}
194
+ files_changed = []
195
+ raw_files = str(row["files_changed"] or "").strip()
196
+ if raw_files:
197
+ try:
198
+ parsed_files = json.loads(raw_files)
199
+ if isinstance(parsed_files, list):
200
+ files_changed = [str(item).strip() for item in parsed_files if str(item).strip()]
201
+ except Exception:
202
+ pass
203
+ results.append(
204
+ {
205
+ "id": int(row["id"]),
206
+ "created_at": str(row["created_at"] or ""),
207
+ "title": str(row["proposal"] or ""),
208
+ "reasoning": str(row["reasoning"] or ""),
209
+ "status": str(row["status"] or ""),
210
+ "files_changed": files_changed,
211
+ "metadata": metadata,
212
+ }
213
+ )
214
+ return results
215
+
216
+
217
+ def update_public_port_candidate(
218
+ conn: sqlite3.Connection,
219
+ log_id: int,
220
+ *,
221
+ status: str,
222
+ metadata_patch: dict | None = None,
223
+ ) -> None:
224
+ row = conn.execute(
225
+ "SELECT test_result FROM evolution_log WHERE id = ? LIMIT 1",
226
+ (int(log_id),),
227
+ ).fetchone()
228
+ payload: dict = {}
229
+ if row and str(row["test_result"] or "").strip():
230
+ try:
231
+ parsed = json.loads(str(row["test_result"]))
232
+ if isinstance(parsed, dict):
233
+ payload = parsed
234
+ except Exception:
235
+ payload = {"raw": str(row["test_result"])}
236
+ if metadata_patch:
237
+ payload.update(metadata_patch)
238
+ conn.execute(
239
+ "UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
240
+ (status, json.dumps(payload, ensure_ascii=False), int(log_id)),
241
+ )