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

newguardstatusprompttoolmodel
v0.6.1MITupdated 2026-10-15audichuang/jev-skill-suggestion
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-skill-suggestion
› fix the failing auth test and add an audit log call ● jev-skill-suggestion: [jev-skill-suggestion] find_skill not offered: it needs a TypeSafe or Gateway key and rerank on ● jev-skill-suggestion: [jev-skill-suggestion] ready on the built-in classifier, no key set; leaving the skill listing in place ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ● jev-skill-suggestion: [jev-skill-suggestion] no suggestion: no candidate skills ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ jev-skill-suggestion: jev · no skill
README

jev-skill-suggestion

TypeSafe Jev, a System One decision model, picks at most one skill for each prompt you send and attaches that skill's SKILL.md to the prompt. Every other skill stays loadable: the model still sees each one by name and can load it with the Skill tool, so a skill that says "now use /other-skill" keeps working.

This is a Claude Code mod (a plugin whose behaviour lives in a hooks module). It started as a fork of jev-skill-suggestion in davila7/claude-code-templates at 55457cfcc7 (tag upstream-55457cf in this repository). See What changed from upstream.

Why the fork

Upstream's setup set every skill to user-invocable-only and turned on disableBundledSkills. That takes skills off the listing, and it also makes the Skill tool refuse every hidden skill. So the model could use only the one skill the mod injected (upstream #1013):

  • a skill that tells the model to load a second skill fails at that step;
  • a project rule such as "run /code-review before you commit" fails;
  • subagents cannot load any hidden skill;
  • when Jev picks the wrong skill, or none, the model cannot load the right one;
  • /simplify, /loop and the other bundled skills disappear, typed or not.

Claude Code has a gentler state: "name-only" in skillOverrides keeps the skill in the listing as - name, without its description, and the Skill tool still loads it. That keeps most of the context saving, because the descriptions are most of the listing. This fork's setup writes name-only, keeps the listing in place by default, and never touches the bundled skills.

Install

Requires Claude Code 2.1.287 or newer (mods load by default from there). The find_skill tool was verified on 2.1.299.

From the marketplace (any machine):

/plugin install jev-skill-suggestion --marketplace audichuang/jev-skill-suggestion

or from a shell:

claude plugin marketplace add audichuang/jev-skill-suggestion
claude plugin install jev-skill-suggestion@jev-skill-suggestion

From a clone, for every project (what you want while you edit it):

git clone https://github.com/audichuang/jev-skill-suggestion
ln -s "$PWD/jev-skill-suggestion" ~/.claude/skills/jev-skill-suggestion

A plugin folder under ~/.claude/skills/ loads in every project as jev-skill-suggestion@skills-dir. Under a project's own .claude/skills/ it loads only there, and only once you trust the project. Keep one copy: two plugins with the same name shadow each other, and /plugin reports the one it dropped.

Switching from npx claude-code-templates --mod: delete the project's .claude/skills/jev-skill-suggestion first. If you ran upstream's setup, change its user-invocable-only entries in ~/.claude/settings.json to name-only by hand (this fork's setup leaves existing user-invocable-only entries alone), and remove "disableBundledSkills": true if you want the bundled skills back.

Configure

The key. Set TYPESAFE_API_KEY (or AI_GATEWAY_API_KEY for the Vercel AI Gateway) in the environment Claude Code starts from, and the mod uses it. A key in the plugin options wins over the environment. Options live in user settings only (~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json), under the plugin's id:

{ "pluginConfigs": { "jev-skill-suggestion@skills-dir": { "options": { "rerankTimeoutMs": 2500 } } } }

The id follows how the plugin was loaded: jev-skill-suggestion@skills-dir from a skills folder, jev-skill-suggestion@jev-skill-suggestion from the marketplace, jev-skill-suggestion with --plugin-dir. Under the wrong id every option stays at its default.

Two things to know about a key stored as an option. Claude Code reads a plaintext key from settings.json, but a value saved through /config goes to the keychain, wins over the file, and removes the plaintext copy. The environment variable avoids both: it never lands in a settings file you might commit.

With no key at all, the mod falls back to Claude Code's built-in classifier: one request, no gate, no rerank, and no time limit.

Trim the listing (optional). Run /jev-skill-suggestion:setup. It shows the user skills it will set to name-only and asks before it edits anything. It writes the old values to jev-skill-suggestion.skill-overrides.backup.json next to your settings. /jev-skill-suggestion:setup restore puts them back. Skills that are already name-only, user-invocable-only or off stay as they are. Plugin skills cannot be trimmed this way. Project skills (.claude/skills in the project) are left with their descriptions: they are the few that project needs most, and you choose how many there are. An earlier version did trim them; to give them back their descriptions, delete their entries from skillOverrides.

Restart Claude Code after either step.

Check that it works

Send a prompt in any project. The transcript should show:

[jev-skill-suggestion] ready on typesafe (https://api.typesafe.ai/v1/systemone); leaving the skill listing in place
[jev-skill-suggestion] jev: needs a skill 0.76 · top of 58: pptx-author (0.70), … · 160ms
  • ready on the built-in classifier, no key set: the key was not found. Check the environment Claude Code was started from, or the pluginConfigs id.
  • typesafe responded 401 to the ranking: the key is wrong. The prompt goes through with no suggestion.
  • No lines at all: the plugin did not load. Run claude --debug and look for hooks module jev-skill-suggestion@… loaded.

How it works

HookWhat it does
prompt.submitRuns the two requests below on the prompt you sent. It attaches the winner, if any, as a <skill_relevance> block the model reads and you never see. With inject: "content" (the default) the block holds the skill's SKILL.md; with inject: "suggest" it holds only the name, and the model loads the skill itself.
prompt.attachment on skill_listingLeaves the listing alone by default. With hideListing: true it withholds the listing, or trims it to the names in alwaysListed.
session.start, tool.describe, tool.callThe find_skill tool (see below): registered when the session starts, kept out of ToolSearch so the model sees its description from the first request, and answered with the same two requests.
skill.promptLogs whether a skill the model loaded was the suggested one, and writes the prompt of /jev-skill-suggestion:setup.

When it decides. Once per prompt you send, from that prompt's text alone. A long turn can run into sub-tasks the prompt never named, and with name-only the model sees those skills by name only. For that, the mod gives the model a find_skill tool. The model calls it with the sub-task in one sentence, and Jev answers with at most one skill: its name and the description the listing leaves out. The model then loads it with the Skill tool, or ignores it. Nothing is pushed mid-turn. A skill that hands off to another skill works as it does without the mod.

find_skill offers only skills the main conversation's listing names, because the Skill tool loads only those. It leaves out the gate questions a prompt must pass: a model that calls find_skill has already decided it wants a skill, and one sub-task sentence without its conversation scores low on them. The rerank's fits check is then the only check, so a pick the rerank never read is not answered, and the tool is offered only with a TypeSafe or Gateway key and rerank on. When a request fails or times out, it says the lookup is unavailable rather than "no skill fits". It answers a name and never a SKILL.md: a skill loaded with the Skill tool is attached again after a compaction, and one attached by the mod is not. findSkill: false turns the tool off. The tool's description and schema cost about 160 tokens in every session.

What it attaches:

<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 again with the Skill tool: it is already loaded here. 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>

A skill already injected is only named again on later prompts, until /clear, a resume or a compaction. When nothing is picked, nothing is attached, so the model is free to load a skill from the listing.

Candidates. Every skill $.command.list() reports, except built-ins, names in neverSuggested, and skills set to off in your user settings. A skill whose frontmatter says disable-model-invocation: true is never picked.

Known limits.

  • An injected skill skips what the Skill tool does: the skill's allowed-tools are not applied.
  • The mod cannot see which skills the model loaded itself, so a skill the model already loaded can be injected a second time later.
  • TypeSafe's choice question takes at most 255 options, so a roster larger than that gets HTTP 400 (upstream #965).

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, four 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)
names_its_taskDoes this message on its own say what is to be done, specifically enough to start without reading earlier messages?

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. names_its_task is this fork's: Jev reads the prompt alone, and a follow-up such as "好幫我完成優化吧" carries its task in earlier turns, so without it such replies were ranked as if they were tasks (see evals/).

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.50) 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 (ranking) or rerankTimeoutMs (rerank), 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, a prompt that is itself a /name (its skill is already named), and a reply with no task of its own — no letters at all (2, 👍), or a stock go-ahead such as ok, continue, 好, 繼續. The decision model reads the prompt alone, so it could only guess at those; the model keeps whatever the task already loaded.

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); leaving the skill listing in place
[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] 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 appears only with hideListing: true: the listing hook firing, with what it cost the context and what it kept. While some listed skills still bring their whole description, the mod adds N skills are still listed with a full description; run /jev-skill-suggestion:setup … once per session.
  • find_skill "<sub-task>": suggesting /name (or no suggestion, or lookup unavailable) is the model calling find_skill mid-turn, after the same jev: lines.
  • 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

Reading decisions back

The transcript lines scroll away, and a headless run writes them only to its debug log. With recordDecisions on (the default), every decision also goes to one file per session, ~/.claude/jev-skill-suggestion/decisions/<session id>.jsonl (under CLAUDE_CONFIG_DIR when set), one JSON object per line:

{"at":"2026-10-16T08:12:03.120Z","source":"prompt","text":"畫一張 web 服務的架構圖","pick":"drawio-skill","reason":"rerank of 3, fits 0.91","answered":true,"ms":1180,"model":"jev-1.13.0","gate":0.71,"gateValues":{…},"top":[{"name":"drawio-skill","probability":0.62},…],"rerank":{"winner":"drawio-skill","confidence":0.88,"fits":{…}}}
{"at":"2026-10-16T08:12:09.402Z","loaded":"drawio-skill","suggested":"drawio-skill"}

source is prompt or find_skill; a loaded line is the model calling the Skill tool, with the pick it was made under. The file holds the request text as typed: it never leaves the machine, and is there so a wrong suggestion can be read back and turned into a test case. Set recordDecisions: false to keep nothing.

To list every recorded decision, newest session files included:

