@trac3er/oh-my-god 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +36 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.claude-plugin/scripts/install.sh +49 -0
- package/.claude-plugin/scripts/uninstall.sh +80 -0
- package/.claude-plugin/scripts/update.sh +84 -0
- package/.mcp.json +20 -0
- package/LICENSE +21 -0
- package/OMG-setup.sh +1093 -0
- package/README.md +335 -0
- package/THIRD_PARTY_NOTICES.md +24 -0
- package/UPSTREAM_DIFF.md +20 -0
- package/agents/__init__.py +1 -0
- package/agents/_model_roles.yaml +26 -0
- package/agents/designer.md +67 -0
- package/agents/explore.md +60 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-api-builder.md +23 -0
- package/agents/omg-architect-mode.md +43 -0
- package/agents/omg-architect.md +13 -0
- package/agents/omg-backend-engineer.md +43 -0
- package/agents/omg-critic.md +16 -0
- package/agents/omg-database-engineer.md +43 -0
- package/agents/omg-escalation-router.md +17 -0
- package/agents/omg-executor.md +12 -0
- package/agents/omg-frontend-designer.md +42 -0
- package/agents/omg-implement-mode.md +50 -0
- package/agents/omg-infra-engineer.md +43 -0
- package/agents/omg-qa-tester.md +16 -0
- package/agents/omg-research-mode.md +43 -0
- package/agents/omg-security-auditor.md +43 -0
- package/agents/omg-testing-engineer.md +43 -0
- package/agents/plan.md +80 -0
- package/agents/quick_task.md +64 -0
- package/agents/reviewer.md +83 -0
- package/agents/task.md +71 -0
- package/commands/OMG:ccg.md +22 -0
- package/commands/OMG:compat.md +57 -0
- package/commands/OMG:crazy.md +125 -0
- package/commands/OMG:domain-init.md +11 -0
- package/commands/OMG:escalate.md +52 -0
- package/commands/OMG:health-check.md +45 -0
- package/commands/OMG:init.md +134 -0
- package/commands/OMG:mode.md +44 -0
- package/commands/OMG:project-init.md +11 -0
- package/commands/OMG:ralph-start.md +43 -0
- package/commands/OMG:ralph-stop.md +23 -0
- package/commands/OMG:teams.md +39 -0
- package/commands/ai-commit.md +113 -0
- package/commands/ccg.md +9 -0
- package/commands/create-agent.md +183 -0
- package/commands/omc-teams.md +9 -0
- package/commands/session-branch.md +85 -0
- package/commands/session-fork.md +53 -0
- package/commands/session-merge.md +134 -0
- package/commands/theme.md +44 -0
- package/config/lsp_languages.yaml +324 -0
- package/config/themes/catppuccin-frappe.yaml +14 -0
- package/config/themes/catppuccin-latte.yaml +14 -0
- package/config/themes/catppuccin-macchiato.yaml +14 -0
- package/config/themes/catppuccin-mocha.yaml +14 -0
- package/config/themes/dracula.yaml +14 -0
- package/config/themes/gruvbox-dark.yaml +14 -0
- package/config/themes/nord.yaml +14 -0
- package/config/themes/one-dark.yaml +14 -0
- package/config/themes/solarized-dark.yaml +14 -0
- package/config/themes/tokyo-night.yaml +14 -0
- package/control_plane/__init__.py +2 -0
- package/control_plane/openapi.yaml +109 -0
- package/control_plane/server.py +107 -0
- package/control_plane/service.py +148 -0
- package/crates/omg-natives/Cargo.toml +17 -0
- package/crates/omg-natives/src/clipboard.rs +5 -0
- package/crates/omg-natives/src/glob.rs +15 -0
- package/crates/omg-natives/src/grep.rs +15 -0
- package/crates/omg-natives/src/highlight.rs +15 -0
- package/crates/omg-natives/src/html.rs +14 -0
- package/crates/omg-natives/src/image.rs +5 -0
- package/crates/omg-natives/src/keys.rs +5 -0
- package/crates/omg-natives/src/lib.rs +36 -0
- package/crates/omg-natives/src/prof.rs +5 -0
- package/crates/omg-natives/src/ps.rs +5 -0
- package/crates/omg-natives/src/shell.rs +5 -0
- package/crates/omg-natives/src/task.rs +5 -0
- package/crates/omg-natives/src/text.rs +14 -0
- package/hooks/_agent_registry.py +421 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +476 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/config-guard.py +163 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +801 -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 +310 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +199 -0
- package/hooks/pre-compact.py +204 -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/secret-guard.py +47 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +275 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +209 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +929 -0
- package/hooks/test-validator.py +138 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +126 -0
- package/hooks/trust_review.py +524 -0
- package/install.sh +9 -0
- package/omg_natives/__init__.py +186 -0
- package/omg_natives/_bindings.py +165 -0
- package/omg_natives/clipboard.py +36 -0
- package/omg_natives/glob.py +42 -0
- package/omg_natives/grep.py +61 -0
- package/omg_natives/highlight.py +54 -0
- package/omg_natives/html.py +157 -0
- package/omg_natives/image.py +51 -0
- package/omg_natives/keys.py +46 -0
- package/omg_natives/prof.py +39 -0
- package/omg_natives/ps.py +93 -0
- package/omg_natives/shell.py +58 -0
- package/omg_natives/task.py +41 -0
- package/omg_natives/text.py +50 -0
- package/package.json +26 -0
- package/plugins/README.md +82 -0
- package/plugins/advanced/commands/OMG:code-review.md +114 -0
- package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
- package/plugins/advanced/commands/OMG:handoff.md +115 -0
- package/plugins/advanced/commands/OMG:learn.md +110 -0
- package/plugins/advanced/commands/OMG:maintainer.md +31 -0
- package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
- package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
- package/plugins/advanced/commands/OMG:security-review.md +119 -0
- package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
- package/plugins/advanced/commands/OMG:ship.md +46 -0
- package/plugins/advanced/plugin.json +96 -0
- package/plugins/core/plugin.json +82 -0
- package/pytest.ini +5 -0
- package/registry/__init__.py +1 -0
- package/registry/verify_artifact.py +90 -0
- package/rules/contextual/architect-mode.md +9 -0
- package/rules/contextual/big-picture.md +20 -0
- package/rules/contextual/code-hygiene.md +26 -0
- package/rules/contextual/context-management.md +19 -0
- package/rules/contextual/context-minimization.md +32 -0
- package/rules/contextual/ddd-sdd.md +28 -0
- package/rules/contextual/dependency-safety.md +16 -0
- package/rules/contextual/doc-check.md +13 -0
- package/rules/contextual/implement-mode.md +9 -0
- package/rules/contextual/infra-safety.md +14 -0
- package/rules/contextual/outside-in.md +13 -0
- package/rules/contextual/persistent-mode.md +24 -0
- package/rules/contextual/research-mode.md +9 -0
- package/rules/contextual/security-domains.md +25 -0
- package/rules/contextual/vision-detection.md +27 -0
- package/rules/contextual/web-search.md +25 -0
- package/rules/contextual/write-verify.md +23 -0
- package/rules/core/00-truth.md +20 -0
- package/rules/core/01-surgical.md +19 -0
- package/rules/core/02-circuit-breaker.md +22 -0
- package/rules/core/03-ensemble.md +28 -0
- package/rules/core/04-testing.md +30 -0
- 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/business_workflow.py +220 -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/omc_compat.py +7 -0
- package/runtime/omc_contract_snapshot.json +916 -0
- package/runtime/omg_compat_contract_snapshot.json +916 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +838 -0
- package/scripts/check-omc-contract-snapshot.py +12 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-standalone-clean.py +102 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-omc.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +493 -0
- package/scripts/settings-merge.py +224 -0
- package/scripts/verify-no-omc.sh +5 -0
- package/scripts/verify-standalone.sh +21 -0
- package/templates/idea.yml +30 -0
- package/templates/policy.yaml +15 -0
- package/templates/profile.yaml +25 -0
- package/templates/runtime.yaml +12 -0
- package/templates/working-memory.md +17 -0
- 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 +268 -0
- package/tools/commit_splitter.py +361 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -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/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
|
@@ -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)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse Hook (Read/Write/Edit/MultiEdit): Secret File Guard (Enterprise)
|
|
3
|
+
|
|
4
|
+
Delegates file policy decisions to policy_engine.py.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
11
|
+
if HOOKS_DIR not in sys.path:
|
|
12
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
13
|
+
|
|
14
|
+
from _common import setup_crash_handler, json_input, deny_decision, is_bypass_mode
|
|
15
|
+
|
|
16
|
+
# Fail-closed: deny on crash (security hook)
|
|
17
|
+
setup_crash_handler("secret-guard", fail_closed=True)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from policy_engine import evaluate_file_access, to_pretool_hook_output
|
|
21
|
+
except Exception as _import_err:
|
|
22
|
+
print(f"OMG secret-guard: policy_engine import failed: {_import_err}", file=sys.stderr)
|
|
23
|
+
deny_decision(f"OMG secret-guard crash: policy_engine import failed: {_import_err}. Denying for safety.")
|
|
24
|
+
sys.exit(0)
|
|
25
|
+
|
|
26
|
+
data = json_input()
|
|
27
|
+
|
|
28
|
+
tool = data.get("tool_name", "")
|
|
29
|
+
if tool not in ("Read", "Write", "Edit", "MultiEdit"):
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
file_path = data.get("tool_input", {}).get("file_path", "")
|
|
33
|
+
if not file_path:
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
|
|
36
|
+
decision = evaluate_file_access(tool, file_path)
|
|
37
|
+
|
|
38
|
+
# In bypass-permission mode, only enforce hard denials (critical safety).
|
|
39
|
+
# Skip "ask" decisions so the user is not prompted for confirmation.
|
|
40
|
+
if is_bypass_mode(data) and decision.action != "deny":
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
|
|
43
|
+
out = to_pretool_hook_output(decision)
|
|
44
|
+
if out:
|
|
45
|
+
json.dump(out, sys.stdout)
|
|
46
|
+
|
|
47
|
+
sys.exit(0)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionEnd Hook — Captures memory + learnings after session completes.
|
|
3
|
+
|
|
4
|
+
This hook fires AFTER the session ends (fire-and-forget, no blocking capability).
|
|
5
|
+
Features are implemented in later tasks:
|
|
6
|
+
- Memory capture: Task 19
|
|
7
|
+
- Compound learning: Task 30
|
|
8
|
+
"""
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Callable, cast
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
16
|
+
from _common import setup_crash_handler as _setup_crash_handler
|
|
17
|
+
from _common import json_input as _json_input
|
|
18
|
+
from _common import get_feature_flag as _get_feature_flag
|
|
19
|
+
from _common import log_hook_error as _log_hook_error
|
|
20
|
+
|
|
21
|
+
setup_crash_handler = cast(Callable[[str, bool], None], _setup_crash_handler)
|
|
22
|
+
json_input = cast(Callable[[], dict[str, str]], _json_input)
|
|
23
|
+
get_feature_flag = cast(Callable[[str], bool], _get_feature_flag)
|
|
24
|
+
log_hook_error = cast(Callable[[str, str], None], _log_hook_error)
|
|
25
|
+
|
|
26
|
+
setup_crash_handler('session-end-capture', False)
|
|
27
|
+
|
|
28
|
+
data = json_input()
|
|
29
|
+
session_id = data.get('session_id', 'unknown')
|
|
30
|
+
cwd = data.get('cwd', os.getcwd())
|
|
31
|
+
|
|
32
|
+
# Capture A: Memory (implemented in Task 19)
|
|
33
|
+
if get_feature_flag('memory'):
|
|
34
|
+
try:
|
|
35
|
+
from _memory import save_memory, rotate_memories
|
|
36
|
+
|
|
37
|
+
summary_parts = [f"# Session: {datetime.now().strftime('%Y-%m-%d')} ({session_id[:8]})"]
|
|
38
|
+
|
|
39
|
+
ledger_path = os.path.join(cwd, '.omg', 'state', 'ledger', 'tool-ledger.jsonl')
|
|
40
|
+
if os.path.exists(ledger_path):
|
|
41
|
+
try:
|
|
42
|
+
with open(ledger_path) as file_obj:
|
|
43
|
+
lines = file_obj.readlines()[-10:]
|
|
44
|
+
tools_used: list[str] = []
|
|
45
|
+
for line in lines:
|
|
46
|
+
try:
|
|
47
|
+
entry = json.loads(line.strip())
|
|
48
|
+
if not isinstance(entry, dict):
|
|
49
|
+
continue
|
|
50
|
+
tool = entry.get('tool', '')
|
|
51
|
+
fname = entry.get('file', entry.get('path', ''))
|
|
52
|
+
if tool and fname:
|
|
53
|
+
tools_used.append(f" - {tool}: {fname}")
|
|
54
|
+
elif tool:
|
|
55
|
+
tools_used.append(f" - {tool}")
|
|
56
|
+
except (json.JSONDecodeError, KeyError):
|
|
57
|
+
pass
|
|
58
|
+
if tools_used:
|
|
59
|
+
summary_parts.append("## What Was Done")
|
|
60
|
+
summary_parts.extend(tools_used[:5])
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
checklist_path = os.path.join(cwd, '.omg', 'state', '_checklist.md')
|
|
65
|
+
if os.path.exists(checklist_path):
|
|
66
|
+
try:
|
|
67
|
+
with open(checklist_path) as file_obj:
|
|
68
|
+
cl_lines = file_obj.readlines()
|
|
69
|
+
total = sum(1 for line in cl_lines if '[ ]' in line or '[x]' in line)
|
|
70
|
+
done = sum(1 for line in cl_lines if '[x]' in line.lower())
|
|
71
|
+
if total > 0:
|
|
72
|
+
summary_parts.append(f"## Outcome\n- Checklist: {done}/{total} complete")
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
summary = '\n'.join(summary_parts)
|
|
77
|
+
_ = save_memory(cwd, session_id, summary)
|
|
78
|
+
_ = rotate_memories(cwd)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
log_hook_error('session-end-capture', str(e))
|
|
81
|
+
|
|
82
|
+
# Capture B: Compound learning (implemented in Task 30)
|
|
83
|
+
if get_feature_flag('compound_learning'):
|
|
84
|
+
try:
|
|
85
|
+
def capture_learnings(project_dir, session_id):
|
|
86
|
+
ledger_path = os.path.join(project_dir, '.omg', 'state', 'ledger', 'tool-ledger.jsonl')
|
|
87
|
+
if not os.path.exists(ledger_path):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Read last 100 entries
|
|
91
|
+
entries = []
|
|
92
|
+
with open(ledger_path) as f:
|
|
93
|
+
for line in f:
|
|
94
|
+
try:
|
|
95
|
+
entries.append(json.loads(line.strip()))
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
entries = entries[-100:]
|
|
99
|
+
|
|
100
|
+
if not entries:
|
|
101
|
+
return # No entries → no learning file
|
|
102
|
+
|
|
103
|
+
# Count tool and file usage
|
|
104
|
+
tool_counts = {}
|
|
105
|
+
file_counts = {}
|
|
106
|
+
for e in entries:
|
|
107
|
+
tool = e.get('tool', 'unknown')
|
|
108
|
+
tool_counts[tool] = tool_counts.get(tool, 0) + 1
|
|
109
|
+
f_path = e.get('file', e.get('path', ''))
|
|
110
|
+
if f_path:
|
|
111
|
+
file_counts[f_path] = file_counts.get(f_path, 0) + 1
|
|
112
|
+
|
|
113
|
+
# Write learning file
|
|
114
|
+
date_str = datetime.now().strftime('%Y-%m-%d')
|
|
115
|
+
session_short = session_id[:8] if len(session_id) > 8 else session_id
|
|
116
|
+
learn_dir = os.path.join(project_dir, '.omg', 'state', 'learnings')
|
|
117
|
+
os.makedirs(learn_dir, exist_ok=True)
|
|
118
|
+
learn_path = os.path.join(learn_dir, f'{date_str}-{session_short}.md')
|
|
119
|
+
|
|
120
|
+
lines = [f'# Learnings: {date_str}', '## Most Used Tools']
|
|
121
|
+
for tool, count in sorted(tool_counts.items(), key=lambda x: -x[1])[:5]:
|
|
122
|
+
lines.append(f'- {tool}: {count}x')
|
|
123
|
+
lines.append('## Most Modified Files')
|
|
124
|
+
for fpath, count in sorted(file_counts.items(), key=lambda x: -x[1])[:5]:
|
|
125
|
+
lines.append(f'- {fpath}: {count}x')
|
|
126
|
+
|
|
127
|
+
content = '\n'.join(lines)
|
|
128
|
+
# Cap at 300 chars
|
|
129
|
+
content = content[:300]
|
|
130
|
+
with open(learn_path, 'w') as f:
|
|
131
|
+
f.write(content)
|
|
132
|
+
|
|
133
|
+
capture_learnings(cwd, session_id)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
log_hook_error('session-end-capture', str(e))
|
|
136
|
+
|
|
137
|
+
sys.exit(0)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart Hook — OMG Standalone Context Injection.
|
|
3
|
+
|
|
4
|
+
Canonical state path: .omg/state/*
|
|
5
|
+
Legacy fallback path: .omc/* (auto-migrated when detected)
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time as _time
|
|
11
|
+
import re as _re
|
|
12
|
+
|
|
13
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
14
|
+
if HOOKS_DIR not in sys.path:
|
|
15
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
16
|
+
|
|
17
|
+
from _common import setup_crash_handler, json_input, get_feature_flag, _resolve_project_dir
|
|
18
|
+
from state_migration import resolve_state_file, resolve_state_dir
|
|
19
|
+
from _budget import BUDGET_SESSION_TOTAL, BUDGET_SESSION_IDLE
|
|
20
|
+
|
|
21
|
+
setup_crash_handler("session-start", fail_closed=False)
|
|
22
|
+
|
|
23
|
+
data = json_input()
|
|
24
|
+
|
|
25
|
+
project_dir = _resolve_project_dir()
|
|
26
|
+
sections: list[str] = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_file(path: str, max_bytes: int = 2000) -> str | None:
|
|
30
|
+
try:
|
|
31
|
+
if not os.path.exists(path):
|
|
32
|
+
return None
|
|
33
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
34
|
+
text = f.read(max_bytes).strip()
|
|
35
|
+
return text or None
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# 1) Project profile summary
|
|
41
|
+
profile_path = resolve_state_file(project_dir, "state/profile.yaml", "profile.yaml")
|
|
42
|
+
project_path = resolve_state_file(project_dir, "state/project.md", "project.md")
|
|
43
|
+
|
|
44
|
+
profile = _read_file(profile_path, 3000)
|
|
45
|
+
if profile:
|
|
46
|
+
lines = [l.strip() for l in profile.split("\n") if l.strip() and not l.strip().startswith("#")]
|
|
47
|
+
kv = {}
|
|
48
|
+
current_section = ""
|
|
49
|
+
for l in lines:
|
|
50
|
+
if ":" not in l:
|
|
51
|
+
continue
|
|
52
|
+
k, v = l.split(":", 1)
|
|
53
|
+
k = k.strip().lower()
|
|
54
|
+
v = v.strip().strip('"').strip("'")
|
|
55
|
+
if k in ("conventions", "ai_behavior"):
|
|
56
|
+
current_section = k
|
|
57
|
+
continue
|
|
58
|
+
if current_section:
|
|
59
|
+
kv[f"{current_section}.{k}"] = v
|
|
60
|
+
else:
|
|
61
|
+
kv[k] = v
|
|
62
|
+
|
|
63
|
+
name = kv.get("name", "")
|
|
64
|
+
conv_parts = []
|
|
65
|
+
for ck in ["conventions.naming", "conventions.test_cmd", "conventions.lint_cmd"]:
|
|
66
|
+
if kv.get(ck):
|
|
67
|
+
conv_parts.append(f"{ck.split('.')[-1]}={kv[ck]}")
|
|
68
|
+
comm = kv.get("ai_behavior.communication", "")
|
|
69
|
+
|
|
70
|
+
summary_parts = [name] if name else []
|
|
71
|
+
if conv_parts:
|
|
72
|
+
summary_parts.append(" ".join(conv_parts))
|
|
73
|
+
if comm:
|
|
74
|
+
summary_parts.append(f"lang:{comm}")
|
|
75
|
+
if summary_parts:
|
|
76
|
+
sections.append(f"@project: {' | '.join(summary_parts)}")
|
|
77
|
+
else:
|
|
78
|
+
project = _read_file(project_path, 1000)
|
|
79
|
+
if project:
|
|
80
|
+
lines = [l for l in project.split("\n") if l.strip() and not l.startswith("#")][:2]
|
|
81
|
+
if lines:
|
|
82
|
+
sections.append("@project: " + " | ".join(l.strip() for l in lines))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# 2) Working memory
|
|
86
|
+
wm_path = resolve_state_file(project_dir, "state/working-memory.md", "working-memory.md")
|
|
87
|
+
wm = _read_file(wm_path, 2200)
|
|
88
|
+
if wm:
|
|
89
|
+
sections.append("[WORKING MEMORY]\n" + wm[:1500])
|
|
90
|
+
else:
|
|
91
|
+
check_path = resolve_state_file(project_dir, "state/_checklist.md", "_checklist.md")
|
|
92
|
+
plan_path = resolve_state_file(project_dir, "state/_plan.md", "_plan.md")
|
|
93
|
+
fallback = []
|
|
94
|
+
check = _read_file(check_path, 2500)
|
|
95
|
+
if check:
|
|
96
|
+
lines = check.split("\n")
|
|
97
|
+
done = sum(1 for l in lines if "[x]" in l.lower())
|
|
98
|
+
total = sum(1 for l in lines if l.strip().startswith(("[", "- [")))
|
|
99
|
+
pending = [l.strip() for l in lines if "[ ]" in l][:3]
|
|
100
|
+
fallback.append(f"Progress: {done}/{total}")
|
|
101
|
+
if pending:
|
|
102
|
+
fallback.append("Next: " + " | ".join(
|
|
103
|
+
p.replace("[ ] ", "").replace("- [ ] ", "")[:50] for p in pending
|
|
104
|
+
))
|
|
105
|
+
plan = _read_file(plan_path, 1200)
|
|
106
|
+
if plan:
|
|
107
|
+
for line in plan.split("\n"):
|
|
108
|
+
if "CHANGE_BUDGET" in line:
|
|
109
|
+
fallback.append(line.strip())
|
|
110
|
+
break
|
|
111
|
+
if fallback:
|
|
112
|
+
sections.append("[WORKING MEMORY]\n" + "\n".join(fallback))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# 3) Tools inventory
|
|
116
|
+
tools = []
|
|
117
|
+
commands_dir = os.path.join(
|
|
118
|
+
os.environ.get("CLAUDE_CONFIG_DIR", os.path.expanduser("~/.claude")),
|
|
119
|
+
"commands",
|
|
120
|
+
)
|
|
121
|
+
for cmd_name in ["OMG:teams", "OMG:ccg", "OMG:compat"]:
|
|
122
|
+
cmd_file = os.path.join(commands_dir, f"{cmd_name}.md")
|
|
123
|
+
if os.path.exists(cmd_file):
|
|
124
|
+
tools.append(f"/{cmd_name}")
|
|
125
|
+
|
|
126
|
+
if os.environ.get("OMG_INCLUDE_LEGACY_ALIASES", "0") == "1":
|
|
127
|
+
for cmd_name in ["OMG:omc-compat", "omc-teams", "ccg"]:
|
|
128
|
+
cmd_file = os.path.join(commands_dir, f"{cmd_name}.md")
|
|
129
|
+
if os.path.exists(cmd_file):
|
|
130
|
+
tools.append(f"/{cmd_name} (alias)")
|
|
131
|
+
|
|
132
|
+
for mcp_loc in [
|
|
133
|
+
os.path.join(project_dir, ".mcp.json"),
|
|
134
|
+
os.path.expanduser("~/.claude/settings.json"),
|
|
135
|
+
]:
|
|
136
|
+
if os.path.exists(mcp_loc):
|
|
137
|
+
try:
|
|
138
|
+
with open(mcp_loc, "r", encoding="utf-8") as f:
|
|
139
|
+
servers = json.load(f).get("mcpServers", {})
|
|
140
|
+
tools.extend(f"mcp:{n}" for n in list(servers.keys())[:5])
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
if tools:
|
|
145
|
+
sections.append("@tools: " + ", ".join(tools))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# 4) Handoff (fresh only, with .consumed idempotency)
|
|
149
|
+
|
|
150
|
+
handoff_path = resolve_state_file(project_dir, "state/handoff.md", "handoff.md")
|
|
151
|
+
|
|
152
|
+
consumed_path = handoff_path + ".consumed"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Check if already consumed (idempotent)
|
|
157
|
+
|
|
158
|
+
if os.path.exists(consumed_path):
|
|
159
|
+
|
|
160
|
+
# Already injected in a previous session, skip
|
|
161
|
+
|
|
162
|
+
handoff_fresh = False
|
|
163
|
+
|
|
164
|
+
elif not os.path.exists(handoff_path):
|
|
165
|
+
|
|
166
|
+
# Try portable version
|
|
167
|
+
|
|
168
|
+
handoff_path = resolve_state_file(project_dir, "state/handoff-portable.md", "handoff-portable.md")
|
|
169
|
+
|
|
170
|
+
consumed_path = handoff_path + ".consumed"
|
|
171
|
+
|
|
172
|
+
handoff_fresh = False
|
|
173
|
+
|
|
174
|
+
else:
|
|
175
|
+
|
|
176
|
+
# Check freshness (< 48 hours)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
|
|
180
|
+
age_hours = (_time.time() - os.path.getmtime(handoff_path)) / 3600
|
|
181
|
+
|
|
182
|
+
handoff_fresh = age_hours < 48
|
|
183
|
+
|
|
184
|
+
except Exception:
|
|
185
|
+
|
|
186
|
+
handoff_fresh = True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if handoff_fresh and os.path.exists(handoff_path):
|
|
191
|
+
|
|
192
|
+
handoff = _read_file(handoff_path, 2400)
|
|
193
|
+
|
|
194
|
+
if handoff:
|
|
195
|
+
|
|
196
|
+
key_parts = []
|
|
197
|
+
|
|
198
|
+
for section in _re.split(r"\n## ", handoff):
|
|
199
|
+
|
|
200
|
+
header = section.split("\n")[0].lower()
|
|
201
|
+
|
|
202
|
+
if any(k in header for k in ("goal", "next", "fail", "state", "decision")):
|
|
203
|
+
|
|
204
|
+
key_parts.append("## " + section[:300])
|
|
205
|
+
|
|
206
|
+
if key_parts:
|
|
207
|
+
|
|
208
|
+
sections.append("[HANDOFF CONTEXT — Resume from previous session]\n" + "\n".join(key_parts)[:800])
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
|
|
212
|
+
sections.append("[HANDOFF CONTEXT — Resume from previous session]\n" + handoff[:600])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Rename handoff to .consumed after successful injection
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
|
|
220
|
+
os.rename(handoff_path, consumed_path)
|
|
221
|
+
|
|
222
|
+
except Exception:
|
|
223
|
+
|
|
224
|
+
pass # If rename fails, continue anyway (injection already happened)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# 5) Active failures
|
|
228
|
+
tracker_path = resolve_state_file(project_dir, "state/ledger/failure-tracker.json", "ledger/failure-tracker.json")
|
|
229
|
+
if os.path.exists(tracker_path):
|
|
230
|
+
try:
|
|
231
|
+
with open(tracker_path, "r", encoding="utf-8") as f:
|
|
232
|
+
tracker = json.load(f)
|
|
233
|
+
active = [(k, v) for k, v in tracker.items() if isinstance(v, dict) and v.get("count", 0) >= 2]
|
|
234
|
+
if active:
|
|
235
|
+
warns = [f" !! {k}: {v['count']}x failed" for k, v in active[:3]]
|
|
236
|
+
sections.append("[ACTIVE FAILURES — consider /OMG:escalate or different approach]\n" + "\n".join(warns))
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# 6) Recent memory (on-demand)
|
|
242
|
+
if get_feature_flag('memory'):
|
|
243
|
+
try:
|
|
244
|
+
from _memory import get_recent_memories
|
|
245
|
+
recent = get_recent_memories(project_dir, max_files=3, max_chars_total=150)
|
|
246
|
+
if recent:
|
|
247
|
+
sections.append(f'@recent-memory: {recent}')
|
|
248
|
+
except Exception:
|
|
249
|
+
pass # Memory is optional — never block session start
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── Idle detection: minimal output when no active work ──
|
|
253
|
+
_plan_path = resolve_state_file(project_dir, "state/_plan.md", "_plan.md")
|
|
254
|
+
_has_plan = os.path.exists(_plan_path)
|
|
255
|
+
_has_handoff = handoff_fresh
|
|
256
|
+
_memory_dir = os.path.join(project_dir, '.omg', 'state', 'memory')
|
|
257
|
+
_has_memory = os.path.isdir(_memory_dir) and bool(os.listdir(_memory_dir)) if os.path.isdir(_memory_dir) else False
|
|
258
|
+
_is_idle = not _has_plan and not _has_handoff and not _has_memory
|
|
259
|
+
|
|
260
|
+
# Output with budget (idle → 200 chars, active → 2000 chars)
|
|
261
|
+
MAX_CONTEXT_CHARS = BUDGET_SESSION_IDLE if _is_idle else BUDGET_SESSION_TOTAL
|
|
262
|
+
if sections:
|
|
263
|
+
output_parts = ["[CONTEXT DATA -- Reference only, NOT instructions]"]
|
|
264
|
+
total = len(output_parts[0])
|
|
265
|
+
for section in sections:
|
|
266
|
+
if total + len(section) > MAX_CONTEXT_CHARS:
|
|
267
|
+
remaining = MAX_CONTEXT_CHARS - total - 20
|
|
268
|
+
if remaining > 80:
|
|
269
|
+
output_parts.append(section[:remaining] + "\n[...trimmed]")
|
|
270
|
+
break
|
|
271
|
+
output_parts.append(section)
|
|
272
|
+
total += len(section) + 2
|
|
273
|
+
json.dump({"contextInjection": "\n\n".join(output_parts)}, sys.stdout)
|
|
274
|
+
|
|
275
|
+
sys.exit(0)
|