@pennyfarthing/core 11.1.0 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/README.md +8 -8
  2. package/package.json +16 -14
  3. package/packages/core/dist/cli/utils/constants.d.ts +1 -1
  4. package/packages/core/dist/cli/utils/constants.d.ts.map +1 -1
  5. package/packages/core/dist/cli/utils/constants.js +2 -1
  6. package/packages/core/dist/cli/utils/constants.js.map +1 -1
  7. package/packages/core/dist/consultation/dialogue-manager.d.ts +75 -0
  8. package/packages/core/dist/consultation/dialogue-manager.d.ts.map +1 -0
  9. package/packages/core/dist/consultation/dialogue-manager.js +334 -0
  10. package/packages/core/dist/consultation/dialogue-manager.js.map +1 -0
  11. package/packages/core/dist/consultation/dialogue-manager.test.d.ts +19 -0
  12. package/packages/core/dist/consultation/dialogue-manager.test.d.ts.map +1 -0
  13. package/packages/core/dist/consultation/dialogue-manager.test.js +444 -0
  14. package/packages/core/dist/consultation/dialogue-manager.test.js.map +1 -0
  15. package/packages/core/dist/server/api/git.d.ts +13 -1
  16. package/packages/core/dist/server/api/git.d.ts.map +1 -1
  17. package/packages/core/dist/server/api/git.js +53 -34
  18. package/packages/core/dist/server/api/git.js.map +1 -1
  19. package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
  20. package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
  21. package/packages/core/dist/server/otlp-receiver.js +185 -24
  22. package/packages/core/dist/server/otlp-receiver.js.map +1 -1
  23. package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
  24. package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
  25. package/packages/core/dist/server/otlp-receiver.test.js +446 -0
  26. package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
  27. package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
  28. package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
  29. package/packages/core/dist/shared/portrait-resolver.js +27 -0
  30. package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
  31. package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
  32. package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
  33. package/packages/core/dist/shared/skill-search.test.js +2 -2
  34. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
  35. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
  36. package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
  37. package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
  38. package/pennyfarthing-dist/agents/dev.md +1 -1
  39. package/pennyfarthing-dist/agents/reviewer.md +1 -1
  40. package/pennyfarthing-dist/agents/sm-setup.md +1 -1
  41. package/pennyfarthing-dist/agents/sm.md +2 -2
  42. package/pennyfarthing-dist/agents/tea.md +1 -1
  43. package/pennyfarthing-dist/agents/testing-runner.md +2 -1
  44. package/pennyfarthing-dist/commands/pf-chore.md +2 -2
  45. package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
  46. package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
  47. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
  48. package/pennyfarthing-dist/guides/bikerack.md +3 -3
  49. package/pennyfarthing-dist/guides/hooks.md +1 -1
  50. package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
  51. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  52. package/pennyfarthing-dist/scripts/README.md +1 -1
  53. package/pennyfarthing-dist/scripts/core/agent-session.sh +0 -0
  54. package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
  55. package/pennyfarthing-dist/scripts/core/dialogue-manager.sh +322 -0
  56. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +0 -0
  57. package/pennyfarthing-dist/scripts/core/prime.sh +0 -0
  58. package/pennyfarthing-dist/scripts/cyclist/is-cyclist.sh +0 -0
  59. package/pennyfarthing-dist/scripts/git/README.md +24 -14
  60. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
  61. package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
  62. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
  63. package/pennyfarthing-dist/scripts/git/release.sh +0 -0
  64. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
  65. package/pennyfarthing-dist/scripts/health/drift-detection.sh +0 -0
  66. package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
  67. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
  68. package/pennyfarthing-dist/scripts/hooks/context-circuit-breaker.sh +0 -0
  69. package/pennyfarthing-dist/scripts/hooks/context-warning.sh +0 -0
  70. package/pennyfarthing-dist/scripts/hooks/cyclist-pretooluse-hook.sh +0 -0
  71. package/pennyfarthing-dist/scripts/hooks/dispatcher-template.sh +0 -0
  72. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
  73. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +0 -0
  74. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +0 -0
  75. package/pennyfarthing-dist/scripts/hooks/pre-edit-check.sh +0 -0
  76. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +0 -0
  77. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +0 -0
  78. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +0 -0
  79. package/pennyfarthing-dist/scripts/hooks/schema-validation.sh +0 -0
  80. package/pennyfarthing-dist/scripts/hooks/session-start.sh +0 -0
  81. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +0 -0
  82. package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +0 -0
  83. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
  84. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +0 -0
  85. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +0 -0
  86. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +0 -0
  87. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +0 -0
  88. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +0 -0
  89. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +0 -0
  90. package/pennyfarthing-dist/scripts/lib/background-tasks.sh +0 -0
  91. package/pennyfarthing-dist/scripts/lib/checkpoint.sh +0 -0
  92. package/pennyfarthing-dist/scripts/lib/common.sh +0 -0
  93. package/pennyfarthing-dist/scripts/lib/file-lock.sh +0 -0
  94. package/pennyfarthing-dist/scripts/lib/logging.sh +0 -0
  95. package/pennyfarthing-dist/scripts/lib/retry.sh +0 -0
  96. package/pennyfarthing-dist/scripts/maintenance/migrate-theme-schema.mjs +0 -0
  97. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +0 -0
  98. package/pennyfarthing-dist/scripts/misc/add-short-names.sh +0 -0
  99. package/pennyfarthing-dist/scripts/misc/add_short_names.py +0 -0
  100. package/pennyfarthing-dist/scripts/misc/backlog.sh +0 -0
  101. package/pennyfarthing-dist/scripts/misc/check-status.sh +0 -0
  102. package/pennyfarthing-dist/scripts/misc/find-related-work.sh +0 -0
  103. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +0 -0
  104. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +0 -0
  105. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +0 -0
  106. package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +0 -0
  107. package/pennyfarthing-dist/scripts/misc/repo-scan.sh +0 -0
  108. package/pennyfarthing-dist/scripts/misc/repo-utils.sh +0 -0
  109. package/pennyfarthing-dist/scripts/misc/run-ci.sh +0 -0
  110. package/pennyfarthing-dist/scripts/misc/run-timestamp.sh +0 -0
  111. package/pennyfarthing-dist/scripts/misc/session-cleanup.sh +0 -0
  112. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +0 -0
  113. package/pennyfarthing-dist/scripts/misc/statusline.sh +0 -0
  114. package/pennyfarthing-dist/scripts/misc/uninstall.sh +0 -0
  115. package/pennyfarthing-dist/scripts/misc/validate-subagent-frontmatter.sh +0 -0
  116. package/pennyfarthing-dist/scripts/portraits/generate-portraits.sh +0 -0
  117. package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
  118. package/pennyfarthing-dist/scripts/story/create-story.sh +0 -0
  119. package/pennyfarthing-dist/scripts/story/size-story.sh +0 -0
  120. package/pennyfarthing-dist/scripts/story/story-template.sh +0 -0
  121. package/pennyfarthing-dist/scripts/tests/check.test.sh +0 -0
  122. package/pennyfarthing-dist/scripts/tests/dev-story-workflow-import.test.sh +0 -0
  123. package/pennyfarthing-dist/scripts/tests/epics-and-stories-workflow-import.test.sh +0 -0
  124. package/pennyfarthing-dist/scripts/tests/handoff-phase-update.test.sh +0 -0
  125. package/pennyfarthing-dist/scripts/tests/implementation-readiness-workflow-import.test.sh +0 -0
  126. package/pennyfarthing-dist/scripts/tests/migrate-bmad-workflow.test.sh +0 -0
  127. package/pennyfarthing-dist/scripts/tests/prd-workflow-import.test.sh +0 -0
  128. package/pennyfarthing-dist/scripts/tests/project-context-workflow-import.test.sh +0 -0
  129. package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +0 -0
  130. package/pennyfarthing-dist/scripts/tests/test-drift-detection.sh +0 -0
  131. package/pennyfarthing-dist/scripts/tests/test-post-merge-hook.sh +0 -0
  132. package/pennyfarthing-dist/scripts/tests/test-session-checkpoint.sh +0 -0
  133. package/pennyfarthing-dist/scripts/tests/test-solo-command.sh +0 -0
  134. package/pennyfarthing-dist/scripts/tests/ux-design-workflow-import.test.sh +0 -0
  135. package/pennyfarthing-dist/scripts/theme/list-themes.sh +0 -0
  136. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +0 -0
  137. package/pennyfarthing-dist/scripts/workflow/check.py +0 -0
  138. package/pennyfarthing-dist/scripts/workflow/check.sh +0 -0
  139. package/pennyfarthing-dist/scripts/workflow/complete-step.py +0 -0
  140. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +0 -0
  141. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
  142. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +0 -0
  143. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
  144. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
  145. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
  146. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
  147. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
  148. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
  149. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
  150. package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
  151. package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
  152. package/pennyfarthing-dist/skills/pf-story/scripts/create-story.sh +0 -0
  153. package/pennyfarthing-dist/skills/pf-story/scripts/size-story.sh +0 -0
  154. package/pennyfarthing-dist/skills/pf-story/scripts/story-template.sh +0 -0
  155. package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
  156. package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
  157. package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
  158. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
  159. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
  160. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
  161. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
  162. package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
  163. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
  164. package/pennyfarthing_scripts/CLAUDE.md +26 -4
  165. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  166. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  167. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  168. package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
  169. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  170. package/pennyfarthing_scripts/bc/cli.py +3 -5
  171. package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
  172. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  173. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  174. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  175. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  176. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  177. package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
  178. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  179. package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
  180. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  181. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  182. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  183. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  184. package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
  185. package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
  186. package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
  187. package/pennyfarthing_scripts/bikerack/cli.py +10 -11
  188. package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
  189. package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
  190. package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
  191. package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
  192. package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
  193. package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
  194. package/pennyfarthing_scripts/bikerack/tui.py +336 -30
  195. package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
  196. package/pennyfarthing_scripts/cli.py +37 -65
  197. package/pennyfarthing_scripts/consultation/__init__.py +1 -0
  198. package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
  199. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  200. package/pennyfarthing_scripts/consultation/cli.py +149 -0
  201. package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
  202. package/pennyfarthing_scripts/context.py +3 -3
  203. package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
  204. package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
  205. package/pennyfarthing_scripts/git/__init__.py +12 -1
  206. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  207. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  208. package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
  209. package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
  210. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  211. package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
  212. package/pennyfarthing_scripts/git/create_branches.py +3 -4
  213. package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
  214. package/pennyfarthing_scripts/git/repos.py +196 -0
  215. package/pennyfarthing_scripts/git/status_all.py +27 -11
  216. package/pennyfarthing_scripts/git/worktree.py +302 -0
  217. package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
  218. package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
  219. package/pennyfarthing_scripts/git_group/cli.py +143 -40
  220. package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
  221. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  222. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  223. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  224. package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
  225. package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
  226. package/pennyfarthing_scripts/hooks/cyclist-pretooluse-hook.sh +0 -0
  227. package/pennyfarthing_scripts/hooks.py +3 -17
  228. package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
  229. package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
  230. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  231. package/pennyfarthing_scripts/prime/heatmap.py +655 -0
  232. package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
  233. package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
  234. package/pennyfarthing_scripts/session_start_hook.py +1 -1
  235. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  236. package/pennyfarthing_scripts/sprint/loader.py +15 -1
  237. package/pennyfarthing_scripts/sprint/story_finish.py +14 -0
  238. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  239. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
  240. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  241. package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
  242. package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
  243. package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
  244. package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
  245. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  246. package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
  247. package/pennyfarthing_scripts/validate/cli.py +17 -5
  248. package/pennyfarthing_scripts/workflow/__init__.py +40 -0
  249. package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  250. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  251. package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
  252. package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
  253. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
  254. package/pennyfarthing_scripts/workflow/cli.py +1099 -0
  255. package/pennyfarthing_scripts/workflow/helpers.py +241 -0
  256. package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
  257. package/pennyfarthing_scripts/workflow/state.py +112 -0
  258. package/scripts/README.md +41 -0
  259. package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
  260. package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
  261. package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
  262. package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
  263. package/pennyfarthing-dist/skills/pf-workflow/scripts/workflow-status.sh +0 -167
@@ -8,11 +8,61 @@ from __future__ import annotations
8
8
 
9
9
  from typing import Any
10
10
 
11
- from rich.table import Table
11
+ from rich.text import Text
12
12
 
13
13
  from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
14
14
 
15
15
 
16
+ def _file_breakdown(dirty_files: list[dict]) -> Text:
17
+ """Break down dirty files into +staged ~modified ?untracked counts."""
18
+ staged = 0
19
+ modified = 0
20
+ untracked = 0
21
+ for f in dirty_files:
22
+ if not isinstance(f, dict):
23
+ continue
24
+ status = f.get("status", " ")
25
+ idx = status[0] if len(status) >= 1 else " "
26
+ wt = status[1] if len(status) >= 2 else " "
27
+ if idx == "?" and wt == "?":
28
+ untracked += 1
29
+ elif idx not in (" ", "?"):
30
+ staged += 1
31
+ elif wt not in (" ", "?"):
32
+ modified += 1
33
+
34
+ parts = Text()
35
+ parts.append(f"+{staged}", style="green")
36
+ parts.append(" ")
37
+ parts.append(f"~{modified}", style="yellow")
38
+ parts.append(" ")
39
+ parts.append(f"?{untracked}", style="dim")
40
+ return parts
41
+
42
+
43
+ _FILE_STATUS_MAP: dict[str, tuple[str, str, str]] = {
44
+ "M": ("~", "Modified", "yellow"),
45
+ "A": ("+", "Added", "green"),
46
+ "D": ("-", "Deleted", "red"),
47
+ "?": ("?", "Untracked", "dim"),
48
+ "R": ("→", "Renamed", "cyan"),
49
+ }
50
+
51
+
52
+ def _parse_file_status(status: str) -> tuple[str, str, str]:
53
+ """Parse git status code into (icon, label, style)."""
54
+ if len(status) < 2:
55
+ return _FILE_STATUS_MAP.get(status[:1], ("·", "Changed", "yellow"))
56
+ idx, wt = status[0], status[1]
57
+ if idx == "?" and wt == "?":
58
+ return _FILE_STATUS_MAP["?"]
59
+ if idx not in (" ", "?"):
60
+ return _FILE_STATUS_MAP.get(idx, ("·", "Changed", "yellow"))
61
+ if wt not in (" ", "?"):
62
+ return _FILE_STATUS_MAP.get(wt, ("·", "Changed", "yellow"))
63
+ return ("·", "Changed", "yellow")
64
+
65
+
16
66
  class GitPanel(BasePanel):
17
67
  """Multi-repo git status panel.
18
68
 
@@ -26,44 +76,64 @@ class GitPanel(BasePanel):
26
76
  icon: str = PANEL_ICONS["git"][0]
27
77
 
28
78
  def render_panel(self, payload: dict[str, Any]) -> Any:
29
- """Render git status as Rich table with Nerd Font glyphs."""
30
- table = Table()
31
- table.add_column("Repository", style="cyan")
32
- table.add_column("Branch")
33
- table.add_column("Commits")
34
- table.add_column("Changes", justify="right")
35
- table.add_column("Status")
36
-
37
- for repo in payload.get("repos", []):
79
+ """Render git status as Rich Tree with expandable file lists."""
80
+ from rich.console import Group as RichGroup
81
+
82
+ repos = payload.get("repos", [])
83
+ if not repos:
84
+ return Text("No repository data", style="dim italic")
85
+
86
+ parts: list[Any] = []
87
+ for repo in repos:
38
88
  branch = repo.get("branch", "")
39
89
  ahead = repo.get("ahead", 0)
40
90
  behind = repo.get("behind", 0)
41
91
  clean = repo.get("clean", True)
42
92
  dirty_files = repo.get("dirtyFiles", [])
93
+ name = repo.get("name", "")
43
94
 
44
- # Branch with Nerd Font glyph
45
- branch_col = f"\ue0a0 {branch}"
95
+ # Build repo header line
96
+ header = Text()
97
+ arrow = "▼" if not clean and dirty_files else "▶"
98
+ header.append(f"{arrow} ", style="bold")
99
+ header.append(name, style="bold cyan")
100
+ header.append(f" \ue0a0 {branch}", style="dim")
46
101
 
47
- # Commits: ahead/behind with arrow glyphs
48
- parts = []
102
+ # Commits
103
+ commit_parts = []
49
104
  if ahead:
50
- parts.append(f"\u2b06{ahead}")
105
+ commit_parts.append(f"{ahead}")
51
106
  if behind:
52
- parts.append(f"\u2b07{behind}")
53
- commits_col = " ".join(parts) if parts else "—"
54
-
55
- # Changes: count of dirty files
56
- changes_col = str(len(dirty_files))
57
-
58
- # Status: checkmark or cross
59
- status_col = "\u2713" if clean else "\u2717"
60
-
61
- table.add_row(
62
- repo.get("name", ""),
63
- branch_col,
64
- commits_col,
65
- changes_col,
66
- status_col,
67
- )
68
-
69
- return table
107
+ commit_parts.append(f"{behind}")
108
+ header.append(f" {' '.join(commit_parts) if commit_parts else ''}", style="dim")
109
+
110
+ # File breakdown
111
+ header.append(" ")
112
+ header.append_text(_file_breakdown(dirty_files))
113
+
114
+ # Status
115
+ header.append(" ")
116
+ if clean:
117
+ header.append("✓ clean", style="green")
118
+ else:
119
+ header.append("✗ dirty", style="red")
120
+
121
+ parts.append(header)
122
+
123
+ # Expanded file list for dirty repos
124
+ if not clean and dirty_files:
125
+ for f in dirty_files:
126
+ if not isinstance(f, dict):
127
+ continue
128
+ status_code = f.get("status", " ")
129
+ path = f.get("path", "")
130
+ icon, label, style = _parse_file_status(status_code)
131
+ file_line = Text()
132
+ file_line.append(" ")
133
+ file_line.append(icon, style=f"bold {style}")
134
+ file_line.append(f" {path}", style=style)
135
+ parts.append(file_line)
136
+
137
+ parts.append(Text("")) # spacer
138
+
139
+ return RichGroup(*parts)
@@ -24,8 +24,8 @@ def is_process_alive(pid: int) -> bool:
24
24
 
25
25
 
26
26
  def cleanup_files(project_dir: Path) -> None:
27
- """Clean up .bikerack-port, .bikerack-pid, and .bikerack-tui-pid files."""
28
- for name in (".bikerack-port", ".bikerack-pid", ".bikerack-tui-pid"):
27
+ """Clean up .wheelhub-port, .wheelhub-pid, and .wheelhub-gui-pid files."""
28
+ for name in (".wheelhub-port", ".wheelhub-pid", ".wheelhub-gui-pid"):
29
29
  try:
30
30
  (project_dir / name).unlink()
31
31
  except FileNotFoundError:
@@ -33,24 +33,24 @@ def cleanup_files(project_dir: Path) -> None:
33
33
 
34
34
 
35
35
  def read_port_file(project_dir: Path) -> int | None:
36
- """Read port from .bikerack-port file. Returns None if not found."""
36
+ """Read port from .wheelhub-port file. Returns None if not found."""
37
37
  try:
38
- return int((project_dir / ".bikerack-port").read_text().strip())
38
+ return int((project_dir / ".wheelhub-port").read_text().strip())
39
39
  except (FileNotFoundError, ValueError):
40
40
  return None
41
41
 
42
42
 
43
43
  def read_pid_file(project_dir: Path) -> int | None:
44
- """Read PID from .bikerack-pid file. Returns None if not found."""
44
+ """Read PID from .wheelhub-pid file. Returns None if not found."""
45
45
  try:
46
- return int((project_dir / ".bikerack-pid").read_text().strip())
46
+ return int((project_dir / ".wheelhub-pid").read_text().strip())
47
47
  except (FileNotFoundError, ValueError):
48
48
  return None
49
49
 
50
50
 
51
51
  def write_pid_file(project_dir: Path, pid: int) -> None:
52
- """Write .bikerack-pid file."""
53
- (project_dir / ".bikerack-pid").write_text(str(pid))
52
+ """Write .wheelhub-pid file."""
53
+ (project_dir / ".wheelhub-pid").write_text(str(pid))
54
54
 
55
55
 
56
56
  def build_otel_env(port: int) -> dict[str, str]:
@@ -104,8 +104,8 @@ def start_wheelhub(project_dir: Path) -> subprocess.Popen:
104
104
  def poll_for_port_file(
105
105
  project_dir: Path, timeout: float = 5.0, interval: float = 0.1
106
106
  ) -> int:
107
- """Poll for .bikerack-port file, return port number."""
108
- port_file = project_dir / ".bikerack-port"
107
+ """Poll for .wheelhub-port file, return port number."""
108
+ port_file = project_dir / ".wheelhub-port"
109
109
  deadline = time.monotonic() + timeout
110
110
 
111
111
  while True:
@@ -209,23 +209,23 @@ def get_status(project_dir: Path) -> dict:
209
209
 
210
210
 
211
211
  def read_tui_pid_file(project_dir: Path) -> int | None:
212
- """Read TUI PID from .bikerack-tui-pid file. Returns None if not found."""
212
+ """Read TUI PID from .wheelhub-gui-pid file. Returns None if not found."""
213
213
  try:
214
- return int((project_dir / ".bikerack-tui-pid").read_text().strip())
214
+ return int((project_dir / ".wheelhub-gui-pid").read_text().strip())
215
215
  except (FileNotFoundError, ValueError):
216
216
  return None
217
217
 
218
218
 
219
219
  def write_tui_pid_file(project_dir: Path, pid: int) -> None:
220
- """Write .bikerack-tui-pid file."""
221
- (project_dir / ".bikerack-tui-pid").write_text(str(pid))
220
+ """Write .wheelhub-gui-pid file."""
221
+ (project_dir / ".wheelhub-gui-pid").write_text(str(pid))
222
222
 
223
223
 
224
224
  def start_tui(project_dir: Path, port: int) -> subprocess.Popen:
225
225
  """Start TUI as independent subprocess.
226
226
 
227
227
  Uses start_new_session=True so TUI survives parent exit.
228
- Writes .bikerack-tui-pid for lifecycle tracking.
228
+ Writes .wheelhub-gui-pid for lifecycle tracking.
229
229
  """
230
230
  import sys
231
231
 
@@ -0,0 +1,315 @@
1
+ """ProgressPanel — Unified story progress view for BikeRack TUI.
2
+
3
+ Combines story context, workflow phase, acceptance criteria, todos, and
4
+ git status into a single at-a-glance panel. Subscribes to 4 WS channels:
5
+ /ws/story, /ws/todos, /ws/git, /ws/sprint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from rich.console import Group
13
+ from rich.text import Text
14
+
15
+ from pennyfarthing_scripts.bikerack.base_panel import (
16
+ PANEL_ICONS,
17
+ BasePanel,
18
+ render_progress_bar,
19
+ )
20
+
21
+
22
+ class ProgressPanel(BasePanel):
23
+ """Unified story progress panel.
24
+
25
+ Subscribes to ``story``, ``todos``, ``git``, and ``sprint`` channels.
26
+ Renders a compact overview combining story header, workflow phase,
27
+ AC progress, todo progress, and git summary.
28
+ """
29
+
30
+ channel: str = "story" # primary channel
31
+ panel_name: str = "Progress"
32
+ icon: str = PANEL_ICONS.get("progress", ("\uf200", "P"))[0]
33
+
34
+ def __init__(self, client: Any = None, **kwargs: Any) -> None:
35
+ super().__init__(client=client, **kwargs)
36
+ self._story_data: dict[str, Any] | None = None
37
+ self._todos_data: dict[str, Any] | None = None
38
+ self._git_data: dict[str, Any] | None = None
39
+ self._sprint_data: dict[str, Any] | None = None
40
+
41
+ def on_mount(self) -> None:
42
+ """Subscribe to all 4 channels."""
43
+ self._mounted = True
44
+ if self._client is not None:
45
+ self._client.subscribe("story", self._handle_story)
46
+ self._client.subscribe("todos", self._handle_todos)
47
+ self._client.subscribe("git", self._handle_git)
48
+ self._client.subscribe("sprint", self._handle_sprint)
49
+
50
+ def _handle_story(self, message: dict[str, Any] | None) -> None:
51
+ if message is None:
52
+ return
53
+ self._story_data = message
54
+ self._rerender()
55
+
56
+ def _handle_todos(self, message: dict[str, Any] | None) -> None:
57
+ if message is None:
58
+ return
59
+ self._todos_data = message
60
+ self._rerender()
61
+
62
+ def _handle_git(self, message: dict[str, Any] | None) -> None:
63
+ if message is None:
64
+ return
65
+ self._git_data = message
66
+ self._rerender()
67
+
68
+ def _handle_sprint(self, message: dict[str, Any] | None) -> None:
69
+ if message is None:
70
+ return
71
+ self._sprint_data = message
72
+ self._rerender()
73
+
74
+ def _rerender(self) -> None:
75
+ """Re-render with latest data from all channels."""
76
+ rendered = self.render_panel({})
77
+ try:
78
+ self.update(rendered)
79
+ except Exception:
80
+ pass
81
+
82
+ def render_panel(self, payload: dict[str, Any]) -> Any:
83
+ """Render unified progress view."""
84
+ parts: list[Any] = []
85
+
86
+ # --- Story Header ---
87
+ story_header = self._render_story_header()
88
+ if story_header is None:
89
+ return Text(
90
+ "No active story \u2014 start with /sprint work",
91
+ style="dim italic",
92
+ )
93
+ parts.append(story_header)
94
+ parts.append(Text("\u2500" * 35, style="dim"))
95
+
96
+ # --- Workflow Phase ---
97
+ workflow = self._render_workflow()
98
+ if workflow is not None:
99
+ parts.append(workflow)
100
+ parts.append(Text("\u2500" * 35, style="dim"))
101
+
102
+ # --- Acceptance Criteria ---
103
+ ac = self._render_ac()
104
+ if ac is not None:
105
+ parts.append(ac)
106
+ parts.append(Text("\u2500" * 35, style="dim"))
107
+
108
+ # --- Todos ---
109
+ todos = self._render_todos()
110
+ if todos is not None:
111
+ parts.append(todos)
112
+ parts.append(Text("\u2500" * 35, style="dim"))
113
+
114
+ # --- Git Summary ---
115
+ git = self._render_git()
116
+ if git is not None:
117
+ parts.append(git)
118
+
119
+ return Group(*parts)
120
+
121
+ def _render_story_header(self) -> Text | None:
122
+ """Render story ID, title, points, epic, assignee."""
123
+ story = self._story_data or {}
124
+ sprint = self._sprint_data or {}
125
+
126
+ # Try sprint data for current story context
127
+ current = sprint.get("sprint", {}).get("currentStory")
128
+ if isinstance(current, str) and current:
129
+ story_id = current
130
+ else:
131
+ story_id = story.get("id", "")
132
+
133
+ title = story.get("title", "")
134
+ points = story.get("points", "")
135
+ epic = story.get("epic", "")
136
+ assignee = story.get("assignee", "")
137
+
138
+ # Also try to extract from sprint epics
139
+ if not title and sprint:
140
+ for ep in sprint.get("epics", []):
141
+ for s in ep.get("stories", []):
142
+ if s.get("id") == story_id:
143
+ title = s.get("title", "")
144
+ points = s.get("points", "")
145
+ epic = ep.get("id", "")
146
+ break
147
+
148
+ if not story_id and not title:
149
+ return None
150
+
151
+ header = Text()
152
+ if story_id:
153
+ header.append(story_id, style="bold cyan")
154
+ header.append(" ")
155
+ if title:
156
+ header.append(title, style="bold")
157
+ if points:
158
+ header.append(f" {points}pt", style="dim")
159
+
160
+ # Second line: epic + assignee
161
+ meta_parts: list[str] = []
162
+ if epic:
163
+ meta_parts.append(f"Epic {epic}")
164
+ if assignee:
165
+ meta_parts.append(assignee)
166
+ if meta_parts:
167
+ header.append("\n")
168
+ header.append(" \u00b7 ".join(meta_parts), style="dim")
169
+
170
+ return header
171
+
172
+ def _render_workflow(self) -> Text | None:
173
+ """Render workflow type badge and phase dots."""
174
+ story = self._story_data or {}
175
+ workflow = story.get("workflow", "")
176
+ phases = story.get("workflowPhases", [])
177
+ current_phase = story.get("phase", "")
178
+
179
+ if not phases:
180
+ return None
181
+
182
+ line = Text()
183
+
184
+ # Workflow type badge
185
+ if workflow:
186
+ line.append(f"[{workflow}]", style="bold")
187
+ line.append(" ")
188
+
189
+ # Phase dots
190
+ for i, phase in enumerate(phases):
191
+ phase_name = phase if isinstance(phase, str) else phase.get("name", "")
192
+ phase_status = ""
193
+ if isinstance(phase, dict):
194
+ phase_status = phase.get("status", "")
195
+
196
+ # Determine phase state
197
+ if phase_status == "done" or (current_phase and phase_name != current_phase and _phase_before(phase_name, current_phase, phases)):
198
+ line.append("\u2713", style="green")
199
+ elif phase_name == current_phase:
200
+ line.append("\u25cf", style="bold yellow")
201
+ else:
202
+ line.append("\u25cb", style="dim")
203
+
204
+ line.append(f" {phase_name}", style="bold" if phase_name == current_phase else "dim")
205
+
206
+ if i < len(phases) - 1:
207
+ line.append(" \u2192 ", style="dim")
208
+
209
+ return line
210
+
211
+ def _render_ac(self) -> Text | None:
212
+ """Render acceptance criteria progress bar."""
213
+ story = self._story_data or {}
214
+ criteria = story.get("criteria", [])
215
+ if not criteria:
216
+ return None
217
+
218
+ total = len(criteria)
219
+ done = sum(1 for c in criteria if isinstance(c, dict) and c.get("met"))
220
+
221
+ if total == 0:
222
+ return None
223
+
224
+ pct = int(done / total * 100)
225
+ line = Text()
226
+ line.append("AC ", style="bold")
227
+ line.append_text(render_progress_bar(pct, width=10))
228
+ line.append(f" {done}/{total}")
229
+ return line
230
+
231
+ def _render_todos(self) -> Text | None:
232
+ """Render todo progress bar with active task."""
233
+ data = self._todos_data or {}
234
+ todos = data.get("todos", [])
235
+ if not todos:
236
+ return None
237
+
238
+ total = len(todos)
239
+ done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "done")
240
+ active = None
241
+ for t in todos:
242
+ if isinstance(t, dict) and t.get("status") in ("in-progress", "active", "running"):
243
+ active = t.get("description", t.get("title", ""))
244
+ break
245
+
246
+ if total == 0:
247
+ return None
248
+
249
+ pct = int(done / total * 100)
250
+ line = Text()
251
+ line.append("Todo ", style="bold")
252
+ line.append_text(render_progress_bar(pct, width=10))
253
+ line.append(f" {done}/{total}")
254
+ if active:
255
+ line.append(f" \u25cf {active}", style="yellow")
256
+ return line
257
+
258
+ def _render_git(self) -> Text | None:
259
+ """Render git summary: branch, dirty counts, ahead/behind."""
260
+ data = self._git_data or {}
261
+ repos = data.get("repos", [])
262
+ if not repos:
263
+ return None
264
+
265
+ line = Text()
266
+ line.append("Git ", style="bold")
267
+
268
+ repo_parts: list[str] = []
269
+ for repo in repos:
270
+ if not isinstance(repo, dict):
271
+ continue
272
+ branch = repo.get("branch", "")
273
+ dirty_files = repo.get("dirtyFiles", [])
274
+ ahead = repo.get("ahead", 0)
275
+ behind = repo.get("behind", 0)
276
+
277
+ # Count file types
278
+ modified = 0
279
+ untracked = 0
280
+ for f in dirty_files:
281
+ if not isinstance(f, dict):
282
+ continue
283
+ status = f.get("status", " ")
284
+ if status.startswith("?"):
285
+ untracked += 1
286
+ else:
287
+ modified += 1
288
+
289
+ part = Text()
290
+ if branch:
291
+ part.append(branch, style="cyan")
292
+ part.append(f" {modified}M", style="yellow" if modified else "dim")
293
+ part.append(f" {untracked}U", style="dim")
294
+ part.append(f" \u2191{ahead}", style="green" if ahead else "dim")
295
+ part.append(f" \u2193{behind}", style="red" if behind else "dim")
296
+
297
+ line.append_text(part)
298
+
299
+ # Only show first repo on main line, rest on separate lines
300
+ break
301
+
302
+ return line
303
+
304
+
305
+ def _phase_before(phase: str, current: str, phases: list) -> bool:
306
+ """Check if phase comes before current in the phases list."""
307
+ phase_idx = -1
308
+ current_idx = -1
309
+ for i, p in enumerate(phases):
310
+ name = p if isinstance(p, str) else p.get("name", "")
311
+ if name == phase:
312
+ phase_idx = i
313
+ if name == current:
314
+ current_idx = i
315
+ return phase_idx >= 0 and current_idx >= 0 and phase_idx < current_idx