diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8695c..d8c17ca 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.110.0 - feat: `work-on-task-assistant` Phase 5 now prompts the `accept edits` permission-mode switch when the extracted workflow assigns cluster/deploy mutations to the operator. Stating the operator/agent split was not enough on its own — observed 2026-08-16, the block correctly said "the **operator** runs the cluster mutations" and the session still spent ~40 minutes handing back command blocks until the owner interrupted. The split says who may run the command; the new line makes the switch that lets the agent run it in-session. 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