nexo-brain 7.31.5 → 7.31.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.31.5",
3
+ "version": "7.31.7",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.31.5` is the current packaged-runtime line. Patch release over v7.31.4 - the breaker resume notice exists: when a notified engine pause recovers, the operator gets exactly one resume email in their language, signed by their agent.
21
+ Version `7.31.7` is the current packaged-runtime line. Patch release over v7.31.6 - stateful answers now require evidence before claiming release, commit, branch, server, ticket, deployment, sent/uploaded, installed, verified, or closed status. Guardian defaults promote identity coherence to hard/core and add a hard/core pre-answer evidence gate, while closeout and Local Context telemetry now leave stronger proof trails.
22
+
23
+ Previously in `7.31.6`: patch release over v7.31.5 - headless/email-monitor notifications now respect the Desktop UI language for static ES/EN templates before falling back to profile/calibration language and English.
24
+
25
+ Previously in `7.31.5`: patch release over v7.31.4 - the breaker resume notice exists: when a notified engine pause recovers, the operator gets exactly one resume email in their language, signed by their agent.
22
26
 
23
27
  Previously in `7.31.4`: patch release over v7.31.3 - memory recall honours absolute time ranges (ISO dates, start..end ranges, datetimes, epochs) and enforces the window in SQL, so asking about a specific past day returns that day.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.31.5",
3
+ "version": "7.31.7",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -4727,6 +4727,54 @@ def _runtime_flat_files(base_dir: Path) -> list[str]:
4727
4727
  return ordered
4728
4728
 
4729
4729
 
4730
+ _RUNTIME_RESOURCE_DIRS = {"crons", "hooks", "managed_mcp", "presets"}
4731
+ _RUNTIME_PACKAGE_IGNORE_DIRS = {
4732
+ "__pycache__",
4733
+ "bin",
4734
+ "node_modules",
4735
+ "scripts",
4736
+ "skills",
4737
+ "skills-core",
4738
+ "skills-runtime",
4739
+ "templates",
4740
+ "test",
4741
+ "tests",
4742
+ "vendor",
4743
+ }
4744
+
4745
+
4746
+ def _pyproject_top_level_packages(repo_dir: Path, src_dir: Path) -> set[str]:
4747
+ pyproject = repo_dir / "pyproject.toml"
4748
+ if not pyproject.is_file():
4749
+ return set()
4750
+ try:
4751
+ text = pyproject.read_text(encoding="utf-8", errors="ignore")
4752
+ except Exception:
4753
+ return set()
4754
+ packages: set[str] = set()
4755
+ for match in re.finditer(r'["\']([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)["\']', text):
4756
+ top = match.group(1).split(".", 1)[0]
4757
+ if top in _RUNTIME_PACKAGE_IGNORE_DIRS:
4758
+ continue
4759
+ if (src_dir / top).is_dir():
4760
+ packages.add(top)
4761
+ return packages
4762
+
4763
+
4764
+ def _runtime_package_dirs(src_dir: Path, repo_dir: Path | None = None) -> list[str]:
4765
+ packages: set[str] = set()
4766
+ if src_dir.is_dir():
4767
+ for child in sorted(src_dir.iterdir(), key=lambda path: path.name):
4768
+ if not child.is_dir() or child.name.startswith(".") or child.name in _RUNTIME_PACKAGE_IGNORE_DIRS:
4769
+ continue
4770
+ if (child / "__init__.py").is_file():
4771
+ packages.add(child.name)
4772
+ if repo_dir is not None:
4773
+ packages.update(_pyproject_top_level_packages(repo_dir, src_dir))
4774
+ packages.update(dirname for dirname in _RUNTIME_RESOURCE_DIRS if (src_dir / dirname).is_dir())
4775
+ return sorted(packages)
4776
+
4777
+
4730
4778
  def _installed_scripts_classification(dest: Path) -> dict[str, str]:
4731
4779
  scripts_dest = dest / "scripts"
4732
4780
  if dest != NEXO_HOME or not scripts_dest.is_dir():
@@ -4753,17 +4801,9 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
4753
4801
  backup_dir = paths.create_backup_dir("runtime-tree")
4754
4802
 
4755
4803
  code_dirs = [
4756
- "hooks",
4804
+ *_runtime_package_dirs(dest),
4757
4805
  "plugins",
4758
- "db",
4759
- "cognitive",
4760
- "dashboard",
4761
- "local_context",
4762
- "product_knowledge",
4763
- "rules",
4764
- "crons",
4765
4806
  "scripts",
4766
- "doctor",
4767
4807
  "skills",
4768
4808
  "skills-core",
4769
4809
  "skills-runtime",
@@ -4839,19 +4879,7 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
4839
4879
  def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
4840
4880
  import shutil
4841
4881
 
4842
- packages = [
4843
- "db",
4844
- "cognitive",
4845
- "doctor",
4846
- "local_context",
4847
- "managed_mcp",
4848
- "product_knowledge",
4849
- "dashboard",
4850
- "rules",
4851
- "crons",
4852
- "hooks",
4853
- "presets",
4854
- ]
4882
+ packages = _runtime_package_dirs(src_dir, repo_dir)
4855
4883
  flat_files = _runtime_flat_files(src_dir)
4856
4884
  copied_packages = 0
4857
4885
  copied_files = 0
package/src/cli.py CHANGED
@@ -2045,6 +2045,7 @@ def _local_context_query(args) -> int:
2045
2045
  max_chars=int(getattr(args, "max_chars", 20000) or 0),
2046
2046
  include_entities=bool(getattr(args, "include_entities", False)),
2047
2047
  include_relations=bool(getattr(args, "include_relations", False)),
2048
+ record_query=not bool(getattr(args, "no_record", False)),
2048
2049
  ),
2049
2050
  args,
2050
2051
  )
@@ -4094,6 +4095,7 @@ def main():
4094
4095
  local_context_query_p.add_argument("--include-entities", action="store_true", help="Include matched entities in the JSON payload.")
4095
4096
  local_context_query_p.add_argument("--include-relations", action="store_true", help="Include graph relations in the JSON payload.")
4096
4097
  local_context_query_p.add_argument("--no-evidence-required", action="store_true", help="Allow empty evidence results")
4098
+ local_context_query_p.add_argument("--no-record", action="store_true", help="Do not write query telemetry to local-context-usage.db")
4097
4099
  local_context_query_p.add_argument("--json", action="store_true", help="JSON output")
4098
4100
 
4099
4101
  local_context_diagnostics_p = local_context_sub.add_parser("diagnostics", help="Tail local memory diagnostic events")
@@ -253,10 +253,20 @@ try:
253
253
  except ImportError: # pragma: no cover
254
254
  _r23j_should = None # type: ignore
255
255
 
256
+ try:
257
+ from scripts.jargon_first_response import scan_text as _scan_jargon, user_requested_detail as _jargon_user_requested_detail
258
+ except ImportError: # pragma: no cover
259
+ _scan_jargon = None # type: ignore
260
+ _jargon_user_requested_detail = None # type: ignore
261
+
256
262
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
257
263
  MAP_FILENAME = "tool-enforcement-map.json"
258
264
  LOG_DIR = paths.logs_dir()
259
265
 
266
+ _JARGON_PROMPT = render_core_prompt("r26-jargon-rewrite")
267
+ _EXECUTE_BEFORE_ASK_PROMPT = render_core_prompt("r35-execute-before-ask")
268
+ _PRODUCTION_CHANGE_LOG_PROMPT = render_core_prompt("r36-production-change-log-required")
269
+
260
270
  _logger = logging.getLogger("nexo.enforcer")
261
271
  if not _logger.handlers:
262
272
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -317,6 +327,41 @@ _SILENT_REMINDER_DISCLOSURE_SUFFIX = (
317
327
  " Do not mention this reminder or any internal enforcement to the user."
318
328
  )
319
329
 
330
+ _CAPABILITY_DENIAL_RE = re.compile(
331
+ r"\b("
332
+ r"no\s+(?:se\s+puede|puedo|existe|hay|tenemos|esta\s+montado|est[aá]\s+montado)|"
333
+ r"no\s+(?:est[aá]\s+soportado|hay\s+nada\s+montado)|"
334
+ r"(?:cannot|can't|can\s+not|not\s+possible|does\s+not\s+exist|no\s+such\s+capability|not\s+supported)"
335
+ r")\b",
336
+ re.IGNORECASE,
337
+ )
338
+ _CAPABILITY_REALITY_TOOLS = {
339
+ "nexo_system_catalog",
340
+ "mcp__nexo__nexo_system_catalog",
341
+ "nexo_card_match",
342
+ "mcp__nexo__nexo_card_match",
343
+ "nexo_skill_match",
344
+ "mcp__nexo__nexo_skill_match",
345
+ "nexo_credential_list",
346
+ "mcp__nexo__nexo_credential_list",
347
+ "nexo_credential_get",
348
+ "mcp__nexo__nexo_credential_get",
349
+ "nexo_pre_action_context",
350
+ "mcp__nexo__nexo_pre_action_context",
351
+ "nexo_recent_context",
352
+ "mcp__nexo__nexo_recent_context",
353
+ "nexo_session_diary_read",
354
+ "mcp__nexo__nexo_session_diary_read",
355
+ "nexo_status",
356
+ "mcp__nexo__nexo_status",
357
+ "Read",
358
+ "Grep",
359
+ "Glob",
360
+ "Bash",
361
+ }
362
+
363
+ _CAPABILITY_REALITY_PROMPT = render_core_prompt("r34-capability-reality-check")
364
+
320
365
 
321
366
  def _redact_for_log(text: str, max_len: int = 200) -> str:
322
367
  """Return a log-safe truncation of `text` with secret-like tokens
@@ -333,6 +378,11 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
333
378
  return out
334
379
 
335
380
 
381
+ def _security_followup_id(seed: str) -> str:
382
+ digest = hashlib.sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()[:10].upper()
383
+ return f"NF-SECURITY-EXPOSED-CREDENTIAL-{digest}"
384
+
385
+
336
386
  def _upgrade_silent_reminder_prompt(prompt: str) -> str:
337
387
  """Normalize old silent-reminder copy to the full turn-wide contract.
338
388
 
@@ -416,6 +466,7 @@ class HeadlessEnforcer:
416
466
  # R25 — last user message is inspected for an explicit permit token
417
467
  # ("force OK", "si borra", etc). Populated by on_user_message.
418
468
  self._r25_last_user_text = ""
469
+ self._first_assistant_text_checked_for_jargon = False
419
470
  # R17 promise-debt state. Opened on a detected promise, counts
420
471
  # down on each tool call.
421
472
  self._r17_window_remaining = 0
@@ -433,6 +484,8 @@ class HeadlessEnforcer:
433
484
  # it as a bool avoids carrying stale push context across
434
485
  # unrelated tool chains.
435
486
  self._r23i_recent_push = False
487
+ self._production_mutation_tool_instance: int | None = None
488
+ self._production_mutation_evidence: str = ""
436
489
  # R23m — circular buffer of outbound-message sends with
437
490
  # {thread, body, ts}. Capped at 16 entries.
438
491
  self._r23m_recent_messages: list[dict] = []
@@ -677,6 +730,7 @@ class HeadlessEnforcer:
677
730
  # R15/R25 context MUST be updated regardless of R14 module availability
678
731
  # (critical fix: R14 import failure was silently killing R15/R25 too).
679
732
  self._r25_last_user_text = text or ""
733
+ self._first_assistant_text_checked_for_jargon = False
680
734
  try:
681
735
  self.on_user_message_r15(text or "")
682
736
  except Exception as _r15_exc: # noqa: BLE001
@@ -925,6 +979,11 @@ class HeadlessEnforcer:
925
979
  shadow → logs only. soft/hard → enqueues the reminder. Dedup 60s
926
980
  via the standard _enqueue tag guard.
927
981
  """
982
+ if not self._first_assistant_text_checked_for_jargon:
983
+ self._first_assistant_text_checked_for_jargon = True
984
+ self._check_jargon_text(text, tag="r26:first-response-jargon")
985
+ self._check_execute_before_ask(text)
986
+ self._check_capability_denial_requires_reality(text)
928
987
  if _detect_declared_done is None:
929
988
  return
930
989
  mode = self._guardian_rule_mode("R16_declared_done")
@@ -959,6 +1018,25 @@ class HeadlessEnforcer:
959
1018
  except Exception:
960
1019
  pass
961
1020
 
1021
+ def _check_capability_denial_requires_reality(self, text: str):
1022
+ """Block unsupported capability denials until a live source was checked."""
1023
+ if not text or not _CAPABILITY_DENIAL_RE.search(text):
1024
+ return
1025
+ if self.tools_called.intersection(_CAPABILITY_REALITY_TOOLS):
1026
+ return
1027
+ mode = self._guardian_rule_mode("R34_capability_reality_check")
1028
+ if mode == "off":
1029
+ return
1030
+ if mode == "shadow":
1031
+ _logger.info("[R34 SHADOW] would inject capability reality check")
1032
+ return
1033
+ self._enqueue(
1034
+ _CAPABILITY_REALITY_PROMPT,
1035
+ "r34:capability-denial-without-reality-check",
1036
+ rule_id="R34_capability_reality_check",
1037
+ )
1038
+ _logger.info("[R34 %s] enqueued capability reality check", mode.upper())
1039
+
962
1040
  def _r25_context(self) -> tuple[set[str], list[str]]:
963
1041
  """Resolve the (read_only_hosts, destructive_patterns) pair from
964
1042
  the shared entities registry. Returns empty sets/list on any
@@ -1149,6 +1227,130 @@ class HeadlessEnforcer:
1149
1227
  except Exception as exc: # noqa: BLE001
1150
1228
  _logger.debug("R17 commitment resolution skipped: %s", exc)
1151
1229
 
1230
+ def _check_jargon_text(self, text: str, *, tag: str) -> None:
1231
+ if _scan_jargon is None:
1232
+ return
1233
+ clean = (text or "").strip()
1234
+ if not clean:
1235
+ return
1236
+ if _jargon_user_requested_detail is not None and _jargon_user_requested_detail(self._r25_last_user_text or ""):
1237
+ return
1238
+ try:
1239
+ matches = _scan_jargon(clean)
1240
+ except Exception as exc: # noqa: BLE001
1241
+ _logger.warning("jargon scan failed (%s); staying silent", exc)
1242
+ return
1243
+ if not matches:
1244
+ return
1245
+ mode = self._guardian_rule_mode("R26_jargon_filter")
1246
+ if mode == "off":
1247
+ return
1248
+ if mode == "shadow":
1249
+ _logger.info("[R26 SHADOW] would inject jargon rewrite: %s", [m.get("token") for m in matches[:5]])
1250
+ return
1251
+ self._enqueue(_JARGON_PROMPT, tag, rule_id="R26_jargon_filter")
1252
+
1253
+ def _check_execute_before_ask(self, text: str) -> None:
1254
+ user = (self._r25_last_user_text or "").lower()
1255
+ reply = (text or "").lower()
1256
+ if not user or not reply:
1257
+ return
1258
+ imperative = re.search(r"\b(hazlo|mira|reactiva|ejecuta|arregla|corrige|aplica|dale|haz|revisa|comprueba)\b", user)
1259
+ asking = (
1260
+ "?" in reply
1261
+ or "tengo dos decisiones" in reply
1262
+ or "elige" in reply
1263
+ or "quieres que" in reply
1264
+ or "confirmas" in reply
1265
+ or "necesito que decidas" in reply
1266
+ )
1267
+ hard_boundary = re.search(
1268
+ r"\b(credencial|contraseñ|password|pago|payment|destructiv|irreversible|borrar|delete|revocar|rotar|publicar|publish|dns|legal)\b",
1269
+ user + "\n" + reply,
1270
+ )
1271
+ if not imperative or not asking or hard_boundary:
1272
+ return
1273
+ mode = self._guardian_rule_mode("R35_execute_before_ask")
1274
+ if mode == "off":
1275
+ return
1276
+ if mode == "shadow":
1277
+ _logger.info("[R35 SHADOW] would inject execute-before-ask")
1278
+ return
1279
+ self._enqueue(_EXECUTE_BEFORE_ASK_PROMPT, "r35:execute-before-ask", rule_id="R35_execute_before_ask")
1280
+
1281
+ def _production_mutation_summary(self, tool_name: str, tool_input) -> str:
1282
+ if tool_name not in {"Bash", "mcp__nexo__Bash"} or not isinstance(tool_input, dict):
1283
+ return ""
1284
+ cmd = str(tool_input.get("command") or "")
1285
+ if not cmd:
1286
+ return ""
1287
+ patterns = (
1288
+ r"\bgit\s+push\b(?!.*--dry-run)(?=.*\b(?:main|master|release|stable)\b)",
1289
+ r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:\S+",
1290
+ r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot)\b",
1291
+ r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*['\"]",
1292
+ r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/)[^'\"]*['\"]",
1293
+ r"\bnpm\s+publish\b",
1294
+ r"\bupload-release\.sh\b",
1295
+ r"\bgcloud\s+builds\s+(?:submit|triggers\s+run)\b",
1296
+ r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
1297
+ r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
1298
+ r"\b(?:whmapi1|uapi|cpapi2)\b",
1299
+ r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
1300
+ r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
1301
+ )
1302
+ for pattern in patterns:
1303
+ if re.search(pattern, cmd, re.IGNORECASE | re.DOTALL):
1304
+ return cmd[:300]
1305
+ return ""
1306
+
1307
+ def _task_close_has_change_trace(self, tool_input) -> bool:
1308
+ payload = tool_input if isinstance(tool_input, dict) else {}
1309
+ fields = (
1310
+ "files_changed",
1311
+ "change_summary",
1312
+ "change_why",
1313
+ "change_verify",
1314
+ "evidence_refs",
1315
+ "evidence",
1316
+ "verification",
1317
+ )
1318
+ joined = "\n".join(str(payload.get(field) or "") for field in fields).lower()
1319
+ if "change_log:" in joined or "nexo_change_log" in joined:
1320
+ return True
1321
+ return bool(str(payload.get("files_changed") or "").strip() and str(payload.get("change_summary") or "").strip())
1322
+
1323
+ def _check_production_change_log_close(self, tool_name: str, tool_input) -> None:
1324
+ if tool_name in {"nexo_change_log", "mcp__nexo__nexo_change_log"}:
1325
+ self._production_mutation_tool_instance = None
1326
+ self._production_mutation_evidence = ""
1327
+ return
1328
+ summary = self._production_mutation_summary(tool_name, tool_input)
1329
+ if summary:
1330
+ self._production_mutation_tool_instance = self._tool_instance_counter
1331
+ self._production_mutation_evidence = summary
1332
+ return
1333
+ if tool_name not in {"nexo_task_close", "mcp__nexo__nexo_task_close"}:
1334
+ return
1335
+ if self._production_mutation_tool_instance is None:
1336
+ return
1337
+ if self._task_close_has_change_trace(tool_input):
1338
+ self._production_mutation_tool_instance = None
1339
+ self._production_mutation_evidence = ""
1340
+ return
1341
+ mode = self._guardian_rule_mode("R36_production_change_log")
1342
+ if mode == "off":
1343
+ return
1344
+ if mode == "shadow":
1345
+ _logger.info("[R36 SHADOW] would inject production change_log requirement")
1346
+ return
1347
+ self.injection_queue.append({
1348
+ "prompt": _PRODUCTION_CHANGE_LOG_PROMPT,
1349
+ "tag": "r36:production-change-log",
1350
+ "rule_id": "R36_production_change_log",
1351
+ })
1352
+ _logger.info("[R36 %s] enqueued production change_log requirement", mode.upper())
1353
+
1152
1354
  def _advance_r17_window(self, tool_name: str):
1153
1355
  if not self._r17_promise_seen_for_turn:
1154
1356
  return
@@ -1713,10 +1915,45 @@ class HeadlessEnforcer:
1713
1915
  return
1714
1916
  if mode == "shadow":
1715
1917
  _logger.info("[R23g SHADOW] would inject")
1918
+ self._ensure_exposed_credential_followup(tool_input, reason="R23g shadow")
1716
1919
  return
1717
1920
  self._enqueue(prompt, "R23g_secrets_in_output", rule_id="R23g_secrets_in_output")
1921
+ self._ensure_exposed_credential_followup(tool_input, reason="R23g detected secret exposure risk")
1718
1922
  _logger.info("[R23g %s] enqueued", mode.upper())
1719
1923
 
1924
+ def _ensure_exposed_credential_followup(self, tool_input, *, reason: str) -> None:
1925
+ if not isinstance(tool_input, dict):
1926
+ return
1927
+ cmd = tool_input.get("command")
1928
+ if not isinstance(cmd, str) or not cmd.strip():
1929
+ return
1930
+ safe_cmd = _redact_for_log(cmd, max_len=160)
1931
+ followup_id = _security_followup_id(f"{reason}:{safe_cmd}")
1932
+ try:
1933
+ from db import create_followup, get_followup # type: ignore
1934
+
1935
+ if get_followup(followup_id):
1936
+ return
1937
+ create_followup(
1938
+ followup_id,
1939
+ description=(
1940
+ "SEGURIDAD: credencial expuesta o en riesgo detectada por el guard. "
1941
+ f"Origen: {safe_cmd}. Rotar/revocar la credencial y sustituirla en el gestor seguro."
1942
+ ),
1943
+ date=time.strftime("%Y-%m-%d"),
1944
+ verification=(
1945
+ "Cierre solo con evidencia de revocación efectiva: llamada/API/HTTP 401 para la credencial antigua "
1946
+ "o comprobación oficial equivalente, más nueva ubicación segura registrada."
1947
+ ),
1948
+ reasoning=reason,
1949
+ priority="critical",
1950
+ internal=1,
1951
+ owner="agent",
1952
+ )
1953
+ _logger.info("[R23g] security followup created: %s", followup_id)
1954
+ except Exception as exc: # noqa: BLE001
1955
+ _logger.warning("R23g security followup create failed: %s", exc)
1956
+
1720
1957
  def _check_r23i(self, tool_name: str, tool_input):
1721
1958
  """R23i — Edit after recent git push on auto_deploy project (soft)."""
1722
1959
  if _r23i_should is None or _r23i_is_push is None:
@@ -2285,12 +2522,22 @@ class HeadlessEnforcer:
2285
2522
  else:
2286
2523
  self._conditional_counters[tool] = self._conditional_counters.get(tool, 0) + 1
2287
2524
 
2525
+ if name != "nexo_task_close":
2526
+ self._check_production_change_log_close(name, tool_input)
2527
+
2288
2528
  # v7.6 task_close observed → rearm conditional for the companion
2289
2529
  # open tool so the next task cycle re-opens the obligation.
2290
2530
  if name == "nexo_task_close":
2531
+ if isinstance(tool_input, dict):
2532
+ close_text = "\n".join(
2533
+ str(tool_input.get(field) or "")
2534
+ for field in ("summary", "result", "evidence", "verification", "outcome_notes", "change_summary")
2535
+ )
2536
+ self._check_jargon_text(close_text, tag="r26:task-close-jargon")
2291
2537
  self._last_task_close_user_message_count = int(self.user_message_count or 0)
2292
2538
  self.reset_task_cycle("nexo_task_open")
2293
2539
  self._start_post_close_cooldown()
2540
+ self._check_production_change_log_close(name, tool_input)
2294
2541
  self._resolve_r17_commitments_from_task_close(tool_input)
2295
2542
 
2296
2543
  if name == "nexo_stop":
@@ -892,6 +892,37 @@ def _collect_local_context(conn: sqlite3.Connection, limit: int, allowed: set[st
892
892
  privacy_level="metadata",
893
893
  )
894
894
  )
895
+ try:
896
+ from local_context import usage_events
897
+
898
+ for row in usage_events.list_recent_query_events(limit=limit):
899
+ intent = str(row.get("intent") or "answer")
900
+ intent_label = intent.replace("_", " ").replace("-", " ")
901
+ entries.append(
902
+ _entry(
903
+ evidence_id=f"local_context_usage:{row.get('event_id')}",
904
+ source_type="local_context",
905
+ source_id=str(row.get("event_id") or ""),
906
+ created_at=row.get("created_at"),
907
+ client=row.get("client"),
908
+ object_type="local_context_query",
909
+ object_ref=row.get("query_hash"),
910
+ action=intent,
911
+ summary=(
912
+ f"local-context query intent={intent_label} "
913
+ f"result_count={row.get('result_count') or 0}"
914
+ ),
915
+ refs=[],
916
+ confidence=0.75 if int(row.get("evidence_refs_count") or 0) > 0 else 0.0,
917
+ privacy_level="metadata",
918
+ metadata={
919
+ "store": "local-context-usage.db",
920
+ "route_stage": row.get("route_stage") or "",
921
+ },
922
+ )
923
+ )
924
+ except Exception:
925
+ pass
895
926
  return entries
896
927
 
897
928
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Fase 2 spec items 0.5 + 0.19. Provides the canonical loader for the
4
4
  Guardian configuration and the validator that enforces the core-rule
5
- invariant: R13, R14, R16, R25, R30 can only be shadow / soft / hard.
5
+ invariant: R13, R14, R16, R25, R30, R34, R37 can only be shadow / soft / hard.
6
6
  A mode of 'off' is rejected with a clear error so the operator cannot
7
7
  accidentally disable a rule that Fase 2 declared non-negotiable.
8
8
 
@@ -43,6 +43,8 @@ CORE_RULES: frozenset[str] = frozenset({
43
43
  "R16_declared_done",
44
44
  "R25_nora_maria_read_only",
45
45
  "R30_pre_done_evidence_system_prompt",
46
+ "R34_identity_coherence",
47
+ "R37_pre_answer_evidence_gate",
46
48
  })
47
49
 
48
50
  VALID_MODES: frozenset[str] = frozenset({"off", "shadow", "soft", "hard"})