htmlgraph 0.26.23__py3-none-any.whl → 0.26.25__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 (36) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/analytics/pattern_learning.py +771 -0
  3. htmlgraph/api/main.py +56 -23
  4. htmlgraph/api/templates/dashboard-redesign.html +3 -3
  5. htmlgraph/api/templates/dashboard.html +3 -3
  6. htmlgraph/api/templates/partials/work-items.html +613 -0
  7. htmlgraph/builders/track.py +26 -0
  8. htmlgraph/cli/base.py +31 -7
  9. htmlgraph/cli/work/__init__.py +74 -0
  10. htmlgraph/cli/work/browse.py +114 -0
  11. htmlgraph/cli/work/snapshot.py +558 -0
  12. htmlgraph/collections/base.py +34 -0
  13. htmlgraph/collections/todo.py +12 -0
  14. htmlgraph/converter.py +11 -0
  15. htmlgraph/db/schema.py +34 -1
  16. htmlgraph/hooks/orchestrator.py +88 -14
  17. htmlgraph/hooks/session_handler.py +3 -1
  18. htmlgraph/models.py +22 -2
  19. htmlgraph/orchestration/__init__.py +4 -0
  20. htmlgraph/orchestration/plugin_manager.py +1 -2
  21. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  22. htmlgraph/refs.py +343 -0
  23. htmlgraph/sdk.py +162 -1
  24. htmlgraph/session_manager.py +154 -2
  25. htmlgraph/sessions/__init__.py +23 -0
  26. htmlgraph/sessions/handoff.py +755 -0
  27. htmlgraph/track_builder.py +12 -0
  28. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/METADATA +1 -1
  29. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/RECORD +36 -28
  30. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/dashboard.html +0 -0
  31. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/styles.css +0 -0
  32. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  33. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  34. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  35. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/WHEEL +0 -0
  36. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/entry_points.txt +0 -0
htmlgraph/refs.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ Short reference manager for graph nodes.
3
+
4
+ Manages persistent mapping of short refs (@f1, @t2, @b5) to full node IDs,
5
+ enabling AI-friendly snapshots and queries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+
15
+ class RefManager:
16
+ """
17
+ Manages short references (@f1, @t1, @b5, etc.) for graph nodes.
18
+
19
+ Maintains a persistent refs.json file mapping short refs to full node IDs.
20
+ Refs are stable across sessions and auto-generated on first access.
21
+
22
+ Ref format: @{prefix}{number}
23
+ Prefixes: f=feature, t=track, b=bug, s=spike, c=chore, e=epic, d=todo
24
+
25
+ Example:
26
+ >>> ref_mgr = RefManager(Path(".htmlgraph"))
27
+ >>> ref = ref_mgr.generate_ref("feat-a1b2c3d4")
28
+ >>> print(ref) # "@f1"
29
+ >>> full_id = ref_mgr.resolve_ref("@f1")
30
+ >>> print(full_id) # "feat-a1b2c3d4"
31
+ """
32
+
33
+ # Map node ID prefix to short ref prefix
34
+ PREFIX_MAP = {
35
+ "feat": "f",
36
+ "trk": "t",
37
+ "bug": "b",
38
+ "spk": "s",
39
+ "chr": "c",
40
+ "epc": "e",
41
+ "todo": "d",
42
+ "phs": "p",
43
+ }
44
+
45
+ # Reverse mapping for type lookup
46
+ TYPE_MAP = {
47
+ "f": "feature",
48
+ "t": "track",
49
+ "b": "bug",
50
+ "s": "spike",
51
+ "c": "chore",
52
+ "e": "epic",
53
+ "d": "todo",
54
+ "p": "phase",
55
+ }
56
+
57
+ def __init__(self, graph_dir: Path):
58
+ """
59
+ Initialize RefManager.
60
+
61
+ Args:
62
+ graph_dir: Path to .htmlgraph directory
63
+ """
64
+ self.graph_dir = Path(graph_dir)
65
+ self.refs_file = self.graph_dir / "refs.json"
66
+ self._refs: dict[str, str] = {} # Maps: "@f1" -> "feat-a1b2c3d4"
67
+ self._reverse_refs: dict[str, str] = {} # Maps: "feat-a1b2c3d4" -> "@f1"
68
+ self._load()
69
+
70
+ def _load(self) -> None:
71
+ """Load refs.json into memory."""
72
+ if not self.refs_file.exists():
73
+ self._refs = {}
74
+ self._reverse_refs = {}
75
+ return
76
+
77
+ try:
78
+ with open(self.refs_file, encoding="utf-8") as f:
79
+ data = json.load(f)
80
+ self._refs = data.get("refs", {})
81
+
82
+ # Build reverse mapping
83
+ self._reverse_refs = {v: k for k, v in self._refs.items()}
84
+ except (json.JSONDecodeError, OSError) as e:
85
+ # Corrupted file - start fresh
86
+ import logging
87
+
88
+ logging.warning(f"Failed to load refs.json: {e}. Starting fresh.")
89
+ self._refs = {}
90
+ self._reverse_refs = {}
91
+
92
+ def _save(self) -> None:
93
+ """Save refs to refs.json."""
94
+ # Ensure directory exists
95
+ self.graph_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ data = {
98
+ "refs": self._refs,
99
+ "version": 1,
100
+ "regenerated_at": datetime.now().isoformat(),
101
+ }
102
+
103
+ try:
104
+ with open(self.refs_file, "w", encoding="utf-8") as f:
105
+ json.dump(data, f, indent=2, ensure_ascii=False)
106
+ except OSError as e:
107
+ import logging
108
+
109
+ logging.error(f"Failed to save refs.json: {e}")
110
+
111
+ def _parse_node_type(self, node_id: str) -> str | None:
112
+ """
113
+ Extract node type prefix from node ID.
114
+
115
+ Args:
116
+ node_id: Full node ID like "feat-a1b2c3d4"
117
+
118
+ Returns:
119
+ Node type prefix (e.g., "feat") or None if invalid
120
+ """
121
+ if "-" not in node_id:
122
+ return None
123
+
124
+ prefix = node_id.split("-", 1)[0]
125
+ return prefix if prefix in self.PREFIX_MAP else None
126
+
127
+ def _next_ref_number(self, prefix: str) -> int:
128
+ """
129
+ Get next available ref number for a type.
130
+
131
+ Args:
132
+ prefix: Short ref prefix (e.g., "f", "t", "b")
133
+
134
+ Returns:
135
+ Next available number (1, 2, 3, ...)
136
+ """
137
+ # Find all refs with this prefix
138
+ existing = [int(ref[2:]) for ref in self._refs if ref.startswith(f"@{prefix}")]
139
+ return max(existing, default=0) + 1
140
+
141
+ def generate_ref(self, node_id: str) -> str:
142
+ """
143
+ Generate a short ref for a node ID.
144
+
145
+ This method is idempotent - calling it multiple times with the same
146
+ node_id returns the same ref without creating duplicates.
147
+
148
+ Args:
149
+ node_id: Full node ID like "feat-a1b2c3d4"
150
+
151
+ Returns:
152
+ Short ref like "@f1"
153
+
154
+ Raises:
155
+ ValueError: If node_id has invalid format
156
+
157
+ Example:
158
+ >>> ref = ref_mgr.generate_ref("feat-abc123")
159
+ >>> print(ref) # "@f1"
160
+ >>> # Second call returns same ref
161
+ >>> ref2 = ref_mgr.generate_ref("feat-abc123")
162
+ >>> assert ref == ref2
163
+ """
164
+ # Check if already has ref (idempotent)
165
+ if node_id in self._reverse_refs:
166
+ return self._reverse_refs[node_id]
167
+
168
+ # Parse node type
169
+ node_prefix = self._parse_node_type(node_id)
170
+ if not node_prefix:
171
+ raise ValueError(f"Invalid node ID format: {node_id}")
172
+
173
+ # Get short prefix
174
+ short_prefix = self.PREFIX_MAP[node_prefix]
175
+
176
+ # Generate next ref
177
+ number = self._next_ref_number(short_prefix)
178
+ short_ref = f"@{short_prefix}{number}"
179
+
180
+ # Store mappings
181
+ self._refs[short_ref] = node_id
182
+ self._reverse_refs[node_id] = short_ref
183
+
184
+ # Persist
185
+ self._save()
186
+
187
+ return short_ref
188
+
189
+ def get_ref(self, node_id: str) -> str | None:
190
+ """
191
+ Get existing ref for a node ID (create if not exist).
192
+
193
+ Args:
194
+ node_id: Full node ID like "feat-a1b2c3d4"
195
+
196
+ Returns:
197
+ Short ref like "@f1", or None if node_id invalid
198
+
199
+ Example:
200
+ >>> ref = ref_mgr.get_ref("feat-abc123")
201
+ >>> # Creates ref if doesn't exist
202
+ """
203
+ # Return existing ref
204
+ if node_id in self._reverse_refs:
205
+ return self._reverse_refs[node_id]
206
+
207
+ # Generate new ref if valid node ID
208
+ try:
209
+ return self.generate_ref(node_id)
210
+ except ValueError:
211
+ return None
212
+
213
+ def resolve_ref(self, short_ref: str) -> str | None:
214
+ """
215
+ Resolve short ref to full node ID.
216
+
217
+ Args:
218
+ short_ref: "@f1", "@t2", etc.
219
+
220
+ Returns:
221
+ Full node ID or None if not found
222
+
223
+ Example:
224
+ >>> full_id = ref_mgr.resolve_ref("@f1")
225
+ >>> print(full_id) # "feat-abc123"
226
+ """
227
+ return self._refs.get(short_ref)
228
+
229
+ def get_all_refs(self) -> dict[str, str]:
230
+ """
231
+ Return all refs.
232
+
233
+ Returns:
234
+ Dict mapping short refs to full IDs: {"@f1": "feat-a1b2c3d4", ...}
235
+ """
236
+ return self._refs.copy()
237
+
238
+ def get_refs_by_type(self, node_type: str) -> list[tuple[str, str]]:
239
+ """
240
+ Get all refs for a specific type.
241
+
242
+ Args:
243
+ node_type: "feature", "track", "bug", "spike", "chore", "epic", "todo", "phase"
244
+
245
+ Returns:
246
+ List of (short_ref, full_id) tuples sorted by ref number
247
+
248
+ Example:
249
+ >>> feature_refs = ref_mgr.get_refs_by_type("feature")
250
+ >>> for ref, full_id in feature_refs:
251
+ ... print(f"{ref} -> {full_id}")
252
+ """
253
+ # Get prefix for this type
254
+ prefix = None
255
+ for short_prefix, type_name in self.TYPE_MAP.items():
256
+ if type_name == node_type:
257
+ prefix = short_prefix
258
+ break
259
+
260
+ if not prefix:
261
+ return []
262
+
263
+ # Filter refs by prefix
264
+ matching = [
265
+ (ref, full_id)
266
+ for ref, full_id in self._refs.items()
267
+ if ref.startswith(f"@{prefix}")
268
+ ]
269
+
270
+ # Sort by ref number
271
+ def sort_key(item: tuple[str, str]) -> int:
272
+ ref = item[0]
273
+ try:
274
+ return int(ref[2:]) # Extract number after "@f"
275
+ except (ValueError, IndexError):
276
+ return 0
277
+
278
+ return sorted(matching, key=sort_key)
279
+
280
+ def rebuild_refs(self) -> None:
281
+ """
282
+ Rebuild refs from all .htmlgraph/ files (recovery tool).
283
+
284
+ Scans all collection directories (features/, tracks/, bugs/, etc.)
285
+ and rebuilds refs.json from scratch. Preserves existing refs where
286
+ possible to maintain stability.
287
+
288
+ This is a recovery tool for when refs.json is corrupted or deleted.
289
+
290
+ Example:
291
+ >>> ref_mgr.rebuild_refs()
292
+ >>> # Refs regenerated from file system
293
+ """
294
+ # Save current refs for preservation
295
+ old_refs = self._refs.copy()
296
+
297
+ # Clear in-memory refs
298
+ self._refs = {}
299
+ self._reverse_refs = {}
300
+
301
+ # Scan all collection directories
302
+ collections = [
303
+ "features",
304
+ "tracks",
305
+ "bugs",
306
+ "spikes",
307
+ "chores",
308
+ "epics",
309
+ "todos",
310
+ "phases",
311
+ ]
312
+
313
+ for collection in collections:
314
+ collection_dir = self.graph_dir / collection
315
+ if not collection_dir.exists():
316
+ continue
317
+
318
+ # Scan HTML files
319
+ for html_file in collection_dir.glob("*.html"):
320
+ # Extract node ID from filename (without .html)
321
+ node_id = html_file.stem
322
+
323
+ # Skip if invalid format
324
+ if not self._parse_node_type(node_id):
325
+ continue
326
+
327
+ # Try to preserve existing ref
328
+ if node_id in {v for v in old_refs.values()}:
329
+ # Find old ref
330
+ old_ref = next(
331
+ (k for k, v in old_refs.items() if v == node_id), None
332
+ )
333
+ if old_ref:
334
+ # Preserve old ref
335
+ self._refs[old_ref] = node_id
336
+ self._reverse_refs[node_id] = old_ref
337
+ continue
338
+
339
+ # Generate new ref
340
+ self.generate_ref(node_id)
341
+
342
+ # Save to disk
343
+ self._save()
htmlgraph/sdk.py CHANGED
@@ -41,7 +41,7 @@ from __future__ import annotations
41
41
 
