534 lines
17 KiB
Go
534 lines
17 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"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"`
|
|
Version PlanTemplateVersion `json:"version"`
|
|
}
|
|
|
|
// 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"`
|
|
Version PlanTemplateVersion `json:"version"`
|
|
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"`
|
|
Version PlanTemplateVersion `json:"version"`
|
|
Commands map[string]string `json:"commands,omitempty"`
|
|
}
|
|
|
|
// version := agentic.PlanTemplateVersion{Slug: "new-feature", Version: 1, ContentHash: "..." }
|
|
type PlanTemplateVersion struct {
|
|
Slug string `json:"slug"`
|
|
Version int `json:"version"`
|
|
Name string `json:"name"`
|
|
Content planTemplateDefinition `json:"content,omitempty"`
|
|
ContentHash string `json:"content_hash"`
|
|
}
|
|
|
|
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, version, err := loadPlanTemplateDefinition(slug, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if input.Category != "" && definition.Category != input.Category {
|
|
continue
|
|
}
|
|
templates = append(templates, templateSummaryFromDefinition(definition, version))
|
|
}
|
|
|
|
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, version, err := loadPlanTemplateDefinition(templateName, input.Variables)
|
|
if err != nil {
|
|
return nil, TemplatePreviewOutput{}, err
|
|
}
|
|
|
|
return nil, TemplatePreviewOutput{
|
|
Success: true,
|
|
Template: definition.Slug,
|
|
Version: version,
|
|
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)
|
|
}
|
|
|
|
definition, version, 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,
|
|
TemplateVersion: version,
|
|
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),
|
|
Version: version,
|
|
}, 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, PlanTemplateVersion, 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{}, PlanTemplateVersion{}, err
|
|
}
|
|
|
|
rawContent := result.Value.(string)
|
|
content := applyTemplateVariables(rawContent, variables)
|
|
|
|
var definition planTemplateDefinition
|
|
if err := yaml.Unmarshal([]byte(content), &definition); err != nil {
|
|
return planTemplateDefinition{}, PlanTemplateVersion{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), err)
|
|
}
|
|
if definition.Name == "" || len(definition.Phases) == 0 {
|
|
return planTemplateDefinition{}, PlanTemplateVersion{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), nil)
|
|
}
|
|
|
|
definition.Slug = slug
|
|
return definition, templateVersionFromContent(slug, definition.Name, rawContent), 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, version PlanTemplateVersion) TemplateSummary {
|
|
return TemplateSummary{
|
|
Slug: definition.Slug,
|
|
Name: definition.Name,
|
|
Description: definition.Description,
|
|
Category: definition.Category,
|
|
PhasesCount: len(definition.Phases),
|
|
Variables: templateVariableList(definition),
|
|
Version: version,
|
|
}
|
|
}
|
|
|
|
func templateVersionFromContent(slug, name, content string) PlanTemplateVersion {
|
|
sum := sha256.Sum256([]byte(content))
|
|
hash := hex.EncodeToString(sum[:])
|
|
version := templateVersionForHash(slug, hash)
|
|
|
|
var snapshot planTemplateDefinition
|
|
if err := yaml.Unmarshal([]byte(content), &snapshot); err != nil {
|
|
snapshot = planTemplateDefinition{}
|
|
}
|
|
snapshot.Slug = slug
|
|
if snapshot.Name == "" {
|
|
snapshot.Name = name
|
|
}
|
|
|
|
return PlanTemplateVersion{
|
|
Slug: slug,
|
|
Version: version,
|
|
Name: name,
|
|
Content: snapshot,
|
|
ContentHash: hash,
|
|
}
|
|
}
|
|
|
|
func templateVersionForHash(slug, contentHash string) int {
|
|
if core.Trim(slug) == "" {
|
|
return 1
|
|
}
|
|
|
|
version := 0
|
|
matchedVersion := 0
|
|
for _, path := range core.PathGlob(core.JoinPath(PlansRoot(), "*.json")) {
|
|
id := core.TrimSuffix(core.PathBase(path), ".json")
|
|
planResult := readPlanResult(PlansRoot(), id)
|
|
if !planResult.OK {
|
|
continue
|
|
}
|
|
|
|
plan, ok := planResult.Value.(*Plan)
|
|
if !ok || plan == nil {
|
|
continue
|
|
}
|
|
if plan.TemplateVersion.Slug != slug || plan.TemplateVersion.Version <= 0 {
|
|
continue
|
|
}
|
|
|
|
if plan.TemplateVersion.ContentHash == contentHash {
|
|
if plan.TemplateVersion.Version > matchedVersion {
|
|
matchedVersion = plan.TemplateVersion.Version
|
|
}
|
|
}
|
|
if plan.TemplateVersion.Version > version {
|
|
version = plan.TemplateVersion.Version
|
|
}
|
|
}
|
|
|
|
if matchedVersion > 0 {
|
|
return matchedVersion
|
|
}
|
|
if version > 0 {
|
|
return version + 1
|
|
}
|
|
return 1
|
|
}
|
|
|
|
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"]),
|
|
File: stringValue(value["file"]),
|
|
Line: intValue(value["line"]),
|
|
}
|
|
}
|
|
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")
|
|
}
|