@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,221 @@
1
+ #!/usr/bin/env python3
2
+ """Tiered token estimation helpers for OMG hooks."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import importlib
8
+ import urllib.request
9
+ from collections.abc import Iterable
10
+
11
+ API_URL = "https://api.anthropic.com/v1/messages/count_tokens"
12
+ API_MODEL = "claude-3-5-haiku-20241022"
13
+
14
+ _FEATURE_UI_DISPLAY = "ui_display"
15
+ _FEATURE_BUDGET_ENFORCEMENT = "budget_enforcement"
16
+ _FEATURE_PREFLIGHT = "preflight"
17
+
18
+
19
+ def _safe_int(value: float) -> int:
20
+ if value <= 0:
21
+ return 0
22
+ return int(value)
23
+
24
+
25
+ def _extract_features(text: str) -> tuple[int, int, int]:
26
+ encoded = text.encode("utf-8")
27
+ byte_count = len(encoded)
28
+ word_count = len(text.split())
29
+ line_count = text.count("\n") + (1 if text else 0)
30
+ return byte_count, word_count, line_count
31
+
32
+
33
+ def _default_coefficients() -> tuple[float, float, float, float]:
34
+ return (1.0, 0.19, 0.75, 1.1)
35
+
36
+
37
+ def _gaussian_solve(matrix: list[list[float]], vector: list[float]) -> list[float] | None:
38
+ n = len(vector)
39
+ if n == 0:
40
+ return None
41
+ try:
42
+ for i in range(n):
43
+ pivot = i
44
+ for r in range(i + 1, n):
45
+ if abs(matrix[r][i]) > abs(matrix[pivot][i]):
46
+ pivot = r
47
+ if abs(matrix[pivot][i]) < 1e-12:
48
+ return None
49
+
50
+ if pivot != i:
51
+ matrix[i], matrix[pivot] = matrix[pivot], matrix[i]
52
+ vector[i], vector[pivot] = vector[pivot], vector[i]
53
+
54
+ pivot_val = matrix[i][i]
55
+ for c in range(i, n):
56
+ matrix[i][c] /= pivot_val
57
+ vector[i] /= pivot_val
58
+
59
+ for r in range(n):
60
+ if r == i:
61
+ continue
62
+ factor = matrix[r][i]
63
+ if factor == 0:
64
+ continue
65
+ for c in range(i, n):
66
+ matrix[r][c] -= factor * matrix[i][c]
67
+ vector[r] -= factor * vector[i]
68
+ return vector
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def _fit_linear_coefficients(samples: Iterable[tuple[str, int]]) -> tuple[float, float, float, float]:
74
+ rows: list[list[float]] = []
75
+ targets: list[float] = []
76
+ for text, tokens in samples:
77
+ bcount, wcount, lcount = _extract_features(text)
78
+ rows.append([1.0, float(bcount), float(wcount), float(lcount)])
79
+ targets.append(float(tokens))
80
+
81
+ dim = 4
82
+ if len(rows) < dim:
83
+ return _default_coefficients()
84
+
85
+ xtx = [[0.0 for _ in range(dim)] for _ in range(dim)]
86
+ xty = [0.0 for _ in range(dim)]
87
+ for row, target in zip(rows, targets):
88
+ for i in range(dim):
89
+ xty[i] += row[i] * target
90
+ for j in range(dim):
91
+ xtx[i][j] += row[i] * row[j]
92
+
93
+ solved = _gaussian_solve(xtx, xty)
94
+ if solved is None:
95
+ return _default_coefficients()
96
+
97
+ return (float(solved[0]), float(solved[1]), float(solved[2]), float(solved[3]))
98
+
99
+
100
+ _CALIBRATION_SAMPLES: tuple[tuple[str, int], ...] = (
101
+ ("ls", 5),
102
+ ("git status", 7),
103
+ ("echo 'hello world'", 10),
104
+ ("python3 -m pytest tests/hooks/test_feature_flags_v2.py -q", 18),
105
+ ("def hello():\n return 'world'\n", 24),
106
+ (
107
+ """from pathlib import Path\nfor path in Path('hooks').glob('*.py'):\n print(path.name)\n""",
108
+ 44,
109
+ ),
110
+ (
111
+ """def estimate_tokens(text: str, tier: int = 1) -> int:\n if tier == 1:\n return max(1, int(len(text) / 3.5))\n return 0\n""",
112
+ 80,
113
+ ),
114
+ (
115
+ "\n".join(["line with common code and comments" for _ in range(80)]),
116
+ 720,
117
+ ),
118
+ (
119
+ "\n".join(["longer source line with punctuation () {} [] == != <= >=" for _ in range(250)]),
120
+ 1800,
121
+ ),
122
+ )
123
+
124
+ _COEFFICIENTS = _fit_linear_coefficients(_CALIBRATION_SAMPLES)
125
+
126
+
127
+ def _estimate_tier1(text: str) -> int:
128
+ if not text:
129
+ return 0
130
+ return max(1, int(len(text) / 3.5))
131
+
132
+
133
+ def _estimate_tier2(text: str) -> int:
134
+ if not text:
135
+ return 0
136
+ bias, w_bytes, w_words, w_lines = _COEFFICIENTS
137
+ bcount, wcount, lcount = _extract_features(text)
138
+ prediction = bias + (w_bytes * bcount) + (w_words * wcount) + (w_lines * lcount)
139
+ return max(1, _safe_int(prediction))
140
+
141
+
142
+ def _get_anthropic_api_key() -> str | None:
143
+ try:
144
+ store_mod = importlib.import_module("credential_store")
145
+ key = store_mod.get_active_key("anthropic")
146
+ if key:
147
+ return key
148
+ except Exception:
149
+ pass
150
+ return os.environ.get("ANTHROPIC_API_KEY")
151
+
152
+
153
+ def _estimate_tier3(text: str) -> int:
154
+ if not text:
155
+ return 0
156
+
157
+ api_key = _get_anthropic_api_key()
158
+ if not api_key:
159
+ return _estimate_tier2(text)
160
+
161
+ payload = {
162
+ "model": API_MODEL,
163
+ "messages": [{"role": "user", "content": text}],
164
+ }
165
+ body = json.dumps(payload).encode("utf-8")
166
+ request = urllib.request.Request(
167
+ API_URL,
168
+ data=body,
169
+ method="POST",
170
+ headers={
171
+ "content-type": "application/json",
172
+ "x-api-key": api_key,
173
+ "anthropic-version": "2023-06-01",
174
+ },
175
+ )
176
+
177
+ try:
178
+ with urllib.request.urlopen(request, timeout=8) as response:
179
+ raw = response.read().decode("utf-8")
180
+ parsed = json.loads(raw)
181
+ token_value = parsed.get("input_tokens")
182
+ if isinstance(token_value, int) and token_value >= 0:
183
+ return token_value
184
+ except Exception:
185
+ return _estimate_tier2(text)
186
+
187
+ return _estimate_tier2(text)
188
+
189
+
190
+ def auto_select_tier(operation: str, text: str = "") -> int:
191
+ normalized = (operation or "").strip().lower()
192
+ if normalized == _FEATURE_UI_DISPLAY:
193
+ return 1
194
+ if normalized == _FEATURE_BUDGET_ENFORCEMENT:
195
+ return 2
196
+ if normalized == _FEATURE_PREFLIGHT:
197
+ if len(text) >= 8000:
198
+ return 3
199
+ if _estimate_tier2(text) >= 1000:
200
+ return 3
201
+ return 2
202
+ return 1
203
+
204
+
205
+ def estimate_tokens(text: str, tier: int = 1) -> int:
206
+ """Estimate token count with 3 reliability/cost tiers.
207
+
208
+ Tier 1: fast heuristic (`len(text)/3.5`).
209
+ Tier 2: calibrated linear model using bytes, words, lines.
210
+ Tier 3: Anthropic count_tokens API with graceful fallback to tier 2.
211
+ """
212
+ try:
213
+ if tier == 1:
214
+ return _estimate_tier1(text)
215
+ if tier == 2:
216
+ return _estimate_tier2(text)
217
+ if tier == 3:
218
+ return _estimate_tier3(text)
219
+ return _estimate_tier1(text)
220
+ except Exception:
221
+ return _estimate_tier1(text)
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart Hook — Smart Branch Manager.
3
+
4
+ Auto-creates a feature branch when on main/master/develop.
5
+ Extracts task description from OMG state files for branch naming.
6
+
7
+ Feature-gated: OMG_GIT_WORKFLOW_ENABLED (uses get_feature_flag('GIT_WORKFLOW'))
8
+ """
9
+ import json
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from datetime import datetime
15
+
16
+ HOOKS_DIR = os.path.dirname(__file__)
17
+ if HOOKS_DIR not in sys.path:
18
+ sys.path.insert(0, HOOKS_DIR)
19
+
20
+ from _common import setup_crash_handler, json_input, get_feature_flag
21
+
22
+ setup_crash_handler("branch-manager", fail_closed=False)
23
+
24
+ # Default branches that trigger feature branch creation
25
+ DEFAULT_BRANCHES = frozenset({"main", "master", "develop"})
26
+
27
+ # Max length for the descriptive part of branch name
28
+ MAX_BRANCH_NAME_LEN = 50
29
+
30
+
31
+ def _get_project_dir() -> str:
32
+ """Get project directory from env or cwd."""
33
+ return os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
34
+
35
+
36
+ def _has_git(project_dir: str) -> bool:
37
+ """Check if project_dir is inside a git repo."""
38
+ try:
39
+ result = subprocess.run(
40
+ ["git", "-C", project_dir, "rev-parse", "--git-dir"],
41
+ capture_output=True, text=True, timeout=5,
42
+ )
43
+ return result.returncode == 0
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ def _current_branch(project_dir: str) -> str | None:
49
+ """Get current branch name. Returns None on failure."""
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "-C", project_dir, "rev-parse", "--abbrev-ref", "HEAD"],
53
+ capture_output=True, text=True, timeout=5,
54
+ )
55
+ if result.returncode == 0:
56
+ return result.stdout.strip()
57
+ except Exception:
58
+ pass
59
+ return None
60
+
61
+
62
+ def _sanitize_branch_name(description: str) -> str:
63
+ """Sanitize a description into a valid git branch name segment.
64
+
65
+ Rules:
66
+ - Lowercase
67
+ - Replace spaces/underscores with hyphens
68
+ - Strip special characters (keep alphanumeric and hyphens)
69
+ - Collapse consecutive hyphens
70
+ - Strip leading/trailing hyphens
71
+ - Max MAX_BRANCH_NAME_LEN chars
72
+ """
73
+ name = description.lower().strip()
74
+ # Replace spaces and underscores with hyphens
75
+ name = re.sub(r"[\s_]+", "-", name)
76
+ # Remove everything except alphanumeric and hyphens
77
+ name = re.sub(r"[^a-z0-9-]", "", name)
78
+ # Collapse consecutive hyphens
79
+ name = re.sub(r"-{2,}", "-", name)
80
+ # Strip leading/trailing hyphens
81
+ name = name.strip("-")
82
+ # Truncate to max length, but don't cut mid-word if possible
83
+ if len(name) > MAX_BRANCH_NAME_LEN:
84
+ truncated = name[:MAX_BRANCH_NAME_LEN]
85
+ # Try to cut at last hyphen to avoid mid-word truncation
86
+ last_hyphen = truncated.rfind("-")
87
+ if last_hyphen > 20:
88
+ truncated = truncated[:last_hyphen]
89
+ name = truncated.rstrip("-")
90
+ return name
91
+
92
+
93
+ def _extract_task_description(project_dir: str) -> str | None:
94
+ """Extract task description from OMG state files.
95
+
96
+ Priority order:
97
+ (a) .omg/state/_plan.md title (first # heading)
98
+ (b) .omg/state/_checklist.md first item
99
+ (c) .omg/state/working-memory.md last entry
100
+ (d) fallback: None (caller uses session-{timestamp})
101
+ """
102
+ state_dir = os.path.join(project_dir, ".omg", "state")
103
+
104
+ # (a) Plan title
105
+ plan_path = os.path.join(state_dir, "_plan.md")
106
+ if os.path.isfile(plan_path):
107
+ try:
108
+ with open(plan_path, "r", encoding="utf-8", errors="ignore") as f:
109
+ for line in f:
110
+ line = line.strip()
111
+ if line.startswith("# "):
112
+ title = line[2:].strip()
113
+ if title:
114
+ return title
115
+ except Exception:
116
+ pass
117
+
118
+ # (b) Checklist first item
119
+ checklist_path = os.path.join(state_dir, "_checklist.md")
120
+ if os.path.isfile(checklist_path):
121
+ try:
122
+ with open(checklist_path, "r", encoding="utf-8", errors="ignore") as f:
123
+ for line in f:
124
+ line = line.strip()
125
+ # Match markdown checkbox items: - [ ] or - [x]
126
+ m = re.match(r"^-\s*\[.\]\s*(.+)$", line)
127
+ if m:
128
+ item = m.group(1).strip()
129
+ if item:
130
+ return item
131
+ except Exception:
132
+ pass
133
+
134
+ # (c) Working memory last entry
135
+ wm_path = os.path.join(state_dir, "working-memory.md")
136
+ if os.path.isfile(wm_path):
137
+ try:
138
+ with open(wm_path, "r", encoding="utf-8", errors="ignore") as f:
139
+ content = f.read()
140
+ # Split by ## headings, take last entry's content
141
+ sections = re.split(r"\n## ", content)
142
+ if len(sections) > 1:
143
+ # Last section: first line is heading, rest is content
144
+ last_lines = sections[-1].split("\n")
145
+ # Find first non-empty content line after heading
146
+ for line in last_lines[1:]:
147
+ line = line.strip()
148
+ if line:
149
+ return line
150
+ # Fallback to heading if no content
151
+ heading = last_lines[0].strip()
152
+ if heading:
153
+ return heading
154
+ elif sections:
155
+ # Single section — try first non-empty non-heading line
156
+ for line in sections[0].split("\n"):
157
+ line = line.strip()
158
+ if line and not line.startswith("#"):
159
+ return line
160
+ except Exception:
161
+ pass
162
+
163
+ # (d) No state files found
164
+ return None
165
+
166
+
167
+ def _create_branch(project_dir: str, branch_name: str) -> bool:
168
+ """Create and checkout a new branch. Returns True on success."""
169
+ try:
170
+ result = subprocess.run(
171
+ ["git", "-C", project_dir, "checkout", "-b", branch_name],
172
+ capture_output=True, text=True, timeout=10,
173
+ )
174
+ return result.returncode == 0
175
+ except Exception:
176
+ return False
177
+
178
+
179
+ def main() -> None:
180
+ """Main hook entry point."""
181
+ data = json_input()
182
+
183
+ # Feature gate: exit silently if disabled
184
+ if not get_feature_flag("GIT_WORKFLOW", default=False):
185
+ sys.exit(0)
186
+
187
+ project_dir = _get_project_dir()
188
+
189
+ # No-op if not a git repo
190
+ if not _has_git(project_dir):
191
+ sys.exit(0)
192
+
193
+ # Get current branch
194
+ branch = _current_branch(project_dir)
195
+ if branch is None:
196
+ sys.exit(0)
197
+
198
+ # No-op if already on a non-default branch (feature branch, etc.)
199
+ if branch not in DEFAULT_BRANCHES:
200
+ sys.exit(0)
201
+
202
+ # Extract task description and build branch name
203
+ description = _extract_task_description(project_dir)
204
+ if description:
205
+ sanitized = _sanitize_branch_name(description)
206
+ else:
207
+ sanitized = ""
208
+
209
+ if not sanitized:
210
+ # Fallback: session-{timestamp}
211
+ sanitized = f"session-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
212
+
213
+ target_branch = f"feature/{sanitized}"
214
+
215
+ # Dry-run mode: output what would happen without executing
216
+ dry_run = os.environ.get("OMG_GIT_WORKFLOW_DRY_RUN", "").lower() in ("1", "true", "yes")
217
+ if dry_run:
218
+ print(
219
+ f"[OMG branch-manager] DRY-RUN: Would create branch '{target_branch}' from '{branch}'",
220
+ file=sys.stderr,
221
+ )
222
+ sys.exit(0)
223
+
224
+ # Create the feature branch
225
+ success = _create_branch(project_dir, target_branch)
226
+ if success:
227
+ print(
228
+ f"[OMG branch-manager] Created branch '{target_branch}' from '{branch}'",
229
+ file=sys.stderr,
230
+ )
231
+
232
+ sys.exit(0)
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse budget governor (BATS-style additionalContext injection)."""
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import Any
10
+
11
+ HOOKS_DIR = os.path.dirname(__file__)
12
+
13
+
14
+ def _load_module(module_name: str, filename: str):
15
+ path = os.path.join(HOOKS_DIR, filename)
16
+ spec = importlib.util.spec_from_file_location(module_name, path)
17
+ if spec is None or spec.loader is None:
18
+ raise RuntimeError(f"Unable to load module: {filename}")
19
+ module = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(module)
21
+ return module
22
+
23
+
24
+ _common = _load_module("_common", "_common.py")
25
+ _cost_ledger = _load_module("_cost_ledger", "_cost_ledger.py")
26
+ _token_counter = _load_module("_token_counter", "_token_counter.py")
27
+
28
+ get_feature_flag = _common.get_feature_flag
29
+ get_project_dir = _common.get_project_dir
30
+ json_input = _common.json_input
31
+ setup_crash_handler = _common.setup_crash_handler
32
+ read_cost_summary = _cost_ledger.read_cost_summary
33
+ estimate_tokens = _token_counter.estimate_tokens
34
+
35
+ DEFAULT_SESSION_LIMIT_USD = 5.0
36
+ DEFAULT_INPUT_PER_MTOK = 3.0
37
+ DEFAULT_OUTPUT_PER_MTOK = 15.0
38
+ DEFAULT_PROJECTED_TOOL_CALLS = 50
39
+ DEFAULT_THRESHOLDS = [50, 80, 95]
40
+ THRESHOLD_STATE_FILE = ".omg/state/.cost-threshold-state.json"
41
+
42
+
43
+ def _safe_float(value, default: float) -> float:
44
+ try:
45
+ parsed = float(value)
46
+ if parsed <= 0:
47
+ return default
48
+ return parsed
49
+ except (TypeError, ValueError):
50
+ return default
51
+
52
+
53
+ def _read_budget_config(project_dir: str) -> tuple[float, float, float]:
54
+ session_limit = DEFAULT_SESSION_LIMIT_USD
55
+ input_per_mtok = DEFAULT_INPUT_PER_MTOK
56
+ output_per_mtok = DEFAULT_OUTPUT_PER_MTOK
57
+
58
+ settings_path = os.path.join(project_dir, "settings.json")
59
+ try:
60
+ with open(settings_path, "r", encoding="utf-8") as f:
61
+ settings = json.load(f)
62
+ budget_cfg = settings.get("_omg", {}).get("cost_budget", {})
63
+ pricing = budget_cfg.get("pricing", {})
64
+ session_limit = _safe_float(budget_cfg.get("session_limit_usd"), DEFAULT_SESSION_LIMIT_USD)
65
+ input_per_mtok = _safe_float(pricing.get("input_per_mtok"), DEFAULT_INPUT_PER_MTOK)
66
+ output_per_mtok = _safe_float(pricing.get("output_per_mtok"), DEFAULT_OUTPUT_PER_MTOK)
67
+ except Exception:
68
+ pass
69
+
70
+ return session_limit, input_per_mtok, output_per_mtok
71
+
72
+
73
+ def _to_text(value) -> str:
74
+ if value is None:
75
+ return ""
76
+ if isinstance(value, str):
77
+ return value
78
+ try:
79
+ return json.dumps(value, ensure_ascii=True, sort_keys=True)
80
+ except Exception:
81
+ return str(value)
82
+
83
+
84
+ def _estimate_call_cost(tool_input, tool_response, input_per_mtok: float, output_per_mtok: float) -> float:
85
+ input_text = _to_text(tool_input)
86
+ output_text = _to_text(tool_response)
87
+
88
+ tokens_in = estimate_tokens(input_text, tier=2)
89
+ tokens_out = estimate_tokens(output_text, tier=2)
90
+
91
+ cost_in = (tokens_in / 1_000_000.0) * input_per_mtok
92
+ cost_out = (tokens_out / 1_000_000.0) * output_per_mtok
93
+ return max(0.0, cost_in + cost_out)
94
+
95
+
96
+ def _project_total_calls(used_cost_usd: float, used_calls: int, session_limit_usd: float) -> int:
97
+ if used_calls <= 0 or used_cost_usd <= 0:
98
+ return DEFAULT_PROJECTED_TOOL_CALLS
99
+ avg_cost = used_cost_usd / float(used_calls)
100
+ if avg_cost <= 0:
101
+ return DEFAULT_PROJECTED_TOOL_CALLS
102
+ projected = max(used_calls, int(round(session_limit_usd / avg_cost)))
103
+ if projected > (DEFAULT_PROJECTED_TOOL_CALLS * 10):
104
+ return DEFAULT_PROJECTED_TOOL_CALLS
105
+ rounded = int(round(projected / 10.0) * 10)
106
+ return max(10, rounded)
107
+
108
+
109
+ def _build_context(used_cost_usd: float, session_limit_usd: float, used_calls: int, projected_calls: int) -> str:
110
+ remaining_ratio = 1.0 - (used_cost_usd / session_limit_usd)
111
+ remaining_pct = int(round(max(0.0, min(1.0, remaining_ratio)) * 100))
112
+ return (
113
+ f"Budget: {remaining_pct}% remaining | "
114
+ f"${used_cost_usd:.2f} of ${session_limit_usd:.2f} used | "
115
+ f"{used_calls} tool calls of ~{projected_calls}"
116
+ )
117
+
118
+
119
+ def _get_threshold_message(pct: int) -> str:
120
+ if pct >= 95:
121
+ return (
122
+ f"@cost-limit: {pct}% budget used. "
123
+ "Complete current task and stop. Do NOT start new tasks."
124
+ )
125
+ if pct >= 80:
126
+ return (
127
+ f"@cost-critical: {pct}% budget used. "
128
+ "Be efficient \u2014 minimize unnecessary tool calls, "
129
+ "batch operations where possible."
130
+ )
131
+ return f"@cost-warning: {pct}% budget used"
132
+
133
+
134
+ def _read_thresholds_config(project_dir: str) -> list[int]:
135
+ try:
136
+ settings_path = os.path.join(project_dir, "settings.json")
137
+ with open(settings_path, "r", encoding="utf-8") as f:
138
+ settings = json.load(f)
139
+ raw = settings.get("_omg", {}).get("cost_budget", {}).get("thresholds")
140
+ if isinstance(raw, list) and all(isinstance(t, (int, float)) for t in raw):
141
+ return sorted(int(t) for t in raw)
142
+ except Exception:
143
+ pass
144
+ return list(DEFAULT_THRESHOLDS)
145
+
146
+
147
+ def _read_threshold_state(project_dir: str) -> dict[str, Any]:
148
+ path = os.path.join(project_dir, THRESHOLD_STATE_FILE)
149
+ try:
150
+ with open(path, "r", encoding="utf-8") as f:
151
+ return json.load(f)
152
+ except Exception:
153
+ return {"session_id": "", "fired": []}
154
+
155
+
156
+ def _write_threshold_state(project_dir: str, state: dict[str, Any]) -> None:
157
+ path = os.path.join(project_dir, THRESHOLD_STATE_FILE)
158
+ try:
159
+ os.makedirs(os.path.dirname(path), exist_ok=True)
160
+ with open(path, "w", encoding="utf-8") as f:
161
+ json.dump(state, f, separators=(",", ":"))
162
+ except Exception:
163
+ pass
164
+
165
+
166
+ def _check_thresholds(
167
+ used_pct: float, project_dir: str, session_id: str
168
+ ) -> list[str]:
169
+ thresholds = _read_thresholds_config(project_dir)
170
+ state = _read_threshold_state(project_dir)
171
+
172
+ if state.get("session_id", "") != session_id:
173
+ state = {"session_id": session_id, "fired": []}
174
+
175
+ already_fired = set(state.get("fired", []))
176
+ new_messages: list[str] = []
177
+ new_fired: list[int] = []
178
+
179
+ for threshold in thresholds:
180
+ if used_pct >= threshold and threshold not in already_fired:
181
+ new_messages.append(_get_threshold_message(threshold))
182
+ new_fired.append(threshold)
183
+
184
+ if new_fired:
185
+ state["fired"] = sorted(list(already_fired | set(new_fired)))
186
+ state["session_id"] = session_id
187
+ _write_threshold_state(project_dir, state)
188
+
189
+ return new_messages
190
+
191
+
192
+ def main() -> None:
193
+ setup_crash_handler("budget-governor", fail_closed=False)
194
+
195
+ payload = json_input()
196
+ if not get_feature_flag("COST_TRACKING", default=False):
197
+ sys.exit(0)
198
+
199
+ project_dir = get_project_dir()
200
+ session_limit_usd, input_per_mtok, output_per_mtok = _read_budget_config(project_dir)
201
+ summary = read_cost_summary(project_dir)
202
+
203
+ estimated_current_cost = _estimate_call_cost(
204
+ payload.get("tool_input", {}),
205
+ payload.get("tool_response", {}),
206
+ input_per_mtok,
207
+ output_per_mtok,
208
+ )
209
+
210
+ used_cost_usd = float(summary.get("total_cost_usd", 0.0)) + estimated_current_cost
211
+ used_calls = int(summary.get("entry_count", 0)) + 1
212
+ projected_calls = _project_total_calls(used_cost_usd, used_calls, session_limit_usd)
213
+
214
+ context = _build_context(
215
+ used_cost_usd=used_cost_usd,
216
+ session_limit_usd=session_limit_usd,
217
+ used_calls=used_calls,
218
+ projected_calls=projected_calls,
219
+ )
220
+
221
+ used_pct = (used_cost_usd / session_limit_usd * 100) if session_limit_usd > 0 else 0.0
222
+ session_id = os.environ.get("CLAUDE_SESSION_ID", "")
223
+ threshold_alerts = _check_thresholds(used_pct, project_dir, session_id)
224
+ if threshold_alerts:
225
+ context += "\n" + "\n".join(threshold_alerts)
226
+
227
+ json.dump({"additionalContext": context}, sys.stdout)
228
+ sys.exit(0)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()