Smart skill activation by deterministic rules, no model calls: tells the model which installed skills fit the person's prompt (Italian and English patterns)…

Personal skills for running Claude Code as a coordinator that delegates work to subagents, cloud sessions and workflows, plus the tools around it. They work in any project.
| Skill | What it decides |
|---|---|
| model-mix | Which model and effort each piece of delegated work gets (Haiku 5.5 for short reading and small mechanical tickets, Sonnet 5.5 for implementation, Opus 5.5 for security, verification and final review), the profile for the plan (Pro, Max 5x, Max 20x), and a budget check that paces the weekly limit against the elapsed week, counting banked limit resets. |
| smart-ultracode | When a multi-agent workflow is worth it, how wide it should be within the plan's profile, which shape to use, and what to believe of its result. |
| coordinator-method | How delegated work runs: briefing cloud and local workers, merging only on green checks, safe git, a round of checks after every merge, the budget check before every launch, overnight loops, UI changes. Includes launch.exp, a launcher for Claude Code cloud sessions. |
| smart-mods | When a Claude Code mod is the right extension, how to write, test and install one globally, and how to vet someone else's mod before it runs with your permissions. |
| cloud-worker | How to launch, follow and collect cloud workers from this machine, Windows included, with launch.ps1. |
| merge-gate | The review and merge procedure for a finished PR: review sized to the diff, verified findings applied, merge on the pinned head, then the follow-up. |
| overnight | Running unattended overnight cheaply and safely: before sleeping, wake design, usage limits, keeping the machine awake, the morning summary. |
| roadmap-tracker | Tracker hygiene for the roadmap: pinned issue, milestones on every issue, labels mapped to the queue, tickets closed with the PR that landed them. |
| matt-bridge | How Matt Pocock's skills run inside the coordinator method, and which rule wins where they differ. |
| verified-research | Researching fast-changing facts with parallel readers, adversarial verifiers and a critic, and reporting them sorted by what survived. |
| release-watch | Cheap checks for Claude Code, Desktop, model, plan and upstream skill changes that should update the skills and mods. |
| skill-audit | Auditing skills for trigger quality, size, duplication, stale facts and token cost, with concrete edits. |
| context-hygiene | Keeping sessions cheap: what every turn pays, how to measure it, when to compact, clear or hand off. |
| windows-ops | What an agent must know on Windows 11: shells, Git Bash path conversion, long paths, line endings, missing tools, where Claude Code keeps its files. |
| mod-ui | The visual language of the mods: band, pane, chat card, toasts, theme colors, Desktop and terminal differences. |
| clean-code | Eight code-quality rules for code Claude writes or reviews (names, small functions, few arguments, no hidden side effects, KISS, DRY, YAGNI, SOLID), each blocking or advisory, with per-project overrides. |
| git-conventions | Default naming for commits, branches, PR titles and merge subjects (Conventional Commits, Conventional Branch), the history and tag rules, and a Node validator with a commit-msg hook for projects with no CI of its own. |
The skills point to each other instead of repeating themselves. A project's own AGENTS.md, CONTRIBUTING.md and branch protection always win over them; project facts stay in the project.
git clone https://github.com/emanueledenaro/claude-skills.git
cd claude-skills
cp -R model-mix smart-ultracode coordinator-method smart-mods cloud-worker merge-gate overnight roadmap-tracker matt-bridge verified-research release-watch skill-audit context-hygiene windows-ops mod-ui clean-code git-conventions ~/.claude/skills/
Then add a routing block to your global ~/.claude/CLAUDE.md, so every project reaches them at the right moment:
Before launching any agent, cloud session, workflow or review, use `model-mix`. Before writing a workflow script, use `smart-ultracode`. When coordinating delegated work (workers, merges, rounds, overnight loops, UI changes), use `coordinator-method`. Before writing, changing, installing or reviewing a Claude Code mod or any plugin, or when asked for a pane, a band above the prompt, a custom command or a tool-call guard, use `smart-mods`; for what a mod draws, also `mod-ui`.
Launching or steering a cloud worker: `cloud-worker`. Merging a finished PR: `merge-gate`. Going unattended or to sleep: `overnight`. Issues, milestones, the roadmap: `roadmap-tracker`. Running Matt Pocock's skills: `matt-bridge`. Researching facts that change (prices, limits, versions, APIs): `verified-research`. Checking for new releases or models: `release-watch`. Reviewing skills or their cost: `skill-audit`. A heavy or long session: `context-hygiene`. Shell, paths or tools on Windows: `windows-ops`. Writing, refactoring or reviewing code: `clean-code`. Writing a commit message, naming a branch, titling a PR or a merge commit: `git-conventions`.
Claude Code can tell Pro from Max, but not Max 5x from Max 20x. Add one line to the same ~/.claude/CLAUDE.md and keep it current:
Claude plan: Max 20x · reserve 10% · banked: weekly reset, expires 2026-10-22
weekly reset, expires YYYY-MM-DD, 5-hour reset, expires YYYY-MM-DD, or … reset, no expiry, separated by ;. Claude Code cannot read them and only you can redeem them, so delete an entry once you redeem it or it expires.usage file: ~/.claude/usage.json, if your statusline script saves its rate_limits there. Desktop sessions read usage directly.Without the line, the skills ask once and use the Pro profile on Pro, the Max 5x profile on Max.
Optional safety net: in the env block of ~/.claude/settings.json, add "CLAUDE_CODE_SUBAGENT_MODEL": "sonnet" next to the keys already there; do not replace the block. A general-purpose or workflow agent launched without a model then runs on Sonnet instead of the session's Opus, and an explicit opus still wins. It does not reach the built-in Explore and Plan agents, which stay on the session's model, or agents whose definition sets model: (including inherit), so the skills still name the model on every call. Do not add CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1: it also overrides the explicit opus the verify stages need.
From a repository root:
expect ~/.claude/skills/coordinator-method/launch.exp task.txt rules.txt worker.log sonnet high
The prompt is the task file, then the worker brief from coordinator-method, then the project's rules file (- for none). It prints the cloud session URL. It needs expect (macOS, Linux, or WSL on Windows; Git Bash does not ship it). On Windows without WSL, start the cloud session from Claude Desktop instead, or use cloud-worker's launch.ps1 in a terminal.
Two Claude Code mods in mods/ enforce and route the skills. They need Claude Code 2.1.287 or later in the terminal and 2.1.286 or later in the Desktop app (check with /status), and were tested with 2.1.293. They run unsandboxed with your permissions, like every mod: read them first (claude plugin validate mods/<name> lists what each one hooks and calls). None calls a model.
| Mod | What it does |
|---|---|
| model-guard | Applies model-mix to delegated launches. Agent calls: Sonnet for a general-purpose or Plan agent (or one with no type) launched without a model, an Explore agent without a model on Haiku at medium effort where the haiku alias is Haiku 5.5 (Claude Code 2.1.293 or later on the Anthropic API, with ANTHROPIC_DEFAULT_HAIKU_MODEL unset or on a Haiku 5.5 id) and on Sonnet elsewhere, the alias itself turned into Sonnet where it is not Haiku 5.5, medium effort added to a Haiku agent that names none, a Haiku 4.5 id turned into Sonnet, Fable turned into Opus, and isolation: 'remote' counted as a cloud session. Every spawned agent (Agent calls, teammates, workflow agents, other plugins' spawns) is judged on the model it will run on: a fork, an agent with no model or model: inherit, and a custom type with no model run on the session model or may inherit it, so in a Fable session they are refused with the advice to use general-purpose with model: 'opus', and the Haiku rules apply to an inherited model too. Custom agents are judged at spawn: the mod cannot read an agent definition's model, so a custom type with no model in the call is refused in a Fable session until the call names one. Workflow agents on Fable, on the alias where it is not Haiku 5.5 or on Haiku 4.5 are refused, and a run's width is capped by the profile that applied at its first agent; that record is kept in session state, so a reload of the mod keeps it, and a workflow resume while red or on Solo gets the plan's own width. From Bash, PowerShell and Monitor, claude --cloud, -p, --bg and a headless prompt get --model sonnet when they name none; Fable, the bare haiku alias and Haiku 4.5 are refused there (also as --fallback-model, in launch.exp and in cloud-worker's launch.ps1), so Haiku takes the full id claude-haiku-5-5; launches through Start-Process or cmd start, and claude launch flags it cannot read, are refused (run claude directly). Routines (RemoteTrigger) are cloud sessions: a model named in the body is checked the same way and Solo allows no new one. While the budget is red (or, with redPolicy warn, only flagged) or the 5-hour window is paused, new launches are refused. A workflow resume and a local session resume (--resume, --continue or --from-pr with -p or --bg) pass while red but wait while the 5-hour window is paused, since a resumed run can hit the limit and fail. Messages to workers sent directly with claude (claude -p "<msg>" --cloud <session> included), git, gh and merges are never blocked; a claude word with -p, --cloud or --bg after echo, git, gh, grep, ls, cat and similar programs, or in a comment, is read as text, and only a git command that runs claude itself (git bisect run claude -p ..., git rebase -x claude) is refused. With no usage reading yet, or a green one older than 10 minutes, the color is unknown and launches pass with one line, also in an unattended session, which a mod cannot tell apart from the Desktop app. A red or yellow reading older than 10 minutes keeps holding: $.session.usage() repeats the last figures, and an old reading never loosens the guard. Not covered, and left to the model like the other model-mix checks: run cost and the cost of work in flight, the Fable window, usage credits, Solo's one Agent call at most, a configured default subagent model, the effort that goes with Sonnet, and the Haiku stage and context rules (keeping Haiku off verify, judge and security work, sending work above about 100K tokens to Sonnet, the Opus review of the first Haiku ticket), since a mod cannot tell what an agent is for or how much it will read. |
| skill-router | Smart activation of skills: when your message calls for a skill that is not loaded (for example "vado a dormire" for overnight, "controlla le pr" for merge-gate), it adds one line telling Claude to load it and shows a dim line in the transcript; before the first workflow, agent, cloud launch, merge or mod edit of a session it asks once for the skills that step needs. Messages sent directly to cloud workers and subagents' own calls are never held; a claude launch through Start-Process or cmd start is held once like a cloud launch. Its gates are nudges and fail open: a gate that fails lets the call through. |
model-guard computes the budget color with budget.js, which follows model-mix/budget.md, from the Claude plan: line of your CLAUDE.md. Without that line (or the mod's planLine option) it cannot tell Pro from Max and assumes Max 5x, so on Pro add the line.
Install from this repository as a marketplace:
claude plugin marketplace add emanueledenaro/claude-skills
claude plugin install model-guard@emanueledenaro
claude plugin install skill-router@emanueledenaro
To try them first, run claude --plugin-dir mods/model-guard --plugin-dir mods/skill-router from a clone. Turn one off in /plugin (Desktop: + → Plugins → Manage plugins).
MIT
hooks/register.js 233 lines1// skill-router: the right skill loads at the right moment, by deterministic rules and no model call.
2// 1. Tracks the skills the main conversation loaded (the Skill tool, slash-command expansion, and
3// the transcript after a resume or fork); skills a subagent loads stay in the subagent.
4// 2. Knows which skills exist ($.command.list() at session start).
5// 3. On the person's prompts, adds one hidden English line naming the matching skills not loaded yet.
6// 4. Holds a launch, a merge or a mod edit of the main conversation once until its prerequisite
7// skills are loaded; subagents' calls pass and leave the gates armed.
8// The only module that touches `$`; the route table and every decision live in routes.js.
9
10import {
11 ancestorDirs, contextLine, gateCheck, gateLog, isPersonOrigin, isUnder, joinPath, logLine,
12 namesFrom, pickSuggestions, samePath, shellGates, skillTail, trustedList, writeGates,
13} from './routes.js'
14
15const config = { lang: 'it', gate: 'deny-once', suggest: 'on' }
16const memo = {
17 installed: null, // Set of skill names from $.command.list(); null = unknown
18 listTries: 0,
19 loaded: new Set(), // skill names (without plugin prefix) loaded in this conversation
20 suggestedAt: new Map(), // skill -> number of the person's prompt it was last suggested on
21 prompts: 0, // the person's prompts in this conversation
22 fired: new Set(), // gate keys that already held a call
23 generation: 0, // bumped on every new conversation
24 rescan: false, // the transcript may hold skills loaded before this process or conversation
25 rescanning: null, // the running or finished rebuild
26}
27
28// A new conversation (/clear, /resume, a fork) starts with nothing suggested or held; /resume and a
29// fork then rebuild what is loaded from their transcript (rescan), /clear starts with nothing loaded.
30function resetConversation() {
31 memo.loaded = new Set()
32 memo.suggestedAt = new Map()
33 memo.prompts = 0
34 memo.fired = new Set()
35 memo.generation++
36}
37
38function markLoaded(name) {
39 const tail = skillTail(name)
40 if (tail) memo.loaded.add(tail)
41}
42
43// The skills the main conversation's transcript already loaded: Skill tool uses that did not fail,
44// and slash commands the person typed. Read lazily, at the first prompt or gate after the flag is
45// set, so a transcript that is not readable yet when the session starts is still read.
46async function rebuildLoaded($) {
47 const generation = memo.generation
48 try {
49 const messages = await $.session.messages()
50 if (!Array.isArray(messages) || generation !== memo.generation) return
51 for (const m of messages) {
52 if (!m) continue
53 for (const u of Array.isArray(m.toolUses) ? m.toolUses : []) {
54 if (u && u.tool === 'Skill' && u.isError !== true && u.input && typeof u.input.skill === 'string') markLoaded(u.input.skill)
55 }
56 if (m.role === 'user' && typeof m.text === 'string') {
57 for (const x of m.text.matchAll(/<command-name>\/?([^<\s]+)<\/command-name>/g)) markLoaded(x[1])
58 }
59 }
60 } catch {}
61}
62
63// Starts the rebuild once per flag; parallel callers await the same one, so none decides early.
64function rescanLoaded($) {
65 if (memo.rescan) {
66 memo.rescan = false
67 memo.rescanning = rebuildLoaded($)
68 }
69 return memo.rescanning
70}
71
72// At most 3 tries per process: a failing list, or one naming no routed skill, leaves `installed` unknown.
73async function refreshInstalled($) {
74 if (memo.installed || memo.listTries >= 3) return
75 memo.listTries++
76 try {
77 const names = namesFrom(await $.command.list())
78 if (trustedList(names)) memo.installed = names
79 } catch {}
80}
81
82function log($, text) {
83 try {
84 $.ui.log(text, { to: 'transcript' })
85 } catch {}
86}
87
88async function exists($, path) {
89 try {
90 return (await $.fs.exists(path)) === true
91 } catch {
92 return false
93 }
94}
95
96// The mod a file belongs to: the nearest folder above it with .claude-plugin/plugin.json, when that
97// folder also has hooks/. A file being written as that manifest, or under hooks/, counts as there.
98async function inMod($, filePath) {
99 for (const dir of ancestorDirs(filePath)) {
100 const manifest = joinPath(dir, '.claude-plugin', 'plugin.json')
101 if (!samePath(filePath, manifest) && !(await exists($, manifest))) continue
102 const hooks = joinPath(dir, 'hooks')
103 return isUnder(filePath, hooks) || (await exists($, hooks))
104 }
105 return false
106}
107
108// Holds the call once when one of these gates fires; check and mark run with no await in between,
109// so parallel calls in one batch hold only the first.
110function hold($, e, next, keys) {
111 const check = gateCheck(keys, memo)
112 if (!check) return next(e)
113 for (const key of check.keys) memo.fired.add(key)
114 log($, gateLog(e.tool, check.skills, config.lang))
115 return { deny: check.reason }
116}
117
118async function passOn($, e, next) {
119 return next(e)
120}
121
122export function register(on, options) {
123 const opts = options || {}
124 config.lang = opts.language === 'en' ? 'en' : 'it'
125 config.gate = opts.gate === 'off' ? 'off' : 'deny-once'
126 config.suggest = opts.suggest === 'off' ? 'off' : 'on'
127
128 // ------------------------------------------------ observers: they record and pass on unchanged
129
130 // A process started with --resume or --continue holds a transcript from its first moment: read it
131 // once (on a fresh start the read finds nothing).
132 on('session.start', async ($, e, next) => {
133 memo.rescan = true
134 const result = await next(e)
135 await refreshInstalled($)
136 return result
137 }).catch(passOn)
138
139 on('classic.SessionStart', async ($, e, next) => {
140 if (e.source === 'clear') resetConversation()
141 else if (e.source === 'resume' || e.source === 'fork') {
142 resetConversation()
143 memo.rescan = true
144 }
145 return next(e)
146 }).catch(passOn)
147
148 // Only the main conversation's loads count (no agentId), and only a call that ran: an error or a
149 // deny from a hook beneath loaded nothing. skill.prompt is not read: it fires for subagents and
150 // preloads too, without saying whose loop it is.
151 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
152 const result = await next(e)
153 if (e.agentId === undefined && result && !result.isError && typeof result.deny !== 'string') markLoaded(e.skill)
154 return result
155 }).catch(passOn)
156
157 on('classic.UserPromptExpansion', async ($, e, next) => {
158 if (e.agent_id === undefined && e.expansion_type !== 'mcp_prompt') markLoaded(e.command_name)
159 return next(e)
160 }).catch(passOn)
161
162 // ------------------------------------------------ suggestions on the person's prompts
163
164 if (config.suggest !== 'off') {
165 // Adds one context line and never touches the prompt text; a failure passes the prompt on.
166 on('prompt.submit', async ($, e, next) => {
167 if (!isPersonOrigin(e.origin)) return next(e)
168 if (typeof e.text !== 'string') throw new TypeError('prompt text is not text')
169 memo.prompts++
170 await refreshInstalled($)
171 await rescanLoaded($)
172 const picks = pickSuggestions({
173 text: e.text,
174 installed: memo.installed,
175 loaded: memo.loaded,
176 suggestedAt: memo.suggestedAt,
177 promptNo: memo.prompts,
178 })
179 if (!picks.length) return next(e)
180 const context = [...(e.context || []), contextLine(picks)]
181 for (const p of picks) memo.suggestedAt.set(p.skill, memo.prompts)
182 log($, logLine(picks, config.lang))
183 return next({ ...e, context })
184 }).catch(passOn)
185 }
186
187 // ------------------------------------------------ prerequisite gates (deny once)
188
189 if (config.gate !== 'off') {
190 // Fail-open on purpose: these gates are nudges, not guards, so .catch(passOn) lets a failing gate's call through.
191 // Each holds a call at most once per session per requirement and the retry always passes. They
192 // read only the main conversation's calls: a subagent's or workflow agent's (agentId set) passes
193 // and spends no gate.
194
195 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
196 if (e.agentId !== undefined) return next(e)
197 const resume = e.resumeFromRunId
198 if (resume !== undefined && resume !== null) {
199 if (typeof resume !== 'string') throw new TypeError('resumeFromRunId is not text')
200 return next(e) // resuming finishes open work: nothing to hold
201 }
202 await rescanLoaded($)
203 return hold($, e, next, ['workflow'])
204 }).catch(passOn)
205
206 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
207 if (e.agentId !== undefined) return next(e)
208 await rescanLoaded($)
209 return hold($, e, next, ['agent'])
210 }).catch(passOn)
211
212 // Every tool that runs a shell command; a Monitor watch without a command (a WebSocket) passes.
213 on('tool.call', { tool: ['Bash', 'PowerShell', 'Monitor'] }, async ($, e, next) => {
214 if (e.agentId !== undefined) return next(e)
215 if (e.tool === 'Monitor' && (e.command === undefined || e.command === null)) return next(e)
216 const keys = shellGates(e.command)
217 if (!keys.length) return next(e)
218 await rescanLoaded($)
219 return hold($, e, next, keys)
220 }).catch(passOn)
221
222 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
223 if (e.agentId !== undefined) return next(e)
224 const keys = writeGates(e)
225 await rescanLoaded($)
226 // No file system call unless a gate could still fire.
227 if (!gateCheck(keys, memo)) return next(e)
228 if (!(await inMod($, e.file_path))) return next(e)
229 return hold($, e, next, keys)
230 }).catch(passOn)
231 }
232}
233hooks/routes.js 1325 lines1// skill-router routes: the route table and the pure matching functions. No `$` here.
2// Data in, data out: register.js keeps the state and makes every call.
3//
4// A route: { skill, why, priority, prompt: [RegExp...], unless?: [skill...], personOnly?: true }
5// why a few English words the model reads beside the skill name
6// priority higher first; process skills (model-mix, smart-ultracode, coordinator-method) lead
7// prompt any one pattern matching the normalized prompt is enough
8// unless drop this route when one of these skills matched too (the better fit)
9// personOnly the skill has disable-model-invocation: the model cannot load it, only suggest it
10//
11// Prompts are normalized before matching (normalize): lower case, accents stripped (è -> e),
12// curly apostrophes made straight, whitespace collapsed, fenced code blocks removed. Patterns are
13// therefore written in plain ASCII, with `e'?` where Italian writes è or e'.
14
15// Word boundaries that know letters beyond ASCII (the `u` flag with \p{L}).
16const LB = '(?<![\\p{L}\\p{N}_])'
17const RB = '(?![\\p{L}\\p{N}_])'
18
19function src(p) {
20 return typeof p === 'string' ? p : p.source
21}
22
23// A whole-word pattern: the alternatives joined, case-insensitive.
24function w(...alternatives) {
25 return new RegExp(LB + '(?:' + alternatives.map(src).join('|') + ')' + RB, 'iu')
26}
27
28// Two whole-word groups both present, in either order.
29function both(first, second) {
30 const part = alts => '(?=[\\s\\S]*?' + LB + '(?:' + alts.map(src).join('|') + ')' + RB + ')'
31 return new RegExp('^' + part(first) + part(second), 'iu')
32}
33
34// ---------------------------------------------------------------- shared word groups
35
36const GRILL = [
37 /grill(ing|ami|a)?/, /grill me/, /interview me/, /intervistami/, /fammi (delle |qualche )?domande/,
38 /stress[- ]?test(a|are|iamo)? (il |del |questo |questa |la |the |this |my |our )?(piano|plan|design|idea|proposta|proposal|architettura)/,
39 /mettimi alla prova/, /challenge (my|this|the) (plan|design|idea)/,
40]
41const DOCS = [
42 /docs?/, /documentazione/, /glossario/, /glossary/, /adrs?/, /context\.md/, /domain model/,
43 /modello di dominio/, /terminologia/, /terminology/,
44]
45// /code-review is left out: Claude Code has a built-in of that name, which matt-bridge does not cover.
46const MATT_COMMANDS = /\/(grill-me|grill-with-docs|grilling|to-prd|to-spec|to-issues|to-tickets|tdd|implement|implement-spec|pr|triage|diagnose|diagnosing-bugs|prototype|handoff|wayfinder|improve-codebase-architecture|setup-matt-pocock-skills|ask-matt|zoom-out)/
47// "tonight" counts only next to work ("lascia girare tutto stanotte", "lancia un worker stanotte"),
48// not in any sentence that mentions the night.
49const TONIGHT_WORK = [
50 /(lavora|lancia|lascia|fai|fallo|falla|gira|continua|procedi|mergia|esegui|avvia|controlla|finisci|sistema|implementa)\w*( \S+){0,5} (stanotte|questa notte)/,
51 /(stanotte|questa notte)( \S+){0,3} (lavora|continua|procedi|gira|lancia|mergia|controlla|finisci)\w*/,
52]
53
54// ---------------------------------------------------------------- the route table
55
56export const ROUTES = [
57 // ------------------------------------------------ process skills
58 {
59 skill: 'model-mix',
60 why: 'model, effort and usage budget for delegated work',
61 priority: 100,
62 prompt: [
63 // launching agents, workers, workflows, reviews
64 w(/(lancia|lanciare|lanciamo|lancio|avvia|avviare|avviamo|fai partire|spawna|delega|delegare|manda|mandare) (un |uno |una |il |lo |la |i |gli |le |dei |degli |delle |altri |altre |\d+ |due |tre |quattro |cinque |sei |otto |piu' )?(nuov[oaie] )?(agent[ei]|subagent[ei]?|sub-agent[ei]?|worker|workflow|review|revision[ei]|reviewer|sessione cloud|sessioni cloud)/),
65 w(/(launch|start|spawn|kick off|fire off|delegate to|dispatch|send out) (an? |the |some |more |another |\d+ |two |three |four |five |six |eight |several |multiple |parallel |new )*(agents?|subagents?|sub-agents?|workers?|workflows?|reviewers?|cloud sessions?|code reviews?|reviews?)/),
66 // choosing model and effort
67 w(/(quale|che|which|what) effort/, /effort (alto|basso|medio|massimo|high|low|medium|xhigh|max)/,
68 /(sonnet|opus|fable|haiku) (o|oppure|or|vs\.?|versus) (sonnet|opus|fable|haiku)/,
69 /(con|su|usa|usare|use|with|on) (sonnet|opus|fable|haiku)/,
70 /(quale|che) modello (uso|usare|usiamo|devo usare|per (il |i |l'|lo |la |gli |le )?(worker|agent[ei]|subagent[ei]?|review|workflow|sessione))/,
71 /which model (should|to use|for (the |this |a |each )?(worker|agent|subagent|review|workflow|session))/),
72 // usage, consumo, budget, limite, piano
73 w(/(weekly|settimanale|5-hour|five-hour|5h|plan|account|claude|token) usage/,
74 /usage (limits?|budget|left|remaining|settimanale|del piano)/, /\/usage/, /how much usage/, /quanto usage/, /l'usage/,
75 /consum\w* (di |dei |del |della )?(token|usage|budget|piano|abbonamento|limite|limiti)/,
76 /quanto (ho |abbiamo |sto |stiamo |hai |ha )?consumat[oi]/, /consumo settimanale/),
77 w(/budget (di oggi|settimanale|del piano|rimasto|residuo|left|today|check|verde|giallo|rosso|green|yellow|red)/,
78 /budget (ci |mi )?(resta|rimane)/, /(quanto|how much) budget/, /(controlla|verifica|check) (il |the )?budget/,
79 /(c'?e'?|abbiamo|is there|do we have|we have) (ancora )?(abbastanza |enough )?budget/),
80 w(/limit[ei] (settimanal[ei]|delle 5 ore|di 5 ore|di utilizzo|d'uso|del piano|di claude)/,
81 /(weekly|5-hour|five-hour|5h|usage|plan) limits?/, /finestra (delle |di )?5 ore/, /5-hour window/),
82 // plan tiers and draining the account
83 w(/max (da )?(100|200|5 ?x|20 ?x)/, /(piano|account|abbonamento|plan) (plus|pro|max)/,
84 /(senza (far )?|non )consumare (tutto|tutti)/, /consumare tutto l'?account/, /(burn|drain)\w* (through )?(the |my )?(account|plan|limits?)/),
85 w(/piano (max|pro|team|enterprise|claude)/, /(mio|nostro|del|sul) piano (claude|max|pro|di abbonamento)/,
86 /max (20x|5x)/, /(my|the|our) (claude |max |pro )?plan (limits?|allows|budget)/),
87 ],
88 },
89 {
90 skill: 'smart-ultracode',
91 why: 'when and how wide to run a multi-agent workflow',
92 priority: 95,
93 prompt: [
94 w(/ultracode/, /fan[- ]?out/, /multi-?agent[ei]?/, /multiagente/,
95 /orchestr\w* (gli |degli |dei |the |of |many |multiple |several )?(agent[ei]?|agents|subagent\w*|worker)/),
96 w(/workflow (multi-?agent[ei]?|di agenti|script|tool)/,
97 /(scrivi|scrivere|lancia|lanciare|avvia|write|launch|start) (un |il |a |the )?workflow/),
98 w(/(agent[ei]|worker|subagent[ei]?|sessioni)( \S+){0,3} in parallelo/, /in parallelo( \S+){0,3} (agent[ei]|worker|subagent[ei]?)/,
99 /parallel (agents|subagents|workers|reviewers)/, /in parallel (with|across|using) (agents|subagents|workers)/),
100 w(/(tanti|molti|piu'|parecchi|diversi|\d+) (agent[ei]|subagent[ei]?)/, /(many|lots of|multiple|several|a dozen|\d+) (agents|subagents)/),
101 w(/audit completo/, /full audit/, /complete audit/,
102 /audit (dell'intero|di tutto il|dell'intera|del|della) (repo|codebase|progetto|base di codice)/,
103 /audit (of )?(the )?(whole|entire) (repo|codebase|project)/),
104 ],
105 },
106 {
107 skill: 'coordinator-method',
108 why: 'delegating, merging and rounds as coordinator',
109 priority: 90,
110 prompt: [
111 w(/coordin(a|are|ati|atore|atrice|amento|ando)/, /coordinator/, /coordinate (the )?(workers|agents|work|sessions)/),
112 // worker, but not the web/service/queue kinds
113 w(/(?<!(web|service|shared|celery|cloudflare|sidekiq|queue|background|thread|gunicorn|uvicorn|pool|node) )workers?/),
114 // "merge" only together with PR words (or the Italian verb, which means a PR merge here)
115 w(/merg\w*( \S+){0,4} (pr|prs|pull requests?)/, /(pr|prs|pull requests?)( \S+){0,4} merg\w*/, /mergia\w*/),
116 w(/(nuovo|prossimo|altro) (giro|round)/, /giro di (controllo|controlli|check|merge|review)/,
117 /(inizia|iniziamo|comincia|cominciamo) (un |il |un altro |il prossimo |un nuovo )?(giro|round)/,
118 /(start|next|new|another) round/, /round (di|of) (check|checks|controllo|controlli|review|merge)/),
119 w(/roadmap/),
120 w(/vado a (dormire|letto)/, ...TONIGHT_WORK, /tutta la notte/, /durante la notte/, /overnight/, /buona ?notte/),
121 w(/deleg(a|are|ato|ata|ando|hiamo)/, /delegate (this|it|the work|to)/),
122 ],
123 },
124
125 // ------------------------------------------------ the eleven new skills
126 {
127 skill: 'overnight',
128 why: 'going to sleep or away for hours',
129 priority: 80,
130 prompt: [
131 w(/vado a (dormire|letto|nanna)/, /me ne vado a (dormire|letto)/, /buona ?notte/, ...TONIGHT_WORK,
132 /tutta la notte/, /durante la notte/,
133 /(lascia|lasciala|lascialo|fallo|falla|tienilo|tienila) (girare|lavorare|andare|in esecuzione)/,
134 /lavora (mentre|finche') (dormo|non ci sono|sono via)/, /mentre (dormo|sono via|non ci sono)/,
135 /torno (domattina|domani mattina|tra (qualche|un paio d'|\d+) or[ae])/,
136 /sono via (tutta la notte|per (qualche|\d+|un paio d') or[ae]|fino a domani)/,
137 /vado (fuori|via) per (qualche|un paio d'|\d+) or[ae]/, /mi assento/),
138 w(/going to (sleep|bed)/, /off to (sleep|bed)/, /good ?night/, /overnight/, /tonight/,
139 /keep (going|working|running) (tonight|while i'?m away|while i sleep)/,
140 /while i(?:'m| am)? (sleep|sleeping|away|out|gone)/, /away until (morning|tomorrow)/,
141 /(leaving|logging off|signing off) for the (night|day)/, /unattended (run|session|work)/,
142 /(back|be back) (in the morning|tomorrow morning|in a few hours)/),
143 // loops, wakes, watchers that last hours
144 w(/svegliati ogni/, /sveglia(mi)? ogni/, /wake (me |up )?every/, /set up a wake/,
145 /(imposta|metti) (un |una )?(wake|sveglia|watcher|monitor)/, /monitora (le pr|la ci|i worker)/,
146 /poll (the )?(prs?|ci)( overnight)?/, /controlla (la ci|le pr) (stanotte|di notte)/),
147 // limits during the night, machine awake, notifications, morning summary
148 w(/autocontinueatusagelimit/, /(riprende|ripartira'?|resumes?|continues?) (dopo|after) (il |the )?(reset|limite|limit)/,
149 /(tieni|tenere|mantieni) (il )?(computer|pc|portatile|mac) sveglio/, /keep (the |my )?(computer|pc|laptop|machine|mac) awake/,
150 /(va|andra'?) in (standby|sospensione)/, /remote control (dal|from) (telefono|phone)/,
151 /avvisami solo/, /(mandami|avvisami con) (una )?(notifica|push)/, /send me (a )?(push|notification)/,
152 /notify me only/, /morning summary/,
153 /riepilogo (di stamattina|mattutino|del mattino)/, /cosa hai fatto (stanotte|questa notte)/,
154 /what did you do (last night|overnight)/, /quanto costa tenere (la sessione )?aperta/),
155 ],
156 },
157 {
158 skill: 'merge-gate',
159 why: 'merging a PR',
160 priority: 79,
161 prompt: [
162 // "merge" only together with PR words
163 w(/merg\w*( \S+){0,4} (pr|prs|pull requests?)/, /(pr|prs|pull requests?)( \S+){0,4} merg\w*/,
164 /mergia(re|mo|la|le|lo)?/, /merg\w* (la |the )?#\d+/,
165 /(unisci|integra) (la |questa |le )?(pr|pull request)/),
166 w(/(pr|pull request)( #?\d+)? (e'? )?(pronta|verde|approvata)/, /(pr|pull request)( #?\d+)? (is )?(ready|green|approved)/,
167 /ready to merge/, /(ok|good) to merge/, /e'? verde,? (puoi )?(fare il )?merge/,
168 /(review|revisione|controlla\w*) prima del merge/, /(review|check) (this |it |the pr )?before (the )?merg(e|ing)/,
169 /pre-merge/, /merge[- ]gate/, /squash and merge/, /gh pr merge/, /dependabot/,
170 /(controlla|rivedi|revisiona|guarda) (le |la |questa |queste |tutte le )?(pr|pull request)\b/, /(check|review) (the |these |this |my |open )?(prs|pull requests)\b/),
171 w(/ship (it|this pr|the pr|questa pr)/, /rilascia (la |questa )pr/,
172 /chiudi (le )?pr (superate|duplicate|vecchie)/, /close (the )?superseded (prs|pull requests)/,
173 /(il )?merge (e'? stato )?(rifiutato|bloccato)/, /merge (was )?(refused|blocked|rejected)/, /merge without review/,
174 /sincronizza (le )?copie installate/),
175 ],
176 },
177 {
178 skill: 'cloud-worker',
179 why: 'launching and following cloud workers',
180 priority: 78,
181 prompt: [
182 w(/(worker|sessione|sessioni) (cloud|remot[oaie])/, /cloud (worker|workers|sessions?|sessione)/, /remote workers?/,
183 /claude --cloud/, /--cloud/, /move_to_cloud/, /--teleport/, /teleport/),
184 w(/(manda|mandalo|mandala|sposta|spostalo|fallo girare|falla girare|fai girare|esegui|lancia|gira) (\S+ ){0,4}(in|nel|sul) cloud/,
185 /(run|send|move|push|put) (\S+ ){0,4}(in|to|on) the cloud/),
186 w(/launch\.exp/, /launch\.ps1/, /expect (non c'?e'?|non trovato|not found|is missing|missing)/,
187 /(no|senza) expect/, /lancio worker senza expect/),
188 w(/(recupera|raccogli|collect|find|trova) (la |the )?pr (del|of the|from the) worker/,
189 /(collect|find) the worker'?s pr/, /dov'?e'? finito il worker/,
190 /(scrivi|manda un messaggio|message) (al|to the) (cloud )?worker( cloud)?/,
191 /(controlla|check) (la |the )?(sessione cloud|cloud session)/),
192 ],
193 },
194 {
195 skill: 'smart-mods',
196 why: 'writing, testing or installing a mod',
197 priority: 77,
198 prompt: [
199 w(/mods?/),
200 w(/claude plugin/, /--plugin-dir/, /plugin\.json/, /plugin marketplace/, /marketplace (di|of) plugin/,
201 /plugin (di|per|for) claude( code)?/, /claude code plugin/,
202 /(installa|installare|install|vet|controlla|valida|validate|scrivi|scrivere|write|crea|creare|create|review) (un |il |questo |quel |this |that |a |the )?plugin/),
203 w(/(pannello|pane|fascia|band|banda|barra) (sopra|above|over) (il |the )?(prompt|composer|input)/,
204 /(pannello|pane) (con|with) (le |the )?(tab|tabs|schede)/,
205 /(pannello|pane|fascia|band|status ?line|toast|spinner) (in|di|dentro|inside|for|per|nel) claude( code)?/),
206 w(/(nuovo|crea|creare|aggiungi|aggiungere|registra|registrare|scrivi|scrivere) (un |il |nuovo )*comando( slash| \/\S+)/,
207 /(new|custom|create|add|register|write) (a |an |the )?(custom )?slash command/, /\$\.command\.register/),
208 w(/guard (che|that|per|for|on|su|sui|sulle|sul) (i |le |the )?(tool|bash|powershell|comandi|commands|chiamate|calls)/,
209 /(tool|command|shell|bash|powershell) guard/, /guard hook/, /tool\.call/, /tool\.check/, /hooks\.json/, /plugin-authoring/),
210 ],
211 },
212 {
213 skill: 'mod-ui',
214 why: 'what a mod draws: band, pane, card, toast',
215 priority: 76,
216 prompt: [
217 w(/(interfaccia|aspetto|colori|grafica|stile|tema|ui|disegno) (del|dei|di un|per il|per un|of the|of a|for the|for a) mods?/,
218 /mods? (ui|interface|interfaccia)/, /(restyle|ristila|ridisegna|redraw) (il |the |this |questo )?mod/, /theme keys/),
219 w(/(barra|fascia|band|banda) (sopra|above|over) (il |the )?(prompt|composer|input)/,
220 /(pannello|pane) (con|with) (le |the )?(tab|tabs|schede)/,
221 /(pannello|pane|band|fascia|card|scheda|toast|notifica) (al|del|nel|for the|to the|in the|of the) mod/,
222 /(scheda|card) (nella|in the|in) chat/, /toast (solo|only) (per|for)/,
223 /(mostra|show) (una |un |a )?(notifica|toast) (in|dentro|nel|inside) claude/),
224 w(/stato sempre visibile/, /always visible (status|state)/, /status ?line (del|di un|for a|of the|of a|per il) mod/,
225 /svg (progress )?bar/, /(text|testo) fallback/, /fallback (di testo|testuale|text)/,
226 /aboveprompt/, /ui\.render/, /commandoutput/),
227 ],
228 },
229 {
230 skill: 'matt-bridge',
231 why: "running Matt Pocock's skills in this workflow",
232 priority: 75,
233 prompt: [
234 w(MATT_COMMANDS),
235 w(/skills? (di|of|by) matt( pocock)?/, /matt'?s skills?/, /matt pocock/),
236 w(/(dall'|da un'|l')idea (al|alla|a|in|ai) (prd|spec|ticket)/, /idea to (prd|spec|shipped|tickets)/,
237 /(trasforma|turn|convert) (l'idea|this|it|the idea|questa idea) (in|into) (un |una |a )?(prd|spec)/,
238 /prd e (i )?ticket/),
239 w(/implementa (la spec|il ticket|l'issue|la issue)/, /implement (the |this )?(spec|ticket)/,
240 /ready-for-agent/, /ready-for-human/, /needs-info/, /afk agent/, /red-green-refactor/,
241 /passa il lavoro a un worker/, /scrivi l'handoff/, /fammi il grilling/, /fai il triage/,
242 /stress-?testa il piano/, /stress-test this plan/, /spezza in (issue|ticket)/, /break (it|this) into issues/,
243 /fai un prototipo/, /prototype this/, /diagnostica il bug/, /diagnose this bug/, /hand this off/),
244 ],
245 },
246 {
247 skill: 'roadmap-tracker',
248 why: 'roadmap issue, milestones, labels, tickets',
249 priority: 74,
250 prompt: [
251 w(/roadmap/),
252 w(/ho mergiato/, /abbiamo mergiato/, /(pr|pull request) (e'? stata )?(mergiata|merged)/, /merged (the |this )?(pr|pull request)/),
253 w(/(inizia|iniziamo|cominciamo|start) (un |il |a |the )?(nuovo |new )?(round|giro)/, /where were we/,
254 /dove eravamo( rimasti)?/, /riparti(amo)? dalla roadmap/),
255 w(/(apri|aprire|apriamo|crea|creare|creiamo) (un |una |l'|il |la |i |gli |le |dei |delle |degli |nuov[oaie] )*(issue|ticket)/,
256 /(open|create|file) (an |a |the |new |some )+(issues?|tickets?)/,
257 /break (this |it |the plan )?(down )?into (issues|tickets)/, /spezza in (issue|ticket|task)/),
258 w(/cosa c'?e'? in coda/, /cosa (aspetta|attende) me/, /(what'?s|what is) (ready|queued|waiting|blocked)/,
259 /ready for agents?/, /cosa e'? bloccato/),
260 w(/milestones?/, /ready-for-agent/, /ready-for-human/, /needs-info/, /needs-triage/, /wontfix/,
261 /labels? (di triage|del tracker|delle issue|su github|on (the )?issues|for (the )?issues)/,
262 /(sistema|sistemare|imposta|impostare|set up|fix) (le |the )?labels/),
263 w(/(fissa|pinna|pin|unpin) (la |the )?roadmap/, /(controlla|check|audit|pulisci|clean up) (il |the )?(tracker|backlog)/,
264 /backlog/, /chiudi (il |i |questo |the )?ticket/, /close (it |this )?as not planned/,
265 /collega (i |le )?(ticket|issue)/, /blocked-by/,
266 /mark (it |this |them )?as blocked by/, /(issue|issues|ticket|tickets)( \S+){0,2} blocked by/, /sub-?issues?/),
267 ],
268 },
269 {
270 skill: 'context-hygiene',
271 why: 'context size: compact, clear or hand off',
272 priority: 73,
273 prompt: [
274 w(/sessione (lunga|lunghissima|pesante)/, /troppo contesto/, /contesto (e'? )?(pieno|alto|quasi pieno|al \d+)/,
275 /context (window )?(is )?(filling|full|almost full|high|at \d+)/, /how full is (the |my )?context/, /long session/),
276 w(/\/compact/, /\/clear/, /compatt(a|are|iamo|ala) (la |il )?(sessione|conversazione|contesto|chat)/,
277 /(clear|compact) (or|vs\.?|o|oppure) (clear|compact|handoff)/, /serve un handoff/,
278 /(hand ?off|passa) (to|a) (a |una )?(fresh|new|nuova) (session|sessione)/, /auto-?compact/),
279 w(/consuma troppo/, /(i )?token (finiscono|stanno finendo)/, /usage (drains|is draining|goes down) fast/,
280 /why is (this|it) so expensive/, /reduce (the )?token (usage|cost)/, /riduci (i |il consumo di )?token/,
281 /what is eating (my |the )?context/, /cosa (mangia|occupa) (il )?contesto/, /\/context/, /\/usage attribution/),
282 w(/troppi (mcp|server mcp|skill|connettori)/, /too many (mcp|mcp servers|skills|connectors|tools)/,
283 /disable unused (mcp )?servers/, /(trim|snellisci|accorcia) (il )?claude\.md/, /claude\.md (troppo lungo|too long)/),
284 w(/prompt cache/, /cache (ttl|miss)/, /cache (di|da) (1|un') ?ora/, /1-hour cache/, /la cache (scade|e'? scaduta)/),
285 ],
286 },
287 {
288 skill: 'verified-research',
289 why: 'research with checked sources',
290 priority: 72,
291 prompt: [
292 // not a search through files or code ("fai una ricerca nel file per TODO")
293 w(/(fai|fare|fammi|serve|facciamo) (una )?ricerca(?! (nel|nei|nella|nelle|in|del|dei|della|sul|sui) (file|codice|repo|repository|progetto|cartella|directory|log|sorgenti))/,
294 /ricerca (approfondita|verificata|con fonti|sul web|online)/,
295 /deep research/, /(do|run) (some |a )?research/, /research (on|into|about|what|whether|how)/),
296 w(/verifica (queste|questa|le|la) (affermazioni|affermazione|fonti|fonte|claim|info|informazioni)/,
297 /controlla le fonti/, /check (the )?sources/, /fact-?check/, /is (this|that|it) still true/, /e'? ancora vero/,
298 /vale ancora/, /is (this|that) (still )?(accurate|up to date|current)/),
299 w(/quali sono i limiti (adesso|ora|attuali|oggi)/, /what are the (current )?limits/,
300 /(con|with) (fonti|sources) (ufficiali|official)/, /fonti ufficiali/, /official (sources|docs|documentation)/,
301 /cosa dicono (i docs|la documentazione)/, /what (do|does) the docs say/, /non fidarti della memoria/,
302 /don'?t trust (your )?memory/, /apri la fonte/, /confronta (le )?opzioni/, /compare (the )?options/),
303 ],
304 },
305 {
306 skill: 'release-watch',
307 why: 'new Claude Code releases, models, limits',
308 priority: 71,
309 prompt: [
310 w(/(novita'?|qualcosa di nuovo|cosa c'?e'? di nuovo) (in|su|di|per) claude( code)?/,
311 /(anything|what'?s) new (in|with|for) claude( code)?/,
312 /nuova versione (di |del )?(claude|desktop)/, /new (claude code |claude |desktop )(version|release)/,
313 /(e'?|sono) uscit[oaie] (una |un )?(nuova versione|claude|haiku|opus|sonnet|nuovo modello)/,
314 /did (a |the )?(new )?(claude code )?(version|release) (ship|come out)/,
315 /controlla (gli )?aggiornamenti/, /check for updates/, /claude update/, /release[- ]?watch/,
316 /changelog (di|of) claude( code)?/, /claude( code)? changelog/, /cosa e'? cambiato nel changelog/,
317 /what changed in the changelog/),
318 w(/(che|quale|which) versione( di claude code)? (ha|usa) (il )?desktop/, /which version does (the )?desktop/,
319 /haiku 5\.5/, /is haiku .{0,6} out/, /nuovo modello (di claude|anthropic|uscito)/,
320 /(e'? uscito|is out|released|rilasciato) (un |a )?(nuovo modello|new model)/,
321 /(model|modello|modelli)( \S+){0,2} (retir\w*|in ritiro|deprecat\w*)/,
322 /sono cambiati i (modelli|limiti)/, /(have|did) the (models?|limits?|plan limits?) change/,
323 /(api|tipi) dei mod/, /mods? (api|types)( changed| cambiat\w*)/, /(did|has) the mods api change/,
324 /skill di matt sono cambiate/, /upstream (changes?|changed|cambiat\w*)/, /(changed|cambiat\w*) upstream/,
325 /skills? (obsolete|out of date|outdated)/, /tienimi aggiornato/, /version floor/),
326 ],
327 },
328 {
329 skill: 'skill-audit',
330 why: 'auditing skills: triggers, tokens, risk',
331 priority: 70,
332 prompt: [
333 w(/audit (delle|the|my|of|of my|of the) skills?/, /skills? audit/, /controlla le skill/, /rivedi (questa|la) skill/,
334 /review (this|the|my) (skill|skill\.md)/, /skill\.md/, /snellisci (la |le )?skill/, /accorcia la descrizione/,
335 /trim (the |a |this )?skill/),
336 w(/la skill non parte/, /skill (doesn'?t|does not|won'?t|isn'?t) trigger/, /parte quando non deve/,
337 /triggers? (at the wrong time|when it shouldn'?t)/, /due skill si contendono/, /two skills (compete|fight)/),
338 w(/costo (in )?token (di una|della|delle) skill/, /skill token cost/, /quanto costano le skill/,
339 /il contesto e'? pesante di skill/, /skills? listing/, /skill di terzi/, /third-party skills?/,
340 /e'? sicura questa skill/, /is this skill safe/, /(valida|validate) (il |the )?frontmatter/,
341 /skill frontmatter/, /disable-model-invocation/, /skilloverrides/, /context: fork/, /regole duplicate/,
342 /duplicated rules/, /skill-doctor/),
343 ],
344 },
345 {
346 skill: 'skill-creator',
347 why: 'creating a new skill',
348 priority: 65,
349 prompt: [
350 w(/(crea|creare|creiamo|scrivi|scrivere|scriviamo|fai|fare|facciamo) (una |delle |altre |nuove |la )?(nuova |nuove )?skills?\b/,
351 /(che |quali )?altre skills? (possiamo|potremmo|puoi) (creare|fare|scrivere)/,
352 /(create|write|make|build) (a |new |another |more |other )?(new )?skills?\b/, /what other skills (can|could) we/),
353 ],
354 },
355 {
356 skill: 'windows-ops',
357 why: 'Windows shell, path and CRLF rules',
358 priority: 69,
359 prompt: [
360 w(/su windows/, /on windows/, /git bash/, /powershell/, /wsl/),
361 w(/(percorso|path|nome( del)? file) troppo lungo/, /(path|filename) too long/, /crlf/, /autocrlf/, /core\.longpaths/,
362 /(quale|che|which) shell/, /sintassi (del comando )?non (e'? )?valida/, /msys2?_\w+/, /claude_config_dir/,
363 /is not a valid statement separator/, /not recognized as (an internal|the name)/, /non e'? riconosciuto come comando/,
364 /(expect|pwsh) (non trovato|not found)/),
365 w(/dove (sta|stanno|tiene|si trova|si trovano) (la config|le impostazioni|i file|le skill) di claude/,
366 /where (does|do) claude( code)? keep/, /versione di claude code (del|nel|in) desktop/, /desktop vs\.? cli/),
367 ],
368 },
369
370 // ------------------------------------------------ Matt Pocock's skills
371 {
372 skill: 'grill-with-docs',
373 why: 'grilling a plan against docs, glossary and ADRs',
374 priority: 60,
375 prompt: [both(GRILL, DOCS)],
376 },
377 {
378 skill: 'grill-me',
379 why: 'one-question interview to stress-test a plan',
380 priority: 59,
381 unless: ['grill-with-docs'],
382 prompt: [w(...GRILL)],
383 },
384 {
385 skill: 'to-prd',
386 why: 'turning the discussion into a PRD',
387 priority: 58,
388 prompt: [
389 w(/prd/, /to-prd/, /product requirements?( doc(ument)?)?/,
390 /(scrivi|stendi|raccogli|documenta|definisci|scrivere|raccogliere) (i |dei )?requisiti/, /documento (dei )?requisiti/,
391 /requirements doc(ument)?/, /(write|draft) (the |a |up )?(spec|specification|requirements)/,
392 /(scrivi|stendi) (la |una )?spec(ifica)?/),
393 ],
394 },
395 {
396 skill: 'to-issues',
397 why: 'splitting a plan into tracker issues',
398 priority: 57,
399 prompt: [
400 w(/to-issues/, /(spezza|dividi) (il piano |la spec |il prd |questo )?in (task|issue|ticket)/,
401 /break (it |this |the plan |the prd |the spec |that )?(down )?into (issues|tickets|tasks)/,
402 /(crea|creare|apri|aprire|create|open) (i |gli |le |dei |degli |delle |the |some )(ticket|tickets|issues)/,
403 /convert (the |this )?(plan|prd|spec) into (issues|tickets)/, /vertical slices?/, /tracer[- ]bullets?/),
404 ],
405 },
406 {
407 skill: 'tdd',
408 why: 'red-green-refactor, tests first',
409 priority: 56,
410 prompt: [w(/tdd/, /test[- ]driven/, /test prima/, /prima i test/, /tests? first/, /test-first/, /red[- ]green/, /red\/green/)],
411 },
412 {
413 skill: 'triage',
414 why: 'triaging issues by label state',
415 priority: 55,
416 prompt: [
417 w(/triage/, /triag(ia|iare|iamo)/, /smista(re)? (le )?(issue|segnalazioni|bug)/, /incoming (issues|bugs|reports)/,
418 /prepar(e|a) (the |le )?issues? (for|per) (an |un )?(afk|agente)/),
419 ],
420 },
421 {
422 skill: 'diagnose',
423 why: 'reproduce, minimise and fix a bug',
424 priority: 54,
425 unless: ['triage'],
426 prompt: [
427 w(/bugs?/, /debugg(a|are|ami|iamo|ing)/, /(fai|facciamo|fare) (il )?debug/,
428 /debug (this|it|that|the (crash|error|issue|failure|test)|questo|questa)/),
429 w(/non funziona(no)?/, /non compila/, /non parte piu'?/, /smesso di funzionare/, /si e'? rotto/, /e'? rotto/,
430 /doesn'?t work/, /does not work/, /not working/, /isn'?t working/, /stopped working/,
431 /(is|it'?s|are|got) broken/, /crash(a|ano|ato|es|ed|ing)?/, /regressione/, /regression/),
432 w(/(c'?e'?|ho|abbiamo|ottengo|mi da'?|da'?|esce|appare|vedo|ricevo|lancia|restituisce|segnala) (un |l'|questo |quest'|lo stesso |uno strano |strano )?(errore|eccezione)/,
433 /(got|getting|get|throws?|throwing|seeing|hit|hitting) (an? |this |the |the same |a weird |weird )?(error|exception)/),
434 ],
435 },
436 {
437 skill: 'handoff',
438 why: 'handoff document for a fresh session',
439 priority: 53,
440 prompt: [
441 w(/hand-?off/, /hand off/, /passa (a|ad) (un'?|una )?altra sessione/, /riprendi (in|da) (un'?|una )?altra sessione/,
442 /(continua|riprendi|prosegui) (in|su) (una )?nuova sessione/, /(fresh|new) session (to|that) (pick|picks|continue)/,
443 /pick (it |this )?up in (a |another )(new |fresh )?session/),
444 ],
445 },
446 {
447 skill: 'prototype',
448 why: 'throwaway prototype to try a design',
449 priority: 52,
450 prompt: [w(/prototip(o|i|are|iamo|a)/, /prova di concetto/, /proof of concept/, /mock[- ]?ups?/, /prototyp(e|es|ing)/)],
451 },
452 {
453 skill: 'improve-codebase-architecture',
454 why: 'architecture and refactoring opportunities',
455 priority: 51,
456 prompt: [
457 w(/architettura/, /architecture/, /(?<!green[- ])refactor(ing|ize)?/, /refactorizza(re)?/, /accoppiament(o|i)/, /accoppiat[oaie]/,
458 /tightly[- ]coupled/, /coupling/),
459 ],
460 },
461 {
462 skill: 'zoom-out',
463 why: 'big picture of unfamiliar code',
464 priority: 50,
465 personOnly: true,
466 prompt: [w(/visione d'insieme/, /zoom[- ]?out/, /quadro generale/, /big(ger)? picture/, /vista dall'alto/, /high-level (view|overview|picture)/)],
467 },
468
469 // ------------------------------------------------ built-ins, when installed
470 {
471 skill: 'schedule',
472 why: 'scheduled cloud agent (routine)',
473 priority: 45,
474 prompt: [
475 w(/(programma|programmare|pianifica|schedula) (un |una |il |la |l')?(task|agente|routine|controllo|job|esecuzione|run|sessione)/,
476 /programmalo/, /ogni giorno alle/, /ogni (lunedi|martedi|mercoledi|giovedi|venerdi|sabato|domenica|mattina|sera)/,
477 // a routine or cron only when set up as one, not a function or a failing job
478 /(crea|creare|imposta|impostare|programma|set up|create|add|schedule) (una |un |a |the )?(nuova |new )?routines?/,
479 /(cloud|scheduled|programmat[ae]) routines?/, /routines? (cloud|programmat[ae]|schedulat[ae])/, /(le mie|my) routines/,
480 /cron ?jobs?/, /crontab/, /(imposta|crea|aggiungi|set up|add|create) (un |a )?cron/,
481 /every (day|morning|evening|weekday|monday|tuesday|wednesday|thursday|friday|saturday|sunday) at/,
482 /schedule (a|an|the) (agent|task|routine|run|job|check)/, /(una volta|once) (domani|tomorrow) (alle|at)/),
483 ],
484 },
485 {
486 skill: 'loop',
487 why: 'repeat a prompt on an interval',
488 priority: 44,
489 prompt: [
490 w(/ogni \d+ (minuti|minuto|min|secondi|ore|ora)/, /every \d+ ?(minutes?|mins?|m|seconds?|hours?|h)/,
491 /controlla di continuo/, /continua a controllare/, /keep (checking|polling)/, /\/loop/,
492 /(metti|mettilo|fallo|run it|put it) in (un )?loop/, /in loop (ogni|every)/),
493 ],
494 },
495]
496
497// ---------------------------------------------------------------- prompt matching
498
499const MAX_CHARS = 6000
500
501// The text the routes read: fenced code blocks out, lower case, no accents, straight apostrophes,
502// single spaces. Throws on anything that is not text.
503export function normalize(text) {
504 if (typeof text !== 'string') throw new TypeError('prompt text is not text')
505 return text
506 .slice(0, MAX_CHARS)
507 .replace(/```[\s\S]*?(```|$)/g, ' ')
508 .normalize('NFD')
509 .replace(/[̀-ͯ]/g, '')
510 .replace(/[‘’ʼ`´]/g, "'")
511 .replace(/\s+/g, ' ')
512 .toLowerCase()
513}
514
515// Routes whose patterns match, highest priority first (table order on ties), one per skill.
516export function matchRoutes(text, routes = ROUTES) {
517 const t = normalize(text)
518 const hits = routes.filter(r => r.prompt.some(p => p.test(t)))
519 const names = new Set(hits.map(r => r.skill))
520 return hits
521 .filter(r => !(r.unless || []).some(s => names.has(s)))
522 .map((r, i) => ({ r, i }))
523 .sort((a, b) => b.r.priority - a.r.priority || a.i - b.i)
524 .map(x => x.r)
525}
526
527// A skill or command name without its plugin prefix or slash: 'cs:merge-gate' -> 'merge-gate'.
528export function skillTail(name) {
529 if (typeof name !== 'string') throw new TypeError('skill name is not text')
530 return name.trim().replace(/^\//, '').split(':').pop().trim().toLowerCase()
531}
532
533// The names $.command.list() gives, as tails. Entries that are not { name: string } are skipped.
534export function namesFrom(list) {
535 const out = new Set()
536 if (!Array.isArray(list)) return out
537 for (const c of list) {
538 if (c && typeof c.name === 'string' && c.name.trim()) out.add(skillTail(c.name))
539 }
540 return out
541}
542
543// Whether a command list can say which skills exist: one that names none of the routed skills
544// probably lists no skills at all (an engine listing only commands), so it is not trusted and the
545// table counts instead.
546export function trustedList(names, routes = ROUTES) {
547 return names.size > 0 && routes.some(r => names.has(r.skill))
548}
549
550// The slash command a prompt starts with ('/grill-me the plan' -> 'grill-me'), else null.
551export function leadingCommand(text) {
552 if (typeof text !== 'string') throw new TypeError('prompt text is not text')
553 const m = /^\s*\/([A-Za-z0-9_:.-]+)/.exec(text)
554 return m ? skillTail(m[1]) : null
555}
556
557// Where a prompt comes from the person. Composer and bridge are typed by the person. 'sdk' is kept on
558// purpose although the types call it the SDK host's own turn: the Desktop app's Code tab delivers
559// what the person types that way, so dropping it would silence the router there. A `claude -p`
560// script gets the hidden line too; that costs one line, never a hold. Never task notifications,
561// plugins, scheduled triggers, peers or relays.
562export const PERSON_ORIGINS = ['composer', 'bridge', 'sdk']
563
564export function isPersonOrigin(origin) {
565 return !!(origin && typeof origin === 'object' && PERSON_ORIGINS.includes(origin.kind))
566}
567
568// The skills to suggest for one of the person's prompts.
569// installed Set of skill names that exist, or null when unknown (then the table counts)
570// loaded Set of skill names already loaded this session
571// suggestedAt Map skill -> number of the person's prompt it was last suggested on
572// promptNo this prompt's number (1, 2, ...)
573// A skill suggested on prompt n is skipped on prompts n+1 .. n+window.
574export function pickSuggestions({ text, installed, loaded, suggestedAt, promptNo, routes = ROUTES, max = 3, window = 5 }) {
575 const typed = leadingCommand(text)
576 const out = []
577 for (const r of matchRoutes(text, routes)) {
578 if (typed && r.skill === typed) continue
579 if (installed && !installed.has(r.skill)) continue
580 if (loaded && loaded.has(r.skill)) continue
581 const last = suggestedAt ? suggestedAt.get(r.skill) : undefined
582 if (typeof last === 'number' && promptNo - last <= window) continue
583 out.push(r)
584 if (out.length >= max) break
585 }
586 return out
587}
588
589// The one English line the model reads beside the prompt.
590export function contextLine(picks) {
591 const loadable = picks.filter(p => !p.personOnly)
592 const manual = picks.filter(p => p.personOnly)
593 const parts = []
594 if (loadable.length) {
595 parts.push(`skill-router: skills relevant to this request, not loaded yet: ${loadable.map(p => `${p.skill} (${p.why})`).join(', ')}. Load each with the Skill tool before acting.`)
596 }
597 if (manual.length) {
598 const list = manual.map(p => `/${p.skill} (${p.why})`).join(', ')
599 parts.push(`${loadable.length ? '' : 'skill-router: '}Only the person can run ${list}: suggest it if it fits.`)
600 }
601 return parts.join(' ')
602}
603
604// The dim transcript line the person sees.
605export function logLine(picks, lang) {
606 const names = picks.map(p => (p.personOnly ? '/' : '') + p.skill).join(', ')
607 return (lang === 'en' ? 'suggested skills: ' : 'skill suggerite: ') + names
608}
609
610// ---------------------------------------------------------------- prerequisite gates
611
612// Each gate: what is about to happen (English, for the model) and the skills it needs first.
613export const GATES = {
614 workflow: { doing: 'launching a workflow', skills: ['model-mix', 'smart-ultracode'] },
615 agent: { doing: 'launching an agent', skills: ['model-mix'] },
616 cloud: { doing: 'launching a cloud session', skills: ['model-mix', 'cloud-worker'] },
617 merge: { doing: 'merging a PR', skills: ['merge-gate'] },
618 mod: { doing: "writing a mod's files", skills: ['smart-mods'] },
619 modUi: { doing: "drawing a mod's interface", skills: ['mod-ui'] },
620}
621
622// New skills a gate names only when the command list shows them; with the list unknown, the
623// established ones (model-mix, smart-ultracode, smart-mods) are assumed installed.
624export const LISTED_ONLY = ['cloud-worker', 'merge-gate', 'mod-ui']
625
626function joinAnd(items) {
627 if (items.length <= 1) return items.join('')
628 return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1]
629}
630
631// Which of these gates fire now. state: { loaded: Set, installed: Set|null, fired: Set }.
632// Returns null (nothing to hold) or { keys, skills, reason }: the caller marks keys fired and denies.
633export function gateCheck(keys, state) {
634 const hit = []
635 const skills = []
636 for (const key of keys) {
637 const gate = GATES[key]
638 if (!gate || state.fired.has(key)) continue
639 const missing = gate.skills.filter(s =>
640 (state.installed ? state.installed.has(s) : !LISTED_ONLY.includes(s)) && !state.loaded.has(s))
641 if (!missing.length) continue
642 hit.push(key)
643 for (const s of missing) if (!skills.includes(s)) skills.push(s)
644 }
645 if (!hit.length) return null
646 const doing = joinAnd(hit.map(k => GATES[k].doing))
647 return {
648 keys: hit,
649 skills,
650 reason: `skill-router: before ${doing}, load ${joinAnd(skills)} with the Skill tool, then retry this exact call. This check fires once per session, so the retry passes.`,
651 }
652}
653
654export function gateLog(tool, skills, lang) {
655 return lang === 'en'
656 ? `skill-router: ${tool} held once, load first: ${skills.join(', ')}`
657 : `skill-router: ${tool} fermato una volta, da caricare prima: ${skills.join(', ')}`
658}
659
660// ---------------------------------------------------------------- Write and Edit (mod gate)
661
662// The gates a Write or Edit asks for; whether the file sits in a mod is the caller's fs check.
663export function writeGates(input) {
664 if (typeof input.file_path !== 'string') throw new TypeError('file_path is not text')
665 const text = input.tool === 'Edit' ? input.new_string : input.content
666 return typeof text === 'string' && /ui\.render/.test(text) ? ['mod', 'modUi'] : ['mod']
667}
668
669function sepOf(path) {
670 return path.includes('\\') ? '\\' : '/'
671}
672
673// The folders above a file, nearest first, at most `max`, never a drive or filesystem root.
674export function ancestorDirs(filePath, max = 6) {
675 if (typeof filePath !== 'string') throw new TypeError('file_path is not text')
676 const sep = sepOf(filePath)
677 const parts = filePath.split(/[\\/]+/)
678 const dirs = []
679 for (let n = parts.length - 1; n >= 1 && dirs.length < max; n--) {
680 const head = parts.slice(0, n)
681 if (head.length === 1 && (head[0] === '' || /^[A-Za-z]:$/.test(head[0]))) break
682 dirs.push(head.join(sep))
683 }
684 return dirs
685}
686
687export function joinPath(dir, ...names) {
688 return [dir.replace(/[\\/]+$/, ''), ...names].join(sepOf(dir))
689}
690
691function canon(path) {
692 return path.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase()
693}
694
695export function samePath(a, b) {
696 return canon(a) === canon(b)
697}
698
699export function isUnder(path, dir) {
700 return canon(path).startsWith(canon(dir) + '/')
701}
702
703// ---------------------------------------------------------------- shell commands (cloud and merge gates)
704// The parser below is model-guard's (rules.js): heredoc bodies and PowerShell here-strings are data,
705// so a brief or commit text that only mentions `claude --cloud` or `gh pr merge` holds nothing.
706
707// ---------------------------------------------------------------- shell parser (shared with skill-router)
708// From here to the "end of the shared shell parser" line, this block is the same text in model-guard's
709// rules.js and skill-router's routes.js: keep the two identical. Both mods' tests run one corpus through
710// tokenizeFull, so a drift fails a test.
711
712const SEPARATORS = new Set([';', '|', '&', '(', ')', '\n', '\r'])
713
714// Reads a heredoc delimiter word at i (quotes and backslashes stripped). Returns { word, next }.
715function heredocWord(command, i) {
716 let word = ''
717 while (i < command.length) {
718 const c = command[i]
719 if (c === ' ' || c === '\t' || SEPARATORS.has(c) || c === '<' || c === '>') break
720 if (c === "'" || c === '"') {
721 const close = command.indexOf(c, i + 1)
722 if (close < 0) { word += command.slice(i + 1); i = command.length; break }
723 word += command.slice(i + 1, close)
724 i = close + 1
725 } else if (c === '\\' && i + 1 < command.length) {
726 word += command[i + 1]
727 i += 2
728 } else {
729 word += c
730 i++
731 }
732 }
733 return { word, next: i }
734}
735
736// From `from` (the start of a line), where the line that ends a heredoc body starts (`body`) and the
737// index just past it (`next`), or null when no line ends it. Unterminated bodies are scanned as
738// commands: missing a real launch costs more than a false alarm (`$((1<<2))` also looks like a heredoc).
739function heredocEnd(command, from, h) {
740 let p = from
741 while (p <= command.length) {
742 const nl = command.indexOf('\n', p)
743 const stop = nl < 0 ? command.length : nl
744 let line = command.slice(p, stop)
745 if (line.endsWith('\r')) line = line.slice(0, -1)
746 if (h.stripTabs) line = line.replace(/^\t+/, '')
747 if (line === h.word) return { body: p, next: nl < 0 ? command.length : nl + 1 }
748 if (nl < 0) return null
749 p = nl + 1
750 }
751 return null
752}
753
754// A PowerShell here-string opening at i (`@'` or `@"` closing its line): { end, body } with end just
755// past its closing `'@` / `"@` at the start of a line, else null.
756function hereString(command, i) {
757 const q = command[i + 1]
758 if (command[i] !== '@' || (q !== "'" && q !== '"')) return null
759 const nl = command.indexOf('\n', i + 2)
760 if (nl < 0 || command.slice(i + 2, nl).trim() !== '') return null
761 const close = command.indexOf('\n' + q + '@', nl)
762 if (close < 0) return null
763 return { end: close + 3, body: command.slice(nl + 1, close).replace(/\r$/, '') }
764}
765
766// Splits a shell line (Bash or PowerShell) into segments of tokens { value, start, end }, with:
767// ends the separator that ended each segment (';', '|', '&&', '\n', '{', ...; '' for the last)
768// subs command substitutions that stay inside one token, { value, start, end }: `$(...)` inside
769// double quotes, and backtick pairs (inside double quotes, or unquoted on one line)
770// bodies heredoc bodies, { seg, value } (seg: the segment that opened them)
771// Never throws on odd input: an unclosed quote runs to the end of the line.
772// A line continuation (`\` in Bash, ` in PowerShell, before a newline) joins the next line.
773// `{` and `}` standing alone (a brace group, a loop body, a PowerShell script block, `%{`) end a
774// segment, so the command inside sits at a command position.
775// Heredoc bodies (`<<EOF`, `<<-'EOF'`, several on one line) add no tokens; a PowerShell here-string
776// (`@'` ... `'@`) is one token holding its body, never split into commands. Even an unquoted `<<EOF` or
777// `@"` body, which the shell expands, is skipped: a launch hidden in a `$(...)` there is far rarer than
778// commit, PR and brief texts that only mention `claude --cloud`. Only a body fed to a shell is a script
779// (the callers read `bodies`). A heredoc inside a `$(...)` inside double quotes (Claude Code's commit
780// and PR idiom `"$(cat <<'EOF' ... EOF\n)"`) is data too: its body stays in the quoted token, and its
781// quotes never close the string.
782export function tokenizeFull(command) {
783 const segments = [[]]
784 const ends = []
785 const subs = []
786 const bodies = []
787 let tok = null
788 let heredocs = []
789 const cur = () => segments[segments.length - 1]
790 const push = () => {
791 if (tok) { cur().push(tok); tok = null }
792 }
793 const brk = sep => {
794 push()
795 if (cur().length) { ends[segments.length - 1] = sep; segments.push([]) }
796 }
797 const startTok = i => { if (!tok) tok = { value: '', start: i, end: i } }
798 let i = 0
799 while (i < command.length) {
800 const c = command[i]
801 const next = command[i + 1]
802 if (c === ' ' || c === '\t') { push(); i++; continue }
803 if ((c === '\\' || c === '`') && (next === '\n' || (next === '\r' && command[i + 2] === '\n'))) {
804 push()
805 i += next === '\r' ? 3 : 2
806 continue
807 }
808 if (c === '<' && next === '<' && command[i + 2] !== '<' && command[i - 1] !== '<') {
809 push()
810 let j = i + 2
811 const stripTabs = command[j] === '-'
812 if (stripTabs) j++
813 while (command[j] === ' ' || command[j] === '\t') j++
814 const { word, next: after } = heredocWord(command, j)
815 if (word) heredocs.push({ word, stripTabs, seg: cur() })
816 i = after
817 continue
818 }
819 const alone = next === undefined || /\s/.test(next)
820 if ((c === '{' && next !== '}' && (alone || !tok || tok.value === '%')) || (c === '}' && !tok && (alone || /[;|&)]/.test(next)))) {
821 brk(c)
822 i++
823 continue
824 }
825 // An & or | inside a redirection stays in its word, so it splits nothing: `2>&1`, `>&2`, `<&3`,
826 // `>|file`, `&>log`, `&>>log`.
827 if ((c === '&' || c === '|') && tok && tok.end === i && (command[i - 1] === '>' || (c === '&' && command[i - 1] === '<'))) {
828 tok.value += c
829 i++
830 tok.end = i
831 continue
832 }
833 if (c === '&' && next === '>') {
834 push()
835 startTok(i)
836 tok.value += c
837 i++
838 tok.end = i
839 continue
840 }
841 if (SEPARATORS.has(c)) {
842 const doubled = (c === '&' || c === '|') && next === c
843 brk(doubled ? c + c : c)
844 i += doubled ? 2 : 1
845 if (c === '\n' && heredocs.length) {
846 for (const h of heredocs) {
847 const end = heredocEnd(command, i, h)
848 if (!end) break
849 bodies.push({ seg: h.seg, value: command.slice(i, end.body) })
850 i = end.next
851 }
852 heredocs = []
853 }
854 continue
855 }
856 const here = c === '@' ? hereString(command, i) : null
857 if (here) {
858 startTok(i)
859 tok.value += here.body
860 i = here.end
861 tok.end = i
862 continue
863 }
864 startTok(i)
865 if (c === "'") {
866 const close = command.indexOf("'", i + 1)
867 const stop = close < 0 ? command.length : close
868 tok.value += command.slice(i + 1, stop)
869 i = close < 0 ? command.length : close + 1
870 } else if (c === '"') {
871 let open = 0 // open `$(` inside this string
872 let from = 0 // where the outermost one's text starts
873 let inner = [] // heredocs opened inside them, waiting for the end of their line
874 i++
875 while (i < command.length && command[i] !== '"') {
876 const d = command[i]
877 if (d === '$' && command[i + 1] === '(') {
878 if (!open) from = i + 2
879 open++
880 tok.value += '$('
881 i += 2
882 continue
883 }
884 if (d === ')' && open > 0) {
885 open--
886 if (!open) subs.push({ value: command.slice(from, i), start: from, end: i })
887 }
888 if (open > 0 && d === '<' && command[i + 1] === '<' && command[i + 2] !== '<' && command[i - 1] !== '<') {
889 let j = i + 2
890 const stripTabs = command[j] === '-'
891 if (stripTabs) j++
892 while (command[j] === ' ' || command[j] === '\t') j++
893 const { word, next: after } = heredocWord(command, j)
894 if (word) inner.push({ word, stripTabs })
895 tok.value += command.slice(i, after)
896 i = after
897 continue
898 }
899 if (d === '\n' && inner.length) {
900 let p = i + 1
901 for (const h of inner) {
902 const end = heredocEnd(command, p, h)
903 if (!end) { p = -1; break }
904 p = end.next
905 }
906 inner = []
907 if (p > 0) {
908 tok.value += command.slice(i, p)
909 i = p
910 continue
911 }
912 }
913 if ((d === '\\' || d === '`') && (command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '`')) {
914 tok.value += command[i + 1]
915 i += 2
916 continue
917 }
918 if (d === '`') {
919 // A Bash substitution, when it closes before the string does (PowerShell's "`n" escapes
920 // read as one too: their text holds no command).
921 const close = command.indexOf('`', i + 1)
922 const quote = command.indexOf('"', i + 1)
923 if (close > 0 && (quote < 0 || close < quote)) {
924 subs.push({ value: command.slice(i + 1, close), start: i + 1, end: close })
925 tok.value += command.slice(i, close + 1)
926 i = close + 1
927 continue
928 }
929 }
930 tok.value += d
931 i++
932 }
933 if (open > 0) subs.push({ value: command.slice(from, i), start: from, end: i })
934 i++
935 } else if (c === '`') {
936 const close = command.indexOf('`', i + 1)
937 const nl = command.indexOf('\n', i + 1)
938 if (close > 0 && (nl < 0 || close < nl)) {
939 subs.push({ value: command.slice(i + 1, close), start: i + 1, end: close })
940 tok.value += command.slice(i, close + 1)
941 i = close + 1
942 } else {
943 tok.value += c
944 i++
945 }
946 } else {
947 tok.value += c
948 i++
949 }
950 tok.end = Math.min(i, command.length)
951 }
952 push()
953 // Only the last segment can be empty, so a kept segment keeps its index.
954 const kept = segments.filter(s => s.length)
955 return {
956 segments: kept,
957 ends: kept.map((s, i) => ends[i] || ''),
958 subs,
959 bodies: bodies.map(b => ({ seg: kept.indexOf(b.seg), value: b.value })),
960 }
961}
962
963// tokenizeFull's segments alone.
964export function tokenize(command) {
965 return tokenizeFull(command).segments
966}
967
968function baseName(value) {
969 const parts = value.split(/[\\/]/)
970 return parts[parts.length - 1].toLowerCase()
971}
972
973// claude by name or path (`claude.exe`, `/usr/local/bin/claude`), or its npm package (`npx @anthropic-ai/claude-code`).
974function isClaude(value) {
975 return /^claude(\.exe|\.cmd|\.ps1)?$/.test(baseName(value)) || /^@anthropic-ai\/claude-code(@\S*)?$/i.test(value)
976}
977
978function isLaunchScript(value) {
979 return /^launch\.(exp|ps1)$/.test(baseName(value))
980}
981
982// Words that run the command after them, each with its own options: v lists the options that take the
983// next word as their value (an attached value, `-n5`, `-I{}` or `--signal=KILL`, is one word); n counts
984// the operands before the command (timeout's duration, chrt's priority, taskset's mask). Any other
985// option stands alone (env -S's string is then the command word), and `--` ends the options. Shell
986// keywords (kw) take no options.
987const WRAPPERS = new Map([
988 ...['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', '}'].map(w => [w, { kw: true }]),
989 ['time', { v: ['-o', '-f', '--output', '--format'] }],
990 ['exec', { v: ['-a'] }],
991 ['command', {}], ['nohup', {}], ['setsid', {}], ['call', {}], ['source', {}], ['.', {}],
992 ['env', { v: ['-u', '-C', '--unset', '--chdir'] }],
993 ['sudo', { v: ['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '-R', '--user', '--group', '--host', '--prompt', '--close-from', '--chdir', '--role', '--type', '--other-user', '--command-timeout', '--chroot'] }],
994 ['xargs', { v: ['-I', '-n', '-P', '-L', '-s', '-d', '-E', '-a', '--max-args', '--max-procs', '--max-lines', '--max-chars', '--delimiter', '--eof', '--arg-file', '--replace', '--process-slot-var'] }],
995 ['timeout', { v: ['-s', '-k', '--signal', '--kill-after'], n: 1 }],
996 ['gtimeout', { v: ['-s', '-k', '--signal', '--kill-after'], n: 1 }],
997 ['nice', { v: ['-n', '--adjustment'] }],
998 ['ionice', { v: ['-c', '-n', '--class', '--classdata'] }],
999 ['chrt', { v: ['-T', '-P', '-D', '--sched-runtime', '--sched-period', '--sched-deadline'], n: 1 }],
1000 ['taskset', { n: 1 }],
1001 ['caffeinate', { v: ['-t', '-w'] }],
1002 ['stdbuf', { v: ['-i', '-o', '-e', '--input', '--output', '--error'] }],
1003 ['watch', { v: ['-n', '--interval', '-q', '--equexit'] }],
1004 ['npx', { v: ['-p', '--package', '--cache', '--registry', '--userconfig'] }],
1005 ['bunx', { v: ['-p', '--package'] }],
1006])
1007
1008// Index of the segment's command word, past `VAR=value`, shell keywords and wrappers with their options.
1009function commandIndex(seg) {
1010 let i = 0
1011 while (i < seg.length) {
1012 const word = seg[i].value
1013 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { i++; continue }
1014 const w = WRAPPERS.get(word)
1015 if (!w) break
1016 i++
1017 if (w.kw) continue
1018 while (i < seg.length && seg[i].value.startsWith('-')) {
1019 const o = seg[i].value
1020 i += o !== '--' && w.v && w.v.includes(o) ? 2 : 1
1021 if (o === '--') break
1022 }
1023 i += w.n || 0
1024 }
1025 return i < seg.length ? i : -1
1026}
1027
1028const SHELLS = /^(bash|sh|zsh|dash|pwsh|powershell|cmd)(\.exe)?$/
1029const SCRIPT_FLAG = /^(-[a-z]*c|-command|\/c|\/k)$/i
1030// Shells whose script flag takes the rest of the line (bash -c takes one word; the rest are $0, $1...).
1031const REST_SHELLS = /^(pwsh|powershell|cmd)(\.exe)?$/
1032const STARTERS = ['start-process', 'start', 'saps']
1033
1034// The scripts a nested shell may run (`bash -lc "..."`, `pwsh -Command "..."`, `cmd /c "..."`), in the
1035// order to try, else null. For cmd and PowerShell the script flag takes the rest of the segment: an
1036// unquoted script (`cmd /c claude --cloud x`) is that raw text, a synthetic token. A quoted script with
1037// more words after it is tried as the quoted text first (`cmd /c "claude --cloud x" 2>&1`, where the
1038// rest is the outer shell's redirection), then as the rest of the segment (`cmd /c "claude" --cloud x`).
1039function nestedScripts(command, seg, ci) {
1040 const shell = baseName(seg[ci].value)
1041 if (!SHELLS.test(shell)) return null
1042 for (let j = ci + 1; j < seg.length - 1; j++) {
1043 if (!SCRIPT_FLAG.test(seg[j].value)) continue
1044 if (!REST_SHELLS.test(shell)) {
1045 // A POSIX shell reads all its options before the script: `bash -c -- "..."`, `sh -c -e "..."`,
1046 // `bash -c -o pipefail "..."` (-o and -O take a value).
1047 let s = j + 1
1048 while (s < seg.length && /^[-+]./.test(seg[s].value) && command.slice(seg[s].start, seg[s].end) === seg[s].value) {
1049 const o = seg[s].value
1050 s += /^[-+][oO]$/.test(o) ? 2 : 1
1051 if (o === '--') break
1052 }
1053 return s < seg.length ? [seg[s]] : null
1054 }
1055 if (j + 2 >= seg.length) return [seg[j + 1]]
1056 const start = seg[j + 1].start
1057 const end = seg[seg.length - 1].end
1058 const rest = { value: command.slice(start, end), start, end, synthetic: true }
1059 const q = command[start]
1060 return q === '"' || q === "'" || q === '@' ? [seg[j + 1], rest] : [rest]
1061 }
1062 return null
1063}
1064
1065const EVALS = ['eval', 'iex', 'invoke-expression']
1066// Commands whose arguments may each hold a whole command line they run (`tmux new "..."`, `ssh host "..."`,
1067// `find . -exec sh -c "..."`): every argument with a space in it is read as a script.
1068const SCRIPT_TAKERS = ['tmux', 'screen', 'ssh', 'su', 'runuser', 'script', 'parallel', 'find']
1069const ECHOES = ['echo', 'printf', 'write-output', 'write-host']
1070
1071// Whether segment k's command reads a script on stdin: a shell, ssh or su, or Invoke-Expression with no argument.
1072function readsStdin(seg, ci) {
1073 const name = baseName(seg[ci].value)
1074 if (EVALS.includes(name)) return seg.length === ci + 1 && name !== 'eval'
1075 return SHELLS.test(name) || name === 'ssh' || name === 'su' || name === 'runuser'
1076}
1077
1078// The other scripts segment k runs, as texts to scan (never edited in place): eval's and Invoke-Expression's
1079// arguments; the command line `watch` or `env -S` runs; SCRIPT_TAKERS' arguments; AppleScript's
1080// `do shell script "..."`; and what a shell reads on stdin: a heredoc or `<<<` it takes, or what the segment
1081// before pipes into it (`cat <<'EOF' | bash`, `echo "..." | sh`, `"..." | iex`).
1082function extraScripts(full, k, ci) {
1083 const seg = full.segments[k]
1084 const name = baseName(seg[ci].value)
1085 const args = seg.slice(ci + 1).map(t => t.value)
1086 const out = []
1087 if (EVALS.includes(name)) out.push(args.filter(a => !/^-c(ommand)?$/i.test(a)).join(' '))
1088 if (/\s/.test(seg[ci].value) && seg.slice(0, ci).some(t => ['watch', '-S', '--split-string'].includes(t.value))) out.push(seg.slice(ci).map(t => t.value).join(' '))
1089 if (SCRIPT_TAKERS.includes(name)) out.push(...args.filter(a => /\s/.test(a)))
1090 if (name === 'osascript') {
1091 for (const a of args) for (const m of a.matchAll(/do (?:shell )?script\s+"((?:[^"\\]|\\.)*)"/g)) out.push(m[1].replace(/\\(.)/g, '$1'))
1092 }
1093 if (readsStdin(seg, ci)) {
1094 for (let j = 0; j < args.length; j++) if (args[j].startsWith('<<<')) out.push(args[j].length > 3 ? args[j].slice(3) : args[j + 1] || '')
1095 for (const b of full.bodies) if (b.seg === k) out.push(b.value)
1096 if (k > 0 && full.ends[k - 1] === '|') {
1097 const prev = full.segments[k - 1]
1098 for (const b of full.bodies) if (b.seg === k - 1) out.push(b.value)
1099 const pi = commandIndex(prev)
1100 if (pi >= 0 && /\s/.test(prev[pi].value)) out.push(prev[pi].value)
1101 else if (pi >= 0 && ECHOES.includes(baseName(prev[pi].value))) out.push(prev.slice(pi + 1).map(t => t.value).join(' '))
1102 }
1103 }
1104 return out.filter(Boolean)
1105}
1106
1107// Where a launch script (coordinator-method's launch.exp, cloud-worker's launch.ps1) sits in segment
1108// seg: as the command word (`./launch.exp`, `& "...\launch.ps1"`, `. launch.ps1`), after expect and its
1109// flags, or as any argument of powershell or pwsh (`-File`, or the first operand). Else -1.
1110function launchScriptAt(seg, ci) {
1111 if (isLaunchScript(seg[ci].value)) return ci
1112 const name = baseName(seg[ci].value)
1113 if (/^expect(\.exe)?$/.test(name)) {
1114 let j = ci + 1
1115 while (j < seg.length && seg[j].value.startsWith('-')) j++
1116 return j < seg.length && isLaunchScript(seg[j].value) ? j : -1
1117 }
1118 if (REST_SHELLS.test(name)) {
1119 for (let j = ci + 1; j < seg.length; j++) if (isLaunchScript(seg[j].value)) return j
1120 }
1121 return -1
1122}
1123
1124function hasFlag(values, ...names) {
1125 return values.some(v => names.includes(v) || names.some(n => v.startsWith(n + '=')))
1126}
1127
1128// ---------------------------------------------------------------- indirect launches (Start-Process, start)
1129// What a starter hands claude is a Windows command line, and re-reading its quoting kept letting launches
1130// through. So the argument list is never read: only which program the starter runs, from the starter's
1131// own parameters.
1132
1133// Start-Process's switches and the parameters that take a value. PowerShell takes an exact name or alias
1134// first (`-wi` is -WhatIf, not -WindowStyle), else any prefix of a name (`-NoNew`, `-File`). `-Name:value`
1135// is one word, and `-Name:` before a space takes the next word. An unknown name is taken to have a value.
1136const START_SWITCHES = ['wait', 'nonewwindow', 'nnw', 'passthru', 'loaduserprofile', 'lup', 'usenewenvironment', 'verbose', 'vb', 'debug', 'db', 'whatif', 'wi', 'confirm', 'cf']
1137const START_VALUED = ['argumentlist', 'args', 'workingdirectory', 'windowstyle', 'verb', 'credential', 'environment', 'redirectstandardinput', 'redirectstandardoutput', 'redirectstandarderror', 'rsi', 'rso', 'rse']
1138const START_FILE = ['filepath', 'path', 'pspath']
1139// cmd's start switches that take the next word (`/D C:\dir`); the others (`/MIN`, `/WAIT`, `/B`) take none.
1140const CMD_VALUED = ['d', 'node', 'affinity']
1141
1142function startSwitch(name) {
1143 if (START_SWITCHES.includes(name)) return true
1144 return START_SWITCHES.some(s => s.startsWith(name)) && ![...START_VALUED, ...START_FILE].some(v => v.startsWith(name))
1145}
1146
1147// `{` minus `}` in a word's raw text, outside quotes.
1148function braceDelta(raw) {
1149 let d = 0
1150 let q = ''
1151 for (const ch of raw) {
1152 if (q) { if (ch === q) q = '' } else if (ch === "'" || ch === '"') q = ch
1153 else if (ch === '{') d++
1154 else if (ch === '}') d--
1155 }
1156 return d
1157}
1158
1159// The words after a starter, as units: a word, or a group with what touches it: `( ... )` (`@('a','b')`,
1160// `(Get-Command claude).Source`) or a hashtable `@{ ... }` (`@{ A = 'b' }`). The statement runs on past
1161// breaks made only of parentheses and braces (and of newlines inside a group, and of a hashtable's `;`;
1162// a ` or \ line continuation is a space) and ends at any other separator. A `{` or `}` standing alone is
1163// a separator to tokenizeFull, so it sits in the gap between words; one inside a word (`@{A='b'`) is
1164// counted from the word's text.
1165function starterUnits(command, segments, k, ci) {
1166 const units = []
1167 let prev = segments[k][ci]
1168 let depth = 0
1169 let braces = 0
1170 for (let s = k; s < segments.length; s++) {
1171 for (let j = s === k ? ci + 1 : 0; j < segments[s].length; j++) {
1172 const tok = segments[s][j]
1173 let apart = !units.length
1174 for (const ch of command.slice(prev.end, tok.start).replace(/[`\\]\r?\n/g, ' ')) {
1175 if (ch === '(') depth++
1176 else if (ch === ')') depth = Math.max(0, depth - 1)
1177 else if (ch === '{') braces++
1178 else if (ch === '}' && braces > 0) braces--
1179 else if (ch === ' ' || ch === '\t' || ((ch === '\n' || ch === '\r') && (depth > 0 || braces > 0))) apart = apart || (depth === 0 && braces === 0)
1180 else if (ch !== ';' || braces === 0) return units
1181 }
1182 prev = tok
1183 if (apart) units.push([tok])
1184 else units[units.length - 1].push(tok)
1185 braces = Math.max(0, braces + braceDelta(command.slice(tok.start, tok.end)))
1186 }
1187 }
1188 return units
1189}
1190
1191// Whether a starter (Start-Process, start, saps, cmd's start) runs claude: the -FilePath value when one
1192// is given before the program, else the first positional word; cmd's start takes a double-quoted first
1193// word as the window title (`start "" claude`). The words after the program word are its own (cmd's
1194// `start claude -p x`, where `-p` is claude's): there only a -FilePath of two letters or more (`-File`,
1195// `-Path`; claude's short flags have one) is still read, as PowerShell binds it. A path counts
1196// (`C:\x\claude.exe`), and so does a group naming claude (`(Get-Command claude).Source`); a URL
1197// (`https://.../claude`) does not.
1198function startsClaude(command, segments, k, ci) {
1199 const units = starterUnits(command, segments, k, ci)
1200 const url = v => v.includes('://') && !/^file:/i.test(v)