feat(cmd/t): support batch status updates

Amolith and Crush created

Modified cmd/t/u.go to accept StringArray for -i (id) and -s (status)
flags,
enabling batch status updates like:
np t u -i abc123 -s completed -i def456 -s in_progress

This reduces context window usage by printing the plan once instead of
multiple times when updating several tasks.

Updated all help text across cmd/{s,r,g/s,g/u,t/a,t/u}.go to show:
- Multi-task addition examples with multi-line descriptions
- Batch status update patterns (especially completed + in_progress)
- Both single and batch operation examples side-by-side

The implementation validates ID/status count matching, processes updates
in a single transaction, and provides appropriate feedback messages for
both single-task and batch operations.

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

Change summary

cmd/g/s.go |   4 
cmd/g/u.go |   4 
cmd/r.go   |   4 
cmd/s.go   |   8 +
cmd/t/a.go |   4 
cmd/t/u.go | 225 +++++++++++++++++++++++++++++++++++++++++++++++--------
6 files changed, 207 insertions(+), 42 deletions(-)

Detailed changes

cmd/g/s.go 🔗

@@ -105,7 +105,9 @@ func runSetGoal(cmd *cobra.Command, _ []string) error {
 	_, _ = fmt.Fprintln(out, "")
 	_, _ = fmt.Fprintln(out, "Study the goal and its description carefully.")
 	_, _ = fmt.Fprintln(out, "Review referenced tickets and files to gather context before planning changes.")
-	_, _ = fmt.Fprintln(out, "Add tasks with `np t a -t \"task title\" -d \"details\"` once you understand the approach.")
+	_, _ = fmt.Fprintln(out, "Add tasks once you understand the approach:")
+	_, _ = fmt.Fprintln(out, "  Single task:   `np t a -t \"task title\" -d \"details\"`")
+	_, _ = fmt.Fprintln(out, "  Multiple tasks: `np t a -t \"first\" -d \"details\" -t \"second\" -d \"more details\"`")
 
 	return nil
 }

cmd/g/u.go 🔗

@@ -128,7 +128,9 @@ func runUpdateGoal(cmd *cobra.Command, _ []string) error {
 	out := cmd.OutOrStdout()
 	_, _ = fmt.Fprintln(out, "")
 	_, _ = fmt.Fprintln(out, "Goal updated. Ensure pending tasks still align and adjust them if necessary.")
-	_, _ = fmt.Fprintln(out, "Add or update tasks with `np t a` / `np t u` so the plan reflects the new direction.")
+	_, _ = fmt.Fprintln(out, "Add or update tasks to reflect the new direction:")
+	_, _ = fmt.Fprintln(out, "  Add multiple:   `np t a -t \"first\" -d \"details\" -t \"second\" -d \"more\"`")
+	_, _ = fmt.Fprintln(out, "  Batch updates:  `np t u -i <id1> -s <status1> -i <id2> -s <status2>`")
 
 	return nil
 }

cmd/r.go 🔗

@@ -44,7 +44,9 @@ func runResume(cmd *cobra.Command, args []string) error {
 	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nResuming session. To continue:")
 	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "1. Check the goal description above for any bug/issue/ticket ID. If present, read that ticket for full context.")
 	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "2. Read the files and symbols referenced in the pending tasks to understand what work remains.")
-	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "3. Update task status as you work: `np t u -i <task-id> -s <status>`")
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "3. Update task status as you work:")
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "     Single: `np t u -i <task-id> -s <status>`")
+	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "     Batch:  `np t u -i <id1> -s <status1> -i <id2> -s <status2>`")
 	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "   Statuses: pending, in_progress, completed, failed, cancelled")
 
 	// Provide context about pending work

cmd/s.go 🔗

@@ -58,7 +58,11 @@ func printSessionStarted(cmd *cobra.Command, doc session.Document) {
 
 	_, _ = fmt.Fprintln(out, "Set the goal immediately with `np g s -t \"goal title\" -d \"goal description\"`.")
 	_, _ = fmt.Fprintln(out, "Include ticket numbers, file paths, and any other references inside the goal description.")
-	_, _ = fmt.Fprintln(out, "Once the goal is set, read the referenced files and add tasks with `np t a -t \"task\" -d \"details\"`.")
-	_, _ = fmt.Fprintln(out, "Keep task statuses up to date with `np t u -i task-id -s in_progress|completed|failed|cancelled` as you work.")
+	_, _ = fmt.Fprintln(out, "Once the goal is set, read the referenced files and add tasks:")
+	_, _ = fmt.Fprintln(out, "  Single task:   `np t a -t \"task\" -d \"details\"`")
+	_, _ = fmt.Fprintln(out, "  Multiple tasks: `np t a -t \"first task\" -d \"step 1 details\" -t \"second task\" -d \"step 2 with\\nmulti-line description\"`")
+	_, _ = fmt.Fprintln(out, "Keep task statuses up to date as you work:")
+	_, _ = fmt.Fprintln(out, "  Single update:  `np t u -i task-id -s in_progress|completed|failed|cancelled`")
+	_, _ = fmt.Fprintln(out, "  Batch updates:  `np t u -i abc123 -s completed -i def456 -s in_progress`")
 	_, _ = fmt.Fprintln(out, "Use `np p` whenever you need to review the full plan.")
 }

cmd/t/a.go 🔗

@@ -156,7 +156,9 @@ func runAddTasks(cmd *cobra.Command, _ []string) error {
 		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.")
+		_, _ = fmt.Fprintln(out, "Update task statuses as you work:")
+		_, _ = fmt.Fprintln(out, "  Single: `np t u -i task-id -s in_progress|completed|failed|cancelled`")
+		_, _ = fmt.Fprintln(out, "  Batch:  `np t u -i abc123 -s completed -i def456 -s in_progress`")
 	}
 
 	return nil

cmd/t/u.go 🔗

@@ -8,8 +8,10 @@ import (
 	"errors"
 	"fmt"
 	"strings"
+	"time"
 
 	"git.secluded.site/np/cmd/shared"
+	"git.secluded.site/np/internal/cli"
 	"git.secluded.site/np/internal/db"
 	"git.secluded.site/np/internal/event"
 	"git.secluded.site/np/internal/task"
@@ -18,18 +20,18 @@ import (
 
 var uCmd = &cobra.Command{
 	Use:   "u",
-	Short: "Update task",
-	Long:  `Update a task's status, title, or description`,
+	Short: "Update tasks",
+	Long:  `Update one or more tasks' status, title, or description`,
 	RunE:  runUpdateTask,
 }
 
 func init() {
 	TCmd.AddCommand(uCmd)
 
-	uCmd.Flags().StringP("id", "i", "", "Task ID (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().StringArrayP("id", "i", []string{}, "Task ID (required, repeatable for batch updates)")
+	uCmd.Flags().StringArrayP("status", "s", []string{}, "New task status (repeatable, must match number of IDs)")
+	uCmd.Flags().StringP("title", "t", "", "New task title (only for single task updates)")
+	uCmd.Flags().StringP("description", "d", "", "New task description (only for single task updates)")
 	uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)")
 	_ = uCmd.MarkFlagRequired("id")
 }
@@ -48,40 +50,191 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
 		return nil
 	}
 
-	taskID, err := cmd.Flags().GetString("id")
+	taskIDs, err := cmd.Flags().GetStringArray("id")
 	if err != nil {
 		return err
 	}
-	taskID = strings.TrimSpace(taskID)
-
-	current, err := env.TaskStore.Get(cmd.Context(), sessionDoc.SID, taskID)
+	statuses, err := cmd.Flags().GetStringArray("status")
 	if err != nil {
-		if errors.Is(err, task.ErrNotFound) {
-			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
+		return err
+	}
+
+	// Trim and validate IDs
+	var cleanIDs []string
+	for _, id := range taskIDs {
+		cleaned := strings.TrimSpace(id)
+		if cleaned != "" {
+			cleanIDs = append(cleanIDs, cleaned)
+		}
+	}
+	taskIDs = cleanIDs
+
+	if len(taskIDs) == 0 {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task ID with -i/--id.")
+		return nil
+	}
+
+	// Check if this is a batch status update
+	isBatchStatusUpdate := len(statuses) > 0 && len(statuses) == len(taskIDs)
+	isSingleTask := len(taskIDs) == 1
+
+	// Title/description only allowed for single task
+	titleInput, _ := cmd.Flags().GetString("title")
+	descInput, _ := cmd.Flags().GetString("description")
+	reasonInput, _ := cmd.Flags().GetString("reason")
+	titleChanged := cmd.Flags().Changed("title")
+	descChanged := cmd.Flags().Changed("description")
+
+	if (titleChanged || descChanged) && !isSingleTask {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Title and description updates are only supported for single task updates.")
+		return nil
+	}
+
+	// Handle batch status updates
+	if isBatchStatusUpdate && !titleChanged && !descChanged {
+		return runBatchStatusUpdate(cmd, env, sessionDoc.SID, taskIDs, statuses, reasonInput)
+	}
+
+	// Handle single task update (possibly with title/description)
+	if !isSingleTask {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Multiple task IDs provided but status count doesn't match. For batch updates, provide equal -i and -s flags.")
+		return nil
+	}
+
+	return runSingleTaskUpdate(cmd, env, sessionDoc.SID, taskIDs[0], statuses, titleInput, descInput, reasonInput, titleChanged, descChanged)
+}
+
+func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskIDs []string, statuses []string, reasonInput string) error {
+	type updatePair struct {
+		id     string
+		status task.Status
+	}
+
+	// Parse and validate all statuses first
+	var pairs []updatePair
+	for i, taskID := range taskIDs {
+		statusStr := strings.TrimSpace(statuses[i])
+		newStatus, err := task.ParseStatus(statusStr)
+		if err != nil {
+			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q for task %s. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr, taskID)
 			return nil
 		}
-		return err
+		pairs = append(pairs, updatePair{id: taskID, status: newStatus})
+	}
+
+	reason := strings.TrimSpace(reasonInput)
+	// Check if any status requires a reason
+	reasonRequired := false
+	for _, pair := range pairs {
+		if pair.status == task.StatusCancelled || pair.status == task.StatusFailed {
+			reasonRequired = true
+			break
+		}
 	}
 