42
42
  import os
43
43
  from pathlib import Path
44
- from typing import Any
44
+ from typing import Any, cast
45
45
 
46
46
  from htmlgraph.agent_detection import detect_agent_name
47
47
  from htmlgraph.agents import AgentInterface
@@ -321,6 +321,21 @@ class SDK:
321
321
  (self._directory / "todos").mkdir(exist_ok=True)
322
322
  (self._directory / "task-delegations").mkdir(exist_ok=True)
323
323
 
324
+ # Initialize RefManager and set on all collections
325
+ from htmlgraph.refs import RefManager
326
+
327
+ self.refs = RefManager(self._directory)
328
+
329
+ # Set ref manager on all work item collections
330
+ self.features.set_ref_manager(self.refs)
331
+ self.bugs.set_ref_manager(self.refs)
332
+ self.chores.set_ref_manager(self.refs)
333
+ self.spikes.set_ref_manager(self.refs)
334
+ self.epics.set_ref_manager(self.refs)
335
+ self.phases.set_ref_manager(self.refs)
336
+ self.tracks.set_ref_manager(self.refs)
337
+ self.todos.set_ref_manager(self.refs)
338
+
324
339
  # Analytics interface (Phase 2: Work Type Analytics)
325
340
  self.analytics = Analytics(self)
