Fresh installs and rig adds were creating full CLAUDE.md files (285 lines for mayor, ~100 lines for other roles), causing gt doctor to fail the priming check immediately. Per the priming architecture, CLAUDE.md should be a minimal bootstrap pointer (<30 lines) that tells agents to run gt prime. Full context is injected ephemerally at session start. Changes: - install.go: createMayorCLAUDEmd now writes 12-line bootstrap pointer - manager.go: createRoleCLAUDEmd now writes role-specific bootstrap pointers for mayor, refinery, crew, and polecat roles Note: The AGENTS.md issue mentioned in #316 could not be reproduced - the code does not appear to create AGENTS.md at rig level. May be from an older version or different configuration. Partial fix for #316 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1141 lines
38 KiB
Go
1141 lines
38 KiB
Go
package rig
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/claude"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/git"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrRigNotFound = errors.New("rig not found")
|
|
ErrRigExists = errors.New("rig already exists")
|
|
)
|
|
|
|
// RigConfig represents the rig-level configuration (config.json at rig root).
|
|
type RigConfig struct {
|
|
Type string `json:"type"` // "rig"
|
|
Version int `json:"version"` // schema version
|
|
Name string `json:"name"` // rig name
|
|
GitURL string `json:"git_url"` // repository URL
|
|
LocalRepo string `json:"local_repo,omitempty"` // optional local reference repo
|
|
DefaultBranch string `json:"default_branch,omitempty"` // main, master, etc.
|
|
CreatedAt time.Time `json:"created_at"` // when rig was created
|
|
Beads *BeadsConfig `json:"beads,omitempty"`
|
|
}
|
|
|
|
// BeadsConfig represents beads configuration for the rig.
|
|
type BeadsConfig struct {
|
|
Prefix string `json:"prefix"` // issue prefix (e.g., "gt")
|
|
SyncRemote string `json:"sync_remote,omitempty"` // git remote for bd sync
|
|
}
|
|
|
|
// CurrentRigConfigVersion is the current schema version.
|
|
const CurrentRigConfigVersion = 1
|
|
|
|
// Manager handles rig discovery, loading, and creation.
|
|
type Manager struct {
|
|
townRoot string
|
|
config *config.RigsConfig
|
|
git *git.Git
|
|
}
|
|
|
|
// NewManager creates a new rig manager.
|
|
func NewManager(townRoot string, rigsConfig *config.RigsConfig, g *git.Git) *Manager {
|
|
return &Manager{
|
|
townRoot: townRoot,
|
|
config: rigsConfig,
|
|
git: g,
|
|
}
|
|
}
|
|
|
|
// DiscoverRigs returns all rigs registered in the workspace.
|
|
// Rigs that fail to load are logged to stderr and skipped; partial results are returned.
|
|
func (m *Manager) DiscoverRigs() ([]*Rig, error) {
|
|
var rigs []*Rig
|
|
|
|
for name, entry := range m.config.Rigs {
|
|
rig, err := m.loadRig(name, entry)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to load rig %q: %v\n", name, err)
|
|
continue
|
|
}
|
|
rigs = append(rigs, rig)
|
|
}
|
|
|
|
return rigs, nil
|
|
}
|
|
|
|
// GetRig returns a specific rig by name.
|
|
func (m *Manager) GetRig(name string) (*Rig, error) {
|
|
entry, ok := m.config.Rigs[name]
|
|
if !ok {
|
|
return nil, ErrRigNotFound
|
|
}
|
|
|
|
return m.loadRig(name, entry)
|
|
}
|
|
|
|
// RigExists checks if a rig is registered.
|
|
func (m *Manager) RigExists(name string) bool {
|
|
_, ok := m.config.Rigs[name]
|
|
return ok
|
|
}
|
|
|
|
// loadRig loads rig details from the filesystem.
|
|
func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) {
|
|
rigPath := filepath.Join(m.townRoot, name)
|
|
|
|
// Verify directory exists
|
|
info, err := os.Stat(rigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rig directory: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("not a directory: %s", rigPath)
|
|
}
|
|
|
|
rig := &Rig{
|
|
Name: name,
|
|
Path: rigPath,
|
|
GitURL: entry.GitURL,
|
|
LocalRepo: entry.LocalRepo,
|
|
Config: entry.BeadsConfig,
|
|
}
|
|
|
|
// Scan for polecats
|
|
polecatsDir := filepath.Join(rigPath, "polecats")
|
|
if entries, err := os.ReadDir(polecatsDir); err == nil {
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
rig.Polecats = append(rig.Polecats, name)
|
|
}
|
|
}
|
|
|
|
// Scan for crew workers
|
|
crewDir := filepath.Join(rigPath, "crew")
|
|
if entries, err := os.ReadDir(crewDir); err == nil {
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
rig.Crew = append(rig.Crew, e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for witness (witnesses don't have clones, just the witness directory)
|
|
witnessPath := filepath.Join(rigPath, "witness")
|
|
if info, err := os.Stat(witnessPath); err == nil && info.IsDir() {
|
|
rig.HasWitness = true
|
|
}
|
|
|
|
// Check for refinery
|
|
refineryPath := filepath.Join(rigPath, "refinery", "rig")
|
|
if _, err := os.Stat(refineryPath); err == nil {
|
|
rig.HasRefinery = true
|
|
}
|
|
|
|
// Check for mayor clone
|
|
mayorPath := filepath.Join(rigPath, "mayor", "rig")
|
|
if _, err := os.Stat(mayorPath); err == nil {
|
|
rig.HasMayor = true
|
|
}
|
|
|
|
return rig, nil
|
|
}
|
|
|
|
// AddRigOptions configures rig creation.
|
|
type AddRigOptions struct {
|
|
Name string // Rig name (directory name)
|
|
GitURL string // Repository URL
|
|
BeadsPrefix string // Beads issue prefix (defaults to derived from name)
|
|
LocalRepo string // Optional local repo for reference clones
|
|
DefaultBranch string // Default branch (defaults to auto-detected from remote)
|
|
}
|
|
|
|
func resolveLocalRepo(path, gitURL string) (string, string) {
|
|
if path == "" {
|
|
return "", ""
|
|
}
|
|
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", fmt.Sprintf("local repo path invalid: %v", err)
|
|
}
|
|
|
|
absPath, err = filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
return "", fmt.Sprintf("local repo path invalid: %v", err)
|
|
}
|
|
|
|
repoGit := git.NewGit(absPath)
|
|
if !repoGit.IsRepo() {
|
|
return "", fmt.Sprintf("local repo is not a git repository: %s", absPath)
|
|
}
|
|
|
|
origin, err := repoGit.RemoteURL("origin")
|
|
if err != nil {
|
|
return absPath, "local repo has no origin; using it anyway"
|
|
}
|
|
if origin != gitURL {
|
|
return "", fmt.Sprintf("local repo origin %q does not match %q", origin, gitURL)
|
|
}
|
|
|
|
return absPath, ""
|
|
}
|
|
|
|
// AddRig creates a new rig as a container with clones for each agent.
|
|
// The rig structure is:
|
|
//
|
|
// <name>/ # Container (NOT a git clone)
|
|
// ├── config.json # Rig configuration
|
|
// ├── .beads/ # Rig-level issue tracking
|
|
// ├── refinery/rig/ # Canonical main clone
|
|
// ├── mayor/rig/ # Mayor's working clone
|
|
// ├── witness/ # Witness agent (no clone)
|
|
// ├── polecats/ # Worker directories (empty)
|
|
// └── crew/<crew>/ # Default human workspace
|
|
func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
|
if m.RigExists(opts.Name) {
|
|
return nil, ErrRigExists
|
|
}
|
|
|
|
// Validate rig name: reject characters that break agent ID parsing
|
|
// Agent IDs use format <prefix>-<rig>-<role>[-<name>] with hyphens as delimiters
|
|
if strings.ContainsAny(opts.Name, "-. ") {
|
|
sanitized := strings.NewReplacer("-", "_", ".", "_", " ", "_").Replace(opts.Name)
|
|
sanitized = strings.ToLower(sanitized)
|
|
return nil, fmt.Errorf("rig name %q contains invalid characters; hyphens, dots, and spaces are reserved for agent ID parsing. Try %q instead (underscores are allowed)", opts.Name, sanitized)
|
|
}
|
|
|
|
rigPath := filepath.Join(m.townRoot, opts.Name)
|
|
|
|
// Check if directory already exists
|
|
if _, err := os.Stat(rigPath); err == nil {
|
|
return nil, fmt.Errorf("directory already exists: %s", rigPath)
|
|
}
|
|
|
|
// Derive defaults
|
|
if opts.BeadsPrefix == "" {
|
|
opts.BeadsPrefix = deriveBeadsPrefix(opts.Name)
|
|
}
|
|
|
|
localRepo, warn := resolveLocalRepo(opts.LocalRepo, opts.GitURL)
|
|
if warn != "" {
|
|
fmt.Printf(" Warning: %s\n", warn)
|
|
}
|
|
|
|
// Create container directory
|
|
if err := os.MkdirAll(rigPath, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating rig directory: %w", err)
|
|
}
|
|
|
|
// Track cleanup on failure (best-effort cleanup)
|
|
cleanup := func() { _ = os.RemoveAll(rigPath) }
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
cleanup()
|
|
}
|
|
}()
|
|
|
|
// Create rig config
|
|
rigConfig := &RigConfig{
|
|
Type: "rig",
|
|
Version: CurrentRigConfigVersion,
|
|
Name: opts.Name,
|
|
GitURL: opts.GitURL,
|
|
LocalRepo: localRepo,
|
|
CreatedAt: time.Now(),
|
|
Beads: &BeadsConfig{
|
|
Prefix: opts.BeadsPrefix,
|
|
},
|
|
}
|
|
if err := m.saveRigConfig(rigPath, rigConfig); err != nil {
|
|
return nil, fmt.Errorf("saving rig config: %w", err)
|
|
}
|
|
|
|
// Create shared bare repo as source of truth for refinery and polecats.
|
|
// This allows refinery to see polecat branches without pushing to remote.
|
|
// Mayor remains a separate clone (doesn't need branch visibility).
|
|
fmt.Printf(" Cloning repository (this may take a moment)...\n")
|
|
bareRepoPath := filepath.Join(rigPath, ".repo.git")
|
|
if localRepo != "" {
|
|
if err := m.git.CloneBareWithReference(opts.GitURL, bareRepoPath, localRepo); err != nil {
|
|
fmt.Printf(" Warning: could not use local repo reference: %v\n", err)
|
|
_ = os.RemoveAll(bareRepoPath)
|
|
if err := m.git.CloneBare(opts.GitURL, bareRepoPath); err != nil {
|
|
return nil, fmt.Errorf("creating bare repo: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
if err := m.git.CloneBare(opts.GitURL, bareRepoPath); err != nil {
|
|
return nil, fmt.Errorf("creating bare repo: %w", err)
|
|
}
|
|
}
|
|
fmt.Printf(" ✓ Created shared bare repo\n")
|
|
bareGit := git.NewGitWithDir(bareRepoPath, "")
|
|
|
|
// Determine default branch: use provided value or auto-detect from remote
|
|
var defaultBranch string
|
|
if opts.DefaultBranch != "" {
|
|
defaultBranch = opts.DefaultBranch
|
|
} else {
|
|
// Try to get default branch from remote first, fall back to local detection
|
|
defaultBranch = bareGit.RemoteDefaultBranch()
|
|
if defaultBranch == "" {
|
|
defaultBranch = bareGit.DefaultBranch()
|
|
}
|
|
}
|
|
rigConfig.DefaultBranch = defaultBranch
|
|
// Re-save config with default branch
|
|
if err := m.saveRigConfig(rigPath, rigConfig); err != nil {
|
|
return nil, fmt.Errorf("updating rig config with default branch: %w", err)
|
|
}
|
|
|
|
// Create mayor as regular clone (separate from bare repo).
|
|
// Mayor doesn't need to see polecat branches - that's refinery's job.
|
|
// This also allows mayor to stay on the default branch without conflicting with refinery.
|
|
fmt.Printf(" Creating mayor clone...\n")
|
|
mayorRigPath := filepath.Join(rigPath, "mayor", "rig")
|
|
if err := os.MkdirAll(filepath.Dir(mayorRigPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("creating mayor dir: %w", err)
|
|
}
|
|
if localRepo != "" {
|
|
if err := m.git.CloneWithReference(opts.GitURL, mayorRigPath, localRepo); err != nil {
|
|
fmt.Printf(" Warning: could not use local repo reference: %v\n", err)
|
|
_ = os.RemoveAll(mayorRigPath)
|
|
if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil {
|
|
return nil, fmt.Errorf("cloning for mayor: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil {
|
|
return nil, fmt.Errorf("cloning for mayor: %w", err)
|
|
}
|
|
}
|
|
|
|
// Checkout the default branch for mayor (clone defaults to remote's HEAD, not our configured branch)
|
|
mayorGit := git.NewGitWithDir("", mayorRigPath)
|
|
if err := mayorGit.Checkout(defaultBranch); err != nil {
|
|
return nil, fmt.Errorf("checking out default branch for mayor: %w", err)
|
|
}
|
|
fmt.Printf(" ✓ Created mayor clone\n")
|
|
|
|
// Check if source repo has .beads/ with its own prefix - if so, use that prefix.
|
|
// This ensures we use the project's existing beads database instead of creating a new one.
|
|
// Without this, routing would fail when trying to access existing issues because the
|
|
// rig config would have a different prefix than what the issues actually use.
|
|
sourceBeadsConfig := filepath.Join(mayorRigPath, ".beads", "config.yaml")
|
|
if _, err := os.Stat(sourceBeadsConfig); err == nil {
|
|
if sourcePrefix := detectBeadsPrefixFromConfig(sourceBeadsConfig); sourcePrefix != "" {
|
|
fmt.Printf(" Detected existing beads prefix '%s' from source repo\n", sourcePrefix)
|
|
opts.BeadsPrefix = sourcePrefix
|
|
rigConfig.Beads.Prefix = sourcePrefix
|
|
// Re-save rig config with detected prefix
|
|
if err := m.saveRigConfig(rigPath, rigConfig); err != nil {
|
|
return nil, fmt.Errorf("updating rig config with detected prefix: %w", err)
|
|
}
|
|
// Initialize bd database with the detected prefix.
|
|
// beads.db is gitignored so it doesn't exist after clone - we need to create it.
|
|
// bd init --prefix will create the database and auto-import from issues.jsonl.
|
|
sourceBeadsDB := filepath.Join(mayorRigPath, ".beads", "beads.db")
|
|
if _, err := os.Stat(sourceBeadsDB); os.IsNotExist(err) {
|
|
cmd := exec.Command("bd", "init", "--prefix", sourcePrefix) // sourcePrefix validated by isValidBeadsPrefix
|
|
cmd.Dir = mayorRigPath
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output)))
|
|
}
|
|
// Configure custom types for Gas Town (beads v0.46.0+)
|
|
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
|
configCmd.Dir = mayorRigPath
|
|
_, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create mayor CLAUDE.md (overrides any from cloned repo)
|
|
if err := m.createRoleCLAUDEmd(mayorRigPath, "mayor", opts.Name, ""); err != nil {
|
|
return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err)
|
|
}
|
|
|
|
// Initialize beads at rig level BEFORE creating worktrees.
|
|
// This ensures rig/.beads exists so worktree redirects can point to it.
|
|
fmt.Printf(" Initializing beads database...\n")
|
|
if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil {
|
|
return nil, fmt.Errorf("initializing beads: %w", err)
|
|
}
|
|
fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix)
|
|
|
|
// Provision PRIME.md with Gas Town context for all workers in this rig.
|
|
// This is the fallback if SessionStart hook fails - ensures ALL workers
|
|
// (crew, polecats, refinery, witness) have GUPP and essential Gas Town context.
|
|
// PRIME.md is read by bd prime and output to the agent.
|
|
rigBeadsPath := filepath.Join(rigPath, ".beads")
|
|
if err := beads.ProvisionPrimeMD(rigBeadsPath); err != nil {
|
|
fmt.Printf(" Warning: Could not provision PRIME.md: %v\n", err)
|
|
}
|
|
|
|
// Create refinery as worktree from bare repo on default branch.
|
|
// Refinery needs to see polecat branches (shared .repo.git) and merges them.
|
|
// Being on the default branch allows direct merge workflow.
|
|
fmt.Printf(" Creating refinery worktree...\n")
|
|
refineryRigPath := filepath.Join(rigPath, "refinery", "rig")
|
|
if err := os.MkdirAll(filepath.Dir(refineryRigPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("creating refinery dir: %w", err)
|
|
}
|
|
if err := bareGit.WorktreeAddExisting(refineryRigPath, defaultBranch); err != nil {
|
|
return nil, fmt.Errorf("creating refinery worktree: %w", err)
|
|
}
|
|
fmt.Printf(" ✓ Created refinery worktree\n")
|
|
// Set up beads redirect for refinery (points to rig-level .beads)
|
|
if err := beads.SetupRedirect(m.townRoot, refineryRigPath); err != nil {
|
|
fmt.Printf(" Warning: Could not set up refinery beads redirect: %v\n", err)
|
|
}
|
|
// Create refinery CLAUDE.md (overrides any from cloned repo)
|
|
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
|
|
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
|
|
}
|
|
// Create refinery hooks for patrol triggering (at refinery/ level, not rig/)
|
|
refineryPath := filepath.Dir(refineryRigPath)
|
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
|
if err := m.createPatrolHooks(refineryPath, runtimeConfig); err != nil {
|
|
fmt.Printf(" Warning: Could not create refinery hooks: %v\n", err)
|
|
}
|
|
|
|
// Create empty crew directory with README (crew members added via gt crew add)
|
|
crewPath := filepath.Join(rigPath, "crew")
|
|
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating crew dir: %w", err)
|
|
}
|
|
// Create README with instructions
|
|
readmePath := filepath.Join(crewPath, "README.md")
|
|
readmeContent := `# Crew Directory
|
|
|
|
This directory contains crew worker workspaces.
|
|
|
|
## Adding a Crew Member
|
|
|
|
` + "```bash" + `
|
|
gt crew add <name> # Creates crew/<name>/ with a git clone
|
|
` + "```" + `
|
|
|
|
## Crew vs Polecats
|
|
|
|
- **Crew**: Persistent, user-managed workspaces (never auto-garbage-collected)
|
|
- **Polecats**: Transient, witness-managed workers (cleaned up after work completes)
|
|
|
|
Use crew for your own workspace. Polecats are for batch work dispatch.
|
|
`
|
|
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
|
|
return nil, fmt.Errorf("creating crew README: %w", err)
|
|
}
|
|
|
|
// Create witness directory (no clone needed)
|
|
witnessPath := filepath.Join(rigPath, "witness")
|
|
if err := os.MkdirAll(witnessPath, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating witness dir: %w", err)
|
|
}
|
|
// Create witness hooks for patrol triggering
|
|
if err := m.createPatrolHooks(witnessPath, runtimeConfig); err != nil {
|
|
fmt.Printf(" Warning: Could not create witness hooks: %v\n", err)
|
|
}
|
|
|
|
// Create polecats directory (empty)
|
|
polecatsPath := filepath.Join(rigPath, "polecats")
|
|
if err := os.MkdirAll(polecatsPath, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating polecats dir: %w", err)
|
|
}
|
|
|
|
// Install Claude settings for all agent directories.
|
|
// Settings are placed in parent directories (not inside git repos) so Claude
|
|
// finds them via directory traversal without polluting source repos.
|
|
fmt.Printf(" Installing Claude settings...\n")
|
|
settingsRoles := []struct {
|
|
dir string
|
|
role string
|
|
}{
|
|
{witnessPath, "witness"},
|
|
{filepath.Join(rigPath, "refinery"), "refinery"},
|
|
{crewPath, "crew"},
|
|
{polecatsPath, "polecat"},
|
|
}
|
|
for _, sr := range settingsRoles {
|
|
if err := claude.EnsureSettingsForRole(sr.dir, sr.role); err != nil {
|
|
fmt.Fprintf(os.Stderr, " Warning: Could not create %s settings: %v\n", sr.role, err)
|
|
}
|
|
}
|
|
fmt.Printf(" ✓ Installed Claude settings\n")
|
|
|
|
// Initialize beads at rig level
|
|
fmt.Printf(" Initializing beads database...\n")
|
|
if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil {
|
|
return nil, fmt.Errorf("initializing beads: %w", err)
|
|
}
|
|
fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix)
|
|
|
|
// Create rig-level agent beads (witness, refinery) in rig beads.
|
|
// Town-level agents (mayor, deacon) are created by gt install in town beads.
|
|
if err := m.initAgentBeads(rigPath, opts.Name, opts.BeadsPrefix); err != nil {
|
|
// Non-fatal: log warning but continue
|
|
fmt.Fprintf(os.Stderr, " Warning: Could not create agent beads: %v\n", err)
|
|
}
|
|
|
|
// Seed patrol molecules for this rig
|
|
if err := m.seedPatrolMolecules(rigPath); err != nil {
|
|
// Non-fatal: log warning but continue
|
|
fmt.Fprintf(os.Stderr, " Warning: Could not seed patrol molecules: %v\n", err)
|
|
}
|
|
|
|
// Create plugin directories
|
|
if err := m.createPluginDirectories(rigPath); err != nil {
|
|
// Non-fatal: log warning but continue
|
|
fmt.Fprintf(os.Stderr, " Warning: Could not create plugin directories: %v\n", err)
|
|
}
|
|
|
|
// Register in town config
|
|
m.config.Rigs[opts.Name] = config.RigEntry{
|
|
GitURL: opts.GitURL,
|
|
LocalRepo: localRepo,
|
|
AddedAt: time.Now(),
|
|
BeadsConfig: &config.BeadsConfig{
|
|
Prefix: opts.BeadsPrefix,
|
|
},
|
|
}
|
|
|
|
success = true
|
|
return m.loadRig(opts.Name, m.config.Rigs[opts.Name])
|
|
}
|
|
|
|
// saveRigConfig writes the rig configuration to config.json.
|
|
func (m *Manager) saveRigConfig(rigPath string, cfg *RigConfig) error {
|
|
configPath := filepath.Join(rigPath, "config.json")
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(configPath, data, 0644)
|
|
}
|
|
|
|
// LoadRigConfig reads the rig configuration from config.json.
|
|
func LoadRigConfig(rigPath string) (*RigConfig, error) {
|
|
configPath := filepath.Join(rigPath, "config.json")
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var cfg RigConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// initBeads initializes the beads database at rig level.
|
|
// The project's .beads/config.yaml determines sync-branch settings.
|
|
// Use `bd doctor --fix` in the project to configure sync-branch if needed.
|
|
// TODO(bd-yaml): beads config should migrate to JSON (see beads issue)
|
|
func (m *Manager) initBeads(rigPath, prefix string) error {
|
|
// Validate prefix format to prevent command injection from config files
|
|
if !isValidBeadsPrefix(prefix) {
|
|
return fmt.Errorf("invalid beads prefix %q: must be alphanumeric with optional hyphens, start with letter, max 20 chars", prefix)
|
|
}
|
|
|
|
beadsDir := filepath.Join(rigPath, ".beads")
|
|
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
|
|
|
|
// Check if source repo has tracked .beads/ (cloned into mayor/rig).
|
|
// If so, create a redirect file instead of a new database.
|
|
if _, err := os.Stat(mayorRigBeads); err == nil {
|
|
// Tracked beads exist - create redirect to mayor/rig/.beads
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil {
|
|
return fmt.Errorf("creating redirect file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// No tracked beads - create local database
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build environment with explicit BEADS_DIR to prevent bd from
|
|
// finding a parent directory's .beads/ database
|
|
env := os.Environ()
|
|
filteredEnv := make([]string, 0, len(env)+1)
|
|
for _, e := range env {
|
|
if !strings.HasPrefix(e, "BEADS_DIR=") {
|
|
filteredEnv = append(filteredEnv, e)
|
|
}
|
|
}
|
|
filteredEnv = append(filteredEnv, "BEADS_DIR="+beadsDir)
|
|
|
|
// Run bd init if available
|
|
cmd := exec.Command("bd", "init", "--prefix", prefix)
|
|
cmd.Dir = rigPath
|
|
cmd.Env = filteredEnv
|
|
_, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// bd might not be installed or failed, create minimal structure
|
|
// Note: beads currently expects YAML format for config
|
|
configPath := filepath.Join(beadsDir, "config.yaml")
|
|
configContent := fmt.Sprintf("prefix: %s\n", prefix)
|
|
if writeErr := os.WriteFile(configPath, []byte(configContent), 0644); writeErr != nil {
|
|
return writeErr
|
|
}
|
|
}
|
|
|
|
// Configure custom types for Gas Town (agent, role, rig, convoy).
|
|
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
|
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
|
configCmd.Dir = rigPath
|
|
configCmd.Env = filteredEnv
|
|
// Ignore errors - older beads versions don't need this
|
|
_, _ = configCmd.CombinedOutput()
|
|
|
|
// Ensure database has repository fingerprint (GH #25).
|
|
// This is idempotent - safe on both new and legacy (pre-0.17.5) databases.
|
|
// Without fingerprint, the bd daemon fails to start silently.
|
|
migrateCmd := exec.Command("bd", "migrate", "--update-repo-id")
|
|
migrateCmd.Dir = rigPath
|
|
migrateCmd.Env = filteredEnv
|
|
// Ignore errors - fingerprint is optional for functionality
|
|
_, _ = migrateCmd.CombinedOutput()
|
|
|
|
// Add route from rig beads to town beads for cross-database resolution.
|
|
// This allows rig beads to resolve hq-* prefixed beads (role beads, etc.)
|
|
// that are stored in town beads.
|
|
townRoute := beads.Route{Prefix: "hq-", Path: ".."}
|
|
if err := beads.AppendRouteToDir(beadsDir, townRoute); err != nil {
|
|
// Non-fatal: role slot set will fail but agent beads still work
|
|
fmt.Printf(" ⚠ Could not add route to town beads: %v\n", err)
|
|
}
|
|
|
|
typesCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
|
typesCmd.Dir = rigPath
|
|
typesCmd.Env = filteredEnv
|
|
_, _ = typesCmd.CombinedOutput()
|
|
|
|
return nil
|
|
}
|
|
|
|
// initAgentBeads creates rig-level agent beads for Witness and Refinery.
|
|
// These agents use the rig's beads prefix and are stored in rig beads.
|
|
//
|
|
// Town-level agents (Mayor, Deacon) are created by gt install in town beads.
|
|
// Role beads are also created by gt install with hq- prefix.
|
|
//
|
|
// Rig-level agents (Witness, Refinery) are created here in rig beads with rig prefix.
|
|
// Format: <prefix>-<rig>-<role> (e.g., pi-pixelforge-witness)
|
|
//
|
|
// Agent beads track lifecycle state for ZFC compliance (gt-h3hak, gt-pinkq).
|
|
func (m *Manager) initAgentBeads(rigPath, rigName, prefix string) error {
|
|
// Rig-level agents go in rig beads with rig prefix (per docs/architecture.md).
|
|
// Town-level agents (Mayor, Deacon) are created by gt install in town beads.
|
|
// Use ResolveBeadsDir to follow redirect files for tracked beads.
|
|
rigBeadsDir := beads.ResolveBeadsDir(rigPath)
|
|
bd := beads.NewWithBeadsDir(rigPath, rigBeadsDir)
|
|
|
|
// Define rig-level agents to create
|
|
type agentDef struct {
|
|
id string
|
|
roleType string
|
|
rig string
|
|
desc string
|
|
}
|
|
|
|
// Create rig-specific agents using rig prefix in rig beads.
|
|
// Format: <prefix>-<rig>-<role> (e.g., pi-pixelforge-witness)
|
|
agents := []agentDef{
|
|
{
|
|
id: beads.WitnessBeadIDWithPrefix(prefix, rigName),
|
|
roleType: "witness",
|
|
rig: rigName,
|
|
desc: fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName),
|
|
},
|
|
{
|
|
id: beads.RefineryBeadIDWithPrefix(prefix, rigName),
|
|
roleType: "refinery",
|
|
rig: rigName,
|
|
desc: fmt.Sprintf("Refinery for %s - processes merge queue.", rigName),
|
|
},
|
|
}
|
|
|
|
// Note: Mayor and Deacon are now created by gt install in town beads.
|
|
|
|
for _, agent := range agents {
|
|
// Check if already exists
|
|
if _, err := bd.Show(agent.id); err == nil {
|
|
continue // Already exists
|
|
}
|
|
|
|
// RoleBead points to the shared role definition bead for this agent type.
|
|
// Role beads are in town beads with hq- prefix (e.g., hq-witness-role).
|
|
fields := &beads.AgentFields{
|
|
RoleType: agent.roleType,
|
|
Rig: agent.rig,
|
|
AgentState: "idle",
|
|
HookBead: "",
|
|
RoleBead: beads.RoleBeadIDTown(agent.roleType),
|
|
}
|
|
|
|
if _, err := bd.CreateAgentBead(agent.id, agent.desc, fields); err != nil {
|
|
return fmt.Errorf("creating %s: %w", agent.id, err)
|
|
}
|
|
fmt.Printf(" ✓ Created agent bead: %s\n", agent.id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist.
|
|
func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error {
|
|
// Read existing content
|
|
content, err := os.ReadFile(gitignorePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
// Check if entry already exists
|
|
lines := strings.Split(string(content), "\n")
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == entry {
|
|
return nil // Already present
|
|
}
|
|
}
|
|
|
|
// Append entry
|
|
f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302: .gitignore should be readable by git tools
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
// Add newline before if file doesn't end with one
|
|
if len(content) > 0 && content[len(content)-1] != '\n' {
|
|
if _, err := f.WriteString("\n"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = f.WriteString(entry + "\n")
|
|
return err
|
|
}
|
|
|
|
// deriveBeadsPrefix generates a beads prefix from a rig name.
|
|
// Examples: "gastown" -> "gt", "my-project" -> "mp", "foo" -> "foo"
|
|
func deriveBeadsPrefix(name string) string {
|
|
// Remove common suffixes
|
|
name = strings.TrimSuffix(name, "-py")
|
|
name = strings.TrimSuffix(name, "-go")
|
|
|
|
// Split on hyphens/underscores
|
|
parts := strings.FieldsFunc(name, func(r rune) bool {
|
|
return r == '-' || r == '_'
|
|
})
|
|
|
|
// If single part, try to detect compound words (e.g., "gastown" -> "gas" + "town")
|
|
if len(parts) == 1 {
|
|
parts = splitCompoundWord(parts[0])
|
|
}
|
|
|
|
if len(parts) >= 2 {
|
|
// Take first letter of each part: "gas-town" -> "gt"
|
|
prefix := ""
|
|
for _, p := range parts {
|
|
if len(p) > 0 {
|
|
prefix += string(p[0])
|
|
}
|
|
}
|
|
return strings.ToLower(prefix)
|
|
}
|
|
|
|
// Single word: use first 2-3 chars
|
|
if len(name) <= 3 {
|
|
return strings.ToLower(name)
|
|
}
|
|
return strings.ToLower(name[:2])
|
|
}
|
|
|
|
// splitCompoundWord attempts to split a compound word into its components.
|
|
// Common suffixes like "town", "ville", "port" are detected to split
|
|
// compound names (e.g., "gastown" -> ["gas", "town"]).
|
|
func splitCompoundWord(word string) []string {
|
|
word = strings.ToLower(word)
|
|
|
|
// Common suffixes for compound place names
|
|
suffixes := []string{"town", "ville", "port", "place", "land", "field", "wood", "ford"}
|
|
|
|
for _, suffix := range suffixes {
|
|
if strings.HasSuffix(word, suffix) && len(word) > len(suffix) {
|
|
prefix := word[:len(word)-len(suffix)]
|
|
if len(prefix) > 0 {
|
|
return []string{prefix, suffix}
|
|
}
|
|
}
|
|
}
|
|
|
|
return []string{word}
|
|
}
|
|
|
|
// detectBeadsPrefixFromConfig reads the issue prefix from a beads config.yaml file.
|
|
// Returns empty string if the file doesn't exist or doesn't contain a prefix.
|
|
// Falls back to detecting prefix from existing issues in issues.jsonl.
|
|
//
|
|
// beadsPrefixRegexp validates beads prefix format: alphanumeric, may contain hyphens,
|
|
// must start with letter, max 20 chars. Prevents shell injection via config files.
|
|
var beadsPrefixRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]{0,19}$`)
|
|
|
|
// isValidBeadsPrefix checks if a prefix is safe for use in shell commands.
|
|
// Prefixes must be alphanumeric (with optional hyphens), start with a letter,
|
|
// and be at most 20 characters. This prevents command injection from
|
|
// malicious config files.
|
|
func isValidBeadsPrefix(prefix string) bool {
|
|
return beadsPrefixRegexp.MatchString(prefix)
|
|
}
|
|
|
|
// When adding a rig from a source repo that has .beads/ tracked in git (like a project
|
|
// that already uses beads for issue tracking), we need to use that project's existing
|
|
// prefix instead of generating a new one. Otherwise, the rig would have a mismatched
|
|
// prefix and routing would fail to find the existing issues.
|
|
func detectBeadsPrefixFromConfig(configPath string) string {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Parse YAML-style config (simple line-by-line parsing)
|
|
// Looking for "issue-prefix: <value>" or "prefix: <value>"
|
|
lines := strings.Split(string(data), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
// Skip comments and empty lines
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
// Check for issue-prefix or prefix key
|
|
for _, key := range []string{"issue-prefix:", "prefix:"} {
|
|
if strings.HasPrefix(line, key) {
|
|
value := strings.TrimSpace(strings.TrimPrefix(line, key))
|
|
// Remove quotes if present
|
|
value = strings.Trim(value, `"'`)
|
|
if value != "" && isValidBeadsPrefix(value) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try to detect prefix from existing issues in issues.jsonl
|
|
// Look for the first issue ID pattern like "gt-abc123"
|
|
beadsDir := filepath.Dir(configPath)
|
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if issuesData, err := os.ReadFile(issuesPath); err == nil {
|
|
issuesLines := strings.Split(string(issuesData), "\n")
|
|
for _, line := range issuesLines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Look for "id":"<prefix>-<hash>" pattern
|
|
if idx := strings.Index(line, `"id":"`); idx != -1 {
|
|
start := idx + 6 // len(`"id":"`)
|
|
if end := strings.Index(line[start:], `"`); end != -1 {
|
|
issueID := line[start : start+end]
|
|
// Extract prefix (everything before the last hyphen-hash part)
|
|
if dashIdx := strings.LastIndex(issueID, "-"); dashIdx > 0 {
|
|
prefix := issueID[:dashIdx]
|
|
// Handle prefixes like "gt" (from "gt-abc") - return without trailing hyphen
|
|
if isValidBeadsPrefix(prefix) {
|
|
return prefix
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break // Only check first issue
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// RemoveRig unregisters a rig (does not delete files).
|
|
func (m *Manager) RemoveRig(name string) error {
|
|
if !m.RigExists(name) {
|
|
return ErrRigNotFound
|
|
}
|
|
|
|
delete(m.config.Rigs, name)
|
|
return nil
|
|
}
|
|
|
|
// ListRigNames returns the names of all registered rigs.
|
|
func (m *Manager) ListRigNames() []string {
|
|
names := make([]string, 0, len(m.config.Rigs))
|
|
for name := range m.config.Rigs {
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// createRoleCLAUDEmd creates a minimal bootstrap pointer CLAUDE.md file.
|
|
// Full context is injected ephemerally by `gt prime` at session start.
|
|
// This keeps on-disk files small (<30 lines) per the priming architecture.
|
|
func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName string, workerName string) error {
|
|
// Create role-specific bootstrap pointer
|
|
var bootstrap string
|
|
switch role {
|
|
case "mayor":
|
|
bootstrap = `# Mayor Context (` + rigName + `)
|
|
|
|
> **Recovery**: Run ` + "`gt prime`" + ` after compaction, clear, or new session
|
|
|
|
Full context is injected by ` + "`gt prime`" + ` at session start.
|
|
`
|
|
case "refinery":
|
|
bootstrap = `# Refinery Context (` + rigName + `)
|
|
|
|
> **Recovery**: Run ` + "`gt prime`" + ` after compaction, clear, or new session
|
|
|
|
Full context is injected by ` + "`gt prime`" + ` at session start.
|
|
|
|
## Quick Reference
|
|
|
|
- Check MQ: ` + "`gt mq list`" + `
|
|
- Process next: ` + "`gt mq process`" + `
|
|
`
|
|
case "crew":
|
|
name := workerName
|
|
if name == "" {
|
|
name = "worker"
|
|
}
|
|
bootstrap = `# Crew Context (` + rigName + `/` + name + `)
|
|
|
|
> **Recovery**: Run ` + "`gt prime`" + ` after compaction, clear, or new session
|
|
|
|
Full context is injected by ` + "`gt prime`" + ` at session start.
|
|
|
|
## Quick Reference
|
|
|
|
- Check hook: ` + "`gt hook`" + `
|
|
- Check mail: ` + "`gt mail inbox`" + `
|
|
`
|
|
case "polecat":
|
|
name := workerName
|
|
if name == "" {
|
|
name = "worker"
|
|
}
|
|
bootstrap = `# Polecat Context (` + rigName + `/` + name + `)
|
|
|
|
> **Recovery**: Run ` + "`gt prime`" + ` after compaction, clear, or new session
|
|
|
|
Full context is injected by ` + "`gt prime`" + ` at session start.
|
|
|
|
## Quick Reference
|
|
|
|
- Check hook: ` + "`gt hook`" + `
|
|
- Report done: ` + "`gt done`" + `
|
|
`
|
|
default:
|
|
bootstrap = `# Agent Context
|
|
|
|
> **Recovery**: Run ` + "`gt prime`" + ` after compaction, clear, or new session
|
|
|
|
Full context is injected by ` + "`gt prime`" + ` at session start.
|
|
`
|
|
}
|
|
|
|
claudePath := filepath.Join(workspacePath, "CLAUDE.md")
|
|
return os.WriteFile(claudePath, []byte(bootstrap), 0644)
|
|
}
|
|
|
|
// createPatrolHooks creates .claude/settings.json with hooks for patrol roles.
|
|
// These hooks trigger gt prime on session start and inject mail, enabling
|
|
// autonomous patrol execution for Witness and Refinery roles.
|
|
func (m *Manager) createPatrolHooks(workspacePath string, runtimeConfig *config.RuntimeConfig) error {
|
|
if runtimeConfig == nil || runtimeConfig.Hooks == nil || runtimeConfig.Hooks.Provider != "claude" {
|
|
return nil
|
|
}
|
|
if runtimeConfig.Hooks.Dir == "" || runtimeConfig.Hooks.SettingsFile == "" {
|
|
return nil
|
|
}
|
|
|
|
settingsDir := filepath.Join(workspacePath, runtimeConfig.Hooks.Dir)
|
|
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
|
return fmt.Errorf("creating settings dir: %w", err)
|
|
}
|
|
|
|
// Standard patrol hooks - same as deacon
|
|
hooksJSON := `{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"matcher": "",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "gt prime && gt mail check --inject"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PreCompact": [
|
|
{
|
|
"matcher": "",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "gt prime"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"UserPromptSubmit": [
|
|
{
|
|
"matcher": "",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "gt mail check --inject"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
`
|
|
settingsPath := filepath.Join(settingsDir, runtimeConfig.Hooks.SettingsFile)
|
|
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
|
|
}
|
|
|
|
// seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database.
|
|
// These molecules define the work loops for Deacon, Witness, and Refinery roles.
|
|
func (m *Manager) seedPatrolMolecules(rigPath string) error {
|
|
// Use bd command to seed molecules (more reliable than internal API)
|
|
cmd := exec.Command("bd", "mol", "seed", "--patrol")
|
|
cmd.Dir = rigPath
|
|
if err := cmd.Run(); err != nil {
|
|
// Fallback: bd mol seed might not support --patrol yet
|
|
// Try creating them individually via bd create
|
|
return m.seedPatrolMoleculesManually(rigPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// seedPatrolMoleculesManually creates patrol molecules using bd create commands.
|
|
func (m *Manager) seedPatrolMoleculesManually(rigPath string) error {
|
|
// Patrol molecule definitions for seeding
|
|
patrolMols := []struct {
|
|
title string
|
|
desc string
|
|
}{
|
|
{
|
|
title: "Deacon Patrol",
|
|
desc: "Mayor's daemon patrol loop for handling callbacks, health checks, and cleanup.",
|
|
},
|
|
{
|
|
title: "Witness Patrol",
|
|
desc: "Per-rig worker monitor patrol loop with progressive nudging.",
|
|
},
|
|
{
|
|
title: "Refinery Patrol",
|
|
desc: "Merge queue processor patrol loop with verification gates.",
|
|
},
|
|
}
|
|
|
|
for _, mol := range patrolMols {
|
|
// Check if already exists by title
|
|
checkCmd := exec.Command("bd", "list", "--type=molecule", "--format=json")
|
|
checkCmd.Dir = rigPath
|
|
output, _ := checkCmd.Output()
|
|
if strings.Contains(string(output), mol.title) {
|
|
continue // Already exists
|
|
}
|
|
|
|
// Create the molecule
|
|
cmd := exec.Command("bd", "create", //nolint:gosec // G204: bd is a trusted internal tool
|
|
"--type=molecule",
|
|
"--title="+mol.title,
|
|
"--description="+mol.desc,
|
|
"--priority=2",
|
|
)
|
|
cmd.Dir = rigPath
|
|
if err := cmd.Run(); err != nil {
|
|
// Non-fatal, continue with others
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createPluginDirectories creates plugin directories at town and rig levels.
|
|
// - ~/gt/plugins/ (town-level, shared across all rigs)
|
|
// - <rig>/plugins/ (rig-level, rig-specific plugins)
|
|
func (m *Manager) createPluginDirectories(rigPath string) error {
|
|
// Town-level plugins directory
|
|
townPluginsDir := filepath.Join(m.townRoot, "plugins")
|
|
if err := os.MkdirAll(townPluginsDir, 0755); err != nil {
|
|
return fmt.Errorf("creating town plugins directory: %w", err)
|
|
}
|
|
|
|
// Create a README in town plugins if it doesn't exist
|
|
townReadme := filepath.Join(townPluginsDir, "README.md")
|
|
if _, err := os.Stat(townReadme); os.IsNotExist(err) {
|
|
content := `# Gas Town Plugins
|
|
|
|
This directory contains town-level plugins that run during Deacon patrol cycles.
|
|
|
|
## Plugin Structure
|
|
|
|
Each plugin is a directory containing:
|
|
- plugin.md - Plugin definition with TOML frontmatter
|
|
|
|
## Gate Types
|
|
|
|
- cooldown: Time since last run (e.g., 24h)
|
|
- cron: Schedule-based (e.g., "0 9 * * *")
|
|
- condition: Metric threshold
|
|
- event: Trigger-based (startup, heartbeat)
|
|
|
|
See docs/deacon-plugins.md for full documentation.
|
|
`
|
|
if writeErr := os.WriteFile(townReadme, []byte(content), 0644); writeErr != nil {
|
|
// Non-fatal
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Rig-level plugins directory
|
|
rigPluginsDir := filepath.Join(rigPath, "plugins")
|
|
if err := os.MkdirAll(rigPluginsDir, 0755); err != nil {
|
|
return fmt.Errorf("creating rig plugins directory: %w", err)
|
|
}
|
|
|
|
// Add plugins/ and .repo.git/ to rig .gitignore
|
|
gitignorePath := filepath.Join(rigPath, ".gitignore")
|
|
if err := m.ensureGitignoreEntry(gitignorePath, "plugins/"); err != nil {
|
|
return err
|
|
}
|
|
return m.ensureGitignoreEntry(gitignorePath, ".repo.git/")
|
|
}
|