@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,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Stop Hook: Quality Gate Runner
|
|
4
|
+
Reads .omg/state/quality-gate.json and runs configured QA commands.
|
|
5
|
+
Blocks completion via JSON decision if any command fails.
|
|
6
|
+
Skips silently if config does not exist.
|
|
7
|
+
|
|
8
|
+
Callable API:
|
|
9
|
+
check_quality_runner(data, project_dir) -> list[str]
|
|
10
|
+
Returns list of block reasons (empty = pass).
|
|
11
|
+
"""
|
|
12
|
+
import json, sys, os, subprocess, shlex
|
|
13
|
+
|
|
14
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
15
|
+
if HOOKS_DIR not in sys.path:
|
|
16
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
17
|
+
|
|
18
|
+
from _common import _resolve_project_dir, should_skip_stop_hooks # noqa: E402
|
|
19
|
+
from state_migration import resolve_state_file # noqa: E402
|
|
20
|
+
|
|
21
|
+
STEPS = ["format", "lint", "typecheck", "test"]
|
|
22
|
+
|
|
23
|
+
# Security: whitelist of allowed command prefixes to prevent injection
|
|
24
|
+
# ONLY direct tool invocations are permitted — no script runners
|
|
25
|
+
ALLOWED_PREFIXES = [
|
|
26
|
+
# JS/TS — test/lint/build ONLY (not arbitrary npm run/npx)
|
|
27
|
+
("npm", "test"),
|
|
28
|
+
("yarn", "test"),
|
|
29
|
+
("pnpm", "test"),
|
|
30
|
+
("bun", "test"),
|
|
31
|
+
("npx", "--no-install", "prettier"),
|
|
32
|
+
("npx", "--no-install", "eslint"),
|
|
33
|
+
("npx", "--no-install", "tsc"),
|
|
34
|
+
("npx", "--no-install", "jest"),
|
|
35
|
+
("npx", "--no-install", "vitest"),
|
|
36
|
+
("npx", "--no-install", "biome"),
|
|
37
|
+
("jest",),
|
|
38
|
+
("vitest",),
|
|
39
|
+
("eslint",),
|
|
40
|
+
("prettier",),
|
|
41
|
+
("tsc",),
|
|
42
|
+
("biome",),
|
|
43
|
+
# Python
|
|
44
|
+
("pytest",),
|
|
45
|
+
("python", "-m", "pytest"),
|
|
46
|
+
("python3", "-m", "pytest"),
|
|
47
|
+
("ruff",),
|
|
48
|
+
("mypy",),
|
|
49
|
+
("flake8",),
|
|
50
|
+
("black",),
|
|
51
|
+
("isort",),
|
|
52
|
+
("bandit",),
|
|
53
|
+
("pylint",),
|
|
54
|
+
# Go
|
|
55
|
+
("go", "test"),
|
|
56
|
+
("go", "vet"),
|
|
57
|
+
("go", "build"),
|
|
58
|
+
("golangci-lint",),
|
|
59
|
+
# Rust
|
|
60
|
+
("cargo", "test"),
|
|
61
|
+
("cargo", "check"),
|
|
62
|
+
("cargo", "build"),
|
|
63
|
+
("cargo", "clippy"),
|
|
64
|
+
("cargo", "fmt"),
|
|
65
|
+
# Shell
|
|
66
|
+
("shellcheck",),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Dangerous patterns that are NEVER allowed regardless of prefix
|
|
70
|
+
BLOCKED_PATTERNS = [
|
|
71
|
+
"&&", "||", "|", ";", "`", "$(", "${", ">", "<", "\n",
|
|
72
|
+
"rm ", "curl ", "wget ", "eval ", "exec ", "sudo ",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_safe_command(cmd):
|
|
77
|
+
"""Check if command matches whitelist and has no injection patterns."""
|
|
78
|
+
cmd = cmd.strip()
|
|
79
|
+
cmd_lower = cmd.lower()
|
|
80
|
+
# Check blocked patterns
|
|
81
|
+
for pattern in BLOCKED_PATTERNS:
|
|
82
|
+
target = cmd_lower if any(ch.isalpha() for ch in pattern) else cmd
|
|
83
|
+
if pattern in target:
|
|
84
|
+
return False, f"blocked pattern '{pattern}'", []
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
argv = shlex.split(cmd)
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
return False, f"invalid command syntax: {exc}", []
|
|
90
|
+
if not argv:
|
|
91
|
+
return False, "empty command", []
|
|
92
|
+
|
|
93
|
+
# Check whitelist using token boundaries so `pytestx` cannot bypass.
|
|
94
|
+
for prefix in ALLOWED_PREFIXES:
|
|
95
|
+
if len(argv) < len(prefix):
|
|
96
|
+
continue
|
|
97
|
+
if tuple(argv[: len(prefix)]) == prefix:
|
|
98
|
+
return True, "", argv
|
|
99
|
+
return False, "not in allowed commands list", []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_quality_runner(data, project_dir):
|
|
103
|
+
"""Core quality-runner validation. Returns list of block-reason strings."""
|
|
104
|
+
config_path = resolve_state_file(project_dir, "state/quality-gate.json", "quality-gate.json")
|
|
105
|
+
|
|
106
|
+
if not os.path.exists(config_path):
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with open(config_path, "r") as f:
|
|
111
|
+
config = json.load(f)
|
|
112
|
+
except (json.JSONDecodeError, IOError):
|
|
113
|
+
return ["quality-gate.json is invalid JSON. Fix or delete it."]
|
|
114
|
+
|
|
115
|
+
failures = []
|
|
116
|
+
results = []
|
|
117
|
+
|
|
118
|
+
for step in STEPS:
|
|
119
|
+
cmd = config.get(step)
|
|
120
|
+
if cmd is None or not isinstance(cmd, str) or not cmd.strip():
|
|
121
|
+
results.append(f"SKIP {step} (not configured)")
|
|
122
|
+
continue
|
|
123
|
+
cmd = cmd.strip()
|
|
124
|
+
|
|
125
|
+
# Security check
|
|
126
|
+
safe, reason, argv = is_safe_command(cmd)
|
|
127
|
+
if not safe:
|
|
128
|
+
failures.append(step)
|
|
129
|
+
results.append(f"BLOCKED {step}: '{cmd}' ({reason}). "
|
|
130
|
+
"Only standard dev tools allowed in quality-gate.json.")
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# Use argv-based execution (no shell interpretation).
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
argv,
|
|
137
|
+
capture_output=True, text=True, timeout=60,
|
|
138
|
+
cwd=project_dir
|
|
139
|
+
)
|
|
140
|
+
if result.returncode == 0:
|
|
141
|
+
results.append(f"PASS {step}: {cmd} (exit 0)")
|
|
142
|
+
else:
|
|
143
|
+
failures.append(step)
|
|
144
|
+
snippet = (result.stderr or result.stdout)[:300]
|
|
145
|
+
results.append(f"FAIL {step}: {cmd} (exit {result.returncode})\n{snippet}")
|
|
146
|
+
except subprocess.TimeoutExpired:
|
|
147
|
+
failures.append(step)
|
|
148
|
+
results.append(f"TIMEOUT {step}: {cmd}")
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
results.append(f"SKIP {step}: command not found ({cmd})")
|
|
151
|
+
|
|
152
|
+
if failures:
|
|
153
|
+
msg = "Quality gate FAILED:\n" + "\n".join(results)
|
|
154
|
+
msg += f"\n\nFailing: {', '.join(failures)}. Fix before completing."
|
|
155
|
+
return [msg]
|
|
156
|
+
|
|
157
|
+
# All passed -- print results as evidence to stderr
|
|
158
|
+
if results:
|
|
159
|
+
print("\n".join(results), file=sys.stderr)
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Standalone execution (backward compat: invoked directly by hook runner)
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
try:
|
|
166
|
+
data = json.load(sys.stdin)
|
|
167
|
+
except (json.JSONDecodeError, EOFError):
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
|
|
170
|
+
# Skip if in a stop-hook loop or context-limited agent
|
|
171
|
+
if should_skip_stop_hooks(data):
|
|
172
|
+
sys.exit(0)
|
|
173
|
+
|
|
174
|
+
project_dir = _resolve_project_dir()
|
|
175
|
+
|
|
176
|
+
# Short-circuit: skip subprocess if context pressure is high
|
|
177
|
+
_pressure_path = os.path.join(project_dir, ".omg", "state", ".context-pressure.json")
|
|
178
|
+
try:
|
|
179
|
+
if os.path.exists(_pressure_path):
|
|
180
|
+
with open(_pressure_path, "r") as _f:
|
|
181
|
+
_pressure = json.load(_f)
|
|
182
|
+
if _pressure.get("is_high", False):
|
|
183
|
+
print("[OMG quality-runner] Skipping subprocess checks: context pressure high", file=sys.stderr)
|
|
184
|
+
sys.exit(0)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass # fail open — run checks if pressure file unreadable
|
|
187
|
+
|
|
188
|
+
blocks = check_quality_runner(data, project_dir)
|
|
189
|
+
if blocks:
|
|
190
|
+
json.dump({"decision": "block", "reason": blocks[0]}, sys.stdout)
|
|
191
|
+
sys.exit(0)
|
package/hooks/query.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# pyright: reportMissingImports=false, reportMissingTypeArgument=false
|
|
2
|
+
"""Unified query layer over OMG state data silos.
|
|
3
|
+
|
|
4
|
+
This module provides a stable interface for querying tool activity, failures,
|
|
5
|
+
session health, escalation outcomes, and file-level heatmaps.
|
|
6
|
+
|
|
7
|
+
Incremental JSONL reading is used for ledger-backed queries. Byte offsets and
|
|
8
|
+
incremental aggregates are persisted in `.omg/state/.query-offsets.json`.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import importlib
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from hooks._common import atomic_json_write
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
read_cost_summary = importlib.import_module("hooks._cost_ledger").read_cost_summary
|
|
22
|
+
except Exception:
|
|
23
|
+
def read_cost_summary(project_dir: str, time_range=None) -> dict:
|
|
24
|
+
del project_dir
|
|
25
|
+
del time_range
|
|
26
|
+
return {
|
|
27
|
+
"total_tokens": 0,
|
|
28
|
+
"total_cost_usd": 0.0,
|
|
29
|
+
"by_tool": {},
|
|
30
|
+
"by_session": {},
|
|
31
|
+
"entry_count": 0,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_TOOL_LEDGER = os.path.join(".omg", "state", "ledger", "tool-ledger.jsonl")
|
|
36
|
+
_COST_LEDGER = os.path.join(".omg", "state", "ledger", "cost-ledger.jsonl")
|
|
37
|
+
_FAILURE_TRACKER = os.path.join(".omg", "state", "ledger", "failure-tracker.json")
|
|
38
|
+
_WORKING_MEMORY = os.path.join(".omg", "state", "working-memory.md")
|
|
39
|
+
_OFFSETS_FILE = os.path.join(".omg", "state", ".query-offsets.json")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe_int(value: Any, default: int = 0) -> int:
|
|
43
|
+
try:
|
|
44
|
+
return int(value)
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_float(value: Any, default: float = 0.0) -> float:
|
|
50
|
+
try:
|
|
51
|
+
return float(value)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _join(project_dir: str, rel_path: str) -> str:
|
|
57
|
+
return os.path.join(project_dir, rel_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_json(path: str, default: object) -> object:
|
|
61
|
+
if not os.path.exists(path):
|
|
62
|
+
return default
|
|
63
|
+
try:
|
|
64
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
except Exception:
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_offsets(project_dir: str) -> dict:
|
|
71
|
+
path = _join(project_dir, _OFFSETS_FILE)
|
|
72
|
+
data = _load_json(path, {})
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
return {"queries": {}}
|
|
75
|
+
queries = data.get("queries")
|
|
76
|
+
if not isinstance(queries, dict):
|
|
77
|
+
data["queries"] = {}
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _save_offsets(project_dir: str, offsets: dict) -> None:
|
|
82
|
+
path = _join(project_dir, _OFFSETS_FILE)
|
|
83
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
84
|
+
try:
|
|
85
|
+
atomic_json_write(path, offsets)
|
|
86
|
+
except Exception:
|
|
87
|
+
try:
|
|
88
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
89
|
+
json.dump(offsets, f, separators=(",", ":"))
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _consume_jsonl(
|
|
95
|
+
project_dir: str,
|
|
96
|
+
query_key: str,
|
|
97
|
+
ledger_rel_path: str,
|
|
98
|
+
initial_state: dict,
|
|
99
|
+
consume_entry,
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""Read only unread JSONL bytes and update cached aggregate state."""
|
|
102
|
+
offsets = _load_offsets(project_dir)
|
|
103
|
+
query_state = offsets.get("queries", {}).get(query_key, {})
|
|
104
|
+
offset = _safe_int(query_state.get("offset", 0), 0)
|
|
105
|
+
state = query_state.get("state", initial_state)
|
|
106
|
+
if not isinstance(state, dict):
|
|
107
|
+
state = dict(initial_state)
|
|
108
|
+
|
|
109
|
+
path = _join(project_dir, ledger_rel_path)
|
|
110
|
+
if not os.path.exists(path):
|
|
111
|
+
offsets.setdefault("queries", {})[query_key] = {
|
|
112
|
+
"offset": 0,
|
|
113
|
+
"state": dict(initial_state),
|
|
114
|
+
}
|
|
115
|
+
_save_offsets(project_dir, offsets)
|
|
116
|
+
return dict(initial_state)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
size = os.path.getsize(path)
|
|
120
|
+
except OSError:
|
|
121
|
+
return state
|
|
122
|
+
|
|
123
|
+
if offset > size:
|
|
124
|
+
offset = 0
|
|
125
|
+
state = dict(initial_state)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with open(path, "rb") as f:
|
|
129
|
+
f.seek(offset)
|
|
130
|
+
for raw in f:
|
|
131
|
+
try:
|
|
132
|
+
line = raw.decode("utf-8", errors="ignore").strip()
|
|
133
|
+
except Exception:
|
|
134
|
+
continue
|
|
135
|
+
if not line:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
entry = json.loads(line)
|
|
139
|
+
except (json.JSONDecodeError, ValueError):
|
|
140
|
+
continue
|
|
141
|
+
if isinstance(entry, dict):
|
|
142
|
+
consume_entry(state, entry)
|
|
143
|
+
new_offset = f.tell()
|
|
144
|
+
except Exception:
|
|
145
|
+
return state
|
|
146
|
+
|
|
147
|
+
offsets.setdefault("queries", {})[query_key] = {
|
|
148
|
+
"offset": new_offset,
|
|
149
|
+
"state": state,
|
|
150
|
+
}
|
|
151
|
+
_save_offsets(project_dir, offsets)
|
|
152
|
+
return state
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _entry_success(entry: dict) -> bool:
|
|
156
|
+
tool = str(entry.get("tool", ""))
|
|
157
|
+
if tool == "Bash":
|
|
158
|
+
exit_code = entry.get("exit_code")
|
|
159
|
+
if exit_code is None:
|
|
160
|
+
return True
|
|
161
|
+
return _safe_int(exit_code, 0) == 0
|
|
162
|
+
if tool in ("Write", "Edit", "MultiEdit"):
|
|
163
|
+
if "success" not in entry:
|
|
164
|
+
return True
|
|
165
|
+
return bool(entry.get("success"))
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_iso_ts(ts: object) -> datetime | None:
|
|
170
|
+
if not isinstance(ts, str) or not ts:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
text = ts.replace("Z", "+00:00")
|
|
174
|
+
return datetime.fromisoformat(text)
|
|
175
|
+
except ValueError:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_test_command(command: object) -> bool:
|
|
180
|
+
if not isinstance(command, str):
|
|
181
|
+
return False
|
|
182
|
+
cmd = command.lower()
|
|
183
|
+
markers = (
|
|
184
|
+
"pytest",
|
|
185
|
+
"npm test",
|
|
186
|
+
"pnpm test",
|
|
187
|
+
"yarn test",
|
|
188
|
+
"bun test",
|
|
189
|
+
"go test",
|
|
190
|
+
"cargo test",
|
|
191
|
+
"unittest",
|
|
192
|
+
)
|
|
193
|
+
return any(marker in cmd for marker in markers)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ensure_cost_cache(project_dir: str, query_key: str) -> None:
|
|
197
|
+
"""Bootstrap cost cache from read_cost_summary once, then read incrementally."""
|
|
198
|
+
path = _join(project_dir, _COST_LEDGER)
|
|
199
|
+
if not os.path.exists(path):
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
offsets = _load_offsets(project_dir)
|
|
203
|
+
queries = offsets.setdefault("queries", {})
|
|
204
|
+
if query_key in queries:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
summary = read_cost_summary(project_dir)
|
|
208
|
+
by_tool = {}
|
|
209
|
+
raw_by_tool = summary.get("by_tool", {})
|
|
210
|
+
if isinstance(raw_by_tool, dict):
|
|
211
|
+
for tool, stats in raw_by_tool.items():
|
|
212
|
+
if not isinstance(stats, dict):
|
|
213
|
+
continue
|
|
214
|
+
by_tool[str(tool)] = {
|
|
215
|
+
"token_total": _safe_int(stats.get("tokens", 0), 0),
|
|
216
|
+
"count": _safe_int(stats.get("count", 0), 0),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
state = {
|
|
220
|
+
"tools": by_tool,
|
|
221
|
+
"tokens_used": _safe_int(summary.get("total_tokens", 0), 0),
|
|
222
|
+
"cost_usd": _safe_float(summary.get("total_cost_usd", 0.0), 0.0),
|
|
223
|
+
}
|
|
224
|
+
queries[query_key] = {
|
|
225
|
+
"offset": _safe_int(os.path.getsize(path), 0),
|
|
226
|
+
"state": state,
|
|
227
|
+
}
|
|
228
|
+
_save_offsets(project_dir, offsets)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_tool_stats(project_dir: str, hours: int | None = None) -> dict:
|
|
232
|
+
"""Return per-tool count, success_rate, and avg_tokens.
|
|
233
|
+
|
|
234
|
+
`hours` is currently accepted for API compatibility and ignored.
|
|
235
|
+
"""
|
|
236
|
+
del hours
|
|
237
|
+
|
|
238
|
+
# Feature flag gate
|
|
239
|
+
try:
|
|
240
|
+
from hooks._common import get_feature_flag
|
|
241
|
+
if not get_feature_flag("SESSION_ANALYTICS", default=False):
|
|
242
|
+
return {"tools": {}}
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def consume_tool(state: dict, entry: dict) -> None:
|
|
247
|
+
tool = str(entry.get("tool", "unknown"))
|
|
248
|
+
tools = state.setdefault("tools", {})
|
|
249
|
+
rec = tools.setdefault(tool, {"count": 0, "success": 0})
|
|
250
|
+
rec["count"] = _safe_int(rec.get("count", 0), 0) + 1
|
|
251
|
+
if _entry_success(entry):
|
|
252
|
+
rec["success"] = _safe_int(rec.get("success", 0), 0) + 1
|
|
253
|
+
|
|
254
|
+
tool_state = _consume_jsonl(
|
|
255
|
+
project_dir,
|
|
256
|
+
"tool_stats_tool",
|
|
257
|
+
_TOOL_LEDGER,
|
|
258
|
+
{"tools": {}},
|
|
259
|
+
consume_tool,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
_ensure_cost_cache(project_dir, "tool_stats_cost")
|
|
263
|
+
|
|
264
|
+
def consume_cost(state: dict, entry: dict) -> None:
|
|
265
|
+
tool = str(entry.get("tool", "unknown"))
|
|
266
|
+
tokens = _safe_int(entry.get("tokens_in", 0), 0) + _safe_int(entry.get("tokens_out", 0), 0)
|
|
267
|
+
tools = state.setdefault("tools", {})
|
|
268
|
+
rec = tools.setdefault(tool, {"token_total": 0, "count": 0})
|
|
269
|
+
rec["token_total"] = _safe_int(rec.get("token_total", 0), 0) + tokens
|
|
270
|
+
rec["count"] = _safe_int(rec.get("count", 0), 0) + 1
|
|
271
|
+
|
|
272
|
+
cost_state = _consume_jsonl(
|
|
273
|
+
project_dir,
|
|
274
|
+
"tool_stats_cost",
|
|
275
|
+
_COST_LEDGER,
|
|
276
|
+
{"tools": {}, "tokens_used": 0, "cost_usd": 0.0},
|
|
277
|
+
consume_cost,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
tools = {}
|
|
281
|
+
tool_agg = tool_state.get("tools", {}) if isinstance(tool_state, dict) else {}
|
|
282
|
+
cost_agg = cost_state.get("tools", {}) if isinstance(cost_state, dict) else {}
|
|
283
|
+
names = set(tool_agg.keys()) | set(cost_agg.keys())
|
|
284
|
+
for tool in names:
|
|
285
|
+
counts = tool_agg.get(tool, {})
|
|
286
|
+
token_stats = cost_agg.get(tool, {})
|
|
287
|
+
count = _safe_int(counts.get("count", 0), 0)
|
|
288
|
+
success = _safe_int(counts.get("success", 0), 0)
|
|
289
|
+
token_total = _safe_int(token_stats.get("token_total", 0), 0)
|
|
290
|
+
token_count = _safe_int(token_stats.get("count", 0), 0)
|
|
291
|
+
tools[tool] = {
|
|
292
|
+
"count": count,
|
|
293
|
+
"success_rate": (success / count) if count > 0 else 0.0,
|
|
294
|
+
"avg_tokens": (token_total / token_count) if token_count > 0 else 0.0,
|
|
295
|
+
}
|
|
296
|
+
return tools
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_failure_hotspots(project_dir: str) -> dict:
|
|
300
|
+
"""Return failure patterns with counts, recent errors, and escalation flag."""
|
|
301
|
+
try:
|
|
302
|
+
from hooks._common import get_feature_flag
|
|
303
|
+
if not get_feature_flag("SESSION_ANALYTICS", default=False):
|
|
304
|
+
return {}
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
data = _load_json(_join(project_dir, _FAILURE_TRACKER), {})
|
|
309
|
+
if not isinstance(data, dict):
|
|
310
|
+
return {}
|
|
311
|
+
out = {}
|
|
312
|
+
for pattern, details in data.items():
|
|
313
|
+
if not isinstance(details, dict):
|
|
314
|
+
continue
|
|
315
|
+
count = _safe_int(details.get("count", 0), 0)
|
|
316
|
+
errors = details.get("errors", [])
|
|
317
|
+
if not isinstance(errors, list):
|
|
318
|
+
errors = []
|
|
319
|
+
normalized = [str(err) for err in errors if err]
|
|
320
|
+
out[str(pattern)] = {
|
|
321
|
+
"count": count,
|
|
322
|
+
"last_3_errors": normalized[-3:],
|
|
323
|
+
"escalated": count >= 3,
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_session_summary(project_dir: str) -> dict:
|
|
329
|
+
"""Return session-level aggregate summary across ledgers."""
|
|
330
|
+
try:
|
|
331
|
+
from hooks._common import get_feature_flag
|
|
332
|
+
if not get_feature_flag("SESSION_ANALYTICS", default=False):
|
|
333
|
+
return {
|
|
334
|
+
"duration": 0,
|
|
335
|
+
"tool_calls": 0,
|
|
336
|
+
"success_rate": 0.0,
|
|
337
|
+
"files_modified": 0,
|
|
338
|
+
"tests_run": 0,
|
|
339
|
+
"tokens_used": 0,
|
|
340
|
+
"cost_usd": 0.0,
|
|
341
|
+
}
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
def consume_tool(state: dict, entry: dict) -> None:
|
|
346
|
+
state["tool_calls"] = _safe_int(state.get("tool_calls", 0), 0) + 1
|
|
347
|
+
if _entry_success(entry):
|
|
348
|
+
state["success_count"] = _safe_int(state.get("success_count", 0), 0) + 1
|
|
349
|
+
|
|
350
|
+
tool = str(entry.get("tool", ""))
|
|
351
|
+
file_path = entry.get("file")
|
|
352
|
+
if tool in ("Write", "Edit", "MultiEdit") and isinstance(file_path, str) and file_path:
|
|
353
|
+
seen = state.setdefault("files_modified_set", [])
|
|
354
|
+
if file_path not in seen:
|
|
355
|
+
seen.append(file_path)
|
|
356
|
+
|
|
357
|
+
if tool == "Bash" and _is_test_command(entry.get("command", "")):
|
|
358
|
+
state["tests_run"] = _safe_int(state.get("tests_run", 0), 0) + 1
|
|
359
|
+
|
|
360
|
+
ts = _parse_iso_ts(entry.get("ts"))
|
|
361
|
+
if ts is not None:
|
|
362
|
+
first = _parse_iso_ts(state.get("first_ts"))
|
|
363
|
+
last = _parse_iso_ts(state.get("last_ts"))
|
|
364
|
+
if first is None or ts < first:
|
|
365
|
+
state["first_ts"] = ts.isoformat()
|
|
366
|
+
if last is None or ts > last:
|
|
367
|
+
state["last_ts"] = ts.isoformat()
|
|
368
|
+
|
|
369
|
+
tool_state = _consume_jsonl(
|
|
370
|
+
project_dir,
|
|
371
|
+
"session_summary_tool",
|
|
372
|
+
_TOOL_LEDGER,
|
|
373
|
+
{
|
|
374
|
+
"tool_calls": 0,
|
|
375
|
+
"success_count": 0,
|
|
376
|
+
"files_modified_set": [],
|
|
377
|
+
"tests_run": 0,
|
|
378
|
+
"first_ts": "",
|
|
379
|
+
"last_ts": "",
|
|
380
|
+
},
|
|
381
|
+
consume_tool,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
_ensure_cost_cache(project_dir, "session_summary_cost")
|
|
385
|
+
|
|
386
|
+
def consume_cost(state: dict, entry: dict) -> None:
|
|
387
|
+
tokens = _safe_int(entry.get("tokens_in", 0), 0) + _safe_int(entry.get("tokens_out", 0), 0)
|
|
388
|
+
state["tokens_used"] = _safe_int(state.get("tokens_used", 0), 0) + tokens
|
|
389
|
+
state["cost_usd"] = _safe_float(state.get("cost_usd", 0.0), 0.0) + _safe_float(
|
|
390
|
+
entry.get("cost_usd", 0.0),
|
|
391
|
+
0.0,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
cost_state = _consume_jsonl(
|
|
395
|
+
project_dir,
|
|
396
|
+
"session_summary_cost",
|
|
397
|
+
_COST_LEDGER,
|
|
398
|
+
{"tools": {}, "tokens_used": 0, "cost_usd": 0.0},
|
|
399
|
+
consume_cost,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
tool_calls = _safe_int(tool_state.get("tool_calls", 0), 0)
|
|
403
|
+
success_count = _safe_int(tool_state.get("success_count", 0), 0)
|
|
404
|
+
first_ts = _parse_iso_ts(tool_state.get("first_ts"))
|
|
405
|
+
last_ts = _parse_iso_ts(tool_state.get("last_ts"))
|
|
406
|
+
duration = 0
|
|
407
|
+
if first_ts is not None and last_ts is not None:
|
|
408
|
+
duration = max(0, int((last_ts - first_ts).total_seconds()))
|
|
409
|
+
|
|
410
|
+
files_modified = tool_state.get("files_modified_set", [])
|
|
411
|
+
if not isinstance(files_modified, list):
|
|
412
|
+
files_modified = []
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
"duration": duration,
|
|
416
|
+
"tool_calls": tool_calls,
|
|
417
|
+
"success_rate": (success_count / tool_calls) if tool_calls > 0 else 0.0,
|
|
418
|
+
"files_modified": len(files_modified),
|
|
419
|
+
"tests_run": _safe_int(tool_state.get("tests_run", 0), 0),
|
|
420
|
+
"tokens_used": _safe_int(cost_state.get("tokens_used", 0), 0),
|
|
421
|
+
"cost_usd": _safe_float(cost_state.get("cost_usd", 0.0), 0.0),
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def get_escalation_effectiveness(project_dir: str) -> dict:
|
|
426
|
+
"""Return escalation counts and coarse resolution estimate.
|
|
427
|
+
|
|
428
|
+
Escalations are inferred from active failure patterns at count >= 3.
|
|
429
|
+
Resolution is inferred by matching "resolved" mentions in working-memory.
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
from hooks._common import get_feature_flag
|
|
433
|
+
if not get_feature_flag("SESSION_ANALYTICS", default=False):
|
|
434
|
+
return {
|
|
435
|
+
"escalations": 0,
|
|
436
|
+
"resolved": 0,
|
|
437
|
+
"unresolved": 0,
|
|
438
|
+
}
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
hotspots = get_failure_hotspots(project_dir)
|
|
443
|
+
escalated_patterns = [name for name, entry in hotspots.items() if bool(entry.get("escalated"))]
|
|
444
|
+
escalations = len(escalated_patterns)
|
|
445
|
+
|
|
446
|
+
resolved = 0
|
|
447
|
+
memory_path = _join(project_dir, _WORKING_MEMORY)
|
|
448
|
+
memory_text = ""
|
|
449
|
+
if os.path.exists(memory_path):
|
|
450
|
+
try:
|
|
451
|
+
with open(memory_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
452
|
+
memory_text = f.read().lower()
|
|
453
|
+
except Exception:
|
|
454
|
+
memory_text = ""
|
|
455
|
+
|
|
456
|
+
if memory_text:
|
|
457
|
+
for pattern in escalated_patterns:
|
|
458
|
+
compact = pattern.lower()
|
|
459
|
+
if compact in memory_text and "resolved" in memory_text:
|
|
460
|
+
resolved += 1
|
|
461
|
+
|
|
462
|
+
unresolved = max(escalations - resolved, 0)
|
|
463
|
+
return {
|
|
464
|
+
"escalations": escalations,
|
|
465
|
+
"resolved": resolved,
|
|
466
|
+
"unresolved": unresolved,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def get_file_heatmap(project_dir: str) -> dict:
|
|
471
|
+
"""Return per-file read/write/edit interaction counts."""
|
|
472
|
+
try:
|
|
473
|
+
from hooks._common import get_feature_flag
|
|
474
|
+
if not get_feature_flag("SESSION_ANALYTICS", default=False):
|
|
475
|
+
return {}
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
def consume_entry(state: dict, entry: dict) -> None:
|
|
480
|
+
tool = str(entry.get("tool", ""))
|
|
481
|
+
file_path = entry.get("file")
|
|
482
|
+
if not isinstance(file_path, str) or not file_path:
|
|
483
|
+
return
|
|
484
|
+
files = state.setdefault("files", {})
|
|
485
|
+
rec = files.setdefault(file_path, {"reads": 0, "writes": 0, "edits": 0})
|
|
486
|
+
if tool == "Read":
|
|
487
|
+
rec["reads"] = _safe_int(rec.get("reads", 0), 0) + 1
|
|
488
|
+
elif tool == "Write":
|
|
489
|
+
rec["writes"] = _safe_int(rec.get("writes", 0), 0) + 1
|
|
490
|
+
elif tool in ("Edit", "MultiEdit"):
|
|
491
|
+
rec["edits"] = _safe_int(rec.get("edits", 0), 0) + 1
|
|
492
|
+
|
|
493
|
+
state = _consume_jsonl(
|
|
494
|
+
project_dir,
|
|
495
|
+
"file_heatmap_tool",
|
|
496
|
+
_TOOL_LEDGER,
|
|
497
|
+
{"files": {}},
|
|
498
|
+
consume_entry,
|
|
499
|
+
)
|
|
500
|
+
files = state.get("files", {}) if isinstance(state, dict) else {}
|
|
501
|
+
if not isinstance(files, dict):
|
|
502
|
+
return {}
|
|
503
|
+
return files
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
__all__ = [
|
|
507
|
+
"get_tool_stats",
|
|
508
|
+
"get_failure_hotspots",
|
|
509
|
+
"get_session_summary",
|
|
510
|
+
"get_escalation_effectiveness",
|
|
511
|
+
"get_file_heatmap",
|
|
512
|
+
]
|