Puente con Gentle Shell (Pi): consulta a otro orquestador como par, con su propia metodología, en los puntos de decisión.

Instala de una vez el stack de agentes que uso para trabajar con Orca: la política del stack, las skills propias, los wrappers de proveedores, Pi con Gentle Shell y los mods de Claude Code. Todo en un comando, con respaldo de lo que toca y con vuelta atrás.
¿Cómo se usa día a día? Leé el manual de uso: comandos, mods, marchas, cuándo conviene usarlo y cuándo no, casos borde y manejo. Marca lo que está solo en main y todavía no salió en un release.
npx github:Gagaismo/orca-stack#v0.12.0 global # instalación a nivel usuario
npx github:Gagaismo/orca-stack#v0.12.0 init # cablea el proyecto actual
npx github:Gagaismo/orca-stack#v0.12.0 doctor # verifica que todo funcione de verdad
npx github:Gagaismo/orca-stack#v0.12.0 remove # deshace lo que instaló global
npx github:Gagaismo/orca-stack#v0.12.0 sesiones # lista las sesiones recientes de Claude, Pi y agy (para `/retro`)
npx github:Gagaismo/orca-stack#v0.12.0 despachar 12 agy # desde el checkout principal: despacha el ticket #12 al agente
npx github:Gagaismo/orca-stack#v0.12.0 revisar 15 pi # despacha el review del PR #15
npx github:Gagaismo/orca-stack#v0.12.0 brief-revision 15 --modelo <modelo> # modo un-agente: worktree y brief de review para un subagente
npx github:Gagaismo/orca-stack#v0.12.0 consultas 42 57 # filtra y avisa consultas nuevas en issues y PRs en curso
npx github:Gagaismo/orca-stack#v0.12.0 avisar 42 <url> # Proactive: le toca el timbre al worker de #42 con la URL de la respuesta
npx github:Gagaismo/orca-stack#v0.12.0 vigilar 42 57 # el Monitor de la tanda: solo eventos de sus PRs, hasta tanda-cerrada
¿Primera vez, o un proyecto nuevo o ya empezado? Seguí la guía de setup: tiene un cuestionario que dice qué configurar en cada caso.
Cero dependencias, Node 20 o más nuevo. Funciona en Windows, macOS y Linux.
global (idempotente; todo archivo que modifica se respalda antes en ~/.orca-stack/backups/<fecha>/ (nunca al lado del archivo, para que ningún agente cargue el respaldo)):
| Qué | Dónde |
|---|---|
Política del stack: ORCA-STACK.md y ROLE-BRIEFS.md | ~/orca/stack/ |
Wrappers claude-orca-stack, codex-orca-stack, agy-orca-stack (.cmd en Windows, .sh en el resto), cada uno con --orca-stack-self-test | ~/orca/stack/scripts/ |
Skills propias: orca-stack-policy, orca-setup, ponytail, ponytail-review, ponytail-audit, ponytail-debt, ponytail-help | ~/.claude/skills/ y ~/.agents/skills/ |
Ocho mods de Claude Code (ver abajo) y sus rutas en env.CLAUDE_CODE_PLUGIN_DIRS | ~/.claude/mods/ y ~/.claude/settings.json |
Nada de metodología en ~/.claude/CLAUDE.md: si un global anterior dejó ahí el bloque <!-- orca-stack:begin --> … <!-- orca-stack:end -->, lo saca (con respaldo). También achica el bloque que gentle-ai escribe ahí (ver abajo) | ~/.claude/CLAUDE.md |
Pi: gentle-pi@4.2.0 y pi-claude-bridge@0.9.2 con pi install (pi install npm:gentle-pi@4.2.0), más npm i -g gentle-pi@4.2.0 (comando gentle-shell). Necesita Pi 1.2.0 o más nuevo: con un Pi anterior no instala gentle-pi y falla | ~/.pi/agent/ y npm global |
Comando orca-stack, para usar desde cualquier repo sin npx ni red | Windows: prefijo global de npm si esta en PATH, con shims .cmd y sin extension para PowerShell y Git Bash. Otros sistemas: primer directorio escribible del PATH. La copia queda en ~/.orca-stack/cli/; si falta el destino compartido, se usa ~/.orca-stack/bin/ y global imprime como agregarlo al PATH |
Registro de lo instalado, para remove | ~/.orca-stack/state.json |
| Capa personal de configuración | ~/.orca-stack/config.json |
Gentle Shell se actualiza volviendo a correr global con la versión nueva de orca-stack. Desde gentle-pi 4.2.0, gentle-shell update reinstala solo el paquete global dueño del Pi que corre, y si no puede probar qué paquete global es dueño de Pi, no toca nada.
Los wrappers se llaman distinto del binario real a propósito: así nunca se llaman a sí mismos. Cada uno fija los marcadores ORCA_STACK_*, busca el binario real en el PATH, pasa los argumentos tal cual y devuelve su código de salida.
global instala herramientas, skills, mods y wrappers, pero ningún texto de metodología: una sesión de Claude en un proyecto sin .orca-stack.json no recibe nada del stack por esta vía. La metodología llega por el proyecto, con init (el contrato y las líneas de la política en AGENTS.md, y @AGENTS.md en CLAUDE.md). Del CLAUDE.md global global solo saca el bloque entre orca-stack:begin y orca-stack:end (o orca-stack:auto, el viejo) que dejó una versión anterior, y respeta todo lo demás. Con settings.json hace algo parecido: solo suma sus carpetas a CLAUDE_CODE_PLUGIN_DIRS (con el separador de tu sistema), conserva las que ya había y no toca ninguna otra clave.
El bloque gentle-ai:sdd-orchestrator (v2/v3) o gentle-ai:orchestrator (v4) de ~/.claude/CLAUDE.md (unos 16 KB: coordinación, delegación, tabla de modelos y el flujo de cada versión) se carga en cada sesión. Por eso global lo achica según gentle.orchestrator en ~/.orca-stack/config.json:
"lazy" (por defecto): guarda el texto completo, tal cual lo escribió gentle-ai, en ~/.orca-stack/gentle/orchestrator.md y deja entre los marcadores un puntero corto (empieza con <!-- orca-stack:gentle-lazy v1 -->). El puntero dice que ODD es el flujo predeterminado y que la sesión que orquesta (main) debe leer la copia completa; también exige presentar completos los menús y pedidos de consentimiento, sin responder por el humano. Hace respaldo, es idempotente, respeta --dry-run e imprime cuántos KB ahorró. Con marcadores incompletos, repetidos o desordenados no escribe nada y dice por qué. Se omite mientras el RDD de gentle-ai esté prendido (o no se pueda leer): el bloque trae su contrato de review y el puntero lo apagaría en silencio; si ya estaba en lazy, global lo restaura desde la copia."full": deja el bloque como lo escribe gentle-ai (y, si estaba en lazy, lo restaura desde la copia, con respaldo; si la copia falta, avisa que corras gentle-ai sync). Con el RDD de gentle-ai apagado (gentle-ai review mode status dice off), además recorta las secciones de review, que no hacen nada y se cargan igual (unos 26 KB); si RDD está prendido o gentle-ai no está instalado, ese recorte se omite y dice por qué.gentle-ai sync reescribe el bloque entre los marcadores (reemplaza el contenido, no duplica ni valida un hash), así que después de cada sync hay que correr orca-stack global de nuevo, que además refresca la copia y el puntero. doctor avisa si la sync lo reescribió, si falta la copia o si todavía está en la ruta antigua. Para volver al bloque completo: poné "gentle": { "orchestrator": "full" } y corré orca-stack global, o corré gentle-ai sync. orca-stack remove también lo restaura desde la copia. La clave antigua gentle.sddOrchestrator sigue funcionando durante la migración; doctor sugiere renombrarla y, si están las dos, gentle.orchestrator tiene prioridad. global mueve la copia antigua sdd-orchestrator.md a orchestrator.md; si existen ambas, conserva la más nueva por fecha de modificación y respalda la otra.
gentle-ai v4. v4 retiró SDD y OpenSpec: ODD es su único flujo y gentle-ai sync renombra el bloque en el lugar a gentle-ai:orchestrator, con el orquestador ODD adentro y las mismas secciones de review. global, doctor y remove reconocen los dos nombres: el modo lazy, el recorte y la restauración funcionan igual; el puntero indica que ODD es el flujo predeterminado y que la sesión main lee la copia completa. La copia queda en ~/.orca-stack/gentle/orchestrator.md. Para pasar a v4 en Windows (necesita Go 1.25.10 o más nuevo en el PATH):
go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@v4.1.1
gentle-ai sync
npx github:Gagaismo/orca-stack global
La sync deja el bloque completo y la copia vieja (de v2) queda desactualizada: doctor avisa que corras orca-stack global, que guarda el texto de v4 en la copia y vuelve a dejar el puntero.
Son plugins de Claude Code que se recargan en caliente (.claude-plugin/plugin.json + hooks/). Se activan con la variable CLAUDE_CODE_PLUGIN_DIRS, que global completa por vos; se cargan en las sesiones que abras después.
| Mod | Qué hace | Config |
|---|---|---|
tablero-despacho | Tablero de PRs abiertos con checks, review y worktrees (/tablero). Usa gh. | Los atajos de despacho aparecen solo si hay despacho |
guardia-despacho | Bloquea o corrige las llamadas a tus scripts de despacho y revisión según reglas del proyecto. Siempre activo, con o sin despacho: pregunta qué perfil de gentle-pi usa un despacho a pi (--perfil-pi); rechaza orca worktree rm y git worktree remove si la rama de ese worktree tiene commits que no están en ningún remoto, porque orca worktree rm borra también la rama local. Si no puede resolver la rama, rechaza | Las reglas de despacho están apagadas salvo que haya despacho |
puente-gentle | Consulta a Gentle Shell (Pi) como otro orquestador par (/pi, /gentle). La instrucción de consultar y la herramienta consultar_gentle_shell aparecen solo en un repo con .orca-stack.json. Frena el merge de un PR grande sin consulta y el de uno que toca riesgo sin sus reviews limpios (de pi, o de dos familias de modelo que no sean la de pi si el PR lo escribió pi) | humano, riesgo, agentes.pi.claudeConfigDir |
bandeja-mensajes | Captura los bloques `mensaje <destinatario> que redacta Claude y deja copiarlos o guardarlos | mensajes.destinatarios |
grilling-ok | Responde con un clic (o con un ok) a las preguntas Q1…Qn de una sesión de grilling | — |
modo-autonomo | Instrucción permanente de trabajo autónomo hasta la meta, con recordatorio anti-parada acotado | — |
marchas | /marcha muestra o cambia la marcha de main: Lazy, Medium o Proactive; cada sesión nueva empieza en Medium; avisa en una línea cuando el modo autónomo coincide con Lazy, Proactive o ultracode | — |
reloj-cache | Muestra cuánto falta para que venza el caché del prompt y ofrece /compactar-ya | — |
El mod marchas actúa solo en el checkout principal de un repo con .orca-stack.json o .orca-stack.local.json cuyo contrato incluye la sección Marchas. Para mostrar la marcha actual, usá /marcha; para cambiarla, /marcha lazy, /marcha medium o /marcha proactive. Para apagarlo, corré claude plugin disable marchas@inline --scope user; usá --scope local para este repo en esta máquina o --scope project para el equipo.
gh), para el tablero y para el puente.postinstall de gentle-pi compila el binario de gentle-ai con Go (1.25.10 o más nuevo): si pi install de gentle-pi falla, revisá que go esté en el PATH.global detecta estas herramientas, no las instala; si faltan, imprime el comando oficial de cada una: Orca, engram, gentle-ai y Pi.
orca-stack global [--dry-run] [--force] [--skip-pi] [--limpiar-sdd] [--humano <nombre>]
orca-stack init [--dry-run]
orca-stack doctor [--offline] [--skip-pi]
orca-stack remove [--dry-run] [--with-pi]
orca-stack sesiones [--desde <AAAA-MM-DD>] [--limite <n>]
orca-stack despachar <issue> <agente> [--nota "<texto>"] [--confirmado] [--perfil-pi <nombre>] [--dry-run]
orca-stack revisar <pr> <agente> [--nota "<texto>"] [--confirmado] [--perfil-pi <nombre>] [--dry-run]
orca-stack consultas <n...>
orca-stack avisar <issue|pr> <url-del-comentario> [--dry-run]
orca-stack reanudar <issue> <url-del-comentario> [--confirmado] [--dry-run]
orca-stack vigilar <n...> [--cada <segundos>] [--una-vez] [--dry-run] [--consultas]
orca-stack vigilar --merge-de-main <pr>
--dry-run cuenta cada acción sin escribir nada.reanudar retoma una TUI detenida o relanza al worker en el mismo worktree con su brief original y la URL de respuesta.--force reemplaza archivos que ya existían y no son de orca-stack (por ejemplo, una skill tuya llamada igual). Sin --force se saltean con un aviso ⚠ y quedan como estaban; con --force se respaldan antes.global --limpiar-sdd respalda en ~/.orca-stack/backups/sdd-<fecha>/ y mueve solo restos conocidos; doctor lista los que encuentra y muestra el comando exacto. Los nombres desconocidos nunca se mueven y global sin esta opción no limpia nada. Si algún movimiento falla, informa qué quedó pendiente; podés volver a ejecutar el mismo comando.init escribe en el AGENTS.md del proyecto el bloque <!-- orca-stack:begin --> … <!-- orca-stack:end --> con el contrato de trabajo completo (assets/stack/CONTRATO.md, igual en todos los proyectos) y las líneas de la política; a CLAUDE.md le pone un bloque corto con @AGENTS.md. Además crea .orca-stack.json y .orca-stack.local.json si faltan y agrega este último a .gitignore. Es idempotente y respalda lo que modifica. Los valores propios de cada proyecto no van en el bloque: viven en .orca-stack.json.orca-setup es la skill con la que la sesión de main entrevista al humano, en una sola ronda, para completar .orca-stack.json ("setup": "completo"). La ofrece sola cuando falta y doctor avisa si quedó pendiente.sesiones es de solo lectura: lista las sesiones recientes de Claude Code (~/.claude/projects y, si está, la carpeta de agentes.pi.claudeConfigDir), de Pi (~/.pi/agent/sessions) y de agy (~/.gemini/antigravity-cli, solo con Node 22.13 o más nuevo). Por sesión muestra la herramienta, la carpeta, inicio y fin, las llamadas, tus mensajes, las llamadas con error y los resultados de más de 15k caracteres. Es la entrada para quien corre /retro de Matt: --desde fija el día de inicio (por defecto, los últimos 2 días) y --limite cuántas sesiones (por defecto, 10).consultas <n...> filtra y avisa consultas nuevas en issues o PRs de una tanda. Consulta gh api user en ejecución, busca comentarios de la cuenta del stack que empiecen con Consulta: e imprime consulta nueva en #<n> una sola vez por comentario, guardando los IDs en .git/orca-stack/consultas-reportadas.json.vigilar <n...> es el Monitor de una tanda (<n...>, los issues de la tanda): stdout lleva solo eventos, uno por línea, y todo diagnóstico va a stderr. Cada --cada segundos (por defecto 60) lee gh pr list --state all (sin --state no aparecen los PRs mergeados ni cerrados) y emite pr-abierto, ci-verde, ci-roja (con los checks que fallaron), pr-mergeado, pr-cerrado, vigilar-ciego (tres vueltas seguidas sin poder leer GitHub, una vez por corte; nunca dice que no hay PRs) y, cuando terminaron todos los items, tanda-cerrada, y sale con 0. Un PR es del issue por su referencia de cierre, por la rama del worktree de este repo enlazado al issue (buscado como en avisar) o por una línea Closes #<n> en su cuerpo, nunca por el nombre de la rama; un issue cerrado sin PR cuenta como terminado. De dos corridas de un mismo check en un commit cuenta la última, y cada corrida que termina en rojo es un ci-roja, también un gh run rerun --failed que vuelve a fallar en el mismo commit; la misma corrida leída de nuevo no se repite. Lo emitido queda en .git/orca-stack/vigilar.json: rearmado con los mismos argumentos, retoma sin repetir nada. --una-vez consulta una vez y sale, --dry-run además no escribe nada, y --consultas corre en cada vuelta el chequeo de consultas para los mismos items, así Proactive sigue con un solo Monitor. Como todas las sesiones comparten la cuenta de gh, un merge de main se ve igual que uno del humano: main corre vigilar --merge-de-main <pr> justo antes de cada intento de gh pr merge <pr> (y de nuevo después de gh pr update-branch), que registra el head del PR en .git/orca-stack/vigilar-merges.json; un merge de ese head hecho dentro de los 30 minutos siguientes al registro (según el mergedAt del PR) no emite pr-mergeado, aunque el watcher lo vea más tarde. Con un PR ya mergeado o cerrado, --merge-de-main sale con 1 y no registra nada. El watcher solo lee ese archivo.avisar <issue|pr> <url-del-comentario> es el timbre de Proactive: cuando main responde la pregunta de un worker, le escribe en su terminal una sola línea fija, Message from main: <url>, con la URL del comentario y nada de su texto. Encuentra la terminal por el worktree de este repo enlazado a <n> en orca worktree ps (con un PR, el de la rama del PR), buscado solo entre los de git worktree list, porque los selectores issue:<n> y branch: de Orca abarcan todos los repos; lista sus terminales con orca terminal list --worktree path:<ruta> y se niega si alguna no trae ese worktreePath. Si un rebote dejó dos, elige la del agente vivo según orca worktree ps. Manda solo mientras el turno del worker está en curso (estado working en Orca y el indicador de turno de Claude en pantalla): se niega si el turno ya terminó, si la terminal muestra un shell o un diálogo, o si hay más de un agente vivo. Por ahora solo avisa a claude: se niega con pi, que corre sin TUI y relee los comentarios antes de su PR, y con codex y agy hasta que pasen su prueba de Enter a mitad de turno. Se niega si el comentario no es de la cuenta del stack (gh api user), si no está en <issue|pr> o si no empieza con Respuesta de main: o Respuesta del humano: (la misma cuenta publica las Consulta: de los workers). Envía una sola vez con --wait-submit: sin recibo de entrega informa la falla y no reenvía. --dry-run muestra la línea y la terminal elegida sin enviar nada. main nunca arma el timbre con llamadas sueltas a orca.despachar y revisar (agentes: claude, agy, codex, codex-cc, pi) corren desde el checkout principal de un repo con el setup completo. Se niegan si el agente no está en agentes.usa, si está en agentes.preguntarAntes (repetí con --confirmado cuando el humano dijo que sí), si el issue está cerrado o asignado, si el revisor es el implementador del PR (línea Implementador:) o corre un modelo de su misma familia (sale de agentes.modelos; pi y claude sí se revisan entre sí), si el agente tiene una regla guardia.bloquear vigente, o si ya hay un worktree o un agente vivo para ese ticket. Antes de tocar nada hacen git fetch origin y comprueban que cada ruta que cita el brief (armado desde assets/stack/brief-ticket.md o brief-revision.md) exista en origin/<ramaBase> (en revisar, en la rama del PR): una ruta que falta o que git ignora aborta sin asignar ni crear nada. Si pasa todo, despachar asigna el issue (el assignee es el cerrojo y lo maneja el comando: no se asigna antes), y ambos crean el worktree con orca worktree create (así lo ve Orca), escriben el brief en ~/orca/briefs/, lanzan el agente en una terminal de Orca y le mandan el brief. despachar imprime Rama: <rama> con el nombre devuelto por Orca; en --dry-run aclara que Orca define la rama al crear el worktree. Después de confirmar el inicio, despachar, revisar y rebote leen tres veces la pantalla de cada terminal salvo pi, con pausas de 5 segundos: fallan si ven un mensaje de cuota o si el último renglón no vacío volvió al prompt de PowerShell (PS <unidad>:\...>). No detectan prompts de bash ni cmd.exe. Si algo falla después de crear el worktree, el mensaje lista lo que quedó (assignee, worktree, brief) y cómo recuperarlo, porque el cerrojo rechaza un reintento. Pi corre sin TUI (pi -p), así que sí puede ejecutar git, gh y la suite. --nota agrega texto al brief y --dry-run muestra el plan y el brief sin escribir nada. Los modelos salen de agentes.modelos de tu config personal: un texto, { "modelo", "esfuerzo" } o { "implementar": {…}, "revisar": {…} } por agente.--perfil-pi <nombre> (solo con pi) fija qué perfil de modelos de gentle-pi usa ese despacho, sin tocar el perfil global activo. Los perfiles viven en ~/.pi/gentle-ai/profiles.json; el comando se niega si el nombre no existe o si el clon ya fija otro perfil (<git-common-dir>/gentle-ai/profile-pin.json, que gana en silencio sobre el del worktree). Después de crear el worktree escribe el pin .pi/gentle-ai/profile.json ahí (gentle-pi lo lee en cada lanzamiento de subagente) y, si git no lo ignora, agrega /.pi/gentle-ai/profile.json a <git-common-dir>/info/exclude, así que nunca se commitea. Si el pin ya está versionado en el repo, lo deja como está cuando declara el mismo perfil y se niega (sin escribir) si declara otro; si git sigue sin ignorarlo después de la entrada (por ejemplo un .gitignore con !.pi/gentle-ai/profile.json), también se niega. Un thinking inválido en el perfil se avisa y se ignora. Como un pin no mueve al orquestador, lanza pi con --model <orchestrator.model del perfil> --thinking <orchestrator.thinking>. Con --dry-run cuenta todo esto sin escribir. Sin el flag y sin agentes.pi.perfil (o con "preguntar") rige el perfil activo global, como siempre. agentes.pi.perfil: "<nombre>" lo fija para todos los despachos a pi y el flag gana.guardia-despacho pregunta ese perfil por vos: cuando Claude corre orca-stack despachar|revisar <n> pi sin --perfil-pi y agentes.pi.perfil no es un nombre, abre el diálogo "Perfil Pi" con los perfiles de profiles.json (opus-haiku y samu-balanceado primero; máximo cuatro opciones) y agrega --perfil-pi <elegido> justo detrás del agente. Si cierran el diálogo, o no hay nadie que conteste (-p), el comando corre sin cambios. Las consultas (/pi, consultar_gentle_shell) no preguntan, y un --dry-run tampoco.opus-haiku: el orquestador es Opus 5.5 (claude-bridge/claude-opus-5-5, thinking high) y los diez subagentes de gentle-pi (gentle-ai-explore, gentle-ai-verify, gentle-ai-worker, jd-fix-agent, jd-judge-a, jd-judge-b, review-readability, review-reliability, review-resilience, review-risk) corren en Haiku 5.5 (claude-bridge/claude-haiku-5-5). Un perfil tiene que nombrar a todos los agentes: el que omite uno lo deja con el modelo de su frontmatter. Para cambiarlo después: /gentle:profiles en Pi, o editar ~/.pi/gentle-ai/profiles.json (hacé una copia antes).--skip-pi no toca Pi (útil si todavía no lo instalaste).--humano fija cómo te nombran los mods; si no lo pasás, sale de git config user.name.doctor hace sondeos reales y sale con código 1 si hay una falla (✖). Los avisos (⚠) no cambian el código.remove borra lo que global creó, restaura lo que había reemplazado, saca sus entradas de CLAUDE_CODE_PLUGIN_DIRS y, si quedó, el bloque de CLAUDE.md de una instalación vieja. Los respaldos (~/.orca-stack/backups/<fecha>/) se quedan. Los paquetes de Pi solo se sacan con --with-pi, y solo los que no tenías antes de instalar. Si editaste ~/.orca-stack/config.json, se conserva.ORCA_STACK_HOME=/ruta/temporal orca-stack global.doctorNo se queda en "el archivo existe":
--orca-stack-self-test de cada wrapper y comprueba que la ruta de la política exista;1.2.0, gentle-pi 4.2.0, pi-claude-bridge 0.9.2) y avisa si no coinciden; un Pi anterior a 1.2.0 es una falla, porque gentle-pi 4.2.0 no arranca con él;pi -p corto (con tiempo máximo) si falta el /login (se omite conhooks/register.tsx 342 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Intercambio } from '../types'
4import {
5 BIG_PR_LINES,
6 PLANTEO_STORED,
7 TIMEOUT_MS,
8 bigPrReason,
9 buildArgs,
10 buildMessage,
11 clock,
12 consultEnv,
13 engramDir,
14 interpret,
15 joinPath,
16 launcherPath,
17 loadMemoria,
18 merges,
19 parsePiArgs,
20 piClaudeConfigDir,
21 piPrompt,
22 prFacts,
23 prSize,
24 remember,
25 riskDeny,
26 riskyFile,
27 section,
28 skillsInGlobal,
29 skillsInPi,
30 slug,
31 truncate,
32 vista,
33} from './puente'
34import type { Memoria, Outcome } from './puente'
35import { hasProjectConfig, loadConfig } from './orca-config'
36import type { Host } from './orca-config'
37
38const PANE = 'gentle'
39const TOOL = 'mcp__puente-gentle__consultar_gentle_shell'
40const temas = { plugin: 'puente-gentle', key: 'temas' } as const
41const sel = { plugin: 'puente-gentle', key: 'sel' } as const
42
43const load = async ($: EngineInterface): Promise<Memoria> => loadMemoria(await $.store.get('temas'))
44
45const publish = async ($: EngineInterface, memoria: Memoria): Promise<void> => {
46 const list = vista(memoria)
47 await $.state.set(temas, list)
48 const current = (await $.state.get(sel)).value
49 if (current === undefined || !list.some(one => one.tema === current)) {
50 await $.state.set(sel, list[0]?.tema ?? '')
51 }
52}
53
54// The engine's `$` cannot cross an import: the config module gets these closures instead.
55const hostOf = ($: EngineInterface): Host => ({
56 home: async () => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')),
57 exists: path => $.fs.exists(path),
58 read: path => $.fs.read(path),
59 git: argv => $.process.run(argv, { timeoutMs: 10_000 }),
60 now: () => $.clock.now(),
61})
62
63const homeDir = async ($: EngineInterface): Promise<string> =>
64 (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
65
66const isWindows = async ($: EngineInterface): Promise<boolean> => (await $.env.get('OS')) === 'Windows_NT'
67
68// The launcher: node on the user's own Pi (~/.pi/agent, where Gentle Shell is installed and configured),
69// so multi-line arguments survive (a .cmd shim would cut them). Otherwise `pi` from the PATH.
70const launcher = async ($: EngineInterface): Promise<string[]> => {
71 const home = await homeDir($)
72 if (home !== '') {
73 const entry = launcherPath(home)
74 if (await $.fs.exists(entry)) {
75 return ['node', entry]
76 }
77 }
78
79 return [(await isWindows($)) ? 'pi.cmd' : 'pi']
80}
81
82// Gentle Shell's own skills dir, resolved from Pi's install first and the npm-global one second.
83// undefined when neither exists: the consult then loads no skills at all.
84const gentleSkills = async ($: EngineInterface): Promise<string | undefined> => {
85 const home = await homeDir($)
86 if (home !== '') {
87 const inPi = skillsInPi(home)
88 if (await $.fs.exists(inPi)) {
89 return inPi
90 }
91 }
92 try {
93 const npm = await $.process.run([(await isWindows($)) ? 'npm.cmd' : 'npm', 'root', '-g'], { timeoutMs: 15_000 })
94 if (npm.exitCode === 0 && npm.stdout.trim() !== '') {
95 const inGlobal = skillsInGlobal(npm.stdout.trim().split('\n')[0] ?? '')
96 if (await $.fs.exists(inGlobal)) {
97 return inGlobal
98 }
99 }
100 } catch {
101 // npm missing: no skills
102 }
103
104 return undefined
105}
106
107// Personal config only: where Pi's Claude provider gets its credentials (a separate account from this session's).
108const claudeConfigDir = async ($: EngineInterface): Promise<string | undefined> => {
109 const home = await homeDir($)
110 if (home === '') {
111 return undefined
112 }
113 const file = joinPath(home, '.orca-stack', 'config.json')
114 try {
115 return piClaudeConfigDir((await $.fs.exists(file)) ? await $.fs.read(file) : undefined, home)
116 } catch {
117 return undefined
118 }
119}
120
121const consult = async (
122 $: EngineInterface,
123 modelo: string,
124 tema: string,
125 planteo: string,
126 pregunta: string,
127): Promise<Outcome> => {
128 $.ui.toast(`Consultando a Gentle Shell (${tema})…`)
129 let outcome: Outcome
130 try {
131 const prefix = await launcher($)
132 const skills = await gentleSkills($)
133 const run = await $.process.run([...prefix, ...buildArgs(tema, modelo, buildMessage(planteo, pregunta), skills)], {
134 env: consultEnv(engramDir(await homeDir($)), await claudeConfigDir($)),
135 // Pi in print mode waits for stdin to close: an open stdin hangs it forever.
136 stdin: '',
137 timeoutMs: TIMEOUT_MS,
138 })
139 outcome = interpret(run)
140 } catch (error) {
141 outcome = {
142 ok: false,
143 error: `No pude lanzar Gentle Shell: ${error instanceof Error ? error.message : String(error)}`,
144 }
145 }
146 if (outcome.ok) {
147 const item: Intercambio = {
148 at: await $.clock.now(),
149 pregunta,
150 planteo: truncate(planteo, PLANTEO_STORED),
151 respuesta: outcome.respuesta,
152 }
153 const memoria = remember(await load($), tema, item)
154 await $.store.set('temas', memoria)
155 await publish($, memoria)
156 $.ui.toast(`Gentle Shell respondió (${tema})`)
157 } else {
158 $.ui.toast(outcome.error)
159 }
160
161 return outcome
162}
163
164// A plugin tool's result is a string, a content array, or nothing: a plain string is the simplest valid shape.
165const textResult = (text: string) => ({ result: text })
166
167const str = (value: unknown): string => (typeof value === 'string' ? value : '')
168
169// Big PRs need a consult before they merge, only where the consult tool exists (a project with orca-stack config);
170// PRs touching a `riesgo` path need clean reviews of their head commit (riskDeny).
171const PR_FIELDS = 'additions,deletions,files,headRefOid,body,comments,reviews'
172
173const gate = async ($: EngineInterface, command: string) => {
174 for (const merge of merges(command)) {
175 let stdout: string | undefined
176 try {
177 const argv = ['gh', 'pr', 'view', merge.number, '--json', PR_FIELDS]
178 if (merge.repo !== undefined) {
179 argv.push('-R', merge.repo)
180 }
181 const run = await $.process.run(argv, { timeoutMs: 30000 })
182 stdout = run.exitCode === 0 ? run.stdout : undefined
183 } catch {
184 stdout = undefined
185 }
186 if (stdout === undefined) {
187 continue
188 }
189 const total = prSize(stdout)
190 if (
191 total !== undefined &&
192 total > BIG_PR_LINES &&
193 !(await load($))[`pr-${merge.number}`]?.length &&
194 (await hasProjectConfig(hostOf($)))
195 ) {
196 return { deny: bigPrReason(merge.number, total) }
197 }
198 const config = await loadConfig(hostOf($))
199 const globs = config.riesgo
200 const facts = Array.isArray(globs) && globs.length > 0 ? prFacts(stdout) : undefined
201 const risky = facts === undefined ? undefined : riskyFile(facts.files, globs ?? [])
202 const denied = facts === undefined || risky === undefined ? undefined : riskDeny(merge.number, risky, facts, config, await $.clock.now())
203 if (denied !== undefined) {
204 return { deny: denied }
205 }
206 }
207
208 return undefined
209}
210
211export const register: Register = (on, options) => {
212 const modelo = str(options['modelo']).trim()
213
214 on('session.start', async ($, e, next) => {
215 await $.command.register({
216 name: 'pi',
217 description: 'Consulta a Gentle Shell: /pi <tema>: <pregunta>',
218 })
219 await $.command.register({
220 name: 'gentle',
221 description: 'Abre el panel con las consultas a Gentle Shell',
222 })
223 // The tool (and the criterio section below) only exist in a repo with orca-stack config: `global` carries no methodology.
224 if (await hasProjectConfig(hostOf($))) {
225 await $.tool.register({
226 name: 'consultar_gentle_shell',
227 description:
228 'Consulta a Gentle Shell (Pi), otro orquestador par con otra metodología, en modo solo lectura. ' +
229 'Usalo en puntos de decisión. El planteo va neutral: problema, restricciones, nuestra propuesta y archivos relevantes.',
230 inputSchema: {
231 type: 'object',
232 properties: {
233 tema: { type: 'string', description: 'Slug corto del tema, por ejemplo pr-612 o auditoria. Misma conversaci\u00f3n por tema.' },
234 planteo: { type: 'string', description: 'Contexto neutral: el problema, las restricciones, nuestra propuesta actual y las rutas relevantes.' },
235 pregunta: { type: 'string', description: 'Lo que querés que opine.' },
236 },
237 required: ['tema', 'planteo', 'pregunta'],
238 },
239 })
240 }
241 await publish($, await load($))
242
243 return next(e)
244 })
245
246 // The tool the model calls.
247 on('tool.call', { tool: TOOL }, async ($, e) => {
248 const tema = slug(str(e['tema']))
249 const planteo = str(e['planteo'])
250 const pregunta = str(e['pregunta'])
251 if (tema === '' || planteo.trim() === '' || pregunta.trim() === '') {
252 return textResult(JSON.stringify({ error: 'Faltan tema, planteo o pregunta.' }))
253 }
254 const outcome = await consult($, modelo, tema, planteo, pregunta)
255
256 return textResult(
257 JSON.stringify(outcome.ok ? { tema, respuesta: outcome.respuesta } : { tema, error: outcome.error }),
258 )
259 })
260
261 on('command.run', { command: 'pi' }, async ($, e) => {
262 const parsed = parsePiArgs(e.args)
263 if (parsed === undefined) {
264 return { text: 'Uso: /pi <tema>: <pregunta> (por ejemplo: /pi pr-612: ¿ves riesgo en este merge?)' }
265 }
266 // Submitted from a timer so the command returns before the turn starts.
267 $.clock.after(0, () => {
268 void $.prompt.submit({ text: piPrompt(parsed.tema, parsed.pregunta), asUser: true })
269 })
270
271 return { text: `Consultando a Gentle Shell (tema ${parsed.tema})…` }
272 })
273
274 on('command.run', { command: 'gentle' }, async $ => {
275 await publish($, await load($))
276 await $.ui.open({ id: PANE, title: 'Gentle Shell' })
277
278 return { text: 'Panel de Gentle Shell abierto.' }
279 })
280
281 on('tool.call', { tool: 'Bash' }, async ($, e, next) => (await gate($, e.command)) ?? next(e))
282 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => (await gate($, e.command)) ?? next(e))
283
284 on('prompt.compose', async ($, e, next) => {
285 const composed = await next(e)
286 if (!(await hasProjectConfig(hostOf($)))) {
287 return composed
288 }
289 const humano = (await loadConfig(hostOf($))).humano
290
291 return {
292 sections: [
293 ...composed.sections,
294 { id: 'puente-gentle:criterio', text: section(typeof humano === 'string' ? humano : ''), scope: 'session' as const },
295 ],
296 }
297 })
298
299 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300 const { Box, Button, Text } = $.ui.resolve(e)
301 const { value: list = [] } = await $.state.get(temas)
302 const { value: chosen = '' } = await $.state.get(sel)
303 if (list.length === 0) {
304 return <Text dimColor>Todavía no hay consultas. Usá /pi o dejá que Claude consulte a Gentle Shell.</Text>
305 }
306
307 return (
308 <Box flexDirection="column" gap={1}>
309 {list.map(entry => (
310 <Box key={`row:${entry.tema}`} flexDirection="column">
311 <Button
312 key={`tema:${entry.tema}`}
313 label={`${entry.tema === chosen ? '▾' : '▸'} ${entry.tema} (${entry.items.length})`}
314 onPress={async () => {
315 await $.state.set(sel, entry.tema)
316 }}
317 />
318 {entry.tema === chosen
319 ? entry.items.slice(0, 10).map((item, i) => (
320 <Box key={`item:${entry.tema}:${i}`} flexDirection="column" marginLeft={2}>
321 <Text bold>
322 {clock(item.at)} · {item.pregunta}
323 </Text>
324 <Text>{item.respuesta}</Text>
325 <Button
326 key={`copy:${entry.tema}:${i}`}
327 label="Copiar respuesta"
328 onPress={async press => {
329 const copied = await $.ui.copy({ text: item.respuesta, surface: press.surface })
330 $.ui.toast(copied.isCopied ? 'Copiado' : 'No pude copiar: probá seleccionando el texto')
331 }}
332 />
333 </Box>
334 ))
335 : null}
336 </Box>
337 ))}
338 </Box>
339 )
340 })
341}
342hooks/puente.ts 523 lines1import type { Intercambio } from '../types'
2import { FAMILIES, blockOf, familyOf } from './orca-config'
3import type { StackConfig } from './orca-config'
4
5export const READ_ONLY_TOOLS = 'read,grep,find,ls,codemode'
6// Variables this session inherits from its Orca terminal. A consult must not carry them: Pi's Orca
7// extensions would report as this terminal (Orca then sees this pane as `pi`), and the ORCA_STACK_* markers
8// belong to our stack, not to Gentle Shell's. Set over the host environment, an empty value unsets them.
9export const ORCA_VARS_CLEARED = [
10 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV',
11 'ORCA_AGENT_HOOK_VERSION', 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_LAUNCH', 'ORCA_AGENT_LAUNCH_TOKEN',
12 'ORCA_AGENT_PANE', 'ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID', 'ORCA_TERMINAL_HANDLE',
13 'ORCA_STACK_AGENT', 'ORCA_STACK_MODE', 'ORCA_STACK_POLICY_PATH', 'ORCA_STACK_PONYTAIL_LEVEL',
14 'ORCA_STACK_CAVEMAN_LEVEL',
15] as const
16
17// claudeConfigDir: the Claude account Pi's provider (pi-claude-bridge) uses; unset = the host's own.
18export const consultEnv = (engramPath: string, claudeConfigDir?: string): Record<string, string> => ({
19 ENGRAM_DATA_DIR: engramPath,
20 ...Object.fromEntries(ORCA_VARS_CLEARED.map(name => [name, ''])),
21 ...(claudeConfigDir === undefined ? {} : { CLAUDE_CONFIG_DIR: claudeConfigDir }),
22})
23
24// agentes.pi.claudeConfigDir from the text of the PERSONAL config (~/.orca-stack/config.json) only:
25// a committed project file must not redirect where Pi's credentials come from. A leading ~ is the home.
26export const piClaudeConfigDir = (personalConfig: string | undefined, home: string): string | undefined => {
27 if (personalConfig === undefined) {
28 return undefined
29 }
30 let dir: unknown
31 try {
32 dir = (JSON.parse(personalConfig.replace(/^/, '')) as { agentes?: { pi?: { claudeConfigDir?: unknown } } })
33 .agentes?.pi?.claudeConfigDir
34 } catch {
35 return undefined
36 }
37 if (typeof dir !== 'string' || dir.trim() === '') {
38 return undefined
39 }
40 const value = dir.trim()
41
42 if (value === '~') {
43 return home
44 }
45
46 return value.startsWith('~/') || value.startsWith('~\\') ? joinPath(home, value.slice(2)) : value
47}
48// Paths are built with the separator the base already uses (C:\Users\x on Windows, /home/x elsewhere).
49export const joinPath = (base: string, ...parts: string[]): string => {
50 const sep = base.includes('\\') ? '\\' : '/'
51 let root = base
52 while (root.length > 1 && (root.endsWith('/') || root.endsWith('\\'))) {
53 root = root.slice(0, -1)
54 }
55
56 return [root, ...parts].join(sep)
57}
58
59export const engramDir = (home: string): string => joinPath(home, '.gentle-shell', 'engram')
60export const launcherPath = (home: string): string => joinPath(home, '.pi', 'agent', 'bin', 'pi-launcher.js')
61
62// Gentle Shell's own skills (the only ones the consult loads: ~/.agents/skills holds our methodology,
63// which Pi would otherwise pick up). Pi's own package dir first, then the npm-global install.
64export const skillsInPi = (home: string): string =>
65 joinPath(home, '.pi', 'agent', 'npm', 'node_modules', 'gentle-pi', 'skills')
66export const skillsInGlobal = (npmRoot: string): string => joinPath(npmRoot.trim(), 'gentle-pi', 'skills')
67export const TIMEOUT_MS = 10 * 60 * 1000
68export const AUTH_MESSAGE =
69 'Gentle Shell no tiene una cuenta conectada: abrí una terminal, corré pi y usá /login.'
70export const BIG_PR_LINES = 400
71export const MAX_PER_TEMA = 20
72export const MAX_TEMAS = 30
73export const PLANTEO_STORED = 2000
74export const PLANTEO_SENT = 12000
75
76export const MARCO =
77 'Claude, otro orquestador del proyecto que trabaja con otra metodología, te consulta como par. ' +
78 'Trabajá con tu propio criterio y tu propia forma de trabajar; no adoptes la de quien te consulta. ' +
79 'Podés leer el código que necesites (solo lectura). ' +
80 'Respondé: cómo leés el problema, en qué coincidís y en qué no y por qué, qué riesgos ves, y qué harías distinto. ' +
81 'Si te falta información, decí cuál. ' +
82 'Estás en modo no interactivo: no uses herramientas de preguntas al usuario; poné tus preguntas en la respuesta.'
83
84export const section = (humano: string): string =>
85 'Gentle Shell (Pi) es otro orquestador, par con otra metodología; consultalo con `consultar_gentle_shell` en los puntos de decisión: ' +
86 'elegir entre enfoques, después de una propuesta o diseño, antes de mergear PRs grandes, y cuando la causa de un bug sigue sin estar clara después de un intento.\n' +
87 'El planteo va neutral: el problema, las restricciones, nuestra propuesta y los archivos. ' +
88 'No le mandes reglas del stack de Orca ni de nuestra metodología; AGENTS.md lo lee él solo.\n' +
89 `Si discrepa en algo material, mostrale ${humano.trim() === '' ? 'al usuario' : `a ${humano.trim()}`} las dos posturas con sus razones; no descartes su opinión por tu cuenta.`
90
91export const truncate = (text: string, max: number): string =>
92 text.length <= max ? text : `${text.slice(0, max)}… [recortado]`
93
94// A safe session-id / store key: lowercase, ascii, dashes.
95export const slug = (text: string): string =>
96 text
97 .normalize('NFD')
98 .replace(/[\u0300-\u036f]/g, '')
99 .toLowerCase()
100 .replace(/[^a-z0-9._-]+/g, '-')
101 .replace(/^-+|-+$/g, '')
102 .slice(0, 40)
103
104export const buildMessage = (planteo: string, pregunta: string): string =>
105 `## Planteo\n${truncate(planteo.trim(), PLANTEO_SENT)}\n\n## Pregunta\n${pregunta.trim()}`
106
107// Arguments after the launcher: every flag the bridge relies on, in one place.
108// --model is only passed when an override is set; otherwise Gentle Shell's own config picks the model.
109export const buildArgs = (tema: string, modelo: string, message: string, skills?: string): string[] => [
110 '-p',
111 '--mode',
112 'json',
113 '--no-skills',
114 ...(skills === undefined ? [] : ['--skill', skills]),
115 '--session-id',
116 `gentle-${tema}`,
117 '--tools',
118 READ_ONLY_TOOLS,
119 ...(modelo.trim() === '' ? [] : ['--model', modelo.trim()]),
120 '--append-system-prompt',
121 MARCO,
122 message,
123]
124
125type Block = { type?: string; text?: string }
126type Message = {
127 role?: string
128 content?: unknown
129 stopReason?: string
130 errorMessage?: string
131}
132
133const textOf = (message: Message): string => {
134 if (typeof message.content === 'string') {
135 return message.content
136 }
137 if (!Array.isArray(message.content)) {
138 return ''
139 }
140
141 return (message.content as Block[])
142 .filter(block => block?.type === 'text' && typeof block.text === 'string')
143 .map(block => block.text)
144 .join('')
145}
146
147export type Parsed = { text: string; error?: string }
148
149type StreamEvent = {
150 type?: string
151 message?: Message
152 assistantMessageEvent?: { type?: string; delta?: string }
153}
154
155// The JSONL stream: the last completed assistant message is the answer (message_end is authoritative).
156export const parseStream = (stdout: string): Parsed => {
157 let last: Message | undefined
158 let deltas = ''
159 for (const raw of stdout.split('\n')) {
160 const line = raw.trim()
161 if (!line.startsWith('{')) {
162 continue
163 }
164 let event: StreamEvent
165 try {
166 event = JSON.parse(line) as StreamEvent
167 } catch {
168 continue
169 }
170 if (event.type === 'message_start' && event.message?.role === 'assistant') {
171 deltas = ''
172 } else if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
173 deltas += event.assistantMessageEvent.delta ?? ''
174 } else if (event.type === 'message_end' && event.message?.role === 'assistant') {
175 last = event.message
176 }
177 }
178 if (last === undefined) {
179 return { text: deltas.trim() }
180 }
181 const text = textOf(last).trim() || deltas.trim()
182 if (last.stopReason === 'error' || last.stopReason === 'aborted') {
183 return { text, error: last.errorMessage ?? `Gentle Shell terminó con ${last.stopReason}` }
184 }
185
186 return { text }
187}
188
189const AUTH = /no api key|\/login|not logged|unauthori[sz]ed|\b401\b|invalid api key|authentication|credential/i
190
191export const isAuthError = (text: string): boolean => AUTH.test(text)
192
193export type Outcome = { ok: true; respuesta: string } | { ok: false; error: string }
194
195export const interpret = (run: { exitCode: number; stdout: string; stderr: string }): Outcome => {
196 const parsed = parseStream(run.stdout)
197 const diagnostics = `${run.stderr}\n${parsed.error ?? ''}`
198 if (isAuthError(diagnostics)) {
199 return { ok: false, error: AUTH_MESSAGE }
200 }
201 if (run.exitCode !== 0 || parsed.error !== undefined) {
202 const detail = truncate((parsed.error ?? run.stderr).trim().split('\n')[0] ?? '', 300)
203
204 return { ok: false, error: `Gentle Shell falló (salida ${run.exitCode})${detail ? `: ${detail}` : ''}` }
205 }
206 if (parsed.text === '') {
207 return { ok: false, error: 'Gentle Shell no devolvió texto.' }
208 }
209
210 return { ok: true, respuesta: parsed.text }
211}
212
213// /pi <tema>: <pregunta> - or just a question, whose first words become the tema.
214export const parsePiArgs = (args: string): { tema: string; pregunta: string } | undefined => {
215 const text = args.trim()
216 if (text === '') {
217 return undefined
218 }
219 const prefixed = /^([A-Za-z0-9._-]{1,40})\s*:\s*([\s\S]+)$/.exec(text)
220 if (prefixed?.[1] !== undefined && prefixed[2] !== undefined) {
221 const tema = slug(prefixed[1])
222 if (tema !== '') {
223 return { tema, pregunta: prefixed[2].trim() }
224 }
225 }
226 const derived = slug(text.split(/\s+/).slice(0, 4).join(' '))
227
228 return { tema: derived === '' ? 'consulta' : derived, pregunta: text }
229}
230
231export const piPrompt = (tema: string, pregunta: string): string =>
232 `Consultá a Gentle Shell (tema ${tema}) sobre: ${pregunta}. ` +
233 'Usá consultar_gentle_shell con un planteo neutral y después mostrame las dos posturas.'
234
235// ---- stored exchanges -------------------------------------------------------
236
237export type Memoria = Record<string, Intercambio[]>
238
239export const loadMemoria = (kept: unknown): Memoria =>
240 kept !== null && typeof kept === 'object' && !Array.isArray(kept) ? (kept as Memoria) : {}
241
242export const remember = (memoria: Memoria, tema: string, item: Intercambio): Memoria => {
243 const next: Memoria = { ...memoria, [tema]: [item, ...(memoria[tema] ?? [])].slice(0, MAX_PER_TEMA) }
244 const newest = (key: string): number => next[key]?.[0]?.at ?? 0
245 const kept = Object.keys(next)
246 .sort((a, b) => newest(b) - newest(a))
247 .slice(0, MAX_TEMAS)
248
249 return Object.fromEntries(kept.map(key => [key, next[key] ?? []]))
250}
251
252// Temas newest first, for the pane.
253export const vista = (memoria: Memoria): { tema: string; items: Intercambio[] }[] =>
254 Object.entries(memoria)
255 .map(([tema, items]) => ({ tema, items }))
256 .sort((a, b) => (b.items[0]?.at ?? 0) - (a.items[0]?.at ?? 0))
257
258// ---- merge gate -------------------------------------------------------------
259
260// Split a command on && || ; | and newlines, outside quotes.
261export const segments = (command: string): string[] => {
262 const out: string[] = []
263 let current = ''
264 let quote = ''
265 for (const ch of command) {
266 if (quote !== '') {
267 current += ch
268 if (ch === quote) {
269 quote = ''
270 }
271 } else if (ch === '"' || ch === "'") {
272 quote = ch
273 current += ch
274 } else if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '\r') {
275 out.push(current)
276 current = ''
277 } else {
278 current += ch
279 }
280 }
281 out.push(current)
282
283 return out.map(one => one.trim()).filter(one => one !== '')
284}
285
286const words = (segment: string): string[] =>
287 (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(word => word.replace(/^["']|["']$/g, ''))
288
289const VALUE_FLAGS = new Set(['-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email', '--match-head-commit'])
290const REPO_FLAGS = new Set(['-R', '--repo'])
291
292export type Merge = { number: string; repo?: string }
293
294// Every `gh pr merge <n>` in the command; a merge without a number is skipped.
295export const merges = (command: string): Merge[] => {
296 const found: Merge[] = []
297 for (const segment of segments(command)) {
298 const w = words(segment)
299 if (w[0] !== 'gh' && w[0] !== 'gh.exe') {
300 continue
301 }
302 let repo: string | undefined
303 const rest: string[] = []
304 for (let i = 1; i < w.length; i += 1) {
305 const word = w[i] ?? ''
306 if (REPO_FLAGS.has(word)) {
307 repo = w[i + 1]
308 i += 1
309 } else if (word.startsWith('--repo=')) {
310 repo = word.slice(7)
311 } else {
312 rest.push(word)
313 }
314 }
315 if (rest[0] !== 'pr' || rest[1] !== 'merge') {
316 continue
317 }
318 for (let i = 2; i < rest.length; i += 1) {
319 const word = rest[i] ?? ''
320 if (VALUE_FLAGS.has(word)) {
321 i += 1
322 continue
323 }
324 const match = /^(\d+)$/.exec(word) ?? /\/pull\/(\d+)/.exec(word)
325 if (match?.[1] !== undefined) {
326 found.push(repo === undefined ? { number: match[1] } : { number: match[1], repo })
327 break
328 }
329 }
330 }
331
332 return found
333}
334
335export const bigPrReason = (n: string, total: number): string =>
336 `PR #${n} grande (${total} líneas): consultá a Gentle Shell (tema pr-${n}) antes de mergear.`
337
338export const prSize = (stdout: string): number | undefined => {
339 try {
340 const data = JSON.parse(stdout) as { additions?: unknown; deletions?: unknown }
341 if (typeof data.additions === 'number' && typeof data.deletions === 'number') {
342 return data.additions + data.deletions
343 }
344 } catch {
345 // fall through
346 }
347
348 return undefined
349}
350
351// ---- risk-path trigger ------------------------------------------------------------------------
352
353// Minimal glob: * and ? stay inside one path segment, ** crosses segments, `**/` may match nothing,
354// a trailing / means "everything under". Node 20 has no path.matchesGlob.
355export const globToRegExp = (glob: string): RegExp => {
356 const g = glob.endsWith('/') ? `${glob}**` : glob
357 let out = ''
358 for (let i = 0; i < g.length; i += 1) {
359 const ch = g[i] ?? ''
360 if (ch === '*' && g[i + 1] === '*') {
361 i += 1
362 if (g[i + 1] === '/') {
363 i += 1
364 out += '(?:.*/)?'
365 } else {
366 out += '.*'
367 }
368 } else if (ch === '*') {
369 out += '[^/]*'
370 } else if (ch === '?') {
371 out += '[^/]'
372 } else {
373 out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
374 }
375 }
376
377 return new RegExp(`^${out}$`)
378}
379
380export const riskyFile = (files: string[], globs: string[]): string | undefined => {
381 const patterns = globs.filter(one => typeof one === 'string' && one.trim() !== '').map(one => globToRegExp(one.trim()))
382
383 return files.find(file => patterns.some(pattern => pattern.test(file.replace(/\\/g, '/'))))
384}
385
386export type PrFacts = { files: string[]; headRefOid: string; bodies: string[]; implementer: string | undefined }
387
388// Same parse as src/despacho.mjs (revisar): the first `Implementador: <agent>` line of the PR body, lowercased.
389export const implementerOf = (body: string): string | undefined =>
390 /^\s*Implementador:\s*(\S+)/im.exec(body)?.[1]?.trim().toLowerCase()
391
392// `gh pr view --json files,headRefOid,body,comments,reviews`: `gh pr review --comment` bodies live in `reviews`,
393// plain PR comments in `comments`. The PR body only says who implemented it; it is never a review.
394export const prFacts = (stdout: string): PrFacts | undefined => {
395 try {
396 const data = JSON.parse(stdout) as {
397 files?: { path?: unknown }[]
398 headRefOid?: unknown
399 body?: unknown
400 comments?: { body?: unknown }[]
401 reviews?: { body?: unknown }[]
402 }
403 if (typeof data.headRefOid !== 'string' || !Array.isArray(data.files)) {
404 return undefined
405 }
406 const bodies = [...(data.comments ?? []), ...(data.reviews ?? [])]
407 .map(one => one?.body)
408 .filter((body): body is string => typeof body === 'string')
409
410 return {
411 files: data.files.map(one => one?.path).filter((path): path is string => typeof path === 'string'),
412 headRefOid: data.headRefOid,
413 bodies,
414 implementer: typeof data.body === 'string' ? implementerOf(data.body) : undefined,
415 }
416 } catch {
417 return undefined
418 }
419}
420
421export type Review = { reviewer: string; clean: boolean }
422
423// The reviews of exactly this commit: `Revisor: <agent>` and `Commit: <sha>` among the first lines of one body (the
424// sha may be abbreviated to 7+ characters). Clean: the first non-empty line after `## BLOQUEA` is `nada`.
425export const reviewsOf = (bodies: string[], headRefOid: string): Review[] =>
426 bodies.flatMap(body => {
427 const lines = body.split(/\r?\n/).map(line => line.trim())
428 const head = lines.slice(0, 5)
429 const reviewer = head.map(line => /^Revisor:\s*(\S+)$/i.exec(line)?.[1]).find(name => name !== undefined)
430 const commit = head.map(line => /^Commit:\s*([0-9a-f]{7,40})$/i.exec(line)?.[1]).find(sha => sha !== undefined)
431 if (reviewer === undefined || commit === undefined || !headRefOid.toLowerCase().startsWith(commit.toLowerCase())) {
432 return []
433 }
434 const at = lines.findIndex(line => /^##\s*BLOQUEA\b/i.test(line))
435 const verdict = at < 0 ? undefined : lines.slice(at + 1).find(line => line !== '')
436
437 return [{ reviewer: reviewer.toLowerCase(), clean: verdict?.toLowerCase() === 'nada' }]
438 })
439
440// Why a PR touching a riesgo path cannot merge yet, or undefined. Every review counted is of the head commit.
441// - A review of the head commit with blockers stops it: on a riesgo path the human settles a blocker, not main.
442// - Implemented by pi: clean reviews from two model families other than pi's. A family comes from the model each
443// reviewer has configured to review (familyOf); a reviewer whose model has no known family does not count.
444// - Implemented by anyone else: a clean pi review, the second review the contract asks for.
445export const riskDeny = (n: string, file: string, facts: PrFacts, config: StackConfig, now: number): string | undefined => {
446 const sha = facts.headRefOid.slice(0, 7)
447 const prefix = `PR #${n} toca una ruta de riesgo (${file})`
448 const agents: readonly string[] = FAMILIES.agents
449 if (facts.implementer === undefined || !agents.includes(facts.implementer)) {
450 return (
451 `${prefix} y su body no tiene una línea "Implementador: <agente>" con uno de ${agents.join(', ')}: ` +
452 `sin ella no sé quién puede revisarlo. Agregala con \`gh pr edit ${n} --body-file <archivo>\`.`
453 )
454 }
455 const reviews = reviewsOf(facts.bodies, facts.headRefOid)
456 const blocked = reviews.find(review => !review.clean)
457 if (blocked !== undefined) {
458 return (
459 `${prefix} y el review de ${blocked.reviewer} sobre el commit ${sha} tiene bloqueos. ` +
460 'En una ruta de riesgo un bloqueo no se salda con "Decidido por main": corregilo y pedí un review nuevo, o pasale la decisión al humano.'
461 )
462 }
463 const reviewers = [...new Set(reviews.map(review => review.reviewer))]
464 if (facts.implementer !== 'pi') {
465 return reviewers.includes('pi')
466 ? undefined
467 : `${prefix}: falta un review de pi sobre el commit ${sha}. Corré \`orca-stack revisar ${n} pi\` y mergeá cuando el review esté limpio.`
468 }
469
470 const own = familyOf(config, 'pi', 'ticket').family
471 const covered = new Set<string>()
472 const skipped: string[] = []
473 for (const reviewer of reviewers) {
474 const { family, model } = familyOf(config, reviewer, 'review')
475 if (!agents.includes(reviewer)) {
476 skipped.push(`${reviewer}: no es un agente del stack`)
477 } else if (family === undefined && model === undefined) {
478 skipped.push(`${reviewer}: no tiene modelo para revisar en agentes.modelos, así que no sé su familia`)
479 } else if (family === undefined) {
480 skipped.push(`${reviewer}: su modelo ${model} no tiene una familia reconocida (agregá su patrón en FAMILIES)`)
481 } else if (family !== own) {
482 covered.add(family)
483 }
484 }
485 if (covered.size >= 2) {
486 return undefined
487 }
488
489 // Who can still add a family: in agentes.usa (as revisar reads it), not blocked, with a known family that is neither
490 // pi's nor covered. Within a family, an agent that needs no OK from the human goes first.
491 const usa = config.agentes?.usa ?? []
492 const ask = config.agentes?.preguntarAntes ?? []
493 const options = new Map<string, string>()
494 for (const agent of agents) {
495 const { family } = familyOf(config, agent, 'review')
496 if (usa.includes(agent) && blockOf(config, agent, now) === undefined && family !== undefined && family !== own && !covered.has(family)) {
497 const current = options.get(family)
498 if (current === undefined || (ask.includes(current) && !ask.includes(agent))) options.set(family, agent)
499 }
500 }
501 const have = covered.size === 0 ? 'Hay 0' : `Hay 1 (${[...covered].join(', ')})`
502 const notCounted = skipped.length === 0 ? '' : ` No cuento ${skipped.join('; ')}.`
503 const missing = 2 - covered.size
504 const picks = [...options.values()].slice(0, missing)
505 const next =
506 picks.length < missing
507 ? ` Con los agentes disponibles (agentes.usa, sin bloqueos) no alcanzan las familias: decidilo con el humano.`
508 : ` Corré ${picks.map(agent => `\`orca-stack revisar ${n} ${agent}\``).join(' y ')}.` +
509 picks.filter(agent => ask.includes(agent)).map(agent => ` ${agent} está en preguntarAntes: pedile el OK al humano y agregá --confirmado.`).join('')
510
511 return (
512 `${prefix} y lo escribió pi: faltan reviews limpios sobre el commit ${sha} de dos familias de modelo distintas ` +
513 `que no sean la de pi (${own ?? '?'}). ${have}.${notCounted}${next}`
514 )
515}
516
517export const clock = (at: number): string => {
518 const d = new Date(at)
519 const pad = (n: number): string => String(n).padStart(2, '0')
520
521 return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
522}
523hooks/orca-config.ts 279 lines1// Config resolution shared by the orca-stack mods. A plugin can only import files of its own folder,
2// so this file is copied verbatim into each mod that needs it (hooks/orca-config.ts); the repo's
3// tests fail if a copy drifts from this one (edit shared/orca-config.ts, then run `npm run sync-shared`).
4//
5// Three layers, later ones override earlier ones:
6// 1. ~/.orca-stack/config.json (personal)
7// 2. <repo>/.orca-stack.json (committed project rules)
8// 3. <repo>/.orca-stack.local.json (gitignored, local/sensitive)
9// <repo> is the main checkout, even when the session runs inside a git worktree.
10
11export type BlockRule = { agente: string; hasta: string; motivo?: string }
12
13export type StackConfig = {
14 humano?: string
15 mensajes?: { destinatarios?: string[] }
16 despacho?: { script?: string; revisar?: string }
17 riesgo?: string[] // path globs whose change needs a second review before merging (pi, or two other model families when pi wrote it)
18 agentes?: {
19 usa?: string[]
20 preguntarAntes?: string[]
21 modelos?: Record<string, unknown>
22 pi?: { perfil?: string; claudeConfigDir?: string } // perfil: gentle-pi profile name, or "preguntar" (the default)
23 }
24 guardia?: {
25 bloquear?: BlockRule[]
26 modeloClaude?: string
27 }
28}
29
30// Model families. Two agents whose models share a family share blind spots: one does not review the other, and
31// they do not count as two independent reviews. The family comes from the model in `agentes.modelos` (the one the
32// launcher passes with --model): its name, without the provider prefix, against `patterns`, in order. An agent
33// with no model takes `defaults`; pi is `fixed`, because its launcher never passes a model. A model that matches
34// no pattern has no family: nothing is proved about it.
35// The literal between the markers is JSON: src/despacho.mjs keeps the same one, and test/units.test.mjs compares them.
36// BEGIN FAMILIES
37export const FAMILIES = {
38 "agents": ["claude", "agy", "codex", "codex-cc", "pi"],
39 "fixed": { "pi": "claude" },
40 "defaults": { "claude": "claude", "codex": "gpt", "agy": "gemini" },
41 "patterns": [
42 ["claude", "^(claude|opus|sonnet|haiku|fable)"],
43 ["gpt", "^(gpt|codex|o\\d)"],
44 ["gemini", "^gemini"],
45 ["glm", "^glm"]
46 ]
47} as const
48// END FAMILIES
49
50const isPlain = (value: unknown): value is Record<string, unknown> =>
51 value !== null && typeof value === 'object' && !Array.isArray(value)
52
53export const familyOfModel = (model: string): string | undefined => {
54 const name = model.trim().toLowerCase().split('/').pop() ?? ''
55
56 return FAMILIES.patterns.find(([, pattern]) => new RegExp(pattern).test(name))?.[0]
57}
58
59// agentes.modelos.<agent>: "model", { modelo, esfuerzo } or { implementar: {...}, revisar: {...} } (as src/despacho.mjs).
60export const modelFor = (config: StackConfig, agent: string, mode: 'ticket' | 'review'): string | undefined => {
61 let entry = config.agentes?.modelos?.[agent]
62 if (isPlain(entry) && (entry['implementar'] !== undefined || entry['revisar'] !== undefined)) {
63 entry = entry[mode === 'review' ? 'revisar' : 'implementar']
64 }
65 const model = typeof entry === 'string' ? entry : isPlain(entry) ? entry['modelo'] : undefined
66
67 return typeof model === 'string' && model.trim() !== '' ? model.trim() : undefined
68}
69
70// The family `agent` runs on to implement ('ticket') or to review; `model` is the configured one, for messages.
71export const familyOf = (
72 config: StackConfig,
73 agent: string,
74 mode: 'ticket' | 'review',
75): { family: string | undefined; model: string | undefined } => {
76 const fixed = (FAMILIES.fixed as Record<string, string>)[agent]
77 if (fixed !== undefined) {
78 return { family: fixed, model: undefined }
79 }
80 const model = modelFor(config, agent, mode)
81
82 return { family: model === undefined ? (FAMILIES.defaults as Record<string, string>)[agent] : familyOfModel(model), model }
83}
84
85// "2026-10-29" -> local midnight of that day; an agent is blocked while now < that instant.
86export const parseUntil = (date: string): number | undefined => {
87 const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date.trim())
88 if (match === null) {
89 return undefined
90 }
91 const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
92 const at = new Date(year, month - 1, day)
93 // Round trip: 2026-02-30 would silently become 2026-03-02.
94 if (at.getFullYear() !== year || at.getMonth() !== month - 1 || at.getDate() !== day) {
95 return undefined
96 }
97
98 return at.getTime()
99}
100
101// Why `agent` cannot be dispatched now, or undefined. A rule with a bad date stops it too: a block must not
102// vanish because of a typo.
103export const blockOf = (config: StackConfig, agent: string, now: number): string | undefined => {
104 for (const rule of config.guardia?.bloquear ?? []) {
105 if (typeof rule?.agente !== 'string' || rule.agente.toLowerCase() !== agent.toLowerCase()) {
106 continue
107 }
108 const until = typeof rule.hasta === 'string' ? parseUntil(rule.hasta) : undefined
109 if (until === undefined) {
110 return `La regla guardia.bloquear de ${agent} tiene una fecha "hasta" que no es AAAA-MM-DD (${String(rule.hasta)}): corregila antes de despacharlo.`
111 }
112 if (now < until) {
113 return typeof rule.motivo === 'string' && rule.motivo.trim() !== '' ? rule.motivo : `${agent} está bloqueado hasta el ${rule.hasta}.`
114 }
115 }
116
117 return undefined
118}
119
120// Plain objects merge key by key; anything else (arrays included) is replaced by the later layer.
121const mergePlain = (...layers: unknown[]): Record<string, unknown> => {
122 const out: Record<string, unknown> = {}
123 for (const layer of layers) {
124 if (!isPlain(layer)) {
125 continue
126 }
127 for (const [key, value] of Object.entries(layer)) {
128 const before = out[key]
129 out[key] = isPlain(value) && isPlain(before) ? mergePlain(before, value) : value
130 }
131 }
132
133 return out
134}
135
136// One exception: `guardia.bloquear` adds up across the layers, so a block in one layer cannot be dropped by another.
137export const mergeConfig = (...layers: unknown[]): StackConfig => {
138 const out = mergePlain(...layers) as StackConfig
139 const rules = layers.flatMap(layer => {
140 const guardia = isPlain(layer) ? layer['guardia'] : undefined
141 const list = isPlain(guardia) ? guardia['bloquear'] : undefined
142
143 return Array.isArray(list) ? (list as BlockRule[]) : []
144 })
145 if (rules.length > 0) {
146 out.guardia = { ...out.guardia, bloquear: rules }
147 }
148
149 return out
150}
151
152export const parseJson = (text: string): unknown => {
153 try {
154 return JSON.parse(text.replace(/^/, ''))
155 } catch {
156 return undefined
157 }
158}
159
160const slashed = (path: string): string => {
161 let out = path.split('\\').join('/')
162 while (out.length > 1 && out.endsWith('/')) {
163 out = out.slice(0, -1)
164 }
165
166 return out
167}
168
169// Output of `git rev-parse --path-format=absolute --git-common-dir --show-toplevel`:
170// the main checkout is the parent of the common `.git` dir; a bare repo falls back to the toplevel.
171export const repoRootFrom = (stdout: string): string | undefined => {
172 const [common, top] = stdout.split(/\r?\n/).map(line => line.trim())
173 if (common !== undefined && common !== '') {
174 const dir = slashed(common)
175 if (dir.endsWith('/.git')) {
176 return dir.slice(0, -'/.git'.length)
177 }
178 }
179
180 return top === undefined || top === '' ? undefined : slashed(top)
181}
182
183// The engine's `$` cannot cross an import, so the host passes the few operations this module needs
184// as closures: { home: async () => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')), exists: p => $.fs.exists(p), read: p => $.fs.read(p),
185// git: argv => $.process.run(argv, { timeoutMs: 10_000 }), now: () => $.clock.now() }.
186export type Host = {
187 home: () => Promise<string | undefined>
188 exists: (path: string) => Promise<boolean>
189 read: (path: string) => Promise<string>
190 git: (argv: string[]) => Promise<{ exitCode: number; stdout: string }>
191 now: () => Promise<number>
192}
193
194export const homeOf = async (host: Host): Promise<string | undefined> => {
195 let home: string | undefined
196 try {
197 home = await host.home()
198 } catch {
199 home = undefined
200 }
201
202 return home === undefined || home === '' ? undefined : slashed(home)
203}
204
205const readLayer = async (host: Host, path: string): Promise<unknown> => {
206 try {
207 if (!(await host.exists(path))) {
208 return undefined
209 }
210
211 return parseJson(await host.read(path))
212 } catch {
213 return undefined
214 }
215}
216
217export const repoRoot = async (host: Host): Promise<string | undefined> => {
218 try {
219 const git = await host.git(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'])
220
221 return git.exitCode === 0 ? repoRootFrom(git.stdout) : undefined
222 } catch {
223 return undefined
224 }
225}
226
227// Whether the repo opted into orca-stack, with `.orca-stack.json` or `.orca-stack.local.json`: mods that inject text
228// into the session, or that gate on it, only do so in such a project.
229export const hasProjectConfig = async (host: Host): Promise<boolean> => {
230 const root = await repoRoot(host)
231 if (root === undefined) {
232 return false
233 }
234 for (const name of ['.orca-stack.json', '.orca-stack.local.json']) {
235 try {
236 if (await host.exists(`${root}/${name}`)) {
237 return true
238 }
239 } catch {
240 // An unreadable layer counts as missing.
241 }
242 }
243
244 return false
245}
246
247const loadFor = async (host: Host, home: string | undefined, root: string | undefined): Promise<StackConfig> =>
248 mergeConfig(
249 home === undefined ? undefined : await readLayer(host, `${home}/.orca-stack/config.json`),
250 root === undefined ? undefined : await readLayer(host, `${root}/.orca-stack.json`),
251 root === undefined ? undefined : await readLayer(host, `${root}/.orca-stack.local.json`),
252 )
253
254// Never throws: a missing or broken layer is skipped.
255export const loadConfig = async (host: Host): Promise<StackConfig> =>
256 loadFor(host, await homeOf(host), await repoRoot(host))
257
258// loadConfig with a short memory, for hooks that run on every tool call. The memory is keyed by the
259// resolved home and repo root, so switching to another checkout never serves the previous one's rules.
260let remembered: { key: string; at: number; config: StackConfig } | undefined
261
262export const currentConfig = async (host: Host, maxAgeMs = 30_000): Promise<StackConfig> => {
263 const home = await homeOf(host)
264 const root = await repoRoot(host)
265 const key = `${home ?? ''}|${root ?? ''}`
266 const now = await host.now()
267 if (remembered !== undefined && remembered.key === key && now - remembered.at < maxAgeMs) {
268 return remembered.config
269 }
270 const config = await loadFor(host, home, root)
271 remembered = { key, at: now, config }
272
273 return config
274}
275
276export const forgetConfig = (): void => {
277 remembered = undefined
278}
279types/index.d.ts 15 lines1export type Intercambio = {
2 at: number
3 pregunta: string
4 planteo: string
5 respuesta: string
6}
7
8export type TemaVista = { tema: string; items: Intercambio[] }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'puente-gentle': { temas: TemaVista[]; sel: string }
13 }
14}
15