@trac3er/oh-my-god 2.0.0 → 2.0.2

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 (243) hide show
  1. package/.claude-plugin/marketplace.json +8 -8
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.claude-plugin/scripts/uninstall.sh +74 -3
  4. package/.claude-plugin/scripts/update.sh +78 -3
  5. package/.coveragerc +26 -0
  6. package/.mcp.json +4 -4
  7. package/CHANGELOG.md +14 -0
  8. package/CODE_OF_CONDUCT.md +27 -0
  9. package/CONTRIBUTING.md +62 -0
  10. package/OMG-setup.sh +1201 -355
  11. package/README.md +77 -56
  12. package/SECURITY.md +25 -0
  13. package/agents/__init__.py +1 -0
  14. package/agents/model_roles.py +196 -0
  15. package/agents/omg-architect-mode.md +3 -5
  16. package/agents/omg-backend-engineer.md +3 -5
  17. package/agents/omg-database-engineer.md +3 -5
  18. package/agents/omg-frontend-designer.md +4 -5
  19. package/agents/omg-implement-mode.md +4 -5
  20. package/agents/omg-infra-engineer.md +3 -5
  21. package/agents/omg-research-mode.md +4 -6
  22. package/agents/omg-security-auditor.md +3 -5
  23. package/agents/omg-testing-engineer.md +3 -5
  24. package/build/lib/yaml.py +321 -0
  25. package/commands/OMG:ai-commit.md +101 -14
  26. package/commands/OMG:arch.md +302 -19
  27. package/commands/OMG:ccg.md +12 -7
  28. package/commands/OMG:compat.md +25 -17
  29. package/commands/OMG:cost.md +173 -13
  30. package/commands/OMG:crazy.md +1 -1
  31. package/commands/OMG:create-agent.md +170 -20
  32. package/commands/OMG:deps.md +235 -17
  33. package/commands/OMG:domain-init.md +1 -1
  34. package/commands/OMG:escalate.md +41 -12
  35. package/commands/OMG:health-check.md +37 -13
  36. package/commands/OMG:init.md +122 -14
  37. package/commands/OMG:project-init.md +1 -1
  38. package/commands/OMG:session-branch.md +76 -9
  39. package/commands/OMG:session-fork.md +42 -5
  40. package/commands/OMG:session-merge.md +124 -8
  41. package/commands/OMG:setup.md +69 -12
  42. package/commands/OMG:stats.md +215 -14
  43. package/commands/OMG:teams.md +19 -10
  44. package/config/lsp_languages.yaml +8 -0
  45. package/hooks/__init__.py +0 -0
  46. package/hooks/_agent_registry.py +423 -0
  47. package/hooks/_analytics.py +291 -0
  48. package/hooks/_budget.py +31 -0
  49. package/hooks/_common.py +569 -0
  50. package/hooks/_compression_optimizer.py +119 -0
  51. package/hooks/_cost_ledger.py +176 -0
  52. package/hooks/_learnings.py +126 -0
  53. package/hooks/_memory.py +103 -0
  54. package/hooks/_protected_context.py +150 -0
  55. package/hooks/_token_counter.py +221 -0
  56. package/hooks/branch_manager.py +236 -0
  57. package/hooks/budget_governor.py +232 -0
  58. package/hooks/circuit-breaker.py +270 -0
  59. package/hooks/compression_feedback.py +254 -0
  60. package/hooks/config-guard.py +216 -0
  61. package/hooks/context_pressure.py +53 -0
  62. package/hooks/credential_store.py +1020 -0
  63. package/hooks/fetch-rate-limits.py +212 -0
  64. package/hooks/firewall.py +48 -0
  65. package/hooks/hashline-formatter-bridge.py +224 -0
  66. package/hooks/hashline-injector.py +273 -0
  67. package/hooks/hashline-validator.py +216 -0
  68. package/hooks/idle-detector.py +95 -0
  69. package/hooks/intentgate-keyword-detector.py +188 -0
  70. package/hooks/magic-keyword-router.py +195 -0
  71. package/hooks/policy_engine.py +505 -0
  72. package/hooks/post-tool-failure.py +19 -0
  73. package/hooks/post-write.py +219 -0
  74. package/hooks/post_write.py +46 -0
  75. package/hooks/pre-compact.py +398 -0
  76. package/hooks/pre-tool-inject.py +98 -0
  77. package/hooks/prompt-enhancer.py +672 -0
  78. package/hooks/quality-runner.py +191 -0
  79. package/hooks/query.py +512 -0
  80. package/hooks/secret-guard.py +61 -0
  81. package/hooks/secret_audit.py +144 -0
  82. package/hooks/session-end-capture.py +137 -0
  83. package/hooks/session-start.py +277 -0
  84. package/hooks/setup_wizard.py +582 -0
  85. package/hooks/shadow_manager.py +297 -0
  86. package/hooks/state_migration.py +225 -0
  87. package/hooks/stop-gate.py +7 -0
  88. package/hooks/stop_dispatcher.py +945 -0
  89. package/hooks/test-validator.py +361 -0
  90. package/hooks/test_generator_hook.py +123 -0
  91. package/hooks/todo-state-tracker.py +114 -0
  92. package/hooks/tool-ledger.py +149 -0
  93. package/hooks/trust_review.py +585 -0
  94. package/hud/omg-hud.mjs +31 -1
  95. package/lab/__init__.py +1 -0
  96. package/lab/pipeline.py +75 -0
  97. package/lab/policies.py +52 -0
  98. package/package.json +7 -18
  99. package/plugins/README.md +33 -61
  100. package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
  101. package/plugins/advanced/commands/OMG:learn.md +1 -1
  102. package/plugins/advanced/commands/OMG:security-review.md +3 -3
  103. package/plugins/advanced/commands/OMG:ship.md +1 -1
  104. package/plugins/advanced/plugin.json +1 -1
  105. package/plugins/core/plugin.json +8 -3
  106. package/plugins/dephealth/__init__.py +0 -0
  107. package/plugins/dephealth/cve_scanner.py +188 -0
  108. package/plugins/dephealth/license_checker.py +135 -0
  109. package/plugins/dephealth/manifest_detector.py +423 -0
  110. package/plugins/dephealth/vuln_analyzer.py +169 -0
  111. package/plugins/testgen/__init__.py +0 -0
  112. package/plugins/testgen/codamosa_engine.py +402 -0
  113. package/plugins/testgen/edge_case_synthesizer.py +184 -0
  114. package/plugins/testgen/framework_detector.py +271 -0
  115. package/plugins/testgen/skeleton_generator.py +219 -0
  116. package/plugins/viz/__init__.py +0 -0
  117. package/plugins/viz/ast_parser.py +139 -0
  118. package/plugins/viz/diagram_generator.py +192 -0
  119. package/plugins/viz/graph_builder.py +444 -0
  120. package/plugins/viz/native_parsers.py +259 -0
  121. package/plugins/viz/regex_parser.py +112 -0
  122. package/pyproject.toml +81 -0
  123. package/rules/contextual/write-verify.md +2 -2
  124. package/rules/core/00-truth.md +1 -1
  125. package/rules/core/01-surgical.md +1 -1
  126. package/rules/core/02-circuit-breaker.md +2 -2
  127. package/rules/core/03-ensemble.md +3 -3
  128. package/rules/core/04-testing.md +3 -3
  129. package/runtime/__init__.py +32 -0
  130. package/runtime/adapters/__init__.py +13 -0
  131. package/runtime/adapters/claude.py +60 -0
  132. package/runtime/adapters/gpt.py +53 -0
  133. package/runtime/adapters/local.py +53 -0
  134. package/runtime/adoption.py +212 -0
  135. package/runtime/business_workflow.py +220 -0
  136. package/runtime/cli_provider.py +85 -0
  137. package/runtime/compat.py +1299 -0
  138. package/runtime/custom_agent_loader.py +366 -0
  139. package/runtime/dispatcher.py +47 -0
  140. package/runtime/ecosystem.py +371 -0
  141. package/runtime/legacy_compat.py +7 -0
  142. package/runtime/mcp_config_writers.py +115 -0
  143. package/runtime/mcp_lifecycle.py +153 -0
  144. package/runtime/mcp_memory_server.py +135 -0
  145. package/runtime/memory_parsers/__init__.py +0 -0
  146. package/runtime/memory_parsers/chatgpt_parser.py +257 -0
  147. package/runtime/memory_parsers/claude_import.py +107 -0
  148. package/runtime/memory_parsers/export.py +97 -0
  149. package/runtime/memory_parsers/gemini_import.py +91 -0
  150. package/runtime/memory_parsers/kimi_import.py +91 -0
  151. package/runtime/memory_store.py +215 -0
  152. package/runtime/omc_compat.py +7 -0
  153. package/runtime/providers/__init__.py +0 -0
  154. package/runtime/providers/codex_provider.py +112 -0
  155. package/runtime/providers/gemini_provider.py +128 -0
  156. package/runtime/providers/kimi_provider.py +151 -0
  157. package/runtime/providers/opencode_provider.py +144 -0
  158. package/runtime/subagent_dispatcher.py +362 -0
  159. package/runtime/team_router.py +1167 -0
  160. package/runtime/tmux_session_manager.py +169 -0
  161. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  162. package/scripts/check-omg-contract-snapshot.py +12 -0
  163. package/scripts/check-omg-public-ready.py +193 -0
  164. package/scripts/check-omg-standalone-clean.py +103 -0
  165. package/scripts/legacy_to_omg_migrate.py +29 -0
  166. package/scripts/migrate-legacy.py +464 -0
  167. package/scripts/omc_to_omg_migrate.py +12 -0
  168. package/scripts/omg.py +492 -0
  169. package/scripts/settings-merge.py +283 -0
  170. package/scripts/verify-standalone.sh +8 -4
  171. package/settings.json +126 -29
  172. package/templates/profile.yaml +1 -1
  173. package/tools/__init__.py +2 -0
  174. package/tools/browser_consent.py +289 -0
  175. package/tools/browser_stealth.py +481 -0
  176. package/tools/browser_tool.py +448 -0
  177. package/tools/changelog_generator.py +347 -0
  178. package/tools/commit_splitter.py +746 -0
  179. package/tools/config_discovery.py +151 -0
  180. package/tools/config_merger.py +449 -0
  181. package/tools/dashboard_generator.py +300 -0
  182. package/tools/git_inspector.py +298 -0
  183. package/tools/lsp_client.py +275 -0
  184. package/tools/lsp_discovery.py +231 -0
  185. package/tools/lsp_operations.py +392 -0
  186. package/tools/pr_generator.py +404 -0
  187. package/tools/python_repl.py +656 -0
  188. package/tools/python_sandbox.py +609 -0
  189. package/tools/search_providers/__init__.py +77 -0
  190. package/tools/search_providers/brave.py +115 -0
  191. package/tools/search_providers/exa.py +116 -0
  192. package/tools/search_providers/jina.py +104 -0
  193. package/tools/search_providers/perplexity.py +139 -0
  194. package/tools/search_providers/synthetic.py +74 -0
  195. package/tools/session_snapshot.py +736 -0
  196. package/tools/ssh_manager.py +912 -0
  197. package/tools/theme_engine.py +294 -0
  198. package/tools/theme_selector.py +137 -0
  199. package/tools/web_search.py +622 -0
  200. package/yaml.py +321 -0
  201. package/.claude-plugin/scripts/install.sh +0 -9
  202. package/bun.lock +0 -23
  203. package/bunfig.toml +0 -3
  204. package/hooks/_budget.ts +0 -1
  205. package/hooks/_common.ts +0 -63
  206. package/hooks/circuit-breaker.ts +0 -101
  207. package/hooks/config-guard.ts +0 -4
  208. package/hooks/firewall.ts +0 -20
  209. package/hooks/policy_engine.ts +0 -156
  210. package/hooks/post-tool-failure.ts +0 -22
  211. package/hooks/post-write.ts +0 -4
  212. package/hooks/pre-tool-inject.ts +0 -4
  213. package/hooks/prompt-enhancer.ts +0 -46
  214. package/hooks/quality-runner.ts +0 -24
  215. package/hooks/secret-guard.ts +0 -4
  216. package/hooks/session-end-capture.ts +0 -19
  217. package/hooks/session-start.ts +0 -19
  218. package/hooks/shadow_manager.ts +0 -81
  219. package/hooks/stop-gate.ts +0 -22
  220. package/hooks/stop_dispatcher.ts +0 -147
  221. package/hooks/test-generator-hook.ts +0 -4
  222. package/hooks/tool-ledger.ts +0 -27
  223. package/hooks/trust_review.ts +0 -175
  224. package/lab/pipeline.ts +0 -75
  225. package/lab/policies.ts +0 -68
  226. package/runtime/common.ts +0 -111
  227. package/runtime/compat.ts +0 -174
  228. package/runtime/dispatcher.ts +0 -25
  229. package/runtime/ecosystem.ts +0 -186
  230. package/runtime/provider_bootstrap.ts +0 -99
  231. package/runtime/provider_smoke.ts +0 -34
  232. package/runtime/release_readiness.ts +0 -186
  233. package/runtime/team_router.ts +0 -144
  234. package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
  235. package/scripts/check-omg-standalone-clean.ts +0 -12
  236. package/scripts/check-runtime-clean.ts +0 -94
  237. package/scripts/omg.ts +0 -352
  238. package/scripts/settings-merge.ts +0 -93
  239. package/tools/commit_splitter.ts +0 -23
  240. package/tools/git_inspector.ts +0 -18
  241. package/tools/session_snapshot.ts +0 -47
  242. package/trac3er-oh-my-god-2.0.0.tgz +0 -0
  243. package/tsconfig.json +0 -15
