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.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/roadmapit_command.py +10 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +51 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1123 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +389 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
- doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
- 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()
|