diff --git a/cmd/hook.go b/cmd/hook.go index d1c839a..dbf73c7 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -227,16 +227,16 @@ func runContinueCheck(maxTasks, maxMinutes int) error { }) } - // Open repository + // Open repository — graceful degradation on failure repo, err := openRepo() if err != nil { if isMissingProjectMemoryError(err) { return outputHookResponse(HookResponse{ - Reason: "No project memory found. Run 'taskwing bootstrap' to initialize project memory.", + Reason: "No project memory found. Run 'taskwing bootstrap' to initialize project memory, or use /tw-next to continue manually.", }) } return outputHookResponse(HookResponse{ - Reason: fmt.Sprintf("Failed to open repository: %v", err), + Reason: fmt.Sprintf("Could not open repository: %v. Use /tw-next to continue manually.", err), }) } defer func() { _ = repo.Close() }() @@ -332,7 +332,7 @@ func runContinueCheck(maxTasks, maxMinutes int) error { blockDecision := "block" return outputHookResponse(HookResponse{ Decision: &blockDecision, - Reason: fmt.Sprintf("Continue to task %d/%d: %s\n\n%s", session.TasksCompleted+1, len(activePlan.Tasks), nextTask.Title, contextStr), + Reason: fmt.Sprintf("Continue to task %d/%d: %s\n\n%s\n\nIf auto-continue fails, use /tw-next to proceed manually.", session.TasksCompleted+1, len(activePlan.Tasks), nextTask.Title, contextStr), }) } diff --git a/cmd/mcp_server.go b/cmd/mcp_server.go index 4ccd40f..c7193b3 100644 --- a/cmd/mcp_server.go +++ b/cmd/mcp_server.go @@ -224,12 +224,14 @@ func runMCPServer(ctx context.Context) error { - current: Get current in-progress task for session - start: Claim a specific task by ID - complete: Mark task as completed with summary +- skip: Skip a task that's irrelevant or overlapping (use summary for reason) REQUIRED FIELDS BY ACTION: -- next: session_id (optional when called via MCP session, required otherwise) -- current: session_id (optional when called via MCP session, required otherwise) -- start: task_id (required), session_id (optional when called via MCP session) -- complete: task_id (required)`, +- next: session_id (auto-inferred from hook session if omitted) +- current: session_id (auto-inferred from hook session if omitted) +- start: task_id (required), session_id (auto-inferred from hook session if omitted) +- complete: task_id (required) +- skip: task_id (required), summary (optional skip reason)`, } mcpsdk.AddTool(server, taskTool, func(ctx context.Context, session *mcpsdk.ServerSession, params *mcpsdk.CallToolParamsFor[mcppresenter.TaskToolParams]) (*mcpsdk.CallToolResultFor[any], error) { defaultSessionID := "" @@ -238,6 +240,13 @@ REQUIRED FIELDS BY ACTION: defaultSessionID = sid } } + // Fallback: infer session_id from hook_session.json when MCP transport + // doesn't provide one (Claude Code stdio never does). + if defaultSessionID == "" { + if hs, hsErr := loadHookSession(); hsErr == nil && hs.SessionID != "" { + defaultSessionID = hs.SessionID + } + } result, err := mcppresenter.HandleTaskTool(ctx, repo, params.Arguments, defaultSessionID) if err != nil { return mcpErrorResponse(err) diff --git a/internal/app/plan.go b/internal/app/plan.go index d6825d1..e6a1a00 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -76,6 +76,7 @@ type GenerateOptions struct { ClarifySessionID string // Required: clarify session that reached ready state EnrichedGoal string // Fully clarified specification Save bool // Whether to persist plan/tasks to DB + ExplicitTasks []task.TaskInput // If provided, use these instead of LLM generation } // AuditResult contains the result of plan auditing. @@ -575,42 +576,69 @@ func (a *PlanApp) Generate(ctx context.Context, opts GenerateOptions) (*Generate } } - // Create and run PlanningAgent - planningAgent := a.PlannerFactory(llmCfg) - defer func() { _ = planningAgent.Close() }() + // If caller provided explicit tasks, use them directly (skip LLM generation) + var tasks []task.Task + if len(opts.ExplicitTasks) > 0 { + for i, et := range opts.ExplicitTasks { + priority := et.Priority + if priority == 0 { + priority = (i + 1) * 10 // auto-assign sequential priority + } + complexity := et.Complexity + if complexity == "" { + complexity = "medium" + } + t := task.Task{ + ID: fmt.Sprintf("task-%s", uuid.New().String()[:8]), + Title: et.Title, + Description: et.Description, + AcceptanceCriteria: et.AcceptanceCriteria, + ValidationSteps: et.ValidationSteps, + Priority: priority, + Complexity: complexity, + Status: task.StatusPending, + } + t.EnrichAIFields() + tasks = append(tasks, t) + } + } else { + // Create and run PlanningAgent + planningAgent := a.PlannerFactory(llmCfg) + defer func() { _ = planningAgent.Close() }() - input := core.Input{ - ExistingContext: map[string]any{ - "goal": opts.Goal, - "enriched_goal": opts.EnrichedGoal, - "context": contextStr, - }, - } + input := core.Input{ + ExistingContext: map[string]any{ + "goal": opts.Goal, + "enriched_goal": opts.EnrichedGoal, + "context": contextStr, + }, + } - output, err := planningAgent.Run(ctx, input) - if err != nil { - return &GenerateResult{ - Success: false, - Message: fmt.Sprintf("Planning agent failed: %v", err), - }, nil - } - if output.Error != nil { - return &GenerateResult{ - Success: false, - Message: fmt.Sprintf("Planning agent error: %v", output.Error), - }, nil - } + output, err := planningAgent.Run(ctx, input) + if err != nil { + return &GenerateResult{ + Success: false, + Message: fmt.Sprintf("Planning agent failed: %v", err), + }, nil + } + if output.Error != nil { + return &GenerateResult{ + Success: false, + Message: fmt.Sprintf("Planning agent error: %v", output.Error), + }, nil + } - // Parse tasks from output - if len(output.Findings) == 0 { - return &GenerateResult{ - Success: false, - Message: "No findings from planning agent", - }, nil - } + // Parse tasks from output + if len(output.Findings) == 0 { + return &GenerateResult{ + Success: false, + Message: "No findings from planning agent", + }, nil + } - finding := output.Findings[0] - tasks := a.parseTasksFromMetadata(ctx, finding.Metadata) + finding := output.Findings[0] + tasks = a.parseTasksFromMetadata(ctx, finding.Metadata) + } if len(tasks) == 0 { return &GenerateResult{ diff --git a/internal/config/prompts.go b/internal/config/prompts.go index ed309f3..c384ee4 100644 --- a/internal/config/prompts.go +++ b/internal/config/prompts.go @@ -525,6 +525,7 @@ Your job is to decompose this goal into a sequential list of actionable executio - If a caching constraint exists, high-volume endpoints MUST implement caching - Never suggest code that violates documented constraints 5. **Verification**: For each task, define clear acceptance criteria and a validation command (e.g., "go test ./..."). +6. **No Overlap**: Each task must be a distinct, non-overlapping unit of work. Do NOT create separate tasks for testing and implementation of the same feature — combine them. If multiple tasks would modify the same files or address the same problem from different angles, merge them into one task. When a caller provides an explicit tasks array, use those tasks directly instead of generating new ones. **Input Context:** - Enriched Goal: {{.Goal}} @@ -560,6 +561,7 @@ Your job is to break this down into 3-5 logical phases that can be reviewed and 3. **Right-Sized**: Each phase should expand into 2-4 detailed tasks (not too granular, not too vague). 4. **Clear Done State**: Each phase should have a clear "done" condition that can be verified. 5. **Context Aware**: Use the provided Knowledge Graph Context to align with existing patterns and constraints. +6. **No Overlap**: Phases must not overlap in scope. Each phase should own a distinct set of files/concerns. Do not split related work (e.g., implementation + testing of the same feature) across phases. **Input Context:** - Enriched Goal: {{.EnrichedGoal}} @@ -606,6 +608,7 @@ Your job is to generate 2-4 atomic tasks that fully accomplish this phase. 3. **Complete Coverage**: The tasks together must fully accomplish the phase's stated goal. 4. **Context Aware**: Use the Knowledge Graph Context to respect existing patterns and constraints. 5. **Verifiable**: Each task must have clear acceptance criteria and validation steps. +6. **No Overlap**: Tasks must not duplicate effort. Do NOT create separate tasks for "write tests" and "implement feature" for the same change — combine them into one task. If two tasks would modify the same files, merge them. Check against tasks already generated for other phases to avoid cross-phase overlap. **Input Context:** - Phase Title: {{.PhaseTitle}} diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 705dff8..8d2f4c7 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -519,7 +519,7 @@ func HandleTaskTool(ctx context.Context, repo *memory.Repository, params TaskToo if !params.Action.IsValid() { return &TaskToolResult{ Action: string(params.Action), - Error: fmt.Sprintf("invalid action %q, must be one of: next, current, start, complete", params.Action), + Error: fmt.Sprintf("invalid action %q, must be one of: next, current, start, complete, skip", params.Action), }, nil } @@ -532,6 +532,8 @@ func HandleTaskTool(ctx context.Context, repo *memory.Repository, params TaskToo return handleTaskStart(ctx, repo, params, defaultSessionID) case TaskActionComplete: return handleTaskComplete(ctx, repo, params) + case TaskActionSkip: + return handleTaskSkip(ctx, repo, params) default: return &TaskToolResult{ Action: string(params.Action), @@ -706,6 +708,43 @@ func handleTaskComplete(ctx context.Context, repo *memory.Repository, params Tas }, nil } +// handleTaskSkip implements the 'skip' action - skip a task that's irrelevant or overlapping. +func handleTaskSkip(_ context.Context, repo *memory.Repository, params TaskToolParams) (*TaskToolResult, error) { + taskID := strings.TrimSpace(params.TaskID) + if taskID == "" { + return &TaskToolResult{ + Action: "skip", + Error: "task_id is required for skip action", + }, nil + } + + reason := strings.TrimSpace(params.Summary) + if reason == "" { + reason = "Skipped by user" + } + + if err := repo.SkipTask(taskID, reason); err != nil { + return &TaskToolResult{ + Action: "skip", + Error: err.Error(), + }, nil + } + + // Fetch the updated task to show confirmation + t, err := repo.GetTask(taskID) + if err != nil { + return &TaskToolResult{ + Action: "skip", + Content: fmt.Sprintf("Task %s skipped. Reason: %s", taskID, reason), + }, nil + } + + return &TaskToolResult{ + Action: "skip", + Content: fmt.Sprintf("## Task Skipped\n\n**%s** (`%s`)\n\n**Reason**: %s\n\nUse `task action=next` to get the next pending task.", t.Title, t.ID, reason), + }, nil +} + // === Plan Tool Handler === // PlanToolResult represents the response from the unified plan tool. @@ -859,6 +898,7 @@ func handlePlanGenerate(ctx context.Context, repo *memory.Repository, params Pla ClarifySessionID: clarifySessionID, EnrichedGoal: enrichedGoal, Save: save, + ExplicitTasks: params.Tasks, }) if err != nil { return &PlanToolResult{ diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 8fe23ca..25cb0d3 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -4,6 +4,8 @@ package mcp import ( "encoding/json" "fmt" + + "github.com/josephgoksu/TaskWing/internal/task" ) // === Action Constants === @@ -42,17 +44,18 @@ const ( TaskActionCurrent TaskAction = "current" TaskActionStart TaskAction = "start" TaskActionComplete TaskAction = "complete" + TaskActionSkip TaskAction = "skip" ) // ValidTaskActions returns all valid task actions. func ValidTaskActions() []TaskAction { - return []TaskAction{TaskActionNext, TaskActionCurrent, TaskActionStart, TaskActionComplete} + return []TaskAction{TaskActionNext, TaskActionCurrent, TaskActionStart, TaskActionComplete, TaskActionSkip} } // IsValid checks if the action is a valid task action. func (a TaskAction) IsValid() bool { switch a { - case TaskActionNext, TaskActionCurrent, TaskActionStart, TaskActionComplete: + case TaskActionNext, TaskActionCurrent, TaskActionStart, TaskActionComplete, TaskActionSkip: return true } return false @@ -248,14 +251,8 @@ type PhaseInput struct { } // TaskInput represents user-provided task data for interactive mode. -type TaskInput struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - AcceptanceCriteria []string `json:"acceptance_criteria,omitempty"` - ValidationSteps []string `json:"validation_steps,omitempty"` - Priority int `json:"priority,omitempty"` - Complexity string `json:"complexity,omitempty"` -} +// TaskInput is an alias for task.TaskInput — shared struct for explicit task definitions. +type TaskInput = task.TaskInput // ClarifyAnswerInput is a structured answer to a clarification question. type ClarifyAnswerInput struct { diff --git a/internal/memory/repository_tasks.go b/internal/memory/repository_tasks.go index 43e60ae..fb71c37 100644 --- a/internal/memory/repository_tasks.go +++ b/internal/memory/repository_tasks.go @@ -83,6 +83,11 @@ func (r *Repository) CompleteTask(taskID, summary string, filesModified []string return r.db.CompleteTask(taskID, summary, filesModified) } +// SkipTask marks a task as skipped with an optional reason. +func (r *Repository) SkipTask(taskID, reason string) error { + return r.db.SkipTask(taskID, reason) +} + // GetActivePlan returns the currently active plan. func (r *Repository) GetActivePlan() (*task.Plan, error) { return r.db.GetActivePlan() diff --git a/internal/memory/task_store.go b/internal/memory/task_store.go index d7a6936..fa70ba5 100644 --- a/internal/memory/task_store.go +++ b/internal/memory/task_store.go @@ -924,11 +924,11 @@ func (s *SQLiteStore) GetNextTask(planID string) (*task.Task, error) { AND NOT EXISTS ( SELECT 1 FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on - WHERE td.task_id = t.id AND dep.status != ? + WHERE td.task_id = t.id AND dep.status NOT IN (?, ?) ) ORDER BY t.priority ASC, t.created_at ASC LIMIT 1 - `, planID, task.StatusPending, task.StatusCompleted) + `, planID, task.StatusPending, task.StatusCompleted, task.StatusSkipped) t, err := scanTaskRow(row) if err == sql.ErrNoRows { @@ -1126,6 +1126,41 @@ func (s *SQLiteStore) CompleteTask(taskID, summary string, filesModified []strin return nil } +// SkipTask marks a task as skipped with an optional reason. +// Allows skipping from pending or in_progress status. +func (s *SQLiteStore) SkipTask(taskID, reason string) error { + if taskID == "" { + return fmt.Errorf("task id is required") + } + + nowStr := time.Now().UTC().Format(time.RFC3339) + + res, err := s.db.Exec(` + UPDATE tasks + SET status = ?, completion_summary = ?, completed_at = ?, updated_at = ? + WHERE id = ? AND status IN (?, ?) + `, task.StatusSkipped, reason, nowStr, nowStr, taskID, task.StatusPending, task.StatusInProgress) + + if err != nil { + return fmt.Errorf("skip task: %w", err) + } + + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("skip task rows affected: %w", err) + } + if affected == 0 { + var status task.TaskStatus + err := s.db.QueryRow(`SELECT status FROM tasks WHERE id = ?`, taskID).Scan(&status) + if err == sql.ErrNoRows { + return fmt.Errorf("task not found: %s", taskID) + } + return fmt.Errorf("cannot skip task: current status is %s (must be pending or in_progress)", status) + } + + return nil +} + // SearchPlans returns plans matching the query and status (with task counts). // Query searches in goal and enriched_goal. func (s *SQLiteStore) SearchPlans(query string, status task.PlanStatus) ([]task.Plan, error) { diff --git a/internal/task/models.go b/internal/task/models.go index 3e53cc5..41c1270 100644 --- a/internal/task/models.go +++ b/internal/task/models.go @@ -18,10 +18,22 @@ const ( StatusVerifying TaskStatus = "verifying" // Work done, running validation StatusCompleted TaskStatus = "completed" // Successfully verified StatusFailed TaskStatus = "failed" // Execution or verification failed + StatusSkipped TaskStatus = "skipped" // Skipped by user or agent StatusBlocked TaskStatus = "blocked" // Waiting on dependencies StatusReady TaskStatus = "ready" // Dependencies met, ready for execution ) +// TaskInput is a caller-provided task definition used to bypass LLM generation. +// Shared between MCP handlers and the plan app layer. +type TaskInput struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + AcceptanceCriteria []string `json:"acceptance_criteria,omitempty"` + ValidationSteps []string `json:"validation_steps,omitempty"` + Priority int `json:"priority,omitempty"` + Complexity string `json:"complexity,omitempty"` +} + // PhaseStatus represents the lifecycle state of a phase type PhaseStatus string