326
341
 
@@ -333,6 +348,11 @@ class SDK:
333
348
  # Context analytics interface (Context usage tracking)
334
349
  self.context = ContextAnalytics(self)
335
350
 
351
+ # Pattern learning interface (Phase 2: Behavior Pattern Learning)
352
+ from htmlgraph.analytics.pattern_learning import PatternLearner
353
+
354
+ self.pattern_learning = PatternLearner(self._directory)
355
+
336
356
  # Lazy-loaded orchestrator for subagent management
337
357
  self._orchestrator: Any = None
338
358
 
@@ -448,6 +468,61 @@ class SDK:
448
468
  return self._session_warning.get_status()
449
469
  return {"dismissed": True, "show_count": 0}
450
470
 
471
+ def ref(self, short_ref: str) -> Node | None:
472
+ """
473
+ Resolve a short ref to a Node object.
474
+
475
+ Short refs are stable identifiers like @f1, @t2, @b5 that map to
476
+ full node IDs. This method resolves the short ref and fetches the
477
+ corresponding node from the appropriate collection.
478
+
479
+ Args:
480
+ short_ref: Short ref like "@f1", "@t2", "@b5", etc.
481
+
482
+ Returns:
483
+ Node object or None if not found
484
+
485
+ Example:
486
+ >>> sdk = SDK(agent="claude")
487
+ >>> feature = sdk.ref("@f1")
488
+ >>> if feature:
489
+ ... print(feature.title)
490
+ ... feature.status = "done"
491
+ ... sdk.features.update(feature)
492
+ """
493
+ # Resolve short ref to full ID
494
+ full_id = self.refs.resolve_ref(short_ref)
495
+ if not full_id:
496
+ return None
497
+
498
+ # Determine type from ref prefix and fetch from appropriate collection
499
+ if len(short_ref) < 2:
500
+ return None
501
+
502
+ prefix = short_ref[1] # Get letter after @
503
+
504
+ # Map prefix to collection
505
+ collection_map = {
506
+ "f": self.features,
507
+ "t": self.tracks,
508
+ "b": self.bugs,
509
+ "s": self.spikes,
510
+ "c": self.chores,
511
+ "e": self.epics,
512
+ "d": self.todos,
513
+ "p": self.phases,
514
+ }
515
+
516
+ collection = collection_map.get(prefix)
517
+ if not collection:
518
+ return None
519
+
520
+ # Get node from collection
521
+ if hasattr(collection, "get"):
522
+ return cast(Node | None, collection.get(full_id))
523
+
524
+ return None
525
+
451
526
  # =========================================================================
