@pennyfarthing/core 7.8.1 → 7.8.4

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 (178) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/init.js +7 -6
  5. package/packages/core/dist/cli/commands/init.js.map +1 -1
  6. package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
  7. package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
  8. package/packages/core/dist/cli/utils/symlinks.js +25 -0
  9. package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
  10. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
  11. package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
  12. package/pennyfarthing-dist/scripts/core/run.sh +5 -5
  13. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
  14. package/pennyfarthing-dist/scripts/git/release.sh +2 -2
  15. package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
  16. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
  17. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
  18. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
  19. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
  20. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
  21. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
  22. package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
  23. package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
  24. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
  25. package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
  26. package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
  27. package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
  28. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
  29. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
  30. package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
  31. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
  32. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
  33. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
  34. package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
  35. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
  36. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
  37. package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
  38. package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
  39. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
  40. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
  41. package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
  42. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
  43. package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
  44. package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
  45. package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
  46. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
  55. package/pennyfarthing_scripts/__init__.py +17 -0
  56. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  57. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  58. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  59. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  60. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  61. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  62. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  63. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  64. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  65. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  66. package/pennyfarthing_scripts/bellmode_hook.py +154 -0
  67. package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
  68. package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
  69. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/brownfield/cli.py +131 -0
  74. package/pennyfarthing_scripts/brownfield/discover.py +753 -0
  75. package/pennyfarthing_scripts/common/__init__.py +49 -0
  76. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/common/config.py +65 -0
  80. package/pennyfarthing_scripts/common/output.py +180 -0
  81. package/pennyfarthing_scripts/config.py +21 -0
  82. package/pennyfarthing_scripts/git/__init__.py +29 -0
  83. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/git/create_branches.py +439 -0
  87. package/pennyfarthing_scripts/git/status_all.py +310 -0
  88. package/pennyfarthing_scripts/hooks.py +455 -0
  89. package/pennyfarthing_scripts/jira/__init__.py +93 -0
  90. package/pennyfarthing_scripts/jira/__main__.py +10 -0
  91. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  103. package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
  104. package/pennyfarthing_scripts/jira/claim.py +211 -0
  105. package/pennyfarthing_scripts/jira/cli.py +150 -0
  106. package/pennyfarthing_scripts/jira/client.py +613 -0
  107. package/pennyfarthing_scripts/jira/epic.py +176 -0
  108. package/pennyfarthing_scripts/jira/story.py +219 -0
  109. package/pennyfarthing_scripts/jira/sync.py +350 -0
  110. package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
  111. package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
  112. package/pennyfarthing_scripts/jira_sync.py +36 -0
  113. package/pennyfarthing_scripts/jira_sync_story.py +30 -0
  114. package/pennyfarthing_scripts/output.py +37 -0
  115. package/pennyfarthing_scripts/preflight/__init__.py +17 -0
  116. package/pennyfarthing_scripts/preflight/__main__.py +10 -0
  117. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/preflight/cli.py +141 -0
  122. package/pennyfarthing_scripts/preflight/finish.py +382 -0
  123. package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
  124. package/pennyfarthing_scripts/prime/__init__.py +38 -0
  125. package/pennyfarthing_scripts/prime/__main__.py +8 -0
  126. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/prime/cli.py +220 -0
  135. package/pennyfarthing_scripts/prime/loader.py +239 -0
  136. package/pennyfarthing_scripts/sprint/__init__.py +66 -0
  137. package/pennyfarthing_scripts/sprint/__main__.py +10 -0
  138. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/sprint/archive.py +108 -0
  147. package/pennyfarthing_scripts/sprint/cli.py +124 -0
  148. package/pennyfarthing_scripts/sprint/loader.py +193 -0
  149. package/pennyfarthing_scripts/sprint/status.py +122 -0
  150. package/pennyfarthing_scripts/sprint/validator.py +405 -0
  151. package/pennyfarthing_scripts/sprint/work.py +192 -0
  152. package/pennyfarthing_scripts/story/__init__.py +67 -0
  153. package/pennyfarthing_scripts/story/__main__.py +10 -0
  154. package/pennyfarthing_scripts/story/cli.py +105 -0
  155. package/pennyfarthing_scripts/story/create.py +167 -0
  156. package/pennyfarthing_scripts/story/size.py +113 -0
  157. package/pennyfarthing_scripts/story/template.py +151 -0
  158. package/pennyfarthing_scripts/swebench.py +216 -0
  159. package/pennyfarthing_scripts/tests/__init__.py +1 -0
  160. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  162. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  163. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  164. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  165. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  166. package/pennyfarthing_scripts/tests/conftest.py +106 -0
  167. package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
  168. package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
  169. package/pennyfarthing_scripts/tests/test_common.py +180 -0
  170. package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
  171. package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
  172. package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
  173. package/pennyfarthing_scripts/tests/test_prime.py +397 -0
  174. package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
  175. package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
  176. package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
  177. package/pennyfarthing_scripts/welcome_hook.py +157 -0
  178. package/pennyfarthing_scripts/workflow.py +183 -0
@@ -0,0 +1,405 @@
1
+ """
2
+ Sprint and Story YAML validators for Pennyfarthing.
3
+
4
+ Story: MSSCI-12394 - Sprint and Story YAML validators
5
+
6
+ This module provides validation for:
7
+ - Sprint-level structure and required fields
8
+ - Story-level required fields and values
9
+ - Epic-level validation with story references
10
+ - Archived sprint validation
11
+
12
+ All functions raise NO errors - they return ValidationResult objects
13
+ with success status and error messages.
14
+ """
15
+
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+
25
+ class ValidationSeverity(Enum):
26
+ """Severity level for validation errors."""
27
+
28
+ ERROR = "error"
29
+ WARNING = "warning"
30
+
31
+
32
+ @dataclass
33
+ class ValidationError:
34
+ """A single validation error."""
35
+
36
+ message: str
37
+ path: str # JSON path to the error location (e.g., "epics[0].stories[1].status")
38
+ severity: ValidationSeverity = ValidationSeverity.ERROR
39
+
40
+
41
+ @dataclass
42
+ class ValidationResult:
43
+ """Result of validation with errors and warnings."""
44
+
45
+ valid: bool
46
+ errors: list[ValidationError] = field(default_factory=list)
47
+
48
+ def add_error(
49
+ self, message: str, path: str, severity: ValidationSeverity = ValidationSeverity.ERROR
50
+ ) -> None:
51
+ """Add an error to the result."""
52
+ self.errors.append(ValidationError(message, path, severity))
53
+ if severity == ValidationSeverity.ERROR:
54
+ self.valid = False
55
+
56
+ def merge(self, other: "ValidationResult") -> None:
57
+ """Merge another result into this one."""
58
+ self.errors.extend(other.errors)
59
+ if not other.valid:
60
+ self.valid = False
61
+
62
+
63
+ # =============================================================================
64
+ # Constants
65
+ # =============================================================================
66
+
67
+ VALID_SPRINT_STATUSES = {"active", "closed"}
68
+ VALID_STORY_STATUSES = {"backlog", "ready", "in_progress", "done", "canceled"}
69
+ JIRA_KEY_PATTERN = re.compile(r"^MSSCI-\d{5}$")
70
+ ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
71
+
72
+ # Required fields for sprint section
73
+ REQUIRED_SPRINT_FIELDS = {"number", "jira_sprint_id", "goal", "start_date", "end_date", "status"}
74
+
75
+ # Required fields for story
76
+ REQUIRED_STORY_FIELDS = {"id", "title", "status", "points"}
77
+
78
+ # Required fields for epic
79
+ REQUIRED_EPIC_FIELDS = {"id", "title"}
80
+
81
+
82
+ # =============================================================================
83
+ # Validation Functions
84
+ # =============================================================================
85
+
86
+
87
+ def validate_sprint(data: dict[str, Any]) -> ValidationResult:
88
+ """Validate sprint-level structure and fields.
89
+
90
+ Validates:
91
+ - Required fields present (number, jira_sprint_id, goal, start_date, end_date, status)
92
+ - status is valid value (active, closed)
93
+ - dates are ISO format
94
+
95
+ Args:
96
+ data: Sprint YAML data (full document)
97
+
98
+ Returns:
99
+ ValidationResult with any errors found
100
+ """
101
+ result = ValidationResult(valid=True)
102
+
103
+ # Check for sprint section
104
+ if "sprint" not in data:
105
+ result.add_error("Missing required 'sprint' section", "sprint")
106
+ return result
107
+
108
+ sprint = data["sprint"]
109
+
110
+ # Check required fields
111
+ for field_name in REQUIRED_SPRINT_FIELDS:
112
+ if field_name not in sprint:
113
+ result.add_error(
114
+ f"Missing required field: {field_name}",
115
+ f"sprint.{field_name}",
116
+ )
117
+
118
+ # Validate status if present
119
+ if "status" in sprint:
120
+ status = sprint["status"]
121
+ if status not in VALID_SPRINT_STATUSES:
122
+ result.add_error(
123
+ f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_SPRINT_STATUSES))}",
124
+ "sprint.status",
125
+ )
126
+
127
+ # Validate date formats
128
+ for date_field in ["start_date", "end_date"]:
129
+ if date_field in sprint:
130
+ date_val = str(sprint[date_field])
131
+ if not ISO_DATE_PATTERN.match(date_val):
132
+ result.add_error(
133
+ f"Invalid date format for {date_field}: '{date_val}'. Expected YYYY-MM-DD",
134
+ f"sprint.{date_field}",
135
+ )
136
+
137
+ return result
138
+
139
+
140
+ def validate_story(story: dict[str, Any], epic_id: str, story_index: int = 0) -> ValidationResult:
141
+ """Validate a single story's structure and fields.
142
+
143
+ Validates:
144
+ - Required fields present (id, title, status, points)
145
+ - status is valid value (backlog, ready, in_progress, done, canceled)
146
+ - points is numeric
147
+ - jira key follows pattern MSSCI-NNNNN if present
148
+ - branch follows convention if present
149
+
150
+ Args:
151
+ story: Story dict from YAML
152
+ epic_id: Parent epic ID for path generation
153
+ story_index: Index of story in epic's stories array
154
+
155
+ Returns:
156
+ ValidationResult with any errors found
157
+ """
158
+ result = ValidationResult(valid=True)
159
+ base_path = f"{epic_id}.stories[{story_index}]"
160
+
161
+ # Check required fields
162
+ for field_name in REQUIRED_STORY_FIELDS:
163
+ if field_name not in story:
164
+ result.add_error(
165
+ f"Missing required field: {field_name}",
166
+ f"{base_path}.{field_name}",
167
+ )
168
+
169
+ # Validate status if present
170
+ if "status" in story:
171
+ status = story["status"]
172
+ if status not in VALID_STORY_STATUSES:
173
+ result.add_error(
174
+ f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_STORY_STATUSES))}",
175
+ f"{base_path}.status",
176
+ )
177
+
178
+ # Validate points is numeric
179
+ if "points" in story:
180
+ points = story["points"]
181
+ if not isinstance(points, (int, float)):
182
+ result.add_error(
183
+ f"Invalid points value '{points}'. Must be numeric",
184
+ f"{base_path}.points",
185
+ )
186
+
187
+ # Validate jira key format if present
188
+ if "jira" in story:
189
+ jira_key = str(story["jira"])
190
+ if not JIRA_KEY_PATTERN.match(jira_key):
191
+ result.add_error(
192
+ f"Invalid Jira key format '{jira_key}'. Expected MSSCI-NNNNN",
193
+ f"{base_path}.jira",
194
+ )
195
+
196
+ return result
197
+
198
+
199
+ def validate_epic(epic: dict[str, Any], all_story_ids: set[str], epic_index: int = 0) -> ValidationResult:
200
+ """Validate an epic's structure and story references.
201
+
202
+ Validates:
203
+ - Required fields present (id, title)
204
+ - All story IDs are unique within epic
205
+ - No orphaned story references (if stories present)
206
+
207
+ Args:
208
+ epic: Epic dict from YAML
209
+ all_story_ids: Set of all story IDs in the sprint (for uniqueness check)
210
+ epic_index: Index of epic for path generation
211
+
212
+ Returns:
213
+ ValidationResult with any errors found
214
+ """
215
+ result = ValidationResult(valid=True)
216
+ base_path = f"epics[{epic_index}]"
217
+ epic_id = epic.get("id", f"epics[{epic_index}]")
218
+
219
+ # Check required fields
220
+ for field_name in REQUIRED_EPIC_FIELDS:
221
+ if field_name not in epic:
222
+ result.add_error(
223
+ f"Missing required field: {field_name}",
224
+ f"{base_path}.{field_name}",
225
+ )
226
+
227
+ # Validate stories if present
228
+ if "stories" in epic:
229
+ seen_in_epic: set[str] = set()
230
+ for idx, story in enumerate(epic["stories"]):
231
+ story_id = story.get("id")
232
+ if story_id:
233
+ # Check for duplicates within this epic
234
+ if story_id in seen_in_epic:
235
+ result.add_error(
236
+ f"Duplicate story ID '{story_id}' within epic",
237
+ f"{base_path}.stories[{idx}].id",
238
+ )
239
+ # Check for duplicates across epics
240
+ elif story_id in all_story_ids:
241
+ result.add_error(
242
+ f"Duplicate story ID '{story_id}' - already exists in another epic",
243
+ f"{base_path}.stories[{idx}].id",
244
+ )
245
+ seen_in_epic.add(story_id)
246
+
247
+ # Validate story structure
248
+ story_result = validate_story(story, epic_id, idx)
249
+ result.merge(story_result)
250
+
251
+ return result
252
+
253
+
254
+ def validate_full_sprint(data: dict[str, Any]) -> ValidationResult:
255
+ """Validate complete sprint YAML including all epics and stories.
256
+
257
+ Validates:
258
+ - Sprint-level structure
259
+ - All epics and their stories
260
+ - Cross-cutting constraints (no duplicate story IDs)
261
+
262
+ Args:
263
+ data: Full sprint YAML data
264
+
265
+ Returns:
266
+ Combined ValidationResult with all errors
267
+ """
268
+ result = ValidationResult(valid=True)
269
+
270
+ # Validate sprint section
271
+ sprint_result = validate_sprint(data)
272
+ result.merge(sprint_result)
273
+
274
+ # Validate epics
275
+ if "epics" in data:
276
+ all_story_ids: set[str] = set()
277
+ for idx, epic in enumerate(data["epics"]):
278
+ epic_result = validate_epic(epic, all_story_ids, idx)
279
+ result.merge(epic_result)
280
+
281
+ # Collect story IDs for cross-epic duplicate detection
282
+ if "stories" in epic:
283
+ for story in epic["stories"]:
284
+ story_id = story.get("id")
285
+ if story_id:
286
+ all_story_ids.add(story_id)
287
+
288
+ return result
289
+
290
+
291
+ def validate_archived_sprint(data: dict[str, Any]) -> ValidationResult:
292
+ """Validate an archived sprint file.
293
+
294
+ Archived sprints have the same structure as current sprints
295
+ but allow done/canceled status for all stories.
296
+
297
+ Args:
298
+ data: Archived sprint YAML data
299
+
300
+ Returns:
301
+ ValidationResult with any errors found
302
+ """
303
+ # For archived sprints, use the same validation as full sprint
304
+ # The key difference is that all story statuses are valid
305
+ # (done/canceled are expected in archived sprints)
306
+ return validate_full_sprint(data)
307
+
308
+
309
+ def validate_sprint_file(file_path: Path) -> ValidationResult:
310
+ """Validate a sprint YAML file from disk.
311
+
312
+ Loads the file and validates its contents.
313
+
314
+ Args:
315
+ file_path: Path to sprint YAML file
316
+
317
+ Returns:
318
+ ValidationResult with any errors (including load errors)
319
+ """
320
+ result = ValidationResult(valid=True)
321
+
322
+ # Check file exists
323
+ if not file_path.exists():
324
+ result.add_error(
325
+ f"File not found: {file_path}",
326
+ str(file_path),
327
+ )
328
+ return result
329
+
330
+ # Try to load YAML
331
+ try:
332
+ with open(file_path) as f:
333
+ data = yaml.safe_load(f)
334
+ except yaml.YAMLError as e:
335
+ result.add_error(
336
+ f"Failed to parse YAML: {e}",
337
+ str(file_path),
338
+ )
339
+ return result
340
+
341
+ # Validate loaded data
342
+ return validate_full_sprint(data)
343
+
344
+
345
+ def format_validation_errors(result: ValidationResult) -> str:
346
+ """Format validation errors for human-readable output.
347
+
348
+ Args:
349
+ result: ValidationResult to format
350
+
351
+ Returns:
352
+ Multi-line string with formatted errors
353
+ """
354
+ if not result.errors:
355
+ return "No validation errors"
356
+
357
+ lines = []
358
+ for error in result.errors:
359
+ severity_label = error.severity.value.upper()
360
+ lines.append(f"[{severity_label}] {error.path}: {error.message}")
361
+
362
+ return "\n".join(lines)
363
+
364
+
365
+ # =============================================================================
366
+ # CLI Entry Point
367
+ # =============================================================================
368
+
369
+
370
+ def main() -> int:
371
+ """CLI entry point for sprint validation.
372
+
373
+ Usage:
374
+ python -m pennyfarthing_scripts.sprint.validator [file_path]
375
+
376
+ If no file_path is provided, validates sprint/current-sprint.yaml.
377
+
378
+ Returns:
379
+ 0 if valid, 1 if invalid
380
+ """
381
+ import sys
382
+
383
+ from pennyfarthing_scripts.common.config import get_project_root
384
+
385
+ # Determine file to validate
386
+ if len(sys.argv) > 1:
387
+ file_path = Path(sys.argv[1])
388
+ else:
389
+ file_path = get_project_root() / "sprint" / "current-sprint.yaml"
390
+
391
+ print(f"Validating: {file_path}")
392
+ result = validate_sprint_file(file_path)
393
+
394
+ if result.valid:
395
+ print("✓ Sprint YAML is valid")
396
+ return 0
397
+ else:
398
+ print(f"✗ Found {len(result.errors)} validation error(s):\n")
399
+ print(format_validation_errors(result))
400
+ return 1
401
+
402
+
403
+ if __name__ == "__main__":
404
+ import sys
405
+ sys.exit(main())
@@ -0,0 +1,192 @@
1
+ """
2
+ Sprint work session management.
3
+
4
+ Provides functions for starting and managing work on stories.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from pennyfarthing_scripts.sprint.loader import (
10
+ find_epic,
11
+ find_story,
12
+ get_stories_by_status,
13
+ get_story_by_id,
14
+ load_sprint,
15
+ )
16
+
17
+
18
+ def check_story(story_id: str) -> dict[str, Any]:
19
+ """Check if a story is available for work.
20
+
21
+ Args:
22
+ story_id: Story ID or Jira key
23
+
24
+ Returns:
25
+ Dict with availability status and story details
26
+ """
27
+ story = get_story_by_id(story_id)
28
+
29
+ if not story:
30
+ return {
31
+ "available": False,
32
+ "error": f"Story '{story_id}' not found",
33
+ }
34
+
35
+ status = story.get("status", "backlog")
36
+ assigned = story.get("assigned_to")
37
+
38
+ # Check if already in progress
39
+ if status == "in_progress":
40
+ return {
41
+ "available": False,
42
+ "type": "story",
43
+ "story": story,
44
+ "reason": "Already in progress",
45
+ "assigned_to": assigned,
46
+ }
47
+
48
+ # Check if done
49
+ if status in ("done", "completed"):
50
+ return {
51
+ "available": False,
52
+ "type": "story",
53
+ "story": story,
54
+ "reason": "Already completed",
55
+ }
56
+
57
+ return {
58
+ "available": True,
59
+ "type": "story",
60
+ "story": story,
61
+ "title": story.get("title"),
62
+ "points": story.get("points"),
63
+ "workflow": story.get("workflow", "tdd"),
64
+ }
65
+
66
+
67
+ def get_next_story() -> dict[str, Any]:
68
+ """Get the highest priority available story.
69
+
70
+ Returns:
71
+ Dict with next story details or error
72
+ """
73
+ backlog = get_stories_by_status("backlog")
74
+
75
+ if not backlog:
76
+ return {
77
+ "available": False,
78
+ "error": "No stories in backlog",
79
+ }
80
+
81
+ # Sort by priority (P0 > P1 > P2 > P3)
82
+ priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
83
+ sorted_stories = sorted(
84
+ backlog,
85
+ key=lambda s: priority_order.get(s.get("priority", "P2"), 2),
86
+ )
87
+
88
+ next_story = sorted_stories[0]
89
+ return {
90
+ "available": True,
91
+ "type": "next",
92
+ "story": next_story,
93
+ "title": next_story.get("title"),
94
+ "points": next_story.get("points"),
95
+ "priority": next_story.get("priority", "P2"),
96
+ }
97
+
98
+
99
+ def start_work(story_id: str, *, dry_run: bool = False) -> dict[str, Any]:
100
+ """Start work on a story.
101
+
102
+ Args:
103
+ story_id: Story ID to start
104
+ dry_run: If True, don't make changes
105
+
106
+ Returns:
107
+ Dict with success status and details
108
+ """
109
+ # Check availability
110
+ check = check_story(story_id)
111
+ if not check.get("available"):
112
+ return {
113
+ "success": False,
114
+ "error": check.get("reason") or check.get("error"),
115
+ }
116
+
117
+ story = check.get("story")
118
+
119
+ if dry_run:
120
+ return {
121
+ "success": True,
122
+ "dry_run": True,
123
+ "story": story,
124
+ "message": f"Would start work on {story_id}",
125
+ }
126
+
127
+ # In a real implementation, this would:
128
+ # 1. Create/update session file
129
+ # 2. Claim in Jira
130
+ # 3. Create branch
131
+ # For now, return success with story info
132
+ return {
133
+ "success": True,
134
+ "story": story,
135
+ "message": f"Ready to start work on {story_id}",
136
+ }
137
+
138
+
139
+ def main(args: list[str] | None = None) -> int:
140
+ """CLI entry point for sprint work.
141
+
142
+ Args:
143
+ args: Command line arguments
144
+
145
+ Returns:
146
+ Exit code
147
+ """
148
+ import argparse
149
+ import sys
150
+
151
+ parser = argparse.ArgumentParser(description="Start work on a story")
152
+ parser.add_argument(
153
+ "story_id",
154
+ nargs="?",
155
+ help="Story ID (or 'next' for highest priority)",
156
+ )
157
+ parser.add_argument(
158
+ "--dry-run",
159
+ action="store_true",
160
+ help="Show what would be done",
161
+ )
162
+
163
+ parsed = parser.parse_args(args)
164
+
165
+ if not parsed.story_id:
166
+ # Show backlog
167
+ backlog = get_stories_by_status("backlog")
168
+ print(f"Available stories: {len(backlog)}")
169
+ for story in backlog[:10]:
170
+ print(f" {story.get('id')}: {story.get('title')} [{story.get('points', '?')}pts]")
171
+ return 0
172
+
173
+ if parsed.story_id == "next":
174
+ result = get_next_story()
175
+ else:
176
+ result = check_story(parsed.story_id)
177
+
178
+ if result.get("available"):
179
+ story = result.get("story", {})
180
+ print(f"Story: {story.get('id')}")
181
+ print(f"Title: {story.get('title')}")
182
+ print(f"Points: {story.get('points')}")
183
+ print(f"Status: Available")
184
+ return 0
185
+ else:
186
+ print(f"Not available: {result.get('error') or result.get('reason')}", file=sys.stderr)
187
+ return 1
188
+
189
+
190
+ if __name__ == "__main__":
191
+ import sys
192
+ sys.exit(main())
@@ -0,0 +1,67 @@
1
+ """
2
+ Story management package for Pennyfarthing scripts.
3
+
4
+ This package provides:
5
+ - size: Story sizing guidelines
6
+ - template: Story templates
7
+ - create: Story creation
8
+
9
+ Usage:
10
+ # Use the modules
11
+ from pennyfarthing_scripts.story import get_sizing_guidelines, get_template
12
+
13
+ # Use CLI
14
+ python -m pennyfarthing_scripts.story <subcommand> [args]
15
+ """
16
+
17
+ # Re-export common functions
18
+ from pennyfarthing_scripts.story.size import (
19
+ SIZING_GUIDELINES,
20
+ format_size_info,
21
+ get_sizing_guidelines,
22
+ )
23
+ from pennyfarthing_scripts.story.template import (
24
+ TEMPLATES,
25
+ format_template,
26
+ get_all_templates,
27
+ get_template,
28
+ )
29
+ from pennyfarthing_scripts.story.create import (
30
+ create_story,
31
+ generate_story_yaml,
32
+ validate_points,
33
+ )
34
+
35
+ # Import submodules
36
+ from pennyfarthing_scripts.story import (
37
+ create,
38
+ size,
39
+ template,
40
+ )
41
+
42
+ # CLI entry point - import module, not function, so "from story import cli" gets the module
43
+ from pennyfarthing_scripts.story import cli
44
+ from pennyfarthing_scripts.story.cli import main
45
+
46
+ __all__ = [
47
+ # Size
48
+ "SIZING_GUIDELINES",
49
+ "format_size_info",
50
+ "get_sizing_guidelines",
51
+ # Template
52
+ "TEMPLATES",
53
+ "format_template",
54
+ "get_all_templates",
55
+ "get_template",
56
+ # Create
57
+ "create_story",
58
+ "generate_story_yaml",
59
+ "validate_points",
60
+ # Submodules
61
+ "create",
62
+ "size",
63
+ "template",
64
+ # CLI
65
+ "cli",
66
+ "main",
67
+ ]
@@ -0,0 +1,10 @@
1
+ """
2
+ Entry point for python -m pennyfarthing_scripts.story
3
+ """
4
+
5
+ import sys
6
+
7
+ from pennyfarthing_scripts.story.cli import cli
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(cli())