feat(cmd/t): implement task commands

Amolith and Crush created

Task commands form a cohesive feature set for managing session tasks.

- t: list tasks with optional status filter
- t a: add multiple tasks (validates title/description pairs)
- t u: update task status, title, or description (requires reason for
  content changes and cancel/fail)
- All commands print plan and provide contextual next steps

Co-authored-by: Crush <crush@charm.land>

Change summary

cmd/t/a.go | 140 ++++++++++++++++++++++++++++++++++++++++++++
cmd/t/t.go |  61 +++++++++++++++++--
cmd/t/u.go | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 356 insertions(+), 17 deletions(-)

Detailed changes

cmd/t/a.go 🔗

@@ -5,8 +5,14 @@
 package t
 
 import (
+	"errors"
 	"fmt"
+	"strings"
 
+	"git.secluded.site/np/cmd/shared"
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/event"
+	"git.secluded.site/np/internal/task"
 	"github.com/spf13/cobra"
 )
 
@@ -14,9 +20,7 @@ var aCmd = &cobra.Command{
 	Use:   "a",
 	Short: "Add tasks",
 	Long:  `Add one or more tasks to the session`,
-	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("[STUB] Add tasks to current session")
-	},
+	RunE:  runAddTasks,
 }
 
 func init() {
@@ -27,3 +31,133 @@ func init() {
 	_ = aCmd.MarkFlagRequired("title")
 	_ = aCmd.MarkFlagRequired("description")
 }
+
+func runAddTasks(cmd *cobra.Command, _ []string) error {
+	env, err := shared.Environment(cmd)
+	if err != nil {
+		return err
+	}
+
+	sessionDoc, found, err := shared.ActiveSession(cmd, env)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return nil
+	}
+
+	titles, err := cmd.Flags().GetStringArray("title")
+	if err != nil {
+		return err
+	}
+	descriptions, err := cmd.Flags().GetStringArray("description")
+	if err != nil {
+		return err
+	}
+
+	if len(titles) != len(descriptions) {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Number of titles and descriptions must match.")
+		return nil
+	}
+	if len(titles) == 0 {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task with -t title -d description.")
+		return nil
+	}
+
+	type taskInput struct {
+		title       string
+		description string
+		id          string
+	}
+
+	var inputs []taskInput
+	for i := range titles {
+		title := strings.TrimSpace(titles[i])
+		description := strings.TrimSpace(descriptions[i])
+
+		if title == "" {
+			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task title at position %d cannot be empty.\n", i+1)
+			return nil
+		}
+
+		id := task.GenerateID(sessionDoc.SID, title, description)
+		inputs = append(inputs, taskInput{
+			title:       title,
+			description: description,
+			id:          id,
+		})
+	}
+
+	var lastUpdated task.Task
+	var addedCount int
+
+	err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
+		taskTxn := env.TaskStore.WithTxn(txn)
+		eventTxn := env.EventStore.WithTxn(txn)
+		sessionTxn := env.SessionStore.WithTxn(txn)
+
+		for _, input := range inputs {
+			exists, err := taskTxn.Exists(sessionDoc.SID, input.id)
+			if err != nil {
+				return err
+			}
+			if exists {
+				continue
+			}
+
+			seq, err := eventTxn.LatestSequence(sessionDoc.SID)
+			if err != nil {
+				return err
+			}
+			nextSeq := seq + 1
+
+			created, err := taskTxn.Create(sessionDoc.SID, task.CreateParams{
+				ID:          input.id,
+				Title:       input.title,
+				Description: input.description,
+				Status:      task.StatusPending,
+				CreatedSeq:  nextSeq,
+			})
+			if err != nil {
+				return err
+			}
+
+			_, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskAdded(shared.CommandString(), "", created))
+			if err != nil {
+				return err
+			}
+
+			lastUpdated = created
+			addedCount++
+		}
+
+		if addedCount > 0 {
+			_, err := sessionTxn.TouchAt(sessionDoc.SID, lastUpdated.UpdatedAt)
+			if err != nil {
+				return err
+			}
+		}
+
+		return nil
+	})
+	if err != nil {
+		if errors.Is(err, task.ErrEmptyTitle) {
+			_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.")
+			return nil
+		}
+		return err
+	}
+
+	if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil {
+		return err
+	}
+
+	if addedCount > 0 {
+		out := cmd.OutOrStdout()
+		_, _ = fmt.Fprintln(out, "")
+		_, _ = fmt.Fprintf(out, "Added %d task(s).\n", addedCount)
+		_, _ = fmt.Fprintln(out, "Update task statuses with `np t u -i task-id -s in_progress|completed|failed|cancelled` as you work.")
+	}
+
+	return nil
+}

cmd/t/t.go 🔗

@@ -7,6 +7,9 @@ package t
 import (
 	"fmt"
 
+	"git.secluded.site/np/cmd/shared"
+	"git.secluded.site/np/internal/cli"
+	"git.secluded.site/np/internal/task"
 	"github.com/spf13/cobra"
 )
 
@@ -14,16 +17,58 @@ var TCmd = &cobra.Command{
 	Use:   "t",
 	Short: "Task commands",
 	Long:  `Manage session tasks`,
-	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("[STUB] Display remaining tasks and descriptions")
-		fmt.Println("Legend: ☐ pending  ⟳ in progress  ☑ completed")
-		fmt.Println("☐ Example pending task [a1b2c3d4]")
-		fmt.Println("  Example task description")
-		fmt.Println("⟳ Example in-progress task [e5f6g7h8]")
-		fmt.Println("  Another task description")
-	},
+	RunE:  runListTasks,
 }
 
 func init() {
 	TCmd.Flags().StringP("status", "s", "all", "Filter tasks by status (pending, in_progress, completed, all, etc.)")
 }
+
+func runListTasks(cmd *cobra.Command, _ []string) error {
+	env, err := shared.Environment(cmd)
+	if err != nil {
+		return err
+	}
+
+	sessionDoc, found, err := shared.ActiveSession(cmd, env)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return nil
+	}
+
+	statusFlag, err := cmd.Flags().GetString("status")
+	if err != nil {
+		return err
+	}
+
+	var statusFilter task.Status
+	if statusFlag != "" && statusFlag != "all" {
+		statusFilter, err = task.ParseStatus(statusFlag)
+		if err != nil {
+			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled, all.\n", statusFlag)
+			return nil
+		}
+	}
+
+	tasks, err := env.LoadTasksByStatus(cmd.Context(), sessionDoc.SID, statusFilter)
+	if err != nil {
+		return err
+	}
+
+	goalDoc, ok, err := env.LoadGoal(cmd.Context(), sessionDoc.SID)
+	if err != nil {
+		return err
+	}
+
+	state := cli.PlanState{
+		Tasks: tasks,
+	}
+	if ok {
+		state.Goal = &goalDoc
+	}
+
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), cli.RenderPlan(state))
+	return nil
+}

cmd/t/u.go 🔗

@@ -5,25 +5,185 @@
 package t
 
 import (
+	"errors"
 	"fmt"
+	"strings"
 
+	"git.secluded.site/np/cmd/shared"
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/event"
+	"git.secluded.site/np/internal/task"
 	"github.com/spf13/cobra"
 )
 
 var uCmd = &cobra.Command{
 	Use:   "u",
 	Short: "Update task",
-	Long:  `Update a task's status`,
-	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("[STUB] Update task status")
-	},
+	Long:  `Update a task's status, title, or description`,
+	RunE:  runUpdateTask,
 }
 
 func init() {
 	TCmd.AddCommand(uCmd)
 
 	uCmd.Flags().StringP("id", "i", "", "Task ID (required)")
-	uCmd.Flags().StringP("status", "s", "", "Task status (required)")
+	uCmd.Flags().StringP("status", "s", "", "New task status")
+	uCmd.Flags().StringP("title", "t", "", "New task title")
+	uCmd.Flags().StringP("description", "d", "", "New task description")
+	uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)")
 	_ = uCmd.MarkFlagRequired("id")
-	_ = uCmd.MarkFlagRequired("status")
+}
+
+func runUpdateTask(cmd *cobra.Command, _ []string) error {
+	env, err := shared.Environment(cmd)
+	if err != nil {
+		return err
+	}
+
+	sessionDoc, found, err := shared.ActiveSession(cmd, env)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return nil
+	}
+
+	taskID, err := cmd.Flags().GetString("id")
+	if err != nil {
+		return err
+	}
+	taskID = strings.TrimSpace(taskID)
+
+	current, err := env.TaskStore.Get(cmd.Context(), sessionDoc.SID, taskID)
+	if err != nil {
+		if errors.Is(err, task.ErrNotFound) {
+			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
+			return nil
+		}
+		return err
+	}
+
+	titleInput, err := cmd.Flags().GetString("title")
+	if err != nil {
+		return err
+	}
+	descInput, err := cmd.Flags().GetString("description")
+	if err != nil {
+		return err
+	}
+	statusInput, err := cmd.Flags().GetString("status")
+	if err != nil {
+		return err
+	}
+	reasonInput, err := cmd.Flags().GetString("reason")
+	if err != nil {
+		return err
+	}
+
+	newTitle := current.Title
+	titleChanged := cmd.Flags().Changed("title")
+	if titleChanged {
+		newTitle = strings.TrimSpace(titleInput)
+		if newTitle == "" {
+			_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.")
+			return nil
+		}
+	}
+
+	newDescription := current.Description
+	descriptionChanged := cmd.Flags().Changed("description")
+	if descriptionChanged {
+		newDescription = strings.TrimSpace(descInput)
+	}
+
+	var newStatus task.Status
+	statusChanged := cmd.Flags().Changed("status")
+	if statusChanged {
+		newStatus, err = task.ParseStatus(statusInput)
+		if err != nil {
+			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusInput)
+			return nil
+		}
+	} else {
+		newStatus = current.Status
+	}
+
+	if !titleChanged && !descriptionChanged && !statusChanged {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.")
+		return nil
+	}
+
+	reason := strings.TrimSpace(reasonInput)
+	reasonRequired := titleChanged || descriptionChanged ||
+		(statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
+
+	if reasonRequired && reason == "" {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed.")
+		return nil
+	}
+
+	contentChanged := titleChanged || descriptionChanged
+	statusOnlyChanged := statusChanged && !contentChanged
+
+	var updated task.Task
+	err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
+		taskTxn := env.TaskStore.WithTxn(txn)
+		eventTxn := env.EventStore.WithTxn(txn)
+		sessionTxn := env.SessionStore.WithTxn(txn)
+
+		before, err := taskTxn.Get(sessionDoc.SID, taskID)
+		if err != nil {
+			return err
+		}
+
+		updated, err = taskTxn.Update(sessionDoc.SID, taskID, func(t *task.Task) error {
+			if titleChanged {
+				t.Title = newTitle
+			}
+			if descriptionChanged {
+				t.Description = newDescription
+			}
+			if statusChanged {
+				t.Status = newStatus
+			}
+			return nil
+		})
+		if err != nil {
+			return err
+		}
+
+		if contentChanged {
+			_, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
+			if err != nil {
+				return err
+			}
+		}
+
+		if statusChanged {
+			_, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
+			if err != nil {
+				return err
+			}
+		}
+
+		_, err = sessionTxn.TouchAt(sessionDoc.SID, updated.UpdatedAt)
+		return err
+	})
+	if err != nil {
+		return err
+	}
+
+	if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil {
+		return err
+	}
+
+	out := cmd.OutOrStdout()
+	_, _ = fmt.Fprintln(out, "")
+	if statusOnlyChanged && newStatus == task.StatusCompleted {
+		_, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks with `np t u`.")
+	} else {
+		_, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")
+	}
+
+	return nil
 }