htmlgraph 0.26.5__py3-none-any.whl → 0.26.6__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 (69) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
  2. htmlgraph/__init__.py +1 -1
  3. htmlgraph/api/main.py +50 -10
  4. htmlgraph/api/templates/dashboard-redesign.html +608 -54
  5. htmlgraph/api/templates/partials/activity-feed.html +21 -0
  6. htmlgraph/api/templates/partials/features.html +81 -12
  7. htmlgraph/api/templates/partials/orchestration.html +35 -0
  8. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  9. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  10. htmlgraph/cli/__init__.py +42 -0
  11. htmlgraph/cli/__main__.py +6 -0
  12. htmlgraph/cli/analytics.py +939 -0
  13. htmlgraph/cli/base.py +660 -0
  14. htmlgraph/cli/constants.py +206 -0
  15. htmlgraph/cli/core.py +856 -0
  16. htmlgraph/cli/main.py +143 -0
  17. htmlgraph/cli/models.py +462 -0
  18. htmlgraph/cli/templates/__init__.py +1 -0
  19. htmlgraph/cli/templates/cost_dashboard.py +398 -0
  20. htmlgraph/cli/work/__init__.py +159 -0
  21. htmlgraph/cli/work/features.py +567 -0
  22. htmlgraph/cli/work/orchestration.py +675 -0
  23. htmlgraph/cli/work/sessions.py +465 -0
  24. htmlgraph/cli/work/tracks.py +485 -0
  25. htmlgraph/dashboard.html +6414 -634
  26. htmlgraph/db/schema.py +8 -3
  27. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
  28. htmlgraph/docs/README.md +2 -3
  29. htmlgraph/hooks/event_tracker.py +157 -25
  30. htmlgraph/hooks/git_commands.py +175 -0
  31. htmlgraph/hooks/orchestrator.py +137 -71
  32. htmlgraph/hooks/orchestrator_reflector.py +23 -0
  33. htmlgraph/hooks/pretooluse.py +29 -6
  34. htmlgraph/hooks/session_handler.py +28 -0
  35. htmlgraph/hooks/session_summary.py +391 -0
  36. htmlgraph/hooks/subagent_detection.py +202 -0
  37. htmlgraph/hooks/validator.py +192 -79
  38. htmlgraph/operations/__init__.py +18 -0
  39. htmlgraph/operations/initialization.py +596 -0
  40. htmlgraph/operations/initialization.py.backup +228 -0
  41. htmlgraph/orchestration/__init__.py +16 -1
  42. htmlgraph/orchestration/claude_launcher.py +185 -0
  43. htmlgraph/orchestration/command_builder.py +71 -0
  44. htmlgraph/orchestration/headless_spawner.py +72 -1332
  45. htmlgraph/orchestration/plugin_manager.py +136 -0
  46. htmlgraph/orchestration/prompts.py +137 -0
  47. htmlgraph/orchestration/spawners/__init__.py +16 -0
  48. htmlgraph/orchestration/spawners/base.py +194 -0
  49. htmlgraph/orchestration/spawners/claude.py +170 -0
  50. htmlgraph/orchestration/spawners/codex.py +442 -0
  51. htmlgraph/orchestration/spawners/copilot.py +299 -0
  52. htmlgraph/orchestration/spawners/gemini.py +478 -0
  53. htmlgraph/orchestration/subprocess_runner.py +33 -0
  54. htmlgraph/orchestration.md +563 -0
  55. htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
  56. htmlgraph/orchestrator_config.py +357 -0
  57. htmlgraph/orchestrator_mode.py +45 -12
  58. htmlgraph/transcript.py +16 -4
  59. htmlgraph-0.26.6.data/data/htmlgraph/dashboard.html +6592 -0
  60. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/METADATA +1 -1
  61. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/RECORD +67 -33
  62. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/entry_points.txt +1 -1
  63. htmlgraph/cli.py +0 -7256
  64. htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
  65. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/styles.css +0 -0
  66. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  67. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  68. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  69. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/WHEEL +0 -0
