@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,212 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fetch Claude rate limits from Anthropic OAuth API and cache for HUD.
4
+
5
+ Reads OAuth credentials from:
6
+ - macOS: Keychain "Claude Code-credentials" (format: {claudeAiOauth: {...}})
7
+ - Linux/fallback: ~/.claude/.credentials.json
8
+
9
+ Caches to: ~/.claude/omg-runtime/.usage-cache.json
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import ssl
15
+ import subprocess
16
+ import sys
17
+ import urllib.error
18
+ import urllib.request
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+
23
+ def get_claude_config_dir():
24
+ """Get Claude config directory."""
25
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
26
+
27
+
28
+ def get_cache_path():
29
+ """Get cache file path."""
30
+ return get_claude_config_dir() / "omg-runtime" / ".usage-cache.json"
31
+
32
+
33
+ def read_credentials_from_keychain():
34
+ """Read OAuth credentials from macOS Keychain."""
35
+ try:
36
+ result = subprocess.run(
37
+ ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=5
41
+ )
42
+ if result.returncode == 0 and result.stdout.strip():
43
+ data = json.loads(result.stdout.strip())
44
+ # Claude Code stores credentials under 'claudeAiOauth' key
45
+ if "claudeAiOauth" in data:
46
+ return data["claudeAiOauth"]
47
+ return data
48
+ except Exception:
49
+ pass
50
+ return None
51
+
52
+
53
+ def read_credentials_from_file():
54
+ """Read OAuth credentials from file."""
55
+ creds_path = get_claude_config_dir() / ".credentials.json"
56
+ try:
57
+ if creds_path.exists():
58
+ data = json.loads(creds_path.read_text())
59
+ # Handle nested format if present
60
+ if "claudeAiOauth" in data:
61
+ return data["claudeAiOauth"]
62
+ return data
63
+ except Exception:
64
+ pass
65
+ return None
66
+
67
+
68
+ def read_credentials():
69
+ """Read OAuth credentials from keychain or file."""
70
+ # Try keychain first (macOS)
71
+ creds = read_credentials_from_keychain()
72
+ if creds and creds.get("accessToken"):
73
+ return creds
74
+
75
+ # Fall back to file
76
+ creds = read_credentials_from_file()
77
+ if creds and creds.get("accessToken"):
78
+ return creds
79
+
80
+ return None
81
+
82
+
83
+ def fetch_usage(credentials):
84
+ """Fetch usage from Anthropic API."""
85
+ access_token = credentials.get("accessToken")
86
+ if not access_token:
87
+ return None
88
+
89
+ # Create HTTPS request
90
+ ctx = ssl.create_default_context()
91
+ req = urllib.request.Request(
92
+ "https://api.anthropic.com/api/oauth/usage",
93
+ headers={
94
+ "Authorization": f"Bearer {access_token}",
95
+ "anthropic-beta": "oauth-2025-04-20", # Required for OAuth API access
96
+ "Accept": "application/json"
97
+ }
98
+ )
99
+
100
+ try:
101
+ with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
102
+ data = json.loads(resp.read().decode())
103
+
104
+ # Parse response into RateLimits format
105
+ rate_limits = {}
106
+
107
+ # Five hour (session) limit - API returns percentage 0-100 directly
108
+ five_hour = data.get("five_hour", {})
109
+ if five_hour and "utilization" in five_hour:
110
+ rate_limits["fiveHourPercent"] = float(five_hour["utilization"])
111
+ if five_hour.get("resets_at"):
112
+ rate_limits["fiveHourResetsAt"] = five_hour["resets_at"]
113
+
114
+ # Seven day (weekly) limit - API returns percentage 0-100 directly
115
+ seven_day = data.get("seven_day", {})
116
+ if seven_day and "utilization" in seven_day:
117
+ rate_limits["weeklyPercent"] = float(seven_day["utilization"])
118
+ if seven_day.get("resets_at"):
119
+ rate_limits["weeklyResetsAt"] = seven_day["resets_at"]
120
+
121
+ # Per-model quotas
122
+ sonnet = data.get("seven_day_sonnet", {})
123
+ if sonnet and "utilization" in sonnet:
124
+ rate_limits["sonnetWeeklyPercent"] = float(sonnet["utilization"])
125
+ if sonnet.get("resets_at"):
126
+ rate_limits["sonnetWeeklyResetsAt"] = sonnet["resets_at"]
127
+
128
+ opus = data.get("seven_day_opus", {})
129
+ if opus and "utilization" in opus:
130
+ rate_limits["opusWeeklyPercent"] = float(opus["utilization"])
131
+ if opus.get("resets_at"):
132
+ rate_limits["opusWeeklyResetsAt"] = opus["resets_at"]
133
+
134
+ return rate_limits
135
+
136
+ except urllib.error.HTTPError as e:
137
+ if e.code == 401:
138
+ # Token expired or invalid
139
+ pass
140
+ return None
141
+ except Exception:
142
+ return None
143
+
144
+
145
+ def write_cache(rate_limits):
146
+ """Write rate limits to cache file."""
147
+ cache_path = get_cache_path()
148
+ cache_dir = cache_path.parent
149
+
150
+ try:
151
+ cache_dir.mkdir(parents=True, exist_ok=True)
152
+
153
+ cache_data = {
154
+ "timestamp": datetime.now(timezone.utc).isoformat(),
155
+ "data": rate_limits,
156
+ "source": "anthropic"
157
+ }
158
+
159
+ # Write to temp file then rename for atomicity
160
+ temp_path = cache_path.with_suffix(".tmp")
161
+ temp_path.write_text(json.dumps(cache_data, indent=2))
162
+ temp_path.rename(cache_path)
163
+
164
+ return True
165
+ except Exception:
166
+ return False
167
+
168
+
169
+ def read_existing_cache():
170
+ """Read existing cache if present."""
171
+ cache_path = get_cache_path()
172
+ try:
173
+ if cache_path.exists():
174
+ return json.loads(cache_path.read_text())
175
+ except Exception:
176
+ pass
177
+ return None
178
+
179
+
180
+ def main():
181
+ """Main entry point."""
182
+ # Check if cache is fresh (less than 30 seconds old)
183
+ existing = read_existing_cache()
184
+ if existing:
185
+ try:
186
+ cached_time = datetime.fromisoformat(existing.get("timestamp", ""))
187
+ age = (datetime.now(timezone.utc) - cached_time).total_seconds()
188
+ if age < 30:
189
+ # Cache is fresh, nothing to do
190
+ sys.exit(0)
191
+ except Exception:
192
+ pass
193
+
194
+ # Read credentials
195
+ credentials = read_credentials()
196
+ if not credentials:
197
+ sys.exit(0) # Silent exit if no credentials
198
+
199
+ # Fetch usage
200
+ rate_limits = fetch_usage(credentials)
201
+ if not rate_limits:
202
+ sys.exit(0) # Silent exit on API error
203
+
204
+ # Write cache
205
+ if write_cache(rate_limits):
206
+ print(f"[OMG] Rate limits updated: daily={rate_limits.get('fiveHourPercent', 'N/A')}%, weekly={rate_limits.get('weeklyPercent', 'N/A')}%")
207
+
208
+ sys.exit(0)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse Hook (Bash): Command Firewall (Enterprise)
3
+
4
+ Delegates policy logic to policy_engine.py so all command decisions are driven by
5
+ one centralized decision model.
6
+ """
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ HOOKS_DIR = os.path.dirname(__file__)
12
+ if HOOKS_DIR not in sys.path:
13
+ sys.path.insert(0, HOOKS_DIR)
14
+
15
+ from _common import setup_crash_handler, json_input, deny_decision, is_bypass_mode
16
+
17
+ # Fail-closed: deny on crash (security hook)
18
+ setup_crash_handler("firewall", fail_closed=True)
19
+
20
+ try:
21
+ from policy_engine import evaluate_bash_command, to_pretool_hook_output
22
+ except Exception as _import_err:
23
+ print(f"OMG firewall: policy_engine import failed: {_import_err}", file=sys.stderr)
24
+ deny_decision(f"OMG firewall crash: policy_engine import failed: {_import_err}. Denying for safety.")
25
+ sys.exit(0)
26
+
27
+ data = json_input()
28
+
29
+ tool = data.get("tool_name", "")
30
+ if tool != "Bash":
31
+ sys.exit(0)
32
+
33
+ cmd = data.get("tool_input", {}).get("command", "")
34
+ if not cmd:
35
+ sys.exit(0)
36
+
37
+ decision = evaluate_bash_command(cmd)
38
+
39
+ # In bypass-permission mode, only enforce hard denials (critical safety).
40
+ # Skip "ask" decisions so the user is not prompted for confirmation.
41
+ if is_bypass_mode(data) and decision.action != "deny":
42
+ sys.exit(0)
43
+
44
+ out = to_pretool_hook_output(decision)
45
+ if out:
46
+ json.dump(out, sys.stdout)
47
+
48
+ sys.exit(0)
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """Hashline Formatter Bridge — reconciles sidecar hash cache after formatters run.
3
+
4
+ When a post-write formatter (e.g. prettier, ruff format) modifies a file,
5
+ the cached line hashes become stale. This bridge detects the change and
6
+ refreshes the sidecar cache so subsequent reads/edits use correct anchors.
7
+
8
+ Feature flag: OMG_HASHLINE_ENABLED (default: False)
9
+ """
10
+ import json
11
+ import os
12
+ import sys
13
+
14
+ HOOKS_DIR = os.path.dirname(__file__)
15
+ if HOOKS_DIR not in sys.path:
16
+ sys.path.insert(0, HOOKS_DIR)
17
+
18
+ from _common import (
19
+ setup_crash_handler,
20
+ json_input,
21
+ get_feature_flag,
22
+ )
23
+
24
+ setup_crash_handler("hashline-formatter-bridge")
25
+
26
+
27
+ # --- Feature Flag ---
28
+
29
+
30
+ def _is_enabled() -> bool:
31
+ """Check if hashline features are enabled.
32
+
33
+ Resolution order: OMG_HASHLINE_ENABLED env var -> settings.json -> False
34
+ """
35
+ env_val = os.environ.get("OMG_HASHLINE_ENABLED", "").lower()
36
+ if env_val in ("1", "true", "yes"):
37
+ return True
38
+ if env_val in ("0", "false", "no"):
39
+ return False
40
+ return get_feature_flag("HASHLINE", default=False)
41
+
42
+
43
+ # --- Lazy Imports from hashline-injector ---
44
+
45
+ _injector = None
46
+
47
+
48
+ def _get_injector():
49
+ """Lazy-load hashline-injector module."""
50
+ global _injector
51
+ if _injector is None:
52
+ import importlib
53
+ _injector = importlib.import_module("hashline-injector")
54
+ return _injector
55
+
56
+
57
+ # --- Core Functions ---
58
+
59
+
60
+ def detect_formatter_change(file_path: str, original_content: str, formatted_content: str) -> bool:
61
+ """Return True if the formatter changed the content.
62
+
63
+ Compares stripped versions of each line to ignore trivial
64
+ trailing-whitespace-only differences while still detecting
65
+ real structural changes.
66
+
67
+ Args:
68
+ file_path: Path to the file (for context, not read).
69
+ original_content: Content before formatting.
70
+ formatted_content: Content after formatting.
71
+
72
+ Returns:
73
+ True if formatted_content differs from original_content.
74
+ """
75
+ if original_content == formatted_content:
76
+ return False
77
+
78
+ # Compare stripped lines to ignore trivial whitespace diffs
79
+ orig_lines = [l.rstrip() for l in original_content.split("\n")]
80
+ fmt_lines = [l.rstrip() for l in formatted_content.split("\n")]
81
+ return orig_lines != fmt_lines
82
+
83
+
84
+ def refresh_cache_after_format(file_path: str, formatted_content: str) -> bool:
85
+ """Recompute and save line hashes for newly formatted content.
86
+
87
+ Args:
88
+ file_path: Path to the formatted file.
89
+ formatted_content: The file content after formatting.
90
+
91
+ Returns:
92
+ True on success (or when feature is disabled), False on error.
93
+ """
94
+ if not _is_enabled():
95
+ return True
96
+
97
+ try:
98
+ injector = _get_injector()
99
+ _line_hash_id = injector._line_hash_id
100
+ _cache_hashes = injector._cache_hashes
101
+ except Exception:
102
+ return False
103
+
104
+ try:
105
+ lines = formatted_content.split("\n")
106
+ line_hashes = {}
107
+ for i, line in enumerate(lines, start=1):
108
+ line_hashes[str(i)] = _line_hash_id(line)
109
+
110
+ _cache_hashes(file_path, line_hashes)
111
+ return True
112
+ except Exception:
113
+ return False
114
+
115
+
116
+ def reconcile_post_format(file_path: str) -> dict:
117
+ """Reconcile hash cache with the current file on disk.
118
+
119
+ Reads the file, checks whether the cached mtime is stale
120
+ (indicating a formatter ran after the last cache write),
121
+ and refreshes the cache if needed.
122
+
123
+ Args:
124
+ file_path: Path to the file to reconcile.
125
+
126
+ Returns:
127
+ dict with reconciliation result:
128
+ - ``{"skipped": True}`` when feature is disabled
129
+ - ``{"refreshed": True/False, "lines_updated": int, "file": str}``
130
+ """
131
+ if not _is_enabled():
132
+ return {"skipped": True}
133
+
134
+ try:
135
+ injector = _get_injector()
136
+ _get_cached_hashes = injector._get_cached_hashes
137
+ _line_hash_id = injector._line_hash_id
138
+ _cache_hashes = injector._cache_hashes
139
+ except Exception:
140
+ return {"refreshed": False, "lines_updated": 0, "file": file_path}
141
+
142
+ try:
143
+ abs_path = os.path.abspath(file_path)
144
+ if not os.path.exists(abs_path):
145
+ return {"refreshed": False, "lines_updated": 0, "file": file_path}
146
+
147
+ # Read current content from disk
148
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
149
+ content = f.read()
150
+
151
+ # Check if cache exists — if _get_cached_hashes returns None,
152
+ # cache is either missing or mtime doesn't match (formatter ran).
153
+ cached = _get_cached_hashes(file_path)
154
+ if cached is not None:
155
+ # Cache is still valid (mtime matches) — no refresh needed
156
+ return {"refreshed": False, "lines_updated": 0, "file": file_path}
157
+
158
+ # Cache is stale or missing — refresh
159
+ lines = content.split("\n")
160
+ line_hashes = {}
161
+ for i, line in enumerate(lines, start=1):
162
+ line_hashes[str(i)] = _line_hash_id(line)
163
+
164
+ _cache_hashes(file_path, line_hashes)
165
+ return {"refreshed": True, "lines_updated": len(lines), "file": file_path}
166
+ except Exception:
167
+ return {"refreshed": False, "lines_updated": 0, "file": file_path}
168
+
169
+
170
+ # --- Write-tool names that trigger reconciliation ---
171
+
172
+ _WRITE_TOOLS = frozenset({
173
+ "Write", "Edit", "MultiEdit",
174
+ "mcp__filesystem__write_file",
175
+ "mcp__filesystem__edit_file",
176
+ })
177
+
178
+
179
+ # --- Hook Entry Point ---
180
+
181
+
182
+ def main():
183
+ """PostToolUse hook stdin/stdout entry point.
184
+
185
+ Reads JSON from stdin::
186
+
187
+ {"tool_name": "Write", "tool_input": {"file_path": "..."}}
188
+
189
+ If tool_name is a write tool and OMG_HASHLINE_ENABLED is set,
190
+ runs ``reconcile_post_format`` to refresh the hash cache after
191
+ any post-write formatter may have modified the file.
192
+
193
+ Always exits 0 — never raises.
194
+ """
195
+ if not _is_enabled():
196
+ sys.exit(0)
197
+
198
+ data = json_input()
199
+ if not isinstance(data, dict):
200
+ sys.exit(0)
201
+
202
+ tool_name = data.get("tool_name", "")
203
+ if tool_name not in _WRITE_TOOLS:
204
+ sys.exit(0)
205
+
206
+ tool_input = data.get("tool_input", {})
207
+ if not isinstance(tool_input, dict):
208
+ sys.exit(0)
209
+
210
+ file_path = tool_input.get("file_path", tool_input.get("filePath", ""))
211
+ if not file_path:
212
+ sys.exit(0)
213
+
214
+ try:
215
+ result = reconcile_post_format(file_path)
216
+ json.dump(result, sys.stdout)
217
+ except Exception:
218
+ pass # Graceful degradation — never crash
219
+
220
+ sys.exit(0)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()