diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..db79a7f63c5c979edcd5f9e8e91398ff294c7a65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Binary files +/planning-mcp-server +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Crush database files +.crush/ + +# macOS +.DS_Store + +# Build artifacts +/dist/ +/bin/ + +# Log files +*.log diff --git a/AGENTS.md b/AGENTS.md index 5ab0b9d57d448599e38baaf6a36f40adbfd8e738..67167fb7857028e450f0f5c4b634b45e637c2215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,11 @@ The project requires license headers (SPDX format) on all source files and uses ### Core Components **MCP Server Architecture**: The server follows a clean layered architecture: + - `cmd/planning-mcp-server/main.go`: CLI entry point with Cobra, supports both STDIO and HTTP modes - `internal/mcp/server.go`: MCP protocol wrapper that bridges MCP calls to planning operations - `internal/planning/manager.go`: Core business logic with thread-safe in-memory storage +- `internal/planning/types.go`: Task and Goal type definitions with status management - `internal/config/`: Configuration management with Viper, supports TOML files and env vars ### Planning System Design @@ -43,25 +45,30 @@ The project requires license headers (SPDX format) on all source files and uses **Thread Safety**: The planning manager uses `sync.RWMutex` for concurrent access. All public methods properly lock/unlock. **Status System**: Tasks use emoji indicators with specific meanings: + - `☐` pending -- `⟳` in_progress +- `⟳` in_progress - `☑` completed - `☒` failed +- `⊗` cancelled -**Task List Legend**: The `get_tasks()` method includes a legend showing status indicators. The legend format is "Legend: ☐ pending ⟳ in progress ☑ completed" and only includes the failed icon (☒) if there are actually failed tasks in the current list. +**Task List Legend**: The `project_management__get_tasks()` method includes a legend showing status indicators. The legend format is "Legend: ☐ pending ⟳ in progress ☑ completed" and only includes the cancelled icon (⊗) and failed icon (☒) if there are actually cancelled or failed tasks in the current list. ### MCP Tool Implementation -The server exposes five MCP tools that map directly to planning manager methods: -- `update_goal(goal: string)`: Sets overarching goal with length validation -- `add_tasks(tasks: []TaskInput)`: Batch task creation with duplicate detection. Encourages breaking tasks down into smallest units of work and regular progress tracking. Output behavior depends on existing tasks: shows verbose instructions + task list when no tasks existed previously, shows brief task list (like `get_tasks`) when tasks already existed. -- `get_tasks()`: Returns markdown-formatted task list with legend and sorted by creation time. Should be called frequently to stay organized. -- `update_tasks(tasks: []TaskUpdate)`: Updates status of one or more tasks and returns full list. Helps maintain planning workflow by tracking progress. -- `delete_tasks(task_ids: []string)`: Deletes one or more tasks by their IDs and returns the resulting task list. Validates all task IDs exist before deleting any. +The server exposes six MCP tools that map directly to planning manager methods: + +- `project_management__set_goal(title: string, description: string)`: Sets initial goal with title and description (both required). Returns error if goal already exists. +- `project_management__change_goal(title: string, description: string, reason: string)`: Changes existing goal (all parameters required). Only used when operator explicitly requests clearing/changing the goal. +- `project_management__add_tasks(tasks: []TaskInput)`: Batch task creation with duplicate detection. Each task requires `title` (required) and `description` (optional). Encourages breaking tasks down into smallest units of work and regular progress tracking. Output behavior depends on existing tasks: shows verbose instructions + task list when no tasks existed previously, shows brief task list (like `project_management__get_tasks`) when tasks already existed. +- `project_management__get_tasks(status: string)`: Returns markdown-formatted task list with optional status filter (all, pending, in_progress, completed, cancelled, failed). Default is "all". Should be called frequently to stay organized. +- `project_management__update_task_statuses(tasks: []TaskUpdate)`: Updates status of one or more tasks and returns full list. Never cancels tasks autonomously - marks as failed on errors and asks operator for guidance. +- `project_management__delete_tasks(task_ids: []string)`: Deletes one or more tasks by their IDs. Only used when operator explicitly requests clearing the board. Otherwise, tasks should be marked as cancelled/failed. Returns the resulting task list. ### Configuration System Uses a three-tier config system (defaults → file → environment variables): + - Server mode: `stdio` (default) or `http` - Planning limits: max tasks (100), max goal length (1000), max task length (500) - Environment variables prefixed with `PLANNING_` (e.g., `PLANNING_SERVER_MODE`) @@ -78,11 +85,15 @@ Uses a three-tier config system (defaults → file → environment variables): ### Testing Approach -The project structure suggests unit testing at the package level. When adding tests: -- Test planning manager methods for concurrent access -- Mock MCP requests for server handler testing -- Test configuration loading and validation edge cases -- Verify task ID generation is deterministic +Testing is planned for future development. Priority areas for testing include: + +- Planning manager methods for concurrent access +- MCP request handling with mocked requests +- Configuration loading and validation edge cases +- Deterministic task ID generation +- Session management and persistence (when implemented) + +Note: Test files (`*_test.go`) will be added in future releases to ensure robust coverage of core functionality. ### Key Dependencies @@ -93,10 +104,10 @@ The project structure suggests unit testing at the package level. When adding te ### Important Constraints -**Stateless Design**: No persistent storage - all data is in-memory. This is intentional for the planning use case. +**Current Storage Model**: In-memory storage only - all data is lost on server restart. Session management and persistence are planned for future releases to enable task continuity across sessions. **Deterministic IDs**: Task IDs must remain consistent. Never change the ID generation algorithm without migration strategy. **MCP Compliance**: All tool responses must follow MCP schema. Responses include both success messages and full task lists where appropriate. -**SPDX Licensing**: All new files require SPDX headers. Use `SPDX-FileCopyrightText: Amolith ` and `SPDX-License-Identifier: AGPL-3.0-or-later` for source files. \ No newline at end of file +**SPDX Licensing**: All new files require SPDX headers. Use `SPDX-FileCopyrightText: Amolith ` and `SPDX-License-Identifier: AGPL-3.0-or-later` for source files. diff --git a/README.md b/README.md index a2372f98ce3ef5c4442d2683d2da68441dfcef8e..de2f9e113f0edb10b9ae38ff685229a0f31a908c 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,22 @@ A Model Context Protocol (MCP) server that provides planning tools for LLMs to t This MCP server enables your AI assistant to: -- **Set clear goals** for complex tasks and keep them visible throughout the work -- **Break down work** into manageable, trackable tasks with progress indicators -- **Stay organized** with persistent task lists that survive across conversations +- **Set clear goals** for complex tasks and keep them visible throughout the session +- **Break down work** into manageable, trackable tasks with progress indicators +- **Stay organized** with structured task lists during the current session - **Track progress** with visual status indicators (☐ pending, ⟳ in progress, ☑ completed, ☒ failed) - **Adapt plans** by adding tasks, updating status, and pivoting when needed ## Core Capabilities -The server provides five essential planning tools: +The server provides six essential planning tools: -- **`update_goal`**: Set or update the overarching goal for your planning session -- **`add_tasks`**: Add one or more tasks to work on. Break tasks down into the smallest units of work possible and track progress. -- **`get_tasks`**: Get current task list with status indicators and legend. Call frequently to stay organized. -- **`update_tasks`**: Update the status of one or more tasks. Maintain your planning workflow by updating statuses regularly. -- **`delete_tasks`**: Delete one or more tasks by their IDs and return the updated task list. +- **`project_management__set_goal`**: Set the initial goal for your planning session (title and description required) +- **`project_management__change_goal`**: Change an existing goal with a required reason for the change +- **`project_management__add_tasks`**: Add one or more tasks to work on. Break tasks down into the smallest units of work possible and track progress. +- **`project_management__get_tasks`**: Get task list with optional status filtering and indicators. Call frequently to stay organized. +- **`project_management__update_task_statuses`**: Update the status of one or more tasks. Maintain your planning workflow by updating statuses regularly. +- **`project_management__delete_tasks`**: Delete one or more tasks by their IDs and return the updated task list. ## Installation @@ -91,7 +92,7 @@ For web-based integrations: ### Setting Goals -Your AI assistant can establish clear objectives that stay visible throughout the planning session, helping maintain focus on the overall purpose. +Your AI assistant can establish clear objectives that stay visible throughout the current planning session, helping maintain focus on the overall purpose. ### Planning Tasks @@ -103,8 +104,8 @@ When you first add tasks to an empty planning session, the tool provides guidanc When adding tasks to an existing planning session, the tool keeps things brief: -- Adds your tasks seamlessly -- Shows your updated task list (same format as `get_tasks`) +- Adds your tasks seamlessly +- Shows your updated task list (same format as `project_management__get_tasks`) - No repetitive instructions - just your updated plan ### Tracking Progress @@ -129,19 +130,24 @@ As work progresses, your AI assistant can update task statuses and immediately s ## Task Status Indicators - ☐ **pending**: Task is ready to be worked on -- ⟳ **in_progress**: Task is currently being worked on +- ⟳ **in_progress**: Task is currently being worked on - ☑ **completed**: Task has been finished successfully - ☒ **failed**: Task encountered an error or failed +- ⊗ **cancelled**: Task was cancelled and won't be completed -The task list includes a legend showing the status indicators. The failed icon (☒) is only shown in the legend if there are actually failed tasks in the list. +The task list includes a legend showing the status indicators. The failed icon (☒) and cancelled icon (⊗) are only shown in the legend if there are actually failed or cancelled tasks in the list. ## Task IDs Task IDs are deterministically generated based on the task title and description using SHA-256 hashing (8 hex characters). This ensures: -- Same task content always gets the same ID +- Same task content always gets the same ID within a session - No collisions for different tasks -- Consistent references across sessions +- Consistent references during the current session + +## Data Storage + +**Important**: This server currently uses in-memory storage only. All goals and tasks are lost when the server restarts. Session management and persistence are planned for future releases. ## License @@ -149,4 +155,4 @@ AGPL-3.0-or-later ## Author -Amolith \ No newline at end of file +Amolith diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b83aa12fac4908a7fb5f6a01f0fdef9d4638ef6f..1d144253f9b8aba74b9c7a445e958558d1f9e2a7 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -58,19 +58,41 @@ func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*S // registerTools registers all planning tools func (s *Server) registerTools(mcpServer *server.MCPServer) { - // Register update_goal tool - updateGoalTool := mcp.NewTool("update_goal", - mcp.WithDescription("Set or update the overarching goal for your planning session"), - mcp.WithString("goal", + // Register project_management__set_goal tool + setGoalTool := mcp.NewTool("project_management__set_goal", + mcp.WithDescription("Set the initial project goal. Returns error if already set and encourages calling project_management__change_goal"), + mcp.WithString("title", mcp.Required(), - mcp.Description("The goal text to set"), + mcp.Description("The goal title"), + ), + mcp.WithString("description", + mcp.Required(), + mcp.Description("The goal description"), ), ) - mcpServer.AddTool(updateGoalTool, s.handleUpdateGoal) + mcpServer.AddTool(setGoalTool, s.handleSetGoal) - // Register add_tasks tool - addTasksTool := mcp.NewTool("add_tasks", - mcp.WithDescription("Add one or more tasks to work on. Break tasks down into the smallest units of work possible. If there are more than one, use me to keep track of where you are in fulfilling the user's request. Call get_tasks often to stay on track."), + // Register project_management__change_goal tool + changeGoalTool := mcp.NewTool("project_management__change_goal", + mcp.WithDescription("Change an existing project goal. Only use if the operator explicitly requests clearing the board/list/goal and doing something else"), + mcp.WithString("title", + mcp.Required(), + mcp.Description("The new goal title"), + ), + mcp.WithString("description", + mcp.Required(), + mcp.Description("The new goal description"), + ), + mcp.WithString("reason", + mcp.Required(), + mcp.Description("The reason for changing the goal"), + ), + ) + mcpServer.AddTool(changeGoalTool, s.handleChangeGoal) + + // Register project_management__add_tasks tool + addTasksTool := mcp.NewTool("project_management__add_tasks", + mcp.WithDescription("Add one or more tasks to work on. Break tasks down into the smallest units of work possible. If there are more than one, use me to keep track of where you are in fulfilling the user's request. Call project_management__get_tasks often to stay on track."), mcp.WithArray("tasks", mcp.Required(), mcp.Description("Array of tasks to add"), @@ -92,15 +114,18 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) { ) mcpServer.AddTool(addTasksTool, s.handleAddTasks) - // Register get_tasks tool - getTasksTool := mcp.NewTool("get_tasks", - mcp.WithDescription("Get current task list with status indicators. Call this frequently to stay organized and track your progress."), + // Register project_management__get_tasks tool + getTasksTool := mcp.NewTool("project_management__get_tasks", + mcp.WithDescription("Get task list with status indicators. Call this frequently to stay organized and track your progress. Prefer to call with 'all' or 'pending', only 'completed' if unsure, only 'cancelled' or 'failed' if the operator explicitly asks."), + mcp.WithString("status", + mcp.Description("Filter tasks by status: all, pending, in_progress, completed, cancelled, or failed (default: all)"), + ), ) mcpServer.AddTool(getTasksTool, s.handleGetTasks) - // Register update_tasks tool - updateTasksTool := mcp.NewTool("update_tasks", - mcp.WithDescription("Update the status of one or more tasks. Maintain your planning workflow by regularly updating task statuses as you make progress."), + // Register project_management__update_task_statuses tool + updateTasksTool := mcp.NewTool("project_management__update_task_statuses", + mcp.WithDescription("Update the status of one or more tasks. Never cancel tasks on your own. If something doesn't work or there's an error, mark it failed and decide whether to ask the operator for guidance or continue on your own. Usually prefer to ask the operator."), mcp.WithArray("tasks", mcp.Required(), mcp.Description("Array of task updates"), @@ -113,18 +138,18 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) { }, "status": map[string]any{ "type": "string", - "description": "New status: pending, in_progress, completed, or failed", + "description": "New status: pending, in_progress, completed, cancelled, or failed", }, }, "required": []string{"task_id", "status"}, }), ), ) - mcpServer.AddTool(updateTasksTool, s.handleUpdateTasks) + mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses) - // Register delete_tasks tool - deleteTasksTool := mcp.NewTool("delete_tasks", - mcp.WithDescription("Delete one or more tasks by their IDs. After deletion, respond with the resulting task list."), + // Register project_management__delete_tasks tool + deleteTasksTool := mcp.NewTool("project_management__delete_tasks", + mcp.WithDescription("Delete one or more tasks by their IDs. Only use if the operator explicitly requests clearing the board/list/goal and doing something else. Otherwise, update statuses to 'cancelled', 'failed', etc. as appropriate. After deletion, respond with the resulting task list."), mcp.WithArray("task_ids", mcp.Required(), mcp.Description("Array of task IDs to delete"), @@ -136,40 +161,57 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) { mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks) } -// handleUpdateGoal handles the update_goal tool call -func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.logger.Info("Received update_goal tool call") +// handleSetGoal handles the project_management__set_goal tool call +func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.logger.Info("Received project_management__set_goal tool call") // Extract parameters arguments := request.GetArguments() - goal, ok := arguments["goal"].(string) - if !ok || goal == "" { + title, ok := arguments["title"].(string) + if !ok || title == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: title parameter is required and must be a string", + }, + }, + IsError: true, + }, nil + } + + description, ok := arguments["description"].(string) + if !ok { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: "Error: goal parameter is required and must be a string", + Text: "Error: description parameter is required and must be a string", }, }, IsError: true, }, nil } - // Update goal - if err := s.planner.UpdateGoal(goal); err != nil { - s.logger.Error("Failed to update goal", "error", err) + // Set goal + if err := s.planner.SetGoal(title, description); err != nil { + s.logger.Error("Failed to set goal", "error", err) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: fmt.Sprintf("Error updating goal: %v", err), + Text: fmt.Sprintf("Error setting goal: %v", err), }, }, IsError: true, }, nil } - response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goal) + goalText := title + if description != "" { + goalText = fmt.Sprintf("%s: %s", title, description) + } + response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ @@ -180,9 +222,83 @@ func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolReque }, nil } -// handleAddTasks handles the add_tasks tool call +// handleChangeGoal handles the project_management__change_goal tool call +func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.logger.Info("Received project_management__change_goal tool call") + + // Extract parameters + arguments := request.GetArguments() + title, ok := arguments["title"].(string) + if !ok || title == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: title parameter is required and must be a string", + }, + }, + IsError: true, + }, nil + } + + description, ok := arguments["description"].(string) + if !ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: description parameter is required and must be a string", + }, + }, + IsError: true, + }, nil + } + + reason, ok := arguments["reason"].(string) + if !ok || reason == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: reason parameter is required and must be a string", + }, + }, + IsError: true, + }, nil + } + + // Change goal + if err := s.planner.ChangeGoal(title, description, reason); err != nil { + s.logger.Error("Failed to change goal", "error", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error changing goal: %v", err), + }, + }, + IsError: true, + }, nil + } + + goalText := title + if description != "" { + goalText = fmt.Sprintf("%s: %s", title, description) + } + response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, reason) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: response, + }, + }, + }, nil +} + +// handleAddTasks handles the project_management__add_tasks tool call func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.logger.Info("Received add_tasks tool call") + s.logger.Info("Received project_management__add_tasks tool call") // Extract parameters arguments := request.GetArguments() @@ -276,7 +392,7 @@ func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest if goal != nil { goalText = fmt.Sprintf("\"%s\"", goal.Text) } - response = fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready, and call `get_tasks` frequently to remind yourself where you are in the process. Reminder that your overarching goal is %s.\n\n%s", goalText, taskList) + response = fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready, and call `project_management__get_tasks` frequently to remind yourself where you are in the process. Reminder that your overarching goal is %s.\n\n%s", goalText, taskList) } else { // Had existing tasks - just show the task list (like get_tasks) response = taskList @@ -291,11 +407,23 @@ func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest }, nil } -// handleGetTasks handles the get_tasks tool call +// handleGetTasks handles the project_management__get_tasks tool call func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.logger.Info("Received get_tasks tool call") + s.logger.Info("Received project_management__get_tasks tool call") - taskList := s.planner.GetTasks() + // Extract parameters + arguments := request.GetArguments() + statusFilter, _ := arguments["status"].(string) + if statusFilter == "" { + statusFilter = "all" + } + + var taskList string + if statusFilter == "all" { + taskList = s.planner.GetTasks() + } else { + taskList = s.planner.GetTasksByStatus(statusFilter) + } return &mcp.CallToolResult{ Content: []mcp.Content{ @@ -307,9 +435,9 @@ func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest }, nil } -// handleUpdateTasks handles the update_tasks tool call -func (s *Server) handleUpdateTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.logger.Info("Received update_tasks tool call") +// handleUpdateTaskStatuses handles the project_management__update_task_statuses tool call +func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.logger.Info("Received project_management__update_task_statuses tool call") // Extract parameters arguments := request.GetArguments() @@ -429,9 +557,9 @@ func (s *Server) handleUpdateTasks(ctx context.Context, request mcp.CallToolRequ }, nil } -// handleDeleteTasks handles the delete_tasks tool call +// handleDeleteTasks handles the project_management__delete_tasks tool call func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.logger.Info("Received delete_tasks tool call") + s.logger.Info("Received project_management__delete_tasks tool call") // Extract parameters arguments := request.GetArguments() diff --git a/internal/planning/manager.go b/internal/planning/manager.go index 13ab903b04c578ed0a1b8242a7607f3d14cc8bd6..5ba14f8869d245a56125b7fcc6dc7df13623c704 100644 --- a/internal/planning/manager.go +++ b/internal/planning/manager.go @@ -34,7 +34,7 @@ func New(cfg *config.Config, logger *slog.Logger) *Manager { } } -// UpdateGoal sets or updates the overarching goal +// UpdateGoal sets or updates the overarching goal (kept for compatibility) func (m *Manager) UpdateGoal(goalText string) error { if len(goalText) > m.config.Planning.MaxGoalLength { return fmt.Errorf("goal too long (max %d characters)", m.config.Planning.MaxGoalLength) @@ -52,6 +52,70 @@ func (m *Manager) UpdateGoal(goalText string) error { return nil } +// SetGoal sets the initial goal (returns error if already set) +func (m *Manager) SetGoal(title, description string) error { + if len(title) > m.config.Planning.MaxGoalLength { + return fmt.Errorf("goal title too long (max %d characters)", m.config.Planning.MaxGoalLength) + } + if len(description) > m.config.Planning.MaxGoalLength { + return fmt.Errorf("goal description too long (max %d characters)", m.config.Planning.MaxGoalLength) + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.goal != nil && m.goal.Text != "" { + return fmt.Errorf("goal already set; use change_goal to update it") + } + + goalText := title + if description != "" { + goalText = fmt.Sprintf("%s: %s", title, description) + } + + m.goal = &Goal{ + Text: strings.TrimSpace(goalText), + UpdatedAt: time.Now(), + } + + m.logger.Info("Goal set", "title", title, "description", description) + return nil +} + +// ChangeGoal changes an existing goal (requires a reason) +func (m *Manager) ChangeGoal(title, description, reason string) error { + if len(title) > m.config.Planning.MaxGoalLength { + return fmt.Errorf("goal title too long (max %d characters)", m.config.Planning.MaxGoalLength) + } + if len(description) > m.config.Planning.MaxGoalLength { + return fmt.Errorf("goal description too long (max %d characters)", m.config.Planning.MaxGoalLength) + } + if reason == "" { + return fmt.Errorf("reason is required when changing the goal") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.goal == nil || m.goal.Text == "" { + return fmt.Errorf("no goal set; use set_goal to create an initial goal") + } + + goalText := title + if description != "" { + goalText = fmt.Sprintf("%s: %s", title, description) + } + + oldGoal := m.goal.Text + m.goal = &Goal{ + Text: strings.TrimSpace(goalText), + UpdatedAt: time.Now(), + } + + m.logger.Info("Goal changed", "old", oldGoal, "new", goalText, "reason", reason) + return nil +} + // AddTasksResult contains the result of adding tasks type AddTasksResult struct { AddedTasks []*Task @@ -107,9 +171,36 @@ func (m *Manager) AddTasks(tasks []TaskInput) (*AddTasksResult, error) { }, nil } -// formatTaskList returns a markdown-formatted list of tasks +// formatTaskList returns a markdown-formatted list of all tasks // This method assumes the mutex is already locked func (m *Manager) formatTaskList() string { + return m.formatTaskListWithFilter(nil) +} + +// GetTasks returns a markdown-formatted list of all tasks +func (m *Manager) GetTasks() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.formatTaskList() +} + +// GetTasksByStatus returns a markdown-formatted list of tasks filtered by status +func (m *Manager) GetTasksByStatus(statusFilter string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + // Parse the filter + var filterStatus *TaskStatus + if statusFilter != "" && statusFilter != "all" { + status := ParseStatus(statusFilter) + filterStatus = &status + } + + return m.formatTaskListWithFilter(filterStatus) +} + +// formatTaskListWithFilter returns a markdown-formatted list of tasks with optional status filter +func (m *Manager) formatTaskListWithFilter(filterStatus *TaskStatus) string { var lines []string // Add goal if it exists @@ -118,32 +209,60 @@ func (m *Manager) formatTaskList() string { lines = append(lines, "") } - if len(m.tasks) == 0 { - if len(lines) > 0 { + // Filter tasks if needed + filteredTasks := make(map[string]*Task) + for id, task := range m.tasks { + if filterStatus == nil || task.Status == *filterStatus { + filteredTasks[id] = task + } + } + + if len(filteredTasks) == 0 { + if filterStatus != nil { + statusName := "pending" + switch *filterStatus { + case StatusInProgress: + statusName = "in progress" + case StatusCompleted: + statusName = "completed" + case StatusFailed: + statusName = "failed" + case StatusCancelled: + statusName = "cancelled" + } + lines = append(lines, fmt.Sprintf("No %s tasks.", statusName)) + } else if len(lines) > 0 { // We have a goal but no tasks lines = append(lines, "No tasks defined yet.") - return strings.Join(lines, "\n") + } else { + lines = append(lines, "No tasks defined yet.") } - return "No tasks defined yet." + return strings.Join(lines, "\n") } // Sort tasks by creation time for consistent output - taskList := make([]*Task, 0, len(m.tasks)) - for _, task := range m.tasks { + taskList := make([]*Task, 0, len(filteredTasks)) + for _, task := range filteredTasks { taskList = append(taskList, task) } - // Check if there are any failed tasks + // Check if there are any failed or cancelled tasks hasFailed := false + hasCancelled := false for _, task := range taskList { if task.Status == StatusFailed { hasFailed = true - break + } + if task.Status == StatusCancelled { + hasCancelled = true } } // Add legend legend := "Legend: ☐ pending ⟳ in progress ☑ completed" + if hasCancelled { + legend += " ⊗ cancelled" + } if hasFailed { legend += " ☒ failed" } @@ -169,13 +288,6 @@ func (m *Manager) formatTaskList() string { return strings.Join(lines, "\n") } -// GetTasks returns a markdown-formatted list of tasks -func (m *Manager) GetTasks() string { - m.mu.RLock() - defer m.mu.RUnlock() - return m.formatTaskList() -} - // UpdateTaskStatus updates the status of a specific task func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error { m.mu.Lock() diff --git a/internal/planning/types.go b/internal/planning/types.go index b564e1b37a5b6a4af97c426ef3ec6baa823629a3..d18fff97207cfa1b6d33405050ecbe6622611221 100644 --- a/internal/planning/types.go +++ b/internal/planning/types.go @@ -19,6 +19,7 @@ const ( StatusInProgress StatusCompleted StatusFailed + StatusCancelled ) // String returns the emoji representation of the task status @@ -32,6 +33,8 @@ func (s TaskStatus) String() string { return "☑" case StatusFailed: return "☒" + case StatusCancelled: + return "⊗" default: return "☐" } @@ -48,6 +51,8 @@ func ParseStatus(status string) TaskStatus { return StatusCompleted case "failed": return StatusFailed + case "cancelled": + return StatusCancelled default: return StatusPending }