feat: workspace templates via Extract — Gosod pattern for agent dispatch
- Move pkg/prompts/lib → pkg/lib (prompt, task, flow, persona, workspace) - New lib.go: unified package with ExtractWorkspace() using text/template - Workspace templates: default, security, review — .tmpl files with data injection - prep.go: uses lib.ExtractWorkspace() + detect helpers for language/build/test - prompts.go: thin re-export wrapper for backwards compat Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
85bdf26aa0
commit
53482cb0c8
154 changed files with 491 additions and 198 deletions
|
|
@ -17,6 +17,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/agent/pkg/lib"
|
||||
"forge.lthn.ai/core/agent/pkg/prompts"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
|
|
@ -200,32 +201,51 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
// Remote stays as local clone origin — agent cannot push to forge.
|
||||
// Reviewer pulls changes from workspace and pushes after verification.
|
||||
|
||||
// 2. Copy CLAUDE.md and GEMINI.md to workspace
|
||||
// 2. Extract workspace template
|
||||
wsTmpl := "default"
|
||||
if input.Template == "security" {
|
||||
wsTmpl = "security"
|
||||
} else if input.Template == "verify" || input.Template == "conventions" {
|
||||
wsTmpl = "review"
|
||||
}
|
||||
|
||||
promptContent, _ := prompts.Prompt(input.Template)
|
||||
personaContent := ""
|
||||
if input.Persona != "" {
|
||||
personaContent, _ = prompts.Persona(input.Persona)
|
||||
}
|
||||
flowContent, _ := prompts.Flow(detectLanguage(repoPath))
|
||||
|
||||
wsData := &lib.WorkspaceData{
|
||||
Repo: input.Repo,
|
||||
Branch: branchName,
|
||||
Task: input.Task,
|
||||
Agent: "agent",
|
||||
Language: detectLanguage(repoPath),
|
||||
Prompt: promptContent,
|
||||
Persona: personaContent,
|
||||
Flow: flowContent,
|
||||
BuildCmd: detectBuildCmd(repoPath),
|
||||
TestCmd: detectTestCmd(repoPath),
|
||||
}
|
||||
|
||||
lib.ExtractWorkspace(wsTmpl, srcDir, wsData)
|
||||
out.ClaudeMd = true
|
||||
|
||||
// Copy repo's own CLAUDE.md over template if it exists
|
||||
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md")
|
||||
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data)
|
||||
out.ClaudeMd = true
|
||||
coreio.Local.Write(filepath.Join(srcDir, "CLAUDE.md"), data)
|
||||
}
|
||||
// Copy GEMINI.md from core/agent (ethics framework for all agents)
|
||||
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md")
|
||||
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data)
|
||||
coreio.Local.Write(filepath.Join(srcDir, "GEMINI.md"), data)
|
||||
}
|
||||
|
||||
// Copy persona if specified
|
||||
if input.Persona != "" {
|
||||
if data, err := prompts.Persona(input.Persona); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate TODO.md
|
||||
// 3. Generate TODO.md from issue (overrides template)
|
||||
if input.Issue > 0 {
|
||||
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
|
||||
} else if input.Task != "" {
|
||||
todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n",
|
||||
input.Task, input.Org, input.Repo, input.Task)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo)
|
||||
}
|
||||
|
||||
// 4. Generate CONTEXT.md from OpenBrain
|
||||
|
|
@ -565,3 +585,60 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
|
|||
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content)
|
||||
}
|
||||
|
||||
// detectLanguage guesses the primary language from repo contents.
|
||||
func detectLanguage(repoPath string) string {
|
||||
checks := map[string]string{
|
||||
"go.mod": "go",
|
||||
"composer.json": "php",
|
||||
"package.json": "ts",
|
||||
"Cargo.toml": "rust",
|
||||
"requirements.txt": "py",
|
||||
"CMakeLists.txt": "cpp",
|
||||
"Dockerfile": "docker",
|
||||
}
|
||||
for file, lang := range checks {
|
||||
if _, err := os.Stat(filepath.Join(repoPath, file)); err == nil {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
return "go"
|
||||
}
|
||||
|
||||
func detectBuildCmd(repoPath string) string {
|
||||
switch detectLanguage(repoPath) {
|
||||
case "go":
|
||||
return "go build ./..."
|
||||
case "php":
|
||||
return "composer install"
|
||||
case "ts":
|
||||
return "npm run build"
|
||||
case "py":
|
||||
return "pip install -e ."
|
||||
case "rust":
|
||||
return "cargo build"
|
||||
case "cpp":
|
||||
return "cmake --build ."
|
||||
default:
|
||||
return "go build ./..."
|
||||
}
|
||||
}
|
||||
|
||||
func detectTestCmd(repoPath string) string {
|
||||
switch detectLanguage(repoPath) {
|
||||
case "go":
|
||||
return "go test ./..."
|
||||
case "php":
|
||||
return "composer test"
|
||||
case "ts":
|
||||
return "npm test"
|
||||
case "py":
|
||||
return "pytest"
|
||||
case "rust":
|
||||
return "cargo test"
|
||||
case "cpp":
|
||||
return "ctest"
|
||||
default:
|
||||
return "go test ./..."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
229
pkg/lib/lib.go
Normal file
229
pkg/lib/lib.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package lib provides embedded content for agent dispatch.
|
||||
// Prompts, tasks, flows, personas, and workspace templates.
|
||||
//
|
||||
// Structure:
|
||||
//
|
||||
// prompt/ — System prompts (HOW to work)
|
||||
// task/ — Structured task plans (WHAT to do)
|
||||
// task/code/ — Code-specific tasks (review, refactor, etc.)
|
||||
// flow/ — Build/release workflows per language/tool
|
||||
// persona/ — Domain/role system prompts (WHO you are)
|
||||
// workspace/ — Agent workspace templates (WHERE to work)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// prompt, _ := lib.Prompt("coding")
|
||||
// task, _ := lib.Task("code/review")
|
||||
// persona, _ := lib.Persona("secops/developer")
|
||||
// flow, _ := lib.Flow("go")
|
||||
// lib.ExtractWorkspace("default", "/tmp/ws", data)
|
||||
package lib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed prompt/*.md
|
||||
var promptFS embed.FS
|
||||
|
||||
//go:embed all:task
|
||||
var taskFS embed.FS
|
||||
|
||||
//go:embed flow/*.md
|
||||
var flowFS embed.FS
|
||||
|
||||
//go:embed persona
|
||||
var personaFS embed.FS
|
||||
|
||||
//go:embed all:workspace
|
||||
var workspaceFS embed.FS
|
||||
|
||||
// --- Prompts ---
|
||||
|
||||
func Prompt(slug string) (string, error) {
|
||||
data, err := promptFS.ReadFile("prompt/" + slug + ".md")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func Task(slug string) (string, error) {
|
||||
for _, ext := range []string{".md", ".yaml", ".yml"} {
|
||||
data, err := taskFS.ReadFile("task/" + slug + ext)
|
||||
if err == nil {
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
return "", fs.ErrNotExist
|
||||
}
|
||||
|
||||
func TaskBundle(slug string) (string, map[string]string, error) {
|
||||
main, err := Task(slug)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
bundleDir := "task/" + slug
|
||||
entries, err := fs.ReadDir(taskFS, bundleDir)
|
||||
if err != nil {
|
||||
return main, nil, nil
|
||||
}
|
||||
bundle := make(map[string]string)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := taskFS.ReadFile(bundleDir + "/" + e.Name())
|
||||
if err == nil {
|
||||
bundle[e.Name()] = string(data)
|
||||
}
|
||||
}
|
||||
return main, bundle, nil
|
||||
}
|
||||
|
||||
func Flow(slug string) (string, error) {
|
||||
data, err := flowFS.ReadFile("flow/" + slug + ".md")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func Persona(path string) (string, error) {
|
||||
data, err := personaFS.ReadFile("persona/" + path + ".md")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// --- Workspace Templates ---
|
||||
|
||||
// WorkspaceData is the data passed to workspace templates.
|
||||
type WorkspaceData struct {
|
||||
Repo string
|
||||
Branch string
|
||||
Task string
|
||||
Agent string
|
||||
Language string
|
||||
Prompt string
|
||||
Persona string
|
||||
Flow string
|
||||
Context string
|
||||
Recent string
|
||||
Dependencies string
|
||||
Conventions string
|
||||
RepoDescription string
|
||||
BuildCmd string
|
||||
TestCmd string
|
||||
}
|
||||
|
||||
// ExtractWorkspace creates an agent workspace from a template.
|
||||
// Template names: "default", "security", "review".
|
||||
func ExtractWorkspace(tmplName, targetDir string, data *WorkspaceData) error {
|
||||
wsDir := "workspace/" + tmplName
|
||||
entries, err := fs.ReadDir(workspaceFS, wsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
content, err := fs.ReadFile(workspaceFS, wsDir+"/"+name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process .tmpl files through text/template
|
||||
outputName := name
|
||||
if strings.HasSuffix(name, ".tmpl") {
|
||||
outputName = strings.TrimSuffix(name, ".tmpl")
|
||||
tmpl, err := template.New(name).Parse(string(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return err
|
||||
}
|
||||
content = buf.Bytes()
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(targetDir, outputName), content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- List Functions ---
|
||||
|
||||
func ListPrompts() []string { return listDir(promptFS, "prompt") }
|
||||
func ListFlows() []string { return listDir(flowFS, "flow") }
|
||||
func ListWorkspaces() []string { return listDir(workspaceFS, "workspace") }
|
||||
|
||||
func ListTasks() []string {
|
||||
var slugs []string
|
||||
fs.WalkDir(taskFS, "task", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel := strings.TrimPrefix(path, "task/")
|
||||
ext := filepath.Ext(rel)
|
||||
slugs = append(slugs, strings.TrimSuffix(rel, ext))
|
||||
return nil
|
||||
})
|
||||
return slugs
|
||||
}
|
||||
|
||||
func ListPersonas() []string {
|
||||
var paths []string
|
||||
fs.WalkDir(personaFS, "persona", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".md") {
|
||||
rel := strings.TrimPrefix(path, "persona/")
|
||||
rel = strings.TrimSuffix(rel, ".md")
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return paths
|
||||
}
|
||||
|
||||
func listDir(fsys embed.FS, dir string) []string {
|
||||
entries, err := fsys.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var slugs []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
name := e.Name()
|
||||
slugs = append(slugs, name)
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
ext := filepath.Ext(name)
|
||||
slugs = append(slugs, strings.TrimSuffix(name, ext))
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue