htmlgraph 0.24.2__py3-none-any.whl → 0.25.0__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 (103) hide show
  1. htmlgraph/__init__.py +20 -1
  2. htmlgraph/agent_detection.py +26 -10
  3. htmlgraph/analytics/cross_session.py +4 -3
  4. htmlgraph/analytics/work_type.py +52 -16
  5. htmlgraph/analytics_index.py +51 -19
  6. htmlgraph/api/__init__.py +3 -0
  7. htmlgraph/api/main.py +2115 -0
  8. htmlgraph/api/static/htmx.min.js +1 -0
  9. htmlgraph/api/static/style-redesign.css +1344 -0
  10. htmlgraph/api/static/style.css +1079 -0
  11. htmlgraph/api/templates/dashboard-redesign.html +812 -0
  12. htmlgraph/api/templates/dashboard.html +783 -0
  13. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  14. htmlgraph/api/templates/partials/activity-feed.html +570 -0
  15. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  16. htmlgraph/api/templates/partials/agents.html +317 -0
  17. htmlgraph/api/templates/partials/event-traces.html +373 -0
  18. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  19. htmlgraph/api/templates/partials/features.html +509 -0
  20. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  21. htmlgraph/api/templates/partials/metrics.html +346 -0
  22. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  23. htmlgraph/api/templates/partials/orchestration.html +163 -0
  24. htmlgraph/api/templates/partials/spawners.html +375 -0
  25. htmlgraph/atomic_ops.py +560 -0
  26. htmlgraph/builders/base.py +55 -1
  27. htmlgraph/builders/bug.py +17 -2
  28. htmlgraph/builders/chore.py +17 -2
  29. htmlgraph/builders/epic.py +17 -2
  30. htmlgraph/builders/feature.py +25 -2
  31. htmlgraph/builders/phase.py +17 -2
  32. htmlgraph/builders/spike.py +27 -2
  33. htmlgraph/builders/track.py +14 -0
  34. htmlgraph/cigs/__init__.py +4 -0
  35. htmlgraph/cigs/reporter.py +818 -0
  36. htmlgraph/cli.py +1427 -401
  37. htmlgraph/cli_commands/__init__.py +1 -0
  38. htmlgraph/cli_commands/feature.py +195 -0
  39. htmlgraph/cli_framework.py +115 -0
  40. htmlgraph/collections/__init__.py +2 -0
  41. htmlgraph/collections/base.py +21 -0
  42. htmlgraph/collections/session.py +189 -0
  43. htmlgraph/collections/spike.py +7 -1
  44. htmlgraph/collections/task_delegation.py +236 -0
  45. htmlgraph/collections/traces.py +482 -0
  46. htmlgraph/config.py +113 -0
  47. htmlgraph/converter.py +41 -0
  48. htmlgraph/cost_analysis/__init__.py +5 -0
  49. htmlgraph/cost_analysis/analyzer.py +438 -0
  50. htmlgraph/dashboard.html +3315 -492
  51. htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
  52. htmlgraph/dashboard.html.bak +7181 -0
  53. htmlgraph/dashboard.html.bak2 +7231 -0
  54. htmlgraph/dashboard.html.bak3 +7232 -0
  55. htmlgraph/db/__init__.py +38 -0
  56. htmlgraph/db/queries.py +790 -0
  57. htmlgraph/db/schema.py +1334 -0
  58. htmlgraph/deploy.py +26 -27
  59. htmlgraph/docs/API_REFERENCE.md +841 -0
  60. htmlgraph/docs/HTTP_API.md +750 -0
  61. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  62. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
  63. htmlgraph/docs/README.md +533 -0
  64. htmlgraph/docs/version_check.py +3 -1
  65. htmlgraph/error_handler.py +544 -0
  66. htmlgraph/event_log.py +2 -0
  67. htmlgraph/hooks/__init__.py +8 -0
  68. htmlgraph/hooks/bootstrap.py +169 -0
  69. htmlgraph/hooks/context.py +271 -0
  70. htmlgraph/hooks/drift_handler.py +521 -0
  71. htmlgraph/hooks/event_tracker.py +405 -15
  72. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  73. htmlgraph/hooks/pretooluse.py +476 -6
  74. htmlgraph/hooks/prompt_analyzer.py +648 -0
  75. htmlgraph/hooks/session_handler.py +583 -0
  76. htmlgraph/hooks/state_manager.py +501 -0
  77. htmlgraph/hooks/subagent_stop.py +309 -0
  78. htmlgraph/hooks/task_enforcer.py +39 -0
  79. htmlgraph/models.py +111 -15
  80. htmlgraph/operations/fastapi_server.py +230 -0
  81. htmlgraph/orchestration/headless_spawner.py +22 -14
  82. htmlgraph/pydantic_models.py +476 -0
  83. htmlgraph/quality_gates.py +350 -0
  84. htmlgraph/repo_hash.py +511 -0
  85. htmlgraph/sdk.py +348 -10
  86. htmlgraph/server.py +194 -0
  87. htmlgraph/session_hooks.py +300 -0
  88. htmlgraph/session_manager.py +131 -1
  89. htmlgraph/session_registry.py +587 -0
  90. htmlgraph/session_state.py +436 -0
  91. htmlgraph/system_prompts.py +449 -0
  92. htmlgraph/templates/orchestration-view.html +350 -0
  93. htmlgraph/track_builder.py +19 -0
  94. htmlgraph/validation.py +115 -0
  95. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +7417 -0
  96. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/METADATA +91 -64
  97. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/RECORD +103 -42
  98. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/styles.css +0 -0
  99. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  100. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  101. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  102. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/WHEEL +0 -0
  103. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,521 @@
