@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
@@ -10,6 +10,7 @@ Panel navigation: Mount all panels, tab bar, keyboard switching, command palette
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import os
13
14
  from functools import partial
14
15
  from pathlib import Path
15
16
  from typing import Any
@@ -17,16 +18,20 @@ from typing import Any
17
18
  from textual.app import App, ComposeResult
18
19
  from textual.binding import Binding
19
20
  from textual.command import Hit, Hits, Provider
20
- from textual.containers import VerticalScroll
21
+ from textual.containers import Horizontal, VerticalScroll
22
+ from textual.message import Message
21
23
  from textual.reactive import reactive
22
- from textual.widgets import Footer, Header, Static
24
+ from textual.widgets import Footer, Header, Static, Tab, Tabs
23
25
 
24
26
  from pennyfarthing_scripts.bc.focus import get_last_panel, save_last_panel
27
+ from pennyfarthing_scripts.bikerack.audit_log_panel import AuditLogPanel
25
28
  from pennyfarthing_scripts.bikerack.background_panel import BackgroundPanel
26
29
  from pennyfarthing_scripts.bikerack.base_panel import get_panel_icon
27
30
  from pennyfarthing_scripts.bikerack.changed_panel import ChangedPanel
31
+ from pennyfarthing_scripts.bikerack.context_meter_footer import ContextMeterFooter
28
32
  from pennyfarthing_scripts.bikerack.debug_panel import DebugPanel
29
33
  from pennyfarthing_scripts.bikerack.diffs_panel import DiffsPanel
34
+ from pennyfarthing_scripts.bikerack.events import NavigateToFile
30
35
  from pennyfarthing_scripts.bikerack.git_panel import GitPanel
31
36
  from pennyfarthing_scripts.bikerack.progress_panel import ProgressPanel
32
37
  from pennyfarthing_scripts.bikerack.sprint_panel import SprintPanel
@@ -76,6 +81,7 @@ PANEL_REGISTRY: list[tuple[str, str]] = [
76
81
  ("diffs", "Diffs"),
77
82
  ("changed", "Changed"),
78
83
  ("background", "Background"),
84
+ ("audit-log", "Audit Log"),
79
85
  ("debug", "Debug"),
80
86
  ("progress", "Progress"),
81
87
  ]
@@ -101,38 +107,69 @@ PANEL_DISPLAY_NAMES: dict[str, str] = {
101
107
  _PANEL_KEYS = [key for key, _ in PANEL_REGISTRY]
102
108
 
103
109
 
104
- class PanelTabBar(Static):
105
- """Horizontal tab bar showing all available panels with active highlight."""
110
+ class BindingFooter(Footer):
111
+ """Footer subclass that exposes active binding text via render().
106
112
 
107
- active: reactive[str] = reactive("sprint")
113
+ Textual's Footer uses compose() for visual content, so render() returns
114
+ Blank. This override makes binding descriptions available through
115
+ str(footer.render()) for programmatic inspection.
116
+ """
108
117
 
109
- def watch_active(self, key: str) -> None:
110
- """Re-render tab bar when active panel changes."""
111
- parts: list[str] = []
112
- for panel_key, display_name in PANEL_REGISTRY:
113
- icon = get_panel_icon(panel_key)
114
- idx = _PANEL_KEYS.index(panel_key) + 1
115
- prefix = f"{idx}:"
116
- if panel_key == key:
117
- if icon:
118
- parts.append(f"[bold reverse] {prefix}{icon} {display_name} [/]")
119
- else:
120
- parts.append(f"[bold reverse] {prefix}{display_name} [/]")
121
- else:
122
- if icon:
123
- parts.append(f"[dim]{prefix}{icon} {display_name}[/]")
124
- else:
125
- parts.append(f"[dim]{prefix}{display_name}[/]")
126
- self.update(" ".join(parts))
118
+ def render(self) -> Any:
119
+ try:
120
+ bindings = self.screen.active_bindings
121
+ parts: list[str] = []
122
+ for _, binding, _enabled, _tooltip in bindings.values():
123
+ if binding.show:
124
+ parts.append(f"{binding.key}:{binding.description}")
125
+ if parts:
126
+ return " ".join(parts)
127
+ except Exception:
128
+ pass
129
+ return super().render()
130
+
131
+
132
+ def _build_panel_tabs() -> list[Tab]:
133
+ """Build Tab widgets for each panel in the registry."""
134
+ tabs: list[Tab] = []
135
+ for panel_key, display_name in PANEL_REGISTRY:
136
+ icon = get_panel_icon(panel_key)
137
+ label = f"{icon} {display_name}" if icon else display_name
138
+ tabs.append(Tab(label, id=f"tab-{panel_key}"))
139
+ return tabs
140
+
141
+
142
+ PORTRAIT_SKELETON = """\
143
+ [dim]┌────────┐
144
+ │░░░░░░░░│
145
+ │░░░▓▓░░░│
146
+ │░░░░░░░░│
147
+ └────────┘[/dim]"""
127
148
 
128
149
 
129
150
  class AgentHeader(Static):
130
- """Displays current agent persona from WheelHub /ws/persona channel."""
151
+ """Displays current agent persona from WheelHub /ws/persona channel.
152
+
153
+ When a portrait image is available (resolved locally or provided via
154
+ portraitPath in persona data), mounts a Horizontal layout container.
155
+ Shows a skeleton placeholder while the image loads.
156
+ Falls back to text-only when no portrait is found.
157
+ """
158
+
159
+ class PortraitLayoutUpdate(Message):
160
+ """Internal message to update portrait layout asynchronously."""
161
+
162
+ def __init__(self, portrait_path: Path | None) -> None:
163
+ super().__init__()
164
+ self.has_portrait = portrait_path is not None
165
+ self.portrait_path = portrait_path
131
166
 
132
167
  def __init__(self, **kwargs: Any) -> None:
133
168
  super().__init__(**kwargs)
134
169
  self._is_streaming: bool = False
135
170
  self._persona_data: dict[str, Any] = {}
171
+ self._header_text: str = ""
172
+ self._current_portrait: Path | None = None
136
173
 
137
174
  def _apply_persona(self, data: dict[str, Any]) -> None:
138
175
  """Render persona data into the header."""
@@ -145,6 +182,21 @@ class AgentHeader(Static):
145
182
  self._is_streaming = bool(data.get("isStreaming", False))
146
183
  self._render_header()
147
184
 
185
+ def _resolve_portrait(self, data: dict[str, Any]) -> Path | None:
186
+ """Get portrait path from persona data or resolve locally."""
187
+ portrait_path = data.get("portraitPath")
188
+ if portrait_path:
189
+ p = Path(portrait_path)
190
+ if p.exists():
191
+ return p
192
+ theme = data.get("theme", "")
193
+ role = data.get("role", "")
194
+ if theme and role:
195
+ from pennyfarthing_scripts.bikerack import portrait_resolver
196
+
197
+ return portrait_resolver.resolve_portrait_path(theme, role)
198
+ return None
199
+
148
200
  def _render_header(self) -> None:
149
201
  """Re-render the header from stored state."""
150
202
  data = self._persona_data
@@ -152,20 +204,20 @@ class AgentHeader(Static):
152
204
  role = data.get("role", "")
153
205
  role_desc = data.get("roleDescription", "")
154
206
  quote = data.get("quote", "")
155
- style = data.get("style", "")
156
207
  theme = data.get("theme", "")
157
208
 
158
209
  if not char:
159
210
  self.update("[dim]Waiting for agent...[/dim]")
211
+ self.post_message(self.PortraitLayoutUpdate(portrait_path=None))
160
212
  return
161
213
 
162
214
  parts: list[str] = []
163
215
 
164
- # Role badge
216
+ # Role badge — escape brackets so Rich doesn't eat them as tags
165
217
  if role:
166
218
  abbrev = AGENT_ABBREV.get(role, role.upper()[:3])
167
219
  color = AGENT_ROLE_COLORS.get(role, "bright_magenta")
168
- parts.append(f"[bold {color}][{abbrev}][/bold {color}]")
220
+ parts.append(f"[bold {color}]\\[{abbrev}][/bold {color}]")
169
221
 
170
222
  # Character name
171
223
  parts.append(f"[bold]{char}[/bold]")
@@ -173,6 +225,7 @@ class AgentHeader(Static):
173
225
  # Theme name
174
226
  if theme:
175
227
  from pennyfarthing_scripts.bikerack.base_panel import humanize_theme
228
+
176
229
  parts.append(f"[dim]{humanize_theme(theme)}[/dim]")
177
230
 
178
231
  # Streaming indicator
@@ -181,17 +234,87 @@ class AgentHeader(Static):
181
234
 
182
235
  line = " ".join(parts)
183
236
 
184
- # Role description / style subtitle
185
- if role_desc:
186
- line += f"\n[dim]{role_desc}[/dim]"
187
- elif style:
188
- line += f"\n[dim]{style}[/dim]"
189
-
190
- # Quote
237
+ # Catchphrase subtitle (quote is a random catchphrase from the theme)
191
238
  if quote:
192
239
  line += f"\n[italic dim]\"{quote}\"[/italic dim]"
240
+ elif role_desc:
241
+ line += f"\n[dim]{role_desc}[/dim]"
242
+
243
+ self._header_text = line
244
+
245
+ # Check portrait and schedule layout update
246
+ portrait = self._resolve_portrait(data)
247
+ self.post_message(self.PortraitLayoutUpdate(portrait_path=portrait))
248
+
249
+ async def on_agent_header_portrait_layout_update(
250
+ self, event: PortraitLayoutUpdate
251
+ ) -> None:
252
+ """Mount or remove Horizontal portrait layout with text beside image.
193
253
 
194
- self.update(line)
254
+ Shows a skeleton placeholder immediately while the real image loads
255
+ to avoid a visible blank gap during image decode/render.
256
+ """
257
+ if event.has_portrait and event.portrait_path:
258
+ if self._current_portrait == event.portrait_path:
259
+ # Same portrait — just update text label if it exists
260
+ try:
261
+ text_widget = self.query_one("#agent-text", Static)
262
+ text_widget.update(self._header_text)
263
+ except Exception:
264
+ pass
265
+ return
266
+
267
+ # New portrait or first time — full layout rebuild
268
+ for child in list(self.query("Horizontal")):
269
+ await child.remove()
270
+
271
+ # Mount skeleton + text immediately so there's no blank gap
272
+ self.update("")
273
+ self._current_portrait = event.portrait_path
274
+ skeleton = Static(PORTRAIT_SKELETON, id="portrait-skeleton")
275
+ text = Static(self._header_text, id="agent-text")
276
+ row = Horizontal(skeleton, text, id="portrait-row")
277
+ await self.mount(row)
278
+
279
+ # Now try to load the real image and swap it in
280
+ try:
281
+ from pennyfarthing_scripts.bikerack.portrait_resolver import (
282
+ detect_image_protocol,
283
+ )
284
+
285
+ protocol = detect_image_protocol()
286
+ if protocol is None:
287
+ # No image protocol — remove skeleton, fall back to text-only
288
+ for child in list(self.query("Horizontal")):
289
+ await child.remove()
290
+ self.update(self._header_text)
291
+ return
292
+
293
+ if protocol == "kitty":
294
+ from textual_image.widget import TGPImage as ImageWidget
295
+ elif protocol == "sixel":
296
+ from textual_image.widget import SixelImage as ImageWidget
297
+ else:
298
+ from textual_image.widget import HalfcellImage as ImageWidget
299
+
300
+ img = ImageWidget(str(event.portrait_path), id="portrait-img")
301
+ try:
302
+ skel = self.query_one("#portrait-skeleton")
303
+ await skel.remove()
304
+ except Exception:
305
+ pass
306
+ await row.mount(img, before=0)
307
+ except (ImportError, Exception):
308
+ # textual-image not available — remove skeleton, text-only
309
+ for child in list(self.query("Horizontal")):
310
+ await child.remove()
311
+ self.update(self._header_text)
312
+ else:
313
+ # No portrait — text-only
314
+ for child in list(self.query("Horizontal")):
315
+ await child.remove()
316
+ self._current_portrait = None
317
+ self.update(self._header_text)
195
318
 
196
319
 
197
320
  class ConnectionStatus(Static):
@@ -227,20 +350,69 @@ class PanelCommands(Provider):
227
350
  class BikeRackApp(App):
228
351
  """BikeRack TUI application shell."""
229
352
 
353
+ class PersonaUpdate(Message, bubble=False):
354
+ """Persona data from WS — routed through Textual message system."""
355
+
356
+ def __init__(self, data: dict[str, Any]) -> None:
357
+ super().__init__()
358
+ self.data = data
359
+
360
+ class FocusUpdate(Message, bubble=False):
361
+ """Focus change from WS — routed through Textual message system."""
362
+
363
+ def __init__(self, focus: str | None) -> None:
364
+ super().__init__()
365
+ self.focus = focus
366
+
367
+ class WsStateUpdate(Message, bubble=False):
368
+ """WS connection state change — routed through Textual message system."""
369
+
370
+ def __init__(self, state: ConnectionState) -> None:
371
+ super().__init__()
372
+ self.state = state
373
+
230
374
  TITLE = "BikeRack"
231
375
 
232
376
  CSS = """
233
377
  #agent-header {
234
378
  height: auto;
235
- max-height: 3;
379
+ max-height: 7;
236
380
  padding: 0 1;
381
+ border-bottom: solid $accent;
382
+ }
383
+ #portrait-row {
384
+ height: 5;
385
+ width: 100%;
386
+ }
387
+ #portrait-img {
388
+ width: 10;
389
+ height: 5;
390
+ margin: 0 1 0 0;
237
391
  }
238
- #tab-bar {
392
+ #agent-text {
393
+ height: auto;
394
+ width: 1fr;
395
+ }
396
+ #project-dir {
239
397
  height: 1;
398
+ padding: 0 1;
399
+ color: $text-muted;
400
+ }
401
+ Tabs {
402
+ dock: top;
403
+ }
404
+ Tab.-active {
405
+ color: $text;
406
+ }
407
+ Tab {
408
+ color: $text-muted;
240
409
  }
241
410
  #connection-status {
242
411
  height: 1;
243
412
  }
413
+ ContextMeterFooter {
414
+ height: 1;
415
+ }
244
416
  """
245
417
 
246
418
  COMMANDS = App.COMMANDS | {PanelCommands}
@@ -252,8 +424,9 @@ class BikeRackApp(App):
252
424
  Binding("3", "switch_panel('diffs')", "Diffs", show=False),
253
425
  Binding("4", "switch_panel('changed')", "Changed", show=False),
254
426
  Binding("5", "switch_panel('background')", "Background", show=False),
255
- Binding("6", "switch_panel('debug')", "Debug", show=False),
256
- Binding("7", "switch_panel('progress')", "Progress", show=False),
427
+ Binding("6", "switch_panel('audit-log')", "Audit Log", show=False),
428
+ Binding("7", "switch_panel('debug')", "Debug", show=False),
429
+ Binding("8", "switch_panel('progress')", "Progress", show=False),
257
430
  Binding("bracketright", "next_panel", "]Next"),
258
431
  Binding("bracketleft", "prev_panel", "[Prev"),
259
432
  Binding("tab", "next_panel", show=False),
@@ -265,16 +438,25 @@ class BikeRackApp(App):
265
438
  Binding("e", "toggle_epic", show=False),
266
439
  ]
