@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
@@ -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
@@ -9,10 +9,26 @@ from __future__ import annotations
9
9
  from typing import Any
10
10
 
11
11
  from rich.console import Group
12
- from rich.table import Table
12
+ from rich.padding import Padding
13
13
  from rich.text import Text
14
14
 
15
- from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
15
+ from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel, render_progress_bar
16
+
17
+
18
+ def _status_badge(status: str) -> Text:
19
+ """Convert status string to styled Rich Text badge."""
20
+ s = status.lower().strip() if status else ""
21
+ if s == "done":
22
+ return Text("\u2713 done", style="green")
23
+ if s == "in-progress":
24
+ return Text("\u27f3 in-progress", style="yellow")
25
+ if s == "backlog":
26
+ return Text("\u25ef backlog", style="dim")
27
+ if s == "blocked":
28
+ return Text("! blocked", style="bold red")
29
+ if s == "review":
30
+ return Text("\u25ce review", style="cyan")
31
+ return Text(status or "\u2014", style="dim")
16
32
 
17
33
 
18
34
  class SprintPanel(BasePanel):
@@ -26,15 +42,83 @@ class SprintPanel(BasePanel):
26
42
  panel_name: str = "Sprint"
27
43
  icon: str = PANEL_ICONS["sprint"][0]
28
44
 
29
- def render_panel(self, payload: dict[str, Any]) -> Any:
30
- """Render sprint data as Rich renderable.
45
+ def __init__(self, client: Any = None, **kwargs: Any) -> None:
46
+ super().__init__(client=client, **kwargs)
47
+ self._selected_epic: int = 0
48
+ self._toggled: dict[str, bool] = {} # epic_id -> user override
49
+
50
+ def next_epic(self) -> None:
51
+ """Move selection to the next epic."""
52
+ epic_count = self._epic_count()
53
+ if epic_count == 0:
54
+ return
55
+ self._selected_epic = (self._selected_epic + 1) % epic_count
56
+ self._rerender()
57
+
58
+ def prev_epic(self) -> None:
59
+ """Move selection to the previous epic."""
60
+ epic_count = self._epic_count()
61
+ if epic_count == 0:
62
+ return
63
+ self._selected_epic = (self._selected_epic - 1) % epic_count
64
+ self._rerender()
65
+
66
+ def toggle_epic(self) -> None:
67
+ """Toggle expand/collapse on the selected epic."""
68
+ if self._last_payload is None:
69
+ return
70
+ epics = self._last_payload.get("epics", [])
71
+ if not epics or self._selected_epic >= len(epics):
72
+ return
73
+ epic_id = epics[self._selected_epic].get("id", "")
74
+ if epic_id:
75
+ self._toggled[epic_id] = not self._is_expanded(epics[self._selected_epic])
76
+ self._rerender()
77
+
78
+ def _rerender(self) -> None:
79
+ if self._last_payload is not None:
80
+ rendered = self.render_panel(self._last_payload)
81
+ try:
82
+ self.update(rendered)
83
+ except Exception:
84
+ pass
85
+
86
+ def _epic_count(self) -> int:
87
+ if self._last_payload is None:
88
+ return 0
89
+ return len(self._last_payload.get("epics", []))
90
+
91
+ def _is_expanded(self, epic: dict[str, Any]) -> bool:
92
+ """Check if an epic should be expanded."""
93
+ epic_id = epic.get("id", "")
94
+ if epic_id in self._toggled:
95
+ return self._toggled[epic_id]
96
+ # Default: expand if has incomplete work
97
+ stories = epic.get("stories", [])
98
+ total_pts = 0
99
+ done_pts = 0
100
+ has_in_progress = False
101
+ for story in stories:
102
+ pts = story.get("points", 0)
103
+ if isinstance(pts, (int, float)):
104
+ total_pts += pts
105
+ status = (story.get("status") or "").lower().strip()
106
+ if status == "done":
107
+ done_pts += pts
108
+ if status == "in-progress":
109
+ has_in_progress = True
110
+ return has_in_progress or done_pts < total_pts
31
111
 
32
- Returns a Group containing a sprint metrics header and a
33
- story table with columns: ID, Title, Status, Pts, Jira.
34
- """
112
+ def render_panel(self, payload: dict[str, Any]) -> Any:
113
+ """Render sprint data with epic grouping and progress bars."""
35
114
  sprint = payload.get("sprint", {})
36
115
  metrics = payload.get("metrics", {})
37
116
  epics = payload.get("epics", [])
117
+ current_story_id = sprint.get("currentStory", "")
118
+
119
+ # Clamp selection
120
+ if epics and self._selected_epic >= len(epics):
121
+ self._selected_epic = len(epics) - 1
38
122
 
39
123
  # Sprint metrics header
40
124
  sprint_num = sprint.get("number", "")
@@ -51,22 +135,70 @@ class SprintPanel(BasePanel):
51
135
  f"Velocity: {velocity}"
52
136
  )
53
137
 
54
- # Story table
55
- table = Table(title=sprint.get("name", "Sprint"))
56
- table.add_column("ID", style="cyan")
57
- table.add_column("Title")
58
- table.add_column("Status")
59
- table.add_column("Pts", justify="right")
60
- table.add_column("Jira", style="dim")
61
-
62
- for epic in epics:
63
- for story in epic.get("stories", []):
64
- table.add_row(
65
- story.get("id", ""),
66
- story.get("title", ""),
67
- story.get("status", ""),
68
- str(story.get("points", "")),
69
- story.get("jiraKey") or "",
70
- )
71
-
72
- return Group(header, table)
138
+ hint = Text.from_markup("[dim]j/k:navigate e:expand/collapse[/dim]")
139
+ parts: list[Any] = [header, hint, Text("")]
140
+
141
+ for i, epic in enumerate(epics):
142
+ epic_id = epic.get("id", "")
143
+ epic_title = epic.get("title", "")
144
+ stories = epic.get("stories", [])
145
+
146
+ # Calculate epic progress
147
+ total_pts = 0
148
+ done_pts = 0
149
+ for story in stories:
150
+ pts = story.get("points", 0)
151
+ if isinstance(pts, (int, float)):
152
+ total_pts += pts
153
+ status = (story.get("status") or "").lower().strip()
154
+ if status == "done":
155
+ done_pts += pts
156
+
157
+ expanded = self._is_expanded(epic)
158
+ selected = i == self._selected_epic
159
+
160
+ # Epic header: selector arrow epic-id progress-bar pts title
161
+ arrow = "▼" if expanded else "▶"
162
+ epic_line = Text(no_wrap=True, overflow="ellipsis")
163
+ if selected:
164
+ epic_line.append("› ", style="bold yellow")
165
+ epic_line.append(f"{arrow} ", style="bold")
166
+ epic_line.append(f"{epic_id}", style="bold cyan")
167
+ epic_line.append(" ")
168
+
169
+ if total_pts > 0:
170
+ pct = int(done_pts / total_pts * 100)
171
+ epic_line.append_text(render_progress_bar(pct, width=10))
172
+ epic_line.append(f" {done_pts}/{total_pts} pts", style="dim")
173
+ else:
174
+ epic_line.append("0 pts", style="dim")
175
+
176
+ epic_line.append(f" {epic_title}", style="bold")
177
+
178
+ parts.append(epic_line)
179
+
180
+ # Show stories if expanded
181
+ if expanded:
182
+ for story in stories:
183
+ story_id = story.get("id", "")
184
+ title = story.get("title", "")
185
+ pts = story.get("points", "")
186
+ jira = story.get("jiraKey") or "—"
187
+ badge = _status_badge(story.get("status", ""))
188
+
189
+ # Fixed-width fields first, title last (truncates)
190
+ story_line = Text(no_wrap=True, overflow="ellipsis")
191
+ story_line.append_text(badge)
192
+ story_line.append(f" {story_id}", style="cyan" if story_id != current_story_id else "bold cyan")
193
+ story_line.append(f" {jira}", style="dim")
194
+ story_line.append(f" {pts}", style="dim")
195
+ story_line.append(f" {title}")
196
+
197
+ if story_id == current_story_id:
198
+ story_line.stylize("bold")
199
+
200
+ parts.append(Padding(story_line, (0, 0, 0, 4)))
201
+
202
+ parts.append(Text("")) # spacer between epics
203
+
204
+ return Group(*parts)