htmlgraph 0.25.0__py3-none-any.whl → 0.26.2__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 (41) 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 +1 -1
  5. htmlgraph/api/main.py +252 -47
  6. htmlgraph/api/templates/dashboard.html +11 -0
  7. htmlgraph/api/templates/partials/activity-feed.html +517 -8
  8. htmlgraph/cli.py +1 -1
  9. htmlgraph/config.py +173 -96
  10. htmlgraph/dashboard.html +632 -7237
  11. htmlgraph/db/schema.py +258 -9
  12. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  13. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  14. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  15. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  16. htmlgraph/hooks/concurrent_sessions.py +208 -0
  17. htmlgraph/hooks/context.py +88 -10
  18. htmlgraph/hooks/drift_handler.py +24 -20
  19. htmlgraph/hooks/event_tracker.py +264 -189
  20. htmlgraph/hooks/orchestrator.py +6 -4
  21. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  22. htmlgraph/hooks/pretooluse.py +63 -36
  23. htmlgraph/hooks/prompt_analyzer.py +14 -25
  24. htmlgraph/hooks/session_handler.py +123 -69
  25. htmlgraph/hooks/state_manager.py +7 -4
  26. htmlgraph/hooks/subagent_stop.py +3 -2
  27. htmlgraph/hooks/validator.py +15 -11
  28. htmlgraph/operations/fastapi_server.py +2 -2
  29. htmlgraph/orchestration/headless_spawner.py +489 -16
  30. htmlgraph/orchestration/live_events.py +377 -0
  31. htmlgraph/server.py +100 -203
  32. htmlgraph-0.26.2.data/data/htmlgraph/dashboard.html +812 -0
  33. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/METADATA +1 -1
  34. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/RECORD +40 -32
  35. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +0 -7417
  36. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/styles.css +0 -0
  37. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  38. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  39. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  40. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/WHEEL +0 -0
  41. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/entry_points.txt +0 -0
@@ -21,7 +21,7 @@ Enforcement Levels:
21
21
  - guidance: ALLOWS but provides warnings and suggestions
22
22
 
23
23
  Public API:
24
- - enforce_orchestrator_mode(tool: str, params: dict) -> dict
24
+ - enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict
25
25
  Main entry point for hook scripts. Returns hook response dict.
26
26
  """
27
27
 
@@ -93,7 +93,9 @@ def add_to_tool_history(tool: str) -> None:
93
93
  save_tool_history(history)
94
94
 
95
95
 
96
- def is_allowed_orchestrator_operation(tool: str, params: dict) -> tuple[bool, str, str]:
96
+ def is_allowed_orchestrator_operation(
97
+ tool: str, params: dict[str, Any]
98
+ ) -> tuple[bool, str, str]:
97
99
  """
98
100
  Check if operation is allowed for orchestrators.
99
101
 
