@agentikos/omega-os 0.1.0 → 0.2.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 (73) hide show
  1. package/README.md +25 -13
  2. package/bootstrap/lib/steps.sh +214 -9
  3. package/bootstrap/manifest.example.yaml +6 -1
  4. package/docs/COMPLETION-PLAN.md +48 -0
  5. package/omega/Agentik_Engine/README.md +25 -10
  6. package/omega/Agentik_Engine/omega_engine/__init__.py +66 -2
  7. package/omega/Agentik_Engine/omega_engine/account.py +505 -0
  8. package/omega/Agentik_Engine/omega_engine/autonomous.py +538 -0
  9. package/omega/Agentik_Engine/omega_engine/cli.py +467 -29
  10. package/omega/Agentik_Engine/omega_engine/daemons/__init__.py +14 -0
  11. package/omega/Agentik_Engine/omega_engine/daemons/autonomous.py +56 -0
  12. package/omega/Agentik_Engine/omega_engine/daemons/engine.py +187 -0
  13. package/omega/Agentik_Engine/omega_engine/daemons/telegram.py +231 -0
  14. package/omega/Agentik_Engine/omega_engine/educators/__init__.py +51 -0
  15. package/omega/Agentik_Engine/omega_engine/educators/artifact.py +65 -0
  16. package/omega/Agentik_Engine/omega_engine/educators/automation.py +76 -0
  17. package/omega/Agentik_Engine/omega_engine/educators/base.py +327 -0
  18. package/omega/Agentik_Engine/omega_engine/educators/claudecode.py +71 -0
  19. package/omega/Agentik_Engine/omega_engine/educators/connection.py +75 -0
  20. package/omega/Agentik_Engine/omega_engine/educators/coworker.py +68 -0
  21. package/omega/Agentik_Engine/omega_engine/educators/loop.py +82 -0
  22. package/omega/Agentik_Engine/omega_engine/educators/prompt.py +68 -0
  23. package/omega/Agentik_Engine/omega_engine/educators/skill.py +69 -0
  24. package/omega/Agentik_Engine/omega_engine/executor.py +46 -6
  25. package/omega/Agentik_Engine/omega_engine/mission.py +13 -1
  26. package/omega/Agentik_Engine/omega_engine/provider.py +247 -1
  27. package/omega/Agentik_Engine/omega_engine/rag/__init__.py +21 -0
  28. package/omega/Agentik_Engine/omega_engine/rag/agentic.py +83 -0
  29. package/omega/Agentik_Engine/omega_engine/rag/base.py +42 -0
  30. package/omega/Agentik_Engine/omega_engine/rag/corrective.py +119 -0
  31. package/omega/Agentik_Engine/omega_engine/rag/graph.py +169 -0
  32. package/omega/Agentik_Engine/omega_engine/rag/hybrid.py +205 -0
  33. package/omega/Agentik_Engine/omega_engine/rag/multimodal.py +136 -0
  34. package/omega/Agentik_Engine/omega_engine/rag/router.py +110 -0
  35. package/omega/Agentik_Engine/omega_engine/reducer.py +21 -3
  36. package/omega/Agentik_Engine/omega_engine/store.py +65 -5
  37. package/omega/Agentik_Engine/omega_engine/sync.py +304 -0
  38. package/omega/Agentik_Engine/omega_engine/tools.py +272 -0
  39. package/omega/Agentik_Engine/pyproject.toml +1 -1
  40. package/omega/Agentik_Engine/tests/test_account.py +333 -0
  41. package/omega/Agentik_Engine/tests/test_autonomous.py +361 -0
  42. package/omega/Agentik_Engine/tests/test_educators.py +233 -0
  43. package/omega/Agentik_Engine/tests/test_rag.py +287 -0
  44. package/omega/Agentik_Engine/tests/test_snapshot_partial.py +172 -0
  45. package/omega/Agentik_Engine/tests/test_tools_and_sync.py +312 -0
  46. package/omega/Agentik_SSOT/skills/rag-route.md +73 -0
  47. package/package.json +1 -1
  48. package/omega/Agentik_Engine/omega_engine/__pycache__/__init__.cpython-313.pyc +0 -0
  49. package/omega/Agentik_Engine/omega_engine/__pycache__/audit.cpython-313.pyc +0 -0
  50. package/omega/Agentik_Engine/omega_engine/__pycache__/audit_arsenal.cpython-313.pyc +0 -0
  51. package/omega/Agentik_Engine/omega_engine/__pycache__/barrier.cpython-313.pyc +0 -0
  52. package/omega/Agentik_Engine/omega_engine/__pycache__/bus.cpython-313.pyc +0 -0
  53. package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
  54. package/omega/Agentik_Engine/omega_engine/__pycache__/events.cpython-313.pyc +0 -0
  55. package/omega/Agentik_Engine/omega_engine/__pycache__/executor.cpython-313.pyc +0 -0
  56. package/omega/Agentik_Engine/omega_engine/__pycache__/mission.cpython-313.pyc +0 -0
  57. package/omega/Agentik_Engine/omega_engine/__pycache__/progress.cpython-313.pyc +0 -0
  58. package/omega/Agentik_Engine/omega_engine/__pycache__/project.cpython-313.pyc +0 -0
  59. package/omega/Agentik_Engine/omega_engine/__pycache__/provider.cpython-313.pyc +0 -0
  60. package/omega/Agentik_Engine/omega_engine/__pycache__/reducer.cpython-313.pyc +0 -0
  61. package/omega/Agentik_Engine/omega_engine/__pycache__/report.cpython-313.pyc +0 -0
  62. package/omega/Agentik_Engine/omega_engine/__pycache__/router.cpython-313.pyc +0 -0
  63. package/omega/Agentik_Engine/omega_engine/__pycache__/store.cpython-313.pyc +0 -0
  64. package/omega/Agentik_Engine/omega_engine/__pycache__/supervisor.cpython-313.pyc +0 -0
  65. package/omega/Agentik_Engine/omega_engine/__pycache__/task.cpython-313.pyc +0 -0
  66. package/omega/Agentik_Engine/omega_engine/__pycache__/telegram.cpython-313.pyc +0 -0
  67. package/omega/Agentik_Engine/tests/__pycache__/test_audit_arsenal.cpython-313.pyc +0 -0
  68. package/omega/Agentik_Engine/tests/__pycache__/test_executor.cpython-313.pyc +0 -0
  69. package/omega/Agentik_Engine/tests/__pycache__/test_mission.cpython-313.pyc +0 -0
  70. package/omega/Agentik_Engine/tests/__pycache__/test_progress.cpython-313.pyc +0 -0
  71. package/omega/Agentik_Engine/tests/__pycache__/test_project.cpython-313.pyc +0 -0
  72. package/omega/Agentik_Engine/tests/__pycache__/test_reducer.cpython-313.pyc +0 -0
  73. package/omega/Agentik_Engine/tests/__pycache__/test_report.cpython-313.pyc +0 -0
