SLOPSHOPPER

jev-skill-suggestion

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'…

newstatuspromptmodelnetwork
A shopper browsing a rack in a slop shop
README

jev-skill-suggestion

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:

BackendEndpointModelConfidence
typesafePOST api.typesafe.ai/v1/systemonejev-latestreported per answer
gatewayPOST ai-gateway.vercel.sh/v4/ai/evaluation-modeltypesafe-ai/jevderived 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.

Quick start

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:

  • the skills it will set to 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;
  • Claude Code's bundled skills (simplify, loop, init, …), turned off together with disableBundledSkills: true;
  • the skills a plugin ships, which 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.

How it works

Three hooks, two on the way in and one on the way out:

HookWhat it does
prompt.attachment on skill_listingAnswers 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.submitRuns 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.promptObservation: 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.

How it decides

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:

noulasks
acts_on_user_systemIs 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_procedureWould a careful expert consult a specific documented procedure or set of commands, rather than answer from general understanding?
prose_sufficesCould 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.

  • The Gateway answers a 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.
  • A skill whose frontmatter says 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).
  • The built-in classifier answers one label from the descriptions, with no gate and no second request.

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).

What you see in the transcript

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
  • The first line appears once per session, the first time a hook runs. It is the proof the module loaded and which backend answers it.
  • The two 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:

  1. You ran 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.
  2. The plugin was never loaded. Claude Code adopts a plugin from a project's .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.
  3. Claude Code is too old. Mods are on by default from 2.1.287; on an older build the debug log says 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).

Checking that it works

Three places tell you different things, and only one of them is what the model was actually sent:

WhereWhat it showsReflects the mod?
/skillseach skill's state (on, name-only, user-only, off) and its estimated listing costafter setup: the hidden ones read user-only
/context → Skillsan estimate rendered from the roster, without asking the prompt.attachment hooksno 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 requestwhat the model readyes: 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.

Privacy

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.

Options

  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.

Install

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

Source 2 files
hooks/jev-skill-suggestion.ts 571 lines
1/**
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}
571
hooks/policy.ts 935 lines
1/**
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