@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/_memory.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import glob
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def save_memory(project_dir: str, session_id: str, content: str) -> str:
|
|
8
|
+
memory_dir = os.path.join(project_dir, ".omg", "state", "memory")
|
|
9
|
+
os.makedirs(memory_dir, exist_ok=True)
|
|
10
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
11
|
+
session_short = session_id[:8] if len(session_id) > 8 else session_id
|
|
12
|
+
filename = f"{date_str}-{session_short}.md"
|
|
13
|
+
filepath = os.path.join(memory_dir, filename)
|
|
14
|
+
content = content[:500]
|
|
15
|
+
if os.path.exists(filepath):
|
|
16
|
+
with open(filepath, "a") as file_obj:
|
|
17
|
+
_ = file_obj.write("\n" + content)
|
|
18
|
+
else:
|
|
19
|
+
with open(filepath, "w") as file_obj:
|
|
20
|
+
_ = file_obj.write(content)
|
|
21
|
+
return filepath
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_recent_memories(
|
|
25
|
+
project_dir: str, max_files: int = 5, max_chars_total: int = 300
|
|
26
|
+
) -> str:
|
|
27
|
+
memory_dir = os.path.join(project_dir, ".omg", "state", "memory")
|
|
28
|
+
if not os.path.exists(memory_dir):
|
|
29
|
+
return ""
|
|
30
|
+
files = sorted(glob.glob(os.path.join(memory_dir, "*.md")), reverse=True)
|
|
31
|
+
files = files[:max_files]
|
|
32
|
+
result: list[str] = []
|
|
33
|
+
total = 0
|
|
34
|
+
separator = "\n---\n"
|
|
35
|
+
for file_path in files:
|
|
36
|
+
try:
|
|
37
|
+
with open(file_path) as file_obj:
|
|
38
|
+
content = file_obj.read()
|
|
39
|
+
separator_len = len(separator) if result else 0
|
|
40
|
+
remaining = max_chars_total - total - separator_len
|
|
41
|
+
if remaining <= 0:
|
|
42
|
+
break
|
|
43
|
+
if len(content) > remaining:
|
|
44
|
+
content = content[:remaining]
|
|
45
|
+
if not content:
|
|
46
|
+
break
|
|
47
|
+
if result:
|
|
48
|
+
total += separator_len
|
|
49
|
+
result.append(content)
|
|
50
|
+
total += len(content)
|
|
51
|
+
if total >= max_chars_total:
|
|
52
|
+
break
|
|
53
|
+
except OSError:
|
|
54
|
+
continue
|
|
55
|
+
return separator.join(result)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rotate_memories(project_dir: str, max_files: int = 50) -> int:
|
|
59
|
+
memory_dir = os.path.join(project_dir, ".omg", "state", "memory")
|
|
60
|
+
if not os.path.exists(memory_dir):
|
|
61
|
+
return 0
|
|
62
|
+
files = sorted(glob.glob(os.path.join(memory_dir, "*.md")))
|
|
63
|
+
excess = len(files) - max_files
|
|
64
|
+
if excess <= 0:
|
|
65
|
+
return 0
|
|
66
|
+
for file_path in files[:excess]:
|
|
67
|
+
try:
|
|
68
|
+
os.remove(file_path)
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
return excess
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def search_memories(project_dir: str, query_keywords: list, max_results: int = 3, max_chars: int = 200) -> str:
|
|
76
|
+
"""Search memory files by keyword relevance. Returns formatted excerpt string."""
|
|
77
|
+
memory_dir = os.path.join(project_dir, '.omg', 'state', 'memory')
|
|
78
|
+
if not os.path.isdir(memory_dir):
|
|
79
|
+
return ''
|
|
80
|
+
results = []
|
|
81
|
+
for fname in sorted(os.listdir(memory_dir), reverse=True):
|
|
82
|
+
if not fname.endswith('.md'):
|
|
83
|
+
continue
|
|
84
|
+
fpath = os.path.join(memory_dir, fname)
|
|
85
|
+
try:
|
|
86
|
+
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
87
|
+
content = f.read(2048)
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
score = sum(1 for kw in query_keywords if kw.lower() in content.lower())
|
|
91
|
+
if score > 0:
|
|
92
|
+
results.append((score, fname, content))
|
|
93
|
+
results.sort(key=lambda x: -x[0])
|
|
94
|
+
summary_parts = []
|
|
95
|
+
chars_used = 0
|
|
96
|
+
for score, fname, content in results[:max_results]:
|
|
97
|
+
lines = [l.strip() for l in content.split('\n') if l.strip() and not l.startswith('#')]
|
|
98
|
+
excerpt = ' '.join(lines[:3])[:100]
|
|
99
|
+
if chars_used + len(excerpt) > max_chars:
|
|
100
|
+
break
|
|
101
|
+
summary_parts.append(f'[{fname}] {excerpt}')
|
|
102
|
+
chars_used += len(excerpt)
|
|
103
|
+
return '\n'.join(summary_parts)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PostToolUse Hook: Circuit Breaker + Auto-Escalation (v4)
|
|
4
|
+
Key v4 change: After 3 failures, automatically SUGGESTS Codex/Gemini escalation
|
|
5
|
+
instead of just saying "stop". Actionable, not just blocking.
|
|
6
|
+
"""
|
|
7
|
+
import json, sys, os
|
|
8
|
+
from datetime import datetime, timezone, timedelta
|
|
9
|
+
|
|
10
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
11
|
+
if HOOKS_DIR not in sys.path:
|
|
12
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
13
|
+
|
|
14
|
+
from _common import setup_crash_handler, json_input, _resolve_project_dir
|
|
15
|
+
from state_migration import resolve_state_dir
|
|
16
|
+
|
|
17
|
+
setup_crash_handler("circuit-breaker", fail_closed=False)
|
|
18
|
+
|
|
19
|
+
# Domain-aware routing hints: pattern prefix → suggested model
|
|
20
|
+
DOMAIN_MODEL_HINTS = {
|
|
21
|
+
'Bash:pytest': 'codex',
|
|
22
|
+
'Bash:npm': 'codex',
|
|
23
|
+
'Bash:python': 'codex',
|
|
24
|
+
'Write:': 'codex',
|
|
25
|
+
'Edit:': 'codex',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_domain_hint(pk: str) -> str:
|
|
30
|
+
"""Return model hint for a pattern key, or empty string."""
|
|
31
|
+
for prefix, model in DOMAIN_MODEL_HINTS.items():
|
|
32
|
+
if pk.startswith(prefix):
|
|
33
|
+
return model
|
|
34
|
+
return ''
|
|
35
|
+
|
|
36
|
+
data = json_input()
|
|
37
|
+
|
|
38
|
+
tool = data.get("tool_name", "")
|
|
39
|
+
tool_input = data.get("tool_input", {})
|
|
40
|
+
tool_response = data.get("tool_response", {})
|
|
41
|
+
project_dir = _resolve_project_dir()
|
|
42
|
+
|
|
43
|
+
ledger_dir = resolve_state_dir(project_dir, "state/ledger", "ledger")
|
|
44
|
+
tracker_path = os.path.join(ledger_dir, "failure-tracker.json")
|
|
45
|
+
os.makedirs(os.path.dirname(tracker_path), exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# Determine failure
|
|
48
|
+
is_failure = False
|
|
49
|
+
if tool == "Bash":
|
|
50
|
+
ec = None
|
|
51
|
+
if isinstance(tool_response, dict):
|
|
52
|
+
ec = tool_response.get("exitCode", tool_response.get("exit_code"))
|
|
53
|
+
if ec is not None and ec != 0:
|
|
54
|
+
is_failure = True
|
|
55
|
+
elif tool in ("Write", "Edit", "MultiEdit"):
|
|
56
|
+
if isinstance(tool_response, dict) and not tool_response.get("success", True):
|
|
57
|
+
is_failure = True
|
|
58
|
+
|
|
59
|
+
# Pattern key — normalized to prevent duplicates
|
|
60
|
+
# "npm test" and "npm run test" should be the same failure pattern
|
|
61
|
+
pattern_key = tool
|
|
62
|
+
if tool == "Bash":
|
|
63
|
+
cmd = tool_input.get("command", "").strip()
|
|
64
|
+
# Normalize: strip common prefixes, reduce to base command
|
|
65
|
+
cmd_clean = cmd
|
|
66
|
+
# Strip package manager prefixes: npx, pnpm, yarn, bunx
|
|
67
|
+
cmd_clean = cmd_clean.replace("npx ", "").replace("pnpm ", "npm ").replace("yarn ", "npm ").replace("bunx ", "")
|
|
68
|
+
# Strip python -m X → X (e.g., "python3 -m pytest" → "pytest")
|
|
69
|
+
if cmd_clean.startswith("python3 -m "):
|
|
70
|
+
cmd_clean = cmd_clean.replace("python3 -m ", "", 1)
|
|
71
|
+
elif cmd_clean.startswith("python -m "):
|
|
72
|
+
cmd_clean = cmd_clean.replace("python -m ", "", 1)
|
|
73
|
+
words = cmd_clean.split()[:3] # first 3 words for more specificity
|
|
74
|
+
# Remove common noise: run, exec, --
|
|
75
|
+
words = [w for w in words if not w.startswith("-") and w not in ("run", "exec")][:2]
|
|
76
|
+
pattern_key = f"Bash:{' '.join(words)}" if words else f"Bash:{cmd[:30]}"
|
|
77
|
+
elif tool in ("Write", "Edit", "MultiEdit"):
|
|
78
|
+
fp = tool_input.get("file_path", "")
|
|
79
|
+
# Normalize: use basename to avoid path-length variants
|
|
80
|
+
pattern_key = f"{tool}:{os.path.basename(fp)}" if fp else tool
|
|
81
|
+
pattern_key = pattern_key[:120].replace("\n", " ")
|
|
82
|
+
|
|
83
|
+
# Load tracker
|
|
84
|
+
tracker = {}
|
|
85
|
+
if os.path.exists(tracker_path):
|
|
86
|
+
try:
|
|
87
|
+
with open(tracker_path, "r") as f:
|
|
88
|
+
tracker = json.load(f)
|
|
89
|
+
if not isinstance(tracker, dict):
|
|
90
|
+
tracker = {}
|
|
91
|
+
except Exception:
|
|
92
|
+
tracker = {}
|
|
93
|
+
|
|
94
|
+
# Evict stale (24h) + cap 100
|
|
95
|
+
now = datetime.now(timezone.utc)
|
|
96
|
+
cutoff = now - timedelta(hours=24)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_ts(ts_str):
|
|
100
|
+
"""Parse ISO timestamp string to datetime, returning None on failure."""
|
|
101
|
+
try:
|
|
102
|
+
# Handle both Z-suffix and +00:00 formats
|
|
103
|
+
ts = ts_str.replace("Z", "+00:00") if ts_str.endswith("Z") else ts_str
|
|
104
|
+
return datetime.fromisoformat(ts)
|
|
105
|
+
except (ValueError, TypeError, AttributeError):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _effective_count(entry: dict[str, object], now: datetime) -> float:
|
|
110
|
+
"""Apply time-decay: failures >30 min old count as 0.5x."""
|
|
111
|
+
last_failure = entry.get('last_failure', '')
|
|
112
|
+
last_ts = _parse_ts(last_failure if isinstance(last_failure, str) else '')
|
|
113
|
+
raw_count = entry.get('count', 0)
|
|
114
|
+
count_value = float(raw_count) if isinstance(raw_count, (int, float)) else 0.0
|
|
115
|
+
if last_ts is None:
|
|
116
|
+
return count_value
|
|
117
|
+
age_minutes = (now - last_ts).total_seconds() / 60
|
|
118
|
+
if age_minutes > 30:
|
|
119
|
+
return count_value * 0.5
|
|
120
|
+
return count_value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
tracker = {k: v for k, v in tracker.items()
|
|
124
|
+
if isinstance(v, dict) and (_parse_ts(v.get("last_failure", "")) or cutoff) >= cutoff}
|
|
125
|
+
if len(tracker) > 100:
|
|
126
|
+
for k in sorted(tracker, key=lambda x: tracker[x].get("last_failure", ""))[:-100]:
|
|
127
|
+
del tracker[k]
|
|
128
|
+
|
|
129
|
+
if is_failure:
|
|
130
|
+
entry_raw = tracker.get(pattern_key, {"count": 0, "errors": []})
|
|
131
|
+
if not isinstance(entry_raw, dict):
|
|
132
|
+
entry_raw = {"count": 0, "errors": []}
|
|
133
|
+
entry: dict[str, object] = dict(entry_raw)
|
|
134
|
+
entry_count = entry.get("count", 0)
|
|
135
|
+
count_value = entry_count if isinstance(entry_count, (int, float)) else 0
|
|
136
|
+
entry["count"] = count_value + 1
|
|
137
|
+
entry["last_failure"] = now.isoformat()
|
|
138
|
+
|
|
139
|
+
err = ""
|
|
140
|
+
if isinstance(tool_response, dict):
|
|
141
|
+
err = str(tool_response.get("stderr", tool_response.get("stdout", "")))[:200].strip()
|
|
142
|
+
errors = entry.get("errors", [])
|
|
143
|
+
if not isinstance(errors, list):
|
|
144
|
+
errors = []
|
|
145
|
+
# Deduplicate: don't store the same error message twice in a row
|
|
146
|
+
if err and (not errors or errors[-1] != err):
|
|
147
|
+
errors.append(err)
|
|
148
|
+
entry["errors"] = errors[-3:] # keep last 3 unique errors
|
|
149
|
+
tracker[pattern_key] = entry
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
import fcntl
|
|
153
|
+
# Open read+write without truncating, acquire lock, THEN truncate and write.
|
|
154
|
+
# This prevents data loss if lock acquisition fails after truncation.
|
|
155
|
+
fd = open(tracker_path, "a+")
|
|
156
|
+
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
157
|
+
fd.seek(0)
|
|
158
|
+
fd.truncate()
|
|
159
|
+
json.dump(tracker, fd, indent=2)
|
|
160
|
+
fd.flush()
|
|
161
|
+
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
|
|
162
|
+
fd.close()
|
|
163
|
+
except (ImportError, BlockingIOError):
|
|
164
|
+
# Fallback: write without lock (better than losing data)
|
|
165
|
+
try:
|
|
166
|
+
with open(tracker_path, "w") as f:
|
|
167
|
+
json.dump(tracker, f, indent=2)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
count = entry["count"]
|
|
174
|
+
effective_count = _effective_count(entry, now)
|
|
175
|
+
domain_hint = _get_domain_hint(pattern_key)
|
|
176
|
+
recent_errs = "\n".join(f" - {e}" for e in entry["errors"] if e)
|
|
177
|
+
|
|
178
|
+
if effective_count >= 5:
|
|
179
|
+
print(
|
|
180
|
+
f"CIRCUIT BREAKER: '{pattern_key}' failed {count}x (effective {effective_count:.1f}x).\n"
|
|
181
|
+
f"STOP. This approach is broken.\n"
|
|
182
|
+
f"{recent_errs}\n\n"
|
|
183
|
+
f"Domain hint: {domain_hint or 'none'}\n"
|
|
184
|
+
f"ESCALATE NOW — pick one:\n"
|
|
185
|
+
f" /OMG:escalate codex \"Debug: {pattern_key} fails with {entry['errors'][-1][:80]}\"\n"
|
|
186
|
+
f" /OMG:escalate gemini \"Review: approach for {pattern_key}\"\n"
|
|
187
|
+
f" Ask the user for a completely different approach\n"
|
|
188
|
+
f" Skip this step: mark [!] in checklist and move on",
|
|
189
|
+
file=sys.stderr
|
|
190
|
+
)
|
|
191
|
+
# NOTE: exit(0), not exit(2). Non-zero exits crash sibling hooks
|
|
192
|
+
# ("Sibling tool call errored"). The warning is in stderr.
|
|
193
|
+
sys.exit(0)
|
|
194
|
+
|
|
195
|
+
elif effective_count >= 3:
|
|
196
|
+
print(
|
|
197
|
+
f"CIRCUIT BREAKER WARNING: '{pattern_key}' failed {count}x (effective {effective_count:.1f}x).\n"
|
|
198
|
+
f"Last error: {entry['errors'][-1][:150] if entry['errors'] else 'unknown'}\n\n"
|
|
199
|
+
f"Domain hint: {domain_hint or 'none'}\n"
|
|
200
|
+
f"STOP auto-retrying. Try:\n"
|
|
201
|
+
f" 1. Fundamentally different approach\n"
|
|
202
|
+
f" 2. /OMG:escalate codex \"Why does {pattern_key} keep failing?\"\n"
|
|
203
|
+
f" 3. Ask the user",
|
|
204
|
+
file=sys.stderr
|
|
205
|
+
)
|
|
206
|
+
sys.exit(0)
|
|
207
|
+
|
|
208
|
+
else:
|
|
209
|
+
# On success, clear this pattern AND similar variants
|
|
210
|
+
# Helper to normalize a tracker key by re-normalizing the command part
|
|
211
|
+
def _normalize_tracker_key(pk):
|
|
212
|
+
"""Normalize a tracker key by applying the same rules as pattern_key generation."""
|
|
213
|
+
if not pk.startswith("Bash:"):
|
|
214
|
+
return pk
|
|
215
|
+
|
|
216
|
+
cmd = pk[5:] # Remove "Bash:" prefix
|
|
217
|
+
# Apply the same normalization as in pattern_key generation
|
|
218
|
+
cmd_clean = cmd
|
|
219
|
+
cmd_clean = cmd_clean.replace("npx ", "").replace("pnpm ", "npm ").replace("yarn ", "npm ").replace("bunx ", "")
|
|
220
|
+
if cmd_clean.startswith("python3 -m "):
|
|
221
|
+
cmd_clean = cmd_clean.replace("python3 -m ", "", 1)
|
|
222
|
+
elif cmd_clean.startswith("python -m "):
|
|
223
|
+
cmd_clean = cmd_clean.replace("python -m ", "", 1)
|
|
224
|
+
words = cmd_clean.split()[:3]
|
|
225
|
+
words = [w for w in words if not w.startswith("-") and w not in ("run", "exec")][:2]
|
|
226
|
+
normalized_cmd = ' '.join(words) if words else cmd[:30]
|
|
227
|
+
return f"Bash:{normalized_cmd}"
|
|
228
|
+
|
|
229
|
+
normalized_pattern_key = _normalize_tracker_key(pattern_key)
|
|
230
|
+
changed = False
|
|
231
|
+
keys_to_remove = []
|
|
232
|
+
|
|
233
|
+
for k in tracker:
|
|
234
|
+
# Normalize both keys for comparison
|
|
235
|
+
normalized_k = _normalize_tracker_key(k)
|
|
236
|
+
if normalized_k == normalized_pattern_key:
|
|
237
|
+
keys_to_remove.append(k)
|
|
238
|
+
|
|
239
|
+
for k in keys_to_remove:
|
|
240
|
+
del tracker[k]
|
|
241
|
+
changed = True
|
|
242
|
+
|
|
243
|
+
if changed:
|
|
244
|
+
try:
|
|
245
|
+
with open(tracker_path, "w") as f:
|
|
246
|
+
json.dump(tracker, f, indent=2)
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
_recovery_path = os.path.join(ledger_dir, 'recovery.jsonl')
|
|
250
|
+
try:
|
|
251
|
+
import json as _json
|
|
252
|
+
_rec = _json.dumps({
|
|
253
|
+
'pattern': pattern_key,
|
|
254
|
+
'recovered_at': now.isoformat(),
|
|
255
|
+
'cleared_count': len(keys_to_remove),
|
|
256
|
+
})
|
|
257
|
+
with open(_recovery_path, 'a') as _rf:
|
|
258
|
+
_rf.write(_rec + '\n')
|
|
259
|
+
try:
|
|
260
|
+
with open(_recovery_path, 'r') as _rf:
|
|
261
|
+
_lines = _rf.readlines()
|
|
262
|
+
if len(_lines) > 200:
|
|
263
|
+
with open(_recovery_path, 'w') as _rf:
|
|
264
|
+
_rf.writelines(_lines[-200:])
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
sys.exit(0)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ConfigChange Hook: Settings Tamper Detection + Trust Review
|
|
3
|
+
|
|
4
|
+
Monitors Claude settings changes and writes Trust Review artifacts to
|
|
5
|
+
.omg/trust/manifest.lock.json.
|
|
6
|
+
"""
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
13
|
+
if HOOKS_DIR not in sys.path:
|
|
14
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
15
|
+
|
|
16
|
+
from _common import setup_crash_handler, json_input, _resolve_project_dir
|
|
17
|
+
|
|
18
|
+
# Compatibility marker for existing tests and policy docs.
|
|
19
|
+
DANGEROUS_IN_ALLOW = [
|
|
20
|
+
"Bash(rm:*)", "Bash(sudo:*)", "Bash(curl:*)", "Bash(wget:*)",
|
|
21
|
+
"Bash(ssh:*)", "Bash(nc:*)", "Bash(ncat:*)",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
setup_crash_handler("config-guard", fail_closed=False)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from trust_review import review_config_change, write_trust_manifest, format_review_summary
|
|
28
|
+
except Exception as e:
|
|
29
|
+
print(f"[OMG] config-guard: trust_review import failed: {type(e).__name__}: {e}", file=sys.stderr)
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
data = json_input()
|
|
33
|
+
|
|
34
|
+
def _decode_json_object(value):
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return value
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
raw = value.strip()
|
|
39
|
+
if not raw:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
parsed = json.loads(raw)
|
|
43
|
+
except Exception: # intentional: decode fallback
|
|
44
|
+
return None
|
|
45
|
+
return parsed if isinstance(parsed, dict) else None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_config_path(payload):
|
|
50
|
+
file_path = payload.get("file_path")
|
|
51
|
+
if isinstance(file_path, str) and file_path.strip():
|
|
52
|
+
return file_path
|
|
53
|
+
|
|
54
|
+
tool_input = payload.get("tool_input", {})
|
|
55
|
+
if isinstance(tool_input, dict):
|
|
56
|
+
legacy_path = tool_input.get("file_path")
|
|
57
|
+
if isinstance(legacy_path, str) and legacy_path.strip():
|
|
58
|
+
return legacy_path
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_config_object(payload, keys):
|
|
63
|
+
for key in keys:
|
|
64
|
+
parsed = _decode_json_object(payload.get(key))
|
|
65
|
+
if parsed is not None:
|
|
66
|
+
return parsed
|
|
67
|
+
|
|
68
|
+
tool_input = payload.get("tool_input", {})
|
|
69
|
+
if isinstance(tool_input, dict):
|
|
70
|
+
for key in keys:
|
|
71
|
+
parsed = _decode_json_object(tool_input.get(key))
|
|
72
|
+
if parsed is not None:
|
|
73
|
+
return parsed
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_watched_settings_path(path):
|
|
78
|
+
normalized = path.replace("\\", "/").rstrip("/")
|
|
79
|
+
return normalized == "settings.json" or normalized.endswith("/settings.json")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
config_path = _extract_config_path(data)
|
|
83
|
+
if not config_path:
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
|
|
86
|
+
is_settings = _is_watched_settings_path(config_path)
|
|
87
|
+
if not is_settings:
|
|
88
|
+
sys.exit(0)
|
|
89
|
+
|
|
90
|
+
project_dir = _resolve_project_dir()
|
|
91
|
+
new_path = config_path if os.path.isabs(config_path) else os.path.join(project_dir, config_path)
|
|
92
|
+
if not os.path.exists(new_path):
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with open(new_path, "r", encoding="utf-8") as f:
|
|
97
|
+
new_config = json.load(f)
|
|
98
|
+
if not isinstance(new_config, dict):
|
|
99
|
+
sys.exit(0)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f"[OMG] config-guard: config read failed: {type(e).__name__}: {e}", file=sys.stderr)
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
# Load previous settings snapshot for diff-based trust review.
|
|
105
|
+
snapshot_dir = os.path.join(project_dir, ".omg", "trust")
|
|
106
|
+
os.makedirs(snapshot_dir, exist_ok=True)
|
|
107
|
+
snapshot_path = os.path.join(snapshot_dir, "last-settings.json")
|
|
108
|
+
|
|
109
|
+
old_config = {}
|
|
110
|
+
# Prefer explicit old config in payload if present.
|
|
111
|
+
payload_old = _extract_config_object(
|
|
112
|
+
data,
|
|
113
|
+
("old_config", "old_settings", "old_content", "before", "old_value"),
|
|
114
|
+
)
|
|
115
|
+
if isinstance(payload_old, dict):
|
|
116
|
+
old_config = payload_old
|
|
117
|
+
elif os.path.exists(snapshot_path):
|
|
118
|
+
try:
|
|
119
|
+
with open(snapshot_path, "r", encoding="utf-8") as f:
|
|
120
|
+
old_config = json.load(f)
|
|
121
|
+
if not isinstance(old_config, dict):
|
|
122
|
+
old_config = {}
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"[OMG] config-guard: snapshot read failed: {type(e).__name__}: {e}", file=sys.stderr)
|
|
125
|
+
old_config = {}
|
|
126
|
+
|
|
127
|
+
# Prefer explicit new config when the payload provides it.
|
|
128
|
+
payload_new = _extract_config_object(
|
|
129
|
+
data,
|
|
130
|
+
("new_config", "new_settings", "new_content", "after", "new_value"),
|
|
131
|
+
)
|
|
132
|
+
if isinstance(payload_new, dict):
|
|
133
|
+
new_config = payload_new
|
|
134
|
+
|
|
135
|
+
review = review_config_change(config_path, old_config, new_config)
|
|
136
|
+
write_trust_manifest(project_dir, review)
|
|
137
|
+
|
|
138
|
+
# Keep a rolling snapshot for next review.
|
|
139
|
+
with contextlib.suppress(OSError): # intentional: cleanup
|
|
140
|
+
with open(snapshot_path, "w", encoding="utf-8") as f:
|
|
141
|
+
json.dump(new_config, f, indent=2, ensure_ascii=True)
|
|
142
|
+
|
|
143
|
+
# Backward-compatibility variable expected by tests.
|
|
144
|
+
hooks = new_config.get("hooks", {})
|
|
145
|
+
hook_count = sum(len(v) if isinstance(v, list) else 0 for v in hooks.values())
|
|
146
|
+
|
|
147
|
+
verdict = review.get("verdict", "allow")
|
|
148
|
+
risk_level = review.get("risk_level", "low")
|
|
149
|
+
summary = format_review_summary(review)
|
|
150
|
+
|
|
151
|
+
if verdict == "deny":
|
|
152
|
+
msg = "⚠ SETTINGS CHANGE DETECTED (Trust Review)\n" + summary
|
|
153
|
+
msg += "\n\nBlocked because risk is critical."
|
|
154
|
+
json.dump({"decision": "block", "reason": msg}, sys.stdout)
|
|
155
|
+
elif verdict == "ask":
|
|
156
|
+
# ConfigChange hook only supports block/pass. For high-risk changes,
|
|
157
|
+
# block and require explicit user re-apply after review.
|
|
158
|
+
msg = "⚠ SETTINGS CHANGE REQUIRES REVIEW\n" + summary
|
|
159
|
+
msg += "\n\nRe-apply after human approval."
|
|
160
|
+
if risk_level == "high":
|
|
161
|
+
json.dump({"decision": "block", "reason": msg}, sys.stdout)
|
|
162
|
+
|
|
163
|
+
sys.exit(0)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Context pressure estimation - importable module for OMG hooks."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
_DEFAULT_THRESHOLD = 150
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def estimate_context_pressure(project_dir):
|
|
12
|
+
threshold = _DEFAULT_THRESHOLD
|
|
13
|
+
try:
|
|
14
|
+
settings_path = os.path.join(project_dir, "settings.json")
|
|
15
|
+
if os.path.exists(settings_path):
|
|
16
|
+
with open(settings_path, "r", encoding="utf-8") as settings_file:
|
|
17
|
+
settings = json.load(settings_file)
|
|
18
|
+
threshold = settings.get("_oal", {}).get("context_budget", {}).get(
|
|
19
|
+
"pressure_threshold", _DEFAULT_THRESHOLD
|
|
20
|
+
)
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
tool_count = 0
|
|
25
|
+
ledger_path = os.path.join(project_dir, ".omg", "state", "ledger", "tool-ledger.jsonl")
|
|
26
|
+
if os.path.exists(ledger_path):
|
|
27
|
+
try:
|
|
28
|
+
with open(ledger_path, "r", encoding="utf-8", errors="ignore") as ledger_file:
|
|
29
|
+
for line in ledger_file:
|
|
30
|
+
if line.strip():
|
|
31
|
+
tool_count += 1
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
is_high = tool_count >= threshold
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
pressure_path = os.path.join(project_dir, ".omg", "state", ".context-pressure.json")
|
|
39
|
+
os.makedirs(os.path.dirname(pressure_path), exist_ok=True)
|
|
40
|
+
with open(pressure_path, "w", encoding="utf-8") as pressure_file:
|
|
41
|
+
json.dump(
|
|
42
|
+
{
|
|
43
|
+
"tool_count": tool_count,
|
|
44
|
+
"threshold": threshold,
|
|
45
|
+
"is_high": is_high,
|
|
46
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
47
|
+
},
|
|
48
|
+
pressure_file,
|
|
49
|
+
)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
return tool_count, threshold, is_high
|