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,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
S26 git capture hooks — always-on capture for commit, merge, checkout, rebase,
|
|
3
|
+
and conflict events.
|
|
4
|
+
|
|
5
|
+
Entry point: devtorch hooks git-capture <event> [args...]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# .GCC/ repo discovery
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_repo() -> Optional[object]:
|
|
23
|
+
"""Walk upward from cwd to find an initialised .GCC/ repo."""
|
|
24
|
+
from devtorch_core import GCCRepository
|
|
25
|
+
|
|
26
|
+
for parent in [Path.cwd()] + list(Path.cwd().parents):
|
|
27
|
+
r = GCCRepository.at(parent)
|
|
28
|
+
if r.is_initialized():
|
|
29
|
+
return r
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Event appender
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _append_event(repo, event_type: str, payload: dict) -> None:
|
|
39
|
+
"""Append a raw event to the repo's event log."""
|
|
40
|
+
try:
|
|
41
|
+
repo._append_event(event_type, payload)
|
|
42
|
+
except Exception:
|
|
43
|
+
# _append_event may not be public on all versions; fall back to
|
|
44
|
+
# writing directly to events.log.jsonl
|
|
45
|
+
try:
|
|
46
|
+
import fcntl
|
|
47
|
+
import hashlib
|
|
48
|
+
import time
|
|
49
|
+
|
|
50
|
+
log_path = Path(repo.gcc_dir) / "events.log.jsonl"
|
|
51
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
53
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
54
|
+
try:
|
|
55
|
+
prev_hash = "0" * 64
|
|
56
|
+
if log_path.exists():
|
|
57
|
+
lines = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
58
|
+
if lines:
|
|
59
|
+
last = json.loads(lines[-1])
|
|
60
|
+
prev_hash = last.get("hash", prev_hash)
|
|
61
|
+
|
|
62
|
+
entry = {
|
|
63
|
+
"type": event_type,
|
|
64
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
65
|
+
**payload,
|
|
66
|
+
}
|
|
67
|
+
entry_str = json.dumps(entry, sort_keys=True)
|
|
68
|
+
entry["hash"] = hashlib.sha256(
|
|
69
|
+
(prev_hash + entry_str).encode()
|
|
70
|
+
).hexdigest()
|
|
71
|
+
|
|
72
|
+
f.write(json.dumps(entry) + "\n")
|
|
73
|
+
f.flush()
|
|
74
|
+
finally:
|
|
75
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Git metadata helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_git(repo_root: Path, *args: str, timeout: float = 10.0) -> str:
|
|
86
|
+
"""Run a git command and return stripped stdout, or empty string on failure."""
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
["git", *args],
|
|
90
|
+
cwd=repo_root,
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
check=True,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
)
|
|
96
|
+
return result.stdout.strip()
|
|
97
|
+
except Exception:
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _current_branch(repo: object) -> str:
|
|
102
|
+
"""Best-effort current branch name from git or GCC repo."""
|
|
103
|
+
try:
|
|
104
|
+
branch = _run_git(repo.root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
105
|
+
if branch:
|
|
106
|
+
return branch
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return getattr(repo, "_current_branch", lambda: "main")()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _head_sha(repo: object, ref: str = "HEAD") -> str:
|
|
113
|
+
"""Best-effort commit SHA for the given ref."""
|
|
114
|
+
return _run_git(repo.root, "rev-parse", ref)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Merge conflict detection
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_CONFLICT_MARKER_RE = re.compile(r"^(<{7}|={7}|>{7})", re.MULTILINE)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _has_conflict_markers(repo_root: Path) -> bool:
|
|
126
|
+
"""Scan tracked files in the working tree for conflict markers."""
|
|
127
|
+
try:
|
|
128
|
+
# Ask git for files with changes (staged or unstaged)
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["git", "diff", "--name-only"],
|
|
131
|
+
cwd=repo_root,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=10.0,
|
|
135
|
+
)
|
|
136
|
+
files = result.stdout.strip().splitlines()
|
|
137
|
+
if not files:
|
|
138
|
+
# Also check staged diffs
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["git", "diff", "--cached", "--name-only"],
|
|
141
|
+
cwd=repo_root,
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
timeout=10.0,
|
|
145
|
+
)
|
|
146
|
+
files = result.stdout.strip().splitlines()
|
|
147
|
+
|
|
148
|
+
for line in files:
|
|
149
|
+
fpath = repo_root / line
|
|
150
|
+
if fpath.is_file():
|
|
151
|
+
try:
|
|
152
|
+
text = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
153
|
+
if _CONFLICT_MARKER_RE.search(text):
|
|
154
|
+
return True
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _merge_state_files(repo_root: Path) -> bool:
|
|
163
|
+
"""Check the classic .git merge state files."""
|
|
164
|
+
git_dir = repo_root / ".git"
|
|
165
|
+
try:
|
|
166
|
+
if (git_dir / "MERGE_HEAD").exists():
|
|
167
|
+
return True
|
|
168
|
+
if (git_dir / "MERGE_MSG").exists():
|
|
169
|
+
return True
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def detect_merge_conflict(repo: object) -> bool:
|
|
176
|
+
"""Detect whether the repository is currently in a merge conflict state."""
|
|
177
|
+
try:
|
|
178
|
+
repo_root = Path(repo.root)
|
|
179
|
+
if _merge_state_files(repo_root):
|
|
180
|
+
return True
|
|
181
|
+
if _has_conflict_markers(repo_root):
|
|
182
|
+
return True
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _emit_conflict_if_detected(repo: object, trigger: str) -> None:
|
|
189
|
+
"""Helper: emit a git_conflict event if the repo is in conflict state."""
|
|
190
|
+
if detect_merge_conflict(repo):
|
|
191
|
+
_append_event(repo, "git_conflict", {
|
|
192
|
+
"branch": _current_branch(repo),
|
|
193
|
+
"trigger": trigger,
|
|
194
|
+
"kind": "merge_conflict",
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Git hook handlers
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def handle_git_commit(event_name: str = "post-commit", args: Optional[list[str]] = None) -> None:
|
|
204
|
+
"""post-commit hook entry point."""
|
|
205
|
+
repo = _find_repo()
|
|
206
|
+
if repo is None:
|
|
207
|
+
return
|
|
208
|
+
_append_event(repo, "git_commit", {
|
|
209
|
+
"branch": _current_branch(repo),
|
|
210
|
+
"commit_sha": _head_sha(repo, "HEAD"),
|
|
211
|
+
"hook": event_name,
|
|
212
|
+
"args": args or [],
|
|
213
|
+
})
|
|
214
|
+
_emit_conflict_if_detected(repo, event_name)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def handle_git_merge(event_name: str = "post-merge", args: Optional[list[str]] = None) -> None:
|
|
218
|
+
"""post-merge hook entry point."""
|
|
219
|
+
repo = _find_repo()
|
|
220
|
+
if repo is None:
|
|
221
|
+
return
|
|
222
|
+
branch = _current_branch(repo)
|
|
223
|
+
payload: dict = {
|
|
224
|
+
"branch": branch,
|
|
225
|
+
"hook": event_name,
|
|
226
|
+
"args": args or [],
|
|
227
|
+
}
|
|
228
|
+
if args:
|
|
229
|
+
payload["squash_flag"] = args[0]
|
|
230
|
+
_append_event(repo, "git_merge", payload)
|
|
231
|
+
_emit_conflict_if_detected(repo, event_name)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def handle_git_checkout(event_name: str = "post-checkout", args: Optional[list[str]] = None) -> None:
|
|
235
|
+
"""post-checkout hook entry point."""
|
|
236
|
+
repo = _find_repo()
|
|
237
|
+
if repo is None:
|
|
238
|
+
return
|
|
239
|
+
branch = _current_branch(repo)
|
|
240
|
+
arg_list = args or []
|
|
241
|
+
payload: dict = {
|
|
242
|
+
"branch": branch,
|
|
243
|
+
"hook": event_name,
|
|
244
|
+
"args": arg_list,
|
|
245
|
+
}
|
|
246
|
+
if len(arg_list) > 0:
|
|
247
|
+
payload["prev_head"] = arg_list[0]
|
|
248
|
+
if len(arg_list) > 1:
|
|
249
|
+
payload["new_head"] = arg_list[1]
|
|
250
|
+
if len(arg_list) > 2:
|
|
251
|
+
payload["flag"] = arg_list[2]
|
|
252
|
+
_append_event(repo, "git_checkout", payload)
|
|
253
|
+
_emit_conflict_if_detected(repo, event_name)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def handle_git_rebase(event_name: str = "post-rewrite", args: Optional[list[str]] = None) -> None:
|
|
257
|
+
"""post-rewrite hook entry point (rebase / amend)."""
|
|
258
|
+
repo = _find_repo()
|
|
259
|
+
if repo is None:
|
|
260
|
+
return
|
|
261
|
+
branch = _current_branch(repo)
|
|
262
|
+
command = (args or [""])[0] if args else ""
|
|
263
|
+
if command == "rebase":
|
|
264
|
+
_append_event(repo, "git_rebase", {
|
|
265
|
+
"branch": branch,
|
|
266
|
+
"hook": event_name,
|
|
267
|
+
"command": command,
|
|
268
|
+
"args": args or [],
|
|
269
|
+
})
|
|
270
|
+
_emit_conflict_if_detected(repo, event_name)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def handle_git_conflict() -> None:
|
|
274
|
+
"""Manual / helper invocation: emit a git_conflict event if in conflict."""
|
|
275
|
+
repo = _find_repo()
|
|
276
|
+
if repo is None:
|
|
277
|
+
return
|
|
278
|
+
_emit_conflict_if_detected(repo, "manual")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Dispatch
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
_HANDLERS = {
|
|
286
|
+
"post-commit": handle_git_commit,
|
|
287
|
+
"post-merge": handle_git_merge,
|
|
288
|
+
"post-checkout": handle_git_checkout,
|
|
289
|
+
"post-rewrite": handle_git_rebase,
|
|
290
|
+
"conflict": handle_git_conflict,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def handle_git_capture(event_name: str, args: Optional[list[str]] = None) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Entry point called by `devtorch hooks git-capture <event> [args...]`.
|
|
297
|
+
Dispatches to the correct handler. Unknown events are ignored silently.
|
|
298
|
+
"""
|
|
299
|
+
handler = _HANDLERS.get(event_name)
|
|
300
|
+
if handler:
|
|
301
|
+
try:
|
|
302
|
+
handler(event_name, args)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass # never interfere with git
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Hook installer
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
_GIT_CAPTURE_HOOKS: dict[str, str] = {
|
|
312
|
+
"post-commit": "devtorch hooks git-capture post-commit \"$@\"",
|
|
313
|
+
"post-merge": "devtorch hooks git-capture post-merge \"$@\"",
|
|
314
|
+
"post-checkout": "devtorch hooks git-capture post-checkout \"$@\"",
|
|
315
|
+
"post-rewrite": "devtorch hooks git-capture post-rewrite \"$@\"",
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def install_git_capture_hooks(repo_root: Path | str) -> tuple[bool, dict[str, str]]:
|
|
320
|
+
"""
|
|
321
|
+
Install the S26 git capture hooks in .git/hooks/.
|
|
322
|
+
|
|
323
|
+
Returns (all_ok, {hook_name: path or error_message}).
|
|
324
|
+
"""
|
|
325
|
+
hooks_dir = Path(repo_root) / ".git" / "hooks"
|
|
326
|
+
if not hooks_dir.is_dir():
|
|
327
|
+
return False, {}
|
|
328
|
+
|
|
329
|
+
written: dict[str, str] = {}
|
|
330
|
+
all_ok = True
|
|
331
|
+
for hook_name, body in _GIT_CAPTURE_HOOKS.items():
|
|
332
|
+
hook_file = hooks_dir / hook_name
|
|
333
|
+
try:
|
|
334
|
+
hook_script = f"#!/bin/sh\n{body}\n"
|
|
335
|
+
hook_file.write_text(hook_script, encoding="utf-8")
|
|
336
|
+
hook_file.chmod(0o755)
|
|
337
|
+
written[hook_name] = str(hook_file)
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
all_ok = False
|
|
340
|
+
written[hook_name] = str(exc)
|
|
341
|
+
return all_ok, written
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
S7 git commit-msg hook — human developer capture.
|
|
3
|
+
|
|
4
|
+
Intercepts git commits to record sensitivity signals and mirror the commit
|
|
5
|
+
into .GCC/ so the reasoning audit trail stays in sync with the git history.
|
|
6
|
+
|
|
7
|
+
Entry point: devtorch hooks git-commit <commit_msg_file>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Keyword → concept mapping
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
_CONCEPT_KEYWORDS: dict[str, list[str]] = {
|
|
24
|
+
"auth": ["auth", "login", "token", "oauth", "jwt", "session", "credential"],
|
|
25
|
+
"schema": ["schema", "migration", "alter", "column", "table", "model"],
|
|
26
|
+
"api": ["api", "endpoint", "route", "rest", "graphql", "grpc", "http"],
|
|
27
|
+
"deploy": ["deploy", "release", "publish", "rollout", "pipeline", "ci", "cd"],
|
|
28
|
+
"security": ["security", "vuln", "cve", "exploit", "xss", "sql", "injection", "sanitize"],
|
|
29
|
+
"performance": ["perf", "performance", "latency", "throughput", "cache", "index", "optimize"],
|
|
30
|
+
"config": ["config", "env", "setting", "feature-flag", "flag", "yaml", "toml"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _infer_concept(message: str) -> str:
|
|
35
|
+
"""Return the best matching concept keyword for a commit message."""
|
|
36
|
+
# Strip DevTorch auto-capture prefix "[session=<UUID>] auto(conf=...):"
|
|
37
|
+
# and standard git trailers before keyword matching to avoid false positives
|
|
38
|
+
# ("auth" matching "Co-Authored-By", "session" matching "[session=UUID]").
|
|
39
|
+
cleaned = re.sub(r"^\[session=[^\]]+\]\s*auto\([^)]*\):[^\n]*\n?", "", message)
|
|
40
|
+
trailer_re = re.compile(
|
|
41
|
+
r"^(co-authored-by|signed-off-by|reviewed-by|acked-by|reported-by):.*$",
|
|
42
|
+
re.IGNORECASE | re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
cleaned = trailer_re.sub("", cleaned)
|
|
45
|
+
lower = cleaned.lower()
|
|
46
|
+
for concept, keywords in _CONCEPT_KEYWORDS.items():
|
|
47
|
+
for kw in keywords:
|
|
48
|
+
if re.search(r"\b" + re.escape(kw) + r"\b", lower):
|
|
49
|
+
return concept
|
|
50
|
+
return "general"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _word_count(text: str) -> int:
|
|
54
|
+
return len(text.split())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Public API
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_sensitivity_from_commit(message: str) -> Optional[dict]:
|
|
63
|
+
"""
|
|
64
|
+
Infer sensitivity signal from a git commit message.
|
|
65
|
+
|
|
66
|
+
Confidence tiers by message quality:
|
|
67
|
+
- > 50 words: 0.75 (substantive reasoning)
|
|
68
|
+
- > 20 words: 0.70 (reasonable explanation)
|
|
69
|
+
- > 5 words: 0.50 (brief note)
|
|
70
|
+
- <= 5 words: 0.40 (minimal — "fix bug", "wip")
|
|
71
|
+
|
|
72
|
+
Returns {"concept": str, "signal": str, "confidence": float}
|
|
73
|
+
or None if message is empty.
|
|
74
|
+
"""
|
|
75
|
+
stripped = message.strip()
|
|
76
|
+
if not stripped:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
n_words = _word_count(stripped)
|
|
80
|
+
if n_words > 50:
|
|
81
|
+
confidence = 0.75
|
|
82
|
+
elif n_words > 20:
|
|
83
|
+
confidence = 0.70
|
|
84
|
+
elif n_words > 5:
|
|
85
|
+
confidence = 0.50
|
|
86
|
+
else:
|
|
87
|
+
confidence = 0.40
|
|
88
|
+
|
|
89
|
+
concept = _infer_concept(stripped)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"concept": concept,
|
|
93
|
+
"signal": stripped,
|
|
94
|
+
"confidence": confidence,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _find_gcc_repo() -> Optional[object]:
|
|
99
|
+
"""Walk upward from cwd to find an initialised .GCC/ repository."""
|
|
100
|
+
from devtorch_core import GCCRepository
|
|
101
|
+
|
|
102
|
+
for parent in [Path.cwd()] + list(Path.cwd().parents):
|
|
103
|
+
r = GCCRepository.at(parent)
|
|
104
|
+
if r.is_initialized():
|
|
105
|
+
return r
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def handle_commit_msg(commit_msg_file: str) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Git commit-msg hook entry point.
|
|
112
|
+
|
|
113
|
+
Reads commit message from commit_msg_file.
|
|
114
|
+
Finds .GCC/ by walking up from cwd.
|
|
115
|
+
Creates SensitivityEvent and calls repo.add_sensitivity().
|
|
116
|
+
Also calls repo.commit(message=git_message).
|
|
117
|
+
All errors are silent — never interfere with git commit.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
git_message = Path(commit_msg_file).read_text(encoding="utf-8").strip()
|
|
121
|
+
if not git_message:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
sensitivity_data = extract_sensitivity_from_commit(git_message)
|
|
125
|
+
if sensitivity_data is None:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
repo = _find_gcc_repo()
|
|
129
|
+
if repo is None:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
from devtorch_core.sensitivity import SensitivityEvent
|
|
133
|
+
|
|
134
|
+
ev = SensitivityEvent(
|
|
135
|
+
source_node="git-commit-hook",
|
|
136
|
+
target_concept=sensitivity_data["concept"],
|
|
137
|
+
counterfactuals=sensitivity_data["signal"],
|
|
138
|
+
confidence=sensitivity_data["confidence"],
|
|
139
|
+
disclosure_level="PROTECTED",
|
|
140
|
+
message=git_message,
|
|
141
|
+
)
|
|
142
|
+
repo.add_sensitivity(ev)
|
|
143
|
+
repo.commit(message=git_message)
|
|
144
|
+
|
|
145
|
+
except Exception:
|
|
146
|
+
# Never interfere with git commit
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def install_hook(repo_path: Optional[Path] = None) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Install the git commit-msg hook in .git/hooks/commit-msg.
|
|
153
|
+
|
|
154
|
+
Creates a shell script that calls: devtorch hooks git-commit "$1"
|
|
155
|
+
Makes it executable.
|
|
156
|
+
"""
|
|
157
|
+
if repo_path is None:
|
|
158
|
+
repo_path = Path.cwd()
|
|
159
|
+
|
|
160
|
+
hooks_dir = repo_path / ".git" / "hooks"
|
|
161
|
+
if not hooks_dir.is_dir():
|
|
162
|
+
raise FileNotFoundError(
|
|
163
|
+
f"No .git/hooks/ directory found at {repo_path}. "
|
|
164
|
+
"Are you inside a git repository?"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
hook_file = hooks_dir / "commit-msg"
|
|
168
|
+
hook_script = "#!/bin/sh\ndevtorch hooks git-commit \"$1\"\n"
|
|
169
|
+
hook_file.write_text(hook_script, encoding="utf-8")
|
|
170
|
+
hook_file.chmod(0o755)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main() -> None:
|
|
174
|
+
"""Entry point: devtorch hooks git-commit <commit_msg_file>"""
|
|
175
|
+
if len(sys.argv) < 2:
|
|
176
|
+
sys.stderr.write(
|
|
177
|
+
"Usage: devtorch hooks git-commit <commit_msg_file>\n"
|
|
178
|
+
)
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
commit_msg_file = sys.argv[1]
|
|
182
|
+
handle_commit_msg(commit_msg_file)
|