savepoint 1.0.2 → 1.0.3

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 (233) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.golangci.yml +11 -0
  3. package/.savepoint/Design.md +37 -36
  4. package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
  5. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  6. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
  7. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
  8. package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
  9. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  10. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
  11. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
  12. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
  13. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
  14. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
  15. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
  16. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
  17. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  18. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  19. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  20. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  21. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  22. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  23. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  24. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  25. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  26. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  27. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  28. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  29. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  30. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  31. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  32. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  33. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  34. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  35. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  36. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  37. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  38. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  39. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  40. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  41. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  42. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  43. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  44. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  45. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  46. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  47. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  48. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  49. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  50. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  51. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  52. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  53. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  54. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  55. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  56. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  57. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  58. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  59. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  60. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  61. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  62. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  63. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  64. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  65. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  66. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  67. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  68. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  69. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  70. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  71. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  72. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  73. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  74. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  75. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  76. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  77. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  78. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  79. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  80. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  81. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  82. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  83. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  84. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
  85. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  86. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
  87. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  88. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  89. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  90. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  91. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
  92. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  93. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  94. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  95. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  96. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  97. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  98. package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
  99. package/.savepoint/router.md +9 -16
  100. package/AGENTS.md +38 -23
  101. package/README.md +0 -1
  102. package/agent-skills/savepoint-audit/SKILL.md +86 -34
  103. package/agent-skills/savepoint-build-task/SKILL.md +7 -2
  104. package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
  105. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  106. package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
  107. package/agent-skills/savepoint-system-design/SKILL.md +7 -2
  108. package/agent_skills_test.go +91 -0
  109. package/cmd/board.go +59 -0
  110. package/cmd/board_test.go +137 -0
  111. package/cmd/doctor.go +53 -0
  112. package/cmd/doctor_test.go +146 -0
  113. package/cmd/init.go +63 -0
  114. package/cmd/init_test.go +104 -0
  115. package/internal/board/board.go +40 -36
  116. package/internal/board/board_test.go +27 -82
  117. package/internal/board/card.go +43 -23
  118. package/internal/board/card_test.go +41 -5
  119. package/internal/board/column.go +44 -13
  120. package/internal/board/column_test.go +5 -2
  121. package/internal/board/detail.go +0 -47
  122. package/internal/board/epic_panel.go +118 -22
  123. package/internal/board/epic_panel_test.go +302 -17
  124. package/internal/board/help.go +1 -0
  125. package/internal/board/help_test.go +1 -0
  126. package/internal/board/integration_test.go +266 -0
  127. package/internal/board/interfaces.go +65 -0
  128. package/internal/board/interfaces_test.go +114 -0
  129. package/internal/board/io.go +93 -0
  130. package/internal/board/model.go +79 -118
  131. package/internal/board/plain.go +88 -0
  132. package/internal/board/plain_test.go +117 -0
  133. package/internal/board/release.go +1 -9
  134. package/internal/board/release_test.go +6 -6
  135. package/internal/board/status.go +4 -4
  136. package/internal/board/theme.go +24 -0
  137. package/internal/board/theme_test.go +31 -0
  138. package/internal/board/transitions.go +113 -88
  139. package/internal/board/transitions_test.go +164 -141
  140. package/internal/board/tui.go +32 -0
  141. package/internal/board/update.go +325 -215
  142. package/internal/board/update_test.go +299 -18
  143. package/internal/board/util.go +76 -0
  144. package/internal/board/view.go +31 -28
  145. package/internal/board/view_test.go +12 -2
  146. package/internal/board/watch.go +35 -5
  147. package/internal/buildtool/main.go +2 -10
  148. package/internal/buildtool/main_test.go +46 -0
  149. package/internal/data/config.go +17 -3
  150. package/internal/data/config_test.go +49 -0
  151. package/internal/data/discover.go +26 -0
  152. package/internal/data/discover_test.go +34 -10
  153. package/internal/data/errors.go +4 -0
  154. package/internal/data/lifecycle.go +13 -6
  155. package/internal/data/lifecycle_test.go +14 -11
  156. package/internal/data/parser.go +21 -6
  157. package/internal/data/parser_test.go +31 -7
  158. package/internal/data/task.go +0 -9
  159. package/internal/data/write.go +85 -11
  160. package/internal/data/write_test.go +167 -0
  161. package/internal/doctor/checks.go +567 -0
  162. package/internal/doctor/checks_test.go +716 -0
  163. package/internal/doctor/gates.go +193 -0
  164. package/internal/doctor/gates_test.go +166 -0
  165. package/internal/doctor/interfaces.go +64 -0
  166. package/internal/doctor/interfaces_test.go +104 -0
  167. package/internal/doctor/repairs.go +80 -0
  168. package/internal/doctor/repairs_test.go +81 -0
  169. package/internal/doctor/report.go +157 -0
  170. package/internal/doctor/report_test.go +89 -0
  171. package/internal/init/clipboard.go +146 -0
  172. package/internal/init/clipboard_test.go +74 -0
  173. package/internal/init/install.go +16 -0
  174. package/internal/init/integration_test.go +197 -0
  175. package/internal/init/prompt.go +14 -0
  176. package/internal/init/prompt_test.go +77 -0
  177. package/internal/init/scaffold.go +59 -0
  178. package/internal/init/scaffold_test.go +179 -0
  179. package/internal/init/template_freshness_test.go +56 -0
  180. package/internal/init/validate.go +85 -0
  181. package/internal/init/validate_test.go +141 -0
  182. package/internal/init/write.go +73 -0
  183. package/internal/init/write_test.go +91 -0
  184. package/internal/styles/styles_test.go +133 -0
  185. package/internal/testutil/fixture.go +113 -0
  186. package/internal/testutil/fs.go +26 -0
  187. package/main.go +101 -4
  188. package/package.json +2 -2
  189. package/project-audit/audit_report_glm_5.1.md +411 -0
  190. package/project-audit/audit_report_opus_4.6 +406 -0
  191. package/project-audit/consolidated-audit-report.md +456 -0
  192. package/savepoint +0 -0
  193. package/templates/project/.savepoint/Design.md +2 -2
  194. package/templates/project/.savepoint/router.md +10 -10
  195. package/templates/project/AGENTS.md +33 -21
  196. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  197. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  198. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  199. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  200. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  201. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  202. package/templates/prompts/audit-reconciliation.prompt.md +33 -28
  203. package/templates/prompts/design.prompt.md +3 -1
  204. package/.savepoint/audit/v1/E01/proposals.md +0 -168
  205. package/.savepoint/audit/v1/E01/snapshot.md +0 -78
  206. package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
  207. package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
  208. package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
  209. package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
  210. package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
  211. package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
  212. package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
  213. package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
  214. package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
  215. package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
  216. package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
  217. package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
  218. package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
  219. package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
  220. package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
  221. package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
  222. package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
  223. package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
  224. package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
  225. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
  226. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
  227. package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
  228. package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
  229. package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
  230. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
  231. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
  232. package/ink-cli-ui-design.zip +0 -0
  233. package/savepoint.exe +0 -0
