Keeps the skill listing out of the context window and lets TypeSafe's Jev, a System One decision model, pick at most one skill per prompt from the skills'…

Takes the skill listing out of the context window and lets Jev, TypeSafe's System One decision model, pick at most one skill per prompt from the skills' descriptions — and then loads that one skill itself, by attaching its SKILL.md to the prompt. The skills stay installed and you can still type /name; what goes away is the listing Claude Code sends the model every session — one line per skill, sixty-odd lines on a well-equipped machine — whether or not the prompt has anything to do with any of them. Because the mod does the loading, the skills can be hidden from the model altogether (/jev-skill-suggestion:setup sets them user-invocable-only), and /skills and /context then show the saving.
The decision is TypeSafe's own skill-suggestion cookbook, which on a 182-skill roster cut wrong skill loads from 16.8% to 7.3% and needless ones from 9.8% to 4.0%: two requests per prompt, one that ranks every skill and asks whether the prompt needs a skill at all, one that re-reads the top three properly and can reject all of them.
Two backends, chosen by whichever key is set:
| Backend | Endpoint | Model | Confidence |
|---|---|---|---|
typesafe | POST api.typesafe.ai/v1/systemone | jev-latest | reported per answer |
gateway | POST ai-gateway.vercel.sh/v4/ai/evaluation-model | typesafe-ai/jev | derived from an optional distribution |
TypeSafe's own API wins when both keys are set: it is the only one that reports a calibrated confidence per answer, which the log shows beside every pick. Set provider to force one, or to builtin to use neither. Each backend keeps its own URL and model option, so an override written for one is never sent to the other. A provider forced onto a backend whose key is missing degrades to the built-in classifier and says so once in the log.
With no key configured the mod still works: it falls back to the engine's own $.model.classify, which answers the ranking question with the small fast model, the descriptions folded into the text it reads. That path has no gate and no second request: its single answer is taken as is.
Five steps, in this order. Each one is checkable before the next.
1. Install and start. Claude Code 2.1.278 or newer, in a project you trust:
npx claude-code-templates@latest --mod productivity/jev-skill-suggestion
claude
The first prompt of the session prints [jev-skill-suggestion] ready on …; withholding the skill listing in the transcript. From here on the listing is already kept from the model on every prompt — but /skills and /context do not know that yet (see Checking that it works), which is what the next step is for.
2. Hand the skills over. In Claude Code:
/jev-skill-suggestion:setup
The mod fills the command in at run time with your real roster and Claude shows you, in your language, three lists before touching anything:
user-invocable-only in ~/.claude/settings.json (skillOverrides) — your user-level skills, the project's (the working directory's .claude/, not its ancestors: a git worktree only sees its own copies) and those synced from claude.ai;simplify, loop, init, …), turned off together with disableBundledSkills: true;skillOverrides cannot touch: they stay listed unless you disable the plugin in /plugin.Say yes, and Claude first writes the previous values to ~/.claude/jev-skill-suggestion.skill-overrides.backup.json, then edits ~/.claude/settings.json with the Edit tool — so the change shows as a diff and asks for permission like any other file edit. Nothing is written before your yes. Running the command again proposes only what is still to change (0 on a machine already set up) and leaves the backup from the first run untouched, so it is safe to repeat after installing new skills.
3. Restart Claude Code — /skills and /context read the settings at start-up.
4. Check. /skills lists every hidden skill as user-only; /context's "Skills" row counts only what a plugin ships. Then ask for something one of your skills does:
Make me a 5-slide pptx deck about Q3 results. Before building anything, tell me which skill instructions you have and what their first workflow step is.
The status row under the prompt reads jev · skill: <name>, the transcript shows suggesting /<name> and injected /<name> from <its SKILL.md> (N characters), and the answer follows that skill's own steps — the skill was never in the model's listing and the Skill tool would have refused it. A prompt with no skill in it (Explain in two sentences what a monad is) reads jev · no skill and no suggestion.
5. Undo, whenever. /jev-skill-suggestion:setup restore puts the saved values back (and removes the backup); restart afterwards. Do this before uninstalling the mod, or your skills stay hidden from the model with nothing left to inject them.
Skills you have hidden are still yours to run by typing /name.
Three hooks, two on the way in and one on the way out:
| Hook | What it does |
|---|---|
prompt.attachment on skill_listing | Answers the engine's skill listing with { text: null }, so the model never reads it — or with the listing trimmed to the names in alwaysListed. The names the listing carried are remembered. |
prompt.submit | Runs the two requests below and attaches the one winner, if any, to the prompt as a <skill_relevance> block the model reads and the user never sees — with the skill's own SKILL.md inside it (inject: "content", the default), or with its name alone for the Skill tool (inject: "suggest"). |
skill.prompt | Observation: logs whether a skill the model loaded was the suggested one. Also writes the prompt of /jev-skill-suggestion:setup (see Install). |
The block the model reads, in place of the listing:
<skill_relevance>
Relevant to the current request: commit. Ignore this if it does not fit what the user actually asked for.
Its instructions follow: follow them now, including any setup steps. Do not load it with the Skill tool (it is already loaded here, and the tool may refuse it). Its files are in /home/me/.claude/skills/commit.
<skill name="commit" dir="/home/me/.claude/skills/commit">
…the SKILL.md body, frontmatter off, ${CLAUDE_SKILL_DIR} and ${CLAUDE_PROJECT_DIR} filled in…
</skill>
</skill_relevance>
The first line is the cookbook's, word for word: it says the suggestion can be ignored, because pushing harder wins compliance on wrong suggestions too, and a wrong one is worse than none. The rest is the skill as the engine would have rendered it on a Skill-tool call, so the model has it whether or not the engine would let it load the skill: a skill set to user-invocable-only or off in skillOverrides is refused by the Skill tool, and this is what makes hiding every skill workable. A skill injected once is only named again on later prompts (Skill /commit is already loaded above; instructions unchanged.), as the engine does on a repeated call — until the conversation is no longer the one it went into: /clear, a resume or a compaction of the main conversation start the count over, and the next pick goes in whole again. A skill whose file cannot be found (a bundled one) is suggested by name, for the Skill tool.
What the Skill tool does that this does not: apply the skill's allowed-tools, and count as a skill invocation for /skill-doctor.
With inject: "suggest" the block is the cookbook's: the name, and with the listing withheld the skill's line and the way to load it. A turn with nothing to suggest, while the listing is in place (hideListing: false), still sends No skill in the roster appears relevant to this request., so the roster's own "err on the side of loading" is not left unopposed. With the listing withheld there is nothing to oppose, so nothing is sent. In this mode the call stays the model's, and a skill hidden with skillOverrides cannot be loaded.
A typed /name still loads any skill, suggested or not.
Where the candidates come from. The listing is rendered at the turn's first model request, after prompt.submit has run, so the first prompt of a session would have nothing to choose from if the listing were the source. The candidates come from $.command.list() instead — every command the person can run, less the built-ins (/help, /clear, and Claude Code's bundled skills, which have no file to inject) and the names in neverSuggested. Skills hidden with skillOverrides are still candidates: the mod loads the winner itself. With inject: "suggest", once a listing has been seen only the skills it named are offered, since there the Skill tool does the loading and the listing is the engine's word on what it will load. A skill whose own frontmatter says disable-model-invocation: true is never picked in either mode.
Only the main conversation. A subagent's own skill listing is left as the engine renders it. Its prompt is a tool call's argument, not a prompt.submit, so nothing here could suggest for it, and hiding its listing would leave it with no skills at all.
The listing hook always answers the same way, so the model's prompt cache holds: the engine asks once per attachment and keeps the answer for the process.
Two requests, in the cookbook's shape. Each may come back empty-handed.
Request 1 — skim every skill. One choice (which) over every candidate, with its one-line description as the criterion; its probability distribution is the ranking. Beside it, three nouls about the request, not about any skill:
| noul | asks |
|---|---|
acts_on_user_system | Is the assistant being asked to act on the user's files, accounts, devices or services, rather than only to explain or advise? |
would_follow_documented_procedure | Would a careful expert consult a specific documented procedure or set of commands, rather than answer from general understanding? |
prose_suffices | Could a knowledgeable generalist fully satisfy this in prose, with no tools and no access to the user's files? (counts the other way round) |
Their mean is the gate: under gateThreshold (0.30) nothing is suggested, whatever the ranking said. Questions about subject matter would not do this job — explain what a monad is and a task that needs a skill are both software.
Request 2 — read the top three properly. The same choice over the shortlist (shortlist, 3), now with each skill's full frontmatter description and the first excerptChars (700) of its SKILL.md as the criterion, and one noul per candidate — does this skill do the specific thing the request asks for? — answered on its own, so all of them can come back low. A shortlist whose best fits is under fitsThreshold (0.30) is dropped entirely; otherwise the choice's winner is suggested. The two decide different things: the choice settles which, the nouls settle whether.
This is where lookalikes separate — on one line the skill that edits .pptx files reads nearly the same as the one that authors them; on 700 characters they do not.
The skill bodies come from disk, by where Claude Code keeps them: .claude/skills/<name>/SKILL.md and .claude/commands/<name>.md in the project and under ~, ~/.claude/skills/synced/<account>/<name>/SKILL.md for a skill synced from claude.ai, and for a plugin's skill its install path from ~/.claude/plugins/installed_plugins.json. A body that cannot be found leaves that candidate with its one-line description; the request still goes out. Bodies are read once per session, and the same read is what gets injected.
noul as a boolean with a probability; both shapes are read.rerank: false skips the second request and suggests the top of the ranking, once the gate passes. A second request that was attempted and failed suggests nothing: the ranking's winner has not had its false-positive check.disable-model-invocation: true is never suggested, whichever path picked it: the engine leaves it out of the listing and the Skill tool refuses it. Before the first listing has been seen the candidates come from $.command.list(), which also names such skills, so their SKILL.md is the check (read for the shortlist and for the winner).Every failure — a non-2xx response, a timeout past timeoutMs on either request, a thrown error, a malformed body — lets the prompt through with no suggestion. The mod never blocks a prompt.
Prompts that are not a task get no suggestion: notifications, peer messages, observer reports, and a prompt that is itself a /name (its skill is already named).
With logDecisions on (the default), the mod reports every step of its own work, because nothing else in Claude Code shows it — a listing that was never sent leaves no trace:
[jev-skill-suggestion] ready on typesafe (https://api.typesafe.ai/v1/systemone); withholding the skill listing
[jev-skill-suggestion] jev: needs a skill 0.76 · top of 58: powerpoint (0.70), pptx-author (0.30), chroma (0.00) · 160ms
[jev-skill-suggestion] jev: rerank → pptx-author (0.81) · fits powerpoint 0.73, pptx-author 0.38, chroma 0.02 · 90ms · 3/3 bodies read
[jev-skill-suggestion] suggesting /pptx-author: rerank of 3, fits 0.38
[jev-skill-suggestion] injected /pptx-author from /home/me/.claude/skills/pptx-author/SKILL.md (4210 characters)
[jev-skill-suggestion] withheld the skill listing (58 skills, 9127 characters); kept listed: none
[jev-skill-suggestion] jev: needs a skill 0.12 · top of 58: debug (0.41), code-review (0.22), commit (0.05) · 150ms
[jev-skill-suggestion] no suggestion: needs a skill 0.12 < 0.3
jev: lines are what the decision model replied to each request, before any policy is applied — the gate, the top of the ranking, then the rerank's winner and every fits, with how long each call took and how many SKILL.md bodies were found.injected /name from <file> is the skill going in with the prompt; already injected this session; named again on a repeat.withheld the skill listing is the listing hook firing, with what it cost the context and what it kept. It appears after the first prompt's lines, because that is when the engine renders the listing. While it still counts skills, the mod adds N skills are still listed for the model … run /jev-skill-suggestion:setup once per session.skill /name loaded is the model calling the Skill tool on its own (inject: "suggest", or a typed /name): as suggested, or which skill was suggested instead when it reached for another.It also keeps a one-line status on screen, replaced as it goes:
jev · skill: pptx-author
jev · no skill
No lines at all has three causes, and only the last is the module failing to load. Check them in this order:
claude -p (or the SDK). A headless run has no transcript and no status row: every line still goes to the debug log, ~/.claude/debug/<session-id>.txt (a .txt, not a .log; latest is a symlink to the newest), and an SDK host receives each one as ui_log..claude/skills/ (where --mod writes it) only once the project is trusted: it is repository content, so an untrusted folder's .claude/ is not read at all, and claude -p never asks. Open claude interactively in the folder and accept the trust prompt, or name the plugin explicitly with --plugin-dir (see Install). claude --debug settles it: a loaded module prints hooks module jev-skill-suggestion@skills-dir loaded (worker, …); events: prompt.attachment,prompt.submit,skill.prompt (@inline when loaded with --plugin-dir); Found N plugins without it means the plugin is not in the session.installed plugins' hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off. Update Claude Code (this mod needs 2.1.278+, see below).A ready on the built-in classifier, no key set line when you did set a key means the key sits under the wrong pluginConfigs entry: the key must match the plugin's id, which depends on how it was loaded (see Options).
Three places tell you different things, and only one of them is what the model was actually sent:
| Where | What it shows | Reflects the mod? |
|---|---|---|
/skills | each skill's state (on, name-only, user-only, off) and its estimated listing cost | after setup: the hidden ones read user-only |
/context → Skills | an estimate rendered from the roster, without asking the prompt.attachment hooks | no while skills are on: it reads the same with the mod as without (5.4k tokens for a 40-skill roster in our test). After setup it counts only what is still listed — the plugin skills |
| the API request | what the model read | yes: the first request's input_tokens in the session's .jsonl under ~/.claude/projects/ drop by the listing's size (5,496 tokens in that test), /context's "Messages" row — computed from what was sent — drops the same, and a model asked "is there a skill listing in your context?" answers no |
So the mod is at work from the first prompt, and setup is what makes the two panels agree with it. The transcript lines (above) are the running proof: withheld the skill listing (N skills, …) says what was kept from the model on that prompt, and injected /name from … that the one skill needed went in instead.
What stays counted after setup: skills shipped by plugins (/skills marks them locked by plugin; disable the plugin in /plugin to remove them) — the mod's own /jev-skill-suggestion:setup is not among them, its disable-model-invocation: true keeps its description out of the listing.
If /skills still shows one of your own skills as on after setup and a restart, its directory holds a SKILL.md whose frontmatter name: differs from the directory name; run setup again — the mod maps such names to the directory the engine goes by — or delete the skill if it is a stray copy.
With a key set, the prompt text and every candidate skill's name and one-line description leave the machine on the first request, and the first excerptChars of each shortlisted skill's SKILL.md on the second, to whichever backend the key belongs to. Nothing else. With no key set, nothing leaves the machine.
typesafeApiKey: string TypeSafe API key (preferred: it reports a confidence)
gatewayApiKey: string Vercel AI Gateway key
provider: string "auto" | "typesafe" | "gateway" | "builtin"
typesafeBaseUrl: string empty uses https://api.typesafe.ai
typesafeModel: string empty uses jev-latest
gatewayBaseUrl: string empty uses https://ai-gateway.vercel.sh/v4/ai
gatewayModel: string empty uses typesafe-ai/jev
inject: string "content" attaches the chosen skill's SKILL.md (default); "suggest" names it for the Skill tool
hideListing: boolean withhold the engine's skill listing (default true)
rerank: boolean second request over the shortlist (default true)
shortlist: number how many of the ranking the second request re-reads (default 3)
gateThreshold: number gate mean under which nothing is suggested (default 0.3)
fitsThreshold: number best `fits` under which the shortlist is dropped (default 0.3)
excerptChars: number SKILL.md characters each candidate brings (default 700)
alwaysListed: string comma-separated names that stay in the listing
neverSuggested: string comma-separated names never offered to the decision model
timeoutMs: number latency budget per request (default 800)
logDecisions: boolean log each decision (default true)
inject: "suggest" with hideListing: false reproduces the cookbook exactly — the listing stays, the suggestion goes on top — and is the way to measure the suggestions against what the model would have chosen on its own before committing to the saving. The two thresholds are the cookbook's; TypeSafe's confidence guide is the place to read before moving them. alwaysListed is for the one or two skills you want the model to know about on every prompt (a house-style commit, say); neverSuggested for skills that should only ever run when the user types them.
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "jev-skill-suggestion@skills-dir": { "options": { "typesafeApiKey": "" } } } }
The entry's key is the plugin's id, and the id follows how the plugin was loaded: "jev-skill-suggestion@skills-dir" when auto-loaded from .claude/skills/ (the --mod install), "jev-skill-suggestion" with --plugin-dir. Under the wrong key every option stays at its default, and the ready on line reports no key set.
The full sequence is in Quick start; this is the detail behind it.
npx claude-code-templates@latest --mod productivity/jev-skill-suggestion
claude
--mod writes the plugin to .claude/skills/jev-skill-suggestion/ in the project, and Claude Code auto-loads it as jev-skill-suggestion@skills-dir in a trusted project: a folder's .claude/ is repository content and is not read until you accept the trust prompt on the first interactive claude there (-p never asks, so a headless run in a fresh folder never sees it). The options then go under the "jev-skill-suggestion@skills-dir" key in pluginConfigs (see Options).
For one session with hot reload, or in a folder you do not want to trust, name it on the command line instead — it loads as jev-skill-suggestion@inline and reads options from the "jev-skill-suggestion" key:
claude --plugin-dir .claude/skills/jev-skill-suggestion
Either way, claude plugin validate .claude/skills/jev-skill-suggestion prints every event it hooks and every $ call it makes.
The setup command. /jev-skill-suggestion:setup is a markdown command the plugin ships (commands/setup.md) whose text the mod replaces in its skill.prompt hook with the plan built from $.command.list() and the settings as they are; its disable-model-invocation: true keeps it out of the model's listing. A skill whose frontmatter name: is not its directory name (name: "PocketBase API Rules" in pb-api-rules/) is written by its directory name, which is what the engine lists, runs and overrides. setup restore reads ~/.claude/jev-skill-suggestion.skill-overrides.backup.json and puts every saved entry back, removing the ones the setup added. Both modes end with a restart of Claude Code. The command only ever proposes an edit; Claude makes it with the Edit tool after you confirm, so a --permission-mode plan session shows the plan and changes nothing.
Uninstalling: run /jev-skill-suggestion:setup restore first, or your skills stay hidden from the model with nothing left to inject them.
Pairs with jev-model-router, which asks the same decision model which model and effort a prompt deserves; the two share
hooks/jev-skill-suggestion.ts 571 lines1/**
2 * jev-skill-suggestion — Claude Mod
3 *
4 * Takes the skill listing out of the context window and has TypeSafe's Jev,
5 * a System One decision model, suggest at most one skill per prompt, going
6 * by the skills' descriptions. The skills stay installed and loadable; what
7 * goes away is the listing the engine sends the model every session, one
8 * line per skill, whether the prompt has anything to do with any of them.
9 *
10 * The decision follows TypeSafe's "Skill suggestion" cookbook: two requests
11 * per prompt, one to rank every skill and ask whether the prompt needs a
12 * skill at all, one to re-read the top few with their full text and let each
13 * be rejected on its own. Either may come back empty-handed.
14 *
15 * Three hooks:
16 * prompt.attachment — the engine's `skill_listing` attachment is answered
17 * with `{ text: null }` (left out) or trimmed to the
18 * names in `alwaysListed`. Its names are remembered:
19 * they are the engine's word on which skills the model
20 * may invoke.
21 * prompt.submit — the two requests run, and the winner (if any) is
22 * attached to the prompt as a `<skill_relevance>`
23 * block: with the skill's own SKILL.md inside it
24 * (`inject: "content"`, the default), so the skill
25 * loads even when `skillOverrides` hides it from the
26 * model, or with its name for the Skill tool
27 * (`inject: "suggest"`).
28 * skill.prompt — observation: whether the model took the suggestion,
29 * or loaded a skill on its own. Also writes the prompt
30 * of the plugin's own `/jev-skill-suggestion:setup`,
31 * which hides every skill from the engine's listing
32 * (user-invocable-only) once the person has seen the
33 * list and said yes; the model makes the edit with its
34 * own tools, so it shows and asks like any other.
35 *
36 * Jev is reached one of two ways, whichever key is configured: TypeSafe's
37 * own API (`typesafeApiKey`), which reports a calibrated confidence, or the
38 * Vercel AI Gateway (`gatewayApiKey`), which does not. With neither, the
39 * engine's own `$.model.classify` stands in with a single request and no
40 * gate, so the mod is useful without any account.
41 *
42 * The candidates come from `$.command.list()`, not from the listing: the
43 * listing is only rendered at the turn's first request, after `prompt.submit`
44 * has run, so the first prompt of a session would otherwise have nothing to
45 * choose from. The listing, once seen, narrows the candidates to what the
46 * engine itself would have shown.
47 *
48 * The second request reads the opening of each shortlisted skill's SKILL.md,
49 * found on disk by how Claude Code lays skills out (project and user
50 * `.claude/skills` and `.claude/commands`, a plugin's install path from
51 * `~/.claude/plugins/installed_plugins.json`). A body that cannot be found
52 * leaves that skill with its one-line description; nothing fails over it.
53 *
54 * Only the main conversation is handled. A subagent's own listing is left as
55 * the engine renders it: its prompt is a tool call's argument, not a
56 * `prompt.submit`, so nothing here could suggest for it.
57 *
58 * Every failure path is fail-open: a request that errors or runs past the
59 * latency budget lets the prompt through with no suggestion, and the listing
60 * hook always answers the same way, so the model's prompt cache holds.
61 *
62 * The API key comes from the plugin's options (userConfig "typesafeApiKey"
63 * or "gatewayApiKey"). Never hardcode it in this file.
64 *
65 * Needs Claude Code >= 2.1.278: the
66 * `prompt.attachment` event is that release's. Typed against Anthropic's
67 * declarations: https://github.com/anthropics/claude-code/tree/main/mods
68 *
69 * Privacy: with a key set, the prompt text, every candidate skill's name and
70 * description, and the opening of each shortlisted skill's SKILL.md are sent
71 * to whichever backend the key belongs to.
72 */
73import type { Register } from 'claude-code'
74import {
75 DEFAULT_BASE_URL,
76 DEFAULT_MODEL,
77 NONE,
78 SETUP_COMMAND,
79 builtinWide,
80 catalog,
81 classifyText,
82 commandLike,
83 decide,
84 describeRerank,
85 describeSetup,
86 describeStatus,
87 describeStillListed,
88 describeWide,
89 detailOf,
90 displayIds,
91 canonical,
92 endpoint,
93 injectionBlock,
94 installPathsOf,
95 parseListing,
96 parseNames,
97 passesGate,
98 pluginFileCandidates,
99 readRerank,
100 readSkillSettings,
101 modelInvocable,
102 readWide,
103 rerankQuestions,
104 requestBody,
105 requestHeaders,
106 selectProvider,
107 setupAborted,
108 setupInstructions,
109 setupPlan,
110 shortlistOf,
111 validBackup,
112 skillFileCandidates,
113 suggestionBlock,
114 syncedFileCandidates,
115 trimListing,
116 wideQuestions,
117} from './policy.ts'
118import type { Candidate, PolicyConfig, Provider, Rerank, Skill, Wide } from './policy.ts'
119
120/** Prompt origins that are not a task of the person's: nothing to suggest for. */
121const NOT_A_TASK = new Set([
122 'task-notification',
123 'peer',
124 'peer-send-message',
125 'projects-relay',
126 'observer',
127 'observer-activity',
128])
129
130export const register: Register = (on, options) => {
131 const text = (key: string, fallback: string) =>
132 typeof options[key] === 'string' && options[key] ? (options[key] as string) : fallback
133 const number = (key: string, fallback: number) =>
134 typeof options[key] === 'number' ? (options[key] as number) : fallback
135 const flag = (key: string, fallback: boolean) =>
136 typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
137
138 // TypeSafe's own API is preferred when both keys are set: it is the only
139 // one that reports a calibrated confidence. `provider` forces one,
140 // including "builtin" to use neither.
141 const typesafeKey = text('typesafeApiKey', '')
142 const gatewayKey = text('gatewayApiKey', '')
143 const forced = text('provider', 'auto')
144 const active: Provider | null = selectProvider(forced, typesafeKey, gatewayKey)
145
146 // Each backend keeps its own URL and model, so an override written for one
147 // can never be sent to the other when `auto` picks differently than expected.
148 const apiKey = active === 'typesafe' ? typesafeKey : active === 'gateway' ? gatewayKey : ''
149 const modelId = !active
150 ? ''
151 : active === 'typesafe'
152 ? text('typesafeModel', DEFAULT_MODEL.typesafe)
153 : text('gatewayModel', DEFAULT_MODEL.gateway)
154 const url = !active
155 ? ''
156 : active === 'typesafe'
157 ? endpoint('typesafe', text('typesafeBaseUrl', DEFAULT_BASE_URL.typesafe))
158 : endpoint('gateway', text('gatewayBaseUrl', DEFAULT_BASE_URL.gateway))
159
160 // A backend named in the options but missing its key degrades to the
161 // built-in classifier, which is silent; say so once, when a hook first runs.
162 let unusableReported = forced === 'auto' || forced === 'builtin' || active !== null
163
164 const hideListing = flag('hideListing', true)
165 // "content": the mod reads the chosen skill's SKILL.md and attaches it, so
166 // the skill loads even when the engine lists it as user-invocable-only or
167 // off. "suggest": the cookbook's block alone, and the model loads the skill
168 // with the Skill tool, which honours the engine's skillOverrides.
169 const injectContent = text('inject', 'content') !== 'suggest'
170 const alwaysListed = parseNames(text('alwaysListed', ''))
171 const neverSuggested = parseNames(text('neverSuggested', ''))
172 const rerankEnabled = flag('rerank', true)
173 const excerptChars = number('excerptChars', 700)
174 const timeoutMs = number('timeoutMs', 800)
175 const logDecisions = flag('logDecisions', true)
176 const policy: PolicyConfig = {
177 shortlist: Math.max(1, Math.round(number('shortlist', 3))),
178 gateThreshold: number('gateThreshold', 0.3),
179 fitsThreshold: number('fitsThreshold', 0.3),
180 }
181
182 // The names every skill_listing attachment carried so far. Once non-empty,
183 // only these are offered to the decision model: the listing is the engine's
184 // word on which skills the model is allowed to invoke, and `$.command.list()`
185 // also names commands the model may not.
186 const listed = new Set<string>()
187 // The skill suggested for the current prompt, so a skill.prompt that loads
188 // it can be told apart from one the model reached for on its own.
189 let suggested: string | null = null
190 // Each skill's SKILL.md as first found, with where, or null when nowhere:
191 // read once per session, since the second request wants it on every prompt
192 // it is on, and the injection wants it whole.
193 const files = new Map<string, { path: string; markdown: string } | null>()
194 // The skills whose instructions were already attached this session: a
195 // second time, the block only names the skill again.
196 const injected = new Set<string>()
197 // Said once, the first time a hook runs. A mod that loaded and one that
198 // never loaded are otherwise told apart only by the absence of later lines,
199 // and absence is not evidence: with the listing gone, silence is the norm.
200 let announced = false
201 // The setup hint, once per session.
202 let hintedSetup = false
203
204 // A skill whose frontmatter `name:` has spaces ("PocketBase API Rules") is
205 // reported by `$.command.list()` under that name, but the engine lists,
206 // runs and overrides it by its directory name (`pb-api-rules`). The map
207 // from one to the other is read from disk once per session, in whichever
208 // hook first needs it.
209 let displayToId: Map<string, string> | null = null
210
211 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
212 if (!announced) {
213 announced = true
214 if (logDecisions) {
215 $.ui.log(`[jev-skill-suggestion] ${describeSetup(active, url, hideListing, forced === 'builtin')}`)
216 }
217 }
218
219 const skills = parseListing(e.text)
220 for (const skill of skills) listed.add(skill.name)
221
222 // With the mod loading skills itself, a listing that still names any is
223 // context the setup command would have saved: say so once.
224 if (injectContent && !hintedSetup && skills.length > 0 && !e.agentId) {
225 hintedSetup = true
226 if (logDecisions) $.ui.log(`[jev-skill-suggestion] ${describeStillListed(skills.length)}`)
227 }
228
229 // A subagent's listing is not ours: nothing here suggests for a subagent,
230 // so hiding its listing would leave it with no skills at all.
231 if (!hideListing || e.agentId) return next(e)
232
233 const kept = trimListing(e.text, alwaysListed)
234 if (logDecisions) {
235 const keptNames = kept
236 ? parseListing(kept)
237 .map((skill) => skill.name)
238 .join(', ')
239 : 'none'
240 $.ui.log(
241 `[jev-skill-suggestion] withheld the skill listing (${skills.length} skills, ${e.text.length} characters); kept listed: ${keptNames}`,
242 )
243 }
244 // Answered without `next`: the engine's text never reaches the model.
245 return { text: kept }
246 })
247
248 on('prompt.submit', async ($, e, next) => {
249 if (!announced) {
250 announced = true
251 if (logDecisions) {
252 $.ui.log(`[jev-skill-suggestion] ${describeSetup(active, url, hideListing, forced === 'builtin')}`)
253 }
254 }
255 suggested = null
256
257 // Notifications and peer messages are not tasks; a typed `/name` already
258 // names its skill. Neither gets a suggestion.
259 if (!e.text.trim() || /^\/\S/.test(e.text.trim())) return next(e)
260 if (e.origin && NOT_A_TASK.has(e.origin.kind)) return next(e)
261
262 /** One request to the active backend, or null on timeout, error or a non-2xx. */
263 const ask = async (
264 prompt: string,
265 questions: Record<string, unknown>,
266 what: string,
267 ): Promise<string | null> => {
268 if (!active) return null
269 try {
270 const response = await Promise.race([
271 $.http.fetch(url, {
272 method: 'POST',
273 headers: requestHeaders(active, apiKey, modelId),
274 body: requestBody(active, prompt, questions, modelId),
275 }),
276 $.clock.sleep(timeoutMs),
277 ])
278 if (response && response.ok) return response.text
279 if (response) $.ui.log(`[jev-skill-suggestion] ${active} responded ${response.status} to the ${what}`)
280 else $.ui.log(`[jev-skill-suggestion] ${what} passed ${timeoutMs}ms; no suggestion`)
281 } catch (error) {
282 $.ui.log(`[jev-skill-suggestion] ${what} failed: ${String(error)}`)
283 }
284 return null
285 }
286
287 /** A skill's file, found on disk by Claude Code's layout, or null. */
288 const fileOf = async (
289 skill: Skill,
290 plugin: string | undefined,
291 ): Promise<{ path: string; markdown: string } | null> => {
292 const cached = files.get(skill.name)
293 if (cached !== undefined) return cached
294 let found: { path: string; markdown: string } | null = null
295 try {
296 const home = (await $.env.get('HOME')) ?? ''
297 const relative = skillFileCandidates(skill.name, plugin)
298 // The engine reads the project's `.claude/` (the working directory
299 // only, not its ancestors) and the user's.
300 const candidates = [...relative, ...(home ? relative.map((file) => `${home}/${file}`) : [])]
301 if (plugin && home) {
302 const installed = `${home}/.claude/plugins/installed_plugins.json`
303 if (await $.fs.exists(installed)) {
304 for (const path of installPathsOf(await $.fs.read(installed), plugin)) {
305 candidates.push(...pluginFileCandidates(path, skill.name, plugin))
306 }
307 }
308 }
309 // A claude.ai-synced skill sits under an account directory only
310 // `$.fs.list` can name.
311 if (home) {
312 const synced = `${home}/.claude/skills/synced`
313 if (await $.fs.exists(synced)) {
314 const accounts = (await $.fs.list(synced)).filter((entry) => entry.kind === 'dir').map((entry) => entry.name)
315 candidates.push(...syncedFileCandidates(home, accounts, skill.name))
316 }
317 }
318 for (const file of candidates) {
319 if (await $.fs.exists(file)) {
320 found = { path: file, markdown: await $.fs.read(file) }
321 break
322 }
323 }
324 } catch (error) {
325 $.ui.log(`[jev-skill-suggestion] could not read /${skill.name}: ${String(error)}`)
326 }
327 files.set(skill.name, found)
328 return found
329 }
330 /** The opening of a skill's body, or null when its file is nowhere. */
331 const bodyOf = async (skill: Skill, plugin: string | undefined): Promise<string | null> =>
332 (await fileOf(skill, plugin))?.markdown ?? null
333
334 if (!unusableReported) {
335 unusableReported = true
336 $.ui.log(`[jev-skill-suggestion] provider "${forced}" has no key set; using the built-in classifier`)
337 }
338
339 let commands: Awaited<ReturnType<typeof $.command.list>>
340 try {
341 commands = await $.command.list()
342 if (!displayToId && commands.some((command) => !commandLike(command.name))) {
343 const found: { dir: string; markdown: string }[] = []
344 for (const root of [await $.session.cwd(), (await $.env.get('HOME')) ?? '']) {
345 const dir = root && `${root}/.claude/skills`
346 if (!dir || !(await $.fs.exists(dir))) continue
347 for (const entry of await $.fs.list(dir)) {
348 const file = `${dir}/${entry.name}/SKILL.md`
349 if (entry.kind === 'dir' && (await $.fs.exists(file))) found.push({ dir: entry.name, markdown: await $.fs.read(file) })
350 }
351 }
352 displayToId = displayIds(found)
353 }
354 commands = canonical(commands, displayToId ?? new Map())
355 } catch (error) {
356 $.ui.log(`[jev-skill-suggestion] could not list the skills: ${String(error)}`)
357 return next(e)
358 }
359 // Loading the skill itself, the mod is not bound to what the engine would
360 // list: a skill hidden with skillOverrides is still a candidate.
361 const skills = catalog(commands, injectContent ? new Set() : listed, neverSuggested)
362 if (skills.length === 0) {
363 if (logDecisions) $.ui.log('[jev-skill-suggestion] no candidate skills; nothing to suggest')
364 return next(e)
365 }
366 const pluginOf = new Map(commands.map((command) => [command.name, command.plugin]))
367
368 // Request 1: rank everything, and ask whether the prompt wants a skill at all.
369 const startedAt = await $.clock.now()
370 let wide: Wide | null = null
371 if (active) {
372 const answer = await ask(e.text, wideQuestions(active, skills), 'ranking')
373 if (answer) wide = readWide(answer)
374 } else {
375 // No backend: the engine's own small-model classifier answers the same
376 // question, with the descriptions folded into the text it reads. One
377 // label, no gate, no rerank.
378 try {
379 const label = await $.model.classify(classifyText(e.text, skills), [
380 NONE,
381 ...skills.map((skill) => skill.name),
382 ])
383 wide = builtinWide(label)
384 } catch (error) {
385 $.ui.log(`[jev-skill-suggestion] built-in classifier failed: ${String(error)}`)
386 }
387 }
388 // What the decision model actually answered, whatever the policy then
389 // does with it. This is the line that proves the ranking ran.
390 if (logDecisions) {
391 const ms = (await $.clock.now()) - startedAt
392 $.ui.log(`[jev-skill-suggestion] jev: ${describeWide(wide, skills.length, ms)}`)
393 }
394
395 // Request 2: re-read the shortlist with each skill's full text, and let
396 // every candidate be rejected on its own.
397 let rerank: Rerank | null = null
398 let rerankAttempted = false
399 // Before the listing has been seen, `$.command.list()` may name a skill
400 // the model is not allowed to invoke; its own frontmatter tells.
401 const barred: string[] = []
402 if (active && rerankEnabled && wide && passesGate(wide, policy)) {
403 const candidates: Candidate[] = []
404 for (const skill of shortlistOf(wide, skills, policy.shortlist)) {
405 const body = await bodyOf(skill, pluginOf.get(skill.name))
406 if (!modelInvocable(body)) {
407 barred.push(skill.name)
408 continue
409 }
410 candidates.push({ ...skill, detail: detailOf(skill, body, excerptChars) })
411 }
412 if (candidates.length > 0) {
413 const rerankStartedAt = await $.clock.now()
414 rerankAttempted = true
415 const answer = await ask(e.text, rerankQuestions(active, candidates), 'rerank')
416 if (answer) rerank = readRerank(answer)
417 if (logDecisions) {
418 const ms = (await $.clock.now()) - rerankStartedAt
419 const read = candidates.filter((candidate) => files.get(candidate.name)).length
420 $.ui.log(
421 `[jev-skill-suggestion] jev: ${describeRerank(rerank, ms)} · ${read}/${candidates.length} bodies read`,
422 )
423 }
424 }
425 }
426
427 const offered = barred.length > 0 ? skills.filter((skill) => !barred.includes(skill.name)) : skills
428 let decision = decide(wide, rerank, offered, policy, rerankAttempted)
429 let pick = decision.name ? (skills.find((skill) => skill.name === decision.name) ?? null) : null
430 // The winner's own frontmatter has the last word, whichever path picked it.
431 if (pick && !barred.includes(pick.name) && !modelInvocable(await bodyOf(pick, pluginOf.get(pick.name)))) {
432 barred.push(pick.name)
433 decision = { name: null, reason: `/${pick.name} has disable-model-invocation` }
434 pick = null
435 }
436 if (logDecisions && barred.length > 0) {
437 $.ui.log(
438 `[jev-skill-suggestion] not model-invocable, left out: ${barred.map((name) => `/${name}`).join(', ')}`,
439 )
440 }
441 // A row in the transcript scrolls away; this line stays on screen.
442 if (logDecisions) $.ui.status(describeStatus(pick?.name ?? null))
443 if (logDecisions) {
444 $.ui.log(
445 pick
446 ? `[jev-skill-suggestion] suggesting /${pick.name}: ${decision.reason}`
447 : `[jev-skill-suggestion] no suggestion: ${decision.reason}`,
448 )
449 }
450
451 suggested = pick?.name ?? null
452 let block: string | null
453 if (injectContent && pick) {
454 const file = await fileOf(pick, pluginOf.get(pick.name))
455 const projectDir = await $.session.cwd()
456 block = injectionBlock(pick, file?.markdown ?? null, file?.path ?? null, projectDir, injected.has(pick.name))
457 if (logDecisions) {
458 $.ui.log(
459 file
460 ? injected.has(pick.name)
461 ? `[jev-skill-suggestion] /${pick.name} already injected this session; named again`
462 : `[jev-skill-suggestion] injected /${pick.name} from ${file.path} (${file.markdown.length} characters)`
463 : `[jev-skill-suggestion] no file found for /${pick.name}; suggested by name only`,
464 )
465 }
466 if (file) injected.add(pick.name)
467 } else {
468 block = suggestionBlock(pick, hideListing)
469 }
470 if (!block) return next(e)
471 // Attached on the way down: one block after the prompt as typed, read by
472 // the model and never shown to the person.
473 return next({ ...e, context: [...(e.context ?? []), block] })
474 })
475
476 // An injected skill lives in the conversation, not the process: `/clear`
477 // or a resume starts another under the same worker, and a compaction may
478 // summarize the block away. Either way the next pick goes in whole again.
479 on('session.end', async ($, e, next) => {
480 injected.clear()
481 suggested = null
482 return next(e)
483 })
484 on('session.compact', async ($, e, next) => {
485 if (!e.agentId) injected.clear()
486 return next(e)
487 })
488
489 on('skill.prompt', { skill: 'jev-skill-suggestion:setup' }, async ($, e, next) => {
490 // The plugin's own setup command: its markdown is a placeholder, and the
491 // prompt the model reads is written here, from the roster as the engine
492 // has it and the user settings as they are. The model does the editing
493 // with its own tools, so the change shows as a diff and asks permission.
494 const mode = /\brestore\b/i.test(e.text) ? 'restore' : 'apply'
495 // No plan from a partial roster or unreadable settings: the edit would
496 // hide too little, and the backup would save the wrong values.
497 let commands: Awaited<ReturnType<typeof $.command.list>> = []
498 try {
499 commands = await $.command.list()
500 if (!displayToId && commands.some((command) => !commandLike(command.name))) {
501 const found: { dir: string; markdown: string }[] = []
502 for (const root of [await $.session.cwd(), (await $.env.get('HOME')) ?? '']) {
503 const dir = root && `${root}/.claude/skills`
504 if (!dir || !(await $.fs.exists(dir))) continue
505 for (const entry of await $.fs.list(dir)) {
506 const file = `${dir}/${entry.name}/SKILL.md`
507 if (entry.kind === 'dir' && (await $.fs.exists(file))) found.push({ dir: entry.name, markdown: await $.fs.read(file) })
508 }
509 }
510 displayToId = displayIds(found)
511 }
512 commands = canonical(commands, displayToId ?? new Map())
513 } catch (error) {
514 $.ui.log(`[jev-skill-suggestion] setup: could not list the skills: ${String(error)}`)
515 return next({ ...e, text: setupAborted(`the skills could not be listed (${String(error)})`) })
516 }
517 const home = (await $.env.get('HOME')) ?? '~'
518 const settingsPath = `${home}/.claude/settings.json`
519 const backupPath = `${home}/.claude/jev-skill-suggestion.skill-overrides.backup.json`
520 let json: string | null = null
521 try {
522 if (await $.fs.exists(settingsPath)) json = await $.fs.read(settingsPath)
523 } catch (error) {
524 $.ui.log(`[jev-skill-suggestion] setup: could not read ${settingsPath}: ${String(error)}`)
525 return next({ ...e, text: setupAborted(`${settingsPath} exists but could not be read (${String(error)})`) })
526 }
527 const settings = readSkillSettings(json)
528 const plan = setupPlan(commands, settings, new Set([SETUP_COMMAND]))
529 // An earlier run's backup is reused only if it is one: a file that is
530 // not this mod's, or is corrupt, is nothing restore could apply, so no
531 // setup is built on top of it.
532 let backupExists = false
533 try {
534 backupExists = await $.fs.exists(backupPath)
535 if (backupExists && !validBackup(await $.fs.read(backupPath))) {
536 $.ui.log(`[jev-skill-suggestion] setup: ${backupPath} is not a valid backup`)
537 return next({
538 ...e,
539 text: setupAborted(
540 `${backupPath} exists but is not a backup this mod wrote (expected {"skillOverrides": {...}, "disableBundledSkills": true|false|null}); ask the user to inspect it and move it away, or fix it, before running the setup again`,
541 ),
542 })
543 }
544 } catch (error) {
545 $.ui.log(`[jev-skill-suggestion] setup: could not read ${backupPath}: ${String(error)}`)
546 return next({ ...e, text: setupAborted(`${backupPath} could not be read (${String(error)})`) })
547 }
548 if (logDecisions) {
549 $.ui.log(
550 `[jev-skill-suggestion] setup (${mode}): ${plan.hide.length} to hide, ${plan.alreadyHidden.length} already hidden, ${plan.locked.length} locked by a plugin`,
551 )
552 }
553 return next({ ...e, text: setupInstructions(mode, plan, settings, settingsPath, backupPath, backupExists) })
554 })
555
556 on('skill.prompt', async ($, e, next) => {
557 // Observation only: whether the model took the suggestion, or reached for
558 // a skill it was never told about, is the one measure of this mod's worth.
559 if (logDecisions && e.skill !== SETUP_COMMAND) {
560 const how =
561 suggested === e.skill
562 ? 'as suggested'
563 : suggested
564 ? `suggested was /${suggested}`
565 : 'nothing was suggested'
566 $.ui.log(`[jev-skill-suggestion] skill /${e.skill} loaded (${how})`)
567 }
568 return next(e)
569 })
570}
571hooks/policy.ts 935 lines1/**
2 * jev-skill-suggestion — pure decision logic.
3 *
4 * No `$` and no I/O here: this module reads the engine's skill listing, builds
5 * the two requests the decision API takes, reads their answers, and turns them
6 * into at most one skill name and the text that suggests it. The hooks module
7 * does every call on `$` at its own call site.
8 *
9 * The shape follows TypeSafe's "Skill suggestion" cookbook
10 * (https://docs.typesafe.ai/cookbooks/skill_suggestion):
11 *
12 * request 1 rank every skill (`which`, a Choice with each skill's one-line
13 * description as its criterion) and ask three Nouls about the
14 * request itself — whether it wants an action taken rather than
15 * an explanation given. Their mean is the gate.
16 * request 2 re-read the top few (`which` again, now with each skill's full
17 * description and the opening of its SKILL.md) and ask one Noul
18 * per candidate: does this skill do the specific thing asked?
19 * Every `fits` may come back low, and then nothing is suggested.
20 *
21 * Two backends speak to the same model with different wire shapes:
22 *
23 * typesafe POST https://api.typesafe.ai/v1/systemone
24 * `{ model, state, questions }`; a yes/no question is a `noul`
25 * and every answer carries its own `confidence`.
26 * gateway POST https://ai-gateway.vercel.sh/v4/ai/evaluation-model
27 * `{ state, questions }` with the model in a header; a yes/no
28 * question is a `boolean` answered as `probability`.
29 *
30 * The Gateway shape is not documented publicly; it was read from
31 * @ai-sdk/gateway and @ai-sdk/provider.
32 */
33
34export type Provider = 'typesafe' | 'gateway'
35
36/** One skill as the model could be told about it: its name and one line. */
37export interface Skill {
38 name: string
39 description: string
40}
41
42/** A shortlisted skill with the longer text the second request reads. */
43export interface Candidate extends Skill {
44 /** The full description and the opening of the skill's body, joined. */
45 detail: string
46}
47
48/** What the first request answered. */
49export interface Wide {
50 /** Every skill with the probability it was given, surest first. */
51 ranked: { name: string; probability: number | null }[]
52 /** The mean of the oriented gate nouls, or null when none was answered. */
53 gate: number | null
54 /** Each gate noul as answered, for the log. */
55 gateValues: Record<string, number>
56}
57
58/** What the second request answered. */
59export interface Rerank {
60 /** The candidate the Choice named. */
61 winner: string
62 /** Confidence in that choice, or null when the backend reported none. */
63 confidence: number | null
64 /** P(true) per candidate that it does the specific thing asked. */
65 fits: Record<string, number>
66}
67
68/** The label the built-in classifier answers when no skill applies. */
69export const NONE = 'none'
70
71export const DEFAULT_BASE_URL: Record<Provider, string> = {
72 typesafe: 'https://api.typesafe.ai',
73 gateway: 'https://ai-gateway.vercel.sh/v4/ai',
74}
75
76export const DEFAULT_MODEL: Record<Provider, string> = {
77 typesafe: 'jev-latest',
78 gateway: 'typesafe-ai/jev',
79}
80
81/**
82 * Which backend a configuration asks for, or null for the built-in
83 * classifier. `auto` prefers TypeSafe, since it is the only one that reports
84 * a calibrated confidence; a forced backend whose key is missing resolves to
85 * null rather than falling through to the other one's key.
86 */
87export function selectProvider(forced: string, typesafeKey: string, gatewayKey: string): Provider | null {
88 if (forced === 'builtin') return null
89 if (forced === 'typesafe') return typesafeKey ? 'typesafe' : null
90 if (forced === 'gateway') return gatewayKey ? 'gateway' : null
91 if (typesafeKey) return 'typesafe'
92 if (gatewayKey) return 'gateway'
93 return null
94}
95
96/** The full endpoint a backend posts to. */
97export function endpoint(provider: Provider, baseUrl: string): string {
98 const root = baseUrl.replace(/\/+$/, '')
99 return provider === 'typesafe' ? `${root}/v1/systemone` : `${root}/evaluation-model`
100}
101
102/** A comma-separated option as a set of trimmed, non-empty names. */
103export function parseNames(option: string): Set<string> {
104 return new Set(
105 option
106 .split(',')
107 .map((name) => name.trim())
108 .filter(Boolean),
109 )
110}
111
112/**
113 * Reads the engine's `skill_listing` attachment: a header line, then one
114 * `- name: description` per skill. A description may run over several lines,
115 * and an entry the engine trimmed to fit its budget has no description at
116 * all. Anything before the first entry is the header and is skipped.
117 */
118export function parseListing(text: string): Skill[] {
119 const skills: Skill[] = []
120 let current: Skill | null = null
121 for (const raw of text.split('\n')) {
122 const line = raw.trimEnd()
123 // A name has no spaces, so it runs up to the first `: `; a name that
124 // itself contains `:` (`engineering:code-review`) is kept whole.
125 const entry = /^- (\S+?)(?::\s(.*))?$/.exec(line)
126 if (entry) {
127 current = { name: entry[1] as string, description: (entry[2] ?? '').trim() }
128 skills.push(current)
129 } else if (current && line.trim()) {
130 current.description = `${current.description} ${line.trim()}`.trim()
131 }
132 }
133 return skills
134}
135
136/**
137 * The skills the decision model is offered: every command the person can run
138 * that the model may also call, less the built-ins (`/help`, `/clear`) and the
139 * names configured out. When the engine's listing has been seen, only skills
140 * it listed are offered: the listing is the engine's word on which skills the
141 * model is allowed to invoke.
142 */
143export function catalog(
144 commands: readonly { name: string; description: string; source: string }[],
145 listed: ReadonlySet<string>,
146 excluded: ReadonlySet<string>,
147): Skill[] {
148 const seen = new Set<string>()
149 const skills: Skill[] = []
150 for (const command of commands) {
151 if (command.source === 'builtin') continue
152 if (listed.size > 0 && !listed.has(command.name)) continue
153 if (excluded.has(command.name) || seen.has(command.name)) continue
154 seen.add(command.name)
155 skills.push({ name: command.name, description: command.description.trim() })
156 }
157 return skills
158}
159
160/**
161 * The listing with only `keep` left in it, or null when nothing is kept: what
162 * the model reads in place of the engine's full listing.
163 */
164export function trimListing(text: string, keep: ReadonlySet<string>): string | null {
165 if (keep.size === 0) return null
166 const kept = parseListing(text).filter((skill) => keep.has(skill.name))
167 if (kept.length === 0) return null
168 const header = text.split('\n').find((line) => line.trim() && !line.startsWith('- ')) ?? ''
169 return [header.trim(), '', ...kept.map(line)].join('\n')
170}
171
172function line(skill: Skill): string {
173 return skill.description ? `- ${skill.name}: ${skill.description}` : `- ${skill.name}`
174}
175
176/**
177 * Whether a skill or plugin name may be spliced into a path: the engine's
178 * names are directory basenames, so anything with a path separator or a
179 * `..` segment is refused rather than resolved.
180 */
181function safeName(name: string): boolean {
182 return name.length > 0 && !name.includes('/') && !name.includes('\\') && !name.split(':').includes('..')
183}
184
185/**
186 * The relative files a skill's body may live in, project or user level, by
187 * how Claude Code lays skills and commands out. A plugin's are under its
188 * install path instead, with the plugin prefix taken off the name.
189 */
190export function skillFileCandidates(name: string, plugin?: string): string[] {
191 if (!safeName(name) || (plugin !== undefined && !safeName(plugin))) return []
192 const short = plugin && name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : name
193 const asPath = short.replace(/:/g, '/')
194 const files = [
195 `.claude/skills/${short}/SKILL.md`,
196 `.claude/commands/${asPath}.md`,
197 `.claude/skills/${asPath}/SKILL.md`,
198 ]
199 if (plugin) {
200 // A plugin auto-loaded from a skills dir keeps its own `skills/` inside.
201 files.push(`.claude/skills/${plugin}/skills/${short}/SKILL.md`)
202 files.push(`.claude/skills/${plugin}/commands/${asPath}.md`)
203 }
204 return files
205}
206
207/** Whether a name is one the engine would run as `/name`: no whitespace. */
208export function commandLike(name: string): boolean {
209 return name.length > 0 && !/\s/.test(name)
210}
211
212/** The `name:` a SKILL.md's frontmatter declares, or null. */
213export function frontmatterName(markdown: string): string | null {
214 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
215 if (!frontmatter) return null
216 const field = /^name:\s*(.*)$/m.exec(frontmatter[1] as string)
217 if (!field) return null
218 const value = (field[1] as string).trim().replace(/^["']|["']$/g, '')
219 return value || null
220}
221
222/**
223 * Display name → directory name, from the skills found on disk: only the
224 * ones whose frontmatter `name:` differs from their directory, since those
225 * are the ones `$.command.list()` reports under a name the engine will not
226 * run, list or override. First found wins.
227 */
228export function displayIds(found: readonly { dir: string; markdown: string }[]): Map<string, string> {
229 const ids = new Map<string, string>()
230 for (const { dir, markdown } of found) {
231 const declared = frontmatterName(markdown)
232 if (declared && declared !== dir && !ids.has(declared)) ids.set(declared, dir)
233 }
234 return ids
235}
236
237/** Commands with every display name replaced by the engine's id. */
238export function canonical<T extends { name: string }>(commands: readonly T[], ids: ReadonlyMap<string, string>): T[] {
239 if (ids.size === 0) return [...commands]
240 return commands.map((command) => (ids.has(command.name) ? { ...command, name: ids.get(command.name) as string } : command))
241}
242
243/**
244 * A claude.ai-synced skill's file: Claude Code keeps them under
245 * `~/.claude/skills/synced/<account>/<skill>/SKILL.md`, listed to the model
246 * with a prefix (`anthropic-skills:pptx`) that is not on disk.
247 */
248export function syncedFileCandidates(home: string, accounts: readonly string[], name: string): string[] {
249 if (!safeName(name)) return []
250 const short = name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name
251 return accounts.filter(safeName).map((account) => `${home}/.claude/skills/synced/${account}/${short}/SKILL.md`)
252}
253
254/**
255 * The same files under a plugin's install path, as
256 * `~/.claude/plugins/installed_plugins.json` records it.
257 */
258export function pluginFileCandidates(installPath: string, name: string, plugin: string): string[] {
259 if (!safeName(name) || !safeName(plugin)) return []
260 const short = name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : name
261 const root = installPath.replace(/\/+$/, '')
262 return [`${root}/skills/${short}/SKILL.md`, `${root}/commands/${short.replace(/:/g, '/')}.md`]
263}
264
265/**
266 * The install paths `installed_plugins.json` records for a plugin name, any
267 * marketplace: the keys are `name@marketplace`.
268 */
269export function installPathsOf(installedJson: string, plugin: string): string[] {
270 let parsed: unknown
271 try {
272 parsed = JSON.parse(installedJson)
273 } catch {
274 return []
275 }
276 const plugins = (parsed as { plugins?: Record<string, { installPath?: string }[]> }).plugins
277 if (!plugins) return []
278 const paths: string[] = []
279 for (const [key, entries] of Object.entries(plugins)) {
280 if (key !== plugin && !key.startsWith(`${plugin}@`)) continue
281 for (const entry of entries ?? []) {
282 if (typeof entry?.installPath === 'string') paths.push(entry.installPath)
283 }
284 }
285 return paths
286}
287
288/**
289 * What the second request reads for one skill: its full frontmatter
290 * description, then the opening of its body with the frontmatter taken off.
291 * With no file found, the one-line description alone.
292 */
293export function detailOf(skill: Skill, markdown: string | null, excerptChars: number): string {
294 if (!markdown) return skill.description || `A skill named ${skill.name}.`
295 let body = markdown
296 let description = skill.description
297 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown)
298 if (frontmatter) {
299 body = markdown.slice(frontmatter[0].length)
300 const field = /^description:\s*(.*)$/m.exec(frontmatter[1] as string)
301 if (field) {
302 const value = (field[1] as string).trim().replace(/^["']|["']$/g, '')
303 if (value.length > description.length) description = value
304 }
305 }
306 const excerpt = body.trim().slice(0, Math.max(0, excerptChars))
307 if (!excerpt) return description || `A skill named ${skill.name}.`
308 return description ? `${description} — ${excerpt}` : excerpt
309}
310
311/**
312 * Whether a skill's own frontmatter lets the model invoke it. A skill with
313 * `disable-model-invocation: true` is left out of the engine's listing and
314 * refused by the Skill tool, so it must never be suggested; before the
315 * listing has been seen, this is the only way to tell. No file, or no such
316 * field, reads as invocable.
317 */
318/** A skill's body with its frontmatter taken off. */
319export function bodyOf(markdown: string): string {
320 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown)
321 return (frontmatter ? markdown.slice(frontmatter[0].length) : markdown).trim()
322}
323
324/** The directory a skill file lives in, for `${CLAUDE_SKILL_DIR}`. */
325export function dirOf(path: string): string {
326 const cut = path.lastIndexOf('/')
327 return cut > 0 ? path.slice(0, cut) : '.'
328}
329
330/**
331 * The block attached to the prompt when the mod loads the skill itself: the
332 * cookbook's line, then the skill's instructions as the engine would have
333 * rendered them, with the paths the engine substitutes. The Skill tool is
334 * not needed, and may refuse the skill when it is `user-invocable-only`, so
335 * the model is told not to reach for it. A skill already injected earlier in
336 * the session is only named again: the engine does the same on a byte-identical
337 * re-invocation.
338 */
339export function injectionBlock(
340 suggested: Skill,
341 markdown: string | null,
342 path: string | null,
343 projectDir: string,
344 alreadyLoaded: boolean,
345): string {
346 const lines = [
347 '<skill_relevance>',
348 `Relevant to the current request: ${suggested.name}. Ignore this if it does not fit what the user actually asked for.`,
349 ]
350 if (alreadyLoaded) {
351 lines.push(`Skill /${suggested.name} is already loaded above; instructions unchanged.`)
352 } else if (markdown && path) {
353 const dir = dirOf(path)
354 // Callbacks, so a path holding `$&` or `$1` goes in verbatim.
355 const body = bodyOf(markdown)
356 .replace(/\$\{CLAUDE_SKILL_DIR\}/g, () => dir)
357 .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, () => projectDir)
358 lines.push(
359 `Its instructions follow: follow them now, including any setup steps. Do not load it with the Skill tool (it is already loaded here, and the tool may refuse it). Its files are in ${dir}.`,
360 `<skill name="${suggested.name}" dir="${dir}">`,
361 body,
362 '</skill>',
363 )
364 } else {
365 // No file on disk (a synced or bundled skill the mod cannot read): the
366 // name and the way to load it are all there is.
367 lines.push(
368 `${line(suggested)}`,
369 `Load it with the Skill tool (skill: "${suggested.name}") before you start; if the tool refuses it, tell the user to type /${suggested.name}.`,
370 )
371 }
372 lines.push('</skill_relevance>')
373 return lines.join('\n')
374}
375
376export function modelInvocable(markdown: string | null): boolean {
377 if (!markdown) return true
378 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
379 if (!frontmatter) return true
380 return !/^disable-model-invocation:\s*true\s*$/m.test(frontmatter[1] as string)
381}
382
383/**
384 * The three questions about the request, each asking a different way whether
385 * it wants an action taken rather than an explanation given. Questions about
386 * subject matter would not separate "explain what a monad is" from a task
387 * that needs a skill, since both are software.
388 */
389export const GATE_QUESTIONS: Record<string, string> = {
390 acts_on_user_system:
391 "Is the assistant being asked to act on the user's files, accounts, devices, or online services, rather than only to explain or advise?",
392 would_follow_documented_procedure:
393 'Would a careful expert answering this consult a specific documented procedure or set of commands, rather than answering from general understanding?',
394 prose_suffices:
395 "Could a knowledgeable generalist fully satisfy this request in prose, with no tools, no documentation, and no access to the user's files or accounts?",
396}
397
398/** Gate questions where a yes points away from needing a skill. */
399const INVERTED = new Set(['prose_suffices'])
400
401/** The same yes/no question under two names. */
402function yesNo(provider: Provider, instructions: string): Record<string, unknown> {
403 return { type: provider === 'typesafe' ? 'noul' : 'boolean', instructions }
404}
405
406/** The first request's `questions`: the ranking and the gate. */
407export function wideQuestions(provider: Provider, skills: readonly Skill[]): Record<string, unknown> {
408 const criteria: Record<string, string> = {}
409 for (const skill of skills) criteria[skill.name] = skill.description || `A skill named ${skill.name}.`
410 const questions: Record<string, unknown> = {
411 which: {
412 type: 'choice',
413 instructions:
414 "Which of these skills, if any, is the right one to load to help with the user's latest request?",
415 criteria,
416 },
417 }
418 for (const [key, text] of Object.entries(GATE_QUESTIONS)) {
419 questions[`gate::${key}`] = yesNo(provider, text)
420 }
421 return questions
422}
423
424/** The second request's `questions`: the shortlist re-read, one `fits` each. */
425export function rerankQuestions(
426 provider: Provider,
427 candidates: readonly Candidate[],
428): Record<string, unknown> {
429 const criteria: Record<string, string> = {}
430 for (const candidate of candidates) criteria[candidate.name] = candidate.detail
431 const questions: Record<string, unknown> = {
432 which: {
433 type: 'choice',
434 instructions:
435 "Exactly one of these skills is the right one to load for the user's latest request. Which one? Read what each actually does, not just its name.",
436 criteria,
437 },
438 }
439 for (const candidate of candidates) {
440 questions[`fits::${candidate.name}`] = yesNo(
441 provider,
442 `Does the skill '${candidate.name}' do the specific thing the user's request asks for? It is described as: ${candidate.description || candidate.detail}`,
443 )
444 }
445 return questions
446}
447
448/** A request body. The Gateway carries the model in a header instead. */
449export function requestBody(
450 provider: Provider,
451 prompt: string,
452 questions: Record<string, unknown>,
453 model: string,
454): string {
455 const state = { request: prompt, recent_context: '' }
456 const body = provider === 'typesafe' ? { model, state, questions } : { state, questions }
457 return JSON.stringify(body)
458}
459
460/** The request headers. */
461export function requestHeaders(provider: Provider, apiKey: string, model: string): Record<string, string> {
462 const common = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }
463 if (provider === 'typesafe') return common
464 return {
465 ...common,
466 'ai-gateway-auth-method': 'api-key',
467 'ai-model-id': model,
468 'ai-evaluation-model-specification-version': '4',
469 }
470}
471
472type Answers = Record<string, Record<string, unknown>>
473
474function answersOf(responseText: string): Answers | null {
475 let parsed: unknown
476 try {
477 parsed = JSON.parse(responseText)
478 } catch {
479 return null
480 }
481 const answers = (parsed as { answers?: Answers }).answers
482 return answers && typeof answers === 'object' ? answers : null
483}
484
485/** P(true) of a yes/no answer: `noul` on TypeSafe, `probability` on the Gateway. */
486function yesNoOf(answer: Record<string, unknown> | undefined): number | null {
487 if (!answer) return null
488 if (typeof answer.noul === 'number') return answer.noul
489 if (typeof answer.probability === 'number') return answer.probability
490 return null
491}
492
493/**
494 * Reads the first request's answer. The ranking is the Choice's probability
495 * distribution, surest first; a backend that sends none ranks the named
496 * choice alone, at the reported confidence or none. The gate is the mean of
497 * the oriented nouls that were answered, or null when none was.
498 */
499export function readWide(responseText: string): Wide | null {
500 const answers = answersOf(responseText)
501 const which = answers?.which
502 if (!answers || !which || typeof which.choice !== 'string') return null
503
504 const probabilities = which.probabilities as Record<string, number> | undefined
505 const ranked: Wide['ranked'] = []
506 if (probabilities) {
507 for (const [name, probability] of Object.entries(probabilities)) {
508 if (typeof probability === 'number') ranked.push({ name, probability })
509 }
510 ranked.sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
511 }
512 if (ranked.length === 0) {
513 ranked.push({
514 name: which.choice,
515 probability: typeof which.confidence === 'number' ? which.confidence : null,
516 })
517 }
518
519 const gateValues: Record<string, number> = {}
520 const oriented: number[] = []
521 for (const key of Object.keys(GATE_QUESTIONS)) {
522 const value = yesNoOf(answers[`gate::${key}`])
523 if (value === null) continue
524 gateValues[key] = value
525 oriented.push(INVERTED.has(key) ? 1 - value : value)
526 }
527 const gate = oriented.length > 0 ? oriented.reduce((a, b) => a + b, 0) / oriented.length : null
528 return { ranked, gate, gateValues }
529}
530
531/** Reads the second request's answer. */
532export function readRerank(responseText: string): Rerank | null {
533 const answers = answersOf(responseText)
534 const which = answers?.which
535 if (!answers || !which || typeof which.choice !== 'string') return null
536 const fits: Record<string, number> = {}
537 for (const [key, answer] of Object.entries(answers)) {
538 if (!key.startsWith('fits::')) continue
539 const value = yesNoOf(answer)
540 if (value !== null) fits[key.slice('fits::'.length)] = value
541 }
542 return {
543 winner: which.choice,
544 confidence: typeof which.confidence === 'number' ? which.confidence : null,
545 fits,
546 }
547}
548
549/** The first request's answer as the built-in classifier can give it: one label, no gate. */
550export function builtinWide(label: string | undefined): Wide | null {
551 if (!label) return null
552 return {
553 ranked: label === NONE ? [] : [{ name: label, probability: null }],
554 gate: null,
555 gateValues: {},
556 }
557}
558
559/**
560 * The text the built-in classifier reads: the prompt and the catalog it must
561 * choose from, since `$.model.classify` takes bare labels and the descriptions
562 * are the whole point.
563 */
564export function classifyText(prompt: string, skills: readonly Skill[]): string {
565 return [
566 'Which skill, going by its description, should be loaded before working on the prompt below? Answer "none" unless the prompt is clearly the kind of task a description names.',
567 '',
568 'Skills:',
569 ...skills.map(line),
570 `- ${NONE}: no listed skill is about this prompt`,
571 '',
572 'Prompt:',
573 prompt,
574 ].join('\n')
575}
576
577export interface PolicyConfig {
578 /** How many of the ranking the second request re-reads. */
579 shortlist: number
580 /** The gate mean under which nothing is suggested. */
581 gateThreshold: number
582 /** The best `fits` under which the whole shortlist is dropped. */
583 fitsThreshold: number
584}
585
586/** The shortlist the second request reads: the top of the ranking, by name. */
587export function shortlistOf(wide: Wide, skills: readonly Skill[], count: number): Skill[] {
588 const byName = new Map(skills.map((skill) => [skill.name, skill]))
589 const picked: Skill[] = []
590 for (const entry of wide.ranked) {
591 const skill = byName.get(entry.name)
592 if (skill) picked.push(skill)
593 if (picked.length >= count) break
594 }
595 return picked
596}
597
598/** Whether the first request's answer is worth a second look at all. */
599export function passesGate(wide: Wide, config: PolicyConfig): boolean {
600 return wide.gate === null || wide.gate >= config.gateThreshold
601}
602
603export interface Suggestion {
604 /** The one skill to suggest, or null for "nothing here applies". */
605 name: string | null
606 /** Why, for the log line. */
607 reason: string
608}
609
610/**
611 * At most one skill name for a request. With the second request switched
612 * off, the top of the ranking stands, which is what the first request alone
613 * can say; a second request that was attempted and failed suggests nothing.
614 */
615export function decide(
616 wide: Wide | null,
617 rerank: Rerank | null,
618 skills: readonly Skill[],
619 config: PolicyConfig,
620 /**
621 * Whether a second request was made: with one attempted and no answer,
622 * nothing is suggested, since the first request's winner has not had its
623 * false-positive check. Off means the second request was never meant to
624 * run, and the top of the ranking is the whole answer.
625 */
626 rerankAttempted = false,
627): Suggestion {
628 if (!wide) return { name: null, reason: 'no answer' }
629 if (!passesGate(wide, config)) {
630 return {
631 name: null,
632 reason: `needs a skill ${(wide.gate as number).toFixed(2)} < ${config.gateThreshold}`,
633 }
634 }
635 const shortlist = shortlistOf(wide, skills, config.shortlist)
636 if (shortlist.length === 0) return { name: null, reason: 'nothing ranked' }
637
638 if (!rerank && rerankAttempted) return { name: null, reason: 'rerank gave no answer; no suggestion' }
639
640 if (rerank) {
641 const values = Object.values(rerank.fits)
642 const best = values.length > 0 ? Math.max(...values) : null
643 if (best !== null && best < config.fitsThreshold) {
644 return { name: null, reason: `nothing fits, best ${best.toFixed(2)} < ${config.fitsThreshold}` }
645 }
646 if (shortlist.some((skill) => skill.name === rerank.winner)) {
647 const fit = rerank.fits[rerank.winner]
648 return {
649 name: rerank.winner,
650 reason: `rerank of ${shortlist.length}${fit === undefined ? '' : `, fits ${fit.toFixed(2)}`}`,
651 }
652 }
653 return { name: null, reason: `rerank named ${rerank.winner}, not on the shortlist` }
654 }
655
656 const top = shortlist[0] as Skill
657 const probability = wide.ranked.find((entry) => entry.name === top.name)?.probability ?? null
658 return {
659 name: top.name,
660 reason: `top of ${wide.ranked.length}${probability === null ? '' : ` (${probability.toFixed(2)})`}, no rerank`,
661 }
662}
663
664/**
665 * The block attached to the prompt, in the cookbook's words. It can be
666 * ignored, because pushing harder wins compliance on wrong suggestions too.
667 * When the listing is still in place, a turn with nothing to suggest says so:
668 * sending nothing would leave "err on the side of loading" unopposed. With
669 * the listing withheld there is nothing to oppose, so nothing is sent.
670 */
671export function suggestionBlock(suggested: Skill | null, hidden: boolean): string | null {
672 if (!suggested) {
673 return hidden
674 ? null
675 : '<skill_relevance>\nNo skill in the roster appears relevant to this request.\n</skill_relevance>'
676 }
677 const lines = [
678 '<skill_relevance>',
679 `Relevant to the current request: ${suggested.name}. Ignore this if it does not fit what the user actually asked for.`,
680 ]
681 if (hidden) {
682 // The model has no listing to look the name up in, so the line it would
683 // have found there and the way to load it come along.
684 lines.push(
685 `${line(suggested)}`,
686 'Load it with the Skill tool (skill: "' +
687 suggested.name +
688 '") before you start. The full skill listing is withheld from your context; the user can invoke any skill by typing /name.',
689 )
690 }
691 lines.push('</skill_relevance>')
692 return lines.join('\n')
693}
694
695/** A number for the log, or `n/d` when the backend reported none. */
696function reported(value: number | null): string {
697 return value === null ? 'n/d' : value.toFixed(2)
698}
699
700/**
701 * The one-time line that says the mod is alive, which backend answers it,
702 * and whether the listing is being withheld.
703 */
704export function describeSetup(
705 provider: Provider | null,
706 url: string,
707 hideListing: boolean,
708 builtinByChoice = false,
709): string {
710 const backend = provider
711 ? `${provider} (${url})`
712 : builtinByChoice
713 ? 'the built-in classifier, by choice'
714 : 'the built-in classifier, no key set'
715 const listing = hideListing ? 'withholding the skill listing' : 'leaving the skill listing in place'
716 return `ready on ${backend}; ${listing}`
717}
718
719/** What the first request answered: the gate and the top of the ranking. */
720export function describeWide(wide: Wide | null, candidates: number, ms: number | null): string {
721 const took = ms === null ? '' : ` · ${Math.round(ms)}ms`
722 if (!wide) return `no answer from ${candidates} candidates${took}`
723 const top = wide.ranked
724 .slice(0, 3)
725 .map((entry) => `${entry.name} (${reported(entry.probability)})`)
726 .join(', ')
727 return `needs a skill ${reported(wide.gate)} · top of ${candidates}: ${top || 'none'}${took}`
728}
729
730/** What the second request answered: the winner and every `fits`. */
731export function describeRerank(rerank: Rerank | null, ms: number | null): string {
732 const took = ms === null ? '' : ` · ${Math.round(ms)}ms`
733 if (!rerank) return `rerank: no answer${took}`
734 const fits = Object.entries(rerank.fits)
735 .map(([name, value]) => `${name} ${value.toFixed(2)}`)
736 .join(', ')
737 return `rerank → ${rerank.winner} (${reported(rerank.confidence)}) · fits ${fits || 'n/d'}${took}`
738}
739
740/**
741 * The persistent status line: the last thing the mod did, short enough to
742 * sit on screen beside the engine's own notices.
743 */
744export function describeStatus(suggested: string | null): string {
745 return suggested ? `jev · skill: ${suggested}` : 'jev · no skill'
746}
747
748/** The name of the plugin's own setup command, as the engine runs it. */
749export const SETUP_COMMAND = 'jev-skill-suggestion:setup'
750
751/** A user settings file's parts the setup touches. */
752export interface SkillSettings {
753 skillOverrides: Record<string, string>
754 disableBundledSkills: boolean | undefined
755}
756
757/** Reads the two fields from `~/.claude/settings.json`; malformed reads as empty. */
758export function readSkillSettings(json: string | null): SkillSettings {
759 let parsed: unknown = null
760 try {
761 parsed = json ? JSON.parse(json) : null
762 } catch {
763 parsed = null
764 }
765 const settings = (parsed ?? {}) as { skillOverrides?: unknown; disableBundledSkills?: unknown }
766 const overrides: Record<string, string> = {}
767 if (settings.skillOverrides && typeof settings.skillOverrides === 'object') {
768 for (const [name, value] of Object.entries(settings.skillOverrides as Record<string, unknown>)) {
769 if (typeof value === 'string') overrides[name] = value
770 }
771 }
772 return {
773 skillOverrides: overrides,
774 disableBundledSkills: typeof settings.disableBundledSkills === 'boolean' ? settings.disableBundledSkills : undefined,
775 }
776}
777
778/**
779 * Whether a file is this mod's backup: `skillOverrides` an object of
780 * strings and `disableBundledSkills` a boolean or null. Anything else is
781 * not something `restore` could apply, so a setup must not build on it.
782 */
783export function validBackup(json: string): boolean {
784 let parsed: unknown
785 try {
786 parsed = JSON.parse(json)
787 } catch {
788 return false
789 }
790 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false
791 const { skillOverrides, disableBundledSkills } = parsed as Record<string, unknown>
792 if (!skillOverrides || typeof skillOverrides !== 'object' || Array.isArray(skillOverrides)) return false
793 if (!Object.values(skillOverrides as Record<string, unknown>).every((value) => typeof value === 'string')) return false
794 return disableBundledSkills === null || typeof disableBundledSkills === 'boolean'
795}
796
797export interface SetupPlan {
798 /** User and project skills the setup hides from the model (`user-invocable-only`). */
799 hide: string[]
800 /** Of those, the ones already hidden or off: nothing to change. */
801 alreadyHidden: string[]
802 /** A plugin's skills: `skillOverrides` cannot touch them, only `/plugin` can. */
803 locked: string[]
804 /** Whether bundled skills still need `disableBundledSkills`. */
805 bundledStillOn: boolean
806}
807
808/**
809 * What the setup has to change, from the roster and the settings as they
810 * are. Every user-level command is listed, so the person sees the whole set
811 * before anything is written.
812 */
813export function setupPlan(
814 commands: readonly { name: string; source: string }[],
815 settings: SkillSettings,
816 excluded: ReadonlySet<string>,
817): SetupPlan {
818 const hide: string[] = []
819 const alreadyHidden: string[] = []
820 const locked: string[] = []
821 const seen = new Set<string>()
822 for (const command of commands) {
823 if (seen.has(command.name) || excluded.has(command.name)) continue
824 seen.add(command.name)
825 if (command.source === 'plugin') locked.push(command.name)
826 else if (command.source === 'user') {
827 const state = settings.skillOverrides[command.name] ?? 'on'
828 if (state === 'user-invocable-only' || state === 'off') alreadyHidden.push(command.name)
829 else hide.push(command.name)
830 }
831 }
832 return { hide, alreadyHidden, locked, bundledStillOn: settings.disableBundledSkills !== true }
833}
834
835/**
836 * The prompt the model reads for `/jev-skill-suggestion:setup`: the plan in
837 * full, the exact edit, and the rule that nothing is written before the
838 * person has seen the list and said yes. The model edits the file with its
839 * own tools, so the change shows as a diff and asks permission like any edit.
840 */
841export function setupInstructions(
842 mode: 'apply' | 'restore',
843 plan: SetupPlan,
844 settings: SkillSettings,
845 settingsPath: string,
846 backupPath: string,
847 /**
848 * Whether a backup from an earlier run is already there. It holds the
849 * values from before the first run, which the current settings no longer
850 * do, so a rerun must leave it alone.
851 */
852 backupExists = false,
853): string {
854 const lines: string[] = ['<jev_skill_suggestion_setup>']
855 if (mode === 'restore') {
856 lines.push(
857 `The user asked to undo jev-skill-suggestion's setup: put their skills back the way they were before it ran.`,
858 `1. Read ${backupPath}. It holds {"skillOverrides": {...}, "disableBundledSkills": ...} as they were before the setup.`,
859 ` If it does not exist, say so and stop: there is nothing to restore.`,
860 `2. Show the user what will change in ${settingsPath}: the "skillOverrides" entries that go back to their saved value (an entry not in the backup is removed), and "disableBundledSkills" back to its saved value (removed when the backup says null).`,
861 `3. Ask the user to confirm. Only after a clear yes, edit ${settingsPath} with the Edit tool, changing nothing else in the file, then remove the backup by running exactly this command with the Bash tool: rm ~/.claude/jev-skill-suggestion.skill-overrides.backup.json`,
862 `4. Tell the user to restart Claude Code for /skills and /context to show the change.`,
863 '</jev_skill_suggestion_setup>',
864 )
865 return lines.join('\n')
866 }
867 const overrides: Record<string, string> = { ...settings.skillOverrides }
868 for (const name of plan.hide) overrides[name] = 'user-invocable-only'
869 const after = JSON.stringify({ skillOverrides: overrides, disableBundledSkills: true }, null, 2)
870 const backup = JSON.stringify(
871 { skillOverrides: settings.skillOverrides, disableBundledSkills: settings.disableBundledSkills ?? null },
872 null,
873 2,
874 )
875 lines.push(
876 `The user asked jev-skill-suggestion to take over skill selection: every skill is hidden from the model's listing (state "user-invocable-only": the user can still type /name) and the mod injects the one skill each prompt needs. Nothing here is written until the user has seen the list and said yes.`,
877 '',
878 `Skills that will be hidden from the model (${plan.hide.length}):`,
879 ...(plan.hide.length > 0 ? plan.hide.map((name) => `- ${name}`) : ['- (none)']),
880 )
881 if (plan.alreadyHidden.length > 0) {
882 lines.push('', `Already hidden, left as they are (${plan.alreadyHidden.length}):`, ...plan.alreadyHidden.map((name) => `- ${name}`))
883 }
884 lines.push(
885 '',
886 plan.bundledStillOn
887 ? `Claude Code's bundled skills (simplify, loop, init, ...) will be hidden too, by setting "disableBundledSkills": true.`
888 : `Claude Code's bundled skills are already disabled ("disableBundledSkills": true).`,
889 )
890 if (plan.locked.length > 0) {
891 lines.push(
892 '',
893 `Plugin skills cannot be hidden by skillOverrides; they stay listed unless the plugin is disabled in /plugin (${plan.locked.length}):`,
894 ...plan.locked.map((name) => `- ${name}`),
895 )
896 }
897 lines.push(
898 '',
899 'Steps:',
900 `1. Show the user the lists above, in their language, and say the change goes to ${settingsPath} (their user settings) and can be undone with /${SETUP_COMMAND} restore.`,
901 '2. Ask them to confirm. Do not edit anything before a clear yes.',
902 ...(backupExists
903 ? [
904 `3. ${backupPath} already exists from an earlier run and holds the values from before the first setup: do NOT overwrite or modify it.`,
905 ]
906 : [`3. After the yes, first write ${backupPath} with exactly this content (it is what restore reads):`, backup]),
907 `4. Then edit ${settingsPath} with the Edit tool (read it first; create it as {} if it does not exist) so that its top-level "skillOverrides" and "disableBundledSkills" become exactly:`,
908 after,
909 ' Change nothing else in the file. Keep every other top-level key as it is.',
910 '5. Tell the user to restart Claude Code: /skills will then show these skills as user-only and /context will count them at 0, while the mod keeps injecting the one skill a prompt needs.',
911 '</jev_skill_suggestion_setup>',
912 )
913 return lines.join('\n')
914}
915
916/**
917 * What the model reads when the setup cannot be planned: the roster or the
918 * settings could not be read, so no edit is proposed — an edit planned from
919 * a partial roster or empty settings would hide too little or back up the
920 * wrong values.
921 */
922export function setupAborted(reason: string): string {
923 return [
924 '<jev_skill_suggestion_setup>',
925 `The jev-skill-suggestion setup could not be prepared: ${reason}.`,
926 'Tell the user, and do not edit any settings file. They can fix the cause and run the command again.',
927 '</jev_skill_suggestion_setup>',
928 ].join('\n')
929}
930
931/** The hint logged while skills are still listed and the mod is meant to be the only source of them. */
932export function describeStillListed(count: number): string {
933 return `${count} skill${count === 1 ? ' is' : 's are'} still listed for the model (withheld here, but /context counts them); run /${SETUP_COMMAND} to hand their selection to the mod for good`
934}
935