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.
@@ -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
- if task.get("must_change_log") and clean_outcome in {"done", "partial", "failed"}:
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"],
@@ -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
  {
@@ -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.5.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) 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": "soft"
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,