htmlgraph 0.20.1__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 (304) 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 +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
@@ -0,0 +1,539 @@
1
+ """
2
+ Bounded path-finding and cycle detection for HtmlGraph.
3
+
4
+ Provides safe, deterministic graph traversal algorithms with built-in
5
+ cycle avoidance and depth bounds. Replaces timeout-based safety guards
6
+ with structural guarantees:
7
+
8
+ - BFS for shortest paths: O(V+E) guaranteed
9
+ - DFS with per-path visited tracking for bounded enumeration
10
+ - Cycle detection with configurable depth limits
11
+
12
+ All algorithms terminate deterministically via depth bounds,
13
+ never requiring timeouts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections import deque
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from htmlgraph.edge_index import EdgeRef
24
+ from htmlgraph.graph import HtmlGraph
25
+
26
+
27
+ @dataclass
28
+ class PathResult:
29
+ """
30
+ Result of a path-finding operation.
31
+
32
+ Represents an ordered sequence of nodes connected by edges,
33
+ forming a path through the graph.
34
+
35
+ Attributes:
36
+ nodes: Ordered list of node IDs in the path (source first, target last).
37
+ edges: List of EdgeRef objects for each edge traversed.
38
+ length: Number of edges in the path (len(nodes) - 1).
39
+ relationship_types: Distinct edge relationship types used in this path.
40
+ """
41
+
42
+ nodes: list[str]
43
+ edges: list[EdgeRef]
44
+ length: int
45
+ relationship_types: list[str]
46
+
47
+
48
+ @dataclass
49
+ class CycleResult:
50
+ """
51
+ Result of a cycle detection operation.
52
+
53
+ Represents a cycle found in the graph, identified by the sequence
54
+ of nodes that form a closed loop.
55
+
56
+ Attributes:
57
+ cycle: Node IDs forming the cycle. The first and last element
58
+ are the same node, closing the loop.
59
+ length: Number of edges in the cycle.
60
+ edge_types: Distinct relationship types in the cycle.
61
+ involves_node: The node that was queried or that participates
62
+ in this cycle.
63
+ """
64
+
65
+ cycle: list[str]
66
+ length: int
67
+ edge_types: list[str]
68
+ involves_node: str
69
+
70
+
71
+ @dataclass
72
+ class BoundedPathFinder:
73
+ """
74
+ Safe, bounded graph traversal with cycle avoidance.
75
+
76
+ Provides deterministic path-finding and cycle detection algorithms
77
+ that terminate based on depth bounds rather than timeouts.
78
+
79
+ All methods use the graph's EdgeIndex for efficient O(1) neighbor
80
+ lookups and support optional edge-type filtering.
81
+
82
+ Example:
83
+ >>> from htmlgraph.graph import HtmlGraph
84
+ >>> graph = HtmlGraph("features/", auto_load=True)
85
+ >>> finder = BoundedPathFinder(graph)
86
+ >>> path = finder.any_shortest("feat-001", "feat-010")
87
+ >>> if path:
88
+ ... print(f"Shortest path: {' -> '.join(path.nodes)}")
89
+ >>> cycles = finder.find_cycles("feat-001")
90
+ >>> for c in cycles:
91
+ ... print(f"Cycle of length {c.length}: {c.cycle}")
92
+ """
93
+
94
+ graph: HtmlGraph
95
+ max_depth: int = 20
96
+
97
+ # Internal caches, not part of __init__ signature
98
+ _adjacency_cache: dict[str, dict[str, list[_NeighborInfo]]] = field(
99
+ default_factory=dict, init=False, repr=False
100
+ )
101
+
102
+ def _get_neighbors(
103
+ self,
104
+ node_id: str,
105
+ edge_types: list[str] | None,
106
+ direction: str = "outgoing",
107
+ ) -> list[_NeighborInfo]:
108
+ """
109
+ Get neighbors of a node with edge metadata, using the EdgeIndex.
110
+
111
+ Args:
112
+ node_id: The node to get neighbors for.
113
+ edge_types: If provided, only follow edges with these relationship types.
114
+ direction: "outgoing" follows edges from node_id, "incoming" follows
115
+ edges pointing to node_id.
116
+
117
+ Returns:
118
+ List of _NeighborInfo with neighbor_id and the EdgeRef.
119
+ """
120
+
121
+ results: list[_NeighborInfo] = []
122
+ if direction == "outgoing":
123
+ refs = self.graph._edge_index.get_outgoing(node_id)
124
+ for ref in refs:
125
+ if edge_types is None or ref.relationship in edge_types:
126
+ results.append(_NeighborInfo(ref.target_id, ref))
127
+ else: # incoming
128
+ refs = self.graph._edge_index.get_incoming(node_id)
129
+ for ref in refs:
130
+ if edge_types is None or ref.relationship in edge_types:
131
+ results.append(_NeighborInfo(ref.source_id, ref))
132
+ return results
133
+
134
+ # ------------------------------------------------------------------
135
+ # Public API
136
+ # ------------------------------------------------------------------
137
+
138
+ def any_shortest(
139
+ self,
140
+ from_id: str,
141
+ to_id: str,
142
+ edge_types: list[str] | None = None,
143
+ ) -> PathResult | None:
144
+ """
145
+ Find ANY shortest path between two nodes using BFS.
146
+
147
+ Guaranteed O(V+E) time complexity with built-in cycle avoidance
148
+ via the BFS visited set.
149
+
150
+ Args:
151
+ from_id: Source node ID.
152
+ to_id: Target node ID.
153
+ edge_types: If provided, only traverse edges with these relationship types.
154
+
155
+ Returns:
156
+ A PathResult for one shortest path, or None if no path exists.
157
+ """
158
+ if from_id not in self.graph._nodes or to_id not in self.graph._nodes:
159
+ return None
160
+
161
+ if from_id == to_id:
162
+ return PathResult(
163
+ nodes=[from_id], edges=[], length=0, relationship_types=[]
164
+ )
165
+
166
+ # BFS: queue entries are (current_node, path_of_nodes, path_of_edges)
167
+ queue: deque[tuple[str, list[str], list[EdgeRef]]] = deque()
168
+ queue.append((from_id, [from_id], []))
169
+ visited: set[str] = {from_id}
170
+
171
+ while queue:
172
+ current, path_nodes, path_edges = queue.popleft()
173
+
174
+ for info in self._get_neighbors(current, edge_types, "outgoing"):
175
+ neighbor = info.neighbor_id
176
+ edge_ref = info.edge_ref
177
+
178
+ new_nodes = path_nodes + [neighbor]
179
+ new_edges = path_edges + [edge_ref]
180
+
181
+ if neighbor == to_id:
182
+ rel_types = sorted(set(e.relationship for e in new_edges))
183
+ return PathResult(
184
+ nodes=new_nodes,
185
+ edges=new_edges,
186
+ length=len(new_edges),
187
+ relationship_types=rel_types,
188
+ )
189
+
190
+ if neighbor not in visited and neighbor in self.graph._nodes:
191
+ visited.add(neighbor)
192
+ queue.append((neighbor, new_nodes, new_edges))
193
+
194
+ return None
195
+
196
+ def all_shortest(
197
+ self,
198
+ from_id: str,
199
+ to_id: str,
200
+ edge_types: list[str] | None = None,
201
+ ) -> list[PathResult]:
202
+ """
203
+ Find ALL shortest paths (same minimum length) between two nodes.
204
+
205
+ Uses BFS to determine the shortest distance, then enumerates all
206
+ paths of exactly that length. The BFS phase is O(V+E); the
207
+ enumeration phase explores only paths within the shortest distance
208
+ bound.
209
+
210
+ Args:
211
+ from_id: Source node ID.
212
+ to_id: Target node ID.
213
+ edge_types: If provided, only traverse edges with these relationship types.
214
+
215
+ Returns:
216
+ List of PathResult objects, all having the same minimum length.
217
+ Empty list if no path exists.
218
+ """
219
+ if from_id not in self.graph._nodes or to_id not in self.graph._nodes:
220
+ return []
221
+
222
+ if from_id == to_id:
223
+ return [
224
+ PathResult(nodes=[from_id], edges=[], length=0, relationship_types=[])
225
+ ]
226
+
227
+ # Phase 1: BFS to find shortest distance and predecessor map.
228
+ # For each node, record ALL predecessors at the shortest distance.
229
+ dist: dict[str, int] = {from_id: 0}
230
+ # predecessors maps node -> list of (predecessor_node, edge_ref)
231
+ predecessors: dict[str, list[tuple[str, EdgeRef]]] = {}
232
+ queue: deque[str] = deque([from_id])
233
+
234
+ while queue:
235
+ current = queue.popleft()
236
+ current_dist = dist[current]
237
+
238
+ for info in self._get_neighbors(current, edge_types, "outgoing"):
239
+ neighbor = info.neighbor_id
240
+ edge_ref = info.edge_ref
241
+
242
+ if neighbor not in self.graph._nodes:
243
+ continue
244
+
245
+ new_dist = current_dist + 1
246
+
247
+ if neighbor not in dist:
248
+ # First time reaching this node
249
+ dist[neighbor] = new_dist
250
+ predecessors[neighbor] = [(current, edge_ref)]
251
+ queue.append(neighbor)
252
+ elif dist[neighbor] == new_dist:
253
+ # Same shortest distance, add alternative predecessor
254
+ predecessors[neighbor].append((current, edge_ref))
255
+
256
+ if to_id not in dist:
257
+ return []
258
+
259
+ # Phase 2: Backtrack from to_id to from_id using predecessors.
260
+ results: list[PathResult] = []
261
+
262
+ def _backtrack(
263
+ node: str,
264
+ path_nodes: list[str],
265
+ path_edges: list[EdgeRef],
266
+ ) -> None:
267
+ if node == from_id:
268
+ # Reverse to get source-to-target order
269
+ final_nodes = list(reversed(path_nodes))
270
+ final_edges = list(reversed(path_edges))
271
+ rel_types = sorted(set(e.relationship for e in final_edges))
272
+ results.append(
273
+ PathResult(
274
+ nodes=final_nodes,
275
+ edges=final_edges,
276
+ length=len(final_edges),
277
+ relationship_types=rel_types,
278
+ )
279
+ )
280
+ return
281
+
282
+ for pred_node, edge_ref in predecessors.get(node, []):
283
+ path_nodes.append(pred_node)
284
+ path_edges.append(edge_ref)
285
+ _backtrack(pred_node, path_nodes, path_edges)
286
+ path_nodes.pop()
287
+ path_edges.pop()
288
+
289
+ _backtrack(to_id, [to_id], [])
290
+ return results
291
+
292
+ def bounded_paths(
293
+ self,
294
+ from_id: str,
295
+ to_id: str,
296
+ max_depth: int | None = None,
297
+ max_results: int = 100,
298
+ edge_types: list[str] | None = None,
299
+ ) -> list[PathResult]:
300
+ """
301
+ Find paths up to max_depth with built-in cycle avoidance per path.
302
+
303
+ Replaces all_paths() with a deterministic depth bound instead of
304
+ a timeout. Each path independently tracks visited nodes to allow
305
+ different paths to share intermediate nodes while preventing
306
+ cycles within any single path.
307
+
308
+ Args:
309
+ from_id: Source node ID.
310
+ to_id: Target node ID.
311
+ max_depth: Maximum path length in edges. Defaults to self.max_depth.
312
+ max_results: Maximum number of paths to return (default 100).
313
+ edge_types: If provided, only traverse edges with these relationship types.
314
+
315
+ Returns:
316
+ List of PathResult objects, up to max_results.
317
+ """
318
+ depth_limit = max_depth if max_depth is not None else self.max_depth
319
+
320
+ if from_id not in self.graph._nodes or to_id not in self.graph._nodes:
321
+ return []
322
+
323
+ if from_id == to_id:
324
+ return [
325
+ PathResult(nodes=[from_id], edges=[], length=0, relationship_types=[])
326
+ ]
327
+
328
+ results: list[PathResult] = []
329
+
330
+ def _dfs(
331
+ current: str,
332
+ path_nodes: list[str],
333
+ path_edges: list[EdgeRef],
334
+ visited: set[str],
335
+ ) -> None:
336
+ if len(results) >= max_results:
337
+ return
338
+
339
+ if len(path_edges) > depth_limit:
340
+ return
341
+
342
+ if current == to_id:
343
+ rel_types = sorted(set(e.relationship for e in path_edges))
344
+ results.append(
345
+ PathResult(
346
+ nodes=list(path_nodes),
347
+ edges=list(path_edges),
348
+ length=len(path_edges),
349
+ relationship_types=rel_types,
350
+ )
351
+ )
352
+ return
353
+
354
+ # Don't go deeper if we're at the depth limit
355
+ if len(path_edges) >= depth_limit:
356
+ return
357
+
358
+ for info in self._get_neighbors(current, edge_types, "outgoing"):
359
+ neighbor = info.neighbor_id
360
+ if neighbor not in visited and neighbor in self.graph._nodes:
361
+ visited.add(neighbor)
362
+ path_nodes.append(neighbor)
363
+ path_edges.append(info.edge_ref)
364
+ _dfs(neighbor, path_nodes, path_edges, visited)
365
+ path_edges.pop()
366
+ path_nodes.pop()
367
+ visited.remove(neighbor)
368
+
369
+ _dfs(from_id, [from_id], [], {from_id})
370
+ return results
371
+
372
+ def find_cycles(
373
+ self,
374
+ node_id: str | None = None,
375
+ edge_types: list[str] | None = None,
376
+ max_cycle_length: int = 10,
377
+ ) -> list[CycleResult]:
378
+ """
379
+ Detect cycles in the graph.
380
+
381
+ If node_id is provided, finds cycles that include that specific node.
382
+ Otherwise, finds all cycles up to max_cycle_length in the entire graph.
383
+
384
+ Uses DFS with depth bounding for deterministic termination.
385
+ Inspired by SQL/PGQ ownership-cycle detection patterns.
386
+
387
+ Args:
388
+ node_id: If provided, only find cycles involving this node.
389
+ edge_types: If provided, only follow edges with these relationship types.
390
+ max_cycle_length: Maximum cycle length to search for (default 10).
391
+
392
+ Returns:
393
+ List of CycleResult objects describing each cycle found.
394
+ """
395
+ if node_id is not None:
396
+ return self._find_cycles_for_node(node_id, edge_types, max_cycle_length)
397
+
398
+ # Find cycles for all nodes
399
+ all_cycles: list[CycleResult] = []
400
+ seen_cycles: set[tuple[str, ...]] = set()
401
+
402
+ for nid in self.graph._nodes:
403
+ for cycle_result in self._find_cycles_for_node(
404
+ nid, edge_types, max_cycle_length
405
+ ):
406
+ # Normalize cycle for deduplication: rotate so smallest ID is first
407
+ cycle_nodes = cycle_result.cycle[:-1] # Remove closing duplicate
408
+ if not cycle_nodes:
409
+ continue
410
+ min_idx = cycle_nodes.index(min(cycle_nodes))
411
+ normalized = tuple(cycle_nodes[min_idx:] + cycle_nodes[:min_idx])
412
+
413
+ if normalized not in seen_cycles:
414
+ seen_cycles.add(normalized)
415
+ all_cycles.append(cycle_result)
416
+
417
+ return all_cycles
418
+
419
+ def _find_cycles_for_node(
420
+ self,
421
+ node_id: str,
422
+ edge_types: list[str] | None,
423
+ max_cycle_length: int,
424
+ ) -> list[CycleResult]:
425
+ """
426
+ Find all cycles involving a specific node, up to max_cycle_length.
427
+
428
+ Uses iterative DFS from node_id looking for paths that return to it.
429
+
430
+ Args:
431
+ node_id: The node to find cycles for.
432
+ edge_types: Optional edge type filter.
433
+ max_cycle_length: Maximum edges in a cycle.
434
+
435
+ Returns:
436
+ List of CycleResult objects for cycles involving node_id.
437
+ """
438
+ if node_id not in self.graph._nodes:
439
+ return []
440
+
441
+ results: list[CycleResult] = []
442
+
443
+ def _dfs(
444
+ current: str,
445
+ path: list[str],
446
+ path_edges: list[EdgeRef],
447
+ visited: set[str],
448
+ ) -> None:
449
+ if len(path_edges) > max_cycle_length:
450
+ return
451
+
452
+ for info in self._get_neighbors(current, edge_types, "outgoing"):
453
+ neighbor = info.neighbor_id
454
+ candidate_length = len(path_edges) + 1
455
+
456
+ if neighbor == node_id:
457
+ # Found a cycle back to start (includes self-loops)
458
+ if candidate_length <= max_cycle_length:
459
+ cycle_path = path + [node_id]
460
+ all_edges = path_edges + [info.edge_ref]
461
+ edge_type_set = sorted(set(e.relationship for e in all_edges))
462
+ results.append(
463
+ CycleResult(
464
+ cycle=cycle_path,
465
+ length=len(all_edges),
466
+ edge_types=edge_type_set,
467
+ involves_node=node_id,
468
+ )
469
+ )
470
+ elif (
471
+ neighbor not in visited
472
+ and neighbor in self.graph._nodes
473
+ and candidate_length < max_cycle_length
474
+ ):
475
+ visited.add(neighbor)
476
+ path.append(neighbor)
477
+ path_edges.append(info.edge_ref)
478
+ _dfs(neighbor, path, path_edges, visited)
479
+ path_edges.pop()
480
+ path.pop()
481
+ visited.remove(neighbor)
482
+
483
+ _dfs(node_id, [node_id], [], {node_id})
484
+ return results
485
+
486
+ def reachable_set(
487
+ self,
488
+ from_id: str,
489
+ edge_types: list[str] | None = None,
490
+ direction: str = "outgoing",
491
+ max_depth: int | None = None,
492
+ ) -> set[str]:
493
+ """
494
+ Find all nodes reachable from a starting node within a depth bound.
495
+
496
+ Uses BFS for level-by-level exploration. Useful for transitive
497
+ dependency analysis with limits.
498
+
499
+ Args:
500
+ from_id: Starting node ID.
501
+ edge_types: If provided, only follow edges with these relationship types.
502
+ direction: "outgoing" follows edges from source, "incoming" follows
503
+ edges pointing to source.
504
+ max_depth: Maximum traversal depth. Defaults to self.max_depth.
505
+
506
+ Returns:
507
+ Set of reachable node IDs (does not include from_id itself).
508
+ """
509
+ depth_limit = max_depth if max_depth is not None else self.max_depth
510
+
511
+ if from_id not in self.graph._nodes:
512
+ return set()
513
+
514
+ reachable: set[str] = set()
515
+ visited: set[str] = {from_id}
516
+ queue: deque[tuple[str, int]] = deque([(from_id, 0)])
517
+
518
+ while queue:
519
+ current, depth = queue.popleft()
520
+
521
+ if depth >= depth_limit:
522
+ continue
523
+
524
+ for info in self._get_neighbors(current, edge_types, direction):
525
+ neighbor = info.neighbor_id
526
+ if neighbor not in visited and neighbor in self.graph._nodes:
527
+ visited.add(neighbor)
528
+ reachable.add(neighbor)
529
+ queue.append((neighbor, depth + 1))
530
+
531
+ return reachable
532
+
533
+
534
+ @dataclass
535
+ class _NeighborInfo:
536
+ """Internal helper pairing a neighbor ID with its edge reference."""
537
+
538
+ neighbor_id: str
539
+ edge_ref: EdgeRef
@@ -1,10 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  """
2
4
  Base builder class for fluent node creation.
