thinkstack-core 4.0.0__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 (205) hide show
  1. thinkstack_core/__init__.py +158 -0
  2. thinkstack_core/aggphi_textual.py +275 -0
  3. thinkstack_core/alerts/__init__.py +23 -0
  4. thinkstack_core/alerts/base.py +46 -0
  5. thinkstack_core/alerts/config.py +60 -0
  6. thinkstack_core/alerts/dispatcher.py +110 -0
  7. thinkstack_core/alerts/jira.py +96 -0
  8. thinkstack_core/alerts/linear.py +72 -0
  9. thinkstack_core/alerts/pagerduty.py +66 -0
  10. thinkstack_core/alerts/slack.py +81 -0
  11. thinkstack_core/alerts/teams.py +70 -0
  12. thinkstack_core/audit/__init__.py +43 -0
  13. thinkstack_core/audit/exporter.py +297 -0
  14. thinkstack_core/audit/privacy.py +101 -0
  15. thinkstack_core/audit/scrubber.py +149 -0
  16. thinkstack_core/audit/service.py +67 -0
  17. thinkstack_core/audit/signing.py +127 -0
  18. thinkstack_core/broadcast/__init__.py +4 -0
  19. thinkstack_core/broadcast/broadcaster.py +100 -0
  20. thinkstack_core/broadcast/watcher.py +71 -0
  21. thinkstack_core/capability.py +639 -0
  22. thinkstack_core/cloud/__init__.py +1 -0
  23. thinkstack_core/cloud/client_config.py +472 -0
  24. thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. thinkstack_core/cloud/client_configs/.opencode.json +15 -0
  29. thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. thinkstack_core/cloud/mcp_client.py +229 -0
  31. thinkstack_core/cloud/setup.py +144 -0
  32. thinkstack_core/cloud/sync.py +143 -0
  33. thinkstack_core/cloud/sync_bundle.py +639 -0
  34. thinkstack_core/cloud/sync_conflicts.py +183 -0
  35. thinkstack_core/cloud/sync_state.py +159 -0
  36. thinkstack_core/cloud/team_sync.py +337 -0
  37. thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
  38. thinkstack_core/codex/__init__.py +9 -0
  39. thinkstack_core/codex/__main__.py +97 -0
  40. thinkstack_core/codex/capture.py +208 -0
  41. thinkstack_core/codex/proxy.py +412 -0
  42. thinkstack_core/compat.py +103 -0
  43. thinkstack_core/concept_catalog.py +209 -0
  44. thinkstack_core/consolidation/__init__.py +3 -0
  45. thinkstack_core/consolidation/synthesizer.py +87 -0
  46. thinkstack_core/consolidation/workflow.py +175 -0
  47. thinkstack_core/daemon/__init__.py +27 -0
  48. thinkstack_core/daemon/supervisor.py +293 -0
  49. thinkstack_core/daemon/watcher.py +244 -0
  50. thinkstack_core/dashboard_api.py +2012 -0
  51. thinkstack_core/deltaf.py +97 -0
  52. thinkstack_core/disclosure.py +50 -0
  53. thinkstack_core/divergence/__init__.py +3 -0
  54. thinkstack_core/divergence/detector.py +166 -0
  55. thinkstack_core/gateway/__init__.py +32 -0
  56. thinkstack_core/gateway/key_manager.py +124 -0
  57. thinkstack_core/gateway/metrics_webhook.py +252 -0
  58. thinkstack_core/gateway/policy.py +262 -0
  59. thinkstack_core/gateway/server.py +727 -0
  60. thinkstack_core/gateway/sso.py +233 -0
  61. thinkstack_core/gcc.py +1246 -0
  62. thinkstack_core/github/__init__.py +35 -0
  63. thinkstack_core/github/app.py +240 -0
  64. thinkstack_core/github/comment_builder.py +113 -0
  65. thinkstack_core/github/pat.py +76 -0
  66. thinkstack_core/github/pr_parser.py +82 -0
  67. thinkstack_core/github/pr_reporter.py +555 -0
  68. thinkstack_core/gitlab/__init__.py +177 -0
  69. thinkstack_core/hitl/__init__.py +4 -0
  70. thinkstack_core/hitl/channels.py +129 -0
  71. thinkstack_core/hitl/orchestrator.py +95 -0
  72. thinkstack_core/hooks/__init__.py +17 -0
  73. thinkstack_core/hooks/claude_code.py +228 -0
  74. thinkstack_core/hooks/git_capture.py +341 -0
  75. thinkstack_core/hooks/git_commit.py +182 -0
  76. thinkstack_core/hooks/installer.py +850 -0
  77. thinkstack_core/hooks/pre_commit.py +157 -0
  78. thinkstack_core/hooks/runner.py +386 -0
  79. thinkstack_core/identity/__init__.py +4 -0
  80. thinkstack_core/identity/agent.py +86 -0
  81. thinkstack_core/identity/providers.py +85 -0
  82. thinkstack_core/invariants.py +182 -0
  83. thinkstack_core/mcp/__init__.py +10 -0
  84. thinkstack_core/mcp/auth.py +177 -0
  85. thinkstack_core/mcp/server.py +1215 -0
  86. thinkstack_core/metrics/__init__.py +35 -0
  87. thinkstack_core/metrics/aggregate.py +215 -0
  88. thinkstack_core/metrics/calibrate.py +198 -0
  89. thinkstack_core/metrics/calibration.py +125 -0
  90. thinkstack_core/metrics/credibility.py +288 -0
  91. thinkstack_core/metrics/delivery_time.py +70 -0
  92. thinkstack_core/metrics/dhs.py +126 -0
  93. thinkstack_core/metrics/mcs.py +96 -0
  94. thinkstack_core/metrics/roi.py +88 -0
  95. thinkstack_core/metrics/session_writer.py +81 -0
  96. thinkstack_core/metrics/shadow_ai.py +117 -0
  97. thinkstack_core/metrics/sprint_writer.py +243 -0
  98. thinkstack_core/observability/__init__.py +78 -0
  99. thinkstack_core/observability/datadog.py +157 -0
  100. thinkstack_core/observability/formatter.py +119 -0
  101. thinkstack_core/observability/report.py +264 -0
  102. thinkstack_core/observability/servicenow.py +147 -0
  103. thinkstack_core/observability/splunk.py +218 -0
  104. thinkstack_core/observability/webhook.py +227 -0
  105. thinkstack_core/parser/__init__.py +30 -0
  106. thinkstack_core/parser/blocks.py +216 -0
  107. thinkstack_core/parser/inference.py +159 -0
  108. thinkstack_core/parser/thinking.py +112 -0
  109. thinkstack_core/projects.py +169 -0
  110. thinkstack_core/prompt_artifact.py +76 -0
  111. thinkstack_core/proxy/__init__.py +9 -0
  112. thinkstack_core/proxy/routes/__init__.py +1 -0
  113. thinkstack_core/proxy/routes/anthropic.py +264 -0
  114. thinkstack_core/proxy/routes/azure_openai.py +336 -0
  115. thinkstack_core/proxy/routes/gemini.py +331 -0
  116. thinkstack_core/proxy/routes/groq.py +284 -0
  117. thinkstack_core/proxy/routes/ollama.py +279 -0
  118. thinkstack_core/proxy/routes/openai.py +287 -0
  119. thinkstack_core/proxy/server.py +356 -0
  120. thinkstack_core/query/__init__.py +15 -0
  121. thinkstack_core/query/grep.py +181 -0
  122. thinkstack_core/query/hybrid.py +86 -0
  123. thinkstack_core/query/semantic.py +157 -0
  124. thinkstack_core/rdp.py +105 -0
  125. thinkstack_core/reasoning/__init__.py +4 -0
  126. thinkstack_core/reasoning/entry.py +31 -0
  127. thinkstack_core/reasoning/store.py +122 -0
  128. thinkstack_core/reasoning_plus/__init__.py +70 -0
  129. thinkstack_core/reasoning_plus/augmenter.py +337 -0
  130. thinkstack_core/reasoning_plus/capture.py +51 -0
  131. thinkstack_core/reasoning_plus/config.py +313 -0
  132. thinkstack_core/reasoning_plus/context.py +262 -0
  133. thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
  134. thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
  135. thinkstack_core/reasoning_plus/learning/api.py +784 -0
  136. thinkstack_core/reasoning_plus/learning/chain.py +285 -0
  137. thinkstack_core/reasoning_plus/learning/composer.py +141 -0
  138. thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
  139. thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
  140. thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
  141. thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
  142. thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
  143. thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
  144. thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
  145. thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
  146. thinkstack_core/reasoning_plus/learning/models.py +149 -0
  147. thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
  148. thinkstack_core/reasoning_plus/learning/pii.py +142 -0
  149. thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
  150. thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
  151. thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
  152. thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
  153. thinkstack_core/reasoning_plus/learning/state.py +86 -0
  154. thinkstack_core/reasoning_plus/learning/store.py +178 -0
  155. thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  156. thinkstack_core/reasoning_plus/prompt.py +90 -0
  157. thinkstack_core/rep.py +134 -0
  158. thinkstack_core/rep_network/__init__.py +25 -0
  159. thinkstack_core/rep_network/merge.py +70 -0
  160. thinkstack_core/rep_network/node.py +137 -0
  161. thinkstack_core/rep_network/server.py +140 -0
  162. thinkstack_core/rep_network/sync.py +207 -0
  163. thinkstack_core/sensitivity.py +182 -0
  164. thinkstack_core/serve.py +258 -0
  165. thinkstack_core/session/__init__.py +39 -0
  166. thinkstack_core/session/disagreement.py +188 -0
  167. thinkstack_core/session/models.py +114 -0
  168. thinkstack_core/session/orchestrator.py +182 -0
  169. thinkstack_core/session/planner.py +169 -0
  170. thinkstack_core/session/simulator.py +132 -0
  171. thinkstack_core/signing.py +290 -0
  172. thinkstack_core/sis.py +197 -0
  173. thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
  174. thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
  175. thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
  176. thinkstack_core/storage.py +308 -0
  177. thinkstack_core/templates/__init__.py +6 -0
  178. thinkstack_core/templates/engine.py +122 -0
  179. thinkstack_core/templates/go.py +18 -0
  180. thinkstack_core/templates/infra.py +19 -0
  181. thinkstack_core/templates/library/__init__.py +18 -0
  182. thinkstack_core/templates/library/api_design.md +27 -0
  183. thinkstack_core/templates/library/bug_fix.md +27 -0
  184. thinkstack_core/templates/library/decision_record.md +27 -0
  185. thinkstack_core/templates/library/engine.py +228 -0
  186. thinkstack_core/templates/library/security_review.md +30 -0
  187. thinkstack_core/templates/python.py +19 -0
  188. thinkstack_core/templates/react.py +18 -0
  189. thinkstack_core/templates/typescript.py +18 -0
  190. thinkstack_core/theta.py +221 -0
  191. thinkstack_core/theta_synthesis.py +268 -0
  192. thinkstack_core/topics.py +320 -0
  193. thinkstack_core/variance.py +219 -0
  194. thinkstack_core/wrapper/__init__.py +52 -0
  195. thinkstack_core/wrapper/anthropic.py +487 -0
  196. thinkstack_core/wrapper/base.py +562 -0
  197. thinkstack_core/wrapper/bedrock.py +342 -0
  198. thinkstack_core/wrapper/gemini.py +422 -0
  199. thinkstack_core/wrapper/ollama.py +527 -0
  200. thinkstack_core/wrapper/openai.py +461 -0
  201. thinkstack_core-4.0.0.dist-info/METADATA +868 -0
  202. thinkstack_core-4.0.0.dist-info/RECORD +205 -0
  203. thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
  204. thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
  205. thinkstack_core-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,850 @@
1
+ """
2
+ Installs ThinkStack 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 ThinkStack 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 = ("thinkstack hooks run", "thinkstack-hook", "sh -c 'thinkstack-hook")
25
+
26
+ CLAUDE_HOOKS_CONFIG: dict = {
27
+ "hooks": {
28
+ "PreToolUse": [
29
+ {
30
+ "matcher": MUTATING_TOOLS_MATCHER,
31
+ "hooks": [
32
+ {"type": "command", "command": "thinkstack-hook pre-tool-use"}
33
+ ],
34
+ }
35
+ ],
36
+ "UserPromptSubmit": [
37
+ {
38
+ "hooks": [
39
+ {"type": "command", "command": "thinkstack-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 'thinkstack-hook post-tool-use >/dev/null 2>&1 &'"}
50
+ ],
51
+ }
52
+ ],
53
+ "Stop": [
54
+ {
55
+ "hooks": [
56
+ {"type": "command", "command": "thinkstack-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": "thinkstack-hook open-code-pre-prompt",
70
+ }
71
+ ],
72
+ "PreToolUse": [
73
+ {
74
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
75
+ "command": "thinkstack-hook pre-tool-use",
76
+ }
77
+ ],
78
+ "PostToolUse": [
79
+ {
80
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
81
+ "command": "sh -c 'thinkstack-hook post-tool-use &'",
82
+ }
83
+ ],
84
+ "Stop": [
85
+ {
86
+ "command": "thinkstack-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
+ skills_written: bool = False
108
+ skills_installed: list[str] = field(default_factory=list)
109
+ errors: list[str] = field(default_factory=list)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Deep-merge helpers
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def _deep_merge(base: dict, overlay: dict) -> dict:
118
+ """
119
+ Recursively merge *overlay* into *base*.
120
+
121
+ - For dicts: recurse.
122
+ - For lists keyed under "hooks" event names: append overlay entries
123
+ whose commands are not already present.
124
+ - Scalar values: overlay wins.
125
+ """
126
+ result = copy.deepcopy(base)
127
+ for key, val in overlay.items():
128
+ if key in result and isinstance(result[key], dict) and isinstance(val, dict):
129
+ result[key] = _deep_merge(result[key], val)
130
+ elif key in result and isinstance(result[key], list) and isinstance(val, list):
131
+ # Merge lists without duplicating identical entries
132
+ existing_cmds = _extract_commands(result[key])
133
+ for item in val:
134
+ item_cmds = _extract_commands([item])
135
+ if not item_cmds or not item_cmds.issubset(existing_cmds):
136
+ result[key].append(copy.deepcopy(item))
137
+ else:
138
+ result[key] = copy.deepcopy(val)
139
+ return result
140
+
141
+
142
+ def _extract_commands(items: list) -> set:
143
+ """Extract all command strings from a list of hook entries."""
144
+ cmds: set = set()
145
+ for item in items:
146
+ if isinstance(item, dict):
147
+ for hook in item.get("hooks", []):
148
+ if isinstance(hook, dict) and "command" in hook:
149
+ cmds.add(hook["command"])
150
+ return cmds
151
+
152
+
153
+ def _strip_thinkstack_hook_entries(settings: dict) -> dict:
154
+ """Remove hook entries whose every command is a ThinkStack-shipped command,
155
+ so re-installation upgrades them instead of accumulating duplicates.
156
+ User-authored entries (any non-ThinkStack command) are kept intact."""
157
+ result = copy.deepcopy(settings)
158
+ hooks = result.get("hooks")
159
+ if not isinstance(hooks, dict):
160
+ return result
161
+ for event, entries in hooks.items():
162
+ if not isinstance(entries, list):
163
+ continue
164
+ kept = []
165
+ for entry in entries:
166
+ cmds = _extract_commands([entry])
167
+ if cmds and all(
168
+ c.startswith(_LEGACY_HOOK_COMMAND_PREFIXES) for c in cmds
169
+ ):
170
+ continue
171
+ kept.append(entry)
172
+ hooks[event] = kept
173
+ return result
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # install_claude_hooks
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def install_claude_hooks(project_root: str) -> tuple[bool, str]:
182
+ """
183
+ Merge ThinkStack hooks into .claude/settings.json under *project_root*.
184
+
185
+ Claude Code only reads hooks from its settings files (settings.json /
186
+ settings.local.json) — a standalone hooks.json is never loaded.
187
+
188
+ - Creates .claude/ directory if it does not exist.
189
+ - Deep-merges ThinkStack hooks into the existing settings.json without
190
+ removing existing keys (permissions, env, other hooks, ...).
191
+ - Migrates a legacy .claude/hooks.json written by older ThinkStack versions
192
+ into settings.json and removes it.
193
+ - Returns (True, path) on success, (False, error_message) on failure.
194
+ """
195
+ try:
196
+ claude_dir = Path(project_root) / ".claude"
197
+ claude_dir.mkdir(parents=True, exist_ok=True)
198
+
199
+ settings_path = claude_dir / "settings.json"
200
+
201
+ existing: dict = {}
202
+ if settings_path.exists():
203
+ try:
204
+ existing = json.loads(settings_path.read_text(encoding="utf-8"))
205
+ except Exception:
206
+ existing = {}
207
+
208
+ legacy_path = claude_dir / "hooks.json"
209
+ legacy_migrated = False
210
+ if legacy_path.exists():
211
+ try:
212
+ legacy = json.loads(legacy_path.read_text(encoding="utf-8"))
213
+ except Exception:
214
+ legacy = None
215
+ if isinstance(legacy, dict) and isinstance(legacy.get("hooks"), dict):
216
+ existing = _deep_merge(existing, {"hooks": legacy["hooks"]})
217
+ legacy_migrated = True
218
+ # Unreadable or unexpected shape: leave the file in place — never
219
+ # delete config we could not migrate.
220
+
221
+ existing = _strip_thinkstack_hook_entries(existing)
222
+ merged = _deep_merge(existing, CLAUDE_HOOKS_CONFIG)
223
+ settings_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
224
+ # Remove the legacy file only after its content is safely in settings.json.
225
+ if legacy_migrated:
226
+ legacy_path.unlink()
227
+ return True, str(settings_path)
228
+
229
+ except Exception as exc:
230
+ return False, str(exc)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # install_git_commit_hook
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ def install_git_commit_hook(repo_root: str) -> tuple[bool, str]:
239
+ """
240
+ Install the git commit-msg hook in .git/hooks/commit-msg.
241
+
242
+ Calls thinkstack_core.hooks.git_commit.install_hook(repo_root).
243
+ Returns (True, path) on success, (False, reason) when no .git found or
244
+ on any other error.
245
+ """
246
+ git_dir = Path(repo_root) / ".git"
247
+ if not git_dir.is_dir():
248
+ return False, "no .git found"
249
+
250
+ try:
251
+ from thinkstack_core.hooks.git_commit import install_hook
252
+
253
+ install_hook(Path(repo_root))
254
+ hook_path = str(git_dir / "hooks" / "commit-msg")
255
+ return True, hook_path
256
+
257
+ except Exception as exc:
258
+ return False, str(exc)
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # install_git_capture_hooks
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ def install_git_capture_hooks(repo_root: str) -> tuple[bool, dict[str, str]]:
267
+ """
268
+ Install the S26 git capture hooks in .git/hooks/.
269
+
270
+ Returns (True, {hook_name: path}) on success, (False, {hook_name: error})
271
+ when no .git/hooks directory is found or on any other error.
272
+ """
273
+ try:
274
+ from thinkstack_core.hooks.git_capture import (
275
+ install_git_capture_hooks as _install_git_capture_hooks,
276
+ )
277
+
278
+ ok, paths = _install_git_capture_hooks(Path(repo_root))
279
+ return ok, paths
280
+ except Exception as exc:
281
+ return False, {"error": str(exc)}
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # install_all_hooks
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ def install_all_hooks(project_root: str) -> HookInstallResult:
290
+ """
291
+ Install all ThinkStack hooks (Claude Code + git).
292
+
293
+ Never raises — all exceptions are captured into result.errors.
294
+ """
295
+ result = HookInstallResult()
296
+
297
+ # Claude Code hooks
298
+ try:
299
+ ok, path = install_claude_hooks(project_root)
300
+ result.claude_hooks_written = ok
301
+ result.claude_hooks_path = path
302
+ if not ok:
303
+ result.errors.append(f"claude hooks: {path}")
304
+ except Exception as exc:
305
+ result.errors.append(f"claude hooks exception: {exc}")
306
+
307
+ # Git commit-msg hook
308
+ try:
309
+ ok, path = install_git_commit_hook(project_root)
310
+ result.git_hook_written = ok
311
+ result.git_hook_path = path
312
+ if not ok:
313
+ result.errors.append(f"git hook: {path}")
314
+ except Exception as exc:
315
+ result.errors.append(f"git hook exception: {exc}")
316
+
317
+ # S26 git capture hooks
318
+ try:
319
+ ok, paths = install_git_capture_hooks(project_root)
320
+ result.git_capture_hooks_written = ok
321
+ result.git_capture_hooks = paths
322
+ if not ok:
323
+ result.errors.append(f"git capture hooks: {paths}")
324
+ except Exception as exc:
325
+ result.errors.append(f"git capture hooks exception: {exc}")
326
+
327
+ # OpenCode / Claude Code skills
328
+ try:
329
+ ok, names = install_skills()
330
+ result.skills_written = ok
331
+ result.skills_installed = names
332
+ if not ok:
333
+ result.errors.append(f"skills: {names}")
334
+ except Exception as exc:
335
+ result.errors.append(f"skills exception: {exc}")
336
+
337
+ return result
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Skill installation
342
+ # ---------------------------------------------------------------------------
343
+
344
+ _DEVTOCH_SKILLS = [
345
+ "thinkstack-session-start",
346
+ "thinkstack-auto-sync",
347
+ "pr-reviewer",
348
+ ]
349
+
350
+ _DEFAULT_SKILLS_DIR = Path.home() / ".claude" / "skills"
351
+
352
+
353
+ def install_skills(
354
+ skills_dir: Path | None = None,
355
+ package_skills_path: Path | None = None,
356
+ ) -> tuple[bool, list[str]]:
357
+ """Copy ThinkStack skills from the package to ~/.claude/skills/.
358
+
359
+ The skills are shipped as package data in thinkstack_core/skills/<name>/SKILL.md.
360
+ On install, each skill directory is created or updated in the target skills dir.
361
+
362
+ Returns (all_ok, list_of_skill_names).
363
+ """
364
+ target_dir = skills_dir or _DEFAULT_SKILLS_DIR
365
+ target_dir.mkdir(parents=True, exist_ok=True)
366
+
367
+ if package_skills_path is None:
368
+ try:
369
+ from importlib.resources import files
370
+ package_skills_path = Path(str(files("thinkstack_core") / "skills"))
371
+ except Exception:
372
+ package_skills_path = Path(__file__).parent.parent / "skills"
373
+
374
+ installed: list[str] = []
375
+ all_ok = len(_DEVTOCH_SKILLS) > 0
376
+
377
+ for skill_name in _DEVTOCH_SKILLS:
378
+ try:
379
+ src = package_skills_path / skill_name / "SKILL.md"
380
+ if not src.exists():
381
+ continue
382
+ dst_dir = target_dir / skill_name
383
+ dst_dir.mkdir(parents=True, exist_ok=True)
384
+ dst = dst_dir / "SKILL.md"
385
+ dst.write_text(src.read_text(encoding="utf-8"))
386
+ if skill_name not in installed:
387
+ installed.append(skill_name)
388
+ except Exception:
389
+ all_ok = False
390
+
391
+ return all_ok, installed
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # AGENTS.md block for Google Antigravity
396
+ # ---------------------------------------------------------------------------
397
+
398
+ ANTIGRAVITY_AGENTS_MD_BLOCK = """
399
+ ## ThinkStack — Reasoning Capture
400
+
401
+ This project uses ThinkStack for AI governance. These steps are mandatory for all agent actions.
402
+
403
+ ### At the start of every task
404
+
405
+ Call `thinkstack_context` (MCP tool) before reading files or writing code:
406
+ - Tool: `thinkstack_context`
407
+ - Args: `{ "max_tokens": 2000 }`
408
+
409
+ Use the returned context to understand prior decisions before proceeding.
410
+
411
+ ### Before writing or editing any code
412
+
413
+ Call `thinkstack_commit` before the first file write:
414
+ - Tool: `thinkstack_commit`
415
+ - Args: `{ "message": "<what you decided and WHY — include alternatives considered>", "concepts": ["<concept>"], "confidence": 0.0–1.0 }`
416
+
417
+ The message must explain the *why*, not just the *what*.
418
+ "Added login endpoint" is not acceptable.
419
+ "Chose JWT over sessions because the service is stateless" is.
420
+
421
+ ### When touching sensitive areas
422
+
423
+ Call `thinkstack_sensitivity_add` for auth, payments, database schema, secrets, PII, external APIs:
424
+ - Tool: `thinkstack_sensitivity_add`
425
+ - Args: `{ "concept": "<auth|schema|payments|secrets|pii|api|security|config>", "confidence": 0.0–1.0, "disclosure_level": "PUBLIC|PROTECTED|PRIVATE" }`
426
+
427
+ | Level | Use when |
428
+ |---|---|
429
+ | PUBLIC | General design decisions |
430
+ | PROTECTED | Internal implementation, business logic |
431
+ | PRIVATE | Credentials, PII, secrets |
432
+ """
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # install_agents_md
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ def install_agents_md(project_root: str) -> tuple[bool, str]:
441
+ """
442
+ Write (or append to) AGENTS.md under *project_root* with the ThinkStack
443
+ governance block for Google Antigravity.
444
+
445
+ - Creates AGENTS.md if it does not exist.
446
+ - If AGENTS.md already contains the marker ``## ThinkStack``, does nothing
447
+ (idempotent).
448
+ - Returns (True, path) on success, (False, error_message) on failure.
449
+ """
450
+ try:
451
+ import re
452
+
453
+ agents_md_path = Path(project_root) / "AGENTS.md"
454
+ # Any-level ThinkStack heading counts as installed — projects may promote
455
+ # the block to an H1; appending our copy next to it would duplicate it.
456
+ marker_re = re.compile(r"^#{1,6}\s+ThinkStack\b", re.MULTILINE)
457
+
458
+ if agents_md_path.exists():
459
+ existing = agents_md_path.read_text(encoding="utf-8")
460
+ if marker_re.search(existing):
461
+ return True, str(agents_md_path)
462
+
463
+ with agents_md_path.open("a", encoding="utf-8") as f:
464
+ f.write(ANTIGRAVITY_AGENTS_MD_BLOCK)
465
+
466
+ return True, str(agents_md_path)
467
+
468
+ except Exception as exc:
469
+ return False, str(exc)
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # get_hooks_status
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ def get_hooks_status(project_root: str) -> dict:
478
+ """
479
+ Return the install status of ThinkStack hooks without writing anything.
480
+
481
+ Returns:
482
+ {
483
+ "claude_hooks": bool,
484
+ "claude_hooks_path": str,
485
+ "git_hook": bool,
486
+ "git_hook_path": str,
487
+ "git_capture_hooks": dict[str, bool],
488
+ "git_capture_hooks_path": dict[str, str],
489
+ }
490
+ """
491
+ settings_path = Path(project_root) / ".claude" / "settings.json"
492
+ claude_hooks_path = str(settings_path)
493
+ claude_hooks = False
494
+ if settings_path.exists():
495
+ try:
496
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
497
+ cmds: set = set()
498
+ for entries in (data.get("hooks") or {}).values():
499
+ if isinstance(entries, list):
500
+ cmds |= _extract_commands(entries)
501
+ claude_hooks = any(
502
+ c.startswith("thinkstack-hook") or c.startswith("sh -c 'thinkstack-hook")
503
+ for c in cmds
504
+ )
505
+ except Exception:
506
+ claude_hooks = False
507
+
508
+ git_hook_path = str(Path(project_root) / ".git" / "hooks" / "commit-msg")
509
+ git_hook = Path(git_hook_path).exists()
510
+
511
+ hooks_dir = Path(project_root) / ".git" / "hooks"
512
+ git_capture_hook_names = ["post-commit", "post-merge", "post-checkout", "post-rewrite"]
513
+ git_capture_hooks: dict[str, bool] = {}
514
+ git_capture_hooks_path: dict[str, str] = {}
515
+ for name in git_capture_hook_names:
516
+ hook_path = hooks_dir / name
517
+ exists = hook_path.exists()
518
+ git_capture_hooks[name] = exists
519
+ git_capture_hooks_path[name] = str(hook_path)
520
+
521
+ return {
522
+ "claude_hooks": claude_hooks,
523
+ "claude_hooks_path": claude_hooks_path,
524
+ "git_hook": git_hook,
525
+ "git_hook_path": git_hook_path,
526
+ "git_capture_hooks": git_capture_hooks,
527
+ "git_capture_hooks_path": git_capture_hooks_path,
528
+ }
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # IDE / project-type detection
533
+ # ---------------------------------------------------------------------------
534
+
535
+ _IDE_DETECTION_MARKERS: dict[str, str] = {
536
+ ".claude": "claude-code",
537
+ ".cursor": "cursor",
538
+ ".vscode": "vscode",
539
+ ".kiro": "kiro",
540
+ ".antigravity": "antigravity",
541
+ "mcp_config.json": "antigravity",
542
+ }
543
+
544
+
545
+ def _detect_ides(project_dir: str) -> list[str]:
546
+ """
547
+ Detect installed IDE directories/files in *project_dir*.
548
+
549
+ Scans for ``.claude/``, ``.cursor/``, ``.vscode/``, ``.kiro/``,
550
+ ``.antigravity/``, and ``mcp_config.json``. Returns a list of
551
+ IDE identifiers in the order they were found, with duplicates removed
552
+ (``mcp_config.json`` and ``.antigravity/`` both map to ``antigravity``).
553
+ """
554
+ root = Path(project_dir)
555
+ found: list[str] = []
556
+ seen: set[str] = set()
557
+ for marker, ide_name in _IDE_DETECTION_MARKERS.items():
558
+ if (root / marker).exists() and ide_name not in seen:
559
+ found.append(ide_name)
560
+ seen.add(ide_name)
561
+ return found
562
+
563
+
564
+ _PROJECT_TYPE_MARKERS: dict[str, str] = {
565
+ "pyproject.toml": "python",
566
+ "requirements.txt": "python",
567
+ "setup.py": "python",
568
+ "go.mod": "go",
569
+ "composer.json": "php",
570
+ "package.json": "typescript",
571
+ "tsconfig.json": "typescript",
572
+ }
573
+
574
+
575
+ def _detect_project_type(project_dir: str) -> str:
576
+ """
577
+ Detect the dominant project type in *project_dir*.
578
+
579
+ Scans for ``pyproject.toml``, ``package.json``, ``go.mod``,
580
+ ``composer.json``, and related markers. Returns one of:
581
+ ``python``, ``typescript``, ``react``, ``go``, ``php``, or ``generic``.
582
+ """
583
+ root = Path(project_dir)
584
+ for marker, project_type in _PROJECT_TYPE_MARKERS.items():
585
+ if (root / marker).exists():
586
+ if marker == "package.json":
587
+ try:
588
+ data = json.loads((root / marker).read_text(encoding="utf-8"))
589
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
590
+ if "react" in deps:
591
+ return "react"
592
+ except Exception:
593
+ pass
594
+ return project_type
595
+ return "generic"
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # Shared MCP server entry for Cursor / Antigravity
600
+ # ---------------------------------------------------------------------------
601
+
602
+ _MCP_SERVER_ENTRY = {
603
+ "thinkstack": {
604
+ "command": "thinkstack",
605
+ "args": ["mcp-server"],
606
+ }
607
+ }
608
+
609
+
610
+ # ---------------------------------------------------------------------------
611
+ # install_claude_mcp
612
+ # ---------------------------------------------------------------------------
613
+
614
+ # Claude Code reads project-scope MCP servers only from <root>/.mcp.json;
615
+ # a mcpServers key inside .claude/settings.json is silently ignored.
616
+ CLAUDE_MCP_SERVER_ENTRY = {
617
+ "thinkstack": {
618
+ "command": "node",
619
+ "args": ["__THINKSTACK_BRIDGE_PATH__"],
620
+ "env": {
621
+ "THINKSTACK_MCP_URL": "__THINKSTACK_MCP_URL__",
622
+ "THINKSTACK_API_KEY": "__THINKSTACK_API_KEY__",
623
+ },
624
+ }
625
+ }
626
+
627
+
628
+ def _read_json_dict_or_backup(path: Path) -> dict:
629
+ """
630
+ Parse *path* as a JSON object. If it is unparseable or not an object,
631
+ rename it to ``<name>.bak`` so hand-edited content is never silently
632
+ clobbered, and return an empty dict.
633
+ """
634
+ try:
635
+ data = json.loads(path.read_text(encoding="utf-8"))
636
+ except Exception:
637
+ data = None
638
+ if isinstance(data, dict):
639
+ return data
640
+ path.rename(path.with_name(path.name + ".bak"))
641
+ return {}
642
+
643
+
644
+ def _pop_legacy_settings_mcp_entry(root: Path) -> Optional[dict]:
645
+ """
646
+ Remove a thinkstack entry stranded in .claude/settings.json mcpServers
647
+ (where Claude Code never reads it) and return it, or None.
648
+
649
+ Other mcpServers entries are left untouched; the mcpServers key itself is
650
+ dropped when the thinkstack entry was the only one.
651
+ """
652
+ settings_path = root / ".claude" / "settings.json"
653
+ if not settings_path.exists():
654
+ return None
655
+ try:
656
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
657
+ except Exception:
658
+ return None
659
+ servers = settings.get("mcpServers")
660
+ if not isinstance(servers, dict) or "thinkstack" not in servers:
661
+ return None
662
+ entry = servers.pop("thinkstack")
663
+ if not servers:
664
+ del settings["mcpServers"]
665
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
666
+ return entry if isinstance(entry, dict) else None
667
+
668
+
669
+ def install_claude_mcp(project_root: str) -> tuple[bool, str]:
670
+ """
671
+ Write (or merge into) <project_root>/.mcp.json for Claude Code.
672
+
673
+ Adds the thinkstack MCP server entry pointing to the Cloudflare Worker
674
+ via the ThinkStack MCP bridge script. Preserves any existing servers.
675
+ Idempotent — skips write if thinkstack entry already present.
676
+ Migrates a thinkstack entry written to .claude/settings.json by older
677
+ ThinkStack versions, preserving its configured values.
678
+ Returns (True, path) on success, (False, error_message) on failure.
679
+ """
680
+ try:
681
+ root = Path(project_root)
682
+ mcp_path = root / ".mcp.json"
683
+
684
+ existing: dict = {}
685
+ if mcp_path.exists():
686
+ existing = _read_json_dict_or_backup(mcp_path)
687
+
688
+ legacy_entry = _pop_legacy_settings_mcp_entry(root)
689
+
690
+ servers = existing.setdefault("mcpServers", {})
691
+ if "thinkstack" not in servers or _uses_local_stdio(servers.get("thinkstack", {})):
692
+ entry = legacy_entry or _build_claude_mcp_entry(root)
693
+ servers["thinkstack"] = entry
694
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
695
+
696
+ return True, str(mcp_path)
697
+
698
+ except Exception as exc:
699
+ return False, str(exc)
700
+
701
+
702
+ def _uses_local_stdio(entry: dict) -> bool:
703
+ """Return True if the MCP entry uses a local thinkstack stdio process."""
704
+ if isinstance(entry, dict):
705
+ cmd = entry.get("command", "")
706
+ args = entry.get("args", [])
707
+ if cmd == "thinkstack" and any("mcp-server" in a for a in args):
708
+ return True
709
+ return False
710
+
711
+
712
+ def _build_claude_mcp_entry(root: Path) -> dict:
713
+ """Build a Claude Code MCP entry pointing to the Cloudflare Worker via bridge."""
714
+ import os
715
+ mcp_url = ""
716
+ api_key = ""
717
+ bridge_path = ""
718
+
719
+ # Read from existing opencode.json if present
720
+ opencode_json = root / "opencode.json"
721
+ if opencode_json.exists():
722
+ try:
723
+ oc = json.loads(opencode_json.read_text(encoding="utf-8"))
724
+ mcp_cfg = oc.get("mcp", {}).get("thinkstack", {})
725
+ env = mcp_cfg.get("environment", {})
726
+ mcp_url = env.get("THINKSTACK_MCP_URL", "")
727
+ api_key = env.get("THINKSTACK_API_KEY", "")
728
+ if isinstance(mcp_cfg.get("command"), list) and len(mcp_cfg.get("command", [])) > 1:
729
+ bridge_path = mcp_cfg["command"][1]
730
+ except Exception:
731
+ pass
732
+
733
+ if not bridge_path:
734
+ try:
735
+ from importlib.resources import files
736
+ bridge_path = str(files("thinkstack_core") / "cloud" / "thinkstack-mcp-bridge.js")
737
+ except Exception:
738
+ bridge_path = os.path.join(os.path.dirname(__file__), "..", "cloud", "thinkstack-mcp-bridge.js")
739
+
740
+ return {
741
+ "command": "node",
742
+ "args": [bridge_path],
743
+ "env": {
744
+ "THINKSTACK_MCP_URL": mcp_url or os.environ.get("THINKSTACK_MCP_URL", ""),
745
+ "THINKSTACK_API_KEY": api_key or os.environ.get("THINKSTACK_API_KEY", ""),
746
+ },
747
+ }
748
+
749
+
750
+ # ---------------------------------------------------------------------------
751
+ # install_cursor_mcp
752
+ # ---------------------------------------------------------------------------
753
+
754
+
755
+ def install_opencode_hooks(project_root: str) -> tuple[bool, str]:
756
+ """
757
+ Merge ThinkStack hooks into ``<project_root>/opencode.json`` for OpenCode.
758
+
759
+ - Reads the existing opencode.json if it exists.
760
+ - Deep-merges ThinkStack's OpenCode hooks into the ``hooks`` key.
761
+ - Strips previously-installed ThinkStack hook entries so re-installation
762
+ upgrades them in-place instead of accumulating duplicates.
763
+ - Returns (True, path) on success, (False, error_message) on failure.
764
+ """
765
+ try:
766
+ root = Path(project_root)
767
+ config_path = root / "opencode.json"
768
+
769
+ existing: dict = {}
770
+ if config_path.exists():
771
+ existing = _read_json_dict_or_backup(config_path)
772
+
773
+ hooks_existing: dict = existing.setdefault("hooks", {})
774
+ working = {"hooks": hooks_existing}
775
+ working = _strip_thinkstack_hook_entries(working)
776
+ merged = _deep_merge(working, OPENCODE_HOOKS_CONFIG)
777
+
778
+ result_obj = dict(existing)
779
+ result_obj["hooks"] = merged.get("hooks", {})
780
+ config_path.write_text(json.dumps(result_obj, indent=2), encoding="utf-8")
781
+
782
+ return True, str(config_path)
783
+ except Exception as exc:
784
+ return False, str(exc)
785
+
786
+
787
+ def install_cursor_mcp(project_root: str) -> tuple[bool, str]:
788
+ """
789
+ Write (or merge into) .cursor/mcp.json under *project_root*.
790
+
791
+ Adds the thinkstack MCP server entry. Preserves any existing servers.
792
+ Idempotent — skips write if thinkstack entry already present.
793
+ Returns (True, path) on success, (False, error_message) on failure.
794
+ """
795
+ try:
796
+ cursor_dir = Path(project_root) / ".cursor"
797
+ cursor_dir.mkdir(parents=True, exist_ok=True)
798
+ mcp_path = cursor_dir / "mcp.json"
799
+
800
+ existing: dict = {}
801
+ if mcp_path.exists():
802
+ try:
803
+ existing = json.loads(mcp_path.read_text(encoding="utf-8"))
804
+ except Exception:
805
+ existing = {}
806
+
807
+ servers = existing.setdefault("mcpServers", {})
808
+ if "thinkstack" not in servers:
809
+ servers["thinkstack"] = _MCP_SERVER_ENTRY["thinkstack"]
810
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
811
+
812
+ return True, str(mcp_path)
813
+
814
+ except Exception as exc:
815
+ return False, str(exc)
816
+
817
+
818
+ # ---------------------------------------------------------------------------
819
+ # install_antigravity_mcp
820
+ # ---------------------------------------------------------------------------
821
+
822
+
823
+ def install_antigravity_mcp(project_root: str) -> tuple[bool, str]:
824
+ """
825
+ Write (or merge into) mcp_config.json at *project_root*.
826
+
827
+ Antigravity uses a project-level mcp_config.json for MCP server registration.
828
+ Adds the thinkstack MCP server entry. Preserves any existing servers.
829
+ Idempotent — skips write if thinkstack entry already present.
830
+ Returns (True, path) on success, (False, error_message) on failure.
831
+ """
832
+ try:
833
+ mcp_path = Path(project_root) / "mcp_config.json"
834
+
835
+ existing: dict = {}
836
+ if mcp_path.exists():
837
+ try:
838
+ existing = json.loads(mcp_path.read_text(encoding="utf-8"))
839
+ except Exception:
840
+ existing = {}
841
+
842
+ servers = existing.setdefault("mcpServers", {})
843
+ if "thinkstack" not in servers:
844
+ servers["thinkstack"] = _MCP_SERVER_ENTRY["thinkstack"]
845
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
846
+
847
+ return True, str(mcp_path)
848
+
849
+ except Exception as exc:
850
+ return False, str(exc)