nexo-brain 7.31.4 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/auto_update.py +50 -22
- package/src/cli.py +2 -0
- package/src/enforcement_engine.py +247 -0
- package/src/evidence_ledger.py +31 -0
- package/src/guardian_config.py +3 -1
- package/src/hooks/post_tool_use.py +228 -2
- package/src/hooks/stop.py +112 -0
- package/src/local_context/api.py +23 -18
- package/src/local_context/health.py +9 -4
- package/src/local_context/usage_events.py +85 -0
- package/src/mcp_write_queue.py +21 -1
- package/src/plugins/protocol.py +272 -1
- package/src/plugins/workflow.py +99 -2
- package/src/pre_answer_router.py +114 -3
- package/src/pre_answer_runtime.py +3 -0
- package/src/presets/guardian_default.json +7 -4
- package/src/provider_circuit_breaker.py +18 -0
- package/src/rules/core-rules.json +11 -3
- package/src/scripts/deep-sleep/collect.py +40 -0
- package/src/scripts/jargon_first_response.py +12 -9
- package/src/scripts/nexo-email-monitor.py +235 -56
- package/templates/CLAUDE.md.template +1 -0
- package/templates/CODEX.AGENTS.md.template +1 -0
- package/templates/core-prompts/r26-jargon-rewrite.md +1 -0
- package/templates/core-prompts/r34-capability-reality-check.md +1 -0
- package/templates/core-prompts/r35-execute-before-ask.md +1 -0
- package/templates/core-prompts/r36-production-change-log-required.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +2 -1
- package/tool-enforcement-map.json +4 -2
package/src/plugins/protocol.py
CHANGED
|
@@ -619,6 +619,103 @@ def _is_release_task(*, goal: str, area: str = "", project_hint: str = "", verif
|
|
|
619
619
|
return any(token in combined_context for token in _RELEASE_CONTEXT_HINTS)
|
|
620
620
|
|
|
621
621
|
|
|
622
|
+
def _requires_live_surface_verification(task: dict, outcome: str) -> bool:
|
|
623
|
+
if outcome != "done":
|
|
624
|
+
return False
|
|
625
|
+
clean_type = str(task.get("task_type") or "").strip()
|
|
626
|
+
if clean_type not in {"edit", "execute"}:
|
|
627
|
+
return False
|
|
628
|
+
combined = " ".join(
|
|
629
|
+
str(task.get(field) or "")
|
|
630
|
+
for field in ("goal", "area", "project_hint", "context_hint", "verification_step")
|
|
631
|
+
).lower()
|
|
632
|
+
if not combined:
|
|
633
|
+
return False
|
|
634
|
+
subject_hit = re.search(
|
|
635
|
+
r"\b(bug|estado|state|storefront|backend|front[- ]?end|datos|data|bd|database|producci[oó]n|production|live)\b",
|
|
636
|
+
combined,
|
|
637
|
+
)
|
|
638
|
+
behavior_hit = re.search(
|
|
639
|
+
r"\b(arregl|fix|resolver|resuelto|declara|afirm|verificar|validar|confirmar|close|cerrar)\b",
|
|
640
|
+
combined,
|
|
641
|
+
)
|
|
642
|
+
return bool(subject_hit and behavior_hit)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _has_live_surface_verification(evidence: str) -> bool:
|
|
646
|
+
text = (evidence or "").lower()
|
|
647
|
+
if not text:
|
|
648
|
+
return False
|
|
649
|
+
live_markers = (
|
|
650
|
+
"black-box.ndjson",
|
|
651
|
+
"impossible_state_recovered",
|
|
652
|
+
"playwright",
|
|
653
|
+
"browser",
|
|
654
|
+
"navegador",
|
|
655
|
+
"screenshot",
|
|
656
|
+
"captura",
|
|
657
|
+
"tema publicado",
|
|
658
|
+
"published theme",
|
|
659
|
+
"theme_id",
|
|
660
|
+
"cloud sql",
|
|
661
|
+
"production db",
|
|
662
|
+
"bd producción",
|
|
663
|
+
"bd de producción",
|
|
664
|
+
"mysql -h",
|
|
665
|
+
"psql ",
|
|
666
|
+
"gcloud logs",
|
|
667
|
+
"live logs",
|
|
668
|
+
"logs vivos",
|
|
669
|
+
"http 200",
|
|
670
|
+
"curl ",
|
|
671
|
+
"url pública",
|
|
672
|
+
"public url",
|
|
673
|
+
)
|
|
674
|
+
if any(marker in text for marker in live_markers):
|
|
675
|
+
return True
|
|
676
|
+
return bool(re.search(r"\b(producci[oó]n|production|live)\b.*\b(logs?|bd|db|browser|navegador|playwright|url|http|tema)\b", text))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _requires_production_change_log(
|
|
680
|
+
task: dict,
|
|
681
|
+
outcome: str,
|
|
682
|
+
evidence: str,
|
|
683
|
+
change_summary: str,
|
|
684
|
+
triggered_by: str,
|
|
685
|
+
) -> bool:
|
|
686
|
+
if outcome not in {"done", "partial", "failed"}:
|
|
687
|
+
return False
|
|
688
|
+
clean_type = str(task.get("task_type") or "").strip()
|
|
689
|
+
if clean_type not in {"edit", "execute"}:
|
|
690
|
+
return False
|
|
691
|
+
task_context = " ".join(
|
|
692
|
+
str(part or "")
|
|
693
|
+
for part in (
|
|
694
|
+
task.get("goal"),
|
|
695
|
+
task.get("area"),
|
|
696
|
+
task.get("project_hint"),
|
|
697
|
+
task.get("context_hint"),
|
|
698
|
+
task.get("verification_step"),
|
|
699
|
+
)
|
|
700
|
+
).lower()
|
|
701
|
+
action_context = " ".join(
|
|
702
|
+
str(part or "")
|
|
703
|
+
for part in (
|
|
704
|
+
evidence,
|
|
705
|
+
change_summary,
|
|
706
|
+
triggered_by,
|
|
707
|
+
)
|
|
708
|
+
).lower()
|
|
709
|
+
combined = f"{task_context}\n{action_context}"
|
|
710
|
+
return bool(
|
|
711
|
+
re.search(
|
|
712
|
+
r"\b(git push|rsync|scp|ssh|npm publish|upload-release\.sh|gcloud builds)\b",
|
|
713
|
+
combined,
|
|
714
|
+
)
|
|
715
|
+
or re.search(r"\b(deploy|desplieg|publish|publicaci[oó]n)\b", action_context)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
622
719
|
def evaluate_response_confidence(
|
|
623
720
|
*,
|
|
624
721
|
goal: str,
|
|
@@ -1135,6 +1232,51 @@ def _record_debt(session_id: str, task_id: str, debt_type: str, *, severity: str
|
|
|
1135
1232
|
_append_debt_ref(debts, debt, debt_type=debt_type, severity=severity)
|
|
1136
1233
|
|
|
1137
1234
|
|
|
1235
|
+
TOTAL_CLOSURE_RE = re.compile(
|
|
1236
|
+
r"\b("
|
|
1237
|
+
r"sin\s+deuda|no\s+queda\s+deuda|deuda\s+cero|todo\s+cerrado|"
|
|
1238
|
+
r"goal\s+cumplido|objetivo\s+cumplido|completamente\s+cerrad[oa]|"
|
|
1239
|
+
r"no\s+queda\s+nada\s+pendiente|all\s+set|all\s+done|no\s+open\s+debt"
|
|
1240
|
+
r")\b",
|
|
1241
|
+
re.IGNORECASE,
|
|
1242
|
+
)
|
|
1243
|
+
PENDING_RELEASE_GATE_RE = re.compile(
|
|
1244
|
+
r"\b(smoke|tag|tags|merge|release|stable|broadcast|publicaci[oó]n)\b.{0,80}\b(pendiente|pending|falta|sin\s+verificar|sin\s+evidencia)\b",
|
|
1245
|
+
re.IGNORECASE | re.DOTALL,
|
|
1246
|
+
)
|
|
1247
|
+
IRREVERSIBLE_ACTION_RE = re.compile(
|
|
1248
|
+
r"\b(publish\s+stable|publicar\s+stable|promocionar\s+stable|broadcast|enviar\s+a\s+clientes|cobrar|payment|force-push|revocar)\b",
|
|
1249
|
+
re.IGNORECASE,
|
|
1250
|
+
)
|
|
1251
|
+
SPECIFIC_OK_AFTER_EVIDENCE_RE = re.compile(
|
|
1252
|
+
r"\b(aprobaci[oó]n\s+expl[ií]cita|ok\s+espec[ií]fico|autorizaci[oó]n\s+espec[ií]fica|specific\s+ok|explicit\s+approval)\b"
|
|
1253
|
+
r".{0,120}\b(evidencia|evidence|smoke|verificad[oa]|verified)\b",
|
|
1254
|
+
re.IGNORECASE | re.DOTALL,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _active_followup_snapshot(limit: int = 5) -> list[dict]:
|
|
1259
|
+
try:
|
|
1260
|
+
followups = get_followups("active")
|
|
1261
|
+
except Exception:
|
|
1262
|
+
return []
|
|
1263
|
+
snapshot: list[dict] = []
|
|
1264
|
+
for item in followups[: max(1, limit)]:
|
|
1265
|
+
snapshot.append(
|
|
1266
|
+
{
|
|
1267
|
+
"id": item.get("id", ""),
|
|
1268
|
+
"status": item.get("status", ""),
|
|
1269
|
+
"date": item.get("date", ""),
|
|
1270
|
+
"description": str(item.get("description", ""))[:180],
|
|
1271
|
+
}
|
|
1272
|
+
)
|
|
1273
|
+
return snapshot
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _closure_claim_text(*parts: object) -> str:
|
|
1277
|
+
return "\n".join(str(part or "") for part in parts if str(part or "").strip())
|
|
1278
|
+
|
|
1279
|
+
|
|
1138
1280
|
def handle_confidence_check(
|
|
1139
1281
|
goal: str,
|
|
1140
1282
|
task_type: str = "answer",
|
|
@@ -1600,6 +1742,93 @@ def handle_task_close(
|
|
|
1600
1742
|
task_type=task.get("task_type", ""),
|
|
1601
1743
|
high_stakes=bool(task.get("response_high_stakes")),
|
|
1602
1744
|
)
|
|
1745
|
+
closure_text = _closure_claim_text(
|
|
1746
|
+
task.get("goal", ""),
|
|
1747
|
+
task.get("context_hint", ""),
|
|
1748
|
+
clean_evidence,
|
|
1749
|
+
clean_change_summary,
|
|
1750
|
+
clean_change_verify,
|
|
1751
|
+
outcome_notes,
|
|
1752
|
+
result,
|
|
1753
|
+
summary,
|
|
1754
|
+
verification,
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
if clean_outcome == "done":
|
|
1758
|
+
open_task_debts = list_protocol_debts(status="open", task_id=task_id, limit=5)
|
|
1759
|
+
active_followups = _active_followup_snapshot()
|
|
1760
|
+
if TOTAL_CLOSURE_RE.search(closure_text) and (active_followups or open_task_debts):
|
|
1761
|
+
debt = _ensure_open_debt(
|
|
1762
|
+
task["session_id"],
|
|
1763
|
+
task_id,
|
|
1764
|
+
"total_closure_with_open_work",
|
|
1765
|
+
severity="error",
|
|
1766
|
+
evidence=(
|
|
1767
|
+
"Task close used total-closure language while open work still exists. "
|
|
1768
|
+
f"open_followups={len(active_followups)} open_task_debts={len(open_task_debts)} "
|
|
1769
|
+
f"claim={closure_text[:240]!r}"
|
|
1770
|
+
),
|
|
1771
|
+
debts=debts_created,
|
|
1772
|
+
)
|
|
1773
|
+
return json.dumps(
|
|
1774
|
+
{
|
|
1775
|
+
"ok": False,
|
|
1776
|
+
"error": "Cannot close as total/no-debt while followups or task debt are still open.",
|
|
1777
|
+
"hint": "Say what was executed in this task and distinguish remaining open followups/debt, then retry.",
|
|
1778
|
+
"task_id": task_id,
|
|
1779
|
+
"blocked_by": "total_closure_open_work_gate",
|
|
1780
|
+
"debt_id": debt.get("id"),
|
|
1781
|
+
"debt_type": "total_closure_with_open_work",
|
|
1782
|
+
"open_followups": active_followups,
|
|
1783
|
+
"open_task_debts": open_task_debts,
|
|
1784
|
+
},
|
|
1785
|
+
ensure_ascii=False,
|
|
1786
|
+
indent=2,
|
|
1787
|
+
)
|
|
1788
|
+
if PENDING_RELEASE_GATE_RE.search(closure_text):
|
|
1789
|
+
debt = _ensure_open_debt(
|
|
1790
|
+
task["session_id"],
|
|
1791
|
+
task_id,
|
|
1792
|
+
"release_gate_pending_at_close",
|
|
1793
|
+
severity="error",
|
|
1794
|
+
evidence=f"Task close attempted done while smoke/tags/merge/release pending language was present: {closure_text[:240]!r}",
|
|
1795
|
+
debts=debts_created,
|
|
1796
|
+
)
|
|
1797
|
+
return json.dumps(
|
|
1798
|
+
{
|
|
1799
|
+
"ok": False,
|
|
1800
|
+
"error": "Cannot close as done while release/smoke/tag/merge work is described as pending.",
|
|
1801
|
+
"hint": "Close as partial or provide evidence that the named pending gate is now verified.",
|
|
1802
|
+
"task_id": task_id,
|
|
1803
|
+
"blocked_by": "release_gate_pending_at_close",
|
|
1804
|
+
"debt_id": debt.get("id"),
|
|
1805
|
+
"debt_type": "release_gate_pending_at_close",
|
|
1806
|
+
},
|
|
1807
|
+
ensure_ascii=False,
|
|
1808
|
+
indent=2,
|
|
1809
|
+
)
|
|
1810
|
+
if IRREVERSIBLE_ACTION_RE.search(closure_text) and not SPECIFIC_OK_AFTER_EVIDENCE_RE.search(closure_text):
|
|
1811
|
+
debt = _ensure_open_debt(
|
|
1812
|
+
task["session_id"],
|
|
1813
|
+
task_id,
|
|
1814
|
+
"irreversible_action_missing_specific_ok",
|
|
1815
|
+
severity="error",
|
|
1816
|
+
evidence=f"Irreversible action close lacks specific post-evidence approval language: {closure_text[:240]!r}",
|
|
1817
|
+
debts=debts_created,
|
|
1818
|
+
)
|
|
1819
|
+
return json.dumps(
|
|
1820
|
+
{
|
|
1821
|
+
"ok": False,
|
|
1822
|
+
"error": "Cannot close irreversible publish/broadcast/payment action without a specific approval after evidence.",
|
|
1823
|
+
"hint": "Record the explicit approval tied to the verified evidence, not a prior generic OK.",
|
|
1824
|
+
"task_id": task_id,
|
|
1825
|
+
"blocked_by": "irreversible_specific_ok_gate",
|
|
1826
|
+
"debt_id": debt.get("id"),
|
|
1827
|
+
"debt_type": "irreversible_action_missing_specific_ok",
|
|
1828
|
+
},
|
|
1829
|
+
ensure_ascii=False,
|
|
1830
|
+
indent=2,
|
|
1831
|
+
)
|
|
1603
1832
|
|
|
1604
1833
|
if (task.get("task_type") or "").strip() == "analyze" and clean_outcome == "done":
|
|
1605
1834
|
artifact_paths = _existing_analyze_artifact_paths(all_evidence_refs)
|
|
@@ -1641,6 +1870,41 @@ def handle_task_close(
|
|
|
1641
1870
|
indent=2,
|
|
1642
1871
|
)
|
|
1643
1872
|
|
|
1873
|
+
live_surface_required = _requires_live_surface_verification(task, clean_outcome)
|
|
1874
|
+
live_surface_evidence = "\n".join(
|
|
1875
|
+
part
|
|
1876
|
+
for part in (clean_evidence, clean_change_verify, outcome_notes, verification, summary, result)
|
|
1877
|
+
if part
|
|
1878
|
+
)
|
|
1879
|
+
if live_surface_required and not _has_live_surface_verification(live_surface_evidence):
|
|
1880
|
+
debt = _ensure_open_debt(
|
|
1881
|
+
task["session_id"],
|
|
1882
|
+
task_id,
|
|
1883
|
+
"live_surface_verification_missing",
|
|
1884
|
+
severity="error",
|
|
1885
|
+
evidence=(
|
|
1886
|
+
"Task closed as done for state/storefront/backend/data behavior without live-surface evidence. "
|
|
1887
|
+
f"Goal: {task.get('goal','')}. Evidence provided: {live_surface_evidence[:240]!r}"
|
|
1888
|
+
),
|
|
1889
|
+
debts=debts_created,
|
|
1890
|
+
)
|
|
1891
|
+
return json.dumps(
|
|
1892
|
+
{
|
|
1893
|
+
"ok": False,
|
|
1894
|
+
"error": "Cannot close task as 'done' without live production verification evidence.",
|
|
1895
|
+
"hint": (
|
|
1896
|
+
"Add evidence from the real surface: live logs such as black-box.ndjson/impossible_state_recovered, "
|
|
1897
|
+
"published storefront/browser or Playwright evidence, or production database evidence."
|
|
1898
|
+
),
|
|
1899
|
+
"task_id": task_id,
|
|
1900
|
+
"blocked_by": "live_surface_verification",
|
|
1901
|
+
"debt_id": debt.get("id"),
|
|
1902
|
+
"debt_type": "live_surface_verification_missing",
|
|
1903
|
+
},
|
|
1904
|
+
ensure_ascii=False,
|
|
1905
|
+
indent=2,
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1644
1908
|
pending_corrections = list_session_correction_requirements(
|
|
1645
1909
|
session_id=task["session_id"],
|
|
1646
1910
|
status="open",
|
|
@@ -1809,7 +2073,14 @@ def handle_task_close(
|
|
|
1809
2073
|
debts=debts_created,
|
|
1810
2074
|
)
|
|
1811
2075
|
|
|
1812
|
-
|
|
2076
|
+
production_change_log_required = _requires_production_change_log(
|
|
2077
|
+
task,
|
|
2078
|
+
clean_outcome,
|
|
2079
|
+
clean_evidence,
|
|
2080
|
+
clean_change_summary,
|
|
2081
|
+
triggered_by,
|
|
2082
|
+
)
|
|
2083
|
+
if (task.get("must_change_log") or production_change_log_required) and clean_outcome in {"done", "partial", "failed"}:
|
|
1813
2084
|
if effective_files:
|
|
1814
2085
|
change = log_change(
|
|
1815
2086
|
task["session_id"],
|
package/src/plugins/workflow.py
CHANGED
|
@@ -8,6 +8,7 @@ from db import (
|
|
|
8
8
|
create_workflow_goal,
|
|
9
9
|
create_workflow_run,
|
|
10
10
|
get_db,
|
|
11
|
+
get_followups,
|
|
11
12
|
get_workflow_goal,
|
|
12
13
|
get_protocol_task,
|
|
13
14
|
get_workflow_run,
|
|
@@ -21,6 +22,22 @@ from db import (
|
|
|
21
22
|
from protocol_settings import get_protocol_strictness
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
TOTAL_CLOSURE_MARKERS = (
|
|
26
|
+
"sin deuda",
|
|
27
|
+
"no queda deuda",
|
|
28
|
+
"deuda cero",
|
|
29
|
+
"todo cerrado",
|
|
30
|
+
"goal cumplido",
|
|
31
|
+
"objetivo cumplido",
|
|
32
|
+
"no queda nada pendiente",
|
|
33
|
+
"all done",
|
|
34
|
+
"no open debt",
|
|
35
|
+
)
|
|
36
|
+
PENDING_RELEASE_MARKERS = ("smoke pendiente", "tags pendientes", "merge pendiente", "stable pendiente", "pending smoke", "pending tags", "pending merge")
|
|
37
|
+
IRREVERSIBLE_MARKERS = ("publish stable", "publicar stable", "promocionar stable", "broadcast", "enviar a clientes", "force-push", "revocar", "cobrar")
|
|
38
|
+
SPECIFIC_APPROVAL_MARKERS = ("aprobacion explicita", "aprobación explícita", "ok especifico", "ok específico", "autorizacion especifica", "autorización específica", "specific ok", "explicit approval")
|
|
39
|
+
|
|
40
|
+
|
|
24
41
|
def _session_has_open_task(session_id: str) -> bool:
|
|
25
42
|
"""Return True if the session has at least one open protocol task.
|
|
26
43
|
|
|
@@ -71,6 +88,26 @@ def _parse_json_object(value: str) -> dict | None:
|
|
|
71
88
|
return parsed if isinstance(parsed, dict) else None
|
|
72
89
|
|
|
73
90
|
|
|
91
|
+
def _goal_close_text(*parts: object) -> str:
|
|
92
|
+
return "\n".join(str(part or "") for part in parts if str(part or "").strip()).lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _active_followup_snapshot(limit: int = 5) -> list[dict]:
|
|
96
|
+
try:
|
|
97
|
+
followups = get_followups("active")
|
|
98
|
+
except Exception:
|
|
99
|
+
return []
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
"id": item.get("id", ""),
|
|
103
|
+
"status": item.get("status", ""),
|
|
104
|
+
"date": item.get("date", ""),
|
|
105
|
+
"description": str(item.get("description", ""))[:180],
|
|
106
|
+
}
|
|
107
|
+
for item in followups[: max(1, limit)]
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
74
111
|
def _checkpoint_active_files(*payloads: dict | None) -> list[str]:
|
|
75
112
|
seen: list[str] = []
|
|
76
113
|
for payload in payloads:
|
|
@@ -277,6 +314,68 @@ def handle_goal_update(
|
|
|
277
314
|
clean_goal_id = (goal_id or "").strip()
|
|
278
315
|
if not clean_goal_id:
|
|
279
316
|
return json.dumps({"ok": False, "error": "goal_id is required"}, ensure_ascii=False, indent=2)
|
|
317
|
+
requested_status = (status or "").strip().lower()
|
|
318
|
+
existing_goal = get_workflow_goal(clean_goal_id)
|
|
319
|
+
if not existing_goal:
|
|
320
|
+
return json.dumps({"ok": False, "error": f"Unknown goal_id: {clean_goal_id}"}, ensure_ascii=False, indent=2)
|
|
321
|
+
close_text = _goal_close_text(
|
|
322
|
+
existing_goal.get("title", ""),
|
|
323
|
+
existing_goal.get("objective", ""),
|
|
324
|
+
title,
|
|
325
|
+
objective,
|
|
326
|
+
next_action,
|
|
327
|
+
success_signal,
|
|
328
|
+
blocker_reason,
|
|
329
|
+
shared_state,
|
|
330
|
+
)
|
|
331
|
+
if requested_status == "completed":
|
|
332
|
+
if int(existing_goal.get("open_run_count") or 0) > 0:
|
|
333
|
+
return json.dumps(
|
|
334
|
+
{
|
|
335
|
+
"ok": False,
|
|
336
|
+
"error": "Cannot mark goal completed while linked workflow runs are still open.",
|
|
337
|
+
"blocked_by": "goal_open_runs_gate",
|
|
338
|
+
"goal_id": clean_goal_id,
|
|
339
|
+
"open_run_count": int(existing_goal.get("open_run_count") or 0),
|
|
340
|
+
},
|
|
341
|
+
ensure_ascii=False,
|
|
342
|
+
indent=2,
|
|
343
|
+
)
|
|
344
|
+
active_followups = _active_followup_snapshot()
|
|
345
|
+
if any(marker in close_text for marker in TOTAL_CLOSURE_MARKERS) and active_followups:
|
|
346
|
+
return json.dumps(
|
|
347
|
+
{
|
|
348
|
+
"ok": False,
|
|
349
|
+
"error": "Cannot mark goal completed as total/no-debt while followups are still open.",
|
|
350
|
+
"blocked_by": "goal_total_closure_open_followups_gate",
|
|
351
|
+
"goal_id": clean_goal_id,
|
|
352
|
+
"open_followups": active_followups,
|
|
353
|
+
},
|
|
354
|
+
ensure_ascii=False,
|
|
355
|
+
indent=2,
|
|
356
|
+
)
|
|
357
|
+
if any(marker in close_text for marker in PENDING_RELEASE_MARKERS):
|
|
358
|
+
return json.dumps(
|
|
359
|
+
{
|
|
360
|
+
"ok": False,
|
|
361
|
+
"error": "Cannot mark goal completed while smoke/tags/merge/stable work is described as pending.",
|
|
362
|
+
"blocked_by": "goal_pending_release_gate",
|
|
363
|
+
"goal_id": clean_goal_id,
|
|
364
|
+
},
|
|
365
|
+
ensure_ascii=False,
|
|
366
|
+
indent=2,
|
|
367
|
+
)
|
|
368
|
+
if any(marker in close_text for marker in IRREVERSIBLE_MARKERS) and not any(marker in close_text for marker in SPECIFIC_APPROVAL_MARKERS):
|
|
369
|
+
return json.dumps(
|
|
370
|
+
{
|
|
371
|
+
"ok": False,
|
|
372
|
+
"error": "Cannot mark irreversible publish/broadcast/payment goal completed without a specific approval tied to evidence.",
|
|
373
|
+
"blocked_by": "goal_irreversible_specific_ok_gate",
|
|
374
|
+
"goal_id": clean_goal_id,
|
|
375
|
+
},
|
|
376
|
+
ensure_ascii=False,
|
|
377
|
+
indent=2,
|
|
378
|
+
)
|
|
280
379
|
try:
|
|
281
380
|
goal = update_workflow_goal(
|
|
282
381
|
clean_goal_id,
|
|
@@ -292,8 +391,6 @@ def handle_goal_update(
|
|
|
292
391
|
)
|
|
293
392
|
except ValueError as exc:
|
|
294
393
|
return json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2)
|
|
295
|
-
if not goal:
|
|
296
|
-
return json.dumps({"ok": False, "error": f"Unknown goal_id: {clean_goal_id}"}, ensure_ascii=False, indent=2)
|
|
297
394
|
|
|
298
395
|
return json.dumps(
|
|
299
396
|
{
|
package/src/pre_answer_router.py
CHANGED
|
@@ -31,11 +31,13 @@ PRE_ANSWER_INTENTS: tuple[str, ...] = (
|
|
|
31
31
|
"memory_question",
|
|
32
32
|
"identity_authorship",
|
|
33
33
|
"schedule_commitment",
|
|
34
|
+
"live_state_claim",
|
|
34
35
|
"runtime_diagnosis",
|
|
35
36
|
"general",
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
INJECTING_INTENTS = set(PRE_ANSWER_INTENTS) - {"general"}
|
|
40
|
+
EVIDENCE_REQUIRED_INTENTS = {"live_state_claim"}
|
|
39
41
|
|
|
40
42
|
DEFAULT_BUDGET_MS = 2500
|
|
41
43
|
DEFAULT_TOKEN_BUDGET = 2500
|
|
@@ -344,6 +346,53 @@ _FEATURE_LEXICON: dict[str, tuple[str, ...]] = {
|
|
|
344
346
|
"instalacion",
|
|
345
347
|
"catalogo",
|
|
346
348
|
),
|
|
349
|
+
"live_state": (
|
|
350
|
+
"release",
|
|
351
|
+
"published",
|
|
352
|
+
"publish",
|
|
353
|
+
"deployed",
|
|
354
|
+
"deploy",
|
|
355
|
+
"uploaded",
|
|
356
|
+
"sent",
|
|
357
|
+
"closed",
|
|
358
|
+
"merged",
|
|
359
|
+
"commit",
|
|
360
|
+
"branch",
|
|
361
|
+
"tag",
|
|
362
|
+
"version",
|
|
363
|
+
"server",
|
|
364
|
+
"port",
|
|
365
|
+
"dns",
|
|
366
|
+
"domain",
|
|
367
|
+
"ticket",
|
|
368
|
+
"issue",
|
|
369
|
+
"pr",
|
|
370
|
+
"pull",
|
|
371
|
+
"status",
|
|
372
|
+
"running",
|
|
373
|
+
"installed",
|
|
374
|
+
"verified",
|
|
375
|
+
"publicado",
|
|
376
|
+
"publicada",
|
|
377
|
+
"publicar",
|
|
378
|
+
"desplegado",
|
|
379
|
+
"desplegada",
|
|
380
|
+
"subido",
|
|
381
|
+
"subida",
|
|
382
|
+
"enviado",
|
|
383
|
+
"enviada",
|
|
384
|
+
"cerrado",
|
|
385
|
+
"cerrada",
|
|
386
|
+
"mergeado",
|
|
387
|
+
"rama",
|
|
388
|
+
"servidor",
|
|
389
|
+
"puerto",
|
|
390
|
+
"dominio",
|
|
391
|
+
"estado",
|
|
392
|
+
"corriendo",
|
|
393
|
+
"instalado",
|
|
394
|
+
"verificado",
|
|
395
|
+
),
|
|
347
396
|
}
|
|
348
397
|
|
|
349
398
|
_INTENT_FEATURE_WEIGHTS: dict[str, dict[str, float]] = {
|
|
@@ -353,6 +402,7 @@ _INTENT_FEATURE_WEIGHTS: dict[str, dict[str, float]] = {
|
|
|
353
402
|
"memory_question": {"memory": 1.40, "past_work": 0.20, "identity": 0.15},
|
|
354
403
|
"identity_authorship": {"identity": 1.20, "past_work": 0.65},
|
|
355
404
|
"schedule_commitment": {"schedule": 1.55, "past_work": 0.10, "memory": 0.20},
|
|
405
|
+
"live_state_claim": {"live_state": 1.45, "past_work": 0.45, "runtime": 0.25, "identity": 0.15},
|
|
356
406
|
"runtime_diagnosis": {"runtime": 1.55, "location": 0.15},
|
|
357
407
|
}
|
|
358
408
|
|
|
@@ -592,6 +642,25 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
592
642
|
SourceStep("transcripts", phase="fallback", timeout_ms=650),
|
|
593
643
|
),
|
|
594
644
|
),
|
|
645
|
+
"live_state_claim": SourcePlan(
|
|
646
|
+
intent="live_state_claim",
|
|
647
|
+
primary=(
|
|
648
|
+
SourceStep("semantic_layers", timeout_ms=120, max_chars=900),
|
|
649
|
+
SourceStep("recent_context", timeout_ms=240),
|
|
650
|
+
SourceStep("evidence_ledger", timeout_ms=260),
|
|
651
|
+
SourceStep("change_log", timeout_ms=300),
|
|
652
|
+
SourceStep("protocol_tasks", timeout_ms=240),
|
|
653
|
+
SourceStep("workflows", timeout_ms=260),
|
|
654
|
+
SourceStep("project_atlas", timeout_ms=160),
|
|
655
|
+
SourceStep("system_catalog", timeout_ms=420),
|
|
656
|
+
SourceStep("diary", timeout_ms=280),
|
|
657
|
+
),
|
|
658
|
+
fallback=(
|
|
659
|
+
SourceStep("transcripts", phase="fallback", timeout_ms=700),
|
|
660
|
+
SourceStep("memory", phase="fallback", timeout_ms=500),
|
|
661
|
+
SourceStep("local_context", phase="fallback", timeout_ms=900, max_chars=900),
|
|
662
|
+
),
|
|
663
|
+
),
|
|
595
664
|
"runtime_diagnosis": SourcePlan(
|
|
596
665
|
intent="runtime_diagnosis",
|
|
597
666
|
primary=(
|
|
@@ -737,6 +806,7 @@ def _classify_intent_semantic(text: str) -> IntentClassification | None:
|
|
|
737
806
|
"memory_question means asking remembered facts, decisions, or context. "
|
|
738
807
|
"identity_authorship means who did something, which session/client acted, or Nero authorship. "
|
|
739
808
|
"schedule_commitment means promises, pending commitments, reminders, deadlines, or future follow-up. "
|
|
809
|
+
"live_state_claim means current or past external state that needs evidence: releases, commits, branches, tags, tickets, servers, ports, DNS, deployments, sent messages, uploads, installs, or verified/closed status. "
|
|
740
810
|
"runtime_diagnosis means diagnosing NEXO/Brain/Desktop/runtime/tools. "
|
|
741
811
|
"general means no pre-answer continuity evidence is needed."
|
|
742
812
|
),
|
|
@@ -1012,6 +1082,7 @@ def route_pre_answer(
|
|
|
1012
1082
|
missing_required = sorted(required_sources - consulted_or_evident)
|
|
1013
1083
|
missing_required_count = len(set(missing_required) | set(required_source_timeouts))
|
|
1014
1084
|
optional_sources_skipped_count = sum(1 for source in sources if source.skipped and source.source not in required_sources)
|
|
1085
|
+
has_any_evidence = any(source.has_evidence for source in sources)
|
|
1015
1086
|
rendered = render_route(
|
|
1016
1087
|
query=query,
|
|
1017
1088
|
classification=classification,
|
|
@@ -1023,12 +1094,21 @@ def route_pre_answer(
|
|
|
1023
1094
|
rendered = _clip(rendered, max_rendered_chars)
|
|
1024
1095
|
elif budget_policy and max_rendered_chars == 0:
|
|
1025
1096
|
rendered = ""
|
|
1026
|
-
should_inject = classification.intent in INJECTING_INTENTS and any(source.has_evidence for source in sources)
|
|
1027
|
-
if not rendered:
|
|
1028
|
-
should_inject = False
|
|
1029
1097
|
aborted_reason = "required_source_timeout" if required_source_timeouts else _route_aborted_reason(sources, budget)
|
|
1030
1098
|
must_disclose_gap = bool((budget_policy or {}).get("must_disclose_gap") or missing_required_count)
|
|
1031
1099
|
decision_signal = "defer" if missing_required_count and (budget_policy or {}).get("fallback_policy") == "mandatory_fail_closed" else ""
|
|
1100
|
+
if classification.intent in EVIDENCE_REQUIRED_INTENTS and (not has_any_evidence or missing_required_count):
|
|
1101
|
+
gap = render_evidence_gap(
|
|
1102
|
+
query=query,
|
|
1103
|
+
classification=classification,
|
|
1104
|
+
sources=sources,
|
|
1105
|
+
missing_required_count=missing_required_count,
|
|
1106
|
+
required_source_timeouts=required_source_timeouts,
|
|
1107
|
+
)
|
|
1108
|
+
rendered = f"{rendered}\n\n{gap}" if rendered else gap
|
|
1109
|
+
must_disclose_gap = True
|
|
1110
|
+
decision_signal = "defer"
|
|
1111
|
+
should_inject = classification.intent in INJECTING_INTENTS and bool(rendered)
|
|
1032
1112
|
telemetry = _build_route_event(
|
|
1033
1113
|
query=query,
|
|
1034
1114
|
route_intent=classification.intent,
|
|
@@ -1100,6 +1180,35 @@ def render_route(
|
|
|
1100
1180
|
return _clip("\n".join(lines), max(400, int(token_budget or DEFAULT_TOKEN_BUDGET) * 4))
|
|
1101
1181
|
|
|
1102
1182
|
|
|
1183
|
+
def render_evidence_gap(
|
|
1184
|
+
*,
|
|
1185
|
+
query: str,
|
|
1186
|
+
classification: IntentClassification,
|
|
1187
|
+
sources: Iterable[SourceResult],
|
|
1188
|
+
missing_required_count: int = 0,
|
|
1189
|
+
required_source_timeouts: Iterable[str] = (),
|
|
1190
|
+
) -> str:
|
|
1191
|
+
checked = [source.source for source in sources if not source.skipped]
|
|
1192
|
+
skipped = [source.source for source in sources if source.skipped]
|
|
1193
|
+
timeout_names = list(dict.fromkeys(required_source_timeouts))
|
|
1194
|
+
lines = [
|
|
1195
|
+
"PRE-ANSWER VERIFICATION GAP",
|
|
1196
|
+
f"Intent: {classification.intent} ({classification.confidence:.2f})",
|
|
1197
|
+
"The user is asking about state that requires evidence before any claim.",
|
|
1198
|
+
"Do not affirm, deny, or present a release/server/ticket/commit/action status as fact from recollection.",
|
|
1199
|
+
"If no stronger source is available in this turn, answer that the state is not verified yet and continue checking.",
|
|
1200
|
+
]
|
|
1201
|
+
if checked:
|
|
1202
|
+
lines.append(f"Sources checked without enough evidence: {', '.join(dict.fromkeys(checked))}.")
|
|
1203
|
+
if skipped:
|
|
1204
|
+
lines.append(f"Sources skipped or unavailable: {', '.join(dict.fromkeys(skipped))}.")
|
|
1205
|
+
if missing_required_count:
|
|
1206
|
+
lines.append(f"Missing required source count: {missing_required_count}.")
|
|
1207
|
+
if timeout_names:
|
|
1208
|
+
lines.append(f"Required source timeouts: {', '.join(timeout_names)}.")
|
|
1209
|
+
return _clip("\n".join(lines), 1800)
|
|
1210
|
+
|
|
1211
|
+
|
|
1103
1212
|
def default_source_adapters() -> dict[str, SourceAdapter]:
|
|
1104
1213
|
return {
|
|
1105
1214
|
"semantic_layers": _source_semantic_layers,
|
|
@@ -2165,6 +2274,7 @@ def _matching_lines(lines: Iterable[str], query: str, *, limit: int = 6) -> list
|
|
|
2165
2274
|
|
|
2166
2275
|
|
|
2167
2276
|
__all__ = [
|
|
2277
|
+
"EVIDENCE_REQUIRED_INTENTS",
|
|
2168
2278
|
"INJECTING_INTENTS",
|
|
2169
2279
|
"PRE_ANSWER_INTENTS",
|
|
2170
2280
|
"IntentClassification",
|
|
@@ -2177,6 +2287,7 @@ __all__ = [
|
|
|
2177
2287
|
"default_source_adapters",
|
|
2178
2288
|
"plan_sources",
|
|
2179
2289
|
"redact_secrets",
|
|
2290
|
+
"render_evidence_gap",
|
|
2180
2291
|
"render_route",
|
|
2181
2292
|
"route_pre_answer",
|
|
2182
2293
|
"shutdown_executor",
|
|
@@ -255,6 +255,9 @@ def _base_tier_for(
|
|
|
255
255
|
if intent == "file_location":
|
|
256
256
|
reasons.append("simple_file_location")
|
|
257
257
|
return "quick", reasons
|
|
258
|
+
if intent == "live_state_claim":
|
|
259
|
+
reasons.append("live_state_claim_deep")
|
|
260
|
+
return "deep", reasons
|
|
258
261
|
if intent in {"runtime_diagnosis"}:
|
|
259
262
|
reasons.append("runtime_diagnosis_deep")
|
|
260
263
|
return "deep", reasons
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://nexo-brain.com/schemas/guardian-config-v1.json",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Default Guardian configuration. Copied to ~/.nexo/personal/config/guardian.json at `nexo init` / `nexo update` if no user config exists. If user config exists, merge keys NOT present in user config without overwriting user overrides. Core rules (R13, R14, R16, R25, R30) can only be shadow/soft/hard — mode=off is rejected by validator. v1.4.0 (constructor-guardian-90 pass 1): R15/R17/R22/R_CATALOG upgraded from soft to hard where FP telemetry was low; R34 raised from shadow to soft. v1.5.0 (pass 2): R11_plugin_load_pre_inventory upgraded from soft to hard; new R_PRIMITIVE_CHOICE (SK-CREATE-NEXO-PRIMITIVE gate) added at soft so it can be observed before hardening.",
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Default Guardian configuration. Copied to ~/.nexo/personal/config/guardian.json at `nexo init` / `nexo update` if no user config exists. If user config exists, merge keys NOT present in user config without overwriting user overrides. Core rules (R13, R14, R16, R25, R30, R34, R37) can only be shadow/soft/hard — mode=off is rejected by validator. v1.4.0 (constructor-guardian-90 pass 1): R15/R17/R22/R_CATALOG upgraded from soft to hard where FP telemetry was low; R34 raised from shadow to soft. v1.5.0 (pass 2): R11_plugin_load_pre_inventory upgraded from soft to hard; new R_PRIMITIVE_CHOICE (SK-CREATE-NEXO-PRIMITIVE gate) added at soft so it can be observed before hardening. v1.6.0: R34 is hard/core and R37 adds a hard pre-answer evidence gate for live or prior state claims.",
|
|
5
5
|
"enabled": true,
|
|
6
6
|
"classifier_task_profile": "enforcer_classify",
|
|
7
7
|
"classifier_tier": "muy_bajo",
|
|
@@ -60,14 +60,17 @@
|
|
|
60
60
|
"R23g_secrets_in_output": "soft",
|
|
61
61
|
"R23m_message_duplicate": "soft",
|
|
62
62
|
"R23h_shebang_mismatch": "shadow",
|
|
63
|
-
"R34_identity_coherence": "
|
|
63
|
+
"R34_identity_coherence": "hard",
|
|
64
|
+
"R37_pre_answer_evidence_gate": "hard"
|
|
64
65
|
},
|
|
65
66
|
"core_rules": [
|
|
66
67
|
"R13_pre_edit_guard",
|
|
67
68
|
"R14_correction_learning",
|
|
68
69
|
"R16_declared_done",
|
|
69
70
|
"R25_nora_maria_read_only",
|
|
70
|
-
"R30_pre_done_evidence_system_prompt"
|
|
71
|
+
"R30_pre_done_evidence_system_prompt",
|
|
72
|
+
"R34_identity_coherence",
|
|
73
|
+
"R37_pre_answer_evidence_gate"
|
|
71
74
|
],
|
|
72
75
|
"fail_closed": {
|
|
73
76
|
"classifier_timeout_seconds": 10,
|