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,8 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "os"
5
+ "path/filepath"
4
6
  "strings"
5
7
  "testing"
6
8
 
@@ -9,21 +11,21 @@ import (
9
11
  )
10
12
 
11
13
  func TestRenderEpicSidebar_containsEpicsHeader(t *testing.T) {
12
- got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil)
14
+ got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil, 999)
13
15
  if !strings.Contains(got, "EPICS") {
14
16
  t.Error("RenderEpicSidebar missing EPICS header")
15
17
  }
16
18
  }
17
19
 
18
20
  func TestRenderEpicSidebar_activeEpicMarked(t *testing.T) {
19
- got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil)
21
+ got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil, 999)
20
22
  if !strings.Contains(got, epicActiveMarker) {
21
23
  t.Errorf("RenderEpicSidebar missing active marker %q", epicActiveMarker)
22
24
  }
23
25
  }
24
26
 
25
27
  func TestRenderEpicSidebar_focusedCursorMarked(t *testing.T) {
26
- got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, true, 1, nil)
28
+ got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, true, 1, nil, 999)
27
29
  if !strings.Contains(got, epicActiveMarker+" E02") {
28
30
  t.Errorf("RenderEpicSidebar focused cursor missing marker, got %q", got)
29
31
  }
@@ -31,7 +33,7 @@ func TestRenderEpicSidebar_focusedCursorMarked(t *testing.T) {
31
33
 
32
34
  func TestRenderEpicSidebar_allEpicsPresent(t *testing.T) {
33
35
  epics := []string{"E01-foo", "E02-bar", "E03-baz"}
34
- got := RenderEpicSidebar(epics, "E01-foo", 32, false, 0, nil)
36
+ got := RenderEpicSidebar(epics, "E01-foo", 32, false, 0, nil, 999)
35
37
  for _, e := range epics {
36
38
  if !strings.Contains(got, e) {
37
39
  t.Errorf("RenderEpicSidebar missing epic %q", e)
@@ -40,14 +42,14 @@ func TestRenderEpicSidebar_allEpicsPresent(t *testing.T) {
40
42
  }
41
43
 
42
44
  func TestRenderEpicSidebar_emptyEpicsFallback(t *testing.T) {
43
- got := RenderEpicSidebar(nil, "E03", 28, false, 0, nil)
45
+ got := RenderEpicSidebar(nil, "E03", 28, false, 0, nil, 999)
44
46
  if !strings.Contains(got, "E03") {
45
47
  t.Error("RenderEpicSidebar with empty list should show selected epic")
46
48
  }
47
49
  }
48
50
 
49
51
  func TestRenderEpicSidebar_emptyBothShowsNone(t *testing.T) {
50
- got := RenderEpicSidebar(nil, "", 28, false, 0, nil)
52
+ got := RenderEpicSidebar(nil, "", 28, false, 0, nil, 999)
51
53
  if !strings.Contains(got, "(none)") {
52
54
  t.Error("RenderEpicSidebar with no epics and no selected should show (none)")
53
55
  }
@@ -81,22 +83,22 @@ func TestRenderEpicDropdown_emptyShowsNone(t *testing.T) {
81
83
  }
82
84
  }
83
85
 
84
- func TestEpicIndex_found(t *testing.T) {
86
+ func TestSliceIndex_found(t *testing.T) {
85
87
  epics := []string{"E01", "E02", "E03"}
86
- if got := epicIndex(epics, "E02"); got != 1 {
87
- t.Errorf("epicIndex = %d, want 1", got)
88
+ if got := sliceIndex(epics, "E02"); got != 1 {
89
+ t.Errorf("sliceIndex = %d, want 1", got)
88
90
  }
89
91
  }
90
92
 
91
- func TestEpicIndex_notFound(t *testing.T) {
92
- if got := epicIndex([]string{"E01"}, "E99"); got != 0 {
93
- t.Errorf("epicIndex not-found = %d, want 0", got)
93
+ func TestSliceIndex_notFound(t *testing.T) {
94
+ if got := sliceIndex([]string{"E01"}, "E99"); got != 0 {
95
+ t.Errorf("sliceIndex not-found = %d, want 0", got)
94
96
  }
95
97
  }
96
98
 
97
- func TestEpicIndex_empty(t *testing.T) {
98
- if got := epicIndex(nil, "E01"); got != 0 {
99
- t.Errorf("epicIndex empty = %d, want 0", got)
99
+ func TestSliceIndex_empty(t *testing.T) {
100
+ if got := sliceIndex(nil, "E01"); got != 0 {
101
+ t.Errorf("sliceIndex empty = %d, want 0", got)
100
102
  }
101
103
  }
102
104
 
@@ -567,7 +569,7 @@ func TestView_epicDetailOverlayNoContent(t *testing.T) {
567
569
 
568
570
  func TestRenderEpicDetail_stripsMarkdownHeadings(t *testing.T) {
569
571
  content := "---\ntype: epic-design\n---\n# Epic E01\n\n## Purpose\nDoes things."
570
- got := RenderEpicDetail("E01-test", content, 60, 40, 0)
572
+ got := RenderEpicDetail("E01-test", content, 60, 40, 0, 0)
571
573
  if !strings.Contains(got, "EPIC DETAIL") {
572
574
  t.Error("RenderEpicDetail missing EPIC DETAIL header")
573
575
  }
@@ -577,8 +579,291 @@ func TestRenderEpicDetail_stripsMarkdownHeadings(t *testing.T) {
577
579
  }
578
580
 
579
581
  func TestRenderEpicDetail_noDetailFallback(t *testing.T) {
580
- got := RenderEpicDetail("E01-test", "(no detail available)", 60, 40, 0)
582
+ got := RenderEpicDetail("E01-test", "(no detail available)", 60, 40, 0, 0)
581
583
  if !strings.Contains(got, "no detail available") {
582
584
  t.Error("RenderEpicDetail fallback message missing")
583
585
  }
584
586
  }
587
+
588
+ func TestRenderEpicDetail_tabIndicatorDetailActive(t *testing.T) {
589
+ got := RenderEpicDetail("E01-test", "content", 60, 40, 0, 0)
590
+ if !strings.Contains(got, "DETAIL [1]") {
591
+ t.Error("RenderEpicDetail tab=0: missing DETAIL [1] indicator")
592
+ }
593
+ if !strings.Contains(got, "AUDIT [2]") {
594
+ t.Error("RenderEpicDetail tab=0: missing AUDIT [2] indicator")
595
+ }
596
+ }
597
+
598
+ func TestRenderEpicDetail_tabIndicatorAuditActive(t *testing.T) {
599
+ got := RenderEpicDetail("E01-test", "content", 60, 40, 0, 1)
600
+ if !strings.Contains(got, "DETAIL [1]") {
601
+ t.Error("RenderEpicDetail tab=1: missing DETAIL [1] indicator")
602
+ }
603
+ if !strings.Contains(got, "AUDIT [2]") {
604
+ t.Error("RenderEpicDetail tab=1: missing AUDIT [2] indicator")
605
+ }
606
+ }
607
+
608
+ func TestRenderEpicAuditTab_header(t *testing.T) {
609
+ got := RenderEpicAuditTab("E06-test", "# Audit\n\n## Main Findings\nAll good.", 60, 40, 0, 1)
610
+ if !strings.Contains(got, "EPIC AUDIT") {
611
+ t.Error("RenderEpicAuditTab missing EPIC AUDIT header")
612
+ }
613
+ }
614
+
615
+ func TestRenderEpicAuditTab_noContent(t *testing.T) {
616
+ got := RenderEpicAuditTab("E06-test", "(no audit available)", 60, 40, 0, 1)
617
+ if !strings.Contains(got, "no audit available") {
618
+ t.Error("RenderEpicAuditTab fallback message missing")
619
+ }
620
+ }
621
+
622
+ func TestRenderEpicAuditTab_emptyContent(t *testing.T) {
623
+ got := RenderEpicAuditTab("E06-test", "", 60, 40, 0, 1)
624
+ if !strings.Contains(got, "no audit available") {
625
+ t.Error("RenderEpicAuditTab empty content should show fallback")
626
+ }
627
+ }
628
+
629
+ func TestRenderEpicAuditTab_stripsFrontmatter(t *testing.T) {
630
+ content := "---\ntype: audit\n---\n# E06 Audit\n\n## Main Findings\nLooks good."
631
+ got := RenderEpicAuditTab("E06-test", content, 60, 40, 0, 1)
632
+ if strings.Contains(got, "type: audit") {
633
+ t.Error("RenderEpicAuditTab should strip frontmatter")
634
+ }
635
+ if !strings.Contains(got, "EPIC AUDIT") {
636
+ t.Error("RenderEpicAuditTab missing header after frontmatter strip")
637
+ }
638
+ }
639
+
640
+ func TestRenderEpicAuditTab_checkboxDonePresent(t *testing.T) {
641
+ content := "## Code Style Review\n- [x] One job per file\n- [ ] One-sentence functions"
642
+ got := RenderEpicAuditTab("E06-test", content, 60, 40, 0, 1)
643
+ if !strings.Contains(got, "One job per file") {
644
+ t.Error("RenderEpicAuditTab missing done checkbox text")
645
+ }
646
+ if !strings.Contains(got, "One-sentence functions") {
647
+ t.Error("RenderEpicAuditTab missing undone checkbox text")
648
+ }
649
+ }
650
+
651
+ func TestRenderEpicAuditTab_scrollFooter(t *testing.T) {
652
+ got := RenderEpicAuditTab("E06-test", "# Audit", 60, 40, 0, 1)
653
+ if !strings.Contains(got, "esc:close") {
654
+ t.Error("RenderEpicAuditTab missing esc:close footer")
655
+ }
656
+ }
657
+
658
+ func TestRenderEpicAuditTab_tabIndicator(t *testing.T) {
659
+ got := RenderEpicAuditTab("E06-test", "# Audit", 60, 40, 0, 1)
660
+ if !strings.Contains(got, "DETAIL [1]") {
661
+ t.Error("RenderEpicAuditTab missing DETAIL [1] indicator")
662
+ }
663
+ if !strings.Contains(got, "AUDIT [2]") {
664
+ t.Error("RenderEpicAuditTab missing AUDIT [2] indicator")
665
+ }
666
+ }
667
+
668
+ func TestRenderEpicAuditTab_mainFindingsVisible(t *testing.T) {
669
+ content := "## Main Findings\nAudit summary is visible.\n\n## Proposed Changes\n### Target File\nAGENTS.md\n"
670
+ got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
671
+ if !strings.Contains(got, "Audit summary is visible") {
672
+ t.Error("RenderEpicAuditTab should render Main Findings body")
673
+ }
674
+ if strings.Contains(got, "Target File") || strings.Contains(got, "AGENTS.md") {
675
+ t.Error("RenderEpicAuditTab should not render Proposed Changes admin blocks")
676
+ }
677
+ }
678
+
679
+ func TestRenderEpicAuditTab_qualityReviewHidden(t *testing.T) {
680
+ content := "## Quality Review\nOld quality section.\n\n## Code Style Review\n- [ ] One job per file\n"
681
+ got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
682
+ if strings.Contains(got, "Old quality section") {
683
+ t.Error("RenderEpicAuditTab should not render superseded Quality Review section")
684
+ }
685
+ if !strings.Contains(got, "One job per file") {
686
+ t.Error("RenderEpicAuditTab should render Code Style Review")
687
+ }
688
+ }
689
+
690
+ func TestRenderEpicAuditTab_hiddenHeadingsRequireExactMatch(t *testing.T) {
691
+ content := "## Proposed Changes Appendix\nNear-match section is visible.\n\n## Proposed Changes\nHidden admin section.\n"
692
+ got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
693
+ if !strings.Contains(got, "Near-match section is visible") {
694
+ t.Error("RenderEpicAuditTab should render headings that only partially match hidden headings")
695
+ }
696
+ if strings.Contains(got, "Hidden admin section") {
697
+ t.Error("RenderEpicAuditTab should hide exact Proposed Changes section")
698
+ }
699
+ }
700
+
701
+ func TestRenderEpicAuditTab_allCodeStyleRules(t *testing.T) {
702
+ rules := []string{
703
+ "One job per file",
704
+ "One-sentence functions",
705
+ "Test branches",
706
+ "Types are documentation",
707
+ "Build, don't speculate",
708
+ "Errors at boundaries",
709
+ "One source of truth",
710
+ "Comments explain WHY",
711
+ "Content in data files",
712
+ "Small diffs",
713
+ }
714
+ content := "## Code Style Review\n"
715
+ for _, r := range rules {
716
+ content += "- [ ] " + r + "\n"
717
+ }
718
+ got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
719
+ for _, r := range rules {
720
+ if !strings.Contains(got, r) {
721
+ t.Errorf("RenderEpicAuditTab missing code style rule %q", r)
722
+ }
723
+ }
724
+ }
725
+
726
+ // TestView_epicAuditTabRendered verifies View() uses RenderEpicAuditTab when EpicDetailTab=1.
727
+ func TestView_epicAuditTabRendered(t *testing.T) {
728
+ m := NewModel(nil, "v1.1", "E06-audit-command")
729
+ m.Width = 120
730
+ m.Height = 30
731
+ m.Epics = []string{"E06-audit-command"}
732
+ m.Overlay = OverlayEpicDetail
733
+ m.EpicDetailTab = 1
734
+ m.EpicAuditContent = "# Audit Findings: E06\n\n## Main Findings\nAll good.\n\n## Code Style Review\n- [x] One job per file\n"
735
+
736
+ got := m.View()
737
+ if !strings.Contains(got, "EPIC AUDIT") {
738
+ t.Error("View() with EpicDetailTab=1 missing EPIC AUDIT header")
739
+ }
740
+ if strings.Contains(got, "EPIC DETAIL") {
741
+ t.Error("View() with EpicDetailTab=1 should not render EPIC DETAIL header")
742
+ }
743
+ }
744
+
745
+ // TestAuditWorkflow_fullEndToEnd exercises the full audit workflow:
746
+ // create E##-Audit.md on disk, open overlay, press 2, verify content loads and renders.
747
+ func TestAuditWorkflow_fullEndToEnd(t *testing.T) {
748
+ root := t.TempDir()
749
+ epicSlug := "E06-audit-command"
750
+ epicDir := filepath.Join(root, "releases", "v1.1", "epics", epicSlug)
751
+ if err := os.MkdirAll(epicDir, 0755); err != nil {
752
+ t.Fatal(err)
753
+ }
754
+
755
+ auditContent := `---
756
+ type: audit-findings
757
+ audited: 2026-05-03
758
+ ---
759
+ # Audit Findings: E06 Agent Audit + Audit Tab
760
+
761
+ ## Main Findings
762
+ All acceptance criteria met.
763
+
764
+ ## Code Style Review
765
+ - [x] One job per file
766
+ - [x] One-sentence functions
767
+ - [x] Test branches
768
+ - [x] Types are documentation
769
+ - [x] Build, don't speculate
770
+ - [x] Errors at boundaries
771
+ - [x] One source of truth
772
+ - [x] Comments explain WHY
773
+ - [x] Content in data files
774
+ - [x] Small diffs
775
+ `
776
+ if err := os.WriteFile(filepath.Join(epicDir, "E06-Audit.md"), []byte(auditContent), 0644); err != nil {
777
+ t.Fatal(err)
778
+ }
779
+
780
+ tasks := []data.Task{
781
+ {ID: "E06-audit-command/T009-integration", Release: "v1.1", Epic: epicSlug, Column: data.ColumnPlanned},
782
+ }
783
+ m := NewModel(tasks, "v1.1", epicSlug)
784
+ m.Root = root
785
+ m.Epics = []string{epicSlug}
786
+ m.EpicPanelCursor = 0
787
+ m.Width = 120
788
+ m.Height = 40
789
+
790
+ // Open detail overlay (tab=0)
791
+ m.openEpicDetailOverlay()
792
+ if m.Overlay != OverlayEpicDetail {
793
+ t.Fatal("overlay not opened")
794
+ }
795
+ if m.EpicDetailTab != 0 {
796
+ t.Errorf("EpicDetailTab = %d, want 0 on open", m.EpicDetailTab)
797
+ }
798
+
799
+ // Press 2 → switch to audit tab, load content
800
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
801
+ updated := requireModel(t, got)
802
+
803
+ if updated.EpicDetailTab != 1 {
804
+ t.Errorf("EpicDetailTab = %d, want 1 after pressing 2", updated.EpicDetailTab)
805
+ }
806
+
807
+ msg := cmd()
808
+ got2, _ := updated.Update(msg)
809
+ updated2 := requireModel(t, got2)
810
+ if updated2.EpicAuditContent == "" || updated2.EpicAuditContent == "(no audit available)" {
811
+ t.Errorf("EpicAuditContent not loaded: %q", updated2.EpicAuditContent)
812
+ }
813
+
814
+ // Verify View() renders audit content
815
+ view := updated2.View()
816
+ if !strings.Contains(view, "EPIC AUDIT") {
817
+ t.Error("View() after tab switch missing EPIC AUDIT")
818
+ }
819
+ if !strings.Contains(view, "One job per file") {
820
+ t.Error("View() after tab switch missing code style rule")
821
+ }
822
+
823
+ // Press 1 → switch back to detail tab
824
+ got, _ = updated2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
825
+ updated = requireModel(t, got)
826
+ if updated.EpicDetailTab != 0 {
827
+ t.Errorf("EpicDetailTab = %d, want 0 after pressing 1", updated.EpicDetailTab)
828
+ }
829
+
830
+ // Press esc → overlay closes
831
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
832
+ updated = requireModel(t, got)
833
+ if updated.Overlay != OverlayNone {
834
+ t.Errorf("Overlay = %q, want none after esc", updated.Overlay)
835
+ }
836
+ }
837
+
838
+ func TestRenderEpicAuditTab_v11AuditFiles(t *testing.T) {
839
+ files := []struct {
840
+ path string
841
+ want string
842
+ }{
843
+ {filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E02-cross-platform-compatibility", "E02-Audit.md"), "cross-platform build work"},
844
+ {filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E03-ui-visual-refinement", "E03-Audit.md"), "visual refinement work"},
845
+ {filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E04-epic-navigation", "E04-Audit.md"), "wide-screen epic navigation"},
846
+ {filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E05-tasking-permissions", "E05-Audit.md"), "tasking-permissions shift"},
847
+ {filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E06-audit-command", "E06-Audit.md"), "agent-led"},
848
+ }
849
+
850
+ for _, tt := range files {
851
+ content, err := os.ReadFile(tt.path)
852
+ if err != nil {
853
+ t.Fatalf("read %s: %v", tt.path, err)
854
+ }
855
+ if !strings.Contains(string(content), tt.want) {
856
+ t.Fatalf("fixture %s missing %q", tt.path, tt.want)
857
+ }
858
+ got := RenderEpicAuditTab(filepath.Base(filepath.Dir(tt.path)), string(content), 80, 40, 0, 1)
859
+ if !strings.Contains(got, tt.want) {
860
+ t.Errorf("RenderEpicAuditTab(%s) missing %q", tt.path, tt.want)
861
+ }
862
+ if strings.Contains(got, "Target File") {
863
+ t.Errorf("RenderEpicAuditTab(%s) should not render Proposed Changes", tt.path)
864
+ }
865
+ if strings.Contains(got, "Boundaries") || strings.Contains(got, "Implemented as") || strings.Contains(got, "Implemented As") {
866
+ t.Errorf("RenderEpicAuditTab(%s) should only render visible audit sections", tt.path)
867
+ }
868
+ }
869
+ }
@@ -23,6 +23,7 @@ func RenderHelp(width int) string {
23
23
  helpRow("enter", "open task detail / select item"),
24
24
  helpRow("e", "open epic selector on narrow screens"),
25
25
  helpRow("r", "open release selector"),
26
+ helpRow("p", "mark focused task as priority"),
26
27
  helpRow("up / k", "move selector up"),
27
28
  helpRow("down / j", "move selector down"),
28
29
  helpRow("?", "open help"),
@@ -22,6 +22,7 @@ func TestRenderHelp_containsShortcuts(t *testing.T) {
22
22
  "enter",
23
23
  "e",
24
24
  "r",
25
+ "p",
25
26
  "up / k",
26
27
  "down / j",
27
28
  "?",
@@ -0,0 +1,266 @@
1
+ package board
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+
10
+ tea "github.com/charmbracelet/bubbletea"
11
+ "github.com/opencode/savepoint/internal/data"
12
+ "github.com/opencode/savepoint/internal/testutil"
13
+ )
14
+
15
+ // writeTaskWithBody creates a task file with a body section to verify content preservation.
16
+ func writeTaskWithBody(t *testing.T, root, release, epic, taskSlug string, column data.ColumnType, body string) string {
17
+ t.Helper()
18
+ tf := testutil.TaskFixture{
19
+ Slug: taskSlug,
20
+ Release: release,
21
+ Status: string(column),
22
+ Objective: "Test task",
23
+ Body: body,
24
+ }
25
+ if column == data.ColumnInProgress {
26
+ tf.Phase = "build"
27
+ }
28
+ testutil.WriteTask(t, root, release, epic, tf)
29
+ return filepath.Join(root, "releases", release, "epics", epic, "tasks", taskSlug+".md")
30
+ }
31
+
32
+ // TestBoardPipeline_endToEnd loads a real project from disk and renders the full board view.
33
+ func TestBoardPipeline_endToEnd(t *testing.T) {
34
+ projectRoot := t.TempDir()
35
+ savepointRoot := filepath.Join(projectRoot, ".savepoint")
36
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
37
+ writeTask(t, savepointRoot, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned)
38
+ writeTask(t, savepointRoot, "v1", "E01-init", "T002-validate", data.ColumnInProgress)
39
+ writeTask(t, savepointRoot, "v1", "E01-init", "T003-done-task", data.ColumnDone)
40
+
41
+ model, err := newProjectModel(projectRoot, "", "")
42
+ if err != nil {
43
+ t.Fatalf("newProjectModel: %v", err)
44
+ }
45
+ if model.Watcher != nil {
46
+ t.Cleanup(func() { model.Watcher.Close() })
47
+ }
48
+
49
+ model.Width = 120
50
+ model.Height = 40
51
+ view := model.View()
52
+
53
+ for _, want := range []string{"PLANNED", "IN PROGRESS", "DONE", "T001", "T002", "T003"} {
54
+ if !strings.Contains(view, want) {
55
+ t.Errorf("board view missing %q", want)
56
+ }
57
+ }
58
+ }
59
+
60
+ // TestRunPlainOutput_endToEnd calls runPlainOutput against a real temp project root.
61
+ func TestRunPlainOutput_endToEnd(t *testing.T) {
62
+ projectRoot := t.TempDir()
63
+ savepointRoot := filepath.Join(projectRoot, ".savepoint")
64
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
65
+ writeTask(t, savepointRoot, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned)
66
+ writeTask(t, savepointRoot, "v1", "E01-init", "T002-validate", data.ColumnDone)
67
+
68
+ model, err := newProjectModel(projectRoot, "", "")
69
+ if err != nil {
70
+ t.Fatalf("newProjectModel: %v", err)
71
+ }
72
+ if model.Watcher != nil {
73
+ t.Cleanup(func() { model.Watcher.Close() })
74
+ }
75
+
76
+ out := RenderPlainTable(model)
77
+
78
+ if !strings.Contains(out, plainNonTTYWarning) {
79
+ t.Error("plain output missing non-TTY warning")
80
+ }
81
+ for _, want := range []string{"PLANNED", "DONE", "T001-scaffold", "T002-validate"} {
82
+ if !strings.Contains(out, want) {
83
+ t.Errorf("plain output missing %q", want)
84
+ }
85
+ }
86
+ }
87
+
88
+ // TestStatusWrite_preservesTaskBody advances a task via space key and verifies the body text is unchanged.
89
+ func TestStatusWrite_preservesTaskBody(t *testing.T) {
90
+ root := t.TempDir()
91
+ body := "## Acceptance Criteria\n\n- [ ] thing one\n- [ ] thing two\n"
92
+ path := writeTaskWithBody(t, root, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned, body)
93
+
94
+ fi, err := os.Stat(path)
95
+ if err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ task := data.Task{
99
+ ID: "E01-init/T001-scaffold",
100
+ Column: data.ColumnPlanned,
101
+ Path: path,
102
+ Mtime: fi.ModTime(),
103
+ }
104
+
105
+ m := NewModel([]data.Task{task}, "v1", "E01-init")
106
+ m.FocusedColumn = data.ColumnPlanned
107
+
108
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
109
+ msg := cmd()
110
+ got2, _ := got.Update(msg)
111
+ updated := requireModel(t, got2)
112
+
113
+ if updated.AllTasks[0].Column != data.ColumnInProgress {
114
+ t.Errorf("Column = %q, want in_progress", updated.AllTasks[0].Column)
115
+ }
116
+
117
+ raw, err := os.ReadFile(path)
118
+ if err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ if !strings.Contains(string(raw), body) {
122
+ t.Errorf("task body was altered after status write; got:\n%s", raw)
123
+ }
124
+ }
125
+
126
+ // TestMtimeConflict_directDetection verifies WriteTaskStatus returns ErrMtimeConflict on mtime mismatch.
127
+ func TestMtimeConflict_directDetection(t *testing.T) {
128
+ dir := t.TempDir()
129
+ path := filepath.Join(dir, "T001.md")
130
+ content := "---\nid: E01/T001\nstatus: planned\nphase: build\n---\n\n# Task\n"
131
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
132
+ t.Fatal(err)
133
+ }
134
+
135
+ task := &data.Task{
136
+ ID: "E01/T001",
137
+ Column: data.ColumnInProgress,
138
+ Stage: data.StageBuild,
139
+ }
140
+ staleTime := time.Now().Add(-time.Hour)
141
+ err := data.WriteTaskStatus(path, task, staleTime)
142
+ if err != data.ErrMtimeConflict {
143
+ t.Errorf("WriteTaskStatus with stale mtime = %v, want ErrMtimeConflict", err)
144
+ }
145
+ }
146
+
147
+ // TestMtimeConflict_boardWarns verifies the board surfaces an mtime conflict instead of overwriting external edits.
148
+ func TestMtimeConflict_boardWarns(t *testing.T) {
149
+ path := filepath.Join(t.TempDir(), "T001.md")
150
+ content := "---\nid: E01/T001\nstatus: in_progress\nphase: build\n---\n\n# Task\n"
151
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ fi, err := os.Stat(path)
155
+ if err != nil {
156
+ t.Fatal(err)
157
+ }
158
+
159
+ task := data.Task{
160
+ ID: "E01/T001",
161
+ Column: data.ColumnInProgress,
162
+ Stage: data.StageBuild,
163
+ Path: path,
164
+ Mtime: fi.ModTime().Add(-time.Minute), // intentionally stale
165
+ }
166
+ m := NewModel([]data.Task{task}, "v1", "E01")
167
+ m.FocusedColumn = data.ColumnInProgress
168
+
169
+ got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
170
+ msg := cmd()
171
+ got2, _ := got.Update(msg)
172
+ updated := requireModel(t, got2)
173
+
174
+ if !strings.Contains(updated.StatusMessage, "mtime conflict") {
175
+ t.Errorf("StatusMessage = %q, want mtime conflict warning", updated.StatusMessage)
176
+ }
177
+
178
+ raw, err := os.ReadFile(path)
179
+ if err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ if !strings.Contains(string(raw), "phase: build") {
183
+ t.Errorf("task file was overwritten despite mtime conflict:\n%s", raw)
184
+ }
185
+ }
186
+
187
+ // TestReleaseFilter_showsOnlyMatchingRelease verifies the --release flag filters tasks.
188
+ func TestReleaseFilter_showsOnlyMatchingRelease(t *testing.T) {
189
+ projectRoot := t.TempDir()
190
+ savepointRoot := filepath.Join(projectRoot, ".savepoint")
191
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
192
+ writeTask(t, savepointRoot, "v1", "E01-init", "T001-v1-task", data.ColumnPlanned)
193
+ writeTask(t, savepointRoot, "v2", "E01-init", "T001-v2-task", data.ColumnPlanned)
194
+
195
+ model, err := newProjectModel(projectRoot, "v2", "")
196
+ if err != nil {
197
+ t.Fatalf("newProjectModel: %v", err)
198
+ }
199
+ if model.Watcher != nil {
200
+ t.Cleanup(func() { model.Watcher.Close() })
201
+ }
202
+
203
+ if model.SelectedRelease != "v2" {
204
+ t.Errorf("SelectedRelease = %q, want v2", model.SelectedRelease)
205
+ }
206
+ planned := model.Tasks[data.ColumnPlanned]
207
+ for _, task := range planned {
208
+ if task.Release != "v2" {
209
+ t.Errorf("task %q has release %q, want v2 only", task.ID, task.Release)
210
+ }
211
+ }
212
+ }
213
+
214
+ // TestEpicFilter_showsOnlyMatchingEpic verifies the --epic flag filters tasks.
215
+ func TestEpicFilter_showsOnlyMatchingEpic(t *testing.T) {
216
+ projectRoot := t.TempDir()
217
+ savepointRoot := filepath.Join(projectRoot, ".savepoint")
218
+ testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
219
+ writeTask(t, savepointRoot, "v1", "E01-init", "T001-alpha", data.ColumnPlanned)
220
+ writeTask(t, savepointRoot, "v1", "E02-build", "T001-beta", data.ColumnPlanned)
221
+
222
+ model, err := newProjectModel(projectRoot, "v1", "E02-build")
223
+ if err != nil {
224
+ t.Fatalf("newProjectModel: %v", err)
225
+ }
226
+ if model.Watcher != nil {
227
+ t.Cleanup(func() { model.Watcher.Close() })
228
+ }
229
+
230
+ if model.SelectedEpic != "E02-build" {
231
+ t.Errorf("SelectedEpic = %q, want E02-build", model.SelectedEpic)
232
+ }
233
+ planned := model.Tasks[data.ColumnPlanned]
234
+ for _, task := range planned {
235
+ if task.Epic != "E02-build" {
236
+ t.Errorf("task %q has epic %q, want E02-build only", task.ID, task.Epic)
237
+ }
238
+ }
239
+ }
240
+
241
+ // TestDetailPane_opensOnEnter verifies enter key opens the detail overlay.
242
+ func TestDetailPane_opensOnEnter(t *testing.T) {
243
+ tasks := []data.Task{{ID: "E01/T001", Title: "Scaffold init", Column: data.ColumnPlanned}}
244
+ m := NewModel(tasks, "v1", "E01")
245
+ m.FocusedColumn = data.ColumnPlanned
246
+
247
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
248
+ updated := requireModel(t, got)
249
+
250
+ if updated.Overlay != OverlayDetail {
251
+ t.Errorf("Overlay = %q, want %q", updated.Overlay, OverlayDetail)
252
+ }
253
+ }
254
+
255
+ // TestDetailPane_escClosesOverlay verifies esc dismisses the detail overlay.
256
+ func TestDetailPane_escClosesOverlay(t *testing.T) {
257
+ m := NewModel(nil, "v1", "E01")
258
+ m.Overlay = OverlayDetail
259
+
260
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
261
+ updated := requireModel(t, got)
262
+
263
+ if updated.Overlay != OverlayNone {
264
+ t.Errorf("Overlay = %q after esc, want none", updated.Overlay)
265
+ }
266
+ }