Skip to content

Commit 7c40de0

Browse files
seanbeardenclaude
andcommitted
fix: FormatMailBody detects run.sh and dispatches script instead of markdown
When a plugin has a run.sh alongside plugin.md, the dog now receives explicit instructions to execute the script rather than interpreting the markdown body — which caused dogs to improvise bash and kill valid crew sessions. Closes gt-zbys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e3a5f80 commit 7c40de0

3 files changed

Lines changed: 164 additions & 3 deletions

File tree

internal/plugin/scanner.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,18 @@ func (s *Scanner) loadPlugin(pluginDir string, location Location, rigName string
130130
return nil, fmt.Errorf("reading plugin.md: %w", err)
131131
}
132132

133-
return parsePluginMD(content, pluginDir, location, rigName)
133+
plugin, err := parsePluginMD(content, pluginDir, location, rigName)
134+
if err != nil {
135+
return nil, err
136+
}
137+
138+
// Check for run.sh alongside plugin.md
139+
runScriptPath := filepath.Join(pluginDir, "run.sh")
140+
if info, statErr := os.Stat(runScriptPath); statErr == nil && !info.IsDir() {
141+
plugin.HasRunScript = true
142+
}
143+
144+
return plugin, nil
134145
}
135146

136147
// parsePluginMD parses a plugin.md file with TOML frontmatter.

internal/plugin/scanner_test.go

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package plugin
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78
)
89

@@ -401,16 +402,26 @@ func TestParsePluginMD_GitHubSheriff(t *testing.T) {
401402

402403
func TestParsePluginMD_SessionHygiene(t *testing.T) {
403404
// Verify the actual session-hygiene plugin.md parses correctly.
404-
content, err := os.ReadFile(filepath.Join("..", "..", "plugins", "session-hygiene", "plugin.md"))
405+
pluginDir := filepath.Join("..", "..", "plugins", "session-hygiene")
406+
content, err := os.ReadFile(filepath.Join(pluginDir, "plugin.md"))
405407
if err != nil {
406408
t.Skipf("session-hygiene plugin not found (expected in plugins/): %v", err)
407409
}
408410

409-
plugin, err := parsePluginMD(content, "/test/session-hygiene", LocationRig, "gastown")
411+
plugin, err := parsePluginMD(content, pluginDir, LocationRig, "gastown")
410412
if err != nil {
411413
t.Fatalf("parsePluginMD failed: %v", err)
412414
}
413415

416+
// Verify run.sh detection (loadPlugin does this, not parsePluginMD)
417+
runScriptPath := filepath.Join(pluginDir, "run.sh")
418+
if info, statErr := os.Stat(runScriptPath); statErr == nil && !info.IsDir() {
419+
plugin.HasRunScript = true
420+
}
421+
if !plugin.HasRunScript {
422+
t.Error("expected HasRunScript=true for session-hygiene (has run.sh)")
423+
}
424+
414425
if plugin.Name != "session-hygiene" {
415426
t.Errorf("expected name 'session-hygiene', got %q", plugin.Name)
416427
}
@@ -509,3 +520,123 @@ version = 1
509520
t.Errorf("expected location 'rig', got %q", plugins[0].Location)
510521
}
511522
}
523+
524+
func TestLoadPlugin_DetectsRunScript(t *testing.T) {
525+
tmpDir, err := os.MkdirTemp("", "plugin-runsh-test")
526+
if err != nil {
527+
t.Fatalf("failed to create temp dir: %v", err)
528+
}
529+
defer os.RemoveAll(tmpDir)
530+
531+
// Create plugin dir with plugin.md AND run.sh
532+
pluginDir := filepath.Join(tmpDir, "plugins", "with-script")
533+
if err := os.MkdirAll(pluginDir, 0755); err != nil {
534+
t.Fatalf("failed to create plugin dir: %v", err)
535+
}
536+
pluginContent := []byte(`+++
537+
name = "with-script"
538+
description = "Plugin with run.sh"
539+
version = 1
540+
+++
541+
542+
# Instructions (should be ignored when run.sh exists)
543+
`)
544+
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.md"), pluginContent, 0644); err != nil {
545+
t.Fatalf("failed to write plugin.md: %v", err)
546+
}
547+
if err := os.WriteFile(filepath.Join(pluginDir, "run.sh"), []byte("#!/bin/bash\necho hello\n"), 0755); err != nil {
548+
t.Fatalf("failed to write run.sh: %v", err)
549+
}
550+
551+
// Create plugin dir with plugin.md only (no run.sh)
552+
pluginDirNoScript := filepath.Join(tmpDir, "plugins", "no-script")
553+
if err := os.MkdirAll(pluginDirNoScript, 0755); err != nil {
554+
t.Fatalf("failed to create plugin dir: %v", err)
555+
}
556+
noScriptContent := []byte(`+++
557+
name = "no-script"
558+
description = "Plugin without run.sh"
559+
version = 1
560+
+++
561+
562+
# Instructions
563+
`)
564+
if err := os.WriteFile(filepath.Join(pluginDirNoScript, "plugin.md"), noScriptContent, 0644); err != nil {
565+
t.Fatalf("failed to write plugin.md: %v", err)
566+
}
567+
568+
scanner := NewScanner(tmpDir, nil)
569+
plugins, err := scanner.DiscoverAll()
570+
if err != nil {
571+
t.Fatalf("DiscoverAll failed: %v", err)
572+
}
573+
574+
if len(plugins) != 2 {
575+
t.Fatalf("expected 2 plugins, got %d", len(plugins))
576+
}
577+
578+
byName := make(map[string]*Plugin)
579+
for _, p := range plugins {
580+
byName[p.Name] = p
581+
}
582+
583+
if p, ok := byName["with-script"]; !ok {
584+
t.Fatal("expected to find 'with-script' plugin")
585+
} else if !p.HasRunScript {
586+
t.Error("expected HasRunScript=true for plugin with run.sh")
587+
}
588+
589+
if p, ok := byName["no-script"]; !ok {
590+
t.Fatal("expected to find 'no-script' plugin")
591+
} else if p.HasRunScript {
592+
t.Error("expected HasRunScript=false for plugin without run.sh")
593+
}
594+
}
595+
596+
func TestFormatMailBody_WithRunScript(t *testing.T) {
597+
p := &Plugin{
598+
Name: "test-plugin",
599+
Description: "A test plugin",
600+
Path: "/home/user/gt/plugins/test-plugin",
601+
HasRunScript: true,
602+
}
603+
604+
body := p.FormatMailBody()
605+
606+
// Must contain the bash command to run the script
607+
if !strings.Contains(body, "cd /home/user/gt/plugins/test-plugin && bash run.sh") {
608+
t.Error("expected mail body to contain run.sh execution command")
609+
}
610+
// Must instruct dog NOT to interpret markdown
611+
if !strings.Contains(body, "Do NOT interpret the plugin.md instructions") {
612+
t.Error("expected mail body to warn against interpreting markdown")
613+
}
614+
// Must NOT contain "## Instructions" section
615+
if strings.Contains(body, "## Instructions") {
616+
t.Error("expected mail body to NOT contain markdown instructions section")
617+
}
618+
}
619+
620+
func TestFormatMailBody_WithoutRunScript(t *testing.T) {
621+
p := &Plugin{
622+
Name: "test-plugin",
623+
Description: "A test plugin",
624+
Path: "/home/user/gt/plugins/test-plugin",
625+
Instructions: "Do the thing.",
626+
HasRunScript: false,
627+
}
628+
629+
body := p.FormatMailBody()
630+
631+
// Must contain the instructions section
632+
if !strings.Contains(body, "## Instructions") {
633+
t.Error("expected mail body to contain instructions section")
634+
}
635+
if !strings.Contains(body, "Do the thing.") {
636+
t.Error("expected mail body to contain plugin instructions")
637+
}
638+
// Must NOT contain run.sh dispatch
639+
if strings.Contains(body, "bash run.sh") {
640+
t.Error("expected mail body to NOT contain run.sh command")
641+
}
642+
}

