htmlgraph 0.26.4__py3-none-any.whl → 0.26.6__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 (69) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
  2. htmlgraph/__init__.py +1 -1
  3. htmlgraph/api/main.py +50 -10
  4. htmlgraph/api/templates/dashboard-redesign.html +608 -54
  5. htmlgraph/api/templates/partials/activity-feed.html +21 -0
  6. htmlgraph/api/templates/partials/features.html +81 -12
  7. htmlgraph/api/templates/partials/orchestration.html +35 -0
  8. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  9. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  10. htmlgraph/cli/__init__.py +42 -0
  11. htmlgraph/cli/__main__.py +6 -0
  12. htmlgraph/cli/analytics.py +939 -0
  13. htmlgraph/cli/base.py +660 -0
  14. htmlgraph/cli/constants.py +206 -0
  15. htmlgraph/cli/core.py +856 -0
  16. htmlgraph/cli/main.py +143 -0
  17. htmlgraph/cli/models.py +462 -0
  18. htmlgraph/cli/templates/__init__.py +1 -0
  19. htmlgraph/cli/templates/cost_dashboard.py +398 -0
  20. htmlgraph/cli/work/__init__.py +159 -0
  21. htmlgraph/cli/work/features.py +567 -0
  22. htmlgraph/cli/work/orchestration.py +675 -0
  23. htmlgraph/cli/work/sessions.py +465 -0
  24. htmlgraph/cli/work/tracks.py +485 -0
  25. htmlgraph/dashboard.html +6414 -634
  26. htmlgraph/db/schema.py +8 -3
  27. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
  28. htmlgraph/docs/README.md +2 -3
  29. htmlgraph/hooks/event_tracker.py +189 -35
  30. htmlgraph/hooks/git_commands.py +175 -0
  31. htmlgraph/hooks/orchestrator.py +137 -71
  32. htmlgraph/hooks/orchestrator_reflector.py +23 -0
  33. htmlgraph/hooks/pretooluse.py +29 -6
  34. htmlgraph/hooks/session_handler.py +28 -0
  35. htmlgraph/hooks/session_summary.py +391 -0
  36. htmlgraph/hooks/subagent_detection.py +202 -0
  37. htmlgraph/hooks/validator.py +192 -79
  38. htmlgraph/operations/__init__.py +18 -0
  39. htmlgraph/operations/initialization.py +596 -0
  40. htmlgraph/operations/initialization.py.backup +228 -0
  41. htmlgraph/orchestration/__init__.py +16 -1
  42. htmlgraph/orchestration/claude_launcher.py +185 -0
  43. htmlgraph/orchestration/command_builder.py +71 -0
  44. htmlgraph/orchestration/headless_spawner.py +72 -1332
  45. htmlgraph/orchestration/plugin_manager.py +136 -0
  46. htmlgraph/orchestration/prompts.py +137 -0
  47. htmlgraph/orchestration/spawners/__init__.py +16 -0
  48. htmlgraph/orchestration/spawners/base.py +194 -0
  49. htmlgraph/orchestration/spawners/claude.py +170 -0
  50. htmlgraph/orchestration/spawners/codex.py +442 -0
  51. htmlgraph/orchestration/spawners/copilot.py +299 -0
  52. htmlgraph/orchestration/spawners/gemini.py +478 -0
  53. htmlgraph/orchestration/subprocess_runner.py +33 -0
  54. htmlgraph/orchestration.md +563 -0
  55. htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
  56. htmlgraph/orchestrator_config.py +357 -0
  57. htmlgraph/orchestrator_mode.py +45 -12
  58. htmlgraph/transcript.py +16 -4
  59. htmlgraph-0.26.6.data/data/htmlgraph/dashboard.html +6592 -0
  60. {htmlgraph-0.26.4.dist-info → htmlgraph-0.26.6.dist-info}/METADATA +1 -1
  61. {htmlgraph-0.26.4.dist-info → htmlgraph-0.26.6.dist-info}/RECORD +67 -33
  62. {htmlgraph-0.26.4.dist-info → htmlgraph-0.26.6.dist-info}/entry_points.txt +1 -1
  63. htmlgraph/cli.py +0 -7256
  64. htmlgraph-0.26.4.data/data/htmlgraph/dashboard.html +0 -812
  65. {htmlgraph-0.26.4.data → htmlgraph-0.26.6.data}/data/htmlgraph/styles.css +0 -0
  66. {htmlgraph-0.26.4.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  67. {htmlgraph-0.26.4.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  68. {htmlgraph-0.26.4.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  69. {htmlgraph-0.26.4.dist-info → htmlgraph-0.26.6.dist-info}/WHEEL +0 -0
htmlgraph/db/schema.py CHANGED
@@ -96,6 +96,7 @@ class HtmlGraphDB:
96
96
 
97
97
  # Add missing columns with defaults
98
98
  migrations = [
99
+ ("feature_id", "TEXT"),
99
100
  ("subagent_type", "TEXT"),
100
101
  ("child_spike_count", "INTEGER DEFAULT 0"),
101
102
  ("cost_tokens", "INTEGER DEFAULT 0"),
@@ -216,6 +217,7 @@ class HtmlGraphDB:
216
217
  output_summary TEXT,
217
218
  context JSON,
218
219
  session_id TEXT NOT NULL,
220
+ feature_id TEXT,
219
221
  parent_agent_id TEXT,
220
222
  parent_event_id TEXT,
221
223
  subagent_type TEXT,
@@ -227,7 +229,8 @@ class HtmlGraphDB:
227
229
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
228
230
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
229
231
  FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ON UPDATE CASCADE,
230
- FOREIGN KEY (parent_event_id) REFERENCES agent_events(event_id) ON DELETE SET NULL ON UPDATE CASCADE
232
+ FOREIGN KEY (parent_event_id) REFERENCES agent_events(event_id) ON DELETE SET NULL ON UPDATE CASCADE,
233
+ FOREIGN KEY (feature_id) REFERENCES features(id) ON DELETE SET NULL ON UPDATE CASCADE
231
234
  )
232
235
  """)
233
236
 
@@ -513,6 +516,7 @@ class HtmlGraphDB:
513
516
  execution_duration_seconds: float = 0.0,
514
517
  subagent_type: str | None = None,
515
518
  model: str | None = None,
519
+ feature_id: str | None = None,
516
520
  ) -> bool:
517
521
  """
518
522
  Insert an agent event into the database.
@@ -553,16 +557,17 @@ class HtmlGraphDB:
553
557
  cursor.execute(
554
558
  """
555
559
  INSERT INTO agent_events
556
- (event_id, agent_id, event_type, session_id, tool_name,
560
+ (event_id, agent_id, event_type, session_id, feature_id, tool_name,
557
561
  input_summary, output_summary, context, parent_agent_id,
558
562
  parent_event_id, cost_tokens, execution_duration_seconds, subagent_type, model)
559
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
563
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
560
564
  """,
561
565
  (
562
566
  event_id,
563
567
  agent_id,
564
568
  event_type,
565
569
  session_id,
570
+ feature_id,
566
571
  tool_name,
567
572
  input_summary,
568
573
  output_summary,
@@ -32,8 +32,8 @@ result = spawner.spawn_claude(
32
32
  )
33
33
 
34
34
  result = spawner.spawn_gemini(
35
- prompt="Analyze codebase for performance issues",
36
- model="gemini-2.0-flash"
35
+ prompt="Analyze codebase for performance issues"
36
+ # model=None (default) - uses latest Gemini models including Gemini 3 preview
37
37
  )
38
38
 
39
39
  result = spawner.spawn_codex(
@@ -88,7 +88,7 @@ else:
88
88
  ```python
89
89
  result = spawner.spawn_gemini(
90
90
  prompt="Analyze this code for security issues",
91
- model="gemini-2.0-flash", # Latest Gemini model
91
+ # model=None (default) - RECOMMENDED: uses latest Gemini models
92
92
  temperature=0.7, # Creativity (0-1)
93
93
  output_json=False
94
94
  )
@@ -99,10 +99,17 @@ else:
99
99
  print(f"Error: {result.error}")
100
100
  ```
101
101
 
102
- **Gemini Models:**
103
- - `gemini-2.0-flash` - Latest, fastest
104
- - `gemini-1.5-pro` - More capable
105
- - `gemini-1.5-flash` - Faster, cheaper
102
+ **Gemini Model Selection:**
103
+ - `None` (default) - **RECOMMENDED**: CLI chooses best available model (gemini-2.5-flash-lite, gemini-3-flash-preview)
104
+ - Explicit model specification is DISCOURAGED - older models may fail with newer CLI versions
105
+
106
+ **Available models (if you must specify):**
107
+ - `gemini-2.5-flash-lite` - Fast, efficient
108
+ - `gemini-3-flash-preview` - Gemini 3 with enhanced capabilities
109
+ - `gemini-2.5-pro` - More capable, slower
110
+
111
+ **DEPRECATED models (may cause errors):**
112
+ - `gemini-2.0-flash`, `gemini-1.5-pro`, `gemini-1.5-flash` - Use `None` instead
106
113
 
107
114
  ---
108
115
 
@@ -174,8 +181,8 @@ if model == "gpt-4-turbo":
174
181
  result = spawner.spawn_codex(prompt="...", model=model)
175
182
  elif model == "claude-opus":
176
183
  result = spawner.spawn_claude(prompt="...", model=model)
177
- elif model == "gemini-2.0-flash":
178
- result = spawner.spawn_gemini(prompt="...", model=model)
184
+ elif model.startswith("gemini"):
185
+ result = spawner.spawn_gemini(prompt="...") # model=None recommended
179
186
  ```
180
187
 
181
188
  **Task Types:**
@@ -427,8 +434,8 @@ Decompose complex tasks into subtasks:
427
434
  ```python
428
435
  # Step 1: Analysis (cheap/fast)
429
436
  analysis_result = spawner.spawn_gemini(
430
- prompt="Analyze the codebase structure",
431
- model="gemini-2.0-flash"
437
+ prompt="Analyze the codebase structure"
438
+ # model=None - uses latest Gemini models (Gemini 3 preview)
432
439
  )
433
440
 
434
441
  # Step 2: Design (more capable)
@@ -559,8 +566,8 @@ def get_consensus(prompt, num_agents=3):
559
566
  prompt=prompt, model="claude-opus"
560
567
  )))
561
568
 
562
- results.append(("gemini-2.0-flash", spawner.spawn_gemini(
563
- prompt=prompt, model="gemini-2.0-flash"
569
+ results.append(("gemini", spawner.spawn_gemini(
570
+ prompt=prompt # model=None uses latest Gemini models
564
571
  )))
565
572
 
566
573
  results.append(("gpt-4-turbo", spawner.spawn_codex(
htmlgraph/docs/README.md CHANGED
@@ -198,10 +198,9 @@ result = spawner.spawn_claude(
198
198
  approval="auto"
199
199
  )
200
200
 
201
- # Spawn Gemini
201
+ # Spawn Gemini (model=None uses latest models including Gemini 3 preview)
202
202
  result = spawner.spawn_gemini(
203
- prompt="Analyze performance",
204
- model="gemini-2.0-flash"
203
+ prompt="Analyze performance"
205
204
  )
206
205
 
207
206
  # Spawn Codex
@@ -446,6 +446,77 @@ def detect_agent_from_environment() -> tuple[str, str | None]:
446
446
  return agent_id, model_name
447
447
 
448
448
 
449
+ def detect_subagent_context_from_database(
450
+ db: HtmlGraphDB, current_session_id: str
451
+ ) -> tuple[str | None, str | None, str | None]:
452
+ """
453
+ Detect if we're in a subagent context by checking for active task_delegation events.
454
+
455
+ This is the DATABASE-BASED approach to subagent detection, which is necessary because
456
+ environment variables set by PreToolUse hooks in the parent process do NOT propagate
457
+ to subagent processes spawned by Claude Code's Task() tool.
458
+
459
+ Strategy:
460
+ 1. Query for task_delegation events with status='started' (not yet completed)
461
+ 2. If found within the last 5 minutes, we're likely in a subagent context
462
+ 3. Return the subagent_type and parent session info
463
+
464
+ Args:
465
+ db: HtmlGraphDB instance
466
+ current_session_id: The session_id from hook_input (Claude Code's session ID)
467
+
468
+ Returns:
469
+ Tuple of (subagent_type, parent_session_id, parent_event_id)
470
+ All None if not in subagent context
471
+ """
472
+ try:
473
+ if db.connection is None:
474
+ return None, None, None
475
+
476
+ cursor = db.connection.cursor()
477
+
478
+ # Query for active task_delegation events (started but not completed)
479
+ # that were created in the last 5 minutes
480
+ # Exclude events that belong to the current session (those are parent context, not subagent)
481
+ cursor.execute(
482
+ """
483
+ SELECT event_id, session_id, subagent_type, timestamp
484
+ FROM agent_events
485
+ WHERE event_type = 'task_delegation'
486
+ AND status = 'started'
487
+ AND timestamp >= datetime('now', '-5 minutes')
488
+ AND session_id != ?
489
+ ORDER BY timestamp DESC
490
+ LIMIT 1
491
+ """,
492
+ (current_session_id,),
493
+ )
494
+ row = cursor.fetchone()
495
+
496
+ if row:
497
+ parent_event_id = row[0]
498
+ parent_session_id = row[1]
499
+ subagent_type = row[2] or "general-purpose"
500
+
501
+ print(
502
+ f"Debug: Detected subagent context from database: "
503
+ f"type={subagent_type}, parent_session={parent_session_id}, "
504
+ f"parent_event={parent_event_id}",
505
+ file=sys.stderr,
506
+ )
507
+
508
+ return subagent_type, parent_session_id, parent_event_id
509
+
510
+ return None, None, None
511
+
512
+ except Exception as e:
513
+ print(
514
+ f"Debug: Error detecting subagent context from database: {e}",
515
+ file=sys.stderr,
516
+ )
517
+ return None, None, None
518
+
519
+
449
520
  def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
450
521
  """Extract file paths from tool input based on tool type."""
451
522
  paths = []
@@ -543,6 +614,7 @@ def record_event_to_sqlite(
543
614
  agent_id: str | None = None,
544
615
  subagent_type: str | None = None,
545
616
  model: str | None = None,
617
+ feature_id: str | None = None,
546
618
  ) -> str | None:
547
619
  """
548
620
  Record a tool call event to SQLite database for dashboard queries.
@@ -559,6 +631,7 @@ def record_event_to_sqlite(
559
631
  agent_id: Agent identifier (optional)
560
632
  subagent_type: Subagent type for Task delegations (optional)
561
633
  model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
634
+ feature_id: Feature ID for attribution (optional)
562
635
 
563
636
  Returns:
564
637
  event_id if successful, None otherwise
@@ -603,6 +676,7 @@ def record_event_to_sqlite(
603
676
  cost_tokens=0,
604
677
  subagent_type=subagent_type,
605
678
  model=model,
679
+ feature_id=feature_id,
606
680
  )
607
681
 
608
682
  if success:
@@ -710,19 +784,43 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
710
784
  detected_model = model_from_input
711
785
 
712
786
  active_session = None
787
+ is_subagent_session = False
788
+ parent_event_id_for_session = None
789
+
790
+ # Get session_id from hook_input first (Claude Code provides this)
791
+ hook_session_id = hook_input.get("session_id") or hook_input.get("sessionId")
713
792
 
714
- # Check if we're in a subagent context (environment variables set by spawner router)
715
- # This MUST be checked BEFORE using get_active_session() to avoid attributing
716
- # subagent events to the parent orchestrator session
793
+ # Check if we're in a subagent context using multiple methods:
794
+ # Method 1: Environment variables (works if Task() spawner sets them)
717
795
  subagent_type = os.environ.get("HTMLGRAPH_SUBAGENT_TYPE")
718
796
  parent_session_id = os.environ.get("HTMLGRAPH_PARENT_SESSION")
719
797
 
798
+ # Method 2: Database-based detection (CRITICAL for Claude Code Task() tool)
799
+ # Environment variables don't propagate to subagent processes, so we check
800
+ # the database for active task_delegation events
801
+ if not subagent_type and db and hook_session_id:
802
+ db_subagent_type, db_parent_session_id, db_parent_event_id = (
803
+ detect_subagent_context_from_database(db, hook_session_id)
804
+ )
805
+ if db_subagent_type:
806
+ subagent_type = db_subagent_type
807
+ parent_session_id = db_parent_session_id
808
+ parent_event_id_for_session = db_parent_event_id
809
+ print(
810
+ f"Debug: Using database-based subagent detection: "
811
+ f"type={subagent_type}, parent={parent_session_id}",
812
+ file=sys.stderr,
813
+ )
814
+
720
815
  if subagent_type and parent_session_id:
721
- # We're in a subagent - create or get subagent session
722
- # Use deterministic session ID based on parent + subagent type
723
- subagent_session_id = f"{parent_session_id}-{subagent_type}"
816
+ # We're in a subagent context
817
+ is_subagent_session = True
724
818
 
725
- # Check if subagent session already exists
819
+ # Use Claude's session_id (hook_session_id) as the subagent session ID
820
+ # This ensures events are properly tracked to this session
821
+ subagent_session_id = hook_session_id or f"{parent_session_id}-{subagent_type}"
822
+
823
+ # Check if session already exists in our system
726
824
  existing = manager.session_converter.load(subagent_session_id)
727
825
  if existing:
728
826
  active_session = existing
@@ -742,7 +840,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
742
840
  )
743
841
  print(
744
842
  f"Debug: Created subagent session: {subagent_session_id} "
745
- f"(parent: {parent_session_id})",
843
+ f"(parent: {parent_session_id}, is_subagent=True)",
746
844
  file=sys.stderr,
747
845
  )
748
846
  except Exception as e:
@@ -755,18 +853,38 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
755
853
  # Override detected agent for subagent context
756
854
  detected_agent = f"{subagent_type}-spawner"
757
855
  else:
758
- # Normal orchestrator/parent context - use global session cache
759
- active_session = manager.get_active_session()
760
- if not active_session:
761
- # No active HtmlGraph session yet; start one (stable internal id).
762
- try:
763
- active_session = manager.start_session(
764
- session_id=None,
765
- agent=detected_agent,
766
- title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
767
- )
768
- except Exception:
769
- return {"continue": True}
856
+ # Normal orchestrator/parent context
857
+ # CRITICAL: Use session_id from hook_input (Claude Code provides this)
858
+ # Only fall back to manager.get_active_session() if not in hook_input
859
+ if hook_session_id:
860
+ # Claude Code provided session_id - use it directly
861
+ # Check if session already exists
862
+ existing = manager.session_converter.load(hook_session_id)
863
+ if existing:
864
+ active_session = existing
865
+ else:
866
+ # Create new session with Claude's session_id
867
+ try:
868
+ active_session = manager.start_session(
869
+ session_id=hook_session_id,
870
+ agent=detected_agent,
871
+ title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
872
+ )
873
+ except Exception:
874
+ return {"continue": True}
875
+ else:
876
+ # Fallback: No session_id in hook_input - use global session cache
877
+ active_session = manager.get_active_session()
878
+ if not active_session:
879
+ # No active HtmlGraph session yet; start one
880
+ try:
881
+ active_session = manager.start_session(
882
+ session_id=None,
883
+ agent=detected_agent,
884
+ title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
885
+ )
886
+ except Exception:
887
+ return {"continue": True}
770
888
 
771
889
  active_session_id = active_session.id
772
890
 
@@ -786,10 +904,13 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
786
904
  except Exception:
787
905
  return default
788
906
 
907
+ # Use is_subagent_session flag from our detection, not just from session object
789
908
  is_subagent_raw = safe_getattr(active_session, "is_subagent", False)
790
- is_subagent = (
909
+ is_subagent_from_obj = (
791
910
  bool(is_subagent_raw) if isinstance(is_subagent_raw, bool) else False
792
911
  )
912
+ # Prefer our detection (is_subagent_session) over object attribute
913
+ final_is_subagent = is_subagent_session or is_subagent_from_obj
793
914
 
794
915
  transcript_id = safe_getattr(active_session, "transcript_id", None)
795
916
  transcript_path = safe_getattr(active_session, "transcript_path", None)
@@ -799,14 +920,35 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
799
920
  if transcript_path is not None and not isinstance(transcript_path, str):
800
921
  transcript_path = None
801
922
 
923
+ # Get parent_session_id from our detection or from session object
924
+ final_parent_session_id = parent_session_id or safe_getattr(
925
+ active_session, "parent_session_id", None
926
+ )
927
+ if final_parent_session_id is not None and not isinstance(
928
+ final_parent_session_id, str
929
+ ):
930
+ final_parent_session_id = None
931
+
802
932
  db.insert_session(
803
933
  session_id=active_session_id,
804
934
  agent_assigned=safe_getattr(active_session, "agent", None)
805
935
  or detected_agent,
806
- is_subagent=is_subagent,
936
+ parent_session_id=final_parent_session_id,
937
+ parent_event_id=parent_event_id_for_session,
938
+ is_subagent=final_is_subagent,
807
939
  transcript_id=transcript_id,
808
940
  transcript_path=transcript_path,
809
941
  )
942
+
943
+ # Log subagent session creation for debugging
944
+ if final_is_subagent:
945
+ print(
946
+ f"Debug: Inserted subagent session to SQLite: "
947
+ f"session_id={active_session_id}, is_subagent=True, "
948
+ f"parent_session={final_parent_session_id}, "
949
+ f"parent_event={parent_event_id_for_session}",
950
+ file=sys.stderr,
951
+ )
810
952
  except Exception as e:
811
953
  # Session may already exist, that's OK - continue
812
954
  print(
@@ -818,7 +960,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
818
960
  if hook_type == "Stop":
819
961
  # Session is ending - track stop event
820
962
  try:
821
- manager.track_activity(
963
+ result = manager.track_activity(
822
964
  session_id=active_session_id, tool="Stop", summary="Agent stopped"
823
965
  )
824
966
 
@@ -833,6 +975,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
833
975
  is_error=False,
834
976
  agent_id=detected_agent,
835
977
  model=detected_model,
978
+ feature_id=result.feature_id if result else None,
836
979
  )
837
980
  except Exception as e:
838
981
  print(f"Warning: Could not track stop: {e}", file=sys.stderr)
@@ -846,7 +989,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
846
989
  preview += "..."
847
990
 
848
991
  try:
849
- manager.track_activity(
992
+ result = manager.track_activity(
850
993
  session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
851
994
  )
852
995
 
@@ -863,6 +1006,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
863
1006
  is_error=False,
864
1007
  agent_id=detected_agent,
865
1008
  model=detected_model,
1009
+ feature_id=result.feature_id if result else None,
866
1010
  )
867
1011
 
868
1012
  except Exception as e:
@@ -915,20 +1059,29 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
915
1059
  warning_threshold = drift_settings.get("warning_threshold") or 0.7
916
1060
  auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
917
1061
 
918
- # Determine parent activity context using database-only lookup
1062
+ # Determine parent activity context using multiple sources
919
1063
  parent_activity_id = None
920
1064
 
921
- # Check environment variable FIRST for cross-process parent linking
1065
+ # Priority 1: If we're in a subagent context, use the parent event from
1066
+ # the task_delegation that spawned us (detected from database)
1067
+ if is_subagent_session and parent_event_id_for_session:
1068
+ parent_activity_id = parent_event_id_for_session
1069
+ print(
1070
+ f"Debug: Using parent_event from subagent detection: {parent_activity_id}",
1071
+ file=sys.stderr,
1072
+ )
1073
+ # Priority 2: Check environment variable for cross-process parent linking
922
1074
  # This is set by PreToolUse hook when Task() spawns a subagent
923
- env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
924
- "HTMLGRAPH_PARENT_QUERY_EVENT"
925
- )
926
- if env_parent:
927
- parent_activity_id = env_parent
928
- # Query database for most recent UserQuery event as parent
929
- # Database is the single source of truth for parent-child linking
930
- elif db:
931
- parent_activity_id = get_parent_user_query(db, active_session_id)
1075
+ else:
1076
+ env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
1077
+ "HTMLGRAPH_PARENT_QUERY_EVENT"
1078
+ )
1079
+ if env_parent:
1080
+ parent_activity_id = env_parent
1081
+ # Priority 3: Query database for most recent UserQuery event as parent
1082
+ # Database is the single source of truth for parent-child linking
1083
+ elif db:
1084
+ parent_activity_id = get_parent_user_query(db, active_session_id)
932
1085
 
933
1086
  # Track the activity
934
1087
  nudge = None
@@ -963,6 +1116,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
963
1116
  agent_id=detected_agent,
964
1117
  subagent_type=task_subagent_type,
965
1118
  model=detected_model,
1119
+ feature_id=result.feature_id if result else None,
966
1120
  )
967
1121
 
968
1122
  # If this was a Task() delegation, also record to agent_collaboration
@@ -0,0 +1,175 @@
1
+ """
2
+ Shared git command classification for hooks.
3
+
4
+ Provides consistent rules for which git operations are allowed vs require delegation.
5
+ Used by both validator.py and orchestrator.py to ensure consistent behavior.
6
+ """
7
+
8
+ from typing import Literal
9
+
10
+ GitCommandType = Literal["read", "write", "unknown"]
11
+
12
+ # Read-only git commands (safe to allow)
13
+ GIT_READ_ONLY = {
14
+ "status",
15
+ "log",
16
+ "diff",
17
+ "show",
18
+ "branch", # When used with -l or --list or no args
19
+ "reflog",
20
+ "ls-files",
21
+ "ls-remote",
22
+ "rev-parse",
23
+ "describe",
24
+ "tag", # When used without -a/-d or with -l
25
+ "remote", # When used with -v or show
26
+ }
27
+
28
+ # Write operations (require delegation)
29
+ GIT_WRITE_OPS = {
30
+ "add",
31
+ "commit",
32
+ "push",
33
+ "pull",
34
+ "fetch",
35
+ "merge",
36
+ "rebase",
37
+ "cherry-pick",
38
+ "reset",
39
+ "checkout", # Can modify working tree
40
+ "switch",
41
+ "restore",
42
+ "rm",
43
+ "mv",
44
+ "clean",
45
+ "stash",
46
+ }
47
+
48
+
49
+ def classify_git_command(command: str) -> GitCommandType:
50
+ """
51
+ Classify a git command as read, write, or unknown.
52
+
53
+ Args:
54
+ command: Full command string (e.g., "git status" or "git add .")
55
+
56
+ Returns:
57
+ "read", "write", or "unknown"
58
+
59
+ Examples:
60
+ >>> classify_git_command("git status")
61
+ "read"
62
+ >>> classify_git_command("git commit -m 'msg'")
63
+ "write"
64
+ >>> classify_git_command("git log --oneline")
65
+ "read"
66
+ >>> classify_git_command("git add .")
67
+ "write"
68
+ """
69
+ # Strip "git" prefix and get subcommand
70
+ parts = command.strip().split()
71
+ if not parts or parts[0] != "git":
72
+ return "unknown"
73
+
74
+ if len(parts) < 2:
75
+ return "unknown"
76
+
77
+ subcommand = parts[1]
78
+
79
+ # Check write operations first (more critical)
80
+ if subcommand in GIT_WRITE_OPS:
81
+ return "write"
82
+
83
+ # Special handling for branch (flag-based classification)
84
+ if subcommand == "branch":
85
+ # branch with -d or -D flags is write, otherwise read
86
+ if len(parts) > 2:
87
+ flags = " ".join(parts[2:])
88
+ if (
89
+ " -d " in flags
90
+ or " -D " in flags
91
+ or flags.startswith("-d ")
92
+ or flags.startswith("-D ")
93
+ ):
94
+ return "write"
95
+ return "read"
96
+
97
+ # Special handling for tag (flag-based classification)
98
+ if subcommand == "tag":
99
+ # tag with -a (annotated) or -d (delete) flags is write, otherwise read
100
+ if len(parts) > 2:
101
+ flags = " ".join(parts[2:])
102
+ if (
103
+ " -a " in flags
104
+ or " -d " in flags
105
+ or flags.startswith("-a ")
106
+ or flags.startswith("-d ")
107
+ ):
108
+ return "write"
109
+ return "read"
110
+
111
+ # Then check read-only
112
+ if subcommand in GIT_READ_ONLY:
113
+ return "read"
114
+
115
+ # Unknown git command
116
+ return "unknown"
117
+
118
+
119
+ def should_allow_git_command(command: str) -> bool:
120
+ """
121
+ Check if a git command should be allowed without delegation.
122
+
123
+ Returns:
124
+ True if command is read-only (safe), False if write (delegate)
125
+
126
+ Examples:
127
+ >>> should_allow_git_command("git status")
128
+ True
129
+ >>> should_allow_git_command("git commit -m 'msg'")
130
+ False
131
+ >>> should_allow_git_command("git diff HEAD~1")
132
+ True
133
+ >>> should_allow_git_command("git push origin main")
134
+ False
135
+ """
136
+ cmd_type = classify_git_command(command)
137
+ return cmd_type == "read"
138
+
139
+
140
+ def get_git_delegation_reason(command: str) -> str:
141
+ """
142
+ Get delegation reason for git write operations.
143
+
144
+ Args:
145
+ command: Git command that requires delegation
146
+
147
+ Returns:
148
+ Human-readable reason explaining why delegation is required
149
+ """
150
+ parts = command.strip().split()
151
+ if len(parts) < 2:
152
+ return "Git write operations should be delegated to Skill('.claude-plugin:copilot')"
153
+
154
+ subcommand = parts[1]
155
+
156
+ if subcommand in ["commit", "add", "push"]:
157
+ return (
158
+ f"Git {subcommand} is a write operation and should be delegated to "
159
+ f"Skill('.claude-plugin:copilot') for proper Git workflow management"
160
+ )
161
+ elif subcommand in ["merge", "rebase", "cherry-pick"]:
162
+ return (
163
+ f"Git {subcommand} is a complex merge operation and should be delegated to "
164
+ f"Skill('.claude-plugin:copilot') for safe execution"
165
+ )
166
+ elif subcommand in ["reset", "checkout", "restore"]:
167
+ return (
168
+ f"Git {subcommand} can modify working tree and should be delegated to "
169
+ f"Skill('.claude-plugin:copilot') for safe execution"
170
+ )
171
+ else:
172
+ return (
173
+ f"Git {subcommand} is a write operation and should be delegated to "
174
+ f"Skill('.claude-plugin:copilot')"
175
+ )