Turns Claude's closing question, or its AskUserQuestion call, into a side panel: each option with what it implies, and a real forked session to try any answer…

Convierte la pregunta con la que Claude cierra su respuesta, o su llamada a AskUserQuestion, en un panel lateral. Cada opción aparece con lo que conlleva. Además, puedes probar cualquier respuesta en una rama, una sesión bifurcada de verdad, antes de confirmarla.
La rama investiga en sólo lectura y te deja un informe: qué haría, qué cambiaría, riesgos y lo que aún no sabe. Tú decides después. La respuesta que confirmas llega como tu próximo mensaje.
Instalarlo desde el marketplace. La raíz de este repositorio trae .claude-plugin/marketplace.json:
claude plugin marketplace add melandres8/claude-mods
claude plugin install decision-tree@claude-mods
Para trabajar sobre una copia local, pasa la ruta del clon en lugar de melandres8/claude-mods. Claude Code lee el plugin desde la carpeta misma. Tras editarlo, /reload-plugins lo recarga en la sesión abierta.
Usa una sola forma de cargarlo. Si lo instalas y además lo pasas con --plugin-dir, los hooks se cargan dos veces.
No hay que hacer nada para que funcione: decision-tree escucha cada turno.
AskUserQuestion: cuando Claude abre su diálogo de preguntas, el panel lo toma en su lugar. En la conversación queda una tarjeta que apunta al panel. Si el panel no se puede colocar, sigue el diálogo nativo de siempre.decision-tree: una pregunta para explorar · /decision-tree)./decision-tree abre el panel cuando quieras.Ramas anteriores: cuando llega una pregunta nueva, los informes de las ramas ya terminadas siguen en el panel, bajo «Ramas anteriores». Se muestran los tres últimos y se guardan los diez últimos.
Si respondes por tu cuenta, escribiendo en el prompt, desde el móvil o desde el SDK, la pregunta del panel se da por respondida y desaparece. Así Confirmar nunca envía una respuesta pasada. Un comando con /, como /decision-tree, no cuenta como respuesta.
Una rama es un fork de la sesión: hereda la conversación y el modo de permisos. Para que pueda probar una respuesta sin consecuencias, decision-tree vigila cada llamada a herramienta que hace la rama y rechaza todo lo que no sea leer. El rechazo le pide que describa el cambio en su informe en lugar de aplicarlo.
| Permitidas | Rechazadas |
|---|---|
Read, Grep, Glob, LSP, ToolSearch y Bash sólo con los comandos de abajo | Todas las demás: Write, Edit, NotebookEdit, Agent, AskUserQuestion, cualquier herramienta MCP y también WebFetch y WebSearch |
Por qué no hay herramientas de red: una rama puede leer secretos, como un .env. Una URL o una búsqueda los sacaría fuera. Esto cierra las herramientas que salen a la red, no todo canal posible: mira las Notas.
Cada comando se lee como lo leería el shell (bash o zsh). Si algo no encaja con estas reglas, se rechaza.
Cómo se lee la línea
|, ||, &&, ;, & y el salto de línea. Cada comando simple debe estar permitido.jq '.x', grep 'a|b'). El flag se juzga ya sin comillas: find . '-delete' es -delete y se rechaza. Dentro de comillas simples todo es literal. Dentro de comillas dobles se rechazan $, el acento grave y \. Una comilla sin cerrar se rechaza._ . / : = , @ % + ~ -, más ! como palabra suelta (para find). Por tanto, no hay expansión de variables, sustitución de comandos, redirecciones, globs (* ? [ ]), llaves ({a,b}) ni barras invertidas.--pre=./x). Por eso ls src/*.ts se rechaza. Para listar por patrón, la rama usa Glob, o pasa el patrón entre comillas a la herramienta que lo interpreta: find . -name '*.ts', rg -g '*.ts', grep --include='*.ts'.2>&1, 2>/dev/null y 2> /dev/null.FOO=1 ls), rutas a binarios (/bin/ls) ni envoltorios (env, command, xargs, sudo).Flags
Cada binario tiene su lista cerrada de flags. Cualquier otro se rechaza.
-la). Una letra que lleva valor toma el resto de la palabra (-n5) o la palabra siguiente (-n 5).--out por --output.= (--glob=x). La palabra siguiente se juzga siempre por separado.-- termina los flags. Después de --, todo cuenta como operando, y un - suelto también.| Comando | Flags permitidos | Notas |
|---|---|---|
echo, true | cualquiera | sólo escriben en la salida |
pwd | -L -P | sin operandos |
ls | -aAbBcCdfFgGhHiklLmnopqQrRsStTuUvwx1, --all --almost-all --human-readable --recursive --reverse --color --classify --directory, --color= --sort= --time-style= | |
cat | -AbeEnstTuv, --number --number-nonblank --show-all --squeeze-blank | |
head | -q -v, -n N -c N, -N, --quiet --silent --verbose, --lines= --bytes= | |
tail | -q -r -v, -n N -c N, -N, --quiet --silent --verbose, --lines= --bytes= | sin -f, -F, --follow ni --retry: no terminaría nunca. Tampoco operandos que empiecen por +, porque la forma tradicional tail +5f también sigue el archivo |
grep | -rRnilLcvwxoqsHhEFGIPabU, -e -f -m -A -B -C -d -D con valor, -N, --recursive --line-number --ignore-case --files-with-matches --files-without-match --count --word-regexp --line-regexp --fixed-strings --extended-regexp --perl-regexp --only-matching --no-filename --with-filename --invert-match --color --colour --quiet --no-messages, --include= --exclude= --exclude-dir= --color= --colour= --max-count= --context= --binary-files= | |
rg | -nNiIsSlcwxvFuoHLpq0.UPabh, -e -f -t -T -g -A -B -C -m -M -j -E -r -d con valor, --files --hidden --no-ignore --no-ignore-vcs --no-heading --heading --count --json --vimgrep --line-number --no-line-number --ignore-case --smart-case --case-sensitive --fixed-strings --word-regexp --files-with-matches --files-without-match --follow --type-list --multiline --only-matching --invert-match --no-messages --count-matches --with-filename, --glob= --iglob= --type= --type-not= --max-count= --max-depth= --max-columns= --color= --sort= --sortr= --context= --after-context= --before-context= --max-filesize= | sin --pre, --pre-glob ni --hostname-bin (ejecutan programas), ni -z (ejecuta descompresores) |
find | delante: -H -L -P -E; luego las rutas; en la expresión: -print -print0 -prune -empty -depth -xdev -mount -follow -readable -executable -nouser -nogroup -true -false -not -a -o -and -or ! ( ) -ls y, con valor, -name -iname -path -ipath -wholename -iwholename -regex -iregex -type -xtype -maxdepth -mindepth -size -mtime -mmin -atime -amin -ctime -cmin -newer -perm -user -group -uid -gid -links -inum -samefile -lname -ilname | sin -delete, -exec, -execdir, -ok, -okdir, -fprint* ni -fls |
wc | -lwcmL, --lines --words --bytes --chars --max-line-length | |
which | -a -s | |
file | -bhiLkN, --mime --mime-type --mime-encoding --brief --dereference --no-dereference | sin -C ni -m (compilan o eligen archivos magic), ni -z |
stat | -LflrsxFtq, -c con valor, --dereference --terse, --format= --printf= | |
tree | -adfilspugDFCnNqvrtUxAShJX, -L -I -P con valor, --dirsfirst --noreport --gitignore --prune | sin -o (escribe el listado), -H ni -R (escriben HTML) |
du | -ahcskmxLHP0, -d con valor, --human-readable --summarize --apparent-size, --max-depth= | |
df | -hHkilTPa, --human-readable | |
date | -u -R -j -I, -r -v con valor, --utc --universal --iso-8601 --rfc-email, --iso-8601= --rfc-3339= --reference= --date= | sólo operandos +FORMATO: en BSD un operando suelto cambia la hora; sin -s, --set ni -d |
sort | -bdfgiMhnRrVuscCmz, -t -k -S con valor, --reverse --unique --numeric-sort --human-numeric-sort --version-sort --ignore-case --general-numeric-sort --month-sort --stable --check --merge --zero-terminated --dictionary-order --ignore-leading-blanks --random-sort, --key= --field-separator= --buffer-size= --sort= | sin -o/--output, -T/--temporary-directory ni --compress-program (ejecuta un programa) |
uniq | -cdDuiz, -f -s -w con valor, --count --repeated --unique --ignore-case, --skip-fields= --skip-chars= --check-chars= | como mucho un operando: el segundo sería el archivo de salida |
cut | -snw, -b -c -f -d con valor, --complement --only-delimited, --delimiter= --fields= --characters= --bytes= | |
tr | -cCdsu | como mucho dos operandos |
nl | -p, -b -d -f -h -i -l -n -s -v -w con valor | |
diff | -uaBbdEeiNnpqrsTtwy, -U -C -D -I -L -F -S -W -x -X con valor, --unified --recursive --brief --side-by-side --ignore-all-space --ignore-space-change --new-file --text, --unified= --color= --exclude= --label= | como mucho dos operandos |
jq | -rcensSjaCMRf, --raw-output --compact-output --slurp --null-input --sort-keys --join-output --exit-status --raw-input --ascii-output --tab --indent --arg --argjson --slurpfile --rawfile --args --jsonargs | |
basename | -a -z, -s con valor, --multiple --zero, --suffix= | |
dirname | -z, --zero | |
realpath | -eEmLPqsz, --relative-to= --relative-base= |
git: el subcomando va justo después de git. Así quedan fuera -c, -C, --exec-path y similares.
| Subcomando | Flags permitidos |
|---|---|
status | -s -b -z, --short --branch --porcelain --long --ignored --untracked-files, --porcelain= --untracked-files= --ignored= |
diff | -p -R -w -b -z, --stat --shortstat --numstat --name-only --name-status --cached --staged --patch --word-diff --color --no-color --minimal --patience --histogram --ignore-all-space --ignore-space-change --check --summary --raw --relative --exit-code --quiet --no-ext-diff --no-textconv, --unified= --diff-filter= --stat= --color= --word-diff= --relative= |
log | -p -z, -n -S -G con valor, -N, --oneline --stat --shortstat --numstat --name-only --name-status --graph --decorate --no-decorate --all --branches --tags --remotes --patch --follow --reverse --no-merges --merges --abbrev-commit --no-abbrev-commit --first-parent --color --no-color --summary --raw --topo-order --date-order --relative-date --pretty --format --full-history --source --left-right, --format= --pretty= --since= --until= --after= --before= --author= --committer= --grep= --max-count= --skip= --date= --decorate= --color= --abbrev= --diff-filter= --stat= |
show | -p -w -z, -n con valor, -N, y los largos de log y diff |
blame | -wslefnpct, -L con valor, --porcelain --line-porcelain --show-email |
ls-files | -cdmoiksutvfz, --others --cached --modified --deleted --exclude-standard --error-unmatch --stage --full-name --directory, --exclude= |
rev-parse | -q, --show-toplevel --abbrev-ref --short --git-dir --git-common-dir --absolute-git-dir --is-inside-work-tree --is-bare-repository --verify --symbolic-full-name --show-prefix --show-cdup, --short= |
describe | --tags --always --dirty --long --all --contains --exact-match, --abbrev= --match= --exclude= --dirty= |
branch | -a -r -v, --all --remotes --list --show-current --verbose; sin operandos |
remote | -v, --verbose; sin operandos |
Ejemplos que funcionan: ls -la, grep -rn foo src, rg -n foo -g '*.ts', find . -name '*.ts' -type f, git log --oneline -5, git diff --stat, head -n 20 f, wc -l f, sort f | uniq -c | sort -rn, jq '.scripts' package.json.
$.agent.spawn devuelva su id. El guardia la reconoce de tres formas. Si su id ya está guardado, la vigila. Si $.agent.list() la nombra como lanzada por decision-tree, también. Y mientras haya un arranque en curso, rechaza a cualquier agente que la lista aún no nombra. Los subagentes que lanzan el modelo u otros plugins siguen con todas sus herramientas.TaskStop y quedan marcadas como detenidas. Lo mismo ocurre con una rama que termina de arrancar cuando su pregunta ya cambió.git diff, git log -p y git show pueden ejecutar programas que tú configuraste en git, como diff.external, textconv o filtros. La rama no puede cambiar esa configuración, pero hereda la tuya.git status y git describe --dirty pueden reescribir la caché de .git/index. No cambian archivos ni historia./net/<host> hace que el automontador resuelva ese nombre. Una consulta DNS puede sacar datos aunque la rama no tenga herramientas de red. Pasa con el shell y también con Read, Grep y Glob.TaskStop pasa por la comprobación de permisos como cualquier herramienta. Si tu configuración la pide, puede aparecer un diálogo al cambiar de pregunta.$.state, así que aguanta una recarga en caliente. Los arranques en curso y las lecturas en curso viven en memoria del módulo: una recarga los olvida y descarta esas lecturas.claude plugin validate .
claude plugin test .
.claude-plugin/types lo genera Claude Code con los tipos del motor que usan el editor y los tests. No se sube al repositorio: la carpeta trae su propio .gitignore, que la excluye entera.
hooks/register.tsx 1078 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 DecisionAsk,
6 DecisionFork,
7 DecisionImplication,
8 DecisionOption,
9 DecisionQuestion,
10} from '../types'
11
12const PANE = 'decision-tree'
13const TITLE = 'Decisión'
14
15const phase = atom({ plugin: 'decision-tree', key: 'phase' } as const, 'idle')
16const ask = atom({ plugin: 'decision-tree', key: 'ask' } as const, null)
17const focus = atom({ plugin: 'decision-tree', key: 'focus' } as const, 0)
18const picks = atom({ plugin: 'decision-tree', key: 'picks' } as const, {})
19const implications = atom({ plugin: 'decision-tree', key: 'implications' } as const, {})
20const forks = atom({ plugin: 'decision-tree', key: 'forks' } as const, {})
21// Every fork this plugin started, never reset nor capped, so the guard below
22// keeps holding a fork that outlives the ask it was started for.
23const forkIds = atom({ plugin: 'decision-tree', key: 'forkIds' } as const, [])
24
25// The guard's own memory, for its .catch, which may not call $: the forks it
26// knows, the agents listed as someone else's, and the spawns under way, whose
27// forks can call tools before $.agent.spawn hands back their id.
28const knownForks = new Set<string>()
29const foreignAgents = new Set<string>()
30let pendingSpawns = 0
31
32// What the last drawing said about the layout; unknown until the pane draws.
33let isFullscreen: boolean | undefined
34
35// What a fork may call: reading the workspace, nothing that changes it.
36// Bash is judged per command by isReadOnlyShell. No WebFetch or WebSearch: a
37// fork may read secrets (.env), and a URL or a query would carry them out.
38const FORK_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch'])
39
40// The shell guard reads a command the way the shell will, then judges each
41// simple command against an allowlist of binaries and, per binary, of flags.
42//
43// Quoting: the command is split into words by a small shell-word lexer, so
44// a quoted flag (`'-delete'`) is judged as the flag it becomes. Quotes are
45// allowed because patterns need them (`jq '.x'`, `grep 'a|b'`). Inside single
46// quotes every character is literal; inside double quotes `$`, backtick and
47// backslash are refused, since the shell expands them there. Unquoted, only
48// plain characters pass (letters, digits, `_ . / : = , @ % + ~ -`, and `!` as a
49// word of its own): no expansion, substitution, redirection, globbing or
50// brace expansion. A glob is refused because it can expand to a file named
51// like a flag (`--pre=./x`); `ls src/*.ts` is therefore refused, and the Glob
52// tool is the way to list by pattern. The one redirection read is stderr's:
53// `2>&1`, `2>/dev/null`, `2> /dev/null`. This is held for bash and zsh alike.
54//
55// Flags: every flag must be one the binary's spec lists, deny by default.
56// Short flags may be bundled (`-la`); a value letter takes the rest of the
57// word (`-n5`) or the next word (`-n 5`). Long flags match exactly, never by
58// prefix (GNU and git accept unique prefixes, `--out` for `--output`), and a
59// long flag's value is read only from `--name=value`: the next word is always
60// judged on its own. A letter is listed as taking a value only where every
61// GNU or BSD tool that knows it takes one (one that lacks it fails), so no
62// word the tool reads as a flag escapes the check.
63
64type ShellSpec = {
65 /** Short letters that take no value; any order, bundled. */
66 short?: string
67 /** Short letters that take a value: the rest of the word, else the next word. */
68 shortValue?: string
69 /** Long flags given bare. */
70 long?: readonly string[]
71 /** Long flags given as `--name=value`. */
72 longValue?: readonly string[]
73 /** Whether `-N` (a count, as `head -50`) is a flag. */
74 count?: boolean
75 /** The most operands it takes; a bare `-` counts. Any when absent. */
76 maxOperands?: number
77 /** What every operand must be. */
78 operand?: (word: string) => boolean
79}
80
81const LOG_LONG = [
82 '--oneline', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--graph',
83 '--decorate', '--no-decorate', '--all', '--branches', '--tags', '--remotes', '--patch', '--follow',
84 '--reverse', '--no-merges', '--merges', '--abbrev-commit', '--no-abbrev-commit', '--first-parent',
85 '--color', '--no-color', '--summary', '--raw', '--topo-order', '--date-order', '--relative-date',
86 '--pretty', '--format', '--full-history', '--source', '--left-right',
87]
88const LOG_VALUE = [
89 '--format', '--pretty', '--since', '--until', '--after', '--before', '--author', '--committer', '--grep',
90 '--max-count', '--skip', '--date', '--decorate', '--color', '--abbrev', '--diff-filter', '--stat',
91]
92const DIFF_LONG = [
93 '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--cached', '--staged', '--patch',
94 '--word-diff', '--color', '--no-color', '--minimal', '--patience', '--histogram', '--ignore-all-space',
95 '--ignore-space-change', '--check', '--summary', '--raw', '--relative', '--exit-code', '--quiet',
96 '--no-ext-diff', '--no-textconv',
97]
98const DIFF_VALUE = ['--unified', '--diff-filter', '--stat', '--color', '--word-diff', '--relative']
99
100const GIT_SPECS: Record<string, ShellSpec> = {
101 status: {
102 short: 'sbz',
103 long: ['--short', '--branch', '--porcelain', '--long', '--ignored', '--untracked-files'],
104 longValue: ['--porcelain', '--untracked-files', '--ignored'],
105 },
106 diff: { short: 'pRwbz', long: DIFF_LONG, longValue: DIFF_VALUE },
107 log: { short: 'pz', shortValue: 'nSG', count: true, long: LOG_LONG, longValue: LOG_VALUE },
108 show: {
109 short: 'pwz',
110 shortValue: 'n',
111 count: true,
112 long: [...LOG_LONG, ...DIFF_LONG],
113 longValue: [...LOG_VALUE, ...DIFF_VALUE],
114 },
115 blame: { short: 'wslefnpct', shortValue: 'L', long: ['--porcelain', '--line-porcelain', '--show-email'] },
116 'ls-files': {
117 short: 'cdmoiksutvfz',
118 long: ['--others', '--cached', '--modified', '--deleted', '--exclude-standard', '--error-unmatch', '--stage', '--full-name', '--directory'],
119 longValue: ['--exclude'],
120 },
121 'rev-parse': {
122 short: 'q',
123 long: [
124 '--show-toplevel', '--abbrev-ref', '--short', '--git-dir', '--git-common-dir', '--absolute-git-dir',
125 '--is-inside-work-tree', '--is-bare-repository', '--verify', '--symbolic-full-name', '--show-prefix', '--show-cdup',
126 ],
127 longValue: ['--short'],
128 },
129 describe: {
130 long: ['--tags', '--always', '--dirty', '--long', '--all', '--contains', '--exact-match'],
131 longValue: ['--abbrev', '--match', '--exclude', '--dirty'],
132 },
133 branch: { short: 'arv', long: ['--all', '--remotes', '--list', '--show-current', '--verbose'], maxOperands: 0 },
134 remote: { short: 'v', long: ['--verbose'], maxOperands: 0 },
135}
136
137const SHELL_SPECS: Record<string, ShellSpec | 'any'> = {
138 echo: 'any',
139 true: 'any',
140 pwd: { short: 'LP', maxOperands: 0 },
141 ls: {
142 short: 'aAbBcCdfFgGhHiklLmnopqQrRsStTuUvwx1',
143 long: ['--all', '--almost-all', '--human-readable', '--recursive', '--reverse', '--color', '--classify', '--directory'],
144 longValue: ['--color', '--sort', '--time-style'],
145 },
146 cat: { short: 'AbeEnstTuv', long: ['--number', '--number-nonblank', '--show-all', '--squeeze-blank'] },
147 head: { short: 'qv', shortValue: 'nc', count: true, long: ['--quiet', '--silent', '--verbose'], longValue: ['--lines', '--bytes'] },
148 // No -f, -F, --follow or --retry: a fork that follows a file never ends. No
149 // operand starting with +: the traditional form `tail +5f` follows too.
150 tail: {
151 short: 'qrv',
152 shortValue: 'nc',
153 count: true,
154 long: ['--quiet', '--silent', '--verbose'],
155 longValue: ['--lines', '--bytes'],
156 operand: word => !word.startsWith('+'),
157 },
158 grep: {
159 short: 'rRnilLcvwxoqsHhEFGIPabU',
160 shortValue: 'efmABCdD',
161 count: true,
162 long: [
163 '--recursive', '--line-number', '--ignore-case', '--files-with-matches', '--files-without-match', '--count',
164 '--word-regexp', '--line-regexp', '--fixed-strings', '--extended-regexp', '--perl-regexp', '--only-matching',
165 '--no-filename', '--with-filename', '--invert-match', '--color', '--colour', '--quiet', '--no-messages',
166 ],
167 longValue: ['--include', '--exclude', '--exclude-dir', '--color', '--colour', '--max-count', '--context', '--binary-files'],
168 },
169 // No --pre, --pre-glob or --hostname-bin (each runs a program), nor -z
170 // (runs decompressors).
171 rg: {
172 short: 'nNiIsSlcwxvFuoHLpq0.UPabh',
173 shortValue: 'eftTgABCmMjErd',
174 long: [
175 '--files', '--hidden', '--no-ignore', '--no-ignore-vcs', '--no-heading', '--heading', '--count', '--json',
176 '--vimgrep', '--line-number', '--no-line-number', '--ignore-case', '--smart-case', '--case-sensitive',
177 '--fixed-strings', '--word-regexp', '--files-with-matches', '--files-without-match', '--follow', '--type-list',
178 '--multiline', '--only-matching', '--invert-match', '--no-messages', '--count-matches', '--with-filename',
179 ],
180 longValue: [
181 '--glob', '--iglob', '--type', '--type-not', '--max-count', '--max-depth', '--max-columns', '--color', '--sort',
182 '--sortr', '--context', '--after-context', '--before-context', '--max-filesize',
183 ],
184 },
185 wc: { short: 'lwcmL', long: ['--lines', '--words', '--bytes', '--chars', '--max-line-length'] },
186 which: { short: 'as' },
187 // No -C or -m (compile and choose magic files), nor -z (runs decompressors).
188 file: { short: 'bhiLkN', long: ['--mime', '--mime-type', '--mime-encoding', '--brief', '--dereference', '--no-dereference'] },
189 stat: { short: 'LflrsxFtq', shortValue: 'c', long: ['--dereference', '--terse'], longValue: ['--format', '--printf'] },
190 // No -o (writes the listing), -H or -R (write HTML files).
191 tree: {
192 short: 'adfilspugDFCnNqvrtUxAShJX',
193 shortValue: 'LIP',
194 long: ['--dirsfirst', '--noreport', '--gitignore', '--prune'],
195 },
196 du: { short: 'ahcskmxLHP0', shortValue: 'd', long: ['--human-readable', '--summarize', '--apparent-size'], longValue: ['--max-depth'] },
197 df: { short: 'hHkilTPa', long: ['--human-readable'] },
198 // Only `+FORMAT` operands: on BSD a bare operand sets the clock; no -s,
199 // --set or -d either.
200 date: {
201 short: 'uRjI',
202 shortValue: 'rv',
203 long: ['--utc', '--universal', '--iso-8601', '--rfc-email'],
204 longValue: ['--iso-8601', '--rfc-3339', '--reference', '--date'],
205 operand: word => word.startsWith('+'),
206 },
207 // No -o/--output, -T/--temporary-directory or --compress-program (runs a program).
208 sort: {
209 short: 'bdfgiMhnRrVuscCmz',
210 shortValue: 'tkS',
211 long: [
212 '--reverse', '--unique', '--numeric-sort', '--human-numeric-sort', '--version-sort', '--ignore-case',
213 '--general-numeric-sort', '--month-sort', '--stable', '--check', '--merge', '--zero-terminated',
214 '--dictionary-order', '--ignore-leading-blanks', '--random-sort',
215 ],
216 longValue: ['--key', '--field-separator', '--buffer-size', '--sort'],
217 },
218 // A second operand is the file uniq writes to.
219 uniq: {
220 short: 'cdDuiz',
221 shortValue: 'fsw',
222 long: ['--count', '--repeated', '--unique', '--ignore-case'],
223 longValue: ['--skip-fields', '--skip-chars', '--check-chars'],
224 maxOperands: 1,
225 },
226 cut: {
227 short: 'snw',
228 shortValue: 'bcfd',
229 long: ['--complement', '--only-delimited'],
230 longValue: ['--delimiter', '--fields', '--characters', '--bytes'],
231 },
232 tr: { short: 'cCdsu', maxOperands: 2 },
233 nl: { short: 'p', shortValue: 'bdfhilnsvw' },
234 diff: {
235 short: 'uaBbdEeiNnpqrsTtwy',
236 shortValue: 'UCDILFSWxX',
237 long: ['--unified', '--recursive', '--brief', '--side-by-side', '--ignore-all-space', '--ignore-space-change', '--new-file', '--text'],
238 longValue: ['--unified', '--color', '--exclude', '--label'],
239 maxOperands: 2,
240 },
241 jq: {
242 short: 'rcensSjaCMRf',
243 long: [
244 '--raw-output', '--compact-output', '--slurp', '--null-input', '--sort-keys', '--join-output', '--exit-status',
245 '--raw-input', '--ascii-output', '--tab', '--indent', '--arg', '--argjson', '--slurpfile', '--rawfile', '--args', '--jsonargs',
246 ],
247 },
248 basename: { short: 'az', shortValue: 's', long: ['--multiple', '--zero'], longValue: ['--suffix'] },
249 dirname: { short: 'z', long: ['--zero'] },
250 realpath: { short: 'eEmLPqsz', longValue: ['--relative-to', '--relative-base'] },
251}
252
253// find's expression: tests and actions that only read and print.
254const FIND_LEADING = new Set(['-H', '-L', '-P', '-E'])
255const FIND_PLAIN = new Set([
256 '-print', '-print0', '-prune', '-empty', '-depth', '-xdev', '-mount', '-follow', '-readable', '-executable',
257 '-nouser', '-nogroup', '-true', '-false', '-not', '-a', '-o', '-and', '-or', '!', '(', ')', '-ls',
258])
259const FIND_VALUE = new Set([
260 '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-type', '-xtype',
261 '-maxdepth', '-mindepth', '-size', '-mtime', '-mmin', '-atime', '-amin', '-ctime', '-cmin', '-newer', '-perm',
262 '-user', '-group', '-uid', '-gid', '-links', '-inum', '-samefile', '-lname', '-ilname',
263])
264
265/** Whether find's words (after `find`) read and print only. */
266function findReadsOnly(words: readonly string[]): boolean {
267 let i = 0
268 while (i < words.length && FIND_LEADING.has(words[i] ?? '')) i += 1
269 // The starting points: words up to the first that opens the expression.
270 while (i < words.length && !/^[-!()]/.test(words[i] ?? '')) i += 1
271 while (i < words.length) {
272 const word = words[i] ?? ''
273 if (FIND_VALUE.has(word)) {
274 if (i + 1 >= words.length) return false
275 i += 2
276 } else if (FIND_PLAIN.has(word)) {
277 i += 1
278 } else {
279 return false
280 }
281 }
282 return true
283}
284
285/** Whether the words after a binary (or a git subcommand) keep to its spec. */
286function keepsToSpec(words: readonly string[], spec: ShellSpec): boolean {
287 let operands = 0
288 let isPastFlags = false
289 for (let i = 0; i < words.length; i += 1) {
290 const word = words[i] ?? ''
291 if (!isPastFlags && word === '--') {
292 isPastFlags = true
293 continue
294 }
295 if (isPastFlags || word === '-' || !word.startsWith('-')) {
296 operands += 1
297 if (spec.operand !== undefined && !spec.operand(word)) return false
298 continue
299 }
300 if (word.startsWith('--')) {
301 const eq = word.indexOf('=')
302 const name = eq === -1 ? word : word.slice(0, eq)
303 const isListed = eq === -1 ? (spec.long ?? []).includes(name) : (spec.longValue ?? []).includes(name)
304 if (!isListed) return false
305 continue
306 }
307 if (spec.count === true && /^-\d+$/.test(word)) continue
308
309 const letters = word.slice(1)
310 for (let j = 0; j < letters.length; j += 1) {
311 const letter = letters[j] ?? ''
312 if ((spec.short ?? '').includes(letter)) continue
313 if (!(spec.shortValue ?? '').includes(letter)) return false
314 // A value letter takes the rest of the word, or else the next word.
315 if (j === letters.length - 1) {
316 if (i + 1 >= words.length) return false
317 i += 1
318 }
319 break
320 }
321 }
322 return spec.maxOperands === undefined || operands <= spec.maxOperands
323}
324
325// A spec of the table's own, never one inherited (`toString`, `constructor`).
326function specOf<T>(table: Record<string, T>, name: string): T | undefined {
327 return Object.prototype.hasOwnProperty.call(table, name) ? table[name] : undefined
328}
329
330function isReadOnlyStep(words: readonly string[]): boolean {
331 const [bin = '', ...rest] = words
332 if (bin === 'find') return findReadsOnly(rest)
333 if (bin === 'git') {
334 // Nothing between git and its subcommand: -c and -C change what runs.
335 const [sub = '', ...args] = rest
336 const spec = specOf(GIT_SPECS, sub)
337 return spec !== undefined && keepsToSpec(args, spec)
338 }
339 const spec = specOf(SHELL_SPECS, bin)
340 if (spec === undefined) return false
341 return spec === 'any' || keepsToSpec(rest, spec)
342}
343
344const PLAIN_CHAR = /[A-Za-z0-9_./:=,@%+~-]/
345const STDERR_REDIRECTS = ['2>&1', '2>/dev/null', '2> /dev/null']
346
347/**
348 * The simple commands of a command line, each as the words the shell would
349 * pass, quotes removed; null when the line holds anything the lexer does not
350 * read as plain (see the comment above ShellSpec).
351 */
352export function shellSteps(command: string): string[][] | null {
353 const steps: string[][] = []
354 let words: string[] = []
355 let word: string | null = null
356 const endWord = () => {
357 if (word !== null) words.push(word)
358 word = null
359 }
360 const endStep = () => {
361 endWord()
362 if (words.length > 0) steps.push(words)
363 words = []
364 }
365 const isBoundary = (at: number) => at >= command.length || /[\s;&|]/.test(command[at] ?? '')
366
367 let i = 0
368 while (i < command.length) {
369 const c = command[i] ?? ''
370 if (word === null) {
371 const redirect = STDERR_REDIRECTS.find(r => command.startsWith(r, i) && isBoundary(i + r.length))
372 if (redirect !== undefined) {
373 i += redirect.length
374 continue
375 }
376 if (c === '!' && isBoundary(i + 1)) {
377 words.push('!')
378 i += 1
379 continue
380 }
381 }
382 if (c === "'") {
383 const close = command.indexOf("'", i + 1)
384 if (close === -1) return null
385 word = (word ?? '') + command.slice(i + 1, close)
386 i = close + 1
387 } else if (c === '"') {
388 const close = command.indexOf('"', i + 1)
389 if (close === -1) return null
390 const inner = command.slice(i + 1, close)
391 if (/[$`\\]/.test(inner)) return null
392 word = (word ?? '') + inner
393 i = close + 1
394 } else if (c === ' ' || c === '\t') {
395 endWord()
396 i += 1
397 } else if (c === '\n' || c === ';' || c === '|' || c === '&') {
398 // `|`, `||`, `&&`, `&`, `;` and a newline all end a simple command.
399 endStep()
400 i += 1
401 } else if (PLAIN_CHAR.test(c)) {
402 word = (word ?? '') + c
403 i += 1
404 } else {
405 return null
406 }
407 }
408 endStep()
409 return steps
410}
411
412export function isReadOnlyShell(command: string): boolean {
413 const steps = shellSteps(command)
414 return steps !== null && steps.length > 0 && steps.every(isReadOnlyStep)
415}
416
417/** Whether a fork this plugin started may make this call: read and look up, never change. */
418export function forkMayCall(tool: string, input: { command?: unknown }): boolean {
419 if (FORK_TOOLS.has(tool)) return true
420
421 return tool === 'Bash' && typeof input.command === 'string' && isReadOnlyShell(input.command)
422}
423
424type AskedQuestion = {
425 question: string
426 header?: string
427 options?: readonly { label: string; description?: string }[]
428}
429
430/** An AskUserQuestion call's questions as the panel holds them; null when none has options. */
431export function fromToolQuestions(questions: readonly AskedQuestion[]): DecisionQuestion[] | null {
432 const held = questions
433 .filter(q => (q.options ?? []).length > 0)
434 .map(q => ({
435 text: q.question,
436 header: q.header,
437 options: (q.options ?? []).map(o => ({ label: o.label, reply: o.label, detail: o.description })),
438 }))
439 return held.length > 0 ? held : null
440}
441
442/** What is sent when the person confirms: the reply alone for one question, a list for several. */
443export function composeAnswer(a: DecisionAsk, chosen: Record<string, number>): string {
444 const replies = a.questions.map((q, i) => ({ q, option: q.options[chosen[i] ?? 0] }))
445 if (replies.length === 1 && a.source === 'text') return replies[0]?.option?.reply ?? ''
446
447 const lines = replies.map(({ q, option }) => `- ${q.text} → ${option?.reply ?? '(sin respuesta)'}`)
448 return `Mis respuestas:\n${lines.join('\n')}`
449}
450
451const ASK_PROMPT = (answer: string) => `You read the end of an assistant reply and decide whether it ends by asking the user a question they are expected to answer.
452
453Reply with JSON only, no prose, no code fence:
454- no question: {"question": null}
455- a question: {"question": "<the question, as asked, in its own language>", "options": [{"label": "<2-6 words>", "reply": "<what the user would type to give this answer, first person, in the question's language>"}]}
456
457Give 2 to 4 options: the ones the reply offered, else the natural answers (yes/no plus a meaningful alternative). Do not invent options unrelated to the reply.
458
459The end of the reply:
460<<<
461${answer.slice(-2500)}
462>>>`
463
464const IMPLY_PROMPT = (a: DecisionAsk, q: DecisionQuestion, reply: string) => `[decision-tree: análisis, no es mi respuesta]
465Tu última respuesta terminó así:
466<<<
467${a.context}
468>>>
469Y me preguntabas: «${q.text}»
470Si te respondiera: «${reply}»
471
472¿Qué conlleva esa respuesta? No hagas nada todavía, sólo analiza. Responde en markdown, máximo 10 líneas, en el idioma de la pregunta, con estas partes en negrita: **Qué harías después**, **A favor**, **En contra / riesgos**, **Reversible**.`
473
474const FORK_PROMPT = (a: DecisionAsk, q: DecisionQuestion, reply: string) => `[Rama de prueba de decision-tree: esta NO es mi respuesta definitiva]
475Tu última respuesta terminó así:
476<<<
477${a.context}
478>>>
479A tu pregunta «${q.text}» respondo: «${reply}»
480
481Sigue como si esta fuera mi respuesta real, pero como ensayo: investiga y lee lo que necesites; no modifiques archivos ni ejecutes nada que cambie estado (en esta rama esas herramientas están bloqueadas, así que no lo intentes en bucle). Cuando tengas suficiente, termina con un informe en markdown:
482**Qué haría**: los pasos concretos.
483**Cambios**: archivos y cambios propuestos (diffs cortos si ayudan).
484**Riesgos** y **Lo que aún no sé**.`
485
486// Marks the refusal as this plugin's handoff, for the transcript row that draws it.
487const HANDOFF_MARK = '[decision-tree]'
488const HANDOFF = `${HANDOFF_MARK} La persona va a decidir en el panel lateral de decision-tree, donde ve estas mismas opciones, lo que conlleva cada una y puede probarlas en ramas. Su respuesta llegará como su próximo mensaje. Termina tu turno ahora con una frase breve, sin repetir la pregunta ni las opciones.`
489
490function parseQuestion(text: string): DecisionQuestion | null {
491 const json = text.match(/\{[\s\S]*\}/)?.[0]
492 if (json === undefined) return null
493 try {
494 const value = JSON.parse(json) as { question?: unknown; options?: unknown }
495 if (typeof value.question !== 'string' || !Array.isArray(value.options)) return null
496 const options = value.options
497 .filter((o): o is DecisionOption => typeof o?.label === 'string' && typeof o?.reply === 'string')
498 .slice(0, 6)
499 return options.length > 0 ? { text: value.question, options } : null
500 } catch {
501 return null
502 }
503}
504
505function endsWithQuestion(answer: string): boolean {
506 return /\?/.test(answer.trim().slice(-600))
507}
508
509const slotOf = (id: string, qi: number, oi: number) => `${id}:${qi}:${oi}`
510
511async function settle($: EngineInterface) {
512 await update($, ask, () => null)
513 await update($, phase, () => 'idle')
514 $.ui.status(undefined)
515}
516
517// How many finished forks of earlier asks stay readable in the panel.
518const KEPT_REPORTS = 10
519const STOPPED = 'Rama detenida: la pregunta cambió antes de que terminara.'
520
521/** Stops a fork whose question is gone, best effort: TaskStop takes a background agent by its id. */
522function stopFork($: EngineInterface, agentId: string) {
523 void $.tool.call({ tool: 'TaskStop', task_id: agentId }).catch(() => undefined)
524}
525
526const isFinished = (fork: DecisionFork) => fork.status === 'done' || fork.status === 'error'
527
528/**
529 * Hands the forks of earlier asks over to a new one: a running fork is
530 * stopped, a finished one stays readable (the latest KEPT_REPORTS of them).
531 */
532async function retireForks($: EngineInterface, askId: string) {
533 const isEarlier = (slot: string) => !slot.startsWith(`${askId}:`)
534 const running = Object.entries(await read($, forks)).filter(([slot, fork]) => isEarlier(slot) && fork.status === 'running')
535 for (const [, fork] of running) if (fork.agentId !== undefined) stopFork($, fork.agentId)
536
537 await update($, forks, all => {
538 const entries = Object.entries(all).map(([slot, fork]): [string, DecisionFork] =>
539 isEarlier(slot) && fork.status === 'running' ? [slot, { ...fork, status: 'error', text: STOPPED }] : [slot, fork],
540 )
541 const finished = entries.filter(([slot, fork]) => isEarlier(slot) && isFinished(fork))
542 const dropped = new Set(finished.slice(0, Math.max(0, finished.length - KEPT_REPORTS)).map(([slot]) => slot))
543 return Object.fromEntries(entries.filter(([slot]) => !dropped.has(slot)))
544 })
545}
546
547/** Seats a new ask, clearing the last one's analysis and retiring its forks; resolves whether the pane is drawn. */
548async function present($: EngineInterface, next: DecisionAsk, isAsked: boolean) {
549 await update($, ask, () => next)
550 await update($, focus, () => 0)
551 await update($, picks, () => ({}))
552 await update($, implications, () => ({}))
553 await retireForks($, next.id)
554 await update($, phase, () => 'ready')
555 const count = next.questions.length > 1 ? `${next.questions.length} preguntas` : 'una pregunta'
556 $.ui.status(`decision-tree: ${count} para explorar · /decision-tree`)
557
558 // Unasked, open only where the pane docks beside the transcript; on the
559 // main screen it would open inline, so the status line points to it instead.
560 const shouldOpen = isAsked || isFullscreen !== false
561 const isPlaced = shouldOpen ? (await $.ui.open({ id: PANE, title: TITLE })).isPlaced : false
562 // A dialog the pane could not take goes back to the native one: nothing to analyse.
563 if (!isAsked || isPlaced) void imply($, next.id, 0, 0)
564 return isPlaced
565}
566
567// The main turn that ended last; undefined once the person spoke after it. A
568// question read for any other turn is stale and dropped.
569let latestTurn: string | undefined
570
571async function readQuestion($: EngineInterface, answer: string, turnId: string) {
572 await update($, phase, () => 'reading')
573 const reply = await $.model.complete({ model: 'haiku', prompt: ASK_PROMPT(answer), maxTokens: 800 })
574 if (latestTurn !== turnId) return
575 const found = reply.isAnswered ? parseQuestion(reply.text) : null
576
577 if (found === null) {
578 await settle($)
579 await update($, phase, () => 'none')
580 return
581 }
582 const context = answer.trim().slice(-2000)
583 await present($, { id: turnId, source: 'text', context, questions: [found] }, false)
584}
585
586async function current($: EngineInterface, id: string) {
587 const a = await read($, ask)
588 return a !== null && a.id === id ? a : null
589}
590
591// Slots whose analysis or fork is being started: a second press of the same
592// option waits for nothing and starts nothing.
593const implying = new Set<string>()
594const branching = new Set<string>()
595
596const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
597
598/** Analyses an option once; a failure is shown as such, never left loading. */
599async function imply($: EngineInterface, id: string, qi: number, oi: number) {
600 const slot = slotOf(id, qi, oi)
601 if (implying.has(slot)) return
602 implying.add(slot)
603 try {
604 await analyse($, id, qi, oi, slot)
605 } catch (error) {
606 const failed: DecisionImplication = { status: 'error', text: `No se pudo analizar (${reasonOf(error)}).` }
607 await update($, implications, all => ({ ...all, [slot]: failed })).catch(() => undefined)
608 } finally {
609 implying.delete(slot)
610 }
611}
612
613async function analyse($: EngineInterface, id: string, qi: number, oi: number, slot: string) {
614 const a = await current($, id)
615 const q = a?.questions[qi]
616 const option = q?.options[oi]
617 if (a === null || q === undefined || option === undefined) return
618 if ((await read($, implications))[slot] !== undefined) return
619
620 const loading: DecisionImplication = { status: 'loading', text: '' }
621 await update($, implications, all => ({ ...all, [slot]: loading }))
622 const r = await $.model.fork({ prompt: IMPLY_PROMPT(a, q, option.reply) })
623 if ((await current($, id)) === null) return
624
625 const result: DecisionImplication = r.isAnswered
626 ? { status: 'ready', text: r.text }
627 : { status: 'error', text: `No se pudo analizar (${r.reason}).` }
628 await update($, implications, all => ({ ...all, [slot]: result }))
629}
630
631async function rememberFork($: EngineInterface, agentId: string) {
632 knownForks.add(agentId)
633 await update($, forkIds, ids => (ids.includes(agentId) ? ids : [...ids, agentId]))
634}
635
636/** Whether the agent is a fork this plugin started, its id handed back yet or not. */
637async function isOwnFork($: EngineInterface, agentId: string): Promise<boolean> {
638 if (knownForks.has(agentId)) return true
639 if (foreignAgents.has(agentId)) return false
640 if ((await read($, forkIds)).includes(agentId)) {
641 knownForks.add(agentId)
642 return true
643 }
644 const listed = (await $.agent.list()).find(one => one.id === agentId)
645 if (listed?.spawnedBy === 'decision-tree') {
646 await rememberFork($, agentId)
647 return true
648 }
649 if (listed !== undefined) {
650 foreignAgents.add(agentId)
651 return false
652 }
653 // Listed by nobody yet: while a spawn of this plugin is under way, it may
654 // be that fork. Not remembered either way; the next call asks again.
655 return pendingSpawns > 0
656}
657
658/** Starts one fork for an option; a second press while it starts does nothing. */
659async function branch($: EngineInterface, id: string, qi: number, oi: number) {
660 const slot = slotOf(id, qi, oi)
661 if (branching.has(slot)) return
662 branching.add(slot)
663 try {
664 await startFork($, id, qi, oi, slot)
665 } finally {
666 branching.delete(slot)
667 }
668}
669
670async function startFork($: EngineInterface, id: string, qi: number, oi: number, slot: string) {
671 const a = await current($, id)
672 const q = a?.questions[qi]
673 const option = q?.options[oi]
674 if (a === null || q === undefined || option === undefined) return
675 const existing = (await read($, forks))[slot]
676 if (existing !== undefined && existing.status !== 'error') return
677
678 const about = { label: option.label, question: q.text }
679 const setFork = (fork: DecisionFork) => update($, forks, all => ({ ...all, [slot]: { ...about, ...fork } }))
680 await setFork({ status: 'starting', text: '' })
681 // Held until the id is remembered: the fork may call tools before then.
682 pendingSpawns += 1
683 try {
684 const spawned = await $.agent.spawn({
685 subagentType: 'fork',
686 description: `Rama: ${option.label}`.slice(0, 60),
687 prompt: FORK_PROMPT(a, q, option.reply),
688 })
689 if (spawned.deny !== undefined || spawned.agentId === undefined) {
690 await setFork({ status: 'error', text: `La rama no arrancó: ${spawned.deny ?? 'sin id de agente'}` })
691 return
692 }
693 const agentId = spawned.agentId
694 await rememberFork($, agentId)
695 // Its question was replaced while it started: nobody waits for it now.
696 if ((await current($, id)) === null) {
697 stopFork($, agentId)
698 await setFork({ status: 'error', agentId, text: STOPPED })
699 return
700 }
701 await setFork({ status: 'running', agentId, text: '' })
702 } catch (error) {
703 await setFork({ status: 'error', text: `La rama no arrancó: ${String(error)}` })
704 } finally {
705 pendingSpawns -= 1
706 }
707}
708
709async function confirm($: EngineInterface, id: string, mode: 'send' | 'edit') {
710 const a = await current($, id)
711 if (a === null) return
712 const text = composeAnswer(a, await read($, picks))
713 if (mode === 'edit') {
714 await $.prompt.fill({ text })
715 return
716 }
717 await settle($)
718 await $.prompt.submit({ text, asUser: true })
719}
720
721export const register: Register = on => {
722 on('session.start', async ($, e, next) => {
723 await $.command.register({
724 name: 'decision-tree',
725 description: 'Abre el panel de la última pregunta: opciones, lo que conllevan y ramas de prueba',
726 })
727 return next(e)
728 })
729
730 on('command.run', { command: 'decision-tree' }, async $ => {
731 await $.ui.open({ id: PANE, title: TITLE, focus: true })
732 return { text: 'Panel de decision-tree abierto.' }
733 })
734
735 // The model's own question dialog: where the panel can be drawn, the panel
736 // takes the questions and the answer comes back as the person's next message.
737 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
738 if (e.agentId !== undefined || e.tool !== 'AskUserQuestion') return next(e)
739 const questions = fromToolQuestions(e.questions)
740 if (questions === null) return next(e)
741
742 const context = questions
743 .map(q => `${q.text}\n${q.options.map(o => `- ${o.label}${o.detail ? `: ${o.detail}` : ''}`).join('\n')}`)
744 .join('\n\n')
745 const isPlaced = await present($, { id: e.tool_use_id, source: 'tool', context, questions }, true)
746 if (!isPlaced) {
747 await settle($)
748 return next(e)
749 }
750 return { deny: HANDOFF }
751 })
752
753 on('turn.complete', async ($, e, next) => {
754 if (e.agentId !== undefined) {
755 // A fork's answer is its report, kept with the fork even when its ask is gone.
756 const agentId = e.agentId
757 const all = await read($, forks)
758 const slot = Object.keys(all).find(key => all[key]?.agentId === agentId)
759 const was = slot === undefined ? undefined : all[slot]
760 if (slot !== undefined && was !== undefined) {
761 if (e.reason === 'answer') {
762 const done: DecisionFork = { ...was, status: 'done', agentId, text: e.answer }
763 await update($, forks, now => ({ ...now, [slot]: done }))
764 $.ui.toast('decision-tree: rama lista, mírala en el panel')
765 } else if (was.status !== 'error') {
766 // One already marked (stopped when its question changed) keeps its line.
767 const failed: DecisionFork = { ...was, status: 'error', agentId, text: `La rama terminó sin respuesta (${e.reason}).` }
768 await update($, forks, now => ({ ...now, [slot]: failed }))
769 }
770 }
771 return next(e)
772 }
773
774 // Any reading still under way is for an older turn now.
775 latestTurn = e.turnId
776
777 // An AskUserQuestion handed to the panel stays until it is answered.
778 const pending = await read($, ask)
779 if (pending?.source === 'tool') return next(e)
780
781 if (e.reason === 'answer' && endsWithQuestion(e.answer)) {
782 const turnId = e.turnId
783 void readQuestion($, e.answer, turnId).catch(() =>
784 latestTurn === turnId ? update($, phase, () => 'none') : undefined,
785 )
786 }
787 return next(e)
788 })
789
790 on('prompt.submit', async ($, e, next) => {
791 // A fork's completion would otherwise wake the main conversation with its
792 // report before the person chose anything; the panel shows it instead.
793 // Dropped only for a fork the panel holds: any other reaches the conversation.
794 if (e.origin?.kind === 'task-notification') {
795 const held = Object.values(await read($, forks)).some(
796 fork => fork.agentId !== undefined && e.text.includes(fork.agentId),
797 )
798 if (held) return { drop: 'decision-tree: la rama terminó; su informe está en el panel (/decision-tree).' }
799 return next(e)
800 }
801 // The person answered on their own (at the prompt, from the phone or as
802 // the SDK host): whatever question the panel holds is settled, and one
803 // still being read is dropped. A slash command is no answer: /decision-tree
804 // is how the person opens the question they are about to answer.
805 const kind = e.origin?.kind
806 const isPerson = kind === undefined || kind === 'composer' || kind === 'bridge' || kind === 'sdk'
807 if (isPerson && !e.text.trimStart().startsWith('/')) {
808 latestTurn = undefined
809 if ((await read($, ask)) !== null || (await read($, phase)) === 'reading') await settle($)
810 }
811 return next(e)
812 })
813
814 const forkDeny = (tool: string) => ({
815 deny: `decision-tree: esta es una rama de prueba; ${tool} no está permitido aquí. Describe el cambio en tu informe en lugar de aplicarlo.`,
816 })
817
818 on('tool.call', async ($, e, next) => {
819 if (e.agentId === undefined) return next(e)
820 if (!(await isOwnFork($, e.agentId))) return next(e)
821
822 const tool = String(e.tool)
823 if (forkMayCall(tool, { command: 'command' in e ? e.command : undefined })) return next(e)
824 return forkDeny(tool)
825 }).catch(($, e, next) => {
826 // Judged from what this module holds: $ may not answer here.
827 if (next.called || e.agentId === undefined || foreignAgents.has(e.agentId)) return next(e)
828 const tool = String(e.tool)
829 if (knownForks.has(e.agentId)) {
830 return forkMayCall(tool, { command: 'command' in e ? e.command : undefined }) ? next(e) : forkDeny(tool)
831 }
832 if (pendingSpawns > 0) {
833 return { deny: 'decision-tree: no se pudo comprobar si esta llamada viene de una rama de prueba.' }
834 }
835 return next(e)
836 })
837
838 // The handed-off dialog's transcript row: a card pointing to the panel in
839 // place of the refusal text the model reads.
840 const isHandoff = (output: unknown) => typeof output === 'string' && (output.includes(HANDOFF_MARK) || output.includes('panel lateral de decision-tree'))
841
842 on('ui.render', { component: 'ToolUse', props: { tool: 'AskUserQuestion' } }, async ($, e, next) => {
843 if (!e.props.isErrored || !isHandoff(e.props.output)) return next(e)
844 const { Box, Text } = $.ui.resolve(e)
845 const input = e.props.input as { questions?: readonly AskedQuestion[] } | undefined
846 const headers = (input?.questions ?? []).map((q, i) => q.header ?? `Pregunta ${i + 1}`)
847
848 return (
849 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
850 <Text>
851 <Text color="claude" bold>◆ decision-tree</Text>
852 <Text dimColor> {headers.length === 1 ? '1 pregunta' : `${headers.length} preguntas`} en el panel lateral</Text>
853 </Text>
854 <Text wrap="wrap">
855 {headers.map((header, i) => (
856 <Text key={`h-${i}`}>
857 <Text color="suggestion">{header}</Text>
858 {i < headers.length - 1 ? <Text dimColor> · </Text> : ''}
859 </Text>
860 ))}
861 </Text>
862 <Text dimColor>Explora, prueba en rama y confirma desde el panel →</Text>
863 </Box>
864 )
865 })
866
867 on('ui.render', { component: 'ToolResult', props: { tool: 'AskUserQuestion' } }, async ($, e, next) => {
868 if (!e.props.isErrored || !isHandoff(e.props.output)) return next(e)
869 const { Box } = $.ui.resolve(e)
870 return <Box display="none" />
871 })
872
873 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
874 isFullscreen = e.viewport?.isFullscreen ?? isFullscreen
875 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
876 const a = await read($, ask)
877 const state = await read($, phase)
878 const allForks = await read($, forks)
879 const width = e.props.bodyColumns
880
881 // The finished forks of earlier asks, newest first: their reports stay readable.
882 const earlier = Object.entries(allForks)
883 .filter(([slot, f]) => (a === null || !slot.startsWith(`${a.id}:`)) && isFinished(f))
884 .slice(-3)
885 .reverse()
886 const earlierBox =
887 earlier.length === 0 ? null : (
888 <Box flexDirection="column" borderStyle="round" borderColor="subtle" borderDimColor paddingX={1}>
889 <Text dimColor bold>⑂ Ramas anteriores</Text>
890 {earlier.map(([, f], i) => (
891 <Box key={`prev-${i}`} flexDirection="column" marginTop={1}>
892 <Text wrap="wrap">
893 <Text bold>{f.label ?? 'Rama'}</Text>
894 {f.question !== undefined ? <Text dimColor> · {f.question}</Text> : ''}
895 </Text>
896 <Markdown key={`prev-report-${i}`} text={f.text} dimColor={f.status === 'error'} />
897 </Box>
898 ))}
899 </Box>
900 )
901
902 const header = (
903 <Box flexDirection="row" justifyContent="space-between">
904 <Text>
905 <Text color="claude" bold>◆ decision-tree</Text>
906 </Text>
907 {a !== null && (
908 <Text dimColor>{a.source === 'tool' ? 'pregunta de Claude' : 'pregunta final'}</Text>
909 )}
910 </Box>
911 )
912
913 if (a === null) {
914 const message =
915 state === 'reading' ? '◐ Leyendo la pregunta…' : 'Sin pregunta pendiente. Cuando Claude te pregunte algo, aparecerá aquí.'
916 return (
917 <Box flexDirection="column" gap={1} width={width}>
918 {header}
919 <Box borderStyle="round" borderColor="subtle" paddingX={1}>
920 <Text dimColor wrap="wrap">{message}</Text>
921 </Box>
922 {earlierBox}
923 </Box>
924 )
925 }
926
927 const qi = Math.min(await read($, focus), a.questions.length - 1)
928 const q = a.questions[qi]
929 if (q === undefined) return header
930 const chosen = await read($, picks)
931 const oi = Math.min(chosen[qi] ?? 0, q.options.length - 1)
932 const option = q.options[oi]
933 const slot = slotOf(a.id, qi, oi)
934 const implication = (await read($, implications))[slot]
935 const fork = allForks[slot]
936
937 const showQuestion = (i: number) => {
938 void update($, focus, () => i)
939 void read($, picks).then(now => imply($, a.id, i, now[i] ?? 0))
940 }
941 const choose = (i: number) => {
942 void update($, picks, now => ({ ...now, [qi]: i }))
943 void imply($, a.id, qi, i)
944 }
945 const addCustom = (text: string) => {
946 const reply = text.trim()
947 if (reply.length === 0) return
948 const added = { label: reply.length > 40 ? `${reply.slice(0, 39)}…` : reply, reply }
949 void update($, ask, now => {
950 if (now === null || now.id !== a.id) return now
951 const questions = now.questions.map((one, i) => (i === qi ? { ...one, options: [...one.options, added] } : one))
952 return { ...now, questions }
953 }).then(() => choose(q.options.length))
954 }
955
956 const chip = (f: DecisionFork | undefined) => {
957 if (f === undefined) return null
958 if (f.status === 'done') return <Text color="success"> ✓ rama lista</Text>
959 if (f.status === 'error') return <Text color="error"> ✗ rama falló</Text>
960 return <Text color="warning"> ◐ rama en curso</Text>
961 }
962
963 // Mobile draws no Input: there the options offered are the choice.
964 let custom = null
965 if (e.surface !== 'mobile') {
966 const { Input } = $.ui.resolve(e)
967 custom = (
968 <Box borderStyle="round" borderColor="subtle" borderDimColor paddingX={1}>
969 <Input
970 key="custom"
971 label="✎ Otra respuesta"
972 placeholder="Escribe la tuya y pulsa Enter"
973 submitLabel="Añadir"
974 onSubmit={addCustom}
975 />
976 </Box>
977 )
978 }
979
980 return (
981 <Box flexDirection="column" gap={1} width={width}>
982 {header}
983
984 {a.questions.length > 1 && (
985 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
986 {a.questions.map((one, i) => {
987 const isDone = chosen[i] !== undefined
988 return (
989 <Button key={`q-${i}`} variant={i === qi ? 'primary' : 'secondary'} onPress={() => showQuestion(i)}>
990 {isDone ? '✓ ' : ''}
991 {one.header ?? `Pregunta ${i + 1}`}
992 </Button>
993 )
994 })}
995 </Box>
996 )}
997
998 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
999 <Text bold wrap="wrap">{q.text}</Text>
1000 <Box flexDirection="column" marginTop={1}>
1001 {q.options.map((o, i) => {
1002 const isPicked = i === oi
1003 return (
1004 <Box
1005 key={`row-${i}`}
1006 flexDirection="column"
1007 borderStyle="round"
1008 borderColor={isPicked ? 'suggestion' : 'subtle'}
1009 borderDimColor={!isPicked}
1010 paddingX={1}
1011 hover={{ borderColor: 'suggestion' }}
1012 >
1013 <Button key={`opt-${i}`} plain onPress={() => choose(i)}>
1014 <Text color={isPicked ? 'suggestion' : 'inactive'}>{isPicked ? '● ' : '○ '}</Text>
1015 <Text bold={isPicked}>{o.label}</Text>
1016 {chip(allForks[slotOf(a.id, qi, i)])}
1017 </Button>
1018 {o.detail !== undefined && o.detail.length > 0 && (
1019 <Text dimColor wrap="wrap"> {o.detail}</Text>
1020 )}
1021 </Box>
1022 )
1023 })}
1024 </Box>
1025 </Box>
1026
1027 {option !== undefined && (
1028 <Box flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1}>
1029 <Text color="permission" bold>⌥ Lo que conlleva · {option.label}</Text>
1030 {option.reply !== option.label && <Text dimColor wrap="wrap">Respuesta: «{option.reply}»</Text>}
1031 {implication === undefined || implication.status === 'loading' ? (
1032 <Text color="warning">◐ Analizando…</Text>
1033 ) : (
1034 <Markdown key="implication" text={implication.text} dimColor={implication.status === 'error'} />
1035 )}
1036 </Box>
1037 )}
1038
1039 {option !== undefined && (
1040 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1041 <Button key="try" onPress={() => void branch($, a.id, qi, oi)}>
1042 ⑂ Probar en rama
1043 </Button>
1044 <Button key="send" variant="primary" onPress={() => void confirm($, a.id, 'send')}>
1045 {a.questions.length > 1 ? '✓ Confirmar todas' : '✓ Confirmar'}
1046 </Button>
1047 <Button key="edit" onPress={() => void confirm($, a.id, 'edit')}>
1048 ✎ Editar
1049 </Button>
1050 </Box>
1051 )}
1052
1053 {fork !== undefined && (
1054 <Box
1055 flexDirection="column"
1056 borderStyle={fork.status === 'done' ? 'round' : 'dashed'}
1057 borderColor={fork.status === 'done' ? 'success' : fork.status === 'error' ? 'error' : 'warning'}
1058 paddingX={1}
1059 >
1060 <Text bold color={fork.status === 'done' ? 'success' : fork.status === 'error' ? 'error' : 'warning'}>
1061 ⑂ Rama de prueba · {option?.label}
1062 </Text>
1063 {fork.status === 'starting' && <Text dimColor>◐ Arrancando la rama…</Text>}
1064 {fork.status === 'running' && <Text dimColor>◐ Trabajando en sólo lectura. Te aviso al terminar.</Text>}
1065 {(fork.status === 'done' || fork.status === 'error') && (
1066 <Markdown key="fork" text={fork.text} dimColor={fork.status === 'error'} />
1067 )}
1068 </Box>
1069 )}
1070
1071 {custom}
1072
1073 {earlierBox}
1074 </Box>
1075 )
1076 })
1077}
1078types/index.d.ts 49 lines1export type DecisionOption = {
2 label: string
3 /** What is sent as the person's answer when this option is confirmed. */
4 reply: string
5 /** The asker's own line on the option, when it gave one. */
6 detail?: string
7}
8
9export type DecisionQuestion = { text: string; header?: string; options: DecisionOption[] }
10
11export type DecisionAsk = {
12 id: string
13 /** `text`: a question closing a reply; `tool`: an AskUserQuestion call. */
14 source: 'text' | 'tool'
15 /** The end of the reply (or the call's questions), for the prompts that reason about it. */
16 context: string
17 questions: DecisionQuestion[]
18}
19
20export type DecisionPhase = 'idle' | 'reading' | 'ready' | 'none'
21
22export type DecisionImplication = { status: 'loading' | 'ready' | 'error'; text: string }
23
24export type DecisionFork = {
25 status: 'starting' | 'running' | 'done' | 'error'
26 agentId?: string
27 text: string
28 /** The option it tries and the question it answers, to show it once its ask is gone. */
29 label?: string
30 question?: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'decision-tree': {
36 phase: DecisionPhase
37 ask: DecisionAsk | null
38 /** The question of the ask shown now. */
39 focus: number
40 /** The option picked per question, by the question's index. */
41 picks: Record<string, number>
42 implications: Record<string, DecisionImplication>
43 /** Every fork by slot (`<ask id>:<question>:<option>`), kept past its ask. */
44 forks: Record<string, DecisionFork>
45 forkIds: string[]
46 }
47 }
48}
49