internal/plugin/types.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ type Plugin struct {
4444

4545
// Instructions is the markdown body (after frontmatter).
4646
Instructions string `json:"instructions,omitempty"`
47+
48+
// HasRunScript is true when a run.sh exists alongside plugin.md.
49+
// When true, FormatMailBody instructs the dog to execute the script
50+
// instead of interpreting the markdown instructions.
51+
HasRunScript bool `json:"has_run_script,omitempty"`
4752
}
4853

4954
// Location indicates where a plugin was discovered.
@@ -159,6 +164,20 @@ func (p *Plugin) Summary() PluginSummary {
159164
// This is the canonical formatting used by both the daemon dispatcher
160165
// and the gt dog dispatch command.
161166
func (p *Plugin) FormatMailBody() string {
167+
if p.HasRunScript {
168+
return fmt.Sprintf(
169+
"Execute the following plugin script:\n\n"+
170+
"**Plugin**: %s\n"+
171+
"**Description**: %s\n\n"+
172+
"```bash\ncd %s && bash run.sh\n```\n\n"+
173+
"Run this command EXACTLY. Do NOT interpret the plugin.md instructions.\n"+
174+
"Do NOT write your own implementation. Just run the script and report the output.\n\n"+
175+
"After completion:\n"+
176+
"1. Create a wisp to record the result (success/failure)\n"+
177+
"2. Run `gt dog done` — this clears your work and auto-terminates the session\n",
178+
p.Name, p.Description, p.Path)
179+
}
180+
162181
var sb strings.Builder
163182

164183
sb.WriteString("Execute the following plugin:\n\n")

0 commit comments

Comments
 (0)