nexo-brain 2.6.13 → 2.6.15

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.
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ """Managed bootstrap documents for Claude Code and Codex."""
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from client_preferences import (
11
+ BACKEND_NONE,
12
+ CLIENT_CLAUDE_CODE,
13
+ CLIENT_CODEX,
14
+ INTERACTIVE_CLIENT_KEYS,
15
+ normalize_backend_key,
16
+ normalize_client_key,
17
+ normalize_client_preferences,
18
+ )
19
+
20
+ def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
21
+ module_dir = Path(module_file).resolve().parent
22
+ direct = module_dir / "templates"
23
+ if direct.is_dir():
24
+ return direct
25
+ parent = module_dir.parent / "templates"
26
+ if parent.is_dir():
27
+ return parent
28
+ return direct
29
+
30
+
31
+ TEMPLATES_DIR = _resolve_templates_dir(__file__)
32
+
33
+ CORE_LABEL = "******CORE******"
34
+ USER_LABEL = "******USER******"
35
+ CORE_START = "<!-- nexo:core:start -->"
36
+ CORE_END = "<!-- nexo:core:end -->"
37
+ USER_START = "<!-- nexo:user:start -->"
38
+ USER_END = "<!-- nexo:user:end -->"
39
+
40
+ BOOTSTRAP_SPECS = {
41
+ CLIENT_CLAUDE_CODE: {
42
+ "label": "Claude Code",
43
+ "template": TEMPLATES_DIR / "CLAUDE.md.template",
44
+ "target_parts": (".claude", "CLAUDE.md"),
45
+ "version_pattern": r"nexo-claude-md-version:\s*([\d.]+)",
46
+ "version_file": "claude_md_version.txt",
47
+ },
48
+ CLIENT_CODEX: {
49
+ "label": "Codex",
50
+ "template": TEMPLATES_DIR / "CODEX.AGENTS.md.template",
51
+ "target_parts": (".codex", "AGENTS.md"),
52
+ "version_pattern": r"nexo-codex-agents-version:\s*([\d.]+)",
53
+ "version_file": "codex_agents_version.txt",
54
+ },
55
+ }
56
+
57
+
58
+ def _user_home() -> Path:
59
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
60
+
61
+
62
+ def _default_nexo_home() -> Path:
63
+ return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
64
+
65
+
66
+ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
67
+ explicit = (explicit or "").strip()
68
+ if explicit:
69
+ return explicit
70
+ env_name = os.environ.get("NEXO_NAME", "").strip()
71
+ if env_name:
72
+ return env_name
73
+ version_file = nexo_home / "version.json"
74
+ if version_file.is_file():
75
+ try:
76
+ return str(json.loads(version_file.read_text()).get("operator_name", "")).strip()
77
+ except Exception:
78
+ pass
79
+ return "NEXO"
80
+
81
+
82
+ def _read_version(text: str, pattern: str) -> str:
83
+ match = re.search(pattern, text)
84
+ return match.group(1) if match else ""
85
+
86
+
87
+ def _template_version_comment(text: str) -> str:
88
+ first_line = text.splitlines()[0] if text else ""
89
+ return first_line if first_line.startswith("<!--") and "version:" in first_line else ""
90
+
91
+
92
+ def _strip_version_comment(text: str) -> str:
93
+ return re.sub(r"^<!--\s*nexo-[^-]+(?:-[^-]+)*-version:\s*[\d.]+\s*-->\s*\n?", "", text, count=1)
94
+
95
+
96
+ def _extract_block(text: str, start: str, end: str) -> str:
97
+ pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
98
+ match = pattern.search(text)
99
+ return match.group(0) if match else ""
100
+
101
+
102
+ def _replace_block(text: str, start: str, end: str, replacement: str) -> str:
103
+ pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
104
+ if pattern.search(text):
105
+ return pattern.sub(replacement, text, count=1)
106
+ return text.rstrip() + "\n\n" + replacement.rstrip() + "\n"
107
+
108
+
109
+ def _build_user_block(user_payload: str, template_text: str) -> str:
110
+ template_user = _extract_block(template_text, USER_START, USER_END)
111
+ if not user_payload.strip():
112
+ return template_user
113
+ return f"{USER_START}\n{user_payload.rstrip()}\n{USER_END}"
114
+
115
+
116
+ def _legacy_user_payload(existing_text: str) -> str:
117
+ cleaned = re.sub(r"<!--\s*nexo-[^-]+-[^-]+-version:\s*[\d.]+\s*-->\s*", "", existing_text)
118
+ if "<!-- nexo:start:" in cleaned:
119
+ first_section = re.search(r"<!-- nexo:start:\w+ -->", cleaned)
120
+ prefix = cleaned[: first_section.start()] if first_section else ""
121
+ remainder = cleaned[first_section.start() :] if first_section else ""
122
+ remainder = re.sub(
123
+ r"<!-- nexo:start:\w+ -->.*?<!-- nexo:end:\w+ -->",
124
+ "",
125
+ remainder,
126
+ flags=re.DOTALL,
127
+ )
128
+ residue = "\n".join([prefix, remainder]).strip()
129
+ filtered_lines: list[str] = []
130
+ for line in residue.splitlines():
131
+ stripped = line.strip()
132
+ if not stripped:
133
+ filtered_lines.append("")
134
+ continue
135
+ if stripped.startswith("# ") and "Cognitive Co-Operator" in stripped:
136
+ continue
137
+ if stripped.startswith("I am ") and "powered by NEXO Brain" in stripped:
138
+ continue
139
+ if "Tool-coupled behavioral rules" in stripped:
140
+ continue
141
+ filtered_lines.append(line)
142
+ residue = "\n".join(filtered_lines).strip()
143
+ return residue
144
+ return cleaned.strip()
145
+
146
+
147
+ def _target_path(client: str, *, user_home: Path | None = None) -> Path:
148
+ spec = BOOTSTRAP_SPECS[client]
149
+ home = user_home or _user_home()
150
+ return home.joinpath(*spec["target_parts"])
151
+
152
+
153
+ def _version_tracker_path(nexo_home: Path, client: str) -> Path:
154
+ return nexo_home / "data" / BOOTSTRAP_SPECS[client]["version_file"]
155
+
156
+
157
+ def render_bootstrap_template(
158
+ client: str,
159
+ *,
160
+ nexo_home: str | os.PathLike[str] | None = None,
161
+ operator_name: str = "",
162
+ ) -> str:
163
+ client_key = normalize_client_key(client)
164
+ spec = BOOTSTRAP_SPECS[client_key]
165
+ template_text = spec["template"].read_text()
166
+ nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
167
+ name = _resolve_operator_name(nexo_home_path, explicit=operator_name)
168
+ return (
169
+ template_text
170
+ .replace("{{NAME}}", name)
171
+ .replace("{{NEXO_HOME}}", str(nexo_home_path))
172
+ )
173
+
174
+
175
+ def load_bootstrap_prompt(
176
+ client: str,
177
+ *,
178
+ nexo_home: str | os.PathLike[str] | None = None,
179
+ operator_name: str = "",
180
+ user_home: str | os.PathLike[str] | None = None,
181
+ ) -> str:
182
+ client_key = normalize_client_key(client)
183
+ if client_key not in BOOTSTRAP_SPECS:
184
+ return ""
185
+
186
+ home_path = Path(user_home).expanduser() if user_home else _user_home()
187
+ target_path = _target_path(client_key, user_home=home_path)
188
+ if target_path.exists():
189
+ text = target_path.read_text()
190
+ if text.strip():
191
+ return _strip_version_comment(text).strip()
192
+
193
+ rendered = render_bootstrap_template(
194
+ client_key,
195
+ nexo_home=nexo_home,
196
+ operator_name=operator_name,
197
+ )
198
+ return _strip_version_comment(rendered).strip()
199
+
200
+
201
+ def sync_client_bootstrap(
202
+ client: str,
203
+ *,
204
+ nexo_home: str | os.PathLike[str] | None = None,
205
+ operator_name: str = "",
206
+ user_home: str | os.PathLike[str] | None = None,
207
+ ) -> dict:
208
+ client_key = normalize_client_key(client)
209
+ if client_key not in BOOTSTRAP_SPECS:
210
+ return {"ok": False, "client": client_key or str(client), "error": "unsupported bootstrap target"}
211
+
212
+ nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
213
+ home_path = Path(user_home).expanduser() if user_home else _user_home()
214
+ target_path = _target_path(client_key, user_home=home_path)
215
+ target_path.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ rendered = render_bootstrap_template(
218
+ client_key,
219
+ nexo_home=nexo_home_path,
220
+ operator_name=operator_name,
221
+ )
222
+ template_version = _read_version(rendered, BOOTSTRAP_SPECS[client_key]["version_pattern"])
223
+ rendered_core = _extract_block(rendered, CORE_START, CORE_END)
224
+ if not rendered_core:
225
+ return {"ok": False, "client": client_key, "path": str(target_path), "error": "template missing CORE block"}
226
+
227
+ if not target_path.exists() or not target_path.read_text().strip():
228
+ target_path.write_text(rendered)
229
+ if template_version:
230
+ tracker = _version_tracker_path(nexo_home_path, client_key)
231
+ tracker.parent.mkdir(parents=True, exist_ok=True)
232
+ tracker.write_text(template_version)
233
+ return {
234
+ "ok": True,
235
+ "client": client_key,
236
+ "action": "created",
237
+ "path": str(target_path),
238
+ "version": template_version,
239
+ }
240
+
241
+ existing = target_path.read_text()
242
+ if CORE_START in existing and CORE_END in existing and USER_START in existing and USER_END in existing:
243
+ user_block = _extract_block(existing, USER_START, USER_END) or _extract_block(rendered, USER_START, USER_END)
244
+ updated = _replace_block(existing, CORE_START, CORE_END, rendered_core)
245
+ updated = _replace_block(updated, USER_START, USER_END, user_block)
246
+ action = "updated" if updated != existing else "unchanged"
247
+ else:
248
+ legacy_user = _legacy_user_payload(existing)
249
+ updated = _replace_block(rendered, USER_START, USER_END, _build_user_block(legacy_user, rendered))
250
+ action = "migrated"
251
+
252
+ if template_version:
253
+ comment = _template_version_comment(rendered)
254
+ if comment:
255
+ updated = re.sub(r"<!--\s*nexo-[^-]+-[^-]+-version:\s*[\d.]+\s*-->", comment, updated, count=1)
256
+ if comment not in updated:
257
+ updated = comment + "\n" + updated.lstrip()
258
+
259
+ if updated != existing:
260
+ backup_path = target_path.with_suffix(target_path.suffix + ".bak")
261
+ try:
262
+ backup_path.write_text(existing)
263
+ except Exception:
264
+ pass
265
+ target_path.write_text(updated)
266
+
267
+ if template_version:
268
+ tracker = _version_tracker_path(nexo_home_path, client_key)
269
+ tracker.parent.mkdir(parents=True, exist_ok=True)
270
+ tracker.write_text(template_version)
271
+
272
+ return {
273
+ "ok": True,
274
+ "client": client_key,
275
+ "action": action,
276
+ "path": str(target_path),
277
+ "version": template_version,
278
+ }
279
+
280
+
281
+ def sync_enabled_bootstraps(
282
+ *,
283
+ nexo_home: str | os.PathLike[str] | None = None,
284
+ operator_name: str = "",
285
+ user_home: str | os.PathLike[str] | None = None,
286
+ enabled_clients: list[str] | tuple[str, ...] | set[str] | None = None,
287
+ preferences: dict | None = None,
288
+ ) -> dict[str, dict]:
289
+ if enabled_clients is None:
290
+ if preferences is None:
291
+ enabled = {CLIENT_CLAUDE_CODE, CLIENT_CODEX}
292
+ else:
293
+ prefs = normalize_client_preferences(preferences)
294
+ enabled = {
295
+ key
296
+ for key in INTERACTIVE_CLIENT_KEYS
297
+ if prefs.get("interactive_clients", {}).get(key, False)
298
+ and key in BOOTSTRAP_SPECS
299
+ }
300
+ backend = normalize_backend_key(prefs.get("automation_backend"))
301
+ if prefs.get("automation_enabled", True) and backend and backend != BACKEND_NONE:
302
+ if backend in BOOTSTRAP_SPECS:
303
+ enabled.add(backend)
304
+ if not enabled:
305
+ enabled.add(CLIENT_CLAUDE_CODE)
306
+ else:
307
+ enabled = {normalize_client_key(item) for item in enabled_clients if normalize_client_key(item) in BOOTSTRAP_SPECS}
308
+ if not enabled:
309
+ enabled = {CLIENT_CLAUDE_CODE}
310
+
311
+ results: dict[str, dict] = {}
312
+ for client_key in (CLIENT_CLAUDE_CODE, CLIENT_CODEX):
313
+ if client_key not in enabled:
314
+ results[client_key] = {
315
+ "ok": True,
316
+ "client": client_key,
317
+ "skipped": True,
318
+ "reason": "disabled in client preferences",
319
+ "path": str(_target_path(client_key, user_home=Path(user_home).expanduser() if user_home else None)),
320
+ }
321
+ continue
322
+ try:
323
+ results[client_key] = sync_client_bootstrap(
324
+ client_key,
325
+ nexo_home=nexo_home,
326
+ operator_name=operator_name,
327
+ user_home=user_home,
328
+ )
329
+ except Exception as exc:
330
+ results[client_key] = {"ok": False, "client": client_key, "error": str(exc)}
331
+ return results
332
+
333
+
334
+ def get_bootstrap_status(
335
+ client: str,
336
+ *,
337
+ nexo_home: str | os.PathLike[str] | None = None,
338
+ user_home: str | os.PathLike[str] | None = None,
339
+ ) -> dict:
340
+ client_key = normalize_client_key(client)
341
+ if client_key not in BOOTSTRAP_SPECS:
342
+ return {"client": client_key or str(client), "supported": False}
343
+
344
+ nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
345
+ home_path = Path(user_home).expanduser() if user_home else _user_home()
346
+ target_path = _target_path(client_key, user_home=home_path)
347
+ template_text = render_bootstrap_template(client_key, nexo_home=nexo_home_path)
348
+ template_version = _read_version(template_text, BOOTSTRAP_SPECS[client_key]["version_pattern"])
349
+ status = {
350
+ "client": client_key,
351
+ "path": str(target_path),
352
+ "exists": target_path.exists(),
353
+ "supported": True,
354
+ "template_version": template_version,
355
+ "version": "",
356
+ "markers_ok": False,
357
+ "user_block_ok": False,
358
+ }
359
+ if not target_path.exists():
360
+ return status
361
+
362
+ text = target_path.read_text()
363
+ status["version"] = _read_version(text, BOOTSTRAP_SPECS[client_key]["version_pattern"])
364
+ status["markers_ok"] = CORE_START in text and CORE_END in text and USER_START in text and USER_END in text
365
+ status["user_block_ok"] = USER_START in text and USER_END in text
366
+ return status
package/src/cli.py CHANGED
@@ -859,6 +859,8 @@ def _chat(args):
859
859
  print(f"[NEXO] {preflight['git_update']}", file=sys.stderr)