1
+ """
2
+ HtmlGraph Drift Handler Module
3
+
4
+ Centralizes drift detection and auto-classification logic for hook operations.
5
+
6
+ This module provides a unified interface for:
7
+ - Loading drift configuration from project or plugin defaults
8
+ - Detecting drift in activity results based on configurable thresholds
9
+ - Handling high-drift conditions with cooldown awareness
10
+ - Triggering auto-classification when thresholds are met
11
+ - Building classification prompts from queued activities
12
+
13
+ Drift detection identifies when tool usage diverges from the active feature's
14
+ scope, allowing automatic classification into appropriate work items (bug, feature,
15
+ spike, chore, hotfix).
16
+
17
+ File Locations:
18
+ - Config: .htmlgraph/drift-config.json (or plugin default)
19
+ - Queue: .htmlgraph/drift-queue.json (activities for classification)
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import subprocess
26
+ from datetime import datetime, timedelta
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+ from htmlgraph.hooks.context import HookContext
31
+ from htmlgraph.hooks.state_manager import DriftQueueManager
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Default drift configuration thresholds and settings
36
+ DEFAULT_DRIFT_CONFIG = {
37
+ "drift_detection": {
38
+ "enabled": True,
39
+ "warning_threshold": 0.7,
40
+ "auto_classify_threshold": 0.85,
41
+ "min_activities_before_classify": 3,
42
+ "cooldown_minutes": 10,
43
+ },
44
+ "classification": {
45
+ "enabled": False,
46
+ "use_haiku_agent": True,
47
+ "use_headless": False,
48
+ "work_item_types": {
49
+ "bug": {
50
+ "keywords": [
51
+ "fix",
52
+ "error",
53
+ "broken",
54
+ "crash",
55
+ "fail",
56
+ "issue",
57
+ "wrong",
58
+ "incorrect",
59
+ ],
60
+ "description": "Fix incorrect behavior - must include repro steps",
61
+ },
62
+ "feature": {
63
+ "keywords": [
64
+ "add",
65
+ "implement",
66
+ "create",
67
+ "new",
68
+ "build",
69
+ "develop",
70
+ ],
71
+ "description": "Deliver user value - normal flow item",
72
+ },
73
+ "spike": {
74
+ "keywords": [
75
+ "research",
76
+ "explore",
77
+ "investigate",
78
+ "understand",
79
+ "analyze",
80
+ "learn",
81
+ ],
82
+ "description": "Reduce uncertainty - time-boxed, ends in decision",
83
+ },
84
+ "chore": {
85
+ "keywords": [
86
+ "refactor",
87
+ "cleanup",
88
+ "update",
89
+ "upgrade",
90
+ "maintain",
91
+ "organize",
92
+ ],
93
+ "description": "Maintenance / tech debt - first-class work",
94
+ },
95
+ "hotfix": {
96
+ "keywords": [
97
+ "urgent",
98
+ "critical",
99
+ "production",
100
+ "emergency",
101
+ "asap",
102
+ ],
103
+ "description": "Emergency production fix - expedite lane only",
104
+ },
105
+ },
106
+ },
107
+ "queue": {
108
+ "max_pending_classifications": 5,
109
+ "max_age_hours": 48,
110
+ "process_on_stop": True,
111
+ "process_on_threshold": True,
112
+ },
113
+ }
114
+
115
+
116
+ def load_drift_config(graph_dir: Path) -> dict:
117
+ """
118
+ Load drift configuration from project or fallback to defaults.
119
+
120
+ Searches for drift configuration in multiple locations with priority:
121
+ 1. .htmlgraph/drift-config.json (project-specific)
122
+ 2. Plugin config/drift-config.json (via CLAUDE_PLUGIN_ROOT)
123
+ 3. Default configuration (hardcoded fallback)
124
+
125
+ Args:
126
+ graph_dir: Path to .htmlgraph directory
127
+
128
+ Returns:
129
+ Drift configuration dict with keys: drift_detection, classification, queue
130
+
131
+ Raises:
132
+ OSError: If graph_dir cannot be accessed
133
+
134
+ Example:
135
+ ```python
136
+ config = load_drift_config(Path(".htmlgraph"))
137
+ print(f"Auto-classify threshold: {config['drift_detection']['auto_classify_threshold']}")
138
+ ```
139
+ """
140
+ graph_dir = Path(graph_dir)
141
+
142
+ # Configuration search paths in priority order
143
+ config_paths = [
144
+ graph_dir / "drift-config.json", # Project-specific (highest priority)
145
+ Path(os.environ.get("CLAUDE_PLUGIN_ROOT", ""))
146
+ / "config"
147
+ / "drift-config.json", # Plugin config
148
+ ]
149
+
150
+ for config_path in config_paths:
151
+ if config_path.exists() and config_path.is_file():
152
+ try:
153
+ with open(config_path) as f:
154
+ config = json.load(f)
155
+ logger.debug(f"Loaded drift config from {config_path}")
156
+ return config
157
+ except json.JSONDecodeError as e:
158
+ logger.warning(f"Invalid JSON in {config_path}: {e}, using defaults")
159
+ except OSError as e:
160
+ logger.warning(f"Error reading {config_path}: {e}, using defaults")
161
+
162
+ logger.debug("No drift config found, using defaults")
163
+ return DEFAULT_DRIFT_CONFIG
164
+
165
+
166
+ def detect_drift(activity_result: dict, config: dict) -> tuple[float, Optional[str]]:
167
+ """
168
+ Calculate drift score from activity result and check thresholds.
169
+
170
+ Drift scoring logic analyzes the activity result to determine if tool usage
171
+ aligns with the current feature context:
172
+ - Multiple "continue": true in sequence = high drift (agent exploring options)
173
+ - Tool errors/timeouts = high drift (unexpected behavior)
174
+ - Normal success = low drift (expected behavior)
175
+ - Errors = high drift (something went wrong)
176
+
177
+ Scoring is from 0.0 (perfect alignment) to 1.0 (high drift).
178
+
179
+ Args:
180
+ activity_result: Activity result dict from SessionManager.track_activity()
181
+ Should have attributes: drift_score (optional), feature_id
182
+ config: Drift configuration dict
183
+
184
+ Returns:
185
+ Tuple of (drift_score: float, feature_id: str | None)
186
+ - drift_score: 0.0 to 1.0 (higher = more drift)
187
+ - feature_id: Feature ID if high drift detected, else None
188
+
189
+ Note:
190
+ This function extracts pre-calculated drift_score from the activity
191
+ result (calculated by SessionManager). If no drift_score exists,
192
+ returns 0.0 (no drift).
193
+
194
+ Example:
195
+ ```python
196
+ score, feature_id = detect_drift(activity_result, config)
197
+ if score > config['drift_detection']['auto_classify_threshold']:
198
+ print(f"HIGH DRIFT: {score:.2f}")
199
+ ```
200
+ """
201
+ drift_score = getattr(activity_result, "drift_score", 0.0) or 0.0
202
+ feature_id = getattr(activity_result, "feature_id", None)
203
+
204
+ logger.debug(
205
+ f"Drift detected: score={drift_score:.2f}, feature={feature_id}"
206
+ )
207
+
208
+ return (drift_score, feature_id)
209
+
210
+
211
+ def handle_high_drift(
212
+ context: HookContext,
213
+ drift_score: float,
214
+ queue: dict,
215
+ config: dict,
216
+ ) -> Optional[str]:
217
+ """
218
+ Generate nudge message for high-drift activities.
219
+
220
+ When drift exceeds the auto-classify threshold:
221
+ 1. Adds activity to classification queue
222
+ 2. Checks cooldown to avoid spamming nudges
223
+ 3. Returns user-facing nudge message with guidance
224
+
225
+ The cooldown prevents excessive notifications when drift is detected
226
+ repeatedly in short timeframes.
227
+
228
+ Args:
229
+ context: Hook execution context with graph_dir access
230
+ drift_score: Calculated drift score (0.0 to 1.0)
231
+ queue: Current drift queue dict from DriftQueueManager
232
+ config: Drift configuration dict
233
+
234
+ Returns:
235
+ Nudge message string for user, or None if high drift but on cooldown
236
+
237
+ Note:
238
+ This function generates nudges but does NOT trigger classification.
239
+ Use trigger_auto_classification() separately to check if classification
240
+ should be spawned.
241
+
242
+ Example:
243
+ ```python
244
+ nudge = handle_high_drift(context, 0.87, queue, config)
245
+ if nudge:
246
+ print(nudge) # "HIGH DRIFT (0.87): Activity queued for classification..."
247
+ ```
248
+ """
249
+ drift_config = config.get("drift_detection", {})
250
+ auto_classify_threshold = drift_config.get("auto_classify_threshold", 0.85)
251
+ min_score = drift_config.get("warning_threshold", 0.7)
252
+
253
+ # Check if drift exceeds threshold
254
+ if drift_score < min_score:
255
+ return None
256
+
257
+ # Get queue size for nudge message
258
+ queue_manager = DriftQueueManager(context.graph_dir)
259
+ min_activities = drift_config.get("min_activities_before_classify", 3)
260
+ current_count = len(queue.get("activities", []))
261
+
262
+ if drift_score >= auto_classify_threshold:
263
+ # High drift - queued for classification
264
+ return (
265
+ f"Drift detected ({drift_score:.2f}): Activity queued for "
266
+ f"classification ({current_count}/{min_activities} needed)."
267
+ )
268
+ else:
269
+ # Moderate drift - just warn
270
+ return (
271
+ f"Drift detected ({drift_score:.2f}): Activity may not align with "
272
+ f"current feature context. Consider refocusing or updating the feature."
273
+ )
274
+
275
+
276
+ def trigger_auto_classification(
277
+ context: HookContext,
278
+ queue: dict,
279
+ feature_id: str,
280
+ config: dict,
281
+ ) -> bool:
282
+ """
283
+ Check if auto-classification should be triggered.
284
+
285
+ Validates whether classification conditions are met:
286
+ 1. Classification is enabled in config
287
+ 2. Minimum activities threshold reached
288
+ 3. Cooldown period has elapsed since last classification
289
+
290
+ Args:
291
+ context: Hook execution context
292
+ queue: Current drift queue dict
293
+ feature_id: Current feature ID for context
294
+ config: Drift configuration dict
295
+
296
+ Returns:
297
+ True if classification should be triggered, False otherwise
298
+
299
+ Example:
300
+ ```python
301
+ if trigger_auto_classification(context, queue, "feat-123", config):
302
+ prompt = build_classification_prompt(queue, feature_id)
303
+ # Spawn classification agent with prompt
304
+ ```
305
+ """
306
+ drift_config = config.get("drift_detection", {})
307
+ classification_config = config.get("classification", {})
308
+
309
+ # Check if classification is enabled
310
+ if not classification_config.get("enabled", False):
311
+ logger.debug("Classification disabled in config")
312
+ return False
313
+
314
+ # Check minimum activities threshold
315
+ min_activities = drift_config.get("min_activities_before_classify", 3)
316
+ current_activities = len(queue.get("activities", []))
317
+ if current_activities < min_activities:
318
+ logger.debug(
319
+ f"Not enough activities for classification: {current_activities}/{min_activities}"
320
+ )
321
+ return False
322
+
323
+ # Check cooldown
324
+ cooldown_minutes = drift_config.get("cooldown_minutes", 10)
325
+ last_classification = queue.get("last_classification")
326
+
327
+ if last_classification:
328
+ try:
329
+ last_time = datetime.fromisoformat(last_classification)
330
+ time_since = datetime.now() - last_time
331
+ if time_since < timedelta(minutes=cooldown_minutes):
332
+ logger.debug(
333
+ f"Classification on cooldown: {time_since.total_seconds():.0f}s "
334
+ f"< {cooldown_minutes}min"
335
+ )
336
+ return False
337
+ except (ValueError, TypeError) as e:
338
+ logger.warning(f"Error parsing last_classification timestamp: {e}")
339
+
340
+ logger.info(
341
+ f"Classification conditions met: {current_activities} activities, "
342
+ f"threshold {min_activities}, cooldown {cooldown_minutes}min"
343
+ )
344
+ return True
345
+
346
+
347
+ def build_classification_prompt(queue: dict, feature_id: str) -> str:
348
+ """
349
+ Build structured prompt for auto-classification agent.
350
+
351
+ Formats queued activities as a clear prompt for an LLM to classify into
352
+ appropriate work item types (bug, feature, spike, chore, hotfix).
353
+
354
+ The prompt includes:
355
+ - Feature context (what the current feature is supposed to do)
356
+ - Activity list with drift scores (what the agent actually did)
357
+ - Classification rules with descriptions
358
+ - Instruction to create work item in .htmlgraph/
359
+
360
+ Args:
361
+ queue: Drift queue dict with activities list
362
+ feature_id: Current feature ID for context
363
+
364
+ Returns:
365
+ Prompt string suitable for passing to classification agent
366
+
367
+ Example:
368
+ ```python
369
+ prompt = build_classification_prompt(queue, "feat-abc123")
370
+ # Use with Task tool or claude CLI
371
+ result = subprocess.run(
372
+ ["claude", "-p", prompt, "--model", "haiku"],
373
+ cwd=str(project_dir),
374
+ )
375
+ ```
376
+ """
377
+ activities = queue.get("activities", [])
378
+
379
+ # Format activity lines with summaries and drift scores
380
+ activity_lines = []
381
+ for activity in activities:
382
+ tool = activity.get("tool", "unknown")
383
+ summary = activity.get("summary", "no summary")
384
+ drift_score = activity.get("drift_score", 0)
385
+ file_paths = activity.get("file_paths", [])
386
+
387
+ # Build activity line
388
+ line = f"- {tool}: {summary}"
389
+
390
+ # Add file context if available
391
+ if file_paths:
392
+ files_str = ", ".join(str(f) for f in file_paths[:2])
393
+ line += f" (files: {files_str})"
394
+
395
+ # Add drift score
396
+ line += f" [drift: {drift_score:.2f}]"
397
+ activity_lines.append(line)
398
+
399
+ # Build classification prompt
400
+ prompt = f"""Classify these high-drift activities into a work item.
401
+
402
+ Current feature context: {feature_id}
403
+
404
+ Recent activities with high drift:
405
+ {chr(10).join(activity_lines)}
406
+
407
+ Based on the activity patterns:
408
+ 1. Determine the work item type (bug, feature, spike, chore, or hotfix)
409
+ 2. Create an appropriate title and description
410
+ 3. Create the work item HTML file in .htmlgraph/
411
+
412
+ Use the classification rules:
413
+ - bug: fixing errors, incorrect behavior
414
+ - feature: new functionality, additions
415
+ - spike: research, exploration, investigation
416
+ - chore: maintenance, refactoring, cleanup
417
+ - hotfix: urgent production issues
418
+
419
+ Create the work item now using Write tool."""
420
+
421
+ logger.debug(f"Built classification prompt ({len(activity_lines)} activities)")
422
+ return prompt
423
+
424
+
425
+ def run_headless_classification(
426
+ context: HookContext, prompt: str, config: dict
427
+ ) -> tuple[bool, Optional[str]]:
428
+ """
429
+ Attempt to run auto-classification via headless claude subprocess.
430
+
431
+ Spawns a subprocess with the classification prompt to avoid blocking
432
+ the main hook execution. Sets HTMLGRAPH_DISABLE_TRACKING to prevent
433
+ recursive hook execution.
434
+
435
+ Args:
436
+ context: Hook execution context with project_dir access
437
+ prompt: Classification prompt to send to claude
438
+ config: Drift configuration dict
439
+
440
+ Returns:
441
+ Tuple of (success: bool, nudge: str | None)
442
+ - success: True if classification subprocess succeeded
443
+ - nudge: Message to include in hook response
444
+
445
+ Raises:
446
+ subprocess.TimeoutExpired: If classification takes > 120 seconds
447
+ OSError: If claude command not found
448
+
449
+ Example:
450
+ ```python
451
+ success, nudge = run_headless_classification(context, prompt, config)
452
+ if success:
453
+ logger.info("Classification completed")
454
+ else:
455
+ logger.warning("Fallback to manual classification needed")
456
+ ```
457
+ """
458
+ classification_config = config.get("classification", {})
459
+ model = classification_config.get("model", "haiku")
460
+
461
+ try:
462
+ result = subprocess.run(
463
+ ["claude", "-p", prompt, "--model", model, "--dangerously-skip-permissions"],
464
+ capture_output=True,
465
+ text=True,
466
+ timeout=120,
467
+ cwd=context.project_dir,
468
+ env={
469
+ **os.environ,
470
+ # Prevent hooks from creating nested HtmlGraph sessions
471
+ "HTMLGRAPH_DISABLE_TRACKING": "1",
472
+ },
473
+ )
474
+
475
+ if result.returncode == 0:
476
+ logger.info("Headless classification completed successfully")
477
+ nudge = (
478
+ "Drift auto-classification completed. "
479
+ "Check .htmlgraph/ for new work item."
480
+ )
481
+ return (True, nudge)
482
+ else:
483
+ logger.warning(f"Classification subprocess failed: {result.stderr}")
484
+ nudge = (
485
+ "HIGH DRIFT - Headless classification failed. "
486
+ "Please classify manually in .htmlgraph/"
487
+ )
488
+ return (False, nudge)
489
+
490
+ except subprocess.TimeoutExpired as e:
491
+ logger.error(f"Classification timeout after {e.timeout}s")
492
+ nudge = (
493
+ "HIGH DRIFT - Classification timeout. "
494
+ "Please classify manually in .htmlgraph/"
495
+ )
496
+ return (False, nudge)
497
+ except FileNotFoundError:
498
+ logger.error("claude command not found")
499
+ nudge = (
500
+ "HIGH DRIFT - claude not available. "
501
+ "Please classify manually in .htmlgraph/"
502
+ )
503
+ return (False, nudge)
504
+ except Exception as e:
505
+ logger.error(f"Unexpected error during classification: {e}")
506
+ nudge = (
507
+ f"HIGH DRIFT - Classification error: {e}. "
508
+ "Please classify manually in .htmlgraph/"
509
+ )
510
+ return (False, nudge)
511
+
512
+
513
+ __all__ = [
514
+ "load_drift_config",
515
+ "detect_drift",
516
+ "handle_high_drift",
517
+ "trigger_auto_classification",
518
+ "build_classification_prompt",
519
+ "run_headless_classification",
520
+ "DEFAULT_DRIFT_CONFIG",
521
+ ]