doit-toolkit-cli 0.1.10__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.

Potentially problematic release.


This version of doit-toolkit-cli might be problematic. Click here for more details.

Files changed (135) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/roadmapit_command.py +10 -0
  11. doit_cli/cli/status_command.py +117 -0
  12. doit_cli/cli/sync_prompts_command.py +248 -0
  13. doit_cli/cli/validate_command.py +196 -0
  14. doit_cli/cli/verify_command.py +204 -0
  15. doit_cli/cli/workflow_mixin.py +224 -0
  16. doit_cli/cli/xref_command.py +555 -0
  17. doit_cli/formatters/__init__.py +8 -0
  18. doit_cli/formatters/base.py +38 -0
  19. doit_cli/formatters/json_formatter.py +126 -0
  20. doit_cli/formatters/markdown_formatter.py +97 -0
  21. doit_cli/formatters/rich_formatter.py +257 -0
  22. doit_cli/main.py +51 -0
  23. doit_cli/models/__init__.py +139 -0
  24. doit_cli/models/agent.py +74 -0
  25. doit_cli/models/analytics_models.py +384 -0
  26. doit_cli/models/context_config.py +464 -0
  27. doit_cli/models/crossref_models.py +182 -0
  28. doit_cli/models/diagram_models.py +363 -0
  29. doit_cli/models/fixit_models.py +355 -0
  30. doit_cli/models/hook_config.py +125 -0
  31. doit_cli/models/project.py +91 -0
  32. doit_cli/models/results.py +121 -0
  33. doit_cli/models/search_models.py +228 -0
  34. doit_cli/models/status_models.py +195 -0
  35. doit_cli/models/sync_models.py +146 -0
  36. doit_cli/models/template.py +77 -0
  37. doit_cli/models/validation_models.py +175 -0
  38. doit_cli/models/workflow_models.py +319 -0
  39. doit_cli/prompts/__init__.py +5 -0
  40. doit_cli/prompts/fixit_prompts.py +344 -0
  41. doit_cli/prompts/interactive.py +390 -0
  42. doit_cli/rules/__init__.py +5 -0
  43. doit_cli/rules/builtin_rules.py +160 -0
  44. doit_cli/services/__init__.py +79 -0
  45. doit_cli/services/agent_detector.py +168 -0
  46. doit_cli/services/analytics_service.py +218 -0
  47. doit_cli/services/architecture_generator.py +290 -0
  48. doit_cli/services/backup_service.py +204 -0
  49. doit_cli/services/config_loader.py +113 -0
  50. doit_cli/services/context_loader.py +1123 -0
  51. doit_cli/services/coverage_calculator.py +142 -0
  52. doit_cli/services/crossref_service.py +237 -0
  53. doit_cli/services/cycle_time_calculator.py +134 -0
  54. doit_cli/services/date_inferrer.py +349 -0
  55. doit_cli/services/diagram_service.py +337 -0
  56. doit_cli/services/drift_detector.py +109 -0
  57. doit_cli/services/entity_parser.py +301 -0
  58. doit_cli/services/er_diagram_generator.py +197 -0
  59. doit_cli/services/fixit_service.py +699 -0
  60. doit_cli/services/github_service.py +192 -0
  61. doit_cli/services/hook_manager.py +258 -0
  62. doit_cli/services/hook_validator.py +528 -0
  63. doit_cli/services/input_validator.py +322 -0
  64. doit_cli/services/memory_search.py +527 -0
  65. doit_cli/services/mermaid_validator.py +334 -0
  66. doit_cli/services/prompt_transformer.py +91 -0
  67. doit_cli/services/prompt_writer.py +133 -0
  68. doit_cli/services/query_interpreter.py +428 -0
  69. doit_cli/services/report_exporter.py +219 -0
  70. doit_cli/services/report_generator.py +256 -0
  71. doit_cli/services/requirement_parser.py +112 -0
  72. doit_cli/services/roadmap_summarizer.py +209 -0
  73. doit_cli/services/rule_engine.py +443 -0
  74. doit_cli/services/scaffolder.py +215 -0
  75. doit_cli/services/score_calculator.py +172 -0
  76. doit_cli/services/section_parser.py +204 -0
  77. doit_cli/services/spec_scanner.py +327 -0
  78. doit_cli/services/state_manager.py +355 -0
  79. doit_cli/services/status_reporter.py +143 -0
  80. doit_cli/services/task_parser.py +347 -0
  81. doit_cli/services/template_manager.py +710 -0
  82. doit_cli/services/template_reader.py +158 -0
  83. doit_cli/services/user_journey_generator.py +214 -0
  84. doit_cli/services/user_story_parser.py +232 -0
  85. doit_cli/services/validation_service.py +188 -0
  86. doit_cli/services/validator.py +232 -0
  87. doit_cli/services/velocity_tracker.py +173 -0
  88. doit_cli/services/workflow_engine.py +405 -0
  89. doit_cli/templates/agent-file-template.md +28 -0
  90. doit_cli/templates/checklist-template.md +39 -0
  91. doit_cli/templates/commands/doit.checkin.md +363 -0
  92. doit_cli/templates/commands/doit.constitution.md +187 -0
  93. doit_cli/templates/commands/doit.documentit.md +485 -0
  94. doit_cli/templates/commands/doit.fixit.md +181 -0
  95. doit_cli/templates/commands/doit.implementit.md +265 -0
  96. doit_cli/templates/commands/doit.planit.md +262 -0
  97. doit_cli/templates/commands/doit.reviewit.md +355 -0
  98. doit_cli/templates/commands/doit.roadmapit.md +389 -0
  99. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  100. doit_cli/templates/commands/doit.specit.md +521 -0
  101. doit_cli/templates/commands/doit.taskit.md +304 -0
  102. doit_cli/templates/commands/doit.testit.md +277 -0
  103. doit_cli/templates/config/context.yaml +134 -0
  104. doit_cli/templates/config/hooks.yaml +93 -0
  105. doit_cli/templates/config/validation-rules.yaml +64 -0
  106. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  107. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  108. doit_cli/templates/github-issue-templates/task.yml +129 -0
  109. doit_cli/templates/hooks/.gitkeep +0 -0
  110. doit_cli/templates/hooks/post-commit.sh +25 -0
  111. doit_cli/templates/hooks/post-merge.sh +75 -0
  112. doit_cli/templates/hooks/pre-commit.sh +17 -0
  113. doit_cli/templates/hooks/pre-push.sh +18 -0
  114. doit_cli/templates/memory/completed_roadmap.md +50 -0
  115. doit_cli/templates/memory/constitution.md +125 -0
  116. doit_cli/templates/memory/roadmap.md +61 -0
  117. doit_cli/templates/plan-template.md +146 -0
  118. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  119. doit_cli/templates/scripts/bash/common.sh +156 -0
  120. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  121. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  122. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  123. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  124. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  125. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  126. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  127. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  128. doit_cli/templates/spec-template.md +159 -0
  129. doit_cli/templates/tasks-template.md +313 -0
  130. doit_cli/templates/vscode-settings.json +14 -0
  131. doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
  132. doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
  133. doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
  134. doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
  135. doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,228 @@
