nexo-brain 2.7.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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 +290 -6
  6. package/src/cli.py +111 -0
  7. package/src/client_preferences.py +94 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +140 -0
  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/runtime.py +918 -7
  22. package/src/evolution_cycle.py +62 -0
  23. package/src/hook_guardrails.py +308 -0
  24. package/src/hooks/protocol-guardrail.sh +10 -0
  25. package/src/nexo_sdk.py +103 -0
  26. package/src/plugins/cognitive_memory.py +18 -0
  27. package/src/plugins/cortex.py +55 -35
  28. package/src/plugins/guard.py +132 -16
  29. package/src/plugins/protocol.py +911 -0
  30. package/src/plugins/schedule.py +40 -6
  31. package/src/plugins/simple_api.py +103 -0
  32. package/src/plugins/skills.py +67 -0
  33. package/src/plugins/state_watchers.py +79 -0
  34. package/src/plugins/workflow.py +588 -0
  35. package/src/public_contribution.py +86 -12
  36. package/src/script_registry.py +142 -0
  37. package/src/scripts/deep-sleep/apply_findings.py +204 -0
  38. package/src/scripts/deep-sleep/collect.py +49 -4
  39. package/src/scripts/nexo-agent-run.py +2 -0
  40. package/src/scripts/nexo-daily-self-audit.py +843 -5
  41. package/src/scripts/nexo-evolution-run.py +343 -1
  42. package/src/server.py +92 -6
  43. package/src/skills_runtime.py +151 -0
  44. package/src/state_watchers_runtime.py +334 -0
  45. package/src/tools_learnings.py +345 -7
  46. package/src/tools_sessions.py +183 -0
  47. package/templates/CLAUDE.md.template +9 -1
  48. 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
@@ -30,6 +30,12 @@ AUTOMATION_BACKEND_KEYS = (
30
30
  CLIENT_CLAUDE_CODE,
31
31
  CLIENT_CODEX,
32
32
  )
