@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,361 @@
1
+ """Autonomous-agent supervisor — charters, cron parser, and trigger firing.
2
+
3
+ Standalone runner: ``python3 tests/test_autonomous.py``
4
+ """
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+
13
+ from omega_engine.autonomous import ( # noqa: E402
14
+ AutonomousSupervisor,
15
+ Charter,
16
+ load_charters,
17
+ next_fire,
18
+ parse_cron,
19
+ )
20
+ from omega_engine.events import Event, EventType # noqa: E402
21
+ from omega_engine.task import Lifecycle # noqa: E402
22
+
23
+
24
+ # ─────────────────────────────────────────────────────────────────────────────
25
+ # fixture: a directory of example charters, identical in spirit to the file
26
+ # shipped at Agentik_Orchestration/autonomous/example-agents.yaml
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ _EXAMPLE_YAML = """\
30
+ # autonomous-agent charters — mirror of example-agents.yaml
31
+ template:
32
+ id: <agent-id>
33
+ role: <role-name>
34
+ lifecycle: persistent
35
+ charter: >
36
+ Placeholder.
37
+ trigger:
38
+ type: <cron|event|webhook|channel>
39
+ config: {}
40
+ channel:
41
+ telegram_topic: 0
42
+ budget:
43
+ max_iterations: 5
44
+ heartbeat_interval_s: 300
45
+
46
+ support-agent:
47
+ id: support-agent
48
+ role: customer-support
49
+ lifecycle: persistent
50
+ charter: >
51
+ Watch the support channel. Triage every inbound message.
52
+ trigger:
53
+ type: channel
54
+ config: { telegram_topic: 4012 }
55
+ channel:
56
+ telegram_topic: 4012
57
+ budget:
58
+ max_iterations: 5
59
+ heartbeat_interval_s: 300
60
+ provider: glm
61
+ allowed:
62
+ skills: [classify-intent, draft-reply, rag-route]
63
+ mcp: [filesystem, github, linear]
64
+ topologies: [aisb-oracle-worker]
65
+ guardrails:
66
+ may_spawn_missions: true
67
+ may_ship: false
68
+
69
+ growth-agent:
70
+ id: growth-agent
71
+ role: growth
72
+ lifecycle: persistent
73
+ charter: >
74
+ Every morning, pull yesterday's product metrics and open one mission.
75
+ trigger:
76
+ type: cron
77
+ config: { schedule: "0 7 * * *" }
78
+ channel:
79
+ telegram_topic: 4020
80
+ budget:
81
+ max_iterations: 3
82
+ heartbeat_interval_s: 600
83
+ provider: claude
84
+ allowed:
85
+ skills: [rag-route, metrics-read, prioritize]
86
+ mcp: [filesystem, postgres, github]
87
+ topologies: [aisb-oracle-worker]
88
+ guardrails:
89
+ may_spawn_missions: true
90
+ may_ship: false
91
+ """
92
+
93
+ _EVENT_AGENT_YAML = """\
94
+ fix-on-fail:
95
+ id: fix-on-fail
96
+ role: triage
97
+ lifecycle: persistent
98
+ charter: >
99
+ React to every task.failed.
100
+ trigger:
101
+ type: event
102
+ config: { event_type: "task.failed" }
103
+ channel:
104
+ telegram_topic: 5000
105
+ budget:
106
+ max_iterations: 1
107
+ heartbeat_interval_s: 60
108
+ """
109
+
110
+ _WEBHOOK_YAML = """\
111
+ ci-hook:
112
+ id: ci-hook
113
+ role: ci
114
+ lifecycle: persistent
115
+ charter: >
116
+ Run on every push.
117
+ trigger:
118
+ type: webhook
119
+ config: { webhook_path: /hooks/push }
120
+ channel:
121
+ telegram_topic: 6000
122
+ budget:
123
+ max_iterations: 1
124
+ heartbeat_interval_s: 60
125
+ """
126
+
127
+
128
+ def _charter_dir() -> Path:
129
+ d = Path(tempfile.mkdtemp(prefix="omega-charters-"))
130
+ (d / "example-agents.yaml").write_text(_EXAMPLE_YAML)
131
+ (d / "fix-on-fail.yaml").write_text(_EVENT_AGENT_YAML)
132
+ (d / "ci-hook.yaml").write_text(_WEBHOOK_YAML)
133
+ return d
134
+
135
+
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+ # tests
138
+ # ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ def test_load_charters_skips_template_block_and_loads_real_charters():
141
+ d = _charter_dir()
142
+ charters = load_charters(d)
143
+ ids = sorted(c.id for c in charters)
144
+ # the `template:` placeholder must NOT come through as a charter; the four
145
+ # real ones (support, growth, event, webhook) must.
146
+ assert ids == ["ci-hook", "fix-on-fail", "growth-agent", "support-agent"], ids
147
+ # every charter is a real Charter dataclass with the right fields populated
148
+ for c in charters:
149
+ assert isinstance(c, Charter)
150
+ assert c.lifecycle == Lifecycle.PERSISTENT.value
151
+ assert c.trigger_type in ("cron", "event", "webhook", "channel")
152
+ assert c.source_file and c.source_file.endswith(".yaml")
153
+ # spot-check the support charter mirrors the YAML
154
+ support = next(c for c in charters if c.id == "support-agent")
155
+ assert support.role == "customer-support"
156
+ assert support.trigger_type == "channel"
157
+ assert support.trigger_config == {"telegram_topic": 4012}
158
+ assert support.channel_topic == 4012
159
+ assert support.max_iterations == 5
160
+ assert support.heartbeat_interval_s == 300
161
+ assert support.provider == "glm"
162
+ assert "classify-intent" in support.allowed_skills
163
+ assert "linear" in support.allowed_mcp
164
+
165
+
166
+ def test_load_charters_handles_missing_or_empty_dir():
167
+ # missing directory → empty list, no exception
168
+ assert load_charters(Path("/tmp/this/does/not/exist")) == []
169
+ # empty directory → empty list
170
+ empty = Path(tempfile.mkdtemp())
171
+ assert load_charters(empty) == []
172
+
173
+
174
+ def test_parse_cron_basic_forms():
175
+ # the exact form used by growth-agent
176
+ minute, hour, dom, month, dow = parse_cron("0 7 * * *")
177
+ assert minute == {0}
178
+ assert hour == {7}
179
+ assert dom == set(range(1, 32))
180
+ assert month == set(range(1, 13))
181
+ assert dow == set(range(0, 7))
182
+
183
+ # */N step on every field
184
+ minute, *_ = parse_cron("*/5 * * * *")
185
+ assert minute == {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
186
+
187
+ # comma lists + ranges
188
+ minute, hour, *_ = parse_cron("0,15,30,45 9-17 * * 1-5")
189
+ assert minute == {0, 15, 30, 45}
190
+ assert hour == set(range(9, 18))
191
+
192
+
193
+ def test_next_fire_every_five_minutes_returns_now_plus_about_5_min():
194
+ now = time.time()
195
+ nf = next_fire("*/5 * * * *", now)
196
+ # the next */5 boundary is at most 5min away (300s); the search rounds up to
197
+ # the next whole minute, so the legal lower bound is 0s, upper bound 300s.
198
+ delta = nf - now
199
+ assert 0 < delta <= 300, f"delta={delta}"
200
+ # and the returned timestamp must actually be on a 5-minute boundary
201
+ assert time.localtime(nf).tm_min % 5 == 0
202
+
203
+
204
+ def test_next_fire_daily_seven_am_lands_on_seven_am():
205
+ now = time.time()
206
+ nf = next_fire("0 7 * * *", now)
207
+ t = time.localtime(nf)
208
+ assert t.tm_hour == 7 and t.tm_min == 0
209
+ # and it's within the next 24 hours + a small margin
210
+ assert nf > now
211
+ assert nf - now <= 24 * 60 * 60 + 60
212
+
213
+
214
+ def test_supervisor_cron_tick_invokes_runner():
215
+ """The crux: a cron trigger past its `next_fire_at` calls the runner."""
216
+ fired: list[dict] = []
217
+
218
+ def fake_runner(*, intent, omega_home, topic_id, telegram, charter):
219
+ fired.append({
220
+ "intent": intent, "topic_id": topic_id, "charter_id": charter.id,
221
+ })
222
+
223
+ d = _charter_dir()
224
+ charters = load_charters(d)
225
+ sup = AutonomousSupervisor(charters, runner=fake_runner)
226
+
227
+ # force the growth-agent's next_fire into the past, then tick
228
+ growth_sched = sup._schedules["growth-agent"] # noqa: SLF001
229
+ growth_sched.next_fire_at = time.time() - 10
230
+ fired_ids = sup.tick(now=time.time())
231
+
232
+ assert "growth-agent" in fired_ids
233
+ assert any(f["charter_id"] == "growth-agent" for f in fired)
234
+ # the runner saw the bound topic id
235
+ growth = [f for f in fired if f["charter_id"] == "growth-agent"][0]
236
+ assert growth["topic_id"] == 4020
237
+ # the schedule rescheduled itself for the next fire — strictly in the future
238
+ assert growth_sched.next_fire_at is not None
239
+ assert growth_sched.next_fire_at > time.time()
240
+ # runs counter incremented; no errors
241
+ assert growth_sched.runs == 1
242
+ assert growth_sched.errors == 0
243
+
244
+
245
+ def test_supervisor_channel_trigger_dispatches_on_inbound_message():
246
+ fired: list[dict] = []
247
+
248
+ def fake_runner(*, intent, omega_home, topic_id, telegram, charter):
249
+ fired.append({"charter": charter.id, "intent": intent, "topic": topic_id})
250
+
251
+ charters = load_charters(_charter_dir())
252
+ sup = AutonomousSupervisor(charters, runner=fake_runner)
253
+ # wrong topic → nothing
254
+ assert sup.on_channel_message(99999, "ignored") == []
255
+ assert fired == []
256
+ # correct topic → support-agent fires with the message as the intent
257
+ out = sup.on_channel_message(4012, "the printer is on fire")
258
+ assert out == ["support-agent"]
259
+ assert fired == [{"charter": "support-agent",
260
+ "intent": "the printer is on fire",
261
+ "topic": 4012}]
262
+
263
+
264
+ def test_supervisor_webhook_trigger_fires_on_matching_path():
265
+ fired: list[str] = []
266
+
267
+ def fake_runner(*, intent, omega_home, topic_id, telegram, charter):
268
+ fired.append(charter.id)
269
+
270
+ charters = load_charters(_charter_dir())
271
+ sup = AutonomousSupervisor(charters, runner=fake_runner)
272
+ # non-matching path → nothing
273
+ assert sup.wake_webhook("/hooks/unknown", {}) == []
274
+ assert fired == []
275
+ # matching path → ci-hook fires
276
+ assert sup.wake_webhook("/hooks/push", {"intent": "deploy"}) == ["ci-hook"]
277
+ assert fired == ["ci-hook"]
278
+
279
+
280
+ def test_supervisor_one_bad_charter_does_not_kill_the_others():
281
+ """A runner that raises must be caught — the supervisor never dies."""
282
+ fired: list[str] = []
283
+
284
+ def runner(*, intent, omega_home, topic_id, telegram, charter):
285
+ if charter.id == "growth-agent":
286
+ raise RuntimeError("boom")
287
+ fired.append(charter.id)
288
+
289
+ sup = AutonomousSupervisor(load_charters(_charter_dir()), runner=runner)
290
+ # force both an immediate-fire cron AND a channel-fire on the same tick
291
+ sup._schedules["growth-agent"].next_fire_at = time.time() - 10 # noqa: SLF001
292
+ sup.tick()
293
+ sup.on_channel_message(4012, "support please")
294
+ # the channel charter ran despite the crash inside the cron charter
295
+ assert fired == ["support-agent"]
296
+ growth_stats = sup.stats()["growth-agent"]
297
+ assert growth_stats["errors"] >= 1
298
+ assert growth_stats["last_error"].startswith("RuntimeError:")
299
+
300
+
301
+ def test_supervisor_event_trigger_via_bus():
302
+ """An event trigger fires when a matching event hits the bus."""
303
+ fired: list[str] = []
304
+
305
+ def runner(*, intent, omega_home, topic_id, telegram, charter):
306
+ fired.append(charter.id)
307
+
308
+ class _FakeBus:
309
+ def __init__(self):
310
+ self.subs = []
311
+ def subscribe(self, fn):
312
+ self.subs.append(fn)
313
+ def publish(self, event):
314
+ for fn in self.subs:
315
+ fn(event)
316
+
317
+ bus = _FakeBus()
318
+ sup = AutonomousSupervisor(
319
+ load_charters(_charter_dir()), bus=bus, runner=runner)
320
+ # publishing a non-matching event does nothing
321
+ bus.publish(Event(task_id="t-1", type=EventType.STARTED))
322
+ assert fired == []
323
+ # publishing task.failed wakes the fix-on-fail charter
324
+ bus.publish(Event(task_id="t-1", type=EventType.FAILED,
325
+ payload={"reason": "test"}))
326
+ assert fired == ["fix-on-fail"]
327
+
328
+
329
+ def test_supervisor_with_no_charters_still_runs_one_tick_cleanly():
330
+ """A supervisor with zero charters must not crash on an empty tick."""
331
+ sup = AutonomousSupervisor([], runner=lambda **kw: None)
332
+ assert sup.charters() == []
333
+ assert sup.tick() == []
334
+ assert sup.stats() == {}
335
+
336
+
337
+ # ─────────────────────────────────────────────────────────────────────────────
338
+ # standalone runner
339
+ # ─────────────────────────────────────────────────────────────────────────────
340
+
341
+ def _run_all() -> bool:
342
+ tests = [(k, v) for k, v in sorted(globals().items())
343
+ if k.startswith("test_") and callable(v)]
344
+ passed = 0
345
+ for name, fn in tests:
346
+ try:
347
+ fn()
348
+ except AssertionError as exc:
349
+ print(f" FAIL {name}: {exc}")
350
+ continue
351
+ except Exception as exc: # noqa: BLE001
352
+ print(f" ERROR {name}: {type(exc).__name__}: {exc}")
353
+ continue
354
+ print(f" PASS {name}")
355
+ passed += 1
356
+ print(f"\n{passed}/{len(tests)} autonomous tests passed")
357
+ return passed == len(tests)
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(0 if _run_all() else 1)
@@ -0,0 +1,233 @@
1
+ """The 8 educators — generators that produce SSOT artifacts under a quality gate.
2
+
3
+ Each educator runs `.generate()` against the `MockProvider` and must return a
4
+ valid `EducatorProposal` whose artifact targets the right SSOT path. The
5
+ `StagingPipeline` then writes the proposal to a temp dir (the real one is
6
+ `Agentik_Extra/staging/promotion/`).
7
+
8
+ Standalone: python3 tests/test_educators.py
9
+ """
10
+ import json
11
+ import shutil
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
+
18
+ from omega_engine.educators import ( # noqa: E402
19
+ ArtifactEducator,
20
+ AutomationEducator,
21
+ ClaudecodeEducator,
22
+ ConnectionEducator,
23
+ CoworkerEducator,
24
+ EducatorProposal,
25
+ EducatorRegistry,
26
+ LoopEducator,
27
+ PromptEducator,
28
+ SkillEducator,
29
+ StagingPipeline,
30
+ )
31
+ from omega_engine.provider import MockProvider # noqa: E402
32
+
33
+
34
+ # --------------------------------------------------------------------------
35
+ # A table of (educator class, generation intent, context, expected path prefix)
36
+ # — one row per educator. Drives every per-educator test.
37
+ # --------------------------------------------------------------------------
38
+
39
+ EDUCATOR_CASES = [
40
+ (
41
+ PromptEducator,
42
+ "oracle dispatches a fresh worker for a Linear feedback fix",
43
+ {"source": "oracle", "target": "worker"},
44
+ "Agentik_SSOT/prompts/oracle-to-worker.md",
45
+ ),
46
+ (
47
+ ArtifactEducator,
48
+ "a Linear ticket mission report after the worker ships",
49
+ {"artifact_kind": "mission-report"},
50
+ "Agentik_SSOT/templates/mission-report.md",
51
+ ),
52
+ (
53
+ SkillEducator,
54
+ "a /releasenotes skill that summarises the last 7 days of merged PRs",
55
+ {"skill_name": "releasenotes"},
56
+ "Agentik_SSOT/skills/releasenotes.md",
57
+ ),
58
+ (
59
+ CoworkerEducator,
60
+ "a react-specialist role focused on Next.js app-router work",
61
+ {"role_name": "react-specialist"},
62
+ "Agentik_SSOT/agents/react-specialist.md",
63
+ ),
64
+ (
65
+ ConnectionEducator,
66
+ "an MCP connector for Linear (issues + comments)",
67
+ {"connector_id": "linear-mcp"},
68
+ "Agentik_SSOT/mcp/configs/linear-mcp.yaml",
69
+ ),
70
+ (
71
+ AutomationEducator,
72
+ "a daily cron that aggregates yesterday's mission outcomes",
73
+ {"automation_id": "daily-mission-rollup"},
74
+ "Agentik_SSOT/automations/daily-mission-rollup.yaml",
75
+ ),
76
+ (
77
+ ClaudecodeEducator,
78
+ "the new native compaction primitive in Claude Code 2.1.155",
79
+ {"change_id": "native-compaction-2-1-155"},
80
+ "Agentik_AI/adapters/claude-code/notes.md",
81
+ ),
82
+ (
83
+ LoopEducator,
84
+ "the loop for a single-file UI bug fix — fast, 3 iterations max",
85
+ {"loop_id": "single-file-ui-fix"},
86
+ "Agentik_SSOT/loops/single-file-ui-fix.yaml",
87
+ ),
88
+ ]
89
+
90
+
91
+ def test_registry_holds_the_eight():
92
+ """Every educator the README lists must be discoverable through the registry."""
93
+ reg = EducatorRegistry.default()
94
+ names = reg.names()
95
+ expected = {"prompt", "artifact", "skill", "coworker",
96
+ "connection", "automation", "claudecode", "loop"}
97
+ assert set(names) == expected, f"registry mismatch: {names}"
98
+ assert len(reg.all()) == 8
99
+ for name in expected:
100
+ e = reg.get(name)
101
+ assert e is not None, f"educator '{name}' missing"
102
+ assert hasattr(e, "generate"), f"'{name}' has no generate()"
103
+ print(f" {len(names)} educators registered: {', '.join(names)}")
104
+
105
+
106
+ def test_each_educator_produces_a_valid_proposal():
107
+ """Run every educator once with the MockProvider; assert the proposal shape."""
108
+ provider = MockProvider()
109
+ for cls, intent, ctx, expected_path in EDUCATOR_CASES:
110
+ educator = cls()
111
+ proposal = educator.generate(intent, ctx, provider)
112
+ # contract: a real EducatorProposal with a populated artifact
113
+ assert isinstance(proposal, EducatorProposal), \
114
+ f"{cls.__name__} did not return EducatorProposal"
115
+ assert proposal.educator == educator.name
116
+ # artifact body must be non-empty (the educator filled it from the mock)
117
+ assert proposal.artifact.content, \
118
+ f"{cls.__name__} produced empty artifact content"
119
+ # target_path is exactly what this educator advertises
120
+ assert proposal.artifact.target_path == expected_path, (
121
+ f"{cls.__name__}: expected target_path={expected_path}, "
122
+ f"got {proposal.artifact.target_path}"
123
+ )
124
+ # score must be machine-parseable; the mock returns >= 85
125
+ assert 0 <= proposal.score <= 100, \
126
+ f"{cls.__name__}: score out of range ({proposal.score})"
127
+ assert proposal.score >= 85, (
128
+ f"{cls.__name__}: mock score should be >= 85, got {proposal.score}"
129
+ )
130
+ print(f" {len(EDUCATOR_CASES)} educators produced valid proposals")
131
+
132
+
133
+ def test_staging_pipeline_writes_under_temp_root():
134
+ """`StagingPipeline.stage` lands proposals under a per-educator bucket."""
135
+ tmp = Path(tempfile.mkdtemp(prefix="omega-staging-"))
136
+ try:
137
+ staging = StagingPipeline(tmp)
138
+ assert staging.root == tmp and tmp.exists()
139
+
140
+ provider = MockProvider()
141
+ records = []
142
+ for cls, intent, ctx, _expected in EDUCATOR_CASES:
143
+ proposal = cls().generate(intent, ctx, provider)
144
+ record = staging.stage(proposal)
145
+ records.append((proposal, record))
146
+
147
+ # one staged artifact + one .json per educator
148
+ listed = staging.list()
149
+ assert len(listed) == len(EDUCATOR_CASES), \
150
+ f"expected {len(EDUCATOR_CASES)} staged proposals, got {len(listed)}"
151
+
152
+ # the on-disk content matches what the educator produced
153
+ for proposal, record in records:
154
+ assert record.artifact_path.exists()
155
+ assert record.proposal_path.exists()
156
+ assert record.target_path == proposal.artifact.target_path
157
+ on_disk = record.artifact_path.read_text()
158
+ assert on_disk == proposal.artifact.content
159
+ meta = json.loads(record.proposal_path.read_text())
160
+ assert meta["educator"] == proposal.educator
161
+ assert meta["artifact"]["target_path"] == proposal.artifact.target_path
162
+ # extension preserved (.md or .yaml)
163
+ expected_suffix = Path(proposal.artifact.target_path).suffix
164
+ assert record.artifact_path.suffix == expected_suffix
165
+
166
+ # clear() removes everything
167
+ removed = staging.clear()
168
+ assert removed >= len(EDUCATOR_CASES) * 2 # artifact + meta per proposal
169
+ assert staging.list() == []
170
+ print(f" staged + cleared {len(EDUCATOR_CASES)} proposals in {tmp.name}")
171
+ finally:
172
+ shutil.rmtree(tmp, ignore_errors=True)
173
+
174
+
175
+ def test_educator_never_writes_to_ssot_directly():
176
+ """An educator returns a proposal — it must NEVER touch the filesystem itself.
177
+
178
+ We assert this structurally: every educator's `.generate()` returns an
179
+ `EducatorProposal` whose `artifact.target_path` is a *string*, not a side
180
+ effect. The only path that ever writes is `StagingPipeline.stage()`.
181
+ """
182
+ provider = MockProvider()
183
+ for cls, intent, ctx, _expected in EDUCATOR_CASES:
184
+ proposal = cls().generate(intent, ctx, provider)
185
+ target = proposal.artifact.target_path
186
+ # the target is a SSOT path, never an absolute path under the user's home
187
+ assert not target.startswith("/"), \
188
+ f"{cls.__name__}: target_path must be repo-relative, got {target}"
189
+ assert "Agentik_SSOT/" in target or "Agentik_AI/" in target, (
190
+ f"{cls.__name__}: target_path must live under Agentik_SSOT/ or "
191
+ f"Agentik_AI/, got {target}"
192
+ )
193
+ print(" every educator returns a SSOT-relative target_path")
194
+
195
+
196
+ def test_router_role_naming():
197
+ """Educators address the provider with role 'educator-<name>' (per-domain LLM)."""
198
+ # We verify by spying on the role through a tiny provider wrapper.
199
+ seen_roles: list[str] = []
200
+
201
+ class SpyProvider:
202
+ id = "spy"
203
+
204
+ def run(self, req):
205
+ seen_roles.append(req.role)
206
+ return MockProvider().run(req)
207
+
208
+ provider = SpyProvider()
209
+ for cls, intent, ctx, _expected in EDUCATOR_CASES:
210
+ cls().generate(intent, ctx, provider)
211
+
212
+ expected_roles = {f"educator-{cls().name}" for cls, *_ in EDUCATOR_CASES}
213
+ assert set(seen_roles) == expected_roles, (
214
+ f"router role naming mismatch.\nseen: {sorted(seen_roles)}\n"
215
+ f"expected: {sorted(expected_roles)}"
216
+ )
217
+ print(f" all 8 educators dispatched via 'educator-<name>' roles")
218
+
219
+
220
+ def _run_all() -> bool:
221
+ tests = [v for k, v in sorted(globals().items())
222
+ if k.startswith("test_") and callable(v)]
223
+ passed = 0
224
+ for t in tests:
225
+ t()
226
+ print(f" PASS {t.__name__}")
227
+ passed += 1
228
+ print(f"\n{passed}/{len(tests)} educator tests passed")
229
+ return passed == len(tests)
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(0 if _run_all() else 1)