traceforge-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,234 @@
1
+ """SQLite output sink — stores enriched events in a queryable database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ from traceforge.sinks.base import StorageSink
12
+ from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _CREATE_TABLE = """\
17
+ CREATE TABLE IF NOT EXISTS enriched_events (
18
+ id TEXT PRIMARY KEY,
19
+ session_id TEXT NOT NULL,
20
+ kind TEXT NOT NULL,
21
+ timestamp TEXT NOT NULL,
22
+ tool_name TEXT,
23
+ risk_level TEXT,
24
+ risk_score INTEGER,
25
+ action TEXT,
26
+ payload_json TEXT,
27
+ metadata_json TEXT,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_enriched_session ON enriched_events(session_id);
32
+ CREATE INDEX IF NOT EXISTS idx_enriched_timestamp ON enriched_events(timestamp);
33
+ CREATE INDEX IF NOT EXISTS idx_enriched_risk ON enriched_events(risk_level);
34
+ CREATE INDEX IF NOT EXISTS idx_enriched_action ON enriched_events(action);
35
+
36
+ CREATE TABLE IF NOT EXISTS segment_titles (
37
+ segment_id TEXT NOT NULL,
38
+ kind TEXT NOT NULL,
39
+ session_id TEXT NOT NULL,
40
+ title TEXT NOT NULL,
41
+ version INTEGER NOT NULL,
42
+ parent_id TEXT,
43
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
44
+ PRIMARY KEY (segment_id, kind)
45
+ );
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_segment_titles_session ON segment_titles(session_id);
48
+
49
+ CREATE TABLE IF NOT EXISTS context_gaps (
50
+ id TEXT PRIMARY KEY,
51
+ session_id TEXT NOT NULL,
52
+ timestamp TEXT NOT NULL,
53
+ dropped_count INTEGER NOT NULL,
54
+ first_dropped_sequence INTEGER,
55
+ last_dropped_sequence INTEGER,
56
+ gap_ordinal INTEGER NOT NULL,
57
+ reason TEXT NOT NULL,
58
+ source_event_key TEXT NOT NULL,
59
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
60
+ );
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_context_gaps_session ON context_gaps(session_id);
63
+ """
64
+
65
+
66
+ class SqliteOutputSink(StorageSink):
67
+ """Persists enriched events to a SQLite database.
68
+
69
+ Provides a queryable local audit trail with indexed columns for
70
+ session_id, timestamp, risk_level, and action.
71
+ """
72
+
73
+ _VALID_JOURNAL_MODES = frozenset({"wal", "delete", "truncate", "persist", "memory", "off"})
74
+
75
+ def __init__(self, path: str | Path, journal_mode: str = "wal") -> None:
76
+ self._path = Path(path).expanduser()
77
+ if journal_mode.lower() not in self._VALID_JOURNAL_MODES:
78
+ raise ValueError(
79
+ f"Invalid journal_mode '{journal_mode}'. "
80
+ f"Must be one of: {', '.join(sorted(self._VALID_JOURNAL_MODES))}"
81
+ )
82
+ self._journal_mode = journal_mode.lower()
83
+ self._conn: sqlite3.Connection | None = None
84
+
85
+ async def on_event(self, event: SessionEvent) -> None:
86
+ tool_name = None
87
+ risk_level = None
88
+ risk_score = None
89
+ action = None
90
+
91
+ if event.payload:
92
+ tool_name = event.payload.get("tool_name")
93
+
94
+ if event.metadata:
95
+ gov = event.metadata.governance
96
+ if gov is not None:
97
+ if gov.risk_assessment is not None:
98
+ risk_level = gov.risk_assessment.level
99
+ risk_score = gov.risk_assessment.score
100
+ if gov.recommendation is not None:
101
+ action = gov.recommendation.recommended_action.value
102
+
103
+ payload_json = json.dumps(event.payload, default=str) if event.payload else None
104
+ metadata_json = (
105
+ json.dumps(event.metadata.model_dump(exclude_none=True), default=str)
106
+ if event.metadata
107
+ else None
108
+ )
109
+
110
+ params = (
111
+ event.id,
112
+ event.session_id,
113
+ event.kind,
114
+ event.timestamp.isoformat() if event.timestamp else None,
115
+ tool_name,
116
+ risk_level,
117
+ risk_score,
118
+ action,
119
+ payload_json,
120
+ metadata_json,
121
+ )
122
+
123
+ await asyncio.to_thread(self._write_event, params)
124
+
125
+ def _write_event(self, params: tuple) -> None:
126
+ """Synchronous write — called via asyncio.to_thread."""
127
+ conn = self._get_conn()
128
+ try:
129
+ conn.execute(
130
+ """INSERT OR IGNORE INTO enriched_events
131
+ (id, session_id, kind, timestamp, tool_name, risk_level, risk_score, action, payload_json, metadata_json)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
133
+ params,
134
+ )
135
+ conn.commit()
136
+ except sqlite3.Error as exc:
137
+ logger.error("SqliteOutputSink: write failed: %s", exc)
138
+
139
+ async def on_span(self, span: TelemetrySpan) -> None:
140
+ pass
141
+
142
+ async def on_usage(self, usage: UsageRecord) -> None:
143
+ pass
144
+
145
+ async def on_enriched_event(self, enriched) -> None:
146
+ """Persist a governance envelope. Live events keep byte-identical output
147
+ (delegated to :meth:`on_event`); context-gap markers land in the
148
+ ``context_gaps`` table."""
149
+ from traceforge.governance.envelope import ContextGapEvent
150
+
151
+ gap = enriched.event
152
+ if not isinstance(gap, ContextGapEvent):
153
+ await super().on_enriched_event(enriched)
154
+ return
155
+
156
+ params = (
157
+ gap.id,
158
+ gap.session_id,
159
+ gap.timestamp.isoformat() if gap.timestamp else None,
160
+ gap.dropped_count,
161
+ gap.first_dropped_sequence,
162
+ gap.last_dropped_sequence,
163
+ gap.gap_ordinal,
164
+ gap.reason,
165
+ gap.source_event_key,
166
+ )
167
+ await asyncio.to_thread(self._write_gap, params)
168
+
169
+ def _write_gap(self, params: tuple) -> None:
170
+ """Synchronous gap write — called via asyncio.to_thread."""
171
+ conn = self._get_conn()
172
+ try:
173
+ conn.execute(
174
+ """INSERT OR IGNORE INTO context_gaps
175
+ (id, session_id, timestamp, dropped_count, first_dropped_sequence,
176
+ last_dropped_sequence, gap_ordinal, reason, source_event_key)
177
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
178
+ params,
179
+ )
180
+ conn.commit()
181
+ except sqlite3.Error as exc:
182
+ logger.error("SqliteOutputSink: gap write failed: %s", exc)
183
+
184
+ async def on_title_update(self, update: TitleUpdate) -> None:
185
+ params = (
186
+ update.segment_id,
187
+ update.kind,
188
+ update.session_id,
189
+ update.title,
190
+ update.version,
191
+ update.parent_id,
192
+ )
193
+ await asyncio.to_thread(self._write_title, params)
194
+
195
+ def _write_title(self, params: tuple) -> None:
196
+ """Synchronous upsert keeping the highest version — called via to_thread."""
197
+ conn = self._get_conn()
198
+ try:
199
+ conn.execute(
200
+ """INSERT INTO segment_titles
201
+ (segment_id, kind, session_id, title, version, parent_id)
202
+ VALUES (?, ?, ?, ?, ?, ?)
203
+ ON CONFLICT(segment_id, kind) DO UPDATE SET
204
+ title=excluded.title,
205
+ session_id=excluded.session_id,
206
+ version=excluded.version,
207
+ parent_id=excluded.parent_id,
208
+ updated_at=datetime('now')
209
+ WHERE excluded.version >= segment_titles.version""",
210
+ params,
211
+ )
212
+ conn.commit()
213
+ except sqlite3.Error as exc:
214
+ logger.error("SqliteOutputSink: title write failed: %s", exc)
215
+
216
+ async def flush(self) -> None:
217
+ if self._conn:
218
+ self._conn.commit()
219
+
220
+ async def close(self) -> None:
221
+ if self._conn:
222
+ self._conn.close()
223
+ self._conn = None
224
+
225
+ def _get_conn(self) -> sqlite3.Connection:
226
+ if self._conn is None:
227
+ self._path.parent.mkdir(parents=True, exist_ok=True)
228
+ self._conn = sqlite3.connect(str(self._path), timeout=5, check_same_thread=False)
229
+ try:
230
+ self._conn.execute(f"PRAGMA journal_mode={self._journal_mode}")
231
+ except sqlite3.OperationalError:
232
+ pass
233
+ self._conn.executescript(_CREATE_TABLE)
234
+ return self._conn
@@ -0,0 +1,136 @@
1
+ """Webhook sink — POST governance results to an HTTP endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from urllib.error import URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from traceforge.sinks.base import StorageSink
12
+ from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class WebhookSink(StorageSink):
18
+ """POSTs enriched events as JSON to a configured URL.
19
+
20
+ Only emits events matching the filter (by governance action).
21
+ Uses stdlib urllib to avoid adding dependencies.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ url: str,
27
+ filter_actions: list[str] | None = None,
28
+ timeout: float = 10.0,
29
+ max_retries: int = 3,
30
+ headers: dict[str, str] | None = None,
31
+ ) -> None:
32
+ if not url.startswith(("http://", "https://")):
33
+ raise ValueError(f"Webhook URL must use http:// or https:// scheme, got: {url}")
34
+ self._url = url
35
+ self._filter = set(filter_actions or ["deny", "escalate"])
36
+ self._timeout = timeout
37
+ self._max_retries = max_retries
38
+ self._headers = headers or {}
39
+
40
+ async def on_event(self, event: SessionEvent) -> None:
41
+ action = self._extract_action(event)
42
+ if action is None or action not in self._filter:
43
+ return
44
+
45
+ payload = {
46
+ "id": event.id,
47
+ "kind": event.kind,
48
+ "session_id": event.session_id,
49
+ "timestamp": event.timestamp.isoformat() if event.timestamp else None,
50
+ "payload": event.payload,
51
+ "governance": self._extract_governance(event),
52
+ }
53
+ await self._post(payload)
54
+
55
+ async def on_title_update(self, update: TitleUpdate) -> None:
56
+ await self._post(
57
+ {
58
+ "record": "title_update",
59
+ "session_id": update.session_id,
60
+ "segment_id": update.segment_id,
61
+ "kind": update.kind,
62
+ "title": update.title,
63
+ "version": update.version,
64
+ "parent_id": update.parent_id,
65
+ }
66
+ )
67
+
68
+ async def _post(self, payload: dict) -> None:
69
+ body = json.dumps(payload, default=str).encode("utf-8")
70
+ headers = {
71
+ "Content-Type": "application/json",
72
+ **self._headers,
73
+ }
74
+
75
+ for attempt in range(1, self._max_retries + 1):
76
+ try:
77
+ req = Request(self._url, data=body, headers=headers, method="POST")
78
+ resp = await asyncio.to_thread(self._do_request, req)
79
+ if resp < 300:
80
+ return
81
+ logger.warning(
82
+ "WebhookSink: %s returned status %d (attempt %d/%d)",
83
+ self._url,
84
+ resp,
85
+ attempt,
86
+ self._max_retries,
87
+ )
88
+ except (URLError, OSError, TimeoutError) as exc:
89
+ logger.warning(
90
+ "WebhookSink: POST to %s failed (attempt %d/%d): %s",
91
+ self._url,
92
+ attempt,
93
+ self._max_retries,
94
+ exc,
95
+ )
96
+
97
+ logger.error("WebhookSink: all %d attempts to %s failed", self._max_retries, self._url)
98
+
99
+ def _do_request(self, req: Request) -> int:
100
+ """Synchronous HTTP request — returns status code. Ensures response body is consumed."""
101
+ with urlopen(req, timeout=self._timeout) as resp:
102
+ resp.read() # consume body to release connection
103
+ return resp.status
104
+
105
+ def _extract_action(self, event: SessionEvent) -> str | None:
106
+ gov = event.metadata.governance if event.metadata else None
107
+ if gov is None:
108
+ return None
109
+ rec = gov.recommendation
110
+ if rec is None:
111
+ return None
112
+ return rec.recommended_action.value
113
+
114
+ def _extract_governance(self, event: SessionEvent) -> dict | None:
115
+ gov = event.metadata.governance if event.metadata else None
116
+ if gov is None:
117
+ return None
118
+ result: dict = {}
119
+ if gov.risk_assessment is not None:
120
+ result["risk_assessment"] = {
121
+ "score": gov.risk_assessment.score,
122
+ "level": gov.risk_assessment.level,
123
+ "confidence": gov.risk_assessment.confidence,
124
+ }
125
+ if gov.recommendation is not None:
126
+ result["recommendation"] = {
127
+ "action": gov.recommendation.recommended_action.value,
128
+ "reason_code": gov.recommendation.reason_code,
129
+ }
130
+ return result or None
131
+
132
+ async def on_span(self, span: TelemetrySpan) -> None:
133
+ pass
134
+
135
+ async def on_usage(self, usage: UsageRecord) -> None:
136
+ pass
@@ -0,0 +1,18 @@
1
+ from traceforge.sources.base import RawRecord, Source
2
+ from traceforge.sources.file_poll import FilePollSource
3
+ from traceforge.sources.file_watch import FileWatchSource
4
+ from traceforge.sources.http_poll import HttpPollSource
5
+ from traceforge.sources.replay import ReplaySource
6
+ from traceforge.sources.sqlite import SqliteSource
7
+ from traceforge.sources.sse import SSESource
8
+
9
+ __all__ = [
10
+ "FilePollSource",
11
+ "FileWatchSource",
12
+ "HttpPollSource",
13
+ "RawRecord",
14
+ "ReplaySource",
15
+ "SqliteSource",
16
+ "SSESource",
17
+ "Source",
18
+ ]
@@ -0,0 +1,173 @@
1
+ """Framework auto-detection — discovers installed AI coding agents by checking well-known paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import platform
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _expand(path: str) -> Path:
14
+ return Path(os.path.expanduser(path))
15
+
16
+
17
+ def _vscode_global_storage() -> Path | None:
18
+ """Locate VS Code's globalStorage directory (OS-specific)."""
19
+ system = platform.system()
20
+ if system == "Darwin":
21
+ base = _expand("~/Library/Application Support/Code/User/globalStorage")
22
+ elif system == "Windows":
23
+ appdata = os.environ.get("APPDATA", "")
24
+ base = Path(appdata) / "Code" / "User" / "globalStorage" if appdata else None
25
+ if base is None:
26
+ return None
27
+ else: # Linux
28
+ base = _expand("~/.config/Code/User/globalStorage")
29
+ return base if base.is_dir() else None
30
+
31
+
32
+ def _goose_data_dir() -> Path:
33
+ """Goose uses XDG data dir conventions via the `etcetera` crate."""
34
+ system = platform.system()
35
+ if system == "Darwin":
36
+ return _expand("~/Library/Application Support/Block/goose")
37
+ elif system == "Windows":
38
+ local = os.environ.get("LOCALAPPDATA", "")
39
+ return Path(local) / "Block" / "goose" if local else _expand("~/.local/share/goose")
40
+ else:
41
+ return _expand("~/.local/share/goose")
42
+
43
+
44
+ def _amazonq_data_dir() -> Path:
45
+ """Amazon Q Developer CLI data directory."""
46
+ system = platform.system()
47
+ if system == "Darwin":
48
+ return _expand("~/Library/Application Support/amazon-q")
49
+ elif system == "Windows":
50
+ local = os.environ.get("LOCALAPPDATA", "")
51
+ return Path(local) / "amazon-q" if local else _expand("~/.local/share/amazon-q")
52
+ else:
53
+ return _expand("~/.local/share/amazon-q")
54
+
55
+
56
+ # ─── Detection registry ─────────────────────────────────────────────────────
57
+
58
+
59
+ class DetectedFramework:
60
+ """A detected framework with its source path and adapter config."""
61
+
62
+ def __init__(self, name: str, path: Path, adapter: str, ingestion_mode: str) -> None:
63
+ self.name = name
64
+ self.path = path
65
+ self.adapter = adapter
66
+ self.ingestion_mode = ingestion_mode
67
+
68
+ def __repr__(self) -> str:
69
+ return f"DetectedFramework({self.name!r}, path={self.path})"
70
+
71
+
72
+ def detect_frameworks(frameworks: list[str] | None = None) -> list[DetectedFramework]:
73
+ """Scan well-known paths for installed AI coding agent frameworks.
74
+
75
+ Args:
76
+ frameworks: If provided, only detect these specific frameworks.
77
+ If None/empty, detect all known frameworks.
78
+
79
+ Returns:
80
+ List of detected frameworks with their paths and adapter configs.
81
+ """
82
+ all_detectors = {
83
+ "claude": _detect_claude,
84
+ "codex": _detect_codex,
85
+ "continue": _detect_continue,
86
+ "cline": _detect_cline,
87
+ "goose": _detect_goose,
88
+ "amazonq": _detect_amazonq,
89
+ "aider": _detect_aider,
90
+ }
91
+
92
+ target = frameworks if frameworks else list(all_detectors.keys())
93
+ detected: list[DetectedFramework] = []
94
+
95
+ for name in target:
96
+ detector = all_detectors.get(name)
97
+ if detector is None:
98
+ logger.debug("Unknown framework for detection: %s", name)
99
+ continue
100
+ try:
101
+ result = detector()
102
+ if result is not None:
103
+ detected.append(result)
104
+ logger.info("Detected %s at %s", result.name, result.path)
105
+ except Exception as exc:
106
+ logger.debug("Detection failed for %s: %s", name, exc)
107
+
108
+ return detected
109
+
110
+
111
+ # ─── Individual detectors ────────────────────────────────────────────────────
112
+
113
+
114
+ def _detect_claude() -> DetectedFramework | None:
115
+ path = _expand("~/.claude/projects")
116
+ if path.is_dir():
117
+ return DetectedFramework("claude", path, "claude", "file_watch")
118
+ return None
119
+
120
+
121
+ def _detect_codex() -> DetectedFramework | None:
122
+ codex_home = os.environ.get("CODEX_HOME")
123
+ if codex_home:
124
+ path = Path(codex_home) / "sessions"
125
+ else:
126
+ path = _expand("~/.codex/sessions")
127
+ if path.is_dir():
128
+ return DetectedFramework("codex", path, "codex", "file_watch")
129
+ return None
130
+
131
+
132
+ def _detect_continue() -> DetectedFramework | None:
133
+ custom = os.environ.get("CONTINUE_GLOBAL_DIR")
134
+ if custom:
135
+ path = Path(custom) / "sessions"
136
+ else:
137
+ path = _expand("~/.continue/sessions")
138
+ if path.is_dir():
139
+ return DetectedFramework("continue", path, "continue", "file_watch")
140
+ return None
141
+
142
+
143
+ def _detect_cline() -> DetectedFramework | None:
144
+ gs = _vscode_global_storage()
145
+ if gs is None:
146
+ return None
147
+ path = gs / "saoudrizwan.claude-dev" / "tasks"
148
+ if path.is_dir():
149
+ return DetectedFramework("cline", path, "cline", "file_watch")
150
+ return None
151
+
152
+
153
+ def _detect_goose() -> DetectedFramework | None:
154
+ path = _goose_data_dir() / "sessions"
155
+ if path.is_dir():
156
+ return DetectedFramework("goose", path, "goose", "poll")
157
+ return None
158
+
159
+
160
+ def _detect_amazonq() -> DetectedFramework | None:
161
+ path = _amazonq_data_dir() / "data.sqlite3"
162
+ if path.is_file():
163
+ return DetectedFramework("amazonq", path, "amazonq", "sqlite")
164
+ return None
165
+
166
+
167
+ def _detect_aider() -> DetectedFramework | None:
168
+ # Aider writes .aider.chat.history.md in the project root
169
+ # We check CWD since aider is project-local
170
+ path = Path.cwd() / ".aider.chat.history.md"
171
+ if path.is_file():
172
+ return DetectedFramework("aider", path, "aider_markdown", "file_watch")
173
+ return None
@@ -0,0 +1,45 @@
1
+ """Base abstractions for configurable ingestion sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from types import TracebackType
10
+
11
+ from traceforge.types import IngestionMode
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class RawRecord:
16
+ """A complete raw record emitted by a source transport."""
17
+
18
+ payload: str
19
+ source_name: str
20
+ mode: IngestionMode
21
+ sequence: int | None = None
22
+ received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
23
+
24
+
25
+ class Source(ABC):
26
+ """Async source that yields complete logical records."""
27
+
28
+ name: str
29
+
30
+ @abstractmethod
31
+ async def __aenter__(self) -> "Source":
32
+ """Allocate any resources required by the source."""
33
+
34
+ @abstractmethod
35
+ async def __aexit__(
36
+ self,
37
+ exc_type: type[BaseException] | None,
38
+ exc: BaseException | None,
39
+ tb: TracebackType | None,
40
+ ) -> None:
41
+ """Release any resources held by the source."""
42
+
43
+ @abstractmethod
44
+ def __aiter__(self) -> AsyncIterator[RawRecord]:
45
+ """Yield complete logical records from the source."""