htmlgraph 0.24.2__py3-none-any.whl → 0.25.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. htmlgraph/__init__.py +20 -1
  2. htmlgraph/agent_detection.py +26 -10
  3. htmlgraph/analytics/cross_session.py +4 -3
  4. htmlgraph/analytics/work_type.py +52 -16
  5. htmlgraph/analytics_index.py +51 -19
  6. htmlgraph/api/__init__.py +3 -0
  7. htmlgraph/api/main.py +2115 -0
  8. htmlgraph/api/static/htmx.min.js +1 -0
  9. htmlgraph/api/static/style-redesign.css +1344 -0
  10. htmlgraph/api/static/style.css +1079 -0
  11. htmlgraph/api/templates/dashboard-redesign.html +812 -0
  12. htmlgraph/api/templates/dashboard.html +783 -0
  13. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  14. htmlgraph/api/templates/partials/activity-feed.html +570 -0
  15. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  16. htmlgraph/api/templates/partials/agents.html +317 -0
  17. htmlgraph/api/templates/partials/event-traces.html +373 -0
  18. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  19. htmlgraph/api/templates/partials/features.html +509 -0
  20. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  21. htmlgraph/api/templates/partials/metrics.html +346 -0
  22. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  23. htmlgraph/api/templates/partials/orchestration.html +163 -0
  24. htmlgraph/api/templates/partials/spawners.html +375 -0
  25. htmlgraph/atomic_ops.py +560 -0
  26. htmlgraph/builders/base.py +55 -1
  27. htmlgraph/builders/bug.py +17 -2
  28. htmlgraph/builders/chore.py +17 -2
  29. htmlgraph/builders/epic.py +17 -2
  30. htmlgraph/builders/feature.py +25 -2
  31. htmlgraph/builders/phase.py +17 -2
  32. htmlgraph/builders/spike.py +27 -2
  33. htmlgraph/builders/track.py +14 -0
  34. htmlgraph/cigs/__init__.py +4 -0
  35. htmlgraph/cigs/reporter.py +818 -0
  36. htmlgraph/cli.py +1427 -401
  37. htmlgraph/cli_commands/__init__.py +1 -0
  38. htmlgraph/cli_commands/feature.py +195 -0
  39. htmlgraph/cli_framework.py +115 -0
  40. htmlgraph/collections/__init__.py +2 -0
  41. htmlgraph/collections/base.py +21 -0
  42. htmlgraph/collections/session.py +189 -0
  43. htmlgraph/collections/spike.py +7 -1
  44. htmlgraph/collections/task_delegation.py +236 -0
  45. htmlgraph/collections/traces.py +482 -0
  46. htmlgraph/config.py +113 -0
  47. htmlgraph/converter.py +41 -0
  48. htmlgraph/cost_analysis/__init__.py +5 -0
  49. htmlgraph/cost_analysis/analyzer.py +438 -0
  50. htmlgraph/dashboard.html +3315 -492
  51. htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
  52. htmlgraph/dashboard.html.bak +7181 -0
  53. htmlgraph/dashboard.html.bak2 +7231 -0
  54. htmlgraph/dashboard.html.bak3 +7232 -0
  55. htmlgraph/db/__init__.py +38 -0
  56. htmlgraph/db/queries.py +790 -0
  57. htmlgraph/db/schema.py +1334 -0
  58. htmlgraph/deploy.py +26 -27
  59. htmlgraph/docs/API_REFERENCE.md +841 -0
  60. htmlgraph/docs/HTTP_API.md +750 -0
  61. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  62. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
  63. htmlgraph/docs/README.md +533 -0
  64. htmlgraph/docs/version_check.py +3 -1
  65. htmlgraph/error_handler.py +544 -0
  66. htmlgraph/event_log.py +2 -0
  67. htmlgraph/hooks/__init__.py +8 -0
  68. htmlgraph/hooks/bootstrap.py +169 -0
  69. htmlgraph/hooks/context.py +271 -0
  70. htmlgraph/hooks/drift_handler.py +521 -0
  71. htmlgraph/hooks/event_tracker.py +405 -15
  72. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  73. htmlgraph/hooks/pretooluse.py +476 -6
  74. htmlgraph/hooks/prompt_analyzer.py +648 -0
  75. htmlgraph/hooks/session_handler.py +583 -0
  76. htmlgraph/hooks/state_manager.py +501 -0
  77. htmlgraph/hooks/subagent_stop.py +309 -0
  78. htmlgraph/hooks/task_enforcer.py +39 -0
  79. htmlgraph/models.py +111 -15
  80. htmlgraph/operations/fastapi_server.py +230 -0
  81. htmlgraph/orchestration/headless_spawner.py +22 -14
  82. htmlgraph/pydantic_models.py +476 -0
  83. htmlgraph/quality_gates.py +350 -0
  84. htmlgraph/repo_hash.py +511 -0
  85. htmlgraph/sdk.py +348 -10
  86. htmlgraph/server.py +194 -0
  87. htmlgraph/session_hooks.py +300 -0
  88. htmlgraph/session_manager.py +131 -1
  89. htmlgraph/session_registry.py +587 -0
  90. htmlgraph/session_state.py +436 -0
  91. htmlgraph/system_prompts.py +449 -0
  92. htmlgraph/templates/orchestration-view.html +350 -0
  93. htmlgraph/track_builder.py +19 -0
  94. htmlgraph/validation.py +115 -0
  95. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +7417 -0
  96. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/METADATA +91 -64
  97. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/RECORD +103 -42
  98. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/styles.css +0 -0
  99. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  100. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  101. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  102. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/WHEEL +0 -0
  103. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,309 @@
