@trac3er/oh-my-god 1.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 (229) hide show
  1. package/.claude-plugin/marketplace.json +36 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.claude-plugin/scripts/install.sh +49 -0
  4. package/.claude-plugin/scripts/uninstall.sh +80 -0
  5. package/.claude-plugin/scripts/update.sh +84 -0
  6. package/.mcp.json +20 -0
  7. package/LICENSE +21 -0
  8. package/OMG-setup.sh +1093 -0
  9. package/README.md +335 -0
  10. package/THIRD_PARTY_NOTICES.md +24 -0
  11. package/UPSTREAM_DIFF.md +20 -0
  12. package/agents/__init__.py +1 -0
  13. package/agents/_model_roles.yaml +26 -0
  14. package/agents/designer.md +67 -0
  15. package/agents/explore.md +60 -0
  16. package/agents/model_roles.py +196 -0
  17. package/agents/omg-api-builder.md +23 -0
  18. package/agents/omg-architect-mode.md +43 -0
  19. package/agents/omg-architect.md +13 -0
  20. package/agents/omg-backend-engineer.md +43 -0
  21. package/agents/omg-critic.md +16 -0
  22. package/agents/omg-database-engineer.md +43 -0
  23. package/agents/omg-escalation-router.md +17 -0
  24. package/agents/omg-executor.md +12 -0
  25. package/agents/omg-frontend-designer.md +42 -0
  26. package/agents/omg-implement-mode.md +50 -0
  27. package/agents/omg-infra-engineer.md +43 -0
  28. package/agents/omg-qa-tester.md +16 -0
  29. package/agents/omg-research-mode.md +43 -0
  30. package/agents/omg-security-auditor.md +43 -0
  31. package/agents/omg-testing-engineer.md +43 -0
  32. package/agents/plan.md +80 -0
  33. package/agents/quick_task.md +64 -0
  34. package/agents/reviewer.md +83 -0
  35. package/agents/task.md +71 -0
  36. package/commands/OMG:ccg.md +22 -0
  37. package/commands/OMG:compat.md +57 -0
  38. package/commands/OMG:crazy.md +125 -0
  39. package/commands/OMG:domain-init.md +11 -0
  40. package/commands/OMG:escalate.md +52 -0
  41. package/commands/OMG:health-check.md +45 -0
  42. package/commands/OMG:init.md +134 -0
  43. package/commands/OMG:mode.md +44 -0
  44. package/commands/OMG:project-init.md +11 -0
  45. package/commands/OMG:ralph-start.md +43 -0
  46. package/commands/OMG:ralph-stop.md +23 -0
  47. package/commands/OMG:teams.md +39 -0
  48. package/commands/ai-commit.md +113 -0
  49. package/commands/ccg.md +9 -0
  50. package/commands/create-agent.md +183 -0
  51. package/commands/omc-teams.md +9 -0
  52. package/commands/session-branch.md +85 -0
  53. package/commands/session-fork.md +53 -0
  54. package/commands/session-merge.md +134 -0
  55. package/commands/theme.md +44 -0
  56. package/config/lsp_languages.yaml +324 -0
  57. package/config/themes/catppuccin-frappe.yaml +14 -0
  58. package/config/themes/catppuccin-latte.yaml +14 -0
  59. package/config/themes/catppuccin-macchiato.yaml +14 -0
  60. package/config/themes/catppuccin-mocha.yaml +14 -0
  61. package/config/themes/dracula.yaml +14 -0
  62. package/config/themes/gruvbox-dark.yaml +14 -0
  63. package/config/themes/nord.yaml +14 -0
  64. package/config/themes/one-dark.yaml +14 -0
  65. package/config/themes/solarized-dark.yaml +14 -0
  66. package/config/themes/tokyo-night.yaml +14 -0
  67. package/control_plane/__init__.py +2 -0
  68. package/control_plane/openapi.yaml +109 -0
  69. package/control_plane/server.py +107 -0
  70. package/control_plane/service.py +148 -0
  71. package/crates/omg-natives/Cargo.toml +17 -0
  72. package/crates/omg-natives/src/clipboard.rs +5 -0
  73. package/crates/omg-natives/src/glob.rs +15 -0
  74. package/crates/omg-natives/src/grep.rs +15 -0
  75. package/crates/omg-natives/src/highlight.rs +15 -0
  76. package/crates/omg-natives/src/html.rs +14 -0
  77. package/crates/omg-natives/src/image.rs +5 -0
  78. package/crates/omg-natives/src/keys.rs +5 -0
  79. package/crates/omg-natives/src/lib.rs +36 -0
  80. package/crates/omg-natives/src/prof.rs +5 -0
  81. package/crates/omg-natives/src/ps.rs +5 -0
  82. package/crates/omg-natives/src/shell.rs +5 -0
  83. package/crates/omg-natives/src/task.rs +5 -0
  84. package/crates/omg-natives/src/text.rs +14 -0
  85. package/hooks/_agent_registry.py +421 -0
  86. package/hooks/_budget.py +31 -0
  87. package/hooks/_common.py +476 -0
  88. package/hooks/_learnings.py +126 -0
  89. package/hooks/_memory.py +103 -0
  90. package/hooks/circuit-breaker.py +270 -0
  91. package/hooks/config-guard.py +163 -0
  92. package/hooks/context_pressure.py +53 -0
  93. package/hooks/credential_store.py +801 -0
  94. package/hooks/fetch-rate-limits.py +212 -0
  95. package/hooks/firewall.py +48 -0
  96. package/hooks/hashline-formatter-bridge.py +224 -0
  97. package/hooks/hashline-injector.py +273 -0
  98. package/hooks/hashline-validator.py +216 -0
  99. package/hooks/idle-detector.py +95 -0
  100. package/hooks/intentgate-keyword-detector.py +188 -0
  101. package/hooks/magic-keyword-router.py +195 -0
  102. package/hooks/policy_engine.py +310 -0
  103. package/hooks/post-tool-failure.py +19 -0
  104. package/hooks/post-write.py +199 -0
  105. package/hooks/pre-compact.py +204 -0
  106. package/hooks/pre-tool-inject.py +98 -0
  107. package/hooks/prompt-enhancer.py +672 -0
  108. package/hooks/quality-runner.py +191 -0
  109. package/hooks/secret-guard.py +47 -0
  110. package/hooks/session-end-capture.py +137 -0
  111. package/hooks/session-start.py +275 -0
  112. package/hooks/shadow_manager.py +297 -0
  113. package/hooks/state_migration.py +209 -0
  114. package/hooks/stop-gate.py +7 -0
  115. package/hooks/stop_dispatcher.py +929 -0
  116. package/hooks/test-validator.py +138 -0
  117. package/hooks/todo-state-tracker.py +114 -0
  118. package/hooks/tool-ledger.py +126 -0
  119. package/hooks/trust_review.py +524 -0
  120. package/install.sh +9 -0
  121. package/omg_natives/__init__.py +186 -0
  122. package/omg_natives/_bindings.py +165 -0
  123. package/omg_natives/clipboard.py +36 -0
  124. package/omg_natives/glob.py +42 -0
  125. package/omg_natives/grep.py +61 -0
  126. package/omg_natives/highlight.py +54 -0
  127. package/omg_natives/html.py +157 -0
  128. package/omg_natives/image.py +51 -0
  129. package/omg_natives/keys.py +46 -0
  130. package/omg_natives/prof.py +39 -0
  131. package/omg_natives/ps.py +93 -0
  132. package/omg_natives/shell.py +58 -0
  133. package/omg_natives/task.py +41 -0
  134. package/omg_natives/text.py +50 -0
  135. package/package.json +26 -0
  136. package/plugins/README.md +82 -0
  137. package/plugins/advanced/commands/OMG:code-review.md +114 -0
  138. package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
  139. package/plugins/advanced/commands/OMG:handoff.md +115 -0
  140. package/plugins/advanced/commands/OMG:learn.md +110 -0
  141. package/plugins/advanced/commands/OMG:maintainer.md +31 -0
  142. package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
  143. package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
  144. package/plugins/advanced/commands/OMG:security-review.md +119 -0
  145. package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
  146. package/plugins/advanced/commands/OMG:ship.md +46 -0
  147. package/plugins/advanced/plugin.json +96 -0
  148. package/plugins/core/plugin.json +82 -0
  149. package/pytest.ini +5 -0
  150. package/registry/__init__.py +1 -0
  151. package/registry/verify_artifact.py +90 -0
  152. package/rules/contextual/architect-mode.md +9 -0
  153. package/rules/contextual/big-picture.md +20 -0
  154. package/rules/contextual/code-hygiene.md +26 -0
  155. package/rules/contextual/context-management.md +19 -0
  156. package/rules/contextual/context-minimization.md +32 -0
  157. package/rules/contextual/ddd-sdd.md +28 -0
  158. package/rules/contextual/dependency-safety.md +16 -0
  159. package/rules/contextual/doc-check.md +13 -0
  160. package/rules/contextual/implement-mode.md +9 -0
  161. package/rules/contextual/infra-safety.md +14 -0
  162. package/rules/contextual/outside-in.md +13 -0
  163. package/rules/contextual/persistent-mode.md +24 -0
  164. package/rules/contextual/research-mode.md +9 -0
  165. package/rules/contextual/security-domains.md +25 -0
  166. package/rules/contextual/vision-detection.md +27 -0
  167. package/rules/contextual/web-search.md +25 -0
  168. package/rules/contextual/write-verify.md +23 -0
  169. package/rules/core/00-truth.md +20 -0
  170. package/rules/core/01-surgical.md +19 -0
  171. package/rules/core/02-circuit-breaker.md +22 -0
  172. package/rules/core/03-ensemble.md +28 -0
  173. package/rules/core/04-testing.md +30 -0
  174. package/runtime/__init__.py +32 -0
  175. package/runtime/adapters/__init__.py +13 -0
  176. package/runtime/adapters/claude.py +60 -0
  177. package/runtime/adapters/gpt.py +53 -0
  178. package/runtime/adapters/local.py +53 -0
  179. package/runtime/business_workflow.py +220 -0
  180. package/runtime/compat.py +1299 -0
  181. package/runtime/custom_agent_loader.py +366 -0
  182. package/runtime/dispatcher.py +47 -0
  183. package/runtime/ecosystem.py +371 -0
  184. package/runtime/legacy_compat.py +7 -0
  185. package/runtime/omc_compat.py +7 -0
  186. package/runtime/omc_contract_snapshot.json +916 -0
  187. package/runtime/omg_compat_contract_snapshot.json +916 -0
  188. package/runtime/subagent_dispatcher.py +362 -0
  189. package/runtime/team_router.py +838 -0
  190. package/scripts/check-omc-contract-snapshot.py +12 -0
  191. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  192. package/scripts/check-omg-standalone-clean.py +102 -0
  193. package/scripts/legacy_to_omg_migrate.py +29 -0
  194. package/scripts/migrate-omc.py +464 -0
  195. package/scripts/omc_to_omg_migrate.py +12 -0
  196. package/scripts/omg.py +493 -0
  197. package/scripts/settings-merge.py +224 -0
  198. package/scripts/verify-no-omc.sh +5 -0
  199. package/scripts/verify-standalone.sh +21 -0
  200. package/templates/idea.yml +30 -0
  201. package/templates/policy.yaml +15 -0
  202. package/templates/profile.yaml +25 -0
  203. package/templates/runtime.yaml +12 -0
  204. package/templates/working-memory.md +17 -0
  205. package/tools/__init__.py +2 -0
  206. package/tools/browser_consent.py +289 -0
  207. package/tools/browser_stealth.py +481 -0
  208. package/tools/browser_tool.py +448 -0
  209. package/tools/changelog_generator.py +268 -0
  210. package/tools/commit_splitter.py +361 -0
  211. package/tools/config_discovery.py +151 -0
  212. package/tools/config_merger.py +449 -0
  213. package/tools/git_inspector.py +298 -0
  214. package/tools/lsp_client.py +275 -0
  215. package/tools/lsp_discovery.py +231 -0
  216. package/tools/lsp_operations.py +392 -0
  217. package/tools/python_repl.py +656 -0
  218. package/tools/python_sandbox.py +609 -0
  219. package/tools/search_providers/__init__.py +77 -0
  220. package/tools/search_providers/brave.py +115 -0
  221. package/tools/search_providers/exa.py +116 -0
  222. package/tools/search_providers/jina.py +104 -0
  223. package/tools/search_providers/perplexity.py +139 -0
  224. package/tools/search_providers/synthetic.py +74 -0
  225. package/tools/session_snapshot.py +736 -0
  226. package/tools/ssh_manager.py +912 -0
  227. package/tools/theme_engine.py +294 -0
  228. package/tools/theme_selector.py +137 -0
  229. package/tools/web_search.py +622 -0
