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,157 @@
1
+ """
2
+ S29 — Git pre-commit hook for ThinkStack governance checks.
3
+
4
+ Installs a `.git/hooks/pre-commit` script that blocks commits when
5
+ `thinkstack doctor` or `thinkstack invariant check` fail. Also provides a
6
+ programmatic entry point so CI and tests can run the same checks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Union
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Data class
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @dataclass
25
+ class PreCommitResult:
26
+ """Outcome of the pre-commit governance checks."""
27
+
28
+ passed: bool
29
+ checks: list[dict]
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Hook script
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ _PRE_COMMIT_SCRIPT = """#!/bin/sh
38
+ # ThinkStack pre-commit hook (S29)
39
+ # Generated by thinkstack_core.hooks.pre_commit — safe to re-run.
40
+
41
+ set -e
42
+
43
+ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
44
+ cd "$PROJECT_ROOT"
45
+
46
+ echo "[thinkstack pre-commit] Running governance checks..."
47
+
48
+ echo "[thinkstack pre-commit] thinkstack doctor"
49
+ thinkstack doctor
50
+
51
+ echo "[thinkstack pre-commit] thinkstack invariant check"
52
+ thinkstack invariant check
53
+
54
+ echo "[thinkstack pre-commit] All checks passed."
55
+ """
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Public API
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def install_pre_commit_hook(project_root: Union[str, Path]) -> Path:
64
+ """
65
+ Write an executable `.git/hooks/pre-commit` script under *project_root*.
66
+
67
+ The script runs `thinkstack doctor` then `thinkstack invariant check` and exits
68
+ non-zero if either command fails, blocking the git commit. The operation is
69
+ idempotent — running it multiple times writes the same script.
70
+
71
+ Raises:
72
+ FileNotFoundError: if `.git/hooks/` does not exist.
73
+
74
+ Returns:
75
+ Path to the written hook.
76
+ """
77
+ root = Path(project_root)
78
+ hooks_dir = root / ".git" / "hooks"
79
+ if not hooks_dir.is_dir():
80
+ raise FileNotFoundError(
81
+ f"No .git/hooks/ directory found at {root}. Are you inside a git repository?"
82
+ )
83
+
84
+ hook_path = hooks_dir / "pre-commit"
85
+ hook_path.write_text(_PRE_COMMIT_SCRIPT, encoding="utf-8")
86
+ hook_path.chmod(0o755)
87
+ return hook_path
88
+
89
+
90
+ RunResult = tuple[int, str, str]
91
+
92
+
93
+ def _run_command(
94
+ command: list[str],
95
+ cwd: Path,
96
+ env: dict | None = None,
97
+ ) -> RunResult:
98
+ """Run a command and return (exit_code, stdout, stderr)."""
99
+ try:
100
+ proc = subprocess.run(
101
+ command,
102
+ cwd=cwd,
103
+ capture_output=True,
104
+ text=True,
105
+ encoding="utf-8",
106
+ errors="replace",
107
+ env=env,
108
+ check=False,
109
+ )
110
+ return proc.returncode, proc.stdout, proc.stderr
111
+ except FileNotFoundError as exc:
112
+ return 127, "", f"Command not found: {exc}"
113
+
114
+
115
+ def run_pre_commit_checks(project_root: Union[str, Path]) -> PreCommitResult:
116
+ """
117
+ Run the same governance checks as the pre-commit hook programmatically.
118
+
119
+ Executes `thinkstack doctor` and `thinkstack invariant check` in the given
120
+ project root using the same CLI that the hook uses. Returns a structured
121
+ result with per-check exit codes and combined output.
122
+
123
+ Args:
124
+ project_root: Directory of the project to check (must contain .GCC).
125
+
126
+ Returns:
127
+ PreCommitResult with `passed=True` only when every check succeeds.
128
+ """
129
+ root = Path(project_root)
130
+
131
+ # Ensure the current Python environment is used by the thinkstack CLI.
132
+ env = dict(os.environ)
133
+ if sys.executable:
134
+ env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{env.get('PATH', '')}"
135
+
136
+ checks: list[dict] = []
137
+ overall_passed = True
138
+
139
+ for name, command in [
140
+ ("thinkstack doctor", ["thinkstack", "doctor"]),
141
+ ("thinkstack invariant check", ["thinkstack", "invariant", "check"]),
142
+ ]:
143
+ exit_code, stdout, stderr = _run_command(command, cwd=root, env=env)
144
+ passed = exit_code == 0
145
+ if not passed:
146
+ overall_passed = False
147
+
148
+ checks.append(
149
+ {
150
+ "name": name,
151
+ "passed": passed,
152
+ "exit_code": exit_code,
153
+ "output": stdout + stderr,
154
+ }
155
+ )
156
+
157
+ return PreCommitResult(passed=overall_passed, checks=checks)
@@ -0,0 +1,386 @@
1
+ """
2
+ Claude Code hook runner — `thinkstack hooks run {pre-tool-use,post-tool-use,session-end}`.
3
+
4
+ Claude Code passes a JSON payload on stdin. The hook must always exit 0
5
+ (approve); non-zero blocks Claude. All errors are swallowed.
6
+
7
+ Event types written to .GCC/events.log.jsonl:
8
+ PRE_TOOL_USE — before every tool call
9
+ POST_TOOL_USE — after every tool call
10
+ SESSION_END — when Claude Code stops
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # .GCC/ repo discovery
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def _find_repo() -> Optional[object]:
28
+ """Walk upward from cwd to find an initialised .GCC/ repo."""
29
+ from thinkstack_core import GCCRepository
30
+
31
+ for parent in [Path.cwd()] + list(Path.cwd().parents):
32
+ r = GCCRepository.at(parent)
33
+ if r.is_initialized():
34
+ return r
35
+ return None
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Stdin reader
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _read_stdin_json() -> dict:
44
+ try:
45
+ raw = sys.stdin.read()
46
+ if raw.strip():
47
+ return json.loads(raw)
48
+ except Exception:
49
+ pass
50
+ return {}
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Event appenders
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def _append_event(repo, event_type: str, payload: dict) -> None:
59
+ """Append a raw event to the repo's event log."""
60
+ try:
61
+ repo._append_event(event_type, payload)
62
+ except Exception:
63
+ # _append_event may not be public on all versions; fall back to
64
+ # writing directly to events.log.jsonl
65
+ try:
66
+ import fcntl
67
+ import hashlib
68
+ import time
69
+
70
+ log_path = Path(repo.gcc_dir) / "events.log.jsonl"
71
+ with log_path.open("a", encoding="utf-8") as f:
72
+ fcntl.flock(f, fcntl.LOCK_EX)
73
+ try:
74
+ prev_hash = "0" * 64
75
+ if log_path.exists():
76
+ lines = log_path.read_text(encoding="utf-8").strip().splitlines()
77
+ if lines:
78
+ last = json.loads(lines[-1])
79
+ prev_hash = last.get("hash", prev_hash)
80
+
81
+ entry = {
82
+ "type": event_type,
83
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
84
+ **payload,
85
+ }
86
+ entry_str = json.dumps(entry, sort_keys=True)
87
+ entry["hash"] = hashlib.sha256(
88
+ (prev_hash + entry_str).encode()
89
+ ).hexdigest()
90
+
91
+ f.write(json.dumps(entry) + "\n")
92
+ f.flush()
93
+ finally:
94
+ fcntl.flock(f, fcntl.LOCK_UN)
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Hook handlers
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def handle_pre_tool_use(payload: dict) -> None:
105
+ repo = _find_repo()
106
+ if repo is None:
107
+ return
108
+ _append_event(repo, "PRE_TOOL_USE", {
109
+ "tool_name": payload.get("tool_name", "unknown"),
110
+ "session_id": payload.get("session_id", ""),
111
+ })
112
+
113
+
114
+ def handle_post_tool_use(payload: dict) -> None:
115
+ repo = _find_repo()
116
+ if repo is None:
117
+ return
118
+
119
+ tool_name = payload.get("tool_name", "unknown")
120
+ tool_input = payload.get("tool_input", {})
121
+
122
+ event_payload: dict = {
123
+ "tool_name": tool_name,
124
+ "session_id": payload.get("session_id", ""),
125
+ }
126
+
127
+ # For file-writing tools, capture which file was touched
128
+ if tool_name in ("Write", "Edit", "NotebookEdit"):
129
+ file_path = tool_input.get("file_path", tool_input.get("notebook_path", ""))
130
+ if file_path:
131
+ event_payload["file_path"] = file_path
132
+
133
+ # For Bash, capture the command (truncated)
134
+ if tool_name == "Bash":
135
+ cmd = tool_input.get("command", "")
136
+ event_payload["command"] = cmd[:200]
137
+
138
+ _append_event(repo, "POST_TOOL_USE", event_payload)
139
+
140
+
141
+ def handle_session_end(payload: dict) -> None:
142
+ repo = _find_repo()
143
+ if repo is None:
144
+ return
145
+ _append_event(repo, "SESSION_END", {
146
+ "session_id": payload.get("session_id", ""),
147
+ "stop_hook_active": payload.get("stop_hook_active", False),
148
+ })
149
+
150
+ _run_session_end_sync(repo)
151
+
152
+
153
+ def _run_session_end_sync(repo) -> None:
154
+ """On session exit, run the auto-sync flow if configured.
155
+
156
+ 1. Commit remaining reasoning (final flush of learnings)
157
+ 2. Push learnings to org store if cloud storage is available
158
+ 3. Log the sync event
159
+ """
160
+ try:
161
+ from thinkstack_core.reasoning_plus.config import load_config
162
+ config, _ = load_config(repo.gcc_dir)
163
+ if not getattr(config, "auto_sync_on_stop", True):
164
+ return
165
+ except Exception:
166
+ pass
167
+
168
+ try:
169
+ from thinkstack_core.cloud.team_sync import push_learnings, pull_learnings
170
+ org_id = _get_org_id(repo)
171
+ if org_id:
172
+ push_learnings(org_id, repo.gcc_dir)
173
+ pull_learnings(org_id, repo.gcc_dir)
174
+ except Exception:
175
+ pass
176
+
177
+ _append_event(repo, "AUTO_SYNC_ON_STOP", {
178
+ "learnings_pushed": True,
179
+ "learnings_pulled": True,
180
+ })
181
+
182
+
183
+ def _get_org_id(repo) -> str | None:
184
+ """Extract org_id from the GCC repo or environment."""
185
+ try:
186
+ return getattr(repo, "org_id", None) or None
187
+ except Exception:
188
+ pass
189
+ import os
190
+ return os.environ.get("THINKSTACK_ORG_ID")
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Dispatch
195
+ # ---------------------------------------------------------------------------
196
+
197
+ _HANDLERS = {
198
+ "pre-tool-use": handle_pre_tool_use,
199
+ "post-tool-use": handle_post_tool_use,
200
+ "session-end": handle_session_end,
201
+ }
202
+
203
+
204
+ _CONTEXT_MAX_ENTRIES = 3
205
+ _CONTEXT_MAX_LINES = 40
206
+
207
+
208
+ # Selections larger than these budgets (lines AND characters — a single long
209
+ # line must not defeat the line count) are injected as a per-entry summary
210
+ # instead of verbatim, so a reasoning entry can never flood the model
211
+ # context. Small relevant selections keep their full detail.
212
+ _CONTEXT_FULL_LINE_BUDGET = 24
213
+ _CONTEXT_MAX_CHARS = 4000
214
+ _CONTEXT_SUMMARY_BODY_CHARS = 160
215
+
216
+ _PROMPT_STOPWORDS = frozenset(
217
+ """
218
+ the and for was were with that this from are but not you your our can
219
+ could should would about into over under what when where which who whom
220
+ why how did does doing done have has had will just like make made use
221
+ used using them they their there then than some any all out get got new
222
+ old one two per off via please context
223
+ """.split()
224
+ )
225
+
226
+
227
+ def _keywords(text: str) -> set:
228
+ """Lowercased alphanumeric tokens (3+ chars) minus stopwords."""
229
+ import re
230
+
231
+ return {
232
+ t
233
+ for t in re.findall(r"[a-z0-9][a-z0-9_\-]{2,}", text.lower())
234
+ if t not in _PROMPT_STOPWORDS
235
+ }
236
+
237
+
238
+ def _handle_user_prompt_submit(payload: dict) -> None:
239
+ """Print reasoning context for the incoming prompt; stdout becomes model
240
+ context in Claude Code. Reads log.md directly — no heavy imports.
241
+
242
+ Selection is relevance-first: entries are scored by keyword overlap with
243
+ the prompt text and only matching entries are injected. When the prompt
244
+ yields no matches (or no prompt text is available), it falls back to the
245
+ most recent entries. Large selections are compressed to per-entry
246
+ summaries so injection cost stays bounded.
247
+ """
248
+ repo = _find_repo()
249
+ if repo is None:
250
+ return
251
+ try:
252
+ text = (Path(repo.gcc_dir) / "log.md").read_text(encoding="utf-8")
253
+ except OSError:
254
+ return
255
+ # log.md entries start with "## <header>"; the leading file header has
256
+ # no "## " prefix. Split on "\n## " to isolate individual entries.
257
+ blocks = text.split("\n## ")
258
+ entries = [("## " + b).strip() for b in blocks[1:]] if len(blocks) > 1 else []
259
+ if not entries:
260
+ return
261
+
262
+ prompt_kws = _keywords(str(payload.get("prompt") or ""))
263
+ selected: list = []
264
+ kind = "Recent"
265
+ if prompt_kws:
266
+ # Score against entry bodies only: every heading contains
267
+ # "Commit at <timestamp>", so heading tokens ("commit", the year)
268
+ # would otherwise mark every entry relevant to date-ish prompts.
269
+ # Normalize by sqrt(|prompt_kws|) so a verbose prompt does not
270
+ # drown out a short, targeted one when both match the same entries.
271
+ _norm = max(1.0, len(prompt_kws) ** 0.5)
272
+ scored = [
273
+ (len(prompt_kws & _keywords("\n".join(entry.splitlines()[1:]))) / _norm, idx, entry)
274
+ for idx, entry in enumerate(entries)
275
+ ]
276
+ scored = [s for s in scored if s[0] > 0]
277
+ if scored:
278
+ # Highest keyword overlap first; ties broken by recency.
279
+ scored.sort(key=lambda s: (-s[0], -s[1]))
280
+ selected = [entry for _, _, entry in scored[:_CONTEXT_MAX_ENTRIES]]
281
+ kind = "Relevant"
282
+ if not selected:
283
+ selected = entries[-_CONTEXT_MAX_ENTRIES:]
284
+
285
+ body_lines = [line for entry in selected for line in entry.splitlines()]
286
+ selection_chars = sum(len(line) for line in body_lines)
287
+ if (
288
+ len(body_lines) <= _CONTEXT_FULL_LINE_BUDGET
289
+ and selection_chars <= _CONTEXT_MAX_CHARS
290
+ ):
291
+ lines = [f"[ThinkStack] {kind} recorded reasoning (shared across agents):"]
292
+ lines.extend(body_lines)
293
+ else:
294
+ # Too much to inject verbatim — emit a compact per-entry summary:
295
+ # the entry heading plus its first body line, both truncated.
296
+ lines = [f"[ThinkStack] {kind} context summary (shared across agents):"]
297
+ for entry in selected:
298
+ entry_lines = entry.splitlines()
299
+ lines.append(entry_lines[0][:_CONTEXT_SUMMARY_BODY_CHARS])
300
+ first_body = next(
301
+ (ln.strip() for ln in entry_lines[1:] if ln.strip()), ""
302
+ )
303
+ if first_body:
304
+ lines.append(" " + first_body[:_CONTEXT_SUMMARY_BODY_CHARS])
305
+ # Hard cap as the final guarantee, whatever mode produced the lines.
306
+ print("\n".join(lines[:_CONTEXT_MAX_LINES])[:_CONTEXT_MAX_CHARS])
307
+
308
+
309
+ def _handle_open_code_pre_prompt() -> None:
310
+ """Print a bounded context bundle (main.md + log.md + theta.json + recent
311
+ commits) so the model sees prior reasoning on a model switch. Uses the
312
+ same GCCRepository.context_bundle() path as the MCP thinkstack_context
313
+ tool, but stays inside the fast hook runner (~0.2s instead of ~1s for
314
+ the full CLI)."""
315
+ repo = _find_repo()
316
+ if repo is None:
317
+ return
318
+ try:
319
+ bundle = repo.context_bundle(k_tokens=2000)
320
+ except Exception:
321
+ return
322
+ artifacts = bundle.get("artifacts", [])
323
+ if not artifacts:
324
+ return
325
+ lines = [f"[ThinkStack] Context bundle (budget=2000 tokens, {len(artifacts)} artifacts):"]
326
+ for art in artifacts:
327
+ name = art.get("name", art.get("path", art.get("id", "unknown")))
328
+ reason = art.get("reason", "")
329
+ tokens = art.get("tokens", art.get("approx_tokens", "?"))
330
+ lines.append(f" - {name} ({tokens} tokens): {reason}")
331
+ print("\n".join(lines))
332
+
333
+
334
+ def run(hook_name: str) -> None:
335
+ """
336
+ Entry point called by `thinkstack hooks run <hook_name>`.
337
+
338
+ Reads JSON from stdin, dispatches to the right handler, always exits 0.
339
+ """
340
+ if os.environ.get("THINKSTACK_DISABLE"):
341
+ return
342
+
343
+ payload = _read_stdin_json()
344
+
345
+ if hook_name == "open-code-pre-prompt":
346
+ try:
347
+ _handle_open_code_pre_prompt()
348
+ except Exception:
349
+ pass
350
+ return
351
+
352
+ if hook_name == "user-prompt-submit":
353
+ try:
354
+ _handle_user_prompt_submit(payload)
355
+ except Exception:
356
+ pass # never interfere with Claude Code
357
+ return
358
+
359
+ handler = _HANDLERS.get(hook_name)
360
+ if handler:
361
+ try:
362
+ handler(payload)
363
+ except Exception:
364
+ pass # never interfere with Claude Code
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Console entry point (fast path — must not import thinkstack_cli)
369
+ # ---------------------------------------------------------------------------
370
+
371
+
372
+ def cli(argv: list[str] | None = None) -> int:
373
+ """Entry point for the `thinkstack-hook` console script.
374
+
375
+ Equivalent to `thinkstack hooks run <name>` but skips the full CLI import
376
+ chain (~0.9s), which matters because IDE hooks run on every tool call.
377
+ Always returns 0 — a non-zero hook exit blocks the IDE.
378
+ """
379
+ args = argv if argv is not None else sys.argv[1:]
380
+ if not args:
381
+ return 0
382
+ try:
383
+ run(args[0])
384
+ except Exception:
385
+ pass
386
+ return 0
@@ -0,0 +1,4 @@
1
+ from .agent import AgentIdentity, AgentRegistry
2
+ from .providers import resolve_agent_identity
3
+
4
+ __all__ = ["AgentIdentity", "AgentRegistry", "resolve_agent_identity"]
@@ -0,0 +1,86 @@
1
+ """
2
+ Sprint 18 (S18) — Agent Identity substrate.
3
+
4
+ AgentIdentity names a reasoning agent; AgentRegistry persists identities as an
5
+ append-only JSONL file under a configurable directory so any .GCC/ can maintain
6
+ a record of which agents have contributed to the project.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import json
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+
16
+ @dataclasses.dataclass
17
+ class AgentIdentity:
18
+ """Named identity for a reasoning agent with scoped visibility."""
19
+
20
+ agent_id: str
21
+ name: str
22
+ provider: str # "native" | "bedrock" | "azure" | "anonymous"
23
+ scope: str # "branch" | "session" | "org"
24
+ metadata: dict = dataclasses.field(default_factory=dict)
25
+
26
+ def to_dict(self) -> dict:
27
+ return dataclasses.asdict(self)
28
+
29
+ @classmethod
30
+ def from_dict(cls, d: dict) -> "AgentIdentity":
31
+ return cls(
32
+ agent_id=d["agent_id"],
33
+ name=d["name"],
34
+ provider=d["provider"],
35
+ scope=d["scope"],
36
+ metadata=d.get("metadata", {}),
37
+ )
38
+
39
+
40
+ _REGISTRY_FILE = "agent_registry.jsonl"
41
+
42
+
43
+ class AgentRegistry:
44
+ """Append-only JSONL registry of known agent identities."""
45
+
46
+ def __init__(self, registry_dir: Path) -> None:
47
+ self._dir = registry_dir
48
+ self._dir.mkdir(parents=True, exist_ok=True)
49
+ self._path = self._dir / _REGISTRY_FILE
50
+
51
+ def register(self, identity: AgentIdentity) -> None:
52
+ existing = self.get(identity.agent_id)
53
+ if existing is not None:
54
+ return # idempotent
55
+ with self._path.open("a", encoding="utf-8") as f:
56
+ f.write(json.dumps(identity.to_dict()) + "\n")
57
+
58
+ def get(self, agent_id: str) -> Optional[AgentIdentity]:
59
+ if not self._path.exists():
60
+ return None
61
+ for line in self._path.read_text(encoding="utf-8").splitlines():
62
+ line = line.strip()
63
+ if not line:
64
+ continue
65
+ try:
66
+ d = json.loads(line)
67
+ if d.get("agent_id") == agent_id:
68
+ return AgentIdentity.from_dict(d)
69
+ except json.JSONDecodeError:
70
+ continue
71
+ return None
72
+
73
+ def list_all(self) -> List[AgentIdentity]:
74
+ if not self._path.exists():
75
+ return []
76
+ seen: dict[str, AgentIdentity] = {}
77
+ for line in self._path.read_text(encoding="utf-8").splitlines():
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ d = json.loads(line)
83
+ seen[d["agent_id"]] = AgentIdentity.from_dict(d)
84
+ except (json.JSONDecodeError, KeyError):
85
+ continue
86
+ return list(seen.values())