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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ 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: Add `STATUS_DATE_MISMATCH` lint check in `pkg/ops/lint.go` — surfaces when `status: next` or `status: backlog` coexists with any of `planned_date`, `defer_date`, or `due_date` (calendar dates are commitments; only `in_progress` and terminal statuses are compatible with a date on an unstarted task). Detector powers both `vault-cli task lint` and `vault-cli task validate` through shared `collectLintIssues`. `lint --fix` auto-promotes `next`/`backlog` to `in_progress` and leaves the date field byte-identical.
- feat: `vault-cli task defer` on a `next` or `backlog` task now also writes `status: in_progress` in the same file write — closing the create-side leak at write-time. Auto-promote is gated to `next` and `backlog` only; `in_progress`, `completed`, `aborted`, and `hold` are left untouched. `defer` on an already-`in_progress` task is idempotent (status line is not re-written — only `defer_date` is set). Existing defer semantics (past-date validation, planned_date clearing when before target, daily-note updates) continue to work unchanged.
- feat: Enforce calendar-as-commitment rule on task status — tasks with any of `planned_date`, `defer_date`, or `due_date` must have `status: in_progress` (or terminal). Enforced at file creation (`task-creator` agent emits `in_progress` when a date field is set), at date assignment (`task defer` auto-promotes `next`/`backlog` to `in_progress` in the same write), and at audit (`task lint` reports `STATUS_DATE_MISMATCH`; `task lint --fix` promotes status, never strips the date). Lint and validate share a single detector.

## v0.77.0

- feat: `/vault-cli:sync-progress` (new Phase 6) and `/vault-cli:complete-task` (MODE=interactive step 2e) now emit a `⚪ DONE` state-closer panel recommending `/vault-cli:session-close` after a task is completed in the session. Prevents the prior drift where Claude invented a closer pointing at `/vault-cli:next-task` — wrong for the one-task-per-session orchestrator workflow (queued daily-note items get fresh Claude sessions via the orchestrator, never appended to the current one). `complete-task` MODE=tool path is explicitly guarded — JSON output stays clean. PR-only / progress-only sync paths skip the closer.
Expand Down
2 changes: 1 addition & 1 deletion agents/task-creator.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ If `task_template` is set but the file does not exist, fail with a clear error n

Required fields:

- `status: todo` (interactive default; tool mode may override via flag)
- `status: in_progress` IF any of `planned_date`, `defer_date`, or `due_date` is being written to this task in step 8 (per spec 017: a calendar date is a commitment, so the task must be visible to the Kanban board); `status: next` OTHERWISE (canonical replacement for the legacy `todo` alias)
- `priority: <1|2|3>`
- `themes:` and/or `goals:` — only if confidently inferred or explicitly provided
- `category: <category>` — if inferred
Expand Down
4 changes: 4 additions & 0 deletions docs/task-writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ The auditor (`task-auditor` agent) checks structure, success-criteria binary-nes
| `aborted` | Abandoned without completion | Operator sets manually with reason in body |
| `backlog` | Not committed yet | Initial state before commitment |

### Calendar-as-commitment rule

Any task with a calendar date (`planned_date`, `defer_date`, or `due_date`) is a commitment, so its status must be `in_progress` (or terminal — `completed` / `aborted`). The rule is enforced at three points: file creation (`task-creator` agent emits `in_progress` when any date field is set), date assignment (`task defer` auto-promotes `next` / `backlog` to `in_progress` in the same write), and audit (`task lint` and `task validate` both surface `STATUS_DATE_MISMATCH`). `task lint --fix` promotes the status; the date is never stripped. Terminal status takes precedence — a `completed` task with a stale `defer_date` is out of scope. See spec 017.

Recurring tasks reset on `complete` rather than archive — `recurring:` frontmatter drives the deferral cycle.

## Vault-Specific Examples
Expand Down
12 changes: 12 additions & 0 deletions pkg/ops/defer.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ func (d *deferOperation) findAndDeferTask(
) (*domain.Task, error) {
task.SetDeferDate(targetDate.Ptr())

// Calendar-as-commitment: promote status when deferring a non-active task.
// Per spec 017: deferring to a future date is a commitment to work the task;
// next/backlog tasks are invisible to the Kanban board and miss cadence.
// Promote to in_progress so the board surfaces the task on its target day.
// Idempotent on in_progress; no-op on completed/aborted/hold (out of scope).
if status := task.Status(); status == domain.TaskStatusNext ||
status == domain.TaskStatusBacklog {
if err := task.SetStatus(domain.TaskStatusInProgress); err != nil {
return nil, errors.Wrap(ctx, err, "set status to in_progress")
}
}

// Clear planned_date if it's before the defer target date
if task.PlannedDate() != nil && task.PlannedDate().Before(targetDate) {
task.SetPlannedDate(nil)
Expand Down
163 changes: 161 additions & 2 deletions pkg/ops/defer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ var _ = Describe("DeferOperation", func() {
Expect(err).To(BeNil())
})

It("does not change task status", func() {
It("promotes status from next (todo alias) to in_progress", func() {
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusNext))
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusInProgress))
})

