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
@@ -0,0 +1,558 @@
1
+ """HtmlGraph CLI - Snapshot command for graph state visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+
11
+ from htmlgraph.cli.base import BaseCommand, CommandResult
12
+
13
+
14
+ class SnapshotFormatter:
15
+ """Helper for agent-friendly colored output formatting.
16
+
17
+ Uses ANSI color codes that are visible to humans but harmless to agents.
18
+ Avoids box-drawing characters and complex table formatting.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ """Initialize formatter with Rich console."""
23
+ # Force color output even when not in TTY
24
+ self.console = Console(force_terminal=True, legacy_windows=False)
25
+
26
+ def colorize_status(self, status: str) -> str:
27
+ """Return ANSI-colored status string.
28
+
29
+ Args:
30
+ status: Status value (todo, in-progress, blocked, done)
31
+
32
+ Returns:
33
+ Colored status string with Rich markup
34
+ """
35
+ colors = {
36
+ "todo": "yellow",
37
+ "in-progress": "cyan",
38
+ "blocked": "red",
39
+ "done": "green",
40
+ }
41
+ color = colors.get(status, "white")
42
+ return f"[{color}]{status}[/{color}]"
43
+
44
+ def colorize_priority(self, priority: str | None) -> str:
45
+ """Return ANSI-colored priority string.
46
+
47
+ Args:
48
+ priority: Priority value (critical, high, medium, low)
49
+
50
+ Returns:
51
+ Colored priority string with Rich markup
52
+ """
53
+ if not priority:
54
+ return "[dim]-[/dim]"
55
+
56
+ colors = {
57
+ "critical": "red",
58
+ "high": "red",
59
+ "medium": "yellow",
60
+ "low": "dim",
61
+ }
62
+ color = colors.get(priority, "white")
63
+ return f"[{color}]{priority}[/{color}]"
64
+
65
+ def colorize_ref(self, ref: str | None) -> str:
66
+ """Return ANSI-colored ref.
67
+
68
+ Args:
69
+ ref: Reference string (@f1, @t1, etc.)
70
+
71
+ Returns:
72
+ Colored ref string with Rich markup
73
+ """
74
+ if not ref:
75
+ return " "
76
+ return f"[cyan]{ref}[/cyan]"
77
+
78
+ def status_symbol(self, status: str) -> str:
79
+ """Return appropriate Unicode symbol for status.
80
+
81
+ Args:
82
+ status: Status value
83
+
84
+ Returns:
85
+ Unicode symbol representing status
86
+ """
87
+ symbols = {
88
+ "done": "✓",
89
+ "blocked": "✗",
90
+ "in-progress": "⟳",
91
+ "todo": "●",
92
+ }
93
+ return symbols.get(status, "●")
94
+
95
+ def render(self, text: str) -> str:
96
+ """Render Rich markup to ANSI-escaped string.
97
+
98
+ Args:
99
+ text: Text with Rich markup
100
+
101
+ Returns:
102
+ String with ANSI color codes
103
+ """
104
+ # Use Rich's export_text to get ANSI-formatted output
105
+ from rich.text import Text
106
+
107
+ # Parse Rich markup
108
+ rich_text = Text.from_markup(text)
109
+
110
+ # Render to string with ANSI codes
111
+ with self.console.capture() as capture:
112
+ self.console.print(rich_text, end="")
113
+ return capture.get()
114
+
115
+
116
+ class SnapshotCommand(BaseCommand):
117
+ """Generate and output a snapshot of the current graph state.
118
+
119
+ Outputs all work items organized by type and status, optionally with
120
+ short refs for AI-friendly references.
121
+
122
+ Usage:
123
+ htmlgraph snapshot # Human-readable with refs
124
+ htmlgraph snapshot --format json # JSON format
125
+ htmlgraph snapshot --format text # Simple text (no refs)
126
+ htmlgraph snapshot --type feature # Only features
127
+ htmlgraph snapshot --status todo # Only todo items
128
+ htmlgraph snapshot --active # Only active work (TODO/IN_PROGRESS)
129
+ htmlgraph snapshot --track @t1 # Only items in track
130
+ htmlgraph snapshot --blockers # Only critical/blocked items
131
+ htmlgraph snapshot --summary # Summary with counts and progress
132
+ htmlgraph snapshot --my-work # Only items assigned to current agent
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ *,
138
+ output_format: str = "refs",
139
+ node_type: str | None = None,
140
+ status: str | None = None,
141
+ track_id: str | None = None,
142
+ active: bool = False,
143
+ blockers: bool = False,
144
+ summary: bool = False,
145
+ my_work: bool = False,
146
+ ) -> None:
147
+ """Initialize snapshot command.
148
+
149
+ Args:
150
+ output_format: Output format (refs, json, text)
151
+ node_type: Filter by type (feature, track, bug, spike, chore, epic, all)
152
+ status: Filter by status (todo, in_progress, blocked, done, all)
153
+ track_id: Filter by track ID or ref
154
+ active: Show only TODO/IN_PROGRESS items
155
+ blockers: Show only critical/blocked items
156
+ summary: Show summary format with counts
157
+ my_work: Show only items assigned to current agent
158
+ """
159
+ super().__init__()
160
+ self.output_format = output_format
161
+ self.node_type = node_type
162
+ self.status = status
163
+ self.track_id = track_id
164
+ self.active = active
165
+ self.blockers = blockers
166
+ self.summary = summary
167
+ self.my_work = my_work
168
+ self.formatter = SnapshotFormatter()
169
+
170
+ @classmethod
171
+ def from_args(cls, args: argparse.Namespace) -> SnapshotCommand:
172
+ """Create command instance from argparse arguments."""
173
+ cmd = cls(
174
+ output_format=args.output_format
175
+ if hasattr(args, "output_format")
176
+ else "refs",
177
+ node_type=args.type if hasattr(args, "type") else None,
178
+ status=args.status if hasattr(args, "status") else None,
179
+ track_id=args.track if hasattr(args, "track") else None,
180
+ active=args.active if hasattr(args, "active") else False,
181
+ blockers=args.blockers if hasattr(args, "blockers") else False,
182
+ summary=args.summary if hasattr(args, "summary") else False,
183
+ my_work=args.my_work if hasattr(args, "my_work") else False,
184
+ )
185
+ # If snapshot command has its own --output-format, override the global --format
186
+ # This allows "htmlgraph snapshot --output-format json" to work without needing --format json
187
+ if hasattr(args, "output_format"):
188
+ cmd.override_output_format = args.output_format
189
+ return cmd
190
+
191
+ def execute(self) -> CommandResult:
192
+ """Execute snapshot command."""
193
+ sdk = self.get_sdk()
194
+
195
+ # Gather all work items
196
+ items = self._gather_items(sdk)
197
+
198
+ # Format output based on output_format setting
199
+ if self.summary:
200
+ output = self._format_summary(items, sdk)
201
+ return CommandResult(
202
+ json_data=items, # For JsonFormatter if needed
203
+ data={"snapshot": output, "item_count": len(items)},
204
+ text=output,
205
+ )
206
+ elif self.output_format == "json":
207
+ # For JSON format, return items as both json_data and text
208
+ # This allows both direct result.text access (in tests) and
209
+ # JsonFormatter to work correctly
210
+ json_text = self._format_json(items)
211
+ return CommandResult(
212
+ json_data=items, # For JsonFormatter
213
+ data=items, # For backward compatibility
214
+ text=json_text, # JSON string for direct access
215
+ )
216
+ elif self.output_format == "refs":
217
+ output = self._format_refs(items)
218
+ else: # text
219
+ output = self._format_text(items)
220
+
221
+ return CommandResult(
222
+ json_data=items, # For JsonFormatter if needed
223
+ data={"snapshot": output, "item_count": len(items)},
224
+ text=output,
225
+ )
226
+
227
+ def _gather_items(self, sdk: Any) -> list[dict[str, Any]]:
228
+ """Gather all relevant items from SDK.
229
+
230
+ Args:
231
+ sdk: HtmlGraph SDK instance
232
+
233
+ Returns:
234
+ List of item dicts with ref, id, type, title, status, priority
235
+ """
236
+ items = []
237
+
238
+ # Resolve track_id if provided as ref
239
+ resolved_track_id = None
240
+ if self.track_id:
241
+ if self.track_id.startswith("@"):
242
+ # Resolve ref to track ID
243
+ track_node = sdk.ref(self.track_id)
244
+ if track_node and track_node.type == "track":
245
+ resolved_track_id = track_node.id
246
+ else:
247
+ resolved_track_id = self.track_id
248
+
249
+ # Map collection names to SDK attributes
250
+ collection_map = {
251
+ "feature": "features",
252
+ "track": "tracks",
253
+ "bug": "bugs",
254
+ "spike": "spikes",
255
+ "chore": "chores",
256
+ "epic": "epics",
257
+ }
258
+
259
+ for node_type, collection_name in collection_map.items():
260
+ # Apply type filter
261
+ if (
262
+ self.node_type
263
+ and self.node_type != "all"
264
+ and self.node_type != node_type
265
+ ):
266
+ continue
267
+
268
+ # Get collection
269
+ collection = getattr(sdk, collection_name, None)
270
+ if not collection:
271
+ continue
272
+
273
+ # Get all nodes from collection
274
+ nodes = collection.all()
275
+
276
+ for node in nodes:
277
+ # Apply status filter
278
+ if self.status and self.status != "all" and node.status != self.status:
279
+ continue
280
+
281
+ # Apply active filter
282
+ if self.active:
283
+ if node.status not in ["todo", "in-progress", "blocked"]:
284
+ continue
285
+ # Filter out metadata spikes
286
+ if node.type == "spike" and self._is_metadata_spike(node):
287
+ continue
288
+
289
+ # Apply blockers filter
290
+ if self.blockers:
291
+ priority = getattr(node, "priority", None)
292
+ if priority != "critical" and node.status != "blocked":
293
+ continue
294
+
295
+ # Apply track filter
296
+ if resolved_track_id:
297
+ node_track_id = getattr(node, "track_id", None)
298
+ if node_track_id != resolved_track_id:
299
+ continue
300
+
301
+ # Apply my_work filter
302
+ if self.my_work:
303
+ assigned_to = getattr(node, "agent_assigned", None)
304
+ if assigned_to != sdk.agent:
305
+ continue
306
+
307
+ items.append(self._node_to_dict(sdk, node))
308
+
309
+ # Sort by type, status, then ref
310
+ return sorted(items, key=lambda x: (x["type"], x["status"], x["ref"] or ""))
311
+
312
+ def _is_metadata_spike(self, node: Any) -> bool:
313
+ """Check if spike is metadata (conversation, transition, etc).
314
+
315
+ Args:
316
+ node: Spike node to check
317
+
318
+ Returns:
319
+ True if spike is metadata
320
+ """
321
+ title = node.title.lower()
322
+ metadata_keywords = ["conversation", "transition", "handoff", "session"]
323
+ return any(keyword in title for keyword in metadata_keywords)
324
+
325
+ def _node_to_dict(self, sdk: Any, node: Any) -> dict[str, Any]:
326
+ """Convert Node to dict with ref.
327
+
328
+ Args:
329
+ sdk: HtmlGraph SDK instance
330
+ node: Node object
331
+
332
+ Returns:
333
+ Dict with ref, id, type, title, status, priority, assigned_to, track_id
334
+ """
335
+ # Get ref if available (may not exist yet)
336
+ ref = None
337
+ if hasattr(sdk, "refs") and sdk.refs:
338
+ ref = sdk.refs.get_ref(node.id)
339
+
340
+ return {
341
+ "ref": ref,
342
+ "id": node.id,
343
+ "type": node.type,
344
+ "title": node.title,
345
+ "status": node.status,
346
+ "priority": getattr(node, "priority", None),
347
+ "assigned_to": getattr(node, "agent_assigned", None),
348
+ "track_id": getattr(node, "track_id", None),
349
+ }
350
+
351
+ def _format_refs(self, items: list[dict]) -> str:
352
+ """Format as readable list with refs and ANSI colors.
353
+
354
+ Args:
355
+ items: List of item dicts
356
+
357
+ Returns:
358
+ Formatted string with refs and ANSI color codes
359
+ """
360
+ lines = []
361
+ lines.append("[bold]SNAPSHOT - Current Graph State[/bold]")
362
+ lines.append("=" * 50)
363
+
364
+ # Group by type
365
+ by_type: dict[str, list[dict[str, Any]]] = {}
366
+ for item in items:
367
+ t = item["type"]
368
+ if t not in by_type:
369
+ by_type[t] = []
370
+ by_type[t].append(item)
371
+
372
+ # Iterate through types in consistent order
373
+ for node_type in ["feature", "track", "bug", "spike", "chore", "epic"]:
374
+ if node_type not in by_type:
375
+ continue
376
+
377
+ type_items = by_type[node_type]
378
+ lines.append(f"\n[bold]{node_type.upper()}S ({len(type_items)})[/bold]")
379
+ lines.append("─" * 40)
380
+
381
+ # Group by status
382
+ by_status: dict[str, list[dict[str, Any]]] = {}
383
+ for item in type_items:
384
+ status = item["status"]
385
+ if status not in by_status:
386
+ by_status[status] = []
387
+ by_status[status].append(item)
388
+
389
+ # Iterate through statuses in consistent order
390
+ for status in ["todo", "in-progress", "blocked", "done"]:
391
+ if status not in by_status:
392
+ continue
393
+
394
+ lines.append(f"\n{status.upper().replace('-', '_')}:")
395
+ for item in by_status[status]:
396
+ ref = self.formatter.colorize_ref(item["ref"])
397
+ title = (
398
+ item["title"][:40] if len(item["title"]) > 40 else item["title"]
399
+ )
400
+ prio = self.formatter.colorize_priority(item["priority"])
401
+ status_colored = self.formatter.colorize_status(item["status"])
402
+ lines.append(f" {ref} {title:40s} {prio:10s} {status_colored}")
403
+
404
+ # Render all lines with Rich markup to ANSI
405
+ return self.formatter.render("\n".join(lines))
406
+
407
+ def _format_json(self, items: list[dict]) -> str:
408
+ """Format as JSON.
409
+
410
+ Args:
411
+ items: List of item dicts
412
+
413
+ Returns:
414
+ JSON string
415
+ """
416
+ return json.dumps(items, indent=2, default=str)
417
+
418
+ def _format_text(self, items: list[dict]) -> str:
419
+ """Format as simple text with colors (no refs).
420
+
421
+ Args:
422
+ items: List of item dicts
423
+
424
+ Returns:
425
+ Plain text string with ANSI color codes
426
+ """
427
+ lines = []
428
+ for item in items:
429
+ title = item["title"][:40] if len(item["title"]) > 40 else item["title"]
430
+ item_type = item["type"]
431
+ status_colored = self.formatter.colorize_status(item["status"])
432
+ lines.append(f"{item_type:8s} {title:40s} {status_colored}")
433
+ return self.formatter.render("\n".join(lines))
434
+
435
+ def _format_summary(self, items: list[dict], sdk: Any) -> str:
436
+ """Format as summary with counts, progress, colors, and symbols.
437
+
438
+ Args:
439
+ items: List of item dicts
440
+ sdk: HtmlGraph SDK instance
441
+
442
+ Returns:
443
+ Summary string with ANSI colors and Unicode symbols
444
+ """
445
+ lines = []
446
+ lines.append("[bold]ACTIVE WORK CONTEXT[/bold]")
447
+ lines.append("═" * 60)
448
+ lines.append("")
449
+
450
+ # Show current track if track filter is active
451
+ if self.track_id:
452
+ track_ref = self.track_id if self.track_id.startswith("@") else None
453
+ if not track_ref:
454
+ # Try to get ref from track_id
455
+ track_ref = sdk.refs.get_ref(self.track_id)
456
+ track_ref_colored = self.formatter.colorize_ref(track_ref or self.track_id)
457
+ lines.append(f"Current Track: {track_ref_colored}")
458
+ lines.append("")
459
+
460
+ # Group by type
461
+ by_type: dict[str, list[dict[str, Any]]] = {}
462
+ for item in items:
463
+ t = item["type"]
464
+ if t not in by_type:
465
+ by_type[t] = []
466
+ by_type[t].append(item)
467
+
468
+ # Features summary
469
+ if "feature" in by_type:
470
+ features = by_type["feature"]
471
+ done_count = sum(1 for f in features if f["status"] == "done")
472
+ total_count = len(features)
473
+ progress = int((done_count / total_count) * 100) if total_count > 0 else 0
474
+
475
+ lines.append(
476
+ f"[bold]● Active Features ({done_count}/{total_count} complete - {progress}%):[/bold]"
477
+ )
478
+ # Show active features (not done)
479
+ active_features = [f for f in features if f["status"] != "done"]
480
+ for feature in active_features[:5]: # Limit to 5
481
+ ref = self.formatter.colorize_ref(feature["ref"])
482
+ symbol = self.formatter.status_symbol(feature["status"])
483
+ title = (
484
+ feature["title"][:40]
485
+ if len(feature["title"]) > 40
486
+ else feature["title"]
487
+ )
488
+ prio = self.formatter.colorize_priority(feature["priority"])
489
+ lines.append(f" {ref} {symbol} {title:40s} {prio}")
490
+ if len(active_features) > 5:
491
+ lines.append(f" ... and {len(active_features) - 5} more")
492
+ lines.append("")
493
+
494
+ # Bugs summary
495
+ if "bug" in by_type:
496
+ bugs = by_type["bug"]
497
+ critical_bugs = [b for b in bugs if b["priority"] == "critical"]
498
+ high_bugs = [b for b in bugs if b["priority"] == "high"]
499
+
500
+ lines.append(
501
+ f"[bold]✗ Active Bugs ({len(critical_bugs)} critical, {len(high_bugs)} high):[/bold]"
502
+ )
503
+ # Show critical and high priority bugs
504
+ priority_bugs = critical_bugs + high_bugs
505
+ for bug in priority_bugs[:5]: # Limit to 5
506
+ ref = self.formatter.colorize_ref(bug["ref"])
507
+ symbol = self.formatter.status_symbol(bug["status"])
508
+ title = bug["title"][:40] if len(bug["title"]) > 40 else bug["title"]
509
+ prio = self.formatter.colorize_priority(bug["priority"])
510
+ lines.append(f" {ref} {symbol} {title:40s} {prio}")
511
+ if len(priority_bugs) > 5:
512
+ lines.append(f" ... and {len(priority_bugs) - 5} more")
513
+ lines.append("")
514
+
515
+ # Blockers & Critical summary
516
+ blockers = [
517
+ i for i in items if i["priority"] == "critical" or i["status"] == "blocked"
518
+ ]
519
+ if blockers:
520
+ lines.append(f"[bold]⚠ Blockers & Critical ({len(blockers)} items):[/bold]")
521
+ for item in blockers[:5]: # Limit to 5
522
+ ref = self.formatter.colorize_ref(item["ref"])
523
+ symbol = self.formatter.status_symbol(item["status"])
524
+ title = item["title"][:40] if len(item["title"]) > 40 else item["title"]
525
+ prio = self.formatter.colorize_priority(item["priority"])
526
+ lines.append(f" {ref} {symbol} {title:40s} {prio}")
527
+ if len(blockers) > 5:
528
+ lines.append(f" ... and {len(blockers) - 5} more")
529
+ lines.append("")
530
+
531
+ # Quick Stats
532
+ lines.append("[bold]Quick Stats:[/bold]")
533
+ if "feature" in by_type:
534
+ features = by_type["feature"]
535
+ done = sum(1 for f in features if f["status"] == "done")
536
+ total = len(features)
537
+ progress = int((done / total) * 100) if total > 0 else 0
538
+ if self.track_id:
539
+ lines.append(f" Track: {done}/{total} features ({progress}% done)")
540
+ else:
541
+ lines.append(f" Features: {done}/{total} complete ({progress}% done)")
542
+
543
+ if "bug" in by_type:
544
+ bugs = by_type["bug"]
545
+ open_bugs = sum(1 for b in bugs if b["status"] != "done")
546
+ critical = sum(1 for b in bugs if b["priority"] == "critical")
547
+ lines.append(f" Bugs: {open_bugs} open ({critical} critical)")
548
+
549
+ if "spike" in by_type:
550
+ spikes = by_type["spike"]
551
+ lines.append(f" Spikes: {len(spikes)} active")
552
+
553
+ if "track" in by_type:
554
+ tracks = by_type["track"]
555
+ active_tracks = sum(1 for t in tracks if t["status"] == "in-progress")
556
+ lines.append(f" Tracks: {active_tracks} active")
557
+
558
+ return self.formatter.render("\n".join(lines))
@@ -69,6 +69,7 @@ class BaseCollection(Generic[CollectionT]):
69
69
  self._collection_name = collection_name or self._collection_name
70
70
  self._node_type = node_type or self._node_type
71
71
  self._graph: HtmlGraph | None = None # Lazy-loaded
72
+ self._ref_manager: Any = None # Set by SDK during initialization
72
73
 
73
74
  def _ensure_graph(self) -> HtmlGraph:
74
75
  """
