diff --git a/cmd/root.go b/cmd/root.go index a837216..50f06c3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,6 +65,7 @@ func init() { rootCmd.AddCommand(scaffoldCmd) rootCmd.AddCommand(queryCmd) rootCmd.AddCommand(analyzeCmd) + rootCmd.AddCommand(workonCmd) // Add model flag to all commands rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)") diff --git a/cmd/workon.go b/cmd/workon.go new file mode 100644 index 0000000..ed230f4 --- /dev/null +++ b/cmd/workon.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "gmgauthier.com/grokkit/internal/logger" + "gmgauthier.com/grokkit/internal/workon" +) + +var workonCmd = &cobra.Command{ + Use: "workon ", + Short: "Start or complete work on a todo item or fix", + Long: `workon automates starting or completing a todo/fix: +- Moves queued todo to doing/ or creates new fix .md +- Creates matching git branch +- Generates + appends "Work Plan" via Grok +- Commits to the branch +- Optional: cnadd log + open in configured IDE + +Purely transactional. See todo/doing/workon.md for full spec.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + title := strings.ReplaceAll(args[0], " ", "-") + customMsg, _ := cmd.Flags().GetString("message") + isFix, _ := cmd.Flags().GetBool("fix") + isComplete, _ := cmd.Flags().GetBool("complete") + + if isComplete && customMsg != "" { + return fmt.Errorf("-c cannot be combined with -m") + } + + logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete) + + if err := workon.Run(title, customMsg, isFix, isComplete); err != nil { + color.Red("workon failed: %v", err) + return err + } + + mode := "todo" + if isFix { + mode = "fix" + } + action := "started" + if isComplete { + action = "completed" + } + color.Green("workon %s: %s (%s)", action, title, mode) + return nil + }, +} + +func init() { + workonCmd.Flags().StringP("message", "M", "", "Custom commit message (default: \"Start working on \")") + workonCmd.Flags().BoolP("fix", "f", false, "Treat as fix instead of todo (create new .md in doing/)") + workonCmd.Flags().BoolP("complete", "c", false, "Complete the item (move to completed/; exclusive)") +} diff --git a/config.toml.example b/config.toml.example index 1929a0c..71c0087 100644 --- a/config.toml.example +++ b/config.toml.example @@ -23,6 +23,8 @@ fast = "grok-4-1-fast-non-reasoning" history.model = "grok-4" prdescribe.model = "grok-4" review.model = "grok-4" + workon.model = "grok-4-1-fast-non-reasoning" # Fast model for work plan generation + workon.ide = "" # IDE command to open repo (e.g. "code", "goland") # Chat history settings [chat] diff --git a/config/config.go b/config/config.go index 36816a9..68df4b0 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,8 @@ func Load() { viper.SetDefault("commands.review.model", "grok-4") viper.SetDefault("commands.docs.model", "grok-4") viper.SetDefault("commands.query.model", "grok-4-1-fast-non-reasoning") + viper.SetDefault("commands.workon.model", "grok-4-1-fast-non-reasoning") + viper.SetDefault("commands.workon.ide", "") // Config file is optional, so we ignore read errors _ = viper.ReadInConfig() @@ -72,6 +74,11 @@ func GetTimeout() int { return timeout } +// GetIDECommand returns the configured IDE command for workon, or empty string if unset. +func GetIDECommand() string { + return viper.GetString("commands.workon.ide") +} + // GetLogLevel returns the log level from the configuration func GetLogLevel() string { return viper.GetString("log_level") diff --git a/internal/todo/todo.go b/internal/todo/todo.go new file mode 100644 index 0000000..417a0e8 --- /dev/null +++ b/internal/todo/todo.go @@ -0,0 +1,28 @@ +package todo + +import ( + "os" + "path/filepath" +) + +const todoRoot = "todo" + +// Bootstrap creates the todo folder structure + basic index if missing. +func Bootstrap() error { + dirs := []string{ + filepath.Join(todoRoot, "queued"), + filepath.Join(todoRoot, "doing"), + filepath.Join(todoRoot, "completed"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return err + } + } + // Basic index README stub + index := filepath.Join(todoRoot, "README.md") + if _, err := os.Stat(index); os.IsNotExist(err) { + return os.WriteFile(index, []byte("# Todo Index\n\n## Queued\n## Doing\n## Completed\n"), 0644) + } + return nil +} diff --git a/internal/workon/workon.go b/internal/workon/workon.go new file mode 100644 index 0000000..2d23362 --- /dev/null +++ b/internal/workon/workon.go @@ -0,0 +1,222 @@ +package workon + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gmgauthier.com/grokkit/config" + "gmgauthier.com/grokkit/internal/grok" + "gmgauthier.com/grokkit/internal/logger" + "gmgauthier.com/grokkit/internal/todo" +) + +// Run executes the full transactional workon flow per todo/doing/workon.md spec. +func Run(title, customMsg string, isFix, isComplete bool) error { + if title == "" { + return fmt.Errorf("todo_item_title is required") + } + + logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete) + + // 1. Bootstrap todo structure if missing + if err := todo.Bootstrap(); err != nil { + return fmt.Errorf("todo bootstrap failed: %w", err) + } + + if isComplete { + return completeItem(title, isFix) + } + + // 2. Handle todo or fix mode + branchPrefix := "feature/" + if isFix { + branchPrefix = "fix/" + } + branchName := branchPrefix + title + mdPath := filepath.Join("todo", "doing", title+".md") + + if isFix { + if err := createFixFile(mdPath, title); err != nil { + return fmt.Errorf("create fix file failed: %w", err) + } + } else { + if err := moveQueuedToDoing(title); err != nil { + return fmt.Errorf("move to doing failed: %w", err) + } + } + + // 3. Create git branch (safe) + if err := createGitBranch(branchName); err != nil { + return fmt.Errorf("failed to create branch %s: %w", branchName, err) + } + + // 4. Generate + append Work Plan via Grok + if err := appendWorkPlan(mdPath, title); err != nil { + return fmt.Errorf("failed to generate/append Work Plan: %w", err) + } + + // 5. Commit + commitMsg := customMsg + if commitMsg == "" { + commitMsg = fmt.Sprintf("Start working on %s", title) + } + if err := commitChanges(commitMsg); err != nil { + return fmt.Errorf("commit failed: %w", err) + } + + // 6. Optional post-steps (graceful) + runCnaddIfAvailable(title) + openIDEIfConfigured() + + logger.Info("workon transaction complete", "branch", branchName, "mode", map[bool]string{true: "fix", false: "todo"}[isFix]) + return nil +} + +func moveQueuedToDoing(title string) error { + src := filepath.Join("todo", "queued", title+".md") + dst := filepath.Join("todo", "doing", title+".md") + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("move %s -> doing failed: %w", title, err) + } + return nil +} + +func createFixFile(path, title string) error { + content := fmt.Sprintf("# %s\n\n## Work Plan\n\n", title) + return os.WriteFile(path, []byte(content), 0644) +} + +func createGitBranch(name string) error { + // Safe: if branch already exists, just checkout it; else create new + if err := exec.Command("git", "checkout", name).Run(); err == nil { + return nil + } + return exec.Command("git", "checkout", "-b", name).Run() +} + +func appendWorkPlan(path, title string) error { + content := readFileContent(path) + + prompt := fmt.Sprintf(`You are helping implement a todo item titled "%s". + +Here is the current markdown content of the todo/fix file: + +%s + +Generate a concise, actionable **Work Plan** section. +Use numbered steps. Be specific to this item. Include testing and commit notes where relevant. +Output ONLY the markdown starting with "## Work Plan" — no extra text, no introduction.`, title, content) + + // Real Grok call using the project's standard client (StreamSilent for clean output) + client := grok.NewClient() + model := config.GetModel("workon", "") + plan := client.StreamSilent([]map[string]string{ + {"role": "system", "content": "You are a precise software engineering assistant."}, + {"role": "user", "content": prompt}, + }, model) // or pull model from config/env if available + + // Append the plan + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer func() { + if cerr := f.Close(); cerr != nil { + logger.Error("failed to close todo file", "err", cerr) + } + }() + _, err = f.WriteString(plan) + return err +} + +func readFileContent(path string) string { + b, _ := os.ReadFile(path) + return string(b) +} + +func commitChanges(msg string) error { + if err := exec.Command("git", "add", "todo/").Run(); err != nil { + return err + } + return exec.Command("git", "commit", "-m", msg).Run() +} + +func completeItem(title string, isFix bool) error { + src := filepath.Join("todo", "doing", title+".md") + dst := filepath.Join("todo", "completed", title+".md") + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("move to completed failed: %w", err) + } + + if !isFix { + if err := updateReadmeIndex(title); err != nil { + logger.Error("failed to update README index", "err", err) + } + } + + commitMsg := fmt.Sprintf("Complete work on %s", title) + if err := commitChanges(commitMsg); err != nil { + return fmt.Errorf("complete commit failed: %w", err) + } + return nil +} + +func updateReadmeIndex(title string) error { + readmePath := filepath.Join("todo", "README.md") + data, err := os.ReadFile(readmePath) + if err != nil { + return fmt.Errorf("read README: %w", err) + } + + lines := strings.Split(string(data), "\n") + var result []string + completedIdx := -1 + + for i, line := range lines { + // Skip the line referencing this title in the Queued section + if strings.Contains(line, title+".md") && strings.Contains(line, "queued/") { + continue + } + // Track the last line in the Completed section + if strings.HasPrefix(line, "## Completed") { + completedIdx = i + } + result = append(result, line) + } + + // Find insertion point: after last non-empty line following ## Completed + if completedIdx >= 0 { + insertIdx := completedIdx + 1 + for insertIdx < len(result) && (strings.HasPrefix(result[insertIdx], "*") || strings.TrimSpace(result[insertIdx]) == "") { + if strings.HasPrefix(result[insertIdx], "*") { + insertIdx++ + } else { + break + } + } + entry := fmt.Sprintf("* [%s](./completed/%s.md) : %s *(done)*", title, title, title) + result = append(result[:insertIdx], append([]string{entry}, result[insertIdx:]...)...) + } + + return os.WriteFile(readmePath, []byte(strings.Join(result, "\n")), 0644) +} + +func runCnaddIfAvailable(title string) { + if _, err := exec.LookPath("cnadd"); err == nil { + _ = exec.Command("cnadd", "log", fmt.Sprintf("started work on %s", title)).Run() + } +} + +func openIDEIfConfigured() { + ideCmd := config.GetIDECommand() + if ideCmd == "" { + logger.Debug("IDE open skipped (no IDE configured)") + return + } + if err := exec.Command(ideCmd, ".").Start(); err != nil { + logger.Error("failed to open IDE", "cmd", ideCmd, "err", err) + } +} diff --git a/todo/README.md b/todo/README.md index 2495af8..7ecb6a5 100644 --- a/todo/README.md +++ b/todo/README.md @@ -4,7 +4,7 @@ This document provides a table of contents for all tasks and features currently ## Queued -* [1] [workon](./queued/workon.md): Grokkit workon for bootstrapping a new feature +* [1] [workon](doing/workon.md): Grokkit workon for bootstrapping a new feature * [2] [cnotes.md](./queued/cnotes.md) : grokkit cnotes integration * [3] [make.md](./queued/make.md) : grokkit make integration * [4] [rg.md](./queued/rg.md) : grokkit ripgrep (rg) integration diff --git a/todo/queued/workon.md b/todo/doing/workon.md similarity index 100% rename from todo/queued/workon.md rename to todo/doing/workon.md