@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,736 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Session State Snapshot System for OMG
4
+
5
+ Captures `.omg/state/` directory, compresses it, versions snapshots,
6
+ and stores them in `.omg/state/snapshots/`.
7
+
8
+ Feature flag: OMG_SNAPSHOT_ENABLED (default: False)
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ import tarfile
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ # Lazy import from hooks
20
+ def _get_feature_flag_enabled() -> bool:
21
+ """Check if snapshot feature is enabled."""
22
+ env_val = os.environ.get("OMG_SNAPSHOT_ENABLED", "").lower()
23
+ if env_val in ("0", "false", "no"):
24
+ return False
25
+ if env_val in ("1", "true", "yes"):
26
+ return True
27
+
28
+ # Lazy import from hooks
29
+ hooks_dir = os.path.normpath(
30
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks")
31
+ )
32
+ if hooks_dir not in sys.path:
33
+ sys.path.insert(0, hooks_dir)
34
+ try:
35
+ from _common import get_feature_flag # type: ignore[import-untyped]
36
+ return get_feature_flag("SNAPSHOT", default=False)
37
+ except ImportError:
38
+ return False
39
+
40
+
41
+ def _get_atomic_json_write():
42
+ """Lazy-import atomic_json_write from hooks/_common.py."""
43
+ hooks_dir = os.path.normpath(
44
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks")
45
+ )
46
+ if hooks_dir not in sys.path:
47
+ sys.path.insert(0, hooks_dir)
48
+ try:
49
+ from _common import atomic_json_write # type: ignore[import-untyped]
50
+ return atomic_json_write
51
+ except ImportError:
52
+ return None
53
+
54
+
55
+ def create_snapshot(name: Optional[str] = None, state_dir: str = ".omg/state") -> Dict[str, Any]:
56
+ """
57
+ Capture `.omg/state/` directory and create a compressed snapshot.
58
+
59
+ Args:
60
+ name: Optional name suffix for the snapshot
61
+ state_dir: Path to the state directory (default: ".omg/state")
62
+
63
+ Returns:
64
+ Snapshot metadata dict with keys: id, name, created_at, files_count, compressed_size, state_dir
65
+ or {"skipped": True} if feature flag is disabled
66
+ """
67
+ if not _get_feature_flag_enabled():
68
+ return {"skipped": True}
69
+
70
+ # Generate snapshot ID
71
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
72
+ snapshot_id = f"{timestamp}_{name}" if name else timestamp
73
+
74
+ # Ensure snapshots directory exists
75
+ snapshots_dir = os.path.join(state_dir, "snapshots")
76
+ os.makedirs(snapshots_dir, exist_ok=True)
77
+
78
+ # Paths for snapshot files
79
+ snapshot_tar_path = os.path.join(snapshots_dir, f"{snapshot_id}.tar.gz")
80
+ snapshot_meta_path = os.path.join(snapshots_dir, f"{snapshot_id}.json")
81
+
82
+ # Files to exclude
83
+ exclude_patterns = {
84
+ "snapshots", # Don't snapshot the snapshots directory itself
85
+ "credentials.enc",
86
+ "credentials.meta",
87
+ }
88
+
89
+ # Create tar.gz archive
90
+ files_count = 0
91
+ try:
92
+ with tarfile.open(snapshot_tar_path, "w:gz") as tar:
93
+ for root, dirs, files in os.walk(state_dir):
94
+ # Filter out excluded directories
95
+ dirs[:] = [d for d in dirs if d not in exclude_patterns]
96
+
97
+ for file in files:
98
+ file_path = os.path.join(root, file)
99
+ # Skip excluded files
100
+ if file in exclude_patterns:
101
+ continue
102
+
103
+ # Calculate arcname (relative path in archive)
104
+ arcname = os.path.relpath(file_path, state_dir)
105
+ tar.add(file_path, arcname=arcname)
106
+ files_count += 1
107
+
108
+ except Exception as e:
109
+ print(f"[OMG] Error creating snapshot: {e}", file=sys.stderr)
110
+ return {"error": str(e)}
111
+
112
+ # Get compressed size
113
+ compressed_size = os.path.getsize(snapshot_tar_path) if os.path.exists(snapshot_tar_path) else 0
114
+
115
+ # Create metadata
116
+ metadata = {
117
+ "id": snapshot_id,
118
+ "name": name or "",
119
+ "created_at": datetime.now().isoformat(),
120
+ "files_count": files_count,
121
+ "compressed_size": compressed_size,
122
+ "state_dir": state_dir,
123
+ }
124
+
125
+ # Write metadata atomically
126
+ atomic_json_write = _get_atomic_json_write()
127
+ if atomic_json_write:
128
+ atomic_json_write(snapshot_meta_path, metadata)
129
+ else:
130
+ # Fallback: write without atomic guarantee
131
+ try:
132
+ with open(snapshot_meta_path, "w", encoding="utf-8") as f:
133
+ json.dump(metadata, f, separators=(",", ":"))
134
+ except Exception as e:
135
+ print(f"[OMG] Error writing metadata: {e}", file=sys.stderr)
136
+
137
+ return metadata
138
+
139
+
140
+ def list_snapshots(state_dir: str = ".omg/state") -> List[Dict[str, Any]]:
141
+ """
142
+ List all available snapshots.
143
+
144
+ Args:
145
+ state_dir: Path to the state directory (default: ".omg/state")
146
+
147
+ Returns:
148
+ List of snapshot metadata dicts, sorted by created_at descending (newest first)
149
+ """
150
+ snapshots_dir = os.path.join(state_dir, "snapshots")
151
+ if not os.path.isdir(snapshots_dir):
152
+ return []
153
+
154
+ snapshots = []
155
+ try:
156
+ for file in os.listdir(snapshots_dir):
157
+ if file.endswith(".json"):
158
+ meta_path = os.path.join(snapshots_dir, file)
159
+ try:
160
+ with open(meta_path, "r", encoding="utf-8") as f:
161
+ metadata = json.load(f)
162
+ snapshots.append(metadata)
163
+ except (json.JSONDecodeError, OSError):
164
+ pass # Skip invalid metadata files
165
+ except OSError:
166
+ pass
167
+
168
+ # Sort by created_at descending (newest first)
169
+ snapshots.sort(key=lambda x: x.get("created_at", ""), reverse=True)
170
+ return snapshots
171
+
172
+
173
+ def restore_snapshot(snapshot_id: str, state_dir: str = ".omg/state") -> bool:
174
+ """
175
+ Restore a snapshot to the state directory.
176
+
177
+ Args:
178
+ snapshot_id: ID of the snapshot to restore
179
+ state_dir: Path to the state directory (default: ".omg/state")
180
+
181
+ Returns:
182
+ True if restored successfully, False if snapshot not found
183
+ """
184
+ snapshots_dir = os.path.join(state_dir, "snapshots")
185
+ snapshot_tar_path = os.path.join(snapshots_dir, f"{snapshot_id}.tar.gz")
186
+
187
+ if not os.path.exists(snapshot_tar_path):
188
+ return False
189
+
190
+ try:
191
+ with tarfile.open(snapshot_tar_path, "r:gz") as tar:
192
+ # Use filter='data' for Python 3.14+ compatibility
193
+ try:
194
+ tar.extractall(path=state_dir, filter='data')
195
+ except TypeError:
196
+ # Fallback for Python < 3.12
197
+ tar.extractall(path=state_dir)
198
+ return True
199
+ except Exception as e:
200
+ print(f"[OMG] Error restoring snapshot: {e}", file=sys.stderr)
201
+ return False
202
+
203
+
204
+ def delete_snapshot(snapshot_id: str, state_dir: str = ".omg/state") -> bool:
205
+ """
206
+ Delete a snapshot.
207
+
208
+ Args:
209
+ snapshot_id: ID of the snapshot to delete
210
+ state_dir: Path to the state directory (default: ".omg/state")
211
+
212
+ Returns:
213
+ True if deleted successfully, False if snapshot not found
214
+ """
215
+ snapshots_dir = os.path.join(state_dir, "snapshots")
216
+ snapshot_tar_path = os.path.join(snapshots_dir, f"{snapshot_id}.tar.gz")
217
+ snapshot_meta_path = os.path.join(snapshots_dir, f"{snapshot_id}.json")
218
+
219
+ deleted = False
220
+ try:
221
+ if os.path.exists(snapshot_tar_path):
222
+ os.remove(snapshot_tar_path)
223
+ deleted = True
224
+ except OSError as e:
225
+ print(f"[OMG] Error deleting snapshot tar: {e}", file=sys.stderr)
226
+
227
+ try:
228
+ if os.path.exists(snapshot_meta_path):
229
+ os.remove(snapshot_meta_path)
230
+ deleted = True
231
+ except OSError as e:
232
+ print(f"[OMG] Error deleting snapshot metadata: {e}", file=sys.stderr)
233
+
234
+ return deleted
235
+
236
+
237
+ # --- Branch / Fork API ---
238
+
239
+
240
+ def _get_branching_flag_enabled() -> bool:
241
+ """Check if branching feature is enabled."""
242
+ env_val = os.environ.get("OMG_BRANCHING_ENABLED", "").lower()
243
+ if env_val in ("0", "false", "no"):
244
+ return False
245
+ if env_val in ("1", "true", "yes"):
246
+ return True
247
+
248
+ # Lazy import from hooks
249
+ hooks_dir = os.path.normpath(
250
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks")
251
+ )
252
+ if hooks_dir not in sys.path:
253
+ sys.path.insert(0, hooks_dir)
254
+ try:
255
+ from _common import get_feature_flag # type: ignore[import-untyped]
256
+ return get_feature_flag("BRANCHING", default=False)
257
+ except ImportError:
258
+ return False
259
+
260
+
261
+ def create_branch(
262
+ name: str,
263
+ from_snapshot_id: Optional[str] = None,
264
+ state_dir: str = ".omg/state",
265
+ ) -> Dict[str, Any]:
266
+ """
267
+ Create a named branch from a snapshot or the current state.
268
+
269
+ Args:
270
+ name: Branch name (must be non-empty, no slashes)
271
+ from_snapshot_id: Optional snapshot ID to branch from.
272
+ If provided, restores that snapshot first.
273
+ Otherwise, creates a new snapshot automatically.
274
+ state_dir: Path to the state directory (default: ".omg/state")
275
+
276
+ Returns:
277
+ Branch metadata dict with keys: name, snapshot_id, created_at,
278
+ parent_branch, status.
279
+ Or {"skipped": True} if feature flag is disabled.
280
+ """
281
+ if not _get_branching_flag_enabled():
282
+ return {"skipped": True}
283
+
284
+ if not name or "/" in name:
285
+ return {"error": "Invalid branch name: must be non-empty with no slashes"}
286
+
287
+ # Resolve source snapshot
288
+ if from_snapshot_id:
289
+ # Verify snapshot exists before restoring
290
+ snapshots_dir = os.path.join(state_dir, "snapshots")
291
+ tar_path = os.path.join(snapshots_dir, f"{from_snapshot_id}.tar.gz")
292
+ if not os.path.exists(tar_path):
293
+ return {"error": f"Snapshot not found: {from_snapshot_id}"}
294
+ restore_snapshot(from_snapshot_id, state_dir=state_dir)
295
+ snapshot_id = from_snapshot_id
296
+ else:
297
+ # Create a fresh snapshot for this branch
298
+ snap = create_snapshot(name=name, state_dir=state_dir)
299
+ if snap.get("error") or snap.get("skipped"):
300
+ return snap
301
+ snapshot_id = snap["id"]
302
+
303
+ # Read current branch (parent)
304
+ current_branch_path = os.path.join(state_dir, "current_branch.json")
305
+ parent_branch: Optional[str] = None
306
+ if os.path.exists(current_branch_path):
307
+ try:
308
+ with open(current_branch_path, "r", encoding="utf-8") as f:
309
+ cb = json.load(f)
310
+ parent_branch = cb.get("name")
311
+ except (json.JSONDecodeError, OSError):
312
+ pass
313
+
314
+ # Build branch metadata
315
+ metadata: Dict[str, Any] = {
316
+ "name": name,
317
+ "snapshot_id": snapshot_id,
318
+ "created_at": datetime.now().isoformat(),
319
+ "parent_branch": parent_branch,
320
+ "status": "active",
321
+ }
322
+
323
+ # Write branch metadata
324
+ branches_dir = os.path.join(state_dir, "branches")
325
+ os.makedirs(branches_dir, exist_ok=True)
326
+ branch_path = os.path.join(branches_dir, f"{name}.json")
327
+
328
+ atomic_json_write = _get_atomic_json_write()
329
+ if atomic_json_write:
330
+ atomic_json_write(branch_path, metadata)
331
+ else:
332
+ try:
333
+ with open(branch_path, "w", encoding="utf-8") as f:
334
+ json.dump(metadata, f, separators=(",", ":"))
335
+ except Exception as e:
336
+ print(f"[OMG] Error writing branch metadata: {e}", file=sys.stderr)
337
+ return {"error": str(e)}
338
+
339
+ # Update current branch tracker
340
+ _update_current_branch(name, state_dir=state_dir)
341
+
342
+ return metadata
343
+
344
+
345
+ def list_branches(state_dir: str = ".omg/state") -> List[Dict[str, Any]]:
346
+ """
347
+ List all branches with metadata.
348
+
349
+ Args:
350
+ state_dir: Path to the state directory (default: ".omg/state")
351
+
352
+ Returns:
353
+ List of branch metadata dicts, sorted by created_at descending (newest first)
354
+ """
355
+ branches_dir = os.path.join(state_dir, "branches")
356
+ if not os.path.isdir(branches_dir):
357
+ return []
358
+
359
+ branches: List[Dict[str, Any]] = []
360
+ try:
361
+ for file in os.listdir(branches_dir):
362
+ if file.endswith(".json"):
363
+ branch_path = os.path.join(branches_dir, file)
364
+ try:
365
+ with open(branch_path, "r", encoding="utf-8") as f:
366
+ metadata = json.load(f)
367
+ branches.append(metadata)
368
+ except (json.JSONDecodeError, OSError):
369
+ pass # Skip invalid metadata files
370
+ except OSError:
371
+ pass
372
+
373
+ # Sort by created_at descending (newest first)
374
+ branches.sort(key=lambda x: x.get("created_at", ""), reverse=True)
375
+ return branches
376
+
377
+
378
+ def switch_branch(name: str, state_dir: str = ".omg/state") -> bool:
379
+ """
380
+ Switch to a named branch by restoring its snapshot.
381
+
382
+ Args:
383
+ name: Branch name to switch to
384
+ state_dir: Path to the state directory (default: ".omg/state")
385
+
386
+ Returns:
387
+ True if switched successfully, False otherwise
388
+ """
389
+ branches_dir = os.path.join(state_dir, "branches")
390
+ branch_path = os.path.join(branches_dir, f"{name}.json")
391
+
392
+ if not os.path.exists(branch_path):
393
+ return False
394
+
395
+ try:
396
+ with open(branch_path, "r", encoding="utf-8") as f:
397
+ branch_meta = json.load(f)
398
+ except (json.JSONDecodeError, OSError):
399
+ return False
400
+
401
+ snapshot_id = branch_meta.get("snapshot_id")
402
+ if not snapshot_id:
403
+ return False
404
+
405
+ if not restore_snapshot(snapshot_id, state_dir=state_dir):
406
+ return False
407
+
408
+ _update_current_branch(name, state_dir=state_dir)
409
+ return True
410
+
411
+
412
+ def _update_current_branch(name: str, state_dir: str = ".omg/state") -> None:
413
+ """Update the current branch tracker file."""
414
+ current_branch_path = os.path.join(state_dir, "current_branch.json")
415
+ data = {"name": name, "switched_at": datetime.now().isoformat()}
416
+ atomic_json_write = _get_atomic_json_write()
417
+ if atomic_json_write:
418
+ atomic_json_write(current_branch_path, data)
419
+ else:
420
+ try:
421
+ with open(current_branch_path, "w", encoding="utf-8") as f:
422
+ json.dump(data, f, separators=(",", ":"))
423
+ except Exception as e:
424
+ print(f"[OMG] Error updating current branch: {e}", file=sys.stderr)
425
+
426
+
427
+ # --- Merge API ---
428
+
429
+
430
+ def _get_merge_flag_enabled() -> bool:
431
+ """Check if merge feature is enabled."""
432
+ env_val = os.environ.get("OMG_MERGE_ENABLED", "").lower()
433
+ if env_val in ("0", "false", "no"):
434
+ return False
435
+ if env_val in ("1", "true", "yes"):
436
+ return True
437
+
438
+ # Lazy import from hooks
439
+ hooks_dir = os.path.normpath(
440
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks")
441
+ )
442
+ if hooks_dir not in sys.path:
443
+ sys.path.insert(0, hooks_dir)
444
+ try:
445
+ from _common import get_feature_flag # type: ignore[import-untyped]
446
+ return get_feature_flag("MERGE", default=False)
447
+ except ImportError:
448
+ return False
449
+
450
+
451
+ def _load_branch_state(branch_name: str, state_dir: str = ".omg/state") -> Optional[Dict[str, Any]]:
452
+ """Load a branch's metadata as a flat state dict.
453
+
454
+ Args:
455
+ branch_name: Name of the branch to load
456
+ state_dir: Path to the state directory
457
+
458
+ Returns:
459
+ Branch metadata dict, or None if branch does not exist or is invalid.
460
+ """
461
+ branch_path = os.path.join(state_dir, "branches", f"{branch_name}.json")
462
+ if not os.path.exists(branch_path):
463
+ return None
464
+ try:
465
+ with open(branch_path, "r", encoding="utf-8") as f:
466
+ return json.load(f)
467
+ except (json.JSONDecodeError, OSError):
468
+ return None
469
+
470
+
471
+ def detect_merge_conflicts(
472
+ source_state: Dict[str, Any], target_state: Dict[str, Any]
473
+ ) -> List[Dict[str, Any]]:
474
+ """Compare two state dicts and find keys where both sides have different values.
475
+
476
+ Args:
477
+ source_state: State dict from the source branch
478
+ target_state: State dict from the target branch
479
+
480
+ Returns:
481
+ List of conflict dicts with keys: key, source_value, target_value, conflict_type.
482
+ conflict_type is "value_conflict" when both sides changed the same key
483
+ to different values.
484
+ """
485
+ conflicts: List[Dict[str, Any]] = []
486
+ # Find keys present in both dicts with different values
487
+ common_keys = set(source_state.keys()) & set(target_state.keys())
488
+ for key in sorted(common_keys):
489
+ source_val = source_state[key]
490
+ target_val = target_state[key]
491
+ if source_val != target_val:
492
+ conflicts.append({
493
+ "key": key,
494
+ "source_value": source_val,
495
+ "target_value": target_val,
496
+ "conflict_type": "value_conflict",
497
+ })
498
+ return conflicts
499
+
500
+
501
+ def preview_merge(
502
+ source_branch: str,
503
+ target_branch: str = "main",
504
+ state_dir: str = ".omg/state",
505
+ ) -> Dict[str, Any]:
506
+ """Preview a merge without applying changes.
507
+
508
+ Loads both branch snapshot states (as flat JSON dicts from snapshot
509
+ metadata) and detects conflicts.
510
+
511
+ Args:
512
+ source_branch: Branch to merge from
513
+ target_branch: Branch to merge into (default: "main")
514
+ state_dir: Path to the state directory
515
+
516
+ Returns:
517
+ Preview dict with keys: source, target, conflicts, changes, preview.
518
+ Or {"skipped": True} if feature flag is disabled.
519
+ Or {"error": ...} if a branch cannot be found.
520
+ """
521
+ if not _get_merge_flag_enabled():
522
+ return {"skipped": True}
523
+
524
+ source_state = _load_branch_state(source_branch, state_dir=state_dir)
525
+ if source_state is None:
526
+ return {"error": f"Source branch not found: {source_branch}"}
527
+
528
+ target_state = _load_branch_state(target_branch, state_dir=state_dir)
529
+ if target_state is None:
530
+ return {"error": f"Target branch not found: {target_branch}"}
531
+
532
+ conflicts = detect_merge_conflicts(source_state, target_state)
533
+
534
+ # Count keys that exist only in source (net new changes)
535
+ source_only_keys = set(source_state.keys()) - set(target_state.keys())
536
+ changes = len(source_only_keys) + len(conflicts)
537
+
538
+ return {
539
+ "source": source_branch,
540
+ "target": target_branch,
541
+ "conflicts": conflicts,
542
+ "changes": changes,
543
+ "preview": True,
544
+ }
545
+
546
+
547
+ def merge_branch(
548
+ source_branch: str,
549
+ target_branch: str = "main",
550
+ state_dir: str = ".omg/state",
551
+ ) -> Dict[str, Any]:
552
+ """Merge source branch state into target branch.
553
+
554
+ Uses last-write-wins strategy when there are no conflicts.
555
+ If conflicts exist, the merge is aborted and conflicts are returned.
556
+
557
+ Args:
558
+ source_branch: Branch to merge from
559
+ target_branch: Branch to merge into (default: "main")
560
+ state_dir: Path to the state directory
561
+
562
+ Returns:
563
+ Result dict with keys: merged, conflicts, changes_applied.
564
+ Or {"skipped": True} if feature flag is disabled.
565
+ Or {"error": ...} on failure.
566
+ """
567
+ if not _get_merge_flag_enabled():
568
+ return {"skipped": True}
569
+
570
+ preview = preview_merge(source_branch, target_branch, state_dir=state_dir)
571
+ if preview.get("error"):
572
+ return preview
573
+
574
+ conflicts = preview.get("conflicts", [])
575
+ if conflicts:
576
+ return {
577
+ "merged": False,
578
+ "conflicts": conflicts,
579
+ "changes_applied": 0,
580
+ }
581
+
582
+ # --- Apply merge: last-write-wins (source on top of target) ---
583
+ source_state = _load_branch_state(source_branch, state_dir=state_dir)
584
+ target_state = _load_branch_state(target_branch, state_dir=state_dir)
585
+ if source_state is None or target_state is None:
586
+ return {"error": "Branch state became unavailable during merge"}
587
+
588
+ merged_state = {**target_state, **source_state}
589
+ # Preserve target branch name and update status
590
+ merged_state["name"] = target_branch
591
+ merged_state["status"] = "active"
592
+
593
+ # Count actual changes applied
594
+ source_only_keys = set(source_state.keys()) - set(target_state.keys())
595
+ changes_applied = len(source_only_keys)
596
+
597
+ # Write merged state to target branch file
598
+ target_branch_path = os.path.join(state_dir, "branches", f"{target_branch}.json")
599
+ atomic_json_write = _get_atomic_json_write()
600
+ if atomic_json_write:
601
+ atomic_json_write(target_branch_path, merged_state)
602
+ else:
603
+ try:
604
+ os.makedirs(os.path.dirname(target_branch_path), exist_ok=True)
605
+ with open(target_branch_path, "w", encoding="utf-8") as f:
606
+ json.dump(merged_state, f, separators=(",", ":"))
607
+ except Exception as e:
608
+ return {"error": f"Failed to write merged state: {e}"}
609
+
610
+ # Mark source branch as merged
611
+ source_branch_path = os.path.join(state_dir, "branches", f"{source_branch}.json")
612
+ if source_state:
613
+ source_state["status"] = "merged"
614
+ source_state["merged_into"] = target_branch
615
+ source_state["merged_at"] = datetime.now().isoformat()
616
+ if atomic_json_write:
617
+ atomic_json_write(source_branch_path, source_state)
618
+ else:
619
+ try:
620
+ with open(source_branch_path, "w", encoding="utf-8") as f:
621
+ json.dump(source_state, f, separators=(",", ":"))
622
+ except Exception as e:
623
+ print(f"[OMG] Error updating source branch status: {e}", file=sys.stderr)
624
+
625
+ # Update current_branch.json to reflect merged state
626
+ _update_current_branch(target_branch, state_dir=state_dir)
627
+
628
+ return {
629
+ "merged": True,
630
+ "conflicts": [],
631
+ "changes_applied": changes_applied,
632
+ }
633
+
634
+ def main():
635
+ """CLI entry point."""
636
+ if len(sys.argv) < 2:
637
+ print(
638
+ "Usage: python3 session_snapshot.py <command> [options]",
639
+ file=sys.stderr,
640
+ )
641
+ print("Commands:", file=sys.stderr)
642
+ print(" create [--name NAME] Create a snapshot", file=sys.stderr)
643
+ print(" list List all snapshots", file=sys.stderr)
644
+ print(" restore <snapshot_id> Restore a snapshot", file=sys.stderr)
645
+ print(" delete <snapshot_id> Delete a snapshot", file=sys.stderr)
646
+ print(" branch <name> Create a branch", file=sys.stderr)
647
+ print(" branches List all branches", file=sys.stderr)
648
+ print(" switch <name> Switch to a branch", file=sys.stderr)
649
+ print(" merge <source> [--into <target>] Merge branches", file=sys.stderr)
650
+ print(" merge-preview <source> [--into <target>] Preview merge", file=sys.stderr)
651
+ sys.exit(1)
652
+
653
+ command = sys.argv[1]
654
+
655
+ if command == "create":
656
+ name = None
657
+ if len(sys.argv) > 3 and sys.argv[2] == "--name":
658
+ name = sys.argv[3]
659
+ result = create_snapshot(name=name)
660
+ print(json.dumps(result, indent=2))
661
+
662
+ elif command == "list":
663
+ snapshots = list_snapshots()
664
+ print(json.dumps(snapshots, indent=2))
665
+
666
+ elif command == "restore":
667
+ if len(sys.argv) < 3:
668
+ print("Usage: python3 session_snapshot.py restore <snapshot_id>", file=sys.stderr)
669
+ sys.exit(1)
670
+ snapshot_id = sys.argv[2]
671
+ success = restore_snapshot(snapshot_id)
672
+ result = {"success": success, "snapshot_id": snapshot_id}
673
+ print(json.dumps(result, indent=2))
674
+
675
+ elif command == "delete":
676
+ if len(sys.argv) < 3:
677
+ print("Usage: python3 session_snapshot.py delete <snapshot_id>", file=sys.stderr)
678
+ sys.exit(1)
679
+ snapshot_id = sys.argv[2]
680
+ success = delete_snapshot(snapshot_id)
681
+ result = {"success": success, "snapshot_id": snapshot_id}
682
+ print(json.dumps(result, indent=2))
683
+
684
+ elif command == "branch":
685
+ if len(sys.argv) < 3:
686
+ print("Usage: python3 session_snapshot.py branch <name> [--from <snapshot_id>]", file=sys.stderr)
687
+ sys.exit(1)
688
+ branch_name = sys.argv[2]
689
+ from_id = None
690
+ if len(sys.argv) > 4 and sys.argv[3] == "--from":
691
+ from_id = sys.argv[4]
692
+ result = create_branch(branch_name, from_snapshot_id=from_id)
693
+ print(json.dumps(result, indent=2))
694
+
695
+ elif command == "branches":
696
+ branches = list_branches()
697
+ print(json.dumps(branches, indent=2))
698
+
699
+ elif command == "switch":
700
+ if len(sys.argv) < 3:
701
+ print("Usage: python3 session_snapshot.py switch <name>", file=sys.stderr)
702
+ sys.exit(1)
703
+ branch_name = sys.argv[2]
704
+ success = switch_branch(branch_name)
705
+ result = {"success": success, "branch": branch_name}
706
+ print(json.dumps(result, indent=2))
707
+
708
+ elif command == "merge":
709
+ if len(sys.argv) < 3:
710
+ print("Usage: python3 session_snapshot.py merge <source> [--into <target>]", file=sys.stderr)
711
+ sys.exit(1)
712
+ source = sys.argv[2]
713
+ target = "main"
714
+ if len(sys.argv) > 4 and sys.argv[3] == "--into":
715
+ target = sys.argv[4]
716
+ result = merge_branch(source, target_branch=target)
717
+ print(json.dumps(result, indent=2))
718
+
719
+ elif command == "merge-preview":
720
+ if len(sys.argv) < 3:
721
+ print("Usage: python3 session_snapshot.py merge-preview <source> [--into <target>]", file=sys.stderr)
722
+ sys.exit(1)
723
+ source = sys.argv[2]
724
+ target = "main"
725
+ if len(sys.argv) > 4 and sys.argv[3] == "--into":
726
+ target = sys.argv[4]
727
+ result = preview_merge(source, target_branch=target)
728
+ print(json.dumps(result, indent=2))
729
+
730
+ else:
731
+ print(f"Unknown command: {command}", file=sys.stderr)
732
+ sys.exit(1)
733
+
734
+
735
+ if __name__ == "__main__":
736
+ main()