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
traceforge/enricher.py ADDED
@@ -0,0 +1,576 @@
1
+ """Enricher — stateful per-session event enrichment (tool pairing, classification, phase)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from traceforge.classify import classify_shell, classify_tool, get_default_engine
10
+ from traceforge.classify.cmd import classify_cmd_command
11
+ from traceforge.classify.config import ClassificationEngine, ClassifyConfig, load_config
12
+ from traceforge.classify.core import Classification, PhaseSegment
13
+ from traceforge.classify.coding import CodingMechanism, CodingScope
14
+ from traceforge.classify.powershell import classify_powershell_command
15
+ from traceforge.classify.risk import assess_risk, assess_tool_risk
16
+ from traceforge.classify.tools import normalize_tool_name
17
+ from traceforge.classify.workflow import Phase, Visibility
18
+ from traceforge.types import EventKind, EventMetadata, SessionEvent
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class Enricher:
24
+ """Stateful per-session enricher that pairs tool events and classifies them."""
25
+
26
+ def __init__(
27
+ self,
28
+ custom_classifications: dict[str, Classification] | None = None,
29
+ config: ClassifyConfig | None = None,
30
+ config_path: Path | str | None = None,
31
+ ) -> None:
32
+ """
33
+ Args:
34
+ custom_classifications: Optional tool_name→Classification map
35
+ that extends/overrides built-in classifications.
36
+ config: Optional pre-built ClassifyConfig (takes priority over config_path).
37
+ config_path: Optional path to a YAML config file. If neither config nor
38
+ config_path is provided, the default discovery chain is used.
39
+ """
40
+ self._custom_classifications = custom_classifications
41
+ self._pending: dict[tuple[str, str], SessionEvent] = {}
42
+
43
+ # Build engine eagerly so config errors surface at construction time
44
+ if config is not None:
45
+ self._engine: ClassificationEngine = ClassificationEngine(config)
46
+ elif config_path is not None:
47
+ self._engine = ClassificationEngine(load_config(Path(config_path)))
48
+ else:
49
+ self._engine = get_default_engine()
50
+
51
+ def process(self, event: SessionEvent) -> SessionEvent | list[SessionEvent] | None:
52
+ """Enrich a single event. Returns None if event is buffered (tool_start waiting
53
+ for its tool_complete pair). Returns enriched event when ready. May return a list
54
+ if a displaced orphan start needs to be emitted alongside buffering a new start."""
55
+ if event.metadata is None:
56
+ # Defensive: SessionEvent defaults metadata to EventMetadata(), but a
57
+ # model_construct-built event may carry None. Normalize up front so the
58
+ # pairing/classification helpers below never raise — a raise here would
59
+ # push the raw event straight to sinks and bypass the exactly-once
60
+ # tool-call pairing guarantee the downstream governance stage relies on.
61
+ event = event.model_copy(update={"metadata": EventMetadata()})
62
+ if event.kind == EventKind.TOOL_CALL_STARTED:
63
+ event = self._classify(event)
64
+ event = self._set_visibility(event)
65
+ event = self._set_phase(event)
66
+ tool_call_id = _extract_tool_call_id(event)
67
+ if tool_call_id:
68
+ # Key pending starts by (session_id, tool_call_id): a single
69
+ # Enricher instance serves interleaved sessions, and tool_call_ids
70
+ # are only unique within a session. Keying on the id alone lets
71
+ # one session's start displace another's and lets a completion
72
+ # pair across sessions — double-scoring one real call.
73
+ key = (event.session_id, tool_call_id)
74
+ displaced = self._pending.pop(key, None)
75
+ self._pending[key] = event
76
+ if displaced is not None:
77
+ logger.warning(
78
+ "Duplicate TOOL_START for session_id=%s tool_call_id=%s; "
79
+ "emitting previous as orphan",
80
+ event.session_id,
81
+ tool_call_id,
82
+ )
83
+ orphan_metadata = displaced.metadata.model_copy(update={"duration_ms": None})
84
+ return [displaced.model_copy(update={"metadata": orphan_metadata})]
85
+ return None
86
+ return event
87
+
88
+ if event.kind == EventKind.TOOL_CALL_COMPLETED:
89
+ tool_call_id = _extract_tool_call_id(event)
90
+ start_event = (
91
+ self._pending.get((event.session_id, tool_call_id)) if tool_call_id else None
92
+ )
93
+
94
+ if start_event is not None:
95
+ duration_ms = _compute_duration_ms(start_event.timestamp, event.timestamp)
96
+ # Merge payloads: start is base, complete overrides, but preserve
97
+ # start's _enrichment (classification/risk already computed on start)
98
+ merged_payload = {**start_event.payload, **event.payload}
99
+ start_enrichment = start_event.payload.get("_enrichment")
100
+ if isinstance(start_enrichment, dict):
101
+ complete_enrichment = event.payload.get("_enrichment")
102
+ if isinstance(complete_enrichment, dict):
103
+ merged_payload["_enrichment"] = {**start_enrichment, **complete_enrichment}
104
+ else:
105
+ merged_payload["_enrichment"] = start_enrichment
106
+ merged_metadata = _merge_metadata(start_event.metadata, event.metadata, duration_ms)
107
+ event = event.model_copy(
108
+ update={"payload": merged_payload, "metadata": merged_metadata}
109
+ )
110
+ del self._pending[(event.session_id, tool_call_id)]
111
+ else:
112
+ event = self._classify(event)
113
+ event = self._set_visibility(event)
114
+
115
+ event = self._set_phase(event)
116
+ return event
117
+
118
+ if event.kind == EventKind.SESSION_ENDED:
119
+ # Flush this session's still-buffered tool starts as orphans BEFORE the
120
+ # session-ended event. A governance stage finalizes and evicts session
121
+ # state on session.ended; without this, buffered unpaired starts would
122
+ # surface only at pipeline close (Enricher.flush) — i.e. after the
123
+ # session summary was already written — and be scored into a resurrected
124
+ # state that never gets finalized.
125
+ #
126
+ # This assumes session.ended is terminal for the session (the contract
127
+ # every supported adapter emits). A tool.call.completed arriving AFTER
128
+ # its own session.ended is a malformed stream: its start was already
129
+ # flushed+scored here, so the late completion would be observed a second
130
+ # time into a resurrected state. We do not carry per-session tombstones
131
+ # to dedup that case — it does not occur in well-formed input.
132
+ orphans = self._flush_session(event.session_id)
133
+ event = self._set_visibility(event)
134
+ event = self._set_phase(event)
135
+ return [*orphans, event] if orphans else event
136
+
137
+ # Non-tool events: set visibility and phase, pass through
138
+ event = self._set_visibility(event)
139
+ event = self._set_phase(event)
140
+ return event
141
+
142
+ def flush(self) -> list[SessionEvent]:
143
+ """Emit any buffered events (unpaired tool_starts) with duration_ms=None.
144
+ Call at session end."""
145
+ buffered = list(self._pending.values())
146
+ result: list[SessionEvent] = []
147
+ for event in buffered:
148
+ new_metadata = event.metadata.model_copy(update={"duration_ms": None})
149
+ result.append(event.model_copy(update={"metadata": new_metadata}))
150
+ self._pending.clear()
151
+ return result
152
+
153
+ def _flush_session(self, session_id: str) -> list[SessionEvent]:
154
+ """Emit and remove this session's buffered unpaired tool-starts as orphans.
155
+
156
+ Like :meth:`flush` but scoped to a single session — used to drain pending
157
+ starts the instant that session ends, rather than waiting for pipeline
158
+ close, so a governance stage sees them before it finalizes the session."""
159
+ result: list[SessionEvent] = []
160
+ for key, event in list(self._pending.items()):
161
+ if event.session_id == session_id:
162
+ new_metadata = event.metadata.model_copy(update={"duration_ms": None})
163
+ result.append(event.model_copy(update={"metadata": new_metadata}))
164
+ del self._pending[key]
165
+ return result
166
+
167
+ # --- Private helpers ---
168
+
169
+ def _classify(self, event: SessionEvent) -> SessionEvent:
170
+ """Set metadata.classification from the tool name and payload.
171
+
172
+ For shell executor tools, performs deep tree-sitter classification of the
173
+ actual command rather than using the static shell entry.
174
+ After classification, refines scope based on file paths in the payload.
175
+ Also computes risk score for shell commands.
176
+ """
177
+ tool_name = event.payload.get("tool_name", "")
178
+ if not tool_name:
179
+ return event
180
+
181
+ canonical = normalize_tool_name(tool_name, engine=self._engine)
182
+
183
+ is_shell = canonical == "shell"
184
+ if is_shell:
185
+ cls = self._classify_shell_command(event)
186
+ else:
187
+ cls = classify_tool(tool_name, self._custom_classifications, engine=self._engine)
188
+
189
+ # Refine scope from file paths in payload
190
+ cls = _refine_scope_from_payload(cls, event.payload)
191
+
192
+ new_metadata = event.metadata.model_copy(update={"classification": cls})
193
+ event = event.model_copy(update={"metadata": new_metadata})
194
+
195
+ # Risk scoring
196
+ if self._engine.risk_config is not None:
197
+ if is_shell:
198
+ event = self._assess_risk(event, cls)
199
+ else:
200
+ event = self._assess_tool_risk(event, cls)
201
+
202
+ return event
203
+
204
+ def _classify_shell_command(self, event: SessionEvent) -> Classification:
205
+ """Deep-classify the actual shell command via the appropriate dialect classifier."""
206
+ arguments = event.payload.get("arguments", {})
207
+ command = ""
208
+ if isinstance(arguments, dict):
209
+ command = arguments.get("command", "") or arguments.get("cmd", "")
210
+ elif isinstance(arguments, str):
211
+ command = arguments
212
+
213
+ if not command:
214
+ return Classification(mechanism=CodingMechanism.PROCESS_SHELL, effect=None)
215
+
216
+ # Dispatch to dialect-specific classifier based on raw tool name
217
+ raw_tool = event.payload.get("tool_name", "").lower()
218
+ if raw_tool in ("powershell", "pwsh"):
219
+ return classify_powershell_command(command, engine=self._engine)
220
+ if raw_tool == "cmd":
221
+ return classify_cmd_command(command, engine=self._engine)
222
+
223
+ return classify_shell(command, engine=self._engine)
224
+
225
+ def _assess_risk(self, event: SessionEvent, cls: Classification) -> SessionEvent:
226
+ """Compute risk score for a shell command and store in payload._enrichment."""
227
+ arguments = event.payload.get("arguments", {})
228
+ command = ""
229
+ if isinstance(arguments, dict):
230
+ command = arguments.get("command", "") or arguments.get("cmd", "")
231
+ elif isinstance(arguments, str):
232
+ command = arguments
233
+
234
+ if not command:
235
+ return event
236
+
237
+ risk = assess_risk(
238
+ classification=cls,
239
+ command=command,
240
+ engine=self._engine,
241
+ )
242
+
243
+ # Store risk assessment in payload under _enrichment
244
+ enrichment_raw = event.payload.get("_enrichment")
245
+ enrichment = dict(enrichment_raw) if isinstance(enrichment_raw, dict) else {}
246
+ enrichment["risk"] = {
247
+ "score": risk.score,
248
+ "level": risk.level,
249
+ "confidence": risk.confidence,
250
+ "factors": list(risk.factors),
251
+ "mitre": list(risk.mitre),
252
+ "version": risk.version,
253
+ }
254
+ new_payload = {**event.payload, "_enrichment": enrichment}
255
+ return event.model_copy(update={"payload": new_payload})
256
+
257
+ def _assess_tool_risk(self, event: SessionEvent, cls: Classification) -> SessionEvent:
258
+ """Compute risk score for a native/MCP tool and store in payload._enrichment."""
259
+ # Extract file targets from payload
260
+ targets = _extract_targets_from_payload(event.payload)
261
+
262
+ risk = assess_tool_risk(
263
+ classification=cls,
264
+ engine=self._engine,
265
+ targets=targets or None,
266
+ )
267
+
268
+ enrichment_raw = event.payload.get("_enrichment")
269
+ enrichment = dict(enrichment_raw) if isinstance(enrichment_raw, dict) else {}
270
+ enrichment["risk"] = {
271
+ "score": risk.score,
272
+ "level": risk.level,
273
+ "confidence": risk.confidence,
274
+ "factors": list(risk.factors),
275
+ "mitre": list(risk.mitre),
276
+ "version": risk.version,
277
+ }
278
+ new_payload = {**event.payload, "_enrichment": enrichment}
279
+ return event.model_copy(update={"payload": new_payload})
280
+
281
+ def _set_visibility(self, event: SessionEvent) -> SessionEvent:
282
+ """Set metadata.visibility based on event kind and classification."""
283
+ visibility = Visibility.VISIBLE
284
+
285
+ if event.kind in (EventKind.SESSION_STARTED, EventKind.SESSION_ENDED):
286
+ visibility = Visibility.SYSTEM
287
+ elif event.metadata.classification is not None:
288
+ cls: Classification = event.metadata.classification
289
+ if cls.mechanism.startswith(("communication.system", "communication.internal")):
290
+ visibility = Visibility.SYSTEM
291
+
292
+ if visibility != event.metadata.visibility:
293
+ new_metadata = event.metadata.model_copy(update={"visibility": visibility})
294
+ return event.model_copy(update={"metadata": new_metadata})
295
+ return event
296
+
297
+ def _set_phase(self, event: SessionEvent) -> SessionEvent:
298
+ """Set metadata.phases based on Classification dimensions."""
299
+ phases = self._detect_phases(event)
300
+ if phases != event.metadata.phases:
301
+ new_metadata = event.metadata.model_copy(update={"phases": phases})
302
+ return event.model_copy(update={"metadata": new_metadata})
303
+ return event
304
+
305
+ def _detect_phases(self, event: SessionEvent) -> frozenset[str]:
306
+ """Determine the phase(s) for an event from its Classification."""
307
+ if event.kind in (EventKind.MESSAGE_USER, EventKind.MESSAGE_ASSISTANT):
308
+ return frozenset({Phase.PLANNING})
309
+
310
+ cls: Classification | None = event.metadata.classification
311
+
312
+ if cls is None:
313
+ if event.kind in (EventKind.TOOL_CALL_STARTED, EventKind.TOOL_CALL_COMPLETED):
314
+ return frozenset({Phase.IMPLEMENTATION})
315
+ return frozenset({Phase.PLANNING})
316
+
317
+ return _phases_from_classification(cls)
318
+
319
+
320
+ def _phases_from_classification(cls: Classification) -> frozenset[str]:
321
+ """Derive phases from a Classification.
322
+
323
+ If the classification has a phase_map (built per-command), use it directly.
324
+ Otherwise, derive phases from the aggregate action/role dimensions.
325
+ """
326
+ if cls.phase_map:
327
+ return frozenset(seg.phase for seg in cls.phase_map)
328
+
329
+ # Rule table: (predicate, phase) — evaluated in order, all matching rules fire
330
+ phases: set[str] = set()
331
+
332
+ _PHASE_RULES: list[tuple[str, str]] = [
333
+ # (action_or_check, phase)
334
+ ("validate", Phase.VERIFICATION),
335
+ ("deliver", Phase.REVIEW),
336
+ ("retrieve", Phase.EXPLORATION),
337
+ ("analyze", Phase.EXPLORATION),
338
+ ("configure", Phase.IMPLEMENTATION),
339
+ ("execute", Phase.IMPLEMENTATION),
340
+ ]
341
+ for action, phase in _PHASE_RULES:
342
+ if cls.has_action(action):
343
+ phases.add(phase)
344
+
345
+ # VCS persist → review (not implementation)
346
+ if cls.has_role("persistence.version_control") and (
347
+ cls.has_action("persist") or cls.has_action("deliver")
348
+ ):
349
+ phases.add(Phase.REVIEW)
350
+ elif cls.has_action("modify") or cls.has_action("persist"):
351
+ phases.add(Phase.IMPLEMENTATION)
352
+
353
+ # Mechanism-based rules
354
+ if cls.mechanism.startswith("communication"):
355
+ phases.add(Phase.PLANNING)
356
+ elif cls.mechanism.startswith("delegation"):
357
+ phases.add(Phase.IMPLEMENTATION)
358
+ elif cls.mechanism == "filesystem" and cls.effect == "read_only":
359
+ phases.add(Phase.EXPLORATION)
360
+
361
+ return frozenset(phases) if phases else frozenset({Phase.IMPLEMENTATION})
362
+
363
+
364
+ # ── Scope inference from file paths ──
365
+
366
+ # Path segment patterns → scope (matched against normalized path segments)
367
+ _TEST_SEGMENTS = frozenset({"tests", "test", "spec", "specs", "__tests__", "__test__"})
368
+ _TEST_FILE_PATTERNS = ("_test.", "test_", ".test.", ".spec.")
369
+ _DOC_SEGMENTS = frozenset({"docs", "doc", "documentation"})
370
+ _CI_FILES = frozenset(
371
+ {
372
+ "jenkinsfile",
373
+ ".travis.yml",
374
+ ".circleci",
375
+ "azure-pipelines.yml",
376
+ "bitbucket-pipelines.yml",
377
+ "cloudbuild.yaml",
378
+ }
379
+ )
380
+
381
+ _DEP_FILES = frozenset(
382
+ {
383
+ "package.json",
384
+ "package-lock.json",
385
+ "yarn.lock",
386
+ "pnpm-lock.yaml",
387
+ "requirements.txt",
388
+ "setup.py",
389
+ "setup.cfg",
390
+ "pyproject.toml",
391
+ "poetry.lock",
392
+ "cargo.toml",
393
+ "cargo.lock",
394
+ "go.mod",
395
+ "go.sum",
396
+ "gemfile",
397
+ "gemfile.lock",
398
+ "composer.json",
399
+ "composer.lock",
400
+ "pom.xml",
401
+ "build.gradle",
402
+ }
403
+ )
404
+ _ENV_FILES = frozenset({".env", ".envrc", ".env.local", ".env.development", ".env.production"})
405
+ _INFRA_EXTENSIONS = (".tf", ".tfvars", ".hcl")
406
+ _INFRA_DIRS = frozenset({"helm", "charts", "k8s", "kubernetes", "terraform", "infra"})
407
+ _CONTAINER_FILES = frozenset({"docker-compose.yml", "docker-compose.yaml", ".dockerignore"})
408
+ _DOC_FILES = frozenset({"readme.md", "contributing.md", "changelog.md", "license.md"})
409
+ _PAYLOAD_PATH_KEYS = ("path", "file_path", "file", "filename")
410
+
411
+
412
+ def _infer_scope_from_path(path: str) -> str | None:
413
+ """Infer a CodingScope from a file path. Returns None if no pattern matches."""
414
+ if not path:
415
+ return None
416
+
417
+ normalized = path.replace("\\", "/").lower()
418
+ segments = normalized.split("/")
419
+ basename = segments[-1] if segments else ""
420
+
421
+ if _TEST_SEGMENTS.intersection(segments):
422
+ return CodingScope.TEST_CODE
423
+ if any(p in basename for p in _TEST_FILE_PATTERNS):
424
+ return CodingScope.TEST_CODE
425
+
426
+ if ".github" in segments and ("workflows" in segments or basename in ("dependabot.yml",)):
427
+ return CodingScope.CI_CD_CONFIG
428
+ if basename in _CI_FILES:
429
+ return CodingScope.CI_CD_CONFIG
430
+
431
+ if basename.startswith("dockerfile") or basename in _CONTAINER_FILES:
432
+ return CodingScope.CONTAINER_IMAGE
433
+
434
+ if _DOC_SEGMENTS.intersection(segments):
435
+ return CodingScope.DOCUMENTATION
436
+ if basename in _DOC_FILES:
437
+ return CodingScope.DOCUMENTATION
438
+
439
+ if basename in _DEP_FILES:
440
+ return CodingScope.DEPENDENCY
441
+
442
+ if basename in _ENV_FILES:
443
+ return CodingScope.ENVIRONMENT
444
+
445
+ if any(basename.endswith(ext) for ext in _INFRA_EXTENSIONS):
446
+ return CodingScope.INFRASTRUCTURE
447
+ if _INFRA_DIRS.intersection(segments):
448
+ return CodingScope.INFRASTRUCTURE
449
+
450
+ return None
451
+
452
+
453
+ def _extract_path_from_payload(payload: dict) -> str:
454
+ """Extract the first file path string from common payload keys."""
455
+ for key in _PAYLOAD_PATH_KEYS:
456
+ val = payload.get(key, "")
457
+ if isinstance(val, str) and val:
458
+ return val
459
+ args = payload.get("arguments", {})
460
+ if isinstance(args, dict):
461
+ for key in _PAYLOAD_PATH_KEYS:
462
+ val = args.get(key, "")
463
+ if isinstance(val, str) and val:
464
+ return val
465
+ return ""
466
+
467
+
468
+ def _refine_scope_from_payload(cls: Classification, payload: dict) -> Classification:
469
+ """Refine classification scope based on file paths in the event payload.
470
+
471
+ Only applies to filesystem-mechanism tools. Updates both top-level scope
472
+ and phase_map segment scopes for consistency.
473
+ """
474
+ if not cls.mechanism.startswith("filesystem"):
475
+ return cls
476
+
477
+ file_path = _extract_path_from_payload(payload)
478
+ if not file_path:
479
+ return cls
480
+
481
+ inferred = _infer_scope_from_path(file_path)
482
+ if inferred is None:
483
+ return cls
484
+
485
+ # Don't override if the inferred scope is already present
486
+ if inferred in cls.scope:
487
+ return cls
488
+
489
+ # Build new scope (replace default source_code with inferred, or add)
490
+ new_scope = set(cls.scope)
491
+ if CodingScope.SOURCE_CODE in new_scope:
492
+ new_scope.discard(CodingScope.SOURCE_CODE)
493
+ new_scope.add(inferred)
494
+ frozen_scope = frozenset(new_scope)
495
+
496
+ # Update phase_map segments consistently
497
+ new_phase_map = tuple(
498
+ PhaseSegment(
499
+ phase=seg.phase,
500
+ actions=seg.actions,
501
+ scopes=(seg.scopes - {CodingScope.SOURCE_CODE}) | {inferred}
502
+ if CodingScope.SOURCE_CODE in seg.scopes
503
+ else seg.scopes | {inferred},
504
+ roles=seg.roles,
505
+ )
506
+ for seg in cls.phase_map
507
+ )
508
+
509
+ return Classification(
510
+ mechanism=cls.mechanism,
511
+ effect=cls.effect,
512
+ scope=frozen_scope,
513
+ role=cls.role,
514
+ action=cls.action,
515
+ capability=cls.capability,
516
+ structure=cls.structure,
517
+ shell_dialect=cls.shell_dialect,
518
+ binaries=cls.binaries,
519
+ phase_map=new_phase_map,
520
+ )
521
+
522
+
523
+ def _compute_duration_ms(start: datetime, end: datetime) -> float:
524
+ """Compute duration in milliseconds between two timestamps."""
525
+ delta = (end - start).total_seconds() * 1000.0
526
+ return max(delta, 0.0)
527
+
528
+
529
+ def _extract_tool_call_id(event: SessionEvent) -> str | None:
530
+ """Extract and validate tool_call_id from event payload.
531
+ Returns None if missing, empty, or non-string."""
532
+ value = event.payload.get("tool_call_id")
533
+ if isinstance(value, str) and value:
534
+ return value
535
+ if value is not None and not isinstance(value, str):
536
+ logger.debug("Ignoring non-string tool_call_id: %r", value)
537
+ return None
538
+
539
+
540
+ def _extract_targets_from_payload(payload: dict) -> list[str]:
541
+ """Extract file path targets from event payload for risk scoring."""
542
+ targets: list[str] = []
543
+ primary = _extract_path_from_payload(payload)
544
+ if primary:
545
+ targets.append(primary)
546
+ # Also pick up pattern/glob from arguments
547
+ args = payload.get("arguments", {})
548
+ if isinstance(args, dict):
549
+ for key in ("pattern", "glob"):
550
+ val = args.get(key, "")
551
+ if isinstance(val, str) and val and val not in targets:
552
+ targets.append(val)
553
+ return targets
554
+
555
+
556
+ def _merge_metadata(
557
+ start: EventMetadata, complete: EventMetadata, duration_ms: float
558
+ ) -> EventMetadata:
559
+ """Merge metadata from start and complete events. Start is the base;
560
+ non-None complete fields override. Duration is always set from computation.
561
+ Classification and visibility come from start (authoritative)."""
562
+ updates: dict[str, object] = {"duration_ms": duration_ms}
563
+ _start_authoritative = {"classification", "visibility"}
564
+ for field_name in EventMetadata.model_fields:
565
+ if field_name == "duration_ms":
566
+ continue
567
+ start_val = getattr(start, field_name)
568
+ complete_val = getattr(complete, field_name)
569
+ if field_name in _start_authoritative:
570
+ updates[field_name] = start_val if start_val is not None else complete_val
571
+ else:
572
+ if complete_val is not None:
573
+ updates[field_name] = complete_val
574
+ elif start_val is not None:
575
+ updates[field_name] = start_val
576
+ return EventMetadata(**updates)
@@ -0,0 +1,12 @@
1
+ """Human-readable event formatting for terminal output, reports, and sinks."""
2
+
3
+ from traceforge.formatting.budget import format_budget_summary, format_session_summary
4
+ from traceforge.formatting.density import Density, format_event, format_trace
5
+
6
+ __all__ = [
7
+ "Density",
8
+ "format_budget_summary",
9
+ "format_event",
10
+ "format_session_summary",
11
+ "format_trace",
12
+ ]
@@ -0,0 +1,92 @@
1
+ """Budget and session summary formatting utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+
7
+ from traceforge.types import SessionEvent
8
+
9
+
10
+ def format_budget_summary(budget_snapshot: dict) -> str:
11
+ """Format budget consumption as a compact table string.
12
+
13
+ Expects a dict with keys like:
14
+ total_tool_calls, max_tool_calls,
15
+ by_effect (dict of effect → count),
16
+ by_capability (dict of capability → count),
17
+ by_scope (dict of scope → count).
18
+ """
19
+ lines: list[str] = []
20
+ lines.append("┌─ Budget Summary ────────────────────┐")
21
+
22
+ total = budget_snapshot.get("total_tool_calls", 0)
23
+ max_calls = budget_snapshot.get("max_tool_calls")
24
+ if max_calls is not None:
25
+ pct = (total / max_calls * 100) if max_calls > 0 else 0
26
+ lines.append(f"│ Tool calls: {total}/{max_calls} ({pct:.0f}%)")
27
+ else:
28
+ lines.append(f"│ Tool calls: {total} (no limit)")
29
+
30
+ for category in ("by_effect", "by_capability", "by_scope"):
31
+ data = budget_snapshot.get(category)
32
+ if data:
33
+ label = category.replace("by_", "").title()
34
+ entries = ", ".join(f"{k}={v}" for k, v in sorted(data.items()))
35
+ lines.append(f"│ {label}: {entries}")
36
+
37
+ lines.append("└─────────────────────────────────────┘")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def format_session_summary(
42
+ events: list[SessionEvent],
43
+ *,
44
+ include_risk: bool = True,
45
+ ) -> str:
46
+ """Aggregate stats: event count by kind, risk distribution, top tools.
47
+
48
+ Returns a multi-line summary string suitable for terminal display.
49
+ """
50
+ if not events:
51
+ return "No events recorded."
52
+
53
+ # Count by kind
54
+ kind_counts: Counter[str] = Counter()
55
+ risk_counts: Counter[str] = Counter()
56
+ tool_counts: Counter[str] = Counter()
57
+
58
+ for event in events:
59
+ kind_counts[event.kind] += 1
60
+
61
+ if event.metadata:
62
+ if event.metadata.tool_display:
63
+ tool_counts[event.metadata.tool_display] += 1
64
+ if include_risk and event.metadata.classification:
65
+ cls = event.metadata.classification
66
+ if hasattr(cls, "risk_band") and cls.risk_band:
67
+ risk_counts[str(cls.risk_band)] += 1
68
+
69
+ lines: list[str] = []
70
+ lines.append(f"Session: {events[0].session_id} ({len(events)} events)")
71
+ lines.append("")
72
+
73
+ # Event kinds
74
+ lines.append("Events by kind:")
75
+ for kind, count in kind_counts.most_common(10):
76
+ lines.append(f" {kind:<35} {count:>4}")
77
+
78
+ # Top tools
79
+ if tool_counts:
80
+ lines.append("")
81
+ lines.append("Top tools:")
82
+ for tool, count in tool_counts.most_common(5):
83
+ lines.append(f" {tool:<35} {count:>4}")
84
+
85
+ # Risk distribution
86
+ if include_risk and risk_counts:
87
+ lines.append("")
88
+ lines.append("Risk distribution:")
89
+ for risk, count in risk_counts.most_common():
90
+ lines.append(f" {risk:<35} {count:>4}")
91
+
92
+ return "\n".join(lines)