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
 
@@ -118,12 +119,15 @@ func TestRenderColumn_viewportShowsScrollIndicators(t *testing.T) {
118
119
  if !strings.Contains(got, "↑ 1 above") {
119
120
  t.Error("RenderColumn missing above indicator")
120
121
  }
121
- if !strings.Contains(got, "↓ 1 more") {
122
- t.Error("RenderColumn missing more indicator")
122
+ if !strings.Contains(got, "↓ 2 more") {
123
+ t.Errorf("RenderColumn missing more indicator, got:\n%s", got)
123
124
  }
124
125
  if strings.Contains(got, "Task one") {
125
126
  t.Error("RenderColumn should not render tasks above viewport")
126
127
  }
128
+ if strings.Contains(got, "Task three") {
129
+ t.Error("RenderColumn should not render tasks that don't fit budget")
130
+ }
127
131
  if strings.Contains(got, "Task four") {
128
132
  t.Error("RenderColumn should not render tasks below viewport")
129
133
  }
@@ -134,3 +138,73 @@ func TestVisibleColumnTaskLimitDefaultsToFourAtStandardHeight(t *testing.T) {
134
138
  t.Errorf("visibleColumnTaskLimit(standard height) = %d, want 4", got)
135
139
  }
136
140
  }
141
+
142
+ func BenchmarkRenderColumn_empty(b *testing.B) {
143
+ b.ReportAllocs()
144
+ for b.Loop() {
145
+ RenderColumn(nil, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
146
+ }
147
+ }
148
+
149
+ func BenchmarkRenderColumn_fewTasks(b *testing.B) {
150
+ tasks := []data.Task{
151
+ {ID: "E06/T001", Title: "First task", Column: data.ColumnPlanned, Stage: data.StageBuild},
152
+ {ID: "E06/T002", Title: "Second task", Column: data.ColumnPlanned, Stage: data.StageTest},
153
+ {ID: "E06/T003", Title: "Third task", Column: data.ColumnPlanned, Stage: data.StageAudit},
154
+ }
155
+ b.ReportAllocs()
156
+ for b.Loop() {
157
+ RenderColumn(tasks, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
158
+ }
159
+ }
160
+
161
+ func BenchmarkRenderColumn_manyTasks(b *testing.B) {
162
+ tasks := make([]data.Task, 20)
163
+ stages := []data.ProgressStage{data.StageBuild, data.StageTest, data.StageAudit}
164
+ for i := range tasks {
165
+ tasks[i] = data.Task{
166
+ ID: fmt.Sprintf("E06/T%03d", i+1),
167
+ Title: fmt.Sprintf("Task number %d with a reasonable length title", i+1),
168
+ Column: data.ColumnPlanned,
169
+ Stage: stages[i%3],
170
+ Release: "v1.1",
171
+ }
172
+ }
173
+ b.ReportAllocs()
174
+ for b.Loop() {
175
+ RenderColumn(tasks, data.ColumnPlanned, 40, 24, 0, 0, false, nil)
176
+ }
177
+ }
178
+
179
+ func BenchmarkRenderColumn_focused(b *testing.B) {
180
+ tasks := []data.Task{
181
+ {ID: "E06/T001", Title: "First task", Column: data.ColumnInProgress, Stage: data.StageBuild},
182
+ {ID: "E06/T002", Title: "Second task", Column: data.ColumnInProgress, Stage: data.StageTest},
183
+ {ID: "E06/T003", Title: "Third task", Column: data.ColumnInProgress, Stage: data.StageAudit},
184
+ }
185
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T001"}
186
+ b.ReportAllocs()
187
+ for b.Loop() {
188
+ RenderColumn(tasks, data.ColumnInProgress, 40, 20, 0, 0, true, router)
189
+ }
190
+ }
191
+
192
+ func TestRenderColumn_focusedLastTaskVisibleWhenScrolled(t *testing.T) {
193
+ // When scrolled (offset > 0) the top scroll indicator steals 1 line.
194
+ // The focused last-in-page task must still appear, not be cut off.
195
+ tasks := []data.Task{
196
+ {ID: "T1", Title: "Task one", Column: data.ColumnPlanned},
197
+ {ID: "T2", Title: "Task two", Column: data.ColumnPlanned},
198
+ {ID: "T3", Title: "Task three", Column: data.ColumnPlanned},
199
+ {ID: "T4", Title: "Task four", Column: data.ColumnPlanned},
200
+ {ID: "T5", Title: "Task five", Column: data.ColumnPlanned},
201
+ }
202
+ // offset=2, focusedTask=4 (last task), maxHeight=14 (24-line terminal)
203
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 14, 2, 4, true, nil)
204
+ if !strings.Contains(got, "Task five") {
205
+ t.Errorf("focused last task must be visible when scrolled, got:\n%s", got)
206
+ }
207
+ if !strings.Contains(got, "↑") {
208
+ t.Error("scroll indicator above must appear when offset > 0")
209
+ }
210
+ }
@@ -0,0 +1,26 @@
1
+ package board
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "sync/atomic"
7
+ )
8
+
9
+ var debugEnabled atomic.Bool
10
+
11
+ // SetDebug enables or disables debug logging for the board package.
12
+ func SetDebug(enabled bool) {
13
+ debugEnabled.Store(enabled)
14
+ }
15
+
16
+ // DebugEnabled reports whether debug logging is active.
17
+ func DebugEnabled() bool {
18
+ return debugEnabled.Load()
19
+ }
20
+
21
+ func debugf(format string, args ...any) {
22
+ if !debugEnabled.Load() {
23
+ return
24
+ }
25
+ fmt.Fprintf(os.Stderr, "[savepoint debug] "+format+"\n", args...)
26
+ }
@@ -0,0 +1,108 @@
1
+ package board
2
+
3
+ import (
4
+ "bytes"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestSetDebugToggle(t *testing.T) {
13
+ t.Cleanup(func() { SetDebug(false) })
14
+
15
+ if DebugEnabled() {
16
+ t.Fatal("debug should be off by default")
17
+ }
18
+
19
+ SetDebug(true)
20
+ if !DebugEnabled() {
21
+ t.Fatal("debug should be on after SetDebug(true)")
22
+ }
23
+
24
+ SetDebug(false)
25
+ if DebugEnabled() {
26
+ t.Fatal("debug should be off after SetDebug(false)")
27
+ }
28
+ }
29
+
30
+ func TestDebugfWritesToStderr(t *testing.T) {
31
+ t.Cleanup(func() { SetDebug(false) })
32
+
33
+ r, w, err := os.Pipe()
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ orig := os.Stderr
38
+ os.Stderr = w
39
+
40
+ SetDebug(true)
41
+ debugf("test message %d", 42)
42
+
43
+ os.Stderr = orig
44
+ w.Close()
45
+
46
+ var buf bytes.Buffer
47
+ io.Copy(&buf, r)
48
+ r.Close()
49
+
50
+ out := buf.String()
51
+ if !strings.Contains(out, "test message 42") {
52
+ t.Fatalf("expected debug output, got: %q", out)
53
+ }
54
+ if !strings.Contains(out, "[savepoint debug]") {
55
+ t.Fatalf("expected debug prefix, got: %q", out)
56
+ }
57
+ }
58
+
59
+ func TestDebugfSilentWhenDisabled(t *testing.T) {
60
+ t.Cleanup(func() { SetDebug(false) })
61
+
62
+ r, w, err := os.Pipe()
63
+ if err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ orig := os.Stderr
67
+ os.Stderr = w
68
+
69
+ SetDebug(false)
70
+ debugf("should not appear")
71
+
72
+ os.Stderr = orig
73
+ w.Close()
74
+
75
+ var buf bytes.Buffer
76
+ io.Copy(&buf, r)
77
+ r.Close()
78
+
79
+ if buf.Len() != 0 {
80
+ t.Fatalf("expected no debug output when disabled, got: %q", buf.String())
81
+ }
82
+ }
83
+
84
+ func TestDebugfFormat(t *testing.T) {
85
+ t.Cleanup(func() { SetDebug(false) })
86
+
87
+ r, w, err := os.Pipe()
88
+ if err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ orig := os.Stderr
92
+ os.Stderr = w
93
+
94
+ SetDebug(true)
95
+ debugf("key=%q value=%d", "hello", 7)
96
+
97
+ os.Stderr = orig
98
+ w.Close()
99
+
100
+ var buf bytes.Buffer
101
+ io.Copy(&buf, r)
102
+ r.Close()
103
+
104
+ want := fmt.Sprintf("[savepoint debug] key=%q value=%d\n", "hello", 7)
105
+ if buf.String() != want {
106
+ t.Fatalf("expected %q, got %q", want, buf.String())
107
+ }
108
+ }
@@ -85,6 +85,36 @@ func renderChecklistSentences(text, glyph string, width int, style lipgloss.Styl
85
85
  return lines
86
86
  }
87
87
 
88
+ // knownAbbreviations is the set of dot-terminated tokens that must not trigger
89
+ // sentence splits. Add entries (lowercase, trailing dot) to extend the list.
90
+ var knownAbbreviations = map[string]bool{
91
+ "e.g.": true,
92
+ "i.e.": true,
93
+ "vs.": true,
94
+ "etc.": true,
95
+ "fig.": true,
96
+ "no.": true,
97
+ "mr.": true,
98
+ "mrs.": true,
99
+ "dr.": true,
100
+ "st.": true,
101
+ "jr.": true,
102
+ "sr.": true,
103
+ "prof.": true,
104
+ "approx.": true,
105
+ "est.": true,
106
+ }
107
+
108
+ // isKnownAbbreviation reports whether the period at dotPos in s is the trailing
109
+ // dot of a known abbreviation (e.g. "e.g.", "Dr.").
110
+ func isKnownAbbreviation(s string, dotPos int) bool {
111
+ start := dotPos
112
+ for start > 0 && s[start-1] != ' ' {
113
+ start--
114
+ }
115
+ return knownAbbreviations[strings.ToLower(s[start:dotPos+1])]
116
+ }
117
+
88
118
  func splitChecklistSentences(text string) []string {
89
119
  fields := strings.Fields(text)
90
120
  if len(fields) == 0 {
@@ -98,6 +128,9 @@ func splitChecklistSentences(text string) []string {
98
128
  if r != '.' && r != '!' && r != '?' {
99
129
  continue
100
130
  }
131
+ if r == '.' && isKnownAbbreviation(normalized, i) {
132
+ continue
133
+ }
101
134
  end := i + len(string(r))
102
135
  if end < len(normalized) && normalized[end] != ' ' {
103
136
  continue
@@ -182,51 +215,4 @@ func phaseLabel(s data.ProgressStage) string {
182
215
  }
183
216
  }
184
217
 
185
- func WrapText(s string, width int) []string {
186
- if width < 4 {
187
- width = 4
188
- }
189
- words := strings.Fields(s)
190
- if len(words) == 0 {
191
- return nil
192
- }
193
- lines := []string{}
194
- current := ""
195
- for _, word := range words {
196
- if len([]rune(word)) > width {
197
- if current != "" {
198
- lines = append(lines, current)
199
- current = ""
200
- }
201
- lines = append(lines, SplitLongWord(word, width)...)
202
- continue
203
- }
204
- if current == "" {
205
- current = word
206
- continue
207
- }
208
- if len([]rune(current))+1+len([]rune(word)) <= width {
209
- current += " " + word
210
- continue
211
- }
212
- lines = append(lines, current)
213
- current = word
214
- }
215
- if current != "" {
216
- lines = append(lines, current)
217
- }
218
- return lines
219
- }
220
218
 
221
- func SplitLongWord(word string, width int) []string {
222
- runes := []rune(word)
223
- lines := []string{}
224
- for len(runes) > width {
225
- lines = append(lines, string(runes[:width]))
226
- runes = runes[width:]
227
- }
228
- if len(runes) > 0 {
229
- lines = append(lines, string(runes))
230
- }
231
- return lines
232
- }
@@ -321,6 +321,54 @@ func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
321
321
  }
322
322
  }
323
323
 
324
+ func TestSplitChecklistSentences_abbreviationEg(t *testing.T) {
325
+ got := splitChecklistSentences("Use e.g. a widget. Done.")
326
+ if len(got) != 2 {
327
+ t.Fatalf("splitChecklistSentences with e.g. = %d sentences, want 2: %v", len(got), got)
328
+ }
329
+ if got[0] != "Use e.g. a widget." {
330
+ t.Errorf("sentence[0] = %q, want %q", got[0], "Use e.g. a widget.")
331
+ }
332
+ }
333
+
334
+ func TestSplitChecklistSentences_abbreviationIe(t *testing.T) {
335
+ got := splitChecklistSentences("Call i.e. the function. Done.")
336
+ if len(got) != 2 {
337
+ t.Fatalf("splitChecklistSentences with i.e. = %d sentences, want 2: %v", len(got), got)
338
+ }
339
+ }
340
+
341
+ func TestSplitChecklistSentences_abbreviationDr(t *testing.T) {
342
+ got := splitChecklistSentences("Dr. Smith approved it. Done.")
343
+ if len(got) != 2 {
344
+ t.Fatalf("splitChecklistSentences with Dr. = %d sentences, want 2: %v", len(got), got)
345
+ }
346
+ if got[0] != "Dr. Smith approved it." {
347
+ t.Errorf("sentence[0] = %q, want %q", got[0], "Dr. Smith approved it.")
348
+ }
349
+ }
350
+
351
+ func TestSplitChecklistSentences_abbreviationEtc(t *testing.T) {
352
+ got := splitChecklistSentences("Add widgets, buttons, etc. to the panel. Done.")
353
+ if len(got) != 2 {
354
+ t.Fatalf("splitChecklistSentences with etc. = %d sentences, want 2: %v", len(got), got)
355
+ }
356
+ }
357
+
358
+ func TestSplitChecklistSentences_abbreviationCaseInsensitive(t *testing.T) {
359
+ got := splitChecklistSentences("See Fig. 3 for details. Done.")
360
+ if len(got) != 2 {
361
+ t.Fatalf("splitChecklistSentences with Fig. = %d sentences, want 2: %v", len(got), got)
362
+ }
363
+ }
364
+
365
+ func TestSplitChecklistSentences_normalSplitUnaffected(t *testing.T) {
366
+ got := splitChecklistSentences("First sentence. Second sentence.")
367
+ if len(got) != 2 {
368
+ t.Fatalf("splitChecklistSentences normal split = %d sentences, want 2: %v", len(got), got)
369
+ }
370
+ }
371
+
324
372
  func TestOverlayWidth_clampMax(t *testing.T) {
325
373
  if got := overlayWidth(120); got != 80 {
326
374
  t.Errorf("overlayWidth(120) = %d, want 80", got)
@@ -8,33 +8,40 @@ import (
8
8
  )
9
9
 
10
10
  // RenderEpicDetail renders an overlay showing the content of an E##-Detail.md file.
11
- func RenderEpicDetail(epicSlug, content string, overlayW, maxHeight, offset int) string {
11
+ func RenderEpicDetail(epicSlug, content string, overlayW, maxHeight, offset int, tab int) string {
12
12
  inner := overlayW - detailBorderPad
13
13
  if inner < 4 {
14
14
  inner = 4
15
15
  }
16
16
 
17
+ tabIndicator := renderTabIndicator(tab, inner)
17
18
  lines := []string{
18
19
  styles.EpicTitleFocused.Render("EPIC DETAIL"),
19
- strings.Repeat("─", inner),
20
+ tabIndicator,
20
21
  }
21
22
 
22
23
  body := epicDetailBody(content, inner)
23
- body = append(body, "", styles.CardMeta.Render("esc:close"))
24
- lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead, offset)...)
24
+ body = append(body, "", styles.CardMeta.Render("1:Detail 2:Audit esc:close"))
25
+ lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead-1, offset)...)
25
26
 
26
27
  return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
27
28
  }
28
29
 
29
- // epicDetailBody parses markdown content into display lines, stripping frontmatter.
30
- func epicDetailBody(content string, width int) []string {
31
- if strings.TrimSpace(content) == "" || content == "(no detail available)" {
32
- return []string{styles.CardMeta.Render("(no detail available)")}
30
+ func renderTabIndicator(tab int, width int) string {
31
+ var detail, audit string
32
+ if tab == 0 {
33
+ detail = styles.EpicItemFocused.Render("DETAIL [1]")
34
+ audit = styles.CardMeta.Render("AUDIT [2]")
35
+ } else {
36
+ detail = styles.CardMeta.Render("DETAIL [1]")
37
+ audit = styles.EpicItemFocused.Render("AUDIT [2]")
33
38
  }
39
+ return detail + styles.CardMeta.Render(" │ ") + audit
40
+ }
34
41
 
42
+ // stripFrontmatter removes YAML frontmatter (between leading --- markers) from content.
43
+ func stripFrontmatter(content string) []string {
35
44
  lines := strings.Split(content, "\n")
36
-
37
- // Strip YAML frontmatter between leading --- markers.
38
45
  start := 0
39
46
  if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
40
47
  for i := 1; i < len(lines); i++ {
@@ -44,7 +51,16 @@ func epicDetailBody(content string, width int) []string {
44
51
  }
45
52
  }
46
53
  }
47
- lines = lines[start:]
54
+ return lines[start:]
55
+ }
56
+
57
+ // epicDetailBody parses markdown content into display lines, stripping frontmatter.
58
+ func epicDetailBody(content string, width int) []string {
59
+ if strings.TrimSpace(content) == "" || content == "(no detail available)" {
60
+ return []string{styles.CardMeta.Render("(no detail available)")}
61
+ }
62
+
63
+ lines := stripFrontmatter(content)
48
64
 
49
65
  var body []string
50
66
  skip := false
@@ -77,11 +93,89 @@ func epicDetailBody(content string, width int) []string {
77
93
  return body
78
94
  }
79
95
 
96
+ // RenderEpicAuditTab renders an overlay showing audit findings from an E##-Audit.md file.
97
+ func RenderEpicAuditTab(epicSlug, content string, overlayW, maxHeight, offset int, tab int) string {
98
+ inner := overlayW - detailBorderPad
99
+ if inner < 4 {
100
+ inner = 4
101
+ }
102
+
103
+ tabIndicator := renderTabIndicator(tab, inner)
104
+ lines := []string{
105
+ styles.GlyphAudit.Render("EPIC AUDIT"),
106
+ tabIndicator,
107
+ }
108
+
109
+ body := epicAuditBody(content, inner)
110
+ body = append(body, "", styles.CardMeta.Render("1:Detail 2:Audit esc:close"))
111
+ lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead-1, offset)...)
112
+
113
+ return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
114
+ }
115
+
116
+ // epicAuditHiddenSectionHeadings lists markdown section headings suppressed in the audit tab overlay.
117
+ // Sections that are implementation details or planning artifacts clutter the summary view.
118
+ var epicAuditHiddenSectionHeadings = map[string]struct{}{
119
+ "12. Distribution & build": {},
120
+ "Acceptance Criteria": {},
121
+ "Architectural notes": {},
122
+ "Boundaries": {},
123
+ "Context Files": {},
124
+ "Implemented As": {},
125
+ "Implemented as": {},
126
+ "Implementation Plan": {},
127
+ "Manual audit override": {},
128
+ "Proposed Changes": {},
129
+ "Quality Review": {},
130
+ "With": {},
131
+ }
132
+
133
+ func epicAuditBody(content string, width int) []string {
134
+ if strings.TrimSpace(content) == "" || content == "(no audit available)" {
135
+ return []string{styles.CardMeta.Render("(no audit available)")}
136
+ }
137
+
138
+ lines := stripFrontmatter(content)
139
+
140
+ var body []string
141
+ inHiddenSection := false
142
+
143
+ for _, line := range lines {
144
+ trimmed := strings.TrimRight(line, " \t\r")
145
+ switch {
146
+ case strings.HasPrefix(trimmed, "## "):
147
+ sectionName := strings.TrimPrefix(trimmed, "## ")
148
+ _, inHiddenSection = epicAuditHiddenSectionHeadings[sectionName]
149
+ if !inHiddenSection {
150
+ body = append(body, "", styles.EpicItemFocused.Render(sectionName))
151
+ }
152
+ case inHiddenSection:
153
+ case strings.HasPrefix(trimmed, "### "):
154
+ body = append(body, styles.EpicItemFocused.Render(strings.TrimPrefix(trimmed, "### ")))
155
+ case strings.HasPrefix(trimmed, "- [x] ") || strings.HasPrefix(trimmed, "- [X] "):
156
+ text := strings.TrimPrefix(strings.TrimPrefix(trimmed, "- [x] "), "- [X] ")
157
+ body = append(body, renderChecklistSentences(text, "[x] ", width, styles.TagDone)...)
158
+ case strings.HasPrefix(trimmed, "- [ ] "):
159
+ text := strings.TrimPrefix(trimmed, "- [ ] ")
160
+ body = append(body, renderChecklistSentences(text, "[ ] ", width, styles.CardMeta)...)
161
+ case strings.HasPrefix(trimmed, "- "):
162
+ body = append(body, styles.CardMeta.Render("• "+strings.TrimPrefix(trimmed, "- ")))
163
+ case trimmed == "":
164
+ body = append(body, "")
165
+ default:
166
+ for _, wrapped := range WrapText(trimmed, width) {
167
+ body = append(body, styles.CardMeta.Render(wrapped))
168
+ }
169
+ }
170
+ }
171
+ return body
172
+ }
173
+
80
174
  const epicActiveMarker = "►"
81
175
 
82
176
  // RenderEpicSidebar renders the fixed left sidebar listing epics with active indicator.
83
177
  // If epics is empty and selected is non-empty, selected is shown as the sole entry.
84
- func RenderEpicSidebar(epics []string, selected string, width int, focus bool, cursor int, status map[string]string) string {
178
+ func RenderEpicSidebar(epics []string, selected string, width int, focus bool, cursor int, status map[string]string, maxHeight int) string {
85
179
  inner := width - epicPanelOverhead
86
180
  if inner < 2 {
87
181
  inner = 2
@@ -114,6 +208,20 @@ func RenderEpicSidebar(epics []string, selected string, width int, focus bool, c
114
208
  if len(list) == 0 {
115
209
  lines = append(lines, styles.TaskItem.Render("(none)"))
116
210
  }
211
+ if maxHeight > 0 && len(lines) > maxHeight {
212
+ items := lines[2:]
213
+ available := maxHeight - 3
214
+ if available < 1 {
215
+ available = 1
216
+ }
217
+ clipped := make([]string, 0, maxHeight)
218
+ clipped = append(clipped, lines[0], lines[1])
219
+ clipped = append(clipped, items[:min(available, len(items))]...)
220
+ if len(items) > available {
221
+ clipped = append(clipped, renderScrollIndicator("↓", len(items)-available, "more"))
222
+ }
223
+ lines = clipped
224
+ }
117
225
  style := styles.EpicPanel.Width(width)
118
226
  if focus && len(epics) > 0 {
119
227
  style = styles.EpicPanelFocused.Width(width)
@@ -154,13 +262,3 @@ func RenderEpicDropdown(epics []string, cursor int, width int) string {
154
262
  lines = append(lines, "", styles.CardMeta.Render("↑↓:nav enter:select esc:cancel"))
155
263
  return styles.EpicPanel.Width(width).Render(strings.Join(lines, "\n"))
156
264
  }
157
-
158
- // epicIndex returns the index of selected in epics, or 0 if not found.
159
- func epicIndex(epics []string, selected string) int {
160
- for i, e := range epics {
161
- if e == selected {
162
- return i
163
- }
164
- }
165
- return 0
166
- }