@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,176 @@
1
+ """
2
+ Cost Ledger Storage — JSONL persistence for token/cost tracking.
3
+
4
+ Provides append_cost_entry, read_cost_summary, and rotate_cost_ledger.
5
+ Follows the same fcntl locking + 5MB rotation pattern as tool-ledger.py.
6
+
7
+ Entry schema:
8
+ {"ts": ISO8601, "tool": str, "tokens_in": int, "tokens_out": int,
9
+ "cost_usd": float, "model": str, "session_id": str}
10
+
11
+ Pure stdlib — no external deps.
12
+ """
13
+ import fcntl
14
+ import json
15
+ import os
16
+ import shutil
17
+
18
+ # ── Constants ──
19
+ _LEDGER_SUBDIR = os.path.join(".omg", "state", "ledger")
20
+ _LEDGER_FILENAME = "cost-ledger.jsonl"
21
+ MAX_BYTES = 5 * 1024 * 1024 # 5MB rotation threshold
22
+
23
+
24
+ def _ledger_path(project_dir: str) -> str:
25
+ """Return the absolute path to the cost ledger JSONL file."""
26
+ return os.path.join(project_dir, _LEDGER_SUBDIR, _LEDGER_FILENAME)
27
+
28
+
29
+ def append_cost_entry(project_dir: str, entry: dict) -> None:
30
+ """Append a cost entry to the cost ledger JSONL file.
31
+
32
+ Creates .omg/state/ledger/ if missing. Uses fcntl file locking
33
+ with fallback to unlocked write (crash isolation invariant).
34
+
35
+ Args:
36
+ project_dir: Project root directory.
37
+ entry: Dict with keys ts, tool, tokens_in, tokens_out,
38
+ cost_usd, model, session_id.
39
+ """
40
+ ledger_dir = os.path.join(project_dir, _LEDGER_SUBDIR)
41
+ os.makedirs(ledger_dir, exist_ok=True)
42
+
43
+ path = _ledger_path(project_dir)
44
+ line = json.dumps(entry, separators=(",", ":")) + "\n"
45
+
46
+ try:
47
+ fd = open(path, "a")
48
+ fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
49
+ fd.write(line)
50
+ fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
51
+ fd.close()
52
+ except (ImportError, BlockingIOError):
53
+ # Fallback: write without locking
54
+ try:
55
+ with open(path, "a") as f:
56
+ f.write(line)
57
+ except Exception:
58
+ pass
59
+ except Exception:
60
+ pass # Non-blocking: crash isolation invariant
61
+
62
+
63
+ def read_cost_summary(project_dir: str, time_range=None) -> dict:
64
+ """Read and aggregate cost entries from the ledger.
65
+
66
+ Args:
67
+ project_dir: Project root directory.
68
+ time_range: Optional (start, end) ISO8601 strings for filtering.
69
+ Not yet implemented — reserved for future use.
70
+
71
+ Returns:
72
+ Dict with keys:
73
+ total_tokens (int): Sum of tokens_in + tokens_out across all entries.
74
+ total_cost_usd (float): Sum of cost_usd across all entries.
75
+ by_tool (dict): {tool_name: {"tokens": int, "cost_usd": float, "count": int}}
76
+ by_session (dict): {session_id: {"tokens": int, "cost_usd": float, "count": int}}
77
+ entry_count (int): Number of valid entries processed.
78
+ """
79
+ empty = {
80
+ "total_tokens": 0,
81
+ "total_cost_usd": 0.0,
82
+ "by_tool": {},
83
+ "by_session": {},
84
+ "entry_count": 0,
85
+ }
86
+
87
+ path = _ledger_path(project_dir)
88
+ if not os.path.exists(path):
89
+ return empty
90
+
91
+ total_tokens = 0
92
+ total_cost = 0.0
93
+ by_tool: dict = {}
94
+ by_session: dict = {}
95
+ entry_count = 0
96
+
97
+ try:
98
+ with open(path, "r", encoding="utf-8") as f:
99
+ for line in f:
100
+ line = line.strip()
101
+ if not line:
102
+ continue
103
+ try:
104
+ entry = json.loads(line)
105
+ except (json.JSONDecodeError, ValueError):
106
+ # Skip malformed lines gracefully
107
+ continue
108
+
109
+ tokens_in = entry.get("tokens_in", 0)
110
+ tokens_out = entry.get("tokens_out", 0)
111
+ cost_usd = entry.get("cost_usd", 0.0)
112
+ tool = entry.get("tool", "unknown")
113
+ session_id = entry.get("session_id", "unknown")
114
+ line_tokens = tokens_in + tokens_out
115
+
116
+ total_tokens += line_tokens
117
+ total_cost += cost_usd
118
+ entry_count += 1
119
+
120
+ # Aggregate by tool
121
+ if tool not in by_tool:
122
+ by_tool[tool] = {"tokens": 0, "cost_usd": 0.0, "count": 0}
123
+ by_tool[tool]["tokens"] += line_tokens
124
+ by_tool[tool]["cost_usd"] += cost_usd
125
+ by_tool[tool]["count"] += 1
126
+
127
+ # Aggregate by session
128
+ if session_id not in by_session:
129
+ by_session[session_id] = {"tokens": 0, "cost_usd": 0.0, "count": 0}
130
+ by_session[session_id]["tokens"] += line_tokens
131
+ by_session[session_id]["cost_usd"] += cost_usd
132
+ by_session[session_id]["count"] += 1
133
+
134
+ except Exception:
135
+ pass # Crash isolation: return what we have
136
+
137
+ return {
138
+ "total_tokens": total_tokens,
139
+ "total_cost_usd": total_cost,
140
+ "by_tool": by_tool,
141
+ "by_session": by_session,
142
+ "entry_count": entry_count,
143
+ }
144
+
145
+
146
+ def rotate_cost_ledger(project_dir: str) -> None:
147
+ """Rotate cost ledger when it exceeds 5MB.
148
+
149
+ Follows the same pattern as tool-ledger.py:
150
+ - Size-only heuristic (avoids O(n) line-count scan)
151
+ - Keeps only one archive with .1 suffix
152
+ - Removes old archive before moving current file
153
+
154
+ Args:
155
+ project_dir: Project root directory.
156
+ """
157
+ path = _ledger_path(project_dir)
158
+
159
+ try:
160
+ if not os.path.exists(path):
161
+ return
162
+
163
+ size = os.path.getsize(path)
164
+ if size <= MAX_BYTES:
165
+ return
166
+
167
+ archive = path + ".1"
168
+ # Keep only one archive
169
+ if os.path.exists(archive):
170
+ try:
171
+ os.remove(archive)
172
+ except OSError:
173
+ pass
174
+ shutil.move(path, archive)
175
+ except Exception:
176
+ pass # Non-blocking: crash isolation invariant
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Learnings storage utilities for OMG compound learning."""
3
+ import os
4
+ import glob
5
+ import re
6
+
7
+
8
+ def read_file_safe(path, max_bytes=4096):
9
+ """Safely read a file, returning empty string on error."""
10
+ try:
11
+ with open(path, 'r') as f:
12
+ return f.read(max_bytes)
13
+ except (OSError, IOError):
14
+ return ''
15
+
16
+
17
+ def aggregate_learnings(project_dir: str, max_patterns: int = 10) -> str:
18
+ """Read all learning files, aggregate top patterns into summary.
19
+
20
+ Returns formatted string with top tool patterns, max 500 chars.
21
+ """
22
+ learn_dir = os.path.join(project_dir, '.omg', 'state', 'learnings')
23
+ if not os.path.isdir(learn_dir):
24
+ return ''
25
+
26
+ all_tools = {} # tool -> total count across sessions
27
+ all_files = {} # file -> total count across sessions
28
+
29
+ for fname in os.listdir(learn_dir):
30
+ if not fname.endswith('.md'):
31
+ continue
32
+ content = read_file_safe(os.path.join(learn_dir, fname))
33
+ in_tools = False
34
+ in_files = False
35
+ for line in content.split('\n'):
36
+ if line.startswith('## Most Used Tools'):
37
+ in_tools = True
38
+ in_files = False
39
+ continue
40
+ if line.startswith('## Most Modified Files'):
41
+ in_tools = False
42
+ in_files = True
43
+ continue
44
+ if line.startswith('##'):
45
+ in_tools = False
46
+ in_files = False
47
+ continue
48
+ # Parse '- toolname: Nx' format
49
+ match = re.match(r'^-\s+(.+?):\s+(\d+)x\s*$', line.strip())
50
+ if match:
51
+ name = match.group(1).strip()
52
+ count = int(match.group(2))
53
+ if in_tools:
54
+ all_tools[name] = all_tools.get(name, 0) + count
55
+ elif in_files:
56
+ all_files[name] = all_files.get(name, 0) + count
57
+
58
+ return format_critical_patterns(all_tools, all_files, max_patterns)
59
+
60
+
61
+ def format_critical_patterns(tools: dict, files: dict, max_patterns: int = 10) -> str:
62
+ """Format tool and file patterns into critical-patterns summary.
63
+
64
+ Returns string ≤500 chars.
65
+ """
66
+ if not tools and not files:
67
+ return ''
68
+
69
+ lines = ['# Critical Patterns']
70
+
71
+ if tools:
72
+ lines.append('## Top Tools')
73
+ for tool, count in sorted(tools.items(), key=lambda x: -x[1])[:max_patterns]:
74
+ lines.append(f'- {tool}: {count}x total')
75
+
76
+ if files:
77
+ lines.append('## Top Files')
78
+ for fpath, count in sorted(files.items(), key=lambda x: -x[1])[:max_patterns]:
79
+ basename = os.path.basename(fpath)
80
+ lines.append(f'- {basename}: {count}x total')
81
+
82
+ result = '\n'.join(lines)
83
+ return result[:500] # Cap at 500 chars
84
+
85
+
86
+ def rotate_learnings(project_dir: str, max_files: int = 30) -> int:
87
+ """Delete oldest learning files if count exceeds max_files.
88
+
89
+ Returns number of files deleted.
90
+ """
91
+ learn_dir = os.path.join(project_dir, '.omg', 'state', 'learnings')
92
+ if not os.path.isdir(learn_dir):
93
+ return 0
94
+
95
+ files = sorted(glob.glob(os.path.join(learn_dir, '*.md')))
96
+ excess = len(files) - max_files
97
+ if excess <= 0:
98
+ return 0
99
+
100
+ for f in files[:excess]:
101
+ try:
102
+ os.remove(f)
103
+ except OSError:
104
+ pass
105
+ return excess
106
+
107
+
108
+ def save_critical_patterns(project_dir: str) -> str:
109
+ """Generate and save critical-patterns.md to .omg/knowledge/.
110
+
111
+ Returns the path of the written file, or empty string on failure.
112
+ """
113
+ content = aggregate_learnings(project_dir)
114
+ if not content:
115
+ return ''
116
+
117
+ knowledge_dir = os.path.join(project_dir, '.omg', 'knowledge')
118
+ os.makedirs(knowledge_dir, exist_ok=True)
119
+ path = os.path.join(knowledge_dir, 'critical-patterns.md')
120
+
121
+ try:
122
+ with open(path, 'w') as f:
123
+ f.write(content)
124
+ return path
125
+ except OSError:
126
+ return ''
@@ -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,150 @@
1
+ """Protected context registry for PreCompact hook.
2
+
3
+ Reads .claude-context-protect entries (file paths, regex patterns, literal strings)
4
+ and collects protected context items to re-inject via additionalContext during compaction.
5
+
6
+ Default protections (when no .claude-context-protect exists):
7
+ - CLAUDE.md content
8
+ - Active task definitions (## Task:, - [ ])
9
+ - Recent error messages (Error:, Exception:, FAILED)
10
+
11
+ Pure stdlib — no external dependencies.
12
+ """
13
+ import os
14
+ import re
15
+
16
+
17
+ PROTECT_FILE_NAME = ".claude-context-protect"
18
+
19
+ # Default protection patterns (used when no protect file exists)
20
+ _DEFAULT_TASK_PATTERNS = [
21
+ re.compile(r"^## Task:"),
22
+ re.compile(r"^- \[ \]"),
23
+ ]
24
+ _DEFAULT_ERROR_KEYWORDS = ("Error:", "Exception:", "FAILED")
25
+
26
+
27
+ def load_protect_entries(project_dir):
28
+ """Read .claude-context-protect file, return list of entries or None if missing.
29
+
30
+ Returns:
31
+ list[str] | None: List of non-empty, non-comment lines. None if file missing.
32
+ """
33
+ protect_path = os.path.join(project_dir, PROTECT_FILE_NAME)
34
+ if not os.path.isfile(protect_path):
35
+ return None
36
+
37
+ try:
38
+ with open(protect_path, "r", encoding="utf-8", errors="ignore") as f:
39
+ lines = f.readlines()
40
+ except Exception:
41
+ return None
42
+
43
+ entries = []
44
+ for line in lines:
45
+ stripped = line.strip()
46
+ if stripped and not stripped.startswith("#"):
47
+ entries.append(stripped)
48
+ return entries
49
+
50
+
51
+ def _read_file_content(file_path):
52
+ """Read file content. Returns stripped string or None on failure."""
53
+ try:
54
+ if not os.path.isfile(file_path):
55
+ return None
56
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
57
+ content = f.read().strip()
58
+ return content if content else None
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def _match_entry_against_lines(entry, context_lines):
64
+ """Match entry against context lines. Tries regex first, falls back to literal.
65
+
66
+ Returns:
67
+ list[str]: Matching lines.
68
+ """
69
+ try:
70
+ pattern = re.compile(entry)
71
+ return [line for line in context_lines if pattern.search(line)]
72
+ except re.error:
73
+ # Invalid regex — fall back to literal substring match
74
+ return [line for line in context_lines if entry in line]
75
+
76
+
77
+ def _process_entry(entry, project_dir, context_lines):
78
+ """Process a single protect entry. Returns list of protected strings.
79
+
80
+ Resolution order:
81
+ 1. If entry resolves to an existing file → include file content
82
+ 2. Otherwise, try regex match against context lines
83
+ 3. If regex fails (re.error), fall back to literal substring match
84
+ """
85
+ # 1. Try as file path
86
+ file_path = os.path.join(project_dir, entry)
87
+ content = _read_file_content(file_path)
88
+ if content is not None:
89
+ return [content]
90
+
91
+ # 2. Try as regex/literal against context lines
92
+ return _match_entry_against_lines(entry, context_lines)
93
+
94
+
95
+ def _get_default_protections(project_dir, context_lines):
96
+ """Apply default protections when no .claude-context-protect exists.
97
+
98
+ Default protected items:
99
+ - CLAUDE.md content (if file exists)
100
+ - Active task definitions (## Task:, - [ ])
101
+ - Recent error messages (Error:, Exception:, FAILED)
102
+ """
103
+ parts = []
104
+
105
+ # 1. CLAUDE.md content
106
+ claude_md_path = os.path.join(project_dir, "CLAUDE.md")
107
+ claude_content = _read_file_content(claude_md_path)
108
+ if claude_content:
109
+ parts.append(claude_content)
110
+
111
+ # 2. Active task definitions
112
+ for line in context_lines:
113
+ for pat in _DEFAULT_TASK_PATTERNS:
114
+ if pat.search(line):
115
+ parts.append(line)
116
+ break
117
+
118
+ # 3. Recent error messages
119
+ for line in context_lines:
120
+ if any(kw in line for kw in _DEFAULT_ERROR_KEYWORDS):
121
+ parts.append(line)
122
+
123
+ return parts
124
+
125
+
126
+ def collect_protected_context(project_dir, context_text=""):
127
+ """Collect all protected context items and return as a single string.
128
+
129
+ Args:
130
+ project_dir: Project root directory.
131
+ context_text: Current context text to scan for regex/literal matches.
132
+
133
+ Returns:
134
+ str: Protected context items joined by newlines. Empty string if nothing.
135
+ """
136
+ context_lines = [l for l in context_text.split("\n") if l.strip()] if context_text else []
137
+ protected_parts = []
138
+
139
+ entries = load_protect_entries(project_dir)
140
+
141
+ if entries is None:
142
+ # No protect file — use defaults
143
+ protected_parts = _get_default_protections(project_dir, context_lines)
144
+ else:
145
+ # Process each entry from protect file
146
+ for entry in entries:
147
+ matched = _process_entry(entry, project_dir, context_lines)
148
+ protected_parts.extend(matched)
149
+
150
+ return "\n".join(protected_parts) if protected_parts else ""