agent/pkg/agentic/task.go
Snider 39914fbf14 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00

340 lines
12 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"time"
core "dappco.re/go/core"
coremcp "dappco.re/go/mcp/pkg/mcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// input := agentic.TaskUpdateInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, TaskIdentifier: "1", File: "pkg/agentic/task.go", Line: 128}
type TaskUpdateInput struct {
PlanSlug string `json:"plan_slug"`
PhaseOrder int `json:"phase_order"`
TaskIdentifier any `json:"task_identifier"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
Priority string `json:"priority,omitempty"`
Category string `json:"category,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
FileRef string `json:"file_ref,omitempty"`
LineRef int `json:"line_ref,omitempty"`
}
// input := agentic.TaskToggleInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, TaskIdentifier: 1}
type TaskToggleInput struct {
PlanSlug string `json:"plan_slug"`
PhaseOrder int `json:"phase_order"`
TaskIdentifier any `json:"task_identifier"`
}
// input := agentic.TaskCreateInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, Title: "Review imports", File: "pkg/agentic/task.go", Line: 153}
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"`
Priority string `json:"priority,omitempty"`
Category string `json:"category,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
FileRef string `json:"file_ref,omitempty"`
LineRef int `json:"line_ref,omitempty"`
}
// out := agentic.TaskOutput{Success: true, Task: agentic.PlanTask{ID: "1", Title: "Review imports", File: "pkg/agentic/task.go", Line: 128}}
type TaskOutput struct {
Success bool `json:"success"`
Task PlanTask `json:"task"`
}
// out := agentic.TaskCreateOutput{Success: true, Task: agentic.PlanTask{ID: "3", Title: "Review imports", File: "pkg/agentic/task.go", Line: 153}}
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"),
Priority: optionStringValue(options, "priority"),
Category: optionStringValue(options, "category"),
File: optionStringValue(options, "file"),
Line: optionIntValue(options, "line"),
FileRef: optionStringValue(options, "file_ref", "file-ref"),
LineRef: optionIntValue(options, "line_ref", "line-ref"),
})
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{
PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"),
PhaseOrder: optionIntValue(options, "phase_order", "phase"),
TaskIdentifier: optionAnyValue(options, "task_identifier", "task"),
Status: optionStringValue(options, "status"),
Notes: optionStringValue(options, "notes"),
Priority: optionStringValue(options, "priority"),
Category: optionStringValue(options, "category"),
File: optionStringValue(options, "file"),
Line: optionIntValue(options, "line"),
FileRef: optionStringValue(options, "file_ref", "file-ref"),
LineRef: optionIntValue(options, "line_ref", "line-ref"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("task.toggle").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"}))
func (s *PrepSubsystem) handleTaskToggle(ctx context.Context, options core.Options) core.Result {
_, output, err := s.taskToggle(ctx, nil, TaskToggleInput{
PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"),
PhaseOrder: optionIntValue(options, "phase_order", "phase"),
TaskIdentifier: optionAnyValue(options, "task_identifier", "task"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) registerTaskTools(svc *coremcp.Service) {
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "task_create",
Description: "Create a plan task by plan slug and phase order.",
}, s.taskCreate)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_task_create",
Description: "Create a plan task by plan slug and phase order.",
}, s.taskCreate)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "task_update",
Description: "Update a plan task status or notes by plan slug, phase order, and task identifier.",
}, s.taskUpdate)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_task_update",
Description: "Update a plan task status or notes by plan slug, phase order, and task identifier.",
}, s.taskUpdate)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "task_toggle",
Description: "Toggle a plan task between pending and completed.",
}, s.taskToggle)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_task_toggle",
Description: "Toggle a plan task between pending and completed.",
}, s.taskToggle)
}
func (s *PrepSubsystem) taskUpdate(_ context.Context, _ *mcp.CallToolRequest, input TaskUpdateInput) (*mcp.CallToolResult, TaskOutput, error) {
if input.Status != "" && !validTaskStatus(input.Status) {
return nil, TaskOutput{}, core.E("taskUpdate", core.Concat("invalid status: ", input.Status), nil)
}
if taskIdentifierValue(input.TaskIdentifier) == "" {
return nil, TaskOutput{}, core.E("taskUpdate", "task_identifier is required", nil)
}
plan, phaseIndex, taskIndex, err := planTaskByIdentifier(PlansRoot(), input.PlanSlug, input.PhaseOrder, input.TaskIdentifier)
if err != nil {
return nil, TaskOutput{}, err
}
if input.Status != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Status = input.Status
}
if notes := core.Trim(input.Notes); notes != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Notes = notes
}
if priority := core.Trim(input.Priority); priority != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Priority = priority
}
if category := core.Trim(input.Category); category != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Category = category
}
if file := core.Trim(input.File); file != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].File = file
plan.Phases[phaseIndex].Tasks[taskIndex].FileRef = file
}
if fileRef := core.Trim(input.FileRef); fileRef != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].FileRef = fileRef
plan.Phases[phaseIndex].Tasks[taskIndex].File = fileRef
}
if input.Line > 0 {
plan.Phases[phaseIndex].Tasks[taskIndex].Line = input.Line
plan.Phases[phaseIndex].Tasks[taskIndex].LineRef = input.Line
}
if input.LineRef > 0 {
plan.Phases[phaseIndex].Tasks[taskIndex].LineRef = input.LineRef
plan.Phases[phaseIndex].Tasks[taskIndex].Line = input.LineRef
}
plan.UpdatedAt = time.Now()
if result := writePlanResult(PlansRoot(), plan); !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("taskUpdate", "failed to write plan", nil)
}
return nil, TaskOutput{}, err
}
return nil, TaskOutput{
Success: true,
Task: plan.Phases[phaseIndex].Tasks[taskIndex],
}, 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),
Priority: core.Trim(input.Priority),
Category: core.Trim(input.Category),
Status: input.Status,
Notes: core.Trim(input.Notes),
File: core.Trim(input.File),
Line: input.Line,
FileRef: core.Trim(input.FileRef),
LineRef: input.LineRef,
}
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)
}
plan, phaseIndex, taskIndex, err := planTaskByIdentifier(PlansRoot(), input.PlanSlug, input.PhaseOrder, input.TaskIdentifier)
if err != nil {
return nil, TaskOutput{}, err
}
status := plan.Phases[phaseIndex].Tasks[taskIndex].Status
if status == "completed" {
plan.Phases[phaseIndex].Tasks[taskIndex].Status = "pending"
} else {
plan.Phases[phaseIndex].Tasks[taskIndex].Status = "completed"
}
plan.UpdatedAt = time.Now()
if result := writePlanResult(PlansRoot(), plan); !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("taskToggle", "failed to write plan", nil)
}
return nil, TaskOutput{}, err
}
return nil, TaskOutput{
Success: true,
Task: plan.Phases[phaseIndex].Tasks[taskIndex],
}, nil
}
func planTaskByIdentifier(dir, planSlug string, phaseOrder int, taskIdentifier any) (*Plan, int, int, error) {
plan, phaseIndex, err := planPhaseByOrder(dir, planSlug, phaseOrder)
if err != nil {
return nil, 0, 0, err
}
tasks := phaseTaskList(plan.Phases[phaseIndex])
if len(tasks) == 0 {
return nil, 0, 0, core.E("planTaskByIdentifier", "phase has no tasks", nil)
}
plan.Phases[phaseIndex].Tasks = tasks
identifier := taskIdentifierValue(taskIdentifier)
if identifier == "" {
return nil, 0, 0, core.E("planTaskByIdentifier", "task_identifier is required", nil)
}
for index := range plan.Phases[phaseIndex].Tasks {
task := plan.Phases[phaseIndex].Tasks[index]
if task.ID == identifier || task.Title == identifier || core.Sprint(index+1) == identifier {
return plan, phaseIndex, index, nil
}
}
return nil, 0, 0, core.E("planTaskByIdentifier", core.Concat("task not found: ", identifier), nil)
}
func taskIdentifierValue(value any) string {
switch typed := value.(type) {
case string:
return core.Trim(typed)
case int:
return core.Sprint(typed)
case int64:
return core.Sprint(typed)
case float64:
return core.Sprint(int(typed))
}
return ""
}
func validTaskStatus(status string) bool {
switch status {
case "pending", "completed":
return true
}
return false
}