nexo-brain 7.31.9 → 7.31.11

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.9",
3
+ "version": "7.31.11",
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,9 @@
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.9` is the current packaged-runtime line. Patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
21
+ Version `7.31.11` is the current packaged-runtime line. Patch release over v7.31.10 - MCP lifecycle robustness + guardrail precision. Version `7.31.10` was a patch release over v7.31.9 - Local Memory search now downranks boilerplate emails when stronger documents match the same query.
22
+
23
+ Previously in `7.31.9`: patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
22
24
 
23
25
  Previously in `7.31.8`: patch release over v7.31.7 - email monitor debt scans no longer escalate intentionally waiting threads as unresolved commitments when recent resolution or hot-context state proves the thread is waiting on the user or a third party. Real unresolved commitments still surface.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.31.9",
3
+ "version": "7.31.11",
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",
@@ -220,9 +220,13 @@ except ImportError: # pragma: no cover
220
220
  _r23d_should = None # type: ignore
221
221
 
222
222
  try:
223
- from r23g_secrets_in_output import should_inject_r23g as _r23g_should
223
+ from r23g_secrets_in_output import (
224
+ should_inject_r23g as _r23g_should,
225
+ has_external_sink as _r23g_has_sink,
226
+ )
224
227
  except ImportError: # pragma: no cover
225
228
  _r23g_should = None # type: ignore
229
+ _r23g_has_sink = None # type: ignore
226
230
 
227
231
  try:
228
232
  from r23i_auto_deploy_ignored import (
@@ -327,12 +331,26 @@ _SILENT_REMINDER_DISCLOSURE_SUFFIX = (
327
331
  " Do not mention this reminder or any internal enforcement to the user."
328
332
  )
329
333
 
334
+ # Object of a capability denial: either the denied ACTION (hacer/acceder/usar…)
335
+ # or a capability NOUN (capacidad/integración/herramienta…). Requiring one of
336
+ # these right after the negation is what separates a real "can't do X / no
337
+ # existe esa integración" from benign phrases that merely start with a negation
338
+ # ("no hay problema", "no puedo esperar a…", "does not exist yet, creating it").
339
+ _CAP_OBJ = (
340
+ r"(?:hacer(?:lo|se)?|acceder|conectar(?:se|lo)?|integrar(?:se|lo)?|usar(?:lo|se)?|montar(?:lo)?|"
341
+ r"crear(?:lo)?|ejecutar(?:lo)?|generar(?:lo)?|enviar(?:lo)?|llamar(?:lo)?|"
342
+ r"capacidad|integraci[oó]n|herramienta|funci[oó]n|funcionalidad|soporte|forma|manera|"
343
+ r"opci[oó]n|acceso|m[oó]dulo|posibilidad|conexi[oó]n|esa|ese|eso|esto|esas|esos)"
344
+ )
330
345
  _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",
346
+ r"(?:"
347
+ r"\bno\s+(?:se\s+puede|puedo|podemos|puede)\s+(?:\w+\s+){0,1}?" + _CAP_OBJ + r"\b|"
348
+ r"\bno\s+(?:existe|hay|tengo|tenemos)\s+(?:\w+\s+){0,2}?" + _CAP_OBJ + r"\b|"
349
+ r"\bno\s+est[aá]\s+(?:soportad[oa]|montad[oa]|disponible|integrad[oa]|configurad[oa]|habilitad[oa])\b|"
350
+ r"\b(?:cannot|can'?t|can\s+not)\s+(?:\w+\s+){0,2}?(?:do\s+(?:that|it|this)|access|connect|integrate|use|create|run|support)\b|"
351
+ r"\bdoes\s+not\s+exist(?!\s+yet)\b|\bnot\s+supported\b|\bnot\s+possible\b|"
352
+ r"\bno\s+such\s+(?:capability|tool|integration|feature)\b"
353
+ r")",
336
354
  re.IGNORECASE,
337
355
  )
338
356
  _CAPABILITY_REALITY_TOOLS = {
@@ -1914,21 +1932,30 @@ class HeadlessEnforcer:
1914
1932
  if not should:
1915
1933
  return
1916
1934
  if mode == "shadow":
1935
+ # shadow → logs only. No enqueue, no followup, no side effects.
1917
1936
  _logger.info("[R23g SHADOW] would inject")
1918
- self._ensure_exposed_credential_followup(tool_input, reason="R23g shadow")
1919
1937
  return
1920
1938
  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")
1939
+ self._ensure_exposed_credential_followup(tool_input)
1922
1940
  _logger.info("[R23g %s] enqueued", mode.upper())
1923
1941
 
1924
- def _ensure_exposed_credential_followup(self, tool_input, *, reason: str) -> None:
1942
+ def _ensure_exposed_credential_followup(self, tool_input) -> None:
1925
1943
  if not isinstance(tool_input, dict):
1926
1944
  return
1927
1945
  cmd = tool_input.get("command")
1928
1946
  if not isinstance(cmd, str) or not cmd.strip():
1929
1947
  return
1948
+ # A critical "rotate the credential" followup is only warranted when the
1949
+ # secret is actually exfiltrated to a third party. A bare local read
1950
+ # (cat .env, env, printenv) exposes nothing and must NOT mint an
1951
+ # un-closeable critical followup. The soft reminder above already nudges
1952
+ # the agent; the persistent debt is reserved for real exposure.
1953
+ if _r23g_has_sink is None or not _r23g_has_sink(cmd):
1954
+ return
1930
1955
  safe_cmd = _redact_for_log(cmd, max_len=160)
1931
- followup_id = _security_followup_id(f"{reason}:{safe_cmd}")
1956
+ # Deterministic id seeded only by the (redacted) command, so the same
1957
+ # exfiltration dedups across shadow/soft/hard instead of duplicating.
1958
+ followup_id = _security_followup_id(safe_cmd)
1932
1959
  try:
1933
1960
  from db import create_followup, get_followup # type: ignore
1934
1961
 
@@ -1937,7 +1964,7 @@ class HeadlessEnforcer:
1937
1964
  create_followup(
1938
1965
  followup_id,
1939
1966
  description=(
1940
- "SEGURIDAD: credencial expuesta o en riesgo detectada por el guard. "
1967
+ "SEGURIDAD: credencial expuesta a un tercero detectada por el guard. "
1941
1968
  f"Origen: {safe_cmd}. Rotar/revocar la credencial y sustituirla en el gestor seguro."
1942
1969
  ),
1943
1970
  date=time.strftime("%Y-%m-%d"),
@@ -1945,7 +1972,7 @@ class HeadlessEnforcer:
1945
1972
  "Cierre solo con evidencia de revocación efectiva: llamada/API/HTTP 401 para la credencial antigua "
1946
1973
  "o comprobación oficial equivalente, más nueva ubicación segura registrada."
1947
1974
  ),
1948
- reasoning=reason,
1975
+ reasoning="R23g detected secret exfiltration to a third party",
1949
1976
  priority="critical",
1950
1977
  internal=1,
1951
1978
  owner="agent",
@@ -244,8 +244,8 @@ def _is_production_mutation_command(cmd: str) -> bool:
244
244
  r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
245
245
  r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
246
246
  r"\bg(?:sutil|cloud\s+storage)\b.*\b(?:cp|rsync)\b.*\b(?:release|stable|cdn|bucket|buckets)\b",
247
- r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot)\b",
248
- r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/)[^'\"]*['\"]",
247
+ r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot|home/nexodesk)\b",
248
+ r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/|/home/nexodesk)[^'\"]*['\"]",
249
249
  r"\b(?:whmapi1|uapi|cpapi2)\b",
250
250
  r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
251
251
  r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
package/src/hooks/stop.py CHANGED
@@ -18,14 +18,24 @@ from pathlib import Path
18
18
 
19
19
  _DIR = Path(__file__).resolve().parent
20
20
 
21
+ # Specific future-commitment phrases. Bare words like "pendiente" / "después"
22
+ # were removed: they appear constantly in ordinary conversation and, read over a
23
+ # GLOBAL rolling buffer, blocked closes spuriously. Each marker now expresses a
24
+ # real deferral, not an incidental adverb.
21
25
  FUTURE_COMMITMENT_MARKERS = (
22
26
  "lo dejo como seguimiento",
23
- "cuando quieras",
24
- "pendiente",
25
27
  "lo cojo aparte",
26
- "después",
27
- "despues",
28
28
  "bloqueado por auth",
29
+ "queda pendiente de",
30
+ "lo dejo pendiente",
31
+ "lo retomo más tarde",
32
+ "lo retomo mas tarde",
33
+ "lo vemos en otra sesión",
34
+ "lo vemos en otra sesion",
35
+ "te lo dejo para después",
36
+ "te lo dejo para despues",
37
+ "lo dejo para más tarde",
38
+ "lo dejo para mas tarde",
29
39
  )
30
40
  FOLLOWUP_CREATE_MARKERS = ("nexo_followup_create", "mcp__nexo__nexo_followup_create")
31
41
  PARTIAL_TASK_CLOSE_RE = re.compile(
@@ -78,6 +88,42 @@ def _read_recent_lines(path: Path, max_lines: int = 800) -> list[str]:
78
88
  return []
79
89
 
80
90
 
91
+ def _line_session_id(raw_line: str) -> str:
92
+ try:
93
+ payload = json.loads(raw_line)
94
+ except Exception:
95
+ return ""
96
+ if not isinstance(payload, dict):
97
+ return ""
98
+ for key in ("session_id", "sid", "claude_session_id", "sessionId"):
99
+ val = payload.get(key)
100
+ if isinstance(val, str) and val.strip():
101
+ return val.strip()
102
+ return ""
103
+
104
+
105
+ def _scope_to_session(lines: list[str], sid: str) -> list[str]:
106
+ """Keep only buffer lines belonging to ``sid``. ``session_buffer.jsonl`` is a
107
+ GLOBAL rolling log shared by every session/client, so without scoping the
108
+ closeout gate counts *other* sessions' commitments and blocks this close
109
+ spuriously. If the buffer carries no session ids at all we cannot scope and
110
+ fall back to every line (prior behaviour)."""
111
+ if not sid:
112
+ return lines
113
+ tagged = [(raw, _line_session_id(raw)) for raw in lines]
114
+ if not any(s for _, s in tagged):
115
+ return lines
116
+ return [raw for raw, s in tagged if s == sid]
117
+
118
+
119
+ def _current_session_id() -> str:
120
+ for key in ("CLAUDE_SESSION_ID", "NEXO_SID", "NEXO_SESSION_ID"):
121
+ val = os.environ.get(key, "").strip()
122
+ if val:
123
+ return val
124
+ return ""
125
+
126
+
81
127
  def _line_text(line: str) -> str:
82
128
  try:
83
129
  payload = json.loads(line)
@@ -133,8 +179,11 @@ def check_closeout_followups() -> dict:
133
179
  if chunk:
134
180
  lines.extend(chunk)
135
181
  sources.append(str(path))
182
+ sid = _current_session_id()
183
+ lines = _scope_to_session(lines, sid)
136
184
  result = scan_closeout_followup_gaps(lines)
137
185
  result["sources"] = sources
186
+ result["session_scoped"] = bool(sid)
138
187
  return result
139
188
 
140
189
 
@@ -4101,6 +4101,118 @@ def _search_text_score(query: str, text: str) -> float:
4101
4101
  return len(q & tokens) / max(len(q), 1)
4102
4102
 
4103
4103
 
4104
+ _CONTEXT_STRONG_DOCUMENT_TERMS = {
4105
+ "acuerdo",
4106
+ "agreement",
4107
+ "balance",
4108
+ "certificado",
4109
+ "comunicado",
4110
+ "contract",
4111
+ "contrato",
4112
+ "declaracion",
4113
+ "declaración",
4114
+ "factura",
4115
+ "invoice",
4116
+ "nomina",
4117
+ "nómina",
4118
+ "payroll",
4119
+ "presupuesto",
4120
+ "quote",
4121
+ "transferencia",
4122
+ }
4123
+
4124
+
4125
+ _CONTEXT_BOILERPLATE_TERMS = {
4126
+ "-ms-text-size-adjust",
4127
+ "-webkit-text-size-adjust",
4128
+ "body, table",
4129
+ "cancelar suscripcion",
4130
+ "cancelar suscripción",
4131
+ "css",
4132
+ "newsletter",
4133
+ "no puedes ver el correo",
4134
+ "politica de privacidad",
4135
+ "política de privacidad",
4136
+ "unsubscribe",
4137
+ "version web",
4138
+ "versión web",
4139
+ }
4140
+
4141
+
4142
+ _CONTEXT_MARKETING_TERMS = {
4143
+ "bajan los precios",
4144
+ "campaña",
4145
+ "descuento",
4146
+ "encuesta satisfaccion",
4147
+ "encuesta satisfacción",
4148
+ "hazte con tu regalo",
4149
+ "oferta",
4150
+ "promocion",
4151
+ "promoción",
4152
+ "prueba gratis",
4153
+ "satisfaccion clientes",
4154
+ "satisfacción clientes",
4155
+ "view online",
4156
+ }
4157
+
4158
+
4159
+ _CONTEXT_LOW_SIGNAL_EMAIL_TERMS = {
4160
+ "acceso bloqueado",
4161
+ "actividad inusual",
4162
+ "bloqueada temporalmente",
4163
+ "cuenta esta en revision",
4164
+ "cuenta está en revisión",
4165
+ }
4166
+
4167
+
4168
+ def _contains_any_text(text: str, terms: set[str]) -> bool:
4169
+ if not text:
4170
+ return False
4171
+ return any(term in text for term in terms)
4172
+
4173
+
4174
+ def _context_quality_adjusted_score(score: float, row: Any) -> float:
4175
+ """Apply deterministic tie-breaks for local search candidates.
4176
+
4177
+ The base scorer is intentionally simple, which can make boilerplate emails
4178
+ tie with PDFs/contracts when a query has a few common terms. Keep this
4179
+ adjustment small and explainable: strong document signals stay high, while
4180
+ newsletter/CSS/legal-footer noise loses the tie.
4181
+ """
4182
+ try:
4183
+ path = str(row["path"] or "")
4184
+ file_type = str(row["file_type"] or "").strip().lower()
4185
+ text = str(row["text"] or "")
4186
+ summary = str(row["summary"] or "")
4187
+ except Exception:
4188
+ return max(0.0, min(float(score), 1.6))
4189
+
4190
+ haystack = f"{path}\n{summary}\n{text}".lower()
4191
+ suffix = Path(path).suffix.lower()
4192
+ adjustment = 0.0
4193
+
4194
+ if suffix in HIGH_VALUE_DOCUMENT_SUFFIXES or file_type in {"document", "spreadsheet", "presentation"}:
4195
+ adjustment += 0.12
4196
+ has_strong_document_signal = _contains_any_text(haystack, _CONTEXT_STRONG_DOCUMENT_TERMS)
4197
+ if has_strong_document_signal:
4198
+ adjustment += 0.12
4199
+
4200
+ if file_type == "email" or suffix in EMAIL_DOCUMENT_SUFFIXES:
4201
+ if _contains_any_text(haystack, _CONTEXT_BOILERPLATE_TERMS):
4202
+ adjustment -= 0.22
4203
+ if _contains_any_text(haystack, _CONTEXT_MARKETING_TERMS):
4204
+ adjustment -= 0.18
4205
+ if _contains_any_text(haystack, _CONTEXT_LOW_SIGNAL_EMAIL_TERMS):
4206
+ adjustment -= 0.16
4207
+ if has_strong_document_signal:
4208
+ adjustment += 0.08
4209
+ else:
4210
+ adjustment -= 0.22
4211
+
4212
+ base = min(float(score), 1.6)
4213
+ return max(0.0, min(base + adjustment, 1.6))
4214
+
4215
+
4104
4216
  _QUERY_STOPWORDS = {
4105
4217
  "about",
4106
4218
  "archivos",
@@ -4739,7 +4851,7 @@ def _context_query_conn(
4739
4851
  # unrelated chunks from a long document outrank direct evidence.
4740
4852
  score = max(score, min(0.48, 0.28 + entity_score * 0.2))
4741
4853
  if score > 0:
4742
- scored.append((min(float(score), 1.6), row))
4854
+ scored.append((_context_quality_adjusted_score(float(score), row), row))
4743
4855
  scored.sort(key=lambda item: item[0], reverse=True)
4744
4856
  scored = _rerank_scored_candidates(search_query, scored, limit=int(limit))
4745
4857
  assets = []
@@ -1289,6 +1289,59 @@ SPECIFIC_OK_AFTER_EVIDENCE_RE = re.compile(
1289
1289
  r".{0,120}\b(evidencia|evidence|smoke|verificad[oa]|verified)\b",
1290
1290
  re.IGNORECASE | re.DOTALL,
1291
1291
  )
1292
+ PUBLIC_RELEASE_EVIDENCE_RE = re.compile(
1293
+ r"("
1294
+ r"screenshot|captura|"
1295
+ r"url\s+p[uú]blica|public\s+url|url-public|"
1296
+ r"https?://[^\s]+|"
1297
+ r"\bHTTP/?\d?(?:\.\d)?\s+200\b|" # HTTP 200, HTTP/1.1 200, HTTP/2 200
1298
+ r"\b200\s+OK\b|" # 200 OK
1299
+ r"\bhttp[_\s-]?code\b[^\n]{0,40}\b200\b|" # http_code: 200 (curl -w '%{http_code}')
1300
+ r"\bstatus(?:[_\s-]?code)?\b[^\n]{0,20}\b200\b|" # status 200 / status_code 200
1301
+ r"\bc[oó]digo\b[^\n]{0,20}\b200\b" # código 200 / código de estado 200
1302
+ r")",
1303
+ re.IGNORECASE,
1304
+ )
1305
+ PUBLIC_RELEASE_WORK_RE = re.compile(
1306
+ r"\b(release|deploy|deployment|publish|publicar|desplegar|lanzar|stable|producci[oó]n|production)\b",
1307
+ re.IGNORECASE,
1308
+ )
1309
+ HIGH_STAKES_WORK_TYPES = {"release", "deploy", "deployment", "publish", "publicar", "desplegar"}
1310
+
1311
+
1312
+ def _normalize_artifact_hash(value: str) -> str:
1313
+ clean = (value or "").strip().lower()
1314
+ clean = re.sub(r"^(sha256|sha1|md5):", "", clean).strip()
1315
+ return clean
1316
+
1317
+
1318
+ def _requires_irreversible_artifact_hash(closure_text: str, artifact_hash: str, validated_hash: str) -> tuple[bool, str]:
1319
+ if not IRREVERSIBLE_ACTION_RE.search(closure_text):
1320
+ return False, ""
1321
+ current = _normalize_artifact_hash(artifact_hash)
1322
+ validated = _normalize_artifact_hash(validated_hash)
1323
+ if not current or not validated:
1324
+ return True, "missing"
1325
+ if current != validated:
1326
+ return True, "mismatch"
1327
+ return False, ""
1328
+
1329
+
1330
+ def _is_high_stakes_public_work(task: dict, work_type: str, stakes: str, closure_text: str) -> bool:
1331
+ high_override = _parse_high_stakes_override(stakes or "")
1332
+ high_stakes = bool(task.get("response_high_stakes")) if high_override is None else high_override
1333
+ if not high_stakes:
1334
+ return False
1335
+ clean_work_type = (work_type or "").strip().lower()
1336
+ if clean_work_type in HIGH_STAKES_WORK_TYPES:
1337
+ return True
1338
+ return bool(PUBLIC_RELEASE_WORK_RE.search(closure_text or ""))
1339
+
1340
+
1341
+ def _has_public_release_evidence(evidence: str) -> bool:
1342
+ if _is_trivial_evidence(evidence)[0]:
1343
+ return False
1344
+ return bool(PUBLIC_RELEASE_EVIDENCE_RE.search(evidence or ""))
1292
1345
 
1293
1346
 
1294
1347
  def _active_followup_snapshot(limit: int = 5) -> list[dict]:
@@ -1729,6 +1782,10 @@ def handle_task_close(
1729
1782
  summary: str = "",
1730
1783
  verification: str = "",
1731
1784
  evidence_refs: str = "",
1785
+ work_type: str = "",
1786
+ stakes: str = "",
1787
+ artifact_hash: str = "",
1788
+ last_human_validation_of_artifact_hash: str = "",
1732
1789
  ) -> str:
1733
1790
  """Close a protocol task and automatically record the required discipline artifacts."""
1734
1791
  task = get_protocol_task(task_id.strip())
@@ -1865,6 +1922,41 @@ def handle_task_close(
1865
1922
  ensure_ascii=False,
1866
1923
  indent=2,
1867
1924
  )
1925
+ hash_blocked, hash_reason = _requires_irreversible_artifact_hash(
1926
+ closure_text,
1927
+ artifact_hash,
1928
+ last_human_validation_of_artifact_hash,
1929
+ )
1930
+ if hash_blocked:
1931
+ debt = _ensure_open_debt(
1932
+ task["session_id"],
1933
+ task_id,
1934
+ "irreversible_artifact_hash_unverified",
1935
+ severity="error",
1936
+ evidence=(
1937
+ "Irreversible action close lacks matching human-validated artifact hash. "
1938
+ f"reason={hash_reason} artifact_hash={_normalize_artifact_hash(artifact_hash)[:16]} "
1939
+ f"validated_hash={_normalize_artifact_hash(last_human_validation_of_artifact_hash)[:16]}"
1940
+ ),
1941
+ debts=debts_created,
1942
+ )
1943
+ return json.dumps(
1944
+ {
1945
+ "ok": False,
1946
+ "error": "Cannot close irreversible publish/broadcast/payment action without a matching human-validated artifact hash.",
1947
+ "hint": (
1948
+ "Run the release decision in verify mode and retry with "
1949
+ "`artifact_hash` plus `last_human_validation_of_artifact_hash`; both values must match."
1950
+ ),
1951
+ "task_id": task_id,
1952
+ "blocked_by": "irreversible_artifact_hash",
1953
+ "debt_id": debt.get("id"),
1954
+ "debt_type": "irreversible_artifact_hash_unverified",
1955
+ "hash_status": hash_reason,
1956
+ },
1957
+ ensure_ascii=False,
1958
+ indent=2,
1959
+ )
1868
1960
 
1869
1961
  if (task.get("task_type") or "").strip() == "analyze" and clean_outcome == "done":
1870
1962
  artifact_paths = _existing_analyze_artifact_paths(all_evidence_refs)
@@ -2353,6 +2445,37 @@ def handle_task_close(
2353
2445
  indent=2,
2354
2446
  )
2355
2447
 
2448
+ if (
2449
+ clean_outcome == "done"
2450
+ and _is_high_stakes_public_work(task, work_type, stakes, closure_text)
2451
+ and not _has_public_release_evidence(live_surface_evidence)
2452
+ ):
2453
+ debt = _ensure_open_debt(
2454
+ task["session_id"],
2455
+ task_id,
2456
+ "high_stakes_public_evidence_missing",
2457
+ severity="error",
2458
+ evidence=(
2459
+ "High-stakes release/deploy/publish close lacked screenshot, public URL, or curl HTTP 200 evidence. "
2460
+ f"Evidence provided: {live_surface_evidence[:240]!r}"
2461
+ ),
2462
+ debts=debts_created,
2463
+ )
2464
+ return json.dumps(
2465
+ {
2466
+ "ok": False,
2467
+ "error": "Cannot close high-stakes release/deploy/publish as done without public verification evidence.",
2468
+ "hint": "Attach screenshot path, public URL evidence, or curl output showing HTTP 200, then retry.",
2469
+ "task_id": task_id,
2470
+ "blocked_by": "high_stakes_public_evidence",
2471
+ "debt_id": debt.get("id"),
2472
+ "debt_type": "high_stakes_public_evidence_missing",
2473
+ "response_mode": "verify",
2474
+ },
2475
+ ensure_ascii=False,
2476
+ indent=2,
2477
+ )
2478
+
2356
2479
  if task.get("guard_has_blocking") and not files_changed_list:
2357
2480
  open_task_debts = list_protocol_debts(status="open", task_id=task_id, limit=200)
2358
2481
  has_guard_touch_violation = any(
@@ -61,7 +61,7 @@
61
61
  "R23m_message_duplicate": "soft",
62
62
  "R23h_shebang_mismatch": "shadow",
63
63
  "R34_identity_coherence": "hard",
64
- "R37_pre_answer_evidence_gate": "hard"
64
+ "R37_pre_answer_evidence_gate": "shadow"
65
65
  },
66
66
  "core_rules": [
67
67
  "R13_pre_edit_guard",
@@ -70,3 +70,40 @@ def should_inject_r23g(tool_name: str, tool_input) -> tuple[bool, str]:
70
70
  reason=reason,
71
71
  )
72
72
  return True, prompt
73
+
74
+
75
+ # A secret READ on its own (cat .env, env, printenv) is benign: it stays on
76
+ # the operator's machine and exposes nothing to a third party. A rotate/revoke
77
+ # followup is only warranted when the same command also EXFILTRATES the output
78
+ # to a third party — piped into a network/email/cloud/repo sink. Distinguishing
79
+ # the two is what keeps R23g from minting un-closeable "rotate credential"
80
+ # critical followups on every local read.
81
+ _EXTERNAL_SINK_RE = re.compile(
82
+ r"(?:"
83
+ # piped into a transmitting client (network/email)
84
+ r"\|\s*(?:curl|wget|https?\b|nc|ncat|netcat|socat|mail|mailx|sendmail|mutt|msmtp|ssmtp|telnet)\b"
85
+ # curl/wget that upload a body
86
+ r"|\bcurl\b[^\n;|&]*(?:--data(?:-binary|-urlencode|-raw)?|--form|--upload-file|-d\b|-F\b|-T\b|-X\s*(?:POST|PUT|PATCH))"
87
+ r"|\bwget\b[^\n;|&]*(?:--post-data|--post-file|--body-data|--body-file)"
88
+ # direct mail senders
89
+ r"|\b(?:mail|mailx|sendmail|mutt|msmtp|ssmtp)\b"
90
+ # remote transfer / cloud upload
91
+ r"|\bscp\b[^\n;|&]*:"
92
+ r"|\brsync\b[^\n;|&]*[\w.-]+@[\w.-]+:"
93
+ r"|\baws\s+s3\b[^\n;|&]*\bcp\b|\bgsutil\b[^\n;|&]*\bcp\b|\bgcloud\s+storage\b[^\n;|&]*\bcp\b"
94
+ r"|\bgh\s+(?:gist\s+create|release\s+upload)\b"
95
+ # secret committed/pushed into a repository
96
+ r"|\bgit\s+(?:commit|push)\b"
97
+ # NEXO messaging / outbound channels
98
+ r"|\bnexo_(?:send|email_send)\b"
99
+ r")",
100
+ re.IGNORECASE,
101
+ )
102
+
103
+
104
+ def has_external_sink(cmd: str) -> bool:
105
+ """True when `cmd` pipes/sends its output to a third party (network, email,
106
+ cloud, remote host, repository). A bare local read returns False."""
107
+ if not isinstance(cmd, str) or not cmd.strip():
108
+ return False
109
+ return bool(_EXTERNAL_SINK_RE.search(cmd))
@@ -511,6 +511,48 @@ def _upsert_inline_learning(
511
511
  return {"ok": True, "action": "created", "learning_id": int(learning_id)}
512
512
 
513
513
 
514
+ def _find_retired_learning_by_title(conn: sqlite3.Connection, title: str) -> sqlite3.Row | None:
515
+ if not _table_exists(conn, "learnings"):
516
+ return None
517
+ columns = _table_columns(conn, "learnings")
518
+ if "title" not in columns:
519
+ return None
520
+ status_expr = "COALESCE(status, 'active')"
521
+ return conn.execute(
522
+ f"""SELECT *
523
+ FROM learnings
524
+ WHERE title = ?
525
+ AND lower({status_expr}) IN ('archived', 'deleted', 'superseded')
526
+ ORDER BY updated_at DESC, id DESC
527
+ LIMIT 1""",
528
+ (title,),
529
+ ).fetchone()
530
+
531
+
532
+ def _link_protocol_tasks_to_learning(
533
+ conn: sqlite3.Connection,
534
+ tasks: list[sqlite3.Row],
535
+ learning_id: int,
536
+ ) -> int:
537
+ if learning_id <= 0 or not tasks or not _table_exists(conn, "protocol_tasks"):
538
+ return 0
539
+ columns = _table_columns(conn, "protocol_tasks")
540
+ if "learning_id" not in columns or "task_id" not in columns:
541
+ return 0
542
+ task_ids = [str(row["task_id"]) for row in tasks if str(row["task_id"] or "").strip()]
543
+ if not task_ids:
544
+ return 0
545
+ placeholders = ",".join("?" for _ in task_ids)
546
+ cur = conn.execute(
547
+ f"""UPDATE protocol_tasks
548
+ SET learning_id = ?
549
+ WHERE task_id IN ({placeholders})
550
+ AND (learning_id IS NULL OR learning_id = 0)""",
551
+ [learning_id] + task_ids,
552
+ )
553
+ return int(cur.rowcount or 0)
554
+
555
+
514
556
  def _supersede_learning_inline(conn: sqlite3.Connection, *, keep_id: int, retire_id: int, note: str) -> bool:
515
557
  if not _table_exists(conn, "learnings"):
516
558
  return False
@@ -1336,6 +1378,13 @@ def check_error_memory_loop():
1336
1378
  prevention = (
1337
1379
  f"Before working around {signature}, review this cluster and capture the prevention rule in the task contract."
1338
1380
  )
1381
+ retired_learning = _find_retired_learning_by_title(conn, title)
1382
+ if retired_learning:
1383
+ linked = _link_protocol_tasks_to_learning(conn, items, int(retired_learning["id"]))
1384
+ if linked:
1385
+ resolved += 1
1386
+ continue
1387
+
1339
1388
  result = _upsert_inline_learning(
1340
1389
  conn,
1341
1390
  category=area,
@@ -1348,6 +1397,7 @@ def check_error_memory_loop():
1348
1397
  )
1349
1398
  if result.get("ok"):
1350
1399
  resolved += 1
1400
+ _link_protocol_tasks_to_learning(conn, items, int(result.get("learning_id") or 0))
1351
1401
  if applies_to:
1352
1402
  _queue_public_core_handoff(
1353
1403
  conn,
package/src/server.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import os
5
5
  import signal
6
6
  import sys
7
+ import threading
7
8
  import json
8
9
  import time
9
10
  from pathlib import Path
@@ -155,9 +156,25 @@ from local_context.db import close_local_context_db
155
156
 
156
157
  # ── Graceful shutdown: close DB on any termination signal ──────────
157
158
  def _shutdown_handler(signum, frame):
158
- close_local_context_db()
159
- close_db()
160
- sys.exit(0)
159
+ # Guarantee the process actually dies on SIGTERM/SIGINT. A prior failure mode
160
+ # left orphaned servers ignoring SIGTERM for *days*: cleanup (close_db) blocked
161
+ # on a never-released lock and sys.exit() never ran while the stdio loop held
162
+ # the main thread. Arm a hard-exit watchdog FIRST, then best-effort close, then
163
+ # os._exit. WAL + autocommit means there is no open transaction to lose and the
164
+ # durable write queue replays unprocessed items on next start, so a hard exit is
165
+ # safe even if cleanup is skipped.
166
+ try:
167
+ watchdog = threading.Timer(3.0, lambda: os._exit(0))
168
+ watchdog.daemon = True
169
+ watchdog.start()
170
+ except Exception:
171
+ pass
172
+ try:
173
+ close_local_context_db()
174
+ close_db()
175
+ except Exception:
176
+ pass
177
+ os._exit(0)
161
178
 
162
179
 
163
180
  def _resolved_nexo_home() -> str:
@@ -360,6 +377,75 @@ def _load_startup_plugins() -> None:
360
377
  print(f"[NEXO] MCP essential plugins ready: {loaded} tools.", file=sys.stderr)
361
378
 
362
379
 
380
+ def _select_reapable_servers(processes, *, self_pid, resident_pid):
381
+ """Pure selection: given ps rows [{pid, ppid, cmdline}], return the PIDs of
382
+ NEXO MCP servers that are safe to reap.
383
+
384
+ A server is reapable iff it is ORPHANED (ppid == 1 — its launching client is
385
+ gone, so it serves nobody) and runs a NEXO ``server.py``. We never touch a
386
+ server that still has a live parent (ppid != 1 → a connected client), nor the
387
+ warm runtime-service resident (``resident_pid``), nor ourselves (``self_pid``).
388
+ """
389
+ reapable: list[int] = []
390
+ for proc in processes:
391
+ try:
392
+ pid = int(proc.get("pid"))
393
+ ppid = int(proc.get("ppid"))
394
+ except (TypeError, ValueError):
395
+ continue
396
+ if pid in (self_pid, resident_pid):
397
+ continue
398
+ if ppid != 1:
399
+ continue # has a live parent/client — leave it alone
400
+ cmd = str(proc.get("cmdline") or "")
401
+ if "server.py" not in cmd or "nexo" not in cmd.lower():
402
+ continue
403
+ reapable.append(pid)
404
+ return reapable
405
+
406
+
407
+ def reap_orphaned_mcp_servers() -> int:
408
+ """Best-effort cleanup of orphaned NEXO MCP servers left behind by dead
409
+ clients. Runs once at stdio startup so the process count can't grow without
410
+ bound (the historical cause of SQLite contention + wedged servers). Never
411
+ raises; returns how many SIGTERMs were signalled."""
412
+ try:
413
+ import subprocess
414
+
415
+ self_pid = os.getpid()
416
+ resident_pid = -1
417
+ try:
418
+ from runtime_service import read_service_state
419
+
420
+ resident_pid = int((read_service_state() or {}).get("pid") or -1)
421
+ except Exception:
422
+ resident_pid = -1
423
+ proc = subprocess.run(
424
+ ["ps", "-eo", "pid=,ppid=,command="],
425
+ capture_output=True,
426
+ text=True,
427
+ timeout=5,
428
+ )
429
+ rows = []
430
+ for line in proc.stdout.splitlines():
431
+ parts = line.split(None, 2)
432
+ if len(parts) < 3:
433
+ continue
434
+ rows.append({"pid": parts[0], "ppid": parts[1], "cmdline": parts[2]})
435
+ reaped = 0
436
+ for pid in _select_reapable_servers(rows, self_pid=self_pid, resident_pid=resident_pid):
437
+ try:
438
+ os.kill(pid, signal.SIGTERM)
439
+ reaped += 1
440
+ except Exception:
441
+ pass
442
+ if reaped:
443
+ print(f"[NEXO] Reaped {reaped} orphaned MCP server(s).", file=sys.stderr)
444
+ return reaped
445
+ except Exception:
446
+ return 0
447
+
448
+
363
449
  def _server_init():
364
450
  """Run side effects needed by the MCP server.
365
451
 
@@ -376,6 +462,13 @@ def _server_init():
376
462
  with open(_pid_file, "w") as f:
377
463
  f.write(str(os.getpid()))
378
464
 
465
+ # ── Reap orphaned servers from dead clients (stdio only) ───────
466
+ # Defense against unbounded process growth: a client that crashed/reconnected
467
+ # leaves its old server orphaned (reparented to init, ppid==1). Those serve
468
+ # nobody yet still open the shared SQLite. Clean them on each fresh start.
469
+ if not _env_flag("NEXO_MCP_PROBE") and not is_runtime_service_process():
470
+ reap_orphaned_mcp_servers()
471
+
379
472
  # ── Database initialization with recovery ─────────────────────
380
473
  _init_db_or_exit()
381
474
 
@@ -773,8 +866,18 @@ def nexo_task_close(
773
866
  summary: str = "",
774
867
  verification: str = "",
775
868
  evidence_refs: str = "",
869
+ work_type: str = "",
870
+ stakes: str = "",
871
+ artifact_hash: str = "",
872
+ last_human_validation_of_artifact_hash: str = "",
776
873
  ) -> str:
777
- """Close a protocol task with evidence and optional artifacts."""
874
+ """Close a protocol task with evidence and optional artifacts.
875
+
876
+ For high-stakes/irreversible closures (publish stable, broadcast, payment,
877
+ force-push, revoke) pass ``work_type``/``stakes`` plus ``artifact_hash`` and
878
+ ``last_human_validation_of_artifact_hash`` (both must match) so the close
879
+ gates can be satisfied instead of dead-ending.
880
+ """
778
881
  return handle_task_close(
779
882
  sid,
780
883
  task_id,
@@ -802,6 +905,10 @@ def nexo_task_close(
802
905
  summary,
803
906
  verification,
804
907
  evidence_refs,
908
+ work_type,
909
+ stakes,
910
+ artifact_hash,
911
+ last_human_validation_of_artifact_hash,
805
912
  )
806
913
 
807
914