@pennyfarthing/core 11.2.0 → 11.2.2

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 (367) hide show
  1. package/README.md +100 -40
  2. package/package.json +2 -1
  3. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/doctor.js +474 -66
  5. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  6. package/packages/core/dist/cli/commands/init.js +4 -4
  7. package/packages/core/dist/cli/commands/init.js.map +1 -1
  8. package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
  9. package/packages/core/dist/cli/commands/update.js +4 -5
  10. package/packages/core/dist/cli/commands/update.js.map +1 -1
  11. package/packages/core/dist/cli/utils/constants.d.ts +3 -8
  12. package/packages/core/dist/cli/utils/constants.d.ts.map +1 -1
  13. package/packages/core/dist/cli/utils/constants.js +3 -4
  14. package/packages/core/dist/cli/utils/constants.js.map +1 -1
  15. package/packages/core/dist/cli/utils/settings.d.ts +7 -0
  16. package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
  17. package/packages/core/dist/cli/utils/settings.js +70 -29
  18. package/packages/core/dist/cli/utils/settings.js.map +1 -1
  19. package/packages/core/dist/cli/utils/symlinks.js +16 -16
  20. package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
  21. package/packages/core/dist/consultation/dialogue-manager.d.ts +1 -1
  22. package/packages/core/dist/consultation/dialogue-manager.d.ts.map +1 -1
  23. package/packages/core/dist/consultation/dialogue-manager.js +1 -1
  24. package/packages/core/dist/consultation/dialogue-manager.js.map +1 -1
  25. package/packages/core/dist/consultation/dialogue-manager.test.js.map +1 -1
  26. package/packages/core/dist/consultation/tandem-metrics.d.ts +91 -0
  27. package/packages/core/dist/consultation/tandem-metrics.d.ts.map +1 -0
  28. package/packages/core/dist/consultation/tandem-metrics.js +131 -0
  29. package/packages/core/dist/consultation/tandem-metrics.js.map +1 -0
  30. package/packages/core/dist/consultation/tandem-metrics.test.d.ts +18 -0
  31. package/packages/core/dist/consultation/tandem-metrics.test.d.ts.map +1 -0
  32. package/packages/core/dist/consultation/tandem-metrics.test.js +457 -0
  33. package/packages/core/dist/consultation/tandem-metrics.test.js.map +1 -0
  34. package/packages/core/dist/public/css/react.css +1 -1
  35. package/packages/core/dist/public/js/react/react.js +14 -14
  36. package/packages/core/dist/server/api/agent-load.js +1 -1
  37. package/packages/core/dist/server/api/agent-load.js.map +1 -1
  38. package/packages/core/dist/server/api/git.d.ts.map +1 -1
  39. package/packages/core/dist/server/api/git.js +0 -1
  40. package/packages/core/dist/server/api/git.js.map +1 -1
  41. package/packages/core/dist/server/api/index.d.ts +2 -0
  42. package/packages/core/dist/server/api/index.d.ts.map +1 -1
  43. package/packages/core/dist/server/api/index.js +2 -0
  44. package/packages/core/dist/server/api/index.js.map +1 -1
  45. package/packages/core/dist/server/api/project-info.d.ts +11 -0
  46. package/packages/core/dist/server/api/project-info.d.ts.map +1 -0
  47. package/packages/core/dist/server/api/project-info.js +18 -0
  48. package/packages/core/dist/server/api/project-info.js.map +1 -0
  49. package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
  50. package/packages/core/dist/server/otlp-receiver.js +18 -1
  51. package/packages/core/dist/server/otlp-receiver.js.map +1 -1
  52. package/packages/core/dist/server/otlp-receiver.test.js +1 -1
  53. package/packages/core/dist/server/otlp-receiver.test.js.map +1 -1
  54. package/packages/core/dist/server/server.d.ts +0 -3
  55. package/packages/core/dist/server/server.d.ts.map +1 -1
  56. package/packages/core/dist/server/server.js +5 -38
  57. package/packages/core/dist/server/server.js.map +1 -1
  58. package/packages/core/dist/server/server.test.d.ts +1 -1
  59. package/packages/core/dist/server/server.test.js +12 -23
  60. package/packages/core/dist/server/server.test.js.map +1 -1
  61. package/packages/core/dist/server/settings.d.ts +1 -0
  62. package/packages/core/dist/server/settings.d.ts.map +1 -1
  63. package/packages/core/dist/server/settings.js +13 -0
  64. package/packages/core/dist/server/settings.js.map +1 -1
  65. package/packages/core/dist/shared/capabilities.d.ts +88 -0
  66. package/packages/core/dist/shared/capabilities.d.ts.map +1 -0
  67. package/packages/core/dist/shared/capabilities.js +133 -0
  68. package/packages/core/dist/shared/capabilities.js.map +1 -0
  69. package/packages/core/dist/shared/capabilities.test.d.ts +2 -0
  70. package/packages/core/dist/shared/capabilities.test.d.ts.map +1 -0
  71. package/packages/core/dist/shared/capabilities.test.js +217 -0
  72. package/packages/core/dist/shared/capabilities.test.js.map +1 -0
  73. package/packages/core/dist/shared/spawn-prompt.d.ts +47 -0
  74. package/packages/core/dist/shared/spawn-prompt.d.ts.map +1 -0
  75. package/packages/core/dist/shared/spawn-prompt.js +82 -0
  76. package/packages/core/dist/shared/spawn-prompt.js.map +1 -0
  77. package/packages/core/dist/shared/spawn-prompt.test.d.ts +2 -0
  78. package/packages/core/dist/shared/spawn-prompt.test.d.ts.map +1 -0
  79. package/packages/core/dist/shared/spawn-prompt.test.js +251 -0
  80. package/packages/core/dist/shared/spawn-prompt.test.js.map +1 -0
  81. package/packages/core/dist/workflow/tandem-workflow-templates.test.d.ts +18 -0
  82. package/packages/core/dist/workflow/tandem-workflow-templates.test.d.ts.map +1 -0
  83. package/packages/core/dist/workflow/tandem-workflow-templates.test.js +434 -0
  84. package/packages/core/dist/workflow/tandem-workflow-templates.test.js.map +1 -0
  85. package/packages/core/dist/workflow/team-lifecycle.d.ts +169 -0
  86. package/packages/core/dist/workflow/team-lifecycle.d.ts.map +1 -0
  87. package/packages/core/dist/workflow/team-lifecycle.js +217 -0
  88. package/packages/core/dist/workflow/team-lifecycle.js.map +1 -0
  89. package/packages/core/dist/workflow/team-lifecycle.test.d.ts +20 -0
  90. package/packages/core/dist/workflow/team-lifecycle.test.d.ts.map +1 -0
  91. package/packages/core/dist/workflow/team-lifecycle.test.js +966 -0
  92. package/packages/core/dist/workflow/team-lifecycle.test.js.map +1 -0
  93. package/packages/core/dist/workflow/workflow-schema.d.ts +32 -0
  94. package/packages/core/dist/workflow/workflow-schema.d.ts.map +1 -1
  95. package/packages/core/dist/workflow/workflow-schema.js +120 -0
  96. package/packages/core/dist/workflow/workflow-schema.js.map +1 -1
  97. package/packages/core/dist/workflow/workflow-schema.test.d.ts.map +1 -1
  98. package/packages/core/dist/workflow/workflow-schema.test.js +570 -1
  99. package/packages/core/dist/workflow/workflow-schema.test.js.map +1 -1
  100. package/packages/core/dist/workflow/workflow-team-templates.test.d.ts +17 -0
  101. package/packages/core/dist/workflow/workflow-team-templates.test.d.ts.map +1 -0
  102. package/packages/core/dist/workflow/workflow-team-templates.test.js +275 -0
  103. package/packages/core/dist/workflow/workflow-team-templates.test.js.map +1 -0
  104. package/pennyfarthing-dist/agents/dev.md +21 -12
  105. package/pennyfarthing-dist/agents/reviewer.md +23 -4
  106. package/pennyfarthing-dist/agents/sm-finish.md +19 -2
  107. package/pennyfarthing-dist/agents/sm-setup.md +7 -7
  108. package/pennyfarthing-dist/agents/sm.md +12 -12
  109. package/pennyfarthing-dist/agents/tea.md +2 -2
  110. package/pennyfarthing-dist/agents/testing-runner.md +1 -1
  111. package/pennyfarthing-dist/commands/pf-architect.md +1 -1
  112. package/pennyfarthing-dist/commands/pf-ba.md +1 -1
  113. package/pennyfarthing-dist/commands/pf-chore.md +2 -2
  114. package/pennyfarthing-dist/commands/pf-dev.md +1 -1
  115. package/pennyfarthing-dist/commands/pf-devops.md +1 -1
  116. package/pennyfarthing-dist/commands/pf-epic.md +6 -6
  117. package/pennyfarthing-dist/commands/pf-git.md +12 -10
  118. package/pennyfarthing-dist/commands/pf-health-check.md +1 -1
  119. package/pennyfarthing-dist/commands/pf-help.md +12 -12
  120. package/pennyfarthing-dist/commands/pf-orchestrator.md +1 -1
  121. package/pennyfarthing-dist/commands/pf-pm.md +1 -1
  122. package/pennyfarthing-dist/commands/pf-prime.md +8 -8
  123. package/pennyfarthing-dist/commands/pf-reviewer.md +1 -1
  124. package/pennyfarthing-dist/commands/pf-session.md +7 -7
  125. package/pennyfarthing-dist/commands/pf-sm.md +1 -1
  126. package/pennyfarthing-dist/commands/pf-sprint.md +7 -7
  127. package/pennyfarthing-dist/commands/pf-tea.md +1 -1
  128. package/pennyfarthing-dist/commands/pf-tech-writer.md +1 -1
  129. package/pennyfarthing-dist/commands/pf-theme.md +9 -9
  130. package/pennyfarthing-dist/commands/pf-ux-designer.md +1 -1
  131. package/pennyfarthing-dist/commands/pf-work.md +1 -1
  132. package/pennyfarthing-dist/gates/approval.md +63 -0
  133. package/pennyfarthing-dist/gates/confidence-sm.md +71 -0
  134. package/pennyfarthing-dist/gates/context-ok.md +56 -0
  135. package/pennyfarthing-dist/gates/evaluations/confidence-sm.md +54 -0
  136. package/pennyfarthing-dist/gates/quality-pass.md +67 -0
  137. package/pennyfarthing-dist/gates/tests-fail.md +84 -0
  138. package/pennyfarthing-dist/gates/tests-pass.md +79 -0
  139. package/pennyfarthing-dist/guides/agent-behavior.md +84 -29
  140. package/pennyfarthing-dist/guides/agent-coordination.md +10 -10
  141. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +6 -6
  142. package/pennyfarthing-dist/guides/agent-template-tactical.md +1 -1
  143. package/pennyfarthing-dist/guides/bell-mode.md +1 -1
  144. package/pennyfarthing-dist/guides/bikerack.md +10 -10
  145. package/pennyfarthing-dist/guides/brownfield-tools.md +24 -24
  146. package/pennyfarthing-dist/guides/command-tag-taxonomy.md +1 -1
  147. package/pennyfarthing-dist/guides/gate-schema.md +2 -2
  148. package/pennyfarthing-dist/guides/gates.md +3 -3
  149. package/pennyfarthing-dist/guides/handoff-cli.md +8 -8
  150. package/pennyfarthing-dist/guides/hooks.md +29 -29
  151. package/pennyfarthing-dist/guides/prime.md +2 -2
  152. package/pennyfarthing-dist/guides/reflector.md +1 -1
  153. package/pennyfarthing-dist/guides/skill-schema.md +6 -6
  154. package/pennyfarthing-dist/guides/tandem-protocol.md +3 -3
  155. package/pennyfarthing-dist/guides/workflow-schema.md +1 -1
  156. package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
  157. package/pennyfarthing-dist/guides/xml-tags.md +8 -8
  158. package/pennyfarthing-dist/scripts/README.md +4 -4
  159. package/pennyfarthing-dist/scripts/core/agent-session.sh +2 -5
  160. package/pennyfarthing-dist/scripts/core/check-context.sh +3 -1
  161. package/pennyfarthing-dist/scripts/core/pf.sh +5 -0
  162. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +4 -89
  163. package/pennyfarthing-dist/scripts/core/prime.sh +2 -25
  164. package/pennyfarthing-dist/scripts/git/README.md +14 -14
  165. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +2 -3
  166. package/pennyfarthing-dist/scripts/git/git-status-all.sh +2 -3
  167. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -3
  168. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +2 -4
  169. package/pennyfarthing-dist/scripts/hooks/README.md +6 -6
  170. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +4 -183
  171. package/pennyfarthing-dist/scripts/hooks/context-circuit-breaker.sh +4 -95
  172. package/pennyfarthing-dist/scripts/hooks/context-warning.sh +4 -65
  173. package/pennyfarthing-dist/scripts/hooks/cyclist-pretooluse-hook.sh +3 -31
  174. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +5 -4
  175. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +29 -34
  176. package/pennyfarthing-dist/scripts/hooks/pre-edit-check.sh +4 -71
  177. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +3 -19
  178. package/pennyfarthing-dist/scripts/hooks/schema-validation.sh +4 -30
  179. package/pennyfarthing-dist/scripts/hooks/session-start.sh +3 -32
  180. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +4 -65
  181. package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +4 -78
  182. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +3 -93
  183. package/pennyfarthing-dist/scripts/lib/env.sh +34 -0
  184. package/pennyfarthing-dist/scripts/lib/run-pf.sh +39 -0
  185. package/pennyfarthing-dist/scripts/misc/README.md +1 -1
  186. package/pennyfarthing-dist/scripts/misc/statusline.sh +4 -301
  187. package/pennyfarthing-dist/scripts/sprint/README.md +21 -21
  188. package/pennyfarthing-dist/scripts/workflow/README.md +2 -2
  189. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +2 -16
  190. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +3 -3
  191. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +3 -3
  192. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +3 -3
  193. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +3 -3
  194. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +3 -3
  195. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +3 -3
  196. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +3 -3
  197. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +3 -3
  198. package/pennyfarthing-dist/skills/pf-bc/examples.md +23 -23
  199. package/pennyfarthing-dist/skills/pf-bc/skill.md +17 -17
  200. package/pennyfarthing-dist/skills/pf-bc/usage.md +8 -8
  201. package/pennyfarthing-dist/skills/pf-jira/SKILL.md +15 -15
  202. package/pennyfarthing-dist/skills/pf-jira/examples.md +48 -48
  203. package/pennyfarthing-dist/skills/pf-jira/usage.md +15 -15
  204. package/pennyfarthing-dist/skills/pf-sprint/examples.md +80 -80
  205. package/pennyfarthing-dist/skills/pf-sprint/skill.md +35 -35
  206. package/pennyfarthing-dist/skills/pf-sprint/usage.md +30 -30
  207. package/pennyfarthing-dist/skills/pf-theme/examples.md +15 -15
  208. package/pennyfarthing-dist/skills/pf-theme/skill.md +6 -6
  209. package/pennyfarthing-dist/skills/pf-theme/usage.md +5 -5
  210. package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -27
  211. package/pennyfarthing-dist/skills/pf-workflow/skill.md +11 -11
  212. package/pennyfarthing-dist/skills/pf-workflow/usage.md +11 -11
  213. package/pennyfarthing-dist/skills/skill-registry.yaml +19 -19
  214. package/pennyfarthing-dist/templates/settings.local.json.template +19 -10
  215. package/pennyfarthing-dist/workflows/bdd-team.yaml +89 -0
  216. package/pennyfarthing-dist/workflows/epics-and-stories/steps/step-05-import-to-future.md +1 -1
  217. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
  218. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
  219. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
  220. package/pennyfarthing-dist/workflows/project-setup/steps/step-01-discover.md +47 -0
  221. package/pennyfarthing-dist/workflows/tdd-team.yaml +80 -0
  222. package/pennyfarthing-dist/workflows/tdd.yaml +11 -2
  223. package/pennyfarthing_scripts/CLAUDE.md +19 -10
  224. package/pennyfarthing_scripts/__init__.py +1 -1
  225. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  226. package/pennyfarthing_scripts/__pycache__/bellmode_hook.cpython-314.pyc +0 -0
  227. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  228. package/pennyfarthing_scripts/__pycache__/context.cpython-314.pyc +0 -0
  229. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  230. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  231. package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
  232. package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
  233. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  234. package/pennyfarthing_scripts/bc/__pycache__/focus.cpython-314.pyc +0 -0
  235. package/pennyfarthing_scripts/bc/__pycache__/split.cpython-314.pyc +0 -0
  236. package/pennyfarthing_scripts/bc/cli.py +2 -2
  237. package/pennyfarthing_scripts/bellmode_hook.py +9 -296
  238. package/pennyfarthing_scripts/bikerack/__pycache__/audit_log_panel.cpython-314.pyc +0 -0
  239. package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
  240. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  241. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  242. package/pennyfarthing_scripts/bikerack/__pycache__/context_meter_footer.cpython-314.pyc +0 -0
  243. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  244. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  245. package/pennyfarthing_scripts/bikerack/__pycache__/events.cpython-314.pyc +0 -0
  246. package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
  247. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  248. package/pennyfarthing_scripts/bikerack/__pycache__/portrait_resolver.cpython-314.pyc +0 -0
  249. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  250. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  251. package/pennyfarthing_scripts/bikerack/__pycache__/story_detail_data.cpython-314.pyc +0 -0
  252. package/pennyfarthing_scripts/bikerack/__pycache__/story_detail_screen.cpython-314.pyc +0 -0
  253. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  254. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  255. package/pennyfarthing_scripts/bikerack/audit_log_panel.py +161 -0
  256. package/pennyfarthing_scripts/bikerack/base_panel.py +27 -4
  257. package/pennyfarthing_scripts/bikerack/changed_panel.py +96 -4
  258. package/pennyfarthing_scripts/bikerack/context_meter_footer.py +88 -0
  259. package/pennyfarthing_scripts/bikerack/debug_panel.py +1 -1
  260. package/pennyfarthing_scripts/bikerack/diffs_panel.py +30 -0
  261. package/pennyfarthing_scripts/bikerack/events.py +28 -0
  262. package/pennyfarthing_scripts/bikerack/launcher.py +6 -6
  263. package/pennyfarthing_scripts/bikerack/portrait_resolver.py +139 -0
  264. package/pennyfarthing_scripts/bikerack/progress_panel.py +0 -1
  265. package/pennyfarthing_scripts/bikerack/sprint_panel.py +373 -142
  266. package/pennyfarthing_scripts/bikerack/story_detail_data.py +247 -0
  267. package/pennyfarthing_scripts/bikerack/story_detail_screen.py +177 -0
  268. package/pennyfarthing_scripts/bikerack/tui.py +304 -62
  269. package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
  270. package/pennyfarthing_scripts/cli.py +5 -0
  271. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  272. package/pennyfarthing_scripts/common/config.py +29 -2
  273. package/pennyfarthing_scripts/common/pr_config.py +38 -0
  274. package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
  275. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  276. package/pennyfarthing_scripts/consultation/cli.py +3 -3
  277. package/pennyfarthing_scripts/context.py +3 -3
  278. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  279. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  280. package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
  281. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  282. package/pennyfarthing_scripts/git/hooks_installer.py +2 -3
  283. package/pennyfarthing_scripts/git/status_all.py +1 -1
  284. package/pennyfarthing_scripts/git/worktree.py +2 -2
  285. package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
  286. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  287. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  288. package/pennyfarthing_scripts/handoff/__pycache__/marker.cpython-314.pyc +0 -0
  289. package/pennyfarthing_scripts/handoff/__pycache__/phase_check.cpython-314.pyc +0 -0
  290. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  291. package/pennyfarthing_scripts/handoff/cli.py +33 -1
  292. package/pennyfarthing_scripts/handoff/complete_phase.py +28 -0
  293. package/pennyfarthing_scripts/handoff/marker.py +15 -15
  294. package/pennyfarthing_scripts/handoff/phase_check.py +96 -0
  295. package/pennyfarthing_scripts/handoff/resolve_gate.py +13 -1
  296. package/pennyfarthing_scripts/hooks/__init__.py +442 -0
  297. package/pennyfarthing_scripts/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  298. package/pennyfarthing_scripts/hooks/__pycache__/bell_mode.cpython-314.pyc +0 -0
  299. package/pennyfarthing_scripts/hooks/__pycache__/cli.cpython-314.pyc +0 -0
  300. package/pennyfarthing_scripts/hooks/__pycache__/context_breaker.cpython-314.pyc +0 -0
  301. package/pennyfarthing_scripts/hooks/__pycache__/context_warning.cpython-314.pyc +0 -0
  302. package/pennyfarthing_scripts/hooks/__pycache__/cyclist_pretooluse.cpython-314.pyc +0 -0
  303. package/pennyfarthing_scripts/hooks/__pycache__/pre_edit_check.cpython-314.pyc +0 -0
  304. package/pennyfarthing_scripts/hooks/__pycache__/reflector_check.cpython-314.pyc +0 -0
  305. package/pennyfarthing_scripts/hooks/__pycache__/schema_validation.cpython-314.pyc +0 -0
  306. package/pennyfarthing_scripts/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
  307. package/pennyfarthing_scripts/hooks/__pycache__/session_stop.cpython-314.pyc +0 -0
  308. package/pennyfarthing_scripts/hooks/__pycache__/sprint_yaml_validation.cpython-314.pyc +0 -0
  309. package/pennyfarthing_scripts/hooks/__pycache__/statusline.cpython-314.pyc +0 -0
  310. package/pennyfarthing_scripts/hooks/bell_mode.py +214 -0
  311. package/pennyfarthing_scripts/hooks/cli.py +96 -0
  312. package/pennyfarthing_scripts/hooks/context_breaker.py +104 -0
  313. package/pennyfarthing_scripts/hooks/context_warning.py +66 -0
  314. package/pennyfarthing_scripts/hooks/cyclist_pretooluse.py +129 -0
  315. package/pennyfarthing_scripts/hooks/pre_edit_check.py +77 -0
  316. package/pennyfarthing_scripts/hooks/reflector_check.py +270 -0
  317. package/pennyfarthing_scripts/hooks/schema_validation.py +202 -0
  318. package/pennyfarthing_scripts/hooks/session_start.py +294 -0
  319. package/pennyfarthing_scripts/hooks/session_stop.py +111 -0
  320. package/pennyfarthing_scripts/hooks/sprint_yaml_validation.py +97 -0
  321. package/pennyfarthing_scripts/hooks/statusline.py +429 -0
  322. package/pennyfarthing_scripts/hooks.py +27 -432
  323. package/pennyfarthing_scripts/pretooluse_hook.py +3 -185
  324. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  325. package/pennyfarthing_scripts/prime/heatmap.py +3 -15
  326. package/pennyfarthing_scripts/prime/workflow.py +2 -1
  327. package/pennyfarthing_scripts/schema_validation_hook.py +3 -298
  328. package/pennyfarthing_scripts/session_start_hook.py +4 -186
  329. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  330. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  331. package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
  332. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  333. package/pennyfarthing_scripts/sprint/cli.py +121 -0
  334. package/pennyfarthing_scripts/sprint/loader.py +154 -3
  335. package/pennyfarthing_scripts/sprint/story_update.py +26 -0
  336. package/pennyfarthing_scripts/tests/__pycache__/test_bikerack.cpython-314-pytest-9.0.2.pyc +0 -0
  337. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  338. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_list_team.cpython-314-pytest-9.0.2.pyc +0 -0
  339. package/pennyfarthing_scripts/tests/test_bikerack.py +26 -26
  340. package/pennyfarthing_scripts/tests/test_dialogue_manager.py +0 -1
  341. package/pennyfarthing_scripts/tests/test_sprint_panel.py +344 -265
  342. package/pennyfarthing_scripts/tests/test_workflow_list_team.py +147 -0
  343. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  344. package/pennyfarthing_scripts/validate/adapters/__pycache__/skill_command.cpython-314.pyc +0 -0
  345. package/pennyfarthing_scripts/validate/adapters/__pycache__/tandem_awareness.cpython-314.pyc +0 -0
  346. package/pennyfarthing_scripts/validate/adapters/__pycache__/team_mode.cpython-314.pyc +0 -0
  347. package/pennyfarthing_scripts/validate/adapters/__pycache__/workflow.cpython-314.pyc +0 -0
  348. package/pennyfarthing_scripts/validate/adapters/team_mode.py +323 -0
  349. package/pennyfarthing_scripts/validate/adapters/workflow.py +19 -0
  350. package/pennyfarthing_scripts/welcome_hook.py +3 -149
  351. package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  352. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  353. package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
  354. package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
  355. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
  356. package/pennyfarthing_scripts/workflow/__pycache__/team_lifecycle.cpython-314.pyc +0 -0
  357. package/pennyfarthing_scripts/workflow/cli.py +22 -20
  358. package/pennyfarthing_scripts/workflow/state.py +0 -1
  359. package/pennyfarthing_scripts/workflow/team_lifecycle.py +256 -0
  360. package/packages/core/dist/cli/cyclist-migration.test.d.ts +0 -16
  361. package/packages/core/dist/cli/cyclist-migration.test.d.ts.map +0 -1
  362. package/packages/core/dist/cli/cyclist-migration.test.js +0 -229
  363. package/packages/core/dist/cli/cyclist-migration.test.js.map +0 -1
  364. package/packages/core/dist/scripts/theme-detail.test.d.ts +0 -10
  365. package/packages/core/dist/scripts/theme-detail.test.d.ts.map +0 -1
  366. package/packages/core/dist/scripts/theme-detail.test.js +0 -199
  367. package/packages/core/dist/scripts/theme-detail.test.js.map +0 -1
@@ -1,204 +1,435 @@
1
1
  """SprintPanel — Sprint status panel for BikeRack TUI.
2
2
 
3
3
  Story 103-6: First panel implementation proving the BasePanel vertical slice.
4
- Subscribes to /ws/sprint, renders sprint status as Rich table.
4
+ Story 110-2: Added per-story cursor navigation and drill-through.
5
+ Migrated to Textual Tree widget for native hierarchy navigation.
6
+ Subscribes to /ws/sprint, renders sprint status with epic/story tree.
5
7
  """
6
8
 
7
9
  from __future__ import annotations
8
10
 
9
11
  from typing import Any
10
12
 
11
- from rich.console import Group
12
- from rich.padding import Padding
13
13
  from rich.text import Text
14
+ from textual.app import ComposeResult
15
+ from textual.message import Message
16
+ from textual.widget import Widget
17
+ from textual.widgets import Static, Tree
14
18
 
15
- from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel, render_progress_bar
19
+ from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, render_progress_bar
20
+
21
+
22
+ def _normalize_status(status: str) -> str:
23
+ """Normalize status string: lowercase, strip, hyphens for separators."""
24
+ s = status.lower().strip().replace("_", "-") if status else ""
25
+ # Normalize both British and American spelling
26
+ if s == "cancelled":
27
+ s = "canceled"
28
+ return s
29
+
30
+
31
+ def _is_terminal(status: str) -> bool:
32
+ """Return True if the normalized status is done or canceled."""
33
+ return _normalize_status(status) in {"done", "canceled"}
16
34
 
