htmlgraph 0.24.2__py3-none-any.whl → 0.25.0__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 (103) hide show
  1. htmlgraph/__init__.py +20 -1
  2. htmlgraph/agent_detection.py +26 -10
  3. htmlgraph/analytics/cross_session.py +4 -3
  4. htmlgraph/analytics/work_type.py +52 -16
  5. htmlgraph/analytics_index.py +51 -19
  6. htmlgraph/api/__init__.py +3 -0
  7. htmlgraph/api/main.py +2115 -0
  8. htmlgraph/api/static/htmx.min.js +1 -0
  9. htmlgraph/api/static/style-redesign.css +1344 -0
  10. htmlgraph/api/static/style.css +1079 -0
  11. htmlgraph/api/templates/dashboard-redesign.html +812 -0
  12. htmlgraph/api/templates/dashboard.html +783 -0
  13. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  14. htmlgraph/api/templates/partials/activity-feed.html +570 -0
  15. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  16. htmlgraph/api/templates/partials/agents.html +317 -0
  17. htmlgraph/api/templates/partials/event-traces.html +373 -0
  18. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  19. htmlgraph/api/templates/partials/features.html +509 -0
  20. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  21. htmlgraph/api/templates/partials/metrics.html +346 -0
  22. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  23. htmlgraph/api/templates/partials/orchestration.html +163 -0
  24. htmlgraph/api/templates/partials/spawners.html +375 -0
  25. htmlgraph/atomic_ops.py +560 -0
  26. htmlgraph/builders/base.py +55 -1
  27. htmlgraph/builders/bug.py +17 -2
  28. htmlgraph/builders/chore.py +17 -2
  29. htmlgraph/builders/epic.py +17 -2
  30. htmlgraph/builders/feature.py +25 -2
  31. htmlgraph/builders/phase.py +17 -2
  32. htmlgraph/builders/spike.py +27 -2
  33. htmlgraph/builders/track.py +14 -0
  34. htmlgraph/cigs/__init__.py +4 -0
  35. htmlgraph/cigs/reporter.py +818 -0
  36. htmlgraph/cli.py +1427 -401
  37. htmlgraph/cli_commands/__init__.py +1 -0
  38. htmlgraph/cli_commands/feature.py +195 -0
  39. htmlgraph/cli_framework.py +115 -0
  40. htmlgraph/collections/__init__.py +2 -0
  41. htmlgraph/collections/base.py +21 -0
  42. htmlgraph/collections/session.py +189 -0
  43. htmlgraph/collections/spike.py +7 -1
  44. htmlgraph/collections/task_delegation.py +236 -0
  45. htmlgraph/collections/traces.py +482 -0
  46. htmlgraph/config.py +113 -0
  47. htmlgraph/converter.py +41 -0
  48. htmlgraph/cost_analysis/__init__.py +5 -0
  49. htmlgraph/cost_analysis/analyzer.py +438 -0
  50. htmlgraph/dashboard.html +3315 -492
  51. htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
  52. htmlgraph/dashboard.html.bak +7181 -0
  53. htmlgraph/dashboard.html.bak2 +7231 -0
  54. htmlgraph/dashboard.html.bak3 +7232 -0
  55. htmlgraph/db/__init__.py +38 -0
  56. htmlgraph/db/queries.py +790 -0
  57. htmlgraph/db/schema.py +1334 -0
  58. htmlgraph/deploy.py +26 -27
  59. htmlgraph/docs/API_REFERENCE.md +841 -0
  60. htmlgraph/docs/HTTP_API.md +750 -0
  61. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  62. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
  63. htmlgraph/docs/README.md +533 -0
  64. htmlgraph/docs/version_check.py +3 -1
  65. htmlgraph/error_handler.py +544 -0
  66. htmlgraph/event_log.py +2 -0
  67. htmlgraph/hooks/__init__.py +8 -0
  68. htmlgraph/hooks/bootstrap.py +169 -0
  69. htmlgraph/hooks/context.py +271 -0
  70. htmlgraph/hooks/drift_handler.py +521 -0
  71. htmlgraph/hooks/event_tracker.py +405 -15
  72. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  73. htmlgraph/hooks/pretooluse.py +476 -6
  74. htmlgraph/hooks/prompt_analyzer.py +648 -0
  75. htmlgraph/hooks/session_handler.py +583 -0
  76. htmlgraph/hooks/state_manager.py +501 -0
  77. htmlgraph/hooks/subagent_stop.py +309 -0
  78. htmlgraph/hooks/task_enforcer.py +39 -0
  79. htmlgraph/models.py +111 -15
  80. htmlgraph/operations/fastapi_server.py +230 -0
  81. htmlgraph/orchestration/headless_spawner.py +22 -14
  82. htmlgraph/pydantic_models.py +476 -0
  83. htmlgraph/quality_gates.py +350 -0
  84. htmlgraph/repo_hash.py +511 -0
  85. htmlgraph/sdk.py +348 -10
  86. htmlgraph/server.py +194 -0
  87. htmlgraph/session_hooks.py +300 -0
  88. htmlgraph/session_manager.py +131 -1
  89. htmlgraph/session_registry.py +587 -0
  90. htmlgraph/session_state.py +436 -0
  91. htmlgraph/system_prompts.py +449 -0
  92. htmlgraph/templates/orchestration-view.html +350 -0
  93. htmlgraph/track_builder.py +19 -0
  94. htmlgraph/validation.py +115 -0
  95. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +7417 -0
  96. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/METADATA +91 -64
  97. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/RECORD +103 -42
  98. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/styles.css +0 -0
  99. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  100. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  101. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  102. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/WHEEL +0 -0
  103. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/entry_points.txt +0 -0
htmlgraph/cli.py CHANGED
@@ -41,6 +41,13 @@ Analytics:
41
41
  htmlgraph analytics # Project-wide analytics
42
42
  htmlgraph analytics --session-id SESSION_ID # Single session analysis
43
43
  htmlgraph analytics --recent N # Analyze recent N sessions
