detached 5cc5482

fix(skills): tell the model where each skill body lives

The system-prompt addendum now tags every skill with a source
pointer: '[builtin]' for skills embedded in the zot binary, or the
SKILL.md path (HOME collapsed to ~) for user-installed ones. The
body still loads on demand through the 'skill' tool by name, so
no behaviour change for execution, this is pure disambiguation.

Before:
  - write-zot-extension — Help the user create a new zot extension...

After:
  - write-zot-extension [builtin]: Help the user create a new zot extension...
  - code-review [~/Library/Application Support/zot/skills/code-review/SKILL.md]: ...

Why: built-in skills have no filesystem path because their
markdown is embedded in the binary. Without the [builtin] tag the
model had no way to distinguish them from user skills, and could
mistakenly try to read a nonexistent file. The path pointer for
user skills also helps the model cite where guidance came from
and reason about trust (builtin vs project-local vs global).

Test updated; addendum grew to ~123 tokens for a typical 3-skill
setup (code-review, test-fix, write-zot-extension).
commit
5cc54822cd09b22af2e8dedbd38a3f67ced2d651
author
patriceckhart · · 2026-04-19 15:24 UTC
parent
1a2ab42
signature
unsigned
refs
tag: v0.0.38
2 files · +43 -8
M internal/skills/skills.go +36 -4
@@ -155,24 +155,56 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err
155155 // SystemPromptAddendum returns the text to append to the system
156156 // prompt when at least one skill is loaded. Empty string if none.
157157 //
158-// Format kept short and explicit so the model reliably calls the
159-// `skill` tool with a name from the list rather than guessing.
158+// The format is deliberately compact: name, one-line description,
159+// and a source pointer telling the model where the full body
160+// lives. Built-in skills show "builtin" since their markdown is
161+// embedded in the zot binary and not on the filesystem; user
162+// skills show their SKILL.md path (shortened with ~ for HOME).
163+//
164+// Loading still goes through the `skill` tool with just the name.
165+// The pointer is there so the model can (a) mention the source
166+// honestly in explanations and (b) distinguish between built-ins
167+// and user-authored instruction sets when reasoning about trust.
160168 func SystemPromptAddendum(skills []*Skill) string {
161169 if len(skills) == 0 {
162170 return ""
163171 }
172+ home, _ := os.UserHomeDir()
164173 var sb strings.Builder
165- sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")
174+ sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
166175 for _, s := range skills {
167176 desc := strings.TrimSpace(s.Description)
168177 if desc == "" {
169178 desc = "(no description)"
170179 }
171- fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)
180+ pointer := skillSourcePointer(s, home)
181+ fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
172182 }
173183 return sb.String()
174184 }
175185
186+// skillSourcePointer returns a short tag describing where a skill
187+// originates. Built-ins are tagged "builtin" because their markdown
188+// is embedded in the zot binary and not reachable through the
189+// filesystem. User skills are tagged with their SKILL.md path,
190+// collapsed to use ~ for the user home when possible.
191+func skillSourcePointer(s *Skill, home string) string {
192+ if s == nil {
193+ return "unknown"
194+ }
195+ if s.Builtin {
196+ return "builtin"
197+ }
198+ p := s.Path
199+ if p == "" {
200+ return "unknown"
201+ }
202+ if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
203+ return "~" + p[len(home):]
204+ }
205+ return p
206+}
207+
176208 // FindByName returns the skill with the given name, or nil.
177209 func FindByName(skills []*Skill, name string) *Skill {
178210 for _, s := range skills {
@@ -155,24 +155,56 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err
155 // SystemPromptAddendum returns the text to append to the system155 // SystemPromptAddendum returns the text to append to the system
156 // prompt when at least one skill is loaded. Empty string if none.156 // prompt when at least one skill is loaded. Empty string if none.
157 //157 //
158-// Format kept short and explicit so the model reliably calls the158+// The format is deliberately compact: name, one-line description,
159-// `skill` tool with a name from the list rather than guessing.159+// and a source pointer telling the model where the full body
160+// lives. Built-in skills show "builtin" since their markdown is
161+// embedded in the zot binary and not on the filesystem; user
162+// skills show their SKILL.md path (shortened with ~ for HOME).
163+//
164+// Loading still goes through the `skill` tool with just the name.
165+// The pointer is there so the model can (a) mention the source
166+// honestly in explanations and (b) distinguish between built-ins
167+// and user-authored instruction sets when reasoning about trust.
160 func SystemPromptAddendum(skills []*Skill) string {168 func SystemPromptAddendum(skills []*Skill) string {
161 if len(skills) == 0 {169 if len(skills) == 0 {
162 return ""170 return ""
163 }171 }
172+ home, _ := os.UserHomeDir()
164 var sb strings.Builder173 var sb strings.Builder
165- sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")174+ sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
166 for _, s := range skills {175 for _, s := range skills {
167 desc := strings.TrimSpace(s.Description)176 desc := strings.TrimSpace(s.Description)
168 if desc == "" {177 if desc == "" {
169 desc = "(no description)"178 desc = "(no description)"
170 }179 }
171- fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)180+ pointer := skillSourcePointer(s, home)
181+ fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
172 }182 }
173 return sb.String()183 return sb.String()
174 }184 }
175 185
186+// skillSourcePointer returns a short tag describing where a skill
187+// originates. Built-ins are tagged "builtin" because their markdown
188+// is embedded in the zot binary and not reachable through the
189+// filesystem. User skills are tagged with their SKILL.md path,
190+// collapsed to use ~ for the user home when possible.
191+func skillSourcePointer(s *Skill, home string) string {
192+ if s == nil {
193+ return "unknown"
194+ }
195+ if s.Builtin {
196+ return "builtin"
197+ }
198+ p := s.Path
199+ if p == "" {
200+ return "unknown"
201+ }
202+ if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
203+ return "~" + p[len(home):]
204+ }
205+ return p
206+}
207+
176 // FindByName returns the skill with the given name, or nil.208 // FindByName returns the skill with the given name, or nil.
177 func FindByName(skills []*Skill, name string) *Skill {209 func FindByName(skills []*Skill, name string) *Skill {
178 for _, s := range skills {210 for _, s := range skills {
M internal/skills/skills_test.go +7 -4
@@ -108,12 +108,15 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) {
108108
109109 func TestSystemPromptAddendum(t *testing.T) {
110110 skills := []*Skill{
111- {Name: "a", Description: "Do A."},
112- {Name: "b", Description: "Do B."},
111+ {Name: "built-a", Description: "Do A.", Builtin: true},
112+ {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
113113 }
114114 out := SystemPromptAddendum(skills)
115- if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) {
116- t.Errorf("addendum missing entries:\n%s", out)
115+ if !contains(out, "- built-a [builtin]: Do A.\n") {
116+ t.Errorf("builtin entry missing or wrong:\n%s", out)
117+ }
118+ if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
119+ t.Errorf("user entry missing path pointer:\n%s", out)
117120 }
118121 }
119122
@@ -108,12 +108,15 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) {
108 108
109 func TestSystemPromptAddendum(t *testing.T) {109 func TestSystemPromptAddendum(t *testing.T) {
110 skills := []*Skill{110 skills := []*Skill{
111- {Name: "a", Description: "Do A."},111+ {Name: "built-a", Description: "Do A.", Builtin: true},
112- {Name: "b", Description: "Do B."},112+ {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
113 }113 }
114 out := SystemPromptAddendum(skills)114 out := SystemPromptAddendum(skills)
115- if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) {115+ if !contains(out, "- built-a [builtin]: Do A.\n") {
116- t.Errorf("addendum missing entries:\n%s", out)116+ t.Errorf("builtin entry missing or wrong:\n%s", out)
117+ }
118+ if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
119+ t.Errorf("user entry missing path pointer:\n%s", out)
117 }120 }
118 }121 }
119 122

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close