SLOPSHOPPER

skill-router

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

newguardprompt
A shopper browsing a rack in a slop shop
README

Claude Code skills

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.

SkillWhat it decides
model-mixWhich 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-ultracodeWhen 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-methodHow 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-modsWhen 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-workerHow to launch, follow and collect cloud workers from this machine, Windows included, with launch.ps1.
merge-gateThe 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.
overnightRunning unattended overnight cheaply and safely: before sleeping, wake design, usage limits, keeping the machine awake, the morning summary.
roadmap-trackerTracker hygiene for the roadmap: pinned issue, milestones on every issue, labels mapped to the queue, tickets closed with the PR that landed them.
matt-bridgeHow Matt Pocock's skills run inside the coordinator method, and which rule wins where they differ.
verified-researchResearching fast-changing facts with parallel readers, adversarial verifiers and a critic, and reporting them sorted by what survived.
release-watchCheap checks for Claude Code, Desktop, model, plan and upstream skill changes that should update the skills and mods.
skill-auditAuditing skills for trigger quality, size, duplication, stale facts and token cost, with concrete edits.
context-hygieneKeeping sessions cheap: what every turn pays, how to measure it, when to compact, clear or hand off.
windows-opsWhat 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-uiThe visual language of the mods: band, pane, chat card, toasts, theme colors, Desktop and terminal differences.
clean-codeEight 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-conventionsDefault 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.

Install

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

Your plan

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
  • reserve: the share of the weekly limit the skills leave for your own use.
  • banked: the limit resets shown in Settings → Usage, each as 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 (optional, terminal only): 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.

Launching a cloud worker

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.

Mods

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.

ModWhat it does
model-guardApplies 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-routerSmart 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).

License

MIT

Source 2 files
hooks/register.js 233 lines
1// 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}
233
hooks/routes.js 1325 lines
1// 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)