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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/enforcement_engine.py +39 -12
- package/src/hooks/post_tool_use.py +2 -2
- package/src/hooks/stop.py +53 -4
- package/src/local_context/api.py +113 -1
- package/src/plugins/protocol.py +123 -0
- package/src/presets/guardian_default.json +1 -1
- package/src/r23g_secrets_in_output.py +37 -0
- package/src/scripts/nexo-daily-self-audit.py +50 -0
- package/src/server.py +111 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.31.
|
|
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.
|
|
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.
|
|
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
|
|
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"
|
|
332
|
-
r"
|
|
333
|
-
r"
|
|
334
|
-
r"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
|
package/src/local_context/api.py
CHANGED
|
@@ -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((
|
|
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 = []
|
package/src/plugins/protocol.py
CHANGED
|
@@ -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(
|
|
@@ -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
|
-
|
|
159
|
-
close_db
|
|
160
|
-
sys.exit(
|
|
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
|
|