feat: add workflow-aware status validation

Amolith created

When creating or updating tasks, status is now validated against the
area's workflow. Error messages include valid options for that workflow:

invalid status for workflow: 'foo' for Kanban; valid: later, next, ...

MCP tools (create/update) validate when area context is available. The
query tool's hardcoded status list is replaced with a dynamic one.

CLI (task add/update) passes resolved area to status validation. When no
area is specified, falls back to listing all valid statuses.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

cmd/task/add.go                   | 39 +++++++++++++++-------
cmd/task/update.go                | 39 +++++++++++++++-------
internal/mcp/shared/errors.go     | 54 +++++++++++++++++++++++++++++++++
internal/mcp/tools/crud/create.go |  7 +++
internal/mcp/tools/crud/query.go  |  2 
internal/mcp/tools/crud/update.go | 27 ++++++++++++---
internal/validate/validate.go     | 37 ++++++++++++++++++++++
7 files changed, 171 insertions(+), 34 deletions(-)

Detailed changes

cmd/task/add.go 🔗

@@ -73,11 +73,12 @@ func runAdd(cmd *cobra.Command, args []string) error {
 
 	builder := apiClient.NewTask(name)
 
-	if err := applyAreaAndGoal(cmd, builder); err != nil {
+	area, err := applyAreaAndGoal(cmd, builder)
+	if err != nil {
 		return err
 	}
 
-	if err := applyOptionalFlags(cmd, builder); err != nil {
+	if err := applyOptionalFlags(cmd, builder, area); err != nil {
 		return err
 	}
 
@@ -110,54 +111,66 @@ func resolveName(arg string) (string, error) {
 	return "", ErrNoInput
 }
 
-func applyAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskBuilder) error {
+//nolint:nilnil // nil area with no error is valid when area flag not provided
+func applyAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskBuilder) (*config.Area, error) {
 	areaKey, _ := cmd.Flags().GetString("area")
 	goalKey, _ := cmd.Flags().GetString("goal")
 
 	if areaKey == "" && goalKey == "" {
-		return nil
+		return nil, nil
 	}
 
 	if areaKey == "" && goalKey != "" {
 		fmt.Fprintln(cmd.ErrOrStderr(), ui.Warning.Render("Goal specified without area; ignoring"))
 
-		return nil
+		return nil, nil
 	}
 
 	cfg, err := config.Load()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	area := cfg.AreaByKey(areaKey)
 	if area == nil {
-		return fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+		return nil, fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
 	}
 
 	builder.InArea(area.ID)
 
 	if goalKey == "" {
-		return nil
+		return area, nil
 	}
 
 	goal := area.GoalByKey(goalKey)
 	if goal == nil {
-		return fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
+		return nil, fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
 	}
 
 	builder.InGoal(goal.ID)
 
-	return nil
+	return area, nil
 }
 
-func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder) error {
+//nolint:cyclop // flag handling inherently has many branches
+func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder, area *config.Area) error {
 	if status, _ := cmd.Flags().GetString("status"); status != "" {
-		s, err := validate.TaskStatus(status)
+		var (
+			parsedStatus lunatask.TaskStatus
+			err          error
+		)
+
+		if area != nil {
+			parsedStatus, err = validate.TaskStatusForWorkflow(status, area.Workflow)
+		} else {
+			parsedStatus, err = validate.TaskStatus(status)
+		}
+
 		if err != nil {
 			return err
 		}
 
-		builder.WithStatus(s)
+		builder.WithStatus(parsedStatus)
 	}
 
 	if note, _ := cmd.Flags().GetString("note"); note != "" {

cmd/task/update.go 🔗

@@ -68,11 +68,12 @@ func runUpdate(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	if err := applyUpdateAreaAndGoal(cmd, builder); err != nil {
+	area, err := applyUpdateAreaAndGoal(cmd, builder)
+	if err != nil {
 		return err
 	}
 
-	if err := applyUpdateFlags(cmd, builder); err != nil {
+	if err := applyUpdateFlags(cmd, builder, area); err != nil {
 		return err
 	}
 
@@ -104,54 +105,66 @@ func applyUpdateName(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) er
 	return nil
 }
 
-func applyUpdateAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) error {
+//nolint:nilnil // nil area with no error is valid when area flag not provided
+func applyUpdateAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) (*config.Area, error) {
 	areaKey, _ := cmd.Flags().GetString("area")
 	goalKey, _ := cmd.Flags().GetString("goal")
 
 	if areaKey == "" && goalKey == "" {
-		return nil
+		return nil, nil
 	}
 
 	if areaKey == "" && goalKey != "" {
 		fmt.Fprintln(cmd.ErrOrStderr(), ui.Warning.Render("Goal specified without area; ignoring"))
 
-		return nil
+		return nil, nil
 	}
 
 	cfg, err := config.Load()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	area := cfg.AreaByKey(areaKey)
 	if area == nil {
-		return fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+		return nil, fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
 	}
 
 	builder.InArea(area.ID)
 
 	if goalKey == "" {
-		return nil
+		return area, nil
 	}
 
 	goal := area.GoalByKey(goalKey)
 	if goal == nil {
-		return fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
+		return nil, fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
 	}
 
 	builder.InGoal(goal.ID)
 
-	return nil
+	return area, nil
 }
 
-func applyUpdateFlags(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) error {
+//nolint:cyclop // flag handling inherently has many branches
+func applyUpdateFlags(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder, area *config.Area) error {
 	if status, _ := cmd.Flags().GetString("status"); status != "" {
-		s, err := validate.TaskStatus(status)
+		var (
+			parsedStatus lunatask.TaskStatus
+			err          error
+		)
+
+		if area != nil {
+			parsedStatus, err = validate.TaskStatusForWorkflow(status, area.Workflow)
+		} else {
+			parsedStatus, err = validate.TaskStatus(status)
+		}
+
 		if err != nil {
 			return err
 		}
 
-		builder.WithStatus(s)
+		builder.WithStatus(parsedStatus)
 	}
 
 	if note, _ := cmd.Flags().GetString("note"); note != "" {

internal/mcp/shared/errors.go 🔗

@@ -7,7 +7,10 @@ package shared
 import (
 	"errors"
 	"fmt"
+	"slices"
+	"strings"
 
+	"git.secluded.site/go-lunatask"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
@@ -32,6 +35,9 @@ const (
 // ErrInvalidEstimate indicates the estimate is out of range.
 var ErrInvalidEstimate = errors.New("estimate must be between 0 and 720 minutes")
 
+// ErrInvalidStatusForWorkflow indicates the status is not valid for the area's workflow.
+var ErrInvalidStatusForWorkflow = errors.New("invalid status for workflow")
+
 // ValidateEstimate checks that an estimate is within the valid range (0-720 minutes).
 func ValidateEstimate(estimate int) error {
 	if estimate < MinEstimate || estimate > MaxEstimate {
@@ -40,3 +46,51 @@ func ValidateEstimate(estimate int) error {
 
 	return nil
 }
+
+// ValidateStatusForWorkflow parses a status string and validates it's allowed for
+// the given workflow. Returns the parsed status and nil on success, or an error
+// with valid options on failure.
+func ValidateStatusForWorkflow(
+	input string,
+	workflow lunatask.Workflow,
+) (lunatask.TaskStatus, error) {
+	status, err := lunatask.ParseTaskStatus(input)
+	if err != nil {
+		return "", fmt.Errorf(
+			"%w: '%s' for %s; valid: %s",
+			ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+		)
+	}
+
+	if !slices.Contains(workflow.ValidStatuses(), status) {
+		return "", fmt.Errorf(
+			"%w: '%s' not valid for %s; valid: %s",
+			ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+		)
+	}
+
+	return status, nil
+}
+
+func formatValidStatuses(workflow lunatask.Workflow) string {
+	statuses := workflow.ValidStatuses()
+	strs := make([]string, len(statuses))
+
+	for i, s := range statuses {
+		strs[i] = string(s)
+	}
+
+	return strings.Join(strs, ", ")
+}
+
+// FormatAllStatuses returns a comma-separated list of all valid task statuses.
+func FormatAllStatuses() string {
+	statuses := lunatask.AllTaskStatuses()
+	strs := make([]string, len(statuses))
+
+	for i, s := range statuses {
+		strs[i] = string(s)
+	}
+
+	return strings.Join(strs, ", ")
+}

internal/mcp/tools/crud/create.go 🔗

@@ -232,7 +232,12 @@ func (h *Handler) parseTaskCreateInput(input CreateInput) (*parsedTaskCreateInpu
 	}
 
 	if input.Status != nil {
-		status, err := lunatask.ParseTaskStatus(*input.Status)
+		area := h.cfg.AreaByID(parsed.AreaID)
+		if area == nil {
+			return nil, shared.ErrorResult("internal error: area not found after validation")
+		}
+
+		status, err := shared.ValidateStatusForWorkflow(*input.Status, area.Workflow)
 		if err != nil {
 			return nil, shared.ErrorResult(err.Error())
 		}

internal/mcp/tools/crud/query.go 🔗

@@ -215,7 +215,7 @@ func (h *Handler) listTasks(
 
 	if input.Status != nil {
 		if _, err := lunatask.ParseTaskStatus(*input.Status); err != nil {
-			return shared.ErrorResult("invalid status: must be later, next, in-progress, waiting, or completed"),
+			return shared.ErrorResult(fmt.Sprintf("invalid status '%s'; valid: %s", *input.Status, shared.FormatAllStatuses())),
 				QueryOutput{Entity: EntityTask}, nil
 		}
 	}

internal/mcp/tools/crud/update.go 🔗

@@ -6,6 +6,7 @@ package crud
 
 import (
 	"context"
+	"fmt"
 
 	"git.secluded.site/go-lunatask"
 	"git.secluded.site/lune/internal/dateutil"
@@ -143,7 +144,7 @@ func (h *Handler) updateTask(
 	}, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil
 }
 
-//nolint:cyclop,funlen
+//nolint:cyclop,funlen,gocognit,nestif
 func (h *Handler) parseTaskUpdateInput(input UpdateInput) (*parsedTaskUpdateInput, *mcp.CallToolResult) {
 	_, id, err := lunatask.ParseReference(input.ID)
 	if err != nil {
@@ -189,12 +190,26 @@ func (h *Handler) parseTaskUpdateInput(input UpdateInput) (*parsedTaskUpdateInpu
 	}
 
 	if input.Status != nil {
-		status, err := lunatask.ParseTaskStatus(*input.Status)
-		if err != nil {
-			return nil, shared.ErrorResult(err.Error())
+		if parsed.AreaID != nil {
+			area := h.cfg.AreaByID(*parsed.AreaID)
+			if area != nil {
+				status, err := shared.ValidateStatusForWorkflow(*input.Status, area.Workflow)
+				if err != nil {
+					return nil, shared.ErrorResult(err.Error())
+				}
+
+				parsed.Status = &status
+			}
+		} else {
+			status, err := lunatask.ParseTaskStatus(*input.Status)
+			if err != nil {
+				return nil, shared.ErrorResult(
+					fmt.Sprintf("invalid status '%s'; valid: %s", *input.Status, shared.FormatAllStatuses()),
+				)
+			}
+
+			parsed.Status = &status
 		}
-
-		parsed.Status = &status
 	}
 
 	if input.Priority != nil {

internal/validate/validate.go 🔗

@@ -8,6 +8,8 @@ package validate
 import (
 	"errors"
 	"fmt"
+	"slices"
+	"strings"
 
 	"git.secluded.site/go-lunatask"
 	"git.secluded.site/lune/internal/config"
@@ -32,6 +34,9 @@ func Reference(input string) (string, error) {
 // ErrInvalidStatus indicates the status value is not recognized.
 var ErrInvalidStatus = errors.New("invalid status")
 
+// ErrInvalidStatusForWorkflow indicates the status is not valid for the area's workflow.
+var ErrInvalidStatusForWorkflow = errors.New("invalid status for workflow")
+
 // TaskStatus validates and normalizes a task status string.
 // Returns the corresponding lunatask.TaskStatus or an error if invalid.
 func TaskStatus(input string) (lunatask.TaskStatus, error) {
@@ -43,6 +48,38 @@ func TaskStatus(input string) (lunatask.TaskStatus, error) {
 	return status, nil
 }
 
+// TaskStatusForWorkflow validates a status string against a specific workflow.
+// Returns the status if valid for the workflow, or an error listing valid options.
+func TaskStatusForWorkflow(input string, workflow lunatask.Workflow) (lunatask.TaskStatus, error) {
+	status, err := lunatask.ParseTaskStatus(input)
+	if err != nil {
+		return "", fmt.Errorf(
+			"%w: '%s' for %s; valid: %s",
+			ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+		)
+	}
+
+	if !slices.Contains(workflow.ValidStatuses(), status) {
+		return "", fmt.Errorf(
+			"%w: '%s' not valid for %s; valid: %s",
+			ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+		)
+	}
+
+	return status, nil
+}
+
+func formatValidStatuses(workflow lunatask.Workflow) string {
+	statuses := workflow.ValidStatuses()
+	strs := make([]string, len(statuses))
+
+	for i, s := range statuses {
+		strs[i] = string(s)
+	}
+
+	return strings.Join(strs, ", ")
+}
+
 // ErrInvalidMotivation indicates the motivation value is not recognized.
 var ErrInvalidMotivation = errors.New("invalid motivation")