feat(agentic): add task create command
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
0d05ccac55
commit
a50248f5ae
6 changed files with 252 additions and 0 deletions
|
|
@ -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 <plan> --phase=1 --task=1 [--status=completed] [--notes=\"Done\"]")
|
||||
core.Print(nil, " core-agent task create <plan> --phase=1 --title=\"Review RFC\" [--description=\"...\"] [--status=pending] [--notes=\"...\"]")
|
||||
core.Print(nil, " core-agent task toggle <plan> --phase=1 --task=1")
|
||||
return core.Result{OK: true}
|
||||
default:
|
||||
core.Print(nil, "usage: core-agent task update <plan> --phase=1 --task=1 [--status=completed] [--notes=\"Done\"]")
|
||||
core.Print(nil, " core-agent task create <plan> --phase=1 --title=\"Review RFC\" [--description=\"...\"] [--status=pending] [--notes=\"...\"]")
|
||||
core.Print(nil, " core-agent task toggle <plan> --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 <plan> --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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue