devtorch-core 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ """
2
+ S29 — Git pre-commit hook for DevTorch governance checks.
3
+
4
+ Installs a `.git/hooks/pre-commit` script that blocks commits when
5
+ `devtorch doctor` or `devtorch 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
+ # DevTorch pre-commit hook (S29)
39
+ # Generated by devtorch_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 "[devtorch pre-commit] Running governance checks..."
47
+
48
+ echo "[devtorch pre-commit] devtorch doctor"
49
+ devtorch doctor
50
+
51
+ echo "[devtorch pre-commit] devtorch invariant check"
52
+ devtorch invariant check
53
+
54
+ echo "[devtorch 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 `devtorch doctor` then `devtorch 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 `devtorch doctor` and `devtorch 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 devtorch 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
+ ("devtorch doctor", ["devtorch", "doctor"]),
141
+ ("devtorch invariant check", ["devtorch", "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,344 @@
1
+ """
2
+ Claude Code hook runner — `devtorch 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 devtorch_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
+
151
+ # ---------------------------------------------------------------------------
152
+ # Dispatch
153
+ # ---------------------------------------------------------------------------
154
+
155
+ _HANDLERS = {
156
+ "pre-tool-use": handle_pre_tool_use,
157
+ "post-tool-use": handle_post_tool_use,
158
+ "session-end": handle_session_end,
159
+ }
160
+
161
+
162
+ _CONTEXT_MAX_ENTRIES = 3
163
+ _CONTEXT_MAX_LINES = 40
164
+
165
+
166
+ # Selections larger than these budgets (lines AND characters — a single long
167
+ # line must not defeat the line count) are injected as a per-entry summary
168
+ # instead of verbatim, so a reasoning entry can never flood the model
169
+ # context. Small relevant selections keep their full detail.
170
+ _CONTEXT_FULL_LINE_BUDGET = 24
171
+ _CONTEXT_MAX_CHARS = 4000
172
+ _CONTEXT_SUMMARY_BODY_CHARS = 160
173
+
174
+ _PROMPT_STOPWORDS = frozenset(
175
+ """
176
+ the and for was were with that this from are but not you your our can
177
+ could should would about into over under what when where which who whom
178
+ why how did does doing done have has had will just like make made use
179
+ used using them they their there then than some any all out get got new
180
+ old one two per off via please context
181
+ """.split()
182
+ )
183
+
184
+
185
+ def _keywords(text: str) -> set:
186
+ """Lowercased alphanumeric tokens (3+ chars) minus stopwords."""
187
+ import re
188
+
189
+ return {
190
+ t
191
+ for t in re.findall(r"[a-z0-9][a-z0-9_\-]{2,}", text.lower())
192
+ if t not in _PROMPT_STOPWORDS
193
+ }
194
+
195
+
196
+ def _handle_user_prompt_submit(payload: dict) -> None:
197
+ """Print reasoning context for the incoming prompt; stdout becomes model
198
+ context in Claude Code. Reads log.md directly — no heavy imports.
199
+
200
+ Selection is relevance-first: entries are scored by keyword overlap with
201
+ the prompt text and only matching entries are injected. When the prompt
202
+ yields no matches (or no prompt text is available), it falls back to the
203
+ most recent entries. Large selections are compressed to per-entry
204
+ summaries so injection cost stays bounded.
205
+ """
206
+ repo = _find_repo()
207
+ if repo is None:
208
+ return
209
+ try:
210
+ text = (Path(repo.gcc_dir) / "log.md").read_text(encoding="utf-8")
211
+ except OSError:
212
+ return
213
+ # log.md entries start with "## <header>"; the leading file header has
214
+ # no "## " prefix. Split on "\n## " to isolate individual entries.
215
+ blocks = text.split("\n## ")
216
+ entries = [("## " + b).strip() for b in blocks[1:]] if len(blocks) > 1 else []
217
+ if not entries:
218
+ return
219
+
220
+ prompt_kws = _keywords(str(payload.get("prompt") or ""))
221
+ selected: list = []
222
+ kind = "Recent"
223
+ if prompt_kws:
224
+ # Score against entry bodies only: every heading contains
225
+ # "Commit at <timestamp>", so heading tokens ("commit", the year)
226
+ # would otherwise mark every entry relevant to date-ish prompts.
227
+ # Normalize by sqrt(|prompt_kws|) so a verbose prompt does not
228
+ # drown out a short, targeted one when both match the same entries.
229
+ _norm = max(1.0, len(prompt_kws) ** 0.5)
230
+ scored = [
231
+ (len(prompt_kws & _keywords("\n".join(entry.splitlines()[1:]))) / _norm, idx, entry)
232
+ for idx, entry in enumerate(entries)
233
+ ]
234
+ scored = [s for s in scored if s[0] > 0]
235
+ if scored:
236
+ # Highest keyword overlap first; ties broken by recency.
237
+ scored.sort(key=lambda s: (-s[0], -s[1]))
238
+ selected = [entry for _, _, entry in scored[:_CONTEXT_MAX_ENTRIES]]
239
+ kind = "Relevant"
240
+ if not selected:
241
+ selected = entries[-_CONTEXT_MAX_ENTRIES:]
242
+
243
+ body_lines = [line for entry in selected for line in entry.splitlines()]
244
+ selection_chars = sum(len(line) for line in body_lines)
245
+ if (
246
+ len(body_lines) <= _CONTEXT_FULL_LINE_BUDGET
247
+ and selection_chars <= _CONTEXT_MAX_CHARS
248
+ ):
249
+ lines = [f"[DevTorch] {kind} recorded reasoning (shared across agents):"]
250
+ lines.extend(body_lines)
251
+ else:
252
+ # Too much to inject verbatim — emit a compact per-entry summary:
253
+ # the entry heading plus its first body line, both truncated.
254
+ lines = [f"[DevTorch] {kind} context summary (shared across agents):"]
255
+ for entry in selected:
256
+ entry_lines = entry.splitlines()
257
+ lines.append(entry_lines[0][:_CONTEXT_SUMMARY_BODY_CHARS])
258
+ first_body = next(
259
+ (ln.strip() for ln in entry_lines[1:] if ln.strip()), ""
260
+ )
261
+ if first_body:
262
+ lines.append(" " + first_body[:_CONTEXT_SUMMARY_BODY_CHARS])
263
+ # Hard cap as the final guarantee, whatever mode produced the lines.
264
+ print("\n".join(lines[:_CONTEXT_MAX_LINES])[:_CONTEXT_MAX_CHARS])
265
+
266
+
267
+ def _handle_open_code_pre_prompt() -> None:
268
+ """Print a bounded context bundle (main.md + log.md + theta.json + recent
269
+ commits) so the model sees prior reasoning on a model switch. Uses the
270
+ same GCCRepository.context_bundle() path as the MCP devtorch_context
271
+ tool, but stays inside the fast hook runner (~0.2s instead of ~1s for
272
+ the full CLI)."""
273
+ repo = _find_repo()
274
+ if repo is None:
275
+ return
276
+ try:
277
+ bundle = repo.context_bundle(k_tokens=2000)
278
+ except Exception:
279
+ return
280
+ artifacts = bundle.get("artifacts", [])
281
+ if not artifacts:
282
+ return
283
+ lines = [f"[DevTorch] Context bundle (budget=2000 tokens, {len(artifacts)} artifacts):"]
284
+ for art in artifacts:
285
+ name = art.get("name", art.get("path", art.get("id", "unknown")))
286
+ reason = art.get("reason", "")
287
+ tokens = art.get("tokens", art.get("approx_tokens", "?"))
288
+ lines.append(f" - {name} ({tokens} tokens): {reason}")
289
+ print("\n".join(lines))
290
+
291
+
292
+ def run(hook_name: str) -> None:
293
+ """
294
+ Entry point called by `devtorch hooks run <hook_name>`.
295
+
296
+ Reads JSON from stdin, dispatches to the right handler, always exits 0.
297
+ """
298
+ if os.environ.get("DEVTORCH_DISABLE"):
299
+ return
300
+
301
+ payload = _read_stdin_json()
302
+
303
+ if hook_name == "open-code-pre-prompt":
304
+ try:
305
+ _handle_open_code_pre_prompt()
306
+ except Exception:
307
+ pass
308
+ return
309
+
310
+ if hook_name == "user-prompt-submit":
311
+ try:
312
+ _handle_user_prompt_submit(payload)
313
+ except Exception:
314
+ pass # never interfere with Claude Code
315
+ return
316
+
317
+ handler = _HANDLERS.get(hook_name)
318
+ if handler:
319
+ try:
320
+ handler(payload)
321
+ except Exception:
322
+ pass # never interfere with Claude Code
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Console entry point (fast path — must not import devtorch_cli)
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ def cli(argv: list[str] | None = None) -> int:
331
+ """Entry point for the `devtorch-hook` console script.
332
+
333
+ Equivalent to `devtorch hooks run <name>` but skips the full CLI import
334
+ chain (~0.9s), which matters because IDE hooks run on every tool call.
335
+ Always returns 0 — a non-zero hook exit blocks the IDE.
336
+ """
337
+ args = argv if argv is not None else sys.argv[1:]
338
+ if not args:
339
+ return 0
340
+ try:
341
+ run(args[0])
342
+ except Exception:
343
+ pass
344
+ 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())
@@ -0,0 +1,85 @@
1
+ """
2
+ Sprint 18 — Identity providers for resolving agent identity from env vars.
3
+
4
+ Each provider reads environment variables set by its runtime (native, Bedrock, Azure, etc.)
5
+ and returns an AgentIdentity. resolve_agent_identity() tries them in priority order,
6
+ falling back to AnonymousProvider if none match.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import uuid
12
+ from typing import Optional
13
+
14
+ from .agent import AgentIdentity
15
+
16
+
17
+ class NativeProvider:
18
+ """Reads DEVTORCH_AGENT_ID / DEVTORCH_AGENT_NAME / DEVTORCH_AGENT_SCOPE env vars."""
19
+
20
+ def resolve(self) -> Optional[AgentIdentity]:
21
+ agent_id = os.environ.get("DEVTORCH_AGENT_ID", "").strip()
22
+ if not agent_id:
23
+ return None
24
+ return AgentIdentity(
25
+ agent_id=agent_id,
26
+ name=os.environ.get("DEVTORCH_AGENT_NAME", agent_id),
27
+ provider="native",
28
+ scope=os.environ.get("DEVTORCH_AGENT_SCOPE", "branch"),
29
+ )
30
+
31
+
32
+ class BedrockProvider:
33
+ """Reads BEDROCK_AGENT_ID env var (set by AWS Bedrock AgentCore runtime)."""
34
+
35
+ def resolve(self) -> Optional[AgentIdentity]:
36
+ agent_id = os.environ.get("BEDROCK_AGENT_ID", "").strip()
37
+ if not agent_id:
38
+ return None
39
+ return AgentIdentity(
40
+ agent_id=agent_id,
41
+ name=os.environ.get("BEDROCK_AGENT_ALIAS", agent_id),
42
+ provider="bedrock",
43
+ scope="session",
44
+ metadata={"region": os.environ.get("AWS_DEFAULT_REGION", "")},
45
+ )
46
+
47
+
48
+ class AzureProvider:
49
+ """Reads AZURE_CLIENT_ID env var (set by Azure Managed Identity)."""
50
+
51
+ def resolve(self) -> Optional[AgentIdentity]:
52
+ agent_id = os.environ.get("AZURE_CLIENT_ID", "").strip()
53
+ if not agent_id:
54
+ return None
55
+ return AgentIdentity(
56
+ agent_id=agent_id,
57
+ name=os.environ.get("AZURE_AGENT_NAME", agent_id),
58
+ provider="azure",
59
+ scope="session",
60
+ metadata={"tenant_id": os.environ.get("AZURE_TENANT_ID", "")},
61
+ )
62
+
63
+
64
+ class AnonymousProvider:
65
+ """Fallback: generates a session-scoped random id."""
66
+
67
+ def resolve(self) -> AgentIdentity:
68
+ return AgentIdentity(
69
+ agent_id=f"anon-{uuid.uuid4().hex[:8]}",
70
+ name="anonymous",
71
+ provider="anonymous",
72
+ scope="session",
73
+ )
74
+
75
+
76
+ _PROVIDERS = [NativeProvider, BedrockProvider, AzureProvider]
77
+
78
+
79
+ def resolve_agent_identity() -> AgentIdentity:
80
+ """Try each provider in priority order; fall back to anonymous."""
81
+ for cls in _PROVIDERS:
82
+ identity = cls().resolve()
83
+ if identity is not None:
84
+ return identity
85
+ return AnonymousProvider().resolve()