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.
- thinkstack_core/__init__.py +158 -0
- thinkstack_core/aggphi_textual.py +275 -0
- thinkstack_core/alerts/__init__.py +23 -0
- thinkstack_core/alerts/base.py +46 -0
- thinkstack_core/alerts/config.py +60 -0
- thinkstack_core/alerts/dispatcher.py +110 -0
- thinkstack_core/alerts/jira.py +96 -0
- thinkstack_core/alerts/linear.py +72 -0
- thinkstack_core/alerts/pagerduty.py +66 -0
- thinkstack_core/alerts/slack.py +81 -0
- thinkstack_core/alerts/teams.py +70 -0
- thinkstack_core/audit/__init__.py +43 -0
- thinkstack_core/audit/exporter.py +297 -0
- thinkstack_core/audit/privacy.py +101 -0
- thinkstack_core/audit/scrubber.py +149 -0
- thinkstack_core/audit/service.py +67 -0
- thinkstack_core/audit/signing.py +127 -0
- thinkstack_core/broadcast/__init__.py +4 -0
- thinkstack_core/broadcast/broadcaster.py +100 -0
- thinkstack_core/broadcast/watcher.py +71 -0
- thinkstack_core/capability.py +639 -0
- thinkstack_core/cloud/__init__.py +1 -0
- thinkstack_core/cloud/client_config.py +472 -0
- thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
- thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode.json +15 -0
- thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
- thinkstack_core/cloud/mcp_client.py +229 -0
- thinkstack_core/cloud/setup.py +144 -0
- thinkstack_core/cloud/sync.py +143 -0
- thinkstack_core/cloud/sync_bundle.py +639 -0
- thinkstack_core/cloud/sync_conflicts.py +183 -0
- thinkstack_core/cloud/sync_state.py +159 -0
- thinkstack_core/cloud/team_sync.py +337 -0
- thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
- thinkstack_core/codex/__init__.py +9 -0
- thinkstack_core/codex/__main__.py +97 -0
- thinkstack_core/codex/capture.py +208 -0
- thinkstack_core/codex/proxy.py +412 -0
- thinkstack_core/compat.py +103 -0
- thinkstack_core/concept_catalog.py +209 -0
- thinkstack_core/consolidation/__init__.py +3 -0
- thinkstack_core/consolidation/synthesizer.py +87 -0
- thinkstack_core/consolidation/workflow.py +175 -0
- thinkstack_core/daemon/__init__.py +27 -0
- thinkstack_core/daemon/supervisor.py +293 -0
- thinkstack_core/daemon/watcher.py +244 -0
- thinkstack_core/dashboard_api.py +2012 -0
- thinkstack_core/deltaf.py +97 -0
- thinkstack_core/disclosure.py +50 -0
- thinkstack_core/divergence/__init__.py +3 -0
- thinkstack_core/divergence/detector.py +166 -0
- thinkstack_core/gateway/__init__.py +32 -0
- thinkstack_core/gateway/key_manager.py +124 -0
- thinkstack_core/gateway/metrics_webhook.py +252 -0
- thinkstack_core/gateway/policy.py +262 -0
- thinkstack_core/gateway/server.py +727 -0
- thinkstack_core/gateway/sso.py +233 -0
- thinkstack_core/gcc.py +1246 -0
- thinkstack_core/github/__init__.py +35 -0
- thinkstack_core/github/app.py +240 -0
- thinkstack_core/github/comment_builder.py +113 -0
- thinkstack_core/github/pat.py +76 -0
- thinkstack_core/github/pr_parser.py +82 -0
- thinkstack_core/github/pr_reporter.py +555 -0
- thinkstack_core/gitlab/__init__.py +177 -0
- thinkstack_core/hitl/__init__.py +4 -0
- thinkstack_core/hitl/channels.py +129 -0
- thinkstack_core/hitl/orchestrator.py +95 -0
- thinkstack_core/hooks/__init__.py +17 -0
- thinkstack_core/hooks/claude_code.py +228 -0
- thinkstack_core/hooks/git_capture.py +341 -0
- thinkstack_core/hooks/git_commit.py +182 -0
- thinkstack_core/hooks/installer.py +850 -0
- thinkstack_core/hooks/pre_commit.py +157 -0
- thinkstack_core/hooks/runner.py +386 -0
- thinkstack_core/identity/__init__.py +4 -0
- thinkstack_core/identity/agent.py +86 -0
- thinkstack_core/identity/providers.py +85 -0
- thinkstack_core/invariants.py +182 -0
- thinkstack_core/mcp/__init__.py +10 -0
- thinkstack_core/mcp/auth.py +177 -0
- thinkstack_core/mcp/server.py +1215 -0
- thinkstack_core/metrics/__init__.py +35 -0
- thinkstack_core/metrics/aggregate.py +215 -0
- thinkstack_core/metrics/calibrate.py +198 -0
- thinkstack_core/metrics/calibration.py +125 -0
- thinkstack_core/metrics/credibility.py +288 -0
- thinkstack_core/metrics/delivery_time.py +70 -0
- thinkstack_core/metrics/dhs.py +126 -0
- thinkstack_core/metrics/mcs.py +96 -0
- thinkstack_core/metrics/roi.py +88 -0
- thinkstack_core/metrics/session_writer.py +81 -0
- thinkstack_core/metrics/shadow_ai.py +117 -0
- thinkstack_core/metrics/sprint_writer.py +243 -0
- thinkstack_core/observability/__init__.py +78 -0
- thinkstack_core/observability/datadog.py +157 -0
- thinkstack_core/observability/formatter.py +119 -0
- thinkstack_core/observability/report.py +264 -0
- thinkstack_core/observability/servicenow.py +147 -0
- thinkstack_core/observability/splunk.py +218 -0
- thinkstack_core/observability/webhook.py +227 -0
- thinkstack_core/parser/__init__.py +30 -0
- thinkstack_core/parser/blocks.py +216 -0
- thinkstack_core/parser/inference.py +159 -0
- thinkstack_core/parser/thinking.py +112 -0
- thinkstack_core/projects.py +169 -0
- thinkstack_core/prompt_artifact.py +76 -0
- thinkstack_core/proxy/__init__.py +9 -0
- thinkstack_core/proxy/routes/__init__.py +1 -0
- thinkstack_core/proxy/routes/anthropic.py +264 -0
- thinkstack_core/proxy/routes/azure_openai.py +336 -0
- thinkstack_core/proxy/routes/gemini.py +331 -0
- thinkstack_core/proxy/routes/groq.py +284 -0
- thinkstack_core/proxy/routes/ollama.py +279 -0
- thinkstack_core/proxy/routes/openai.py +287 -0
- thinkstack_core/proxy/server.py +356 -0
- thinkstack_core/query/__init__.py +15 -0
- thinkstack_core/query/grep.py +181 -0
- thinkstack_core/query/hybrid.py +86 -0
- thinkstack_core/query/semantic.py +157 -0
- thinkstack_core/rdp.py +105 -0
- thinkstack_core/reasoning/__init__.py +4 -0
- thinkstack_core/reasoning/entry.py +31 -0
- thinkstack_core/reasoning/store.py +122 -0
- thinkstack_core/reasoning_plus/__init__.py +70 -0
- thinkstack_core/reasoning_plus/augmenter.py +337 -0
- thinkstack_core/reasoning_plus/capture.py +51 -0
- thinkstack_core/reasoning_plus/config.py +313 -0
- thinkstack_core/reasoning_plus/context.py +262 -0
- thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
- thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
- thinkstack_core/reasoning_plus/learning/api.py +784 -0
- thinkstack_core/reasoning_plus/learning/chain.py +285 -0
- thinkstack_core/reasoning_plus/learning/composer.py +141 -0
- thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
- thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
- thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
- thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
- thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
- thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
- thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
- thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
- thinkstack_core/reasoning_plus/learning/models.py +149 -0
- thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
- thinkstack_core/reasoning_plus/learning/pii.py +142 -0
- thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
- thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
- thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
- thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
- thinkstack_core/reasoning_plus/learning/state.py +86 -0
- thinkstack_core/reasoning_plus/learning/store.py +178 -0
- thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- thinkstack_core/reasoning_plus/prompt.py +90 -0
- thinkstack_core/rep.py +134 -0
- thinkstack_core/rep_network/__init__.py +25 -0
- thinkstack_core/rep_network/merge.py +70 -0
- thinkstack_core/rep_network/node.py +137 -0
- thinkstack_core/rep_network/server.py +140 -0
- thinkstack_core/rep_network/sync.py +207 -0
- thinkstack_core/sensitivity.py +182 -0
- thinkstack_core/serve.py +258 -0
- thinkstack_core/session/__init__.py +39 -0
- thinkstack_core/session/disagreement.py +188 -0
- thinkstack_core/session/models.py +114 -0
- thinkstack_core/session/orchestrator.py +182 -0
- thinkstack_core/session/planner.py +169 -0
- thinkstack_core/session/simulator.py +132 -0
- thinkstack_core/signing.py +290 -0
- thinkstack_core/sis.py +197 -0
- thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
- thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
- thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
- thinkstack_core/storage.py +308 -0
- thinkstack_core/templates/__init__.py +6 -0
- thinkstack_core/templates/engine.py +122 -0
- thinkstack_core/templates/go.py +18 -0
- thinkstack_core/templates/infra.py +19 -0
- thinkstack_core/templates/library/__init__.py +18 -0
- thinkstack_core/templates/library/api_design.md +27 -0
- thinkstack_core/templates/library/bug_fix.md +27 -0
- thinkstack_core/templates/library/decision_record.md +27 -0
- thinkstack_core/templates/library/engine.py +228 -0
- thinkstack_core/templates/library/security_review.md +30 -0
- thinkstack_core/templates/python.py +19 -0
- thinkstack_core/templates/react.py +18 -0
- thinkstack_core/templates/typescript.py +18 -0
- thinkstack_core/theta.py +221 -0
- thinkstack_core/theta_synthesis.py +268 -0
- thinkstack_core/topics.py +320 -0
- thinkstack_core/variance.py +219 -0
- thinkstack_core/wrapper/__init__.py +52 -0
- thinkstack_core/wrapper/anthropic.py +487 -0
- thinkstack_core/wrapper/base.py +562 -0
- thinkstack_core/wrapper/bedrock.py +342 -0
- thinkstack_core/wrapper/gemini.py +422 -0
- thinkstack_core/wrapper/ollama.py +527 -0
- thinkstack_core/wrapper/openai.py +461 -0
- thinkstack_core-4.0.0.dist-info/METADATA +868 -0
- thinkstack_core-4.0.0.dist-info/RECORD +205 -0
- thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
- thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
- 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,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())
|