@agentikos/omega-os 0.19.37 → 0.19.39

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 (39) hide show
  1. package/bin/omega-os.js +6 -1
  2. package/bootstrap/lib/steps.sh +43 -0
  3. package/install.sh +5 -0
  4. package/omega/Agentik_Engine/omega_engine/__init__.py +1 -1
  5. package/omega/Agentik_Engine/omega_engine/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/omega/Agentik_Engine/omega_engine/__pycache__/paperclip_bridge.cpython-313.pyc +0 -0
  8. package/omega/Agentik_Engine/omega_engine/__pycache__/prompt_audit.cpython-313.pyc +0 -0
  9. package/omega/Agentik_Engine/omega_engine/__pycache__/tmux.cpython-313.pyc +0 -0
  10. package/omega/Agentik_Engine/omega_engine/__pycache__/tui.cpython-313.pyc +0 -0
  11. package/omega/Agentik_Engine/omega_engine/cli.py +73 -0
  12. package/omega/Agentik_Engine/omega_engine/paperclip_bridge.py +110 -0
  13. package/omega/Agentik_Engine/omega_engine/prompt_audit.py +395 -0
  14. package/omega/Agentik_Engine/omega_engine/tmux.py +16 -0
  15. package/omega/Agentik_Engine/omega_engine/tui.py +269 -67
  16. package/omega/Agentik_Engine/pyproject.toml +1 -1
  17. package/omega/Agentik_Engine/tests/__pycache__/test_installer_wiring.cpython-313-pytest-8.4.2.pyc +0 -0
  18. package/omega/Agentik_Engine/tests/__pycache__/test_installer_wiring.cpython-313.pyc +0 -0
  19. package/omega/Agentik_Engine/tests/__pycache__/test_paperclip_status.cpython-313-pytest-8.4.2.pyc +0 -0
  20. package/omega/Agentik_Engine/tests/__pycache__/test_paperclip_status.cpython-313.pyc +0 -0
  21. package/omega/Agentik_Engine/tests/__pycache__/test_prompt_audit.cpython-313-pytest-8.4.2.pyc +0 -0
  22. package/omega/Agentik_Engine/tests/__pycache__/test_prompt_audit.cpython-313.pyc +0 -0
  23. package/omega/Agentik_Engine/tests/__pycache__/test_tui_runtime.cpython-313-pytest-8.4.2.pyc +0 -0
  24. package/omega/Agentik_Engine/tests/__pycache__/test_tui_runtime.cpython-313.pyc +0 -0
  25. package/omega/Agentik_Engine/tests/test_installer_wiring.py +130 -0
  26. package/omega/Agentik_Engine/tests/test_paperclip_status.py +142 -0
  27. package/omega/Agentik_Engine/tests/test_prompt_audit.py +199 -0
  28. package/omega/Agentik_Engine/tests/test_tui_runtime.py +106 -0
  29. package/omega/Agentik_SSOT/VERSION +1 -1
  30. package/omega/Agentik_SSOT/docs/AUDIT-V0.19.38.md +90 -0
  31. package/omega/Agentik_SSOT/docs/AUDIT-V0.19.39.md +161 -0
  32. package/omega/Agentik_SSOT/rules/audit-gates.md +189 -0
  33. package/omega/Agentik_SSOT/rules/constitution.md +7 -0
  34. package/omega/Agentik_SSOT/rules/orchestration.md +215 -0
  35. package/omega/Agentik_SSOT/rules/prompt-protocols.md +219 -0
  36. package/omega/Agentik_SSOT/rules/scope-safety.md +197 -0
  37. package/omega/Agentik_SSOT/rules/three-laws.md +214 -0
  38. package/omega/Agentik_SSOT/rules/verified-completion.md +216 -0
  39. package/package.json +1 -1
@@ -462,46 +462,180 @@ def _arrow_menu() -> int:
462
462
  # `omega_engine.provider_state` module both can read.
463
463
  from omega_engine.provider_state import active_provider as _active_provider
464
464
 
