nexo-brain 2.7.0 → 3.0.1

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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -12
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +295 -7
  6. package/src/cli.py +111 -0
  7. package/src/client_preferences.py +99 -1
  8. package/src/client_sync.py +207 -3
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +141 -1
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/protocol.html +199 -0
  14. package/src/db/__init__.py +23 -1
  15. package/src/db/_learnings.py +31 -4
  16. package/src/db/_personal_scripts.py +12 -0
  17. package/src/db/_protocol.py +303 -0
  18. package/src/db/_schema.py +248 -0
  19. package/src/db/_watchers.py +173 -0
  20. package/src/db/_workflow.py +952 -0
  21. package/src/doctor/providers/boot.py +45 -19
  22. package/src/doctor/providers/runtime.py +923 -8
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/requirements.txt +1 -0
  38. package/src/script_registry.py +142 -0
  39. package/src/scripts/deep-sleep/apply_findings.py +204 -0
  40. package/src/scripts/deep-sleep/collect.py +49 -4
  41. package/src/scripts/nexo-agent-run.py +2 -0
  42. package/src/scripts/nexo-daily-self-audit.py +843 -5
  43. package/src/scripts/nexo-evolution-run.py +343 -1
  44. package/src/server.py +92 -6
  45. package/src/skills_runtime.py +151 -0
  46. package/src/state_watchers_runtime.py +334 -0
  47. package/src/tools_learnings.py +345 -7
  48. package/src/tools_sessions.py +183 -0
  49. package/templates/CLAUDE.md.template +9 -1
  50. package/templates/CODEX.AGENTS.md.template +10 -2
package/src/cli.py CHANGED
@@ -1056,6 +1056,23 @@ def _skills_apply(args):
1056
1056
  return 0 if result.get("ok") else 1
1057
1057
 
1058
1058
 
1059
+ def _skills_test(args):
1060
+ from skills_runtime import test_skill
1061
+
1062
+ try:
1063
+ params = json.loads(args.params) if args.params else {}
1064
+ except json.JSONDecodeError as e:
1065
+ print(f"Invalid params JSON: {e}", file=sys.stderr)
1066
+ return 1
1067
+
1068
+ result = test_skill(args.id, params=params, mode=args.mode, context=args.context)
1069
+ if args.json:
1070
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1071
+ else:
1072
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1073
+ return 0 if result.get("ok") else 1
1074
+
1075
+
1059
1076
  def _skills_sync(args):
1060
1077
  from skills_runtime import sync_skills
1061
1078
 
@@ -1103,6 +1120,62 @@ def _skills_evolution(args):
1103
1120
  return 0
1104
1121
 
1105
1122
 
1123
+ def _skills_promote(args):
1124
+ from skills_runtime import promote_skill
1125
+
1126
+ result = promote_skill(args.id, target_level=args.target_level, reason=args.reason)
1127
+ if args.json:
1128
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1129
+ else:
1130
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1131
+ return 0 if result.get("ok") else 1
1132
+
1133
+
1134
+ def _skills_retire(args):
1135
+ from skills_runtime import retire_skill
1136
+
1137
+ result = retire_skill(args.id, replacement_id=args.replacement_id, reason=args.reason)
1138
+ if args.json:
1139
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1140
+ else:
1141
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1142
+ return 0 if result.get("ok") else 1
1143
+
1144
+
1145
+ def _skills_compose(args):
1146
+ from skills_runtime import compose_skills
1147
+
1148
+ try:
1149
+ component_ids = json.loads(args.component_ids) if args.component_ids.strip().startswith("[") else [
1150
+ item.strip() for item in args.component_ids.split(",") if item.strip()
1151
+ ]
1152
+ tags = json.loads(args.tags) if args.tags.strip().startswith("[") else [
1153
+ item.strip() for item in args.tags.split(",") if item.strip()
1154
+ ]
1155
+ trigger_patterns = json.loads(args.trigger_patterns) if args.trigger_patterns.strip().startswith("[") else [
1156
+ item.strip() for item in args.trigger_patterns.split(",") if item.strip()
1157
+ ]
1158
+ except json.JSONDecodeError as e:
1159
+ print(f"Invalid JSON: {e}", file=sys.stderr)
1160
+ return 1
1161
+
1162
+ result = compose_skills(
1163
+ new_skill_id=args.new_id,
1164
+ name=args.name,
1165
+ component_ids=component_ids,
1166
+ description=args.description,
1167
+ level=args.level,
1168
+ mode=args.mode,
1169
+ tags=tags,
1170
+ trigger_patterns=trigger_patterns,
1171
+ )
1172
+ if args.json:
1173
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1174
+ else:
1175
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1176
+ return 0 if result.get("ok") else 1
1177
+
1178
+
1106
1179
  def _print_help():