44
+
45
+ Cost Attribution:
46
+ htmlgraph cigs cost-dashboard # Display cost summary in console
47
+ htmlgraph cigs cost-dashboard --save # Save to .htmlgraph/cost-dashboard.html
48
+ htmlgraph cigs cost-dashboard --open # Open in browser after generation
49
+ htmlgraph cigs cost-dashboard --json # Output JSON instead of HTML
50
+ htmlgraph cigs cost-dashboard --output PATH # Custom output path
44
51
  """
45
52
 
46
53
  import argparse
@@ -51,6 +58,20 @@ from datetime import datetime
51
58
  from pathlib import Path
52
59
  from typing import Any
53
60
 
61
+ from rich import box
62
+ from rich.console import Console
63
+ from rich.panel import Panel
64
+ from rich.progress import Progress, SpinnerColumn, TextColumn
65
+ from rich.prompt import Confirm, Prompt
66
+ from rich.table import Table
67
+ from rich.traceback import install as install_traceback
68
+
69
+ # Install Rich traceback globally for better error display
70
+ install_traceback(show_locals=True)
71
+
72
+ # Global Rich Console for beautiful CLI output
73
+ console = Console()
74
+
54
75
 
55
76
  def create_json_response(
56
77
  command: str,
@@ -84,14 +105,15 @@ def cmd_install_gemini_extension(args: argparse.Namespace) -> None:
84
105
  extension_dir = package_dir / "extensions" / "gemini"
85
106
 
86
107
  if not extension_dir.exists():
87
- print(f"Error: Gemini extension not found at {extension_dir}", file=sys.stderr)
88
- print(
89
- "The extension may not be bundled with this version of htmlgraph.",
90
- file=sys.stderr,
108
+ console.print(
109
+ f"[red]Error: Gemini extension not found at {extension_dir}[/red]"
110
+ )
111
+ console.print(
112
+ "[red]The extension may not be bundled with this version of htmlgraph.[/red]"
91
113
  )
92
114
  sys.exit(1)
93
115
 
94
- print(f"Installing Gemini extension from: {extension_dir}")
116
+ console.print(f"[cyan]Installing Gemini extension from:[/cyan] {extension_dir}")
95
117
 
96
118
  # Run gemini extensions install with the bundled path
97
119
  try:
@@ -101,35 +123,125 @@ def cmd_install_gemini_extension(args: argparse.Namespace) -> None:
101
123
  text=True,
102
124
  check=True,
103
125
  )
104
- print(result.stdout)
105
- print("\nāœ… Gemini extension installed successfully!")
106
- print("\nTo verify installation:")
107
- print(" gemini extensions list")
126
+ console.print(result.stdout)
127
+ console.print("\n[green]āœ… Gemini extension installed successfully![/green]")
128
+ console.print("\nTo verify installation:")
129
+ console.print(" gemini extensions list", style="dim")
108
130
  except subprocess.CalledProcessError as e:
109
- print(f"Error installing extension: {e.stderr}", file=sys.stderr)
131
+ console.print(f"[red]Error installing extension: {e.stderr}[/red]", style="red")
110
132
  sys.exit(1)
111
133
  except FileNotFoundError:
112
- print("Error: 'gemini' command not found.", file=sys.stderr)
113
- print("Please install Gemini CLI first:", file=sys.stderr)
114
- print(" npm install -g @google/gemini-cli", file=sys.stderr)
134
+ console.print("[red]Error: 'gemini' command not found.[/red]")
135
+ console.print("Please install Gemini CLI first:")
136
+ console.print(" npm install -g @google/gemini-cli", style="dim")
115
137
  sys.exit(1)
116
138
 
117
139
 
118
140
  def cmd_serve(args: argparse.Namespace) -> None:
119
- """Start the HtmlGraph server."""
120
- from htmlgraph.operations import start_server
141
+ """Start the HtmlGraph server (FastAPI-based)."""
142
+ import asyncio
121
143
 
122
- start_server(
123
- port=args.port,
124
- graph_dir=args.graph_dir,
125
- static_dir=args.static_dir,
126
- host=args.host,
127
- watch=not args.no_watch,
128
- auto_port=args.auto_port,
144
+ from htmlgraph.operations.fastapi_server import (
145
+ run_fastapi_server,
146
+ start_fastapi_server,
129
147
  )
130
148
 
131
- # The start_server operation already handles all output and blocks
132
- # No additional CLI formatting needed
149
+ try:
150
+ # Default to database in graph dir if not specified
151
+ db_path = getattr(args, "db", None)
152
+ if not db_path:
153
+ db_path = str(Path(args.graph_dir) / "index.sqlite")
154
+
155
+ result = start_fastapi_server(
156
+ port=args.port,
157
+ host=args.host,
158
+ db_path=db_path,
159
+ auto_port=args.auto_port,
160
+ reload=getattr(args, "reload", False),
161
+ )
162
+
163
+ # Print server info
164
+ console.print("\n[bold cyan]HtmlGraph Server (FastAPI)[/bold cyan]")
165
+ console.print(f"URL: [bold blue]{result.handle.url}[/bold blue]")
166
+ console.print(f"Graph directory: {args.graph_dir}")
167
+ console.print(f"Database: {result.config_used['db_path']}")
168
+
169
+ if result.warnings:
170
+ for warning in result.warnings:
171
+ console.print(f"[yellow]Warning: {warning}[/yellow]")
172
+
173
+ from htmlgraph.server import HtmlGraphAPIHandler
174
+
175
+ console.print(f"Collections: {', '.join(HtmlGraphAPIHandler.COLLECTIONS)}")
176
+ console.print("\n[cyan]Features:[/cyan]")
177
+ console.print(" • Real-time agent activity feed (HTMX)")
178
+ console.print(" • Orchestration chains visualization")
179
+ console.print(" • Feature tracker with Kanban view")
180
+ console.print(" • Session metrics & performance analytics")
181
+ console.print("\n[cyan]Press Ctrl+C to stop.[/cyan]\n")
182
+
183
+ # Run server
184
+ asyncio.run(run_fastapi_server(result.handle))
185
+
186
+ except KeyboardInterrupt:
187
+ console.print("\n[yellow]Shutting down...[/yellow]")
188
+ except Exception as e:
189
+ console.print(f"\n[red]Error:[/red] {e}")
190
+ import traceback
191
+
192
+ if getattr(args, "verbose", False):
193
+ traceback.print_exc()
194
+ sys.exit(1)
195
+
196
+
197
+ def cmd_serve_api(args: argparse.Namespace) -> None:
198
+ """Start the FastAPI-based observability dashboard (Phase 3)."""
199
+ import asyncio
200
+
201
+ from htmlgraph.operations.fastapi_server import (
202
+ run_fastapi_server,
203
+ start_fastapi_server,
204
+ )
205
+
206
+ try:
207
+ result = start_fastapi_server(
208
+ port=args.port,
209
+ host=args.host,
210
+ db_path=args.db,
211
+ auto_port=args.auto_port,
212
+ reload=args.reload,
213
+ )
214
+
215
+ # Print server info
216
+ console.print("\n[bold cyan]HtmlGraph FastAPI Dashboard[/bold cyan]")
217
+ console.print("[bold green]āœ“[/bold green] Started observability dashboard")
218
+ console.print(f"URL: [bold blue]{result.handle.url}[/bold blue]")
219
+ console.print(f"Database: {result.config_used['db_path']}")
220
+
221
+ if result.warnings:
222
+ for warning in result.warnings:
223
+ console.print(f"[yellow]Warning: {warning}[/yellow]")
224
+
225
+ console.print("\n[cyan]Features:[/cyan]")
226
+ console.print(" • Real-time agent activity feed")
227
+ console.print(" • Orchestration chains visualization")
228
+ console.print(" • Feature tracker with Kanban view")
229
+ console.print(" • Session metrics & performance analytics")
230
+ console.print(" • WebSocket live event streaming")
231
+ console.print("\n[cyan]Press Ctrl+C to stop.[/cyan]\n")
232
+
233
+ # Run server
234
+ asyncio.run(run_fastapi_server(result.handle))
235
+
236
+ except KeyboardInterrupt:
237
+ console.print("\n[yellow]Shutting down...[/yellow]")
238
+ except Exception as e:
239
+ console.print(f"\n[red]Error:[/red] {e}")
240
+ import traceback
241
+
242
+ if getattr(args, "verbose", False):
243
+ traceback.print_exc()
244
+ sys.exit(1)
133
245
 
134
246
 
135
247
  def cmd_init(args: argparse.Namespace) -> None:
@@ -142,28 +254,26 @@ def cmd_init(args: argparse.Namespace) -> None:
142
254
 
143
255
  # Interactive setup wizard
144
256
  if args.interactive:
145
- print("=== HtmlGraph Interactive Setup ===\n")
257
+ console.print("\n[bold cyan]=== HtmlGraph Interactive Setup ===[/bold cyan]\n")
146
258
 
147
259
  # Get project name
148
260
  default_name = Path(args.dir).resolve().name
149
- project_name = input(f"Project name [{default_name}]: ").strip() or default_name
261
+ project_name = Prompt.ask("Project name", default=default_name)
150
262
 
151
263
  # Get agent name
152
- agent_name = input("Your agent name [claude]: ").strip() or "claude"
264
+ agent_name = Prompt.ask("Your agent name", default="claude")
153
265
 
154
266
  # Ask about git hooks
155
- install_hooks_response = (
156
- input("Install git hooks for automatic tracking? [Y/n]: ").strip().lower()
267
+ args.install_hooks = Confirm.ask(
268
+ "Install git hooks for automatic tracking?", default=True
157
269
  )
158
- args.install_hooks = install_hooks_response != "n"
159
270
 
160
271
  # Ask about documentation generation
161
- gen_docs_response = (
162
- input("Generate AGENTS.md, CLAUDE.md, GEMINI.md? [Y/n]: ").strip().lower()
272
+ generate_docs = Confirm.ask(
273
+ "Generate AGENTS.md, CLAUDE.md, GEMINI.md?", default=True
163
274
  )
164
- generate_docs = gen_docs_response != "n"
165
275
 
166
- print()
276
+ console.print()
167
277
  else:
168
278
  # Non-interactive defaults
169
279
  project_name = Path(args.dir).resolve().name
@@ -548,7 +658,7 @@ exit 0
548
658
  content = render_template(agents_template, replacements)
549
659
  if content:
550
660
  agents_dest.write_text(content, encoding="utf-8")
551
- print(f"āœ“ Generated: {agents_dest}")
661
+ console.print(f"[green]āœ“ Generated:[/green] {agents_dest}")
552
662
 
553
663
  # Generate CLAUDE.md
554
664
  claude_template = templates_dir / "CLAUDE.md.template"
@@ -557,7 +667,7 @@ exit 0
557
667
  content = render_template(claude_template, replacements)
558
668
  if content:
559
669
  claude_dest.write_text(content, encoding="utf-8")
560
- print(f"āœ“ Generated: {claude_dest}")
670
+ console.print(f"[green]āœ“ Generated:[/green] {claude_dest}")
561
671
 
562
672
  # Generate GEMINI.md
563
673
  gemini_template = templates_dir / "GEMINI.md.template"
@@ -566,15 +676,17 @@ exit 0
566
676
  content = render_template(gemini_template, replacements)
567
677
  if content:
568
678
  gemini_dest.write_text(content, encoding="utf-8")
569
- print(f"āœ“ Generated: {gemini_dest}")
679
+ console.print(f"[green]āœ“ Generated:[/green] {gemini_dest}")
570
680
 
571
681
  def install_hooks_step() -> None:
572
682
  if not args.install_hooks:
573
683
  return
574
684
  git_dir = Path(args.dir) / ".git"
575
685
  if not git_dir.exists():
576
- print("\nāš ļø Warning: No .git directory found. Git hooks not installed.")
577
- print(" Initialize git first: git init")
686
+ console.print(
687
+ "\n[yellow]āš ļø Warning: No .git directory found. Git hooks not installed.[/yellow]"
688
+ )
689
+ console.print("[dim]Initialize git first: git init[/dim]")
578
690
  return
579
691
 
580
692
  def install_hook(
@@ -607,11 +719,11 @@ exit 0
607
719
  git_hook_path = git_dir / "hooks" / hook_name
608
720
 
609
721
  if git_hook_path.exists():
610
- print(f"\nāš ļø Existing {hook_name} hook found")
722
+ console.print(f"\n[yellow]āš ļø Existing {hook_name} hook found[/yellow]")
611
723
  backup_path = git_hook_path.with_suffix(".existing")
612
724
  if not backup_path.exists():
613
725
  shutil.copy(git_hook_path, backup_path)
614
- print(f" Backed up to: {backup_path}")
726
+ console.print(f"[dim]Backed up to: {backup_path}[/dim]")
615
727
 
616
728
  chain_content = f'''#!/bin/bash
617
729
  # Chained hook - runs existing hook then HtmlGraph hook
@@ -626,18 +738,18 @@ fi
626
738
  '''
627
739
  git_hook_path.write_text(chain_content)
628
740
  git_hook_path.chmod(0o755)
629
- print(f" Installed chained hook at: {git_hook_path}")
741
+ console.print(f"[dim]Installed chained hook at: {git_hook_path}[/dim]")
630
742
  return
631
743
 
632
744
  try:
633
745
  git_hook_path.symlink_to(hook_dest.resolve())
634
- print("\nāœ“ Git hooks installed")
635
- print(f" {hook_name}: {git_hook_path} -> {hook_dest}")
746
+ console.print("\n[green]āœ“ Git hooks installed[/green]")
747
+ console.print(f"[dim]{hook_name}: {git_hook_path} -> {hook_dest}[/dim]")
636
748
  except OSError:
637
749
  shutil.copy(hook_dest, git_hook_path)
638
750
  git_hook_path.chmod(0o755)
639
- print("\nāœ“ Git hooks installed")
640
- print(f" {hook_name}: {git_hook_path}")
751
+ console.print("\n[green]āœ“ Git hooks installed[/green]")
752
+ console.print(f"[dim]{hook_name}: {git_hook_path}[/dim]")
641
753
 
642
754
  install_hook("pre-commit", hook_files["pre-commit"], pre_commit)
643
755
  install_hook("post-commit", hook_files["post-commit"], post_commit)
@@ -645,7 +757,9 @@ fi
645
757
  install_hook("post-merge", hook_files["post-merge"], post_merge)
646
758
  install_hook("pre-push", hook_files["pre-push"], pre_push)
647
759
 
648
- print("\nGit events will now be logged to HtmlGraph automatically.")
760
+ console.print(
761
+ "\n[cyan]Git events will now be logged to HtmlGraph automatically.[/cyan]"
762
+ )
649
763
 
650
764
  steps: list[tuple[str, Any]] = [
651
765
  ("Create .htmlgraph directories", create_graph_dirs),
@@ -678,14 +792,19 @@ fi
678
792
 
679
793
  run_steps(steps)
680
794
 
681
- print(f"\nInitialized HtmlGraph in {graph_dir}")
682
- print(f"Collections: {', '.join(HtmlGraphAPIHandler.COLLECTIONS)}")
683
- print("\nStart server with: htmlgraph serve")
795
+ console.print()
796
+ console.print(f"[green]āœ“ Initialized HtmlGraph in[/green] {graph_dir}")
797
+ console.print(
798
+ f"[cyan]Collections:[/cyan] {', '.join(HtmlGraphAPIHandler.COLLECTIONS)}"
799
+ )
800
+ console.print()
801
+ console.print("[bold cyan]Start server with:[/bold cyan]")
802
+ console.print(" htmlgraph serve", style="dim")
684
803
  if not args.no_index:
685
- print(
686
- f"Analytics cache: {graph_dir / 'index.sqlite'} (rebuildable; typically gitignored)"
804
+ console.print(
805
+ f"[dim]Analytics cache: {graph_dir / 'index.sqlite'} (rebuildable; typically gitignored)[/dim]"
687
806
  )
688
- print(f"Events: {events_dir}/ (append-only JSONL)")
807
+ console.print(f"[dim]Events: {events_dir}/ (append-only JSONL)[/dim]")
689
808
 
690
809
 
691
810
  def cmd_install_hooks(args: argparse.Namespace) -> None:
@@ -704,22 +823,24 @@ def cmd_install_hooks(args: argparse.Namespace) -> None:
704
823
  # Handle configuration changes
705
824
  if args.enable:
706
825
  if args.enable not in AVAILABLE_HOOKS:
707
- print(f"Error: Unknown hook '{args.enable}'")
708
- print(f"Available hooks: {', '.join(AVAILABLE_HOOKS)}")
826
+ console.print(f"[red]Error: Unknown hook '{args.enable}'[/red]")
827
+ console.print(f"[cyan]Available hooks:[/cyan] {', '.join(AVAILABLE_HOOKS)}")
709
828
  return
710
829
  config.enable_hook(args.enable)
711
830
  config.save()
712
- print(f"āœ“ Enabled hook '{args.enable}' in configuration")
831
+ console.print(f"[green]āœ“ Enabled hook '{args.enable}' in configuration[/green]")
713
832
  return
714
833
 
715
834
  if args.disable:
716
835
  if args.disable not in AVAILABLE_HOOKS:
717
- print(f"Error: Unknown hook '{args.disable}'")
718
- print(f"Available hooks: {', '.join(AVAILABLE_HOOKS)}")
836
+ console.print(f"[red]Error: Unknown hook '{args.disable}'[/red]")
837
+ console.print(f"[cyan]Available hooks:[/cyan] {', '.join(AVAILABLE_HOOKS)}")
719
838
  return
720
839
  config.disable_hook(args.disable)
721
840
  config.save()
722
- print(f"āœ“ Disabled hook '{args.disable}' in configuration")
841
+ console.print(
842
+ f"[green]āœ“ Disabled hook '{args.disable}' in configuration[/green]"
843
+ )
723
844
  return
724
845
 
725
846
  # Override symlink preference if --use-copy is set
@@ -732,84 +853,116 @@ def cmd_install_hooks(args: argparse.Namespace) -> None:
732
853
  # Validate environment
733
854
  is_valid, error_msg = installer.validate_environment()
734
855
  if not is_valid:
735
- print(f"āŒ {error_msg}")
856
+ console.print(f"[red]āŒ {error_msg}[/red]")
736
857
  return
737
858
 
738
859
  # List hooks status
739
860
  if args.list:
740
- print("\nGit Hooks Installation Status")
741
- print("=" * 60)
861
+ console.print()
862
+ table = Table(title="Git Hooks Installation Status", border_style="cyan")
863
+ table.add_column("Hook", style="cyan", no_wrap=True)
864
+ table.add_column("Enabled", justify="center", style="green")
865
+ table.add_column("Installed", justify="center", style="green")
866
+ table.add_column("Type", style="dim")
867
+ table.add_column("Status", style="dim")
742
868
 
743
869
  status = installer.list_hooks()
744
870
  for hook_name, info in status.items():
745
871
  status_icon = "āœ“" if info["installed"] else "āœ—"
746
872
  enabled_icon = "🟢" if info["enabled"] else "šŸ”“"
873
+ type_str = "Symlink" if info["is_symlink"] else "Copied"
874
+ our_hook = (
875
+ "āœ“"
876
+ if info.get("our_hook", False)
877
+ else "āœ—"
878
+ if info["is_symlink"]
879
+ else ""
880
+ )
881
+
882
+ status_str = (
883
+ f"{type_str} {our_hook}".strip() if type_str == "Symlink" else type_str
884
+ )
885
+
886
+ table.add_row(hook_name, enabled_icon, status_icon, type_str, status_str)
747
887
 
748
- print(f"\n{enabled_icon} {hook_name} ({status_icon} installed)")
749
- print(f" Enabled in config: {info['enabled']}")
750
- print(f" Versioned (.htmlgraph/hooks/): {info['versioned']}")
751
- print(f" Installed (.git/hooks/): {info['installed']}")
752
-
753
- if info["is_symlink"]:
754
- our_hook = "āœ“" if info.get("our_hook", False) else "āœ—"
755
- print(f" Type: Symlink ({our_hook} ours)")
756
- print(f" Target: {info.get('symlink_target', 'unknown')}")
757
- elif info["installed"]:
758
- print(" Type: Copied file")
759
-
760
- print("\n" + "=" * 60)
761
- print(f"\nConfiguration: {config_path}")
762
- print("Use 'htmlgraph install-hooks --enable <hook>' to enable")
763
- print("Use 'htmlgraph install-hooks --disable <hook>' to disable")
888
+ console.print(table)
889
+ console.print()
890
+ console.print(f"[dim]Configuration: {config_path}[/dim]")
891
+ console.print(
892
+ "[dim]Use 'htmlgraph install-hooks --enable <hook>' to enable[/dim]"
893
+ )
894
+ console.print(
895
+ "[dim]Use 'htmlgraph install-hooks --disable <hook>' to disable[/dim]"
896
+ )
764
897
  return
765
898
 
766
899
  # Uninstall a hook
767
900
  if args.uninstall:
768
901
  if args.uninstall not in AVAILABLE_HOOKS:
769
- print(f"Error: Unknown hook '{args.uninstall}'")
770
- print(f"Available hooks: {', '.join(AVAILABLE_HOOKS)}")
902
+ console.print(f"[red]Error: Unknown hook '{args.uninstall}'[/red]")
903
+ console.print(f"[cyan]Available hooks:[/cyan] {', '.join(AVAILABLE_HOOKS)}")
771
904
  return
772
905
 
773
906
  success, message = installer.uninstall_hook(args.uninstall)
774
907
  if success:
775
- print(f"āœ“ {message}")
908
+ console.print(f"[green]āœ“ {message}[/green]")
776
909
  else:
777
- print(f"āŒ {message}")
910
+ console.print(f"[red]āŒ {message}[/red]")
778
911
  return
779
912
 
780
913
  # Install hooks
781
- print("\nšŸ”§ Installing Git hooks for HtmlGraph\n")
782
- print(f"Project: {project_dir}")
783
- print(f"Configuration: {config_path}")
914
+ console.print("\n[cyan]šŸ”§ Installing Git hooks for HtmlGraph[/cyan]\n")
915
+ console.print(f"[dim]Project: {project_dir}[/dim]")
916
+ console.print(f"[dim]Configuration: {config_path}[/dim]")
784
917
 
785
918
  if args.dry_run:
786
- print("\n[DRY RUN MODE - No changes will be made]\n")
919
+ console.print("\n[yellow][DRY RUN MODE - No changes will be made][/yellow]\n")
787
920
 
788
921
  results = installer.install_all_hooks(force=args.force, dry_run=args.dry_run)
789
922
 
790
- # Display results
923
+ # Display results in a table
924
+ result_table = Table(title="Installation Results", border_style="cyan")
925
+ result_table.add_column("Hook", style="cyan", no_wrap=True)
926
+ result_table.add_column("Status", justify="center")
927
+ result_table.add_column("Message", style="dim")
928
+
791
929
  success_count = 0
792
930
  failure_count = 0
793
931
 
794
932
  for hook_name, (success, message) in results.items():
795
933
  if success:
796
934
  success_count += 1
797
- print(f"āœ“ {message}")
935
+ result_table.add_row(hook_name, "[green]āœ“ Success[/green]", message)
798
936
  else:
799
937
  failure_count += 1
800
- print(f"āŒ {message}")
938
+ result_table.add_row(hook_name, "[red]āŒ Failed[/red]", message)
801
939
 
802
- print("\n" + "=" * 60)
803
- print(f"Summary: {success_count} installed, {failure_count} failed")
940
+ console.print(result_table)
941
+ console.print()
942
+ console.print(
943
+ f"[cyan]Summary:[/cyan] {success_count} installed, {failure_count} failed"
944
+ )
804
945
 
805
946
  if not args.dry_run:
806
- print(f"\nConfiguration saved to: {config_path}")
807
- print("\nGit events will now be logged to HtmlGraph automatically.")
808
- print("\nManagement commands:")
809
- print(" htmlgraph install-hooks --list # Show status")
810
- print(" htmlgraph install-hooks --uninstall <hook> # Remove hook")
811
- print(" htmlgraph install-hooks --enable <hook> # Enable hook")
812
- print(" htmlgraph install-hooks --disable <hook> # Disable hook")
947
+ console.print()
948
+ console.print(f"[green]Configuration saved to: {config_path}[/green]")
949
+ console.print(
950
+ "[cyan]Git events will now be logged to HtmlGraph automatically.[/cyan]"
951
+ )
952
+ console.print()
953
+ console.print("[bold cyan]Management commands:[/bold cyan]")
954
+ console.print(
955
+ "[dim] htmlgraph install-hooks --list # Show status[/dim]"
956
+ )
957
+ console.print(
958
+ "[dim] htmlgraph install-hooks --uninstall <hook> # Remove hook[/dim]"
959
+ )
960
+ console.print(
961
+ "[dim] htmlgraph install-hooks --enable <hook> # Enable hook[/dim]"
962
+ )
963
+ console.print(
964
+ "[dim] htmlgraph install-hooks --disable <hook> # Disable hook[/dim]"
965
+ )
813
966
 
814
967
 
815
968
  def cmd_status(args: argparse.Namespace) -> None:
@@ -819,8 +972,9 @@ def cmd_status(args: argparse.Namespace) -> None:
819
972
 
820
973
  from htmlgraph.sdk import SDK
821
974
 
822
- # Use SDK to query all collections
823
- sdk = SDK(directory=args.graph_dir)
975
+ # Use SDK to query all collections with status spinner
976
+ with console.status("[blue]Initializing SDK...", spinner="dots"):
977
+ sdk = SDK(directory=args.graph_dir)
824
978
 
825
979
  total = 0
826
980
  by_status: Counter[str] = Counter()
@@ -839,22 +993,34 @@ def cmd_status(args: argparse.Namespace) -> None:
839
993
  "agents",
840
994
  ]
841
995
 
842
- for coll_name in collections:
843
- coll = getattr(sdk, coll_name)
844
- try:
845
- nodes = coll.all()
846
- count = len(nodes)
847
- if count > 0:
848
- by_collection[coll_name] = count
849
- total += count
850
-
851
- # Count by status
852
- for node in nodes:
853
- status = getattr(node, "status", "unknown")
854
- by_status[status] += 1
855
- except Exception:
856
- # Collection might not exist yet
857
- pass
996
+ # Use progress bar for scanning collections
997
+ with Progress(
998
+ SpinnerColumn(),
999
+ TextColumn("[progress.description]{task.description}"),
1000
+ console=console,
1001
+ transient=True,
1002
+ ) as progress:
1003
+ task = progress.add_task("Scanning collections...", total=len(collections))
1004
+
1005
+ for coll_name in collections:
1006
+ progress.update(task, description=f"Scanning {coll_name}...")
1007
+ coll = getattr(sdk, coll_name)
1008
+ try:
1009
+ nodes = coll.all()
1010
+ count = len(nodes)
1011
+ if count > 0:
1012
+ by_collection[coll_name] = count
1013
+ total += count
1014
+
1015
+ # Count by status
1016
+ for node in nodes:
1017
+ status = getattr(node, "status", "unknown")
1018
+ by_status[status] += 1
1019
+ except Exception:
1020
+ # Collection might not exist yet
1021
+ pass
1022
+
1023
+ progress.update(task, advance=1)
858
1024
 
859
1025
  # Output based on format flag
860
1026
  if args.format == "json":
@@ -995,17 +1161,21 @@ def cmd_query(args: argparse.Namespace) -> None:
995
1161
 
996
1162
  graph_dir = Path(args.graph_dir)
997
1163
  if not graph_dir.exists():
998
- print(f"Error: {graph_dir} not found.", file=sys.stderr)
1164
+ console.print(f"[red]Error: {graph_dir} not found.[/red]")
999
1165
  sys.exit(1)
1000
1166
 
1001
- results = []
1002
- for collection_dir in graph_dir.iterdir():
1003
- if collection_dir.is_dir() and not collection_dir.name.startswith("."):
1004
- graph = HtmlGraph(collection_dir, auto_load=True)
1005
- for node in graph.query(args.selector):
1006
- data = node_to_dict(node)
1007
- data["_collection"] = collection_dir.name
1008
- results.append(data)
1167
+ # Query with status spinner
1168
+ with console.status(
1169
+ f"[blue]Querying with selector '{args.selector}'...", spinner="dots"
1170
+ ):
1171
+ results = []
1172
+ for collection_dir in graph_dir.iterdir():
1173
+ if collection_dir.is_dir() and not collection_dir.name.startswith("."):
1174
+ graph = HtmlGraph(collection_dir, auto_load=True)
1175
+ for node in graph.query(args.selector):
1176
+ data = node_to_dict(node)
1177
+ data["_collection"] = collection_dir.name
1178
+ results.append(data)
1009
1179
 
1010
1180
  if args.format == "json":
1011
1181
  print(json.dumps(results, indent=2, default=str))
@@ -1027,10 +1197,31 @@ def cmd_session_start(args: argparse.Namespace) -> None:
1027
1197
  """Start a new session."""
1028
1198
  import json
1029
1199
 
1200
+ from pydantic import ValidationError
1201
+
1202
+ from htmlgraph.pydantic_models import SessionStartInput
1030
1203
  from htmlgraph.sdk import SDK
1204
+ from htmlgraph.validation import display_validation_error
1031
1205
 
1032
- sdk = SDK(directory=args.graph_dir, agent=args.agent)
1033
- session = sdk.start_session(session_id=args.id, title=args.title, agent=args.agent)
1206
+ try:
1207
+ input_data = SessionStartInput(
1208
+ session_id=args.id,
1209
+ title=args.title,
1210
+ agent=args.agent,
1211
+ )
1212
+ except ValidationError as e:
1213
+ display_validation_error(e)
1214
+ sys.exit(1)
1215
+
1216
+ with console.status(
1217
+ "[blue]Initializing SDK and starting session...", spinner="dots"
1218
+ ):
1219
+ sdk = SDK(directory=args.graph_dir, agent=input_data.agent)
1220
+ session = sdk.start_session(
1221
+ session_id=input_data.session_id,
1222
+ title=input_data.title,
1223
+ agent=input_data.agent,
1224
+ )
1034
1225
 
1035
1226
  if args.format == "json":
1036
1227
  from htmlgraph.converter import session_to_dict
@@ -1048,19 +1239,33 @@ def cmd_session_end(args: argparse.Namespace) -> None:
1048
1239
  """End a session."""
1049
1240
  import json
1050
1241
 
1242
+ from pydantic import ValidationError
1243
+
1244
+ from htmlgraph.pydantic_models import SessionEndInput
1051
1245
  from htmlgraph.sdk import SDK
1246
+ from htmlgraph.validation import display_validation_error
1247
+
1248
+ try:
1249
+ input_data = SessionEndInput(
1250
+ session_id=args.id,
1251
+ notes=args.notes,
1252
+ recommend=args.recommend,
1253
+ blocker=args.blocker if args.blocker else None,
1254
+ )
1255
+ except ValidationError as e:
1256
+ display_validation_error(e)
1257
+ sys.exit(1)
1052
1258
 
1053
1259
  sdk = SDK(directory=args.graph_dir)
1054
- blockers = args.blocker if args.blocker else None
1055
1260
  session = sdk.end_session(
1056
- args.id,
1057
- handoff_notes=args.notes,
1058
- recommended_next=args.recommend,
1059
- blockers=blockers,
1261
+ input_data.session_id,
1262
+ handoff_notes=input_data.notes,
1263
+ recommended_next=input_data.recommend,
1264
+ blockers=input_data.blocker,
1060
1265
  )
1061
1266
 
1062
1267
  if session is None:
1063
- print(f"Error: Session '{args.id}' not found.", file=sys.stderr)
1268
+ console.print(f"[red]Error: Session '{input_data.session_id}' not found.[/red]")
1064
1269
  sys.exit(1)
1065
1270
 
1066
1271
  if args.format == "json":
@@ -1079,18 +1284,30 @@ def cmd_session_handoff(args: argparse.Namespace) -> None:
1079
1284
  """Set or show session handoff context."""
1080
1285
  import json
1081
1286
 
1287
+ from pydantic import ValidationError
1288
+
1289
+ from htmlgraph.pydantic_models import SessionHandoffInput
1082
1290
  from htmlgraph.sdk import SDK
1291
+ from htmlgraph.validation import display_validation_error
1083
1292
 
1084
- sdk = SDK(directory=args.graph_dir, agent=args.agent)
1293
+ try:
1294
+ input_data = SessionHandoffInput(
1295
+ session_id=args.session_id,
1296
+ notes=args.notes,
1297
+ recommend=args.recommend,
1298
+ blocker=args.blocker if args.blocker else None,
1299
+ show=args.show,
1300
+ )
1301
+ except ValidationError as e:
1302
+ display_validation_error(e)
1303
+ sys.exit(1)
1085
1304
 
1086
- if args.show:
1087
- # For showing, we might still need direct manager access or add more methods to SDK
1088
- # But for now, let's keep using SessionManager logic via SDK property if needed
1089
- # or implement show logic here using SDK collections
1305
+ sdk = SDK(directory=args.graph_dir, agent=args.agent)
1090
1306
 
1307
+ if input_data.show:
1091
1308
  # Use session_manager.get_session() to get Session objects (not Node)
1092
- if args.session_id:
1093
- session = sdk.session_manager.get_session(args.session_id)
1309
+ if input_data.session_id:
1310
+ session = sdk.session_manager.get_session(input_data.session_id)
1094
1311
  else:
1095
1312
  # Need "last ended session" - SDK doesn't expose this yet.
1096
1313
  # Fallback to session_manager logic exposed on SDK
@@ -1118,7 +1335,7 @@ def cmd_session_handoff(args: argparse.Namespace) -> None:
1118
1335
  return
1119
1336
 
1120
1337
  # Setting handoff
1121
- if not (args.notes or args.recommend or args.blocker):
1338
+ if not (input_data.notes or input_data.recommend or input_data.blocker):
1122
1339
  print(
1123
1340
  "Error: Provide --notes, --recommend, or --blocker (or use --show).",
1124
1341
  file=sys.stderr,
@@ -1126,15 +1343,17 @@ def cmd_session_handoff(args: argparse.Namespace) -> None:
1126
1343
  sys.exit(1)
1127
1344
 
1128
1345
  handoff_result = sdk.set_session_handoff(
1129
- session_id=args.session_id, # Optional, defaults to active
1130
- handoff_notes=args.notes,
1131
- recommended_next=args.recommend,
1132
- blockers=args.blocker if args.blocker else None,
1346
+ session_id=input_data.session_id, # Optional, defaults to active
1347
+ handoff_notes=input_data.notes,
1348
+ recommended_next=input_data.recommend,
1349
+ blockers=input_data.blocker,
1133
1350
  )
1134
1351
 
1135
1352
  if handoff_result is None:
1136
- if args.session_id:
1137
- print(f"Error: Session '{args.session_id}' not found.", file=sys.stderr)
1353
+ if input_data.session_id:
1354
+ print(
1355
+ f"Error: Session '{input_data.session_id}' not found.", file=sys.stderr
1356
+ )
1138
1357
  else:
1139
1358
  print(
1140
1359
  "Error: No active session found. Provide --session-id.",
@@ -1160,17 +1379,20 @@ def cmd_session_list(args: argparse.Namespace) -> None:
1160
1379
  return
1161
1380
 
1162
1381
  converter = SessionConverter(sessions_dir)
1163
- sessions = converter.load_all()
1164
1382
 
1165
- # Sort by started_at descending (handle mixed tz-aware/naive datetimes)
1166
- def sort_key(s: Any) -> Any:
1167
- ts = s.started_at
1168
- # Make naive datetimes comparable by assuming UTC
1169
- if ts.tzinfo is None:
1170
- return ts.replace(tzinfo=None)
1171
- return ts.replace(tzinfo=None) # Compare as naive for sorting
1383
+ # Load sessions with status spinner
1384
+ with console.status("[blue]Loading sessions...", spinner="dots"):
1385
+ sessions = converter.load_all()
1386
+
1387
+ # Sort by started_at descending (handle mixed tz-aware/naive datetimes)
1388
+ def sort_key(s: Any) -> Any:
1389
+ ts = s.started_at
1390
+ # Make naive datetimes comparable by assuming UTC
1391
+ if ts.tzinfo is None:
1392
+ return ts.replace(tzinfo=None)
1393
+ return ts.replace(tzinfo=None) # Compare as naive for sorting
1172
1394
 
1173
- sessions.sort(key=sort_key, reverse=True)
1395
+ sessions.sort(key=sort_key, reverse=True)
1174
1396
 
1175
1397
  if args.format == "json":
1176
1398
  from htmlgraph.converter import session_to_dict
@@ -1178,17 +1400,34 @@ def cmd_session_list(args: argparse.Namespace) -> None:
1178
1400
  print(json.dumps([session_to_dict(s) for s in sessions], indent=2))
1179
1401
  else:
1180
1402
  if not sessions:
1181
- print("No sessions found.")
1403
+ console.print("[yellow]No sessions found.[/yellow]")
1182
1404
  return
1183
1405
 
1184
- print(f"{'ID':<30} {'Status':<10} {'Agent':<15} {'Events':<8} {'Started'}")
1185
- print("=" * 90)
1406
+ # Create Rich table
1407
+ table = Table(
1408
+ title="Sessions",
1409
+ show_header=True,
1410
+ header_style="bold magenta",
1411
+ box=box.ROUNDED,
1412
+ )
1413
+ table.add_column("ID", style="cyan", no_wrap=False, max_width=30)
1414
+ table.add_column("Status", style="green", width=10)
1415
+ table.add_column("Agent", style="blue", width=15)
1416
+ table.add_column("Events", justify="right", style="yellow", width=8)
1417
+ table.add_column("Started", style="white")
1418
+
1186
1419
  for session in sessions:
1187
1420
  started = session.started_at.strftime("%Y-%m-%d %H:%M")
1188
- print(
1189
- f"{session.id:<30} {session.status:<10} {session.agent:<15} {session.event_count:<8} {started}"
1421
+ table.add_row(
1422
+ session.id,
1423
+ session.status,
1424
+ session.agent,
1425
+ str(session.event_count),
1426
+ started,
1190
1427
  )
1191
1428
 
1429
+ console.print(table)
1430
+
1192
1431
 
1193
1432
  def cmd_session_start_info(args: argparse.Namespace) -> None:
1194
1433
  """Get comprehensive session start information (optimized for AI agents)."""
@@ -1210,16 +1449,18 @@ def cmd_session_start_info(args: argparse.Namespace) -> None:
1210
1449
  else:
1211
1450
  # Human-readable format
1212
1451
  status: dict = info["status"] # type: ignore
1213
- print("=" * 80)
1214
- print("SESSION START INFO")
1215
- print("=" * 80)
1216
-
1217
- # Project status
1218
- print(f"\nProject: {status.get('project_name', 'HtmlGraph')}")
1219
- print(f"Total features: {status.get('total_features', 0)}")
1220
- print(f"In progress: {status.get('wip_count', 0)}")
1452
+
1453
+ # Project status panel
1221
1454
  by_status = status.get("by_status", {})
1222
- print(f"Completed: {by_status.get('done', 0)}")
1455
+ project_info = (
1456
+ f"Project: {status.get('project_name', 'HtmlGraph')}\n"
1457
+ f"Total features: {status.get('total_features', 0)}\n"
1458
+ f"In progress: {status.get('wip_count', 0)}\n"
1459
+ f"Completed: {by_status.get('done', 0)}"
1460
+ )
1461
+ console.print(
1462
+ Panel(project_info, title="SESSION START INFO", border_style="cyan")
1463
+ )
1223
1464
 
1224
1465
  # Active work item (validation status)
1225
1466
  active_work = info.get("active_work")
@@ -1382,13 +1623,17 @@ def cmd_session_dedupe(args: argparse.Namespace) -> None:
1382
1623
  """Move low-signal session files out of the main sessions directory."""
1383
1624
  from htmlgraph import SDK
1384
1625
 
1385
- sdk = SDK(directory=args.graph_dir)
1386
- result = sdk.dedupe_sessions(
1387
- max_events=args.max_events,
1388
- move_dir_name=args.move_dir,
1389
- dry_run=args.dry_run,
1390
- stale_extra_active=not args.no_stale_active,
1391
- )
1626
+ with console.status("[blue]Initializing SDK...", spinner="dots"):
1627
+ sdk = SDK(directory=args.graph_dir)
1628
+
1629
+ operation = "Analyzing" if args.dry_run else "Deduplicating"
1630
+ with console.status(f"[blue]{operation} sessions...", spinner="dots"):
1631
+ result = sdk.dedupe_sessions(
1632
+ max_events=args.max_events,
1633
+ move_dir_name=args.move_dir,
1634
+ dry_run=args.dry_run,
1635
+ stale_extra_active=not args.no_stale_active,
1636
+ )
1392
1637
 
1393
1638
  print(f"Scanned: {result['scanned']}")
1394
1639
  print(f"Moved: {result['moved']}")
@@ -1424,15 +1669,14 @@ def cmd_session_link(args: argparse.Namespace) -> None:
1424
1669
  session_graph = HtmlGraph(sessions_dir)
1425
1670
  session = session_graph.get(args.session_id)
1426
1671
  if not session:
1427
- print(f"Error: Failed to load session '{args.session_id}'", file=sys.stderr)
1672
+ console.print(f"[red]Error: Failed to load session '{args.session_id}'[/red]")
1428
1673
  sys.exit(1)
1429
1674
 
1430
1675
  # Load feature
1431
1676
  feature_file = feature_dir / f"{args.feature_id}.html"
1432
1677
  if not feature_file.exists():
1433
- print(
1434
- f"Error: Feature '{args.feature_id}' not found at {feature_file}",
1435
- file=sys.stderr,
1678
+ console.print(
1679
+ f"[red]Error: Feature '{args.feature_id}' not found at {feature_file}[/red]"
1436
1680
  )
1437
1681
  sys.exit(1)
1438
1682
 
@@ -1620,6 +1864,70 @@ def cmd_session_validate_attribution(args: argparse.Namespace) -> None:
1620
1864
  )
1621
1865
 
1622
1866
 
1867
+ def cmd_session_debug(args: argparse.Namespace) -> None:
1868
+ """Show full error traceback and debugging information for a session."""
1869
+ from htmlgraph.session_manager import SessionManager
1870
+
1871
+ manager = SessionManager(args.graph_dir)
1872
+
1873
+ try:
1874
+ session = manager.get_session(args.session_id)
1875
+ except Exception:
1876
+ session = None
1877
+
1878
+ if not session:
1879
+ console.print(f"[red]āœ— Session not found:[/red] {args.session_id}")
1880
+ sys.exit(1)
1881
+
1882
+ # Display session header
1883
+ console.print("\n[bold cyan]Session Debug Information[/bold cyan]")
1884
+ console.print(f"[dim]Session ID:[/dim] {session.id}")
1885
+ console.print(f"[dim]Agent:[/dim] {session.agent}")
1886
+ console.print(f"[dim]Status:[/dim] {session.status}")
1887
+ console.print(
1888
+ f"[dim]Started:[/dim] {session.started_at.strftime('%Y-%m-%d %H:%M:%S')}"
1889
+ )
1890
+
1891
+ # Check for errors
1892
+ errors = session.error_log
1893
+ if not errors:
1894
+ console.print("\n[green]āœ“ No errors in session[/green]")
1895
+ return
1896
+
1897
+ console.print(f"\n[bold yellow]Errors ({len(errors)})[/bold yellow]")
1898
+
1899
+ for i, error in enumerate(errors, 1):
1900
+ # Error header
1901
+ console.print(
1902
+ f"\n[bold]Error {i}[/bold] [{error.timestamp.strftime('%H:%M:%S')}]"
1903
+ )
1904
+ console.print(f"[red]{error.error_type}[/red]: {error.message}")
1905
+
1906
+ # Tool information
1907
+ if error.tool:
1908
+ console.print(f"[dim]Tool:[/dim] {error.tool}")
1909
+
1910
+ # Context information
1911
+ if error.context:
1912
+ console.print(f"[dim]Context:[/dim] {error.context}")
1913
+
1914
+ # Full traceback
1915
+ if error.traceback:
1916
+ console.print("\n[dim]Traceback:[/dim]")
1917
+ console.print(f"[dim]{error.traceback}[/dim]")
1918
+
1919
+ console.print("[dim]─" * 80 + "[/dim]")
1920
+
1921
+ # Summary
1922
+ console.print("\n[bold cyan]Summary[/bold cyan]")
1923
+ console.print(f"[dim]Total errors:[/dim] {len(errors)}")
1924
+ error_types: dict[str, int] = {}
1925
+ for error in errors:
1926
+ error_types[error.error_type] = error_types.get(error.error_type, 0) + 1
1927
+ for error_type, count in sorted(error_types.items()):
1928
+ console.print(f"[dim] - {error_type}:[/dim] {count}")
1929
+
1930
+
1623
1931
  # =========================================================================
1624
1932
  # Transcript Commands
1625
1933
  # =========================================================================
@@ -1974,27 +2282,33 @@ def cmd_transcript_patterns(args: argparse.Namespace) -> None:
1974
2282
  )
1975
2283
  )
1976
2284
  else:
1977
- print("Workflow Patterns Detected")
1978
- print("=" * 50)
1979
-
1980
2285
  optimal = [p for p in patterns if p.category == "optimal"]
1981
2286
  anti = [p for p in patterns if p.category == "anti-pattern"]
1982
2287
  neutral = [p for p in patterns if p.category == "neutral"][:10]
1983
2288
 
2289
+ content = ""
1984
2290
  if optimal:
1985
- print("\nāœ… Optimal Patterns:")
2291
+ content += "āœ… Optimal Patterns:\n"
1986
2292
  for p in optimal:
1987
- print(f" {' → '.join(p.sequence)} ({p.count}x)")
2293
+ content += f" {' → '.join(p.sequence)} ({p.count}x)\n"
1988
2294
 
1989
2295
  if anti:
1990
- print("\nāš ļø Anti-Patterns:")
2296
+ content += "\nāš ļø Anti-Patterns:\n"
1991
2297
  for p in anti:
1992
- print(f" {' → '.join(p.sequence)} ({p.count}x)")
2298
+ content += f" {' → '.join(p.sequence)} ({p.count}x)\n"
1993
2299
 
1994
2300
  if neutral:
1995
- print("\nšŸ“Š Common Patterns:")
2301
+ content += "\nšŸ“Š Common Patterns:\n"
1996
2302
  for p in neutral:
1997
- print(f" {' → '.join(p.sequence)} ({p.count}x)")
2303
+ content += f" {' → '.join(p.sequence)} ({p.count}x)\n"
2304
+
2305
+ console.print(
2306
+ Panel(
2307
+ content.strip(),
2308
+ title="Workflow Patterns Detected",
2309
+ border_style="green",
2310
+ )
2311
+ )
1998
2312
 
1999
2313
 
2000
2314
  def cmd_transcript_transitions(args: argparse.Namespace) -> None:
@@ -2009,11 +2323,6 @@ def cmd_transcript_transitions(args: argparse.Namespace) -> None:
2009
2323
  if args.format == "json":
2010
2324
  print(json.dumps(transitions, indent=2))
2011
2325
  else:
2012
- print("Tool Transition Matrix")
2013
- print("=" * 50)
2014
- print("(from_tool → to_tool: count)")
2015
- print()
2016
-
2017
2326
  # Flatten and sort
2018
2327
  flat = []
2019
2328
  for from_tool, tos in transitions.items():
@@ -2022,9 +2331,23 @@ def cmd_transcript_transitions(args: argparse.Namespace) -> None:
2022
2331
 
2023
2332
  flat.sort(key=lambda x: -x[2])
2024
2333
 
2334
+ # Create Rich table
2335
+ table = Table(
2336
+ title="Tool Transition Matrix",
2337
+ show_header=True,
2338
+ header_style="bold magenta",
2339
+ box=box.ROUNDED,
2340
+ )
2341
+ table.add_column("From Tool", style="cyan", width=12)
2342
+ table.add_column("To Tool", style="blue", width=12)
2343
+ table.add_column("Count", justify="right", style="yellow", width=6)
2344
+ table.add_column("Visual", style="green")
2345
+
2025
2346
  for from_t, to_t, count in flat[:20]:
2026
2347
  bar = "ā–ˆ" * min(count, 20)
2027
- print(f" {from_t:12} → {to_t:12} {count:4} {bar}")
2348
+ table.add_row(from_t, to_t, str(count), bar)
2349
+
2350
+ console.print(table)
2028
2351
 
2029
2352
 
2030
2353
  def cmd_transcript_recommendations(args: argparse.Namespace) -> None:
@@ -2041,10 +2364,10 @@ def cmd_transcript_recommendations(args: argparse.Namespace) -> None:
2041
2364
  if args.format == "json":
2042
2365
  print(json.dumps({"recommendations": recommendations}, indent=2))
2043
2366
  else:
2044
- print("Workflow Recommendations")
2045
- print("=" * 50)
2046
- for rec in recommendations:
2047
- print(f" {rec}")
2367
+ content = "\n".join([f" • {rec}" for rec in recommendations])
2368
+ console.print(
2369
+ Panel(content, title="Workflow Recommendations", border_style="yellow")
2370
+ )
2048
2371
 
2049
2372
 
2050
2373
  def cmd_transcript_insights(args: argparse.Namespace) -> None:
@@ -2071,24 +2394,43 @@ def cmd_transcript_insights(args: argparse.Namespace) -> None:
2071
2394
  )
2072
2395
  )
2073
2396
  else:
2074
- print("šŸ“Š Transcript Insights")
2075
- print("=" * 50)
2076
- print(f"Sessions Analyzed: {insights.total_sessions}")
2077
- print(f"Total User Messages: {insights.total_user_messages}")
2078
- print(f"Total Tool Calls: {insights.total_tool_calls}")
2079
- print(f"Avg Session Health: {insights.avg_session_health:.0%}")
2080
- print()
2397
+ # Summary panel
2398
+ summary = (
2399
+ f"Sessions Analyzed: {insights.total_sessions}\n"
2400
+ f"Total User Messages: {insights.total_user_messages}\n"
2401
+ f"Total Tool Calls: {insights.total_tool_calls}\n"
2402
+ f"Avg Session Health: {insights.avg_session_health:.0%}"
2403
+ )
2404
+ console.print(
2405
+ Panel(summary, title="šŸ“Š Transcript Insights", border_style="cyan")
2406
+ )
2081
2407
 
2408
+ # Top tools table
2082
2409
  if insights.tool_frequency:
2083
- print("šŸ”§ Top Tools:")
2410
+ table = Table(
2411
+ title="šŸ”§ Top Tools",
2412
+ show_header=True,
2413
+ header_style="bold magenta",
2414
+ box=box.ROUNDED,
2415
+ )
2416
+ table.add_column("Tool", style="cyan", width=15)
2417
+ table.add_column("Count", justify="right", style="yellow", width=6)
2418
+ table.add_column("Visual", style="green")
2419
+
2084
2420
  for tool, count in list(insights.tool_frequency.items())[:8]:
2085
2421
  bar = "ā–ˆ" * min(count // 5, 15)
2086
- print(f" {tool:15} {count:4} {bar}")
2422
+ table.add_row(tool, str(count), bar)
2087
2423
 
2088
- print()
2089
- print("šŸ’” Recommendations:")
2090
- for rec in insights.recommendations[:5]:
2091
- print(f" {rec}")
2424
+ console.print(table)
2425
+
2426
+ # Recommendations panel
2427
+ if insights.recommendations:
2428
+ rec_content = "\n".join(
2429
+ [f" {rec}" for rec in insights.recommendations[:5]]
2430
+ )
2431
+ console.print(
2432
+ Panel(rec_content, title="šŸ’” Recommendations", border_style="yellow")
2433
+ )
2092
2434
 
2093
2435
 
2094
2436
  def cmd_transcript_export(args: argparse.Namespace) -> None:
@@ -2210,18 +2552,34 @@ def cmd_track(args: argparse.Namespace) -> None:
2210
2552
  """Track an activity in the current session."""
2211
2553
  import json
2212
2554
 
2555
+ from pydantic import ValidationError
2556
+
2213
2557
  from htmlgraph import SDK
2558
+ from htmlgraph.pydantic_models import ActivityTrackInput
2559
+ from htmlgraph.validation import display_validation_error
2560
+
2561
+ try:
2562
+ input_data = ActivityTrackInput(
2563
+ tool=args.tool,
2564
+ summary=args.summary,
2565
+ files=args.files,
2566
+ session=args.session,
2567
+ failed=args.failed,
2568
+ )
2569
+ except ValidationError as e:
2570
+ display_validation_error(e)
2571
+ sys.exit(1)
2214
2572
 
2215
2573
  agent = os.environ.get("HTMLGRAPH_AGENT")
2216
2574
  sdk = SDK(directory=args.graph_dir, agent=agent)
2217
2575
 
2218
2576
  try:
2219
2577
  entry = sdk.track_activity(
2220
- tool=args.tool,
2221
- summary=args.summary,
2222
- file_paths=args.files,
2223
- success=not args.failed,
2224
- session_id=args.session, # None if not specified, SDK will find active session
2578
+ tool=input_data.tool,
2579
+ summary=input_data.summary,
2580
+ file_paths=input_data.files,
2581
+ success=not input_data.failed,
2582
+ session_id=input_data.session, # None if not specified, SDK will find active session
2225
2583
  )
2226
2584
  except ValueError as e:
2227
2585
  print(f"Error: {e}", file=sys.stderr)
@@ -2664,160 +3022,121 @@ def cmd_agent_list(args: argparse.Namespace) -> None:
2664
3022
 
2665
3023
  def cmd_feature_create(args: argparse.Namespace) -> None:
2666
3024
  """Create a new feature."""
2667
- import json
2668
-
2669
- from htmlgraph.sdk import SDK
3025
+ from pydantic import ValidationError
2670
3026
 
2671
- # Use SDK for feature creation (which now handles logging)
2672
- sdk = SDK(directory=args.graph_dir, agent=args.agent)
3027
+ from htmlgraph.cli_commands.feature import FeatureCreateCommand
3028
+ from htmlgraph.pydantic_models import FeatureCreateInput
3029
+ from htmlgraph.validation import display_validation_error
2673
3030
 
2674
3031
  try:
2675
- # Determine collection (features -> create builder, others -> manual create?)
2676
- # For now, only 'features' has a builder in SDK.features.create()
2677
- # But BaseCollection doesn't have create().
2678
-
2679
- # If collection is 'features', use builder
2680
- if args.collection == "features":
2681
- builder = sdk.features.create(
2682
- title=args.title,
2683
- description=args.description or "",
2684
- priority=args.priority,
2685
- )
2686
- if args.steps:
2687
- builder.add_steps(args.steps)
2688
- node = builder.save()
2689
- else:
2690
- # Fallback to SessionManager directly for non-feature collections
2691
- # (or extend SDK to support create on all collections)
2692
- # For consistency with old CLI, we use SessionManager here if not features.
2693
- # But wait, SDK initializes SessionManager.
2694
-
2695
- # Creating bugs/chores via SDK isn't fully fluent yet.
2696
- # Let's use the low-level SessionManager.create_feature logic for now via SDK's session_manager
2697
- # IF we want to strictly use SDK. But SDK.session_manager IS exposed now.
2698
- node = sdk.session_manager.create_feature(
2699
- title=args.title,
2700
- collection=args.collection,
2701
- description=args.description or "",
2702
- priority=args.priority,
2703
- steps=args.steps,
2704
- agent=args.agent,
2705
- )
2706
-
2707
- except ValueError as e:
2708
- print(f"Error: {e}", file=sys.stderr)
3032
+ input_data = FeatureCreateInput(
3033
+ title=args.title,
3034
+ description=args.description,
3035
+ priority=args.priority,
3036
+ steps=args.steps,
3037
+ collection=args.collection,
3038
+ )
3039
+ except ValidationError as e:
3040
+ display_validation_error(e)
2709
3041
  sys.exit(1)
2710
3042
 
2711
- if args.format == "json":
2712
- from htmlgraph.converter import node_to_dict
3043
+ # Convert steps count to list of step names (e.g., 3 -> ["Step 1", "Step 2", "Step 3"])
3044
+ step_names = None
3045
+ if input_data.steps:
3046
+ step_names = [f"Step {i + 1}" for i in range(input_data.steps)]
2713
3047
 
2714
- print(json.dumps(node_to_dict(node), indent=2))
2715
- else:
2716
- print(f"Created: {node.id}")
2717
- print(f" Title: {node.title}")
2718
- print(f" Status: {node.status}")
2719
- print(f" Path: {args.graph_dir}/{args.collection}/{node.id}.html")
3048
+ command = FeatureCreateCommand(
3049
+ collection=input_data.collection,
3050
+ title=input_data.title,
3051
+ description=input_data.description or "",
3052
+ priority=input_data.priority,
3053
+ steps=step_names,
3054
+ track_id=args.track,
3055
+ )
3056
+ command.run(graph_dir=args.graph_dir, agent=args.agent, output_format=args.format)
2720
3057
 
2721
3058
 
2722
3059
  def cmd_feature_start(args: argparse.Namespace) -> None:
2723
3060
  """Start working on a feature."""
2724
- import json
3061
+ from pydantic import ValidationError
2725
3062
 
2726
- from htmlgraph.sdk import SDK
2727
-
2728
- sdk = SDK(directory=args.graph_dir, agent=args.agent)
2729
- collection = getattr(sdk, args.collection, None)
2730
-
2731
- if not collection:
2732
- print(
2733
- f"Error: Collection '{args.collection}' not found in SDK.", file=sys.stderr
2734
- )
2735
- sys.exit(1)
3063
+ from htmlgraph.cli_commands.feature import FeatureStartCommand
3064
+ from htmlgraph.pydantic_models import FeatureStartInput
3065
+ from htmlgraph.validation import display_validation_error
2736
3066
 
2737
3067
  try:
2738
- node = collection.start(args.id)
2739
- except ValueError as e:
2740
- print(f"Error: {e}", file=sys.stderr)
2741
- sys.exit(1)
2742
-
2743
- if node is None:
2744
- print(
2745
- f"Error: Feature '{args.id}' not found in {args.collection}.",
2746
- file=sys.stderr,
3068
+ input_data = FeatureStartInput(
3069
+ feature_id=args.id,
3070
+ collection=args.collection,
2747
3071
  )
3072
+ except ValidationError as e:
3073
+ display_validation_error(e)
2748
3074
  sys.exit(1)
2749
3075
 
2750
- if args.format == "json":
2751
- from htmlgraph.converter import node_to_dict
2752
-
2753
- print(json.dumps(node_to_dict(node), indent=2))
2754
- else:
2755
- print(f"Started: {node.id}")
2756
- print(f" Title: {node.title}")
2757
- print(f" Status: {node.status}")
2758
-
2759
- # Show WIP status
2760
- status = sdk.session_manager.get_status()
2761
- print(f" WIP: {status['wip_count']}/{status['wip_limit']}")
3076
+ command = FeatureStartCommand(
3077
+ collection=input_data.collection,
3078
+ feature_id=input_data.feature_id,
3079
+ )
3080
+ command.run(graph_dir=args.graph_dir, agent=args.agent, output_format=args.format)
2762
3081
 
2763
3082
 
2764
3083
  def cmd_feature_complete(args: argparse.Namespace) -> None:
2765
3084
  """Mark a feature as complete."""
2766
- import json
3085
+ from pydantic import ValidationError
2767
3086
 
2768
- from htmlgraph.sdk import SDK
2769
-
2770
- sdk = SDK(directory=args.graph_dir, agent=args.agent)
2771
- collection = getattr(sdk, args.collection, None)
2772
-
2773
- if not collection:
2774
- print(
2775
- f"Error: Collection '{args.collection}' not found in SDK.", file=sys.stderr
2776
- )
2777
- sys.exit(1)
3087
+ from htmlgraph.cli_commands.feature import FeatureCompleteCommand
3088
+ from htmlgraph.pydantic_models import FeatureCompleteInput
3089
+ from htmlgraph.validation import display_validation_error
2778
3090
 
2779
3091
  try:
2780
- node = collection.complete(args.id)
2781
- except ValueError as e:
2782
- print(f"Error: {e}", file=sys.stderr)
2783
- sys.exit(1)
2784
-
2785
- if node is None:
2786
- print(
2787
- f"Error: Feature '{args.id}' not found in {args.collection}.",
2788
- file=sys.stderr,
3092
+ input_data = FeatureCompleteInput(
3093
+ feature_id=args.id,
3094
+ collection=args.collection,
2789
3095
  )
3096
+ except ValidationError as e:
3097
+ display_validation_error(e)
2790
3098
  sys.exit(1)
2791
3099
 
2792
- if args.format == "json":
2793
- from htmlgraph.converter import node_to_dict
2794
-
2795
- print(json.dumps(node_to_dict(node), indent=2))
2796
- else:
2797
- print(f"Completed: {node.id}")
2798
- print(f" Title: {node.title}")
3100
+ command = FeatureCompleteCommand(
3101
+ collection=input_data.collection,
3102
+ feature_id=input_data.feature_id,
3103
+ )
3104
+ command.run(graph_dir=args.graph_dir, agent=args.agent, output_format=args.format)
2799
3105
 
2800
3106
 
2801
3107
  def cmd_feature_primary(args: argparse.Namespace) -> None:
2802
3108
  """Set the primary feature for attribution."""
2803
3109
  import json
2804
3110
 
3111
+ from pydantic import ValidationError
3112
+
3113
+ from htmlgraph.pydantic_models import FeaturePrimaryInput
2805
3114
  from htmlgraph.sdk import SDK
3115
+ from htmlgraph.validation import display_validation_error
3116
+
3117
+ try:
3118
+ input_data = FeaturePrimaryInput(
3119
+ feature_id=args.id,
3120
+ collection=args.collection,
3121
+ )
3122
+ except ValidationError as e:
3123
+ display_validation_error(e)
3124
+ sys.exit(1)
2806
3125
 
2807
3126
  sdk = SDK(directory=args.graph_dir, agent=args.agent)
2808
3127
 
2809
3128
  # Only FeatureCollection has set_primary currently
2810
- if args.collection == "features":
2811
- node = sdk.features.set_primary(args.id)
3129
+ if input_data.collection == "features":
3130
+ node = sdk.features.set_primary(input_data.feature_id)
2812
3131
  else:
2813
3132
  # Fallback to direct session manager for other collections
2814
3133
  node = sdk.session_manager.set_primary_feature(
2815
- args.id, collection=args.collection, agent=args.agent
3134
+ input_data.feature_id, collection=input_data.collection, agent=args.agent
2816
3135
  )
2817
3136
 
2818
3137
  if node is None:
2819
3138
  print(
2820
- f"Error: Feature '{args.id}' not found in {args.collection}.",
3139
+ f"Error: Feature '{input_data.feature_id}' not found in {input_data.collection}.",
2821
3140
  file=sys.stderr,
2822
3141
  )
2823
3142
  sys.exit(1)
@@ -2835,26 +3154,40 @@ def cmd_feature_claim(args: argparse.Namespace) -> None:
2835
3154
  """Claim a feature."""
2836
3155
  import json
2837
3156
 
3157
+ from pydantic import ValidationError
3158
+
3159
+ from htmlgraph.pydantic_models import FeatureClaimInput
2838
3160
  from htmlgraph.sdk import SDK
3161
+ from htmlgraph.validation import display_validation_error
3162
+
3163
+ try:
3164
+ input_data = FeatureClaimInput(
3165
+ feature_id=args.id,
3166
+ collection=args.collection,
3167
+ )
3168
+ except ValidationError as e:
3169
+ display_validation_error(e)
3170
+ sys.exit(1)
2839
3171
 
2840
3172
  sdk = SDK(directory=args.graph_dir, agent=args.agent)
2841
- collection = getattr(sdk, args.collection, None)
3173
+ collection = getattr(sdk, input_data.collection, None)
2842
3174
 
2843
3175
  if not collection:
2844
3176
  print(
2845
- f"Error: Collection '{args.collection}' not found in SDK.", file=sys.stderr
3177
+ f"Error: Collection '{input_data.collection}' not found in SDK.",
3178
+ file=sys.stderr,
2846
3179
  )
2847
3180
  sys.exit(1)
2848
3181
 
2849
3182
  try:
2850
- node = collection.claim(args.id)
3183
+ node = collection.claim(input_data.feature_id)
2851
3184
  except ValueError as e:
2852
3185
  print(f"Error: {e}", file=sys.stderr)
2853
3186
  sys.exit(1)
2854
3187
 
2855
3188
  if node is None:
2856
3189
  print(
2857
- f"Error: Feature '{args.id}' not found in {args.collection}.",
3190
+ f"Error: Feature '{input_data.feature_id}' not found in {input_data.collection}.",
2858
3191
  file=sys.stderr,
2859
3192
  )
2860
3193
  sys.exit(1)
@@ -2873,26 +3206,40 @@ def cmd_feature_release(args: argparse.Namespace) -> None:
2873
3206
  """Release a feature."""
2874
3207
  import json
2875
3208
 
3209
+ from pydantic import ValidationError
3210
+
3211
+ from htmlgraph.pydantic_models import FeatureReleaseInput
2876
3212
  from htmlgraph.sdk import SDK
3213
+ from htmlgraph.validation import display_validation_error
3214
+
3215
+ try:
3216
+ input_data = FeatureReleaseInput(
3217
+ feature_id=args.id,
3218
+ collection=args.collection,
3219
+ )
3220
+ except ValidationError as e:
3221
+ display_validation_error(e)
3222
+ sys.exit(1)
2877
3223
 
2878
3224
  sdk = SDK(directory=args.graph_dir, agent=args.agent)
2879
- collection = getattr(sdk, args.collection, None)
3225
+ collection = getattr(sdk, input_data.collection, None)
2880
3226
 
2881
3227
  if not collection:
2882
3228
  print(
2883
- f"Error: Collection '{args.collection}' not found in SDK.", file=sys.stderr
3229
+ f"Error: Collection '{input_data.collection}' not found in SDK.",
3230
+ file=sys.stderr,
2884
3231
  )
2885
3232
  sys.exit(1)
2886
3233
 
2887
3234
  try:
2888
- node = collection.release(args.id)
3235
+ node = collection.release(input_data.feature_id)
2889
3236
  except ValueError as e:
2890
3237
  print(f"Error: {e}", file=sys.stderr)
2891
3238
  sys.exit(1)
2892
3239
 
2893
3240
  if node is None:
2894
3241
  print(
2895
- f"Error: Feature '{args.id}' not found in {args.collection}.",
3242
+ f"Error: Feature '{input_data.feature_id}' not found in {input_data.collection}.",
2896
3243
  file=sys.stderr,
2897
3244
  )
2898
3245
  sys.exit(1)
@@ -3193,11 +3540,10 @@ def cmd_cigs_reset_violations(args: argparse.Namespace) -> None:
3193
3540
 
3194
3541
  # Confirm reset
3195
3542
  if not args.yes:
3196
- print(f"Current violations: {summary.total_violations}")
3197
- print(f"Total waste: {summary.total_waste_tokens} tokens")
3198
- response = input("\nReset violations for current session? [y/N]: ")
3199
- if response.lower() not in ("y", "yes"):
3200
- print("Reset cancelled")
3543
+ console.print(f"Current violations: {summary.total_violations}")
3544
+ console.print(f"Total waste: {summary.total_waste_tokens} tokens")
3545
+ if not Confirm.ask("\nReset violations for current session?", default=False):
3546
+ console.print("Reset cancelled")
3201
3547
  return
3202
3548
 
3203
3549
  # Clear violations file
@@ -3208,6 +3554,552 @@ def cmd_cigs_reset_violations(args: argparse.Namespace) -> None:
3208
3554
  print("Starting fresh for this session")
3209
3555
 
3210
3556
 
3557
+ def cmd_cigs_cost_dashboard(args: argparse.Namespace) -> None:
3558
+ """Generate cost attribution dashboard from HtmlGraph events."""
3559
+ import webbrowser
3560
+ from pathlib import Path
3561
+
3562
+ from htmlgraph.cigs.cost import CostCalculator
3563
+ from htmlgraph.operations.events import query_events
3564
+
3565
+ graph_dir = Path(args.graph_dir or ".htmlgraph")
3566
+
3567
+ # Parse options
3568
+ save = getattr(args, "save", False)
3569
+ open_browser = getattr(args, "open", False)
3570
+ output_json = getattr(args, "json", False)
3571
+ output_path = getattr(args, "output", None)
3572
+
3573
+ # Display progress
3574
+ with console.status("[blue]Analyzing HtmlGraph events...[/blue]", spinner="dots"):
3575
+ try:
3576
+ # Query all events
3577
+ result = query_events(graph_dir=graph_dir, limit=None)
3578
+ events = result.events if hasattr(result, "events") else []
3579
+
3580
+ if not events:
3581
+ console.print(
3582
+ "[yellow]No events found. Run some work to generate analytics![/yellow]"
3583
+ )
3584
+ return
3585
+
3586
+ # Calculate costs from events
3587
+ cost_calc = CostCalculator()
3588
+ cost_summary = _analyze_event_costs(events, cost_calc)
3589
+
3590
+ except Exception as e:
3591
+ console.print(f"[red]Error analyzing events: {e}[/red]")
3592
+ return
3593
+
3594
+ # Generate output
3595
+ if output_json:
3596
+ _output_cost_json(cost_summary, output_path)
3597
+ else:
3598
+ html_content = _generate_cost_dashboard_html(cost_summary)
3599
+
3600
+ if save or output_path:
3601
+ output_file = (
3602
+ Path(output_path) if output_path else graph_dir / "cost-dashboard.html"
3603
+ )
3604
+ output_file.write_text(html_content)
3605
+ console.print(f"[green]āœ“ Dashboard saved to: {output_file}[/green]")
3606
+
3607
+ if open_browser:
3608
+ webbrowser.open(f"file://{output_file.absolute()}")
3609
+ console.print("[blue]Opening dashboard in browser...[/blue]")
3610
+ else:
3611
+ # Display summary to console
3612
+ _display_cost_summary(cost_summary)
3613
+
3614
+ # Print recommendations
3615
+ _print_cost_recommendations(cost_summary)
3616
+
3617
+
3618
+ def _analyze_event_costs(events: list[dict], cost_calc: object) -> dict:
3619
+ """Analyze events and calculate cost attribution."""
3620
+ cost_summary: dict[str, Any] = {
3621
+ "total_cost_tokens": 0,
3622
+ "total_events": len(events),
3623
+ "tool_costs": {},
3624
+ "session_costs": {},
3625
+ "delegation_count": 0,
3626
+ "direct_execution_count": 0,
3627
+ "cost_by_category": {},
3628
+ }
3629
+
3630
+ for event in events:
3631
+ try:
3632
+ tool = event.get("tool", "unknown")
3633
+ session_id = event.get("session_id", "unknown")
3634
+ cost = (
3635
+ event.get("predicted_tokens", 0)
3636
+ or event.get("actual_tokens", 0)
3637
+ or 2000
3638
+ )
3639
+
3640
+ # Track by tool
3641
+ if tool not in cost_summary["tool_costs"]:
3642
+ cost_summary["tool_costs"][tool] = {"count": 0, "total_tokens": 0}
3643
+ cost_summary["tool_costs"][tool]["count"] += 1
3644
+ cost_summary["tool_costs"][tool]["total_tokens"] += cost
3645
+
3646
+ # Track by session
3647
+ if session_id not in cost_summary["session_costs"]:
3648
+ cost_summary["session_costs"][session_id] = {
3649
+ "count": 0,
3650
+ "total_tokens": 0,
3651
+ }
3652
+ cost_summary["session_costs"][session_id]["count"] += 1
3653
+ cost_summary["session_costs"][session_id]["total_tokens"] += cost
3654
+
3655
+ # Track delegation vs direct
3656
+ if tool in ["Task", "spawn_gemini", "spawn_codex", "spawn_copilot"]:
3657
+ cost_summary["delegation_count"] += 1
3658
+ category = "delegation"
3659
+ else:
3660
+ cost_summary["direct_execution_count"] += 1
3661
+ category = "direct"
3662
+
3663
+ if category not in cost_summary["cost_by_category"]:
3664
+ cost_summary["cost_by_category"][category] = {
3665
+ "count": 0,
3666
+ "total_tokens": 0,
3667
+ }
3668
+ cost_summary["cost_by_category"][category]["count"] += 1
3669
+ cost_summary["cost_by_category"][category]["total_tokens"] += cost
3670
+
3671
+ cost_summary["total_cost_tokens"] += cost
3672
+ except Exception:
3673
+ continue
3674
+
3675
+ return cost_summary
3676
+
3677
+
3678
+ def _generate_cost_dashboard_html(cost_summary: dict) -> str:
3679
+ """Generate HTML dashboard for cost attribution."""
3680
+ from datetime import datetime
3681
+
3682
+ # Calculate metrics
3683
+ total_cost = cost_summary["total_cost_tokens"]
3684
+ total_events = cost_summary["total_events"]
3685
+ avg_cost = total_cost / total_events if total_events > 0 else 0
3686
+ delegation_pct = (
3687
+ cost_summary["delegation_count"] / total_events * 100 if total_events > 0 else 0
3688
+ )
3689
+
3690
+ # Estimate cost in dollars (assuming $0.001 per 1K tokens for simplicity)
3691
+ cost_usd = total_cost / 1_000_000 * 5 # Rough estimate
3692
+
3693
+ # Sort tools by cost
3694
+ sorted_tools = sorted(
3695
+ cost_summary["tool_costs"].items(),
3696
+ key=lambda x: x[1]["total_tokens"],
3697
+ reverse=True,
3698
+ )
3699
+
3700
+ # Sort sessions by cost
3701
+ sorted_sessions = sorted(
3702
+ cost_summary["session_costs"].items(),
3703
+ key=lambda x: x[1]["total_tokens"],
3704
+ reverse=True,
3705
+ )
3706
+
3707
+ # Build tool cost rows
3708
+ tool_rows = "".join(
3709
+ f"""
3710
+ <tr>
3711
+ <td class="cell">{tool}</td>
3712
+ <td class="cell number">{data["count"]}</td>
3713
+ <td class="cell number">{data["total_tokens"]:,}</td>
3714
+ <td class="cell number">{data["total_tokens"] / total_cost * 100:.1f}%</td>
3715
+ </tr>
3716
+ """
3717
+ for tool, data in sorted_tools[:20]
3718
+ )
3719
+
3720
+ # Build session cost rows
3721
+ session_rows = "".join(
3722
+ f"""
3723
+ <tr>
3724
+ <td class="cell">{session[:12]}...</td>
3725
+ <td class="cell number">{data["count"]}</td>
3726
+ <td class="cell number">{data["total_tokens"]:,}</td>
3727
+ <td class="cell number">{data["total_tokens"] / total_cost * 100:.1f}%</td>
3728
+ </tr>
3729
+ """
3730
+ for session, data in sorted_sessions[:20]
3731
+ )
3732
+
3733
+ html = f"""<!DOCTYPE html>
3734
+ <html lang="en">
3735
+ <head>
3736
+ <meta charset="UTF-8">
3737
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3738
+ <title>HtmlGraph Cost Dashboard</title>
3739
+ <style>
3740
+ * {{
3741
+ margin: 0;
3742
+ padding: 0;
3743
+ box-sizing: border-box;
3744
+ }}
3745
+
3746
+ body {{
3747
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
3748
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
3749
+ min-height: 100vh;
3750
+ padding: 40px 20px;
3751
+ }}
3752
+
3753
+ .container {{
3754
+ max-width: 1400px;
3755
+ margin: 0 auto;
3756
+ }}
3757
+
3758
+ header {{
3759
+ background: white;
3760
+ border-radius: 12px;
3761
+ padding: 30px;
3762
+ margin-bottom: 30px;
3763
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
3764
+ }}
3765
+
3766
+ h1 {{
3767
+ color: #667eea;
3768
+ margin-bottom: 10px;
3769
+ font-size: 28px;
3770
+ }}
3771
+
3772
+ .timestamp {{
3773
+ color: #999;
3774
+ font-size: 12px;
3775
+ }}
3776
+
3777
+ .metrics {{
3778
+ display: grid;
3779
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
3780
+ gap: 20px;
3781
+ margin-top: 20px;
3782
+ }}
3783
+
3784
+ .metric {{
3785
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
3786
+ color: white;
3787
+ padding: 20px;
3788
+ border-radius: 8px;
3789
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
3790
+ }}
3791
+
3792
+ .metric-label {{
3793
+ font-size: 12px;
3794
+ opacity: 0.9;
3795
+ margin-bottom: 8px;
3796
+ text-transform: uppercase;
3797
+ letter-spacing: 1px;
3798
+ }}
3799
+
3800
+ .metric-value {{
3801
+ font-size: 32px;
3802
+ font-weight: bold;
3803
+ }}
3804
+
3805
+ .metric-unit {{
3806
+ font-size: 14px;
3807
+ opacity: 0.8;
3808
+ margin-left: 8px;
3809
+ }}
3810
+
3811
+ .metric.warning {{
3812
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
3813
+ }}
3814
+
3815
+ .metric.success {{
3816
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
3817
+ }}
3818
+
3819
+ section {{
3820
+ background: white;
3821
+ border-radius: 12px;
3822
+ padding: 30px;
3823
+ margin-bottom: 30px;
3824
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
3825
+ }}
3826
+
3827
+ h2 {{
3828
+ color: #333;
3829
+ margin-bottom: 20px;
3830
+ font-size: 20px;
3831
+ border-bottom: 2px solid #667eea;
3832
+ padding-bottom: 10px;
3833
+ }}
3834
+
3835
+ table {{
3836
+ width: 100%;
3837
+ border-collapse: collapse;
3838
+ }}
3839
+
3840
+ th {{
3841
+ background: #f5f5f5;
3842
+ padding: 12px;
3843
+ text-align: left;
3844
+ font-weight: 600;
3845
+ color: #333;
3846
+ border-bottom: 2px solid #ddd;
3847
+ }}
3848
+
3849
+ td {{
3850
+ padding: 12px;
3851
+ border-bottom: 1px solid #eee;
3852
+ }}
3853
+
3854
+ td.cell {{
3855
+ color: #333;
3856
+ }}
3857
+
3858
+ td.number {{
3859
+ text-align: right;
3860
+ font-family: 'Monaco', 'Courier New', monospace;
3861
+ color: #667eea;
3862
+ font-weight: 500;
3863
+ }}
3864
+
3865
+ tr:hover {{
3866
+ background: #f9f9f9;
3867
+ }}
3868
+
3869
+ .insights {{
3870
+ display: grid;
3871
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
3872
+ gap: 20px;
3873
+ margin-top: 20px;
3874
+ }}
3875
+
3876
+ .insight {{
3877
+ background: #f0f4ff;
3878
+ border-left: 4px solid #667eea;
3879
+ padding: 16px;
3880
+ border-radius: 4px;
3881
+ }}
3882
+
3883
+ .insight-title {{
3884
+ font-weight: 600;
3885
+ color: #333;
3886
+ margin-bottom: 8px;
3887
+ }}
3888
+
3889
+ .insight-text {{
3890
+ color: #666;
3891
+ font-size: 14px;
3892
+ line-height: 1.6;
3893
+ }}
3894
+
3895
+ .footer {{
3896
+ text-align: center;
3897
+ color: #999;
3898
+ font-size: 12px;
3899
+ margin-top: 40px;
3900
+ }}
3901
+ </style>
3902
+ </head>
3903
+ <body>
3904
+ <div class="container">
3905
+ <header>
3906
+ <h1>šŸ’° HtmlGraph Cost Dashboard</h1>
3907
+ <p class="timestamp">Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
3908
+ <div class="metrics">
3909
+ <div class="metric">
3910
+ <div class="metric-label">Total Cost</div>
3911
+ <div class="metric-value">{total_cost:,}<span class="metric-unit">tokens</span></div>
3912
+ </div>
3913
+ <div class="metric success">
3914
+ <div class="metric-label">Estimated USD</div>
3915
+ <div class="metric-value">${cost_usd:.2f}</div>
3916
+ </div>
3917
+ <div class="metric">
3918
+ <div class="metric-label">Average Cost</div>
3919
+ <div class="metric-value">{avg_cost:,.0f}<span class="metric-unit">tokens</span></div>
3920
+ </div>
3921
+ <div class="metric success">
3922
+ <div class="metric-label">Delegation Rate</div>
3923
+ <div class="metric-value">{delegation_pct:.1f}%</div>
3924
+ </div>
3925
+ </div>
3926
+ </header>
3927
+
3928
+ <section>
3929
+ <h2>šŸ“Š Cost by Tool</h2>
3930
+ <table>
3931
+ <thead>
3932
+ <tr>
3933
+ <th>Tool</th>
3934
+ <th>Count</th>
3935
+ <th>Total Tokens</th>
3936
+ <th>% of Total</th>
3937
+ </tr>
3938
+ </thead>
3939
+ <tbody>
3940
+ {tool_rows}
3941
+ </tbody>
3942
+ </table>
3943
+ </section>
3944
+
3945
+ <section>
3946
+ <h2>šŸ”„ Cost by Session</h2>
3947
+ <table>
3948
+ <thead>
3949
+ <tr>
3950
+ <th>Session ID</th>
3951
+ <th>Count</th>
3952
+ <th>Total Tokens</th>
3953
+ <th>% of Total</th>
3954
+ </tr>
3955
+ </thead>
3956
+ <tbody>
3957
+ {session_rows}
3958
+ </tbody>
3959
+ </table>
3960
+ </section>
3961
+
3962
+ <section>
3963
+ <h2>šŸ’” Insights & Recommendations</h2>
3964
+ <div class="insights">
3965
+ <div class="insight">
3966
+ <div class="insight-title">āœ“ Delegation Usage</div>
3967
+ <div class="insight-text">
3968
+ You're delegating {delegation_pct:.1f}% of operations.
3969
+ {"Continue delegation for cost efficiency!" if delegation_pct > 50 else "Consider delegating more operations to reduce costs."}
3970
+ </div>
3971
+ </div>
3972
+ <div class="insight">
3973
+ <div class="insight-title">šŸŽÆ Top Cost Driver</div>
3974
+ <div class="insight-text">
3975
+ {sorted_tools[0][0] if sorted_tools else "N/A"} accounts for {sorted_tools[0][1]["total_tokens"] / total_cost * 100:.1f}% of total cost.
3976
+ Review if this tool usage is optimal.
3977
+ </div>
3978
+ </div>
3979
+ <div class="insight">
3980
+ <div class="insight-title">šŸ“ˆ Parallelization Opportunity</div>
3981
+ <div class="insight-text">
3982
+ Parallel Task() calls can reduce overall execution time by ~40%.
3983
+ Look for independent operations that can run simultaneously.
3984
+ </div>
3985
+ </div>
3986
+ </div>
3987
+ </section>
3988
+
3989
+ <div class="footer">
3990
+ <p>HtmlGraph Cost Attribution Dashboard | Real-time cost tracking and optimization</p>
3991
+ </div>
3992
+ </div>
3993
+ </body>
3994
+ </html>"""
3995
+
3996
+ return html
3997
+
3998
+
3999
+ def _output_cost_json(cost_summary: dict, output_path: str | None) -> None:
4000
+ """Output cost data as JSON."""
4001
+ import json
4002
+
4003
+ output_file = Path(output_path) if output_path else Path("cost-summary.json")
4004
+ output_file.write_text(json.dumps(cost_summary, indent=2))
4005
+ console.print(f"[green]āœ“ JSON output saved to: {output_file}[/green]")
4006
+
4007
+
4008
+ def _display_cost_summary(cost_summary: dict) -> None:
4009
+ """Display cost summary in console."""
4010
+ console.print("\n[bold cyan]Cost Dashboard Summary[/bold cyan]\n")
4011
+
4012
+ # Create summary table
4013
+ summary_table = Table(
4014
+ show_header=True, header_style="bold magenta", box=box.ROUNDED
4015
+ )
4016
+ summary_table.add_column("Metric", style="cyan")
4017
+ summary_table.add_column("Value", style="green")
4018
+
4019
+ total_tokens = cost_summary["total_cost_tokens"]
4020
+ total_events = cost_summary["total_events"]
4021
+ avg_tokens = total_tokens / total_events if total_events > 0 else 0
4022
+ delegation_pct = (
4023
+ cost_summary["delegation_count"] / total_events * 100 if total_events > 0 else 0
4024
+ )
4025
+ cost_usd = total_tokens / 1_000_000 * 5
4026
+
4027
+ summary_table.add_row("Total Events", str(total_events))
4028
+ summary_table.add_row("Total Cost", f"{total_tokens:,} tokens")
4029
+ summary_table.add_row("Average Cost", f"{avg_tokens:,.0f} tokens/event")
4030
+ summary_table.add_row("Estimated USD", f"${cost_usd:.2f}")
4031
+ summary_table.add_row("Delegation Count", str(cost_summary["delegation_count"]))
4032
+ summary_table.add_row("Delegation Rate", f"{delegation_pct:.1f}%")
4033
+ summary_table.add_row(
4034
+ "Direct Executions", str(cost_summary["direct_execution_count"])
4035
+ )
4036
+
4037
+ console.print(summary_table)
4038
+
4039
+ # Top tools
4040
+ if cost_summary["tool_costs"]:
4041
+ console.print("\n[bold cyan]Top Cost Drivers (by Tool)[/bold cyan]\n")
4042
+ tools_table = Table(
4043
+ show_header=True, header_style="bold magenta", box=box.ROUNDED
4044
+ )
4045
+ tools_table.add_column("Tool", style="cyan")
4046
+ tools_table.add_column("Count", justify="right", style="green")
4047
+ tools_table.add_column("Tokens", justify="right", style="yellow")
4048
+ tools_table.add_column("% Total", justify="right", style="magenta")
4049
+
4050
+ for tool, data in sorted(
4051
+ cost_summary["tool_costs"].items(),
4052
+ key=lambda x: x[1]["total_tokens"],
4053
+ reverse=True,
4054
+ )[:10]:
4055
+ pct = data["total_tokens"] / total_tokens * 100
4056
+ tools_table.add_row(
4057
+ tool, str(data["count"]), f"{data['total_tokens']:,}", f"{pct:.1f}%"
4058
+ )
4059
+
4060
+ console.print(tools_table)
4061
+
4062
+
4063
+ def _print_cost_recommendations(cost_summary: dict) -> None:
4064
+ """Print recommendations for cost optimization."""
4065
+ console.print("\n[bold cyan]Recommendations[/bold cyan]\n")
4066
+
4067
+ total_events = cost_summary["total_events"]
4068
+ delegation_pct = (
4069
+ cost_summary["delegation_count"] / total_events * 100 if total_events > 0 else 0
4070
+ )
4071
+
4072
+ recommendations = []
4073
+
4074
+ if delegation_pct < 50:
4075
+ recommendations.append(
4076
+ "[yellow]→ Increase delegation usage[/yellow] - Consider using Task() and spawn_* for more operations"
4077
+ )
4078
+
4079
+ if cost_summary["tool_costs"]:
4080
+ top_tool = max(
4081
+ cost_summary["tool_costs"].items(), key=lambda x: x[1]["total_tokens"]
4082
+ )
4083
+ if top_tool[1]["total_tokens"] / cost_summary["total_cost_tokens"] > 0.4:
4084
+ recommendations.append(
4085
+ f"[yellow]→ Review {top_tool[0]} usage[/yellow] - It accounts for {top_tool[1]['total_tokens'] / cost_summary['total_cost_tokens'] * 100:.1f}% of total cost"
4086
+ )
4087
+
4088
+ if total_events > 100:
4089
+ recommendations.append(
4090
+ "[green]āœ“ Good event volume[/green] - Sufficient data for optimization analysis"
4091
+ )
4092
+
4093
+ recommendations.append(
4094
+ "[blue]šŸ’” Tip: Use parallel Task() calls to reduce execution time by ~40%[/blue]"
4095
+ )
4096
+
4097
+ for rec in recommendations:
4098
+ console.print(f" {rec}")
4099
+
4100
+ console.print()
4101
+
4102
+
3211
4103
  def cmd_publish(args: argparse.Namespace) -> None:
3212
4104
  """Build and publish the package to PyPI (Interoperable)."""
3213
4105
  import shutil
@@ -3228,14 +4120,14 @@ def cmd_publish(args: argparse.Namespace) -> None:
3228
4120
  shutil.rmtree(dist_dir)
3229
4121
 
3230
4122
  # 2. Build
3231
- print("Building package with uv...")
4123
+ console.print("Building package with uv...", style="blue")
3232
4124
  try:
3233
4125
  subprocess.run(["uv", "build"], check=True)
3234
4126
  except subprocess.CalledProcessError:
3235
- print("Error: Build failed.", file=sys.stderr)
4127
+ console.print("[red]Error: Build failed.[/red]")
3236
4128
  sys.exit(1)
3237
4129
  except FileNotFoundError:
3238
- print("Error: 'uv' command not found.", file=sys.stderr)
4130
+ console.print("[red]Error: 'uv' command not found.[/red]")
3239
4131
  sys.exit(1)
3240
4132
 
3241
4133
  # 3. Publish
@@ -3316,39 +4208,61 @@ def cmd_feature_list(args: argparse.Namespace) -> None:
3316
4208
  else:
3317
4209
  if not nodes:
3318
4210
  if not args.quiet:
3319
- print(
3320
- f"No features found with status '{args.status}'."
4211
+ console.print(
4212
+ f"[yellow]No features found with status '{args.status}'.[/yellow]"
3321
4213
  if args.status
3322
- else "No features found."
4214
+ else "[yellow]No features found.[/yellow]"
3323
4215
  )
3324
4216
  return
3325
4217
 
3326
- # Header (skip if quiet)
4218
+ # Create Rich table (skip if quiet)
3327
4219
  if not args.quiet:
3328
- print(f"{'ID':<25} {'Status':<12} {'Priority':<10} {'Title'}")
3329
- print("=" * 80)
4220
+ table = Table(
4221
+ title="Features",
4222
+ show_header=True,
4223
+ header_style="bold magenta",
4224
+ box=box.ROUNDED,
4225
+ )
4226
+ table.add_column("ID", style="cyan", no_wrap=True, width=25)
4227
+ table.add_column("Status", style="green", width=12)
4228
+ table.add_column("Priority", style="yellow", width=10)
4229
+ table.add_column("Title", style="white")
3330
4230
 
3331
- # List features
3332
- for node in nodes:
3333
- title = node.title[:35] + "..." if len(node.title) > 38 else node.title
3334
- print(f"{node.id:<25} {node.status:<12} {node.priority:<10} {title}")
4231
+ # List features
4232
+ for node in nodes:
4233
+ title = node.title[:35] + "..." if len(node.title) > 38 else node.title
4234
+ table.add_row(node.id, node.status, node.priority or "-", title)
3335
4235
 
3336
- # Verbose output
4236
+ console.print(table)
4237
+ else:
4238
+ # Quiet mode - simple output without table
4239
+ for node in nodes:
4240
+ print(f"{node.id}\t{node.status}\t{node.priority}\t{node.title}")
4241
+
4242
+ # Verbose output with Rich.Panel
3337
4243
  if args.verbose >= 1:
3338
- print("\n--- Verbose Details ---")
3339
- print(f"Total features: {len(nodes)}")
3340
- print(f"Graph directory: {args.graph_dir}")
4244
+ details = f"Total features: {len(nodes)}\nGraph directory: {args.graph_dir}"
3341
4245
  if args.status:
3342
- print(f"Filtered by status: {args.status}")
4246
+ details += f"\nFiltered by status: {args.status}"
4247
+
4248
+ console.print(Panel(details, title="Verbose Details", border_style="cyan"))
3343
4249
 
3344
4250
  if args.verbose >= 2:
3345
- print("\nFeature breakdown by status:")
3346
4251
  from collections import Counter
3347
4252
 
3348
4253
  status_counts = Counter(n.status for n in sdk.features.all())
3349
- for status, count in sorted(status_counts.items()):
3350
- marker = "→" if status == args.status else " "
3351
- print(f" {marker} {status}: {count}")
4254
+ breakdown = "\n".join(
4255
+ [
4256
+ f" {'→' if status == args.status else ' '} {status}: {count}"
4257
+ for status, count in sorted(status_counts.items())
4258
+ ]
4259
+ )
4260
+
4261
+ console.print(
4262
+ Panel(
4263
+ breakdown, title="Feature Breakdown by Status", border_style="blue"
4264
+ )
4265
+ )
3352
4266
 
3353
4267
 
3354
4268
  # =============================================================================
@@ -3377,7 +4291,7 @@ def cmd_feature_step_complete(args: argparse.Namespace) -> None:
3377
4291
  step_indices = sorted(set(step_indices))
3378
4292
 
3379
4293
  if not step_indices:
3380
- print("Error: No step indices provided", file=sys.stderr)
4294
+ console.print("[red]Error: No step indices provided[/red]")
3381
4295
  sys.exit(1)
3382
4296
 
3383
4297
  # Make API requests for each step
@@ -3463,14 +4377,13 @@ def cmd_feature_delete(args: argparse.Namespace) -> None:
3463
4377
 
3464
4378
  # Confirmation prompt (unless --yes flag)
3465
4379
  if not args.yes:
3466
- print(f"Delete {args.collection.rstrip('s')} '{args.id}'?")
3467
- print(f" Title: {feature.title}")
3468
- print(f" Status: {feature.status}")
3469
- print("\nThis cannot be undone. Continue? [y/N] ", end="")
4380
+ console.print(f"Delete {args.collection.rstrip('s')} '{args.id}'?")
4381
+ console.print(f" Title: {feature.title}")
4382
+ console.print(f" Status: {feature.status}")
4383
+ console.print("\n[bold red]This cannot be undone.[/bold red]")
3470
4384
 
3471
- response = input().strip().lower()
3472
- if response not in ("y", "yes"):
3473
- print("Cancelled")
4385
+ if not Confirm.ask("Continue?", default=False):
4386
+ console.print("Cancelled")
3474
4387
  sys.exit(0)
3475
4388
 
3476
4389
  # Delete
@@ -3545,12 +4458,23 @@ def cmd_track_list(args: argparse.Namespace) -> None:
3545
4458
  print(json.dumps({"tracks": track_ids}, indent=2))
3546
4459
  else:
3547
4460
  if not track_ids:
3548
- print("No tracks found.")
3549
- print("\nCreate a track with: htmlgraph track new 'Track Title'")
4461
+ console.print("[yellow]No tracks found.[/yellow]")
4462
+ console.print(
4463
+ "\n[dim]Create a track with: htmlgraph track new 'Track Title'[/dim]"
4464
+ )
3550
4465
  return
3551
4466
 
3552
- print(f"Tracks in {args.graph_dir}/tracks/:")
3553
- print("=" * 60)
4467
+ # Create Rich table
4468
+ table = Table(
4469
+ title=f"Tracks in {args.graph_dir}/tracks/",
4470
+ show_header=True,
4471
+ header_style="bold magenta",
4472
+ box=box.ROUNDED,
4473
+ )
4474
+ table.add_column("Track ID", style="cyan", no_wrap=True)
4475
+ table.add_column("Components", style="green")
4476
+ table.add_column("Format", style="blue")
4477
+
3554
4478
  for track_id in track_ids:
3555
4479
  # Check for both consolidated (single file) and directory-based formats
3556
4480
  track_file = Path(args.graph_dir) / "tracks" / f"{track_id}.html"
@@ -3564,12 +4488,12 @@ def cmd_track_list(args: argparse.Namespace) -> None:
3564
4488
  or 'data-section="requirements"' in content
3565
4489
  )
3566
4490
  has_plan = 'data-section="plan"' in content
3567
- format_indicator = " (consolidated)"
4491
+ format_type = "consolidated"
3568
4492
  else:
3569
4493
  # Directory format
3570
4494
  has_spec = (track_dir / "spec.html").exists()
3571
4495
  has_plan = (track_dir / "plan.html").exists()
3572
- format_indicator = ""
4496
+ format_type = "directory"
3573
4497
 
3574
4498
  components = []
3575
4499
  if has_spec:
@@ -3577,8 +4501,11 @@ def cmd_track_list(args: argparse.Namespace) -> None:
3577
4501
  if has_plan:
3578
4502
  components.append("plan")
3579
4503
 
3580
- components_str = f" [{', '.join(components)}]" if components else " [empty]"
3581
- print(f" {track_id}{components_str}{format_indicator}")
4504
+ components_str = ", ".join(components) if components else "empty"
4505
+
4506
+ table.add_row(track_id, components_str, format_type)
4507
+
4508
+ console.print(table)
3582
4509
 
3583
4510
 
3584
4511
  def cmd_track_spec(args: argparse.Namespace) -> None:
@@ -4221,6 +5148,32 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
4221
5148
  help="Automatically find an available port if default is occupied",
4222
5149
  )
4223
5150
 
5151
+ # serve-api (FastAPI-based dashboard with real-time observability)
5152
+ serve_api_parser = subparsers.add_parser(
5153
+ "serve-api",
5154
+ help="Start the FastAPI-based observability dashboard (Phase 3)",
5155
+ )
5156
+ serve_api_parser.add_argument(
5157
+ "--port", "-p", type=int, default=8000, help="Port (default: 8000)"
5158
+ )
5159
+ serve_api_parser.add_argument(
5160
+ "--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)"
5161
+ )
5162
+ serve_api_parser.add_argument(
5163
+ "--db", default=None, help="Path to SQLite database file"
5164
+ )
5165
+ serve_api_parser.add_argument(
5166
+ "--auto-port",
5167
+ action="store_true",
5168
+ help="Automatically find an available port if default is occupied",
5169
+ )
5170
+ serve_api_parser.add_argument(
5171
+ "--reload",
5172
+ action="store_true",
5173
+ help="Enable auto-reload on file changes (development mode)",
5174
+ )
5175
+ serve_api_parser.set_defaults(func=cmd_serve_api)
5176
+
4224
5177
  # init
4225
5178
  init_parser = subparsers.add_parser("init", help="Initialize .htmlgraph directory")
4226
5179
  init_parser.add_argument(
@@ -4486,6 +5439,17 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
4486
5439
  session_validate.add_argument(
4487
5440
  "--format", "-f", choices=["text", "json"], default="text", help="Output format"
4488
5441
  )
5442
+ session_validate.set_defaults(func=cmd_session_validate_attribution)
5443
+
5444
+ # session debug
5445
+ session_debug = session_subparsers.add_parser(
5446
+ "debug", help="Show error tracebacks and debug information for a session"
5447
+ )
5448
+ session_debug.add_argument("session_id", help="Session ID to debug")
5449
+ session_debug.add_argument(
5450
+ "--graph-dir", "-g", default=".htmlgraph", help="Graph directory"
5451
+ )
5452
+ session_debug.set_defaults(func=cmd_session_debug)
4489
5453
 
4490
5454
  # activity (legacy: was "track")
4491
5455
  activity_parser = subparsers.add_parser(
@@ -4824,6 +5788,11 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
4824
5788
  help="Priority",
4825
5789
  )
4826
5790
  feature_create.add_argument("--steps", nargs="*", help="Implementation steps")
5791
+ feature_create.add_argument(
5792
+ "--track",
5793
+ "-t",
5794
+ help="Track ID to link feature to (required for features collection)",
5795
+ )
4827
5796
  feature_create.add_argument(
4828
5797
  "--agent",
4829
5798
  default=os.environ.get("HTMLGRAPH_AGENT") or "cli",
@@ -5565,6 +6534,27 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
5565
6534
  "--graph-dir", "-g", default=".htmlgraph", help="Graph directory"
5566
6535
  )
5567
6536
 
6537
+ # cigs cost dashboard
6538
+ cigs_cost_dashboard = cigs_subparsers.add_parser(
6539
+ "cost-dashboard", help="Generate cost attribution dashboard"
6540
+ )
6541
+ cigs_cost_dashboard.add_argument(
6542
+ "--graph-dir", "-g", default=".htmlgraph", help="Graph directory"
6543
+ )
6544
+ cigs_cost_dashboard.add_argument(
6545
+ "--save",
6546
+ "-s",
6547
+ action="store_true",
6548
+ help="Save to .htmlgraph/cost-dashboard.html",
6549
+ )
6550
+ cigs_cost_dashboard.add_argument(
6551
+ "--open", "-o", action="store_true", help="Open in browser after generation"
6552
+ )
6553
+ cigs_cost_dashboard.add_argument(
6554
+ "--json", action="store_true", help="Output JSON instead of HTML"
6555
+ )
6556
+ cigs_cost_dashboard.add_argument("--output", type=str, help="Custom output path")
6557
+
5568
6558
  # install-gemini-extension
5569
6559
  subparsers.add_parser(
5570
6560
  "install-gemini-extension",
@@ -5598,6 +6588,8 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
5598
6588
 
5599
6589
  if args.command == "serve":
5600
6590
  cmd_serve(args)
6591
+ elif args.command == "serve-api":
6592
+ cmd_serve_api(args)
5601
6593
  elif args.command == "init":
5602
6594
  cmd_init(args)
5603
6595
  elif args.command == "install-hooks":
@@ -5840,6 +6832,8 @@ For more help: https://github.com/Shakes-tzd/htmlgraph
5840
6832
  cmd_cigs_patterns(args)
5841
6833
  elif args.cigs_command == "reset-violations":
5842
6834
  cmd_cigs_reset_violations(args)
6835
+ elif args.cigs_command == "cost-dashboard":
6836
+ cmd_cigs_cost_dashboard(args)
5843
6837
  else:
5844
6838
  cigs_parser.print_help()
5845
6839
  sys.exit(1)
@@ -6016,14 +7010,17 @@ def cmd_archive_create(args: argparse.Namespace) -> None:
6016
7010
  print(f"Error: Directory not found: {htmlgraph_dir}", file=sys.stderr)
6017
7011
  sys.exit(1)
6018
7012
 
6019
- manager = ArchiveManager(htmlgraph_dir)
7013
+ with console.status("[blue]Initializing archive manager...", spinner="dots"):
7014
+ manager = ArchiveManager(htmlgraph_dir)
6020
7015
 
6021
- # Run archive operation
6022
- result = manager.archive_entities(
6023
- older_than_days=args.older_than,
6024
- period=args.period,
6025
- dry_run=args.dry_run,
6026
- )
7016
+ # Run archive operation with status spinner
7017
+ operation = "Previewing" if args.dry_run else "Archiving"
7018
+ with console.status(f"[blue]{operation} entities...", spinner="dots"):
7019
+ result = manager.archive_entities(
7020
+ older_than_days=args.older_than,
7021
+ period=args.period,
7022
+ dry_run=args.dry_run,
7023
+ )
6027
7024
 
6028
7025
  if result["dry_run"]:
6029
7026
  print("\nšŸ” DRY RUN - Preview (no changes made)\n")
@@ -6055,26 +7052,55 @@ def cmd_archive_search(args: argparse.Namespace) -> None:
6055
7052
  print(f"Error: Directory not found: {htmlgraph_dir}", file=sys.stderr)
6056
7053
  sys.exit(1)
6057
7054
 
6058
- manager = ArchiveManager(htmlgraph_dir)
7055
+ with console.status("[blue]Initializing archive manager...", spinner="dots"):
7056
+ manager = ArchiveManager(htmlgraph_dir)
6059
7057
 
6060
- # Search archives
6061
- results = manager.search(args.query, limit=args.limit)
7058
+ # Search archives with status spinner
7059
+ with console.status(
7060
+ f"[blue]Searching archives for '{args.query}'...", spinner="dots"
7061
+ ):
7062
+ results = manager.search(args.query, limit=args.limit)
6062
7063
 
6063
7064
  if args.format == "json":
6064
7065
  print(json.dumps({"query": args.query, "results": results}, indent=2))
6065
7066
  else:
6066
- print(f"\nšŸ” Search results for: '{args.query}'\n")
6067
- print(f"Found {len(results)} result(s):\n")
6068
-
6069
- for i, result in enumerate(results, 1):
6070
- print(f"{i}. {result['entity_id']} ({result['entity_type']})")
6071
- print(f" Archive: {result['archive_file']}")
6072
- print(f" Status: {result['status']}")
6073
- print(f" Title: {result['title_snippet']}")
6074
- if result["description_snippet"]:
6075
- print(f" Description: {result['description_snippet']}")
6076
- print(f" Relevance: {result['rank']:.2f}")
6077
- print()
7067
+ console.print(f"\nšŸ” Search results for: '{args.query}'\n")
7068
+ console.print(f"Found {len(results)} result(s):\n")
7069
+
7070
+ # Use progress bar for large result sets
7071
+ if len(results) > 10:
7072
+ with Progress(
7073
+ SpinnerColumn(),
7074
+ TextColumn("[progress.description]{task.description}"),
7075
+ console=console,
7076
+ transient=True,
7077
+ ) as progress:
7078
+ task = progress.add_task("Displaying results...", total=len(results))
7079
+ for i, result in enumerate(results, 1):
7080
+ console.print(
7081
+ f"{i}. {result['entity_id']} ({result['entity_type']})"
7082
+ )
7083
+ console.print(f" Archive: {result['archive_file']}")
7084
+ console.print(f" Status: {result['status']}")
7085
+ console.print(f" Title: {result['title_snippet']}")
7086
+ if result["description_snippet"]:
7087
+ console.print(
7088
+ f" Description: {result['description_snippet']}"
7089
+ )
7090
+ console.print(f" Relevance: {result['rank']:.2f}")
7091
+ console.print()
7092
+ progress.update(task, advance=1)
7093
+ else:
7094
+ # No progress bar for small result sets
7095
+ for i, result in enumerate(results, 1):
7096
+ console.print(f"{i}. {result['entity_id']} ({result['entity_type']})")
7097
+ console.print(f" Archive: {result['archive_file']}")
7098
+ console.print(f" Status: {result['status']}")
7099
+ console.print(f" Title: {result['title_snippet']}")
7100
+ if result["description_snippet"]:
7101
+ console.print(f" Description: {result['description_snippet']}")
7102
+ console.print(f" Relevance: {result['rank']:.2f}")
7103
+ console.print()
6078
7104
 
6079
7105
  manager.close()
6080
7106