465
+ # v0.19.39 — chat-first redesign. The TUI now opens on CONVERSATIONS
466
+ # (live tmux sessions: AISB chat, Hermès chat, active Oracles per
467
+ # project, active Workers per task) with ●/○ status dots. Setup,
468
+ # config, infra, audits, scrape, etc. move into sub-menus opened from
469
+ # a short "MENU" section. Reasoning: the user spends 99% of their
470
+ # time in conversations — the menu should reflect that, not bury the
471
+ # chats below 6 sections of admin options.
472
+ DOT_ON = f"{ORANGE}●{RST}" # alive
473
+ DOT_OFF = f"{MUTED}○{RST}" # not running
474
+
475
+ def _dot(alive: bool) -> str:
476
+ return DOT_ON if alive else DOT_OFF
477
+
478
+ def _conv_label(name: str, hint: str, alive: bool) -> str:
479
+ """Conversation row: `<dot> <name> <dim hint>`."""
480
+ dot = _dot(alive)
481
+ return f" {dot} {name:<32}{DIM}{hint}{RST}"
482
+
483
+ def _paperclip_status_quick() -> tuple[bool, str]:
484
+ """Lightweight inline Paperclip probe — short timeout, no raise.
485
+ Returns (alive, hint). Hint is empty when down, ``localhost:8080``
486
+ (or whatever port the bridge reports) when up. Uses the new
487
+ ``paperclip_bridge.is_running()`` from chantier 4 when present;
488
+ falls back gracefully to a plain TCP probe.
489
+ """
490
+ try:
491
+ from omega_engine.paperclip_bridge import is_running as _pcst
492
+ st = _pcst(HOME)
493
+ if getattr(st, "running", False):
494
+ port = getattr(st, "port", None) or 8080
495
+ return True, f"localhost:{port}"
496
+ except Exception: # noqa: BLE001
497
+ # Fallback: a 200ms TCP probe to 127.0.0.1:8080.
498
+ import socket as _s
499
+ try:
500
+ with _s.create_connection(("127.0.0.1", 8080), timeout=0.2):
501
+ return True, "localhost:8080"
502
+ except OSError:
503
+ pass
504
+ return False, ""
505
+
506
+ def _active_oracles_and_workers() -> tuple[list, list]:
507
+ """Categorize live tmux sessions into (oracles, workers).
508
+ Each entry is the ``TmuxSession`` instance."""
509
+ all_sessions = tmux.list_sessions(HOME)
510
+ oracles = sorted([s for s in all_sessions if s.category == "oracle"],
511
+ key=lambda x: x.name)
512
+ workers = sorted([s for s in all_sessions if s.category == "worker"],
513
+ key=lambda x: x.name)
514
+ return oracles, workers
515
+
465
516
  def _build_items() -> list[tuple[str, str]]:
466
- """Return (display, action_key) — section headers have key '__sep__'."""
517
+ """Return (display, action_key) — section headers have key '__sep__'.
518
+
519
+ v0.19.39 layout (chat-first):
520
+ 1. CONVERSATIONS — AISB / Hermès / live Oracles / live Workers
521
+ 2. QUICK ACTIONS — new chat, new project, run mission, Paperclip
522
+ 3. MENU — sub-menus (audits, setup, infra, health)
523
+ 4. EXIT
524
+ """
467
525
  provider = _active_provider()
