@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,211 @@
1
+ """Finish a completed story: archive, merge PR, update Jira, update YAML.
2
+
3
+ Replaces finish-story.sh with native Python that correctly handles
4
+ sharded epic YAML files via read_sprint/write_sprint.
5
+
6
+ Steps:
7
+ 1. Archive session file to sprint/archive/{jira-key}-session.md
8
+ 2. Squash merge PR via gh (handle already-merged)
9
+ 3. Transition Jira to Done
10
+ 4. Update sprint YAML (status: done, completed date, remove assigned_to)
11
+ 5. Archive completed epics
12
+ 6. Git cleanup (checkout develop, pull, delete local branch)
13
+ 7. Remove session file
14
+ """
15
+
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ from datetime import date
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from pennyfarthing_scripts.sprint.loader import find_epic, find_story
24
+ from pennyfarthing_scripts.sprint.yaml_io import read_sprint, write_sprint
25
+
26
+
27
+ SESSION_FIELD_RE = re.compile(r"\*\*(\w[\w\s]*):\*\*\s*(.*)")
28
+
29
+
30
+ def _parse_session(session_path: Path) -> dict[str, str]:
31
+ """Extract metadata fields from a session markdown file.
32
+
33
+ Parses lines like ``**Jira:** MSSCI-14467`` and
34
+ ``**PR:** #748 - title`` into a dict.
35
+ """
36
+ fields: dict[str, str] = {}
37
+ if not session_path.exists():
38
+ return fields
39
+ for line in session_path.read_text().splitlines():
40
+ m = SESSION_FIELD_RE.search(line)
41
+ if m:
42
+ key = m.group(1).strip().lower()
43
+ value = m.group(2).strip()
44
+ fields[key] = value
45
+ return fields
46
+
47
+
48
+ def _extract_jira_key(fields: dict[str, str]) -> str | None:
49
+ """Get Jira key from session fields, handling markdown link format."""
50
+ raw = fields.get("jira", "")
51
+ # Strip markdown link: [MSSCI-14467](https://...)
52
+ raw = re.sub(r"\[([^\]]+)\].*", r"\1", raw).strip()
53
+ if re.match(r"^MSSCI-\d+$", raw):
54
+ return raw
55
+ return None
56
+
57
+
58
+ def _extract_pr_number(fields: dict[str, str]) -> str | None:
59
+ """Get PR number from session fields like ``#748 - title``."""
60
+ raw = fields.get("pr", "")
61
+ m = re.search(r"#(\d+)", raw)
62
+ return m.group(1) if m else None
63
+
64
+
65
+ def _extract_branch(fields: dict[str, str]) -> str | None:
66
+ """Get branch name, stripping trailing annotations like ``(pushed)``."""
67
+ raw = fields.get("branch", "")
68
+ return re.sub(r"\s*\(.*\)\s*$", "", raw).strip() or None
69
+
70
+
71
+ def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
72
+ """Run a subprocess with sane defaults."""
73
+ return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
74
+
75
+
76
+ def finish_story(
77
+ project_root: Path,
78
+ story_id: str,
79
+ *,
80
+ dry_run: bool = False,
81
+ ) -> dict[str, Any]:
82
+ """Finish a story: archive, merge, update Jira, update YAML, clean up.
83
+
84
+ Args:
85
+ project_root: Project root directory.
86
+ story_id: Story ID (e.g., "83-2").
87
+ dry_run: If True, report what would happen without side-effects.
88
+
89
+ Returns:
90
+ Result dict ``{success, data?, error?, steps?}``.
91
+ """
92
+ session_path = project_root / ".session" / f"{story_id}-session.md"
93
+ sprint_path = project_root / "sprint" / "current-sprint.yaml"
94
+ archive_dir = project_root / "sprint" / "archive"
95
+ archive_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ # --- Validate session ---
98
+ if not session_path.exists():
99
+ return {"success": False, "error": f"Session file not found: {session_path}"}
100
+
101
+ fields = _parse_session(session_path)
102
+ jira_key = _extract_jira_key(fields)
103
+ branch = _extract_branch(fields)
104
+ pr_number = _extract_pr_number(fields)
105
+
106
+ # Fallback: resolve Jira key from sprint YAML
107
+ if not jira_key:
108
+ try:
109
+ data = read_sprint(sprint_path)
110
+ parts = story_id.split("-")
111
+ if len(parts) >= 2:
112
+ epic = find_epic(data, parts[0])
113
+ story = find_story(epic, story_id) if epic else None
114
+ if story:
115
+ jira_key = story.get("jira")
116
+ except Exception:
117
+ pass
118
+
119
+ if not jira_key:
120
+ return {"success": False, "error": f"Could not determine Jira key for {story_id}"}
121
+
122
+ # Fallback: resolve PR from GitHub if not in session
123
+ if not pr_number and branch:
124
+ result = _run(["gh", "pr", "list", "--head", branch, "--json", "number", "--jq", ".[0].number"])
125
+ if result.returncode == 0 and result.stdout.strip():
126
+ pr_number = result.stdout.strip()
127
+
128
+ today = date.today().isoformat()
129
+ steps: list[dict[str, Any]] = []
130
+
131
+ if dry_run:
132
+ steps.append({"step": 1, "action": f"Archive session → {archive_dir / f'{jira_key}-session.md'}"})
133
+ if pr_number:
134
+ steps.append({"step": 2, "action": f"Merge PR #{pr_number} (squash, delete branch)"})
135
+ else:
136
+ steps.append({"step": 2, "action": "No PR to merge"})
137
+ steps.append({"step": 3, "action": f"Transition {jira_key} to Done"})
138
+ steps.append({"step": 4, "action": f"Update sprint YAML (status: done, completed: {today})"})
139
+ steps.append({"step": 5, "action": "Archive completed epics"})
140
+ steps.append({"step": 6, "action": f"Delete local branch: {branch}"})
141
+ steps.append({"step": 7, "action": "Remove session file"})
142
+ return {"success": True, "dry_run": True, "jira_key": jira_key, "steps": steps}
143
+
144
+ # --- Step 1: Archive session ---
145
+ archive_dest = archive_dir / f"{jira_key}-session.md"
146
+ shutil.copy2(session_path, archive_dest)
147
+ steps.append({"step": 1, "action": "archive_session", "dest": str(archive_dest)})
148
+
149
+ # --- Step 2: Merge PR ---
150
+ if pr_number:
151
+ result = _run(["gh", "pr", "merge", pr_number, "--squash", "--delete-branch"])
152
+ if result.returncode == 0:
153
+ steps.append({"step": 2, "action": "merge_pr", "pr": pr_number})
154
+ else:
155
+ steps.append({"step": 2, "action": "merge_pr", "pr": pr_number, "warning": "Already merged or failed"})
156
+ else:
157
+ steps.append({"step": 2, "action": "merge_pr", "skipped": True})
158
+
159
+ # --- Step 3: Transition Jira ---
160
+ result = _run(["jira", "issue", "move", jira_key, "Done"])
161
+ if result.returncode == 0:
162
+ steps.append({"step": 3, "action": "jira_done", "key": jira_key})
163
+ else:
164
+ steps.append({"step": 3, "action": "jira_done", "key": jira_key, "warning": "Already Done or failed"})
165
+
166
+ # --- Step 4: Update sprint YAML ---
167
+ try:
168
+ data = read_sprint(sprint_path)
169
+ parts = story_id.split("-")
170
+ if len(parts) < 2:
171
+ steps.append({"step": 4, "action": "yaml_update", "error": f"Invalid story ID: {story_id}"})
172
+ else:
173
+ epic = find_epic(data, parts[0])
174
+ story = find_story(epic, story_id) if epic else None
175
+ if story:
176
+ story["status"] = "done"
177
+ story["completed"] = today
178
+ if "assigned_to" in story:
179
+ del story["assigned_to"]
180
+ write_sprint(sprint_path, data)
181
+ steps.append({"step": 4, "action": "yaml_update", "status": "done", "completed": today})
182
+ else:
183
+ steps.append({"step": 4, "action": "yaml_update", "warning": f"Story {story_id} not found in YAML"})
184
+ except Exception as exc:
185
+ steps.append({"step": 4, "action": "yaml_update", "error": str(exc)})
186
+
187
+ # --- Step 5: Archive completed epics ---
188
+ result = _run(
189
+ ["python", "-m", "pennyfarthing_scripts.cli", "sprint", "epic", "archive"],
190
+ cwd=str(project_root),
191
+ )
192
+ steps.append({"step": 5, "action": "archive_epics", "ran": True})
193
+
194
+ # --- Step 6: Git cleanup ---
195
+ _run(["git", "checkout", "develop"], cwd=str(project_root))
196
+ _run(["git", "pull", "origin", "develop"], cwd=str(project_root))
197
+ if branch:
198
+ _run(["git", "branch", "-d", branch], cwd=str(project_root))
199
+ steps.append({"step": 6, "action": "git_cleanup", "branch": branch})
200
+
201
+ # --- Step 7: Remove session file ---
202
+ if session_path.exists():
203
+ session_path.unlink()
204
+ steps.append({"step": 7, "action": "remove_session"})
205
+
206
+ return {
207
+ "success": True,
208
+ "story_id": story_id,
209
+ "jira_key": jira_key,
210
+ "steps": steps,
211
+ }
@@ -15,7 +15,14 @@ from pathlib import Path
15
15
  import click
