From 42dee048e06c7129854d23ad388c76e3398a5465 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 24 Jan 2026 08:25:57 -0600 Subject: [PATCH] Document external orchestration flow --- README.md | 9 ++ cmd/task/main.go | 353 +++++++++++++++++++++++++++++++++++++++++++ docs/orchestrator.md | 56 +++++++ 3 files changed, 418 insertions(+) create mode 100644 docs/orchestrator.md diff --git a/README.md b/README.md index 8d7d976d..1bed36e5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A personal task management system with a beautiful terminal UI, SQLite storage, - **Real-time Updates** - Watch tasks execute live - **Running Process Indicator** - Green dot (`●`) shows which tasks have active shell processes (servers, watchers, etc.) - **Auto-cleanup** - Automatic cleanup of Claude processes and config entries for completed tasks +- **Automation-Ready CLI** - Every Kanban action is also exposed via the `task` CLI, making it trivial to script or plug in your own orchestrator (see [external orchestration docs](docs/orchestrator.md)) - **SSH Access** - Connect from anywhere via `ssh -p 2222 server` ## Prerequisites @@ -95,6 +96,14 @@ ssh -p 2222 your-server ./bin/task claudes cleanup # Kill orphaned Claude processes ``` +### External orchestration + +Need an always-on supervisor or LLM agent? Keep it outside Task You and use the CLI instead: + +- `task board --json` surfaces the full Kanban snapshot +- `task pin`, `task status`, `task execute`, `task retry`, etc. mirror every interaction from the TUI +- See [docs/orchestrator.md](docs/orchestrator.md) for a step-by-step Claude example + **Auto-cleanup:** The daemon automatically cleans up Claude processes for tasks that have been done for more than 30 minutes, preventing memory bloat from orphaned processes. ## Keyboard Shortcuts diff --git a/cmd/task/main.go b/cmd/task/main.go index 5308020e..9d2ae49a 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -646,6 +646,77 @@ Examples: listCmd.Flags().Bool("pr", false, "Show PR/CI status (requires network)") rootCmd.AddCommand(listCmd) + boardCmd := &cobra.Command{ + Use: "board", + Short: "Show the Kanban board in the CLI", + Long: `Print the same Backlog / Queued / In Progress / Blocked / Done view +that the TUI shows, either as formatted text or JSON for automation.`, + Run: func(cmd *cobra.Command, args []string) { + outputJSON, _ := cmd.Flags().GetBool("json") + limit, _ := cmd.Flags().GetInt("limit") + + if limit <= 0 { + limit = 5 + } + + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + tasks, err := database.ListTasks(db.ListTasksOptions{IncludeClosed: true, Limit: 500}) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + + snapshot := buildBoardSnapshot(tasks, limit) + + if outputJSON { + data, _ := json.MarshalIndent(snapshot, "", " ") + fmt.Println(string(data)) + return + } + + fmt.Println(boldStyle.Render("Kanban Snapshot")) + fmt.Println(strings.Repeat("─", 50)) + for _, column := range snapshot.Columns { + fmt.Printf("%s (%d)\n", column.Label, column.Count) + if column.Count == 0 { + fmt.Println(" (empty)") + fmt.Println() + continue + } + for _, task := range column.Tasks { + line := fmt.Sprintf("- #%d %s", task.ID, task.Title) + if task.Project != "" { + line += fmt.Sprintf(" [%s]", task.Project) + } + if task.Type != "" { + line += fmt.Sprintf(" (%s)", task.Type) + } + if task.Pinned { + line += " 📌" + } + if task.AgeHint != "" { + line += fmt.Sprintf(" • %s", task.AgeHint) + } + fmt.Println(" " + line) + } + if column.Count > len(column.Tasks) { + fmt.Printf(" … +%d more\n", column.Count-len(column.Tasks)) + } + fmt.Println() + } + }, + } + boardCmd.Flags().Bool("json", false, "Output board snapshot as JSON") + boardCmd.Flags().Int("limit", 5, "Maximum entries to show per column") + rootCmd.AddCommand(boardCmd) + // Show subcommand - show task details showCmd := &cobra.Command{ Use: "show ", @@ -1019,6 +1090,112 @@ Examples: } rootCmd.AddCommand(executeCmd) + statusCmd := &cobra.Command{ + Use: "status ", + Short: "Set a task's status", + Long: `Manually update a task's status. Useful for automation/orchestration when +you need to move cards between columns without opening the TUI. + +Valid statuses: backlog, queued, processing, blocked, done, archived.`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + var taskID int64 + if _, err := fmt.Sscanf(args[0], "%d", &taskID); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Invalid task ID: "+args[0])) + os.Exit(1) + } + + status := strings.ToLower(strings.TrimSpace(args[1])) + if !isValidStatus(status) { + fmt.Fprintln(os.Stderr, errorStyle.Render("Invalid status. Must be one of: "+strings.Join(validStatuses(), ", "))) + os.Exit(1) + } + + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + task, err := database.GetTask(taskID) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + if task == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found", taskID))) + os.Exit(1) + } + + if err := database.UpdateTaskStatus(taskID, status); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + + fmt.Println(successStyle.Render(fmt.Sprintf("Task #%d moved to %s", taskID, status))) + }, + } + rootCmd.AddCommand(statusCmd) + + pinCmd := &cobra.Command{ + Use: "pin ", + Short: "Pin, unpin, or toggle a task", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var taskID int64 + if _, err := fmt.Sscanf(args[0], "%d", &taskID); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Invalid task ID: "+args[0])) + os.Exit(1) + } + + unpin, _ := cmd.Flags().GetBool("unpin") + toggle, _ := cmd.Flags().GetBool("toggle") + + dbPath := db.DefaultPath() + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + task, err := database.GetTask(taskID) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + if task == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found", taskID))) + os.Exit(1) + } + + var newValue bool + if toggle { + newValue = !task.Pinned + } else if unpin { + newValue = false + } else { + newValue = true + } + + if err := database.UpdateTaskPinned(taskID, newValue); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + + state := "pinned" + if !newValue { + state = "unpinned" + } + fmt.Println(successStyle.Render(fmt.Sprintf("Task #%d %s", taskID, state))) + }, + } + pinCmd.Flags().Bool("unpin", false, "Unpin the task") + pinCmd.Flags().Bool("toggle", false, "Toggle the current pin state") + rootCmd.AddCommand(pinCmd) + // Close subcommand - mark a task as done closeCmd := &cobra.Command{ Use: "close ", @@ -1501,6 +1678,182 @@ func writePidFile(path string, pid int) error { return os.WriteFile(path, []byte(fmt.Sprintf("%d", pid)), 0644) } +type boardSnapshot struct { + Columns []boardColumn `json:"columns"` +} + +type boardColumn struct { + Status string `json:"status"` + Label string `json:"label"` + Count int `json:"count"` + Tasks []boardEntry `json:"tasks"` +} + +type boardEntry struct { + ID int64 `json:"id"` + Title string `json:"title"` + Project string `json:"project"` + Type string `json:"type"` + Pinned bool `json:"pinned"` + AgeHint string `json:"age_hint"` +} + +func buildBoardSnapshot(tasks []*db.Task, limit int) boardSnapshot { + sections := []struct { + status string + label string + }{ + {db.StatusBacklog, "Backlog"}, + {db.StatusQueued, "Queued"}, + {db.StatusProcessing, "In Progress"}, + {db.StatusBlocked, "Blocked"}, + {db.StatusDone, "Done"}, + } + + grouped := make(map[string][]*db.Task) + for _, task := range tasks { + if task.Status == db.StatusArchived { + continue + } + grouped[task.Status] = append(grouped[task.Status], task) + } + + var snapshot boardSnapshot + for _, section := range sections { + columnTasks := grouped[section.status] + if len(columnTasks) == 0 { + snapshot.Columns = append(snapshot.Columns, boardColumn{Status: section.status, Label: section.label, Count: 0}) + continue + } + + sortTasksForBoard(columnTasks) + column := boardColumn{Status: section.status, Label: section.label, Count: len(columnTasks)} + for i, task := range columnTasks { + if i >= limit { + break + } + entry := boardEntry{ + ID: task.ID, + Title: truncate(task.Title, 80), + Project: task.Project, + Type: task.Type, + Pinned: task.Pinned, + AgeHint: boardAgeHint(task), + } + column.Tasks = append(column.Tasks, entry) + } + snapshot.Columns = append(snapshot.Columns, column) + } + + return snapshot +} + +func sortTasksForBoard(tasks []*db.Task) { + sort.SliceStable(tasks, func(i, j int) bool { + if tasks[i].Pinned != tasks[j].Pinned { + return tasks[i].Pinned + } + return boardReferenceTime(tasks[i]).After(boardReferenceTime(tasks[j])) + }) +} + +func boardReferenceTime(task *db.Task) time.Time { + switch task.Status { + case db.StatusProcessing: + if task.StartedAt != nil { + return task.StartedAt.Time + } + case db.StatusDone: + if task.CompletedAt != nil { + return task.CompletedAt.Time + } + case db.StatusBlocked: + return task.UpdatedAt.Time + case db.StatusQueued: + return task.UpdatedAt.Time + case db.StatusBacklog: + return task.CreatedAt.Time + } + return task.UpdatedAt.Time +} + +func boardAgeHint(task *db.Task) string { + ref := boardReferenceTime(task) + if ref.IsZero() { + return "" + } + + delta := time.Since(ref) + if delta < 0 { + delta = -delta + } + + switch task.Status { + case db.StatusProcessing: + return fmt.Sprintf("running %s ago", formatShortDuration(delta)) + case db.StatusBlocked: + return fmt.Sprintf("blocked %s", formatShortDuration(delta)) + case db.StatusQueued: + return fmt.Sprintf("queued %s", formatShortDuration(delta)) + case db.StatusBacklog: + return fmt.Sprintf("created %s", formatShortDuration(delta)) + case db.StatusDone: + return fmt.Sprintf("done %s", formatShortDuration(delta)) + default: + return formatShortDuration(delta) + } +} + +func formatShortDuration(d time.Duration) string { + if d < time.Second { + return "0s" + } + units := []struct { + dur time.Duration + label string + }{ + {24 * time.Hour, "d"}, + {time.Hour, "h"}, + {time.Minute, "m"}, + {time.Second, "s"}, + } + var parts []string + remainder := d + for _, u := range units { + if remainder >= u.dur { + value := remainder / u.dur + parts = append(parts, fmt.Sprintf("%d%s", value, u.label)) + remainder %= u.dur + } + if len(parts) == 2 { + break + } + } + return strings.Join(parts, " ") +} + +var allowedStatuses = []string{ + db.StatusBacklog, + db.StatusQueued, + db.StatusProcessing, + db.StatusBlocked, + db.StatusDone, + db.StatusArchived, +} + +func validStatuses() []string { + return allowedStatuses +} + +func isValidStatus(status string) bool { + for _, s := range allowedStatuses { + if status == s { + return true + } + } + return false +} + func processExists(pid int) bool { process, err := os.FindProcess(pid) if err != nil { diff --git a/docs/orchestrator.md b/docs/orchestrator.md new file mode 100644 index 00000000..f9cbbb32 --- /dev/null +++ b/docs/orchestrator.md @@ -0,0 +1,56 @@ +# External Orchestration with Task You + +Task You's Kanban UI is great for humans, but every action it performs is also available through the `task` CLI. That means you can run your own always-on supervisor (Claude, Codex, bash scripts, etc.) completely outside the app by shelling into these commands. + +## CLI Coverage for Automation + +| Need | Command(s) | +|------|-----------| +| Snapshot the entire board | `task board` or `task board --json` | +| List/filter cards | `task list [--status ] [--json]` | +| Inspect a task (project, logs, attachments) | `task show --json --logs` | +| Create or edit | `task create`, `task update` | +| Queue/execute or retry | `task execute `, `task retry ` | +| Mark blocked/done/backlog/etc. | `task status ` | +| Pin/unpin priorities | `task pin [--unpin|--toggle]` | +| Close/delete | `task close `, `task delete ` | +| Tail executor output | `task logs` | + +Statuses accepted by `task status` are: `backlog`, `queued`, `processing`, `blocked`, `done`, `archived`. + +## Example: Running Claude as an Orchestrator + +1. **Start the daemon / UI** (locally or via SSH): + ```bash + task -l # launches the TUI + daemon locally + ``` +2. **Open a second tmux pane/window** and launch Claude Code inside your project root: + ```bash + cd ~/Projects/workflow + claude code + ``` +3. **Prime Claude with the following system prompt** (paste at the top of the session): + ```text + You are an autonomous operator that controls Task You via its CLI. + Only interact with tasks by running shell commands that start with "task". + Available tools: + • task board --json # get the full Kanban snapshot + • task list --json --status X # list cards in a column + • task show --json --logs ID # inspect a card deeply + • task execute|retry|close ID # run or finish cards + • task status ID # move cards between columns + • task pin ID [--unpin] # prioritize/deprioritize + Workflow: + 1. Periodically run `task board --json` to understand the queue. + 2. Decide what should happen next and run the appropriate CLI command. + 3. After taking an action, summarize what you changed before continuing. + 4. Ask the human for input when you cannot proceed. + ``` +4. **Let Claude drive**. It will now issue `task …` commands the same way the TUI would, so it can start/retry/close/pin tasks, inspect logs, or re-order the backlog – all while living completely outside Task You. + +### Tips +- Use `task board --json | jq` to feed structured snapshots directly into an LLM or script. +- Combine `task board` with `watch -n30` for a rolling dashboard. +- When scripting, prefer JSON flags (`--json`, `--logs`) so the output is machine-readable. + +With these primitives, you can plug in any agent or automation stack you like—all without adding bespoke orchestrators inside Task You itself.