Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 25 additions & 4 deletions pkg/ops/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Expand Down
89 changes: 89 additions & 0 deletions pkg/ops/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,95 @@ 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 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"})
Expand Down
Loading