@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,746 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ AI Commit Splitter for OMG
4
+
5
+ Analyzes git changes and groups them into logical atomic commits
6
+ with hunk-level staging support. Read-only analysis — never runs git commit.
7
+
8
+ Feature flag: OMG_AI_COMMIT_ENABLED (default: False)
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ # Lazy imports for git_inspector and feature flags
20
+ _git_inspector = None
21
+ _get_feature_flag = None
22
+
23
+
24
+ def _ensure_imports():
25
+ """Lazy import git_inspector and feature flag helper."""
26
+ global _git_inspector, _get_feature_flag
27
+ if _git_inspector is None:
28
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
29
+ from tools import git_inspector as _gi
30
+ from hooks._common import get_feature_flag as _gff
31
+ _git_inspector = _gi
32
+ _get_feature_flag = _gff
33
+
34
+
35
+ def _is_enabled() -> bool:
36
+ """Check if AI commit splitter feature is enabled."""
37
+ _ensure_imports()
38
+ if _get_feature_flag is None:
39
+ return False
40
+ return bool(_get_feature_flag("GIT_WORKFLOW", default=False))
41
+
42
+
43
+ # --- File Classification ---
44
+
45
+ # Extension → category mapping
46
+ _EXT_CATEGORY = {
47
+ # Python
48
+ ".py": "python",
49
+ ".pyi": "python",
50
+ ".pyx": "python",
51
+ # JavaScript/TypeScript
52
+ ".js": "javascript",
53
+ ".jsx": "javascript",
54
+ ".ts": "javascript",
55
+ ".tsx": "javascript",
56
+ ".mjs": "javascript",
57
+ ".cjs": "javascript",
58
+ # Config
59
+ ".json": "config",
60
+ ".yaml": "config",
61
+ ".yml": "config",
62
+ ".toml": "config",
63
+ ".ini": "config",
64
+ ".cfg": "config",
65
+ ".env": "config",
66
+ ".conf": "config",
67
+ ".properties": "config",
68
+ # Docs
69
+ ".md": "docs",
70
+ ".rst": "docs",
71
+ ".txt": "docs",
72
+ ".adoc": "docs",
73
+ # Shell
74
+ ".sh": "shell",
75
+ ".bash": "shell",
76
+ ".zsh": "shell",
77
+ # CSS/Styles
78
+ ".css": "styles",
79
+ ".scss": "styles",
80
+ ".less": "styles",
81
+ ".sass": "styles",
82
+ # HTML/Templates
83
+ ".html": "markup",
84
+ ".htm": "markup",
85
+ ".xml": "markup",
86
+ ".svg": "markup",
87
+ }
88
+
89
+ # Test path indicators
90
+ _TEST_INDICATORS = (
91
+ "test_",
92
+ "_test.",
93
+ "tests/",
94
+ "test/",
95
+ "__tests__/",
96
+ ".test.",
97
+ ".spec.",
98
+ "spec/",
99
+ "conftest.py",
100
+ )
101
+
102
+ # Category → default suggested commit type
103
+ _CATEGORY_DEFAULT_TYPE = {
104
+ "python": "feat",
105
+ "javascript": "feat",
106
+ "shell": "chore",
107
+ "config": "chore",
108
+ "docs": "docs",
109
+ "styles": "style",
110
+ "markup": "feat",
111
+ "tests": "test",
112
+ "other": "chore",
113
+ }
114
+
115
+ _QUALITY_STEPS = ["format", "lint", "typecheck", "test"]
116
+
117
+ _ALLOWED_PREFIXES = [
118
+ ("npm", "test"),
119
+ ("yarn", "test"),
120
+ ("pnpm", "test"),
121
+ ("bun", "test"),
122
+ ("npx", "--no-install", "prettier"),
123
+ ("npx", "--no-install", "eslint"),
124
+ ("npx", "--no-install", "tsc"),
125
+ ("npx", "--no-install", "jest"),
126
+ ("npx", "--no-install", "vitest"),
127
+ ("npx", "--no-install", "biome"),
128
+ ("jest",),
129
+ ("vitest",),
130
+ ("eslint",),
131
+ ("prettier",),
132
+ ("tsc",),
133
+ ("biome",),
134
+ ("pytest",),
135
+ ("python", "-m", "pytest"),
136
+ ("python3", "-m", "pytest"),
137
+ ("ruff",),
138
+ ("mypy",),
139
+ ("flake8",),
140
+ ("black",),
141
+ ("isort",),
142
+ ("bandit",),
143
+ ("pylint",),
144
+ ("go", "test"),
145
+ ("go", "vet"),
146
+ ("go", "build"),
147
+ ("golangci-lint",),
148
+ ("cargo", "test"),
149
+ ("cargo", "check"),
150
+ ("cargo", "build"),
151
+ ("cargo", "clippy"),
152
+ ("cargo", "fmt"),
153
+ ("shellcheck",),
154
+ ]
155
+
156
+ _BLOCKED_PATTERNS = [
157
+ "&&", "||", "|", ";", "`", "$(", "${", ">", "<", "\n",
158
+ "rm ", "curl ", "wget ", "eval ", "exec ", "sudo ",
159
+ ]
160
+
161
+ _CONVENTIONAL_COMMIT_RE = re.compile(r"^(feat|fix|chore|refactor|docs|test|ci|style|perf|build)(\([^)]+\))?: .+")
162
+
163
+
164
+ def _classify_file(file_path: str) -> str:
165
+ """Classify a file path into a category.
166
+
167
+ Test files are always classified as 'tests' regardless of extension.
168
+
169
+ Args:
170
+ file_path: Relative file path from git diff.
171
+
172
+ Returns:
173
+ Category string: 'python', 'javascript', 'config', 'docs',
174
+ 'tests', 'shell', 'styles', 'markup', or 'other'.
175
+ """
176
+ if file_path is None:
177
+ return "other"
178
+
179
+ lower_path = file_path.lower()
180
+
181
+ # Check for test files first — they always get their own group
182
+ for indicator in _TEST_INDICATORS:
183
+ if indicator in lower_path:
184
+ return "tests"
185
+
186
+ # Classify by extension
187
+ _, ext = os.path.splitext(lower_path)
188
+ return _EXT_CATEGORY.get(ext, "other")
189
+
190
+
191
+ def _derive_scope(files: List[str]) -> str:
192
+ """Derive a scope name from a list of files.
193
+
194
+ Uses the most common parent directory or module name.
195
+
196
+ Args:
197
+ files: List of file paths.
198
+
199
+ Returns:
200
+ Scope string suitable for conventional commit format.
201
+ """
202
+ if not files:
203
+ return "general"
204
+
205
+ # Collect directory components
206
+ dirs: List[str] = []
207
+ for f in files:
208
+ parts = f.replace("\\", "/").split("/")
209
+ if len(parts) > 1:
210
+ dirs.append(parts[0])
211
+ else:
212
+ # Single file at root — use filename without extension
213
+ name, _ = os.path.splitext(parts[0])
214
+ dirs.append(name)
215
+
216
+ if not dirs:
217
+ return "general"
218
+
219
+ # Most common directory
220
+ from collections import Counter
221
+ counts = Counter(dirs)
222
+ scope = counts.most_common(1)[0][0]
223
+ return scope
224
+
225
+
226
+ def _derive_scope_from_prefix(files: List[str], prefix_dirs: tuple[str, ...]) -> str:
227
+ """Derive scope from subdirectory after a type-matched prefix."""
228
+ from collections import Counter
229
+
230
+ sub_dirs: List[str] = []
231
+ for f in files:
232
+ norm = f.replace("\\", "/")
233
+ matched_prefix = ""
234
+ for prefix in prefix_dirs:
235
+ if norm.startswith(prefix):
236
+ matched_prefix = prefix
237
+ break
238
+
239
+ if matched_prefix:
240
+ remainder = norm[len(matched_prefix):]
241
+ parts = remainder.split("/")
242
+ if len(parts) > 1:
243
+ sub_dirs.append(parts[0])
244
+ else:
245
+ sub_dirs.append(matched_prefix.rstrip("/"))
246
+ else:
247
+ parts = norm.split("/")
248
+ if len(parts) > 1:
249
+ sub_dirs.append(parts[0])
250
+ else:
251
+ name, _ = os.path.splitext(parts[0])
252
+ sub_dirs.append(name)
253
+
254
+ if not sub_dirs:
255
+ return "general"
256
+
257
+ counts = Counter(sub_dirs)
258
+ return counts.most_common(1)[0][0]
259
+
260
+
261
+ # --- Commit type detection by path ---
262
+
263
+ # Prefix directories → commit type
264
+ _PATH_PREFIX_TYPE = {
265
+ "src/": "feat",
266
+ "lib/": "feat",
267
+ "app/": "feat",
268
+ "hooks/": "feat",
269
+ "tests/": "test",
270
+ "test/": "test",
271
+ "spec/": "test",
272
+ "docs/": "docs",
273
+ }
274
+
275
+ # Fix-related path keywords
276
+ _FIX_KEYWORDS = ("fix", "bug", "patch", "hotfix")
277
+
278
+ # Config file extensions (checked when no prefix matches)
279
+ _CONFIG_EXTENSIONS = (".json", ".yaml", ".yml", ".toml", ".cfg")
280
+
281
+ # Config file name patterns (basename startswith or exact match)
282
+ _CONFIG_BASENAMES = ("setup.", "makefile")
283
+
284
+
285
+ def _detect_commit_type(file_path: str) -> str:
286
+ """Detect commit type from file path. Priority: fix keywords → prefix → docs → config → chore."""
287
+ norm = file_path.replace("\\", "/").lower()
288
+
289
+ for keyword in _FIX_KEYWORDS:
290
+ if ("/" + keyword) in norm or norm.startswith(keyword):
291
+ return "fix"
292
+
293
+ for prefix, commit_type in _PATH_PREFIX_TYPE.items():
294
+ if norm.startswith(prefix):
295
+ return commit_type
296
+
297
+ basename = os.path.basename(norm)
298
+ if basename.startswith("readme") or basename == "changelog.md":
299
+ return "docs"
300
+ _, ext = os.path.splitext(norm)
301
+ if ext == ".md":
302
+ return "docs"
303
+
304
+ if ext in _CONFIG_EXTENSIONS:
305
+ return "chore"
306
+ for config_name in _CONFIG_BASENAMES:
307
+ if basename.startswith(config_name) or basename == config_name.rstrip("."):
308
+ return "chore"
309
+
310
+ return "chore"
311
+
312
+
313
+ def _detect_type_majority(files: List[str]) -> str:
314
+ """Detect commit type by majority vote across files."""
315
+ if not files:
316
+ return "chore"
317
+
318
+ from collections import Counter
319
+ types = [_detect_commit_type(f) for f in files]
320
+ counts = Counter(types)
321
+ return counts.most_common(1)[0][0]
322
+
323
+
324
+ def _prefix_dirs_for_type(commit_type: str) -> tuple:
325
+ result = [p for p, t in _PATH_PREFIX_TYPE.items() if t == commit_type]
326
+ return tuple(result) if result else ("",)
327
+
328
+
329
+ def generate_commit_message(diff_stats: Dict[str, Any]) -> str:
330
+ """Generate conventional commit message: type(scope): description.
331
+
332
+ Args:
333
+ diff_stats: Dict with ``files`` (list[str]), optional ``description``
334
+ (str), and optional ``breaking_change`` (str).
335
+ """
336
+ files: List[str] = diff_stats.get("files", [])
337
+ description: str = diff_stats.get("description", "")
338
+ breaking_change: str = diff_stats.get("breaking_change", "")
339
+
340
+ commit_type = _detect_type_majority(files)
341
+
342
+ prefix_dirs = _prefix_dirs_for_type(commit_type)
343
+ scope = _derive_scope_from_prefix(files, prefix_dirs) if files else "general"
344
+
345
+ if not description:
346
+ if files:
347
+ category = _classify_file(files[0])
348
+ description = _derive_description(category, files)
349
+ else:
350
+ description = "update project"
351
+
352
+ prefix = f"{commit_type}({scope}): "
353
+ max_desc_len = 72 - len(prefix)
354
+ if max_desc_len < 1:
355
+ max_desc_len = 1
356
+ if len(description) > max_desc_len:
357
+ description = description[:max_desc_len - 3].rstrip() + "..."
358
+
359
+ subject = f"{prefix}{description}"
360
+
361
+ if breaking_change:
362
+ return f"{subject}\n\nBREAKING CHANGE: {breaking_change}"
363
+
364
+ return subject
365
+
366
+
367
+ def _derive_description(category: str, files: List[str]) -> str:
368
+ """Generate a short description for a commit group.
369
+
370
+ Args:
371
+ category: File category (e.g., 'python', 'tests').
372
+ files: List of affected files.
373
+
374
+ Returns:
375
+ Human-readable description string.
376
+ """
377
+ n = len(files)
378
+ if category == "tests":
379
+ if n == 1:
380
+ return f"update test {os.path.basename(files[0])}"
381
+ return f"update {n} test files"
382
+ if category == "docs":
383
+ if n == 1:
384
+ return f"update {os.path.basename(files[0])}"
385
+ return f"update {n} documentation files"
386
+ if category == "config":
387
+ if n == 1:
388
+ return f"update {os.path.basename(files[0])}"
389
+ return f"update {n} config files"
390
+ # Source code
391
+ if n == 1:
392
+ return f"update {os.path.basename(files[0])}"
393
+ return f"update {n} {category} files"
394
+
395
+
396
+ def _is_safe_command(cmd: str) -> tuple[bool, str, list[str]]:
397
+ cmd = cmd.strip()
398
+ cmd_lower = cmd.lower()
399
+
400
+ for pattern in _BLOCKED_PATTERNS:
401
+ target = cmd_lower if any(ch.isalpha() for ch in pattern) else cmd
402
+ if pattern in target:
403
+ return False, f"blocked pattern '{pattern}'", []
404
+
405
+ try:
406
+ argv = shlex.split(cmd)
407
+ except ValueError as exc:
408
+ return False, f"invalid command syntax: {exc}", []
409
+
410
+ if not argv:
411
+ return False, "empty command", []
412
+
413
+ for prefix in _ALLOWED_PREFIXES:
414
+ if len(argv) < len(prefix):
415
+ continue
416
+ if tuple(argv[: len(prefix)]) == prefix:
417
+ return True, "", argv
418
+
419
+ return False, "not in allowed commands list", []
420
+
421
+
422
+ def _load_quality_gate_config(project_dir: str) -> Optional[dict[str, str]]:
423
+ primary = os.path.join(project_dir, ".omg", "state", "quality-gate.json")
424
+ legacy = os.path.join(project_dir, ".omc", "quality-gate.json")
425
+ path = primary if os.path.exists(primary) else legacy
426
+
427
+ if not os.path.exists(path):
428
+ return None
429
+
430
+ with open(path, "r", encoding="utf-8") as file_obj:
431
+ loaded = json.load(file_obj)
432
+
433
+ if not isinstance(loaded, dict):
434
+ raise ValueError("quality-gate.json must be a JSON object")
435
+
436
+ config: dict[str, str] = {}
437
+ for key, value in loaded.items():
438
+ if isinstance(key, str) and isinstance(value, str):
439
+ config[key] = value
440
+ return config
441
+
442
+
443
+ def _run_quality_gate(project_dir: str) -> Dict[str, Any]:
444
+ try:
445
+ config = _load_quality_gate_config(project_dir)
446
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
447
+ return {"ok": False, "step": "config", "reason": str(exc), "results": []}
448
+
449
+ if not config:
450
+ return {"ok": True, "results": []}
451
+
452
+ results: list[str] = []
453
+ for step in _QUALITY_STEPS:
454
+ cmd = config.get(step)
455
+ if cmd is None or not cmd.strip():
456
+ continue
457
+
458
+ safe, reason, argv = _is_safe_command(cmd)
459
+ if not safe:
460
+ return {
461
+ "ok": False,
462
+ "step": step,
463
+ "reason": f"{cmd} ({reason})",
464
+ "results": results,
465
+ }
466
+
467
+ try:
468
+ run_result = subprocess.run(
469
+ argv,
470
+ capture_output=True,
471
+ text=True,
472
+ timeout=60,
473
+ cwd=project_dir,
474
+ )
475
+ except subprocess.TimeoutExpired:
476
+ return {
477
+ "ok": False,
478
+ "step": step,
479
+ "reason": f"TIMEOUT {step}: {cmd}",
480
+ "results": results,
481
+ }
482
+ except FileNotFoundError:
483
+ results.append(f"SKIP {step}: command not found ({cmd})")
484
+ continue
485
+
486
+ if run_result.returncode != 0:
487
+ snippet = (run_result.stderr or run_result.stdout).strip()[:300]
488
+ return {
489
+ "ok": False,
490
+ "step": step,
491
+ "reason": f"FAIL {step}: {cmd} (exit {run_result.returncode}) {snippet}",
492
+ "results": results,
493
+ }
494
+ results.append(f"PASS {step}: {cmd} (exit 0)")
495
+
496
+ return {"ok": True, "results": results}
497
+
498
+
499
+ def execute_commit_plan(plan: List[Dict[str, Any]], project_dir: str, dry_run: bool = False) -> Dict[str, Any]:
500
+ if not isinstance(plan, list):
501
+ raise ValueError("plan must be a list")
502
+
503
+ for idx, group in enumerate(plan):
504
+ if not isinstance(group, dict):
505
+ raise ValueError(f"plan[{idx}] must be a dict")
506
+ files = group.get("files")
507
+ message = group.get("message")
508
+ if not isinstance(files, list) or not files or not all(isinstance(p, str) and p for p in files):
509
+ raise ValueError(f"plan[{idx}] must include non-empty files list")
510
+ if not isinstance(message, str) or not message.strip():
511
+ raise ValueError(f"plan[{idx}] must include non-empty message")
512
+ if _CONVENTIONAL_COMMIT_RE.match(message.strip()) is None:
513
+ raise ValueError(f"plan[{idx}] message must be conventional commit format")
514
+
515
+ result: Dict[str, Any] = {
516
+ "succeeded": [],
517
+ "failed": None,
518
+ "aborted": [],
519
+ }
520
+
521
+ if dry_run:
522
+ for group in plan:
523
+ files = group["files"]
524
+ message = group["message"].strip()
525
+ print(f"[DRY-RUN] git add {' '.join(files)}")
526
+ print(f"[DRY-RUN] git commit -m {message}")
527
+ return result
528
+
529
+ for idx, group in enumerate(plan):
530
+ files = group["files"]
531
+ message = group["message"].strip()
532
+
533
+ gate = _run_quality_gate(project_dir)
534
+ if not gate.get("ok", False):
535
+ result["failed"] = {
536
+ "message": message,
537
+ "files": files,
538
+ "stage": "quality_gate",
539
+ "reason": gate.get("reason", "quality gate failed"),
540
+ }
541
+ result["aborted"] = [remaining["message"] for remaining in plan[idx + 1:]]
542
+ return result
543
+
544
+ add_cmd = ["git", "-C", project_dir, "add", *files]
545
+ add_result = subprocess.run(
546
+ add_cmd,
547
+ capture_output=True,
548
+ text=True,
549
+ timeout=60,
550
+ cwd=project_dir,
551
+ )
552
+ if add_result.returncode != 0:
553
+ result["failed"] = {
554
+ "message": message,
555
+ "files": files,
556
+ "stage": "git_add",
557
+ "reason": (add_result.stderr or add_result.stdout).strip(),
558
+ }
559
+ result["aborted"] = [remaining["message"] for remaining in plan[idx + 1:]]
560
+ return result
561
+
562
+ commit_cmd = ["git", "-C", project_dir, "commit", "-m", message]
563
+ commit_result = subprocess.run(
564
+ commit_cmd,
565
+ capture_output=True,
566
+ text=True,
567
+ timeout=60,
568
+ cwd=project_dir,
569
+ )
570
+ if commit_result.returncode != 0:
571
+ result["failed"] = {
572
+ "message": message,
573
+ "files": files,
574
+ "stage": "git_commit",
575
+ "reason": (commit_result.stderr or commit_result.stdout).strip(),
576
+ }
577
+ result["aborted"] = [remaining["message"] for remaining in plan[idx + 1:]]
578
+ return result
579
+
580
+ result["succeeded"].append(message)
581
+
582
+ return result
583
+
584
+
585
+ # --- Public API ---
586
+
587
+
588
+ def analyze_changes(cwd: str = ".") -> List[Dict[str, Any]]:
589
+ """Analyze git changes and group hunks by logical concern.
590
+
591
+ Groups changes by file type/category, always separating test files
592
+ from source code. Returns an empty list when the feature flag
593
+ ``OMG_AI_COMMIT_ENABLED`` is ``False``.
594
+
595
+ Args:
596
+ cwd: Working directory (default: current directory).
597
+
598
+ Returns:
599
+ List of dicts, each with keys:
600
+ - group_name (str): Human-readable group name.
601
+ - files (list[str]): Affected file paths.
602
+ - hunks (list[dict]): Raw hunk dicts from git_hunk().
603
+ - suggested_type (str): Conventional commit type.
604
+ """
605
+ if not _is_enabled():
606
+ return []
607
+
608
+ _ensure_imports()
609
+ if _git_inspector is None:
610
+ return []
611
+ hunks = _git_inspector.git_hunk(cwd)
612
+
613
+ if not hunks:
614
+ return []
615
+
616
+ # Bucket hunks by category
617
+ buckets: Dict[str, Dict[str, Any]] = {}
618
+ for hunk in hunks:
619
+ file_path = hunk.get("file", "")
620
+ category = _classify_file(file_path)
621
+
622
+ if category not in buckets:
623
+ buckets[category] = {
624
+ "files_set": set(),
625
+ "hunks": [],
626
+ }
627
+ buckets[category]["files_set"].add(file_path)
628
+ buckets[category]["hunks"].append(hunk)
629
+
630
+ # Build result groups
631
+ groups: List[Dict[str, Any]] = []
632
+ for category, data in sorted(buckets.items()):
633
+ files = sorted(data["files_set"])
634
+ groups.append({
635
+ "group_name": category,
636
+ "files": files,
637
+ "hunks": data["hunks"],
638
+ "suggested_type": _CATEGORY_DEFAULT_TYPE.get(category, "chore"),
639
+ })
640
+
641
+ return groups
642
+
643
+
644
+ def generate_commit_plan(cwd: str = ".") -> Dict[str, Any]:
645
+ """Generate a full commit plan with proposed messages.
646
+
647
+ Calls ``analyze_changes()`` and builds conventional commit messages
648
+ for each group. Returns an empty plan when the feature flag is off.
649
+
650
+ Args:
651
+ cwd: Working directory (default: current directory).
652
+
653
+ Returns:
654
+ Dict with keys:
655
+ - groups (list[dict]): Raw groups from analyze_changes().
656
+ - proposed_commits (list[dict]): Each with ``message``,
657
+ ``files``, and ``hunks``.
658
+ - total_commits (int): Number of proposed commits.
659
+ """
660
+ groups = analyze_changes(cwd)
661
+
662
+ if not groups:
663
+ return {
664
+ "groups": [],
665
+ "proposed_commits": [],
666
+ "total_commits": 0,
667
+ }
668
+
669
+ proposed: List[Dict[str, Any]] = []
670
+ for group in groups:
671
+ commit_type = group["suggested_type"]
672
+ scope = _derive_scope(group["files"])
673
+ description = _derive_description(group["group_name"], group["files"])
674
+ message = f"{commit_type}({scope}): {description}"
675
+
676
+ proposed.append({
677
+ "message": message,
678
+ "files": group["files"],
679
+ "hunks": group["hunks"],
680
+ })
681
+
682
+ return {
683
+ "groups": groups,
684
+ "proposed_commits": proposed,
685
+ "total_commits": len(proposed),
686
+ }
687
+
688
+
689
+ def preview_commit_plan(cwd: str = ".") -> str:
690
+ """Human-readable preview of the commit plan.
691
+
692
+ Args:
693
+ cwd: Working directory (default: current directory).
694
+
695
+ Returns:
696
+ Formatted string showing each proposed commit and affected files.
697
+ Returns a notice string if the feature flag is off or no changes found.
698
+ """
699
+ plan = generate_commit_plan(cwd)
700
+
701
+ if plan["total_commits"] == 0:
702
+ if not _is_enabled():
703
+ return "[OMG] AI Commit Splitter is disabled. Set OMG_AI_COMMIT_ENABLED=1 to enable."
704
+ return "[OMG] No uncommitted changes found."
705
+
706
+ lines: List[str] = []
707
+ lines.append("=" * 60)
708
+ lines.append(" OMG AI Commit Splitter — Proposed Commit Plan")
709
+ lines.append("=" * 60)
710
+ lines.append("")
711
+ lines.append(f" Total proposed commits: {plan['total_commits']}")
712
+ lines.append("")
713
+
714
+ for idx, commit in enumerate(plan["proposed_commits"], 1):
715
+ lines.append(f" Commit {idx}: {commit['message']}")
716
+ lines.append(f" {'─' * 50}")
717
+ for f in commit["files"]:
718
+ lines.append(f" • {f}")
719
+ hunk_count = len(commit["hunks"])
720
+ lines.append(f" ({hunk_count} hunk{'s' if hunk_count != 1 else ''})")
721
+ lines.append("")
722
+
723
+ lines.append("=" * 60)
724
+ lines.append(" NOTE: This is a preview only. No commits were made.")
725
+ lines.append("=" * 60)
726
+
727
+ return "\n".join(lines)
728
+
729
+
730
+ # --- CLI ---
731
+
732
+
733
+ def main():
734
+ """CLI entry point."""
735
+ if len(sys.argv) < 2 or sys.argv[1] != "--dry-run":
736
+ print("Usage:", file=sys.stderr)
737
+ print(" python3 tools/commit_splitter.py --dry-run", file=sys.stderr)
738
+ sys.exit(1)
739
+
740
+ cwd = os.getcwd()
741
+ output = preview_commit_plan(cwd)
742
+ print(output)
743
+
744
+
745
+ if __name__ == "__main__":
746
+ main()