It("sets defer_date to 7 days from now", func() {
Expand Down Expand Up @@ -214,6 +214,165 @@ var _ = Describe("DeferOperation", func() {
})
})

Context("status auto-promote on defer", func() {
expectedDeferDate := func() time.Time {
return libtimetest.ParseDateTime("2026-03-03T12:00:00Z").
Time().
AddDate(0, 0, 7).
Truncate(24 * time.Hour)
}

Context("when current status is next", func() {
BeforeEach(func() {
task = domain.NewTask(
map[string]any{"status": "next"},
domain.FileMetadata{Name: taskName},
domain.Content(""),
)
mockTaskStorage.FindTaskByNameReturns(task, nil)
})

It("writes status: in_progress", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusInProgress))
})

It("still writes defer_date", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.DeferDate()).NotTo(BeNil())
Expect(writtenTask.DeferDate().Time()).To(Equal(expectedDeferDate()))
})

It("does not call WriteTask twice", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
})
})

Context("when current status is backlog", func() {
BeforeEach(func() {
task = domain.NewTask(
map[string]any{"status": "backlog"},
domain.FileMetadata{Name: taskName},
domain.Content(""),
)
mockTaskStorage.FindTaskByNameReturns(task, nil)
})

It("writes status: in_progress", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusInProgress))
})

It("still writes defer_date", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.DeferDate()).NotTo(BeNil())
Expect(writtenTask.DeferDate().Time()).To(Equal(expectedDeferDate()))
})

It("does not call WriteTask twice", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
})
})

Context("when current status is in_progress", func() {
BeforeEach(func() {
task = domain.NewTask(
map[string]any{"status": "in_progress"},
domain.FileMetadata{Name: taskName},
domain.Content(""),
)
mockTaskStorage.FindTaskByNameReturns(task, nil)
})

It("leaves status as in_progress (idempotent)", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusInProgress))
// Byte-level check: raw frontmatter key is exactly "in_progress"
Expect(writtenTask.GetString("status")).To(Equal("in_progress"))
})

It("still writes defer_date", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.DeferDate()).NotTo(BeNil())
Expect(writtenTask.DeferDate().Time()).To(Equal(expectedDeferDate()))
})
})

Context("when current status is completed", func() {
BeforeEach(func() {
task = domain.NewTask(
map[string]any{"status": "completed"},
domain.FileMetadata{Name: taskName},
domain.Content(""),
)
mockTaskStorage.FindTaskByNameReturns(task, nil)
})

It("leaves status as completed (terminal preserved)", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusCompleted))
// Byte-level check: raw frontmatter key is exactly "completed"
Expect(writtenTask.GetString("status")).To(Equal("completed"))
})
})

Context("when current status is aborted", func() {
BeforeEach(func() {
task = domain.NewTask(
map[string]any{"status": "aborted"},
domain.FileMetadata{Name: taskName},
domain.Content(""),
)
mockTaskStorage.FindTaskByNameReturns(task, nil)
})

It("leaves status as aborted (terminal preserved)", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusAborted))
// Byte-level check: raw frontmatter key is exactly "aborted"
Expect(writtenTask.GetString("status")).To(Equal("aborted"))
})
})

Context("when current status is hold", func() {
BeforeEach(func() {
task = domain.NewTask(
map[string]any{"status": "hold"},
domain.FileMetadata{Name: taskName},
domain.Content(""),
)
mockTaskStorage.FindTaskByNameReturns(task, nil)
})

It("leaves status as hold", func() {
Expect(err).To(BeNil())
Expect(mockTaskStorage.WriteTaskCallCount()).To(Equal(1))
_, writtenTask := mockTaskStorage.WriteTaskArgsForCall(0)
Expect(writtenTask.Status()).To(Equal(domain.TaskStatusHold))
// Byte-level check: raw frontmatter key is exactly "hold"
Expect(writtenTask.GetString("status")).To(Equal("hold"))
})
})
})

It("calls FindTaskByName", func() {
Expect(mockTaskStorage.FindTaskByNameCallCount()).To(Equal(1))
actualCtx, actualVaultPath, actualTaskName := mockTaskStorage.FindTaskByNameArgsForCall(
Expand Down
Loading
Loading