htmlgraph 0.25.0__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 (32) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/main.py +193 -45
  3. htmlgraph/api/templates/dashboard.html +11 -0
  4. htmlgraph/api/templates/partials/activity-feed.html +458 -8
  5. htmlgraph/dashboard.html +41 -0
  6. htmlgraph/db/schema.py +254 -4
  7. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  8. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  9. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  10. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  11. htmlgraph/hooks/concurrent_sessions.py +208 -0
  12. htmlgraph/hooks/context.py +57 -10
  13. htmlgraph/hooks/drift_handler.py +24 -20
  14. htmlgraph/hooks/event_tracker.py +204 -177
  15. htmlgraph/hooks/orchestrator.py +6 -4
  16. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  17. htmlgraph/hooks/pretooluse.py +3 -6
  18. htmlgraph/hooks/prompt_analyzer.py +14 -25
  19. htmlgraph/hooks/session_handler.py +123 -69
  20. htmlgraph/hooks/state_manager.py +7 -4
  21. htmlgraph/hooks/validator.py +15 -11
  22. htmlgraph/orchestration/headless_spawner.py +322 -15
  23. htmlgraph/orchestration/live_events.py +377 -0
  24. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/dashboard.html +41 -0
  25. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +32 -27
  27. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
  28. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  29. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  30. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  31. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
  32. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/entry_points.txt +0 -0
@@ -5,12 +5,17 @@ Reusable event tracking logic for hook integrations.
5
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
10
 
11
11
  Events are recorded to both:
12
12
  - HTML files via SessionManager (existing)
13
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
14
19
  """
15
20
 
16
21
  import json
@@ -20,7 +25,7 @@ import subprocess
20
25
  import sys
21
26
  from datetime import datetime, timedelta, timezone
22
27
  from pathlib import Path
23
- from typing import Any, cast
28
+ from typing import Any, cast # noqa: F401
24
29
 
25
30
  from htmlgraph.db.schema import HtmlGraphDB
26
31
  from htmlgraph.ids import generate_id
@@ -28,18 +33,50 @@ from htmlgraph.session_manager import SessionManager
28
33
 
29
34
  # Drift classification queue (stored in session directory)
30
35
  DRIFT_QUEUE_FILE = "drift-queue.json"
31
- # Active parent activity tracker (for Skill/Task invocations)
32
- PARENT_ACTIVITY_FILE = "parent-activity.json"
33
- # UserQuery event tracker (for parent-child linking) - DEPRECATED (use session-scoped files)
34
- USER_QUERY_EVENT_FILE = "user-query-event.json"
35
36
 
36
37
 
37
- def get_user_query_event_file(graph_dir: Path, session_id: str) -> Path:
38
- """Get the session-scoped user query event file path."""
39
- return graph_dir / f"user-query-event-{session_id}.json"
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
40
77
 
41
78
 
42
- def load_drift_config() -> dict:
79
+ def load_drift_config() -> dict[str, Any]:
43
80
  """Load drift configuration from plugin config or project .claude directory."""
44
81
  config_paths = [
45
82
  Path(__file__).parent.parent.parent.parent.parent
@@ -80,110 +117,46 @@ def load_drift_config() -> dict:
80
117
  }
81
118
 
82
119
 
83
- def load_parent_activity(graph_dir: Path) -> dict:
84
- """Load the active parent activity state."""
85
- path = graph_dir / PARENT_ACTIVITY_FILE
86
- if path.exists():
87
- try:
88
- with open(path) as f:
89
- data = cast(dict[Any, Any], json.load(f))
90
- # Clean up stale parent activities (older than 5 minutes)
91
- if data.get("timestamp"):
92
- ts = datetime.fromisoformat(data["timestamp"])
93
- # Use timezone-aware datetime for comparison
94
- now = datetime.now(timezone.utc)
95
- # Ensure ts is timezone-aware (handle both formats)
96
- if ts.tzinfo is None:
97
- ts = ts.replace(tzinfo=timezone.utc)
98
- if now - ts > timedelta(minutes=5):
99
- return {}
100
- return data
101
- except Exception:
102
- pass
103
- return {}
104
-
105
-
106
- def save_parent_activity(
107
- graph_dir: Path, parent_id: str | None, tool: str | None = None
108
- ) -> None:
109
- """Save the active parent activity state."""
110
- path = graph_dir / PARENT_ACTIVITY_FILE
111
- try:
112
- if parent_id:
113
- with open(path, "w") as f:
114
- json.dump(
115
- {
116
- "parent_id": parent_id,
117
- "tool": tool,
118
- "timestamp": datetime.now(timezone.utc).isoformat(),
119
- },
120
- f,
121
- )
122
- else:
123
- # Clear parent activity
124
- path.unlink(missing_ok=True)
125
- except Exception as e:
126
- print(f"Warning: Could not save parent activity: {e}", file=sys.stderr)
127
-
128
-
129
- def load_user_query_event(graph_dir: Path, session_id: str) -> str | None:
120
+ def get_parent_user_query(db: HtmlGraphDB, session_id: str) -> str | None:
130
121
  """
