From 7e4bc71068244e9daf7be2df2d765bdcbc60e50a Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan Date: Tue, 11 Aug 2026 13:54:27 -0400 Subject: [PATCH 1/4] :sparkles: Symlink skills dir for native goose discovery (#70) Assisted-By: Claude Code Signed-off-by: Savitha Raghunathan --- .../70-skill-symlink-for-goose-discovery.yaml | 6 +++ harness/cmd/migration-harness/main.go | 37 ++++++++------- harness/cmd/migration-harness/main_test.go | 47 ++++++++++++++----- harness/internal/prompt/prompt.go | 16 ++----- harness/internal/prompt/prompt_test.go | 28 ++++------- harness/internal/watcher/patterns.go | 2 +- 6 files changed, 74 insertions(+), 62 deletions(-) create mode 100644 changes/unreleased/70-skill-symlink-for-goose-discovery.yaml diff --git a/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml b/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml new file mode 100644 index 00000000..2b5c450d --- /dev/null +++ b/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml @@ -0,0 +1,6 @@ +kind: feature +description: > + Symlink .agents/skills to /opt/skills after clone so goose discovers + mounted SkillCard content via its native skill platform. Skills are no + longer injected into the prompt; the agent runtime handles progressive + disclosure. diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index fec55ffd..bf82f1ae 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -8,7 +8,6 @@ import ( "os/signal" "path/filepath" "strconv" - "strings" "sync/atomic" "syscall" "time" @@ -120,7 +119,7 @@ func runStage(cmd *cobra.Command, args []string) error { logging.Ok("cloned to %s, branch %s", cloneDir, creds.Branch) // 4. Discover skills early — controls which setup steps run - skillContent, skillPaths, err := discoverSkills() + skillPaths, err := discoverSkills() if err != nil { return fmt.Errorf("discover skills: %w", err) } @@ -128,6 +127,7 @@ func runStage(cmd *cobra.Command, args []string) error { if hasSkills { if err := git.EnsureGitignore(cloneDir, []string{ + ".agents/", "graphify-out/", ".goose/", "__pycache__/", @@ -139,6 +139,11 @@ func runStage(cmd *cobra.Command, args []string) error { }); err != nil { logging.Warn("gitignore: %v", err) } + + if err := symlinkSkillsDir(cloneDir, skillsDir()); err != nil { + return fmt.Errorf("skill symlink: %w", err) + } + logging.Ok("symlinked %s/.agents/skills → %s", cloneDir, skillsDir()) } if hasSkills { @@ -275,7 +280,6 @@ func runStage(cmd *cobra.Command, args []string) error { stagePrompt := prompt.Build(prompt.Layers{ AgentPrompt: cfg.AgentPrompt, WorkflowGuide: cfg.WorkflowGuide, - Skill: skillContent, StageTask: cfg.StageInstructions, }) @@ -366,6 +370,14 @@ func runStage(cmd *cobra.Command, args []string) error { return nil } +func symlinkSkillsDir(cloneDir, skillsSrc string) error { + link := filepath.Join(cloneDir, ".agents", "skills") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + return err + } + return os.Symlink(skillsSrc, link) +} + const defaultSkillsDir = "/opt/skills" func skillsDir() string { @@ -375,30 +387,21 @@ func skillsDir() string { return defaultSkillsDir } -func discoverSkills() (string, []string, error) { +func discoverSkills() ([]string, error) { pattern := filepath.Join(skillsDir(), "*/SKILL.md") matches, err := filepath.Glob(pattern) if err != nil { - return "", nil, err + return nil, err } if len(matches) == 0 { logging.Info("no skills found at %s — proceeding without skills", pattern) - return "", nil, nil + return nil, nil } - var combined strings.Builder - for i, m := range matches { - content, err := os.ReadFile(m) - if err != nil { - return "", nil, fmt.Errorf("read skill %s: %w", m, err) - } + for _, m := range matches { logging.Info("discovered skill: %s", m) - if i > 0 { - combined.WriteString("\n\n---\n\n") - } - combined.Write(content) } - return combined.String(), matches, nil + return matches, nil } func resolveFromHub(cfg *config.Config, hubClient *hub.Client) (*git.Credentials, error) { diff --git a/harness/cmd/migration-harness/main_test.go b/harness/cmd/migration-harness/main_test.go index b6ab9c65..9c60bf4f 100644 --- a/harness/cmd/migration-harness/main_test.go +++ b/harness/cmd/migration-harness/main_test.go @@ -12,13 +12,10 @@ func TestDiscoverSkills_NoSkills(t *testing.T) { dir := t.TempDir() t.Setenv("HARNESS_SKILLS_DIR", dir) - content, paths, err := discoverSkills() + paths, err := discoverSkills() if err != nil { t.Fatalf("expected no error, got: %v", err) } - if content != "" { - t.Errorf("expected empty content, got: %q", content) - } if len(paths) != 0 { t.Errorf("expected no paths, got: %v", paths) } @@ -36,13 +33,10 @@ func TestDiscoverSkills_WithSkills(t *testing.T) { t.Fatal(err) } - content, paths, err := discoverSkills() + paths, err := discoverSkills() if err != nil { t.Fatalf("unexpected error: %v", err) } - if content != "do the thing" { - t.Errorf("expected skill content, got: %q", content) - } if len(paths) != 1 { t.Errorf("expected 1 path, got: %v", paths) } @@ -60,18 +54,47 @@ func TestDiscoverSkills_EmptySkillFile(t *testing.T) { t.Fatal(err) } - content, paths, err := discoverSkills() + paths, err := discoverSkills() if err != nil { t.Fatalf("unexpected error: %v", err) } - if content != "" { - t.Errorf("expected empty content, got: %q", content) - } if len(paths) != 1 { t.Errorf("expected 1 path (skill is mounted), got: %v", paths) } } +func TestSymlinkSkillsDir(t *testing.T) { + cloneDir := t.TempDir() + skillsSrc := t.TempDir() + + if err := symlinkSkillsDir(cloneDir, skillsSrc); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + link := filepath.Join(cloneDir, ".agents", "skills") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("expected symlink at %s: %v", link, err) + } + if target != skillsSrc { + t.Errorf("symlink target = %q, want %q", target, skillsSrc) + } +} + +func TestSymlinkSkillsDir_AlreadyExists(t *testing.T) { + cloneDir := t.TempDir() + skillsSrc := t.TempDir() + + if err := os.MkdirAll(filepath.Join(cloneDir, ".agents", "skills"), 0o755); err != nil { + t.Fatal(err) + } + + err := symlinkSkillsDir(cloneDir, skillsSrc) + if err == nil { + t.Fatal("expected error when .agents/skills already exists") + } +} + func TestShouldRevokeToken(t *testing.T) { tests := []struct { name string diff --git a/harness/internal/prompt/prompt.go b/harness/internal/prompt/prompt.go index 3ffd34e9..70c4dce5 100644 --- a/harness/internal/prompt/prompt.go +++ b/harness/internal/prompt/prompt.go @@ -28,14 +28,12 @@ not say where to put something. ` // Layers are the context layers composed into a stage prompt, ordered from -// least to most specific. Any of them may be empty except Skill. +// least to most specific. type Layers struct { // AgentPrompt is the Agent's standing prompt. AgentPrompt string // WorkflowGuide is the workflow's ambient guide. WorkflowGuide string - // Skill is the content discovered from the mounted SkillCards. - Skill string // StageTask is the task for this stage. StageTask string } @@ -59,16 +57,8 @@ func Build(l Layers) string { b.WriteString("\n\n") } - // Skills are optional (#82): with none mounted the agent still needs to be - // told to commit, since nothing else in the prompt says so. - if l.Skill != "" { - b.WriteString("## Skill Instructions\n\n") - b.WriteString(l.Skill) - b.WriteString("\n\n") - } else { - b.WriteString("## Working Guidelines\n\n") - b.WriteString("Commit your changes to git with a descriptive message when your work is complete.\n\n") - } + b.WriteString("## Working Guidelines\n\n") + b.WriteString("Commit your changes to git with a descriptive message when your work is complete.\n\n") if l.StageTask != "" { b.WriteString("## Stage Task\n\n") diff --git a/harness/internal/prompt/prompt_test.go b/harness/internal/prompt/prompt_test.go index beb0a78d..82678466 100644 --- a/harness/internal/prompt/prompt_test.go +++ b/harness/internal/prompt/prompt_test.go @@ -9,7 +9,6 @@ func fullLayers() Layers { return Layers{ AgentPrompt: "AGENT PROMPT", WorkflowGuide: "WORKFLOW GUIDE", - Skill: "SKILL BODY", StageTask: "STAGE TASK", } } @@ -21,10 +20,8 @@ func TestBuildPutsStagingRulesFirst(t *testing.T) { t.Fatalf("staging rules missing from prompt:\n%s", got) } - // Order matters as much as presence: the rules have to precede the skill, - // so a skill that says where to write cannot read as overriding them. rules := strings.Index(got, "Working Environment") - for _, later := range []string{"AGENT PROMPT", "WORKFLOW GUIDE", "SKILL BODY", "STAGE TASK"} { + for _, later := range []string{"AGENT PROMPT", "WORKFLOW GUIDE", "STAGE TASK"} { if strings.Index(got, later) < rules { t.Errorf("%q appears before the staging rules; rules must come first", later) } @@ -34,7 +31,7 @@ func TestBuildPutsStagingRulesFirst(t *testing.T) { func TestBuildOrdersLayersLeastToMostSpecific(t *testing.T) { got := Build(fullLayers()) - order := []string{"AGENT PROMPT", "WORKFLOW GUIDE", "SKILL BODY", "STAGE TASK"} + order := []string{"AGENT PROMPT", "WORKFLOW GUIDE", "STAGE TASK"} for i := 1; i < len(order); i++ { if strings.Index(got, order[i]) < strings.Index(got, order[i-1]) { t.Errorf("%q should come after %q", order[i], order[i-1]) @@ -43,40 +40,33 @@ func TestBuildOrdersLayersLeastToMostSpecific(t *testing.T) { } func TestBuildOmitsEmptyLayers(t *testing.T) { - got := Build(Layers{Skill: "SKILL BODY"}) + got := Build(Layers{}) for _, header := range []string{"## Workflow Guide", "## Stage Task"} { if strings.Contains(got, header) { t.Errorf("empty layer produced %q header", header) } } - if !strings.Contains(got, "## Skill Instructions") { - t.Error("skill content should always be included") + if !strings.Contains(got, "## Working Guidelines") { + t.Error("working guidelines should always be included") } } -// Skills are optional (#82). With none mounted the agent still needs to be told -// to commit, since nothing else in the prompt says so. -func TestBuildFallsBackWhenNoSkills(t *testing.T) { +func TestBuildIncludesCommitGuideline(t *testing.T) { got := Build(Layers{AgentPrompt: "AGENT PROMPT"}) - if strings.Contains(got, "## Skill Instructions") { - t.Error("empty skill should not produce a Skill Instructions header") - } if !strings.Contains(got, "## Working Guidelines") { - t.Fatalf("no skills should fall back to Working Guidelines:\n%s", got) + t.Fatalf("working guidelines missing:\n%s", got) } if !strings.Contains(got, "Commit your changes to git") { - t.Error("fallback should tell the agent to commit") + t.Error("working guidelines should tell the agent to commit") } } -// The ending differed depending on whether StageTask was set: sections append -// "\n\n" but StageTask appended nothing. func TestBuildEndsWithExactlyOneNewline(t *testing.T) { cases := map[string]Layers{ "with stage task": fullLayers(), - "without stage task": {Skill: "SKILL BODY"}, + "without stage task": {}, } for name, layers := range cases { t.Run(name, func(t *testing.T) { diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go index 790f5963..02ea97d0 100644 --- a/harness/internal/watcher/patterns.go +++ b/harness/internal/watcher/patterns.go @@ -1,7 +1,7 @@ package watcher var excludeDirs = map[string]bool{ - ".goose": true, "__pycache__": true, ".git": true, + ".agents": true, ".goose": true, "__pycache__": true, ".git": true, "node_modules": true, "target": true, "graphify-out": true, } From af474bfc86a003056f95d640ae342ef8c30ec955 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan Date: Tue, 11 Aug 2026 15:41:40 -0400 Subject: [PATCH 2/4] :lock: Harden symlinkSkillsDir against traversal and relative paths Reject repo-controlled .agents symlinks before creating the skills link to prevent writes outside cloneDir. Resolve relative HARNESS_SKILLS_DIR to absolute so the symlink target matches discovery resolution. Assisted-By: Claude Code Signed-off-by: Savitha Raghunathan --- .../70-skill-symlink-for-goose-discovery.yaml | 3 +- harness/cmd/migration-harness/main.go | 17 +++++- harness/cmd/migration-harness/main_test.go | 55 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml b/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml index 2b5c450d..dae0f41f 100644 --- a/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml +++ b/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml @@ -1,6 +1,7 @@ kind: feature description: > - Symlink .agents/skills to /opt/skills after clone so goose discovers + Symlink .agents/skills to the skills directory (default /opt/skills, + configurable via HARNESS_SKILLS_DIR) after clone so goose discovers mounted SkillCard content via its native skill platform. Skills are no longer injected into the prompt; the agent runtime handles progressive disclosure. diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index bf82f1ae..82f6bf8a 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -371,11 +371,22 @@ func runStage(cmd *cobra.Command, args []string) error { } func symlinkSkillsDir(cloneDir, skillsSrc string) error { - link := filepath.Join(cloneDir, ".agents", "skills") - if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + skillsSrc, err := filepath.Abs(skillsSrc) + if err != nil { + return fmt.Errorf("resolve skills source: %w", err) + } + + agentsDir := filepath.Join(cloneDir, ".agents") + if info, err := os.Lstat(agentsDir); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink (repo-controlled) — refusing to follow", agentsDir) + } + } + + if err := os.MkdirAll(agentsDir, 0o755); err != nil { return err } - return os.Symlink(skillsSrc, link) + return os.Symlink(skillsSrc, filepath.Join(agentsDir, "skills")) } const defaultSkillsDir = "/opt/skills" diff --git a/harness/cmd/migration-harness/main_test.go b/harness/cmd/migration-harness/main_test.go index 9c60bf4f..7ea6687d 100644 --- a/harness/cmd/migration-harness/main_test.go +++ b/harness/cmd/migration-harness/main_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "github.com/konveyor/migration-harness/internal/config" @@ -95,6 +96,60 @@ func TestSymlinkSkillsDir_AlreadyExists(t *testing.T) { } } +func TestSymlinkSkillsDir_RejectsSymlinkedAgentsDir(t *testing.T) { + cloneDir := t.TempDir() + outside := t.TempDir() + skillsSrc := t.TempDir() + + if err := os.Symlink(outside, filepath.Join(cloneDir, ".agents")); err != nil { + t.Fatal(err) + } + + err := symlinkSkillsDir(cloneDir, skillsSrc) + if err == nil { + t.Fatal("expected error when .agents is a symlink") + } + if !strings.Contains(err.Error(), "symlink") { + t.Errorf("error should mention symlink, got: %v", err) + } +} + +func TestSymlinkSkillsDir_ResolvesRelativePath(t *testing.T) { + parent := t.TempDir() + cloneDir := filepath.Join(parent, "repo") + skillsSrc := filepath.Join(parent, "skills") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(skillsSrc, 0o755); err != nil { + t.Fatal(err) + } + + relPath, err := filepath.Rel(parent, skillsSrc) + if err != nil { + t.Fatal(err) + } + + oldWd, _ := os.Getwd() + if err := os.Chdir(parent); err != nil { + t.Fatal(err) + } + defer os.Chdir(oldWd) + + if err := symlinkSkillsDir(cloneDir, relPath); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + link := filepath.Join(cloneDir, ".agents", "skills") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("expected symlink at %s: %v", link, err) + } + if !filepath.IsAbs(target) { + t.Errorf("symlink target should be absolute, got %q", target) + } +} + func TestShouldRevokeToken(t *testing.T) { tests := []struct { name string From c7ad6e10b285c1931f49bc0d940f5a5ba135b157 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan Date: Wed, 12 Aug 2026 09:17:09 -0400 Subject: [PATCH 3/4] Move skill symlink to $HOME to avoid touching repo tree Assisted-By: Claude Code Signed-off-by: Savitha Raghunathan --- .../70-skill-symlink-for-goose-discovery.yaml | 11 +-- harness/cmd/migration-harness/main.go | 34 ++++++--- harness/cmd/migration-harness/main_test.go | 72 +++++++++++++------ harness/internal/watcher/patterns.go | 2 +- 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml b/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml index dae0f41f..b16f90e1 100644 --- a/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml +++ b/changes/unreleased/70-skill-symlink-for-goose-discovery.yaml @@ -1,7 +1,8 @@ kind: feature description: > - Symlink .agents/skills to the skills directory (default /opt/skills, - configurable via HARNESS_SKILLS_DIR) after clone so goose discovers - mounted SkillCard content via its native skill platform. Skills are no - longer injected into the prompt; the agent runtime handles progressive - disclosure. + Symlink $HOME/.agents/skills to the skills directory (default /opt/skills, + configurable via HARNESS_SKILLS_DIR) so goose discovers mounted SkillCard + content via its native skill platform. The link is placed in the home + directory rather than the clone to avoid touching the repo tree. Skills + are no longer injected into the prompt; the agent runtime handles + progressive disclosure. diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index 82f6bf8a..ed323e5c 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -127,7 +127,6 @@ func runStage(cmd *cobra.Command, args []string) error { if hasSkills { if err := git.EnsureGitignore(cloneDir, []string{ - ".agents/", "graphify-out/", ".goose/", "__pycache__/", @@ -140,10 +139,14 @@ func runStage(cmd *cobra.Command, args []string) error { logging.Warn("gitignore: %v", err) } - if err := symlinkSkillsDir(cloneDir, skillsDir()); err != nil { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home dir: %w", err) + } + if err := symlinkSkillsDir(home, skillsDir()); err != nil { return fmt.Errorf("skill symlink: %w", err) } - logging.Ok("symlinked %s/.agents/skills → %s", cloneDir, skillsDir()) + logging.Ok("symlinked %s/.agents/skills → %s", home, skillsDir()) } if hasSkills { @@ -370,23 +373,32 @@ func runStage(cmd *cobra.Command, args []string) error { return nil } -func symlinkSkillsDir(cloneDir, skillsSrc string) error { +func symlinkSkillsDir(homeDir, skillsSrc string) error { skillsSrc, err := filepath.Abs(skillsSrc) if err != nil { return fmt.Errorf("resolve skills source: %w", err) } - agentsDir := filepath.Join(cloneDir, ".agents") - if info, err := os.Lstat(agentsDir); err == nil { + agentsDir := filepath.Join(homeDir, ".agents") + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + return err + } + + link := filepath.Join(agentsDir, "skills") + if info, err := os.Lstat(link); err == nil { if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%s is a symlink (repo-controlled) — refusing to follow", agentsDir) + if target, err := os.Readlink(link); err == nil && target == skillsSrc { + return nil + } + if err := os.Remove(link); err != nil { + return fmt.Errorf("remove stale symlink %s: %w", link, err) + } + } else { + return fmt.Errorf("%s already exists and is not a symlink", link) } } - if err := os.MkdirAll(agentsDir, 0o755); err != nil { - return err - } - return os.Symlink(skillsSrc, filepath.Join(agentsDir, "skills")) + return os.Symlink(skillsSrc, link) } const defaultSkillsDir = "/opt/skills" diff --git a/harness/cmd/migration-harness/main_test.go b/harness/cmd/migration-harness/main_test.go index 7ea6687d..9061d02f 100644 --- a/harness/cmd/migration-harness/main_test.go +++ b/harness/cmd/migration-harness/main_test.go @@ -65,14 +65,14 @@ func TestDiscoverSkills_EmptySkillFile(t *testing.T) { } func TestSymlinkSkillsDir(t *testing.T) { - cloneDir := t.TempDir() + homeDir := t.TempDir() skillsSrc := t.TempDir() - if err := symlinkSkillsDir(cloneDir, skillsSrc); err != nil { + if err := symlinkSkillsDir(homeDir, skillsSrc); err != nil { t.Fatalf("unexpected error: %v", err) } - link := filepath.Join(cloneDir, ".agents", "skills") + link := filepath.Join(homeDir, ".agents", "skills") target, err := os.Readlink(link) if err != nil { t.Fatalf("expected symlink at %s: %v", link, err) @@ -82,43 +82,71 @@ func TestSymlinkSkillsDir(t *testing.T) { } } -func TestSymlinkSkillsDir_AlreadyExists(t *testing.T) { - cloneDir := t.TempDir() +func TestSymlinkSkillsDir_AlreadyExistsDir(t *testing.T) { + homeDir := t.TempDir() skillsSrc := t.TempDir() - if err := os.MkdirAll(filepath.Join(cloneDir, ".agents", "skills"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(homeDir, ".agents", "skills"), 0o755); err != nil { t.Fatal(err) } - err := symlinkSkillsDir(cloneDir, skillsSrc) + err := symlinkSkillsDir(homeDir, skillsSrc) if err == nil { - t.Fatal("expected error when .agents/skills already exists") + t.Fatal("expected error when .agents/skills already exists as a directory") + } + if !strings.Contains(err.Error(), "not a symlink") { + t.Errorf("error should mention 'not a symlink', got: %v", err) } } -func TestSymlinkSkillsDir_RejectsSymlinkedAgentsDir(t *testing.T) { - cloneDir := t.TempDir() - outside := t.TempDir() +func TestSymlinkSkillsDir_Idempotent(t *testing.T) { + homeDir := t.TempDir() skillsSrc := t.TempDir() - if err := os.Symlink(outside, filepath.Join(cloneDir, ".agents")); err != nil { - t.Fatal(err) + if err := symlinkSkillsDir(homeDir, skillsSrc); err != nil { + t.Fatalf("first call: %v", err) + } + if err := symlinkSkillsDir(homeDir, skillsSrc); err != nil { + t.Fatalf("second call (same target) should be idempotent: %v", err) } - err := symlinkSkillsDir(cloneDir, skillsSrc) - if err == nil { - t.Fatal("expected error when .agents is a symlink") + link := filepath.Join(homeDir, ".agents", "skills") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("expected symlink at %s: %v", link, err) + } + if target != skillsSrc { + t.Errorf("symlink target = %q, want %q", target, skillsSrc) + } +} + +func TestSymlinkSkillsDir_RelinksOnDifferentTarget(t *testing.T) { + homeDir := t.TempDir() + oldSrc := t.TempDir() + newSrc := t.TempDir() + + if err := symlinkSkillsDir(homeDir, oldSrc); err != nil { + t.Fatalf("first call: %v", err) + } + if err := symlinkSkillsDir(homeDir, newSrc); err != nil { + t.Fatalf("second call (different target): %v", err) + } + + link := filepath.Join(homeDir, ".agents", "skills") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("expected symlink at %s: %v", link, err) } - if !strings.Contains(err.Error(), "symlink") { - t.Errorf("error should mention symlink, got: %v", err) + if target != newSrc { + t.Errorf("symlink target = %q, want %q", target, newSrc) } } func TestSymlinkSkillsDir_ResolvesRelativePath(t *testing.T) { parent := t.TempDir() - cloneDir := filepath.Join(parent, "repo") + homeDir := filepath.Join(parent, "home") skillsSrc := filepath.Join(parent, "skills") - if err := os.MkdirAll(cloneDir, 0o755); err != nil { + if err := os.MkdirAll(homeDir, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(skillsSrc, 0o755); err != nil { @@ -136,11 +164,11 @@ func TestSymlinkSkillsDir_ResolvesRelativePath(t *testing.T) { } defer os.Chdir(oldWd) - if err := symlinkSkillsDir(cloneDir, relPath); err != nil { + if err := symlinkSkillsDir(homeDir, relPath); err != nil { t.Fatalf("unexpected error: %v", err) } - link := filepath.Join(cloneDir, ".agents", "skills") + link := filepath.Join(homeDir, ".agents", "skills") target, err := os.Readlink(link) if err != nil { t.Fatalf("expected symlink at %s: %v", link, err) diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go index 02ea97d0..790f5963 100644 --- a/harness/internal/watcher/patterns.go +++ b/harness/internal/watcher/patterns.go @@ -1,7 +1,7 @@ package watcher var excludeDirs = map[string]bool{ - ".agents": true, ".goose": true, "__pycache__": true, ".git": true, + ".goose": true, "__pycache__": true, ".git": true, "node_modules": true, "target": true, "graphify-out": true, } From 350d5e27e613e898bdd801fafc9b3dd31d9b041e Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan Date: Wed, 12 Aug 2026 16:17:13 -0400 Subject: [PATCH 4/4] Fix merge artifact in main_test.go Assisted-By: Claude Code Signed-off-by: Savitha Raghunathan --- harness/cmd/migration-harness/main_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/harness/cmd/migration-harness/main_test.go b/harness/cmd/migration-harness/main_test.go index 6a3fa8ad..0a5e3c0f 100644 --- a/harness/cmd/migration-harness/main_test.go +++ b/harness/cmd/migration-harness/main_test.go @@ -178,7 +178,6 @@ func TestSymlinkSkillsDir_ResolvesRelativePath(t *testing.T) { } } -func TestShouldRevokeToken(t *testing.T) { func TestParseHubTokenID(t *testing.T) { tests := []struct { name string