@@ -9,6 +9,7 @@ import (
9
9
  "io"
10
10
  "os"
11
11
  "os/exec"
12
+ "strings"
12
13
  "path/filepath"
13
14
  "runtime"
14
15
  )
@@ -196,7 +197,7 @@ func version() string {
196
197
  cmd := exec.Command("git", "describe", "--tags", "--abbrev=0")
197
198
  output, err := cmd.Output()
198
199
  if err == nil && len(output) > 0 {
199
- return string(trimSpace(output))
200
+ return strings.TrimSpace(string(output))
200
201
  }
201
202
  return "v0.0.0"
202
203
  }
@@ -208,12 +209,3 @@ func localExecutable() string {
208
209
  return "savepoint"
209
210
  }
210
211
 
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,46 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "runtime"
6
+ "testing"
7
+ )
8
+
9
+ func TestVersion_override(t *testing.T) {
10
+ versionOverride = "v1.2.3"
11
+ defer func() { versionOverride = "" }()
12
+ if got := version(); got != "v1.2.3" {
13
+ t.Errorf("version() = %q, want %q", got, "v1.2.3")
14
+ }
15
+ }
16
+
17
+ func TestVersion_env(t *testing.T) {
18
+ versionOverride = ""
19
+ os.Setenv("VERSION", "v2.0.0-env")
20
+ defer os.Unsetenv("VERSION")
21
+ if got := version(); got != "v2.0.0-env" {
22
+ t.Errorf("version() = %q, want %q", got, "v2.0.0-env")
23
+ }
24
+ }
25
+
26
+ func TestVersion_fallback(t *testing.T) {
27
+ versionOverride = ""
28
+ os.Unsetenv("VERSION")
29
+ got := version()
30
+ if got == "" {
31
+ t.Error("version() returned empty string")
32
+ }
33
+ }
34
+
35
+ func TestLocalExecutable(t *testing.T) {
36
+ got := localExecutable()
37
+ if runtime.GOOS == "windows" {
38
+ if got != "savepoint.exe" {
39
+ t.Errorf("localExecutable() = %q, want %q", got, "savepoint.exe")
40
+ }
41
+ } else {
42
+ if got != "savepoint" {
43
+ t.Errorf("localExecutable() = %q, want %q", got, "savepoint")
44
+ }
45
+ }
46
+ }
@@ -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)
@@ -1,18 +1,17 @@
1
1
  package data
2
2
 
3
3
  import (
4
- "os"
5
4
  "path/filepath"
6
5
  "testing"
6
+
7
+ "github.com/opencode/savepoint/internal/testutil"
7
8
  )
8
9
 
9
10
  func TestFindSavepointRoot(t *testing.T) {
10
11
  d := NewDiscover()
11
12
  savepointRoot := createDiscoveryFixture(t)
12
13
  start := filepath.Join(filepath.Dir(savepointRoot), "nested", "child")
13
- if err := os.MkdirAll(start, 0755); err != nil {
14
- t.Fatal(err)
15
- }
14
+ testutil.MkdirAll(t, start)
16
15
 
17
16
  root, err := d.FindSavepointRoot(start)
18
17
  if err != nil {
@@ -40,6 +39,35 @@ func TestListReleases(t *testing.T) {
40
39
  }
41
40
  }
42
41
 
42
+ func TestListRootDirs(t *testing.T) {
43
+ d := NewDiscover()
44
+ root := t.TempDir()
45
+ testutil.MkdirAll(t, filepath.Join(root, "beta"))
46
+ testutil.MkdirAll(t, filepath.Join(root, "alpha"))
47
+ testutil.WriteFile(t, filepath.Join(root, "notes.txt"), "test")
48
+
49
+ dirs, err := d.ListRootDirs(root)
50
+ if err != nil {
51
+ t.Fatalf("ListRootDirs() error = %v", err)
52
+ }
53
+
54
+ if len(dirs) != 2 || dirs[0] != "alpha" || dirs[1] != "beta" {
55
+ t.Fatalf("ListRootDirs() = %v, want [alpha beta]", dirs)
56
+ }
57
+ }
58
+
59
+ func TestListRootDirsRejectsFile(t *testing.T) {
60
+ d := NewDiscover()
61
+ root := t.TempDir()
62
+ path := filepath.Join(root, "not-dir")
63
+ testutil.WriteFile(t, path, "test")
64
+
65
+ _, err := d.ListRootDirs(path)
66
+ if err == nil {
67
+ t.Fatal("ListRootDirs() error = nil, want not directory error")
68
+ }
69
+ }
70
+
43
71
  func TestListEpics(t *testing.T) {
44
72
  d := NewDiscover()
45
73
  root := createDiscoveryFixture(t)
@@ -86,9 +114,7 @@ func createDiscoveryFixture(t *testing.T) string {
86
114
  filepath.Join(savepointRoot, "releases", "v2", "epics"),
87
115
  }
88
116
  for _, path := range paths {
89
- if err := os.MkdirAll(path, 0755); err != nil {
90
- t.Fatal(err)
91
- }
117
+ testutil.MkdirAll(t, path)
92
118
  }
93
119
 
94
120
  files := []string{
@@ -97,9 +123,7 @@ func createDiscoveryFixture(t *testing.T) string {
97
123
  filepath.Join(savepointRoot, "releases", "v1", "epics", "E02-data-readers", "tasks", "notes.txt"),
98
124
  }
99
125
  for _, file := range files {
100
- if err := os.WriteFile(file, []byte("test"), 0644); err != nil {
101
- t.Fatal(err)
102
- }
126
+ testutil.WriteFile(t, file, "test")
103
127
  }
104
128
 
105
129
  return savepointRoot
@@ -6,4 +6,8 @@ var (
6
6
  ErrNoFrontmatter = errors.New("no frontmatter found")
7
7
  ErrNoClosingFrontmatter = errors.New("no closing frontmatter delimiter found")
8
8
  ErrSavepointDirectoryMissing = errors.New(".savepoint directory not found")
9
+ ErrInvalidStatus = errors.New("invalid router state")
10
+ ErrMissingFrontmatter = errors.New("missing or invalid frontmatter")
11
+ ErrConfigNotFound = errors.New("configuration file not found")
12
+ ErrStructureProblem = errors.New("project structure problem")
9
13
  )
@@ -2,17 +2,24 @@ package data
2
2
 
3
3
  import "fmt"
4
4
 
5
- func ValidateTaskLifecycle(task Task) error {
5
+ func ValidateTaskLifecycle(task *Task) error {
6
6
  if !IsCanonicalColumn(task.Column) {
7
- return fmt.Errorf("invalid task status %q: use planned, in_progress, or done", task.Column)
7
+ return fmt.Errorf("invalid status %q: use planned, in_progress, or done. Add 'status: planned' or 'status: in_progress' to task frontmatter", task.Column)
8
8
  }
9
9
 
10
- if task.Column != ColumnInProgress && task.Stage != "" {
11
- return fmt.Errorf("phase %q is only valid when status is in_progress", task.Stage)
10
+ if task.Column == ColumnInProgress {
11
+ if task.Stage == "" {
12
+ task.Stage = StageBuild
13
+ return nil
14
+ }
15
+ if !IsCanonicalStage(task.Stage) {
16
+ return fmt.Errorf("invalid phase %q: use build, test, or audit. Add 'phase: build' to task frontmatter", task.Stage)
17
+ }
18
+ return nil
12
19
  }
13
20
 
14
- if task.Column == ColumnInProgress && !IsCanonicalStage(task.Stage) {
15
- return fmt.Errorf("invalid in_progress phase %q: use build, test, or audit", task.Stage)
21
+ if task.Stage != "" {
22
+ return fmt.Errorf("phase field %q is only valid when status is in_progress. Remove 'phase' or change status to in_progress", task.Stage)
16
23
  }
17
24
 
18
25
  return nil
@@ -4,35 +4,38 @@ import "testing"
4
4
 
5
5
  func TestValidateTaskLifecycle_allowsPlannedWithoutPhase(t *testing.T) {
6
6
  task := Task{Column: ColumnPlanned}
7
- if err := ValidateTaskLifecycle(task); err != nil {
7
+ if err := ValidateTaskLifecycle(&task); err != nil {
8
8
  t.Fatalf("ValidateTaskLifecycle() error = %v", err)
9
9
  }
10
10
  }
11
11
 
12
+ func TestValidateTaskLifecycle_defaultsInProgressWithoutPhase(t *testing.T) {
13
+ task := Task{Column: ColumnInProgress}
14
+ if err := ValidateTaskLifecycle(&task); err != nil {
15
+ t.Fatalf("ValidateTaskLifecycle() error = %v", err)
16
+ }
17
+ if task.Stage != StageBuild {
18
+ t.Fatalf("Task.Stage = %q, want %q", task.Stage, StageBuild)
19
+ }
20
+ }
21
+
12
22
  func TestValidateTaskLifecycle_allowsInProgressWithPhase(t *testing.T) {
13
23
  task := Task{Column: ColumnInProgress, Stage: StageAudit}
14
- if err := ValidateTaskLifecycle(task); err != nil {
24
+ if err := ValidateTaskLifecycle(&task); err != nil {
15
25
  t.Fatalf("ValidateTaskLifecycle() error = %v", err)
16
26
  }
17
27
  }
18
28
 
19
29
  func TestValidateTaskLifecycle_rejectsUnknownStatus(t *testing.T) {
20
30
  task := Task{Column: "review"}
21
- if err := ValidateTaskLifecycle(task); err == nil {
31
+ if err := ValidateTaskLifecycle(&task); err == nil {
22
32
  t.Fatal("ValidateTaskLifecycle() expected unknown status error")
23
33
  }
24
34
  }
25
35
 
26
36
  func TestValidateTaskLifecycle_rejectsPhaseOutsideInProgress(t *testing.T) {
27
37
  task := Task{Column: ColumnPlanned, Stage: StageBuild}
28
- if err := ValidateTaskLifecycle(task); err == nil {
38
+ if err := ValidateTaskLifecycle(&task); err == nil {
29
39
  t.Fatal("ValidateTaskLifecycle() expected phase/status error")
30
40
  }
31
41
  }
32
-
33
- func TestValidateTaskLifecycle_rejectsInProgressWithoutCanonicalPhase(t *testing.T) {
34
- task := Task{Column: ColumnInProgress}
35
- if err := ValidateTaskLifecycle(task); err == nil {
36
- t.Fatal("ValidateTaskLifecycle() expected missing phase error")
37
- }
38
- }
@@ -46,7 +46,7 @@ func (p *Parser) ParseTaskFile(path string, content string) (*Task, error) {
46
46
  Epic: firstNonEmpty(fields.Epic, extractEpicFromID(fields.ID)),
47
47
  Release: firstNonEmpty(fields.Release, "v1"),
48
48
  Column: normalizeColumn(rawColumn),
49
- Stage: firstStage(fields.Stage, fields.Phase),
49
+ Stage: firstStage(fields.Phase, fields.Stage),
50
50
  Priority: fields.Priority,
51
51
  Points: fields.Points,
52
52
  Tags: fields.Tags,
@@ -61,6 +61,10 @@ func (p *Parser) ParseTaskFile(path string, content string) (*Task, error) {
61
61
  return nil, fmt.Errorf("parse error for %s: %w", path, err)
62
62
  }
63
63
 
64
+ if task.Column == ColumnInProgress && task.Stage == "" {
65
+ task.Stage = StageBuild
66
+ }
67
+
64
68
  return task, nil
65
69
  }
66
70
 
@@ -84,8 +88,13 @@ type taskFrontmatter struct {
84
88
  Progress Progress `yaml:"progress"`
85
89
  }
86
90
 
91
+ // normalizeLineEndings replaces Windows line endings with Unix line endings.
92
+ func normalizeLineEndings(s string) string {
93
+ return strings.ReplaceAll(s, "\r\n", "\n")
94
+ }
95
+
87
96
  func extractFrontmatter(content string) (string, error) {
88
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
97
+ normalized := normalizeLineEndings(content)
89
98
  if !strings.HasPrefix(normalized, "---\n") {
90
99
  return "", ErrNoFrontmatter
91
100
  }
@@ -139,9 +148,15 @@ const legacyTodoColumn ColumnType = "todo"
139
148
 
140
149
  func validateParsedTaskLifecycle(rawColumn ColumnType, task Task) error {
141
150
  if rawColumn != "" && rawColumn != legacyTodoColumn && !IsCanonicalColumn(rawColumn) {
142
- return fmt.Errorf("invalid task status %q: use planned, in_progress, or done", rawColumn)
151
+ return fmt.Errorf("invalid task status %q: use planned, in_progress, or done. Add 'status: planned' or 'status: in_progress' to task frontmatter", rawColumn)
143
152
  }
144
- return ValidateTaskLifecycle(task)
153
+ if task.Column == ColumnInProgress && !IsCanonicalStage(task.Stage) && task.Stage != "" {
154
+ return fmt.Errorf("invalid phase %q: use build, test, or audit. Add 'phase: build' to task frontmatter", task.Stage)
155
+ }
156
+ if task.Column != ColumnInProgress && task.Stage != "" {
157
+ return nil
158
+ }
159
+ return nil
145
160
  }
146
161
 
147
162
  func firstStage(values ...ProgressStage) ProgressStage {
@@ -163,7 +178,7 @@ func firstList(values ...[]string) []string {
163
178
  }
164
179
 
165
180
  func extractChecklistItems(content, heading string) []CheckItem {
166
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
181
+ normalized := normalizeLineEndings(content)
167
182
  start := strings.Index(normalized, heading)
168
183
  if start == -1 {
169
184
  return nil
@@ -201,7 +216,7 @@ func extractChecklistItems(content, heading string) []CheckItem {
201
216
  }
202
217
 
203
218
  func extractChecklistSection(content, heading string) []string {
204
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
219
+ normalized := normalizeLineEndings(content)
205
220
  start := strings.Index(normalized, heading)
206
221
  if start == -1 {
207
222
  return nil
@@ -146,7 +146,7 @@ objective: "Style the board"
146
146
  }
147
147
  }
148
148
 
149
- func TestParseTaskFile_rejectsPhaseOutsideInProgress(t *testing.T) {
149
+ func TestParseTaskFile_allowsPhaseOutsideInProgress(t *testing.T) {
150
150
  p := NewParser()
151
151
  content := `---
152
152
  id: E06/T001
@@ -158,12 +158,12 @@ objective: "Style the board"
158
158
  # Task`
159
159
 
160
160
  _, err := p.ParseTaskFile("test.md", content)
161
- if err == nil {
162
- t.Fatal("ParseTaskFile() expected invalid phase/status error")
161
+ if err != nil {
162
+ t.Fatalf("ParseTaskFile() error = %v, want no error for legacy phase field", err)
163
163
  }
164
164
  }
165
165
 
166
- func TestParseTaskFile_rejectsInProgressWithoutPhase(t *testing.T) {
166
+ func TestParseTaskFile_includesDefaultBuildForInProgress(t *testing.T) {
167
167
  p := NewParser()
168
168
  content := `---
169
169
  id: E06/T001
@@ -173,9 +173,33 @@ objective: "Style the board"
173
173
 
174
174
  # Task`
175
175
 
176
- _, err := p.ParseTaskFile("test.md", content)
177
- if err == nil {
178
- t.Fatal("ParseTaskFile() expected missing phase error")
176
+ task, err := p.ParseTaskFile("test.md", content)
177
+ if err != nil {
178
+ t.Fatalf("ParseTaskFile() error = %v", err)
179
+ }
180
+ if task.Stage != StageBuild {
181
+ t.Fatalf("ParseTaskFile() expected StageBuild default, got %q", task.Stage)
182
+ }
183
+ }
184
+
185
+ func TestParseTaskFile_prefersPhaseOverLegacyStage(t *testing.T) {
186
+ p := NewParser()
187
+ content := `---
188
+ id: E06/T001
189
+ status: in_progress
190
+ stage: build
191
+ phase: test
192
+ objective: "Style the board"
193
+ ---
194
+
195
+ # Task`
196
+
197
+ task, err := p.ParseTaskFile("test.md", content)
198
+ if err != nil {
199
+ t.Fatalf("ParseTaskFile() error = %v", err)
200
+ }
201
+ if task.Stage != StageTest {
202
+ t.Fatalf("Task.Stage = %q, want test from phase", task.Stage)
179
203
  }
180
204
  }
181
205
 
@@ -26,15 +26,6 @@ const (
26
26
  StageAudit ProgressStage = "audit"
27
27
  )
28
28
 
29
- type TaskStatus string
30
-
31
- const (
32
- StatusPlanned TaskStatus = "planned"
33
- StatusInProgress TaskStatus = "in_progress"
34
- StatusDone TaskStatus = "done"
35
- StatusAudited TaskStatus = "audited"
36
- )
37
-
38
29
  type Progress struct {
39
30
  Stage ProgressStage `yaml:"stage"`
40
31
  Started bool `yaml:"started"`
@@ -11,9 +11,88 @@ import (
11
11
  )
12
12
 
13
13
  var ErrMtimeConflict = fmt.Errorf("file modified since last read")
14
+ var ErrProposalNotFound = fmt.Errorf("target text not found in file")
15
+
16
+ // ApplyProposal replaces the first occurrence of old with newText in the file at path.
17
+ func ApplyProposal(path, old, newText string) error {
18
+ content, err := os.ReadFile(path)
19
+ if err != nil {
20
+ return fmt.Errorf("read %s: %w", path, err)
21
+ }
22
+ normalized := normalizeLineEndings(string(content))
23
+ if !strings.Contains(normalized, old) {
24
+ return fmt.Errorf("%w: %s", ErrProposalNotFound, path)
25
+ }
26
+ updated := strings.Replace(normalized, old, newText, 1)
27
+ return os.WriteFile(path, []byte(updated), 0644)
28
+ }
29
+
30
+ // UpdateEpicStatus sets the status field in the frontmatter of an E##-Detail.md file.
31
+ func UpdateEpicStatus(path, status string) error {
32
+ return updateFrontmatterField(path, "status", status)
33
+ }
34
+
35
+ // UpdateLastAudited sets the last_audited field in the frontmatter of Design.md.
36
+ func UpdateLastAudited(path, value string) error {
37
+ return updateFrontmatterField(path, "last_audited", value)
38
+ }
39
+
40
+ // SplitFrontmatterBody splits content into frontmatter YAML and body.
41
+ func SplitFrontmatterBody(content string) (yamlStr string, body string, err error) {
42
+ normalized := normalizeLineEndings(content)
43
+ raw, err := extractFrontmatter(normalized)
44
+ if err != nil {
45
+ return "", "", err
46
+ }
47
+ delimLen := 4
48
+ bodyStart := delimLen + len(raw) + delimLen
49
+ body = ""
50
+ if bodyStart < len(normalized) {
51
+ body = normalized[bodyStart:]
52
+ }
53
+ return raw, body, nil
54
+ }
55
+
56
+ func updateFrontmatterField(path, key, value string) error {
57
+ content, err := os.ReadFile(path)
58
+ if err != nil {
59
+ return fmt.Errorf("read %s: %w", path, err)
60
+ }
61
+
62
+ normalized := normalizeLineEndings(string(content))
63
+
64
+ raw, body, err := SplitFrontmatterBody(normalized)
65
+ if err != nil {
66
+ return fmt.Errorf("extract frontmatter: %w", err)
67
+ }
68
+
69
+ var doc yaml.Node
70
+ if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
71
+ return fmt.Errorf("parse yaml: %w", err)
72
+ }
73
+
74
+ if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
75
+ return fmt.Errorf("unexpected yaml structure")
76
+ }
77
+
78
+ mapping := doc.Content[0]
79
+ if mapping.Kind != yaml.MappingNode {
80
+ return fmt.Errorf("frontmatter is not a mapping")
81
+ }
82
+
83
+ setMappingField(mapping, key, value)
84
+
85
+ out, err := yaml.Marshal(&doc)
86
+ if err != nil {
87
+ return fmt.Errorf("marshal yaml: %w", err)
88
+ }
89
+
90
+ newContent := "---\n" + strings.TrimSpace(string(out)) + "\n---" + body
91
+ return os.WriteFile(path, []byte(newContent), 0644)
92
+ }
14
93
 
15
94
  func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
16
- if err := ValidateTaskLifecycle(*task); err != nil {
95
+ if err := ValidateTaskLifecycle(task); err != nil {
17
96
  return err
18
97
  }
19
98
 
@@ -31,9 +110,9 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
31
110
  return fmt.Errorf("read %s: %w", path, err)
32
111
  }
33
112
 
34
- normalized := strings.ReplaceAll(string(content), "\r\n", "\n")
113
+ normalized := normalizeLineEndings(string(content))
35
114
 
36
- raw, err := extractFrontmatter(normalized)
115
+ raw, body, err := SplitFrontmatterBody(normalized)
37
116
  if err != nil {
38
117
  return fmt.Errorf("extract frontmatter: %w", err)
39
118
  }
@@ -56,8 +135,10 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
56
135
 
57
136
  if task.Stage == "" {
58
137
  removeMappingField(mapping, "phase")
138
+ removeMappingField(mapping, "stage")
59
139
  } else {
60
140
  setMappingField(mapping, "phase", string(task.Stage))
141
+ removeMappingField(mapping, "stage")
61
142
  }
62
143
 
63
144
  out, err := yaml.Marshal(&doc)
@@ -65,13 +146,6 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
65
146
  return fmt.Errorf("marshal yaml: %w", err)
66
147
  }
67
148
 
68
- delimLen := 4
69
- bodyStart := delimLen + len(raw) + delimLen
70
- body := ""
71
- if bodyStart < len(normalized) {
72
- body = normalized[bodyStart:]
73
- }
74
-
75
149
  newContent := "---\n" + strings.TrimSpace(string(out)) + "\n---" + body
76
150
 
77
151
  return os.WriteFile(path, []byte(newContent), 0644)
@@ -115,7 +189,7 @@ func WriteRouterState(root string, state *RouterState, expectedMtime time.Time)
115
189
  return fmt.Errorf("read %s: %w", path, err)
116
190
  }
117
191
 
118
- normalized := strings.ReplaceAll(string(content), "\r\n", "\n")
192
+ normalized := normalizeLineEndings(string(content))
119
193
 
120
194
  startIdx := strings.Index(normalized, stateBlockStart)
121
195
  if startIdx == -1 {