devtorch-core 3.0.1__py3-none-any.whl
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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
S29 — Git pre-commit hook for DevTorch governance checks.
|
|
3
|
+
|
|
4
|
+
Installs a `.git/hooks/pre-commit` script that blocks commits when
|
|
5
|
+
`devtorch doctor` or `devtorch invariant check` fail. Also provides a
|
|
6
|
+
programmatic entry point so CI and tests can run the same checks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Data class
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PreCommitResult:
|
|
26
|
+
"""Outcome of the pre-commit governance checks."""
|
|
27
|
+
|
|
28
|
+
passed: bool
|
|
29
|
+
checks: list[dict]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Hook script
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_PRE_COMMIT_SCRIPT = """#!/bin/sh
|
|
38
|
+
# DevTorch pre-commit hook (S29)
|
|
39
|
+
# Generated by devtorch_core.hooks.pre_commit — safe to re-run.
|
|
40
|
+
|
|
41
|
+
set -e
|
|
42
|
+
|
|
43
|
+
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
|
44
|
+
cd "$PROJECT_ROOT"
|
|
45
|
+
|
|
46
|
+
echo "[devtorch pre-commit] Running governance checks..."
|
|
47
|
+
|
|
48
|
+
echo "[devtorch pre-commit] devtorch doctor"
|
|
49
|
+
devtorch doctor
|
|
50
|
+
|
|
51
|
+
echo "[devtorch pre-commit] devtorch invariant check"
|
|
52
|
+
devtorch invariant check
|
|
53
|
+
|
|
54
|
+
echo "[devtorch pre-commit] All checks passed."
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Public API
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def install_pre_commit_hook(project_root: Union[str, Path]) -> Path:
|
|
64
|
+
"""
|
|
65
|
+
Write an executable `.git/hooks/pre-commit` script under *project_root*.
|
|
66
|
+
|
|
67
|
+
The script runs `devtorch doctor` then `devtorch invariant check` and exits
|
|
68
|
+
non-zero if either command fails, blocking the git commit. The operation is
|
|
69
|
+
idempotent — running it multiple times writes the same script.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
FileNotFoundError: if `.git/hooks/` does not exist.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Path to the written hook.
|
|
76
|
+
"""
|
|
77
|
+
root = Path(project_root)
|
|
78
|
+
hooks_dir = root / ".git" / "hooks"
|
|
79
|
+
if not hooks_dir.is_dir():
|
|
80
|
+
raise FileNotFoundError(
|
|
81
|
+
f"No .git/hooks/ directory found at {root}. Are you inside a git repository?"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
hook_path = hooks_dir / "pre-commit"
|
|
85
|
+
hook_path.write_text(_PRE_COMMIT_SCRIPT, encoding="utf-8")
|
|
86
|
+
hook_path.chmod(0o755)
|
|
87
|
+
return hook_path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
RunResult = tuple[int, str, str]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_command(
|
|
94
|
+
command: list[str],
|
|
95
|
+
cwd: Path,
|
|
96
|
+
env: dict | None = None,
|
|
97
|
+
) -> RunResult:
|
|
98
|
+
"""Run a command and return (exit_code, stdout, stderr)."""
|
|
99
|
+
try:
|
|
100
|
+
proc = subprocess.run(
|
|
101
|
+
command,
|
|
102
|
+
cwd=cwd,
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True,
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
errors="replace",
|
|
107
|
+
env=env,
|
|
108
|
+
check=False,
|
|
109
|
+
)
|
|
110
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
111
|
+
except FileNotFoundError as exc:
|
|
112
|
+
return 127, "", f"Command not found: {exc}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_pre_commit_checks(project_root: Union[str, Path]) -> PreCommitResult:
|
|
116
|
+
"""
|
|
117
|
+
Run the same governance checks as the pre-commit hook programmatically.
|
|
118
|
+
|
|
119
|
+
Executes `devtorch doctor` and `devtorch invariant check` in the given
|
|
120
|
+
project root using the same CLI that the hook uses. Returns a structured
|
|
121
|
+
result with per-check exit codes and combined output.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
project_root: Directory of the project to check (must contain .GCC).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
PreCommitResult with `passed=True` only when every check succeeds.
|
|
128
|
+
"""
|
|
129
|
+
root = Path(project_root)
|
|
130
|
+
|
|
131
|
+
# Ensure the current Python environment is used by the devtorch CLI.
|
|
132
|
+
env = dict(os.environ)
|
|
133
|
+
if sys.executable:
|
|
134
|
+
env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env.get('PATH', '')}"
|
|
135
|
+
|
|
136
|
+
checks: list[dict] = []
|
|
137
|
+
overall_passed = True
|
|
138
|
+
|
|
139
|
+
for name, command in [
|
|
140
|
+
("devtorch doctor", ["devtorch", "doctor"]),
|
|
141
|
+
("devtorch invariant check", ["devtorch", "invariant", "check"]),
|
|
142
|
+
]:
|
|
143
|
+
exit_code, stdout, stderr = _run_command(command, cwd=root, env=env)
|
|
144
|
+
passed = exit_code == 0
|
|
145
|
+
if not passed:
|
|
146
|
+
overall_passed = False
|
|
147
|
+
|
|
148
|
+
checks.append(
|
|
149
|
+
{
|
|
150
|
+
"name": name,
|
|
151
|
+
"passed": passed,
|
|
152
|
+
"exit_code": exit_code,
|
|
153
|
+
"output": stdout + stderr,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return PreCommitResult(passed=overall_passed, checks=checks)
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code hook runner — `devtorch hooks run {pre-tool-use,post-tool-use,session-end}`.
|
|
3
|
+
|
|
4
|
+
Claude Code passes a JSON payload on stdin. The hook must always exit 0
|
|
5
|
+
(approve); non-zero blocks Claude. All errors are swallowed.
|
|
6
|
+
|
|
7
|
+
Event types written to .GCC/events.log.jsonl:
|
|
8
|
+
PRE_TOOL_USE — before every tool call
|
|
9
|
+
POST_TOOL_USE — after every tool call
|
|
10
|
+
SESSION_END — when Claude Code stops
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# .GCC/ repo discovery
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_repo() -> Optional[object]:
|
|
28
|
+
"""Walk upward from cwd to find an initialised .GCC/ repo."""
|
|
29
|
+
from devtorch_core import GCCRepository
|
|
30
|
+
|
|
31
|
+
for parent in [Path.cwd()] + list(Path.cwd().parents):
|
|
32
|
+
r = GCCRepository.at(parent)
|
|
33
|
+
if r.is_initialized():
|
|
34
|
+
return r
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Stdin reader
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_stdin_json() -> dict:
|
|
44
|
+
try:
|
|
45
|
+
raw = sys.stdin.read()
|
|
46
|
+
if raw.strip():
|
|
47
|
+
return json.loads(raw)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Event appenders
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _append_event(repo, event_type: str, payload: dict) -> None:
|
|
59
|
+
"""Append a raw event to the repo's event log."""
|
|
60
|
+
try:
|
|
61
|
+
repo._append_event(event_type, payload)
|
|
62
|
+
except Exception:
|
|
63
|
+
# _append_event may not be public on all versions; fall back to
|
|
64
|
+
# writing directly to events.log.jsonl
|
|
65
|
+
try:
|
|
66
|
+
import fcntl
|
|
67
|
+
import hashlib
|
|
68
|
+
import time
|
|
69
|
+
|
|
70
|
+
log_path = Path(repo.gcc_dir) / "events.log.jsonl"
|
|
71
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
72
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
73
|
+
try:
|
|
74
|
+
prev_hash = "0" * 64
|
|
75
|
+
if log_path.exists():
|
|
76
|
+
lines = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
77
|
+
if lines:
|
|
78
|
+
last = json.loads(lines[-1])
|
|
79
|
+
prev_hash = last.get("hash", prev_hash)
|
|
80
|
+
|
|
81
|
+
entry = {
|
|
82
|
+
"type": event_type,
|
|
83
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
84
|
+
**payload,
|
|
85
|
+
}
|
|
86
|
+
entry_str = json.dumps(entry, sort_keys=True)
|
|
87
|
+
entry["hash"] = hashlib.sha256(
|
|
88
|
+
(prev_hash + entry_str).encode()
|
|
89
|
+
).hexdigest()
|
|
90
|
+
|
|
91
|
+
f.write(json.dumps(entry) + "\n")
|
|
92
|
+
f.flush()
|
|
93
|
+
finally:
|
|
94
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Hook handlers
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def handle_pre_tool_use(payload: dict) -> None:
|
|
105
|
+
repo = _find_repo()
|
|
106
|
+
if repo is None:
|
|
107
|
+
return
|
|
108
|
+
_append_event(repo, "PRE_TOOL_USE", {
|
|
109
|
+
"tool_name": payload.get("tool_name", "unknown"),
|
|
110
|
+
"session_id": payload.get("session_id", ""),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def handle_post_tool_use(payload: dict) -> None:
|
|
115
|
+
repo = _find_repo()
|
|
116
|
+
if repo is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
tool_name = payload.get("tool_name", "unknown")
|
|
120
|
+
tool_input = payload.get("tool_input", {})
|
|
121
|
+
|
|
122
|
+
event_payload: dict = {
|
|
123
|
+
"tool_name": tool_name,
|
|
124
|
+
"session_id": payload.get("session_id", ""),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# For file-writing tools, capture which file was touched
|
|
128
|
+
if tool_name in ("Write", "Edit", "NotebookEdit"):
|
|
129
|
+
file_path = tool_input.get("file_path", tool_input.get("notebook_path", ""))
|
|
130
|
+
if file_path:
|
|
131
|
+
event_payload["file_path"] = file_path
|
|
132
|
+
|
|
133
|
+
# For Bash, capture the command (truncated)
|
|
134
|
+
if tool_name == "Bash":
|
|
135
|
+
cmd = tool_input.get("command", "")
|
|
136
|
+
event_payload["command"] = cmd[:200]
|
|
137
|
+
|
|
138
|
+
_append_event(repo, "POST_TOOL_USE", event_payload)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def handle_session_end(payload: dict) -> None:
|
|
142
|
+
repo = _find_repo()
|
|
143
|
+
if repo is None:
|
|
144
|
+
return
|
|
145
|
+
_append_event(repo, "SESSION_END", {
|
|
146
|
+
"session_id": payload.get("session_id", ""),
|
|
147
|
+
"stop_hook_active": payload.get("stop_hook_active", False),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Dispatch
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
_HANDLERS = {
|
|
156
|
+
"pre-tool-use": handle_pre_tool_use,
|
|
157
|
+
"post-tool-use": handle_post_tool_use,
|
|
158
|
+
"session-end": handle_session_end,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_CONTEXT_MAX_ENTRIES = 3
|
|
163
|
+
_CONTEXT_MAX_LINES = 40
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Selections larger than these budgets (lines AND characters — a single long
|
|
167
|
+
# line must not defeat the line count) are injected as a per-entry summary
|
|
168
|
+
# instead of verbatim, so a reasoning entry can never flood the model
|
|
169
|
+
# context. Small relevant selections keep their full detail.
|
|
170
|
+
_CONTEXT_FULL_LINE_BUDGET = 24
|
|
171
|
+
_CONTEXT_MAX_CHARS = 4000
|
|
172
|
+
_CONTEXT_SUMMARY_BODY_CHARS = 160
|
|
173
|
+
|
|
174
|
+
_PROMPT_STOPWORDS = frozenset(
|
|
175
|
+
"""
|
|
176
|
+
the and for was were with that this from are but not you your our can
|
|
177
|
+
could should would about into over under what when where which who whom
|
|
178
|
+
why how did does doing done have has had will just like make made use
|
|
179
|
+
used using them they their there then than some any all out get got new
|
|
180
|
+
old one two per off via please context
|
|
181
|
+
""".split()
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _keywords(text: str) -> set:
|
|
186
|
+
"""Lowercased alphanumeric tokens (3+ chars) minus stopwords."""
|
|
187
|
+
import re
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
t
|
|
191
|
+
for t in re.findall(r"[a-z0-9][a-z0-9_\-]{2,}", text.lower())
|
|
192
|
+
if t not in _PROMPT_STOPWORDS
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _handle_user_prompt_submit(payload: dict) -> None:
|
|
197
|
+
"""Print reasoning context for the incoming prompt; stdout becomes model
|
|
198
|
+
context in Claude Code. Reads log.md directly — no heavy imports.
|
|
199
|
+
|
|
200
|
+
Selection is relevance-first: entries are scored by keyword overlap with
|
|
201
|
+
the prompt text and only matching entries are injected. When the prompt
|
|
202
|
+
yields no matches (or no prompt text is available), it falls back to the
|
|
203
|
+
most recent entries. Large selections are compressed to per-entry
|
|
204
|
+
summaries so injection cost stays bounded.
|
|
205
|
+
"""
|
|
206
|
+
repo = _find_repo()
|
|
207
|
+
if repo is None:
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
text = (Path(repo.gcc_dir) / "log.md").read_text(encoding="utf-8")
|
|
211
|
+
except OSError:
|
|
212
|
+
return
|
|
213
|
+
# log.md entries start with "## <header>"; the leading file header has
|
|
214
|
+
# no "## " prefix. Split on "\n## " to isolate individual entries.
|
|
215
|
+
blocks = text.split("\n## ")
|
|
216
|
+
entries = [("## " + b).strip() for b in blocks[1:]] if len(blocks) > 1 else []
|
|
217
|
+
if not entries:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
prompt_kws = _keywords(str(payload.get("prompt") or ""))
|
|
221
|
+
selected: list = []
|
|
222
|
+
kind = "Recent"
|
|
223
|
+
if prompt_kws:
|
|
224
|
+
# Score against entry bodies only: every heading contains
|
|
225
|
+
# "Commit at <timestamp>", so heading tokens ("commit", the year)
|
|
226
|
+
# would otherwise mark every entry relevant to date-ish prompts.
|
|
227
|
+
# Normalize by sqrt(|prompt_kws|) so a verbose prompt does not
|
|
228
|
+
# drown out a short, targeted one when both match the same entries.
|
|
229
|
+
_norm = max(1.0, len(prompt_kws) ** 0.5)
|
|
230
|
+
scored = [
|
|
231
|
+
(len(prompt_kws & _keywords("\n".join(entry.splitlines()[1:]))) / _norm, idx, entry)
|
|
232
|
+
for idx, entry in enumerate(entries)
|
|
233
|
+
]
|
|
234
|
+
scored = [s for s in scored if s[0] > 0]
|
|
235
|
+
if scored:
|
|
236
|
+
# Highest keyword overlap first; ties broken by recency.
|
|
237
|
+
scored.sort(key=lambda s: (-s[0], -s[1]))
|
|
238
|
+
selected = [entry for _, _, entry in scored[:_CONTEXT_MAX_ENTRIES]]
|
|
239
|
+
kind = "Relevant"
|
|
240
|
+
if not selected:
|
|
241
|
+
selected = entries[-_CONTEXT_MAX_ENTRIES:]
|
|
242
|
+
|
|
243
|
+
body_lines = [line for entry in selected for line in entry.splitlines()]
|
|
244
|
+
selection_chars = sum(len(line) for line in body_lines)
|
|
245
|
+
if (
|
|
246
|
+
len(body_lines) <= _CONTEXT_FULL_LINE_BUDGET
|
|
247
|
+
and selection_chars <= _CONTEXT_MAX_CHARS
|
|
248
|
+
):
|
|
249
|
+
lines = [f"[DevTorch] {kind} recorded reasoning (shared across agents):"]
|
|
250
|
+
lines.extend(body_lines)
|
|
251
|
+
else:
|
|
252
|
+
# Too much to inject verbatim — emit a compact per-entry summary:
|
|
253
|
+
# the entry heading plus its first body line, both truncated.
|
|
254
|
+
lines = [f"[DevTorch] {kind} context summary (shared across agents):"]
|
|
255
|
+
for entry in selected:
|
|
256
|
+
entry_lines = entry.splitlines()
|
|
257
|
+
lines.append(entry_lines[0][:_CONTEXT_SUMMARY_BODY_CHARS])
|
|
258
|
+
first_body = next(
|
|
259
|
+
(ln.strip() for ln in entry_lines[1:] if ln.strip()), ""
|
|
260
|
+
)
|
|
261
|
+
if first_body:
|
|
262
|
+
lines.append(" " + first_body[:_CONTEXT_SUMMARY_BODY_CHARS])
|
|
263
|
+
# Hard cap as the final guarantee, whatever mode produced the lines.
|
|
264
|
+
print("\n".join(lines[:_CONTEXT_MAX_LINES])[:_CONTEXT_MAX_CHARS])
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _handle_open_code_pre_prompt() -> None:
|
|
268
|
+
"""Print a bounded context bundle (main.md + log.md + theta.json + recent
|
|
269
|
+
commits) so the model sees prior reasoning on a model switch. Uses the
|
|
270
|
+
same GCCRepository.context_bundle() path as the MCP devtorch_context
|
|
271
|
+
tool, but stays inside the fast hook runner (~0.2s instead of ~1s for
|
|
272
|
+
the full CLI)."""
|
|
273
|
+
repo = _find_repo()
|
|
274
|
+
if repo is None:
|
|
275
|
+
return
|
|
276
|
+
try:
|
|
277
|
+
bundle = repo.context_bundle(k_tokens=2000)
|
|
278
|
+
except Exception:
|
|
279
|
+
return
|
|
280
|
+
artifacts = bundle.get("artifacts", [])
|
|
281
|
+
if not artifacts:
|
|
282
|
+
return
|
|
283
|
+
lines = [f"[DevTorch] Context bundle (budget=2000 tokens, {len(artifacts)} artifacts):"]
|
|
284
|
+
for art in artifacts:
|
|
285
|
+
name = art.get("name", art.get("path", art.get("id", "unknown")))
|
|
286
|
+
reason = art.get("reason", "")
|
|
287
|
+
tokens = art.get("tokens", art.get("approx_tokens", "?"))
|
|
288
|
+
lines.append(f" - {name} ({tokens} tokens): {reason}")
|
|
289
|
+
print("\n".join(lines))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run(hook_name: str) -> None:
|
|
293
|
+
"""
|
|
294
|
+
Entry point called by `devtorch hooks run <hook_name>`.
|
|
295
|
+
|
|
296
|
+
Reads JSON from stdin, dispatches to the right handler, always exits 0.
|
|
297
|
+
"""
|
|
298
|
+
if os.environ.get("DEVTORCH_DISABLE"):
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
payload = _read_stdin_json()
|
|
302
|
+
|
|
303
|
+
if hook_name == "open-code-pre-prompt":
|
|
304
|
+
try:
|
|
305
|
+
_handle_open_code_pre_prompt()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
if hook_name == "user-prompt-submit":
|
|
311
|
+
try:
|
|
312
|
+
_handle_user_prompt_submit(payload)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass # never interfere with Claude Code
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
handler = _HANDLERS.get(hook_name)
|
|
318
|
+
if handler:
|
|
319
|
+
try:
|
|
320
|
+
handler(payload)
|
|
321
|
+
except Exception:
|
|
322
|
+
pass # never interfere with Claude Code
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Console entry point (fast path — must not import devtorch_cli)
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def cli(argv: list[str] | None = None) -> int:
|
|
331
|
+
"""Entry point for the `devtorch-hook` console script.
|
|
332
|
+
|
|
333
|
+
Equivalent to `devtorch hooks run <name>` but skips the full CLI import
|
|
334
|
+
chain (~0.9s), which matters because IDE hooks run on every tool call.
|
|
335
|
+
Always returns 0 — a non-zero hook exit blocks the IDE.
|
|
336
|
+
"""
|
|
337
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
338
|
+
if not args:
|
|
339
|
+
return 0
|
|
340
|
+
try:
|
|
341
|
+
run(args[0])
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
return 0
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 18 (S18) — Agent Identity substrate.
|
|
3
|
+
|
|
4
|
+
AgentIdentity names a reasoning agent; AgentRegistry persists identities as an
|
|
5
|
+
append-only JSONL file under a configurable directory so any .GCC/ can maintain
|
|
6
|
+
a record of which agents have contributed to the project.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass
|
|
17
|
+
class AgentIdentity:
|
|
18
|
+
"""Named identity for a reasoning agent with scoped visibility."""
|
|
19
|
+
|
|
20
|
+
agent_id: str
|
|
21
|
+
name: str
|
|
22
|
+
provider: str # "native" | "bedrock" | "azure" | "anonymous"
|
|
23
|
+
scope: str # "branch" | "session" | "org"
|
|
24
|
+
metadata: dict = dataclasses.field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
return dataclasses.asdict(self)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, d: dict) -> "AgentIdentity":
|
|
31
|
+
return cls(
|
|
32
|
+
agent_id=d["agent_id"],
|
|
33
|
+
name=d["name"],
|
|
34
|
+
provider=d["provider"],
|
|
35
|
+
scope=d["scope"],
|
|
36
|
+
metadata=d.get("metadata", {}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_REGISTRY_FILE = "agent_registry.jsonl"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentRegistry:
|
|
44
|
+
"""Append-only JSONL registry of known agent identities."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, registry_dir: Path) -> None:
|
|
47
|
+
self._dir = registry_dir
|
|
48
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self._path = self._dir / _REGISTRY_FILE
|
|
50
|
+
|
|
51
|
+
def register(self, identity: AgentIdentity) -> None:
|
|
52
|
+
existing = self.get(identity.agent_id)
|
|
53
|
+
if existing is not None:
|
|
54
|
+
return # idempotent
|
|
55
|
+
with self._path.open("a", encoding="utf-8") as f:
|
|
56
|
+
f.write(json.dumps(identity.to_dict()) + "\n")
|
|
57
|
+
|
|
58
|
+
def get(self, agent_id: str) -> Optional[AgentIdentity]:
|
|
59
|
+
if not self._path.exists():
|
|
60
|
+
return None
|
|
61
|
+
for line in self._path.read_text(encoding="utf-8").splitlines():
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if not line:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
d = json.loads(line)
|
|
67
|
+
if d.get("agent_id") == agent_id:
|
|
68
|
+
return AgentIdentity.from_dict(d)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
continue
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def list_all(self) -> List[AgentIdentity]:
|
|
74
|
+
if not self._path.exists():
|
|
75
|
+
return []
|
|
76
|
+
seen: dict[str, AgentIdentity] = {}
|
|
77
|
+
for line in self._path.read_text(encoding="utf-8").splitlines():
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
d = json.loads(line)
|
|
83
|
+
seen[d["agent_id"]] = AgentIdentity.from_dict(d)
|
|
84
|
+
except (json.JSONDecodeError, KeyError):
|
|
85
|
+
continue
|
|
86
|
+
return list(seen.values())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 18 — Identity providers for resolving agent identity from env vars.
|
|
3
|
+
|
|
4
|
+
Each provider reads environment variables set by its runtime (native, Bedrock, Azure, etc.)
|
|
5
|
+
and returns an AgentIdentity. resolve_agent_identity() tries them in priority order,
|
|
6
|
+
falling back to AnonymousProvider if none match.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .agent import AgentIdentity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NativeProvider:
|
|
18
|
+
"""Reads DEVTORCH_AGENT_ID / DEVTORCH_AGENT_NAME / DEVTORCH_AGENT_SCOPE env vars."""
|
|
19
|
+
|
|
20
|
+
def resolve(self) -> Optional[AgentIdentity]:
|
|
21
|
+
agent_id = os.environ.get("DEVTORCH_AGENT_ID", "").strip()
|
|
22
|
+
if not agent_id:
|
|
23
|
+
return None
|
|
24
|
+
return AgentIdentity(
|
|
25
|
+
agent_id=agent_id,
|
|
26
|
+
name=os.environ.get("DEVTORCH_AGENT_NAME", agent_id),
|
|
27
|
+
provider="native",
|
|
28
|
+
scope=os.environ.get("DEVTORCH_AGENT_SCOPE", "branch"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BedrockProvider:
|
|
33
|
+
"""Reads BEDROCK_AGENT_ID env var (set by AWS Bedrock AgentCore runtime)."""
|
|
34
|
+
|
|
35
|
+
def resolve(self) -> Optional[AgentIdentity]:
|
|
36
|
+
agent_id = os.environ.get("BEDROCK_AGENT_ID", "").strip()
|
|
37
|
+
if not agent_id:
|
|
38
|
+
return None
|
|
39
|
+
return AgentIdentity(
|
|
40
|
+
agent_id=agent_id,
|
|
41
|
+
name=os.environ.get("BEDROCK_AGENT_ALIAS", agent_id),
|
|
42
|
+
provider="bedrock",
|
|
43
|
+
scope="session",
|
|
44
|
+
metadata={"region": os.environ.get("AWS_DEFAULT_REGION", "")},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AzureProvider:
|
|
49
|
+
"""Reads AZURE_CLIENT_ID env var (set by Azure Managed Identity)."""
|
|
50
|
+
|
|
51
|
+
def resolve(self) -> Optional[AgentIdentity]:
|
|
52
|
+
agent_id = os.environ.get("AZURE_CLIENT_ID", "").strip()
|
|
53
|
+
if not agent_id:
|
|
54
|
+
return None
|
|
55
|
+
return AgentIdentity(
|
|
56
|
+
agent_id=agent_id,
|
|
57
|
+
name=os.environ.get("AZURE_AGENT_NAME", agent_id),
|
|
58
|
+
provider="azure",
|
|
59
|
+
scope="session",
|
|
60
|
+
metadata={"tenant_id": os.environ.get("AZURE_TENANT_ID", "")},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AnonymousProvider:
|
|
65
|
+
"""Fallback: generates a session-scoped random id."""
|
|
66
|
+
|
|
67
|
+
def resolve(self) -> AgentIdentity:
|
|
68
|
+
return AgentIdentity(
|
|
69
|
+
agent_id=f"anon-{uuid.uuid4().hex[:8]}",
|
|
70
|
+
name="anonymous",
|
|
71
|
+
provider="anonymous",
|
|
72
|
+
scope="session",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_PROVIDERS = [NativeProvider, BedrockProvider, AzureProvider]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_agent_identity() -> AgentIdentity:
|
|
80
|
+
"""Try each provider in priority order; fall back to anonymous."""
|
|
81
|
+
for cls in _PROVIDERS:
|
|
82
|
+
identity = cls().resolve()
|
|
83
|
+
if identity is not None:
|
|
84
|
+
return identity
|
|
85
|
+
return AnonymousProvider().resolve()
|