-	titleInput, err := cmd.Flags().GetString("title")
+	if reasonRequired && reason == "" {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required when changing status to cancelled or failed.")
+		return nil
+	}
+
+	var lastUpdatedAt time.Time
+	var updatedCount 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 _, pair := range pairs {
+			before, err := taskTxn.Get(sid, pair.id)
+			if err != nil {
+				if errors.Is(err, task.ErrNotFound) {
+					_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", pair.id)
+					continue
+				}
+				return err
+			}
+
+			// Skip if status unchanged
+			if before.Status == pair.status {
+				continue
+			}
+
+			updated, err := taskTxn.Update(sid, pair.id, func(t *task.Task) error {
+				t.Status = pair.status
+				return nil
+			})
+			if err != nil {
+				return err
+			}
+
+			_, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
+			if err != nil {
+				return err
+			}
+
+			lastUpdatedAt = updated.UpdatedAt
+			updatedCount++
+		}
+
+		if updatedCount > 0 {
+			_, err := sessionTxn.TouchAt(sid, lastUpdatedAt)
+			return err
+		}
+
+		return nil
+	})
 	if err != nil {
 		return err
 	}
-	descInput, err := cmd.Flags().GetString("description")
+
+	if updatedCount == 0 {
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No tasks were updated (all tasks either not found or already at target status).")
+		return nil
+	}
+
+	if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
+		return err
+	}
+
+	out := cmd.OutOrStdout()
+	_, _ = fmt.Fprintln(out, "")
+	_, _ = fmt.Fprintf(out, "Updated %d task(s).\n", updatedCount)
+
+	// Check if all work is complete
+	allComplete := true
+	pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
 	if err != nil {
 		return err
 	}
-	statusInput, err := cmd.Flags().GetString("status")
+	inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
 	if err != nil {
 		return err
 	}
-	reasonInput, err := cmd.Flags().GetString("reason")
+	if len(pending) > 0 || len(inProgress) > 0 {
+		allComplete = false
+	}
+
+	if allComplete {
+		_, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and obtain confirmation before archiving the session with `np a`.")
+	} else {
+		_, _ = fmt.Fprintln(out, "Continue working through remaining tasks. Use `np t u -i <id1> -s <status1> -i <id2> -s <status2>` to update multiple tasks at once.")
+	}
+
+	return nil
+}
+
+func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskID string, statuses []string, titleInput, descInput, reasonInput string, titleChanged, descChanged bool) error {
+	current, err := env.TaskStore.Get(cmd.Context(), 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
 	}
 
 	newTitle := current.Title
-	titleChanged := cmd.Flags().Changed("title")
 	if titleChanged {
 		newTitle = strings.TrimSpace(titleInput)
 		if newTitle == "" {
@@ -91,30 +244,30 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
 	}
 
 	newDescription := current.Description
-	descriptionChanged := cmd.Flags().Changed("description")
-	if descriptionChanged {
+	if descChanged {
 		newDescription = strings.TrimSpace(descInput)
 	}
 
 	var newStatus task.Status
-	statusChanged := cmd.Flags().Changed("status")
+	statusChanged := len(statuses) > 0
 	if statusChanged {
-		newStatus, err = task.ParseStatus(statusInput)
+		statusStr := strings.TrimSpace(statuses[0])
+		newStatus, err = task.ParseStatus(statusStr)
 		if err != nil {
-			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusInput)
+			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr)
 			return nil
 		}
 	} else {
 		newStatus = current.Status
 	}
 
-	if !titleChanged && !descriptionChanged && !statusChanged {
+	if !titleChanged && !descChanged && !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 ||
+	reasonRequired := titleChanged || descChanged ||
 		(statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
 
 	if reasonRequired && reason == "" {
@@ -122,7 +275,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
 		return nil
 	}
 
-	contentChanged := titleChanged || descriptionChanged
+	contentChanged := titleChanged || descChanged
 	statusOnlyChanged := statusChanged && !contentChanged
 
 	var updated task.Task
@@ -131,16 +284,16 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
 		eventTxn := env.EventStore.WithTxn(txn)
 		sessionTxn := env.SessionStore.WithTxn(txn)
 
-		before, err := taskTxn.Get(sessionDoc.SID, taskID)
+		before, err := taskTxn.Get(sid, taskID)
 		if err != nil {
 			return err
 		}
 
-		updated, err = taskTxn.Update(sessionDoc.SID, taskID, func(t *task.Task) error {
+		updated, err = taskTxn.Update(sid, taskID, func(t *task.Task) error {
 			if titleChanged {
 				t.Title = newTitle
 			}
-			if descriptionChanged {
+			if descChanged {
 				t.Description = newDescription
 			}
 			if statusChanged {
@@ -153,38 +306,38 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
 		}
 
 		if contentChanged {
-			_, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
+			_, err = eventTxn.Append(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))
+			_, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
 			if err != nil {
 				return err
 			}
 		}
 
-		_, err = sessionTxn.TouchAt(sessionDoc.SID, updated.UpdatedAt)
+		_, err = sessionTxn.TouchAt(sid, updated.UpdatedAt)
 		return err
 	})
 	if err != nil {
 		return err
 	}
 
-	if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil {
+	if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
 		return err
 	}
 
 	out := cmd.OutOrStdout()
 	_, _ = fmt.Fprintln(out, "")
 	if statusOnlyChanged && newStatus == task.StatusCompleted {
-		pending, err := env.TaskStore.ListByStatus(cmd.Context(), sessionDoc.SID, task.StatusPending)
+		pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
 		if err != nil {
 			return err
 		}
-		inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sessionDoc.SID, task.StatusInProgress)
+		inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
 		if err != nil {
 			return err
 		}
@@ -192,7 +345,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
 		if len(pending) == 0 && len(inProgress) == 0 {
 			_, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and obtain confirmation before archiving the session with `np a`.")
 		} else {
-			_, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks with `np t u`.")
+			_, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks. Use `np t u -i <id1> -s <status1> -i <id2> -s <status2>` to update multiple at once.")
 		}
 	} else {
 		_, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")