htmlgraph 0.22.0__py3-none-any.whl → 0.23.1__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 (29) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/agent_detection.py +41 -2
  3. htmlgraph/analytics/cli.py +86 -20
  4. htmlgraph/cli.py +280 -87
  5. htmlgraph/collections/base.py +68 -4
  6. htmlgraph/git_events.py +61 -7
  7. htmlgraph/operations/README.md +62 -0
  8. htmlgraph/operations/__init__.py +61 -0
  9. htmlgraph/operations/analytics.py +338 -0
  10. htmlgraph/operations/events.py +243 -0
  11. htmlgraph/operations/hooks.py +349 -0
  12. htmlgraph/operations/server.py +302 -0
  13. htmlgraph/orchestration/__init__.py +39 -0
  14. htmlgraph/orchestration/headless_spawner.py +566 -0
  15. htmlgraph/orchestration/model_selection.py +323 -0
  16. htmlgraph/orchestrator-system-prompt-optimized.txt +92 -0
  17. htmlgraph/parser.py +56 -1
  18. htmlgraph/sdk.py +529 -7
  19. htmlgraph/server.py +153 -60
  20. {htmlgraph-0.22.0.dist-info → htmlgraph-0.23.1.dist-info}/METADATA +3 -1
  21. {htmlgraph-0.22.0.dist-info → htmlgraph-0.23.1.dist-info}/RECORD +29 -19
  22. /htmlgraph/{orchestration.py → orchestration/task_coordination.py} +0 -0
  23. {htmlgraph-0.22.0.data → htmlgraph-0.23.1.data}/data/htmlgraph/dashboard.html +0 -0
  24. {htmlgraph-0.22.0.data → htmlgraph-0.23.1.data}/data/htmlgraph/styles.css +0 -0
  25. {htmlgraph-0.22.0.data → htmlgraph-0.23.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  26. {htmlgraph-0.22.0.data → htmlgraph-0.23.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  27. {htmlgraph-0.22.0.data → htmlgraph-0.23.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  28. {htmlgraph-0.22.0.dist-info → htmlgraph-0.23.1.dist-info}/WHEEL +0 -0
  29. {htmlgraph-0.22.0.dist-info → htmlgraph-0.23.1.dist-info}/entry_points.txt +0 -0
htmlgraph/cli.py CHANGED
@@ -8,6 +8,11 @@ Usage:
8
8
  htmlgraph status [--dir DIR]
9
9
  htmlgraph query SELECTOR [--dir DIR]
10
10
 
11
+ Claude Code Integration:
12
+ htmlgraph claude # Start Claude Code
13
+ htmlgraph claude --init # Start with orchestrator system prompt (recommended)
14
+ htmlgraph claude --continue # Resume last Claude Code session
15
+
11
16
  Session Management:
12
17
  htmlgraph session start [--id ID] [--agent AGENT]
13
18
  htmlgraph session end ID [--notes NOTES] [--recommend NEXT] [--blocker BLOCKER]
@@ -111,9 +116,9 @@ def cmd_install_gemini_extension(args: argparse.Namespace) -> None:
111
116
 
112
117
  def cmd_serve(args: argparse.Namespace) -> None:
113
118
  """Start the HtmlGraph server."""
114
- from htmlgraph.server import serve
119
+ from htmlgraph.operations import start_server
115
120
 
116
- serve(
121
+ start_server(
117
122
  port=args.port,
118
123
  graph_dir=args.graph_dir,
119
124
  static_dir=args.static_dir,
@@ -122,10 +127,14 @@ def cmd_serve(args: argparse.Namespace) -> None:
122
127
  auto_port=args.auto_port,
123
128
  )
124
129
 
130
+ # The start_server operation already handles all output and blocks
131
+ # No additional CLI formatting needed
132
+
125
133
 
126
134
  def cmd_init(args: argparse.Namespace) -> None:
127
135
  """Initialize a new .htmlgraph directory."""
128
136
  import shutil
137
+ from contextlib import nullcontext
129
138
 
130
139
  from htmlgraph.analytics_index import AnalyticsIndex
131
140
  from htmlgraph.server import HtmlGraphAPIHandler
@@ -160,38 +169,33 @@ def cmd_init(args: argparse.Namespace) -> None:
160
169
  agent_name = "claude"
161
170
  generate_docs = True # Always generate in non-interactive mode
162
171
 
163
- graph_dir = Path(args.dir) / ".htmlgraph"
164
- graph_dir.mkdir(parents=True, exist_ok=True)
165
-
166
- for collection in HtmlGraphAPIHandler.COLLECTIONS:
167
- (graph_dir / collection).mkdir(exist_ok=True)
168
-
169
- # Event stream directory (Git-friendly source of truth)
170
- events_dir = graph_dir / "events"
171
- events_dir.mkdir(exist_ok=True)
172
- if not args.no_events_keep:
173
- keep = events_dir / ".gitkeep"
174
- if not keep.exists():
175
- keep.write_text("", encoding="utf-8")
176
-
177
- # Copy stylesheet
178
- styles_src = Path(__file__).parent / "styles.css"
179
- styles_dest = graph_dir / "styles.css"
180
- if styles_src.exists() and not styles_dest.exists():
181
- styles_dest.write_text(styles_src.read_text())
182
-
183
- # Create default index.html if not exists
184
- index_path = Path(args.dir) / "index.html"
185
- if not index_path.exists():
186
- create_default_index(index_path)
187
-
188
- # Create analytics cache DB (rebuildable; typically gitignored)
189
- if not args.no_index:
172
+ def init_progress() -> tuple[Any | None, Any | None]:
173
+ if args.quiet or getattr(args, "format", "text") != "text":
174
+ return None, None
190
175
  try:
191
- AnalyticsIndex(graph_dir / "index.sqlite").ensure_schema()
176
+ from rich.console import Console
177
+ from rich.progress import (
178
+ BarColumn,
179
+ Progress,
180
+ SpinnerColumn,
181
+ TextColumn,
182
+ TimeElapsedColumn,
183
+ )
192
184
  except Exception:
193
- # Never fail init because of analytics cache.
194
- pass
185
+ return None, None
186
+ console = Console()
187
+ progress = Progress(
188
+ SpinnerColumn(),
189
+ TextColumn("{task.description}"),
190
+ BarColumn(),
191
+ TimeElapsedColumn(),
192
+ console=console,
193
+ transient=True,
194
+ )
195
+ return progress, console
196
+
197
+ graph_dir = Path(args.dir) / ".htmlgraph"
198
+ events_dir = graph_dir / "events"
195
199
 
196
200
  def ensure_gitignore_entries(project_dir: Path, lines: list[str]) -> None:
197
201
  if args.no_update_gitignore:
@@ -218,19 +222,12 @@ def cmd_init(args: argparse.Namespace) -> None:
218
222
  # Don't fail init on .gitignore issues.
219
223
  pass
220
224
 
221
- ensure_gitignore_entries(
222
- Path(args.dir),
223
- [
224
- ".htmlgraph/index.sqlite",
225
- ".htmlgraph/index.sqlite-wal",
226
- ".htmlgraph/index.sqlite-shm",
227
- ".htmlgraph/git-hook-errors.log",
228
- ],
229
- )
225
+ progress, progress_console = init_progress()
230
226
 
231
- # Ensure versioned hook scripts exist (installation into .git/hooks is optional)
232
- hooks_dir = graph_dir / "hooks"
233
- hooks_dir.mkdir(exist_ok=True)
227
+ def status_context(message: str) -> Any:
228
+ if progress_console is None:
229
+ return nullcontext()
230
+ return progress_console.status(message)
234
231
 
235
232
  # Hook templates (used when htmlgraph is installed without this repo layout).
236
233
  post_commit = """#!/bin/bash
@@ -443,26 +440,77 @@ fi
443
440
  exit 0
444
441
  """
445
442
 
446
- def ensure_hook_file(hook_name: str, hook_content: str) -> Path:
447
- hook_dest = hooks_dir / f"{hook_name}.sh"
448
- if not hook_dest.exists():
449
- hook_dest.write_text(hook_content)
450
- try:
451
- hook_dest.chmod(0o755)
452
- except Exception:
453
- pass
454
- return hook_dest
455
-
456
- hook_files = {
457
- "pre-commit": ensure_hook_file("pre-commit", pre_commit),
458
- "post-commit": ensure_hook_file("post-commit", post_commit),
459
- "post-checkout": ensure_hook_file("post-checkout", post_checkout),
460
- "post-merge": ensure_hook_file("post-merge", post_merge),
461
- "pre-push": ensure_hook_file("pre-push", pre_push),
462
- }
443
+ hook_files: dict[str, Path] = {}
463
444
 
464
- # Generate documentation files from templates
465
- if generate_docs:
445
+ def create_graph_dirs() -> None:
446
+ graph_dir.mkdir(parents=True, exist_ok=True)
447
+ for collection in HtmlGraphAPIHandler.COLLECTIONS:
448
+ (graph_dir / collection).mkdir(exist_ok=True)
449
+
450
+ def create_events_dir() -> None:
451
+ events_dir.mkdir(exist_ok=True)
452
+ if not args.no_events_keep:
453
+ keep = events_dir / ".gitkeep"
454
+ if not keep.exists():
455
+ keep.write_text("", encoding="utf-8")
456
+
457
+ def copy_assets() -> None:
458
+ styles_src = Path(__file__).parent / "styles.css"
459
+ styles_dest = graph_dir / "styles.css"
460
+ if styles_src.exists() and not styles_dest.exists():
461
+ styles_dest.write_text(styles_src.read_text())
462
+
463
+ index_path = Path(args.dir) / "index.html"
464
+ if not index_path.exists():
465
+ create_default_index(index_path)
466
+
467
+ def init_analytics_cache() -> None:
468
+ if args.no_index:
469
+ return
470
+ with status_context("Initializing analytics cache..."):
471
+ try:
472
+ AnalyticsIndex(graph_dir / "index.sqlite").ensure_schema()
473
+ except Exception:
474
+ # Never fail init because of analytics cache.
475
+ pass
476
+
477
+ def update_gitignore() -> None:
478
+ ensure_gitignore_entries(
479
+ Path(args.dir),
480
+ [
481
+ ".htmlgraph/index.sqlite",
482
+ ".htmlgraph/index.sqlite-wal",
483
+ ".htmlgraph/index.sqlite-shm",
484
+ ".htmlgraph/git-hook-errors.log",
485
+ ],
486
+ )
487
+
488
+ def create_hook_templates() -> None:
489
+ nonlocal hook_files
490
+ hooks_dir = graph_dir / "hooks"
491
+ hooks_dir.mkdir(exist_ok=True)
492
+
493
+ def ensure_hook_file(hook_name: str, hook_content: str) -> Path:
494
+ hook_dest = hooks_dir / f"{hook_name}.sh"
495
+ if not hook_dest.exists():
496
+ hook_dest.write_text(hook_content)
497
+ try:
498
+ hook_dest.chmod(0o755)
499
+ except Exception:
500
+ pass
501
+ return hook_dest
502
+
503
+ hook_files = {
504
+ "pre-commit": ensure_hook_file("pre-commit", pre_commit),
505
+ "post-commit": ensure_hook_file("post-commit", post_commit),
506
+ "post-checkout": ensure_hook_file("post-checkout", post_checkout),
507
+ "post-merge": ensure_hook_file("post-merge", post_merge),
508
+ "pre-push": ensure_hook_file("pre-push", pre_push),
509
+ }
510
+
511
+ def generate_docs_step() -> None:
512
+ if not generate_docs:
513
+ return
466
514
 
467
515
  def render_template(
468
516
  template_path: Path, replacements: dict[str, str]
@@ -483,7 +531,7 @@ exit 0
483
531
  from htmlgraph import __version__
484
532
 
485
533
  version = __version__
486
- except:
534
+ except Exception:
487
535
  version = "unknown"
488
536
 
489
537
  replacements = {
@@ -519,17 +567,9 @@ exit 0
519
567
  gemini_dest.write_text(content, encoding="utf-8")
520
568
  print(f"✓ Generated: {gemini_dest}")
521
569
 
522
- print(f"\nInitialized HtmlGraph in {graph_dir}")
523
- print(f"Collections: {', '.join(HtmlGraphAPIHandler.COLLECTIONS)}")
524
- print("\nStart server with: htmlgraph serve")
525
- if not args.no_index:
526
- print(
527
- f"Analytics cache: {graph_dir / 'index.sqlite'} (rebuildable; typically gitignored)"
528
- )
529
- print(f"Events: {events_dir}/ (append-only JSONL)")
530
-
531
- # Install Git hooks if requested
532
- if args.install_hooks:
570
+ def install_hooks_step() -> None:
571
+ if not args.install_hooks:
572
+ return
533
573
  git_dir = Path(args.dir) / ".git"
534
574
  if not git_dir.exists():
535
575
  print("\n⚠️ Warning: No .git directory found. Git hooks not installed.")
@@ -606,6 +646,46 @@ fi
606
646
 
607
647
  print("\nGit events will now be logged to HtmlGraph automatically.")
608
648
 
649
+ steps: list[tuple[str, Any]] = [
650
+ ("Create .htmlgraph directories", create_graph_dirs),
651
+ ("Create event log directory", create_events_dir),
652
+ ("Update .gitignore", update_gitignore),
653
+ ("Prepare git hook templates", create_hook_templates),
654
+ ("Copy default assets", copy_assets),
655
+ ]
656
+ if not args.no_index:
657
+ steps.append(("Initialize analytics cache", init_analytics_cache))
658
+ if generate_docs:
659
+ steps.append(("Generate documentation", generate_docs_step))
660
+ if args.install_hooks:
661
+ steps.append(("Install git hooks", install_hooks_step))
662
+
663
+ def run_steps(step_list: list[tuple[str, Any]]) -> None:
664
+ if progress is None:
665
+ for _, fn in step_list:
666
+ fn()
667
+ return
668
+
669
+ with progress:
670
+ task_id = progress.add_task(
671
+ "Initializing HtmlGraph...", total=len(step_list)
672
+ )
673
+ for description, fn in step_list:
674
+ progress.update(task_id, description=description)
675
+ fn()
676
+ progress.advance(task_id)
677
+
678
+ run_steps(steps)
679
+
680
+ print(f"\nInitialized HtmlGraph in {graph_dir}")
681
+ print(f"Collections: {', '.join(HtmlGraphAPIHandler.COLLECTIONS)}")
682
+ print("\nStart server with: htmlgraph serve")
683
+ if not args.no_index:
684
+ print(
685
+ f"Analytics cache: {graph_dir / 'index.sqlite'} (rebuildable; typically gitignored)"
686
+ )
687
+ print(f"Events: {events_dir}/ (append-only JSONL)")
688
+
609
689
 
610
690
  def cmd_install_hooks(args: argparse.Namespace) -> None:
611
691
  """Install Git hooks for automatic tracking."""
@@ -2358,22 +2438,15 @@ def cmd_events_export(args: argparse.Namespace) -> None:
2358
2438
 
2359
2439
  def cmd_index_rebuild(args: argparse.Namespace) -> None:
2360
2440
  """Rebuild the SQLite analytics index from JSONL event logs."""
2361
- from htmlgraph.analytics_index import AnalyticsIndex
2362
- from htmlgraph.event_log import JsonlEventLog
2441
+ from htmlgraph.operations import rebuild_index
2363
2442
 
2364
2443
  graph_dir = Path(args.graph_dir)
2365
- events_dir = graph_dir / "events"
2366
- db_path = graph_dir / "index.sqlite"
2367
-
2368
- log = JsonlEventLog(events_dir)
2369
- index = AnalyticsIndex(db_path)
2370
2444
 
2371
- events = (event for _, event in log.iter_events())
2372
- result = index.rebuild_from_events(events)
2445
+ result = rebuild_index(graph_dir=graph_dir)
2373
2446
 
2374
- print(f"DB: {db_path}")
2375
- print(f"Inserted: {result['inserted']}")
2376
- print(f"Skipped: {result['skipped']}")
2447
+ print(f"DB: {result.db_path}")
2448
+ print(f"Inserted: {result.inserted}")
2449
+ print(f"Skipped: {result.skipped}")
2377
2450
 
2378
2451
 
2379
2452
  def cmd_watch(args: argparse.Namespace) -> None:
@@ -3512,6 +3585,106 @@ def create_default_index(path: Path) -> None:
3512
3585
  )
3513
3586
 
3514
3587
 
3588
+ def cmd_claude(args: argparse.Namespace) -> None:
3589
+ """Start Claude Code with orchestrator prompt."""
3590
+ import textwrap
3591
+
3592
+ try:
3593
+ if args.init:
3594
+ # Load optimized orchestrator system prompt
3595
+ prompt_file = Path(__file__).parent / "orchestrator_system_prompt.txt"
3596
+
3597
+ if prompt_file.exists():
3598
+ system_prompt = prompt_file.read_text(encoding="utf-8")
3599
+ else:
3600
+ # Fallback: provide minimal orchestrator guidance
3601
+ system_prompt = textwrap.dedent(
3602
+ """
3603
+ You are an AI orchestrator for HtmlGraph project development.
3604
+
3605
+ CRITICAL DIRECTIVES:
3606
+ 1. DELEGATE to subagents - do not implement directly
3607
+ 2. CREATE work items before delegating (features, bugs, spikes)
3608
+ 3. USE SDK for tracking - all work must be tracked in .htmlgraph/
3609
+ 4. RESPECT dependencies - check blockers before starting
3610
+
3611
+ Key Rules:
3612
+ - Implementation work → delegate to general-purpose subagent
3613
+ - Research/exploration → delegate to explorer subagent
3614
+ - Testing/validation → delegate to test-runner subagent
3615
+ - Complex analysis → delegate to appropriate specialist
3616
+
3617
+ Always use:
3618
+ from htmlgraph import SDK
3619
+ sdk = SDK(agent='orchestrator')
3620
+
3621
+ See CLAUDE.md for complete orchestrator directives.
3622
+ """
3623
+ )
3624
+
3625
+ if args.quiet or args.format == "json":
3626
+ # Non-interactive: directly launch Claude with system prompt
3627
+ cmd = ["claude", "--append-system-prompt", system_prompt]
3628
+ else:
3629
+ # Interactive: show summary first
3630
+ print("=" * 60)
3631
+ print("🤖 HtmlGraph Orchestrator Mode")
3632
+ print("=" * 60)
3633
+ print("\nStarting Claude Code with orchestrator system prompt...")
3634
+ print("Key directives:")
3635
+ print(" ✓ Delegate implementation to subagents")
3636
+ print(" ✓ Create work items before delegating")
3637
+ print(" ✓ Track all work in .htmlgraph/")
3638
+ print(" ✓ Respect dependency chains")
3639
+ print()
3640
+
3641
+ cmd = ["claude", "--append-system-prompt", system_prompt]
3642
+
3643
+ try:
3644
+ subprocess.run(cmd, check=False)
3645
+ except FileNotFoundError:
3646
+ print("Error: 'claude' command not found.", file=sys.stderr)
3647
+ print(
3648
+ "Please install Claude Code CLI: https://code.claude.com",
3649
+ file=sys.stderr,
3650
+ )
3651
+ sys.exit(1)
3652
+
3653
+ elif args.continue_session:
3654
+ # Resume last Claude Code session
3655
+ if args.quiet or args.format == "json":
3656
+ cmd = ["claude", "--resume"]
3657
+ else:
3658
+ print("Resuming last Claude Code session...")
3659
+ cmd = ["claude", "--resume"]
3660
+
3661
+ try:
3662
+ subprocess.run(cmd, check=False)
3663
+ except FileNotFoundError:
3664
+ print("Error: 'claude' command not found.", file=sys.stderr)
3665
+ print(
3666
+ "Please install Claude Code CLI: https://code.claude.com",
3667
+ file=sys.stderr,
3668
+ )
3669
+ sys.exit(1)
3670
+
3671
+ else:
3672
+ # Default: start normal Claude Code session
3673
+ try:
3674
+ subprocess.run(["claude"], check=False)
3675
+ except FileNotFoundError:
3676
+ print("Error: 'claude' command not found.", file=sys.stderr)
3677
+ print(
3678
+ "Please install Claude Code CLI: https://code.claude.com",
3679
+ file=sys.stderr,
3680
+ )
3681
+ sys.exit(1)
3682
+
3683
+ except Exception as e:
3684
+ print(f"Error: Failed to start Claude Code: {e}", file=sys.stderr)
3685
+ sys.exit(1)
3686
+
3687
+
3515
3688
  def main() -> None:
3516
3689
  parser = argparse.ArgumentParser(
3517
3690
  description="HtmlGraph - HTML is All You Need",
@@ -4917,6 +5090,24 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
4917
5090
  help="Install the Gemini CLI extension from the bundled package",
4918
5091
  )
4919
5092
 
5093
+ # claude - Start Claude Code with orchestrator support
5094
+ claude_parser = subparsers.add_parser(
5095
+ "claude", help="Start Claude Code with HtmlGraph integration"
5096
+ )
5097
+ claude_group = claude_parser.add_mutually_exclusive_group()
5098
+ claude_group.add_argument(
5099
+ "--init",
5100
+ action="store_true",
5101
+ help="Start with orchestrator system prompt (recommended)",
5102
+ )
5103
+ claude_group.add_argument(
5104
+ "--continue",
5105
+ dest="continue_session",
5106
+ action="store_true",
5107
+ help="Resume last Claude Code session",
5108
+ )
5109
+ claude_parser.set_defaults(func=cmd_claude)
5110
+
4920
5111
  args = parser.parse_args()
4921
5112
 
4922
5113
  if args.command == "serve":
@@ -5154,6 +5345,8 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
5154
5345
  sys.exit(1)
5155
5346
  elif args.command == "install-gemini-extension":
5156
5347
  cmd_install_gemini_extension(args)
5348
+ elif args.command == "claude":
5349
+ cmd_claude(args)
5157
5350
  else:
5158
5351
  parser.print_help()
5159
5352
  sys.exit(1)
@@ -91,6 +91,48 @@ class BaseCollection(Generic[CollectionT]):
91
91
 
92
92
  return self._graph
93
93
 
94
+ def __getattribute__(self, name: str) -> Any:
95
+ """Override to provide helpful error messages for missing attributes."""
96
+ try:
97
+ return object.__getattribute__(self, name)
98
+ except AttributeError as e:
99
+ # Get available methods
100
+ available = [m for m in dir(self) if not m.startswith("_")]
101
+
102
+ # Common mistakes mapping
103
+ common_mistakes = {
104
+ "mark_complete": "mark_done",
105
+ "complete": "Use complete(node_id) for single item or mark_done([ids]) for batch",
106
+ "finish": "mark_done",
107
+ "end": "mark_done",
108
+ "update_status": "edit() context manager or batch_update()",
109
+ "mark_as_done": "mark_done",
110
+ "set_done": "mark_done",
111
+ "complete_all": "mark_done",
112
+ }
113
+
114
+ suggestions = []
115
+ if name in common_mistakes:
116
+ suggestions.append(f"Did you mean: {common_mistakes[name]}")
117
+
118
+ # Find similar method names
119
+ similar = [
120
+ m
121
+ for m in available
122
+ if name.lower() in m.lower() or m.lower() in name.lower()
123
+ ]
124
+ if similar:
125
+ suggestions.append(f"Similar methods: {', '.join(similar[:5])}")
126
+
127
+ # Build helpful error message
128
+ error_msg = f"'{type(self).__name__}' has no attribute '{name}'."
129
+ if suggestions:
130
+ error_msg += "\n\n" + "\n".join(suggestions)
131
+ error_msg += f"\n\nAvailable methods: {', '.join(available[:15])}"
132
+ error_msg += "\n\nTip: Use sdk.help() to see all available operations."
133
+
134
+ raise AttributeError(error_msg) from e
135
+
94
136
  def __dir__(self) -> list[str]:
95
137
  """Return attributes with most useful ones first for discoverability."""
96
138
  priority = [
@@ -418,7 +460,7 @@ class BaseCollection(Generic[CollectionT]):
418
460
 
419
461
  return count
420
462
 
421
- def mark_done(self, node_ids: list[str]) -> int:
463
+ def mark_done(self, node_ids: list[str]) -> dict[str, Any]:
422
464
  """
423
465
  Batch mark nodes as done.
424
466
 
@@ -426,12 +468,34 @@ class BaseCollection(Generic[CollectionT]):
426
468
  node_ids: List of node IDs to mark as done
427
469
 
428
470
  Returns:
429
- Number of nodes updated
471
+ Dict with 'success_count', 'failed_ids', and 'warnings'
430
472
 
431
473
  Example:
432
- >>> sdk.features.mark_done(["feat-001", "feat-002"])
474
+ >>> result = sdk.features.mark_done(["feat-001", "feat-002"])
475
+ >>> print(f"Completed {result['success_count']} of {len(node_ids)}")
476
+ >>> if result['failed_ids']:
477
+ ... print(f"Failed: {result['failed_ids']}")
433
478
  """
434
- return self.batch_update(node_ids, {"status": "done"})
479
+ graph = self._ensure_graph()
480
+ results: dict[str, Any] = {"success_count": 0, "failed_ids": [], "warnings": []}
481
+
482
+ for node_id in node_ids:
483
+ try:
484
+ node = graph.get(node_id)
485
+ if not node:
486
+ results["failed_ids"].append(node_id)
487
+ results["warnings"].append(f"Node {node_id} not found")
488
+ continue
489
+
490
+ node.status = "done"
491
+ node.updated = datetime.now()
492
+ graph.update(node)
493
+ results["success_count"] += 1
494
+ except Exception as e:
495
+ results["failed_ids"].append(node_id)
496
+ results["warnings"].append(f"Failed to mark {node_id}: {str(e)}")
497
+
498
+ return results
435
499
 
436
500
  def assign(self, node_ids: list[str], agent: str) -> int:
437
501
  """
htmlgraph/git_events.py CHANGED
@@ -168,9 +168,19 @@ def parse_feature_refs(message: str) -> list[str]:
168
168
  Parse feature IDs from commit message.
169
169
 
170
170
  Looks for patterns like:
171
- - Implements: feature-xyz
171
+ - Implements: feat-xyz
172
172
  - Fixes: bug-abc
173
- - feature-xyz
173
+ - [feat-123abc]
174
+ - feat-xyz
175
+
176
+ Supports HtmlGraph ID formats:
177
+ - feat-XXXXXXXX (features)
178
+ - feature-XXXXXXXX (legacy features)
179
+ - bug-XXXXXXXX (bugs)
180
+ - spk-XXXXXXXX (spikes)
181
+ - spike-XXXXXXXX (legacy spikes)
182
+ - chr-XXXXXXXX (chores)
183
+ - trk-XXXXXXXX (tracks)
174
184
 
175
185
  Args:
176
186
  message: Commit message
@@ -180,14 +190,21 @@ def parse_feature_refs(message: str) -> list[str]:
180
190
  """
181
191
  features = []
182
192
 
183
- # Pattern: Implements: feature-xyz
184
- pattern1 = r"(?:Implements|Fixes|Closes|Refs):\s*(feature-[\w-]+|bug-[\w-]+)"
193
+ # All HtmlGraph ID prefixes (current + legacy)
194
+ id_prefixes = r"(?:feat|feature|bug|spk|spike|chr|chore|trk|track|todo)"
195
+
196
+ # Pattern 1: Explicit tags (Implements: feat-xyz)
197
+ pattern1 = rf"(?:Implements|Fixes|Closes|Refs):\s*({id_prefixes}-[\w-]+)"
185
198
  features.extend(re.findall(pattern1, message, re.IGNORECASE))
186
199
 
187
- # Pattern: feature-xyz (anywhere in message)
188
- pattern2 = r"\b(feature-[\w-]+|bug-[\w-]+)\b"
200
+ # Pattern 2: Square brackets [feat-xyz] (common in commit messages)
201
+ pattern2 = rf"\[({id_prefixes}-[\w-]+)\]"
189
202
  features.extend(re.findall(pattern2, message, re.IGNORECASE))
190
203
 
204
+ # Pattern 3: Anywhere in message as word boundary
205
+ pattern3 = rf"\b({id_prefixes}-[\w-]+)\b"
206
+ features.extend(re.findall(pattern3, message, re.IGNORECASE))
207
+
191
208
  # Remove duplicates while preserving order
192
209
  seen = set()
193
210
  unique_features = []
@@ -288,7 +305,44 @@ def _determine_context(graph_dir: Path, commit_message: str | None = None) -> di
288
305
  if f and f not in all_features:
289
306
  all_features.append(f)
290
307
 
291
- session = get_active_session(graph_dir)
308
+ # Try to find the right session based on feature IDs in commit message
309
+ # This handles multi-agent scenarios where multiple sessions are active
310
+ session = None
311
+ if message_features:
312
+ # If commit mentions specific features, find the session working on them
313
+ manager = _get_session_manager(graph_dir)
314
+ if manager:
315
+ try:
316
+ # Try to find a session that has any of the message features as active
317
+ for feature_id in message_features:
318
+ # Get the feature to check which agent is working on it
319
+ try:
320
+ # Try features graph first
321
+ feature = manager.features_graph.get(feature_id)
322
+ if not feature:
323
+ # Try bugs graph
324
+ feature = manager.bugs_graph.get(feature_id)
325
+
326
+ if (
327
+ feature
328
+ and hasattr(feature, "agent_assigned")
329
+ and feature.agent_assigned
330
+ ):
331
+ # Find active session for this agent
332
+ session = manager.get_active_session(
333
+ agent=feature.agent_assigned
334
+ )
335
+ if session:
336
+ break
337
+ except Exception:
338
+ pass
339
+ except Exception:
340
+ pass
341
+
342
+ # Fallback to any active session if we couldn't match by feature
343
+ if not session:
344
+ session = get_active_session(graph_dir)
345
+
292
346
  if session:
293
347
  return {
294
348
  "session_id": session.id,