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,733 @@
1
+ """
2
+ Installs DevTorch hooks for Claude Code, git, and Google Antigravity (AGENTS.md).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Canonical hooks.json configuration
16
+ # ---------------------------------------------------------------------------
17
+
18
+ # Hooks fire a process per event; scope them to mutating tools so read-heavy
19
+ # agent sessions don't pay ~1 spawn per file read.
20
+ MUTATING_TOOLS_MATCHER = "Write|Edit|MultiEdit|NotebookEdit|Bash"
21
+
22
+ # Commands DevTorch has shipped historically — replaced on re-install so
23
+ # users are upgraded to the current fast/detached commands in place.
24
+ _LEGACY_HOOK_COMMAND_PREFIXES = ("devtorch hooks run", "devtorch-hook", "sh -c 'devtorch-hook")
25
+
26
+ CLAUDE_HOOKS_CONFIG: dict = {
27
+ "hooks": {
28
+ "PreToolUse": [
29
+ {
30
+ "matcher": MUTATING_TOOLS_MATCHER,
31
+ "hooks": [
32
+ {"type": "command", "command": "devtorch-hook pre-tool-use"}
33
+ ],
34
+ }
35
+ ],
36
+ "UserPromptSubmit": [
37
+ {
38
+ "hooks": [
39
+ {"type": "command", "command": "devtorch-hook user-prompt-submit"}
40
+ ]
41
+ }
42
+ ],
43
+ "PostToolUse": [
44
+ {
45
+ "matcher": MUTATING_TOOLS_MATCHER,
46
+ "hooks": [
47
+ # Detached: capture is write-only telemetry; the IDE never
48
+ # consumes its output, so don't make the user wait on it.
49
+ {"type": "command", "command": "sh -c 'devtorch-hook post-tool-use >/dev/null 2>&1 &'"}
50
+ ],
51
+ }
52
+ ],
53
+ "Stop": [
54
+ {
55
+ "hooks": [
56
+ {"type": "command", "command": "devtorch-hook session-end"}
57
+ ]
58
+ }
59
+ ],
60
+ }
61
+ }
62
+
63
+ # OpenCode hook events use a flat per-entry { command, matcher? } shape
64
+ # (unlike Claude Code's nested { hooks: [{ type: "command", command: ... }] }).
65
+ OPENCODE_HOOKS_CONFIG: dict = {
66
+ "hooks": {
67
+ "PreUserPrompt": [
68
+ {
69
+ "command": "devtorch-hook open-code-pre-prompt",
70
+ }
71
+ ],
72
+ "PreToolUse": [
73
+ {
74
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
75
+ "command": "devtorch-hook pre-tool-use",
76
+ }
77
+ ],
78
+ "PostToolUse": [
79
+ {
80
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
81
+ "command": "sh -c 'devtorch-hook post-tool-use &'",
82
+ }
83
+ ],
84
+ "Stop": [
85
+ {
86
+ "command": "devtorch-hook session-end",
87
+ }
88
+ ],
89
+ }
90
+ }
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Result dataclass
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ @dataclass
99
+ class HookInstallResult:
100
+ claude_hooks_written: bool = False
101
+ claude_hooks_path: str = ""
102
+ git_hook_written: bool = False
103
+ git_hook_path: str = ""
104
+ # S26 git-capture hooks (post-commit, post-merge, post-checkout, post-rewrite)
105
+ git_capture_hooks_written: bool = False
106
+ git_capture_hooks: dict[str, str] = field(default_factory=dict)
107
+ errors: list[str] = field(default_factory=list)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Deep-merge helpers
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _deep_merge(base: dict, overlay: dict) -> dict:
116
+ """
117
+ Recursively merge *overlay* into *base*.
118
+
119
+ - For dicts: recurse.
120
+ - For lists keyed under "hooks" event names: append overlay entries
121
+ whose commands are not already present.
122
+ - Scalar values: overlay wins.
123
+ """
124
+ result = copy.deepcopy(base)
125
+ for key, val in overlay.items():
126
+ if key in result and isinstance(result[key], dict) and isinstance(val, dict):
127
+ result[key] = _deep_merge(result[key], val)
128
+ elif key in result and isinstance(result[key], list) and isinstance(val, list):
129
+ # Merge lists without duplicating identical entries
130
+ existing_cmds = _extract_commands(result[key])
131
+ for item in val:
132
+ item_cmds = _extract_commands([item])
133
+ if not item_cmds or not item_cmds.issubset(existing_cmds):
134
+ result[key].append(copy.deepcopy(item))
135
+ else:
136
+ result[key] = copy.deepcopy(val)
137
+ return result
138
+
139
+
140
+ def _extract_commands(items: list) -> set:
141
+ """Extract all command strings from a list of hook entries."""
142
+ cmds: set = set()
143
+ for item in items:
144
+ if isinstance(item, dict):
145
+ for hook in item.get("hooks", []):
146
+ if isinstance(hook, dict) and "command" in hook:
147
+ cmds.add(hook["command"])
148
+ return cmds
149
+
150
+
151
+ def _strip_devtorch_hook_entries(settings: dict) -> dict:
152
+ """Remove hook entries whose every command is a DevTorch-shipped command,
153
+ so re-installation upgrades them instead of accumulating duplicates.
154
+ User-authored entries (any non-DevTorch command) are kept intact."""
155
+ result = copy.deepcopy(settings)
156
+ hooks = result.get("hooks")
157
+ if not isinstance(hooks, dict):
158
+ return result
159
+ for event, entries in hooks.items():
160
+ if not isinstance(entries, list):
161
+ continue
162
+ kept = []
163
+ for entry in entries:
164
+ cmds = _extract_commands([entry])
165
+ if cmds and all(
166
+ c.startswith(_LEGACY_HOOK_COMMAND_PREFIXES) for c in cmds
167
+ ):
168
+ continue
169
+ kept.append(entry)
170
+ hooks[event] = kept
171
+ return result
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # install_claude_hooks
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def install_claude_hooks(project_root: str) -> tuple[bool, str]:
180
+ """
181
+ Merge DevTorch hooks into .claude/settings.json under *project_root*.
182
+
183
+ Claude Code only reads hooks from its settings files (settings.json /
184
+ settings.local.json) — a standalone hooks.json is never loaded.
185
+
186
+ - Creates .claude/ directory if it does not exist.
187
+ - Deep-merges DevTorch hooks into the existing settings.json without
188
+ removing existing keys (permissions, env, other hooks, ...).
189
+ - Migrates a legacy .claude/hooks.json written by older DevTorch versions
190
+ into settings.json and removes it.
191
+ - Returns (True, path) on success, (False, error_message) on failure.
192
+ """
193
+ try:
194
+ claude_dir = Path(project_root) / ".claude"
195
+ claude_dir.mkdir(parents=True, exist_ok=True)
196
+
197
+ settings_path = claude_dir / "settings.json"
198
+
199
+ existing: dict = {}
200
+ if settings_path.exists():
201
+ try:
202
+ existing = json.loads(settings_path.read_text(encoding="utf-8"))
203
+ except Exception:
204
+ existing = {}
205
+
206
+ legacy_path = claude_dir / "hooks.json"
207
+ legacy_migrated = False
208
+ if legacy_path.exists():
209
+ try:
210
+ legacy = json.loads(legacy_path.read_text(encoding="utf-8"))
211
+ except Exception:
212
+ legacy = None
213
+ if isinstance(legacy, dict) and isinstance(legacy.get("hooks"), dict):
214
+ existing = _deep_merge(existing, {"hooks": legacy["hooks"]})
215
+ legacy_migrated = True
216
+ # Unreadable or unexpected shape: leave the file in place — never
217
+ # delete config we could not migrate.
218
+
219
+ existing = _strip_devtorch_hook_entries(existing)
220
+ merged = _deep_merge(existing, CLAUDE_HOOKS_CONFIG)
221
+ settings_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
222
+ # Remove the legacy file only after its content is safely in settings.json.
223
+ if legacy_migrated:
224
+ legacy_path.unlink()
225
+ return True, str(settings_path)
226
+
227
+ except Exception as exc:
228
+ return False, str(exc)
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # install_git_commit_hook
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ def install_git_commit_hook(repo_root: str) -> tuple[bool, str]:
237
+ """
238
+ Install the git commit-msg hook in .git/hooks/commit-msg.
239
+
240
+ Calls devtorch_core.hooks.git_commit.install_hook(repo_root).
241
+ Returns (True, path) on success, (False, reason) when no .git found or
242
+ on any other error.
243
+ """
244
+ git_dir = Path(repo_root) / ".git"
245
+ if not git_dir.is_dir():
246
+ return False, "no .git found"
247
+
248
+ try:
249
+ from devtorch_core.hooks.git_commit import install_hook
250
+
251
+ install_hook(Path(repo_root))
252
+ hook_path = str(git_dir / "hooks" / "commit-msg")
253
+ return True, hook_path
254
+
255
+ except Exception as exc:
256
+ return False, str(exc)
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # install_git_capture_hooks
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def install_git_capture_hooks(repo_root: str) -> tuple[bool, dict[str, str]]:
265
+ """
266
+ Install the S26 git capture hooks in .git/hooks/.
267
+
268
+ Returns (True, {hook_name: path}) on success, (False, {hook_name: error})
269
+ when no .git/hooks directory is found or on any other error.
270
+ """
271
+ try:
272
+ from devtorch_core.hooks.git_capture import (
273
+ install_git_capture_hooks as _install_git_capture_hooks,
274
+ )
275
+
276
+ ok, paths = _install_git_capture_hooks(Path(repo_root))
277
+ return ok, paths
278
+ except Exception as exc:
279
+ return False, {"error": str(exc)}
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # install_all_hooks
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def install_all_hooks(project_root: str) -> HookInstallResult:
288
+ """
289
+ Install all DevTorch hooks (Claude Code + git).
290
+
291
+ Never raises — all exceptions are captured into result.errors.
292
+ """
293
+ result = HookInstallResult()
294
+
295
+ # Claude Code hooks
296
+ try:
297
+ ok, path = install_claude_hooks(project_root)
298
+ result.claude_hooks_written = ok
299
+ result.claude_hooks_path = path
300
+ if not ok:
301
+ result.errors.append(f"claude hooks: {path}")
302
+ except Exception as exc:
303
+ result.errors.append(f"claude hooks exception: {exc}")
304
+
305
+ # Git commit-msg hook
306
+ try:
307
+ ok, path = install_git_commit_hook(project_root)
308
+ result.git_hook_written = ok
309
+ result.git_hook_path = path
310
+ if not ok:
311
+ result.errors.append(f"git hook: {path}")
312
+ except Exception as exc:
313
+ result.errors.append(f"git hook exception: {exc}")
314
+
315
+ # S26 git capture hooks
316
+ try:
317
+ ok, paths = install_git_capture_hooks(project_root)
318
+ result.git_capture_hooks_written = ok
319
+ result.git_capture_hooks = paths
320
+ if not ok:
321
+ result.errors.append(f"git capture hooks: {paths}")
322
+ except Exception as exc:
323
+ result.errors.append(f"git capture hooks exception: {exc}")
324
+
325
+ return result
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # AGENTS.md block for Google Antigravity
330
+ # ---------------------------------------------------------------------------
331
+
332
+ ANTIGRAVITY_AGENTS_MD_BLOCK = """
333
+ ## DevTorch — Reasoning Capture
334
+
335
+ This project uses DevTorch for AI governance. These steps are mandatory for all agent actions.
336
+
337
+ ### At the start of every task
338
+
339
+ Call `devtorch_context` (MCP tool) before reading files or writing code:
340
+ - Tool: `devtorch_context`
341
+ - Args: `{ "max_tokens": 2000 }`
342
+
343
+ Use the returned context to understand prior decisions before proceeding.
344
+
345
+ ### Before writing or editing any code
346
+
347
+ Call `devtorch_commit` before the first file write:
348
+ - Tool: `devtorch_commit`
349
+ - Args: `{ "message": "<what you decided and WHY — include alternatives considered>", "concepts": ["<concept>"], "confidence": 0.0–1.0 }`
350
+
351
+ The message must explain the *why*, not just the *what*.
352
+ "Added login endpoint" is not acceptable.
353
+ "Chose JWT over sessions because the service is stateless" is.
354
+
355
+ ### When touching sensitive areas
356
+
357
+ Call `devtorch_sensitivity_add` for auth, payments, database schema, secrets, PII, external APIs:
358
+ - Tool: `devtorch_sensitivity_add`
359
+ - Args: `{ "concept": "<auth|schema|payments|secrets|pii|api|security|config>", "confidence": 0.0–1.0, "disclosure_level": "PUBLIC|PROTECTED|PRIVATE" }`
360
+
361
+ | Level | Use when |
362
+ |---|---|
363
+ | PUBLIC | General design decisions |
364
+ | PROTECTED | Internal implementation, business logic |
365
+ | PRIVATE | Credentials, PII, secrets |
366
+ """
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # install_agents_md
371
+ # ---------------------------------------------------------------------------
372
+
373
+
374
+ def install_agents_md(project_root: str) -> tuple[bool, str]:
375
+ """
376
+ Write (or append to) AGENTS.md under *project_root* with the DevTorch
377
+ governance block for Google Antigravity.
378
+
379
+ - Creates AGENTS.md if it does not exist.
380
+ - If AGENTS.md already contains the marker ``## DevTorch``, does nothing
381
+ (idempotent).
382
+ - Returns (True, path) on success, (False, error_message) on failure.
383
+ """
384
+ try:
385
+ import re
386
+
387
+ agents_md_path = Path(project_root) / "AGENTS.md"
388
+ # Any-level DevTorch heading counts as installed — projects may promote
389
+ # the block to an H1; appending our copy next to it would duplicate it.
390
+ marker_re = re.compile(r"^#{1,6}\s+DevTorch\b", re.MULTILINE)
391
+
392
+ if agents_md_path.exists():
393
+ existing = agents_md_path.read_text(encoding="utf-8")
394
+ if marker_re.search(existing):
395
+ return True, str(agents_md_path)
396
+
397
+ with agents_md_path.open("a", encoding="utf-8") as f:
398
+ f.write(ANTIGRAVITY_AGENTS_MD_BLOCK)
399
+
400
+ return True, str(agents_md_path)
401
+
402
+ except Exception as exc:
403
+ return False, str(exc)
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # get_hooks_status
408
+ # ---------------------------------------------------------------------------
409
+
410
+
411
+ def get_hooks_status(project_root: str) -> dict:
412
+ """
413
+ Return the install status of DevTorch hooks without writing anything.
414
+
415
+ Returns:
416
+ {
417
+ "claude_hooks": bool,
418
+ "claude_hooks_path": str,
419
+ "git_hook": bool,
420
+ "git_hook_path": str,
421
+ "git_capture_hooks": dict[str, bool],
422
+ "git_capture_hooks_path": dict[str, str],
423
+ }
424
+ """
425
+ settings_path = Path(project_root) / ".claude" / "settings.json"
426
+ claude_hooks_path = str(settings_path)
427
+ claude_hooks = False
428
+ if settings_path.exists():
429
+ try:
430
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
431
+ cmds: set = set()
432
+ for entries in (data.get("hooks") or {}).values():
433
+ if isinstance(entries, list):
434
+ cmds |= _extract_commands(entries)
435
+ claude_hooks = any(
436
+ c.startswith("devtorch-hook") or c.startswith("sh -c 'devtorch-hook")
437
+ for c in cmds
438
+ )
439
+ except Exception:
440
+ claude_hooks = False
441
+
442
+ git_hook_path = str(Path(project_root) / ".git" / "hooks" / "commit-msg")
443
+ git_hook = Path(git_hook_path).exists()
444
+
445
+ hooks_dir = Path(project_root) / ".git" / "hooks"
446
+ git_capture_hook_names = ["post-commit", "post-merge", "post-checkout", "post-rewrite"]
447
+ git_capture_hooks: dict[str, bool] = {}
448
+ git_capture_hooks_path: dict[str, str] = {}
449
+ for name in git_capture_hook_names:
450
+ hook_path = hooks_dir / name
451
+ exists = hook_path.exists()
452
+ git_capture_hooks[name] = exists
453
+ git_capture_hooks_path[name] = str(hook_path)
454
+
455
+ return {
456
+ "claude_hooks": claude_hooks,
457
+ "claude_hooks_path": claude_hooks_path,
458
+ "git_hook": git_hook,
459
+ "git_hook_path": git_hook_path,
460
+ "git_capture_hooks": git_capture_hooks,
461
+ "git_capture_hooks_path": git_capture_hooks_path,
462
+ }
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # IDE / project-type detection
467
+ # ---------------------------------------------------------------------------
468
+
469
+ _IDE_DETECTION_MARKERS: dict[str, str] = {
470
+ ".claude": "claude-code",
471
+ ".cursor": "cursor",
472
+ ".vscode": "vscode",
473
+ ".kiro": "kiro",
474
+ ".antigravity": "antigravity",
475
+ "mcp_config.json": "antigravity",
476
+ }
477
+
478
+
479
+ def _detect_ides(project_dir: str) -> list[str]:
480
+ """
481
+ Detect installed IDE directories/files in *project_dir*.
482
+
483
+ Scans for ``.claude/``, ``.cursor/``, ``.vscode/``, ``.kiro/``,
484
+ ``.antigravity/``, and ``mcp_config.json``. Returns a list of
485
+ IDE identifiers in the order they were found, with duplicates removed
486
+ (``mcp_config.json`` and ``.antigravity/`` both map to ``antigravity``).
487
+ """
488
+ root = Path(project_dir)
489
+ found: list[str] = []
490
+ seen: set[str] = set()
491
+ for marker, ide_name in _IDE_DETECTION_MARKERS.items():
492
+ if (root / marker).exists() and ide_name not in seen:
493
+ found.append(ide_name)
494
+ seen.add(ide_name)
495
+ return found
496
+
497
+
498
+ _PROJECT_TYPE_MARKERS: dict[str, str] = {
499
+ "pyproject.toml": "python",
500
+ "requirements.txt": "python",
501
+ "setup.py": "python",
502
+ "go.mod": "go",
503
+ "composer.json": "php",
504
+ "package.json": "typescript",
505
+ "tsconfig.json": "typescript",
506
+ }
507
+
508
+
509
+ def _detect_project_type(project_dir: str) -> str:
510
+ """
511
+ Detect the dominant project type in *project_dir*.
512
+
513
+ Scans for ``pyproject.toml``, ``package.json``, ``go.mod``,
514
+ ``composer.json``, and related markers. Returns one of:
515
+ ``python``, ``typescript``, ``react``, ``go``, ``php``, or ``generic``.
516
+ """
517
+ root = Path(project_dir)
518
+ for marker, project_type in _PROJECT_TYPE_MARKERS.items():
519
+ if (root / marker).exists():
520
+ if marker == "package.json":
521
+ try:
522
+ data = json.loads((root / marker).read_text(encoding="utf-8"))
523
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
524
+ if "react" in deps:
525
+ return "react"
526
+ except Exception:
527
+ pass
528
+ return project_type
529
+ return "generic"
530
+
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # Shared MCP server entry for Cursor / Antigravity
534
+ # ---------------------------------------------------------------------------
535
+
536
+ _MCP_SERVER_ENTRY = {
537
+ "devtorch": {
538
+ "command": "devtorch",
539
+ "args": ["mcp-server"],
540
+ }
541
+ }
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # install_claude_mcp
546
+ # ---------------------------------------------------------------------------
547
+
548
+ # Claude Code reads project-scope MCP servers only from <root>/.mcp.json;
549
+ # a mcpServers key inside .claude/settings.json is silently ignored.
550
+ CLAUDE_MCP_SERVER_ENTRY = {
551
+ "devtorch": {
552
+ "type": "stdio",
553
+ "command": "devtorch",
554
+ "args": ["mcp-server"],
555
+ }
556
+ }
557
+
558
+
559
+ def _read_json_dict_or_backup(path: Path) -> dict:
560
+ """
561
+ Parse *path* as a JSON object. If it is unparseable or not an object,
562
+ rename it to ``<name>.bak`` so hand-edited content is never silently
563
+ clobbered, and return an empty dict.
564
+ """
565
+ try:
566
+ data = json.loads(path.read_text(encoding="utf-8"))
567
+ except Exception:
568
+ data = None
569
+ if isinstance(data, dict):
570
+ return data
571
+ path.rename(path.with_name(path.name + ".bak"))
572
+ return {}
573
+
574
+
575
+ def _pop_legacy_settings_mcp_entry(root: Path) -> Optional[dict]:
576
+ """
577
+ Remove a devtorch entry stranded in .claude/settings.json mcpServers
578
+ (where Claude Code never reads it) and return it, or None.
579
+
580
+ Other mcpServers entries are left untouched; the mcpServers key itself is
581
+ dropped when the devtorch entry was the only one.
582
+ """
583
+ settings_path = root / ".claude" / "settings.json"
584
+ if not settings_path.exists():
585
+ return None
586
+ try:
587
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
588
+ except Exception:
589
+ return None
590
+ servers = settings.get("mcpServers")
591
+ if not isinstance(servers, dict) or "devtorch" not in servers:
592
+ return None
593
+ entry = servers.pop("devtorch")
594
+ if not servers:
595
+ del settings["mcpServers"]
596
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
597
+ return entry if isinstance(entry, dict) else None
598
+
599
+
600
+ def install_claude_mcp(project_root: str) -> tuple[bool, str]:
601
+ """
602
+ Write (or merge into) <project_root>/.mcp.json for Claude Code.
603
+
604
+ Adds the devtorch MCP server entry. Preserves any existing servers.
605
+ Idempotent — skips write if devtorch entry already present.
606
+ Migrates a devtorch entry written to .claude/settings.json by older
607
+ DevTorch versions, preserving its configured values.
608
+ Returns (True, path) on success, (False, error_message) on failure.
609
+ """
610
+ try:
611
+ root = Path(project_root)
612
+ mcp_path = root / ".mcp.json"
613
+
614
+ existing: dict = {}
615
+ if mcp_path.exists():
616
+ existing = _read_json_dict_or_backup(mcp_path)
617
+
618
+ legacy_entry = _pop_legacy_settings_mcp_entry(root)
619
+
620
+ servers = existing.setdefault("mcpServers", {})
621
+ if "devtorch" not in servers:
622
+ servers["devtorch"] = legacy_entry or copy.deepcopy(
623
+ CLAUDE_MCP_SERVER_ENTRY["devtorch"]
624
+ )
625
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
626
+
627
+ return True, str(mcp_path)
628
+
629
+ except Exception as exc:
630
+ return False, str(exc)
631
+
632
+
633
+ # ---------------------------------------------------------------------------
634
+ # install_cursor_mcp
635
+ # ---------------------------------------------------------------------------
636
+
637
+
638
+ def install_opencode_hooks(project_root: str) -> tuple[bool, str]:
639
+ """
640
+ Merge DevTorch hooks into ``<project_root>/opencode.json`` for OpenCode.
641
+
642
+ - Reads the existing opencode.json if it exists.
643
+ - Deep-merges DevTorch's OpenCode hooks into the ``hooks`` key.
644
+ - Strips previously-installed DevTorch hook entries so re-installation
645
+ upgrades them in-place instead of accumulating duplicates.
646
+ - Returns (True, path) on success, (False, error_message) on failure.
647
+ """
648
+ try:
649
+ root = Path(project_root)
650
+ config_path = root / "opencode.json"
651
+
652
+ existing: dict = {}
653
+ if config_path.exists():
654
+ existing = _read_json_dict_or_backup(config_path)
655
+
656
+ hooks_existing: dict = existing.setdefault("hooks", {})
657
+ working = {"hooks": hooks_existing}
658
+ working = _strip_devtorch_hook_entries(working)
659
+ merged = _deep_merge(working, OPENCODE_HOOKS_CONFIG)
660
+
661
+ result_obj = dict(existing)
662
+ result_obj["hooks"] = merged.get("hooks", {})
663
+ config_path.write_text(json.dumps(result_obj, indent=2), encoding="utf-8")
664
+
665
+ return True, str(config_path)
666
+ except Exception as exc:
667
+ return False, str(exc)
668
+
669
+
670
+ def install_cursor_mcp(project_root: str) -> tuple[bool, str]:
671
+ """
672
+ Write (or merge into) .cursor/mcp.json under *project_root*.
673
+
674
+ Adds the devtorch MCP server entry. Preserves any existing servers.
675
+ Idempotent — skips write if devtorch entry already present.
676
+ Returns (True, path) on success, (False, error_message) on failure.
677
+ """
678
+ try:
679
+ cursor_dir = Path(project_root) / ".cursor"
680
+ cursor_dir.mkdir(parents=True, exist_ok=True)
681
+ mcp_path = cursor_dir / "mcp.json"
682
+
683
+ existing: dict = {}
684
+ if mcp_path.exists():
685
+ try:
686
+ existing = json.loads(mcp_path.read_text(encoding="utf-8"))
687
+ except Exception:
688
+ existing = {}
689
+
690
+ servers = existing.setdefault("mcpServers", {})
691
+ if "devtorch" not in servers:
692
+ servers["devtorch"] = _MCP_SERVER_ENTRY["devtorch"]
693
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
694
+
695
+ return True, str(mcp_path)
696
+
697
+ except Exception as exc:
698
+ return False, str(exc)
699
+
700
+
701
+ # ---------------------------------------------------------------------------
702
+ # install_antigravity_mcp
703
+ # ---------------------------------------------------------------------------
704
+
705
+
706
+ def install_antigravity_mcp(project_root: str) -> tuple[bool, str]:
707
+ """
708
+ Write (or merge into) mcp_config.json at *project_root*.
709
+
710
+ Antigravity uses a project-level mcp_config.json for MCP server registration.
711
+ Adds the devtorch MCP server entry. Preserves any existing servers.
712
+ Idempotent — skips write if devtorch entry already present.
713
+ Returns (True, path) on success, (False, error_message) on failure.
714
+ """
715
+ try:
716
+ mcp_path = Path(project_root) / "mcp_config.json"
717
+
718
+ existing: dict = {}
719
+ if mcp_path.exists():
720
+ try:
721
+ existing = json.loads(mcp_path.read_text(encoding="utf-8"))
722
+ except Exception:
723
+ existing = {}
724
+
725
+ servers = existing.setdefault("mcpServers", {})
726
+ if "devtorch" not in servers:
727
+ servers["devtorch"] = _MCP_SERVER_ENTRY["devtorch"]
728
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
729
+
730
+ return True, str(mcp_path)
731
+
732
+ except Exception as exc:
733
+ return False, str(exc)