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,579 @@
1
+ """Pydantic models for traceforge configuration.
2
+
3
+ All config surfaces (YAML files, env vars, constructor args) share these
4
+ strongly-typed models. Discriminated unions provide exhaustive validation
5
+ while remaining extensible via the factory registry.
6
+
7
+ Follows SOLID:
8
+ - Single Responsibility: each config class owns one concern
9
+ - Open/Closed: new source/sink types add a model + register, don't modify existing
10
+ - Liskov: all SourceConfig subtypes are substitutable
11
+ - Interface Segregation: SDK-only configs (CallbackSink) are separate from serializable
12
+ - Dependency Inversion: pipeline config references abstractions (type discriminators)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Annotated, Literal
19
+
20
+ from pydantic import Field, field_validator, model_validator
21
+
22
+ from traceforge.models import StrictModel
23
+
24
+
25
+ # ─── Source Configs ──────────────────────────────────────────────────────────
26
+
27
+
28
+ class FileWatchSourceConfig(StrictModel):
29
+ """Watch a file for appended content (OS-native events via watchdog)."""
30
+
31
+ type: Literal["file_watch"] = "file_watch"
32
+ path: Path
33
+ start_at: Literal["beginning", "end"] = "end"
34
+ encoding: str = "utf-8"
35
+
36
+
37
+ class FilePollSourceConfig(StrictModel):
38
+ """Poll a file for changes at a fixed interval."""
39
+
40
+ type: Literal["file_poll"] = "file_poll"
41
+ path: Path
42
+ interval: float = 1.0
43
+ missing: Literal["wait", "error"] = "wait"
44
+ encoding: str = "utf-8"
45
+
46
+ @field_validator("interval")
47
+ @classmethod
48
+ def _interval_positive(cls, v: float) -> float:
49
+ if v <= 0:
50
+ raise ValueError("interval must be positive")
51
+ return v
52
+
53
+
54
+ class HttpPollSourceConfig(StrictModel):
55
+ """Poll an HTTP endpoint at a fixed interval."""
56
+
57
+ type: Literal["http_poll"] = "http_poll"
58
+ url: str
59
+ interval: float = 30.0
60
+ headers: dict[str, str] = Field(default_factory=dict)
61
+ cursor_header: str | None = None
62
+ timeout: float = 30.0
63
+ max_retries: int = 3
64
+
65
+ @field_validator("interval")
66
+ @classmethod
67
+ def _interval_positive(cls, v: float) -> float:
68
+ if v <= 0:
69
+ raise ValueError("interval must be positive")
70
+ return v
71
+
72
+
73
+ class SSESourceConfig(StrictModel):
74
+ """Connect to a Server-Sent Events stream."""
75
+
76
+ type: Literal["sse"] = "sse"
77
+ url: str
78
+ headers: dict[str, str] = Field(default_factory=dict)
79
+ reconnect_interval: float = 3.0
80
+ timeout: float = 60.0
81
+
82
+
83
+ class ReplaySourceConfig(StrictModel):
84
+ """Replay a previously-captured file (one-shot, no watching)."""
85
+
86
+ type: Literal["replay"] = "replay"
87
+ path: Path
88
+ encoding: str = "utf-8"
89
+
90
+
91
+ # Discriminated union of all serializable source types
92
+ SourceConfig = Annotated[
93
+ FileWatchSourceConfig
94
+ | FilePollSourceConfig
95
+ | HttpPollSourceConfig
96
+ | SSESourceConfig
97
+ | ReplaySourceConfig,
98
+ Field(discriminator="type"),
99
+ ]
100
+
101
+
102
+ # ─── Adapter Configs ─────────────────────────────────────────────────────────
103
+
104
+
105
+ class MappedJsonAdapterConfig(StrictModel):
106
+ """Data-driven adapter using a YAML framework mapping."""
107
+
108
+ type: Literal["mapped_json"] = "mapped_json"
109
+ mapping: str # framework name (resolved from bundled + user mappings)
110
+ mapping_file: Path | None = None # explicit path override
111
+
112
+
113
+ class OtelSpanAdapterConfig(StrictModel):
114
+ """Adapter for OpenTelemetry span JSON."""
115
+
116
+ type: Literal["otel_span"] = "otel_span"
117
+
118
+
119
+ # Discriminated union of all adapter types
120
+ AdapterConfig = Annotated[
121
+ MappedJsonAdapterConfig | OtelSpanAdapterConfig,
122
+ Field(discriminator="type"),
123
+ ]
124
+
125
+
126
+ # ─── Sink Configs ────────────────────────────────────────────────────────────
127
+
128
+
129
+ class SqliteSinkConfig(StrictModel):
130
+ """SQLite storage sink."""
131
+
132
+ type: Literal["sqlite"] = "sqlite"
133
+ path: Path
134
+ journal_mode: Literal["wal", "delete", "truncate"] = "wal"
135
+
136
+
137
+ class JsonlSinkConfig(StrictModel):
138
+ """Append-only JSONL file sink."""
139
+
140
+ type: Literal["jsonl"] = "jsonl"
141
+ path: Path
142
+ rotate_size_mb: float | None = None
143
+
144
+
145
+ class ConsoleSinkConfig(StrictModel):
146
+ """Pretty-print governance results to terminal."""
147
+
148
+ type: Literal["console"] = "console"
149
+ filter: list[str] = Field(default_factory=lambda: ["warn", "deny", "escalate"])
150
+ color: bool = True
151
+
152
+
153
+ class WebhookSinkConfig(StrictModel):
154
+ """POST governance results to a webhook URL."""
155
+
156
+ type: Literal["webhook"] = "webhook"
157
+ url: str
158
+ filter: list[str] = Field(default_factory=lambda: ["deny", "escalate"])
159
+ timeout: float = 10.0
160
+ max_retries: int = 3
161
+ headers: dict[str, str] = Field(default_factory=dict)
162
+
163
+
164
+ class OtelSinkConfig(StrictModel):
165
+ """Export governance results as OTel spans."""
166
+
167
+ type: Literal["otel"] = "otel"
168
+ endpoint: str = "http://localhost:4318/v1/traces"
169
+ service_name: str = "traceforge"
170
+ headers: dict[str, str] = Field(default_factory=dict)
171
+
172
+
173
+ class S3SinkConfig(StrictModel):
174
+ """S3-compatible object store sink."""
175
+
176
+ type: Literal["s3"] = "s3"
177
+ bucket: str
178
+ prefix: str = ""
179
+ region: str | None = None
180
+ endpoint_url: str | None = None
181
+
182
+
183
+ # Discriminated union of all serializable sink types
184
+ SinkConfig = Annotated[
185
+ SqliteSinkConfig
186
+ | JsonlSinkConfig
187
+ | ConsoleSinkConfig
188
+ | WebhookSinkConfig
189
+ | OtelSinkConfig
190
+ | S3SinkConfig,
191
+ Field(discriminator="type"),
192
+ ]
193
+
194
+
195
+ # ─── Pipeline Config ─────────────────────────────────────────────────────────
196
+
197
+
198
+ class PipelineConfig(StrictModel):
199
+ """A single ingestion pipeline: source → adapter → sinks."""
200
+
201
+ name: str
202
+ source: SourceConfig
203
+ adapter: AdapterConfig
204
+ sinks: list[SinkConfig] = Field(min_length=1)
205
+
206
+ @field_validator("name")
207
+ @classmethod
208
+ def _name_non_empty(cls, v: str) -> str:
209
+ if not v.strip():
210
+ raise ValueError("pipeline name must be non-empty")
211
+ return v.strip()
212
+
213
+
214
+ # ─── SDK Config ──────────────────────────────────────────────────────────────
215
+
216
+
217
+ class SDKConfig(StrictModel):
218
+ """Configuration for traceforge SDK (in-process push mode)."""
219
+
220
+ batch_size: int = Field(default=64, ge=1)
221
+ flush_interval: float = Field(default=5.0, gt=0)
222
+ max_queue_size: int = Field(default=10000, ge=1)
223
+
224
+
225
+ # ─── Governance Config ────────────────────────────────────────────────────────
226
+
227
+
228
+ class BudgetConfig(StrictModel):
229
+ """Budget thresholds for governance scoring."""
230
+
231
+ max_tool_calls: int | None = None
232
+ max_by_effect: dict[str, int] | None = None
233
+ max_by_capability: dict[str, int] | None = None
234
+ max_by_scope: dict[str, int] | None = None
235
+
236
+
237
+ class SubprocessGateConfig(StrictModel):
238
+ """External preflight gate that shells out to a decider command per call.
239
+
240
+ The JSON request is written to the process's stdin; the JSON verdict is read
241
+ from stdout. Fail-closed by default (any error/timeout/bad output -> DENY).
242
+ """
243
+
244
+ type: Literal["subprocess"] = "subprocess"
245
+ command: str
246
+ timeout: float = Field(default=10.0, gt=0)
247
+ fail_open: bool = False
248
+ max_input_bytes: int = Field(default=65536, gt=0)
249
+
250
+
251
+ class HttpGateConfig(StrictModel):
252
+ """External preflight gate that POSTs the request to a persistent HTTP PDP.
253
+
254
+ Recommended mode (e.g. an OPA REST server). Fail-closed by default (any
255
+ error/timeout/non-2xx -> DENY). ``headers`` carries auth tokens.
256
+ """
257
+
258
+ type: Literal["http"] = "http"
259
+ endpoint: str
260
+ timeout: float = Field(default=2.0, gt=0)
261
+ fail_open: bool = False
262
+ headers: dict[str, str] = Field(default_factory=dict)
263
+ max_input_bytes: int = Field(default=65536, gt=0)
264
+
265
+
266
+ # Discriminated union of external (out-of-process) preflight gate types
267
+ ExternalGateConfig = Annotated[
268
+ SubprocessGateConfig | HttpGateConfig,
269
+ Field(discriminator="type"),
270
+ ]
271
+
272
+
273
+ class GovernanceConfig(StrictModel):
274
+ """Governance pipeline configuration.
275
+
276
+ Same shape in YAML and SDK::
277
+
278
+ # traceforge.yaml
279
+ governance:
280
+ db_path: ./traceforge.db
281
+ project_root: .
282
+ pii_scanning: true
283
+ integrity_verification: true
284
+ budget:
285
+ max_tool_calls: 200
286
+ max_by_effect:
287
+ destructive: 10
288
+
289
+ # SDK equivalent
290
+ GovernanceConfig(
291
+ db_path="./traceforge.db",
292
+ project_root=".",
293
+ pii_scanning=True,
294
+ integrity_verification=True,
295
+ budget=BudgetConfig(max_tool_calls=200, max_by_effect={"destructive": 10}),
296
+ )
297
+
298
+ Preflight gating is configured one of two mutually-exclusive ways:
299
+
300
+ * ``tool_preflight_gate`` — a dotted import path to an in-process Python
301
+ ``PreflightGate`` callable (SDK / code-defined policy).
302
+ * ``preflight_gate`` — an out-of-process, YAML-native external decider
303
+ (``http`` or ``subprocess``); see :data:`ExternalGateConfig`.
304
+
305
+ Setting both is a configuration error (raised by the validator below).
306
+ """
307
+
308
+ db_path: str | None = None # None = in-memory
309
+ project_root: str | None = None
310
+ rules_path: str | None = None # custom rules YAML override
311
+ pii_scanning: bool = True
312
+ integrity_verification: bool = True # content-hash tamper detection (baseline + drift)
313
+ budget: BudgetConfig = Field(default_factory=BudgetConfig)
314
+ tool_preflight_gate: str | None = None # dotted import path (e.g. "myapp.policies.my_policy")
315
+ preflight_gate: ExternalGateConfig | None = (
316
+ None # out-of-process external decider (http/subprocess)
317
+ )
318
+
319
+ @model_validator(mode="after")
320
+ def _preflight_gate_exclusivity(self) -> "GovernanceConfig":
321
+ """``tool_preflight_gate`` and ``preflight_gate`` are mutually exclusive.
322
+
323
+ They configure the same slot (the preflight gate chain) via two different
324
+ mechanisms; allowing both would make precedence ambiguous. Fail loudly.
325
+ """
326
+ if self.tool_preflight_gate is not None and self.preflight_gate is not None:
327
+ raise ValueError(
328
+ "governance.tool_preflight_gate (dotted import path) and "
329
+ "governance.preflight_gate (external gate config) are mutually "
330
+ "exclusive — set only one."
331
+ )
332
+ return self
333
+
334
+
335
+ # ─── Score API Config ────────────────────────────────────────────────────────
336
+
337
+
338
+ class ScoreAPIConfig(StrictModel):
339
+ """Configuration for the preflight scoring HTTP endpoint."""
340
+
341
+ enabled: bool = True
342
+ listen: str = "localhost:7331"
343
+ socket: str | None = None # Unix socket alternative (lower latency)
344
+
345
+
346
+ # ─── Auto-Detect Config ──────────────────────────────────────────────────────
347
+
348
+
349
+ class AutoDetectConfig(StrictModel):
350
+ """Framework auto-detection settings."""
351
+
352
+ enabled: bool = True
353
+ frameworks: list[str] = Field(default_factory=list) # empty = detect all known
354
+
355
+
356
+ # ─── Phase Tracker Config ────────────────────────────────────────────────────
357
+
358
+
359
+ class PhaseTrackerConfig(StrictModel):
360
+ """Phase tracker (debounced majority-vote segmentation) configuration.
361
+
362
+ No hardcoded numeric constants live in the tracker; they all live here.
363
+ Defaults are evidence-based starting points, not magic numbers, and are
364
+ meant to be replaced by the values measured in the
365
+ ``phase-tracker-window-sweep`` calibration experiment.
366
+
367
+ # traceforge.yaml
368
+ phase_tracker:
369
+ enabled: true
370
+ window_size: 3
371
+ debounce: 2
372
+ phase_root_depth: 1
373
+ """
374
+
375
+ enabled: bool = True
376
+
377
+ # Sliding window of activity-derived phase signals whose mode is the current
378
+ # phase. Default seeded from Banos 2014 / Wang 2019 short-stream HAR work;
379
+ # recalibrate via phase-tracker-window-sweep.yaml.
380
+ window_size: int = Field(default=3, ge=1)
381
+
382
+ # Consecutive events the window mode must hold a new value before a boundary
383
+ # commits. Higher = fewer spurious transitions, more detection latency.
384
+ debounce: int = Field(default=2, ge=1)
385
+
386
+ # Dot-path depth used to group activities into the root compared at
387
+ # boundaries (1 => 'verification.lint' and 'verification.test' share root
388
+ # 'verification' and do not open a new block).
389
+ phase_root_depth: int = Field(default=1, ge=1)
390
+
391
+
392
+ # ─── Title Config ─────────────────────────────────────────────────────────────
393
+
394
+
395
+ class SessionNamingHeuristicConfig(StrictModel):
396
+ """Deterministic, zero-cost session-title heuristic (the default floor).
397
+
398
+ Session naming derives a session title from the first substantive user
399
+ message. The heuristic is *extractive* -- it reuses the user's own words --
400
+ so it is coherent by construction (no model, no network, no key). Its ceiling
401
+ is the phrasing already in the message; for abstractive titles, opt into the
402
+ ``api`` strategy.
403
+ """
404
+
405
+ method: Literal["clip", "imperative", "keyphrase", "hybrid"] = "hybrid"
406
+ max_words: int = Field(default=8, ge=1)
407
+ max_chars: int = Field(default=60, ge=8)
408
+
409
+
410
+ class TitleApiConfig(StrictModel):
411
+ """Shared opt-in LiteLLM API-tier settings for title generation.
412
+
413
+ Both session naming and activity/step titling reach any LLM provider through
414
+ this one typed surface. The API key is **never** stored here. LiteLLM reads it
415
+ from the provider's conventional environment variable (``OPENAI_API_KEY``,
416
+ ``ANTHROPIC_API_KEY``, ``AZURE_API_KEY`` …). Set ``api_key_env`` only to name
417
+ a *different* env var to read from. ``model`` is any LiteLLM model string, so
418
+ OpenAI, Azure, Anthropic, Gemini, and local runtimes (``ollama/…``, ``vllm/…``
419
+ via ``api_base``) are all reachable through one code path.
420
+ """
421
+
422
+ model: str = "gpt-4o-mini"
423
+ api_base: str | None = None
424
+ api_key_env: str | None = None
425
+ timeout: float = Field(default=10.0, gt=0)
426
+ max_tokens: int = Field(default=24, ge=1)
427
+
428
+
429
+ class SessionNamingApiConfig(TitleApiConfig):
430
+ """Opt-in LLM API tier for session naming, served via LiteLLM.
431
+
432
+ Inherits the shared :class:`TitleApiConfig` surface unchanged: session titles
433
+ are short, so the base ``max_tokens`` default is sufficient.
434
+ """
435
+
436
+
437
+ class SessionNamingConfig(StrictModel):
438
+ """How the session title is generated from the first substantive user message.
439
+
440
+ # traceforge.yaml
441
+ title:
442
+ session_naming:
443
+ strategy: heuristic # heuristic | api (DEFAULT: heuristic)
444
+ heuristic:
445
+ method: hybrid # clip | imperative | keyphrase | hybrid
446
+ max_words: 8
447
+ max_chars: 60
448
+ api:
449
+ model: gpt-4o-mini # any LiteLLM model string
450
+ api_base: null # azure / ollama / vllm / openai-compatible
451
+ api_key_env: null # override env var name (else LiteLLM default)
452
+ timeout: 10
453
+ max_tokens: 24
454
+
455
+ ``heuristic`` (default) is free and coherent by construction. ``api`` engages
456
+ LiteLLM for an abstractive title and takes effect **only** when the provider's
457
+ API key is present in the environment; otherwise it silently falls back to the
458
+ heuristic, so a missing key never errors or blocks.
459
+ """
460
+
461
+ strategy: Literal["heuristic", "api"] = "heuristic"
462
+ heuristic: SessionNamingHeuristicConfig = Field(default_factory=SessionNamingHeuristicConfig)
463
+ api: SessionNamingApiConfig = Field(default_factory=SessionNamingApiConfig)
464
+
465
+
466
+ class ActivityTitlingApiConfig(TitleApiConfig):
467
+ """Opt-in LLM API tier for activity/step (span) titling, served via LiteLLM.
468
+
469
+ One call per closed activity returns the activity title **and** all of its
470
+ step titles together (as JSON), so the default ``max_tokens`` is larger than
471
+ session naming's to fit a multi-title response.
472
+ """
473
+
474
+ max_tokens: int = Field(default=256, ge=1)
475
+
476
+
477
+ class ActivityTitlingConfig(StrictModel):
478
+ """How activity/step (span) titles are generated for a closed activity.
479
+
480
+ # traceforge.yaml
481
+ title:
482
+ activity_titling:
483
+ strategy: model # model | api (DEFAULT: model)
484
+ api:
485
+ model: gpt-4o-mini # any LiteLLM model string
486
+ api_base: null # azure / ollama / vllm / openai-compatible
487
+ api_key_env: null # override env var name (else LiteLLM default)
488
+ timeout: 10
489
+ max_tokens: 256
490
+
491
+ ``model`` (default) titles each closed activity and its steps with the
492
+ packaged, offline ONNX ``traceforge-title-model`` — free, no key, no network,
493
+ byte-for-byte the shipped behavior. ``api`` engages LiteLLM to *refine* those
494
+ titles and takes effect **only** when the provider's API key is present in the
495
+ environment; otherwise it silently keeps the packaged-model titles, so a
496
+ missing key never errors or blocks.
497
+
498
+ The packaged title is always emitted the instant an activity closes; when the
499
+ API tier is configured and keyed the abstractive upgrade arrives **later** as
500
+ an append-only title update, computed off the hot path so live event emission
501
+ is never delayed by the network.
502
+ """
503
+
504
+ strategy: Literal["model", "api"] = "model"
505
+ api: ActivityTitlingApiConfig = Field(default_factory=ActivityTitlingApiConfig)
506
+
507
+
508
+ class TitleConfig(StrictModel):
509
+ """Titling configuration.
510
+
511
+ Two title surfaces, each a free/offline floor with an opt-in LiteLLM API tier
512
+ that engages only when a provider key is present in the environment:
513
+
514
+ * ``session_naming`` — the session title derived from the first substantive
515
+ user message. Floor: a zero-cost extractive heuristic over the user's own
516
+ words; see :class:`SessionNamingConfig`.
517
+ * ``activity_titling`` — the activity/step (span) titles. Floor: the packaged,
518
+ offline ONNX ``traceforge-title-model`` (proven strong at that task and
519
+ shipped with every install); see :class:`ActivityTitlingConfig`.
520
+
521
+ In both cases the offline floor is emitted immediately and the API upgrade
522
+ (when configured and keyed) is applied later, off the hot path, as an
523
+ append-only title update — never blocking live event emission.
524
+ """
525
+
526
+ session_naming: SessionNamingConfig = Field(default_factory=SessionNamingConfig)
527
+ activity_titling: ActivityTitlingConfig = Field(default_factory=ActivityTitlingConfig)
528
+
529
+
530
+ # ─── Root Config ─────────────────────────────────────────────────────────────
531
+
532
+
533
+ class TraceforgeConfig(StrictModel):
534
+ """Root traceforge configuration.
535
+
536
+ Loaded from (in precedence order):
537
+ 1. Constructor/init kwargs (highest)
538
+ 2. Environment variables (TRACEFORGE_ prefix)
539
+ 3. Project-local: ./traceforge.yaml
540
+ 4. User-global: ~/.traceforge/config.yaml
541
+ 5. Defaults (lowest)
542
+
543
+ Config file location override: TRACEFORGE_CONFIG env var.
544
+ """
545
+
546
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
547
+
548
+ # Mapping search paths (in addition to bundled)
549
+ mappings_dirs: list[Path] = Field(default_factory=list)
550
+
551
+ # Named pipelines (CLI / file-watch mode)
552
+ pipelines: list[PipelineConfig] = Field(default_factory=list)
553
+
554
+ # SDK-mode configuration
555
+ sdk: SDKConfig = Field(default_factory=SDKConfig)
556
+
557
+ # Governance pipeline configuration
558
+ governance: GovernanceConfig = Field(default_factory=GovernanceConfig)
559
+
560
+ # Score API (preflight scoring endpoint)
561
+ score: ScoreAPIConfig = Field(default_factory=ScoreAPIConfig)
562
+
563
+ # Auto-detection of installed frameworks
564
+ auto_detect: AutoDetectConfig = Field(default_factory=AutoDetectConfig)
565
+
566
+ # Phase tracker (session-level phase segmentation)
567
+ phase_tracker: PhaseTrackerConfig = Field(default_factory=PhaseTrackerConfig)
568
+
569
+ # Titling (span titles + configurable session naming)
570
+ title: TitleConfig = Field(default_factory=TitleConfig)
571
+
572
+ @field_validator("pipelines")
573
+ @classmethod
574
+ def _unique_pipeline_names(cls, v: list[PipelineConfig]) -> list[PipelineConfig]:
575
+ names = [p.name for p in v]
576
+ dupes = [n for n in names if names.count(n) > 1]
577
+ if dupes:
578
+ raise ValueError(f"duplicate pipeline names: {set(dupes)}")
579
+ return v