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,313 @@
1
+ """
2
+ devtorch_core.reasoning_plus.learning.api
3
+ =========================================
4
+ High-level facade for Reasoning Plus Learning (DRPL).
5
+
6
+ This is the main entry point for callers. It orchestrates:
7
+ - recording calls
8
+ - extracting learnings
9
+ - retrieving relevant learnings
10
+ - composing prompt blocks
11
+ - applying outcome feedback
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ import logging
19
+
20
+ from devtorch_core.reasoning_plus.config import ReasoningPlusConfig
21
+ from devtorch_core.reasoning_plus.learning.composer import PromptComposer
22
+ from devtorch_core.reasoning_plus.learning.embeddings import (
23
+ EmbeddingBackend,
24
+ KeywordEmbeddingBackend,
25
+ get_embedding_backend,
26
+ )
27
+ from devtorch_core.reasoning_plus.learning.extractor import (
28
+ LearningExtractor,
29
+ make_extractor,
30
+ )
31
+ from devtorch_core.reasoning_plus.learning.models import Learning, ReasoningCall, _make_id
32
+ from devtorch_core.reasoning_plus.learning.provenance import ProvenanceRecord, ProvenanceStore, _hash_prompt
33
+ from devtorch_core.reasoning_plus.learning.recorder import CallRecorder
34
+ from devtorch_core.reasoning_plus.learning.relevance import RelevanceEngine
35
+ from devtorch_core.reasoning_plus.learning.state import workspace_state_hash
36
+ from devtorch_core.reasoning_plus.learning.store import LearningStore
37
+ from devtorch_core.reasoning_plus.learning import cross_project
38
+
39
+ logger = logging.getLogger("devtorch.reasoning_plus.learning")
40
+
41
+
42
+ LLMClient = Callable[[str], str]
43
+
44
+
45
+ class ReasoningPlusLearning:
46
+ """
47
+ Facade for learning from reasoning and feeding it back into future calls.
48
+
49
+ Usage:
50
+ drpl = ReasoningPlusLearning(gcc_dir, repo_path)
51
+ call_id = drpl.record_call(
52
+ call_type="tool",
53
+ name="read_file",
54
+ reasoning="Reading utils.py because the error mentions parse_date.",
55
+ outcome="failure",
56
+ input="utils.py",
57
+ output="...",
58
+ state_hash=drpl.workspace_hash(),
59
+ session_id="session-1",
60
+ )
61
+ drpl.extract_learnings(call_id)
62
+ learnings = drpl.get_relevant_learnings("Fix the parse_date bug")
63
+ prompt = drpl.compose_user_prompt("Fix the parse_date bug", learnings)
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ gcc_dir: Path | str,
69
+ repo_path: Path | str | None = None,
70
+ config: ReasoningPlusConfig | None = None,
71
+ llm_client: Callable[[str], str] | None = None,
72
+ ) -> None:
73
+ self._gcc_dir = Path(gcc_dir)
74
+ self._repo_path = Path(repo_path) if repo_path else self._gcc_dir.parent
75
+ self._config = config or ReasoningPlusConfig()
76
+ self._llm_client = llm_client
77
+
78
+ self._recorder = CallRecorder(self._gcc_dir)
79
+ self._store = LearningStore(self._gcc_dir)
80
+ self._provenance = ProvenanceStore(self._gcc_dir)
81
+ self._extractor = self._make_extractor()
82
+ self._relevance = RelevanceEngine(
83
+ backend=self._make_backend(),
84
+ cache_ttl=self._config.learning_relevance_cache_ttl,
85
+ )
86
+ self._composer = PromptComposer(max_lines=self._config.learning_max_lines)
87
+
88
+ def _make_extractor(self) -> LearningExtractor:
89
+ """Build the configured learning extractor, falling back to rule-based on errors."""
90
+ try:
91
+ return make_extractor(
92
+ strategy=self._config.learning_extraction_strategy,
93
+ llm_client=self._llm_client,
94
+ )
95
+ except Exception as exc:
96
+ logger.warning(
97
+ "devtorch: failed to load learning extractor strategy '%s' — %s; falling back to rule-based",
98
+ self._config.learning_extraction_strategy,
99
+ exc,
100
+ )
101
+ return LearningExtractor()
102
+
103
+ def _make_backend(self) -> EmbeddingBackend:
104
+ """Build the configured embedding backend, falling back to keyword on errors."""
105
+ try:
106
+ return get_embedding_backend(
107
+ self._config.learning_embedding_backend,
108
+ self._config.learning_embedding_model,
109
+ max_keywords=self._config.max_keywords,
110
+ )
111
+ except Exception as exc:
112
+ logger.warning(
113
+ "devtorch: failed to load embedding backend '%s' — %s; falling back to keyword",
114
+ self._config.learning_embedding_backend,
115
+ exc,
116
+ )
117
+ return KeywordEmbeddingBackend(max_keywords=self._config.max_keywords)
118
+
119
+ # ------------------------------------------------------------------
120
+ # Recording
121
+ # ------------------------------------------------------------------
122
+
123
+ def record_call(self, *, call_type: str, reasoning: str, session_id: str = "", name: str = "", input: str = "", output: str = "", outcome: str = "unknown", state_hash: str = "", concepts: list[str] | None = None, confidence: float = 1.0, sensitivity: str = "PUBLIC", meta: dict[str, Any] | None = None, call_id: str | None = None) -> str:
124
+ """Record a single call and return its ID."""
125
+ call = ReasoningCall(
126
+ id=call_id or _make_id("call"),
127
+ call_type=call_type,
128
+ name=name,
129
+ reasoning=reasoning,
130
+ input=input,
131
+ output=output,
132
+ outcome=outcome,
133
+ state_hash=state_hash or self.workspace_hash(),
134
+ concepts=concepts or [],
135
+ confidence=confidence,
136
+ sensitivity=sensitivity,
137
+ session_id=session_id,
138
+ meta=meta or {},
139
+ )
140
+ self._recorder.record(call)
141
+ return call.id
142
+
143
+ def record_and_extract(self, *, call_type: str, reasoning: str, session_id: str = "", outcome: str = "unknown", **kwargs: Any) -> tuple[str, list[str]]:
144
+ """Record a call and immediately extract learnings from it."""
145
+ call_id = self.record_call(
146
+ call_type=call_type,
147
+ reasoning=reasoning,
148
+ session_id=session_id,
149
+ outcome=outcome,
150
+ **kwargs,
151
+ )
152
+ learning_ids = self.extract_learnings(call_id)
153
+ return call_id, learning_ids
154
+
155
+ # ------------------------------------------------------------------
156
+ # Extraction
157
+ # ------------------------------------------------------------------
158
+
159
+ def extract_learnings(self, call_id: str) -> list[str]:
160
+ """Extract and store learnings from a recorded call. Returns learning IDs."""
161
+ call = self._recorder.get(call_id)
162
+ if not call:
163
+ return []
164
+ learnings = self._extractor.extract(call)
165
+ ids: list[str] = []
166
+ for learning in learnings:
167
+ self._store.save(learning)
168
+ ids.append(learning.id)
169
+ return ids
170
+
171
+ # ------------------------------------------------------------------
172
+ # Retrieval
173
+ # ------------------------------------------------------------------
174
+
175
+ def get_relevant_learnings(
176
+ self,
177
+ context: str,
178
+ top_n: int | None = None,
179
+ state_hash: str | None = None,
180
+ ) -> list[Learning]:
181
+ """Return the most relevant active learnings for the given context."""
182
+ if not self._config.learning_enabled:
183
+ return []
184
+ top_n = top_n or self._config.learning_top_n
185
+ all_active = self._store.list(validity="active")
186
+ return self._relevance.rank(
187
+ context,
188
+ all_active,
189
+ current_state_hash=state_hash or self.workspace_hash(),
190
+ top_n=top_n,
191
+ )
192
+
193
+ def cross_project_suggest(self, query: str, top_n: int = 3) -> list[Learning]:
194
+ """Return relevant learnings from sibling governed projects.
195
+
196
+ Looks up the machine-level registry of governed projects, finds projects
197
+ whose name or tags overlap with the query, loads their active learnings,
198
+ and returns the top-N most relevant ones.
199
+ """
200
+ if not self._config.learning_enabled:
201
+ return []
202
+ return cross_project.cross_project_suggest(
203
+ query=query,
204
+ current_project_root=self._repo_path,
205
+ top_n=top_n,
206
+ )
207
+
208
+ # ------------------------------------------------------------------
209
+ # Composition
210
+ # ------------------------------------------------------------------
211
+
212
+ def compose_user_prompt(self, user_prompt: str, learnings: list[Learning]) -> str:
213
+ """Append a compact <learnings> block to the user prompt."""
214
+ block = self._composer.compose(learnings)
215
+ if not block:
216
+ return user_prompt
217
+ return f"{user_prompt}\n\n{block}"
218
+
219
+ def compose_system_prompt(self, system_prompt: str, learnings: list[Learning]) -> str:
220
+ """Append a learning appendix to the system prompt (fallback)."""
221
+ block = self._composer.compose_system_prompt(learnings)
222
+ if not block:
223
+ return system_prompt
224
+ return f"{system_prompt}\n\n{block}"
225
+
226
+ # ------------------------------------------------------------------
227
+ # Feedback
228
+ # ------------------------------------------------------------------
229
+
230
+ def apply_feedback(self, learning_id: str, outcome: str) -> Learning | None:
231
+ """Adjust confidence or validity based on observed outcome."""
232
+ learning = self._store.get(learning_id)
233
+ if not learning:
234
+ return None
235
+
236
+ if outcome == "success":
237
+ learning.confidence = min(1.0, learning.confidence + 0.1)
238
+ elif outcome == "failure":
239
+ learning.confidence = max(0.0, learning.confidence - 0.2)
240
+ if learning.confidence < 0.3:
241
+ learning.validity = "deprecated"
242
+ elif outcome == "stale":
243
+ learning.validity = "stale"
244
+
245
+ learning.updated_at = _now_iso()
246
+ self._store.save(learning)
247
+ return learning
248
+
249
+ # ------------------------------------------------------------------
250
+ # Provenance
251
+ # ------------------------------------------------------------------
252
+
253
+ def record_injection(
254
+ self,
255
+ learning_ids: list[str],
256
+ *,
257
+ call_id: str,
258
+ session_id: str = "",
259
+ prompt_text: str = "",
260
+ model_name: str = "",
261
+ ) -> ProvenanceRecord:
262
+ """Record that a set of learnings was injected into a call."""
263
+ record = ProvenanceRecord(
264
+ call_id=call_id,
265
+ learning_ids=learning_ids,
266
+ session_id=session_id,
267
+ prompt_hash=_hash_prompt(prompt_text),
268
+ prompt_excerpt=prompt_text[:200],
269
+ model_name=model_name,
270
+ )
271
+ self._provenance.save(record)
272
+ return record
273
+
274
+ def record_injection_outcome(self, call_id: str, outcome: str) -> None:
275
+ """Update provenance for a call and apply feedback to injected learnings."""
276
+ record = self._provenance.update_outcome(call_id, outcome)
277
+ if not record:
278
+ return
279
+ for learning_id in record.learning_ids:
280
+ try:
281
+ self.apply_feedback(learning_id, outcome)
282
+ except Exception as exc:
283
+ logger.warning("devtorch: feedback failed for learning %s — %s", learning_id, exc)
284
+
285
+ def get_provenance(self, call_id: str) -> ProvenanceRecord | None:
286
+ """Return the provenance record for a call, if any."""
287
+ return self._provenance.get_by_call(call_id)
288
+
289
+ def list_provenance(self, learning_id: str | None = None) -> list[ProvenanceRecord]:
290
+ """Return provenance records, optionally filtered by learning."""
291
+ return self._provenance.list(learning_id=learning_id)
292
+
293
+ def invalidate_stale(self, changed_paths: list[str]) -> list[str]:
294
+ """Mark learnings affected by the given changed paths as stale."""
295
+ from devtorch_core.reasoning_plus.learning.state import affected_by_state_change
296
+ invalidated: list[str] = []
297
+ for learning in self._store.list(validity="active"):
298
+ if affected_by_state_change(self._repo_path, learning.trigger_concepts, changed_paths):
299
+ self._store.invalidate(learning.id, reason="stale")
300
+ invalidated.append(learning.id)
301
+ return invalidated
302
+
303
+ # ------------------------------------------------------------------
304
+ # State
305
+ # ------------------------------------------------------------------
306
+
307
+ def workspace_hash(self, file_paths: list[str] | None = None) -> str:
308
+ return workspace_state_hash(self._repo_path, file_paths)
309
+
310
+
311
+ def _now_iso() -> str:
312
+ from datetime import datetime, timezone
313
+ return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,285 @@
1
+ """
2
+ devtorch_core.reasoning_plus.learning.chain
3
+ ============================================
4
+ Reasoning chain: trace the full developer-agent handoff path.
5
+
6
+ Traverses: human commit -> agent session -> agent calls -> learnings -> outcomes.
7
+
8
+ The chain is built by linking:
9
+ 1. Commit records (with concepts or session references)
10
+ 2. Session records (from .GCC/sessions/)
11
+ 3. ReasoningCall records (from .GCC/reasoning_learnings/calls/)
12
+ 4. Provenance records (from .GCC/reasoning_learnings/provenance/)
13
+ 5. Learning records (from .GCC/reasoning_learnings/learnings/)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger("devtorch.reasoning_plus.learning")
23
+
24
+
25
+ def build_chain(
26
+ gcc_dir: Path | str,
27
+ concept: str | None = None,
28
+ max_chains: int = 5,
29
+ ) -> list[dict[str, Any]]:
30
+ """Build reasoning chains by linking commits to sessions to calls to learnings.
31
+
32
+ Each chain is a list of nodes:
33
+ commit -> session -> [call -> learning -> outcome]*
34
+
35
+ Chains are filtered to those whose commits or sessions mention *concept*,
36
+ or all chains if *concept* is None.
37
+
38
+ Args:
39
+ gcc_dir: Path to .GCC/ directory.
40
+ concept: Optional concept filter.
41
+ max_chains: Maximum number of chains to return.
42
+
43
+ Returns:
44
+ List of chain dicts, each with keys:
45
+ - commit_id: the commit ID
46
+ - commit_message: the commit message
47
+ - commit_concepts: list of concepts
48
+ - timestamp: commit timestamp
49
+ - session: session dict or None
50
+ - calls: list of call dicts (with learning + outcome)
51
+ """
52
+ chains: list[dict] = []
53
+ commits = _load_commits(gcc_dir)
54
+
55
+ for commit in commits:
56
+ if concept and not _matches_concept(commit, concept):
57
+ continue
58
+
59
+ chain = {
60
+ "commit_id": commit.get("id", "?"),
61
+ "commit_message": commit.get("message", ""),
62
+ "commit_concepts": commit.get("concepts", []),
63
+ "timestamp": commit.get("timestamp", ""),
64
+ "session": None,
65
+ "calls": [],
66
+ }
67
+
68
+ session_id = _extract_session_id(commit)
69
+ if session_id:
70
+ session = _load_session(gcc_dir, session_id)
71
+ if session:
72
+ chain["session"] = {
73
+ "session_id": session.get("session_id"),
74
+ "name": session.get("name"),
75
+ "problem": session.get("problem"),
76
+ "status": session.get("status"),
77
+ "subtasks": session.get("subtasks", []),
78
+ }
79
+ calls = _load_calls_for_session(gcc_dir, session_id)
80
+ for call in calls:
81
+ call_entry = _build_call_entry(gcc_dir, call)
82
+ if call_entry:
83
+ chain["calls"].append(call_entry)
84
+
85
+ chains.append(chain)
86
+ if len(chains) >= max_chains:
87
+ break
88
+
89
+ return chains
90
+
91
+
92
+ def _load_commits(gcc_dir: Path) -> list[dict]:
93
+ """Load all commits from .GCC/commits/, sorted newest-first."""
94
+ commits_dir = Path(gcc_dir) / "commits"
95
+ if not commits_dir.exists():
96
+ return []
97
+ commits: list[dict] = []
98
+ for path in sorted(commits_dir.glob("*.json"), reverse=True):
99
+ try:
100
+ data = json.loads(path.read_text(encoding="utf-8"))
101
+ commits.append(data)
102
+ except (json.JSONDecodeError, OSError):
103
+ continue
104
+ return commits
105
+
106
+
107
+ def _matches_concept(commit: dict, concept: str) -> bool:
108
+ """Check if a commit matches a concept (via concepts field or message)."""
109
+ cname = concept.lower()
110
+ for c in commit.get("concepts", []):
111
+ if c.lower() == cname:
112
+ return True
113
+ if cname in commit.get("message", "").lower():
114
+ return True
115
+ return False
116
+
117
+
118
+ def _extract_session_id(commit: dict) -> str | None:
119
+ """Extract session_id from commit message or a stored session_id field."""
120
+ msg = commit.get("message", "")
121
+ # Check for [session=xxx] pattern in message
122
+ import re
123
+ m = re.search(r"\[session=([^\]]+)\]", msg)
124
+ if m:
125
+ return m.group(1)
126
+ return None
127
+
128
+
129
+ def _load_session(gcc_dir: Path, session_id: str) -> dict | None:
130
+ """Load a session record from .GCC/sessions/."""
131
+ session_path = Path(gcc_dir) / "sessions" / f"{session_id}.json"
132
+ if not session_path.exists():
133
+ # Try matching by prefix or name
134
+ sessions_dir = Path(gcc_dir) / "sessions"
135
+ if sessions_dir.exists():
136
+ for sp in sessions_dir.glob("*.json"):
137
+ try:
138
+ data = json.loads(sp.read_text(encoding="utf-8"))
139
+ if data.get("session_id") == session_id or data.get("name") == session_id:
140
+ return data
141
+ except (json.JSONDecodeError, OSError):
142
+ continue
143
+ return None
144
+ try:
145
+ return json.loads(session_path.read_text(encoding="utf-8"))
146
+ except (json.JSONDecodeError, OSError):
147
+ return None
148
+
149
+
150
+ def _load_calls_for_session(gcc_dir: Path, session_id: str) -> list[dict]:
151
+ """Load all ReasoningCall records for a given session."""
152
+ calls_dir = Path(gcc_dir) / "reasoning_learnings" / "calls"
153
+ if not calls_dir.exists():
154
+ return []
155
+ calls: list[dict] = []
156
+ for path in sorted(calls_dir.glob("*.json")):
157
+ try:
158
+ data = json.loads(path.read_text(encoding="utf-8"))
159
+ if data.get("session_id") == session_id:
160
+ calls.append(data)
161
+ except (json.JSONDecodeError, OSError):
162
+ continue
163
+ return calls
164
+
165
+
166
+ def _build_call_entry(gcc_dir: Path, call: dict) -> dict | None:
167
+ """Build a chain entry for a call, linking to its learnings and outcomes."""
168
+ call_id = call.get("id", "")
169
+ if not call_id:
170
+ return None
171
+
172
+ entry: dict = {
173
+ "call_id": call_id,
174
+ "call_type": call.get("call_type", ""),
175
+ "name": call.get("name", ""),
176
+ "reasoning": call.get("reasoning", ""),
177
+ "outcome": call.get("outcome", "unknown"),
178
+ "concepts": call.get("concepts", []),
179
+ "learnings": [],
180
+ }
181
+
182
+ # Find provenance records for this call
183
+ provenance = _load_provenance_for_call(gcc_dir, call_id)
184
+ if provenance:
185
+ entry["provenance_outcome"] = provenance.get("outcome")
186
+ for lid in provenance.get("learning_ids", []):
187
+ learning = _load_learning(gcc_dir, lid)
188
+ if learning:
189
+ entry["learnings"].append({
190
+ "learning_id": lid,
191
+ "content": learning.get("content", "")[:120],
192
+ "type": learning.get("type", ""),
193
+ "confidence": learning.get("confidence", 0),
194
+ "validity": learning.get("validity", ""),
195
+ "trigger_concepts": learning.get("trigger_concepts", []),
196
+ })
197
+
198
+ return entry
199
+
200
+
201
+ def _load_provenance_for_call(gcc_dir: Path, call_id: str) -> dict | None:
202
+ """Find the provenance record for a specific call."""
203
+ prov_dir = Path(gcc_dir) / "reasoning_learnings" / "provenance"
204
+ if not prov_dir.exists():
205
+ return None
206
+ for path in prov_dir.glob("*.json"):
207
+ try:
208
+ data = json.loads(path.read_text(encoding="utf-8"))
209
+ if data.get("call_id") == call_id:
210
+ return data
211
+ except (json.JSONDecodeError, OSError):
212
+ continue
213
+ return None
214
+
215
+
216
+ def _load_learning(gcc_dir: Path, learning_id: str) -> dict | None:
217
+ """Load a single learning record by ID."""
218
+ path = Path(gcc_dir) / "reasoning_learnings" / "learnings" / f"{learning_id}.json"
219
+ if not path.exists():
220
+ return None
221
+ try:
222
+ return json.loads(path.read_text(encoding="utf-8"))
223
+ except (json.JSONDecodeError, OSError):
224
+ return None
225
+
226
+
227
+ def format_chain(chain: dict, indent: str = "") -> str:
228
+ """Format a single chain as readable text."""
229
+ lines = []
230
+
231
+ # Commit node
232
+ cid = chain.get("commit_id", "?")
233
+ msg = chain.get("commit_message", "")
234
+ concepts = chain.get("commit_concepts", [])
235
+ ts = chain.get("timestamp", "")
236
+ lines.append(f"{indent}Commit {cid} [{ts}]")
237
+ lines.append(f"{indent} Message: {msg[:80]}")
238
+ if concepts:
239
+ lines.append(f"{indent} Concepts: {', '.join(concepts)}")
240
+
241
+ # Session node
242
+ session = chain.get("session")
243
+ if session:
244
+ lines.append(f"{indent} Session: {session.get('name', '?')} ({session.get('status', '?')})")
245
+ lines.append(f"{indent} Problem: {session.get('problem', '')[:80]}")
246
+ subtasks = session.get("subtasks", [])
247
+ if subtasks:
248
+ completed = sum(1 for s in subtasks if s.get("status") == "completed")
249
+ failed = sum(1 for s in subtasks if s.get("status") == "failed")
250
+ total = len(subtasks)
251
+ lines.append(f"{indent} Subtasks: {completed}/{total} completed, {failed} failed")
252
+
253
+ # Call + learning nodes
254
+ calls = chain.get("calls", [])
255
+ if calls:
256
+ lines.append(f"{indent} Agent calls ({len(calls)}):")
257
+ success = sum(1 for c in calls if c.get("outcome") == "success")
258
+ failure = sum(1 for c in calls if c.get("outcome") == "failure")
259
+ lines.append(f"{indent} Outcomes: {success} success, {failure} failure")
260
+ for call in calls[:3]: # Show first 3 calls
261
+ lines.append(f"{indent} [{call.get('call_type','')}] {call.get('name','')}: {call.get('reasoning','')[:60]}")
262
+ lines.append(f"{indent} Outcome: {call.get('outcome', '?')}")
263
+ for lr in call.get("learnings", [])[:2]: # Show first 2 learnings per call
264
+ lines.append(f"{indent} Learning: {lr.get('content','')[:60]}")
265
+ lines.append(f"{indent} [{lr.get('type','')}] conf={lr.get('confidence',0)} concepts={lr.get('trigger_concepts',[])}")
266
+ if len(calls) > 3:
267
+ lines.append(f"{indent} ... and {len(calls) - 3} more calls")
268
+ else:
269
+ lines.append(f"{indent} No agent calls linked to this commit.")
270
+
271
+ return "\n".join(lines)
272
+
273
+
274
+ def format_chains(chains: list[dict]) -> str:
275
+ """Format multiple chains for CLI output."""
276
+ if not chains:
277
+ return "No reasoning chains found."
278
+
279
+ lines = ["═══ Reasoning chains ═══", ""]
280
+ for i, chain in enumerate(chains, 1):
281
+ lines.append(f"Chain #{i}")
282
+ lines.append(format_chain(chain, indent=" "))
283
+ lines.append("")
284
+ lines.append("═══ End ═══")
285
+ return "\n".join(lines)
@@ -0,0 +1,74 @@
1
+ """
2
+ devtorch_core.reasoning_plus.learning.composer
3
+ ==============================================
4
+ Format selected learnings into a compact prompt block.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from devtorch_core.reasoning_plus.learning.models import Learning
9
+
10
+
11
+ class PromptComposer:
12
+ """Compose a <learnings> block for injection into a user prompt.
13
+
14
+ By default only `PUBLIC` learnings are injected. Callers can override
15
+ `allowed_sensitivity` to include PROTECTED or PRIVATE learnings, but the
16
+ default prevents accidentally leaking sensitive reasoning into prompts.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ max_lines: int = 100,
22
+ allowed_sensitivity: list[str] | None = None,
23
+ ) -> None:
24
+ self.max_lines = max_lines
25
+ self.allowed_sensitivity = set(
26
+ allowed_sensitivity if allowed_sensitivity is not None else ["PUBLIC"]
27
+ )
28
+
29
+ def _filter(self, learnings: list[Learning]) -> list[Learning]:
30
+ return [
31
+ learning
32
+ for learning in learnings
33
+ if learning.sensitivity in self.allowed_sensitivity
34
+ ]
35
+
36
+ def compose(self, learnings: list[Learning]) -> str:
37
+ learnings = self._filter(learnings)
38
+ if not learnings:
39
+ return ""
40
+
41
+ lines: list[str] = [
42
+ "<learnings>",
43
+ "The following are relevant insights from previous steps in this session.",
44
+ "Evaluate them against the current evidence; they may be outdated.",
45
+ "",
46
+ ]
47
+ for i, learning in enumerate(learnings, start=1):
48
+ text = learning.short_form(max_chars=240)
49
+ concepts_tag = ""
50
+ if learning.trigger_concepts:
51
+ concepts_tag = f" [concepts: {', '.join(learning.trigger_concepts)}]"
52
+ lines.append(f"{i}. {text}{concepts_tag}")
53
+
54
+ lines.extend(["", "</learnings>"])
55
+
56
+ # Bound total lines
57
+ if len(lines) > self.max_lines:
58
+ lines = lines[: self.max_lines - 1]
59
+ lines.append("...</learnings>")
60
+
61
+ return "\n".join(lines)
62
+
63
+ def compose_system_prompt(self, learnings: list[Learning]) -> str:
64
+ """
65
+ Alternative: format learnings as a short system-prompt appendix.
66
+ Kept for callers that cannot modify the user prompt.
67
+ """
68
+ learnings = self._filter(learnings)
69
+ if not learnings:
70
+ return ""
71
+ parts = ["Relevant context from previous reasoning steps:"]
72
+ for i, learning in enumerate(learnings, start=1):
73
+ parts.append(f"{i}. {learning.short_form(max_chars=200)}")
74
+ return "\n".join(parts)