@pennyfarthing/core 10.0.3 → 10.1.0

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 (282) hide show
  1. package/README.md +9 -7
  2. package/package.json +7 -1
  3. package/packages/core/dist/cli/commands/cyclist.d.ts +5 -1
  4. package/packages/core/dist/cli/commands/cyclist.d.ts.map +1 -1
  5. package/packages/core/dist/cli/commands/cyclist.js +4 -4
  6. package/packages/core/dist/cli/commands/cyclist.js.map +1 -1
  7. package/packages/core/dist/cli/commands/cyclist.test.js +2 -2
  8. package/packages/core/dist/cli/commands/cyclist.test.js.map +1 -1
  9. package/packages/core/dist/cli/commands/doctor-legacy.test.js +17 -16
  10. package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
  11. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/packages/core/dist/cli/commands/doctor.js +251 -4
  13. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  14. package/packages/core/dist/cli/commands/init.d.ts +7 -0
  15. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  16. package/packages/core/dist/cli/commands/init.js +43 -7
  17. package/packages/core/dist/cli/commands/init.js.map +1 -1
  18. package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
  19. package/packages/core/dist/cli/commands/update.js +26 -0
  20. package/packages/core/dist/cli/commands/update.js.map +1 -1
  21. package/packages/core/dist/cli/index.js +1 -1
  22. package/packages/core/dist/cli/index.js.map +1 -1
  23. package/packages/core/dist/cli/ocean-profiles.test.js +1 -1
  24. package/packages/core/dist/cli/ocean-profiles.test.js.map +1 -1
  25. package/packages/core/dist/cli/utils/files.d.ts +10 -0
  26. package/packages/core/dist/cli/utils/files.d.ts.map +1 -1
  27. package/packages/core/dist/cli/utils/files.js +35 -0
  28. package/packages/core/dist/cli/utils/files.js.map +1 -1
  29. package/packages/core/dist/cli/utils/python.d.ts +22 -0
  30. package/packages/core/dist/cli/utils/python.d.ts.map +1 -0
  31. package/packages/core/dist/cli/utils/python.js +102 -0
  32. package/packages/core/dist/cli/utils/python.js.map +1 -0
  33. package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
  34. package/packages/core/dist/cli/utils/settings.js +10 -0
  35. package/packages/core/dist/cli/utils/settings.js.map +1 -1
  36. package/packages/core/dist/scripts/generate-report.d.ts.map +1 -1
  37. package/packages/core/dist/scripts/generate-report.js +11 -7
  38. package/packages/core/dist/scripts/generate-report.js.map +1 -1
  39. package/packages/core/dist/scripts/generate-spider-report.d.ts.map +1 -1
  40. package/packages/core/dist/scripts/generate-spider-report.js +12 -8
  41. package/packages/core/dist/scripts/generate-spider-report.js.map +1 -1
  42. package/packages/core/dist/scripts/generate-spider.d.ts.map +1 -1
  43. package/packages/core/dist/scripts/generate-spider.js +6 -4
  44. package/packages/core/dist/scripts/generate-spider.js.map +1 -1
  45. package/packages/core/dist/scripts/generate-spider.test.js +2 -2
  46. package/packages/core/dist/scripts/generate-spider.test.js.map +1 -1
  47. package/pennyfarthing-dist/agents/README.md +1 -3
  48. package/pennyfarthing-dist/agents/architect.md +0 -6
  49. package/pennyfarthing-dist/agents/devops.md +0 -6
  50. package/pennyfarthing-dist/agents/orchestrator.md +0 -6
  51. package/pennyfarthing-dist/agents/pm.md +1 -7
  52. package/pennyfarthing-dist/agents/sm-finish.md +1 -1
  53. package/pennyfarthing-dist/agents/sm-setup.md +2 -2
  54. package/pennyfarthing-dist/agents/sm.md +4 -11
  55. package/pennyfarthing-dist/commands/architect.md +11 -3
  56. package/pennyfarthing-dist/commands/close-epic.md +24 -131
  57. package/pennyfarthing-dist/commands/create-theme.md +14 -24
  58. package/pennyfarthing-dist/commands/dev.md +11 -3
  59. package/pennyfarthing-dist/commands/devops.md +11 -3
  60. package/pennyfarthing-dist/commands/health-check.md +1 -3
  61. package/pennyfarthing-dist/commands/help.md +8 -12
  62. package/pennyfarthing-dist/commands/list-themes.md +14 -16
  63. package/pennyfarthing-dist/commands/orchestrator.md +11 -3
  64. package/pennyfarthing-dist/commands/parallel-work.md +1 -3
  65. package/pennyfarthing-dist/commands/pm.md +11 -3
  66. package/pennyfarthing-dist/commands/prime.md +6 -6
  67. package/pennyfarthing-dist/commands/repo-status.md +2 -2
  68. package/pennyfarthing-dist/commands/reviewer.md +11 -3
  69. package/pennyfarthing-dist/commands/run-ci.md +1 -1
  70. package/pennyfarthing-dist/commands/set-theme.md +14 -51
  71. package/pennyfarthing-dist/commands/setup.md +1 -1
  72. package/pennyfarthing-dist/commands/show-theme.md +14 -16
  73. package/pennyfarthing-dist/commands/sm.md +11 -3
  74. package/pennyfarthing-dist/commands/sprint.md +8 -8
  75. package/pennyfarthing-dist/commands/tea.md +11 -3
  76. package/pennyfarthing-dist/commands/tech-writer.md +11 -3
  77. package/pennyfarthing-dist/commands/theme-maker.md +14 -671
  78. package/pennyfarthing-dist/commands/theme.md +95 -0
  79. package/pennyfarthing-dist/commands/ux-designer.md +11 -3
  80. package/pennyfarthing-dist/commands/work.md +3 -5
  81. package/pennyfarthing-dist/guides/agent-coordination.md +11 -13
  82. package/pennyfarthing-dist/guides/agent-template-tactical.md +2 -3
  83. package/pennyfarthing-dist/guides/command-tag-taxonomy.md +212 -0
  84. package/pennyfarthing-dist/guides/hooks.md +5 -5
  85. package/pennyfarthing-dist/guides/patterns/fan-out-fan-in-pattern.md +3 -3
  86. package/pennyfarthing-dist/guides/patterns/helper-delegation-pattern.md +9 -59
  87. package/pennyfarthing-dist/guides/patterns/tdd-flow-pattern.md +4 -5
  88. package/pennyfarthing-dist/guides/prime.md +2 -2
  89. package/pennyfarthing-dist/guides/skill-schema.md +25 -26
  90. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  91. package/pennyfarthing-dist/scripts/README.md +2 -2
  92. package/pennyfarthing-dist/scripts/core/agent-session.sh +6 -2
  93. package/pennyfarthing-dist/scripts/core/prime.sh +8 -10
  94. package/pennyfarthing-dist/scripts/git/git-status-all.sh +1 -1
  95. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +8 -6
  96. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +3 -3
  97. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +14 -12
  98. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +4 -3
  99. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +11 -5
  100. package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +1 -1
  101. package/pennyfarthing-dist/scripts/misc/README.md +1 -1
  102. package/pennyfarthing-dist/scripts/misc/repo-utils.sh +3 -3
  103. package/pennyfarthing-dist/scripts/misc/validate-subagent-frontmatter.sh +1 -2
  104. package/pennyfarthing-dist/scripts/sprint/README.md +32 -17
  105. package/pennyfarthing-dist/scripts/story/README.md +1 -1
  106. package/pennyfarthing-dist/scripts/test/test-setup.sh +1 -1
  107. package/pennyfarthing-dist/scripts/tests/handoff-phase-update.test.sh +5 -5
  108. package/pennyfarthing-dist/scripts/tests/test-drift-detection.sh +3 -79
  109. package/pennyfarthing-dist/scripts/theme/README.md +1 -1
  110. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +0 -1
  111. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +62 -17
  112. package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +2 -2
  113. package/pennyfarthing-dist/skills/skill-registry.yaml +41 -28
  114. package/pennyfarthing-dist/skills/sprint/skill.md +386 -68
  115. package/pennyfarthing-dist/skills/story/skill.md +14 -206
  116. package/pennyfarthing-dist/skills/theme/skill.md +290 -75
  117. package/pennyfarthing-dist/skills/theme-creation/SKILL.md +23 -166
  118. package/pennyfarthing-dist/skills/workflow/skill.md +4 -4
  119. package/pennyfarthing-dist/templates/agent-scopes.yaml.template +0 -11
  120. package/pennyfarthing-dist/templates/auto-load-sm.sh.template +14 -0
  121. package/pennyfarthing-dist/templates/settings.local.json.template +9 -0
  122. package/pennyfarthing-dist/workflows/2party-tdd.yaml +399 -0
  123. package/pennyfarthing-dist/workflows/epics-and-stories/steps/step-05-import-to-future.md +42 -25
  124. package/pennyfarthing-dist/workflows/git-cleanup.yaml +1 -1
  125. package/pennyfarthing-dist/workflows/project-setup/steps/step-10-complete.md +1 -1
  126. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/cli.py +15 -0
  131. package/pennyfarthing_scripts/codemarkers/__init__.py +19 -0
  132. package/pennyfarthing_scripts/codemarkers/__main__.py +6 -0
  133. package/pennyfarthing_scripts/codemarkers/__pycache__/__init__.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/codemarkers/__pycache__/__main__.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/codemarkers/__pycache__/analyze.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/codemarkers/__pycache__/cli.cpython-314.pyc +0 -0
  137. package/pennyfarthing_scripts/codemarkers/__pycache__/formatters.cpython-314.pyc +0 -0
  138. package/pennyfarthing_scripts/codemarkers/__pycache__/models.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/codemarkers/analyze.py +326 -0
  140. package/pennyfarthing_scripts/codemarkers/cli.py +129 -0
  141. package/pennyfarthing_scripts/codemarkers/formatters.py +89 -0
  142. package/pennyfarthing_scripts/codemarkers/models.py +45 -0
  143. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/common/__pycache__/themes.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/common/config.py +2 -1
  146. package/pennyfarthing_scripts/complexity/__init__.py +15 -0
  147. package/pennyfarthing_scripts/complexity/__main__.py +6 -0
  148. package/pennyfarthing_scripts/complexity/__pycache__/__init__.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/complexity/__pycache__/__main__.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/complexity/__pycache__/analyze.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/complexity/__pycache__/cli.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/complexity/__pycache__/formatters.cpython-314.pyc +0 -0
  153. package/pennyfarthing_scripts/complexity/__pycache__/models.cpython-314.pyc +0 -0
  154. package/pennyfarthing_scripts/complexity/analyze.py +207 -0
  155. package/pennyfarthing_scripts/complexity/cli.py +78 -0
  156. package/pennyfarthing_scripts/complexity/formatters.py +64 -0
  157. package/pennyfarthing_scripts/complexity/models.py +32 -0
  158. package/pennyfarthing_scripts/deadcode/__init__.py +6 -0
  159. package/pennyfarthing_scripts/deadcode/__main__.py +6 -0
  160. package/pennyfarthing_scripts/deadcode/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/deadcode/__pycache__/__main__.cpython-314.pyc +0 -0
  162. package/pennyfarthing_scripts/deadcode/__pycache__/analyze.cpython-314.pyc +0 -0
  163. package/pennyfarthing_scripts/deadcode/__pycache__/cli.cpython-314.pyc +0 -0
  164. package/pennyfarthing_scripts/deadcode/__pycache__/formatters.cpython-314.pyc +0 -0
  165. package/pennyfarthing_scripts/deadcode/__pycache__/models.cpython-314.pyc +0 -0
  166. package/pennyfarthing_scripts/deadcode/analyze.py +323 -0
  167. package/pennyfarthing_scripts/deadcode/cli.py +163 -0
  168. package/pennyfarthing_scripts/deadcode/formatters.py +106 -0
  169. package/pennyfarthing_scripts/deadcode/models.py +54 -0
  170. package/pennyfarthing_scripts/dependencies/__init__.py +20 -0
  171. package/pennyfarthing_scripts/dependencies/__main__.py +5 -0
  172. package/pennyfarthing_scripts/dependencies/__pycache__/__init__.cpython-314.pyc +0 -0
  173. package/pennyfarthing_scripts/dependencies/__pycache__/__main__.cpython-314.pyc +0 -0
  174. package/pennyfarthing_scripts/dependencies/__pycache__/analyze.cpython-314.pyc +0 -0
  175. package/pennyfarthing_scripts/dependencies/__pycache__/cli.cpython-314.pyc +0 -0
  176. package/pennyfarthing_scripts/dependencies/__pycache__/formatters.cpython-314.pyc +0 -0
  177. package/pennyfarthing_scripts/dependencies/__pycache__/models.cpython-314.pyc +0 -0
  178. package/pennyfarthing_scripts/dependencies/analyze.py +155 -0
  179. package/pennyfarthing_scripts/dependencies/cli.py +72 -0
  180. package/pennyfarthing_scripts/dependencies/formatters.py +63 -0
  181. package/pennyfarthing_scripts/dependencies/models.py +39 -0
  182. package/pennyfarthing_scripts/healthscore/__init__.py +21 -0
  183. package/pennyfarthing_scripts/healthscore/__main__.py +6 -0
  184. package/pennyfarthing_scripts/healthscore/__pycache__/__init__.cpython-314.pyc +0 -0
  185. package/pennyfarthing_scripts/healthscore/__pycache__/__main__.cpython-314.pyc +0 -0
  186. package/pennyfarthing_scripts/healthscore/__pycache__/analyze.cpython-314.pyc +0 -0
  187. package/pennyfarthing_scripts/healthscore/__pycache__/cli.cpython-314.pyc +0 -0
  188. package/pennyfarthing_scripts/healthscore/__pycache__/formatters.cpython-314.pyc +0 -0
  189. package/pennyfarthing_scripts/healthscore/__pycache__/models.cpython-314.pyc +0 -0
  190. package/pennyfarthing_scripts/healthscore/analyze.py +161 -0
  191. package/pennyfarthing_scripts/healthscore/cli.py +76 -0
  192. package/pennyfarthing_scripts/healthscore/formatters.py +46 -0
  193. package/pennyfarthing_scripts/healthscore/models.py +44 -0
  194. package/pennyfarthing_scripts/hotspots/__pycache__/__init__.cpython-314.pyc +0 -0
  195. package/pennyfarthing_scripts/hotspots/__pycache__/__main__.cpython-314.pyc +0 -0
  196. package/pennyfarthing_scripts/hotspots/__pycache__/analyze.cpython-314.pyc +0 -0
  197. package/pennyfarthing_scripts/hotspots/__pycache__/cli.cpython-314.pyc +0 -0
  198. package/pennyfarthing_scripts/hotspots/__pycache__/formatters.cpython-314.pyc +0 -0
  199. package/pennyfarthing_scripts/hotspots/__pycache__/models.cpython-314.pyc +0 -0
  200. package/pennyfarthing_scripts/hotspots/analyze.py +28 -1
  201. package/pennyfarthing_scripts/hotspots/cli.py +11 -9
  202. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  203. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  204. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  205. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  206. package/pennyfarthing_scripts/jira/__pycache__/create.cpython-314.pyc +0 -0
  207. package/pennyfarthing_scripts/jira/__pycache__/operations.cpython-314.pyc +0 -0
  208. package/pennyfarthing_scripts/jira/__pycache__/reconcile.cpython-314.pyc +0 -0
  209. package/pennyfarthing_scripts/jira/bidirectional.py +42 -15
  210. package/pennyfarthing_scripts/jira/cli.py +78 -1
  211. package/pennyfarthing_scripts/jira/client.py +28 -0
  212. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  213. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  214. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  215. package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
  216. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  217. package/pennyfarthing_scripts/prime/workflow.py +5 -3
  218. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  219. package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
  220. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  221. package/pennyfarthing_scripts/sprint/__pycache__/epic_add.cpython-314.pyc +0 -0
  222. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  223. package/pennyfarthing_scripts/sprint/__pycache__/story_add.cpython-314.pyc +0 -0
  224. package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
  225. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  226. package/pennyfarthing_scripts/sprint/__pycache__/validate_cmd.cpython-314.pyc +0 -0
  227. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  228. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  229. package/pennyfarthing_scripts/sprint/__pycache__/yaml_io.cpython-314.pyc +0 -0
  230. package/pennyfarthing_scripts/sprint/archive.py +63 -6
  231. package/pennyfarthing_scripts/sprint/archive_epic.py +198 -85
  232. package/pennyfarthing_scripts/sprint/cli.py +1565 -65
  233. package/pennyfarthing_scripts/sprint/epic_add.py +173 -0
  234. package/pennyfarthing_scripts/sprint/loader.py +46 -2
  235. package/pennyfarthing_scripts/sprint/story_add.py +202 -27
  236. package/pennyfarthing_scripts/sprint/story_finish.py +211 -0
  237. package/pennyfarthing_scripts/sprint/validate_cmd.py +44 -5
  238. package/pennyfarthing_scripts/sprint/validator.py +13 -3
  239. package/pennyfarthing_scripts/sprint/work.py +43 -3
  240. package/pennyfarthing_scripts/sprint/yaml_io.py +124 -15
  241. package/pennyfarthing_scripts/tests/__pycache__/test_codemarkers.cpython-314-pytest-9.0.2.pyc +0 -0
  242. package/pennyfarthing_scripts/tests/__pycache__/test_healthscore.cpython-314-pytest-9.0.2.pyc +0 -0
  243. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
  244. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  245. package/pennyfarthing_scripts/tests/__pycache__/test_story_add.cpython-314-pytest-9.0.2.pyc +0 -0
  246. package/pennyfarthing_scripts/tests/__pycache__/test_story_update.cpython-314-pytest-9.0.2.pyc +0 -0
  247. package/pennyfarthing_scripts/tests/__pycache__/test_validate_cmd.cpython-314-pytest-9.0.2.pyc +0 -0
  248. package/pennyfarthing_scripts/tests/__pycache__/test_yaml_io.cpython-314-pytest-9.0.2.pyc +0 -0
  249. package/pennyfarthing_scripts/tests/test_codemarkers.py +682 -0
  250. package/pennyfarthing_scripts/tests/test_healthscore.py +524 -0
  251. package/pennyfarthing_scripts/tests/test_sprint_package.py +166 -0
  252. package/pennyfarthing_scripts/tests/test_yaml_io.py +117 -0
  253. package/pennyfarthing_scripts/theme/__init__.py +5 -0
  254. package/pennyfarthing_scripts/theme/__main__.py +6 -0
  255. package/pennyfarthing_scripts/theme/__pycache__/__init__.cpython-314.pyc +0 -0
  256. package/pennyfarthing_scripts/theme/__pycache__/cli.cpython-314.pyc +0 -0
  257. package/pennyfarthing_scripts/theme/cli.py +286 -0
  258. package/scripts/README.md +53 -0
  259. package/scripts/postinstall.cjs +34 -0
  260. package/pennyfarthing-dist/agents/workflow-status-check.md +0 -96
  261. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +0 -133
  262. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +0 -91
  263. package/pennyfarthing-dist/scripts/sprint/check-story.sh +0 -158
  264. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +0 -52
  265. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +0 -63
  266. package/pennyfarthing-dist/scripts/sprint/list-future.sh +0 -145
  267. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +0 -110
  268. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +0 -148
  269. package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +0 -415
  270. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +0 -33
  271. package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +0 -230
  272. package/pennyfarthing-dist/scripts/sprint/sprint-status.sh +0 -134
  273. package/pennyfarthing-dist/scripts/sprint/validate-sprint-yaml.sh +0 -139
  274. package/pennyfarthing-dist/skills/sprint/scripts/archive-story.sh +0 -101
  275. package/pennyfarthing-dist/skills/sprint/scripts/available-stories.sh +0 -97
  276. package/pennyfarthing-dist/skills/sprint/scripts/check-story.sh +0 -164
  277. package/pennyfarthing-dist/skills/sprint/scripts/create-jira-epic.sh +0 -23
  278. package/pennyfarthing-dist/skills/sprint/scripts/new-sprint.sh +0 -116
  279. package/pennyfarthing-dist/skills/sprint/scripts/promote-epic.sh +0 -164
  280. package/pennyfarthing-dist/skills/sprint/scripts/sprint-info.sh +0 -39
  281. package/pennyfarthing-dist/skills/sprint/scripts/sprint-status.sh +0 -147
  282. package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +0 -23
@@ -0,0 +1,524 @@
1
+ """
2
+ Tests for the healthscore module.
3
+
4
+ Covers all acceptance criteria for MSSCI-14470:
5
+ AC1: Module structure (cli.py, models.py, analyze.py, formatters.py)
6
+ AC2: Weighted scoring algorithm with 8 configurable dimensions
7
+ AC3: Each dimension 0-100, composite is weighted average 0-100
8
+ AC4: CLI command: pf healthscore analyze [--format table|json|csv] [--path DIR]
9
+ AC5: Result caching within 5-minute window
10
+ AC6: Cache stored in .pennyfarthing/.cache/healthscore/
11
+ AC7: ADR-0008 result pattern
12
+ AC8: Registered in main CLI
13
+ AC9: Tests cover scoring algorithm, caching, and CLI output
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import time
21
+ from dataclasses import asdict
22
+ from pathlib import Path
23
+ from unittest.mock import patch, MagicMock
24
+
25
+ import pytest
26
+ from click.testing import CliRunner
27
+
28
+ from pennyfarthing_scripts.healthscore.models import (
29
+ DimensionScore,
30
+ HealthscoreResult,
31
+ DEFAULT_WEIGHTS,
32
+ )
33
+ from pennyfarthing_scripts.healthscore.analyze import (
34
+ analyze_healthscore,
35
+ compute_composite_score,
36
+ get_cache_path,
37
+ read_cached_score,
38
+ write_cached_score,
39
+ )
40
+ from pennyfarthing_scripts.healthscore.formatters import (
41
+ format_table,
42
+ export_json,
43
+ export_csv,
44
+ )
45
+ from pennyfarthing_scripts.healthscore.cli import healthscore
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # AC1: Module structure
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class TestModuleStructure:
53
+ """AC1: New module at pennyfarthing_scripts/healthscore/ with standard files."""
54
+
55
+ def test_package_has_init(self):
56
+ """Module must be importable as a package."""
57
+ import pennyfarthing_scripts.healthscore
58
+ assert hasattr(pennyfarthing_scripts.healthscore, "HealthscoreResult")
59
+ assert hasattr(pennyfarthing_scripts.healthscore, "DimensionScore")
60
+ assert hasattr(pennyfarthing_scripts.healthscore, "DEFAULT_WEIGHTS")
61
+ assert hasattr(pennyfarthing_scripts.healthscore, "analyze_healthscore")
62
+
63
+ def test_models_module_exists(self):
64
+ """models.py must define DimensionScore and HealthscoreResult."""
65
+ from pennyfarthing_scripts.healthscore import models
66
+ assert hasattr(models, "DimensionScore")
67
+ assert hasattr(models, "HealthscoreResult")
68
+
69
+ def test_analyze_module_exists(self):
70
+ """analyze.py must define analyze_healthscore."""
71
+ from pennyfarthing_scripts.healthscore import analyze
72
+ assert hasattr(analyze, "analyze_healthscore")
73
+
74
+ def test_cli_module_exists(self):
75
+ """cli.py must define the click group."""
76
+ from pennyfarthing_scripts.healthscore import cli
77
+ assert hasattr(cli, "healthscore")
78
+
79
+ def test_formatters_module_exists(self):
80
+ """formatters.py must define table/json/csv formatters."""
81
+ from pennyfarthing_scripts.healthscore import formatters
82
+ assert hasattr(formatters, "format_table")
83
+ assert hasattr(formatters, "export_json")
84
+ assert hasattr(formatters, "export_csv")
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # AC2: Weighted scoring algorithm with 8 configurable dimensions
89
+ # ---------------------------------------------------------------------------
90
+
91
+ class TestWeightedScoring:
92
+ """AC2: 8 dimensions with configurable weights."""
93
+
94
+ def test_default_weights_has_8_dimensions(self):
95
+ """Must define exactly 8 dimensions."""
96
+ assert len(DEFAULT_WEIGHTS) == 8
97
+
98
+ def test_default_weights_sum_to_one(self):
99
+ """Default weights must sum to 1.0."""
100
+ total = sum(DEFAULT_WEIGHTS.values())
101
+ assert abs(total - 1.0) < 1e-9, f"Weights sum to {total}, expected 1.0"
102
+
103
+ def test_default_weight_keys(self):
104
+ """Must include all 8 named dimensions."""
105
+ expected = {
106
+ "churn", "todo_density", "complexity", "test_gaps",
107
+ "dead_code", "deprecation_debt", "dependency_freshness",
108
+ "agent_context_efficiency",
109
+ }
110
+ assert set(DEFAULT_WEIGHTS.keys()) == expected
111
+
112
+ def test_default_weight_values(self):
113
+ """Default weights must match spec."""
114
+ assert DEFAULT_WEIGHTS["churn"] == 0.15
115
+ assert DEFAULT_WEIGHTS["todo_density"] == 0.15
116
+ assert DEFAULT_WEIGHTS["complexity"] == 0.15
117
+ assert DEFAULT_WEIGHTS["test_gaps"] == 0.15
118
+ assert DEFAULT_WEIGHTS["dead_code"] == 0.10
119
+ assert DEFAULT_WEIGHTS["deprecation_debt"] == 0.10
120
+ assert DEFAULT_WEIGHTS["dependency_freshness"] == 0.10
121
+ assert DEFAULT_WEIGHTS["agent_context_efficiency"] == 0.10
122
+
123
+ def test_custom_weights_override_defaults(self):
124
+ """compute_composite_score must accept custom weights."""
125
+ custom = {k: 1.0 / 8 for k in DEFAULT_WEIGHTS}
126
+ scores = {k: 80.0 for k in DEFAULT_WEIGHTS}
127
+ result = compute_composite_score(scores, custom)
128
+ assert abs(result - 80.0) < 1e-9
129
+
130
+ def test_unequal_custom_weights(self):
131
+ """Asymmetric weights should shift composite score."""
132
+ weights = {k: 0.0 for k in DEFAULT_WEIGHTS}
133
+ weights["churn"] = 1.0 # All weight on churn
134
+
135
+ scores = {k: 50.0 for k in DEFAULT_WEIGHTS}
136
+ scores["churn"] = 100.0
137
+
138
+ result = compute_composite_score(scores, weights)
139
+ assert abs(result - 100.0) < 1e-9
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # AC3: Each dimension 0-100, composite is weighted average 0-100
144
+ # ---------------------------------------------------------------------------
145
+
146
+ class TestScoreRanges:
147
+ """AC3: Dimension scores 0-100, composite 0-100."""
148
+
149
+ def test_all_zeros_yields_zero(self):
150
+ """All dimensions at 0 → composite 0."""
151
+ scores = {k: 0.0 for k in DEFAULT_WEIGHTS}
152
+ result = compute_composite_score(scores, DEFAULT_WEIGHTS)
153
+ assert result == 0.0
154
+
155
+ def test_all_hundreds_yields_hundred(self):
156
+ """All dimensions at 100 → composite 100."""
157
+ scores = {k: 100.0 for k in DEFAULT_WEIGHTS}
158
+ result = compute_composite_score(scores, DEFAULT_WEIGHTS)
159
+ assert abs(result - 100.0) < 1e-9
160
+
161
+ def test_mixed_scores_weighted_average(self):
162
+ """Mixed scores should produce correct weighted average."""
163
+ scores = {
164
+ "churn": 80.0,
165
+ "todo_density": 60.0,
166
+ "complexity": 70.0,
167
+ "test_gaps": 50.0,
168
+ "dead_code": 90.0,
169
+ "deprecation_debt": 40.0,
170
+ "dependency_freshness": 85.0,
171
+ "agent_context_efficiency": 75.0,
172
+ }
173
+ expected = sum(scores[k] * DEFAULT_WEIGHTS[k] for k in DEFAULT_WEIGHTS)
174
+ result = compute_composite_score(scores, DEFAULT_WEIGHTS)
175
+ assert abs(result - expected) < 1e-9
176
+
177
+ def test_none_dimensions_excluded_and_renormalized(self):
178
+ """Unavailable dimensions (None) are excluded; remaining weights renormalize."""
179
+ scores = {k: None for k in DEFAULT_WEIGHTS}
180
+ scores["churn"] = 80.0
181
+ scores["complexity"] = 60.0
182
+ # Only churn (0.15) and complexity (0.15) available → renorm to 0.5 each
183
+ expected = (80.0 * 0.5) + (60.0 * 0.5)
184
+ result = compute_composite_score(scores, DEFAULT_WEIGHTS)
185
+ assert abs(result - expected) < 1e-9
186
+
187
+ def test_all_none_dimensions_returns_zero(self):
188
+ """All dimensions unavailable → composite 0."""
189
+ scores = {k: None for k in DEFAULT_WEIGHTS}
190
+ result = compute_composite_score(scores, DEFAULT_WEIGHTS)
191
+ assert result == 0.0
192
+
193
+ def test_composite_score_in_result_object(self):
194
+ """HealthscoreResult.composite_score should reflect computed value."""
195
+ result = HealthscoreResult(
196
+ success=True,
197
+ composite_score=73.5,
198
+ target_path="/tmp/project",
199
+ dimensions=[
200
+ DimensionScore(name="churn", score=80.0, weight=0.15),
201
+ DimensionScore(name="complexity", score=67.0, weight=0.15),
202
+ ],
203
+ )
204
+ assert 0.0 <= result.composite_score <= 100.0
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # AC4: CLI command
209
+ # ---------------------------------------------------------------------------
210
+
211
+ class TestCLI:
212
+ """AC4: pf healthscore analyze [--format table|json|csv] [--path DIR]."""
213
+
214
+ def test_help_shows_analyze_command(self):
215
+ """CLI group must expose the analyze subcommand."""
216
+ runner = CliRunner()
217
+ result = runner.invoke(healthscore, ["--help"])
218
+ assert result.exit_code == 0
219
+ assert "analyze" in result.output
220
+
221
+ def test_analyze_help_shows_options(self):
222
+ """analyze command must show --format, --path, --output, --no-cache."""
223
+ runner = CliRunner()
224
+ result = runner.invoke(healthscore, ["analyze", "--help"])
225
+ assert result.exit_code == 0
226
+ assert "--format" in result.output
227
+ assert "--path" in result.output
228
+ assert "--output" in result.output
229
+ assert "--no-cache" in result.output
230
+
231
+ def test_analyze_format_choices(self):
232
+ """--format must accept table, json, csv."""
233
+ runner = CliRunner()
234
+ result = runner.invoke(healthscore, ["analyze", "--help"])
235
+ assert "table" in result.output
236
+ assert "json" in result.output
237
+ assert "csv" in result.output
238
+
239
+ def test_analyze_json_output_is_valid_json(self):
240
+ """--format json must produce parseable JSON."""
241
+ mock_result = HealthscoreResult(
242
+ success=True,
243
+ composite_score=72.5,
244
+ target_path="/tmp/project",
245
+ dimensions=[
246
+ DimensionScore(name="churn", score=80.0, weight=0.15),
247
+ ],
248
+ )
249
+ with patch(
250
+ "pennyfarthing_scripts.healthscore.cli._run_analysis",
251
+ return_value=mock_result,
252
+ ):
253
+ runner = CliRunner()
254
+ result = runner.invoke(healthscore, ["analyze", "--format", "json"])
255
+ assert result.exit_code == 0
256
+ data = json.loads(result.output)
257
+ assert data["success"] is True
258
+ assert data["composite_score"] == 72.5
259
+
260
+ def test_analyze_table_output_has_score_header(self):
261
+ """Table output must include score and dimension labels."""
262
+ mock_result = HealthscoreResult(
263
+ success=True,
264
+ composite_score=72.5,
265
+ target_path="/tmp/project",
266
+ dimensions=[
267
+ DimensionScore(name="churn", score=80.0, weight=0.15),
268
+ DimensionScore(name="complexity", score=65.0, weight=0.15),
269
+ ],
270
+ )
271
+ with patch(
272
+ "pennyfarthing_scripts.healthscore.cli._run_analysis",
273
+ return_value=mock_result,
274
+ ):
275
+ runner = CliRunner()
276
+ result = runner.invoke(healthscore, ["analyze", "--format", "table"])
277
+ assert result.exit_code == 0
278
+ assert "churn" in result.output.lower() or "Churn" in result.output
279
+
280
+ def test_analyze_csv_output_has_header_row(self):
281
+ """CSV output must include header row with dimension names."""
282
+ mock_result = HealthscoreResult(
283
+ success=True,
284
+ composite_score=72.5,
285
+ target_path="/tmp/project",
286
+ dimensions=[
287
+ DimensionScore(name="churn", score=80.0, weight=0.15),
288
+ ],
289
+ )
290
+ with patch(
291
+ "pennyfarthing_scripts.healthscore.cli._run_analysis",
292
+ return_value=mock_result,
293
+ ):
294
+ runner = CliRunner()
295
+ result = runner.invoke(healthscore, ["analyze", "--format", "csv"])
296
+ assert result.exit_code == 0
297
+ lines = result.output.strip().split("\n")
298
+ assert len(lines) >= 2 # header + at least one data row
299
+
300
+ def test_analyze_output_to_file(self, tmp_path):
301
+ """--output must write result to file."""
302
+ mock_result = HealthscoreResult(
303
+ success=True,
304
+ composite_score=72.5,
305
+ target_path="/tmp/project",
306
+ dimensions=[],
307
+ )
308
+ output_file = tmp_path / "result.json"
309
+ with patch(
310
+ "pennyfarthing_scripts.healthscore.cli._run_analysis",
311
+ return_value=mock_result,
312
+ ):
313
+ runner = CliRunner()
314
+ result = runner.invoke(healthscore, [
315
+ "analyze", "--format", "json", "--output", str(output_file)
316
+ ])
317
+ assert result.exit_code == 0
318
+ assert output_file.exists()
319
+ data = json.loads(output_file.read_text())
320
+ assert data["success"] is True
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # AC5: Result caching within 5-minute window
325
+ # ---------------------------------------------------------------------------
326
+
327
+ class TestCaching:
328
+ """AC5: Component scores cached, reused within 5-minute window."""
329
+
330
+ def test_write_then_read_cached_score(self, tmp_path):
331
+ """Written score must be readable back."""
332
+ write_cached_score(tmp_path, "churn", 85.0)
333
+ result = read_cached_score(tmp_path, "churn", ttl=300)
334
+ assert result == 85.0
335
+
336
+ def test_cache_miss_returns_none(self, tmp_path):
337
+ """Reading a non-existent cache entry returns None."""
338
+ result = read_cached_score(tmp_path, "nonexistent", ttl=300)
339
+ assert result is None
340
+
341
+ def test_expired_cache_returns_none(self, tmp_path):
342
+ """Cache entry older than TTL returns None."""
343
+ write_cached_score(tmp_path, "churn", 85.0)
344
+ # Read with 0 TTL → always expired
345
+ result = read_cached_score(tmp_path, "churn", ttl=0)
346
+ assert result is None
347
+
348
+ def test_cache_ttl_boundary(self, tmp_path):
349
+ """Cache at exactly TTL boundary should still be valid."""
350
+ write_cached_score(tmp_path, "churn", 85.0)
351
+ # Read within generous TTL
352
+ result = read_cached_score(tmp_path, "churn", ttl=300)
353
+ assert result == 85.0
354
+
355
+ def test_multiple_dimensions_cached_independently(self, tmp_path):
356
+ """Each dimension has its own cache entry."""
357
+ write_cached_score(tmp_path, "churn", 85.0)
358
+ write_cached_score(tmp_path, "complexity", 60.0)
359
+ assert read_cached_score(tmp_path, "churn", ttl=300) == 85.0
360
+ assert read_cached_score(tmp_path, "complexity", ttl=300) == 60.0
361
+
362
+ def test_overwrite_cached_score(self, tmp_path):
363
+ """Writing a new score overwrites the previous value."""
364
+ write_cached_score(tmp_path, "churn", 85.0)
365
+ write_cached_score(tmp_path, "churn", 42.0)
366
+ result = read_cached_score(tmp_path, "churn", ttl=300)
367
+ assert result == 42.0
368
+
369
+ def test_no_cache_flag_bypasses_cache(self):
370
+ """analyze_healthscore with cache_ttl=0 must not use cached results."""
371
+ # This tests that the analyze function respects cache_ttl=0
372
+ # (will fail until implementation — that's the point)
373
+ with patch(
374
+ "pennyfarthing_scripts.healthscore.analyze.read_cached_score",
375
+ return_value=99.0,
376
+ ) as mock_read:
377
+ result = asyncio.run(
378
+ analyze_healthscore(Path("/tmp/project"), cache_ttl=0)
379
+ )
380
+ # With ttl=0, cached values should not be used
381
+ mock_read.assert_not_called()
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # AC6: Cache stored in .pennyfarthing/.cache/healthscore/
386
+ # ---------------------------------------------------------------------------
387
+
388
+ class TestCacheLocation:
389
+ """AC6: Cache files stored in .pennyfarthing/.cache/healthscore/."""
390
+
391
+ def test_cache_path_under_pennyfarthing(self, tmp_path):
392
+ """get_cache_path must return a path under .pennyfarthing/.cache/healthscore/."""
393
+ cache_dir = get_cache_path(tmp_path)
394
+ path_str = str(cache_dir)
395
+ assert ".pennyfarthing" in path_str
396
+ assert ".cache" in path_str
397
+ assert "healthscore" in path_str
398
+
399
+ def test_cache_path_includes_target_directory(self, tmp_path):
400
+ """Cache path should be specific to the target directory."""
401
+ path_a = get_cache_path(tmp_path / "project-a")
402
+ path_b = get_cache_path(tmp_path / "project-b")
403
+ # Different projects should get different cache dirs (or at least different keys)
404
+ assert path_a != path_b
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # AC7: ADR-0008 result pattern
409
+ # ---------------------------------------------------------------------------
410
+
411
+ class TestADR0008Pattern:
412
+ """AC7: HealthscoreResult follows ADR-0008 pattern."""
413
+
414
+ def test_result_has_success_field(self):
415
+ """Result must have a success boolean."""
416
+ result = HealthscoreResult(success=True)
417
+ assert result.success is True
418
+
419
+ def test_result_has_error_field(self):
420
+ """Result must have an optional error field."""
421
+ result = HealthscoreResult(success=False, error="something broke")
422
+ assert result.error == "something broke"
423
+
424
+ def test_result_serializable_with_asdict(self):
425
+ """Result must be serializable via dataclasses.asdict."""
426
+ result = HealthscoreResult(
427
+ success=True,
428
+ composite_score=72.5,
429
+ target_path="/tmp/project",
430
+ dimensions=[
431
+ DimensionScore(name="churn", score=80.0, weight=0.15),
432
+ ],
433
+ )
434
+ d = asdict(result)
435
+ assert d["success"] is True
436
+ assert d["composite_score"] == 72.5
437
+ assert len(d["dimensions"]) == 1
438
+
439
+ def test_result_json_roundtrip(self):
440
+ """Result must survive JSON serialization."""
441
+ result = HealthscoreResult(
442
+ success=True,
443
+ composite_score=72.5,
444
+ target_path="/tmp/project",
445
+ dimensions=[
446
+ DimensionScore(name="churn", score=80.0, weight=0.15),
447
+ ],
448
+ )
449
+ d = asdict(result)
450
+ text = json.dumps(d, default=str)
451
+ loaded = json.loads(text)
452
+ assert loaded["success"] is True
453
+ assert loaded["composite_score"] == 72.5
454
+
455
+ def test_dimension_score_has_name_score_weight(self):
456
+ """DimensionScore must have name, score, weight, error fields."""
457
+ ds = DimensionScore(name="churn", score=80.0, weight=0.15)
458
+ assert ds.name == "churn"
459
+ assert ds.score == 80.0
460
+ assert ds.weight == 0.15
461
+ assert ds.error is None
462
+
463
+ def test_dimension_score_none_means_unavailable(self):
464
+ """DimensionScore with score=None means dimension unavailable."""
465
+ ds = DimensionScore(name="churn", score=None, weight=0.15, error="no data")
466
+ assert ds.score is None
467
+ assert ds.error == "no data"
468
+
469
+ def test_error_result(self):
470
+ """Failed result has success=False and error message."""
471
+ result = HealthscoreResult(success=False, error="target not found")
472
+ assert result.success is False
473
+ assert result.error == "target not found"
474
+ assert result.composite_score == 0.0
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # AC8: Registered in main CLI
479
+ # ---------------------------------------------------------------------------
480
+
481
+ class TestMainCLIRegistration:
482
+ """AC8: healthscore command registered in pennyfarthing_scripts/cli.py."""
483
+
484
+ def test_healthscore_registered_in_main_cli(self):
485
+ """Main CLI must have a 'healthscore' command group."""
486
+ from pennyfarthing_scripts.cli import cli
487
+ command_names = [cmd for cmd in cli.commands]
488
+ assert "healthscore" in command_names
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # AC9: Full integration — analyze_healthscore returns real result
493
+ # ---------------------------------------------------------------------------
494
+
495
+ class TestAnalyzeIntegration:
496
+ """AC9: End-to-end analysis returns HealthscoreResult."""
497
+
498
+ def test_analyze_returns_healthscore_result(self):
499
+ """analyze_healthscore must return a HealthscoreResult."""
500
+ result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent")))
501
+ assert isinstance(result, HealthscoreResult)
502
+
503
+ def test_analyze_result_has_dimensions(self):
504
+ """Result must include dimension scores list."""
505
+ result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent")))
506
+ assert isinstance(result.dimensions, list)
507
+
508
+ def test_analyze_with_custom_weights(self):
509
+ """Custom weights must be accepted and applied."""
510
+ custom = {k: 1.0 / 8 for k in DEFAULT_WEIGHTS}
511
+ result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent"), weights=custom))
512
+ assert isinstance(result, HealthscoreResult)
513
+
514
+ def test_analyze_result_cached_flag(self):
515
+ """Result must indicate whether values came from cache."""
516
+ result = asyncio.run(analyze_healthscore(Path("/tmp/nonexistent")))
517
+ assert isinstance(result.cached, bool)
518
+
519
+ def test_analyze_graceful_on_missing_path(self):
520
+ """Missing target path should return error result, not raise."""
521
+ result = asyncio.run(analyze_healthscore(Path("/tmp/definitely-not-a-real-path-xyz123")))
522
+ assert isinstance(result, HealthscoreResult)
523
+ # Should either succeed with degraded scores or fail gracefully
524
+ assert isinstance(result.success, bool)
@@ -222,6 +222,172 @@ class TestSprintWork:
222
222
  assert result.get("success") is False or "error" in result
223
223
 
224
224
 
225
+ class TestShardedSprint:
226
+ """Tests for sharded per-epic sprint format."""
227
+
228
+ def _create_sharded_sprint(self, tmp_path: Path) -> Path:
229
+ """Create a sharded sprint structure in tmp_path."""
230
+ sprint_dir = tmp_path / "sprint"
231
+ sprint_dir.mkdir()
232
+
233
+ # Index file with string refs
234
+ index = """\
235
+ sprint:
236
+ name: TO Sprint 2606
237
+ jira_sprint_id: 309
238
+ jira_sprint_name: TO Sprint 2606
239
+ goal: Test sharding
240
+ start_date: 2026-02-02
241
+ end_date: 2026-02-15
242
+ status: active
243
+ number: 2606
244
+ epics:
245
+ - MSSCI-14298
246
+ - epic-40
247
+ stories: []
248
+ """
249
+ (sprint_dir / "current-sprint.yaml").write_text(index)
250
+
251
+ # Shard file 1: Jira-style ID
252
+ (sprint_dir / "epic-MSSCI-14298.yaml").write_text("""\
253
+ id: MSSCI-14298
254
+ type: epic
255
+ title: 'Epic: Stepped Workflow'
256
+ priority: P1
257
+ status: in_progress
258
+ stories:
259
+ - id: MSSCI-14299
260
+ title: Wire up stepped workflow
261
+ points: 5
262
+ priority: P0
263
+ status: done
264
+ """)
265
+
266
+ # Shard file 2: internal ID
267
+ (sprint_dir / "epic-epic-40.yaml").write_text("""\
268
+ id: epic-40
269
+ type: epic
270
+ title: 'Epic: Scale Adaptation'
271
+ priority: P2
272
+ status: backlog
273
+ stories:
274
+ - id: 40-1
275
+ title: First story
276
+ points: 3
277
+ priority: P1
278
+ status: backlog
279
+ - id: 40-2
280
+ title: Second story
281
+ points: 2
282
+ priority: P1
283
+ status: ready
284
+ """)
285
+ return tmp_path
286
+
287
+ def test_load_sprint_merges_shards(self, tmp_path: Path) -> None:
288
+ """load_sprint should merge sharded epic files into full dicts."""
289
+ from pennyfarthing_scripts.sprint.loader import load_sprint
290
+
291
+ root = self._create_sharded_sprint(tmp_path)
292
+ data = load_sprint(project_root=root)
293
+
294
+ assert data is not None
295
+ assert len(data["epics"]) == 2
296
+ assert isinstance(data["epics"][0], dict)
297
+ assert data["epics"][0]["id"] == "MSSCI-14298"
298
+ assert data["epics"][1]["id"] == "epic-40"
299
+
300
+ def test_load_sprint_merges_stories(self, tmp_path: Path) -> None:
301
+ """Merged epics should contain their stories."""
302
+ from pennyfarthing_scripts.sprint.loader import load_sprint
303
+
304
+ root = self._create_sharded_sprint(tmp_path)
305
+ data = load_sprint(project_root=root)
306
+
307
+ assert len(data["epics"][0]["stories"]) == 1
308
+ assert len(data["epics"][1]["stories"]) == 2
309
+ assert data["epics"][1]["stories"][0]["id"] == "40-1"
310
+
311
+ def test_load_sprint_non_sharded_unchanged(self, tmp_path: Path) -> None:
312
+ """load_sprint should pass through non-sharded data unchanged."""
313
+ from pennyfarthing_scripts.sprint.loader import load_sprint
314
+
315
+ sprint_dir = tmp_path / "sprint"
316
+ sprint_dir.mkdir()
317
+ (sprint_dir / "current-sprint.yaml").write_text("""\
318
+ sprint:
319
+ name: Test
320
+ jira_sprint_id: 1
321
+ jira_sprint_name: Test
322
+ goal: Test
323
+ start_date: 2026-01-01
324
+ end_date: 2026-01-15
325
+ status: active
326
+ number: 1
327
+ epics:
328
+ - id: epic-1
329
+ title: Inline Epic
330
+ stories:
331
+ - id: 1-1
332
+ title: Story
333
+ points: 1
334
+ status: backlog
335
+ """)
336
+ data = load_sprint(project_root=tmp_path)
337
+
338
+ assert data is not None
339
+ assert isinstance(data["epics"][0], dict)
340
+ assert data["epics"][0]["id"] == "epic-1"
341
+
342
+ def test_get_all_stories_with_shards(self, tmp_path: Path) -> None:
343
+ """get_all_stories should return stories from merged shards."""
344
+ from pennyfarthing_scripts.sprint.loader import get_all_stories, load_sprint
345
+ from unittest.mock import patch
346
+
347
+ root = self._create_sharded_sprint(tmp_path)
348
+
349
+ with patch("pennyfarthing_scripts.sprint.loader.get_project_root", return_value=root):
350
+ stories = get_all_stories()
351
+
352
+ assert len(stories) == 3
353
+ ids = {s["id"] for s in stories}
354
+ assert "MSSCI-14299" in ids
355
+ assert "40-1" in ids
356
+ assert "40-2" in ids
357
+
358
+ def test_find_epic_with_jira_id(self, tmp_path: Path) -> None:
359
+ """find_epic should work with Jira-style epic IDs after merge."""
360
+ from pennyfarthing_scripts.sprint.loader import find_epic, load_sprint
361
+
362
+ root = self._create_sharded_sprint(tmp_path)
363
+ data = load_sprint(project_root=root)
364
+
365
+ epic = find_epic(data, "MSSCI-14298")
366
+ assert epic is not None
367
+ assert epic["title"] == "Epic: Stepped Workflow"
368
+
369
+ def test_backlog_count_with_shards(self, tmp_path: Path) -> None:
370
+ """get_backlog_count should count stories from merged shards."""
371
+ from pennyfarthing_scripts.prime.workflow import get_backlog_count
372
+
373
+ root = self._create_sharded_sprint(tmp_path)
374
+ count = get_backlog_count(root)
375
+
376
+ # 40-1 is backlog, 40-2 is ready -> 2 stories
377
+ assert count == 2
378
+
379
+ def test_backlog_count_defensive_on_strings(self) -> None:
380
+ """get_backlog_count should not crash on string epics."""
381
+ from pennyfarthing_scripts.prime.workflow import get_backlog_count
382
+ from unittest.mock import patch
383
+
384
+ fake_data = {"epics": ["MSSCI-14298", "MSSCI-14317"]}
385
+ with patch("pennyfarthing_scripts.sprint.loader.load_sprint", return_value=fake_data):
386
+ count = get_backlog_count(Path("/fake"))
387
+
388
+ assert count == 0
389
+
390
+
225
391
  class TestSprintArchive:
226
392
  """Tests for sprint/archive.py module."""
227
393