htmlgraph 0.26.24__py3-none-any.whl → 0.26.25__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 (33) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/main.py +56 -23
  3. htmlgraph/api/templates/dashboard-redesign.html +3 -3
  4. htmlgraph/api/templates/dashboard.html +3 -3
  5. htmlgraph/api/templates/partials/work-items.html +613 -0
  6. htmlgraph/builders/track.py +26 -0
  7. htmlgraph/cli/base.py +31 -7
  8. htmlgraph/cli/work/__init__.py +74 -0
  9. htmlgraph/cli/work/browse.py +114 -0
  10. htmlgraph/cli/work/snapshot.py +558 -0
  11. htmlgraph/collections/base.py +34 -0
  12. htmlgraph/collections/todo.py +12 -0
  13. htmlgraph/converter.py +11 -0
  14. htmlgraph/hooks/orchestrator.py +88 -14
  15. htmlgraph/hooks/session_handler.py +3 -1
  16. htmlgraph/models.py +18 -1
  17. htmlgraph/orchestration/__init__.py +4 -0
  18. htmlgraph/orchestration/plugin_manager.py +1 -2
  19. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  20. htmlgraph/refs.py +343 -0
  21. htmlgraph/sdk.py +71 -1
  22. htmlgraph/session_manager.py +1 -7
  23. htmlgraph/sessions/handoff.py +6 -0
  24. htmlgraph/track_builder.py +12 -0
  25. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/RECORD +33 -28
  27. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/dashboard.html +0 -0
  28. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/styles.css +0 -0
  29. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  30. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  31. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  32. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/WHEEL +0 -0
  33. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/entry_points.txt +0 -0
@@ -94,6 +94,73 @@ def load_tool_history(session_id: str) -> list[dict]:
94
94
  return []
95
95
 
96
96
 
97
+ def record_tool_event(tool_name: str, session_id: str) -> None:
98
+ """
99
+ Record a tool event to the database for history tracking.
100
+
101
+ This is called at the end of PreToolUse hook execution to track
102
+ tool usage patterns for sequence detection.
103
+
104
+ Args:
105
+ tool_name: Name of the tool being called
106
+ session_id: Session identifier for isolation
107
+ """
108
+ try:
109
+ import datetime
110
+ import uuid
111
+
112
+ from htmlgraph.db.schema import HtmlGraphDB
113
+
114
+ # Find database path
115
+ cwd = Path.cwd()
116
+ graph_dir = cwd / ".htmlgraph"
117
+ if not graph_dir.exists():
118
+ for parent in [cwd.parent, cwd.parent.parent, cwd.parent.parent.parent]:
119
+ candidate = parent / ".htmlgraph"
120
+ if candidate.exists():
121
+ graph_dir = candidate
122
+ break
123
+
124
+ if not graph_dir.exists():
125
+ return
126
+
127
+ db_path = graph_dir / "htmlgraph.db"
128
+ db = HtmlGraphDB(str(db_path))
129
+ if db.connection is None:
130
+ return
131
+
132
+ cursor = db.connection.cursor()
133
+ timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
134
+
135
+ # Ensure session exists (required by FK constraint)
136
+ cursor.execute(
137
+ """
138
+ INSERT OR IGNORE INTO sessions (session_id, agent_assigned, created_at, status)
139
+ VALUES (?, ?, ?, ?)
140
+ """,
141
+ (session_id, "orchestrator-hook", timestamp, "active"),
142
+ )
143
+
144
+ # Record the tool event using the actual schema
145
+ # Schema has: event_id, agent_id, event_type, timestamp, tool_name, session_id, etc.
146
+ event_id = str(uuid.uuid4())
147
+ agent_id = "orchestrator-hook" # Identifier for the hook
148
+
149
+ cursor.execute(
150
+ """
151
+ INSERT INTO agent_events (event_id, agent_id, event_type, timestamp, tool_name, session_id)
152
+ VALUES (?, ?, ?, ?, ?, ?)
153
+ """,
154
+ (event_id, agent_id, "tool_call", timestamp, tool_name, session_id),
155
+ )
156
+
157
+ db.connection.commit()
158
+ db.disconnect()
159
+ except Exception:
160
+ # Graceful degradation - don't fail hook on recording error
161
+ pass
162
+
163
+
97
164
  def is_allowed_orchestrator_operation(
98
165
  tool: str, params: dict[str, Any], session_id: str = "unknown"
99
166
  ) -> tuple[bool, str, str]:
@@ -402,6 +469,7 @@ def enforce_orchestrator_mode(
402
469
  # Check if this is a subagent context - subagents have unrestricted tool access
403
470
  if is_subagent_context():
404
471
  return {
472
+ "continue": True,
405
473
  "hookSpecificOutput": {
406
474
  "hookEventName": "PreToolUse",
407
475
  "permissionDecision": "allow",
@@ -425,18 +493,14 @@ def enforce_orchestrator_mode(
425
493
  manager = OrchestratorModeManager(graph_dir)
426
494
 
427
495
  if not manager.is_enabled():
428
- # Mode not active, allow everything
429
- return {
430
- "hookSpecificOutput": {
431
- "hookEventName": "PreToolUse",
432
- "permissionDecision": "allow",
433
- },
434
- }
496
+ # Mode not active, allow everything with no additional output
497
+ return {"continue": True}
435
498
 
436
499
  enforcement_level = manager.get_enforcement_level()
437
500
  except Exception:
438
501
  # If we can't check mode, fail open (allow)
439
502
  return {
503
+ "continue": True,
440
504
  "hookSpecificOutput": {
441
505
  "hookEventName": "PreToolUse",
442
506
  "permissionDecision": "allow",
@@ -467,6 +531,7 @@ def enforce_orchestrator_mode(
467
531
  )
468
532
 
469
533
  return {
534
+ "continue": False,
470
535
  "hookSpecificOutput": {
471
536
  "hookEventName": "PreToolUse",
472
537
  "permissionDecision": "deny",
@@ -491,6 +556,7 @@ def enforce_orchestrator_mode(
491
556
  ):
492
557
  # Provide guidance even when allowing
493
558
  return {
559
+ "continue": True,
494
560
  "hookSpecificOutput": {
495
561
  "hookEventName": "PreToolUse",
496
562
  "permissionDecision": "allow",
@@ -498,6 +564,7 @@ def enforce_orchestrator_mode(
498
564
  },
499
565
  }
500
566
  return {
567
+ "continue": True,
501
568
  "hookSpecificOutput": {
502
569
  "hookEventName": "PreToolUse",
503
570
  "permissionDecision": "allow",
@@ -513,8 +580,8 @@ def enforce_orchestrator_mode(
513
580
  suggestion = create_task_suggestion(tool, params)
514
581
 
515
582
  if enforcement_level == "strict":
516
- # STRICT mode - loud warning with violation count
517
- error_message = (
583
+ # STRICT mode - advisory warning with violation count (does not block)
584
+ warning_message = (
518
585
  f"🚫 ORCHESTRATOR MODE VIOLATION ({violations}/{circuit_breaker_threshold}): {reason}\n\n"
519
586
  f"⚠️ WARNING: Direct operations waste context and break delegation pattern!\n\n"
520
587
  f"Suggested delegation:\n"
@@ -523,23 +590,25 @@ def enforce_orchestrator_mode(
523
590
 
524
591
  # Add circuit breaker warning if approaching threshold
525
592
  if violations >= circuit_breaker_threshold:
526
- error_message += (
593
+ warning_message += (
527
594
  "🚨 CIRCUIT BREAKER TRIGGERED - Further violations will be blocked!\n\n"
528
595
  "Reset with: uv run htmlgraph orchestrator reset-violations\n"
529
596
  )
530
597
  elif violations == circuit_breaker_threshold - 1:
531
- error_message += "⚠️ Next violation will trigger circuit breaker!\n\n"
598
+ warning_message += "⚠️ Next violation will trigger circuit breaker!\n\n"
532
599
 
533
- error_message += (
600
+ warning_message += (
534
601
  "See ORCHESTRATOR_DIRECTIVES in session context for HtmlGraph delegation pattern.\n"
535
602
  "To disable orchestrator mode: uv run htmlgraph orchestrator disable"
536
603
  )
537
604
 
605
+ # Advisory-only: allow operation but provide warning
538
606
  return {
607
+ "continue": True,
539
608
  "hookSpecificOutput": {
540
609
  "hookEventName": "PreToolUse",
541
- "permissionDecision": "deny",
542
- "permissionDecisionReason": error_message,
610
+ "permissionDecision": "allow",
611
+ "additionalContext": warning_message,
543
612
  },
544
613
  }
545
614
  else:
@@ -549,6 +618,7 @@ def enforce_orchestrator_mode(
549
618
  )
550
619
 
551
620
  return {
621
+ "continue": True,
552
622
  "hookSpecificOutput": {
553
623
  "hookEventName": "PreToolUse",
554
624
  "permissionDecision": "allow",
@@ -592,5 +662,9 @@ def main() -> None:
592
662
  # Enforce orchestrator mode with session_id for history lookup
593
663
  response = enforce_orchestrator_mode(tool_name, tool_input, session_id)
594
664
 
665
+ # Record tool event to database for history tracking
666
+ # This allows subsequent calls to detect patterns (e.g., multiple Reads)
667
+ record_tool_event(tool_name, session_id)
668
+
595
669
  # Output JSON response
596
670
  print(json.dumps(response))
@@ -185,7 +185,9 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict[str,
185
185
 
186
186
  {feature_list}
187
187
 
188
- Activity will be attributed to these features based on file patterns and keywords."""
188
+ Activity will be attributed to these features based on file patterns and keywords.
189
+
190
+ **To view all work and progress:** `htmlgraph snapshot --summary`"""
189
191
  output["hookSpecificOutput"]["sessionFeatureContext"] = context_str
190
192
  context.log("info", f"Loaded {len(active_features)} active features")
191
193
 
htmlgraph/models.py CHANGED
@@ -1385,7 +1385,12 @@ class Session(BaseModel):
1385
1385
 
1386
1386
  # Build handoff HTML
1387
1387
  handoff_html = ""
1388
- if self.handoff_notes or self.recommended_next or self.blockers:
1388
+ if (
1389
+ self.handoff_notes
1390
+ or self.recommended_next
1391
+ or self.blockers
1392
+ or self.recommended_context
1393
+ ):
1389
1394
  handoff_section = """
1390
1395
  <section data-handoff>
1391
1396
  <h3>Handoff Context</h3>"""
@@ -1408,6 +1413,18 @@ class Session(BaseModel):
1408
1413
  </ul>
1409
1414
  </div>"""
1410
1415
 
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
+
1411
1428
  handoff_section += "\n </section>"
1412
1429
  handoff_html = handoff_section
1413
1430
 
@@ -9,6 +9,7 @@ from .model_selection import (
9
9
  get_fallback_chain,
10
10
  select_model,
11
11
  )
12
+ from .spawner_event_tracker import SpawnerEventTracker, create_tracker_from_env
12
13
 
13
14
  # Export modular spawners for advanced usage
14
15
  from .spawners import (
@@ -37,6 +38,9 @@ __all__ = [
37
38
  "CodexSpawner",
38
39
  "CopilotSpawner",
39
40
  "ClaudeSpawner",
41
+ # Spawner event tracking
42
+ "SpawnerEventTracker",
43
+ "create_tracker_from_env",
40
44
  # Model selection
41
45
  "ModelSelection",
42
46
  "TaskType",
@@ -22,7 +22,7 @@ class PluginManager:
22
22
  """Get the plugin directory path.
23
23
 
24
24
  Returns:
25
- Path to packages/claude-plugin/.claude-plugin
25
+ Path to packages/claude-plugin (the plugin root, not .claude-plugin)
26
26
  """
27
27
  # Path(__file__) = .../src/python/htmlgraph/orchestration/plugin_manager.py
28
28
  # Need to go up 5 levels to reach project root
@@ -30,7 +30,6 @@ class PluginManager:
30
30
  Path(__file__).parent.parent.parent.parent.parent
31
31
  / "packages"
32
32
  / "claude-plugin"
33
- / ".claude-plugin"
34
33
  )
35
34
 
36
35
  @staticmethod
@@ -0,0 +1,383 @@
1
+ """Spawner event tracking helper for internal activity tracking in spawned sessions.
2
+
3
+ This module provides utilities for tracking internal activities within spawner agents
4
+ and linking them to parent delegation events for observability in HtmlGraph.
5
+
6
+ Usage:
7
+ from htmlgraph.orchestration.spawner_event_tracker import SpawnerEventTracker
8
+
9
+ tracker = SpawnerEventTracker(
10
+ delegation_event_id="event-abc123",
11
+ parent_agent="orchestrator",
12
+ spawner_type="gemini"
13
+ )
14
+
15
+ # Track initialization phase
16
+ init_event = tracker.record_phase("Initializing Spawner", spawned_agent="gemini-2.0-flash")
17
+
18
+ # Track execution phase
19
+ exec_event = tracker.record_phase("Executing Gemini", tool_name="gemini-cli")
20
+ tracker.complete_phase(exec_event["event_id"], output_summary="Generated output...")
21
+
22
+ # Track completion
23
+ tracker.record_completion(success=True, response="Result here")
24
+ """
25
+
26
+ import os
27
+ import time
28
+ import uuid
29
+ from typing import Any
30
+
31
+
32
+ class SpawnerEventTracker:
33
+ """Track internal activities in spawner agents with parent-child linking."""
34
+
35
+ def __init__(
36
+ self,
37
+ delegation_event_id: str | None = None,
38
+ parent_agent: str = "orchestrator",
39
+ spawner_type: str = "generic",
40
+ session_id: str | None = None,
41
+ ):
42
+ """
43
+ Initialize spawner event tracker.
44
+
45
+ Args:
46
+ delegation_event_id: Parent delegation event ID to link to
47
+ parent_agent: Agent that initiated the spawning
48
+ spawner_type: Type of spawner (gemini, codex, copilot)
49
+ session_id: Session ID for events
50
+ """
51
+ self.delegation_event_id = delegation_event_id
52
+ self.parent_agent = parent_agent
53
+ self.spawner_type = spawner_type
54
+ self.session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
55
+ self.db = None
56
+ self.phase_events: dict[str, dict[str, Any]] = {}
57
+ self.start_time = time.time()
58
+
59
+ # Try to initialize database for event tracking
60
+ try:
61
+ from htmlgraph.config import get_database_path
62
+ from htmlgraph.db.schema import HtmlGraphDB
63
+
64
+ # Get correct database path from environment or project root
65
+ db_path = get_database_path()
66
+
67
+ if db_path.exists():
68
+ self.db = HtmlGraphDB(str(db_path))
69
+ except Exception:
70
+ # Tracking is optional, continue without it
71
+ pass
72
+
73
+ def record_phase(
74
+ self,
75
+ phase_name: str,
76
+ spawned_agent: str | None = None,
77
+ tool_name: str | None = None,
78
+ input_summary: str | None = None,
79
+ status: str = "running",
80
+ parent_phase_event_id: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ """
83
+ Record the start of a phase in spawner execution.
84
+
85
+ Args:
86
+ phase_name: Human-readable phase name (e.g., "Initializing Spawner")
87
+ spawned_agent: Agent being spawned (optional)
88
+ tool_name: Tool being executed (optional)
89
+ input_summary: Summary of input (optional)
90
+ status: Current status (running, completed, failed)
91
+ parent_phase_event_id: Parent phase event ID for proper nesting (optional)
92
+
93
+ Returns:
94
+ Event dictionary with event_id and metadata
95
+ """
96
+ if not self.db:
97
+ return {}
98
+
99
+ event_id = f"event-{uuid.uuid4().hex[:8]}"
100
+ event_type = "tool_call"
101
+
102
+ try:
103
+ context = {
104
+ "phase_name": phase_name,
105
+ "spawner_type": self.spawner_type,
106
+ "parent_delegation_event": self.delegation_event_id,
107
+ }
108
+ if spawned_agent:
109
+ context["spawned_agent"] = spawned_agent
110
+ if tool_name:
111
+ context["tool"] = tool_name
112
+
113
+ # Use parent_phase_event_id if provided, otherwise use delegation_event_id
114
+ actual_parent_event_id = parent_phase_event_id or self.delegation_event_id
115
+
116
+ self.db.insert_event(
117
+ event_id=event_id,
118
+ agent_id=spawned_agent or self.parent_agent,
119
+ event_type=event_type,
120
+ session_id=self.session_id,
121
+ tool_name=tool_name
122
+ or f"HeadlessSpawner.{phase_name.replace(' ', '_').lower()}",
123
+ input_summary=input_summary or phase_name,
124
+ context=context,
125
+ parent_event_id=actual_parent_event_id,
126
+ subagent_type=self.spawner_type,
127
+ )
128
+
129
+ event = {
130
+ "event_id": event_id,
131
+ "phase_name": phase_name,
132
+ "spawned_agent": spawned_agent,
133
+ "tool_name": tool_name,
134
+ "status": status,
135
+ "start_time": time.time(),
136
+ }
137
+ self.phase_events[event_id] = event
138
+ return event
139
+
140
+ except Exception:
141
+ # Non-fatal - tracking is best-effort
142
+ return {}
143
+
144
+ def complete_phase(
145
+ self,
146
+ event_id: str,
147
+ output_summary: str | None = None,
148
+ status: str = "completed",
149
+ execution_duration: float | None = None,
150
+ ) -> bool:
151
+ """
152
+ Mark a phase as completed with results.
153
+
154
+ Args:
155
+ event_id: Event ID from record_phase
156
+ output_summary: Summary of output/result
157
+ status: Final status (completed, failed)
158
+ execution_duration: Execution time in seconds (auto-calculated if not provided)
159
+
160
+ Returns:
161
+ True if update successful, False otherwise
162
+ """
163
+ if not self.db or not event_id:
164
+ return False
165
+
166
+ try:
167
+ if execution_duration is None and event_id in self.phase_events:
168
+ execution_duration = (
169
+ time.time() - self.phase_events[event_id]["start_time"]
170
+ )
171
+
172
+ if self.db.connection is None:
173
+ return False
174
+
175
+ cursor = self.db.connection.cursor()
176
+ cursor.execute(
177
+ """
178
+ UPDATE agent_events
179
+ SET output_summary = ?, status = ?, execution_duration_seconds = ?,
180
+ updated_at = CURRENT_TIMESTAMP
181
+ WHERE event_id = ?
182
+ """,
183
+ (output_summary, status, execution_duration or 0.0, event_id),
184
+ )
185
+ self.db.connection.commit()
186
+
187
+ if event_id in self.phase_events:
188
+ self.phase_events[event_id]["status"] = status
189
+ self.phase_events[event_id]["output_summary"] = output_summary
190
+
191
+ return True
192
+ except Exception:
193
+ # Non-fatal
194
+ return False
195
+
196
+ def record_completion(
197
+ self,
198
+ success: bool,
199
+ response: str | None = None,
200
+ error: str | None = None,
201
+ tokens_used: int = 0,
202
+ cost: float = 0.0,
203
+ ) -> dict[str, Any]:
204
+ """
205
+ Record final completion with overall results.
206
+
207
+ Args:
208
+ success: Whether execution succeeded
209
+ response: Successful response/output
210
+ error: Error message if failed
211
+ tokens_used: Tokens consumed
212
+ cost: Execution cost
213
+
214
+ Returns:
215
+ Completion event dictionary
216
+ """
217
+ total_duration = time.time() - self.start_time
218
+
219
+ completion_event: dict[str, Any] = {
220
+ "success": success,
221
+ "duration": total_duration,
222
+ "tokens": tokens_used,
223
+ "cost": cost,
224
+ "phase_count": len(self.phase_events),
225
+ }
226
+
227
+ if success:
228
+ completion_event["response"] = response
229
+ else:
230
+ completion_event["error"] = error
231
+
232
+ return completion_event
233
+
234
+ def get_phase_events(self) -> dict[str, dict[str, Any]]:
235
+ """Get all recorded phase events."""
236
+ return self.phase_events
237
+
238
+ def record_tool_call(
239
+ self,
240
+ tool_name: str,
241
+ tool_input: dict | None,
242
+ phase_event_id: str,
243
+ spawned_agent: str | None = None,
244
+ ) -> dict[str, Any]:
245
+ """
246
+ Record a tool call within a spawned execution phase.
247
+
248
+ Args:
249
+ tool_name: Name of the tool (bash, read_file, write_file, etc.)
250
+ tool_input: Input parameters to the tool
251
+ phase_event_id: Parent phase event ID to link to
252
+ spawned_agent: Agent making the tool call (optional)
253
+
254
+ Returns:
255
+ Event dictionary with event_id and metadata
256
+ """
257
+ if not self.db:
258
+ return {}
259
+
260
+ event_id = f"event-{uuid.uuid4().hex[:8]}"
261
+
262
+ try:
263
+ context = {
264
+ "tool_name": tool_name,
265
+ "spawner_type": self.spawner_type,
266
+ "parent_phase_event": phase_event_id,
267
+ }
268
+ if spawned_agent:
269
+ context["spawned_agent"] = spawned_agent
270
+
271
+ self.db.insert_event(
272
+ event_id=event_id,
273
+ agent_id=spawned_agent or self.parent_agent,
274
+ event_type="tool_call",
275
+ session_id=self.session_id,
276
+ tool_name=tool_name,
277
+ input_summary=(
278
+ str(tool_input)[:200] if tool_input else f"Call to {tool_name}"
279
+ ),
280
+ context=context,
281
+ parent_event_id=phase_event_id,
282
+ subagent_type=self.spawner_type,
283
+ )
284
+
285
+ tool_event = {
286
+ "event_id": event_id,
287
+ "tool_name": tool_name,
288
+ "tool_input": tool_input,
289
+ "phase_event_id": phase_event_id,
290
+ "spawned_agent": spawned_agent,
291
+ "status": "running",
292
+ "start_time": time.time(),
293
+ }
294
+ return tool_event
295
+
296
+ except Exception:
297
+ # Non-fatal - tracking is best-effort
298
+ return {}
299
+
300
+ def complete_tool_call(
301
+ self,
302
+ event_id: str,
303
+ output_summary: str | None = None,
304
+ success: bool = True,
305
+ ) -> bool:
306
+ """
307
+ Mark a tool call as complete with results.
308
+
309
+ Args:
310
+ event_id: Event ID from record_tool_call
311
+ output_summary: Summary of tool output/result
312
+ success: Whether the tool call succeeded
313
+
314
+ Returns:
315
+ True if update successful, False otherwise
316
+ """
317
+ if not self.db or not event_id:
318
+ return False
319
+
320
+ try:
321
+ if self.db.connection is None:
322
+ return False
323
+
324
+ cursor = self.db.connection.cursor()
325
+ cursor.execute(
326
+ """
327
+ UPDATE agent_events
328
+ SET output_summary = ?, status = ?, updated_at = CURRENT_TIMESTAMP
329
+ WHERE event_id = ?
330
+ """,
331
+ (
332
+ output_summary,
333
+ "completed" if success else "failed",
334
+ event_id,
335
+ ),
336
+ )
337
+ self.db.connection.commit()
338
+ return True
339
+ except Exception:
340
+ # Non-fatal
341
+ return False
342
+
343
+ def get_event_hierarchy(self) -> dict[str, Any]:
344
+ """
345
+ Get the event hierarchy for this spawner execution.
346
+
347
+ Returns:
348
+ Dictionary with delegation event as root and phases as children
349
+ """
350
+ return {
351
+ "delegation_event_id": self.delegation_event_id,
352
+ "spawner_type": self.spawner_type,
353
+ "session_id": self.session_id,
354
+ "total_duration": time.time() - self.start_time,
355
+ "phase_events": self.phase_events,
356
+ }
357
+
358
+
359
+ def create_tracker_from_env(
360
+ spawner_type: str = "generic",
361
+ ) -> SpawnerEventTracker:
362
+ """
363
+ Create a SpawnerEventTracker from environment variables.
364
+
365
+ Reads HTMLGRAPH_PARENT_EVENT, HTMLGRAPH_PARENT_AGENT, HTMLGRAPH_PARENT_SESSION
366
+ from environment to link to parent context.
367
+
368
+ Args:
369
+ spawner_type: Type of spawner (gemini, codex, copilot)
370
+
371
+ Returns:
372
+ Initialized SpawnerEventTracker
373
+ """
374
+ delegation_event_id = os.getenv("HTMLGRAPH_PARENT_EVENT")
375
+ parent_agent = os.getenv("HTMLGRAPH_PARENT_AGENT", "orchestrator")
376
+ session_id = os.getenv("HTMLGRAPH_PARENT_SESSION")
377
+
378
+ return SpawnerEventTracker(
379
+ delegation_event_id=delegation_event_id,
380
+ parent_agent=parent_agent,
381
+ spawner_type=spawner_type,
382
+ session_id=session_id,
383
+ )