@@ -11,7 +11,6 @@ import (
1111 "path/filepath"
1212 "regexp"
1313 "slices"
14− "sort"
1514 "strings"
1615 "sync"
1716
@@ -30,6 +29,9 @@ const (
3029var (
3130 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
3231 promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
32+
33+ latestStates []*SkillState
34+ latestStatesMu sync.RWMutex
3335)
3436
3537// Skill represents a parsed SKILL.md file.
@@ -75,6 +77,41 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
7577 return broker.Subscribe(ctx)
7678}
7779
80+// PublishStates publishes a skill discovery event with the given states.
81+func PublishStates(states []*SkillState) {
82+ broker.Publish(pubsub.UpdatedEvent, Event{States: cloneStates(states)})
83+}
84+
85+// cloneStates returns a deep copy of the given state slice so callers cannot
86+// accidentally mutate the source.
87+func cloneStates(states []*SkillState) []*SkillState {
88+ if states == nil {
89+ return nil
90+ }
91+ result := make([]*SkillState, len(states))
92+ for i, s := range states {
93+ clone := *s
94+ result[i] = &clone
95+ }
96+ return result
97+}
98+
99+// GetLatestStates returns the latest discovery states.
100+func GetLatestStates() []*SkillState {
101+ latestStatesMu.RLock()
102+ defer latestStatesMu.RUnlock()
103+ return cloneStates(latestStates)
104+}
105+
106+// SetLatestStates stores the given states in the package-level cache so that
107+// GetLatestStates can return them synchronously before the first pubsub event
108+// arrives.
109+func SetLatestStates(states []*SkillState) {
110+ latestStatesMu.Lock()
111+ latestStates = cloneStates(states)
112+ latestStatesMu.Unlock()
113+}
114+
78115// Validate checks if the skill meets spec requirements.
79116func (s *Skill) Validate() error {
80117 var errs []error
@@ -244,15 +281,15 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
244281 }
245282
246283 // fastwalk traversal order is non-deterministic, so sort for stable output.
247− sort.SliceStable(skills, func(i, j int) bool {
248− left := strings.ToLower(skills[i].SkillFilePath)
249− right := strings.ToLower(skills[j].SkillFilePath)
250− if left == right {
251− return skills[i].SkillFilePath < skills[j].SkillFilePath
284+ // Sort by path first, then alphabetically by name within each path.
285+ slices.SortStableFunc(skills, func(a, b *Skill) int {
286+ if c := strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path)); c != 0 {
287+ return c
252288 }
253− return left < right
289+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
254290 })
255291
292+ // Publish states as-is; the coordinator will merge and re-sort them later.
256293 broker.Publish(pubsub.UpdatedEvent, Event{States: states})
257294 return skills, states
258295}
@@ -283,6 +320,26 @@ func escape(s string) string {
283320 return promptReplacer.Replace(s)
284321}
285322
323+// DeduplicateStates removes duplicate skill states by name. When duplicates exist,
324+// the last occurrence wins (consistent with Deduplicate for skills).
325+func DeduplicateStates(all []*SkillState) []*SkillState {
326+ seen := make(map[string]int, len(all))
327+ for i, s := range all {
328+ if s.Name != "" {
329+ seen[s.Name] = i
330+ }
331+ }
332+
333+ result := make([]*SkillState, 0, len(seen))
334+ for i, s := range all {
335+ // If it's the last occurrence of this name, or it has no name (error state), keep it
336+ if s.Name == "" || seen[s.Name] == i {
337+ result = append(result, s)
338+ }
339+ }
340+ return result
341+}
342+
286343// Deduplicate removes duplicate skills by name. When duplicates exist, the
287344// last occurrence wins. This means user skills (appended after builtins)
288345// override builtin skills with the same name.