@pennyfarthing/core 11.1.1 → 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 (160) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
  4. package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
  5. package/packages/core/dist/server/otlp-receiver.js +185 -24
  6. package/packages/core/dist/server/otlp-receiver.js.map +1 -1
  7. package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
  8. package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
  9. package/packages/core/dist/server/otlp-receiver.test.js +446 -0
  10. package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
  11. package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
  12. package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
  13. package/packages/core/dist/shared/portrait-resolver.js +27 -0
  14. package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
  15. package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
  16. package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
  17. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
  18. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
  19. package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
  20. package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
  21. package/pennyfarthing-dist/agents/dev.md +1 -1
  22. package/pennyfarthing-dist/agents/reviewer.md +1 -1
  23. package/pennyfarthing-dist/agents/sm-setup.md +1 -1
  24. package/pennyfarthing-dist/agents/sm.md +2 -2
  25. package/pennyfarthing-dist/agents/tea.md +1 -1
  26. package/pennyfarthing-dist/agents/testing-runner.md +2 -1
  27. package/pennyfarthing-dist/commands/pf-chore.md +2 -2
  28. package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
  29. package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
  30. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
  31. package/pennyfarthing-dist/guides/bikerack.md +3 -3
  32. package/pennyfarthing-dist/guides/hooks.md +1 -1
  33. package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
  34. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  35. package/pennyfarthing-dist/scripts/README.md +1 -1
  36. package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
  37. package/pennyfarthing-dist/scripts/git/README.md +24 -14
  38. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
  39. package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
  40. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
  41. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
  42. package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
  43. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
  44. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
  45. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
  46. package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
  55. package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
  56. package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
  57. package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
  58. package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
  59. package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
  60. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
  61. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
  62. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
  63. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
  64. package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
  65. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
  66. package/pennyfarthing_scripts/CLAUDE.md +26 -4
  67. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  68. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  69. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/bc/cli.py +3 -5
  73. package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
  74. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  75. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  76. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
  80. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  81. package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
  82. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  83. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
  87. package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
  88. package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
  89. package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
  90. package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
  91. package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
  92. package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
  93. package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
  94. package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
  95. package/pennyfarthing_scripts/bikerack/tui.py +336 -30
  96. package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
  97. package/pennyfarthing_scripts/cli.py +37 -65
  98. package/pennyfarthing_scripts/consultation/__init__.py +1 -0
  99. package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/consultation/cli.py +149 -0
  102. package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
  103. package/pennyfarthing_scripts/context.py +3 -3
  104. package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/git/__init__.py +12 -1
  107. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
  110. package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  112. package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
  113. package/pennyfarthing_scripts/git/create_branches.py +3 -4
  114. package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
  115. package/pennyfarthing_scripts/git/repos.py +196 -0
  116. package/pennyfarthing_scripts/git/status_all.py +27 -11
  117. package/pennyfarthing_scripts/git/worktree.py +302 -0
  118. package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/git_group/cli.py +143 -40
  121. package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
  122. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  123. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  124. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  125. package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
  126. package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
  127. package/pennyfarthing_scripts/hooks.py +3 -17
  128. package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
  129. package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/heatmap.py +655 -0
  131. package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/session_start_hook.py +1 -1
  134. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/sprint/loader.py +15 -1
  136. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  137. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
  138. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
  140. package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
  141. package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
  142. package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
  143. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
  145. package/pennyfarthing_scripts/validate/cli.py +17 -5
  146. package/pennyfarthing_scripts/workflow/__init__.py +40 -0
  147. package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/workflow/cli.py +1099 -0
  153. package/pennyfarthing_scripts/workflow/helpers.py +241 -0
  154. package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
  155. package/pennyfarthing_scripts/workflow/state.py +112 -0
  156. package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
  157. package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
  158. package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
  159. package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
  160. package/pennyfarthing-dist/skills/pf-workflow/scripts/workflow-status.sh +0 -167
@@ -5,21 +5,30 @@ Story 103-4: Connection status indicator in TUI header.
5
5
  Story 103-6: SprintPanel as default panel on launch.
6
6
  Story 103-7: /bc TUI panel focus — subscribe to /ws/focus, switch panels.
7
7
  Story 103-9: Panel header chrome — icon + name indicator for active panel.
8
+ Panel navigation: Mount all panels, tab bar, keyboard switching, command palette.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
11
12
 
13
+ from functools import partial
12
14
  from pathlib import Path
13
15
  from typing import Any
14
16
 
15
17
  from textual.app import App, ComposeResult
16
18
  from textual.binding import Binding
19
+ from textual.command import Hit, Hits, Provider
17
20
  from textual.containers import VerticalScroll
18
21
  from textual.reactive import reactive
19
22
  from textual.widgets import Footer, Header, Static
20
23
 
21
24
  from pennyfarthing_scripts.bc.focus import get_last_panel, save_last_panel
25
+ from pennyfarthing_scripts.bikerack.background_panel import BackgroundPanel
22
26
  from pennyfarthing_scripts.bikerack.base_panel import get_panel_icon
27
+ from pennyfarthing_scripts.bikerack.changed_panel import ChangedPanel
28
+ from pennyfarthing_scripts.bikerack.debug_panel import DebugPanel
29
+ from pennyfarthing_scripts.bikerack.diffs_panel import DiffsPanel
30
+ from pennyfarthing_scripts.bikerack.git_panel import GitPanel
31
+ from pennyfarthing_scripts.bikerack.progress_panel import ProgressPanel
23
32
  from pennyfarthing_scripts.bikerack.sprint_panel import SprintPanel
24
33
  from pennyfarthing_scripts.bikerack.ws_client import ConnectionState, WheelHubClient
25
34
 
@@ -30,7 +39,48 @@ STATE_DISPLAY: dict[ConnectionState, str] = {
30
39
  ConnectionState.CONNECTING: "[yellow]● Connecting…[/yellow]",
31
40
  }
32
41
 
33
- # Human-readable display names for panels
42
+ # Agent role colors for Rich markup (mapped from React AGENT_COLORS)
43
+ AGENT_ROLE_COLORS: dict[str, str] = {
44
+ "pm": "purple",
45
+ "sm": "blue",
46
+ "dev": "green",
47
+ "tea": "cyan",
48
+ "reviewer": "red",
49
+ "architect": "dark_orange",
50
+ "devops": "bright_cyan",
51
+ "ux-designer": "magenta",
52
+ "tech-writer": "white",
53
+ "orchestrator": "bright_magenta",
54
+ "ba": "bright_green",
55
+ }
56
+
57
+ AGENT_ABBREV: dict[str, str] = {
58
+ "pm": "PM",
59
+ "sm": "SM",
60
+ "dev": "DEV",
61
+ "tea": "TEA",
62
+ "reviewer": "REV",
63
+ "architect": "ARC",
64
+ "devops": "OPS",
65
+ "ux-designer": "UX",
66
+ "tech-writer": "TW",
67
+ "orchestrator": "ORC",
68
+ "ba": "BA",
69
+ }
70
+
71
+ # Ordered panel registry: (key, display_name, widget_class)
72
+ # Only panels with implemented widget classes are included.
73
+ PANEL_REGISTRY: list[tuple[str, str]] = [
74
+ ("sprint", "Sprint"),
75
+ ("git", "Git"),
76
+ ("diffs", "Diffs"),
77
+ ("changed", "Changed"),
78
+ ("background", "Background"),
79
+ ("debug", "Debug"),
80
+ ("progress", "Progress"),
81
+ ]
82
+
83
+ # Human-readable display names for panels (full set for external focus messages)
34
84
  PANEL_DISPLAY_NAMES: dict[str, str] = {
35
85
  "sprint": "Sprint",
36
86
  "git": "Git",
@@ -42,24 +92,106 @@ PANEL_DISPLAY_NAMES: dict[str, str] = {
42
92
  "changed": "Changed",
43
93
  "ac": "Acceptance Criteria",
44
94
  "debug": "Debug",
95
+ "progress": "Progress",
45
96
  "settings": "Settings",
46
97
  "tty": "TTY",
47
98
  }
48
99
 
100
+ # Keys from PANEL_REGISTRY for fast lookup
101
+ _PANEL_KEYS = [key for key, _ in PANEL_REGISTRY]
49
102
 
50
- class PanelIndicator(Static):
51
- """Displays the active panel's Nerd Font icon and name."""
52
103
 
53
- panel_key: reactive[str] = reactive("sprint")
104
+ class PanelTabBar(Static):
105
+ """Horizontal tab bar showing all available panels with active highlight."""
54
106
 
55
- def watch_panel_key(self, key: str) -> None:
56
- """Update display when the active panel changes."""
57
- icon = get_panel_icon(key)
58
- name = PANEL_DISPLAY_NAMES.get(key, key.title())
59
- if icon:
60
- self.update(f"[bold]{icon} {name}[/bold]")
61
- else:
62
- self.update(f"[bold]{name}[/bold]")
107
+ active: reactive[str] = reactive("sprint")
108
+
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))
127
+
128
+
129
+ class AgentHeader(Static):
130
+ """Displays current agent persona from WheelHub /ws/persona channel."""
131
+
132
+ def __init__(self, **kwargs: Any) -> None:
133
+ super().__init__(**kwargs)
134
+ self._is_streaming: bool = False
135
+ self._persona_data: dict[str, Any] = {}
136
+
137
+ def _apply_persona(self, data: dict[str, Any]) -> None:
138
+ """Render persona data into the header."""
139
+ if data.get("type") == "streaming":
140
+ self._is_streaming = bool(data.get("isStreaming", False))
141
+ self._render_header()
142
+ return
143
+
144
+ self._persona_data = data
145
+ self._is_streaming = bool(data.get("isStreaming", False))
146
+ self._render_header()
147
+
148
+ def _render_header(self) -> None:
149
+ """Re-render the header from stored state."""
150
+ data = self._persona_data
151
+ char = data.get("character", "")
152
+ role = data.get("role", "")
153
+ role_desc = data.get("roleDescription", "")
154
+ quote = data.get("quote", "")
155
+ style = data.get("style", "")
156
+ theme = data.get("theme", "")
157
+
158
+ if not char:
159
+ self.update("[dim]Waiting for agent...[/dim]")
160
+ return
161
+
162
+ parts: list[str] = []
163
+
164
+ # Role badge
165
+ if role:
166
+ abbrev = AGENT_ABBREV.get(role, role.upper()[:3])
167
+ color = AGENT_ROLE_COLORS.get(role, "bright_magenta")
168
+ parts.append(f"[bold {color}][{abbrev}][/bold {color}]")
169
+
170
+ # Character name
171
+ parts.append(f"[bold]{char}[/bold]")
172
+
173
+ # Theme name
174
+ if theme:
175
+ from pennyfarthing_scripts.bikerack.base_panel import humanize_theme
176
+ parts.append(f"[dim]{humanize_theme(theme)}[/dim]")
177
+
178
+ # Streaming indicator
179
+ if self._is_streaming:
180
+ parts.append("[bold yellow]⚡[/bold yellow]")
181
+
182
+ line = " ".join(parts)
183
+
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
191
+ if quote:
192
+ line += f"\n[italic dim]\"{quote}\"[/italic dim]"
193
+
194
+ self.update(line)
63
195
 
64
196
 
65
197
  class ConnectionStatus(Static):
@@ -74,50 +206,212 @@ class ConnectionStatus(Static):
74
206
  self.update(STATE_DISPLAY.get(state, "● Unknown"))
75
207
 
76
208
 
209
+ class PanelCommands(Provider):
210
+ """Command palette provider for panel switching."""
211
+
212
+ async def search(self, query: str) -> Hits:
213
+ matcher = self.matcher(query)
214
+ for panel_key, display_name in PANEL_REGISTRY:
215
+ icon = get_panel_icon(panel_key)
216
+ label = f"{icon} {display_name}" if icon else display_name
217
+ score = matcher.match(display_name)
218
+ if score > 0:
219
+ yield Hit(
220
+ score,
221
+ matcher.highlight(label),
222
+ partial(self.app.action_switch_panel, panel_key),
223
+ help=f"Switch to {display_name} panel",
224
+ )
225
+
226
+
77
227
  class BikeRackApp(App):
78
228
  """BikeRack TUI application shell."""
79
229
 
80
230
  TITLE = "BikeRack"
81
231
 
232
+ CSS = """
233
+ #agent-header {
234
+ height: auto;
235
+ max-height: 3;
236
+ padding: 0 1;
237
+ }
238
+ #tab-bar {
239
+ height: 1;
240
+ }
241
+ #connection-status {
242
+ height: 1;
243
+ }
244
+ """
245
+
246
+ COMMANDS = App.COMMANDS | {PanelCommands}
247
+
82
248
  BINDINGS = [
83
249
  Binding("q", "quit", "Quit"),
250
+ Binding("1", "switch_panel('sprint')", "Sprint", show=False),
251
+ Binding("2", "switch_panel('git')", "Git", show=False),
252
+ Binding("3", "switch_panel('diffs')", "Diffs", show=False),
253
+ Binding("4", "switch_panel('changed')", "Changed", show=False),
254
+ 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),
257
+ Binding("bracketright", "next_panel", "]Next"),
258
+ Binding("bracketleft", "prev_panel", "[Prev"),
259
+ Binding("tab", "next_panel", show=False),
260
+ Binding("shift+tab", "prev_panel", show=False),
261
+ Binding("n", "next_diff_file", "Next file", show=False),
262
+ Binding("p", "prev_diff_file", "Prev file", show=False),
263
+ Binding("j", "next_epic", show=False),
264
+ Binding("k", "prev_epic", show=False),
265
+ Binding("e", "toggle_epic", show=False),
84
266
  ]
85
267
 
86
268
  def __init__(self, client=None, **kwargs):
87
269
  super().__init__(**kwargs)
88
270
  self._client = client
89
- self._focused_panel: str | None = None
271
+ self._focused_panel: str = "sprint"
90
272
  self._previous_panel: str | None = None
91
273
 
92
274
  def compose(self) -> ComposeResult:
93
275
  yield Header()
94
- yield PanelIndicator(id="panel-indicator")
276
+ yield AgentHeader(id="agent-header")
277
+ yield PanelTabBar(id="tab-bar")
95
278
  yield ConnectionStatus(
96
279
  STATE_DISPLAY[ConnectionState.DISCONNECTED],
97
280
  id="connection-status",
98
281
  )
99
282
  with VerticalScroll(id="main-content"):
100
- yield SprintPanel(client=self._client, id="sprint-panel")
283
+ yield SprintPanel(client=self._client, id="panel-sprint")
284
+ yield GitPanel(client=self._client, id="panel-git")
285
+ yield DiffsPanel(client=self._client, id="panel-diffs")
286
+ yield ChangedPanel(client=self._client, id="panel-changed")
287
+ yield BackgroundPanel(client=self._client, id="panel-background")
288
+ yield DebugPanel(client=self._client, id="panel-debug")
289
+ yield ProgressPanel(client=self._client, id="panel-progress")
101
290
  yield Footer()
102
291
 
103
292
  async def on_mount(self) -> None:
293
+ # Restore last panel or default to sprint
104
294
  result = get_last_panel()
295
+ initial = "sprint"
105
296
  if result.get("success") and result.get("last_panel"):
106
- self._focused_panel = result["last_panel"]
297
+ last = result["last_panel"]
298
+ if last in _PANEL_KEYS:
299
+ initial = last
300
+
301
+ self._focused_panel = initial
107
302
 
108
- # Set initial panel indicator
109
- self._update_panel_indicator(self._focused_panel or "sprint")
303
+ # Hide all panels except the active one
304
+ for panel_key in _PANEL_KEYS:
305
+ widget_id = f"panel-{panel_key}"
306
+ try:
307
+ widget = self.query_one(f"#{widget_id}")
308
+ widget.display = (panel_key == initial)
309
+ except Exception:
310
+ pass
311
+
312
+ # Set tab bar active state
313
+ self._update_tab_bar(initial)
110
314
 
111
315
  if self._client is not None:
112
316
  self._client.on_state_change(self._on_ws_state_change)
113
317
  self._client.subscribe("focus", self._handle_focus_message)
318
+ self._client.subscribe("persona", self._handle_persona_message)
114
319
  self.run_worker(self._client.connect(), exclusive=True, name="ws-client")
115
320
 
116
- def _update_panel_indicator(self, panel_key: str) -> None:
117
- """Update the panel indicator widget with the given panel key."""
321
+ def action_switch_panel(self, key: str) -> None:
322
+ """Switch to a panel by key."""
323
+ if key not in _PANEL_KEYS:
324
+ return
325
+ if key == self._focused_panel:
326
+ return
327
+
328
+ # Hide current panel
329
+ try:
330
+ current = self.query_one(f"#panel-{self._focused_panel}")
331
+ current.display = False
332
+ except Exception:
333
+ pass
334
+
335
+ # Show target panel
336
+ try:
337
+ target = self.query_one(f"#panel-{key}")
338
+ target.display = True
339
+ except Exception:
340
+ pass
341
+
342
+ self._previous_panel = self._focused_panel
343
+ self._focused_panel = key
344
+ save_last_panel(key, project_dir=None)
345
+ self._update_tab_bar(key)
346
+
347
+ def action_next_panel(self) -> None:
348
+ """Cycle to the next panel."""
118
349
  try:
119
- indicator = self.query_one("#panel-indicator", PanelIndicator)
120
- indicator.panel_key = panel_key
350
+ idx = _PANEL_KEYS.index(self._focused_panel)
351
+ except ValueError:
352
+ idx = 0
353
+ next_idx = (idx + 1) % len(_PANEL_KEYS)
354
+ self.action_switch_panel(_PANEL_KEYS[next_idx])
355
+
356
+ def action_prev_panel(self) -> None:
357
+ """Cycle to the previous panel."""
358
+ try:
359
+ idx = _PANEL_KEYS.index(self._focused_panel)
360
+ except ValueError:
361
+ idx = 0
362
+ prev_idx = (idx - 1) % len(_PANEL_KEYS)
363
+ self.action_switch_panel(_PANEL_KEYS[prev_idx])
364
+
365
+ def action_next_diff_file(self) -> None:
366
+ """Advance to next file in diffs panel."""
367
+ if self._focused_panel == "diffs":
368
+ try:
369
+ panel = self.query_one("#panel-diffs", DiffsPanel)
370
+ panel.next_file()
371
+ except Exception:
372
+ pass
373
+
374
+ def action_prev_diff_file(self) -> None:
375
+ """Go to previous file in diffs panel."""
376
+ if self._focused_panel == "diffs":
377
+ try:
378
+ panel = self.query_one("#panel-diffs", DiffsPanel)
379
+ panel.prev_file()
380
+ except Exception:
381
+ pass
382
+
383
+ def action_next_epic(self) -> None:
384
+ """Move to next epic in sprint panel."""
385
+ if self._focused_panel == "sprint":
386
+ try:
387
+ panel = self.query_one("#panel-sprint", SprintPanel)
388
+ panel.next_epic()
389
+ except Exception:
390
+ pass
391
+
392
+ def action_prev_epic(self) -> None:
393
+ """Move to previous epic in sprint panel."""
394
+ if self._focused_panel == "sprint":
395
+ try:
396
+ panel = self.query_one("#panel-sprint", SprintPanel)
397
+ panel.prev_epic()
398
+ except Exception:
399
+ pass
400
+
401
+ def action_toggle_epic(self) -> None:
402
+ """Toggle expand/collapse on selected epic in sprint panel."""
403
+ if self._focused_panel == "sprint":
404
+ try:
405
+ panel = self.query_one("#panel-sprint", SprintPanel)
406
+ panel.toggle_epic()
407
+ except Exception:
408
+ pass
409
+
410
+ def _update_tab_bar(self, panel_key: str) -> None:
411
+ """Update the tab bar widget with the given panel key."""
412
+ try:
413
+ tab_bar = self.query_one("#tab-bar", PanelTabBar)
414
+ tab_bar.active = panel_key
121
415
  except Exception:
122
416
  pass
123
417
 
@@ -135,14 +429,23 @@ class BikeRackApp(App):
135
429
  return
136
430
 
137
431
  focus = message["focus"]
138
- if focus is not None:
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
139
436
  self._previous_panel = self._focused_panel
140
437
  self._focused_panel = focus
141
438
  save_last_panel(focus, project_dir=None)
142
- self._update_panel_indicator(focus)
143
- else:
144
- self._focused_panel = None
145
- self._previous_panel = None
439
+
440
+ def _handle_persona_message(self, message: dict[str, Any] | None) -> None:
441
+ """Handle incoming persona channel messages."""
442
+ if message is None or not isinstance(message, dict):
443
+ return
444
+ try:
445
+ header = self.query_one("#agent-header", AgentHeader)
446
+ header._apply_persona(message)
447
+ except Exception:
448
+ pass
146
449
 
147
450
  def _on_ws_state_change(self, state: ConnectionState) -> None:
148
451
  """Handle WheelHub connection state changes."""
@@ -156,16 +459,19 @@ class BikeRackApp(App):
156
459
  DEFAULT_PORT = 2898
157
460
 
158
461
 
159
- def main(port: int | None = None, project_dir: Path | None = None) -> None:
462
+ def main(
463
+ port: int | None = None,
464
+ project_dir: Path | None = None,
465
+ ) -> None:
160
466
  """Launch BikeRack TUI as a standalone application.
161
467
 
162
468
  Args:
163
- port: Explicit WheelHub port. If None, reads from .bikerack-port file.
469
+ port: Explicit WheelHub port. If None, reads from .wheelhub-port file.
164
470
  project_dir: Project directory for port file discovery. Defaults to cwd.
165
471
  """
166
472
  if port is None:
167
473
  if project_dir is not None:
168
- port_file = project_dir / ".bikerack-port"
474
+ port_file = project_dir / ".wheelhub-port"
169
475
  if port_file.exists():
170
476
  try:
171
477
  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 .bikerack-port file, fallback to DEFAULT_PORT.
78
+ """Read port from .wheelhub-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 / ".bikerack-port"
85
+ port_file = self._project_dir / ".wheelhub-port"
86
86
  if port_file.exists():
87
87
  try:
88
88
  return int(port_file.read_text().strip())
@@ -137,6 +137,11 @@ from pennyfarthing_scripts.epic.cli import epic # noqa: E402
137
137
 
138
138
  cli.add_command(epic)
139
139
 
140
+ # Import and register consultation group
141
+ from pennyfarthing_scripts.consultation.cli import consultation # noqa: E402
142
+
143
+ cli.add_command(consultation)
144
+
140
145
 
141
146
  @cli.group()
142
147
  def agent():
@@ -144,7 +149,8 @@ def agent():
144
149
 
145
150
  \b
146
151
  Commands:
147
- start - Start an agent session with context
152
+ start - Start an agent session with context
153
+ heatmap - Visualize context distribution and attention
148
154
  """
149
155
  pass
150
156
 
@@ -193,78 +199,44 @@ def agent_start(
193
199
  raise SystemExit(exit_code)
194
200
 
195
201
 
196
- @cli.group()
197
- def workflow():
198
- """Workflow state and phase management.
199
-
200
- \b
201
- Commands:
202
- check - Check current workflow state
203
- phase-check - Verify phase ownership
204
- handoff - Emit handoff marker
205
- """
206
- pass
207
-
208
-
209
- @workflow.command("check")
210
- @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
211
- def workflow_check(output_json: bool):
212
- """Check current workflow state.
213
-
214
- Returns the current story ID, phase, and workflow state.
215
- """
216
- # Lazy import - only load when command is actually invoked
217
- from pennyfarthing_scripts.workflow import get_workflow_state
218
-
219
- state = get_workflow_state()
220
-
221
- if output_json:
222
- import json
223
-
224
- click.echo(json.dumps(state, indent=2))
225
- else:
226
- click.echo(f"State: {state.get('state', 'unknown')}")
227
- if state.get("story_id"):
228
- click.echo(f"Story: {state['story_id']}")
229
- if state.get("workflow"):
230
- click.echo(f"Workflow: {state['workflow']}")
231
- if state.get("phase"):
232
- click.echo(f"Phase: {state['phase']}")
233
-
202
+ @agent.command("heatmap")
203
+ @click.argument("name", required=False)
204
+ @click.option("--all", "show_all", is_flag=True, help="Show summary across all primary agents")
205
+ @click.option("--csv", "csv_output", is_flag=True, help="Output CSV for machine consumption")
206
+ @click.option("--json", "json_output", is_flag=True, help="Output JSON")
207
+ def agent_heatmap(
208
+ name: str | None,
209
+ show_all: bool,
210
+ csv_output: bool,
211
+ json_output: bool,
212
+ ):
213
+ """Visualize context distribution and attention for agent activation.
234
214
 
235
- @workflow.command("phase-check")
236
- @click.argument("workflow_name")
237
- @click.argument("phase")
238
- def workflow_phase_check(workflow_name: str, phase: str):
239
- """Check which agent owns a workflow phase.
215
+ Shows a heat map of how tokens are distributed across sections of an
216
+ agent's activation context, with attention scores based on the
217
+ "Lost in the Middle" U-shaped attention model.
240
218
 
241
219
  \b
242
- Arguments:
243
- WORKFLOW_NAME - The workflow type (tdd, trivial, etc.)
244
- PHASE - The phase to check (red, implement, review, etc.)
220
+ Examples:
221
+ pf agent heatmap sm # Detailed view for SM
222
+ pf agent heatmap --all # Summary across all agents
223
+ pf agent heatmap dev --json # JSON output for tooling
245
224
  """
246
- # Lazy import
247
- from pennyfarthing_scripts.workflow import get_phase_owner
225
+ from pennyfarthing_scripts.prime.heatmap import run_heatmap
248
226
 
249
- owner = get_phase_owner(workflow_name, phase)
250
- click.echo(owner)
227
+ exit_code = run_heatmap(
228
+ agent_name=name,
229
+ show_all=show_all,
230
+ csv_output=csv_output,
231
+ json_output=json_output,
232
+ )
233
+ raise SystemExit(exit_code)
251
234
 
252
235
 
253
- @workflow.command("handoff")
254
- @click.argument("next_agent")
255
- def workflow_handoff(next_agent: str):
256
- """Emit a handoff marker for Cyclist.
236
+ # Import and register workflow group
237
+ from pennyfarthing_scripts.workflow.cli import workflow # noqa: E402
257
238
 
258
- \b
259
- Arguments:
260
- NEXT_AGENT - The agent to hand off to (tea, dev, reviewer, etc.)
261
- """
262
- # Output the marker format expected by Cyclist
263
- click.echo("---")
264
- click.echo("AGENT_COMMAND:")
265
- click.echo(f' marker: "<!-- CYCLIST:HANDOFF:/{next_agent} -->"')
266
- click.echo(f' fallback: "Run `/{next_agent}` to continue"')
267
- click.echo("---")
239
+ cli.add_command(workflow)
268
240
 
269
241
 
270
242
  @cli.command("help")
@@ -0,0 +1 @@
1
+ """Consultation package — Dialogue file management for tandem agent consultation."""