@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.
- package/.claude-plugin/marketplace.json +36 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.claude-plugin/scripts/install.sh +49 -0
- package/.claude-plugin/scripts/uninstall.sh +80 -0
- package/.claude-plugin/scripts/update.sh +84 -0
- package/.mcp.json +20 -0
- package/LICENSE +21 -0
- package/OMG-setup.sh +1093 -0
- package/README.md +335 -0
- package/THIRD_PARTY_NOTICES.md +24 -0
- package/UPSTREAM_DIFF.md +20 -0
- package/agents/__init__.py +1 -0
- package/agents/_model_roles.yaml +26 -0
- package/agents/designer.md +67 -0
- package/agents/explore.md +60 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-api-builder.md +23 -0
- package/agents/omg-architect-mode.md +43 -0
- package/agents/omg-architect.md +13 -0
- package/agents/omg-backend-engineer.md +43 -0
- package/agents/omg-critic.md +16 -0
- package/agents/omg-database-engineer.md +43 -0
- package/agents/omg-escalation-router.md +17 -0
- package/agents/omg-executor.md +12 -0
- package/agents/omg-frontend-designer.md +42 -0
- package/agents/omg-implement-mode.md +50 -0
- package/agents/omg-infra-engineer.md +43 -0
- package/agents/omg-qa-tester.md +16 -0
- package/agents/omg-research-mode.md +43 -0
- package/agents/omg-security-auditor.md +43 -0
- package/agents/omg-testing-engineer.md +43 -0
- package/agents/plan.md +80 -0
- package/agents/quick_task.md +64 -0
- package/agents/reviewer.md +83 -0
- package/agents/task.md +71 -0
- package/commands/OMG:ccg.md +22 -0
- package/commands/OMG:compat.md +57 -0
- package/commands/OMG:crazy.md +125 -0
- package/commands/OMG:domain-init.md +11 -0
- package/commands/OMG:escalate.md +52 -0
- package/commands/OMG:health-check.md +45 -0
- package/commands/OMG:init.md +134 -0
- package/commands/OMG:mode.md +44 -0
- package/commands/OMG:project-init.md +11 -0
- package/commands/OMG:ralph-start.md +43 -0
- package/commands/OMG:ralph-stop.md +23 -0
- package/commands/OMG:teams.md +39 -0
- package/commands/ai-commit.md +113 -0
- package/commands/ccg.md +9 -0
- package/commands/create-agent.md +183 -0
- package/commands/omc-teams.md +9 -0
- package/commands/session-branch.md +85 -0
- package/commands/session-fork.md +53 -0
- package/commands/session-merge.md +134 -0
- package/commands/theme.md +44 -0
- package/config/lsp_languages.yaml +324 -0
- package/config/themes/catppuccin-frappe.yaml +14 -0
- package/config/themes/catppuccin-latte.yaml +14 -0
- package/config/themes/catppuccin-macchiato.yaml +14 -0
- package/config/themes/catppuccin-mocha.yaml +14 -0
- package/config/themes/dracula.yaml +14 -0
- package/config/themes/gruvbox-dark.yaml +14 -0
- package/config/themes/nord.yaml +14 -0
- package/config/themes/one-dark.yaml +14 -0
- package/config/themes/solarized-dark.yaml +14 -0
- package/config/themes/tokyo-night.yaml +14 -0
- package/control_plane/__init__.py +2 -0
- package/control_plane/openapi.yaml +109 -0
- package/control_plane/server.py +107 -0
- package/control_plane/service.py +148 -0
- package/crates/omg-natives/Cargo.toml +17 -0
- package/crates/omg-natives/src/clipboard.rs +5 -0
- package/crates/omg-natives/src/glob.rs +15 -0
- package/crates/omg-natives/src/grep.rs +15 -0
- package/crates/omg-natives/src/highlight.rs +15 -0
- package/crates/omg-natives/src/html.rs +14 -0
- package/crates/omg-natives/src/image.rs +5 -0
- package/crates/omg-natives/src/keys.rs +5 -0
- package/crates/omg-natives/src/lib.rs +36 -0
- package/crates/omg-natives/src/prof.rs +5 -0
- package/crates/omg-natives/src/ps.rs +5 -0
- package/crates/omg-natives/src/shell.rs +5 -0
- package/crates/omg-natives/src/task.rs +5 -0
- package/crates/omg-natives/src/text.rs +14 -0
- package/hooks/_agent_registry.py +421 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +476 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/config-guard.py +163 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +801 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +310 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +199 -0
- package/hooks/pre-compact.py +204 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/secret-guard.py +47 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +275 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +209 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +929 -0
- package/hooks/test-validator.py +138 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +126 -0
- package/hooks/trust_review.py +524 -0
- package/install.sh +9 -0
- package/omg_natives/__init__.py +186 -0
- package/omg_natives/_bindings.py +165 -0
- package/omg_natives/clipboard.py +36 -0
- package/omg_natives/glob.py +42 -0
- package/omg_natives/grep.py +61 -0
- package/omg_natives/highlight.py +54 -0
- package/omg_natives/html.py +157 -0
- package/omg_natives/image.py +51 -0
- package/omg_natives/keys.py +46 -0
- package/omg_natives/prof.py +39 -0
- package/omg_natives/ps.py +93 -0
- package/omg_natives/shell.py +58 -0
- package/omg_natives/task.py +41 -0
- package/omg_natives/text.py +50 -0
- package/package.json +26 -0
- package/plugins/README.md +82 -0
- package/plugins/advanced/commands/OMG:code-review.md +114 -0
- package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
- package/plugins/advanced/commands/OMG:handoff.md +115 -0
- package/plugins/advanced/commands/OMG:learn.md +110 -0
- package/plugins/advanced/commands/OMG:maintainer.md +31 -0
- package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
- package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
- package/plugins/advanced/commands/OMG:security-review.md +119 -0
- package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
- package/plugins/advanced/commands/OMG:ship.md +46 -0
- package/plugins/advanced/plugin.json +96 -0
- package/plugins/core/plugin.json +82 -0
- package/pytest.ini +5 -0
- package/registry/__init__.py +1 -0
- package/registry/verify_artifact.py +90 -0
- package/rules/contextual/architect-mode.md +9 -0
- package/rules/contextual/big-picture.md +20 -0
- package/rules/contextual/code-hygiene.md +26 -0
- package/rules/contextual/context-management.md +19 -0
- package/rules/contextual/context-minimization.md +32 -0
- package/rules/contextual/ddd-sdd.md +28 -0
- package/rules/contextual/dependency-safety.md +16 -0
- package/rules/contextual/doc-check.md +13 -0
- package/rules/contextual/implement-mode.md +9 -0
- package/rules/contextual/infra-safety.md +14 -0
- package/rules/contextual/outside-in.md +13 -0
- package/rules/contextual/persistent-mode.md +24 -0
- package/rules/contextual/research-mode.md +9 -0
- package/rules/contextual/security-domains.md +25 -0
- package/rules/contextual/vision-detection.md +27 -0
- package/rules/contextual/web-search.md +25 -0
- package/rules/contextual/write-verify.md +23 -0
- package/rules/core/00-truth.md +20 -0
- package/rules/core/01-surgical.md +19 -0
- package/rules/core/02-circuit-breaker.md +22 -0
- package/rules/core/03-ensemble.md +28 -0
- package/rules/core/04-testing.md +30 -0
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/omc_contract_snapshot.json +916 -0
- package/runtime/omg_compat_contract_snapshot.json +916 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +838 -0
- package/scripts/check-omc-contract-snapshot.py +12 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-standalone-clean.py +102 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-omc.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +493 -0
- package/scripts/settings-merge.py +224 -0
- package/scripts/verify-no-omc.sh +5 -0
- package/scripts/verify-standalone.sh +21 -0
- package/templates/idea.yml +30 -0
- package/templates/policy.yaml +15 -0
- package/templates/profile.yaml +25 -0
- package/templates/runtime.yaml +12 -0
- package/templates/working-memory.md +17 -0
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +268 -0
- package/tools/commit_splitter.py +361 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
package/hooks/_common.py
ADDED
|
@@ -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 ''
|