1
+ """
2
+ SubagentStop Hook - Update parent events when subagents complete.
3
+
4
+ This module handles the SubagentStop hook event, which fires when a subagent
5
+ (spawned via Task()) completes. It updates the parent event with completion
6
+ status and counts child spikes created during the subagent's execution.
7
+
8
+ Architecture:
9
+ - Reads HTMLGRAPH_PARENT_EVENT from environment (set by PreToolUse hook)
10
+ - Queries database for spikes created since parent event start
11
+ - Updates parent event: status="completed", child_spike_count=N
12
+ - Handles graceful degradation if parent event not found
13
+
14
+ Parent-Child Event Nesting:
15
+ - Parent: evt-abc (Task delegation) created by PreToolUse
16
+ - Child events: spikes created by subagent during task execution
17
+ - Result: Full trace of delegation work visible in dashboard
18
+ """
19
+
20
+ import json
21
+ import logging
22
+ import os
23
+ import sqlite3
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def get_parent_event_id() -> str | None:
33
+ """
34
+ Get the parent event ID from environment.
35
+
36
+ Set by PreToolUse hook when Task() is detected.
37
+
38
+ Returns:
39
+ Parent event ID (evt-XXXXX) or None if not found
40
+ """
41
+ return os.environ.get("HTMLGRAPH_PARENT_EVENT")
42
+
43
+
44
+ def get_session_id() -> str | None:
45
+ """
46
+ Get the current session ID from environment.
47
+
48
+ Set by SessionStart hook.
49
+
50
+ Returns:
51
+ Session ID or None if not found
52
+ """
53
+ return os.environ.get("HTMLGRAPH_SESSION_ID")
54
+
55
+
56
+ def count_child_spikes(
57
+ db_path: str, parent_event_id: str, parent_start_time: str
58
+ ) -> int:
59
+ """
60
+ Count spikes created after the parent event started.
61
+
62
+ Queries the features table for spikes with created_at > parent start time.
63
+ Uses a narrow time window (5 minutes) to avoid counting unrelated spikes
64
+ from other sessions.
65
+
66
+ Args:
67
+ db_path: Path to SQLite database
68
+ parent_event_id: Parent event ID
69
+ parent_start_time: ISO8601 timestamp when parent event started
70
+
71
+ Returns:
72
+ Count of child spikes (0 if none found)
73
+ """
74
+ try:
75
+ conn = sqlite3.connect(db_path)
76
+ cursor = conn.cursor()
77
+
78
+ # Validate parent start time format (ISO8601)
79
+ try:
80
+ datetime.fromisoformat(parent_start_time)
81
+ except (ValueError, TypeError):
82
+ # If parsing fails, return 0 (couldn't validate time window)
83
+ logger.warning(f"Could not parse parent start time: {parent_start_time}")
84
+ return 0
85
+
86
+ # Query spikes created within 5 minutes after parent event
87
+ # This avoids counting unrelated spikes from other sessions
88
+ query = """
89
+ SELECT COUNT(*) FROM features
90
+ WHERE type = 'spike'
91
+ AND created_at >= ?
92
+ AND created_at <= datetime(?, '+5 minutes')
93
+ """
94
+
95
+ cursor.execute(query, (parent_start_time, parent_start_time))
96
+ result = cursor.fetchone()
97
+ count = result[0] if result else 0
98
+
99
+ conn.close()
100
+ logger.debug(f"Found {count} child spikes for parent event {parent_event_id}")
101
+ return count
102
+
103
+ except Exception as e:
104
+ logger.warning(f"Error counting child spikes: {e}")
105
+ return 0
106
+
107
+
108
+ def update_parent_event(
109
+ db_path: str,
110
+ parent_event_id: str,
111
+ child_spike_count: int,
112
+ completion_time: str | None = None,
113
+ ) -> bool:
114
+ """
115
+ Update parent event with completion status and child spike count.
116
+
117
+ Updates agent_events table:
118
+ - status: "started" → "completed"
119
+ - child_spike_count: Count of spikes created by subagent
120
+ - output_summary: JSON with completion info
121
+
122
+ Args:
123
+ db_path: Path to SQLite database
124
+ parent_event_id: Parent event ID to update
125
+ child_spike_count: Number of child spikes created
126
+ completion_time: ISO8601 timestamp (optional, defaults to now)
127
+
128
+ Returns:
129
+ True if update successful, False otherwise
130
+ """
131
+ try:
132
+ if completion_time is None:
133
+ completion_time = datetime.now(timezone.utc).isoformat()
134
+
135
+ conn = sqlite3.connect(db_path)
136
+ cursor = conn.cursor()
137
+
138
+ # Build output summary
139
+ output_summary = json.dumps(
140
+ {
141
+ "status": "completed",
142
+ "child_spike_count": child_spike_count,
143
+ "completion_time": completion_time,
144
+ }
145
+ )
146
+
147
+ # Update parent event
148
+ query = """
149
+ UPDATE agent_events
150
+ SET status = ?, child_spike_count = ?, output_summary = ?, updated_at = CURRENT_TIMESTAMP
151
+ WHERE event_id = ?
152
+ """
153
+
154
+ cursor.execute(
155
+ query,
156
+ ("completed", child_spike_count, output_summary, parent_event_id),
157
+ )
158
+
159
+ if cursor.rowcount == 0:
160
+ logger.warning(f"Parent event not found: {parent_event_id}")
161
+ conn.close()
162
+ return False
163
+
164
+ conn.commit()
165
+ conn.close()
166
+
167
+ logger.info(
168
+ f"Updated parent event {parent_event_id}: "
169
+ f"status=completed, child_spike_count={child_spike_count}"
170
+ )
171
+ return True
172
+
173
+ except Exception as e:
174
+ logger.warning(f"Error updating parent event: {e}")
175
+ return False
176
+
177
+
178
+ def get_parent_event_start_time(db_path: str, parent_event_id: str) -> str | None:
179
+ """
180
+ Get the start time of the parent event.
181
+
182
+ Used to set the time window for counting child spikes.
183
+
184
+ Args:
185
+ db_path: Path to SQLite database
186
+ parent_event_id: Parent event ID
187
+
188
+ Returns:
189
+ ISO8601 timestamp or None if not found
190
+ """
191
+ try:
192
+ conn = sqlite3.connect(db_path)
193
+ cursor = conn.cursor()
194
+
195
+ query = "SELECT timestamp FROM agent_events WHERE event_id = ?"
196
+ cursor.execute(query, (parent_event_id,))
197
+ result = cursor.fetchone()
198
+
199
+ conn.close()
200
+ return result[0] if result else None
201
+
202
+ except Exception as e:
203
+ logger.warning(f"Error getting parent event start time: {e}")
204
+ return None
205
+
206
+
207
+ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
208
+ """
209
+ Handle SubagentStop hook event.
210
+
211
+ When a subagent completes, updates the parent event with:
212
+ 1. Completion status
213
+ 2. Count of spikes created during subagent execution
214
+ 3. Completion timestamp
215
+
216
+ This closes the parent-child event trace and enables dashboard visualization
217
+ of the complete delegation hierarchy.
218
+
219
+ Args:
220
+ hook_input: Hook input data from Claude Code
221
+
222
+ Returns:
223
+ Response: {"continue": True} with optional context
224
+ """
225
+ # Get parent event ID from environment
226
+ parent_event_id = get_parent_event_id()
227
+
228
+ if not parent_event_id:
229
+ logger.debug("No parent event ID found, skipping subagent stop tracking")
230
+ return {"continue": True}
231
+
232
+ # Get project directory and database path
233
+ try:
234
+ cwd = hook_input.get("cwd", os.getcwd())
235
+ graph_dir = Path(cwd) / ".htmlgraph"
236
+ db_path = str(graph_dir / "index.sqlite")
237
+
238
+ if not Path(db_path).exists():
239
+ logger.warning(f"Database not found: {db_path}")
240
+ return {"continue": True}
241
+
242
+ except Exception as e:
243
+ logger.warning(f"Error resolving database path: {e}")
244
+ return {"continue": True}
245
+
246
+ # Get parent event start time
247
+ parent_start_time = get_parent_event_start_time(db_path, parent_event_id)
248
+ if not parent_start_time:
249
+ logger.warning(f"Could not find parent event: {parent_event_id}")
250
+ return {"continue": True}
251
+
252
+ # Count child spikes
253
+ child_spike_count = count_child_spikes(db_path, parent_event_id, parent_start_time)
254
+
255
+ # Update parent event with completion info
256
+ completion_time = datetime.now(timezone.utc).isoformat()
257
+ success = update_parent_event(
258
+ db_path,
259
+ parent_event_id,
260
+ child_spike_count,
261
+ completion_time,
262
+ )
263
+
264
+ if success:
265
+ # Clear parent event from environment
266
+ os.environ.pop("HTMLGRAPH_PARENT_EVENT", None)
267
+ os.environ.pop("HTMLGRAPH_SUBAGENT_TYPE", None)
268
+
269
+ logger.info(
270
+ f"Subagent stop recorded: parent_event={parent_event_id}, "
271
+ f"child_spikes={child_spike_count}"
272
+ )
273
+
274
+ return {
275
+ "continue": True,
276
+ "hookSpecificOutput": {
277
+ "hookEventName": "SubagentStop",
278
+ "additionalContext": (
279
+ f"Task delegation completed: {child_spike_count} spike(s) created"
280
+ ),
281
+ },
282
+ }
283
+
284
+ return {"continue": True}
285
+
286
+
287
+ def main() -> None:
288
+ """Hook entry point for script wrapper."""
289
+ # Check if tracking is disabled
290
+ if os.environ.get("HTMLGRAPH_DISABLE_TRACKING") == "1":
291
+ print(json.dumps({"continue": True}))
292
+ sys.exit(0)
293
+
294
+ # Read hook input from stdin
295
+ try:
296
+ hook_input = json.load(sys.stdin)
297
+ except json.JSONDecodeError:
298
+ hook_input = {}
299
+
300
+ # Handle subagent stop
301
+ result = handle_subagent_stop(hook_input)
302
+
303
+ # Output response
304
+ print(json.dumps(result))
305
+ sys.exit(0)
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()
@@ -130,6 +130,45 @@ def enforce_task_saving(tool_name: str, tool_params: dict[str, Any]) -> dict[str
130
130
  parent_session = os.environ.get("HTMLGRAPH_PARENT_SESSION")
131
131
  parent_agent = os.environ.get("HTMLGRAPH_PARENT_AGENT", "claude-code")
132
132
  nesting_depth = int(os.environ.get("HTMLGRAPH_NESTING_DEPTH", "0"))
133
+ current_session = os.environ.get("HTMLGRAPH_SESSION_ID", "")
134
+
135
+ # Record delegation event in database
136
+ try:
137
+ import uuid
138
+
139
+ from htmlgraph.db.schema import HtmlGraphDB
140
+
141
+ db = HtmlGraphDB()
142
+ db.connect()
143
+
144
+ # Extract to_agent from subagent_type (e.g., "haiku" -> "haiku", "gemini-spawner" -> "gemini-spawner")
145
+ to_agent = tool_params.get("subagent_type", "unknown")
146
+ task_description = tool_params.get("description", "Unnamed task")
147
+
148
+ # Determine session ID, using current > parent > auto-generated
149
+ session_id = (
150
+ current_session or parent_session or f"session-{uuid.uuid4().hex[:8]}"
151
+ )
152
+
153
+ # Ensure session exists before recording delegation (handles FK constraints)
154
+ db._ensure_session_exists(session_id, parent_agent)
155
+
156
+ # Record the delegation event
157
+ db.record_delegation_event(
158
+ from_agent=parent_agent,
159
+ to_agent=to_agent,
160
+ task_description=task_description,
161
+ session_id=session_id,
162
+ context={
163
+ "nesting_depth": nesting_depth,
164
+ "prompt_preview": prompt[:200] if prompt else "",
165
+ },
166
+ )
167
+
168
+ db.close()
169
+ except Exception:
170
+ # Graceful degradation - continue even if delegation tracking fails
171
+ pass
133
172
 
134
173
  # Track Task invocation as activity (if parent session exists)
135
174
  task_activity_id = None
htmlgraph/models.py CHANGED
@@ -7,7 +7,7 @@ These models provide:
7
7
  - Lightweight context generation for AI agents
8
8
  """
9
9
 
10
- from datetime import datetime
10
+ from datetime import datetime, timezone
11
11
  from enum import Enum
12
12
  from pathlib import Path
13
13
  from typing import Any, Literal
@@ -15,6 +15,11 @@ from typing import Any, Literal
15
15
  from pydantic import BaseModel, Field
16
16
 
17
17
 
18
+ def utc_now() -> datetime:
19
+ """Return current time as UTC-aware datetime."""
20
+ return datetime.now(timezone.utc)
21
+
22
+
18
23
  class WorkType(str, Enum):
19
24
  """
20
25
  Classification of work/activity type for events and sessions.
@@ -264,15 +269,15 @@ class Node(BaseModel):
264
269
  if edge.relationship not in self.edges:
265
270
  self.edges[edge.relationship] = []
266
271
  self.edges[edge.relationship].append(edge)
267
- self.updated = datetime.now()
272
+ self.updated = utc_now()
268
273
 
269
274
  def complete_step(self, index: int, agent: str | None = None) -> bool:
270
275
  """Mark a step as completed."""
271
276
  if 0 <= index < len(self.steps):
272
277
  self.steps[index].completed = True
273
278
  self.steps[index].agent = agent
274
- self.steps[index].timestamp = datetime.now()
275
- self.updated = datetime.now()
279
+ self.steps[index].timestamp = utc_now()
280
+ self.updated = utc_now()
276
281
  return True
277
282
  return False
278
283
 
@@ -300,7 +305,7 @@ class Node(BaseModel):
300
305
  self.context_tokens_used += tokens_used
301
306
  self.context_peak_tokens = max(self.context_peak_tokens, peak_tokens)
302
307
  self.context_cost_usd += cost_usd
303
- self.updated = datetime.now()
308
+ self.updated = utc_now()
304
309
 
305
310
  def context_stats(self) -> dict:
306
311
  """
@@ -831,9 +836,7 @@ class ContextSnapshot(BaseModel):
831
836
  def from_dict(cls, data: dict) -> "ContextSnapshot":
832
837
  """Create from dictionary."""
833
838
  return cls(
834
- timestamp=datetime.fromisoformat(data["ts"])
835
- if "ts" in data
836
- else datetime.now(),
839
+ timestamp=datetime.fromisoformat(data["ts"]) if "ts" in data else utc_now(),
837
840
  input_tokens=data.get("in", 0),
838
841
  output_tokens=data.get("out", 0),
839
842
  cache_creation_tokens=data.get("cache_create", 0),
@@ -847,6 +850,48 @@ class ContextSnapshot(BaseModel):
847
850
  )
848
851
 
849
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
+
850
895
  class ActivityEntry(BaseModel):
851
896
  """
852
897
  A lightweight activity log entry for high-frequency events.
@@ -975,20 +1020,52 @@ class Session(BaseModel):
975
1020
  }
