SLOPSHOPPER

scenarios

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

newpanecommandtoastagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scenarios
│ ┃ Scénarios d'agents ✕ › fix the failing auth test and add an audit log call │ ┃ Read-only guide. Edit scenarios.json or your │ ┃ own file (userConfig "file") to add S5 and ⏺ Read(src/auth.ts) │ ┃ more. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 1 error(s): those scenarios are left out ⎿ Added 2 lines, removed 1 line │ ┃ · cannot read ⏺ Bash(bun test) │ ┃ /plugins/scenarios/scenarios.json ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No valid scenario. Fix the errors above. │ ✻ Worked for 42s · done 4:20 PM │ │ › /scenarios │ ⎿ scenarios: scenarios: 0 scenario(s), 1 error(s) in the files · p │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Scénarios d'agents
Read-only guide. Edit scenarios.json or your own file (userConfig "file") to add S5 and more. 1 error(s): those scenarios are left out · cannot read /plugins/scenarios/scenarios.json No valid scenario. Fix the errors above.
README

claude-mod

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

The mods

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

Catalog

What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.

ModVerdictWhy
factorykeepThe 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.
controlkeepThe "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.
coordinatormerge into factoryLinked 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 bandSmall and correct: context size against the hand-off threshold, read-only. Not written yet.
cc-control guardsdropThey refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out.
cinew (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.
scenariosnew (Oct 2026)Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts.
guidenew (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, nightdropWrong numbers for new model ids, superseded by factory, or built on timer wake-ups.
V4 pages and watcherdropDead since early Oct, replaced by factory and the app's own agent view.

Install

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.

ci

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: --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.
  • Status check: token presence only (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: 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.
  • Needs Python 3 on PATH; gh and glab are optional.

Rules for every mod here

  • Show and warn, never block: no deny, no rewriting of tool calls or prompts.
  • Read-only on your files; each mod writes only its own cache and output folder (ci also writes its routing file, on /ci route and /ci clear).
  • No network except gh / glab for PR state and the GitLab API for runner status (ci).
  • Configurable legends, labels, colors and thresholds.
  • Tests next to the code.

Tests

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.

License

MIT

Source 3 files
hooks/register.tsx 209 lines
1// 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}
209
hooks/logic.ts 184 lines
1// 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, '/')
184
types/index.d.ts 72 lines
1// 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