860
860
  elif preflight.get("npm_notice"):
861
861
  print(f"[NEXO] {preflight['npm_notice']}", file=sys.stderr)
862
+ for message in preflight.get("client_bootstrap_updates", []):
863
+ print(f"[NEXO] {message}", file=sys.stderr)
862
864
  if preflight.get("error"):
863
865
  print(f"[NEXO] Startup preflight warning: {preflight['error']}", file=sys.stderr)
864
866
  except Exception:
@@ -1017,7 +1019,7 @@ Commands:
1017
1019
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
1018
1020
  Personal scripts
1019
1021
  nexo skills list|apply|sync|approve Executable skills
1020
- nexo clients sync Sync Claude Code/Desktop/Codex MCP configs
1022
+ nexo clients sync Sync Claude/Codex shared-brain configs and bootstrap files
1021
1023
  nexo update Update installed runtime
1022
1024
  nexo contributor status|on|off Public Draft PR contribution mode
1023
1025
  nexo dashboard on|off|status Web dashboard control
@@ -10,6 +10,8 @@ import subprocess
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
+ from bootstrap_docs import sync_client_bootstrap
14
+
13
15
  try:
14
16
  from client_preferences import (
15
17
  BACKEND_NONE,
@@ -202,11 +204,22 @@ def sync_claude_code(
202
204
  python_path=python_path,
203
205
  operator_name=operator_name,
204
206
  )
205
- return _sync_json_client(
207
+ result = _sync_json_client(
206
208
  _claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
207
209
  server_config,
208
210
  "claude_code",
209
211
  )
212
+ bootstrap_result = sync_client_bootstrap(
213
+ "claude_code",
214
+ nexo_home=nexo_home,
215
+ operator_name=operator_name,
216
+ user_home=user_home,
217
+ )
218
+ result["bootstrap"] = bootstrap_result
219
+ if not bootstrap_result.get("ok"):
220
+ result["ok"] = False
221
+ result["error"] = bootstrap_result.get("error", "Claude bootstrap sync failed")
222
+ return result
210
223
 
211
224
 
212
225
  def sync_claude_desktop(
@@ -249,13 +262,21 @@ def sync_codex(
249
262
  codex_bin = shutil.which("codex")
250
263
  config_path = _codex_config_path(home_path)
251
264
  if not codex_bin:
252
- return {
265
+ result = {
253
266
  "ok": True,
254
267
  "client": "codex",
255
268
  "skipped": True,
256
269
  "reason": "codex binary not found in PATH",
257
270
  "path": str(config_path),
258
271
  }
272
+ bootstrap_result = sync_client_bootstrap(
273
+ "codex",
274
+ nexo_home=nexo_home,
275
+ operator_name=operator_name,
276
+ user_home=user_home,
277
+ )
278
+ result["bootstrap"] = bootstrap_result
279
+ return result
259
280
 
260
281
  cmd = [codex_bin, "mcp", "add", "nexo"]
261
282
  for key, value in sorted(server_config.get("env", {}).items()):
@@ -270,19 +291,40 @@ def sync_codex(
270
291
  env=env,
271
292
  )
272
293
  if result.returncode != 0:
273
- return {
294
+ result = {
274
295
  "ok": False,
275
296
  "client": "codex",
276
297
  "path": str(config_path),
277
298
  "error": (result.stderr or result.stdout or "codex mcp add failed").strip(),
278
299
  }
279
- return {
300
+ bootstrap_result = sync_client_bootstrap(
301
+ "codex",
302
+ nexo_home=nexo_home,
303
+ operator_name=operator_name,
304
+ user_home=user_home,
305
+ )
306
+ result["bootstrap"] = bootstrap_result
307
+ if not bootstrap_result.get("ok"):
308
+ result["error"] = f"{result['error']}; bootstrap: {bootstrap_result.get('error', 'unknown error')}"
309
+ return result
310
+ sync_result = {
280
311
  "ok": True,
281
312
  "client": "codex",
282
313
  "action": "updated",
283
314
  "path": str(config_path),
284
315
  "mode": "cli",
285
316
  }
317
+ bootstrap_result = sync_client_bootstrap(
318
+ "codex",
319
+ nexo_home=nexo_home_path,
320
+ operator_name=operator_name,
321
+ user_home=home_path,
322
+ )
323
+ sync_result["bootstrap"] = bootstrap_result
324
+ if not bootstrap_result.get("ok"):
325
+ sync_result["ok"] = False
326
+ sync_result["error"] = bootstrap_result.get("error", "Codex bootstrap sync failed")
327
+ return sync_result
286
328
 
287
329
 
288
330
  def sync_all_clients(
@@ -363,9 +405,19 @@ def format_sync_summary(result: dict) -> str:
363
405
  item = result.get("clients", {}).get(key, {})
364
406
  label = labels[key]
365
407
  if item.get("skipped"):
366
- lines.append(f" {label}: skipped ({item.get('reason', 'not available')})")
408
+ bootstrap = item.get("bootstrap", {})
409
+ if bootstrap.get("ok") and not bootstrap.get("skipped"):
410
+ lines.append(
411
+ f" {label}: skipped ({item.get('reason', 'not available')}); bootstrap {bootstrap.get('action', 'synced')} -> {bootstrap.get('path', '')}"
412
+ )
413
+ else:
414
+ lines.append(f" {label}: skipped ({item.get('reason', 'not available')})")
367
415
  elif item.get("ok"):
368
- lines.append(f" {label}: {item.get('action', 'synced')} -> {item.get('path', '')}")
416
+ bootstrap = item.get("bootstrap", {})
417
+ suffix = ""
418
+ if bootstrap.get("ok"):
419
+ suffix = f"; bootstrap {bootstrap.get('action', 'synced')} -> {bootstrap.get('path', '')}"
420
+ lines.append(f" {label}: {item.get('action', 'synced')} -> {item.get('path', '')}{suffix}")
369
421
  else:
370
422
  lines.append(f" {label}: ERROR -> {item.get('error', 'unknown error')}")
371
423
  return "\n".join(lines)
@@ -982,6 +982,176 @@ def check_client_backend_preferences() -> DoctorCheck:
982
982
  )
983
983
 
984
984
 
985
+ def check_client_bootstrap_parity(fix: bool = False) -> DoctorCheck:
986
+ """Check managed Claude/Codex bootstrap documents and CORE/USER markers."""
987
+ try:
988
+ from bootstrap_docs import get_bootstrap_status, sync_enabled_bootstraps
989
+ except Exception as e:
990
+ return DoctorCheck(
991
+ id="runtime.client_bootstrap",
992
+ tier="runtime",
993
+ status="degraded",
994
+ severity="warn",
995
+ summary=f"Bootstrap check unavailable: {e}",
996
+ )
997
+
998
+ try:
999
+ schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
1000
+ except Exception:
1001
+ schedule = {}
1002
+ prefs = normalize_client_preferences(schedule)
1003
+ detected = detect_installed_clients()
1004
+
1005
+ relevant: set[str] = set()
1006
+ default_terminal = prefs["default_terminal_client"]
1007
+ if default_terminal in {"claude_code", "codex"}:
1008
+ relevant.add(default_terminal)
1009
+ if prefs.get("automation_enabled", True):
1010
+ backend = prefs.get("automation_backend")
1011
+ if backend in {"claude_code", "codex"}:
1012
+ relevant.add(backend)
1013
+ for client_key, enabled in prefs.get("interactive_clients", {}).items():
1014
+ if enabled and client_key in {"claude_code", "codex"}:
1015
+ relevant.add(client_key)
1016
+ if not relevant:
1017
+ relevant.add("claude_code")
1018
+
1019
+ evidence: list[str] = []
1020
+ repair_plan: list[str] = []
1021
+ status = "healthy"
1022
+ severity = "info"
1023
+
1024
+ def _evaluate() -> list[tuple[str, dict]]:
1025
+ return [
1026
+ (client_key, get_bootstrap_status(client_key, nexo_home=NEXO_HOME, user_home=Path.home()))
1027
+ for client_key in sorted(relevant)
1028
+ ]
1029
+
1030
+ evaluated = _evaluate()
1031
+ for client_key, info in evaluated:
1032
+ installed = detected.get(client_key, {}).get("installed", False)
1033
+ if not installed and client_key in {default_terminal, prefs.get("automation_backend")}:
1034
+ status = "degraded"
1035
+ severity = "warn"
1036
+ evidence.append(f"`{client_key}` selected but not installed; bootstrap parity cannot be verified")
1037
+ continue
1038
+ if not info.get("exists"):
1039
+ status = "degraded"
1040
+ severity = "warn"
1041
+ evidence.append(f"`{client_key}` bootstrap missing at {info.get('path')}")
1042
+ repair_plan.append("Run `nexo clients sync` or `nexo update` to regenerate client bootstrap files")
1043
+ continue
1044
+ if not info.get("markers_ok"):
1045
+ status = "degraded"
1046
+ severity = "warn"
1047
+ evidence.append(f"`{client_key}` bootstrap lacks CORE/USER markers")
1048
+ repair_plan.append("Migrate bootstrap files so NEXO owns CORE and preserves USER")
1049
+ continue
1050
+ if info.get("template_version") and info.get("version") != info.get("template_version"):
1051
+ status = "degraded"
1052
+ severity = "warn"
1053
+ evidence.append(
1054
+ f"`{client_key}` bootstrap version {info.get('version') or 'unknown'} != template {info.get('template_version')}"
1055
+ )
1056
+ repair_plan.append("Refresh bootstrap files from the current NEXO templates")
1057
+
1058
+ if fix and status != "healthy":
1059
+ sync_enabled_bootstraps(
1060
+ nexo_home=NEXO_HOME,
1061
+ user_home=Path.home(),
1062
+ preferences=prefs,
1063
+ )
1064
+ post = check_client_bootstrap_parity(fix=False)
1065
+ if post.status == "healthy":
1066
+ post.fixed = True
1067
+ post.summary += " (fixed)"
1068
+ return post
1069
+
1070
+ return DoctorCheck(
1071
+ id="runtime.client_bootstrap",
1072
+ tier="runtime",
1073
+ status=status,
1074
+ severity=severity,
1075
+ summary="Client bootstrap parity OK" if status == "healthy" else "Client bootstrap parity needs attention",
1076
+ evidence=evidence or [
1077
+ f"{client_key}: {info.get('path')}"
1078
+ for client_key, info in evaluated
1079
+ ],
1080
+ repair_plan=repair_plan,
1081
+ escalation_prompt=(
1082
+ "Claude/Codex startup bootstrap files are missing, outdated, or lack the CORE/USER contract. "
1083
+ "Repair them so updates can refresh product rules without clobbering operator-specific instructions."
1084
+ ) if status != "healthy" else "",
1085
+ )
1086
+
1087
+
1088
+ def check_transcript_source_parity() -> DoctorCheck:
1089
+ """Check whether Deep Sleep can see transcript sources for the selected clients."""
1090
+ try:
1091
+ schedule = _load_json(SCHEDULE_FILE) if SCHEDULE_FILE.is_file() else {}
1092
+ except Exception:
1093
+ schedule = {}
1094
+ prefs = normalize_client_preferences(schedule)
1095
+
1096
+ wants_codex = bool(
1097
+ prefs.get("interactive_clients", {}).get("codex")
1098
+ or prefs.get("default_terminal_client") == "codex"
1099
+ or (prefs.get("automation_enabled", True) and prefs.get("automation_backend") == "codex")
1100
+ )
1101
+ wants_claude = bool(
1102
+ prefs.get("interactive_clients", {}).get("claude_code")
1103
+ or prefs.get("default_terminal_client") == "claude_code"
1104
+ or (prefs.get("automation_enabled", True) and prefs.get("automation_backend") == "claude_code")
1105
+ )
1106
+
1107
+ claude_root = Path.home() / ".claude" / "projects"
1108
+ codex_roots = [
1109
+ Path.home() / ".codex" / "sessions",
1110
+ Path.home() / ".codex" / "archived_sessions",
1111
+ ]
1112
+
1113
+ evidence = []
1114
+ status = "healthy"
1115
+ severity = "info"
1116
+ if wants_claude:
1117
+ evidence.append(f"claude_code transcripts: {'present' if claude_root.exists() else 'missing'} at {claude_root}")
1118
+ if wants_codex:
1119
+ codex_present = any(root.exists() for root in codex_roots)
1120
+ evidence.append(
1121
+ "codex transcripts: "
1122
+ + ("present" if codex_present else "missing")
1123
+ + f" at {', '.join(str(root) for root in codex_roots)}"
1124
+ )
1125
+ if not codex_present:
1126
+ status = "degraded"
1127
+ severity = "warn"
1128
+
1129
+ summary = "Deep Sleep transcript sources available"
1130
+ repair_plan = []
1131
+ escalation_prompt = ""
1132
+ if status != "healthy":
1133
+ summary = "Deep Sleep transcript source parity needs attention"
1134
+ repair_plan = [
1135
+ "Start at least one Codex session so ~/.codex/sessions is created",
1136
+ "If Codex sessions already exist elsewhere, update the collector before relying on Codex-only transcript analysis",
1137
+ ]
1138
+ escalation_prompt = (
1139
+ "Codex is selected, but no durable Codex session store is visible under ~/.codex. "
1140
+ "Deep Sleep can still use DB artifacts, but transcript-level overnight analysis will be limited until Codex session files exist."
1141
+ )
1142
+
1143
+ return DoctorCheck(
1144
+ id="runtime.transcript_sources",
1145
+ tier="runtime",
1146
+ status=status,
1147
+ severity=severity,
1148
+ summary=summary,
1149
+ evidence=evidence,
1150
+ repair_plan=repair_plan,
1151
+ escalation_prompt=escalation_prompt,
1152
+ )
1153
+
1154
+
985
1155
  def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
986
1156
  """Run all runtime-tier checks. Read-only by default."""
987
1157
  return [
@@ -990,6 +1160,8 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
990
1160
  check_stale_sessions(),
991
1161
  check_cron_freshness(),
992
1162
  check_client_backend_preferences(),
1163
+ check_client_bootstrap_parity(fix=fix),
1164
+ check_transcript_source_parity(),
993
1165
  check_launchagent_integrity(fix=fix),
994
1166
  check_personal_script_registry(fix=fix),
995
1167
  check_skill_health(fix=fix),