nexo-brain 7.34.0 → 7.36.0
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 +1 -1
- package/package.json +1 -1
- package/src/db/_hot_context.py +30 -1
- package/src/db/_schema.py +64 -0
- package/src/deep_sleep_retention.py +8 -0
- package/src/email_sent_events.py +7 -0
- package/src/enforcement_engine.py +58 -0
- package/src/hooks/post_tool_use.py +114 -0
- package/src/local_context/api.py +157 -7
- package/src/local_context/db.py +61 -1
- package/src/memory_forget.py +1249 -0
- package/src/plugins/protocol.py +30 -0
- package/src/plugins/schema_abstraction.py +66 -0
- package/src/schema_abstraction.py +763 -0
- package/src/scripts/nexo-daily-self-audit.py +97 -13
- package/src/server.py +54 -0
- package/src/tools_credentials.py +60 -2
- package/tool-enforcement-map.json +66 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.36.0",
|
|
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,7 @@
|
|
|
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.
|
|
21
|
+
Version `7.36.0` is the current packaged-runtime line. Minor release - local index disk reclaim: the local file/code index (`local-context.db`) no longer grows without bound. It now uses `auto_vacuum=INCREMENTAL` plus a one-time guarded `VACUUM` to convert existing databases, stores embeddings as compact float32 BLOBs instead of JSON text (~4-6x smaller, back-compatible dual-write/dual-read with a resumable backfill and kill switches), reclaims disk on purge/clear, and the daily self-audit now actively compacts at its size cap (`NEXO_LOCAL_INDEX_MAX_BYTES`) instead of only warning. An established index reclaims ~10-20GB immediately and grows several-fold slower; the backup subsystem was audited and is already bounded. Builds on v7.35.0 (selective forget + recurring-incident diagnostic templates).
|
|
22
22
|
|
|
23
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.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.36.0",
|
|
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",
|
package/src/db/_hot_context.py
CHANGED
|
@@ -570,6 +570,21 @@ def build_pre_action_context(
|
|
|
570
570
|
reminders = _find_related_items("reminders", clean_query, hours=hours, limit=4) if clean_query else []
|
|
571
571
|
followups = _find_related_items("followups", clean_query, hours=hours, limit=4) if clean_query else []
|
|
572
572
|
|
|
573
|
+
# Ola 4 SCHEMA-ABSTRACTION: if the current action CLEARLY matches a distilled
|
|
574
|
+
# recurring-incident archetype (e.g. "cron exit 0 but the tool failed
|
|
575
|
+
# silently"), prime the complete diagnosis instead of re-diagnosing from
|
|
576
|
+
# scratch. Best-effort, non-authoritative, precision-first: a template is
|
|
577
|
+
# surfaced only on a clear archetype match, never in general, and it NEVER
|
|
578
|
+
# blocks. Any failure here must not break pre-action context.
|
|
579
|
+
diagnostic_templates: list[dict] = []
|
|
580
|
+
if clean_query:
|
|
581
|
+
try:
|
|
582
|
+
import schema_abstraction as sa
|
|
583
|
+
|
|
584
|
+
diagnostic_templates = sa.match_templates_for_action(query=clean_query, limit=1)
|
|
585
|
+
except Exception:
|
|
586
|
+
diagnostic_templates = []
|
|
587
|
+
|
|
573
588
|
return {
|
|
574
589
|
"query": clean_query,
|
|
575
590
|
"context_key": clean_key,
|
|
@@ -578,7 +593,8 @@ def build_pre_action_context(
|
|
|
578
593
|
"events": events,
|
|
579
594
|
"reminders": reminders,
|
|
580
595
|
"followups": followups,
|
|
581
|
-
"
|
|
596
|
+
"diagnostic_templates": diagnostic_templates,
|
|
597
|
+
"has_matches": bool(contexts or events or reminders or followups or diagnostic_templates),
|
|
582
598
|
}
|
|
583
599
|
|
|
584
600
|
|
|
@@ -592,6 +608,19 @@ def format_pre_action_context_bundle(bundle: dict, *, compact: bool = False) ->
|
|
|
592
608
|
header += f" — query: {bundle['query'][:120]}"
|
|
593
609
|
lines.append(header)
|
|
594
610
|
|
|
611
|
+
# Ola 4: primed diagnosis from a matched recurring-incident archetype goes
|
|
612
|
+
# FIRST — the whole point is to lead with the right diagnosis.
|
|
613
|
+
templates = bundle.get("diagnostic_templates") or []
|
|
614
|
+
if templates:
|
|
615
|
+
try:
|
|
616
|
+
import schema_abstraction as sa
|
|
617
|
+
|
|
618
|
+
rendered = sa.format_templates_for_injection(templates)
|
|
619
|
+
if rendered:
|
|
620
|
+
lines.append(rendered)
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
|
|
595
624
|
contexts = bundle.get("contexts") or []
|
|
596
625
|
if contexts:
|
|
597
626
|
lines.append("Contexts:")
|
package/src/db/_schema.py
CHANGED
|
@@ -2007,6 +2007,7 @@ def _m63_local_context_layer(conn):
|
|
|
2007
2007
|
model_revision TEXT NOT NULL DEFAULT '',
|
|
2008
2008
|
dimension INTEGER NOT NULL,
|
|
2009
2009
|
vector_json TEXT NOT NULL,
|
|
2010
|
+
vector_blob BLOB,
|
|
2010
2011
|
created_at REAL NOT NULL
|
|
2011
2012
|
);
|
|
2012
2013
|
|
|
@@ -2714,6 +2715,68 @@ def _m75_failure_prevention_ledger(conn):
|
|
|
2714
2715
|
_migrate_add_index(conn, "idx_antibody_actions_verification", "antibody_actions", "verification_status, review_due_at")
|
|
2715
2716
|
|
|
2716
2717
|
|
|
2718
|
+
def _m88_schema_abstraction_templates(conn):
|
|
2719
|
+
"""Ola 4 — diagnostic templates distilled from recurring incident archetypes.
|
|
2720
|
+
|
|
2721
|
+
NOTE (append-only migration discipline): this used to be called inline from
|
|
2722
|
+
``_m75_failure_prevention_ledger`` (as ``_m75b_...``). That meant any install
|
|
2723
|
+
already at schema v75 would NEVER create ``diagnostic_templates`` through
|
|
2724
|
+
``run_migrations()`` (v75 was already marked applied), so the table only
|
|
2725
|
+
appeared via the lazy ``_ensure_tables`` fallback. Promoting it to its own
|
|
2726
|
+
appended migration version makes a normal upgrade from v75 create the table
|
|
2727
|
+
through the standard migration path. Idempotent (``IF NOT EXISTS``), so it is
|
|
2728
|
+
a no-op on installs where ``_ensure_tables``/the old inline call already
|
|
2729
|
+
created it.
|
|
2730
|
+
|
|
2731
|
+
A diagnostic template is the destillation of a GENUINELY recurring class of
|
|
2732
|
+
incident (>= MIN_CLUSTER_SIZE distinct failure cases of the same archetype,
|
|
2733
|
+
by symptom similarity) into a reusable, complete-diagnosis-first checklist
|
|
2734
|
+
that primes the right diagnosis instantly when the archetype reappears,
|
|
2735
|
+
instead of re-diagnosing from scratch (Francisco's canonical case: "cron
|
|
2736
|
+
exit 0 but the tool failed in SILENCE").
|
|
2737
|
+
|
|
2738
|
+
Non-authoritative guidance: templates never block; they only inject a primed
|
|
2739
|
+
diagnosis into pre_action_context on a clear archetype match. Idempotent:
|
|
2740
|
+
deduped by ``template_uid`` (stable hash of archetype key). A template is
|
|
2741
|
+
minted only at high confidence; ambiguity yields nothing (a low-confidence
|
|
2742
|
+
candidate, never an active template).
|
|
2743
|
+
"""
|
|
2744
|
+
conn.execute(
|
|
2745
|
+
"""
|
|
2746
|
+
CREATE TABLE IF NOT EXISTS diagnostic_templates (
|
|
2747
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2748
|
+
template_uid TEXT NOT NULL UNIQUE,
|
|
2749
|
+
policy_version TEXT NOT NULL DEFAULT 'schema_abstraction.v1',
|
|
2750
|
+
archetype TEXT NOT NULL,
|
|
2751
|
+
archetype_key TEXT NOT NULL,
|
|
2752
|
+
failure_type TEXT NOT NULL DEFAULT 'other',
|
|
2753
|
+
area TEXT NOT NULL DEFAULT '',
|
|
2754
|
+
symptom_pattern TEXT NOT NULL DEFAULT '',
|
|
2755
|
+
diagnosis_steps_json TEXT NOT NULL DEFAULT '[]',
|
|
2756
|
+
prevention TEXT NOT NULL DEFAULT '',
|
|
2757
|
+
match_tokens_json TEXT NOT NULL DEFAULT '[]',
|
|
2758
|
+
member_uids_json TEXT NOT NULL DEFAULT '[]',
|
|
2759
|
+
incident_count INTEGER NOT NULL DEFAULT 0,
|
|
2760
|
+
confidence REAL NOT NULL DEFAULT 0.0,
|
|
2761
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
2762
|
+
privacy_level TEXT NOT NULL DEFAULT 'normal',
|
|
2763
|
+
created_at REAL NOT NULL,
|
|
2764
|
+
updated_at REAL NOT NULL,
|
|
2765
|
+
retired_at REAL,
|
|
2766
|
+
retired_reason TEXT NOT NULL DEFAULT '',
|
|
2767
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
2768
|
+
CHECK(status IN ('active','candidate','retired','superseded')),
|
|
2769
|
+
CHECK(privacy_level IN ('public','normal','private','sensitive','secret')),
|
|
2770
|
+
CHECK(incident_count >= 0),
|
|
2771
|
+
CHECK(confidence >= 0.0 AND confidence <= 1.0)
|
|
2772
|
+
)
|
|
2773
|
+
"""
|
|
2774
|
+
)
|
|
2775
|
+
_migrate_add_index(conn, "idx_diagnostic_templates_archetype", "diagnostic_templates", "archetype_key")
|
|
2776
|
+
_migrate_add_index(conn, "idx_diagnostic_templates_status", "diagnostic_templates", "status, area")
|
|
2777
|
+
_migrate_add_index(conn, "idx_diagnostic_templates_type", "diagnostic_templates", "failure_type, status")
|
|
2778
|
+
|
|
2779
|
+
|
|
2717
2780
|
def _m76_semantic_layers(conn):
|
|
2718
2781
|
"""SemanticLayers cache for compact, source-backed continuity.
|
|
2719
2782
|
|
|
@@ -3402,6 +3465,7 @@ MIGRATIONS = [
|
|
|
3402
3465
|
(85, "eval_runs", _m85_eval_runs),
|
|
3403
3466
|
(86, "resolution_cache", _m86_resolution_cache),
|
|
3404
3467
|
(87, "resolution_cache_content_snapshot", _m87_resolution_cache_content_snapshot),
|
|
3468
|
+
(88, "schema_abstraction_templates", _m88_schema_abstraction_templates),
|
|
3405
3469
|
]
|
|
3406
3470
|
|
|
3407
3471
|
|
|
@@ -146,6 +146,14 @@ def _prune_db_backups(deep_sleep_dir: Path, report: dict, *, keep: int, apply: b
|
|
|
146
146
|
_record_delete(report, backup, reason=f"old-db-backup:{family}", apply=apply)
|
|
147
147
|
for sidecar in _sidecars(backup):
|
|
148
148
|
_record_delete(report, sidecar, reason=f"old-db-backup-sidecar:{family}", apply=apply)
|
|
149
|
+
# Orphan sweep: -wal/-shm sidecars whose base .db no longer exists (left by
|
|
150
|
+
# interrupted/legacy deep-sleep processes). The online-backup path produces
|
|
151
|
+
# sidecar-free snapshots, so any sidecar with a missing base is a true
|
|
152
|
+
# orphan. Scoped strictly to this deep-sleep backup dir; never the live DBs.
|
|
153
|
+
for sidecar in list(deep_sleep_dir.glob("*-backup-*.db-wal")) + list(deep_sleep_dir.glob("*-backup-*.db-shm")):
|
|
154
|
+
base = Path(str(sidecar)[: -len("-wal")]) if str(sidecar).endswith("-wal") else Path(str(sidecar)[: -len("-shm")])
|
|
155
|
+
if not base.exists():
|
|
156
|
+
_record_delete(report, sidecar, reason="orphan-db-sidecar", apply=apply)
|
|
149
157
|
|
|
150
158
|
|
|
151
159
|
def _prune_contexts(deep_sleep_dir: Path, report: dict, *, keep: int, apply: bool) -> None:
|
package/src/email_sent_events.py
CHANGED
|
@@ -8,6 +8,7 @@ sent even when the send path did not originate from an inbound email row.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import os
|
|
11
12
|
import sqlite3
|
|
12
13
|
from datetime import datetime, timedelta
|
|
13
14
|
from pathlib import Path
|
|
@@ -43,6 +44,12 @@ RECENT_SENT_EMAILS_TITLE = "EMAILS ENVIADOS ULTIMAS 24H POR LA OPERATIVA"
|
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def sent_email_db_path() -> Path:
|
|
47
|
+
# NEXO_EMAIL_DB lets tests (and the selective-forget live-DB enumerator)
|
|
48
|
+
# isolate / discover the email store deterministically without rewiring
|
|
49
|
+
# NEXO_HOME. Falls back to the canonical runtime location.
|
|
50
|
+
override = os.environ.get("NEXO_EMAIL_DB", "").strip()
|
|
51
|
+
if override:
|
|
52
|
+
return Path(override).expanduser()
|
|
46
53
|
return paths.nexo_email_dir() / "nexo-email.db"
|
|
47
54
|
|
|
48
55
|
|
|
@@ -465,6 +465,7 @@ class HeadlessEnforcer:
|
|
|
465
465
|
self.user_message_count = 0
|
|
466
466
|
self.tool_timestamps: dict[str, float] = {}
|
|
467
467
|
self.msg_since_tool: dict[str, int] = {}
|
|
468
|
+
self._tool_user_message_index: dict[str, int] = {}
|
|
468
469
|
self.injection_queue: list[dict] = []
|
|
469
470
|
self._started_at = time.time()
|
|
470
471
|
self._injections_done = 0
|
|
@@ -551,6 +552,8 @@ class HeadlessEnforcer:
|
|
|
551
552
|
# seen, periodic/conditional reminders stay suppressed so cron
|
|
552
553
|
# runners can reach TURN_END instead of reopening the task loop.
|
|
553
554
|
self._session_stopped: bool = False
|
|
555
|
+
self._first_visible_startup_gate_fired: bool = False
|
|
556
|
+
self._first_visible_text_allowed: bool = False
|
|
554
557
|
try:
|
|
555
558
|
self._post_close_cooldown_seconds = max(
|
|
556
559
|
0,
|
|
@@ -1036,6 +1039,52 @@ class HeadlessEnforcer:
|
|
|
1036
1039
|
except Exception:
|
|
1037
1040
|
pass
|
|
1038
1041
|
|
|
1042
|
+
def should_block_first_visible_text(self) -> bool:
|
|
1043
|
+
"""Fail closed before the first visible answer when startup context is missing."""
|
|
1044
|
+
if self._first_visible_text_allowed:
|
|
1045
|
+
return False
|
|
1046
|
+
if self.user_message_count <= 0:
|
|
1047
|
+
self._first_visible_text_allowed = True
|
|
1048
|
+
return False
|
|
1049
|
+
|
|
1050
|
+
current_turn = int(self.user_message_count or 0)
|
|
1051
|
+
has_startup = "nexo_startup" in self.tools_called
|
|
1052
|
+
continuity_tools = {
|
|
1053
|
+
"nexo_smart_startup",
|
|
1054
|
+
"nexo_session_diary_read",
|
|
1055
|
+
"nexo_reminders",
|
|
1056
|
+
"nexo_checkpoint_read",
|
|
1057
|
+
}
|
|
1058
|
+
has_continuity = bool(self.tools_called.intersection(continuity_tools))
|
|
1059
|
+
heartbeat_turn = max(
|
|
1060
|
+
self._tool_user_message_index.get("nexo_heartbeat", -1),
|
|
1061
|
+
self._tool_user_message_index.get("nexo_task_open", -1),
|
|
1062
|
+
)
|
|
1063
|
+
has_turn_heartbeat = heartbeat_turn >= current_turn
|
|
1064
|
+
|
|
1065
|
+
missing = []
|
|
1066
|
+
if not has_startup:
|
|
1067
|
+
missing.append("nexo_startup")
|
|
1068
|
+
if not has_continuity:
|
|
1069
|
+
missing.append("continuidad minima")
|
|
1070
|
+
if not has_turn_heartbeat:
|
|
1071
|
+
missing.append("nexo_heartbeat")
|
|
1072
|
+
if not missing:
|
|
1073
|
+
self._first_visible_text_allowed = True
|
|
1074
|
+
return False
|
|
1075
|
+
if self._first_visible_startup_gate_fired:
|
|
1076
|
+
return True
|
|
1077
|
+
|
|
1078
|
+
prompt = (
|
|
1079
|
+
"Before any visible answer, register the session, load minimal continuity, "
|
|
1080
|
+
"and associate the current user message with a heartbeat. Missing: "
|
|
1081
|
+
f"{', '.join(missing)}. Execute the required NEXO tool calls now. "
|
|
1082
|
+
"Do not produce visible text for this reminder."
|
|
1083
|
+
)
|
|
1084
|
+
self._enqueue(prompt, "first-visible-startup-heartbeat-gate", rule_id="R38_first_visible_startup_gate")
|
|
1085
|
+
self._first_visible_startup_gate_fired = True
|
|
1086
|
+
return True
|
|
1087
|
+
|
|
1039
1088
|
def _check_capability_denial_requires_reality(self, text: str):
|
|
1040
1089
|
"""Block unsupported capability denials until a live source was checked."""
|
|
1041
1090
|
if not text or not _CAPABILITY_DENIAL_RE.search(text):
|
|
@@ -2537,6 +2586,7 @@ class HeadlessEnforcer:
|
|
|
2537
2586
|
self.tools_called.add(name)
|
|
2538
2587
|
self.tool_timestamps[name] = time.time()
|
|
2539
2588
|
self.msg_since_tool[name] = 0
|
|
2589
|
+
self._tool_user_message_index[name] = int(self.user_message_count or 0)
|
|
2540
2590
|
|
|
2541
2591
|
# v7.6 conditional counter advance. Tools watched by a
|
|
2542
2592
|
# conditional rule tick a counter on every non-matching call.
|
|
@@ -3346,6 +3396,14 @@ def run_with_enforcement(
|
|
|
3346
3396
|
msg = event.get("message", {})
|
|
3347
3397
|
for block in msg.get("content", []):
|
|
3348
3398
|
if block.get("type") == "text":
|
|
3399
|
+
try:
|
|
3400
|
+
if enforcer.should_block_first_visible_text():
|
|
3401
|
+
item = enforcer.flush()
|
|
3402
|
+
if item:
|
|
3403
|
+
_inject(item["prompt"])
|
|
3404
|
+
return False
|
|
3405
|
+
except Exception as _startup_gate_exc: # noqa: BLE001
|
|
3406
|
+
_logger.warning("first visible startup gate failed: %s", _startup_gate_exc)
|
|
3349
3407
|
collected_text.append(block["text"])
|
|
3350
3408
|
# R16 — probe each assistant text block as it arrives
|
|
3351
3409
|
# so a declared-done line is caught on the same turn
|
|
@@ -374,6 +374,118 @@ def _write_json(path: Path, payload: dict) -> None:
|
|
|
374
374
|
tmp.replace(path)
|
|
375
375
|
|
|
376
376
|
|
|
377
|
+
def _pending_trace_path(sid: str) -> Path:
|
|
378
|
+
safe_sid = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (sid or "unknown"))
|
|
379
|
+
return _production_closeout_dir() / f"post-change-trace-{safe_sid}.json"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _split_files(value: object) -> set[str]:
|
|
383
|
+
if value is None:
|
|
384
|
+
return set()
|
|
385
|
+
if isinstance(value, (list, tuple, set)):
|
|
386
|
+
raw = "\n".join(str(item) for item in value)
|
|
387
|
+
else:
|
|
388
|
+
raw = str(value)
|
|
389
|
+
parts = re.split(r"[\n,;]+", raw)
|
|
390
|
+
return {part.strip() for part in parts if part and part.strip()}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _record_post_change_trace(payload: dict, sid: str) -> None:
|
|
394
|
+
if not sid:
|
|
395
|
+
sid = "unknown"
|
|
396
|
+
path = _pending_trace_path(sid)
|
|
397
|
+
trace = _read_json(path) or {
|
|
398
|
+
"sid": sid,
|
|
399
|
+
"touched_files": [],
|
|
400
|
+
"guard_files": [],
|
|
401
|
+
"change_log_files": [],
|
|
402
|
+
"production_mutation": False,
|
|
403
|
+
"created_at": time.time(),
|
|
404
|
+
}
|
|
405
|
+
tool_name = _tool_name(payload)
|
|
406
|
+
tool_input = _tool_input(payload)
|
|
407
|
+
cmd = _extract_command(payload)
|
|
408
|
+
|
|
409
|
+
touched = set(trace.get("touched_files") or [])
|
|
410
|
+
guards = set(trace.get("guard_files") or [])
|
|
411
|
+
logged = set(trace.get("change_log_files") or [])
|
|
412
|
+
|
|
413
|
+
if _is_shared_mutation_payload(payload):
|
|
414
|
+
touched.update(_split_files(tool_input.get("file_path")))
|
|
415
|
+
touched.update(_split_files(tool_input.get("path")))
|
|
416
|
+
touched.update(_split_files(tool_input.get("files")))
|
|
417
|
+
touched.update(_split_files(tool_input.get("paths")))
|
|
418
|
+
if cmd:
|
|
419
|
+
trace["last_mutation_command"] = cmd[:500]
|
|
420
|
+
if _is_production_mutation_command(cmd):
|
|
421
|
+
trace["production_mutation"] = True
|
|
422
|
+
|
|
423
|
+
if tool_name in {"nexo_guard_check", "mcp__nexo__nexo_guard_check"}:
|
|
424
|
+
guards.update(_split_files(tool_input.get("files")))
|
|
425
|
+
|
|
426
|
+
if _is_change_log_tool(tool_name):
|
|
427
|
+
logged.update(_split_files(tool_input.get("files")))
|
|
428
|
+
logged.update(_split_files(tool_input.get("files_changed")))
|
|
429
|
+
if not logged and touched:
|
|
430
|
+
logged.update(touched)
|
|
431
|
+
|
|
432
|
+
if _is_task_close_tool(tool_name):
|
|
433
|
+
touched.update(_split_files(tool_input.get("files_changed")))
|
|
434
|
+
|
|
435
|
+
trace["touched_files"] = sorted(touched)
|
|
436
|
+
trace["guard_files"] = sorted(guards)
|
|
437
|
+
trace["change_log_files"] = sorted(logged)
|
|
438
|
+
trace["updated_at"] = time.time()
|
|
439
|
+
|
|
440
|
+
if touched or guards or logged or trace.get("production_mutation"):
|
|
441
|
+
_write_json(path, trace)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _missing_trace_items(payload: dict, sid: str) -> list[str]:
|
|
445
|
+
if not _is_task_close_tool(_tool_name(payload)):
|
|
446
|
+
return []
|
|
447
|
+
trace = _read_json(_pending_trace_path(sid or "unknown"))
|
|
448
|
+
if not trace:
|
|
449
|
+
return []
|
|
450
|
+
tool_input = _tool_input(payload)
|
|
451
|
+
touched = set(trace.get("touched_files") or [])
|
|
452
|
+
if not touched and not trace.get("production_mutation"):
|
|
453
|
+
return []
|
|
454
|
+
guards = set(trace.get("guard_files") or [])
|
|
455
|
+
logged = set(trace.get("change_log_files") or [])
|
|
456
|
+
closing_files = _split_files(tool_input.get("files_changed"))
|
|
457
|
+
|
|
458
|
+
missing = []
|
|
459
|
+
if touched and not guards:
|
|
460
|
+
missing.append("guardias ejecutados")
|
|
461
|
+
if trace.get("production_mutation") and not logged and not _task_close_payload_has_change_trace(payload):
|
|
462
|
+
missing.append("registro de cambios")
|
|
463
|
+
if touched and closing_files and not touched.issubset(closing_files):
|
|
464
|
+
missing.append("files_changed completo")
|
|
465
|
+
if touched and not closing_files:
|
|
466
|
+
missing.append("files_changed")
|
|
467
|
+
return missing
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def check_post_change_trace_closeout(payload: dict, sid: str) -> str | None:
|
|
471
|
+
if not sid:
|
|
472
|
+
sid = "unknown"
|
|
473
|
+
_record_post_change_trace(payload, sid)
|
|
474
|
+
missing = _missing_trace_items(payload, sid)
|
|
475
|
+
if not missing:
|
|
476
|
+
if _is_task_close_tool(_tool_name(payload)):
|
|
477
|
+
_pending_trace_path(sid).unlink(missing_ok=True)
|
|
478
|
+
return None
|
|
479
|
+
trace = _read_json(_pending_trace_path(sid))
|
|
480
|
+
files = ", ".join((trace.get("touched_files") or [])[:6]) or "cambio detectado"
|
|
481
|
+
message = (
|
|
482
|
+
"Cierre bloqueado: antes de marcar completado hay que cuadrar archivos tocados, "
|
|
483
|
+
f"guardias y registro de cambios. Falta: {', '.join(missing)}. "
|
|
484
|
+
f"Archivos detectados: {files}."
|
|
485
|
+
)
|
|
486
|
+
return append_operator_language_contract(message)
|
|
487
|
+
|
|
488
|
+
|
|
377
489
|
def check_production_change_log_closeout(payload: dict, sid: str) -> str | None:
|
|
378
490
|
if not sid:
|
|
379
491
|
sid = "unknown"
|
|
@@ -551,6 +663,7 @@ def main() -> int:
|
|
|
551
663
|
sid = _resolve_sid_from_payload(payload)
|
|
552
664
|
reminder = check_inbox_and_emit_reminder(sid)
|
|
553
665
|
change_log_message = check_production_change_log_closeout(payload, sid)
|
|
666
|
+
post_change_trace_message = check_post_change_trace_closeout(payload, sid)
|
|
554
667
|
shared_scope_message = check_shared_scope_closeout(payload)
|
|
555
668
|
g1_message: str | None = None
|
|
556
669
|
try:
|
|
@@ -562,6 +675,7 @@ def main() -> int:
|
|
|
562
675
|
protocol_message,
|
|
563
676
|
reminder,
|
|
564
677
|
change_log_message,
|
|
678
|
+
post_change_trace_message,
|
|
565
679
|
shared_scope_message,
|
|
566
680
|
g1_message,
|
|
567
681
|
)
|