@@ -0,0 +1,110 @@
1
+ """RAGRouter — classifies a query and picks the right inner strategy, then
2
+ wraps the result in the Corrective envelope.
3
+
4
+ Routing happens in two steps:
5
+ 1. A cheap heuristic ("does the query look relational? mention an image
6
+ or PDF? have a 'why/how' shape?") gives a default.
7
+ 2. The provider (role "rag-route") gets a chance to override; its choice
8
+ wins when it picks one of the registered strategies.
9
+
10
+ The Corrective envelope wraps every dispatch — that's the project's stance:
11
+ "check / recheck until 100%" applied to retrieval (ARCHITECTURE.md §7).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Iterable
17
+
18
+ from omega_engine.provider import AgentProvider, AgentRequest
19
+ from omega_engine.rag.base import RetrievalResult, Retriever
20
+ from omega_engine.rag.corrective import CorrectiveRetriever
21
+
22
+
23
+ _GRAPH_HINTS = re.compile(
24
+ r"\b(depend|related|connection|who|graph|owner|"
25
+ r"between|link|relation|adjacent|connected)\b",
26
+ re.IGNORECASE,
27
+ )
28
+ _MULTIMODAL_HINTS = re.compile(
29
+ r"\b(image|picture|screenshot|diagram|pdf|figure|chart|photo)\b",
30
+ re.IGNORECASE,
31
+ )
32
+ _AGENTIC_HINTS = re.compile(
33
+ r"\b(why|how|explain|compare|trace|step by step|deep dive)\b",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ class RAGRouter:
39
+ """Routes a query to one of the registered strategies, then wraps in CRAG."""
40
+
41
+ def __init__(
42
+ self,
43
+ strategies: dict[str, Retriever],
44
+ provider: AgentProvider,
45
+ *,
46
+ default: str = "hybrid",
47
+ corrective: bool = True,
48
+ threshold: float = 70.0,
49
+ max_retries: int = 2,
50
+ ) -> None:
51
+ if not strategies:
52
+ raise ValueError("RAGRouter needs at least one strategy")
53
+ self.strategies = dict(strategies)
54
+ self.provider = provider
55
+ self.default = default if default in self.strategies else next(iter(self.strategies))
56
+ self.corrective = corrective
57
+ self.threshold = threshold
58
+ self.max_retries = max_retries
59
+
60
+ def classify(self, query: str) -> str:
61
+ """Pick a strategy name. Heuristic first, provider may override."""
62
+ # 1. Cheap heuristic — works with no provider.
63
+ heuristic = self.default
64
+ if "multimodal" in self.strategies and _MULTIMODAL_HINTS.search(query):
65
+ heuristic = "multimodal"
66
+ elif "graph" in self.strategies and _GRAPH_HINTS.search(query):
67
+ heuristic = "graph"
68
+ elif "agentic" in self.strategies and _AGENTIC_HINTS.search(query):
69
+ heuristic = "agentic"
70
+
71
+ # 2. Provider override. If it returns a non-registered strategy, we
72
+ # ignore it (never trust a free-form provider blindly).
73
+ try:
74
+ result = self.provider.run(AgentRequest(
75
+ role="rag-route",
76
+ prompt=f"Classify retrieval strategy for: {query}",
77
+ context={
78
+ "query": query,
79
+ "available": sorted(self.strategies.keys()),
80
+ "heuristic": heuristic,
81
+ },
82
+ ))
83
+ chosen = (result.artifacts or {}).get("strategy")
84
+ if isinstance(chosen, str) and chosen in self.strategies:
85
+ return chosen
86
+ except Exception:
87
+ # Provider misbehaved — keep the heuristic, never crash retrieval.
88
+ pass
89
+ return heuristic
90
+
91
+ def retrieve(self, query: str, k: int = 5) -> RetrievalResult:
92
+ name = self.classify(query)
93
+ inner = self.strategies[name]
94
+ if self.corrective:
95
+ wrapped = CorrectiveRetriever(
96
+ inner,
97
+ self.provider,
98
+ threshold=self.threshold,
99
+ max_retries=self.max_retries,
100
+ )
101
+ result = wrapped.retrieve(query, k=k)
102
+ # Surface the chosen inner strategy in the result for observability.
103
+ result.strategy = f"router({name})->{result.strategy}"
104
+ return result
105
+ result = inner.retrieve(query, k=k)
106
+ result.strategy = f"router({name})->{result.strategy}"
107
+ return result
108
+
109
+ def names(self) -> Iterable[str]:
110
+ return self.strategies.keys()
@@ -63,14 +63,32 @@ def reduce(state: Optional[TaskState], event: Event) -> TaskState:
63
63
  return _TRANSITIONS[key]
64
64
 
65
65
 
66
- def reduce_task(events: Iterable[Event]) -> TaskState:
66
+ def reduce_task(events: Iterable[Event],
67
+ initial: Optional[TaskState] = None) -> TaskState:
67
68
  """Fold an ordered event stream into the current task state.