@@ -0,0 +1,151 @@
1
+ """Kimi Code CLI provider -- implements CLIProvider for the ``kimi`` binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import uuid
12
+ from typing import Any
13
+
14
+ from runtime.cli_provider import CLIProvider, register_provider
15
+ from runtime.tmux_session_manager import TmuxSessionManager
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+
20
+ class KimiCodeProvider(CLIProvider):
21
+ """CLIProvider implementation for the Kimi Code CLI (``kimi``)."""
22
+
23
+ # -- identity -----------------------------------------------------------
24
+
25
+ def get_name(self) -> str: # noqa: D401
26
+ """Return the canonical provider name."""
27
+ return "kimi"
28
+
29
+ # -- detection ----------------------------------------------------------
30
+
31
+ def detect(self) -> bool:
32
+ """Return ``True`` when the ``kimi`` binary is available on PATH."""
33
+ return shutil.which("kimi") is not None
34
+
35
+ # -- authentication -----------------------------------------------------
36
+
37
+ def check_auth(self) -> tuple[bool | None, str]:
38
+ """Check Kimi authentication by parsing ``~/.kimi/config.toml`` for stored credentials."""
39
+ try:
40
+ config_path = os.path.expanduser("~/.kimi/config.toml")
41
+ if not os.path.exists(config_path):
42
+ return False, "not authenticated — config file not found"
43
+
44
+ with open(config_path) as fh:
45
+ content = fh.read()
46
+
47
+ # Look for a token entry in the TOML file
48
+ if "token" in content:
49
+ return True, "authenticated"
50
+ return False, "not authenticated — no token in config"
51
+ except Exception as exc:
52
+ return None, f"kimi auth check failed: {exc}"
53
+
54
+ # -- invocation ---------------------------------------------------------
55
+
56
+ def invoke(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
57
+ """Invoke ``kimi --print -p`` via subprocess."""
58
+ try:
59
+ result = self.run_tool(
60
+ ["kimi", "--print", "-p", prompt],
61
+ timeout=timeout,
62
+ )
63
+ return {
64
+ "model": "kimi-cli",
65
+ "output": result.stdout,
66
+ "exit_code": result.returncode,
67
+ }
68
+ except subprocess.TimeoutExpired:
69
+ return {"error": "kimi-cli timeout", "fallback": "claude"}
70
+ except FileNotFoundError:
71
+ return {"error": "kimi-cli not found", "fallback": "claude"}
72
+ except Exception as exc:
73
+ return {"error": str(exc), "fallback": "claude"}
74
+
75
+ def invoke_json(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
76
+ """Invoke ``kimi --print --output-format stream-json -p`` for JSONL event stream output."""
77
+ try:
78
+ result = self.run_tool(
79
+ ["kimi", "--print", "--output-format", "stream-json", "-p", prompt],
80
+ timeout=timeout,
81
+ )
82
+ return {
83
+ "model": "kimi-cli",
84
+ "output": result.stdout,
85
+ "exit_code": result.returncode,
86
+ }
87
+ except subprocess.TimeoutExpired:
88
+ return {"error": "kimi-cli timeout", "fallback": "claude"}
89
+ except FileNotFoundError:
90
+ return {"error": "kimi-cli not found", "fallback": "claude"}
91
+ except Exception as exc:
92
+ return {"error": str(exc), "fallback": "claude"}
93
+
94
+ def invoke_tmux(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
95
+ """Invoke ``kimi --print -p`` via a persistent tmux session.
96
+
97
+ Falls back to :meth:`invoke` on failure.
98
+ """
99
+ try:
100
+ mgr = TmuxSessionManager()
101
+ session_name = mgr.make_session_name("kimi", unique_id=str(uuid.uuid4())[:8])
102
+ session = mgr.get_or_create_session(session_name)
103
+ output = mgr.send_command(session, f"kimi --print -p {shlex.quote(prompt)}", timeout=timeout)
104
+ mgr.kill_session(session)
105
+ return {"model": "kimi-cli", "output": output, "exit_code": 0}
106
+ except Exception as exc:
107
+ _logger.warning("tmux kimi invocation failed, falling back to subprocess: %s", exc)
108
+ return self.invoke(prompt, project_dir, timeout=timeout)
109
+
110
+ # -- command helpers ----------------------------------------------------
111
+
112
+ def get_non_interactive_cmd(self, prompt: str) -> list[str]:
113
+ """Return the non-interactive command for kimi."""
114
+ return ["kimi", "--print", "-p", prompt]
115
+
116
+ # -- configuration ------------------------------------------------------
117
+
118
+ def get_config_path(self) -> str:
119
+ """Return the Kimi MCP configuration file path."""
120
+ return os.path.expanduser("~/.kimi/mcp.json")
121
+
122
+ def write_mcp_config(self, server_url: str, server_name: str = "memory-server") -> None:
123
+ """Write an MCP server entry to ``~/.kimi/mcp.json``.
124
+
125
+ Uses standard ``mcpServers`` JSON format with ``type: "http"`` and ``url`` field,
126
+ merging into any existing configuration.
127
+ """
128
+ config_path = self.get_config_path()
129
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
130
+
131
+ # Load existing config or start fresh
132
+ existing: dict[str, Any] = {} # pyright: ignore[reportExplicitAny]
133
+ if os.path.exists(config_path):
134
+ with open(config_path) as fh:
135
+ try:
136
+ existing = json.load(fh)
137
+ except (json.JSONDecodeError, ValueError):
138
+ existing = {}
139
+
140
+ # Ensure mcpServers dict exists
141
+ if "mcpServers" not in existing:
142
+ existing["mcpServers"] = {}
143
+
144
+ existing["mcpServers"][server_name] = {"type": "http", "url": server_url}
145
+
146
+ with open(config_path, "w") as fh:
147
+ json.dump(existing, fh, indent=2)
148
+
149
+
150
+ # -- auto-register on import -----------------------------------------------
151
+ register_provider(KimiCodeProvider())
@@ -0,0 +1,144 @@
1
+ """OpenCode CLI provider -- implements CLIProvider for the ``opencode`` binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import uuid
12
+ from typing import Any
13
+
14
+ from runtime.cli_provider import CLIProvider, register_provider
15
+ from runtime.tmux_session_manager import TmuxSessionManager
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+
20
+ class OpenCodeProvider(CLIProvider):
21
+ """CLIProvider implementation for the OpenCode CLI (``opencode``)."""
22
+
23
+ # -- identity -----------------------------------------------------------
24
+
25
+ def get_name(self) -> str: # noqa: D401
26
+ """Return the canonical provider name."""
27
+ return "opencode"
28
+
29
+ # -- detection ----------------------------------------------------------
30
+
31
+ def detect(self) -> bool:
32
+ """Return ``True`` when the ``opencode`` binary is available on PATH."""
33
+ return shutil.which("opencode") is not None
34
+
35
+ # -- authentication -----------------------------------------------------
36
+
37
+ def check_auth(self) -> tuple[bool | None, str]:
38
+ """Check OpenCode authentication status via ``opencode auth list``."""
39
+ try:
40
+ result = self.run_tool(["opencode", "auth", "list"], timeout=30)
41
+ if result.returncode == 0:
42
+ return True, result.stdout.strip()
43
+ return False, result.stderr.strip() or result.stdout.strip()
44
+ except Exception as exc:
45
+ return None, f"opencode auth check failed: {exc}"
46
+
47
+ # -- invocation ---------------------------------------------------------
48
+
49
+ def invoke(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
50
+ """Invoke ``opencode run`` via subprocess."""
51
+ try:
52
+ result = self.run_tool(
53
+ ["opencode", "run", prompt],
54
+ timeout=timeout,
55
+ )
56
+ return {
57
+ "model": "opencode-cli",
58
+ "output": result.stdout,
59
+ "exit_code": result.returncode,
60
+ }
61
+ except subprocess.TimeoutExpired:
62
+ return {"error": "opencode-cli timeout", "fallback": "claude"}
63
+ except FileNotFoundError:
64
+ return {"error": "opencode-cli not found", "fallback": "claude"}
65
+ except Exception as exc:
66
+ return {"error": str(exc), "fallback": "claude"}
67
+
68
+ def invoke_json(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
69
+ """Invoke ``opencode run --format json`` for raw JSON event stream output."""
70
+ try:
71
+ result = self.run_tool(
72
+ ["opencode", "run", "--format", "json", prompt],
73
+ timeout=timeout,
74
+ )
75
+ return {
76
+ "model": "opencode-cli",
77
+ "output": result.stdout,
78
+ "exit_code": result.returncode,
79
+ }
80
+ except subprocess.TimeoutExpired:
81
+ return {"error": "opencode-cli timeout", "fallback": "claude"}
82
+ except FileNotFoundError:
83
+ return {"error": "opencode-cli not found", "fallback": "claude"}
84
+ except Exception as exc:
85
+ return {"error": str(exc), "fallback": "claude"}
86
+
87
+ def invoke_tmux(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
88
+ """Invoke ``opencode run`` via a persistent tmux session.
89
+
90
+ Falls back to :meth:`invoke` on failure.
91
+ """
92
+ try:
93
+ mgr = TmuxSessionManager()
94
+ session_name = mgr.make_session_name("opencode", unique_id=str(uuid.uuid4())[:8])
95
+ session = mgr.get_or_create_session(session_name)
96
+ output = mgr.send_command(session, f"opencode run {shlex.quote(prompt)}", timeout=timeout)
97
+ mgr.kill_session(session)
98
+ return {"model": "opencode-cli", "output": output, "exit_code": 0}
99
+ except Exception as exc:
100
+ _logger.warning("tmux opencode invocation failed, falling back to subprocess: %s", exc)
101
+ return self.invoke(prompt, project_dir, timeout=timeout)
102
+
103
+ # -- command helpers ----------------------------------------------------
104
+
105
+ def get_non_interactive_cmd(self, prompt: str) -> list[str]:
106
+ """Return the non-interactive command for opencode."""
107
+ return ["opencode", "run", prompt]
108
+
109
+ # -- configuration ------------------------------------------------------
110
+
111
+ def get_config_path(self) -> str:
112
+ """Return the OpenCode configuration file path."""
113
+ return os.path.expanduser("~/.config/opencode/opencode.json")
114
+
115
+ def write_mcp_config(self, server_url: str, server_name: str = "memory-server") -> None:
116
+ """Write an MCP server entry to ``~/.config/opencode/opencode.json``.
117
+
118
+ Uses JSON format with ``mcp`` key, ``type: "remote"``, and ``url`` field,
119
+ merging into any existing configuration.
120
+ """
121
+ config_path = self.get_config_path()
122
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
123
+
124
+ # Load existing config or start fresh
125
+ existing: dict[str, Any] = {} # pyright: ignore[reportExplicitAny]
126
+ if os.path.exists(config_path):
127
+ with open(config_path) as fh:
128
+ try:
129
+ existing = json.load(fh)
130
+ except (json.JSONDecodeError, ValueError):
131
+ existing = {}
132
+
133
+ # Ensure mcp dict exists
134
+ if "mcp" not in existing:
135
+ existing["mcp"] = {}
136
+
137
+ existing["mcp"][server_name] = {"type": "remote", "url": server_url}
138
+
139
+ with open(config_path, "w") as fh:
140
+ json.dump(existing, fh, indent=2)
141
+
142
+
143
+ # -- auto-register on import -----------------------------------------------
144
+ register_provider(OpenCodeProvider())
@@ -0,0 +1,362 @@
1
+ """Parallel Execution Backend — concurrent subagent job management.
2
+
3
+ Manages concurrent subagent jobs with isolation, artifact streaming,
4
+ and a 100-job limit using stdlib ThreadPoolExecutor.
5
+
6
+ Feature flag: OMG_PARALLEL_SUBAGENTS_ENABLED (default: False)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import time
13
+ import uuid
14
+ import threading
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from datetime import datetime, timezone
17
+ from typing import Any
18
+
19
+ # --- Path resolution (never relies on CWD) ---
20
+ _DISPATCHER_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ _OMG_ROOT = os.path.dirname(_DISPATCHER_DIR)
22
+
23
+ # --- Constants ---
24
+ MAX_JOBS = 100
25
+
26
+ # --- Module-level singletons ---
27
+ _executor: ThreadPoolExecutor | None = None
28
+ _jobs: dict[str, dict[str, Any]] = {}
29
+ _lock = threading.Lock()
30
+
31
+
32
+ def _get_feature_flag() -> Any:
33
+ """Lazy-import get_feature_flag from hooks/_common.py."""
34
+ hooks_dir = os.path.join(_OMG_ROOT, "hooks")
35
+ if hooks_dir not in sys.path:
36
+ sys.path.insert(0, hooks_dir)
37
+ try:
38
+ from _common import get_feature_flag # pyright: ignore[reportMissingImports]
39
+ return get_feature_flag
40
+ except ImportError:
41
+ return None
42
+
43
+
44
+ def _get_atomic_json_write() -> Any:
45
+ """Lazy-import atomic_json_write from hooks/_common.py."""
46
+ hooks_dir = os.path.join(_OMG_ROOT, "hooks")
47
+ if hooks_dir not in sys.path:
48
+ sys.path.insert(0, hooks_dir)
49
+ try:
50
+ from _common import atomic_json_write # pyright: ignore[reportMissingImports]
51
+ return atomic_json_write
52
+ except ImportError:
53
+ return None
54
+
55
+
56
+ def _is_enabled() -> bool:
57
+ """Check if parallel subagents feature is enabled.
58
+
59
+ Resolution: env var OMG_PARALLEL_SUBAGENTS_ENABLED → settings.json → default False.
60
+ """
61
+ # Fast path: check env var directly
62
+ env_val = os.environ.get("OMG_PARALLEL_SUBAGENTS_ENABLED", "").lower()
63
+ if env_val in ("0", "false", "no"):
64
+ return False
65
+ if env_val in ("1", "true", "yes"):
66
+ return True
67
+
68
+ # Slow path: check via get_feature_flag
69
+ get_flag = _get_feature_flag()
70
+ if get_flag is not None:
71
+ return get_flag("PARALLEL_SUBAGENTS", default=False)
72
+ return False
73
+
74
+
75
+ def _get_project_dir() -> str:
76
+ """Get project directory from env or cwd."""
77
+ return os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
78
+
79
+
80
+ def _jobs_dir() -> str:
81
+ """Return the jobs state directory path."""
82
+ return os.path.join(_get_project_dir(), ".omg", "state", "jobs")
83
+
84
+
85
+ def _job_path(job_id: str) -> str:
86
+ """Return the file path for a specific job."""
87
+ return os.path.join(_jobs_dir(), f"{job_id}.json")
88
+
89
+
90
+ def _persist_job(job_id: str, record: dict[str, Any]) -> None:
91
+ """Persist job record to disk via atomic_json_write."""
92
+ writer = _get_atomic_json_write()
93
+ if writer is not None:
94
+ writer(_job_path(job_id), record)
95
+
96
+
97
+ def _load_job_from_disk(job_id: str) -> dict[str, Any] | None:
98
+ """Load a job record from disk if it exists."""
99
+ path = _job_path(job_id)
100
+ if not os.path.exists(path):
101
+ return None
102
+ try:
103
+ import json
104
+ with open(path, "r", encoding="utf-8") as f:
105
+ return json.load(f)
106
+ except (OSError, ValueError):
107
+ return None
108
+
109
+
110
+ def get_executor() -> ThreadPoolExecutor:
111
+ """Lazy-init and return the module-level ThreadPoolExecutor singleton."""
112
+ global _executor
113
+ if _executor is None:
114
+ _executor = ThreadPoolExecutor(max_workers=MAX_JOBS)
115
+ return _executor
116
+
117
+
118
+ def _running_count() -> int:
119
+ """Count jobs with status 'running'."""
120
+ return len([j for j in _jobs.values() if j["status"] == "running"])
121
+
122
+
123
+ def submit_job(
124
+ agent_name: str,
125
+ task_text: str,
126
+ isolation: str = "none",
127
+ ) -> str:
128
+ """Submit a subagent job for concurrent execution.
129
+
130
+ Args:
131
+ agent_name: Name of the agent to dispatch.
132
+ task_text: Task description / prompt for the agent.
133
+ isolation: Isolation backend — "none" (default) or "worktree".
134
+
135
+ Returns:
136
+ job_id (8-char hex string).
137
+
138
+ Raises:
139
+ RuntimeError: If feature is disabled or job limit reached.
140
+ """
141
+ if not _is_enabled():
142
+ raise RuntimeError("feature disabled")
143
+
144
+ with _lock:
145
+ if _running_count() >= MAX_JOBS:
146
+ raise RuntimeError("job limit reached")
147
+
148
+ job_id = uuid.uuid4().hex[:8]
149
+ now = datetime.now(timezone.utc).isoformat()
150
+ record: dict[str, Any] = {
151
+ "job_id": job_id,
152
+ "agent_name": agent_name,
153
+ "task_text": task_text,
154
+ "isolation": isolation,
155
+ "status": "queued",
156
+ "created_at": now,
157
+ "artifacts": [],
158
+ "error": None,
159
+ }
160
+ _jobs[job_id] = record
161
+
162
+ # Persist initial state
163
+ _persist_job(job_id, record)
164
+
165
+ # Submit to thread pool — returns immediately
166
+ executor = get_executor()
167
+ executor.submit(_run_job, job_id)
168
+
169
+ return job_id
170
+
171
+
172
+ def _check_git_available() -> bool:
173
+ """Return True if git is available on PATH."""
174
+ import shutil
175
+ return shutil.which("git") is not None
176
+
177
+
178
+ def _setup_worktree(job_id: str) -> str | None:
179
+ """Attempt to create a git worktree for job isolation.
180
+
181
+ Returns worktree path on success, None on failure.
182
+ """
183
+ if not _check_git_available():
184
+ return None
185
+
186
+ import subprocess
187
+
188
+ project_dir = _get_project_dir()
189
+ worktree_dir = os.path.join(project_dir, ".omg", "worktrees", job_id)
190
+
191
+ try:
192
+ os.makedirs(os.path.dirname(worktree_dir), exist_ok=True)
193
+ subprocess.run(
194
+ ["git", "worktree", "add", "--detach", worktree_dir],
195
+ capture_output=True,
196
+ text=True,
197
+ check=True,
198
+ timeout=30,
199
+ cwd=project_dir,
200
+ )
201
+ return worktree_dir
202
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
203
+ return None
204
+
205
+
206
+ def _cleanup_worktree(worktree_dir: str) -> None:
207
+ """Remove a git worktree (best-effort)."""
208
+ import subprocess
209
+ import shutil
210
+
211
+ project_dir = _get_project_dir()
212
+ try:
213
+ subprocess.run(
214
+ ["git", "worktree", "remove", "--force", worktree_dir],
215
+ capture_output=True,
216
+ text=True,
217
+ check=False,
218
+ timeout=15,
219
+ cwd=project_dir,
220
+ )
221
+ except (subprocess.TimeoutExpired, OSError):
222
+ pass
223
+
224
+ # Fallback: remove directory if still exists
225
+ try:
226
+ if os.path.isdir(worktree_dir):
227
+ shutil.rmtree(worktree_dir, ignore_errors=True)
228
+ except OSError:
229
+ pass
230
+
231
+
232
+ def _run_job(job_id: str) -> None:
233
+ """Execute a subagent job in the thread pool.
234
+
235
+ Updates job status and persists artifacts as they are produced.
236
+ NOTE: Does NOT actually spawn Claude — simulates execution for now.
237
+ """
238
+ with _lock:
239
+ record = _jobs.get(job_id)
240
+ if record is None:
241
+ return
242
+ if record["status"] == "cancelled":
243
+ return
244
+ record["status"] = "running"
245
+ record["started_at"] = datetime.now(timezone.utc).isoformat()
246
+
247
+ _persist_job(job_id, record)
248
+
249
+ worktree_dir: str | None = None
250
+ try:
251
+ # Setup isolation if requested
252
+ if record.get("isolation") == "worktree":
253
+ worktree_dir = _setup_worktree(job_id)
254
+ if worktree_dir:
255
+ with _lock:
256
+ record["worktree"] = worktree_dir
257
+ _persist_job(job_id, record)
258
+
259
+ # --- Simulated execution ---
260
+ # Real implementation would dispatch to an agent here.
261
+ # For now, simulate work + produce an artifact.
262
+ time.sleep(0.05) # Simulate brief work
263
+
264
+ artifact = {
265
+ "type": "result",
266
+ "agent": record["agent_name"],
267
+ "content": f"Simulated output for: {record['task_text'][:100]}",
268
+ "produced_at": datetime.now(timezone.utc).isoformat(),
269
+ }
270
+
271
+ with _lock:
272
+ # Check for cancellation mid-execution
273
+ if record["status"] == "cancelled":
274
+ return
275
+ record["artifacts"].append(artifact)
276
+ record["status"] = "completed"
277
+ record["completed_at"] = datetime.now(timezone.utc).isoformat()
278
+
279
+ _persist_job(job_id, record)
280
+
281
+ except Exception as exc:
282
+ with _lock:
283
+ record["status"] = "failed"
284
+ record["error"] = str(exc)
285
+ record["completed_at"] = datetime.now(timezone.utc).isoformat()
286
+ _persist_job(job_id, record)
287
+
288
+ finally:
289
+ # Cleanup worktree if created
290
+ if worktree_dir:
291
+ _cleanup_worktree(worktree_dir)
292
+
293
+
294
+ def get_job_status(job_id: str) -> dict[str, Any]:
295
+ """Get the current status and artifacts for a job.
296
+
297
+ Args:
298
+ job_id: Job identifier returned by submit_job().
299
+
300
+ Returns:
301
+ Job record dict, or {"error": "not found"} if job doesn't exist.
302
+ """
303
+ with _lock:
304
+ record = _jobs.get(job_id)
305
+ if record is not None:
306
+ return dict(record)
307
+
308
+ # Not in memory — try loading from disk
309
+ disk_record = _load_job_from_disk(job_id)
310
+ if disk_record is not None:
311
+ return disk_record
312
+
313
+ return {"error": "not found"}
314
+
315
+
316
+ def cancel_job(job_id: str) -> bool:
317
+ """Cancel a queued or running job.
318
+
319
+ Args:
320
+ job_id: Job identifier to cancel.
321
+
322
+ Returns:
323
+ True if the job was cancelled, False if not found or already terminal.
324
+ """
325
+ with _lock:
326
+ record = _jobs.get(job_id)
327
+ if record is None:
328
+ return False
329
+ if record["status"] in ("completed", "failed", "cancelled"):
330
+ return False
331
+ record["status"] = "cancelled"
332
+ record["completed_at"] = datetime.now(timezone.utc).isoformat()
333
+
334
+ _persist_job(job_id, record)
335
+ return True
336
+
337
+
338
+ def list_jobs(status_filter: str | None = None) -> list[dict[str, Any]]:
339
+ """List all jobs, optionally filtered by status.
340
+
341
+ Args:
342
+ status_filter: If provided, only return jobs matching this status.
343
+
344
+ Returns:
345
+ List of job record dicts.
346
+ """
347
+ with _lock:
348
+ if status_filter is None:
349
+ return [dict(j) for j in _jobs.values()]
350
+ return [dict(j) for j in _jobs.values() if j["status"] == status_filter]
351
+
352
+
353
+ def shutdown(wait: bool = True) -> None:
354
+ """Shut down the executor gracefully.
355
+
356
+ Args:
357
+ wait: If True, wait for running jobs to complete before returning.
358
+ """
359
+ global _executor
360
+ if _executor is not None:
361
+ _executor.shutdown(wait=wait)
362
+ _executor = None