@trac3er/oh-my-god 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/.claude-plugin/marketplace.json +36 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.claude-plugin/scripts/install.sh +49 -0
  4. package/.claude-plugin/scripts/uninstall.sh +80 -0
  5. package/.claude-plugin/scripts/update.sh +84 -0
  6. package/.mcp.json +20 -0
  7. package/LICENSE +21 -0
  8. package/OMG-setup.sh +1093 -0
  9. package/README.md +335 -0
  10. package/THIRD_PARTY_NOTICES.md +24 -0
  11. package/UPSTREAM_DIFF.md +20 -0
  12. package/agents/__init__.py +1 -0
  13. package/agents/_model_roles.yaml +26 -0
  14. package/agents/designer.md +67 -0
  15. package/agents/explore.md +60 -0
  16. package/agents/model_roles.py +196 -0
  17. package/agents/omg-api-builder.md +23 -0
  18. package/agents/omg-architect-mode.md +43 -0
  19. package/agents/omg-architect.md +13 -0
  20. package/agents/omg-backend-engineer.md +43 -0
  21. package/agents/omg-critic.md +16 -0
  22. package/agents/omg-database-engineer.md +43 -0
  23. package/agents/omg-escalation-router.md +17 -0
  24. package/agents/omg-executor.md +12 -0
  25. package/agents/omg-frontend-designer.md +42 -0
  26. package/agents/omg-implement-mode.md +50 -0
  27. package/agents/omg-infra-engineer.md +43 -0
  28. package/agents/omg-qa-tester.md +16 -0
  29. package/agents/omg-research-mode.md +43 -0
  30. package/agents/omg-security-auditor.md +43 -0
  31. package/agents/omg-testing-engineer.md +43 -0
  32. package/agents/plan.md +80 -0
  33. package/agents/quick_task.md +64 -0
  34. package/agents/reviewer.md +83 -0
  35. package/agents/task.md +71 -0
  36. package/commands/OMG:ccg.md +22 -0
  37. package/commands/OMG:compat.md +57 -0
  38. package/commands/OMG:crazy.md +125 -0
  39. package/commands/OMG:domain-init.md +11 -0
  40. package/commands/OMG:escalate.md +52 -0
  41. package/commands/OMG:health-check.md +45 -0
  42. package/commands/OMG:init.md +134 -0
  43. package/commands/OMG:mode.md +44 -0
  44. package/commands/OMG:project-init.md +11 -0
  45. package/commands/OMG:ralph-start.md +43 -0
  46. package/commands/OMG:ralph-stop.md +23 -0
  47. package/commands/OMG:teams.md +39 -0
  48. package/commands/ai-commit.md +113 -0
  49. package/commands/ccg.md +9 -0
  50. package/commands/create-agent.md +183 -0
  51. package/commands/omc-teams.md +9 -0
  52. package/commands/session-branch.md +85 -0
  53. package/commands/session-fork.md +53 -0
  54. package/commands/session-merge.md +134 -0
  55. package/commands/theme.md +44 -0
  56. package/config/lsp_languages.yaml +324 -0
  57. package/config/themes/catppuccin-frappe.yaml +14 -0
  58. package/config/themes/catppuccin-latte.yaml +14 -0
  59. package/config/themes/catppuccin-macchiato.yaml +14 -0
  60. package/config/themes/catppuccin-mocha.yaml +14 -0
  61. package/config/themes/dracula.yaml +14 -0
  62. package/config/themes/gruvbox-dark.yaml +14 -0
  63. package/config/themes/nord.yaml +14 -0
  64. package/config/themes/one-dark.yaml +14 -0
  65. package/config/themes/solarized-dark.yaml +14 -0
  66. package/config/themes/tokyo-night.yaml +14 -0
  67. package/control_plane/__init__.py +2 -0
  68. package/control_plane/openapi.yaml +109 -0
  69. package/control_plane/server.py +107 -0
  70. package/control_plane/service.py +148 -0
  71. package/crates/omg-natives/Cargo.toml +17 -0
  72. package/crates/omg-natives/src/clipboard.rs +5 -0
  73. package/crates/omg-natives/src/glob.rs +15 -0
  74. package/crates/omg-natives/src/grep.rs +15 -0
  75. package/crates/omg-natives/src/highlight.rs +15 -0
  76. package/crates/omg-natives/src/html.rs +14 -0
  77. package/crates/omg-natives/src/image.rs +5 -0
  78. package/crates/omg-natives/src/keys.rs +5 -0
  79. package/crates/omg-natives/src/lib.rs +36 -0
  80. package/crates/omg-natives/src/prof.rs +5 -0
  81. package/crates/omg-natives/src/ps.rs +5 -0
  82. package/crates/omg-natives/src/shell.rs +5 -0
  83. package/crates/omg-natives/src/task.rs +5 -0
  84. package/crates/omg-natives/src/text.rs +14 -0
  85. package/hooks/_agent_registry.py +421 -0
  86. package/hooks/_budget.py +31 -0
  87. package/hooks/_common.py +476 -0
  88. package/hooks/_learnings.py +126 -0
  89. package/hooks/_memory.py +103 -0
  90. package/hooks/circuit-breaker.py +270 -0
  91. package/hooks/config-guard.py +163 -0
  92. package/hooks/context_pressure.py +53 -0
  93. package/hooks/credential_store.py +801 -0
  94. package/hooks/fetch-rate-limits.py +212 -0
  95. package/hooks/firewall.py +48 -0
  96. package/hooks/hashline-formatter-bridge.py +224 -0
  97. package/hooks/hashline-injector.py +273 -0
  98. package/hooks/hashline-validator.py +216 -0
  99. package/hooks/idle-detector.py +95 -0
  100. package/hooks/intentgate-keyword-detector.py +188 -0
  101. package/hooks/magic-keyword-router.py +195 -0
  102. package/hooks/policy_engine.py +310 -0
  103. package/hooks/post-tool-failure.py +19 -0
  104. package/hooks/post-write.py +199 -0
  105. package/hooks/pre-compact.py +204 -0
  106. package/hooks/pre-tool-inject.py +98 -0
  107. package/hooks/prompt-enhancer.py +672 -0
  108. package/hooks/quality-runner.py +191 -0
  109. package/hooks/secret-guard.py +47 -0
  110. package/hooks/session-end-capture.py +137 -0
  111. package/hooks/session-start.py +275 -0
  112. package/hooks/shadow_manager.py +297 -0
  113. package/hooks/state_migration.py +209 -0
  114. package/hooks/stop-gate.py +7 -0
  115. package/hooks/stop_dispatcher.py +929 -0
  116. package/hooks/test-validator.py +138 -0
  117. package/hooks/todo-state-tracker.py +114 -0
  118. package/hooks/tool-ledger.py +126 -0
  119. package/hooks/trust_review.py +524 -0
  120. package/install.sh +9 -0
  121. package/omg_natives/__init__.py +186 -0
  122. package/omg_natives/_bindings.py +165 -0
  123. package/omg_natives/clipboard.py +36 -0
  124. package/omg_natives/glob.py +42 -0
  125. package/omg_natives/grep.py +61 -0
  126. package/omg_natives/highlight.py +54 -0
  127. package/omg_natives/html.py +157 -0
  128. package/omg_natives/image.py +51 -0
  129. package/omg_natives/keys.py +46 -0
  130. package/omg_natives/prof.py +39 -0
  131. package/omg_natives/ps.py +93 -0
  132. package/omg_natives/shell.py +58 -0
  133. package/omg_natives/task.py +41 -0
  134. package/omg_natives/text.py +50 -0
  135. package/package.json +26 -0
  136. package/plugins/README.md +82 -0
  137. package/plugins/advanced/commands/OMG:code-review.md +114 -0
  138. package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
  139. package/plugins/advanced/commands/OMG:handoff.md +115 -0
  140. package/plugins/advanced/commands/OMG:learn.md +110 -0
  141. package/plugins/advanced/commands/OMG:maintainer.md +31 -0
  142. package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
  143. package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
  144. package/plugins/advanced/commands/OMG:security-review.md +119 -0
  145. package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
  146. package/plugins/advanced/commands/OMG:ship.md +46 -0
  147. package/plugins/advanced/plugin.json +96 -0
  148. package/plugins/core/plugin.json +82 -0
  149. package/pytest.ini +5 -0
  150. package/registry/__init__.py +1 -0
  151. package/registry/verify_artifact.py +90 -0
  152. package/rules/contextual/architect-mode.md +9 -0
  153. package/rules/contextual/big-picture.md +20 -0
  154. package/rules/contextual/code-hygiene.md +26 -0
  155. package/rules/contextual/context-management.md +19 -0
  156. package/rules/contextual/context-minimization.md +32 -0
  157. package/rules/contextual/ddd-sdd.md +28 -0
  158. package/rules/contextual/dependency-safety.md +16 -0
  159. package/rules/contextual/doc-check.md +13 -0
  160. package/rules/contextual/implement-mode.md +9 -0
  161. package/rules/contextual/infra-safety.md +14 -0
  162. package/rules/contextual/outside-in.md +13 -0
  163. package/rules/contextual/persistent-mode.md +24 -0
  164. package/rules/contextual/research-mode.md +9 -0
  165. package/rules/contextual/security-domains.md +25 -0
  166. package/rules/contextual/vision-detection.md +27 -0
  167. package/rules/contextual/web-search.md +25 -0
  168. package/rules/contextual/write-verify.md +23 -0
  169. package/rules/core/00-truth.md +20 -0
  170. package/rules/core/01-surgical.md +19 -0
  171. package/rules/core/02-circuit-breaker.md +22 -0
  172. package/rules/core/03-ensemble.md +28 -0
  173. package/rules/core/04-testing.md +30 -0
  174. package/runtime/__init__.py +32 -0
  175. package/runtime/adapters/__init__.py +13 -0
  176. package/runtime/adapters/claude.py +60 -0
  177. package/runtime/adapters/gpt.py +53 -0
  178. package/runtime/adapters/local.py +53 -0
  179. package/runtime/business_workflow.py +220 -0
  180. package/runtime/compat.py +1299 -0
  181. package/runtime/custom_agent_loader.py +366 -0
  182. package/runtime/dispatcher.py +47 -0
  183. package/runtime/ecosystem.py +371 -0
  184. package/runtime/legacy_compat.py +7 -0
  185. package/runtime/omc_compat.py +7 -0
  186. package/runtime/omc_contract_snapshot.json +916 -0
  187. package/runtime/omg_compat_contract_snapshot.json +916 -0
  188. package/runtime/subagent_dispatcher.py +362 -0
  189. package/runtime/team_router.py +838 -0
  190. package/scripts/check-omc-contract-snapshot.py +12 -0
  191. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  192. package/scripts/check-omg-standalone-clean.py +102 -0
  193. package/scripts/legacy_to_omg_migrate.py +29 -0
  194. package/scripts/migrate-omc.py +464 -0
  195. package/scripts/omc_to_omg_migrate.py +12 -0
  196. package/scripts/omg.py +493 -0
  197. package/scripts/settings-merge.py +224 -0
  198. package/scripts/verify-no-omc.sh +5 -0
  199. package/scripts/verify-standalone.sh +21 -0
  200. package/templates/idea.yml +30 -0
  201. package/templates/policy.yaml +15 -0
  202. package/templates/profile.yaml +25 -0
  203. package/templates/runtime.yaml +12 -0
  204. package/templates/working-memory.md +17 -0
  205. package/tools/__init__.py +2 -0
  206. package/tools/browser_consent.py +289 -0
  207. package/tools/browser_stealth.py +481 -0
  208. package/tools/browser_tool.py +448 -0
  209. package/tools/changelog_generator.py +268 -0
  210. package/tools/commit_splitter.py +361 -0
  211. package/tools/config_discovery.py +151 -0
  212. package/tools/config_merger.py +449 -0
  213. package/tools/git_inspector.py +298 -0
  214. package/tools/lsp_client.py +275 -0
  215. package/tools/lsp_discovery.py +231 -0
  216. package/tools/lsp_operations.py +392 -0
  217. package/tools/python_repl.py +656 -0
  218. package/tools/python_sandbox.py +609 -0
  219. package/tools/search_providers/__init__.py +77 -0
  220. package/tools/search_providers/brave.py +115 -0
  221. package/tools/search_providers/exa.py +116 -0
  222. package/tools/search_providers/jina.py +104 -0
  223. package/tools/search_providers/perplexity.py +139 -0
  224. package/tools/search_providers/synthetic.py +74 -0
  225. package/tools/session_snapshot.py +736 -0
  226. package/tools/ssh_manager.py +912 -0
  227. package/tools/theme_engine.py +294 -0
  228. package/tools/theme_selector.py +137 -0
  229. package/tools/web_search.py +622 -0
@@ -0,0 +1,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