68
69
 
69
- The events MUST be ordered (the Event Store returns them by `ts, id`).
70
+ The events MUST be ordered (the Event Store returns them by rowid).
71
+ `initial` lets a snapshot-aware caller start the fold from a known state
72
+ (`store.events_since_snapshot` + `snap.state`) instead of from genesis.
70
73
  """
71
- state: Optional[TaskState] = None
74
+ state: Optional[TaskState] = initial
72
75
  for ev in events:
73
76
  state = reduce(state, ev)
74
77
  if state is None:
75
78
  raise IllegalTransition("empty event stream — task never created")
76
79
  return state
80
+
81
+
82
+ def reduce_task_fast(store, task_id: str) -> TaskState:
83
+ """Reduce a task using its latest snapshot if available, else full replay.
84
+
85
+ Bounded reduction time on a long event log. Correctness is identical to
86
+ `reduce_task(store.events_for(task_id))`.
87
+ """
88
+ snap = store.latest_snapshot(task_id)
89
+ if snap is None:
90
+ return reduce_task(store.events_for(task_id))
91
+ events_after = store.events_since_snapshot(task_id)
92
+ if not events_after:
93
+ return snap["state"]
94
+ return reduce_task(events_after, initial=snap["state"])
@@ -47,6 +47,18 @@ CREATE TABLE IF NOT EXISTS events (
47
47
  );
48
48
  CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id, ts, id);
49
49
  CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts, id);
50
+
51
+ -- Snapshots: bounded reduction time on a growing log. A snapshot captures the
52
+ -- reduced state of a task at a given event rowid; later reductions can start
53
+ -- from the snapshot instead of replaying from genesis.
54
+ CREATE TABLE IF NOT EXISTS snapshots (
55
+ task_id TEXT NOT NULL,
56
+ state TEXT NOT NULL,
57
+ ts REAL NOT NULL,
58
+ last_event_rowid INTEGER NOT NULL,
59
+ PRIMARY KEY (task_id, ts)
60
+ );
61
+ CREATE INDEX IF NOT EXISTS idx_snapshots_task ON snapshots(task_id, ts DESC);
50
62
  """
