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,158 @@
1
+ """Service to read and scan doit command templates."""
2
+
3
+ import importlib.resources
4
+ from pathlib import Path
5
+
6
+ from ..models.sync_models import CommandTemplate
7
+
8
+
9
+ class TemplateReader:
10
+ """Reads command templates from bundled or project templates directory.
11
+
12
+ Supports three template locations (checked in order):
13
+ 1. CLI repo development: src/doit_cli/templates/commands/
14
+ 2. Installed package: via importlib.resources (doit_cli.templates.commands)
15
+ 3. Project templates: .doit/templates/commands/ (for end-user projects)
16
+
17
+ When running from an installed package, bundled templates are used.
18
+ """
19
+
20
+ BUNDLED_TEMPLATES_DIR = "src/doit_cli/templates/commands"
21
+ PROJECT_TEMPLATES_DIR = ".doit/templates/commands"
22
+ COMMAND_PATTERN = "doit.*.md"
23
+
24
+ def __init__(self, project_root: Path | None = None):
25
+ """Initialize the template reader.
26
+
27
+ Args:
28
+ project_root: Root directory of the project. Defaults to current directory.
29
+ """
30
+ self.project_root = project_root or Path.cwd()
31
+ self.templates_dir = self._resolve_templates_directory()
32
+
33
+ def _resolve_templates_directory(self) -> Path:
34
+ """Resolve the templates directory.
35
+
36
+ Priority order:
37
+ 1. Project templates (.doit/templates/commands/) - allows customization
38
+ 2. CLI repo development (src/doit_cli/templates/commands/)
39
+ 3. Installed package templates (via importlib.resources)
40
+
41
+ Returns:
42
+ Path to the templates directory to use.
43
+ """
44
+ # First, check for project templates (allows customization)
45
+ project_dir = self.project_root / self.PROJECT_TEMPLATES_DIR
46
+ if project_dir.exists() and any(project_dir.glob(self.COMMAND_PATTERN)):
47
+ return project_dir
48
+
49
+ # Second, check for CLI repo development templates
50
+ bundled_dir = self.project_root / self.BUNDLED_TEMPLATES_DIR
51
+ if bundled_dir.exists() and any(bundled_dir.glob(self.COMMAND_PATTERN)):
52
+ return bundled_dir
53
+
54
+ # Third, try to find templates from installed package
55
+ package_templates = self._get_package_templates_directory()
56
+ if package_templates and package_templates.exists():
57
+ if any(package_templates.glob(self.COMMAND_PATTERN)):
58
+ return package_templates
59
+
60
+ # Fall back to project templates path (will be empty/not exist)
61
+ return project_dir
62
+
63
+ def _get_package_templates_directory(self) -> Path | None:
64
+ """Get the templates directory from the installed package.
65
+
66
+ Returns:
67
+ Path to bundled templates directory, or None if not found.
68
+ """
69
+ try:
70
+ # Use importlib.resources to find package templates
71
+ # Get the actual path from the package location
72
+ pkg_files = importlib.resources.files("doit_cli")
73
+ templates_path = pkg_files.joinpath("templates/commands")
74
+
75
+ # Check if it's a real path we can use
76
+ if hasattr(templates_path, "_path"):
77
+ # Direct filesystem path (editable install or source)
78
+ return Path(templates_path._path)
79
+
80
+ # Try to get path via traversable
81
+ # For installed packages, get the actual location
82
+ import doit_cli
83
+ pkg_location = Path(doit_cli.__file__).parent
84
+ templates_dir = pkg_location / "templates" / "commands"
85
+ if templates_dir.exists():
86
+ return templates_dir
87
+
88
+ return None
89
+ except (ImportError, TypeError, AttributeError, FileNotFoundError):
90
+ return None
91
+
92
+ def get_templates_directory(self) -> Path:
93
+ """Get the templates directory path."""
94
+ return self.templates_dir
95
+
96
+ def is_using_bundled_templates(self) -> bool:
97
+ """Check if using bundled templates (CLI repo) vs project templates.
98
+
99
+ Returns:
100
+ True if using bundled templates, False if using project templates.
101
+ """
102
+ return self.BUNDLED_TEMPLATES_DIR in str(self.templates_dir)
103
+
104
+ def scan_templates(self, filter_name: str | None = None) -> list[CommandTemplate]:
105
+ """Scan for command templates in the templates directory.
106
+
107
+ Args:
108
+ filter_name: Optional command name to filter by (e.g., "doit.checkin").
109
+
110
+ Returns:
111
+ List of CommandTemplate objects found.
112
+ """
113
+ if not self.templates_dir.exists():
114
+ return []
115
+
116
+ templates = []
117
+ pattern = self.COMMAND_PATTERN
118
+
119
+ for path in sorted(self.templates_dir.glob(pattern)):
120
+ if not path.is_file():
121
+ continue
122
+
123
+ # Apply name filter if specified
124
+ if filter_name:
125
+ # Allow matching with or without .md extension
126
+ filter_stem = filter_name.replace(".md", "")
127
+ if path.stem != filter_stem:
128
+ continue
129
+
130
+ try:
131
+ template = CommandTemplate.from_path(path)
132
+ templates.append(template)
133
+ except (OSError, UnicodeDecodeError) as e:
134
+ # Log error but continue with other templates
135
+ print(f"Warning: Could not read template {path}: {e}")
136
+
137
+ return templates
138
+
139
+ def get_template(self, name: str) -> CommandTemplate | None:
140
+ """Get a specific command template by name.
141
+
142
+ Args:
143
+ name: Command name (e.g., "doit.checkin" or "doit.checkin.md").
144
+
145
+ Returns:
146
+ CommandTemplate if found, None otherwise.
147
+ """
148
+ templates = self.scan_templates(filter_name=name)
149
+ return templates[0] if templates else None
150
+
151
+ def list_template_names(self) -> list[str]:
152
+ """List all available template names.
153
+
154
+ Returns:
155
+ List of template names (e.g., ["doit.checkin", "doit.specit", ...]).
156
+ """
157
+ templates = self.scan_templates()
158
+ return [t.name for t in templates]
@@ -0,0 +1,214 @@
1
+ """Generator for User Journey flowchart diagrams from user stories."""
2
+
3
+ from ..models.diagram_models import GeneratedDiagram, DiagramType, ParsedUserStory
4
+
5
+
6
+ class UserJourneyGenerator:
7
+ """Generates Mermaid flowchart diagrams from user stories.
8
+
9
+ Converts ParsedUserStory objects into a flowchart with subgraphs
10
+ per story and nodes for each acceptance scenario.
11
+ """
12
+
13
+ def __init__(self, direction: str = "LR"):
14
+ """Initialize generator.
15
+
16
+ Args:
17
+ direction: Flowchart direction (LR, TB, RL, BT)
18
+ """
19
+ self.direction = direction
20
+
21
+ def generate(self, stories: list[ParsedUserStory]) -> str:
22
+ """Generate Mermaid flowchart from user stories.
23
+
24
+ Args:
25
+ stories: List of parsed user stories
26
+
27
+ Returns:
28
+ Mermaid flowchart syntax
29
+ """
30
+ if not stories:
31
+ return ""
32
+
33
+ lines = [f"flowchart {self.direction}"]
34
+
35
+ for story in stories:
36
+ story_lines = self._generate_story_subgraph(story)
37
+ lines.extend(story_lines)
38
+
39
+ # Add story connections (story to story flow)
40
+ connection_lines = self._generate_story_connections(stories)
41
+ if connection_lines:
42
+ lines.append("")
43
+ lines.extend(connection_lines)
44
+
45
+ return "\n".join(lines)
46
+
47
+ def generate_diagram(self, stories: list[ParsedUserStory]) -> GeneratedDiagram:
48
+ """Generate a GeneratedDiagram object from user stories.
49
+
50
+ Args:
51
+ stories: List of parsed user stories
52
+
53
+ Returns:
54
+ GeneratedDiagram with content and metadata
55
+ """
56
+ content = self.generate(stories)
57
+
58
+ # Count nodes
59
+ node_count = sum(
60
+ len(story.scenarios) + 1 for story in stories # +1 for entry node
61
+ )
62
+
63
+ return GeneratedDiagram(
64
+ id="user-journey",
65
+ diagram_type=DiagramType.USER_JOURNEY,
66
+ mermaid_content=content,
67
+ is_valid=True,
68
+ node_count=node_count,
69
+ )
70
+
71
+ def _generate_story_subgraph(self, story: ParsedUserStory) -> list[str]:
72
+ """Generate subgraph for a single user story.
73
+
74
+ Args:
75
+ story: Parsed user story
76
+
77
+ Returns:
78
+ List of Mermaid syntax lines
79
+ """
80
+ lines = []
81
+ lines.append(f' subgraph {story.subgraph_id}["{story.subgraph_label}"]')
82
+
83
+ if not story.scenarios:
84
+ # No scenarios - create a single placeholder node
85
+ node_id = f"{story.subgraph_id}_A"
86
+ lines.append(f' {node_id}["{self._escape_label(story.title)}"]')
87
+ else:
88
+ # Create nodes for each scenario
89
+ prev_node_id = None
90
+ for i, scenario in enumerate(story.scenarios):
91
+ letter = chr(ord("A") + i)
92
+ node_id = f"{story.subgraph_id}_{letter}"
93
+
94
+ # Generate node based on scenario
95
+ given_label = self._truncate_label(scenario.given_clause, 50)
96
+ when_label = self._truncate_label(scenario.when_clause, 50)
97
+ then_label = self._truncate_label(scenario.then_clause, 50)
98
+
99
+ # Create nodes for Given, When, Then
100
+ given_node = f"{node_id}_G"
101
+ when_node = f"{node_id}_W"
102
+ then_node = f"{node_id}_T"
103
+
104
+ lines.append(f' {given_node}["{self._escape_label(given_label)}"]')
105
+ lines.append(f' {when_node}{{"{self._escape_label(when_label)}"}}')
106
+ lines.append(f' {then_node}["{self._escape_label(then_label)}"]')
107
+
108
+ # Connect Given -> When -> Then
109
+ lines.append(f" {given_node} --> {when_node}")
110
+ lines.append(f" {when_node} --> {then_node}")
111
+
112
+ # Connect to previous scenario
113
+ if prev_node_id:
114
+ lines.append(f" {prev_node_id} -.-> {given_node}")
115
+
116
+ prev_node_id = then_node
117
+
118
+ lines.append(" end")
119
+ return lines
120
+
121
+ def _generate_story_connections(self, stories: list[ParsedUserStory]) -> list[str]:
122
+ """Generate connections between story subgraphs.
123
+
124
+ Args:
125
+ stories: List of parsed user stories
126
+
127
+ Returns:
128
+ List of connection lines
129
+ """
130
+ if len(stories) <= 1:
131
+ return []
132
+
133
+ lines = []
134
+ lines.append(" %% Story flow connections")
135
+
136
+ for i in range(len(stories) - 1):
137
+ current_story = stories[i]
138
+ next_story = stories[i + 1]
139
+
140
+ # Connect last node of current story to first node of next
141
+ if current_story.scenarios:
142
+ last_letter = chr(ord("A") + len(current_story.scenarios) - 1)
143
+ current_end = f"{current_story.subgraph_id}_{last_letter}_T"
144
+ else:
145
+ current_end = f"{current_story.subgraph_id}_A"
146
+
147
+ if next_story.scenarios:
148
+ next_start = f"{next_story.subgraph_id}_A_G"
149
+ else:
150
+ next_start = f"{next_story.subgraph_id}_A"
151
+
152
+ # Use dotted line for cross-story connections
153
+ lines.append(f" {current_end} ==> {next_start}")
154
+
155
+ return lines
156
+
157
+ def _truncate_label(self, text: str, max_length: int = 40) -> str:
158
+ """Truncate label to maximum length.
159
+
160
+ Args:
161
+ text: Original label text
162
+ max_length: Maximum characters
163
+
164
+ Returns:
165
+ Truncated text with ellipsis if needed
166
+ """
167
+ if len(text) <= max_length:
168
+ return text
169
+ return text[: max_length - 3] + "..."
170
+
171
+ def _escape_label(self, text: str) -> str:
172
+ """Escape special characters for Mermaid labels.
173
+
174
+ Args:
175
+ text: Original label text
176
+
177
+ Returns:
178
+ Escaped text safe for Mermaid
179
+ """
180
+ # Escape quotes
181
+ text = text.replace('"', "'")
182
+ # Escape brackets that could be misinterpreted
183
+ text = text.replace("[", "(")
184
+ text = text.replace("]", ")")
185
+ # Remove newlines
186
+ text = text.replace("\n", " ")
187
+ # Clean up whitespace
188
+ text = " ".join(text.split())
189
+ return text
190
+
191
+ def generate_simple(self, stories: list[ParsedUserStory]) -> str:
192
+ """Generate a simplified flowchart with one node per story.
193
+
194
+ Args:
195
+ stories: List of parsed user stories
196
+
197
+ Returns:
198
+ Simplified Mermaid flowchart syntax
199
+ """
200
+ if not stories:
201
+ return ""
202
+
203
+ lines = [f"flowchart {self.direction}"]
204
+
205
+ for i, story in enumerate(stories):
206
+ node_id = story.subgraph_id
207
+ label = f"{story.id}: {self._truncate_label(story.title, 30)}"
208
+ lines.append(f' {node_id}["{self._escape_label(label)}"]')
209
+
210
+ if i > 0:
211
+ prev_id = stories[i - 1].subgraph_id
212
+ lines.append(f" {prev_id} --> {node_id}")
213
+
214
+ return "\n".join(lines)
@@ -0,0 +1,232 @@
1
+ """Parser for user stories from spec.md files."""
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ from ..models.diagram_models import AcceptanceScenario, ParsedUserStory
7
+
8
+
9
+ class UserStoryParser:
10
+ """Parses user stories from spec.md content.
11
+
12
+ Extracts user story headers, descriptions, and acceptance scenarios
13
+ following the doit spec template format.
14
+ """
15
+
16
+ # Pattern for user story header: ### User Story N - Title (Priority: PN)
17
+ STORY_HEADER_PATTERN = re.compile(
18
+ r"^###\s+User\s+Story\s+(\d+)\s*[-–—]\s*(.+?)\s*\(Priority:\s*(P\d+)\)\s*$",
19
+ re.IGNORECASE | re.MULTILINE,
20
+ )
21
+
22
+ # Pattern for Given/When/Then acceptance scenarios
23
+ # Supports both bold and plain text formats
24
+ SCENARIO_PATTERN = re.compile(
25
+ r"^\d+\.\s+\*?\*?(?:Given)\*?\*?\s+(.+?),\s*\*?\*?(?:When)\*?\*?\s+(.+?),\s*\*?\*?(?:Then)\*?\*?\s+(.+)$",
26
+ re.IGNORECASE | re.MULTILINE,
27
+ )
28
+
29
+ # Alternative pattern for bold keywords
30
+ SCENARIO_BOLD_PATTERN = re.compile(
31
+ r"^\d+\.\s+\*\*Given\*\*\s+(.+?),\s*\*\*When\*\*\s+(.+?),\s*\*\*Then\*\*\s+(.+)$",
32
+ re.IGNORECASE | re.MULTILINE,
33
+ )
34
+
35
+ def parse(self, content: str) -> list[ParsedUserStory]:
36
+ """Parse all user stories from spec content.
37
+
38
+ Args:
39
+ content: Full content of spec.md file
40
+
41
+ Returns:
42
+ List of ParsedUserStory objects
43
+ """
44
+ stories = []
45
+
46
+ # Find all story headers and their positions
47
+ header_matches = list(self.STORY_HEADER_PATTERN.finditer(content))
48
+
49
+ if not header_matches:
50
+ return stories
51
+
52
+ # Extract each story's content
53
+ for i, match in enumerate(header_matches):
54
+ story_number = int(match.group(1))
55
+ title = match.group(2).strip()
56
+ priority = match.group(3).strip().upper()
57
+
58
+ # Determine story content boundaries
59
+ start_pos = match.end()
60
+ if i + 1 < len(header_matches):
61
+ end_pos = header_matches[i + 1].start()
62
+ else:
63
+ # Last story - find next major section or end of file
64
+ next_section = self._find_next_section(content, start_pos)
65
+ end_pos = next_section if next_section else len(content)
66
+
67
+ story_content = content[start_pos:end_pos].strip()
68
+
69
+ # Extract description and scenarios
70
+ description = self._extract_description(story_content)
71
+ scenarios = self._extract_scenarios(story_content, story_number)
72
+
73
+ story = ParsedUserStory(
74
+ id=f"US{story_number}",
75
+ story_number=story_number,
76
+ title=title,
77
+ priority=priority,
78
+ description=description,
79
+ scenarios=scenarios,
80
+ raw_text=content[match.start() : end_pos],
81
+ )
82
+ stories.append(story)
83
+
84
+ return stories
85
+
86
+ def parse_single(self, content: str) -> Optional[ParsedUserStory]:
87
+ """Parse a single user story from content.
88
+
89
+ Args:
90
+ content: Content containing one user story
91
+
92
+ Returns:
93
+ ParsedUserStory if found, None otherwise
94
+ """
95
+ stories = self.parse(content)
96
+ return stories[0] if stories else None
97
+
98
+ def _extract_description(self, story_content: str) -> str:
99
+ """Extract user story description from content.
100
+
101
+ The description is typically the narrative before acceptance scenarios.
102
+
103
+ Args:
104
+ story_content: Content of a single user story section
105
+
106
+ Returns:
107
+ Description text
108
+ """
109
+ lines = story_content.split("\n")
110
+ description_lines = []
111
+
112
+ for line in lines:
113
+ stripped = line.strip()
114
+
115
+ # Stop at acceptance scenarios or numbered lists
116
+ if stripped.startswith("1.") or "**Given**" in stripped:
117
+ break
118
+
119
+ # Skip empty lines at the start
120
+ if not stripped and not description_lines:
121
+ continue
122
+
123
+ # Stop at sub-headers
124
+ if stripped.startswith("##"):
125
+ break
126
+
127
+ description_lines.append(stripped)
128
+
129
+ return " ".join(description_lines).strip()
130
+
131
+ def _extract_scenarios(
132
+ self, story_content: str, story_number: int
133
+ ) -> list[AcceptanceScenario]:
134
+ """Extract acceptance scenarios from story content.
135
+
136
+ Args:
137
+ story_content: Content of a single user story section
138
+ story_number: The story number for generating IDs
139
+
140
+ Returns:
141
+ List of AcceptanceScenario objects
142
+ """
143
+ scenarios = []
144
+
145
+ # Try bold pattern first (more specific)
146
+ matches = list(self.SCENARIO_BOLD_PATTERN.finditer(story_content))
147
+
148
+ # Fall back to general pattern
149
+ if not matches:
150
+ matches = list(self.SCENARIO_PATTERN.finditer(story_content))
151
+
152
+ for scenario_num, match in enumerate(matches, start=1):
153
+ given = self._clean_clause(match.group(1))
154
+ when = self._clean_clause(match.group(2))
155
+ then = self._clean_clause(match.group(3))
156
+
157
+ scenario = AcceptanceScenario(
158
+ id=f"US{story_number}_S{scenario_num}",
159
+ scenario_number=scenario_num,
160
+ given_clause=given,
161
+ when_clause=when,
162
+ then_clause=then,
163
+ raw_text=match.group(0),
164
+ )
165
+ scenarios.append(scenario)
166
+
167
+ return scenarios
168
+
169
+ def _clean_clause(self, text: str) -> str:
170
+ """Clean a Given/When/Then clause.
171
+
172
+ Args:
173
+ text: Raw clause text
174
+
175
+ Returns:
176
+ Cleaned text with markdown removed
177
+ """
178
+ # Remove bold markers
179
+ text = text.replace("**", "")
180
+ # Remove trailing punctuation
181
+ text = text.rstrip(".,;")
182
+ # Clean whitespace
183
+ text = " ".join(text.split())
184
+ return text.strip()
185
+
186
+ def _find_next_section(self, content: str, start_pos: int) -> Optional[int]:
187
+ """Find the position of the next major section.
188
+
189
+ Args:
190
+ content: Full file content
191
+ start_pos: Position to start searching from
192
+
193
+ Returns:
194
+ Position of next section header, or None if not found
195
+ """
196
+ # Look for ## or ### headers that aren't user stories
197
+ section_pattern = re.compile(r"^##\s+[^#]", re.MULTILINE)
198
+ match = section_pattern.search(content, start_pos)
199
+
200
+ if match:
201
+ return match.start()
202
+
203
+ return None
204
+
205
+ def get_story_by_number(
206
+ self, content: str, story_number: int
207
+ ) -> Optional[ParsedUserStory]:
208
+ """Get a specific user story by number.
209
+
210
+ Args:
211
+ content: Spec content to parse
212
+ story_number: Story number to find (1, 2, 3...)
213
+
214
+ Returns:
215
+ ParsedUserStory if found, None otherwise
216
+ """
217
+ stories = self.parse(content)
218
+ for story in stories:
219
+ if story.story_number == story_number:
220
+ return story
221
+ return None
222
+
223
+ def count_stories(self, content: str) -> int:
224
+ """Count user stories in content without full parsing.
225
+
226
+ Args:
227
+ content: Spec content to check
228
+
229
+ Returns:
230
+ Number of user stories found
231
+ """
232
+ return len(self.STORY_HEADER_PATTERN.findall(content))