@@ -0,0 +1,476 @@
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
+
196
+
197
+ def get_feature_flag(flag_name, default=True):
198
+ """Get feature flag value with resolution order: env var → settings.json → default.
199
+
200
+ Env var format: OMG_{FLAG_NAME.upper()}_ENABLED
201
+ Values: "0"/"false"/"no" → False, "1"/"true"/"yes" → True
202
+
203
+ Returns default on any error (missing settings.json, malformed JSON, etc).
204
+ """
205
+ # Check environment variable first
206
+ env_key = f"OMG_{flag_name.upper()}_ENABLED"
207
+ env_val = os.environ.get(env_key, "").lower()
208
+ if env_val in ("0", "false", "no"):
209
+ return False
210
+ if env_val in ("1", "true", "yes"):
211
+ return True
212
+
213
+ # Check settings.json (cached)
214
+ if not _FEATURE_CACHE:
215
+ try:
216
+ settings_path = os.path.join(get_project_dir(), "settings.json")
217
+ if os.path.exists(settings_path):
218
+ with open(settings_path, "r", encoding="utf-8") as f:
219
+ settings = json.load(f)
220
+ _FEATURE_CACHE.update(settings.get("_oal", {}).get("features", {}))
221
+ except Exception:
222
+ pass # Return default on any error
223
+
224
+ # Return from cache, or default
225
+ return _FEATURE_CACHE.get(flag_name, default)
226
+
227
+
228
+ # Permission mode helpers
229
+ BYPASS_MODES = frozenset({"bypasspermissions", "dontask"})
230
+
231
+
232
+ def is_bypass_mode(data):
233
+ """Return True if the hook input indicates permission prompts should be skipped.
234
+
235
+ Claude Code passes ``permission_mode`` in the hook input. When the user
236
+ enables *bypass permissions* or *don't ask* mode, hooks should still
237
+ enforce hard denials (critical safety) but must NOT emit ``ask`` decisions
238
+ that would re-introduce confirmation prompts.
239
+ """
240
+ if not isinstance(data, dict):
241
+ return False
242
+ mode = (data.get("permission_mode") or "").lower().strip()
243
+ return mode in BYPASS_MODES
244
+
245
+
246
+ # --- Subagent & Context-Limit Detection ---
247
+
248
+ # Stop hook feedback markers injected by Claude Code when a stop hook blocks
249
+ _STOP_HOOK_FEEDBACK_PREFIX = "Stop hook feedback:"
250
+
251
+
252
+ def should_skip_stop_hooks(data):
253
+ """Return True if stop hooks should exit immediately without blocking.
254
+
255
+ Detects four conditions:
256
+ 1. stop_hook_active flag (Claude Code's built-in re-entry guard)
257
+ 2. Stop hook feedback loop (previous block was already injected,
258
+ agent couldn't respond — blocking again is futile)
259
+ 3. Context-limit / rate-limit stop (blocking these prevents compaction
260
+ or creates infinite retry loops — must allow stop to proceed)
261
+ 4. File-based loop breaker (if hooks blocked >= 2 times within 90s,
262
+ agent cannot resolve — likely context-limited)
263
+
264
+ Safe for all stop hooks to call at the top of main().
265
+ """
266
+ if not isinstance(data, dict):
267
+ return False
268
+
269
+ # Guard 1: Claude Code's built-in re-entry prevention
270
+ if data.get("stop_hook_active", False):
271
+ return True
272
+
273
+ # Guard 3: Context-limit and rate-limit stop detection
274
+ # When context is exhausted, Claude Code needs to stop so it can compact.
275
+ # Blocking these stops causes a deadlock: can't compact because can't stop,
276
+ # can't continue because context is full.
277
+ # Similarly, rate-limit stops (429/quota) must not be blocked or they loop.
278
+ stop_reason = str(data.get("stop_reason", data.get("stopReason", ""))).lower()
279
+ end_turn_reason = str(data.get("end_turn_reason", data.get("endTurnReason", ""))).lower()
280
+ signal_text = " ".join(
281
+ str(data.get(k, ""))
282
+ for k in ("message", "error", "reason", "type", "event")
283
+ ).lower()
284
+ context_limit_markers = (
285
+ "context window",
286
+ "token limit",
287
+ "too much context",
288
+ "context length exceeded",
289
+ "maximum context length",
290
+ "prompt is too long",
291
+ "request too large",
292
+ "input too long",
293
+ "context_limit",
294
+ "context overflow",
295
+ )
296
+ if any(marker in signal_text for marker in context_limit_markers):
297
+ print(
298
+ "[OMG] Context limit detected: allowing stop so compaction can proceed. "
299
+ "If this repeats, run /OMG:handoff and resume from .omg/state/handoff.md.",
300
+ file=sys.stderr,
301
+ )
302
+ return True
303
+
304
+ # Guard 2: Check transcript for stop-hook feedback loop
305
+ # If the last user message is stop hook feedback, the hooks already
306
+ # blocked once and the agent tried (and failed) to respond.
307
+ # Blocking again creates an unrecoverable loop.
308
+ transcript_path = data.get("transcript_path", "")
309
+ if transcript_path and os.path.exists(transcript_path):
310
+ try:
311
+ last_user_text = ""
312
+ with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
313
+ for line in f:
314
+ line = line.strip()
315
+ if not line:
316
+ continue
317
+ try:
318
+ entry = json.loads(line)
319
+ except json.JSONDecodeError:
320
+ continue
321
+ if entry.get("type") == "user":
322
+ msg = entry.get("message", {})
323
+ content = msg.get("content", "")
324
+ if isinstance(content, str):
325
+ last_user_text = content
326
+ elif isinstance(content, list):
327
+ for block in content:
328
+ if isinstance(block, dict) and block.get("type") == "text":
329
+ last_user_text = block.get("text", "")
330
+ elif isinstance(block, str):
331
+ last_user_text = block
332
+ # If last user message is stop hook feedback, we're in a loop
333
+ if last_user_text.startswith(_STOP_HOOK_FEEDBACK_PREFIX):
334
+ print("[OMG] Guard 2 triggered: stop-hook feedback loop", file=sys.stderr)
335
+ return True
336
+ except Exception:
337
+ pass # Fail open — don't skip hooks on read errors
338
+
339
+ # Guard 4: File-based loop breaker (safety net)
340
+ # If stop hooks have blocked multiple times in quick succession,
341
+ # the agent cannot meaningfully resolve the issue (likely context-limited).
342
+ # This is the last-resort safety net when Guards 1-3 all fail to detect the loop.
343
+ if is_stop_block_loop():
344
+ print("[OMG] Guard 4 triggered: stop-block loop detected, skipping hooks", file=sys.stderr)
345
+ return True
346
+
347
+ # Guard 5: Empty stop_reason + recent block = likely context-limit deadlock
348
+ # Claude Code often doesn't set stop_reason/end_turn_reason for context-limit stops.
349
+ # If we blocked recently (any count >= 1 within window) AND stop_reason is missing,
350
+ # it's almost certainly a deadlock. Allow the stop to proceed.
351
+ if not stop_reason and not end_turn_reason:
352
+ try:
353
+ _pdir = get_project_dir()
354
+ _tracker_path = os.path.join(_pdir, _STOP_BLOCK_TRACKER)
355
+ if os.path.exists(_tracker_path):
356
+ with open(_tracker_path, "r", encoding="utf-8") as _f:
357
+ _state = json.load(_f)
358
+ _elapsed = (datetime.now(timezone.utc) - datetime.fromisoformat(_state["ts"])).total_seconds()
359
+ if _elapsed < _BLOCK_LOOP_WINDOW_SECS and _state.get("count", 0) >= 1:
360
+ _reason = _state.get("reason", "unknown")
361
+ if _reason in _LOOP_BLOCK_REASONS:
362
+ print(
363
+ "[OMG] Guard 5 triggered: context may be exhausted and stop hooks recently blocked. "
364
+ "Skipping stop-hook blocks so compaction can run. "
365
+ "Tip: /OMG:handoff then continue in a fresh session.",
366
+ file=sys.stderr,
367
+ )
368
+ return True
369
+ except Exception:
370
+ pass # fail open
371
+ return False
372
+
373
+
374
+ # --- Stop-Block Loop Breaker (file-based safety net) ---
375
+
376
+ def record_stop_block(project_dir=None, reason: str = "unknown", session_id: str = ""):
377
+ """Record that a stop hook block was issued. Called before block_decision().
378
+
379
+ Args:
380
+ project_dir: Project directory (auto-detected if None)
381
+ reason: Human-readable reason for the block (e.g., 'ralph_loop', 'planning_gate', 'quality_check')
382
+ session_id: Session identifier to prevent cross-session interference
383
+ """
384
+ try:
385
+ pdir = project_dir or get_project_dir()
386
+ path = os.path.join(pdir, _STOP_BLOCK_TRACKER)
387
+ state = {
388
+ "ts": datetime.now(timezone.utc).isoformat(),
389
+ "count": 1,
390
+ "session_id": session_id,
391
+ "reason": reason,
392
+ }
393
+ if os.path.exists(path):
394
+ try:
395
+ with open(path, "r", encoding="utf-8") as f:
396
+ old = json.load(f)
397
+ elapsed = (datetime.now(timezone.utc) - datetime.fromisoformat(old["ts"])).total_seconds()
398
+ if elapsed < _BLOCK_LOOP_WINDOW_SECS:
399
+ state["count"] = old.get("count", 0) + 1
400
+ # Preserve session_id and reason from old state if not overridden
401
+ if not session_id:
402
+ state["session_id"] = old.get("session_id", "")
403
+ if reason == "unknown":
404
+ state["reason"] = old.get("reason", "unknown")
405
+ # else: reset — old block is stale
406
+ except Exception:
407
+ pass # intentional: corrupt file, start fresh
408
+ atomic_json_write(path, state)
409
+ except Exception:
410
+ pass # intentional: never crash on tracking
411
+
412
+
413
+ def is_stop_block_loop(project_dir=None, session_id: str = ""):
414
+ """Return True if stop hooks have blocked repeatedly within the loop window.
415
+
416
+ Safety net for deadlocks: if hooks blocked >= N times within M seconds,
417
+ the agent clearly cannot resolve the issue (likely context-limited).
418
+ All stop hooks should allow the stop to proceed.
419
+
420
+ Args:
421
+ project_dir: Project directory (auto-detected if None)
422
+ session_id: Current session ID. If provided and tracker has a different session_id,
423
+ returns False (cross-session, not a loop).
424
+ """
425
+ try:
426
+ pdir = project_dir or get_project_dir()
427
+ path = os.path.join(pdir, _STOP_BLOCK_TRACKER)
428
+ if not os.path.exists(path):
429
+ return False
430
+ with open(path, "r", encoding="utf-8") as f:
431
+ state = json.load(f)
432
+
433
+ # Cross-session check: if tracker has session_id and it differs from current, not a loop
434
+ tracker_session_id = state.get("session_id", "")
435
+ if tracker_session_id and session_id and tracker_session_id != session_id:
436
+ return False # Different session, not a loop
437
+
438
+ ts = datetime.fromisoformat(state["ts"])
439
+ elapsed = (datetime.now(timezone.utc) - ts).total_seconds()
440
+ count = state.get("count", 0)
441
+ return elapsed < _BLOCK_LOOP_WINDOW_SECS and count >= _BLOCK_LOOP_THRESHOLD
442
+ except Exception:
443
+ return False # fail open — don't skip hooks on errors
444
+
445
+
446
+ def reset_stop_block_tracker(project_dir=None):
447
+ """Reset the stop block tracker. Called on clean (non-blocked) stop."""
448
+ try:
449
+ pdir = project_dir or get_project_dir()
450
+ path = os.path.join(pdir, _STOP_BLOCK_TRACKER)
451
+ if os.path.exists(path):
452
+ os.remove(path)
453
+ except Exception:
454
+ pass # intentional: never crash on cleanup
455
+
456
+
457
+ def check_performance_budget(hook_name: str, elapsed_ms: float, budget_ms: float) -> bool:
458
+ """Check if hook execution is within performance budget.
459
+
460
+ Args:
461
+ hook_name: Name of the hook being checked
462
+ elapsed_ms: Elapsed time in milliseconds
463
+ budget_ms: Budget threshold in milliseconds
464
+
465
+ Returns:
466
+ True if within budget, False if over budget (with warning logged)
467
+ """
468
+ if elapsed_ms <= budget_ms:
469
+ return True
470
+ # Log warning for budget overrun
471
+ log_hook_error(
472
+ hook_name,
473
+ f"Performance budget exceeded: {elapsed_ms:.1f}ms > {budget_ms}ms",
474
+ context={"elapsed_ms": elapsed_ms, "budget_ms": budget_ms}
475
+ )
476
+ return False
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Learnings storage utilities for OMG compound learning."""
3
+ import os
4
+ import glob
5
+ import re
6
+
7
+
8
+ def read_file_safe(path, max_bytes=4096):
9
+ """Safely read a file, returning empty string on error."""
10
+ try:
11
+ with open(path, 'r') as f:
12
+ return f.read(max_bytes)
13
+ except (OSError, IOError):
14
+ return ''
15
+
16
+
17
+ def aggregate_learnings(project_dir: str, max_patterns: int = 10) -> str:
18
+ """Read all learning files, aggregate top patterns into summary.
19
+
20
+ Returns formatted string with top tool patterns, max 500 chars.
21
+ """
22
+ learn_dir = os.path.join(project_dir, '.omg', 'state', 'learnings')
23
+ if not os.path.isdir(learn_dir):
24
+ return ''
25
+
26
+ all_tools = {} # tool -> total count across sessions
27
+ all_files = {} # file -> total count across sessions
28
+
29
+ for fname in os.listdir(learn_dir):
30
+ if not fname.endswith('.md'):
31
+ continue
32
+ content = read_file_safe(os.path.join(learn_dir, fname))
33
+ in_tools = False
34
+ in_files = False
35
+ for line in content.split('\n'):
36
+ if line.startswith('## Most Used Tools'):
37
+ in_tools = True
38
+ in_files = False
39
+ continue
40
+ if line.startswith('## Most Modified Files'):
41
+ in_tools = False
42
+ in_files = True
43
+ continue
44
+ if line.startswith('##'):
45
+ in_tools = False
46
+ in_files = False
47
+ continue
48
+ # Parse '- toolname: Nx' format
49
+ match = re.match(r'^-\s+(.+?):\s+(\d+)x\s*$', line.strip())
50
+ if match:
51
+ name = match.group(1).strip()
52
+ count = int(match.group(2))
53
+ if in_tools:
54
+ all_tools[name] = all_tools.get(name, 0) + count
55
+ elif in_files:
56
+ all_files[name] = all_files.get(name, 0) + count
57
+
58
+ return format_critical_patterns(all_tools, all_files, max_patterns)
59
+
60
+
61
+ def format_critical_patterns(tools: dict, files: dict, max_patterns: int = 10) -> str:
62
+ """Format tool and file patterns into critical-patterns summary.
63
+
64
+ Returns string ≤500 chars.
65
+ """
66
+ if not tools and not files:
67
+ return ''
68
+
69
+ lines = ['# Critical Patterns']
70
+
71
+ if tools:
72
+ lines.append('## Top Tools')
73
+ for tool, count in sorted(tools.items(), key=lambda x: -x[1])[:max_patterns]:
74
+ lines.append(f'- {tool}: {count}x total')
75
+
76
+ if files:
77
+ lines.append('## Top Files')
78
+ for fpath, count in sorted(files.items(), key=lambda x: -x[1])[:max_patterns]:
79
+ basename = os.path.basename(fpath)
80
+ lines.append(f'- {basename}: {count}x total')
81
+
82
+ result = '\n'.join(lines)
83
+ return result[:500] # Cap at 500 chars
84
+
85
+
86
+ def rotate_learnings(project_dir: str, max_files: int = 30) -> int:
87
+ """Delete oldest learning files if count exceeds max_files.
88
+
89
+ Returns number of files deleted.
90
+ """
91
+ learn_dir = os.path.join(project_dir, '.omg', 'state', 'learnings')
92
+ if not os.path.isdir(learn_dir):
93
+ return 0
94
+
95
+ files = sorted(glob.glob(os.path.join(learn_dir, '*.md')))
96
+ excess = len(files) - max_files
97
+ if excess <= 0:
98
+ return 0
99
+
100
+ for f in files[:excess]:
101
+ try:
102
+ os.remove(f)
103
+ except OSError:
104
+ pass
105
+ return excess
106
+
107
+
108
+ def save_critical_patterns(project_dir: str) -> str:
109
+ """Generate and save critical-patterns.md to .omg/knowledge/.
110
+
111
+ Returns the path of the written file, or empty string on failure.
112
+ """
113
+ content = aggregate_learnings(project_dir)
114
+ if not content:
115
+ return ''
116
+
117
+ knowledge_dir = os.path.join(project_dir, '.omg', 'knowledge')
118
+ os.makedirs(knowledge_dir, exist_ok=True)
119
+ path = os.path.join(knowledge_dir, 'critical-patterns.md')
120
+
121
+ try:
122
+ with open(path, 'w') as f:
123
+ f.write(content)
124
+ return path
125
+ except OSError:
126
+ return ''