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
@@ -0,0 +1,65 @@
1
+ package board
2
+
3
+ import "github.com/opencode/savepoint/internal/data"
4
+
5
+ // taskDiscoverer provides project traversal for board loading.
6
+ type taskDiscoverer interface {
7
+ FindSavepointRoot(start string) (string, error)
8
+ ListReleases(root string) ([]data.ReleaseInfo, error)
9
+ ListEpics(root, release string) ([]data.EpicInfo, error)
10
+ ListTasks(root, release, epic string) ([]data.TaskInfo, error)
11
+ }
12
+
13
+ // taskParser parses Savepoint frontmatter and task files for board loading.
14
+ type taskParser interface {
15
+ ParseFrontmatter(content string) (map[string]any, error)
16
+ ParseTaskFile(path string, content string) (*data.Task, error)
17
+ }
18
+
19
+ // configReader reads board display configuration.
20
+ type configReader interface {
21
+ Read(path string) (*data.Config, error)
22
+ }
23
+
24
+ // routerReader parses router state from router.md content.
25
+ type routerReader interface {
26
+ ReadState(content string) (*data.RouterState, error)
27
+ }
28
+
29
+ // ModelDependencies contains board data-access dependencies.
30
+ type ModelDependencies struct {
31
+ Discoverer taskDiscoverer
32
+ Parser taskParser
33
+ ConfigReader configReader
34
+ RouterReader routerReader
35
+ }
36
+
37
+ func defaultModelDependencies() ModelDependencies {
38
+ return ModelDependencies{
39
+ Discoverer: data.NewDiscover(),
40
+ Parser: data.NewParser(),
41
+ ConfigReader: data.NewConfigReader(),
42
+ RouterReader: data.NewRouterReader(),
43
+ }
44
+ }
45
+
46
+ func modelDependencies(overrides []ModelDependencies) ModelDependencies {
47
+ deps := defaultModelDependencies()
48
+ if len(overrides) == 0 {
49
+ return deps
50
+ }
51
+ override := overrides[0]
52
+ if override.Discoverer != nil {
53
+ deps.Discoverer = override.Discoverer
54
+ }
55
+ if override.Parser != nil {
56
+ deps.Parser = override.Parser
57
+ }
58
+ if override.ConfigReader != nil {
59
+ deps.ConfigReader = override.ConfigReader
60
+ }
61
+ if override.RouterReader != nil {
62
+ deps.RouterReader = override.RouterReader
63
+ }
64
+ return deps
65
+ }
@@ -0,0 +1,114 @@
1
+ package board
2
+
3
+ import (
4
+ "path/filepath"
5
+ "testing"
6
+
7
+ "github.com/opencode/savepoint/internal/data"
8
+ "github.com/opencode/savepoint/internal/testutil"
9
+ )
10
+
11
+ type stubBoardDiscoverer struct {
12
+ root string
13
+ releases []data.ReleaseInfo
14
+ epics map[string][]data.EpicInfo
15
+ tasks map[string][]data.TaskInfo
16
+ findCalls int
17
+ }
18
+
19
+ func (d *stubBoardDiscoverer) FindSavepointRoot(start string) (string, error) {
20
+ d.findCalls++
21
+ return d.root, nil
22
+ }
23
+
24
+ func (d *stubBoardDiscoverer) ListReleases(root string) ([]data.ReleaseInfo, error) {
25
+ return d.releases, nil
26
+ }
27
+
28
+ func (d *stubBoardDiscoverer) ListEpics(root, release string) ([]data.EpicInfo, error) {
29
+ return d.epics[release], nil
30
+ }
31
+
32
+ func (d *stubBoardDiscoverer) ListTasks(root, release, epic string) ([]data.TaskInfo, error) {
33
+ return d.tasks[release+"/"+epic], nil
34
+ }
35
+
36
+ type countingBoardParser struct {
37
+ parser *data.Parser
38
+ frontmatterCalls int
39
+ taskFileCalls int
40
+ }
41
+
42
+ func (p *countingBoardParser) ParseFrontmatter(content string) (map[string]any, error) {
43
+ p.frontmatterCalls++
44
+ return p.parser.ParseFrontmatter(content)
45
+ }
46
+
47
+ func (p *countingBoardParser) ParseTaskFile(path string, content string) (*data.Task, error) {
48
+ p.taskFileCalls++
49
+ return p.parser.ParseTaskFile(path, content)
50
+ }
51
+
52
+ type stubBoardRouterReader struct {
53
+ state *data.RouterState
54
+ calls int
55
+ }
56
+
57
+ func (r *stubBoardRouterReader) ReadState(content string) (*data.RouterState, error) {
58
+ r.calls++
59
+ return r.state, nil
60
+ }
61
+
62
+ func TestNewProjectModelUsesInjectedInterfaces(t *testing.T) {
63
+ projectRoot := t.TempDir()
64
+ savepointRoot := filepath.Join(projectRoot, ".savepoint")
65
+ epicPath := filepath.Join(savepointRoot, "releases", "v9", "epics", "E01-mock")
66
+ taskPath := filepath.Join(epicPath, "tasks", "T001-mock.md")
67
+
68
+ testutil.WriteFile(t, filepath.Join(savepointRoot, "router.md"), "# router")
69
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# Epic\n")
70
+ testutil.WriteFile(t, taskPath, "---\nid: E01-mock/T001-mock\nstatus: planned\nobjective: Mock task\ndepends_on: []\n---\n\n# Task\n")
71
+
72
+ discoverer := &stubBoardDiscoverer{
73
+ root: savepointRoot,
74
+ releases: []data.ReleaseInfo{{
75
+ ID: "v9",
76
+ Path: filepath.Join(savepointRoot, "releases", "v9"),
77
+ }},
78
+ epics: map[string][]data.EpicInfo{
79
+ "v9": {{ID: "E01-mock", Path: epicPath}},
80
+ },
81
+ tasks: map[string][]data.TaskInfo{
82
+ "v9/E01-mock": {{ID: "T001-mock", Path: taskPath}},
83
+ },
84
+ }
85
+ parser := &countingBoardParser{parser: data.NewParser()}
86
+ router := &stubBoardRouterReader{state: &data.RouterState{
87
+ State: "task-building",
88
+ Release: "v9",
89
+ Epic: "E01-mock",
90
+ Task: "E01-mock/T001-mock",
91
+ }}
92
+
93
+ model, err := newProjectModelWithDependencies(projectRoot, "", "", ModelDependencies{
94
+ Discoverer: discoverer,
95
+ Parser: parser,
96
+ RouterReader: router,
97
+ })
98
+ if err != nil {
99
+ t.Fatalf("newProjectModelWithDependencies() error = %v", err)
100
+ }
101
+
102
+ if discoverer.findCalls != 1 {
103
+ t.Fatalf("FindSavepointRoot calls = %d, want 1", discoverer.findCalls)
104
+ }
105
+ if router.calls != 1 {
106
+ t.Fatalf("ReadState calls = %d, want 1", router.calls)
107
+ }
108
+ if parser.frontmatterCalls != 1 || parser.taskFileCalls != 1 {
109
+ t.Fatalf("parser calls = frontmatter:%d task:%d, want 1 each", parser.frontmatterCalls, parser.taskFileCalls)
110
+ }
111
+ if got := model.Tasks[data.ColumnPlanned][0].ID; got != "E01-mock/T001-mock" {
112
+ t.Fatalf("loaded task = %q, want injected task", got)
113
+ }
114
+ }
@@ -0,0 +1,93 @@
1
+ package board
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "time"
7
+
8
+ tea "github.com/charmbracelet/bubbletea"
9
+ "github.com/opencode/savepoint/internal/data"
10
+ )
11
+
12
+ func writeRouterTaskCmd(root string, task data.Task, reader routerReader) tea.Cmd {
13
+ return func() tea.Msg {
14
+ routerPath := filepath.Join(root, "router.md")
15
+ fi, err := os.Stat(routerPath)
16
+ if err != nil {
17
+ return errorMsg{message: err.Error()}
18
+ }
19
+ content, err := os.ReadFile(routerPath)
20
+ if err != nil {
21
+ return errorMsg{message: err.Error()}
22
+ }
23
+ state, err := reader.ReadState(string(content))
24
+ if err != nil {
25
+ return errorMsg{message: err.Error()}
26
+ }
27
+ state.Release = task.Release
28
+ state.Epic = task.Epic
29
+ state.State = "task-building"
30
+ state.Task = task.ID
31
+ state.NextAction = "Build " + task.ID + "."
32
+ if err := data.WriteRouterState(root, state, fi.ModTime()); err != nil {
33
+ return errorMsg{message: err.Error()}
34
+ }
35
+ message := "Router set to " + task.Release + " " + task.Epic + "/" + shortID(task.ID)
36
+ return routerWriteMsg{message: message, state: state, taskID: task.ID}
37
+ }
38
+ }
39
+
40
+ func writeRouterReleaseEpicCmd(root, selectedEpic, selectedRelease string, reader routerReader) tea.Cmd {
41
+ return func() tea.Msg {
42
+ routerPath := filepath.Join(root, "router.md")
43
+ fi, err := os.Stat(routerPath)
44
+ if err != nil {
45
+ return errorMsg{message: err.Error()}
46
+ }
47
+ content, err := os.ReadFile(routerPath)
48
+ if err != nil {
49
+ return errorMsg{message: err.Error()}
50
+ }
51
+ state, err := reader.ReadState(string(content))
52
+ if err != nil {
53
+ return errorMsg{message: err.Error()}
54
+ }
55
+ state.Epic = shortID(selectedEpic)
56
+ state.Release = selectedRelease
57
+ if err := data.WriteRouterState(root, state, fi.ModTime()); err != nil {
58
+ return errorMsg{message: err.Error()}
59
+ }
60
+ return routerWriteMsg{state: state}
61
+ }
62
+ }
63
+
64
+ func writeTaskStatusCmd(orig, next data.Task, expectedMtime time.Time, prefix string) tea.Cmd {
65
+ return func() tea.Msg {
66
+ if err := data.WriteTaskStatus(next.Path, &next, expectedMtime); err != nil {
67
+ return errorMsg{message: taskWriteErrorMessage(err)}
68
+ }
69
+ fi, err := os.Stat(next.Path)
70
+ if err != nil {
71
+ return errorMsg{message: err.Error()}
72
+ }
73
+ next.Mtime = fi.ModTime()
74
+ return taskWriteMsg{prefix: prefix, next: next}
75
+ }
76
+ }
77
+
78
+ func readEpicDetailCmd(epicDir, shortIDStr string) tea.Cmd {
79
+ return func() tea.Msg {
80
+ content := readEpicDetailFile(epicDir, shortIDStr)
81
+ return epicDetailMsg{content: content}
82
+ }
83
+ }
84
+
85
+ func readEpicAuditCmd(epicDir, shortIDStr string) tea.Cmd {
86
+ return func() tea.Msg {
87
+ raw, err := os.ReadFile(filepath.Join(epicDir, shortIDStr+"-Audit.md"))
88
+ if err != nil {
89
+ return auditContentMsg{content: "(no audit available)"}
90
+ }
91
+ return auditContentMsg{content: string(raw)}
92
+ }
93
+ }
@@ -1,9 +1,6 @@
1
1
  package board
2
2
 
3
3
  import (
4
- "os"
5
- "path/filepath"
6
-
7
4
  tea "github.com/charmbracelet/bubbletea"
8
5
  "github.com/fsnotify/fsnotify"
9
6
  "github.com/opencode/savepoint/internal/data"
@@ -20,46 +17,94 @@ const (
20
17
  OverlayEpicDetail OverlayType = "detail-epic"
21
18
  )
22
19
 
23
- // Model holds all board state. Tasks are grouped by column for O(1) column access.
24
- type Model struct {
25
- AllTasks []data.Task
26
- Tasks map[data.ColumnType][]data.Task
27
- FocusedColumn data.ColumnType
28
- FocusedTask int
29
- ColumnOffsets map[data.ColumnType]int
30
- DetailOffset int
20
+ // ViewConfig holds terminal and overlay presentation state.
21
+ type ViewConfig struct {
22
+ Theme data.Theme
23
+ Overlay OverlayType
24
+ Width int
25
+ Height int
26
+ StatusMessage string
27
+ }
28
+
29
+ // DataState holds task, router, and filesystem state used by the board.
30
+ type DataState struct {
31
+ AllTasks []data.Task
32
+ Tasks map[data.ColumnType][]data.Task
33
+ Root string
34
+ EpicStatus map[string]string
35
+ RouterTask string
36
+ RouterState *data.RouterState
37
+ Watcher *fsnotify.Watcher
38
+ }
39
+
40
+ // NavigationState holds board-column and detail scrolling state.
41
+ type NavigationState struct {
42
+ FocusedColumn data.ColumnType
43
+ FocusedTask int
44
+ ColumnOffsets map[data.ColumnType]int
45
+ DetailOffset int
46
+ }
47
+
48
+ // EpicState holds epic list, sidebar, and detail overlay state.
49
+ type EpicState struct {
31
50
  SelectedEpic string
32
- SelectedRelease string
33
51
  Epics []string
34
52
  EpicCursor int
35
53
  EpicPanelFocus bool
36
54
  EpicPanelCursor int
37
55
  EpicDetailOffset int
56
+ EpicDetailEpic string
38
57
  EpicDetailContent string
39
- Releases []string
40
- ReleaseEpics map[string][]string
41
- ReleaseCursor int
42
- Overlay OverlayType
43
- Width int
44
- Height int
45
- StatusMessage string
46
- Root string
47
- EpicStatus map[string]string
48
- RouterTask string
49
- RouterState *data.RouterState
50
- Watcher *fsnotify.Watcher
58
+ EpicDetailTab int // 0=Detail, 1=Audit
59
+ EpicAuditContent string // cached E##-Audit.md content
60
+ }
61
+
62
+ // ReleaseState holds release list and release picker state.
63
+ type ReleaseState struct {
64
+ SelectedRelease string
65
+ Releases []string
66
+ ReleaseEpics map[string][]string
67
+ ReleaseCursor int
68
+ }
69
+
70
+ // DataAccessState holds board data-access implementations.
71
+ type DataAccessState struct {
72
+ Dependencies ModelDependencies
73
+ }
74
+
75
+ // Model holds all board state. Tasks are grouped by column for O(1) column access.
76
+ type Model struct {
77
+ ViewConfig
78
+ DataState
79
+ NavigationState
80
+ EpicState
81
+ ReleaseState
82
+ DataAccessState
51
83
  }
52
84
 
53
85
  // NewModel groups tasks by column and returns an initialized Model.
54
- func NewModel(tasks []data.Task, release, epic string) Model {
86
+ func NewModel(tasks []data.Task, release, epic string, deps ...ModelDependencies) Model {
55
87
  m := Model{
56
- AllTasks: append([]data.Task(nil), tasks...),
57
- FocusedColumn: data.ColumnPlanned,
58
- FocusedTask: 0,
59
- ColumnOffsets: newColumnOffsets(),
60
- SelectedEpic: epic,
61
- SelectedRelease: release,
62
- Overlay: OverlayNone,
88
+ ViewConfig: ViewConfig{
89
+ Overlay: OverlayNone,
90
+ },
91
+ DataState: DataState{
92
+ AllTasks: append([]data.Task(nil), tasks...),
93
+ },
94
+ NavigationState: NavigationState{
95
+ FocusedColumn: data.ColumnPlanned,
96
+ FocusedTask: 0,
97
+ ColumnOffsets: newColumnOffsets(),
98
+ },
99
+ EpicState: EpicState{
100
+ SelectedEpic: epic,
101
+ },
102
+ ReleaseState: ReleaseState{
103
+ SelectedRelease: release,
104
+ },
105
+ DataAccessState: DataAccessState{
106
+ Dependencies: modelDependencies(deps),
107
+ },
63
108
  }
64
109
  m.refreshTasks()
65
110
  return m
@@ -129,7 +174,7 @@ func (m *Model) refreshEpicsForRelease() {
129
174
 
130
175
  for _, epic := range m.Epics {
131
176
  if epic == m.SelectedEpic {
132
- m.EpicCursor = epicIndex(m.Epics, m.SelectedEpic)
177
+ m.EpicCursor = sliceIndex(m.Epics, m.SelectedEpic)
133
178
  m.clampEpicPanelCursor()
134
179
  return
135
180
  }
@@ -185,90 +230,6 @@ func (m *Model) clampColumnOffsets() {
185
230
  }
186
231
  }
187
232
 
188
- func (m *Model) writeRouterReleaseEpic() error {
189
- routerPath := filepath.Join(m.Root, "router.md")
190
-
191
- fi, err := os.Stat(routerPath)
192
- if err != nil {
193
- return err
194
- }
195
-
196
- content, err := os.ReadFile(routerPath)
197
- if err != nil {
198
- return err
199
- }
200
-
201
- r := data.NewRouterReader()
202
- state, err := r.ReadState(string(content))
203
- if err != nil {
204
- return err
205
- }
206
-
207
- state.Epic = shortID(m.SelectedEpic)
208
- state.Release = m.SelectedRelease
209
-
210
- return data.WriteRouterState(m.Root, state, fi.ModTime())
211
- }
212
-
213
- func (m *Model) writeRouterTask(task data.Task) (string, error) {
214
- routerPath := filepath.Join(m.Root, "router.md")
215
-
216
- fi, err := os.Stat(routerPath)
217
- if err != nil {
218
- return "", err
219
- }
220
-
221
- content, err := os.ReadFile(routerPath)
222
- if err != nil {
223
- return "", err
224
- }
225
-
226
- r := data.NewRouterReader()
227
- state, err := r.ReadState(string(content))
228
- if err != nil {
229
- return "", err
230
- }
231
-
232
- state.Release = task.Release
233
- state.Epic = task.Epic
234
- if m.isLastUncompletedTask(task) {
235
- state.State = "audit-pending"
236
- state.Task = ""
237
- state.NextAction = "Audit " + task.Epic + "."
238
- if err := data.WriteRouterState(m.Root, state, fi.ModTime()); err != nil {
239
- return "", err
240
- }
241
- m.RouterState = state
242
- m.RouterTask = ""
243
- return "Audit pending for " + task.Epic, nil
244
- }
245
-
246
- state.State = "task-building"
247
- state.Task = task.ID
248
- state.NextAction = "Build " + task.ID + "."
249
- if err := data.WriteRouterState(m.Root, state, fi.ModTime()); err != nil {
250
- return "", err
251
- }
252
- m.RouterState = state
253
- m.RouterTask = task.ID
254
- return "Router set to " + task.Release + " " + task.Epic + "/" + shortID(task.ID), nil
255
- }
256
-
257
- func (m Model) isLastUncompletedTask(task data.Task) bool {
258
- for _, candidate := range m.AllTasks {
259
- if candidate.ID == task.ID {
260
- continue
261
- }
262
- if candidate.Release != task.Release || candidate.Epic != task.Epic {
263
- continue
264
- }
265
- if !taskDone(candidate) {
266
- return false
267
- }
268
- }
269
- return true
270
- }
271
-
272
233
  func taskDone(task data.Task) bool {
273
- return task.Column == data.ColumnDone || task.Status == string(data.StatusDone)
234
+ return task.Column == data.ColumnDone
274
235
  }
@@ -0,0 +1,88 @@
1
+ package board
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+
9
+ "github.com/opencode/savepoint/internal/data"
10
+ )
11
+
12
+ const plainNonTTYWarning = "[non-interactive mode — run in a TTY to launch the board UI]"
13
+ const plainAuditSignal = "[◆ audit proposals pending]"
14
+
15
+ // RenderPlainTable renders a plain text three-column task table for non-TTY output.
16
+ func RenderPlainTable(model Model) string {
17
+ var b strings.Builder
18
+
19
+ fmt.Fprintln(&b, plainNonTTYWarning)
20
+ if hasAuditProposals(model.Root) {
21
+ fmt.Fprintln(&b, plainAuditSignal)
22
+ }
23
+ fmt.Fprintln(&b)
24
+
25
+ cols := []struct {
26
+ label string
27
+ col data.ColumnType
28
+ }{
29
+ {"PLANNED", data.ColumnPlanned},
30
+ {"IN PROGRESS", data.ColumnInProgress},
31
+ {"DONE", data.ColumnDone},
32
+ }
33
+
34
+ for _, c := range cols {
35
+ tasks := model.Tasks[c.col]
36
+ fmt.Fprintln(&b, c.label)
37
+ if len(tasks) == 0 {
38
+ fmt.Fprintln(&b, " (none)")
39
+ }
40
+ for _, t := range tasks {
41
+ title := t.Title
42
+ if title == "" {
43
+ title = "(no title)"
44
+ }
45
+ fmt.Fprintf(&b, " %-52s %s\n", t.ID, title)
46
+ }
47
+ fmt.Fprintln(&b)
48
+ }
49
+
50
+ return b.String()
51
+ }
52
+
53
+ // hasAuditProposals reports whether any audit file under root contains a Proposed Changes section.
54
+ func hasAuditProposals(root string) bool {
55
+ releasesDir := filepath.Join(root, "releases")
56
+ releases, err := os.ReadDir(releasesDir)
57
+ if err != nil {
58
+ return false
59
+ }
60
+ for _, r := range releases {
61
+ if !r.IsDir() {
62
+ continue
63
+ }
64
+ epicsDir := filepath.Join(releasesDir, r.Name(), "epics")
65
+ epics, err := os.ReadDir(epicsDir)
66
+ if err != nil {
67
+ continue
68
+ }
69
+ for _, e := range epics {
70
+ if !e.IsDir() {
71
+ continue
72
+ }
73
+ short := e.Name()
74
+ if idx := strings.Index(short, "-"); idx >= 0 {
75
+ short = short[:idx]
76
+ }
77
+ auditPath := filepath.Join(epicsDir, e.Name(), short+"-Audit.md")
78
+ raw, err := os.ReadFile(auditPath)
79
+ if err != nil {
80
+ continue
81
+ }
82
+ if strings.Contains(string(raw), "## Proposed Changes") {
83
+ return true
84
+ }
85
+ }
86
+ }
87
+ return false
88
+ }