976
1021
  """
977
1022
 
1023
+ # Error handling (Phase 1B)
1024
+ error_log: list[ErrorEntry] = Field(default_factory=list)
1025
+ """Error records for this session with full tracebacks for debugging."""
1026
+
978
1027
  def add_activity(self, entry: ActivityEntry) -> None:
979
1028
  """Add an activity entry to the log."""
980
1029
  self.activity_log.append(entry)
981
1030
  self.event_count += 1
982
- self.last_activity = datetime.now()
1031
+ self.last_activity = utc_now()
983
1032
 
984
1033
  # Track features worked on
985
1034
  if entry.feature_id and entry.feature_id not in self.worked_on:
986
1035
  self.worked_on.append(entry.feature_id)
987
1036
 
1037
+ def add_error(
1038
+ self,
1039
+ error_type: str,
1040
+ message: str,
1041
+ traceback: str | None = None,
1042
+ tool: str | None = None,
1043
+ context: str | None = None,
1044
+ ) -> None:
1045
+ """
1046
+ Add an error entry to the error log.
1047
+
1048
+ Args:
1049
+ error_type: Exception class name (ValueError, FileNotFoundError, etc.)
1050
+ message: Error message
1051
+ traceback: Full traceback for debugging
1052
+ tool: Tool that caused the error (Edit, Bash, etc.)
1053
+ context: Additional context information
1054
+ """
1055
+ error = ErrorEntry(
1056
+ error_type=error_type,
1057
+ message=message,
1058
+ traceback=traceback,
1059
+ tool=tool,
1060
+ context=context,
1061
+ session_id=self.id,
1062
+ )
1063
+ self.error_log.append(error)
1064
+
988
1065
  def end(self) -> None:
989
1066
  """Mark session as ended."""
990
1067
  self.status = "ended"
991
- self.ended_at = datetime.now()
1068
+ self.ended_at = utc_now()
992
1069
 
993
1070
  def record_context(
994
1071
  self, snapshot: ContextSnapshot, sample_interval: int = 10
@@ -1461,6 +1538,25 @@ class Session(BaseModel):
1461
1538
  </table>
1462
1539
  </section>"""
