htmlgraph 0.9.3__py3-none-any.whl → 0.27.5__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 (331) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +173 -17
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +127 -0
  7. htmlgraph/agent_registry.py +45 -30
  8. htmlgraph/agents.py +160 -107
  9. htmlgraph/analytics/__init__.py +9 -2
  10. htmlgraph/analytics/cli.py +190 -51
  11. htmlgraph/analytics/cost_analyzer.py +391 -0
  12. htmlgraph/analytics/cost_monitor.py +664 -0
  13. htmlgraph/analytics/cost_reporter.py +675 -0
  14. htmlgraph/analytics/cross_session.py +617 -0
  15. htmlgraph/analytics/dependency.py +192 -100
  16. htmlgraph/analytics/pattern_learning.py +771 -0
  17. htmlgraph/analytics/session_graph.py +707 -0
  18. htmlgraph/analytics/strategic/__init__.py +80 -0
  19. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  20. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  21. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  22. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  23. htmlgraph/analytics/work_type.py +190 -14
  24. htmlgraph/analytics_index.py +135 -51
  25. htmlgraph/api/__init__.py +3 -0
  26. htmlgraph/api/cost_alerts_websocket.py +416 -0
  27. htmlgraph/api/main.py +2498 -0
  28. htmlgraph/api/static/htmx.min.js +1 -0
  29. htmlgraph/api/static/style-redesign.css +1344 -0
  30. htmlgraph/api/static/style.css +1079 -0
  31. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  32. htmlgraph/api/templates/dashboard.html +794 -0
  33. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  34. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  35. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  36. htmlgraph/api/templates/partials/agents.html +317 -0
  37. htmlgraph/api/templates/partials/event-traces.html +373 -0
  38. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  39. htmlgraph/api/templates/partials/features.html +578 -0
  40. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  41. htmlgraph/api/templates/partials/metrics.html +346 -0
  42. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  43. htmlgraph/api/templates/partials/orchestration.html +198 -0
  44. htmlgraph/api/templates/partials/spawners.html +375 -0
  45. htmlgraph/api/templates/partials/work-items.html +613 -0
  46. htmlgraph/api/websocket.py +538 -0
  47. htmlgraph/archive/__init__.py +24 -0
  48. htmlgraph/archive/bloom.py +234 -0
  49. htmlgraph/archive/fts.py +297 -0
  50. htmlgraph/archive/manager.py +583 -0
  51. htmlgraph/archive/search.py +244 -0
  52. htmlgraph/atomic_ops.py +560 -0
  53. htmlgraph/attribute_index.py +208 -0
  54. htmlgraph/bounded_paths.py +539 -0
  55. htmlgraph/builders/__init__.py +14 -0
  56. htmlgraph/builders/base.py +118 -29
  57. htmlgraph/builders/bug.py +150 -0
  58. htmlgraph/builders/chore.py +119 -0
  59. htmlgraph/builders/epic.py +150 -0
  60. htmlgraph/builders/feature.py +31 -6
  61. htmlgraph/builders/insight.py +195 -0
  62. htmlgraph/builders/metric.py +217 -0
  63. htmlgraph/builders/pattern.py +202 -0
  64. htmlgraph/builders/phase.py +162 -0
  65. htmlgraph/builders/spike.py +52 -19
  66. htmlgraph/builders/track.py +148 -72
  67. htmlgraph/cigs/__init__.py +81 -0
  68. htmlgraph/cigs/autonomy.py +385 -0
  69. htmlgraph/cigs/cost.py +475 -0
  70. htmlgraph/cigs/messages_basic.py +472 -0
  71. htmlgraph/cigs/messaging.py +365 -0
  72. htmlgraph/cigs/models.py +771 -0
  73. htmlgraph/cigs/pattern_storage.py +427 -0
  74. htmlgraph/cigs/patterns.py +503 -0
  75. htmlgraph/cigs/posttool_analyzer.py +234 -0
  76. htmlgraph/cigs/reporter.py +818 -0
  77. htmlgraph/cigs/tracker.py +317 -0
  78. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  79. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  80. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  81. htmlgraph/cli/__init__.py +42 -0
  82. htmlgraph/cli/__main__.py +6 -0
  83. htmlgraph/cli/analytics.py +1424 -0
  84. htmlgraph/cli/base.py +685 -0
  85. htmlgraph/cli/constants.py +206 -0
  86. htmlgraph/cli/core.py +954 -0
  87. htmlgraph/cli/main.py +147 -0
  88. htmlgraph/cli/models.py +475 -0
  89. htmlgraph/cli/templates/__init__.py +1 -0
  90. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  91. htmlgraph/cli/work/__init__.py +239 -0
  92. htmlgraph/cli/work/browse.py +115 -0
  93. htmlgraph/cli/work/features.py +568 -0
  94. htmlgraph/cli/work/orchestration.py +676 -0
  95. htmlgraph/cli/work/report.py +728 -0
  96. htmlgraph/cli/work/sessions.py +466 -0
  97. htmlgraph/cli/work/snapshot.py +559 -0
  98. htmlgraph/cli/work/tracks.py +486 -0
  99. htmlgraph/cli_commands/__init__.py +1 -0
  100. htmlgraph/cli_commands/feature.py +195 -0
  101. htmlgraph/cli_framework.py +115 -0
  102. htmlgraph/collections/__init__.py +18 -0
  103. htmlgraph/collections/base.py +415 -98
  104. htmlgraph/collections/bug.py +53 -0
  105. htmlgraph/collections/chore.py +53 -0
  106. htmlgraph/collections/epic.py +53 -0
  107. htmlgraph/collections/feature.py +12 -26
  108. htmlgraph/collections/insight.py +100 -0
  109. htmlgraph/collections/metric.py +92 -0
  110. htmlgraph/collections/pattern.py +97 -0
  111. htmlgraph/collections/phase.py +53 -0
  112. htmlgraph/collections/session.py +194 -0
  113. htmlgraph/collections/spike.py +56 -16
  114. htmlgraph/collections/task_delegation.py +241 -0
  115. htmlgraph/collections/todo.py +511 -0
  116. htmlgraph/collections/traces.py +487 -0
  117. htmlgraph/config/cost_models.json +56 -0
  118. htmlgraph/config.py +190 -0
  119. htmlgraph/context_analytics.py +344 -0
  120. htmlgraph/converter.py +216 -28
  121. htmlgraph/cost_analysis/__init__.py +5 -0
  122. htmlgraph/cost_analysis/analyzer.py +438 -0
  123. htmlgraph/dashboard.html +2406 -307
  124. htmlgraph/dashboard.html.backup +6592 -0
  125. htmlgraph/dashboard.html.bak +7181 -0
  126. htmlgraph/dashboard.html.bak2 +7231 -0
  127. htmlgraph/dashboard.html.bak3 +7232 -0
  128. htmlgraph/db/__init__.py +38 -0
  129. htmlgraph/db/queries.py +790 -0
  130. htmlgraph/db/schema.py +1788 -0
  131. htmlgraph/decorators.py +317 -0
  132. htmlgraph/dependency_models.py +19 -2
  133. htmlgraph/deploy.py +142 -125
  134. htmlgraph/deployment_models.py +474 -0
  135. htmlgraph/docs/API_REFERENCE.md +841 -0
  136. htmlgraph/docs/HTTP_API.md +750 -0
  137. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  138. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  139. htmlgraph/docs/README.md +532 -0
  140. htmlgraph/docs/__init__.py +77 -0
  141. htmlgraph/docs/docs_version.py +55 -0
  142. htmlgraph/docs/metadata.py +93 -0
  143. htmlgraph/docs/migrations.py +232 -0
  144. htmlgraph/docs/template_engine.py +143 -0
  145. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  146. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  147. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  148. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  149. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  150. htmlgraph/docs/version_check.py +163 -0
  151. htmlgraph/edge_index.py +182 -27
  152. htmlgraph/error_handler.py +544 -0
  153. htmlgraph/event_log.py +100 -52
  154. htmlgraph/event_migration.py +13 -4
  155. htmlgraph/exceptions.py +49 -0
  156. htmlgraph/file_watcher.py +101 -28
  157. htmlgraph/find_api.py +75 -63
  158. htmlgraph/git_events.py +145 -63
  159. htmlgraph/graph.py +1122 -106
  160. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  161. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  162. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  163. htmlgraph/hooks/__init__.py +45 -0
  164. htmlgraph/hooks/bootstrap.py +169 -0
  165. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  166. htmlgraph/hooks/concurrent_sessions.py +208 -0
  167. htmlgraph/hooks/context.py +350 -0
  168. htmlgraph/hooks/drift_handler.py +525 -0
  169. htmlgraph/hooks/event_tracker.py +1314 -0
  170. htmlgraph/hooks/git_commands.py +175 -0
  171. htmlgraph/hooks/hooks-config.example.json +12 -0
  172. htmlgraph/hooks/installer.py +343 -0
  173. htmlgraph/hooks/orchestrator.py +674 -0
  174. htmlgraph/hooks/orchestrator_reflector.py +223 -0
  175. htmlgraph/hooks/post-checkout.sh +28 -0
  176. htmlgraph/hooks/post-commit.sh +24 -0
  177. htmlgraph/hooks/post-merge.sh +26 -0
  178. htmlgraph/hooks/post_tool_use_failure.py +273 -0
  179. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  180. htmlgraph/hooks/posttooluse.py +408 -0
  181. htmlgraph/hooks/pre-commit.sh +94 -0
  182. htmlgraph/hooks/pre-push.sh +28 -0
  183. htmlgraph/hooks/pretooluse.py +819 -0
  184. htmlgraph/hooks/prompt_analyzer.py +637 -0
  185. htmlgraph/hooks/session_handler.py +668 -0
  186. htmlgraph/hooks/session_summary.py +395 -0
  187. htmlgraph/hooks/state_manager.py +504 -0
  188. htmlgraph/hooks/subagent_detection.py +202 -0
  189. htmlgraph/hooks/subagent_stop.py +369 -0
  190. htmlgraph/hooks/task_enforcer.py +255 -0
  191. htmlgraph/hooks/task_validator.py +177 -0
  192. htmlgraph/hooks/validator.py +628 -0
  193. htmlgraph/ids.py +41 -27
  194. htmlgraph/index.d.ts +286 -0
  195. htmlgraph/learning.py +767 -0
  196. htmlgraph/mcp_server.py +69 -23
  197. htmlgraph/models.py +1586 -87
  198. htmlgraph/operations/README.md +62 -0
  199. htmlgraph/operations/__init__.py +79 -0
  200. htmlgraph/operations/analytics.py +339 -0
  201. htmlgraph/operations/bootstrap.py +289 -0
  202. htmlgraph/operations/events.py +244 -0
  203. htmlgraph/operations/fastapi_server.py +231 -0
  204. htmlgraph/operations/hooks.py +350 -0
  205. htmlgraph/operations/initialization.py +597 -0
  206. htmlgraph/operations/initialization.py.backup +228 -0
  207. htmlgraph/operations/server.py +303 -0
  208. htmlgraph/orchestration/__init__.py +58 -0
  209. htmlgraph/orchestration/claude_launcher.py +179 -0
  210. htmlgraph/orchestration/command_builder.py +72 -0
  211. htmlgraph/orchestration/headless_spawner.py +281 -0
  212. htmlgraph/orchestration/live_events.py +377 -0
  213. htmlgraph/orchestration/model_selection.py +327 -0
  214. htmlgraph/orchestration/plugin_manager.py +140 -0
  215. htmlgraph/orchestration/prompts.py +137 -0
  216. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  217. htmlgraph/orchestration/spawners/__init__.py +16 -0
  218. htmlgraph/orchestration/spawners/base.py +194 -0
  219. htmlgraph/orchestration/spawners/claude.py +173 -0
  220. htmlgraph/orchestration/spawners/codex.py +435 -0
  221. htmlgraph/orchestration/spawners/copilot.py +294 -0
  222. htmlgraph/orchestration/spawners/gemini.py +471 -0
  223. htmlgraph/orchestration/subprocess_runner.py +36 -0
  224. htmlgraph/orchestration/task_coordination.py +343 -0
  225. htmlgraph/orchestration.md +563 -0
  226. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  227. htmlgraph/orchestrator.py +669 -0
  228. htmlgraph/orchestrator_config.py +357 -0
  229. htmlgraph/orchestrator_mode.py +328 -0
  230. htmlgraph/orchestrator_validator.py +133 -0
  231. htmlgraph/parallel.py +646 -0
  232. htmlgraph/parser.py +160 -35
  233. htmlgraph/path_query.py +608 -0
  234. htmlgraph/pattern_matcher.py +636 -0
  235. htmlgraph/planning.py +147 -52
  236. htmlgraph/pydantic_models.py +476 -0
  237. htmlgraph/quality_gates.py +350 -0
  238. htmlgraph/query_builder.py +109 -72
  239. htmlgraph/query_composer.py +509 -0
  240. htmlgraph/reflection.py +443 -0
  241. htmlgraph/refs.py +344 -0
  242. htmlgraph/repo_hash.py +512 -0
  243. htmlgraph/repositories/__init__.py +292 -0
  244. htmlgraph/repositories/analytics_repository.py +455 -0
  245. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  246. htmlgraph/repositories/feature_repository.py +581 -0
  247. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  248. htmlgraph/repositories/feature_repository_memory.py +607 -0
  249. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  250. htmlgraph/repositories/filter_service.py +620 -0
  251. htmlgraph/repositories/filter_service_standard.py +445 -0
  252. htmlgraph/repositories/shared_cache.py +621 -0
  253. htmlgraph/repositories/shared_cache_memory.py +395 -0
  254. htmlgraph/repositories/track_repository.py +552 -0
  255. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  256. htmlgraph/repositories/track_repository_memory.py +508 -0
  257. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  258. htmlgraph/routing.py +8 -19
  259. htmlgraph/scripts/deploy.py +1 -2
  260. htmlgraph/sdk/__init__.py +398 -0
  261. htmlgraph/sdk/__init__.pyi +14 -0
  262. htmlgraph/sdk/analytics/__init__.py +19 -0
  263. htmlgraph/sdk/analytics/engine.py +155 -0
  264. htmlgraph/sdk/analytics/helpers.py +178 -0
  265. htmlgraph/sdk/analytics/registry.py +109 -0
  266. htmlgraph/sdk/base.py +484 -0
  267. htmlgraph/sdk/constants.py +216 -0
  268. htmlgraph/sdk/core.pyi +308 -0
  269. htmlgraph/sdk/discovery.py +120 -0
  270. htmlgraph/sdk/help/__init__.py +12 -0
  271. htmlgraph/sdk/help/mixin.py +699 -0
  272. htmlgraph/sdk/mixins/__init__.py +15 -0
  273. htmlgraph/sdk/mixins/attribution.py +113 -0
  274. htmlgraph/sdk/mixins/mixin.py +410 -0
  275. htmlgraph/sdk/operations/__init__.py +12 -0
  276. htmlgraph/sdk/operations/mixin.py +427 -0
  277. htmlgraph/sdk/orchestration/__init__.py +17 -0
  278. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  279. htmlgraph/sdk/orchestration/spawner.py +204 -0
  280. htmlgraph/sdk/planning/__init__.py +19 -0
  281. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  282. htmlgraph/sdk/planning/mixin.py +211 -0
  283. htmlgraph/sdk/planning/parallel.py +186 -0
  284. htmlgraph/sdk/planning/queue.py +210 -0
  285. htmlgraph/sdk/planning/recommendations.py +87 -0
  286. htmlgraph/sdk/planning/smart_planning.py +319 -0
  287. htmlgraph/sdk/session/__init__.py +19 -0
  288. htmlgraph/sdk/session/continuity.py +57 -0
  289. htmlgraph/sdk/session/handoff.py +110 -0
  290. htmlgraph/sdk/session/info.py +309 -0
  291. htmlgraph/sdk/session/manager.py +103 -0
  292. htmlgraph/sdk/strategic/__init__.py +26 -0
  293. htmlgraph/sdk/strategic/mixin.py +563 -0
  294. htmlgraph/server.py +685 -180
  295. htmlgraph/services/__init__.py +10 -0
  296. htmlgraph/services/claiming.py +199 -0
  297. htmlgraph/session_hooks.py +300 -0
  298. htmlgraph/session_manager.py +1392 -175
  299. htmlgraph/session_registry.py +587 -0
  300. htmlgraph/session_state.py +436 -0
  301. htmlgraph/session_warning.py +201 -0
  302. htmlgraph/sessions/__init__.py +23 -0
  303. htmlgraph/sessions/handoff.py +756 -0
  304. htmlgraph/setup.py +34 -17
  305. htmlgraph/spike_index.py +143 -0
  306. htmlgraph/sync_docs.py +12 -15
  307. htmlgraph/system_prompts.py +450 -0
  308. htmlgraph/templates/AGENTS.md.template +366 -0
  309. htmlgraph/templates/CLAUDE.md.template +97 -0
  310. htmlgraph/templates/GEMINI.md.template +87 -0
  311. htmlgraph/templates/orchestration-view.html +350 -0
  312. htmlgraph/track_builder.py +146 -15
  313. htmlgraph/track_manager.py +69 -21
  314. htmlgraph/transcript.py +890 -0
  315. htmlgraph/transcript_analytics.py +699 -0
  316. htmlgraph/types.py +323 -0
  317. htmlgraph/validation.py +115 -0
  318. htmlgraph/watch.py +8 -5
  319. htmlgraph/work_type_utils.py +3 -2
  320. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
  321. htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
  322. htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
  323. htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
  324. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
  325. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  326. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  327. htmlgraph/cli.py +0 -2688
  328. htmlgraph/sdk.py +0 -709
  329. htmlgraph-0.9.3.dist-info/RECORD +0 -61
  330. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  331. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/models.py CHANGED