1107
1180
  v = _get_version()
1108
1181
  print(f"""NEXO Runtime CLI v{v}
@@ -1248,6 +1321,13 @@ def main():
1248
1321
  skills_apply_p.add_argument("--context", default="", help="Usage context for feedback loop")
1249
1322
  skills_apply_p.add_argument("--json", action="store_true", help="JSON output")
1250
1323
 
1324
+ skills_test_p = skills_sub.add_parser("test", help="Dry-run test a skill")
1325
+ skills_test_p.add_argument("id", help="Skill ID")
1326
+ skills_test_p.add_argument("--params", default="{}", help="JSON parameters")
1327
+ skills_test_p.add_argument("--mode", default="auto", choices=["auto", "guide", "execute", "hybrid"])
1328
+ skills_test_p.add_argument("--context", default="", help="Testing context")
1329
+ skills_test_p.add_argument("--json", action="store_true", help="JSON output")
1330
+
1251
1331
  skills_sync_p = skills_sub.add_parser("sync", help="Sync filesystem skills")
1252
1332
  skills_sync_p.add_argument("--json", action="store_true", help="JSON output")
1253
1333
 
@@ -1264,6 +1344,29 @@ def main():
1264
1344
  skills_evolution_p = skills_sub.add_parser("evolution", help="Evolution candidates")
1265
1345
  skills_evolution_p.add_argument("--json", action="store_true", help="JSON output")
1266
1346
 
1347
+ skills_promote_p = skills_sub.add_parser("promote", help="Promote a skill lifecycle level")
1348
+ skills_promote_p.add_argument("id", help="Skill ID")
1349
+ skills_promote_p.add_argument("--target-level", default="published", choices=["draft", "published", "stable"])
1350
+ skills_promote_p.add_argument("--reason", default="", help="Why promote this skill")
1351
+ skills_promote_p.add_argument("--json", action="store_true", help="JSON output")
1352
+
1353
+ skills_retire_p = skills_sub.add_parser("retire", help="Archive a skill")
1354
+ skills_retire_p.add_argument("id", help="Skill ID")
1355
+ skills_retire_p.add_argument("--replacement-id", default="", help="Optional replacement skill ID")
1356
+ skills_retire_p.add_argument("--reason", default="", help="Why retire this skill")
1357
+ skills_retire_p.add_argument("--json", action="store_true", help="JSON output")
1358
+
1359
+ skills_compose_p = skills_sub.add_parser("compose", help="Compose multiple skills into one")
1360
+ skills_compose_p.add_argument("new_id", help="New skill ID")
1361
+ skills_compose_p.add_argument("name", help="New skill name")
1362
+ skills_compose_p.add_argument("--component-ids", required=True, help="JSON array or comma-separated skill IDs")
1363
+ skills_compose_p.add_argument("--description", default="", help="Composite skill description")
1364
+ skills_compose_p.add_argument("--level", default="draft", choices=["trace", "draft", "published", "stable"])
1365
+ skills_compose_p.add_argument("--mode", default="guide", choices=["guide", "hybrid"])
1366
+ skills_compose_p.add_argument("--tags", default="[]", help="JSON array or comma-separated tags")
1367
+ skills_compose_p.add_argument("--trigger-patterns", default="[]", help="JSON array or comma-separated trigger patterns")
1368
+ skills_compose_p.add_argument("--json", action="store_true", help="JSON output")
1369
+
1267
1370
  # -- dashboard --
1268
1371
  dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
1269
1372
  dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
@@ -1332,6 +1435,8 @@ def main():
1332
1435
  return _skills_get(args)
1333
1436
  elif args.skills_command == "apply":
1334
1437
  return _skills_apply(args)
1438
+ elif args.skills_command == "test":
1439
+ return _skills_test(args)
1335
1440
  elif args.skills_command == "sync":
1336
1441
  return _skills_sync(args)
1337
1442
  elif args.skills_command == "approve":
@@ -1340,6 +1445,12 @@ def main():
1340
1445
  return _skills_featured(args)
1341
1446
  elif args.skills_command == "evolution":
1342
1447
  return _skills_evolution(args)
1448
+ elif args.skills_command == "promote":
1449
+ return _skills_promote(args)
1450
+ elif args.skills_command == "retire":
1451
+ return _skills_retire(args)
1452
+ elif args.skills_command == "compose":
1453
+ return _skills_compose(args)
1343
1454
  else:
1344
1455
  skills_parser.print_help()
1345
1456
  return 0
@@ -5,9 +5,13 @@ from __future__ import annotations
5
5
  import os
6
6
  import shutil
7
7
  import sys
8
- import tomllib
9
8
  from pathlib import Path
10
9
 
10
+ try:
11
+ import tomllib
12
+ except ModuleNotFoundError: # Python < 3.11
13
+ import tomli as tomllib
14
+
11
15
  from runtime_power import load_schedule_config, save_schedule_config
12
16
 
13
17
 
@@ -30,6 +34,12 @@ AUTOMATION_BACKEND_KEYS = (
30
34
  CLIENT_CLAUDE_CODE,
31
35
  CLIENT_CODEX,
32
36
  )
37
+ AUTOMATION_TASK_PROFILE_KEYS = (
38
+ "default",
39
+ "fast",
40
+ "balanced",
41
+ "deep",
42
+ )
33
43
  INSTALL_PREFERENCE_KEYS = {
34
44
  "ask",
35
45
  "auto",
@@ -40,6 +50,10 @@ DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
40
50
  DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
41
51
  DEFAULT_CODEX_MODEL = "gpt-5.4"
42
52
  DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
53
+ DEFAULT_FAST_MODEL = "gpt-5.4-mini"
54
+ DEFAULT_FAST_REASONING_EFFORT = "medium"
55
+
56
+
43
57
  def _user_home() -> Path:
44
58
  return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
45
59
 
@@ -77,6 +91,7 @@ def default_client_preferences() -> dict:
77
91
  "automation_enabled": True,
78
92
  "automation_backend": CLIENT_CLAUDE_CODE,
79
93
  "client_runtime_profiles": default_client_runtime_profiles(),
94
+ "automation_task_profiles": default_automation_task_profiles(),
80
95
  "client_install_preferences": {
81
96
  CLIENT_CLAUDE_CODE: "ask",
82
97
  CLIENT_CODEX: "ask",
@@ -237,6 +252,31 @@ def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
237
252
  }
238
253
 
239
254
 
255
+ def default_automation_task_profiles() -> dict[str, dict[str, str]]:
256
+ return {
257
+ "default": {
258
+ "backend": "",
259
+ "model": "",
260
+ "reasoning_effort": "",
261
+ },
262
+ "fast": {
263
+ "backend": CLIENT_CODEX,
264
+ "model": DEFAULT_FAST_MODEL,
265
+ "reasoning_effort": DEFAULT_FAST_REASONING_EFFORT,
266
+ },
267
+ "balanced": {
268
+ "backend": "",
269
+ "model": "",
270
+ "reasoning_effort": "",
271
+ },
272
+ "deep": {
273
+ "backend": CLIENT_CLAUDE_CODE,
274
+ "model": DEFAULT_CLAUDE_CODE_MODEL,
275
+ "reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
276
+ },
277
+ }
278
+
279
+
240
280
  def _normalize_runtime_model(value, *, default: str) -> str:
241
281
  candidate = str(value or "").strip()
242
282
  return candidate or default
@@ -276,6 +316,31 @@ def normalize_client_runtime_profiles(value) -> dict[str, dict[str, str]]:
276
316
  return normalized
277
317
 
278
318
 
319
+ def normalize_automation_task_profiles(value) -> dict[str, dict[str, str]]:
320
+ defaults = default_automation_task_profiles()
321
+ normalized = {key: dict(profile) for key, profile in defaults.items()}
322
+ if not isinstance(value, dict):
323
+ return normalized
324
+
325
+ for raw_profile, raw_value in value.items():
326
+ profile_key = str(raw_profile or "").strip().lower()
327
+ if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
328
+ continue
329
+ if not isinstance(raw_value, dict):
330
+ continue
331
+ backend = normalize_backend_key(raw_value.get("backend"))
332
+ if backend == BACKEND_NONE:
333
+ backend = ""
334
+ normalized[profile_key] = {
335
+ "backend": backend or defaults[profile_key]["backend"],
336
+ "model": str(raw_value.get("model") or defaults[profile_key]["model"]).strip(),
337
+ "reasoning_effort": str(
338
+ raw_value.get("reasoning_effort") or defaults[profile_key]["reasoning_effort"]
339
+ ).strip().lower(),
340
+ }
341
+ return normalized
342
+
343
+
279
344
  def normalize_client_preferences(
280
345
  schedule: dict | None = None,
281
346
  *,
@@ -312,6 +377,9 @@ def normalize_client_preferences(
312
377
  "automation_enabled": automation_enabled,
313
378
  "automation_backend": automation_backend,
314
379
  "client_runtime_profiles": runtime_profiles,
380
+ "automation_task_profiles": normalize_automation_task_profiles(
381
+ schedule.get("automation_task_profiles")
382
+ ),
315
383
  "client_install_preferences": install_preferences,
316
384
  }
317
385
 
@@ -325,6 +393,7 @@ def apply_client_preferences(
325
393
  automation_enabled=None,
326
394
  automation_backend: str | None = None,
327
395
  client_runtime_profiles: dict | None = None,
396
+ automation_task_profiles: dict | None = None,
328
397
  client_install_preferences: dict | None = None,
329
398
  ) -> dict:
330
399
  merged = dict(schedule or {})
@@ -352,6 +421,11 @@ def apply_client_preferences(
352
421
  if client_runtime_profiles is not None
353
422
  else current["client_runtime_profiles"]
354
423
  )
424
+ merged["automation_task_profiles"] = normalize_automation_task_profiles(
425
+ automation_task_profiles
426
+ if automation_task_profiles is not None
427
+ else current["automation_task_profiles"]
428
+ )
355
429
  merged["client_install_preferences"] = normalize_client_install_preferences(
356
430
  client_install_preferences
357
431
  if client_install_preferences is not None
@@ -372,6 +446,7 @@ def save_client_preferences(
372
446
  automation_enabled=None,
373
447
  automation_backend: str | None = None,
374
448
  client_runtime_profiles: dict | None = None,
449
+ automation_task_profiles: dict | None = None,
375
450
  client_install_preferences: dict | None = None,
376
451
  ) -> Path:
377
452
  schedule = apply_client_preferences(
@@ -382,6 +457,7 @@ def save_client_preferences(
382
457
  automation_enabled=automation_enabled,
383
458
  automation_backend=automation_backend,
384
459
  client_runtime_profiles=client_runtime_profiles,
460
+ automation_task_profiles=automation_task_profiles,
385
461
  client_install_preferences=client_install_preferences,
386
462
  )
387
463
  return save_schedule_config(schedule)
@@ -477,3 +553,25 @@ def resolve_client_runtime_profile(
477
553
  default=defaults[client_key]["reasoning_effort"],
478
554
  ),
479
555
  }
556
+
557
+
558
+ def resolve_automation_task_profile(
559
+ profile: str | None,
560
+ *,
561
+ preferences: dict | None = None,
562
+ ) -> dict[str, str]:
563
+ normalized = preferences or load_client_preferences()
564
+ defaults = default_automation_task_profiles()
565
+ profile_key = str(profile or "").strip().lower() or "default"
566
+ if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
567
+ profile_key = "default"
568
+ configured = normalize_automation_task_profiles(normalized.get("automation_task_profiles"))
569
+ selected = dict(configured.get(profile_key) or defaults[profile_key])
570
+ backend = selected.get("backend") or resolve_automation_backend(normalized)
571
+ runtime_profile = resolve_client_runtime_profile(backend, preferences=normalized)
572
+ return {
573
+ "name": profile_key,
574
+ "backend": backend,
575
+ "model": selected.get("model") or runtime_profile["model"],
576
+ "reasoning_effort": selected.get("reasoning_effort") or runtime_profile["reasoning_effort"],
577
+ }
@@ -5,12 +5,18 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import os
8
+ import re
9
+ import shlex
8
10
  import shutil
9
11
  import subprocess
10
12
  import sys
11
- import tomllib
12
13
  from pathlib import Path
13
14
 
15
+ try:
16
+ import tomllib
17
+ except ModuleNotFoundError: # Python < 3.11
18
+ import tomli as tomllib
19
+
14
20
  from bootstrap_docs import sync_client_bootstrap
15
21
 
16
22
  try:
@@ -308,6 +314,179 @@ def _write_json_object(path: Path, payload: dict) -> None:
308
314
  path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
309
315
 
310
316
 
317
+ CORE_HOOK_SPECS = [
318
+ {
319
+ "event": "SessionStart",
320
+ "identity": "session-start-ts",
321
+ "timeout": 2,
322
+ "command_template": lambda nexo_home, _runtime_root, _hooks_dir: (
323
+ f"mkdir -p {shlex.quote(str(nexo_home / 'operations'))} && "
324
+ f"date +%s > {shlex.quote(str(nexo_home / 'operations' / '.session-start-ts'))}"
325
+ ),
326
+ },
327
+ {
328
+ "event": "SessionStart",
329
+ "identity": "daily-briefing-check.sh",
330
+ "timeout": 5,
331
+ "script": "daily-briefing-check.sh",
332
+ },
333
+ {
334
+ "event": "SessionStart",
335
+ "identity": "session-start.sh",
336
+ "timeout": 35,
337
+ "script": "session-start.sh",
338
+ },
339
+ {
340
+ "event": "Stop",
341
+ "identity": "session-stop.sh",
342
+ "timeout": 10,
343
+ "script": "session-stop.sh",
344
+ },
345
+ {
346
+ "event": "PostToolUse",
347
+ "identity": "capture-tool-logs.sh",
348
+ "timeout": 5,
349
+ "script": "capture-tool-logs.sh",
350
+ },
351
+ {
352
+ "event": "PostToolUse",
353
+ "identity": "capture-session.sh",
354
+ "timeout": 3,
355
+ "script": "capture-session.sh",
356
+ },
357
+ {
358
+ "event": "PostToolUse",
359
+ "identity": "inbox-hook.sh",
360
+ "timeout": 5,
361
+ "script": "inbox-hook.sh",
362
+ },
363
+ {
364
+ "event": "PostToolUse",
365
+ "identity": "protocol-guardrail.sh",
366
+ "timeout": 5,
367
+ "script": "protocol-guardrail.sh",
368
+ },
369
+ {
370
+ "event": "PreCompact",
371
+ "identity": "pre-compact.sh",
372
+ "timeout": 10,
373
+ "script": "pre-compact.sh",
374
+ },
375
+ {
376
+ "event": "PostCompact",
377
+ "identity": "post-compact.sh",
378
+ "timeout": 10,
379
+ "script": "post-compact.sh",
380
+ },
381
+ ]
382
+
383
+
384
+ def _resolve_hook_source_dir(runtime_root: Path) -> Path:
385
+ direct = runtime_root / "hooks"
386
+ if direct.is_dir():
387
+ return direct
388
+ sibling = runtime_root.parent / "src" / "hooks"
389
+ if sibling.is_dir():
390
+ return sibling
391
+ fallback = runtime_root.parent / "hooks"
392
+ if fallback.is_dir():
393
+ return fallback
394
+ return direct
395
+
396
+
397
+ def _render_hook_command(spec: dict, *, nexo_home: Path, runtime_root: Path, hooks_dir: Path) -> str:
398
+ command_template = spec.get("command_template")
399
+ if callable(command_template):
400
+ return command_template(nexo_home, runtime_root, hooks_dir)
401
+ script_name = spec.get("script", "").strip()
402
+ script_path = hooks_dir / script_name
403
+ return (
404
+ f"NEXO_HOME={shlex.quote(str(nexo_home))} "
405
+ f"NEXO_CODE={shlex.quote(str(runtime_root))} "
406
+ f"bash {shlex.quote(str(script_path))}"
407
+ )
408
+
409
+
410
+ def _hook_identity(command: str) -> str:
411
+ text = str(command or "")
412
+ if ".session-start-ts" in text:
413
+ return "session-start-ts"
414
+ match = re.search(r"([A-Za-z0-9._-]+\.sh)\b", text)
415
+ if match:
416
+ return match.group(1)
417
+ return text.strip()
418
+
419
+
420
+ def _normalize_hook_sections(entries) -> list[dict]:
421
+ normalized: list[dict] = []
422
+ for entry in entries or []:
423
+ if not isinstance(entry, dict):
424
+ continue
425
+ hooks = entry.get("hooks")
426
+ if isinstance(hooks, list):
427
+ normalized.append(
428
+ {
429
+ "matcher": entry.get("matcher", "*") or "*",
430
+ "hooks": [dict(hook) for hook in hooks if isinstance(hook, dict)],
431
+ }
432
+ )
433
+ continue
434
+ if entry.get("command"):
435
+ hook = {"type": entry.get("type", "command"), "command": entry["command"]}
436
+ if entry.get("timeout"):
437
+ hook["timeout"] = entry["timeout"]
438
+ normalized.append({"matcher": entry.get("matcher", "*") or "*", "hooks": [hook]})
439
+ return normalized
440
+
441
+
442
+ def _merge_core_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
443
+ hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
444
+ hooks_dir = _resolve_hook_source_dir(runtime_root)
445
+ managed_count = 0
446
+
447
+ for spec in CORE_HOOK_SPECS:
448
+ event = spec["event"]
449
+ sections = _normalize_hook_sections(hooks_payload.get(event))
450
+ hooks_payload[event] = sections
451
+ command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
452
+ identity = spec["identity"]
453
+
454
+ found = False
455
+ for section in sections:
456
+ for hook in section["hooks"]:
457
+ if _hook_identity(hook.get("command", "")) != identity:
458
+ continue
459
+ hook["type"] = "command"
460
+ hook["command"] = command
461
+ if spec.get("timeout"):
462
+ hook["timeout"] = spec["timeout"]
463
+ found = True
464
+ managed_count += 1
465
+ break
466
+ if found:
467
+ break
468
+
469
+ if found:
470
+ continue
471
+
472
+ target = None
473
+ for section in sections:
474
+ if section.get("matcher", "*") == "*":
475
+ target = section
476
+ break
477
+ if target is None:
478
+ target = {"matcher": "*", "hooks": []}
479
+ sections.append(target)
480
+
481
+ new_hook = {"type": "command", "command": command}
482
+ if spec.get("timeout"):
483
+ new_hook["timeout"] = spec["timeout"]
484
+ target["hooks"].append(new_hook)
485
+ managed_count += 1
486
+
487
+ return hooks_payload, managed_count
488
+
489
+
311
490
  def _sync_json_client(path: Path, server_config: dict, label: str, *, managed_metadata: dict | None = None) -> dict:
312
491
  payload = _load_json_object(path)
313
492
  mcp_servers = payload.setdefault("mcpServers", {})
@@ -343,6 +522,32 @@ def _claude_desktop_managed_metadata(server_config: dict, *, operator_name: str)
343
522
  }
344
523
 
345
524
 
525
+ def _sync_claude_code_settings(path: Path, server_config: dict) -> dict:
526
+ payload = _load_json_object(path)
527
+ mcp_servers = payload.setdefault("mcpServers", {})
528
+ if not isinstance(mcp_servers, dict):
529
+ mcp_servers = {}
530
+ payload["mcpServers"] = mcp_servers
531
+ action = "updated" if "nexo" in mcp_servers else "created"
532
+ mcp_servers["nexo"] = server_config
533
+
534
+ runtime_root = Path(server_config.get("env", {}).get("NEXO_CODE", "")).expanduser()
535
+ nexo_home = Path(server_config.get("env", {}).get("NEXO_HOME", "")).expanduser()
536
+ payload["hooks"], managed_hook_count = _merge_core_hooks(
537
+ payload.get("hooks", {}),
538
+ runtime_root=runtime_root,
539
+ nexo_home=nexo_home,
540
+ )
541
+ _write_json_object(path, payload)
542
+ return {
543
+ "ok": True,
544
+ "client": "claude_code",
545
+ "action": action,
546
+ "path": str(path),
547
+ "managed_hook_count": managed_hook_count,
548
+ }
549
+
550
+
346
551
  def sync_claude_code(
347
552
  *,
348
553
  nexo_home: str | os.PathLike[str] | None = None,
@@ -358,10 +563,9 @@ def sync_claude_code(
358
563
  python_path=python_path,
359
564
  operator_name=operator_name,
360
565
  )
361
- result = _sync_json_client(
566
+ result = _sync_claude_code_settings(
362
567
  _claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
363
568
  server_config,
364
- "claude_code",
365
569
  )
366
570
  bootstrap_result = sync_client_bootstrap(
367
571
  "claude_code",
@@ -29,7 +29,7 @@ from cognitive._search import (
29
29
  search, bm25_search, hyde_expand_query,
30
30
  record_co_activation,
31
31
  _kg_boost_results, _apply_temporal_boost,
32
- create_trigger, check_triggers, list_triggers, delete_trigger, rearm_trigger,
32
+ create_trigger, preview_triggers, check_triggers, list_triggers, delete_trigger, rearm_trigger,
33
33
  # Constants
34
34
  CO_ACTIVATION_DECAY, CO_ACTIVATION_BOOST, CO_ACTIVATION_MIN_STRENGTH,
35
35
  )
@@ -542,18 +542,17 @@ def create_trigger(pattern: str, action: str, context: str = "") -> int:
542
542
  return cur.lastrowid
543
543
 
544
544
 
545
- def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
546
- """Check text against all armed triggers. Fires matches.
547
-
548
- Uses keyword matching by default. If use_semantic=True, also checks
549
- semantic similarity (Vestige TriggerPattern.matches pattern).
545
+ def _match_triggers(
546
+ text: str,
547
+ *,
548
+ use_semantic: bool = False,
549
+ semantic_threshold: float = 0.7,
550
+ fire: bool = False,
551
+ ) -> list[dict]:
552
+ """Match armed prospective triggers against text.
550
553
 
551
- Args:
552
- text: Input text to check
553
- use_semantic: Also do embedding similarity matching
554
- semantic_threshold: Min cosine similarity for semantic match
555
- Returns:
556
- List of fired triggers with actions
554
+ When ``fire`` is False, matches are previewed without mutating trigger state.
555
+ When ``fire`` is True, matching armed triggers transition to fired.
557
556
  """
558
557
  if not text or not text.strip():
559
558
  return []
@@ -571,7 +570,7 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
571
570
  if use_semantic:
572
571
  text_vec = embed(text)
573
572
 
574
- fired = []
573
+ matched_triggers = []
575
574
  now = datetime.utcnow().isoformat()
576
575
 
577
576
  for trigger in armed:
@@ -594,11 +593,12 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
594
593
  match_type = f"semantic({sim:.3f})"
595
594
 
596
595
  if matched:
597
- db.execute(
598
- "UPDATE prospective_triggers SET status = 'fired', fired_at = ? WHERE id = ?",
599
- (now, trigger["id"])
600
- )
601
- fired.append({
596
+ if fire:
597
+ db.execute(
598
+ "UPDATE prospective_triggers SET status = 'fired', fired_at = ? WHERE id = ?",
599
+ (now, trigger["id"])
600
+ )
601
+ matched_triggers.append({
602
602
  "id": trigger["id"],
603
603
  "pattern": trigger["trigger_pattern"],
604
604
  "action": trigger["action"],
@@ -607,10 +607,30 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
607
607
  "created_at": trigger["created_at"],
608
608
  })
609
609
 
610
- if fired:
610
+ if fire and matched_triggers:
611
611
  db.commit()
612
612
 
613
- return fired
613
+ return matched_triggers
614
+
615
+
616
+ def preview_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
617
+ """Preview trigger matches without consuming or firing them."""
618
+ return _match_triggers(
619
+ text,
620
+ use_semantic=use_semantic,
621
+ semantic_threshold=semantic_threshold,
622
+ fire=False,
623
+ )
624
+
625
+
626
+ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
627
+ """Check text against all armed triggers and fire matching ones."""
628
+ return _match_triggers(
629
+ text,
630
+ use_semantic=use_semantic,
631
+ semantic_threshold=semantic_threshold,
632
+ fire=True,
633
+ )
614
634
 
615
635
 
616
636
  def list_triggers(status: str = "armed") -> list[dict]: