Scénarios d'agents: read-only guide and editable presets of roles -> model, effort, unit and max parallel, with ready-to-paste snippets. /scenarios

Mods for Claude Code: small panes that show what your agents do. They never block or rewrite a tool call; at most they warn.
Needs Claude Code 2.1.287 or later (function-hook mods, panes, Svg element).
| Mod | Command | What it shows | ||||
|---|---|---|---|---|---|---|
factory | /factory, /factory <title or id> | Dashboard of every conversation (model, effort, context %, cost today, running subagents, PR/MR and CI, waiting on you). Click one for its agent tree: main thread, each subagent and Workflow node with model, effort, tokens and estimated cost, the bill per model and per turn, refused tool calls, the session log and warnings (subagent without an explicit model, Opus doing read-only search, big context without handoff/RESUME.md, timer tools). | ||||
control | `/control [setup\ | usage\ | practices\ | history] [scan]` | SETUP: everything that shapes a session (settings, env, permissions, global and repo CLAUDE.md, agents, skills, hook scripts, mods, scheduled tasks, MCP servers, plugins, memory), with hidden rules and any blocker (hook, deny rule, mod deny path) at the top. USAGE: 1/7/30-day model, effort and delegation mix from the transcripts, each metric against a target. PRACTICES and HISTORY: your rules file, followed or not, and its version history. | |
ci | /ci [refresh], `/ci route <repo\ | session\ | here> <backend-id>, /ci clear <target>` | CI routing: the CI backends from your config (GitHub Actions, GitLab.com shared runners, a self-hosted runner, VM runners still coming), whether each is usable now (token present yes/no, runners online, running/pending jobs, Actions per routed repo), and which repo or conversation uses which. A lead conversation or you set the route; each conversation sees its route in the status line and as a short note in its system prompt. | ||
scenarios | `/scenarios [S1..] [prompt\ | agent\ | workflow\ | frontmatter\ | env]` | Scénarios d'agents: presets of roles -> model, effort, unit (session, sub-conversation, Workflow agent(), Agent subagent) and max parallel (S1 Standard V6, S2 Économe, S3 Qualité max, S4 Nuit, plus yours from a JSON file). For each role, the exact settings (Agent call, agent() opts, agent-file frontmatter, env vars) and the documented limits, each with its doc URL and "non vérifié" where the docs say nothing; a Copy button for a ready-to-paste snippet. Warn-only toast when an Agent call names no model. |
guide | /mods-guide [onglet ou section] | Mode d'emploi des mods, in French: tabs (Sommaire, Bases, Événements, Interface, Configuration, Installer et activer, Tester, Écrire un mod pas à pas, Référence API, Nos mods, FAQ), foldable categories, and one card per mod of this repo built live from its plugin.json and hooks module. Same content in MODE-EMPLOI.md. |
factory and control read local files only (transcripts under ~/.claude/projects, the desktop app's session files, your settings) through a Python scanner (standard library) and draw the result. Dollar figures are estimates from token counts and the rate table in each mod's config.
What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.
| Mod | Verdict | Why |
|---|---|---|
| factory | keep | The only view of the agent tree with models, efforts and cost. Next: feed it from hook events (agent.spawn, turn.start) instead of scanning files. |
| control | keep | The "nothing hidden" proof: every rule source, env variable and blocker in one place. Its metrics and practices still follow the V5 rules and are being rewritten for V6. |
| coordinator | merge into factory | Linked sub-conversations remove most lane collisions; only the "green PR not merged" and "waiting on you" rows are worth keeping, as a strip in factory. |
| context band (cc-control band, context-meter) | merge into a V6 band | Small and correct: context size against the hand-off threshold, read-only. Not written yet. |
| cc-control guards | drop | They refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out. |
| ci | new (Oct 2026) | Which CI backend a repo or conversation should use, and whether it works right now, in one place instead of in rules text. |
| scenarios | new (Oct 2026) | Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts. |
| guide | new (Oct 2026) | How mods work and how to write one, plus a card per mod that reads the mod itself. |
| cost-tracker, activity-pane, launchpad, night | drop | Wrong numbers for new model ids, superseded by factory, or built on timer wake-ups. |
| V4 pages and watcher | drop | Dead since early Oct, replaced by factory and the app's own agent view. |
Copy or symlink a mod folder to ~/.claude/mods/<name>:
git clone https://github.com/julian-passebecq/claude-mod.git
# copy
cp -r claude-mod/mods/factory ~/.claude/mods/factory
# or symlink (Windows: mklink /D, as admin or with developer mode)
ln -s "$PWD/claude-mod/mods/factory" ~/.claude/mods/factory
Then load it, either for every session with the environment (in ~/.claude/settings.json under env, folders separated by ; on Windows, : elsewhere):
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/factory;~/.claude/mods/control;~/.claude/mods/ci;~/.claude/mods/scenarios;~/.claude/mods/guide"
or as a dev-mod in the desktop app for one session. Python 3 must be on PATH (the scanners); gh / glab are used, when present, for PR/MR and CI state.
Each mod has a config file next to it (factory.config.json, config.json): rates, thresholds, colors, labels and paths are set there, nothing is hard-coded. factory also deep-merges ~/.claude/effort-board/factory.config.json if you have one. In control/config.json, set paths.projects_root (the folder that holds your repos) and the optional doc paths.
cp claude-mod/mods/ci/ci.config.example.json ~/.claude/ci.config.json # then fill the <PLACEHOLDERS>
python -I ~/.claude/mods/ci/scanner/ci.py show --check # same data as /ci, from any shell
python -I ~/.claude/mods/ci/scanner/ci.py route ~/code/my-repo gitlab-local --by lead
--config, then $CI_MOD_CONFIG, then ~/.claude/ci.config.json, then ci.config.json next to the mod, then the shipped example. Each backend has id, label, kind (github | gitlab), host, runner_tag, status (active | coming-soon | disabled) and notes; optional group, concurrent, minutes_month, public_only, check_runners, pending_projects. A value still in <angle brackets> counts as unset. Labels and colors are in labels.gh auth status, glab auth status, or the env variable names listed under hosts); a token value is never printed or stored. With a GitLab token: runners with the backend's tag online/offline and their running jobs (pending jobs for the projects in pending_projects). With gh: Actions availability for the repos routed to a GitHub backend. Missing tools or tokens show as unknown. Results are cached (status_cache, cache_minutes); /ci refresh checks now.routing_file (default ~/.claude/ci-routing.json), one entry per repo path or session id with backend, who set it and when. A session route wins over a repo route; a worktree inside a routed repo follows the repo. status_line and prompt_note in the config turn the status-line entry and the system-prompt note off.PATH; gh and glab are optional.deny, no rewriting of tool calls or prompts.ci also writes its routing file, on /ci route and /ci clear).gh / glab for PR state and the GitLab API for runner status (ci).python -I -m unittest discover -s mods/control/tests -p "test_*.py"
python -I -m unittest discover -s mods/ci/tests -p "test_*.py"
node --test mods/factory/tests/logic.test.mjs mods/scenarios/tests/logic.test.mjs mods/guide/tests/guide.test.mjs
node mods/guide/scripts/build.mjs # regenerate MODE-EMPLOI.md (table of contents, Nos mods cards)
# hook and pane tests, with the Claude Code test kit:
claude plugin test mods/factory
claude plugin test mods/control
claude plugin test mods/ci
claude plugin test mods/scenarios
claude plugin test mods/guide
# before every push (public repo): no private paths, tokens, e-mails or transcripts
python -I tools/confidentiality_scan.py .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is ignored since Claude Code 2.1.287 (mods are on by default, overview). GitHub Actions (.github/workflows/tests.yml) runs the Python and node tests and the confidentiality scan; the test-kit suites need the claude CLI and run locally.
control's reference-window test replays the author's own transcripts and only runs with CONTROL_REFERENCE=1.
MIT
hooks/register.tsx 209 lines1// scenarios mod ("Scénarios d'agents"): a read-only guide over editable presets of roles -> model, effort, unit, max parallel.
2// /scenarios [id] [kind] opens the pane; the pane copies a ready-to-paste snippet. Scenarios come from scenarios.json
3// beside this mod plus the user's own file (userConfig `file`). Nothing here blocks or rewrites a tool call: the only
4// non-UI hook, on agent.spawn, passes the spawn on unchanged and at most shows a toast.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { Catalog, Checked, ScenarioFile } from '../types'
9import { KINDS, KIND_LABEL, check, expandHome, limitsFor, merge, parseArgs, roleLine, settingsFor, snippet } from './logic'
10import type { Kind } from './logic'
11
12const PANE = { id: 'scenarios', title: "Scénarios d'agents" }
13const selected = atom({ plugin: 'scenarios', key: 'selected' } as const, '')
14const kind = atom({ plugin: 'scenarios', key: 'kind' } as const, 'prompt')
15const copied = atom({ plugin: 'scenarios', key: 'copied' } as const, '')
16
17type Options = { file?: string; warn_no_model?: string }
18
19const readJson = async ($: EngineInterface, path: string): Promise<{ data?: ScenarioFile; problem?: string }> => {
20 let text = ''
21 try {
22 text = (await $.fs.read(path, { as: 'text' })) as string
23 } catch {
24 return { problem: `cannot read ${path}` }
25 }
26 try {
27 return { data: JSON.parse(text) as ScenarioFile }
28 } catch (err) {
29 return { problem: `${path}: not valid JSON (${String(err).slice(0, 120)})` }
30 }
31}
32
33const home = async ($: EngineInterface) => {
34 let v: string | undefined
35 try {
36 v = await $.env.get('USERPROFILE')
37 } catch {}
38 if (!v) {
39 try {
40 v = await $.env.get('HOME')
41 } catch {}
42 }
43 return v ?? ''
44}
45
46const load = async ($: EngineInterface, options: Options): Promise<Checked> => {
47 const root = $.plugin.root.replace(/\\/g, '/')
48 const shipped = await readJson($, `${root}/scenarios.json`)
49 const problems: string[] = shipped.problem ? [shipped.problem] : []
50 let extra: ScenarioFile | undefined
51 if (options.file?.trim()) {
52 const user = await readJson($, expandHome(options.file.trim(), await home($)))
53 if (user.problem) problems.push(user.problem)
54 extra = user.data
55 }
56 const checked = check(merge(shipped.data, extra))
57 return { ...checked, errors: [...problems, ...checked.errors] }
58}
59
60const kindsOf = (c: Catalog): Kind[] => {
61 const listed = c.snippets.filter((k): k is Kind => (KINDS as readonly string[]).includes(k))
62 return listed.length ? listed : [...KINDS]
63}
64
65export const register: Register = (on, raw) => {
66 const options = (raw ?? {}) as Options
67 // Spam guard for the warn-only toast: one per minute at most.
68 let lastToast = 0
69
70 on('session.start', async ($, e, next) => {
71 await $.command.register({
72 name: 'scenarios',
73 description: "Scénarios d'agents: roles -> model, effort, unit, max parallel, with ready-to-paste snippets (args: [S1..] [prompt|agent|workflow|frontmatter|env])",
74 argumentHint: '[scenario id] [prompt|agent|workflow|frontmatter|env]',
75 })
76 return next(e)
77 })
78
79 on('command.run', { command: 'scenarios' }, async ($, e) => {
80 const { catalog, errors } = await load($, options)
81 const ids = catalog.scenarios.map(s => s.id)
82 const args = parseArgs(e.args ?? '', ids)
83 if (args.id && !ids.includes(args.id)) {
84 return { text: `scenarios: no scenario "${args.id}" (known: ${ids.join(', ') || 'none'})${errors[0] ? ` · ${errors[0]}` : ''}` }
85 }
86 const current = await read($, selected)
87 const scenario = catalog.scenarios.find(s => s.id === (args.id ?? current)) ?? catalog.scenarios[0]
88 if (scenario) await update($, selected, () => scenario.id)
89 if (args.kind) await update($, kind, () => args.kind as string)
90 await $.ui.open({ id: PANE.id, title: PANE.title })
91 const head = `scenarios: ${ids.length} scenario(s)${errors.length ? `, ${errors.length} error(s) in the files` : ''}`
92 if (scenario && args.kind) return { text: `${head}\n\n${snippet(catalog, scenario, args.kind)}` }
93 return { text: `${head} · pane opened${scenario ? ` on ${scenario.id} "${scenario.name}"` : ''}` }
94 })
95
96 // Warn-only: a subagent or Workflow agent that names no model inherits one. The spawn goes on untouched.
97 on('agent.spawn', async ($, e, next) => {
98 const result = await next(e)
99 if (options.warn_no_model !== 'off' && !e.model && !e.fork && !e.isTeammate && Date.now() - lastToast > 60000) {
100 lastToast = Date.now()
101 const what = e.workflow ? 'a Workflow agent()' : `an Agent call (${e.subagentType})`
102 $.ui.toast(`scenarios: ${what} named no model; it runs on ${'model' in result && result.model ? result.model : 'the inherited model'}. /scenarios shows what to set.`)
103 }
104 return result
105 }).catch(($, e, next) => next(e)) // fail open: a failing warning never stops a spawn
106
107 on('ui.render', { component: 'Pane', requestId: 'scenarios' }, async ($, e) => {
108 const { Box, Text, Button, Code, Link } = $.ui.resolve(e)
109 const { catalog, errors, warnings } = await load($, options)
110 const want = await read($, selected)
111 const s = catalog.scenarios.find(x => x.id === want) ?? catalog.scenarios[0]
112 const k = ((await read($, kind)) as Kind) || 'prompt'
113 const last = await read($, copied)
114 const kinds = kindsOf(catalog)
115 const url = (key: string) => catalog.sources[key] ?? ''
116 const cite = (key: string, verified = true) => (
117 <Box flexDirection="row" gap={1}>
118 {verified ? null : <Text color="warning">non vérifié</Text>}
119 {url(key) ? <Link href={url(key)} label={url(key)} /> : <Text dimColor>no source</Text>}
120 </Box>
121 )
122
123 return (
124 <Box flexDirection="column" gap={1}>
125 <Text dimColor>Read-only guide. Edit scenarios.json or your own file (userConfig "file") to add S5 and more.</Text>
126 {errors.length > 0 && (
127 <Box flexDirection="column">
128 <Text color="error" bold>{`${errors.length} error(s): those scenarios are left out`}</Text>
129 {errors.map(x => <Text color="error">{`· ${x}`}</Text>)}
130 </Box>
131 )}
132 {warnings.length > 0 && (
133 <Box flexDirection="column">
134 {warnings.map(x => <Text color="warning">{`· ${x}`}</Text>)}
135 </Box>
136 )}
137 <Box flexDirection="row" gap={1} flexWrap="wrap">
138 {catalog.scenarios.map(x => (
139 <Button key={`sc-${x.id}`} {...(x.id !== s?.id ? { plain: true as const } : {})} onPress={() => update($, selected, () => x.id)}>
140 {`${x.id} ${x.name}`}
141 </Button>
142 ))}
143 </Box>
144 {!s && <Text>No valid scenario. Fix the errors above.</Text>}
145 {s && (
146 <Box flexDirection="column" gap={1}>
147 <Text bold>{`${s.id} · ${s.name}`}</Text>
148 {s.summary ? <Text>{s.summary}</Text> : null}
149 <Text dimColor>
150 {`Escalation: ${s.escalation === false ? 'no' : 'after one failure, one step up'}` +
151 (s.caps && Object.keys(s.caps).length ? ` · caps: ${Object.entries(s.caps).map(([a, b]) => `${a.replace(/_/g, ' ')} ${b}`).join(', ')}` : '')}
152 </Text>
153 <Box flexDirection="column">
154 {s.roles.map(r => (
155 <Box flexDirection="column">
156 <Text bold>{roleLine(catalog, r)}</Text>
157 {settingsFor(catalog, r).map(line => (
158 <Box flexDirection="column">
159 <Text>{` ${line.text}`}</Text>
160 <Box flexDirection="row" gap={1}>
161 <Text dimColor>{' '}</Text>
162 {cite(line.source, line.verified)}
163 </Box>
164 </Box>
165 ))}
166 </Box>
167 ))}
168 </Box>
169 <Text bold>Documented limits</Text>
170 <Box flexDirection="column">
171 {limitsFor(catalog, s).map(l => (
172 <Box flexDirection="column">
173 <Text>{`· ${l.text}`}</Text>
174 <Box flexDirection="row" gap={1}>
175 <Text dimColor>{' '}</Text>
176 {cite(l.source, l.verified)}
177 </Box>
178 </Box>
179 ))}
180 </Box>
181 <Text bold>Snippet</Text>
182 <Box flexDirection="row" gap={1} flexWrap="wrap">
183 {kinds.map(x => (
184 <Button key={`kind-${x}`} {...(x !== k ? { plain: true as const } : {})} onPress={() => update($, kind, () => x)}>
185 {KIND_LABEL[x]}
186 </Button>
187 ))}
188 </Box>
189 <Code source={snippet(catalog, s, k)} language={k === 'frontmatter' ? 'yaml' : k === 'prompt' ? 'text' : 'javascript'} />
190 <Box flexDirection="row" gap={1}>
191 <Button
192 key="copy"
193 hotkey="c"
194 onPress={async press => {
195 const r = await $.ui.copy({ text: snippet(catalog, s, k), surface: press.surface })
196 await update($, copied, () => (r.isCopied ? `copied: ${s.id} ${KIND_LABEL[k]}` : 'copy failed: select the text above'))
197 }}
198 >
199 Copy snippet
200 </Button>
201 {last ? <Text dimColor>{last}</Text> : null}
202 </Box>
203 </Box>
204 )}
205 </Box>
206 )
207 })
208}
209hooks/logic.ts 184 lines1// Pure logic of the scenarios mod: merge, validate, describe and turn a scenario into ready-to-paste snippets.
2// No `$`, no imports but types: the engine loads it from register.tsx and `node --test` loads it as plain TS.
3import type { Catalog, Checked, Limit, Role, Scenario, ScenarioFile } from '../types'
4
5export const KINDS = ['prompt', 'agent', 'workflow', 'frontmatter', 'env'] as const
6export type Kind = (typeof KINDS)[number]
7export const KIND_LABEL: Record<Kind, string> = {
8 prompt: 'Prompt line',
9 agent: 'Agent calls',
10 workflow: 'Workflow agent()',
11 frontmatter: 'Agent files',
12 env: 'Env vars',
13}
14
15const EMPTY: Catalog = { sources: {}, models: {}, efforts: [], units: {}, limits: [], snippets: [], scenarios: [] }
16
17/** Shipped file first, then the user's: tables merge by key, limits and scenarios by id (the later one wins). */
18export function merge(...files: (ScenarioFile | undefined)[]): Catalog {
19 const out: Catalog = { ...EMPTY, sources: {}, models: {}, units: {}, limits: [], scenarios: [] }
20 for (const f of files) {
21 if (!f || typeof f !== 'object') continue
22 Object.assign(out.sources, f.sources ?? {})
23 Object.assign(out.models, f.models ?? {})
24 Object.assign(out.units, f.units ?? {})
25 if (Array.isArray(f.efforts) && f.efforts.length) out.efforts = [...f.efforts]
26 if (Array.isArray(f.snippets) && f.snippets.length) out.snippets = [...f.snippets]
27 for (const l of Array.isArray(f.limits) ? f.limits : []) out.limits = [...out.limits.filter(x => x.id !== l?.id), l]
28 for (const s of Array.isArray(f.scenarios) ? f.scenarios : []) out.scenarios = [...out.scenarios.filter(x => x.id !== s?.id), s]
29 }
30 return out
31}
32
33const isFullId = (m: string) => /^claude-[a-z0-9.-]+$/.test(m)
34const rank = (c: Catalog, effort: string) => c.efforts.indexOf(effort)
35const leadOf = (s: Scenario) => s.roles.find(r => r.unit === 'session')
36
37/** Checks every scenario against the catalog's tables and the documented limits; a broken scenario is dropped with its errors. */
38export function check(c: Catalog): Checked {
39 const errors: string[] = []
40 const warnings: string[] = []
41 for (const [key, url] of Object.entries(c.sources)) if (!/^https:\/\//.test(String(url))) errors.push(`source ${key}: not an https URL`)
42 for (const l of c.limits) if (!l || !l.id || !l.text || !(l.source in c.sources)) errors.push(`limit ${l?.id ?? '?'}: needs id, text and a known source`)
43 const seen = new Set<string>()
44 const kept: Scenario[] = []
45 for (const s of c.scenarios) {
46 const where = `scenario ${s?.id ?? '?'}`
47 const own: string[] = []
48 if (!s || typeof s !== 'object') { errors.push(`${where}: not an object`); continue }
49 if (!s.id || typeof s.id !== 'string') own.push(`${where}: missing id`)
50 else if (seen.has(s.id)) own.push(`${where}: duplicate id`)
51 if (!s.name || typeof s.name !== 'string') own.push(`${where}: missing name`)
52 if (!Array.isArray(s.roles) || s.roles.length === 0) own.push(`${where}: needs at least one role`)
53 for (const r of Array.isArray(s.roles) ? s.roles : []) {
54 const at = `${where} role ${r?.role ?? '?'}`
55 if (!r || !r.role) { own.push(`${at}: missing role name`); continue }
56 if (!(r.model in c.models) && !isFullId(String(r.model))) own.push(`${at}: unknown model "${r.model}"`)
57 if (rank(c, r.effort) < 0) own.push(`${at}: effort "${r.effort}" is not one of ${c.efforts.join(', ')}`)
58 if (!(r.unit in c.units)) own.push(`${at}: unit "${r.unit}" is not one of ${Object.keys(c.units).join(', ')}`)
59 if (!Number.isInteger(r.max_parallel) || r.max_parallel < 1) own.push(`${at}: max_parallel must be a whole number >= 1`)
60 for (const l of c.limits) {
61 if (l.max != null && l.unit === r.unit && r.max_parallel > l.max) own.push(`${at}: max_parallel ${r.max_parallel} is above the limit "${l.text}"`)
62 }
63 }
64 if (own.length === 0) {
65 const lead = leadOf(s)
66 for (const r of s.roles) {
67 if (lead && r.unit === 'sub-conversation' && rank(c, r.effort) > rank(c, lead.effort)) {
68 warnings.push(`${where} role ${r.role}: a sub-conversation's effort (${r.effort}) is above its parent's (${lead.effort}); expected at most the parent's (non vérifié)`)
69 }
70 }
71 for (const l of c.limits) {
72 if (l.max == null || !l.unit) continue
73 const total = s.roles.filter(r => r.unit === l.unit).reduce((n, r) => n + r.max_parallel, 0)
74 if (total > l.max) warnings.push(`${where}: ${total} ${l.unit} at once if every role peaks together; limit "${l.text}"`)
75 }
76 }
77 if (s?.id) seen.add(s.id)
78 if (own.length) errors.push(...own)
79 else kept.push(s)
80 }
81 return { catalog: { ...c, scenarios: kept }, errors, warnings }
82}
83
84export const modelLabel = (c: Catalog, m: string) => c.models[m]?.label ?? m
85const roleName = (r: Role) => r.label ?? r.role
86const unitLabel = (c: Catalog, u: string) => c.units[u]?.label ?? u
87
88/** One line per role: what it runs on and as what. */
89export function roleLine(c: Catalog, r: Role): string {
90 return `${roleName(r)}: ${modelLabel(c, r.model)} · ${r.effort} · ${unitLabel(c, r.unit)} · max ${r.max_parallel}${r.note ? ` (${r.note})` : ''}`
91}
92
93/** The exact settings for a role, by the unit it runs as. Each line ends with the doc key it rests on. */
94export function settingsFor(c: Catalog, r: Role): { text: string; source: string; verified: boolean }[] {
95 const agent = r.agent ?? r.role
96 const out: { text: string; source: string; verified: boolean }[] = []
97 switch (r.unit) {
98 case 'session':
99 out.push({ text: `start the session with --model ${r.model} and /effort ${r.effort} (or "model" / "effortLevel" in settings); never switch a running session`, source: 'model-config', verified: true })
100 break
101 case 'sub-conversation':
102 out.push({ text: `start it on ${r.model}, effort ${r.effort}; its effort is expected to be at most the parent's`, source: 'model-config', verified: false })
103 break
104 case 'subagent':
105 out.push({ text: `Agent call: model: "${r.model}", effort: "${r.effort}", subagent_type: "${agent}"`, source: 'sub-agents', verified: true })
106 out.push({ text: `or agents/${agent}.md frontmatter: model: ${r.model}, effort: ${r.effort}`, source: 'sub-agents', verified: true })
107 break
108 case 'workflow-agent':
109 out.push({ text: `the model a script names for an agent() counts as the per-invocation model: ${r.model}`, source: 'workflows', verified: true })
110 out.push({ text: `agent() opts: { model: '${r.model}' } (option spelling not shown in the docs)`, source: 'workflows', verified: false })
111 out.push({ text: `effort ${r.effort}: no documented agent() option; set it in agents/${agent}.md frontmatter`, source: 'workflows', verified: false })
112 break
113 }
114 return out
115}
116
117const envDefault = (s: Scenario, unit: string) => s.roles.find(r => r.unit === unit)?.model
118
119/** A ready-to-paste snippet of one kind for one scenario. */
120export function snippet(c: Catalog, s: Scenario, kind: Kind): string {
121 const subs = s.roles.filter(r => r.unit === 'subagent')
122 const wfs = s.roles.filter(r => r.unit === 'workflow-agent')
123 switch (kind) {
124 case 'prompt': {
125 const caps = Object.entries(s.caps ?? {}).map(([k, v]) => `${k.replace(/_/g, ' ')} <= ${v}`)
126 return (
127 `Scenario ${s.id} "${s.name}": ` +
128 s.roles.map(r => `${roleName(r)} ${modelLabel(c, r.model)} ${r.effort} as ${unitLabel(c, r.unit)} (max ${r.max_parallel})`).join('; ') +
129 `. Every Agent and Workflow agent() call names its model.` +
130 (s.escalation === false ? ' No effort escalation.' : '') +
131 (caps.length ? ` Caps: ${caps.join(', ')}.` : '')
132 )
133 }
134 case 'agent':
135 if (!subs.length) return `// ${s.id}: no role runs as an Agent subagent`
136 return subs
137 .map(r => `// ${roleName(r)}, up to ${r.max_parallel} at once\nAgent({ description: "<3-5 words>", subagent_type: "${r.agent ?? r.role}", model: "${r.model}", effort: "${r.effort}", prompt: "<task>" })`)
138 .join('\n')
139 case 'workflow': {
140 if (!wfs.length) return `// ${s.id}: no role runs as a Workflow agent`
141 const lines = wfs.map(r => ` ${JSON.stringify(r.role)}: { model: '${r.model}' }, // effort ${r.effort}: agents/${r.agent ?? r.role}.md (agent() effort undocumented); max ${r.max_parallel} at once`)
142 return `// Scenario ${s.id} "${s.name}": opts for each agent() call\nconst ROLE_OPTS = {\n${lines.join('\n')}\n}`
143 }
144 case 'frontmatter': {
145 const files = [...subs, ...wfs]
146 if (!files.length) return `# ${s.id}: no agent roles`
147 return files.map(r => `# agents/${r.agent ?? r.role}.md\n---\nname: ${r.agent ?? r.role}\ndescription: ${roleName(r)}\nmodel: ${r.model}\neffort: ${r.effort}\n---`).join('\n\n')
148 }
149 case 'env': {
150 const env: Record<string, string> = {}
151 const sub = s.defaults?.subagent_model ?? envDefault(s, 'subagent')
152 const wf = s.defaults?.workflow_model ?? envDefault(s, 'workflow-agent')
153 if (sub) env.CLAUDE_CODE_SUBAGENT_MODEL = c.models[sub]?.id ?? sub
154 if (wf) env.CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL = c.models[wf]?.id ?? wf
155 return (
156 `// ~/.claude/settings.json: the model a call that names none falls back to (after its agent file's model)\n` +
157 (wf ? `// CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL: non vérifié (not in the workflows docs; workflow agents follow the subagent order)\n` : '') +
158 JSON.stringify({ env }, null, 2)
159 )
160 }
161 }
162}
163
164/** Limits that apply to a scenario: every limit with no unit, and those whose unit a role uses. */
165export function limitsFor(c: Catalog, s: Scenario): Limit[] {
166 const units = new Set(s.roles.map(r => r.unit))
167 return c.limits.filter(l => !l.unit || units.has(l.unit))
168}
169
170/** `/scenarios [id] [kind]`, in either order, case-insensitive. */
171export function parseArgs(args: string, ids: string[]): { id?: string; kind?: Kind } {
172 const out: { id?: string; kind?: Kind } = {}
173 for (const word of args.trim().split(/\s+/).filter(Boolean)) {
174 const low = word.toLowerCase()
175 const kind = KINDS.find(k => k === low)
176 if (kind) out.kind = kind
177 else out.id = ids.find(i => i.toLowerCase() === low) ?? word
178 }
179 return out
180}
181
182/** `~` and `%USERPROFILE%`-free path for a user's file. */
183export const expandHome = (p: string, home: string) => p.replace(/^~(?=[\\/]|$)/, home).replace(/\\/g, '/')
184types/index.d.ts 72 lines1// Shapes of scenarios.json (and of a user's extra scenarios file) and the `$.state` values of the scenarios mod.
2
3export type ModelInfo = { label: string; id: string }
4export type UnitInfo = {
5 label: string
6 /** what to set for a role run as this unit (one line) */
7 how: string
8 /** key into `sources` */
9 source: string
10}
11export type Limit = {
12 id: string
13 text: string
14 /** key into `sources` */
15 source: string
16 /** false: not found in the docs ("non vérifié") */
17 verified: boolean
18 /** with `unit`: the most agents of that unit that run at once */
19 max?: number
20 unit?: string
21}
22export type Role = {
23 role: string
24 label?: string
25 /** alias from `models` (opus, sonnet, haiku) or a full model id */
26 model: string
27 effort: string
28 /** key into `units`: session, sub-conversation, workflow-agent, subagent */
29 unit: string
30 max_parallel: number
31 /** agent file name (subagent_type) for subagent and workflow-agent roles */
32 agent?: string
33 note?: string
34}
35export type Scenario = {
36 id: string
37 name: string
38 summary?: string
39 roles: Role[]
40 /** free caps shown as rules: { "ci_pipelines": 4, "workflows": 1 } */
41 caps?: Record<string, number>
42 /** may a role move up the effort ladder after a failure */
43 escalation?: boolean
44 /** default model for the env vars; else the first role of that unit */
45 defaults?: { subagent_model?: string; workflow_model?: string }
46}
47export type ScenarioFile = {
48 schema?: number
49 sources?: Record<string, string>
50 models?: Record<string, ModelInfo>
51 efforts?: string[]
52 units?: Record<string, UnitInfo>
53 limits?: Limit[]
54 snippets?: string[]
55 scenarios?: Scenario[]
56}
57export type Catalog = Required<Omit<ScenarioFile, 'schema'>>
58export type Checked = { catalog: Catalog; errors: string[]; warnings: string[] }
59
60declare module 'claude-code' {
61 interface PluginState {
62 scenarios: {
63 /** id of the scenario the pane shows */
64 selected: string
65 /** snippet kind the pane shows: prompt, agent, workflow, frontmatter, env */
66 kind: string
67 /** last copy's outcome line */
68 copied: string
69 }
70 }
71}
72