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,183 @@
1
+ """
2
+ thinkstack_core.cloud.sync_conflicts
3
+ ====================================
4
+ Append-only, hash-chained conflict log for sync conflicts.
5
+
6
+ Each conflict entry records what was found on both sides, what resolution
7
+ was applied, and whether it has been manually reviewed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ from collections.abc import Sequence
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger("thinkstack.cloud.sync_conflicts")
20
+
21
+ LEARNING_CONFLICT_EVENT = "LEARNING_CONFLICT"
22
+ _CONFLICTS_FILE = "sync_conflicts.jsonl"
23
+
24
+
25
+ class ConflictLog:
26
+ """Append, list, and resolve sync conflicts."""
27
+
28
+ def __init__(self, gcc_dir: Path | str) -> None:
29
+ self._gcc_dir = Path(gcc_dir)
30
+ self._path = self._gcc_dir / _CONFLICTS_FILE
31
+
32
+ def append(self, conflict: dict) -> str:
33
+ """Append a conflict entry (hash-chained). Returns conflict_id.
34
+
35
+ Also emits a ``{LEARNING_CONFLICT_EVENT}`` event to ``.GCC/events.log.jsonl``
36
+ so the sprint-writer and dashboard can track learning sync conflicts.
37
+ """
38
+ entry = dict(conflict)
39
+ if "conflict_id" not in entry:
40
+ from uuid import uuid4
41
+ entry["conflict_id"] = f"conf_{uuid4().hex[:12]}"
42
+ entry["timestamp"] = entry.get("timestamp", _now_iso())
43
+
44
+ last_hash = self._last_hash()
45
+ prior = last_hash or "null"
46
+ entry["prior_hash"] = prior
47
+ entry["hash"] = _compute_hash(json.dumps(entry, separators=(",", ":")))
48
+
49
+ self._gcc_dir.mkdir(parents=True, exist_ok=True)
50
+ with open(str(self._path), "a", encoding="utf-8") as f:
51
+ f.write(json.dumps(entry, separators=(",", ":")) + "\n")
52
+
53
+ self._emit_conflict_event(entry)
54
+ return entry["conflict_id"]
55
+
56
+ def _emit_conflict_event(self, entry: dict) -> None:
57
+ """Write a learning conflict event to .GCC/events.log.jsonl."""
58
+ events_path = self._gcc_dir / "events.log.jsonl"
59
+ try:
60
+ event = {
61
+ "type": LEARNING_CONFLICT_EVENT,
62
+ "conflict_id": entry.get("conflict_id", ""),
63
+ "severity": entry.get("severity", "medium"),
64
+ "data_type": entry.get("data_type", "unknown"),
65
+ "item_id": entry.get("item_id", ""),
66
+ "resolution": entry.get("resolution", ""),
67
+ "timestamp": _now_iso(),
68
+ }
69
+ with open(str(events_path), "a", encoding="utf-8") as f:
70
+ f.write(json.dumps(event, separators=(",", ":")) + "\n")
71
+ except Exception as exc:
72
+ logger.warning("thinkstack: failed to emit conflict event — %s", exc)
73
+
74
+ def list_unresolved(self) -> list[dict]:
75
+ """Return all unresolved conflicts."""
76
+ return [c for c in self._read_all() if not c.get("resolved")]
77
+
78
+ def list_all(self) -> list[dict]:
79
+ """Return all conflicts (resolved + unresolved)."""
80
+ return self._read_all()
81
+
82
+ def resolve(self, conflict_id: str, use_remote: bool = False, dismiss: bool = False) -> dict | None:
83
+ """Mark a conflict as resolved. If *use_remote*, returns the remote_value for application.
84
+
85
+ Returns the conflict dict (with resolution fields) if found, or None if not found.
86
+ """
87
+ all_entries = self._read_all()
88
+ found = None
89
+ for entry in all_entries:
90
+ if entry.get("conflict_id") == conflict_id:
91
+ entry["resolved"] = True
92
+ entry["resolved_at"] = _now_iso()
93
+ if use_remote:
94
+ entry["resolution"] = "used_remote"
95
+ elif dismiss:
96
+ entry["resolution"] = "dismissed"
97
+ else:
98
+ entry["resolution"] = entry.get("resolution", "kept_local")
99
+ found = entry
100
+ break
101
+
102
+ if found is None:
103
+ return None
104
+
105
+ self._rewrite_all(all_entries)
106
+ if use_remote:
107
+ return found
108
+ return found
109
+
110
+ def count_unresolved(self) -> int:
111
+ """Return count of unresolved conflicts."""
112
+ return len(self.list_unresolved())
113
+
114
+ def summary(self) -> str:
115
+ """Return a formatted summary for the ``thinkstack today`` dashboard."""
116
+ unresolved = self.list_unresolved()
117
+ if not unresolved:
118
+ return ""
119
+ high = sum(1 for c in unresolved if c.get("severity") == "high")
120
+ medium = sum(1 for c in unresolved if c.get("severity") == "medium")
121
+ parts = [f" \u26a0 {len(unresolved)} unresolved conflict(s)"]
122
+ for c in unresolved[:5]:
123
+ parts.append(f" [{c.get('severity','?')}] {c.get('type','?')} — {c.get('item_id','?')}")
124
+ if len(unresolved) > 5:
125
+ parts.append(f" ... and {len(unresolved) - 5} more")
126
+ parts.append(" Run 'thinkstack sync conflicts' for details.")
127
+ return "\n".join(["─── Sync conflicts ───"] + parts)
128
+
129
+ def _read_all(self) -> list[dict]:
130
+ if not self._path.exists():
131
+ return []
132
+ entries: list[dict] = []
133
+ try:
134
+ for line in self._path.read_text(encoding="utf-8").splitlines():
135
+ if not line.strip():
136
+ continue
137
+ try:
138
+ entries.append(json.loads(line))
139
+ except json.JSONDecodeError:
140
+ continue
141
+ except OSError:
142
+ pass
143
+ return entries
144
+
145
+ def _rewrite_all(self, entries: Sequence[dict]) -> None:
146
+ content = "\n".join(json.dumps(e, separators=(",", ":")) for e in entries) + "\n"
147
+ _atomic_write(self._path, content)
148
+
149
+ def _last_hash(self) -> str | None:
150
+ if not self._path.exists():
151
+ return None
152
+ try:
153
+ last_line = ""
154
+ with open(str(self._path), "r", encoding="utf-8") as f:
155
+ for line in f:
156
+ if line.strip():
157
+ last_line = line.strip()
158
+ if last_line:
159
+ return json.loads(last_line).get("hash")
160
+ except (OSError, json.JSONDecodeError):
161
+ pass
162
+ return None
163
+
164
+
165
+ def _now_iso() -> str:
166
+ return datetime.now(timezone.utc).isoformat()
167
+
168
+
169
+ def _compute_hash(data: str) -> str:
170
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()[:16]
171
+
172
+
173
+ def _atomic_write(path: Path, content: str) -> None:
174
+ tmp = path.with_suffix(path.suffix + ".tmp")
175
+ try:
176
+ tmp.write_text(content, encoding="utf-8")
177
+ tmp.replace(path)
178
+ finally:
179
+ if tmp.exists():
180
+ try:
181
+ tmp.unlink()
182
+ except Exception:
183
+ pass
@@ -0,0 +1,159 @@
1
+ """
2
+ thinkstack_core.cloud.sync_state
3
+ ===============================
4
+ Read, update, and persist sync state at .GCC/sync_state.json.
5
+
6
+ Tracks what has been synced between local .GCC/ and the MCP server for
7
+ each data type (commits, theta, learnings, sensitivities, topics).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger("thinkstack.cloud.sync_state")
18
+
19
+
20
+ class SyncState:
21
+ """Read, update, and persist sync state at .GCC/sync_state.json."""
22
+
23
+ def __init__(self, gcc_dir: Path | str) -> None:
24
+ self._gcc_dir = Path(gcc_dir)
25
+ self._path = self._gcc_dir / "sync_state.json"
26
+
27
+ def load(self) -> dict:
28
+ """Load sync state, returning empty state if file doesn't exist."""
29
+ if not self._path.exists():
30
+ return self._empty_state()
31
+ try:
32
+ data = json.loads(self._path.read_text(encoding="utf-8"))
33
+ if not isinstance(data, dict):
34
+ return self._empty_state()
35
+ return data
36
+ except (json.JSONDecodeError, OSError):
37
+ return self._empty_state()
38
+
39
+ def save(self, state: dict) -> None:
40
+ """Persist sync state atomically."""
41
+ self._gcc_dir.mkdir(parents=True, exist_ok=True)
42
+ state["last_sync_at"] = _now_iso()
43
+ _atomic_write(self._path, json.dumps(state, indent=2) + "\n")
44
+
45
+ def update_after_pull(self, state: dict, pull_result: dict) -> dict:
46
+ """Update cursors after a successful pull."""
47
+ server_state = pull_result.get("server_state", {})
48
+
49
+ commits_state = state.setdefault("commits", {})
50
+ server_commits = server_state.get("commits", {})
51
+ commits_state["remote_head"] = server_commits.get("head", "0")
52
+ synced_ids = set(commits_state.get("synced_ids", []))
53
+ for c in pull_result.get("delta", {}).get("commits", []):
54
+ cid = c.get("id")
55
+ if cid:
56
+ synced_ids.add(cid)
57
+ commits_state["synced_ids"] = sorted(synced_ids)
58
+
59
+ state["theta"] = server_state.get("theta", {})
60
+
61
+ learnings_state = state.setdefault("learnings", {})
62
+ server_learnings = server_state.get("learnings", {})
63
+ synced_lids = set(learnings_state.get("synced_ids", []))
64
+ for l in pull_result.get("delta", {}).get("learnings", []):
65
+ lid = l.get("id")
66
+ if lid:
67
+ synced_lids.add(lid)
68
+ learnings_state["synced_ids"] = sorted(synced_lids)
69
+ learnings_state["synced_count"] = len(synced_lids)
70
+
71
+ sensitivities_state = state.setdefault("sensitivities", {})
72
+ sensitivities_state["last_synced_ts"] = server_state.get("sensitivities", {}).get("last_synced_ts", "")
73
+
74
+ topics_state = state.setdefault("topics", {})
75
+ synced_topics = set(topics_state.get("synced_custom_names", []))
76
+ for name in server_state.get("topics", {}).get("synced_custom_names", []):
77
+ synced_topics.add(name)
78
+ topics_state["synced_custom_names"] = sorted(synced_topics)
79
+
80
+ return state
81
+
82
+ def update_after_push(self, state: dict, push_result: dict, server_state: dict) -> dict:
83
+ """Update cursors after a successful push."""
84
+ commits_state = state.setdefault("commits", {})
85
+ synced_ids = set(commits_state.get("synced_ids", []))
86
+ for c in push_result.get("delta", {}).get("commits", []):
87
+ cid = c.get("id")
88
+ if cid:
89
+ synced_ids.add(cid)
90
+ commits_state["synced_ids"] = sorted(synced_ids)
91
+ commits_state["remote_head"] = server_state.get("commits", {}).get("head", "0")
92
+
93
+ learnings_state = state.setdefault("learnings", {})
94
+ synced_lids = set(learnings_state.get("synced_ids", []))
95
+ for l in push_result.get("delta", {}).get("learnings", []):
96
+ lid = l.get("id")
97
+ if lid:
98
+ synced_lids.add(lid)
99
+ learnings_state["synced_ids"] = sorted(synced_lids)
100
+ learnings_state["synced_count"] = len(synced_lids)
101
+
102
+ state["theta"] = server_state.get("theta", state.get("theta", {}))
103
+
104
+ sensitivities_state = state.setdefault("sensitivities", {})
105
+ sensitivities_state["last_synced_ts"] = server_state.get("sensitivities", {}).get("last_synced_ts", "")
106
+
107
+ topics_state = state.setdefault("topics", {})
108
+ synced_topics = set(topics_state.get("synced_custom_names", []))
109
+ for name in server_state.get("topics", {}).get("synced_custom_names", []):
110
+ synced_topics.add(name)
111
+ topics_state["synced_custom_names"] = sorted(synced_topics)
112
+
113
+ return state
114
+
115
+ def get_remote_state_view(self, state: dict) -> dict:
116
+ """Return the subset of state representing what the remote has."""
117
+ commits_state = state.get("commits", {})
118
+ return {
119
+ "commits": {
120
+ "synced_ids": commits_state.get("synced_ids", []),
121
+ "remote_head": commits_state.get("remote_head", "0"),
122
+ },
123
+ "theta": state.get("theta", {}),
124
+ "learnings": {"synced_ids": state.get("learnings", {}).get("synced_ids", [])},
125
+ "sensitivities": {"last_synced_ts": state.get("sensitivities", {}).get("last_synced_ts", "")},
126
+ "topics": {"synced_custom_names": state.get("topics", {}).get("synced_custom_names", [])},
127
+ }
128
+
129
+ def reset(self, state: dict) -> dict:
130
+ """Reset sync state to empty (triggers full re-sync on next sync)."""
131
+ return self._empty_state()
132
+
133
+ def _empty_state(self) -> dict:
134
+ return {
135
+ "version": "1",
136
+ "last_sync_at": "",
137
+ "commits": {"synced_ids": [], "remote_head": "0"},
138
+ "theta": {},
139
+ "learnings": {"synced_ids": [], "synced_count": 0},
140
+ "sensitivities": {"last_synced_ts": ""},
141
+ "topics": {"synced_custom_names": []},
142
+ }
143
+
144
+
145
+ def _now_iso() -> str:
146
+ return datetime.now(timezone.utc).isoformat()
147
+
148
+
149
+ def _atomic_write(path: Path, content: str) -> None:
150
+ tmp = path.with_suffix(path.suffix + ".tmp")
151
+ try:
152
+ tmp.write_text(content, encoding="utf-8")
153
+ tmp.replace(path)
154
+ finally:
155
+ if tmp.exists():
156
+ try:
157
+ tmp.unlink()
158
+ except Exception:
159
+ pass
@@ -0,0 +1,337 @@
1
+ """Team-level Θ (coordination vector) synchronization.
2
+
3
+ Provides ``push_theta`` and ``pull_theta`` primitives that store the org-level
4
+ Θ in the same cloud storage backend used by the ThinkStack MCP server. Each repo
5
+ pushes its local ``.GCC/theta.json`` to ``team_theta/<org_id>.json`` and pulls
6
+ the remote org Θ back, merging it with the local copy using the same
7
+ ``merge_theta`` logic used by the REP network.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from thinkstack_core.rep_network.merge import merge_theta
18
+ from thinkstack_core.storage import create_backend_from_env
19
+ from thinkstack_core.theta import THETA_FILE
20
+
21
+ if TYPE_CHECKING:
22
+ from thinkstack_core.storage import StorageBackend
23
+
24
+ TEAM_THETA_PREFIX = "team_theta"
25
+
26
+
27
+ def _theta_remote_key(org_id: str) -> str:
28
+ return f"{TEAM_THETA_PREFIX}/{org_id}.json"
29
+
30
+
31
+ def _load_local_theta(gcc_dir: Path) -> dict:
32
+ path = gcc_dir / THETA_FILE
33
+ if not path.exists():
34
+ return {"version": 1, "updated_at": None, "coordination_vector": {}}
35
+ try:
36
+ return json.loads(path.read_text(encoding="utf-8"))
37
+ except (json.JSONDecodeError, OSError):
38
+ return {"version": 1, "updated_at": None, "coordination_vector": {}}
39
+
40
+
41
+ def _save_local_theta(gcc_dir: Path, theta: dict) -> None:
42
+ path = gcc_dir / THETA_FILE
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_text(json.dumps(theta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
45
+
46
+
47
+ def _get_backend(org_id: str) -> "StorageBackend":
48
+ return create_backend_from_env(prefix=org_id)
49
+
50
+
51
+ def push_theta(org_id: str, gcc_dir: str | Path) -> dict:
52
+ """Push the local ``.GCC/theta.json`` to the org's team Θ store.
53
+
54
+ Uses read-merge-write to avoid destructive overwrite of concurrent pushes.
55
+ Local theta is merged into the remote store using the same ``merge_theta``
56
+ logic used by ``pull_theta``, so concurrent pushes from different repos
57
+ do not lose data.
58
+
59
+ Args:
60
+ org_id: ThinkStack org ID.
61
+ gcc_dir: Path to the local ``.GCC/`` directory.
62
+
63
+ Returns:
64
+ A status dict with the number of concepts pushed.
65
+ """
66
+ backend = _get_backend(org_id)
67
+ local_path = Path(gcc_dir)
68
+ local_theta = _load_local_theta(local_path)
69
+ remote_key = _theta_remote_key(org_id)
70
+
71
+ try:
72
+ remote_bytes = backend.read_bytes(remote_key)
73
+ remote_theta = json.loads(remote_bytes.decode("utf-8"))
74
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
75
+ remote_theta = {"version": 1, "updated_at": None, "coordination_vector": {}}
76
+
77
+ merged = merge_theta(local_theta, remote_theta)
78
+ backend.write_bytes(
79
+ remote_key,
80
+ json.dumps(merged, indent=2, sort_keys=True).encode("utf-8"),
81
+ )
82
+ concepts = len(local_theta.get("coordination_vector", {}))
83
+ return {
84
+ "org_id": org_id,
85
+ "remote_key": remote_key,
86
+ "concepts_pushed": concepts,
87
+ }
88
+
89
+
90
+ def pull_theta(org_id: str, gcc_dir: str | Path) -> dict:
91
+ """Pull the org's team Θ and merge it into the local ``.GCC/theta.json``.
92
+
93
+ Args:
94
+ org_id: ThinkStack org ID.
95
+ gcc_dir: Path to the local ``.GCC/`` directory.
96
+
97
+ Returns:
98
+ A status dict with the number of concepts before and after the merge.
99
+ """
100
+ backend = _get_backend(org_id)
101
+ local_path = Path(gcc_dir)
102
+ local_theta = _load_local_theta(local_path)
103
+ remote_key = _theta_remote_key(org_id)
104
+
105
+ try:
106
+ remote_bytes = backend.read_bytes(remote_key)
107
+ except FileNotFoundError:
108
+ remote_theta = {"version": 1, "updated_at": None, "coordination_vector": {}}
109
+ else:
110
+ remote_theta = json.loads(remote_bytes.decode("utf-8"))
111
+
112
+ before = len(local_theta.get("coordination_vector", {}))
113
+ merged = merge_theta(local_theta, remote_theta)
114
+ _save_local_theta(local_path, merged)
115
+ after = len(merged.get("coordination_vector", {}))
116
+ return {
117
+ "org_id": org_id,
118
+ "remote_key": remote_key,
119
+ "concepts_before": before,
120
+ "concepts_after": after,
121
+ "concepts_added": after - before,
122
+ }
123
+
124
+
125
+ # ------------------------------------------------------------------
126
+ # Cross-project learning sync
127
+ # ------------------------------------------------------------------
128
+
129
+ TEAM_LEARNINGS_PREFIX = "team_learnings"
130
+
131
+
132
+ def _learnings_remote_key(org_id: str) -> str:
133
+ return f"{TEAM_LEARNINGS_PREFIX}/{org_id}.json"
134
+
135
+
136
+ def _load_local_learnings(gcc_dir: Path) -> list[dict]:
137
+ """Load and serialize all learnings from a local .GCC/ into dicts."""
138
+ from thinkstack_core.reasoning_plus.learning.store import LearningStore
139
+ store = LearningStore(gcc_dir)
140
+ return [l.to_dict() for l in store.list()]
141
+
142
+
143
+ def _save_remote_learnings(
144
+ backend: "StorageBackend",
145
+ remote_key: str,
146
+ learnings: list[dict],
147
+ ) -> None:
148
+ """Atomically write learnings to the remote key.
149
+
150
+ Each learning dict carries a ``_source_gcc_dir`` field to track origin.
151
+ """
152
+ data = {
153
+ "version": "1",
154
+ "updated_at": _now_iso(),
155
+ "learnings": learnings,
156
+ }
157
+ backend.write_bytes(
158
+ remote_key,
159
+ json.dumps(data, indent=2).encode("utf-8"),
160
+ )
161
+
162
+
163
+ def _load_remote_learnings(
164
+ backend: "StorageBackend",
165
+ remote_key: str,
166
+ ) -> list[dict]:
167
+ """Load learnings from the remote key, returning a list of dicts."""
168
+ try:
169
+ raw_bytes = backend.read_bytes(remote_key)
170
+ except FileNotFoundError:
171
+ return []
172
+ try:
173
+ data = json.loads(raw_bytes.decode("utf-8"))
174
+ return data.get("learnings", [])
175
+ except (json.JSONDecodeError, OSError, KeyError):
176
+ return []
177
+
178
+
179
+ def _merge_learnings(
180
+ local: list[dict],
181
+ remote: list[dict],
182
+ ) -> list[dict]:
183
+ """Merge remote learnings into local, keyed by learning ID.
184
+
185
+ Remote learnings with IDs not present locally are added.
186
+ Remote learnings with IDs already present are skipped (local wins).
187
+ """
188
+ local_ids = {l.get("id") for l in local if l.get("id")}
189
+ merged = list(local)
190
+ for rl in remote:
191
+ rid = rl.get("id")
192
+ if rid and rid not in local_ids:
193
+ merged.append(rl)
194
+ local_ids.add(rid)
195
+ return merged
196
+
197
+
198
+ def _emit_sync_new_data_event(local_path: Path, local_learnings: list[dict]) -> None:
199
+ """Emit a SYNC_NEW_DATA event to .GCC/events.log.jsonl for cross-agent notification."""
200
+ events_path = local_path / "events.log.jsonl"
201
+ try:
202
+ event = {
203
+ "type": "SYNC_NEW_DATA",
204
+ "learnings_pushed": len(local_learnings),
205
+ "agent_id": local_path.name,
206
+ "timestamp": _now_iso(),
207
+ }
208
+ with open(str(events_path), "a", encoding="utf-8") as f:
209
+ f.write(json.dumps(event, separators=(",", ":")) + "\n")
210
+ except Exception as exc:
211
+ logger.warning("thinkstack: failed to emit sync event — %s", exc)
212
+
213
+
214
+ def push_learnings(
215
+ org_id: str,
216
+ gcc_dir: str | Path,
217
+ *,
218
+ skip_private: bool = True,
219
+ skip_branch: bool = True,
220
+ ) -> dict:
221
+ """Push local learnings to the org's team learning store.
222
+
223
+ Args:
224
+ org_id: ThinkStack org ID.
225
+ gcc_dir: Path to the local .GCC/ directory.
226
+ skip_private: Exclude PRIVATE-disclosure learnings from push.
227
+ skip_branch: Exclude branch-scoped learnings from push.
228
+
229
+ Returns:
230
+ A status dict with the number of learnings pushed.
231
+ """
232
+ from thinkstack_core.reasoning_plus.learning.models import Learning
233
+
234
+ backend = _get_backend(org_id)
235
+ local_path = Path(gcc_dir)
236
+ local_learnings_raw = _load_local_learnings(local_path)
237
+
238
+ local_learnings: list[dict] = []
239
+ for l in local_learnings_raw:
240
+ if skip_private and l.get("sensitivity") == "PRIVATE":
241
+ continue
242
+ scope = l.get("scope", "project")
243
+ if skip_branch and scope == "branch":
244
+ continue
245
+ l["_source_gcc_dir"] = str(local_path)
246
+ l.setdefault("scope", "project")
247
+ l.setdefault("sensitivity", "PUBLIC")
248
+ local_learnings.append(l)
249
+
250
+ remote_learnings = _load_remote_learnings(backend, _learnings_remote_key(org_id))
251
+ merged = _merge_learnings(remote_learnings, local_learnings)
252
+ _save_remote_learnings(backend, _learnings_remote_key(org_id), merged)
253
+
254
+ _emit_sync_new_data_event(local_path, local_learnings)
255
+
256
+ return {
257
+ "org_id": org_id,
258
+ "remote_key": _learnings_remote_key(org_id),
259
+ "learnings_pushed": len(local_learnings),
260
+ "learnings_total_remote": len(merged),
261
+ "learnings_skipped": len(local_learnings_raw) - len(local_learnings),
262
+ }
263
+
264
+
265
+ def pull_learnings(
266
+ org_id: str,
267
+ gcc_dir: str | Path,
268
+ *,
269
+ min_confidence: float = 0.3,
270
+ allowed_scope: str | None = None,
271
+ ) -> dict:
272
+ """Pull team learnings from the org store and add new ones to the local store.
273
+
274
+ Remote learnings that do not exist locally (by ID) are saved.
275
+ Remote learnings with IDs already present are skipped (local wins).
276
+
277
+ Args:
278
+ org_id: ThinkStack org ID.
279
+ gcc_dir: Path to the local .GCC/ directory.
280
+ min_confidence: Minimum confidence threshold for incoming learnings.
281
+ allowed_scope: Only accept learnings with this scope (e.g. "org").
282
+
283
+ Returns:
284
+ A status dict with the number of learnings added locally.
285
+ """
286
+ from thinkstack_core.reasoning_plus.learning.models import Learning
287
+ from thinkstack_core.reasoning_plus.learning.store import LearningStore
288
+
289
+ backend = _get_backend(org_id)
290
+ local_path = Path(gcc_dir)
291
+ store = LearningStore(local_path)
292
+ local_learnings = store.list()
293
+ local_ids = {l.id for l in local_learnings}
294
+
295
+ remote_learnings = _load_remote_learnings(backend, _learnings_remote_key(org_id))
296
+ added = 0
297
+ for rl in remote_learnings:
298
+ rid = rl.get("id")
299
+ if rid and rid not in local_ids:
300
+ if min_confidence > 0 and rl.get("confidence", 1.0) < min_confidence:
301
+ continue
302
+ if allowed_scope and rl.get("scope") != allowed_scope:
303
+ continue
304
+ try:
305
+ learning = Learning.from_dict(rl)
306
+ store.save(learning)
307
+ local_ids.add(rid)
308
+ added += 1
309
+ except Exception as exc:
310
+ logger.warning("thinkstack: failed to save remote learning %s — %s", rid, exc)
311
+
312
+ return {
313
+ "org_id": org_id,
314
+ "remote_key": _learnings_remote_key(org_id),
315
+ "learnings_added": added,
316
+ "learnings_total_local": len(local_learnings) + added,
317
+ "learnings_skipped_confidence": sum(
318
+ 1 for rl in remote_learnings
319
+ if rl.get("id") and rl.get("id") not in local_ids and rl.get("confidence", 1.0) < min_confidence
320
+ ),
321
+ }
322
+
323
+
324
+ def _now_iso() -> str:
325
+ from datetime import datetime, timezone
326
+ return datetime.now(timezone.utc).isoformat()
327
+
328
+
329
+ def setup_team_sync_env(org_id: str, backend: "StorageBackend" | None = None) -> None:
330
+ """Ensure the process environment points at the cloud backend for this org.
331
+
332
+ This is mainly a convenience for CLI tests; the backend factory is normally
333
+ driven by ``THINKSTACK_STORAGE`` and friends.
334
+ """
335
+ os.environ.setdefault("THINKSTACK_ORG_ID", org_id)
336
+ if backend is None:
337
+ os.environ.setdefault("THINKSTACK_STORAGE", "r2")