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
@@ -1,6 +1,7 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "fmt"
4
5
  "strings"
5
6
  "testing"
6
7
 
@@ -66,7 +67,7 @@ func TestView_containsFooterHints(t *testing.T) {
66
67
  m := NewModel(nil, "v1", "E03")
67
68
  footer := m.renderFooter(80)
68
69
 
69
- if !strings.Contains(footer, "←/→:nav E:epic R:release ?:help q:quit") {
70
+ if !strings.Contains(footer, "←/→:nav p: Priority R:release ?:help q:quit") {
70
71
  t.Fatal("renderFooter() missing navigation hints")
71
72
  }
72
73
 
@@ -75,7 +76,7 @@ func TestView_containsFooterHints(t *testing.T) {
75
76
  t.Fatalf("renderFooter() returned %d lines, want 3", len(lines))
76
77
  }
77
78
  if strings.TrimSpace(plainTerminal(lines[1])) != "" {
78
- t.Fatalf("renderFooter() spacer line = %q, want blank", lines[1])
79
+ t.Fatalf("renderFooter() status line = %q, want blank", lines[1])
79
80
  }
80
81
  for i, line := range lines {
81
82
  if got := lipgloss.Width(line); got > 80 {
@@ -84,6 +85,16 @@ func TestView_containsFooterHints(t *testing.T) {
84
85
  }
85
86
  }
86
87
 
88
+ func TestView_footerRendersStatusMessage(t *testing.T) {
89
+ m := NewModel(nil, "v1", "E03")
90
+ m.StatusMessage = "Router set to v1.1 E05-tasking-permissions/T004"
91
+ footer := plainTerminal(m.renderFooter(80))
92
+
93
+ if !strings.Contains(footer, "Router set to v1.1 E05-tasking-permissions/T004") {
94
+ t.Fatal("renderFooter() missing status message")
95
+ }
96
+ }
97
+
87
98
  func TestView_containsBottomDivider(t *testing.T) {
88
99
  m := NewModel(nil, "v1", "E03")
89
100
  m.Width = 120
@@ -291,6 +302,67 @@ func TestRenderNextActivityLine_truncatesAtNarrowWidth(t *testing.T) {
291
302
  }
292
303
  }
293
304
 
305
+ func BenchmarkCalculateLayout_narrow(b *testing.B) {
306
+ b.ReportAllocs()
307
+ for b.Loop() {
308
+ CalculateLayout(60, 24)
309
+ }
310
+ }
311
+
312
+ func BenchmarkCalculateLayout_standard(b *testing.B) {
313
+ b.ReportAllocs()
314
+ for b.Loop() {
315
+ CalculateLayout(80, 24)
316
+ }
317
+ }
318
+
319
+ func BenchmarkCalculateLayout_wide(b *testing.B) {
320
+ b.ReportAllocs()
321
+ for b.Loop() {
322
+ CalculateLayout(120, 24)
323
+ }
324
+ }
325
+
326
+ func BenchmarkCalculateLayout_extraWide(b *testing.B) {
327
+ b.ReportAllocs()
328
+ for b.Loop() {
329
+ CalculateLayout(220, 50)
330
+ }
331
+ }
332
+
333
+ func BenchmarkView_empty(b *testing.B) {
334
+ m := NewModel(nil, "v1", "E03")
335
+ m.Width = 120
336
+ m.Height = 40
337
+ b.ReportAllocs()
338
+ for b.Loop() {
339
+ m.View()
340
+ }
341
+ }
342
+
343
+ func BenchmarkView_withTasks(b *testing.B) {
344
+ tasks := make([]data.Task, 15)
345
+ stages := []data.ProgressStage{data.StageBuild, data.StageTest, data.StageAudit}
346
+ cols := []data.ColumnType{data.ColumnPlanned, data.ColumnInProgress, data.ColumnDone}
347
+ for i := range tasks {
348
+ tasks[i] = data.Task{
349
+ ID: fmt.Sprintf("E06-layout/T%03d-task-slug", i+1),
350
+ Title: fmt.Sprintf("Task %d with a reasonable title length", i+1),
351
+ Column: cols[i%3],
352
+ Stage: stages[i%3],
353
+ Release: "v1.1",
354
+ Epic: "E06-layout",
355
+ }
356
+ }
357
+ m := NewModel(tasks, "v1.1", "E06")
358
+ m.Width = 120
359
+ m.Height = 40
360
+ b.ReportAllocs()
361
+ for b.Loop() {
362
+ m.View()
363
+ }
364
+ }
365
+
294
366
  func TestView_narrowShowsSingleColumn(t *testing.T) {
295
367
  m := NewModel(nil, "v1", "E03")
296
368
  m.Width = 60
@@ -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.
@@ -27,6 +52,7 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
27
52
  if !ok {
28
53
  return nil
29
54
  }
55
+ debugf("watcher: event %s", event)
30
56
  watchCreatedDir(w, event)
31
57
  timer := time.NewTimer(100 * time.Millisecond)
32
58
  drain:
@@ -37,11 +63,13 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
37
63
  timer.Stop()
38
64
  return nil
39
65
  }
66
+ debugf("watcher: event %s", event)
40
67
  watchCreatedDir(w, event)
41
68
  case <-timer.C:
42
69
  break drain
43
70
  }
44
71
  }
72
+ debugf("watcher: emitting fileChangeMsg")
45
73
  return fileChangeMsg{}
46
74
  case _, ok := <-w.Errors:
47
75
  if !ok {
@@ -52,22 +80,30 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
52
80
  }
53
81
  }
54
82
 
55
- func reloadTasks(root string) tea.Cmd {
83
+ func reloadTasks(root string, deps ModelDependencies) tea.Cmd {
56
84
  return func() tea.Msg {
57
- tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root)
85
+ debugf("reload: starting task reload from %q", root)
86
+ tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root, deps.Discoverer, deps.Parser)
58
87
  if err != nil {
59
- return nil
88
+ debugf("reload: error: %v", err)
89
+ return errorMsg{message: "reload failed: " + err.Error()}
60
90
  }
61
- return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses}
91
+ debugf("reload: loaded %d tasks", len(tasks))
92
+ routerState, _ := readRouterState(root, deps.RouterReader)
93
+ return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses, routerState: routerState}
62
94
  }
63
95
  }
64
96
 
65
- // newWatcher watches the releases directory by walking all subdirs (fsnotify v1.10 has no recursive opt).
97
+ // newWatcher watches the savepoint root (for router.md) and all releases subdirs.
66
98
  func newWatcher(root string) (*fsnotify.Watcher, error) {
67
99
  w, err := fsnotify.NewWatcher()
68
100
  if err != nil {
69
101
  return nil, err
70
102
  }
103
+ if err := w.Add(root); err != nil {
104
+ w.Close()
105
+ return nil, err
106
+ }
71
107
  releasesPath := filepath.Join(root, "releases")
72
108
  if err := addDirsRecursive(w, releasesPath); err != nil {
73
109
  w.Close()
@@ -3,6 +3,8 @@ package main
3
3
  import (
4
4
  "archive/tar"
5
5
  "compress/gzip"
6
+ "crypto/sha256"
7
+ "encoding/hex"
6
8
  "errors"
7
9
  "flag"
8
10
  "fmt"
@@ -11,6 +13,7 @@ import (
11
13
  "os/exec"
12
14
  "path/filepath"
13
15
  "runtime"
16
+ "strings"
14
17
  )
15
18
 
16
19
  type target struct {
@@ -23,6 +26,8 @@ var targets = []target{
23
26
  {os: "linux", arch: "arm64"},
24
27
  {os: "darwin", arch: "amd64"},
25
28
  {os: "darwin", arch: "arm64"},
29
+ {os: "windows", arch: "amd64"},
30
+ {os: "windows", arch: "arm64"},
26
31
  }
27
32
 
28
33
  var versionOverride string
@@ -41,7 +46,7 @@ func run(args []string) error {
41
46
  return err
42
47
  }
43
48
  if flags.NArg() != 1 {
44
- return errors.New("usage: go run ./internal/buildtool [-version vX.Y.Z] <build|clean|build-linux|build-darwin|build-all|dist|smoke-test>")
49
+ return errors.New("usage: go run ./internal/buildtool [-version vX.Y.Z] <build|clean|build-linux|build-darwin|build-windows|build-all|dist|smoke-test>")
45
50
  }
46
51
 
47
52
  switch flags.Arg(0) {
@@ -53,6 +58,8 @@ func run(args []string) error {
53
58
  return buildMatching("linux")
54
59
  case "build-darwin":
55
60
  return buildMatching("darwin")
61
+ case "build-windows":
62
+ return buildMatching("windows")
56
63
  case "build-all":
57
64
  return buildAll()
58
65
  case "dist":
@@ -98,8 +105,15 @@ func buildAll() error {
98
105
  return nil
99
106
  }
100
107
 
108
+ func executableName(goos string) string {
109
+ if goos == "windows" {
110
+ return "savepoint.exe"
111
+ }
112
+ return "savepoint"
113
+ }
114
+
101
115
  func buildTarget(target target) error {
102
- output := filepath.Join("dist", target.os+"-"+target.arch, "savepoint")
116
+ output := filepath.Join("dist", target.os+"-"+target.arch, executableName(target.os))
103
117
  return runGoBuild(output, target.os, target.arch)
104
118
  }
105
119
 
@@ -122,13 +136,39 @@ func dist() error {
122
136
  if err := buildAll(); err != nil {
123
137
  return err
124
138
  }
139
+ var archives []string
125
140
  for _, target := range targets {
126
141
  name := "savepoint-" + version() + "-" + target.os + "-" + target.arch + ".tar.gz"
127
- source := filepath.Join("dist", target.os+"-"+target.arch, "savepoint")
142
+ source := filepath.Join("dist", target.os+"-"+target.arch, executableName(target.os))
128
143
  archive := filepath.Join("dist", name)
129
- if err := writeTarGz(archive, source, "savepoint"); err != nil {
144
+ if err := writeTarGz(archive, source, executableName(target.os)); err != nil {
130
145
  return err
131
146
  }
147
+ archives = append(archives, archive)
148
+ }
149
+ return writeChecksums(filepath.Join("dist", "checksums.txt"), archives)
150
+ }
151
+
152
+ func writeChecksums(dest string, archives []string) error {
153
+ var lines strings.Builder
154
+ for _, path := range archives {
155
+ f, err := os.Open(path)
156
+ if err != nil {
157
+ return fmt.Errorf("checksum open %s: %w", path, err)
158
+ }
159
+ h := sha256.New()
160
+ if _, err := io.Copy(h, f); err != nil {
161
+ f.Close()
162
+ return fmt.Errorf("checksum read %s: %w", path, err)
163
+ }
164
+ f.Close()
165
+ lines.WriteString(hex.EncodeToString(h.Sum(nil)))
166
+ lines.WriteString(" ")
167
+ lines.WriteString(filepath.Base(path))
168
+ lines.WriteString("\n")
169
+ }
170
+ if err := os.WriteFile(dest, []byte(lines.String()), 0o644); err != nil {
171
+ return fmt.Errorf("write checksums: %w", err)
132
172
  }
133
173
  return nil
134
174
  }
@@ -196,7 +236,7 @@ func version() string {
196
236
  cmd := exec.Command("git", "describe", "--tags", "--abbrev=0")
197
237
  output, err := cmd.Output()
198
238
  if err == nil && len(output) > 0 {
199
- return string(trimSpace(output))
239
+ return strings.TrimSpace(string(output))
200
240
  }
201
241
  return "v0.0.0"
202
242
  }
@@ -207,13 +247,3 @@ func localExecutable() string {
207
247
  }
208
248
  return "savepoint"
209
249
  }
210
-
211
- func trimSpace(value []byte) []byte {
212
- for len(value) > 0 && (value[len(value)-1] == '\n' || value[len(value)-1] == '\r' || value[len(value)-1] == '\t' || value[len(value)-1] == ' ') {
213
- value = value[:len(value)-1]
214
- }
215
- for len(value) > 0 && (value[0] == '\n' || value[0] == '\r' || value[0] == '\t' || value[0] == ' ') {
216
- value = value[1:]
217
- }
218
- return value
219
- }
@@ -0,0 +1,224 @@
1
+ package main
2
+
3
+ import (
4
+ "archive/tar"
5
+ "compress/gzip"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "io"
9
+ "os"
10
+ "path/filepath"
11
+ "runtime"
12
+ "strings"
13
+ "testing"
14
+ )
15
+
16
+ func TestVersion_override(t *testing.T) {
17
+ versionOverride = "v1.2.3"
18
+ defer func() { versionOverride = "" }()
19
+ if got := version(); got != "v1.2.3" {
20
+ t.Errorf("version() = %q, want %q", got, "v1.2.3")
21
+ }
22
+ }
23
+
24
+ func TestVersion_env(t *testing.T) {
25
+ versionOverride = ""
26
+ os.Setenv("VERSION", "v2.0.0-env")
27
+ defer os.Unsetenv("VERSION")
28
+ if got := version(); got != "v2.0.0-env" {
29
+ t.Errorf("version() = %q, want %q", got, "v2.0.0-env")
30
+ }
31
+ }
32
+
33
+ func TestVersion_fallback(t *testing.T) {
34
+ versionOverride = ""
35
+ os.Unsetenv("VERSION")
36
+ got := version()
37
+ if got == "" {
38
+ t.Error("version() returned empty string")
39
+ }
40
+ }
41
+
42
+ func TestWriteChecksums(t *testing.T) {
43
+ dir := t.TempDir()
44
+
45
+ content := []byte("fake archive content")
46
+ archive := filepath.Join(dir, "savepoint-v1.0.0-linux-amd64.tar.gz")
47
+ if err := os.WriteFile(archive, content, 0o644); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+
51
+ dest := filepath.Join(dir, "checksums.txt")
52
+ if err := writeChecksums(dest, []string{archive}); err != nil {
53
+ t.Fatalf("writeChecksums: %v", err)
54
+ }
55
+
56
+ got, err := os.ReadFile(dest)
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+
61
+ h := sha256.Sum256(content)
62
+ wantHash := hex.EncodeToString(h[:])
63
+ wantLine := wantHash + " savepoint-v1.0.0-linux-amd64.tar.gz"
64
+
65
+ lines := strings.Split(strings.TrimSpace(string(got)), "\n")
66
+ if len(lines) != 1 {
67
+ t.Fatalf("expected 1 line, got %d", len(lines))
68
+ }
69
+ if lines[0] != wantLine {
70
+ t.Errorf("line = %q, want %q", lines[0], wantLine)
71
+ }
72
+ }
73
+
74
+ func TestWriteChecksums_multiple(t *testing.T) {
75
+ dir := t.TempDir()
76
+
77
+ names := []string{"a.tar.gz", "b.tar.gz"}
78
+ var paths []string
79
+ for _, name := range names {
80
+ p := filepath.Join(dir, name)
81
+ if err := os.WriteFile(p, []byte(name), 0o644); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ paths = append(paths, p)
85
+ }
86
+
87
+ dest := filepath.Join(dir, "checksums.txt")
88
+ if err := writeChecksums(dest, paths); err != nil {
89
+ t.Fatalf("writeChecksums: %v", err)
90
+ }
91
+
92
+ got, err := os.ReadFile(dest)
93
+ if err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ lines := strings.Split(strings.TrimSpace(string(got)), "\n")
97
+ if len(lines) != 2 {
98
+ t.Fatalf("expected 2 lines, got %d: %s", len(lines), got)
99
+ }
100
+ for i, name := range names {
101
+ h := sha256.Sum256([]byte(name))
102
+ want := hex.EncodeToString(h[:]) + " " + name
103
+ if lines[i] != want {
104
+ t.Errorf("line[%d] = %q, want %q", i, lines[i], want)
105
+ }
106
+ }
107
+ }
108
+
109
+ func TestWriteChecksums_missingFile(t *testing.T) {
110
+ dir := t.TempDir()
111
+ dest := filepath.Join(dir, "checksums.txt")
112
+ err := writeChecksums(dest, []string{filepath.Join(dir, "nonexistent.tar.gz")})
113
+ if err == nil {
114
+ t.Error("expected error for missing archive, got nil")
115
+ }
116
+ }
117
+
118
+ func TestTargets_includesWindows(t *testing.T) {
119
+ var gotAMD64, gotARM64 bool
120
+ for _, tgt := range targets {
121
+ if tgt.os != "windows" {
122
+ continue
123
+ }
124
+ switch tgt.arch {
125
+ case "amd64":
126
+ gotAMD64 = true
127
+ case "arm64":
128
+ gotARM64 = true
129
+ }
130
+ }
131
+ if !gotAMD64 {
132
+ t.Error("targets missing windows/amd64")
133
+ }
134
+ if !gotARM64 {
135
+ t.Error("targets missing windows/arm64")
136
+ }
137
+ }
138
+
139
+ func TestTargets_preservesLinuxDarwin(t *testing.T) {
140
+ want := map[string]bool{
141
+ "linux/amd64": false,
142
+ "linux/arm64": false,
143
+ "darwin/amd64": false,
144
+ "darwin/arm64": false,
145
+ }
146
+ for _, tgt := range targets {
147
+ key := tgt.os + "/" + tgt.arch
148
+ if _, ok := want[key]; ok {
149
+ want[key] = true
150
+ }
151
+ }
152
+ for key, found := range want {
153
+ if !found {
154
+ t.Errorf("targets missing %s", key)
155
+ }
156
+ }
157
+ }
158
+
159
+ func TestExecutableName(t *testing.T) {
160
+ if got := executableName("windows"); got != "savepoint.exe" {
161
+ t.Errorf("executableName(windows) = %q, want savepoint.exe", got)
162
+ }
163
+ if got := executableName("linux"); got != "savepoint" {
164
+ t.Errorf("executableName(linux) = %q, want savepoint", got)
165
+ }
166
+ if got := executableName("darwin"); got != "savepoint" {
167
+ t.Errorf("executableName(darwin) = %q, want savepoint", got)
168
+ }
169
+ }
170
+
171
+ func TestWriteTarGzPreservesWindowsExecutableName(t *testing.T) {
172
+ dir := t.TempDir()
173
+ source := filepath.Join(dir, "savepoint.exe")
174
+ if err := os.WriteFile(source, []byte("binary"), 0o755); err != nil {
175
+ t.Fatal(err)
176
+ }
177
+
178
+ archive := filepath.Join(dir, "savepoint-windows-amd64.tar.gz")
179
+ if err := writeTarGz(archive, source, executableName("windows")); err != nil {
180
+ t.Fatalf("writeTarGz: %v", err)
181
+ }
182
+
183
+ f, err := os.Open(archive)
184
+ if err != nil {
185
+ t.Fatal(err)
186
+ }
187
+ defer f.Close()
188
+
189
+ gz, err := gzip.NewReader(f)
190
+ if err != nil {
191
+ t.Fatal(err)
192
+ }
193
+ defer gz.Close()
194
+
195
+ tr := tar.NewReader(gz)
196
+ header, err := tr.Next()
197
+ if err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ if header.Name != "savepoint.exe" {
201
+ t.Fatalf("archive member = %q, want savepoint.exe", header.Name)
202
+ }
203
+
204
+ content, err := io.ReadAll(tr)
205
+ if err != nil {
206
+ t.Fatal(err)
207
+ }
208
+ if string(content) != "binary" {
209
+ t.Fatalf("archive content = %q, want binary", content)
210
+ }
211
+ }
212
+
213
+ func TestLocalExecutable(t *testing.T) {
214
+ got := localExecutable()
215
+ if runtime.GOOS == "windows" {
216
+ if got != "savepoint.exe" {
217
+ t.Errorf("localExecutable() = %q, want %q", got, "savepoint.exe")
218
+ }
219
+ } else {
220
+ if got != "savepoint" {
221
+ t.Errorf("localExecutable() = %q, want %q", got, "savepoint")
222
+ }
223
+ }
224
+ }
@@ -16,8 +16,17 @@ type Theme struct {
16
16
  Accents map[string]string `yaml:"accents"`
17
17
  }
18
18
 
19
+ type QualityGates struct {
20
+ Lint *string `yaml:"lint"`
21
+ Typecheck *string `yaml:"typecheck"`
22
+ Test *string `yaml:"test"`
23
+ BlockOnFailure bool `yaml:"block_on_failure"`
24
+ Timeout string `yaml:"gate_timeout"`
25
+ }
26
+
19
27
  type Config struct {
20
- Theme Theme `yaml:"theme"`
28
+ Theme Theme `yaml:"theme"`
29
+ QualityGates QualityGates `yaml:"quality_gates"`
21
30
  }
22
31
 
23
32
  var defaultTheme = Theme{
@@ -80,8 +89,13 @@ func fillThemeDefaults(theme Theme) Theme {
80
89
  if theme.Text == "" {
81
90
  theme.Text = defaultTheme.Text
82
91
  }
83
- if len(theme.Accents) == 0 {
84
- theme.Accents = defaultTheme.Accents
92
+ if theme.Accents == nil {
93
+ theme.Accents = make(map[string]string)
94
+ }
95
+ for k, v := range defaultTheme.Accents {
96
+ if _, ok := theme.Accents[k]; !ok {
97
+ theme.Accents[k] = v
98
+ }
85
99
  }
86
100
  return theme
87
101
  }
@@ -53,6 +53,55 @@ func TestConfigReaderRead(t *testing.T) {
53
53
  }
54
54
  }
55
55
 
56
+ func TestFillThemeDefaults_PartialAccents(t *testing.T) {
57
+ theme := Theme{
58
+ BG: "#000000",
59
+ Accents: map[string]string{"planned": "#ff0000"},
60
+ }
61
+ result := fillThemeDefaults(theme)
62
+ if result.Accents["planned"] != "#ff0000" {
63
+ t.Errorf("Accents[planned] = %v, want #ff0000 (user value preserved)", result.Accents["planned"])
64
+ }
65
+ if result.Accents["in_progress"] != defaultTheme.Accents["in_progress"] {
66
+ t.Errorf("Accents[in_progress] = %v, want default %v", result.Accents["in_progress"], defaultTheme.Accents["in_progress"])
67
+ }
68
+ if result.Accents["done"] != defaultTheme.Accents["done"] {
69
+ t.Errorf("Accents[done] = %v, want default %v", result.Accents["done"], defaultTheme.Accents["done"])
70
+ }
71
+ if result.Accents["blocked"] != defaultTheme.Accents["blocked"] {
72
+ t.Errorf("Accents[blocked] = %v, want default %v", result.Accents["blocked"], defaultTheme.Accents["blocked"])
73
+ }
74
+ if result.Accents["epic"] != defaultTheme.Accents["epic"] {
75
+ t.Errorf("Accents[epic] = %v, want default %v", result.Accents["epic"], defaultTheme.Accents["epic"])
76
+ }
77
+ }
78
+
79
+ func TestFillThemeDefaults_NilAccents(t *testing.T) {
80
+ theme := Theme{
81
+ BG: "#000000",
82
+ Accents: nil,
83
+ }
84
+ result := fillThemeDefaults(theme)
85
+ for k, v := range defaultTheme.Accents {
86
+ if result.Accents[k] != v {
87
+ t.Errorf("Accents[%s] = %v, want default %v", k, result.Accents[k], v)
88
+ }
89
+ }
90
+ }
91
+
92
+ func TestFillThemeDefaults_EmptyAccents(t *testing.T) {
93
+ theme := Theme{
94
+ BG: "#000000",
95
+ Accents: map[string]string{},
96
+ }
97
+ result := fillThemeDefaults(theme)
98
+ for k, v := range defaultTheme.Accents {
99
+ if result.Accents[k] != v {
100
+ t.Errorf("Accents[%s] = %v, want default %v", k, result.Accents[k], v)
101
+ }
102
+ }
103
+ }
104
+
56
105
  func TestConfigReaderMalformedYAML(t *testing.T) {
57
106
  tmpfile, err := os.CreateTemp("", "config-*.yml")
58
107
  if err != nil {
@@ -85,6 +85,32 @@ func (d *Discover) ListReleases(root string) ([]ReleaseInfo, error) {
85
85
  return releases, nil
86
86
  }
87
87
 
88
+ // ListRootDirs returns sorted child directory names directly under root.
89
+ func (d *Discover) ListRootDirs(root string) ([]string, error) {
90
+ info, err := os.Stat(root)
91
+ if err != nil {
92
+ return nil, err
93
+ }
94
+ if !info.IsDir() {
95
+ return nil, fmt.Errorf("%s is not a directory", root)
96
+ }
97
+
98
+ entries, err := os.ReadDir(root)
99
+ if err != nil {
100
+ return nil, err
101
+ }
102
+
103
+ var dirs []string
104
+ for _, entry := range entries {
105
+ if entry.IsDir() {
106
+ dirs = append(dirs, entry.Name())
107
+ }
108
+ }
109
+
110
+ sort.Strings(dirs)
111
+ return dirs, nil
112
+ }
113
+
88
114
  func (d *Discover) ListEpics(root, release string) ([]EpicInfo, error) {
89
115
  epicsPath := filepath.Join(root, "releases", release, "epics")
90
116
  info, err := os.Stat(epicsPath)