16
16
  import yaml
17
17
 
18
- from pennyfarthing_scripts.sprint.validator import validate_full_sprint, validate_future
18
+ from pennyfarthing_scripts.sprint.validator import (
19
+ REQUIRED_INITIATIVE_FIELDS,
20
+ VALID_INITIATIVE_STATUSES,
21
+ ValidationResult,
22
+ validate_epic,
23
+ validate_full_sprint,
24
+ validate_future,
25
+ )
19
26
  from pennyfarthing_scripts.sprint.yaml_io import (
20
27
  EPIC_KEY_ORDER,
21
28
  SPRINT_KEY_ORDER,
@@ -142,6 +149,25 @@ def check_format_drift(path: Path) -> list[FormatIssue]:
142
149
  return issues
143
150
 
144
151
 
152
+ def _validate_initiative_shard(data: dict) -> ValidationResult:
153
+ """Validate a standalone initiative shard file (initiative-*.yaml)."""
154
+ result = ValidationResult(valid=True)
155
+ if not isinstance(data, dict):
156
+ result.add_error("Initiative shard must be a mapping", "")
157
+ return result
158
+ for field_name in REQUIRED_INITIATIVE_FIELDS:
159
+ if field_name not in data:
160
+ result.add_error(f"Missing required field: {field_name}", field_name)
161
+ if "status" in data:
162
+ status = data["status"]
163
+ if status and status not in VALID_INITIATIVE_STATUSES:
164
+ result.add_error(
165
+ f"Invalid initiative status '{status}'. Must be one of: {', '.join(sorted(VALID_INITIATIVE_STATUSES))}",
166
+ "status",
167
+ )
168
+ return result
169
+
170
+
145
171
  def validate_sprint_yaml(path: Path, fix: bool = False) -> ValidateResult:
146
172
  """Validate a sprint YAML file for syntax, schema, and format issues.
147
173
 
@@ -204,8 +230,21 @@ def validate_sprint_yaml(path: Path, fix: bool = False) -> ValidateResult:
204
230
  return result
205
231
 
206
232
  # Step 2: Schema validation — detect file type and use appropriate validator
233
+ #
234
+ # Shard files (epic-*.yaml, initiative-*.yaml) are standalone fragments
235
+ # that don't have the full sprint/future structure. Validate their
236
+ # internal structure only — the index files handle cross-references.
237
+ is_epic_shard = path.name.startswith("epic-") and path.name.endswith(".yaml")
238
+ is_initiative_shard = path.name.startswith("initiative-") and path.name.endswith(".yaml")
207
239
  is_future = path.name == "future.yaml" or "future" in data
208
- if is_future:
240
+
241
+ if is_epic_shard:
242
+ # Epic shard: validate as a single epic (has id, title, stories)
243
+ schema_result = validate_epic(data, set(), 0)
244
+ elif is_initiative_shard:
245
+ # Initiative shard: validate as a single initiative (has name, status)
246
+ schema_result = _validate_initiative_shard(data)
247
+ elif is_future:
209
248
  schema_result = validate_future(data)
210
249
  else:
211
250
  schema_result = validate_full_sprint(data)
@@ -218,13 +257,13 @@ def validate_sprint_yaml(path: Path, fix: bool = False) -> ValidateResult:
218
257
  category="schema",
219
258
  ))
220
259
 
221
- # Step 3: Format drift detection (sprint files only — future.yaml has different structure)
222
- if not is_future:
260
+ # Step 3: Format drift detection (sprint files only — shards and future.yaml have different structure)
261
+ if not is_future and not is_epic_shard and not is_initiative_shard:
223
262
  format_issues = check_format_drift(path)
224
263
  result.format_issues = format_issues
225
264
 
226
265
  # Step 4: Fix if requested (only format issues, not schema; sprint files only)
227
- if fix and not is_future and path.exists():
266
+ if fix and not is_future and not is_epic_shard and not is_initiative_shard and path.exists():
228
267
  try:
229
268
  canon_data = read_sprint(path)
230
269
  write_sprint(path, canon_data)
@@ -65,7 +65,7 @@ class ValidationResult:
65
65
  # =============================================================================
66
66
 
67
67
  VALID_SPRINT_STATUSES = {"active", "closed"}
68
- VALID_STORY_STATUSES = {"backlog", "ready", "in_progress", "done", "canceled"}
68
+ VALID_STORY_STATUSES = {"backlog", "ready", "in_progress", "done", "canceled", "planning"}
69
69
  JIRA_KEY_PATTERN = re.compile(r"^MSSCI-\d{5}$")
70
70
  ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
71
71
 
@@ -87,7 +87,7 @@ REQUIRED_FUTURE_EPIC_FIELDS = {"id", "title", "points"}
87
87
  # Required fields for future.yaml story (what promote-epic.sh transforms)
88
88
  REQUIRED_FUTURE_STORY_FIELDS = {"id", "title", "points"}
89
89
 
90
- VALID_INITIATIVE_STATUSES = {"ready", "planning", "blocked", "research_complete", "backlog", "complete"}
90
+ VALID_INITIATIVE_STATUSES = {"ready", "planning", "blocked", "research_complete", "backlog", "complete", "canceled"}
91
91
 
92
92
 
93
93
  # =============================================================================
@@ -282,10 +282,13 @@ def validate_full_sprint(data: dict[str, Any]) -> ValidationResult:
282
282
  sprint_result = validate_sprint(data)
283
283
  result.merge(sprint_result)
284
284
 
285
- # Validate epics
285
+ # Validate epics (skip if sharded — epics are string refs to shard files)
286
286
  if "epics" in data:
287
287
  all_story_ids: set[str] = set()
288
288
  for idx, epic in enumerate(data["epics"]):
289
+ # Sharded format: epics are string refs, not dicts
290
+ if isinstance(epic, str):
291
+ continue
289
292
  epic_result = validate_epic(epic, all_story_ids, idx)
290
293
  result.merge(epic_result)
291
294
 
@@ -349,6 +352,9 @@ def validate_future(data: dict[str, Any]) -> ValidationResult:
349
352
  return result
350
353
 
351
354
  for i, initiative in enumerate(initiatives):
355
+ # Sharded format: initiatives are string slugs, not dicts
356
+ if isinstance(initiative, str):
357
+ continue
352
358
  if not isinstance(initiative, dict):
353
359
  result.add_error(f"Initiative must be a mapping", f"future.initiatives[{i}]")
354
360
  continue
@@ -503,6 +509,10 @@ def validate_sprint_file(file_path: Path) -> ValidationResult:
503
509
  )
504
510
  return result
505
511
 
512
+ # Merge sharded epic files if present
513
+ from pennyfarthing_scripts.sprint.loader import _merge_epic_shards
514
+ data = _merge_epic_shards(data, file_path.parent)
515
+
506
516
  # Validate loaded data
507
517
  return validate_full_sprint(data)
508
518
 
@@ -35,6 +35,20 @@ def check_story(story_id: str) -> dict[str, Any]:
35
35
  status = story.get("status", "backlog")
36
36
  assigned = story.get("assigned_to")
37
37
 
38
+ # Check if assigned to someone else
39
+ if assigned:
40
+ from pennyfarthing_scripts.jira.client import get_current_user_email
41
+
42
+ current_user = get_current_user_email()
43
+ if assigned != current_user:
44
+ return {
45
+ "available": False,
46
+ "type": "story",
47
+ "story": story,
48
+ "reason": f"Assigned to {assigned}",
49
+ "assigned_to": assigned,
50
+ }
51
+
38
52
  # Check if already in progress
39
53
  if status == "in_progress":
40
54
  return {
@@ -54,6 +68,16 @@ def check_story(story_id: str) -> dict[str, Any]:
54
68
  "reason": "Already completed",
55
69
  }
56
70
 
71
+ # Check if canceled
72
+ if status == "canceled":
73
+ return {
74
+ "available": False,
75
+ "type": "story",
76
+ "story": story,
77
+ "reason": "Story is canceled",
78
+ }
79
+
80
+ # Available statuses: backlog, ready, planning
57
81
  return {
58
82
  "available": True,
59
83
  "type": "story",
@@ -67,10 +91,23 @@ def check_story(story_id: str) -> dict[str, Any]:
67
91
  def get_next_story() -> dict[str, Any]:
68
92
  """Get the highest priority available story.
69
93
 
94
+ Considers stories with backlog, ready, or planning status.
95
+ Excludes stories assigned to other users.
96
+
70
97
  Returns:
71
98
  Dict with next story details or error
72
99
  """
73
- backlog = get_stories_by_status("backlog")
100
+ from pennyfarthing_scripts.jira.client import get_current_user_email
101
+ from pennyfarthing_scripts.sprint.loader import get_all_stories
102
+
103
+ current_user = get_current_user_email()
104
+ all_stories = get_all_stories()
105
+ available_statuses = {"backlog", "ready", "planning"}
106
+ backlog = [
107
+ s for s in all_stories
108
+ if s.get("status") in available_statuses
109
+ and (not s.get("assigned_to") or s.get("assigned_to") == current_user)
110
+ ]
74
111
 
75
112
  if not backlog:
76
113
  return {
@@ -78,11 +115,14 @@ def get_next_story() -> dict[str, Any]:
78
115
  "error": "No stories in backlog",
79
116
  }
80
117
 
81
- # Sort by priority (P0 > P1 > P2 > P3)
118
+ # Sort by priority (P0 > P1 > P2 > P3), preferring own assignments
82
119
  priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
83
120
  sorted_stories = sorted(
84
121
  backlog,
85
- key=lambda s: priority_order.get(s.get("priority", "P2"), 2),
122
+ key=lambda s: (
123
+ 0 if s.get("assigned_to") == current_user else 1,
124
+ priority_order.get(s.get("priority", "P2"), 2),
125
+ ),
86
126
  )
87
127
 
88
128
  next_story = sorted_stories[0]
@@ -4,13 +4,14 @@ Deterministic YAML I/O for sprint data.
4
4
  Story: MSSCI-14254 - Core yaml_io module with deterministic serialization
5
5
 
6
6
  Provides:
7
- - read_sprint(path) -> CommentedMap (preserves ordering/comments)
8
- - write_sprint(path, data) -> atomic write
7
+ - read_sprint(path) -> CommentedMap (preserves ordering/comments, merges shards)
8
+ - write_sprint(path, data) -> atomic write (shard-aware)
9
9
  - canonical_dump(data) -> deterministic YAML string
10
10
  """
11
11
 
12
12
  import io
13
13
  import os
14
+ import re
14
15
  from collections.abc import Mapping
15
16
  from pathlib import Path
16
17
  from typing import Any
@@ -19,6 +20,8 @@ from ruamel.yaml import YAML
19
20
  from ruamel.yaml.comments import CommentedMap, CommentedSeq
20
21
  from ruamel.yaml.scalarstring import LiteralScalarString
21
22
 
23
+ JIRA_PATTERN = re.compile(r"^MSSCI-\d{5}$")
24
+
22
25
 
23
26
  # Canonical key ordering derived from sprint-template.yaml
24
27
  SPRINT_KEY_ORDER: list[str] = [
@@ -52,11 +55,11 @@ def _make_yaml() -> YAML:
52
55
  return yml
53
56
 
54
57
 
55
- def read_sprint(path: Path) -> CommentedMap:
56
- """Read sprint YAML file preserving ordering and comments.
58
+ def _read_yaml_file(path: Path) -> CommentedMap:
59
+ """Read a single YAML file preserving ordering and comments.
57
60
 
58
61
  Args:
59
- path: Path to sprint YAML file
62
+ path: Path to YAML file
60
63
 
61
64
  Returns:
62
65
  CommentedMap with preserved ordering and comments
@@ -81,6 +84,44 @@ def read_sprint(path: Path) -> CommentedMap:
81
84
  return data
82
85
 
83
86
 
87
+ def read_sprint(path: Path) -> CommentedMap:
88
+ """Read sprint YAML file, merging sharded epic files.
89
+
90
+ When the epics list contains string references (sharded format),
91
+ loads each epic-{ref}.yaml shard file and replaces the strings
92
+ with full epic CommentedMaps.
93
+
94
+ Args:
95
+ path: Path to sprint YAML index file
96
+
97
+ Returns:
98
+ CommentedMap with full epic data merged in
99
+
100
+ Raises:
101
+ FileNotFoundError: If path doesn't exist
102
+ ValueError: If YAML is malformed or empty
103
+ """
104
+ data = _read_yaml_file(path)
105
+
106
+ epics = data.get("epics", [])
107
+ if not epics or not isinstance(epics[0], str):
108
+ return data
109
+
110
+ sprint_dir = path.parent
111
+ merged_epics = CommentedSeq()
112
+ for ref in epics:
113
+ if isinstance(ref, str):
114
+ shard_file = sprint_dir / f"epic-{ref}.yaml"
115
+ if shard_file.exists():
116
+ epic_data = _read_yaml_file(shard_file)
117
+ merged_epics.append(epic_data)
118
+ else:
119
+ merged_epics.append(ref)
120
+
121
+ data["epics"] = merged_epics
122
+ return data
123
+
124
+
84
125
  def _sort_mapping(data: CommentedMap, key_order: list[str]) -> CommentedMap:
85
126
  """Reorder keys in a CommentedMap according to key_order.
86
127
 
@@ -228,18 +269,25 @@ def canonical_dump(data: Any) -> str:
228
269
  return result
229
270
 
230
271
 
231
- def write_sprint(path: Path, data: Any) -> None:
232
- """Write sprint data to YAML file atomically.
272
+ def _get_epic_ref(epic: Mapping) -> str:
273
+ """Get the canonical reference ID for an epic shard file.
233
274
 
234
- Uses temp file + os.replace() for atomic writes on POSIX.
275
+ Mirrors the logic in migrate-to-shards.py: prefer Jira key, fall back to ID.
276
+ """
277
+ jira = epic.get("jira")
278
+ epic_id = str(epic.get("id", ""))
235
279
 
236
- Args:
237
- path: Destination path
238
- data: Sprint data (CommentedMap or dict)
280
+ if jira and JIRA_PATTERN.match(str(jira)):
281
+ return str(jira)
282
+ if JIRA_PATTERN.match(epic_id):
283
+ return epic_id
284
+ return epic_id
239
285
 
240
- Raises:
241
- TypeError: If data is not a valid mapping type
242
- OSError: If write fails
286
+
287
+ def _write_yaml_file(path: Path, data: Any) -> None:
288
+ """Write data to a single YAML file atomically.
289
+
290
+ Uses temp file + os.replace() for atomic writes on POSIX.
243
291
  """
244
292
  if not isinstance(data, Mapping):
245
293
  raise TypeError(f"Expected mapping type, got {type(data).__name__}")
@@ -252,7 +300,68 @@ def write_sprint(path: Path, data: Any) -> None:
252
300
  f.write(output)
253
301
  os.replace(tmp_path, path)
254
302
  except Exception:
255
- # Clean up temp file if it exists
256
303
  if tmp_path.exists():
257
304
  tmp_path.unlink()
258
305
  raise
306
+
307
+
308
+ def _is_sharded_on_disk(path: Path) -> bool:
309
+ """Check if the on-disk index file uses sharded epic references."""
310
+ if not path.exists():
311
+ return False
312
+ yml = _make_yaml()
313
+ try:
314
+ with open(path) as f:
315
+ on_disk = yml.load(f)
316
+ except Exception:
317
+ return False
318
+ if on_disk is None or not isinstance(on_disk, Mapping):
319
+ return False
320
+ epics = on_disk.get("epics", [])
321
+ return bool(epics) and isinstance(epics[0], str)
322
+
323
+
324
+ def write_sprint(path: Path, data: Any) -> None:
325
+ """Write sprint data to YAML file(s) atomically.
326
+
327
+ If the on-disk index uses sharded format (epics as string refs),
328
+ writes each epic to its shard file and the index with string refs.
329
+ Otherwise writes the full data to a single file.
330
+
331
+ Args:
332
+ path: Destination path (the index file)
333
+ data: Sprint data (CommentedMap or dict)
334
+
335
+ Raises:
336
+ TypeError: If data is not a valid mapping type
337
+ OSError: If write fails
338
+ """
339
+ if not isinstance(data, Mapping):
340
+ raise TypeError(f"Expected mapping type, got {type(data).__name__}")
341
+
342
+ if not _is_sharded_on_disk(path):
343
+ _write_yaml_file(path, data)
344
+ return
345
+
346
+ # Sharded write: each epic goes to its own file
347
+ sprint_dir = path.parent
348
+ epic_refs = CommentedSeq()
349
+
350
+ for epic in data.get("epics", []):
351
+ if isinstance(epic, Mapping):
352
+ ref = _get_epic_ref(epic)
353
+ shard_file = sprint_dir / f"epic-{ref}.yaml"
354
+ _write_yaml_file(shard_file, epic)
355
+ epic_refs.append(ref)
356
+ else:
357
+ epic_refs.append(epic)
358
+
359
+ # Write index with string refs instead of full epic dicts
360
+ index = CommentedMap()
361
+ for key in data:
362
+ if key == "epics":
363
+ index["epics"] = epic_refs
364
+ else:
365
+ index[key] = data[key]
366
+
367
+ _write_yaml_file(path, index)