Harness opinionado de Claude Code para construir aplicaciones serverless en Azure con Event Driven Architecture, Event Sourcing (Marten + Wolverine), Postgres…

Repositorio:
eda-evsourcing-azure-harness· Nombre del plugin:mefisto
Plugin de Claude Code que provee un harness opinionado para construir aplicaciones .NET 10 serverless en Azure con Event Driven Architecture y Event Sourcing.
Estado: v0.1.0 (internal alpha) — extraído del proyecto Bitakora.ControlAsistencia el 2026-05-15. La API del harness puede cambiar entre versiones menores hasta
v1.0.0.
mefisto es un guiño a Mefistófeles, el espíritu de Fausto de Goethe. La analogía es simple: quien invoca el harness encarna a Fausto — fija la intención y firma el pacto —; el plugin, como Mefisto, ejecuta esa voluntad bajo las reglas del marco (EDA, Event Sourcing, Azure Functions, TDD).
«Ich will mich hier zu deinem Dienst verbinden, auf deinen Wink nicht rasten und nicht ruhn».
— Mefistófeles, Fausto I, escena «Studierzimmer», vv. 1656-1657
«Aquí me ataré a tu servicio, a tu menor seña no descansaré ni cesaré».
/onboard, /upgrade, /runtimes, /implement, /tooling, /infra, /infra-base, /scaffold, /scaffold-projections, /scaffold-mcp, /seed-secret, /install-workos, /install-apim, /install-auth, /parallel, /batch-stop, /next-order, /sequential, /bug, /draft, /fix-review, /health-check, /work-status, /eraser-diagram, /merge, /bitacora, /purge-store.planner, test-writer, implementer, projection-test-writer, projection-implementer, projections-scaffolder, reviewer, smoke-test-writer, domain-scaffolder, infra-base-scaffolder, apim-gateway-scaffolder, workos-identity-scaffolder, historiador, infra-writer, infra-reviewer, infra-bootstrap, pr-sync, bug-investigator, tooling-investigator.tmux y git worktree.MEF-ADR-, ver índice temático).microsoft-learn (endpoint remoto oficial https://learn.microsoft.com/api/mcp, HTTP sin autenticación). En Claude Code se declara en .mcp.json; en OpenCode se proyecta globalmente como plugin local, sin modificar opencode.json. Terraform permanece externo: su instalación y los permisos por artefacto no forman parte de este bundle (la traducción de permisos OpenCode llega en #1145). Discovery certificado; invocación real no certificada — ver el veredicto de docs/testing/opencode-consumer-cutover.md.El veredicto del corte vertical (issue #1066, PASA sobre la cadena v0.37.14 → v0.37.16, con la desviación de release declarada allí) certifica soporte OpenCode publicado exclusivamente para /mefisto:tooling: instalación real con checksum del artefacto OpenCode, discovery de comandos/agentes/Skills/scripts/permisos/hooks/MCP, ejecución headless hasta PR real lanzada desde sesión interactiva, Herdr con pools de panes separados por runtime, y observabilidad correlacionable sin secretos.
Queda fuera de esa certificación —discovery disponible, invocación real no ejercitada por el gate—:
/implement, /infra-base, /scaffold, /scaffold-projections, /scaffold-mcp, /parallel, /sequential, /merge y los demás del catálogo de "Qué incluye");tooling-writer y tooling-reviewer;read/edit/shell);microsoft-learn, y la traducción de permisos MCP por agente/comando (#1145)./mefisto:implement (rutas write-side y tipo:projection) tiene su propio protocolo de certificación en docs/testing/tdd-consumer-certification.md, y su veredicto (#1487) cerró PASA (2026-09-22, release v0.38.2): las cuatro corridas reales write-side/read-side × Claude/OpenCode exigidas por #1435/#1436 se ejecutaron en vivo el 2026-09-21, así que esas dos rutas quedan certificadas bajo ambos runtimes. --scaffold-domain, --from-stage, --variant y la remediación de coverage siguen fuera de esa certificación. El veredicto anterior (#1411, NO PASA) queda como antecedente.
/mefisto:infra tiene su propio protocolo en docs/testing/infra-consumer-certification.md, y su veredicto (#1629) cerró PASA (2026-09-30, release v0.40.1): Claude Code y OpenCode, lanzados desde Herdr y desde tmux autónomo, con la invocación directa y la selección automática de modelos por perfil. Mezclar lanzamientos de dos distribuciones o versiones sobre el mismo servidor tmux sigue fuera de esa certificación hasta #1740. El intento sobre v0.40.0 (NO PASA, #1730) queda como antecedente.
Cosmos.EventSourcing.Testing.UtilitiesSi tu proyecto no encaja con este stack, este harness no es para ti.
Requisito de versión: el plugin requiere Claude Code >= 2.1.287, la versión que publicó los mods: hooks/hooks.json declara la clave modules, y una versión anterior podría rechazar el archivo entero y dejarte sin .plugin-root, sessions.jsonl ni el recordatorio de field notes (MEF-ADR-0055). /mefisto:onboard lo verifica (claude update para actualizar).
Camino recomendado desde Claude Code: ejecuta /mefisto:upgrade. Actualiza Claude y consulta el estado estructurado de la proyeccion OpenCode sin leer opencode.json, providers, modelos ni credenciales. Si OpenCode ya estaba habilitado (incluso si esta stale), alinea, activa y reproyecta automaticamente la misma release; si esta deshabilitado, pide una unica confirmacion para bootstrap o reactivacion. Si se declina, conserva el modo solo Claude: no crea ni reproyecta OpenCode. Un conflicto se informa y nunca se repara automaticamente. El resultado separa la version cargada por la sesion, la destino en disco y la identidad verificable de ambas distribuciones; luego requiere /reload-plugins o reiniciar la sesion.
El procedimiento directo siguiente queda como recuperacion avanzada cuando no puedes iniciar Claude Code o necesitas reparar manualmente una instalacion OpenCode conflictiva.
OpenCode usa una release global por usuario; no copia el checkout. La raíz es ${XDG_DATA_HOME:-$HOME/.local/share}/mefisto o, en macOS cuando XDG_DATA_HOME no está definido, $HOME/Library/Application Support/mefisto, conforme a MEF-ADR-0053 y la XDG Base Directory Specification.
Para el primer bootstrap, descarga el tarball y su checksum del mismo tag, verifica antes de extraer y ejecuta exclusivamente el instalador que quedó dentro del contenido verificado:
VERSION=<semver>
BASE="https://github.com/augusto-romero-arango/eda-evsourcing-azure-harness/releases/download/v$VERSION"
curl -fL "$BASE/mefisto-opencode-v$VERSION.tar.gz" -o "mefisto-opencode-v$VERSION.tar.gz"
curl -fL "$BASE/mefisto-opencode-v$VERSION.tar.gz.sha256" -o "mefisto-opencode-v$VERSION.tar.gz.sha256"
shasum -a 256 -c "mefisto-opencode-v$VERSION.tar.gz.sha256"
mkdir "mefisto-opencode-release" && tar -xzf "mefisto-opencode-v$VERSION.tar.gz" -C "mefisto-opencode-release" && ./mefisto-opencode-release/install.sh install "$VERSION"
Tras el bootstrap, el único punto de entrada es <raíz-de-datos>/mefisto/active/bin/mefisto-opencode. El instalador instalado descarga y vuelve a verificar ambos assets antes de extraer; dos activaciones concurrentes se serializan con un lock bajo esa misma raíz. No crea enlaces en el PATH, no lee auth stores ni modifica opencode.json: el proyector global crea solo enlaces de Mefisto y conserva proveedores, modelos, permisos y MCP del usuario. Si un proceso termina forzosamente y deja .activation.lock, verifica que no haya otra instalación en curso y elimina solo ese directorio de lock antes de reintentar.
M="${XDG_DATA_HOME:-$HOME/.local/share}/mefisto/active/bin/mefisto-opencode" # Linux/XDG
"$M" install <semver> # upgrade verificando el tag v<semver>
"$M" activate <semver> # rollback a una release ya instalada
"$M" project # proyecta comandos/agentes/Skills/plugins de active
"$M" deactivate # retira solo los enlaces creados por Mefisto
"$M" status # runtime, versión, tag, commit y diagnóstico
"$M" prune --keep 2 # previsualiza y pide confirmar la poda de releases inactivas
prune es siempre opt-in: ni install ni activate eliminan releases. Conserva la activa y su anterior inmediata para el rollback de un paso, incluso si --keep (retención total, 2 por defecto) pide menos. Antes de borrar lista las releases y los KiB recuperables; responde exactamente si desde una terminal o usa --yes para una ejecución no interactiva. Las entradas que no son releases válidas se informan y se conservan.
En macOS sin XDG_DATA_HOME, sustituye M por "$HOME/Library/Application Support/mefisto/active/bin/mefisto-opencode".
La configuración global de OpenCode es $OPENCODE_CONFIG_DIR si está definido; si no, ${XDG_CONFIG_HOME:-$HOME/.config}/opencode, también en macOS. project usa enlaces por archivo a active/{commands,agents,skills,plugins} de la release actual (OpenCode 1.18.29 o posterior). Si un nombre ya pertenece al usuario, aborta sin sobrescribirlo; las capacidades que la release todavía no contiene se anuncian como degradación visible. OPENCODE_CONFIG_DIR definido pero vacío se rechaza para no proyectar accidentalmente en otra raíz. La instalación no inspecciona auth stores. Véase MEF-ADR-0053.
.claude/settings.json del repo consumidorCrea (o extiende) .claude/settings.json en la raíz del repo consumidor con tres bloques — el marketplace, la habilitación del plugin y los permisos recomendados:
{
"extraKnownMarketplaces": {
"augusto-romero-arango-harness": {
"source": {
"source": "github",
"repo": "augusto-romero-arango/eda-evsourcing-azure-harness"
}
}
},
"enabledPlugins": {
"mefisto@augusto-romero-arango-harness": true
},
"permissions": {
"allow": [
"Bash(dotnet:*)",
"Bash(git:*)",
"Bash(gh:*)",
"Bash(terraform:*)",
"Bash(az:*)"
],
"deny": [
"Bash(terraform destroy:*)",
"Bash(az group delete:*)",
"Bash(git push --force:*)"
]
}
}
extraKnownMarketplaces registra el marketplace que aloja a Mefisto (el repo de GitHub). El esquema del bloque source usa la clave source (no type) con valor "github" — ver issue #75.enabledPlugins habilita el plugin de forma reproducible y commiteable: al estar en el settings.json versionado, cualquiera que clone el repo arranca con Mefisto ya habilitado. La clave es <plugin.name>@<marketplace.name> = mefisto@augusto-romero-arango-harness (verificado contra .claude-plugin/plugin.json y .claude-plugin/marketplace.json). El /plugin install interactivo del paso 2 habilita el plugin en tu instalación local pero no deja artefacto en el repo, así que sin esta clave la habilitación no es reproducible.permissions es un punto de partida ajustable (sintaxis Bash(<cmd>:*) de Claude Code; ver la doc de settings). El allow evita la fricción de aprobar uno a uno los dotnet/git/gh/terraform/az que disparan los pipelines; el deny es una red de seguridad contra comandos destructivos (terraform destroy, az group delete, git push --force). Endurécelo o relájalo según la política de tu equipo — el deny tiene prioridad sobre el allow./plugin marketplace add augusto-romero-arango-harness
/plugin install mefisto@augusto-romero-arango-harness
El
/plugin installinteractivo no deja rastro en el repo; el bloqueenabledPluginsdel paso 1 es lo que hace la habilitación reproducible y commiteable. Si declarasteenabledPlugins, este paso sigue siendo útil la primera vez para que Claude Code descargue el plugin al cache local.
Si vas a correr los pipelines (
/infra,/implement,/scaffold), instala a scopeuser, noproject:claude plugin install mefisto@augusto-romero-arango-harness --scope user. Esos pipelines invocan a sus agentes dentro de un git worktree hermano del repo consumidor (${REPO_ROOT}/../<rama>), que un scopeprojectno carga. Ver "Primeros pasos con el harness (greenfield)", paso 1, para el porqué detallado.
Crea .mefisto/harness.config.json en la raíz del proyecto consumidor. Agrega exactamente .mefisto/pipeline/ a tu .gitignore — nunca ignores .mefisto/ completo: .mefisto/harness.config.json debe versionarse (misma regla que valida /mefisto:onboard, ver scripts/onboard-diagnose.sh):
{
"projectName": "MiProyecto",
"namespacePrefix": "MiOrg.MiProyecto",
"solutionFile": "MiProyecto.slnx",
"infraResourceGroupPrefix": "rg-miproyecto",
"terraformStateStorage": "stmiproyectotfstatedev",
"githubServicePrincipalName": "github-miproyecto-ci",
"appInsightsApp": "appi-miproyecto-dev-eus2-001",
"azureLocation": "eastus2",
"domainLabels": ["dominio1", "dominio2"],
"boundedContext": {
"name": "Principal",
"domains": ["dominio1", "dominio2"]
},
"tenancy": { "strategy": "mono-tenant-transitorio" },
"projections": { "enabled": true },
"azureRegionShort": "eus2",
"resourceSequence": "001",
"secrets": [
{
"name": "stripe-api-key",
"source": { "type": "github-secret", "value": "STRIPE_API_KEY" }
}
]
}
Campo terraformStateStorage (nombre BASE, solo naming legacy): el nombre de una Storage Account es un endpoint DNS público (*.blob.core.windows.net) y por tanto único en todo Azure, no solo en tu suscripción. Sin azureRegionShort declarado (naming legacy, retrocompatible), scripts/bootstrap-backend.sh trata este campo como un nombre base: le anexa un sufijo aleatorio de 6 caracteres para garantizar unicidad global (mismo patrón random_string que el scaffolder usa en las Storage de dominio) y valida la disponibilidad con az storage account check-name antes de crear. El nombre final (con sufijo) es el que queda en infra/environments/<env>/backend.tf, así que terraform init usa exactamente la cuenta creada. Declara la base sin sufijo (ej. stmiproyectotfstatedev, 22 chars). Restricción de Azure: el nombre de una Storage Account debe tener 3-24 caracteres, solo minúsculas y dígitos (Microsoft Learn — reglas de nombres de recursos, Microsoft.Storage). El patrón sugerido st<proyecto>tfstate<env> deja ~12 caracteres para <proyecto> (st=2, tfstate=7, dev=3), así que para nombres largos abrevia el prefijo del proyecto: p. ej. micontrolplane produce stmicontrolplanetfstatedev = 26 chars (inválido); abreviado a mcp queda stmcptfstatedev = 15 chars (válido). load_harness_config valida este formato (^[a-z0-9]{3,24}$) al cargar el config (issue #78) y aborta temprano si no cumple, en vez de fallar tarde en el apply. Si la base válida más el sufijo de unicidad no cabe en 24 caracteres, bootstrap-backend.sh trunca la base y avisa. Las corridas posteriores reutilizan la cuenta ya creada (idempotente; ancla el nombre en el backend.tf versionado y en la cuenta existente del Resource Group del tfstate), no generan un sufijo nuevo.
Con azureRegionShort declarado (naming CAF, MEF-ADR-0045, issue #732): bootstrap-backend.sh deja de leer terraformStateStorage para nombrar el backend nuevo — deriva el Resource Group y la Storage Account únicamente de infraResourceGroupPrefix + env + azureRegionShort + resourceSequence, sin sufijo aleatorio (unicidad estructural, MEF-ADR-0045 sección 2): RG rg-tfstate-{app}-{env}-{region}-{seq} y Storage sttfstate{app}{env}{region}{seq} (sin guiones; {app} se trunca si no cabe en 24 chars, preservando el prefijo). Ante colisión real, el fallback es incrementar resourceSequence en el config, nunca un sufijo random. terraformStateStorage sigue siendo obligatorio en el schema (retrocompatibilidad con el naming legacy), pero su contenido queda sin efecto en este modo. Un backend ya creado con el naming legacy (backend.tf versionado con el nombre viejo) sigue funcionando sin cambios aunque declares azureRegionShort después — ningún recurso desplegado se renombra (MEF-ADR-0045 sección 3, "solo greenfield").
Campo opcional azureLocation: la región de Azure (ej. "eastus2", "westeurope") donde scripts/bootstrap-backend.sh crea el backend de Terraform (Resource Group, Storage Account y container del tfstate). Si lo declaras, el bootstrap lo usa por defecto sin tener que pasar --location en cada corrida; el flag --location siempre lo sobrescribe. Si no lo declaras y tampoco pasas --location, el bootstrap aborta pidiéndote uno de los dos. Es opcional, así que añadirlo no es un cambio incompatible del schema (no es MAJOR).
azureLocation(backend del tfstate) ≠ región de PostgreSQL.azureLocationsolo fija dónde vive el backend deltfstate(Resource Group, Storage Account, container); no es la región del PostgreSQL Flexible Server que/infra-baseprovisiona como event store de Marten (MEF-ADR-0003, MEF-ADR-0021). Esa región se fija con el default versionadopostgresql_locationeninfra/environments/<env>/variables.tfy puede —y a veces debe— diferir deazureLocation: en el primer greenfield real (Bitakora.ControlAsistencia),eastus2—válido para el backend del tfstate— devolvióLocationIsOfferRestrictedal crear el PostgreSQL Flexible Server, y se resolvió usandocentralus. Para que CI conserve el cambio, edita y versiona también el defaultpostgresql_region_shortcon la abreviatura de la región real; por ejemplo:variable "postgresql_location" { default = "centralus" } variable "postgresql_region_short" { default = "cus" }No derives
cusdesdecentralus: decláralo según la convención regional del consumidor (MEF-ADR-0045). Este cambio no alteralocationniazure_region_short, que siguen describiendo los recursos primarios ya ubicados eneastus2.terraform.tfvarspermanece ignorado exclusivamente para overrides locales no versionados y no es una fuente de configuración de runners limpios; nunca guardes allípostgresql_admin_passwordni otro secreto. Ese error depende de tu suscripción/oferta (la región figura como soportada en la lista oficial de regiones de Postgres, pero la oferta no está habilitada para tu suscripción ahí), no es una indisponibilidad global de la región — por eso no hay una región "apta" universal y conviene verificar la tuya antes del primerterraform apply, no descubrir la restricción en el apply:az postgres flexible-server list-skus --location <region> -o tableSi lista SKUs (entre ellas
Standard_B1ms, la SKU de cómputo que usa el módulopostgresql—list-skusy--sku-namela nombran así, con el tierBurstablecomo parámetro aparte; el providerazurermla declara ensku_namecomoB_Standard_B1ms, anteponiendo el tier, que es el valor que figura en la tabla de módulos del MEF-ADR-0021), la región sirve para tu suscripción; si sale vacío o falla, elige otra (p. ej.centralus). El comando es la referencia oficial de Azure CLI (az postgres flexible-server list-skus). Ver MEF-ADR-0021, sección "Región de PostgreSQL Flexible Server".
Campo boundedContext (obligatorio, MEF-ADR-0023): declara el Bounded Context del proyecto. Un BC es un grupo de dominios relacionados que comparte un resource group de Azure y dos namespaces de Azure Service Bus (interno e integración). load_harness_config valida los dos subfields y exporta HARNESS_BC_NAME y HARNESS_BC_DOMAINS:
name: nombre del BC, 1-63 caracteres alfanuméricos y guiones. Puede coincidir o no con projectName (ej: "Principal", "Admin", "Core").domains: array de dominios del BC, no vacío. Cada elemento debe estar en domainLabels. Los dominios del BC son un subconjunto de todos los dominios del proyecto (en proyectos con un solo BC, el subconjunto es igual a domainLabels).El resource group del BC se forma como infraResourceGroupPrefix+-+name (ej: rg-miproyecto-principal). El context map (registro de BCs externos) es trabajo diferido; hoy el BC solo se nombra a sí mismo.
Proyectos existentes (que vienen de una versión sin este campo): si
boundedContextno está en tu config,load_harness_configaborta con un mensaje accionable que muestra el shape exacto a añadir y un ejemplo con tusdomainLabelsactuales. Corre/mefisto:onboardpara obtener el diagnóstico, o lee la sección "Migración para consumidores existentes" al final de este documento.
Campo opcional repoSlug: el slug owner/repo del repositorio de Mefisto al que se enrutan los drafts cross-repo (estado:borrador) que crean el planner y el tooling-investigator cuando detectan que un problema descubierto en tu proyecto pertenece al harness. Sirve para redirigir esos drafts a tu fork de Mefisto en vez del repo upstream. Si no lo declaras, el default es augusto-romero-arango/eda-evsourcing-azure-harness. No se exporta como variable HARNESS_*: se lee directo con jq donde se necesita (scripts/_pipeline-common.sh, agents/planner.md, agents/tooling-investigator.md). Es opcional (añadirlo no es MAJOR).
Campo opcional tenancy (MEF-ADR-0028, issue #323): declara la etapa vigente del ITenantResolver del BC. Único subfield, strategy, con dos valores válidos:
"mono-tenant-transitorio" (etapa a): greenfield sin autenticación instalada. Es el default de domain-scaffolder (issue #318) y el valor asumido si el campo está ausente (retrocompatible con todo consumidor existente)."multi-tenant-header" (etapa b): el proyecto ya instaló una autenticación que produce un TenantContext (header-based o el híbrido HTTP+Wolverine de Cosmos.MultiTenancy.CritterStack).domain-scaffolder lee este campo inline con jq en su Paso 0 (mismo patrón que serviceBus.external) para elegir el ITenantResolver que registra en el Program.cs del dominio que scaffoldea: en etapa (a) genera el resolver mono-tenant transitorio (sin cambios respecto a #318); en etapa (b) auto-cablea el resolver híbrido genérico de Cosmos.MultiTenancy.CritterStack, dejando un // TODO(tenancy claims) para el mapping claims → TenantContext (siempre project-specific). /onboard reporta el valor de for
hooks/register.tsx 558 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardFocus, BoardList, BoardTab } from './types'
5import {
6 nextOrderPath,
7 typeBadge,
8 agentFlagOf,
9 agentSettingOf,
10 transcriptPathOf,
11 arrivals,
12 clip,
13 createdIssueOf,
14 createdText,
15 cropGrid,
16 isIssueChange,
17 isIssueCreate,
18 usedColumns,
19 mascotPose,
20 isPlannerClosing,
21 issueMarkedListo,
22 numberWidth,
23 padEnd,
24 pageOf,
25 pageSizeOf,
26 parseNextOrder,
27 parseOpenIssues,
28 reasonOf,
29 refineTargetOf,
30 signatureOf,
31 topicOf,
32} from './logic'
33import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, sprite } from './sprites'
34
35const PLANNER_AGENT = 'mefisto:planner'
36const POLL_MS = 60_000
37const SCRIPT_TIMEOUT_MS = 180_000
38const FLASH_MS = 8_000
39// GitHub tarda en reflejar un cambio en todas sus consultas: un refresco inmediato puede guardar la firma nueva
40// con listas viejas de next-order y el poll ya no vuelve a refrescar. Se repite forzado tras este margen.
41const SETTLE_MS = 15_000
42
43const activeAtom = atom({ plugin: 'mefisto', key: 'isActive' } as const, false)
44const plannerAtom = atom({ plugin: 'mefisto', key: 'isPlannerSession' } as const, false)
45const expandedAtom = atom({ plugin: 'mefisto', key: 'isExpanded' } as const, false)
46const pageAtom = atom({ plugin: 'mefisto', key: 'page' } as const, 0)
47const tabAtom = atom({ plugin: 'mefisto', key: 'tab' } as const, 'borrador')
48const focusAtom = atom({ plugin: 'mefisto', key: 'focus' } as const, null)
49const confirmAtom = atom({ plugin: 'mefisto', key: 'isConfirmingClose' } as const, false)
50const flashAtom = atom({ plugin: 'mefisto', key: 'flash' } as const, null)
51const refineAtom = atom({ plugin: 'mefisto', key: 'refine' } as const, null)
52const developAtom = atom({ plugin: 'mefisto', key: 'develop' } as const, null)
53const updatedAtom = atom({ plugin: 'mefisto', key: 'updatedMs' } as const, 0)
54const signatureAtom = atom({ plugin: 'mefisto', key: 'signature' } as const, '')
55const frameAtom = atom({ plugin: 'mefisto', key: 'frame' } as const, 0)
56const stepsAtom = atom({ plugin: 'mefisto', key: 'stepsInTurn' } as const, 0)
57const workingAtom = atom({ plugin: 'mefisto', key: 'isWorking' } as const, false)
58const knownAtom = atom({ plugin: 'mefisto', key: 'known' } as const, [])
59
60let isInteractive = false
61let isRefreshing = false
62let settle: { cancel: () => void } | null = null
63let timer: { cancel: () => void } | null = null
64let animation: { cancel: () => void } | null = null
65const ANIMATION_MS = 600
66
67// Raiz del plugin: la que el engine expone como $.plugin.root; si faltara, el archivo que escribe el SessionStart del plugin.
68async function pluginRoot($: EngineInterface): Promise<string | null> {
69 const own = $.plugin.root
70 if (own) return own
71 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => ({ exitCode: 1, stdout: '' }))
72 const base = top.exitCode === 0 ? top.stdout.trim() : await $.session.cwd()
73 const root = await $.fs.read(`${base}/.mefisto/pipeline/.plugin-root`).catch(() => '')
74 return typeof root === 'string' && root.trim() !== '' ? root.trim() : null
75}
76
77async function runNextOrder($: EngineInterface, args: string[]): Promise<BoardList> {
78 const root = await pluginRoot($)
79 if (!root) return parseNextOrder(2, '', 'raiz del plugin desconocida')
80 const { exitCode, stdout, stderr } = await $.process
81 .run([nextOrderPath(root), ...args], { timeoutMs: SCRIPT_TIMEOUT_MS })
82 .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) }))
83 return parseNextOrder(exitCode, stdout, stderr)
84}
85
86async function refresh($: EngineInterface, isForced: boolean) {
87 if (isRefreshing || !(await read($, activeAtom))) return
88 isRefreshing = true
89 try {
90 const { exitCode, stdout } = await $.process
91 .run(['gh', 'issue', 'list', '--state', 'open', '--limit', '300', '--json', 'number,title,labels,updatedAt'])
92 .catch(() => ({ exitCode: 1, stdout: '' }))
93 const issues = exitCode === 0 ? parseOpenIssues(stdout) : null
94 if (!issues) return
95
96 const signature = signatureOf(issues)
97 if (!isForced && signature === (await read($, signatureAtom))) return
98
99 const focus = await read($, focusAtom)
100 for (const a of arrivals(issues, await read($, knownAtom))) {
101 if (!focus?.created.includes(a.number)) $.ui.toast(`Nuevo ${a.kind} #${a.number}: ${clip(a.title, 60)}`)
102 }
103 const [refine, develop] = await Promise.all([runNextOrder($, ['--refinement', '--json']), runNextOrder($, ['--json'])])
104 await update($, refineAtom, () => refine)
105 await update($, developAtom, () => develop)
106 await update($, signatureAtom, () => signature)
107 await update($, knownAtom, () => issues.map(i => i.number))
108 await update($, updatedAtom, () => Date.now())
109 } finally {
110 isRefreshing = false
111 }
112}
113
114async function activate($: EngineInterface) {
115 await update($, activeAtom, () => true)
116 timer?.cancel()
117 timer = $.clock.every(POLL_MS, () => void refresh($, false))
118 void refresh($, true)
119}
120
121async function deactivate($: EngineInterface) {
122 timer?.cancel()
123 timer = null
124 await update($, activeAtom, () => false)
125}
126
127const DETECT_TRIES = 20
128const DETECT_EVERY_MS = 3_000
129
130// El agente principal de la sesion. Los eventos clasicos no llegan a un modulo cargado con --plugin-dir,
131// asi que se lee primero la linea de comando del proceso de Claude Code (padre del `sh` que corre aqui) y,
132// si no se puede, el inicio del transcript, que solo existe tras el primer mensaje: por eso se reintenta.
133async function detectPlanner($: EngineInterface, triesLeft: number) {
134 if (!isInteractive || (await read($, activeAtom))) return
135 const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
136 const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
137 if (/\bclaude\b/.test(cmdline)) {
138 if (agentFlagOf(cmdline) === PLANNER_AGENT) await claimPlanner($)
139 return
140 }
141 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
142 const path = transcriptPathOf(home, await $.session.cwd(), await $.session.id())
143 const { stdout } = await $.process.run(['head', '-c', '200000', path]).catch(() => ({ stdout: '' }))
144 const agent = agentSettingOf(stdout)
145 if (agent === PLANNER_AGENT) {
146 await claimPlanner($)
147 return
148 }
149 if (agent === null && triesLeft > 0) $.clock.after(DETECT_EVERY_MS, () => void detectPlanner($, triesLeft - 1))
150}
151
152// Marca la sesion como del planner y la activa si ya se sabe que es interactiva. Devuelve si la activo ahora.
153async function claimPlanner($: EngineInterface): Promise<boolean> {
154 await update($, plannerAtom, () => true)
155 if (!isInteractive || (await read($, activeAtom))) return false
156 await activate($)
157 return true
158}
159
160// Cada instruccion del agente (una herramienta, o tu mensaje que abre el turno) alterna el cuadro A/B.
161async function step($: EngineInterface, isNewTurn: boolean) {
162 await update($, frameAtom, f => (f === 1 ? 0 : 1))
163 await update($, stepsAtom, n => (isNewTurn ? 0 : (n ?? 0) + 1))
164}
165
166// Recorte comun a todos los cuadros del planner, calculado una vez: sin margen y sin saltos al alternar.
167const MASCOT_STATES = ['planeando', 'pensando', 'listo'] as const
168const MASCOT_COLS = usedColumns(MASCOT_STATES.flatMap(state => [sprite('planner', state, 0), sprite('planner', state, 1)]))
169const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
170
171/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
172function mascotCells(grid: readonly string[]): string {
173 const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
174 const words: number[] = []
175 for (let y = 0; y < HEIGHT; y += 2) {
176 for (let x = 0; x < MASCOT_WIDTH; x++) {
177 const top = color(grid[y]?.[x])
178 const bottom = color(grid[y + 1]?.[x])
179 if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
180 else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
181 else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
182 }
183 }
184 let bin = ''
185 for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
186 return btoa(bin)
187}
188
189// Mientras Claude trabaja (pensando o con herramientas) la mascota alterna sola; en reposo queda quieta.
190async function setWorking($: EngineInterface, isWorking: boolean) {
191 await update($, workingAtom, () => isWorking)
192 animation?.cancel()
193 animation = isWorking ? $.clock.every(ANIMATION_MS, () => void update($, frameAtom, f => (f === 1 ? 0 : 1))) : null
194}
195
196async function startFocus($: EngineInterface, focus: BoardFocus) {
197 await update($, confirmAtom, () => false)
198 await update($, flashAtom, () => null)
199 await update($, focusAtom, () => focus)
200}
201
202async function closeFocus($: EngineInterface, summary: string) {
203 await update($, confirmAtom, () => false)
204 await update($, focusAtom, () => null)
205 await update($, flashAtom, () => ({ text: summary, untilMs: Date.now() + FLASH_MS }))
206 $.clock.after(FLASH_MS, () => void update($, flashAtom, () => null))
207 void refresh($, true)
208}
209
210function summaryOf(focus: BoardFocus, isListo: boolean): string {
211 const created = createdText(focus.created)
212 const head =
213 focus.kind === 'refinar'
214 ? `✓ #${focus.issue}${isListo ? ' → listo' : ' · refinamiento cerrado'}`
215 : `✓ exploración «${clip(focus.topic, 40)}»`
216 return [head, created].filter(Boolean).join(' · ')
217}
218
219async function onPrompt($: EngineInterface, text: string) {
220 const focus = await read($, focusAtom)
221 const target = refineTargetOf(text)
222 if (target !== null && !(focus?.kind === 'refinar' && focus.issue === target)) {
223 const known = (await read($, refineAtom))?.items.find(i => i.number === target)
224 await startFocus($, { kind: 'refinar', issue: target, topic: known?.title ?? '', created: [], startedMs: Date.now() })
225 return
226 }
227 if (!focus && target === null && text.trim() !== '' && !text.trim().startsWith('/')) {
228 await startFocus($, { kind: 'explorar', issue: null, topic: topicOf(text), created: [], startedMs: Date.now() })
229 }
230}
231
232function refreshAfterSettle($: EngineInterface) {
233 settle?.cancel()
234 settle = $.clock.after(SETTLE_MS, () => void refresh($, true))
235}
236
237async function onBash($: EngineInterface, command: string, output: string) {
238 if (isIssueChange(command)) refreshAfterSettle($)
239 const focus = await read($, focusAtom)
240 if (!focus) return
241 if (isIssueCreate(command)) {
242 const created = createdIssueOf(output)
243 if (created !== null && !focus.created.includes(created)) {
244 await update($, focusAtom, f => (f ? { ...f, created: [...f.created, created] } : f))
245 }
246 return
247 }
248 const listo = issueMarkedListo(command)
249 if (focus.kind === 'refinar' && listo !== null && listo === focus.issue) {
250 await closeFocus($, summaryOf(focus, true))
251 return
252 }
253 if (isPlannerClosing(command)) await closeFocus($, summaryOf(focus, false))
254}
255
256// Cierre de la exploracion en dos teclas dentro de la banda: el planner corre sin AskUserQuestion, asi
257// que $.ui.ask se rechaza. Al confirmar le pide su rutina de cierre (resumen + field note); el foco se
258// cierra solo cuando el planner corre field-note.sh.
259async function closeSession($: EngineInterface, isConfirmed: boolean) {
260 if (!isConfirmed) {
261 await update($, confirmAtom, () => true)
262 return
263 }
264 await update($, confirmAtom, () => false)
265 await $.prompt.submit({ text: 'Cerremos la sesión.', asUser: true })
266}
267
268// La misma tecla abre su lista o, si ya estaba abierta, la cierra.
269async function toggleList($: EngineInterface, tab: BoardTab) {
270 if (await read($, focusAtom)) return
271 const isOpen = (await read($, expandedAtom)) && (await read($, tabAtom)) === tab
272 await update($, tabAtom, () => tab)
273 await update($, pageAtom, () => 0)
274 await update($, expandedAtom, () => !isOpen)
275}
276
277// Avanza de pagina y da la vuelta al final.
278async function nextPage($: EngineInterface) {
279 const list = (await read($, tabAtom)) === 'borrador' ? await read($, refineAtom) : await read($, developAtom)
280 const tab = await read($, tabAtom)
281 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, pageSizeOf(tab))
282 await update($, pageAtom, () => (page + 1) % pages)
283}
284
285async function fill($: EngineInterface, text: string) {
286 await $.prompt.fill({ text, mode: 'replace' })
287}
288
289export const register: Register = on => {
290 on('session.start', async ($, e, next) => {
291 isInteractive = e.isInteractive
292 await $.command.register({
293 name: 'mefisto-board',
294 description: 'Tablero del planner: refresh | borradores | listos | cerrar | on | off',
295 argumentHint: '[refresh|borradores|listos|cerrar|on|off]',
296 immediate: true,
297 })
298 const isWanted = (await read($, activeAtom)) || (await read($, plannerAtom))
299 if (isInteractive && isWanted) await activate($)
300 if (isInteractive && (await read($, workingAtom))) await setWorking($, true)
301 else if (isInteractive) void detectPlanner($, DETECT_TRIES)
302 return next(e)
303 })
304
305 on('prompt.submit', async ($, e, next) => {
306 if (e.origin.kind === 'composer' && !(await read($, activeAtom))) await detectPlanner($, 0)
307 if (await read($, activeAtom)) {
308 await step($, true)
309 await setWorking($, true)
310 }
311 if (e.origin.kind === 'composer' && (await read($, activeAtom))) await onPrompt($, e.text)
312 return next(e)
313 }).catch(($, e, next) => next(e))
314
315 on('tool.call', async ($, e, next) => {
316 if (await read($, activeAtom)) await step($, false)
317 return next(e)
318 })
319
320 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
321 const ran = await next(e)
322 if (await read($, activeAtom)) {
323 const output = ran.text ?? JSON.stringify(ran.result ?? '')
324 await onBash($, e.command, output).catch(() => undefined)
325 }
326 return ran
327 })
328
329 on('turn.complete', async ($, e, next) => {
330 const done = await next(e)
331 if (await read($, activeAtom)) await setWorking($, false)
332 return done
333 })
334
335 on('command.run', { command: 'mefisto-board' }, async ($, e) => {
336 const arg = e.args.trim()
337 if (arg === 'off') {
338 await deactivate($)
339 return { text: 'Tablero del planner apagado en esta sesión.' }
340 }
341 if (arg === 'on' || !(await read($, activeAtom))) {
342 await activate($)
343 return { text: 'Tablero del planner activo en esta sesión.' }
344 }
345 if (arg === 'borradores' || arg === 'listos') {
346 await toggleList($, arg === 'borradores' ? 'borrador' : 'listo')
347 return { text: `Lista de ${arg} alternada.` }
348 }
349 if (arg === 'cerrar') {
350 const focus = await read($, focusAtom)
351 if (focus) await closeFocus($, summaryOf(focus, false))
352 return { text: focus ? 'Foco cerrado.' : 'No había foco abierto.' }
353 }
354 await refresh($, true)
355 return { text: 'Tablero actualizado.' }
356 })
357
358 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
359 if (!(await read($, activeAtom)) || e.props.hasSurvey) return next(e)
360 const { Box, Text, Button } = $.ui.resolve(e)
361 const focus = await read($, focusAtom)
362 const isConfirmingClose = await read($, confirmAtom)
363 const flash = await read($, flashAtom)
364 const isExpanded = await read($, expandedAtom)
365 const refine = await read($, refineAtom)
366 const develop = await read($, developAtom)
367 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
368 const count = (l: BoardList | null) => (l ? String(l.items.length) : '…')
369 const tab = await read($, tabAtom)
370 const suggested = refine?.items[0]?.number ?? null
371 const listButtons = (
372 <Box gap={2}>
373 <Button key="list-borrador" hotkey="3" plain variant={isExpanded && tab === 'borrador' ? 'primary' : undefined}
374 dimColor={!(isExpanded && tab === 'borrador')} label={`borradores ${count(refine)}`}
375 onPress={() => void toggleList($, 'borrador')} />
376 <Button key="list-listo" hotkey="4" plain variant={isExpanded && tab === 'listo' ? 'primary' : undefined}
377 dimColor={!(isExpanded && tab === 'listo')} label={`listos ${count(develop)}`}
378 onPress={() => void toggleList($, 'listo')} />
379 </Box>
380 )
381
382 let head
383 if (focus) {
384 const label =
385 focus.kind === 'refinar'
386 ? `refinar #${focus.issue}${focus.topic ? ` · ${focus.topic}` : ''}`
387 : `explorar · ${focus.topic}`
388 const created = createdText(focus.created)
389 head = (
390 <Box justifyContent="space-between">
391 <Box gap={2}>
392 <Text color="claude">●</Text>
393 <Text bold wrap="truncate-end">{clip(label, Math.max(20, inner - created.length - 46))}</Text>
394 {created !== '' && <Text color="success">{created}</Text>}
395 </Box>
396 {focus.kind === 'explorar' && !isConfirmingClose && (
397 <Button key="close-session" hotkey="1" plain label="cerrar sesión" onPress={() => void closeSession($, false)} />
398 )}
399 {focus.kind === 'explorar' && isConfirmingClose && (
400 <Box gap={2}>
401 <Text color="warning">¿cerrar? hará el resumen y la field note</Text>
402 <Button key="close-confirm" hotkey="1" plain label="sí, cerrar" onPress={() => void closeSession($, true)} />
403 <Button key="close-cancel" hotkey="2" plain label="seguir"
404 onPress={() => void update($, confirmAtom, () => false)} />
405 </Box>
406 )}
407 </Box>
408 )
409 } else if (flash && flash.untilMs > Date.now()) {
410 head = (
411 <Box justifyContent="space-between">
412 <Text color="success">{clip(flash.text, inner - 34)}</Text>
413 {listButtons}
414 </Box>
415 )
416 } else {
417 head = (
418 <Box justifyContent="space-between">
419 <Box gap={3}>
420 <Button key="mode-explorar" hotkey="1" plain label="explorar" onPress={() => void fill($, 'Quiero explorar: ')} />
421 {suggested !== null && (
422 <Button key="mode-refinar" hotkey="2" plain label={`refinar #${suggested}`}
423 onPress={() => void fill($, `Refina el borrador #${suggested}`)} />
424 )}
425 </Box>
426 {listButtons}
427 </Box>
428 )
429 }
430
431 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
432 const frame = await read($, frameAtom)
433 const isWorking = e.props.isWorking || (await read($, workingAtom))
434 const state = mascotPose(isWorking, await read($, stepsAtom), !!flash && flash.untilMs > Date.now())
435 const grid = cropGrid(sprite('planner', state, frame), MASCOT_COLS)
436 const elements = $.ui.resolve(e)
437 const mascot =
438 'Raster' in elements ? (
439 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
440 ) : (
441 <Box width={MASCOT_WIDTH} />
442 )
443
444 const updatedMs = await read($, updatedAtom)
445 let content
446 if (focus) {
447 const minutes = Math.max(0, Math.floor((Date.now() - (focus.startedMs ?? Date.now())) / 60_000))
448 const what = focus.kind === 'refinar' ? `Refinando #${focus.issue}` : 'Explorando'
449 const ends =
450 focus.kind === 'refinar'
451 ? `termina al pasar #${focus.issue} a estado:listo o con el cierre`
452 : 'termina con el cierre del planner'
453 content = (
454 <Box flexDirection="column" width={body}>
455 <Text bold color="claude">{what}{minutes > 0 ? ` · ${minutes} min` : ''}</Text>
456 <Text wrap="wrap">{clip(focus.topic || ' ', body * 2 - 2)}</Text>
457 <Text color={focus.created.length > 0 ? 'success' : undefined} dimColor={focus.created.length === 0}>
458 {focus.created.length > 0 ? createdText(focus.created) : 'sin borradores nuevos'}
459 </Text>
460 <Text dimColor wrap="truncate-end">{ends}</Text>
461 <Text dimColor>{focus.kind === 'explorar' ? '1 cierra la sesión (pide confirmar)' : '/mefisto-board cerrar lo cierra a mano'}</Text>
462 </Box>
463 )
464 } else if (!isExpanded) {
465 const nextRefine = refine?.items.slice(0, 2) ?? []
466 const nextDevelop = develop?.items[0]
467 content = (
468 <Box flexDirection="column" width={body}>
469 <Text dimColor>siguiente a refinar</Text>
470 {[0, 1].map(i => (
471 <Text key={`r-${i}`} dimColor={i > 0} wrap="truncate-end">
472 {nextRefine[i] ? `#${nextRefine[i].number} ${clip(nextRefine[i].title, body - 8)}` : ' '}
473 </Text>
474 ))}
475 <Text dimColor>siguiente a desarrollar</Text>
476 <Text wrap="truncate-end">{nextDevelop ? `#${nextDevelop.number} ${clip(nextDevelop.title, body - 8)}` : ' '}</Text>
477 <Text dimColor>{updatedMs ? ' ' : 'cargando…'}</Text>
478 </Box>
479 )
480 } else {
481 const list = tab === 'borrador' ? refine : develop
482 const size = pageSizeOf(tab)
483 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, size)
484 const visible = list?.items.slice(page * size, (page + 1) * size) ?? []
485 const blankRows = Math.max(0, size - Math.max(visible.length, 1))
486 const numWidth = numberWidth(visible)
487 // Solo 'tras #N' ocupa columna: sin dependencias no se marca.
488 const afterWidth = Math.min(16, Math.max(0, ...visible.map(i => reasonOf(i).length)))
489 // Ancho exacto de la fila: el prefijo '5: ' de la tecla (solo en borradores), el numero, un espacio
490 // y, si alguno depende de otro, la columna 'tras #N'. El titulo ocupa el resto hasta el marco.
491 const keyPrefix = tab === 'borrador' ? 3 : 0
492 const badgeWidth = 2
493 const titleWidth = Math.max(12, body - keyPrefix - badgeWidth - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
494 const more = list
495 ? [
496 list.blockedCount > 0 ? `${list.blockedCount} bloqueados` : '',
497 list.cycleCount > 0 ? `${list.cycleCount} en ciclo` : '',
498 ].filter(Boolean).join(' · ')
499 : ''
500 const hint = tab === 'borrador' ? '5-9 al prompt' : ''
501 content = (
502 <Box flexDirection="column" width={body}>
503 {list?.error && <Text color="error">next-order falló: {clip(list.error, body - 20)}</Text>}
504 {list && !list.error && visible.length === 0 && (
505 <Text dimColor>{tab === 'borrador' ? 'Sin borradores refinables.' : 'Sin issues lanzables.'}</Text>
506 )}
507 {visible.map((item, i) => {
508 const reason = reasonOf(item)
509 const badge = typeBadge(item.tipo)
510 return (
511 <Box>
512 <Text color={badge.color}>{`${badge.letter} `}</Text>
513 {tab === 'borrador' ? (
514 <Button
515 key={`item-${item.number}`}
516 hotkey={String(i + 5)}
517 plain
518 dimColor={page > 0 || i > 0}
519 label={`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
520 onPress={() => void fill($, `Refina el borrador #${item.number}`)}
521 />
522 ) : (
523 <Text dimColor={page > 0 || i > 0}>
524 {`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
525 </Text>
526 )}
527 <Text dimColor>{reason ? ` ${clip(reason, afterWidth)}` : ''}</Text>
528 </Box>
529 )
530 })}
531 {Array.from({ length: blankRows }, (_, i) => (
532 <Text key={`blank-${i}`}> </Text>
533 ))}
534 <Box justifyContent="space-between">
535 <Text dimColor>{hint}</Text>
536 <Box gap={2}>
537 {more !== '' && <Text dimColor>{more}</Text>}
538 {pages > 1 && (
539 <Button key="next-page" hotkey="0" plain dimColor label={`página ${page + 1}/${pages} ▸`} onPress={() => void nextPage($)} />
540 )}
541 </Box>
542 </Box>
543 </Box>
544 )
545 }
546
547 return (
548 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
549 {head}
550 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
551 {mascot}
552 <Box marginTop={1}>{content}</Box>
553 </Box>
554 </Box>
555 )
556 })
557}
558hooks/logic.ts 241 lines1import type { BoardItem, BoardList } from './types'
2
3export const NEXT_ORDER_FILE = 'scripts/next-order.sh'
4
5/** Ruta de next-order.sh bajo la raiz del plugin (`root` sin barra final). */
6export function nextOrderPath(root: string): string {
7 return `${root.replace(/\/+$/, '')}/${NEXT_ORDER_FILE}`
8}
9
10const TYPE_LETTERS: Record<string, { letter: string; color: string }> = {
11 feature: { letter: 'F', color: 'success' },
12 refactor: { letter: 'R', color: 'warning' },
13 projection: { letter: 'P', color: 'cyan' },
14 tooling: { letter: 'T', color: 'magenta' },
15 infra: { letter: 'I', color: 'blue' },
16}
17
18/** Letra y color del tipo (`tipo` de next-order --json); `?` gris si falta o es desconocido. */
19export function typeBadge(tipo: string | null | undefined): { letter: string; color: string } {
20 return (tipo ? TYPE_LETTERS[tipo] : undefined) ?? { letter: '?', color: 'gray' }
21}
22/** Filas fijas de la lista: la banda no cambia de alto con la cantidad de issues. */
23export const LIST_ROWS = 5
24
25type NextOrderJson = {
26 items?: { number: number; title: string; tipo?: string | null; after?: number[]; hasDepsSection?: boolean }[]
27 blocked?: unknown[]
28 cycles?: unknown[]
29 launch?: string | null
30}
31
32/** Exit 0 (hay orden) y 1 (vacio) traen JSON valido; 2 es un fallo de gh o de argumentos. */
33export function parseNextOrder(exitCode: number, stdout: string, stderr: string): BoardList {
34 if (exitCode !== 0 && exitCode !== 1) {
35 return { items: [], blockedCount: 0, cycleCount: 0, launch: null, error: firstLine(stderr) || `exit ${exitCode}` }
36 }
37 let json: NextOrderJson
38 try {
39 json = JSON.parse(stdout)
40 } catch {
41 return { items: [], blockedCount: 0, cycleCount: 0, launch: null, error: 'salida de next-order no es JSON' }
42 }
43 const items: BoardItem[] = (json.items ?? []).map(i => ({
44 number: i.number,
45 title: i.title,
46 tipo: i.tipo ?? null,
47 after: i.after ?? [],
48 hasDepsSection: i.hasDepsSection !== false,
49 }))
50 return {
51 items,
52 blockedCount: (json.blocked ?? []).length,
53 cycleCount: (json.cycles ?? []).length,
54 launch: json.launch ?? null,
55 error: null,
56 }
57}
58
59type OpenIssue = { number: number; title?: string; updatedAt?: string; labels?: { name: string }[] }
60
61export function parseOpenIssues(stdout: string): OpenIssue[] | null {
62 try {
63 const list = JSON.parse(stdout)
64 return Array.isArray(list) ? list : null
65 } catch {
66 return null
67 }
68}
69
70/** Cambia cuando entra, sale o se edita un issue abierto (labels o body actualizan updatedAt). */
71export function signatureOf(issues: OpenIssue[]): string {
72 return issues
73 .map(i => `${i.number}:${i.updatedAt ?? ''}:${(i.labels ?? []).map(l => l.name).sort().join(',')}`)
74 .sort()
75 .join('|')
76}
77
78export type Arrival = { number: number; title: string; kind: 'borrador' | 'listo' }
79
80/** Issues abiertos nuevos con estado borrador o listo. Sin conocidos previos (primer refresco) no hay avisos. */
81export function arrivals(issues: OpenIssue[], known: number[]): Arrival[] {
82 if (known.length === 0) return []
83 const seen = new Set(known)
84 const out: Arrival[] = []
85 for (const i of issues) {
86 if (seen.has(i.number)) continue
87 const names = (i.labels ?? []).map(l => l.name)
88 const kind = names.includes('estado:borrador') ? 'borrador' : names.includes('estado:listo') ? 'listo' : null
89 if (kind) out.push({ number: i.number, title: i.title ?? '', kind })
90 }
91 return out
92}
93
94/** De que depende el issue (`tras #A #B`), o vacio si no depende de nada abierto. */
95export function reasonOf(item: BoardItem): string {
96 return item.after.length > 0 ? `tras ${item.after.map(n => `#${n}`).join(' ')}` : ''
97}
98
99/** Ancho comun de la columna de numeros (#801 y #1981 alineados). */
100export function numberWidth(items: BoardItem[]): number {
101 return Math.max(4, ...items.map(i => String(i.number).length + 1))
102}
103
104export function padEnd(text: string, width: number): string {
105 return text.length >= width ? text : text + ' '.repeat(width - text.length)
106}
107
108export function clip(text: string, max: number): string {
109 const one = text.replace(/\s+/g, ' ').trim()
110 return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
111}
112
113export function clockOf(ms: number): string {
114 if (ms === 0) return ''
115 const d = new Date(ms)
116 const p = (n: number) => String(n).padStart(2, '0')
117 return `${p(d.getHours())}:${p(d.getMinutes())}`
118}
119
120function firstLine(text: string): string {
121 return text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
122}
123
124const REFINE = /\brefin\w*\s+(?:el\s+)?(?:borrador\s+)?#(\d+)/i
125
126/** `refina #801`, `Refina el borrador #801`, `refinemos #801`: el issue a refinar, o null. */
127export function refineTargetOf(prompt: string): number | null {
128 const m = REFINE.exec(prompt)
129 return m ? Number(m[1]) : null
130}
131
132/** El tema de una exploracion: la primera linea con contenido del mensaje, recortada. */
133export function topicOf(prompt: string): string {
134 const line = prompt.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
135 return clip(line.replace(/^quiero explorar:\s*/i, ''), 70)
136}
137
138/** Un cambio de issues hecho por el planner (crear, editar, cerrar, reabrir): sus listas pueden haber cambiado. */
139export function isIssueChange(command: string): boolean {
140 return /\bgh\s+issue\s+(create|edit|close|reopen)\b/.test(command)
141}
142
143export function isIssueCreate(command: string): boolean {
144 return /\bgh\s+issue\s+create\b/.test(command)
145}
146
147/** `gh issue edit 801 ... --add-label estado:listo`: el issue que pasa a listo, o null. */
148export function issueMarkedListo(command: string): number | null {
149 const m = /\bgh\s+issue\s+edit\s+#?(\d+)\b/.exec(command)
150 if (!m || !/--add-label[=\s]+["']?[^"'\s]*estado:listo/.test(command)) return null
151 return Number(m[1])
152}
153
154export function isPlannerClosing(command: string): boolean {
155 return /field-note\.sh/.test(command)
156}
157
158/** Numero del issue que `gh issue create` imprime como URL. */
159export function createdIssueOf(output: string): number | null {
160 const m = /\/issues\/(\d+)/.exec(output)
161 return m ? Number(m[1]) : null
162}
163
164export function createdText(created: number[]): string {
165 return created.length > 0 ? `creó ${created.map(n => `#${n}`).join(' ')}` : ''
166}
167
168/** Issues por pagina, igual en las dos listas. */
169export function pageSizeOf(_tab: 'borrador' | 'listo'): number {
170 return LIST_ROWS
171}
172
173/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
174export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
175 const pages = Math.max(1, Math.ceil(total / size))
176 return { page: page >= 0 && page < pages ? page : 0, pages }
177}
178
179/** Ruta del transcript de una sesion: ~/.claude/projects/<cwd con lo no alfanumerico como '-'>/<id>.jsonl. */
180export function transcriptPathOf(configDir: string, cwd: string, sessionId: string): string {
181 return `${configDir}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
182}
183
184/**
185 * `claude --agent mefisto:planner` deja en el transcript de la sesion filas
186 * `{"type":"agent-setting","agentSetting":"mefisto:planner"}`; manda la ultima. Un planner lanzado como
187 * subagente escribe en el transcript del subagente, nunca en el de la sesion que lo lanzo.
188 */
189export function agentSettingOf(transcript: string): string | null {
190 let found: string | null = null
191 for (const line of transcript.split('\n')) {
192 if (!line.includes('"agent-setting"')) continue
193 try {
194 const row = JSON.parse(line)
195 if (row.type === 'agent-setting' && typeof row.agentSetting === 'string') found = row.agentSetting
196 } catch {
197 continue
198 }
199 }
200 return found
201}
202
203/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
204export function agentFlagOf(cmdline: string): string | null {
205 const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
206 return m ? (m[1] ?? null) : null
207}
208
209export type MascotState = 'planeando' | 'pensando' | 'listo'
210
211/** Siempre la mascota del planner: trabajando con herramientas planea, sin ellas piensa y al cerrar un foco queda lista. */
212export function mascotPose(isWorking: boolean, stepsInTurn: number, hasFlash: boolean): MascotState {
213 if (isWorking) return stepsInTurn > 0 ? 'planeando' : 'pensando'
214 if (hasFlash) return 'listo'
215 return 'planeando'
216}
217
218type Grid = readonly string[]
219
220/** Columnas con algun pixel visible en cualquiera de los cuadros: recorta el margen sin que la mascota salte. */
221export function usedColumns(grids: readonly Grid[]): { from: number; to: number } {
222 let from = Infinity
223 let to = -1
224 for (const g of grids) {
225 for (const row of g) {
226 for (let x = 0; x < row.length; x++) {
227 const c = row[x]
228 if (c !== '.' && c !== ' ') {
229 from = Math.min(from, x)
230 to = Math.max(to, x)
231 }
232 }
233 }
234 }
235 return to < 0 ? { from: 0, to: 0 } : { from, to }
236}
237
238export function cropGrid(grid: Grid, cols: { from: number; to: number }): Grid {
239 return grid.map(row => row.slice(cols.from, cols.to + 1).padEnd(cols.to - cols.from + 1, '.'))
240}
241hooks/sprites.ts 730 lines1/**
2 * mefisto-sprites.ts
3 * Mascota Mefisto para mods de Claude Code.
4 *
5 * Cada sprite es una cuadrícula de 18×12 píxeles escrita como texto:
6 * cada carácter es un píxel y apunta a un color de PALETTE.
7 * '.' píxel transparente (en EMOTIONS)
8 * ' ' "no tocar" (en las capas de ROLES: deja ver lo de abajo)
9 *
10 * Un sprite se arma así: emoción (cara) + capa del rol (objeto y detalles)
11 * Cada estado tiene dos cuadros, A (0) y B (1), que se alternan con cada instrucción.
12 *
13 * Salidas:
14 * toRasterCells(grid) -> celdas empacadas para el elemento Raster (terminal), 18 columnas × 6 filas
15 * toSvg(grid) -> documento SVG para el elemento Svg (app de escritorio)
16 * ONE_LINERS -> versión de una línea para la status line (elemento Text)
17 */
18
19export type Grid = readonly string[]
20export type Frame = 0 | 1
21
22export const WIDTH = 18
23export const HEIGHT = 12
24/** Filas que ocupa el sprite en la terminal (dos píxeles por fila con ▀ / ▄). */
25export const RASTER_ROWS = HEIGHT / 2
26
27/** Color por defecto de la terminal (transparente), según la documentación de Raster. */
28export const DEFAULT_COLOR = 0x01000000
29
30/** Paleta: letra -> color 0xRRGGBB. */
31export const PALETTE: Record<string, number> = {
32 "h": 0xA32D2D, // cuernos
33 "r": 0xC4504D, // cuerpo (un poco mas opaco que el original 0xE24B4A)
34 "t": 0xA32D2D, // cola
35 "E": 0x2C2C2A, // ojos / pupilas
36 "M": 0x501313, // boca y cejas
37 "W": 0xF1EFE8, // colmillos
38 "p": 0xD4537E, // corazones y lengua
39 "s": 0xB4B2A9, // humo (enojado)
40 "B": 0x85B7EB, // Z y gota de sudor
41 "Y": 0xEF9F27, // bombillo, casco, flecha
42 "y": 0xFAC775, // destellos
43 "w": 0xFFF6D9, // brillo del bombillo
44 "G": 0x888780, // casquillo del bombillo
45 "o": 0xB4B2A9, // bombillo apagado
46 "D": 0x5F5E5A, // marco del portátil
47 "U": 0x378ADD, // pantalla
48 "u": 0xB5D4F4, // líneas de código
49 "q": 0xB4B2A9, // teclado / renglones
50 "Q": 0xFFFFFF, // blanco (pantalla, hoja)
51 "R": 0xE24B4A, // error / marcas rojas
52 "V": 0x639922, // verde de éxito
53 "g": 0x888780, // vidrio del matraz
54 "L": 0x97C459, // líquido del matraz
55 "l": 0xEAF3DE, // reflejo del líquido
56 "b": 0xC0DD97, // burbujas
57 "P": 0x7F77DD, // marco de la lupa
58 "e": 0xEEEDFE, // brillo de la lupa
59 "n": 0x854F0B, // mango de la lupa
60 "m": 0xE8C98E, // mapa claro
61 "j": 0xC9A866, // mapa oscuro
62 "Z": 0x444441, // ruta del mapa
63 "O": 0xD85A30, // destino del mapa
64 "c": 0xB5D4F4, // nube
65 "C": 0x888780, // nube caída
66}
67
68export type Emotion =
69 | 'normal'
70 | 'feliz'
71 | 'sonriente'
72 | 'enojado'
73 | 'dormido'
74 | 'asustado'
75 | 'pensando'
76 | 'preocupado'
77 | 'concentrado'
78
79/** Caras base: [cuadro A, cuadro B]. Solo 'pensando' cambia entre cuadros (bombillo encendido / apagado). */
80export const EMOTIONS: Record<Emotion, readonly [Grid, Grid]> = (() => {
81 const normalA: Grid = [
82 '..................',
83 '..................',
84 '....h..........h..',
85 '....hh........hh..',
86 '.....hrrrrrrrrh...',
87 '.....rrrrrrrrrr..t',
88 '.....rrEErrEErr.tt',
89 '.....rrEErrEErr..t',
90 '.....rrrrrrrrrr..t',
91 '......rrMMMMrr..t.',
92 '.......rrrrrrtt...',
93 '.......rr..rr.....',
94 ]
95 const felizA: Grid = [
96 '......p.p..p.p....',
97 '......ppp..ppp....',
98 '....h..p....p..h..',
99 '....hh........hh..',
100 '.....hrrrrrrrrh...',
101 '.....rrrrrrrrrr..t',
102 '.....rrEErrEErr.tt',
103 '.....rrrrrrrrrr..t',
104 '.....rMrrrrrrMr..t',
105 '......rMMWWMMr..t.',
106 '.......rrrrrrtt...',
107 '.......rr..rr.....',
108 ]
109 const sonrienteA: Grid = [
110 '..................',
111 '..................',
112 '....h..........h..',
113 '....hh........hh..',
114 '.....hrrrrrrrrh...',
115 '.....rrrrrrrrrr..t',
116 '.....rrEErrEErr.tt',
117 '.....rrrrrrrrrr..t',
118 '.....rMrrrrrrMr..t',
119 '......rMMWWMMr..t.',
120 '.......rrrrrrtt...',
121 '.......rr..rr.....',
122 ]
123 const enojadoA: Grid = [
124 '.....s.s......s.s.',
125 '......s........s..',
126 '....h..........h..',
127 '....hh........hh..',
128 '.....hrrrrrrrrh...',
129 '.....rMrrrrrrMr..t',
130 '.....rrMMrrMMrr.tt',
131 '.....rrEErrEErr..t',
132 '.....rrrrrrrrrr..t',
133 '......rrMMMMrr..t.',
134 '.......rMrrMrtt...',
135 '.......rr..rr.....',
136 ]
137 const dormidoA: Grid = [
138 '...........BBB....',
139 '............B.....',
140 '....h......BBB.h..',
141 '....hh........hh..',
142 '.....hrrrrrrrrh...',
143 '.....rrrrrrrrrr..t',
144 '.....rrrrrrrrrr.tt',
145 '.....rEErrrrEEr..t',
146 '.....rrrrrrrrrr..t',
147 '......rrrMMrrr..t.',
148 '.......rrrrrrtt...',
149 '.......rr..rr.....',
150 ]
151 const asustadoA: Grid = [
152 '.......y......y...',
153 '........y....y....',
154 '....h..........h..',
155 '....hh........hh..',
156 '.....hrMMrrMMrhB..',
157 '.....rrrrrrrrrrB.t',
158 '.....rrWWrrWWrr.tt',
159 '.....rrWErrWErr..t',
160 '.....rrrrMMrrrr..t',
161 '......rrrMMrrr..t.',
162 '.......rrrrrrtt...',
163 '.......rr..rr.....',
164 ]
165 const pensandoA: Grid = [
166 '......y..YY..y....',
167 '........YwYY......',
168 '....h...YYYY...h..',
169 '....hh...GG...hh..',
170 '.....hrrrrrrrrh...',
171 '.....rrrrrrrrrr..t',
172 '.....rrrEErrEEr.tt',
173 '.....rrrrrrrrrr..t',
174 '.....rrrrrrrrrr..t',
175 '......rrrMMrrr..t.',
176 '.......rrrrrrtt...',
177 '.......rr..rr.....',
178 ]
179 const pensandoB: Grid = [
180 '.........oo.......',
181 '........oooo......',
182 '....h...oooo...h..',
183 '....hh...GG...hh..',
184 '.....hrrrrrrrrh...',
185 '.....rrrrrrrrrr..t',
186 '.....rrrEErrEEr.tt',
187 '.....rrrrrrrrrr..t',
188 '.....rrrrrrrrrr..t',
189 '......rrrMMrrr..t.',
190 '.......rrrrrrtt...',
191 '.......rr..rr.....',
192 ]
193 const preocupadoA: Grid = [
194 '.................R',
195 '.................R',
196 '....h..........h..',
197 '....hh........hh.R',
198 '.....hrrrrrrrrh...',
199 '....BrrrMrrMrrr..t',
200 '....BrrMrrrrMrr.tt',
201 '.....rrEErrEErr..t',
202 '.....rrrrrrrrrr..t',
203 '......rrrMrMrr..t.',
204 '.......rMrMrrtt...',
205 '.......rr..rr.....',
206 ]
207 const concentradoA: Grid = [
208 '..................',
209 '..................',
210 '....h..........h..',
211 '....hh........hh..',
212 '.....hrrrrrrrrh...',
213 '.....rrrrrrrrrr..t',
214 '.....rrEErrMMrr.tt',
215 '.....rrEErrEErr..t',
216 '.....rrrrrrrrrr..t',
217 '......rrrMMrrr..t.',
218 '.......rrrprrtt...',
219 '.......rr..rr.....',
220 ]
221 return {
222 normal: [normalA, normalA],
223 feliz: [felizA, felizA],
224 sonriente: [sonrienteA, sonrienteA],
225 enojado: [enojadoA, enojadoA],
226 dormido: [dormidoA, dormidoA],
227 asustado: [asustadoA, asustadoA],
228 pensando: [pensandoA, pensandoB],
229 preocupado: [preocupadoA, preocupadoA],
230 concentrado: [concentradoA, concentradoA],
231 }
232})()
233
234export type Role = 'desarrollador' | 'tester' | 'revisor' | 'planner' | 'infraestructura'
235
236export interface RoleState {
237 /** Cara que usa este estado. */
238 emotion: Emotion
239 /** Capas del rol para el cuadro A y el B. ' ' deja ver la cara. */
240 layers: readonly [Grid, Grid]
241}
242
243/** Objetos y detalles de cada rol, por estado de trabajo. */
244export const ROLES: Record<Role, Record<string, RoleState>> = (() => {
245 // Desarrollador · portátil, azul
246 const desarrollador_trabajando_A: Grid = [
247 ' ',
248 ' ',
249 ' ',
250 ' ',
251 ' ',
252 ' ',
253 ' ',
254 'DDDDD ',
255 'DUuUD ',
256 'DuUUD ',
257 'DDDDD ',
258 'qqqqqqq ',
259 ]
260 const desarrollador_trabajando_B: Grid = [
261 ' ',
262 ' ',
263 ' ',
264 ' ',
265 ' ',
266 ' ',
267 ' ',
268 'DDDDD ',
269 'DuUUD ',
270 'DUUuD ',
271 'DDDDD ',
272 'qqqqqqq ',
273 ]
274 const desarrollador_pensando_A: Grid = [
275 ' ',
276 ' ',
277 ' ',
278 ' ',
279 ' ',
280 ' ',
281 ' ',
282 'DDDDD ',
283 'DUuUD ',
284 'DuUUD ',
285 'DDDDD ',
286 'qqqqqqq ',
287 ]
288 const desarrollador_error_A: Grid = [
289 ' ',
290 ' ',
291 ' ',
292 ' ',
293 ' ',
294 ' ',
295 ' ',
296 'DDDDD ',
297 'DQRQD ',
298 'DRQRD ',
299 'DDDDD ',
300 'qqqqqqq ',
301 ]
302 const desarrollador_error_B: Grid = [
303 ' ',
304 ' ',
305 ' ',
306 ' ',
307 ' ',
308 ' ',
309 ' ',
310 'DDDDD ',
311 'DRQRD ',
312 'DQRQD ',
313 'DDDDD ',
314 'qqqqqqq ',
315 ]
316 const desarrollador_resuelto_A: Grid = [
317 ' ',
318 ' ',
319 ' ',
320 ' ',
321 ' ',
322 ' ',
323 ' ',
324 'DDDDD ',
325 'DVVQD ',
326 'DQQVD ',
327 'DDDDD ',
328 'qqqqqqq ',
329 ]
330 // Tester · matraz, verde
331 const tester_trabajando_A: Grid = [
332 ' ',
333 ' ',
334 ' ',
335 ' ',
336 ' ',
337 ' b ',
338 ' b ',
339 ' ggg ',
340 ' g ',
341 ' LLL ',
342 'LlLLL ',
343 'LLLLL ',
344 ]
345 const tester_trabajando_B: Grid = [
346 ' ',
347 ' ',
348 ' ',
349 ' ',
350 ' ',
351 ' b ',
352 ' b ',
353 ' ggg ',
354 ' g ',
355 ' LLL ',
356 'LlLLL ',
357 'LLLLL ',
358 ]
359 const tester_pensando_A: Grid = [
360 ' ',
361 ' ',
362 ' ',
363 ' ',
364 ' ',
365 ' b ',
366 ' b ',
367 ' ggg ',
368 ' g ',
369 ' LLL ',
370 'LlLLL ',
371 'LLLLL ',
372 ]
373 const tester_feliz_A: Grid = [
374 ' ',
375 ' ',
376 ' ',
377 ' ',
378 ' ',
379 ' b ',
380 ' b ',
381 ' ggg ',
382 ' g ',
383 ' LLL ',
384 'LlLLL ',
385 'LLLLL ',
386 ]
387 const tester_feliz_B: Grid = [
388 ' ',
389 ' ',
390 ' ',
391 ' ',
392 ' ',
393 ' b ',
394 ' b ',
395 ' ggg ',
396 ' g ',
397 ' LLL ',
398 'LlLLL ',
399 'LLLLL ',
400 ]
401 // Revisor · lupa, morado
402 const revisor_trabajando_A: Grid = [
403 ' ',
404 ' ',
405 ' ',
406 ' ',
407 ' ',
408 ' PP ',
409 ' PeEP ',
410 ' PEEP ',
411 ' PP ',
412 ' n ',
413 ' n ',
414 ' n ',
415 ]
416 const revisor_trabajando_B: Grid = [
417 ' ',
418 ' ',
419 ' ',
420 ' ',
421 ' ',
422 ' PP ',
423 ' PEEP ',
424 ' PEeP ',
425 ' PP ',
426 ' n ',
427 ' n ',
428 ' n ',
429 ]
430 const revisor_pensando_A: Grid = [
431 ' ',
432 ' ',
433 ' ',
434 ' ',
435 ' ',
436 ' PP ',
437 ' PeEP ',
438 ' PEEP ',
439 ' PP ',
440 ' n ',
441 ' n ',
442 ' n ',
443 ]
444 const revisor_corrigiendo_A: Grid = [
445 ' ',
446 ' ',
447 ' ',
448 ' ',
449 ' ',
450 ' PP ',
451 ' PeEP ',
452 'QQQ PEEP ',
453 'qqR PP ',
454 'QQQ n ',
455 'qRq n ',
456 'QQQn ',
457 ]
458 const revisor_corrigiendo_B: Grid = [
459 ' ',
460 ' ',
461 ' ',
462 ' ',
463 ' ',
464 ' PP ',
465 ' PEEP ',
466 'QQQ PEeP ',
467 'qqR PP ',
468 'QQQ n ',
469 'qRq n ',
470 'QQQn ',
471 ]
472 const revisor_aprobado_A: Grid = [
473 ' ',
474 ' ',
475 ' ',
476 ' ',
477 ' ',
478 ' PP ',
479 ' PeEP ',
480 'QQQ PEEP ',
481 'QQV PP ',
482 'VQV n ',
483 'QVQ n ',
484 'QQQn ',
485 ]
486 // Planner · mapa, coral
487 const planner_planeando_A: Grid = [
488 ' ',
489 ' ',
490 ' ',
491 ' ',
492 ' ',
493 ' ',
494 'mjmjO ',
495 'mjmjm ',
496 'mjmjm ',
497 'mZmjm ',
498 'Zjmjm ',
499 ' ',
500 ]
501 const planner_planeando_B: Grid = [
502 ' ',
503 ' ',
504 ' ',
505 ' ',
506 ' ',
507 ' ',
508 'mjmjO ',
509 'mjmjm ',
510 'mjZjm ',
511 'mZmjm ',
512 'Zjmjm ',
513 ' ',
514 ]
515 const planner_pensando_A: Grid = [
516 ' ',
517 ' ',
518 ' ',
519 ' ',
520 ' ',
521 ' ',
522 'mjmjO ',
523 'mjmjm ',
524 'mjZjm ',
525 'mZmjm ',
526 'Zjmjm ',
527 ' ',
528 ]
529 const planner_listo_A: Grid = [
530 ' ',
531 ' ',
532 ' ',
533 ' ',
534 ' ',
535 ' ',
536 'mjmjV ',
537 'mjmZm ',
538 'mjZjm ',
539 'mZmjm ',
540 'Zjmjm ',
541 ' ',
542 ]
543 // Infraestructura · nube y casco, amarillo
544 const infraestructura_desplegando_A: Grid = [
545 ' ',
546 ' ',
547 ' YyYY ',
548 ' YyYYYY ',
549 ' YYYYYYYY ',
550 ' cc ',
551 'cccc ',
552 'ccccc ',
553 'ccccc ',
554 ' Y ',
555 ' YYY ',
556 ' Y ',
557 ]
558 const infraestructura_desplegando_B: Grid = [
559 ' ',
560 ' ',
561 ' YyYY ',
562 ' YyYYYY ',
563 ' YYYYYYYY ',
564 ' cc ',
565 'cccc ',
566 'ccccc ',
567 'ccYcc ',
568 ' YYY ',
569 ' Y ',
570 ' ',
571 ]
572 const infraestructura_caido_A: Grid = [
573 ' ',
574 ' ',
575 ' YyYY ',
576 ' YyYYYY ',
577 ' YYYYYYYY ',
578 ' CC ',
579 'CCCC ',
580 'CCCCC ',
581 'CCCCC ',
582 ' RR ',
583 ' R ',
584 ' R ',
585 ]
586 const infraestructura_arriba_A: Grid = [
587 ' ',
588 ' ',
589 ' YyYY ',
590 ' YyYYYY ',
591 ' YYYYYYYY ',
592 ' cc ',
593 'cccc ',
594 'ccccc ',
595 'ccccc ',
596 ' V ',
597 ' V V ',
598 ' V ',
599 ]
600 return {
601 desarrollador: {
602 trabajando: { emotion: 'normal', layers: [desarrollador_trabajando_A, desarrollador_trabajando_B] },
603 pensando: { emotion: 'pensando', layers: [desarrollador_pensando_A, desarrollador_pensando_A] },
604 error: { emotion: 'preocupado', layers: [desarrollador_error_A, desarrollador_error_B] },
605 resuelto: { emotion: 'sonriente', layers: [desarrollador_resuelto_A, desarrollador_resuelto_A] },
606 },
607 tester: {
608 trabajando: { emotion: 'normal', layers: [tester_trabajando_A, tester_trabajando_B] },
609 pensando: { emotion: 'pensando', layers: [tester_pensando_A, tester_pensando_A] },
610 feliz: { emotion: 'feliz', layers: [tester_feliz_A, tester_feliz_B] },
611 },
612 revisor: {
613 trabajando: { emotion: 'normal', layers: [revisor_trabajando_A, revisor_trabajando_B] },
614 pensando: { emotion: 'pensando', layers: [revisor_pensando_A, revisor_pensando_A] },
615 corrigiendo: { emotion: 'concentrado', layers: [revisor_corrigiendo_A, revisor_corrigiendo_B] },
616 aprobado: { emotion: 'sonriente', layers: [revisor_aprobado_A, revisor_aprobado_A] },
617 },
618 planner: {
619 planeando: { emotion: 'normal', layers: [planner_planeando_A, planner_planeando_B] },
620 pensando: { emotion: 'pensando', layers: [planner_pensando_A, planner_pensando_A] },
621 listo: { emotion: 'sonriente', layers: [planner_listo_A, planner_listo_A] },
622 },
623 infraestructura: {
624 desplegando: { emotion: 'normal', layers: [infraestructura_desplegando_A, infraestructura_desplegando_B] },
625 caido: { emotion: 'asustado', layers: [infraestructura_caido_A, infraestructura_caido_A] },
626 arriba: { emotion: 'sonriente', layers: [infraestructura_arriba_A, infraestructura_arriba_A] },
627 },
628 }
629})()
630
631/** Versión de una línea por emoción, para la status line. */
632export const ONE_LINERS: Record<Emotion, string> = {
633 normal: "ψ(•‿•)",
634 feliz: "ψ(^▽^)♥",
635 sonriente: "ψ(^▽^)",
636 enojado: "ψ(ಠ益ಠ)",
637 dormido: "ψ(-.-)zz",
638 asustado: "ψ(°o°)!",
639 pensando: "ψ(•_•)💡",
640 preocupado: "ψ(°~°);",
641 concentrado: "ψ(•ᴗ<)",
642}
643
644/** Pone una capa encima de una cuadrícula. En la capa, ' ' significa "no tocar". */
645export function overlay(base: Grid, layer: Grid): Grid {
646 return base.map((row, y) => {
647 const top = layer[y] ?? ''
648 let out = ''
649 for (let x = 0; x < WIDTH; x++) {
650 const c = top[x]
651 out += c && c !== ' ' ? c : row[x] ?? '.'
652 }
653 return out
654 })
655}
656
657/** Arma el sprite de un rol en un estado y un cuadro. */
658export function sprite(role: Role, state: string, frame: Frame = 0): Grid {
659 const def = ROLES[role]?.[state]
660 if (!def) throw new Error(`Estado desconocido: ${role}.${state}`)
661 return overlay(EMOTIONS[def.emotion][frame], def.layers[frame])
662}
663
664/** Sprite solo con la cara, sin objeto de rol. */
665export function face(emotion: Emotion, frame: Frame = 0): Grid {
666 return EMOTIONS[emotion][frame]
667}
668
669function colorOf(ch: string | undefined): number | null {
670 if (!ch || ch === '.' || ch === ' ') return null
671 const c = PALETTE[ch]
672 if (c === undefined) throw new Error(`Letra sin color en PALETTE: '${ch}'`)
673 return c
674}
675
676const UPPER = '▀'.codePointAt(0)!
677const LOWER = '▄'.codePointAt(0)!
678const SPACE = ' '.codePointAt(0)!
679
680/**
681 * Convierte una cuadrícula en las celdas empacadas que recibe el elemento Raster.
682 * Cada celda junta dos píxeles verticales: ▀ con el de arriba como color y el de abajo como fondo.
683 * Resultado: columns = WIDTH (18), rows = RASTER_ROWS (6).
684 */
685export function toRasterCells(grid: Grid): string {
686 const numbers: number[] = []
687 for (let y = 0; y < HEIGHT; y += 2) {
688 for (let x = 0; x < WIDTH; x++) {
689 const top = colorOf(grid[y]?.[x])
690 const bottom = colorOf(grid[y + 1]?.[x])
691 if (top !== null && bottom !== null) numbers.push(UPPER, top, bottom)
692 else if (top !== null) numbers.push(UPPER, top, DEFAULT_COLOR)
693 else if (bottom !== null) numbers.push(LOWER, bottom, DEFAULT_COLOR)
694 else numbers.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
695 }
696 }
697 const bytes = new Uint8Array(Uint32Array.from(numbers).buffer)
698 const anyBytes = bytes as Uint8Array & { toBase64?: () => string }
699 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
700 let bin = ''
701 for (const b of bytes) bin += String.fromCharCode(b)
702 return btoa(bin)
703}
704
705/** Props listas para Raster({ ...rasterProps(grid, 'mefisto') }). */
706export function rasterProps(grid: Grid, key: string) {
707 return { key, columns: WIDTH, rows: RASTER_ROWS, cells: toRasterCells(grid) }
708}
709
710/** Convierte una cuadrícula en un documento SVG (para la app de escritorio). */
711export function toSvg(grid: Grid, scale = 6): string {
712 const rects: string[] = []
713 grid.forEach((row, y) => {
714 let x = 0
715 while (x < WIDTH) {
716 const ch = row[x]
717 const c = colorOf(ch)
718 if (c === null) { x++; continue }
719 let end = x
720 while (end < WIDTH && row[end] === ch) end++
721 const hex = '#' + c.toString(16).padStart(6, '0')
722 rects.push(`<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${hex}"/>`)
723 x = end
724 }
725 })
726 const w = WIDTH * scale
727 const h = HEIGHT * scale
728 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">${rects.join('')}</svg>`
729}
730hooks/types/index.d.ts 53 lines1export type BoardItem = {
2 number: number
3 title: string
4 tipo: string | null
5 after: number[]
6 hasDepsSection: boolean
7}
8
9export type BoardList = {
10 items: BoardItem[]
11 blockedCount: number
12 cycleCount: number
13 launch: string | null
14 error: string | null
15}
16
17export type BoardTab = 'borrador' | 'listo'
18
19/** Lo que el planner esta haciendo: refinar un borrador o explorar una idea. */
20export type BoardFocus = {
21 kind: 'refinar' | 'explorar'
22 issue: number | null
23 topic: string
24 created: number[]
25 startedMs: number
26}
27
28/** Resumen breve que la banda muestra al cerrar un foco. */
29export type BoardFlash = { text: string; untilMs: number }
30
31declare module 'claude-code' {
32 interface PluginState {
33 mefisto: {
34 isActive: boolean
35 isPlannerSession: boolean
36 isExpanded: boolean
37 tab: BoardTab
38 page: number
39 frame: 0 | 1
40 stepsInTurn: number
41 isWorking: boolean
42 focus: BoardFocus | null
43 isConfirmingClose: boolean
44 flash: BoardFlash | null
45 refine: BoardList | null
46 develop: BoardList | null
47 updatedMs: number
48 signature: string
49 known: number[]
50 }
51 }
52}
53