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,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitLab MR webhook handler for DevTorch.
|
|
3
|
+
|
|
4
|
+
Mirrors the GitHub App flow for GitLab:
|
|
5
|
+
- Validates X-Gitlab-Token signature
|
|
6
|
+
- Parses MR descriptions for DevTorch reasoning metadata
|
|
7
|
+
- Provides merge gate logic (I3 critical concepts unresolved)
|
|
8
|
+
|
|
9
|
+
Actual GitLab API calls to post comments require a project access token and
|
|
10
|
+
are left to the caller or a future connector.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MRMetadata:
|
|
27
|
+
"""Governance metadata extracted from an MR description."""
|
|
28
|
+
branch: str
|
|
29
|
+
commit_ids: List[str]
|
|
30
|
+
sensitivity_refs: List[str]
|
|
31
|
+
concept_refs: List[str]
|
|
32
|
+
mcs_score: Optional[float]
|
|
33
|
+
has_devtorch_section: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GitLabWebhookHandler:
|
|
37
|
+
"""Handles GitLab Merge Request webhook events."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, webhook_secret: bytes) -> None:
|
|
40
|
+
self._webhook_secret = webhook_secret
|
|
41
|
+
|
|
42
|
+
def handle_event(self, body: bytes, token_header: str, event_type: str) -> Dict[str, Any]:
|
|
43
|
+
"""Validate and process a GitLab webhook event."""
|
|
44
|
+
if not self._verify_token(token_header):
|
|
45
|
+
return {"status": "forbidden", "reason": "invalid token"}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
payload: Dict[str, Any] = json.loads(body)
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return {"status": "error", "reason": "invalid JSON"}
|
|
51
|
+
|
|
52
|
+
if event_type != "Merge Request Hook":
|
|
53
|
+
logger.debug("Ignoring non-MR event: %s", event_type)
|
|
54
|
+
return {"status": "ignored"}
|
|
55
|
+
|
|
56
|
+
attrs = payload.get("object_attributes", {})
|
|
57
|
+
action = attrs.get("action", "")
|
|
58
|
+
if action not in ("open", "update", "reopen"):
|
|
59
|
+
logger.debug("Ignoring MR action: %s", action)
|
|
60
|
+
return {"status": "ignored"}
|
|
61
|
+
|
|
62
|
+
description = attrs.get("description") or ""
|
|
63
|
+
metadata = self._parse_description(description)
|
|
64
|
+
|
|
65
|
+
# Merge gate: block if critical concepts are unresolved
|
|
66
|
+
gate_result = self._evaluate_merge_gate(metadata)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"status": "ok",
|
|
70
|
+
"metadata": {
|
|
71
|
+
"branch": metadata.branch,
|
|
72
|
+
"commit_ids": metadata.commit_ids,
|
|
73
|
+
"sensitivity_refs": metadata.sensitivity_refs,
|
|
74
|
+
"concept_refs": metadata.concept_refs,
|
|
75
|
+
"mcs_score": metadata.mcs_score,
|
|
76
|
+
"has_devtorch_section": metadata.has_devtorch_section,
|
|
77
|
+
},
|
|
78
|
+
"merge_gate": gate_result,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def _verify_token(self, token_header: str) -> bool:
|
|
82
|
+
"""GitLab sends a plain secret token; compare in constant time."""
|
|
83
|
+
if not token_header:
|
|
84
|
+
return False
|
|
85
|
+
return hmac.compare_digest(
|
|
86
|
+
self._webhook_secret.decode("utf-8"),
|
|
87
|
+
token_header,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _parse_description(self, description: str) -> MRMetadata:
|
|
91
|
+
"""Parse a DevTorch reasoning section from an MR description."""
|
|
92
|
+
branch = ""
|
|
93
|
+
commit_ids: List[str] = []
|
|
94
|
+
sensitivity_refs: List[str] = []
|
|
95
|
+
concept_refs: List[str] = []
|
|
96
|
+
mcs_score: Optional[float] = None
|
|
97
|
+
has_devtorch_section = False
|
|
98
|
+
|
|
99
|
+
in_section = False
|
|
100
|
+
for line in description.splitlines():
|
|
101
|
+
stripped = line.strip()
|
|
102
|
+
if stripped == "<!-- DevTorch Reasoning Delta -->":
|
|
103
|
+
in_section = True
|
|
104
|
+
has_devtorch_section = True
|
|
105
|
+
continue
|
|
106
|
+
if stripped == "<!-- End DevTorch Reasoning Delta -->":
|
|
107
|
+
in_section = False
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if not in_section:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if stripped.startswith("- Branch:"):
|
|
114
|
+
branch = stripped.split(":", 1)[1].strip()
|
|
115
|
+
elif stripped.startswith("- Commits:"):
|
|
116
|
+
commits_str = stripped.split(":", 1)[1].strip()
|
|
117
|
+
commit_ids = [c.strip() for c in commits_str.split(",") if c.strip()]
|
|
118
|
+
elif stripped.startswith("- Sensitivities:"):
|
|
119
|
+
refs_str = stripped.split(":", 1)[1].strip()
|
|
120
|
+
sensitivity_refs = [r.strip() for r in refs_str.split(",") if r.strip()]
|
|
121
|
+
elif stripped.startswith("- Concepts:"):
|
|
122
|
+
concepts_str = stripped.split(":", 1)[1].strip()
|
|
123
|
+
concept_refs = [c.strip() for c in concepts_str.split(",") if c.strip()]
|
|
124
|
+
elif stripped.startswith("- MCS:"):
|
|
125
|
+
mcs_str = stripped.split(":", 1)[1].strip()
|
|
126
|
+
try:
|
|
127
|
+
mcs_score = float(mcs_str)
|
|
128
|
+
except ValueError:
|
|
129
|
+
mcs_score = None
|
|
130
|
+
|
|
131
|
+
return MRMetadata(
|
|
132
|
+
branch=branch,
|
|
133
|
+
commit_ids=commit_ids,
|
|
134
|
+
sensitivity_refs=sensitivity_refs,
|
|
135
|
+
concept_refs=concept_refs,
|
|
136
|
+
mcs_score=mcs_score,
|
|
137
|
+
has_devtorch_section=has_devtorch_section,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _evaluate_merge_gate(self, metadata: MRMetadata) -> Dict[str, Any]:
|
|
141
|
+
"""Evaluate merge gate: block if I3 critical concepts unresolved."""
|
|
142
|
+
# For now, a simple policy: block if no DevTorch section and there are
|
|
143
|
+
# critical concepts referenced but no sensitivity entries.
|
|
144
|
+
blocked = False
|
|
145
|
+
reasons: List[str] = []
|
|
146
|
+
|
|
147
|
+
if not metadata.has_devtorch_section:
|
|
148
|
+
blocked = True
|
|
149
|
+
reasons.append("Missing DevTorch Reasoning Delta")
|
|
150
|
+
|
|
151
|
+
if metadata.mcs_score is not None and metadata.mcs_score < 0.5:
|
|
152
|
+
blocked = True
|
|
153
|
+
reasons.append(f"MCS score {metadata.mcs_score:.2f} below threshold 0.5")
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"blocked": blocked,
|
|
157
|
+
"reasons": reasons,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def build_mr_comment(self, metadata: MRMetadata) -> str:
|
|
161
|
+
"""Build a Markdown comment to post on the MR."""
|
|
162
|
+
gate = self._evaluate_merge_gate(metadata)
|
|
163
|
+
status = "🟢 Merge gate passed" if not gate["blocked"] else "🔴 Merge gate blocked"
|
|
164
|
+
lines = [
|
|
165
|
+
"## DevTorch Reasoning Delta",
|
|
166
|
+
"",
|
|
167
|
+
f"**Branch:** `{metadata.branch}`" if metadata.branch else "**Branch:** (not specified)",
|
|
168
|
+
f"**MCS:** {metadata.mcs_score:.2f}" if metadata.mcs_score is not None else "**MCS:** (not provided)",
|
|
169
|
+
"",
|
|
170
|
+
f"**{status}**",
|
|
171
|
+
]
|
|
172
|
+
if gate["reasons"]:
|
|
173
|
+
lines.append("")
|
|
174
|
+
lines.append("Reasons:")
|
|
175
|
+
for reason in gate["reasons"]:
|
|
176
|
+
lines.append(f"- {reason}")
|
|
177
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from collections import deque
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Deque, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass
|
|
13
|
+
class HITLRequest:
|
|
14
|
+
"""
|
|
15
|
+
A request for human (or arbiter agent) input on a divergence.
|
|
16
|
+
|
|
17
|
+
options provides suggested resolution choices; the human may pick one
|
|
18
|
+
or write free-form text in their response.
|
|
19
|
+
"""
|
|
20
|
+
request_id: str
|
|
21
|
+
concept: str
|
|
22
|
+
consolidation_record_id: str
|
|
23
|
+
signals: List[dict]
|
|
24
|
+
prompt: str
|
|
25
|
+
options: List[str]
|
|
26
|
+
state: str # "pending" | "responded" | "timeout"
|
|
27
|
+
created_at: str
|
|
28
|
+
response: Optional[str] = None
|
|
29
|
+
responded_at: Optional[str] = None
|
|
30
|
+
responder: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
return dataclasses.asdict(self)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HITLChannel:
|
|
37
|
+
"""Base class for HITL notification + response channels."""
|
|
38
|
+
channel_name: str = "base"
|
|
39
|
+
|
|
40
|
+
def send(self, request: HITLRequest) -> None:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def poll(self, request_id: str) -> Optional[str]:
|
|
44
|
+
"""Return response text if available, else None."""
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CLIHITLChannel(HITLChannel):
|
|
49
|
+
"""
|
|
50
|
+
CLI-based HITL channel for development and testing.
|
|
51
|
+
|
|
52
|
+
In tests, pass a `response_queue` list — responses are dequeued in order.
|
|
53
|
+
In interactive use (response_queue=None), reads from stdin.
|
|
54
|
+
"""
|
|
55
|
+
channel_name = "cli"
|
|
56
|
+
|
|
57
|
+
def __init__(self, response_queue: Optional[List[str]] = None) -> None:
|
|
58
|
+
self._queue: Deque[str] = deque(response_queue or [])
|
|
59
|
+
self._responses: dict[str, str] = {}
|
|
60
|
+
|
|
61
|
+
def send(self, request: HITLRequest) -> None:
|
|
62
|
+
if self._queue:
|
|
63
|
+
response = self._queue.popleft()
|
|
64
|
+
self._responses[request.request_id] = response
|
|
65
|
+
else:
|
|
66
|
+
print(f"\n[DevTorch HITL] Concept: {request.concept}")
|
|
67
|
+
print(f" {request.prompt}")
|
|
68
|
+
for i, opt in enumerate(request.options, 1):
|
|
69
|
+
print(f" {i}. {opt}")
|
|
70
|
+
answer = input(" Your response: ").strip()
|
|
71
|
+
self._responses[request.request_id] = answer
|
|
72
|
+
|
|
73
|
+
def poll(self, request_id: str) -> Optional[str]:
|
|
74
|
+
return self._responses.get(request_id)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SlackHITLChannel(HITLChannel):
|
|
78
|
+
"""
|
|
79
|
+
Slack-based HITL channel.
|
|
80
|
+
|
|
81
|
+
Sends a Block Kit message via webhook. Responses are written to
|
|
82
|
+
.GCC/hitl/<request_id>.response.json by an external Slack action handler;
|
|
83
|
+
poll() reads from that file.
|
|
84
|
+
"""
|
|
85
|
+
channel_name = "slack"
|
|
86
|
+
|
|
87
|
+
def __init__(self, webhook_url: str, gcc_dir: Path) -> None:
|
|
88
|
+
self._webhook_url = webhook_url
|
|
89
|
+
self._gcc_dir = gcc_dir
|
|
90
|
+
|
|
91
|
+
def send(self, request: HITLRequest) -> None:
|
|
92
|
+
import urllib.request
|
|
93
|
+
options_text = "\n".join(f" {i+1}. {opt}" for i, opt in enumerate(request.options))
|
|
94
|
+
payload = {
|
|
95
|
+
"blocks": [
|
|
96
|
+
{
|
|
97
|
+
"type": "header",
|
|
98
|
+
"text": {"type": "plain_text", "text": f"[DevTorch HITL] Concept: {request.concept}"},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"type": "section",
|
|
102
|
+
"text": {"type": "mrkdwn", "text": f"*{request.prompt}*\n{options_text}"},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"type": "context",
|
|
106
|
+
"elements": [{"type": "mrkdwn",
|
|
107
|
+
"text": f"Record ID: `{request.consolidation_record_id}` | Request: `{request.request_id}`"}],
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
data = json.dumps(payload).encode("utf-8")
|
|
112
|
+
req = urllib.request.Request(
|
|
113
|
+
self._webhook_url, data=data,
|
|
114
|
+
headers={"Content-Type": "application/json"},
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
urllib.request.urlopen(req, timeout=5)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass # Non-blocking; Slack delivery failure does not block the workflow
|
|
120
|
+
|
|
121
|
+
def poll(self, request_id: str) -> Optional[str]:
|
|
122
|
+
response_file = self._gcc_dir / "hitl" / f"{request_id}.response.json"
|
|
123
|
+
if not response_file.exists():
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
d = json.loads(response_file.read_text(encoding="utf-8"))
|
|
127
|
+
return d.get("response")
|
|
128
|
+
except (json.JSONDecodeError, OSError):
|
|
129
|
+
return None
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as _dt
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from devtorch_core.divergence import DivergenceDetector
|
|
10
|
+
from devtorch_core.consolidation import ConsolidationWorkflow, ConsolidationRecord
|
|
11
|
+
from .channels import HITLChannel, HITLRequest
|
|
12
|
+
|
|
13
|
+
_HITL_DIR = "hitl"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HITLOrchestrator:
|
|
17
|
+
"""
|
|
18
|
+
Detects divergence, starts a ConsolidationWorkflow, and triggers HITL
|
|
19
|
+
when the severity requires human input.
|
|
20
|
+
|
|
21
|
+
Flow:
|
|
22
|
+
1. Detect divergence for concept.
|
|
23
|
+
2. If no divergence → return None.
|
|
24
|
+
3. Start ConsolidationWorkflow → get ConsolidationRecord.
|
|
25
|
+
4. If state is pending_human/blocked → build HITLRequest, send to channels.
|
|
26
|
+
5. Poll channels for response; if found → add_human_comment to record.
|
|
27
|
+
6. Return ConsolidationRecord.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, repo, channels: List[HITLChannel], poll_attempts: int = 1) -> None:
|
|
31
|
+
self._repo = repo
|
|
32
|
+
self._channels = channels
|
|
33
|
+
self._poll_attempts = poll_attempts
|
|
34
|
+
self._hitl_dir = repo.gcc_dir / _HITL_DIR
|
|
35
|
+
self._hitl_dir.mkdir(exist_ok=True)
|
|
36
|
+
|
|
37
|
+
def run(self, concept: str) -> Optional[ConsolidationRecord]:
|
|
38
|
+
signals = DivergenceDetector(self._repo).detect(concept=concept)
|
|
39
|
+
if not signals:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
workflow = ConsolidationWorkflow(self._repo)
|
|
43
|
+
record = workflow.start(concept=concept, signals=signals)
|
|
44
|
+
|
|
45
|
+
if record.state in ("pending_human", "blocked"):
|
|
46
|
+
request = self._build_request(record)
|
|
47
|
+
self._persist_request(request)
|
|
48
|
+
for channel in self._channels:
|
|
49
|
+
channel.send(request)
|
|
50
|
+
|
|
51
|
+
response = self._collect_response(request)
|
|
52
|
+
if response:
|
|
53
|
+
workflow.add_human_comment(
|
|
54
|
+
record_id=record.record_id,
|
|
55
|
+
author=response.get("responder", "human"),
|
|
56
|
+
comment=response.get("text", ""),
|
|
57
|
+
decision=response.get("text"),
|
|
58
|
+
)
|
|
59
|
+
record = workflow.get(record.record_id)
|
|
60
|
+
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
def _build_request(self, record: ConsolidationRecord) -> HITLRequest:
|
|
64
|
+
signal_descs = [s.get("description", "")[:100] for s in record.signals[:3]]
|
|
65
|
+
options = [
|
|
66
|
+
f"Accept highest-disclosure level (most restrictive)",
|
|
67
|
+
f"Accept lowest-disclosure level (most permissive)",
|
|
68
|
+
f"Defer — mark concept '{record.concept}' as requiring manual review",
|
|
69
|
+
]
|
|
70
|
+
return HITLRequest(
|
|
71
|
+
request_id=str(uuid.uuid4()),
|
|
72
|
+
concept=record.concept,
|
|
73
|
+
consolidation_record_id=record.record_id,
|
|
74
|
+
signals=record.signals,
|
|
75
|
+
prompt=(
|
|
76
|
+
f"Divergence detected on '{record.concept}': "
|
|
77
|
+
+ "; ".join(signal_descs)
|
|
78
|
+
+ "\nPlease provide a resolution or select an option."
|
|
79
|
+
),
|
|
80
|
+
options=options,
|
|
81
|
+
state="pending",
|
|
82
|
+
created_at=_dt.datetime.now(tz=_dt.timezone.utc).isoformat(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _persist_request(self, request: HITLRequest) -> None:
|
|
86
|
+
path = self._hitl_dir / f"{request.request_id}.json"
|
|
87
|
+
path.write_text(json.dumps(request.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
def _collect_response(self, request: HITLRequest) -> Optional[dict]:
|
|
90
|
+
for _ in range(self._poll_attempts):
|
|
91
|
+
for channel in self._channels:
|
|
92
|
+
text = channel.poll(request.request_id)
|
|
93
|
+
if text:
|
|
94
|
+
return {"text": text, "responder": channel.channel_name}
|
|
95
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DevTorch hooks package.
|
|
3
|
+
|
|
4
|
+
Provides Claude Code hook handlers and git commit-msg hook integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .claude_code import handle_pre_tool_use, handle_post_tool_use, handle_session_end
|
|
8
|
+
from .git_commit import handle_commit_msg, install_hook, extract_sensitivity_from_commit
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"handle_pre_tool_use",
|
|
12
|
+
"handle_post_tool_use",
|
|
13
|
+
"handle_session_end",
|
|
14
|
+
"handle_commit_msg",
|
|
15
|
+
"install_hook",
|
|
16
|
+
"extract_sensitivity_from_commit",
|
|
17
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
S7 Claude Code hooks — intercept Claude Code tool-use events.
|
|
3
|
+
|
|
4
|
+
Claude Code hooks are shell commands that receive JSON on stdin and write
|
|
5
|
+
JSON to stdout. This module implements three hook handlers:
|
|
6
|
+
|
|
7
|
+
handle_pre_tool_use() — PreToolUse event; records session start; always approves
|
|
8
|
+
handle_post_tool_use() — PostToolUse event; auto-commits for write tools
|
|
9
|
+
handle_session_end() — Stop event; finalises session metrics
|
|
10
|
+
|
|
11
|
+
Dispatch is done by main() based on argv[1]:
|
|
12
|
+
pre-tool-use | post-tool-use | session-end
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
# Tools whose use we want to capture as GCC commits.
|
|
24
|
+
_WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "Bash"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Helpers
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_event() -> dict:
|
|
33
|
+
"""Read the JSON event from stdin."""
|
|
34
|
+
return json.loads(sys.stdin.read())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_response(payload: dict) -> None:
|
|
38
|
+
"""Write JSON response to stdout."""
|
|
39
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
40
|
+
sys.stdout.flush()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_gcc_repo() -> Optional[Any]:
|
|
44
|
+
"""Walk upward from cwd to find an initialised .GCC/ repository."""
|
|
45
|
+
from devtorch_core import GCCRepository
|
|
46
|
+
|
|
47
|
+
for parent in [Path.cwd()] + list(Path.cwd().parents):
|
|
48
|
+
r = GCCRepository.at(parent)
|
|
49
|
+
if r.is_initialized():
|
|
50
|
+
return r
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _gcc_root() -> Optional[Path]:
|
|
55
|
+
"""Return the root path containing .GCC/ or None."""
|
|
56
|
+
from devtorch_core.gcc import GCC_DIR_NAME
|
|
57
|
+
|
|
58
|
+
for parent in [Path.cwd()] + list(Path.cwd().parents):
|
|
59
|
+
if (parent / GCC_DIR_NAME).is_dir():
|
|
60
|
+
return parent
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _metrics_path(gcc_root: Path, session_id: str) -> Path:
|
|
65
|
+
metrics_dir = gcc_root / ".GCC" / "metrics" / "session"
|
|
66
|
+
metrics_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return metrics_dir / f"{session_id}.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_session_metrics(path: Path) -> dict:
|
|
71
|
+
if path.exists():
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(path.read_text())
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _save_session_metrics(path: Path, data: dict) -> None:
|
|
80
|
+
path.write_text(json.dumps(data, indent=2))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _tool_summary(tool_name: str, tool_input: dict) -> str:
|
|
84
|
+
"""Extract a one-line summary from tool_input for commit messages."""
|
|
85
|
+
if tool_name in ("Write", "Edit", "MultiEdit"):
|
|
86
|
+
return tool_input.get("file_path", tool_input.get("path", "unknown"))
|
|
87
|
+
elif tool_name == "Bash":
|
|
88
|
+
cmd = tool_input.get("command", "")
|
|
89
|
+
# Truncate long commands
|
|
90
|
+
return cmd[:80] if len(cmd) > 80 else cmd
|
|
91
|
+
return str(tool_input)[:80]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Hook handlers
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def handle_pre_tool_use() -> None:
|
|
100
|
+
"""
|
|
101
|
+
PreToolUse hook handler.
|
|
102
|
+
|
|
103
|
+
- Records session start timestamp to .GCC/metrics/session/<session_id>.json
|
|
104
|
+
if a .GCC/ repo is found.
|
|
105
|
+
- Always approves.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
event = _read_event()
|
|
109
|
+
session_id = event.get("session_id", "unknown")
|
|
110
|
+
|
|
111
|
+
gcc_root = _gcc_root()
|
|
112
|
+
if gcc_root:
|
|
113
|
+
path = _metrics_path(gcc_root, session_id)
|
|
114
|
+
metrics = _load_session_metrics(path)
|
|
115
|
+
if "start_ts" not in metrics:
|
|
116
|
+
# First tool call this session — record start
|
|
117
|
+
metrics = {
|
|
118
|
+
"session_id": session_id,
|
|
119
|
+
"start_ts": time.time(),
|
|
120
|
+
"tool_calls": 0,
|
|
121
|
+
"commits": 0,
|
|
122
|
+
}
|
|
123
|
+
metrics["tool_calls"] = metrics.get("tool_calls", 0) + 1
|
|
124
|
+
try:
|
|
125
|
+
_save_session_metrics(path, metrics)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass # never fail
|
|
128
|
+
except Exception:
|
|
129
|
+
pass # never fail the hook
|
|
130
|
+
|
|
131
|
+
_write_response({"decision": "approve"})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def handle_post_tool_use() -> None:
|
|
135
|
+
"""
|
|
136
|
+
PostToolUse hook handler.
|
|
137
|
+
|
|
138
|
+
Captures write tools (Write, Edit, MultiEdit, Bash) and auto-commits
|
|
139
|
+
them to .GCC/. Read-only tools (Read, Glob, Grep, etc.) are ignored.
|
|
140
|
+
Always approves.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
event = _read_event()
|
|
144
|
+
tool_name = event.get("tool_name", "")
|
|
145
|
+
tool_input = event.get("tool_input") or {}
|
|
146
|
+
session_id = event.get("session_id", "unknown")
|
|
147
|
+
|
|
148
|
+
if tool_name in _WRITE_TOOLS:
|
|
149
|
+
repo = _find_gcc_repo()
|
|
150
|
+
if repo is not None:
|
|
151
|
+
summary = _tool_summary(tool_name, tool_input)
|
|
152
|
+
commit_msg = f"auto: {tool_name} {summary}"
|
|
153
|
+
try:
|
|
154
|
+
repo.commit(message=commit_msg)
|
|
155
|
+
|
|
156
|
+
# Update session metrics
|
|
157
|
+
gcc_root = _gcc_root()
|
|
158
|
+
if gcc_root:
|
|
159
|
+
path = _metrics_path(gcc_root, session_id)
|
|
160
|
+
metrics = _load_session_metrics(path)
|
|
161
|
+
metrics["commits"] = metrics.get("commits", 0) + 1
|
|
162
|
+
_save_session_metrics(path, metrics)
|
|
163
|
+
except Exception:
|
|
164
|
+
pass # never fail the hook
|
|
165
|
+
except Exception:
|
|
166
|
+
pass # never fail the hook
|
|
167
|
+
|
|
168
|
+
_write_response({"decision": "approve"})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def handle_session_end() -> None:
|
|
172
|
+
"""
|
|
173
|
+
Stop hook handler.
|
|
174
|
+
|
|
175
|
+
Reads session start timestamp from .GCC/metrics/session/<session_id>.json,
|
|
176
|
+
computes session duration, and writes final session metrics.
|
|
177
|
+
Always approves.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
event = _read_event()
|
|
181
|
+
session_id = event.get("session_id", "unknown")
|
|
182
|
+
|
|
183
|
+
gcc_root = _gcc_root()
|
|
184
|
+
if gcc_root:
|
|
185
|
+
path = _metrics_path(gcc_root, session_id)
|
|
186
|
+
metrics = _load_session_metrics(path)
|
|
187
|
+
if metrics:
|
|
188
|
+
metrics["end_ts"] = time.time()
|
|
189
|
+
try:
|
|
190
|
+
_save_session_metrics(path, metrics)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
except Exception:
|
|
194
|
+
pass # never fail the hook
|
|
195
|
+
|
|
196
|
+
_write_response({"decision": "approve"})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Entry point
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main() -> None:
|
|
205
|
+
"""
|
|
206
|
+
Dispatcher for Claude Code hooks.
|
|
207
|
+
|
|
208
|
+
Usage:
|
|
209
|
+
devtorch hooks pre-tool-use
|
|
210
|
+
devtorch hooks post-tool-use
|
|
211
|
+
devtorch hooks session-end
|
|
212
|
+
"""
|
|
213
|
+
if len(sys.argv) < 2:
|
|
214
|
+
# Fallback: attempt to read event and determine hook type
|
|
215
|
+
_write_response({"decision": "approve"})
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
hook_type = sys.argv[1]
|
|
219
|
+
|
|
220
|
+
if hook_type == "pre-tool-use":
|
|
221
|
+
handle_pre_tool_use()
|
|
222
|
+
elif hook_type == "post-tool-use":
|
|
223
|
+
handle_post_tool_use()
|
|
224
|
+
elif hook_type == "session-end":
|
|
225
|
+
handle_session_end()
|
|
226
|
+
else:
|
|
227
|
+
# Unknown hook — always approve
|
|
228
|
+
_write_response({"decision": "approve"})
|