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.
Files changed (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. 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,4 @@
1
+ from .channels import HITLRequest, HITLChannel, CLIHITLChannel, SlackHITLChannel
2
+ from .orchestrator import HITLOrchestrator
3
+
4
+ __all__ = ["HITLRequest", "HITLChannel", "CLIHITLChannel", "SlackHITLChannel", "HITLOrchestrator"]
@@ -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"})