nexo-brain 7.12.14 → 7.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.14",
3
+ "version": "7.13.3",
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.12.14` is the current packaged-runtime line. Patch release — dashboard followup execution now respects the selected terminal client across macOS, Windows via WSL, and Linux instead of returning a macOS-only dead end. Result: operators can launch followups from the dashboard on every supported Brain host without hardcoding Claude-only or Mac-only assumptions.
21
+ Version `7.13.3` is the current packaged-runtime line. Unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI parity is release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
22
+
23
+ Previously in `7.12.15`: patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
22
24
 
23
25
  Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.14",
3
+ "version": "7.13.3",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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",
@@ -152,6 +152,36 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
152
152
  delete_diary_draft(sid)
153
153
 
154
154
 
155
+ def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
156
+ """Close stale open protocol tasks as partial when their session is reaped."""
157
+ rows = conn.execute(
158
+ """SELECT task_id, goal
159
+ FROM protocol_tasks
160
+ WHERE session_id = ? AND status = 'open'
161
+ ORDER BY opened_at ASC""",
162
+ (sid,),
163
+ ).fetchall()
164
+ closed: list[str] = []
165
+ for row in rows:
166
+ task_id = row["task_id"]
167
+ goal = str(row["goal"] or "")
168
+ evidence = (
169
+ f"Auto-closed as partial because session {sid} became stale before an explicit nexo_task_close. "
170
+ f"Session task: {task or 'unknown'}. Open goal: {goal[:240]}"
171
+ )
172
+ conn.execute(
173
+ """UPDATE protocol_tasks
174
+ SET status = 'partial',
175
+ close_evidence = ?,
176
+ outcome_notes = 'auto-close: stale session ended without explicit task_close',
177
+ closed_at = datetime('now')
178
+ WHERE task_id = ? AND status = 'open'""",
179
+ (evidence[:4000], task_id),
180
+ )
181
+ closed.append(task_id)
182
+ return closed
183
+
184
+
155
185
  def main():
156
186
  init_db()
157
187
  conn = get_db()
@@ -161,9 +191,12 @@ def main():
161
191
  print(f"[{datetime.datetime.now().isoformat(timespec='seconds')}] No stale sessions")
162
192
  return
163
193
 
194
+ closed_task_ids: list[str] = []
164
195
  for session in orphans:
165
196
  sid = session["sid"]
166
197
  draft = get_diary_draft(sid)
198
+ closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
199
+ closed_task_ids.extend(closed_tasks)
167
200
 
168
201
  if draft:
169
202
  promote_draft_to_diary(sid, draft, task=session.get("task", ""))
@@ -196,7 +229,10 @@ def main():
196
229
  os.makedirs(os.path.dirname(AUTO_CLOSE_LOG), exist_ok=True)
197
230
  with open(AUTO_CLOSE_LOG, "a") as f:
198
231
  ts = datetime.datetime.now().isoformat(timespec="seconds")
199
- f.write(f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]}\n")
232
+ f.write(
233
+ f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]} "
234
+ f"and {len(closed_task_ids)} protocol task(s): {closed_task_ids}\n"
235
+ )
200
236
 
201
237
 
202
238
  if __name__ == "__main__":
@@ -704,10 +704,6 @@ def _client_assumption_regressions() -> list[str]:
704
704
  }
705
705
  offenders: list[str] = []
706
706
  for path in src_root.rglob("*.py"):
707
- try:
708
- text = path.read_text()
709
- except Exception:
710
- continue
711
707
  resolved = path.resolve()
712
708
  try:
713
709
  if resolved.is_relative_to(backup_root):
@@ -723,6 +719,12 @@ def _client_assumption_regressions() -> list[str]:
723
719
  relative_path = resolved.relative_to(src_root)
724
720
  except Exception:
725
721
  relative_path = path.relative_to(src_root)
722
+ if "versions" in relative_path.parts:
723
+ continue
724
+ try:
725
+ text = path.read_text()
726
+ except Exception:
727
+ continue
726
728
  if ".claude/projects" in text and relative_path not in allowed_relative_paths:
727
729
  offenders.append(f"{relative_path} hardcodes ~/.claude/projects")
728
730
  collect_path = src_root / "scripts" / "deep-sleep" / "collect.py"
@@ -0,0 +1,222 @@
1
+ """Durable sent-email continuity for NEXO automations.
2
+
3
+ The email monitor tracks inbound lifecycle rows. This module tracks outbound
4
+ messages so startup, duplicate checks, and briefings can see what NEXO already
5
+ sent even when the send path did not originate from an inbound email row.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import paths
17
+
18
+
19
+ EMAIL_SENT_TABLE_SQL = """
20
+ CREATE TABLE IF NOT EXISTS sent_email_events (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ message_id TEXT,
23
+ sender TEXT,
24
+ to_addrs TEXT NOT NULL DEFAULT '',
25
+ cc_addrs TEXT NOT NULL DEFAULT '',
26
+ subject TEXT NOT NULL DEFAULT '',
27
+ in_reply_to TEXT NOT NULL DEFAULT '',
28
+ references_header TEXT NOT NULL DEFAULT '',
29
+ source TEXT NOT NULL DEFAULT '',
30
+ status TEXT NOT NULL DEFAULT 'sent',
31
+ sent_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
32
+ meta TEXT NOT NULL DEFAULT '{}'
33
+ );
34
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_sent_email_message_id
35
+ ON sent_email_events(message_id)
36
+ WHERE message_id IS NOT NULL AND message_id != '';
37
+ CREATE INDEX IF NOT EXISTS idx_sent_email_sent_at ON sent_email_events(sent_at);
38
+ CREATE INDEX IF NOT EXISTS idx_sent_email_subject ON sent_email_events(subject);
39
+ """
40
+
41
+ RECENT_SENT_EMAILS_TITLE = "EMAILS ENVIADOS ULTIMAS 24H POR LA OPERATIVA"
42
+
43
+
44
+ def sent_email_db_path() -> Path:
45
+ return paths.nexo_email_dir() / "nexo-email.db"
46
+
47
+
48
+ def ensure_sent_email_table(conn: sqlite3.Connection) -> None:
49
+ conn.executescript(EMAIL_SENT_TABLE_SQL)
50
+
51
+
52
+ def _connect(db_path: str | Path | None = None) -> sqlite3.Connection:
53
+ path = Path(db_path) if db_path else sent_email_db_path()
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ conn = sqlite3.connect(path)
56
+ conn.row_factory = sqlite3.Row
57
+ ensure_sent_email_table(conn)
58
+ return conn
59
+
60
+
61
+ def _clean(value: object, limit: int = 500) -> str:
62
+ text = " ".join(str(value or "").split())
63
+ if len(text) <= limit:
64
+ return text
65
+ return text[: limit - 3].rstrip() + "..."
66
+
67
+
68
+ def _safe_meta(meta: dict[str, Any] | None) -> str:
69
+ try:
70
+ return json.dumps(meta or {}, ensure_ascii=True, sort_keys=True)
71
+ except Exception:
72
+ return "{}"
73
+
74
+
75
+ def _record_cognitive_memory(event: dict[str, str]) -> None:
76
+ try:
77
+ import cognitive
78
+
79
+ to_value = event.get("to_addrs", "")
80
+ subject = event.get("subject", "")
81
+ message_id = event.get("message_id", "")
82
+ content = (
83
+ "Sent email recorded by NEXO. "
84
+ f"To: {to_value}. Subject: {subject}. Message-ID: {message_id}."
85
+ )
86
+ cognitive.ingest_to_ltm(
87
+ content,
88
+ source_type="email_sent",
89
+ source_id=message_id or f"{to_value}:{subject}",
90
+ source_title=subject,
91
+ domain="email",
92
+ tags="email,sent,continuity",
93
+ bypass_gate=True,
94
+ )
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ def record_sent_email(
100
+ *,
101
+ message_id: str = "",
102
+ sender: str = "",
103
+ to_addrs: str = "",
104
+ cc_addrs: str = "",
105
+ subject: str = "",
106
+ in_reply_to: str = "",
107
+ references_header: str = "",
108
+ source: str = "",
109
+ status: str = "sent",
110
+ meta: dict[str, Any] | None = None,
111
+ db_path: str | Path | None = None,
112
+ record_memory: bool = True,
113
+ ) -> dict[str, str]:
114
+ event = {
115
+ "message_id": _clean(message_id, 300),
116
+ "sender": _clean(sender, 300),
117
+ "to_addrs": _clean(to_addrs, 800),
118
+ "cc_addrs": _clean(cc_addrs, 800),
119
+ "subject": _clean(subject, 500),
120
+ "in_reply_to": _clean(in_reply_to, 300),
121
+ "references_header": _clean(references_header, 1000),
122
+ "source": _clean(source or "unknown", 120),
123
+ "status": _clean(status or "sent", 80),
124
+ "meta": _safe_meta(meta),
125
+ }
126
+ conn = _connect(db_path)
127
+ try:
128
+ conn.execute(
129
+ """
130
+ INSERT OR REPLACE INTO sent_email_events (
131
+ message_id, sender, to_addrs, cc_addrs, subject, in_reply_to,
132
+ references_header, source, status, meta
133
+ )
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
135
+ """,
136
+ (
137
+ event["message_id"],
138
+ event["sender"],
139
+ event["to_addrs"],
140
+ event["cc_addrs"],
141
+ event["subject"],
142
+ event["in_reply_to"],
143
+ event["references_header"],
144
+ event["source"],
145
+ event["status"],
146
+ event["meta"],
147
+ ),
148
+ )
149
+ conn.commit()
150
+ finally:
151
+ conn.close()
152
+
153
+ if record_memory:
154
+ _record_cognitive_memory(event)
155
+ return event
156
+
157
+
158
+ def recent_sent_emails(
159
+ *,
160
+ hours: int = 24,
161
+ limit: int = 10,
162
+ db_path: str | Path | None = None,
163
+ ) -> list[dict[str, str]]:
164
+ cutoff = (datetime.now() - timedelta(hours=max(1, int(hours)))).strftime("%Y-%m-%d %H:%M:%S")
165
+ conn = _connect(db_path)
166
+ try:
167
+ rows = conn.execute(
168
+ """
169
+ SELECT message_id, sender, to_addrs, cc_addrs, subject, in_reply_to,
170
+ references_header, source, status, sent_at, meta
171
+ FROM sent_email_events
172
+ WHERE sent_at >= ?
173
+ ORDER BY sent_at DESC
174
+ LIMIT ?
175
+ """,
176
+ (cutoff, max(1, int(limit))),
177
+ ).fetchall()
178
+ return [dict(row) for row in rows]
179
+ finally:
180
+ conn.close()
181
+
182
+
183
+ def find_sent_email(
184
+ *,
185
+ to_addr: str = "",
186
+ subject: str = "",
187
+ since_hours: int = 72,
188
+ db_path: str | Path | None = None,
189
+ ) -> dict[str, str] | None:
190
+ target_to = _clean(to_addr).lower()
191
+ target_subject = _clean(subject).lower()
192
+ if not target_to or not target_subject:
193
+ return None
194
+ for event in recent_sent_emails(hours=since_hours, limit=100, db_path=db_path):
195
+ if target_to in str(event.get("to_addrs") or "").lower() and target_subject in str(event.get("subject") or "").lower():
196
+ return event
197
+ return None
198
+
199
+
200
+ def format_recent_sent_email_block(*, hours: int = 24, limit: int = 8) -> str:
201
+ rows = recent_sent_emails(hours=hours, limit=limit)
202
+ if not rows:
203
+ return ""
204
+ lines = [f"== {RECENT_SENT_EMAILS_TITLE} =="]
205
+ for row in rows:
206
+ sent_at = str(row.get("sent_at") or "")
207
+ to_value = _clean(row.get("to_addrs"), 120)
208
+ subject = _clean(row.get("subject"), 160)
209
+ source = _clean(row.get("source"), 80)
210
+ lines.append(f"- {sent_at} | to: {to_value} | subject: {subject} | source: {source}")
211
+ return "\n".join(lines)
212
+
213
+
214
+ __all__ = [
215
+ "RECENT_SENT_EMAILS_TITLE",
216
+ "ensure_sent_email_table",
217
+ "find_sent_email",
218
+ "format_recent_sent_email_block",
219
+ "recent_sent_emails",
220
+ "record_sent_email",
221
+ "sent_email_db_path",
222
+ ]
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  import paths
14
14
 
15
15
  from core_prompts import render_core_prompt
16
- from db import create_protocol_debt, get_db, get_last_heartbeat_ts
16
+ from db import create_protocol_debt, create_protocol_task, get_db, get_last_heartbeat_ts
17
17
  from operator_language import append_operator_language_contract
18
18
  from plugins.guard import _load_conditioned_learnings, _normalize_path_token
19
19
  from protocol_settings import get_protocol_strictness
@@ -429,6 +429,42 @@ def _strict_write_without_task_severity(session_id: str) -> str:
429
429
  return "error"
430
430
 
431
431
 
432
+ def _auto_task_open_enabled() -> bool:
433
+ return os.environ.get("NEXO_AUTO_TASK_OPEN", "1").strip().lower() not in {"0", "false", "no", "off"}
434
+
435
+
436
+ def _auto_open_protocol_task_for_write(*, sid: str, tool_name: str, operation: str, files: list[str]) -> dict | None:
437
+ if not sid or not _auto_task_open_enabled():
438
+ return None
439
+ task_type = "edit" if operation == "write" else "execute"
440
+ clean_files = [item for item in files if str(item or "").strip()]
441
+ target = ", ".join(clean_files[:3]) if clean_files else "unknown target"
442
+ if len(clean_files) > 3:
443
+ target += f", +{len(clean_files) - 3} more"
444
+ try:
445
+ return create_protocol_task(
446
+ sid,
447
+ f"Auto-opened {task_type} task for {tool_name} on {target}",
448
+ task_type=task_type,
449
+ area="auto",
450
+ context_hint="PreToolUse auto-task_open: write/delete attempted without a matching open task.",
451
+ files=clean_files,
452
+ plan=[
453
+ "Auto-opened because the agent attempted a write/delete before explicit task_open.",
454
+ "Verify the edit and close with evidence.",
455
+ ],
456
+ constraints=[
457
+ "Do not treat this auto-open as success evidence.",
458
+ "Close as done only after verification; otherwise close partial/failed.",
459
+ ],
460
+ verification_step="Run the relevant test or inspection and close with evidence.",
461
+ must_verify=True,
462
+ must_change_log=True,
463
+ )
464
+ except Exception:
465
+ return None
466
+
467
+
432
468
  def _resolve_runtime_path(path: str) -> Path:
433
469
  candidate = Path(str(path or "")).expanduser()
434
470
  if not candidate.is_absolute():
@@ -1599,6 +1635,24 @@ def process_pre_tool_event(payload: dict) -> dict:
1599
1635
  "status": "blocked",
1600
1636
  }
1601
1637
 
1638
+ auto_opened_task = None
1639
+ if files:
1640
+ missing_task_files = [filepath for filepath in files if not _find_open_task_for_file(conn, sid, filepath)]
1641
+ if missing_task_files:
1642
+ auto_opened_task = _auto_open_protocol_task_for_write(
1643
+ sid=sid,
1644
+ tool_name=tool_name,
1645
+ operation=op,
1646
+ files=missing_task_files,
1647
+ )
1648
+ elif not _find_any_open_task(conn, sid):
1649
+ auto_opened_task = _auto_open_protocol_task_for_write(
1650
+ sid=sid,
1651
+ tool_name=tool_name,
1652
+ operation=op,
1653
+ files=[],
1654
+ )
1655
+
1602
1656
  if not files:
1603
1657
  task = _find_any_open_task(conn, sid)
1604
1658
  if not task:
@@ -1628,6 +1682,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1628
1682
  "operation": op,
1629
1683
  "strictness": strictness,
1630
1684
  "blocks": blocks,
1685
+ "auto_opened_task": auto_opened_task,
1631
1686
  "status": "blocked" if blocks else "clean",
1632
1687
  }
1633
1688
 
@@ -1711,6 +1766,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1711
1766
  "operation": op,
1712
1767
  "strictness": strictness,
1713
1768
  "blocks": blocks,
1769
+ "auto_opened_task": auto_opened_task,
1714
1770
  "status": "blocked" if blocks else "clean",
1715
1771
  }
1716
1772
 
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Best-effort PostToolUse change_log recorder for write tools.
5
+
6
+ This hook records file edit visibility directly in the DB layer. It never
7
+ calls MCP tools and never blocks the client pipeline.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ _DIR = Path(__file__).resolve().parent
17
+ _SRC = _DIR.parent
18
+ if str(_SRC) not in sys.path:
19
+ sys.path.insert(0, str(_SRC))
20
+
21
+ WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
22
+
23
+
24
+ def _short_tool_name(tool_name: str) -> str:
25
+ clean = str(tool_name or "").strip()
26
+ return clean.rsplit("__", 1)[-1] if "__" in clean else clean
27
+
28
+
29
+ def _read_stdin_json() -> dict:
30
+ if sys.stdin.isatty():
31
+ return {}
32
+ try:
33
+ raw = sys.stdin.read()
34
+ if not raw.strip():
35
+ return {}
36
+ payload = json.loads(raw)
37
+ return payload if isinstance(payload, dict) else {}
38
+ except Exception:
39
+ return {}
40
+
41
+
42
+ def _tool_input(payload: dict) -> dict:
43
+ value = payload.get("tool_input") or payload.get("toolInput") or payload.get("input") or {}
44
+ return value if isinstance(value, dict) else {}
45
+
46
+
47
+ def _extract_file_paths(payload: dict) -> list[str]:
48
+ tool_input = _tool_input(payload)
49
+ candidates: list[str] = []
50
+ for key in ("file_path", "filePath", "path", "notebook_path", "notebookPath"):
51
+ value = tool_input.get(key)
52
+ if isinstance(value, str) and value.strip():
53
+ candidates.append(value.strip())
54
+ for key in ("file_paths", "filePaths", "paths"):
55
+ value = tool_input.get(key)
56
+ if isinstance(value, list):
57
+ candidates.extend(str(item).strip() for item in value if str(item).strip())
58
+ seen: set[str] = set()
59
+ paths: list[str] = []
60
+ for item in candidates:
61
+ if item in seen:
62
+ continue
63
+ seen.add(item)
64
+ paths.append(item)
65
+ return paths
66
+
67
+
68
+ def _resolve_sid_from_payload(payload: dict) -> str:
69
+ candidates: list[str] = []
70
+ for key in ("nexo_sid", "sid", "session_id", "sessionId"):
71
+ value = payload.get(key)
72
+ if isinstance(value, str) and value.strip():
73
+ candidates.append(value.strip())
74
+ for key in ("NEXO_SID", "CLAUDE_SESSION_ID"):
75
+ value = os.environ.get(key, "").strip()
76
+ if value:
77
+ candidates.append(value)
78
+ try:
79
+ from db import resolve_sid_from_external
80
+ except Exception:
81
+ return ""
82
+ for candidate in candidates:
83
+ if candidate.startswith("nexo-"):
84
+ return candidate
85
+ resolved = resolve_sid_from_external(candidate)
86
+ if resolved:
87
+ return resolved
88
+ return ""
89
+
90
+
91
+ def record_post_edit_change(payload: dict) -> dict:
92
+ """Record a minimal change_log row for write-like tool payloads."""
93
+ tool_name = _short_tool_name(str(payload.get("tool_name") or payload.get("toolName") or ""))
94
+ if tool_name not in WRITE_TOOLS:
95
+ return {"ok": True, "skipped": True, "reason": "tool_not_write"}
96
+ if os.environ.get("NEXO_AUTO_CHANGE_LOG", "1").strip().lower() in {"0", "false", "no", "off"}:
97
+ return {"ok": True, "skipped": True, "reason": "disabled"}
98
+
99
+ paths = _extract_file_paths(payload)
100
+ if not paths:
101
+ return {"ok": True, "skipped": True, "reason": "missing_file_path"}
102
+
103
+ try:
104
+ from db import init_db, log_change
105
+ except Exception as exc:
106
+ return {"ok": False, "error": f"db_import_failed: {exc}"}
107
+
108
+ try:
109
+ init_db()
110
+ sid = _resolve_sid_from_payload(payload) or "unknown"
111
+ files = ", ".join(paths)
112
+ result = log_change(
113
+ sid,
114
+ files,
115
+ f"Auto-recorded PostToolUse {tool_name} file edit",
116
+ "PostToolUse observed a file write; recording traceability even if the agent forgets nexo_change_log.",
117
+ triggered_by="post_edit_change_log.py",
118
+ affects=files,
119
+ risks="Automatic trace only; verify the actual diff and tests separately.",
120
+ verify="Inspect git diff and run the relevant tests for the edited file.",
121
+ commit_ref="",
122
+ )
123
+ if "error" in result:
124
+ return {"ok": False, "error": result["error"]}
125
+ return {"ok": True, "change_log_id": result.get("id"), "files": paths}
126
+ except Exception as exc:
127
+ return {"ok": False, "error": str(exc)}
128
+
129
+
130
+ def main() -> int:
131
+ record_post_edit_change(_read_stdin_json())
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())
@@ -208,6 +208,21 @@ def _run_auto_capture(payload: dict) -> int:
208
208
  return 1
209
209
 
210
210
 
211
+ def _run_post_edit_change_log(payload: dict) -> int:
212
+ """Record write-tool visibility without calling MCP from the hook."""
213
+ try:
214
+ proc = subprocess.run(
215
+ ["python3", str(_DIR / "post_edit_change_log.py")],
216
+ input=json.dumps(payload),
217
+ capture_output=True,
218
+ text=True,
219
+ timeout=5,
220
+ )
221
+ return proc.returncode
222
+ except Exception:
223
+ return 1
224
+
225
+
211
226
  def main() -> int:
212
227
  started = time.time()
213
228
  payload = _read_stdin_json()
@@ -231,6 +246,7 @@ def main() -> int:
231
246
  protocol_message = stdout
232
247
 
233
248
  exits.append(_run_auto_capture(payload))
249
+ exits.append(_run_post_edit_change_log(payload))
234
250
 
235
251
  # v6.0.1 — inbox autodetect runs LAST so it sees the latest DB state
236
252
  # (including any writes the previous steps may have done). Emits a