diff --git a/pkg/agentic/commands_task.go b/pkg/agentic/commands_task.go index e849ab8..2431c2b 100644 --- a/pkg/agentic/commands_task.go +++ b/pkg/agentic/commands_task.go @@ -9,6 +9,7 @@ import ( func (s *PrepSubsystem) registerTaskCommands() { c := s.Core() c.Command("task", core.Command{Description: "Manage plan tasks", Action: s.cmdTask}) + c.Command("task/create", core.Command{Description: "Create a task in a plan phase", Action: s.cmdTaskCreate}) c.Command("task/update", core.Command{Description: "Update a plan task status or notes", Action: s.cmdTaskUpdate}) c.Command("task/toggle", core.Command{Description: "Toggle a plan task between pending and completed", Action: s.cmdTaskToggle}) } @@ -16,21 +17,61 @@ func (s *PrepSubsystem) registerTaskCommands() { func (s *PrepSubsystem) cmdTask(options core.Options) core.Result { action := optionStringValue(options, "action") switch action { + case "create": + return s.cmdTaskCreate(options) case "toggle": return s.cmdTaskToggle(options) case "update": return s.cmdTaskUpdate(options) case "": core.Print(nil, "usage: core-agent task update --phase=1 --task=1 [--status=completed] [--notes=\"Done\"]") + core.Print(nil, " core-agent task create --phase=1 --title=\"Review RFC\" [--description=\"...\"] [--status=pending] [--notes=\"...\"]") core.Print(nil, " core-agent task toggle --phase=1 --task=1") return core.Result{OK: true} default: core.Print(nil, "usage: core-agent task update --phase=1 --task=1 [--status=completed] [--notes=\"Done\"]") + core.Print(nil, " core-agent task create --phase=1 --title=\"Review RFC\" [--description=\"...\"] [--status=pending] [--notes=\"...\"]") core.Print(nil, " core-agent task toggle --phase=1 --task=1") return core.Result{Value: core.E("agentic.cmdTask", core.Concat("unknown task command: ", action), nil), OK: false} } } +func (s *PrepSubsystem) cmdTaskCreate(options core.Options) core.Result { + planSlug := optionStringValue(options, "plan_slug", "plan", "slug", "_arg") + phaseOrder := optionIntValue(options, "phase_order", "phase") + title := optionStringValue(options, "title", "task") + + if planSlug == "" || phaseOrder == 0 || title == "" { + core.Print(nil, "usage: core-agent task create --phase=1 --title=\"Review RFC\" [--description=\"...\"] [--status=pending] [--notes=\"...\"]") + return core.Result{Value: core.E("agentic.cmdTaskCreate", "plan_slug, phase_order, and title are required", nil), OK: false} + } + + result := s.handleTaskCreate(s.commandContext(), core.NewOptions( + core.Option{Key: "plan_slug", Value: planSlug}, + core.Option{Key: "phase_order", Value: phaseOrder}, + core.Option{Key: "title", Value: title}, + core.Option{Key: "description", Value: optionStringValue(options, "description")}, + core.Option{Key: "status", Value: optionStringValue(options, "status")}, + core.Option{Key: "notes", Value: optionStringValue(options, "notes")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdTaskCreate", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(TaskCreateOutput) + if !ok { + err := core.E("agentic.cmdTaskCreate", "invalid task create output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "task: %s", output.Task.Title) + core.Print(nil, "status: %s", output.Task.Status) + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) cmdTaskUpdate(options core.Options) core.Result { planSlug := optionStringValue(options, "plan_slug", "plan", "slug", "_arg") phaseOrder := optionIntValue(options, "phase_order", "phase") diff --git a/pkg/agentic/commands_task_test.go b/pkg/agentic/commands_task_test.go index acc36ab..e684b0b 100644 --- a/pkg/agentic/commands_task_test.go +++ b/pkg/agentic/commands_task_test.go @@ -43,6 +43,40 @@ func TestCommands_TaskCommand_Good_Update(t *testing.T) { assert.Equal(t, "Done", output.Task.Notes) } +func TestCommands_TaskCommand_Good_Create(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Task Command Create", + Description: "Create task through CLI command", + Phases: []Phase{ + {Name: "Setup"}, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + r := s.cmdTaskCreate(core.NewOptions( + core.Option{Key: "plan_slug", Value: plan.Slug}, + core.Option{Key: "phase_order", Value: 1}, + core.Option{Key: "title", Value: "Patch code"}, + core.Option{Key: "description", Value: "Update the implementation"}, + core.Option{Key: "status", Value: "pending"}, + core.Option{Key: "notes", Value: "Do this first"}, + )) + require.True(t, r.OK) + + output, ok := r.Value.(TaskCreateOutput) + require.True(t, ok) + assert.Equal(t, "Patch code", output.Task.Title) + assert.Equal(t, "pending", output.Task.Status) + assert.Equal(t, "Do this first", output.Task.Notes) +} + func TestCommands_TaskCommand_Bad_MissingRequiredFields(t *testing.T) { s := newTestPrep(t) @@ -84,3 +118,15 @@ func TestCommands_TaskCommand_Ugly_ToggleCriteriaFallback(t *testing.T) { assert.Equal(t, "completed", output.Task.Status) assert.Equal(t, "Review RFC", output.Task.Title) } + +func TestCommands_TaskCommand_Bad_CreateMissingTitle(t *testing.T) { + s := newTestPrep(t) + + r := s.cmdTaskCreate(core.NewOptions( + core.Option{Key: "plan_slug", Value: "my-plan"}, + core.Option{Key: "phase_order", Value: 1}, + )) + + assert.False(t, r.OK) + assert.Contains(t, r.Value.(error).Error(), "required") +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index fd9437f..660c733 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -190,6 +190,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("phase.get", s.handlePhaseGet).Description = "Read a plan phase by slug and order" c.Action("phase.update_status", s.handlePhaseUpdateStatus).Description = "Update plan phase status by slug and order" c.Action("phase.add_checkpoint", s.handlePhaseAddCheckpoint).Description = "Append a checkpoint note to a plan phase" + c.Action("task.create", s.handleTaskCreate).Description = "Create a plan task in a phase" c.Action("task.update", s.handleTaskUpdate).Description = "Update a plan task by slug, phase, and identifier" c.Action("task.toggle", s.handleTaskToggle).Description = "Toggle a plan task between pending and completed" c.Action("session.start", s.handleSessionStart).Description = "Start an agent session for a plan" diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 72477b5..06145e7 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -451,6 +451,7 @@ func TestPrep_OnStartup_Good_RegistersPlanActions(t *testing.T) { assert.True(t, c.Action("phase.get").Exists()) assert.True(t, c.Action("phase.update_status").Exists()) assert.True(t, c.Action("phase.add_checkpoint").Exists()) + assert.True(t, c.Action("task.create").Exists()) assert.True(t, c.Action("task.update").Exists()) assert.True(t, c.Action("task.toggle").Exists()) } @@ -584,6 +585,7 @@ func TestPrep_OnStartup_Good_RegistersGenerateCommand(t *testing.T) { assert.Contains(t, c.Commands(), "brain/seed-memory") assert.Contains(t, c.Commands(), "plan-cleanup") assert.Contains(t, c.Commands(), "task") + assert.Contains(t, c.Commands(), "task/create") assert.Contains(t, c.Commands(), "task/update") assert.Contains(t, c.Commands(), "task/toggle") } diff --git a/pkg/agentic/task.go b/pkg/agentic/task.go index 6cbe43a..0f786b6 100644 --- a/pkg/agentic/task.go +++ b/pkg/agentic/task.go @@ -26,12 +26,50 @@ type TaskToggleInput struct { TaskIdentifier any `json:"task_identifier"` } +// input := agentic.TaskCreateInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, Title: "Review imports"} +type TaskCreateInput struct { + PlanSlug string `json:"plan_slug"` + PhaseOrder int `json:"phase_order"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` +} + // out := agentic.TaskOutput{Success: true, Task: agentic.PlanTask{ID: "1", Title: "Review imports"}} type TaskOutput struct { Success bool `json:"success"` Task PlanTask `json:"task"` } +// out := agentic.TaskCreateOutput{Success: true, Task: agentic.PlanTask{ID: "3", Title: "Review imports"}} +type TaskCreateOutput struct { + Success bool `json:"success"` + Task PlanTask `json:"task"` +} + +// result := c.Action("task.create").Run(ctx, core.NewOptions( +// +// core.Option{Key: "plan_slug", Value: "my-plan-abc123"}, +// core.Option{Key: "phase_order", Value: 1}, +// core.Option{Key: "title", Value: "Review imports"}, +// +// )) +func (s *PrepSubsystem) handleTaskCreate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.taskCreate(ctx, nil, TaskCreateInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"), + PhaseOrder: optionIntValue(options, "phase_order", "phase"), + Title: optionStringValue(options, "title", "task", "_arg"), + Description: optionStringValue(options, "description"), + Status: optionStringValue(options, "status"), + Notes: optionStringValue(options, "notes"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + // result := c.Action("task.update").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"})) func (s *PrepSubsystem) handleTaskUpdate(ctx context.Context, options core.Options) core.Result { _, output, err := s.taskUpdate(ctx, nil, TaskUpdateInput{ @@ -61,6 +99,11 @@ func (s *PrepSubsystem) handleTaskToggle(ctx context.Context, options core.Optio } func (s *PrepSubsystem) registerTaskTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "task_create", + Description: "Create a plan task by plan slug and phase order.", + }, s.taskCreate) + mcp.AddTool(server, &mcp.Tool{ Name: "task_update", Description: "Update a plan task status or notes by plan slug, phase order, and task identifier.", @@ -107,6 +150,49 @@ func (s *PrepSubsystem) taskUpdate(_ context.Context, _ *mcp.CallToolRequest, in }, nil } +func (s *PrepSubsystem) taskCreate(_ context.Context, _ *mcp.CallToolRequest, input TaskCreateInput) (*mcp.CallToolResult, TaskCreateOutput, error) { + if core.Trim(input.Title) == "" { + return nil, TaskCreateOutput{}, core.E("taskCreate", "title is required", nil) + } + if input.Status != "" && !validTaskStatus(input.Status) { + return nil, TaskCreateOutput{}, core.E("taskCreate", core.Concat("invalid status: ", input.Status), nil) + } + + plan, phaseIndex, err := planPhaseByOrder(PlansRoot(), input.PlanSlug, input.PhaseOrder) + if err != nil { + return nil, TaskCreateOutput{}, err + } + + tasks := phaseTaskList(plan.Phases[phaseIndex]) + plan.Phases[phaseIndex].Tasks = tasks + + nextIndex := len(tasks) + 1 + newTask := PlanTask{ + ID: core.Sprint(nextIndex), + Title: core.Trim(input.Title), + Description: core.Trim(input.Description), + Status: input.Status, + Notes: core.Trim(input.Notes), + } + newTask = normalisePlanTask(newTask, nextIndex) + + plan.Phases[phaseIndex].Tasks = append(plan.Phases[phaseIndex].Tasks, newTask) + plan.UpdatedAt = time.Now() + + if result := writePlanResult(PlansRoot(), plan); !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("taskCreate", "failed to write plan", nil) + } + return nil, TaskCreateOutput{}, err + } + + return nil, TaskCreateOutput{ + Success: true, + Task: newTask, + }, nil +} + func (s *PrepSubsystem) taskToggle(_ context.Context, _ *mcp.CallToolRequest, input TaskToggleInput) (*mcp.CallToolResult, TaskOutput, error) { if taskIdentifierValue(input.TaskIdentifier) == "" { return nil, TaskOutput{}, core.E("taskToggle", "task_identifier is required", nil) diff --git a/pkg/agentic/task_test.go b/pkg/agentic/task_test.go index 611d565..6f60a6c 100644 --- a/pkg/agentic/task_test.go +++ b/pkg/agentic/task_test.go @@ -40,6 +40,49 @@ func TestTask_TaskUpdate_Good(t *testing.T) { assert.Equal(t, "Done", output.Task.Notes) } +func TestTask_TaskCreate_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Task Create", + Description: "Create task by phase", + Phases: []Phase{ + {Name: "Setup", Tasks: []PlanTask{{ID: "1", Title: "Review RFC"}}}, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.taskCreate(context.Background(), nil, TaskCreateInput{ + PlanSlug: plan.Slug, + PhaseOrder: 1, + Title: "Patch code", + Description: "Update the implementation", + Status: "pending", + Notes: "Do this first", + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, "Patch code", output.Task.Title) + assert.Equal(t, "pending", output.Task.Status) + assert.Equal(t, "Do this first", output.Task.Notes) +} + +func TestTask_TaskCreate_Bad_MissingTitle(t *testing.T) { + s := newTestPrep(t) + + _, _, err := s.taskCreate(context.Background(), nil, TaskCreateInput{ + PlanSlug: "my-plan", + PhaseOrder: 1, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "title is required") +} + func TestTask_TaskToggle_Bad_MissingIdentifier(t *testing.T) { s := newTestPrep(t) _, _, err := s.taskToggle(context.Background(), nil, TaskToggleInput{ @@ -77,3 +120,36 @@ func TestTask_TaskToggle_Ugly_CriteriaFallback(t *testing.T) { assert.Equal(t, "completed", output.Task.Status) assert.Equal(t, "Review RFC", output.Task.Title) } + +func TestTask_TaskCreate_Ugly_CriteriaFallback(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Task Create Criteria", + Description: "Create task from criteria fallback", + Phases: []Phase{ + {Name: "Setup", Criteria: []string{"Review RFC"}}, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.taskCreate(context.Background(), nil, TaskCreateInput{ + PlanSlug: plan.Slug, + PhaseOrder: 1, + Title: "Patch code", + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, "Patch code", output.Task.Title) + + updated, err := readPlan(PlansRoot(), plan.ID) + require.NoError(t, err) + require.Len(t, updated.Phases[0].Tasks, 2) + assert.Equal(t, "Review RFC", updated.Phases[0].Tasks[0].Title) + assert.Equal(t, "Patch code", updated.Phases[0].Tasks[1].Title) +}