3
5
 
4
6
  Provides common builder patterns shared across all node types.
5
7
  """
6
8
 
7
- from __future__ import annotations
8
9
 
9
10
  from datetime import datetime
10
11
  from typing import TYPE_CHECKING, Any, Generic, TypeVar
@@ -161,6 +162,9 @@ class BaseBuilder(Generic[BuilderT]):
161
162
 
162
163
  Returns:
163
164
  Created Node instance
165
+
166
+ Raises:
167
+ ValueError: If node type requires track_id but none is set
164
168
  """
165
169
  # Generate collision-resistant ID if not provided
166
170
  if "id" not in self._data:
@@ -169,6 +173,33 @@ class BaseBuilder(Generic[BuilderT]):
169
173
  title=self._data.get("title", ""),
170
174
  )
171
175
 
176
+ # Validate track_id requirement for features
177
+ node_type = self._data.get("type", self.node_type)
178
+ if node_type == "feature" and not self._data.get("track_id"):
179
+ # Get available tracks for helpful error message
180
+ try:
181
+ tracks = self._sdk.tracks.all()
182
+ track_options = "\n".join(
183
+ [f" - {track.id}: {track.title}" for track in tracks[:10]]
184
+ )
185
+ if len(tracks) > 10:
186
+ track_options += f"\n ... and {len(tracks) - 10} more tracks"
187
+
188
+ error_msg = (
189
+ f"Feature '{self._data.get('title', 'Unknown')}' requires a track linkage.\n\n"
190
+ f"Use: .set_track('track_id') to link to a track before saving.\n\n"
191
+ f"Available tracks:\n{track_options or ' (no tracks found)'}\n\n"
192
+ f"Create a track first: sdk.tracks.create('Track Title')"
193
+ )
194
+ except Exception:
195
+ # Fallback error message if we can't fetch tracks
196
+ error_msg = (
197
+ f"Feature '{self._data.get('title', 'Unknown')}' requires a track linkage.\n"
198
+ f"Use: .set_track('track_id') to link to a track before saving."
199
+ )
200
+
201
+ raise ValueError(error_msg)
202
+
172
203
  # Import Node here to avoid circular imports
