@trac3er/oh-my-god 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.claude-plugin/marketplace.json +8 -8
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.claude-plugin/scripts/uninstall.sh +74 -3
  4. package/.claude-plugin/scripts/update.sh +78 -3
  5. package/.coveragerc +26 -0
  6. package/.mcp.json +4 -4
  7. package/CHANGELOG.md +14 -0
  8. package/CODE_OF_CONDUCT.md +27 -0
  9. package/CONTRIBUTING.md +62 -0
  10. package/OMG-setup.sh +1201 -355
  11. package/README.md +77 -56
  12. package/SECURITY.md +25 -0
  13. package/agents/__init__.py +1 -0
  14. package/agents/model_roles.py +196 -0
  15. package/agents/omg-architect-mode.md +3 -5
  16. package/agents/omg-backend-engineer.md +3 -5
  17. package/agents/omg-database-engineer.md +3 -5
  18. package/agents/omg-frontend-designer.md +4 -5
  19. package/agents/omg-implement-mode.md +4 -5
  20. package/agents/omg-infra-engineer.md +3 -5
  21. package/agents/omg-research-mode.md +4 -6
  22. package/agents/omg-security-auditor.md +3 -5
  23. package/agents/omg-testing-engineer.md +3 -5
  24. package/build/lib/yaml.py +321 -0
  25. package/commands/OMG:ai-commit.md +101 -14
  26. package/commands/OMG:arch.md +302 -19
  27. package/commands/OMG:ccg.md +12 -7
  28. package/commands/OMG:compat.md +25 -17
  29. package/commands/OMG:cost.md +173 -13
  30. package/commands/OMG:crazy.md +1 -1
  31. package/commands/OMG:create-agent.md +170 -20
  32. package/commands/OMG:deps.md +235 -17
  33. package/commands/OMG:domain-init.md +1 -1
  34. package/commands/OMG:escalate.md +41 -12
  35. package/commands/OMG:health-check.md +37 -13
  36. package/commands/OMG:init.md +122 -14
  37. package/commands/OMG:project-init.md +1 -1
  38. package/commands/OMG:session-branch.md +76 -9
  39. package/commands/OMG:session-fork.md +42 -5
  40. package/commands/OMG:session-merge.md +124 -8
  41. package/commands/OMG:setup.md +69 -12
  42. package/commands/OMG:stats.md +215 -14
  43. package/commands/OMG:teams.md +19 -10
  44. package/config/lsp_languages.yaml +8 -0
  45. package/hooks/__init__.py +0 -0
  46. package/hooks/_agent_registry.py +423 -0
  47. package/hooks/_analytics.py +291 -0
  48. package/hooks/_budget.py +31 -0
  49. package/hooks/_common.py +569 -0
  50. package/hooks/_compression_optimizer.py +119 -0
  51. package/hooks/_cost_ledger.py +176 -0
  52. package/hooks/_learnings.py +126 -0
  53. package/hooks/_memory.py +103 -0
  54. package/hooks/_protected_context.py +150 -0
  55. package/hooks/_token_counter.py +221 -0
  56. package/hooks/branch_manager.py +236 -0
  57. package/hooks/budget_governor.py +232 -0
  58. package/hooks/circuit-breaker.py +270 -0
  59. package/hooks/compression_feedback.py +254 -0
  60. package/hooks/config-guard.py +216 -0
  61. package/hooks/context_pressure.py +53 -0
  62. package/hooks/credential_store.py +1020 -0
  63. package/hooks/fetch-rate-limits.py +212 -0
  64. package/hooks/firewall.py +48 -0
  65. package/hooks/hashline-formatter-bridge.py +224 -0
  66. package/hooks/hashline-injector.py +273 -0
  67. package/hooks/hashline-validator.py +216 -0
  68. package/hooks/idle-detector.py +95 -0
  69. package/hooks/intentgate-keyword-detector.py +188 -0
  70. package/hooks/magic-keyword-router.py +195 -0
  71. package/hooks/policy_engine.py +505 -0
  72. package/hooks/post-tool-failure.py +19 -0
  73. package/hooks/post-write.py +219 -0
  74. package/hooks/post_write.py +46 -0
  75. package/hooks/pre-compact.py +398 -0
  76. package/hooks/pre-tool-inject.py +98 -0
  77. package/hooks/prompt-enhancer.py +672 -0
  78. package/hooks/quality-runner.py +191 -0
  79. package/hooks/query.py +512 -0
  80. package/hooks/secret-guard.py +61 -0
  81. package/hooks/secret_audit.py +144 -0
  82. package/hooks/session-end-capture.py +137 -0
  83. package/hooks/session-start.py +277 -0
  84. package/hooks/setup_wizard.py +582 -0
  85. package/hooks/shadow_manager.py +297 -0
  86. package/hooks/state_migration.py +225 -0
  87. package/hooks/stop-gate.py +7 -0
  88. package/hooks/stop_dispatcher.py +945 -0
  89. package/hooks/test-validator.py +361 -0
  90. package/hooks/test_generator_hook.py +123 -0
  91. package/hooks/todo-state-tracker.py +114 -0
  92. package/hooks/tool-ledger.py +149 -0
  93. package/hooks/trust_review.py +585 -0
  94. package/hud/omg-hud.mjs +31 -1
  95. package/lab/__init__.py +1 -0
  96. package/lab/pipeline.py +75 -0
  97. package/lab/policies.py +52 -0
  98. package/package.json +7 -18
  99. package/plugins/README.md +33 -61
  100. package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
  101. package/plugins/advanced/commands/OMG:learn.md +1 -1
  102. package/plugins/advanced/commands/OMG:security-review.md +3 -3
  103. package/plugins/advanced/commands/OMG:ship.md +1 -1
  104. package/plugins/advanced/plugin.json +1 -1
  105. package/plugins/core/plugin.json +8 -3
  106. package/plugins/dephealth/__init__.py +0 -0
  107. package/plugins/dephealth/cve_scanner.py +188 -0
  108. package/plugins/dephealth/license_checker.py +135 -0
  109. package/plugins/dephealth/manifest_detector.py +423 -0
  110. package/plugins/dephealth/vuln_analyzer.py +169 -0
  111. package/plugins/testgen/__init__.py +0 -0
  112. package/plugins/testgen/codamosa_engine.py +402 -0
  113. package/plugins/testgen/edge_case_synthesizer.py +184 -0
  114. package/plugins/testgen/framework_detector.py +271 -0
  115. package/plugins/testgen/skeleton_generator.py +219 -0
  116. package/plugins/viz/__init__.py +0 -0
  117. package/plugins/viz/ast_parser.py +139 -0
  118. package/plugins/viz/diagram_generator.py +192 -0
  119. package/plugins/viz/graph_builder.py +444 -0
  120. package/plugins/viz/native_parsers.py +259 -0
  121. package/plugins/viz/regex_parser.py +112 -0
  122. package/pyproject.toml +81 -0
  123. package/rules/contextual/write-verify.md +2 -2
  124. package/rules/core/00-truth.md +1 -1
  125. package/rules/core/01-surgical.md +1 -1
  126. package/rules/core/02-circuit-breaker.md +2 -2
  127. package/rules/core/03-ensemble.md +3 -3
  128. package/rules/core/04-testing.md +3 -3
  129. package/runtime/__init__.py +32 -0
  130. package/runtime/adapters/__init__.py +13 -0
  131. package/runtime/adapters/claude.py +60 -0
  132. package/runtime/adapters/gpt.py +53 -0
  133. package/runtime/adapters/local.py +53 -0
  134. package/runtime/adoption.py +212 -0
  135. package/runtime/business_workflow.py +220 -0
  136. package/runtime/cli_provider.py +85 -0
  137. package/runtime/compat.py +1299 -0
  138. package/runtime/custom_agent_loader.py +366 -0
  139. package/runtime/dispatcher.py +47 -0
  140. package/runtime/ecosystem.py +371 -0
  141. package/runtime/legacy_compat.py +7 -0
  142. package/runtime/mcp_config_writers.py +115 -0
  143. package/runtime/mcp_lifecycle.py +153 -0
  144. package/runtime/mcp_memory_server.py +135 -0
  145. package/runtime/memory_parsers/__init__.py +0 -0
  146. package/runtime/memory_parsers/chatgpt_parser.py +257 -0
  147. package/runtime/memory_parsers/claude_import.py +107 -0
  148. package/runtime/memory_parsers/export.py +97 -0
  149. package/runtime/memory_parsers/gemini_import.py +91 -0
  150. package/runtime/memory_parsers/kimi_import.py +91 -0
  151. package/runtime/memory_store.py +215 -0
  152. package/runtime/omc_compat.py +7 -0
  153. package/runtime/providers/__init__.py +0 -0
  154. package/runtime/providers/codex_provider.py +112 -0
  155. package/runtime/providers/gemini_provider.py +128 -0
  156. package/runtime/providers/kimi_provider.py +151 -0
  157. package/runtime/providers/opencode_provider.py +144 -0
  158. package/runtime/subagent_dispatcher.py +362 -0
  159. package/runtime/team_router.py +1167 -0
  160. package/runtime/tmux_session_manager.py +169 -0
  161. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  162. package/scripts/check-omg-contract-snapshot.py +12 -0
  163. package/scripts/check-omg-public-ready.py +193 -0
  164. package/scripts/check-omg-standalone-clean.py +103 -0
  165. package/scripts/legacy_to_omg_migrate.py +29 -0
  166. package/scripts/migrate-legacy.py +464 -0
  167. package/scripts/omc_to_omg_migrate.py +12 -0
  168. package/scripts/omg.py +492 -0
  169. package/scripts/settings-merge.py +283 -0
  170. package/scripts/verify-standalone.sh +8 -4
  171. package/settings.json +126 -29
  172. package/templates/profile.yaml +1 -1
  173. package/tools/__init__.py +2 -0
  174. package/tools/browser_consent.py +289 -0
  175. package/tools/browser_stealth.py +481 -0
  176. package/tools/browser_tool.py +448 -0
  177. package/tools/changelog_generator.py +347 -0
  178. package/tools/commit_splitter.py +746 -0
  179. package/tools/config_discovery.py +151 -0
  180. package/tools/config_merger.py +449 -0
  181. package/tools/dashboard_generator.py +300 -0
  182. package/tools/git_inspector.py +298 -0
  183. package/tools/lsp_client.py +275 -0
  184. package/tools/lsp_discovery.py +231 -0
  185. package/tools/lsp_operations.py +392 -0
  186. package/tools/pr_generator.py +404 -0
  187. package/tools/python_repl.py +656 -0
  188. package/tools/python_sandbox.py +609 -0
  189. package/tools/search_providers/__init__.py +77 -0
  190. package/tools/search_providers/brave.py +115 -0
  191. package/tools/search_providers/exa.py +116 -0
  192. package/tools/search_providers/jina.py +104 -0
  193. package/tools/search_providers/perplexity.py +139 -0
  194. package/tools/search_providers/synthetic.py +74 -0
  195. package/tools/session_snapshot.py +736 -0
  196. package/tools/ssh_manager.py +912 -0
  197. package/tools/theme_engine.py +294 -0
  198. package/tools/theme_selector.py +137 -0
  199. package/tools/web_search.py +622 -0
  200. package/yaml.py +321 -0
  201. package/.claude-plugin/scripts/install.sh +0 -9
  202. package/bun.lock +0 -23
  203. package/bunfig.toml +0 -3
  204. package/hooks/_budget.ts +0 -1
  205. package/hooks/_common.ts +0 -63
  206. package/hooks/circuit-breaker.ts +0 -101
  207. package/hooks/config-guard.ts +0 -4
  208. package/hooks/firewall.ts +0 -20
  209. package/hooks/policy_engine.ts +0 -156
  210. package/hooks/post-tool-failure.ts +0 -22
  211. package/hooks/post-write.ts +0 -4
  212. package/hooks/pre-tool-inject.ts +0 -4
  213. package/hooks/prompt-enhancer.ts +0 -46
  214. package/hooks/quality-runner.ts +0 -24
  215. package/hooks/secret-guard.ts +0 -4
  216. package/hooks/session-end-capture.ts +0 -19
  217. package/hooks/session-start.ts +0 -19
  218. package/hooks/shadow_manager.ts +0 -81
  219. package/hooks/stop-gate.ts +0 -22
  220. package/hooks/stop_dispatcher.ts +0 -147
  221. package/hooks/test-generator-hook.ts +0 -4
  222. package/hooks/tool-ledger.ts +0 -27
  223. package/hooks/trust_review.ts +0 -175
  224. package/lab/pipeline.ts +0 -75
  225. package/lab/policies.ts +0 -68
  226. package/runtime/common.ts +0 -111
  227. package/runtime/compat.ts +0 -174
  228. package/runtime/dispatcher.ts +0 -25
  229. package/runtime/ecosystem.ts +0 -186
  230. package/runtime/provider_bootstrap.ts +0 -99
  231. package/runtime/provider_smoke.ts +0 -34
  232. package/runtime/release_readiness.ts +0 -186
  233. package/runtime/team_router.ts +0 -144
  234. package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
  235. package/scripts/check-omg-standalone-clean.ts +0 -12
  236. package/scripts/check-runtime-clean.ts +0 -94
  237. package/scripts/omg.ts +0 -352
  238. package/scripts/settings-merge.ts +0 -93
  239. package/tools/commit_splitter.ts +0 -23
  240. package/tools/git_inspector.ts +0 -18
  241. package/tools/session_snapshot.ts +0 -47
  242. package/trac3er-oh-my-god-2.0.0.tgz +0 -0
  243. package/tsconfig.json +0 -15
@@ -0,0 +1,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)