@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,569 @@
1
+ """Shared utilities for OMG hooks. Pure stdlib — no external deps."""
2
+ import json
3
+ import os
4
+ import sys
5
+ import fcntl
6
+ from datetime import datetime, timezone
7
+
8
+ # --- Stop-Block Loop Breaker ---
9
+ _STOP_BLOCK_TRACKER = ".omg/state/ledger/.stop-block-tracker.json"
10
+ # Max seconds between blocks to consider it a loop
11
+ _BLOCK_LOOP_WINDOW_SECS = 30
12
+ # How many consecutive blocks before we skip
13
+ _BLOCK_LOOP_THRESHOLD = 2
14
+ # Block reasons that indicate a loop scenario (Guard 5 skip-eligible)
15
+ _LOOP_BLOCK_REASONS = {"planning_gate", "ralph_loop", "quality_check", "block_decision", "unknown"}
16
+
17
+ # --- Performance Budget Constants ---
18
+ PRE_TOOL_INJECT_MAX_MS = 100
19
+ STOP_CHECK_MAX_MS = 15000
20
+ STOP_DISPATCHER_TOTAL_MAX_MS = 90000
21
+
22
+ def json_input():
23
+ """Parse JSON from stdin. Returns dict or exits 0 on parse failure."""
24
+ try:
25
+ return json.load(sys.stdin)
26
+ except (json.JSONDecodeError, EOFError):
27
+ sys.exit(0)
28
+
29
+
30
+ def get_project_dir():
31
+ """Get project directory from env or cwd."""
32
+ return os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
33
+
34
+
35
+ def _resolve_project_dir():
36
+ """Get and validate project directory; warns if .omg/ missing."""
37
+ path = get_project_dir()
38
+ if not os.path.isdir(os.path.join(path, ".omg")):
39
+ print(f"[OMG] Warning: .omg/ not found in {path}", file=sys.stderr)
40
+ return path
41
+
42
+ def deny_decision(reason):
43
+ """Emit a PreToolUse deny decision to stdout."""
44
+ json.dump({
45
+ "hookSpecificOutput": {
46
+ "hookEventName": "PreToolUse",
47
+ "permissionDecision": "deny",
48
+ "permissionDecisionReason": reason,
49
+ }
50
+ }, sys.stdout)
51
+
52
+
53
+ def block_decision(reason):
54
+ """Emit a Stop hook block decision to stdout.
55
+
56
+ Also records the block for loop detection. Every stop hook that calls
57
+ block_decision() contributes to the loop breaker counter, so deadlocks
58
+ are detected regardless of which specific hook triggers the block.
59
+ """
60
+ # Record block BEFORE emitting -- ensures tracker is updated even if
61
+ # the process is killed after emitting the decision.
62
+ try:
63
+ record_stop_block()
64
+ except Exception:
65
+ pass # never let tracker failure prevent the block decision
66
+ json.dump({"decision": "block", "reason": reason}, sys.stdout)
67
+
68
+
69
+ def setup_crash_handler(hook_name, fail_closed=False):
70
+ """Install a crash handler that prevents non-zero exits.
71
+
72
+ fail_closed=True: emit deny on crash (for security hooks like firewall, secret-guard)
73
+ fail_closed=False: silently exit 0 (for non-security hooks)
74
+ """
75
+ def _excepthook(exc_type, exc_val, exc_tb):
76
+ print(f"OMG hook error ({hook_name}): {exc_val}", file=sys.stderr)
77
+ log_hook_error(hook_name, exc_val)
78
+ if fail_closed:
79
+ try:
80
+ deny_decision(f"OMG {hook_name} crash: {exc_val}. Denying for safety.")
81
+ except Exception:
82
+ pass
83
+ os._exit(0)
84
+ sys.excepthook = _excepthook
85
+
86
+
87
+ def read_file_safe(path, max_bytes=2000):
88
+ """Read file content safely, returning None on any failure."""
89
+ try:
90
+ if not os.path.exists(path):
91
+ return None
92
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
93
+ text = f.read(max_bytes).strip()
94
+ return text or None
95
+ except Exception:
96
+ return None
97
+
98
+
99
+ def log_hook_error(hook_name, error, context=None):
100
+ """Log hook error to .omg/state/ledger/hook-errors.jsonl with file locking.
101
+
102
+ Args:
103
+ hook_name: Name of the hook that errored
104
+ error: Exception or error message
105
+ context: Optional dict with additional context
106
+
107
+ Silently fails if logging cannot be completed (crash isolation).
108
+ """
109
+ try:
110
+ project_dir = get_project_dir()
111
+ ledger_dir = os.path.join(project_dir, ".omg", "state", "ledger")
112
+ os.makedirs(ledger_dir, exist_ok=True)
113
+
114
+ ledger_path = os.path.join(ledger_dir, "hook-errors.jsonl")
115
+
116
+ # Rotation: if file > 100KB, rename to .hook-errors.jsonl.1
117
+ try:
118
+ if os.path.exists(ledger_path):
119
+ size = os.path.getsize(ledger_path)
120
+ if size > 100 * 1024: # 100KB
121
+ archive = ledger_path + ".1"
122
+ if os.path.exists(archive):
123
+ try:
124
+ os.remove(archive)
125
+ except OSError:
126
+ pass
127
+ try:
128
+ os.rename(ledger_path, archive)
129
+ except OSError:
130
+ pass
131
+ except Exception:
132
+ pass
133
+
134
+ # Build entry
135
+ entry = {
136
+ "ts": datetime.now(timezone.utc).isoformat(),
137
+ "hook": hook_name,
138
+ "error": str(error),
139
+ }
140
+ if context:
141
+ entry["context"] = context
142
+
143
+ # Write with file locking
144
+ try:
145
+ fd = open(ledger_path, "a")
146
+ fcntl.flock(fd.fileno(), fcntl.LOCK_EX)
147
+ fd.write(json.dumps(entry, separators=(",", ":")) + "\n")
148
+ fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
149
+ fd.close()
150
+ except (ImportError, BlockingIOError):
151
+ # Fallback: write without locking
152
+ try:
153
+ with open(ledger_path, "a") as f:
154
+ f.write(json.dumps(entry, separators=(",", ":")) + "\n")
155
+ except Exception as e:
156
+ print(f"[OMG] _common.py: {type(e).__name__}: {e}", file=sys.stderr)
157
+ pass
158
+ except Exception as e:
159
+ print(f"[OMG] _common.py: {type(e).__name__}: {e}", file=sys.stderr)
160
+ pass
161
+ except Exception as e:
162
+ print(f"[OMG] _common.py: {type(e).__name__}: {e}", file=sys.stderr)
163
+ pass
164
+
165
+
166
+ def atomic_json_write(path, data):
167
+ """Atomically write JSON data to a file using temp + rename.
168
+
169
+ Args:
170
+ path: Target file path
171
+ data: Data to write as JSON
172
+
173
+ Creates parent directories if needed. Silently fails on error.
174
+ """
175
+ try:
176
+ # Create parent dirs
177
+ parent = os.path.dirname(path)
178
+ if parent:
179
+ os.makedirs(parent, exist_ok=True)
180
+
181
+ # Write to temp file
182
+ tmp_path = path + ".tmp"
183
+ with open(tmp_path, "w", encoding="utf-8") as f:
184
+ json.dump(data, f, separators=(",", ":"))
185
+
186
+ # Atomic rename
187
+ os.rename(tmp_path, path)
188
+ except Exception as e:
189
+ print(f"[OMG] _common.py: {type(e).__name__}: {e}", file=sys.stderr)
190
+ pass
191
+
192
+
193
+ # Feature flags cache — read settings.json once per hook invocation
194
+ _FEATURE_CACHE = {}
195
+ _SETTINGS_PRESET = None
196
+ _MANAGED_PRESET_FLAGS = {
197
+ "SETUP",
198
+ "SETUP_WIZARD",
199
+ "MEMORY_AUTOSTART",
200
+ "SESSION_ANALYTICS",
201
+ "CONTEXT_MANAGER",
202
+ "COST_TRACKING",
203
+ "MEMORY_SERVER",
204
+ "GIT_WORKFLOW",
205
+ "TEST_GENERATION",
206
+ "DEP_HEALTH",
207
+ "CODEBASE_VIZ",
208
+ }
209
+ _PRESET_FEATURES = {
210
+ "safe": {flag: False for flag in _MANAGED_PRESET_FLAGS},
211
+ "balanced": {
212
+ "SETUP": True,
213
+ "SETUP_WIZARD": True,
214
+ "MEMORY_AUTOSTART": True,
215
+ "SESSION_ANALYTICS": True,
216
+ "CONTEXT_MANAGER": True,
217
+ "COST_TRACKING": True,
218
+ "MEMORY_SERVER": False,
219
+ "GIT_WORKFLOW": False,
220
+ "TEST_GENERATION": False,
221
+ "DEP_HEALTH": False,
222
+ "CODEBASE_VIZ": False,
223
+ },
224
+ "interop": {
225
+ "SETUP": True,
226
+ "SETUP_WIZARD": True,
227
+ "MEMORY_AUTOSTART": True,
228
+ "SESSION_ANALYTICS": True,
229
+ "CONTEXT_MANAGER": True,
230
+ "COST_TRACKING": True,
231
+ "MEMORY_SERVER": True,
232
+ "GIT_WORKFLOW": False,
233
+ "TEST_GENERATION": False,
234
+ "DEP_HEALTH": False,
235
+ "CODEBASE_VIZ": False,
236
+ },
237
+ "labs": {
238
+ "SETUP": True,
239
+ "SETUP_WIZARD": True,
240
+ "MEMORY_AUTOSTART": True,
241
+ "SESSION_ANALYTICS": True,
242
+ "CONTEXT_MANAGER": True,
243
+ "COST_TRACKING": True,
244
+ "MEMORY_SERVER": True,
245
+ "GIT_WORKFLOW": True,
246
+ "TEST_GENERATION": True,
247
+ "DEP_HEALTH": True,
248
+ "CODEBASE_VIZ": True,
249
+ },
250
+ }
251
+ _FEATURE_ALIASES = {
252
+ "SETUP": ("SETUP", "SETUP_WIZARD"),
253
+ "SETUP_WIZARD": ("SETUP_WIZARD", "SETUP"),
254
+ }
255
+
256
+
257
+ def _load_feature_settings():
258
+ """Populate feature cache from settings.json and return the configured preset."""
259
+ global _SETTINGS_PRESET
260
+
261
+ _FEATURE_CACHE.clear()
262
+ _SETTINGS_PRESET = None
263
+ try:
264
+ settings_path = os.path.join(get_project_dir(), "settings.json")
265
+ if os.path.exists(settings_path):
266
+ with open(settings_path, "r", encoding="utf-8") as f:
267
+ settings = json.load(f)
268
+ omg = settings.get("_omg", {})
269
+ if isinstance(omg, dict):
270
+ features = omg.get("features", {})
271
+ if isinstance(features, dict):
272
+ _FEATURE_CACHE.update(features)
273
+ preset = omg.get("preset")
274
+ if isinstance(preset, str) and preset in _PRESET_FEATURES:
275
+ _SETTINGS_PRESET = preset
276
+ except Exception:
277
+ pass
278
+
279
+
280
+ def get_feature_flag(flag_name, default=True):
281
+ """Get feature flag value with resolution order: env var → settings.json → default.
282
+
283
+ Env var format: OMG_{FLAG_NAME.upper()}_ENABLED
284
+ Values: "0"/"false"/"no" → False, "1"/"true"/"yes" → True
285
+
286
+ Returns default on any error (missing settings.json, malformed JSON, etc).
287
+ """
288
+ # Check environment variable first
289
+ env_key = f"OMG_{flag_name.upper()}_ENABLED"
290
+ env_val = os.environ.get(env_key, "").lower()
291
+ if env_val in ("0", "false", "no"):
292
+ return False
293
+ if env_val in ("1", "true", "yes"):
294
+ return True
295
+
296
+ # Check settings.json (cached)
297
+ if not _FEATURE_CACHE:
298
+ _load_feature_settings()
299
+
300
+ env_preset = os.environ.get("OMG_PRESET", "").lower().strip()
301
+ lookup_names = _FEATURE_ALIASES.get(flag_name, (flag_name,))
302
+
303
+ # Env preset is a session-scoped override for managed flags.
304
+ if env_preset in _PRESET_FEATURES:
305
+ for name in lookup_names:
306
+ if name in _MANAGED_PRESET_FLAGS:
307
+ return _PRESET_FEATURES[env_preset].get(name, default)
308
+
309
+ for name in lookup_names:
310
+ if name in _FEATURE_CACHE:
311
+ return _FEATURE_CACHE[name]
312
+
313
+ if _SETTINGS_PRESET in _PRESET_FEATURES:
314
+ for name in lookup_names:
315
+ if name in _MANAGED_PRESET_FLAGS:
316
+ return _PRESET_FEATURES[_SETTINGS_PRESET].get(name, default)
317
+
318
+ return default
319
+
320
+
321
+ # Permission mode helpers
322
+ BYPASS_MODES = frozenset({"bypasspermissions", "dontask"})
323
+
324
+
325
+ def is_bypass_mode(data):
326
+ """Return True if the hook input indicates permission prompts should be skipped.
327
+
328
+ Claude Code passes ``permission_mode`` in the hook input. When the user
329
+ enables *bypass permissions* or *don't ask* mode, hooks should still
330
+ enforce hard denials (critical safety) but must NOT emit ``ask`` decisions
331
+ that would re-introduce confirmation prompts.
332
+ """
333
+ if not isinstance(data, dict):
334
+ return False
335
+ mode = (data.get("permission_mode") or "").lower().strip()
336
+ return mode in BYPASS_MODES
337
+
338
+
339
+ # --- Subagent & Context-Limit Detection ---
340
+
341
+ # Stop hook feedback markers injected by Claude Code when a stop hook blocks
342
+ _STOP_HOOK_FEEDBACK_PREFIX = "Stop hook feedback:"
343
+
344
+
345
+ def should_skip_stop_hooks(data):
346
+ """Return True if stop hooks should exit immediately without blocking.
347
+
348
+ Detects four conditions:
349
+ 1. stop_hook_active flag (Claude Code's built-in re-entry guard)
350
+ 2. Stop hook feedback loop (previous block was already injected,
351
+ agent couldn't respond — blocking again is futile)
352
+ 3. Context-limit / rate-limit stop (blocking these prevents compaction
353
+ or creates infinite retry loops — must allow stop to proceed)
354
+ 4. File-based loop breaker (if hooks blocked >= 2 times within 90s,
355
+ agent cannot resolve — likely context-limited)
356
+
357
+ Safe for all stop hooks to call at the top of main().
358
+ """
359
+ if not isinstance(data, dict):
360
+ return False
361
+
362
+ # Guard 1: Claude Code's built-in re-entry prevention
363
+ if data.get("stop_hook_active", False):
364
+ return True
365
+
366
+ # Guard 3: Context-limit and rate-limit stop detection
367
+ # When context is exhausted, Claude Code needs to stop so it can compact.
368
+ # Blocking these stops causes a deadlock: can't compact because can't stop,
369
+ # can't continue because context is full.
370
+ # Similarly, rate-limit stops (429/quota) must not be blocked or they loop.
371
+ stop_reason = str(data.get("stop_reason", data.get("stopReason", ""))).lower()
372
+ end_turn_reason = str(data.get("end_turn_reason", data.get("endTurnReason", ""))).lower()
373
+ signal_text = " ".join(
374
+ str(data.get(k, ""))
375
+ for k in ("message", "error", "reason", "type", "event")
376
+ ).lower()
377
+ context_limit_markers = (
378
+ "context window",
379
+ "token limit",
380
+ "too much context",
381
+ "context length exceeded",
382
+ "maximum context length",
383
+ "prompt is too long",
384
+ "request too large",
385
+ "input too long",
386
+ "context_limit",
387
+ "context overflow",
388
+ )
389
+ if any(marker in signal_text for marker in context_limit_markers):
390
+ print(
391
+ "[OMG] Context limit detected: allowing stop so compaction can proceed. "
392
+ "If this repeats, run /OMG:handoff and resume from .omg/state/handoff.md.",
393
+ file=sys.stderr,
394
+ )
395
+ return True
396
+
397
+ # Guard 2: Check transcript for stop-hook feedback loop
398
+ # If the last user message is stop hook feedback, the hooks already
399
+ # blocked once and the agent tried (and failed) to respond.
400
+ # Blocking again creates an unrecoverable loop.
401
+ transcript_path = data.get("transcript_path", "")
402
+ if transcript_path and os.path.exists(transcript_path):
403
+ try:
404
+ last_user_text = ""
405
+ with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
406
+ for line in f:
407
+ line = line.strip()
408
+ if not line:
409
+ continue
410
+ try:
411
+ entry = json.loads(line)
412
+ except json.JSONDecodeError:
413
+ continue
414
+ if entry.get("type") == "user":
415
+ msg = entry.get("message", {})
416
+ content = msg.get("content", "")
417
+ if isinstance(content, str):
418
+ last_user_text = content
419
+ elif isinstance(content, list):
420
+ for block in content:
421
+ if isinstance(block, dict) and block.get("type") == "text":
422
+ last_user_text = block.get("text", "")
423
+ elif isinstance(block, str):
424
+ last_user_text = block
425
+ # If last user message is stop hook feedback, we're in a loop
426
+ if last_user_text.startswith(_STOP_HOOK_FEEDBACK_PREFIX):
427
+ print("[OMG] Guard 2 triggered: stop-hook feedback loop", file=sys.stderr)
428
+ return True
429
+ except Exception:
430
+ pass # Fail open — don't skip hooks on read errors
431
+
432
+ # Guard 4: File-based loop breaker (safety net)
433
+ # If stop hooks have blocked multiple times in quick succession,
434
+ # the agent cannot meaningfully resolve the issue (likely context-limited).
435
+ # This is the last-resort safety net when Guards 1-3 all fail to detect the loop.
436
+ if is_stop_block_loop():
437
+ print("[OMG] Guard 4 triggered: stop-block loop detected, skipping hooks", file=sys.stderr)
438
+ return True
439
+
440
+ # Guard 5: Empty stop_reason + recent block = likely context-limit deadlock
441
+ # Claude Code often doesn't set stop_reason/end_turn_reason for context-limit stops.
442
+ # If we blocked recently (any count >= 1 within window) AND stop_reason is missing,
443
+ # it's almost certainly a deadlock. Allow the stop to proceed.
444
+ if not stop_reason and not end_turn_reason:
445
+ try:
446
+ _pdir = get_project_dir()
447
+ _tracker_path = os.path.join(_pdir, _STOP_BLOCK_TRACKER)
448
+ if os.path.exists(_tracker_path):
449
+ with open(_tracker_path, "r", encoding="utf-8") as _f:
450
+ _state = json.load(_f)
451
+ _elapsed = (datetime.now(timezone.utc) - datetime.fromisoformat(_state["ts"])).total_seconds()
452
+ if _elapsed < _BLOCK_LOOP_WINDOW_SECS and _state.get("count", 0) >= 1:
453
+ _reason = _state.get("reason", "unknown")
454
+ if _reason in _LOOP_BLOCK_REASONS:
455
+ print(
456
+ "[OMG] Guard 5 triggered: context may be exhausted and stop hooks recently blocked. "
457
+ "Skipping stop-hook blocks so compaction can run. "
458
+ "Tip: /OMG:handoff then continue in a fresh session.",
459
+ file=sys.stderr,
460
+ )
461
+ return True
462
+ except Exception:
463
+ pass # fail open
464
+ return False
465
+
466
+
467
+ # --- Stop-Block Loop Breaker (file-based safety net) ---
468
+
469
+ def record_stop_block(project_dir=None, reason: str = "unknown", session_id: str = ""):
470
+ """Record that a stop hook block was issued. Called before block_decision().
471
+
472
+ Args:
473
+ project_dir: Project directory (auto-detected if None)
474
+ reason: Human-readable reason for the block (e.g., 'ralph_loop', 'planning_gate', 'quality_check')
475
+ session_id: Session identifier to prevent cross-session interference
476
+ """
477
+ try:
478
+ pdir = project_dir or get_project_dir()
479
+ path = os.path.join(pdir, _STOP_BLOCK_TRACKER)
480
+ state = {
481
+ "ts": datetime.now(timezone.utc).isoformat(),
482
+ "count": 1,
483
+ "session_id": session_id,
484
+ "reason": reason,
485
+ }
486
+ if os.path.exists(path):
487
+ try:
488
+ with open(path, "r", encoding="utf-8") as f:
489
+ old = json.load(f)
490
+ elapsed = (datetime.now(timezone.utc) - datetime.fromisoformat(old["ts"])).total_seconds()
491
+ if elapsed < _BLOCK_LOOP_WINDOW_SECS:
492
+ state["count"] = old.get("count", 0) + 1
493
+ # Preserve session_id and reason from old state if not overridden
494
+ if not session_id:
495
+ state["session_id"] = old.get("session_id", "")
496
+ if reason == "unknown":
497
+ state["reason"] = old.get("reason", "unknown")
498
+ # else: reset — old block is stale
499
+ except Exception:
500
+ pass # intentional: corrupt file, start fresh
501
+ atomic_json_write(path, state)
502
+ except Exception:
503
+ pass # intentional: never crash on tracking
504
+
505
+
506
+ def is_stop_block_loop(project_dir=None, session_id: str = ""):
507
+ """Return True if stop hooks have blocked repeatedly within the loop window.
508
+
509
+ Safety net for deadlocks: if hooks blocked >= N times within M seconds,
510
+ the agent clearly cannot resolve the issue (likely context-limited).
511
+ All stop hooks should allow the stop to proceed.
512
+
513
+ Args:
514
+ project_dir: Project directory (auto-detected if None)
515
+ session_id: Current session ID. If provided and tracker has a different session_id,
516
+ returns False (cross-session, not a loop).
517
+ """
518
+ try:
519
+ pdir = project_dir or get_project_dir()
520
+ path = os.path.join(pdir, _STOP_BLOCK_TRACKER)
521
+ if not os.path.exists(path):
522
+ return False
523
+ with open(path, "r", encoding="utf-8") as f:
524
+ state = json.load(f)
525
+
526
+ # Cross-session check: if tracker has session_id and it differs from current, not a loop
527
+ tracker_session_id = state.get("session_id", "")
528
+ if tracker_session_id and session_id and tracker_session_id != session_id:
529
+ return False # Different session, not a loop
530
+
531
+ ts = datetime.fromisoformat(state["ts"])
532
+ elapsed = (datetime.now(timezone.utc) - ts).total_seconds()
533
+ count = state.get("count", 0)
534
+ return elapsed < _BLOCK_LOOP_WINDOW_SECS and count >= _BLOCK_LOOP_THRESHOLD
535
+ except Exception:
536
+ return False # fail open — don't skip hooks on errors
537
+
538
+
539
+ def reset_stop_block_tracker(project_dir=None):
540
+ """Reset the stop block tracker. Called on clean (non-blocked) stop."""
541
+ try:
542
+ pdir = project_dir or get_project_dir()
543
+ path = os.path.join(pdir, _STOP_BLOCK_TRACKER)
544
+ if os.path.exists(path):
545
+ os.remove(path)
546
+ except Exception:
547
+ pass # intentional: never crash on cleanup
548
+
549
+
550
+ def check_performance_budget(hook_name: str, elapsed_ms: float, budget_ms: float) -> bool:
551
+ """Check if hook execution is within performance budget.
552
+
553
+ Args:
554
+ hook_name: Name of the hook being checked
555
+ elapsed_ms: Elapsed time in milliseconds
556
+ budget_ms: Budget threshold in milliseconds
557
+
558
+ Returns:
559
+ True if within budget, False if over budget (with warning logged)
560
+ """
561
+ if elapsed_ms <= budget_ms:
562
+ return True
563
+ # Log warning for budget overrun
564
+ log_hook_error(
565
+ hook_name,
566
+ f"Performance budget exceeded: {elapsed_ms:.1f}ms > {budget_ms}ms",
567
+ context={"elapsed_ms": elapsed_ms, "budget_ms": budget_ms}
568
+ )
569
+ return False
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Compression guideline optimizer from compression feedback JSONL."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections import Counter
8
+ from datetime import datetime, timezone
9
+
10
+ from ._common import get_feature_flag
11
+
12
+
13
+ def _new_guidelines() -> dict[str, object]:
14
+ return {
15
+ "generated_at": datetime.now(timezone.utc).isoformat(),
16
+ "always_keep": [],
17
+ "prefer_keep": [],
18
+ "compress_ok": [],
19
+ "drop_ok": [],
20
+ }
21
+
22
+
23
+ def _is_failure_entry(entry: object) -> bool:
24
+ if not isinstance(entry, dict):
25
+ return False
26
+ if entry.get("failed") is True:
27
+ return True
28
+ if entry.get("success") is False:
29
+ return True
30
+ status = str(entry.get("status", "")).strip().lower()
31
+ if status in {"failed", "failure", "error"}:
32
+ return True
33
+ outcome = str(entry.get("outcome", "")).strip().lower()
34
+ return outcome in {"failed", "failure", "error"}
35
+
36
+
37
+ def _coerce_items(value: object) -> list[str]:
38
+ if not isinstance(value, list):
39
+ return []
40
+ items: list[str] = []
41
+ for raw in value:
42
+ if not isinstance(raw, str):
43
+ continue
44
+ item = raw.strip()
45
+ if item:
46
+ items.append(item)
47
+ return items
48
+
49
+
50
+ def _extract_dropped_items(entry: object) -> list[str]:
51
+ if not isinstance(entry, dict):
52
+ return []
53
+ for key in ("dropped_items", "dropped", "items_dropped", "dropped_context"):
54
+ items = _coerce_items(entry.get(key))
55
+ if items:
56
+ return items
57
+ return []
58
+
59
+
60
+ def _read_feedback(path: str) -> tuple[Counter[str], set[str]]:
61
+ failure_counts: Counter[str] = Counter()
62
+ all_dropped_items: set[str] = set()
63
+
64
+ if not path or not os.path.exists(path):
65
+ return failure_counts, all_dropped_items
66
+
67
+ try:
68
+ with open(path, "r", encoding="utf-8") as handle:
69
+ for line in handle:
70
+ raw = line.strip()
71
+ if not raw:
72
+ continue
73
+ try:
74
+ entry = json.loads(raw)
75
+ except json.JSONDecodeError:
76
+ continue
77
+
78
+ dropped_items = _extract_dropped_items(entry)
79
+ if not dropped_items:
80
+ continue
81
+
82
+ all_dropped_items.update(dropped_items)
83
+ if _is_failure_entry(entry):
84
+ failure_counts.update(set(dropped_items))
85
+ except OSError:
86
+ return Counter(), set()
87
+
88
+ return failure_counts, all_dropped_items
89
+
90
+
91
+ def optimize_guidelines(feedback_path: str, output_path: str) -> dict[str, object]:
92
+ if not get_feature_flag("CONTEXT_MANAGER", default=False):
93
+ return _new_guidelines()
94
+
95
+ guidelines = _new_guidelines()
96
+ failure_counts, all_items = _read_feedback(feedback_path)
97
+
98
+ always_keep = sorted(item for item, count in failure_counts.items() if count >= 3)
99
+ prefer_keep = sorted(item for item, count in failure_counts.items() if 1 <= count <= 2)
100
+ compress_ok = sorted(item for item in all_items if failure_counts.get(item, 0) == 0)
101
+
102
+ guidelines["always_keep"] = always_keep
103
+ guidelines["prefer_keep"] = prefer_keep
104
+ guidelines["compress_ok"] = compress_ok
105
+
106
+ if output_path:
107
+ try:
108
+ parent = os.path.dirname(output_path)
109
+ if parent:
110
+ os.makedirs(parent, exist_ok=True)
111
+ with open(output_path, "w", encoding="utf-8") as handle:
112
+ json.dump(guidelines, handle, indent=2)
113
+ except OSError:
114
+ pass
115
+
116
+ return guidelines
117
+
118
+
119
+ __all__ = ["optimize_guidelines"]