@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
@@ -34,8 +34,8 @@ from pennyfarthing_scripts.bc.focus import (
34
34
  def _get_current_layout() -> dict | None:
35
35
  """Fetch the current layout from a running Cyclist or BikeRack server.
36
36
 
37
- Checks .bikerack-port first, then .cyclist-port, and fetches the
38
- appropriate layout endpoint.
37
+ Reads .wheelhub-port (shared by both Cyclist and BikeRack) and fetches
38
+ the layout endpoint.
39
39
 
40
40
  Returns:
41
41
  Layout dict, or None if no server is running or fetch fails.
@@ -44,10 +44,8 @@ def _get_current_layout() -> dict | None:
44
44
 
45
45
  root = _get_root()
46
46
 
47
- # Try BikeRack first (bikerack-layout), then Cyclist (layout)
48
47
  candidates = [
49
- (root / ".bikerack-port", "/api/settings/bikerack-layout"),
50
- (root / ".cyclist-port", "/api/settings/bikerack-layout"),
48
+ (root / ".wheelhub-port", "/api/settings/bikerack-layout"),
51
49
  ]
52
50
 
53
51
  for port_file, endpoint in candidates:
@@ -6,12 +6,13 @@ list with status indicators (running, completed, failed).
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import time
9
10
  from typing import Any
10
11
 
11
12
  from rich.console import Group
12
13
  from rich.text import Text
13
14
 
14
- from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
15
+ from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel, format_duration
15
16
 
16
17
 
17
18
  class BackgroundPanel(BasePanel):
@@ -25,12 +26,62 @@ class BackgroundPanel(BasePanel):
25
26
  panel_name: str = "Background"
26
27
  icon: str = PANEL_ICONS["background"][0]
27
28
 
29
+ def __init__(self, client=None, **kwargs):
30
+ super().__init__(client=client, **kwargs)
31
+ self._timer = None
32
+
33
+ def on_mount(self) -> None:
34
+ """Subscribe to channel and start elapsed timer."""
35
+ super().on_mount()
36
+ self._timer = self.set_interval(1, self._tick)
37
+
38
+ def on_unmount(self) -> None:
39
+ """Stop timer and cleanup."""
40
+ if self._timer is not None:
41
+ self._timer.stop()
42
+ super().on_unmount()
43
+
44
+ def _tick(self) -> None:
45
+ """Re-render every second to update elapsed times."""
46
+ if self._last_payload is not None:
47
+ rendered = self.render_panel(self._last_payload)
48
+ try:
49
+ self.update(rendered)
50
+ except Exception:
51
+ pass
52
+
28
53
  def render_panel(self, payload: dict[str, Any]) -> Any:
29
54
  """Render background task data from WebSocket payload."""
30
55
  tasks = payload.get("tasks", [])
31
56
  if not isinstance(tasks, list) or not tasks:
32
57
  return Text("No background tasks", style="dim italic")
33
58
 
59
+ running = 0
60
+ done = 0
61
+ failed = 0
62
+ for task in tasks:
63
+ if not isinstance(task, dict):
64
+ continue
65
+ if task.get("completedAt") is not None:
66
+ if task.get("success"):
67
+ done += 1
68
+ else:
69
+ failed += 1
70
+ else:
71
+ running += 1
72
+
73
+ # Summary header
74
+ summary = Text()
75
+ summary.append(f"{running} running", style="yellow")
76
+ summary.append(" ")
77
+ summary.append(f"{done} done", style="green")
78
+ if failed > 0:
79
+ summary.append(" ")
80
+ summary.append(f"{failed} failed", style="red")
81
+
82
+ separator = Text("─" * 30, style="dim")
83
+
84
+ # Task list
34
85
  parts: list[Any] = []
35
86
  for task in tasks:
36
87
  if not isinstance(task, dict):
@@ -40,7 +91,7 @@ class BackgroundPanel(BasePanel):
40
91
  if not parts:
41
92
  return Text("No background tasks", style="dim italic")
42
93
 
43
- return Group(*parts)
94
+ return Group(summary, separator, *parts)
44
95
 
45
96
 
46
97
  def _render_task(task: dict[str, Any]) -> Text:
@@ -48,6 +99,7 @@ def _render_task(task: dict[str, Any]) -> Text:
48
99
  description = task.get("description", "Unknown task")
49
100
  subagent_type = task.get("subagentType", "")
50
101
  completed_at = task.get("completedAt")
102
+ started_at = task.get("startedAt")
51
103
  success = task.get("success")
52
104
  result = task.get("result", "")
53
105
  error = task.get("error", "")
@@ -60,7 +112,12 @@ def _render_task(task: dict[str, Any]) -> Text:
60
112
  line.append(description)
61
113
  if subagent_type:
62
114
  line.append(f" [{subagent_type}]", style="dim")
63
- line.append(" done", style="green")
115
+ # Show duration if timestamps available
116
+ duration = _calc_duration(started_at, completed_at)
117
+ if duration:
118
+ line.append(f" — {duration}", style="green")
119
+ else:
120
+ line.append(" — done", style="green")
64
121
  if result:
65
122
  line.append(f" ({result})", style="dim green")
66
123
  else:
@@ -68,7 +125,11 @@ def _render_task(task: dict[str, Any]) -> Text:
68
125
  line.append(description)
69
126
  if subagent_type:
70
127
  line.append(f" [{subagent_type}]", style="dim")
71
- line.append(" failed", style="red")
128
+ duration = _calc_duration(started_at, completed_at)
129
+ if duration:
130
+ line.append(f" — {duration}", style="red")
131
+ else:
132
+ line.append(" — failed", style="red")
72
133
  if error:
73
134
  line.append(f"\n {error}", style="red")
74
135
  else:
@@ -76,6 +137,26 @@ def _render_task(task: dict[str, Any]) -> Text:
76
137
  line.append(description)
77
138
  if subagent_type:
78
139
  line.append(f" [{subagent_type}]", style="dim")
79
- line.append(" running", style="yellow")
140
+ # Live elapsed time for running tasks
141
+ if started_at:
142
+ try:
143
+ elapsed = time.time() - float(started_at) / 1000 # startedAt is typically ms
144
+ line.append(f" — {format_duration(elapsed)}", style="yellow")
145
+ except (ValueError, TypeError):
146
+ line.append(" — running", style="yellow")
147
+ else:
148
+ line.append(" — running", style="yellow")
80
149
 
81
150
  return line
151
+
152
+
153
+ def _calc_duration(started_at, completed_at) -> str:
154
+ """Calculate duration string from timestamps."""
155
+ if started_at is None or completed_at is None:
156
+ return ""
157
+ try:
158
+ start = float(started_at) / 1000 # ms to seconds
159
+ end = float(completed_at) / 1000
160
+ return format_duration(end - start)
161
+ except (ValueError, TypeError):
162
+ return ""
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any
11
11
 
12
+ from rich.text import Text
12
13
  from textual.widgets import Static
13
14
 
14
15
  # Nerd Font icon registry: panel_name → (nerd_font_icon, ascii_fallback)
@@ -25,6 +26,7 @@ PANEL_ICONS: dict[str, tuple[str, str]] = {
25
26
  "debug": ("\uf188", "d"), # nf-fa-bug
26
27
  "settings": ("\uf013", "S"), # nf-fa-gear
27
28
  "tty": ("\uf120", ">"), # nf-fa-terminal
29
+ "progress": ("\uf200", "P"), # nf-fa-pie_chart
28
30
  }
29
31
 
30
32
 
@@ -44,6 +46,66 @@ def get_panel_icon(panel_name: str, use_nerd_font: bool = True) -> str:
44
46
  return entry[0] if use_nerd_font else entry[1]
45
47
 
46
48
 
49
+ def render_progress_bar(percent: int | float, width: int = 20, warn_high: bool = False) -> Text:
50
+ """Render a Unicode progress bar with color based on percentage.
51
+
52
+ Args:
53
+ percent: Value 0-100.
54
+ width: Number of bar characters (default 20).
55
+ warn_high: If True, use red at high values (for resource usage).
56
+ If False (default), use blue at 100% (for completion).
57
+
58
+ Returns:
59
+ Rich Text like ``[████████░░░░░░░░░░░░] 22%``
60
+ """
61
+ percent = max(0, min(100, int(percent)))
62
+ filled = round(width * percent / 100)
63
+ empty = width - filled
64
+
65
+ if warn_high:
66
+ if percent < 50:
67
+ style = "green"
68
+ elif percent <= 80:
69
+ style = "yellow"
70
+ else:
71
+ style = "red"
72
+ else:
73
+ style = "blue"
74
+
75
+ bar = Text()
76
+ bar.append("[")
77
+ bar.append("█" * filled, style=style)
78
+ bar.append("░" * empty, style="dim")
79
+ bar.append(f"] {percent}%")
80
+ return bar
81
+
82
+
83
+ def format_duration(seconds: int | float) -> str:
84
+ """Format seconds into human-friendly duration string.
85
+
86
+ Returns:
87
+ ``47s``, ``2m 14s``, ``1h 5m``.
88
+ """
89
+ seconds = max(0, int(seconds))
90
+ if seconds < 60:
91
+ return f"{seconds}s"
92
+ minutes, secs = divmod(seconds, 60)
93
+ if minutes < 60:
94
+ return f"{minutes}m {secs}s"
95
+ hours, mins = divmod(minutes, 60)
96
+ return f"{hours}h {mins}m"
97
+
98
+
99
+ def humanize_theme(slug: str) -> str:
100
+ """Convert a theme slug to a display name.
101
+
102
+ ``princess-bride`` → ``Princess Bride``
103
+ """
104
+ if not slug:
105
+ return ""
106
+ return slug.replace("-", " ").replace("_", " ").title()
107
+
108
+
47
109
  class BasePanel(Static):
48
110
  """Base class for BikeRack TUI panels.
49
111
 
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  from typing import Any
10
10
 
11
- from rich.table import Table
12
11
  from rich.text import Text
13
12
 
14
13
  from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
@@ -64,42 +63,47 @@ class ChangedPanel(BasePanel):
64
63
  icon: str = PANEL_ICONS["changed"][0]
65
64
 
66
65
  def render_panel(self, payload: dict[str, Any]) -> Any:
67
- """Render changed file data from WebSocket payload."""
66
+ """Render changed files grouped by repository."""
68
67
  repos = payload.get("repos", [])
69
68
  if not isinstance(repos, list):
70
69
  return Text("No changed files", style="dim italic")
71
70
 
72
- files: list[tuple[str, dict[str, Any]]] = []
71
+ # Group files by repo
72
+ repo_files: dict[str, list[dict[str, Any]]] = {}
73
73
  for repo in repos:
74
74
  if not isinstance(repo, dict):
75
75
  continue
76
- repo_name = repo.get("name", "")
76
+ repo_name = repo.get("name", "unknown")
77
77
  dirty_files = repo.get("dirtyFiles", [])
78
- if not isinstance(dirty_files, list):
78
+ if not isinstance(dirty_files, list) or not dirty_files:
79
79
  continue
80
- for f in dirty_files:
81
- if not isinstance(f, dict):
82
- continue
83
- files.append((repo_name, f))
80
+ repo_files[repo_name] = [f for f in dirty_files if isinstance(f, dict)]
84
81
 
85
- if not files:
82
+ if not repo_files:
86
83
  return Text("No changed files", style="dim italic")
87
84
 
88
- table = Table()
89
- table.add_column("", width=2)
90
- table.add_column("File", style="cyan")
91
- table.add_column("Status")
92
- table.add_column("Repo", style="dim")
93
-
94
- for repo_name, f in files:
95
- status_code = f.get("status", " ")
96
- path = f.get("path", "")
97
- icon, label, style = _parse_status(status_code)
98
- table.add_row(
99
- Text(icon, style=f"bold {style}"),
100
- path,
101
- Text(label, style=style),
102
- repo_name,
103
- )
104
-
105
- return table
85
+ from rich.console import Group as RichGroup
86
+
87
+ parts: list[Any] = []
88
+ for repo_name, files in repo_files.items():
89
+ count = len(files)
90
+ label = "file" if count == 1 else "files"
91
+ header = Text()
92
+ header.append(repo_name, style="bold cyan")
93
+ header.append(f" ({count} {label})", style="dim")
94
+ parts.append(header)
95
+
96
+ for f in files:
97
+ status_code = f.get("status", " ")
98
+ path = f.get("path", "")
99
+ icon, label_text, style = _parse_status(status_code)
100
+ line = Text()
101
+ line.append(" ")
102
+ line.append(icon, style=f"bold {style}")
103
+ line.append(f" {path}", style="cyan")
104
+ line.append(f" {label_text}", style=style)
105
+ parts.append(line)
106
+
107
+ parts.append(Text("")) # spacer between repos
108
+
109
+ return RichGroup(*parts)
@@ -64,12 +64,12 @@ def start(project_dir, dry_run):
64
64
 
65
65
  running, pid, port = is_already_running(project_dir)
66
66
  if running:
67
- click.echo(
68
- f"Error: BikeRack is already running (PID {pid}, port {port})",
69
- err=True,
70
- )
71
- click.echo("Use 'pf bikerack stop' to stop it.", err=True)
72
- sys.exit(2)
67
+ # Idempotent: WheelHub already up, just exec Claude with OTEL env
68
+ click.echo(f"BikeRack already running (PID {pid}, port {port})")
69
+ otel_env = build_otel_env(port)
70
+ click.echo(f"Dashboard: http://localhost:{port}/bikerack")
71
+ click.echo("Starting Claude CLI...")
72
+ exec_claude(otel_env, project_dir)
73
73
 
74
74
  click.echo("Starting BikeRack mode...")
75
75
  try:
@@ -116,11 +116,10 @@ def stop(project_dir, dry_run):
116
116
 
117
117
  result = stop_bikerack(project_dir)
118
118
 
119
- if result["success"]:
120
- click.echo(result["message"])
121
- else:
122
- click.echo(result["message"], err=True)
123
- sys.exit(1)
119
+ click.echo(result["message"])
120
+ if not result["success"]:
121
+ # "Not running" is not an error for stop — idempotent
122
+ sys.exit(0)
124
123
 
125
124
 
126
125
  @bikerack.command()
@@ -7,13 +7,14 @@ token consumption stats (input, output, cache, cost).
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections import deque
10
11
  from typing import Any
11
12
 
12
13
  from rich.console import Group
13
14
  from rich.table import Table
14
15
  from rich.text import Text
15
16
 
16
- from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
17
+ from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel, render_progress_bar
17
18
 
18
19
  # Tier → Rich style mapping
19
20
  _TIER_STYLES: dict[str, str] = {
@@ -68,6 +69,7 @@ class DebugPanel(BasePanel):
68
69
  super().__init__(client=client, **kwargs)
69
70
  self._context_data: dict[str, Any] | None = None
70
71
  self._token_stats: dict[str, Any] | None = None
72
+ self._sparkline_history: deque[int] = deque(maxlen=20)
71
73
 
72
74
  def on_mount(self) -> None:
73
75
  """Subscribe to both context and token-stats channels."""
@@ -83,6 +85,9 @@ class DebugPanel(BasePanel):
83
85
  ctx = message.get("context")
84
86
  if isinstance(ctx, dict):
85
87
  self._context_data = ctx
88
+ pct = _safe_int(ctx.get("percent"))
89
+ if pct is not None:
90
+ self._sparkline_history.append(pct)
86
91
  else:
87
92
  self._context_data = {}
88
93
  self._rerender()
@@ -110,6 +115,8 @@ class DebugPanel(BasePanel):
110
115
  ctx = self._context_data
111
116
  if ctx:
112
117
  parts.append(_render_context(ctx))
118
+ if len(self._sparkline_history) >= 2:
119
+ parts.append(_render_sparkline(self._sparkline_history))
113
120
  elif not self._token_stats:
114
121
  return Text("No context data", style="dim italic")
115
122
 
@@ -157,6 +164,10 @@ def _render_context(ctx: dict[str, Any]) -> Any:
157
164
  usage_text.append(f" ({percent}%)")
158
165
  parts.append(usage_text)
159
166
 
167
+ # Context usage progress bar
168
+ if percent is not None:
169
+ parts.append(render_progress_bar(percent, warn_high=True))
170
+
160
171
  # Breakdown: baseline / conversation / available
161
172
  if baseline is not None:
162
173
  breakdown = Table(show_header=False, show_edge=False, pad_edge=False, box=None)
@@ -176,6 +187,25 @@ def _render_context(ctx: dict[str, Any]) -> Any:
176
187
  return Group(*parts)
177
188
 
178
189
 
190
+ _SPARKLINE_CHARS = "▁▂▃▄▅▆▇█"
191
+
192
+
193
+ def _render_sparkline(history: deque[int]) -> Text:
194
+ """Render a Unicode sparkline from context usage history."""
195
+ text = Text()
196
+ text.append("Context trend: ", style="dim")
197
+ for pct in history:
198
+ level = min(7, max(0, int(pct / 100 * 7.99)))
199
+ if pct < 50:
200
+ style = "green"
201
+ elif pct <= 80:
202
+ style = "yellow"
203
+ else:
204
+ style = "red"
205
+ text.append(_SPARKLINE_CHARS[level], style=style)
206
+ return text
207
+
208
+
179
209
  def _render_token_stats(stats: dict[str, Any]) -> Any:
180
210
  """Render token stats section."""
181
211
  table = Table(show_header=False, show_edge=False, pad_edge=False, box=None)
@@ -52,6 +52,8 @@ class DiffsPanel(BasePanel):
52
52
  self._current_page: int = 0
53
53
  self._max_page: int = 0
54
54
  self._temp_files: list[str] = []
55
+ self._current_file_index: int = 0
56
+ self._total_files: int = 0
55
57
 
56
58
  def next_page(self) -> None:
57
59
  """Advance to the next page of truncated diff content."""
@@ -63,11 +65,34 @@ class DiffsPanel(BasePanel):
63
65
  if self._current_page > 0:
64
66
  self._current_page -= 1
65
67
 
68
+ def next_file(self) -> None:
69
+ """Advance to the next file."""
70
+ if self._current_file_index < self._total_files - 1:
71
+ self._current_file_index += 1
72
+ if self._last_payload:
73
+ rendered = self.render_panel(self._last_payload)
74
+ try:
75
+ self.update(rendered)
76
+ except Exception:
77
+ pass
78
+
79
+ def prev_file(self) -> None:
80
+ """Go back to the previous file."""
81
+ if self._current_file_index > 0:
82
+ self._current_file_index -= 1
83
+ if self._last_payload:
84
+ rendered = self.render_panel(self._last_payload)
85
+ try:
86
+ self.update(rendered)
87
+ except Exception:
88
+ pass
89
+
66
90
  def handle_message(self, message: dict[str, Any] | None) -> None:
67
91
  """Handle incoming WebSocket message with pagination reset and temp management."""
68
92
  if not self._mounted or message is None:
69
93
  return
70
94
  self._current_page = 0
95
+ self._current_file_index = 0
71
96
  self._cleanup_temp_files()
72
97
  self._store_large_diffs(message)
73
98
  super().handle_message(message)
@@ -78,31 +103,63 @@ class DiffsPanel(BasePanel):
78
103
  super().on_unmount()
79
104
 
80
105
  def render_panel(self, payload: dict[str, Any]) -> Any:
81
- """Render diff data from WebSocket payload with truncation/pagination."""
106
+ """Render diff data showing one file at a time with file selector header."""
82
107
  diffs = payload.get("diffs", [])
83
108
  if not diffs:
84
109
  return Text("No diffs yet", style="dim italic")
85
110
 
111
+ self._total_files = len(diffs)
112
+
113
+ # Clamp file index
114
+ if self._current_file_index >= len(diffs):
115
+ self._current_file_index = len(diffs) - 1
116
+
86
117
  parts: list[Any] = []
87
- max_total = 0
88
- for diff_entry in diffs:
89
- # Skip syntax highlighting for very large diffs (>2000 lines) for performance
90
- raw_diff = diff_entry.get("diff", "")
91
- skip_highlight = raw_diff.count("\n") > HIGHLIGHT_THRESHOLD
92
118
 
93
- file_parts, total_lines = _render_file_diff(
94
- diff_entry,
95
- page=self._current_page,
96
- page_size=DEFAULT_LINE_LIMIT,
97
- skip_highlight=skip_highlight,
98
- )
99
- parts.extend(file_parts)
100
- parts.append(Text("")) # separator between files
101
- max_total = max(max_total, total_lines)
119
+ # File selector header
120
+ selector = Text()
121
+ selector.append("Files: ", style="dim")
122
+ for i, d in enumerate(diffs):
123
+ path = d.get("path", "unknown")
124
+ additions = d.get("additions")
125
+ deletions = d.get("deletions")
126
+ stats = ""
127
+ if additions is not None and deletions is not None:
128
+ stats = f" +{additions} -{deletions}"
129
+
130
+ if i == self._current_file_index:
131
+ selector.append(f"[{i+1}/{len(diffs)}] ", style="bold")
132
+ selector.append(path, style="bold cyan")
133
+ if stats:
134
+ selector.append(stats, style="bold dim")
135
+ else:
136
+ selector.append(path, style="dim")
137
+ if stats:
138
+ selector.append(stats, style="dim")
139
+
140
+ if i < len(diffs) - 1:
141
+ selector.append(" | ", style="dim")
142
+
143
+ parts.append(selector)
144
+ parts.append(Text("n:next p:prev", style="dim"))
145
+ parts.append(Text(""))
146
+
147
+ # Render only current file's diff
148
+ diff_entry = diffs[self._current_file_index]
149
+ raw_diff = diff_entry.get("diff", "")
150
+ skip_highlight = raw_diff.count("\n") > HIGHLIGHT_THRESHOLD
151
+
152
+ file_parts, total_lines = _render_file_diff(
153
+ diff_entry,
154
+ page=self._current_page,
155
+ page_size=DEFAULT_LINE_LIMIT,
156
+ skip_highlight=skip_highlight,
157
+ )
158
+ parts.extend(file_parts)
102
159
 
103
160
  # Track max page for pagination bounds
104
- if max_total > DEFAULT_LINE_LIMIT:
105
- self._max_page = -(-max_total // DEFAULT_LINE_LIMIT) - 1
161
+ if total_lines > DEFAULT_LINE_LIMIT:
162
+ self._max_page = -(-total_lines // DEFAULT_LINE_LIMIT) - 1
106
163
  else:
107
164
  self._max_page = 0
108
165