51
63
 
52
64
 
@@ -88,10 +100,58 @@ class SQLiteStore(EventStore):
88
100
  cur = self._conn.execute("SELECT DISTINCT task_id FROM events")
89
101
  return [r["task_id"] for r in cur.fetchall()]
90
102
 
103
+ # --- snapshots --------------------------------------------------------
104
+ # Snapshots are an optional optimisation. Correctness depends only on
105
+ # events; snapshots merely bound reduction time as the log grows.
106
+
107
+ def snapshot(self, task_id: str) -> dict | None:
108
+ """Capture the current reduced state of a task at the latest event.
109
+ Returns the snapshot row, or None if the task has no events."""
110
+ cur = self._conn.execute(
111
+ "SELECT rowid FROM events WHERE task_id = ? ORDER BY rowid DESC LIMIT 1",
112
+ (task_id,),
113
+ )
114
+ row = cur.fetchone()
115
+ if not row:
116
+ return None
117
+ last_rowid = row[0]
118
+ # Local import to keep `store` importable without `reducer`-time deps.
119
+ from omega_engine.reducer import reduce_task
120
+ events = self.events_for(task_id)
121
+ state = reduce_task(events)
122
+ ts = events[-1].ts
123
+ self._conn.execute(
124
+ "INSERT OR REPLACE INTO snapshots (task_id, state, ts, last_event_rowid) "
125
+ "VALUES (?, ?, ?, ?)",
126
+ (task_id, state.value, ts, last_rowid),
127
+ )
128
+ return {"task_id": task_id, "state": state, "ts": ts,
129
+ "last_event_rowid": last_rowid}
130
+
131
+ def latest_snapshot(self, task_id: str) -> dict | None:
132
+ """Latest snapshot for a task, or None."""
133
+ cur = self._conn.execute(
134
+ "SELECT state, ts, last_event_rowid FROM snapshots "
135
+ "WHERE task_id = ? ORDER BY ts DESC LIMIT 1",
136
+ (task_id,),
137
+ )
138
+ row = cur.fetchone()
139
+ if not row:
140
+ return None
141
+ from omega_engine.task import TaskState
142
+ return {"state": TaskState(row["state"]), "ts": row["ts"],
143
+ "last_event_rowid": row["last_event_rowid"]}
144
+
145
+ def events_since_snapshot(self, task_id: str) -> list[Event]:
146
+ """Events appended since the latest snapshot, or all events if none."""
147
+ snap = self.latest_snapshot(task_id)
148
+ if not snap:
149
+ return self.events_for(task_id)
150
+ cur = self._conn.execute(
151
+ "SELECT * FROM events WHERE task_id = ? AND rowid > ? ORDER BY rowid",
152
+ (task_id, snap["last_event_rowid"]),
153
+ )
154
+ return [Event.from_row(dict(r)) for r in cur.fetchall()]
155
+
91
156
  def close(self) -> None:
92
157
  self._conn.close()
