htmlgraph 0.24.2__py3-none-any.whl → 0.26.1__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 (112) 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 +2263 -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 +794 -0
  13. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  14. htmlgraph/api/templates/partials/activity-feed.html +1020 -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 +3356 -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 +1584 -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/.htmlgraph/.session-warning-state.json +6 -0
  68. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  69. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  70. htmlgraph/hooks/__init__.py +8 -0
  71. htmlgraph/hooks/bootstrap.py +169 -0
  72. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  73. htmlgraph/hooks/concurrent_sessions.py +208 -0
  74. htmlgraph/hooks/context.py +318 -0
  75. htmlgraph/hooks/drift_handler.py +525 -0
  76. htmlgraph/hooks/event_tracker.py +496 -79
  77. htmlgraph/hooks/orchestrator.py +6 -4
  78. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  79. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  80. htmlgraph/hooks/pretooluse.py +473 -6
  81. htmlgraph/hooks/prompt_analyzer.py +637 -0
  82. htmlgraph/hooks/session_handler.py +637 -0
  83. htmlgraph/hooks/state_manager.py +504 -0
  84. htmlgraph/hooks/subagent_stop.py +309 -0
  85. htmlgraph/hooks/task_enforcer.py +39 -0
  86. htmlgraph/hooks/validator.py +15 -11
  87. htmlgraph/models.py +111 -15
  88. htmlgraph/operations/fastapi_server.py +230 -0
  89. htmlgraph/orchestration/headless_spawner.py +344 -29
  90. htmlgraph/orchestration/live_events.py +377 -0
  91. htmlgraph/pydantic_models.py +476 -0
  92. htmlgraph/quality_gates.py +350 -0
  93. htmlgraph/repo_hash.py +511 -0
  94. htmlgraph/sdk.py +348 -10
  95. htmlgraph/server.py +194 -0
  96. htmlgraph/session_hooks.py +300 -0
  97. htmlgraph/session_manager.py +131 -1
  98. htmlgraph/session_registry.py +587 -0
  99. htmlgraph/session_state.py +436 -0
  100. htmlgraph/system_prompts.py +449 -0
  101. htmlgraph/templates/orchestration-view.html +350 -0
  102. htmlgraph/track_builder.py +19 -0
  103. htmlgraph/validation.py +115 -0
  104. htmlgraph-0.26.1.data/data/htmlgraph/dashboard.html +7458 -0
  105. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +91 -64
  106. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +112 -46
  107. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
  108. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  109. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  110. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  111. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
  112. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/entry_points.txt +0 -0
@@ -2,11 +2,20 @@
2
2
  HtmlGraph Event Tracker Module
3
3
 
4
4
  Reusable event tracking logic for hook integrations.
5
- Provides session management, drift detection, and activity logging.
5
+ Provides session management, drift detection, activity logging, and SQLite persistence.
6
6
 
7
7
  Public API:
8
- track_event(hook_type: str, tool_input: dict) -> dict
8
+ track_event(hook_type: str, tool_input: dict[str, Any]) -> dict
9
9
  Main entry point for tracking hook events (PostToolUse, Stop, UserPromptSubmit)
10
+
11
+ Events are recorded to both:
12
+ - HTML files via SessionManager (existing)
13
+ - SQLite database via HtmlGraphDB (new - for dashboard queries)
14
+
15
+ Parent-child event linking:
16
+ - Database is the single source of truth for parent-child linking
17
+ - UserQuery events are stored in agent_events table with tool_name='UserQuery'
18
+ - get_parent_user_query() queries database for most recent UserQuery in session
10
19
  """
11
20
 
12
21
  import json
@@ -14,19 +23,60 @@ import os
14
23
  import re
15
24
  import subprocess
16
25
  import sys
17
- from datetime import datetime, timedelta
26
+ from datetime import datetime, timedelta, timezone
18
27
  from pathlib import Path
19
- from typing import Any, cast
28
+ from typing import Any, cast # noqa: F401
20
29
 
30
+ from htmlgraph.db.schema import HtmlGraphDB
31
+ from htmlgraph.ids import generate_id
21
32
  from htmlgraph.session_manager import SessionManager
22
33
 
23
34
  # Drift classification queue (stored in session directory)
24
35
  DRIFT_QUEUE_FILE = "drift-queue.json"
25
- # Active parent activity tracker (for Skill/Task invocations)
26
- PARENT_ACTIVITY_FILE = "parent-activity.json"
27
36
 
28
37
 
29
- def load_drift_config() -> dict:
38
+ def get_model_from_status_cache(session_id: str | None = None) -> str | None:
39
+ """
40
+ Read current model from SQLite model_cache table.
41
+
42
+ The status line script writes model info to the model_cache table.
43
+ This allows hooks to know which Claude model is currently running,
44
+ even though hooks don't receive model info directly from Claude Code.
45
+
46
+ Args:
47
+ session_id: Unused, kept for backward compatibility.
48
+
49
+ Returns:
50
+ Model display name (e.g., "Opus 4.5", "Sonnet", "Haiku") or None if not found.
51
+ """
52
+ import sqlite3
53
+
54
+ try:
55
+ # Try project database first
56
+ db_path = Path.cwd() / ".htmlgraph" / "htmlgraph.db"
57
+ if not db_path.exists():
58
+ return None
59
+
60
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
61
+ cursor = conn.cursor()
62
+
63
+ # Check if model_cache table exists and has data
64
+ cursor.execute("SELECT model FROM model_cache WHERE id = 1 LIMIT 1")
65
+ row = cursor.fetchone()
66
+ conn.close()
67
+
68
+ if row and row[0] and row[0] != "Claude":
69
+ return str(row[0])
70
+ return str(row[0]) if row else None
71
+
72
+ except Exception:
73
+ # Table doesn't exist or read error - silently fail
74
+ pass
75
+
76
+ return None
77
+
78
+
79
+ def load_drift_config() -> dict[str, Any]:
30
80
  """Load drift configuration from plugin config or project .claude directory."""
31
81
  config_paths = [
32
82
  Path(__file__).parent.parent.parent.parent.parent
@@ -67,48 +117,46 @@ def load_drift_config() -> dict:
67
117
  }
68
118
 
69
119
 
70
- def load_parent_activity(graph_dir: Path) -> dict:
71
- """Load the active parent activity state."""
72
- path = graph_dir / PARENT_ACTIVITY_FILE
73
- if path.exists():
74
- try:
75
- with open(path) as f:
76
- data = cast(dict[Any, Any], json.load(f))
77
- # Clean up stale parent activities (older than 5 minutes)
78
- if data.get("timestamp"):
79
- ts = datetime.fromisoformat(data["timestamp"])
80
- if datetime.now() - ts > timedelta(minutes=5):
81
- return {}
82
- return data
83
- except Exception:
84
- pass
85
- return {}
120
+ def get_parent_user_query(db: HtmlGraphDB, session_id: str) -> str | None:
121
+ """
122
+ Get the most recent UserQuery event_id for this session from database.
123
+
124
+ This is the primary method for parent-child event linking.
125
+ Database is the single source of truth - no file-based state.
86
126
 
127
+ Args:
128
+ db: HtmlGraphDB instance
129
+ session_id: Session ID to query
87
130
 
88
- def save_parent_activity(
89
- graph_dir: Path, parent_id: str | None, tool: str | None = None
90
- ) -> None:
91
- """Save the active parent activity state."""
92
- path = graph_dir / PARENT_ACTIVITY_FILE
131
+ Returns:
132
+ event_id of the most recent UserQuery event, or None if not found
133
+ """
93
134
  try:
94
- if parent_id:
95
- with open(path, "w") as f:
96
- json.dump(
97
- {
98
- "parent_id": parent_id,
99
- "tool": tool,
100
- "timestamp": datetime.now().isoformat(),
101
- },
102
- f,
103
- )
104
- else:
105
- # Clear parent activity
106
- path.unlink(missing_ok=True)
135
+ if db.connection is None:
136
+ return None
137
+ cursor = db.connection.cursor()
138
+ cursor.execute(
139
+ """
140
+ SELECT event_id FROM agent_events
141
+ WHERE session_id = ? AND tool_name = 'UserQuery'
142
+ ORDER BY timestamp DESC
143
+ LIMIT 1
144
+ """,
145
+ (session_id,),
146
+ )
147
+ row = cursor.fetchone()
148
+ if row:
149
+ return str(row[0])
150
+ return None
107
151
  except Exception as e:
108
- print(f"Warning: Could not save parent activity: {e}", file=sys.stderr)
152
+ print(
153
+ f"Debug: Database query for UserQuery failed: {e}",
154
+ file=sys.stderr,
155
+ )
156
+ return None
109
157
 
110
158
 
111
- def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
159
+ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict[str, Any]:
112
160
  """
113
161
  Load the drift queue from file and clean up stale entries.
114
162
 
@@ -157,7 +205,7 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
157
205
  return {"activities": [], "last_classification": None}
158
206
 
159
207
 
160
- def save_drift_queue(graph_dir: Path, queue: dict) -> None:
208
+ def save_drift_queue(graph_dir: Path, queue: dict[str, Any]) -> None:
161
209
  """Save the drift queue to file."""
162
210
  queue_path = graph_dir / DRIFT_QUEUE_FILE
163
211
  try:
@@ -191,7 +239,9 @@ def clear_drift_queue_activities(graph_dir: Path) -> None:
191
239
  print(f"Warning: Could not clear drift queue: {e}", file=sys.stderr)
192
240
 
193
241
 
194
- def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
242
+ def add_to_drift_queue(
243
+ graph_dir: Path, activity: dict[str, Any], config: dict[str, Any]
244
+ ) -> dict[str, Any]:
195
245
  """Add a high-drift activity to the queue."""
196
246
  max_age_hours = config.get("queue", {}).get("max_age_hours", 48)
197
247
  queue = load_drift_queue(graph_dir, max_age_hours=max_age_hours)
@@ -199,7 +249,7 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
199
249
 
200
250
  queue["activities"].append(
201
251
  {
202
- "timestamp": datetime.now().isoformat(),
252
+ "timestamp": datetime.now(timezone.utc).isoformat(),
203
253
  "tool": activity.get("tool"),
204
254
  "summary": activity.get("summary"),
205
255
  "file_paths": activity.get("file_paths", []),
@@ -214,7 +264,9 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
214
264
  return queue
215
265
 
216
266
 
217
- def should_trigger_classification(queue: dict, config: dict) -> bool:
267
+ def should_trigger_classification(
268
+ queue: dict[str, Any], config: dict[str, Any]
269
+ ) -> bool:
218
270
  """Check if we should trigger auto-classification."""
219
271
  drift_config = config.get("drift_detection", {})
220
272
 
@@ -241,7 +293,7 @@ def should_trigger_classification(queue: dict, config: dict) -> bool:
241
293
  return True
242
294
 
243
295
 
244
- def build_classification_prompt(queue: dict, feature_id: str) -> str:
296
+ def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
245
297
  """Build the prompt for the classification agent."""
246
298
  activities = queue.get("activities", [])
247
299
 
@@ -293,7 +345,108 @@ def resolve_project_path(cwd: str | None = None) -> str:
293
345
  return start_dir
294
346
 
295
347
 
296
- def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
348
+ def detect_model_from_hook_input(hook_input: dict[str, Any]) -> str | None:
349
+ """
350
+ Detect the Claude model from hook input data.
351
+
352
+ Checks in order of priority:
353
+ 1. Task() model parameter (if tool_name == 'Task')
354
+ 2. HTMLGRAPH_MODEL environment variable (set by hooks)
355
+ 3. ANTHROPIC_MODEL or CLAUDE_MODEL environment variables
356
+
357
+ Args:
358
+ hook_input: Hook input dict containing tool_name and tool_input
359
+
360
+ Returns:
361
+ Model name (e.g., 'claude-opus', 'claude-sonnet', 'claude-haiku') or None
362
+ """
363
+ # Get tool info
364
+ tool_name_value: Any = hook_input.get("tool_name", "") or hook_input.get("name", "")
365
+ tool_name = tool_name_value if isinstance(tool_name_value, str) else ""
366
+ tool_input_value: Any = hook_input.get("tool_input", {}) or hook_input.get(
367
+ "input", {}
368
+ )
369
+ tool_input = tool_input_value if isinstance(tool_input_value, dict) else {}
370
+
371
+ # 1. Check for Task() model parameter first
372
+ if tool_name == "Task" and "model" in tool_input:
373
+ model_value: Any = tool_input.get("model")
374
+ if model_value and isinstance(model_value, str):
375
+ model = model_value.strip().lower()
376
+ if model:
377
+ if not model.startswith("claude-"):
378
+ model = f"claude-{model}"
379
+ return cast(str, model)
380
+
381
+ # 2. Check environment variables (set by PreToolUse hook)
382
+ for env_var in ["HTMLGRAPH_MODEL", "ANTHROPIC_MODEL", "CLAUDE_MODEL"]:
383
+ value = os.environ.get(env_var)
384
+ if value and isinstance(value, str):
385
+ model = value.strip()
386
+ if model:
387
+ return model
388
+
389
+ return None
390
+
391
+
392
+ def detect_agent_from_environment() -> tuple[str, str | None]:
393
+ """
394
+ Detect the agent/model name from environment variables and status cache.
395
+
396
+ Checks multiple sources in order of priority:
397
+ 1. HTMLGRAPH_AGENT - Explicit agent name set by user
398
+ 2. HTMLGRAPH_SUBAGENT_TYPE - For subagent sessions
399
+ 3. HTMLGRAPH_PARENT_AGENT - Parent agent context
400
+ 4. HTMLGRAPH_MODEL - Model name (e.g., claude-haiku, claude-opus)
401
+ 5. CLAUDE_MODEL - Model name if exposed by Claude Code
402
+ 6. ANTHROPIC_MODEL - Alternative model env var
403
+ 7. Status line cache (model only) - ~/.cache/claude-code/status-{session_id}.json
404
+
405
+ Falls back to 'claude-code' if no environment variable is set.
406
+
407
+ Returns:
408
+ Tuple of (agent_id, model_name). Model name may be None if not detected.
409
+ """
410
+ # Check for explicit agent name first
411
+ agent_id = None
412
+ env_vars_agent = [
413
+ "HTMLGRAPH_AGENT",
414
+ "HTMLGRAPH_SUBAGENT_TYPE",
415
+ "HTMLGRAPH_PARENT_AGENT",
416
+ ]
417
+
418
+ for var in env_vars_agent:
419
+ value = os.environ.get(var)
420
+ if value and value.strip():
421
+ agent_id = value.strip()
422
+ break
423
+
424
+ # Check for model name separately
425
+ model_name = None
426
+ env_vars_model = [
427
+ "HTMLGRAPH_MODEL",
428
+ "CLAUDE_MODEL",
429
+ "ANTHROPIC_MODEL",
430
+ ]
431
+
432
+ for var in env_vars_model:
433
+ value = os.environ.get(var)
434
+ if value and value.strip():
435
+ model_name = value.strip()
436
+ break
437
+
438
+ # Fallback: Try to read model from status line cache
439
+ if not model_name:
440
+ model_name = get_model_from_status_cache()
441
+
442
+ # Default fallback for agent_id
443
+ if not agent_id:
444
+ agent_id = "claude-code"
445
+
446
+ return agent_id, model_name
447
+
448
+
449
+ def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
297
450
  """Extract file paths from tool input based on tool type."""
298
451
  paths = []
299
452
 
@@ -318,7 +471,7 @@ def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
318
471
 
319
472
 
320
473
  def format_tool_summary(
321
- tool_name: str, tool_input: dict, tool_result: dict | None = None
474
+ tool_name: str, tool_input: dict[str, Any], tool_result: dict | None = None
322
475
  ) -> str:
323
476
  """Format a human-readable summary of the tool call."""
324
477
  if tool_name == "Read":
@@ -366,13 +519,156 @@ def format_tool_summary(
366
519
  url = tool_input.get("url", "")[:40]
367
520
  return f"WebFetch: {url}"
368
521
 
522
+ elif tool_name == "UserQuery":
523
+ # Extract the actual prompt text from the tool_input
524
+ prompt = str(tool_input.get("prompt", ""))
525
+ preview = prompt[:100].replace("\n", " ")
526
+ if len(prompt) > 100:
527
+ preview += "..."
528
+ return preview
529
+
369
530
  else:
370
531
  return f"{tool_name}: {str(tool_input)[:50]}"
371
532
 
372
533
 
373
- def track_event(hook_type: str, hook_input: dict) -> dict:
534
+ def record_event_to_sqlite(
535
+ db: HtmlGraphDB,
536
+ session_id: str,
537
+ tool_name: str,
538
+ tool_input: dict[str, Any],
539
+ tool_response: dict[str, Any],
540
+ is_error: bool,
541
+ file_paths: list[str] | None = None,
542
+ parent_event_id: str | None = None,
543
+ agent_id: str | None = None,
544
+ subagent_type: str | None = None,
545
+ model: str | None = None,
546
+ ) -> str | None:
374
547
  """
375
- Track a hook event and log it to HtmlGraph.
548
+ Record a tool call event to SQLite database for dashboard queries.
549
+
550
+ Args:
551
+ db: HtmlGraphDB instance
552
+ session_id: Session ID from HtmlGraph
553
+ tool_name: Name of the tool called
554
+ tool_input: Tool input parameters
555
+ tool_response: Tool response/result
556
+ is_error: Whether the tool call resulted in an error
557
+ file_paths: File paths affected by the tool
558
+ parent_event_id: Parent event ID if this is a child event
559
+ agent_id: Agent identifier (optional)
560
+ subagent_type: Subagent type for Task delegations (optional)
561
+ model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
562
+
563
+ Returns:
564
+ event_id if successful, None otherwise
565
+ """
566
+ try:
567
+ event_id = generate_id("event")
568
+ input_summary = format_tool_summary(tool_name, tool_input, tool_response)
569
+
570
+ # Build output summary from tool response
571
+ output_summary = ""
572
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
573
+ if is_error:
574
+ output_summary = tool_response.get("error", "error")[:200]
575
+ else:
576
+ # Extract summary from response
577
+ content = tool_response.get("content", tool_response.get("output", ""))
578
+ if isinstance(content, str):
579
+ output_summary = content[:200]
580
+ elif isinstance(content, list):
581
+ output_summary = f"{len(content)} items"
582
+ else:
583
+ output_summary = "success"
584
+
585
+ # Build context metadata
586
+ context = {
587
+ "file_paths": file_paths or [],
588
+ "tool_input_keys": list(tool_input.keys()),
589
+ "is_error": is_error,
590
+ }
591
+
592
+ # Insert event to SQLite
593
+ success = db.insert_event(
594
+ event_id=event_id,
595
+ agent_id=agent_id or "claude-code",
596
+ event_type="tool_call",
597
+ session_id=session_id,
598
+ tool_name=tool_name,
599
+ input_summary=input_summary,
600
+ output_summary=output_summary,
601
+ context=context,
602
+ parent_event_id=parent_event_id,
603
+ cost_tokens=0,
604
+ subagent_type=subagent_type,
605
+ model=model,
606
+ )
607
+
608
+ if success:
609
+ return event_id
610
+ return None
611
+
612
+ except Exception as e:
613
+ print(f"Warning: Could not record event to SQLite: {e}", file=sys.stderr)
614
+ return None
615
+
616
+
617
+ def record_delegation_to_sqlite(
618
+ db: HtmlGraphDB,
619
+ session_id: str,
620
+ from_agent: str,
621
+ to_agent: str,
622
+ task_description: str,
623
+ task_input: dict[str, Any],
624
+ ) -> str | None:
625
+ """
626
+ Record a Task() delegation to agent_collaboration table.
627
+
628
+ Args:
629
+ db: HtmlGraphDB instance
630
+ session_id: Session ID from HtmlGraph
631
+ from_agent: Agent delegating the task (usually 'orchestrator' or 'claude-code')
632
+ to_agent: Target subagent type (e.g., 'general-purpose', 'researcher')
633
+ task_description: Task description/prompt
634
+ task_input: Full task input parameters
635
+
636
+ Returns:
637
+ handoff_id if successful, None otherwise
638
+ """
639
+ try:
640
+ handoff_id = generate_id("handoff")
641
+
642
+ # Build context with task input
643
+ context = {
644
+ "task_input_keys": list(task_input.keys()),
645
+ "model": task_input.get("model"),
646
+ "temperature": task_input.get("temperature"),
647
+ }
648
+
649
+ # Insert delegation record
650
+ success = db.insert_collaboration(
651
+ handoff_id=handoff_id,
652
+ from_agent=from_agent,
653
+ to_agent=to_agent,
654
+ session_id=session_id,
655
+ handoff_type="delegation",
656
+ reason=task_description[:200],
657
+ context=context,
658
+ )
659
+
660
+ if success:
661
+ return handoff_id
662
+ return None
663
+
664
+ except Exception as e:
665
+ print(f"Warning: Could not record delegation to SQLite: {e}", file=sys.stderr)
666
+ return None
667
+
668
+
669
+ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
670
+ """
671
+ Track a hook event and log it to HtmlGraph (both HTML files and SQLite).
376
672
 
377
673
  Args:
378
674
  hook_type: Type of hook event ("PostToolUse", "Stop", "UserPromptSubmit")
@@ -388,13 +684,29 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
388
684
  # Load drift configuration
389
685
  drift_config = load_drift_config()
390
686
 
391
- # Initialize SessionManager
687
+ # Initialize SessionManager and SQLite DB
392
688
  try:
393
689
  manager = SessionManager(graph_dir)
394
690
  except Exception as e:
395
691
  print(f"Warning: Could not initialize SessionManager: {e}", file=sys.stderr)
396
692
  return {"continue": True}
397
693
 
694
+ # Initialize SQLite database for event recording
695
+ db = None
696
+ try:
697
+ db = HtmlGraphDB(str(graph_dir / "index.sqlite"))
698
+ except Exception as e:
699
+ print(f"Warning: Could not initialize SQLite database: {e}", file=sys.stderr)
700
+ # Continue without SQLite (graceful degradation)
701
+
702
+ # Detect agent and model from environment
703
+ detected_agent, detected_model = detect_agent_from_environment()
704
+
705
+ # Also try to detect model from hook input (more specific than environment)
706
+ model_from_input = detect_model_from_hook_input(hook_input)
707
+ if model_from_input:
708
+ detected_model = model_from_input
709
+
398
710
  # Get active session ID
399
711
  active_session = manager.get_active_session()
400
712
  if not active_session:
@@ -402,7 +714,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
402
714
  try:
403
715
  active_session = manager.start_session(
404
716
  session_id=None,
405
- agent="claude-code",
717
+ agent=detected_agent,
406
718
  title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
407
719
  )
408
720
  except Exception:
@@ -410,6 +722,50 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
410
722
 
411
723
  active_session_id = active_session.id
412
724
 
725
+ # Ensure session exists in SQLite database (for foreign key constraints)
726
+ if db:
727
+ try:
728
+ # Get attributes safely - MagicMock objects can cause SQLite binding errors
729
+ # When getattr is called on a MagicMock, it returns another MagicMock, not the default
730
+ def safe_getattr(obj: Any, attr: str, default: Any) -> Any:
731
+ """Get attribute safely, returning default for MagicMock/invalid values."""
732
+ try:
733
+ val = getattr(obj, attr, default)
734
+ # Check if it's a mock object (has _mock_name attribute)
735
+ if hasattr(val, "_mock_name"):
736
+ return default
737
+ return val
738
+ except Exception:
739
+ return default
740
+
741
+ is_subagent_raw = safe_getattr(active_session, "is_subagent", False)
742
+ is_subagent = (
743
+ bool(is_subagent_raw) if isinstance(is_subagent_raw, bool) else False
744
+ )
745
+
746
+ transcript_id = safe_getattr(active_session, "transcript_id", None)
747
+ transcript_path = safe_getattr(active_session, "transcript_path", None)
748
+ # Ensure strings or None, not mock objects
749
+ if transcript_id is not None and not isinstance(transcript_id, str):
750
+ transcript_id = None
751
+ if transcript_path is not None and not isinstance(transcript_path, str):
752
+ transcript_path = None
753
+
754
+ db.insert_session(
755
+ session_id=active_session_id,
756
+ agent_assigned=safe_getattr(active_session, "agent", None)
757
+ or detected_agent,
758
+ is_subagent=is_subagent,
759
+ transcript_id=transcript_id,
760
+ transcript_path=transcript_path,
761
+ )
762
+ except Exception as e:
763
+ # Session may already exist, that's OK - continue
764
+ print(
765
+ f"Debug: Could not insert session to SQLite (may already exist): {e}",
766
+ file=sys.stderr,
767
+ )
768
+
413
769
  # Handle different hook types
414
770
  if hook_type == "Stop":
415
771
  # Session is ending - track stop event
@@ -417,6 +773,19 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
417
773
  manager.track_activity(
418
774
  session_id=active_session_id, tool="Stop", summary="Agent stopped"
419
775
  )
776
+
777
+ # Record to SQLite if available
778
+ if db:
779
+ record_event_to_sqlite(
780
+ db=db,
781
+ session_id=active_session_id,
782
+ tool_name="Stop",
783
+ tool_input={},
784
+ tool_response={"content": "Agent stopped"},
785
+ is_error=False,
786
+ agent_id=detected_agent,
787
+ model=detected_model,
788
+ )
420
789
  except Exception as e:
421
790
  print(f"Warning: Could not track stop: {e}", file=sys.stderr)
422
791
  return {"continue": True}
@@ -432,6 +801,22 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
432
801
  manager.track_activity(
433
802
  session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
434
803
  )
804
+
805
+ # Record to SQLite if available
806
+ # UserQuery event is stored in database - no file-based state needed
807
+ # Subsequent tool calls query database for parent via get_parent_user_query()
808
+ if db:
809
+ record_event_to_sqlite(
810
+ db=db,
811
+ session_id=active_session_id,
812
+ tool_name="UserQuery",
813
+ tool_input={"prompt": prompt},
814
+ tool_response={"content": "Query received"},
815
+ is_error=False,
816
+ agent_id=detected_agent,
817
+ model=detected_model,
818
+ )
819
+
435
820
  except Exception as e:
436
821
  print(f"Warning: Could not track query: {e}", file=sys.stderr)
437
822
  return {"continue": True}
@@ -456,7 +841,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
456
841
  summary = format_tool_summary(tool_name, tool_input_data, tool_response)
457
842
 
458
843
  # Determine success
459
- if isinstance(tool_response, dict):
844
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
460
845
  success_field = tool_response.get("success")
461
846
  if isinstance(success_field, bool):
462
847
  is_error = not success_field
@@ -479,26 +864,23 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
479
864
 
480
865
  # Get drift thresholds from config
481
866
  drift_settings = drift_config.get("drift_detection", {})
482
- warning_threshold = drift_settings.get("warning_threshold", 0.7)
483
- auto_classify_threshold = drift_settings.get("auto_classify_threshold", 0.85)
867
+ warning_threshold = drift_settings.get("warning_threshold") or 0.7
868
+ auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
484
869
 
485
- # Determine parent activity context
486
- parent_activity_state = load_parent_activity(graph_dir)
870
+ # Determine parent activity context using database-only lookup
487
871
  parent_activity_id = None
488
872
 
489
- # Tools that create parent context (Skill, Task)
490
- parent_tools = {"Skill", "Task"}
491
-
492
- # If this is a parent tool invocation, save its context for subsequent activities
493
- if tool_name in parent_tools:
494
- # We'll get the event_id after tracking, so we use a placeholder for now
495
- # The actual parent_id will be set below after we track the activity
496
- is_parent_tool = True
497
- else:
498
- is_parent_tool = False
499
- # Check if there's an active parent context
500
- if parent_activity_state.get("parent_id"):
501
- parent_activity_id = parent_activity_state["parent_id"]
873
+ # Check environment variable FIRST for cross-process parent linking
874
+ # This is set by PreToolUse hook when Task() spawns a subagent
875
+ env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
876
+ "HTMLGRAPH_PARENT_QUERY_EVENT"
877
+ )
878
+ if env_parent:
879
+ parent_activity_id = env_parent
880
+ # Query database for most recent UserQuery event as parent
881
+ # Database is the single source of truth for parent-child linking
882
+ elif db:
883
+ parent_activity_id = get_parent_user_query(db, active_session_id)
502
884
 
503
885
  # Track the activity
504
886
  nudge = None
@@ -512,11 +894,41 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
512
894
  parent_activity_id=parent_activity_id,
513
895
  )
514
896
 
515
- # If this was a parent tool, save its ID for subsequent activities
516
- if is_parent_tool and result:
517
- save_parent_activity(graph_dir, result.id, tool_name)
518
- # If this tool finished a parent context (e.g., Task completed), clear it
519
- # We'll clear parent context after 5 minutes automatically (see load_parent_activity)
897
+ # Record to SQLite if available
898
+ if db:
899
+ # Extract subagent_type for Task delegations
900
+ task_subagent_type = None
901
+ if tool_name == "Task":
902
+ task_subagent_type = tool_input_data.get(
903
+ "subagent_type", "general-purpose"
904
+ )
905
+
906
+ record_event_to_sqlite(
907
+ db=db,
908
+ session_id=active_session_id,
909
+ tool_name=tool_name,
910
+ tool_input=tool_input_data,
911
+ tool_response=tool_response,
912
+ is_error=is_error,
913
+ file_paths=file_paths if file_paths else None,
914
+ parent_event_id=parent_activity_id, # Link to parent event
915
+ agent_id=detected_agent,
916
+ subagent_type=task_subagent_type,
917
+ model=detected_model,
918
+ )
919
+
920
+ # If this was a Task() delegation, also record to agent_collaboration
921
+ if tool_name == "Task" and db:
922
+ subagent = tool_input_data.get("subagent_type", "general-purpose")
923
+ description = tool_input_data.get("description", "")
924
+ record_delegation_to_sqlite(
925
+ db=db,
926
+ session_id=active_session_id,
927
+ from_agent=detected_agent,
928
+ to_agent=subagent,
929
+ task_description=description,
930
+ task_input=tool_input_data,
931
+ )
520
932
 
521
933
  # Check for drift and handle accordingly
522
934
  # Skip drift detection for child activities (they inherit parent's context)
@@ -524,7 +936,10 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
524
936
  drift_score = result.drift_score
525
937
  feature_id = getattr(result, "feature_id", "unknown")
526
938
 
527
- if drift_score and drift_score >= auto_classify_threshold:
939
+ # Skip drift detection if no score available
940
+ if drift_score is None:
941
+ pass # No active features - can't calculate drift
942
+ elif drift_score >= auto_classify_threshold:
528
943
  # High drift - add to classification queue
529
944
  queue = add_to_drift_queue(
530
945
  graph_dir,
@@ -598,7 +1013,9 @@ Task tool with subagent_type="general-purpose", model="haiku", prompt:
598
1013
  Or manually create a work item in .htmlgraph/ (bug, feature, spike, or chore)."""
599
1014
 
600
1015
  # Mark classification as triggered
601
- queue["last_classification"] = datetime.now().isoformat()
1016
+ queue["last_classification"] = datetime.now(
1017
+ timezone.utc
1018
+ ).isoformat()
602
1019
  save_drift_queue(graph_dir, queue)
603
1020
  else:
604
1021
  nudge = f"Drift detected ({drift_score:.2f}): Activity queued for classification ({len(queue['activities'])}/{drift_settings.get('min_activities_before_classify', 3)} needed)."