@trac3er/oh-my-god 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/pr_generator.py +404 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,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 ''
|
package/hooks/_memory.py
ADDED
|
@@ -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 ""
|