@@ -7,18 +7,26 @@ These models provide:
7
7
  - Lightweight context generation for AI agents
8
8
  """
9
9
 
10
- from datetime import datetime
11
- from typing import Any, Literal
10
+ from datetime import datetime, timezone
12
11
  from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
13
15
  from pydantic import BaseModel, Field
14
16
 
15
17
 
18
+ def utc_now() -> datetime:
19
+ """Return current time as UTC-aware datetime."""
20
+ return datetime.now(timezone.utc)
21
+
22
+
16
23
  class WorkType(str, Enum):
17
24
  """
18
25
  Classification of work/activity type for events and sessions.
19
26
 
20
27
  Used to differentiate exploratory work from implementation work in analytics.
21
28
  """
29
+
22
30
  FEATURE = "feature-implementation"
23
31
  SPIKE = "spike-investigation"
24
32
  BUG_FIX = "bug-fix"
@@ -38,6 +46,7 @@ class SpikeType(str, Enum):
38
46
  - RISK: Identify and assess project risks
39
47
  - GENERAL: Uncategorized investigation
40
48
  """
49
+
41
50
  TECHNICAL = "technical"
42
51
  ARCHITECTURAL = "architectural"
43
52
  RISK = "risk"
@@ -53,6 +62,7 @@ class MaintenanceType(str, Enum):
53
62
  - PERFECTIVE: Improve performance, usability, maintainability
54
63
  - PREVENTIVE: Prevent future problems (refactoring, tech debt)
55
64
  """
65
+
56
66
  CORRECTIVE = "corrective"
57
67
  ADAPTIVE = "adaptive"
58
68
  PERFECTIVE = "perfective"
@@ -72,7 +82,7 @@ class Step(BaseModel):
72
82
  status = "✅" if self.completed else "⏳"
73
83
  agent_attr = f' data-agent="{self.agent}"' if self.agent else ""
74
84
  completed_attr = f' data-completed="{str(self.completed).lower()}"'
75
- return f'<li{completed_attr}{agent_attr}>{status} {self.description}</li>'
85
+ return f"<li{completed_attr}{agent_attr}>{status} {self.description}</li>"
76
86
 
77
87
  def to_context(self) -> str:
78
88
  """Lightweight context for AI agents."""
@@ -98,7 +108,11 @@ class Edge(BaseModel):
98
108
 
99
109
  def to_html(self, base_path: str = "") -> str:
100
110
  """Convert edge to HTML anchor element."""
101
- href = f"{base_path}{self.target_id}.html" if not self.target_id.endswith('.html') else f"{base_path}{self.target_id}"
111
+ href = (
112
+ f"{base_path}{self.target_id}.html"
113
+ if not self.target_id.endswith(".html")
114
+ else f"{base_path}{self.target_id}"
115
+ )
102
116
  attrs = [f'href="{href}"', f'data-relationship="{self.relationship}"']
103
117
 
104
118
  if self.since:
@@ -108,7 +122,7 @@ class Edge(BaseModel):
108
122
  attrs.append(f'data-{key}="{value}"')
109
123
 
110
124
  title = self.title or self.target_id
111
- return f'<a {" ".join(attrs)}>{title}</a>'
125
+ return f"<a {' '.join(attrs)}>{title}</a>"
112
126
 
113
127
  def to_context(self) -> str:
114
128
  """Lightweight context for AI agents."""
@@ -137,7 +151,9 @@ class Node(BaseModel):
137
151
  id: str
138
152
  title: str
139
153
  type: str = "node"
140
- status: Literal["todo", "in-progress", "blocked", "done", "active", "ended", "stale"] = "todo"
154
+ status: Literal[
155
+ "todo", "in-progress", "blocked", "done", "active", "ended", "stale"
156
+ ] = "todo"
141
157
  priority: Literal["low", "medium", "high", "critical"] = "medium"
142
158
  created: datetime = Field(default_factory=datetime.now)
143
159
  updated: datetime = Field(default_factory=datetime.now)
@@ -153,18 +169,58 @@ class Node(BaseModel):
153
169
  # Vertical integration: Track/Spec/Plan relationships
154
170
  track_id: str | None = None # Which track this feature belongs to
155
171
  plan_task_id: str | None = None # Which plan task this feature implements
156
- spec_requirements: list[str] = Field(default_factory=list) # Which spec requirements this satisfies
172
+ spec_requirements: list[str] = Field(
173
+ default_factory=list
174
+ ) # Which spec requirements this satisfies
157
175
 
158
176
  # Handoff context fields for agent-to-agent transitions
159
177
  handoff_required: bool = False # Whether this node needs to be handed off
160
178
  previous_agent: str | None = None # Agent who previously worked on this
161
- handoff_reason: str | None = None # Reason for handoff (e.g., blocked, requires different expertise)
179
+ handoff_reason: str | None = (
180
+ None # Reason for handoff (e.g., blocked, requires different expertise)
181
+ )
162
182
  handoff_notes: str | None = None # Detailed handoff context/decisions
163
183
  handoff_timestamp: datetime | None = None # When the handoff was created
164
184
 
165
185
  # Capability-based routing (Phase 3: Agent Routing & Capabilities)
166
- required_capabilities: list[str] = Field(default_factory=list) # Capabilities needed for this task
167
- capability_tags: list[str] = Field(default_factory=list) # Flexible tags for advanced matching
186
+ required_capabilities: list[str] = Field(
187
+ default_factory=list
188
+ ) # Capabilities needed for this task
189
+ capability_tags: list[str] = Field(
190
+ default_factory=list
191
+ ) # Flexible tags for advanced matching
192
+
193
+ # Context tracking (aggregated from sessions)
194
+ # These are updated when sessions report context usage for this feature
195
+ context_tokens_used: int = 0 # Total context tokens attributed to this feature
196
+ context_peak_tokens: int = 0 # Highest context usage in any session
197
+ context_cost_usd: float = 0.0 # Total cost attributed to this feature
198
+ context_sessions: list[str] = Field(
199
+ default_factory=list
200
+ ) # Session IDs that reported context
201
+
202
+ # Auto-spike metadata (for transition spike generation)
203
+ spike_subtype: (
204
+ Literal[
205
+ "session-init",
206
+ "transition",
207
+ "conversation-init",
208
+ "planning",
209
+ "investigation",
210
+ ]
211
+ | None
212
+ ) = None
213
+ auto_generated: bool = False # True if auto-created by SessionManager
214
+ session_id: str | None = None # Session that created/owns this spike
215
+ from_feature_id: str | None = (
216
+ None # For transition spikes: feature we transitioned from
217
+ )
218
+ to_feature_id: str | None = (
219
+ None # For transition spikes: feature we transitioned to
220
+ )
221
+ model_name: str | None = (
222
+ None # Model that worked on this (e.g., "claude-sonnet-4-5")
223
+ )
168
224
 
169
225
  def model_post_init(self, __context: Any) -> None:
170
226
  """Lightweight validation for required fields."""
@@ -173,6 +229,16 @@ class Node(BaseModel):
173
229
  if not self.title or not str(self.title).strip():
174
230
  raise ValueError("Node.title must be non-empty")
175
231
 
232
+ # Validate auto-spike metadata
233
+ if self.spike_subtype and self.type != "spike":
234
+ raise ValueError(
235
+ f"spike_subtype can only be set on spike nodes, got type='{self.type}'"
236
+ )
237
+ if self.auto_generated and not self.session_id:
238
+ raise ValueError("auto_generated spikes must have session_id set")
239
+ if self.spike_subtype == "transition" and not self.from_feature_id:
240
+ raise ValueError("transition spikes must have from_feature_id set")
241
+
176
242
  @property
177
243
  def completion_percentage(self) -> int:
178
244
  """Calculate completion percentage from steps."""
@@ -203,18 +269,77 @@ class Node(BaseModel):
203
269
  if edge.relationship not in self.edges:
204
270
  self.edges[edge.relationship] = []
205
271
  self.edges[edge.relationship].append(edge)
206
- self.updated = datetime.now()
272
+ self.updated = utc_now()
207
273
 
208
274
  def complete_step(self, index: int, agent: str | None = None) -> bool:
209
275
  """Mark a step as completed."""
210
276
  if 0 <= index < len(self.steps):
211
277
  self.steps[index].completed = True
212
278
  self.steps[index].agent = agent
213
- self.steps[index].timestamp = datetime.now()
214
- self.updated = datetime.now()
279
+ self.steps[index].timestamp = utc_now()
280
+ self.updated = utc_now()
215
281
  return True
216
282
  return False
217
283
 
284
+ def record_context_usage(
285
+ self,
286
+ session_id: str,
287
+ tokens_used: int,
288
+ peak_tokens: int = 0,
289
+ cost_usd: float = 0.0,
290
+ ) -> None:
291
+ """
292
+ Record context usage from a session working on this feature.
293
+
294
+ Args:
295
+ session_id: Session that used context
296
+ tokens_used: Total tokens attributed to this feature
297
+ peak_tokens: Peak context usage during this work
298
+ cost_usd: Cost attributed to this feature
299
+ """
300
+ # Track session if not already recorded
301
+ if session_id not in self.context_sessions:
302
+ self.context_sessions.append(session_id)
303
+
304
+ # Update aggregates
305
+ self.context_tokens_used += tokens_used
306
+ self.context_peak_tokens = max(self.context_peak_tokens, peak_tokens)
307
+ self.context_cost_usd += cost_usd
308
+ self.updated = utc_now()
309
+
310
+ def context_stats(self) -> dict:
311
+ """
312
+ Get context usage statistics for this feature.
313
+
314
+ Returns:
315
+ Dictionary with context usage metrics
316
+ """
317
+ return {
318
+ "tokens_used": self.context_tokens_used,
319
+ "peak_tokens": self.context_peak_tokens,
320
+ "cost_usd": self.context_cost_usd,
321
+ "sessions": len(self.context_sessions),
322
+ "session_ids": self.context_sessions,
323
+ }
324
+
325
+ def to_dict(self) -> dict:
326
+ """
327
+ Convert Node to dictionary format.
328
+
329
+ This is a convenience alias for Pydantic's model_dump() method,
330
+ providing a more discoverable API for serialization.
331
+
332
+ Returns:
333
+ dict: Dictionary representation of the Node with all fields
334
+
335
+ Example:
336
+ >>> feature = sdk.features.create("My Feature").save()
337
+ >>> data = feature.to_dict()
338
+ >>> print(data['title'])
339
+ 'My Feature'
340
+ """
341
+ return self.model_dump()
342
+
218
343
  def to_html(self, stylesheet_path: str = "../styles.css") -> str:
219
344
  """
220
345
  Convert node to full HTML document.
@@ -242,21 +367,23 @@ class Node(BaseModel):
242
367
  </ul>
243
368
  </section>''')
244
369
  if edge_sections:
245
- edges_html = f'''
370
+ edges_html = f"""
246
371
  <nav data-graph-edges>{"".join(edge_sections)}
247
- </nav>'''
372
+ </nav>"""
248
373
 
249
374
  # Build steps HTML
250
375
  steps_html = ""
251
376
  if self.steps:
252
- step_items = "\n ".join(step.to_html() for step in self.steps)
253
- steps_html = f'''
377
+ step_items = "\n ".join(
378
+ step.to_html() for step in self.steps
379
+ )
380
+ steps_html = f"""
254
381
  <section data-steps>
255
382
  <h3>Implementation Steps</h3>
256
383
  <ol>
257
384
  {step_items}
258
385
  </ol>
259
- </section>'''
386
+ </section>"""
260
387
 
261
388
  # Build properties HTML
262
389
  props_html = ""
@@ -265,23 +392,27 @@ class Node(BaseModel):
265
392
  for key, value in self.properties.items():
266
393
  unit = ""
267
394
  if isinstance(value, dict) and "value" in value:
268
- unit = f' data-unit="{value.get("unit", "")}"' if value.get("unit") else ""
269
- display = f'{value["value"]} {value.get("unit", "")}'.strip()
395
+ unit = (
396
+ f' data-unit="{value.get("unit", "")}"'
397
+ if value.get("unit")
398
+ else ""
399
+ )
400
+ display = f"{value['value']} {value.get('unit', '')}".strip()
270
401
  val = value["value"]
271
402
  else:
272
403
  display = str(value)
273
404
  val = value
274
405
  prop_items.append(
275
- f'<dt>{key.replace("_", " ").title()}</dt>\n'
406
+ f"<dt>{key.replace('_', ' ').title()}</dt>\n"
276
407
  f' <dd data-key="{key}" data-value="{val}"{unit}>{display}</dd>'
277
408
  )
278
- props_html = f'''
409
+ props_html = f"""
279
410
  <section data-properties>
280
411
  <h3>Properties</h3>
281
412
  <dl>
282
413
  {chr(10).join(prop_items)}
283
414
  </dl>
284
- </section>'''
415
+ </section>"""
285
416
 
286
417
  # Build handoff HTML
287
418
  handoff_html = ""
@@ -292,33 +423,39 @@ class Node(BaseModel):
292
423
  if self.handoff_reason:
293
424
  handoff_attrs.append(f'data-reason="{self.handoff_reason}"')
294
425
  if self.handoff_timestamp:
295
- handoff_attrs.append(f'data-timestamp="{self.handoff_timestamp.isoformat()}"')
426
+ handoff_attrs.append(
427
+ f'data-timestamp="{self.handoff_timestamp.isoformat()}"'
428
+ )
296
429
 
297
430
  attrs_str = " ".join(handoff_attrs)
298
- handoff_section = f'''
431
+ handoff_section = f"""
299
432
  <section data-handoff{f" {attrs_str}" if attrs_str else ""}>
300
- <h3>Handoff Context</h3>'''
433
+ <h3>Handoff Context</h3>"""
301
434
 
302
435
  if self.previous_agent:
303
- handoff_section += f'\n <p><strong>From:</strong> {self.previous_agent}</p>'
436
+ handoff_section += (
437
+ f"\n <p><strong>From:</strong> {self.previous_agent}</p>"
438
+ )
304
439
 
305
440
  if self.handoff_reason:
306
- handoff_section += f'\n <p><strong>Reason:</strong> {self.handoff_reason}</p>'
441
+ handoff_section += f"\n <p><strong>Reason:</strong> {self.handoff_reason}</p>"
307
442
 
308
443
  if self.handoff_notes:
309
- handoff_section += f'\n <p><strong>Notes:</strong> {self.handoff_notes}</p>'
444
+ handoff_section += (
445
+ f"\n <p><strong>Notes:</strong> {self.handoff_notes}</p>"
446
+ )
310
447
 
311
- handoff_section += '\n </section>'
448
+ handoff_section += "\n </section>"
312
449
  handoff_html = handoff_section
313
450
 
314
451
  # Build content HTML
315
452
  content_html = ""
316
453
  if self.content:
317
- content_html = f'''
454
+ content_html = f"""
318
455
  <section data-content>
319
456
  <h3>Description</h3>
320
457
  {self.content}
321
- </section>'''
458
+ </section>"""
322
459
 
323
460
  # Build required capabilities HTML
324
461
  capabilities_html = ""
@@ -331,16 +468,20 @@ class Node(BaseModel):
331
468
  for tag in self.capability_tags:
332
469
  cap_items.append(f'<li data-tag="{tag}" class="tag">{tag}</li>')
333
470
  if cap_items:
334
- capabilities_html = f'''
471
+ capabilities_html = f"""
335
472
  <section data-required-capabilities>
336
473
  <h3>Required Capabilities</h3>
337
474
  <ul>
338
475
  {chr(10).join(cap_items)}
339
476
  </ul>
340
- </section>'''
477
+ </section>"""
341
478
 
342
479
  # Agent attribute
343
- agent_attr = f' data-agent-assigned="{self.agent_assigned}"' if self.agent_assigned else ""
480
+ agent_attr = (
481
+ f' data-agent-assigned="{self.agent_assigned}"'
482
+ if self.agent_assigned
483
+ else ""
484
+ )
344
485
  if self.claimed_at:
345
486
  agent_attr += f' data-claimed-at="{self.claimed_at.isoformat()}"'
346
487
  if self.claimed_by_session:
@@ -349,6 +490,50 @@ class Node(BaseModel):
349
490
  # Track ID attribute
350
491
  track_attr = f' data-track-id="{self.track_id}"' if self.track_id else ""
351
492
 
493
+ # Context tracking attributes
494
+ context_attr = ""
495
+ if self.context_tokens_used > 0:
496
+ context_attr += f' data-context-tokens="{self.context_tokens_used}"'
497
+ if self.context_peak_tokens > 0:
498
+ context_attr += f' data-context-peak="{self.context_peak_tokens}"'
499
+ if self.context_cost_usd > 0:
500
+ context_attr += f' data-context-cost="{self.context_cost_usd:.4f}"'
501
+
502
+ # Auto-spike metadata attributes
503
+ auto_spike_attr = ""
504
+ if self.spike_subtype:
505
+ auto_spike_attr += f' data-spike-subtype="{self.spike_subtype}"'
506
+ if self.auto_generated:
507
+ auto_spike_attr += (
508
+ f' data-auto-generated="{str(self.auto_generated).lower()}"'
509
+ )
510
+ if self.session_id:
511
+ auto_spike_attr += f' data-session-id="{self.session_id}"'
512
+ if self.from_feature_id:
513
+ auto_spike_attr += f' data-from-feature-id="{self.from_feature_id}"'
514
+ if self.to_feature_id:
515
+ auto_spike_attr += f' data-to-feature-id="{self.to_feature_id}"'
516
+ if self.model_name:
517
+ auto_spike_attr += f' data-model-name="{self.model_name}"'
518
+
519
+ # Build context usage section
520
+ context_html = ""
521
+ if self.context_tokens_used > 0 or self.context_sessions:
522
+ context_html = f"""
523
+ <section data-context-tracking>
524
+ <h3>Context Usage</h3>
525
+ <dl>
526
+ <dt>Total Tokens</dt>
527
+ <dd>{self.context_tokens_used:,}</dd>
528
+ <dt>Peak Tokens</dt>
529
+ <dd>{self.context_peak_tokens:,}</dd>
530
+ <dt>Total Cost</dt>
531
+ <dd>${self.context_cost_usd:.4f}</dd>
532
+ <dt>Sessions</dt>
533
+ <dd>{len(self.context_sessions)}</dd>
534
+ </dl>
535
+ </section>"""
536
+
352
537
  return f'''<!DOCTYPE html>
353
538
  <html lang="en">
354
539
  <head>
@@ -364,7 +549,7 @@ class Node(BaseModel):
364
549
  data-status="{self.status}"
365
550
  data-priority="{self.priority}"
366
551
  data-created="{self.created.isoformat()}"
367
- data-updated="{self.updated.isoformat()}"{agent_attr}{track_attr}>
552
+ data-updated="{self.updated.isoformat()}"{agent_attr}{track_attr}{context_attr}{auto_spike_attr}>
368
553
 
369
554
  <header>
370
555
  <h1>{self.title}</h1>
@@ -373,11 +558,7 @@ class Node(BaseModel):
373
558
  <span class="badge priority-{self.priority}">{self.priority.title()} Priority</span>
374
559
  </div>
375
560
  </header>
376
- <<<<<<< HEAD
377
- {edges_html}{props_html}{capabilities_html}{steps_html}{content_html}
378
- =======
379
- {edges_html}{handoff_html}{props_html}{steps_html}{content_html}
380
- >>>>>>> origin/dev
561
+ {edges_html}{handoff_html}{props_html}{capabilities_html}{context_html}{steps_html}{content_html}
381
562
  </article>
382
563
  </body>
383
564
  </html>
@@ -413,7 +594,9 @@ class Node(BaseModel):
413
594
 
414
595
  if self.steps:
415
596
  completed = sum(1 for s in self.steps if s.completed)
416
- lines.append(f"Progress: {completed}/{len(self.steps)} steps ({self.completion_percentage}%)")
597
+ lines.append(
598
+ f"Progress: {completed}/{len(self.steps)} steps ({self.completion_percentage}%)"
599
+ )
417
600
 
418
601
  # Blocking dependencies
419
602
  blocked_by = self.edges.get("blocked_by", [])
@@ -435,16 +618,14 @@ class Node(BaseModel):
435
618
  edges = {}
436
619
  for rel_type, edge_list in data["edges"].items():
437
620
  edges[rel_type] = [
438
- Edge(**e) if isinstance(e, dict) else e
439
- for e in edge_list
621
+ Edge(**e) if isinstance(e, dict) else e for e in edge_list
440
622
  ]
441
623
  data["edges"] = edges
442
624
 
443
625
  # Convert step dicts to Step objects
444
626
  if "steps" in data:
445
627
  data["steps"] = [
446
- Step(**s) if isinstance(s, dict) else s
447
- for s in data["steps"]
628
+ Step(**s) if isinstance(s, dict) else s for s in data["steps"]
448
629
  ]
449
630
 
450
631
  return cls(**data)
@@ -471,6 +652,75 @@ class Spike(Node):
471
652
  data["type"] = "spike"
472
653
  super().__init__(**data)
473
654
 
655
+ def to_html(self, stylesheet_path: str = "../styles.css") -> str:
656
+ """
657
+ Convert spike to HTML document with spike-specific fields.
658
+
659
+ Overrides Node.to_html() to include findings and decision sections.
660
+ """
661
+ # Build findings section
662
+ findings_html = ""
663
+ if self.findings:
664
+ findings_html = f"""
665
+ <section data-findings>
666
+ <h3>Findings</h3>
667
+ <div class="findings-content">
668
+ {self.findings}
669
+ </div>
670
+ </section>"""
671
+
672
+ # Build decision section
673
+ decision_html = ""
674
+ if self.decision:
675
+ decision_html = f"""
676
+ <section data-decision>
677
+ <h3>Decision</h3>
678
+ <p>{self.decision}</p>
679
+ </section>"""
680
+
681
+ # Build spike metadata section
682
+ spike_meta_html = f"""
683
+ <section data-spike-metadata>
684
+ <h3>Spike Metadata</h3>
685
+ <dl>
686
+ <dt>Type</dt>
687
+ <dd>{self.spike_type.value.title()}</dd>"""
688
+
689
+ if self.timebox_hours:
690
+ spike_meta_html += f"""
691
+ <dt>Timebox</dt>
692
+ <dd>{self.timebox_hours} hours</dd>"""
693
+
694
+ spike_meta_html += """
695
+ </dl>
696
+ </section>"""
697
+
698
+ # Get base HTML from Node and insert spike-specific sections
699
+ # We need to call Node's to_html() but inject our sections
700
+ # Strategy: Get base HTML, then insert our sections before closing article tag
701
+
702
+ # Call parent's to_html to get base structure
703
+ base_html = super().to_html(stylesheet_path)
704
+
705
+ # Insert spike sections before </article>
706
+ spike_sections = f"{spike_meta_html}{findings_html}{decision_html}"
707
+ html_with_findings = base_html.replace(
708
+ "</article>", f"{spike_sections}\n </article>"
709
+ )
710
+
711
+ # Add spike-specific attributes to article tag
712
+ spike_attrs = f' data-spike-type="{self.spike_type.value}"'
713
+ if self.timebox_hours:
714
+ spike_attrs += f' data-timebox-hours="{self.timebox_hours}"'
715
+
716
+ # Insert spike attributes into article tag
717
+ html_with_attrs = html_with_findings.replace(
718
+ f'data-updated="{self.updated.isoformat()}"',
719
+ f'data-updated="{self.updated.isoformat()}"{spike_attrs}',
720
+ )
721
+
722
+ return html_with_attrs
723
+
474
724
 
