From c728403b352a90f427673cb30f758ec91b6f5f6c Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Sun, 16 Aug 2026 14:44:55 +0200 Subject: [PATCH] feat: task complete gains --force flag --- CHANGELOG.md | 4 +++ commands/complete-task.md | 12 ++++++-- mocks/complete-operation.go | 18 ++++++------ pkg/cli/cli.go | 9 ++++-- pkg/ops/complete.go | 12 ++++++-- pkg/ops/complete_test.go | 55 ++++++++++++++++++++++++++++++++++++- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef9dbca..bf6b98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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 + +- feat: `task complete` gains `--force`, bypassing the incomplete-checkbox guard. `commands/complete-task.md` has advertised the flag since it was written — `argument-hint` lists `[--force]`, and the abort path tells the operator to "re-run with `--force` to complete anyway" — but only `goal complete` implemented it (`cli.go`, "Complete even if open tasks are linked to this goal"). On the task path the advice was a dead end: the run failed with `incomplete subtasks: N pending` and the only way through was editing the task file to remove the checkboxes, which is lossy — out-of-scope follow-up items have to be relabelled as prose to satisfy a gate that was never meant to be absolute. Hit twice in one session on 2026-08-16, and first reported 2026-08-11. + ## v0.109.3 - fix: make `session-close` Phase 2 idempotent. It mandated invoking `/vault-cli:sync-progress` with no guard for having already run in the same conversation — but the command's own Integration section lists `sync-progress` as the mid-session checkpoint, so running it and then closing is the expected path, and the second invocation appends duplicate "What happened today" entries. Phase 2 now documents the skip and requires stating it in the Phase 9 output, so Phase 7's representation check still has something to verify against. diff --git a/commands/complete-task.md b/commands/complete-task.md index ca6930a..b1fdc95 100644 --- a/commands/complete-task.md +++ b/commands/complete-task.md @@ -29,11 +29,19 @@ Mark task as complete using vault-cli. Handles normal and recurring tasks approp - Print: `❌ Task has incomplete items. Finish them first, or re-run with --force to complete anyway.` - STOP. Do NOT call `vault-cli task complete`. No interactive prompt. - c. Run vault-cli (incomplete items + FORCE=true, OR no incomplete items): + c. Run vault-cli. Pass `--force` through whenever FORCE=true — the CLI enforces + the same incomplete-checkbox guard, so omitting it makes the flag a no-op and + the run fails with `incomplete subtasks: N pending`: ```bash - vault-cli task complete "{task_name}" + vault-cli task complete "{task_name}" # no incomplete items + vault-cli task complete "{task_name}" --force # incomplete items + FORCE=true ``` + **`--force` is for items that were never this task's work** — follow-ups filed + for a separate task, or notes that are checkboxes only by formatting. It is not + a way to close unfinished work. If the unchecked items are genuinely part of the + task, finish them. + d. Show report: ``` ✅ Task completed: [[{task_name}]] diff --git a/mocks/complete-operation.go b/mocks/complete-operation.go index cb82096..3b99084 100644 --- a/mocks/complete-operation.go +++ b/mocks/complete-operation.go @@ -9,13 +9,14 @@ import ( ) type CompleteOperation struct { - ExecuteStub func(context.Context, string, string, string) (ops.MutationResult, error) + ExecuteStub func(context.Context, string, string, string, bool) (ops.MutationResult, error) executeMutex sync.RWMutex executeArgsForCall []struct { arg1 context.Context arg2 string arg3 string arg4 string + arg5 bool } executeReturns struct { result1 ops.MutationResult @@ -29,7 +30,7 @@ type CompleteOperation struct { invocationsMutex sync.RWMutex } -func (fake *CompleteOperation) Execute(arg1 context.Context, arg2 string, arg3 string, arg4 string) (ops.MutationResult, error) { +func (fake *CompleteOperation) Execute(arg1 context.Context, arg2 string, arg3 string, arg4 string, arg5 bool) (ops.MutationResult, error) { fake.executeMutex.Lock() ret, specificReturn := fake.executeReturnsOnCall[len(fake.executeArgsForCall)] fake.executeArgsForCall = append(fake.executeArgsForCall, struct { @@ -37,13 +38,14 @@ func (fake *CompleteOperation) Execute(arg1 context.Context, arg2 string, arg3 s arg2 string arg3 string arg4 string - }{arg1, arg2, arg3, arg4}) + arg5 bool + }{arg1, arg2, arg3, arg4, arg5}) stub := fake.ExecuteStub fakeReturns := fake.executeReturns - fake.recordInvocation("Execute", []interface{}{arg1, arg2, arg3, arg4}) + fake.recordInvocation("Execute", []interface{}{arg1, arg2, arg3, arg4, arg5}) fake.executeMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3, arg4) + return stub(arg1, arg2, arg3, arg4, arg5) } if specificReturn { return ret.result1, ret.result2 @@ -57,17 +59,17 @@ func (fake *CompleteOperation) ExecuteCallCount() int { return len(fake.executeArgsForCall) } -func (fake *CompleteOperation) ExecuteCalls(stub func(context.Context, string, string, string) (ops.MutationResult, error)) { +func (fake *CompleteOperation) ExecuteCalls(stub func(context.Context, string, string, string, bool) (ops.MutationResult, error)) { fake.executeMutex.Lock() defer fake.executeMutex.Unlock() fake.ExecuteStub = stub } -func (fake *CompleteOperation) ExecuteArgsForCall(i int) (context.Context, string, string, string) { +func (fake *CompleteOperation) ExecuteArgsForCall(i int) (context.Context, string, string, string, bool) { fake.executeMutex.RLock() defer fake.executeMutex.RUnlock() argsForCall := fake.executeArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 } func (fake *CompleteOperation) ExecuteReturns(result1 ops.MutationResult, result2 error) { diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 3e4cdd4..507bcfb 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -148,7 +148,8 @@ func createCompleteCommand( vaultName *string, outputFormat *string, ) *cobra.Command { - return &cobra.Command{ + var force bool + cmd := &cobra.Command{ Use: "complete ", Short: "Mark a task as complete", Args: cobra.ExactArgs(1), @@ -176,7 +177,7 @@ func createCompleteCommand( dailyStore, currentDateTime, ) - result, err := completeOp.Execute(ctx, vault.Path, taskName, vault.Name) + result, err := completeOp.Execute(ctx, vault.Path, taskName, vault.Name, force) if err != nil { return result, err } @@ -195,6 +196,10 @@ func createCompleteCommand( ) }, } + + cmd.Flags(). + BoolVar(&force, "force", false, "Complete even if the task has incomplete checkboxes") + return cmd } func createDeferCommand( diff --git a/pkg/ops/complete.go b/pkg/ops/complete.go index 4b7d57c..0e7cf24 100644 --- a/pkg/ops/complete.go +++ b/pkg/ops/complete.go @@ -20,11 +20,14 @@ import ( //counterfeiter:generate -o ../../mocks/complete-operation.go --fake-name CompleteOperation . CompleteOperation type CompleteOperation interface { + // Execute marks a task as complete. When force is true the incomplete-subtask + // guard is bypassed, mirroring the --force flag on goal complete. Execute( ctx context.Context, vaultPath string, taskName string, vaultName string, + force bool, ) (MutationResult, error) } @@ -73,6 +76,7 @@ func (c *completeOperation) Execute( vaultPath string, taskName string, vaultName string, + force bool, ) (MutationResult, error) { var warnings []string @@ -94,9 +98,11 @@ func (c *completeOperation) Execute( return c.handleRecurringTask(ctx, task, vaultPath, vaultName, warnings) } - // Check subtask completion for non-recurring tasks - if result, shouldBlock, blockErr := c.checkSubtaskCompletion(ctx, task); shouldBlock { - return result, blockErr + // Check subtask completion for non-recurring tasks, unless forced + if !force { + if result, shouldBlock, blockErr := c.checkSubtaskCompletion(ctx, task); shouldBlock { + return result, blockErr + } } // Update task status to completed diff --git a/pkg/ops/complete_test.go b/pkg/ops/complete_test.go index fb18d4f..1ef1ee2 100644 --- a/pkg/ops/complete_test.go +++ b/pkg/ops/complete_test.go @@ -31,6 +31,7 @@ var _ = Describe("CompleteOperation", func() { vaultPath string taskName string task *domain.Task + force bool ) BeforeEach(func() { @@ -57,10 +58,11 @@ var _ = Describe("CompleteOperation", func() { ) mockTaskStorage.FindTaskByNameReturns(task, nil) mockTaskStorage.WriteTaskReturns(nil) + force = false }) JustBeforeEach(func() { - result, err = completeOp.Execute(ctx, vaultPath, taskName, "test-vault") + result, err = completeOp.Execute(ctx, vaultPath, taskName, "test-vault", force) }) Context("success", func() { @@ -116,6 +118,57 @@ var _ = Describe("CompleteOperation", func() { }) }) + Context("task with incomplete checkboxes", func() { + BeforeEach(func() { + task = domain.NewTask( + map[string]any{"status": "todo"}, + domain.FileMetadata{Name: taskName}, + domain.Content("# Tasks\n\n- [x] done\n- [ ] still open\n"), + ) + mockTaskStorage.FindTaskByNameReturns(task, nil) + }) + + Context("without force", func() { + BeforeEach(func() { + force = false + }) + + It("returns error", func() { + Expect(err).NotTo(BeNil()) + }) + + It("reports the pending count", func() { + Expect(err.Error()).To(ContainSubstring("incomplete subtasks: 1 pending")) + }) + + It("does not write the task", func() { + Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(0)) + }) + + It("reports incomplete_items as the reason", func() { + Expect(result.Reason).To(Equal("incomplete_items")) + }) + }) + + Context("force bypasses the incomplete-checkbox check", func() { + BeforeEach(func() { + force = true + }) + + It("returns no error", func() { + Expect(err).To(BeNil()) + }) + + It("writes the task", func() { + Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1)) + }) + + It("marks the task completed", func() { + Expect(task.Status()).To(Equal(domain.TaskStatusCompleted)) + }) + }) + }) + Context("task with associated goal", func() { var goal *domain.Goal