diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index a96223f..24a1ee9 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -14,7 +14,6 @@ import ( "dappco.re/go/core/forge" coremcp "forge.lthn.ai/core/mcp/pkg/mcp" "github.com/modelcontextprotocol/go-sdk/mcp" - "gopkg.in/yaml.v3" ) // options := agentic.AgentOptions{} @@ -202,6 +201,9 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("state.set", s.handleStateSet).Description = "Store shared plan state for later sessions" c.Action("state.get", s.handleStateGet).Description = "Read shared plan state by key" c.Action("state.list", s.handleStateList).Description = "List shared plan state for a plan" + c.Action("template.list", s.handleTemplateList).Description = "List available YAML plan templates" + c.Action("template.preview", s.handleTemplatePreview).Description = "Preview a YAML plan template with variable substitution" + c.Action("template.create_plan", s.handleTemplateCreatePlan).Description = "Create a stored plan from a YAML template" c.Action("agentic.prompt", s.handlePrompt).Description = "Read a system prompt by slug" c.Action("agentic.task", s.handleTask).Description = "Read a task plan by slug" @@ -308,6 +310,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { s.registerStateTools(server) s.registerPhaseTools(server) s.registerTaskTools(server) + s.registerTemplateTools(server) mcp.AddTool(server, &mcp.Tool{ Name: "agentic_scan", @@ -778,68 +781,11 @@ func (s *PrepSubsystem) pullWikiContent(ctx context.Context, org, repo string) s } func (s *PrepSubsystem) renderPlan(templateSlug string, variables map[string]string, task string) string { - r := lib.Template(templateSlug) - if !r.OK { + definition, err := loadPlanTemplateDefinition(templateSlug, variables) + if err != nil { return "" } - - content := r.Value.(string) - for key, value := range variables { - content = core.Replace(content, core.Concat("{{", key, "}}"), value) - content = core.Replace(content, core.Concat("{{ ", key, " }}"), value) - } - - var tmpl struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Guidelines []string `yaml:"guidelines"` - Phases []struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Tasks []any `yaml:"tasks"` - } `yaml:"phases"` - } - - if err := yaml.Unmarshal([]byte(content), &tmpl); err != nil { - return "" - } - - plan := core.NewBuilder() - plan.WriteString(core.Concat("# ", tmpl.Name, "\n\n")) - if task != "" { - plan.WriteString(core.Concat("**Task:** ", task, "\n\n")) - } - if tmpl.Description != "" { - plan.WriteString(core.Concat(tmpl.Description, "\n\n")) - } - - if len(tmpl.Guidelines) > 0 { - plan.WriteString("## Guidelines\n\n") - for _, g := range tmpl.Guidelines { - plan.WriteString(core.Concat("- ", g, "\n")) - } - plan.WriteString("\n") - } - - for i, phase := range tmpl.Phases { - plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) - if phase.Description != "" { - plan.WriteString(core.Concat(phase.Description, "\n\n")) - } - for _, t := range phase.Tasks { - switch v := t.(type) { - case string: - plan.WriteString(core.Concat("- [ ] ", v, "\n")) - case map[string]any: - if name, ok := v["name"].(string); ok { - plan.WriteString(core.Concat("- [ ] ", name, "\n")) - } - } - } - plan.WriteString("\n") - } - - return plan.String() + return renderPlanMarkdown(definition, task) } func detectLanguage(repoPath string) string { diff --git a/pkg/agentic/template.go b/pkg/agentic/template.go new file mode 100644 index 0000000..f09bfb0 --- /dev/null +++ b/pkg/agentic/template.go @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "sort" + + "dappco.re/go/agent/pkg/lib" + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" + "gopkg.in/yaml.v3" +) + +// variable := agentic.TemplateVariable{Name: "feature_name", Required: true} +type TemplateVariable struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` +} + +// summary := agentic.TemplateSummary{Slug: "bug-fix", Name: "Bug Fix", PhasesCount: 6} +type TemplateSummary struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + PhasesCount int `json:"phases_count"` + Variables []TemplateVariable `json:"variables,omitempty"` +} + +// input := agentic.TemplateListInput{Category: "development"} +type TemplateListInput struct { + Category string `json:"category,omitempty"` +} + +// out := agentic.TemplateListOutput{Success: true, Total: 2} +type TemplateListOutput struct { + Success bool `json:"success"` + Templates []TemplateSummary `json:"templates"` + Total int `json:"total"` +} + +// input := agentic.TemplatePreviewInput{Template: "new-feature", Variables: map[string]string{"feature_name": "Auth"}} +type TemplatePreviewInput struct { + Template string `json:"template,omitempty"` + TemplateSlug string `json:"template_slug,omitempty"` + Slug string `json:"slug,omitempty"` + Variables map[string]string `json:"variables,omitempty"` +} + +// out := agentic.TemplatePreviewOutput{Success: true, Template: "bug-fix"} +type TemplatePreviewOutput struct { + Success bool `json:"success"` + Template string `json:"template"` + Preview string `json:"preview"` +} + +// input := agentic.TemplateCreatePlanInput{Template: "new-feature", Variables: map[string]string{"feature_name": "Auth"}} +type TemplateCreatePlanInput struct { + Template string `json:"template,omitempty"` + TemplateSlug string `json:"template_slug,omitempty"` + Variables map[string]string `json:"variables,omitempty"` + Slug string `json:"slug,omitempty"` + Title string `json:"title,omitempty"` + Activate bool `json:"activate,omitempty"` +} + +// out := agentic.TemplateCreatePlanOutput{Success: true, Plan: agentic.PlanCompatibilitySummary{Slug: "auth-feature"}} +type TemplateCreatePlanOutput struct { + Success bool `json:"success"` + Plan PlanCompatibilitySummary `json:"plan"` + Commands map[string]string `json:"commands,omitempty"` +} + +type planTemplateDefinition struct { + Slug string `yaml:"-"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Category string `yaml:"category"` + Variables map[string]planTemplateVariableDef `yaml:"variables"` + Guidelines []string `yaml:"guidelines"` + Phases []planTemplatePhaseDef `yaml:"phases"` +} + +type planTemplateVariableDef struct { + Description string `yaml:"description"` + Required bool `yaml:"required"` +} + +type planTemplatePhaseDef struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Tasks []any `yaml:"tasks"` +} + +// result := c.Action("template.list").Run(ctx, core.NewOptions()) +func (s *PrepSubsystem) handleTemplateList(ctx context.Context, options core.Options) core.Result { + _, output, err := s.templateList(ctx, nil, TemplateListInput{ + Category: optionStringValue(options, "category"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("template.preview").Run(ctx, core.NewOptions(core.Option{Key: "template", Value: "bug-fix"})) +func (s *PrepSubsystem) handleTemplatePreview(ctx context.Context, options core.Options) core.Result { + _, output, err := s.templatePreview(ctx, nil, TemplatePreviewInput{ + Template: optionStringValue(options, "template"), + TemplateSlug: optionStringValue(options, "template_slug", "template-slug", "slug"), + Variables: optionStringMapValue(options, "variables"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("template.create_plan").Run(ctx, core.NewOptions(core.Option{Key: "template", Value: "bug-fix"})) +func (s *PrepSubsystem) handleTemplateCreatePlan(ctx context.Context, options core.Options) core.Result { + _, output, err := s.templateCreatePlan(ctx, nil, TemplateCreatePlanInput{ + Template: optionStringValue(options, "template"), + TemplateSlug: optionStringValue(options, "template_slug", "template-slug"), + Variables: optionStringMapValue(options, "variables"), + Slug: optionStringValue(options, "slug", "plan_slug", "plan-slug"), + Title: optionStringValue(options, "title"), + Activate: optionBoolValue(options, "activate"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerTemplateTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "template_list", + Description: "List available plan templates with variables, category, and phase counts.", + }, s.templateList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "template_preview", + Description: "Preview a plan template with variable substitution before creating a stored plan.", + }, s.templatePreview) + + mcp.AddTool(server, &mcp.Tool{ + Name: "template_create_plan", + Description: "Create a stored plan from an embedded YAML template, with optional activation.", + }, s.templateCreatePlan) +} + +func (s *PrepSubsystem) templateList(_ context.Context, _ *mcp.CallToolRequest, input TemplateListInput) (*mcp.CallToolResult, TemplateListOutput, error) { + templates := make([]TemplateSummary, 0, len(lib.ListTasks())) + for _, slug := range lib.ListTasks() { + definition, err := loadPlanTemplateDefinition(slug, nil) + if err != nil { + continue + } + if input.Category != "" && definition.Category != input.Category { + continue + } + templates = append(templates, templateSummaryFromDefinition(definition)) + } + + sort.Slice(templates, func(i, j int) bool { + return templates[i].Slug < templates[j].Slug + }) + + return nil, TemplateListOutput{ + Success: true, + Templates: templates, + Total: len(templates), + }, nil +} + +func (s *PrepSubsystem) templatePreview(_ context.Context, _ *mcp.CallToolRequest, input TemplatePreviewInput) (*mcp.CallToolResult, TemplatePreviewOutput, error) { + templateName := templateNameValue(input.Template, input.TemplateSlug, input.Slug) + if templateName == "" { + return nil, TemplatePreviewOutput{}, core.E("templatePreview", "template is required", nil) + } + + definition, err := loadPlanTemplateDefinition(templateName, input.Variables) + if err != nil { + return nil, TemplatePreviewOutput{}, err + } + + return nil, TemplatePreviewOutput{ + Success: true, + Template: definition.Slug, + Preview: renderPlanMarkdown(definition, ""), + }, nil +} + +func (s *PrepSubsystem) templateCreatePlan(ctx context.Context, _ *mcp.CallToolRequest, input TemplateCreatePlanInput) (*mcp.CallToolResult, TemplateCreatePlanOutput, error) { + templateName := templateNameValue(input.Template, input.TemplateSlug) + if templateName == "" { + return nil, TemplateCreatePlanOutput{}, core.E("templateCreatePlan", "template is required", nil) + } + if input.Variables == nil { + return nil, TemplateCreatePlanOutput{}, core.E("templateCreatePlan", "variables are required", nil) + } + + definition, err := loadPlanTemplateDefinition(templateName, input.Variables) + if err != nil { + return nil, TemplateCreatePlanOutput{}, err + } + + if missing := missingTemplateVariables(definition, input.Variables); len(missing) > 0 { + return nil, TemplateCreatePlanOutput{}, core.E("templateCreatePlan", core.Concat("missing required variables: ", core.Join(", ", missing...)), nil) + } + + title := core.Trim(input.Title) + if title == "" { + title = definition.Name + } + + contextData := map[string]any{ + "template": definition.Slug, + } + if len(input.Variables) > 0 { + contextData["variables"] = input.Variables + } + + _, created, err := s.planCreate(ctx, nil, PlanCreateInput{ + Title: title, + Slug: input.Slug, + Objective: definition.Description, + Description: definition.Description, + Context: contextData, + Phases: templatePlanPhases(definition), + Notes: templateGuidelinesNote(definition.Guidelines), + }) + if err != nil { + return nil, TemplateCreatePlanOutput{}, err + } + + plan, err := readPlan(PlansRoot(), created.ID) + if err != nil { + return nil, TemplateCreatePlanOutput{}, err + } + + if input.Activate { + _, updated, updateErr := s.planUpdate(ctx, nil, PlanUpdateInput{ + Slug: plan.Slug, + Status: planCompatibilityInputStatus("active"), + }) + if updateErr != nil { + return nil, TemplateCreatePlanOutput{}, updateErr + } + plan = &updated.Plan + } + + return nil, TemplateCreatePlanOutput{ + Success: true, + Plan: planCompatibilitySummary(*plan), + }, nil +} + +func templateNameValue(values ...string) string { + for _, value := range values { + if trimmed := core.Trim(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func loadPlanTemplateDefinition(slug string, variables map[string]string) (planTemplateDefinition, error) { + result := lib.Task(slug) + if !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("loadPlanTemplateDefinition", core.Concat("template not found: ", slug), nil) + } + return planTemplateDefinition{}, err + } + + content := applyTemplateVariables(result.Value.(string), variables) + + var definition planTemplateDefinition + if err := yaml.Unmarshal([]byte(content), &definition); err != nil { + return planTemplateDefinition{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), err) + } + if definition.Name == "" || len(definition.Phases) == 0 { + return planTemplateDefinition{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), nil) + } + + definition.Slug = slug + return definition, nil +} + +func applyTemplateVariables(content string, variables map[string]string) string { + for key, value := range variables { + content = core.Replace(content, core.Concat("{{", key, "}}"), value) + content = core.Replace(content, core.Concat("{{ ", key, " }}"), value) + } + return content +} + +func renderPlanMarkdown(definition planTemplateDefinition, task string) string { + plan := core.NewBuilder() + plan.WriteString(core.Concat("# ", definition.Name, "\n\n")) + if task != "" { + plan.WriteString(core.Concat("**Task:** ", task, "\n\n")) + } + if definition.Description != "" { + plan.WriteString(core.Concat(definition.Description, "\n\n")) + } + + if len(definition.Guidelines) > 0 { + plan.WriteString("## Guidelines\n\n") + for _, guideline := range definition.Guidelines { + plan.WriteString(core.Concat("- ", guideline, "\n")) + } + plan.WriteString("\n") + } + + for index, phase := range definition.Phases { + plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", index+1, phase.Name)) + if phase.Description != "" { + plan.WriteString(core.Concat(phase.Description, "\n\n")) + } + for _, taskItem := range templatePlanTasks(phase.Tasks) { + plan.WriteString(core.Concat("- [ ] ", taskItem.Title, "\n")) + } + plan.WriteString("\n") + } + + return plan.String() +} + +func templateSummaryFromDefinition(definition planTemplateDefinition) TemplateSummary { + return TemplateSummary{ + Slug: definition.Slug, + Name: definition.Name, + Description: definition.Description, + Category: definition.Category, + PhasesCount: len(definition.Phases), + Variables: templateVariableList(definition), + } +} + +func templateVariableList(definition planTemplateDefinition) []TemplateVariable { + if len(definition.Variables) == 0 { + return nil + } + + names := make([]string, 0, len(definition.Variables)) + for name := range definition.Variables { + names = append(names, name) + } + sort.Strings(names) + + variables := make([]TemplateVariable, 0, len(names)) + for _, name := range names { + definitionValue := definition.Variables[name] + variables = append(variables, TemplateVariable{ + Name: name, + Description: definitionValue.Description, + Required: definitionValue.Required, + }) + } + return variables +} + +func missingTemplateVariables(definition planTemplateDefinition, variables map[string]string) []string { + var missing []string + for name, variable := range definition.Variables { + if !variable.Required { + continue + } + if core.Trim(variables[name]) == "" { + missing = append(missing, name) + } + } + sort.Strings(missing) + return missing +} + +func templatePlanPhases(definition planTemplateDefinition) []Phase { + phases := make([]Phase, 0, len(definition.Phases)) + for index, phaseDefinition := range definition.Phases { + phases = append(phases, Phase{ + Number: index + 1, + Name: phaseDefinition.Name, + Description: phaseDefinition.Description, + Status: "pending", + Tasks: templatePlanTasks(phaseDefinition.Tasks), + }) + } + return phases +} + +func templatePlanTasks(items []any) []PlanTask { + tasks := make([]PlanTask, 0, len(items)) + for index, item := range items { + task := templatePlanTask(item, index+1) + if task.Title == "" { + continue + } + tasks = append(tasks, task) + } + return tasks +} + +func templatePlanTask(item any, number int) PlanTask { + switch value := item.(type) { + case string: + return PlanTask{ + ID: core.Sprint(number), + Title: core.Trim(value), + Status: "pending", + } + case map[string]any: + title := stringValue(value["title"]) + if title == "" { + title = stringValue(value["name"]) + } + status := stringValue(value["status"]) + if status == "" { + status = "pending" + } + taskID := stringValue(value["id"]) + if taskID == "" { + taskID = core.Sprint(number) + } + return PlanTask{ + ID: taskID, + Title: title, + Description: stringValue(value["description"]), + Status: status, + Notes: stringValue(value["notes"]), + } + } + return PlanTask{} +} + +func templateGuidelinesNote(guidelines []string) string { + cleaned := cleanStrings(guidelines) + if len(cleaned) == 0 { + return "" + } + + builder := core.NewBuilder() + builder.WriteString("Guidelines:\n") + for _, guideline := range cleaned { + builder.WriteString(core.Concat("- ", guideline, "\n")) + } + return core.TrimSuffix(builder.String(), "\n") +} diff --git a/pkg/agentic/template_example_test.go b/pkg/agentic/template_example_test.go new file mode 100644 index 0000000..adfd8a9 --- /dev/null +++ b/pkg/agentic/template_example_test.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import "fmt" + +func Example_templateVariableList() { + variables := templateVariableList(planTemplateDefinition{ + Variables: map[string]planTemplateVariableDef{ + "feature_name": {Required: true}, + "description": {Required: false}, + }, + }) + + fmt.Println(variables[0].Name, variables[0].Required) + fmt.Println(variables[1].Name, variables[1].Required) + // Output: + // description false + // feature_name true +} diff --git a/pkg/agentic/template_test.go b/pkg/agentic/template_test.go new file mode 100644 index 0000000..ac9b5db --- /dev/null +++ b/pkg/agentic/template_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTemplate_HandleTemplateList_Good(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplateList(context.Background(), core.NewOptions( + core.Option{Key: "category", Value: "development"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(TemplateListOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.NotZero(t, output.Total) + + found := false + for _, summary := range output.Templates { + assert.Equal(t, "development", summary.Category) + if summary.Slug == "bug-fix" { + found = true + assert.Equal(t, "Bug Fix", summary.Name) + assert.NotZero(t, summary.PhasesCount) + assert.NotEmpty(t, summary.Variables) + } + } + assert.True(t, found) +} + +func TestTemplate_HandleTemplatePreview_Good(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplatePreview(context.Background(), core.NewOptions( + core.Option{Key: "template", Value: "new-feature"}, + core.Option{Key: "variables", Value: `{"feature_name":"Authentication"}`}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(TemplatePreviewOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "new-feature", output.Template) + assert.Contains(t, output.Preview, "Authentication") + assert.Contains(t, output.Preview, "Phase 1") +} + +func TestTemplate_HandleTemplatePreview_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplatePreview(context.Background(), core.NewOptions()) + assert.False(t, result.OK) +} + +func TestTemplate_HandleTemplatePreview_Ugly_MissingVariables(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplatePreview(context.Background(), core.NewOptions( + core.Option{Key: "template", Value: "new-feature"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(TemplatePreviewOutput) + require.True(t, ok) + assert.Contains(t, output.Preview, "{{ feature_name }}") +} + +func TestTemplate_HandleTemplateCreatePlan_Good(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplateCreatePlan(context.Background(), core.NewOptions( + core.Option{Key: "template", Value: "new-feature"}, + core.Option{Key: "variables", Value: `{"feature_name":"Authentication"}`}, + core.Option{Key: "title", Value: "Authentication Rollout"}, + core.Option{Key: "plan_slug", Value: "auth-rollout"}, + core.Option{Key: "activate", Value: true}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(TemplateCreatePlanOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "auth-rollout", output.Plan.Slug) + assert.Equal(t, "active", output.Plan.Status) + + plan, err := readPlan(PlansRoot(), "auth-rollout") + require.NoError(t, err) + assert.Equal(t, "Authentication Rollout", plan.Title) + assert.Equal(t, "in_progress", plan.Status) + require.NotEmpty(t, plan.Phases) + require.NotEmpty(t, plan.Phases[0].Tasks) + assert.Equal(t, "pending", plan.Phases[0].Tasks[0].Status) + assert.Equal(t, "new-feature", stringValue(plan.Context["template"])) +} + +func TestTemplate_HandleTemplateCreatePlan_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplateCreatePlan(context.Background(), core.NewOptions( + core.Option{Key: "template", Value: "new-feature"}, + )) + assert.False(t, result.OK) +} + +func TestTemplate_HandleTemplateCreatePlan_Ugly_UnknownTemplate(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleTemplateCreatePlan(context.Background(), core.NewOptions( + core.Option{Key: "template", Value: "unknown-template"}, + core.Option{Key: "variables", Value: `{"feature_name":"Authentication"}`}, + )) + assert.False(t, result.OK) +}