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,30 @@
1
+ """Phase tracker: streaming session-level phase segmentation.
2
+
3
+ Public surface:
4
+
5
+ * :class:`PhaseTracker` — the streaming segmenter.
6
+ * :class:`PhaseBlock`, :class:`PhaseTransition`, :class:`PhaseStats`,
7
+ :class:`PhaseTimeline`, :class:`PhaseSummary` — frozen output types.
8
+ * :func:`resolve_phase_root` — phase dot-path -> boundary-comparison root.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .models import (
14
+ PhaseBlock,
15
+ PhaseStats,
16
+ PhaseSummary,
17
+ PhaseTimeline,
18
+ PhaseTransition,
19
+ )
20
+ from .phase_tracker import PhaseTracker, resolve_phase_root
21
+
22
+ __all__ = [
23
+ "PhaseTracker",
24
+ "resolve_phase_root",
25
+ "PhaseBlock",
26
+ "PhaseTransition",
27
+ "PhaseStats",
28
+ "PhaseTimeline",
29
+ "PhaseSummary",
30
+ ]
@@ -0,0 +1,115 @@
1
+ """Output data types for the phase tracker.
2
+
3
+ All types are frozen, following project convention: once a block or summary is
4
+ emitted it is immutable. See docs/design-phase-tracker.md for the full design.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PhaseBlock:
15
+ """A contiguous run of events dominated by a single phase.
16
+
17
+ Boundaries are determined by the debounced majority-vote algorithm. Phase
18
+ blocks are first-class pipeline data: emitted to sinks and the system DB on
19
+ each boundary commit.
20
+ """
21
+
22
+ session_id: str
23
+ phase: str
24
+ """Dominant phase, dot-path string (e.g. 'verification.lint'). Derived from
25
+ the per-event phase signals in this block."""
26
+
27
+ phase_root: str
28
+ """Root phase used for boundary detection (e.g. 'verification'). What the
29
+ majority-vote window compares."""
30
+
31
+ start_time: datetime
32
+ end_time: datetime
33
+ event_count: int
34
+
35
+ tool_names: tuple[str, ...] = ()
36
+ """Ordered tool names invoked during this block."""
37
+
38
+ dominant_motivation: str | None = None
39
+ """Most common motivation intent across events in this block, or None."""
40
+
41
+ minority_phases: tuple[tuple[str, int], ...] = ()
42
+ """Phase signals suggesting a different phase during this block, sorted by
43
+ count desc. E.g. (('exploration', 3),) means 3 exploration-signal events
44
+ appeared inside an implementation block."""
45
+
46
+ @property
47
+ def duration_seconds(self) -> float:
48
+ return (self.end_time - self.start_time).total_seconds()
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PhaseTransition:
53
+ """A boundary between two adjacent phase blocks. Derivable from consecutive
54
+ blocks; surfaced directly for downstream consumers."""
55
+
56
+ session_id: str
57
+ from_phase: str
58
+ to_phase: str
59
+ timestamp: datetime
60
+ """Timestamp of the first event in the new block."""
61
+
62
+ trigger_event_id: str
63
+ """ID of the event that caused the boundary to commit."""
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class PhaseStats:
68
+ """Aggregate stats for a single phase across the session."""
69
+
70
+ phase: str
71
+ event_count: int
72
+ block_count: int
73
+ total_duration_seconds: float
74
+ fraction_of_events: float
75
+ fraction_of_duration: float
76
+ avg_block_duration_seconds: float
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class PhaseTimeline:
81
+ """Complete phase segmentation of a session.
82
+
83
+ Built incrementally — blocks are appended as they close. The last block may
84
+ be open (still accumulating events) when produced via ``snapshot()``.
85
+ """
86
+
87
+ session_id: str
88
+ blocks: tuple[PhaseBlock, ...] = ()
89
+ transitions: tuple[PhaseTransition, ...] = ()
90
+
91
+ @property
92
+ def total_events(self) -> int:
93
+ return sum(b.event_count for b in self.blocks)
94
+
95
+ @property
96
+ def total_duration_seconds(self) -> float:
97
+ if not self.blocks:
98
+ return 0.0
99
+ return (self.blocks[-1].end_time - self.blocks[0].start_time).total_seconds()
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class PhaseSummary:
104
+ """Aggregate statistics derived from a finalized PhaseTimeline.
105
+
106
+ Provides the '60% implementation, 25% exploration' view.
107
+ """
108
+
109
+ session_id: str
110
+ total_events: int
111
+ total_duration_seconds: float
112
+ by_phase: tuple[PhaseStats, ...] = ()
113
+ transition_count: int = 0
114
+ most_common_transitions: tuple[tuple[str, str, int], ...] = ()
115
+ """Top transition pairs (from, to, count), sorted desc."""
@@ -0,0 +1,288 @@
1
+ """Streaming phase segmentation: the debounced majority-vote PhaseTracker.
2
+
3
+ Consumes per-event phase signals one at a time and maintains an
4
+ incrementally-built phase timeline. The algorithm is a debounced majority vote
5
+ over a sliding window of per-event phase signals: the current block's phase is
6
+ the window mode, and a new block opens only after the mode changes for
7
+ ``debounce`` consecutive events. O(1) per event.
8
+
9
+ The per-event phase signal is the existing ``metadata.phases`` estimate the
10
+ enricher already produces, collapsed to a single dominant phase. This module is
11
+ the *phase* system only; it is independent of the activity/step segmentation
12
+ system and does not consume or emit activity labels.
13
+
14
+ See docs/design-phase-tracker.md for the design and rationale (why not
15
+ BOCPD/HMM: short sessions, crisp categorical phase labels, no posterior
16
+ needed). All numeric knobs come from :class:`PhaseTrackerConfig`; nothing is
17
+ hardcoded here.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections import Counter, deque
23
+ from datetime import datetime
24
+
25
+ from traceforge.config.models import PhaseTrackerConfig
26
+
27
+ from .models import (
28
+ PhaseBlock,
29
+ PhaseStats,
30
+ PhaseSummary,
31
+ PhaseTimeline,
32
+ PhaseTransition,
33
+ )
34
+
35
+
36
+ def resolve_phase_root(phase: str, depth: int) -> str:
37
+ """Group a phase dot-path to the root used for boundary comparison.
38
+
39
+ ``resolve_phase_root('verification.lint', 1) == 'verification'``. The
40
+ tracker compares roots when deciding boundaries, so that sub-phase churn
41
+ (``verification.lint`` -> ``verification.test``) does not open a new block.
42
+ """
43
+
44
+ if depth <= 0:
45
+ return phase
46
+ return ".".join(phase.split(".")[:depth])
47
+
48
+
49
+ def _mode(window: deque[str]) -> str:
50
+ """Most common element in the window, ties broken by most-recent occurrence.
51
+
52
+ Recency tie-breaking keeps the tracker responsive: when two signals are
53
+ equally frequent, the fresher one wins.
54
+ """
55
+
56
+ counts = Counter(window)
57
+ top = max(counts.values())
58
+ for item in reversed(window):
59
+ if counts[item] == top:
60
+ return item
61
+ return window[-1] # unreachable for non-empty window
62
+
63
+
64
+ class _OpenBlock:
65
+ """Mutable accumulator for the block currently being built."""
66
+
67
+ __slots__ = (
68
+ "session_id",
69
+ "phase_root",
70
+ "start_time",
71
+ "end_time",
72
+ "event_count",
73
+ "tool_names",
74
+ "_motivations",
75
+ "_phase_roots",
76
+ "_full_phases",
77
+ )
78
+
79
+ def __init__(self, session_id: str, phase_root: str, timestamp: datetime) -> None:
80
+ self.session_id = session_id
81
+ self.phase_root = phase_root
82
+ self.start_time = timestamp
83
+ self.end_time = timestamp
84
+ self.event_count = 0
85
+ self.tool_names: list[str] = []
86
+ self._motivations: list[str] = []
87
+ self._phase_roots: list[str] = []
88
+ self._full_phases: list[str] = []
89
+
90
+ def add(
91
+ self,
92
+ phase: str,
93
+ phase_root: str,
94
+ timestamp: datetime,
95
+ tool_name: str | None,
96
+ motivation: str | None,
97
+ ) -> None:
98
+ self.event_count += 1
99
+ self.end_time = timestamp
100
+ self._full_phases.append(phase)
101
+ self._phase_roots.append(phase_root)
102
+ if tool_name:
103
+ self.tool_names.append(tool_name)
104
+ if motivation:
105
+ self._motivations.append(motivation)
106
+
107
+ def _dominant_phase(self) -> str:
108
+ """Most common full phase dot-path in the block (falls back to root)."""
109
+
110
+ if not self._full_phases:
111
+ return self.phase_root
112
+ return Counter(self._full_phases).most_common(1)[0][0]
113
+
114
+ def _dominant_motivation(self) -> str | None:
115
+ if not self._motivations:
116
+ return None
117
+ return Counter(self._motivations).most_common(1)[0][0]
118
+
119
+ def _minority_phases(self) -> tuple[tuple[str, int], ...]:
120
+ minority = Counter(r for r in self._phase_roots if r != self.phase_root)
121
+ return tuple(sorted(minority.items(), key=lambda kv: (-kv[1], kv[0])))
122
+
123
+ def close(self) -> PhaseBlock:
124
+ return PhaseBlock(
125
+ session_id=self.session_id,
126
+ phase=self._dominant_phase(),
127
+ phase_root=self.phase_root,
128
+ start_time=self.start_time,
129
+ end_time=self.end_time,
130
+ event_count=self.event_count,
131
+ tool_names=tuple(self.tool_names),
132
+ dominant_motivation=self._dominant_motivation(),
133
+ minority_phases=self._minority_phases(),
134
+ )
135
+
136
+
137
+ class PhaseTracker:
138
+ """Streaming phase segmentation for a single session.
139
+
140
+ Single-writer; no locking. Feed events in session order via
141
+ :meth:`observe`; query the live phase via :attr:`phase`; produce a frozen
142
+ :class:`PhaseTimeline` via :meth:`snapshot`/:meth:`finalize`.
143
+ """
144
+
145
+ def __init__(self, session_id: str, config: PhaseTrackerConfig | None = None) -> None:
146
+ self.session_id = session_id
147
+ self._config = config or PhaseTrackerConfig()
148
+ self._window: deque[str] = deque(maxlen=max(1, self._config.window_size))
149
+ self._open: _OpenBlock | None = None
150
+ self._closed_blocks: list[PhaseBlock] = []
151
+ self._transitions: list[PhaseTransition] = []
152
+ self._candidate_streak = 0
153
+ self._finalized = False
154
+
155
+ @property
156
+ def phase(self) -> str | None:
157
+ """Phase of the currently-open block (real-time query)."""
158
+
159
+ return self._open.phase_root if self._open is not None else None
160
+
161
+ def observe(
162
+ self,
163
+ phase: str,
164
+ timestamp: datetime,
165
+ event_id: str,
166
+ *,
167
+ tool_name: str | None = None,
168
+ motivation: str | None = None,
169
+ ) -> tuple[str, PhaseTransition | None]:
170
+ """Process one event.
171
+
172
+ ``phase`` is the per-event phase signal (the enricher's collapsed
173
+ ``metadata.phases`` estimate). Returns ``(current_phase, transition_or_None)``;
174
+ ``current_phase`` is the debounced phase this tracker intends its consumer
175
+ to stamp onto ``event.metadata.phase``. (This v1 tracker is not wired into
176
+ the live pipeline; the trained ``PhaseInferencer`` is the current producer
177
+ of ``metadata.phase``.)
178
+ """
179
+
180
+ if self._finalized:
181
+ raise RuntimeError("PhaseTracker.observe called after finalize()")
182
+
183
+ root = resolve_phase_root(phase, self._config.phase_root_depth)
184
+ self._window.append(root)
185
+ transition: PhaseTransition | None = None
186
+
187
+ if self._open is None:
188
+ # First event seeds the first block; no transition.
189
+ self._open = _OpenBlock(self.session_id, root, timestamp)
190
+ self._candidate_streak = 0
191
+ else:
192
+ new_mode = _mode(self._window)
193
+ if new_mode == self._open.phase_root:
194
+ self._candidate_streak = 0
195
+ else:
196
+ self._candidate_streak += 1
197
+ if self._candidate_streak >= self._config.debounce:
198
+ self._closed_blocks.append(self._open.close())
199
+ from_phase = self._open.phase_root
200
+ self._open = _OpenBlock(self.session_id, new_mode, timestamp)
201
+ transition = PhaseTransition(
202
+ session_id=self.session_id,
203
+ from_phase=from_phase,
204
+ to_phase=new_mode,
205
+ timestamp=timestamp,
206
+ trigger_event_id=event_id,
207
+ )
208
+ self._transitions.append(transition)
209
+ self._candidate_streak = 0
210
+
211
+ self._open.add(phase, root, timestamp, tool_name, motivation)
212
+ return self._open.phase_root, transition
213
+
214
+ def snapshot(self) -> PhaseTimeline:
215
+ """Immutable snapshot of the timeline so far, including the open block."""
216
+
217
+ blocks = tuple(self._closed_blocks)
218
+ if self._open is not None:
219
+ blocks += (self._open.close(),)
220
+ return PhaseTimeline(
221
+ session_id=self.session_id,
222
+ blocks=blocks,
223
+ transitions=tuple(self._transitions),
224
+ )
225
+
226
+ def finalize(self) -> PhaseTimeline:
227
+ """Close the session and return the final timeline. Idempotent."""
228
+
229
+ if not self._finalized:
230
+ if self._open is not None:
231
+ self._closed_blocks.append(self._open.close())
232
+ self._open = None
233
+ self._finalized = True
234
+ return PhaseTimeline(
235
+ session_id=self.session_id,
236
+ blocks=tuple(self._closed_blocks),
237
+ transitions=tuple(self._transitions),
238
+ )
239
+
240
+ def summarize(self) -> PhaseSummary:
241
+ """Aggregate statistics from the current (or finalized) timeline."""
242
+
243
+ timeline = self.snapshot() if not self._finalized else self.finalize()
244
+ blocks = timeline.blocks
245
+ total_events = sum(b.event_count for b in blocks)
246
+ total_duration = timeline.total_duration_seconds
247
+
248
+ by_phase: dict[str, dict[str, float]] = {}
249
+ for b in blocks:
250
+ agg = by_phase.setdefault(
251
+ b.phase_root,
252
+ {"event_count": 0, "block_count": 0, "duration": 0.0},
253
+ )
254
+ agg["event_count"] += b.event_count
255
+ agg["block_count"] += 1
256
+ agg["duration"] += b.duration_seconds
257
+
258
+ stats = tuple(
259
+ PhaseStats(
260
+ phase=phase,
261
+ event_count=int(agg["event_count"]),
262
+ block_count=int(agg["block_count"]),
263
+ total_duration_seconds=agg["duration"],
264
+ fraction_of_events=(agg["event_count"] / total_events) if total_events else 0.0,
265
+ fraction_of_duration=(agg["duration"] / total_duration) if total_duration else 0.0,
266
+ avg_block_duration_seconds=(agg["duration"] / agg["block_count"])
267
+ if agg["block_count"]
268
+ else 0.0,
269
+ )
270
+ for phase, agg in sorted(
271
+ by_phase.items(), key=lambda kv: (-kv[1]["event_count"], kv[0])
272
+ )
273
+ )
274
+
275
+ transition_pairs = Counter((t.from_phase, t.to_phase) for t in timeline.transitions)
276
+ most_common = tuple(
277
+ (frm, to, count)
278
+ for (frm, to), count in sorted(transition_pairs.items(), key=lambda kv: (-kv[1], kv[0]))
279
+ )
280
+
281
+ return PhaseSummary(
282
+ session_id=self.session_id,
283
+ total_events=total_events,
284
+ total_duration_seconds=total_duration,
285
+ by_phase=stats,
286
+ transition_count=len(timeline.transitions),
287
+ most_common_transitions=most_common,
288
+ )