delimit-cli 4.0.3 → 4.0.4

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.
@@ -7,6 +7,9 @@ Config: ~/.delimit/swarm/config.yml
7
7
  """
8
8
 
9
9
  import json
10
+ import os
11
+ import signal
12
+ import sys
10
13
  import time
11
14
  from pathlib import Path
12
15
  from typing import Any, Dict, List, Optional
@@ -592,6 +595,168 @@ def check_docs_freshness(
592
595
  }
593
596
 
594
597
 
598
+ # ═══════════════════════════════════════════════════════════════════════
599
+ # Swarm Governance Auto-Triggers — NEVER skip these
600
+ # Runs pre-flight checks before any major action
601
+ # ═══════════════════════════════════════════════════════════════════════
602
+
603
+ PREFLIGHT_LOG = SWARM_DIR / "preflight_log.jsonl"
604
+
605
+
606
+ def preflight_check(
607
+ action: str,
608
+ venture: str = "",
609
+ path: str = "",
610
+ agent_id: str = "",
611
+ ) -> Dict[str, Any]:
612
+ """Run mandatory governance checks before any major swarm action.
613
+
614
+ This MUST be called before:
615
+ - Creating a new project/venture
616
+ - Deploying to production
617
+ - Publishing to npm
618
+ - Creating new agents or tools
619
+ - Any cross-venture operation
620
+
621
+ Returns a gate result: PASS (proceed), WARN (proceed with caution),
622
+ or BLOCK (stop and fix issues first).
623
+ """
624
+ _ensure_dir()
625
+ checks = []
626
+ gate = "PASS"
627
+
628
+ # 1. Venture must be registered
629
+ if venture:
630
+ ventures = _load_ventures()
631
+ if venture not in ventures.get("ventures", {}):
632
+ checks.append({
633
+ "check": "venture_registered",
634
+ "status": "FAIL",
635
+ "message": f"Venture '{venture}' is not registered. Call delimit_swarm(action='register') first.",
636
+ "required_action": "register_venture",
637
+ })
638
+ gate = "BLOCK"
639
+ else:
640
+ checks.append({"check": "venture_registered", "status": "PASS"})
641
+
642
+ # 2. Agent must exist and be authorized
643
+ if agent_id:
644
+ registry = _load_registry()
645
+ agent = registry["agents"].get(agent_id, {})
646
+ if not agent:
647
+ checks.append({
648
+ "check": "agent_exists",
649
+ "status": "FAIL",
650
+ "message": f"Agent '{agent_id}' not found in registry.",
651
+ })
652
+ gate = "BLOCK"
653
+ elif agent.get("status") != "active":
654
+ checks.append({
655
+ "check": "agent_active",
656
+ "status": "FAIL",
657
+ "message": f"Agent '{agent_id}' is not active (status: {agent.get('status')}).",
658
+ })
659
+ gate = "BLOCK"
660
+ else:
661
+ checks.append({"check": "agent_authorized", "status": "PASS"})
662
+
663
+ # 3. Namespace isolation check
664
+ if venture and path:
665
+ ventures = _load_ventures()
666
+ v_data = ventures.get("ventures", {}).get(venture, {})
667
+ v_path = v_data.get("path", "")
668
+ if v_path and not path.startswith(v_path):
669
+ checks.append({
670
+ "check": "namespace_isolation",
671
+ "status": "WARN",
672
+ "message": f"Path '{path}' is outside venture namespace '{v_path}'.",
673
+ })
674
+ if gate == "PASS":
675
+ gate = "WARN"
676
+ else:
677
+ checks.append({"check": "namespace_isolation", "status": "PASS"})
678
+
679
+ # 4. Action-specific checks
680
+ if action in ("deploy_production", "publish_npm"):
681
+ # Must have run scan
682
+ checks.append({
683
+ "check": "pre_deploy_scan",
684
+ "status": "WARN",
685
+ "message": "Ensure delimit_scan, delimit_security_audit, and delimit_test_smoke have been run.",
686
+ "required_tools": ["delimit_scan", "delimit_security_audit", "delimit_test_smoke"],
687
+ })
688
+ if gate == "PASS":
689
+ gate = "WARN"
690
+
691
+ if action == "new_project":
692
+ checks.append({
693
+ "check": "project_init",
694
+ "status": "WARN",
695
+ "message": "New project: ensure delimit_scan is run after scaffolding.",
696
+ "required_tools": ["delimit_scan", "delimit_swarm(action='register')"],
697
+ })
698
+ if gate == "PASS":
699
+ gate = "WARN"
700
+
701
+ if action == "create_tool" or action == "create_agent":
702
+ checks.append({
703
+ "check": "extension_governance",
704
+ "status": "PASS" if agent_id else "WARN",
705
+ "message": "Self-extension requires architect role and founder approval for activation.",
706
+ })
707
+
708
+ # 5. Collision check
709
+ if path:
710
+ try:
711
+ from ai.collision_detect import check_collisions
712
+ collisions = check_collisions()
713
+ if collisions.get("conflicts"):
714
+ checks.append({
715
+ "check": "collision_free",
716
+ "status": "WARN",
717
+ "message": f"{len(collisions['conflicts'])} file collision(s) detected.",
718
+ })
719
+ if gate == "PASS":
720
+ gate = "WARN"
721
+ else:
722
+ checks.append({"check": "collision_free", "status": "PASS"})
723
+ except ImportError:
724
+ checks.append({"check": "collision_free", "status": "SKIP"})
725
+
726
+ # Log the preflight
727
+ log_entry = {
728
+ "timestamp": time.time(),
729
+ "action": action,
730
+ "venture": venture,
731
+ "agent_id": agent_id,
732
+ "gate": gate,
733
+ "checks_passed": sum(1 for c in checks if c["status"] == "PASS"),
734
+ "checks_total": len(checks),
735
+ }
736
+ try:
737
+ with open(PREFLIGHT_LOG, "a") as f:
738
+ f.write(json.dumps(log_entry) + "\n")
739
+ except Exception:
740
+ pass
741
+
742
+ _log({"action": "preflight_check", "gate": gate, "venture": venture,
743
+ "checks": len(checks), "action_type": action})
744
+
745
+ return {
746
+ "gate": gate,
747
+ "action": action,
748
+ "venture": venture,
749
+ "checks": checks,
750
+ "passed": sum(1 for c in checks if c["status"] == "PASS"),
751
+ "total": len(checks),
752
+ "message": {
753
+ "PASS": "All governance checks passed. Proceed.",
754
+ "WARN": "Governance checks passed with warnings. Review before proceeding.",
755
+ "BLOCK": "Governance checks FAILED. Fix blocking issues before proceeding.",
756
+ }[gate],
757
+ }
758
+
759
+
595
760
  # ═══════════════════════════════════════════════════════════════════════
596
761
  # LED-279: Self-Extending Swarm — Founder Mode
597
762
  # Agents can create new MCP tools when authorized
@@ -696,3 +861,272 @@ def list_custom_tools(venture: str = "") -> Dict[str, Any]:
696
861
  "total": len(tools),
697
862
  "venture_filter": venture or "all",
698
863
  }
864
+
865
+
866
+ # ═══════════════════════════════════════════════════════════════════════
867
+ # MCP Hot Reload — Option B: subprocess restart with state transfer
868
+ # Consensus: Grok + Gemini agreed on subprocess restart with IPC
869
+ # ═══════════════════════════════════════════════════════════════════════
870
+
871
+ STATE_FILE = SWARM_DIR / "reload_state.json"
872
+ RESTART_FLAG = SWARM_DIR / "restart_pending"
873
+
874
+
875
+ def hot_reload(reason: str = "update") -> Dict[str, Any]:
876
+ """Restart the MCP server process to pick up new module code.
877
+
878
+ Saves current state (registry, ventures, metrics, custom tools list)
879
+ to a transfer file, signals the parent process to restart, and the
880
+ new process ingests the state on boot.
881
+
882
+ Works across all AI CLIs because MCP servers are subprocesses —
883
+ the CLI reconnects automatically within its timeout window (5-10s).
884
+ """
885
+ _ensure_dir()
886
+
887
+ # 1. Capture current state for transfer
888
+ state = {
889
+ "timestamp": time.time(),
890
+ "reason": reason,
891
+ "registry": _load_registry(),
892
+ "ventures": _load_ventures(),
893
+ "metrics": _load_metrics(),
894
+ "custom_tools": list_custom_tools().get("tools", []),
895
+ }
896
+ STATE_FILE.write_text(json.dumps(state, indent=2))
897
+
898
+ # 2. Write restart flag (picked up by boot sequence)
899
+ RESTART_FLAG.write_text(json.dumps({
900
+ "requested_at": time.time(),
901
+ "reason": reason,
902
+ "pid": os.getpid(),
903
+ }))
904
+
905
+ _log({"action": "hot_reload_requested", "reason": reason, "pid": os.getpid()})
906
+
907
+ # 3. Schedule graceful restart — send SIGHUP to self after brief delay
908
+ # The MCP framework (FastMCP) handles SIGHUP by restarting the server
909
+ # If SIGHUP isn't supported, fall back to writing the flag for manual pickup
910
+ restart_method = "flag"
911
+ try:
912
+ # Check if we're the MCP server process
913
+ if os.environ.get("DELIMIT_MCP_PID"):
914
+ mcp_pid = int(os.environ["DELIMIT_MCP_PID"])
915
+ os.kill(mcp_pid, signal.SIGHUP)
916
+ restart_method = "sighup"
917
+ except (ValueError, ProcessLookupError, PermissionError):
918
+ pass
919
+
920
+ return {
921
+ "status": "reload_scheduled",
922
+ "method": restart_method,
923
+ "state_file": str(STATE_FILE),
924
+ "reason": reason,
925
+ "message": f"MCP server reload scheduled ({restart_method}). "
926
+ "AI CLI will reconnect within 5-10 seconds. "
927
+ "Session context (ledger, memory, conversation) is preserved.",
928
+ "next_step": "The MCP server will restart and load updated modules. "
929
+ "No action needed — tools will be available again momentarily.",
930
+ }
931
+
932
+
933
+ def ingest_reload_state() -> Dict[str, Any]:
934
+ """Called on MCP server boot to restore state from a hot reload.
935
+
936
+ Returns the transferred state if a reload just happened, or empty
937
+ if this is a fresh boot.
938
+ """
939
+ if not STATE_FILE.exists():
940
+ return {"status": "fresh_boot", "restored": False}
941
+
942
+ try:
943
+ state = json.loads(STATE_FILE.read_text())
944
+ age = time.time() - state.get("timestamp", 0)
945
+
946
+ # Only ingest if state is less than 60 seconds old
947
+ if age > 60:
948
+ STATE_FILE.unlink(missing_ok=True)
949
+ return {"status": "stale_state", "restored": False, "age_seconds": age}
950
+
951
+ # Clean up
952
+ STATE_FILE.unlink(missing_ok=True)
953
+ RESTART_FLAG.unlink(missing_ok=True)
954
+
955
+ _log({"action": "reload_state_ingested", "reason": state.get("reason"), "age": age})
956
+
957
+ return {
958
+ "status": "restored",
959
+ "restored": True,
960
+ "reason": state.get("reason", "unknown"),
961
+ "age_seconds": round(age, 1),
962
+ "registry_agents": len(state.get("registry", {}).get("agents", {})),
963
+ "ventures": len(state.get("ventures", {}).get("ventures", {})),
964
+ "custom_tools": len(state.get("custom_tools", [])),
965
+ }
966
+ except (json.JSONDecodeError, KeyError):
967
+ STATE_FILE.unlink(missing_ok=True)
968
+ return {"status": "corrupt_state", "restored": False}
969
+
970
+
971
+ # ═══════════════════════════════════════════════════════════════════════
972
+ # Swarm Self-Scaling — as ventures grow, so does the workforce
973
+ # Architect agents can provision new specialist roles
974
+ # ═══════════════════════════════════════════════════════════════════════
975
+
976
+ CUSTOM_ROLES_FILE = SWARM_DIR / "custom_roles.json"
977
+
978
+
979
+ def create_agent(
980
+ venture: str,
981
+ role_name: str,
982
+ description: str,
983
+ default_model: str = "claude-opus-4.6",
984
+ fallback_model: str = "gemini-3.1-pro-preview",
985
+ permissions: Optional[List[str]] = None,
986
+ creator_agent_id: str = "",
987
+ ) -> Dict[str, Any]:
988
+ """Create a new specialist agent role for a venture.
989
+
990
+ Only the Architect agent can create new roles. New roles inherit
991
+ the venture's namespace isolation but get scoped permissions.
992
+ The agent is registered but requires founder approval to activate.
993
+
994
+ All models see new agents via the standard MCP tool interface —
995
+ no model-specific configuration needed.
996
+ """
997
+ if not venture or not role_name:
998
+ return {"error": "venture and role_name are required"}
999
+
1000
+ # Verify creator has authority
1001
+ registry = _load_registry()
1002
+ creator = registry["agents"].get(creator_agent_id, {})
1003
+ if creator.get("role") != "architect":
1004
+ return {
1005
+ "error": f"Only architect agents can create new roles. "
1006
+ f"Agent '{creator_agent_id}' has role '{creator.get('role', 'unknown')}'.",
1007
+ }
1008
+
1009
+ # Verify venture namespace
1010
+ if creator.get("venture", "") != venture:
1011
+ return {"error": f"Agent '{creator_agent_id}' cannot create roles for venture '{venture}'"}
1012
+
1013
+ # Normalize role name
1014
+ safe_role = role_name.lower().replace("-", "_").replace(" ", "_")
1015
+
1016
+ # Check for duplicate
1017
+ if safe_role in DEFAULT_ROSTER:
1018
+ return {"error": f"Cannot override built-in role '{safe_role}'"}
1019
+
1020
+ # Load or create custom roles registry
1021
+ custom_roles = {}
1022
+ if CUSTOM_ROLES_FILE.exists():
1023
+ try:
1024
+ custom_roles = json.loads(CUSTOM_ROLES_FILE.read_text())
1025
+ except json.JSONDecodeError:
1026
+ custom_roles = {}
1027
+
1028
+ venture_roles = custom_roles.setdefault(venture, {})
1029
+ if safe_role in venture_roles:
1030
+ return {"error": f"Role '{safe_role}' already exists for venture '{venture}'"}
1031
+
1032
+ # Create the role definition
1033
+ role_def = {
1034
+ "role": description,
1035
+ "default_model": default_model,
1036
+ "fallback_model": fallback_model,
1037
+ "permissions": permissions or ["read", "suggest"],
1038
+ "created_by": creator_agent_id,
1039
+ "created_at": time.time(),
1040
+ "status": "pending_approval",
1041
+ }
1042
+ venture_roles[safe_role] = role_def
1043
+
1044
+ # Save
1045
+ _ensure_dir()
1046
+ CUSTOM_ROLES_FILE.write_text(json.dumps(custom_roles, indent=2))
1047
+
1048
+ # Auto-register the agent (inactive until approved)
1049
+ agent_id = f"{venture}_{safe_role}"
1050
+ registry["agents"][agent_id] = {
1051
+ "venture": venture,
1052
+ "role": safe_role,
1053
+ "model": default_model,
1054
+ "fallback": fallback_model,
1055
+ "status": "pending_approval",
1056
+ "registered_at": time.time(),
1057
+ "custom": True,
1058
+ }
1059
+ _save_registry(registry)
1060
+
1061
+ _log({
1062
+ "action": "agent_created",
1063
+ "venture": venture,
1064
+ "role": safe_role,
1065
+ "model": default_model,
1066
+ "created_by": creator_agent_id,
1067
+ "status": "pending_approval",
1068
+ })
1069
+
1070
+ return {
1071
+ "status": "created",
1072
+ "agent_id": agent_id,
1073
+ "role": safe_role,
1074
+ "venture": venture,
1075
+ "model": default_model,
1076
+ "permissions": role_def["permissions"],
1077
+ "created_by": creator_agent_id,
1078
+ "activation": "pending_approval",
1079
+ "message": f"Agent '{agent_id}' created with role '{safe_role}'. "
1080
+ f"Founder approval required to activate.",
1081
+ }
1082
+
1083
+
1084
+ def approve_agent(agent_id: str) -> Dict[str, Any]:
1085
+ """Approve a pending custom agent for activation (founder only)."""
1086
+ registry = _load_registry()
1087
+ agent = registry["agents"].get(agent_id)
1088
+ if not agent:
1089
+ return {"error": f"Agent '{agent_id}' not found"}
1090
+ if not agent.get("custom"):
1091
+ return {"error": f"Agent '{agent_id}' is a built-in role, not a custom agent"}
1092
+ if agent.get("status") == "active":
1093
+ return {"status": "already_active", "agent_id": agent_id}
1094
+
1095
+ agent["status"] = "active"
1096
+ agent["approved_at"] = time.time()
1097
+ _save_registry(registry)
1098
+
1099
+ _log({"action": "agent_approved", "agent_id": agent_id})
1100
+
1101
+ return {
1102
+ "status": "activated",
1103
+ "agent_id": agent_id,
1104
+ "role": agent.get("role"),
1105
+ "venture": agent.get("venture"),
1106
+ "message": f"Agent '{agent_id}' is now active.",
1107
+ }
1108
+
1109
+
1110
+ def list_agents(venture: str = "") -> Dict[str, Any]:
1111
+ """List all agents — built-in and custom — optionally filtered by venture."""
1112
+ registry = _load_registry()
1113
+ agents = []
1114
+
1115
+ for agent_id, agent in registry["agents"].items():
1116
+ if venture and agent.get("venture") != venture:
1117
+ continue
1118
+ agents.append({
1119
+ "id": agent_id,
1120
+ "venture": agent.get("venture", ""),
1121
+ "role": agent.get("role", ""),
1122
+ "model": agent.get("model", ""),
1123
+ "status": agent.get("status", "active"),
1124
+ "custom": agent.get("custom", False),
1125
+ })
1126
+
1127
+ return {
1128
+ "status": "ok",
1129
+ "agents": agents,
1130
+ "total": len(agents),
1131
+ "venture_filter": venture or "all",
1132
+ }