93
-
94
- # NOTE — snapshotting. An append-only log grows without bound. A future
95
- # `snapshots` table (state of every task at a checkpoint ts) lets `reduce_task`
96
- # start from the last snapshot instead of replaying from genesis. Snapshots live
97
- # in Agentik_Runtime/snapshots/. Not required for correctness — only for scale.
@@ -0,0 +1,304 @@
1
+ """SST → provider projection — the `omega sync` engine.
2
+
3
+ The SSOT is provider-neutral. Each provider adapter compiles
4
+ `Agentik_SSOT/` into that provider's native shape. Re-running `omega sync` is
5
+ idempotent: same SSOT in, same projection out — so the SSOT remains the only
6
+ hand-edited surface.
7
+
8
+ Today only the Claude Code adapter is fully wired. GLM / OpenAI / DeepSeek
9
+ providers consume the SSOT directly via API calls and have no on-disk native
10
+ layout, so their adapters log a no-op message. New providers add one adapter
11
+ file; the SSOT does not change.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any, Iterable, Protocol, runtime_checkable
19
+
20
+
21
+ def _omega_home(explicit: str | Path | None = None) -> Path:
22
+ return Path(explicit or os.environ.get("OMEGA_HOME", str(Path.home() / "Omega")))
23
+
24
+
25
+ # --------------------------------------------------------------------------
26
+ # Provider adapter contract
27
+ # --------------------------------------------------------------------------
28
+
29
+
30
+ @runtime_checkable
31
+ class ProviderAdapter(Protocol):
32
+ """Each provider exposes a `project(omega_home)` that writes its native layout."""
33
+
34
+ id: str
35
+
36
+ def project(self, omega_home: Path) -> dict[str, Any]: ...
37
+
38
+
39
+ class _StubAdapter:
40
+ """Adapter that has no on-disk native layout — logs and returns metadata."""
41
+
42
+ def __init__(self, provider_id: str, reason: str) -> None:
43
+ self.id = provider_id
44
+ self._reason = reason
45
+
46
+ def project(self, omega_home: Path) -> dict[str, Any]:
47
+ msg = f"[sync] {self.id}: {self._reason}"
48
+ print(msg)
49
+ return {"provider": self.id, "projected": False, "reason": self._reason}
50
+
51
+
52
+ # --------------------------------------------------------------------------
53
+ # Claude Code adapter — the only fully wired one for now
54
+ # --------------------------------------------------------------------------
55
+
56
+
57
+ class ClaudeCodeAdapter:
58
+ """Project the SSOT into Claude Code's native `.claude/` layout.
59
+
60
+ Native target tree (idempotent):
61
+ <target>/skills/<id>/SKILL.md
62
+ <target>/commands/<id>.md
63
+ <target>/agents/<id>.md
64
+ <target>/.mcp.json { "mcpServers": { ... } }
65
+ <target>/settings.json { ..., "hooks": {...} }
66
+
67
+ The SSOT is never modified — the adapter is one-way (SSOT → projection).
68
+ Default target: `<omega_home>/Agentik_AI/providers/claude-code/.claude/`.
69
+ """
70
+
71
+ id = "claude-code"
72
+
73
+ def __init__(self, target: str | Path | None = None) -> None:
74
+ self._explicit_target = Path(target) if target else None
75
+
76
+ # ----- helpers -------------------------------------------------------
77
+
78
+ def target_dir(self, omega_home: Path) -> Path:
79
+ if self._explicit_target is not None:
80
+ return self._explicit_target
81
+ return omega_home / "Agentik_AI" / "providers" / "claude-code" / ".claude"
82
+
83
+ @staticmethod
84
+ def _ensure(d: Path) -> Path:
85
+ d.mkdir(parents=True, exist_ok=True)
86
+ return d
87
+
88
+ @staticmethod
89
+ def _stem(path: Path) -> str:
90
+ return path.stem
91
+
92
+ # ----- skills / commands / agents (markdown surfaces) ----------------
93
+
94
+ def _project_markdown_dir(
95
+ self,
96
+ src: Path,
97
+ out_root: Path,
98
+ layout: str, # "skill" | "command" | "agent"
99
+ ) -> list[str]:
100
+ """Copy `src/*.md` to the native layout. Returns list of written paths."""
101
+ if not src.is_dir():
102
+ return []
103
+ out_root = self._ensure(out_root)
104
+ written: list[str] = []
105
+ for md in sorted(src.glob("*.md")):
106
+ stem = self._stem(md)
107
+ if layout == "skill":
108
+ dest_dir = self._ensure(out_root / stem)
109
+ dest = dest_dir / "SKILL.md"
110
+ else:
111
+ dest = out_root / f"{stem}.md"
112
+ dest.write_text(md.read_text())
113
+ written.append(str(dest.relative_to(out_root.parent)))
114
+ return written
115
+
116
+ # ----- MCP config (.mcp.json) ---------------------------------------
117
+
118
+ def _project_mcp(self, omega_home: Path, out_root: Path) -> dict[str, Any]:
119
+ """Compile mcp-config.yaml + the tool registry → Claude Code .mcp.json.
120
+
121
+ Claude Code expects: { "mcpServers": { "<id>": { "command": "...",
122
+ "args": [...],
123
+ "env": {...} } } }
124
+ We resolve `command` from the tool registry invoke path when available.
125
+ """
126
+ try:
127
+ import yaml
128
+ except ImportError:
129
+ return {}
130
+
131
+ cfg_path = omega_home / "Agentik_SSOT" / "mcp" / "mcp-config.yaml"
132
+ catalog_path = omega_home / "Agentik_SSOT" / "mcp" / "mcp-catalog.yaml"
133
+
134
+ cfg: dict[str, Any] = {}
135
+ if cfg_path.exists():
136
+ cfg = yaml.safe_load(cfg_path.read_text()) or {}
137
+
138
+ catalog_by_id: dict[str, dict[str, Any]] = {}
139
+ if catalog_path.exists():
140
+ catalog = yaml.safe_load(catalog_path.read_text()) or {}
141
+ for e in catalog.get("catalog") or []:
142
+ catalog_by_id[str(e.get("id"))] = e
143
+
144
+ # The tool registry tells us where the binary is for each MCP id.
145
+ from omega_engine.tools import ToolRegistry # local import — avoid cycle
146
+ reg = ToolRegistry.load(omega_home)
147
+ tool_by_id = {t.name: t for t in reg.list()}
148
+
149
+ mcp_servers: dict[str, Any] = {}
150
+ for server in cfg.get("servers") or []:
151
+ sid = str(server.get("id", "")).strip()
152
+ if not sid or not server.get("enabled", True):
153
+ continue
154
+ entry: dict[str, Any] = {}
155
+ tool = tool_by_id.get(sid)
156
+ cat = catalog_by_id.get(sid, {})
157
+ install = cat.get("install") or {}
158
+
159
+ # Prefer the locally installed binary; fall back to `npx <package>`.
160
+ if tool and tool.invoke:
161
+ entry["command"] = str(omega_home / tool.invoke)
162
+ entry["args"] = list(server.get("args") or [])
163
+ elif install.get("method") == "npx" and install.get("package"):
164
+ entry["command"] = "npx"
165
+ entry["args"] = ["-y", str(install["package"]), *(server.get("args") or [])]
166
+ else:
167
+ # nothing to invoke — skip rather than write a half-broken entry
168
+ continue
169
+
170
+ from_server = list(server.get("secret_refs") or [])
171
+ from_tool = list(tool.secret_refs) if tool else []
172
+ secret_refs = from_server or from_tool
173
+ if secret_refs:
174
+ # Claude Code's .mcp.json supports an `env` block; we project
175
+ # secret refs by name (the value is filled at runtime from the
176
+ # vault, never written to disk in plaintext).
177
+ entry["env"] = {ref: f"${{{ref}}}" for ref in secret_refs}
178
+
179
+ mcp_servers[sid] = entry
180
+
181
+ out_root = self._ensure(out_root)
182
+ target = out_root / ".mcp.json"
183
+ payload = {"mcpServers": mcp_servers}
184
+ target.write_text(json.dumps(payload, indent=2) + "\n")
185
+ return payload
186
+
187
+ # ----- hooks → settings.json -----------------------------------------
188
+
189
+ def _project_hooks(self, omega_home: Path, out_root: Path) -> dict[str, Any]:
190
+ """Merge `Agentik_SSOT/hooks/*` into <target>/settings.json `hooks`.
191
+
192
+ Each file under hooks/ is one of:
193
+ - *.json — already in Claude Code hook shape; merged shallow into hooks
194
+ - *.yaml/*.yml — same shape, parsed as YAML
195
+ Unknown extensions are ignored.
196
+ """
197
+ hooks_dir = omega_home / "Agentik_SSOT" / "hooks"
198
+ out_root = self._ensure(out_root)
199
+ settings_file = out_root / "settings.json"
200
+
201
+ settings: dict[str, Any] = {}
202
+ if settings_file.exists():
203
+ try:
204
+ settings = json.loads(settings_file.read_text() or "{}")
205
+ except json.JSONDecodeError:
206
+ settings = {}
207
+
208
+ hooks_block: dict[str, Any] = dict(settings.get("hooks") or {})
209
+
210
+ if hooks_dir.is_dir():
211
+ import yaml
212
+ for path in sorted(hooks_dir.iterdir()):
213
+ if not path.is_file():
214
+ continue
215
+ if path.suffix == ".json":
216
+ try:
217
+ data = json.loads(path.read_text() or "{}")
218
+ except json.JSONDecodeError:
219
+ continue
220
+ elif path.suffix in {".yaml", ".yml"}:
221
+ data = yaml.safe_load(path.read_text() or "") or {}
222
+ else:
223
+ continue
224
+ if not isinstance(data, dict):
225
+ continue
226
+ for k, v in data.items():
227
+ hooks_block[k] = v
228
+
229
+ settings["hooks"] = hooks_block
230
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
231
+ return hooks_block
232
+
233
+ # ----- entry point ---------------------------------------------------
234
+
235
+ def project(self, omega_home: Path) -> dict[str, Any]:
236
+ target = self.target_dir(omega_home)
237
+ self._ensure(target)
238
+ ssot = omega_home / "Agentik_SSOT"
239
+
240
+ skills = self._project_markdown_dir(ssot / "skills", target / "skills", "skill")
241
+ commands = self._project_markdown_dir(ssot / "commands", target / "commands", "command")
242
+ agents = self._project_markdown_dir(ssot / "agents", target / "agents", "agent")
243
+ mcp = self._project_mcp(omega_home, target)
244
+ hooks = self._project_hooks(omega_home, target)
245
+
246
+ return {
247
+ "provider": self.id,
248
+ "projected": True,
249
+ "target": str(target),
250
+ "skills_written": len(skills),
251
+ "commands_written": len(commands),
252
+ "agents_written": len(agents),
253
+ "mcp_servers": len(mcp.get("mcpServers", {})) if isinstance(mcp, dict) else 0,
254
+ "hooks": list(hooks.keys()),
255
+ }
256
+
257
+
258
+ # --------------------------------------------------------------------------
259
+ # Sync engine
260
+ # --------------------------------------------------------------------------
261
+
262
+
263
+ def _active_providers(omega_home: Path) -> list[str]:
264
+ """Read Agentik_Providers/registry.yaml; default to [claude] if missing."""
265
+ try:
266
+ import yaml
267
+ except ImportError:
268
+ return ["claude"]
269
+ path = omega_home / "Agentik_Providers" / "registry.yaml"
270
+ if not path.exists():
271
+ return ["claude"]
272
+ data = yaml.safe_load(path.read_text()) or {}
273
+ return [str(p.get("id")) for p in data.get("providers") or [] if p.get("id")]
274
+
275
+
276
+ class SyncEngine:
277
+ """Run every active provider's adapter against the SSOT."""
278
+
279
+ def __init__(self, adapters: Iterable[ProviderAdapter] | None = None) -> None:
280
+ self._explicit = list(adapters) if adapters is not None else None
281
+
282
+ def adapters_for(self, omega_home: Path) -> list[ProviderAdapter]:
283
+ if self._explicit is not None:
284
+ return list(self._explicit)
285
+ active = _active_providers(omega_home)
286
+ result: list[ProviderAdapter] = []
287
+ for pid in active:
288
+ if pid == "claude":
289
+ result.append(ClaudeCodeAdapter())
290
+ else:
291
+ # GLM / OpenAI / DeepSeek don't have a native on-disk format;
292
+ # MCP/skills/commands are passed via API. Document this.
293
+ result.append(_StubAdapter(
294
+ pid,
295
+ "no native projection — used directly via API",
296
+ ))
297
+ return result
298
+
299
+ def sync_all(self, omega_home: str | Path | None = None) -> list[dict[str, Any]]:
300
+ home = _omega_home(omega_home)
301
+ outcomes: list[dict[str, Any]] = []
302
+ for adapter in self.adapters_for(home):
303
+ outcomes.append(adapter.project(home))
304
+ return outcomes