diff --git a/cmd/g/s.go b/cmd/g/s.go index bc7fd0fbe4a849d18f33e0225d412371da66b283..9f8aa503ea10a5560f2c0d4ed664d981d4800241 100644 --- a/cmd/g/s.go +++ b/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 } diff --git a/cmd/g/u.go b/cmd/g/u.go index 1d2ad56c73ef3aea2b0673994495360b332712bc..7869e10a4dbede2fdccb0ce905293e3b3506cb45 100644 --- a/cmd/g/u.go +++ b/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 -s -i -s `") return nil } diff --git a/cmd/r.go b/cmd/r.go index 001dc537c72cb917620f065e3605b834aa414312..50e63f6044cd2167b7f7ac2b28349fb16206006f 100644 --- a/cmd/r.go +++ b/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 -s `") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "3. Update task status as you work:") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Single: `np t u -i -s `") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Batch: `np t u -i -s -i -s `") _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Statuses: pending, in_progress, completed, failed, cancelled") // Provide context about pending work diff --git a/cmd/s.go b/cmd/s.go index 94b10c3f12302cd1b7f62a35d80bbb66e2d056a9..76ea0ca811ecf73220d5c58e225632d1a2384bc5 100644 --- a/cmd/s.go +++ b/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.") } diff --git a/cmd/t/a.go b/cmd/t/a.go index e3985309cdeedefd9ee596ff36ab200e36edcf1b..7d784cfc5efd9b6b2beb52b5d1f283e266af59d9 100644 --- a/cmd/t/a.go +++ b/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 diff --git a/cmd/t/u.go b/cmd/t/u.go index d5e98a3a3aa680ed190cc00ec818cb8fe9e5db41..73f86b34b968f27f7a3e41b6b2f86ff1f2d2d9bf 100644 --- a/cmd/t/u.go +++ b/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 -s -i -s ` 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 -s -i -s ` to update multiple at once.") } } else { _, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")