17
35
 
18
36
  def _status_badge(status: str) -> Text:
19
- """Convert status string to styled Rich Text badge."""
20
- s = status.lower().strip() if status else ""
37
+ """Convert status string to styled Rich Text badge (symbol only, no text)."""
38
+ s = _normalize_status(status)
21
39
  if s == "done":
22
- return Text("\u2713 done", style="green")
40
+ return Text("\u2713", style="dim green")
41
+ if s == "canceled":
42
+ return Text("\u2715", style="dim")
23
43
  if s == "in-progress":
24
- return Text("\u27f3 in-progress", style="yellow")
44
+ return Text("\u27f3", style="bold yellow")
25
45
  if s == "backlog":
26
- return Text("\u25ef backlog", style="dim")
46
+ return Text("\u25ef", style="dim")
27
47
  if s == "blocked":
28
- return Text("! blocked", style="bold red")
48
+ return Text("!", style="bold red")
29
49
  if s == "review":
30
- return Text("\u25ce review", style="cyan")
31
- return Text(status or "\u2014", style="dim")
50
+ return Text("\u25ce", style="cyan")
51
+ return Text("\u2014", style="dim")
52
+
53
+
54
+ def _format_assignee(email: str | None) -> str:
55
+ """Format an email address into a short display name.
56
+
57
+ ``"keith.avery@1898andco.io"`` → ``"K. Avery"``
58
+ ``None`` → ``""``
59
+ """
60
+ if not email:
61
+ return ""
62
+ local = email.split("@")[0]
63
+ parts = local.replace("_", ".").split(".")
64
+ if len(parts) < 2:
65
+ return parts[0].capitalize()
66
+ first_initial = parts[0][0].upper() if parts[0] else ""
67
+ last = parts[-1].capitalize()
68
+ return f"{first_initial}. {last}"
69
+
70
+
71
+ def _should_expand(epic: dict[str, Any]) -> bool:
72
+ """Check if an epic should be expanded by default (has incomplete work).
73
+
74
+ Epics that are canceled, or whose stories are all done/canceled, collapse.
75
+ """
76
+ if _is_terminal(epic.get("status", "")):
77
+ return False
78
+ stories = epic.get("stories", [])
79
+ total_pts = 0
80
+ done_pts = 0
81
+ has_in_progress = False
82
+ for story in stories:
83
+ pts = story.get("points", 0)
84
+ if isinstance(pts, (int, float)):
85
+ total_pts += pts
86
+ if _is_terminal(story.get("status", "")):
87
+ done_pts += pts
88
+ if _normalize_status(story.get("status", "")) == "in-progress":
89
+ has_in_progress = True
90
+ return has_in_progress or done_pts < total_pts
91
+
92
+
93
+ _EPIC_ID_WIDTH = 11 # "MSSCI-NNNNN" = 11 chars
94
+
95
+
96
+ def _build_epic_label(
97
+ epic_id: str, title: str, done_pts: int, total_pts: int, jira_key: str = ""
98
+ ) -> Text:
99
+ """Build Rich Text label for an epic tree node."""
100
+ label = Text(no_wrap=True, overflow="ellipsis")
101
+ display_id = jira_key if jira_key else epic_id
102
+ if len(display_id) > _EPIC_ID_WIDTH:
103
+ display_id = display_id[: _EPIC_ID_WIDTH - 1] + "\u2026"
104
+ display_id = f"{display_id:<{_EPIC_ID_WIDTH}}"
105
+ label.append(display_id, style="bold cyan")
106
+ label.append(" ")
107
+ if total_pts > 0:
108
+ pct = int(done_pts / total_pts * 100)
109
+ label.append_text(render_progress_bar(pct, width=10, fill_style="dim green"))
110
+ label.append(f" {done_pts}/{total_pts} pts", style="dim")
111
+ else:
112
+ label.append("0 pts", style="dim")
113
+ label.append(f" {title}", style="bold")
114
+ return label
115
+
116
+
117
+ def _build_story_label(story: dict[str, Any], current_story_id: str) -> Text:
118
+ """Build Rich Text label for a story tree leaf.
119
+
120
+ Layout: ``✓ MSSCI-14952 2 Story title``
121
+ In-progress adds owner: ``⟳ MSSCI-15186 5 Story title [K. Avery]``
122
+ Done stories are rendered entirely dim.
123
+ """
124
+ story_id = story.get("id", "")
125
+ title = story.get("title", "")
126
+ pts = story.get("points", "")
127
+ jira = story.get("jiraKey") or "\u2014"
128
+ status = _normalize_status(story.get("status", ""))
129
+ badge = _status_badge(story.get("status", ""))
130
+
131
+ is_done = _is_terminal(story.get("status", ""))
132
+ is_in_progress = status == "in-progress"
133
+ is_current = story_id == current_story_id
134
+
135
+ label = Text(no_wrap=True, overflow="ellipsis")
136
+ label.append_text(badge)
137
+
138
+ # MSSCI key flush left, fixed-width (14 chars — fits "MSSCI-NNNNN" + padding)
139
+ jira_padded = f"{jira:<14}"
140
+ label.append(f" {jira_padded}", style="dim" if is_done else ("bold cyan" if is_current else "cyan"))
32
141
 