267
440
 
441
+ def _get_dom_base(self):
442
+ """Query the active screen so app.query() finds pushed screen widgets."""
443
+ return self.screen
444
+
268
445
  def __init__(self, client=None, **kwargs):
269
446
  super().__init__(**kwargs)
270
447
  self._client = client
271
448
  self._focused_panel: str = "sprint"
272
449
  self._previous_panel: str | None = None
450
+ self._programmatic_tab_count: int = 0
273
451
 
274
452
  def compose(self) -> ComposeResult:
453
+ project_dir_name = Path(
454
+ os.environ.get("CYCLIST_PROJECT_DIR", os.getcwd())
455
+ ).name
275
456
  yield Header()
276
457
  yield AgentHeader(id="agent-header")
277
- yield PanelTabBar(id="tab-bar")
458
+ yield Static(f"[dim]{project_dir_name}[/dim]", id="project-dir")
459
+ yield Tabs(*_build_panel_tabs(), id="tab-bar")
278
460
  yield ConnectionStatus(
279
461
  STATE_DISPLAY[ConnectionState.DISCONNECTED],
280
462
  id="connection-status",
@@ -285,9 +467,11 @@ class BikeRackApp(App):
285
467
  yield DiffsPanel(client=self._client, id="panel-diffs")
286
468
  yield ChangedPanel(client=self._client, id="panel-changed")
287
469
  yield BackgroundPanel(client=self._client, id="panel-background")
470
+ yield AuditLogPanel(client=self._client, id="panel-audit-log")
288
471
  yield DebugPanel(client=self._client, id="panel-debug")
289
472
  yield ProgressPanel(client=self._client, id="panel-progress")
290
- yield Footer()
473
+ yield ContextMeterFooter(client=self._client)
474
+ yield BindingFooter()
291
475
 
292
476
  async def on_mount(self) -> None:
293
477
  # Restore last panel or default to sprint
@@ -309,8 +493,13 @@ class BikeRackApp(App):
309
493
  except Exception:
310
494
  pass
311
495
 
312
- # Set tab bar active state
496
+ # Set tab bar active state and focus initial panel
313
497
  self._update_tab_bar(initial)
498
+ try:
499
+ initial_widget = self.query_one(f"#panel-{initial}")
500
+ initial_widget.focus()
501
+ except Exception:
502
+ pass
314
503
 
315
504
  if self._client is not None:
316
505
  self._client.on_state_change(self._on_ws_state_change)
@@ -318,6 +507,15 @@ class BikeRackApp(App):
318
507
  self._client.subscribe("persona", self._handle_persona_message)
319
508
  self.run_worker(self._client.connect(), exclusive=True, name="ws-client")
320
509
 
510
+ def on_navigate_to_file(self, event: NavigateToFile) -> None:
511
+ """Handle NavigateToFile — switch to diffs and navigate to file."""
512
+ self.action_switch_panel("diffs")
513
+ try:
514
+ diffs = self.query_one("#panel-diffs", DiffsPanel)
515
+ diffs.navigate_to_file(event.path)
516
+ except Exception:
517
+ pass
518
+
321
519
  def action_switch_panel(self, key: str) -> None:
322
520
  """Switch to a panel by key."""
323
521
  if key not in _PANEL_KEYS:
@@ -332,10 +530,11 @@ class BikeRackApp(App):
332
530
  except Exception:
333
531
  pass
334
532
 
335
- # Show target panel
533
+ # Show target panel and focus it
336
534
  try:
337
535
  target = self.query_one(f"#panel-{key}")
338
536
  target.display = True
537
+ target.focus()
339
538
  except Exception:
340
539
  pass
341
540
 
@@ -408,18 +607,40 @@ class BikeRackApp(App):
408
607
  pass
409
608
 
410
609
  def _update_tab_bar(self, panel_key: str) -> None:
411
- """Update the tab bar widget with the given panel key."""
610
+ """Update the tab bar widget with the given panel key.
611
+
612
+ Increments _programmatic_tab_count so the async TabActivated
613
+ handler knows to ignore the event (prevents infinite ping-pong).
614
+ """
412
615
  try:
413
- tab_bar = self.query_one("#tab-bar", PanelTabBar)
414
- tab_bar.active = panel_key
616
+ tab_bar = self.query_one("#tab-bar", Tabs)
617
+ tab_id = f"tab-{panel_key}"
618
+ if tab_bar.active != tab_id:
619
+ self._programmatic_tab_count += 1
620
+ tab_bar.active = tab_id
415
621
  except Exception:
416
622
  pass
417
623
 
624
+ def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
625
+ """Handle tab activation from the Tabs widget.
626
+
627
+ Programmatic tabs.active changes fire TabActivated asynchronously.
628
+ We use a counter to skip those and only react to genuine user clicks.
629
+ """
630
+ if self._programmatic_tab_count > 0:
631
+ self._programmatic_tab_count -= 1
632
+ return
633
+ tab_id = event.tab.id or ""
634
+ panel_key = tab_id.removeprefix("tab-")
635
+ if panel_key in _PANEL_KEYS and panel_key != self._focused_panel:
636
+ self.action_switch_panel(panel_key)
637
+
418
638
  def _handle_focus_message(self, message: dict[str, Any] | None) -> None:
419
639
  """Handle incoming focus channel messages.
420
640
 
421
641
  Expected format: {type: 'init'|'update', focus: '<panel>'|null}
422
642
  Only 'update' messages trigger panel switches (matching React hook).
643
+ Routes through Textual message system via post_message for proper repaint.
423
644
  """
424
645
  if message is None or not isinstance(message, dict):
425
646
  return
@@ -427,31 +648,47 @@ class BikeRackApp(App):
427
648
  return
428
649
  if "focus" not in message:
429
650
  return
430
-
431
- focus = message["focus"]
432
- if focus is not None and focus in _PANEL_KEYS:
433
- self.action_switch_panel(focus)
434
- elif focus is not None:
435
- # Panel exists in display names but not implemented — just update state
436
- self._previous_panel = self._focused_panel
437
- self._focused_panel = focus
438
- save_last_panel(focus, project_dir=None)
651
+ self.post_message(self.FocusUpdate(message["focus"]))
439
652
 
440
653
  def _handle_persona_message(self, message: dict[str, Any] | None) -> None:
441
- """Handle incoming persona channel messages."""
654
+ """Handle incoming persona channel messages.
655
+
656
+ Routes through Textual message system via post_message for proper repaint.
657
+ """
442
658
  if message is None or not isinstance(message, dict):
443
659
  return
660
+ self.post_message(self.PersonaUpdate(message))
661
+
662
+ def _on_ws_state_change(self, state: ConnectionState) -> None:
663
+ """Handle WheelHub connection state changes.
664
+
665
+ Routes through Textual message system via post_message for proper repaint.
666
+ """
667
+ self.post_message(self.WsStateUpdate(state))
668
+
669
+ def on_bike_rack_app_persona_update(self, event: PersonaUpdate) -> None:
670
+ """Apply persona data in Textual message context."""
444
671
  try:
445
672
  header = self.query_one("#agent-header", AgentHeader)
446
- header._apply_persona(message)
673
+ header._apply_persona(event.data)
447
674
  except Exception:
448
675
  pass
449
676
 
450
- def _on_ws_state_change(self, state: ConnectionState) -> None:
451
- """Handle WheelHub connection state changes."""
677
+ def on_bike_rack_app_focus_update(self, event: FocusUpdate) -> None:
678
+ """Apply focus change in Textual message context."""
679
+ focus = event.focus
680
+ if focus is not None and focus in _PANEL_KEYS:
681
+ self.action_switch_panel(focus)
682
+ elif focus is not None:
683
+ self._previous_panel = self._focused_panel
684
+ self._focused_panel = focus
685
+ save_last_panel(focus, project_dir=None)
686
+
687
+ def on_bike_rack_app_ws_state_update(self, event: WsStateUpdate) -> None:
688
+ """Apply connection state in Textual message context."""
452
689
  try:
453
690
  widget = self.query_one("#connection-status", ConnectionStatus)
454
- widget.connection_state = state
691
+ widget.connection_state = event.state
455
692
  except Exception:
456
693
  pass
457
694
 
@@ -466,12 +703,17 @@ def main(
466
703
  """Launch BikeRack TUI as a standalone application.
467
704
 
468
705
  Args:
469
- port: Explicit WheelHub port. If None, reads from .wheelhub-port file.
706
+ port: Explicit WheelHub port. If None, reads from .bikerack-port file.
470
707
  project_dir: Project directory for port file discovery. Defaults to cwd.
471
708
  """
709
+ # Detect terminal image protocol BEFORE App.run() claims the terminal
710
+ from pennyfarthing_scripts.bikerack import portrait_resolver
711
+
712
+ portrait_resolver.detect_image_protocol()
713
+
472
714
  if port is None:
473
715
  if project_dir is not None:
474
- port_file = project_dir / ".wheelhub-port"
716
+ port_file = project_dir / ".bikerack-port"
475
717
  if port_file.exists():
476
718
  try:
477
719
  port = int(port_file.read_text().strip())
@@ -75,14 +75,14 @@ class WheelHubClient:
75
75
  cb(new_state)
76
76
 
77
77
  def discover_port(self) -> int:
78
- """Read port from .wheelhub-port file, fallback to DEFAULT_PORT.
78
+ """Read port from .bikerack-port file, fallback to DEFAULT_PORT.
79
79
 
80
80
  Priority: explicit port > port file > DEFAULT_PORT.
81
81
  """
82
82
  if self._port is not None:
83
83
  return self._port
84
84
  if self._project_dir is not None:
85
- port_file = self._project_dir / ".wheelhub-port"
85
+ port_file = self._project_dir / ".bikerack-port"
86
86
  if port_file.exists():
87
87
  try:
88
88
  return int(port_file.read_text().strip())
@@ -142,6 +142,11 @@ from pennyfarthing_scripts.consultation.cli import consultation # noqa: E402
142
142
 
143
143
  cli.add_command(consultation)
144
144
 
145
+ # Import and register hooks group
146
+ from pennyfarthing_scripts.hooks.cli import hooks # noqa: E402
147
+
148
+ cli.add_command(hooks)
149
+
145
150
 
146
151
  @cli.group()
147
152
  def agent():
@@ -82,11 +82,38 @@ def load_yaml_config(path: Path) -> dict[str, Any] | None:
82
82
  return yaml.safe_load(f)
83
83
 
84
84
 
85
- def load_pennyfarthing_config() -> dict[str, Any]:
85
+ def load_pennyfarthing_config(project_root: Path | None = None) -> dict[str, Any]:
86
86
  """Load .pennyfarthing/config.local.yaml.
87
87
 
88
+ Args:
89
+ project_root: Project root path (defaults to auto-detect)
90
+
88
91
  Returns:
89
92
  Config dict, or empty dict if not found
90
93
  """
91
- config_path = get_project_root() / ".pennyfarthing" / "config.local.yaml"
94
+ root = project_root or get_project_root()
95
+ config_path = root / ".pennyfarthing" / "config.local.yaml"
92
96
  return load_yaml_config(config_path) or {}
97
+
98
+
99
+ def save_pennyfarthing_config_key(
100
+ key: str, value: Any, project_root: Path | None = None
101
+ ) -> None:
102
+ """Set a top-level key in .pennyfarthing/config.local.yaml.
103
+
104
+ Creates the file if it doesn't exist. Preserves existing keys.
105
+
106
+ Args:
107
+ key: Top-level key (e.g., "sprint")
108
+ value: Value to set (dict, str, etc.)
109
+ project_root: Project root path (defaults to auto-detect)
110
+ """
111
+ root = project_root or get_project_root()
112
+ config_path = root / ".pennyfarthing" / "config.local.yaml"
113
+
114
+ config = load_yaml_config(config_path) or {}
115
+ config[key] = value
116
+
117
+ config_path.parent.mkdir(parents=True, exist_ok=True)
118
+ with open(config_path, "w") as f:
119
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
@@ -0,0 +1,38 @@
1
+ """PR mode configuration reader.
2
+
3
+ Reads the pr_mode preference from .pennyfarthing/config.local.yaml.
4
+
5
+ Values: draft | ready | none (default: draft)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pennyfarthing_scripts.common.config import load_pennyfarthing_config
11
+
12
+ VALID_PR_MODES = {"draft", "ready", "none"}
13
+ DEFAULT_PR_MODE = "draft"
14
+
15
+
16
+ def get_pr_mode() -> str:
17
+ """Read pr_mode from pennyfarthing config.
18
+
19
+ Looks for workflow.pr_mode in .pennyfarthing/config.local.yaml.
20
+ Falls back to 'draft' if not set or invalid.
21
+
22
+ Returns:
23
+ One of: 'draft', 'ready', 'none'
24
+ """
25
+ config = load_pennyfarthing_config()
26
+ workflow = config.get("workflow", {})
27
+ if not isinstance(workflow, dict):
28
+ return DEFAULT_PR_MODE
29
+
30
+ mode = workflow.get("pr_mode", DEFAULT_PR_MODE)
31
+ if mode not in VALID_PR_MODES:
32
+ return DEFAULT_PR_MODE
33
+
34
+ return mode
35
+
36
+
37
+ if __name__ == "__main__":
38
+ print(get_pr_mode())