htmlgraph 0.9.3__py3-none-any.whl → 0.27.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) 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 +173 -17
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +127 -0
  7. htmlgraph/agent_registry.py +45 -30
  8. htmlgraph/agents.py +160 -107
  9. htmlgraph/analytics/__init__.py +9 -2
  10. htmlgraph/analytics/cli.py +190 -51
  11. htmlgraph/analytics/cost_analyzer.py +391 -0
  12. htmlgraph/analytics/cost_monitor.py +664 -0
  13. htmlgraph/analytics/cost_reporter.py +675 -0
  14. htmlgraph/analytics/cross_session.py +617 -0
  15. htmlgraph/analytics/dependency.py +192 -100
  16. htmlgraph/analytics/pattern_learning.py +771 -0
  17. htmlgraph/analytics/session_graph.py +707 -0
  18. htmlgraph/analytics/strategic/__init__.py +80 -0
  19. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  20. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  21. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  22. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  23. htmlgraph/analytics/work_type.py +190 -14
  24. htmlgraph/analytics_index.py +135 -51
  25. htmlgraph/api/__init__.py +3 -0
  26. htmlgraph/api/cost_alerts_websocket.py +416 -0
  27. htmlgraph/api/main.py +2498 -0
  28. htmlgraph/api/static/htmx.min.js +1 -0
  29. htmlgraph/api/static/style-redesign.css +1344 -0
  30. htmlgraph/api/static/style.css +1079 -0
  31. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  32. htmlgraph/api/templates/dashboard.html +794 -0
  33. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  34. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  35. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  36. htmlgraph/api/templates/partials/agents.html +317 -0
  37. htmlgraph/api/templates/partials/event-traces.html +373 -0
  38. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  39. htmlgraph/api/templates/partials/features.html +578 -0
  40. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  41. htmlgraph/api/templates/partials/metrics.html +346 -0
  42. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  43. htmlgraph/api/templates/partials/orchestration.html +198 -0
  44. htmlgraph/api/templates/partials/spawners.html +375 -0
  45. htmlgraph/api/templates/partials/work-items.html +613 -0
  46. htmlgraph/api/websocket.py +538 -0
  47. htmlgraph/archive/__init__.py +24 -0
  48. htmlgraph/archive/bloom.py +234 -0
  49. htmlgraph/archive/fts.py +297 -0
  50. htmlgraph/archive/manager.py +583 -0
  51. htmlgraph/archive/search.py +244 -0
  52. htmlgraph/atomic_ops.py +560 -0
  53. htmlgraph/attribute_index.py +208 -0
  54. htmlgraph/bounded_paths.py +539 -0
  55. htmlgraph/builders/__init__.py +14 -0
  56. htmlgraph/builders/base.py +118 -29
  57. htmlgraph/builders/bug.py +150 -0
  58. htmlgraph/builders/chore.py +119 -0
  59. htmlgraph/builders/epic.py +150 -0
  60. htmlgraph/builders/feature.py +31 -6
  61. htmlgraph/builders/insight.py +195 -0
  62. htmlgraph/builders/metric.py +217 -0
  63. htmlgraph/builders/pattern.py +202 -0
  64. htmlgraph/builders/phase.py +162 -0
  65. htmlgraph/builders/spike.py +52 -19
  66. htmlgraph/builders/track.py +148 -72
  67. htmlgraph/cigs/__init__.py +81 -0
  68. htmlgraph/cigs/autonomy.py +385 -0
  69. htmlgraph/cigs/cost.py +475 -0
  70. htmlgraph/cigs/messages_basic.py +472 -0
  71. htmlgraph/cigs/messaging.py +365 -0
  72. htmlgraph/cigs/models.py +771 -0
  73. htmlgraph/cigs/pattern_storage.py +427 -0
  74. htmlgraph/cigs/patterns.py +503 -0
  75. htmlgraph/cigs/posttool_analyzer.py +234 -0
  76. htmlgraph/cigs/reporter.py +818 -0
  77. htmlgraph/cigs/tracker.py +317 -0
  78. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  79. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  80. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  81. htmlgraph/cli/__init__.py +42 -0
  82. htmlgraph/cli/__main__.py +6 -0
  83. htmlgraph/cli/analytics.py +1424 -0
  84. htmlgraph/cli/base.py +685 -0
  85. htmlgraph/cli/constants.py +206 -0
  86. htmlgraph/cli/core.py +954 -0
  87. htmlgraph/cli/main.py +147 -0
  88. htmlgraph/cli/models.py +475 -0
  89. htmlgraph/cli/templates/__init__.py +1 -0
  90. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  91. htmlgraph/cli/work/__init__.py +239 -0
  92. htmlgraph/cli/work/browse.py +115 -0
  93. htmlgraph/cli/work/features.py +568 -0
  94. htmlgraph/cli/work/orchestration.py +676 -0
  95. htmlgraph/cli/work/report.py +728 -0
  96. htmlgraph/cli/work/sessions.py +466 -0
  97. htmlgraph/cli/work/snapshot.py +559 -0
  98. htmlgraph/cli/work/tracks.py +486 -0
  99. htmlgraph/cli_commands/__init__.py +1 -0
  100. htmlgraph/cli_commands/feature.py +195 -0
  101. htmlgraph/cli_framework.py +115 -0
  102. htmlgraph/collections/__init__.py +18 -0
  103. htmlgraph/collections/base.py +415 -98
  104. htmlgraph/collections/bug.py +53 -0
  105. htmlgraph/collections/chore.py +53 -0
  106. htmlgraph/collections/epic.py +53 -0
  107. htmlgraph/collections/feature.py +12 -26
  108. htmlgraph/collections/insight.py +100 -0
  109. htmlgraph/collections/metric.py +92 -0
  110. htmlgraph/collections/pattern.py +97 -0
  111. htmlgraph/collections/phase.py +53 -0
  112. htmlgraph/collections/session.py +194 -0
  113. htmlgraph/collections/spike.py +56 -16
  114. htmlgraph/collections/task_delegation.py +241 -0
  115. htmlgraph/collections/todo.py +511 -0
  116. htmlgraph/collections/traces.py +487 -0
  117. htmlgraph/config/cost_models.json +56 -0
  118. htmlgraph/config.py +190 -0
  119. htmlgraph/context_analytics.py +344 -0
  120. htmlgraph/converter.py +216 -28
  121. htmlgraph/cost_analysis/__init__.py +5 -0
  122. htmlgraph/cost_analysis/analyzer.py +438 -0
  123. htmlgraph/dashboard.html +2406 -307
  124. htmlgraph/dashboard.html.backup +6592 -0
  125. htmlgraph/dashboard.html.bak +7181 -0
  126. htmlgraph/dashboard.html.bak2 +7231 -0
  127. htmlgraph/dashboard.html.bak3 +7232 -0
  128. htmlgraph/db/__init__.py +38 -0
  129. htmlgraph/db/queries.py +790 -0
  130. htmlgraph/db/schema.py +1788 -0
  131. htmlgraph/decorators.py +317 -0
  132. htmlgraph/dependency_models.py +19 -2
  133. htmlgraph/deploy.py +142 -125
  134. htmlgraph/deployment_models.py +474 -0
  135. htmlgraph/docs/API_REFERENCE.md +841 -0
  136. htmlgraph/docs/HTTP_API.md +750 -0
  137. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  138. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  139. htmlgraph/docs/README.md +532 -0
  140. htmlgraph/docs/__init__.py +77 -0
  141. htmlgraph/docs/docs_version.py +55 -0
  142. htmlgraph/docs/metadata.py +93 -0
  143. htmlgraph/docs/migrations.py +232 -0
  144. htmlgraph/docs/template_engine.py +143 -0
  145. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  146. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  147. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  148. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  149. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  150. htmlgraph/docs/version_check.py +163 -0
  151. htmlgraph/edge_index.py +182 -27
  152. htmlgraph/error_handler.py +544 -0
  153. htmlgraph/event_log.py +100 -52
  154. htmlgraph/event_migration.py +13 -4
  155. htmlgraph/exceptions.py +49 -0
  156. htmlgraph/file_watcher.py +101 -28
  157. htmlgraph/find_api.py +75 -63
  158. htmlgraph/git_events.py +145 -63
  159. htmlgraph/graph.py +1122 -106
  160. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  161. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  162. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  163. htmlgraph/hooks/__init__.py +45 -0
  164. htmlgraph/hooks/bootstrap.py +169 -0
  165. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  166. htmlgraph/hooks/concurrent_sessions.py +208 -0
  167. htmlgraph/hooks/context.py +350 -0
  168. htmlgraph/hooks/drift_handler.py +525 -0
  169. htmlgraph/hooks/event_tracker.py +1314 -0
  170. htmlgraph/hooks/git_commands.py +175 -0
  171. htmlgraph/hooks/hooks-config.example.json +12 -0
  172. htmlgraph/hooks/installer.py +343 -0
  173. htmlgraph/hooks/orchestrator.py +674 -0
  174. htmlgraph/hooks/orchestrator_reflector.py +223 -0
  175. htmlgraph/hooks/post-checkout.sh +28 -0
  176. htmlgraph/hooks/post-commit.sh +24 -0
  177. htmlgraph/hooks/post-merge.sh +26 -0
  178. htmlgraph/hooks/post_tool_use_failure.py +273 -0
  179. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  180. htmlgraph/hooks/posttooluse.py +408 -0
  181. htmlgraph/hooks/pre-commit.sh +94 -0
  182. htmlgraph/hooks/pre-push.sh +28 -0
  183. htmlgraph/hooks/pretooluse.py +819 -0
  184. htmlgraph/hooks/prompt_analyzer.py +637 -0
  185. htmlgraph/hooks/session_handler.py +668 -0
  186. htmlgraph/hooks/session_summary.py +395 -0
  187. htmlgraph/hooks/state_manager.py +504 -0
  188. htmlgraph/hooks/subagent_detection.py +202 -0
  189. htmlgraph/hooks/subagent_stop.py +369 -0
  190. htmlgraph/hooks/task_enforcer.py +255 -0
  191. htmlgraph/hooks/task_validator.py +177 -0
  192. htmlgraph/hooks/validator.py +628 -0
  193. htmlgraph/ids.py +41 -27
  194. htmlgraph/index.d.ts +286 -0
  195. htmlgraph/learning.py +767 -0
  196. htmlgraph/mcp_server.py +69 -23
  197. htmlgraph/models.py +1586 -87
  198. htmlgraph/operations/README.md +62 -0
  199. htmlgraph/operations/__init__.py +79 -0
  200. htmlgraph/operations/analytics.py +339 -0
  201. htmlgraph/operations/bootstrap.py +289 -0
  202. htmlgraph/operations/events.py +244 -0
  203. htmlgraph/operations/fastapi_server.py +231 -0
  204. htmlgraph/operations/hooks.py +350 -0
  205. htmlgraph/operations/initialization.py +597 -0
  206. htmlgraph/operations/initialization.py.backup +228 -0
  207. htmlgraph/operations/server.py +303 -0
  208. htmlgraph/orchestration/__init__.py +58 -0
  209. htmlgraph/orchestration/claude_launcher.py +179 -0
  210. htmlgraph/orchestration/command_builder.py +72 -0
  211. htmlgraph/orchestration/headless_spawner.py +281 -0
  212. htmlgraph/orchestration/live_events.py +377 -0
  213. htmlgraph/orchestration/model_selection.py +327 -0
  214. htmlgraph/orchestration/plugin_manager.py +140 -0
  215. htmlgraph/orchestration/prompts.py +137 -0
  216. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  217. htmlgraph/orchestration/spawners/__init__.py +16 -0
  218. htmlgraph/orchestration/spawners/base.py +194 -0
  219. htmlgraph/orchestration/spawners/claude.py +173 -0
  220. htmlgraph/orchestration/spawners/codex.py +435 -0
  221. htmlgraph/orchestration/spawners/copilot.py +294 -0
  222. htmlgraph/orchestration/spawners/gemini.py +471 -0
  223. htmlgraph/orchestration/subprocess_runner.py +36 -0
  224. htmlgraph/orchestration/task_coordination.py +343 -0
  225. htmlgraph/orchestration.md +563 -0
  226. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  227. htmlgraph/orchestrator.py +669 -0
  228. htmlgraph/orchestrator_config.py +357 -0
  229. htmlgraph/orchestrator_mode.py +328 -0
  230. htmlgraph/orchestrator_validator.py +133 -0
  231. htmlgraph/parallel.py +646 -0
  232. htmlgraph/parser.py +160 -35
  233. htmlgraph/path_query.py +608 -0
  234. htmlgraph/pattern_matcher.py +636 -0
  235. htmlgraph/planning.py +147 -52
  236. htmlgraph/pydantic_models.py +476 -0
  237. htmlgraph/quality_gates.py +350 -0
  238. htmlgraph/query_builder.py +109 -72
  239. htmlgraph/query_composer.py +509 -0
  240. htmlgraph/reflection.py +443 -0
  241. htmlgraph/refs.py +344 -0
  242. htmlgraph/repo_hash.py +512 -0
  243. htmlgraph/repositories/__init__.py +292 -0
  244. htmlgraph/repositories/analytics_repository.py +455 -0
  245. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  246. htmlgraph/repositories/feature_repository.py +581 -0
  247. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  248. htmlgraph/repositories/feature_repository_memory.py +607 -0
  249. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  250. htmlgraph/repositories/filter_service.py +620 -0
  251. htmlgraph/repositories/filter_service_standard.py +445 -0
  252. htmlgraph/repositories/shared_cache.py +621 -0
  253. htmlgraph/repositories/shared_cache_memory.py +395 -0
  254. htmlgraph/repositories/track_repository.py +552 -0
  255. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  256. htmlgraph/repositories/track_repository_memory.py +508 -0
  257. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  258. htmlgraph/routing.py +8 -19
  259. htmlgraph/scripts/deploy.py +1 -2
  260. htmlgraph/sdk/__init__.py +398 -0
  261. htmlgraph/sdk/__init__.pyi +14 -0
  262. htmlgraph/sdk/analytics/__init__.py +19 -0
  263. htmlgraph/sdk/analytics/engine.py +155 -0
  264. htmlgraph/sdk/analytics/helpers.py +178 -0
  265. htmlgraph/sdk/analytics/registry.py +109 -0
  266. htmlgraph/sdk/base.py +484 -0
  267. htmlgraph/sdk/constants.py +216 -0
  268. htmlgraph/sdk/core.pyi +308 -0
  269. htmlgraph/sdk/discovery.py +120 -0
  270. htmlgraph/sdk/help/__init__.py +12 -0
  271. htmlgraph/sdk/help/mixin.py +699 -0
  272. htmlgraph/sdk/mixins/__init__.py +15 -0
  273. htmlgraph/sdk/mixins/attribution.py +113 -0
  274. htmlgraph/sdk/mixins/mixin.py +410 -0
  275. htmlgraph/sdk/operations/__init__.py +12 -0
  276. htmlgraph/sdk/operations/mixin.py +427 -0
  277. htmlgraph/sdk/orchestration/__init__.py +17 -0
  278. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  279. htmlgraph/sdk/orchestration/spawner.py +204 -0
  280. htmlgraph/sdk/planning/__init__.py +19 -0
  281. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  282. htmlgraph/sdk/planning/mixin.py +211 -0
  283. htmlgraph/sdk/planning/parallel.py +186 -0
  284. htmlgraph/sdk/planning/queue.py +210 -0
  285. htmlgraph/sdk/planning/recommendations.py +87 -0
  286. htmlgraph/sdk/planning/smart_planning.py +319 -0
  287. htmlgraph/sdk/session/__init__.py +19 -0
  288. htmlgraph/sdk/session/continuity.py +57 -0
  289. htmlgraph/sdk/session/handoff.py +110 -0
  290. htmlgraph/sdk/session/info.py +309 -0
  291. htmlgraph/sdk/session/manager.py +103 -0
  292. htmlgraph/sdk/strategic/__init__.py +26 -0
  293. htmlgraph/sdk/strategic/mixin.py +563 -0
  294. htmlgraph/server.py +685 -180
  295. htmlgraph/services/__init__.py +10 -0
  296. htmlgraph/services/claiming.py +199 -0
  297. htmlgraph/session_hooks.py +300 -0
  298. htmlgraph/session_manager.py +1392 -175
  299. htmlgraph/session_registry.py +587 -0
  300. htmlgraph/session_state.py +436 -0
  301. htmlgraph/session_warning.py +201 -0
  302. htmlgraph/sessions/__init__.py +23 -0
  303. htmlgraph/sessions/handoff.py +756 -0
  304. htmlgraph/setup.py +34 -17
  305. htmlgraph/spike_index.py +143 -0
  306. htmlgraph/sync_docs.py +12 -15
  307. htmlgraph/system_prompts.py +450 -0
  308. htmlgraph/templates/AGENTS.md.template +366 -0
  309. htmlgraph/templates/CLAUDE.md.template +97 -0
  310. htmlgraph/templates/GEMINI.md.template +87 -0
  311. htmlgraph/templates/orchestration-view.html +350 -0
  312. htmlgraph/track_builder.py +146 -15
  313. htmlgraph/track_manager.py +69 -21
  314. htmlgraph/transcript.py +890 -0
  315. htmlgraph/transcript_analytics.py +699 -0
  316. htmlgraph/types.py +323 -0
  317. htmlgraph/validation.py +115 -0
  318. htmlgraph/watch.py +8 -5
  319. htmlgraph/work_type_utils.py +3 -2
  320. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
  321. htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
  322. htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
  323. htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
  324. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
  325. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  326. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  327. htmlgraph/cli.py +0 -2688
  328. htmlgraph/sdk.py +0 -709
  329. htmlgraph-0.9.3.dist-info/RECORD +0 -61
  330. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  331. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
@@ -0,0 +1,504 @@
1
+ """
2
+ HtmlGraph Hooks State Manager
3
+
4
+ Unified state file management for hook operations:
5
+ - Parent activity tracking (for Skill/Task context)
6
+ - User query event tracking (for parent-child linking)
7
+ - Drift queue management (for auto-classification)
8
+
9
+ This module provides file-based state persistence with:
10
+ - Atomic writes (write to temp, then rename)
11
+ - File locking to prevent concurrent writes
12
+ - Error handling for missing/corrupted files
13
+ - Age-based filtering and cleanup
14
+ - Comprehensive logging
15
+
16
+ File Locations (.htmlgraph/):
17
+ - parent-activity.json: Current parent context (Skill/Task invocation)
18
+ - user-query-event-{SESSION_ID}.json: UserQuery event ID for session
19
+ - drift-queue.json: Classification queue for high-drift activities
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import tempfile
26
+ from datetime import datetime, timedelta
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class ParentActivityTracker:
34
+ """
35
+ Tracks the active parent activity context for Skill/Task invocations.
36
+
37
+ Parent context allows child tool calls to link to their parent Skill/Task.
38
+ Parent activities automatically expire after 5 minutes of inactivity.
39
+
40
+ File: parent-activity.json (single entry)
41
+ ```json
42
+ {
43
+ "parent_id": "evt-xyz123",
44
+ "tool": "Task",
45
+ "timestamp": "2025-01-10T12:34:56Z"
46
+ }
47
+ ```
48
+ """
49
+
50
+ def __init__(self, graph_dir: Path):
51
+ """
52
+ Initialize parent activity tracker.
53
+
54
+ Args:
55
+ graph_dir: Path to .htmlgraph directory
56
+ """
57
+ self.graph_dir = Path(graph_dir)
58
+ self.file_path = self.graph_dir / "parent-activity.json"
59
+ self._ensure_graph_dir()
60
+
61
+ def _ensure_graph_dir(self) -> None:
62
+ """Ensure .htmlgraph directory exists."""
63
+ self.graph_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ def load(self, max_age_minutes: int = 5) -> dict[str, Any]:
66
+ """
67
+ Load parent activity state.
68
+
69
+ Automatically filters out stale parent activities older than max_age_minutes.
70
+ This allows long-running parent contexts (like Tasks) to timeout naturally.
71
+
72
+ Args:
73
+ max_age_minutes: Maximum age in minutes before activity is considered stale
74
+ (default: 5 minutes)
75
+
76
+ Returns:
77
+ Parent activity dict with keys: parent_id, tool, timestamp
78
+ Empty dict if file missing or stale
79
+ """
80
+ if not self.file_path.exists():
81
+ return {}
82
+
83
+ try:
84
+ with open(self.file_path) as f:
85
+ data: dict[str, object] = json.load(f)
86
+
87
+ # Validate timestamp and check if stale
88
+ if data.get("timestamp"):
89
+ ts = datetime.fromisoformat(data["timestamp"]) # type: ignore[arg-type]
90
+ age = datetime.now() - ts
91
+ if age > timedelta(minutes=max_age_minutes):
92
+ logger.debug(
93
+ f"Parent activity stale ({age.total_seconds():.0f}s > {max_age_minutes}min)"
94
+ )
95
+ return {}
96
+
97
+ logger.debug(f"Loaded parent activity: {data.get('parent_id')}")
98
+ return data # type: ignore[return-value]
99
+
100
+ except json.JSONDecodeError:
101
+ logger.warning("Corrupted parent-activity.json, returning empty state")
102
+ return {}
103
+ except (ValueError, KeyError, OSError) as e:
104
+ logger.warning(f"Error loading parent activity: {e}")
105
+ return {}
106
+
107
+ def save(self, parent_id: str, tool: str) -> None:
108
+ """
109
+ Save parent activity context.
110
+
111
+ Creates or updates parent-activity.json with the current parent context.
112
+ Uses atomic write to prevent corruption from concurrent access.
113
+
114
+ Args:
115
+ parent_id: Event ID of parent activity (e.g., "evt-xyz123")
116
+ tool: Tool name that created parent context (e.g., "Task", "Skill")
117
+ """
118
+ try:
119
+ data = {
120
+ "parent_id": parent_id,
121
+ "tool": tool,
122
+ "timestamp": datetime.now().isoformat(),
123
+ }
124
+
125
+ # Atomic write: write to temp file, then rename
126
+ with tempfile.NamedTemporaryFile(
127
+ mode="w",
128
+ dir=self.graph_dir,
129
+ delete=False,
130
+ suffix=".json",
131
+ ) as tmp:
132
+ json.dump(data, tmp)
133
+ tmp_path = tmp.name
134
+
135
+ # Atomic rename
136
+ os.replace(tmp_path, self.file_path)
137
+ logger.debug(f"Saved parent activity: {parent_id} (tool={tool})")
138
+
139
+ except OSError as e:
140
+ logger.warning(f"Could not save parent activity: {e}")
141
+ except Exception as e:
142
+ logger.error(f"Unexpected error saving parent activity: {e}")
143
+
144
+ def clear(self) -> None:
145
+ """
146
+ Delete parent activity file.
147
+
148
+ Clears the parent context, causing subsequent tool calls to not link
149
+ to a parent activity.
150
+ """
151
+ try:
152
+ self.file_path.unlink(missing_ok=True)
153
+ logger.debug("Cleared parent activity")
154
+ except OSError as e:
155
+ logger.warning(f"Could not clear parent activity: {e}")
156
+
157
+
158
+ class UserQueryEventTracker:
159
+ """
160
+ Tracks the active UserQuery event ID for parent-child linking.
161
+
162
+ Each session maintains its own UserQuery event context to support
163
+ multiple concurrent Claude windows in the same project.
164
+
165
+ UserQuery events expire after 2 minutes (conversation turn boundary),
166
+ allowing natural grouping of tool calls by conversation turn.
167
+
168
+ File: user-query-event-{SESSION_ID}.json (single entry)
169
+ ```json
170
+ {
171
+ "event_id": "evt-abc456",
172
+ "timestamp": "2025-01-10T12:34:56Z"
173
+ }
174
+ ```
175
+ """
176
+
177
+ def __init__(self, graph_dir: Path):
178
+ """
179
+ Initialize user query event tracker.
180
+
181
+ Args:
182
+ graph_dir: Path to .htmlgraph directory
183
+ """
184
+ self.graph_dir = Path(graph_dir)
185
+ self._ensure_graph_dir()
186
+
187
+ def _ensure_graph_dir(self) -> None:
188
+ """Ensure .htmlgraph directory exists."""
189
+ self.graph_dir.mkdir(parents=True, exist_ok=True)
190
+
191
+ def _get_file_path(self, session_id: str) -> Path:
192
+ """Get session-specific user query event file path."""
193
+ return self.graph_dir / f"user-query-event-{session_id}.json"
194
+
195
+ def load(self, session_id: str, max_age_minutes: int = 2) -> str | None:
196
+ """
197
+ Load active UserQuery event ID for a session.
198
+
199
+ Automatically filters out stale events older than max_age_minutes.
200
+ This creates natural conversation turn boundaries when queries timeout.
201
+
202
+ Args:
203
+ session_id: Session ID (e.g., "sess-xyz789")
204
+ max_age_minutes: Maximum age in minutes before event is considered stale
205
+ (default: 2 minutes for conversation turns)
206
+
207
+ Returns:
208
+ Event ID string (e.g., "evt-abc456") or None if missing/stale
209
+ """
210
+ file_path = self._get_file_path(session_id)
211
+ if not file_path.exists():
212
+ return None
213
+
214
+ try:
215
+ with open(file_path) as f:
216
+ data: dict[str, object] = json.load(f)
217
+
218
+ # Validate timestamp and check if stale
219
+ if data.get("timestamp"):
220
+ ts = datetime.fromisoformat(data["timestamp"]) # type: ignore[arg-type]
221
+ age = datetime.now() - ts
222
+ if age > timedelta(minutes=max_age_minutes):
223
+ logger.debug(
224
+ f"UserQuery event stale ({age.total_seconds():.0f}s > {max_age_minutes}min)"
225
+ )
226
+ return None
227
+
228
+ event_id = data.get("event_id")
229
+ logger.debug(f"Loaded UserQuery event: {event_id}")
230
+ return event_id # type: ignore[return-value]
231
+
232
+ except json.JSONDecodeError:
233
+ logger.warning(f"Corrupted user-query-event file for {session_id}")
234
+ return None
235
+ except (ValueError, KeyError, OSError) as e:
236
+ logger.warning(f"Error loading UserQuery event for {session_id}: {e}")
237
+ return None
238
+
239
+ def save(self, session_id: str, event_id: str) -> None:
240
+ """
241
+ Save UserQuery event ID for a session.
242
+
243
+ Creates or updates the session-specific user query event file.
244
+ Uses atomic write to prevent corruption from concurrent access.
245
+
246
+ Args:
247
+ session_id: Session ID (e.g., "sess-xyz789")
248
+ event_id: Event ID to save (e.g., "evt-abc456")
249
+ """
250
+ file_path = self._get_file_path(session_id)
251
+ try:
252
+ data = {
253
+ "event_id": event_id,
254
+ "timestamp": datetime.now().isoformat(),
255
+ }
256
+
257
+ # Atomic write: write to temp file, then rename
258
+ with tempfile.NamedTemporaryFile(
259
+ mode="w",
260
+ dir=self.graph_dir,
261
+ delete=False,
262
+ suffix=".json",
263
+ ) as tmp:
264
+ json.dump(data, tmp)
265
+ tmp_path = tmp.name
266
+
267
+ # Atomic rename
268
+ os.replace(tmp_path, file_path)
269
+ logger.debug(f"Saved UserQuery event: {event_id} (session={session_id})")
270
+
271
+ except OSError as e:
272
+ logger.warning(f"Could not save UserQuery event for {session_id}: {e}")
273
+ except Exception as e:
274
+ logger.error(
275
+ f"Unexpected error saving UserQuery event for {session_id}: {e}"
276
+ )
277
+
278
+ def clear(self, session_id: str) -> None:
279
+ """
280
+ Delete UserQuery event file for a session.
281
+
282
+ Clears the session's UserQuery context, allowing a new conversation turn
283
+ to begin without inheriting the previous turn's parent context.
284
+
285
+ Args:
286
+ session_id: Session ID to clear
287
+ """
288
+ file_path = self._get_file_path(session_id)
289
+ try:
290
+ file_path.unlink(missing_ok=True)
291
+ logger.debug(f"Cleared UserQuery event for {session_id}")
292
+ except OSError as e:
293
+ logger.warning(f"Could not clear UserQuery event for {session_id}: {e}")
294
+
295
+
296
+ class DriftQueueManager:
297
+ """
298
+ Manages the drift classification queue for high-drift activities.
299
+
300
+ The drift queue accumulates activities that exceed the auto-classification
301
+ threshold, triggering classification when thresholds are met.
302
+
303
+ Activities are automatically filtered by age to prevent indefinite accumulation.
304
+
305
+ File: drift-queue.json
306
+ ```json
307
+ {
308
+ "activities": [
309
+ {
310
+ "timestamp": "2025-01-10T12:34:56Z",
311
+ "tool": "Read",
312
+ "summary": "Read: /path/to/file.py",
313
+ "file_paths": ["/path/to/file.py"],
314
+ "drift_score": 0.87,
315
+ "feature_id": "feat-xyz123"
316
+ }
317
+ ],
318
+ "last_classification": "2025-01-10T12:30:00Z"
319
+ }
320
+ ```
321
+ """
322
+
323
+ def __init__(self, graph_dir: Path):
324
+ """
325
+ Initialize drift queue manager.
326
+
327
+ Args:
328
+ graph_dir: Path to .htmlgraph directory
329
+ """
330
+ self.graph_dir = Path(graph_dir)
331
+ self.file_path = self.graph_dir / "drift-queue.json"
332
+ self._ensure_graph_dir()
333
+
334
+ def _ensure_graph_dir(self) -> None:
335
+ """Ensure .htmlgraph directory exists."""
336
+ self.graph_dir.mkdir(parents=True, exist_ok=True)
337
+
338
+ def load(self, max_age_hours: int = 48) -> dict[str, Any]:
339
+ """
340
+ Load drift queue and filter by age.
341
+
342
+ Automatically removes activities older than max_age_hours.
343
+ This prevents the queue from growing indefinitely over time.
344
+
345
+ Args:
346
+ max_age_hours: Maximum age in hours before activities are removed
347
+ (default: 48 hours)
348
+
349
+ Returns:
350
+ Queue dict with keys: activities (list), last_classification (timestamp)
351
+ Returns default empty queue if file missing
352
+ """
353
+ if not self.file_path.exists():
354
+ return {"activities": [], "last_classification": None}
355
+
356
+ try:
357
+ with open(self.file_path) as f:
358
+ queue: dict[str, object] = json.load(f)
359
+
360
+ # Filter out stale activities
361
+ cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
362
+ original_count = len(queue.get("activities", [])) # type: ignore[arg-type]
363
+
364
+ fresh_activities = []
365
+ for activity in queue.get("activities", []): # type: ignore[attr-defined]
366
+ try:
367
+ activity_time = datetime.fromisoformat(
368
+ activity.get("timestamp", "")
369
+ )
370
+ if activity_time >= cutoff_time:
371
+ fresh_activities.append(activity)
372
+ except (ValueError, TypeError):
373
+ # Keep activities with invalid timestamps to avoid data loss
374
+ fresh_activities.append(activity)
375
+
376
+ # Update queue if we removed stale entries
377
+ if len(fresh_activities) < original_count:
378
+ queue["activities"] = fresh_activities
379
+ self.save(queue)
380
+ removed = original_count - len(fresh_activities)
381
+ logger.info(
382
+ f"Cleaned {removed} stale drift queue entries (older than {max_age_hours}h)"
383
+ )
384
+
385
+ logger.debug(
386
+ f"Loaded drift queue: {len(fresh_activities)} recent activities"
387
+ )
388
+ return queue
389
+
390
+ except json.JSONDecodeError:
391
+ logger.warning("Corrupted drift-queue.json, returning empty queue")
392
+ return {"activities": [], "last_classification": None}
393
+ except (ValueError, KeyError, OSError) as e:
394
+ logger.warning(f"Error loading drift queue: {e}")
395
+ return {"activities": [], "last_classification": None}
396
+
397
+ def save(self, queue: dict[str, Any]) -> None:
398
+ """
399
+ Save drift queue to file.
400
+
401
+ Persists the queue with all activities and classification metadata.
402
+ Uses atomic write to prevent corruption from concurrent access.
403
+
404
+ Args:
405
+ queue: Queue dict with activities and last_classification timestamp
406
+ """
407
+ try:
408
+ # Atomic write: write to temp file, then rename
409
+ with tempfile.NamedTemporaryFile(
410
+ mode="w",
411
+ dir=self.graph_dir,
412
+ delete=False,
413
+ suffix=".json",
414
+ ) as tmp:
415
+ json.dump(queue, tmp, indent=2, default=str)
416
+ tmp_path = tmp.name
417
+
418
+ # Atomic rename
419
+ os.replace(tmp_path, self.file_path)
420
+ logger.debug(
421
+ f"Saved drift queue: {len(queue.get('activities', []))} activities"
422
+ )
423
+
424
+ except OSError as e:
425
+ logger.warning(f"Could not save drift queue: {e}")
426
+ except Exception as e:
427
+ logger.error(f"Unexpected error saving drift queue: {e}")
428
+
429
+ def add_activity(
430
+ self, activity: dict[str, Any], timestamp: datetime | None = None
431
+ ) -> None:
432
+ """
433
+ Add activity to drift queue.
434
+
435
+ Appends a high-drift activity to the queue for later classification.
436
+ Timestamp defaults to current time if not provided.
437
+
438
+ Args:
439
+ activity: Activity dict with keys: tool, summary, file_paths, drift_score, feature_id
440
+ timestamp: Activity timestamp (defaults to now)
441
+ """
442
+ if timestamp is None:
443
+ timestamp = datetime.now()
444
+
445
+ queue = self.load()
446
+ queue["activities"].append(
447
+ {
448
+ "timestamp": timestamp.isoformat(),
449
+ "tool": activity.get("tool"),
450
+ "summary": activity.get("summary"),
451
+ "file_paths": activity.get("file_paths", []),
452
+ "drift_score": activity.get("drift_score"),
453
+ "feature_id": activity.get("feature_id"),
454
+ }
455
+ )
456
+ self.save(queue)
457
+ logger.debug(
458
+ f"Added activity to drift queue (drift_score={activity.get('drift_score')})"
459
+ )
460
+
461
+ def clear(self) -> None:
462
+ """
463
+ Delete drift queue file.
464
+
465
+ Removes the entire drift queue, typically after classification completes.
466
+ """
467
+ try:
468
+ self.file_path.unlink(missing_ok=True)
469
+ logger.debug("Cleared drift queue")
470
+ except OSError as e:
471
+ logger.warning(f"Could not clear drift queue: {e}")
472
+
473
+ def clear_activities(self) -> None:
474
+ """
475
+ Clear activities from queue while preserving last_classification timestamp.
476
+
477
+ Called after successful classification to remove processed activities
478
+ while keeping track of when the last classification occurred.
479
+ """
480
+ try:
481
+ queue = {
482
+ "activities": [],
483
+ "last_classification": datetime.now().isoformat(),
484
+ }
485
+
486
+ # Preserve existing last_classification if this file already exists
487
+ if self.file_path.exists():
488
+ try:
489
+ with open(self.file_path) as f:
490
+ existing = json.load(f)
491
+ if existing.get("last_classification"):
492
+ queue["last_classification"] = existing[
493
+ "last_classification"
494
+ ]
495
+ except Exception:
496
+ pass
497
+
498
+ self.save(queue)
499
+ logger.debug(
500
+ "Cleared drift queue activities (preserved classification timestamp)"
501
+ )
502
+
503
+ except Exception as e:
504
+ logger.error(f"Error clearing drift queue activities: {e}")
@@ -0,0 +1,202 @@
1
+ """
2
+ Subagent Context Detection for Orchestrator Mode
3
+
4
+ This module provides utilities to detect when code is executing within a
5
+ delegated subagent context (spawned via Task() tool) vs. the main orchestrator.
6
+
7
+ Key Problem:
8
+ PreToolUse hooks (orchestrator-enforce.py, validator.py) enforce delegation
9
+ rules that block direct tool use in strict mode. However, subagents MUST use
10
+ tools directly - that's the delegated work. Without context detection, subagents
11
+ get blocked, making strict orchestrator mode unusable.
12
+
13
+ Solution:
14
+ Detect subagent context via multiple signals:
15
+ 1. Environment variables set by Claude Code when spawning Task() subagents
16
+ 2. Session state markers in database
17
+ 3. Parent session tracking
18
+
19
+ Usage:
20
+ from htmlgraph.hooks.subagent_detection import is_subagent_context
21
+
22
+ if is_subagent_context():
23
+ # Allow direct tool use - this is delegated work
24
+ return {"continue": True}
25
+ else:
26
+ # Enforce delegation rules - this is orchestrator
27
+ return enforce_delegation(tool, params)
28
+ """
29
+
30
+ import os
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+
35
+ def is_subagent_context() -> bool:
36
+ """
37
+ Check if we're executing within a delegated subagent (spawned via Task()).
38
+
39
+ Detection Strategy (in priority order):
40
+ 1. CLAUDE_SUBAGENT_ID environment variable (set by Task() spawner)
41
+ 2. CLAUDE_PARENT_SESSION_ID environment variable (set by Task() spawner)
42
+ 3. Session state marker in database (is_subagent flag)
43
+ 4. Active session has parent_session_id set
44
+
45
+ Returns:
46
+ True if executing in subagent context, False if orchestrator context
47
+
48
+ Note:
49
+ - Gracefully degrades if detection mechanisms fail (returns False)
50
+ - False positives are safe (allow direct tool use)
51
+ - False negatives would break subagents (must be avoided)
52
+ """
53
+ # Check 1: Direct environment variable from Task() spawner
54
+ if os.getenv("CLAUDE_SUBAGENT_ID"):
55
+ return True
56
+
57
+ # Check 2: Parent session ID indicates we're a subagent
58
+ if os.getenv("CLAUDE_PARENT_SESSION_ID"):
59
+ return True
60
+
61
+ # Check 3: Session state marker in database
62
+ try:
63
+ session_state = _load_session_state()
64
+ if session_state.get("is_subagent", False):
65
+ return True
66
+
67
+ # Check 4: Session has parent_session_id
68
+ if session_state.get("parent_session_id"):
69
+ return True
70
+ except Exception:
71
+ # Graceful degradation - if we can't check, assume NOT subagent
72
+ # This is safe because it only allows stricter enforcement
73
+ pass
74
+
75
+ # Check 5: Query database for active session with parent_session_id
76
+ try:
77
+ if _has_parent_session_in_db():
78
+ return True
79
+ except Exception:
80
+ pass
81
+
82
+ return False
83
+
84
+
85
+ def _load_session_state() -> dict[str, Any]:
86
+ """
87
+ Load session state from .htmlgraph/session-state.json.
88
+
89
+ Returns:
90
+ Session state dict, or empty dict if not found
91
+ """
92
+ try:
93
+ # Find .htmlgraph directory
94
+ graph_dir = _find_graph_dir()
95
+ if not graph_dir:
96
+ return {}
97
+
98
+ state_file = graph_dir / "session-state.json"
99
+ if not state_file.exists():
100
+ return {}
101
+
102
+ import json
103
+
104
+ result: dict[str, Any] = json.loads(state_file.read_text())
105
+ return result
106
+ except Exception:
107
+ return {}
108
+
109
+
110
+ def _has_parent_session_in_db() -> bool:
111
+ """
112
+ Check if current session has a parent_session_id in database.
113
+
114
+ Returns:
115
+ True if session is a subagent (has parent), False otherwise
116
+ """
117
+ try:
118
+ graph_dir = _find_graph_dir()
119
+ if not graph_dir:
120
+ return False
121
+
122
+ db_path = graph_dir / "htmlgraph.db"
123
+ if not db_path.exists():
124
+ return False
125
+
126
+ import sqlite3
127
+
128
+ # Get current session ID from environment or database
129
+
130
+ # We need hook_input to create context, but we don't have it here
131
+ # Fall back to environment check
132
+ session_id = os.getenv("HTMLGRAPH_SESSION_ID") or os.getenv("CLAUDE_SESSION_ID")
133
+
134
+ if not session_id:
135
+ # Try to get most recent session from database
136
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
137
+ cursor = conn.cursor()
138
+ cursor.execute("""
139
+ SELECT session_id FROM sessions
140
+ WHERE status = 'active'
141
+ ORDER BY created_at DESC
142
+ LIMIT 1
143
+ """)
144
+ row = cursor.fetchone()
145
+ if row:
146
+ session_id = row[0]
147
+ conn.close()
148
+
149
+ if not session_id:
150
+ return False
151
+
152
+ # Check if this session has a parent
153
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
154
+ cursor = conn.cursor()
155
+ cursor.execute(
156
+ """
157
+ SELECT parent_session_id FROM sessions
158
+ WHERE session_id = ?
159
+ """,
160
+ (session_id,),
161
+ )
162
+ row = cursor.fetchone()
163
+ conn.close()
164
+
165
+ if row and row[0]:
166
+ return True
167
+
168
+ except Exception:
169
+ pass
170
+
171
+ return False
172
+
173
+
174
+ def _find_graph_dir() -> Path | None:
175
+ """
176
+ Find .htmlgraph directory starting from current working directory.
177
+
178
+ Returns:
179
+ Path to .htmlgraph directory, or None if not found
180
+ """
181
+ try:
182
+ cwd = Path.cwd()
183
+ graph_dir = cwd / ".htmlgraph"
184
+
185
+ if graph_dir.exists():
186
+ return graph_dir
187
+
188
+ # Search up to 3 parent directories
189
+ for parent in [cwd.parent, cwd.parent.parent, cwd.parent.parent.parent]:
190
+ candidate = parent / ".htmlgraph"
191
+ if candidate.exists():
192
+ return candidate
193
+
194
+ except Exception:
195
+ pass
196
+
197
+ return None
198
+
199
+
200
+ __all__ = [
201
+ "is_subagent_context",
202
+ ]