htmlgraph/cli/main.py ADDED
@@ -0,0 +1,143 @@
1
+ """HtmlGraph CLI - Main entry point.
2
+
3
+ Entry point with argument parsing and command routing.
4
+ Keeps main() thin by delegating to command modules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from rich.console import Console
13
+
14
+ from htmlgraph.cli.constants import (
15
+ DEFAULT_GRAPH_DIR,
16
+ DEFAULT_OUTPUT_FORMAT,
17
+ OUTPUT_FORMATS,
18
+ )
19
+
20
+
21
+ def create_parser() -> argparse.ArgumentParser:
22
+ """Create and configure the argument parser.
23
+
24
+ Returns:
25
+ Configured ArgumentParser with all subcommands
26
+ """
27
+ parser = argparse.ArgumentParser(
28
+ description="HtmlGraph - HTML is All You Need",
29
+ formatter_class=argparse.RawDescriptionHelpFormatter,
30
+ epilog="""
31
+ Examples:
32
+ htmlgraph init # Initialize .htmlgraph in current dir
33
+ htmlgraph serve # Start server on port 8080
34
+ htmlgraph status # Show graph status
35
+ htmlgraph query "[data-status='todo']" # Query nodes
36
+
37
+ Session Management:
38
+ htmlgraph session start # Start a new session (auto-ID)
39
+ htmlgraph session end my-session # End a session
40
+ htmlgraph session list # List all sessions
41
+
42
+ Feature Management:
43
+ htmlgraph feature list # List all features
44
+ htmlgraph feature start feat-001 # Start working on a feature
45
+ htmlgraph feature complete feat-001 # Mark feature as done
46
+
47
+ Track Management:
48
+ htmlgraph track new "User Auth" # Create a new track
49
+ htmlgraph track list # List all tracks
50
+
51
+ Analytics:
52
+ htmlgraph analytics # Project-wide analytics
53
+ htmlgraph analytics --recent 10 # Analyze last 10 sessions
54
+
55
+ For more help: https://github.com/Shakes-tzd/htmlgraph
56
+ """,
57
+ )
58
+
59
+ # Global output control flags
60
+ parser.add_argument(
61
+ "--format",
62
+ choices=OUTPUT_FORMATS,
63
+ default=DEFAULT_OUTPUT_FORMAT,
64
+ help="Output format: text (default), json, or plain",
65
+ )
66
+ parser.add_argument(
67
+ "--quiet",
68
+ "-q",
69
+ action="store_true",
70
+ help="Suppress progress messages and non-essential output",
71
+ )
72
+ parser.add_argument(
73
+ "--verbose",
74
+ "-v",
75
+ action="count",
76
+ default=0,
77
+ help="Increase verbosity (can be used multiple times: -v, -vv, -vvv)",
78
+ )
79
+
80
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
81
+
82
+ # Import command registration functions
83
+ from htmlgraph.cli import analytics, core, work
84
+
85
+ # Register commands from each module
86
+ core.register_commands(subparsers)
87
+ work.register_commands(subparsers)
88
+ analytics.register_commands(subparsers)
89
+
90
+ return parser
91
+
92
+
93
+ def main() -> None:
94
+ """Main entry point for the CLI."""
95
+ parser = create_parser()
96
+ args = parser.parse_args()
97
+
98
+ # If no command specified, show help
99
+ if not args.command:
100
+ parser.print_help()
101
+ sys.exit(0)
102
+
103
+ # Get the command handler (set by register_commands via set_defaults)
104
+ if not hasattr(args, "func"):
105
+ parser.print_help()
106
+ sys.exit(1)
107
+
108
+ # Determine graph directory and agent
109
+ graph_dir = getattr(args, "graph_dir", DEFAULT_GRAPH_DIR)
110
+ agent = getattr(args, "agent", None)
111
+
112
+ # Get output format from args
113
+ output_format = args.format
114
+
115
+ # Execute the command
116
+ try:
117
+ # Create command instance from args
118
+ command = args.func(args)
119
+
120
+ # Run command with context
121
+ command.run(
122
+ graph_dir=graph_dir,
123
+ agent=agent,
124
+ output_format=output_format,
125
+ )
126
+ except KeyboardInterrupt:
127
+ err_console = Console(stderr=True)
128
+ err_console.print("\n\n[yellow]Interrupted by user[/yellow]")
129
+ sys.exit(130)
130
+ except Exception as e:
131
+ from htmlgraph.cli.base import save_traceback
132
+
133
+ err_console = Console(stderr=True)
134
+ log_file = save_traceback(
135
+ e, context={"command": args.command, "cwd": graph_dir}
136
+ )
137
+ err_console.print(f"[red]Error:[/red] {e}")
138
+ err_console.print(f"[dim]Full traceback saved to:[/dim] {log_file}")
139
+ sys.exit(1)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
@@ -0,0 +1,462 @@
1
+ """HtmlGraph CLI - Pydantic models for command filters and configuration.
2
+
3
+ This module provides type-safe models for validating command inputs:
4
+ - Filter models for list commands (features, sessions, tracks)
5
+ - Configuration models for infrastructure commands (init, serve)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field, field_validator
14
+
15
+ # ============================================================================
16
+ # Filter Models
17
+ # ============================================================================
18
+
19
+
20
+ class FeatureFilter(BaseModel):
21
+ """Filter options for feature listing.
22
+
23
+ Attributes:
24
+ status: Filter by status (todo, in_progress, completed, blocked, all)
25
+ priority: Filter by priority (high, medium, low, critical, all)
26
+ agent: Filter by agent name
27
+ collection: Collection name to query (default: features)
28
+ """
29
+
30
+ status: Literal["todo", "in_progress", "completed", "blocked", "all"] | None = None
31
+ priority: Literal["high", "medium", "low", "critical", "all"] | None = None
32
+ agent: str | None = None
33
+ collection: str = Field(default="features")
34
+
35
+ @field_validator("status")
36
+ @classmethod
37
+ def validate_status(cls, v: str | None) -> str | None:
38
+ """Validate status value."""
39
+ if v and v not in ["todo", "in_progress", "completed", "blocked", "all"]:
40
+ raise ValueError(
41
+ f"Invalid status: {v}. "
42
+ f"Valid values: todo, in_progress, completed, blocked, all"
43
+ )
44
+ return v
45
+
46
+ @field_validator("priority")
47
+ @classmethod
48
+ def validate_priority(cls, v: str | None) -> str | None:
49
+ """Validate priority value."""
50
+ if v and v not in ["high", "medium", "low", "critical", "all"]:
51
+ raise ValueError(
52
+ f"Invalid priority: {v}. Valid values: high, medium, low, critical, all"
53
+ )
54
+ return v
55
+
56
+
57
+ class SessionFilter(BaseModel):
58
+ """Filter options for session listing.
59
+
60
+ Attributes:
61
+ status: Filter by status (active, ended, all)
62
+ agent: Filter by agent name
63
+ since: Only show sessions since this date
64
+ """
65
+
66
+ status: Literal["active", "ended", "all"] | None = None
67
+ agent: str | None = None
68
+ since: datetime | None = None
69
+
70
+ @field_validator("status")
71
+ @classmethod
72
+ def validate_status(cls, v: str | None) -> str | None:
73
+ """Validate status value."""
74
+ if v and v not in ["active", "ended", "all"]:
75
+ raise ValueError(f"Invalid status: {v}. Valid values: active, ended, all")
76
+ return v
77
+
78
+
79
+ class TrackFilter(BaseModel):
80
+ """Filter options for track listing.
81
+
82
+ Attributes:
83
+ status: Filter by status (todo, in_progress, completed, all)
84
+ priority: Filter by priority (high, medium, low, all)
85
+ has_spec: Filter for tracks with specs
86
+ has_plan: Filter for tracks with plans
87
+ """
88
+
89
+ status: Literal["todo", "in_progress", "completed", "all"] | None = None
90
+ priority: Literal["high", "medium", "low", "all"] | None = None
91
+ has_spec: bool | None = None
92
+ has_plan: bool | None = None
93
+
94
+ @field_validator("status")
95
+ @classmethod
96
+ def validate_status(cls, v: str | None) -> str | None:
97
+ """Validate status value."""
98
+ if v and v not in ["todo", "in_progress", "completed", "all"]:
99
+ raise ValueError(
100
+ f"Invalid status: {v}. Valid values: todo, in_progress, completed, all"
101
+ )
102
+ return v
103
+
104
+ @field_validator("priority")
105
+ @classmethod
106
+ def validate_priority(cls, v: str | None) -> str | None:
107
+ """Validate priority value."""
108
+ if v and v not in ["high", "medium", "low", "all"]:
109
+ raise ValueError(
110
+ f"Invalid priority: {v}. Valid values: high, medium, low, all"
111
+ )
112
+ return v
113
+
114
+
115
+ # ============================================================================
116
+ # Configuration Models
117
+ # ============================================================================
118
+
119
+
120
+ class InitConfig(BaseModel):
121
+ """Configuration for htmlgraph init command.
122
+
123
+ Attributes:
124
+ dir: Directory to initialize (default: .)
125
+ install_hooks: Install Git hooks for event logging
126
+ interactive: Interactive setup wizard
127
+ no_index: Do not create the analytics cache (index.sqlite)
128
+ no_update_gitignore: Do not update/create .gitignore for cache files
129
+ no_events_keep: Do not create .htmlgraph/events/.gitkeep
130
+ """
131
+
132
+ dir: str = Field(default=".")
133
+ install_hooks: bool = Field(default=False)
134
+ interactive: bool = Field(default=False)
135
+ no_index: bool = Field(default=False)
136
+ no_update_gitignore: bool = Field(default=False)
137
+ no_events_keep: bool = Field(default=False)
138
+
139
+
140
+ class ServeConfig(BaseModel):
141
+ """Configuration for htmlgraph serve command.
142
+
143
+ Attributes:
144
+ port: Port to bind to (must be between 1024-65535)
145
+ host: Host to bind to (default: 0.0.0.0)
146
+ graph_dir: Graph directory path
147
+ static_dir: Static files directory
148
+ no_watch: Disable file watching (auto-reload disabled)
149
+ auto_port: Automatically find available port if default is occupied
150
+ """
151
+
152
+ port: int = Field(default=8080, ge=1024, le=65535)
153
+ host: str = Field(default="0.0.0.0")
154
+ graph_dir: str = Field(default=".htmlgraph")
155
+ static_dir: str = Field(default=".")
156
+ no_watch: bool = Field(default=False)
157
+ auto_port: bool = Field(default=False)
158
+
159
+ @field_validator("port")
160
+ @classmethod
161
+ def validate_port(cls, v: int) -> int:
162
+ """Validate port is in valid range."""
163
+ if not 1024 <= v <= 65535:
164
+ raise ValueError(f"Port must be between 1024 and 65535, got {v}")
165
+ return v
166
+
167
+ @field_validator("host")
168
+ @classmethod
169
+ def validate_host(cls, v: str) -> str:
170
+ """Validate host is not empty."""
171
+ if not v or not v.strip():
172
+ raise ValueError("Host cannot be empty")
173
+ return v.strip()
174
+
175
+
176
+ class ServeApiConfig(BaseModel):
177
+ """Configuration for htmlgraph serve-api command.
178
+
179
+ Attributes:
180
+ port: Port to bind to (must be between 1024-65535)
181
+ host: Host to bind to (default: 127.0.0.1)
182
+ db: Path to SQLite database file
183
+ auto_port: Automatically find available port if default is occupied
184
+ reload: Enable auto-reload on file changes (development mode)
185
+ """
186
+
187
+ port: int = Field(default=8000, ge=1024, le=65535)
188
+ host: str = Field(default="127.0.0.1")
189
+ db: str | None = None
190
+ auto_port: bool = Field(default=False)
191
+ reload: bool = Field(default=False)
192
+
193
+ @field_validator("port")
194
+ @classmethod
195
+ def validate_port(cls, v: int) -> int:
196
+ """Validate port is in valid range."""
197
+ if not 1024 <= v <= 65535:
198
+ raise ValueError(f"Port must be between 1024 and 65535, got {v}")
199
+ return v
200
+
201
+ @field_validator("host")
202
+ @classmethod
203
+ def validate_host(cls, v: str) -> str:
204
+ """Validate host is not empty."""
205
+ if not v or not v.strip():
206
+ raise ValueError("Host cannot be empty")
207
+ return v.strip()
208
+
209
+
210
+ # ============================================================================
211
+ # Result Models
212
+ # ============================================================================
213
+
214
+
215
+ class InitResult(BaseModel):
216
+ """Result from htmlgraph init command.
217
+
218
+ Attributes:
219
+ success: Whether initialization succeeded
220
+ graph_dir: Path to initialized .htmlgraph directory
221
+ directories_created: List of directories created
222
+ files_created: List of files created
223
+ hooks_installed: Whether Git hooks were installed
224
+ warnings: List of warning messages
225
+ errors: List of error messages
226
+ """
227
+
228
+ success: bool = Field(default=True)
229
+ graph_dir: str = Field(...)
230
+ directories_created: list[str] = Field(default_factory=list)
231
+ files_created: list[str] = Field(default_factory=list)
232
+ hooks_installed: bool = Field(default=False)
233
+ warnings: list[str] = Field(default_factory=list)
234
+ errors: list[str] = Field(default_factory=list)
235
+
236
+ @property
237
+ def summary(self) -> str:
238
+ """Human-readable summary of initialization."""
239
+ lines = [f"Initialized {self.graph_dir}"]
240
+ if self.directories_created:
241
+ lines.append(f" • Created {len(self.directories_created)} directories")
242
+ if self.files_created:
243
+ lines.append(f" • Created {len(self.files_created)} files")
244
+ if self.hooks_installed:
245
+ lines.append(" • Installed Git hooks")
246
+ if self.warnings:
247
+ lines.append(f" • {len(self.warnings)} warnings")
248
+ if self.errors:
249
+ lines.append(f" • {len(self.errors)} errors")
250
+ return "\n".join(lines)
251
+
252
+
253
+ class ValidationResult(BaseModel):
254
+ """Result from directory validation.
255
+
256
+ Attributes:
257
+ valid: Whether directory is valid for initialization
258
+ exists: Whether directory already exists
259
+ is_initialized: Whether directory is already initialized
260
+ has_git: Whether directory is in a Git repository
261
+ errors: List of validation errors
262
+ """
263
+
264
+ valid: bool = Field(default=True)
265
+ exists: bool = Field(default=False)
266
+ is_initialized: bool = Field(default=False)
267
+ has_git: bool = Field(default=False)
268
+ errors: list[str] = Field(default_factory=list)
269
+
270
+
271
+ # ============================================================================
272
+ # Display Models
273
+ # ============================================================================
274
+
275
+
276
+ class SessionDisplay(BaseModel):
277
+ """Validated session data for CLI display."""
278
+
279
+ id: str = Field(..., description="Session identifier")
280
+ status: str = Field(default="active", description="Session status")
281
+ agent: str = Field(default="unknown", description="Agent name")
282
+ event_count: int = Field(default=0, ge=0, description="Number of events")
283
+ started_at: datetime = Field(..., description="Session start time")
284
+ ended_at: datetime | None = Field(None, description="Session end time")
285
+ title: str | None = Field(None, description="Session title")
286
+
287
+ @classmethod
288
+ def from_node(cls, node: object) -> SessionDisplay:
289
+ """
290
+ Create SessionDisplay from graph node.
291
+
292
+ Args:
293
+ node: Session node from graph
294
+
295
+ Returns:
296
+ SessionDisplay instance
297
+ """
298
+ return cls(
299
+ id=getattr(node, "id"),
300
+ status=getattr(node, "status", "active"),
301
+ agent=getattr(node, "agent", "unknown"),
302
+ event_count=getattr(node, "event_count", 0),
303
+ started_at=getattr(node, "started_at"),
304
+ ended_at=getattr(node, "ended_at", None),
305
+ title=getattr(node, "title", None),
306
+ )
307
+
308
+ @property
309
+ def started_str(self) -> str:
310
+ """Formatted start time for display."""
311
+ return self.started_at.strftime("%Y-%m-%d %H:%M")
312
+
313
+ @property
314
+ def ended_str(self) -> str | None:
315
+ """Formatted end time for display."""
316
+ if self.ended_at:
317
+ return self.ended_at.strftime("%Y-%m-%d %H:%M")
318
+ return None
319
+
320
+ def sort_key(self) -> datetime:
321
+ """
322
+ Return sort key for session ordering.
323
+
324
+ Returns timezone-naive datetime for consistent sorting.
325
+ """
326
+ ts = self.started_at
327
+ if ts.tzinfo is None:
328
+ return ts
329
+ return ts.replace(tzinfo=None)
330
+
331
+
332
+ class FeatureDisplay(BaseModel):
333
+ """Validated feature data for CLI display."""
334
+
335
+ id: str = Field(..., description="Feature identifier")
336
+ title: str = Field(default="Untitled", description="Feature title")
337
+ status: str = Field(default="unknown", description="Feature status")
338
+ priority: Literal["low", "medium", "high", "critical"] = Field(
339
+ default="medium", description="Feature priority"
340
+ )
341
+ updated: datetime = Field(..., description="Last update time")
342
+ created: datetime | None = Field(None, description="Creation time")
343
+ track_id: str | None = Field(None, description="Associated track ID")
344
+ agent_assigned: str | None = Field(None, description="Assigned agent")
345
+
346
+ @classmethod
347
+ def from_node(cls, node: object) -> FeatureDisplay:
348
+ """
349
+ Create FeatureDisplay from graph node.
350
+
351
+ Args:
352
+ node: Feature node from graph
353
+
354
+ Returns:
355
+ FeatureDisplay instance
356
+ """
357
+ return cls(
358
+ id=getattr(node, "id"),
359
+ title=getattr(node, "title", "Untitled"),
360
+ status=getattr(node, "status", "unknown"),
361
+ priority=getattr(node, "priority", "medium"),
362
+ updated=getattr(node, "updated"),
363
+ created=getattr(node, "created", None),
364
+ track_id=getattr(node, "track_id", None),
365
+ agent_assigned=getattr(node, "agent_assigned", None),
366
+ )
367
+
368
+ @property
369
+ def updated_str(self) -> str:
370
+ """Formatted update time for display."""
371
+ return self.updated.strftime("%Y-%m-%d %H:%M")
372
+
373
+ @property
374
+ def created_str(self) -> str | None:
375
+ """Formatted creation time for display."""
376
+ if self.created:
377
+ return self.created.strftime("%Y-%m-%d %H:%M")
378
+ return None
379
+
380
+ def sort_key(self) -> tuple[int, datetime]:
381
+ """
382
+ Return sort key for feature ordering (priority, then updated time).
383
+
384
+ Returns:
385
+ Tuple of (priority_rank, timezone_naive_updated_time)
386
+ """
387
+ priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
388
+ priority_rank = priority_order.get(self.priority, 99)
389
+
390
+ updated_ts = self.updated
391
+ if updated_ts.tzinfo is None:
392
+ updated_naive = updated_ts
393
+ else:
394
+ updated_naive = updated_ts.replace(tzinfo=None)
395
+
396
+ return (priority_rank, updated_naive)
397
+
398
+
399
+ class TrackDisplay(BaseModel):
400
+ """Validated track data for CLI display."""
401
+
402
+ id: str = Field(..., description="Track identifier")
403
+ title: str = Field(default="Untitled", description="Track title")
404
+ status: str = Field(default="planning", description="Track status")
405
+ priority: Literal["low", "medium", "high"] = Field(
406
+ default="medium", description="Track priority"
407
+ )
408
+ has_spec: bool = Field(default=False, description="Has specification")
409
+ has_plan: bool = Field(default=False, description="Has plan")
410
+ format_type: str = Field(
411
+ default="consolidated", description="File format (consolidated or directory)"
412
+ )
413
+ feature_count: int = Field(default=0, ge=0, description="Number of features")
414
+
415
+ @classmethod
416
+ def from_track_id(
417
+ cls,
418
+ track_id: str,
419
+ has_spec: bool = False,
420
+ has_plan: bool = False,
421
+ format_type: str = "consolidated",
422
+ ) -> TrackDisplay:
423
+ """
424
+ Create TrackDisplay from track ID and metadata.
425
+
426
+ Args:
427
+ track_id: Track identifier
428
+ has_spec: Whether track has specification
429
+ has_plan: Whether track has plan
430
+ format_type: File format type
431
+
432
+ Returns:
433
+ TrackDisplay instance
434
+ """
435
+ return cls(
436
+ id=track_id,
437
+ title=track_id, # Default to ID if title not available
438
+ has_spec=has_spec,
439
+ has_plan=has_plan,
440
+ format_type=format_type,
441
+ )
442
+
443
+ @property
444
+ def components_str(self) -> str:
445
+ """Formatted components list for display."""
446
+ components = []
447
+ if self.has_spec:
448
+ components.append("spec")
449
+ if self.has_plan:
450
+ components.append("plan")
451
+ return ", ".join(components) if components else "empty"
452
+
453
+ def sort_key(self) -> tuple[int, str]:
454
+ """
455
+ Return sort key for track ordering (priority, then ID).
456
+
457
+ Returns:
458
+ Tuple of (priority_rank, track_id)
459
+ """
460
+ priority_order = {"high": 0, "medium": 1, "low": 2}
461
+ priority_rank = priority_order.get(self.priority, 99)
462
+ return (priority_rank, self.id)
@@ -0,0 +1 @@
1
+ """HTML templates for CLI output."""