452
527
  # SQLite Database Integration (Phase 2)
453
528
  # =========================================================================
@@ -776,6 +851,92 @@ class SDK:
776
851
  blockers=blockers,
777
852
  )
778
853
 
854
+ def continue_from_last(
855
+ self,
856
+ agent: str | None = None,
857
+ auto_create_session: bool = True,
858
+ ) -> tuple[Any, Any]:
859
+ """
860
+ Continue work from the last completed session.
861
+
862
+ Loads context from previous session including handoff notes,
863
+ recommended files, blockers, and recent commits.
864
+
865
+ Args:
866
+ agent: Filter by agent (None = current SDK agent)
867
+ auto_create_session: Create new session if True
868
+
869
+ Returns:
870
+ Tuple of (new_session, resume_info) or (None, None)
871
+
872
+ Example:
873
+ >>> sdk = SDK(agent="claude")
874
+ >>> session, resume = sdk.continue_from_last()
875
+ >>> if resume:
876
+ ... print(resume.summary)
877
+ ... print(resume.next_focus)
878
+ ... for file in resume.recommended_files:
879
+ ... print(f" - {file}")
880
+ """
881
+ if not agent:
882
+ agent = self._agent_id
883
+
884
+ return self.session_manager.continue_from_last(
885
+ agent=agent,
886
+ auto_create_session=auto_create_session,
887
+ )
888
+
889
+ def end_session_with_handoff(
890
+ self,
891
+ session_id: str | None = None,
892
+ summary: str | None = None,
893
+ next_focus: str | None = None,
894
+ blockers: list[str] | None = None,
895
+ keep_context: list[str] | None = None,
896
+ auto_recommend_context: bool = True,
897
+ ) -> Any:
898
+ """
899
+ End session with handoff information for next session.
900
+
901
+ Args:
902
+ session_id: Session to end (None = active session)
903
+ summary: What was accomplished
904
+ next_focus: What should be done next
905
+ blockers: List of blockers
906
+ keep_context: List of files to keep context for
907
+ auto_recommend_context: Auto-recommend files from git
908
+
909
+ Returns:
910
+ Updated Session or None
911
+
912
+ Example:
913
+ >>> sdk.end_session_with_handoff(
914
+ ... summary="Completed OAuth integration",
915
+ ... next_focus="Implement JWT token refresh",
916
+ ... blockers=["Waiting for security review"],
917
+ ... keep_context=["src/auth/oauth.py"]
918
+ ... )
919
+ """
920
+ if not session_id:
921
+ if self._agent_id:
922
+ active = self.session_manager.get_active_session_for_agent(
923
+ self._agent_id
924
+ )
925
+ else:
926
+ active = self.session_manager.get_active_session()
927
+ if not active:
928
+ return None
929
+ session_id = active.id
930
+
931
+ return self.session_manager.end_session_with_handoff(
932
+ session_id=session_id,
933
+ summary=summary,
934
+ next_focus=next_focus,
935
+ blockers=blockers,
936
+ keep_context=keep_context,
937
+ auto_recommend_context=auto_recommend_context,
938
+ )
939
+
779
940
  def start_session(
780
941
  self,
781
942
  session_id: str | None = None,