@@ -268,7 +270,7 @@ def is_allowed_orchestrator_operation(tool: str, params: dict) -> tuple[bool, st
268
270
  return True, "Allowed in guidance mode", "guidance-allowed"
269
271
 
270
272
 
271
- def create_task_suggestion(tool: str, params: dict) -> str:
273
+ def create_task_suggestion(tool: str, params: dict[str, Any]) -> str:
272
274
  """
273
275
  Create Task tool suggestion based on blocked operation.
274
276
 
@@ -370,7 +372,7 @@ def create_task_suggestion(tool: str, params: dict) -> str:
370
372
  )
371
373
 
372
374
 
373
- def enforce_orchestrator_mode(tool: str, params: dict) -> dict:
375
+ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, Any]:
374
376
  """
375
377
  Enforce orchestrator mode rules.
376
378
 
@@ -22,7 +22,7 @@ Usage:
22
22
  """
23
23
 
24
24
  import re
25
- from typing import TypedDict
25
+ from typing import Any, TypedDict
26
26
 
27
27
 
28
28
  class HookSpecificOutput(TypedDict):
@@ -93,7 +93,7 @@ def is_python_execution(command: str) -> bool:
93
93
  return False
94
94
 
95
95
 
96
- def should_reflect(hook_input: dict) -> tuple[bool, str]:
96
+ def should_reflect(hook_input: dict[str, Any]) -> tuple[bool, str]:
97
97
  """
98
98
  Check if we should show reflection prompt.
99
99
 
@@ -156,7 +156,7 @@ Ask yourself:
156
156
  Continue, but consider delegation for similar future tasks."""
157
157
 
158
158
 
159
- def orchestrator_reflect(tool_input: dict) -> dict:
159
+ def orchestrator_reflect(tool_input: dict[str, Any]) -> dict[str, Any]:
160
160
  """
161
161
  Main API function for orchestrator reflection.
162
162
 
@@ -184,7 +184,7 @@ def orchestrator_reflect(tool_input: dict) -> dict:
184
184
  should_show, command_preview = should_reflect(tool_input)
185
185
 
186
186
  # Build response
187
- response: dict = {"continue": True}
187
+ response: dict[str, Any] = {"continue": True}
188
188
 
189
189
  if should_show:
190
190
  reflection = build_reflection_message(command_preview)
@@ -243,8 +243,6 @@ def create_task_parent_event(
243
243
  Parent event_id if successful, None otherwise
244
244
  """
245
245
  try:
246
- from pathlib import Path
247
-
248
246
  if not db.connection:
249
247
  db.connect()
250
248
 
@@ -252,13 +250,12 @@ def create_task_parent_event(
252
250
  subagent_type = extract_subagent_type(tool_input)
253
251
  prompt = str(tool_input.get("prompt", ""))[:200]
254
252
 
255
- # Load UserQuery event ID for parent-child linking
256
- graph_dir = Path.cwd() / ".htmlgraph"
253
+ # Load UserQuery event ID for parent-child linking from database
257
254
  user_query_event_id = None
258
255
  try:
259
- from htmlgraph.hooks.event_tracker import load_user_query_event
256
+ from htmlgraph.hooks.event_tracker import get_parent_user_query
260
257
 
261
- user_query_event_id = load_user_query_event(graph_dir, session_id)
258
+ user_query_event_id = get_parent_user_query(db, session_id)
262
259
  except Exception:
263
260
  pass
264
261
 
@@ -343,8 +340,6 @@ def create_start_event(
343
340
  """
344
341
  tool_use_id = None
345
342
  try:
346
- from pathlib import Path
347
-
348
343
  tool_use_id = generate_tool_use_id()
349
344
  trace_id = os.environ.get("HTMLGRAPH_TRACE_ID", tool_use_id)
350
345
  start_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
@@ -352,9 +347,10 @@ def create_start_event(
352
347
  # Sanitize input before storing
353
348
  sanitized_input = sanitize_tool_input(tool_input)
354
349
 
355
- # Connect to database (use project's .htmlgraph/index.sqlite, not home directory)
356
- graph_dir = Path.cwd() / ".htmlgraph"
357
- db_path = str(graph_dir / "index.sqlite")
350
+ # Connect to database (use project's .htmlgraph/htmlgraph.db, not home directory)
351
+ from htmlgraph.config import get_database_path
352
+
353
+ db_path = str(get_database_path())
358
354
  db = HtmlGraphDB(db_path)
359
355
 
360
356
  # Ensure session exists (create placeholder if needed)
@@ -367,10 +363,19 @@ def create_start_event(
367
363
 
368
364
  cursor = db.connection.cursor() # type: ignore[union-attr]
369
365
 
366
+ # Get UserQuery event ID for ALL tool calls (links conversation turns)
367
+ user_query_event_id = None
368
+ try:
369
+ from htmlgraph.hooks.event_tracker import get_parent_user_query
370
+
371
+ user_query_event_id = get_parent_user_query(db, session_id)
372
+ except Exception:
373
+ pass
374
+
370
375
  # Check if this is a Task() call for parent event creation
371
- parent_event_id = None
376
+ task_parent_event_id = None
372
377
  if tool_name == "Task":
373
- parent_event_id = create_task_parent_event(
378
+ task_parent_event_id = create_task_parent_event(
374
379
  db, tool_input, session_id, start_time
375
380
  )
376
381
 
@@ -379,6 +384,11 @@ def create_start_event(
379
384
 
380
385
  event_id = f"evt-{str(uuid.uuid4())[:8]}"
381
386
 
387
+ # Determine parent: Task() uses task_parent_event, others use UserQuery
388
+ parent_event_id = (
389
+ task_parent_event_id if tool_name == "Task" else user_query_event_id
390
+ )
391
+
382
392
  cursor.execute(
383
393
  """
384
394
  INSERT INTO agent_events
@@ -395,10 +405,17 @@ def create_start_event(
395
405
  json.dumps(sanitized_input)[:500], # Truncate for summary
396
406
  session_id,
397
407
  "recorded",
398
- parent_event_id, # Link to parent if this is Task()
408
+ parent_event_id, # Link to UserQuery or Task parent
399
409
  ),
400
410
  )
401
411
 
412
+ # Export Bash event as parent for child processes (e.g., spawner executables)
413
+ if tool_name == "Bash":
414
+ os.environ["HTMLGRAPH_PARENT_EVENT"] = event_id
415
+ logger.debug(
416
+ f"Exported HTMLGRAPH_PARENT_EVENT={event_id} for Bash tool call"
417
+ )
418
+
402
419
  # Also insert into tool_traces for correlation (if table exists)
403
420
  try:
404
421
  cursor.execute(
@@ -453,36 +470,46 @@ async def run_event_tracing(
453
470
  Event tracing response: {"hookSpecificOutput": {"tool_use_id": "...", ...}}
454
471
  """
455
472
  try:
473
+ from htmlgraph.hooks.context import HookContext
474
+
456
475
  loop = asyncio.get_event_loop()
457
476
  tool_name = tool_input.get("name", "") or tool_input.get("tool_name", "")
458
- session_id = get_current_session_id()
459
477
 
460
- # Skip if no session ID
461
- if not session_id:
462
- logger.debug("No session ID found, skipping event tracing")
463
- return {}
478
+ # Use HookContext to properly extract session_id (same as UserPromptSubmit)
479
+ context = HookContext.from_input(tool_input)
464
480
 
465
- # Run in thread pool since it involves I/O
466
- tool_use_id = await loop.run_in_executor(
467
- None,
468
- create_start_event,
469
- tool_name,
470
- tool_input,
471
- session_id,
472
- )
481
+ try:
482
+ session_id = context.session_id
473
483
 
474
- if tool_use_id:
475
- # Store in environment for PostToolUse correlation
476
- os.environ["HTMLGRAPH_TOOL_USE_ID"] = tool_use_id
484
+ # Skip if no session ID
485
+ if not session_id or session_id == "unknown":
486
+ logger.debug("No session ID found, skipping event tracing")
487
+ return {}
477
488
 
478
- return {
479
- "hookSpecificOutput": {
480
- "tool_use_id": tool_use_id,
481
- "additionalContext": f"Event tracing started: {tool_use_id}",
489
+ # Run in thread pool since it involves I/O
490
+ tool_use_id = await loop.run_in_executor(
491
+ None,
492
+ create_start_event,
493
+ tool_name,
494
+ tool_input,
495
+ session_id,
496
+ )
497
+
498
+ if tool_use_id:
499
+ # Store in environment for PostToolUse correlation
500
+ os.environ["HTMLGRAPH_TOOL_USE_ID"] = tool_use_id
501
+
502
+ return {
503
+ "hookSpecificOutput": {
504
+ "tool_use_id": tool_use_id,
505
+ "additionalContext": f"Event tracing started: {tool_use_id}",
506
+ }
482
507
  }
483
- }
484
508
 
485
- return {}
509
+ return {}
510
+ finally:
511
+ # Ensure context resources are properly closed
512
+ context.close()
486
513
  except Exception:
487
514
  # Graceful degradation - allow on error
488
515
  return {}
@@ -15,14 +15,11 @@ The module is designed to be reusable across different hook implementations,
15
15
  with graceful degradation if dependencies are unavailable.
16
16
  """
17
17
 
18
- import json
19
18
  import logging
20
- import os
21
19
  import re
22
20
  import uuid
23
21
  from datetime import datetime, timezone
24
- from pathlib import Path
25
- from typing import Any, Optional
22
+ from typing import Any
26
23
 
27
24
  from htmlgraph.hooks.context import HookContext
28
25
 
@@ -276,7 +273,7 @@ def get_session_violation_count(context: HookContext) -> tuple[int, int]:
276
273
  return 0, 0
277
274
 
278
275
 
279
- def get_active_work_item(context: HookContext) -> Optional[dict[str, Any]]:
276
+ def get_active_work_item(context: HookContext) -> dict[str, Any] | None:
280
277
  """
281
278
  Query HtmlGraph for active feature/spike.
282
279
 
@@ -299,15 +296,18 @@ def get_active_work_item(context: HookContext) -> Optional[dict[str, Any]]:
299
296
 
300
297
  sdk = SDK()
301
298
  work_item = sdk.get_active_work_item()
302
- return work_item if isinstance(work_item, dict) else None
299
+ if work_item is None:
300
+ return None
301
+ # Convert ActiveWorkItem TypedDict to dict
302
+ return dict(work_item) if hasattr(work_item, "__iter__") else work_item # type: ignore
303
303
  except Exception as e:
304
304
  logger.debug(f"Could not get active work item: {e}")
305
305
  return None
306
306
 
307
307
 
308
308
  def generate_guidance(
309
- classification: dict[str, Any], active_work: Optional[dict[str, Any]], prompt: str
310
- ) -> Optional[str]:
309
+ classification: dict[str, Any], active_work: dict[str, Any] | None, prompt: str
310
+ ) -> str | None:
311
311
  """
312
312
  Generate workflow guidance based on classification and context.
313
313
 
@@ -447,7 +447,7 @@ def generate_guidance(
447
447
 
448
448
 
449
449
  def generate_cigs_guidance(
450
- cigs_intent: dict, violation_count: int, waste_tokens: int
450
+ cigs_intent: dict[str, Any], violation_count: int, waste_tokens: int
451
451
  ) -> str:
452
452
  """
453
453
  Generate CIGS-specific guidance for detected violations.
@@ -521,13 +521,13 @@ def generate_cigs_guidance(
521
521
  return "\n".join(guidance_parts)
522
522
 
523
523
 
524
- def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
524
+ def create_user_query_event(context: HookContext, prompt: str) -> str | None:
525
525
  """
526
526
  Create UserQuery event in HtmlGraph database.
527
527
 
528
528
  Records the user prompt as a UserQuery event that serves as the parent
529
- for subsequent tool calls and delegations. The event ID is saved to a
530
- session-scoped file for retrieval by PreToolUse hook.
529
+ for subsequent tool calls and delegations. Database is the single source
530
+ of truth - no file-based state is used.
531
531
 
532
532
  Args:
533
533
  context: HookContext with session and database access
@@ -548,7 +548,6 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
548
548
  """
549
549
  try:
550
550
  session_id = context.session_id
551
- graph_dir = context.graph_dir
552
551
 
553
552
  if not session_id or session_id == "unknown":
554
553
  logger.debug("No valid session ID for UserQuery event")
@@ -556,9 +555,6 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
556
555
 
557
556
  # Create UserQuery event in database
558
557
  try:
559
- from htmlgraph.db.schema import HtmlGraphDB
560
- from htmlgraph.hooks.event_tracker import save_user_query_event
561
-
562
558
  db = context.database
563
559
 
564
560
  # Ensure session exists in database before creating event
@@ -589,6 +585,8 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
589
585
  input_summary = prompt[:200]
590
586
 
591
587
  # Insert UserQuery event into agent_events
588
+ # Database is the single source of truth for parent-child linking
589
+ # Subsequent tool calls query database via get_parent_user_query()
592
590
  success = db.insert_event(
593
591
  event_id=user_query_event_id,
594
592
  agent_id="user",
@@ -606,15 +604,6 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
606
604
  logger.warning("Failed to insert UserQuery event into database")
607
605
  return None
608
606
 
609
- # Save event ID to session-scoped file for subsequent tool calls
610
- # This is used by PreToolUse hook to link Task delegations
611
- try:
612
- save_user_query_event(graph_dir, session_id, user_query_event_id)
613
- logger.debug(f"Saved UserQuery event file: {user_query_event_id}")
614
- except Exception as e:
615
- # If saving to file fails, still continue (database insert succeeded)
616
- logger.warning(f"Could not save UserQuery event file: {e}")
617
-
618
607
  logger.info(f"Created UserQuery event: {user_query_event_id}")
619
608
  return user_query_event_id
620
609
 
@@ -89,7 +89,7 @@ def init_or_get_session(context: HookContext) -> Any | None:
89
89
  return None
90
90
 
91
91
 
92
- def handle_session_start(context: HookContext, session: Any | None) -> dict:
92
+ def handle_session_start(context: HookContext, session: Any | None) -> dict[str, Any]:
93
93
  """
94
94
  Initialize HtmlGraph tracking for the session.
95
95
 
@@ -99,6 +99,7 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict:
99
99
  - Builds feature context string
100
100
  - Records session start event
101
101
  - Creates conversation-init spike if new conversation
102
+ - Injects concurrent session and recent work context
102
103
 
103
104
  Args:
104
105
  context: HookContext with project and graph directory information
@@ -110,6 +111,7 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict:
110
111
  "continue": True,
111
112
  "hookSpecificOutput": {
112
113
  "sessionFeatureContext": str with feature context,
114
+ "sessionContext": optional concurrent/recent work context,
113
115
  "versionInfo": optional version check result
114
116
  }
115
117
  }
@@ -118,6 +120,7 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict:
118
120
  "continue": True,
119
121
  "hookSpecificOutput": {
120
122
  "sessionFeatureContext": "",
123
+ "sessionContext": "",
121
124
  "versionInfo": None,
122
125
  },
123
126
  }
@@ -201,10 +204,61 @@ Activity will be attributed to these features based on file patterns and keyword
201
204
  except Exception as e:
202
205
  context.log("debug", f"Could not check version: {e}")
203
206
 
207
+ # Build concurrent session context
208
+ try:
209
+ from htmlgraph.hooks.concurrent_sessions import (
210
+ format_concurrent_sessions_markdown,
211
+ format_recent_work_markdown,
212
+ get_concurrent_sessions,
213
+ get_recent_completed_sessions,
214
+ )
215
+
216
+ db = context.database
217
+
218
+ # Get concurrent sessions (other active windows)
219
+ concurrent = get_concurrent_sessions(db, context.session_id, minutes=30)
220
+ concurrent_md = format_concurrent_sessions_markdown(concurrent)
221
+
222
+ # Get recent completed work
223
+ recent = get_recent_completed_sessions(db, hours=24, limit=5)
224
+ recent_md = format_recent_work_markdown(recent)
225
+
226
+ # Build session context
227
+ session_context = ""
228
+ if concurrent_md:
229
+ session_context += concurrent_md + "\n"
230
+ if recent_md:
231
+ session_context += recent_md + "\n"
232
+
233
+ if session_context:
234
+ output["hookSpecificOutput"]["sessionContext"] = session_context.strip()
235
+ context.log(
236
+ "info",
237
+ f"Injected context: {len(concurrent)} concurrent, {len(recent)} recent",
238
+ )
239
+
240
+ except ImportError:
241
+ context.log("debug", "Concurrent session module not available")
242
+ except Exception as e:
243
+ context.log("warning", f"Failed to get concurrent session context: {e}")
244
+
245
+ # Update session with user's current query (if available from hook input)
246
+ try:
247
+ user_query = context.hook_input.get("prompt", "")
248
+ if user_query and session:
249
+ context.session_manager.track_activity(
250
+ session_id=session.id,
251
+ tool="UserQuery",
252
+ summary=user_query[:100],
253
+ payload={"query_length": len(user_query)},
254
+ )
255
+ except Exception as e:
256
+ context.log("warning", f"Failed to update session activity: {e}")
257
+
204
258
  return output
205
259
 
206
260
 
207
- def handle_session_end(context: HookContext) -> dict:
261
+ def handle_session_end(context: HookContext) -> dict[str, Any]:
208
262
  """
209
263
  Close session gracefully and record final metrics.
210
264
 
@@ -233,76 +287,75 @@ def handle_session_end(context: HookContext) -> dict:
233
287
  session = context.session_manager.get_active_session()
234
288
  if not session:
235
289
  context.log("debug", "No active session to close")
236
- return output
290
+ else:
291
+ # Capture handoff context if provided
292
+ handoff_notes = context.hook_input.get("handoff_notes") or os.environ.get(
293
+ "HTMLGRAPH_HANDOFF_NOTES"
294
+ )
295
+ recommended_next = context.hook_input.get(
296
+ "recommended_next"
297
+ ) or os.environ.get("HTMLGRAPH_HANDOFF_RECOMMEND")
298
+ blockers_raw = context.hook_input.get("blockers") or os.environ.get(
299
+ "HTMLGRAPH_HANDOFF_BLOCKERS"
300
+ )
237
301
 
238
- # Capture handoff context if provided
239
- handoff_notes = context.hook_input.get("handoff_notes") or os.environ.get(
240
- "HTMLGRAPH_HANDOFF_NOTES"
241
- )
242
- recommended_next = context.hook_input.get("recommended_next") or os.environ.get(
243
- "HTMLGRAPH_HANDOFF_RECOMMEND"
244
- )
245
- blockers_raw = context.hook_input.get("blockers") or os.environ.get(
246
- "HTMLGRAPH_HANDOFF_BLOCKERS"
247
- )
302
+ blockers = None
303
+ if isinstance(blockers_raw, str):
304
+ blockers = [b.strip() for b in blockers_raw.split(",") if b.strip()]
305
+ elif isinstance(blockers_raw, list):
306
+ blockers = [str(b).strip() for b in blockers_raw if str(b).strip()]
248
307
 
249
- blockers = None
250
- if isinstance(blockers_raw, str):
251
- blockers = [b.strip() for b in blockers_raw.split(",") if b.strip()]
252
- elif isinstance(blockers_raw, list):
253
- blockers = [str(b).strip() for b in blockers_raw if str(b).strip()]
308
+ if handoff_notes or recommended_next or blockers:
309
+ try:
310
+ context.session_manager.set_session_handoff(
311
+ session_id=session.id,
312
+ handoff_notes=handoff_notes,
313
+ recommended_next=recommended_next,
314
+ blockers=blockers,
315
+ )
316
+ context.log("info", "Session handoff recorded")
317
+ except Exception as e:
318
+ context.log("warning", f"Could not set handoff: {e}")
319
+ output["status"] = "partial"
320
+
321
+ # Link transcript if external session ID provided
322
+ external_session_id = context.hook_input.get(
323
+ "session_id"
324
+ ) or os.environ.get("CLAUDE_SESSION_ID")
325
+ if external_session_id:
326
+ try:
327
+ from htmlgraph.transcript import TranscriptReader
328
+
329
+ reader = TranscriptReader()
330
+ transcript = reader.read_session(external_session_id)
331
+ if transcript:
332
+ context.session_manager.link_transcript(
333
+ session_id=session.id,
334
+ transcript_id=external_session_id,
335
+ transcript_path=str(transcript.path),
336
+ git_branch=transcript.git_branch
337
+ if hasattr(transcript, "git_branch")
338
+ else None,
339
+ )
340
+ context.log("info", "Transcript linked to session")
341
+ except ImportError:
342
+ context.log("debug", "Transcript reader not available")
343
+ except Exception as e:
344
+ context.log("warning", f"Could not link transcript: {e}")
345
+ output["status"] = "partial"
254
346
 
255
- if handoff_notes or recommended_next or blockers:
347
+ # Record session end activity
256
348
  try:
257
- context.session_manager.set_session_handoff(
349
+ context.session_manager.track_activity(
258
350
  session_id=session.id,
259
- handoff_notes=handoff_notes,
260
- recommended_next=recommended_next,
261
- blockers=blockers,
351
+ tool="SessionEnd",
352
+ summary="Session ended",
262
353
  )
263
- context.log("info", "Session handoff recorded")
264
354
  except Exception as e:
265
- context.log("warning", f"Could not set handoff: {e}")
355
+ context.log("warning", f"Could not track session end: {e}")
266
356
  output["status"] = "partial"
267
357
 
268
- # Link transcript if external session ID provided
269
- external_session_id = context.hook_input.get("session_id") or os.environ.get(
270
- "CLAUDE_SESSION_ID"
271
- )
272
- if external_session_id:
273
- try:
274
- from htmlgraph.transcript import TranscriptReader
275
-
276
- reader = TranscriptReader()
277
- transcript = reader.read_session(external_session_id)
278
- if transcript:
279
- context.session_manager.link_transcript(
280
- session_id=session.id,
281
- transcript_id=external_session_id,
282
- transcript_path=str(transcript.path),
283
- git_branch=transcript.git_branch
284
- if hasattr(transcript, "git_branch")
285
- else None,
286
- )
287
- context.log("info", "Transcript linked to session")
288
- except ImportError:
289
- context.log("debug", "Transcript reader not available")
290
- except Exception as e:
291
- context.log("warning", f"Could not link transcript: {e}")
292
- output["status"] = "partial"
293
-
294
- # Record session end activity
295
- try:
296
- context.session_manager.track_activity(
297
- session_id=session.id,
298
- tool="SessionEnd",
299
- summary="Session ended",
300
- )
301
- except Exception as e:
302
- context.log("warning", f"Could not track session end: {e}")
303
- output["status"] = "partial"
304
-
305
- context.log("info", f"Session closed: {session.id}")
358
+ context.log("info", f"Session closed: {session.id}")
306
359
 
307
360
  except ImportError:
308
361
  context.log("error", "SessionManager not available")
@@ -507,7 +560,8 @@ def _get_latest_pypi_version() -> str | None:
507
560
  )
508
561
  with urllib.request.urlopen(req, timeout=5) as response:
509
562
  data = json.loads(response.read().decode())
510
- return data.get("info", {}).get("version")
563
+ version: str | None = data.get("info", {}).get("version")
564
+ return version
511
565
  except Exception:
512
566
  return None
513
567
 
@@ -565,13 +619,13 @@ def _cleanup_temp_files(graph_dir: Path) -> None:
565
619
  logger.debug(f"Could not clean up {path}: {e}")
566
620
  else:
567
621
  # Handle single file
568
- path = graph_dir / pattern
622
+ file_path: Path = graph_dir / pattern
569
623
  try:
570
- if path.exists():
571
- path.unlink()
572
- logger.debug(f"Cleaned up: {path}")
624
+ if file_path.exists():
625
+ file_path.unlink()
626
+ logger.debug(f"Cleaned up: {file_path}")
573
627
  except Exception as e:
574
- logger.debug(f"Could not clean up {path}: {e}")
628
+ logger.debug(f"Could not clean up {file_path}: {e}")
575
629
 
576
630
 
577
631
  __all__ = [
@@ -25,6 +25,7 @@ import os
25
25
  import tempfile
26
26
  from datetime import datetime, timedelta
27
27
  from pathlib import Path
28
+ from typing import Any
28
29
 
29
30
  logger = logging.getLogger(__name__)
30
31
 
@@ -61,7 +62,7 @@ class ParentActivityTracker:
61
62
  """Ensure .htmlgraph directory exists."""
62
63
  self.graph_dir.mkdir(parents=True, exist_ok=True)
63
64
 
64
- def load(self, max_age_minutes: int = 5) -> dict:
65
+ def load(self, max_age_minutes: int = 5) -> dict[str, Any]:
65
66
  """
66
67
  Load parent activity state.
67
68
 
@@ -334,7 +335,7 @@ class DriftQueueManager:
334
335
  """Ensure .htmlgraph directory exists."""
335
336
  self.graph_dir.mkdir(parents=True, exist_ok=True)
336
337
 
337
- def load(self, max_age_hours: int = 48) -> dict:
338
+ def load(self, max_age_hours: int = 48) -> dict[str, Any]:
338
339
  """
339
340
  Load drift queue and filter by age.
340
341
 
@@ -393,7 +394,7 @@ class DriftQueueManager:
393
394
  logger.warning(f"Error loading drift queue: {e}")
394
395
  return {"activities": [], "last_classification": None}
395
396
 
396
- def save(self, queue: dict) -> None:
397
+ def save(self, queue: dict[str, Any]) -> None:
397
398
  """
398
399
  Save drift queue to file.
399
400
 
@@ -425,7 +426,9 @@ class DriftQueueManager:
425
426
  except Exception as e:
426
427
  logger.error(f"Unexpected error saving drift queue: {e}")
427
428
 
428
- def add_activity(self, activity: dict, timestamp: datetime | None = None) -> None:
429
+ def add_activity(
430
+ self, activity: dict[str, Any], timestamp: datetime | None = None
431
+ ) -> None:
429
432
  """
430
433
  Add activity to drift queue.
431
434
 
@@ -231,9 +231,10 @@ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
231
231
 
232
232
  # Get project directory and database path
233
233
  try:
234
+ from htmlgraph.config import get_database_path
235
+
234
236
  cwd = hook_input.get("cwd", os.getcwd())
235
- graph_dir = Path(cwd) / ".htmlgraph"
236
- db_path = str(graph_dir / "index.sqlite")
237
+ db_path = str(get_database_path(cwd))
237
238
 
238
239
  if not Path(db_path).exists():
239
240
  logger.warning(f"Database not found: {db_path}")