From 8ef5b222d9c5155de93f6f2bfe68c612e208fe16 Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Fri, 21 Aug 2026 21:16:16 +0200 Subject: [PATCH 1/2] accept in-progress checkbox in goal roll-up --- CHANGELOG.md | 3 +++ pkg/ops/complete.go | 29 +++++++++++++++++++++++++---- pkg/ops/complete_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9c9bd..c95ece1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Please choose versions by [Semantic Versioning](http://semver.org/). * MINOR version when you add functionality in a backwards-compatible manner, and * PATCH version when you make backwards-compatible bug fixes. +## Unreleased +- `task complete`: the parent-goal roll-up now accepts an in-progress checkbox (`- [/]`), not just `- [ ]`. It matched only the pending marker, so a goal entry that existed and correctly named the task was skipped whenever the task was marked in-progress — and that is precisely the state a task being completed is usually in. The result was a `checkbox not found for task ... in goal ...` warning, task completion succeeding anyway, and the goal silently left stale. Both markers now roll up to `- [x]` + ## v0.114.3 - `sync-progress`: daily-note entry template now wikilinks the task name (`### [[{Task Name}]] — Done ✅`). The plain-text form it emitted before failed `session-close` Phase 7, which verifies the session's work is represented by matching `[[wikilink]]` against touched task/goal titles — so every sync-then-close pair produced a false "no entry for this session's work" flag that had to be hand-patched. Producer and consumer now agree diff --git a/pkg/ops/complete.go b/pkg/ops/complete.go index 0337cdc..86633d7 100644 --- a/pkg/ops/complete.go +++ b/pkg/ops/complete.go @@ -316,6 +316,21 @@ func countCheckboxStates(content string) (completed, inProgress, pending int) { return completed, inProgress, pending } +// goalCheckboxMarkers are the unchecked checkbox markers a goal may use for a +// task: pending and in-progress. Both roll up to "- [x]" on completion. +var goalCheckboxMarkers = []string{"- [ ]", "- [/]"} + +// goalCheckboxMarker returns the unchecked checkbox marker present in line, or +// an empty string when the line carries none. +func goalCheckboxMarker(line string) string { + for _, marker := range goalCheckboxMarkers { + if strings.Contains(line, marker) { + return marker + } + } + return "" +} + // markGoalCheckbox marks the checkbox for a task in the goal file. func (c *completeOperation) markGoalCheckbox( ctx context.Context, @@ -333,10 +348,16 @@ func (c *completeOperation) markGoalCheckbox( modified := false for i, line := range lines { - // Match checkbox with task name (case-insensitive) - if strings.Contains(line, "- [ ]") && - strings.Contains(strings.ToLower(line), strings.ToLower(taskName)) { - lines[i] = strings.Replace(line, "- [ ]", "- [x]", 1) + // Match checkbox with task name (case-insensitive). Both the pending + // marker and the in-progress marker are accepted: a task that is + // actively being worked is marked "- [/]" on its goal, so matching only + // "- [ ]" skipped exactly the tasks most likely to be completed. + marker := goalCheckboxMarker(line) + if marker == "" { + continue + } + if strings.Contains(strings.ToLower(line), strings.ToLower(taskName)) { + lines[i] = strings.Replace(line, marker, "- [x]", 1) modified = true break } diff --git a/pkg/ops/complete_test.go b/pkg/ops/complete_test.go index 44c68a1..2f0f9bc 100644 --- a/pkg/ops/complete_test.go +++ b/pkg/ops/complete_test.go @@ -229,6 +229,37 @@ status: active }) }) + Context("task with an in-progress goal checkbox", func() { + var goal *domain.Goal + + BeforeEach(func() { + task.SetGoals([]string{"Test Goal"}) + + goal = domain.NewGoal( + map[string]any{"status": "active"}, + domain.FileMetadata{Name: "Test Goal"}, + domain.Content(`--- +status: active +--- +# Test Goal + +## Tasks +- [/] my-task +`), + ) + mockGoalStorage.FindGoalByNameReturns(goal, nil) + mockGoalStorage.WriteGoalReturns(nil) + }) + + It("marks the in-progress checkbox as complete", func() { + Expect(err).To(BeNil()) + Expect(mockGoalStorage.WriteGoalCallCount()).To(BeNumerically(">", 0)) + _, updatedGoal := mockGoalStorage.WriteGoalArgsForCall(0) + Expect(string(updatedGoal.Content)).To(ContainSubstring("- [x] my-task")) + Expect(string(updatedGoal.Content)).NotTo(ContainSubstring("- [/] my-task")) + }) + }) + Context("task with goal not found", func() { BeforeEach(func() { task.SetGoals([]string{"Missing Goal"}) From 13850e36d08a27648b4a816fecff26b6dc0cb6a3 Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Fri, 21 Aug 2026 21:40:26 +0200 Subject: [PATCH 2/2] cover no-marker and already-checked goal entries --- pkg/ops/complete_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/pkg/ops/complete_test.go b/pkg/ops/complete_test.go index 2f0f9bc..8eff5c6 100644 --- a/pkg/ops/complete_test.go +++ b/pkg/ops/complete_test.go @@ -260,6 +260,64 @@ status: active }) }) + Context("task whose goal entry carries no checkbox", func() { + var goal *domain.Goal + + BeforeEach(func() { + task.SetGoals([]string{"Test Goal"}) + + goal = domain.NewGoal( + map[string]any{"status": "active"}, + domain.FileMetadata{Name: "Test Goal"}, + domain.Content(`--- +status: active +--- +# Test Goal + +## Tasks + +1. my-task — written as a plain numbered list, no checkbox + +`), + ) + mockGoalStorage.FindGoalByNameReturns(goal, nil) + mockGoalStorage.WriteGoalReturns(nil) + }) + + It("completes the task without rewriting the goal", func() { + Expect(err).To(BeNil()) + Expect(mockGoalStorage.WriteGoalCallCount()).To(Equal(0)) + }) + }) + + Context("task whose goal checkbox is already complete", func() { + var goal *domain.Goal + + BeforeEach(func() { + task.SetGoals([]string{"Test Goal"}) + + goal = domain.NewGoal( + map[string]any{"status": "active"}, + domain.FileMetadata{Name: "Test Goal"}, + domain.Content(`--- +status: active +--- +# Test Goal + +## Tasks +- [x] my-task +`), + ) + mockGoalStorage.FindGoalByNameReturns(goal, nil) + mockGoalStorage.WriteGoalReturns(nil) + }) + + It("completes the task without rewriting the goal", func() { + Expect(err).To(BeNil()) + Expect(mockGoalStorage.WriteGoalCallCount()).To(Equal(0)) + }) + }) + Context("task with goal not found", func() { BeforeEach(func() { task.SetGoals([]string{"Missing Goal"})