@@ -200,6 +201,39 @@ class BaseCollection(Generic[CollectionT]):
200
201
  # Return priority items first, then regular, then dunder
201
202
  return priority + regular + dunder
202
203
 
204
+ def set_ref_manager(self, ref_manager: Any) -> None:
205
+ """
206
+ Set the ref manager for this collection.
207
+
208
+ Called by SDK during initialization to enable short ref support.
209
+
210
+ Args:
211
+ ref_manager: RefManager instance from SDK
212
+ """
213
+ self._ref_manager = ref_manager
214
+
215
+ def get_ref(self, node_id: str) -> str | None:
216
+ """
217
+ Get short ref for a node in this collection.
218
+
219
+ Convenience method to get ref without accessing SDK directly.
220
+
221
+ Args:
222
+ node_id: Full node ID like "feat-a1b2c3d4"
223
+
224
+ Returns:
225
+ Short ref like "@f1", or None if ref manager not available
226
+
227
+ Example:
228
+ >>> feature = sdk.features.get("feat-abc123")
229
+ >>> ref = sdk.features.get_ref(feature.id)
230
+ >>> print(ref) # "@f1"
231
+ """
232
+ if self._ref_manager:
233
+ result = self._ref_manager.get_ref(node_id)
234
+ return cast(str | None, result)
235
+ return None
236
+
203
237
  def create(
204
238
  self, title: str, priority: str = "medium", status: str = "todo", **kwargs: Any
205
239
  ) -> Any:
@@ -64,6 +64,18 @@ class TodoCollection:
64
64
 
65
65
  # Cache for loaded todos (lazy-loaded)
66
66
  self._todos: dict[str, Todo] | None = None
67
+ self._ref_manager: Any = None # Set by SDK during initialization
68
+
69
+ def set_ref_manager(self, ref_manager: Any) -> None:
70
+ """
71
+ Set the ref manager for this collection.
72
+
73
+ Called by SDK during initialization to enable short ref support.
74
+
75
+ Args:
76
+ ref_manager: RefManager instance from SDK
77
+ """
78
+ self._ref_manager = ref_manager
67
79
 
68
80
  def _ensure_loaded(self) -> dict[str, Todo]:
69
81
  """Load todos from disk if not cached."""
htmlgraph/converter.py CHANGED
@@ -520,6 +520,17 @@ def html_to_session(filepath: Path | str) -> Session:
520
520
  if blockers:
521
521
  data["blockers"] = blockers
522
522
 
523
+ # Parse recommended context files
524
+ recommended_context = []
525
+ for li in parser.query(
526
+ "section[data-handoff] div[data-recommended-context] li"
527
+ ):
528
+ file_path = li.to_text().strip()
529
+ if file_path:
530
+ recommended_context.append(file_path)
531
+ if recommended_context:
532
+ data["recommended_context"] = recommended_context
533
+
523
534
  # Parse activity log
524
535
  activity_log = []
525
536
  for li in parser.query("section[data-activity-log] ol li"):