@pennyfarthing/core 10.2.0 → 10.3.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 (268) hide show
  1. package/README.md +11 -8
  2. package/package.json +1 -1
  3. package/packages/core/dist/cli/commands/e2e-fresh-install.test.js +1 -1
  4. package/packages/core/dist/cli/commands/e2e-fresh-install.test.js.map +1 -1
  5. package/packages/core/dist/cli/commands/e2e-upgrade.test.js +1 -1
  6. package/packages/core/dist/cli/commands/e2e-upgrade.test.js.map +1 -1
  7. package/packages/core/dist/cli/commands/theme.js +1 -1
  8. package/packages/core/dist/cli/commands/theme.js.map +1 -1
  9. package/packages/core/dist/cli/utils/themes.d.ts.map +1 -1
  10. package/packages/core/dist/cli/utils/themes.js +3 -2
  11. package/packages/core/dist/cli/utils/themes.js.map +1 -1
  12. package/packages/core/dist/scripts/add-ocean-profiles.js +1 -1
  13. package/packages/core/dist/scripts/add-ocean-profiles.js.map +1 -1
  14. package/packages/core/dist/scripts/generate-all-spiders.js +2 -0
  15. package/packages/core/dist/scripts/generate-all-spiders.js.map +1 -1
  16. package/packages/core/dist/scripts/generate-report.d.ts.map +1 -1
  17. package/packages/core/dist/scripts/generate-report.js +2 -0
  18. package/packages/core/dist/scripts/generate-report.js.map +1 -1
  19. package/packages/core/dist/scripts/generate-spider.d.ts.map +1 -1
  20. package/packages/core/dist/scripts/generate-spider.js +2 -0
  21. package/packages/core/dist/scripts/generate-spider.js.map +1 -1
  22. package/packages/core/dist/scripts/validate-ocean-profiles.js +1 -1
  23. package/packages/core/dist/scripts/validate-ocean-profiles.js.map +1 -1
  24. package/packages/core/dist/workflow/file-watch.test.js.map +1 -1
  25. package/packages/core/dist/workflow/output-path-normalizer.d.ts +47 -0
  26. package/packages/core/dist/workflow/output-path-normalizer.d.ts.map +1 -0
  27. package/packages/core/dist/workflow/output-path-normalizer.js +79 -0
  28. package/packages/core/dist/workflow/output-path-normalizer.js.map +1 -0
  29. package/packages/core/dist/workflow/output-path-normalizer.test.d.ts +16 -0
  30. package/packages/core/dist/workflow/output-path-normalizer.test.d.ts.map +1 -0
  31. package/packages/core/dist/workflow/output-path-normalizer.test.js +157 -0
  32. package/packages/core/dist/workflow/output-path-normalizer.test.js.map +1 -0
  33. package/packages/core/dist/workflow/tool-watch.test.js +1 -2
  34. package/packages/core/dist/workflow/tool-watch.test.js.map +1 -1
  35. package/packages/core/dist/workflow/variable-resolver.js +1 -1
  36. package/packages/core/dist/workflow/variable-resolver.js.map +1 -1
  37. package/pennyfarthing-dist/agents/README.md +3 -1
  38. package/pennyfarthing-dist/agents/ba.md +165 -0
  39. package/pennyfarthing-dist/commands/ba.md +17 -0
  40. package/pennyfarthing-dist/guides/workflow-schema.md +1 -1
  41. package/pennyfarthing-dist/personas/themes/a-team.yaml +30 -0
  42. package/pennyfarthing-dist/personas/themes/alice-in-wonderland.yaml +30 -0
  43. package/pennyfarthing-dist/personas/themes/battlestar-galactica.yaml +30 -0
  44. package/pennyfarthing-dist/personas/themes/blade-runner.yaml +30 -0
  45. package/pennyfarthing-dist/personas/themes/catch-22.yaml +30 -0
  46. package/pennyfarthing-dist/personas/themes/control.yaml +30 -0
  47. package/pennyfarthing-dist/personas/themes/cowboy-bebop.yaml +31 -0
  48. package/pennyfarthing-dist/personas/themes/discworld.yaml +31 -0
  49. package/pennyfarthing-dist/personas/themes/doctor-who.yaml +31 -0
  50. package/pennyfarthing-dist/personas/themes/dune.yaml +32 -0
  51. package/pennyfarthing-dist/personas/themes/fifth-element.yaml +32 -0
  52. package/pennyfarthing-dist/personas/themes/firefly.yaml +31 -0
  53. package/pennyfarthing-dist/personas/themes/game-of-thrones.yaml +30 -0
  54. package/pennyfarthing-dist/personas/themes/harry-potter.yaml +30 -0
  55. package/pennyfarthing-dist/personas/themes/hitchhikers-guide.yaml +30 -0
  56. package/pennyfarthing-dist/personas/themes/lord-of-the-rings.yaml +30 -0
  57. package/pennyfarthing-dist/personas/themes/mad-max.yaml +30 -0
  58. package/pennyfarthing-dist/personas/themes/mash.yaml +33 -0
  59. package/pennyfarthing-dist/personas/themes/princess-bride.yaml +34 -0
  60. package/pennyfarthing-dist/personas/themes/sandman.yaml +33 -0
  61. package/pennyfarthing-dist/personas/themes/star-trek-tng.yaml +34 -0
  62. package/pennyfarthing-dist/personas/themes/star-wars.yaml +33 -0
  63. package/pennyfarthing-dist/personas/themes/the-expanse.yaml +30 -0
  64. package/pennyfarthing-dist/personas/themes/the-matrix.yaml +30 -0
  65. package/pennyfarthing-dist/personas/themes/watchmen.yaml +30 -0
  66. package/pennyfarthing-dist/personas/themes/west-wing.yaml +30 -0
  67. package/pennyfarthing-dist/personas/themes/x-files.yaml +30 -0
  68. package/pennyfarthing-dist/scripts/core/agent-session.sh +1 -1
  69. package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
  70. package/pennyfarthing-dist/scripts/portraits/generate-portraits.py +2 -2
  71. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +1 -0
  72. package/pennyfarthing-dist/skills/theme/skill.md +1 -1
  73. package/pennyfarthing-dist/workflows/architecture/workflow.yaml +2 -2
  74. package/pennyfarthing-dist/workflows/architecture.yaml +2 -2
  75. package/pennyfarthing-dist/workflows/epics-and-stories/workflow.yaml +2 -2
  76. package/pennyfarthing-dist/workflows/implementation-readiness/workflow.yaml +2 -2
  77. package/pennyfarthing-dist/workflows/prd/workflow.yaml +2 -2
  78. package/pennyfarthing-dist/workflows/product-brief/workflow.yaml +2 -2
  79. package/pennyfarthing-dist/workflows/project-context/workflow.yaml +2 -2
  80. package/pennyfarthing-dist/workflows/quick-dev/workflow.yaml +2 -2
  81. package/pennyfarthing-dist/workflows/research/workflow.yaml +2 -2
  82. package/pennyfarthing-dist/workflows/retrospective/workflow.yaml +1 -1
  83. package/pennyfarthing-dist/workflows/sprint-planning/workflow.yaml +3 -3
  84. package/pennyfarthing-dist/workflows/ux-design/workflow.yaml +2 -2
  85. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  86. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  87. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  88. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  89. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  90. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  91. package/pennyfarthing_scripts/__pycache__/jira_bidirectional_sync.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/__pycache__/output.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/__pycache__/patch_mode.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  101. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/bellmode_hook.py +1 -1
  103. package/pennyfarthing_scripts/bikerack/__init__.py +36 -0
  104. package/pennyfarthing_scripts/bikerack/__main__.py +5 -0
  105. package/pennyfarthing_scripts/bikerack/__pycache__/__init__.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/bikerack/__pycache__/__main__.cpython-314.pyc +0 -0
  107. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/bikerack/cli.py +148 -0
  110. package/pennyfarthing_scripts/bikerack/launcher.py +181 -0
  111. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  112. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  113. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  114. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  115. package/pennyfarthing_scripts/cli.py +5 -0
  116. package/pennyfarthing_scripts/codemarkers/__pycache__/__init__.cpython-314.pyc +0 -0
  117. package/pennyfarthing_scripts/codemarkers/__pycache__/__main__.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/codemarkers/__pycache__/analyze.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/codemarkers/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/codemarkers/__pycache__/formatters.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/codemarkers/__pycache__/models.cpython-314.pyc +0 -0
  122. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  123. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  124. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  125. package/pennyfarthing_scripts/common/__pycache__/themes.cpython-314.pyc +0 -0
  126. package/pennyfarthing_scripts/complexity/__pycache__/__init__.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/complexity/__pycache__/__main__.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/complexity/__pycache__/analyze.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/complexity/__pycache__/cli.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/complexity/__pycache__/formatters.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/complexity/__pycache__/models.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/deadcode/__pycache__/__init__.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/deadcode/__pycache__/__main__.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/deadcode/__pycache__/analyze.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/deadcode/__pycache__/cli.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/deadcode/__pycache__/formatters.cpython-314.pyc +0 -0
  137. package/pennyfarthing_scripts/deadcode/__pycache__/models.cpython-314.pyc +0 -0
  138. package/pennyfarthing_scripts/dependencies/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/dependencies/__pycache__/__main__.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/dependencies/__pycache__/analyze.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/dependencies/__pycache__/cli.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/dependencies/__pycache__/formatters.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/dependencies/__pycache__/models.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  147. package/pennyfarthing_scripts/healthscore/__pycache__/__init__.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/healthscore/__pycache__/__main__.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/healthscore/__pycache__/analyze.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/healthscore/__pycache__/cli.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/healthscore/__pycache__/formatters.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/healthscore/__pycache__/models.cpython-314.pyc +0 -0
  153. package/pennyfarthing_scripts/healthscore/analyze.py +2 -1
  154. package/pennyfarthing_scripts/hotspots/__pycache__/__init__.cpython-314.pyc +0 -0
  155. package/pennyfarthing_scripts/hotspots/__pycache__/__main__.cpython-314.pyc +0 -0
  156. package/pennyfarthing_scripts/hotspots/__pycache__/analyze.cpython-314.pyc +0 -0
  157. package/pennyfarthing_scripts/hotspots/__pycache__/cli.cpython-314.pyc +0 -0
  158. package/pennyfarthing_scripts/hotspots/__pycache__/formatters.cpython-314.pyc +0 -0
  159. package/pennyfarthing_scripts/hotspots/__pycache__/models.cpython-314.pyc +0 -0
  160. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  162. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  163. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  164. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  165. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  166. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  167. package/pennyfarthing_scripts/jira/__pycache__/create.cpython-314.pyc +0 -0
  168. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  169. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  170. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  171. package/pennyfarthing_scripts/jira/__pycache__/operations.cpython-314.pyc +0 -0
  172. package/pennyfarthing_scripts/jira/__pycache__/reconcile.cpython-314.pyc +0 -0
  173. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  174. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  175. package/pennyfarthing_scripts/migration/__pycache__/__init__.cpython-314.pyc +0 -0
  176. package/pennyfarthing_scripts/migration/__pycache__/__main__.cpython-314.pyc +0 -0
  177. package/pennyfarthing_scripts/migration/__pycache__/cli.cpython-314.pyc +0 -0
  178. package/pennyfarthing_scripts/migration/__pycache__/session.cpython-314.pyc +0 -0
  179. package/pennyfarthing_scripts/migration/__pycache__/skill.cpython-314.pyc +0 -0
  180. package/pennyfarthing_scripts/migration/__pycache__/step.cpython-314.pyc +0 -0
  181. package/pennyfarthing_scripts/migration/__pycache__/validate.cpython-314.pyc +0 -0
  182. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  183. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  184. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  185. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  186. package/pennyfarthing_scripts/prime/__init__.py +2 -0
  187. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  188. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  189. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  190. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  191. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  192. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  193. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  194. package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
  195. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  196. package/pennyfarthing_scripts/prime/cli.py +13 -0
  197. package/pennyfarthing_scripts/prime/loader.py +70 -0
  198. package/pennyfarthing_scripts/prime/persona.py +2 -1
  199. package/pennyfarthing_scripts/prime/tiers.py +13 -0
  200. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  201. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  202. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  203. package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
  204. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  205. package/pennyfarthing_scripts/sprint/__pycache__/epic_add.cpython-314.pyc +0 -0
  206. package/pennyfarthing_scripts/sprint/__pycache__/import_epic.cpython-314.pyc +0 -0
  207. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  208. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  209. package/pennyfarthing_scripts/sprint/__pycache__/story_add.cpython-314.pyc +0 -0
  210. package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
  211. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  212. package/pennyfarthing_scripts/sprint/__pycache__/validate_cmd.cpython-314.pyc +0 -0
  213. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  214. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  215. package/pennyfarthing_scripts/sprint/__pycache__/yaml_io.cpython-314.pyc +0 -0
  216. package/pennyfarthing_scripts/story/__pycache__/__init__.cpython-314.pyc +0 -0
  217. package/pennyfarthing_scripts/story/__pycache__/__main__.cpython-314.pyc +0 -0
  218. package/pennyfarthing_scripts/story/__pycache__/cli.cpython-314.pyc +0 -0
  219. package/pennyfarthing_scripts/story/__pycache__/create.cpython-314.pyc +0 -0
  220. package/pennyfarthing_scripts/story/__pycache__/size.cpython-314.pyc +0 -0
  221. package/pennyfarthing_scripts/story/__pycache__/template.cpython-314.pyc +0 -0
  222. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  223. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  224. package/pennyfarthing_scripts/tests/__pycache__/test_bikerack.cpython-314-pytest-9.0.2.pyc +0 -0
  225. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  226. package/pennyfarthing_scripts/tests/__pycache__/test_cli_modules.cpython-314-pytest-9.0.2.pyc +0 -0
  227. package/pennyfarthing_scripts/tests/__pycache__/test_codemarkers.cpython-314-pytest-9.0.2.pyc +0 -0
  228. package/pennyfarthing_scripts/tests/__pycache__/test_common.cpython-314-pytest-9.0.2.pyc +0 -0
  229. package/pennyfarthing_scripts/tests/__pycache__/test_epic_shard_validation.cpython-314-pytest-9.0.2.pyc +0 -0
  230. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  231. package/pennyfarthing_scripts/tests/__pycache__/test_healthscore.cpython-314-pytest-9.0.2.pyc +0 -0
  232. package/pennyfarthing_scripts/tests/__pycache__/test_jira_package.cpython-314-pytest-9.0.2.pyc +0 -0
  233. package/pennyfarthing_scripts/tests/__pycache__/test_package_structure.cpython-314-pytest-9.0.2.pyc +0 -0
  234. package/pennyfarthing_scripts/tests/__pycache__/test_patch_mode.cpython-314-pytest-9.0.2.pyc +0 -0
  235. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  236. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
  237. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  238. package/pennyfarthing_scripts/tests/__pycache__/test_story_add.cpython-314-pytest-9.0.2.pyc +0 -0
  239. package/pennyfarthing_scripts/tests/__pycache__/test_story_package.cpython-314-pytest-9.0.2.pyc +0 -0
  240. package/pennyfarthing_scripts/tests/__pycache__/test_story_update.cpython-314-pytest-9.0.2.pyc +0 -0
  241. package/pennyfarthing_scripts/tests/__pycache__/test_tiers.cpython-314-pytest-9.0.2.pyc +0 -0
  242. package/pennyfarthing_scripts/tests/__pycache__/test_token_counting.cpython-314-pytest-9.0.2.pyc +0 -0
  243. package/pennyfarthing_scripts/tests/__pycache__/test_validate_cmd.cpython-314-pytest-9.0.2.pyc +0 -0
  244. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  245. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  246. package/pennyfarthing_scripts/tests/__pycache__/test_yaml_io.cpython-314-pytest-9.0.2.pyc +0 -0
  247. package/pennyfarthing_scripts/tests/test_bikerack.py +785 -0
  248. package/pennyfarthing_scripts/tests/test_topology_loader.py +620 -0
  249. package/pennyfarthing_scripts/theme/__pycache__/__init__.cpython-314.pyc +0 -0
  250. package/pennyfarthing_scripts/theme/__pycache__/cli.cpython-314.pyc +0 -0
  251. package/pennyfarthing_scripts/validate/__pycache__/__init__.cpython-314.pyc +0 -0
  252. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  253. package/pennyfarthing_scripts/validate/adapters/__pycache__/__init__.cpython-314.pyc +0 -0
  254. package/pennyfarthing_scripts/validate/adapters/__pycache__/agent.cpython-314.pyc +0 -0
  255. package/pennyfarthing_scripts/validate/adapters/__pycache__/schema.cpython-314.pyc +0 -0
  256. package/pennyfarthing_scripts/validate/adapters/__pycache__/skill_command.cpython-314.pyc +0 -0
  257. package/pennyfarthing_scripts/validate/adapters/__pycache__/sprint.cpython-314.pyc +0 -0
  258. package/pennyfarthing_scripts/validate/adapters/__pycache__/workflow.cpython-314.pyc +0 -0
  259. package/pennyfarthing_scripts/validate/adapters/skill_command.py +0 -1
  260. package/packages/core/dist/workflow/context-watch.d.ts +0 -80
  261. package/packages/core/dist/workflow/context-watch.d.ts.map +0 -1
  262. package/packages/core/dist/workflow/context-watch.js +0 -235
  263. package/packages/core/dist/workflow/context-watch.js.map +0 -1
  264. package/packages/core/dist/workflow/context-watch.test.d.ts +0 -1
  265. package/packages/core/dist/workflow/context-watch.test.d.ts.map +0 -1
  266. package/packages/core/dist/workflow/context-watch.test.js +0 -746
  267. package/packages/core/dist/workflow/context-watch.test.js.map +0 -1
  268. package/pennyfarthing_scripts/__pycache__/bellmode_hook.cpython-314.pyc +0 -0
@@ -0,0 +1,785 @@
1
+ """Tests for BikeRack launcher CLI.
2
+
3
+ Story 101-5: BikeRack launcher CLI (pf bikerack start/stop/status)
4
+ Epic: 101 — BikeRack Mode (ADR-0024)
5
+
6
+ Acceptance Criteria:
7
+ - [AC1] `pf bikerack start` starts WheelHub background with IS_BIKERACK=1
8
+ - [AC2] Polls for .bikerack-port file (100ms interval, 5s timeout)
9
+ - [AC3] Sets exactly 5 OTEL env vars from discovered port (Rule 5)
10
+ - [AC4] Uses exec (not spawn) for Claude CLI (CE-4)
11
+ - [AC5] trap EXIT registered before exec to kill WheelHub PID (Rule 8)
12
+ - [AC6] Writes .bikerack-pid after spawning WheelHub
13
+ - [AC7] `pf bikerack stop` reads PID, sends SIGTERM, deletes files
14
+ - [AC8] `pf bikerack status` shows running state (PID, port, uptime)
15
+ - [AC9] Error if already running (.bikerack-port exists with live PID)
16
+ - [AC10] Exit code 1 if WheelHub fails to start, 2 if already running
17
+ - [AC11] Prints dashboard URL on startup
18
+ - [AC12] `just bikerack` works as alias
19
+
20
+ Tests should FAIL until launcher.py is implemented.
21
+ """
22
+
23
+ import os
24
+ import signal
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+ from unittest.mock import MagicMock, patch
29
+
30
+ import pytest
31
+
32
+ from pennyfarthing_scripts.bikerack.launcher import (
33
+ build_otel_env,
34
+ cleanup_files,
35
+ exec_claude,
36
+ get_status,
37
+ is_already_running,
38
+ is_process_alive,
39
+ poll_for_port_file,
40
+ read_pid_file,
41
+ read_port_file,
42
+ register_cleanup,
43
+ start_wheelhub,
44
+ stop_bikerack,
45
+ write_pid_file,
46
+ )
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # AC1: `pf bikerack start` starts WheelHub background with IS_BIKERACK=1
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ class TestStartWheelHub:
54
+ """AC1: start_wheelhub starts WheelHub in background with IS_BIKERACK=1."""
55
+
56
+ def test_starts_subprocess(self, tmp_path: Path) -> None:
57
+ """start_wheelhub should return a Popen object (background process)."""
58
+ with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
59
+ mock_proc = MagicMock()
60
+ mock_proc.pid = 12345
61
+ mock_popen.return_value = mock_proc
62
+
63
+ result = start_wheelhub(tmp_path)
64
+
65
+ assert result.pid == 12345
66
+ mock_popen.assert_called_once()
67
+
68
+ def test_sets_is_bikerack_env(self, tmp_path: Path) -> None:
69
+ """start_wheelhub should set IS_BIKERACK=1 in subprocess env."""
70
+ with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
71
+ mock_popen.return_value = MagicMock(pid=12345)
72
+
73
+ start_wheelhub(tmp_path)
74
+
75
+ # Inspect the env passed to Popen
76
+ popen_kwargs = mock_popen.call_args
77
+ env = popen_kwargs.kwargs.get("env") or popen_kwargs[1].get("env")
78
+ assert env is not None, "Popen should be called with env parameter"
79
+ assert env.get("IS_BIKERACK") == "1"
80
+
81
+ def test_sets_project_dir_env(self, tmp_path: Path) -> None:
82
+ """start_wheelhub should set CYCLIST_PROJECT_DIR in subprocess env."""
83
+ with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
84
+ mock_popen.return_value = MagicMock(pid=12345)
85
+
86
+ start_wheelhub(tmp_path)
87
+
88
+ popen_kwargs = mock_popen.call_args
89
+ env = popen_kwargs.kwargs.get("env") or popen_kwargs[1].get("env")
90
+ assert env.get("CYCLIST_PROJECT_DIR") == str(tmp_path)
91
+
92
+ def test_process_is_background(self, tmp_path: Path) -> None:
93
+ """start_wheelhub should not block (background process)."""
94
+ with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
95
+ mock_popen.return_value = MagicMock(pid=12345)
96
+
97
+ result = start_wheelhub(tmp_path)
98
+
99
+ # Should return immediately (Popen, not run)
100
+ result.wait.assert_not_called()
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # AC2: Polls for .bikerack-port file (100ms interval, 5s timeout)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class TestPortFilePolling:
109
+ """AC2: poll_for_port_file polls with correct interval and timeout."""
110
+
111
+ def test_returns_port_when_file_exists(self, tmp_path: Path) -> None:
112
+ """poll_for_port_file should return port number from file."""
113
+ port_file = tmp_path / ".bikerack-port"
114
+ port_file.write_text("2898")
115
+
116
+ result = poll_for_port_file(tmp_path)
117
+
118
+ assert result == 2898
119
+
120
+ def test_raises_on_timeout(self, tmp_path: Path) -> None:
121
+ """poll_for_port_file should raise TimeoutError when file never appears."""
122
+ # No port file exists — should timeout
123
+ with pytest.raises(TimeoutError):
124
+ poll_for_port_file(tmp_path, timeout=0.3, interval=0.1)
125
+
126
+ def test_waits_for_file_to_appear(self, tmp_path: Path) -> None:
127
+ """poll_for_port_file should poll until file appears."""
128
+ port_file = tmp_path / ".bikerack-port"
129
+
130
+ # Simulate file appearing after short delay
131
+ call_count = [0]
132
+ original_exists = Path.exists
133
+
134
+ def delayed_exists(self_path):
135
+ if str(self_path) == str(port_file):
136
+ call_count[0] += 1
137
+ if call_count[0] >= 3:
138
+ port_file.write_text("2898")
139
+ return True
140
+ return False
141
+ return original_exists(self_path)
142
+
143
+ with patch.object(Path, "exists", delayed_exists):
144
+ result = poll_for_port_file(tmp_path, timeout=5.0, interval=0.05)
145
+ assert result == 2898
146
+
147
+ def test_default_timeout_is_5_seconds(self, tmp_path: Path) -> None:
148
+ """poll_for_port_file default timeout should be 5 seconds."""
149
+ import inspect
150
+
151
+ sig = inspect.signature(poll_for_port_file)
152
+ assert sig.parameters["timeout"].default == 5.0
153
+
154
+ def test_default_interval_is_100ms(self, tmp_path: Path) -> None:
155
+ """poll_for_port_file default interval should be 0.1 seconds (100ms)."""
156
+ import inspect
157
+
158
+ sig = inspect.signature(poll_for_port_file)
159
+ assert sig.parameters["interval"].default == 0.1
160
+
161
+ def test_reads_integer_port(self, tmp_path: Path) -> None:
162
+ """poll_for_port_file should parse port as integer."""
163
+ port_file = tmp_path / ".bikerack-port"
164
+ port_file.write_text("3000\n") # Trailing newline should be handled
165
+
166
+ result = poll_for_port_file(tmp_path)
167
+
168
+ assert isinstance(result, int)
169
+ assert result == 3000
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # AC3: Sets exactly 5 OTEL env vars from discovered port (Rule 5)
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ class TestOtelEnvVars:
178
+ """AC3: build_otel_env sets exactly 5 OTEL env vars (Rule 5 from ADR-0024)."""
179
+
180
+ def test_returns_exactly_five_vars(self) -> None:
181
+ """build_otel_env should return exactly 5 environment variables."""
182
+ result = build_otel_env(2898)
183
+ assert len(result) == 5, f"Expected 5 OTEL vars, got {len(result)}: {list(result.keys())}"
184
+
185
+ def test_includes_telemetry_enable(self) -> None:
186
+ """build_otel_env should include CLAUDE_CODE_ENABLE_TELEMETRY=1."""
187
+ result = build_otel_env(2898)
188
+ assert result.get("CLAUDE_CODE_ENABLE_TELEMETRY") == "1"
189
+
190
+ def test_includes_logs_exporter(self) -> None:
191
+ """build_otel_env should include OTEL_LOGS_EXPORTER=otlp."""
192
+ result = build_otel_env(2898)
193
+ assert result.get("OTEL_LOGS_EXPORTER") == "otlp"
194
+
195
+ def test_includes_metrics_exporter(self) -> None:
196
+ """build_otel_env should include OTEL_METRICS_EXPORTER=otlp."""
197
+ result = build_otel_env(2898)
198
+ assert result.get("OTEL_METRICS_EXPORTER") == "otlp"
199
+
200
+ def test_includes_otlp_protocol(self) -> None:
201
+ """build_otel_env should include OTEL_EXPORTER_OTLP_PROTOCOL=http/json."""
202
+ result = build_otel_env(2898)
203
+ assert result.get("OTEL_EXPORTER_OTLP_PROTOCOL") == "http/json"
204
+
205
+ def test_includes_otlp_endpoint_with_port(self) -> None:
206
+ """build_otel_env should include OTEL_EXPORTER_OTLP_ENDPOINT with correct port."""
207
+ result = build_otel_env(2898)
208
+ assert result.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "http://localhost:2898"
209
+
210
+ def test_endpoint_uses_provided_port(self) -> None:
211
+ """build_otel_env endpoint should use the port argument."""
212
+ result = build_otel_env(3456)
213
+ assert result["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:3456"
214
+
215
+ def test_no_traces_exporter(self) -> None:
216
+ """build_otel_env should NOT include OTEL_TRACES_EXPORTER (Claude doesn't emit traces)."""
217
+ result = build_otel_env(2898)
218
+ assert "OTEL_TRACES_EXPORTER" not in result
219
+
220
+ def test_returns_dict_of_strings(self) -> None:
221
+ """build_otel_env should return dict[str, str]."""
222
+ result = build_otel_env(2898)
223
+ for key, val in result.items():
224
+ assert isinstance(key, str), f"Key {key!r} is not str"
225
+ assert isinstance(val, str), f"Value {val!r} for {key} is not str"
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # AC4: Uses exec (not spawn) for Claude CLI (CE-4)
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ class TestExecClaude:
234
+ """AC4: exec_claude replaces the process with Claude CLI via os.execvpe."""
235
+
236
+ def test_calls_os_execvpe(self) -> None:
237
+ """exec_claude should call os.execvpe (not subprocess.Popen)."""
238
+ otel_env = build_otel_env(2898)
239
+
240
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
241
+ # execvpe never returns, so mock it
242
+ mock_exec.side_effect = SystemExit(0)
243
+
244
+ with pytest.raises(SystemExit):
245
+ exec_claude(otel_env)
246
+
247
+ mock_exec.assert_called_once()
248
+
249
+ def test_execs_claude_binary(self) -> None:
250
+ """exec_claude should exec the 'claude' binary."""
251
+ otel_env = build_otel_env(2898)
252
+
253
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
254
+ mock_exec.side_effect = SystemExit(0)
255
+
256
+ with pytest.raises(SystemExit):
257
+ exec_claude(otel_env)
258
+
259
+ args = mock_exec.call_args
260
+ # First arg to execvpe is the program name
261
+ assert args[0][0] == "claude"
262
+
263
+ def test_merges_otel_env_with_current_env(self) -> None:
264
+ """exec_claude should merge OTEL vars into current environment."""
265
+ otel_env = {"CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_LOGS_EXPORTER": "otlp"}
266
+
267
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
268
+ mock_exec.side_effect = SystemExit(0)
269
+
270
+ with pytest.raises(SystemExit):
271
+ exec_claude(otel_env)
272
+
273
+ args = mock_exec.call_args
274
+ # Third arg is the env dict
275
+ exec_env = args[0][2] if len(args[0]) > 2 else args.kwargs.get("env")
276
+ assert exec_env is not None
277
+ # Should contain OTEL vars
278
+ assert exec_env.get("CLAUDE_CODE_ENABLE_TELEMETRY") == "1"
279
+ # Should also contain existing env vars (e.g., PATH)
280
+ assert "PATH" in exec_env
281
+
282
+ def test_does_not_use_subprocess(self) -> None:
283
+ """exec_claude must NOT use subprocess (CE-4: exec, not spawn)."""
284
+ otel_env = build_otel_env(2898)
285
+
286
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
287
+ mock_exec.side_effect = SystemExit(0)
288
+
289
+ with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
290
+ with pytest.raises(SystemExit):
291
+ exec_claude(otel_env)
292
+
293
+ mock_popen.assert_not_called()
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # AC5: trap EXIT registered before exec to kill WheelHub PID (Rule 8)
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ class TestCleanupRegistration:
302
+ """AC5: register_cleanup sets up atexit handler to kill WheelHub."""
303
+
304
+ def test_registers_atexit_handler(self, tmp_path: Path) -> None:
305
+ """register_cleanup should register an atexit handler."""
306
+ with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register") as mock_register:
307
+ register_cleanup(tmp_path, pid=12345)
308
+
309
+ mock_register.assert_called_once()
310
+
311
+ def test_cleanup_kills_wheelhub_pid(self, tmp_path: Path) -> None:
312
+ """Registered cleanup should send SIGTERM to WheelHub PID."""
313
+ cleanup_func = None
314
+
315
+ def capture_handler(func, *args, **kwargs):
316
+ nonlocal cleanup_func
317
+
318
+ def _call_cleanup():
319
+ return func(*args, **kwargs)
320
+
321
+ cleanup_func = _call_cleanup
322
+
323
+ with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register", side_effect=capture_handler):
324
+ register_cleanup(tmp_path, pid=12345)
325
+
326
+ assert cleanup_func is not None, "atexit handler not registered"
327
+
328
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill") as mock_kill:
329
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.path.exists", return_value=True):
330
+ # Simulate cleanup
331
+ try:
332
+ cleanup_func()
333
+ except (ProcessLookupError, OSError):
334
+ pass # Expected if mocking doesn't cover all paths
335
+
336
+ # Should attempt to kill the PID
337
+ mock_kill.assert_called()
338
+ kill_args = mock_kill.call_args[0]
339
+ assert kill_args[0] == 12345
340
+ assert kill_args[1] == signal.SIGTERM
341
+
342
+ def test_cleanup_removes_port_file(self, tmp_path: Path) -> None:
343
+ """Registered cleanup should delete .bikerack-port file."""
344
+ port_file = tmp_path / ".bikerack-port"
345
+ port_file.write_text("2898")
346
+
347
+ cleanup_func = None
348
+
349
+ def capture_handler(func, *args, **kwargs):
350
+ nonlocal cleanup_func
351
+
352
+ def _call_cleanup():
353
+ return func(*args, **kwargs)
354
+
355
+ cleanup_func = _call_cleanup
356
+
357
+ with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register", side_effect=capture_handler):
358
+ register_cleanup(tmp_path, pid=12345)
359
+
360
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
361
+ try:
362
+ cleanup_func()
363
+ except (ProcessLookupError, OSError):
364
+ pass
365
+
366
+ assert not port_file.exists(), ".bikerack-port should be deleted by cleanup"
367
+
368
+ def test_cleanup_removes_pid_file(self, tmp_path: Path) -> None:
369
+ """Registered cleanup should delete .bikerack-pid file."""
370
+ pid_file = tmp_path / ".bikerack-pid"
371
+ pid_file.write_text("12345")
372
+
373
+ cleanup_func = None
374
+
375
+ def capture_handler(func, *args, **kwargs):
376
+ nonlocal cleanup_func
377
+
378
+ def _call_cleanup():
379
+ return func(*args, **kwargs)
380
+
381
+ cleanup_func = _call_cleanup
382
+
383
+ with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register", side_effect=capture_handler):
384
+ register_cleanup(tmp_path, pid=12345)
385
+
386
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
387
+ try:
388
+ cleanup_func()
389
+ except (ProcessLookupError, OSError):
390
+ pass
391
+
392
+ assert not pid_file.exists(), ".bikerack-pid should be deleted by cleanup"
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # AC6: Writes .bikerack-pid after spawning WheelHub
397
+ # ---------------------------------------------------------------------------
398
+
399
+
400
+ class TestPidFile:
401
+ """AC6: write_pid_file writes .bikerack-pid."""
402
+
403
+ def test_writes_pid_to_file(self, tmp_path: Path) -> None:
404
+ """write_pid_file should write PID as ASCII string."""
405
+ write_pid_file(tmp_path, pid=48291)
406
+
407
+ pid_file = tmp_path / ".bikerack-pid"
408
+ assert pid_file.exists()
409
+ assert pid_file.read_text().strip() == "48291"
410
+
411
+ def test_read_pid_file_returns_pid(self, tmp_path: Path) -> None:
412
+ """read_pid_file should return PID as integer."""
413
+ pid_file = tmp_path / ".bikerack-pid"
414
+ pid_file.write_text("48291")
415
+
416
+ result = read_pid_file(tmp_path)
417
+
418
+ assert result == 48291
419
+
420
+ def test_read_pid_file_returns_none_when_missing(self, tmp_path: Path) -> None:
421
+ """read_pid_file should return None when file doesn't exist."""
422
+ result = read_pid_file(tmp_path)
423
+
424
+ assert result is None
425
+
426
+ def test_write_pid_creates_file_in_project_dir(self, tmp_path: Path) -> None:
427
+ """write_pid_file should create .bikerack-pid in project directory."""
428
+ write_pid_file(tmp_path, pid=99999)
429
+
430
+ expected = tmp_path / ".bikerack-pid"
431
+ assert expected.exists()
432
+ assert expected.name == ".bikerack-pid"
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # AC7: `pf bikerack stop` reads PID, sends SIGTERM, deletes files
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ class TestStopBikeRack:
441
+ """AC7: stop_bikerack sends SIGTERM and cleans up files."""
442
+
443
+ def test_sends_sigterm_to_pid(self, tmp_path: Path) -> None:
444
+ """stop_bikerack should send SIGTERM to the WheelHub PID."""
445
+ # Setup: create port and pid files
446
+ (tmp_path / ".bikerack-port").write_text("2898")
447
+ (tmp_path / ".bikerack-pid").write_text("12345")
448
+
449
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill") as mock_kill:
450
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
451
+ stop_bikerack(tmp_path)
452
+
453
+ mock_kill.assert_called_with(12345, signal.SIGTERM)
454
+
455
+ def test_deletes_port_file(self, tmp_path: Path) -> None:
456
+ """stop_bikerack should delete .bikerack-port."""
457
+ port_file = tmp_path / ".bikerack-port"
458
+ port_file.write_text("2898")
459
+ (tmp_path / ".bikerack-pid").write_text("12345")
460
+
461
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
462
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
463
+ stop_bikerack(tmp_path)
464
+
465
+ assert not port_file.exists()
466
+
467
+ def test_deletes_pid_file(self, tmp_path: Path) -> None:
468
+ """stop_bikerack should delete .bikerack-pid."""
469
+ (tmp_path / ".bikerack-port").write_text("2898")
470
+ pid_file = tmp_path / ".bikerack-pid"
471
+ pid_file.write_text("12345")
472
+
473
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
474
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
475
+ stop_bikerack(tmp_path)
476
+
477
+ assert not pid_file.exists()
478
+
479
+ def test_returns_success_dict(self, tmp_path: Path) -> None:
480
+ """stop_bikerack should return {success: True, pid: N, message: str}."""
481
+ (tmp_path / ".bikerack-port").write_text("2898")
482
+ (tmp_path / ".bikerack-pid").write_text("12345")
483
+
484
+ with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
485
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
486
+ result = stop_bikerack(tmp_path)
487
+
488
+ assert result["success"] is True
489
+ assert result["pid"] == 12345
490
+
491
+ def test_returns_error_when_not_running(self, tmp_path: Path) -> None:
492
+ """stop_bikerack should return error when no instance is running."""
493
+ # No port or pid files
494
+ result = stop_bikerack(tmp_path)
495
+
496
+ assert result["success"] is False
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # AC8: `pf bikerack status` shows running state (PID, port, uptime)
501
+ # ---------------------------------------------------------------------------
502
+
503
+
504
+ class TestStatus:
505
+ """AC8: get_status returns running state info."""
506
+
507
+ def test_returns_running_state(self, tmp_path: Path) -> None:
508
+ """get_status should detect running BikeRack."""
509
+ (tmp_path / ".bikerack-port").write_text("2898")
510
+ (tmp_path / ".bikerack-pid").write_text("12345")
511
+
512
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
513
+ result = get_status(tmp_path)
514
+
515
+ assert result["running"] is True
516
+ assert result["pid"] == 12345
517
+ assert result["port"] == 2898
518
+
519
+ def test_returns_not_running(self, tmp_path: Path) -> None:
520
+ """get_status should detect no running BikeRack."""
521
+ result = get_status(tmp_path)
522
+
523
+ assert result["running"] is False
524
+
525
+ def test_includes_dashboard_url(self, tmp_path: Path) -> None:
526
+ """get_status should include dashboard URL when running."""
527
+ (tmp_path / ".bikerack-port").write_text("2898")
528
+ (tmp_path / ".bikerack-pid").write_text("12345")
529
+
530
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
531
+ result = get_status(tmp_path)
532
+
533
+ assert "http://localhost:2898/bikerack" in result.get("dashboard", "")
534
+
535
+ def test_detects_stale_pid(self, tmp_path: Path) -> None:
536
+ """get_status should detect stale PID (file exists, process dead)."""
537
+ (tmp_path / ".bikerack-port").write_text("2898")
538
+ (tmp_path / ".bikerack-pid").write_text("99999")
539
+
540
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=False):
541
+ result = get_status(tmp_path)
542
+
543
+ assert result["running"] is False
544
+
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # AC9: Error if already running (.bikerack-port exists with live PID)
548
+ # ---------------------------------------------------------------------------
549
+
550
+
551
+ class TestAlreadyRunning:
552
+ """AC9: is_already_running detects existing BikeRack instance."""
553
+
554
+ def test_detects_running_instance(self, tmp_path: Path) -> None:
555
+ """is_already_running should return True when port file + live PID."""
556
+ (tmp_path / ".bikerack-port").write_text("2898")
557
+ (tmp_path / ".bikerack-pid").write_text("12345")
558
+
559
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
560
+ running, pid, port = is_already_running(tmp_path)
561
+
562
+ assert running is True
563
+ assert pid == 12345
564
+ assert port == 2898
565
+
566
+ def test_not_running_when_no_files(self, tmp_path: Path) -> None:
567
+ """is_already_running should return False when no files exist."""
568
+ running, pid, port = is_already_running(tmp_path)
569
+
570
+ assert running is False
571
+ assert pid is None
572
+ assert port is None
573
+
574
+ def test_not_running_when_stale_pid(self, tmp_path: Path) -> None:
575
+ """is_already_running should return False when PID is dead (stale)."""
576
+ (tmp_path / ".bikerack-port").write_text("2898")
577
+ (tmp_path / ".bikerack-pid").write_text("99999")
578
+
579
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=False):
580
+ running, pid, port = is_already_running(tmp_path)
581
+
582
+ assert running is False
583
+
584
+ def test_cleans_stale_files(self, tmp_path: Path) -> None:
585
+ """is_already_running should clean up stale files when PID is dead."""
586
+ port_file = tmp_path / ".bikerack-port"
587
+ pid_file = tmp_path / ".bikerack-pid"
588
+ port_file.write_text("2898")
589
+ pid_file.write_text("99999")
590
+
591
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=False):
592
+ is_already_running(tmp_path)
593
+
594
+ assert not port_file.exists(), "Stale port file should be cleaned up"
595
+ assert not pid_file.exists(), "Stale PID file should be cleaned up"
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # AC10: Exit codes — 1 if WheelHub fails to start, 2 if already running
600
+ # ---------------------------------------------------------------------------
601
+
602
+
603
+ class TestExitCodes:
604
+ """AC10: Correct exit codes for error conditions."""
605
+
606
+ def test_exit_code_2_when_already_running(self, tmp_path: Path) -> None:
607
+ """Start should raise SystemExit(2) when already running."""
608
+ (tmp_path / ".bikerack-port").write_text("2898")
609
+ (tmp_path / ".bikerack-pid").write_text("12345")
610
+
611
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
612
+ with patch("pennyfarthing_scripts.bikerack.launcher.is_already_running",
613
+ return_value=(True, 12345, 2898)):
614
+ # The start flow should detect already-running and exit 2
615
+ # This tests the logic, not the full CLI flow
616
+ running, pid, port = is_already_running(tmp_path)
617
+ assert running is True
618
+ # The CLI layer should convert this to exit code 2
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # AC11: Prints dashboard URL on startup
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ class TestDashboardUrl:
627
+ """AC11: Dashboard URL is displayed on startup."""
628
+
629
+ def test_dashboard_url_format(self) -> None:
630
+ """Dashboard URL should be http://localhost:{port}/bikerack."""
631
+ # The URL format is deterministic from the port
632
+ port = 2898
633
+ expected = f"http://localhost:{port}/bikerack"
634
+ assert expected == "http://localhost:2898/bikerack"
635
+
636
+ def test_dashboard_url_uses_custom_port(self) -> None:
637
+ """Dashboard URL should use the actual discovered port."""
638
+ port = 3456
639
+ expected = f"http://localhost:{port}/bikerack"
640
+ assert expected == "http://localhost:3456/bikerack"
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # AC12: `just bikerack` works as alias (tested at integration level)
645
+ # ---------------------------------------------------------------------------
646
+
647
+
648
+ class TestBikeRackCLI:
649
+ """AC12: CLI module entry point works."""
650
+
651
+ def test_bikerack_cli_help(self) -> None:
652
+ """bikerack CLI should show help with --help."""
653
+ result = subprocess.run(
654
+ [sys.executable, "-m", "pennyfarthing_scripts.bikerack", "--help"],
655
+ capture_output=True,
656
+ text=True,
657
+ timeout=30,
658
+ cwd=str(Path(__file__).parent.parent.parent),
659
+ )
660
+
661
+ assert result.returncode == 0
662
+ assert "bikerack" in result.stdout.lower() or "BikeRack" in result.stdout
663
+
664
+ def test_bikerack_has_start_subcommand(self) -> None:
665
+ """bikerack CLI should have start subcommand."""
666
+ result = subprocess.run(
667
+ [sys.executable, "-m", "pennyfarthing_scripts.bikerack", "start", "--help"],
668
+ capture_output=True,
669
+ text=True,
670
+ timeout=30,
671
+ cwd=str(Path(__file__).parent.parent.parent),
672
+ )
673
+
674
+ assert result.returncode == 0
675
+
676
+ def test_bikerack_has_stop_subcommand(self) -> None:
677
+ """bikerack CLI should have stop subcommand."""
678
+ result = subprocess.run(
679
+ [sys.executable, "-m", "pennyfarthing_scripts.bikerack", "stop", "--help"],
680
+ capture_output=True,
681
+ text=True,
682
+ timeout=30,
683
+ cwd=str(Path(__file__).parent.parent.parent),
684
+ )
685
+
686
+ assert result.returncode == 0
687
+
688
+ def test_bikerack_has_status_subcommand(self) -> None:
689
+ """bikerack CLI should have status subcommand."""
690
+ result = subprocess.run(
691
+ [sys.executable, "-m", "pennyfarthing_scripts.bikerack", "status", "--help"],
692
+ capture_output=True,
693
+ text=True,
694
+ timeout=30,
695
+ cwd=str(Path(__file__).parent.parent.parent),
696
+ )
697
+
698
+ assert result.returncode == 0
699
+
700
+ def test_bikerack_default_invokes_start(self) -> None:
701
+ """bikerack with no subcommand should invoke start."""
702
+ # Running without subcommand should behave like 'start'
703
+ # Since start is not implemented, it should error
704
+ result = subprocess.run(
705
+ [sys.executable, "-m", "pennyfarthing_scripts.bikerack"],
706
+ capture_output=True,
707
+ text=True,
708
+ timeout=30,
709
+ cwd=str(Path(__file__).parent.parent.parent),
710
+ )
711
+
712
+ # Should fail (NotImplementedError in start) but not with usage error
713
+ assert result.returncode != 0
714
+
715
+
716
+ # ---------------------------------------------------------------------------
717
+ # Utility function tests
718
+ # ---------------------------------------------------------------------------
719
+
720
+
721
+ class TestProcessAlive:
722
+ """Utility: is_process_alive checks if PID is running."""
723
+
724
+ def test_detects_own_process(self) -> None:
725
+ """is_process_alive should return True for current process."""
726
+ result = is_process_alive(os.getpid())
727
+ assert result is True
728
+
729
+ def test_returns_false_for_invalid_pid(self) -> None:
730
+ """is_process_alive should return False for non-existent PID."""
731
+ result = is_process_alive(999999999)
732
+ assert result is False
733
+
734
+
735
+ class TestCleanupFiles:
736
+ """Utility: cleanup_files removes port and PID files."""
737
+
738
+ def test_removes_port_file(self, tmp_path: Path) -> None:
739
+ """cleanup_files should remove .bikerack-port."""
740
+ port_file = tmp_path / ".bikerack-port"
741
+ port_file.write_text("2898")
742
+
743
+ cleanup_files(tmp_path)
744
+
745
+ assert not port_file.exists()
746
+
747
+ def test_removes_pid_file(self, tmp_path: Path) -> None:
748
+ """cleanup_files should remove .bikerack-pid."""
749
+ pid_file = tmp_path / ".bikerack-pid"
750
+ pid_file.write_text("12345")
751
+
752
+ cleanup_files(tmp_path)
753
+
754
+ assert not pid_file.exists()
755
+
756
+ def test_no_error_when_files_missing(self, tmp_path: Path) -> None:
757
+ """cleanup_files should not error when files don't exist."""
758
+ # Should not raise
759
+ cleanup_files(tmp_path)
760
+
761
+
762
+ class TestReadPortFile:
763
+ """Utility: read_port_file reads port from file."""
764
+
765
+ def test_reads_port(self, tmp_path: Path) -> None:
766
+ """read_port_file should return port as integer."""
767
+ (tmp_path / ".bikerack-port").write_text("2898")
768
+
769
+ result = read_port_file(tmp_path)
770
+
771
+ assert result == 2898
772
+
773
+ def test_returns_none_when_missing(self, tmp_path: Path) -> None:
774
+ """read_port_file should return None when file doesn't exist."""
775
+ result = read_port_file(tmp_path)
776
+
777
+ assert result is None
778
+
779
+ def test_handles_trailing_whitespace(self, tmp_path: Path) -> None:
780
+ """read_port_file should handle trailing newlines/spaces."""
781
+ (tmp_path / ".bikerack-port").write_text("2898\n")
782
+
783
+ result = read_port_file(tmp_path)
784
+
785
+ assert result == 2898