131
- Load the active UserQuery event ID for parent-child linking.
132
-
133
- Session-scoped: Each session maintains its own parent context via
134
- user-query-event-{SESSION_ID}.json to support multiple concurrent
135
- Claude windows in the same project.
136
- """
137
- path = get_user_query_event_file(graph_dir, session_id)
138
- if path.exists():
139
- try:
140
- with open(path) as f:
141
- data = cast(dict[Any, Any], json.load(f))
142
- # Clean up stale UserQuery events (older than 10 minutes)
143
- if data.get("timestamp"):
144
- ts = datetime.fromisoformat(data["timestamp"])
145
- now = datetime.now(timezone.utc)
146
- if ts.tzinfo is None:
147
- ts = ts.replace(tzinfo=timezone.utc)
148
- # UserQuery events expire after 10 minutes (conversation turn boundary)
149
- # This allows tool calls up to 10 minutes after a user query to be linked as children
150
- if now - ts > timedelta(minutes=10):
151
- return None
152
- return data.get("event_id")
153
- except Exception:
154
- pass
155
- return None
122
+ Get the most recent UserQuery event_id for this session from database.
156
123
 
124
+ This is the primary method for parent-child event linking.
125
+ Database is the single source of truth - no file-based state.
157
126
 
158
- def save_user_query_event(
159
- graph_dir: Path, session_id: str, event_id: str | None
160
- ) -> None:
161
- """
162
- Save the active UserQuery event ID for parent-child linking.
127
+ Args:
128
+ db: HtmlGraphDB instance
129
+ session_id: Session ID to query
163
130
 
164
- Session-scoped: Each session maintains its own parent context via
165
- user-query-event-{SESSION_ID}.json to support multiple concurrent
166
- Claude windows in the same project.
131
+ Returns:
132
+ event_id of the most recent UserQuery event, or None if not found
167
133
  """
168
- path = get_user_query_event_file(graph_dir, session_id)
169
134
  try:
170
- if event_id:
171
- with open(path, "w") as f:
172
- json.dump(
173
- {
174
- "event_id": event_id,
175
- "timestamp": datetime.now(timezone.utc).isoformat(),
176
- },
177
- f,
178
- )
179
- else:
180
- # Clear UserQuery event
181
- 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
182
151
  except Exception as e:
183
- print(f"Warning: Could not save UserQuery event: {e}", file=sys.stderr)
152
+ print(
153
+ f"Debug: Database query for UserQuery failed: {e}",
154
+ file=sys.stderr,
155
+ )
156
+ return None
184
157
 
185
158
 
186
- 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]:
187
160
  """
188
161
  Load the drift queue from file and clean up stale entries.
189
162
 
@@ -232,7 +205,7 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
232
205
  return {"activities": [], "last_classification": None}
233
206
 
234
207
 
235
- def save_drift_queue(graph_dir: Path, queue: dict) -> None:
208
+ def save_drift_queue(graph_dir: Path, queue: dict[str, Any]) -> None:
236
209
  """Save the drift queue to file."""
237
210
  queue_path = graph_dir / DRIFT_QUEUE_FILE
238
211
  try:
@@ -266,7 +239,9 @@ def clear_drift_queue_activities(graph_dir: Path) -> None:
266
239
  print(f"Warning: Could not clear drift queue: {e}", file=sys.stderr)
267
240
 
268
241
 
269
- 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]:
270
245
  """Add a high-drift activity to the queue."""
271
246
  max_age_hours = config.get("queue", {}).get("max_age_hours", 48)
272
247
  queue = load_drift_queue(graph_dir, max_age_hours=max_age_hours)
@@ -289,7 +264,9 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
289
264
  return queue
290
265
 
291
266
 
292
- 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:
293
270
  """Check if we should trigger auto-classification."""
294
271
  drift_config = config.get("drift_detection", {})
295
272
 
@@ -316,7 +293,7 @@ def should_trigger_classification(queue: dict, config: dict) -> bool:
316
293
  return True
317
294
 
318
295
 
319
- def build_classification_prompt(queue: dict, feature_id: str) -> str:
296
+ def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
320
297
  """Build the prompt for the classification agent."""
321
298
  activities = queue.get("activities", [])
322
299
 
