@trac3er/oh-my-god 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -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 +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -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/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- 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/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -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/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- 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 +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -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/pr_generator.py +404 -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/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -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,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUseFailure Hook — Acon-style compression feedback loop."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
15
|
+
PROJECT_ROOT = os.path.dirname(HOOKS_DIR)
|
|
16
|
+
if HOOKS_DIR not in sys.path:
|
|
17
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
18
|
+
if PROJECT_ROOT not in sys.path:
|
|
19
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
20
|
+
|
|
21
|
+
from hooks._common import atomic_json_write, get_feature_flag, get_project_dir, json_input, setup_crash_handler
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
setup_crash_handler("compression-feedback", fail_closed=False)
|
|
25
|
+
|
|
26
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
27
|
+
PROMOTION_THRESHOLD = 3
|
|
28
|
+
POST_COMPACTION_WINDOW = timedelta(minutes=30)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_iso8601(value: str | None) -> datetime | None:
|
|
32
|
+
if not value or not isinstance(value, str):
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_failure_reason(payload: dict[str, Any]) -> str:
|
|
41
|
+
for key in ("error", "message", "failure_reason"):
|
|
42
|
+
val = payload.get(key)
|
|
43
|
+
if isinstance(val, str) and val.strip():
|
|
44
|
+
return val.strip()
|
|
45
|
+
|
|
46
|
+
tool_response = payload.get("tool_response")
|
|
47
|
+
if isinstance(tool_response, dict):
|
|
48
|
+
for key in ("error", "message", "stderr", "stdout"):
|
|
49
|
+
val = tool_response.get(key)
|
|
50
|
+
if isinstance(val, str) and val.strip():
|
|
51
|
+
return val.strip()[:500]
|
|
52
|
+
elif isinstance(tool_response, str) and tool_response.strip():
|
|
53
|
+
return tool_response.strip()[:500]
|
|
54
|
+
|
|
55
|
+
return "unknown"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_json(path: str) -> dict[str, Any] | None:
|
|
59
|
+
try:
|
|
60
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
if isinstance(data, dict):
|
|
63
|
+
return data
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_last_compaction_ts(state_dir: str) -> datetime | None:
|
|
70
|
+
data = _read_json(os.path.join(state_dir, "last-compaction.json"))
|
|
71
|
+
if not data:
|
|
72
|
+
return None
|
|
73
|
+
for key in ("timestamp", "ts", "compacted_at", "last_compaction"):
|
|
74
|
+
parsed = _parse_iso8601(data.get(key))
|
|
75
|
+
if parsed:
|
|
76
|
+
return parsed
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_handoff_snapshot(state_dir: str) -> str:
|
|
81
|
+
handoff_path = os.path.join(state_dir, "handoff.md")
|
|
82
|
+
try:
|
|
83
|
+
with open(handoff_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
84
|
+
return f.read()[:4000]
|
|
85
|
+
except Exception:
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _candidate_items(context_snapshot: str) -> list[str]:
|
|
90
|
+
items: list[str] = []
|
|
91
|
+
for raw_line in context_snapshot.splitlines():
|
|
92
|
+
line = raw_line.strip()
|
|
93
|
+
if not line:
|
|
94
|
+
continue
|
|
95
|
+
line = re.sub(r"^[-*]\s+", "", line)
|
|
96
|
+
line = re.sub(r"^\d+\.\s+", "", line)
|
|
97
|
+
if line and not line.startswith("#"):
|
|
98
|
+
items.append(line)
|
|
99
|
+
return items
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _match_dropped_items(payload: dict[str, Any], context_snapshot: str) -> list[str]:
|
|
103
|
+
if not context_snapshot:
|
|
104
|
+
return []
|
|
105
|
+
haystack = " ".join(
|
|
106
|
+
[
|
|
107
|
+
json.dumps(payload.get("tool_input", ""), sort_keys=True),
|
|
108
|
+
json.dumps(payload.get("tool_response", ""), sort_keys=True),
|
|
109
|
+
str(payload.get("error", "")),
|
|
110
|
+
str(payload.get("message", "")),
|
|
111
|
+
]
|
|
112
|
+
).lower()
|
|
113
|
+
|
|
114
|
+
matched = []
|
|
115
|
+
for item in _candidate_items(context_snapshot):
|
|
116
|
+
token = item.strip().lower()
|
|
117
|
+
if token and token in haystack:
|
|
118
|
+
matched.append(item)
|
|
119
|
+
return sorted(set(matched))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _rotate_jsonl_if_needed(path: str) -> None:
|
|
123
|
+
try:
|
|
124
|
+
if os.path.exists(path) and os.path.getsize(path) > MAX_BYTES:
|
|
125
|
+
archive = path + ".1"
|
|
126
|
+
if os.path.exists(archive):
|
|
127
|
+
try:
|
|
128
|
+
os.remove(archive)
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
shutil.move(path, archive)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_feedback_entries(path: str) -> list[dict[str, Any]]:
|
|
137
|
+
rows: list[dict[str, Any]] = []
|
|
138
|
+
if not os.path.exists(path):
|
|
139
|
+
return rows
|
|
140
|
+
try:
|
|
141
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
142
|
+
for line in f:
|
|
143
|
+
line = line.strip()
|
|
144
|
+
if not line:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
row = json.loads(line)
|
|
148
|
+
if isinstance(row, dict):
|
|
149
|
+
rows.append(row)
|
|
150
|
+
except Exception:
|
|
151
|
+
continue
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return rows
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _append_jsonl(path: str, entry: dict[str, Any]) -> None:
|
|
158
|
+
try:
|
|
159
|
+
import fcntl
|
|
160
|
+
|
|
161
|
+
fd = open(path, "a", encoding="utf-8")
|
|
162
|
+
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
163
|
+
fd.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
164
|
+
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
|
|
165
|
+
fd.close()
|
|
166
|
+
except (ImportError, BlockingIOError):
|
|
167
|
+
try:
|
|
168
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
169
|
+
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _promotions(entries: list[dict[str, Any]], matched_items: list[str]) -> list[str]:
|
|
177
|
+
promoted: list[str] = []
|
|
178
|
+
for item in matched_items:
|
|
179
|
+
count = 0
|
|
180
|
+
for row in entries:
|
|
181
|
+
row_items = row.get("matched_items", [])
|
|
182
|
+
if isinstance(row_items, list) and item in row_items:
|
|
183
|
+
count += 1
|
|
184
|
+
if count >= PROMOTION_THRESHOLD:
|
|
185
|
+
promoted.append(item)
|
|
186
|
+
return sorted(set(promoted))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _update_always_keep(state_dir: str, promoted_items: list[str]) -> None:
|
|
190
|
+
if not promoted_items:
|
|
191
|
+
return
|
|
192
|
+
path = os.path.join(state_dir, "always-keep.json")
|
|
193
|
+
current = _read_json(path) or {}
|
|
194
|
+
existing = current.get("items", [])
|
|
195
|
+
if not isinstance(existing, list):
|
|
196
|
+
existing = []
|
|
197
|
+
merged = sorted(set(str(x) for x in existing if x) | set(promoted_items))
|
|
198
|
+
atomic_json_write(path, {"items": merged})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main() -> None:
|
|
202
|
+
data = json_input()
|
|
203
|
+
|
|
204
|
+
if not get_feature_flag("CONTEXT_MANAGER", default=False):
|
|
205
|
+
sys.exit(0)
|
|
206
|
+
|
|
207
|
+
project_dir = get_project_dir()
|
|
208
|
+
state_dir = os.path.join(project_dir, ".omg", "state")
|
|
209
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
210
|
+
|
|
211
|
+
compaction_ts = _read_last_compaction_ts(state_dir)
|
|
212
|
+
failure_ts = _parse_iso8601(data.get("timestamp")) or datetime.now(timezone.utc)
|
|
213
|
+
if not compaction_ts:
|
|
214
|
+
sys.exit(0)
|
|
215
|
+
|
|
216
|
+
delta = failure_ts - compaction_ts
|
|
217
|
+
post_compaction = timedelta(0) <= delta <= POST_COMPACTION_WINDOW
|
|
218
|
+
if not post_compaction:
|
|
219
|
+
sys.exit(0)
|
|
220
|
+
|
|
221
|
+
feedback_path = os.path.join(state_dir, "compression-feedback.jsonl")
|
|
222
|
+
context_snapshot = _read_handoff_snapshot(state_dir)
|
|
223
|
+
matched_items = _match_dropped_items(data, context_snapshot)
|
|
224
|
+
|
|
225
|
+
_rotate_jsonl_if_needed(feedback_path)
|
|
226
|
+
prior_entries = _read_feedback_entries(feedback_path)
|
|
227
|
+
|
|
228
|
+
provisional_entry = {
|
|
229
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
230
|
+
"session_id": str(data.get("session_id", "")),
|
|
231
|
+
"tool_name": str(data.get("tool_name", "")),
|
|
232
|
+
"failure_reason": _extract_failure_reason(data),
|
|
233
|
+
"post_compaction": True,
|
|
234
|
+
"context_snapshot": context_snapshot,
|
|
235
|
+
"promoted_items": [],
|
|
236
|
+
"matched_items": matched_items,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
all_entries = prior_entries + [provisional_entry]
|
|
240
|
+
promoted_items = _promotions(all_entries, matched_items)
|
|
241
|
+
provisional_entry["promoted_items"] = promoted_items
|
|
242
|
+
|
|
243
|
+
_append_jsonl(feedback_path, provisional_entry)
|
|
244
|
+
_update_always_keep(state_dir, promoted_items)
|
|
245
|
+
|
|
246
|
+
sys.exit(0)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
try:
|
|
251
|
+
main()
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
sys.exit(0)
|