diff --git a/docs/ADRs/0045-forge-portable-harness-schema.md b/docs/ADRs/0045-forge-portable-harness-schema.md index e24be6ac5a..d52c863d60 100644 --- a/docs/ADRs/0045-forge-portable-harness-schema.md +++ b/docs/ADRs/0045-forge-portable-harness-schema.md @@ -187,7 +187,7 @@ resolved as follows: | Field type | Merge behavior | Nil vs empty | |------------------|------------------------------------------------------|-------------------------------------------------------| | Scalar fields | Forge value overrides top-level value | Absent = inherit from top level | -| `skills` | Top-level list + forge-specific list (concatenated) | Absent (nil) = inherit; `skills: []` = no forge-specific additions (top-level skills still apply) | +| `skills` | Merged with deduplication by basename (forge overrides top-level) | Absent (nil) = inherit; `skills: []` = no forge-specific additions (top-level skills still apply) | | `runner_env` | Top-level map merged with forge map; forge keys win | Absent (nil) = inherit; `runner_env: {}` = no forge-specific keys (top-level env still inherited) | | `validation_loop`| Forge value replaces top-level value entirely | Absent (nil) = inherit from top level; explicit empty struct = intended to mean "no validation" but requires implementation changes (see note¹) | @@ -359,7 +359,7 @@ itself is consumed during loading and is not present on the merged harness. The same inheritance table applies to base→child merging: - **Scalar fields** (agent, model, image, pre_script, etc.): child overrides base -- **`skills`**: base list + child list (concatenated) +- **`skills`**: merged with deduplication by basename (child overrides base) - **`runner_env`**: base map merged with child map; child keys win - **`validation_loop`**: child replaces base entirely (if non-nil) - **`host_files`**: concatenated (base + child); if both declare the same @@ -609,7 +609,7 @@ forge-specific artifact. The harness and agent definition are portable. both are written atomically during `fullsend install`. - **Merge semantics add complexity.** The inheritance rules (scalars - override, skills concatenate, runner_env merges, validation_loop replaces) + override, skills merge with deduplication by basename, runner_env merges, validation_loop replaces) must be well-documented and tested. Edge cases — such as a forge block wanting to *remove* an inherited skill or runner_env key — are not supported by this design. If needed, a future extension could add explicit @@ -657,7 +657,7 @@ forge-specific artifact. The harness and agent definition are portable. field type, matching the inheritance rules in the table above: - `skills`: nil = inherit top-level list; `skills: []` = no forge-specific additions (top-level skills still apply, since skills - uses concatenation semantics). + uses merge-with-deduplication-by-basename semantics). - `runner_env`: nil = inherit top-level map; `runner_env: {}` = no forge-specific keys (top-level env still inherited, since runner_env uses merge semantics). diff --git a/docs/ADRs/0064-deprecate-customized-directory-overlay.md b/docs/ADRs/0064-deprecate-customized-directory-overlay.md index 98c4fca468..b483552569 100644 --- a/docs/ADRs/0064-deprecate-customized-directory-overlay.md +++ b/docs/ADRs/0064-deprecate-customized-directory-overlay.md @@ -37,7 +37,9 @@ customization scenario the overlay handled, with better ergonomics: - [ADR 0045](0045-forge-portable-harness-schema.md) added `base:` composition for harness files. A thin wrapper inherits an upstream harness by URL and overrides only the fields that differ, with proper merge - semantics (scalars override, skills concatenate, runner_env merges). + semantics (scalars override, skills merge with deduplication by basename + per [#5408](https://github.com/fullsend-ai/fullsend/pull/5408), runner_env + merges). - [ADR 0038](0038-universal-harness-access.md) added URL-based references for declarative resources (agents, skills, policies, schemas). Resources @@ -55,7 +57,7 @@ Together these make the `customized/` directory overlay redundant: | Override a harness | `base:` composition (ADR 0045) | | Override an agent definition | Harness `agent:` field with path or URL (ADR 0038) | | Add/remove agents | `agents:` list in config (ADR 0058) | -| Add custom skills | Harness `skills:` list with paths or URLs (ADR 0038); concatenated via `base:` (ADR 0045) | +| Add custom skills | Harness `skills:` list with paths or URLs (ADR 0038); merged with deduplication by basename via `base:` (ADR 0045, [#5408](https://github.com/fullsend-ai/fullsend/pull/5408)) | | Override policies/schemas | Harness fields with paths or URLs (ADR 0038) | | Custom scripts | `pre_script`/`post_script` in harness; inherited from `base:` (ADR 0045) | | Custom env vars | `env:` in harness; merged via `base:` (ADR 0045) | diff --git a/docs/ADRs/0070-portable-provider-profile-resolution.md b/docs/ADRs/0070-portable-provider-profile-resolution.md index dd4a2a8997..9e9b6380e4 100644 --- a/docs/ADRs/0070-portable-provider-profile-resolution.md +++ b/docs/ADRs/0070-portable-provider-profile-resolution.md @@ -88,7 +88,7 @@ When a harness declares `base:`, the base YAML is fetched and parsed. The - Base entries come first, child entries append - Deduplication by profile `id` (from profile YAML) / provider `name` (from provider YAML) - Child wins in dedup conflicts -- Same merge pattern as `skills` in ADR 0045 +- Concatenation with last-writer-wins dedup (note: skills in ADR 0045 now use basename-aware dedup per [#5408](https://github.com/fullsend-ai/fullsend/pull/5408); profiles and providers use simpler id/name dedup at resolution time) **Phase 2 — Resource resolution (`resolve.go`)** diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 7f7ab26d93..c08d6fcb91 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -167,7 +167,7 @@ Migration actions per agent: | Custom | Not in upstream scaffold | Move files, register local path in config | | Modified | Standard scaffold agent, not in config | Compute `base:` composition harness via `DiffHarness`, register in config | -The diff engine (`internal/harness/diff.go`) computes the minimal child harness that reproduces the customized version when composed with the upstream base. It mirrors `mergeBaseIntoChild` semantics: scalar overrides, slice concatenation extras, map merge deltas, and security fields always included. +The diff engine (`internal/harness/diff.go`) computes the minimal child harness that reproduces the customized version when composed with the upstream base. It mirrors `mergeBaseIntoChild` semantics: scalar overrides, basename-aware skill overrides (matching `mergeSkills`), slice concatenation extras for plugins/providers, map merge deltas, and security fields always included. ### Command Decomposition diff --git a/docs/guides/user/bring-your-own-agent.md b/docs/guides/user/bring-your-own-agent.md index 1690947539..5291f14c60 100644 --- a/docs/guides/user/bring-your-own-agent.md +++ b/docs/guides/user/bring-your-own-agent.md @@ -275,7 +275,8 @@ security: | Field type | Behavior | |-----------|----------| | Scalars (`model`, `pre_script`, `image`, etc.) | Child wins if non-empty | -| `skills`, `plugins`, `providers`, `api_servers`, `openshell.profiles` | Concatenated (base + child) | +| `skills` | Merged with deduplication by basename (child overrides base) | +| `plugins`, `providers`, `api_servers`, `openshell.profiles` | Concatenated (base + child) | | `host_files` | Concatenated; child overrides by `dest` | | `env`, `runner_env` | Merged; child keys win | | `validation_loop`, `security` | Child replaces entirely | @@ -366,7 +367,7 @@ Create a thin harness that inherits from the upstream code agent and adds your s base: https://raw.githubusercontent.com/fullsend-ai/fullsend//internal/scaffold/fullsend-repo/harness/code.yaml#sha256=abc... skills: - - skills/my-custom-linting # Concatenated with base skills + - skills/my-custom-linting # Merged with base skills (child overrides by basename) timeout_minutes: 45 # Override timeout (scalar → child wins) ``` @@ -420,7 +421,7 @@ env: Any harness field can be overridden. The [field merge rules](#field-merge-rules-for-base-and-forge) determine how your overrides combine with the base: - **Change model, timeout, image, scripts** — scalars replace the base value. -- **Add skills, plugins, or host_files** — your entries are concatenated with the base's. +- **Add skills** — your entries are merged with the base's by basename; same-named skills override the base entry. **Add plugins or host_files** — your entries are concatenated with the base's. - **Add or override env vars** — maps are merged; your keys win on collision. - **Replace validation or security config** — child replaces the entire block. diff --git a/docs/plans/adr-0045-forge-portable-harness-phase1.md b/docs/plans/adr-0045-forge-portable-harness-phase1.md index d537ad0260..295646d5fa 100644 --- a/docs/plans/adr-0045-forge-portable-harness-phase1.md +++ b/docs/plans/adr-0045-forge-portable-harness-phase1.md @@ -49,7 +49,7 @@ PRs 1, 2, 7 can start in parallel. PR 4 depends on PRs 1, 2, and 3 (`loadRaw` fo - `validForgeKeys = map[string]bool{"github": true, "gitlab": true}` - `(h *Harness) ResolveForge(platform string) error` — merges forge overrides into harness in place per ADR rules: - Scalars: forge overrides if non-empty - - Skills: top-level + forge (concatenated) + - Skills: merged with deduplication by basename (forge overrides top-level) - RunnerEnv: top-level + forge map, forge wins on key conflict - ValidationLoop: forge replaces entirely if non-nil - Sets `h.Forge = nil` after merge (consumed) @@ -149,7 +149,8 @@ For **lock file integration**, `base` URLs are recorded as `DependencyEntry` ent - After the full base chain is merged, calls `ResolveForge(opts.ForgePlatform)` once on the final merged result, then calls `Validate()`. This matches the ADR's resolution order: `base harness (recursive) → child overrides → ResolveForge(platform)`. - `mergeHarness(base, child *Harness)` — same inheritance rules as forge merge: - Scalars: child overrides base if non-zero - - Skills, Plugins, Providers, APIServers: concatenated (base + child) + - Skills: merged with deduplication by basename (child overrides base) + - Plugins, Providers, APIServers: concatenated (base + child) - RunnerEnv: base map merged with child map, child keys win - ValidationLoop, Security: child replaces if non-nil - HostFiles: concatenated (base + child order), last-writer-wins dedup by `Dest` (exact string comparison, no path canonicalization) — child entries override base entries with the same `Dest` diff --git a/docs/plans/adr-0045-forge-portable-harness-phase2.md b/docs/plans/adr-0045-forge-portable-harness-phase2.md index ab52121819..d9e61ed5cd 100644 --- a/docs/plans/adr-0045-forge-portable-harness-phase2.md +++ b/docs/plans/adr-0045-forge-portable-harness-phase2.md @@ -358,7 +358,7 @@ The `HarnessWrappersLayer` maintains this mapping. A helper function `harnessNam - `Agent`, `Model`, `Image`, `Policy` inherited from base - `PreScript`, `PostScript` populated (from `forge.github:` after merge) - `RunnerEnv` contains both top-level keys (e.g., `FULLSEND_OUTPUT_SCHEMA`) and GitHub keys (e.g., `GH_TOKEN`) after forge resolution - - `Skills` contains both base skills and forge skills (concatenated) + - `Skills` merged from base and forge with deduplication by basename - `Forge` is nil (consumed by ResolveForge) - `Base` is empty (consumed by LoadWithBase) diff --git a/internal/harness/compose.go b/internal/harness/compose.go index f83d7ca64d..9270f5e06b 100644 --- a/internal/harness/compose.go +++ b/internal/harness/compose.go @@ -472,13 +472,12 @@ func mergeBaseIntoChild(base, child *Harness) { child.SandboxTimeoutSeconds = base.SandboxTimeoutSeconds } - // Concatenated slices: base + child. - // Pre-allocate new slices to avoid mutating base's backing array. - if base.Skills != nil { - merged := make([]string, 0, len(base.Skills)+len(child.Skills)) - merged = append(merged, base.Skills...) - merged = append(merged, child.Skills...) - child.Skills = merged + // Skills: base + child with child-overrides-base-by-basename. + // A child skill whose directory basename matches a base skill replaces + // the base entry (same as host_files' override-by-dest). This allows + // child harnesses to override built-in skills via base: composition. + if base.Skills != nil || child.Skills != nil { + child.Skills = mergeSkills(base.Skills, child.Skills) } if base.Plugins != nil { merged := make([]string, 0, len(base.Plugins)+len(child.Plugins)) @@ -1149,6 +1148,42 @@ func urlIndexPut(workspaceRoot, rawURL, hash string) error { return os.WriteFile(idxPath, out, 0o600) } +// mergeSkills concatenates base and child skill paths, with child entries +// overriding base entries that resolve to the same sandbox directory name +// (filepath.Base). This mirrors mergeHostFiles' override-by-dest behavior +// and allows a child harness to replace a built-in skill by declaring a +// same-named skill via base: composition (see #5408). +// +// Known limitation: if the base slice itself contains two entries with the +// same basename (e.g., /cache/a/skill-x and /cache/b/skill-x), the second +// entry silently overwrites the first in baseIndex. In practice this is +// benign because duplicateDestinationNameError at bootstrap time catches +// duplicate basenames within a single harness. +func mergeSkills(base, child []string) []string { + baseIndex := make(map[string]int, len(base)) + result := make([]string, 0, len(base)+len(child)) + + // Add base entries + for _, s := range base { + name := filepath.Base(s) + baseIndex[name] = len(result) + result = append(result, s) + } + + // Add/override with child entries + for _, s := range child { + name := filepath.Base(s) + if idx, exists := baseIndex[name]; exists { + result[idx] = s // child overrides base + } else { + baseIndex[name] = len(result) + result = append(result, s) + } + } + + return result +} + // mergeHostFiles concatenates base and child host files, with child entries // overriding base entries that have the same Dest path. func mergeHostFiles(base, child []HostFile) []HostFile { @@ -1212,12 +1247,9 @@ func mergeForgeConfigInto(base, child *ForgeConfig) { child.PostScript = base.PostScript } - // Skills: concatenate (pre-allocate to avoid mutating base's backing array) - if base.Skills != nil { - merged := make([]string, 0, len(base.Skills)+len(child.Skills)) - merged = append(merged, base.Skills...) - merged = append(merged, child.Skills...) - child.Skills = merged + // Skills: base + child with child-overrides-base-by-basename + if base.Skills != nil || child.Skills != nil { + child.Skills = mergeSkills(base.Skills, child.Skills) } // RunnerEnv: merge, child keys win diff --git a/internal/harness/compose_test.go b/internal/harness/compose_test.go index b1a63d28a4..b3556d66ea 100644 --- a/internal/harness/compose_test.go +++ b/internal/harness/compose_test.go @@ -106,10 +106,134 @@ skills: h, _, err := LoadWithBase(context.Background(), path, ComposeOpts{}) require.NoError(t, err) - // Skills concatenated: base + child + // Skills concatenated: base + child (no name collision) assert.Equal(t, []string{"skill-a", "skill-b", "skill-c"}, h.Skills) } +// TestLoadWithBase_ChildSkillOverridesBaseByBasename verifies that a child +// skill whose directory basename matches a base skill replaces the base entry +// instead of producing a duplicate that trips duplicateDestinationNameError +// at bootstrap time (see #5408). +func TestLoadWithBase_ChildSkillOverridesBaseByBasename(t *testing.T) { + dir := t.TempDir() + + writeTestHarness(t, dir, "base.yaml", ` +agent: agents/test.md +role: test +skills: + - /cache/sha256/abc123/code-implementation + - /cache/sha256/def456/pr-review +`) + + path := writeTestHarness(t, dir, "child.yaml", ` +base: base.yaml +skills: + - skills/code-implementation +`) + + h, _, err := LoadWithBase(context.Background(), path, ComposeOpts{}) + require.NoError(t, err) + + // Child's code-implementation replaces base's, pr-review stays + require.Len(t, h.Skills, 2) + assert.Equal(t, "skills/code-implementation", h.Skills[0]) + assert.Equal(t, "/cache/sha256/def456/pr-review", h.Skills[1]) +} + +// TestLoadWithBase_ChildSkillOverride_PreservesOrder verifies that when a +// child overrides multiple base skills, the merged list preserves base +// ordering for non-overridden entries and replaces overridden entries +// in-place. +func TestLoadWithBase_ChildSkillOverride_PreservesOrder(t *testing.T) { + dir := t.TempDir() + + writeTestHarness(t, dir, "base.yaml", ` +agent: agents/test.md +role: test +skills: + - /cache/skill-a + - /cache/skill-b + - /cache/skill-c +`) + + path := writeTestHarness(t, dir, "child.yaml", ` +base: base.yaml +skills: + - local/skill-b + - local/skill-d +`) + + h, _, err := LoadWithBase(context.Background(), path, ComposeOpts{}) + require.NoError(t, err) + + // skill-b replaced in-place, skill-d appended + assert.Equal(t, []string{ + "/cache/skill-a", + "local/skill-b", + "/cache/skill-c", + "local/skill-d", + }, h.Skills) +} + +// TestMergeSkills verifies the mergeSkills helper directly. +func TestMergeSkills(t *testing.T) { + tests := []struct { + name string + base []string + child []string + want []string + }{ + { + name: "no overlap appends", + base: []string{"/base/skill-a"}, + child: []string{"/child/skill-b"}, + want: []string{"/base/skill-a", "/child/skill-b"}, + }, + { + name: "child overrides base by basename", + base: []string{"/base/skill-a", "/base/skill-b"}, + child: []string{"/child/skill-a"}, + want: []string{"/child/skill-a", "/base/skill-b"}, + }, + { + name: "nil base", + base: nil, + child: []string{"/child/skill-a"}, + want: []string{"/child/skill-a"}, + }, + { + name: "nil child", + base: []string{"/base/skill-a"}, + child: nil, + want: []string{"/base/skill-a"}, + }, + { + name: "both nil", + base: nil, + child: nil, + want: []string{}, + }, + { + name: "full override", + base: []string{"/cache/sha256/abc/code-implementation"}, + child: []string{"skills/code-implementation"}, + want: []string{"skills/code-implementation"}, + }, + { + name: "duplicate child basename deduplicates", + base: []string{"/base/skill-a"}, + child: []string{"/child1/skill-b", "/child2/skill-b"}, + want: []string{"/base/skill-a", "/child2/skill-b"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeSkills(tt.base, tt.child) + assert.Equal(t, tt.want, got) + }) + } +} + func TestLoadWithBase_LocalBase_RunnerEnvMerge(t *testing.T) { dir := t.TempDir() diff --git a/internal/harness/diff.go b/internal/harness/diff.go index df7115c894..5b16432a10 100644 --- a/internal/harness/diff.go +++ b/internal/harness/diff.go @@ -1,6 +1,7 @@ package harness import ( + "path/filepath" "reflect" ) @@ -99,10 +100,10 @@ func DiffHarness(base, child *Harness, customizedFiles map[string]bool) *DiffRes hasAny = true } - // String slices (concatenated by mergeBaseIntoChild) — keep only extras. - // Pass nil for customizedFiles: file-path override semantics only apply - // to scalar fields, not concatenated slices (would cause duplication). - if extras, removed := diffStringSlice(base.Skills, child.Skills, nil); len(extras) > 0 || removed { + // Skills use basename-aware diffing to match mergeSkills' override-by- + // basename semantics. A child skill that replaces a base skill (same + // basename, different path) is an override, not a removal + addition. + if extras, removed := diffSkills(base.Skills, child.Skills); len(extras) > 0 || removed { if removed { result.Warnings = append(result.Warnings, "skills: child removes items from base; cannot express with base: composition") result.Child = nil @@ -253,6 +254,43 @@ func diffStringSlice(base, child []string, customizedFiles map[string]bool) (ext return extras, removed } +// diffSkills returns skill entries in child that override base entries (by +// filepath.Base basename) or are entirely new, and reports whether any base +// entries were removed. Unlike diffStringSlice which compares exact paths, +// diffSkills compares by basename to match mergeSkills' override-by-basename +// semantics (see #5408). +func diffSkills(base, child []string) (extras []string, removed bool) { + baseByName := make(map[string]string, len(base)) + for _, s := range base { + baseByName[filepath.Base(s)] = s + } + + childByName := make(map[string]bool, len(child)) + for _, s := range child { + childByName[filepath.Base(s)] = true + } + + // Check for base entries removed in child (no basename match) + for _, s := range base { + if !childByName[filepath.Base(s)] { + removed = true + break + } + } + + // Extras: child entries that are new or override a base entry + // (same basename but different full path) + for _, s := range child { + name := filepath.Base(s) + basePath, inBase := baseByName[name] + if !inBase || basePath != s { + extras = append(extras, s) + } + } + + return extras, removed +} + // diffHostFiles returns HostFile entries in child whose Dest is not in base, // or whose fields differ from the base entry with the same Dest. It also // reports whether any base entries were removed from child. @@ -422,7 +460,7 @@ func diffForgeConfig(base, child *ForgeConfig, platform string) (*ForgeConfig, [ fc.PostScript = child.PostScript hasAny = true } - if extras, removed := diffStringSlice(base.Skills, child.Skills, nil); len(extras) > 0 || removed { + if extras, removed := diffSkills(base.Skills, child.Skills); len(extras) > 0 || removed { if removed { return nil, []string{"forge[" + platform + "].skills: child removes items from base; cannot express with base: composition"} } diff --git a/internal/harness/diff_test.go b/internal/harness/diff_test.go index f8f49de78f..97378f7314 100644 --- a/internal/harness/diff_test.go +++ b/internal/harness/diff_test.go @@ -50,6 +50,22 @@ func TestDiffHarness_SliceAddition(t *testing.T) { assert.Empty(t, result.Warnings) } +// TestDiffHarness_SkillOverrideByBasename verifies that when a child skill +// overrides a base skill by basename (different full path, same basename), +// diffSkills treats this as an override (extra) rather than a removal+addition. +func TestDiffHarness_SkillOverrideByBasename(t *testing.T) { + base := &Harness{ + Skills: []string{"/cache/sha256/abc/code-implementation", "skills/pr-review"}, + } + child := &Harness{ + Skills: []string{"skills/code-implementation", "skills/pr-review"}, + } + result := DiffHarness(base, child, nil) + require.NotNil(t, result.Child, "basename override should not abort diff") + assert.Equal(t, []string{"skills/code-implementation"}, result.Child.Skills) + assert.Empty(t, result.Warnings) +} + func TestDiffHarness_SliceRemoval(t *testing.T) { base := &Harness{ Skills: []string{"skills/a", "skills/b", "skills/c"}, @@ -602,6 +618,31 @@ func TestDiffHarness_ForgeSkillsRemoval(t *testing.T) { assert.Contains(t, result.Warnings[0], "forge[github].skills") } +// TestDiffHarness_ForgeSkillsOverrideByBasename verifies that forge skill +// overrides by basename are treated as extras, not removals. +func TestDiffHarness_ForgeSkillsOverrideByBasename(t *testing.T) { + base := &Harness{ + Forge: map[string]*ForgeConfig{ + "github": { + Skills: []string{"/cache/code-review", "skill/common"}, + }, + }, + } + child := &Harness{ + Forge: map[string]*ForgeConfig{ + "github": { + Skills: []string{"skills/code-review", "skill/common"}, + }, + }, + } + result := DiffHarness(base, child, nil) + require.NotNil(t, result.Child, "forge basename override should not abort diff") + require.NotNil(t, result.Child.Forge) + require.NotNil(t, result.Child.Forge["github"]) + assert.Equal(t, []string{"skills/code-review"}, result.Child.Forge["github"].Skills) + assert.Empty(t, result.Warnings) +} + func TestDiffHarness_ForgePlatformRemoval(t *testing.T) { base := &Harness{ Forge: map[string]*ForgeConfig{ diff --git a/internal/harness/forge.go b/internal/harness/forge.go index 3cdde07a5e..8c0d08cbed 100644 --- a/internal/harness/forge.go +++ b/internal/harness/forge.go @@ -122,7 +122,7 @@ func mergeForgeConfig(h *Harness, fc *ForgeConfig) { } if fc.Skills != nil { - h.Skills = append(h.Skills, fc.Skills...) + h.Skills = mergeSkills(h.Skills, fc.Skills) } if fc.RunnerEnv != nil { diff --git a/internal/harness/forge_test.go b/internal/harness/forge_test.go index c9cfa31bc4..10576fb692 100644 --- a/internal/harness/forge_test.go +++ b/internal/harness/forge_test.go @@ -57,6 +57,24 @@ func TestResolveForge_SkillsConcat(t *testing.T) { assert.Equal(t, []string{"skills/common-a", "skills/common-b", "skills/gh-specific"}, h.Skills) } +// TestResolveForge_SkillsOverrideByBasename verifies that a forge skill +// whose basename matches a top-level skill replaces it instead of producing +// a duplicate (see #5408). +func TestResolveForge_SkillsOverrideByBasename(t *testing.T) { + h := &Harness{ + Agent: "agents/test.md", + Skills: []string{"/cache/code-implementation", "skills/common-b"}, + Forge: map[string]*ForgeConfig{ + "github": { + Skills: []string{"skills/code-implementation"}, + }, + }, + } + + require.NoError(t, h.ResolveForge("github")) + assert.Equal(t, []string{"skills/code-implementation", "skills/common-b"}, h.Skills) +} + func TestResolveForge_NilSkillsInherits(t *testing.T) { h := &Harness{ Agent: "agents/test.md",