468
- sessions = tmux.list_sessions(HOME)
469
- return [
470
- (_section("CHAT"), "__sep__"),
471
- (_label("AISB master chat", "→ claude (Max OAuth)"), "open:aisb"),
472
- (_label("Hermès companion", "→ claude (Anthropic API)"), "open:hermes"),
473
- (_label("Switch LLM", f"current: {provider}"), "switch:provider"),
474
- ("", "__sep__"),
475
- (_section("PROJECTS"), "__sep__"),
476
- (_label("New project", "Genesis pipeline"), "genesis:new"),
477
- (_label("Open project shell"), "project:open"),
478
- ("", "__sep__"),
479
- (_section("AUDITS & MISSIONS"), "__sep__"),
480
- (_label("Run a mission", "verified completion"), "run:mission"),
481
- (_label("Quality Arsenal", "17 forensic audits"), "audit:menu"),
482
- ("", "__sep__"),
483
- (_section("INFRASTRUCTURE"), "__sep__"),
484
- (_label("Sessions", f"{len(sessions)} active"), "sessions:list"),
485
- (_label("Accounts", "Claude Max pool"), "accounts:menu"),
486
- (_label("Vault", "encrypted secrets"), "vault:menu"),
487
- ("", "__sep__"),
488
- (_section("HEALTH"), "__sep__"),
489
- (_label("omega doctor", "full health check"), "cmd:doctor"),
490
- (_label("omega status", "task state"), "cmd:status"),
491
- ("", "__sep__"),
492
- (_section("SCRAPE"), "__sep__"),
493
- (_label("Stealth scrape", "CloakBrowser"), "scrape:cloak"),
494
- (_label("Fast scrape", "Scrapling"), "scrape:scrapling"),
495
- ("", "__sep__"),
496
- (_section("GOVERNANCE (Paperclip L0)"), "__sep__"),
497
- (_label("Paperclip dashboard","web UI for 14 agents"), "paperclip:dashboard"),
498
- (_label("Paperclip status", "bridge health"), "paperclip:status"),
499
- (_label("Paperclip register","(re)sync OmegaOS company"), "paperclip:register"),
500
- ("", "__sep__"),
501
- (_section("EXIT"), "__sep__"),
502
- (_label("Detach", "session keeps running"), "detach"),
503
- (_label("Quit Omega", "kills the tmux session"), "quit:kill"),
504
- ]
526
+ aisb_alive = tmux.omega_window_alive("aisb")
527
+ hermes_alive = tmux.omega_window_alive("hermes")
528
+ oracles, workers = _active_oracles_and_workers()
529
+ paperclip_alive, paperclip_hint = _paperclip_status_quick()
530
+
531
+ items: list[tuple[str, str]] = []
532
+
533
+ # ── CONVERSATIONS ───────────────────────────────────────────────
534
+ items.append((_section("CONVERSATIONS"), "__sep__"))
535
+ items.append((_conv_label("AISB master", "claude (Max OAuth)", aisb_alive), "open:aisb"))
536
+ items.append((_conv_label("Hermès", "claude (Anthropic API)", hermes_alive), "open:hermes"))
537
+
538
+ # Per-project live oracles + workers (only when there are any —
539
+ # don't waste a row when the user has nothing running).
540
+ if oracles or workers:
541
+ items.append(("", "__sep__"))
542
+ if oracles:
543
+ items.append((f" {DIM}— Active Oracles ({len(oracles)}) —{RST}", "__sep__"))
544
+ for s in oracles:
545
+ proj = s.project or "?"
546
+ hint = f"project: {proj}"
547
+ items.append((_conv_label(s.name, hint, True), f"attach:{s.name}"))
548
+ if workers:
549
+ items.append((f" {DIM}— Active Workers ({len(workers)}) —{RST}", "__sep__"))
550
+ for s in workers:
551
+ # Extract task from `<proj>-worker-N-<task>`.
552
+ task = s.name.split("worker-", 1)[-1].split("-", 1)[-1] if "worker-" in s.name else "?"
553
+ hint = f"task: {task}"
554
+ items.append((_conv_label(s.name, hint, True), f"attach:{s.name}"))
555
+
556
+ items.append(("", "__sep__"))
557
+
558
+ # ── QUICK ACTIONS ───────────────────────────────────────────────
559
+ items.append((_section("QUICK ACTIONS"), "__sep__"))
560
+ items.append((_label("+ New AISB chat", "fresh session"), "open:aisb:new"))
561
+ items.append((_label("+ New Hermès chat", "fresh session"), "open:hermes:new"))
562
+ items.append((_label("+ New project", "Genesis pipeline"), "genesis:new"))
563
+ items.append((_label("Run a mission", "verified completion"), "run:mission"))
564
+ # Paperclip dashboard with live status indicator.
565
+ pclbl = f"Paperclip dashboard"
566
+ pcdot = _dot(paperclip_alive)
567
+ pchint = paperclip_hint if paperclip_alive else "not running"
568
+ items.append((f" {pcdot} {pclbl:<32}{DIM}{pchint}{RST}", "paperclip:dashboard"))
569
+
570
+ items.append(("", "__sep__"))
571
+
572
+ # ── MENU (sub-menus for everything else) ────────────────────────
573
+ items.append((_section("MENU"), "__sep__"))
574
+ items.append((_label("Quality Arsenal", "17 forensic audits"), "submenu:audits"))
575
+ items.append((_label("Setup & config", f"LLM: {provider}"), "submenu:setup"))
576
+ items.append((_label("Infrastructure", "sessions, scrape"), "submenu:infra"))
577
+ items.append((_label("Health checks", "doctor, status"), "submenu:health"))
578
+ items.append((_label("Paperclip governance", "register, status"), "submenu:paperclip"))
579
+
580
+ items.append(("", "__sep__"))
581
+
582
+ # ── EXIT ────────────────────────────────────────────────────────
583
+ items.append((_section("EXIT"), "__sep__"))
584
+ items.append((_label("Detach", "session keeps running"), "detach"))
585
+ items.append((_label("Quit Omega", "kills the tmux session"), "quit:kill"))
586
+
587
+ return items
588
+
589
+ def _submenu_items(name: str) -> list[tuple[str, str]]:
590
+ """Return (display, action_key) for one of the named sub-menus.
591
+ Every sub-menu ends with a synthetic '← back' row that aborts the
592
+ cascaded fzf with key 'back'."""
593
+ if name == "audits":
594
+ return [
595
+ (_section("QUALITY ARSENAL"), "__sep__"),
596
+ (_label("List all 17 audits"), "audit:menu"),
597
+ (_label("Run an audit", "interactive"), "audit:run"),
598
+ ("", "__sep__"),
599
+ (_label("← back"), "back"),
600
+ ]
601
+ if name == "setup":
602
+ return [
603
+ (_section("SETUP & CONFIG"), "__sep__"),
604
+ (_label("Switch LLM provider"), "switch:provider"),
605
+ (_label("Accounts", "Claude Max pool"), "accounts:menu"),
606
+ (_label("Vault", "encrypted secrets"), "vault:menu"),
607
+ ("", "__sep__"),
608
+ (_label("← back"), "back"),
609
+ ]
610
+ if name == "infra":
611
+ return [
612
+ (_section("INFRASTRUCTURE"), "__sep__"),
613
+ (_label("List tmux sessions"), "sessions:list"),
614
+ (_label("Open project shell"), "project:open"),
615
+ ("", "__sep__"),
616
+ (_label("Stealth scrape", "CloakBrowser"), "scrape:cloak"),
617
+ (_label("Fast scrape", "Scrapling"), "scrape:scrapling"),
618
+ ("", "__sep__"),
619
+ (_label("← back"), "back"),
620
+ ]
621
+ if name == "health":
622
+ return [
623
+ (_section("HEALTH"), "__sep__"),
624
+ (_label("omega doctor", "full health check"), "cmd:doctor"),
625
+ (_label("omega status", "task state"), "cmd:status"),
626
+ ("", "__sep__"),
627
+ (_label("← back"), "back"),
628
+ ]
629
+ if name == "paperclip":
630
+ return [
631
+ (_section("PAPERCLIP GOVERNANCE (L0)"), "__sep__"),
632
+ (_label("Open dashboard"), "paperclip:dashboard"),
633
+ (_label("Status", "bridge health"), "paperclip:status"),
634
+ (_label("Register", "(re)sync OmegaOS company"), "paperclip:register"),
635
+ ("", "__sep__"),
636
+ (_label("← back"), "back"),
637
+ ]
638
+ return [(_label("← back"), "back")]
505
639
 