33
+ AUTOMATION_TASK_PROFILE_KEYS = (
34
+ "default",
35
+ "fast",
36
+ "balanced",
37
+ "deep",
38
+ )
33
39
  INSTALL_PREFERENCE_KEYS = {
34
40
  "ask",
35
41
  "auto",
@@ -40,6 +46,10 @@ DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
40
46
  DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
41
47
  DEFAULT_CODEX_MODEL = "gpt-5.4"
42
48
  DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
49
+ DEFAULT_FAST_MODEL = "gpt-5.4-mini"
50
+ DEFAULT_FAST_REASONING_EFFORT = "medium"
51
+
52
+
43
53
  def _user_home() -> Path:
44
54
  return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
45
55
 
@@ -77,6 +87,7 @@ def default_client_preferences() -> dict:
77
87
  "automation_enabled": True,
78
88
  "automation_backend": CLIENT_CLAUDE_CODE,
79
89
  "client_runtime_profiles": default_client_runtime_profiles(),
90
+ "automation_task_profiles": default_automation_task_profiles(),
80
91
  "client_install_preferences": {
81
92
  CLIENT_CLAUDE_CODE: "ask",
82
93
  CLIENT_CODEX: "ask",
@@ -237,6 +248,31 @@ def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
237
248
  }
238
249
 
239
250
 
251
+ def default_automation_task_profiles() -> dict[str, dict[str, str]]:
252
+ return {
253
+ "default": {
254
+ "backend": "",
255
+ "model": "",
256
+ "reasoning_effort": "",
257
+ },
258
+ "fast": {
259
+ "backend": CLIENT_CODEX,
260
+ "model": DEFAULT_FAST_MODEL,
261
+ "reasoning_effort": DEFAULT_FAST_REASONING_EFFORT,
262
+ },
263
+ "balanced": {
264
+ "backend": "",
265
+ "model": "",
266
+ "reasoning_effort": "",
267
+ },
268
+ "deep": {
269
+ "backend": CLIENT_CLAUDE_CODE,
270
+ "model": DEFAULT_CLAUDE_CODE_MODEL,
271
+ "reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
272
+ },
273
+ }
274
+
275
+
240
276
  def _normalize_runtime_model(value, *, default: str) -> str:
241
277
  candidate = str(value or "").strip()
242
278
  return candidate or default
@@ -276,6 +312,31 @@ def normalize_client_runtime_profiles(value) -> dict[str, dict[str, str]]:
276
312
  return normalized
277
313
 
278
314
 
315
+ def normalize_automation_task_profiles(value) -> dict[str, dict[str, str]]:
316
+ defaults = default_automation_task_profiles()
317
+ normalized = {key: dict(profile) for key, profile in defaults.items()}
318
+ if not isinstance(value, dict):
319
+ return normalized
320
+
321
+ for raw_profile, raw_value in value.items():
322
+ profile_key = str(raw_profile or "").strip().lower()
323
+ if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
324
+ continue
325
+ if not isinstance(raw_value, dict):
326
+ continue
327
+ backend = normalize_backend_key(raw_value.get("backend"))
328
+ if backend == BACKEND_NONE:
329
+ backend = ""
330
+ normalized[profile_key] = {
331
+ "backend": backend or defaults[profile_key]["backend"],
332
+ "model": str(raw_value.get("model") or defaults[profile_key]["model"]).strip(),
333
+ "reasoning_effort": str(
334
+ raw_value.get("reasoning_effort") or defaults[profile_key]["reasoning_effort"]
335
+ ).strip().lower(),
336
+ }
337
+ return normalized
338
+
339
+
279
340
  def normalize_client_preferences(
280
341
  schedule: dict | None = None,
281
342
  *,
@@ -312,6 +373,9 @@ def normalize_client_preferences(
312
373
  "automation_enabled": automation_enabled,
313
374
  "automation_backend": automation_backend,
314
375
  "client_runtime_profiles": runtime_profiles,
376
+ "automation_task_profiles": normalize_automation_task_profiles(
377
+ schedule.get("automation_task_profiles")
378
+ ),
315
379
  "client_install_preferences": install_preferences,
316
380
  }
317
381
 
@@ -325,6 +389,7 @@ def apply_client_preferences(
325
389
  automation_enabled=None,
326
390
  automation_backend: str | None = None,
327
391
  client_runtime_profiles: dict | None = None,
392
+ automation_task_profiles: dict | None = None,
328
393
  client_install_preferences: dict | None = None,
329
394
  ) -> dict:
330
395
  merged = dict(schedule or {})
@@ -352,6 +417,11 @@ def apply_client_preferences(
352
417
  if client_runtime_profiles is not None
353
418
  else current["client_runtime_profiles"]
354
419
  )
420
+ merged["automation_task_profiles"] = normalize_automation_task_profiles(
421
+ automation_task_profiles
422
+ if automation_task_profiles is not None
423
+ else current["automation_task_profiles"]
424
+ )
355
425
  merged["client_install_preferences"] = normalize_client_install_preferences(
356
426
  client_install_preferences
357
427
  if client_install_preferences is not None
@@ -372,6 +442,7 @@ def save_client_preferences(
372
442
  automation_enabled=None,
373
443
  automation_backend: str | None = None,
374
444
  client_runtime_profiles: dict | None = None,
445
+ automation_task_profiles: dict | None = None,
375
446
  client_install_preferences: dict | None = None,
376
447
  ) -> Path:
377
448
  schedule = apply_client_preferences(
@@ -382,6 +453,7 @@ def save_client_preferences(
382
453
  automation_enabled=automation_enabled,
383
454
  automation_backend=automation_backend,
384
455
  client_runtime_profiles=client_runtime_profiles,
456
+ automation_task_profiles=automation_task_profiles,
385
457
  client_install_preferences=client_install_preferences,
386
458
  )
387
459
  return save_schedule_config(schedule)
@@ -477,3 +549,25 @@ def resolve_client_runtime_profile(
477
549
  default=defaults[client_key]["reasoning_effort"],
478
550
  ),
479
551
  }
552
+
553
+
554
+ def resolve_automation_task_profile(
555
+ profile: str | None,
556
+ *,
557
+ preferences: dict | None = None,
558
+ ) -> dict[str, str]:
559
+ normalized = preferences or load_client_preferences()
560
+ defaults = default_automation_task_profiles()
561
+ profile_key = str(profile or "").strip().lower() or "default"
562
+ if profile_key not in AUTOMATION_TASK_PROFILE_KEYS:
563
+ profile_key = "default"
564
+ configured = normalize_automation_task_profiles(normalized.get("automation_task_profiles"))
565
+ selected = dict(configured.get(profile_key) or defaults[profile_key])
566
+ backend = selected.get("backend") or resolve_automation_backend(normalized)
567
+ runtime_profile = resolve_client_runtime_profile(backend, preferences=normalized)
568
+ return {
569
+ "name": profile_key,
570
+ "backend": backend,
571
+ "model": selected.get("model") or runtime_profile["model"],
572
+ "reasoning_effort": selected.get("reasoning_effort") or runtime_profile["reasoning_effort"],
573
+ }
@@ -5,6 +5,8 @@ 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
@@ -308,6 +310,179 @@ def _write_json_object(path: Path, payload: dict) -> None:
308
310
  path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
309
311
 
310
312
 
313
+ CORE_HOOK_SPECS = [
314
+ {
315
+ "event": "SessionStart",
316
+ "identity": "session-start-ts",
317
+ "timeout": 2,
318
+ "command_template": lambda nexo_home, _runtime_root, _hooks_dir: (
319
+ f"mkdir -p {shlex.quote(str(nexo_home / 'operations'))} && "
320
+ f"date +%s > {shlex.quote(str(nexo_home / 'operations' / '.session-start-ts'))}"
321
+ ),
322
+ },
323
+ {
324
+ "event": "SessionStart",
325
+ "identity": "daily-briefing-check.sh",
326
+ "timeout": 5,
327
+ "script": "daily-briefing-check.sh",
328
+ },
329
+ {
330
+ "event": "SessionStart",
331
+ "identity": "session-start.sh",
332
+ "timeout": 35,
333
+ "script": "session-start.sh",
334
+ },
335
+ {
336
+ "event": "Stop",
337
+ "identity": "session-stop.sh",
338
+ "timeout": 10,
339
+ "script": "session-stop.sh",
340
+ },
341
+ {
342
+ "event": "PostToolUse",
343
+ "identity": "capture-tool-logs.sh",
344
+ "timeout": 5,
345
+ "script": "capture-tool-logs.sh",
346
+ },
347
+ {
348
+ "event": "PostToolUse",
349
+ "identity": "capture-session.sh",
350
+ "timeout": 3,
351
+ "script": "capture-session.sh",
352
+ },
353
+ {
354
+ "event": "PostToolUse",
355
+ "identity": "inbox-hook.sh",
356
+ "timeout": 5,
357
+ "script": "inbox-hook.sh",
358
+ },
359
+ {
360
+ "event": "PostToolUse",
361
+ "identity": "protocol-guardrail.sh",
362
+ "timeout": 5,
363
+ "script": "protocol-guardrail.sh",
364
+ },
365
+ {
366
+ "event": "PreCompact",
367
+ "identity": "pre-compact.sh",
368
+ "timeout": 10,
369
+ "script": "pre-compact.sh",
370
+ },
371
+ {
372
+ "event": "PostCompact",
373
+ "identity": "post-compact.sh",
374
+ "timeout": 10,
375
+ "script": "post-compact.sh",
376
+ },
377
+ ]
378
+
379
+
380
+ def _resolve_hook_source_dir(runtime_root: Path) -> Path:
381
+ direct = runtime_root / "hooks"
382
+ if direct.is_dir():
383
+ return direct
384
+ sibling = runtime_root.parent / "src" / "hooks"
385
+ if sibling.is_dir():
386
+ return sibling
387
+ fallback = runtime_root.parent / "hooks"
388
+ if fallback.is_dir():
389
+ return fallback
390
+ return direct
391
+
392
+
393
+ def _render_hook_command(spec: dict, *, nexo_home: Path, runtime_root: Path, hooks_dir: Path) -> str:
394
+ command_template = spec.get("command_template")
395
+ if callable(command_template):
396
+ return command_template(nexo_home, runtime_root, hooks_dir)
397
+ script_name = spec.get("script", "").strip()
398
+ script_path = hooks_dir / script_name
399
+ return (
400
+ f"NEXO_HOME={shlex.quote(str(nexo_home))} "
401
+ f"NEXO_CODE={shlex.quote(str(runtime_root))} "
402
+ f"bash {shlex.quote(str(script_path))}"
403
+ )
404
+
405
+
406
+ def _hook_identity(command: str) -> str:
407
+ text = str(command or "")
408
+ if ".session-start-ts" in text:
409
+ return "session-start-ts"
410
+ match = re.search(r"([A-Za-z0-9._-]+\.sh)\b", text)
411
+ if match:
412
+ return match.group(1)
413
+ return text.strip()
414
+
415
+
416
+ def _normalize_hook_sections(entries) -> list[dict]:
417
+ normalized: list[dict] = []
418
+ for entry in entries or []:
419
+ if not isinstance(entry, dict):
420
+ continue
421
+ hooks = entry.get("hooks")
422
+ if isinstance(hooks, list):
423
+ normalized.append(
424
+ {
425
+ "matcher": entry.get("matcher", "*") or "*",
426
+ "hooks": [dict(hook) for hook in hooks if isinstance(hook, dict)],
427
+ }
428
+ )
429
+ continue
430
+ if entry.get("command"):
431
+ hook = {"type": entry.get("type", "command"), "command": entry["command"]}
432
+ if entry.get("timeout"):
433
+ hook["timeout"] = entry["timeout"]
434
+ normalized.append({"matcher": entry.get("matcher", "*") or "*", "hooks": [hook]})
435
+ return normalized
436
+
437
+
438
+ def _merge_core_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
439
+ hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
440
+ hooks_dir = _resolve_hook_source_dir(runtime_root)
441
+ managed_count = 0
442
+
443
+ for spec in CORE_HOOK_SPECS:
444
+ event = spec["event"]
445
+ sections = _normalize_hook_sections(hooks_payload.get(event))
446
+ hooks_payload[event] = sections
447
+ command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
448
+ identity = spec["identity"]
449
+
450
+ found = False
451
+ for section in sections:
452
+ for hook in section["hooks"]:
453
+ if _hook_identity(hook.get("command", "")) != identity:
454
+ continue
455
+ hook["type"] = "command"
456
+ hook["command"] = command
457
+ if spec.get("timeout"):
458
+ hook["timeout"] = spec["timeout"]
459
+ found = True
460
+ managed_count += 1
461
+ break
462
+ if found:
463
+ break
464
+
465
+ if found:
466
+ continue
467
+
468
+ target = None
469
+ for section in sections:
470
+ if section.get("matcher", "*") == "*":
471
+ target = section
472
+ break
473
+ if target is None:
474
+ target = {"matcher": "*", "hooks": []}
475
+ sections.append(target)
476
+
477
+ new_hook = {"type": "command", "command": command}
478
+ if spec.get("timeout"):
479
+ new_hook["timeout"] = spec["timeout"]
480
+ target["hooks"].append(new_hook)
481
+ managed_count += 1
482
+
483
+ return hooks_payload, managed_count
484
+
485
+
311
486
  def _sync_json_client(path: Path, server_config: dict, label: str, *, managed_metadata: dict | None = None) -> dict:
312
487
  payload = _load_json_object(path)
313
488
  mcp_servers = payload.setdefault("mcpServers", {})
@@ -343,6 +518,32 @@ def _claude_desktop_managed_metadata(server_config: dict, *, operator_name: str)
343
518
  }
344
519
 
345
520
 
521
+ def _sync_claude_code_settings(path: Path, server_config: dict) -> dict:
522
+ payload = _load_json_object(path)
523
+ mcp_servers = payload.setdefault("mcpServers", {})
524
+ if not isinstance(mcp_servers, dict):
525
+ mcp_servers = {}
526
+ payload["mcpServers"] = mcp_servers
527
+ action = "updated" if "nexo" in mcp_servers else "created"
528
+ mcp_servers["nexo"] = server_config
529
+
530
+ runtime_root = Path(server_config.get("env", {}).get("NEXO_CODE", "")).expanduser()
531
+ nexo_home = Path(server_config.get("env", {}).get("NEXO_HOME", "")).expanduser()
532
+ payload["hooks"], managed_hook_count = _merge_core_hooks(
533
+ payload.get("hooks", {}),
534
+ runtime_root=runtime_root,
535
+ nexo_home=nexo_home,
536
+ )
537
+ _write_json_object(path, payload)
538
+ return {
539
+ "ok": True,
540
+ "client": "claude_code",
541
+ "action": action,
542
+ "path": str(path),
543
+ "managed_hook_count": managed_hook_count,
544
+ }
545
+
546
+
346
547
  def sync_claude_code(
347
548
  *,
348
549
  nexo_home: str | os.PathLike[str] | None = None,
@@ -358,10 +559,9 @@ def sync_claude_code(
358
559
  python_path=python_path,
359
560
  operator_name=operator_name,
360
561
  )
361
- result = _sync_json_client(
562
+ result = _sync_claude_code_settings(
362
563
  _claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
363
564
  server_config,
364
- "claude_code",
365
565
  )
366
566
  bootstrap_result = sync_client_bootstrap(
367
567
  "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]: