htmlgraph 0.20.1__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 (304) 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 +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
@@ -1,32 +1,85 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger(__name__)
4
+
1
5
  """
2
6
  HtmlGraph Event Tracker Module
3
7
 
4
8
  Reusable event tracking logic for hook integrations.
5
- Provides session management, drift detection, and activity logging.
9
+ Provides session management, drift detection, activity logging, and SQLite persistence.
6
10
 
7
11
  Public API:
8
- track_event(hook_type: str, tool_input: dict) -> dict
12
+ track_event(hook_type: str, tool_input: dict[str, Any]) -> dict
9
13
  Main entry point for tracking hook events (PostToolUse, Stop, UserPromptSubmit)
14
+
15
+ Events are recorded to both:
16
+ - HTML files via SessionManager (existing)
17
+ - SQLite database via HtmlGraphDB (new - for dashboard queries)
18
+
19
+ Parent-child event linking:
20
+ - Database is the single source of truth for parent-child linking
21
+ - UserQuery events are stored in agent_events table with tool_name='UserQuery'
22
+ - get_parent_user_query() queries database for most recent UserQuery in session
10
23
  """
11
24
 
12
25
  import json
13
26
  import os
14
27
  import re
15
28
  import subprocess
16
- import sys
17
- from datetime import datetime, timedelta
29
+ from datetime import datetime, timedelta, timezone
18
30
  from pathlib import Path
19
- from typing import Any, cast
31
+ from typing import Any, cast # noqa: F401
20
32
 
33
+ from htmlgraph.db.schema import HtmlGraphDB
34
+ from htmlgraph.ids import generate_id
21
35
  from htmlgraph.session_manager import SessionManager
22
36
 
23
37
  # Drift classification queue (stored in session directory)
24
38
  DRIFT_QUEUE_FILE = "drift-queue.json"
25
- # Active parent activity tracker (for Skill/Task invocations)
26
- PARENT_ACTIVITY_FILE = "parent-activity.json"
27
39
 
28
40
 
29
- def load_drift_config() -> dict:
41
+ def get_model_from_status_cache(session_id: str | None = None) -> str | None:
42
+ """
43
+ Read current model from SQLite model_cache table.
44
+
45
+ The status line script writes model info to the model_cache table.
46
+ This allows hooks to know which Claude model is currently running,
47
+ even though hooks don't receive model info directly from Claude Code.
48
+
49
+ Args:
50
+ session_id: Unused, kept for backward compatibility.
51
+
52
+ Returns:
53
+ Model display name (e.g., "Opus 4.5", "Sonnet", "Haiku") or None if not found.
54
+ """
55
+ import sqlite3
56
+
57
+ try:
58
+ # Try project database first
59
+ db_path = Path.cwd() / ".htmlgraph" / "htmlgraph.db"
60
+ if not db_path.exists():
61
+ return None
62
+
63
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
64
+ cursor = conn.cursor()
65
+
66
+ # Check if model_cache table exists and has data
67
+ cursor.execute("SELECT model FROM model_cache WHERE id = 1 LIMIT 1")
68
+ row = cursor.fetchone()
69
+ conn.close()
70
+
71
+ if row and row[0] and row[0] != "Claude":
72
+ return str(row[0])
73
+ return str(row[0]) if row else None
74
+
75
+ except Exception:
76
+ # Table doesn't exist or read error - silently fail
77
+ pass
78
+
79
+ return None
80
+
81
+
82
+ def load_drift_config() -> dict[str, Any]:
30
83
  """Load drift configuration from plugin config or project .claude directory."""
31
84
  config_paths = [
32
85
  Path(__file__).parent.parent.parent.parent.parent
@@ -67,48 +120,43 @@ def load_drift_config() -> dict:
67
120
  }
68
121
 
69
122
 
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 {}
123
+ def get_parent_user_query(db: HtmlGraphDB, session_id: str) -> str | None:
124
+ """
125
+ Get the most recent UserQuery event_id for this session from database.
126
+
127
+ This is the primary method for parent-child event linking.
128
+ Database is the single source of truth - no file-based state.
86
129
 
130
+ Args:
131
+ db: HtmlGraphDB instance
132
+ session_id: Session ID to query
87
133
 
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
134
+ Returns:
135
+ event_id of the most recent UserQuery event, or None if not found
136
+ """
93
137
  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)
138
+ if db.connection is None:
139
+ return None
140
+ cursor = db.connection.cursor()
141
+ cursor.execute(
142
+ """
143
+ SELECT event_id FROM agent_events
144
+ WHERE session_id = ? AND tool_name = 'UserQuery'
145
+ ORDER BY timestamp DESC
146
+ LIMIT 1
147
+ """,
148
+ (session_id,),
149
+ )
150
+ row = cursor.fetchone()
151
+ if row:
152
+ return str(row[0])
153
+ return None
107
154
  except Exception as e:
108
- print(f"Warning: Could not save parent activity: {e}", file=sys.stderr)
155
+ logger.warning(f"Debug: Database query for UserQuery failed: {e}")
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
 
@@ -146,9 +194,8 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
146
194
  queue["activities"] = fresh_activities
147
195
  save_drift_queue(graph_dir, queue)
148
196
  removed = original_count - len(fresh_activities)
