savepoint 1.0.2 → 1.0.3

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 (233) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.golangci.yml +11 -0
  3. package/.savepoint/Design.md +37 -36
  4. package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
  5. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  6. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
  7. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
  8. package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
  9. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  10. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
  11. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
  12. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
  13. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
  14. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
  15. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
  16. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
  17. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  18. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  19. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  20. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  21. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  22. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  23. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  24. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  25. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  26. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  27. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  28. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  29. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  30. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  31. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  32. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  33. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  34. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  35. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  36. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  37. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  38. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  39. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  40. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  41. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  42. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  43. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  44. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  45. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  46. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  47. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  48. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  49. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  50. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  51. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  52. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  53. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  54. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  55. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  56. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  57. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  58. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  59. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  60. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  61. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  62. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  63. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  64. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  65. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  66. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  67. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  68. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  69. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  70. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  71. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  72. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  73. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  74. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  75. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  76. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  77. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  78. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  79. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  80. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  81. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  82. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  83. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  84. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
  85. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  86. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
  87. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  88. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  89. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  90. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  91. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
  92. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  93. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  94. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  95. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  96. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  97. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  98. package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
  99. package/.savepoint/router.md +9 -16
  100. package/AGENTS.md +38 -23
  101. package/README.md +0 -1
  102. package/agent-skills/savepoint-audit/SKILL.md +86 -34
  103. package/agent-skills/savepoint-build-task/SKILL.md +7 -2
  104. package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
  105. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  106. package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
  107. package/agent-skills/savepoint-system-design/SKILL.md +7 -2
  108. package/agent_skills_test.go +91 -0
  109. package/cmd/board.go +59 -0
  110. package/cmd/board_test.go +137 -0
  111. package/cmd/doctor.go +53 -0
  112. package/cmd/doctor_test.go +146 -0
  113. package/cmd/init.go +63 -0
  114. package/cmd/init_test.go +104 -0
  115. package/internal/board/board.go +40 -36
  116. package/internal/board/board_test.go +27 -82
  117. package/internal/board/card.go +43 -23
  118. package/internal/board/card_test.go +41 -5
  119. package/internal/board/column.go +44 -13
  120. package/internal/board/column_test.go +5 -2
  121. package/internal/board/detail.go +0 -47
  122. package/internal/board/epic_panel.go +118 -22
  123. package/internal/board/epic_panel_test.go +302 -17
  124. package/internal/board/help.go +1 -0
  125. package/internal/board/help_test.go +1 -0
  126. package/internal/board/integration_test.go +266 -0
  127. package/internal/board/interfaces.go +65 -0
  128. package/internal/board/interfaces_test.go +114 -0
  129. package/internal/board/io.go +93 -0
  130. package/internal/board/model.go +79 -118
  131. package/internal/board/plain.go +88 -0
  132. package/internal/board/plain_test.go +117 -0
  133. package/internal/board/release.go +1 -9
  134. package/internal/board/release_test.go +6 -6
  135. package/internal/board/status.go +4 -4
  136. package/internal/board/theme.go +24 -0
  137. package/internal/board/theme_test.go +31 -0
  138. package/internal/board/transitions.go +113 -88
  139. package/internal/board/transitions_test.go +164 -141
  140. package/internal/board/tui.go +32 -0
  141. package/internal/board/update.go +325 -215
  142. package/internal/board/update_test.go +299 -18
  143. package/internal/board/util.go +76 -0
  144. package/internal/board/view.go +31 -28
  145. package/internal/board/view_test.go +12 -2
  146. package/internal/board/watch.go +35 -5
  147. package/internal/buildtool/main.go +2 -10
  148. package/internal/buildtool/main_test.go +46 -0
  149. package/internal/data/config.go +17 -3
  150. package/internal/data/config_test.go +49 -0
  151. package/internal/data/discover.go +26 -0
  152. package/internal/data/discover_test.go +34 -10
  153. package/internal/data/errors.go +4 -0
  154. package/internal/data/lifecycle.go +13 -6
  155. package/internal/data/lifecycle_test.go +14 -11
  156. package/internal/data/parser.go +21 -6
  157. package/internal/data/parser_test.go +31 -7
  158. package/internal/data/task.go +0 -9
  159. package/internal/data/write.go +85 -11
  160. package/internal/data/write_test.go +167 -0
  161. package/internal/doctor/checks.go +567 -0
  162. package/internal/doctor/checks_test.go +716 -0
  163. package/internal/doctor/gates.go +193 -0
  164. package/internal/doctor/gates_test.go +166 -0
  165. package/internal/doctor/interfaces.go +64 -0
  166. package/internal/doctor/interfaces_test.go +104 -0
  167. package/internal/doctor/repairs.go +80 -0
  168. package/internal/doctor/repairs_test.go +81 -0
  169. package/internal/doctor/report.go +157 -0
  170. package/internal/doctor/report_test.go +89 -0
  171. package/internal/init/clipboard.go +146 -0
  172. package/internal/init/clipboard_test.go +74 -0
  173. package/internal/init/install.go +16 -0
  174. package/internal/init/integration_test.go +197 -0
  175. package/internal/init/prompt.go +14 -0
  176. package/internal/init/prompt_test.go +77 -0
  177. package/internal/init/scaffold.go +59 -0
  178. package/internal/init/scaffold_test.go +179 -0
  179. package/internal/init/template_freshness_test.go +56 -0
  180. package/internal/init/validate.go +85 -0
  181. package/internal/init/validate_test.go +141 -0
  182. package/internal/init/write.go +73 -0
  183. package/internal/init/write_test.go +91 -0
  184. package/internal/styles/styles_test.go +133 -0
  185. package/internal/testutil/fixture.go +113 -0
  186. package/internal/testutil/fs.go +26 -0
  187. package/main.go +101 -4
  188. package/package.json +2 -2
  189. package/project-audit/audit_report_glm_5.1.md +411 -0
  190. package/project-audit/audit_report_opus_4.6 +406 -0
  191. package/project-audit/consolidated-audit-report.md +456 -0
  192. package/savepoint +0 -0
  193. package/templates/project/.savepoint/Design.md +2 -2
  194. package/templates/project/.savepoint/router.md +10 -10
  195. package/templates/project/AGENTS.md +33 -21
  196. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  197. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  198. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  199. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  200. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  201. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  202. package/templates/prompts/audit-reconciliation.prompt.md +33 -28
  203. package/templates/prompts/design.prompt.md +3 -1
  204. package/.savepoint/audit/v1/E01/proposals.md +0 -168
  205. package/.savepoint/audit/v1/E01/snapshot.md +0 -78
  206. package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
  207. package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
  208. package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
  209. package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
  210. package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
  211. package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
  212. package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
  213. package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
  214. package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
  215. package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
  216. package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
  217. package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
  218. package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
  219. package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
  220. package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
  221. package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
  222. package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
  223. package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
  224. package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
  225. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
  226. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
  227. package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
  228. package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
  229. package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
  230. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
  231. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
  232. package/ink-cli-ui-design.zip +0 -0
  233. package/savepoint.exe +0 -0
@@ -5,9 +5,11 @@ import (
5
5
  "path/filepath"
6
6
  "strings"
7
7
  "testing"
8
+ "time"
8
9
 
9
10
  tea "github.com/charmbracelet/bubbletea"
10
11
  "github.com/opencode/savepoint/internal/data"
12
+ "github.com/opencode/savepoint/internal/testutil"
11
13
  )
12
14
 
13
15
  func requireModel(t *testing.T, got tea.Model) Model {
@@ -178,7 +180,14 @@ func TestUpdate_unknownMsgNoOp(t *testing.T) {
178
180
  }
179
181
  }
180
182
 
181
- func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
183
+ func processCmd(t *testing.T, m Model, cmd tea.Cmd) Model {
184
+ t.Helper()
185
+ msg := cmd()
186
+ got, _ := m.Update(msg)
187
+ return requireModel(t, got)
188
+ }
189
+
190
+ func TestUpdate_pSetsRouterToFocusedTask(t *testing.T) {
182
191
  root := writeRouterFixture(t)
183
192
  tasks := []data.Task{
184
193
  {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
@@ -187,8 +196,9 @@ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
187
196
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
188
197
  m.Root = root
189
198
 
190
- got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
191
- updated := requireModel(t, got)
199
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
200
+ first := requireModel(t, got)
201
+ updated := processCmd(t, first, cmd)
192
202
 
193
203
  if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
194
204
  t.Fatalf("StatusMessage = %q", updated.StatusMessage)
@@ -208,7 +218,7 @@ func TestUpdate_mSetsRouterToFocusedTask(t *testing.T) {
208
218
  }
209
219
  }
210
220
 
211
- func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
221
+ func TestUpdate_pSetsRouterToFocusedTaskWhenItIsLastUncompleted(t *testing.T) {
212
222
  root := writeRouterFixture(t)
213
223
  tasks := []data.Task{
214
224
  {ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned},
@@ -217,28 +227,47 @@ func TestUpdate_mSetsAuditPendingForLastUncompletedTask(t *testing.T) {
217
227
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
218
228
  m.Root = root
219
229
 
220
- got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
221
- updated := requireModel(t, got)
230
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
231
+ first := requireModel(t, got)
232
+ updated := processCmd(t, first, cmd)
222
233
 
223
- if updated.StatusMessage != "Audit pending for E05-tasking-permissions" {
234
+ if !strings.Contains(updated.StatusMessage, "Router set to v1.1 E05-tasking-permissions/T004") {
224
235
  t.Fatalf("StatusMessage = %q", updated.StatusMessage)
225
236
  }
226
237
  state := readRouterFixture(t, root)
227
- if state.State != "audit-pending" {
228
- t.Errorf("router state = %q, want audit-pending", state.State)
238
+ if state.State != "task-building" {
239
+ t.Errorf("router state = %q, want task-building", state.State)
229
240
  }
230
- if state.Task != "" {
231
- t.Errorf("router task = %q, want empty", state.Task)
241
+ if state.Task != "E05-tasking-permissions/T004-implement-m-hotkey" {
242
+ t.Errorf("router task = %q, want focused task", state.Task)
232
243
  }
233
244
  }
234
245
 
235
- func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
246
+ func TestUpdate_pDoesNothingWhenOverlayOpen(t *testing.T) {
236
247
  root := writeRouterFixture(t)
237
248
  tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
238
249
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
239
250
  m.Root = root
240
251
  m.Overlay = OverlayHelp
241
252
 
253
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
254
+ updated := requireModel(t, got)
255
+
256
+ if updated.StatusMessage != "" {
257
+ t.Fatalf("StatusMessage = %q, want empty", updated.StatusMessage)
258
+ }
259
+ state := readRouterFixture(t, root)
260
+ if state.Task != "T001" {
261
+ t.Errorf("router task = %q, want unchanged T001", state.Task)
262
+ }
263
+ }
264
+
265
+ func TestUpdate_mDoesNotSetRouterTask(t *testing.T) {
266
+ root := writeRouterFixture(t)
267
+ tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnPlanned}}
268
+ m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
269
+ m.Root = root
270
+
242
271
  got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
243
272
  updated := requireModel(t, got)
244
273
 
@@ -251,14 +280,14 @@ func TestUpdate_mDoesNothingWhenOverlayOpen(t *testing.T) {
251
280
  }
252
281
  }
253
282
 
254
- func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
283
+ func TestUpdate_pDoesNotSetDoneTask(t *testing.T) {
255
284
  root := writeRouterFixture(t)
256
285
  tasks := []data.Task{{ID: "E05-tasking-permissions/T004-implement-m-hotkey", Release: "v1.1", Epic: "E05-tasking-permissions", Column: data.ColumnDone}}
257
286
  m := NewModel(tasks, "v1.1", "E05-tasking-permissions")
258
287
  m.Root = root
259
288
  m.FocusedColumn = data.ColumnDone
260
289
 
261
- got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
290
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
262
291
  updated := requireModel(t, got)
263
292
 
264
293
  if updated.StatusMessage != "Router not updated: focused task is done" {
@@ -270,13 +299,265 @@ func TestUpdate_mDoesNotSetDoneTask(t *testing.T) {
270
299
  }
271
300
  }
272
301
 
302
+ func TestUpdate_spaceShowsPhaseTransitionMessage(t *testing.T) {
303
+ tasks := []data.Task{{ID: "E05/T004", Column: data.ColumnInProgress, Stage: data.StageBuild}}
304
+ m := NewModel(tasks, "v1.1", "E05")
305
+ m.FocusedColumn = data.ColumnInProgress
306
+
307
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
308
+ updated := requireModel(t, got)
309
+
310
+ if updated.StatusMessage != "Moved T004 to test" {
311
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
312
+ }
313
+ if updated.AllTasks[0].Stage != data.StageTest {
314
+ t.Errorf("Stage = %q, want test", updated.AllTasks[0].Stage)
315
+ }
316
+ }
317
+
318
+ func TestUpdate_spaceWarnsAfterStaleMtime(t *testing.T) {
319
+ path := filepath.Join(t.TempDir(), "T004-task.md")
320
+ content := "---\nid: E05/T004\nstatus: in_progress\nstage: build\nphase: build\n---\n\n# Task\n"
321
+ testutil.WriteFile(t, path, content)
322
+ fi, err := os.Stat(path)
323
+ if err != nil {
324
+ t.Fatal(err)
325
+ }
326
+ tasks := []data.Task{{
327
+ ID: "E05/T004",
328
+ Column: data.ColumnInProgress,
329
+ Stage: data.StageBuild,
330
+ Path: path,
331
+ Mtime: fi.ModTime().Add(-time.Hour),
332
+ }}
333
+ m := NewModel(tasks, "v1.1", "E05")
334
+ m.FocusedColumn = data.ColumnInProgress
335
+
336
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
337
+ // cmd should be errorMsg since mtime is stale
338
+ msg := cmd()
339
+ if _, ok := msg.(errorMsg); !ok {
340
+ t.Fatalf("expected errorMsg, got %T", msg)
341
+ }
342
+ updated := requireModel(t, got)
343
+ got2, _ := updated.Update(msg)
344
+ updated2 := requireModel(t, got2)
345
+
346
+ if updated2.StatusMessage != "mtime conflict: refresh before retrying" {
347
+ t.Fatalf("StatusMessage = %q", updated2.StatusMessage)
348
+ }
349
+ raw, err := os.ReadFile(path)
350
+ if err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ parsed, err := data.NewParser().ParseTaskFile(path, string(raw))
354
+ if err != nil {
355
+ t.Fatal(err)
356
+ }
357
+ if parsed.Stage != data.StageBuild {
358
+ t.Errorf("persisted Stage = %q, want build", parsed.Stage)
359
+ }
360
+ if !strings.Contains(string(raw), "stage:") {
361
+ t.Error("legacy stage field should remain when write is rejected")
362
+ }
363
+ if updated2.AllTasks[0].Stage != data.StageBuild {
364
+ t.Errorf("model Stage = %q after rejected write, want build", updated2.AllTasks[0].Stage)
365
+ }
366
+ }
367
+
368
+ func TestUpdate_backspaceShowsRetreatMessageAndSyncsStatus(t *testing.T) {
369
+ tasks := []data.Task{{ID: "E05/T004", Status: string(data.ColumnDone), Column: data.ColumnDone}}
370
+ m := NewModel(tasks, "v1.1", "E05")
371
+ m.FocusedColumn = data.ColumnDone
372
+
373
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
374
+ updated := requireModel(t, got)
375
+
376
+ if updated.StatusMessage != "Moved back T004 to audit" {
377
+ t.Fatalf("StatusMessage = %q", updated.StatusMessage)
378
+ }
379
+ if updated.AllTasks[0].Column != data.ColumnInProgress {
380
+ t.Errorf("Column = %q, want in_progress", updated.AllTasks[0].Column)
381
+ }
382
+ if updated.AllTasks[0].Stage != data.StageAudit {
383
+ t.Errorf("Stage = %q, want audit", updated.AllTasks[0].Stage)
384
+ }
385
+ if updated.AllTasks[0].Status != string(data.ColumnInProgress) {
386
+ t.Errorf("Status = %q, want in_progress", updated.AllTasks[0].Status)
387
+ }
388
+ }
389
+
390
+ func TestUpdate_key1SwitchesToDetailTab(t *testing.T) {
391
+ m := NewModel(nil, "v1.1", "E06-audit-command")
392
+ m.Epics = []string{"E06-audit-command"}
393
+ m.Overlay = OverlayEpicDetail
394
+ m.EpicDetailTab = 1
395
+ m.EpicDetailOffset = 5
396
+
397
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
398
+ updated := requireModel(t, got)
399
+
400
+ if updated.EpicDetailTab != 0 {
401
+ t.Errorf("EpicDetailTab = %d, want 0", updated.EpicDetailTab)
402
+ }
403
+ if updated.EpicDetailOffset != 0 {
404
+ t.Errorf("EpicDetailOffset = %d, want 0 (reset on tab switch)", updated.EpicDetailOffset)
405
+ }
406
+ }
407
+
408
+ func TestUpdate_key2SwitchesToAuditTabAndLoadsContent(t *testing.T) {
409
+ root := t.TempDir()
410
+ auditDir := filepath.Join(root, "releases", "v1.1", "epics", "E06-audit-command")
411
+ testutil.MkdirAll(t, auditDir)
412
+ auditContent := "# E06 Audit\n\n## Findings\n\n- [x] All good\n"
413
+ testutil.WriteFile(t, filepath.Join(auditDir, "E06-Audit.md"), auditContent)
414
+
415
+ m := NewModel(nil, "v1.1", "E06-audit-command")
416
+ m.Root = root
417
+ m.Epics = []string{"E06-audit-command"}
418
+ m.EpicPanelCursor = 0
419
+ m.Overlay = OverlayEpicDetail
420
+ m.EpicDetailTab = 0
421
+ m.EpicDetailOffset = 3
422
+
423
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
424
+ updated := requireModel(t, got)
425
+
426
+ if updated.EpicDetailTab != 1 {
427
+ t.Errorf("EpicDetailTab = %d, want 1", updated.EpicDetailTab)
428
+ }
429
+ if updated.EpicDetailOffset != 0 {
430
+ t.Errorf("EpicDetailOffset = %d, want 0 (reset on tab switch)", updated.EpicDetailOffset)
431
+ }
432
+
433
+ msg := cmd()
434
+ got2, _ := updated.Update(msg)
435
+ updated2 := requireModel(t, got2)
436
+ if updated2.EpicAuditContent != auditContent {
437
+ t.Errorf("EpicAuditContent = %q, want %q", updated2.EpicAuditContent, auditContent)
438
+ }
439
+ }
440
+
441
+ func TestUpdate_key2LoadsAuditForOpenedEpicWhenPanelCursorStale(t *testing.T) {
442
+ root := t.TempDir()
443
+ epicA := filepath.Join(root, "releases", "v1.1", "epics", "E02-cross-platform-compatibility")
444
+ epicB := filepath.Join(root, "releases", "v1.1", "epics", "E06-audit-command")
445
+ testutil.MkdirAll(t, epicA)
446
+ testutil.MkdirAll(t, epicB)
447
+ auditContent := "# E06 Audit\n\n## Main Findings\nE06 content\n"
448
+ testutil.WriteFile(t, filepath.Join(epicB, "E06-Audit.md"), auditContent)
449
+
450
+ m := NewModel(nil, "v1.1", "E06-audit-command")
451
+ m.Root = root
452
+ m.Epics = []string{"E02-cross-platform-compatibility", "E06-audit-command"}
453
+ m.SelectedEpic = "E06-audit-command"
454
+ m.EpicDetailEpic = "E06-audit-command"
455
+ m.EpicPanelCursor = 0
456
+ m.Overlay = OverlayEpicDetail
457
+
458
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
459
+ updated := requireModel(t, got)
460
+
461
+ msg := cmd()
462
+ got2, _ := updated.Update(msg)
463
+ updated2 := requireModel(t, got2)
464
+ if updated2.EpicAuditContent != auditContent {
465
+ t.Errorf("EpicAuditContent = %q, want opened epic audit content", updated2.EpicAuditContent)
466
+ }
467
+ }
468
+
469
+ func TestUpdate_key2FallsBackWhenNoAuditFile(t *testing.T) {
470
+ m := NewModel(nil, "v1.1", "E06-audit-command")
471
+ m.Epics = []string{"E06-audit-command"}
472
+ m.EpicPanelCursor = 0
473
+ m.Overlay = OverlayEpicDetail
474
+
475
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
476
+ updated := requireModel(t, got)
477
+
478
+ if updated.EpicDetailTab != 1 {
479
+ t.Errorf("EpicDetailTab = %d, want 1", updated.EpicDetailTab)
480
+ }
481
+
482
+ msg := cmd()
483
+ got2, _ := updated.Update(msg)
484
+ updated2 := requireModel(t, got2)
485
+ if updated2.EpicAuditContent != "(no audit available)" {
486
+ t.Errorf("EpicAuditContent = %q, want \"(no audit available)\"", updated2.EpicAuditContent)
487
+ }
488
+ }
489
+
490
+ func TestUpdate_key2CachesAuditContent(t *testing.T) {
491
+ m := NewModel(nil, "v1.1", "E06-audit-command")
492
+ m.Epics = []string{"E06-audit-command"}
493
+ m.EpicPanelCursor = 0
494
+ m.Overlay = OverlayEpicDetail
495
+ m.EpicAuditContent = "already cached"
496
+
497
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
498
+ updated := requireModel(t, got)
499
+
500
+ if updated.EpicAuditContent != "already cached" {
501
+ t.Errorf("EpicAuditContent = %q, want cached value preserved", updated.EpicAuditContent)
502
+ }
503
+ }
504
+
505
+ func TestUpdate_openEpicDetailOverlayResetsTabState(t *testing.T) {
506
+ m := NewModel(nil, "v1.1", "E06-audit-command")
507
+ m.Epics = []string{"E06-audit-command"}
508
+ m.EpicPanelCursor = 0
509
+ m.EpicDetailTab = 1
510
+ m.EpicAuditContent = "stale content"
511
+
512
+ m.openEpicDetailOverlay()
513
+
514
+ if m.EpicDetailTab != 0 {
515
+ t.Errorf("EpicDetailTab = %d, want 0 after overlay open", m.EpicDetailTab)
516
+ }
517
+ if m.EpicAuditContent != "" {
518
+ t.Errorf("EpicAuditContent = %q, want empty after overlay open", m.EpicAuditContent)
519
+ }
520
+ }
521
+
522
+ func TestUpdate_tabKeysNoopOutsideEpicDetailOverlay(t *testing.T) {
523
+ m := NewModel(nil, "v1.1", "E06-audit-command")
524
+ m.Overlay = OverlayHelp
525
+ m.EpicDetailTab = 0
526
+
527
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
528
+ updated := requireModel(t, got)
529
+
530
+ if updated.EpicDetailTab != 0 {
531
+ t.Errorf("EpicDetailTab changed outside EpicDetail overlay: got %d", updated.EpicDetailTab)
532
+ }
533
+ }
534
+
535
+ func TestReloadMsgUpdatesRouterState(t *testing.T) {
536
+ m := NewModel(nil, "v1", "E01")
537
+ m.RouterState = &data.RouterState{State: "task-building", Task: "E01/T001", NextAction: "Build E01/T001."}
538
+ m.RouterTask = "E01/T001"
539
+
540
+ newState := &data.RouterState{State: "task-building", Task: "E01/T002", NextAction: "Build E01/T002."}
541
+ got, _ := m.Update(reloadMsg{
542
+ tasks: nil,
543
+ releases: []string{"v1"},
544
+ releaseEpics: map[string][]string{"v1": {"E01"}},
545
+ routerState: newState,
546
+ })
547
+ updated := requireModel(t, got)
548
+
549
+ if updated.RouterTask != "E01/T002" {
550
+ t.Errorf("RouterTask = %q, want E01/T002", updated.RouterTask)
551
+ }
552
+ if updated.RouterState == nil || updated.RouterState.NextAction != "Build E01/T002." {
553
+ t.Errorf("RouterState.NextAction = %q, want Build E01/T002.", updated.RouterState.NextAction)
554
+ }
555
+ }
556
+
273
557
  func writeRouterFixture(t *testing.T) string {
274
558
  t.Helper()
275
559
  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
- }
560
+ testutil.WriteRouter(t, root, "task-building", "v1.1", "E05", "T001", "Build T001.")
280
561
  return root
281
562
  }
282
563
 
@@ -0,0 +1,76 @@
1
+ package board
2
+
3
+ import "strings"
4
+
5
+ // sliceIndex returns the index of target in items, or 0 if not found.
6
+ func sliceIndex(items []string, target string) int {
7
+ for i, e := range items {
8
+ if e == target {
9
+ return i
10
+ }
11
+ }
12
+ return 0
13
+ }
14
+
15
+ // WrapText wraps s to fit within width, splitting on word boundaries.
16
+ func WrapText(s string, width int) []string {
17
+ if width < 4 {
18
+ width = 4
19
+ }
20
+ words := strings.Fields(s)
21
+ if len(words) == 0 {
22
+ return nil
23
+ }
24
+ lines := []string{}
25
+ current := ""
26
+ for _, word := range words {
27
+ if len([]rune(word)) > width {
28
+ if current != "" {
29
+ lines = append(lines, current)
30
+ current = ""
31
+ }
32
+ lines = append(lines, SplitLongWord(word, width)...)
33
+ continue
34
+ }
35
+ if current == "" {
36
+ current = word
37
+ continue
38
+ }
39
+ if len([]rune(current))+1+len([]rune(word)) <= width {
40
+ current += " " + word
41
+ continue
42
+ }
43
+ lines = append(lines, current)
44
+ current = word
45
+ }
46
+ if current != "" {
47
+ lines = append(lines, current)
48
+ }
49
+ return lines
50
+ }
51
+
52
+ // SplitLongWord splits a long word into chunks of at most width runes.
53
+ func SplitLongWord(word string, width int) []string {
54
+ runes := []rune(word)
55
+ lines := []string{}
56
+ for len(runes) > width {
57
+ lines = append(lines, string(runes[:width]))
58
+ runes = runes[width:]
59
+ }
60
+ if len(runes) > 0 {
61
+ lines = append(lines, string(runes))
62
+ }
63
+ return lines
64
+ }
65
+
66
+ // truncate clips s to max runes, appending "…" if clipped.
67
+ func truncate(s string, max int) string {
68
+ runes := []rune(s)
69
+ if len(runes) <= max {
70
+ return s
71
+ }
72
+ if max <= 1 {
73
+ return "…"
74
+ }
75
+ return string(runes[:max-1]) + "…"
76
+ }
@@ -28,9 +28,18 @@ func (m Model) View() string {
28
28
 
29
29
  header := m.renderHeader(w)
30
30
  nextActivity := m.renderNextActivityLine(w)
31
- layout := CalculateLayoutWithChrome(w, h, extraHeaderLines(nextActivity))
31
+ extra := extraHeaderLines(nextActivity)
32
+ layout := CalculateLayoutWithChrome(w, h, extra)
32
33
  topDivider := dividerLine(w)
33
34
  board := m.renderBoard(layout)
35
+ boardBudget := h - 8 - extra
36
+ if boardBudget < 0 {
37
+ boardBudget = 0
38
+ }
39
+ boardLines := strings.Split(board, "\n")
40
+ if len(boardLines) > boardBudget {
41
+ board = strings.Join(boardLines[:boardBudget], "\n")
42
+ }
34
43
  bottomDivider := dividerLine(w)
35
44
  footer := m.renderFooter(w)
36
45
  sections := []string{header}
@@ -67,11 +76,13 @@ func (m Model) View() string {
67
76
 
68
77
  if m.Overlay == OverlayEpicDetail {
69
78
  ow := overlayWidth(w)
70
- epicSlug := ""
71
- if m.EpicPanelCursor >= 0 && m.EpicPanelCursor < len(m.Epics) {
72
- epicSlug = m.Epics[m.EpicPanelCursor]
79
+ epicSlug := m.epicDetailEpic()
80
+ var detail string
81
+ if m.EpicDetailTab == 1 {
82
+ detail = RenderEpicAuditTab(epicSlug, m.EpicAuditContent, ow, detailMaxHeight(h), m.EpicDetailOffset, m.EpicDetailTab)
83
+ } else {
84
+ detail = RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset, m.EpicDetailTab)
73
85
  }
74
- detail := RenderEpicDetail(epicSlug, m.EpicDetailContent, ow, detailMaxHeight(h), m.EpicDetailOffset)
75
86
  return overlayOnBase(dimLines(base), detail, w, h)
76
87
  }
77
88
 
@@ -137,13 +148,13 @@ func FormatNextActivity(state *data.RouterState) string {
137
148
  var s string
138
149
  switch state.State {
139
150
  case "task-building":
140
- s = fmt.Sprintf("Build %s %s/%s", state.Release, shortRouterID(state.Epic), shortRouterID(state.Task))
151
+ s = fmt.Sprintf("Build %s %s/%s", state.Release, shortID(state.Epic), shortID(state.Task))
141
152
  case "audit-pending":
142
- s = fmt.Sprintf("Audit %s", shortRouterID(state.Epic))
153
+ s = fmt.Sprintf("Audit %s", shortID(state.Epic))
143
154
  case "epic-design":
144
- s = fmt.Sprintf("Design %s", shortRouterID(state.Epic))
155
+ s = fmt.Sprintf("Design %s", shortID(state.Epic))
145
156
  case "epic-task-breakdown":
146
- s = fmt.Sprintf("Plan %s", shortRouterID(state.Epic))
157
+ s = fmt.Sprintf("Plan %s", shortID(state.Epic))
147
158
  case "pre-implementation":
148
159
  s = fmt.Sprintf("Planning %s", state.Release)
149
160
  default:
@@ -152,19 +163,7 @@ func FormatNextActivity(state *data.RouterState) string {
152
163
  return xansi.Truncate(s, 20, "…")
153
164
  }
154
165
 
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
- }
166
+
168
167
 
169
168
  func (m Model) focusedTask() (data.Task, bool) {
170
169
  tasks := m.Tasks[m.FocusedColumn]
@@ -243,7 +242,7 @@ func (m Model) renderBoard(layout Layout) string {
243
242
  cols := m.renderColumns(layout)
244
243
  var content string
245
244
  if layout.EpicPanelVisible {
246
- epic := m.renderEpicPanel(layout.EpicPanelWidth)
245
+ epic := m.renderEpicPanel(layout.EpicPanelWidth, layout.ContentHeight)
247
246
  content = lipgloss.JoinHorizontal(lipgloss.Top, epic, cols)
248
247
  } else {
249
248
  content = cols
@@ -263,8 +262,8 @@ func (m Model) renderColumns(layout Layout) string {
263
262
  return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
264
263
  }
265
264
 
266
- func (m Model) renderEpicPanel(w int) string {
267
- return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus)
265
+ func (m Model) renderEpicPanel(w int, maxHeight int) string {
266
+ return RenderEpicSidebar(m.Epics, m.SelectedEpic, w, m.EpicPanelFocus, m.EpicPanelCursor, m.EpicStatus, maxHeight)
268
267
  }
269
268
 
270
269
  func (m Model) renderColumn(col data.ColumnType, colW, maxHeight int) string {
@@ -291,9 +290,13 @@ func (m Model) renderFooter(termW int) string {
291
290
  styles.FooterDivider.Render(" │ ")+
292
291
  styles.FooterPhaseAudit.Render("AUDIT"),
293
292
  )
294
- hints := footerLine(termW, styles.FooterHints.Render("←/→:nav E:epic R:release ?:help q:quit"))
295
- spacer := footerLine(termW, "")
296
- return lipgloss.JoinVertical(lipgloss.Center, phase, spacer, hints)
293
+ hints := footerLine(termW, styles.FooterHints.Render("←/→:nav p: Priority R:release ?:help q:quit"))
294
+ status := ""
295
+ if m.StatusMessage != "" {
296
+ status = styles.StatusBar.Render(m.StatusMessage)
297
+ }
298
+ statusLine := footerLine(termW, status)
299
+ return lipgloss.JoinVertical(lipgloss.Center, phase, statusLine, hints)
297
300
  }
298
301
 
299
302
  func dividerLine(termW int) string {
@@ -66,7 +66,7 @@ func TestView_containsFooterHints(t *testing.T) {
66
66
  m := NewModel(nil, "v1", "E03")
67
67
  footer := m.renderFooter(80)
68
68
 
69
- if !strings.Contains(footer, "←/→:nav E:epic R:release ?:help q:quit") {
69
+ if !strings.Contains(footer, "←/→:nav p: Priority R:release ?:help q:quit") {
70
70
  t.Fatal("renderFooter() missing navigation hints")
71
71
  }
72
72
 
@@ -75,7 +75,7 @@ func TestView_containsFooterHints(t *testing.T) {
75
75
  t.Fatalf("renderFooter() returned %d lines, want 3", len(lines))
76
76
  }
77
77
  if strings.TrimSpace(plainTerminal(lines[1])) != "" {
78
- t.Fatalf("renderFooter() spacer line = %q, want blank", lines[1])
78
+ t.Fatalf("renderFooter() status line = %q, want blank", lines[1])
79
79
  }
80
80
  for i, line := range lines {
81
81
  if got := lipgloss.Width(line); got > 80 {
@@ -84,6 +84,16 @@ func TestView_containsFooterHints(t *testing.T) {
84
84
  }
85
85
  }
86
86
 
87
+ func TestView_footerRendersStatusMessage(t *testing.T) {
88
+ m := NewModel(nil, "v1", "E03")
89
+ m.StatusMessage = "Router set to v1.1 E05-tasking-permissions/T004"
90
+ footer := plainTerminal(m.renderFooter(80))
91
+
92
+ if !strings.Contains(footer, "Router set to v1.1 E05-tasking-permissions/T004") {
93
+ t.Fatal("renderFooter() missing status message")
94
+ }
95
+ }
96
+
87
97
  func TestView_containsBottomDivider(t *testing.T) {
88
98
  m := NewModel(nil, "v1", "E03")
89
99
  m.Width = 120
@@ -16,6 +16,31 @@ type reloadMsg struct {
16
16
  releases []string
17
17
  releaseEpics map[string][]string
18
18
  epicStatuses map[string]string
19
+ routerState *data.RouterState
20
+ }
21
+
22
+ type routerWriteMsg struct {
23
+ message string
24
+ state *data.RouterState
25
+ taskID string
26
+ }
27
+
28
+ type taskWriteMsg struct {
29
+ prefix string
30
+ next data.Task
31
+ err error
32
+ }
33
+
34
+ type epicDetailMsg struct {
35
+ content string
36
+ }
37
+
38
+ type auditContentMsg struct {
39
+ content string
40
+ }
41
+
42
+ type errorMsg struct {
43
+ message string
19
44
  }
20
45
 
21
46
  // watchFiles blocks until a file event arrives, debounces for 100ms, emits fileChangeMsg.
@@ -52,22 +77,27 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
52
77
  }
53
78
  }
54
79
 
55
- func reloadTasks(root string) tea.Cmd {
80
+ func reloadTasks(root string, deps ModelDependencies) tea.Cmd {
56
81
  return func() tea.Msg {
57
- tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
82
+ tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root, deps.Discoverer, deps.Parser)
58
83
  if err != nil {
59
- return nil
84
+ return errorMsg{message: "reload failed: " + err.Error()}
60
85
  }
61
- return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses}
86
+ routerState, _ := readRouterState(root, deps.RouterReader)
87
+ return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses, routerState: routerState}
62
88
  }
63
89
  }
64
90
 
65
- // newWatcher watches the releases directory by walking all subdirs (fsnotify v1.10 has no recursive opt).
91
+ // newWatcher watches the savepoint root (for router.md) and all releases subdirs.
66
92
  func newWatcher(root string) (*fsnotify.Watcher, error) {
67
93
  w, err := fsnotify.NewWatcher()
68
94
  if err != nil {
69
95
  return nil, err
70
96
  }
97
+ if err := w.Add(root); err != nil {
98
+ w.Close()
99
+ return nil, err
100
+ }
71
101
  releasesPath := filepath.Join(root, "releases")
72
102
  if err := addDirsRecursive(w, releasesPath); err != nil {
73
103
  w.Close()