4 Commits

Author SHA1 Message Date
nux
123d0b2bed feat(templates): Add local override support for role templates
Adds ability to override embedded role templates at town or rig level:
- Town: <townRoot>/templates/roles/<role>.md.tmpl
- Rig: <rigPath>/templates/roles/<role>.md.tmpl

Rig-level overrides take precedence over town-level.

This enables customizing polecat (or other role) behavior per-rig without
modifying gastown source, following the same 3-tier override pattern as
role configs.

New APIs:
- NewWithOverrides(townRoot, rigPath string) - loads templates with overrides
- HasRoleOverride(role string) bool - check if role has override
- RoleOverrideCount() int - count of loaded overrides

Implements sc-6ghhn

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:12:35 -08:00
riker
6be7fdd76c fix(dog): properly set identity for dog sessions
Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Successful in 25s
CI / Test (push) Failing after 1m22s
CI / Lint (push) Failing after 16s
CI / Integration Tests (push) Successful in 1m9s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
Three fixes to make dog dispatch work end-to-end:

1. Add BuildDogStartupCommand in loader.go
   - Similar to BuildPolecatStartupCommand/BuildCrewStartupCommand
   - Passes AgentName to AgentEnv so BD_ACTOR is exported in startup command

2. Use BuildDogStartupCommand in dog.go
   - Removes ineffective SetEnvironment calls (env vars set after shell starts
     don't propagate to already-running processes)

3. Add "dog" case in mail_identity.go detectSenderFromRole
   - Dogs now use BD_ACTOR for mail identity
   - Without this, dogs fell through to "overseer" and couldn't find their mail

Tested: dog alpha now correctly sees inbox as deacon/dogs/alpha

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:44:45 -08:00
riker
f0014bb21a fix(dog): spawn session and set BD_ACTOR for dog dispatch
Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Successful in 24s
CI / Test (push) Failing after 1m24s
CI / Lint (push) Failing after 16s
CI / Integration Tests (push) Successful in 1m9s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
Recovered from reflog - these commits were lost during a rebase/force-push.

Dogs are directories with state files but no sessions. When `gt dog dispatch`
assigned work and sent mail, nothing executed because no session existed.

Changes:
1. Spawn tmux session after dispatch (gt-<town>-deacon-<dogname>)
2. Set BD_ACTOR=deacon/dogs/<name> so dogs can find their mail
3. Add dog case to AgentEnv for proper identity

Session spawn is non-blocking - if it fails, mail was sent and human can
manually start the session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:26:47 -08:00
c9f844b477 feat(dog): add 'gt dog done' command for dogs to mark themselves idle
Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Successful in 21s
CI / Test (push) Failing after 1m28s
CI / Lint (push) Failing after 16s
CI / Integration Tests (push) Successful in 1m7s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
Dogs can now reset their own state to idle after completing work:

  gt dog done        # Auto-detect from BD_ACTOR
  gt dog done alpha  # Explicit name

This solves the issue where dog sessions would complete work but remain in
"working" state because nothing processed the DOG_DONE mail. Now dogs can
explicitly mark themselves idle before handing off.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 08:34:29 -08:00
8 changed files with 364 additions and 18 deletions

View File

@@ -182,6 +182,22 @@ Examples:
RunE: runDogDispatch,
}
var dogDoneCmd = &cobra.Command{
Use: "done [name]",
Short: "Mark a dog as idle (work complete)",
Long: `Mark a dog as idle after completing its work.
Dogs call this command after finishing plugin execution to reset their state
to idle, allowing them to receive new work dispatches.
If no name is provided, attempts to detect the current dog from BD_ACTOR.
Examples:
gt dog done alpha # Explicit dog name
gt dog done # Auto-detect from BD_ACTOR (e.g., "deacon/dogs/alpha")`,
RunE: runDogDone,
}
func init() {
// List flags
dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON")
@@ -212,6 +228,7 @@ func init() {
dogCmd.AddCommand(dogCallCmd)
dogCmd.AddCommand(dogStatusCmd)
dogCmd.AddCommand(dogDispatchCmd)
dogCmd.AddCommand(dogDoneCmd)
rootCmd.AddCommand(dogCmd)
}
@@ -500,6 +517,34 @@ func runDogStatus(cmd *cobra.Command, args []string) error {
return showPackStatus(mgr)
}
func runDogDone(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
var name string
if len(args) > 0 {
name = args[0]
} else {
// Try to detect from BD_ACTOR (e.g., "deacon/dogs/alpha")
actor := os.Getenv("BD_ACTOR")
if actor != "" && strings.HasPrefix(actor, "deacon/dogs/") {
name = strings.TrimPrefix(actor, "deacon/dogs/")
}
if name == "" {
return fmt.Errorf("no dog name provided and could not detect from BD_ACTOR")
}
}
if err := mgr.ClearWork(name); err != nil {
return fmt.Errorf("marking dog %s as done: %w", name, err)
}
fmt.Printf("✓ %s marked as idle (ready for new work)\n", name)
return nil
}
func showDogStatus(mgr *dog.Manager, name string) error {
d, err := mgr.Get(name)
if err != nil {
@@ -791,6 +836,35 @@ func runDogDispatch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("sending plugin mail to dog: %w", err)
}
// Spawn a session for the dog to execute the work.
// Without a session, the dog's mail inbox is never checked.
// See: https://github.com/steveyegge/gastown/issues/XXX (dog dispatch doesn't execute)
t := tmux.NewTmux()
townName, err := workspace.GetTownName(townRoot)
if err != nil {
townName = "gt" // fallback
}
dogSessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, targetDog.Name)
// Kill any stale session first
if has, _ := t.HasSession(dogSessionName); has {
_ = t.KillSessionWithProcesses(dogSessionName)
}
// Build startup command with initial prompt to check mail and execute plugin
// Use BuildDogStartupCommand to properly set BD_ACTOR=deacon/dogs/<name> in the startup command
initialPrompt := fmt.Sprintf("I am dog %s. Check my mail inbox with 'gt mail inbox' and execute the plugin instructions I received.", targetDog.Name)
startCmd := config.BuildDogStartupCommand(targetDog.Name, townRoot, targetDog.Path, initialPrompt)
// Create session from dog's directory
if err := t.NewSessionWithCommand(dogSessionName, targetDog.Path, startCmd); err != nil {
if !dogDispatchJSON {
fmt.Printf(" Warning: could not spawn dog session: %v\n", err)
}
// Non-fatal: mail was sent, dog is marked as working, but no session to execute
// The deacon or human can manually start the session later
}
// Success - output result
if dogDispatchJSON {
return json.NewEncoder(os.Stdout).Encode(result)

View File

@@ -129,6 +129,13 @@ func detectSenderFromRole(role string) string {
return fmt.Sprintf("%s/refinery", rig)
}
return detectSenderFromCwd()
case "dog":
// Dogs use BD_ACTOR directly (set by BuildDogStartupCommand)
actor := os.Getenv("BD_ACTOR")
if actor != "" {
return actor
}
return detectSenderFromCwd()
default:
// Unknown role, try cwd detection
return detectSenderFromCwd()

View File

@@ -18,8 +18,14 @@ import (
// outputPrimeContext outputs the role-specific context using templates or fallback.
func outputPrimeContext(ctx RoleContext) error {
// Try to use templates first
tmpl, err := templates.New()
// Calculate rig path for template overrides
var rigPath string
if ctx.Rig != "" && ctx.TownRoot != "" {
rigPath = filepath.Join(ctx.TownRoot, ctx.Rig)
}
// Try to use templates with override support
tmpl, err := templates.NewWithOverrides(ctx.TownRoot, rigPath)
if err != nil {
// Fall back to hardcoded output if templates fail
return outputPrimeContextFallback(ctx)

View File

@@ -61,6 +61,11 @@ func AgentEnv(cfg AgentEnvConfig) map[string]string {
env["GIT_AUTHOR_NAME"] = "boot"
env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local"
case "dog":
env["BD_ACTOR"] = fmt.Sprintf("deacon/dogs/%s", cfg.AgentName)
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("dog-%s", cfg.AgentName)
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("dog-%s@gastown.local", cfg.AgentName)
case "witness":
env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig)
@@ -128,7 +133,7 @@ func AgentEnvSimple(role, rig, agentName string) map[string]string {
// ShellQuote returns a shell-safe quoted string.
// Values containing special characters are wrapped in single quotes.
// Single quotes within the value are escaped using the '\'' idiom.
// Single quotes within the value are escaped using the '\ idiom.
func ShellQuote(s string) string {
// Check if quoting is needed (contains shell special chars)
needsQuoting := false

View File

@@ -125,6 +125,23 @@ func TestAgentEnv_Boot(t *testing.T) {
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_Dog(t *testing.T) {
t.Parallel()
env := AgentEnv(AgentEnvConfig{
Role: "dog",
AgentName: "alpha",
TownRoot: "/town",
})
assertEnv(t, env, "GT_ROLE", "dog")
assertEnv(t, env, "BD_ACTOR", "deacon/dogs/alpha")
assertEnv(t, env, "GIT_AUTHOR_NAME", "dog-alpha")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "dog-alpha@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_WithRuntimeConfigDir(t *testing.T) {
t.Parallel()
env := AgentEnv(AgentEnvConfig{

View File

@@ -1457,6 +1457,17 @@ func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath,
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
}
// BuildDogStartupCommand builds the startup command for a deacon dog.
// Sets GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
func BuildDogStartupCommand(dogName, townRoot, dogPath, prompt string) string {
envVars := AgentEnv(AgentEnvConfig{
Role: "dog",
AgentName: dogName,
TownRoot: townRoot,
})
return BuildStartupCommand(envVars, dogPath, prompt)
}
// BuildCrewStartupCommand builds the startup command for a crew member.
// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {

View File

@@ -20,6 +20,9 @@ var commandsFS embed.FS
type Templates struct {
roleTemplates *template.Template
messageTemplates *template.Template
// roleOverrides maps role name to override template content.
// When set, these are used instead of embedded templates.
roleOverrides map[string]*template.Template
}
// RoleData contains information for rendering role contexts.
@@ -81,11 +84,24 @@ type HandoffData struct {
GitDirty bool
}
// New creates a new Templates instance.
// New creates a new Templates instance with only embedded templates.
func New() (*Templates, error) {
t := &Templates{}
return NewWithOverrides("", "")
}
// Parse role templates
// NewWithOverrides creates a Templates instance that checks for local overrides.
// Override resolution order (later overrides earlier):
// 1. Built-in templates (embedded in binary)
// 2. Town-level overrides (<townRoot>/templates/roles/<role>.md.tmpl)
// 3. Rig-level overrides (<rigPath>/templates/roles/<role>.md.tmpl)
//
// If townRoot or rigPath is empty, that level is skipped.
func NewWithOverrides(townRoot, rigPath string) (*Templates, error) {
t := &Templates{
roleOverrides: make(map[string]*template.Template),
}
// Parse embedded role templates
roleTempl, err := template.ParseFS(templateFS, "roles/*.md.tmpl")
if err != nil {
return nil, fmt.Errorf("parsing role templates: %w", err)
@@ -99,14 +115,51 @@ func New() (*Templates, error) {
}
t.messageTemplates = msgTempl
// Load role overrides from filesystem
// Check both town and rig levels, rig takes precedence
roles := []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"}
overridePaths := []string{}
if townRoot != "" {
overridePaths = append(overridePaths, filepath.Join(townRoot, "templates", "roles"))
}
if rigPath != "" {
overridePaths = append(overridePaths, filepath.Join(rigPath, "templates", "roles"))
}
for _, role := range roles {
templateName := role + ".md.tmpl"
// Check each override path in order (later paths override earlier)
for _, overrideDir := range overridePaths {
overridePath := filepath.Join(overrideDir, templateName)
if content, err := os.ReadFile(overridePath); err == nil {
tmpl, err := template.New(templateName).Parse(string(content))
if err != nil {
return nil, fmt.Errorf("parsing override template %s: %w", overridePath, err)
}
t.roleOverrides[role] = tmpl
}
}
}
return t, nil
}
// RenderRole renders a role context template.
// If an override template exists for the role, it is used instead of the embedded template.
func (t *Templates) RenderRole(role string, data RoleData) (string, error) {
templateName := role + ".md.tmpl"
var buf bytes.Buffer
// Check for override template first
if override, ok := t.roleOverrides[role]; ok {
if err := override.Execute(&buf, data); err != nil {
return "", fmt.Errorf("rendering override template %s: %w", templateName, err)
}
return buf.String(), nil
}
// Fall back to embedded template
if err := t.roleTemplates.ExecuteTemplate(&buf, templateName, data); err != nil {
return "", fmt.Errorf("rendering role template %s: %w", templateName, err)
}
@@ -131,6 +184,17 @@ func (t *Templates) RoleNames() []string {
return []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"}
}
// HasRoleOverride returns true if the given role has a local override template.
func (t *Templates) HasRoleOverride(role string) bool {
_, ok := t.roleOverrides[role]
return ok
}
// RoleOverrideCount returns the number of role templates that have local overrides.
func (t *Templates) RoleOverrideCount() int {
return len(t.roleOverrides)
}
// MessageNames returns the list of available message templates.
func (t *Templates) MessageNames() []string {
return []string{"spawn", "nudge", "escalation", "handoff"}

View File

@@ -1,6 +1,7 @@
package templates
import (
"os"
"strings"
"testing"
)
@@ -295,3 +296,164 @@ func TestGetAllRoleTemplates_ContentValidity(t *testing.T) {
}
}
}
func TestNewWithOverrides_NoOverrides(t *testing.T) {
// When no override paths exist, should work like New()
tmpl, err := NewWithOverrides("/nonexistent/town", "/nonexistent/rig")
if err != nil {
t.Fatalf("NewWithOverrides() error = %v", err)
}
if tmpl == nil {
t.Fatal("NewWithOverrides() returned nil")
}
// Should have no overrides
if tmpl.RoleOverrideCount() != 0 {
t.Errorf("RoleOverrideCount() = %d, want 0", tmpl.RoleOverrideCount())
}
// Should still render embedded templates
data := RoleData{
Role: "polecat",
RigName: "myrig",
Polecat: "TestCat",
}
output, err := tmpl.RenderRole("polecat", data)
if err != nil {
t.Fatalf("RenderRole() error = %v", err)
}
if !strings.Contains(output, "Polecat Context") {
t.Error("embedded template not rendered correctly")
}
}
func TestNewWithOverrides_WithOverride(t *testing.T) {
// Create a temp directory with an override template
tempDir := t.TempDir()
overrideDir := tempDir + "/templates/roles"
if err := os.MkdirAll(overrideDir, 0755); err != nil {
t.Fatalf("failed to create override dir: %v", err)
}
// Write a simple override template
overrideContent := `# Custom Polecat Context
You are polecat {{ .Polecat }} with custom instructions.
Rig: {{ .RigName }}
`
if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(overrideContent), 0644); err != nil {
t.Fatalf("failed to write override template: %v", err)
}
// Create Templates with the override
tmpl, err := NewWithOverrides("", tempDir)
if err != nil {
t.Fatalf("NewWithOverrides() error = %v", err)
}
// Should have one override
if tmpl.RoleOverrideCount() != 1 {
t.Errorf("RoleOverrideCount() = %d, want 1", tmpl.RoleOverrideCount())
}
// Should report polecat as having override
if !tmpl.HasRoleOverride("polecat") {
t.Error("HasRoleOverride('polecat') = false, want true")
}
if tmpl.HasRoleOverride("mayor") {
t.Error("HasRoleOverride('mayor') = true, want false")
}
// Render should use override
data := RoleData{
Role: "polecat",
RigName: "myrig",
Polecat: "TestCat",
}
output, err := tmpl.RenderRole("polecat", data)
if err != nil {
t.Fatalf("RenderRole() error = %v", err)
}
// Should contain custom content, not embedded
if !strings.Contains(output, "Custom Polecat Context") {
t.Error("override template not used - missing 'Custom Polecat Context'")
}
if strings.Contains(output, "Idle Polecat Heresy") {
t.Error("embedded template used instead of override")
}
if !strings.Contains(output, "TestCat") {
t.Error("template variable not expanded")
}
}
func TestNewWithOverrides_RigOverridesTown(t *testing.T) {
// Create temp dirs for both town and rig overrides
townDir := t.TempDir()
rigDir := t.TempDir()
townOverrideDir := townDir + "/templates/roles"
rigOverrideDir := rigDir + "/templates/roles"
if err := os.MkdirAll(townOverrideDir, 0755); err != nil {
t.Fatalf("failed to create town override dir: %v", err)
}
if err := os.MkdirAll(rigOverrideDir, 0755); err != nil {
t.Fatalf("failed to create rig override dir: %v", err)
}
// Write town override
townContent := "# Town Polecat\nTown override: {{ .Polecat }}"
if err := os.WriteFile(townOverrideDir+"/polecat.md.tmpl", []byte(townContent), 0644); err != nil {
t.Fatalf("failed to write town override: %v", err)
}
// Write rig override (should win)
rigContent := "# Rig Polecat\nRig override: {{ .Polecat }}"
if err := os.WriteFile(rigOverrideDir+"/polecat.md.tmpl", []byte(rigContent), 0644); err != nil {
t.Fatalf("failed to write rig override: %v", err)
}
// Create Templates with both overrides
tmpl, err := NewWithOverrides(townDir, rigDir)
if err != nil {
t.Fatalf("NewWithOverrides() error = %v", err)
}
// Render should use rig override (higher precedence)
data := RoleData{Polecat: "TestCat"}
output, err := tmpl.RenderRole("polecat", data)
if err != nil {
t.Fatalf("RenderRole() error = %v", err)
}
if !strings.Contains(output, "Rig Polecat") {
t.Error("rig override not used")
}
if strings.Contains(output, "Town Polecat") {
t.Error("town override used instead of rig override")
}
}
func TestNewWithOverrides_InvalidTemplate(t *testing.T) {
// Create a temp directory with an invalid template
tempDir := t.TempDir()
overrideDir := tempDir + "/templates/roles"
if err := os.MkdirAll(overrideDir, 0755); err != nil {
t.Fatalf("failed to create override dir: %v", err)
}
// Write an invalid template (unclosed action)
invalidContent := "# Invalid Template\n{{ .Polecat"
if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(invalidContent), 0644); err != nil {
t.Fatalf("failed to write invalid template: %v", err)
}
// Should return error for invalid template
_, err := NewWithOverrides("", tempDir)
if err == nil {
t.Error("NewWithOverrides() should fail with invalid template")
}
if !strings.Contains(err.Error(), "parsing override template") {
t.Errorf("error should mention parsing override template: %v", err)
}
}