savepoint 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/.savepoint/Design.md +22 -17
  3. package/.savepoint/audit/v1/E01/proposals.md +168 -0
  4. package/.savepoint/audit/v1/E01/snapshot.md +78 -0
  5. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
  6. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
  7. package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
  8. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
  9. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
  10. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
  11. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
  12. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
  13. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
  14. package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
  15. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
  16. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
  17. package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/proposals.md +2 -2
  18. package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
  19. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
  20. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
  21. package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
  22. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
  23. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  24. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  25. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  26. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  27. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
  28. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  29. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  30. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  31. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
  32. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
  33. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
  34. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
  35. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
  36. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
  37. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
  38. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
  39. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  40. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
  41. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
  42. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
  43. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
  44. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
  45. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
  46. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  47. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  48. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  49. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  50. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  51. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  52. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  53. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  54. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  55. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
  56. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
  57. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
  58. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
  59. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
  60. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
  61. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
  62. package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
  63. package/.savepoint/router.md +33 -105
  64. package/AGENTS.md +56 -113
  65. package/Makefile +19 -3
  66. package/README.md +6 -5
  67. package/agent-skills/savepoint-audit/SKILL.md +6 -6
  68. package/agent-skills/savepoint-build-task/SKILL.md +2 -2
  69. package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
  70. package/agent-skills/savepoint-create-task/SKILL.md +2 -2
  71. package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
  72. package/agent-skills/savepoint-system-design/SKILL.md +1 -1
  73. package/internal/board/board.go +43 -27
  74. package/internal/board/board_test.go +71 -0
  75. package/internal/board/card.go +34 -3
  76. package/internal/board/card_test.go +105 -12
  77. package/internal/board/column.go +40 -5
  78. package/internal/board/column_test.go +60 -13
  79. package/internal/board/detail.go +107 -25
  80. package/internal/board/detail_test.go +117 -26
  81. package/internal/board/epic_panel.go +105 -8
  82. package/internal/board/epic_panel_test.go +343 -5
  83. package/internal/board/layout.go +12 -2
  84. package/internal/board/layout_test.go +17 -0
  85. package/internal/board/model.go +141 -24
  86. package/internal/board/render_policy_test.go +77 -0
  87. package/internal/board/status.go +23 -0
  88. package/internal/board/update.go +276 -8
  89. package/internal/board/update_test.go +166 -0
  90. package/internal/board/view.go +131 -17
  91. package/internal/board/view_test.go +159 -1
  92. package/internal/board/watch.go +24 -6
  93. package/internal/buildtool/main.go +219 -0
  94. package/internal/data/parser.go +8 -0
  95. package/internal/data/parser_test.go +35 -0
  96. package/internal/data/task.go +10 -0
  97. package/internal/styles/palette.go +3 -3
  98. package/internal/styles/styles.go +39 -12
  99. package/main.go +9 -0
  100. package/package.json +1 -1
  101. package/savepoint +0 -0
  102. package/savepoint.exe +0 -0
  103. package/templates/project/.savepoint/router.md +6 -5
  104. package/templates/project/AGENTS.md +47 -101
  105. package/templates/prompts/audit-reconciliation.prompt.md +6 -6
  106. package/templates/prompts/epic-design.prompt.md +3 -3
  107. package/templates/prompts/task-breakdown.prompt.md +1 -1
  108. package/templates/prompts/task-building.prompt.md +1 -1
  109. package/templates/prompts/task-planning.prompt.md +1 -1
  110. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
  111. package/main.exe +0 -0
  112. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
  113. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
  114. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
  115. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
  116. /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
  117. /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
  118. /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
  119. /package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/snapshot.md +0 -0
  120. /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
  121. /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
  122. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  123. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  124. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  125. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  126. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  127. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
@@ -1,6 +1,9 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
4
7
  "testing"
5
8
 
6
9
  tea "github.com/charmbracelet/bubbletea"
@@ -90,6 +93,54 @@ func TestUpdate_downMovesTaskFocus(t *testing.T) {
90
93
  }
91
94
  }
92
95
 
96
+ func TestUpdate_downAutoScrollsFocusedTaskIntoViewport(t *testing.T) {
97
+ tasks := []data.Task{
98
+ {ID: "T1", Column: data.ColumnPlanned},
99
+ {ID: "T2", Column: data.ColumnPlanned},
100
+ {ID: "T3", Column: data.ColumnPlanned},
101
+ {ID: "T4", Column: data.ColumnPlanned},
102
+ {ID: "T5", Column: data.ColumnPlanned},
103
+ }
104
+ m := NewModel(tasks, "v1", "E03")
105
+ m.Width = 100
106
+ m.Height = 24
107
+ m.FocusedTask = 3
108
+
109
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
110
+ updated := requireModel(t, got)
111
+
112
+ if updated.FocusedTask != 4 {
113
+ t.Errorf("FocusedTask = %d, want 4", updated.FocusedTask)
114
+ }
115
+ if updated.ColumnOffsets[data.ColumnPlanned] != 1 {
116
+ t.Errorf("ColumnOffsets[planned] = %d, want 1", updated.ColumnOffsets[data.ColumnPlanned])
117
+ }
118
+ }
119
+
120
+ func TestUpdate_pageDownScrollsFocusedColumnByPage(t *testing.T) {
121
+ tasks := []data.Task{
122
+ {ID: "T1", Column: data.ColumnPlanned},
123
+ {ID: "T2", Column: data.ColumnPlanned},
124
+ {ID: "T3", Column: data.ColumnPlanned},
125
+ {ID: "T4", Column: data.ColumnPlanned},
126
+ {ID: "T5", Column: data.ColumnPlanned},
127
+ {ID: "T6", Column: data.ColumnPlanned},
128
+ }
129
+ m := NewModel(tasks, "v1", "E03")
130
+ m.Width = 100
131
+ m.Height = 24
132
+
133
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
134
+ updated := requireModel(t, got)
135
+
136
+ if updated.ColumnOffsets[data.ColumnPlanned] != 2 {
137
+ t.Errorf("ColumnOffsets[planned] = %d, want 2", updated.ColumnOffsets[data.ColumnPlanned])
138
+ }
139
+ if updated.FocusedTask != 2 {
140
+ t.Errorf("FocusedTask = %d, want 2", updated.FocusedTask)
141
+ }
142
+ }
143
+
93
144
  func TestUpdate_upMovesTaskFocus(t *testing.T) {
94
145
  tasks := []data.Task{
95
146
  {ID: "T1", Column: data.ColumnPlanned},
@@ -126,3 +177,118 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
126
177
  t.Errorf("Width changed unexpectedly: %d", updated.Width)
127
178
  }
128
179
  }
180
+
181
+ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
182
+ root := writeRouterFixture(t)
183
+ tasks := []data.Task{
184
+ {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
185
+ {ID: "E05-tasking-permissions/T005-update-help-overlay", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
186
+ }
187
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
188
+ m.Root = root
189
+
190
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
191
+ updated := requireModel(t, got)
192
+
193
+ if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
194
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
195
+ }
196
+ state := readRouterFixture(t, root)
197
+ if state.State != "task-building" {
198
+ t.Errorf("router state = %q, want task-building", state.State)
199
+ }
200
+ if state.Release != "v1.1" {
201
+ t.Errorf("router release = %q, want v1.1", state.Release)
202
+ }
203
+ if state.Epic != "E05-tasking-permissions" {
204
+ t.Errorf("router epic = %q, want E05-tasking-permissions", state.Epic)
205
+ }
206
+ if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
207
+ t.Errorf("router task = %q, want focused task", state.Task)
208
+ }
209
+ }
210
+
211
+ func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
212
+ root := writeRouterFixture(t)
213
+ tasks := []data.Task{
214
+ {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
215
+ {ID: "E05-tasking-permissions/T003-update-design-md", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone},
216
+ }
217
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
218
+ m.Root = root
219
+
220
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
221
+ updated := requireModel(t, got)
222
+
223
+ if updated.StatusMessage != "Audit pending for E05-tasking-permissions" {
224
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
225
+ }
226
+ state := readRouterFixture(t, root)
227
+ if state.State != "audit-pending" {
228
+ t.Errorf("router state = %q, want audit-pending", state.State)
229
+ }
230
+ if state.Task != "" {
231
+ t.Errorf("router task = %q, want empty", state.Task)
232
+ }
233
+ }
234
+
235
+ func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
236
+ root := writeRouterFixture(t)
237
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
238
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
239
+ m.Root = root
240
+ m.Overlay = OverlayHelp
241
+
242
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
243
+ updated := requireModel(t, got)
244
+
245
+ if updated.StatusMessage != "" {
246
+ t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
247
+ }
248
+ state := readRouterFixture(t, root)
249
+ if state.Task != "T001" {
250
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
251
+ }
252
+ }
253
+
254
+ func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
255
+ root := writeRouterFixture(t)
256
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
257
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
258
+ m.Root = root
259
+ m.FocusedColumn = data.ColumnDone
260
+
261
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
262
+ updated := requireModel(t, got)
263
+
264
+ if updated.StatusMessage != "Router not updated: focused task is done" {
265
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
266
+ }
267
+ state := readRouterFixture(t, root)
268
+ if state.Task != "T001" {
269
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
270
+ }
271
+ }
272
+
273
+ func writeRouterFixture(t *testing.T) string {
274
+ t.Helper()
275
+ root := t.TempDir()
276
+ content := "# Agent State Machine\n\n## Current state\n\n```yaml\nstate: task-building\nrelease: v1.1\nepic: E05\ntask: T001\nnext_action: Build T001.\n```\n"
277
+ if err := os.WriteFile(filepath.Join(root, "router.md"), []byte(content), 0644); err != nil {
278
+ t.Fatal(err)
279
+ }
280
+ return root
281
+ }
282
+
283
+ func readRouterFixture(t *testing.T, root string) *data.RouterState {
284
+ t.Helper()
285
+ content, err := os.ReadFile(filepath.Join(root, "router.md"))
286
+ if err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ state, err := data.NewRouterReader().ReadState(string(content))
290
+ if err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ return state
294
+ }
@@ -1,6 +1,7 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "fmt"
4
5
  "strings"
5
6
 
6
7
  "github.com/charmbracelet/lipgloss"
@@ -19,21 +20,25 @@ func (m Model) View() string {
19
20
  w = defaultTermW
20
21
  }
21
22
  m.Width = w
23
+ h := m.Height
24
+ if h == 0 {
25
+ h = defaultTermH
26
+ }
27
+ m.Height = h
22
28
 
23
- layout := CalculateLayout(w, m.Height)
24
- icon := styles.HeaderIcon.Render("▣")
25
- text := styles.HeaderText.Render("S A V E P O I N T")
26
- header := styles.HeaderFrame.Width(w).Render(icon + " " + text)
29
+ header := m.renderHeader(w)
30
+ nextActivity := m.renderNextActivityLine(w)
31
+ layout := CalculateLayoutWithChrome(w, h, extraHeaderLines(nextActivity))
27
32
  topDivider := dividerLine(w)
28
33
  board := m.renderBoard(layout)
29
34
  bottomDivider := dividerLine(w)
30
35
  footer := m.renderFooter(w)
31
- base := lipgloss.JoinVertical(lipgloss.Left, header, topDivider, board, bottomDivider, footer)
32
-
33
- h := m.Height
34
- if h == 0 {
35
- h = defaultTermH
36
+ sections := []string{header}
37
+ if nextActivity != "" {
38
+ sections = append(sections, nextActivity)
36
39
  }
40
+ sections = append(sections, topDivider, board, bottomDivider, footer)
41
+ base := lipgloss.JoinVertical(lipgloss.Left, sections...)
37
42
 
38
43
  if m.Overlay == OverlayEpic {
39
44
  overlay := RenderEpicDropdown(m.Epics, m.EpicCursor, min(40, w))
@@ -56,13 +61,111 @@ func (m Model) View() string {
56
61
  return base
57
62
  }
58
63
  ow := overlayWidth(w)
59
- detail := RenderDetail(task, ow, m.RouterTask)
64
+ detail := RenderDetail(task, ow, m.RouterState, detailMaxHeight(h), m.DetailOffset)
65
+ return overlayOnBase(dimLines(base), detail, w, h)
66
+ }
67
+
68
+ if m.Overlay == OverlayEpicDetail {
69
+ ow := overlayWidth(w)
70
+ epicSlug := ""
71
+ if m.EpicPanelCursor >= 0 && m.EpicPanelCursor < len(m.Epics) {
72
+ epicSlug = m.Epics[m.EpicPanelCursor]
73
+ }
74
+ detail := RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset)
60
75
  return overlayOnBase(dimLines(base), detail, w, h)
61
76
  }
62
77
 
63
78
  return base
64
79
  }
65
80
 
81
+ func (m Model) renderHeader(w int) string {
82
+ icon := styles.HeaderIcon.Render("▣")
83
+ text := styles.HeaderText.Render("S A V E P O I N T")
84
+ left := icon + " " + text
85
+ return styles.HeaderFrame.Width(w).Render(left)
86
+ }
87
+
88
+ func extraHeaderLines(line string) int {
89
+ if line == "" {
90
+ return 0
91
+ }
92
+ return 1
93
+ }
94
+
95
+ func (m Model) renderNextActivityLine(w int) string {
96
+ if w <= 0 {
97
+ w = defaultTermW
98
+ }
99
+ return renderNextActivityLine(m.RouterState, w)
100
+ }
101
+
102
+ func renderNextActivityLine(state *data.RouterState, w int) string {
103
+ tag, style, ok := nextActivityPhase(state)
104
+ if !ok || strings.TrimSpace(state.NextAction) == "" {
105
+ return ""
106
+ }
107
+
108
+ content := style.Render(tag+":") + " " + state.NextAction
109
+ if lipgloss.Width(content) > w {
110
+ content = xansi.Truncate(content, w, "…")
111
+ }
112
+ return styles.RootLine.Width(w).Render(content)
113
+ }
114
+
115
+ func nextActivityPhase(state *data.RouterState) (string, lipgloss.Style, bool) {
116
+ if state == nil {
117
+ return "", lipgloss.Style{}, false
118
+ }
119
+ switch state.State {
120
+ case "task-building":
121
+ return "BUILD", styles.FooterPhaseBuild, true
122
+ case "audit-pending":
123
+ return "AUDIT", styles.FooterPhaseAudit, true
124
+ case "pre-implementation", "epic-design", "epic-task-breakdown":
125
+ return "PLAN", styles.FooterPhasePlan, true
126
+ default:
127
+ return "", lipgloss.Style{}, false
128
+ }
129
+ }
130
+
131
+ // FormatNextActivity formats a compact activity string from router state.
132
+ // Returns empty string when state is nil. Result is capped at 20 visible chars.
133
+ func FormatNextActivity(state *data.RouterState) string {
134
+ if state == nil {
135
+ return ""
136
+ }
137
+ var s string
138
+ switch state.State {
139
+ case "task-building":
140
+ s = fmt.Sprintf("Build %s %s/%s", state.Release, shortRouterID(state.Epic), shortRouterID(state.Task))
141
+ case "audit-pending":
142
+ s = fmt.Sprintf("Audit %s", shortRouterID(state.Epic))
143
+ case "epic-design":
144
+ s = fmt.Sprintf("Design %s", shortRouterID(state.Epic))
145
+ case "epic-task-breakdown":
146
+ s = fmt.Sprintf("Plan %s", shortRouterID(state.Epic))
147
+ case "pre-implementation":
148
+ s = fmt.Sprintf("Planning %s", state.Release)
149
+ default:
150
+ s = state.State
151
+ }
152
+ return xansi.Truncate(s, 20, "…")
153
+ }
154
+
155
+ // shortRouterID extracts the compact prefix from a full router slug.
156
+ // "E01-tui-optimisation/T001-border-resize-fix" → "T001"
157
+ // "E01-tui-optimisation" → "E01"
158
+ func shortRouterID(full string) string {
159
+ part := full
160
+ if i := strings.LastIndex(full, "/"); i >= 0 {
161
+ part = full[i+1:]
162
+ }
163
+ if i := strings.Index(part, "-"); i >= 0 {
164
+ return part[:i]
165
+ }
166
+ return part
167
+ }
168
+
66
169
  func (m Model) focusedTask() (data.Task, bool) {
67
170
  tasks := m.Tasks[m.FocusedColumn]
68
171
  if len(tasks) == 0 || m.FocusedTask >= len(tasks) {
@@ -150,23 +253,34 @@ func (m Model) renderBoard(layout Layout) string {
150
253
 
151
254
  func (m Model) renderColumns(layout Layout) string {
152
255
  if layout.ColCount == 1 {
153
- return m.renderColumn(m.FocusedColumn, layout.ColWidths[0])
256
+ return m.renderColumn(m.FocusedColumn, layout.ColWidths[0], layout.ContentHeight)
154
257
  }
155
258
  allCols := []data.ColumnType{data.ColumnPlanned, data.ColumnInProgress, data.ColumnDone}
156
259
  rendered := make([]string, len(allCols))
157
260
  for i, col := range allCols {
158
- rendered[i] = m.renderColumn(col, layout.ColWidths[i])
261
+ rendered[i] = m.renderColumn(col, layout.ColWidths[i], layout.ContentHeight)
159
262
  }
160
263
  return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
161
264
  }
162
265
 
163
266
  func (m Model) renderEpicPanel(w int) string {
164
- return RenderEpicSidebar(m.Epics, m.SelectedEpic, w)
267
+ return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
268
+ }
269
+
270
+ func (m Model) renderColumn(col data.ColumnType, colW, maxHeight int) string {
271
+ focused := !m.EpicPanelFocus && m.FocusedColumn == col
272
+ return RenderColumn(m.Tasks[col], col, colW, maxHeight, m.ColumnOffsets[col], m.FocusedTask, focused, m.RouterState)
165
273
  }
166
274
 
167
- func (m Model) renderColumn(col data.ColumnType, colW int) string {
168
- focused := m.FocusedColumn == col
169
- return RenderColumn(m.Tasks[col], col, colW, m.FocusedTask, focused, m.RouterTask)
275
+ func detailMaxHeight(termH int) int {
276
+ if termH <= 0 {
277
+ termH = defaultTermH
278
+ }
279
+ h := termH * 7 / 10
280
+ if h < 6 {
281
+ h = 6
282
+ }
283
+ return h
170
284
  }
171
285
 
172
286
  func (m Model) renderFooter(termW int) string {
@@ -196,5 +310,5 @@ func footerLine(termW int, content string) string {
196
310
  if lipgloss.Width(content) > termW {
197
311
  content = xansi.Truncate(content, termW, "")
198
312
  }
199
- return lipgloss.NewStyle().Width(termW).Align(lipgloss.Center).Render(content)
313
+ return styles.RootLine.Width(termW).Align(lipgloss.Center).Render(content)
200
314
  }
@@ -74,7 +74,7 @@ func TestView_containsFooterHints(t *testing.T) {
74
74
  if len(lines) != 3 {
75
75
  t.Fatalf("renderFooter() returned %d lines, want 3", len(lines))
76
76
  }
77
- if strings.TrimSpace(lines[1]) != "" {
77
+ if strings.TrimSpace(plainTerminal(lines[1])) != "" {
78
78
  t.Fatalf("renderFooter() spacer line = %q, want blank", lines[1])
79
79
  }
80
80
  for i, line := range lines {
@@ -133,6 +133,164 @@ func TestView_wideShowsEpicPanel(t *testing.T) {
133
133
  }
134
134
  }
135
135
 
136
+ func TestFormatNextActivity_nil(t *testing.T) {
137
+ if got := FormatNextActivity(nil); got != "" {
138
+ t.Errorf("FormatNextActivity(nil) = %q, want empty", got)
139
+ }
140
+ }
141
+
142
+ func TestFormatNextActivity_states(t *testing.T) {
143
+ cases := []struct {
144
+ state *data.RouterState
145
+ want string
146
+ }{
147
+ {&data.RouterState{State: "task-building", Task: "T010", Epic: "E06", Release: "v1"}, "Build v1 E06/T010"},
148
+ {&data.RouterState{State: "audit-pending", Epic: "E06"}, "Audit E06"},
149
+ {&data.RouterState{State: "epic-design", Epic: "E06"}, "Design E06"},
150
+ {&data.RouterState{State: "epic-task-breakdown", Epic: "E06"}, "Plan E06"},
151
+ {&data.RouterState{State: "pre-implementation", Release: "v1"}, "Planning v1"},
152
+ }
153
+ for _, c := range cases {
154
+ got := FormatNextActivity(c.state)
155
+ if got != c.want {
156
+ t.Errorf("FormatNextActivity(%q) = %q, want %q", c.state.State, got, c.want)
157
+ }
158
+ }
159
+ }
160
+
161
+ func TestFormatNextActivity_truncation(t *testing.T) {
162
+ state := &data.RouterState{State: "task-building", Task: "T001", Epic: "E01-very-long-epic-name", Release: "v1.1"}
163
+ got := FormatNextActivity(state)
164
+ if lipgloss.Width(got) > 20 {
165
+ t.Errorf("FormatNextActivity truncation: width %d > 20, got %q", lipgloss.Width(got), got)
166
+ }
167
+ }
168
+
169
+ func TestFormatNextActivity_taskBuildingKeepsMinorReleaseVisible(t *testing.T) {
170
+ state := &data.RouterState{State: "task-building", Task: "T001", Epic: "E03", Release: "v1.1"}
171
+ got := FormatNextActivity(state)
172
+ if got != "Build v1.1 E03/T001" {
173
+ t.Errorf("FormatNextActivity() = %q, want Build v1.1 E03/T001", got)
174
+ }
175
+ }
176
+
177
+ func TestView_headerShowsNextActivity(t *testing.T) {
178
+ m := NewModel(nil, "v1", "E03")
179
+ m.Width = 120
180
+ m.RouterState = &data.RouterState{State: "audit-pending", NextAction: "Audit E06"}
181
+ got := m.View()
182
+ if !strings.Contains(got, "AUDIT:") {
183
+ t.Error("View() missing Next Activity phase tag")
184
+ }
185
+ if !strings.Contains(got, "Audit E06") {
186
+ t.Error("View() missing activity text below header")
187
+ }
188
+ if strings.Contains(got, "Next Activity:") {
189
+ t.Error("View() should not render Next Activity inside the header")
190
+ }
191
+ }
192
+
193
+ func TestView_nextActivityLineImmediatelyBelowHeader(t *testing.T) {
194
+ m := NewModel(nil, "v1", "E03")
195
+ m.Width = 120
196
+ m.RouterState = &data.RouterState{State: "task-building", NextAction: "Build T010 (E06) v1"}
197
+ got := m.View()
198
+
199
+ lines := strings.Split(got, "\n")
200
+ headerIndex := -1
201
+ activityIndex := -1
202
+ dividerIndex := -1
203
+ for i, line := range lines {
204
+ if strings.Contains(line, "S A V E P O I N T") {
205
+ headerIndex = i
206
+ }
207
+ if strings.Contains(line, "BUILD:") && strings.Contains(line, "Build T010 (E06) v1") {
208
+ activityIndex = i
209
+ }
210
+ if dividerIndex == -1 && strings.Contains(line, strings.Repeat("─", 120)) {
211
+ dividerIndex = i
212
+ }
213
+ }
214
+ if headerIndex == -1 || activityIndex == -1 || dividerIndex == -1 {
215
+ t.Fatalf("View() missing expected header/activity/divider lines: header=%d activity=%d divider=%d", headerIndex, activityIndex, dividerIndex)
216
+ }
217
+ if !(headerIndex < activityIndex && activityIndex < dividerIndex) {
218
+ t.Fatalf("Next Activity line order invalid: header=%d activity=%d divider=%d", headerIndex, activityIndex, dividerIndex)
219
+ }
220
+ }
221
+
222
+ func TestView_headerNoActivityWhenNilState(t *testing.T) {
223
+ m := NewModel(nil, "v1", "E03")
224
+ m.Width = 120
225
+ m.RouterState = nil
226
+ got := m.View()
227
+ if strings.Contains(got, "Next Activity:") || strings.Contains(got, "BUILD:") || strings.Contains(got, "PLAN:") || strings.Contains(got, "AUDIT:") {
228
+ t.Error("View() should not show Next Activity line when RouterState is nil")
229
+ }
230
+ }
231
+
232
+ func TestView_headerNarrowWidth(t *testing.T) {
233
+ m := NewModel(nil, "v1", "E03")
234
+ m.Width = 40
235
+ m.RouterState = &data.RouterState{State: "audit-pending", NextAction: "Audit E06"}
236
+ got := m.View()
237
+ // Should not panic and header text should still be present
238
+ if !strings.Contains(got, "S A V E P O I N T") {
239
+ t.Error("View() at narrow width missing header text")
240
+ }
241
+ }
242
+
243
+ func TestRenderNextActivityLine_phaseMapping(t *testing.T) {
244
+ cases := []struct {
245
+ name string
246
+ state *data.RouterState
247
+ tag string
248
+ }{
249
+ {"build", &data.RouterState{State: "task-building", NextAction: "Build T010 (E06) v1"}, "BUILD:"},
250
+ {"audit", &data.RouterState{State: "audit-pending", NextAction: "Audit E03"}, "AUDIT:"},
251
+ {"pre implementation", &data.RouterState{State: "pre-implementation", NextAction: "Plan v1.1"}, "PLAN:"},
252
+ {"epic design", &data.RouterState{State: "epic-design", NextAction: "Design E03"}, "PLAN:"},
253
+ {"task breakdown", &data.RouterState{State: "epic-task-breakdown", NextAction: "Break down E03"}, "PLAN:"},
254
+ }
255
+ for _, tc := range cases {
256
+ t.Run(tc.name, func(t *testing.T) {
257
+ got := renderNextActivityLine(tc.state, 80)
258
+ if !strings.Contains(got, tc.tag) {
259
+ t.Fatalf("renderNextActivityLine() missing phase tag %q in %q", tc.tag, got)
260
+ }
261
+ if !strings.Contains(got, tc.state.NextAction) {
262
+ t.Fatalf("renderNextActivityLine() missing next_action %q in %q", tc.state.NextAction, got)
263
+ }
264
+ })
265
+ }
266
+ }
267
+
268
+ func TestRenderNextActivityLine_hiddenStates(t *testing.T) {
269
+ cases := []*data.RouterState{
270
+ nil,
271
+ {State: "idle", NextAction: "Wait"},
272
+ {State: "task-building", NextAction: ""},
273
+ }
274
+ for _, state := range cases {
275
+ if got := renderNextActivityLine(state, 80); got != "" {
276
+ t.Fatalf("renderNextActivityLine(%v) = %q, want empty", state, got)
277
+ }
278
+ }
279
+ }
280
+
281
+ func TestRenderNextActivityLine_truncatesAtNarrowWidth(t *testing.T) {
282
+ got := renderNextActivityLine(&data.RouterState{
283
+ State: "pre-implementation",
284
+ NextAction: "Build T010 (E06) v1 with a very long follow-up activity",
285
+ }, 18)
286
+ if lipgloss.Width(got) > 18 {
287
+ t.Fatalf("renderNextActivityLine() width = %d, want <= 18; got %q", lipgloss.Width(got), got)
288
+ }
289
+ if !strings.Contains(got, "PLAN:") || !strings.Contains(got, "…") {
290
+ t.Fatalf("renderNextActivityLine() = %q, want PLAN tag and ellipsis", got)
291
+ }
292
+ }
293
+
136
294
  func TestView_narrowShowsSingleColumn(t *testing.T) {
137
295
  m := NewModel(nil, "v1", "E03")
138
296
  m.Width = 60
@@ -11,26 +11,33 @@ import (
11
11
  )
12
12
 
13
13
  type fileChangeMsg struct{}
14
- type reloadMsg struct{ tasks []data.Task }
14
+ type reloadMsg struct {
15
+ tasks []data.Task
16
+ releases []string
17
+ releaseEpics map[string][]string
18
+ epicStatuses map[string]string
19
+ }
15
20
 
16
21
  // watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
17
22
  func watchFiles(w *fsnotify.Watcher) tea.Cmd {
18
23
  return func() tea.Msg {
19
24
  for {
20
25
  select {
21
- case _, ok := <-w.Events:
26
+ case event, ok := <-w.Events:
22
27
  if !ok {
23
28
  return nil
24
29
  }
30
+ watchCreatedDir(w, event)
25
31
  timer := time.NewTimer(100 * time.Millisecond)
26
32
  drain:
27
33
  for {
28
34
  select {
29
- case _, ok := <-w.Events:
35
+ case event, ok := <-w.Events:
30
36
  if !ok {
31
37
  timer.Stop()
32
38
  return nil
33
39
  }
40
+ watchCreatedDir(w, event)
34
41
  case <-timer.C:
35
42
  break drain
36
43
  }
@@ -47,11 +54,11 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
47
54
 
48
55
  func reloadTasks(root string) tea.Cmd {
49
56
  return func() tea.Msg {
50
- tasks, err := loadAllTasks(root)
57
+ tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
51
58
  if err != nil {
52
59
  return nil
53
60
  }
54
- return reloadMsg{tasks: tasks}
61
+ return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses}
55
62
  }
56
63
  }
57
64
 
@@ -61,7 +68,7 @@ func newWatcher(root string) (*fsnotify.Watcher, error) {
61
68
  if err != nil {
62
69
  return nil, err
63
70
  }
64
- releasesPath := filepath.Join(root, ".savepoint", "releases")
71
+ releasesPath := filepath.Join(root, "releases")
65
72
  if err := addDirsRecursive(w, releasesPath); err != nil {
66
73
  w.Close()
67
74
  return nil, err
@@ -69,6 +76,17 @@ func newWatcher(root string) (*fsnotify.Watcher, error) {
69
76
  return w, nil
70
77
  }
71
78
 
79
+ func watchCreatedDir(w *fsnotify.Watcher, event fsnotify.Event) {
80
+ if !event.Has(fsnotify.Create) {
81
+ return
82
+ }
83
+ info, err := os.Stat(event.Name)
84
+ if err != nil || !info.IsDir() {
85
+ return
86
+ }
87
+ _ = addDirsRecursive(w, event.Name)
88
+ }
89
+
72
90
  func addDirsRecursive(w *fsnotify.Watcher, root string) error {
73
91
  return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
74
92
  if err != nil {