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:
Snider 2026-03-18 14:03:06 +00:00
parent 85bdf26aa0
commit 53482cb0c8
154 changed files with 491 additions and 198 deletions

View file

@ -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
View 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