@@ -368,41 +345,108 @@ def resolve_project_path(cwd: str | None = None) -> str:
368
345
  return start_dir
369
346
 
370
347
 
371
- def detect_agent_from_environment() -> str:
348
+ def detect_model_from_hook_input(hook_input: dict[str, Any]) -> str | None:
372
349
  """
373
- Detect the agent/model name from environment variables.
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
374
356
 
375
- Checks multiple environment variables in order of priority:
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:
376
397
  1. HTMLGRAPH_AGENT - Explicit agent name set by user
377
398
  2. HTMLGRAPH_SUBAGENT_TYPE - For subagent sessions
378
- 3. CLAUDE_MODEL - Model name if exposed by Claude Code
379
- 4. ANTHROPIC_MODEL - Alternative model env var
380
- 5. HTMLGRAPH_PARENT_AGENT - Parent agent context
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
381
404
 
382
405
  Falls back to 'claude-code' if no environment variable is set.
383
406
 
384
407
  Returns:
385
- Agent/model identifier string
408
+ Tuple of (agent_id, model_name). Model name may be None if not detected.
386
409
  """
387
- # Check environment variables in priority order
388
- env_vars = [
410
+ # Check for explicit agent name first
411
+ agent_id = None
412
+ env_vars_agent = [
389
413
  "HTMLGRAPH_AGENT",
390
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",
391
428
  "CLAUDE_MODEL",
392
429
  "ANTHROPIC_MODEL",
393
- "HTMLGRAPH_PARENT_AGENT",
394
430
  ]
395
431
 
396
- for var in env_vars:
432
+ for var in env_vars_model:
397
433
  value = os.environ.get(var)
398
434
  if value and value.strip():
399
- return 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"
400
445
 
401
- # Default fallback
402
- return "claude-code"
446
+ return agent_id, model_name
403
447
 
404
448
 
405
- def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
449
+ def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
406
450
  """Extract file paths from tool input based on tool type."""
407
451
  paths = []
408
452
 
@@ -427,7 +471,7 @@ def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
427
471
 
428
472
 
429
473
  def format_tool_summary(
430
- 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
431
475
  ) -> str:
432
476
  """Format a human-readable summary of the tool call."""
433
477
  if tool_name == "Read":
@@ -491,13 +535,14 @@ def record_event_to_sqlite(
491
535
  db: HtmlGraphDB,
492
536
  session_id: str,
493
537
  tool_name: str,
494
- tool_input: dict,
495
- tool_response: dict,
538
+ tool_input: dict[str, Any],
539
+ tool_response: dict[str, Any],
496
540
  is_error: bool,
497
541
  file_paths: list[str] | None = None,
498
542
  parent_event_id: str | None = None,
499
543
  agent_id: str | None = None,
500
544
  subagent_type: str | None = None,
545
+ model: str | None = None,
501
546
  ) -> str | None:
502
547
  """
503
548
  Record a tool call event to SQLite database for dashboard queries.
@@ -513,6 +558,7 @@ def record_event_to_sqlite(
513
558
  parent_event_id: Parent event ID if this is a child event
514
559
  agent_id: Agent identifier (optional)
515
560
  subagent_type: Subagent type for Task delegations (optional)
561
+ model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
516
562
 
517
563
  Returns:
518
564
  event_id if successful, None otherwise
@@ -523,7 +569,7 @@ def record_event_to_sqlite(
523
569
 
524
570
  # Build output summary from tool response
525
571
  output_summary = ""
526
- if isinstance(tool_response, dict):
572
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
527
573
  if is_error:
528
574
  output_summary = tool_response.get("error", "error")[:200]
529
575
  else:
@@ -556,6 +602,7 @@ def record_event_to_sqlite(
556
602
  parent_event_id=parent_event_id,
557
603
  cost_tokens=0,
558
604
  subagent_type=subagent_type,
605
+ model=model,
559
606
  )
560
607
 
561
608
  if success:
@@ -573,7 +620,7 @@ def record_delegation_to_sqlite(
573
620
  from_agent: str,
574
621
  to_agent: str,
575
622
  task_description: str,
576
- task_input: dict,
623
+ task_input: dict[str, Any],
577
624
  ) -> str | None:
578
625
  """
579
626
  Record a Task() delegation to agent_collaboration table.
@@ -619,7 +666,7 @@ def record_delegation_to_sqlite(
619
666
  return None
620
667
 
621
668
 
622
- def track_event(hook_type: str, hook_input: dict) -> dict:
669
+ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
623
670
  """
624
671
  Track a hook event and log it to HtmlGraph (both HTML files and SQLite).
625
672
 
@@ -652,8 +699,13 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
652
699
  print(f"Warning: Could not initialize SQLite database: {e}", file=sys.stderr)
653
700
  # Continue without SQLite (graceful degradation)
654
701
 
655
- # Detect agent from environment
656
- detected_agent = detect_agent_from_environment()
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
657
709
 
658
710
  # Get active session ID
659
711
  active_session = manager.get_active_session()
@@ -732,6 +784,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
732
784
  tool_response={"content": "Agent stopped"},
733
785
  is_error=False,
734
786
  agent_id=detected_agent,
787
+ model=detected_model,
735
788
  )
736
789
  except Exception as e:
737
790
  print(f"Warning: Could not track stop: {e}", file=sys.stderr)
@@ -749,10 +802,11 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
749
802
  session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
750
803
  )
751
804
 
752
- # Record to SQLite if available and capture event_id for parent-child linking
753
- user_query_event_id = None
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()
754
808
  if db:
755
- user_query_event_id = record_event_to_sqlite(
809
+ record_event_to_sqlite(
756
810
  db=db,
757
811
  session_id=active_session_id,
758
812
  tool_name="UserQuery",
@@ -760,14 +814,9 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
760
814
  tool_response={"content": "Query received"},
761
815
  is_error=False,
762
816
  agent_id=detected_agent,
817
+ model=detected_model,
763
818
  )
764
819
 
765
- # Store the UserQuery event_id for subsequent tool calls to use as parent
766
- if user_query_event_id:
767
- save_user_query_event(
768
- graph_dir, active_session_id, user_query_event_id
769
- )
770
-
771
820
  except Exception as e:
772
821
  print(f"Warning: Could not track query: {e}", file=sys.stderr)
773
822
  return {"continue": True}
@@ -792,7 +841,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
792
841
  summary = format_tool_summary(tool_name, tool_input_data, tool_response)
793
842
 
794
843
  # Determine success
795
- if isinstance(tool_response, dict):
844
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
796
845
  success_field = tool_response.get("success")
797
846
  if isinstance(success_field, bool):
798
847
  is_error = not success_field
@@ -818,37 +867,20 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
818
867
  warning_threshold = drift_settings.get("warning_threshold") or 0.7
819
868
  auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
820
869
 
821
- # Determine parent activity context
822
- parent_activity_state = load_parent_activity(graph_dir)
870
+ # Determine parent activity context using database-only lookup
823
871
  parent_activity_id = None
824
872
 
825
- # Tools that create parent context (Skill, Task)
826
- parent_tools = {"Skill", "Task"}
827
-
828
- # If this is a parent tool invocation, save its context for subsequent activities
829
- if tool_name in parent_tools:
830
- # We'll get the event_id after tracking, so we use a placeholder for now
831
- # The actual parent_id will be set below after we track the activity
832
- is_parent_tool = True
833
- else:
834
- is_parent_tool = False
835
- # Check environment variable FIRST for cross-process parent linking
836
- # This is set by PreToolUse hook when Task() spawns a subagent
837
- env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT")
838
- if env_parent:
839
- parent_activity_id = env_parent
840
- # Next, check for UserQuery event as parent (for prompt-based grouping)
841
- # UserQuery takes priority over parent_activity_json to ensure each conversation turn
842
- # has its tool calls properly grouped together
843
- else:
844
- user_query_event_id = load_user_query_event(
845
- graph_dir, active_session_id
846
- )
847
- if user_query_event_id:
848
- parent_activity_id = user_query_event_id
849
- # Fall back to parent-activity.json only if no UserQuery event (backward compatibility)
850
- elif parent_activity_state.get("parent_id"):
851
- 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)
852
884
 
853
885
  # Track the activity
854
886
  nudge = None
@@ -882,6 +914,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
882
914
  parent_event_id=parent_activity_id, # Link to parent event
883
915
  agent_id=detected_agent,
884
916
  subagent_type=task_subagent_type,
917
+ model=detected_model,
885
918
  )
886
919
 
887
920
  # If this was a Task() delegation, also record to agent_collaboration
@@ -897,12 +930,6 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
897
930
  task_input=tool_input_data,
898
931
  )
899
932
 
900
- # If this was a parent tool, save its ID for subsequent activities
901
- if is_parent_tool and result:
902
- save_parent_activity(graph_dir, result.id, tool_name)
903
- # If this tool finished a parent context (e.g., Task completed), clear it
904
- # We'll clear parent context after 5 minutes automatically (see load_parent_activity)
905
-
906
933
  # Check for drift and handle accordingly
907
934
  # Skip drift detection for child activities (they inherit parent's context)
908
935
  if result and hasattr(result, "drift_score") and not parent_activity_id:
@@ -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)