htmlgraph 0.26.24__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 (33) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/main.py +56 -23
  3. htmlgraph/api/templates/dashboard-redesign.html +3 -3
  4. htmlgraph/api/templates/dashboard.html +3 -3
  5. htmlgraph/api/templates/partials/work-items.html +613 -0
  6. htmlgraph/builders/track.py +26 -0
  7. htmlgraph/cli/base.py +31 -7
  8. htmlgraph/cli/work/__init__.py +74 -0
  9. htmlgraph/cli/work/browse.py +114 -0
  10. htmlgraph/cli/work/snapshot.py +558 -0
  11. htmlgraph/collections/base.py +34 -0
  12. htmlgraph/collections/todo.py +12 -0
  13. htmlgraph/converter.py +11 -0
  14. htmlgraph/hooks/orchestrator.py +88 -14
  15. htmlgraph/hooks/session_handler.py +3 -1
  16. htmlgraph/models.py +18 -1
  17. htmlgraph/orchestration/__init__.py +4 -0
  18. htmlgraph/orchestration/plugin_manager.py +1 -2
  19. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  20. htmlgraph/refs.py +343 -0
  21. htmlgraph/sdk.py +71 -1
  22. htmlgraph/session_manager.py +1 -7
  23. htmlgraph/sessions/handoff.py +6 -0
  24. htmlgraph/track_builder.py +12 -0
  25. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/RECORD +33 -28
  27. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/dashboard.html +0 -0
  28. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/styles.css +0 -0
  29. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  30. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  31. {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  32. {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/WHEEL +0 -0
  33. {htmlgraph-0.26.24.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
 
@@ -453,6 +468,61 @@ class SDK:
453
468
  return self._session_warning.get_status()
454
469
  return {"dismissed": True, "show_count": 0}
455
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
+
456
526
  # =========================================================================
457
527
  # SQLite Database Integration (Phase 2)
458
528
  # =========================================================================
@@ -929,13 +929,7 @@ class SessionManager:
929
929
  session.handoff_notes = handoff_data["handoff_notes"]
930
930
  session.recommended_next = handoff_data["recommended_next"]
931
931
  session.blockers = handoff_data["blockers"]
932
-
933
- # Store recommended_context as JSON-serializable list
934
- # (Session model expects list[str], converter will handle serialization)
935
- if hasattr(session, "__dict__"):
936
- session.__dict__["recommended_context"] = handoff_data[
937
- "recommended_context"
938
- ]
932
+ session.recommended_context = handoff_data["recommended_context"]
939
933
 
940
934
  # Persist handoff data to database before ending session
941
935
  self.session_converter.save(session)
@@ -613,6 +613,9 @@ class HandoffTracker:
613
613
  handoff_id = generate_id("hand")
614
614
 
615
615
  if self.db and self.db.connection:
616
+ # Ensure session exists in database (handles FK constraint)
617
+ self.db._ensure_session_exists(from_session_id)
618
+
616
619
  cursor = self.db.connection.cursor()
617
620
  cursor.execute(
618
621
  """
@@ -649,6 +652,9 @@ class HandoffTracker:
649
652
  return False
650
653
 
651
654
  try:
655
+ # Ensure to_session exists in database (handles FK constraint)
656
+ self.db._ensure_session_exists(to_session_id)
657
+
652
658
  cursor = self.db.connection.cursor()
653
659
  cursor.execute(
654
660
  """
@@ -31,6 +31,18 @@ class TrackCollection:
31
31
  self.collection_name = "tracks" # For backward compatibility
32
32
  self.id_prefix = "track"
33
33
  self._graph: HtmlGraph | None = None # Lazy-loaded
34
+ self._ref_manager: Any = None # Set by SDK during initialization
35
+
36
+ def set_ref_manager(self, ref_manager: Any) -> None:
37
+ """
38
+ Set the ref manager for this collection.
39
+
40
+ Called by SDK during initialization to enable short ref support.
41
+
42
+ Args:
43
+ ref_manager: RefManager instance from SDK
44
+ """
45
+ self._ref_manager = ref_manager
34
46
 
35
47
  def _ensure_graph(self) -> HtmlGraph:
36
48
  """Lazy-load the graph for tracks with multi-pattern support."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlgraph
3
- Version: 0.26.24
3
+ Version: 0.26.25
4
4
  Summary: HTML is All You Need - Graph database on web standards
5
5
  Project-URL: Homepage, https://github.com/Shakes-tzd/htmlgraph
6
6
  Project-URL: Documentation, https://github.com/Shakes-tzd/htmlgraph#readme