1463
1540
 
1541
+ # Build error log section
1542
+ error_html = ""
1543
+ if self.error_log:
1544
+ error_items = "\n ".join(
1545
+ error.to_html() for error in self.error_log
1546
+ )
1547
+ error_html = f"""
1548
+ <section data-error-log>
1549
+ <h3>Errors ({len(self.error_log)})</h3>
1550
+ <div class="error-log">
1551
+ {error_items}
1552
+ </div>
1553
+ <style>
1554
+ .error-item {{ margin: 10px 0; padding: 10px; border-left: 3px solid #ff6b6b; }}
1555
+ .error-type {{ font-weight: bold; color: #ff6b6b; }}
1556
+ .traceback {{ background: #f5f5f5; padding: 10px; overflow-x: auto; font-size: 0.9em; margin-top: 5px; }}
1557
+ </style>
1558
+ </section>"""
1559
+
1464
1560
  title = self.title or f"Session {self.id}"
1465
1561
 
1466
1562
  return f'''<!DOCTYPE html>
@@ -1489,7 +1585,7 @@ class Session(BaseModel):
1489
1585
  <span class="badge">{self.event_count} events</span>
1490
1586
  </div>
1491
1587
  </header>
1492
- {edges_html}{handoff_html}{context_html}{patterns_html}{activity_html}
1588
+ {edges_html}{handoff_html}{context_html}{error_html}{patterns_html}{activity_html}
1493
1589
  </article>
1494
1590
  </body>
1495
1591
  </html>
@@ -2141,16 +2237,16 @@ class Todo(BaseModel):
2141
2237
  def start(self) -> "Todo":
2142
2238
  """Mark todo as in progress."""
2143
2239
  self.status = "in_progress"
2144
- self.started_at = datetime.now()
2145
- self.updated = datetime.now()
2240
+ self.started_at = utc_now()
2241
+ self.updated = utc_now()
2146
2242
  return self
2147
2243
 
2148
2244
  def complete(self, agent: str | None = None) -> "Todo":
2149
2245
  """Mark todo as completed."""
2150
2246
  self.status = "completed"
2151
- self.completed_at = datetime.now()
2247
+ self.completed_at = utc_now()
2152
2248
  self.completed_by = agent
2153
- self.updated = datetime.now()
2249
+ self.updated = utc_now()
2154
2250
 
2155
2251
  # Calculate duration if started
2156
2252
  if self.started_at: