savepoint 1.0.2 → 1.0.4

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 (242) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.golangci.yml +11 -0
  4. package/.savepoint/Design.md +40 -38
  5. package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
  6. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  7. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
  8. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
  9. package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
  10. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  11. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
  12. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
  13. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
  14. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
  15. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
  16. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
  17. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
  18. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  19. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  20. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  21. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  22. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  23. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  24. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  25. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  26. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  27. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  28. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  29. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  30. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  31. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  32. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  33. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  34. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  35. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  36. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  37. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  38. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  39. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  40. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  41. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  42. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  43. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  44. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  45. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  46. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  47. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  48. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  49. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  50. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  51. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  52. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  53. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  54. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  55. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  56. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  57. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  58. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  59. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  60. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  61. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  62. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  63. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  64. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  65. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  66. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  67. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  68. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  69. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  70. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  71. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  72. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  73. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  74. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  75. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  76. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  77. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  78. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  79. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  80. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  81. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  82. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  83. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  84. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  85. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Audit.md +272 -0
  86. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +60 -0
  87. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  88. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +34 -0
  89. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  90. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  91. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  92. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  93. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +33 -0
  94. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T008-ci-and-release-automation.md +46 -0
  95. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  96. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  97. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  98. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  99. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  100. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  101. package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
  102. package/.savepoint/router.md +10 -17
  103. package/AGENTS.md +39 -24
  104. package/Makefile +3 -1
  105. package/README.md +0 -1
  106. package/agent-skills/savepoint-audit/SKILL.md +86 -34
  107. package/agent-skills/savepoint-build-task/SKILL.md +7 -2
  108. package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
  109. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  110. package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
  111. package/agent-skills/savepoint-system-design/SKILL.md +7 -2
  112. package/agent_skills_test.go +91 -0
  113. package/cmd/board.go +59 -0
  114. package/cmd/board_test.go +137 -0
  115. package/cmd/doctor.go +53 -0
  116. package/cmd/doctor_test.go +146 -0
  117. package/cmd/init.go +63 -0
  118. package/cmd/init_test.go +104 -0
  119. package/internal/board/board.go +44 -36
  120. package/internal/board/board_test.go +27 -82
  121. package/internal/board/card.go +43 -23
  122. package/internal/board/card_test.go +74 -5
  123. package/internal/board/column.go +75 -15
  124. package/internal/board/column_test.go +76 -2
  125. package/internal/board/debug.go +26 -0
  126. package/internal/board/debug_test.go +108 -0
  127. package/internal/board/detail.go +33 -47
  128. package/internal/board/detail_test.go +48 -0
  129. package/internal/board/epic_panel.go +120 -22
  130. package/internal/board/epic_panel_test.go +302 -17
  131. package/internal/board/help.go +1 -0
  132. package/internal/board/help_test.go +1 -0
  133. package/internal/board/integration_test.go +266 -0
  134. package/internal/board/interfaces.go +65 -0
  135. package/internal/board/interfaces_test.go +114 -0
  136. package/internal/board/io.go +93 -0
  137. package/internal/board/model.go +79 -118
  138. package/internal/board/plain.go +88 -0
  139. package/internal/board/plain_test.go +117 -0
  140. package/internal/board/release.go +1 -9
  141. package/internal/board/release_test.go +6 -6
  142. package/internal/board/status.go +4 -4
  143. package/internal/board/theme.go +24 -0
  144. package/internal/board/theme_test.go +31 -0
  145. package/internal/board/transitions.go +113 -88
  146. package/internal/board/transitions_test.go +164 -141
  147. package/internal/board/tui.go +32 -0
  148. package/internal/board/update.go +344 -215
  149. package/internal/board/update_test.go +326 -18
  150. package/internal/board/util.go +76 -0
  151. package/internal/board/view.go +31 -28
  152. package/internal/board/view_test.go +74 -2
  153. package/internal/board/watch.go +41 -5
  154. package/internal/buildtool/main.go +45 -15
  155. package/internal/buildtool/main_test.go +224 -0
  156. package/internal/data/config.go +17 -3
  157. package/internal/data/config_test.go +49 -0
  158. package/internal/data/discover.go +26 -0
  159. package/internal/data/discover_test.go +34 -10
  160. package/internal/data/errors.go +4 -0
  161. package/internal/data/fuzz_test.go +75 -0
  162. package/internal/data/lifecycle.go +13 -6
  163. package/internal/data/lifecycle_test.go +14 -11
  164. package/internal/data/parser.go +22 -6
  165. package/internal/data/parser_test.go +31 -7
  166. package/internal/data/task.go +0 -9
  167. package/internal/data/testdata/fuzz/FuzzSplitFrontmatterBody/68eb66b0fe91e7e3 +2 -0
  168. package/internal/data/write.go +88 -11
  169. package/internal/data/write_test.go +167 -0
  170. package/internal/doctor/checks.go +567 -0
  171. package/internal/doctor/checks_test.go +716 -0
  172. package/internal/doctor/gates.go +193 -0
  173. package/internal/doctor/gates_test.go +166 -0
  174. package/internal/doctor/interfaces.go +64 -0
  175. package/internal/doctor/interfaces_test.go +104 -0
  176. package/internal/doctor/repairs.go +80 -0
  177. package/internal/doctor/repairs_test.go +81 -0
  178. package/internal/doctor/report.go +157 -0
  179. package/internal/doctor/report_test.go +89 -0
  180. package/internal/init/clipboard.go +146 -0
  181. package/internal/init/clipboard_test.go +74 -0
  182. package/internal/init/install.go +16 -0
  183. package/internal/init/integration_test.go +197 -0
  184. package/internal/init/prompt.go +14 -0
  185. package/internal/init/prompt_test.go +77 -0
  186. package/internal/init/scaffold.go +59 -0
  187. package/internal/init/scaffold_test.go +179 -0
  188. package/internal/init/template_freshness_test.go +56 -0
  189. package/internal/init/validate.go +85 -0
  190. package/internal/init/validate_test.go +141 -0
  191. package/internal/init/write.go +73 -0
  192. package/internal/init/write_test.go +91 -0
  193. package/internal/styles/styles_test.go +133 -0
  194. package/internal/testutil/fixture.go +113 -0
  195. package/internal/testutil/fs.go +26 -0
  196. package/main.go +120 -4
  197. package/package.json +2 -2
  198. package/project-audit/audit_report_glm_5.1.md +411 -0
  199. package/project-audit/audit_report_opus_4.6.md +406 -0
  200. package/project-audit/consolidated-audit-report.md +456 -0
  201. package/templates/project/.savepoint/Design.md +2 -2
  202. package/templates/project/.savepoint/router.md +10 -10
  203. package/templates/project/AGENTS.md +33 -21
  204. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  205. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  206. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  207. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  208. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  209. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  210. package/templates/prompts/audit-reconciliation.prompt.md +33 -28
  211. package/templates/prompts/design.prompt.md +3 -1
  212. package/.savepoint/audit/v1/E01/proposals.md +0 -168
  213. package/.savepoint/audit/v1/E01/snapshot.md +0 -78
  214. package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
  215. package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
  216. package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
  217. package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
  218. package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
  219. package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
  220. package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
  221. package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
  222. package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
  223. package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
  224. package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
  225. package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
  226. package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
  227. package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
  228. package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
  229. package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
  230. package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
  231. package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
  232. package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
  233. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
  234. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
  235. package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
  236. package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
  237. package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
  238. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
  239. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
  240. package/ink-cli-ui-design.zip +0 -0
  241. package/savepoint +0 -0
  242. package/savepoint.exe +0 -0
@@ -5,53 +5,68 @@ import (
5
5
  "os"
6
6
  "path/filepath"
7
7
 
8
- tea "github.com/charmbracelet/bubbletea"
9
- "github.com/charmbracelet/lipgloss"
10
- "github.com/muesli/termenv"
8
+ xterm "github.com/charmbracelet/x/term"
11
9
  "github.com/opencode/savepoint/internal/data"
12
10
  )
13
11
 
14
12
  func Run() error {
15
- lipgloss.SetColorProfile(termenv.ANSI256)
13
+ return RunWithFilters("", "")
14
+ }
16
15
 
17
- model, err := newProjectModel(".")
18
- if err != nil {
19
- return err
16
+ func RunWithFilters(release, epic string) error {
17
+ if !xterm.IsTerminal(os.Stdout.Fd()) {
18
+ return runPlainOutput(release, epic)
20
19
  }
20
+ return RunTUI(release, epic)
21
+ }
21
22
 
22
- p := tea.NewProgram(model, tea.WithAltScreen())
23
- if _, err := p.Run(); err != nil {
24
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
23
+ func runPlainOutput(release, epic string) error {
24
+ model, err := newProjectModel(".", release, epic)
25
+ if err != nil {
25
26
  return err
26
27
  }
28
+ fmt.Print(RenderPlainTable(model))
27
29
  return nil
28
30
  }
29
31
 
30
- func newProgramModel() Model {
31
- return NewModel(nil, "v1", "E03-board-tui-core")
32
+ func newProjectModel(start, releaseFilter, epicFilter string) (Model, error) {
33
+ return newProjectModelWithDependencies(start, releaseFilter, epicFilter, defaultModelDependencies())
32
34
  }
33
35
 
34
- func newProjectModel(start string) (Model, error) {
35
- d := data.NewDiscover()
36
- root, err := d.FindSavepointRoot(start)
36
+ func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, deps ModelDependencies) (Model, error) {
37
+ deps = modelDependencies([]ModelDependencies{deps})
38
+
39
+ debugf("board init: finding savepoint root from %q", start)
40
+ root, err := deps.Discoverer.FindSavepointRoot(start)
37
41
  if err != nil {
38
42
  return Model{}, err
39
43
  }
44
+ debugf("board init: root = %q", root)
40
45
 
41
- routerState, err := readRouterState(root)
46
+ routerState, err := readRouterState(root, deps.RouterReader)
42
47
  if err != nil {
43
48
  return Model{}, err
44
49
  }
45
50
 
46
- tasks, releaseIDs, releaseEpics, epicStatuses, err := loadBoardData(root)
51
+ tasks, releaseIDs, releaseEpics, epicStatuses, err := loadBoardData(root, deps.Discoverer, deps.Parser)
47
52
  if err != nil {
48
53
  return Model{}, err
49
54
  }
55
+ debugf("board init: loaded %d tasks across %d releases", len(tasks), len(releaseIDs))
50
56
 
51
- release := firstKnown(routerState.Release, releaseIDs)
52
- epic := firstKnown(routerState.Epic, releaseEpics[release])
57
+ preferredRelease := routerState.Release
58
+ if releaseFilter != "" {
59
+ preferredRelease = releaseFilter
60
+ }
61
+ preferredEpic := routerState.Epic
62
+ if epicFilter != "" {
63
+ preferredEpic = epicFilter
64
+ }
65
+
66
+ release := firstKnown(preferredRelease, releaseIDs)
67
+ epic := firstKnown(preferredEpic, releaseEpics[release])
53
68
 
54
- model := NewModel(tasks, release, epic)
69
+ model := NewModel(tasks, release, epic, deps)
55
70
  model.Root = root
56
71
  model.RouterTask = routerState.Task
57
72
  model.RouterState = routerState
@@ -66,13 +81,13 @@ func newProjectModel(start string) (Model, error) {
66
81
  return Model{}, err
67
82
  }
68
83
  model.Watcher = watcher
84
+ debugf("board init: file watcher started at %q", root)
69
85
 
70
86
  return model, nil
71
87
  }
72
88
 
73
- func loadBoardData(root string) ([]data.Task, []string, map[string][]string, map[string]string, error) {
74
- d := data.NewDiscover()
75
- releases, err := d.ListReleases(root)
89
+ func loadBoardData(root string, discoverer taskDiscoverer, parser taskParser) ([]data.Task, []string, map[string][]string, map[string]string, error) {
90
+ releases, err := discoverer.ListReleases(root)
76
91
  if err != nil {
77
92
  return nil, nil, nil, nil, err
78
93
  }
@@ -84,13 +99,13 @@ func loadBoardData(root string) ([]data.Task, []string, map[string][]string, map
84
99
 
85
100
  for _, release := range releases {
86
101
  releaseIDs = append(releaseIDs, release.ID)
87
- epics, err := d.ListEpics(root, release.ID)
102
+ epics, err := discoverer.ListEpics(root, release.ID)
88
103
  if err != nil {
89
104
  return nil, nil, nil, nil, err
90
105
  }
91
106
  for _, epic := range epics {
92
107
  releaseEpics[release.ID] = append(releaseEpics[release.ID], epic.ID)
93
- epicTasks, err := loadEpicTasks(d, root, release.ID, epic.ID)
108
+ epicTasks, err := loadEpicTasks(discoverer, parser, root, release.ID, epic.ID)
94
109
  if err != nil {
95
110
  return nil, nil, nil, nil, err
96
111
  }
@@ -98,7 +113,6 @@ func loadBoardData(root string) ([]data.Task, []string, map[string][]string, map
98
113
 
99
114
  detailPath := filepath.Join(epic.Path, shortID(epic.ID)+"-Detail.md")
100
115
  if raw, err := os.ReadFile(detailPath); err == nil {
101
- parser := data.NewParser()
102
116
  if fm, err := parser.ParseFrontmatter(string(raw)); err == nil {
103
117
  if status, ok := fm["status"].(string); ok {
104
118
  epicStatuses[epic.ID] = status
@@ -111,27 +125,21 @@ func loadBoardData(root string) ([]data.Task, []string, map[string][]string, map
111
125
  return tasks, releaseIDs, releaseEpics, epicStatuses, nil
112
126
  }
113
127
 
114
- func loadAllTasks(root string) ([]data.Task, error) {
115
- tasks, _, _, _, err := loadBoardData(root)
116
- return tasks, err
117
- }
118
-
119
- func readRouterState(root string) (*data.RouterState, error) {
128
+ func readRouterState(root string, reader routerReader) (*data.RouterState, error) {
120
129
  content, err := os.ReadFile(filepath.Join(root, "router.md"))
121
130
  if err != nil {
122
131
  return nil, err
123
132
  }
124
133
 
125
- return data.NewRouterReader().ReadState(string(content))
134
+ return reader.ReadState(string(content))
126
135
  }
127
136
 
128
- func loadEpicTasks(d *data.Discover, root, release, epic string) ([]data.Task, error) {
129
- taskInfos, err := d.ListTasks(root, release, epic)
137
+ func loadEpicTasks(discoverer taskDiscoverer, parser taskParser, root, release, epic string) ([]data.Task, error) {
138
+ taskInfos, err := discoverer.ListTasks(root, release, epic)
130
139
  if err != nil {
131
140
  return nil, err
132
141
  }
133
142
 
134
- parser := data.NewParser()
135
143
  tasks := make([]data.Task, 0, len(taskInfos))
136
144
  for _, taskInfo := range taskInfos {
137
145
  content, err := os.ReadFile(taskInfo.Path)
@@ -1,16 +1,16 @@
1
1
  package board
2
2
 
3
3
  import (
4
- "os"
5
4
  "path/filepath"
6
5
  "strings"
7
6
  "testing"
8
7
 
9
8
  "github.com/opencode/savepoint/internal/data"
9
+ "github.com/opencode/savepoint/internal/testutil"
10
10
  )
11
11
 
12
- func TestNewProgramModelUsesBoardCore(t *testing.T) {
13
- m := newProgramModel()
12
+ func TestNewModelUsesBoardCore(t *testing.T) {
13
+ m := NewModel(nil, "v1", "E03-board-tui-core")
14
14
  m.Width = 100
15
15
 
16
16
  got := m.View()
@@ -27,22 +27,11 @@ func TestNewProgramModelUsesBoardCore(t *testing.T) {
27
27
  func TestNewProjectModelLoadsReleasesEpicsAndTasks(t *testing.T) {
28
28
  projectRoot := t.TempDir()
29
29
  savepointRoot := filepath.Join(projectRoot, ".savepoint")
30
- writeFile(t, filepath.Join(savepointRoot, "router.md"), `# Agent State Machine
31
-
32
- ## Current state
33
-
34
- `+"```"+`yaml
35
- state: task-building
36
- release: v2
37
- epic: E03-live
38
- task: ""
39
- next_action: "test"
40
- `+"```"+`
41
- `)
30
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v2", "E03-live", "", "test")
42
31
  writeTask(t, savepointRoot, "v1", "E01-old", "T001-old", data.ColumnPlanned)
43
32
  writeTask(t, savepointRoot, "v2", "E03-live", "T001-live", data.ColumnInProgress)
44
33
 
45
- model, err := newProjectModel(projectRoot)
34
+ model, err := newProjectModel(projectRoot, "", "")
46
35
  if err != nil {
47
36
  t.Fatalf("newProjectModel() error = %v", err)
48
37
  }
@@ -75,21 +64,10 @@ next_action: "test"
75
64
  func TestNewProjectModelUsesPathReleaseForTaskWithoutReleaseFrontmatter(t *testing.T) {
76
65
  projectRoot := t.TempDir()
77
66
  savepointRoot := filepath.Join(projectRoot, ".savepoint")
78
- writeFile(t, filepath.Join(savepointRoot, "router.md"), `# Agent State Machine
79
-
80
- ## Current state
81
-
82
- `+"```"+`yaml
83
- state: task-building
84
- release: v1.1
85
- epic: E01-tui-optimisation
86
- task: E01-tui-optimisation/T001-border-resize-fix
87
- next_action: "test"
88
- `+"```"+`
89
- `)
67
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v1.1", "E01-tui-optimisation", "E01-tui-optimisation/T001-border-resize-fix", "test")
90
68
  writeTaskWithoutRelease(t, savepointRoot, "v1.1", "E01-tui-optimisation", "T001-border-resize-fix", data.ColumnInProgress)
91
69
 
92
- model, err := newProjectModel(projectRoot)
70
+ model, err := newProjectModel(projectRoot, "", "")
93
71
  if err != nil {
94
72
  t.Fatalf("newProjectModel() error = %v", err)
95
73
  }
@@ -112,22 +90,11 @@ next_action: "test"
112
90
  func TestNewProjectModelResolvesShortRouterEpicToFullEpicID(t *testing.T) {
113
91
  projectRoot := t.TempDir()
114
92
  savepointRoot := filepath.Join(projectRoot, ".savepoint")
115
- writeFile(t, filepath.Join(savepointRoot, "router.md"), `# Agent State Machine
116
-
117
- ## Current state
118
-
119
- `+"```"+`yaml
120
- state: task-building
121
- release: v1.1
122
- epic: E03
123
- task: T001
124
- next_action: "Build v1.1 E03/T001"
125
- `+"```"+`
126
- `)
93
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v1.1", "E03", "T001", "Build v1.1 E03/T001")
127
94
  writeTask(t, savepointRoot, "v1.1", "E01-tui-optimisation", "T007-column-focus-border-stability", data.ColumnInProgress)
128
95
  writeTask(t, savepointRoot, "v1.1", "E03-ui-visual-refinement", "T001-border-resize-fix", data.ColumnInProgress)
129
96
 
130
- model, err := newProjectModel(projectRoot)
97
+ model, err := newProjectModel(projectRoot, "", "")
131
98
  if err != nil {
132
99
  t.Fatalf("newProjectModel() error = %v", err)
133
100
  }
@@ -173,51 +140,29 @@ func TestUpdateReloadMsgRefreshesReleaseEpicIndex(t *testing.T) {
173
140
  }
174
141
  }
175
142
 
176
- func writeTask(t *testing.T, root, release, epic, task string, column data.ColumnType) {
143
+ func writeTask(t *testing.T, root, release, epic, taskSlug string, column data.ColumnType) {
177
144
  t.Helper()
178
- path := filepath.Join(root, "releases", release, "epics", epic, "tasks", task+".md")
179
- phase := ""
180
- if column == data.ColumnInProgress {
181
- phase = "phase: build\n"
182
- }
183
- content := `---
184
- id: ` + epic + `/` + task + `
185
- release: ` + release + `
186
- status: ` + string(column) + `
187
- ` + phase + `objective: "Test task"
188
- depends_on: []
189
- ---
190
-
191
- # Test task
192
- `
193
- writeFile(t, path, content)
194
- }
195
-
196
- func writeTaskWithoutRelease(t *testing.T, root, release, epic, task string, column data.ColumnType) {
197
- t.Helper()
198
- path := filepath.Join(root, "releases", release, "epics", epic, "tasks", task+".md")
199
- phase := ""
145
+ tf := testutil.TaskFixture{
146
+ Slug: taskSlug,
147
+ Release: release,
148
+ Status: string(column),
149
+ Objective: "Test task",
150
+ }
200
151
  if column == data.ColumnInProgress {
201
- phase = "phase: build\n"
202
- }
203
- content := `---
204
- id: ` + epic + `/` + task + `
205
- status: ` + string(column) + `
206
- ` + phase + `objective: "Test task"
207
- depends_on: []
208
- ---
209
-
210
- # Test task
211
- `
212
- writeFile(t, path, content)
152
+ tf.Phase = "build"
153
+ }
154
+ testutil.WriteTask(t, root, release, epic, tf)
213
155
  }
214
156
 
215
- func writeFile(t *testing.T, path, content string) {
157
+ func writeTaskWithoutRelease(t *testing.T, root, release, epic, taskSlug string, column data.ColumnType) {
216
158
  t.Helper()
217
- if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
218
- t.Fatal(err)
159
+ tf := testutil.TaskFixture{
160
+ Slug: taskSlug,
161
+ Status: string(column),
162
+ Objective: "Test task",
219
163
  }
220
- if err := os.WriteFile(path, []byte(content), 0644); err != nil {
221
- t.Fatal(err)
164
+ if column == data.ColumnInProgress {
165
+ tf.Phase = "build"
222
166
  }
167
+ testutil.WriteTask(t, root, release, epic, tf)
223
168
  }
@@ -4,6 +4,7 @@ import (
4
4
  "fmt"
5
5
  "strings"
6
6
 
7
+ "github.com/charmbracelet/lipgloss"
7
8
  "github.com/opencode/savepoint/internal/data"
8
9
  "github.com/opencode/savepoint/internal/styles"
9
10
  )
@@ -24,18 +25,20 @@ func RenderCard(t data.Task, width int, focused bool, routerState *data.RouterSt
24
25
  inner = 2
25
26
  }
26
27
 
27
- var glyph string
28
- if t.Status != "" {
29
- glyph = statusGlyph(t.Status)
30
- } else if t.Column == data.ColumnDone {
31
- glyph = styles.GlyphBuild.Render(glyphBuild)
32
- } else if isRouterPriority(t, routerState) {
33
- glyph = styles.TagDone.Render(glyphBuild)
34
- } else {
35
- glyph = phaseGlyphStyled(t.Stage)
36
- }
37
- // glyph is 1 rune + 1 space prefix; leave room for "▣ "
38
- idLine := fmt.Sprintf("%s %s", glyph, truncate(shortID(t.ID), inner-2))
28
+ glyph := taskGlyph(t, routerState)
29
+ phase := taskPhaseText(t)
30
+ idWidth := inner - 2
31
+ if phase != "" {
32
+ idWidth -= lipgloss.Width(phase) + 1
33
+ }
34
+ if idWidth < 1 {
35
+ idWidth = 1
36
+ }
37
+
38
+ idLine := fmt.Sprintf("%s %s", glyph, truncate(shortID(t.ID), idWidth))
39
+ if phase != "" && lipgloss.Width(idLine)+1+lipgloss.Width(phase) <= inner {
40
+ idLine += " " + phase
41
+ }
39
42
  titleLine := styles.CardMeta.Render(strings.Join(WrapText(t.Title, inner), "\n"))
40
43
 
41
44
  content := idLine + "\n" + titleLine
@@ -46,6 +49,33 @@ func RenderCard(t data.Task, width int, focused bool, routerState *data.RouterSt
46
49
  return styles.Card.Width(width).Render(content)
47
50
  }
48
51
 
52
+ func taskGlyph(t data.Task, routerState *data.RouterState) string {
53
+ if t.Column == data.ColumnInProgress {
54
+ return phaseGlyphStyled(t.Stage)
55
+ }
56
+ if t.Column == data.ColumnDone {
57
+ return styles.GlyphBuild.Render(glyphBuild)
58
+ }
59
+ if isRouterPriority(t, routerState) {
60
+ return styles.TagDone.Render(glyphBuild)
61
+ }
62
+ if t.Status != "" {
63
+ return statusGlyph(t.Status)
64
+ }
65
+ return phaseGlyphStyled(t.Stage)
66
+ }
67
+
68
+ func taskPhaseText(t data.Task) string {
69
+ switch t.Column {
70
+ case data.ColumnInProgress:
71
+ return styles.CardMeta.Render(strings.ToUpper(phaseLabel(t.Stage)))
72
+ case data.ColumnDone:
73
+ return styles.CardMeta.Render("DONE")
74
+ default:
75
+ return ""
76
+ }
77
+ }
78
+
49
79
  func phaseGlyphStyled(stage data.ProgressStage) string {
50
80
  switch stage {
51
81
  case data.StageTest:
@@ -96,14 +126,4 @@ func shortID(id string) string {
96
126
  return id
97
127
  }
98
128
 
99
- // truncate clips s to max runes, appending "…" if clipped.
100
- func truncate(s string, max int) string {
101
- runes := []rune(s)
102
- if len(runes) <= max {
103
- return s
104
- }
105
- if max <= 1 {
106
- return "…"
107
- }
108
- return string(runes[:max-1]) + "…"
109
- }
129
+
@@ -194,13 +194,12 @@ func TestRenderCard_doneTaskUsesOrangeBuildGlyph(t *testing.T) {
194
194
  func TestRenderCard_explicitStatusUsesUnifiedGlyph(t *testing.T) {
195
195
  tests := []struct {
196
196
  name string
197
- status data.TaskStatus
197
+ status string
198
198
  glyph string
199
199
  }{
200
- {"planned", data.StatusPlanned, "○"},
201
- {"in progress", data.StatusInProgress, ""},
202
- {"done", data.StatusDone, ""},
203
- {"audited", data.StatusAudited, "✓"},
200
+ {"planned", string(data.ColumnPlanned), "○"},
201
+ {"done", string(data.ColumnDone), ""},
202
+ {"audited", "audited", ""},
204
203
  }
205
204
 
206
205
  for _, tt := range tests {
@@ -216,3 +215,73 @@ func TestRenderCard_explicitStatusUsesUnifiedGlyph(t *testing.T) {
216
215
  })
217
216
  }
218
217
  }
218
+
219
+ func TestRenderCard_inProgressShowsPhaseText(t *testing.T) {
220
+ tests := []struct {
221
+ name string
222
+ stage data.ProgressStage
223
+ label string
224
+ glyph string
225
+ }{
226
+ {"build", data.StageBuild, "BUILD", glyphBuild},
227
+ {"test", data.StageTest, "TEST", glyphTest},
228
+ {"audit", data.StageAudit, "AUDIT", glyphAudit},
229
+ }
230
+
231
+ for _, tt := range tests {
232
+ t.Run(tt.name, func(t *testing.T) {
233
+ task := data.Task{ID: "T1", Column: data.ColumnInProgress, Status: string(data.ColumnInProgress), Stage: tt.stage}
234
+ got := RenderCard(task, 30, false, nil)
235
+ if !strings.Contains(got, tt.label) {
236
+ t.Errorf("RenderCard missing phase label %q", tt.label)
237
+ }
238
+ if !strings.Contains(got, tt.glyph) {
239
+ t.Errorf("RenderCard missing phase glyph %q", tt.glyph)
240
+ }
241
+ if strings.Contains(got, "▶") {
242
+ t.Error("RenderCard should not use generic in_progress glyph when phase is available")
243
+ }
244
+ })
245
+ }
246
+ }
247
+
248
+ func TestRenderCard_doneShowsDoneText(t *testing.T) {
249
+ task := data.Task{ID: "T1", Column: data.ColumnDone, Status: string(data.ColumnDone)}
250
+ got := RenderCard(task, 30, false, nil)
251
+ if !strings.Contains(got, "DONE") {
252
+ t.Error("RenderCard missing DONE phase label")
253
+ }
254
+ }
255
+
256
+ func BenchmarkRenderCard_narrow(b *testing.B) {
257
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout", Stage: data.StageBuild}
258
+ b.ReportAllocs()
259
+ for b.Loop() {
260
+ RenderCard(task, 24, false, nil)
261
+ }
262
+ }
263
+
264
+ func BenchmarkRenderCard_standard(b *testing.B) {
265
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout for the board view", Stage: data.StageTest}
266
+ b.ReportAllocs()
267
+ for b.Loop() {
268
+ RenderCard(task, 40, false, nil)
269
+ }
270
+ }
271
+
272
+ func BenchmarkRenderCard_wide(b *testing.B) {
273
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout for the board view with extra details", Stage: data.StageAudit}
274
+ b.ReportAllocs()
275
+ for b.Loop() {
276
+ RenderCard(task, 60, false, nil)
277
+ }
278
+ }
279
+
280
+ func BenchmarkRenderCard_focused(b *testing.B) {
281
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout", Stage: data.StageBuild}
282
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T004"}
283
+ b.ReportAllocs()
284
+ for b.Loop() {
285
+ RenderCard(task, 40, true, router)
286
+ }
287
+ }
@@ -28,17 +28,84 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
28
28
  if len(tasks) == 0 {
29
29
  lines = append(lines, styles.TaskItem.Render("(empty)"))
30
30
  } else {
31
- limit := visibleColumnTaskLimit(maxHeight)
32
- end := min(offset+limit, len(tasks))
31
+ contentBudget := maxHeight - 2
32
+ if contentBudget < 1 {
33
+ contentBudget = 1
34
+ }
35
+
36
+ type cardEntry struct {
37
+ card string
38
+ lines int
39
+ }
40
+ cardEntries := make([]cardEntry, 0, len(tasks)-offset)
41
+ for i := offset; i < len(tasks); i++ {
42
+ c := RenderCard(tasks[i], inner, focused && i == focusedTask, routerState)
43
+ cardEntries = append(cardEntries, cardEntry{card: c, lines: strings.Count(c, "\n") + 1})
44
+ }
45
+
46
+ // Standard window: fit as many cards as possible from the start of cardEntries.
47
+ reserveAbove := 0
33
48
  if offset > 0 {
34
- lines = append(lines, renderScrollIndicator("↑", offset, "above"))
49
+ reserveAbove = 1
50
+ }
51
+ usedLines := reserveAbove
52
+ endIdx := 0
53
+ for endIdx < len(cardEntries) {
54
+ hasMore := (offset + endIdx + 1) < len(tasks)
55
+ bottomReserve := 0
56
+ if hasMore {
57
+ bottomReserve = 1
58
+ }
59
+ if usedLines+cardEntries[endIdx].lines+bottomReserve > contentBudget {
60
+ break
61
+ }
62
+ usedLines += cardEntries[endIdx].lines
63
+ endIdx++
35
64
  }
36
- for i, t := range tasks[offset:end] {
37
- taskIndex := offset + i
38
- lines = append(lines, RenderCard(t, inner, focused && taskIndex == focusedTask, routerState))
65
+ if endIdx == 0 && len(cardEntries) > 0 {
66
+ endIdx = 1
39
67
  }
40
- if end < len(tasks) {
41
- lines = append(lines, renderScrollIndicator("↓", len(tasks)-end, "more"))
68
+
69
+ // Determine what portion of cardEntries to render.
70
+ renderStart := 0
71
+ renderEnd := endIdx
72
+
73
+ focusedRelIdx := focusedTask - offset
74
+ if focused && focusedRelIdx >= 0 && focusedRelIdx < len(cardEntries) && focusedRelIdx >= endIdx {
75
+ // Focused task is beyond the standard window.
76
+ // Anchor viewport at focused task: fill backward to use remaining budget.
77
+ bottomCost := 0
78
+ if focusedTask+1 < len(tasks) {
79
+ bottomCost = 1
80
+ }
81
+ cardsLines := cardEntries[focusedRelIdx].lines
82
+ newStart := focusedRelIdx
83
+ for newStart > 0 {
84
+ prev := cardEntries[newStart-1]
85
+ topCost := 1
86
+ if offset+newStart-1 == 0 {
87
+ topCost = 0
88
+ }
89
+ if cardsLines+prev.lines+topCost+bottomCost > contentBudget {
90
+ break
91
+ }
92
+ cardsLines += prev.lines
93
+ newStart--
94
+ }
95
+ renderStart = newStart
96
+ renderEnd = focusedRelIdx + 1
97
+ }
98
+
99
+ effectiveOffset := offset + renderStart
100
+ if effectiveOffset > 0 {
101
+ lines = append(lines, renderScrollIndicator("↑", effectiveOffset, "above"))
102
+ }
103
+ for i := renderStart; i < renderEnd; i++ {
104
+ lines = append(lines, cardEntries[i].card)
105
+ }
106
+ if offset+renderEnd < len(tasks) {
107
+ remaining := len(tasks) - (offset + renderEnd)
108
+ lines = append(lines, renderScrollIndicator("↓", remaining, "more"))
42
109
  }
43
110
  }
44
111
 
@@ -87,10 +154,3 @@ func columnTitle(col data.ColumnType) string {
87
154
  return strings.ToUpper(string(col))
88
155
  }
89
156
  }
90
-
91
- func taskLabel(t data.Task) string {
92
- if t.Title == "" {
93
- return t.ID
94
- }
95
- return fmt.Sprintf("%s %s", t.ID, t.Title)
96
- }