142
+ # Points right-aligned (2 chars)
143
+ pts_str = f"{pts:>2}" if isinstance(pts, int) else f"{pts!s:>2}"
144
+ label.append(f" {pts_str}", style="dim")
33
145
 
34
- class SprintPanel(BasePanel):
35
- """Sprint status panel.
146
+ # Title
147
+ label.append(f" {title}", style="dim" if is_done else "")
148
+
149
+ # Owner for in-progress stories
150
+ if is_in_progress:
151
+ owner = _format_assignee(story.get("assignee"))
152
+ if owner:
153
+ label.append(f" [{owner}]", style="dim yellow")
154
+
155
+ if is_current:
156
+ label.stylize("bold")
157
+
158
+ if is_done:
159
+ label.stylize("dim")
160
+
161
+ return label
162
+
163
+
164
+ class SprintPanel(Widget):
165
+ """Sprint panel using native Textual Tree widget.
36
166
 
37
167
  Subscribes to the ``sprint`` WebSocket channel and renders
38
- sprint status as a Rich table with story list, points, and velocity.
168
+ sprint status as a Tree with epic/story hierarchy.
169
+ """
170
+
171
+ DEFAULT_CSS = """
172
+ SprintPanel {
173
+ height: 1fr;
174
+ layout: vertical;
175
+ }
176
+ #sprint-header {
177
+ height: auto;
178
+ max-height: 3;
179
+ padding: 0 1;
180
+ }
181
+ #sprint-hints {
182
+ height: 1;
183
+ padding: 0 1;
184
+ }
185
+ #sprint-tree {
186
+ height: 1fr;
187
+ }
39
188
  """
40
189
 
41
190
  channel: str = "sprint"
42
191
  panel_name: str = "Sprint"
43
192
  icon: str = PANEL_ICONS["sprint"][0]
193
+ can_focus = True
44
194
 
45
- def __init__(self, client: Any = None, **kwargs: Any) -> None:
46
- super().__init__(client=client, **kwargs)
47
- self._selected_epic: int = 0
48
- self._toggled: dict[str, bool] = {} # epic_id -> user override
195
+ class DataReceived(Message, bubble=False):
196
+ """WebSocket data received — triggers tree rebuild."""
49
197
 
50
- def next_epic(self) -> None:
51
- """Move selection to the next epic."""
52
- epic_count = self._epic_count()
53
- if epic_count == 0:
54
- return
55
- self._selected_epic = (self._selected_epic + 1) % epic_count
56
- self._rerender()
198
+ def __init__(self, payload: dict[str, Any]) -> None:
199
+ super().__init__()
200
+ self.payload = payload
57
201
 
58
- def prev_epic(self) -> None:
59
- """Move selection to the previous epic."""
60
- epic_count = self._epic_count()
61
- if epic_count == 0:
62
- return
63
- self._selected_epic = (self._selected_epic - 1) % epic_count
64
- self._rerender()
65
-
66
- def toggle_epic(self) -> None:
67
- """Toggle expand/collapse on the selected epic."""
68
- if self._last_payload is None:
202
+ def __init__(self, client: Any = None, **kwargs: Any) -> None:
203
+ super().__init__(**kwargs)
204
+ self._client = client
205
+ self._last_payload: dict[str, Any] | None = None
206
+ self._mounted = False
207
+
208
+ def compose(self) -> ComposeResult:
209
+ yield Static(
210
+ "[dim]Waiting for sprint data...[/dim]", id="sprint-header"
211
+ )
212
+ yield Static(
213
+ "[dim]\u2191/\u2193:navigate space:expand/collapse Enter:open j/k/e:vim nav[/dim]",
214
+ id="sprint-hints",
215
+ )
216
+ tree: Tree[dict[str, Any]] = Tree("Sprint", id="sprint-tree")
217
+ tree.show_root = False
218
+ tree.guide_depth = 3
219
+ yield tree
220
+
221
+ def on_mount(self) -> None:
222
+ """Subscribe to sprint channel via WS client."""
223
+ self._mounted = True
224
+ if self._client is not None and self.channel:
225
+ self._client.subscribe(self.channel, self._handle_ws_message)
226
+
227
+ def on_unmount(self) -> None:
228
+ """Mark unmounted so WS callbacks are ignored."""
229
+ self._mounted = False
230
+
231
+ def focus(self, scroll_visible: bool = True) -> SprintPanel:
232
+ """Delegate focus to the tree widget."""
233
+ try:
234
+ tree = self.query_one("#sprint-tree", Tree)
235
+ tree.focus(scroll_visible)
236
+ except Exception:
237
+ super().focus(scroll_visible)
238
+ return self
239
+
240
+ # --- WebSocket message handling ---
241
+
242
+ def _handle_ws_message(self, message: dict[str, Any] | None) -> None:
243
+ """Handle incoming WebSocket message (called from async WS task)."""
244
+ if not self._mounted or message is None:
69
245
  return
70
- epics = self._last_payload.get("epics", [])
71
- if not epics or self._selected_epic >= len(epics):
246
+ self._last_payload = message
247
+ try:
248
+ self.post_message(self.DataReceived(message))
249
+ except Exception:
250
+ pass
251
+
252
+ def on_sprint_panel_data_received(self, event: DataReceived) -> None:
253
+ """Process data in Textual message context — rebuilds tree."""
254
+ self._rebuild_tree(event.payload)
255
+
256
+ # --- Tree rebuild ---
257
+
258
+ def _rebuild_tree(self, payload: dict[str, Any]) -> None:
259
+ """Rebuild tree nodes from sprint payload, preserving expand/cursor state."""
260
+ try:
261
+ tree = self.query_one("#sprint-tree", Tree)
262
+ except Exception:
72
263
  return
73
- epic_id = epics[self._selected_epic].get("id", "")
74
- if epic_id:
75
- self._toggled[epic_id] = not self._is_expanded(epics[self._selected_epic])
76
- self._rerender()
77
-
78
- def _rerender(self) -> None:
79
- if self._last_payload is not None:
80
- rendered = self.render_panel(self._last_payload)
81
- try:
82
- self.update(rendered)
83
- except Exception:
84
- pass
85
264
 
86
- def _epic_count(self) -> int:
87
- if self._last_payload is None:
88
- return 0
89
- return len(self._last_payload.get("epics", []))
90
-
91
- def _is_expanded(self, epic: dict[str, Any]) -> bool:
92
- """Check if an epic should be expanded."""
93
- epic_id = epic.get("id", "")
94
- if epic_id in self._toggled:
95
- return self._toggled[epic_id]
96
- # Default: expand if has incomplete work
97
- stories = epic.get("stories", [])
98
- total_pts = 0
99
- done_pts = 0
100
- has_in_progress = False
101
- for story in stories:
102
- pts = story.get("points", 0)
103
- if isinstance(pts, (int, float)):
104
- total_pts += pts
105
- status = (story.get("status") or "").lower().strip()
106
- if status == "done":
107
- done_pts += pts
108
- if status == "in-progress":
109
- has_in_progress = True
110
- return has_in_progress or done_pts < total_pts
111
-
112
- def render_panel(self, payload: dict[str, Any]) -> Any:
113
- """Render sprint data with epic grouping and progress bars."""
114
265
  sprint = payload.get("sprint", {})
115
266
  metrics = payload.get("metrics", {})
116
267
  epics = payload.get("epics", [])
117
268
  current_story_id = sprint.get("currentStory", "")
118
269
 
119
- # Clamp selection
120
- if epics and self._selected_epic >= len(epics):
121
- self._selected_epic = len(epics) - 1
122
-
123
- # Sprint metrics header
270
+ # Update header
124
271
  sprint_num = sprint.get("number", "")
125
272
  done = sprint.get("done", 0)
126
273
  remaining = sprint.get("remaining", 0)
127
274
  in_progress = sprint.get("inProgress", 0)
128
275
  velocity = metrics.get("velocity", 0)
129
-
130
- header = Text.from_markup(
276
+ header_text = Text.from_markup(
131
277
  f"Sprint {sprint_num} "
132
278
  f"[green]Done: {done}[/green] | "
133
279
  f"Remaining: {remaining} | "
134
280
  f"In Progress: {in_progress} | "
135
281
  f"Velocity: {velocity}"
136
282
  )
137
-
138
- hint = Text.from_markup("[dim]j/k:navigate e:expand/collapse[/dim]")
139
- parts: list[Any] = [header, hint, Text("")]
140
-
141
- for i, epic in enumerate(epics):
283
+ try:
284
+ self.query_one("#sprint-header", Static).update(header_text)
285
+ except Exception:
286
+ pass
287
+
288
+ # Save expand state from current tree nodes
289
+ saved_expanded: dict[str, bool] = {}
290
+ for node in tree.root.children:
291
+ data = node.data
292
+ if data and data.get("type") == "epic":
293
+ saved_expanded[data["id"]] = node.is_expanded
294
+
295
+ # Save cursor position by node identity
296
+ cursor_node_key: str | None = None
297
+ try:
298
+ highlighted = tree.get_node_at_line(tree.cursor_line)
299
+ if highlighted and highlighted.data:
300
+ data = highlighted.data
301
+ if data.get("type") == "epic":
302
+ cursor_node_key = f"epic:{data['id']}"
303
+ elif data.get("type") == "story":
304
+ cursor_node_key = f"story:{data['story'].get('id', '')}"
305
+ except Exception:
306
+ pass
307
+
308
+ # Rebuild tree
309
+ tree.clear()
310
+ cursor_target = None
311
+
312
+ for epic in epics:
142
313
  epic_id = epic.get("id", "")
143
314
  epic_title = epic.get("title", "")
144
315
  stories = epic.get("stories", [])
145
316
 
146
- # Calculate epic progress
317
+ # Calculate epic progress (canceled counts as done)
147
318
  total_pts = 0
148
319
  done_pts = 0
149
320
  for story in stories:
150
321
  pts = story.get("points", 0)
151
322
  if isinstance(pts, (int, float)):
152
323
  total_pts += pts
153
- status = (story.get("status") or "").lower().strip()
154
- if status == "done":
324
+ if _is_terminal(story.get("status", "")):
155
325
  done_pts += pts
156
326
 
157
- expanded = self._is_expanded(epic)
158
- selected = i == self._selected_epic
159
-
160
- # Epic header: selector arrow epic-id progress-bar pts title
161
- arrow = "▼" if expanded else "▶"
162
- epic_line = Text(no_wrap=True, overflow="ellipsis")
163
- if selected:
164
- epic_line.append(" ", style="bold yellow")
165
- epic_line.append(f"{arrow} ", style="bold")
166
- epic_line.append(f"{epic_id}", style="bold cyan")
167
- epic_line.append(" ")
168
-
169
- if total_pts > 0:
170
- pct = int(done_pts / total_pts * 100)
171
- epic_line.append_text(render_progress_bar(pct, width=10))
172
- epic_line.append(f" {done_pts}/{total_pts} pts", style="dim")
173
- else:
174
- epic_line.append("0 pts", style="dim")
175
-
176
- epic_line.append(f" {epic_title}", style="bold")
177
-
178
- parts.append(epic_line)
179
-
180
- # Show stories if expanded
181
- if expanded:
182
- for story in stories:
183
- story_id = story.get("id", "")
184
- title = story.get("title", "")
185
- pts = story.get("points", "")
186
- jira = story.get("jiraKey") or ""
187
- badge = _status_badge(story.get("status", ""))
188
-
189
- # Fixed-width fields first, title last (truncates)
190
- story_line = Text(no_wrap=True, overflow="ellipsis")
191
- story_line.append_text(badge)
192
- story_line.append(f" {story_id}", style="cyan" if story_id != current_story_id else "bold cyan")
193
- story_line.append(f" {jira}", style="dim")
194
- story_line.append(f" {pts}", style="dim")
195
- story_line.append(f" {title}")
196
-
197
- if story_id == current_story_id:
198
- story_line.stylize("bold")
199
-
200
- parts.append(Padding(story_line, (0, 0, 0, 4)))
201
-
202
- parts.append(Text("")) # spacer between epics
203
-
204
- return Group(*parts)
327
+ label = _build_epic_label(
328
+ epic_id, epic_title, done_pts, total_pts,
329
+ jira_key=epic.get("jiraKey", ""),
330
+ )
331
+ epic_data: dict[str, Any] = {
332
+ "type": "epic",
333
+ "id": epic_id,
334
+ "title": epic_title,
335
+ }
336
+ epic_node = tree.root.add(label, data=epic_data)
337
+
338
+ # Add story leaves
339
+ for story in stories:
340
+ story_label = _build_story_label(story, current_story_id)
341
+ story_data: dict[str, Any] = {"type": "story", "story": story}
342
+ story_node = epic_node.add_leaf(story_label, data=story_data)
343
+
344
+ if cursor_node_key == f"story:{story.get('id', '')}":
345
+ cursor_target = story_node
346
+
347
+ # Restore expand state or apply default
348
+ if epic_id in saved_expanded:
349
+ if saved_expanded[epic_id]:
350
+ epic_node.expand()
351
+ else:
352
+ epic_node.collapse()
353
+ elif _should_expand(epic):
354
+ epic_node.expand()
355
+
356
+ if cursor_node_key == f"epic:{epic_id}":
357
+ cursor_target = epic_node
358
+
359
+ # Restore cursor position
360
+ if cursor_target is not None:
361
+ try:
362
+ tree.move_cursor(cursor_target)
363
+ except Exception:
364
+ pass
365
+
366
+ # --- Tree event handling ---
367
+
368
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
369
+ """Handle enter on a tree node: drill into story or toggle epic."""
370
+ data = event.node.data
371
+ if not data:
372
+ return
373
+ if data.get("type") == "story":
374
+ story = data["story"]
375
+ from pennyfarthing_scripts.bikerack.story_detail_screen import (
376
+ StoryDetailScreen,
377
+ )
378
+
379
+ try:
380
+ self.app.push_screen(StoryDetailScreen(story_data=story))
381
+ except Exception:
382
+ pass
383
+ elif data.get("type") == "epic":
384
+ event.node.toggle()
385
+
386
+ # --- Compatibility methods for app-level bindings ---
387
+
388
+ def next_epic(self) -> None:
389
+ """Move cursor down — compat wrapper for app j binding."""
390
+ try:
391
+ tree = self.query_one("#sprint-tree", Tree)
392
+ tree.action_cursor_down()
393
+ except Exception:
394
+ pass
395
+
396
+ def prev_epic(self) -> None:
397
+ """Move cursor up — compat wrapper for app k binding."""
398
+ try:
399
+ tree = self.query_one("#sprint-tree", Tree)
400
+ tree.action_cursor_up()
401
+ except Exception:
402
+ pass
403
+
404
+ def toggle_epic(self) -> None:
405
+ """Toggle current node — compat wrapper for app e binding."""
406
+ try:
407
+ tree = self.query_one("#sprint-tree", Tree)
408
+ tree.action_toggle_node()
409
+ except Exception:
410
+ pass
411
+
412
+ def get_selected_story(self) -> dict[str, Any] | None:
413
+ """Return the currently highlighted story data, or None."""
414
+ try:
415
+ tree = self.query_one("#sprint-tree", Tree)
416
+ node = tree.get_node_at_line(tree.cursor_line)
417
+ if node and node.data and node.data.get("type") == "story":
418
+ return node.data["story"]
419
+ except Exception:
420
+ pass
421
+ return None
422
+
423
+ def drill_into_story(self) -> None:
424
+ """Push StoryDetailScreen for the highlighted story."""
425
+ story = self.get_selected_story()
426
+ if story is None:
427
+ return
428
+ from pennyfarthing_scripts.bikerack.story_detail_screen import (
429
+ StoryDetailScreen,
430
+ )
431
+
432
+ try:
433
+ self.app.push_screen(StoryDetailScreen(story_data=story))
434
+ except Exception:
435
+ pass