Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() }()
Expand Down Expand Up @@ -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),
})
}

Expand Down
17 changes: 13 additions & 4 deletions cmd/mcp_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand All @@ -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)
Expand Down
92 changes: 60 additions & 32 deletions internal/app/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down
3 changes: 3 additions & 0 deletions internal/config/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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}}
Expand Down
42 changes: 41 additions & 1 deletion internal/mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down
17 changes: 7 additions & 10 deletions internal/mcp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package mcp
import (
"encoding/json"
"fmt"

"github.com/josephgoksu/TaskWing/internal/task"
)

// === Action Constants ===
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/memory/repository_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
39 changes: 37 additions & 2 deletions internal/memory/task_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading