bots-garden/mini-mepublic Fork
⑂ 11f470a
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge feature/skill-discovery into main

rickub committed 2026-09-21T19:02:08Z Browse files
11f470a parents: dda11d4 0f8196a
modified .tickets/issues/0003-improve-tui.yaml +2 -2
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 3
33 title: Improve TUI
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-15T22:25:16.449Z
9-updatedAt: 2026-09-18T18:55:57.663Z
9+updatedAt: 2026-09-21T11:52:52.713Z
1010 labels:
1111 - kind::feature
1212 - priority::medium
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 32 id: 3
3 title: Improve TUI3 title: Improve TUI
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-15T22:25:16.449Z8 createdAt: 2026-09-15T22:25:16.449Z
9-updatedAt: 2026-09-18T18:55:57.663Z9+updatedAt: 2026-09-21T11:52:52.713Z
10 labels:10 labels:
11 - kind::feature11 - kind::feature
12 - priority::medium12 - priority::medium
modified .tickets/issues/0015-improve-skills-detection.yaml +266 -15
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 15
33 title: Improve skills detection
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-16T05:52:51.530Z
9-updatedAt: 2026-09-21T05:45:00.000Z
9+updatedAt: 2026-09-21T19:00:59.231Z
1010 labels: []
1111 milestone: v0.0.3
1212 body: |
@@ -71,61 +71,312 @@ body: |
7171 ### Not re-opened
7272
7373 Decisions in force per `.memory/summary.md`, none of which the above touches: both layouts accepted; the flat file wins when both exist; `skills/README.md` skipped; relative `skillsDir` resolved by `resolveSkillsDir`; banner warning at count zero.
74+ ---
75+
76+ ## Tier 1 implemented (Claude, 2026-09-21, branch `feature/skill-discovery`)
77+
78+ Tasks 1–5 closed. Tasks 6–7 (the eval, then the prompt block) deliberately left open: nothing there should start before the eval exists.
79+
80+ ### 1. `read_skill` resolves through the catalogue
81+
82+ `skills.Find` / `skills.Load` replace `skills.Read`. The model's string is now only ever compared against the names the catalogue advertises and **never reaches the filesystem** — so the `filepath.Base` barrier is gone because there is nothing left to escape from, which the test `TestALoadedNameNeverReachesTheFilesystem` pins.
83+
84+ Matching is forgiving in the two ways a model gets a name wrong and a human does not — stray whitespace or quotes, and the wrong case — and never forgiving about *which* skill: an exact name always beats a case-insensitive one.
85+
86+ **Found while doing it**: `List` used `sort.Slice`, which is **not stable**. The recorded rule "when both layouts claim a name, the flat file wins" was therefore decided by coin toss as soon as the lookup went through the sorted list. Now `sort.SliceStable` over a slice whose flat entries come first, and a test pins the rule.
87+
88+ ### 2 and 3. `bg-jobs`, and the test that prevents the class
89+
90+ `skills/bg-jobs/SKILL.md` written — start (both streams redirected, pid kept), **poll for readiness instead of sleeping**, inspect with `kill -0` and the log, stop politely then insist, and the traps (one shell per call, port already held, no stdin, stray `nohup.out`).
91+
92+ The durable half is `TestEverySkillNamedInAShippedPromptExists`: it scans every `agent*.yaml`, `tests/agent*.yaml` and the built-in default prompt for the `` `name` skill `` shape and asserts each one resolves. Verified by removing the new skill — it fails on all four sites at once, naming each.
93+
94+ ### 4. `keywords` parsed and rendered
95+
96+ Capped at 8 per skill, because the catalogue is paid for on every request. What the model now sees:
97+
98+ ```
99+ greetings — Greet somebody with a festive message. (greet, greetings, hello, hi, welcome, bonjour, coucou, saluer)
100+ ```
101+
102+ `docs/{en,fr}/reference/skill-format.md` corrected: it stated that `keywords` was "accepted in the file and ignored by the loader".
103+
104+ ### 5. Two new warnings, and a recap that can say zero
105+
106+ Banner: `N skill(s) with no description (…) — the model sees only the name and will pick blindly`, and `two skills share a name (…) — only the first is reachable`. Neither could fire before, since the only warning triggered at a count of zero.
107+
108+ `engine.Skills` now counts skills **loaded**, not `read_skill` responses: a miss answers `[skill: no skill named …]`, the `[tool: …]` shape the other tools use, and is not counted. The recap prints `📖 N skill(s)` whenever the agent has skills at all (`engine.SkillsAvailable`, set once by `main`), zero included.
109+
110+ **`📖 0 skill(s)` is the point of the whole item**: before, a turn where no skill fired and a turn where one fired and was ignored looked identical — the segment was simply absent.
111+
112+ ### Verified end to end (qwen3.5-4b against a real DMR)
113+
114+ | Asked | What happened |
115+ |---|---|
116+ | *« quelle heure est-il ? »* | reached `what-time-is-it` through the `heure` / `quelle heure` keywords → `⚙ 1 command(s) · 📖 1 skill(s)` |
117+ | *"What is 17 times 23?"* | no skill, and it says so: `⚙ 0 command(s) · 📖 0 skill(s)` |
118+ | *« Salue Bob de ma part »* | reached a skill from a French phrase — but picked `vulcan-salute` over `greetings`. **Both claim the same keyword space** (`salut` / `saluer`). The bridge works; the choice between two overlapping skills is exactly what task 6's eval is for. |
119+
120+ Quality gate run 34: 10 warnings, 20 smells — the baseline. `go test ./...`, `gofmt`, `go vet` green.
121+
122+ ### Not done, on purpose
123+
124+ Tasks 6 and 7. Also untouched: the `📖` line in the ACP trail still prints unconditionally on stderr (it always did), and the session `errors` trail still records no tool-level failure (Tier 3, item 10).
125+
126+ ---
127+
128+ ## Task 6 done: the routing eval exists, and here is its first reading (Claude, 2026-09-21)
129+
130+ `internal/eval`, opt-in:
131+
132+ ```
133+ MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 60m
134+ ```
135+
136+ `go test ./...` skips it, so nothing in CI needs a server. Docs: `docs/{en,fr}/how-to/measure-skill-routing.md`.
137+
138+ ### What it measures
139+
140+ **The catalogue, not the agent.** One model turn per case, with stub tools that record what was asked for and run nothing — `read_skill` carries the real `skills.Catalogue(list)`, which is the thing under test. Rationale: `vulcan-salute` says "run this echo", and letting the agent loop would echo twenty times and measure recovery as much as routing.
141+
142+ 20 cases, EN and FR, deliberately **not** phrased in the catalogue's words, including six where no skill applies and abstaining is the right answer.
143+
144+ Four outcomes, and **`wrong` is kept apart from `miss`** because they have different fixes: a skill loaded but the wrong one is a *catalogue* problem, nothing loaded at all is a *prompt* problem.
145+
146+ **No threshold, and it does not fail on a low score.** A number that fails a build becomes a target, and the first response to a red build is to edit the cases. Use it as a before/after, one change at a time.
147+
148+ ### First reading — qwen3.5-4b, 5 skills, catalogue 1048 bytes
149+
150+ ```
151+ 20 cases · hit 16 · miss 1 · error 3 → 80% right
152+ ```
153+
154+ Of the 17 cases that completed: **16 hits, 1 miss**. Every skill was reached in both English and French, including `give Spock the proper farewell` → `vulcan-salute`, which shares no word with that skill's name or description and could only have come through the keywords. All six abstention cases were correct — the catalogue does not fire on "write a haiku about pointers".
155+
156+ **The one genuine routing miss**: `« qui appelle cette fonction ? »` → no skill. French, and about symbols. Its English twin (`where is the Greet function defined?`) did not miss. That is the kind of single, concrete finding this ticket wanted.
157+
158+ **The 3 errors are not routing at all** — the model could not compose a valid tool call: twice `unexpected end of JSON input`, once the runaway guard cutting off 2558 characters of repeated `{"command":"ls -laR …"}`. Same failure family the codebase already carries three guards for (`normalize`, `cleanSymbol`, `toolCallGuard`), now observed on a different model. **No catalogue or prompt work fixes that**, and the eval says so rather than scoring it.
159+
160+ ### The eval found two defects in itself, both fixed
161+
162+ 1. The first stubs answered `[eval] command not run`. Given nothing to finish on, the model reran the same command until the runaway guard fired or the case timed out: **5 of 20 cases were artefacts of the instrument**. Stubs now answer that the work is done and the turn should end — run time also dropped from minutes per case to 5–20s.
163+ 2. `caseTimeout` was 2 minutes, and three good cases hit it exactly. Raised to 5 minutes: an `error` poisons the whole table, so the cap exists to catch a hung server, not to score speed.
164+
165+ Rows now stream to stderr as they complete (`testing` buffers `t.Log` to the end, and a silent multi-minute eval looks hung), and `os.Stdout` is redirected for the run because `engine.emit` writes the answer with `fmt.Print`, not through `ui.Out`.
166+
167+ ### What this changes for task 7
168+
169+ Task 7 (fix the SKILLS prompt block) now has an instrument and a baseline. Two things to know before touching it:
170+
171+ - The **`Salue Bob de ma part`** collision does **not** reproduce here: the eval picks `greetings` in both runs, where the full agent picked `vulcan-salute` earlier the same day. One-turn-with-stubs and the real loop are not the same setting. Keep the case, do not declare it fixed.
172+ - The single miss is a **French** case whose English twin passes. That points at the catalogue's French coverage, not at the SKILLS block — worth checking before rewriting the prompt.
173+
174+ Quality gate run 35: 10 warnings, 20 smells — unchanged.
175+
176+ ---
177+
178+ ## What the eval established, and what it refuted (Claude, 2026-09-21)
179+
180+ The eval's first real use. **Three keyword hypotheses, three refutations, zero routing improvement — and that is the result.** Every one of the three sounded right and would have shipped without an instrument.
181+
182+ Baseline, mellum2-12b, 23 cases: `hit 17 · wrong 2 · miss 4`.
183+
184+ ### Refuted (1): "the French miss is because no French keyword survives the 8-word cap"
185+
186+ `lsp-navigation` has 16 keywords and the surviving 8 were English jargon, three of them duplicating the description. Reordered so French sat inside the cap. On qwen the totals did not move and cases merely shuffled; on mellum, `« qui appelle cette fonction ? »` **still misses**. Reverted.
187+
188+ ### Refuted (2): "`greet` as a keyword hijacks `where is the Greet function defined?`"
189+
190+ That request routes to `greetings` instead of `lsp-navigation`. Dropped `greet` from the keywords, re-measured — **it still routes to `greetings`**. So it is the skill's **NAME** colliding with the symbol `Greet`, and no keyword edit can fix it. Reverted.
191+
192+ **Finding worth keeping**: a skill named after a common code identifier will hijack requests about that identifier. Either rename the skill or accept it; keywords are the wrong lever.
193+
194+ ### Refuted (3): "`bg-jobs` needs its cap spread across its four actions"
195+
196+ Its surviving 8 say "start" four ways (`serve`, `daemon`, `nohup`, `start`) and "stop" none — `stop`, `kill`, `running` are all cut. Spread them across start/wait/inspect/stop. Result: **worse**, 17 hits → 15, then 16 on a second run, with `start the dev server in the background` stably flipping from hit to **miss**. The "redundant" synonyms were carrying weight; the keyword set does not behave additively. Reverted.
197+
198+ ### Established: the language is irrelevant
199+
200+ `bg-jobs` was missing in English and hitting in French, and the obvious reading was thin French coverage — except **`bg-jobs` has no French keyword at all**. Three cases added to the eval settle it, a clean 2×2:
201+
202+ | Request | Result |
203+ |---|---|
204+ | `start the dev server in the background` — English, **names** the concept | ✓ hit |
205+ | `lance le serveur en tâche de fond` — French, **names** the concept | ✓ hit |
206+ | `start the dev server and check it answers` — English, only a situation | · miss |
207+ | `démarre le serveur de dev et vérifie qu'il répond` — French, only a situation | · miss |
208+
209+ **What decides it is whether the request names the concept the skill is about**, not which language it is in. That moves the remaining `bg-jobs` failures out of the catalogue and into the prompt: the model has no reason to know that starting a server blocks the turn.
210+
211+ ### Established: run the eval on mellum2-12b
212+
213+ qwen3.5-4b errors on all three `lsp` cases — tool-call composition, not routing (`unexpected end of JSON input`, runaway guard) — which makes `lsp` routing unmeasurable on that model. mellum2-12b runs all 23 cases clean in about two minutes instead of twenty.
214+
215+ ### What this means for task 7
216+
217+ Task 7 proposes rewriting the `SKILLS` block of the system prompt. The three known failures are now characterised, and **two of the three are prompt problems after all**:
218+
219+ - `start the dev server …` and `démarre le serveur …` — the model does not know a server blocks. A prompt that says "before acting, check whether a procedure covers this" is the right lever. **This is task 7, and it now has a measurable target.**
220+ - `qui appelle cette fonction ?` — names no symbol. Likely also a prompt matter, or simply out of reach.
221+ - `where is the Greet function defined?` — a NAME collision. Not task 7; not keywords either.
222+
223+ So task 7 is worth doing, but for different reasons than the ticket originally assumed, and against a baseline that now exists.
224+
225+ ### Also fixed on the way
226+
227+ Keyword ORDER became significant when the cap was introduced, and nothing told an author their last words were dropped. `skills.Truncated` + a banner warning + the rule in `reference/skill-format.md` (put the most distinctive first; do not spend the eight on words the `description` already carries, since the description is one language and the keywords are where the others belong).
228+
229+ `internal/lsp`'s `TestOperationsOverAFakeServer` was flaky at ~10% and is fixed (`fakeServer.awaitDoc`, a bounded poll — `didChange` is a notification and the test read the server's state out of band). 100 runs green, 60 under `-race`.
230+
231+ **Not fixed, reported**: `internal/engine`'s `TestExplainThroughTheStack/connection_refused` fails about once in thirty. `closedURL` records an ephemeral port and closes it, assuming nothing takes it — a TOCTOU race. One-line fix: return `http://127.0.0.1:1` instead.
232+
233+ `skills/greetings`, `skills/lsp-navigation` and `skills/bg-jobs` are byte-identical to before the experiments. Quality gate run 37: 10 warnings, 20 smells.
234+
235+ ---
236+
237+ ## Task 7 measured, and REJECTED on evidence (Claude, 2026-09-21)
238+
239+ First, a fact nobody had noticed: **the eval's configuration and the built-in default prompt have no `SKILLS` block at all.** So the whole baseline above — 17 of 23 — was the catalogue routing on its own, with zero prompt guidance. That gave task 7 a clean "before".
240+
241+ Four conditions, same model (mellum2-12b), same 23 cases, one change each:
242+
243+ | `SKILLS` block | hit | wrong | miss | **error** |
244+ |---|---|---|---|---|
245+ | none (run 1) | 17 | 2 | 4 | **0** |
246+ | none (run 2) | 16 | 2 | 5 | **0** |
247+ | 2 lines | 16 | 2 | 5 | **0** |
248+ | 9 lines (the proposed wording) | 14 | 2 | 3 | **4** |
249+
250+ Two conclusions, and the second is the important one.
251+
252+ **1. No block and a short block are indistinguishable.** 17 / 16 / 16 — the baseline itself moves by a case, so a one-case difference means nothing. Telling the model to check the catalogue does not make it check the catalogue.
253+
254+ **2. The nine-line block broke tool-call composition.** Four cases errored — `unexpected end of JSON input`, `generation timed out (stalled)` — where all three other conditions had zero. And it cost the two `bg-jobs` cases that had reliably worked: `lance le serveur en tâche de fond` and `start the dev server in the background` both went from hit to miss.
255+
256+ The nine-line and two-line blocks say the same thing; only the length differs, and only the long one produced errors. **So prompt weight, not prompt content, is what hurt** — which is exactly the argument ticket 4 records against MCP ("20 MCP tools sit in every request forever, and mm's target model class already mangles a 6-field schema"), here observed directly on nine lines of prose rather than on tool schemas.
257+
258+ Also constant across all four conditions: the two `wrong` cases (`where is the Greet function defined?` → `greetings`). The name collision moves with neither keywords nor prompt.
259+
260+ ### So task 7 is closed as rejected
261+
262+ The premise — "the model does not think to look, so tell it to look" — does not survive the instrument. Nothing was changed in the repository: all four prompt variants lived in `/tmp`.
263+
264+ ### And it opened a better question (task 8)
265+
266+ `agent.dmr.yaml`, `agent.llamacpp.yaml` and `agent.lsp.yaml` all **ship a `SKILLS` block of about six lines**, and it has never been measured. Given the above, it may be costing more than it gives. I did not touch them — I measured a block of my own wording on a config that had none, which is not the same experiment. Measuring theirs is now the open question, and the eval makes it a two-minute job: run it against each shipped YAML with the block, then with it commented out.
267+
268+ ---
269+
270+ ## Task 8 done — and it CORRECTS the task 7 write-up above (Claude, 2026-09-21)
271+
272+ Measured `agent.dmr.yaml`'s own `SKILLS` block, in its own prompt, on mellum2-12b, 23 cases, **two runs of each condition** because the differences are two cases wide:
273+
274+ | Condition | run 1 | run 2 |
275+ |---|---|---|
276+ | the shipped block, as it ships | **hit 18** · wrong 2 · miss 3 | **hit 18** · wrong 1 · miss 3 · extra 1 |
277+ | the shipped block **+ the no-match escape sentence** | hit 16 · wrong 1 · miss 6 | hit 16 · wrong 1 · miss 6 |
278+ | no block at all | hit 16 · wrong 2 · miss 5 | hit 16 · wrong 2 · miss 5 |
279+
280+ **The shipped block earns +2 cases, reproducibly. Do not remove it.** That answers task 8: it is not costing more than it gives, it is paying for itself.
281+
282+ ### And it isolates why my own block failed
283+
284+ The sentence I added, following this ticket's own task 7 —
285+
286+ > If none of them covers the request, do not mention skills — just do the work.
287+
288+ — **costs exactly the block's entire benefit**: 18 → 16, back to the no-block level, with misses going 3 → 6. It under-fires, which is the predicted mechanism: telling a model it may skip is an invitation to skip, and a 12B model takes it.
289+
290+ So the task 7 write-up above is **too broad where it says prompt guidance does not help routing**. Corrected:
291+
292+ - **Prompt guidance DOES help** — the shipped, unconditional block is worth +2 over nothing.
293+ - **The explicit no-match escape is what hurts**, and it was the specific thing task 7 proposed adding. Isolated to one sentence, reproduced twice.
294+ - The "prompt weight" reading also needs narrowing: the shipped prompt is much LONGER overall than the one I first tested, and its six-line block adds **no errors at all**. Length alone does not predict harm. The four tool-call failures my nine-line block produced remain unexplained — weight or wording, not separated.
295+
296+ ### What to do with this
297+
298+ Leave all three shipped blocks alone. If anyone is tempted to "soften" a SKILLS block with a no-match escape — which reads like an obvious improvement, and is why this ticket proposed it — the measurement says it costs two cases in twenty-three.
299+
300+ Method note for whoever runs this next: the two conditions must differ by **one** thing. These were derived from the same `agent.dmr.yaml` with only the block removed or one sentence added; the model, the catalogue, the skills directory and the rest of the prompt were identical.
74301 tasks:
75302 - id: 1
76- title: "Resolve read_skill through the catalogue's name, not the file path"
77- state: open
303+ title: Resolve read_skill through the catalogue's name, not the file path
304+ state: closed
78305 priority: high
79306 author:
80307 name: k33g
81308 email: ph.charriere@gmail.com
82309 createdAt: 2026-09-21T05:45:00.000Z
310+ updatedAt: 2026-09-21T11:20:00.000Z
311+ closedAt: 2026-09-21T11:20:00.000Z
83312 - id: 2
84313 title: "bg-jobs: ship the skill or delete the sentence from the 3 YAMLs and the default prompt"
85- state: open
314+ state: closed
86315 priority: high
87316 author:
88317 name: k33g
89318 email: ph.charriere@gmail.com
90319 createdAt: 2026-09-21T05:45:00.000Z
320+ updatedAt: 2026-09-21T11:20:00.000Z
321+ closedAt: 2026-09-21T11:20:00.000Z
91322 - id: 3
92- title: "Test that every skill named in a shipped prompt actually resolves"
93- state: open
323+ title: Test that every skill named in a shipped prompt actually resolves
324+ state: closed
94325 priority: high
95326 author:
96327 name: k33g
97328 email: ph.charriere@gmail.com
98329 createdAt: 2026-09-21T05:45:00.000Z
330+ updatedAt: 2026-09-21T11:20:00.000Z
331+ closedAt: 2026-09-21T11:20:00.000Z
99332 - id: 4
100- title: "Parse keywords and render them in the catalogue; correct skill-format.md"
101- state: open
333+ title: Parse keywords and render them in the catalogue; correct skill-format.md
334+ state: closed
102335 priority: medium
103336 author:
104337 name: k33g
105338 email: ph.charriere@gmail.com
106339 createdAt: 2026-09-21T05:45:00.000Z
340+ updatedAt: 2026-09-21T11:20:00.000Z
341+ closedAt: 2026-09-21T11:20:00.000Z
107342 - id: 5
108- title: "Warn on a skill with no description; print the 📖 recap unconditionally and count only successes"
109- state: open
343+ title: Warn on a skill with no description; print the 📖 recap unconditionally and count only successes
344+ state: closed
110345 priority: medium
111346 author:
112347 name: k33g
113348 email: ph.charriere@gmail.com
114349 createdAt: 2026-09-21T05:45:00.000Z
350+ updatedAt: 2026-09-21T11:20:00.000Z
351+ closedAt: 2026-09-21T11:20:00.000Z
115352 - id: 6
116- title: "Build a skill-routing eval before any prompt change"
117- state: open
353+ title: Build a skill-routing eval before any prompt change
354+ state: closed
118355 priority: medium
119356 author:
120357 name: k33g
121358 email: ph.charriere@gmail.com
122359 createdAt: 2026-09-21T05:45:00.000Z
360+ updatedAt: 2026-09-21T13:10:00.000Z
361+ closedAt: 2026-09-21T13:10:00.000Z
123362 - id: 7
124- title: "Then: fix the SKILLS prompt block and add it to the built-in default prompt"
125- state: open
363+ title: "Then: fix the SKILLS prompt block — MEASURED AND REJECTED, it makes routing worse"
364+ state: closed
126365 priority: low
127366 author:
128367 name: k33g
129368 email: ph.charriere@gmail.com
130369 createdAt: 2026-09-21T05:45:00.000Z
370+ updatedAt: 2026-09-21T17:40:00.000Z
371+ closedAt: 2026-09-21T17:40:00.000Z
372+ - id: 8
373+ title: "Measure the shipped SKILLS blocks — DONE: the block earns +2 cases, and the escape sentence task 7 proposed cancels exactly that"
374+ state: closed
375+ priority: medium
376+ author:
377+ name: k33g
378+ email: ph.charriere@gmail.com
379+ createdAt: 2026-09-21T17:40:00.000Z
380+ updatedAt: 2026-09-21T20:15:00.000Z
381+ closedAt: 2026-09-21T20:15:00.000Z
131382 comments: []
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 152 id: 15
3 title: Improve skills detection3 title: Improve skills detection
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-16T05:52:51.530Z8 createdAt: 2026-09-16T05:52:51.530Z
9-updatedAt: 2026-09-21T05:45:00.000Z9+updatedAt: 2026-09-21T19:00:59.231Z
10 labels: []10 labels: []
11 milestone: v0.0.311 milestone: v0.0.3
12 body: |12 body: |
@@ -71,61 +71,312 @@ body: |
71 ### Not re-opened71 ### Not re-opened
72 72
73 Decisions in force per `.memory/summary.md`, none of which the above touches: both layouts accepted; the flat file wins when both exist; `skills/README.md` skipped; relative `skillsDir` resolved by `resolveSkillsDir`; banner warning at count zero.73 Decisions in force per `.memory/summary.md`, none of which the above touches: both layouts accepted; the flat file wins when both exist; `skills/README.md` skipped; relative `skillsDir` resolved by `resolveSkillsDir`; banner warning at count zero.
74+ ---
75+
76+ ## Tier 1 implemented (Claude, 2026-09-21, branch `feature/skill-discovery`)
77+
78+ Tasks 1–5 closed. Tasks 6–7 (the eval, then the prompt block) deliberately left open: nothing there should start before the eval exists.
79+
80+ ### 1. `read_skill` resolves through the catalogue
81+
82+ `skills.Find` / `skills.Load` replace `skills.Read`. The model's string is now only ever compared against the names the catalogue advertises and **never reaches the filesystem** — so the `filepath.Base` barrier is gone because there is nothing left to escape from, which the test `TestALoadedNameNeverReachesTheFilesystem` pins.
83+
84+ Matching is forgiving in the two ways a model gets a name wrong and a human does not — stray whitespace or quotes, and the wrong case — and never forgiving about *which* skill: an exact name always beats a case-insensitive one.
85+
86+ **Found while doing it**: `List` used `sort.Slice`, which is **not stable**. The recorded rule "when both layouts claim a name, the flat file wins" was therefore decided by coin toss as soon as the lookup went through the sorted list. Now `sort.SliceStable` over a slice whose flat entries come first, and a test pins the rule.
87+
88+ ### 2 and 3. `bg-jobs`, and the test that prevents the class
89+
90+ `skills/bg-jobs/SKILL.md` written — start (both streams redirected, pid kept), **poll for readiness instead of sleeping**, inspect with `kill -0` and the log, stop politely then insist, and the traps (one shell per call, port already held, no stdin, stray `nohup.out`).
91+
92+ The durable half is `TestEverySkillNamedInAShippedPromptExists`: it scans every `agent*.yaml`, `tests/agent*.yaml` and the built-in default prompt for the `` `name` skill `` shape and asserts each one resolves. Verified by removing the new skill — it fails on all four sites at once, naming each.
93+
94+ ### 4. `keywords` parsed and rendered
95+
96+ Capped at 8 per skill, because the catalogue is paid for on every request. What the model now sees:
97+
98+ ```
99+ greetings — Greet somebody with a festive message. (greet, greetings, hello, hi, welcome, bonjour, coucou, saluer)
100+ ```
101+
102+ `docs/{en,fr}/reference/skill-format.md` corrected: it stated that `keywords` was "accepted in the file and ignored by the loader".
103+
104+ ### 5. Two new warnings, and a recap that can say zero
105+
106+ Banner: `N skill(s) with no description (…) — the model sees only the name and will pick blindly`, and `two skills share a name (…) — only the first is reachable`. Neither could fire before, since the only warning triggered at a count of zero.
107+
108+ `engine.Skills` now counts skills **loaded**, not `read_skill` responses: a miss answers `[skill: no skill named …]`, the `[tool: …]` shape the other tools use, and is not counted. The recap prints `📖 N skill(s)` whenever the agent has skills at all (`engine.SkillsAvailable`, set once by `main`), zero included.
109+
110+ **`📖 0 skill(s)` is the point of the whole item**: before, a turn where no skill fired and a turn where one fired and was ignored looked identical — the segment was simply absent.
111+
112+ ### Verified end to end (qwen3.5-4b against a real DMR)
113+
114+ | Asked | What happened |
115+ |---|---|
116+ | *« quelle heure est-il ? »* | reached `what-time-is-it` through the `heure` / `quelle heure` keywords → `⚙ 1 command(s) · 📖 1 skill(s)` |
117+ | *"What is 17 times 23?"* | no skill, and it says so: `⚙ 0 command(s) · 📖 0 skill(s)` |
118+ | *« Salue Bob de ma part »* | reached a skill from a French phrase — but picked `vulcan-salute` over `greetings`. **Both claim the same keyword space** (`salut` / `saluer`). The bridge works; the choice between two overlapping skills is exactly what task 6's eval is for. |
119+
120+ Quality gate run 34: 10 warnings, 20 smells — the baseline. `go test ./...`, `gofmt`, `go vet` green.
121+
122+ ### Not done, on purpose
123+
124+ Tasks 6 and 7. Also untouched: the `📖` line in the ACP trail still prints unconditionally on stderr (it always did), and the session `errors` trail still records no tool-level failure (Tier 3, item 10).
125+
126+ ---
127+
128+ ## Task 6 done: the routing eval exists, and here is its first reading (Claude, 2026-09-21)
129+
130+ `internal/eval`, opt-in:
131+
132+ ```
133+ MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 60m
134+ ```
135+
136+ `go test ./...` skips it, so nothing in CI needs a server. Docs: `docs/{en,fr}/how-to/measure-skill-routing.md`.
137+
138+ ### What it measures
139+
140+ **The catalogue, not the agent.** One model turn per case, with stub tools that record what was asked for and run nothing — `read_skill` carries the real `skills.Catalogue(list)`, which is the thing under test. Rationale: `vulcan-salute` says "run this echo", and letting the agent loop would echo twenty times and measure recovery as much as routing.
141+
142+ 20 cases, EN and FR, deliberately **not** phrased in the catalogue's words, including six where no skill applies and abstaining is the right answer.
143+
144+ Four outcomes, and **`wrong` is kept apart from `miss`** because they have different fixes: a skill loaded but the wrong one is a *catalogue* problem, nothing loaded at all is a *prompt* problem.
145+
146+ **No threshold, and it does not fail on a low score.** A number that fails a build becomes a target, and the first response to a red build is to edit the cases. Use it as a before/after, one change at a time.
147+
148+ ### First reading — qwen3.5-4b, 5 skills, catalogue 1048 bytes
149+
150+ ```
151+ 20 cases · hit 16 · miss 1 · error 3 → 80% right
152+ ```
153+
154+ Of the 17 cases that completed: **16 hits, 1 miss**. Every skill was reached in both English and French, including `give Spock the proper farewell` → `vulcan-salute`, which shares no word with that skill's name or description and could only have come through the keywords. All six abstention cases were correct — the catalogue does not fire on "write a haiku about pointers".
155+
156+ **The one genuine routing miss**: `« qui appelle cette fonction ? »` → no skill. French, and about symbols. Its English twin (`where is the Greet function defined?`) did not miss. That is the kind of single, concrete finding this ticket wanted.
157+
158+ **The 3 errors are not routing at all** — the model could not compose a valid tool call: twice `unexpected end of JSON input`, once the runaway guard cutting off 2558 characters of repeated `{"command":"ls -laR …"}`. Same failure family the codebase already carries three guards for (`normalize`, `cleanSymbol`, `toolCallGuard`), now observed on a different model. **No catalogue or prompt work fixes that**, and the eval says so rather than scoring it.
159+
160+ ### The eval found two defects in itself, both fixed
161+
162+ 1. The first stubs answered `[eval] command not run`. Given nothing to finish on, the model reran the same command until the runaway guard fired or the case timed out: **5 of 20 cases were artefacts of the instrument**. Stubs now answer that the work is done and the turn should end — run time also dropped from minutes per case to 5–20s.
163+ 2. `caseTimeout` was 2 minutes, and three good cases hit it exactly. Raised to 5 minutes: an `error` poisons the whole table, so the cap exists to catch a hung server, not to score speed.
164+
165+ Rows now stream to stderr as they complete (`testing` buffers `t.Log` to the end, and a silent multi-minute eval looks hung), and `os.Stdout` is redirected for the run because `engine.emit` writes the answer with `fmt.Print`, not through `ui.Out`.
166+
167+ ### What this changes for task 7
168+
169+ Task 7 (fix the SKILLS prompt block) now has an instrument and a baseline. Two things to know before touching it:
170+
171+ - The **`Salue Bob de ma part`** collision does **not** reproduce here: the eval picks `greetings` in both runs, where the full agent picked `vulcan-salute` earlier the same day. One-turn-with-stubs and the real loop are not the same setting. Keep the case, do not declare it fixed.
172+ - The single miss is a **French** case whose English twin passes. That points at the catalogue's French coverage, not at the SKILLS block — worth checking before rewriting the prompt.
173+
174+ Quality gate run 35: 10 warnings, 20 smells — unchanged.
175+
176+ ---
177+
178+ ## What the eval established, and what it refuted (Claude, 2026-09-21)
179+
180+ The eval's first real use. **Three keyword hypotheses, three refutations, zero routing improvement — and that is the result.** Every one of the three sounded right and would have shipped without an instrument.
181+
182+ Baseline, mellum2-12b, 23 cases: `hit 17 · wrong 2 · miss 4`.
183+
184+ ### Refuted (1): "the French miss is because no French keyword survives the 8-word cap"
185+
186+ `lsp-navigation` has 16 keywords and the surviving 8 were English jargon, three of them duplicating the description. Reordered so French sat inside the cap. On qwen the totals did not move and cases merely shuffled; on mellum, `« qui appelle cette fonction ? »` **still misses**. Reverted.
187+
188+ ### Refuted (2): "`greet` as a keyword hijacks `where is the Greet function defined?`"
189+
190+ That request routes to `greetings` instead of `lsp-navigation`. Dropped `greet` from the keywords, re-measured — **it still routes to `greetings`**. So it is the skill's **NAME** colliding with the symbol `Greet`, and no keyword edit can fix it. Reverted.
191+
192+ **Finding worth keeping**: a skill named after a common code identifier will hijack requests about that identifier. Either rename the skill or accept it; keywords are the wrong lever.
193+
194+ ### Refuted (3): "`bg-jobs` needs its cap spread across its four actions"
195+
196+ Its surviving 8 say "start" four ways (`serve`, `daemon`, `nohup`, `start`) and "stop" none — `stop`, `kill`, `running` are all cut. Spread them across start/wait/inspect/stop. Result: **worse**, 17 hits → 15, then 16 on a second run, with `start the dev server in the background` stably flipping from hit to **miss**. The "redundant" synonyms were carrying weight; the keyword set does not behave additively. Reverted.
197+
198+ ### Established: the language is irrelevant
199+
200+ `bg-jobs` was missing in English and hitting in French, and the obvious reading was thin French coverage — except **`bg-jobs` has no French keyword at all**. Three cases added to the eval settle it, a clean 2×2:
201+
202+ | Request | Result |
203+ |---|---|
204+ | `start the dev server in the background` — English, **names** the concept | ✓ hit |
205+ | `lance le serveur en tâche de fond` — French, **names** the concept | ✓ hit |
206+ | `start the dev server and check it answers` — English, only a situation | · miss |
207+ | `démarre le serveur de dev et vérifie qu'il répond` — French, only a situation | · miss |
208+
209+ **What decides it is whether the request names the concept the skill is about**, not which language it is in. That moves the remaining `bg-jobs` failures out of the catalogue and into the prompt: the model has no reason to know that starting a server blocks the turn.
210+
211+ ### Established: run the eval on mellum2-12b
212+
213+ qwen3.5-4b errors on all three `lsp` cases — tool-call composition, not routing (`unexpected end of JSON input`, runaway guard) — which makes `lsp` routing unmeasurable on that model. mellum2-12b runs all 23 cases clean in about two minutes instead of twenty.
214+
215+ ### What this means for task 7
216+
217+ Task 7 proposes rewriting the `SKILLS` block of the system prompt. The three known failures are now characterised, and **two of the three are prompt problems after all**:
218+
219+ - `start the dev server …` and `démarre le serveur …` — the model does not know a server blocks. A prompt that says "before acting, check whether a procedure covers this" is the right lever. **This is task 7, and it now has a measurable target.**
220+ - `qui appelle cette fonction ?` — names no symbol. Likely also a prompt matter, or simply out of reach.
221+ - `where is the Greet function defined?` — a NAME collision. Not task 7; not keywords either.
222+
223+ So task 7 is worth doing, but for different reasons than the ticket originally assumed, and against a baseline that now exists.
224+
225+ ### Also fixed on the way
226+
227+ Keyword ORDER became significant when the cap was introduced, and nothing told an author their last words were dropped. `skills.Truncated` + a banner warning + the rule in `reference/skill-format.md` (put the most distinctive first; do not spend the eight on words the `description` already carries, since the description is one language and the keywords are where the others belong).
228+
229+ `internal/lsp`'s `TestOperationsOverAFakeServer` was flaky at ~10% and is fixed (`fakeServer.awaitDoc`, a bounded poll — `didChange` is a notification and the test read the server's state out of band). 100 runs green, 60 under `-race`.
230+
231+ **Not fixed, reported**: `internal/engine`'s `TestExplainThroughTheStack/connection_refused` fails about once in thirty. `closedURL` records an ephemeral port and closes it, assuming nothing takes it — a TOCTOU race. One-line fix: return `http://127.0.0.1:1` instead.
232+
233+ `skills/greetings`, `skills/lsp-navigation` and `skills/bg-jobs` are byte-identical to before the experiments. Quality gate run 37: 10 warnings, 20 smells.
234+
235+ ---
236+
237+ ## Task 7 measured, and REJECTED on evidence (Claude, 2026-09-21)
238+
239+ First, a fact nobody had noticed: **the eval's configuration and the built-in default prompt have no `SKILLS` block at all.** So the whole baseline above — 17 of 23 — was the catalogue routing on its own, with zero prompt guidance. That gave task 7 a clean "before".
240+
241+ Four conditions, same model (mellum2-12b), same 23 cases, one change each:
242+
243+ | `SKILLS` block | hit | wrong | miss | **error** |
244+ |---|---|---|---|---|
245+ | none (run 1) | 17 | 2 | 4 | **0** |
246+ | none (run 2) | 16 | 2 | 5 | **0** |
247+ | 2 lines | 16 | 2 | 5 | **0** |
248+ | 9 lines (the proposed wording) | 14 | 2 | 3 | **4** |
249+
250+ Two conclusions, and the second is the important one.
251+
252+ **1. No block and a short block are indistinguishable.** 17 / 16 / 16 — the baseline itself moves by a case, so a one-case difference means nothing. Telling the model to check the catalogue does not make it check the catalogue.
253+
254+ **2. The nine-line block broke tool-call composition.** Four cases errored — `unexpected end of JSON input`, `generation timed out (stalled)` — where all three other conditions had zero. And it cost the two `bg-jobs` cases that had reliably worked: `lance le serveur en tâche de fond` and `start the dev server in the background` both went from hit to miss.
255+
256+ The nine-line and two-line blocks say the same thing; only the length differs, and only the long one produced errors. **So prompt weight, not prompt content, is what hurt** — which is exactly the argument ticket 4 records against MCP ("20 MCP tools sit in every request forever, and mm's target model class already mangles a 6-field schema"), here observed directly on nine lines of prose rather than on tool schemas.
257+
258+ Also constant across all four conditions: the two `wrong` cases (`where is the Greet function defined?` → `greetings`). The name collision moves with neither keywords nor prompt.
259+
260+ ### So task 7 is closed as rejected
261+
262+ The premise — "the model does not think to look, so tell it to look" — does not survive the instrument. Nothing was changed in the repository: all four prompt variants lived in `/tmp`.
263+
264+ ### And it opened a better question (task 8)
265+
266+ `agent.dmr.yaml`, `agent.llamacpp.yaml` and `agent.lsp.yaml` all **ship a `SKILLS` block of about six lines**, and it has never been measured. Given the above, it may be costing more than it gives. I did not touch them — I measured a block of my own wording on a config that had none, which is not the same experiment. Measuring theirs is now the open question, and the eval makes it a two-minute job: run it against each shipped YAML with the block, then with it commented out.
267+
268+ ---
269+
270+ ## Task 8 done — and it CORRECTS the task 7 write-up above (Claude, 2026-09-21)
271+
272+ Measured `agent.dmr.yaml`'s own `SKILLS` block, in its own prompt, on mellum2-12b, 23 cases, **two runs of each condition** because the differences are two cases wide:
273+
274+ | Condition | run 1 | run 2 |
275+ |---|---|---|
276+ | the shipped block, as it ships | **hit 18** · wrong 2 · miss 3 | **hit 18** · wrong 1 · miss 3 · extra 1 |
277+ | the shipped block **+ the no-match escape sentence** | hit 16 · wrong 1 · miss 6 | hit 16 · wrong 1 · miss 6 |
278+ | no block at all | hit 16 · wrong 2 · miss 5 | hit 16 · wrong 2 · miss 5 |
279+
280+ **The shipped block earns +2 cases, reproducibly. Do not remove it.** That answers task 8: it is not costing more than it gives, it is paying for itself.
281+
282+ ### And it isolates why my own block failed
283+
284+ The sentence I added, following this ticket's own task 7 —
285+
286+ > If none of them covers the request, do not mention skills — just do the work.
287+
288+ — **costs exactly the block's entire benefit**: 18 → 16, back to the no-block level, with misses going 3 → 6. It under-fires, which is the predicted mechanism: telling a model it may skip is an invitation to skip, and a 12B model takes it.
289+
290+ So the task 7 write-up above is **too broad where it says prompt guidance does not help routing**. Corrected:
291+
292+ - **Prompt guidance DOES help** — the shipped, unconditional block is worth +2 over nothing.
293+ - **The explicit no-match escape is what hurts**, and it was the specific thing task 7 proposed adding. Isolated to one sentence, reproduced twice.
294+ - The "prompt weight" reading also needs narrowing: the shipped prompt is much LONGER overall than the one I first tested, and its six-line block adds **no errors at all**. Length alone does not predict harm. The four tool-call failures my nine-line block produced remain unexplained — weight or wording, not separated.
295+
296+ ### What to do with this
297+
298+ Leave all three shipped blocks alone. If anyone is tempted to "soften" a SKILLS block with a no-match escape — which reads like an obvious improvement, and is why this ticket proposed it — the measurement says it costs two cases in twenty-three.
299+
300+ Method note for whoever runs this next: the two conditions must differ by **one** thing. These were derived from the same `agent.dmr.yaml` with only the block removed or one sentence added; the model, the catalogue, the skills directory and the rest of the prompt were identical.
74 tasks:301 tasks:
75 - id: 1302 - id: 1
76- title: "Resolve read_skill through the catalogue's name, not the file path"303+ title: Resolve read_skill through the catalogue's name, not the file path
77- state: open304+ state: closed
78 priority: high305 priority: high
79 author:306 author:
80 name: k33g307 name: k33g
81 email: ph.charriere@gmail.com308 email: ph.charriere@gmail.com
82 createdAt: 2026-09-21T05:45:00.000Z309 createdAt: 2026-09-21T05:45:00.000Z
310+ updatedAt: 2026-09-21T11:20:00.000Z
311+ closedAt: 2026-09-21T11:20:00.000Z
83 - id: 2312 - id: 2
84 title: "bg-jobs: ship the skill or delete the sentence from the 3 YAMLs and the default prompt"313 title: "bg-jobs: ship the skill or delete the sentence from the 3 YAMLs and the default prompt"
85- state: open314+ state: closed
86 priority: high315 priority: high
87 author:316 author:
88 name: k33g317 name: k33g
89 email: ph.charriere@gmail.com318 email: ph.charriere@gmail.com
90 createdAt: 2026-09-21T05:45:00.000Z319 createdAt: 2026-09-21T05:45:00.000Z
320+ updatedAt: 2026-09-21T11:20:00.000Z
321+ closedAt: 2026-09-21T11:20:00.000Z
91 - id: 3322 - id: 3
92- title: "Test that every skill named in a shipped prompt actually resolves"323+ title: Test that every skill named in a shipped prompt actually resolves
93- state: open324+ state: closed
94 priority: high325 priority: high
95 author:326 author:
96 name: k33g327 name: k33g
97 email: ph.charriere@gmail.com328 email: ph.charriere@gmail.com
98 createdAt: 2026-09-21T05:45:00.000Z329 createdAt: 2026-09-21T05:45:00.000Z
330+ updatedAt: 2026-09-21T11:20:00.000Z
331+ closedAt: 2026-09-21T11:20:00.000Z
99 - id: 4332 - id: 4
100- title: "Parse keywords and render them in the catalogue; correct skill-format.md"333+ title: Parse keywords and render them in the catalogue; correct skill-format.md
101- state: open334+ state: closed
102 priority: medium335 priority: medium
103 author:336 author:
104 name: k33g337 name: k33g
105 email: ph.charriere@gmail.com338 email: ph.charriere@gmail.com
106 createdAt: 2026-09-21T05:45:00.000Z339 createdAt: 2026-09-21T05:45:00.000Z
340+ updatedAt: 2026-09-21T11:20:00.000Z
341+ closedAt: 2026-09-21T11:20:00.000Z
107 - id: 5342 - id: 5
108- title: "Warn on a skill with no description; print the 📖 recap unconditionally and count only successes"343+ title: Warn on a skill with no description; print the 📖 recap unconditionally and count only successes
109- state: open344+ state: closed
110 priority: medium345 priority: medium
111 author:346 author:
112 name: k33g347 name: k33g
113 email: ph.charriere@gmail.com348 email: ph.charriere@gmail.com
114 createdAt: 2026-09-21T05:45:00.000Z349 createdAt: 2026-09-21T05:45:00.000Z
350+ updatedAt: 2026-09-21T11:20:00.000Z
351+ closedAt: 2026-09-21T11:20:00.000Z
115 - id: 6352 - id: 6
116- title: "Build a skill-routing eval before any prompt change"353+ title: Build a skill-routing eval before any prompt change
117- state: open354+ state: closed
118 priority: medium355 priority: medium
119 author:356 author:
120 name: k33g357 name: k33g
121 email: ph.charriere@gmail.com358 email: ph.charriere@gmail.com
122 createdAt: 2026-09-21T05:45:00.000Z359 createdAt: 2026-09-21T05:45:00.000Z
360+ updatedAt: 2026-09-21T13:10:00.000Z
361+ closedAt: 2026-09-21T13:10:00.000Z
123 - id: 7362 - id: 7
124- title: "Then: fix the SKILLS prompt block and add it to the built-in default prompt"363+ title: "Then: fix the SKILLS prompt block — MEASURED AND REJECTED, it makes routing worse"
125- state: open364+ state: closed
126 priority: low365 priority: low
127 author:366 author:
128 name: k33g367 name: k33g
129 email: ph.charriere@gmail.com368 email: ph.charriere@gmail.com
130 createdAt: 2026-09-21T05:45:00.000Z369 createdAt: 2026-09-21T05:45:00.000Z
370+ updatedAt: 2026-09-21T17:40:00.000Z
371+ closedAt: 2026-09-21T17:40:00.000Z
372+ - id: 8
373+ title: "Measure the shipped SKILLS blocks — DONE: the block earns +2 cases, and the escape sentence task 7 proposed cancels exactly that"
374+ state: closed
375+ priority: medium
376+ author:
377+ name: k33g
378+ email: ph.charriere@gmail.com
379+ createdAt: 2026-09-21T17:40:00.000Z
380+ updatedAt: 2026-09-21T20:15:00.000Z
381+ closedAt: 2026-09-21T20:15:00.000Z
131 comments: []382 comments: []
added .tickets/issues/0037-add-a-file-selector-to-the-tui-mode.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 37
3+title: Add a file selector to the tui mode
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-21T18:55:37.512Z
9+updatedAt: 2026-09-21T18:55:37.512Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 37
3+title: Add a file selector to the tui mode
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-21T18:55:37.512Z
9+updatedAt: 2026-09-21T18:55:37.512Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0038-add-a-skill-selector.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 38
3+title: add a skill selector
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-21T19:00:12.508Z
9+updatedAt: 2026-09-21T19:00:12.508Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 38
3+title: add a skill selector
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-21T19:00:12.508Z
9+updatedAt: 2026-09-21T19:00:12.508Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added demo/agent.dmr.yaml +181 -0
new file mode 100644
@@ -0,0 +1,181 @@
1+# Agent settings.
2+#
3+provider: dmr
4+
5+#model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
6+model: huggingface.co/unsloth/gemma-4-e2b-it-gguf:Q4_K_M
7+
8+baseUrl: http://localhost:12434/engines/v1
9+fallback: http://host.docker.internal:12434/engines/v1
10+
11+
12+# How many tokens the server actually serves.
13+# 0 = ask the server
14+contextWindow: 0
15+
16+# Max number of characters a tool returns to the model.
17+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
18+maxOutput: 16000
19+
20+# Max number of model <-> tools round trips for a single question.
21+# Raise it for tasks that need long chains of commands.
22+maxTurns: 40
23+
24+# The `bash` tool.
25+# `false` removes it: the model can still read and edit files (editTools) and load skills,
26+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
27+bashTool: true
28+
29+# The built-in file tools: read_file, write_file, edit_file.
30+editTools: true
31+
32+lsp:
33+ servers:
34+ - name: gopls # go install golang.org/x/tools/gopls@latest
35+ command: gopls
36+ extensions: [".go"]
37+
38+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
39+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
40+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
41+# lsp:
42+# servers:
43+# - name: gopls
44+# command: gopls
45+# extensions: [".go"]
46+# - name: typescript
47+# command: typescript-language-server
48+# args: ["--stdio"]
49+# extensions: [".ts", ".tsx", ".js", ".jsx"]
50+# - name: python
51+# command: pyright-langserver
52+# args: ["--stdio"]
53+# extensions: [".py"]
54+# - name: rust
55+# command: rust-analyzer
56+# extensions: [".rs"]
57+
58+# Where the `read_skill` tool looks for its markdown procedures.
59+# The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
60+skillsDir: .mm/skills
61+
62+# Persistent sessions, per project.
63+# When enabled, every conversation is saved as one JSON file under `dir`
64+sessions:
65+ enabled: true
66+ dir: .mm/sessions
67+
68+# How many lines of a command's output are echoed to the terminal.
69+# 0 disables it.
70+previewLines: 20
71+
72+# The system prompt: what the agent is, and what it is allowed to do.
73+# This is the knob to play with — it is the shortest path to changing behaviour.
74+system: |
75+ Your name is Bob.
76+ You are a coding agent working in a terminal.
77+ You have a "bash" tool to run shell commands.
78+ Use it to explore files, run tests, inspect the repository, etc.
79+ Chain several commands if needed, then answer clearly in English.
80+
81+ A request often mixes things you answer from yourself ("say hello") with
82+ things only a command can answer ("list the files"). Handle every part, in
83+ the order asked, and run a command for each part that needs one.
84+ Never state the contents of a file, the output of a command, or the state of
85+ the repository unless a command in THIS answer returned it. What you did not
86+ read, you do not know: run the command instead of recalling it.
87+
88+ SKILLS
89+ You have a second tool, `read_skill`. Its description lists the procedures
90+ available for this project — one per kind of task.
91+
92+ Any request to DO something to a Go project is a skill, not a shell command
93+ you invent. Match the request against that list, call `read_skill` FIRST,
94+ before any bash command, and then follow what it says step by step.
95+
96+ FILE EDITING
97+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
98+ They are how a file gets read and changed here: each change is exact,
99+ checked before it is written, and comes back as a diff with line numbers.
100+ bash is for running things — building, testing, listing, searching.
101+
102+ - Read before you write: call `read_file` on the file (numbered=true when
103+ you need line numbers). You cannot target text you have not seen; never
104+ rely on what you think you remember about a file.
105+ - To change an existing file, call `edit_file` with one or more {old, new}
106+ pairs. `old` is copied from the file character for character — same
107+ spaces, same indentation, same line breaks — and appears exactly once:
108+ add the surrounding lines until it is unique. Several pairs are applied
109+ together, against the original file. An empty `new` deletes the text.
110+ - Call `write_file` only to create a file, or to rewrite one entirely and
111+ on purpose. On an existing file it replaces everything, including what
112+ you did not intend to touch.
113+ - Read the diff the tool returns: it says exactly what changed and on which
114+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
115+ edits — read the file again and fix `old`. Do not fall back to
116+ `write_file` to force the change through.
117+ - After editing code, run the narrowest check with bash: the formatter, the
118+ compiler, or the test covering that file.
119+
120+ RULES
121+ - Keep everything the file already does, unless the user asked to remove it.
122+ - Touch only the files the request is about. Do not add tests, files or
123+ features that were not asked for.
124+ - Never run a git command unless the user says git, commit or push.
125+ - Never move, rename or delete a file unless the user asked for it.
126+ - Then answer in English, in a few lines.
127+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
128+ to understand the options, then run the command.
129+
130+ BACKGROUND JOBS
131+ Never let a command block the answer. Anything that serves, watches or runs
132+ long goes to the background, with BOTH streams redirected and its pid kept:
133+
134+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
135+
136+ Redirecting only stdout still blocks until the process exits. Read the
137+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
138+ rule you cannot guess. Stop every job you started before you finish, and say
139+ which ones you left running.
140+
141+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
142+# we want precise and reproducible answers, not creativity.
143+sampling:
144+ temperature: 0.0
145+ parallel_tool_calls: false
146+ top_p: 0.9
147+ max_tokens: 4096
148+
149+watchdogTimeout: 30s
150+
151+# Context compression
152+context:
153+ enabled: false
154+
155+ # Compress when the history reaches this share of the window.
156+ threshold: 75
157+
158+ # Fallback on the message count, for when the window is unknown.
159+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
160+ maxMessages: 80
161+
162+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
163+ # The model works on the last few turns, so summarising them would make it redo what it just did.
164+ keepLastTurns: 3
165+
166+ # max_tokens of the summary request.
167+ summaryMaxTokens: 1200
168+
169+ # Replaces the built-in summary prompt when set.
170+ # Empty = built-in.
171+ prompt: ""
172+
173+ showStats: true
174+
175+displayCommands: true
176+# Show what a thinking model reasons about before it answers (dim italic in the
177+# terminal, its own style in the window, a thought chunk in an editor).
178+# The reasoning is always READ — it is what keeps the watchdog alive while the
179+# model thinks — this only says whether to show it.
180+showThinking: true
181+
new file mode 100644
@@ -0,0 +1,181 @@
1+# Agent settings.
2+#
3+provider: dmr
4+
5+#model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
6+model: huggingface.co/unsloth/gemma-4-e2b-it-gguf:Q4_K_M
7+
8+baseUrl: http://localhost:12434/engines/v1
9+fallback: http://host.docker.internal:12434/engines/v1
10+
11+
12+# How many tokens the server actually serves.
13+# 0 = ask the server
14+contextWindow: 0
15+
16+# Max number of characters a tool returns to the model.
17+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
18+maxOutput: 16000
19+
20+# Max number of model <-> tools round trips for a single question.
21+# Raise it for tasks that need long chains of commands.
22+maxTurns: 40
23+
24+# The `bash` tool.
25+# `false` removes it: the model can still read and edit files (editTools) and load skills,
26+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
27+bashTool: true
28+
29+# The built-in file tools: read_file, write_file, edit_file.
30+editTools: true
31+
32+lsp:
33+ servers:
34+ - name: gopls # go install golang.org/x/tools/gopls@latest
35+ command: gopls
36+ extensions: [".go"]
37+
38+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
39+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
40+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
41+# lsp:
42+# servers:
43+# - name: gopls
44+# command: gopls
45+# extensions: [".go"]
46+# - name: typescript
47+# command: typescript-language-server
48+# args: ["--stdio"]
49+# extensions: [".ts", ".tsx", ".js", ".jsx"]
50+# - name: python
51+# command: pyright-langserver
52+# args: ["--stdio"]
53+# extensions: [".py"]
54+# - name: rust
55+# command: rust-analyzer
56+# extensions: [".rs"]
57+
58+# Where the `read_skill` tool looks for its markdown procedures.
59+# The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
60+skillsDir: .mm/skills
61+
62+# Persistent sessions, per project.
63+# When enabled, every conversation is saved as one JSON file under `dir`
64+sessions:
65+ enabled: true
66+ dir: .mm/sessions
67+
68+# How many lines of a command's output are echoed to the terminal.
69+# 0 disables it.
70+previewLines: 20
71+
72+# The system prompt: what the agent is, and what it is allowed to do.
73+# This is the knob to play with — it is the shortest path to changing behaviour.
74+system: |
75+ Your name is Bob.
76+ You are a coding agent working in a terminal.
77+ You have a "bash" tool to run shell commands.
78+ Use it to explore files, run tests, inspect the repository, etc.
79+ Chain several commands if needed, then answer clearly in English.
80+
81+ A request often mixes things you answer from yourself ("say hello") with
82+ things only a command can answer ("list the files"). Handle every part, in
83+ the order asked, and run a command for each part that needs one.
84+ Never state the contents of a file, the output of a command, or the state of
85+ the repository unless a command in THIS answer returned it. What you did not
86+ read, you do not know: run the command instead of recalling it.
87+
88+ SKILLS
89+ You have a second tool, `read_skill`. Its description lists the procedures
90+ available for this project — one per kind of task.
91+
92+ Any request to DO something to a Go project is a skill, not a shell command
93+ you invent. Match the request against that list, call `read_skill` FIRST,
94+ before any bash command, and then follow what it says step by step.
95+
96+ FILE EDITING
97+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
98+ They are how a file gets read and changed here: each change is exact,
99+ checked before it is written, and comes back as a diff with line numbers.
100+ bash is for running things — building, testing, listing, searching.
101+
102+ - Read before you write: call `read_file` on the file (numbered=true when
103+ you need line numbers). You cannot target text you have not seen; never
104+ rely on what you think you remember about a file.
105+ - To change an existing file, call `edit_file` with one or more {old, new}
106+ pairs. `old` is copied from the file character for character — same
107+ spaces, same indentation, same line breaks — and appears exactly once:
108+ add the surrounding lines until it is unique. Several pairs are applied
109+ together, against the original file. An empty `new` deletes the text.
110+ - Call `write_file` only to create a file, or to rewrite one entirely and
111+ on purpose. On an existing file it replaces everything, including what
112+ you did not intend to touch.
113+ - Read the diff the tool returns: it says exactly what changed and on which
114+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
115+ edits — read the file again and fix `old`. Do not fall back to
116+ `write_file` to force the change through.
117+ - After editing code, run the narrowest check with bash: the formatter, the
118+ compiler, or the test covering that file.
119+
120+ RULES
121+ - Keep everything the file already does, unless the user asked to remove it.
122+ - Touch only the files the request is about. Do not add tests, files or
123+ features that were not asked for.
124+ - Never run a git command unless the user says git, commit or push.
125+ - Never move, rename or delete a file unless the user asked for it.
126+ - Then answer in English, in a few lines.
127+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
128+ to understand the options, then run the command.
129+
130+ BACKGROUND JOBS
131+ Never let a command block the answer. Anything that serves, watches or runs
132+ long goes to the background, with BOTH streams redirected and its pid kept:
133+
134+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
135+
136+ Redirecting only stdout still blocks until the process exits. Read the
137+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
138+ rule you cannot guess. Stop every job you started before you finish, and say
139+ which ones you left running.
140+
141+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
142+# we want precise and reproducible answers, not creativity.
143+sampling:
144+ temperature: 0.0
145+ parallel_tool_calls: false
146+ top_p: 0.9
147+ max_tokens: 4096
148+
149+watchdogTimeout: 30s
150+
151+# Context compression
152+context:
153+ enabled: false
154+
155+ # Compress when the history reaches this share of the window.
156+ threshold: 75
157+
158+ # Fallback on the message count, for when the window is unknown.
159+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
160+ maxMessages: 80
161+
162+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
163+ # The model works on the last few turns, so summarising them would make it redo what it just did.
164+ keepLastTurns: 3
165+
166+ # max_tokens of the summary request.
167+ summaryMaxTokens: 1200
168+
169+ # Replaces the built-in summary prompt when set.
170+ # Empty = built-in.
171+ prompt: ""
172+
173+ showStats: true
174+
175+displayCommands: true
176+# Show what a thinking model reasons about before it answers (dim italic in the
177+# terminal, its own style in the window, a thought chunk in an editor).
178+# The reasoning is always READ — it is what keeps the watchdog alive while the
179+# model thinks — this only says whether to show it.
180+showThinking: true
181+
added demo/main.go +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "log"
7+ "net/http"
8+)
9+
10+// Animal struct to represent an animal
11+type Animal struct {
12+ Name string `json:"name"`
13+ Species string `json:"species"`
14+}
15+
16+// Sample data
17+var animals = []Animal{
18+ {Name: "Lion", Species: "Mammal"},
19+ {Name: "Elephant", Species: "Mammal"},
20+ {Name: "Eagle", Species: "Bird"},
21+ {Name: "Shark", Species: "Fish"},
22+}
23+
24+// handler function to serve the animal list
25+func animalHandler(w http.ResponseWriter, r *http.Request) {
26+ // Set the content type to JSON
27+ w.Header().Set("Content-Type", "application/json")
28+
29+ // Encode the animal slice to JSON
30+ json.NewEncoder(w).Encode(animals)
31+}
32+
33+func main() {
34+ // Define the handler for the root path
35+ http.HandleFunc("/", animalHandler)
36+
37+ // Start the server on port 8080
38+ port := ":8080"
39+ fmt.Printf("Starting server on http://localhost%s\n", port)
40+ log.Fatal(http.ListenAndServe(port, nil))
41+}
new file mode 100644
@@ -0,0 +1,41 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "log"
7+ "net/http"
8+)
9+
10+// Animal struct to represent an animal
11+type Animal struct {
12+ Name string `json:"name"`
13+ Species string `json:"species"`
14+}
15+
16+// Sample data
17+var animals = []Animal{
18+ {Name: "Lion", Species: "Mammal"},
19+ {Name: "Elephant", Species: "Mammal"},
20+ {Name: "Eagle", Species: "Bird"},
21+ {Name: "Shark", Species: "Fish"},
22+}
23+
24+// handler function to serve the animal list
25+func animalHandler(w http.ResponseWriter, r *http.Request) {
26+ // Set the content type to JSON
27+ w.Header().Set("Content-Type", "application/json")
28+
29+ // Encode the animal slice to JSON
30+ json.NewEncoder(w).Encode(animals)
31+}
32+
33+func main() {
34+ // Define the handler for the root path
35+ http.HandleFunc("/", animalHandler)
36+
37+ // Start the server on port 8080
38+ port := ":8080"
39+ fmt.Printf("Starting server on http://localhost%s\n", port)
40+ log.Fatal(http.ListenAndServe(port, nil))
41+}
modified docs/en/README.md +1 -0
@@ -13,6 +13,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1313 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
1414 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
1515 - [Add a skill](how-to/add-a-skill.md)
16+- [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after
1617 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)
1718 - [Start a new session without restarting](how-to/start-a-new-session.md)
1819 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
@@ -13,6 +13,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
13 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)13 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
14 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)14 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
15 - [Add a skill](how-to/add-a-skill.md)15 - [Add a skill](how-to/add-a-skill.md)
16+- [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after
16 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)17 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)
17 - [Start a new session without restarting](how-to/start-a-new-session.md)18 - [Start a new session without restarting](how-to/start-a-new-session.md)
18 - [Point the model at a file with `@path`](how-to/attach-a-file.md)19 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
added docs/en/how-to/measure-skill-routing.md +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+# Measure whether the model picks the right skill
2+
3+> Run the routing eval, read its table, and use it to decide whether a change to the catalogue or to the prompt helped.
4+
5+`mm` shows a skill in the catalogue and hopes the model reaches for it. Whether it does is a property of the **model**, not of the code, so no ordinary test can answer it. This one asks a live model, twenty times.
6+
7+## Run it
8+
9+```bash
10+MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 25m
11+```
12+
13+Both variables are required in practice: without `MM_EVAL` the test skips, and without `AGENT_CONFIG` it evaluates the built-in defaults rather than the configuration you care about. `go test ./...` skips it, so nothing in CI depends on a server being up.
14+
15+Expect a few minutes: one model turn per case, and small models are not fast. Rows are printed as they complete.
16+
17+## Read the table
18+
19+```
20+[ 6/20] ✓ hit want=what-time-is-it got=what-time-is-it 1.204s "quelle heure est-il ?" ← French
21+[14/20] ≠ wrong want=greetings got=vulcan-salute 0.981s "Salue Bob de ma part" ← two skills claim salut/saluer
22+[16/20] · miss want=bg-jobs got=(none) 2.017s "lance le serveur en tâche de fond"
23+[17/20] ! extra want=(none) got=greetings 0.774s "write a haiku about pointers"
24+```
25+
26+| Mark | Meaning | What it points at |
27+|---|---|---|
28+| `✓ hit` | the expected skill, or correctly no skill | — |
29+| `≠ wrong` | a skill was loaded, but not that one | the **catalogue**: two entries the model cannot tell apart |
30+| `· miss` | a skill applied and none was loaded | the **prompt**: the model did not think to look |
31+| `! extra` | no skill applied and one was loaded | over-eager instruction, or a catalogue entry that claims too much |
32+| `✗ error` | the turn failed | **never a routing score.** Either the instrument (a stub that starved the model, `caseTimeout` too short, a server that went away) or the model being unable to compose a valid tool call at all (`unexpected end of JSON input`, or the runaway guard cutting off repeated arguments). The message says which. Fails the test, because the numbers are then incomplete. |
33+
34+`wrong` and `miss` are kept apart on purpose. Merging them hides which of the two problems you have, and they have different fixes.
35+
36+## Use it
37+
38+**The number on its own means nothing.** There is no threshold and the test does not fail on a low score — a number that fails a build becomes a target, and the first thing anyone does with a red build is edit the cases.
39+
40+The eval is a **before and after**:
41+
42+1. Run it, keep the output.
43+2. Change one thing — a skill's `keywords`, the `SKILLS` block of the system prompt, a `description`.
44+3. Run it again against the same model, with the same configuration.
45+4. Compare.
46+
47+One change at a time. Two at once and you have measured their sum.
48+
49+Small models at `temperature: 0.0` are reproducible enough for this to mean something; run the eval twice before trusting a difference of one case.
50+
51+## What it measures, and what it does not
52+
53+It measures the **catalogue** — the tool description the model reads — under one system prompt. One model turn per case, with tools that record what was asked for and **run nothing**: a skill that says "run this echo" must not echo twenty times, and letting the agent loop would measure recovery as much as routing.
54+
55+It does not measure whether the skill, once loaded, was followed correctly. That is a different question and needs a different instrument.
56+
57+It also does not measure whether the model can emit a valid tool call. When it cannot — and small models often cannot — you get `✗ error`, not a low routing score. Those two failures have nothing to do with each other and no amount of catalogue work fixes the second one. Measured on `qwen3.5-4b`: three of twenty cases failed this way, reproducibly, on the prompts that invite a more structured call.
58+
59+## Add a case
60+
61+`internal/eval/routing_test.go`, the `cases` table:
62+
63+```go
64+{"lance le serveur en tâche de fond", "bg-jobs", "French"},
65+{"what is 17 times 23?", none, ""},
66+```
67+
68+Two rules that keep the numbers honest:
69+
70+- **Do not phrase the prompt in the words of the catalogue.** A case that says "use the greetings skill" measures nothing.
71+- **Keep the `none` cases.** A catalogue that fires on everything is as broken as one that never fires, and abstaining correctly is a hit.
72+
73+When you find a real routing failure, add it as a case before fixing it. That is how the eval stops the same failure coming back.
74+
75+## Write cases that can separate explanations
76+
77+A single case tells you something failed. A **set** of cases tells you why, and that is what makes the eval worth running.
78+
79+Worked example. `bg-jobs` was missing on English requests and hitting on French ones, and the obvious reading was "the catalogue's French coverage is thin" — except `bg-jobs` has no French keyword at all. Three cases settled it:
80+
81+| Case | Result |
82+|---|---|
83+| `start the dev server in the background` — English, **names** the concept | ✓ hit |
84+| `démarre le serveur de dev et vérifie qu'il répond` — French, only a situation | · miss |
85+| `lance le serveur en tâche de fond` — French, **names** the concept | ✓ hit |
86+| `start the dev server and check it answers` — English, only a situation | · miss |
87+
88+A clean 2×2: **the language is irrelevant.** What decides it is whether the request names the concept the skill is about. That reframed the problem from a catalogue one into a prompt one — the model has no reason to know a server blocks the turn — and no amount of keyword editing would have found it.
89+
90+Build cases in pairs that differ by **one** thing. One case is an anecdote.
new file mode 100644
@@ -0,0 +1,90 @@
1+# Measure whether the model picks the right skill
2+
3+> Run the routing eval, read its table, and use it to decide whether a change to the catalogue or to the prompt helped.
4+
5+`mm` shows a skill in the catalogue and hopes the model reaches for it. Whether it does is a property of the **model**, not of the code, so no ordinary test can answer it. This one asks a live model, twenty times.
6+
7+## Run it
8+
9+```bash
10+MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 25m
11+```
12+
13+Both variables are required in practice: without `MM_EVAL` the test skips, and without `AGENT_CONFIG` it evaluates the built-in defaults rather than the configuration you care about. `go test ./...` skips it, so nothing in CI depends on a server being up.
14+
15+Expect a few minutes: one model turn per case, and small models are not fast. Rows are printed as they complete.
16+
17+## Read the table
18+
19+```
20+[ 6/20] ✓ hit want=what-time-is-it got=what-time-is-it 1.204s "quelle heure est-il ?" ← French
21+[14/20] ≠ wrong want=greetings got=vulcan-salute 0.981s "Salue Bob de ma part" ← two skills claim salut/saluer
22+[16/20] · miss want=bg-jobs got=(none) 2.017s "lance le serveur en tâche de fond"
23+[17/20] ! extra want=(none) got=greetings 0.774s "write a haiku about pointers"
24+```
25+
26+| Mark | Meaning | What it points at |
27+|---|---|---|
28+| `✓ hit` | the expected skill, or correctly no skill | — |
29+| `≠ wrong` | a skill was loaded, but not that one | the **catalogue**: two entries the model cannot tell apart |
30+| `· miss` | a skill applied and none was loaded | the **prompt**: the model did not think to look |
31+| `! extra` | no skill applied and one was loaded | over-eager instruction, or a catalogue entry that claims too much |
32+| `✗ error` | the turn failed | **never a routing score.** Either the instrument (a stub that starved the model, `caseTimeout` too short, a server that went away) or the model being unable to compose a valid tool call at all (`unexpected end of JSON input`, or the runaway guard cutting off repeated arguments). The message says which. Fails the test, because the numbers are then incomplete. |
33+
34+`wrong` and `miss` are kept apart on purpose. Merging them hides which of the two problems you have, and they have different fixes.
35+
36+## Use it
37+
38+**The number on its own means nothing.** There is no threshold and the test does not fail on a low score — a number that fails a build becomes a target, and the first thing anyone does with a red build is edit the cases.
39+
40+The eval is a **before and after**:
41+
42+1. Run it, keep the output.
43+2. Change one thing — a skill's `keywords`, the `SKILLS` block of the system prompt, a `description`.
44+3. Run it again against the same model, with the same configuration.
45+4. Compare.
46+
47+One change at a time. Two at once and you have measured their sum.
48+
49+Small models at `temperature: 0.0` are reproducible enough for this to mean something; run the eval twice before trusting a difference of one case.
50+
51+## What it measures, and what it does not
52+
53+It measures the **catalogue** — the tool description the model reads — under one system prompt. One model turn per case, with tools that record what was asked for and **run nothing**: a skill that says "run this echo" must not echo twenty times, and letting the agent loop would measure recovery as much as routing.
54+
55+It does not measure whether the skill, once loaded, was followed correctly. That is a different question and needs a different instrument.
56+
57+It also does not measure whether the model can emit a valid tool call. When it cannot — and small models often cannot — you get `✗ error`, not a low routing score. Those two failures have nothing to do with each other and no amount of catalogue work fixes the second one. Measured on `qwen3.5-4b`: three of twenty cases failed this way, reproducibly, on the prompts that invite a more structured call.
58+
59+## Add a case
60+
61+`internal/eval/routing_test.go`, the `cases` table:
62+
63+```go
64+{"lance le serveur en tâche de fond", "bg-jobs", "French"},
65+{"what is 17 times 23?", none, ""},
66+```
67+
68+Two rules that keep the numbers honest:
69+
70+- **Do not phrase the prompt in the words of the catalogue.** A case that says "use the greetings skill" measures nothing.
71+- **Keep the `none` cases.** A catalogue that fires on everything is as broken as one that never fires, and abstaining correctly is a hit.
72+
73+When you find a real routing failure, add it as a case before fixing it. That is how the eval stops the same failure coming back.
74+
75+## Write cases that can separate explanations
76+
77+A single case tells you something failed. A **set** of cases tells you why, and that is what makes the eval worth running.
78+
79+Worked example. `bg-jobs` was missing on English requests and hitting on French ones, and the obvious reading was "the catalogue's French coverage is thin" — except `bg-jobs` has no French keyword at all. Three cases settled it:
80+
81+| Case | Result |
82+|---|---|
83+| `start the dev server in the background` — English, **names** the concept | ✓ hit |
84+| `démarre le serveur de dev et vérifie qu'il répond` — French, only a situation | · miss |
85+| `lance le serveur en tâche de fond` — French, **names** the concept | ✓ hit |
86+| `start the dev server and check it answers` — English, only a situation | · miss |
87+
88+A clean 2×2: **the language is irrelevant.** What decides it is whether the request names the concept the skill is about. That reframed the problem from a catalogue one into a prompt one — the model has no reason to know a server blocks the turn — and no amount of keyword editing would have found it.
89+
90+Build cases in pairs that differ by **one** thing. One case is an anecdote.
modified docs/en/reference/skill-format.md +32 -7
@@ -8,18 +8,20 @@
88 |------|-------|
99 | Directory | `skillsDir` (default `skills`). |
1010 | Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. A `README.md` at the root of the directory is documentation, not a skill: it is skipped. |
11-| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |
12-| Both forms for one name | `read_skill` returns the flat file. |
11+| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md` — used only as the default `name` when the front matter gives none. |
12+| Resolution | `read_skill` resolves the name **through the catalogue**, not through a path: it accepts exactly the names the catalogue shows, so a `name:` that differs from the file name works. Surrounding spaces and quotes are trimmed and the case is forgiven; an exact name always wins over a case-insensitive one. |
13+| Both forms for one name | `read_skill` returns the flat file. The banner warns that the other is unreachable. |
1314 | Missing directory | Not an error: zero skills, `read_skill` not declared. |
1415
1516 ## Front matter
1617
17-Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only two keys are read; the others are ignored.
18+Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only three keys are read; the others are ignored.
1819
1920 | Key | Type | Default | Description |
2021 |-----|------|---------|-------------|
2122 | `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. |
22-| `description` | string | `""` | One-line summary shown in the catalogue. |
23+| `description` | string | `""` | One-line summary shown in the catalogue. A skill without one appears as a bare name, which the model cannot match a request against; the start-up banner warns about it. |
24+| `keywords` | comma-separated list | empty | Words a user might actually type, in any language. Shown in the catalogue after the description, **capped at the first 8** — the catalogue is sent with every request. This is the lexical bridge: *"salue Bob"* reaches `greetings` through `saluer`, never through the name. **Order matters**: past the eighth word nothing reaches the model, so put the most distinctive first. The banner warns when a list is being truncated. Do not spend the eight on words already in the `description` — the catalogue shows both, and a description is one language, so the keywords are where the others belong. |
2325
2426 A `description:` line in the body, after the closing `---`, is not metadata.
2527
@@ -29,11 +31,11 @@ The tool description is:
2931
3032 ```
3133 Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32- <name> — <description>
34+ <name> — <description> (<keyword>, <keyword>, …)
3335 <name>
3436 ```
3537
36-Entries are sorted by name; the ` — <description>` part is omitted when the description is empty.
38+Entries are sorted by name; the ` — <description>` part is omitted when the description is empty, and the ` (…)` part when there are no keywords.
3739
3840 ### Example
3941
@@ -53,7 +55,30 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
5355 ```
5456 ```
5557
56-The `keywords` key is accepted in the file and ignored by the loader.
58+The `keywords` of that example reach the catalogue as `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
59+
60+## Writing a skill the model will ACT on
61+
62+A skill that tells the model to run a command has one failure mode, and it is not the one you expect: the model **reproduces the command instead of running it**, in a fenced code block, and answers as if it had done the work.
63+
64+Measured on `mellum2-12b-a2.5b-instruct`, the same skill either way:
65+
66+| The skill says | Result |
67+|---|---|
68+| ` ```bash ` fenced block | `⚙ 0 command(s)` — the model printed the command back, three runs out of three |
69+| the command **indented**, and "call your `bash` tool with this command" | `⚙ 1 command(s)` — three runs out of three |
70+
71+A fenced block is a *display* affordance. Put one in the model's context and the most probable continuation is the same block, which is exactly what a small model produces. Naming the tool and indenting the command removes the invitation.
72+
73+Note this is model-dependent — `qwen3.5-4b` ran the fenced version correctly — so a skill that works on your model may still fail on someone else's. Write it the safe way regardless.
74+
75+The rule, for a skill whose point is that a command runs:
76+
77+- **no ` ```bash ` fence around the command to execute.** Indent it by four spaces instead.
78+- **name the tool**: "call your `bash` tool with this command", not "run:".
79+- keep saying what the shipped skills say — *the answer is the command's output, nothing else* — because that is what stops the model answering from memory.
80+
81+A skill that is reference material rather than one command to run — `bg-jobs`, which shows half a dozen — keeps its fences. Nothing there is "the command to execute", so there is no invitation to misread.
5782
5883 ## Shipped examples
5984
@@ -8,18 +8,20 @@
8 |------|-------|8 |------|-------|
9 | Directory | `skillsDir` (default `skills`). |9 | Directory | `skillsDir` (default `skills`). |
10 | Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. A `README.md` at the root of the directory is documentation, not a skill: it is skipped. |10 | Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. A `README.md` at the root of the directory is documentation, not a skill: it is skipped. |
11-| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |11+| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md` — used only as the default `name` when the front matter gives none. |
12-| Both forms for one name | `read_skill` returns the flat file. |12+| Resolution | `read_skill` resolves the name **through the catalogue**, not through a path: it accepts exactly the names the catalogue shows, so a `name:` that differs from the file name works. Surrounding spaces and quotes are trimmed and the case is forgiven; an exact name always wins over a case-insensitive one. |
13+| Both forms for one name | `read_skill` returns the flat file. The banner warns that the other is unreachable. |
13 | Missing directory | Not an error: zero skills, `read_skill` not declared. |14 | Missing directory | Not an error: zero skills, `read_skill` not declared. |
14 15
15 ## Front matter16 ## Front matter
16 17
17-Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only two keys are read; the others are ignored.18+Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only three keys are read; the others are ignored.
18 19
19 | Key | Type | Default | Description |20 | Key | Type | Default | Description |
20 |-----|------|---------|-------------|21 |-----|------|---------|-------------|
21 | `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. |22 | `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. |
22-| `description` | string | `""` | One-line summary shown in the catalogue. |23+| `description` | string | `""` | One-line summary shown in the catalogue. A skill without one appears as a bare name, which the model cannot match a request against; the start-up banner warns about it. |
24+| `keywords` | comma-separated list | empty | Words a user might actually type, in any language. Shown in the catalogue after the description, **capped at the first 8** — the catalogue is sent with every request. This is the lexical bridge: *"salue Bob"* reaches `greetings` through `saluer`, never through the name. **Order matters**: past the eighth word nothing reaches the model, so put the most distinctive first. The banner warns when a list is being truncated. Do not spend the eight on words already in the `description` — the catalogue shows both, and a description is one language, so the keywords are where the others belong. |
23 25
24 A `description:` line in the body, after the closing `---`, is not metadata.26 A `description:` line in the body, after the closing `---`, is not metadata.
25 27
@@ -29,11 +31,11 @@ The tool description is:
29 31
30 ```32 ```
31 Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:33 Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32- <name> — <description>34+ <name> — <description> (<keyword>, <keyword>, …)
33 <name>35 <name>
34 ```36 ```
35 37
36-Entries are sorted by name; the ` — <description>` part is omitted when the description is empty.38+Entries are sorted by name; the ` — <description>` part is omitted when the description is empty, and the ` (…)` part when there are no keywords.
37 39
38 ### Example40 ### Example
39 41
@@ -53,7 +55,30 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
53 ```55 ```
54 ```56 ```
55 57
56-The `keywords` key is accepted in the file and ignored by the loader.58+The `keywords` of that example reach the catalogue as `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
59+
60+## Writing a skill the model will ACT on
61+
62+A skill that tells the model to run a command has one failure mode, and it is not the one you expect: the model **reproduces the command instead of running it**, in a fenced code block, and answers as if it had done the work.
63+
64+Measured on `mellum2-12b-a2.5b-instruct`, the same skill either way:
65+
66+| The skill says | Result |
67+|---|---|
68+| ` ```bash ` fenced block | `⚙ 0 command(s)` — the model printed the command back, three runs out of three |
69+| the command **indented**, and "call your `bash` tool with this command" | `⚙ 1 command(s)` — three runs out of three |
70+
71+A fenced block is a *display* affordance. Put one in the model's context and the most probable continuation is the same block, which is exactly what a small model produces. Naming the tool and indenting the command removes the invitation.
72+
73+Note this is model-dependent — `qwen3.5-4b` ran the fenced version correctly — so a skill that works on your model may still fail on someone else's. Write it the safe way regardless.
74+
75+The rule, for a skill whose point is that a command runs:
76+
77+- **no ` ```bash ` fence around the command to execute.** Indent it by four spaces instead.
78+- **name the tool**: "call your `bash` tool with this command", not "run:".
79+- keep saying what the shipped skills say — *the answer is the command's output, nothing else* — because that is what stops the model answering from memory.
80+
81+A skill that is reference material rather than one command to run — `bg-jobs`, which shows half a dozen — keeps its fences. Nothing there is "the command to execute", so there is no invitation to misread.
57 82
58 ## Shipped examples83 ## Shipped examples
59 84
modified docs/fr/README.md +1 -0
@@ -13,6 +13,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1313 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
1414 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
1515 - [Ajouter un skill](how-to/add-a-skill.md)
16+- [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après
1617 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
1718 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
1819 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
@@ -13,6 +13,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
13 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)13 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
14 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)14 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
15 - [Ajouter un skill](how-to/add-a-skill.md)15 - [Ajouter un skill](how-to/add-a-skill.md)
16+- [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après
16 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)17 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
17 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)18 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
18 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)19 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
added docs/fr/how-to/measure-skill-routing.md +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+# Mesurer si le modèle choisit le bon skill
2+
3+> Lancer l'eval de routage, lire son tableau, et s'en servir pour décider si un changement du catalogue ou du prompt a aidé.
4+
5+`mm` affiche un skill dans le catalogue et espère que le modèle ira le chercher. Qu'il le fasse ou non est une propriété du **modèle**, pas du code : aucun test ordinaire ne peut y répondre. Celui-ci interroge un modèle vivant, vingt fois.
6+
7+## Le lancer
8+
9+```bash
10+MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 25m
11+```
12+
13+Les deux variables sont nécessaires en pratique : sans `MM_EVAL` le test se saute, et sans `AGENT_CONFIG` il évalue les défauts intégrés plutôt que la configuration qui vous intéresse. `go test ./...` le saute, donc rien dans la CI ne dépend d'un serveur démarré.
14+
15+Comptez quelques minutes : un tour de modèle par cas, et les petits modèles ne sont pas rapides. Les lignes s'affichent au fur et à mesure.
16+
17+## Lire le tableau
18+
19+```
20+[ 6/20] ✓ hit want=what-time-is-it got=what-time-is-it 1.204s "quelle heure est-il ?" ← French
21+[14/20] ≠ wrong want=greetings got=vulcan-salute 0.981s "Salue Bob de ma part" ← two skills claim salut/saluer
22+[16/20] · miss want=bg-jobs got=(none) 2.017s "lance le serveur en tâche de fond"
23+[17/20] ! extra want=(none) got=greetings 0.774s "write a haiku about pointers"
24+```
25+
26+| Marque | Sens | Ce que ça désigne |
27+|---|---|---|
28+| `✓ hit` | le skill attendu, ou correctement aucun | — |
29+| `≠ wrong` | un skill a été chargé, mais pas celui-là | le **catalogue** : deux entrées que le modèle ne distingue pas |
30+| `· miss` | un skill s'appliquait et aucun n'a été chargé | le **prompt** : le modèle n'a pas pensé à chercher |
31+| `! extra` | aucun skill ne s'appliquait et un a été chargé | instruction trop insistante, ou entrée qui promet trop |
32+| `✗ error` | le tour a échoué | **jamais un score de routage.** Soit l'instrument (un stub qui a affamé le modèle, `caseTimeout` trop court, un serveur parti), soit le modèle incapable de composer un appel d'outil valide (`unexpected end of JSON input`, ou le garde-fou anti-emballement qui coupe des arguments répétés). Le message dit lequel. Fait échouer le test, car les chiffres sont alors incomplets. |
33+
34+`wrong` et `miss` sont séparés à dessein. Les confondre masque lequel des deux problèmes vous avez, et ils n'ont pas le même remède.
35+
36+## S'en servir
37+
38+**Le chiffre seul ne veut rien dire.** Il n'y a pas de seuil et le test n'échoue pas sur un mauvais score — un chiffre qui casse le build devient une cible, et la première chose qu'on fait devant un build rouge, c'est modifier les cas.
39+
40+L'eval est un **avant/après** :
41+
42+1. Le lancer, garder la sortie.
43+2. Changer **une** chose — les `keywords` d'un skill, le bloc `SKILLS` du prompt système, une `description`.
44+3. Le relancer sur le même modèle, avec la même configuration.
45+4. Comparer.
46+
47+Un changement à la fois. Deux d'un coup et vous avez mesuré leur somme.
48+
49+Les petits modèles à `temperature: 0.0` sont assez reproductibles pour que ça ait un sens ; relancez deux fois avant de croire à un écart d'un seul cas.
50+
51+## Ce qu'il mesure, et ce qu'il ne mesure pas
52+
53+Il mesure le **catalogue** — la description d'outil que le modèle lit — sous un prompt système donné. Un tour de modèle par cas, avec des outils qui enregistrent ce qui a été demandé et **n'exécutent rien** : un skill qui dit « lance cet echo » ne doit pas echo vingt fois, et laisser tourner la boucle de l'agent mesurerait la récupération autant que le routage.
54+
55+Il ne mesure pas si le skill, une fois chargé, a été correctement suivi. C'est une autre question, et il faut un autre instrument.
56+
57+Il ne mesure pas non plus si le modèle sait émettre un appel d'outil valide. Quand il ne sait pas — et les petits modèles souvent ne savent pas — vous obtenez `✗ error`, pas un mauvais score de routage. Ces deux échecs n'ont rien à voir, et aucun travail sur le catalogue ne corrige le second. Mesuré sur `qwen3.5-4b` : trois cas sur vingt échouent ainsi, de façon reproductible, sur les demandes qui appellent un appel plus structuré.
58+
59+## Ajouter un cas
60+
61+`internal/eval/routing_test.go`, la table `cases` :
62+
63+```go
64+{"lance le serveur en tâche de fond", "bg-jobs", "French"},
65+{"what is 17 times 23?", none, ""},
66+```
67+
68+Deux règles qui gardent les chiffres honnêtes :
69+
70+- **Ne formulez pas la demande avec les mots du catalogue.** Un cas qui dit « utilise le skill greetings » ne mesure rien.
71+- **Gardez les cas `none`.** Un catalogue qui se déclenche sur tout est aussi cassé qu'un catalogue qui ne se déclenche jamais, et s'abstenir à raison est un succès.
72+
73+Quand vous trouvez un vrai échec de routage, ajoutez-le comme cas **avant** de le corriger. C'est ainsi que l'eval empêche le même échec de revenir.
74+
75+## Écrivez des cas capables de séparer les explications
76+
77+Un cas isolé dit que quelque chose a échoué. Un **ensemble** de cas dit pourquoi, et c'est ce qui rend l'eval utile.
78+
79+Exemple vécu. `bg-jobs` échouait sur les demandes anglaises et réussissait sur les françaises, et la lecture évidente était « la couverture française du catalogue est faible » — sauf que `bg-jobs` n'a aucun mot-clé français. Trois cas ont tranché :
80+
81+| Cas | Résultat |
82+|---|---|
83+| `start the dev server in the background` — anglais, **nomme** le concept | ✓ hit |
84+| `démarre le serveur de dev et vérifie qu'il répond` — français, juste une situation | · miss |
85+| `lance le serveur en tâche de fond` — français, **nomme** le concept | ✓ hit |
86+| `start the dev server and check it answers` — anglais, juste une situation | · miss |
87+
88+Un 2×2 net : **la langue n'a rien à voir.** Ce qui décide, c'est que la demande nomme le concept dont parle le skill. Ça a déplacé le problème du catalogue vers le prompt — le modèle n'a aucune raison de savoir qu'un serveur bloque le tour — et aucune retouche de mots-clés ne l'aurait trouvé.
89+
90+Construisez les cas par paires qui ne diffèrent que d'**une** chose. Un cas seul est une anecdote.
new file mode 100644
@@ -0,0 +1,90 @@
1+# Mesurer si le modèle choisit le bon skill
2+
3+> Lancer l'eval de routage, lire son tableau, et s'en servir pour décider si un changement du catalogue ou du prompt a aidé.
4+
5+`mm` affiche un skill dans le catalogue et espère que le modèle ira le chercher. Qu'il le fasse ou non est une propriété du **modèle**, pas du code : aucun test ordinaire ne peut y répondre. Celui-ci interroge un modèle vivant, vingt fois.
6+
7+## Le lancer
8+
9+```bash
10+MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v -timeout 25m
11+```
12+
13+Les deux variables sont nécessaires en pratique : sans `MM_EVAL` le test se saute, et sans `AGENT_CONFIG` il évalue les défauts intégrés plutôt que la configuration qui vous intéresse. `go test ./...` le saute, donc rien dans la CI ne dépend d'un serveur démarré.
14+
15+Comptez quelques minutes : un tour de modèle par cas, et les petits modèles ne sont pas rapides. Les lignes s'affichent au fur et à mesure.
16+
17+## Lire le tableau
18+
19+```
20+[ 6/20] ✓ hit want=what-time-is-it got=what-time-is-it 1.204s "quelle heure est-il ?" ← French
21+[14/20] ≠ wrong want=greetings got=vulcan-salute 0.981s "Salue Bob de ma part" ← two skills claim salut/saluer
22+[16/20] · miss want=bg-jobs got=(none) 2.017s "lance le serveur en tâche de fond"
23+[17/20] ! extra want=(none) got=greetings 0.774s "write a haiku about pointers"
24+```
25+
26+| Marque | Sens | Ce que ça désigne |
27+|---|---|---|
28+| `✓ hit` | le skill attendu, ou correctement aucun | — |
29+| `≠ wrong` | un skill a été chargé, mais pas celui-là | le **catalogue** : deux entrées que le modèle ne distingue pas |
30+| `· miss` | un skill s'appliquait et aucun n'a été chargé | le **prompt** : le modèle n'a pas pensé à chercher |
31+| `! extra` | aucun skill ne s'appliquait et un a été chargé | instruction trop insistante, ou entrée qui promet trop |
32+| `✗ error` | le tour a échoué | **jamais un score de routage.** Soit l'instrument (un stub qui a affamé le modèle, `caseTimeout` trop court, un serveur parti), soit le modèle incapable de composer un appel d'outil valide (`unexpected end of JSON input`, ou le garde-fou anti-emballement qui coupe des arguments répétés). Le message dit lequel. Fait échouer le test, car les chiffres sont alors incomplets. |
33+
34+`wrong` et `miss` sont séparés à dessein. Les confondre masque lequel des deux problèmes vous avez, et ils n'ont pas le même remède.
35+
36+## S'en servir
37+
38+**Le chiffre seul ne veut rien dire.** Il n'y a pas de seuil et le test n'échoue pas sur un mauvais score — un chiffre qui casse le build devient une cible, et la première chose qu'on fait devant un build rouge, c'est modifier les cas.
39+
40+L'eval est un **avant/après** :
41+
42+1. Le lancer, garder la sortie.
43+2. Changer **une** chose — les `keywords` d'un skill, le bloc `SKILLS` du prompt système, une `description`.
44+3. Le relancer sur le même modèle, avec la même configuration.
45+4. Comparer.
46+
47+Un changement à la fois. Deux d'un coup et vous avez mesuré leur somme.
48+
49+Les petits modèles à `temperature: 0.0` sont assez reproductibles pour que ça ait un sens ; relancez deux fois avant de croire à un écart d'un seul cas.
50+
51+## Ce qu'il mesure, et ce qu'il ne mesure pas
52+
53+Il mesure le **catalogue** — la description d'outil que le modèle lit — sous un prompt système donné. Un tour de modèle par cas, avec des outils qui enregistrent ce qui a été demandé et **n'exécutent rien** : un skill qui dit « lance cet echo » ne doit pas echo vingt fois, et laisser tourner la boucle de l'agent mesurerait la récupération autant que le routage.
54+
55+Il ne mesure pas si le skill, une fois chargé, a été correctement suivi. C'est une autre question, et il faut un autre instrument.
56+
57+Il ne mesure pas non plus si le modèle sait émettre un appel d'outil valide. Quand il ne sait pas — et les petits modèles souvent ne savent pas — vous obtenez `✗ error`, pas un mauvais score de routage. Ces deux échecs n'ont rien à voir, et aucun travail sur le catalogue ne corrige le second. Mesuré sur `qwen3.5-4b` : trois cas sur vingt échouent ainsi, de façon reproductible, sur les demandes qui appellent un appel plus structuré.
58+
59+## Ajouter un cas
60+
61+`internal/eval/routing_test.go`, la table `cases` :
62+
63+```go
64+{"lance le serveur en tâche de fond", "bg-jobs", "French"},
65+{"what is 17 times 23?", none, ""},
66+```
67+
68+Deux règles qui gardent les chiffres honnêtes :
69+
70+- **Ne formulez pas la demande avec les mots du catalogue.** Un cas qui dit « utilise le skill greetings » ne mesure rien.
71+- **Gardez les cas `none`.** Un catalogue qui se déclenche sur tout est aussi cassé qu'un catalogue qui ne se déclenche jamais, et s'abstenir à raison est un succès.
72+
73+Quand vous trouvez un vrai échec de routage, ajoutez-le comme cas **avant** de le corriger. C'est ainsi que l'eval empêche le même échec de revenir.
74+
75+## Écrivez des cas capables de séparer les explications
76+
77+Un cas isolé dit que quelque chose a échoué. Un **ensemble** de cas dit pourquoi, et c'est ce qui rend l'eval utile.
78+
79+Exemple vécu. `bg-jobs` échouait sur les demandes anglaises et réussissait sur les françaises, et la lecture évidente était « la couverture française du catalogue est faible » — sauf que `bg-jobs` n'a aucun mot-clé français. Trois cas ont tranché :
80+
81+| Cas | Résultat |
82+|---|---|
83+| `start the dev server in the background` — anglais, **nomme** le concept | ✓ hit |
84+| `démarre le serveur de dev et vérifie qu'il répond` — français, juste une situation | · miss |
85+| `lance le serveur en tâche de fond` — français, **nomme** le concept | ✓ hit |
86+| `start the dev server and check it answers` — anglais, juste une situation | · miss |
87+
88+Un 2×2 net : **la langue n'a rien à voir.** Ce qui décide, c'est que la demande nomme le concept dont parle le skill. Ça a déplacé le problème du catalogue vers le prompt — le modèle n'a aucune raison de savoir qu'un serveur bloque le tour — et aucune retouche de mots-clés ne l'aurait trouvé.
89+
90+Construisez les cas par paires qui ne diffèrent que d'**une** chose. Un cas seul est une anecdote.
modified docs/fr/reference/skill-format.md +29 -5
@@ -14,12 +14,13 @@
1414
1515 ## En-tête (front matter)
1616
17-Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules deux clés sont lues ; les autres sont ignorées.
17+Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules trois clés sont lues ; les autres sont ignorées.
1818
1919 | Clé | Type | Défaut | Description |
2020 |-----|------|--------|-------------|
2121 | `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. |
22-| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. |
22+| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. Un skill qui n'en a pas apparaît comme un nom nu, auquel le modèle ne peut rien rattacher ; la bannière de démarrage le signale. |
23+| `keywords` | liste séparée par des virgules | vide | Les mots qu'un utilisateur taperait vraiment, dans n'importe quelle langue. Affichés dans le catalogue après la description, **plafonnés aux 8 premiers** — le catalogue part à chaque requête. C'est le pont lexical : *« salue Bob »* atteint `greetings` par `saluer`, jamais par le nom. **L'ordre compte** : au-delà du huitième mot, rien n'atteint le modèle — mettez les plus distinctifs en premier. La bannière avertit quand une liste est tronquée. Ne dépensez pas les huit sur des mots déjà présents dans la `description` : le catalogue affiche les deux, et une description est dans une seule langue — les mots-clés sont là pour les autres. |
2324
2425 Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée.
2526
@@ -29,11 +30,11 @@ La description de l'outil est :
2930
3031 ```
3132 Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32- <name> — <description>
33+ <name> — <description> (<keyword>, <keyword>, …)
3334 <name>
3435 ```
3536
36-Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide.
37+Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide, et la partie ` (…)` quand il n'y a pas de mots-clés.
3738
3839 ### Exemple
3940
@@ -53,7 +54,30 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
5354 ```
5455 ```
5556
56-La clé `keywords` est acceptée dans le fichier et ignorée par le chargeur.
57+Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
58+
59+## Écrire un skill que le modèle va EXÉCUTER
60+
61+Un skill qui demande au modèle de lancer une commande a un mode d'échec, et ce n'est pas celui qu'on attend : le modèle **recopie la commande au lieu de l'exécuter**, dans un bloc de code clôturé, et répond comme s'il avait fait le travail.
62+
63+Mesuré sur `mellum2-12b-a2.5b-instruct`, le même skill dans les deux formes :
64+
65+| Ce que dit le skill | Résultat |
66+|---|---|
67+| bloc clôturé ` ```bash ` | `⚙ 0 command(s)` — le modèle réaffiche la commande, trois passes sur trois |
68+| la commande **indentée**, et « call your `bash` tool with this command » | `⚙ 1 command(s)` — trois passes sur trois |
69+
70+Un bloc clôturé est une affordance d'*affichage*. Mettez-en un dans le contexte du modèle et la continuation la plus probable est le même bloc — c'est exactement ce qu'un petit modèle produit. Nommer l'outil et indenter la commande retire l'invitation.
71+
72+C'est dépendant du modèle — `qwen3.5-4b` exécutait correctement la version clôturée — donc un skill qui marche sur votre modèle peut échouer sur celui d'un autre. Écrivez-le de la façon sûre quand même.
73+
74+La règle, pour un skill dont tout l'intérêt est qu'une commande tourne :
75+
76+- **pas de clôture ` ```bash ` autour de la commande à exécuter.** Indentez-la de quatre espaces.
77+- **nommez l'outil** : « call your `bash` tool with this command », pas « run: ».
78+- gardez ce que disent les skills livrés — *the answer is the command's output, nothing else* — car c'est ce qui empêche le modèle de répondre de mémoire.
79+
80+Un skill qui est de la documentation plutôt qu'une commande à lancer — `bg-jobs`, qui en montre une demi-douzaine — garde ses clôtures. Rien là-dedans n'est « la commande à exécuter », donc il n'y a pas d'invitation à mal lire.
5781
5882 ## Exemples livrés
5983
@@ -14,12 +14,13 @@
14 14
15 ## En-tête (front matter)15 ## En-tête (front matter)
16 16
17-Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules deux clés sont lues ; les autres sont ignorées.17+Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules trois clés sont lues ; les autres sont ignorées.
18 18
19 | Clé | Type | Défaut | Description |19 | Clé | Type | Défaut | Description |
20 |-----|------|--------|-------------|20 |-----|------|--------|-------------|
21 | `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. |21 | `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. |
22-| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. |22+| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. Un skill qui n'en a pas apparaît comme un nom nu, auquel le modèle ne peut rien rattacher ; la bannière de démarrage le signale. |
23+| `keywords` | liste séparée par des virgules | vide | Les mots qu'un utilisateur taperait vraiment, dans n'importe quelle langue. Affichés dans le catalogue après la description, **plafonnés aux 8 premiers** — le catalogue part à chaque requête. C'est le pont lexical : *« salue Bob »* atteint `greetings` par `saluer`, jamais par le nom. **L'ordre compte** : au-delà du huitième mot, rien n'atteint le modèle — mettez les plus distinctifs en premier. La bannière avertit quand une liste est tronquée. Ne dépensez pas les huit sur des mots déjà présents dans la `description` : le catalogue affiche les deux, et une description est dans une seule langue — les mots-clés sont là pour les autres. |
23 24
24 Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée.25 Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée.
25 26
@@ -29,11 +30,11 @@ La description de l'outil est :
29 30
30 ```31 ```
31 Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:32 Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32- <name> — <description>33+ <name> — <description> (<keyword>, <keyword>, …)
33 <name>34 <name>
34 ```35 ```
35 36
36-Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide.37+Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide, et la partie ` (…)` quand il n'y a pas de mots-clés.
37 38
38 ### Exemple39 ### Exemple
39 40
@@ -53,7 +54,30 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
53 ```54 ```
54 ```55 ```
55 56
56-La clé `keywords` est acceptée dans le fichier et ignorée par le chargeur.57+Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
58+
59+## Écrire un skill que le modèle va EXÉCUTER
60+
61+Un skill qui demande au modèle de lancer une commande a un mode d'échec, et ce n'est pas celui qu'on attend : le modèle **recopie la commande au lieu de l'exécuter**, dans un bloc de code clôturé, et répond comme s'il avait fait le travail.
62+
63+Mesuré sur `mellum2-12b-a2.5b-instruct`, le même skill dans les deux formes :
64+
65+| Ce que dit le skill | Résultat |
66+|---|---|
67+| bloc clôturé ` ```bash ` | `⚙ 0 command(s)` — le modèle réaffiche la commande, trois passes sur trois |
68+| la commande **indentée**, et « call your `bash` tool with this command » | `⚙ 1 command(s)` — trois passes sur trois |
69+
70+Un bloc clôturé est une affordance d'*affichage*. Mettez-en un dans le contexte du modèle et la continuation la plus probable est le même bloc — c'est exactement ce qu'un petit modèle produit. Nommer l'outil et indenter la commande retire l'invitation.
71+
72+C'est dépendant du modèle — `qwen3.5-4b` exécutait correctement la version clôturée — donc un skill qui marche sur votre modèle peut échouer sur celui d'un autre. Écrivez-le de la façon sûre quand même.
73+
74+La règle, pour un skill dont tout l'intérêt est qu'une commande tourne :
75+
76+- **pas de clôture ` ```bash ` autour de la commande à exécuter.** Indentez-la de quatre espaces.
77+- **nommez l'outil** : « call your `bash` tool with this command », pas « run: ».
78+- gardez ce que disent les skills livrés — *the answer is the command's output, nothing else* — car c'est ce qui empêche le modèle de répondre de mémoire.
79+
80+Un skill qui est de la documentation plutôt qu'une commande à lancer — `bg-jobs`, qui en montre une demi-douzaine — garde ses clôtures. Rien là-dedans n'est « la commande à exécuter », donc il n'y a pas d'invitation à mal lire.
57 81
58 ## Exemples livrés82 ## Exemples livrés
59 83
modified internal/agent/agent.go +4 -2
@@ -251,8 +251,10 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
251251 if len(ops) > 0 {
252252 line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
253253 }
254- if k := engine.Skills(turn); k > 0 {
255- line += fmt.Sprintf(" · 📖 %d skill(s)", k)
254+ // Always shown when the agent has skills, 0 included: see
255+ // engine.SkillsAvailable.
256+ if engine.SkillsAvailable() {
257+ line += fmt.Sprintf(" · 📖 %d skill(s)", engine.Skills(turn))
256258 }
257259 if k := engine.LSPCalls(turn); k > 0 {
258260 line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
@@ -251,8 +251,10 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
251 if len(ops) > 0 {251 if len(ops) > 0 {
252 line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))252 line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
253 }253 }
254- if k := engine.Skills(turn); k > 0 {254+ // Always shown when the agent has skills, 0 included: see
255- line += fmt.Sprintf(" · 📖 %d skill(s)", k)255+ // engine.SkillsAvailable.
256+ if engine.SkillsAvailable() {
257+ line += fmt.Sprintf(" · 📖 %d skill(s)", engine.Skills(turn))
256 }258 }
257 if k := engine.LSPCalls(turn); k > 0 {259 if k := engine.LSPCalls(turn); k > 0 {
258 line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)260 line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
modified internal/engine/engine.go +42 -1
@@ -666,9 +666,50 @@ func fileOpText(r *ai.ToolRequest) string {
666666 // next to the command count: seeing which procedure the agent chose to follow is
667667 // half the point of this part.
668668 func Skills(history []*ai.Message) int {
669- return responses(history, toolSkill)
669+ n := 0
670+ for _, m := range history {
671+ for _, p := range m.Content {
672+ if !p.IsToolResponse() || p.ToolResponse == nil || p.ToolResponse.Name != toolSkill {
673+ continue
674+ }
675+ if !strings.HasPrefix(strings.TrimSpace(responseText(p.ToolResponse)), skillMiss) {
676+ n++
677+ }
678+ }
679+ }
680+ return n
670681 }
671682
683+// skillMiss is how `read_skill` answers a name that is not in the catalogue.
684+// Counting those as skills made the number say the opposite of what it is
685+// for: `📖 2 skill(s)` could mean two failed lookups and nothing loaded.
686+const skillMiss = "[skill:"
687+
688+// responseText is a tool response as text. Every tool here returns a string;
689+// anything else is formatted rather than dropped, so a change of shape shows
690+// up as an odd count instead of a silent zero.
691+func responseText(r *ai.ToolResponse) string {
692+ if s, ok := r.Output.(string); ok {
693+ return s
694+ }
695+ return fmt.Sprintf("%v", r.Output)
696+}
697+
698+// skillsDeclared says this agent has a skills catalogue at all. Set once by
699+// main, read by the three turn recaps.
700+//
701+// Why it exists: `📖 N skill(s)` used to be printed only when N > 0, so
702+// "no skill applied" and "a skill applied and the model ignored it" looked
703+// identical — nothing at all. Showing `📖 0 skill(s)` is the whole point;
704+// showing it in a project that has no skills would be noise.
705+var skillsDeclared atomic.Bool
706+
707+// DeclareSkills records whether the agent was given any skill.
708+func DeclareSkills(yes bool) { skillsDeclared.Store(yes) }
709+
710+// SkillsAvailable reports it, for the turn recaps.
711+func SkillsAvailable() bool { return skillsDeclared.Load() }
712+
672713 // LSPCalls counts the `lsp` tool responses in a history — questions put to a
673714 // language server (definition, references, hover, symbols, diagnostics,
674715 // rename). Shown apart from the commands for the same reason as the file
@@ -666,9 +666,50 @@ func fileOpText(r *ai.ToolRequest) string {
666 // next to the command count: seeing which procedure the agent chose to follow is666 // next to the command count: seeing which procedure the agent chose to follow is
667 // half the point of this part.667 // half the point of this part.
668 func Skills(history []*ai.Message) int {668 func Skills(history []*ai.Message) int {
669- return responses(history, toolSkill)669+ n := 0
670+ for _, m := range history {
671+ for _, p := range m.Content {
672+ if !p.IsToolResponse() || p.ToolResponse == nil || p.ToolResponse.Name != toolSkill {
673+ continue
674+ }
675+ if !strings.HasPrefix(strings.TrimSpace(responseText(p.ToolResponse)), skillMiss) {
676+ n++
677+ }
678+ }
679+ }
680+ return n
670 }681 }
671 682
683+// skillMiss is how `read_skill` answers a name that is not in the catalogue.
684+// Counting those as skills made the number say the opposite of what it is
685+// for: `📖 2 skill(s)` could mean two failed lookups and nothing loaded.
686+const skillMiss = "[skill:"
687+
688+// responseText is a tool response as text. Every tool here returns a string;
689+// anything else is formatted rather than dropped, so a change of shape shows
690+// up as an odd count instead of a silent zero.
691+func responseText(r *ai.ToolResponse) string {
692+ if s, ok := r.Output.(string); ok {
693+ return s
694+ }
695+ return fmt.Sprintf("%v", r.Output)
696+}
697+
698+// skillsDeclared says this agent has a skills catalogue at all. Set once by
699+// main, read by the three turn recaps.
700+//
701+// Why it exists: `📖 N skill(s)` used to be printed only when N > 0, so
702+// "no skill applied" and "a skill applied and the model ignored it" looked
703+// identical — nothing at all. Showing `📖 0 skill(s)` is the whole point;
704+// showing it in a project that has no skills would be noise.
705+var skillsDeclared atomic.Bool
706+
707+// DeclareSkills records whether the agent was given any skill.
708+func DeclareSkills(yes bool) { skillsDeclared.Store(yes) }
709+
710+// SkillsAvailable reports it, for the turn recaps.
711+func SkillsAvailable() bool { return skillsDeclared.Load() }
712+
672 // LSPCalls counts the `lsp` tool responses in a history — questions put to a713 // LSPCalls counts the `lsp` tool responses in a history — questions put to a
673 // language server (definition, references, hover, symbols, diagnostics,714 // language server (definition, references, hover, symbols, diagnostics,
674 // rename). Shown apart from the commands for the same reason as the file715 // rename). Shown apart from the commands for the same reason as the file
modified internal/engine/provider_test.go +11 -4
@@ -51,12 +51,19 @@ func openaiServer(t *testing.T, status int, body string, models ...string) *http
5151
5252 // closedURL returns a URL nothing listens on: a server started and stopped, so
5353 // the port was free a moment ago.
54+// closedURL is an address nothing answers on.
55+//
56+// It used to start an httptest server, record its URL and close it — which
57+// leaves an EPHEMERAL port that the kernel is free to hand to the next
58+// listener, so the test occasionally connected to something real and lost the
59+// "nothing answers" hint it was checking for. Measured: one failure in about
60+// thirty runs, and more when other servers were being started nearby.
61+//
62+// Port 1 cannot be listened on without privileges and nothing uses it, so a
63+// connection there is refused by construction rather than by luck.
5464 func closedURL(t *testing.T) string {
5565 t.Helper()
56- srv := httptest.NewServer(http.NotFoundHandler())
57- url := srv.URL
58- srv.Close()
59- return url
66+ return "http://127.0.0.1:1"
6067 }
6168
6269 func ptr(s string) *string { return &s }
@@ -51,12 +51,19 @@ func openaiServer(t *testing.T, status int, body string, models ...string) *http
51 51
52 // closedURL returns a URL nothing listens on: a server started and stopped, so52 // closedURL returns a URL nothing listens on: a server started and stopped, so
53 // the port was free a moment ago.53 // the port was free a moment ago.
54+// closedURL is an address nothing answers on.
55+//
56+// It used to start an httptest server, record its URL and close it — which
57+// leaves an EPHEMERAL port that the kernel is free to hand to the next
58+// listener, so the test occasionally connected to something real and lost the
59+// "nothing answers" hint it was checking for. Measured: one failure in about
60+// thirty runs, and more when other servers were being started nearby.
61+//
62+// Port 1 cannot be listened on without privileges and nothing uses it, so a
63+// connection there is refused by construction rather than by luck.
54 func closedURL(t *testing.T) string {64 func closedURL(t *testing.T) string {
55 t.Helper()65 t.Helper()
56- srv := httptest.NewServer(http.NotFoundHandler())66+ return "http://127.0.0.1:1"
57- url := srv.URL
58- srv.Close()
59- return url
60 }67 }
61 68
62 func ptr(s string) *string { return &s }69 func ptr(s string) *string { return &s }
added internal/eval/doc.go +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+// Package eval measures whether the model picks the right skill.
2+//
3+// Everything else in mm is tested against a fake: fast, hermetic, and
4+// deterministic. This one cannot be. The question it answers — "does a 4B
5+// model reading this catalogue reach for the right procedure?" — is a
6+// question about a MODEL, and only a model can answer it.
7+//
8+// So it is opt-in. `go test ./...` skips it; nothing in CI depends on a
9+// server being up. Run it deliberately:
10+//
11+// MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v
12+//
13+// What it measures is the CATALOGUE, not the agent. One model turn per case,
14+// with tools that record what was asked for and run nothing: a skill that
15+// says "run this echo" must not be echoed twenty times, and an agent loop
16+// would measure recovery as much as routing. What is left is exactly the
17+// thing ticket 15 is about — given this system prompt and this catalogue,
18+// what does the model reach for first?
19+//
20+// Read the numbers as a comparison, not as a grade. The useful question is
21+// never "is 70% good" but "did that change to the keywords, or to the SKILLS
22+// block of the prompt, move it up or down" — so run it before a change and
23+// after, against the same model, and keep both outputs.
24+package eval
new file mode 100644
@@ -0,0 +1,24 @@
1+// Package eval measures whether the model picks the right skill.
2+//
3+// Everything else in mm is tested against a fake: fast, hermetic, and
4+// deterministic. This one cannot be. The question it answers — "does a 4B
5+// model reading this catalogue reach for the right procedure?" — is a
6+// question about a MODEL, and only a model can answer it.
7+//
8+// So it is opt-in. `go test ./...` skips it; nothing in CI depends on a
9+// server being up. Run it deliberately:
10+//
11+// MM_EVAL=1 AGENT_CONFIG=tests/agent.dmr-qwen.yaml go test ./internal/eval -v
12+//
13+// What it measures is the CATALOGUE, not the agent. One model turn per case,
14+// with tools that record what was asked for and run nothing: a skill that
15+// says "run this echo" must not be echoed twenty times, and an agent loop
16+// would measure recovery as much as routing. What is left is exactly the
17+// thing ticket 15 is about — given this system prompt and this catalogue,
18+// what does the model reach for first?
19+//
20+// Read the numbers as a comparison, not as a grade. The useful question is
21+// never "is 70% good" but "did that change to the keywords, or to the SKILLS
22+// block of the prompt, move it up or down" — so run it before a change and
23+// after, against the same model, and keep both outputs.
24+package eval
added internal/eval/routing_test.go +345 -0
new file mode 100644
@@ -0,0 +1,345 @@
1+package eval
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "mm/internal/config"
16+ "mm/internal/engine"
17+ "mm/internal/skills"
18+ "mm/internal/spinner"
19+ "mm/internal/ui"
20+
21+ "github.com/firebase/genkit/go/ai"
22+ "github.com/firebase/genkit/go/genkit"
23+)
24+
25+// none is the expected skill of a request that no skill covers. Abstaining is
26+// a right answer, and a catalogue that fires on everything is as broken as
27+// one that never fires — hence these cases carry the same weight as the rest.
28+const none = ""
29+
30+// A case is a request a user could plausibly type, and the skill it should
31+// reach. The prompts are deliberately not phrased in the words of the
32+// catalogue: a model matching "use the greetings skill" measures nothing.
33+type routingCase struct {
34+ prompt string
35+ want string
36+ why string // what this case is here to catch, when it is not obvious
37+}
38+
39+var cases = []routingCase{
40+ // --- each shipped skill, in the words a user would use ------------------
41+ {"say hi to Sam for me", "greetings", ""},
42+ {"envoie un petit bonjour à Sam", "greetings", "French, and the skill's name is English"},
43+ {"give Spock the proper farewell", "vulcan-salute", "no shared word with the name or the description"},
44+ {"fais le salut vulcain à Sam", "vulcan-salute", ""},
45+ {"what time is it?", "what-time-is-it", ""},
46+ {"quelle heure est-il ?", "what-time-is-it", "French"},
47+ {"what is today's date?", "what-time-is-it", ""},
48+ {"where is the Greet function defined?", "lsp-navigation", ""},
49+ {"qui appelle cette fonction ?", "lsp-navigation", "French, and the skill is about symbols"},
50+ {"rename the Greet symbol to Hello everywhere", "lsp-navigation", ""},
51+ {"start the dev server and check it answers", "bg-jobs", ""},
52+ {"lance le serveur en tâche de fond", "bg-jobs", "French"},
53+ {"the server I started earlier is still running, stop it", "bg-jobs", ""},
54+
55+ // --- bg-jobs: why does the FRENCH case hit and the English ones miss? ---
56+ // Measured on mellum2-12b and qwen3.5-4b: "lance le serveur en tâche de
57+ // fond" hits, "start the dev server and check it answers" misses. bg-jobs
58+ // has NO French keyword at all, so "French coverage" cannot be the
59+ // reason. Three candidate explanations, and these three cases separate
60+ // them:
61+ // (a) the French prompt NAMES the concept — "tâche de fond" is literally
62+ // "background", the first word of the description and of the
63+ // keywords — while the English ones describe a situation;
64+ // (b) `stop`, `kill` and `running` are past the 8-keyword cap and never
65+ // reach the model, so a request to stop a job has only `server`;
66+ // (c) the language really does matter.
67+ // If (a) holds, the first hits, the second misses, and the language is a
68+ // red herring. If (b) holds, the third hits.
69+ {"start the dev server in the background", "bg-jobs", "(a) English, but NAMES the concept"},
70+ {"démarre le serveur de dev et vérifie qu'il répond", "bg-jobs", "(a) French, but only a situation"},
71+ {"kill the background job I started", "bg-jobs", "(b) stop/kill/running are past the cap"},
72+
73+ // --- the overlap found on 2026-09-21 ------------------------------------
74+ // "Salue Bob de ma part" reached a skill through the keywords — and picked
75+ // vulcan-salute, because `greetings` claims "saluer" and `vulcan-salute`
76+ // claims "salut". Both are defensible readings of the French; the point of
77+ // keeping the case is to see whether a change makes the collision worse.
78+ {"Salue Bob de ma part", "greetings", "two skills claim salut/saluer — see ticket 15"},
79+
80+ // --- nothing applies: abstaining is the right answer --------------------
81+ {"what is 17 times 23?", none, ""},
82+ {"explain the difference between a slice and an array in Go", none, "a question, not a task"},
83+ {"read main.go and tell me what it does", none, "a file tool, not a skill"},
84+ {"list the files in this directory", none, "bash, not a skill"},
85+ {"write a haiku about pointers", none, ""},
86+ {"combien font 17 fois 23 ?", none, "French, still no skill"},
87+}
88+
89+// outcome is how one case came out. "wrong" is kept apart from "miss" on
90+// purpose: a model that reaches for a procedure and picks the neighbouring
91+// one has a catalogue problem, and a model that reaches for nothing has a
92+// prompt problem. Merging them hides which.
93+type outcome string
94+
95+const (
96+ hit outcome = "hit" // the expected skill, or correctly nothing
97+ wrong outcome = "wrong" // a skill was loaded, but not that one
98+ miss outcome = "miss" // a skill was expected and none was loaded
99+ extra outcome = "extra" // no skill applied and one was loaded anyway
100+ broke outcome = "error" // the turn failed: see the note in the test body
101+)
102+
103+func TestSkillRouting(t *testing.T) {
104+ if os.Getenv("MM_EVAL") == "" {
105+ t.Skip("set MM_EVAL=1 (and AGENT_CONFIG) to run the routing eval against a live model")
106+ }
107+
108+ // The eval writes a table, not a transcript: silence the agent's own
109+ // output and its spinner, which would fight the test log for the line.
110+ // ui.Out covers the banner and the warnings; the ANSWER does not go
111+ // through it — engine.emit writes the streamed text with fmt.Print, i.e.
112+ // straight to os.Stdout — so stdout itself is redirected for the run.
113+ // t.Log is unaffected: testing keeps its own writer.
114+ ui.Out = io.Discard
115+ spinner.Disable()
116+ if devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
117+ saved := os.Stdout
118+ os.Stdout = devnull
119+ t.Cleanup(func() { os.Stdout = saved; devnull.Close() })
120+ }
121+
122+ path, err := config.Load(os.Getenv("AGENT_CONFIG"))
123+ if err != nil {
124+ t.Fatalf("config: %v", err)
125+ }
126+ dir := config.Cfg.SkillsDir
127+ if !filepath.IsAbs(dir) {
128+ root, _ := filepath.Abs(filepath.Join("..", ".."))
129+ dir = filepath.Join(root, dir)
130+ }
131+ list := skills.List(dir)
132+ if len(list) == 0 {
133+ t.Fatalf("no skills in %s — the eval would score an empty catalogue", dir)
134+ }
135+
136+ e, err := engine.New(context.Background())
137+ if err != nil {
138+ t.Fatalf("engine: %v", err)
139+ }
140+ if info := e.Probe(context.Background()); !info.Reachable {
141+ t.Fatalf("%s does not answer — start the model server first", e.Backend.BaseURL)
142+ }
143+
144+ asked := &recorder{}
145+ tools := stubTools(t, e.G, list, asked)
146+
147+ t.Logf("model %s @ %s · config %s · %d skills · catalogue %d bytes",
148+ config.Cfg.Model, e.Backend.Provider, path, len(list), len(skills.Catalogue(list)))
149+
150+ // Rows go to stderr as they happen, not through t.Log: testing buffers
151+ // its log until the test ends, and an eval that prints nothing for four
152+ // minutes looks hung. The summary goes to both, so it survives in the
153+ // test output too.
154+ scores := map[outcome]int{}
155+ for i, c := range cases {
156+ asked.reset()
157+ got, dur, err := ask(e, tools, c.prompt)
158+ res := score(c.want, got, err)
159+ scores[res]++
160+ fmt.Fprintf(os.Stderr, "[%2d/%2d] %s\n", i+1, len(cases), row(res, c, got, dur, err))
161+ }
162+
163+ out := summary(scores, len(cases))
164+ fmt.Fprintln(os.Stderr, out)
165+ t.Log(out)
166+
167+ // Deliberately NOT a threshold. A number that fails the build would turn
168+ // a measurement into a target, and the first response to a red build is
169+ // to edit the cases. Read the table; compare it with the last run.
170+ if scores[broke] > 0 {
171+ t.Errorf("%d case(s) failed to run, so the numbers above are incomplete.\n"+
172+ "An `error` is never a routing score. Two families, and the message says which:\n"+
173+ " - the instrument: a stub that gave the model nothing to finish on, a caseTimeout "+
174+ "too short for this model, a server that went away;\n"+
175+ " - the model: it could not compose a valid tool call at all — `unexpected end of "+
176+ "JSON input`, or the runaway guard cutting off repeated arguments. That is a real "+
177+ "finding about the model, not about the catalogue, and no prompt change will fix it.",
178+ scores[broke])
179+ }
180+}
181+
182+// caseTimeout is generous on purpose. An `error` does not just lose one case,
183+// it poisons the whole table — so the cap is there to stop a hung server, not
184+// to score speed. Measured on qwen3.5-4b: most cases land in 5–20s, one took
185+// 1m20s, and a 2-minute cap turned three perfectly good cases into errors.
186+//
187+// A slow model therefore makes a slow eval. That is the right trade: the
188+// numbers are the point, the wall clock is not.
189+const caseTimeout = 5 * time.Minute
190+
191+// ask runs ONE model turn and reports the skill it asked for, if any.
192+func ask(e *engine.Engine, tools []ai.ToolRef, prompt string) (string, time.Duration, error) {
193+ ctx, cancel := context.WithTimeout(context.Background(), caseTimeout)
194+ defer cancel()
195+
196+ start := time.Now()
197+ msgs := []*ai.Message{
198+ ai.NewSystemTextMessage(config.Cfg.System),
199+ ai.NewUserTextMessage(prompt),
200+ }
201+ _, history, err := e.Generate(ctx, msgs, tools)
202+ took := time.Since(start).Round(time.Millisecond)
203+ if err != nil {
204+ return "", took, err
205+ }
206+ return firstSkill(history), took, nil
207+}
208+
209+// firstSkill is the name of the first read_skill call of a turn — what the
210+// model reached for, before any recovery.
211+func firstSkill(history []*ai.Message) string {
212+ for _, m := range history {
213+ for _, p := range m.Content {
214+ if !p.IsToolRequest() || p.ToolRequest == nil || p.ToolRequest.Name != "read_skill" {
215+ continue
216+ }
217+ if in, ok := p.ToolRequest.Input.(map[string]any); ok {
218+ if n, ok := in["name"].(string); ok {
219+ return strings.TrimSpace(n)
220+ }
221+ }
222+ }
223+ }
224+ return ""
225+}
226+
227+func score(want, got string, err error) outcome {
228+ switch {
229+ case err != nil:
230+ return broke
231+ case want == none && got == "":
232+ return hit
233+ case want == none:
234+ return extra
235+ case got == "":
236+ return miss
237+ case got == want:
238+ return hit
239+ }
240+ return wrong
241+}
242+
243+func row(res outcome, c routingCase, got string, dur time.Duration, err error) string {
244+ mark := map[outcome]string{hit: "✓", wrong: "≠", miss: "·", extra: "!", broke: "✗"}[res]
245+ want, gotShown := c.want, got
246+ if want == none {
247+ want = "(none)"
248+ }
249+ if gotShown == "" {
250+ gotShown = "(none)"
251+ }
252+ line := fmt.Sprintf("%s %-6s want=%-16s got=%-16s %6s %q", mark, res, want, gotShown, dur, c.prompt)
253+ if c.why != "" {
254+ line += " ← " + c.why
255+ }
256+ if err != nil {
257+ line += " [" + err.Error() + "]"
258+ }
259+ return line
260+}
261+
262+func summary(scores map[outcome]int, total int) string {
263+ var b strings.Builder
264+ fmt.Fprintf(&b, "\n%d cases · ", total)
265+ keys := []outcome{hit, wrong, miss, extra, broke}
266+ var parts []string
267+ for _, k := range keys {
268+ if scores[k] > 0 {
269+ parts = append(parts, fmt.Sprintf("%s %d", k, scores[k]))
270+ }
271+ }
272+ sort.Strings(nil) // keep the order above, not alphabetical
273+ b.WriteString(strings.Join(parts, " · "))
274+ fmt.Fprintf(&b, " → %d%% right", 100*scores[hit]/max(total, 1))
275+ return b.String()
276+}
277+
278+// --- the stub tools ---------------------------------------------------------
279+
280+// recorder remembers what the model asked for. Written from Genkit's tool
281+// goroutines, read after the turn.
282+type recorder struct {
283+ mu sync.Mutex
284+ skill string
285+}
286+
287+func (r *recorder) reset() { r.mu.Lock(); r.skill = ""; r.mu.Unlock() }
288+
289+// stubTools declares the real tool DESCRIPTIONS with implementations that run
290+// nothing.
291+//
292+// The descriptions are what is being measured — `read_skill` carries the real
293+// catalogue — but a skill that says "run this echo" must not actually echo,
294+// and the file tools must not touch the disk.
295+//
296+// Every stub must answer something the model can FINISH on. The first version
297+// answered "[eval] command not run", and a quarter of the run turned into
298+// artefacts: given nothing to work with, the model reran the same command
299+// until the runaway guard cut it off or the case timed out — five cases
300+// scored `error`, none of them about routing. A stub that starves the model
301+// measures the stub. So each one now says, in as few words as possible, that
302+// the work is done and the turn should end.
303+func stubTools(t *testing.T, g *genkit.Genkit, list []skills.Skill, rec *recorder) []ai.ToolRef {
304+ t.Helper()
305+ type in struct {
306+ Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description"`
307+ }
308+ readSkill := genkit.DefineTool(g, "read_skill", skills.Catalogue(list),
309+ func(_ *ai.ToolContext, i in) (string, error) {
310+ rec.mu.Lock()
311+ if rec.skill == "" {
312+ rec.skill = i.Name
313+ }
314+ rec.mu.Unlock()
315+ // Answer as if the procedure had been read and carried out: what
316+ // happens after the choice is not what is being measured.
317+ return done("the skill was loaded and its steps are already carried out"), nil
318+ })
319+
320+ type cmd struct {
321+ Command string `json:"command" jsonschema_description:"The shell command to run"`
322+ }
323+ bash := genkit.DefineTool(g, "bash", "Run a shell command and return its output.",
324+ func(_ *ai.ToolContext, c cmd) (string, error) {
325+ return done("the command ran and did what was asked"), nil
326+ })
327+
328+ type path struct {
329+ Path string `json:"path" jsonschema_description:"Path of the file"`
330+ }
331+ readFile := genkit.DefineTool(g, "read_file", "Read a file from the project.",
332+ func(_ *ai.ToolContext, p path) (string, error) {
333+ return done("the file was read and holds nothing surprising"), nil
334+ })
335+
336+ return []ai.ToolRef{readSkill, bash, readFile}
337+}
338+
339+// done is what every stub answers: this worked, there is nothing more to do,
340+// end the turn. The wording matters more than it looks — the eval scores the
341+// FIRST skill asked for, so anything that keeps the model calling tools costs
342+// time and risks a runaway, and measures nothing extra.
343+func done(what string) string {
344+ return "[eval] " + what + ". Nothing else is needed: answer the user now, in one short sentence, and call no further tool."
345+}
new file mode 100644
@@ -0,0 +1,345 @@
1+package eval
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "mm/internal/config"
16+ "mm/internal/engine"
17+ "mm/internal/skills"
18+ "mm/internal/spinner"
19+ "mm/internal/ui"
20+
21+ "github.com/firebase/genkit/go/ai"
22+ "github.com/firebase/genkit/go/genkit"
23+)
24+
25+// none is the expected skill of a request that no skill covers. Abstaining is
26+// a right answer, and a catalogue that fires on everything is as broken as
27+// one that never fires — hence these cases carry the same weight as the rest.
28+const none = ""
29+
30+// A case is a request a user could plausibly type, and the skill it should
31+// reach. The prompts are deliberately not phrased in the words of the
32+// catalogue: a model matching "use the greetings skill" measures nothing.
33+type routingCase struct {
34+ prompt string
35+ want string
36+ why string // what this case is here to catch, when it is not obvious
37+}
38+
39+var cases = []routingCase{
40+ // --- each shipped skill, in the words a user would use ------------------
41+ {"say hi to Sam for me", "greetings", ""},
42+ {"envoie un petit bonjour à Sam", "greetings", "French, and the skill's name is English"},
43+ {"give Spock the proper farewell", "vulcan-salute", "no shared word with the name or the description"},
44+ {"fais le salut vulcain à Sam", "vulcan-salute", ""},
45+ {"what time is it?", "what-time-is-it", ""},
46+ {"quelle heure est-il ?", "what-time-is-it", "French"},
47+ {"what is today's date?", "what-time-is-it", ""},
48+ {"where is the Greet function defined?", "lsp-navigation", ""},
49+ {"qui appelle cette fonction ?", "lsp-navigation", "French, and the skill is about symbols"},
50+ {"rename the Greet symbol to Hello everywhere", "lsp-navigation", ""},
51+ {"start the dev server and check it answers", "bg-jobs", ""},
52+ {"lance le serveur en tâche de fond", "bg-jobs", "French"},
53+ {"the server I started earlier is still running, stop it", "bg-jobs", ""},
54+
55+ // --- bg-jobs: why does the FRENCH case hit and the English ones miss? ---
56+ // Measured on mellum2-12b and qwen3.5-4b: "lance le serveur en tâche de
57+ // fond" hits, "start the dev server and check it answers" misses. bg-jobs
58+ // has NO French keyword at all, so "French coverage" cannot be the
59+ // reason. Three candidate explanations, and these three cases separate
60+ // them:
61+ // (a) the French prompt NAMES the concept — "tâche de fond" is literally
62+ // "background", the first word of the description and of the
63+ // keywords — while the English ones describe a situation;
64+ // (b) `stop`, `kill` and `running` are past the 8-keyword cap and never
65+ // reach the model, so a request to stop a job has only `server`;
66+ // (c) the language really does matter.
67+ // If (a) holds, the first hits, the second misses, and the language is a
68+ // red herring. If (b) holds, the third hits.
69+ {"start the dev server in the background", "bg-jobs", "(a) English, but NAMES the concept"},
70+ {"démarre le serveur de dev et vérifie qu'il répond", "bg-jobs", "(a) French, but only a situation"},
71+ {"kill the background job I started", "bg-jobs", "(b) stop/kill/running are past the cap"},
72+
73+ // --- the overlap found on 2026-09-21 ------------------------------------
74+ // "Salue Bob de ma part" reached a skill through the keywords — and picked
75+ // vulcan-salute, because `greetings` claims "saluer" and `vulcan-salute`
76+ // claims "salut". Both are defensible readings of the French; the point of
77+ // keeping the case is to see whether a change makes the collision worse.
78+ {"Salue Bob de ma part", "greetings", "two skills claim salut/saluer — see ticket 15"},
79+
80+ // --- nothing applies: abstaining is the right answer --------------------
81+ {"what is 17 times 23?", none, ""},
82+ {"explain the difference between a slice and an array in Go", none, "a question, not a task"},
83+ {"read main.go and tell me what it does", none, "a file tool, not a skill"},
84+ {"list the files in this directory", none, "bash, not a skill"},
85+ {"write a haiku about pointers", none, ""},
86+ {"combien font 17 fois 23 ?", none, "French, still no skill"},
87+}
88+
89+// outcome is how one case came out. "wrong" is kept apart from "miss" on
90+// purpose: a model that reaches for a procedure and picks the neighbouring
91+// one has a catalogue problem, and a model that reaches for nothing has a
92+// prompt problem. Merging them hides which.
93+type outcome string
94+
95+const (
96+ hit outcome = "hit" // the expected skill, or correctly nothing
97+ wrong outcome = "wrong" // a skill was loaded, but not that one
98+ miss outcome = "miss" // a skill was expected and none was loaded
99+ extra outcome = "extra" // no skill applied and one was loaded anyway
100+ broke outcome = "error" // the turn failed: see the note in the test body
101+)
102+
103+func TestSkillRouting(t *testing.T) {
104+ if os.Getenv("MM_EVAL") == "" {
105+ t.Skip("set MM_EVAL=1 (and AGENT_CONFIG) to run the routing eval against a live model")
106+ }
107+
108+ // The eval writes a table, not a transcript: silence the agent's own
109+ // output and its spinner, which would fight the test log for the line.
110+ // ui.Out covers the banner and the warnings; the ANSWER does not go
111+ // through it — engine.emit writes the streamed text with fmt.Print, i.e.
112+ // straight to os.Stdout — so stdout itself is redirected for the run.
113+ // t.Log is unaffected: testing keeps its own writer.
114+ ui.Out = io.Discard
115+ spinner.Disable()
116+ if devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
117+ saved := os.Stdout
118+ os.Stdout = devnull
119+ t.Cleanup(func() { os.Stdout = saved; devnull.Close() })
120+ }
121+
122+ path, err := config.Load(os.Getenv("AGENT_CONFIG"))
123+ if err != nil {
124+ t.Fatalf("config: %v", err)
125+ }
126+ dir := config.Cfg.SkillsDir
127+ if !filepath.IsAbs(dir) {
128+ root, _ := filepath.Abs(filepath.Join("..", ".."))
129+ dir = filepath.Join(root, dir)
130+ }
131+ list := skills.List(dir)
132+ if len(list) == 0 {
133+ t.Fatalf("no skills in %s — the eval would score an empty catalogue", dir)
134+ }
135+
136+ e, err := engine.New(context.Background())
137+ if err != nil {
138+ t.Fatalf("engine: %v", err)
139+ }
140+ if info := e.Probe(context.Background()); !info.Reachable {
141+ t.Fatalf("%s does not answer — start the model server first", e.Backend.BaseURL)
142+ }
143+
144+ asked := &recorder{}
145+ tools := stubTools(t, e.G, list, asked)
146+
147+ t.Logf("model %s @ %s · config %s · %d skills · catalogue %d bytes",
148+ config.Cfg.Model, e.Backend.Provider, path, len(list), len(skills.Catalogue(list)))
149+
150+ // Rows go to stderr as they happen, not through t.Log: testing buffers
151+ // its log until the test ends, and an eval that prints nothing for four
152+ // minutes looks hung. The summary goes to both, so it survives in the
153+ // test output too.
154+ scores := map[outcome]int{}
155+ for i, c := range cases {
156+ asked.reset()
157+ got, dur, err := ask(e, tools, c.prompt)
158+ res := score(c.want, got, err)
159+ scores[res]++
160+ fmt.Fprintf(os.Stderr, "[%2d/%2d] %s\n", i+1, len(cases), row(res, c, got, dur, err))
161+ }
162+
163+ out := summary(scores, len(cases))
164+ fmt.Fprintln(os.Stderr, out)
165+ t.Log(out)
166+
167+ // Deliberately NOT a threshold. A number that fails the build would turn
168+ // a measurement into a target, and the first response to a red build is
169+ // to edit the cases. Read the table; compare it with the last run.
170+ if scores[broke] > 0 {
171+ t.Errorf("%d case(s) failed to run, so the numbers above are incomplete.\n"+
172+ "An `error` is never a routing score. Two families, and the message says which:\n"+
173+ " - the instrument: a stub that gave the model nothing to finish on, a caseTimeout "+
174+ "too short for this model, a server that went away;\n"+
175+ " - the model: it could not compose a valid tool call at all — `unexpected end of "+
176+ "JSON input`, or the runaway guard cutting off repeated arguments. That is a real "+
177+ "finding about the model, not about the catalogue, and no prompt change will fix it.",
178+ scores[broke])
179+ }
180+}
181+
182+// caseTimeout is generous on purpose. An `error` does not just lose one case,
183+// it poisons the whole table — so the cap is there to stop a hung server, not
184+// to score speed. Measured on qwen3.5-4b: most cases land in 5–20s, one took
185+// 1m20s, and a 2-minute cap turned three perfectly good cases into errors.
186+//
187+// A slow model therefore makes a slow eval. That is the right trade: the
188+// numbers are the point, the wall clock is not.
189+const caseTimeout = 5 * time.Minute
190+
191+// ask runs ONE model turn and reports the skill it asked for, if any.
192+func ask(e *engine.Engine, tools []ai.ToolRef, prompt string) (string, time.Duration, error) {
193+ ctx, cancel := context.WithTimeout(context.Background(), caseTimeout)
194+ defer cancel()
195+
196+ start := time.Now()
197+ msgs := []*ai.Message{
198+ ai.NewSystemTextMessage(config.Cfg.System),
199+ ai.NewUserTextMessage(prompt),
200+ }
201+ _, history, err := e.Generate(ctx, msgs, tools)
202+ took := time.Since(start).Round(time.Millisecond)
203+ if err != nil {
204+ return "", took, err
205+ }
206+ return firstSkill(history), took, nil
207+}
208+
209+// firstSkill is the name of the first read_skill call of a turn — what the
210+// model reached for, before any recovery.
211+func firstSkill(history []*ai.Message) string {
212+ for _, m := range history {
213+ for _, p := range m.Content {
214+ if !p.IsToolRequest() || p.ToolRequest == nil || p.ToolRequest.Name != "read_skill" {
215+ continue
216+ }
217+ if in, ok := p.ToolRequest.Input.(map[string]any); ok {
218+ if n, ok := in["name"].(string); ok {
219+ return strings.TrimSpace(n)
220+ }
221+ }
222+ }
223+ }
224+ return ""
225+}
226+
227+func score(want, got string, err error) outcome {
228+ switch {
229+ case err != nil:
230+ return broke
231+ case want == none && got == "":
232+ return hit
233+ case want == none:
234+ return extra
235+ case got == "":
236+ return miss
237+ case got == want:
238+ return hit
239+ }
240+ return wrong
241+}
242+
243+func row(res outcome, c routingCase, got string, dur time.Duration, err error) string {
244+ mark := map[outcome]string{hit: "✓", wrong: "≠", miss: "·", extra: "!", broke: "✗"}[res]
245+ want, gotShown := c.want, got
246+ if want == none {
247+ want = "(none)"
248+ }
249+ if gotShown == "" {
250+ gotShown = "(none)"
251+ }
252+ line := fmt.Sprintf("%s %-6s want=%-16s got=%-16s %6s %q", mark, res, want, gotShown, dur, c.prompt)
253+ if c.why != "" {
254+ line += " ← " + c.why
255+ }
256+ if err != nil {
257+ line += " [" + err.Error() + "]"
258+ }
259+ return line
260+}
261+
262+func summary(scores map[outcome]int, total int) string {
263+ var b strings.Builder
264+ fmt.Fprintf(&b, "\n%d cases · ", total)
265+ keys := []outcome{hit, wrong, miss, extra, broke}
266+ var parts []string
267+ for _, k := range keys {
268+ if scores[k] > 0 {
269+ parts = append(parts, fmt.Sprintf("%s %d", k, scores[k]))
270+ }
271+ }
272+ sort.Strings(nil) // keep the order above, not alphabetical
273+ b.WriteString(strings.Join(parts, " · "))
274+ fmt.Fprintf(&b, " → %d%% right", 100*scores[hit]/max(total, 1))
275+ return b.String()
276+}
277+
278+// --- the stub tools ---------------------------------------------------------
279+
280+// recorder remembers what the model asked for. Written from Genkit's tool
281+// goroutines, read after the turn.
282+type recorder struct {
283+ mu sync.Mutex
284+ skill string
285+}
286+
287+func (r *recorder) reset() { r.mu.Lock(); r.skill = ""; r.mu.Unlock() }
288+
289+// stubTools declares the real tool DESCRIPTIONS with implementations that run
290+// nothing.
291+//
292+// The descriptions are what is being measured — `read_skill` carries the real
293+// catalogue — but a skill that says "run this echo" must not actually echo,
294+// and the file tools must not touch the disk.
295+//
296+// Every stub must answer something the model can FINISH on. The first version
297+// answered "[eval] command not run", and a quarter of the run turned into
298+// artefacts: given nothing to work with, the model reran the same command
299+// until the runaway guard cut it off or the case timed out — five cases
300+// scored `error`, none of them about routing. A stub that starves the model
301+// measures the stub. So each one now says, in as few words as possible, that
302+// the work is done and the turn should end.
303+func stubTools(t *testing.T, g *genkit.Genkit, list []skills.Skill, rec *recorder) []ai.ToolRef {
304+ t.Helper()
305+ type in struct {
306+ Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description"`
307+ }
308+ readSkill := genkit.DefineTool(g, "read_skill", skills.Catalogue(list),
309+ func(_ *ai.ToolContext, i in) (string, error) {
310+ rec.mu.Lock()
311+ if rec.skill == "" {
312+ rec.skill = i.Name
313+ }
314+ rec.mu.Unlock()
315+ // Answer as if the procedure had been read and carried out: what
316+ // happens after the choice is not what is being measured.
317+ return done("the skill was loaded and its steps are already carried out"), nil
318+ })
319+
320+ type cmd struct {
321+ Command string `json:"command" jsonschema_description:"The shell command to run"`
322+ }
323+ bash := genkit.DefineTool(g, "bash", "Run a shell command and return its output.",
324+ func(_ *ai.ToolContext, c cmd) (string, error) {
325+ return done("the command ran and did what was asked"), nil
326+ })
327+
328+ type path struct {
329+ Path string `json:"path" jsonschema_description:"Path of the file"`
330+ }
331+ readFile := genkit.DefineTool(g, "read_file", "Read a file from the project.",
332+ func(_ *ai.ToolContext, p path) (string, error) {
333+ return done("the file was read and holds nothing surprising"), nil
334+ })
335+
336+ return []ai.ToolRef{readSkill, bash, readFile}
337+}
338+
339+// done is what every stub answers: this worked, there is nothing more to do,
340+// end the turn. The wording matters more than it looks — the eval scores the
341+// FIRST skill asked for, so anything that keeps the model calling tools costs
342+// time and risks a runaway, and measures nothing extra.
343+func done(what string) string {
344+ return "[eval] " + what + ". Nothing else is needed: answer the user now, in one short sentence, and call no further tool."
345+}
modified internal/lsp/lsp_test.go +33 -5
@@ -82,6 +82,35 @@ func (f *fakeServer) DidChange(_ context.Context, p *protocol.DidChangeTextDocum
8282 return nil
8383 }
8484
85+// awaitDoc waits until the server has been told about a document at the given
86+// version, and returns the text it holds for it.
87+//
88+// It exists because `textDocument/didChange` is a NOTIFICATION: the client
89+// sends it and moves on without a reply, so a test that reads this state the
90+// instant a client call returns can read it before the notification has been
91+// handled. Nothing is wrong in production — JSON-RPC on one stream delivers
92+// the notification before the next request, which is what every later call
93+// relies on — but a test looking at the server's state OUT OF BAND has to
94+// wait for it. Measured before this helper: the rename assertion below failed
95+// about one run in ten.
96+func (f *fakeServer) awaitDoc(t *testing.T, u uri.URI, version int32) string {
97+ t.Helper()
98+ deadline := time.Now().Add(2 * time.Second)
99+ for {
100+ f.mu.Lock()
101+ seen, text := f.opened[u], f.texts[u]
102+ f.mu.Unlock()
103+ if seen == version {
104+ return text
105+ }
106+ if time.Now().After(deadline) {
107+ t.Fatalf("the server was never told about %s at version %d (last seen %d, text %q)",
108+ u, version, seen, text)
109+ }
110+ time.Sleep(5 * time.Millisecond)
111+ }
112+}
113+
85114 func (f *fakeServer) Definition(_ context.Context, p *protocol.DefinitionParams) (protocol.DefinitionResult, error) {
86115 return protocol.LocationSlice{{URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)}}, nil
87116 }
@@ -197,11 +226,10 @@ func TestOperationsOverAFakeServer(t *testing.T) {
197226 if string(data) != "package main\n\nfunc greet() {}\n\nfunc main() {\n\tgreet()\n}\n" {
198227 t.Errorf("file after rename:\n%s", data)
199228 }
200- fake.mu.Lock()
201- version, text := fake.opened[uri.File(filepath.Join(root, "main.go"))], fake.texts[uri.File(filepath.Join(root, "main.go"))]
202- fake.mu.Unlock()
203- if version != 2 || !strings.Contains(text, "greet") {
204- t.Errorf("server not told about the renamed file: version %d, text %q", version, text)
229+ // Rename writes the file and then re-syncs it; the sync is a notification,
230+ // so wait for it rather than reading the instant Rename returns.
231+ if text := fake.awaitDoc(t, uri.File(filepath.Join(root, "main.go")), 2); !strings.Contains(text, "greet") {
232+ t.Errorf("server told about the renamed file, but with the old text: %q", text)
205233 }
206234
207235 if _, err := c.Rename(ctx, "main.go", 6, 2, "refuse"); err == nil || !strings.Contains(err.Error(), "fake: rename failed: ") {
@@ -82,6 +82,35 @@ func (f *fakeServer) DidChange(_ context.Context, p *protocol.DidChangeTextDocum
82 return nil82 return nil
83 }83 }
84 84
85+// awaitDoc waits until the server has been told about a document at the given
86+// version, and returns the text it holds for it.
87+//
88+// It exists because `textDocument/didChange` is a NOTIFICATION: the client
89+// sends it and moves on without a reply, so a test that reads this state the
90+// instant a client call returns can read it before the notification has been
91+// handled. Nothing is wrong in production — JSON-RPC on one stream delivers
92+// the notification before the next request, which is what every later call
93+// relies on — but a test looking at the server's state OUT OF BAND has to
94+// wait for it. Measured before this helper: the rename assertion below failed
95+// about one run in ten.
96+func (f *fakeServer) awaitDoc(t *testing.T, u uri.URI, version int32) string {
97+ t.Helper()
98+ deadline := time.Now().Add(2 * time.Second)
99+ for {
100+ f.mu.Lock()
101+ seen, text := f.opened[u], f.texts[u]
102+ f.mu.Unlock()
103+ if seen == version {
104+ return text
105+ }
106+ if time.Now().After(deadline) {
107+ t.Fatalf("the server was never told about %s at version %d (last seen %d, text %q)",
108+ u, version, seen, text)
109+ }
110+ time.Sleep(5 * time.Millisecond)
111+ }
112+}
113+
85 func (f *fakeServer) Definition(_ context.Context, p *protocol.DefinitionParams) (protocol.DefinitionResult, error) {114 func (f *fakeServer) Definition(_ context.Context, p *protocol.DefinitionParams) (protocol.DefinitionResult, error) {
86 return protocol.LocationSlice{{URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)}}, nil115 return protocol.LocationSlice{{URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)}}, nil
87 }116 }
@@ -197,11 +226,10 @@ func TestOperationsOverAFakeServer(t *testing.T) {
197 if string(data) != "package main\n\nfunc greet() {}\n\nfunc main() {\n\tgreet()\n}\n" {226 if string(data) != "package main\n\nfunc greet() {}\n\nfunc main() {\n\tgreet()\n}\n" {
198 t.Errorf("file after rename:\n%s", data)227 t.Errorf("file after rename:\n%s", data)
199 }228 }
200- fake.mu.Lock()229+ // Rename writes the file and then re-syncs it; the sync is a notification,
201- version, text := fake.opened[uri.File(filepath.Join(root, "main.go"))], fake.texts[uri.File(filepath.Join(root, "main.go"))]230+ // so wait for it rather than reading the instant Rename returns.
202- fake.mu.Unlock()231+ if text := fake.awaitDoc(t, uri.File(filepath.Join(root, "main.go")), 2); !strings.Contains(text, "greet") {
203- if version != 2 || !strings.Contains(text, "greet") {232+ t.Errorf("server told about the renamed file, but with the old text: %q", text)
204- t.Errorf("server not told about the renamed file: version %d, text %q", version, text)
205 }233 }
206 234
207 if _, err := c.Rename(ctx, "main.go", 6, 2, "refuse"); err == nil || !strings.Contains(err.Error(), "fake: rename failed: ") {235 if _, err := c.Rename(ctx, "main.go", 6, 2, "refuse"); err == nil || !strings.Contains(err.Error(), "fake: rename failed: ") {
added internal/skills/report.go +57 -0
new file mode 100644
@@ -0,0 +1,57 @@
1+// What is wrong with a catalogue, for the banner to say at start-up.
2+//
3+// None of these refuses to run: a skills directory with a problem still
4+// works, it just works less well than its author thinks. The whole point is
5+// that each of these failures is SILENT — the skill is there, the count is
6+// not zero, and the one warning that existed before only fired when the
7+// directory was empty.
8+
9+package skills
10+
11+import (
12+ "fmt"
13+ "strings"
14+)
15+
16+// Duplicates lists the names two skills share. The catalogue would show the
17+// name twice and Find would always return the first, so the second is
18+// unreachable — worth a warning at start-up, not a refusal to run.
19+func Duplicates(list []Skill) []string {
20+ var out []string
21+ for i := 1; i < len(list); i++ { // List sorts by name, so duplicates are adjacent
22+ if list[i].Name == list[i-1].Name && (len(out) == 0 || out[len(out)-1] != list[i].Name) {
23+ out = append(out, list[i].Name)
24+ }
25+ }
26+ return out
27+}
28+
29+// Truncated lists the skills with more keywords than the catalogue shows.
30+//
31+// The cap made the ORDER of a keyword list significant, which it was not
32+// before, and nothing tells an author that their last eight words are being
33+// dropped. Found the hard way: `lsp-navigation` has sixteen keywords, the
34+// eight that survived were all English jargon, and the one routing failure
35+// the eval found was a French request.
36+func Truncated(list []Skill) []string {
37+ var out []string
38+ for _, s := range list {
39+ if len(s.Keywords) > maxKeywords {
40+ out = append(out, fmt.Sprintf("%s (%d of %d)", s.Name, maxKeywords, len(s.Keywords)))
41+ }
42+ }
43+ return out
44+}
45+
46+// Undescribed lists the skills whose front matter gave no description. They
47+// still appear in the catalogue, as a bare name — the worst entry there is,
48+// since the model has nothing to match a request against.
49+func Undescribed(list []Skill) []string {
50+ var out []string
51+ for _, s := range list {
52+ if strings.TrimSpace(s.Description) == "" {
53+ out = append(out, s.Name)
54+ }
55+ }
56+ return out
57+}
new file mode 100644
@@ -0,0 +1,57 @@
1+// What is wrong with a catalogue, for the banner to say at start-up.
2+//
3+// None of these refuses to run: a skills directory with a problem still
4+// works, it just works less well than its author thinks. The whole point is
5+// that each of these failures is SILENT — the skill is there, the count is
6+// not zero, and the one warning that existed before only fired when the
7+// directory was empty.
8+
9+package skills
10+
11+import (
12+ "fmt"
13+ "strings"
14+)
15+
16+// Duplicates lists the names two skills share. The catalogue would show the
17+// name twice and Find would always return the first, so the second is
18+// unreachable — worth a warning at start-up, not a refusal to run.
19+func Duplicates(list []Skill) []string {
20+ var out []string
21+ for i := 1; i < len(list); i++ { // List sorts by name, so duplicates are adjacent
22+ if list[i].Name == list[i-1].Name && (len(out) == 0 || out[len(out)-1] != list[i].Name) {
23+ out = append(out, list[i].Name)
24+ }
25+ }
26+ return out
27+}
28+
29+// Truncated lists the skills with more keywords than the catalogue shows.
30+//
31+// The cap made the ORDER of a keyword list significant, which it was not
32+// before, and nothing tells an author that their last eight words are being
33+// dropped. Found the hard way: `lsp-navigation` has sixteen keywords, the
34+// eight that survived were all English jargon, and the one routing failure
35+// the eval found was a French request.
36+func Truncated(list []Skill) []string {
37+ var out []string
38+ for _, s := range list {
39+ if len(s.Keywords) > maxKeywords {
40+ out = append(out, fmt.Sprintf("%s (%d of %d)", s.Name, maxKeywords, len(s.Keywords)))
41+ }
42+ }
43+ return out
44+}
45+
46+// Undescribed lists the skills whose front matter gave no description. They
47+// still appear in the catalogue, as a bare name — the worst entry there is,
48+// since the model has nothing to match a request against.
49+func Undescribed(list []Skill) []string {
50+ var out []string
51+ for _, s := range list {
52+ if strings.TrimSpace(s.Description) == "" {
53+ out = append(out, s.Name)
54+ }
55+ }
56+ return out
57+}
added internal/skills/shipped_test.go +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+package skills_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+ "mm/internal/skills"
12+)
13+
14+// repoRoot is where the shipped YAML files and the skills directory live.
15+func repoRoot(t *testing.T) string {
16+ t.Helper()
17+ root, err := filepath.Abs(filepath.Join("..", ".."))
18+ if err != nil {
19+ t.Fatalf("repo root: %v", err)
20+ }
21+ if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
22+ t.Fatalf("%s is not the repo root: %v", root, err)
23+ }
24+ return root
25+}
26+
27+// namedSkill matches the way every prompt refers to a skill: the name quoted
28+// or backticked, followed by the word "skill". The prompts in the YAML files
29+// use backticks; the built-in default prompt is a Go string, so it uses
30+// double quotes.
31+var namedSkill = regexp.MustCompile("[`\"']([a-z0-9][a-z0-9._-]*)[`\"']\\s+skill\\b")
32+
33+// TestEverySkillNamedInAShippedPromptExists is the durable half of the
34+// bg-jobs fix.
35+//
36+// All three shipped YAML files AND the built-in default prompt told the model
37+// to "read the `bg-jobs` skill", and `skills/bg-jobs` did not exist — so every
38+// background-job request drove the model into a read_skill call that could
39+// only fail. Nothing caught it: not a test, not the banner, not the docs.
40+//
41+// Shipping the skill fixes today. This test fixes the class: name a skill in
42+// a prompt without shipping it, and the build says so.
43+func TestEverySkillNamedInAShippedPromptExists(t *testing.T) {
44+ root := repoRoot(t)
45+ list := skills.List(filepath.Join(root, "skills"))
46+ if len(list) == 0 {
47+ t.Fatal("no skills found in skills/ — the test cannot tell a missing skill from a missing directory")
48+ }
49+
50+ prompts := map[string]string{"built-in default prompt": config.Cfg.System} // package default: Load is never called here
51+ yamls, err := filepath.Glob(filepath.Join(root, "agent*.yaml"))
52+ if err != nil {
53+ t.Fatalf("glob: %v", err)
54+ }
55+ more, _ := filepath.Glob(filepath.Join(root, "tests", "agent*.yaml"))
56+ for _, p := range append(yamls, more...) {
57+ b, err := os.ReadFile(p)
58+ if err != nil {
59+ t.Fatalf("read %s: %v", p, err)
60+ }
61+ prompts[filepath.Base(p)] = string(b)
62+ }
63+ if len(prompts) < 2 {
64+ t.Fatal("no shipped YAML found: the test would pass by looking at nothing")
65+ }
66+
67+ for where, text := range prompts {
68+ for _, m := range namedSkill.FindAllStringSubmatch(text, -1) {
69+ name := m[1]
70+ if _, ok := skills.Find(list, name); !ok {
71+ t.Errorf("%s tells the model to read the %q skill, which does not exist — available: %s",
72+ where, name, strings.Join(skills.Names(list), ", "))
73+ }
74+ }
75+ }
76+}
77+
78+// Every shipped skill must be reachable by the name the catalogue advertises,
79+// whatever its file is called. This is the regression test for the defect the
80+// catalogue/path split caused.
81+func TestEveryShippedSkillLoadsByItsCatalogueName(t *testing.T) {
82+ list := skills.List(filepath.Join(repoRoot(t), "skills"))
83+ for _, s := range list {
84+ content, err := skills.Load(list, s.Name)
85+ if err != nil {
86+ t.Errorf("%q is in the catalogue but cannot be loaded: %v", s.Name, err)
87+ continue
88+ }
89+ if strings.TrimSpace(content) == "" {
90+ t.Errorf("%q loads empty", s.Name)
91+ }
92+ }
93+}
94+
95+// A catalogue entry with no description is a name the model cannot match a
96+// request against. The banner warns about it; the shipped ones must not need
97+// the warning.
98+func TestShippedSkillsAreDescribedAndKeyworded(t *testing.T) {
99+ list := skills.List(filepath.Join(repoRoot(t), "skills"))
100+ if bare := skills.Undescribed(list); len(bare) > 0 {
101+ t.Errorf("shipped skills with no description: %s", strings.Join(bare, ", "))
102+ }
103+ if dup := skills.Duplicates(list); len(dup) > 0 {
104+ t.Errorf("shipped skills sharing a name: %s", strings.Join(dup, ", "))
105+ }
106+ for _, s := range list {
107+ if len(s.Keywords) == 0 {
108+ t.Errorf("%q has no keywords: it can only be found by its name", s.Name)
109+ }
110+ }
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+package skills_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+ "mm/internal/skills"
12+)
13+
14+// repoRoot is where the shipped YAML files and the skills directory live.
15+func repoRoot(t *testing.T) string {
16+ t.Helper()
17+ root, err := filepath.Abs(filepath.Join("..", ".."))
18+ if err != nil {
19+ t.Fatalf("repo root: %v", err)
20+ }
21+ if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
22+ t.Fatalf("%s is not the repo root: %v", root, err)
23+ }
24+ return root
25+}
26+
27+// namedSkill matches the way every prompt refers to a skill: the name quoted
28+// or backticked, followed by the word "skill". The prompts in the YAML files
29+// use backticks; the built-in default prompt is a Go string, so it uses
30+// double quotes.
31+var namedSkill = regexp.MustCompile("[`\"']([a-z0-9][a-z0-9._-]*)[`\"']\\s+skill\\b")
32+
33+// TestEverySkillNamedInAShippedPromptExists is the durable half of the
34+// bg-jobs fix.
35+//
36+// All three shipped YAML files AND the built-in default prompt told the model
37+// to "read the `bg-jobs` skill", and `skills/bg-jobs` did not exist — so every
38+// background-job request drove the model into a read_skill call that could
39+// only fail. Nothing caught it: not a test, not the banner, not the docs.
40+//
41+// Shipping the skill fixes today. This test fixes the class: name a skill in
42+// a prompt without shipping it, and the build says so.
43+func TestEverySkillNamedInAShippedPromptExists(t *testing.T) {
44+ root := repoRoot(t)
45+ list := skills.List(filepath.Join(root, "skills"))
46+ if len(list) == 0 {
47+ t.Fatal("no skills found in skills/ — the test cannot tell a missing skill from a missing directory")
48+ }
49+
50+ prompts := map[string]string{"built-in default prompt": config.Cfg.System} // package default: Load is never called here
51+ yamls, err := filepath.Glob(filepath.Join(root, "agent*.yaml"))
52+ if err != nil {
53+ t.Fatalf("glob: %v", err)
54+ }
55+ more, _ := filepath.Glob(filepath.Join(root, "tests", "agent*.yaml"))
56+ for _, p := range append(yamls, more...) {
57+ b, err := os.ReadFile(p)
58+ if err != nil {
59+ t.Fatalf("read %s: %v", p, err)
60+ }
61+ prompts[filepath.Base(p)] = string(b)
62+ }
63+ if len(prompts) < 2 {
64+ t.Fatal("no shipped YAML found: the test would pass by looking at nothing")
65+ }
66+
67+ for where, text := range prompts {
68+ for _, m := range namedSkill.FindAllStringSubmatch(text, -1) {
69+ name := m[1]
70+ if _, ok := skills.Find(list, name); !ok {
71+ t.Errorf("%s tells the model to read the %q skill, which does not exist — available: %s",
72+ where, name, strings.Join(skills.Names(list), ", "))
73+ }
74+ }
75+ }
76+}
77+
78+// Every shipped skill must be reachable by the name the catalogue advertises,
79+// whatever its file is called. This is the regression test for the defect the
80+// catalogue/path split caused.
81+func TestEveryShippedSkillLoadsByItsCatalogueName(t *testing.T) {
82+ list := skills.List(filepath.Join(repoRoot(t), "skills"))
83+ for _, s := range list {
84+ content, err := skills.Load(list, s.Name)
85+ if err != nil {
86+ t.Errorf("%q is in the catalogue but cannot be loaded: %v", s.Name, err)
87+ continue
88+ }
89+ if strings.TrimSpace(content) == "" {
90+ t.Errorf("%q loads empty", s.Name)
91+ }
92+ }
93+}
94+
95+// A catalogue entry with no description is a name the model cannot match a
96+// request against. The banner warns about it; the shipped ones must not need
97+// the warning.
98+func TestShippedSkillsAreDescribedAndKeyworded(t *testing.T) {
99+ list := skills.List(filepath.Join(repoRoot(t), "skills"))
100+ if bare := skills.Undescribed(list); len(bare) > 0 {
101+ t.Errorf("shipped skills with no description: %s", strings.Join(bare, ", "))
102+ }
103+ if dup := skills.Duplicates(list); len(dup) > 0 {
104+ t.Errorf("shipped skills sharing a name: %s", strings.Join(dup, ", "))
105+ }
106+ for _, s := range list {
107+ if len(s.Keywords) == 0 {
108+ t.Errorf("%q has no keywords: it can only be found by its name", s.Name)
109+ }
110+ }
111+}
modified internal/skills/skills.go +76 -12
@@ -16,6 +16,7 @@
1616 package skills
1717
1818 import (
19+ "fmt"
1920 "os"
2021 "path/filepath"
2122 "sort"
@@ -26,6 +27,7 @@ import (
2627 type Skill struct {
2728 Name string // from the front matter, falling back to the file name
2829 Description string // the one-liner shown in the catalogue
30+ Keywords []string
2931 Path string
3032 }
3133
@@ -43,8 +45,9 @@ func defaultName(path string) string {
4345 return strings.TrimSuffix(filepath.Base(path), ".md")
4446 }
4547
46-// parseHeader reads the `name:` and `description:` lines of the front matter.
47-// It stops at the closing `---`: a `description:` in the body is not metadata.
48+// parseHeader reads the `name:`, `description:` and `keywords:` lines of the
49+// front matter. It stops at the closing `---`: a `description:` in the body is
50+// not metadata.
4851 func parseHeader(content, path string) Skill {
4952 s := Skill{Name: defaultName(path), Path: path}
5053 lines := strings.Split(content, "\n")
@@ -67,11 +70,25 @@ func parseHeader(content, path string) Skill {
6770 }
6871 case "description":
6972 s.Description = value
73+ case "keywords":
74+ s.Keywords = splitKeywords(value)
7075 }
7176 }
7277 return s
7378 }
7479
80+// splitKeywords turns "greet, hello, bonjour" into its words, dropping the
81+// empties a trailing comma leaves behind.
82+func splitKeywords(value string) []string {
83+ var out []string
84+ for _, w := range strings.Split(value, ",") {
85+ if w = strings.TrimSpace(w); w != "" {
86+ out = append(out, w)
87+ }
88+ }
89+ return out
90+}
91+
7592 // List returns the skills of dir, sorted by name. Two layouts are accepted,
7693 // and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill,
7794 // `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means
@@ -96,7 +113,11 @@ func List(dir string) []Skill {
96113 }
97114 out = append(out, parseHeader(string(content), p))
98115 }
99- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
116+ // STABLE, and the flat files were appended first: when two skills share a
117+ // name, the flat `<name>.md` stays ahead of `<name>/SKILL.md` and is the
118+ // one Find returns. That is the recorded rule — "the flat file wins" —
119+ // and an unstable sort would decide it by coin toss.
120+ sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
100121 return out
101122 }
102123
@@ -106,17 +127,45 @@ func isReadme(path string) bool {
106127 return strings.EqualFold(filepath.Base(path), "README.md")
107128 }
108129
109-// Read returns the full markdown of one skill, whichever layout it uses: the
110-// flat `<name>.md` is tried first, then `<name>/SKILL.md`.
111-func Read(dir, name string) (string, error) {
112- // Keep the name a plain file name: no "../" escaping out of the directory.
113- name = filepath.Base(name)
114- content, err := os.ReadFile(filepath.Join(dir, name+".md"))
115- if err != nil {
116- if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil {
117- return string(nested), nil
130+// Find returns the skill the CATALOGUE advertises under this name.
131+//
132+// The lookup goes through the list, not through the filesystem, and that is
133+// the whole point: `Catalogue` names a skill by its front-matter `name`,
134+// while the old path-based read looked for `<dir>/<name>.md`. When the two
135+// disagreed — a skill whose `name:` is not its file name — the tool refused
136+// the very string the catalogue had told the model to use, with a message
137+// that contradicted itself: `No skill named "renamed". Available: renamed`.
138+//
139+// It is also strictly safer: the model's string is now only ever compared
140+// against known names and never reaches the filesystem, so there is no path
141+// to escape from.
142+//
143+// Matching is forgiving in the two ways a model gets it wrong and no human
144+// would: surrounding whitespace or quotes, and the wrong case. It is never
145+// forgiving about which skill: an exact name always wins over a case-
146+// insensitive one.
147+func Find(list []Skill, name string) (Skill, bool) {
148+ name = strings.Trim(strings.TrimSpace(name), `"'`+"`")
149+ for _, s := range list {
150+ if s.Name == name {
151+ return s, true
118152 }
119153 }
154+ for _, s := range list {
155+ if strings.EqualFold(s.Name, name) {
156+ return s, true
157+ }
158+ }
159+ return Skill{}, false
160+}
161+
162+// Load reads the markdown of the skill the catalogue advertises under name.
163+func Load(list []Skill, name string) (string, error) {
164+ s, ok := Find(list, name)
165+ if !ok {
166+ return "", fmt.Errorf("no skill named %q", name)
167+ }
168+ content, err := os.ReadFile(s.Path)
120169 return string(content), err
121170 }
122171
@@ -133,11 +182,26 @@ func Catalogue(list []Skill) string {
133182 if s.Description != "" {
134183 b.WriteString(" — " + s.Description)
135184 }
185+ // The keywords are the lexical bridge between what a user types and
186+ // what a skill is called: "salue Bob" reaches `greetings` through
187+ // "saluer", never through the name. They were written in every
188+ // shipped skill and read by nobody until 2026-09-21.
189+ if kw := s.Keywords; len(kw) > 0 {
190+ if len(kw) > maxKeywords {
191+ kw = kw[:maxKeywords]
192+ }
193+ b.WriteString(" (" + strings.Join(kw, ", ") + ")")
194+ }
136195 b.WriteString("\n")
137196 }
138197 return b.String()
139198 }
140199
200+// maxKeywords caps what one skill contributes to the catalogue. The catalogue
201+// is sent with EVERY request, so it is paid for on every turn: a handful of
202+// words per skill helps a small model match, thirty would crowd out the task.
203+const maxKeywords = 8
204+
141205 // Names lists the skill names, for an error message that helps the model
142206 // recover from a typo.
143207 func Names(list []Skill) []string {
@@ -16,6 +16,7 @@
16 package skills16 package skills
17 17
18 import (18 import (
19+ "fmt"
19 "os"20 "os"
20 "path/filepath"21 "path/filepath"
21 "sort"22 "sort"
@@ -26,6 +27,7 @@ import (
26 type Skill struct {27 type Skill struct {
27 Name string // from the front matter, falling back to the file name28 Name string // from the front matter, falling back to the file name
28 Description string // the one-liner shown in the catalogue29 Description string // the one-liner shown in the catalogue
30+ Keywords []string
29 Path string31 Path string
30 }32 }
31 33
@@ -43,8 +45,9 @@ func defaultName(path string) string {
43 return strings.TrimSuffix(filepath.Base(path), ".md")45 return strings.TrimSuffix(filepath.Base(path), ".md")
44 }46 }
45 47
46-// parseHeader reads the `name:` and `description:` lines of the front matter.48+// parseHeader reads the `name:`, `description:` and `keywords:` lines of the
47-// It stops at the closing `---`: a `description:` in the body is not metadata.49+// front matter. It stops at the closing `---`: a `description:` in the body is
50+// not metadata.
48 func parseHeader(content, path string) Skill {51 func parseHeader(content, path string) Skill {
49 s := Skill{Name: defaultName(path), Path: path}52 s := Skill{Name: defaultName(path), Path: path}
50 lines := strings.Split(content, "\n")53 lines := strings.Split(content, "\n")
@@ -67,11 +70,25 @@ func parseHeader(content, path string) Skill {
67 }70 }
68 case "description":71 case "description":
69 s.Description = value72 s.Description = value
73+ case "keywords":
74+ s.Keywords = splitKeywords(value)
70 }75 }
71 }76 }
72 return s77 return s
73 }78 }
74 79
80+// splitKeywords turns "greet, hello, bonjour" into its words, dropping the
81+// empties a trailing comma leaves behind.
82+func splitKeywords(value string) []string {
83+ var out []string
84+ for _, w := range strings.Split(value, ",") {
85+ if w = strings.TrimSpace(w); w != "" {
86+ out = append(out, w)
87+ }
88+ }
89+ return out
90+}
91+
75 // List returns the skills of dir, sorted by name. Two layouts are accepted,92 // List returns the skills of dir, sorted by name. Two layouts are accepted,
76 // and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill,93 // and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill,
77 // `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means94 // `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means
@@ -96,7 +113,11 @@ func List(dir string) []Skill {
96 }113 }
97 out = append(out, parseHeader(string(content), p))114 out = append(out, parseHeader(string(content), p))
98 }115 }
99- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })116+ // STABLE, and the flat files were appended first: when two skills share a
117+ // name, the flat `<name>.md` stays ahead of `<name>/SKILL.md` and is the
118+ // one Find returns. That is the recorded rule — "the flat file wins" —
119+ // and an unstable sort would decide it by coin toss.
120+ sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
100 return out121 return out
101 }122 }
102 123
@@ -106,17 +127,45 @@ func isReadme(path string) bool {
106 return strings.EqualFold(filepath.Base(path), "README.md")127 return strings.EqualFold(filepath.Base(path), "README.md")
107 }128 }
108 129
109-// Read returns the full markdown of one skill, whichever layout it uses: the130+// Find returns the skill the CATALOGUE advertises under this name.
110-// flat `<name>.md` is tried first, then `<name>/SKILL.md`.131+//
111-func Read(dir, name string) (string, error) {132+// The lookup goes through the list, not through the filesystem, and that is
112- // Keep the name a plain file name: no "../" escaping out of the directory.133+// the whole point: `Catalogue` names a skill by its front-matter `name`,
113- name = filepath.Base(name)134+// while the old path-based read looked for `<dir>/<name>.md`. When the two
114- content, err := os.ReadFile(filepath.Join(dir, name+".md"))135+// disagreed — a skill whose `name:` is not its file name — the tool refused
115- if err != nil {136+// the very string the catalogue had told the model to use, with a message
116- if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil {137+// that contradicted itself: `No skill named "renamed". Available: renamed`.
117- return string(nested), nil138+//
139+// It is also strictly safer: the model's string is now only ever compared
140+// against known names and never reaches the filesystem, so there is no path
141+// to escape from.
142+//
143+// Matching is forgiving in the two ways a model gets it wrong and no human
144+// would: surrounding whitespace or quotes, and the wrong case. It is never
145+// forgiving about which skill: an exact name always wins over a case-
146+// insensitive one.
147+func Find(list []Skill, name string) (Skill, bool) {
148+ name = strings.Trim(strings.TrimSpace(name), `"'`+"`")
149+ for _, s := range list {
150+ if s.Name == name {
151+ return s, true
118 }152 }
119 }153 }
154+ for _, s := range list {
155+ if strings.EqualFold(s.Name, name) {
156+ return s, true
157+ }
158+ }
159+ return Skill{}, false
160+}
161+
162+// Load reads the markdown of the skill the catalogue advertises under name.
163+func Load(list []Skill, name string) (string, error) {
164+ s, ok := Find(list, name)
165+ if !ok {
166+ return "", fmt.Errorf("no skill named %q", name)
167+ }
168+ content, err := os.ReadFile(s.Path)
120 return string(content), err169 return string(content), err
121 }170 }
122 171
@@ -133,11 +182,26 @@ func Catalogue(list []Skill) string {
133 if s.Description != "" {182 if s.Description != "" {
134 b.WriteString(" — " + s.Description)183 b.WriteString(" — " + s.Description)
135 }184 }
185+ // The keywords are the lexical bridge between what a user types and
186+ // what a skill is called: "salue Bob" reaches `greetings` through
187+ // "saluer", never through the name. They were written in every
188+ // shipped skill and read by nobody until 2026-09-21.
189+ if kw := s.Keywords; len(kw) > 0 {
190+ if len(kw) > maxKeywords {
191+ kw = kw[:maxKeywords]
192+ }
193+ b.WriteString(" (" + strings.Join(kw, ", ") + ")")
194+ }
136 b.WriteString("\n")195 b.WriteString("\n")
137 }196 }
138 return b.String()197 return b.String()
139 }198 }
140 199
200+// maxKeywords caps what one skill contributes to the catalogue. The catalogue
201+// is sent with EVERY request, so it is paid for on every turn: a handful of
202+// words per skill helps a small model match, thirty would crowd out the task.
203+const maxKeywords = 8
204+
141 // Names lists the skill names, for an error message that helps the model205 // Names lists the skill names, for an error message that helps the model
142 // recover from a typo.206 // recover from a typo.
143 func Names(list []Skill) []string {207 func Names(list []Skill) []string {
modified internal/skills/skills_test.go +87 -25
@@ -95,41 +95,94 @@ func TestListMissingDirIsEmpty(t *testing.T) {
9595 }
9696 }
9797
98-func TestRead(t *testing.T) {
98+func TestLoad(t *testing.T) {
9999 dir := t.TempDir()
100100 write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n")
101+ list := List(dir)
101102
102- content, err := Read(dir, "go-fmt")
103+ content, err := Load(list, "go-fmt")
103104 if err != nil {
104- t.Fatalf("Read: %v", err)
105+ t.Fatalf("Load: %v", err)
105106 }
106107 if !strings.Contains(content, "# Format") {
107108 t.Errorf("unexpected content: %q", content)
108109 }
109-
110- if _, err := Read(dir, "unknown"); err == nil {
110+ if _, err := Load(list, "unknown"); err == nil {
111111 t.Error("an unknown skill must return an error")
112112 }
113113 }
114114
115-// filepath.Base in Read is a barrier: the name comes from the MODEL, so it must
116-// not be able to escape the skills directory.
117-func TestReadCannotEscapeDir(t *testing.T) {
115+// The catalogue names a skill by its front-matter `name`, and loading must
116+// use the same name. The old path-based read did not: a skill whose `name:`
117+// differed from its file name was advertised and then refused, with a message
118+// that contradicted itself — `No skill named "renamed". Available: renamed`.
119+func TestLoadUsesTheCatalogueNameNotTheFileName(t *testing.T) {
118120 dir := t.TempDir()
119- parent := filepath.Dir(dir)
121+ write(t, dir, "01-rename.md", "---\nname: go-rename\ndescription: rename a symbol\n---\n# Rename\n")
122+ list := List(dir)
123+
124+ if len(list) != 1 || list[0].Name != "go-rename" {
125+ t.Fatalf("catalogue = %+v, want one skill called go-rename", list)
126+ }
127+ if got, err := Load(list, "go-rename"); err != nil || !strings.Contains(got, "# Rename") {
128+ t.Fatalf("the catalogue advertises go-rename and Load gives %q, %v", got, err)
129+ }
130+ if _, err := Load(list, "01-rename"); err == nil {
131+ t.Error("the file name is not the skill's name and must not resolve")
132+ }
133+}
134+
135+// A model gets a name almost right: stray spaces, quotes it should not have
136+// sent, the wrong case. None of those is a different skill.
137+func TestFindForgivesWhatOnlyAModelGetsWrong(t *testing.T) {
138+ dir := t.TempDir()
139+ write(t, dir, "go-rename.md", "---\nname: go-rename\n---\n#\n")
140+ list := List(dir)
141+
142+ for _, name := range []string{"go-rename", " go-rename ", `"go-rename"`, "'go-rename'", "Go-Rename"} {
143+ if _, ok := Find(list, name); !ok {
144+ t.Errorf("Find(%q) missed the skill", name)
145+ }
146+ }
147+ if _, ok := Find(list, "go-renam"); ok {
148+ t.Error("Find matched a name that is not the skill's")
149+ }
150+}
151+
152+// Forgiving about the spelling, never about WHICH skill: an exact name always
153+// beats a case-insensitive one.
154+func TestFindPrefersTheExactName(t *testing.T) {
155+ dir := t.TempDir()
156+ write(t, dir, "a.md", "---\nname: Deploy\n---\n# upper\n")
157+ write(t, dir, "b.md", "---\nname: deploy\n---\n# lower\n")
158+
159+ if got, ok := Find(List(dir), "deploy"); !ok || got.Name != "deploy" {
160+ t.Errorf("Find(deploy) = %+v, %v — want the exact match", got, ok)
161+ }
162+}
163+
164+// The name now comes from the CATALOGUE, so it never reaches the filesystem
165+// at all. What used to need a filepath.Base barrier is now structural: a
166+// traversal string simply is not a name any skill has.
167+func TestALoadedNameNeverReachesTheFilesystem(t *testing.T) {
168+ parent := t.TempDir()
169+ dir := filepath.Join(parent, "skills")
170+ if err := os.MkdirAll(dir, 0o755); err != nil {
171+ t.Fatal(err)
172+ }
173+ write(t, dir, "real.md", "---\nname: real\n---\n# real\n")
120174 if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil {
121175 t.Fatal(err)
122176 }
177+ list := List(dir)
123178
124- for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} {
125- if content, err := Read(dir, name); err == nil {
126- t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content)
179+ for _, name := range []string{"../secret", "../../secret", "/etc/passwd", "real/../../secret"} {
180+ if content, err := Load(list, name); err == nil {
181+ t.Errorf("Load(%q) succeeded and returned %q", name, content)
127182 }
128183 }
129184 }
130185
131-// The catalogue IS the tool description: every skill must appear in it with its
132-// name, otherwise the model cannot ask for it.
133186 func TestCatalogue(t *testing.T) {
134187 list := []Skill{
135188 {Name: "go-fmt", Description: "format code"},
@@ -219,25 +272,34 @@ func TestListAcceptsFlatAndNestedLayouts(t *testing.T) {
219272 // both exist (it is the more explicit of the two), and still cannot be walked
220273 // out of the directory with a nested name: "../escape" is reduced to "escape",
221274 // which does not exist inside dir.
222-func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) {
275+func TestLoadAcceptsFlatAndNestedLayouts(t *testing.T) {
223276 parent := t.TempDir()
224277 dir := filepath.Join(parent, "skills")
225278 writeNested(t, parent, "escape", "# outside")
226279 writeNested(t, dir, "nested-only", "# nested")
227280 write(t, dir, "both.md", "# flat wins")
228281 writeNested(t, dir, "both", "# nested loses")
282+ list := List(dir)
229283
230- if got, err := Read(dir, "nested-only"); err != nil || got != "# nested" {
231- t.Errorf("Read(nested-only) = %q, %v", got, err)
284+ if got, err := Load(list, "nested-only"); err != nil || got != "# nested" {
285+ t.Errorf("Load(nested-only) = %q, %v", got, err)
286+ }
287+ // The recorded rule: when both layouts claim a name, the flat file wins.
288+ // List sorts STABLY over a slice whose flat entries come first, which is
289+ // what makes this deterministic — an unstable sort decided it by coin toss.
290+ if got, err := Load(list, "both"); err != nil || got != "# flat wins" {
291+ t.Errorf("Load(both) = %q, %v; want the flat file", got, err)
232292 }
233- if got, err := Read(dir, "both"); err != nil || got != "# flat wins" {
234- t.Errorf("Read(both) = %q, %v; want the flat file", got, err)
293+ if _, err := Load(list, "missing"); err == nil {
294+ t.Error("Load(missing) returned no error")
235295 }
236- if _, err := Read(dir, "missing"); err == nil {
237- t.Error("Read(missing) returned no error")
296+ if _, err := Load(list, "../escape"); err == nil {
297+ t.Error("Load(../escape) escaped the directory")
238298 }
239- if _, err := Read(dir, "../escape"); err == nil {
240- t.Error("Read(../escape) escaped the directory")
299+ // Two skills under one name: reported, so the banner can say the second
300+ // one is unreachable.
301+ if dup := Duplicates(list); len(dup) != 1 || dup[0] != "both" {
302+ t.Errorf("Duplicates = %v, want [both]", dup)
241303 }
242304 }
243305
@@ -253,7 +315,7 @@ func TestListSkipsReadme(t *testing.T) {
253315 if len(list) != 1 || list[0].Name != "real" {
254316 t.Errorf("List = %+v, want only the real skill", list)
255317 }
256- if _, err := Read(dir, "README"); err == nil {
257- t.Log("Read(README) still opens the file: acceptable, only the catalogue hides it")
318+ if _, err := Load(list, "README"); err == nil {
319+ t.Error("README is not a skill and must not be loadable through the catalogue")
258320 }
259321 }
@@ -95,41 +95,94 @@ func TestListMissingDirIsEmpty(t *testing.T) {
95 }95 }
96 }96 }
97 97
98-func TestRead(t *testing.T) {98+func TestLoad(t *testing.T) {
99 dir := t.TempDir()99 dir := t.TempDir()
100 write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n")100 write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n")
101+ list := List(dir)
101 102
102- content, err := Read(dir, "go-fmt")103+ content, err := Load(list, "go-fmt")
103 if err != nil {104 if err != nil {
104- t.Fatalf("Read: %v", err)105+ t.Fatalf("Load: %v", err)
105 }106 }
106 if !strings.Contains(content, "# Format") {107 if !strings.Contains(content, "# Format") {
107 t.Errorf("unexpected content: %q", content)108 t.Errorf("unexpected content: %q", content)
108 }109 }
109-110+ if _, err := Load(list, "unknown"); err == nil {
110- if _, err := Read(dir, "unknown"); err == nil {
111 t.Error("an unknown skill must return an error")111 t.Error("an unknown skill must return an error")
112 }112 }
113 }113 }
114 114
115-// filepath.Base in Read is a barrier: the name comes from the MODEL, so it must115+// The catalogue names a skill by its front-matter `name`, and loading must
116-// not be able to escape the skills directory.116+// use the same name. The old path-based read did not: a skill whose `name:`
117-func TestReadCannotEscapeDir(t *testing.T) {117+// differed from its file name was advertised and then refused, with a message
118+// that contradicted itself — `No skill named "renamed". Available: renamed`.
119+func TestLoadUsesTheCatalogueNameNotTheFileName(t *testing.T) {
118 dir := t.TempDir()120 dir := t.TempDir()
119- parent := filepath.Dir(dir)121+ write(t, dir, "01-rename.md", "---\nname: go-rename\ndescription: rename a symbol\n---\n# Rename\n")
122+ list := List(dir)
123+
124+ if len(list) != 1 || list[0].Name != "go-rename" {
125+ t.Fatalf("catalogue = %+v, want one skill called go-rename", list)
126+ }
127+ if got, err := Load(list, "go-rename"); err != nil || !strings.Contains(got, "# Rename") {
128+ t.Fatalf("the catalogue advertises go-rename and Load gives %q, %v", got, err)
129+ }
130+ if _, err := Load(list, "01-rename"); err == nil {
131+ t.Error("the file name is not the skill's name and must not resolve")
132+ }
133+}
134+
135+// A model gets a name almost right: stray spaces, quotes it should not have
136+// sent, the wrong case. None of those is a different skill.
137+func TestFindForgivesWhatOnlyAModelGetsWrong(t *testing.T) {
138+ dir := t.TempDir()
139+ write(t, dir, "go-rename.md", "---\nname: go-rename\n---\n#\n")
140+ list := List(dir)
141+
142+ for _, name := range []string{"go-rename", " go-rename ", `"go-rename"`, "'go-rename'", "Go-Rename"} {
143+ if _, ok := Find(list, name); !ok {
144+ t.Errorf("Find(%q) missed the skill", name)
145+ }
146+ }
147+ if _, ok := Find(list, "go-renam"); ok {
148+ t.Error("Find matched a name that is not the skill's")
149+ }
150+}
151+
152+// Forgiving about the spelling, never about WHICH skill: an exact name always
153+// beats a case-insensitive one.
154+func TestFindPrefersTheExactName(t *testing.T) {
155+ dir := t.TempDir()
156+ write(t, dir, "a.md", "---\nname: Deploy\n---\n# upper\n")
157+ write(t, dir, "b.md", "---\nname: deploy\n---\n# lower\n")
158+
159+ if got, ok := Find(List(dir), "deploy"); !ok || got.Name != "deploy" {
160+ t.Errorf("Find(deploy) = %+v, %v — want the exact match", got, ok)
161+ }
162+}
163+
164+// The name now comes from the CATALOGUE, so it never reaches the filesystem
165+// at all. What used to need a filepath.Base barrier is now structural: a
166+// traversal string simply is not a name any skill has.
167+func TestALoadedNameNeverReachesTheFilesystem(t *testing.T) {
168+ parent := t.TempDir()
169+ dir := filepath.Join(parent, "skills")
170+ if err := os.MkdirAll(dir, 0o755); err != nil {
171+ t.Fatal(err)
172+ }
173+ write(t, dir, "real.md", "---\nname: real\n---\n# real\n")
120 if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil {174 if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil {
121 t.Fatal(err)175 t.Fatal(err)
122 }176 }
177+ list := List(dir)
123 178
124- for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} {179+ for _, name := range []string{"../secret", "../../secret", "/etc/passwd", "real/../../secret"} {
125- if content, err := Read(dir, name); err == nil {180+ if content, err := Load(list, name); err == nil {
126- t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content)181+ t.Errorf("Load(%q) succeeded and returned %q", name, content)
127 }182 }
128 }183 }
129 }184 }
130 185
131-// The catalogue IS the tool description: every skill must appear in it with its
132-// name, otherwise the model cannot ask for it.
133 func TestCatalogue(t *testing.T) {186 func TestCatalogue(t *testing.T) {
134 list := []Skill{187 list := []Skill{
135 {Name: "go-fmt", Description: "format code"},188 {Name: "go-fmt", Description: "format code"},
@@ -219,25 +272,34 @@ func TestListAcceptsFlatAndNestedLayouts(t *testing.T) {
219 // both exist (it is the more explicit of the two), and still cannot be walked272 // both exist (it is the more explicit of the two), and still cannot be walked
220 // out of the directory with a nested name: "../escape" is reduced to "escape",273 // out of the directory with a nested name: "../escape" is reduced to "escape",
221 // which does not exist inside dir.274 // which does not exist inside dir.
222-func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) {275+func TestLoadAcceptsFlatAndNestedLayouts(t *testing.T) {
223 parent := t.TempDir()276 parent := t.TempDir()
224 dir := filepath.Join(parent, "skills")277 dir := filepath.Join(parent, "skills")
225 writeNested(t, parent, "escape", "# outside")278 writeNested(t, parent, "escape", "# outside")
226 writeNested(t, dir, "nested-only", "# nested")279 writeNested(t, dir, "nested-only", "# nested")
227 write(t, dir, "both.md", "# flat wins")280 write(t, dir, "both.md", "# flat wins")
228 writeNested(t, dir, "both", "# nested loses")281 writeNested(t, dir, "both", "# nested loses")
282+ list := List(dir)
229 283
230- if got, err := Read(dir, "nested-only"); err != nil || got != "# nested" {284+ if got, err := Load(list, "nested-only"); err != nil || got != "# nested" {
231- t.Errorf("Read(nested-only) = %q, %v", got, err)285+ t.Errorf("Load(nested-only) = %q, %v", got, err)
286+ }
287+ // The recorded rule: when both layouts claim a name, the flat file wins.
288+ // List sorts STABLY over a slice whose flat entries come first, which is
289+ // what makes this deterministic — an unstable sort decided it by coin toss.
290+ if got, err := Load(list, "both"); err != nil || got != "# flat wins" {
291+ t.Errorf("Load(both) = %q, %v; want the flat file", got, err)
232 }292 }
233- if got, err := Read(dir, "both"); err != nil || got != "# flat wins" {293+ if _, err := Load(list, "missing"); err == nil {
234- t.Errorf("Read(both) = %q, %v; want the flat file", got, err)294+ t.Error("Load(missing) returned no error")
235 }295 }
236- if _, err := Read(dir, "missing"); err == nil {296+ if _, err := Load(list, "../escape"); err == nil {
237- t.Error("Read(missing) returned no error")297+ t.Error("Load(../escape) escaped the directory")
238 }298 }
239- if _, err := Read(dir, "../escape"); err == nil {299+ // Two skills under one name: reported, so the banner can say the second
240- t.Error("Read(../escape) escaped the directory")300+ // one is unreachable.
301+ if dup := Duplicates(list); len(dup) != 1 || dup[0] != "both" {
302+ t.Errorf("Duplicates = %v, want [both]", dup)
241 }303 }
242 }304 }
243 305
@@ -253,7 +315,7 @@ func TestListSkipsReadme(t *testing.T) {
253 if len(list) != 1 || list[0].Name != "real" {315 if len(list) != 1 || list[0].Name != "real" {
254 t.Errorf("List = %+v, want only the real skill", list)316 t.Errorf("List = %+v, want only the real skill", list)
255 }317 }
256- if _, err := Read(dir, "README"); err == nil {318+ if _, err := Load(list, "README"); err == nil {
257- t.Log("Read(README) still opens the file: acceptable, only the catalogue hides it")319+ t.Error("README is not a skill and must not be loadable through the catalogue")
258 }320 }
259 }321 }
added internal/tools/skillmiss_test.go +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+package tools
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/engine"
7+ "mm/internal/skills"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// This test exists because two packages agree on a string and nothing made
13+// them.
14+//
15+// `read_skill` answers a name that is not in the catalogue with a `[skill: …]`
16+// line, and engine.Skills counts a turn's LOADED skills by skipping exactly
17+// that prefix — otherwise `📖 2 skill(s)` can mean two failed lookups and
18+// nothing loaded, which is how it behaved before 2026-09-21.
19+//
20+// Reword the miss without touching the counter and the recap silently starts
21+// counting failures again. No compiler and no other test would notice, so
22+// this one asserts the round trip: what the tool produces is what the engine
23+// recognises.
24+//
25+// The import of engine is deliberately test-only — a production edge from
26+// every tool to the LLM connection would be a heavy price for one string.
27+func TestTheMissMessageIsWhatTheRecapSkips(t *testing.T) {
28+ list := []skills.Skill{
29+ {Name: "greetings", Description: "greet somebody"},
30+ {Name: "bg-jobs", Description: "background jobs"},
31+ }
32+
33+ history := func(output string) []*ai.Message {
34+ return []*ai.Message{{
35+ Role: ai.RoleTool,
36+ Content: []*ai.Part{ai.NewToolResponsePart(&ai.ToolResponse{
37+ Name: "read_skill",
38+ Output: output,
39+ })},
40+ }}
41+ }
42+
43+ miss := skillMiss("greetigns", list)
44+ if n := engine.Skills(history(miss)); n != 0 {
45+ t.Errorf("engine.Skills counted a miss as a loaded skill (%d).\n"+
46+ "The tool said: %q\n"+
47+ "Keep the [skill: …] shape, or update engine's marker with it.", n, miss)
48+ }
49+
50+ // A real skill, counted. Without this half the test would pass on a
51+ // counter that returns zero for everything.
52+ loaded := "---\nname: greetings\n---\n# Greet\nsay hello\n"
53+ if n := engine.Skills(history(loaded)); n != 1 {
54+ t.Errorf("engine.Skills = %d for a loaded skill, want 1", n)
55+ }
56+}
57+
58+// The message has to be usable by the model that receives it: what it asked
59+// for, and what it could have asked for instead.
60+func TestTheMissMessageTellsTheModelHowToRecover(t *testing.T) {
61+ list := []skills.Skill{{Name: "greetings"}, {Name: "bg-jobs"}}
62+ got := skillMiss("greetigns", list)
63+
64+ for _, want := range []string{"greetigns", "greetings", "bg-jobs"} {
65+ if !contains(got, want) {
66+ t.Errorf("the miss message does not mention %q: %s", want, got)
67+ }
68+ }
69+}
70+
71+func contains(s, sub string) bool {
72+ for i := 0; i+len(sub) <= len(s); i++ {
73+ if s[i:i+len(sub)] == sub {
74+ return true
75+ }
76+ }
77+ return false
78+}
new file mode 100644
@@ -0,0 +1,78 @@
1+package tools
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/engine"
7+ "mm/internal/skills"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// This test exists because two packages agree on a string and nothing made
13+// them.
14+//
15+// `read_skill` answers a name that is not in the catalogue with a `[skill: …]`
16+// line, and engine.Skills counts a turn's LOADED skills by skipping exactly
17+// that prefix — otherwise `📖 2 skill(s)` can mean two failed lookups and
18+// nothing loaded, which is how it behaved before 2026-09-21.
19+//
20+// Reword the miss without touching the counter and the recap silently starts
21+// counting failures again. No compiler and no other test would notice, so
22+// this one asserts the round trip: what the tool produces is what the engine
23+// recognises.
24+//
25+// The import of engine is deliberately test-only — a production edge from
26+// every tool to the LLM connection would be a heavy price for one string.
27+func TestTheMissMessageIsWhatTheRecapSkips(t *testing.T) {
28+ list := []skills.Skill{
29+ {Name: "greetings", Description: "greet somebody"},
30+ {Name: "bg-jobs", Description: "background jobs"},
31+ }
32+
33+ history := func(output string) []*ai.Message {
34+ return []*ai.Message{{
35+ Role: ai.RoleTool,
36+ Content: []*ai.Part{ai.NewToolResponsePart(&ai.ToolResponse{
37+ Name: "read_skill",
38+ Output: output,
39+ })},
40+ }}
41+ }
42+
43+ miss := skillMiss("greetigns", list)
44+ if n := engine.Skills(history(miss)); n != 0 {
45+ t.Errorf("engine.Skills counted a miss as a loaded skill (%d).\n"+
46+ "The tool said: %q\n"+
47+ "Keep the [skill: …] shape, or update engine's marker with it.", n, miss)
48+ }
49+
50+ // A real skill, counted. Without this half the test would pass on a
51+ // counter that returns zero for everything.
52+ loaded := "---\nname: greetings\n---\n# Greet\nsay hello\n"
53+ if n := engine.Skills(history(loaded)); n != 1 {
54+ t.Errorf("engine.Skills = %d for a loaded skill, want 1", n)
55+ }
56+}
57+
58+// The message has to be usable by the model that receives it: what it asked
59+// for, and what it could have asked for instead.
60+func TestTheMissMessageTellsTheModelHowToRecover(t *testing.T) {
61+ list := []skills.Skill{{Name: "greetings"}, {Name: "bg-jobs"}}
62+ got := skillMiss("greetigns", list)
63+
64+ for _, want := range []string{"greetigns", "greetings", "bg-jobs"} {
65+ if !contains(got, want) {
66+ t.Errorf("the miss message does not mention %q: %s", want, got)
67+ }
68+ }
69+}
70+
71+func contains(s, sub string) bool {
72+ for i := 0; i+len(sub) <= len(s); i++ {
73+ if s[i:i+len(sub)] == sub {
74+ return true
75+ }
76+ }
77+ return false
78+}
modified internal/tools/tools.go +15 -6
@@ -323,7 +323,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
323323 printMu.Unlock()
324324 })
325325
326- content, err := skills.Read(dir, in.Name)
326+ content, err := skills.Load(list, in.Name)
327327 if err != nil {
328328 // Unknown skill: information, not a crash. The list of valid
329329 // names goes back AS TEXT so the model can correct itself.
@@ -331,8 +331,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
331331 // Recorded in the detector like everything else: a model
332332 // asking four times for the same missing skill is looping,
333333 // exactly as it would on a failing command.
334- out := fmt.Sprintf("No skill named %q. Available skills: %s",
335- in.Name, strings.Join(skills.Names(list), ", "))
334+ out := skillMiss(in.Name, list)
336335 return record(d, "read_skill", in.Name, out)
337336 }
338337
@@ -345,6 +344,17 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
345344 })
346345 }
347346
347+// skillMiss is what a model gets when it asks for a skill that is not in the
348+// catalogue: what it asked for, and every name it could have asked for.
349+//
350+// The `[skill: …]` shape is the one the other tools use for an answer that is
351+// information rather than content (`[lsp: …]`, `[command rejected …]`), and
352+// the turn recap keys off it: a miss must not be counted as a skill loaded.
353+func skillMiss(name string, list []skills.Skill) string {
354+ return fmt.Sprintf("[skill: no skill named %q — available: %s]",
355+ name, strings.Join(skills.Names(list), ", "))
356+}
357+
348358 // readSkillEvented is `read_skill` for an event-consuming front end. No
349359 // permission dialog: it reads a procedure the agent ships, nothing more — and a
350360 // dialog for every skill would teach the user to click "allow" without reading,
@@ -361,10 +371,9 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk
361371 s.ToolStart(ev)
362372 s.ToolRunning(ev.ID)
363373
364- content, err := skills.Read(dir, in.Name)
374+ content, err := skills.Load(list, in.Name)
365375 if err != nil {
366- out := fmt.Sprintf("No skill named %q. Available skills: %s",
367- in.Name, strings.Join(skills.Names(list), ", "))
376+ out := skillMiss(in.Name, list)
368377 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
369378 return record(d, "read_skill", in.Name, out)
370379 }
@@ -323,7 +323,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
323 printMu.Unlock()323 printMu.Unlock()
324 })324 })
325 325
326- content, err := skills.Read(dir, in.Name)326+ content, err := skills.Load(list, in.Name)
327 if err != nil {327 if err != nil {
328 // Unknown skill: information, not a crash. The list of valid328 // Unknown skill: information, not a crash. The list of valid
329 // names goes back AS TEXT so the model can correct itself.329 // names goes back AS TEXT so the model can correct itself.
@@ -331,8 +331,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
331 // Recorded in the detector like everything else: a model331 // Recorded in the detector like everything else: a model
332 // asking four times for the same missing skill is looping,332 // asking four times for the same missing skill is looping,
333 // exactly as it would on a failing command.333 // exactly as it would on a failing command.
334- out := fmt.Sprintf("No skill named %q. Available skills: %s",334+ out := skillMiss(in.Name, list)
335- in.Name, strings.Join(skills.Names(list), ", "))
336 return record(d, "read_skill", in.Name, out)335 return record(d, "read_skill", in.Name, out)
337 }336 }
338 337
@@ -345,6 +344,17 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
345 })344 })
346 }345 }
347 346
347+// skillMiss is what a model gets when it asks for a skill that is not in the
348+// catalogue: what it asked for, and every name it could have asked for.
349+//
350+// The `[skill: …]` shape is the one the other tools use for an answer that is
351+// information rather than content (`[lsp: …]`, `[command rejected …]`), and
352+// the turn recap keys off it: a miss must not be counted as a skill loaded.
353+func skillMiss(name string, list []skills.Skill) string {
354+ return fmt.Sprintf("[skill: no skill named %q — available: %s]",
355+ name, strings.Join(skills.Names(list), ", "))
356+}
357+
348 // readSkillEvented is `read_skill` for an event-consuming front end. No358 // readSkillEvented is `read_skill` for an event-consuming front end. No
349 // permission dialog: it reads a procedure the agent ships, nothing more — and a359 // permission dialog: it reads a procedure the agent ships, nothing more — and a
350 // dialog for every skill would teach the user to click "allow" without reading,360 // dialog for every skill would teach the user to click "allow" without reading,
@@ -361,10 +371,9 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk
361 s.ToolStart(ev)371 s.ToolStart(ev)
362 s.ToolRunning(ev.ID)372 s.ToolRunning(ev.ID)
363 373
364- content, err := skills.Read(dir, in.Name)374+ content, err := skills.Load(list, in.Name)
365 if err != nil {375 if err != nil {
366- out := fmt.Sprintf("No skill named %q. Available skills: %s",376+ out := skillMiss(in.Name, list)
367- in.Name, strings.Join(skills.Names(list), ", "))
368 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})377 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
369 return record(d, "read_skill", in.Name, out)378 return record(d, "read_skill", in.Name, out)
370 }379 }
modified internal/tui/turn.go +5 -2
@@ -305,8 +305,11 @@ func recap(turn []*ai.Message) string {
305305 if ops := engine.FileOps(turn); len(ops) > 0 {
306306 line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
307307 }
308- if k := engine.Skills(turn); k > 0 {
309- line += fmt.Sprintf(" · 📖 %d skill(s)", k)
308+ // Always shown when the agent has skills, 0 included: a zero here is the
309+ // signal that the model never reached for a procedure that may have
310+ // applied, and it is the only place that signal exists.
311+ if engine.SkillsAvailable() {
312+ line += fmt.Sprintf(" · 📖 %d skill(s)", engine.Skills(turn))
310313 }
311314 if k := engine.LSPCalls(turn); k > 0 {
312315 line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
@@ -305,8 +305,11 @@ func recap(turn []*ai.Message) string {
305 if ops := engine.FileOps(turn); len(ops) > 0 {305 if ops := engine.FileOps(turn); len(ops) > 0 {
306 line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))306 line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
307 }307 }
308- if k := engine.Skills(turn); k > 0 {308+ // Always shown when the agent has skills, 0 included: a zero here is the
309- line += fmt.Sprintf(" · 📖 %d skill(s)", k)309+ // signal that the model never reached for a procedure that may have
310+ // applied, and it is the only place that signal exists.
311+ if engine.SkillsAvailable() {
312+ line += fmt.Sprintf(" · 📖 %d skill(s)", engine.Skills(turn))
310 }313 }
311 if k := engine.LSPCalls(turn); k > 0 {314 if k := engine.LSPCalls(turn); k > 0 {
312 line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)315 line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
modified main.go +33 -3
@@ -191,8 +191,11 @@ type toolSet struct {
191191 tools []ai.ToolRef
192192 names []string
193193 skillCount int
194- warnings []string
195- close func()
194+ // skills is the catalogue itself, kept so the banner can say what is
195+ // wrong with it and not only how many there are.
196+ skills []skills.Skill
197+ warnings []string
198+ close func()
196199 }
197200
198201 // assembleTools declares the tools every front end gets: `bash` unless
@@ -210,7 +213,11 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet {
210213 set.tools = append(set.tools, tools.Bash(e.G, d))
211214 set.names = append(set.names, "bash")
212215 }
213- set.skillCount = len(skills.List(config.Cfg.SkillsDir))
216+ set.skills = skills.List(config.Cfg.SkillsDir)
217+ set.skillCount = len(set.skills)
218+ // The turn recap shows `📖 0 skill(s)` only where a skill could have been
219+ // used — see engine.SkillsAvailable.
220+ engine.DeclareSkills(set.skillCount > 0)
214221 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
215222 set.tools = append(set.tools, skillTool)
216223 set.names = append(set.names, "read_skill")
@@ -281,6 +288,29 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
281288 info.Warnings = append(info.Warnings,
282289 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
283290 }
291+ // A skill with no description reaches the catalogue as a bare name, which
292+ // is the worst entry there is: the model has nothing to match a request
293+ // against, and the failure is silent — the count is not zero, so the
294+ // warning above never fires.
295+ if bare := skills.Undescribed(set.skills); len(bare) > 0 {
296+ info.Warnings = append(info.Warnings,
297+ fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly",
298+ len(bare), strings.Join(bare, ", ")))
299+ }
300+ // Only the first maxKeywords of a list reach the catalogue, so the order
301+ // of a keyword line decides what the model gets to match against. An
302+ // author cannot see that from their own file.
303+ if cut := skills.Truncated(set.skills); len(cut) > 0 {
304+ info.Warnings = append(info.Warnings,
305+ fmt.Sprintf("keywords truncated for %s — only the first ones reach the model, so put the most distinctive first",
306+ strings.Join(cut, ", ")))
307+ }
308+ // Two skills under one name: the catalogue shows it twice and the second
309+ // is unreachable, since a lookup always returns the first.
310+ if dup := skills.Duplicates(set.skills); len(dup) > 0 {
311+ info.Warnings = append(info.Warnings,
312+ fmt.Sprintf("two skills share a name (%s) — only the first is reachable", strings.Join(dup, ", ")))
313+ }
284314 // No tool at all is a legal set-up — a chat with the model — but rarely the
285315 // intended one; say so where the "tools:" column would otherwise be empty.
286316 if len(toolNames) == 0 {
@@ -191,8 +191,11 @@ type toolSet struct {
191 tools []ai.ToolRef191 tools []ai.ToolRef
192 names []string192 names []string
193 skillCount int193 skillCount int
194- warnings []string194+ // skills is the catalogue itself, kept so the banner can say what is
195- close func()195+ // wrong with it and not only how many there are.
196+ skills []skills.Skill
197+ warnings []string
198+ close func()
196 }199 }
197 200
198 // assembleTools declares the tools every front end gets: `bash` unless201 // assembleTools declares the tools every front end gets: `bash` unless
@@ -210,7 +213,11 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet {
210 set.tools = append(set.tools, tools.Bash(e.G, d))213 set.tools = append(set.tools, tools.Bash(e.G, d))
211 set.names = append(set.names, "bash")214 set.names = append(set.names, "bash")
212 }215 }
213- set.skillCount = len(skills.List(config.Cfg.SkillsDir))216+ set.skills = skills.List(config.Cfg.SkillsDir)
217+ set.skillCount = len(set.skills)
218+ // The turn recap shows `📖 0 skill(s)` only where a skill could have been
219+ // used — see engine.SkillsAvailable.
220+ engine.DeclareSkills(set.skillCount > 0)
214 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {221 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
215 set.tools = append(set.tools, skillTool)222 set.tools = append(set.tools, skillTool)
216 set.names = append(set.names, "read_skill")223 set.names = append(set.names, "read_skill")
@@ -281,6 +288,29 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
281 info.Warnings = append(info.Warnings,288 info.Warnings = append(info.Warnings,
282 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))289 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
283 }290 }
291+ // A skill with no description reaches the catalogue as a bare name, which
292+ // is the worst entry there is: the model has nothing to match a request
293+ // against, and the failure is silent — the count is not zero, so the
294+ // warning above never fires.
295+ if bare := skills.Undescribed(set.skills); len(bare) > 0 {
296+ info.Warnings = append(info.Warnings,
297+ fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly",
298+ len(bare), strings.Join(bare, ", ")))
299+ }
300+ // Only the first maxKeywords of a list reach the catalogue, so the order
301+ // of a keyword line decides what the model gets to match against. An
302+ // author cannot see that from their own file.
303+ if cut := skills.Truncated(set.skills); len(cut) > 0 {
304+ info.Warnings = append(info.Warnings,
305+ fmt.Sprintf("keywords truncated for %s — only the first ones reach the model, so put the most distinctive first",
306+ strings.Join(cut, ", ")))
307+ }
308+ // Two skills under one name: the catalogue shows it twice and the second
309+ // is unreachable, since a lookup always returns the first.
310+ if dup := skills.Duplicates(set.skills); len(dup) > 0 {
311+ info.Warnings = append(info.Warnings,
312+ fmt.Sprintf("two skills share a name (%s) — only the first is reachable", strings.Join(dup, ", ")))
313+ }
284 // No tool at all is a legal set-up — a chat with the model — but rarely the314 // No tool at all is a legal set-up — a chat with the model — but rarely the
285 // intended one; say so where the "tools:" column would otherwise be empty.315 // intended one; say so where the "tools:" column would otherwise be empty.
286 if len(toolNames) == 0 {316 if len(toolNames) == 0 {
added skills/bg-jobs/SKILL.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+---
2+name: bg-jobs
3+description: Start, wait on, inspect and stop a long-running background job — a server, a watcher, a build that does not end — without blocking the turn.
4+keywords: background, job, server, serve, start, daemon, nohup, watch, tail, log, pid, kill, stop, port, running
5+---
6+
7+# Background jobs
8+
9+## When to use it
10+
11+Any command that **serves, watches, or runs until stopped**: an HTTP server, `npm run dev`, `go run` on a web app, a file watcher, a queue worker. Read this before you start one, and before you wait on, inspect or stop one.
12+
13+A command that never returns will hold the turn for its whole budget and then be killed. The answer the user asked for never arrives.
14+
15+## Start it
16+
17+Both streams redirected, the pid kept:
18+
19+```bash
20+nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
21+```
22+
23+Three parts, and all three matter:
24+
25+- **`> /tmp/<job>.log 2>&1`** — both streams. Redirecting only stdout is the classic mistake: the job keeps the tool's own error pipe open, and the call cannot finish until the job does. You get the 30-second budget spent on nothing.
26+- **`&`** — the shell returns at once.
27+- **`echo $! > /tmp/<job>.pid`** — without the pid you cannot stop it later except by guessing at `pkill` patterns, which also kills things you did not start.
28+
29+Use one name per job, and reuse it for the log and the pid file.
30+
31+## Wait for it to be ready
32+
33+**Do not `sleep` and hope.** Poll for the thing you actually need, with a bound:
34+
35+```bash
36+for i in $(seq 1 30); do
37+ curl -fsS localhost:8080/ >/dev/null 2>&1 && echo ready && break
38+ sleep 0.5
39+done
40+```
41+
42+For something that is not an HTTP server, poll the log for the line it prints when it is up:
43+
44+```bash
45+for i in $(seq 1 30); do
46+ grep -q "listening on" /tmp/<job>.log && echo ready && break
47+ sleep 0.5
48+done
49+```
50+
51+If the loop ends without `ready`, the job did not start — go read the log.
52+
53+## Inspect it
54+
55+```bash
56+tail -n 40 /tmp/<job>.log # what it said
57+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && echo alive || echo dead
58+```
59+
60+`kill -0` sends no signal; it only asks whether the process is there. A job that is `dead` right after starting almost always says why in the last lines of its log — read them before changing anything.
61+
62+## Stop it
63+
64+```bash
65+kill "$(cat /tmp/<job>.pid)" 2>/dev/null
66+sleep 0.5
67+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && kill -9 "$(cat /tmp/<job>.pid)"
68+rm -f /tmp/<job>.pid
69+```
70+
71+Ask politely first (`kill` sends TERM, which lets a server close its listener), then insist. Removing the pid file afterwards stops a later command from acting on a pid the system has since reused for something else.
72+
73+## Before you finish
74+
75+**Stop every job you started**, and **say which ones you left running** and why. A server still holding port 8080 after the turn is a surprise the user will hit later, when nothing on screen explains it.
76+
77+## Traps
78+
79+- **One shell per call.** Each `bash` call is a new shell in the same directory: a job started in one call is still running in the next, but shell variables are gone. That is exactly why the pid goes to a file and not to `$PID`.
80+- **The port is already in use.** A previous run left something behind. `lsof -i :8080` or `ss -lptn 'sport = :8080'` names the holder; stop that before starting again, rather than moving to another port and forgetting.
81+- **Nothing is interactive.** There is no stdin. A command that asks a question will hang until it is killed; pass the answer as a flag, or do not run it.
82+- **`nohup` writes `nohup.out`** in the working directory when you forget to redirect — if you see that file appear, a redirect is missing.
new file mode 100644
@@ -0,0 +1,82 @@
1+---
2+name: bg-jobs
3+description: Start, wait on, inspect and stop a long-running background job — a server, a watcher, a build that does not end — without blocking the turn.
4+keywords: background, job, server, serve, start, daemon, nohup, watch, tail, log, pid, kill, stop, port, running
5+---
6+
7+# Background jobs
8+
9+## When to use it
10+
11+Any command that **serves, watches, or runs until stopped**: an HTTP server, `npm run dev`, `go run` on a web app, a file watcher, a queue worker. Read this before you start one, and before you wait on, inspect or stop one.
12+
13+A command that never returns will hold the turn for its whole budget and then be killed. The answer the user asked for never arrives.
14+
15+## Start it
16+
17+Both streams redirected, the pid kept:
18+
19+```bash
20+nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
21+```
22+
23+Three parts, and all three matter:
24+
25+- **`> /tmp/<job>.log 2>&1`** — both streams. Redirecting only stdout is the classic mistake: the job keeps the tool's own error pipe open, and the call cannot finish until the job does. You get the 30-second budget spent on nothing.
26+- **`&`** — the shell returns at once.
27+- **`echo $! > /tmp/<job>.pid`** — without the pid you cannot stop it later except by guessing at `pkill` patterns, which also kills things you did not start.
28+
29+Use one name per job, and reuse it for the log and the pid file.
30+
31+## Wait for it to be ready
32+
33+**Do not `sleep` and hope.** Poll for the thing you actually need, with a bound:
34+
35+```bash
36+for i in $(seq 1 30); do
37+ curl -fsS localhost:8080/ >/dev/null 2>&1 && echo ready && break
38+ sleep 0.5
39+done
40+```
41+
42+For something that is not an HTTP server, poll the log for the line it prints when it is up:
43+
44+```bash
45+for i in $(seq 1 30); do
46+ grep -q "listening on" /tmp/<job>.log && echo ready && break
47+ sleep 0.5
48+done
49+```
50+
51+If the loop ends without `ready`, the job did not start — go read the log.
52+
53+## Inspect it
54+
55+```bash
56+tail -n 40 /tmp/<job>.log # what it said
57+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && echo alive || echo dead
58+```
59+
60+`kill -0` sends no signal; it only asks whether the process is there. A job that is `dead` right after starting almost always says why in the last lines of its log — read them before changing anything.
61+
62+## Stop it
63+
64+```bash
65+kill "$(cat /tmp/<job>.pid)" 2>/dev/null
66+sleep 0.5
67+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && kill -9 "$(cat /tmp/<job>.pid)"
68+rm -f /tmp/<job>.pid
69+```
70+
71+Ask politely first (`kill` sends TERM, which lets a server close its listener), then insist. Removing the pid file afterwards stops a later command from acting on a pid the system has since reused for something else.
72+
73+## Before you finish
74+
75+**Stop every job you started**, and **say which ones you left running** and why. A server still holding port 8080 after the turn is a surprise the user will hit later, when nothing on screen explains it.
76+
77+## Traps
78+
79+- **One shell per call.** Each `bash` call is a new shell in the same directory: a job started in one call is still running in the next, but shell variables are gone. That is exactly why the pid goes to a file and not to `$PID`.
80+- **The port is already in use.** A previous run left something behind. `lsof -i :8080` or `ss -lptn 'sport = :8080'` names the holder; stop that before starting again, rather than moving to another port and forgetting.
81+- **Nothing is interactive.** There is no stdin. A command that asks a question will hang until it is killed; pass the answer as a flag, or do not run it.
82+- **`nohup` writes `nohup.out`** in the working directory when you forget to redirect — if you see that file appear, a redirect is missing.
modified skills/greetings/SKILL.md +2 -4
@@ -12,8 +12,6 @@ The command below is for YOU to run with your `bash` tool, in this same turn.
1212 Do not show it to the user, and do not answer from memory: the answer is the
1313 command's output, nothing else.
1414
15-Replace `<name>` with the person being greeted, then run:
15+Replace `<name>` with the person being greeted, then call your `bash` tool with this command:
1616
17-```bash
18-echo "🎉 greetings <name>"
19-```
17+ echo "🎉 greetings <name>"
@@ -12,8 +12,6 @@ The command below is for YOU to run with your `bash` tool, in this same turn.
12 Do not show it to the user, and do not answer from memory: the answer is the12 Do not show it to the user, and do not answer from memory: the answer is the
13 command's output, nothing else.13 command's output, nothing else.
14 14
15-Replace `<name>` with the person being greeted, then run:15+Replace `<name>` with the person being greeted, then call your `bash` tool with this command:
16 16
17-```bash17+ echo "🎉 greetings <name>"
18-echo "🎉 greetings <name>"
19-```
modified skills/vulcan-salute/SKILL.md +2 -4
@@ -12,8 +12,6 @@ The command below is for YOU to run with your `bash` tool, in this same turn.
1212 Do not show it to the user, and do not answer from memory: the answer is the
1313 command's output, nothing else.
1414
15-Replace `<name>` with the person being saluted, the run:
15+Replace `<name>` with the person being saluted, then call your `bash` tool with this command:
1616
17-```bash
18-echo "🖖 Live long and prosper! <name> 🖖"
19-```
17+ echo "🖖 Live long and prosper! <name> 🖖"
@@ -12,8 +12,6 @@ The command below is for YOU to run with your `bash` tool, in this same turn.
12 Do not show it to the user, and do not answer from memory: the answer is the12 Do not show it to the user, and do not answer from memory: the answer is the
13 command's output, nothing else.13 command's output, nothing else.
14 14
15-Replace `<name>` with the person being saluted, the run:15+Replace `<name>` with the person being saluted, then call your `bash` tool with this command:
16 16
17-```bash17+ echo "🖖 Live long and prosper! <name> 🖖"
18-echo "🖖 Live long and prosper! <name> 🖖"
19-```
modified skills/what-time-is-it/SKILL.md +2 -4
@@ -11,9 +11,7 @@ keywords: time, date, clock, now, today, heure, date du jour, quelle heure
1111 The command below is for YOU to run with your `bash` tool, in this same turn.
1212 Do not show it to the user, and never guess the time: only the command knows it.
1313
14-Run:
14+Call your `bash` tool with this command:
1515
16-```bash
17-date "+%A %d %B %Y — %H:%M:%S (%Z)"
18-```
16+ date "+%A %d %B %Y — %H:%M:%S (%Z)"
1917
@@ -11,9 +11,7 @@ keywords: time, date, clock, now, today, heure, date du jour, quelle heure
11 The command below is for YOU to run with your `bash` tool, in this same turn.11 The command below is for YOU to run with your `bash` tool, in this same turn.
12 Do not show it to the user, and never guess the time: only the command knows it.12 Do not show it to the user, and never guess the time: only the command knows it.
13 13
14-Run:14+Call your `bash` tool with this command:
15 15
16-```bash16+ date "+%A %d %B %Y — %H:%M:%S (%Z)"
17-date "+%A %d %B %Y — %H:%M:%S (%Z)"
18-```
19 17