@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,273 @@
1
+ #!/usr/bin/env python3
2
+ """Hashline Injector — injects content-hash anchors into file content on read.
3
+
4
+ Each line gets a tag: `{line_number}#{2-char-id}|{original line}`
5
+ where the 2-char ID is derived from SHA-256 of the line content mapped to
6
+ the charset ZPMQVRWSNKTXJBYH (16 chars, 4-bit nibbles of first hash byte).
7
+
8
+ Uses a sidecar cache at `.omg/state/hashline_cache.json` to avoid
9
+ regenerating hashes for unchanged files. Never modifies original files.
10
+
11
+ Feature flag: OMG_HASHLINE_ENABLED (default: False)
12
+ """
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ import time
19
+
20
+ HOOKS_DIR = os.path.dirname(__file__)
21
+ if HOOKS_DIR not in sys.path:
22
+ sys.path.insert(0, HOOKS_DIR)
23
+
24
+ from _common import (
25
+ setup_crash_handler,
26
+ json_input,
27
+ get_feature_flag,
28
+ get_project_dir,
29
+ atomic_json_write,
30
+ )
31
+
32
+ setup_crash_handler("hashline-injector")
33
+
34
+ # --- Constants ---
35
+
36
+ # 16-char charset for 4-bit nibble mapping
37
+ HASH_CHARSET = "ZPMQVRWSNKTXJBYH"
38
+
39
+ # Regex to strip hashline prefix: digits + # + 2 uppercase letters + |
40
+ _HASHLINE_RE = re.compile(r"^\d+#[A-Z]{2}\|")
41
+
42
+ # Sidecar cache path (relative to project dir)
43
+ _CACHE_REL_PATH = os.path.join(".omg", "state", "hashline_cache.json")
44
+
45
+
46
+ # --- Core Functions ---
47
+
48
+
49
+ def _line_hash_id(line: str) -> str:
50
+ """Generate 2-char hash ID from line content.
51
+
52
+ Takes first byte of SHA-256 digest, splits into two 4-bit nibbles,
53
+ maps each nibble to HASH_CHARSET.
54
+ """
55
+ digest = hashlib.sha256(line.encode("utf-8", errors="replace")).digest()
56
+ first_byte = digest[0]
57
+ high_nibble = (first_byte >> 4) & 0x0F
58
+ low_nibble = first_byte & 0x0F
59
+ return HASH_CHARSET[high_nibble] + HASH_CHARSET[low_nibble]
60
+
61
+
62
+ def inject_hashlines(content: str, file_path: str = None) -> str:
63
+ """Add hash anchors to each line of content.
64
+
65
+ Format: `{line_num}#{hash_id}|{original_line}` (1-indexed)
66
+
67
+ Args:
68
+ content: File content string
69
+ file_path: Optional file path for caching. If provided and the file
70
+ exists, hashes are cached to `.omg/state/hashline_cache.json`.
71
+
72
+ Returns:
73
+ Content with hash anchors prepended to each line.
74
+ Returns content unchanged if OMG_HASHLINE_ENABLED is False.
75
+ """
76
+ if not _is_enabled():
77
+ return content
78
+
79
+ # Check cache first
80
+ if file_path:
81
+ cached = _get_cached_hashes(file_path)
82
+ if cached is not None:
83
+ return _apply_cached_hashes(content, cached)
84
+
85
+ lines = content.split("\n")
86
+ result = []
87
+ line_hashes = {}
88
+
89
+ for i, line in enumerate(lines, start=1):
90
+ hash_id = _line_hash_id(line)
91
+ line_hashes[str(i)] = hash_id
92
+ result.append(f"{i}#{hash_id}|{line}")
93
+
94
+ # Cache if file_path provided
95
+ if file_path:
96
+ _cache_hashes(file_path, line_hashes)
97
+
98
+ return "\n".join(result)
99
+
100
+
101
+ def strip_hashlines(content: str) -> str:
102
+ """Remove hash anchors from content, restoring original text.
103
+
104
+ Strips `^\\d+#[A-Z]{2}\\|` prefix from each line.
105
+
106
+ Args:
107
+ content: Content with hash anchors
108
+
109
+ Returns:
110
+ Original content without hash anchors.
111
+ """
112
+ lines = content.split("\n")
113
+ result = []
114
+ for line in lines:
115
+ result.append(_HASHLINE_RE.sub("", line))
116
+ return "\n".join(result)
117
+
118
+
119
+ def _apply_cached_hashes(content: str, line_hashes: dict) -> str:
120
+ """Apply cached hash IDs to content lines."""
121
+ lines = content.split("\n")
122
+ result = []
123
+ for i, line in enumerate(lines, start=1):
124
+ hash_id = line_hashes.get(str(i))
125
+ if hash_id is None:
126
+ # Line count changed — cache is stale, regenerate
127
+ hash_id = _line_hash_id(line)
128
+ result.append(f"{i}#{hash_id}|{line}")
129
+ return "\n".join(result)
130
+
131
+
132
+ # --- Sidecar Cache ---
133
+
134
+
135
+ def _get_cache_path() -> str:
136
+ """Get absolute path to hashline cache file."""
137
+ return os.path.join(get_project_dir(), _CACHE_REL_PATH)
138
+
139
+
140
+ def _load_cache() -> dict:
141
+ """Load the entire hashline cache from disk. Returns empty dict on failure."""
142
+ cache_path = _get_cache_path()
143
+ try:
144
+ if not os.path.exists(cache_path):
145
+ return {}
146
+ with open(cache_path, "r", encoding="utf-8") as f:
147
+ return json.load(f)
148
+ except Exception:
149
+ return {}
150
+
151
+
152
+ def _get_cached_hashes(file_path: str):
153
+ """Get cached line hashes for a file, if still valid.
154
+
155
+ Args:
156
+ file_path: Path to the source file
157
+
158
+ Returns:
159
+ dict mapping line number (str) -> hash_id, or None if not cached
160
+ or if the file's mtime has changed (cache invalidation).
161
+ """
162
+ try:
163
+ abs_path = os.path.abspath(file_path)
164
+ if not os.path.exists(abs_path):
165
+ return None
166
+
167
+ cache = _load_cache()
168
+ entry = cache.get(abs_path)
169
+ if entry is None:
170
+ return None
171
+
172
+ # Check mtime for invalidation
173
+ current_mtime = os.path.getmtime(abs_path)
174
+ cached_mtime = entry.get("mtime", 0)
175
+ if abs(current_mtime - cached_mtime) > 0.001:
176
+ return None
177
+
178
+ return entry.get("line_hashes")
179
+ except Exception:
180
+ return None
181
+
182
+
183
+ def _cache_hashes(file_path: str, line_hashes: dict) -> None:
184
+ """Save line hashes to sidecar cache with mtime for invalidation.
185
+
186
+ Args:
187
+ file_path: Path to the source file
188
+ line_hashes: dict mapping line number (str) -> hash_id
189
+ """
190
+ try:
191
+ abs_path = os.path.abspath(file_path)
192
+ cache = _load_cache()
193
+
194
+ mtime = 0.0
195
+ if os.path.exists(abs_path):
196
+ mtime = os.path.getmtime(abs_path)
197
+
198
+ cache[abs_path] = {
199
+ "mtime": mtime,
200
+ "line_hashes": line_hashes,
201
+ }
202
+
203
+ atomic_json_write(_get_cache_path(), cache)
204
+ except Exception:
205
+ pass # Never crash on cache write failure
206
+
207
+
208
+ # --- Feature Flag ---
209
+
210
+
211
+ def _is_enabled() -> bool:
212
+ """Check if hashline injection is enabled.
213
+
214
+ Resolution order: OMG_HASHLINE_ENABLED env var → settings.json → False
215
+ """
216
+ # Fast path: check env var directly
217
+ env_val = os.environ.get("OMG_HASHLINE_ENABLED", "").lower()
218
+ if env_val in ("1", "true", "yes"):
219
+ return True
220
+ if env_val in ("0", "false", "no"):
221
+ return False
222
+ # Slow path: check settings.json via get_feature_flag
223
+ return get_feature_flag("HASHLINE", default=False)
224
+
225
+
226
+ # --- Hook Entry Point ---
227
+
228
+
229
+ def main():
230
+ """Hook stdin/stdout entry point for Claude Code PreToolUse hooks.
231
+
232
+ Reads JSON from stdin with tool_input containing file content.
233
+ If hashline injection is enabled and tool is a file read, injects
234
+ hash anchors into the content.
235
+ Writes modified tool input back to stdout.
236
+ Always exits 0 — never raises.
237
+ """
238
+ if not _is_enabled():
239
+ sys.exit(0)
240
+
241
+ data = json_input()
242
+ if not isinstance(data, dict):
243
+ sys.exit(0)
244
+
245
+ # Only inject for file-read tools
246
+ tool_name = data.get("tool_name", "")
247
+ if tool_name not in ("Read", "mcp__filesystem__read_file",
248
+ "mcp__filesystem__read_text_file"):
249
+ sys.exit(0)
250
+
251
+ tool_input = data.get("tool_input", {})
252
+ if not isinstance(tool_input, dict):
253
+ sys.exit(0)
254
+
255
+ content = tool_input.get("content", "")
256
+ file_path = tool_input.get("file_path", tool_input.get("filePath", ""))
257
+
258
+ if not content:
259
+ sys.exit(0)
260
+
261
+ try:
262
+ injected = inject_hashlines(content, file_path or None)
263
+ tool_input["content"] = injected
264
+ data["tool_input"] = tool_input
265
+ json.dump(data, sys.stdout)
266
+ except Exception:
267
+ pass # Graceful degradation — never crash
268
+
269
+ sys.exit(0)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """Hashline Validator — validates edit targets against stored hash anchors.
3
+
4
+ Validates that ``line_ref`` (e.g. ``"11#VK"``) matches the cached hash for
5
+ that line before allowing an edit. Rejects mismatched edits with a clear
6
+ error dict. Updates cache after successful edits.
7
+
8
+ Feature flag: OMG_HASHLINE_ENABLED (default: False)
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+ HOOKS_DIR = os.path.dirname(__file__)
16
+ if HOOKS_DIR not in sys.path:
17
+ sys.path.insert(0, HOOKS_DIR)
18
+
19
+ from _common import (
20
+ setup_crash_handler,
21
+ json_input,
22
+ get_feature_flag,
23
+ )
24
+
25
+ setup_crash_handler("hashline-validator")
26
+
27
+ # --- Constants ---
28
+
29
+ # Valid line_ref: digits + # + exactly 2 chars from HASH_CHARSET
30
+ _LINE_REF_RE = re.compile(r"^\d+#[ZPMQVRWSNKTXJBYH]{2}$")
31
+
32
+
33
+ # --- Feature Flag ---
34
+
35
+
36
+ def _is_enabled() -> bool:
37
+ """Check if hashline validation is enabled.
38
+
39
+ Resolution order: OMG_HASHLINE_ENABLED env var → settings.json → False
40
+ """
41
+ env_val = os.environ.get("OMG_HASHLINE_ENABLED", "").lower()
42
+ if env_val in ("1", "true", "yes"):
43
+ return True
44
+ if env_val in ("0", "false", "no"):
45
+ return False
46
+ return get_feature_flag("HASHLINE", default=False)
47
+
48
+
49
+ # --- Lazy Imports from hashline-injector ---
50
+
51
+ _injector = None
52
+
53
+
54
+ def _get_injector():
55
+ """Lazy-load hashline-injector module."""
56
+ global _injector
57
+ if _injector is None:
58
+ import importlib
59
+ _injector = importlib.import_module("hashline-injector")
60
+ return _injector
61
+
62
+
63
+ # --- Core Functions ---
64
+
65
+
66
+ def validate_line_ref_format(line_ref: str) -> bool:
67
+ """Return True if *line_ref* matches ``'{line_num}#{2-char hash_id}'``.
68
+
69
+ Hash ID characters must belong to the charset ``ZPMQVRWSNKTXJBYH``.
70
+
71
+ Args:
72
+ line_ref: Line reference string (e.g. ``"11#VK"``)
73
+
74
+ Returns:
75
+ True if format is valid, False otherwise.
76
+ """
77
+ if not isinstance(line_ref, str):
78
+ return False
79
+ return bool(_LINE_REF_RE.match(line_ref))
80
+
81
+
82
+ def validate_edit(file_path: str, line_ref: str, expected_line: str) -> dict:
83
+ """Validate a hash anchor before allowing an edit.
84
+
85
+ Args:
86
+ file_path: Path to the file being edited.
87
+ line_ref: Line reference ``"{line_num}#{hash_id}"`` (e.g. ``"11#VK"``).
88
+ expected_line: The expected content of the line (context, not used
89
+ for hash matching itself).
90
+
91
+ Returns:
92
+ dict with validation result — always contains ``"valid"`` key:
93
+
94
+ * Feature disabled → ``{"valid": True, "skipped": True}``
95
+ * No cache available → ``{"valid": True, "uncached": True}``
96
+ * Hash match → ``{"valid": True, "line": <int>}``
97
+ * Hash mismatch → ``{"valid": False, "error": "HASH_MISMATCH",
98
+ "line": <int>, "expected": <str>, "actual": <str>}``
99
+ * Bad format → ``{"valid": False, "error": "INVALID_LINE_REF",
100
+ "line_ref": <str>}``
101
+ """
102
+ # Skip when disabled
103
+ if not _is_enabled():
104
+ return {"valid": True, "skipped": True}
105
+
106
+ # Validate format
107
+ if not validate_line_ref_format(line_ref):
108
+ return {"valid": False, "error": "INVALID_LINE_REF", "line_ref": line_ref}
109
+
110
+ # Parse line_ref
111
+ parts = line_ref.split("#")
112
+ line_num = int(parts[0])
113
+ hash_id = parts[1]
114
+
115
+ # Load cache via injector
116
+ try:
117
+ injector = _get_injector()
118
+ cached_hashes = injector._get_cached_hashes(file_path)
119
+ except Exception:
120
+ # Injector unavailable — cannot validate
121
+ return {"valid": True, "uncached": True}
122
+
123
+ if cached_hashes is None:
124
+ return {"valid": True, "uncached": True}
125
+
126
+ # Look up the line in the cache
127
+ stored_hash = cached_hashes.get(str(line_num))
128
+ if stored_hash is None:
129
+ # Line number not in cache (file may have grown since last cache)
130
+ return {"valid": True, "uncached": True}
131
+
132
+ # Compare
133
+ if stored_hash != hash_id:
134
+ return {
135
+ "valid": False,
136
+ "error": "HASH_MISMATCH",
137
+ "line": line_num,
138
+ "expected": hash_id,
139
+ "actual": stored_hash,
140
+ }
141
+
142
+ return {"valid": True, "line": line_num}
143
+
144
+
145
+ def update_hashes_after_edit(file_path: str, new_content: str) -> bool:
146
+ """Refresh the hash cache after a successful edit.
147
+
148
+ Re-generates line hashes for *new_content* and updates the sidecar
149
+ cache (``hashline_cache.json``) for *file_path*.
150
+
151
+ Args:
152
+ file_path: Path to the edited file.
153
+ new_content: The file content after the edit.
154
+
155
+ Returns:
156
+ True on success (or when disabled), False on error.
157
+ """
158
+ if not _is_enabled():
159
+ return True
160
+
161
+ try:
162
+ injector = _get_injector()
163
+ _line_hash_id = injector._line_hash_id
164
+ _cache_hashes = injector._cache_hashes
165
+ except Exception:
166
+ return False
167
+
168
+ try:
169
+ lines = new_content.split("\n")
170
+ line_hashes = {}
171
+ for i, line in enumerate(lines, start=1):
172
+ line_hashes[str(i)] = _line_hash_id(line)
173
+
174
+ _cache_hashes(file_path, line_hashes)
175
+ return True
176
+ except Exception:
177
+ return False
178
+
179
+
180
+ # --- Hook Entry Point ---
181
+
182
+
183
+ def main():
184
+ """Hook stdin/stdout entry point.
185
+
186
+ Reads JSON from stdin::
187
+
188
+ {"file_path": "...", "line_ref": "11#VK", "expected_line": "..."}
189
+
190
+ Calls :func:`validate_edit` and writes the result dict to stdout.
191
+ Always exits 0 — never raises.
192
+ """
193
+ if not _is_enabled():
194
+ json.dump({"valid": True, "skipped": True}, sys.stdout)
195
+ sys.exit(0)
196
+
197
+ data = json_input()
198
+ if not isinstance(data, dict):
199
+ json.dump({"valid": False, "error": "INVALID_INPUT"}, sys.stdout)
200
+ sys.exit(0)
201
+
202
+ file_path = data.get("file_path", "")
203
+ line_ref = data.get("line_ref", "")
204
+ expected_line = data.get("expected_line", "")
205
+
206
+ try:
207
+ result = validate_edit(file_path, line_ref, expected_line)
208
+ json.dump(result, sys.stdout)
209
+ except Exception:
210
+ json.dump({"valid": False, "error": "INTERNAL_ERROR"}, sys.stdout)
211
+
212
+ sys.exit(0)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Stop Hook: Idle Detector (v1)
4
+
5
+ Detects when an agent has gone idle with incomplete todos.
6
+ Reads .omg/state/todo_progress.json (from todo-state-tracker)
7
+ and writes continuation signal to .omg/state/idle_signal.json.
8
+
9
+ Detection only — does not block the stop.
10
+
11
+ Feature flag: OMG_IDLE_DETECTION_ENABLED (default: False)
12
+ """
13
+ import json
14
+ import sys
15
+ import os
16
+ from datetime import datetime, timezone
17
+
18
+ HOOKS_DIR = os.path.dirname(__file__)
19
+ if HOOKS_DIR not in sys.path:
20
+ sys.path.insert(0, HOOKS_DIR)
21
+
22
+ from _common import ( # noqa: E402
23
+ setup_crash_handler,
24
+ json_input,
25
+ get_project_dir,
26
+ get_feature_flag,
27
+ atomic_json_write,
28
+ )
29
+
30
+ setup_crash_handler("idle-detector", fail_closed=False)
31
+
32
+ # Feature flag check — exit cleanly if disabled
33
+ if not get_feature_flag("IDLE_DETECTION", default=False):
34
+ sys.exit(0)
35
+
36
+ # Consume stdin (stop hooks receive JSON input)
37
+ _data = json_input()
38
+
39
+ project_dir = get_project_dir()
40
+ todo_path = os.path.join(project_dir, ".omg", "state", "todo_progress.json")
41
+ signal_path = os.path.join(project_dir, ".omg", "state", "idle_signal.json")
42
+
43
+
44
+ def _write_signal(idle: bool, incomplete: list | None = None, call_count: int = 0):
45
+ """Write idle signal state atomically."""
46
+ items = incomplete or []
47
+ atomic_json_write(signal_path, {
48
+ "idle_detected": idle,
49
+ "incomplete_count": len(items),
50
+ "incomplete_items": items[:3],
51
+ "timestamp": datetime.now(timezone.utc).isoformat(),
52
+ "trigger": "stop_hook",
53
+ "call_count": call_count,
54
+ })
55
+
56
+
57
+ # --- Read todo progress ---
58
+ todo_state = None
59
+ if os.path.exists(todo_path):
60
+ try:
61
+ with open(todo_path, "r", encoding="utf-8") as f:
62
+ todo_state = json.load(f)
63
+ except Exception:
64
+ todo_state = None
65
+
66
+ # No todo file or malformed → not idle
67
+ if not isinstance(todo_state, dict):
68
+ _write_signal(False)
69
+ sys.exit(0)
70
+
71
+ incomplete = todo_state.get("incomplete", [])
72
+ if not isinstance(incomplete, list):
73
+ incomplete = []
74
+
75
+ # No incomplete items → not idle
76
+ if not incomplete:
77
+ _write_signal(False)
78
+ sys.exit(0)
79
+
80
+ # --- Check call counter from existing signal ---
81
+ call_count = 0
82
+ if os.path.exists(signal_path):
83
+ try:
84
+ with open(signal_path, "r", encoding="utf-8") as f:
85
+ existing = json.load(f)
86
+ if isinstance(existing, dict):
87
+ call_count = existing.get("call_count", 0)
88
+ except Exception:
89
+ call_count = 0
90
+
91
+ # Idle = incomplete todos AND hook has been called at least once before
92
+ idle_detected = call_count >= 1
93
+
94
+ _write_signal(idle_detected, incomplete, call_count + 1)
95
+ sys.exit(0)