@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,300 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ HTML Dashboard Generator for OMG
4
+
5
+ Generates a single self-contained HTML dashboard with Chart.js (CDN)
6
+ from tool-ledger.jsonl and cost-ledger.jsonl data.
7
+
8
+ Feature flag: SESSION_ANALYTICS (default: False)
9
+ Pure stdlib — no external dependencies.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ from collections import Counter, defaultdict
17
+ from datetime import datetime, timezone
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ # Lazy import for feature flag helper
21
+ _get_feature_flag = None
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _ensure_imports():
27
+ """Lazy import feature flag helper."""
28
+ global _get_feature_flag
29
+ if _get_feature_flag is None:
30
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31
+ try:
32
+ from hooks._common import get_feature_flag as _gff
33
+ _get_feature_flag = _gff
34
+ except ImportError:
35
+ # Fallback: always return default
36
+ _get_feature_flag = None
37
+
38
+
39
+ def _read_jsonl(path: str) -> List[Dict[str, Any]]:
40
+ """Read JSONL file and return list of parsed entries.
41
+
42
+ Skips malformed lines gracefully. Returns empty list if file missing.
43
+ """
44
+ if not os.path.exists(path):
45
+ return []
46
+
47
+ entries: List[Dict[str, Any]] = []
48
+ try:
49
+ with open(path, "r", encoding="utf-8") as f:
50
+ for line in f:
51
+ line = line.strip()
52
+ if not line:
53
+ continue
54
+ try:
55
+ entry = json.loads(line)
56
+ if isinstance(entry, dict):
57
+ entries.append(entry)
58
+ except (json.JSONDecodeError, ValueError):
59
+ continue
60
+ except Exception:
61
+ pass # Crash isolation: return what we have
62
+
63
+ return entries
64
+
65
+
66
+ def _aggregate_tool_usage(tool_entries: List[Dict[str, Any]]) -> Dict[str, int]:
67
+ """Count tool invocations by tool name."""
68
+ counts: Dict[str, int] = Counter()
69
+ for entry in tool_entries:
70
+ tool = entry.get("tool", "unknown")
71
+ if isinstance(tool, str) and tool:
72
+ counts[tool] += 1
73
+ return dict(counts)
74
+
75
+
76
+ def _aggregate_cost_over_time(cost_entries: List[Dict[str, Any]]) -> List[Tuple[str, float]]:
77
+ """Aggregate cost by date (YYYY-MM-DD)."""
78
+ by_date: Dict[str, float] = defaultdict(float)
79
+ for entry in cost_entries:
80
+ ts = entry.get("ts", "")
81
+ cost = entry.get("cost_usd", 0.0)
82
+ if not isinstance(ts, str) or not ts:
83
+ continue
84
+ try:
85
+ cost_val = float(cost)
86
+ except (TypeError, ValueError):
87
+ cost_val = 0.0
88
+ # Extract date portion
89
+ date_str = ts[:10] # YYYY-MM-DD
90
+ if len(date_str) == 10:
91
+ by_date[date_str] += cost_val
92
+
93
+ return sorted(by_date.items())
94
+
95
+
96
+ def _build_session_summary(
97
+ tool_entries: List[Dict[str, Any]],
98
+ cost_entries: List[Dict[str, Any]],
99
+ ) -> Dict[str, Any]:
100
+ """Build session summary stats."""
101
+ total_tools = len(tool_entries)
102
+ total_cost = sum(
103
+ float(e.get("cost_usd", 0.0))
104
+ for e in cost_entries
105
+ if isinstance(e.get("cost_usd"), (int, float))
106
+ )
107
+ total_tokens = sum(
108
+ int(e.get("tokens_in", 0)) + int(e.get("tokens_out", 0))
109
+ for e in cost_entries
110
+ if isinstance(e.get("tokens_in"), (int, float))
111
+ and isinstance(e.get("tokens_out"), (int, float))
112
+ )
113
+
114
+ return {
115
+ "total_tool_calls": total_tools,
116
+ "total_cost_usd": round(total_cost, 6),
117
+ "total_tokens": total_tokens,
118
+ "cost_entries": len(cost_entries),
119
+ }
120
+
121
+
122
+ def _render_html(
123
+ tool_usage: Dict[str, int],
124
+ cost_over_time: List[Tuple[str, float]],
125
+ session_summary: Dict[str, Any],
126
+ cost_entries: List[Dict[str, Any]],
127
+ ) -> str:
128
+ """Render a self-contained HTML dashboard with Chart.js CDN."""
129
+ # Prepare JSON data for charts
130
+ tool_labels = json.dumps(list(tool_usage.keys()))
131
+ tool_data = json.dumps(list(tool_usage.values()))
132
+
133
+ cost_labels = json.dumps([item[0] for item in cost_over_time])
134
+ cost_data = json.dumps([round(item[1], 6) for item in cost_over_time])
135
+
136
+ # Per-entry cost data for detailed chart
137
+ entry_costs = []
138
+ for entry in cost_entries:
139
+ ts = entry.get("ts", "")
140
+ cost = entry.get("cost_usd", 0.0)
141
+ try:
142
+ cost_val = round(float(cost), 6)
143
+ except (TypeError, ValueError):
144
+ cost_val = 0.0
145
+ entry_costs.append({"ts": ts, "cost": cost_val})
146
+ entry_costs_json = json.dumps(entry_costs)
147
+
148
+ summary_html = (
149
+ f"<li>Total Tool Calls: <strong>{session_summary['total_tool_calls']}</strong></li>"
150
+ f"<li>Total Cost: <strong>${session_summary['total_cost_usd']}</strong></li>"
151
+ f"<li>Total Tokens: <strong>{session_summary['total_tokens']}</strong></li>"
152
+ f"<li>Cost Entries: <strong>{session_summary['cost_entries']}</strong></li>"
153
+ )
154
+
155
+ return f"""<!DOCTYPE html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="UTF-8">
159
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
160
+ <title>OMG Session Dashboard</title>
161
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
162
+ <style>
163
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
164
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 24px; }}
165
+ h1 {{ text-align: center; margin-bottom: 24px; color: #38bdf8; }}
166
+ .grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; max-width: 1200px; margin: 0 auto; }}
167
+ .card {{ background: #1e293b; border-radius: 12px; padding: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }}
168
+ .card h2 {{ margin-bottom: 16px; color: #94a3b8; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }}
169
+ .card.full {{ grid-column: 1 / -1; }}
170
+ canvas {{ max-height: 300px; }}
171
+ ul {{ list-style: none; }}
172
+ ul li {{ padding: 8px 0; border-bottom: 1px solid #334155; font-size: 15px; }}
173
+ ul li strong {{ color: #38bdf8; }}
174
+ .timestamp {{ text-align: center; color: #64748b; margin-top: 24px; font-size: 12px; }}
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <h1>OMG Session Dashboard</h1>
179
+ <div class="grid">
180
+ <div class="card">
181
+ <h2>Tool Usage</h2>
182
+ <canvas id="toolChart"></canvas>
183
+ </div>
184
+ <div class="card">
185
+ <h2>Cost Over Time</h2>
186
+ <canvas id="costChart"></canvas>
187
+ </div>
188
+ <div class="card full">
189
+ <h2>Session Summary</h2>
190
+ <ul>
191
+ {summary_html}
192
+ </ul>
193
+ </div>
194
+ </div>
195
+ <div class="timestamp">Generated: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")}</div>
196
+ <script>
197
+ const toolLabels = {tool_labels};
198
+ const toolData = {tool_data};
199
+ const costLabels = {cost_labels};
200
+ const costData = {cost_data};
201
+
202
+ new Chart(document.getElementById('toolChart'), {{
203
+ type: 'bar',
204
+ data: {{
205
+ labels: toolLabels,
206
+ datasets: [{{
207
+ label: 'Tool Calls',
208
+ data: toolData,
209
+ backgroundColor: 'rgba(56, 189, 248, 0.7)',
210
+ borderColor: 'rgba(56, 189, 248, 1)',
211
+ borderWidth: 1
212
+ }}]
213
+ }},
214
+ options: {{
215
+ responsive: true,
216
+ plugins: {{ legend: {{ display: false }} }},
217
+ scales: {{
218
+ y: {{ beginAtZero: true, ticks: {{ color: '#94a3b8' }}, grid: {{ color: '#334155' }} }},
219
+ x: {{ ticks: {{ color: '#94a3b8' }}, grid: {{ color: '#334155' }} }}
220
+ }}
221
+ }}
222
+ }});
223
+
224
+ new Chart(document.getElementById('costChart'), {{
225
+ type: 'line',
226
+ data: {{
227
+ labels: costLabels,
228
+ datasets: [{{
229
+ label: 'Cost (USD)',
230
+ data: costData,
231
+ borderColor: 'rgba(34, 197, 94, 1)',
232
+ backgroundColor: 'rgba(34, 197, 94, 0.1)',
233
+ fill: true,
234
+ tension: 0.3
235
+ }}]
236
+ }},
237
+ options: {{
238
+ responsive: true,
239
+ plugins: {{ legend: {{ display: false }} }},
240
+ scales: {{
241
+ y: {{ beginAtZero: true, ticks: {{ color: '#94a3b8' }}, grid: {{ color: '#334155' }} }},
242
+ x: {{ ticks: {{ color: '#94a3b8' }}, grid: {{ color: '#334155' }} }}
243
+ }}
244
+ }}
245
+ }});
246
+ </script>
247
+ </body>
248
+ </html>"""
249
+
250
+
251
+ def generate_dashboard(
252
+ project_dir: str,
253
+ output_path: Optional[str] = None,
254
+ ) -> str:
255
+ """Generate an HTML dashboard from OMG ledger data.
256
+
257
+ Reads tool usage stats from {project_dir}/.omg/state/ledger/tool-ledger.jsonl
258
+ and cost data from {project_dir}/.omg/state/ledger/cost-ledger.jsonl.
259
+ Generates a single self-contained HTML file with Chart.js loaded from CDN.
260
+
261
+ Args:
262
+ project_dir: Project root directory.
263
+ output_path: Path for the HTML file. Defaults to
264
+ {project_dir}/.omg/state/dashboard.html.
265
+
266
+ Returns:
267
+ The output_path string on success.
268
+ """
269
+ _ensure_imports()
270
+
271
+ # Check feature flag — return empty string if disabled
272
+ if _get_feature_flag is not None:
273
+ if not _get_feature_flag("SESSION_ANALYTICS", default=False):
274
+ return ""
275
+
276
+ # Resolve default output path
277
+ if output_path is None:
278
+ output_path = os.path.join(project_dir, ".omg", "state", "dashboard.html")
279
+
280
+ # Read ledger data
281
+ tool_ledger_path = os.path.join(project_dir, ".omg", "state", "ledger", "tool-ledger.jsonl")
282
+ cost_ledger_path = os.path.join(project_dir, ".omg", "state", "ledger", "cost-ledger.jsonl")
283
+
284
+ tool_entries = _read_jsonl(tool_ledger_path)
285
+ cost_entries = _read_jsonl(cost_ledger_path)
286
+
287
+ # Aggregate data
288
+ tool_usage = _aggregate_tool_usage(tool_entries)
289
+ cost_over_time = _aggregate_cost_over_time(cost_entries)
290
+ session_summary = _build_session_summary(tool_entries, cost_entries)
291
+
292
+ # Render HTML
293
+ html = _render_html(tool_usage, cost_over_time, session_summary, cost_entries)
294
+
295
+ # Write file
296
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
297
+ with open(output_path, "w", encoding="utf-8") as f:
298
+ f.write(html)
299
+
300
+ return output_path
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git Inspection Tools for OMG
4
+
5
+ Read-only git inspection: status, log, and hunk-level diffs.
6
+ Feature flag: OMG_GIT_TOOLS_ENABLED (default: False)
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ # Import feature flag helper
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+ from hooks._common import get_feature_flag
19
+
20
+
21
+ def git_status(cwd: str = ".") -> Dict[str, Any]:
22
+ """
23
+ Get git status: staged, unstaged, untracked files and current branch.
24
+
25
+ Args:
26
+ cwd: Working directory (default: current directory)
27
+
28
+ Returns:
29
+ {
30
+ "skipped": True # if feature flag disabled
31
+ }
32
+ or
33
+ {
34
+ "staged": ["file1.py", "file2.py"],
35
+ "unstaged": ["file3.py"],
36
+ "untracked": ["file4.py"],
37
+ "branch": "main",
38
+ "error": None
39
+ }
40
+ or
41
+ {
42
+ "error": "git not found"
43
+ }
44
+ """
45
+ # Check feature flag
46
+ if not get_feature_flag("git_tools", default=False):
47
+ return {"skipped": True}
48
+
49
+ try:
50
+ # Get status with porcelain format
51
+ result = subprocess.run(
52
+ ["git", "status", "--porcelain"],
53
+ cwd=cwd,
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=10
57
+ )
58
+
59
+ if result.returncode != 0:
60
+ return {"error": "git command failed"}
61
+
62
+ staged = []
63
+ unstaged = []
64
+ untracked = []
65
+
66
+ for line in result.stdout.split("\n"):
67
+ if not line:
68
+ continue
69
+
70
+ status_code = line[:2]
71
+ file_path = line[3:]
72
+
73
+ # First char: index (staged)
74
+ # Second char: working tree (unstaged)
75
+ if status_code[0] != " ":
76
+ staged.append(file_path)
77
+ if status_code[1] != " ":
78
+ unstaged.append(file_path)
79
+ if status_code == "??":
80
+ untracked.append(file_path)
81
+
82
+ # Get current branch
83
+ branch_result = subprocess.run(
84
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
85
+ cwd=cwd,
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=10
89
+ )
90
+ branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
91
+
92
+ return {
93
+ "staged": staged,
94
+ "unstaged": unstaged,
95
+ "untracked": untracked,
96
+ "branch": branch,
97
+ "error": None
98
+ }
99
+
100
+ except FileNotFoundError:
101
+ return {"error": "git not found"}
102
+ except subprocess.TimeoutExpired:
103
+ return {"error": "git command timeout"}
104
+ except Exception as e:
105
+ return {"error": str(e)}
106
+
107
+
108
+ def git_log(cwd: str = ".", n: int = 10) -> List[Dict[str, Any]]:
109
+ """
110
+ Get recent N commits with hash, subject, author, and date.
111
+
112
+ Args:
113
+ cwd: Working directory (default: current directory)
114
+ n: Number of commits to retrieve (default: 10)
115
+
116
+ Returns:
117
+ List of {hash, subject, author, date} dicts
118
+ Empty list if git not available or feature flag disabled
119
+ """
120
+ # Check feature flag
121
+ if not get_feature_flag("git_tools", default=False):
122
+ return []
123
+
124
+ try:
125
+ result = subprocess.run(
126
+ ["git", "log", f"--oneline", f"-n", str(n),
127
+ "--format=%H|%s|%an|%ai"],
128
+ cwd=cwd,
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=10
132
+ )
133
+
134
+ if result.returncode != 0:
135
+ return []
136
+
137
+ commits = []
138
+ for line in result.stdout.strip().split("\n"):
139
+ if not line:
140
+ continue
141
+
142
+ parts = line.split("|", 3)
143
+ if len(parts) == 4:
144
+ commits.append({
145
+ "hash": parts[0],
146
+ "subject": parts[1],
147
+ "author": parts[2],
148
+ "date": parts[3]
149
+ })
150
+
151
+ return commits
152
+
153
+ except FileNotFoundError:
154
+ return []
155
+ except subprocess.TimeoutExpired:
156
+ return []
157
+ except Exception:
158
+ return []
159
+
160
+
161
+ def git_hunk(cwd: str = ".", file_path: Optional[str] = None) -> List[Dict[str, Any]]:
162
+ """
163
+ Get hunk-level diff for a file or all files.
164
+
165
+ Args:
166
+ cwd: Working directory (default: current directory)
167
+ file_path: Specific file to diff (None for all files)
168
+
169
+ Returns:
170
+ List of {file, old_start, old_count, new_start, new_count, context, lines} dicts
171
+ Empty list if no diff or git not available or feature flag disabled
172
+ """
173
+ # Check feature flag
174
+ if not get_feature_flag("git_tools", default=False):
175
+ return []
176
+
177
+ try:
178
+ cmd = ["git", "diff", "--unified=3"]
179
+ if file_path:
180
+ cmd.append(file_path)
181
+
182
+ result = subprocess.run(
183
+ cmd,
184
+ cwd=cwd,
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=10
188
+ )
189
+
190
+ if result.returncode != 0:
191
+ return []
192
+
193
+ hunks = []
194
+ current_file = None
195
+ current_hunk = None
196
+ hunk_lines = []
197
+
198
+ # Regex to match hunk header: @@ -a,b +c,d @@ context
199
+ hunk_header_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@\s*(.*)")
200
+
201
+ for line in result.stdout.split("\n"):
202
+ # File header
203
+ if line.startswith("diff --git"):
204
+ # Save previous hunk if exists
205
+ if current_hunk and hunk_lines:
206
+ current_hunk["lines"] = hunk_lines
207
+ hunks.append(current_hunk)
208
+ hunk_lines = []
209
+ current_hunk = None
210
+
211
+ # Extract filename from "diff --git a/file b/file"
212
+ parts = line.split()
213
+ if len(parts) >= 4:
214
+ current_file = parts[3][2:] # Remove "b/" prefix
215
+
216
+ # Hunk header
217
+ elif line.startswith("@@"):
218
+ # Save previous hunk if exists
219
+ if current_hunk and hunk_lines:
220
+ current_hunk["lines"] = hunk_lines
221
+ hunks.append(current_hunk)
222
+ hunk_lines = []
223
+
224
+ match = hunk_header_re.match(line)
225
+ if match:
226
+ old_start = int(match.group(1))
227
+ old_count = int(match.group(2)) if match.group(2) else 1
228
+ new_start = int(match.group(3))
229
+ new_count = int(match.group(4)) if match.group(4) else 1
230
+ context = match.group(5).strip()
231
+
232
+ current_hunk = {
233
+ "file": current_file,
234
+ "old_start": old_start,
235
+ "old_count": old_count,
236
+ "new_start": new_start,
237
+ "new_count": new_count,
238
+ "context": context,
239
+ "lines": []
240
+ }
241
+
242
+ # Hunk content
243
+ elif current_hunk is not None:
244
+ if line.startswith("+") or line.startswith("-") or line.startswith(" "):
245
+ hunk_lines.append(line)
246
+
247
+ # Save last hunk
248
+ if current_hunk and hunk_lines:
249
+ current_hunk["lines"] = hunk_lines
250
+ hunks.append(current_hunk)
251
+
252
+ return hunks
253
+
254
+ except FileNotFoundError:
255
+ return []
256
+ except subprocess.TimeoutExpired:
257
+ return []
258
+ except Exception:
259
+ return []
260
+
261
+
262
+ def main():
263
+ """CLI entry point."""
264
+ if len(sys.argv) < 2:
265
+ print("Usage:", file=sys.stderr)
266
+ print(" python3 git_inspector.py --overview", file=sys.stderr)
267
+ print(" python3 git_inspector.py --hunk [--file <path>]", file=sys.stderr)
268
+ sys.exit(1)
269
+
270
+ cwd = os.getcwd()
271
+
272
+ if sys.argv[1] == "--overview":
273
+ # Return status + log
274
+ status = git_status(cwd)
275
+ log = git_log(cwd)
276
+ result = {
277
+ "status": status,
278
+ "log": log
279
+ }
280
+ print(json.dumps(result, indent=2))
281
+
282
+ elif sys.argv[1] == "--hunk":
283
+ # Return hunk diff
284
+ file_path = None
285
+ if len(sys.argv) >= 4 and sys.argv[2] == "--file":
286
+ file_path = sys.argv[3]
287
+
288
+ hunks = git_hunk(cwd, file_path)
289
+ result = {"hunks": hunks}
290
+ print(json.dumps(result, indent=2))
291
+
292
+ else:
293
+ print(f"Unknown command: {sys.argv[1]}", file=sys.stderr)
294
+ sys.exit(1)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ main()