feat(cmd/g): implement goal commands

Amolith and Crush created

Goal commands form a cohesive feature set for managing session goals.

- g: show goal with full plan
- g s: set goal (requires title and description)
- g u: update goal (requires reason and at least one change)
- All commands print plan and provide contextual next steps

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

Change summary

cmd/g/g.go |  33 +++++++++++--
cmd/g/s.go |  88 +++++++++++++++++++++++++++++++++++-
cmd/g/u.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 247 insertions(+), 8 deletions(-)

Detailed changes

cmd/g/g.go 🔗

@@ -7,6 +7,7 @@ package g
 import (
 	"fmt"
 
+	"git.secluded.site/np/cmd/shared"
 	"github.com/spf13/cobra"
 )
 
@@ -14,9 +15,31 @@ var GCmd = &cobra.Command{
 	Use:   "g",
 	Short: "Goal commands",
 	Long:  `Manage the session goal`,
-	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("[STUB] Display goal and description")
-		fmt.Println("Goal: Example goal title")
-		fmt.Println("Description: Example goal description")
-	},
+	RunE:  runShowGoal,
+}
+
+func runShowGoal(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
+	}
+
+	state, err := shared.PrintPlan(cmd, env, sessionDoc.SID)
+	if err != nil {
+		return err
+	}
+
+	if state.Goal == nil {
+		fmt.Fprintln(cmd.OutOrStdout(), "")
+		fmt.Fprintln(cmd.OutOrStdout(), "Set the goal with `np g s -t \"goal title\" -d \"goal description\"` to begin.")
+	}
+	return nil
 }

cmd/g/s.go 🔗

@@ -6,7 +6,12 @@ package g
 
 import (
 	"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/goal"
 	"github.com/spf13/cobra"
 )
 
@@ -14,9 +19,7 @@ var sCmd = &cobra.Command{
 	Use:   "s",
 	Short: "Set goal",
 	Long:  `Set the session goal with title and description`,
-	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("[STUB] Set session goal with title and description")
-	},
+	RunE:  runSetGoal,
 }
 
 func init() {
@@ -27,3 +30,82 @@ func init() {
 	_ = sCmd.MarkFlagRequired("title")
 	_ = sCmd.MarkFlagRequired("description")
 }
+
+func runSetGoal(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
+	}
+
+	title, err := cmd.Flags().GetString("title")
+	if err != nil {
+		return err
+	}
+	description, err := cmd.Flags().GetString("description")
+	if err != nil {
+		return err
+	}
+
+	title = strings.TrimSpace(title)
+	description = strings.TrimSpace(description)
+
+	if title == "" {
+		fmt.Fprintln(cmd.OutOrStdout(), "Goal title is required.")
+		return nil
+	}
+	if description == "" {
+		fmt.Fprintln(cmd.OutOrStdout(), "Goal description is required.")
+		return nil
+	}
+
+	exists, err := env.GoalStore.Exists(cmd.Context(), sessionDoc.SID)
+	if err != nil {
+		return err
+	}
+	if exists {
+		fmt.Fprintln(cmd.OutOrStdout(), "Goal already set. Use 'np g u' to update it (requires -r/--reason flag).")
+		return nil
+	}
+
+	var saved goal.Document
+	err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
+		goalTxn := env.GoalStore.WithTxn(txn)
+		var err error
+		saved, err = goalTxn.Set(sessionDoc.SID, title, description)
+		if err != nil {
+			return err
+		}
+
+		sessionTxn := env.SessionStore.WithTxn(txn)
+		if _, err := sessionTxn.TouchAt(sessionDoc.SID, saved.UpdatedAt); err != nil {
+			return err
+		}
+
+		eventTxn := env.EventStore.WithTxn(txn)
+		_, err = eventTxn.Append(sessionDoc.SID, event.BuildGoalSet(shared.CommandString(), "", saved))
+		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, "")
+	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.")
+
+	return nil
+}

cmd/g/u.go 🔗

@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package g
+
+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/goal"
+	"github.com/spf13/cobra"
+)
+
+var uCmd = &cobra.Command{
+	Use:   "u",
+	Short: "Update goal",
+	Long:  `Update the goal title or description (requires a reason)`,
+	RunE:  runUpdateGoal,
+}
+
+func init() {
+	GCmd.AddCommand(uCmd)
+
+	uCmd.Flags().StringP("title", "t", "", "New goal title")
+	uCmd.Flags().StringP("description", "d", "", "New goal description")
+	uCmd.Flags().StringP("reason", "r", "", "Reason for updating the goal (required)")
+	_ = uCmd.MarkFlagRequired("reason")
+}
+
+func runUpdateGoal(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
+	}
+
+	current, err := env.GoalStore.Get(cmd.Context(), sessionDoc.SID)
+	if err != nil {
+		if errors.Is(err, goal.ErrNotFound) {
+			fmt.Fprintln(cmd.OutOrStdout(), "No goal set yet. Use 'np g s' first.")
+			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
+	}
+	reason, err := cmd.Flags().GetString("reason")
+	if err != nil {
+		return err
+	}
+
+	reason = strings.TrimSpace(reason)
+	if reason == "" {
+		fmt.Fprintln(cmd.OutOrStdout(), "Reason is required for goal updates.")
+		return nil
+	}
+
+	newTitle := current.Title
+	if cmd.Flags().Changed("title") {
+		newTitle = strings.TrimSpace(titleInput)
+		if newTitle == "" {
+			fmt.Fprintln(cmd.OutOrStdout(), "Goal title cannot be empty.")
+			return nil
+		}
+	}
+
+	newDescription := current.Description
+	if cmd.Flags().Changed("description") {
+		newDescription = strings.TrimSpace(descInput)
+	}
+
+	if !cmd.Flags().Changed("title") && !cmd.Flags().Changed("description") {
+		fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title or --description to update the goal.")
+		return nil
+	}
+
+	if newTitle == current.Title && newDescription == current.Description {
+		fmt.Fprintln(cmd.OutOrStdout(), "Goal already matches the provided values; no changes made.")
+		return nil
+	}
+
+	var updated goal.Document
+	err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
+		goalTxn := env.GoalStore.WithTxn(txn)
+
+		var err error
+		updated, err = goalTxn.Set(sessionDoc.SID, newTitle, newDescription)
+		if err != nil {
+			return err
+		}
+
+		sessionTxn := env.SessionStore.WithTxn(txn)
+		if _, err := sessionTxn.TouchAt(sessionDoc.SID, updated.UpdatedAt); err != nil {
+			return err
+		}
+
+		eventTxn := env.EventStore.WithTxn(txn)
+		_, err = eventTxn.Append(sessionDoc.SID, event.BuildGoalUpdated(shared.CommandString(), reason, current, updated))
+		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, "")
+	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.")
+
+	return nil
+}