1
+ """Search models for memory search and query functionality.
2
+
3
+ This module provides data models for the memory search feature, including
4
+ query types, search results, and memory sources.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ import uuid
13
+
14
+
15
+ class QueryType(str, Enum):
16
+ """Type of search query."""
17
+
18
+ KEYWORD = "keyword" # Simple word/phrase search
19
+ PHRASE = "phrase" # Exact phrase (quoted)
20
+ NATURAL = "natural" # Natural language question
21
+ REGEX = "regex" # Regular expression
22
+
23
+
24
+ class SourceType(str, Enum):
25
+ """Type of memory source file."""
26
+
27
+ GOVERNANCE = "governance" # constitution, roadmap, completed_roadmap
28
+ SPEC = "spec" # spec.md files
29
+
30
+
31
+ class SourceFilter(str, Enum):
32
+ """Filter for source types to search."""
33
+
34
+ ALL = "all" # Search everything
35
+ GOVERNANCE = "governance" # Only governance files
36
+ SPECS = "specs" # Only spec files
37
+
38
+
39
+ @dataclass
40
+ class SearchQuery:
41
+ """Represents a user's search request with all parameters.
42
+
43
+ Attributes:
44
+ id: Unique identifier (UUID)
45
+ query_text: The search term or natural language question
46
+ query_type: Type of query (KEYWORD, PHRASE, NATURAL, REGEX)
47
+ timestamp: When the query was executed
48
+ source_filter: Filter to specific source types (default: ALL)
49
+ max_results: Maximum results to return (default: 20)
50
+ case_sensitive: Case-sensitive matching (default: False)
51
+ use_regex: Interpret query as regex (default: False)
52
+ """
53
+
54
+ query_text: str
55
+ query_type: QueryType = QueryType.KEYWORD
56
+ timestamp: datetime = field(default_factory=datetime.now)
57
+ source_filter: SourceFilter = SourceFilter.ALL
58
+ max_results: int = 20
59
+ case_sensitive: bool = False
60
+ use_regex: bool = False
61
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
62
+
63
+ def __post_init__(self):
64
+ """Validate query after initialization."""
65
+ if not self.query_text or not self.query_text.strip():
66
+ raise ValueError("Query text cannot be empty")
67
+ if len(self.query_text) > 500:
68
+ raise ValueError("Query text exceeds maximum length of 500 characters")
69
+ if not 1 <= self.max_results <= 100:
70
+ raise ValueError("Max results must be between 1 and 100")
71
+
72
+
73
+ @dataclass
74
+ class SearchResult:
75
+ """A single search match with context and scoring.
76
+
77
+ Attributes:
78
+ id: Unique identifier
79
+ query_id: Reference to parent query
80
+ source_id: Reference to source file
81
+ relevance_score: Score between 0.0 and 1.0
82
+ line_number: Line where match was found
83
+ matched_text: The actual matched text
84
+ context_before: Lines before the match
85
+ context_after: Lines after the match
86
+ """
87
+
88
+ query_id: str
89
+ source_id: str
90
+ relevance_score: float
91
+ line_number: int
92
+ matched_text: str
93
+ context_before: str = ""
94
+ context_after: str = ""
95
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
96
+
97
+ def __post_init__(self):
98
+ """Validate result after initialization."""
99
+ if not 0.0 <= self.relevance_score <= 1.0:
100
+ raise ValueError("Relevance score must be between 0.0 and 1.0")
101
+
102
+
103
+ @dataclass
104
+ class MemorySource:
105
+ """A searchable file in the project memory.
106
+
107
+ Attributes:
108
+ id: Unique identifier (file path hash)
109
+ file_path: Absolute path to the file
110
+ source_type: Classification (GOVERNANCE, SPEC)
111
+ last_modified: File modification timestamp
112
+ line_count: Total lines in file
113
+ token_count: Estimated token count
114
+ """
115
+
116
+ file_path: Path
117
+ source_type: SourceType
118
+ last_modified: datetime = field(default_factory=datetime.now)
119
+ line_count: int = 0
120
+ token_count: int = 0
121
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
122
+
123
+ @classmethod
124
+ def from_path(cls, path: Path, source_type: SourceType) -> "MemorySource":
125
+ """Create a MemorySource from a file path.
126
+
127
+ Args:
128
+ path: Path to the file
129
+ source_type: Type of source (governance or spec)
130
+
131
+ Returns:
132
+ MemorySource instance
133
+ """
134
+ import hashlib
135
+
136
+ file_id = hashlib.md5(str(path).encode()).hexdigest()[:16]
137
+ last_modified = datetime.fromtimestamp(path.stat().st_mtime)
138
+
139
+ content = path.read_text(encoding="utf-8")
140
+ line_count = len(content.splitlines())
141
+
142
+ # Estimate tokens (approximately 4 chars per token)
143
+ token_count = max(1, len(content) // 4)
144
+
145
+ return cls(
146
+ id=file_id,
147
+ file_path=path,
148
+ source_type=source_type,
149
+ last_modified=last_modified,
150
+ line_count=line_count,
151
+ token_count=token_count,
152
+ )
153
+
154
+
155
+ @dataclass
156
+ class ContentSnippet:
157
+ """A portion of text extracted for display.
158
+
159
+ Attributes:
160
+ id: Unique identifier
161
+ source_id: Reference to source file
162
+ content: The snippet text
163
+ start_line: First line number
164
+ end_line: Last line number
165
+ highlights: Character positions to highlight [(start, end), ...]
166
+ """
167
+
168
+ source_id: str
169
+ content: str
170
+ start_line: int
171
+ end_line: int
172
+ highlights: list[tuple[int, int]] = field(default_factory=list)
173
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
174
+
175
+ MAX_CONTENT_LENGTH = 1000
176
+
177
+ def __post_init__(self):
178
+ """Validate and truncate content if needed."""
179
+ if len(self.content) > self.MAX_CONTENT_LENGTH:
180
+ # Truncate at word boundary if possible
181
+ truncated = self.content[: self.MAX_CONTENT_LENGTH]
182
+ last_space = truncated.rfind(" ")
183
+ if last_space > self.MAX_CONTENT_LENGTH - 100:
184
+ truncated = truncated[:last_space]
185
+ self.content = truncated + "..."
186
+
187
+
188
+ @dataclass
189
+ class SearchHistory:
190
+ """Session-scoped history of queries.
191
+
192
+ Attributes:
193
+ session_id: Unique session identifier
194
+ session_start: When session began
195
+ entries: List of past queries
196
+ max_entries: Maximum entries to keep (default: 10)
197
+ """
198
+
199
+ session_start: datetime = field(default_factory=datetime.now)
200
+ entries: list[SearchQuery] = field(default_factory=list)
201
+ max_entries: int = 10
202
+ session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
203
+
204
+ def add_query(self, query: SearchQuery) -> None:
205
+ """Add a query to history, enforcing max entries.
206
+
207
+ Args:
208
+ query: The query to add
209
+ """
210
+ self.entries.append(query)
211
+ # FIFO when limit reached
212
+ while len(self.entries) > self.max_entries:
213
+ self.entries.pop(0)
214
+
215
+ def clear(self) -> None:
216
+ """Clear all history entries."""
217
+ self.entries.clear()
218
+
219
+ def get_recent(self, count: int = 10) -> list[SearchQuery]:
220
+ """Get most recent queries.
221
+
222
+ Args:
223
+ count: Number of queries to return
224
+
225
+ Returns:
226
+ List of most recent queries (newest first)
227
+ """
228
+ return list(reversed(self.entries[-count:]))
@@ -0,0 +1,195 @@
1
+ """Models for spec status dashboard."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .validation_models import ValidationResult
10
+
11
+
12
+ class SpecState(str, Enum):
13
+ """Valid specification statuses parsed from spec.md frontmatter.
14
+
15
+ Values:
16
+ DRAFT: Initial state, not yet validated by git hooks
17
+ IN_PROGRESS: Active development, validated on commit
18
+ COMPLETE: Implementation finished, pending approval
19
+ APPROVED: Fully approved and merged
20
+ ERROR: Could not parse status from file
21
+ """
22
+
23
+ DRAFT = "draft"
24
+ IN_PROGRESS = "in_progress"
25
+ COMPLETE = "complete"
26
+ APPROVED = "approved"
27
+ ERROR = "error"
28
+
29
+ @classmethod
30
+ def from_string(cls, value: str) -> "SpecState":
31
+ """Parse a status string into a SpecState enum.
32
+
33
+ Args:
34
+ value: Status string from spec file (e.g., "Draft", "In Progress")
35
+
36
+ Returns:
37
+ Corresponding SpecState enum value, or ERROR if not recognized.
38
+ """
39
+ # Normalize: lowercase and replace spaces with underscores
40
+ normalized = value.lower().strip().replace(" ", "_")
41
+
42
+ # Map common variations
43
+ mapping = {
44
+ "draft": cls.DRAFT,
45
+ "in_progress": cls.IN_PROGRESS,
46
+ "inprogress": cls.IN_PROGRESS,
47
+ "in-progress": cls.IN_PROGRESS,
48
+ "complete": cls.COMPLETE,
49
+ "completed": cls.COMPLETE,
50
+ "approved": cls.APPROVED,
51
+ "done": cls.APPROVED,
52
+ }
53
+
54
+ return mapping.get(normalized, cls.ERROR)
55
+
56
+ @property
57
+ def display_name(self) -> str:
58
+ """Human-readable display name for the status."""
59
+ names = {
60
+ SpecState.DRAFT: "Draft",
61
+ SpecState.IN_PROGRESS: "In Progress",
62
+ SpecState.COMPLETE: "Complete",
63
+ SpecState.APPROVED: "Approved",
64
+ SpecState.ERROR: "Error",
65
+ }
66
+ return names.get(self, "Unknown")
67
+
68
+ @property
69
+ def emoji(self) -> str:
70
+ """Emoji representation for terminal display."""
71
+ emojis = {
72
+ SpecState.DRAFT: "📝",
73
+ SpecState.IN_PROGRESS: "🔄",
74
+ SpecState.COMPLETE: "✅",
75
+ SpecState.APPROVED: "🏆",
76
+ SpecState.ERROR: "❌",
77
+ }
78
+ return emojis.get(self, "❓")
79
+
80
+
81
+ @dataclass
82
+ class SpecStatus:
83
+ """Represents the parsed status of a single specification.
84
+
85
+ Attributes:
86
+ name: Spec name derived from directory name (e.g., "032-status-dashboard")
87
+ path: Full path to the spec.md file
88
+ status: Current status (Draft, In Progress, Complete, Approved)
89
+ last_modified: File modification timestamp
90
+ validation_result: Result from SpecValidator (if validated)
91
+ is_blocking: Whether this spec would block commits
92
+ error: Parse error message if spec couldn't be read
93
+ """
94
+
95
+ name: str
96
+ path: Path
97
+ status: SpecState
98
+ last_modified: datetime
99
+ validation_result: Optional[ValidationResult] = None
100
+ is_blocking: bool = False
101
+ error: Optional[str] = None
102
+
103
+ @property
104
+ def validation_passed(self) -> bool:
105
+ """Check if validation passed (or wasn't run)."""
106
+ if self.validation_result is None:
107
+ return True # No validation = no failures
108
+ return self.validation_result.error_count == 0
109
+
110
+ @property
111
+ def validation_score(self) -> int:
112
+ """Get validation quality score (0-100)."""
113
+ if self.validation_result is None:
114
+ return 100
115
+ return self.validation_result.quality_score
116
+
117
+
118
+ @dataclass
119
+ class StatusReport:
120
+ """Aggregated report containing all spec statuses and summary statistics.
121
+
122
+ Attributes:
123
+ specs: All parsed spec statuses
124
+ generated_at: Report generation timestamp
125
+ project_root: Root directory of the project
126
+ """
127
+
128
+ specs: list[SpecStatus] = field(default_factory=list)
129
+ generated_at: datetime = field(default_factory=datetime.now)
130
+ project_root: Path = field(default_factory=Path.cwd)
131
+
132
+ @property
133
+ def total_count(self) -> int:
134
+ """Total number of specs."""
135
+ return len(self.specs)
136
+
137
+ @property
138
+ def by_status(self) -> dict[SpecState, int]:
139
+ """Count of specs grouped by status."""
140
+ counts: dict[SpecState, int] = {state: 0 for state in SpecState}
141
+ for spec in self.specs:
142
+ counts[spec.status] = counts.get(spec.status, 0) + 1
143
+ return counts
144
+
145
+ @property
146
+ def blocking_count(self) -> int:
147
+ """Number of specs blocking commits."""
148
+ return sum(1 for spec in self.specs if spec.is_blocking)
149
+
150
+ @property
151
+ def validation_pass_count(self) -> int:
152
+ """Specs passing validation."""
153
+ return sum(1 for spec in self.specs if spec.validation_passed)
154
+
155
+ @property
156
+ def validation_fail_count(self) -> int:
157
+ """Specs failing validation."""
158
+ return sum(1 for spec in self.specs if not spec.validation_passed)
159
+
160
+ @property
161
+ def completion_percentage(self) -> float:
162
+ """Percentage of Complete + Approved specs."""
163
+ if self.total_count == 0:
164
+ return 0.0
165
+ complete_count = sum(
166
+ 1
167
+ for spec in self.specs
168
+ if spec.status in (SpecState.COMPLETE, SpecState.APPROVED)
169
+ )
170
+ return (complete_count / self.total_count) * 100
171
+
172
+ @property
173
+ def is_ready_to_commit(self) -> bool:
174
+ """True if no blocking specs."""
175
+ return self.blocking_count == 0
176
+
177
+ @property
178
+ def draft_count(self) -> int:
179
+ """Number of draft specs."""
180
+ return self.by_status.get(SpecState.DRAFT, 0)
181
+
182
+ @property
183
+ def in_progress_count(self) -> int:
184
+ """Number of in-progress specs."""
185
+ return self.by_status.get(SpecState.IN_PROGRESS, 0)
186
+
187
+ @property
188
+ def complete_count(self) -> int:
189
+ """Number of complete specs."""
190
+ return self.by_status.get(SpecState.COMPLETE, 0)
191
+
192
+ @property
193
+ def approved_count(self) -> int:
194
+ """Number of approved specs."""
195
+ return self.by_status.get(SpecState.APPROVED, 0)
@@ -0,0 +1,146 @@
1
+ """Data models for GitHub Copilot prompt synchronization."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from pathlib import Path
7
+
8
+
9
+ class SyncStatusEnum(str, Enum):
10
+ """Synchronization status between command and prompt."""
11
+
12
+ SYNCHRONIZED = "synchronized"
13
+ OUT_OF_SYNC = "out_of_sync"
14
+ MISSING = "missing"
15
+
16
+
17
+ class OperationType(str, Enum):
18
+ """Type of file operation during sync."""
19
+
20
+ CREATED = "created"
21
+ UPDATED = "updated"
22
+ SKIPPED = "skipped"
23
+ FAILED = "failed"
24
+
25
+
26
+ @dataclass
27
+ class CommandTemplate:
28
+ """Represents a doit command template file in .doit/templates/commands/."""
29
+
30
+ name: str
31
+ path: Path
32
+ modified_at: datetime
33
+ description: str = ""
34
+ content: str = field(default="", repr=False)
35
+
36
+ @property
37
+ def prompt_filename(self) -> str:
38
+ """Generate the corresponding prompt filename."""
39
+ # doit.checkin.md -> doit.checkin.prompt.md
40
+ return f"{self.name}.prompt.md"
41
+
42
+ @classmethod
43
+ def from_path(cls, path: Path) -> "CommandTemplate":
44
+ """Create a CommandTemplate from a file path."""
45
+ content = path.read_text(encoding="utf-8")
46
+ modified_at = datetime.fromtimestamp(path.stat().st_mtime)
47
+
48
+ # Extract name from filename: doit.checkin.md -> doit.checkin
49
+ name = path.stem # removes .md extension
50
+
51
+ # Extract description from YAML frontmatter
52
+ description = ""
53
+ if content.startswith("---"):
54
+ try:
55
+ end_idx = content.index("---", 3)
56
+ frontmatter = content[3:end_idx].strip()
57
+ for line in frontmatter.split("\n"):
58
+ if line.startswith("description:"):
59
+ description = line.replace("description:", "").strip()
60
+ # Remove quotes if present
61
+ description = description.strip("\"'")
62
+ break
63
+ except ValueError:
64
+ pass # No closing ---
65
+
66
+ return cls(
67
+ name=name,
68
+ path=path,
69
+ modified_at=modified_at,
70
+ description=description,
71
+ content=content,
72
+ )
73
+
74
+
75
+ @dataclass
76
+ class PromptFile:
77
+ """Represents a generated GitHub Copilot prompt file in .github/prompts/."""
78
+
79
+ name: str
80
+ path: Path
81
+ generated_at: datetime
82
+ content: str = field(default="", repr=False)
83
+
84
+ @classmethod
85
+ def from_path(cls, path: Path) -> "PromptFile":
86
+ """Create a PromptFile from an existing file path."""
87
+ content = path.read_text(encoding="utf-8")
88
+ generated_at = datetime.fromtimestamp(path.stat().st_mtime)
89
+
90
+ # Extract name from filename: doit.checkin.prompt.md -> doit.checkin.prompt
91
+ name = path.name.replace(".md", "")
92
+
93
+ return cls(
94
+ name=name,
95
+ path=path,
96
+ generated_at=generated_at,
97
+ content=content,
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class SyncStatus:
103
+ """Represents the synchronization state between a command and its prompt."""
104
+
105
+ command_name: str
106
+ status: SyncStatusEnum
107
+ checked_at: datetime
108
+ reason: str = ""
109
+
110
+
111
+ @dataclass
112
+ class FileOperation:
113
+ """Represents a single file operation during sync."""
114
+
115
+ file_path: str
116
+ operation_type: OperationType
117
+ success: bool
118
+ message: str = ""
119
+
120
+
121
+ @dataclass
122
+ class SyncResult:
123
+ """Represents the result of a synchronization operation."""
124
+
125
+ total_commands: int = 0
126
+ synced: int = 0
127
+ skipped: int = 0
128
+ failed: int = 0
129
+ operations: list[FileOperation] = field(default_factory=list)
130
+
131
+ def add_operation(self, operation: FileOperation) -> None:
132
+ """Add a file operation to the result."""
133
+ self.operations.append(operation)
134
+ if operation.operation_type == OperationType.CREATED:
135
+ self.synced += 1
136
+ elif operation.operation_type == OperationType.UPDATED:
137
+ self.synced += 1
138
+ elif operation.operation_type == OperationType.SKIPPED:
139
+ self.skipped += 1
140
+ elif operation.operation_type == OperationType.FAILED:
141
+ self.failed += 1
142
+
143
+ @property
144
+ def success(self) -> bool:
145
+ """Check if sync completed without failures."""
146
+ return self.failed == 0
@@ -0,0 +1,77 @@
1
+ """Template model representing a bundled command template."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import hashlib
6
+
7
+ from .agent import Agent
8
+
9
+
10
+ # List of all doit command names (without extension/prefix)
11
+ DOIT_COMMANDS = [
12
+ "checkin",
13
+ "constitution",
14
+ "documentit",
15
+ "fixit",
16
+ "implementit",
17
+ "planit",
18
+ "reviewit",
19
+ "roadmapit",
20
+ "scaffoldit",
21
+ "specit",
22
+ "taskit",
23
+ "testit",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class Template:
29
+ """Represents a bundled command template."""
30
+
31
+ name: str
32
+ agent: Agent
33
+ source_path: Path
34
+ content: str = field(default="", repr=False)
35
+
36
+ @property
37
+ def content_hash(self) -> str:
38
+ """SHA-256 hash of template content (first 12 chars)."""
39
+ return hashlib.sha256(self.content.encode()).hexdigest()[:12]
40
+
41
+ @property
42
+ def target_filename(self) -> str:
43
+ """Filename for the generated command file."""
44
+ if self.agent == Agent.CLAUDE:
45
+ return f"doit.{self.name}.md"
46
+ else: # COPILOT
47
+ return f"doit.{self.name}.prompt.md"
48
+
49
+ @classmethod
50
+ def from_file(cls, path: Path, agent: Agent) -> "Template":
51
+ """Create a Template from a file path."""
52
+ content = path.read_text(encoding="utf-8")
53
+
54
+ # Extract command name from filename
55
+ filename = path.name
56
+ if agent == Agent.CLAUDE:
57
+ # doit.specit.md -> specit
58
+ name = filename.replace("doit.", "").replace(".md", "")
59
+ else: # COPILOT
60
+ # doit.specit.prompt.md -> specit
61
+ name = filename.replace("doit.", "").replace(".prompt.md", "")
62
+
63
+ return cls(name=name, agent=agent, source_path=path, content=content)
64
+
65
+ def matches_target(self, target_path: Path) -> bool:
66
+ """Check if target file exists and matches this template's content."""
67
+ if not target_path.exists():
68
+ return False
69
+
70
+ target_content = target_path.read_text(encoding="utf-8")
71
+ target_hash = hashlib.sha256(target_content.encode()).hexdigest()[:12]
72
+ return target_hash == self.content_hash
73
+
74
+
75
+ def get_required_templates() -> list[str]:
76
+ """Return list of required template names."""
77
+ return DOIT_COMMANDS.copy()