173
204
  from htmlgraph.models import Node
174
205
 
@@ -191,7 +222,31 @@ class BaseBuilder(Generic[BuilderT]):
191
222
  graph = HtmlGraph(graph_path, auto_load=False)
192
223
  graph.add(node)
193
224
 
194
- # Log creation event if SessionManager is available and agent is set
225
+ # Log creation event to SQLite for dashboard observability
226
+ try:
227
+ action_type = self._data.get("type", self.node_type)
228
+ self._sdk._log_event(
229
+ event_type="tool_call",
230
+ tool_name="SDK.create",
231
+ input_summary=f"Create {action_type}: {self._data.get('title', 'Untitled')}",
232
+ output_summary=f"Created {collection_name}/{node.id}",
233
+ context={
234
+ "collection": collection_name,
235
+ "node_id": node.id,
236
+ "node_type": action_type,
237
+ "title": node.title,
238
+ "status": self._data.get("status", "todo"),
239
+ "priority": self._data.get("priority", "medium"),
240
+ },
241
+ cost_tokens=50,
242
+ )
243
+ except Exception as e:
244
+ # Never break save because of logging
245
+ import logging
246
+
247
+ logging.debug(f"Event logging failed: {e}")
248
+
249
+ # Also log via SessionManager for backward compatibility
195
250
  if hasattr(self._sdk, "session_manager") and self._sdk.agent:
196
251
  try:
197
252
  self._sdk.session_manager._maybe_log_work_item_action(
htmlgraph/builders/bug.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  """
2
4
  Bug builder for creating bug report nodes.
3
5
 
@@ -5,12 +7,11 @@ Extends BaseBuilder with bug-specific methods like
5
7
  severity and reproduction steps.
6
8
  """
7
9
 
8
- from __future__ import annotations
9
10
 
10
- from typing import TYPE_CHECKING
11
+ from typing import TYPE_CHECKING, Any
11
12
 
12
13
  if TYPE_CHECKING:
13
- pass
14
+ from htmlgraph.sdk import SDK
14
15
 
15
16
  from htmlgraph.builders.base import BaseBuilder
16
17
 
@@ -37,6 +38,21 @@ class BugBuilder(BaseBuilder["BugBuilder"]):
37
38
 
38
39
  node_type = "bug"
39
40
 
41
+ def __init__(self, sdk: SDK, title: str, **kwargs: Any):
42
+ """Initialize bug builder with agent attribution."""
43
+ super().__init__(sdk, title, **kwargs)
44
+ # Auto-assign agent from SDK for work tracking
45
+ if sdk._agent_id:
46
+ self._data["agent_assigned"] = sdk._agent_id
47
+ elif "agent_assigned" not in self._data:
48
+ # Log warning if agent not assigned (defensive check)
49
+ import logging
50
+
51
+ logging.warning(
52
+ f"Creating bug '{self._data.get('title', 'Unknown')}' without agent attribution. "
53
+ "Pass agent='name' to SDK() initialization."
54
+ )
55
+
40
56
  def set_severity(self, severity: str) -> BugBuilder:
41
57
  """
42
58
  Set bug severity level.