cat ~/.claude/jev-skill-suggestion/decisions/*.jsonl | jq -c 'select(.source) | {source, pick, reason, text: .text[0:60]}'

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

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; empty reads TYPESAFE_API_KEY
  gatewayApiKey:    string  Vercel AI Gateway key; empty reads AI_GATEWAY_API_KEY
  provider:         string  "auto" | "typesafe" | "gateway" | "builtin"
  typesafeBaseUrl:  string  empty uses https://api.typesafe.ai
  typesafeModel:    string  empty uses jev-1.14.0
  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.md (default); "suggest" names it for the Skill tool
  hideListing:      boolean withhold the engine's skill listing (default false)
  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.5)
  excerptChars:     number  SKILL.md characters each candidate brings (default 700)
  alwaysListed:     string  comma-separated names kept when hideListing is on
  neverSuggested:   string  comma-separated names never offered to the decision model
  timeoutMs:        number  latency budget for the ranking request (default 800)
  rerankTimeoutMs:  number  latency budget for the rerank request (default 2500)
  findSkill:        boolean give the model the find_skill tool for mid-turn sub-tasks (default true; needs a key and rerank on)
  logDecisions:     boolean log each decision (default true)
  recordDecisions:  boolean keep every decision in a file per session (default true)
  sendRecentContext: boolean send the previous user message and the end of the last reply with the request (default true)

What changed from upstream

ChangeUpstream reference
Setup writes name-only, treats name-only as already done, and never sets disableBundledSkills. Restore leaves disableBundledSkills alone.#1013
hideListing defaults to false. The "run setup" hint fires only while listed skills still carry a description.#1013
Content mode attaches nothing when nothing is picked. Upstream attached "No skill in the roster appears relevant", which talked the model out of loading one itself.—
A key can come from TYPESAFE_API_KEY / AI_GATEWAY_API_KEY.#954 (closed upstream)
Gateway requests send ai-gateway-protocol-version.#1123
Setup trims user skills only: a project's own skills keep their descriptions, so the person decides how much of the listing they take. Upstream trims project skills too.#1123
The rerank request gets its own budget, rerankTimeoutMs, default 2500 ms.#962
Settings, skills, plugin records and the backup follow CLAUDE_CONFIG_DIR.#1042
Replies with no task of their own (ok, 2, 繼續) are not ranked.#1081
Skills set to off are never injected.—
A find_skill tool lets the model ask Jev mid-turn, so a sub-task that turns up after the prompt is not left to a guess from bare names.—
Every decision, with the request text and the ranking, is kept in a file per session (recordDecisions), so a wrong pick can be read back.—
The turn before the request (the previous user message and the end of the assistant's last reply) goes in recent_context, which upstream always sends empty (sendRecentContext).—
A fourth gate question (names_its_task), a structured fits question, fitsThreshold 0.5 and the model pinned to jev-1.14.0, all measured with evals/run.ts (see evals/README.md).Cookbook uses three gate nouls, fits 0.30 and jev-latest

To see upstream's later changes: gh api "repos/davila7/claude-code-templates/commits?path=cli-tool/components/mods/productivity/jev-skill-suggestion", then diff the new upstream copy against the upstream-* tag.

Tests

claude plugin test .
claude plugin validate .

License

MIT. Original work © Daniel (San) Ávila and the claude-code-templates contributors; changes © audichuang. See LICENSE.

Source 2 files
hooks/jev-skill-suggestion.ts 833 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 * The 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 *   session.start, tool.describe, tool.call
29 *                      — the `find_skill` tool: registered when the session
30 *                        starts, kept out of ToolSearch so the model sees its
31 *                        description, and answered by the same two requests
32 *                        on the sub-task the model names. The prompt.submit
33 *                        pick covers the prompt; this covers what turns up
34 *                        mid-turn, for a model that sees most skills by name
35 *                        only. It answers a name for the Skill tool, never a
36 *                        SKILL.md.
37 *   skill.prompt       — observation: whether the model took the suggestion,
38 *                        or loaded a skill on its own. Also writes the prompt
39 *                        of the plugin's own `/jev-skill-suggestion:setup`,
40 *                        which hides every skill from the engine's listing
41 *                        (user-invocable-only) once the person has seen the
42 *                        list and said yes; the model makes the edit with its
43 *                        own tools, so it shows and asks like any other.
44 *
45 * Jev is reached one of two ways, whichever key is configured: TypeSafe's
46 * own API (`typesafeApiKey`), which reports a calibrated confidence, or the
47 * Vercel AI Gateway (`gatewayApiKey`), which does not. With neither, the
48 * engine's own `$.model.classify` stands in with a single request and no
49 * gate, so the mod is useful without any account.
50 *
51 * The candidates come from `$.command.list()`, not from the listing: the
52 * listing is only rendered at the turn's first request, after `prompt.submit`
53 * has run, so the first prompt of a session would otherwise have nothing to
54 * choose from. The listing, once seen, narrows the candidates to what the
55 * engine itself would have shown.
56 *
57 * The second request reads the opening of each shortlisted skill's SKILL.md,
58 * found on disk by how Claude Code lays skills out (project and user
59 * `.claude/skills` and `.claude/commands`, a plugin's install path from
60 * `~/.claude/plugins/installed_plugins.json`). A body that cannot be found
61 * leaves that skill with its one-line description; nothing fails over it.
62 *
63 * Only the main conversation is handled. A subagent's own listing is left as
64 * the engine renders it: its prompt is a tool call's argument, not a
65 * `prompt.submit`, so nothing here could suggest for it. `find_skill`, where a
66 * subagent is given it, is the exception (unverified: calls carry `agentId`).
67 *
68 * Every failure path is fail-open: a request that errors or runs past the
69 * latency budget lets the prompt through with no suggestion, and the listing
70 * hook always answers the same way, so the model's prompt cache holds.
71 *
72 * The API key comes from the plugin's options (userConfig "typesafeApiKey"
73 * or "gatewayApiKey"). Never hardcode it in this file.
74 *
75 * Needs Claude Code >= 2.1.278: the
76 * `prompt.attachment` event is that release's. Typed against Anthropic's
77 * declarations: https://github.com/anthropics/claude-code/tree/main/mods
78 *
79 * Privacy: with a key set, the prompt text, every candidate skill's name and
80 * description, and the opening of each shortlisted skill's SKILL.md are sent
81 * to whichever backend the key belongs to.
82 */
83import type { EngineInterface, Register } from 'claude-code'
84import {
85  DEFAULT_BASE_URL,
86  DEFAULT_MODEL,
87  FIND_SKILL_TOOL,
88  NONE,
89  SETUP_COMMAND,
90  builtinWide,
91  catalog,
92  classifyText,
93  commandLike,
94  configDirOf,
95  contextFree,
96  decide,
97  describeRerank,
98  describeSetup,
99  describeStatus,
100  describeStillListed,
101  describeWide,
102  detailOf,
103  displayIds,
104  canonical,
105  endpoint,
106  findSkillResult,
107  injectionBlock,
108  installPathsOf,
109  parseListing,
110  parseNames,
111  passesGate,
112  pluginFileCandidates,
113  readRerank,
114  readSkillSettings,
115  modelInvocable,
116  readWide,
117  recentContextOf,
118  modelOf,
119  recordLine,
120  rerankQuestions,
121  requestBody,
122  requestHeaders,
123  selectProvider,
124  setupAborted,
125  setupInstructions,
126  setupPlan,
127  shortlistOf,
128  validBackup,
129  skillFileCandidates,
130  suggestionBlock,
131  syncedFileCandidates,
132  trimListing,
133  underConfigDir,
134  wideQuestions,
135} from './policy.ts'
136import type { Candidate, PolicyConfig, Provider, RecentContext, Rerank, Skill, Trace, Wide } from './policy.ts'
137
138/** Prompt origins that are not a task of the person's: nothing to suggest for. */
139const NOT_A_TASK = new Set([
140  'task-notification',
141  'peer',
142  'peer-send-message',
143  'projects-relay',
144  'observer',
145  'observer-activity',
146])
147
148/** A SKILL.md as found on disk. */
149type SkillFile = { path: string; markdown: string }
150
151/** The backend the keys select, with where and as which model it is asked. */
152type Backend = { active: Provider | null; apiKey: string; modelId: string; url: string }
153
154/** What `choose` answers. */
155interface Choice {
156  pick: Skill | null
157  /** Why, for the log line. */
158  reason: string
159  file: SkillFile | null
160  /** False when a request failed or timed out: "no pick" is then not an answer. */
161  answered: boolean
162  /** What the requests answered, for the record file; null when none was asked. */
163  trace: Trace | null
164}
165
166/** What `choose` reads from the options, and the session state it shares with the hooks. */
167interface ChooseContext {
168  readonly backendOf: (typesafeEnv: string | undefined, gatewayEnv: string | undefined) => Backend
169  readonly forced: string
170  readonly neverSuggested: ReadonlySet<string>
171  readonly logDecisions: boolean
172  readonly recordDecisions: boolean
173  readonly sendRecentContext: boolean
174  readonly timeoutMs: number
175  readonly rerankTimeoutMs: number
176  readonly rerankEnabled: boolean
177  readonly excerptChars: number
178  readonly policy: PolicyConfig
179  // Session state, filled in as hooks run.
180  files: Map<string, SkillFile | null>
181  unusableReported: boolean
182  displayToId: Map<string, string> | null
183  /** Each session's record file as written so far, by session id. */
184  records: Map<string, string>
185}
186
187/**
188 * The turn before `current` (null mid-turn), sent beside the request; null
189 * when off or unreadable, and for a subagent, whose conversation is not the
190 * session's transcript.
191 */
192async function contextFor(
193  $: EngineInterface,
194  ctx: ChooseContext,
195  current: string | null,
196  agentId?: string,
197): Promise<RecentContext | null> {
198  if (!ctx.sendRecentContext || agentId) return null
199  try {
200    return recentContextOf(await $.session.messages(), current)
201  } catch (error) {
202    $.ui.log(`[jev-skill-suggestion] could not read the transcript: ${String(error)}`)
203    return null
204  }
205}
206
207/** A record file stops growing here, under `$.fs`'s 4 MiB per read and write. */
208const RECORD_LIMIT = 3_500_000
209
210/**
211 * Appends one line to the session's record file,
212 * `<config dir>/jev-skill-suggestion/decisions/<session id>.jsonl`. `$.fs`
213 * writes whole files, so the content so far is kept in memory, read back from
214 * disk the first time (a resumed session). Fails open, like everything else.
215 */
216async function record($: EngineInterface, ctx: ChooseContext, entry: Parameters<typeof recordLine>[1]) {
217  if (!ctx.recordDecisions) return
218  try {
219    const configDir = await configDirFor($)
220    if (!configDir) return
221    const id = await $.session.id()
222    const path = `${configDir}/jev-skill-suggestion/decisions/${id}.jsonl`
223    let content = ctx.records.get(id)
224    if (content === undefined) content = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
225    const next = content + recordLine(await $.clock.now(), entry)
226    if (next.length > RECORD_LIMIT) return
227    ctx.records.set(id, next)
228    await $.fs.write(path, next)
229  } catch (error) {
230    $.ui.log(`[jev-skill-suggestion] could not record the decision: ${String(error)}`)
231  }
232}
233
234/** The user's config dir, or '' when HOME is unset and it cannot be named. */
235async function configDirFor($: EngineInterface): Promise<string> {
236  const home = (await $.env.get('HOME')) ?? ''
237  return home ? configDirOf(home, await $.env.get('CLAUDE_CONFIG_DIR')) : ''
238}
239
240/**
241 * Every command `$.command.list()` reports, under the id the engine lists,
242 * runs and overrides it by. Throws as `$.command.list()` does.
243 */
244async function listCommands($: EngineInterface, ctx: ChooseContext, configDir: string) {
245  const commands = await $.command.list()
246  if (!ctx.displayToId && commands.some((command) => !commandLike(command.name))) {
247    const found: { dir: string; markdown: string }[] = []
248    for (const dir of [`${await $.session.cwd()}/.claude/skills`, configDir && `${configDir}/skills`]) {
249      if (!dir || !(await $.fs.exists(dir))) continue
250      for (const entry of await $.fs.list(dir)) {
251        const file = `${dir}/${entry.name}/SKILL.md`
252        if (entry.kind === 'dir' && (await $.fs.exists(file))) found.push({ dir: entry.name, markdown: await $.fs.read(file) })
253      }
254    }
255    ctx.displayToId = displayIds(found)
256  }
257  return canonical(commands, ctx.displayToId ?? new Map())
258}
259
260/**
261 * Jev's pick for `text`, with the pick's SKILL.md when found, or null when
262 * the skills could not be listed. `restrictTo`, when
263 * non-empty, keeps the candidates to those names: the listing's, for a pick
264 * the model will load with the Skill tool (empty until the listing is seen).
265 * `askGate` off leaves the gate questions out, so the gate passes: a model
266 * that calls find_skill has already decided it wants a skill.
267 */
268async function choose(
269  $: EngineInterface,
270  ctx: ChooseContext,
271  text: string,
272  { restrictTo, askGate, context }: { restrictTo: ReadonlySet<string>; askGate: boolean; context: RecentContext | null },
273): Promise<Choice | null> {
274  const configDir = await configDirFor($)
275  const { active, apiKey, modelId, url } = ctx.backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY'))
276
277  /** One request to the active backend, or null on timeout, error or a non-2xx. */
278  const ask = async (
279    prompt: string,
280    questions: Record<string, unknown>,
281    what: string,
282    budgetMs: number,
283  ): Promise<string | null> => {
284    if (!active) return null
285    try {
286      const response = await Promise.race([
287        $.http.fetch(url, {
288          method: 'POST',
289          headers: requestHeaders(active, apiKey, modelId),
290          body: requestBody(active, prompt, questions, modelId, context),
291        }),
292        $.clock.sleep(budgetMs),
293      ])
294      if (response && response.ok) return response.text
295      if (response) $.ui.log(`[jev-skill-suggestion] ${active} responded ${response.status} to the ${what}`)
296      else $.ui.log(`[jev-skill-suggestion] ${what} passed ${budgetMs}ms; no suggestion`)
297    } catch (error) {
298      $.ui.log(`[jev-skill-suggestion] ${what} failed: ${String(error)}`)
299    }
300    return null
301  }
302
303  /** A skill's file, found on disk by Claude Code's layout, or null. */
304  const fileOf = async (
305    skill: Skill,
306    plugin: string | undefined,
307  ): Promise<SkillFile | null> => {
308    const cached = ctx.files.get(skill.name)
309    if (cached !== undefined) return cached
310    let found: SkillFile | null = null
311    try {
312      const relative = skillFileCandidates(skill.name, plugin)
313      // The engine reads the project's `.claude/` (the working directory
314      // only, not its ancestors) and the user's config dir.
315      const candidates = [...relative, ...(configDir ? relative.map((file) => underConfigDir(configDir, file)) : [])]
316      if (plugin && configDir) {
317        const installed = `${configDir}/plugins/installed_plugins.json`
318        if (await $.fs.exists(installed)) {
319          for (const path of installPathsOf(await $.fs.read(installed), plugin)) {
320            candidates.push(...pluginFileCandidates(path, skill.name, plugin))
321          }
322        }
323      }
324      // A claude.ai-synced skill sits under an account directory only
325      // `$.fs.list` can name.
326      if (configDir) {
327        const synced = `${configDir}/skills/synced`
328        if (await $.fs.exists(synced)) {
329          const accounts = (await $.fs.list(synced)).filter((entry) => entry.kind === 'dir').map((entry) => entry.name)
330          candidates.push(...syncedFileCandidates(configDir, accounts, skill.name))
331        }
332      }
333      for (const file of candidates) {
334        if (await $.fs.exists(file)) {
335          found = { path: file, markdown: await $.fs.read(file) }
336          break
337        }
338      }
339    } catch (error) {
340      $.ui.log(`[jev-skill-suggestion] could not read /${skill.name}: ${String(error)}`)
341    }
342    ctx.files.set(skill.name, found)
343    return found
344  }
345
346  if (!ctx.unusableReported && !active) {
347    ctx.unusableReported = true
348    $.ui.log(`[jev-skill-suggestion] provider "${ctx.forced}" has no key set; using the built-in classifier`)
349  }
350
351  let commands: Awaited<ReturnType<typeof $.command.list>>
352  try {
353    commands = await listCommands($, ctx, configDir)
354  } catch (error) {
355    $.ui.log(`[jev-skill-suggestion] could not list the skills: ${String(error)}`)
356    return null
357  }
358  // Loading the skill itself, the mod is not bound to what the engine would
359  // list, but a skill the person turned `off` stays off: the overrides are
360  // read from the user settings on every prompt, so an edit counts at once.
361  const off = new Set<string>()
362  try {
363    const settingsPath = `${configDir}/settings.json`
364    if (configDir && (await $.fs.exists(settingsPath))) {
365      const { skillOverrides } = readSkillSettings(await $.fs.read(settingsPath))
366      for (const [name, state] of Object.entries(skillOverrides)) if (state === 'off') off.add(name)
367    }
368  } catch (error) {
369    $.ui.log(`[jev-skill-suggestion] could not read the skill overrides: ${String(error)}`)
370  }
371  const skills = catalog(commands, restrictTo, new Set([...ctx.neverSuggested, ...off]))
372  if (skills.length === 0) {
373    return { pick: null, reason: 'no candidate skills', file: null, answered: true, trace: null }
374  }
375  const pluginOf = new Map(commands.map((command) => [command.name, command.plugin]))
376
377  // Request 1: rank everything, and ask whether the prompt wants a skill at all.
378  const startedAt = await $.clock.now()
379  let wide: Wide | null = null
380  let model: string | null = null
381  if (active) {
382    const answer = await ask(text, wideQuestions(active, skills, { askGate }), 'ranking', ctx.timeoutMs)
383    if (answer) {
384      wide = readWide(answer)
385      model = modelOf(answer)
386    }
387  } else {
388    // No backend: the engine's own small-model classifier answers the same
389    // question, with the descriptions folded into the text it reads. One
390    // label, no gate, no rerank.
391    try {
392      const label = await $.model.classify(classifyText(text, skills), [
393        NONE,
394        ...skills.map((skill) => skill.name),
395      ])
396      wide = builtinWide(label)
397    } catch (error) {
398      $.ui.log(`[jev-skill-suggestion] built-in classifier failed: ${String(error)}`)
399    }
400  }
401  // What the decision model actually answered, whatever the policy then
402  // does with it. This is the line that proves the ranking ran.
403  if (ctx.logDecisions) {
404    const ms = (await $.clock.now()) - startedAt
405    $.ui.log(`[jev-skill-suggestion] jev: ${describeWide(wide, skills.length, ms)}`)
406  }
407
408  // Request 2: re-read the shortlist with each skill's full text, and let
409  // every candidate be rejected on its own.
410  let rerank: Rerank | null = null
411  let rerankAttempted = false
412  // Before the listing has been seen, `$.command.list()` may name a skill
413  // the model is not allowed to invoke; its own frontmatter tells.
414  const barred: string[] = []
415  if (active && ctx.rerankEnabled && wide && passesGate(wide, ctx.policy)) {
416    const candidates: Candidate[] = []
417    for (const skill of shortlistOf(wide, skills, ctx.policy.shortlist)) {
418      const body = (await fileOf(skill, pluginOf.get(skill.name)))?.markdown ?? null
419      if (!modelInvocable(body)) {
420        barred.push(skill.name)
421        continue
422      }
423      candidates.push({ ...skill, detail: detailOf(skill, body, ctx.excerptChars) })
424    }
425    if (candidates.length > 0) {
426      const rerankStartedAt = await $.clock.now()
427      rerankAttempted = true
428      const answer = await ask(text, rerankQuestions(active, candidates), 'rerank', ctx.rerankTimeoutMs)
429      if (answer) rerank = readRerank(answer)
430      if (ctx.logDecisions) {
431        const ms = (await $.clock.now()) - rerankStartedAt
432        const read = candidates.filter((candidate) => ctx.files.get(candidate.name)).length
433        $.ui.log(
434          `[jev-skill-suggestion] jev: ${describeRerank(rerank, ms)} · ${read}/${candidates.length} bodies read`,
435        )
436      }
437    }
438  }
439
440  const offered = barred.length > 0 ? skills.filter((skill) => !barred.includes(skill.name)) : skills
441  let decision = decide(wide, rerank, offered, ctx.policy, rerankAttempted)
442  let pick = decision.name ? (skills.find((skill) => skill.name === decision.name) ?? null) : null
443  // With the gate left out, the rerank's `fits` is the only check against a
444  // wrong pick: a pick it never read (every shortlisted skill barred, so the
445  // ranking's next one stands) is no answer.
446  if (pick && !askGate && !rerank) {
447    decision = { name: null, reason: 'no rerank read the pick' }
448    pick = null
449  }
450  // The winner's own frontmatter has the last word, whichever path picked it.
451  if (pick && !barred.includes(pick.name) && !modelInvocable((await fileOf(pick, pluginOf.get(pick.name)))?.markdown ?? null)) {
452    barred.push(pick.name)
453    decision = { name: null, reason: `/${pick.name} has disable-model-invocation` }
454    pick = null
455  }
456  if (ctx.logDecisions && barred.length > 0) {
457    $.ui.log(
458      `[jev-skill-suggestion] not model-invocable, left out: ${barred.map((name) => `/${name}`).join(', ')}`,
459    )
460  }
461  const file = pick ? await fileOf(pick, pluginOf.get(pick.name)) : null
462  return {
463    pick,
464    reason: decision.reason,
465    file,
466    answered: wide !== null && (!rerankAttempted || rerank !== null),
467    trace: { wide, rerank, ms: (await $.clock.now()) - startedAt, model },
468  }
469}
470
471export const register: Register = (on, options) => {
472  const text = (key: string, fallback: string) =>
473    typeof options[key] === 'string' && options[key] ? (options[key] as string) : fallback
474  const number = (key: string, fallback: number) =>
475    typeof options[key] === 'number' ? (options[key] as number) : fallback
476  const flag = (key: string, fallback: boolean) =>
477    typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
478
479  const forced = text('provider', 'auto')
480
481  // TypeSafe's own API is preferred when both keys are set: it is the only
482  // one that reports a calibrated confidence. `provider` forces one,
483  // including "builtin" to use neither. A key option wins; with none, the
484  // key's environment variable (TYPESAFE_API_KEY, AI_GATEWAY_API_KEY) is
485  // read at the first hook, since only `$` can read the environment; the
486  // first answer stands for the process.
487  let backend: Backend | null = null
488  const backendOf = (typesafeEnv: string | undefined, gatewayEnv: string | undefined): Backend => {
489    if (backend) return backend
490    const typesafeKey = text('typesafeApiKey', '') || (typesafeEnv ?? '').trim()
491    const gatewayKey = text('gatewayApiKey', '') || (gatewayEnv ?? '').trim()
492    const active = selectProvider(forced, typesafeKey, gatewayKey)
493    // Each backend keeps its own URL and model, so an override written for one
494    // can never be sent to the other when `auto` picks differently than expected.
495    backend = {
496      active,
497      apiKey: active === 'typesafe' ? typesafeKey : active === 'gateway' ? gatewayKey : '',
498      modelId: !active
499        ? ''
500        : active === 'typesafe'
501          ? text('typesafeModel', DEFAULT_MODEL.typesafe)
502          : text('gatewayModel', DEFAULT_MODEL.gateway),
503      url: !active
504        ? ''
505        : active === 'typesafe'
506          ? endpoint('typesafe', text('typesafeBaseUrl', DEFAULT_BASE_URL.typesafe))
507          : endpoint('gateway', text('gatewayBaseUrl', DEFAULT_BASE_URL.gateway)),
508    }
509    return backend
510  }
511
512  // The listing stays by default: `name-only` overrides (what the setup
513  // writes) already take the descriptions out, and a listed name is what lets
514  // the model load a skill another skill names.
515  const hideListing = flag('hideListing', false)
516  // "content": the mod reads the chosen skill's SKILL.md and attaches it, so
517  // the skill loads even when the engine lists it as user-invocable-only or
518  // off. "suggest": the cookbook's block alone, and the model loads the skill
519  // with the Skill tool, which honours the engine's skillOverrides.
520  const injectContent = text('inject', 'content') !== 'suggest'
521  const alwaysListed = parseNames(text('alwaysListed', ''))
522  const neverSuggested = parseNames(text('neverSuggested', ''))
523  const rerankEnabled = flag('rerank', true)
524  const excerptChars = number('excerptChars', 700)
525  const timeoutMs = number('timeoutMs', 800)
526  // The second request carries each shortlisted SKILL.md's opening and one
527  // question per candidate, so it gets its own, longer budget.
528  const rerankTimeoutMs = number('rerankTimeoutMs', 2500)
529  const logDecisions = flag('logDecisions', true)
530  const recordDecisions = flag('recordDecisions', true)
531  const sendRecentContext = flag('sendRecentContext', true)
532  const findSkill = flag('findSkill', true)
533  const policy: PolicyConfig = {
534    shortlist: Math.max(1, Math.round(number('shortlist', 3))),
535    gateThreshold: number('gateThreshold', 0.3),
536    fitsThreshold: number('fitsThreshold', 0.5),
537  }
538
539  // The names every skill_listing attachment carried so far. Once non-empty,
540  // only these are offered to the decision model: the listing is the engine's
541  // word on which skills the model is allowed to invoke, and `$.command.list()`
542  // also names commands the model may not.
543  const listed = new Set<string>()
544  // The skill suggested for the current prompt, so a skill.prompt that loads
545  // it can be told apart from one the model reached for on its own.
546  let suggested: string | null = null
547  // Each skill's SKILL.md as first found, with where, or null when nowhere:
548  // read once per session, since the second request wants it on every prompt
549  // it is on, and the injection wants it whole.
550  const files = new Map<string, SkillFile | null>()
551  // The skills whose instructions were already attached this session: a
552  // second time, the block only names the skill again.
553  const injected = new Set<string>()
554  // Said once, the first time a hook runs. A mod that loaded and one that
555  // never loaded are otherwise told apart only by the absence of later lines,
556  // and absence is not evidence: with the listing gone, silence is the norm.
557  let announced = false
558  // The setup hint, once per session.
559  let hintedSetup = false
560
561  const state: ChooseContext = {
562    backendOf,
563    forced,
564    files,
565    neverSuggested,
566    logDecisions,
567    recordDecisions,
568    sendRecentContext,
569    timeoutMs,
570    rerankTimeoutMs,
571    rerankEnabled,
572    excerptChars,
573    policy,
574    // A backend named in the options but missing its key degrades to the
575    // built-in classifier, which is silent; say so once, when a hook first runs.
576    unusableReported: forced === 'auto' || forced === 'builtin',
577    // A skill whose frontmatter `name:` has spaces ("PocketBase API Rules") is
578    // reported by `$.command.list()` under that name, but the engine lists,
579    // runs and overrides it by its directory name (`pb-api-rules`). The map
580    // from one to the other is read from disk once per session, in whichever
581    // hook first needs it.
582    displayToId: null,
583    records: new Map(),
584  }
585
586  on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
587    if (!announced) {
588      announced = true
589      const { active, url } = backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY'))
590      if (logDecisions) {
591        $.ui.log(`[jev-skill-suggestion] ${describeSetup(active, url, hideListing, forced === 'builtin')}`)
592      }
593    }
594
595    const skills = parseListing(e.text)
596    // A subagent's listing may name skills the main conversation's does not.
597    if (!e.agentId) for (const skill of skills) listed.add(skill.name)
598
599    // A listed skill that still brings its whole description is context the
600    // setup command would trim to the name: say so once.
601    // ponytail: a `prefix:name` is taken for a plugin's skill, which
602    // skillOverrides cannot trim; read `$.command.list()` sources if a
603    // colon-named user skill ever shows up.
604    const described = skills.filter((skill) => skill.description && !skill.name.includes(':')).length
605    if (!hintedSetup && described > 0 && !e.agentId) {
606      hintedSetup = true
607      if (logDecisions) $.ui.log(`[jev-skill-suggestion] ${describeStillListed(described)}`)
608    }
609
610    // A subagent's listing is not ours: nothing here suggests for a subagent,
611    // so hiding its listing would leave it with no skills at all.
612    if (!hideListing || e.agentId) return next(e)
613
614    const kept = trimListing(e.text, alwaysListed)
615    if (logDecisions) {
616      const keptNames = kept
617        ? parseListing(kept)
618            .map((skill) => skill.name)
619            .join(', ')
620        : 'none'
621      $.ui.log(
622        `[jev-skill-suggestion] withheld the skill listing (${skills.length} skills, ${e.text.length} characters); kept listed: ${keptNames}`,
623      )
624    }
625    // Answered without `next`: the engine's text never reaches the model.
626    return { text: kept }
627  })
628
629  on('prompt.submit', async ($, e, next) => {
630    if (!announced) {
631      announced = true
632      const { active, url } = backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY'))
633      if (logDecisions) {
634        $.ui.log(`[jev-skill-suggestion] ${describeSetup(active, url, hideListing, forced === 'builtin')}`)
635      }
636    }
637    suggested = null
638
639    // Notifications and peer messages are not tasks; a typed `/name` already
640    // names its skill; "ok" or "繼續" carries no task the prompt alone shows.
641    // None of them gets a suggestion.
642    if (!e.text.trim() || /^\/\S/.test(e.text.trim())) return next(e)
643    if (e.origin && NOT_A_TASK.has(e.origin.kind)) return next(e)
644    if (contextFree(e.text)) {
645      if (logDecisions) $.ui.log('[jev-skill-suggestion] no suggestion: a reply with no task of its own')
646      return next(e)
647    }
648
649    // Content mode loads the skill itself, so it is not bound to what the
650    // engine lists; "suggest" leaves loading to the Skill tool, which is.
651    const context = await contextFor($, state, e.text)
652    const choice = await choose($, state, e.text, { restrictTo: injectContent ? new Set() : listed, askGate: true, context })
653    if (!choice) return next(e)
654    const { pick, file } = choice
655    await record($, state, { source: 'prompt', text: e.text, context, pick: pick?.name ?? null, reason: choice.reason, answered: choice.answered, trace: choice.trace })
656    // A row in the transcript scrolls away; this line stays on screen.
657    if (logDecisions) $.ui.status(describeStatus(pick?.name ?? null))
658    if (logDecisions) {
659      $.ui.log(
660        pick
661          ? `[jev-skill-suggestion] suggesting /${pick.name}: ${choice.reason}`
662          : `[jev-skill-suggestion] no suggestion: ${choice.reason}`,
663      )
664    }
665
666    suggested = pick?.name ?? null
667    let block: string | null
668    if (injectContent && pick) {
669      const projectDir = await $.session.cwd()
670      block = injectionBlock(pick, file?.markdown ?? null, file?.path ?? null, projectDir, injected.has(pick.name))
671      if (logDecisions) {
672        $.ui.log(
673          file
674            ? injected.has(pick.name)
675              ? `[jev-skill-suggestion] /${pick.name} already injected this session; named again`
676              : `[jev-skill-suggestion] injected /${pick.name} from ${file.path} (${file.markdown.length} characters)`
677            : `[jev-skill-suggestion] no file found for /${pick.name}; suggested by name only`,
678        )
679      }
680      if (file) injected.add(pick.name)
681    } else if (injectContent) {
682      // Content mode with nothing picked says nothing: "no skill is relevant"
683      // would talk the model out of loading one from the listing on its own.
684      block = null
685    } else {
686      block = suggestionBlock(pick, hideListing)
687    }
688    if (!block) return next(e)
689    // Attached on the way down: one block after the prompt as typed, read by
690    // the model and never shown to the person.
691    return next({ ...e, context: [...(e.context ?? []), block] })
692  })
693
694  // An injected skill lives in the conversation, not the process: `/clear`
695  // or a resume starts another under the same worker, and a compaction may
696  // summarize the block away. Either way the next pick goes in whole again.
697  on('session.end', async ($, e, next) => {
698    injected.clear()
699    suggested = null
700    return next(e)
701  })
702  on('session.compact', async ($, e, next) => {
703    if (!e.agentId) injected.clear()
704    return next(e)
705  })
706
707  // find_skill: the prompt's suggestion is made once, from the prompt alone;
708  // a sub-task that turns up mid-turn is the model's to look up. Registered
709  // at session.start so the tool list, and the prompt cache, stay put; the
710  // full name is the engine's (it depends on how the plugin was loaded).
711  let findSkillName = ''
712  on('session.start', async ($, e, next) => {
713    // Without a backend that reranks, nothing would check the ranking's pick,
714    // and the gate is left out: the tool is not offered.
715    const vetted = backendOf(await $.env.get('TYPESAFE_API_KEY'), await $.env.get('AI_GATEWAY_API_KEY')).active && rerankEnabled
716    if (findSkill && !vetted && logDecisions) {
717      $.ui.log('[jev-skill-suggestion] find_skill not offered: it needs a TypeSafe or Gateway key and rerank on')
718    }
719    if (findSkill && vetted) {
720      try {
721        findSkillName = (await $.tool.register(FIND_SKILL_TOOL)).tool
722      } catch (error) {
723        $.ui.log(`[jev-skill-suggestion] could not register find_skill: ${String(error)}`)
724      }
725    }
726    return next(e)
727  })
728  // Left to the engine, a plugin's tool waits behind ToolSearch, named but
729  // without its description: the model would have to guess at it, which is
730  // the very thing the tool is for.
731  on('tool.describe', async ($, e, next) => {
732    const out = await next(e)
733    return findSkillName && e.tool === findSkillName ? { ...out, isDeferred: false } : out
734  })
735  on('tool.call', async ($, e, next) => {
736    if (!findSkillName || e.tool !== findSkillName) return next(e)
737    const task = 'task' in e && typeof e.task === 'string' ? e.task.trim() : ''
738    // The model loads the answer with the Skill tool, so only what the
739    // engine lists is offered; before the listing is seen, nothing is.
740    let answer: Skill | null | 'unavailable' = 'unavailable'
741    try {
742      const context = await contextFor($, state, null, e.agentId)
743      const choice = task && listed.size > 0 ? await choose($, state, task, { restrictTo: listed, askGate: false, context }) : null
744      if (choice?.answered) answer = choice.pick
745      if (choice) {
746        const { reason, answered, trace } = choice
747        await record($, state, { source: 'find_skill', text: task, context, pick: choice.pick?.name ?? null, reason, answered, trace, agentId: e.agentId })
748      }
749    } catch (error) {
750      $.ui.log(`[jev-skill-suggestion] find_skill failed: ${String(error)}`)
751    }
752    if (logDecisions) {
753      const outcome = answer === 'unavailable' ? 'lookup unavailable' : answer ? `suggesting /${answer.name}` : 'no suggestion'
754      $.ui.log(`[jev-skill-suggestion] find_skill${e.agentId ? ` (${e.agentId})` : ''} "${task}": ${outcome}`)
755    }
756    // The latest pick, prompt's or find_skill's, is what a skill.prompt that
757    // follows is measured against: without it, a skill loaded on
758    // find_skill's word would be logged as one nothing suggested.
759    if (answer && answer !== 'unavailable') suggested = answer.name
760    return { result: findSkillResult(answer) }
761  })
762
763  on('skill.prompt', { skill: 'jev-skill-suggestion:setup' }, async ($, e, next) => {
764    // The plugin's own setup command: its markdown is a placeholder, and the
765    // prompt the model reads is written here, from the roster as the engine
766    // has it and the user settings as they are. The model does the editing
767    // with its own tools, so the change shows as a diff and asks permission.
768    const mode = /\brestore\b/i.test(e.text) ? 'restore' : 'apply'
769    // No plan from a partial roster or unreadable settings: the edit would
770    // hide too little, and the backup would save the wrong values.
771    let commands: Awaited<ReturnType<typeof $.command.list>> = []
772    try {
773      commands = await listCommands($, state, await configDirFor($))
774    } catch (error) {
775      $.ui.log(`[jev-skill-suggestion] setup: could not list the skills: ${String(error)}`)
776      return next({ ...e, text: setupAborted(`the skills could not be listed (${String(error)})`) })
777    }
778    const configDir = configDirOf((await $.env.get('HOME')) ?? '~', await $.env.get('CLAUDE_CONFIG_DIR'))
779    const settingsPath = `${configDir}/settings.json`
780    const backupPath = `${configDir}/jev-skill-suggestion.skill-overrides.backup.json`
781    let json: string | null = null
782    try {
783      if (await $.fs.exists(settingsPath)) json = await $.fs.read(settingsPath)
784    } catch (error) {
785      $.ui.log(`[jev-skill-suggestion] setup: could not read ${settingsPath}: ${String(error)}`)
786      return next({ ...e, text: setupAborted(`${settingsPath} exists but could not be read (${String(error)})`) })
787    }
788    const settings = readSkillSettings(json)
789    const plan = setupPlan(commands, settings, new Set([SETUP_COMMAND]))
790    // An earlier run's backup is reused only if it is one: a file that is
791    // not this mod's, or is corrupt, is nothing restore could apply, so no
792    // setup is built on top of it.
793    let backupExists = false
794    try {
795      backupExists = await $.fs.exists(backupPath)
796      if (backupExists && !validBackup(await $.fs.read(backupPath))) {
797        $.ui.log(`[jev-skill-suggestion] setup: ${backupPath} is not a valid backup`)
798        return next({
799          ...e,
800          text: setupAborted(
801            `${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`,
802          ),
803        })
804      }
805    } catch (error) {
806      $.ui.log(`[jev-skill-suggestion] setup: could not read ${backupPath}: ${String(error)}`)
807      return next({ ...e, text: setupAborted(`${backupPath} could not be read (${String(error)})`) })
808    }
809    if (logDecisions) {
810      $.ui.log(
811        `[jev-skill-suggestion] setup (${mode}): ${plan.hide.length} to hide, ${plan.alreadyHidden.length} already hidden, ${plan.locked.length} locked by a plugin`,
812      )
813    }
814    return next({ ...e, text: setupInstructions(mode, plan, settings, settingsPath, backupPath, backupExists) })
815  })
816
817  on('skill.prompt', async ($, e, next) => {
818    // Observation only: whether the model took the suggestion, or reached for
819    // a skill it was never told about, is the one measure of this mod's worth.
820    if (logDecisions && e.skill !== SETUP_COMMAND) {
821      const how =
822        suggested === e.skill
823          ? 'as suggested'
824          : suggested
825            ? `suggested was /${suggested}`
826            : 'nothing was suggested'
827      $.ui.log(`[jev-skill-suggestion] skill /${e.skill} loaded (${how})`)
828    }
829    if (e.skill !== SETUP_COMMAND) await record($, state, { loaded: e.skill, suggested })
830    return next(e)
831  })
832}
833
hooks/policy.ts 1117 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  // Pinned, not `jev-latest`: an alias moves, and the thresholds are tuned
78  // against one version (evals/README.md).
79  typesafe: 'jev-1.14.0',
80  gateway: 'typesafe-ai/jev',
81}
82
83/**
84 * The Gateway's own protocol version, sent as `ai-gateway-protocol-version`:
85 * without it every request is refused with 400. Tracks the
86 * `AI_GATEWAY_PROTOCOL_VERSION` of `@ai-sdk/gateway` (4.0.87).
87 */
88const AI_GATEWAY_PROTOCOL_VERSION = '0.0.1'
89
90/**
91 * Which backend a configuration asks for, or null for the built-in
92 * classifier. `auto` prefers TypeSafe, since it is the only one that reports
93 * a calibrated confidence; a forced backend whose key is missing resolves to
94 * null rather than falling through to the other one's key.
95 */
96export function selectProvider(forced: string, typesafeKey: string, gatewayKey: string): Provider | null {
97  if (forced === 'builtin') return null
98  if (forced === 'typesafe') return typesafeKey ? 'typesafe' : null
99  if (forced === 'gateway') return gatewayKey ? 'gateway' : null
100  if (typesafeKey) return 'typesafe'
101  if (gatewayKey) return 'gateway'
102  return null
103}
104
105/** The full endpoint a backend posts to. */
106export function endpoint(provider: Provider, baseUrl: string): string {
107  const root = baseUrl.replace(/\/+$/, '')
108  return provider === 'typesafe' ? `${root}/v1/systemone` : `${root}/evaluation-model`
109}
110
111/** A comma-separated option as a set of trimmed, non-empty names. */
112export function parseNames(option: string): Set<string> {
113  return new Set(
114    option
115      .split(',')
116      .map((name) => name.trim())
117      .filter(Boolean),
118  )
119}
120
121/**
122 * Reads the engine's `skill_listing` attachment: a header line, then one
123 * `- name: description` per skill. A description may run over several lines,
124 * and an entry the engine trimmed to fit its budget has no description at
125 * all. Anything before the first entry is the header and is skipped.
126 */
127export function parseListing(text: string): Skill[] {
128  const skills: Skill[] = []
129  let current: Skill | null = null
130  for (const raw of text.split('\n')) {
131    const line = raw.trimEnd()
132    // A name has no spaces, so it runs up to the first `: `; a name that
133    // itself contains `:` (`engineering:code-review`) is kept whole.
134    const entry = /^- (\S+?)(?::\s(.*))?$/.exec(line)
135    if (entry) {
136      current = { name: entry[1] as string, description: (entry[2] ?? '').trim() }
137      skills.push(current)
138    } else if (current && line.trim()) {
139      current.description = `${current.description} ${line.trim()}`.trim()
140    }
141  }
142  return skills
143}
144
145/**
146 * The skills the decision model is offered: every command the person can run
147 * that the model may also call, less the built-ins (`/help`, `/clear`) and the
148 * names configured out. When the engine's listing has been seen, only skills
149 * it listed are offered: the listing is the engine's word on which skills the
150 * model is allowed to invoke.
151 */
152export function catalog(
153  commands: readonly { name: string; description: string; source: string }[],
154  listed: ReadonlySet<string>,
155  excluded: ReadonlySet<string>,
156): Skill[] {
157  const seen = new Set<string>()
158  const skills: Skill[] = []
159  for (const command of commands) {
160    if (command.source === 'builtin') continue
161    if (listed.size > 0 && !listed.has(command.name)) continue
162    if (excluded.has(command.name) || seen.has(command.name)) continue
163    seen.add(command.name)
164    skills.push({ name: command.name, description: command.description.trim() })
165  }
166  return skills
167}
168
169/**
170 * The listing with only `keep` left in it, or null when nothing is kept: what
171 * the model reads in place of the engine's full listing.
172 */
173export function trimListing(text: string, keep: ReadonlySet<string>): string | null {
174  if (keep.size === 0) return null
175  const kept = parseListing(text).filter((skill) => keep.has(skill.name))
176  if (kept.length === 0) return null
177  const header = text.split('\n').find((line) => line.trim() && !line.startsWith('- ')) ?? ''
178  return [header.trim(), '', ...kept.map(line)].join('\n')
179}
180
181function line(skill: Skill): string {
182  return skill.description ? `- ${skill.name}: ${skill.description}` : `- ${skill.name}`
183}
184
185/**
186 * Whether a skill or plugin name may be spliced into a path: the engine's
187 * names are directory basenames, so anything with a path separator or a
188 * `..` segment is refused rather than resolved.
189 */
190function safeName(name: string): boolean {
191  return name.length > 0 && !name.includes('/') && !name.includes('\\') && !name.split(':').includes('..')
192}
193
194/**
195 * The relative files a skill's body may live in, project or user level, by
196 * how Claude Code lays skills and commands out. A plugin's are under its
197 * install path instead, with the plugin prefix taken off the name.
198 */
199export function skillFileCandidates(name: string, plugin?: string): string[] {
200  if (!safeName(name) || (plugin !== undefined && !safeName(plugin))) return []
201  const short = plugin && name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : name
202  const asPath = short.replace(/:/g, '/')
203  const files = [
204    `.claude/skills/${short}/SKILL.md`,
205    `.claude/commands/${asPath}.md`,
206    `.claude/skills/${asPath}/SKILL.md`,
207  ]
208  if (plugin) {
209    // A plugin auto-loaded from a skills dir keeps its own `skills/` inside.
210    files.push(`.claude/skills/${plugin}/skills/${short}/SKILL.md`)
211    files.push(`.claude/skills/${plugin}/commands/${asPath}.md`)
212  }
213  return files
214}
215
216/** Whether a name is one the engine would run as `/name`: no whitespace. */
217export function commandLike(name: string): boolean {
218  return name.length > 0 && !/\s/.test(name)
219}
220
221/** The `name:` a SKILL.md's frontmatter declares, or null. */
222export function frontmatterName(markdown: string): string | null {
223  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
224  if (!frontmatter) return null
225  const field = /^name:\s*(.*)$/m.exec(frontmatter[1] as string)
226  if (!field) return null
227  const value = (field[1] as string).trim().replace(/^["']|["']$/g, '')
228  return value || null
229}
230
231/**
232 * Display name → directory name, from the skills found on disk: only the
233 * ones whose frontmatter `name:` differs from their directory, since those
234 * are the ones `$.command.list()` reports under a name the engine will not
235 * run, list or override. First found wins.
236 */
237export function displayIds(found: readonly { dir: string; markdown: string }[]): Map<string, string> {
238  const ids = new Map<string, string>()
239  for (const { dir, markdown } of found) {
240    const declared = frontmatterName(markdown)
241    if (declared && declared !== dir && !ids.has(declared)) ids.set(declared, dir)
242  }
243  return ids
244}
245
246/** Commands with every display name replaced by the engine's id. */
247export function canonical<T extends { name: string }>(commands: readonly T[], ids: ReadonlyMap<string, string>): T[] {
248  if (ids.size === 0) return [...commands]
249  return commands.map((command) => (ids.has(command.name) ? { ...command, name: ids.get(command.name) as string } : command))
250}
251
252/**
253 * A claude.ai-synced skill's file: Claude Code keeps them under
254 * `~/.claude/skills/synced/<account>/<skill>/SKILL.md`, listed to the model
255 * with a prefix (`anthropic-skills:pptx`) that is not on disk.
256 */
257export function syncedFileCandidates(configDir: string, accounts: readonly string[], name: string): string[] {
258  if (!safeName(name)) return []
259  const short = name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name
260  return accounts.filter(safeName).map((account) => `${configDir}/skills/synced/${account}/${short}/SKILL.md`)
261}
262
263/**
264 * Where Claude Code keeps the user's settings, skills and plugin records:
265 * `CLAUDE_CONFIG_DIR` when set, else `~/.claude`.
266 */
267export function configDirOf(home: string, override: string | undefined): string {
268  const dir = override?.trim()
269  return (dir ? dir : `${home}/.claude`).replace(/\/+$/, '')
270}
271
272/** A project-relative `.claude/...` file as it sits under the user's config dir. */
273export function underConfigDir(configDir: string, file: string): string {
274  return `${configDir}/${file.replace(/^\.claude\//, '')}`
275}
276
277/**
278 * The same files under a plugin's install path, as
279 * `~/.claude/plugins/installed_plugins.json` records it.
280 */
281export function pluginFileCandidates(installPath: string, name: string, plugin: string): string[] {
282  if (!safeName(name) || !safeName(plugin)) return []
283  const short = name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : name
284  const root = installPath.replace(/\/+$/, '')
285  return [`${root}/skills/${short}/SKILL.md`, `${root}/commands/${short.replace(/:/g, '/')}.md`]
286}
287
288/**
289 * The install paths `installed_plugins.json` records for a plugin name, any
290 * marketplace: the keys are `name@marketplace`.
291 */
292export function installPathsOf(installedJson: string, plugin: string): string[] {
293  let parsed: unknown
294  try {
295    parsed = JSON.parse(installedJson)
296  } catch {
297    return []
298  }
299  const plugins = (parsed as { plugins?: Record<string, { installPath?: string }[]> }).plugins
300  if (!plugins) return []
301  const paths: string[] = []
302  for (const [key, entries] of Object.entries(plugins)) {
303    if (key !== plugin && !key.startsWith(`${plugin}@`)) continue
304    for (const entry of entries ?? []) {
305      if (typeof entry?.installPath === 'string') paths.push(entry.installPath)
306    }
307  }
308  return paths
309}
310
311/**
312 * What the second request reads for one skill: its full frontmatter
313 * description, then the opening of its body with the frontmatter taken off.
314 * With no file found, the one-line description alone.
315 */
316export function detailOf(skill: Skill, markdown: string | null, excerptChars: number): string {
317  if (!markdown) return skill.description || `A skill named ${skill.name}.`
318  let body = markdown
319  let description = skill.description
320  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown)
321  if (frontmatter) {
322    body = markdown.slice(frontmatter[0].length)
323    const field = /^description:\s*(.*)$/m.exec(frontmatter[1] as string)
324    if (field) {
325      const value = (field[1] as string).trim().replace(/^["']|["']$/g, '')
326      if (value.length > description.length) description = value
327    }
328  }
329  const excerpt = body.trim().slice(0, Math.max(0, excerptChars))
330  if (!excerpt) return description || `A skill named ${skill.name}.`
331  return description ? `${description} — ${excerpt}` : excerpt
332}
333
334/**
335 * Whether a skill's own frontmatter lets the model invoke it. A skill with
336 * `disable-model-invocation: true` is left out of the engine's listing and
337 * refused by the Skill tool, so it must never be suggested; before the
338 * listing has been seen, this is the only way to tell. No file, or no such
339 * field, reads as invocable.
340 */
341/** A skill's body with its frontmatter taken off. */
342export function bodyOf(markdown: string): string {
343  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown)
344  return (frontmatter ? markdown.slice(frontmatter[0].length) : markdown).trim()
345}
346
347/** The directory a skill file lives in, for `${CLAUDE_SKILL_DIR}`. */
348export function dirOf(path: string): string {
349  const cut = path.lastIndexOf('/')
350  return cut > 0 ? path.slice(0, cut) : '.'
351}
352
353/**
354 * The block attached to the prompt when the mod loads the skill itself: the
355 * cookbook's line, then the skill's instructions as the engine would have
356 * rendered them, with the paths the engine substitutes. The Skill tool is
357 * not needed, so the model is told not to load it a second time. A skill already injected earlier in
358 * the session is only named again: the engine does the same on a byte-identical
359 * re-invocation.
360 */
361export function injectionBlock(
362  suggested: Skill,
363  markdown: string | null,
364  path: string | null,
365  projectDir: string,
366  alreadyLoaded: boolean,
367): string {
368  const lines = [
369    '<skill_relevance>',
370    `Relevant to the current request: ${suggested.name}. Ignore this if it does not fit what the user actually asked for.`,
371  ]
372  if (alreadyLoaded) {
373    lines.push(`Skill /${suggested.name} is already loaded above; instructions unchanged.`)
374  } else if (markdown && path) {
375    const dir = dirOf(path)
376    // Callbacks, so a path holding `$&` or `$1` goes in verbatim.
377    const body = bodyOf(markdown)
378      .replace(/\$\{CLAUDE_SKILL_DIR\}/g, () => dir)
379      .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, () => projectDir)
380    lines.push(
381      `Its instructions follow: follow them now, including any setup steps. Do not load it again with the Skill tool: it is already loaded here. Its files are in ${dir}.`,
382      `<skill name="${suggested.name}" dir="${dir}">`,
383      body,
384      '</skill>',
385    )
386  } else {
387    // No file on disk (a synced or bundled skill the mod cannot read): the
388    // name and the way to load it are all there is.
389    lines.push(
390      `${line(suggested)}`,
391      `Load it with the Skill tool (skill: "${suggested.name}") before you start; if the tool refuses it, tell the user to type /${suggested.name}.`,
392    )
393  }
394  lines.push('</skill_relevance>')
395  return lines.join('\n')
396}
397
398export function modelInvocable(markdown: string | null): boolean {
399  if (!markdown) return true
400  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
401  if (!frontmatter) return true
402  return !/^disable-model-invocation:\s*true\s*$/m.test(frontmatter[1] as string)
403}
404
405/**
406 * The three questions about the request, each asking a different way whether
407 * it wants an action taken rather than an explanation given. Questions about
408 * subject matter would not separate "explain what a monad is" from a task
409 * that needs a skill, since both are software.
410 */
411export const GATE_QUESTIONS: Record<string, string> = {
412  acts_on_user_system:
413    "Is the assistant being asked to act on the user's files, accounts, devices, or online services, rather than only to explain or advise?",
414  would_follow_documented_procedure:
415    'Would a careful expert answering this consult a specific documented procedure or set of commands, rather than answering from general understanding?',
416  prose_suffices:
417    "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?",
418  names_its_task:
419    'Does this message on its own say what is to be done, specifically enough to start without reading any earlier messages?',
420}
421
422/** Gate questions where a yes points away from needing a skill. */
423const INVERTED = new Set(['prose_suffices'])
424
425/** The same yes/no question under two names. */
426function yesNo(provider: Provider, instructions: string | Record<string, unknown>): Record<string, unknown> {
427  return { type: provider === 'typesafe' ? 'noul' : 'boolean', instructions }
428}
429
430/** The first request's `questions`: the ranking, and the gate unless `askGate` is off. */
431export function wideQuestions(
432  provider: Provider,
433  skills: readonly Skill[],
434  { askGate = true }: { askGate?: boolean } = {},
435): Record<string, unknown> {
436  const criteria: Record<string, string> = {}
437  for (const skill of skills) criteria[skill.name] = skill.description || `A skill named ${skill.name}.`
438  const questions: Record<string, unknown> = {
439    which: {
440      type: 'choice',
441      instructions:
442        "Which of these skills, if any, is the right one to load to help with the user's latest request?",
443      criteria,
444    },
445  }
446  if (!askGate) return questions
447  for (const [key, text] of Object.entries(GATE_QUESTIONS)) {
448    questions[`gate::${key}`] = yesNo(provider, text)
449  }
450  return questions
451}
452
453/**
454 * Does this candidate do the thing asked? Structured instructions are
455 * TypeSafe's documented form; the Gateway's shape is read from its SDK, so it
456 * keeps the plain string.
457 */
458function fitsInstructions(provider: Provider, candidate: Candidate): string | Record<string, unknown> {
459  const description = candidate.description || candidate.detail
460  if (provider === 'gateway') {
461    return `Does the skill '${candidate.name}' do the specific thing the user's request asks for? It is described as: ${description}`
462  }
463  return { question: 'Does `skill` do the specific thing the request asks for?', skill: { name: candidate.name, description } }
464}
465
466/** The second request's `questions`: the shortlist re-read, one `fits` each. */
467export function rerankQuestions(
468  provider: Provider,
469  candidates: readonly Candidate[],
470): Record<string, unknown> {
471  const criteria: Record<string, string> = {}
472  for (const candidate of candidates) criteria[candidate.name] = candidate.detail
473  const questions: Record<string, unknown> = {
474    which: {
475      type: 'choice',
476      instructions:
477        "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.",
478      criteria,
479    },
480  }
481  for (const candidate of candidates) {
482    questions[`fits::${candidate.name}`] = yesNo(provider, fitsInstructions(provider, candidate))
483  }
484  return questions
485}
486
487/** The turn before the request, as the decision model reads it beside the request. */
488export interface RecentContext {
489  previous_user_message: string
490  assistant_reply: string
491}
492
493/**
494 * The turn before `current` in a transcript: the last user message before it
495 * and the assistant's last reply, each cut short, or null when there is none.
496 * A follow-up such as "查無資料ㄟ 為什麼??" carries its task in that turn, and
497 * the decision model otherwise reads the request alone. Mid-turn (find_skill),
498 * `current` is null: the context is the prompt being worked on and the reply
499 * so far.
500 */
501export function recentContextOf(
502  messages: readonly { role: 'user' | 'assistant'; text: string }[],
503  current: string | null,
504): RecentContext | null {
505  const said = messages.filter((message) => message.text.trim())
506  // The prompt being decided may already be in the transcript.
507  const last = said.at(-1)
508  if (current !== null && last?.role === 'user' && last.text.trim() === current.trim()) said.pop()
509  const at = said.findLastIndex((message) => message.role === 'user')
510  const user = at < 0 ? '' : (said[at] as { text: string }).text.trim()
511  const reply = said.slice(at + 1).findLast((message) => message.role === 'assistant')?.text.trim() ?? ''
512  if (!user && !reply) return null
513  return { previous_user_message: user.slice(0, 400), assistant_reply: reply.slice(-600) }
514}
515
516/** A request body. The Gateway carries the model in a header instead. */
517export function requestBody(
518  provider: Provider,
519  prompt: string,
520  questions: Record<string, unknown>,
521  model: string,
522  context: RecentContext | null = null,
523): string {
524  const state = { request: prompt, recent_context: context ?? '' }
525  const body = provider === 'typesafe' ? { model, state, questions } : { state, questions }
526  return JSON.stringify(body)
527}
528
529/** The request headers. */
530export function requestHeaders(provider: Provider, apiKey: string, model: string): Record<string, string> {
531  const common = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }
532  if (provider === 'typesafe') return common
533  return {
534    ...common,
535    'ai-gateway-auth-method': 'api-key',
536    'ai-model-id': model,
537    'ai-gateway-protocol-version': AI_GATEWAY_PROTOCOL_VERSION,
538    'ai-evaluation-model-specification-version': '4',
539  }
540}
541
542type Answers = Record<string, Record<string, unknown>>
543
544function answersOf(responseText: string): Answers | null {
545  let parsed: unknown
546  try {
547    parsed = JSON.parse(responseText)
548  } catch {
549    return null
550  }
551  const answers = (parsed as { answers?: Answers }).answers
552  return answers && typeof answers === 'object' ? answers : null
553}
554
555/** P(true) of a yes/no answer: `noul` on TypeSafe, `probability` on the Gateway. */
556function yesNoOf(answer: Record<string, unknown> | undefined): number | null {
557  if (!answer) return null
558  if (typeof answer.noul === 'number') return answer.noul
559  if (typeof answer.probability === 'number') return answer.probability
560  return null
561}
562
563/** The model version a response says answered it (`jev-latest` resolves to one), or null. */
564export function modelOf(responseText: string): string | null {
565  try {
566    const model = (JSON.parse(responseText) as { model?: unknown }).model
567    return typeof model === 'string' ? model : null
568  } catch {
569    return null
570  }
571}
572
573/**
574 * Reads the first request's answer. The ranking is the Choice's probability
575 * distribution, surest first; a backend that sends none ranks the named
576 * choice alone, at the reported confidence or none. The gate is the mean of
577 * the oriented nouls that were answered, or null when none was.
578 */
579export function readWide(responseText: string): Wide | null {
580  const answers = answersOf(responseText)
581  const which = answers?.which
582  if (!answers || !which || typeof which.choice !== 'string') return null
583
584  const probabilities = which.probabilities as Record<string, number> | undefined
585  const ranked: Wide['ranked'] = []
586  if (probabilities) {
587    for (const [name, probability] of Object.entries(probabilities)) {
588      if (typeof probability === 'number') ranked.push({ name, probability })
589    }
590    ranked.sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
591  }
592  if (ranked.length === 0) {
593    ranked.push({
594      name: which.choice,
595      probability: typeof which.confidence === 'number' ? which.confidence : null,
596    })
597  }
598
599  const gateValues: Record<string, number> = {}
600  const oriented: number[] = []
601  for (const key of Object.keys(GATE_QUESTIONS)) {
602    const value = yesNoOf(answers[`gate::${key}`])
603    if (value === null) continue
604    gateValues[key] = value
605    oriented.push(INVERTED.has(key) ? 1 - value : value)
606  }
607  const gate = oriented.length > 0 ? oriented.reduce((a, b) => a + b, 0) / oriented.length : null
608  return { ranked, gate, gateValues }
609}
610
611/** Reads the second request's answer. */
612export function readRerank(responseText: string): Rerank | null {
613  const answers = answersOf(responseText)
614  const which = answers?.which
615  if (!answers || !which || typeof which.choice !== 'string') return null
616  const fits: Record<string, number> = {}
617  for (const [key, answer] of Object.entries(answers)) {
618    if (!key.startsWith('fits::')) continue
619    const value = yesNoOf(answer)
620    if (value !== null) fits[key.slice('fits::'.length)] = value
621  }
622  return {
623    winner: which.choice,
624    confidence: typeof which.confidence === 'number' ? which.confidence : null,
625    fits,
626  }
627}
628
629/** The first request's answer as the built-in classifier can give it: one label, no gate. */
630export function builtinWide(label: string | undefined): Wide | null {
631  if (!label) return null
632  return {
633    ranked: label === NONE ? [] : [{ name: label, probability: null }],
634    gate: null,
635    gateValues: {},
636  }
637}
638
639/**
640 * The text the built-in classifier reads: the prompt and the catalog it must
641 * choose from, since `$.model.classify` takes bare labels and the descriptions
642 * are the whole point.
643 */
644export function classifyText(prompt: string, skills: readonly Skill[]): string {
645  return [
646    '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.',
647    '',
648    'Skills:',
649    ...skills.map(line),
650    `- ${NONE}: no listed skill is about this prompt`,
651    '',
652    'Prompt:',
653    prompt,
654  ].join('\n')
655}
656
657/**
658 * Replies that carry no task of their own: a greeting, an acknowledgement, a
659 * go-ahead, a bare number picking an option. The decision model reads the
660 * prompt alone, with no conversation, so ranking these only guesses; they get
661 * no suggestion, and the model keeps whatever skill the task already loaded.
662 */
663const CONTEXT_FREE = new Set([
664  'hi', 'hello', 'hey', 'thanks', 'thank you', 'thx', 'ok', 'okay', 'k', 'yes', 'y', 'no', 'n', 'sure',
665  'go', 'go ahead', 'continue', 'proceed', 'next', 'done', 'lgtm', 'sounds good', 'do it',
666  '好', '好的', '好啊', '好喔', '好哦', '可以', '是', '是的', '對', '對的', '嗯', '恩', '謝謝', '感謝',
667  '繼續', '继续', '下一步', '做吧', '好做吧', '開始', '开始', '沒問題', '没问题', '不用', '不要', 'ok啦', '可',
668])
669
670/** Whether a prompt is such a reply: no letters at all, or a stock phrase. */
671export function contextFree(text: string): boolean {
672  const trimmed = text.trim().toLowerCase()
673  if (!/\p{L}/u.test(trimmed)) return true
674  return CONTEXT_FREE.has(trimmed.replace(/[\s!.,?~。!,?~、]+$/u, '').replace(/\s+/g, ' '))
675}
676
677export interface PolicyConfig {
678  /** How many of the ranking the second request re-reads. */
679  shortlist: number
680  /** The gate mean under which nothing is suggested. */
681  gateThreshold: number
682  /** The best `fits` under which the whole shortlist is dropped. */
683  fitsThreshold: number
684}
685
686/** The shortlist the second request reads: the top of the ranking, by name. */
687export function shortlistOf(wide: Wide, skills: readonly Skill[], count: number): Skill[] {
688  const byName = new Map(skills.map((skill) => [skill.name, skill]))
689  const picked: Skill[] = []
690  for (const entry of wide.ranked) {
691    const skill = byName.get(entry.name)
692    if (skill) picked.push(skill)
693    if (picked.length >= count) break
694  }
695  return picked
696}
697
698/** Whether the first request's answer is worth a second look at all. */
699export function passesGate(wide: Wide, config: PolicyConfig): boolean {
700  return wide.gate === null || wide.gate >= config.gateThreshold
701}
702
703export interface Suggestion {
704  /** The one skill to suggest, or null for "nothing here applies". */
705  name: string | null
706  /** Why, for the log line. */
707  reason: string
708}
709
710/**
711 * At most one skill name for a request. With the second request switched
712 * off, the top of the ranking stands, which is what the first request alone
713 * can say; a second request that was attempted and failed suggests nothing.
714 */
715export function decide(
716  wide: Wide | null,
717  rerank: Rerank | null,
718  skills: readonly Skill[],
719  config: PolicyConfig,
720  /**
721   * Whether a second request was made: with one attempted and no answer,
722   * nothing is suggested, since the first request's winner has not had its
723   * false-positive check. Off means the second request was never meant to
724   * run, and the top of the ranking is the whole answer.
725   */
726  rerankAttempted = false,
727): Suggestion {
728  if (!wide) return { name: null, reason: 'no answer' }
729  if (!passesGate(wide, config)) {
730    return {
731      name: null,
732      reason: `needs a skill ${(wide.gate as number).toFixed(2)} < ${config.gateThreshold}`,
733    }
734  }
735  const shortlist = shortlistOf(wide, skills, config.shortlist)
736  if (shortlist.length === 0) return { name: null, reason: 'nothing ranked' }
737
738  if (!rerank && rerankAttempted) return { name: null, reason: 'rerank gave no answer; no suggestion' }
739
740  if (rerank) {
741    const values = Object.values(rerank.fits)
742    const best = values.length > 0 ? Math.max(...values) : null
743    if (best !== null && best < config.fitsThreshold) {
744      return { name: null, reason: `nothing fits, best ${best.toFixed(2)} < ${config.fitsThreshold}` }
745    }
746    if (shortlist.some((skill) => skill.name === rerank.winner)) {
747      const fit = rerank.fits[rerank.winner]
748      return {
749        name: rerank.winner,
750        reason: `rerank of ${shortlist.length}${fit === undefined ? '' : `, fits ${fit.toFixed(2)}`}`,
751      }
752    }
753    return { name: null, reason: `rerank named ${rerank.winner}, not on the shortlist` }
754  }
755
756  const top = shortlist[0] as Skill
757  const probability = wide.ranked.find((entry) => entry.name === top.name)?.probability ?? null
758  return {
759    name: top.name,
760    reason: `top of ${wide.ranked.length}${probability === null ? '' : ` (${probability.toFixed(2)})`}, no rerank`,
761  }
762}
763
764/**
765 * The block attached to the prompt, in the cookbook's words. It can be
766 * ignored, because pushing harder wins compliance on wrong suggestions too.
767 * When the listing is still in place, a turn with nothing to suggest says so:
768 * sending nothing would leave "err on the side of loading" unopposed. With
769 * the listing withheld there is nothing to oppose, so nothing is sent.
770 */
771export function suggestionBlock(suggested: Skill | null, hidden: boolean): string | null {
772  if (!suggested) {
773    return hidden
774      ? null
775      : '<skill_relevance>\nNo skill in the roster appears relevant to this request.\n</skill_relevance>'
776  }
777  const lines = [
778    '<skill_relevance>',
779    `Relevant to the current request: ${suggested.name}. Ignore this if it does not fit what the user actually asked for.`,
780  ]
781  if (hidden) {
782    // The model has no listing to look the name up in, so the line it would
783    // have found there and the way to load it come along.
784    lines.push(
785      `${line(suggested)}`,
786      'Load it with the Skill tool (skill: "' +
787        suggested.name +
788        '") before you start. The full skill listing is withheld from your context; the user can invoke any skill by typing /name.',
789    )
790  }
791  lines.push('</skill_relevance>')
792  return lines.join('\n')
793}
794
795/**
796 * The tool the model calls mid-turn, when a sub-task starts that the prompt's
797 * own suggestion did not cover. Worded as facts, not orders: text that reads
798 * like a command can trip the model's prompt-injection defences.
799 */
800export const FIND_SKILL_TOOL = {
801  name: 'find_skill',
802  description:
803    'Finds which installed skill fits a sub-task. The skill listing names most skills without a description; ' +
804    'this tool reads their descriptions and SKILL.md and answers with at most one skill, to load with the Skill tool, or none. ' +
805    'Call it when a new sub-task starts partway through the work (the next step of a plan, a new kind of file, tool or service), ' +
806    'and before you hand the user something whose shape a team may have rules for: a query or command for them to run, ' +
807    'a document, a diagram, a config. Skip it when a loaded skill already covers the step. Pass the sub-task in one sentence.',
808  inputSchema: {
809    type: 'object',
810    properties: { task: { type: 'string', description: 'The sub-task, in one sentence.' } },
811    required: ['task'],
812  },
813} as const
814
815/** What the requests answered for one decision, kept for the record file. */
816export interface Trace {
817  wide: Wide | null
818  rerank: Rerank | null
819  ms: number
820  /** The model version that answered, so a shift in scores can be told from an alias moving. */
821  model: string | null
822}
823
824/** One decision as the record file keeps it. */
825export interface Decision {
826  /** Where the text came from: the person's prompt, or the model's find_skill. */
827  source: 'prompt' | 'find_skill'
828  text: string
829  /** The turn before, as sent beside the text. */
830  context: RecentContext | null
831  pick: string | null
832  reason: string
833  /** False when a request failed or timed out. */
834  answered: boolean
835  trace: Trace | null
836  /** The subagent that called find_skill, when one did. */
837  agentId?: string
838}
839
840/**
841 * One line of the session's record file (JSON Lines): a decision, with the
842 * ranking's top five, so a wrong pick can be read back and replayed later; or
843 * a skill the model loaded, with the pick it was made under.
844 */
845export function recordLine(at: number, entry: Decision | { loaded: string; suggested: string | null }): string {
846  const time = new Date(at).toISOString()
847  if ('loaded' in entry) return `${JSON.stringify({ at: time, ...entry })}\n`
848  const { trace, ...rest } = entry
849  return `${JSON.stringify({
850    at: time,
851    ...rest,
852    ms: trace?.ms ?? 0,
853    model: trace?.model ?? null,
854    gate: trace?.wide?.gate ?? null,
855    gateValues: trace?.wide?.gateValues ?? {},
856    top: trace?.wide?.ranked.slice(0, 5) ?? [],
857    rerank: trace?.rerank ? { winner: trace.rerank.winner, confidence: trace.rerank.confidence, fits: trace.rerank.fits } : null,
858  })}\n`
859}
860
861/**
862 * What `find_skill` answers: the pick with the description the listing left
863 * out, a plain "none", or that the lookup failed, which is not a "none". Never
864 * a SKILL.md: the model loads the skill with the Skill tool, which re-attaches
865 * it after a compaction.
866 */
867export function findSkillResult(pick: Skill | null | 'unavailable'): string {
868  if (pick === 'unavailable') return 'The skill lookup is unavailable right now (it failed or timed out); the skill listing still names every skill.'
869  if (!pick) return 'No installed skill fits this sub-task.'
870  return [
871    line(pick),
872    `Load it with the Skill tool (skill: "${pick.name}") if it fits the sub-task; ignore it if it does not.`,
873  ].join('\n')
874}
875
876/** A number for the log, or `n/d` when the backend reported none. */
877function reported(value: number | null): string {
878  return value === null ? 'n/d' : value.toFixed(2)
879}
880
881/**
882 * The one-time line that says the mod is alive, which backend answers it,
883 * and whether the listing is being withheld.
884 */
885export function describeSetup(
886  provider: Provider | null,
887  url: string,
888  hideListing: boolean,
889  builtinByChoice = false,
890): string {
891  const backend = provider
892    ? `${provider} (${url})`
893    : builtinByChoice
894      ? 'the built-in classifier, by choice'
895      : 'the built-in classifier, no key set'
896  const listing = hideListing ? 'withholding the skill listing' : 'leaving the skill listing in place'
897  return `ready on ${backend}; ${listing}`
898}
899
900/** What the first request answered: the gate and the top of the ranking. */
901export function describeWide(wide: Wide | null, candidates: number, ms: number | null): string {
902  const took = ms === null ? '' : ` · ${Math.round(ms)}ms`
903  if (!wide) return `no answer from ${candidates} candidates${took}`
904  const top = wide.ranked
905    .slice(0, 3)
906    .map((entry) => `${entry.name} (${reported(entry.probability)})`)
907    .join(', ')
908  return `needs a skill ${reported(wide.gate)} · top of ${candidates}: ${top || 'none'}${took}`
909}
910
911/** What the second request answered: the winner and every `fits`. */
912export function describeRerank(rerank: Rerank | null, ms: number | null): string {
913  const took = ms === null ? '' : ` · ${Math.round(ms)}ms`
914  if (!rerank) return `rerank: no answer${took}`
915  const fits = Object.entries(rerank.fits)
916    .map(([name, value]) => `${name} ${value.toFixed(2)}`)
917    .join(', ')
918  return `rerank → ${rerank.winner} (${reported(rerank.confidence)}) · fits ${fits || 'n/d'}${took}`
919}
920
921/**
922 * The persistent status line: the last thing the mod did, short enough to
923 * sit on screen beside the engine's own notices.
924 */
925export function describeStatus(suggested: string | null): string {
926  return suggested ? `jev · skill: ${suggested}` : 'jev · no skill'
927}
928
929/** The name of the plugin's own setup command, as the engine runs it. */
930export const SETUP_COMMAND = 'jev-skill-suggestion:setup'
931
932/** A user settings file's parts the setup touches. */
933export interface SkillSettings {
934  skillOverrides: Record<string, string>
935  disableBundledSkills: boolean | undefined
936}
937
938/** Reads the two fields from `~/.claude/settings.json`; malformed reads as empty. */
939export function readSkillSettings(json: string | null): SkillSettings {
940  let parsed: unknown = null
941  try {
942    parsed = json ? JSON.parse(json) : null
943  } catch {
944    parsed = null
945  }
946  const settings = (parsed ?? {}) as { skillOverrides?: unknown; disableBundledSkills?: unknown }
947  const overrides: Record<string, string> = {}
948  if (settings.skillOverrides && typeof settings.skillOverrides === 'object') {
949    for (const [name, value] of Object.entries(settings.skillOverrides as Record<string, unknown>)) {
950      if (typeof value === 'string') overrides[name] = value
951    }
952  }
953  return {
954    skillOverrides: overrides,
955    disableBundledSkills: typeof settings.disableBundledSkills === 'boolean' ? settings.disableBundledSkills : undefined,
956  }
957}
958
959/**
960 * Whether a file is this mod's backup: `skillOverrides` an object of
961 * strings and `disableBundledSkills` a boolean or null. Anything else is
962 * not something `restore` could apply, so a setup must not build on it.
963 */
964export function validBackup(json: string): boolean {
965  let parsed: unknown
966  try {
967    parsed = JSON.parse(json)
968  } catch {
969    return false
970  }
971  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false
972  const { skillOverrides, disableBundledSkills } = parsed as Record<string, unknown>
973  if (!skillOverrides || typeof skillOverrides !== 'object' || Array.isArray(skillOverrides)) return false
974  if (!Object.values(skillOverrides as Record<string, unknown>).every((value) => typeof value === 'string')) return false
975  return disableBundledSkills === null || typeof disableBundledSkills === 'boolean'
976}
977
978/** The state the setup writes: the name stays listed, the description goes, the Skill tool still loads it. */
979export const SETUP_STATE = 'name-only'
980
981/** Override states that already keep a skill's description out of the listing. */
982const TRIMMED = new Set(['name-only', 'user-invocable-only', 'off'])
983
984export interface SetupPlan {
985  /** User skills the setup lists by name only (`name-only`). */
986  hide: string[]
987  /** Of those, the ones already name-only, user-invocable-only or off: nothing to change. */
988  alreadyHidden: string[]
989  /** A plugin's skills: `skillOverrides` cannot touch them, only `/plugin` can. */
990  locked: string[]
991}
992
993/**
994 * What the setup has to change, from the roster and the settings as they
995 * are. Every user-level command is listed, so the person sees the whole set
996 * before anything is written.
997 */
998export function setupPlan(
999  commands: readonly { name: string; source: string }[],
1000  settings: SkillSettings,
1001  excluded: ReadonlySet<string>,
1002): SetupPlan {
1003  const hide: string[] = []
1004  const alreadyHidden: string[] = []
1005  const locked: string[] = []
1006  const seen = new Set<string>()
1007  for (const command of commands) {
1008    if (seen.has(command.name) || excluded.has(command.name)) continue
1009    seen.add(command.name)
1010    if (command.source === 'plugin') locked.push(command.name)
1011    // A project's own skills are few and the ones that project needs most:
1012    // they keep their descriptions, and the person decides how many there are.
1013    else if (command.source === 'user') {
1014      const state = settings.skillOverrides[command.name] ?? 'on'
1015      if (TRIMMED.has(state)) alreadyHidden.push(command.name)
1016      else hide.push(command.name)
1017    }
1018  }
1019  return { hide, alreadyHidden, locked }
1020}
1021
1022/**
1023 * The prompt the model reads for `/jev-skill-suggestion:setup`: the plan in
1024 * full, the exact edit, and the rule that nothing is written before the
1025 * person has seen the list and said yes. The model edits the file with its
1026 * own tools, so the change shows as a diff and asks permission like any edit.
1027 */
1028export function setupInstructions(
1029  mode: 'apply' | 'restore',
1030  plan: SetupPlan,
1031  settings: SkillSettings,
1032  settingsPath: string,
1033  backupPath: string,
1034  /**
1035   * Whether a backup from an earlier run is already there. It holds the
1036   * values from before the first run, which the current settings no longer
1037   * do, so a rerun must leave it alone.
1038   */
1039  backupExists = false,
1040): string {
1041  const lines: string[] = ['<jev_skill_suggestion_setup>']
1042  if (mode === 'restore') {
1043    lines.push(
1044      `The user asked to undo jev-skill-suggestion's setup: put their skills back the way they were before it ran.`,
1045      `1. Read ${backupPath}. It holds {"skillOverrides": {...}, "disableBundledSkills": ...} as they were before the setup.`,
1046      `   If it does not exist, say so and stop: there is nothing to restore.`,
1047      `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). Leave "disableBundledSkills" as it is: this setup never changes it.`,
1048      `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 ${JSON.stringify(backupPath)}`,
1049      `4. Tell the user to restart Claude Code for /skills and /context to show the change.`,
1050      '</jev_skill_suggestion_setup>',
1051    )
1052    return lines.join('\n')
1053  }
1054  const overrides: Record<string, string> = { ...settings.skillOverrides }
1055  for (const name of plan.hide) overrides[name] = SETUP_STATE
1056  const after = JSON.stringify({ skillOverrides: overrides }, null, 2)
1057  const backup = JSON.stringify(
1058    { skillOverrides: settings.skillOverrides, disableBundledSkills: settings.disableBundledSkills ?? null },
1059    null,
1060    2,
1061  )
1062  lines.push(
1063    `The user asked jev-skill-suggestion to trim the skill listing: every skill stays listed for the model by name only (state "name-only": the description leaves the listing, the model can still load the skill with the Skill tool, and the user can still type /name), and the mod injects the one skill each prompt most needs. Nothing here is written until the user has seen the list and said yes.`,
1064    '',
1065    `Skills that will be listed by name only (${plan.hide.length}):`,
1066    ...(plan.hide.length > 0 ? plan.hide.map((name) => `- ${name}`) : ['- (none)']),
1067  )
1068  if (plan.alreadyHidden.length > 0) {
1069    lines.push('', `Already trimmed or off, left as they are (${plan.alreadyHidden.length}):`, ...plan.alreadyHidden.map((name) => `- ${name}`))
1070  }
1071  lines.push('', `Claude Code's bundled skills (simplify, loop, init, ...) are left as they are.`)
1072  if (plan.locked.length > 0) {
1073    lines.push(
1074      '',
1075      `Plugin skills cannot be hidden by skillOverrides; they stay listed unless the plugin is disabled in /plugin (${plan.locked.length}):`,
1076      ...plan.locked.map((name) => `- ${name}`),
1077    )
1078  }
1079  lines.push(
1080    '',
1081    'Steps:',
1082    `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.`,
1083    '2. Ask them to confirm. Do not edit anything before a clear yes.',
1084    ...(backupExists
1085      ? [
1086          `3. ${backupPath} already exists from an earlier run and holds the values from before the first setup: do NOT overwrite or modify it.`,
1087        ]
1088      : [`3. After the yes, first write ${backupPath} with exactly this content (it is what restore reads):`, backup]),
1089    `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" becomes exactly:`,
1090    after,
1091    '   Change nothing else in the file. Keep every other top-level key as it is.',
1092    '5. Tell the user to restart Claude Code: /skills will then show these skills as name-only, and the model keeps loading any of them by name, including one skill that names another.',
1093    '</jev_skill_suggestion_setup>',
1094  )
1095  return lines.join('\n')
1096}
1097
1098/**
1099 * What the model reads when the setup cannot be planned: the roster or the
1100 * settings could not be read, so no edit is proposed — an edit planned from
1101 * a partial roster or empty settings would hide too little or back up the
1102 * wrong values.
1103 */
1104export function setupAborted(reason: string): string {
1105  return [
1106    '<jev_skill_suggestion_setup>',
1107    `The jev-skill-suggestion setup could not be prepared: ${reason}.`,
1108    'Tell the user, and do not edit any settings file. They can fix the cause and run the command again.',
1109    '</jev_skill_suggestion_setup>',
1110  ].join('\n')
1111}
1112
1113/** The hint logged while skills are still listed with their whole description. */
1114export function describeStillListed(count: number): string {
1115  return `${count} skill${count === 1 ? ' is' : 's are'} still listed with a full description; run /${SETUP_COMMAND} to list them by name only`
1116}
1117