475
725
  class Chore(Node):
476
726
  """
@@ -490,6 +740,158 @@ class Chore(Node):
490
740
  super().__init__(**data)
491
741
 
492
742
 
743
+ class ContextSnapshot(BaseModel):
744
+ """
745
+ A snapshot of context window usage at a point in time.
746
+
747
+ Used to track how context is consumed across sessions, features,
748
+ and activities. Enables analytics for context efficiency.
749
+
750
+ The snapshot captures data from Claude Code's status line JSON input.
751
+ """
752
+
753
+ timestamp: datetime = Field(default_factory=datetime.now)
754
+
755
+ # Token usage in current context window
756
+ input_tokens: int = 0
757
+ output_tokens: int = 0
758
+ cache_creation_tokens: int = 0
759
+ cache_read_tokens: int = 0
760
+
761
+ # Context window capacity
762
+ context_window_size: int = 200000
763
+
764
+ # Cumulative totals for the session
765
+ total_input_tokens: int = 0
766
+ total_output_tokens: int = 0
767
+
768
+ # Cost tracking
769
+ cost_usd: float = 0.0
770
+
771
+ # Optional context for what triggered this snapshot
772
+ trigger: str | None = None # "activity", "feature_switch", "session_start", etc.
773
+ feature_id: str | None = None # Feature being worked on at this moment
774
+
775
+ @property
776
+ def current_tokens(self) -> int:
777
+ """Total tokens in current context window."""
778
+ return self.input_tokens + self.cache_creation_tokens + self.cache_read_tokens
779
+
780
+ @property
781
+ def usage_percent(self) -> float:
782
+ """Context window usage as a percentage."""
783
+ if self.context_window_size == 0:
784
+ return 0.0
785
+ return (self.current_tokens / self.context_window_size) * 100
786
+
787
+ @classmethod
788
+ def from_claude_input(
789
+ cls, data: dict, trigger: str | None = None, feature_id: str | None = None
790
+ ) -> "ContextSnapshot":
791
+ """
792
+ Create a ContextSnapshot from Claude Code status line JSON input.
793
+
794
+ Args:
795
+ data: JSON input from Claude Code (contains context_window, cost, etc.)
796
+ trigger: What triggered this snapshot
797
+ feature_id: Current feature being worked on
798
+
799
+ Returns:
800
+ ContextSnapshot instance
801
+ """
802
+ context = data.get("context_window", {})
803
+ usage = context.get("current_usage") or {}
804
+ cost = data.get("cost", {})
805
+
806
+ return cls(
807
+ input_tokens=usage.get("input_tokens", 0),
808
+ output_tokens=usage.get("output_tokens", 0),
809
+ cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
810
+ cache_read_tokens=usage.get("cache_read_input_tokens", 0),
811
+ context_window_size=context.get("context_window_size", 200000),
812
+ total_input_tokens=context.get("total_input_tokens", 0),
813
+ total_output_tokens=context.get("total_output_tokens", 0),
814
+ cost_usd=cost.get("total_cost_usd", 0.0),
815
+ trigger=trigger,
816
+ feature_id=feature_id,
817
+ )
818
+
819
+ def to_dict(self) -> dict:
820
+ """Convert to dictionary for serialization."""
821
+ return {
822
+ "ts": self.timestamp.isoformat(),
823
+ "in": self.input_tokens,
824
+ "out": self.output_tokens,
825
+ "cache_create": self.cache_creation_tokens,
826
+ "cache_read": self.cache_read_tokens,
827
+ "window": self.context_window_size,
828
+ "total_in": self.total_input_tokens,
829
+ "total_out": self.total_output_tokens,
830
+ "cost": self.cost_usd,
831
+ "trigger": self.trigger,
832
+ "feature": self.feature_id,
833
+ }
834
+
835
+ @classmethod
836
+ def from_dict(cls, data: dict) -> "ContextSnapshot":
837
+ """Create from dictionary."""
838
+ return cls(
839
+ timestamp=datetime.fromisoformat(data["ts"]) if "ts" in data else utc_now(),
840
+ input_tokens=data.get("in", 0),
841
+ output_tokens=data.get("out", 0),
842
+ cache_creation_tokens=data.get("cache_create", 0),
843
+ cache_read_tokens=data.get("cache_read", 0),
844
+ context_window_size=data.get("window", 200000),
845
+ total_input_tokens=data.get("total_in", 0),
846
+ total_output_tokens=data.get("total_out", 0),
847
+ cost_usd=data.get("cost", 0.0),
848
+ trigger=data.get("trigger"),
849
+ feature_id=data.get("feature"),
850
+ )
851
+
852
+
853
+ class ErrorEntry(BaseModel):
854
+ """
855
+ An error record for session error tracking and debugging.
856
+
857
+ Stored inline within Session nodes for error analysis and debugging.
858
+ """
859
+
860
+ timestamp: datetime = Field(default_factory=datetime.now)
861
+ error_type: str # Exception class name (ValueError, FileNotFoundError, etc.)
862
+ message: str # Error message
863
+ traceback: str | None = None # Full traceback for debugging
864
+ tool: str | None = None # Tool that caused the error (Edit, Bash, etc.)
865
+ context: str | None = None # Additional context information
866
+ session_id: str | None = None # Session ID for cross-referencing
867
+ locals_dump: str | None = None # JSON-serialized local variables at error point
868
+ stack_frames: list[dict[str, Any]] | None = (
869
+ None # Structured stack frame information
870
+ )
871
+ command_args: dict[str, Any] | None = None # Command arguments being executed
872
+ display_level: str = "minimal" # Display level: minimal, verbose, or debug
873
+
874
+ def to_html(self) -> str:
875
+ """Convert error to HTML details element."""
876
+ attrs = [
877
+ f'data-ts="{self.timestamp.isoformat()}"',
878
+ f'data-error-type="{self.error_type}"',
879
+ ]
880
+ if self.tool:
881
+ attrs.append(f'data-tool="{self.tool}"')
882
+
883
+ summary = f"<span class='error-type'>{self.error_type}</span>: {self.message}"
884
+ details = ""
885
+ if self.traceback:
886
+ details = f"<pre class='traceback'>{self.traceback}</pre>"
887
+
888
+ return f"<details class='error-item' {' '.join(attrs)}><summary>{summary}</summary>{details}</details>"
889
+
890
+ def to_context(self) -> str:
891
+ """Lightweight context for AI agents."""
892
+ return f"[{self.timestamp.strftime('%H:%M:%S')}] ERROR {self.error_type}: {self.message}"
893
+
894
+
493
895
  class ActivityEntry(BaseModel):
494
896
  """
495
897
  A lightweight activity log entry for high-frequency events.
@@ -504,8 +906,15 @@ class ActivityEntry(BaseModel):
504
906
  success: bool = True
505
907
  feature_id: str | None = None # Link to feature this activity belongs to
506
908
  drift_score: float | None = None # 0.0-1.0 alignment score
507
- parent_activity_id: str | None = None # Link to parent activity (e.g., Skill invocation)
508
- payload: dict[str, Any] | None = None # Optional rich payload for significant events
909
+ parent_activity_id: str | None = (
910
+ None # Link to parent activity (e.g., Skill invocation)
911
+ )
912
+ payload: dict[str, Any] | None = (
913
+ None # Optional rich payload for significant events
914
+ )
915
+
916
+ # Context tracking (optional, captured when available)
917
+ context_tokens: int | None = None # Tokens in context when this activity occurred
509
918
 
510
919
  def to_html(self) -> str:
511
920
  """Convert activity to HTML list item."""
@@ -522,8 +931,10 @@ class ActivityEntry(BaseModel):
522
931
  attrs.append(f'data-drift="{self.drift_score:.2f}"')
523
932
  if self.parent_activity_id:
524
933
  attrs.append(f'data-parent="{self.parent_activity_id}"')
934
+ if self.context_tokens is not None:
935
+ attrs.append(f'data-context-tokens="{self.context_tokens}"')
525
936
 
526
- return f'<li {" ".join(attrs)}>{self.summary}</li>'
937
+ return f"<li {' '.join(attrs)}>{self.summary}</li>"
527
938
 
528
939
  def to_context(self) -> str:
529
940
  """Lightweight context for AI agents."""
@@ -559,10 +970,18 @@ class Session(BaseModel):
559
970
  worked_on: list[str] = Field(default_factory=list) # Feature IDs
560
971
  continued_from: str | None = None # Previous session ID
561
972
 
562
- # Handoff context
973
+ # Parent session context (for nested Task() calls)
974
+ parent_session: str | None = None # Parent session ID
975
+ parent_activity: str | None = None # Parent activity ID
976
+ nesting_depth: int = 0 # Depth of nesting (0 = top-level)
977
+
978
+ # Handoff context (Phase 2 Feature 3: Cross-Session Continuity)
563
979
  handoff_notes: str | None = None
564
980
  recommended_next: str | None = None
565
981
  blockers: list[str] = Field(default_factory=list)
982
+ recommended_context: list[str] = Field(
983
+ default_factory=list
984
+ ) # File paths to keep context for
566
985
 
567
986
  # High-frequency activity log
568
987
  activity_log: list[ActivityEntry] = Field(default_factory=list)
@@ -571,26 +990,168 @@ class Session(BaseModel):
571
990
  primary_work_type: str | None = None # WorkType enum value
572
991
  work_breakdown: dict[str, int] | None = None # {work_type: event_count}
573
992
 
993
+ # Conversation tracking (for conversation-level auto-spikes)
994
+ last_conversation_id: str | None = None # Last external conversation ID
995
+
996
+ # Context tracking (Phase N: Context Analytics)
997
+ context_snapshots: list[ContextSnapshot] = Field(default_factory=list)
998
+ peak_context_tokens: int = 0 # High water mark for context usage
999
+ total_tokens_generated: int = 0 # Cumulative output tokens
1000
+ total_cost_usd: float = 0.0 # Cumulative cost for session
1001
+ context_by_feature: dict[str, int] = Field(
1002
+ default_factory=dict
1003
+ ) # {feature_id: tokens}
1004
+
1005
+ # Claude Code transcript integration
1006
+ transcript_id: str | None = None # Claude Code session UUID (from JSONL)
1007
+ transcript_path: str | None = None # Path to source JSONL file
1008
+ transcript_synced_at: datetime | None = None # Last sync timestamp
1009
+ transcript_git_branch: str | None = None # Git branch from transcript
1010
+
1011
+ # Pattern detection (inline storage to avoid file bloat)
1012
+ detected_patterns: list[dict[str, Any]] = Field(default_factory=list)
1013
+ """
1014
+ Patterns detected during this session.
1015
+
1016
+ Format:
1017
+ {
1018
+ "sequence": ["Bash", "Read", "Edit"],
1019
+ "pattern_type": "neutral", # or "optimal", "anti_pattern"
1020
+ "detection_count": 3,
1021
+ "first_detected": "2026-01-02T10:00:00",
1022
+ "last_detected": "2026-01-02T10:30:00"
1023
+ }
1024
+ """
1025
+
1026
+ # Error handling (Phase 1B)
1027
+ error_log: list[ErrorEntry] = Field(default_factory=list)
1028
+ """Error records for this session with full tracebacks for debugging."""
1029
+
574
1030
  def add_activity(self, entry: ActivityEntry) -> None:
575
1031
  """Add an activity entry to the log."""
576
1032
  self.activity_log.append(entry)
577
1033
  self.event_count += 1
578
- self.last_activity = datetime.now()
1034
+ self.last_activity = utc_now()
579
1035
 
580
1036
  # Track features worked on
581
1037
  if entry.feature_id and entry.feature_id not in self.worked_on:
582
1038
  self.worked_on.append(entry.feature_id)
583
1039
 
1040
+ def add_error(
1041
+ self,
1042
+ error_type: str,
1043
+ message: str,
1044
+ traceback: str | None = None,
1045
+ tool: str | None = None,
1046
+ context: str | None = None,
1047
+ ) -> None:
1048
+ """
1049
+ Add an error entry to the error log.
1050
+
1051
+ Args:
1052
+ error_type: Exception class name (ValueError, FileNotFoundError, etc.)
1053
+ message: Error message
1054
+ traceback: Full traceback for debugging
1055
+ tool: Tool that caused the error (Edit, Bash, etc.)
1056
+ context: Additional context information
1057
+ """
1058
+ error = ErrorEntry(
1059
+ error_type=error_type,
1060
+ message=message,
1061
+ traceback=traceback,
1062
+ tool=tool,
1063
+ context=context,
1064
+ session_id=self.id,
1065
+ )
1066
+ self.error_log.append(error)
1067
+
584
1068
  def end(self) -> None:
585
1069
  """Mark session as ended."""
586
1070
  self.status = "ended"
587
- self.ended_at = datetime.now()
1071
+ self.ended_at = utc_now()
1072
+
1073
+ def record_context(
1074
+ self, snapshot: ContextSnapshot, sample_interval: int = 10
1075
+ ) -> None:
1076
+ """
1077
+ Record a context snapshot for analytics.
1078
+
1079
+ Args:
1080
+ snapshot: ContextSnapshot to record
1081
+ sample_interval: Only store every Nth snapshot to avoid bloat
1082
+
1083
+ Updates:
1084
+ - peak_context_tokens if current exceeds previous peak
1085
+ - total_tokens_generated from cumulative output
1086
+ - total_cost_usd from snapshot
1087
+ - context_by_feature if feature_id is set
1088
+ - context_snapshots (sampled)
1089
+ """
1090
+ # Update peak context
1091
+ current_tokens = snapshot.current_tokens
1092
+ if current_tokens > self.peak_context_tokens:
1093
+ self.peak_context_tokens = current_tokens
1094
+
1095
+ # Update totals
1096
+ self.total_tokens_generated = snapshot.total_output_tokens
1097
+ self.total_cost_usd = snapshot.cost_usd
1098
+
1099
+ # Track context by feature
1100
+ if snapshot.feature_id:
1101
+ prev = self.context_by_feature.get(snapshot.feature_id, 0)
1102
+ # Use delta from last snapshot with same feature
1103
+ self.context_by_feature[snapshot.feature_id] = max(prev, current_tokens)
1104
+
1105
+ # Sample snapshots to avoid bloat (every Nth or on significant events)
1106
+ should_sample = (
1107
+ len(self.context_snapshots) == 0
1108
+ or len(self.context_snapshots) % sample_interval == 0
1109
+ or snapshot.trigger in ("session_start", "session_end", "feature_switch")
1110
+ or current_tokens > self.peak_context_tokens * 0.9 # Near peak
1111
+ )
1112
+
1113
+ if should_sample:
1114
+ self.context_snapshots.append(snapshot)
1115
+
1116
+ def context_stats(self) -> dict:
1117
+ """
1118
+ Get context usage statistics for this session.
1119
+
1120
+ Returns:
1121
+ Dictionary with context usage metrics
1122
+ """
1123
+ if not self.context_snapshots:
1124
+ return {
1125
+ "peak_tokens": self.peak_context_tokens,
1126
+ "total_output": self.total_tokens_generated,
1127
+ "total_cost": self.total_cost_usd,
1128
+ "by_feature": self.context_by_feature,
1129
+ "snapshots": 0,
1130
+ }
1131
+
1132
+ # Calculate averages and trends
1133
+ tokens_over_time = [s.current_tokens for s in self.context_snapshots]
1134
+ avg_tokens = (
1135
+ sum(tokens_over_time) / len(tokens_over_time) if tokens_over_time else 0
1136
+ )
1137
+
1138
+ return {
1139
+ "peak_tokens": self.peak_context_tokens,
1140
+ "avg_tokens": int(avg_tokens),
1141
+ "total_output": self.total_tokens_generated,
1142
+ "total_cost": self.total_cost_usd,
1143
+ "by_feature": self.context_by_feature,
1144
+ "snapshots": len(self.context_snapshots),
1145
+ "peak_percent": (self.peak_context_tokens / 200000) * 100
1146
+ if self.context_snapshots
1147
+ else 0,
1148
+ }
588
1149
 
589
1150
  def get_events(
590
1151
  self,
591
1152
  limit: int | None = 100,
592
1153
  offset: int = 0,
593
- events_dir: str = ".htmlgraph/events"
1154
+ events_dir: str = ".htmlgraph/events",
594
1155
  ) -> list[dict]:
595
1156
  """
596
1157
  Get events for this session from JSONL event log.
@@ -610,6 +1171,7 @@ class Session(BaseModel):
610
1171
  ... print(f"{evt['event_id']}: {evt['tool']}")
611
1172
  """
612
1173
  from htmlgraph.event_log import JsonlEventLog
1174
+
613
1175
  event_log = JsonlEventLog(events_dir)
614
1176
  return event_log.get_session_events(self.id, limit=limit, offset=offset)
615
1177
 
@@ -619,7 +1181,7 @@ class Session(BaseModel):
619
1181
  feature_id: str | None = None,
620
1182
  since: Any = None,
621
1183
  limit: int | None = 100,
622
- events_dir: str = ".htmlgraph/events"
1184
+ events_dir: str = ".htmlgraph/events",
623
1185
  ) -> list[dict]:
624
1186
  """
625
1187
  Query events for this session with filters.
@@ -640,13 +1202,14 @@ class Session(BaseModel):
640
1202
  >>> feature_events = session.query_events(feature_id='feat-123')
641
1203
  """
642
1204
  from htmlgraph.event_log import JsonlEventLog
1205
+
643
1206
  event_log = JsonlEventLog(events_dir)
644
1207
  return event_log.query_events(
645
1208
  session_id=self.id,
646
1209
  tool=tool,
647
1210
  feature_id=feature_id,
648
1211
  since=since,
649
- limit=limit
1212
+ limit=limit,
650
1213
  )
651
1214
 
652
1215
  def event_stats(self, events_dir: str = ".htmlgraph/events") -> dict:
@@ -664,28 +1227,30 @@ class Session(BaseModel):
664
1227
  """
665
1228
  events = self.get_events(limit=None, events_dir=events_dir)
666
1229
 
667
- by_tool = {}
668
- by_feature = {}
1230
+ by_tool: dict[str, int] = {}
1231
+ by_feature: dict[str, int] = {}
669
1232
 
670
1233
  for evt in events:
671
1234
  # Count by tool
672
- tool = evt.get('tool', 'Unknown')
1235
+ tool = evt.get("tool", "Unknown")
673
1236
  by_tool[tool] = by_tool.get(tool, 0) + 1
674
1237
 
675
1238
  # Count by feature
676
- feature = evt.get('feature_id')
1239
+ feature = evt.get("feature_id")
677
1240
  if feature:
678
1241
  by_feature[feature] = by_feature.get(feature, 0) + 1
679
1242
 
680
1243
  return {
681
- 'total_events': len(events),
682
- 'by_tool': by_tool,
683
- 'by_feature': by_feature,
684
- 'tools_used': len(by_tool),
685
- 'features_worked': len(by_feature)
1244
+ "total_events": len(events),
1245
+ "by_tool": by_tool,
1246
+ "by_feature": by_feature,
1247
+ "tools_used": len(by_tool),
1248
+ "features_worked": len(by_feature),
686
1249
  }
687
1250
 
688
- def calculate_work_breakdown(self, events_dir: str = ".htmlgraph/events") -> dict[str, int]:
1251
+ def calculate_work_breakdown(
1252
+ self, events_dir: str = ".htmlgraph/events"
1253
+ ) -> dict[str, int]:
689
1254
  """
690
1255
  Calculate distribution of work types from events.
691
1256
 
@@ -708,7 +1273,9 @@ class Session(BaseModel):
708
1273
 
709
1274
  return breakdown
710
1275
 
711
- def calculate_primary_work_type(self, events_dir: str = ".htmlgraph/events") -> str | None:
1276
+ def calculate_primary_work_type(
1277
+ self, events_dir: str = ".htmlgraph/events"
1278
+ ) -> str | None:
712
1279
  """
713
1280
  Determine primary work type based on event distribution.
714
1281
 
@@ -727,6 +1294,62 @@ class Session(BaseModel):
727
1294
  # Return work type with most events
728
1295
  return max(breakdown, key=breakdown.get) # type: ignore
729
1296
 
1297
+ def cleanup_missing_references(self, graph_dir: str | Path) -> dict[str, Any]:
1298
+ """
1299
+ Remove references to deleted/missing work items from worked_on list.
1300
+
1301
+ This fixes session data integrity issues where worked_on contains IDs
1302
+ that no longer exist (deleted spikes, removed features, etc.).
1303
+
1304
+ Args:
1305
+ graph_dir: Path to .htmlgraph directory
1306
+
1307
+ Returns:
1308
+ Dict with cleanup statistics: {
1309
+ "removed": [...], # List of removed IDs
1310
+ "kept": [...], # List of valid IDs that were kept
1311
+ "removed_count": int,
1312
+ "kept_count": int
1313
+ }
1314
+ """
1315
+ graph_path = Path(graph_dir)
1316
+ removed = []
1317
+ kept = []
1318
+
1319
+ # Check each work item in worked_on
1320
+ for item_id in self.worked_on:
1321
+ # Determine work item type from ID prefix
1322
+ if item_id.startswith("feat-") or item_id.startswith("feature-"):
1323
+ file_path = graph_path / "features" / f"{item_id}.html"
1324
+ elif item_id.startswith("bug-"):
1325
+ file_path = graph_path / "bugs" / f"{item_id}.html"
1326
+ elif item_id.startswith("spk-") or item_id.startswith("spike-"):
1327
+ file_path = graph_path / "spikes" / f"{item_id}.html"
1328
+ elif item_id.startswith("chore-"):
1329
+ file_path = graph_path / "chores" / f"{item_id}.html"
1330
+ elif item_id.startswith("epic-"):
1331
+ file_path = graph_path / "epics" / f"{item_id}.html"
1332
+ else:
1333
+ # Unknown type, keep it
1334
+ kept.append(item_id)
1335
+ continue
1336
+
1337
+ # Check if file exists
1338
+ if file_path.exists():
1339
+ kept.append(item_id)
1340
+ else:
1341
+ removed.append(item_id)
1342
+
1343
+ # Update worked_on with only valid references
1344
+ self.worked_on = kept
1345
+
1346
+ return {
1347
+ "removed": removed,
1348
+ "kept": kept,
1349
+ "removed_count": len(removed),
1350
+ "kept_count": len(kept),
1351
+ }
1352
+
730
1353
  def to_html(self, stylesheet_path: str = "../styles.css") -> str:
731
1354
  """Convert session to HTML document with inline activity log."""
732
1355
  # Build edges HTML for worked_on features
@@ -739,13 +1362,13 @@ class Session(BaseModel):
739
1362
  f'<li><a href="../features/{fid}.html" data-relationship="worked-on">{fid}</a></li>'
740
1363
  for fid in self.worked_on
741
1364
  )
742
- edge_sections.append(f'''
1365
+ edge_sections.append(f"""
743
1366
  <section data-edge-type="worked-on">
744
1367
  <h3>Worked On:</h3>
745
1368
  <ul>
746
1369
  {feature_links}
747
1370
  </ul>
748
- </section>''')
1371
+ </section>""")
749
1372
 
750
1373
  if self.continued_from:
751
1374
  edge_sections.append(f'''
@@ -756,65 +1379,203 @@ class Session(BaseModel):
756
1379
  </ul>
757
1380
  </section>''')
758
1381
 
759
- edges_html = f'''
1382
+ edges_html = f"""
760
1383
  <nav data-graph-edges>{"".join(edge_sections)}
761
- </nav>'''
1384
+ </nav>"""
762
1385
 
763
1386
  # Build handoff HTML
764
1387
  handoff_html = ""
765
- if self.handoff_notes or self.recommended_next or self.blockers:
766
- handoff_section = '''
1388
+ if (
1389
+ self.handoff_notes
1390
+ or self.recommended_next
1391
+ or self.blockers
1392
+ or self.recommended_context
1393
+ ):
1394
+ handoff_section = """
767
1395
  <section data-handoff>
768
- <h3>Handoff Context</h3>'''
1396
+ <h3>Handoff Context</h3>"""
769
1397
 
770
1398
  if self.handoff_notes:
771
- handoff_section += f'\n <p data-handoff-notes><strong>Notes:</strong> {self.handoff_notes}</p>'
1399
+ handoff_section += f"\n <p data-handoff-notes><strong>Notes:</strong> {self.handoff_notes}</p>"
772
1400
 
773
1401
  if self.recommended_next:
774
- handoff_section += f'\n <p data-recommended-next><strong>Recommended Next:</strong> {self.recommended_next}</p>'
1402
+ handoff_section += f"\n <p data-recommended-next><strong>Recommended Next:</strong> {self.recommended_next}</p>"
775
1403
 
776
1404
  if self.blockers:
777
1405
  blockers_items = "\n ".join(
778
1406
  f"<li>{blocker}</li>" for blocker in self.blockers
779
1407
  )
780
- handoff_section += f'''
1408
+ handoff_section += f"""
781
1409
  <div data-blockers>
782
1410
  <strong>Blockers:</strong>
783
1411
  <ul>
784
1412
  {blockers_items}
785
1413
  </ul>
786
- </div>'''
1414
+ </div>"""
787
1415
 
788
- handoff_section += '\n </section>'
1416
+ if self.recommended_context:
1417
+ context_items = "\n ".join(
1418
+ f"<li>{file_path}</li>" for file_path in self.recommended_context
1419
+ )
1420
+ handoff_section += f"""
1421
+ <div data-recommended-context>
1422
+ <strong>Recommended Context:</strong>
1423
+ <ul>
1424
+ {context_items}
1425
+ </ul>
1426
+ </div>"""
1427
+
1428
+ handoff_section += "\n </section>"
789
1429
  handoff_html = handoff_section
790
1430
 
791
1431
  # Build activity log HTML
792
1432
  activity_html = ""
793
1433
  if self.activity_log:
794
1434
  # Show most recent first (reversed)
1435
+ # NOTE: Previously limited to last 100 entries, but this caused data loss
1436
+ # for pattern detection and analytics. Now stores all entries.
795
1437
  log_items = "\n ".join(
796
- entry.to_html() for entry in reversed(self.activity_log[-100:]) # Last 100 entries
1438
+ entry.to_html()
1439
+ for entry in reversed(self.activity_log) # All entries
797
1440
  )
798
- activity_html = f'''
1441
+ activity_html = f"""
799
1442
  <section data-activity-log>
800
1443
  <h3>Activity Log ({self.event_count} events)</h3>
801
1444
  <ol reversed>
802
1445
  {log_items}
803
1446
  </ol>
804
- </section>'''
1447
+ </section>"""
805
1448
 
806
1449
  # Build attributes
807
1450
  subagent_attr = f' data-is-subagent="{str(self.is_subagent).lower()}"'
808
- commit_attr = f' data-start-commit="{self.start_commit}"' if self.start_commit else ""
809
- ended_attr = f' data-ended-at="{self.ended_at.isoformat()}"' if self.ended_at else ""
810
- primary_work_type_attr = f' data-primary-work-type="{self.primary_work_type}"' if self.primary_work_type else ""
1451
+ commit_attr = (
1452
+ f' data-start-commit="{self.start_commit}"' if self.start_commit else ""
1453
+ )
1454
+ ended_attr = (
1455
+ f' data-ended-at="{self.ended_at.isoformat()}"' if self.ended_at else ""
1456
+ )
1457
+ primary_work_type_attr = (
1458
+ f' data-primary-work-type="{self.primary_work_type}"'
1459
+ if self.primary_work_type
1460
+ else ""
1461
+ )
1462
+ # Parent session attributes
1463
+ parent_session_attrs = ""
1464
+ if self.parent_session:
1465
+ parent_session_attrs += f' data-parent-session="{self.parent_session}"'
1466
+ if self.parent_activity:
1467
+ parent_session_attrs += f' data-parent-activity="{self.parent_activity}"'
1468
+ if self.nesting_depth > 0:
1469
+ parent_session_attrs += f' data-nesting-depth="{self.nesting_depth}"'
811
1470
 
812
1471
  # Serialize work_breakdown as JSON if present
813
1472
  import json
1473
+
814
1474
  work_breakdown_attr = ""
815
1475
  if self.work_breakdown:
816
1476
  work_breakdown_json = json.dumps(self.work_breakdown)
817
- work_breakdown_attr = f' data-work-breakdown=\'{work_breakdown_json}\''
1477
+ work_breakdown_attr = f" data-work-breakdown='{work_breakdown_json}'"
1478
+
1479
+ # Context tracking attributes
1480
+ context_attrs = ""
1481
+ if self.peak_context_tokens > 0:
1482
+ context_attrs += f' data-peak-context="{self.peak_context_tokens}"'
1483
+ if self.total_tokens_generated > 0:
1484
+ context_attrs += f' data-total-output="{self.total_tokens_generated}"'
1485
+ if self.total_cost_usd > 0:
1486
+ context_attrs += f' data-total-cost="{self.total_cost_usd:.4f}"'
1487
+ if self.context_by_feature:
1488
+ context_by_feature_json = json.dumps(self.context_by_feature)
1489
+ context_attrs += f" data-context-by-feature='{context_by_feature_json}'"
1490
+
1491
+ # Transcript integration attributes
1492
+ transcript_attrs = ""
1493
+ if self.transcript_id:
1494
+ transcript_attrs += f' data-transcript-id="{self.transcript_id}"'
1495
+ if self.transcript_path:
1496
+ transcript_attrs += f' data-transcript-path="{self.transcript_path}"'
1497
+ if self.transcript_synced_at:
1498
+ transcript_attrs += (
1499
+ f' data-transcript-synced="{self.transcript_synced_at.isoformat()}"'
1500
+ )
1501
+ if self.transcript_git_branch:
1502
+ transcript_attrs += (
1503
+ f' data-transcript-branch="{self.transcript_git_branch}"'
1504
+ )
1505
+
1506
+ # Build context summary section
1507
+ context_html = ""
1508
+ if self.peak_context_tokens > 0 or self.context_snapshots:
1509
+ context_html = f"""
1510
+ <section data-context-tracking>
1511
+ <h3>Context Usage</h3>
1512
+ <dl>
1513
+ <dt>Peak Context</dt>
1514
+ <dd>{self.peak_context_tokens:,} tokens ({self.peak_context_tokens * 100 // 200000}%)</dd>
1515
+ <dt>Total Output</dt>
1516
+ <dd>{self.total_tokens_generated:,} tokens</dd>
1517
+ <dt>Total Cost</dt>
1518
+ <dd>${self.total_cost_usd:.4f}</dd>
1519
+ <dt>Snapshots</dt>
1520
+ <dd>{len(self.context_snapshots)}</dd>
1521
+ </dl>
1522
+ </section>"""
1523
+
1524
+ # Build detected patterns section
1525
+ patterns_html = ""
1526
+ if self.detected_patterns:
1527
+ patterns_html = f"""
1528
+ <section data-detected-patterns>
1529
+ <h3>Detected Patterns ({len(self.detected_patterns)})</h3>
1530
+ <table class="patterns-table">
1531
+ <thead>
1532
+ <tr>
1533
+ <th>Sequence</th>
1534
+ <th>Type</th>
1535
+ <th>Count</th>
1536
+ <th>First/Last Detected</th>
1537
+ </tr>
1538
+ </thead>
1539
+ <tbody>"""
1540
+
1541
+ for pattern in self.detected_patterns:
1542
+ seq_str = " → ".join(pattern.get("sequence", []))
1543
+ pattern_type = pattern.get("pattern_type", "neutral")
1544
+ count = pattern.get("detection_count", 0)
1545
+ first = pattern.get("first_detected", "")
1546
+ last = pattern.get("last_detected", "")
1547
+
1548
+ patterns_html += f"""
1549
+ <tr data-pattern-type="{pattern_type}">
1550
+ <td class="sequence">{seq_str}</td>
1551
+ <td><span class="badge pattern-{pattern_type}">{pattern_type}</span></td>
1552
+ <td>{count}</td>
1553
+ <td>{first} / {last}</td>
1554
+ </tr>"""
1555
+
1556
+ patterns_html += """
1557
+ </tbody>
1558
+ </table>
1559
+ </section>"""
1560
+
1561
+ # Build error log section
1562
+ error_html = ""
1563
+ if self.error_log:
1564
+ error_items = "\n ".join(
1565
+ error.to_html() for error in self.error_log
1566
+ )
1567
+ error_html = f"""
1568
+ <section data-error-log>
1569
+ <h3>Errors ({len(self.error_log)})</h3>
1570
+ <div class="error-log">
1571
+ {error_items}
1572
+ </div>
1573
+ <style>
1574
+ .error-item {{ margin: 10px 0; padding: 10px; border-left: 3px solid #ff6b6b; }}
1575
+ .error-type {{ font-weight: bold; color: #ff6b6b; }}
1576
+ .traceback {{ background: #f5f5f5; padding: 10px; overflow-x: auto; font-size: 0.9em; margin-top: 5px; }}
1577
+ </style>
1578
+ </section>"""
818
1579
 
819
1580
  title = self.title or f"Session {self.id}"
820
1581
 
@@ -834,7 +1595,7 @@ class Session(BaseModel):
834
1595
  data-agent="{self.agent}"
835
1596
  data-started-at="{self.started_at.isoformat()}"
836
1597
  data-last-activity="{self.last_activity.isoformat()}"
837
- data-event-count="{self.event_count}"{subagent_attr}{commit_attr}{ended_attr}{primary_work_type_attr}{work_breakdown_attr}>
1598
+ data-event-count="{self.event_count}"{subagent_attr}{commit_attr}{ended_attr}{primary_work_type_attr}{work_breakdown_attr}{context_attrs}{transcript_attrs}{parent_session_attrs}>
838
1599
 
839
1600
  <header>
840
1601
  <h1>{title}</h1>
@@ -844,7 +1605,7 @@ class Session(BaseModel):
844
1605
  <span class="badge">{self.event_count} events</span>
845
1606
  </div>
846
1607
  </header>
847
- {edges_html}{handoff_html}{activity_html}
1608
+ {edges_html}{handoff_html}{context_html}{error_html}{patterns_html}{activity_html}
848
1609
  </article>
849
1610
  </body>
850
1611
  </html>
@@ -885,6 +1646,11 @@ class Session(BaseModel):
885
1646
  ActivityEntry(**e) if isinstance(e, dict) else e
886
1647
  for e in data["activity_log"]
887
1648
  ]
1649
+ if "context_snapshots" in data:
1650
+ data["context_snapshots"] = [
1651
+ ContextSnapshot.from_dict(s) if isinstance(s, dict) else s
1652
+ for s in data["context_snapshots"]
1653
+ ]
888
1654
  return cls(**data)
889
1655
 
890
1656
 
@@ -925,3 +1691,736 @@ class Graph(BaseModel):
925
1691
  def to_context(self) -> str:
926
1692
  """Generate lightweight context for all nodes."""
927
1693
  return "\n\n".join(node.to_context() for node in self.nodes.values())
1694
+
1695
+
1696
+ class Pattern(Node):
1697
+ """Learned workflow pattern for agent optimization.
1698
+
1699
+ Stores detected tool sequences that are either optimal patterns
1700
+ to encourage or anti-patterns to avoid.
1701
+ """
1702
+
1703
+ pattern_type: Literal["optimal", "anti-pattern", "neutral"] = "neutral"
1704
+ sequence: list[str] = Field(default_factory=list) # ["Bash", "Edit", "Read"]
1705
+
1706
+ # Detection metrics
1707
+ detection_count: int = 0
1708
+ success_rate: float = 0.0 # 0.0-1.0
1709
+ avg_duration_seconds: float = 0.0
1710
+
1711
+ # Sessions where detected
1712
+ detected_in_sessions: list[str] = Field(default_factory=list)
1713
+
1714
+ # Recommendation
1715
+ recommendation: str | None = None
1716
+
1717
+ # Trend
1718
+ first_detected: datetime | None = None
1719
+ last_detected: datetime | None = None
1720
+ detection_trend: Literal["increasing", "stable", "decreasing"] = "stable"
1721
+
1722
+ def __init__(self, **data: Any):
1723
+ # Ensure type is always "pattern"
1724
+ data["type"] = "pattern"
1725
+ super().__init__(**data)
1726
+
1727
+ def to_html(self, stylesheet_path: str = "../styles.css") -> str:
1728
+ """Convert pattern to HTML document with pattern-specific fields."""
1729
+ # Build pattern sequence HTML
1730
+ sequence_html = ""
1731
+ if self.sequence:
1732
+ sequence_items = " → ".join(self.sequence)
1733
+ sequence_html = f"""
1734
+ <section data-pattern-sequence>
1735
+ <h3>Tool Sequence</h3>
1736
+ <p class="sequence">{sequence_items}</p>
1737
+ </section>"""
1738
+
1739
+ # Build pattern metrics HTML
1740
+ metrics_html = f"""
1741
+ <section data-pattern-metrics>
1742
+ <h3>Pattern Metrics</h3>
1743
+ <dl>
1744
+ <dt>Detection Count</dt>
1745
+ <dd>{self.detection_count}</dd>
1746
+ <dt>Success Rate</dt>
1747
+ <dd>{self.success_rate:.1%}</dd>
1748
+ <dt>Avg Duration</dt>
1749
+ <dd>{self.avg_duration_seconds:.1f}s</dd>
1750
+ </dl>
1751
+ </section>"""
1752
+
1753
+ # Build detected sessions HTML
1754
+ detected_sessions_html = ""
1755
+ if self.detected_in_sessions:
1756
+ session_links = "\n ".join(
1757
+ f'<li><a href="../sessions/{sid}.html">{sid}</a></li>'
1758
+ for sid in self.detected_in_sessions
1759
+ )
1760
+ detected_sessions_html = f"""
1761
+ <section data-detected-sessions>
1762
+ <h3>Detected In Sessions</h3>
1763
+ <ul>
1764
+ {session_links}
1765
+ </ul>
1766
+ </section>"""
1767
+
1768
+ # Build recommendation HTML
1769
+ recommendation_html = ""
1770
+ if self.recommendation:
1771
+ recommendation_html = f"""
1772
+ <section data-recommendation>
1773
+ <h3>Recommendation</h3>
1774
+ <p>{self.recommendation}</p>
1775
+ </section>"""
1776
+
1777
+ # Build trend HTML
1778
+ trend_html = ""
1779
+ if self.first_detected or self.last_detected:
1780
+ trend_html = """
1781
+ <section data-trend>
1782
+ <h3>Trend Analysis</h3>
1783
+ <dl>"""
1784
+ if self.first_detected:
1785
+ trend_html += f"""
1786
+ <dt>First Detected</dt>
1787
+ <dd>{self.first_detected.strftime("%Y-%m-%d %H:%M")}</dd>"""
1788
+ if self.last_detected:
1789
+ trend_html += f"""
1790
+ <dt>Last Detected</dt>
1791
+ <dd>{self.last_detected.strftime("%Y-%m-%d %H:%M")}</dd>"""
1792
+ trend_html += f"""
1793
+ <dt>Detection Trend</dt>
1794
+ <dd class="trend-{self.detection_trend}">{self.detection_trend.title()}</dd>
1795
+ </dl>
1796
+ </section>"""
1797
+
1798
+ # Build pattern-specific attributes
1799
+ pattern_attrs = f' data-pattern-type="{self.pattern_type}"'
1800
+ pattern_attrs += f' data-detection-count="{self.detection_count}"'
1801
+ pattern_attrs += f' data-success-rate="{self.success_rate:.2f}"'
1802
+ pattern_attrs += f' data-detection-trend="{self.detection_trend}"'
1803
+ if self.sequence:
1804
+ import json
1805
+
1806
+ sequence_json = json.dumps(self.sequence)
1807
+ pattern_attrs += f" data-sequence='{sequence_json}'"
1808
+
1809
+ return f'''<!DOCTYPE html>
1810
+ <html lang="en">
1811
+ <head>
1812
+ <meta charset="UTF-8">
1813
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1814
+ <meta name="htmlgraph-version" content="1.0">
1815
+ <title>{self.title}</title>
1816
+ <link rel="stylesheet" href="{stylesheet_path}">
1817
+ </head>
1818
+ <body>
1819
+ <article id="{self.id}"
1820
+ data-type="{self.type}"
1821
+ data-status="{self.status}"
1822
+ data-priority="{self.priority}"
1823
+ data-created="{self.created.isoformat()}"
1824
+ data-updated="{self.updated.isoformat()}"{pattern_attrs}>
1825
+
1826
+ <header>
1827
+ <h1>{self.title}</h1>
1828
+ <div class="metadata">
1829
+ <span class="badge status-{self.status}">{self.status.replace("-", " ").title()}</span>
1830
+ <span class="badge pattern-{self.pattern_type}">{self.pattern_type.title()}</span>
1831
+ </div>
1832
+ </header>
1833
+ {sequence_html}{metrics_html}{detected_sessions_html}{recommendation_html}{trend_html}
1834
+ </article>
1835
+ </body>
1836
+ </html>
1837
+ '''
1838
+
1839
+
1840
+ class SessionInsight(Node):
1841
+ """Session analysis and health metrics.
1842
+
1843
+ Stores efficiency scores, detected issues, and recommendations
1844
+ for a specific session.
1845
+ """
1846
+
1847
+ session_id: str = ""
1848
+ insight_type: Literal["health", "recommendation", "anomaly"] = "health"
1849
+
1850
+ # Health metrics
1851
+ efficiency_score: float = 0.0 # 0.0-1.0
1852
+ retry_rate: float = 0.0
1853
+ context_rebuild_count: int = 0
1854
+ tool_diversity: float = 0.0
1855
+ error_recovery_rate: float = 0.0
1856
+ overall_health_score: float = 0.0
1857
+
1858
+ # Detections
1859
+ issues_detected: list[str] = Field(default_factory=list)
1860
+ patterns_matched: list[str] = Field(default_factory=list) # Pattern IDs
1861
+ anti_patterns_matched: list[str] = Field(default_factory=list)
1862
+
1863
+ # Recommendations
1864
+ recommendations: list[str] = Field(default_factory=list)
1865
+
1866
+ # Metadata
1867
+ analyzed_at: datetime | None = None
1868
+
1869
+ def __init__(self, **data: Any):
1870
+ # Ensure type is always "session-insight"
1871
+ data["type"] = "session-insight"
1872
+ super().__init__(**data)
1873
+
1874
+ def to_html(self, stylesheet_path: str = "../styles.css") -> str:
1875
+ """Convert session insight to HTML document with insight-specific fields."""
1876
+ # Build health metrics HTML
1877
+ metrics_html = f"""
1878
+ <section data-health-metrics>
1879
+ <h3>Health Metrics</h3>
1880
+ <dl>
1881
+ <dt>Efficiency Score</dt>
1882
+ <dd>{self.efficiency_score:.2f}</dd>
1883
+ <dt>Retry Rate</dt>
1884
+ <dd>{self.retry_rate:.1%}</dd>
1885
+ <dt>Context Rebuild Count</dt>
1886
+ <dd>{self.context_rebuild_count}</dd>
1887
+ <dt>Tool Diversity</dt>
1888
+ <dd>{self.tool_diversity:.2f}</dd>
1889
+ <dt>Error Recovery Rate</dt>
1890
+ <dd>{self.error_recovery_rate:.1%}</dd>
1891
+ <dt>Overall Health Score</dt>
1892
+ <dd class="health-score">{self.overall_health_score:.2f}</dd>
1893
+ </dl>
1894
+ </section>"""
1895
+
1896
+ # Build issues detected HTML
1897
+ issues_html = ""
1898
+ if self.issues_detected:
1899
+ issues_items = "\n ".join(
1900
+ f"<li>{issue}</li>" for issue in self.issues_detected
1901
+ )
1902
+ issues_html = f"""
1903
+ <section data-issues-detected>
1904
+ <h3>Issues Detected</h3>
1905
+ <ul>
1906
+ {issues_items}
1907
+ </ul>
1908
+ </section>"""
1909
+
1910
+ # Build patterns matched HTML
1911
+ patterns_html = ""
1912
+ if self.patterns_matched or self.anti_patterns_matched:
1913
+ patterns_section = """
1914
+ <section data-patterns-matched>
1915
+ <h3>Patterns Matched</h3>"""
1916
+
1917
+ if self.patterns_matched:
1918
+ pattern_links = "\n ".join(
1919
+ f'<li><a href="../patterns/{pid}.html" data-pattern-type="optimal">{pid}</a></li>'
1920
+ for pid in self.patterns_matched
1921
+ )
1922
+ patterns_section += f"""
1923
+ <div data-optimal-patterns>
1924
+ <h4>Optimal Patterns:</h4>
1925
+ <ul>
1926
+ {pattern_links}
1927
+ </ul>
1928
+ </div>"""
1929
+
1930
+ if self.anti_patterns_matched:
1931
+ anti_pattern_links = "\n ".join(
1932
+ f'<li><a href="../patterns/{pid}.html" data-pattern-type="anti-pattern">{pid}</a></li>'
1933
+ for pid in self.anti_patterns_matched
1934
+ )
1935
+ patterns_section += f"""
1936
+ <div data-anti-patterns>
1937
+ <h4>Anti-Patterns:</h4>
1938
+ <ul>
1939
+ {anti_pattern_links}
1940
+ </ul>
1941
+ </div>"""
1942
+
1943
+ patterns_section += """
1944
+ </section>"""
1945
+ patterns_html = patterns_section
1946
+
1947
+ # Build recommendations HTML
1948
+ recommendations_html = ""
1949
+ if self.recommendations:
1950
+ rec_items = "\n ".join(
1951
+ f"<li>{rec}</li>" for rec in self.recommendations
1952
+ )
1953
+ recommendations_html = f"""
1954
+ <section data-recommendations>
1955
+ <h3>Recommendations</h3>
1956
+ <ul>
1957
+ {rec_items}
1958
+ </ul>
1959
+ </section>"""
1960
+
1961
+ # Build session link HTML
1962
+ session_link_html = ""
1963
+ if self.session_id:
1964
+ session_link_html = f"""
1965
+ <section data-session-link>
1966
+ <h3>Related Session</h3>
1967
+ <p><a href="../sessions/{self.session_id}.html">{self.session_id}</a></p>
1968
+ </section>"""
1969
+
1970
+ # Build insight-specific attributes
1971
+ import json
1972
+
1973
+ insight_attrs = (
1974
+ f' data-session-id="{self.session_id}"' if self.session_id else ""
1975
+ )
1976
+ insight_attrs += f' data-insight-type="{self.insight_type}"'
1977
+ insight_attrs += f' data-efficiency-score="{self.efficiency_score:.2f}"'
1978
+ insight_attrs += f' data-retry-rate="{self.retry_rate:.2f}"'
1979
+ insight_attrs += f' data-overall-health="{self.overall_health_score:.2f}"'
1980
+
1981
+ if self.analyzed_at:
1982
+ insight_attrs += f' data-analyzed-at="{self.analyzed_at.isoformat()}"'
1983
+
1984
+ if self.issues_detected:
1985
+ issues_json = json.dumps(self.issues_detected)
1986
+ insight_attrs += f" data-issues='{issues_json}'"
1987
+
1988
+ return f'''<!DOCTYPE html>
1989
+ <html lang="en">
1990
+ <head>
1991
+ <meta charset="UTF-8">
1992
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1993
+ <meta name="htmlgraph-version" content="1.0">
1994
+ <title>{self.title}</title>
1995
+ <link rel="stylesheet" href="{stylesheet_path}">
1996
+ </head>
1997
+ <body>
1998
+ <article id="{self.id}"
1999
+ data-type="{self.type}"
2000
+ data-status="{self.status}"
2001
+ data-priority="{self.priority}"
2002
+ data-created="{self.created.isoformat()}"
2003
+ data-updated="{self.updated.isoformat()}"{insight_attrs}>
2004
+
2005
+ <header>
2006
+ <h1>{self.title}</h1>
2007
+ <div class="metadata">
2008
+ <span class="badge status-{self.status}">{self.status.replace("-", " ").title()}</span>
2009
+ <span class="badge insight-{self.insight_type}">{self.insight_type.title()}</span>
2010
+ <span class="badge health-score">Health: {self.overall_health_score:.2f}</span>
2011
+ </div>
2012
+ </header>
2013
+ {session_link_html}{metrics_html}{issues_html}{patterns_html}{recommendations_html}
2014
+ </article>
2015
+ </body>
2016
+ </html>
2017
+ '''
2018
+
2019
+
2020
+ class AggregatedMetric(Node):
2021
+ """Time-aggregated metrics across sessions.
2022
+
2023
+ Stores weekly/monthly aggregated metrics for trend analysis.
2024
+ """
2025
+
2026
+ metric_type: Literal["efficiency", "context_usage", "tool_distribution"] = (
2027
+ "efficiency"
2028
+ )
2029
+ scope: Literal["session", "feature", "track", "agent"] = "session"
2030
+ scope_id: str | None = None
2031
+
2032
+ # Time window
2033
+ period: Literal["daily", "weekly", "monthly"] = "weekly"
2034
+ period_start: datetime | None = None
2035
+ period_end: datetime | None = None
2036
+
2037
+ # Metrics
2038
+ metric_values: dict[str, float] = Field(default_factory=dict)
2039
+ percentiles: dict[str, float] = Field(
2040
+ default_factory=dict
2041
+ ) # {"p50": 0.8, "p90": 0.9}
2042
+
2043
+ # Trend
2044
+ trend_direction: Literal["improving", "stable", "declining"] = "stable"
2045
+ trend_strength: float = 0.0 # 0.0-1.0
2046
+ vs_previous_period_pct: float = 0.0
2047
+
2048
+ # Data source
2049
+ sessions_in_period: list[str] = Field(default_factory=list)
2050
+ data_points_count: int = 0
2051
+
2052
+ def __init__(self, **data: Any):
2053
+ # Ensure type is always "aggregated-metric"
2054
+ data["type"] = "aggregated-metric"
2055
+ super().__init__(**data)
2056
+
2057
+ def to_html(self, stylesheet_path: str = "../styles.css") -> str:
2058
+ """Convert aggregated metric to HTML document with metric-specific fields."""
2059
+ # Build metric overview HTML
2060
+ overview_html = f"""
2061
+ <section data-metric-overview>
2062
+ <h3>Metric Overview</h3>
2063
+ <dl>
2064
+ <dt>Metric Type</dt>
2065
+ <dd>{self.metric_type.replace("_", " ").title()}</dd>
2066
+ <dt>Scope</dt>
2067
+ <dd>{self.scope.title()}</dd>"""
2068
+
2069
+ if self.scope_id:
2070
+ overview_html += f"""
2071
+ <dt>Scope ID</dt>
2072
+ <dd>{self.scope_id}</dd>"""
2073
+
2074
+ overview_html += f"""
2075
+ <dt>Period</dt>
2076
+ <dd>{self.period.title()}</dd>"""
2077
+
2078
+ if self.period_start:
2079
+ overview_html += f"""
2080
+ <dt>Period Start</dt>
2081
+ <dd>{self.period_start.strftime("%Y-%m-%d %H:%M")}</dd>"""
2082
+
2083
+ if self.period_end:
2084
+ overview_html += f"""
2085
+ <dt>Period End</dt>
2086
+ <dd>{self.period_end.strftime("%Y-%m-%d %H:%M")}</dd>"""
2087
+
2088
+ overview_html += """
2089
+ </dl>
2090
+ </section>"""
2091
+
2092
+ # Build metric values HTML
2093
+ values_html = ""
2094
+ if self.metric_values:
2095
+ value_items = "\n ".join(
2096
+ f"<dt>{k.replace('_', ' ').title()}</dt>\n <dd>{v:.4f}</dd>"
2097
+ for k, v in self.metric_values.items()
2098
+ )
2099
+ values_html = f"""
2100
+ <section data-metric-values>
2101
+ <h3>Metric Values</h3>
2102
+ <dl>
2103
+ {value_items}
2104
+ </dl>
2105
+ </section>"""
2106
+
2107
+ # Build percentiles HTML
2108
+ percentiles_html = ""
2109
+ if self.percentiles:
2110
+ percentile_items = "\n ".join(
2111
+ f"<dt>{k}</dt>\n <dd>{v:.4f}</dd>"
2112
+ for k, v in self.percentiles.items()
2113
+ )
2114
+ percentiles_html = f"""
2115
+ <section data-percentiles>
2116
+ <h3>Percentiles</h3>
2117
+ <dl>
2118
+ {percentile_items}
2119
+ </dl>
2120
+ </section>"""
2121
+
2122
+ # Build trend HTML
2123
+ trend_html = f"""
2124
+ <section data-trend>
2125
+ <h3>Trend Analysis</h3>
2126
+ <dl>
2127
+ <dt>Direction</dt>
2128
+ <dd class="trend-{self.trend_direction}">{self.trend_direction.title()}</dd>
2129
+ <dt>Strength</dt>
2130
+ <dd>{self.trend_strength:.1%}</dd>
2131
+ <dt>vs Previous Period</dt>
2132
+ <dd class="{"positive" if self.vs_previous_period_pct > 0 else "negative"}">{self.vs_previous_period_pct:+.1f}%</dd>
2133
+ </dl>
2134
+ </section>"""
2135
+
2136
+ # Build sessions HTML
2137
+ sessions_html = ""
2138
+ if self.sessions_in_period:
2139
+ session_links = "\n ".join(
2140
+ f'<li><a href="../sessions/{sid}.html">{sid}</a></li>'
2141
+ for sid in self.sessions_in_period[:20] # Limit to first 20
2142
+ )
2143
+ more_sessions = ""
2144
+ if len(self.sessions_in_period) > 20:
2145
+ more_sessions = f"\n <li>... and {len(self.sessions_in_period) - 20} more</li>"
2146
+
2147
+ sessions_html = f"""
2148
+ <section data-sessions>
2149
+ <h3>Sessions in Period ({len(self.sessions_in_period)})</h3>
2150
+ <ul>
2151
+ {session_links}{more_sessions}
2152
+ </ul>
2153
+ </section>"""
2154
+
2155
+ # Build data source HTML
2156
+ data_source_html = f"""
2157
+ <section data-data-source>
2158
+ <h3>Data Source</h3>
2159
+ <dl>
2160
+ <dt>Data Points</dt>
2161
+ <dd>{self.data_points_count}</dd>
2162
+ <dt>Sessions Analyzed</dt>
2163
+ <dd>{len(self.sessions_in_period)}</dd>
2164
+ </dl>
2165
+ </section>"""
2166
+
2167
+ # Build metric-specific attributes
2168
+ import json
2169
+
2170
+ metric_attrs = f' data-metric-type="{self.metric_type}"'
2171
+ metric_attrs += f' data-scope="{self.scope}"'
2172
+ if self.scope_id:
2173
+ metric_attrs += f' data-scope-id="{self.scope_id}"'
2174
+ metric_attrs += f' data-period="{self.period}"'
2175
+ metric_attrs += f' data-trend-direction="{self.trend_direction}"'
2176
+ metric_attrs += f' data-trend-strength="{self.trend_strength:.2f}"'
2177
+ metric_attrs += f' data-data-points="{self.data_points_count}"'
2178
+
2179
+ if self.period_start:
2180
+ metric_attrs += f' data-period-start="{self.period_start.isoformat()}"'
2181
+ if self.period_end:
2182
+ metric_attrs += f' data-period-end="{self.period_end.isoformat()}"'
2183
+
2184
+ if self.metric_values:
2185
+ values_json = json.dumps(self.metric_values)
2186
+ metric_attrs += f" data-values='{values_json}'"
2187
+
2188
+ return f'''<!DOCTYPE html>
2189
+ <html lang="en">
2190
+ <head>
2191
+ <meta charset="UTF-8">
2192
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2193
+ <meta name="htmlgraph-version" content="1.0">
2194
+ <title>{self.title}</title>
2195
+ <link rel="stylesheet" href="{stylesheet_path}">
2196
+ </head>
2197
+ <body>
2198
+ <article id="{self.id}"
2199
+ data-type="{self.type}"
2200
+ data-status="{self.status}"
2201
+ data-priority="{self.priority}"
2202
+ data-created="{self.created.isoformat()}"
2203
+ data-updated="{self.updated.isoformat()}"{metric_attrs}>
2204
+
2205
+ <header>
2206
+ <h1>{self.title}</h1>
2207
+ <div class="metadata">
2208
+ <span class="badge status-{self.status}">{self.status.replace("-", " ").title()}</span>
2209
+ <span class="badge metric-{self.metric_type}">{self.metric_type.replace("_", " ").title()}</span>
2210
+ <span class="badge trend-{self.trend_direction}">{self.trend_direction.title()}</span>
2211
+ </div>
2212
+ </header>
2213
+ {overview_html}{values_html}{percentiles_html}{trend_html}{data_source_html}{sessions_html}
2214
+ </article>
2215
+ </body>
2216
+ </html>
2217
+ '''
2218
+
2219
+
2220
+ class Todo(BaseModel):
2221
+ """
2222
+ A persistent todo item for AI agent task tracking.
2223
+
2224
+ Unlike ephemeral in-context todos (TodoWrite), this model:
2225
+ - Persists to `.htmlgraph/todos/` as HTML files
2226
+ - Links to sessions and features
2227
+ - Enables learning from task patterns across sessions
2228
+ - Provides full audit trail of agent work decomposition
2229
+
2230
+ Matches TodoWrite format with content and activeForm fields.
2231
+ """
2232
+
2233
+ id: str
2234
+ content: str # The imperative form (e.g., "Run tests")
2235
+ active_form: str # The present continuous form (e.g., "Running tests")
2236
+ status: Literal["pending", "in_progress", "completed"] = "pending"
2237
+
2238
+ # Timestamps
2239
+ created: datetime = Field(default_factory=datetime.now)
2240
+ updated: datetime = Field(default_factory=datetime.now)
2241
+ started_at: datetime | None = None
2242
+ completed_at: datetime | None = None
2243
+
2244
+ # Context linking
2245
+ session_id: str | None = None # Session where this todo was created
2246
+ feature_id: str | None = None # Feature this todo belongs to
2247
+ parent_todo_id: str | None = None # For nested/sub-todos
2248
+
2249
+ # Agent tracking
2250
+ agent: str | None = None # Agent that created this todo
2251
+ completed_by: str | None = None # Agent that completed it
2252
+
2253
+ # Metadata
2254
+ priority: int = 0 # Order within a list (0 = first)
2255
+ duration_seconds: float | None = None # How long it took to complete
2256
+
2257
+ def start(self) -> "Todo":
2258
+ """Mark todo as in progress."""
2259
+ self.status = "in_progress"
2260
+ self.started_at = utc_now()
2261
+ self.updated = utc_now()
2262
+ return self
2263
+
2264
+ def complete(self, agent: str | None = None) -> "Todo":
2265
+ """Mark todo as completed."""
2266
+ self.status = "completed"
2267
+ self.completed_at = utc_now()
2268
+ self.completed_by = agent
2269
+ self.updated = utc_now()
2270
+
2271
+ # Calculate duration if started
2272
+ if self.started_at:
2273
+ self.duration_seconds = (
2274
+ self.completed_at - self.started_at
2275
+ ).total_seconds()
2276
+
2277
+ return self
2278
+
2279
+ def to_html(self, stylesheet_path: str = "../styles.css") -> str:
2280
+ """Convert todo to HTML document."""
2281
+ # Status emoji
2282
+ status_emoji = {
2283
+ "pending": "⏳",
2284
+ "in_progress": "🔄",
2285
+ "completed": "✅",
2286
+ }.get(self.status, "⏳")
2287
+
2288
+ # Build attributes
2289
+ # Escape quotes in content for HTML attributes
2290
+ escaped_content = self.content.replace('"', "&quot;")
2291
+ escaped_active_form = self.active_form.replace('"', "&quot;")
2292
+
2293
+ attrs = [
2294
+ f'data-status="{self.status}"',
2295
+ f'data-priority="{self.priority}"',
2296
+ f'data-created="{self.created.isoformat()}"',
2297
+ f'data-updated="{self.updated.isoformat()}"',
2298
+ f'data-todo-content="{escaped_content}"',
2299
+ f'data-todo-active-form="{escaped_active_form}"',
2300
+ ]
2301
+
2302
+ if self.session_id:
2303
+ attrs.append(f'data-session-id="{self.session_id}"')
2304
+ if self.feature_id:
2305
+ attrs.append(f'data-feature-id="{self.feature_id}"')
2306
+ if self.parent_todo_id:
2307
+ attrs.append(f'data-parent-todo-id="{self.parent_todo_id}"')
2308
+ if self.agent:
2309
+ attrs.append(f'data-agent="{self.agent}"')
2310
+ if self.started_at:
2311
+ attrs.append(f'data-started-at="{self.started_at.isoformat()}"')
2312
+ if self.completed_at:
2313
+ attrs.append(f'data-completed-at="{self.completed_at.isoformat()}"')
2314
+ if self.completed_by:
2315
+ attrs.append(f'data-completed-by="{self.completed_by}"')
2316
+ if self.duration_seconds is not None:
2317
+ attrs.append(f'data-duration="{self.duration_seconds:.1f}"')
2318
+
2319
+ attrs_str = " ".join(attrs)
2320
+
2321
+ # Build links section
2322
+ links_html = ""
2323
+ if self.session_id or self.feature_id or self.parent_todo_id:
2324
+ links_section = """
2325
+ <section data-links>
2326
+ <h3>Related</h3>
2327
+ <ul>"""
2328
+ if self.session_id:
2329
+ links_section += f'\n <li><a href="../sessions/{self.session_id}.html">Session: {self.session_id}</a></li>'
2330
+ if self.feature_id:
2331
+ links_section += f'\n <li><a href="../features/{self.feature_id}.html">Feature: {self.feature_id}</a></li>'
2332
+ if self.parent_todo_id:
2333
+ links_section += f'\n <li><a href="{self.parent_todo_id}.html">Parent: {self.parent_todo_id}</a></li>'
2334
+ links_section += """
2335
+ </ul>
2336
+ </section>"""
2337
+ links_html = links_section
2338
+
2339
+ return f'''<!DOCTYPE html>
2340
+ <html lang="en">
2341
+ <head>
2342
+ <meta charset="UTF-8">
2343
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2344
+ <meta name="htmlgraph-version" content="1.0">
2345
+ <title>{status_emoji} {self.content}</title>
2346
+ <link rel="stylesheet" href="{stylesheet_path}">
2347
+ </head>
2348
+ <body>
2349
+ <article id="{self.id}"
2350
+ data-type="todo"
2351
+ {attrs_str}>
2352
+
2353
+ <header>
2354
+ <h1>{status_emoji} {self.content}</h1>
2355
+ <div class="metadata">
2356
+ <span class="badge status-{self.status}">{self.status.replace("_", " ").title()}</span>
2357
+ </div>
2358
+ </header>
2359
+
2360
+ <section data-content>
2361
+ <h3>Task</h3>
2362
+ <p><strong>Content:</strong> {self.content}</p>
2363
+ <p><strong>Active Form:</strong> {self.active_form}</p>
2364
+ </section>
2365
+ {links_html}
2366
+ </article>
2367
+ </body>
2368
+ </html>
2369
+ '''
2370
+
2371
+ def to_context(self) -> str:
2372
+ """Lightweight context for AI agents."""
2373
+ status_marker = {
2374
+ "pending": "[ ]",
2375
+ "in_progress": "[~]",
2376
+ "completed": "[x]",
2377
+ }.get(self.status, "[ ]")
2378
+
2379
+ return f"{status_marker} {self.content}"
2380
+
2381
+ def to_todowrite_format(self) -> dict[str, str]:
2382
+ """Convert to TodoWrite format for compatibility."""
2383
+ return {
2384
+ "content": self.content,
2385
+ "status": self.status,
2386
+ "activeForm": self.active_form,
2387
+ }
2388
+
2389
+ @classmethod
2390
+ def from_todowrite(
2391
+ cls,
2392
+ todo_dict: dict[str, str],
2393
+ todo_id: str,
2394
+ session_id: str | None = None,
2395
+ feature_id: str | None = None,
2396
+ agent: str | None = None,
2397
+ priority: int = 0,
2398
+ ) -> "Todo":
2399
+ """
2400
+ Create a Todo from TodoWrite format.
2401
+
2402
+ Args:
2403
+ todo_dict: Dict with 'content', 'status', 'activeForm' keys
2404
+ todo_id: Unique ID for this todo
2405
+ session_id: Current session ID
2406
+ feature_id: Feature this todo belongs to
2407
+ agent: Agent creating this todo
2408
+ priority: Order in the list
2409
+
2410
+ Returns:
2411
+ Todo instance
2412
+ """
2413
+ status = todo_dict.get("status", "pending")
2414
+ if status not in ("pending", "in_progress", "completed"):
2415
+ status = "pending"
2416
+
2417
+ return cls(
2418
+ id=todo_id,
2419
+ content=todo_dict.get("content", ""),
2420
+ active_form=todo_dict.get("activeForm", todo_dict.get("content", "")),
2421
+ status=status, # type: ignore
2422
+ session_id=session_id,
2423
+ feature_id=feature_id,
2424
+ agent=agent,
2425
+ priority=priority,
2426
+ )