506
640
  def _run_inline(cmd_argv, *, shell: bool = False) -> None:
507
641
  os.system("clear")
@@ -525,23 +659,29 @@ def _arrow_menu() -> int:
525
659
  except (EOFError, KeyboardInterrupt):
526
660
  return ""
527
661
 
528
- while True:
529
- items = _build_items()
530
- # v0.19.37 bulletproof matching via tab-delimited index column.
531
- # fzf with --ansi STRIPS escape codes from stdout, which broke
532
- # the old `display == pick` exact-string match (every pick fell
533
- # through, menu just reloaded). Now we prefix each line with a
534
- # stable index (`<i>\t<display>`) and use --with-nth=2.. so fzf
535
- # only DISPLAYS the second column but RETURNS the full line.
536
- # We split off the index and look up the action — survives any
537
- # ANSI/whitespace transformation fzf might apply.
662
+ def _pick(items: list[tuple[str, str]],
663
+ header_extra: str = "") -> str | None:
664
+ """Render one fzf pass over ``items`` and return the chosen
665
+ action key (the second element of the picked tuple), or ``None``
666
+ on Esc / Ctrl-C / error.
667
+
668
+ Skips section headers and blank rows automatically — the caller
669
+ never has to worry about ``__sep__`` picks.
670
+
671
+ v0.19.37 lock-in: input is tab-delimited ``<idx>\\t<display>``,
672
+ fzf --with-nth=2.. shows only the visual column, and we parse
673
+ the index column out of stdout — survives any ANSI/whitespace
674
+ transformation fzf applies.
675
+ """
538
676
  lines = []
539
- for i, (disp, key) in enumerate(items):
677
+ for i, (disp, _key) in enumerate(items):
540
678
  lines.append(f"{i}\t{disp}")
541
679
  header = (
542
680
  f"{ORANGE}{BOLD}Ω Omega OS v{__version__}{RST} "
543
681
  f"{MUTED}• ↑↓ navigate • ↵ pick • / search • Esc refresh{RST}"
544
682
  )
683
+ if header_extra:
684
+ header = f"{header}\n{MUTED}{header_extra}{RST}"
545
685
  try:
546
686
  proc = subprocess.run(
547
687
  ["fzf",
@@ -577,27 +717,50 @@ def _arrow_menu() -> int:
577
717
  input="\n".join(lines), capture_output=True, text=True,
578
718
  )
579
719
  except (KeyboardInterrupt, subprocess.SubprocessError):
580
- return 0
720
+ return None
581
721
  if proc.returncode != 0:
582
- # Esc → refresh.
583
- continue
722
+ return None
584
723
  pick = proc.stdout.rstrip("\n")
585
- # Parse the index column. The tab is always the first \t in the
586
- # picked line (the rest of the display may contain colour codes
587
- # but no tabs).
588
724
  if "\t" not in pick:
589
- continue
590
- idx_str, _disp_part = pick.split("\t", 1)
725
+ return None
726
+ idx_str, _ = pick.split("\t", 1)
591
727
  try:
592
728
  idx = int(idx_str)
593
729
  except ValueError:
594
- continue
730
+ return None
595
731
  if not (0 <= idx < len(items)):
596
- continue
732
+ return None
597
733
  action = items[idx][1]
598
734
  if action == "__sep__":
599
- # User picked a section header — silent reroll.
735
+ return None
736
+ return action
737
+
738
+ def _open_submenu(name: str) -> str | None:
739
+ """Render a sub-menu in a loop until the user picks an action OR
740
+ chooses 'back'. Returns the action key to dispatch (the caller
741
+ runs it), or None when the user backs out / Esc'd.
742
+ """
743
+ while True:
744
+ sub_items = _submenu_items(name)
745
+ picked = _pick(sub_items, header_extra=f"sub-menu: {name} · Esc/← back to main")
746
+ if picked is None or picked == "back":
747
+ return None
748
+ return picked
749
+
750
+ while True:
751
+ items = _build_items()
752
+ action = _pick(items)
753
+ if action is None:
754
+ # Esc → just refresh the conversations panel (cheap — re-renders
755
+ # with live tmux state).
600
756
  continue
757
+ # Sub-menu indirection: when the user picks a sub-menu, render it
758
+ # in its own loop until they pick a real action or back out.
759
+ if action.startswith("submenu:"):
760
+ sub_name = action.split(":", 1)[1]
761
+ action = _open_submenu(sub_name)
762
+ if action is None:
763
+ continue
601
764
 
602
765
  # === Dispatch ===
603
766
  if action == "detach":
@@ -606,19 +769,50 @@ def _arrow_menu() -> int:
606
769
  if action == "quit:kill":
607
770
  subprocess.run(["tmux", "kill-session", "-t", "Omega"])
608
771
  return 0
609
- if action == "open:aisb":
610
- # v0.19.31 open as a WINDOW in Omega, not a separate session.
611
- # User stays in Omega; Ctrl-b 0 returns to menu, Ctrl-b d
612
- # detaches the whole Omega (and `omega` brings them back).
772
+ # v0.19.39 attach to a live Oracle / Worker tmux session. The
773
+ # menu lists them as `attach:<session_name>`. We use
774
+ # `select-window` when the session is already a window inside
775
+ # Omega; otherwise we switch the client to that session.
776
+ if action.startswith("attach:"):
777
+ target = action.split(":", 1)[1]
778
+ # If the target is a window of Omega, just select it. Else
779
+ # switch-client to that session (works under nested tmux).
780
+ wnames = subprocess.run(
781
+ ["tmux", "list-windows", "-t", "Omega", "-F", "#W"],
782
+ capture_output=True, text=True,
783
+ )
784
+ if (wnames.returncode == 0
785
+ and target in (wnames.stdout or "").splitlines()):
786
+ subprocess.run(["tmux", "select-window",
787
+ "-t", f"Omega:{target}"])
788
+ else:
789
+ # Switch to the foreign session. Use switch-client (works
790
+ # when we're already inside a tmux client) or fall back to
791
+ # attach for non-tmux contexts.
792
+ rc = subprocess.run(
793
+ ["tmux", "switch-client", "-t", target],
794
+ capture_output=True,
795
+ ).returncode
796
+ if rc != 0:
797
+ _run_inline(f"echo ' cannot attach to {target} — is it alive?' "
798
+ f"&& tmux has-session -t {target} && "
799
+ f"echo ' tmux says: yes' || echo ' tmux says: no'",
800
+ shell=True)
801
+ continue
802
+ # v0.19.39 — "+ New" actions: kill existing chat window first so
803
+ # the user gets a clean fresh session.
804
+ if action in ("open:aisb", "open:aisb:new"):
805
+ force = (action == "open:aisb:new")
613
806
  persona = HOME / "Agentik_SSOT" / "agents" / "aisb" / "CLAUDE.md"
614
807
  ctx_dir = tmux._ensure_chat_context_dir(HOME, "aisb-master", persona)
615
808
  tmux.spawn_chat_in_omega(
616
809
  "aisb", ctx_dir=ctx_dir, run_command="claude",
617
- force_replace=False,
810
+ force_replace=force,
618
811
  )
619
812
  subprocess.run(["tmux", "select-window", "-t", "Omega:aisb"])
620
813
  continue
621
- if action == "open:hermes":
814
+ if action in ("open:hermes", "open:hermes:new"):
815
+ force = (action == "open:hermes:new")
622
816
  persona = HOME / "Agentik_SSOT" / "docs" / "LAYERS.md"
623
817
  ctx_dir = tmux._ensure_chat_context_dir(HOME, "hermes", persona)
624
818
  try:
@@ -632,10 +826,18 @@ def _arrow_menu() -> int:
632
826
  if api_key else "claude")
633
827
  tmux.spawn_chat_in_omega(
634
828
  "hermes", ctx_dir=ctx_dir, run_command=run_cmd,
635
- force_replace=False,
829
+ force_replace=force,
636
830
  )
637
831
  subprocess.run(["tmux", "select-window", "-t", "Omega:hermes"])
638
832
  continue
833
+ if action == "audit:run":
834
+ audit_id = _prompt("audit id (codeaudit, uiuxaudit, flowaudit, ...)")
835
+ if audit_id:
836
+ _run_inline([OMEGA_BIN, "audit", audit_id])
837
+ continue
838
+ # (v0.19.31 open:aisb / open:hermes legacy handlers removed in
839
+ # v0.19.39 — the new unified handlers above (which accept both
840
+ # plain and `:new` variants) cover the same surface.)
639
841
  if action == "switch:provider":
640
842
  _run_inline([OMEGA_BIN, "switch"]); continue
641
843
  if action == "genesis:new":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omega-engine"
3
- version = "0.19.37"
3
+ version = "0.19.39"
4
4
  description = "The Omega OS orchestration engine — event-sourced, verified-completion agent graphs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -48,6 +48,7 @@ class TestInstallerStepList(unittest.TestCase):
48
48
  # name (the human label) → step function name
49
49
  ("36-tmux-config", "step_tmux_config"),
50
50
  ("37-hermes-brief", "step_hermes_brief"),
51
+ ("38-personas", "step_personas"),
51
52
  ("59-hermes-session", "step_hermes_session"),
52
53
  ]
53
54
 
@@ -497,5 +498,134 @@ class TestPostInstallCardSurfacesEntryPoints(unittest.TestCase):
497
498
  self.assertIn("tmux", text)
498
499
 
499
500
 
501
+ # ---------------------------------------------------------------------------
502
+ # step_personas (v0.19.38) — eagerly seeds canonical OMEGAOS-CONTEXT.md +
503
+ # every LLM's expected persona filename for AISB-master + Hermès chats,
504
+ # so the user does NOT have to spawn a chat once before personas exist.
505
+ # ---------------------------------------------------------------------------
506
+
507
+
508
+ class TestStepPersonasBehavior(unittest.TestCase):
509
+ """Without this step, personas materialize lazily on first chat spawn —
510
+ `omega doctor` shows them missing and the operator can't pre-edit the
511
+ canonical. This locks in eager seeding."""
512
+
513
+ def test_personas_land_in_both_chat_contexts(self):
514
+ with tempfile.TemporaryDirectory() as tmp:
515
+ home = Path(tmp) / "Omega"
516
+ home.mkdir()
517
+ # Mirror the engine into OMEGA_HOME the way step_structure does.
518
+ engine_src = REPO_ROOT / "omega" / "Agentik_Engine"
519
+ engine_dst = home / "Agentik_Engine"
520
+ engine_dst.mkdir()
521
+ for entry in engine_src.iterdir():
522
+ if entry.name in {"tests", "__pycache__"}:
523
+ continue
524
+ if entry.is_dir():
525
+ import shutil
526
+ shutil.copytree(entry, engine_dst / entry.name)
527
+ else:
528
+ (engine_dst / entry.name).write_bytes(entry.read_bytes())
529
+
530
+ old_home = os.environ.get("OMEGA_HOME")
531
+ os.environ["OMEGA_HOME"] = str(home)
532
+ sys.path.insert(0, str(engine_dst))
533
+ try:
534
+ # Reload the engine modules under the new OMEGA_HOME.
535
+ import importlib
536
+ import omega_engine.personas as P
537
+ importlib.reload(P)
538
+
539
+ # Mirror what step_personas does (the heredoc body).
540
+ P.ensure_canonical(home)
541
+ for label in ("aisb-master", "hermes"):
542
+ ctx = home / "Agentik_Coding" / "chat-contexts" / label
543
+ P.write_all_personas(home, ctx)
544
+ finally:
545
+ if old_home is None:
546
+ os.environ.pop("OMEGA_HOME", None)
547
+ else:
548
+ os.environ["OMEGA_HOME"] = old_home
549
+ sys.path.remove(str(engine_dst))
550
+
551
+ # Canonical lands at the SSOT path.
552
+ canon = home / "Agentik_SSOT" / "personas" / "OMEGAOS-CONTEXT.md"
553
+ self.assertTrue(canon.is_file(),
554
+ f"canonical must exist at {canon} — operator must be able "
555
+ "to edit it BEFORE the first chat spawn")
556
+ self.assertGreater(canon.stat().st_size, 500,
557
+ "canonical context looks suspiciously small")
558
+
559
+ # Per-LLM mirrors land in BOTH chat contexts.
560
+ for label in ("aisb-master", "hermes"):
561
+ ctx = home / "Agentik_Coding" / "chat-contexts" / label
562
+ for fname in ("CLAUDE.md", "GEMINI.md", "AGENTS.md",
563
+ "QWEN.md", "HERMES.md", "CONVENTIONS.md"):
564
+ self.assertTrue((ctx / fname).is_file(),
565
+ f"missing {ctx}/{fname} — chat context not fully seeded")
566
+ # Subdir personas (.opencode shared by opencode/openrouter/deepseek).
567
+ self.assertTrue((ctx / ".opencode" / "CONTEXT.md").is_file(),
568
+ f"missing .opencode/CONTEXT.md in {ctx}")
569
+ self.assertTrue((ctx / ".continue" / "CONTEXT.md").is_file(),
570
+ f"missing .continue/CONTEXT.md in {ctx}")
571
+
572
+ def test_step_personas_heredoc_calls_required_helpers(self):
573
+ """Lock in the heredoc body — if someone renames or removes
574
+ ensure_canonical / write_all_personas the step still has to call
575
+ them or the install silently stops seeding."""
576
+ body = STEPS_SH.read_text()
577
+ m = re.search(r"^step_personas\(\) \{(.+?)^\}", body,
578
+ re.DOTALL | re.MULTILINE)
579
+ self.assertIsNotNone(m, "step_personas function not found in steps.sh")
580
+ fn_body = m.group(1)
581
+ for name in ("ensure_canonical", "write_all_personas"):
582
+ self.assertIn(name, fn_body,
583
+ f"step_personas must call {name}() — without it the install "
584
+ "stops seeding personas")
585
+ for label in ("aisb-master", "hermes"):
586
+ self.assertIn(label, fn_body,
587
+ f"step_personas must seed chat-contexts/{label}/")
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # v0.19.38 — `--` separator must NOT raise "unknown argument".
592
+ # Repro: `npx -y @agentikos/omega-os@latest -- --full` lands in install.sh
593
+ # as `install.sh -- --full`, and the case-block used to reject `--`.
594
+ # ---------------------------------------------------------------------------
595
+
596
+
597
+ class TestDashDashSeparatorAccepted(unittest.TestCase):
598
+ """Two layers must accept `--`:
599
+ 1. bin/omega-os.js strips it before exec'ing bash.
600
+ 2. install.sh accepts it as a no-op as defense-in-depth."""
601
+
602
+ def test_install_sh_has_dash_dash_no_op_case(self):
603
+ if not INSTALL_SH.is_file():
604
+ self.skipTest("install.sh not in repo")
605
+ text = INSTALL_SH.read_text()
606
+ # The case-block must explicitly match `--`. Use re.search with
607
+ # MULTILINE so `^` matches each line start.
608
+ self.assertTrue(
609
+ re.search(r"^\s*--\)\s+shift\s*;;", text, re.MULTILINE),
610
+ "install.sh must accept `--` as a no-op — without it "
611
+ "`bash install.sh -- --full` dies with `unknown argument: --`")
612
+
613
+ def test_bin_launcher_filters_dash_dash(self):
614
+ bin_js = REPO_ROOT / "bin" / "omega-os.js"
615
+ if not bin_js.is_file():
616
+ self.skipTest("bin/omega-os.js not in repo")
617
+ text = bin_js.read_text()
618
+ # The filter must drop the literal `--` so the bash installer's
619
+ # case-block isn't even reached for the npx-injected separator.
620
+ # Match `.filter(...)` containing the literal "--" on the same line.
621
+ # Use DOTALL-free single-line scan, just look for the two anchors
622
+ # together: `.filter(` and `"--"` within ~80 chars.
623
+ self.assertTrue(
624
+ re.search(r'\.filter\(.{0,80}"--"', text),
625
+ "bin/omega-os.js must filter the `--` separator out of argv "
626
+ "before passing to install.sh — pattern `.filter(... \"--\")` "
627
+ "not found")
628
+
629
+
500
630
  if __name__ == "__main__":
501
631
  unittest.main()