149
- print(
150
- f"Cleaned {removed} stale drift queue entries (older than {max_age_hours}h)",
151
- file=sys.stderr,
197
+ logger.warning(
198
+ f"Cleaned {removed} stale drift queue entries (older than {max_age_hours}h)"
152
199
  )
153
200
 
154
201
  return cast(dict[Any, Any], queue)
@@ -157,14 +204,14 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
157
204
  return {"activities": [], "last_classification": None}
158
205
 
159
206
 
160
- def save_drift_queue(graph_dir: Path, queue: dict) -> None:
207
+ def save_drift_queue(graph_dir: Path, queue: dict[str, Any]) -> None:
161
208
  """Save the drift queue to file."""
162
209
  queue_path = graph_dir / DRIFT_QUEUE_FILE
163
210
  try:
164
211
  with open(queue_path, "w") as f:
165
212
  json.dump(queue, f, indent=2, default=str)
166
213
  except Exception as e:
167
- print(f"Warning: Could not save drift queue: {e}", file=sys.stderr)
214
+ logger.warning(f"Warning: Could not save drift queue: {e}")
168
215
 
169
216
 
170
217
  def clear_drift_queue_activities(graph_dir: Path) -> None:
@@ -188,10 +235,12 @@ def clear_drift_queue_activities(graph_dir: Path) -> None:
188
235
  with open(queue_path, "w") as f:
189
236
  json.dump(queue, f, indent=2)
190
237
  except Exception as e:
191
- print(f"Warning: Could not clear drift queue: {e}", file=sys.stderr)
238
+ logger.warning(f"Warning: Could not clear drift queue: {e}")
192
239
 
193
240
 
194
- def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
241
+ def add_to_drift_queue(
242
+ graph_dir: Path, activity: dict[str, Any], config: dict[str, Any]
243
+ ) -> dict[str, Any]:
195
244
  """Add a high-drift activity to the queue."""
196
245
  max_age_hours = config.get("queue", {}).get("max_age_hours", 48)
197
246
  queue = load_drift_queue(graph_dir, max_age_hours=max_age_hours)
@@ -199,7 +248,7 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
199
248
 
200
249
  queue["activities"].append(
201
250
  {
202
- "timestamp": datetime.now().isoformat(),
251
+ "timestamp": datetime.now(timezone.utc).isoformat(),
203
252
  "tool": activity.get("tool"),
204
253
  "summary": activity.get("summary"),
205
254
  "file_paths": activity.get("file_paths", []),
@@ -214,7 +263,9 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
214
263
  return queue
215
264
 
216
265
 
217
- def should_trigger_classification(queue: dict, config: dict) -> bool:
266
+ def should_trigger_classification(
267
+ queue: dict[str, Any], config: dict[str, Any]
268
+ ) -> bool:
218
269
  """Check if we should trigger auto-classification."""
219
270
  drift_config = config.get("drift_detection", {})
220
271
 
@@ -241,7 +292,7 @@ def should_trigger_classification(queue: dict, config: dict) -> bool:
241
292
  return True
242
293
 
243
294
 
244
- def build_classification_prompt(queue: dict, feature_id: str) -> str:
295
+ def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
245
296
  """Build the prompt for the classification agent."""
246
297
  activities = queue.get("activities", [])
247
298
 
@@ -293,7 +344,108 @@ def resolve_project_path(cwd: str | None = None) -> str:
293
344
  return start_dir
294
345
 
295
346
 
296
- def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
347
+ def detect_model_from_hook_input(hook_input: dict[str, Any]) -> str | None:
348
+ """
349
+ Detect the Claude model from hook input data.
350
+
351
+ Checks in order of priority:
352
+ 1. Task() model parameter (if tool_name == 'Task')
353
+ 2. HTMLGRAPH_MODEL environment variable (set by hooks)
354
+ 3. ANTHROPIC_MODEL or CLAUDE_MODEL environment variables
355
+
356
+ Args:
357
+ hook_input: Hook input dict containing tool_name and tool_input
358
+
359
+ Returns:
360
+ Model name (e.g., 'claude-opus', 'claude-sonnet', 'claude-haiku') or None
361
+ """
362
+ # Get tool info
363
+ tool_name_value: Any = hook_input.get("tool_name", "") or hook_input.get("name", "")
364
+ tool_name = tool_name_value if isinstance(tool_name_value, str) else ""
365
+ tool_input_value: Any = hook_input.get("tool_input", {}) or hook_input.get(
366
+ "input", {}
367
+ )
368
+ tool_input = tool_input_value if isinstance(tool_input_value, dict) else {}
369
+
370
+ # 1. Check for Task() model parameter first
371
+ if tool_name == "Task" and "model" in tool_input:
372
+ model_value: Any = tool_input.get("model")
373
+ if model_value and isinstance(model_value, str):
374
+ model = model_value.strip().lower()
375
+ if model:
376
+ if not model.startswith("claude-"):
377
+ model = f"claude-{model}"
378
+ return cast(str, model)
379
+
380
+ # 2. Check environment variables (set by PreToolUse hook)
381
+ for env_var in ["HTMLGRAPH_MODEL", "ANTHROPIC_MODEL", "CLAUDE_MODEL"]:
382
+ value = os.environ.get(env_var)
383
+ if value and isinstance(value, str):
384
+ model = value.strip()
385
+ if model:
386
+ return model
387
+
388
+ return None
389
+
390
+
391
+ def detect_agent_from_environment() -> tuple[str, str | None]:
392
+ """
393
+ Detect the agent/model name from environment variables and status cache.
394
+
395
+ Checks multiple sources in order of priority:
396
+ 1. HTMLGRAPH_AGENT - Explicit agent name set by user
397
+ 2. HTMLGRAPH_SUBAGENT_TYPE - For subagent sessions
398
+ 3. HTMLGRAPH_PARENT_AGENT - Parent agent context
399
+ 4. HTMLGRAPH_MODEL - Model name (e.g., claude-haiku, claude-opus)
400
+ 5. CLAUDE_MODEL - Model name if exposed by Claude Code
401
+ 6. ANTHROPIC_MODEL - Alternative model env var
402
+ 7. Status line cache (model only) - ~/.cache/claude-code/status-{session_id}.json
403
+
404
+ Falls back to 'claude-code' if no environment variable is set.
405
+
406
+ Returns:
407
+ Tuple of (agent_id, model_name). Model name may be None if not detected.
408
+ """
409
+ # Check for explicit agent name first
410
+ agent_id = None
411
+ env_vars_agent = [
412
+ "HTMLGRAPH_AGENT",
413
+ "HTMLGRAPH_SUBAGENT_TYPE",
414
+ "HTMLGRAPH_PARENT_AGENT",
415
+ ]
416
+
417
+ for var in env_vars_agent:
418
+ value = os.environ.get(var)
419
+ if value and value.strip():
420
+ agent_id = value.strip()
421
+ break
422
+
423
+ # Check for model name separately
424
+ model_name = None
425
+ env_vars_model = [
426
+ "HTMLGRAPH_MODEL",
427
+ "CLAUDE_MODEL",
428
+ "ANTHROPIC_MODEL",
429
+ ]
430
+
431
+ for var in env_vars_model:
432
+ value = os.environ.get(var)
433
+ if value and value.strip():
434
+ model_name = value.strip()
435
+ break
436
+
437
+ # Fallback: Try to read model from status line cache
438
+ if not model_name:
439
+ model_name = get_model_from_status_cache()
440
+
441
+ # Default fallback for agent_id
442
+ if not agent_id:
443
+ agent_id = "claude-code"
444
+
445
+ return agent_id, model_name
446
+
447
+
448
+ def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
297
449
  """Extract file paths from tool input based on tool type."""
298
450
  paths = []
299
451
 
@@ -318,7 +470,7 @@ def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
318
470
 
319
471
 
320
472
  def format_tool_summary(
321
- tool_name: str, tool_input: dict, tool_result: dict | None = None
473
+ tool_name: str, tool_input: dict[str, Any], tool_result: dict | None = None
322
474
  ) -> str:
323
475
  """Format a human-readable summary of the tool call."""
324
476
  if tool_name == "Read":
@@ -366,13 +518,170 @@ def format_tool_summary(
366
518
  url = tool_input.get("url", "")[:40]
367
519
  return f"WebFetch: {url}"
368
520
 
521
+ elif tool_name == "UserQuery":
522
+ # Extract the actual prompt text from the tool_input
523
+ prompt = str(tool_input.get("prompt", ""))
524
+ preview = prompt[:100].replace("\n", " ")
525
+ if len(prompt) > 100:
526
+ preview += "..."
527
+ return preview
528
+
369
529
  else:
370
530
  return f"{tool_name}: {str(tool_input)[:50]}"
371
531
 
372
532
 
373
- def track_event(hook_type: str, hook_input: dict) -> dict:
533
+ def record_event_to_sqlite(
534
+ db: HtmlGraphDB,
535
+ session_id: str,
536
+ tool_name: str,
537
+ tool_input: dict[str, Any],
538
+ tool_response: dict[str, Any],
539
+ is_error: bool,
540
+ file_paths: list[str] | None = None,
541
+ parent_event_id: str | None = None,
542
+ agent_id: str | None = None,
543
+ subagent_type: str | None = None,
544
+ model: str | None = None,
545
+ feature_id: str | None = None,
546
+ claude_task_id: str | None = None,
547
+ ) -> str | None:
548
+ """
549
+ Record a tool call event to SQLite database for dashboard queries.
550
+
551
+ Args:
552
+ db: HtmlGraphDB instance
553
+ session_id: Session ID from HtmlGraph
554
+ tool_name: Name of the tool called
555
+ tool_input: Tool input parameters
556
+ tool_response: Tool response/result
557
+ is_error: Whether the tool call resulted in an error
558
+ file_paths: File paths affected by the tool
559
+ parent_event_id: Parent event ID if this is a child event
560
+ agent_id: Agent identifier (optional)
561
+ subagent_type: Subagent type for Task delegations (optional)
562
+ model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
563
+ feature_id: Feature ID for attribution (optional)
564
+ claude_task_id: Claude Code's internal task ID for tool attribution (optional)
565
+
566
+ Returns:
567
+ event_id if successful, None otherwise
568
+ """
569
+ try:
570
+ event_id = generate_id("event")
571
+ input_summary = format_tool_summary(tool_name, tool_input, tool_response)
572
+
573
+ # Build output summary from tool response
574
+ output_summary = ""
575
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
576
+ if is_error:
577
+ output_summary = tool_response.get("error", "error")[:200]
578
+ else:
579
+ # Extract summary from response
580
+ content = tool_response.get("content", tool_response.get("output", ""))
581
+ if isinstance(content, str):
582
+ output_summary = content[:200]
583
+ elif isinstance(content, list):
584
+ output_summary = f"{len(content)} items"
585
+ else:
586
+ output_summary = "success"
587
+
588
+ # Build context metadata
589
+ context = {
590
+ "file_paths": file_paths or [],
591
+ "tool_input_keys": list(tool_input.keys()),
592
+ "is_error": is_error,
593
+ }
594
+
595
+ # Extract task_id from Tool response if not provided
596
+ if (
597
+ not claude_task_id
598
+ and tool_name == "Task"
599
+ and isinstance(tool_response, dict)
600
+ ):
601
+ claude_task_id = tool_response.get("task_id")
602
+
603
+ # Insert event to SQLite
604
+ success = db.insert_event(
605
+ event_id=event_id,
606
+ agent_id=agent_id or "claude-code",
607
+ event_type="tool_call",
608
+ session_id=session_id,
609
+ tool_name=tool_name,
610
+ input_summary=input_summary,
611
+ output_summary=output_summary,
612
+ context=context,
613
+ parent_event_id=parent_event_id,
614
+ cost_tokens=0,
615
+ subagent_type=subagent_type,
616
+ model=model,
617
+ feature_id=feature_id,
618
+ claude_task_id=claude_task_id,
619
+ )
620
+
621
+ if success:
622
+ return event_id
623
+ return None
624
+
625
+ except Exception as e:
626
+ logger.warning(f"Warning: Could not record event to SQLite: {e}")
627
+ return None
628
+
629
+
630
+ def record_delegation_to_sqlite(
631
+ db: HtmlGraphDB,
632
+ session_id: str,
633
+ from_agent: str,
634
+ to_agent: str,
635
+ task_description: str,
636
+ task_input: dict[str, Any],
637
+ ) -> str | None:
638
+ """
639
+ Record a Task() delegation to agent_collaboration table.
640
+
641
+ Args:
642
+ db: HtmlGraphDB instance
643
+ session_id: Session ID from HtmlGraph
644
+ from_agent: Agent delegating the task (usually 'orchestrator' or 'claude-code')
645
+ to_agent: Target subagent type (e.g., 'general-purpose', 'researcher')
646
+ task_description: Task description/prompt
647
+ task_input: Full task input parameters
648
+
649
+ Returns:
650
+ handoff_id if successful, None otherwise
651
+ """
652
+ try:
653
+ handoff_id = generate_id("handoff")
654
+
655
+ # Build context with task input
656
+ context = {
657
+ "task_input_keys": list(task_input.keys()),
658
+ "model": task_input.get("model"),
659
+ "temperature": task_input.get("temperature"),
660
+ }
661
+
662
+ # Insert delegation record
663
+ success = db.insert_collaboration(
664
+ handoff_id=handoff_id,
665
+ from_agent=from_agent,
666
+ to_agent=to_agent,
667
+ session_id=session_id,
668
+ handoff_type="delegation",
669
+ reason=task_description[:200],
670
+ context=context,
671
+ )
672
+
673
+ if success:
674
+ return handoff_id
675
+ return None
676
+
677
+ except Exception as e:
678
+ logger.warning(f"Warning: Could not record delegation to SQLite: {e}")
679
+ return None
680
+
681
+
682
+ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
374
683
  """
375
- Track a hook event and log it to HtmlGraph.
684
+ Track a hook event and log it to HtmlGraph (both HTML files and SQLite).
376
685
 
377
686
  Args:
378
687
  hook_type: Type of hook event ("PostToolUse", "Stop", "UserPromptSubmit")
@@ -388,37 +697,322 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
388
697
  # Load drift configuration
389
698
  drift_config = load_drift_config()
390
699
 
391
- # Initialize SessionManager
700
+ # Initialize SessionManager and SQLite DB
392
701
  try:
393
702
  manager = SessionManager(graph_dir)
394
703
  except Exception as e:
395
- print(f"Warning: Could not initialize SessionManager: {e}", file=sys.stderr)
704
+ logger.warning(f"Warning: Could not initialize SessionManager: {e}")
396
705
  return {"continue": True}
397
706
 
398
- # Get active session ID
399
- active_session = manager.get_active_session()
400
- if not active_session:
401
- # No active HtmlGraph session yet; start one (stable internal id).
707
+ # Initialize SQLite database for event recording
708
+ db = None
709
+ try:
710
+ from htmlgraph.config import get_database_path
711
+ from htmlgraph.db.schema import HtmlGraphDB
712
+
713
+ db = HtmlGraphDB(str(get_database_path()))
714
+ except Exception as e:
715
+ logger.warning(f"Warning: Could not initialize SQLite database: {e}")
716
+ # Continue without SQLite (graceful degradation)
717
+
718
+ # Detect agent and model from environment
719
+ detected_agent, detected_model = detect_agent_from_environment()
720
+
721
+ # Also try to detect model from hook input (more specific than environment)
722
+ model_from_input = detect_model_from_hook_input(hook_input)
723
+ if model_from_input:
724
+ detected_model = model_from_input
725
+
726
+ active_session = None
727
+
728
+ # Check if we're in a subagent context using multiple methods:
729
+ #
730
+ # PRECEDENCE ORDER:
731
+ # 1. Sessions table - if THIS session is already marked as subagent, use stored parent info
732
+ # (fixes persistence issue for subsequent tool calls in same subagent)
733
+ # 2. Environment variables - set by spawner router for first tool call
734
+ # 3. Fallback to normal orchestrator context
735
+ #
736
+ # Method 1: Check if current session is already a subagent (CRITICAL for persistence!)
737
+ # This fixes the issue where subsequent tool calls in the same subagent session
738
+ # lose the parent_event_id linkage.
739
+ subagent_type = None
740
+ parent_session_id = None
741
+ task_event_id_from_db = None # Will be set by Method 1 if found
742
+ hook_session_id = hook_input.get("session_id") or hook_input.get("sessionId")
743
+
744
+ if db and db.connection and hook_session_id:
402
745
  try:
403
- active_session = manager.start_session(
404
- session_id=None,
405
- agent="claude-code",
406
- title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
746
+ cursor = db.connection.cursor()
747
+ cursor.execute(
748
+ """
749
+ SELECT parent_session_id, agent_assigned
750
+ FROM sessions
751
+ WHERE session_id = ? AND is_subagent = 1
752
+ LIMIT 1
753
+ """,
754
+ (hook_session_id,),
407
755
  )
408
- except Exception:
409
- return {"continue": True}
756
+ row = cursor.fetchone()
757
+ if row:
758
+ parent_session_id = row[0]
759
+ # Extract subagent_type from agent_assigned (e.g., "general-purpose-spawner" -> "general-purpose")
760
+ agent_assigned = row[1] or ""
761
+ if agent_assigned and agent_assigned.endswith("-spawner"):
762
+ subagent_type = agent_assigned[:-8] # Remove "-spawner" suffix
763
+ else:
764
+ subagent_type = "general-purpose" # Default if format unexpected
765
+
766
+ # CRITICAL FIX: When Method 1 succeeds, also find the task_delegation event!
767
+ # This ensures parent_activity_id will use the task event, not fall back to UserQuery
768
+ try:
769
+ # First try to find task in parent_session_id (if not NULL)
770
+ if parent_session_id:
771
+ cursor.execute(
772
+ """
773
+ SELECT event_id
774
+ FROM agent_events
775
+ WHERE event_type = 'task_delegation'
776
+ AND subagent_type = ?
777
+ AND status = 'started'
778
+ AND session_id = ?
779
+ ORDER BY timestamp DESC
780
+ LIMIT 1
781
+ """,
782
+ (subagent_type, parent_session_id),
783
+ )
784
+ task_row = cursor.fetchone()
785
+ if task_row:
786
+ task_event_id_from_db = task_row[0]
787
+
788
+ # If not found (parent_session_id is NULL), fallback to finding most recent task
789
+ # This handles Claude Code's session reuse where parent_session_id can be NULL
790
+ if not task_event_id_from_db:
791
+ cursor.execute(
792
+ """
793
+ SELECT event_id
794
+ FROM agent_events
795
+ WHERE event_type = 'task_delegation'
796
+ AND subagent_type = ?
797
+ AND status = 'started'
798
+ ORDER BY timestamp DESC
799
+ LIMIT 1
800
+ """,
801
+ (subagent_type,),
802
+ )
803
+ task_row = cursor.fetchone()
804
+ if task_row:
805
+ task_event_id_from_db = task_row[0]
806
+ logger.warning(
807
+ f"DEBUG Method 1 fallback: Found task_delegation={task_event_id_from_db} for {subagent_type}"
808
+ )
809
+ else:
810
+ logger.warning(
811
+ f"DEBUG Method 1: No task_delegation found for subagent_type={subagent_type}"
812
+ )
813
+ else:
814
+ logger.warning(
815
+ f"DEBUG Method 1: Found task_delegation={task_event_id_from_db} for subagent {subagent_type}"
816
+ )
817
+ except Exception as e:
818
+ logger.warning(
819
+ f"DEBUG: Error finding task_delegation for Method 1: {e}"
820
+ )
821
+
822
+ logger.debug(
823
+ f"DEBUG subagent persistence: Found current session as subagent in sessions table: "
824
+ f"type={subagent_type}, parent_session={parent_session_id}, task_event={task_event_id_from_db}",
825
+ )
826
+ except Exception as e:
827
+ logger.warning(f"DEBUG: Error checking sessions table for subagent: {e}")
828
+
829
+ # Method 2: Environment variables (for first tool call before session table is populated)
830
+ if not subagent_type:
831
+ subagent_type = os.environ.get("HTMLGRAPH_SUBAGENT_TYPE")
832
+ parent_session_id = os.environ.get("HTMLGRAPH_PARENT_SESSION")
833
+
834
+ # Method 3: Database detection of active task_delegation events
835
+ # CRITICAL: When Task() subprocess is launched, environment variables don't propagate
836
+ # So we must query the database for active task_delegation events to detect subagent context
837
+ # NOTE: Claude Code passes the SAME session_id to parent and subagent, so we CAN'T use
838
+ # session_id to distinguish them. Instead, look for the most recent task_delegation event
839
+ # and if found with status='started', we ARE the subagent.
840
+ #
841
+ # CRITICAL FIX: The actual PARENT session is hook_session_id (what Claude Code passes),
842
+ # NOT the session_id from the task_delegation event (which is the same as current).
843
+ # NOTE: DO NOT reinitialize task_event_id_from_db here - it may have been set by Method 1!
844
+ if not subagent_type and db and db.connection:
845
+ try:
846
+ cursor = db.connection.cursor()
847
+ # Find the most recent active task_delegation event
848
+ cursor.execute(
849
+ """
850
+ SELECT event_id, subagent_type, session_id
851
+ FROM agent_events
852
+ WHERE event_type = 'task_delegation'
853
+ AND status = 'started'
854
+ AND tool_name = 'Task'
855
+ ORDER BY timestamp DESC
856
+ LIMIT 1
857
+ """,
858
+ )
859
+ row = cursor.fetchone()
860
+ if row:
861
+ task_event_id, detected_subagent_type, parent_sess = row
862
+ # If we found an active task_delegation, we're running as a subagent
863
+ # (Claude Code uses the same session_id for both parent and subagent)
864
+ subagent_type = detected_subagent_type or "general-purpose"
865
+ # IMPORTANT: Use the hook_session_id as parent, not parent_sess!
866
+ # The parent_sess from task_delegation is the same as current session
867
+ # (Claude Code reuses session_id). The actual parent is hook_session_id.
868
+ parent_session_id = hook_session_id
869
+ task_event_id_from_db = (
870
+ task_event_id # Store for later use as parent_event_id
871
+ )
872
+ logger.debug(
873
+ f"DEBUG subagent detection (database): Detected active task_delegation "
874
+ f"type={subagent_type}, parent_session={parent_session_id}, "
875
+ f"parent_event={task_event_id}"
876
+ )
877
+ except Exception as e:
878
+ logger.warning(f"DEBUG: Error detecting subagent from database: {e}")
879
+
880
+ if subagent_type and parent_session_id:
881
+ # We're in a subagent - create or get subagent session
882
+ # Use deterministic session ID based on parent + subagent type
883
+ subagent_session_id = f"{parent_session_id}-{subagent_type}"
884
+
885
+ # Check if subagent session already exists
886
+ existing = manager.session_converter.load(subagent_session_id)
887
+ if existing:
888
+ active_session = existing
889
+ logger.warning(
890
+ f"Debug: Using existing subagent session: {subagent_session_id}"
891
+ )
892
+ else:
893
+ # Create new subagent session with parent link
894
+ try:
895
+ active_session = manager.start_session(
896
+ session_id=subagent_session_id,
897
+ agent=f"{subagent_type}-spawner",
898
+ is_subagent=True,
899
+ parent_session_id=parent_session_id,
900
+ title=f"{subagent_type.capitalize()} Subagent",
901
+ )
902
+ logger.debug(
903
+ f"Debug: Created subagent session: {subagent_session_id} "
904
+ f"(parent: {parent_session_id})"
905
+ )
906
+ except Exception as e:
907
+ logger.warning(f"Warning: Could not create subagent session: {e}")
908
+ return {"continue": True}
909
+
910
+ # Override detected agent for subagent context
911
+ detected_agent = f"{subagent_type}-spawner"
912
+ else:
913
+ # Normal orchestrator/parent context
914
+ # CRITICAL: Use session_id from hook_input (Claude Code provides this)
915
+ # Only fall back to manager.get_active_session() if not in hook_input
916
+ # hook_session_id already defined at line 730
917
+
918
+ if hook_session_id:
919
+ # Claude Code provided session_id - use it directly
920
+ # Check if session already exists
921
+ existing = manager.session_converter.load(hook_session_id)
922
+ if existing:
923
+ active_session = existing
924
+ else:
925
+ # Create new session with Claude's session_id
926
+ try:
927
+ active_session = manager.start_session(
928
+ session_id=hook_session_id,
929
+ agent=detected_agent,
930
+ title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
931
+ )
932
+ except Exception:
933
+ return {"continue": True}
934
+ else:
935
+ # Fallback: No session_id in hook_input - use global session cache
936
+ active_session = manager.get_active_session()
937
+ if not active_session:
938
+ # No active HtmlGraph session yet; start one
939
+ try:
940
+ active_session = manager.start_session(
941
+ session_id=None,
942
+ agent=detected_agent,
943
+ title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
944
+ )
945
+ except Exception:
946
+ return {"continue": True}
410
947
 
411
948
  active_session_id = active_session.id
412
949
 
950
+ # Ensure session exists in SQLite database (for foreign key constraints)
951
+ if db:
952
+ try:
953
+ # Get attributes safely - MagicMock objects can cause SQLite binding errors
954
+ # When getattr is called on a MagicMock, it returns another MagicMock, not the default
955
+ def safe_getattr(obj: Any, attr: str, default: Any) -> Any:
956
+ """Get attribute safely, returning default for MagicMock/invalid values."""
957
+ try:
958
+ val = getattr(obj, attr, default)
959
+ # Check if it's a mock object (has _mock_name attribute)
960
+ if hasattr(val, "_mock_name"):
961
+ return default
962
+ return val
963
+ except Exception:
964
+ return default
965
+
966
+ is_subagent_raw = safe_getattr(active_session, "is_subagent", False)
967
+ is_subagent = (
968
+ bool(is_subagent_raw) if isinstance(is_subagent_raw, bool) else False
969
+ )
970
+
971
+ transcript_id = safe_getattr(active_session, "transcript_id", None)
972
+ transcript_path = safe_getattr(active_session, "transcript_path", None)
973
+ # Ensure strings or None, not mock objects
974
+ if transcript_id is not None and not isinstance(transcript_id, str):
975
+ transcript_id = None
976
+ if transcript_path is not None and not isinstance(transcript_path, str):
977
+ transcript_path = None
978
+
979
+ db.insert_session(
980
+ session_id=active_session_id,
981
+ agent_assigned=safe_getattr(active_session, "agent", None)
982
+ or detected_agent,
983
+ is_subagent=is_subagent,
984
+ transcript_id=transcript_id,
985
+ transcript_path=transcript_path,
986
+ )
987
+ except Exception as e:
988
+ # Session may already exist, that's OK - continue
989
+ logger.warning(
990
+ f"Debug: Could not insert session to SQLite (may already exist): {e}"
991
+ )
992
+
413
993
  # Handle different hook types
414
994
  if hook_type == "Stop":
415
995
  # Session is ending - track stop event
416
996
  try:
417
- manager.track_activity(
997
+ result = manager.track_activity(
418
998
  session_id=active_session_id, tool="Stop", summary="Agent stopped"
419
999
  )
1000
+
1001
+ # Record to SQLite if available
1002
+ if db:
1003
+ record_event_to_sqlite(
1004
+ db=db,
1005
+ session_id=active_session_id,
1006
+ tool_name="Stop",
1007
+ tool_input={},
1008
+ tool_response={"content": "Agent stopped"},
1009
+ is_error=False,
1010
+ agent_id=detected_agent,
1011
+ model=detected_model,
1012
+ feature_id=result.feature_id if result else None,
1013
+ )
420
1014
  except Exception as e:
421
- print(f"Warning: Could not track stop: {e}", file=sys.stderr)
1015
+ logger.warning(f"Warning: Could not track stop: {e}")
422
1016
  return {"continue": True}
423
1017
 
424
1018
  elif hook_type == "UserPromptSubmit":
@@ -429,11 +1023,28 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
429
1023
  preview += "..."
430
1024
 
431
1025
  try:
432
- manager.track_activity(
1026
+ result = manager.track_activity(
433
1027
  session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
434
1028
  )
1029
+
1030
+ # Record to SQLite if available
1031
+ # UserQuery event is stored in database - no file-based state needed
1032
+ # Subsequent tool calls query database for parent via get_parent_user_query()
1033
+ if db:
1034
+ record_event_to_sqlite(
1035
+ db=db,
1036
+ session_id=active_session_id,
1037
+ tool_name="UserQuery",
1038
+ tool_input={"prompt": prompt},
1039
+ tool_response={"content": "Query received"},
1040
+ is_error=False,
1041
+ agent_id=detected_agent,
1042
+ model=detected_model,
1043
+ feature_id=result.feature_id if result else None,
1044
+ )
1045
+
435
1046
  except Exception as e:
436
- print(f"Warning: Could not track query: {e}", file=sys.stderr)
1047
+ logger.warning(f"Warning: Could not track query: {e}")
437
1048
  return {"continue": True}
438
1049
 
439
1050
  elif hook_type == "PostToolUse":
@@ -456,7 +1067,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
456
1067
  summary = format_tool_summary(tool_name, tool_input_data, tool_response)
457
1068
 
458
1069
  # Determine success
459
- if isinstance(tool_response, dict):
1070
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
460
1071
  success_field = tool_response.get("success")
461
1072
  if isinstance(success_field, bool):
462
1073
  is_error = not success_field
@@ -479,26 +1090,70 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
479
1090
 
480
1091
  # Get drift thresholds from config
481
1092
  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)
1093
+ warning_threshold = drift_settings.get("warning_threshold") or 0.7
1094
+ auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
484
1095
 
485
- # Determine parent activity context
486
- parent_activity_state = load_parent_activity(graph_dir)
1096
+ # Determine parent activity context using database-only lookup
487
1097
  parent_activity_id = None
488
1098
 
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
1099
+ # Check environment variable FIRST for cross-process parent linking
1100
+ # This is set by PreToolUse hook when Task() spawns a subagent
1101
+ env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
1102
+ "HTMLGRAPH_PARENT_QUERY_EVENT"
1103
+ )
1104
+ if env_parent:
1105
+ parent_activity_id = env_parent
1106
+ # If we detected a Task delegation event via database detection (Method 3),
1107
+ # use that as the parent for all tool calls within the subagent
1108
+ elif task_event_id_from_db:
1109
+ parent_activity_id = task_event_id_from_db
1110
+ # CRITICAL FIX: Check for active task_delegation EVEN IF task_event_id_from_db not set
1111
+ # This handles Claude Code's session reuse where parent_session_id is NULL
1112
+ # When tool calls come from a subagent, they should be under the task_delegation parent,
1113
+ # NOT under UserQuery. So we MUST check for active tasks BEFORE falling back to UserQuery.
1114
+ # IMPORTANT: This must work EVEN IF db is None, so try to get it from htmlgraph_db
497
1115
  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"]
1116
+ # Ensure we have a db connection (may not have been passed in for parent session)
1117
+ db_to_use = db
1118
+ if not db_to_use:
1119
+ try:
1120
+ from htmlgraph.config import get_database_path
1121
+ from htmlgraph.db.schema import HtmlGraphDB
1122
+
1123
+ db_to_use = HtmlGraphDB(str(get_database_path()))
1124
+ except Exception:
1125
+ db_to_use = None
1126
+
1127
+ # Try to find an active task_delegation event
1128
+ if db_to_use:
1129
+ try:
1130
+ cursor = db_to_use.connection.cursor() # type: ignore[union-attr]
1131
+ cursor.execute(
1132
+ """
1133
+ SELECT event_id
1134
+ FROM agent_events
1135
+ WHERE event_type = 'task_delegation'
1136
+ AND status = 'started'
1137
+ ORDER BY timestamp DESC
1138
+ LIMIT 1
1139
+ """,
1140
+ )
1141
+ task_row = cursor.fetchone()
1142
+ if task_row:
1143
+ parent_activity_id = task_row[0]
1144
+ logger.warning(
1145
+ f"DEBUG: Found active task_delegation={parent_activity_id} in parent_activity_id fallback"
1146
+ )
1147
+ except Exception as e:
1148
+ logger.warning(
1149
+ f"DEBUG: Error finding task_delegation in parent_activity_id: {e}"
1150
+ )
1151
+
1152
+ # Only if no active task found, fall back to UserQuery
1153
+ if not parent_activity_id:
1154
+ parent_activity_id = get_parent_user_query(
1155
+ db_to_use, active_session_id
1156
+ )
502
1157
 
503
1158
  # Track the activity
504
1159
  nudge = None
@@ -512,11 +1167,42 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
512
1167
  parent_activity_id=parent_activity_id,
513
1168
  )
514
1169
 
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)
1170
+ # Record to SQLite if available
1171
+ if db:
1172
+ # Extract subagent_type for Task delegations
1173
+ task_subagent_type = None
1174
+ if tool_name == "Task":
1175
+ task_subagent_type = tool_input_data.get(
1176
+ "subagent_type", "general-purpose"
1177
+ )
1178
+
1179
+ record_event_to_sqlite(
1180
+ db=db,
1181
+ session_id=active_session_id,
1182
+ tool_name=tool_name,
1183
+ tool_input=tool_input_data,
1184
+ tool_response=tool_response,
1185
+ is_error=is_error,
1186
+ file_paths=file_paths if file_paths else None,
1187
+ parent_event_id=parent_activity_id, # Link to parent event
1188
+ agent_id=detected_agent,
1189
+ subagent_type=task_subagent_type,
1190
+ model=detected_model,
1191
+ feature_id=result.feature_id if result else None,
1192
+ )
1193
+
1194
+ # If this was a Task() delegation, also record to agent_collaboration
1195
+ if tool_name == "Task" and db:
1196
+ subagent = tool_input_data.get("subagent_type", "general-purpose")
1197
+ description = tool_input_data.get("description", "")
1198
+ record_delegation_to_sqlite(
1199
+ db=db,
1200
+ session_id=active_session_id,
1201
+ from_agent=detected_agent,
1202
+ to_agent=subagent,
1203
+ task_description=description,
1204
+ task_input=tool_input_data,
1205
+ )
520
1206
 
521
1207
  # Check for drift and handle accordingly
522
1208
  # Skip drift detection for child activities (they inherit parent's context)
@@ -524,7 +1210,10 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
524
1210
  drift_score = result.drift_score
525
1211
  feature_id = getattr(result, "feature_id", "unknown")
526
1212
 
527
- if drift_score and drift_score >= auto_classify_threshold:
1213
+ # Skip drift detection if no score available
1214
+ if drift_score is None:
1215
+ pass # No active features - can't calculate drift
1216
+ elif drift_score >= auto_classify_threshold:
528
1217
  # High drift - add to classification queue
529
1218
  queue = add_to_drift_queue(
530
1219
  graph_dir,
@@ -598,7 +1287,9 @@ Task tool with subagent_type="general-purpose", model="haiku", prompt:
598
1287
  Or manually create a work item in .htmlgraph/ (bug, feature, spike, or chore)."""
599
1288
 
600
1289
  # Mark classification as triggered
601
- queue["last_classification"] = datetime.now().isoformat()
1290
+ queue["last_classification"] = datetime.now(
1291
+ timezone.utc
1292
+ ).isoformat()
602
1293
  save_drift_queue(graph_dir, queue)
603
1294
  else:
604
1295
  nudge = f"Drift detected ({drift_score:.2f}): Activity queued for classification ({len(queue['activities'])}/{drift_settings.get('min_activities_before_classify', 3)} needed)."
@@ -608,7 +1299,7 @@ Or manually create a work item in .htmlgraph/ (bug, feature, spike, or chore).""
608
1299
  nudge = f"Drift detected ({drift_score:.2f}): Activity may not align with {feature_id}. Consider refocusing or updating the feature."
609
1300
 
610
1301
  except Exception as e:
611
- print(f"Warning: Could not track activity: {e}", file=sys.stderr)
1302
+ logger.warning(f"Warning: Could not track activity: {e}")
612
1303
 
613
1304
  # Build response
614
1305
  response: dict[str, Any] = {"continue": True}