savepoint 1.0.3 → 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 (39) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.savepoint/Design.md +4 -3
  3. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Audit.md +272 -0
  4. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +25 -8
  5. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +11 -11
  6. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +15 -9
  7. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +11 -11
  8. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +9 -9
  9. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +11 -11
  10. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +9 -9
  11. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +15 -10
  12. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T008-ci-and-release-automation.md +46 -0
  13. package/.savepoint/router.md +4 -4
  14. package/AGENTS.md +2 -2
  15. package/Makefile +3 -1
  16. package/agent_skills_test.go +1 -1
  17. package/internal/board/board.go +4 -0
  18. package/internal/board/card_test.go +33 -0
  19. package/internal/board/column.go +43 -14
  20. package/internal/board/column_test.go +71 -0
  21. package/internal/board/debug.go +26 -0
  22. package/internal/board/debug_test.go +108 -0
  23. package/internal/board/detail.go +33 -0
  24. package/internal/board/detail_test.go +48 -0
  25. package/internal/board/epic_panel.go +2 -0
  26. package/internal/board/update.go +19 -0
  27. package/internal/board/update_test.go +27 -0
  28. package/internal/board/view_test.go +62 -0
  29. package/internal/board/watch.go +6 -0
  30. package/internal/buildtool/main.go +44 -6
  31. package/internal/buildtool/main_test.go +178 -0
  32. package/internal/data/fuzz_test.go +75 -0
  33. package/internal/data/parser.go +3 -2
  34. package/internal/data/testdata/fuzz/FuzzSplitFrontmatterBody/68eb66b0fe91e7e3 +2 -0
  35. package/internal/data/write.go +9 -6
  36. package/main.go +24 -5
  37. package/package.json +1 -1
  38. package/savepoint +0 -0
  39. /package/project-audit/{audit_report_opus_4.6 → audit_report_opus_4.6.md} +0 -0
@@ -0,0 +1,46 @@
1
+ ---
2
+ id: E15-hardening/T008-ci-and-release-automation
3
+ status: done
4
+ objective: Add repo-local CI coverage for build/test checks and keep the buildtool-driven release flow reproducible
5
+ depends_on: []
6
+ ---
7
+
8
+ # T008: Add CI and Release Automation Guardrails
9
+
10
+ ## Context Files
11
+
12
+ - `Makefile` - local entrypoint for build, test, and dist commands
13
+ - `internal/buildtool/main.go` - build orchestration and packaging logic
14
+ - `.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md` - epic scope and component map
15
+
16
+ ## Acceptance Criteria
17
+
18
+ - [x] `.github/workflows/ci.yml` exists and runs on push and pull_request events
19
+ - [x] The CI workflow runs `go test ./...` and `make build` at minimum
20
+ - [x] The CI workflow uses the repo-local Makefile targets rather than duplicating buildtool logic inline
21
+ - [x] `Makefile` exposes a `ci` target that runs the local verification steps used by CI
22
+ - [x] Generated binaries and archives remain excluded from version control via `.gitignore`
23
+
24
+ ## Implementation Plan
25
+
26
+ - [x] Add a GitHub Actions workflow for CI in `.github/workflows/ci.yml`
27
+ - [x] Add a `ci` Makefile target that wraps the local verification commands
28
+ - [x] Reuse existing `build`, `test`, and `smoke-test` targets instead of duplicating orchestration
29
+ - [x] Confirm artifact ignore rules still cover local binaries and archives
30
+ - [x] Run `make build && make test`
31
+
32
+ ## Context Log
33
+
34
+ Files read:
35
+ - Makefile
36
+ - internal/buildtool/main.go
37
+ - .gitignore
38
+ - .savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md
39
+ - main.go
40
+ - go.mod
41
+
42
+ Estimated input tokens:
43
+ -
44
+
45
+ Notes:
46
+ -
@@ -12,11 +12,11 @@ Read `.savepoint/PRD.md` only for vision changes. Read `.savepoint/Design.md` on
12
12
  ## Current state
13
13
 
14
14
  ```yaml
15
- state: task-building
15
+ state: epic-design
16
16
  release: v1.1
17
- epic: E14
18
- task: E15-hardening/T001-benchmarks
19
- next_action: Build E15-hardening/T001-benchmarks.
17
+ epic: ""
18
+ task: ""
19
+ next_action: Plan next epic.
20
20
  ```
21
21
 
22
22
  ## State → action
package/AGENTS.md CHANGED
@@ -85,8 +85,8 @@ make build && make test
85
85
  | `main.go` | CLI entrypoint, --version |
86
86
  | `cmd/` | CLI command arg parsing and dispatch for init, board, and doctor |
87
87
  | `internal/init/` | Target validation, scaffold writing from templates |
88
- | `internal/board/` | TUI board, overlays, epic sidebar, Next Activity line, router priority key, detail checklist rendering, status glyphs, forced color profile, async update I/O commands, shared board utilities |
89
- | `internal/buildtool/` | Makefile helper, cross-compile, archives |
88
+ | `internal/board/` | TUI board, overlays, epic sidebar, Next Activity line, router priority key, detail checklist rendering, status glyphs, forced color profile, debug logging hooks, async update I/O commands, shared board utilities |
89
+ | `internal/buildtool/` | Makefile helper, cross-compile including Windows targets, archives, distribution checksums |
90
90
  | `internal/doctor/` | Read-only project diagnostics, integrity checks, timed quality gate execution, report formatting, typed repair suggestions |
91
91
  | `internal/data/` | Task/router models, frontmatter parsing/splitting, lifecycle validation/defaulting, discovery including root-dir traversal, unified task status constants, canonical write helpers |
92
92
  | `internal/testutil/` | Shared Go test fixtures and filesystem helpers for internal package tests |
package/Makefile CHANGED
@@ -1,4 +1,4 @@
1
- .PHONY: build test run clean build-linux build-darwin build-all dist smoke-test
1
+ .PHONY: build test run clean build-linux build-darwin build-all dist smoke-test ci
2
2
 
3
3
  VERSION ?=
4
4
 
@@ -27,3 +27,5 @@ dist:
27
27
 
28
28
  smoke-test:
29
29
  go run ./internal/buildtool -version "$(VERSION)" smoke-test
30
+
31
+ ci: test build
@@ -1,4 +1,4 @@
1
- package main
1
+ package main_test
2
2
 
3
3
  import (
4
4
  "os"
@@ -36,10 +36,12 @@ func newProjectModel(start, releaseFilter, epicFilter string) (Model, error) {
36
36
  func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, deps ModelDependencies) (Model, error) {
37
37
  deps = modelDependencies([]ModelDependencies{deps})
38
38
 
39
+ debugf("board init: finding savepoint root from %q", start)
39
40
  root, err := deps.Discoverer.FindSavepointRoot(start)
40
41
  if err != nil {
41
42
  return Model{}, err
42
43
  }
44
+ debugf("board init: root = %q", root)
43
45
 
44
46
  routerState, err := readRouterState(root, deps.RouterReader)
45
47
  if err != nil {
@@ -50,6 +52,7 @@ func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, de
50
52
  if err != nil {
51
53
  return Model{}, err
52
54
  }
55
+ debugf("board init: loaded %d tasks across %d releases", len(tasks), len(releaseIDs))
53
56
 
54
57
  preferredRelease := routerState.Release
55
58
  if releaseFilter != "" {
@@ -78,6 +81,7 @@ func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, de
78
81
  return Model{}, err
79
82
  }
80
83
  model.Watcher = watcher
84
+ debugf("board init: file watcher started at %q", root)
81
85
 
82
86
  return model, nil
83
87
  }
@@ -252,3 +252,36 @@ func TestRenderCard_doneShowsDoneText(t *testing.T) {
252
252
  t.Error("RenderCard missing DONE phase label")
253
253
  }
254
254
  }
255
+
256
+ func BenchmarkRenderCard_narrow(b *testing.B) {
257
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout", Stage: data.StageBuild}
258
+ b.ReportAllocs()
259
+ for b.Loop() {
260
+ RenderCard(task, 24, false, nil)
261
+ }
262
+ }
263
+
264
+ func BenchmarkRenderCard_standard(b *testing.B) {
265
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout for the board view", Stage: data.StageTest}
266
+ b.ReportAllocs()
267
+ for b.Loop() {
268
+ RenderCard(task, 40, false, nil)
269
+ }
270
+ }
271
+
272
+ func BenchmarkRenderCard_wide(b *testing.B) {
273
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout for the board view with extra details", Stage: data.StageAudit}
274
+ b.ReportAllocs()
275
+ for b.Loop() {
276
+ RenderCard(task, 60, false, nil)
277
+ }
278
+ }
279
+
280
+ func BenchmarkRenderCard_focused(b *testing.B) {
281
+ task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout", Stage: data.StageBuild}
282
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T004"}
283
+ b.ReportAllocs()
284
+ for b.Loop() {
285
+ RenderCard(task, 40, true, router)
286
+ }
287
+ }
@@ -33,11 +33,6 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
33
33
  contentBudget = 1
34
34
  }
35
35
 
36
- reserveAbove := 0
37
- if offset > 0 {
38
- reserveAbove = 1
39
- }
40
-
41
36
  type cardEntry struct {
42
37
  card string
43
38
  lines int
@@ -48,12 +43,17 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
48
43
  cardEntries = append(cardEntries, cardEntry{card: c, lines: strings.Count(c, "\n") + 1})
49
44
  }
50
45
 
46
+ // Standard window: fit as many cards as possible from the start of cardEntries.
47
+ reserveAbove := 0
48
+ if offset > 0 {
49
+ reserveAbove = 1
50
+ }
51
51
  usedLines := reserveAbove
52
52
  endIdx := 0
53
53
  for endIdx < len(cardEntries) {
54
- needsMore := endIdx < len(cardEntries)-1
54
+ hasMore := (offset + endIdx + 1) < len(tasks)
55
55
  bottomReserve := 0
56
- if needsMore {
56
+ if hasMore {
57
57
  bottomReserve = 1
58
58
  }
59
59
  if usedLines+cardEntries[endIdx].lines+bottomReserve > contentBudget {
@@ -62,19 +62,49 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
62
62
  usedLines += cardEntries[endIdx].lines
63
63
  endIdx++
64
64
  }
65
-
66
65
  if endIdx == 0 && len(cardEntries) > 0 {
67
66
  endIdx = 1
68
67
  }
69
68
 
70
- if offset > 0 {
71
- lines = append(lines, renderScrollIndicator("↑", offset, "above"))
69
+ // Determine what portion of cardEntries to render.
70
+ renderStart := 0
71
+ renderEnd := endIdx
72
+
73
+ focusedRelIdx := focusedTask - offset
74
+ if focused && focusedRelIdx >= 0 && focusedRelIdx < len(cardEntries) && focusedRelIdx >= endIdx {
75
+ // Focused task is beyond the standard window.
76
+ // Anchor viewport at focused task: fill backward to use remaining budget.
77
+ bottomCost := 0
78
+ if focusedTask+1 < len(tasks) {
79
+ bottomCost = 1
80
+ }
81
+ cardsLines := cardEntries[focusedRelIdx].lines
82
+ newStart := focusedRelIdx
83
+ for newStart > 0 {
84
+ prev := cardEntries[newStart-1]
85
+ topCost := 1
86
+ if offset+newStart-1 == 0 {
87
+ topCost = 0
88
+ }
89
+ if cardsLines+prev.lines+topCost+bottomCost > contentBudget {
90
+ break
91
+ }
92
+ cardsLines += prev.lines
93
+ newStart--
94
+ }
95
+ renderStart = newStart
96
+ renderEnd = focusedRelIdx + 1
97
+ }
98
+
99
+ effectiveOffset := offset + renderStart
100
+ if effectiveOffset > 0 {
101
+ lines = append(lines, renderScrollIndicator("↑", effectiveOffset, "above"))
72
102
  }
73
- for i := 0; i < endIdx; i++ {
103
+ for i := renderStart; i < renderEnd; i++ {
74
104
  lines = append(lines, cardEntries[i].card)
75
105
  }
76
- if endIdx < len(cardEntries) {
77
- remaining := len(tasks) - (offset + endIdx)
106
+ if offset+renderEnd < len(tasks) {
107
+ remaining := len(tasks) - (offset + renderEnd)
78
108
  lines = append(lines, renderScrollIndicator("↓", remaining, "more"))
79
109
  }
80
110
  }
@@ -124,4 +154,3 @@ func columnTitle(col data.ColumnType) string {
124
154
  return strings.ToUpper(string(col))
125
155
  }
126
156
  }
127
-
@@ -1,6 +1,7 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "fmt"
4
5
  "strings"
5
6
  "testing"
6
7
 
@@ -137,3 +138,73 @@ func TestVisibleColumnTaskLimitDefaultsToFourAtStandardHeight(t *testing.T) {
137
138
  t.Errorf("visibleColumnTaskLimit(standard height) = %d, want 4", got)
138
139
  }
139
140
  }
141
+
142
+ func BenchmarkRenderColumn_empty(b *testing.B) {
143
+ b.ReportAllocs()
144
+ for b.Loop() {
145
+ RenderColumn(nil, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
146
+ }
147
+ }
148
+
149
+ func BenchmarkRenderColumn_fewTasks(b *testing.B) {
150
+ tasks := []data.Task{
151
+ {ID: "E06/T001", Title: "First task", Column: data.ColumnPlanned, Stage: data.StageBuild},
152
+ {ID: "E06/T002", Title: "Second task", Column: data.ColumnPlanned, Stage: data.StageTest},
153
+ {ID: "E06/T003", Title: "Third task", Column: data.ColumnPlanned, Stage: data.StageAudit},
154
+ }
155
+ b.ReportAllocs()
156
+ for b.Loop() {
157
+ RenderColumn(tasks, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
158
+ }
159
+ }
160
+
161
+ func BenchmarkRenderColumn_manyTasks(b *testing.B) {
162
+ tasks := make([]data.Task, 20)
163
+ stages := []data.ProgressStage{data.StageBuild, data.StageTest, data.StageAudit}
164
+ for i := range tasks {
165
+ tasks[i] = data.Task{
166
+ ID: fmt.Sprintf("E06/T%03d", i+1),
167
+ Title: fmt.Sprintf("Task number %d with a reasonable length title", i+1),
168
+ Column: data.ColumnPlanned,
169
+ Stage: stages[i%3],
170
+ Release: "v1.1",
171
+ }
172
+ }
173
+ b.ReportAllocs()
174
+ for b.Loop() {
175
+ RenderColumn(tasks, data.ColumnPlanned, 40, 24, 0, 0, false, nil)
176
+ }
177
+ }
178
+
179
+ func BenchmarkRenderColumn_focused(b *testing.B) {
180
+ tasks := []data.Task{
181
+ {ID: "E06/T001", Title: "First task", Column: data.ColumnInProgress, Stage: data.StageBuild},
182
+ {ID: "E06/T002", Title: "Second task", Column: data.ColumnInProgress, Stage: data.StageTest},
183
+ {ID: "E06/T003", Title: "Third task", Column: data.ColumnInProgress, Stage: data.StageAudit},
184
+ }
185
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T001"}
186
+ b.ReportAllocs()
187
+ for b.Loop() {
188
+ RenderColumn(tasks, data.ColumnInProgress, 40, 20, 0, 0, true, router)
189
+ }
190
+ }
191
+
192
+ func TestRenderColumn_focusedLastTaskVisibleWhenScrolled(t *testing.T) {
193
+ // When scrolled (offset > 0) the top scroll indicator steals 1 line.
194
+ // The focused last-in-page task must still appear, not be cut off.
195
+ tasks := []data.Task{
196
+ {ID: "T1", Title: "Task one", Column: data.ColumnPlanned},
197
+ {ID: "T2", Title: "Task two", Column: data.ColumnPlanned},
198
+ {ID: "T3", Title: "Task three", Column: data.ColumnPlanned},
199
+ {ID: "T4", Title: "Task four", Column: data.ColumnPlanned},
200
+ {ID: "T5", Title: "Task five", Column: data.ColumnPlanned},
201
+ }
202
+ // offset=2, focusedTask=4 (last task), maxHeight=14 (24-line terminal)
203
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 14, 2, 4, true, nil)
204
+ if !strings.Contains(got, "Task five") {
205
+ t.Errorf("focused last task must be visible when scrolled, got:\n%s", got)
206
+ }
207
+ if !strings.Contains(got, "↑") {
208
+ t.Error("scroll indicator above must appear when offset > 0")
209
+ }
210
+ }
@@ -0,0 +1,26 @@
1
+ package board
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "sync/atomic"
7
+ )
8
+
9
+ var debugEnabled atomic.Bool
10
+
11
+ // SetDebug enables or disables debug logging for the board package.
12
+ func SetDebug(enabled bool) {
13
+ debugEnabled.Store(enabled)
14
+ }
15
+
16
+ // DebugEnabled reports whether debug logging is active.
17
+ func DebugEnabled() bool {
18
+ return debugEnabled.Load()
19
+ }
20
+
21
+ func debugf(format string, args ...any) {
22
+ if !debugEnabled.Load() {
23
+ return
24
+ }
25
+ fmt.Fprintf(os.Stderr, "[savepoint debug] "+format+"\n", args...)
26
+ }
@@ -0,0 +1,108 @@
1
+ package board
2
+
3
+ import (
4
+ "bytes"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestSetDebugToggle(t *testing.T) {
13
+ t.Cleanup(func() { SetDebug(false) })
14
+
15
+ if DebugEnabled() {
16
+ t.Fatal("debug should be off by default")
17
+ }
18
+
19
+ SetDebug(true)
20
+ if !DebugEnabled() {
21
+ t.Fatal("debug should be on after SetDebug(true)")
22
+ }
23
+
24
+ SetDebug(false)
25
+ if DebugEnabled() {
26
+ t.Fatal("debug should be off after SetDebug(false)")
27
+ }
28
+ }
29
+
30
+ func TestDebugfWritesToStderr(t *testing.T) {
31
+ t.Cleanup(func() { SetDebug(false) })
32
+
33
+ r, w, err := os.Pipe()
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ orig := os.Stderr
38
+ os.Stderr = w
39
+
40
+ SetDebug(true)
41
+ debugf("test message %d", 42)
42
+
43
+ os.Stderr = orig
44
+ w.Close()
45
+
46
+ var buf bytes.Buffer
47
+ io.Copy(&buf, r)
48
+ r.Close()
49
+
50
+ out := buf.String()
51
+ if !strings.Contains(out, "test message 42") {
52
+ t.Fatalf("expected debug output, got: %q", out)
53
+ }
54
+ if !strings.Contains(out, "[savepoint debug]") {
55
+ t.Fatalf("expected debug prefix, got: %q", out)
56
+ }
57
+ }
58
+
59
+ func TestDebugfSilentWhenDisabled(t *testing.T) {
60
+ t.Cleanup(func() { SetDebug(false) })
61
+
62
+ r, w, err := os.Pipe()
63
+ if err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ orig := os.Stderr
67
+ os.Stderr = w
68
+
69
+ SetDebug(false)
70
+ debugf("should not appear")
71
+
72
+ os.Stderr = orig
73
+ w.Close()
74
+
75
+ var buf bytes.Buffer
76
+ io.Copy(&buf, r)
77
+ r.Close()
78
+
79
+ if buf.Len() != 0 {
80
+ t.Fatalf("expected no debug output when disabled, got: %q", buf.String())
81
+ }
82
+ }
83
+
84
+ func TestDebugfFormat(t *testing.T) {
85
+ t.Cleanup(func() { SetDebug(false) })
86
+
87
+ r, w, err := os.Pipe()
88
+ if err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ orig := os.Stderr
92
+ os.Stderr = w
93
+
94
+ SetDebug(true)
95
+ debugf("key=%q value=%d", "hello", 7)
96
+
97
+ os.Stderr = orig
98
+ w.Close()
99
+
100
+ var buf bytes.Buffer
101
+ io.Copy(&buf, r)
102
+ r.Close()
103
+
104
+ want := fmt.Sprintf("[savepoint debug] key=%q value=%d\n", "hello", 7)
105
+ if buf.String() != want {
106
+ t.Fatalf("expected %q, got %q", want, buf.String())
107
+ }
108
+ }
@@ -85,6 +85,36 @@ func renderChecklistSentences(text, glyph string, width int, style lipgloss.Styl
85
85
  return lines
86
86
  }
87
87
 
88
+ // knownAbbreviations is the set of dot-terminated tokens that must not trigger
89
+ // sentence splits. Add entries (lowercase, trailing dot) to extend the list.
90
+ var knownAbbreviations = map[string]bool{
91
+ "e.g.": true,
92
+ "i.e.": true,
93
+ "vs.": true,
94
+ "etc.": true,
95
+ "fig.": true,
96
+ "no.": true,
97
+ "mr.": true,
98
+ "mrs.": true,
99
+ "dr.": true,
100
+ "st.": true,
101
+ "jr.": true,
102
+ "sr.": true,
103
+ "prof.": true,
104
+ "approx.": true,
105
+ "est.": true,
106
+ }
107
+
108
+ // isKnownAbbreviation reports whether the period at dotPos in s is the trailing
109
+ // dot of a known abbreviation (e.g. "e.g.", "Dr.").
110
+ func isKnownAbbreviation(s string, dotPos int) bool {
111
+ start := dotPos
112
+ for start > 0 && s[start-1] != ' ' {
113
+ start--
114
+ }
115
+ return knownAbbreviations[strings.ToLower(s[start:dotPos+1])]
116
+ }
117
+
88
118
  func splitChecklistSentences(text string) []string {
89
119
  fields := strings.Fields(text)
90
120
  if len(fields) == 0 {
@@ -98,6 +128,9 @@ func splitChecklistSentences(text string) []string {
98
128
  if r != '.' && r != '!' && r != '?' {
99
129
  continue
100
130
  }
131
+ if r == '.' && isKnownAbbreviation(normalized, i) {
132
+ continue
133
+ }
101
134
  end := i + len(string(r))
102
135
  if end < len(normalized) && normalized[end] != ' ' {
103
136
  continue
@@ -321,6 +321,54 @@ func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
321
321
  }
322
322
  }
323
323
 
324
+ func TestSplitChecklistSentences_abbreviationEg(t *testing.T) {
325
+ got := splitChecklistSentences("Use e.g. a widget. Done.")
326
+ if len(got) != 2 {
327
+ t.Fatalf("splitChecklistSentences with e.g. = %d sentences, want 2: %v", len(got), got)
328
+ }
329
+ if got[0] != "Use e.g. a widget." {
330
+ t.Errorf("sentence[0] = %q, want %q", got[0], "Use e.g. a widget.")
331
+ }
332
+ }
333
+
334
+ func TestSplitChecklistSentences_abbreviationIe(t *testing.T) {
335
+ got := splitChecklistSentences("Call i.e. the function. Done.")
336
+ if len(got) != 2 {
337
+ t.Fatalf("splitChecklistSentences with i.e. = %d sentences, want 2: %v", len(got), got)
338
+ }
339
+ }
340
+
341
+ func TestSplitChecklistSentences_abbreviationDr(t *testing.T) {
342
+ got := splitChecklistSentences("Dr. Smith approved it. Done.")
343
+ if len(got) != 2 {
344
+ t.Fatalf("splitChecklistSentences with Dr. = %d sentences, want 2: %v", len(got), got)
345
+ }
346
+ if got[0] != "Dr. Smith approved it." {
347
+ t.Errorf("sentence[0] = %q, want %q", got[0], "Dr. Smith approved it.")
348
+ }
349
+ }
350
+
351
+ func TestSplitChecklistSentences_abbreviationEtc(t *testing.T) {
352
+ got := splitChecklistSentences("Add widgets, buttons, etc. to the panel. Done.")
353
+ if len(got) != 2 {
354
+ t.Fatalf("splitChecklistSentences with etc. = %d sentences, want 2: %v", len(got), got)
355
+ }
356
+ }
357
+
358
+ func TestSplitChecklistSentences_abbreviationCaseInsensitive(t *testing.T) {
359
+ got := splitChecklistSentences("See Fig. 3 for details. Done.")
360
+ if len(got) != 2 {
361
+ t.Fatalf("splitChecklistSentences with Fig. = %d sentences, want 2: %v", len(got), got)
362
+ }
363
+ }
364
+
365
+ func TestSplitChecklistSentences_normalSplitUnaffected(t *testing.T) {
366
+ got := splitChecklistSentences("First sentence. Second sentence.")
367
+ if len(got) != 2 {
368
+ t.Fatalf("splitChecklistSentences normal split = %d sentences, want 2: %v", len(got), got)
369
+ }
370
+ }
371
+
324
372
  func TestOverlayWidth_clampMax(t *testing.T) {
325
373
  if got := overlayWidth(120); got != 80 {
326
374
  t.Errorf("overlayWidth(120) = %d, want 80", got)
@@ -113,6 +113,8 @@ func RenderEpicAuditTab(epicSlug, content string, overlayW, maxHeight, offset in
113
113
  return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
114
114
  }
115
115
 
116
+ // epicAuditHiddenSectionHeadings lists markdown section headings suppressed in the audit tab overlay.
117
+ // Sections that are implementation details or planning artifacts clutter the summary view.
116
118
  var epicAuditHiddenSectionHeadings = map[string]struct{}{
117
119
  "12. Distribution & build": {},
118
120
  "Acceptance Criteria": {},
@@ -18,10 +18,12 @@ var columnOrder = []data.ColumnType{
18
18
  func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19
19
  switch msg := msg.(type) {
20
20
  case fileChangeMsg:
21
+ debugf("dispatch: fileChangeMsg")
21
22
  if m.Root != "" {
22
23
  return m, reloadTasks(m.Root, m.Dependencies)
23
24
  }
24
25
  case reloadMsg:
26
+ debugf("dispatch: reloadMsg tasks=%d releases=%d", len(msg.tasks), len(msg.releases))
25
27
  m.AllTasks = msg.tasks
26
28
  m.Releases = append([]string(nil), msg.releases...)
27
29
  m.ReleaseEpics = copyReleaseEpics(msg.releaseEpics)
@@ -521,6 +523,23 @@ func (m Model) columnPageSize() int {
521
523
  return visibleColumnTaskLimit(CalculateLayout(m.Width, h).ContentHeight)
522
524
  }
523
525
 
526
+ // conservativeColumnPageSize reserves 2 lines for scroll indicators so that
527
+ // ensureFocusedTaskVisible never sets an offset where the focused card is hidden
528
+ // by a top or bottom indicator that wasn't accounted for in the page budget.
529
+ func (m Model) conservativeColumnPageSize() int {
530
+ h := m.Height
531
+ if h == 0 {
532
+ h = defaultTermH
533
+ }
534
+ contentHeight := CalculateLayout(m.Width, h).ContentHeight
535
+ // contentHeight - 2 = card budget; subtract 2 more for both indicators.
536
+ limit := (contentHeight - 4) / 3
537
+ if limit < 1 {
538
+ return 1
539
+ }
540
+ return limit
541
+ }
542
+
524
543
  func (m Model) detailPageSize() int {
525
544
  return max(detailMaxHeight(m.Height)-3, 1)
526
545
  }
@@ -554,6 +554,33 @@ func TestReloadMsgUpdatesRouterState(t *testing.T) {
554
554
  }
555
555
  }
556
556
 
557
+ func TestEnsureFocusedTaskVisible_lastTaskAlwaysVisible(t *testing.T) {
558
+ // 5 tasks, pageSize=4 at standard height. Pressing down past the page
559
+ // boundary must keep the focused task in the rendered window.
560
+ tasks := make([]data.Task, 5)
561
+ for i := range tasks {
562
+ tasks[i] = data.Task{ID: "T00" + string(rune('1'+i)), Column: data.ColumnPlanned}
563
+ }
564
+ m := NewModel(tasks, "v1", "E01")
565
+ m.Width = 100
566
+ m.Height = 24
567
+ m.FocusedColumn = data.ColumnPlanned
568
+ m.FocusedTask = 4
569
+
570
+ m.ensureFocusedTaskVisible()
571
+
572
+ offset := m.ColumnOffsets[data.ColumnPlanned]
573
+ pageSize := m.columnPageSize() // 4
574
+ if offset+pageSize-1 < 4 {
575
+ t.Errorf("offset=%d pageSize=%d: focused task 4 not in [offset, offset+pageSize-1]", offset, pageSize)
576
+ }
577
+ // Render must actually show the focused task (not cut off by scroll indicator).
578
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, CalculateLayout(100, 24).ContentHeight, offset, 4, true, nil)
579
+ if !strings.Contains(got, tasks[4].ID) {
580
+ t.Errorf("focused last task %q not visible in rendered column (offset=%d):\n%s", tasks[4].ID, offset, got)
581
+ }
582
+ }
583
+
557
584
  func writeRouterFixture(t *testing.T) string {
558
585
  t.Helper()
559
586
  root := t.TempDir()