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,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
  )
@@ -0,0 +1,75 @@
1
+ package data
2
+
3
+ import (
4
+ "testing"
5
+ )
6
+
7
+ func FuzzExtractFrontmatter(f *testing.F) {
8
+ seeds := []string{
9
+ "---\nid: E01/T001\nstatus: planned\n---\nbody",
10
+ "---\n---\n",
11
+ "---\n\n---\n",
12
+ "---\nid: test\n---",
13
+ "",
14
+ "# no frontmatter",
15
+ "---\nid: [broken\n---\n",
16
+ "---\nname: héllo wörld\n---\n",
17
+ "---\nid: test\nstatus: in_progress\nphase: build\n---\nbody content",
18
+ "---\r\nid: test\r\n---\r\nbody",
19
+ }
20
+ for _, s := range seeds {
21
+ f.Add(s)
22
+ }
23
+ f.Fuzz(func(t *testing.T, content string) {
24
+ _, _ = extractFrontmatter(content)
25
+ })
26
+ }
27
+
28
+ func FuzzParseFrontmatter(f *testing.F) {
29
+ seeds := []string{
30
+ "---\nid: E01/T001\nstatus: planned\n---\nbody",
31
+ "---\n---\n",
32
+ "---\nid: [broken\n---\n",
33
+ "---\nname: héllo\n---\n",
34
+ "",
35
+ "no frontmatter",
36
+ "---\ntags: [a, b, c]\n---\n",
37
+ "---\nnested:\n key: val\n---\n",
38
+ }
39
+ for _, s := range seeds {
40
+ f.Add(s)
41
+ }
42
+ f.Fuzz(func(t *testing.T, content string) {
43
+ p := NewParser()
44
+ _, _ = p.ParseFrontmatter(content)
45
+ })
46
+ }
47
+
48
+ func FuzzSplitFrontmatterBody(f *testing.F) {
49
+ seeds := []string{
50
+ "---\nid: E01/T001\nstatus: planned\n---\nbody",
51
+ "---\n---\n",
52
+ "---\nkey: value\n---",
53
+ "",
54
+ "# no frontmatter",
55
+ "---\nid: test\nstatus: in_progress\n---\n\n## Section\n\nContent.",
56
+ "---\nid: test\n---\n\nbody with unicode: 日本語",
57
+ }
58
+ for _, s := range seeds {
59
+ f.Add(s)
60
+ }
61
+ f.Fuzz(func(t *testing.T, content string) {
62
+ yamlStr, body, err := SplitFrontmatterBody(content)
63
+ if err != nil {
64
+ return
65
+ }
66
+ reconstructed := "---\n" + yamlStr + "\n---" + body
67
+ _, body2, err2 := SplitFrontmatterBody(reconstructed)
68
+ if err2 != nil {
69
+ t.Errorf("round-trip SplitFrontmatterBody failed on reconstructed: %v", err2)
70
+ }
71
+ if body2 != body {
72
+ t.Errorf("round-trip body mismatch: got %q, want %q", body2, body)
73
+ }
74
+ })
75
+ }
@@ -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,14 @@ type taskFrontmatter struct {
84
88
  Progress Progress `yaml:"progress"`
85
89
  }
86
90
 
91
+ // normalizeLineEndings replaces Windows (CRLF) and legacy Mac (CR) line endings with LF.
92
+ func normalizeLineEndings(s string) string {
93
+ s = strings.ReplaceAll(s, "\r\n", "\n")
94
+ return strings.ReplaceAll(s, "\r", "\n")
95
+ }
96
+
87
97
  func extractFrontmatter(content string) (string, error) {
88
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
98
+ normalized := normalizeLineEndings(content)
89
99
  if !strings.HasPrefix(normalized, "---\n") {
90
100
  return "", ErrNoFrontmatter
91
101
  }
@@ -139,9 +149,15 @@ const legacyTodoColumn ColumnType = "todo"
139
149
 
140
150
  func validateParsedTaskLifecycle(rawColumn ColumnType, task Task) error {
141
151
  if rawColumn != "" && rawColumn != legacyTodoColumn && !IsCanonicalColumn(rawColumn) {
142
- return fmt.Errorf("invalid task status %q: use planned, in_progress, or done", rawColumn)
152
+ 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
153
  }
144
- return ValidateTaskLifecycle(task)
154
+ if task.Column == ColumnInProgress && !IsCanonicalStage(task.Stage) && task.Stage != "" {
155
+ return fmt.Errorf("invalid phase %q: use build, test, or audit. Add 'phase: build' to task frontmatter", task.Stage)
156
+ }
157
+ if task.Column != ColumnInProgress && task.Stage != "" {
158
+ return nil
159
+ }
160
+ return nil
145
161
  }
146
162
 
147
163
  func firstStage(values ...ProgressStage) ProgressStage {
@@ -163,7 +179,7 @@ func firstList(values ...[]string) []string {
163
179
  }
164
180
 
165
181
  func extractChecklistItems(content, heading string) []CheckItem {
166
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
182
+ normalized := normalizeLineEndings(content)
167
183
  start := strings.Index(normalized, heading)
168
184
  if start == -1 {
169
185
  return nil
@@ -201,7 +217,7 @@ func extractChecklistItems(content, heading string) []CheckItem {
201
217
  }
202
218
 
203
219
  func extractChecklistSection(content, heading string) []string {
204
- normalized := strings.ReplaceAll(content, "\r\n", "\n")
220
+ normalized := normalizeLineEndings(content)
205
221
  start := strings.Index(normalized, heading)
206
222
  if start == -1 {
207
223
  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"`
@@ -0,0 +1,2 @@
1
+ go test fuzz v1
2
+ string("---\n\n---\r\r\n")
@@ -11,9 +11,91 @@ 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
+ if !strings.HasPrefix(normalized, "---\n") {
44
+ return "", "", ErrNoFrontmatter
45
+ }
46
+ end := strings.Index(normalized[4:], "\n---")
47
+ if end == -1 {
48
+ return "", "", ErrNoClosingFrontmatter
49
+ }
50
+ yamlStr = strings.TrimSpace(normalized[4 : 4+end])
51
+ bodyStart := 4 + end + 4 // "---\n" + yaml + "\n---"
52
+ body = ""
53
+ if bodyStart < len(normalized) {
54
+ body = normalized[bodyStart:]
55
+ }
56
+ return yamlStr, body, nil
57
+ }
58
+
59
+ func updateFrontmatterField(path, key, value string) error {
60
+ content, err := os.ReadFile(path)
61
+ if err != nil {
62
+ return fmt.Errorf("read %s: %w", path, err)
63
+ }
64
+
65
+ normalized := normalizeLineEndings(string(content))
66
+
67
+ raw, body, err := SplitFrontmatterBody(normalized)
68
+ if err != nil {
69
+ return fmt.Errorf("extract frontmatter: %w", err)
70
+ }
71
+
72
+ var doc yaml.Node
73
+ if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
74
+ return fmt.Errorf("parse yaml: %w", err)
75
+ }
76
+
77
+ if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
78
+ return fmt.Errorf("unexpected yaml structure")
79
+ }
80
+
81
+ mapping := doc.Content[0]
82
+ if mapping.Kind != yaml.MappingNode {
83
+ return fmt.Errorf("frontmatter is not a mapping")
84
+ }
85
+
86
+ setMappingField(mapping, key, value)
87
+
88
+ out, err := yaml.Marshal(&doc)
89
+ if err != nil {
90
+ return fmt.Errorf("marshal yaml: %w", err)
91
+ }
92
+
93
+ newContent := "---\n" + strings.TrimSpace(string(out)) + "\n---" + body
94
+ return os.WriteFile(path, []byte(newContent), 0644)
95
+ }
14
96
 
15
97
  func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
16
- if err := ValidateTaskLifecycle(*task); err != nil {
98
+ if err := ValidateTaskLifecycle(task); err != nil {
17
99
  return err
18
100
  }
19
101
 
@@ -31,9 +113,9 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
31
113
  return fmt.Errorf("read %s: %w", path, err)
32
114
  }
33
115
 
34
- normalized := strings.ReplaceAll(string(content), "\r\n", "\n")
116
+ normalized := normalizeLineEndings(string(content))
35
117
 
36
- raw, err := extractFrontmatter(normalized)
118
+ raw, body, err := SplitFrontmatterBody(normalized)
37
119
  if err != nil {
38
120
  return fmt.Errorf("extract frontmatter: %w", err)
39
121
  }
@@ -56,8 +138,10 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
56
138
 
57
139
  if task.Stage == "" {
58
140
  removeMappingField(mapping, "phase")
141
+ removeMappingField(mapping, "stage")
59
142
  } else {
60
143
  setMappingField(mapping, "phase", string(task.Stage))
144
+ removeMappingField(mapping, "stage")
61
145
  }
62
146
 
63
147
  out, err := yaml.Marshal(&doc)
@@ -65,13 +149,6 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
65
149
  return fmt.Errorf("marshal yaml: %w", err)
66
150
  }
67
151
 
68
- delimLen := 4
69
- bodyStart := delimLen + len(raw) + delimLen
70
- body := ""
71
- if bodyStart < len(normalized) {
72
- body = normalized[bodyStart:]
73
- }
74
-
75
152
  newContent := "---\n" + strings.TrimSpace(string(out)) + "\n---" + body
76
153
 
77
154
  return os.WriteFile(path, []byte(newContent), 0644)
@@ -115,7 +192,7 @@ func WriteRouterState(root string, state *RouterState, expectedMtime time.Time)
115
192
  return fmt.Errorf("read %s: %w", path, err)
116
193
  }
117
194
 
118
- normalized := strings.ReplaceAll(string(content), "\r\n", "\n")
195
+ normalized := normalizeLineEndings(string(content))
119
196
 
120
197
  startIdx := strings.Index(normalized, stateBlockStart)
121
198
  if startIdx == -1 {