nexo-brain 2.6.13 → 2.6.14
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 +34 -4
- package/bin/nexo-brain.js +1 -0
- package/package.json +3 -2
- package/src/agent_runner.py +19 -0
- package/src/auto_update.py +62 -77
- package/src/bootstrap_docs.py +357 -0
- package/src/cli.py +3 -1
- package/src/client_sync.py +58 -6
- package/src/doctor/providers/runtime.py +172 -0
- package/src/evolution_cycle.py +4 -4
- package/src/hooks/session-start.sh +16 -0
- package/src/scripts/deep-sleep/collect.py +162 -24
- package/src/scripts/deep-sleep/extract.py +33 -6
- package/src/scripts/nexo-daily-self-audit.py +1 -1
- package/src/scripts/nexo-deep-sleep.sh +2 -2
- package/src/scripts/nexo-evolution-run.py +5 -2
- package/src/scripts/nexo-watchdog.sh +1 -1
- package/src/server.py +9 -2
- package/src/tools_sessions.py +3 -2
- package/templates/CLAUDE.md.template +34 -10
- package/templates/CODEX.AGENTS.md.template +45 -0
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
|
|
21
|
+
REPO_DIR = Path(__file__).resolve().parents[1]
|
|
22
|
+
TEMPLATES_DIR = REPO_DIR / "templates"
|
|
23
|
+
|
|
24
|
+
CORE_LABEL = "******CORE******"
|
|
25
|
+
USER_LABEL = "******USER******"
|
|
26
|
+
CORE_START = "<!-- nexo:core:start -->"
|
|
27
|
+
CORE_END = "<!-- nexo:core:end -->"
|
|
28
|
+
USER_START = "<!-- nexo:user:start -->"
|
|
29
|
+
USER_END = "<!-- nexo:user:end -->"
|
|
30
|
+
|
|
31
|
+
BOOTSTRAP_SPECS = {
|
|
32
|
+
CLIENT_CLAUDE_CODE: {
|
|
33
|
+
"label": "Claude Code",
|
|
34
|
+
"template": TEMPLATES_DIR / "CLAUDE.md.template",
|
|
35
|
+
"target_parts": (".claude", "CLAUDE.md"),
|
|
36
|
+
"version_pattern": r"nexo-claude-md-version:\s*([\d.]+)",
|
|
37
|
+
"version_file": "claude_md_version.txt",
|
|
38
|
+
},
|
|
39
|
+
CLIENT_CODEX: {
|
|
40
|
+
"label": "Codex",
|
|
41
|
+
"template": TEMPLATES_DIR / "CODEX.AGENTS.md.template",
|
|
42
|
+
"target_parts": (".codex", "AGENTS.md"),
|
|
43
|
+
"version_pattern": r"nexo-codex-agents-version:\s*([\d.]+)",
|
|
44
|
+
"version_file": "codex_agents_version.txt",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _user_home() -> Path:
|
|
50
|
+
return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_nexo_home() -> Path:
|
|
54
|
+
return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
|
|
58
|
+
explicit = (explicit or "").strip()
|
|
59
|
+
if explicit:
|
|
60
|
+
return explicit
|
|
61
|
+
env_name = os.environ.get("NEXO_NAME", "").strip()
|
|
62
|
+
if env_name:
|
|
63
|
+
return env_name
|
|
64
|
+
version_file = nexo_home / "version.json"
|
|
65
|
+
if version_file.is_file():
|
|
66
|
+
try:
|
|
67
|
+
return str(json.loads(version_file.read_text()).get("operator_name", "")).strip()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
return "NEXO"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_version(text: str, pattern: str) -> str:
|
|
74
|
+
match = re.search(pattern, text)
|
|
75
|
+
return match.group(1) if match else ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _template_version_comment(text: str) -> str:
|
|
79
|
+
first_line = text.splitlines()[0] if text else ""
|
|
80
|
+
return first_line if first_line.startswith("<!--") and "version:" in first_line else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _strip_version_comment(text: str) -> str:
|
|
84
|
+
return re.sub(r"^<!--\s*nexo-[^-]+(?:-[^-]+)*-version:\s*[\d.]+\s*-->\s*\n?", "", text, count=1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_block(text: str, start: str, end: str) -> str:
|
|
88
|
+
pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
|
|
89
|
+
match = pattern.search(text)
|
|
90
|
+
return match.group(0) if match else ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _replace_block(text: str, start: str, end: str, replacement: str) -> str:
|
|
94
|
+
pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
|
|
95
|
+
if pattern.search(text):
|
|
96
|
+
return pattern.sub(replacement, text, count=1)
|
|
97
|
+
return text.rstrip() + "\n\n" + replacement.rstrip() + "\n"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_user_block(user_payload: str, template_text: str) -> str:
|
|
101
|
+
template_user = _extract_block(template_text, USER_START, USER_END)
|
|
102
|
+
if not user_payload.strip():
|
|
103
|
+
return template_user
|
|
104
|
+
return f"{USER_START}\n{user_payload.rstrip()}\n{USER_END}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _legacy_user_payload(existing_text: str) -> str:
|
|
108
|
+
cleaned = re.sub(r"<!--\s*nexo-[^-]+-[^-]+-version:\s*[\d.]+\s*-->\s*", "", existing_text)
|
|
109
|
+
if "<!-- nexo:start:" in cleaned:
|
|
110
|
+
first_section = re.search(r"<!-- nexo:start:\w+ -->", cleaned)
|
|
111
|
+
prefix = cleaned[: first_section.start()] if first_section else ""
|
|
112
|
+
remainder = cleaned[first_section.start() :] if first_section else ""
|
|
113
|
+
remainder = re.sub(
|
|
114
|
+
r"<!-- nexo:start:\w+ -->.*?<!-- nexo:end:\w+ -->",
|
|
115
|
+
"",
|
|
116
|
+
remainder,
|
|
117
|
+
flags=re.DOTALL,
|
|
118
|
+
)
|
|
119
|
+
residue = "\n".join([prefix, remainder]).strip()
|
|
120
|
+
filtered_lines: list[str] = []
|
|
121
|
+
for line in residue.splitlines():
|
|
122
|
+
stripped = line.strip()
|
|
123
|
+
if not stripped:
|
|
124
|
+
filtered_lines.append("")
|
|
125
|
+
continue
|
|
126
|
+
if stripped.startswith("# ") and "Cognitive Co-Operator" in stripped:
|
|
127
|
+
continue
|
|
128
|
+
if stripped.startswith("I am ") and "powered by NEXO Brain" in stripped:
|
|
129
|
+
continue
|
|
130
|
+
if "Tool-coupled behavioral rules" in stripped:
|
|
131
|
+
continue
|
|
132
|
+
filtered_lines.append(line)
|
|
133
|
+
residue = "\n".join(filtered_lines).strip()
|
|
134
|
+
return residue
|
|
135
|
+
return cleaned.strip()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _target_path(client: str, *, user_home: Path | None = None) -> Path:
|
|
139
|
+
spec = BOOTSTRAP_SPECS[client]
|
|
140
|
+
home = user_home or _user_home()
|
|
141
|
+
return home.joinpath(*spec["target_parts"])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _version_tracker_path(nexo_home: Path, client: str) -> Path:
|
|
145
|
+
return nexo_home / "data" / BOOTSTRAP_SPECS[client]["version_file"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_bootstrap_template(
|
|
149
|
+
client: str,
|
|
150
|
+
*,
|
|
151
|
+
nexo_home: str | os.PathLike[str] | None = None,
|
|
152
|
+
operator_name: str = "",
|
|
153
|
+
) -> str:
|
|
154
|
+
client_key = normalize_client_key(client)
|
|
155
|
+
spec = BOOTSTRAP_SPECS[client_key]
|
|
156
|
+
template_text = spec["template"].read_text()
|
|
157
|
+
nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
|
|
158
|
+
name = _resolve_operator_name(nexo_home_path, explicit=operator_name)
|
|
159
|
+
return (
|
|
160
|
+
template_text
|
|
161
|
+
.replace("{{NAME}}", name)
|
|
162
|
+
.replace("{{NEXO_HOME}}", str(nexo_home_path))
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_bootstrap_prompt(
|
|
167
|
+
client: str,
|
|
168
|
+
*,
|
|
169
|
+
nexo_home: str | os.PathLike[str] | None = None,
|
|
170
|
+
operator_name: str = "",
|
|
171
|
+
user_home: str | os.PathLike[str] | None = None,
|
|
172
|
+
) -> str:
|
|
173
|
+
client_key = normalize_client_key(client)
|
|
174
|
+
if client_key not in BOOTSTRAP_SPECS:
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
home_path = Path(user_home).expanduser() if user_home else _user_home()
|
|
178
|
+
target_path = _target_path(client_key, user_home=home_path)
|
|
179
|
+
if target_path.exists():
|
|
180
|
+
text = target_path.read_text()
|
|
181
|
+
if text.strip():
|
|
182
|
+
return _strip_version_comment(text).strip()
|
|
183
|
+
|
|
184
|
+
rendered = render_bootstrap_template(
|
|
185
|
+
client_key,
|
|
186
|
+
nexo_home=nexo_home,
|
|
187
|
+
operator_name=operator_name,
|
|
188
|
+
)
|
|
189
|
+
return _strip_version_comment(rendered).strip()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def sync_client_bootstrap(
|
|
193
|
+
client: str,
|
|
194
|
+
*,
|
|
195
|
+
nexo_home: str | os.PathLike[str] | None = None,
|
|
196
|
+
operator_name: str = "",
|
|
197
|
+
user_home: str | os.PathLike[str] | None = None,
|
|
198
|
+
) -> dict:
|
|
199
|
+
client_key = normalize_client_key(client)
|
|
200
|
+
if client_key not in BOOTSTRAP_SPECS:
|
|
201
|
+
return {"ok": False, "client": client_key or str(client), "error": "unsupported bootstrap target"}
|
|
202
|
+
|
|
203
|
+
nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
|
|
204
|
+
home_path = Path(user_home).expanduser() if user_home else _user_home()
|
|
205
|
+
target_path = _target_path(client_key, user_home=home_path)
|
|
206
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
|
|
208
|
+
rendered = render_bootstrap_template(
|
|
209
|
+
client_key,
|
|
210
|
+
nexo_home=nexo_home_path,
|
|
211
|
+
operator_name=operator_name,
|
|
212
|
+
)
|
|
213
|
+
template_version = _read_version(rendered, BOOTSTRAP_SPECS[client_key]["version_pattern"])
|
|
214
|
+
rendered_core = _extract_block(rendered, CORE_START, CORE_END)
|
|
215
|
+
if not rendered_core:
|
|
216
|
+
return {"ok": False, "client": client_key, "path": str(target_path), "error": "template missing CORE block"}
|
|
217
|
+
|
|
218
|
+
if not target_path.exists() or not target_path.read_text().strip():
|
|
219
|
+
target_path.write_text(rendered)
|
|
220
|
+
if template_version:
|
|
221
|
+
tracker = _version_tracker_path(nexo_home_path, client_key)
|
|
222
|
+
tracker.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
tracker.write_text(template_version)
|
|
224
|
+
return {
|
|
225
|
+
"ok": True,
|
|
226
|
+
"client": client_key,
|
|
227
|
+
"action": "created",
|
|
228
|
+
"path": str(target_path),
|
|
229
|
+
"version": template_version,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
existing = target_path.read_text()
|
|
233
|
+
if CORE_START in existing and CORE_END in existing and USER_START in existing and USER_END in existing:
|
|
234
|
+
user_block = _extract_block(existing, USER_START, USER_END) or _extract_block(rendered, USER_START, USER_END)
|
|
235
|
+
updated = _replace_block(existing, CORE_START, CORE_END, rendered_core)
|
|
236
|
+
updated = _replace_block(updated, USER_START, USER_END, user_block)
|
|
237
|
+
action = "updated" if updated != existing else "unchanged"
|
|
238
|
+
else:
|
|
239
|
+
legacy_user = _legacy_user_payload(existing)
|
|
240
|
+
updated = _replace_block(rendered, USER_START, USER_END, _build_user_block(legacy_user, rendered))
|
|
241
|
+
action = "migrated"
|
|
242
|
+
|
|
243
|
+
if template_version:
|
|
244
|
+
comment = _template_version_comment(rendered)
|
|
245
|
+
if comment:
|
|
246
|
+
updated = re.sub(r"<!--\s*nexo-[^-]+-[^-]+-version:\s*[\d.]+\s*-->", comment, updated, count=1)
|
|
247
|
+
if comment not in updated:
|
|
248
|
+
updated = comment + "\n" + updated.lstrip()
|
|
249
|
+
|
|
250
|
+
if updated != existing:
|
|
251
|
+
backup_path = target_path.with_suffix(target_path.suffix + ".bak")
|
|
252
|
+
try:
|
|
253
|
+
backup_path.write_text(existing)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
target_path.write_text(updated)
|
|
257
|
+
|
|
258
|
+
if template_version:
|
|
259
|
+
tracker = _version_tracker_path(nexo_home_path, client_key)
|
|
260
|
+
tracker.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
tracker.write_text(template_version)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
"ok": True,
|
|
265
|
+
"client": client_key,
|
|
266
|
+
"action": action,
|
|
267
|
+
"path": str(target_path),
|
|
268
|
+
"version": template_version,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sync_enabled_bootstraps(
|
|
273
|
+
*,
|
|
274
|
+
nexo_home: str | os.PathLike[str] | None = None,
|
|
275
|
+
operator_name: str = "",
|
|
276
|
+
user_home: str | os.PathLike[str] | None = None,
|
|
277
|
+
enabled_clients: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
278
|
+
preferences: dict | None = None,
|
|
279
|
+
) -> dict[str, dict]:
|
|
280
|
+
if enabled_clients is None:
|
|
281
|
+
if preferences is None:
|
|
282
|
+
enabled = {CLIENT_CLAUDE_CODE, CLIENT_CODEX}
|
|
283
|
+
else:
|
|
284
|
+
prefs = normalize_client_preferences(preferences)
|
|
285
|
+
enabled = {
|
|
286
|
+
key
|
|
287
|
+
for key in INTERACTIVE_CLIENT_KEYS
|
|
288
|
+
if prefs.get("interactive_clients", {}).get(key, False)
|
|
289
|
+
and key in BOOTSTRAP_SPECS
|
|
290
|
+
}
|
|
291
|
+
backend = normalize_backend_key(prefs.get("automation_backend"))
|
|
292
|
+
if prefs.get("automation_enabled", True) and backend and backend != BACKEND_NONE:
|
|
293
|
+
if backend in BOOTSTRAP_SPECS:
|
|
294
|
+
enabled.add(backend)
|
|
295
|
+
if not enabled:
|
|
296
|
+
enabled.add(CLIENT_CLAUDE_CODE)
|
|
297
|
+
else:
|
|
298
|
+
enabled = {normalize_client_key(item) for item in enabled_clients if normalize_client_key(item) in BOOTSTRAP_SPECS}
|
|
299
|
+
if not enabled:
|
|
300
|
+
enabled = {CLIENT_CLAUDE_CODE}
|
|
301
|
+
|
|
302
|
+
results: dict[str, dict] = {}
|
|
303
|
+
for client_key in (CLIENT_CLAUDE_CODE, CLIENT_CODEX):
|
|
304
|
+
if client_key not in enabled:
|
|
305
|
+
results[client_key] = {
|
|
306
|
+
"ok": True,
|
|
307
|
+
"client": client_key,
|
|
308
|
+
"skipped": True,
|
|
309
|
+
"reason": "disabled in client preferences",
|
|
310
|
+
"path": str(_target_path(client_key, user_home=Path(user_home).expanduser() if user_home else None)),
|
|
311
|
+
}
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
results[client_key] = sync_client_bootstrap(
|
|
315
|
+
client_key,
|
|
316
|
+
nexo_home=nexo_home,
|
|
317
|
+
operator_name=operator_name,
|
|
318
|
+
user_home=user_home,
|
|
319
|
+
)
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
results[client_key] = {"ok": False, "client": client_key, "error": str(exc)}
|
|
322
|
+
return results
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def get_bootstrap_status(
|
|
326
|
+
client: str,
|
|
327
|
+
*,
|
|
328
|
+
nexo_home: str | os.PathLike[str] | None = None,
|
|
329
|
+
user_home: str | os.PathLike[str] | None = None,
|
|
330
|
+
) -> dict:
|
|
331
|
+
client_key = normalize_client_key(client)
|
|
332
|
+
if client_key not in BOOTSTRAP_SPECS:
|
|
333
|
+
return {"client": client_key or str(client), "supported": False}
|
|
334
|
+
|
|
335
|
+
nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
|
|
336
|
+
home_path = Path(user_home).expanduser() if user_home else _user_home()
|
|
337
|
+
target_path = _target_path(client_key, user_home=home_path)
|
|
338
|
+
template_text = render_bootstrap_template(client_key, nexo_home=nexo_home_path)
|
|
339
|
+
template_version = _read_version(template_text, BOOTSTRAP_SPECS[client_key]["version_pattern"])
|
|
340
|
+
status = {
|
|
341
|
+
"client": client_key,
|
|
342
|
+
"path": str(target_path),
|
|
343
|
+
"exists": target_path.exists(),
|
|
344
|
+
"supported": True,
|
|
345
|
+
"template_version": template_version,
|
|
346
|
+
"version": "",
|
|
347
|
+
"markers_ok": False,
|
|
348
|
+
"user_block_ok": False,
|
|
349
|
+
}
|
|
350
|
+
if not target_path.exists():
|
|
351
|
+
return status
|
|
352
|
+
|
|
353
|
+
text = target_path.read_text()
|
|
354
|
+
status["version"] = _read_version(text, BOOTSTRAP_SPECS[client_key]["version_pattern"])
|
|
355
|
+
status["markers_ok"] = CORE_START in text and CORE_END in text and USER_START in text and USER_END in text
|
|
356
|
+
status["user_block_ok"] = USER_START in text and USER_END in text
|
|
357
|
+
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
|
|
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
|
package/src/client_sync.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|