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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ 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(domain): add `GoalPhase` string-enum type with four canonical values (`todo`/`planning`/`execution`/`done`), mirroring `TaskPhase` but with no aliases — foundation for goal phase validation in a later prompt
- feat(goal): goals carry a validated `phase` frontmatter field (`todo` / `planning` / `execution` / `done`); set via `goal set <name> phase <value>`, surfaced by `goal show` (plain + `--output json`). Legacy goals without a phase are untouched; non-canonical values are rejected on write. Mirrors the task-phase shape without touching the task-phase type.

## v0.99.2

- docs(help): document XDG config path (~/.config/vault-cli/config.yaml, legacy fallback ~/.vault-cli/config.yaml) in vault-cli --help output and README
Expand Down
45 changes: 45 additions & 0 deletions pkg/domain/goal_frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/bborbe/errors"
libtime "github.com/bborbe/time"
"github.com/bborbe/validation"
)

// GoalFrontmatter holds the YAML frontmatter for a Goal.
Expand Down Expand Up @@ -63,6 +64,18 @@ func (f GoalFrontmatter) Priority() Priority {
// Assignee reads "assignee" key.
func (f GoalFrontmatter) Assignee() string { return f.GetString("assignee") }

// Phase reads "phase" key as string, returns *GoalPhase.
// Returns nil when the key is absent. The raw value is returned as-is
// (no validation, no default substitution) so legacy/hand-typed values survive display.
func (f GoalFrontmatter) Phase() *GoalPhase {
raw := f.GetString("phase")
if raw == "" {
return nil
}
p := GoalPhase(raw)
return &p
}

// ClaudeSessionID reads "claude_session_id" key as string.
func (f GoalFrontmatter) ClaudeSessionID() string { return f.GetString("claude_session_id") }

Expand Down Expand Up @@ -151,6 +164,15 @@ func (f *GoalFrontmatter) SetPriority(ctx context.Context, p Priority) error {
// SetAssignee stores the assignee in the map.
func (f *GoalFrontmatter) SetAssignee(v string) { f.Set("assignee", v) }

// SetPhase stores the phase pointer in the map. Deletes the key if p is nil.
func (f *GoalFrontmatter) SetPhase(p *GoalPhase) {
if p == nil {
f.Delete("phase")
return
}
f.Set("phase", string(*p))
}

// SetClaudeSessionID stores the claude_session_id in the map.
func (f *GoalFrontmatter) SetClaudeSessionID(v string) { f.Set("claude_session_id", v) }

Expand Down Expand Up @@ -219,6 +241,12 @@ func (f GoalFrontmatter) GetField(key string) string {
return strconv.Itoa(int(p))
case "assignee":
return f.Assignee()
case "phase":
ph := f.Phase()
if ph == nil {
return ""
}
return string(*ph)
case "start_date":
return dateFieldString(f.StartDate())
case "target_date":
Expand Down Expand Up @@ -253,6 +281,8 @@ func (f *GoalFrontmatter) SetField(ctx context.Context, key, value string) error
return f.setPriorityFromString(ctx, value)
case "assignee":
f.SetAssignee(value)
case "phase":
return f.setPhaseField(ctx, value)
case "start_date":
return setDateField(ctx, f.SetStartDate, value)
case "target_date":
Expand Down Expand Up @@ -304,6 +334,21 @@ func (f *GoalFrontmatter) setCompletedFromString(ctx context.Context, value stri
return nil
}

// setPhaseField validates the value against the goal phase enum and stores it,
// or clears the key on empty. Goal phases have no aliases — a non-canonical value is rejected.
func (f *GoalFrontmatter) setPhaseField(ctx context.Context, value string) error {
if value == "" {
f.SetPhase(nil)
return nil
}
phase := GoalPhase(value)
if err := phase.Validate(ctx); err != nil {
return errors.Wrapf(ctx, validation.Error, "unknown goal phase '%s'", value)
}
f.SetPhase(&phase)
return nil
}

func (f *GoalFrontmatter) setDeferDateFromString(ctx context.Context, value string) error {
if value == "" {
f.SetDeferDate(nil)
Expand Down
90 changes: 90 additions & 0 deletions pkg/domain/goal_frontmatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,96 @@ var _ = Describe("GoalFrontmatter", func() {
})
})

Describe("Phase", func() {
It("returns nil for missing key", func() {
Expect(domain.NewGoalFrontmatter(nil).Phase()).To(BeNil())
})

It("returns pointer for canonical value", func() {
fm := domain.NewGoalFrontmatter(map[string]any{"phase": "execution"})
Expect(fm.Phase()).NotTo(BeNil())
Expect(*fm.Phase()).To(Equal(domain.GoalPhaseExecution))
})

It("returns pointer for legacy hand-typed value without validation", func() {
fm := domain.NewGoalFrontmatter(map[string]any{"phase": "in_progress"})
Expect(fm.Phase()).NotTo(BeNil())
Expect(*fm.Phase()).To(Equal(domain.GoalPhase("in_progress")))
})
})

Describe("SetPhase", func() {
It("stores the phase string via pointer", func() {
fm := domain.NewGoalFrontmatter(nil)
fm.SetPhase(domain.GoalPhaseDone.Ptr())
Expect(fm.GetField("phase")).To(Equal("done"))
})

It("nil pointer deletes the key", func() {
fm := domain.NewGoalFrontmatter(map[string]any{"phase": "todo"})
fm.SetPhase(nil)
Expect(fm.GetField("phase")).To(Equal(""))
Expect(fm.Keys()).NotTo(ContainElement("phase"))
})
})

Describe("SetField phase", func() {
DescribeTable("accepts canonical values",
func(value string) {
fm := domain.NewGoalFrontmatter(nil)
Expect(fm.SetField(ctx, "phase", value)).To(Succeed())
Expect(fm.GetField("phase")).To(Equal(value))
},
Entry("todo", "todo"),
Entry("planning", "planning"),
Entry("execution", "execution"),
Entry("done", "done"),
)

It("rejects invalid value with named error", func() {
fm := domain.NewGoalFrontmatter(nil)
err := fm.SetField(ctx, "phase", "bogus")
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(ContainSubstring("unknown goal phase"))
Expect(err.Error()).To(ContainSubstring("bogus"))
Expect(fm.GetField("phase")).To(Equal(""))
})

It("rejects legacy alias on explicit re-set", func() {
fm := domain.NewGoalFrontmatter(nil)
err := fm.SetField(ctx, "phase", "in_progress")
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(ContainSubstring("unknown goal phase"))
})

It("clears phase on empty value", func() {
fm := domain.NewGoalFrontmatter(map[string]any{"phase": "execution"})
Expect(fm.SetField(ctx, "phase", "")).To(Succeed())
Expect(fm.GetField("phase")).To(Equal(""))
})
})

Describe("GetField phase", func() {
It("returns value when present", func() {
fm := domain.NewGoalFrontmatter(map[string]any{"phase": "planning"})
Expect(fm.GetField("phase")).To(Equal("planning"))
})

It("returns empty when absent", func() {
Expect(domain.NewGoalFrontmatter(nil).GetField("phase")).To(Equal(""))
})
})

Describe("legacy goal round-trip", func() {
It("does not inject phase on unrelated mutation", func() {
fm := domain.NewGoalFrontmatter(map[string]any{"status": "active", "theme": "x"})
Expect(fm.GetField("phase")).To(Equal(""))
Expect(fm.SetField(ctx, "theme", "y")).To(Succeed())
Expect(fm.GetField("phase")).To(Equal(""))
Expect(fm.Keys()).NotTo(ContainElement("phase"))
})
})

Describe("DeferDate", func() {
It("returns nil for missing key", func() {
Expect(fm.DeferDate()).To(BeNil())
Expand Down
61 changes: 61 additions & 0 deletions pkg/domain/goal_phase.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2025 Benjamin Borbe All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package domain

import (
"context"

"github.com/bborbe/collection"
"github.com/bborbe/errors"
"github.com/bborbe/validation"
)

// GoalPhase represents a phase in a goal's lifecycle.
type GoalPhase string

const (
// GoalPhaseTodo means the goal is ready to start but needs planning.
GoalPhaseTodo GoalPhase = "todo"
// GoalPhasePlanning means the approach is being designed.
GoalPhasePlanning GoalPhase = "planning"
// GoalPhaseExecution means active work is underway.
GoalPhaseExecution GoalPhase = "execution"
// GoalPhaseDone means the goal is ready to close.
GoalPhaseDone GoalPhase = "done"
)

// AvailableGoalPhases lists all valid canonical goal phase values.
var AvailableGoalPhases = GoalPhases{
GoalPhaseTodo,
GoalPhasePlanning,
GoalPhaseExecution,
GoalPhaseDone,
}

// GoalPhases is a collection of GoalPhase values.
type GoalPhases []GoalPhase

// Contains returns true if the collection contains the given phase.
func (g GoalPhases) Contains(phase GoalPhase) bool {
return collection.Contains(g, phase)
}

// String returns the string representation of the phase.
func (g GoalPhase) String() string {
return string(g)
}

// Validate returns an error if the phase is not a valid canonical value.
func (g GoalPhase) Validate(ctx context.Context) error {
if !AvailableGoalPhases.Contains(g) {
return errors.Wrapf(ctx, validation.Error, "unknown goal phase '%s'", g)
}
return nil
}

// Ptr returns a pointer to a copy of the phase.
func (g GoalPhase) Ptr() *GoalPhase {
return &g
}
97 changes: 97 additions & 0 deletions pkg/domain/goal_phase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2025 Benjamin Borbe All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package domain_test

import (
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/bborbe/vault-cli/pkg/domain"
)

var _ = Describe("GoalPhase", func() {
var ctx context.Context

BeforeEach(func() {
ctx = context.Background()
})

Describe("Validate", func() {
DescribeTable("valid phases",
func(phase domain.GoalPhase) {
Expect(phase.Validate(ctx)).To(BeNil())
},
Entry("todo", domain.GoalPhaseTodo),
Entry("planning", domain.GoalPhasePlanning),
Entry("execution", domain.GoalPhaseExecution),
Entry("done", domain.GoalPhaseDone),
)

Context("invalid phase", func() {
It("returns an error for unknown phase", func() {
phase := domain.GoalPhase("bogus")
err := phase.Validate(ctx)
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(ContainSubstring("unknown goal phase"))
})

It("returns an error for empty phase", func() {
phase := domain.GoalPhase("")
err := phase.Validate(ctx)
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(ContainSubstring("unknown goal phase"))
})

It("returns an error for task-only phase in_progress", func() {
phase := domain.GoalPhase("in_progress")
err := phase.Validate(ctx)
Expect(err).NotTo(BeNil())
Expect(err.Error()).To(ContainSubstring("unknown goal phase"))
})
})
})

Describe("String", func() {
It("returns the string value", func() {
Expect(domain.GoalPhaseExecution.String()).To(Equal("execution"))
Expect(domain.GoalPhasePlanning.String()).To(Equal("planning"))
Expect(domain.GoalPhaseTodo.String()).To(Equal("todo"))
Expect(domain.GoalPhaseDone.String()).To(Equal("done"))
})
})

Describe("Ptr", func() {
It("returns a non-nil pointer with the correct value", func() {
ptr := domain.GoalPhaseExecution.Ptr()
Expect(ptr).NotTo(BeNil())
Expect(*ptr).To(Equal(domain.GoalPhaseExecution))
})

It("returns independent copies", func() {
p1 := domain.GoalPhaseTodo.Ptr()
p2 := domain.GoalPhaseTodo.Ptr()
Expect(p1).NotTo(BeIdenticalTo(p2))
})
})

Describe("AvailableGoalPhases.Contains", func() {
It("returns true for valid phases", func() {
Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhaseTodo)).To(BeTrue())
Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhasePlanning)).To(BeTrue())
Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhaseExecution)).To(BeTrue())
Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhaseDone)).To(BeTrue())
})

It("returns false for invalid phases", func() {
Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhase("invalid"))).To(BeFalse())
Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhase(""))).To(BeFalse())
Expect(
domain.AvailableGoalPhases.Contains(domain.GoalPhase("in_progress")),
).To(BeFalse())
})
})
})
Loading
Loading