Tablero del planner de Mefisto sobre el prompt: borradores en orden de refinamiento e issues listos en orden de lanzamiento.

Mod de Claude Code para la sesion del planner (MEF-ADR-0055): una banda sobre el prompt que dice que se esta haciendo (refinar #N o explorar un tema) y da acceso a los borradores y listos en el orden de mefisto-next-order.sh.
Una sola vez por checkout. Los mods se instalan en alcance local (.claude/settings.local.json, no versionado) desde el marketplace de carpeta .claude/mods/:
claude plugin marketplace add ./.claude/mods --scope local
claude plugin install mefisto-divine-wager@mefisto-mods --scope local
claude plugin install mefisto-planner-board@mefisto-mods --scope local
Desde ahi cargan solos en toda sesion de este checkout, sin --plugin-dir, y se leen de la carpeta: un cambio se aplica con /reload-plugins. Los worktrees de los pipelines no tienen settings.local.json, asi que sus sesiones -p no los cargan. Para probar una copia suelta: claude --plugin-dir .claude/mods/<mod>.
Solo se activa en una sesion interactiva cuyo proceso es claude --agent mefisto-planner (lo lee de la linea de comando del proceso, o del transcript como respaldo). Un planner lanzado como subagente o con -p no lo ve. En otra sesion, /mefisto-planner-board on lo enciende a mano.
/clear, /resume y /branch conservan el tablero: reaparece en reposo en menos de un segundo y recarga las listas sin esperar un mensaje (solo si estaba activo; una sesion que no es del planner y no se encendio a mano no lo enciende). Lo reactiva un reloj de un segundo que compara la intencion del modulo con el atom: session.start no se repite en esos casos, el engine no entrega eventos classic.* a un plugin de tier user y un ui.render no puede hacerlo porque dibujar es puro y el engine rechaza su state.set. /mefisto-planner-board off dura toda la sesion, tambien tras mensajes y /clear, hasta un /mefisto-planner-board on.
refina #N; termina cuando #N pasa a estado:listo o con el cierre del planner (mefisto-field-note.sh).gh issue create de ambos se anotan como creó #….Con foco, la cabecera dice que se hace y desde cuando (● refinar #N · 4 min) y el cuerpo junto a la mascota lo que la conversacion no muestra:
#N ✓ si ya cerro, o su estado) y a quien bloquea (los de next-order que van tras el). Se relee con cada cambio de issues.#N titulo · tipo:x).| Tecla | Accion |
|---|---|
1 / 2 | En reposo: Quiero explorar: / Refina el borrador #N en el prompt |
3 / 4 | Abre o cierra la lista de borradores / listos. En explorar, tras crear el primer borrador la banda muestra sola la lista de borradores (5-9, 0 pagina) y un borrador nuevo la refresca al instante; sin creados se ve el tema |
5-9 | Borradores: Refina el borrador #N en el prompt. Listos es solo lectura: el planner no lanza trabajo |
0 | Siguiente pagina |
/mefisto-planner-board [refresh|borradores|listos|cerrar|cerrar-sesion|on|off] cubre lo mismo sin teclas. cerrar-sesion hace lo mismo que el boton 1: cerrar sesión: pide al planner su rutina de cierre sin confirmar (cerrar solo cierra el foco, sin pedirla).
Un digito con el prompt vacio solo presiona un boton si la banda lo tiene armado: no lo esta con la banda colapsada, con el boton fuera de la ventana visible o con una encuesta en la banda. Si no, el digito se escribe en el prompt y viaja como mensaje. Se vio una vez, intermitente, con 1 en explorar justo tras pegar una imagen (#2210, sin reproducir). El mod deja en el log de debug cada render de la cabecera (foco, teclas dibujadas, isWorking, hasSurvey, maxRows) y cada ui.press: si falla de nuevo, ese log dice si el boton estaba en el arbol y si llego la pulsacion. Mientras tanto, usar /mefisto-planner-board cerrar-sesion.
claude plugin validate .claude/mods/mefisto-planner-board
claude plugin test .claude/mods/mefisto-planner-board
Cuando el planner pasa #N a estado:listo o lo cierra (gh issue close N), el tablero lo quita de la lista de borradores en el acto, antes de cualquier refresco: "siguiente a refinar", 2 refinar #N y las teclas 5-9 apuntan ya a la lista corregida. #N queda como transicion pendiente y todo refresco lo filtra de borradores hasta que GitHub confirme el cambio (o pasen 60 s); mientras tanto el contador muestra actualizando…. La lista de listos no se toca de forma optimista: #N aparece cuando un refresco lo trae, en el orden de mefisto-next-order.sh (MEF-ADR-0055, decisiones 1, 8 y 11).
hooks/register.tsx 626 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardFocus, BoardList, BoardTab } from '../types'
5import {
6 NEXT_ORDER,
7 agentFlagOf,
8 agentSettingOf,
9 transcriptPathOf,
10 arrivals,
11 blockedBy,
12 blocksText,
13 briefsOf,
14 clip,
15 createdCardText,
16 createdIssueOf,
17 createdText,
18 showsDraftList,
19 cropGrid,
20 depsText,
21 labelsText,
22 minutesSince,
23 parseIssueCard,
24 isIssueChange,
25 isIssueCreate,
26 usedColumns,
27 mascotPose,
28 isPlannerClosing,
29 issueMarkedListo,
30 issueClosed,
31 PENDING_TTL_MS,
32 settlePending,
33 withoutItems,
34 numberWidth,
35 padEnd,
36 pageOf,
37 pageSizeOf,
38 parseNextOrder,
39 parseOpenIssues,
40 reasonOf,
41 refineTargetOf,
42 signatureOf,
43 topicOf,
44} from './logic'
45import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, sprite } from './sprites'
46
47const PLANNER_AGENT = 'mefisto-planner'
48const POLL_MS = 60_000
49const SCRIPT_TIMEOUT_MS = 180_000
50const FLASH_MS = 8_000
51// GitHub tarda en reflejar un cambio en todas sus consultas: un refresco inmediato puede guardar la firma nueva
52// con listas viejas de next-order y el poll ya no vuelve a refrescar. Se repite forzado tras este margen.
53const SETTLE_MS = 15_000
54
55const activeAtom = atom({ plugin: 'mefisto-planner-board', key: 'isActive' } as const, false)
56const plannerAtom = atom({ plugin: 'mefisto-planner-board', key: 'isPlannerSession' } as const, false)
57const expandedAtom = atom({ plugin: 'mefisto-planner-board', key: 'isExpanded' } as const, false)
58const pageAtom = atom({ plugin: 'mefisto-planner-board', key: 'page' } as const, 0)
59const tabAtom = atom({ plugin: 'mefisto-planner-board', key: 'tab' } as const, 'borrador')
60const focusAtom = atom({ plugin: 'mefisto-planner-board', key: 'focus' } as const, null)
61const flashAtom = atom({ plugin: 'mefisto-planner-board', key: 'flash' } as const, null)
62const refineAtom = atom({ plugin: 'mefisto-planner-board', key: 'refine' } as const, null)
63const developAtom = atom({ plugin: 'mefisto-planner-board', key: 'develop' } as const, null)
64const updatedAtom = atom({ plugin: 'mefisto-planner-board', key: 'updatedMs' } as const, 0)
65const signatureAtom = atom({ plugin: 'mefisto-planner-board', key: 'signature' } as const, '')
66const frameAtom = atom({ plugin: 'mefisto-planner-board', key: 'frame' } as const, 0)
67const stepsAtom = atom({ plugin: 'mefisto-planner-board', key: 'stepsInTurn' } as const, 0)
68const workingAtom = atom({ plugin: 'mefisto-planner-board', key: 'isWorking' } as const, false)
69const knownAtom = atom({ plugin: 'mefisto-planner-board', key: 'known' } as const, [])
70const cardAtom = atom({ plugin: 'mefisto-planner-board', key: 'card' } as const, null)
71const openAtom = atom({ plugin: 'mefisto-planner-board', key: 'open' } as const, [])
72
73const pendingAtom = atom({ plugin: 'mefisto-planner-board', key: 'pendingCount' } as const, 0)
74
75let pending = new Map<number, number>()
76let isInteractive = false
77// La intencion vive en el modulo, no en atoms: /clear abre una sesion nueva con los atoms en blanco, el modulo sigue.
78let isBoardOn = false
79let isTurnedOff = false
80let isRefreshing = false
81let settle: { cancel: () => void } | null = null
82let timer: { cancel: () => void } | null = null
83let animation: { cancel: () => void } | null = null
84const ANIMATION_MS = 600
85
86async function runNextOrder($: EngineInterface, args: string[]): Promise<BoardList> {
87 const { exitCode, stdout, stderr } = await $.process
88 .run([NEXT_ORDER, ...args], { timeoutMs: SCRIPT_TIMEOUT_MS })
89 .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) }))
90 return parseNextOrder(exitCode, stdout, stderr)
91}
92
93async function refresh($: EngineInterface, isForced: boolean) {
94 if (isRefreshing || !(await read($, activeAtom))) return
95 isRefreshing = true
96 try {
97 const { exitCode, stdout } = await $.process
98 .run(['gh', 'issue', 'list', '--state', 'open', '--limit', '300', '--json', 'number,title,labels,updatedAt'])
99 .catch(() => ({ exitCode: 1, stdout: '' }))
100 const issues = exitCode === 0 ? parseOpenIssues(stdout) : null
101 if (!issues) return
102
103 const signature = signatureOf(issues)
104 if (!isForced && pending.size === 0 && signature === (await read($, signatureAtom))) return
105
106 const focus = await read($, focusAtom)
107 await update($, openAtom, () => briefsOf(issues))
108 if (focus?.kind === 'refinar' && focus.issue !== null) await loadCard($, focus.issue)
109 for (const a of arrivals(issues, await read($, knownAtom))) {
110 if (!focus?.created.includes(a.number)) $.ui.toast(`Nuevo ${a.kind} #${a.number}: ${clip(a.title, 60)}`)
111 }
112 const [refine, develop] = await Promise.all([runNextOrder($, ['--refinement', '--json']), runNextOrder($, ['--json'])])
113 pending = settlePending(pending, refine.error ? null : refine.items.map(i => i.number), Date.now())
114 await update($, refineAtom, () => withoutItems(refine, pending.keys()))
115 await update($, developAtom, () => develop)
116 await update($, pendingAtom, () => pending.size)
117 await update($, signatureAtom, () => signature)
118 await update($, knownAtom, () => issues.map(i => i.number))
119 await update($, updatedAtom, () => Date.now())
120 } finally {
121 isRefreshing = false
122 }
123}
124
125async function loadCard($: EngineInterface, issue: number) {
126 const { exitCode, stdout } = await $.process
127 .run(['gh', 'issue', 'view', String(issue), '--json', 'number,title,labels,body'])
128 .catch(() => ({ exitCode: 1, stdout: '' }))
129 const card = exitCode === 0 ? parseIssueCard(stdout) : null
130 if (card) await update($, cardAtom, () => card)
131}
132
133async function activate($: EngineInterface) {
134 isBoardOn = true
135 await update($, activeAtom, () => true)
136 timer?.cancel()
137 timer = $.clock.every(POLL_MS, () => void refresh($, false))
138 void refresh($, true)
139}
140
141async function deactivate($: EngineInterface) {
142 isBoardOn = false
143 isTurnedOff = true
144 pending.clear()
145 timer?.cancel()
146 timer = null
147 await update($, activeAtom, () => false)
148}
149
150// El engine no entrega eventos classic.* a un plugin de tier user y session.start no se repite tras /clear,
151// /resume ni /branch: cada segundo se compara la intencion del modulo con el atom y se rearma el tablero.
152const HEAL_MS = 1_000
153
154async function heal($: EngineInterface) {
155 if (isBoardOn && !isTurnedOff && !(await read($, activeAtom))) await activate($)
156}
157
158const DETECT_TRIES = 20
159const DETECT_EVERY_MS = 3_000
160
161// El agente principal de la sesion. Los eventos clasicos no llegan a un plugin de tier user,
162// asi que se lee primero la linea de comando del proceso de Claude Code (padre del `sh` que corre aqui) y,
163// si no se puede, el inicio del transcript, que solo existe tras el primer mensaje: por eso se reintenta.
164async function detectPlanner($: EngineInterface, triesLeft: number) {
165 if (!isInteractive || isTurnedOff || (await read($, activeAtom))) return
166 const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
167 const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
168 if (/\bclaude\b/.test(cmdline)) {
169 if (agentFlagOf(cmdline) === PLANNER_AGENT) await claimPlanner($)
170 return
171 }
172 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
173 const path = transcriptPathOf(home, await $.session.cwd(), await $.session.id())
174 const { stdout } = await $.process.run(['head', '-c', '200000', path]).catch(() => ({ stdout: '' }))
175 const agent = agentSettingOf(stdout)
176 if (agent === PLANNER_AGENT) {
177 await claimPlanner($)
178 return
179 }
180 if (agent === null && triesLeft > 0) $.clock.after(DETECT_EVERY_MS, () => void detectPlanner($, triesLeft - 1))
181}
182
183// Marca la sesion como del planner y la activa si ya se sabe que es interactiva. Devuelve si la activo ahora.
184async function claimPlanner($: EngineInterface): Promise<boolean> {
185 await update($, plannerAtom, () => true)
186 if (!isInteractive || isTurnedOff || (await read($, activeAtom))) return false
187 await activate($)
188 return true
189}
190
191// Cada instruccion del agente (una herramienta, o tu mensaje que abre el turno) alterna el cuadro A/B.
192async function step($: EngineInterface, isNewTurn: boolean) {
193 await update($, frameAtom, f => (f === 1 ? 0 : 1))
194 await update($, stepsAtom, n => (isNewTurn ? 0 : (n ?? 0) + 1))
195}
196
197// Recorte comun a todos los cuadros del planner, calculado una vez: sin margen y sin saltos al alternar.
198const MASCOT_STATES = ['planeando', 'pensando', 'listo'] as const
199const MASCOT_COLS = usedColumns(MASCOT_STATES.flatMap(state => [sprite('planner', state, 0), sprite('planner', state, 1)]))
200const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
201
202/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
203function mascotCells(grid: readonly string[]): string {
204 const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
205 const words: number[] = []
206 for (let y = 0; y < HEIGHT; y += 2) {
207 for (let x = 0; x < MASCOT_WIDTH; x++) {
208 const top = color(grid[y]?.[x])
209 const bottom = color(grid[y + 1]?.[x])
210 if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
211 else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
212 else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
213 }
214 }
215 let bin = ''
216 for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
217 return btoa(bin)
218}
219
220// Mientras Claude trabaja (pensando o con herramientas) la mascota alterna sola; en reposo queda quieta.
221async function setWorking($: EngineInterface, isWorking: boolean) {
222 await update($, workingAtom, () => isWorking)
223 animation?.cancel()
224 animation = isWorking ? $.clock.every(ANIMATION_MS, () => void update($, frameAtom, f => (f === 1 ? 0 : 1))) : null
225}
226
227async function startFocus($: EngineInterface, focus: BoardFocus) {
228 await update($, flashAtom, () => null)
229 await update($, focusAtom, () => focus)
230 await update($, cardAtom, () => null)
231 if (focus.kind === 'refinar' && focus.issue !== null) void loadCard($, focus.issue)
232}
233
234async function closeFocus($: EngineInterface, summary: string) {
235 await update($, focusAtom, () => null)
236 await update($, cardAtom, () => null)
237 await update($, flashAtom, () => ({ text: summary, untilMs: Date.now() + FLASH_MS }))
238 $.clock.after(FLASH_MS, () => void update($, flashAtom, () => null))
239 void refresh($, true)
240}
241
242function summaryOf(focus: BoardFocus, isListo: boolean): string {
243 const created = createdText(focus.created)
244 const head =
245 focus.kind === 'refinar'
246 ? `✓ #${focus.issue}${isListo ? ' → listo' : ' · refinamiento cerrado'}`
247 : `✓ exploración «${clip(focus.topic, 40)}»`
248 return [head, created].filter(Boolean).join(' · ')
249}
250
251async function onPrompt($: EngineInterface, text: string) {
252 const focus = await read($, focusAtom)
253 const target = refineTargetOf(text)
254 if (target !== null && !(focus?.kind === 'refinar' && focus.issue === target)) {
255 const known = (await read($, refineAtom))?.items.find(i => i.number === target)
256 await startFocus($, { kind: 'refinar', issue: target, topic: known?.title ?? '', created: [], startedMs: Date.now() })
257 return
258 }
259 if (!focus && target === null && text.trim() !== '' && !text.trim().startsWith('/')) {
260 await startFocus($, { kind: 'explorar', issue: null, topic: topicOf(text), created: [], startedMs: Date.now() })
261 }
262}
263
264function refreshAfterSettle($: EngineInterface) {
265 settle?.cancel()
266 settle = $.clock.after(SETTLE_MS, () => void refresh($, true))
267}
268
269// El issue que sale de borrador se quita de la lista al instante; los refrescos lo siguen filtrando hasta que
270// GitHub confirme el cambio o venza el plazo. La lista de listos no se toca: la trae el siguiente refresco.
271async function markPending($: EngineInterface, issue: number) {
272 pending.set(issue, Date.now() + PENDING_TTL_MS)
273 await update($, refineAtom, list => withoutItems(list, [issue]))
274 await update($, pendingAtom, () => pending.size)
275 $.clock.after(PENDING_TTL_MS + 1_000, () => void refresh($, true))
276}
277
278async function onBash($: EngineInterface, command: string, output: string) {
279 const left = issueMarkedListo(command) ?? issueClosed(command)
280 if (left !== null) await markPending($, left)
281 if (isIssueChange(command)) refreshAfterSettle($)
282 const focus = await read($, focusAtom)
283 if (!focus) return
284 // Un mismo comando puede crear un borrador y pasar el foco a listo: se revisan las dos cosas.
285 if (isIssueCreate(command)) {
286 const created = createdIssueOf(output)
287 if (created !== null && !focus.created.includes(created)) {
288 if (focus.kind === 'explorar' && focus.created.length === 0) await update($, pageAtom, () => 0)
289 await update($, focusAtom, f => (f ? { ...f, created: [...f.created, created] } : f))
290 void refresh($, true)
291 }
292 }
293 const listo = issueMarkedListo(command)
294 if (focus.kind === 'refinar' && listo !== null && listo === focus.issue) {
295 const updated = (await read($, focusAtom)) ?? focus
296 await closeFocus($, summaryOf(updated, true))
297 return
298 }
299 if (isPlannerClosing(command)) await closeFocus($, summaryOf(focus, false))
300}
301
302// Cierre de la exploracion con una tecla: pulsarla ya es la autorizacion, asi que le pide al planner su rutina
303// de cierre (resumen + field note) sin confirmar. El foco se cierra solo cuando el planner corre
304// mefisto-field-note.sh.
305async function closeSession($: EngineInterface) {
306 await $.prompt.submit({ text: 'Cierra la sesión ahora, sin pedir confirmación: resumen, field note y entrega.', asUser: true })
307}
308
309// La misma tecla abre su lista o, si ya estaba abierta, la cierra.
310async function toggleList($: EngineInterface, tab: BoardTab) {
311 if (await read($, focusAtom)) return
312 const isOpen = (await read($, expandedAtom)) && (await read($, tabAtom)) === tab
313 await update($, tabAtom, () => tab)
314 await update($, pageAtom, () => 0)
315 await update($, expandedAtom, () => !isOpen)
316}
317
318// Avanza de pagina y da la vuelta al final.
319async function nextPage($: EngineInterface) {
320 const tab = showsDraftList(await read($, focusAtom)) ? 'borrador' : await read($, tabAtom)
321 const list = tab === 'borrador' ? await read($, refineAtom) : await read($, developAtom)
322 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, pageSizeOf(tab))
323 await update($, pageAtom, () => (page + 1) % pages)
324}
325
326// Rastro para diagnosticar teclas que llegan como texto (#2210): el log de debug dice si el boton estaba en el
327// arbol de la banda y si la pulsacion llego al mod.
328async function debugLog($: EngineInterface, text: () => string) {
329 try {
330 await $.ui.log(`[mefisto-planner-board] ${text()}`, { to: 'debug' })
331 } catch {
332 // El rastro nunca debe tumbar el render ni la pulsacion que observa.
333 }
334}
335
336async function fill($: EngineInterface, text: string) {
337 await $.prompt.fill({ text, mode: 'replace' })
338}
339
340export const register: Register = on => {
341 on('session.start', async ($, e, next) => {
342 isInteractive = e.isInteractive
343 await $.command.register({
344 name: 'mefisto-planner-board',
345 description: 'Tablero del planner: refresh | borradores | listos | cerrar | cerrar-sesion | on | off',
346 argumentHint: '[refresh|borradores|listos|cerrar|cerrar-sesion|on|off]',
347 immediate: true,
348 })
349 const isWanted = (await read($, activeAtom)) || (await read($, plannerAtom))
350 if (isInteractive && isWanted) await activate($)
351 if (isInteractive && (await read($, workingAtom))) await setWorking($, true)
352 else if (isInteractive) void detectPlanner($, DETECT_TRIES)
353 if (isInteractive) $.clock.every(HEAL_MS, () => void heal($))
354 return next(e)
355 })
356
357 on('prompt.submit', async ($, e, next) => {
358 if (e.origin.kind === 'composer' && !(await read($, activeAtom))) await detectPlanner($, 0)
359 if (await read($, activeAtom)) {
360 await step($, true)
361 await setWorking($, true)
362 }
363 if (e.origin.kind === 'composer' && (await read($, activeAtom))) await onPrompt($, e.text)
364 return next(e)
365 }).catch(($, e, next) => next(e))
366
367 on('tool.call', async ($, e, next) => {
368 if (await read($, activeAtom)) await step($, false)
369 return next(e)
370 })
371
372 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
373 const ran = await next(e)
374 if (await read($, activeAtom)) {
375 const output = ran.text ?? JSON.stringify(ran.result ?? '')
376 await onBash($, e.command, output).catch(() => undefined)
377 }
378 return ran
379 })
380
381 on('turn.complete', async ($, e, next) => {
382 const done = await next(e)
383 if (await read($, activeAtom)) await setWorking($, false)
384 return done
385 })
386
387 on('command.run', { command: 'mefisto-planner-board' }, async ($, e) => {
388 const arg = e.args.trim()
389 if (arg === 'off') {
390 await deactivate($)
391 return { text: 'Tablero del planner apagado en esta sesión.' }
392 }
393 if (arg === 'on' || !(await read($, activeAtom))) {
394 isTurnedOff = false
395 await activate($)
396 return { text: 'Tablero del planner activo en esta sesión.' }
397 }
398 if (arg === 'borradores' || arg === 'listos') {
399 await toggleList($, arg === 'borradores' ? 'borrador' : 'listo')
400 return { text: `Lista de ${arg} alternada.` }
401 }
402 if (arg === 'cerrar-sesion') {
403 await closeSession($)
404 return { text: 'Cierre de sesión solicitado al planner.' }
405 }
406 if (arg === 'cerrar') {
407 const focus = await read($, focusAtom)
408 if (focus) await closeFocus($, summaryOf(focus, false))
409 return { text: focus ? 'Foco cerrado.' : 'No había foco abierto.' }
410 }
411 await refresh($, true)
412 return { text: 'Tablero actualizado.' }
413 })
414
415 on('ui.press', async ($, e, next) => {
416 await debugLog($, () => `ui.press ${JSON.stringify(e)}`)
417 return next(e)
418 }).catch(($, e, next) => next(e))
419
420 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
421 if (!(await read($, activeAtom))) return next(e)
422 if (e.props.hasSurvey) {
423 await debugLog($, () => `render omitido hasSurvey=true isWorking=${!!e.props.isWorking} maxRows=${String(e.props.maxRows)}`)
424 return next(e)
425 }
426 const { Box, Text, Button } = $.ui.resolve(e)
427 const focus = await read($, focusAtom)
428 const flash = await read($, flashAtom)
429 const isExpanded = await read($, expandedAtom)
430 const refine = await read($, refineAtom)
431 const develop = await read($, developAtom)
432 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
433 const count = (l: BoardList | null) => (l ? String(l.items.length) : '…')
434 const tab = showsDraftList(focus) ? 'borrador' : await read($, tabAtom)
435 const pendingCount = await read($, pendingAtom)
436 const suggested = refine?.items[0]?.number ?? null
437 const listButtons = (
438 <Box gap={2}>
439 <Button key="list-borrador" hotkey="3" plain variant={isExpanded && tab === 'borrador' ? 'primary' : undefined}
440 dimColor={!(isExpanded && tab === 'borrador')} label={`borradores ${count(refine)}`}
441 onPress={() => void toggleList($, 'borrador')} />
442 {pendingCount > 0 && <Text dimColor>actualizando…</Text>}
443 <Button key="list-listo" hotkey="4" plain variant={isExpanded && tab === 'listo' ? 'primary' : undefined}
444 dimColor={!(isExpanded && tab === 'listo')} label={`listos ${count(develop)}`}
445 onPress={() => void toggleList($, 'listo')} />
446 </Box>
447 )
448
449 let head
450 if (focus) {
451 // La cabecera dice que se hace y desde cuando; el titulo o el tema va una sola vez, en el cuerpo.
452 const minutes = minutesSince(focus.startedMs ?? Date.now(), Date.now())
453 const label = `${focus.kind === 'refinar' ? `refinar #${focus.issue}` : 'explorar'} · ${minutes} min`
454 const created = createdText(focus.created)
455 head = (
456 <Box justifyContent="space-between">
457 <Box gap={2}>
458 <Text color="claude">●</Text>
459 <Text bold>{label}</Text>
460 {created !== '' && <Text color="success">{created}</Text>}
461 </Box>
462 {focus.kind === 'explorar' && (
463 <Button key="close-session" hotkey="1" plain label="cerrar sesión" onPress={() => void closeSession($)} />
464 )}
465 </Box>
466 )
467 } else if (flash && flash.untilMs > Date.now()) {
468 head = (
469 <Box justifyContent="space-between">
470 <Text color="success">{clip(flash.text, inner - 34)}</Text>
471 {listButtons}
472 </Box>
473 )
474 } else {
475 head = (
476 <Box justifyContent="space-between">
477 <Box gap={3}>
478 <Button key="mode-explorar" hotkey="1" plain label="explorar" onPress={() => void fill($, 'Quiero explorar: ')} />
479 {suggested !== null && (
480 <Button key="mode-refinar" hotkey="2" plain label={`refinar #${suggested}`}
481 onPress={() => void fill($, `Refina el borrador #${suggested}`)} />
482 )}
483 </Box>
484 {listButtons}
485 </Box>
486 )
487 }
488
489 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
490 const hotkeys = focus ? (focus.kind === 'explorar' ? ['1'] : []) : flash && flash.untilMs > Date.now() ? ['3', '4'] : suggested !== null ? ['1', '2', '3', '4'] : ['1', '3', '4']
491 await debugLog($, () => `render kind=${focus?.kind ?? 'reposo'} hotkeys=[${hotkeys.join(',')}] isWorking=${!!e.props.isWorking} hasSurvey=${!!e.props.hasSurvey} maxRows=${String(e.props.maxRows)}`)
492 const frame = await read($, frameAtom)
493 const isWorking = e.props.isWorking || (await read($, workingAtom))
494 const state = mascotPose(isWorking, await read($, stepsAtom), !!flash && flash.untilMs > Date.now())
495 const grid = cropGrid(sprite('planner', state, frame), MASCOT_COLS)
496 const elements = $.ui.resolve(e)
497 const mascot =
498 'Raster' in elements ? (
499 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
500 ) : (
501 <Box width={MASCOT_WIDTH} />
502 )
503
504 const updatedMs = await read($, updatedAtom)
505 let content
506 if (focus?.kind === 'refinar' && focus.issue !== null) {
507 const card = await read($, cardAtom)
508 const open = await read($, openAtom)
509 const title = card?.title || focus.topic
510 content = (
511 <Box flexDirection="column" width={body}>
512 <Text bold color="claude" wrap="wrap">{clip(title || ' ', body * 2 - 2)}</Text>
513 {title.length <= body && <Text> </Text>}
514 {!card && <Text dimColor>cargando ficha…</Text>}
515 {card && <Text dimColor wrap="truncate-end">{labelsText(card.labels)}</Text>}
516 {card && (
517 <Text color={card.hasDepsSection ? undefined : 'warning'} wrap="truncate-end">{depsText(card, open)}</Text>
518 )}
519 {card && <Text dimColor wrap="truncate-end">{blocksText(blockedBy(focus.issue, [refine, develop]))}</Text>}
520 </Box>
521 )
522 } else if (focus && !showsDraftList(focus)) {
523 const open = await read($, openAtom)
524 const shown = focus.created.slice(-3)
525 content = (
526 <Box flexDirection="column" width={body}>
527 <Text bold color="claude" wrap="wrap">{clip(focus.topic || ' ', body * 2 - 2)}</Text>
528 {focus.topic.length <= body && <Text> </Text>}
529 {shown.length === 0 && <Text dimColor>sin borradores aún</Text>}
530 {shown.map(n => (
531 <Text key={`created-${n}`} color="success" wrap="truncate-end">{createdCardText(n, open, body)}</Text>
532 ))}
533 </Box>
534 )
535 } else if (!isExpanded && !showsDraftList(focus)) {
536 const nextRefine = refine?.items.slice(0, 2) ?? []
537 const nextDevelop = develop?.items[0]
538 content = (
539 <Box flexDirection="column" width={body}>
540 <Text dimColor>siguiente a refinar</Text>
541 {[0, 1].map(i => (
542 <Text key={`r-${i}`} dimColor={i > 0} wrap="truncate-end">
543 {nextRefine[i] ? `#${nextRefine[i].number} ${clip(nextRefine[i].title, body - 8)}` : ' '}
544 </Text>
545 ))}
546 <Text dimColor>siguiente a desarrollar</Text>
547 <Text wrap="truncate-end">{nextDevelop ? `#${nextDevelop.number} ${clip(nextDevelop.title, body - 8)}` : ' '}</Text>
548 <Text dimColor>{updatedMs ? ' ' : 'cargando…'}</Text>
549 </Box>
550 )
551 } else {
552 const list = tab === 'borrador' ? refine : develop
553 const size = pageSizeOf(tab)
554 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, size)
555 const visible = list?.items.slice(page * size, (page + 1) * size) ?? []
556 const blankRows = Math.max(0, size - Math.max(visible.length, 1))
557 const numWidth = numberWidth(visible)
558 // Solo 'tras #N' ocupa columna: sin dependencias no se marca.
559 const afterWidth = Math.min(16, Math.max(0, ...visible.map(i => reasonOf(i).length)))
560 // Ancho exacto de la fila: el prefijo '5: ' de la tecla (solo en borradores), el numero, un espacio
561 // y, si alguno depende de otro, la columna 'tras #N'. El titulo ocupa el resto hasta el marco.
562 const keyPrefix = tab === 'borrador' ? 3 : 0
563 const titleWidth = Math.max(12, body - keyPrefix - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
564 const more = list
565 ? [
566 list.blockedCount > 0 ? `${list.blockedCount} bloqueados` : '',
567 list.cycleCount > 0 ? `${list.cycleCount} en ciclo` : '',
568 ].filter(Boolean).join(' · ')
569 : ''
570 const hint = tab === 'borrador' ? '5-9 al prompt' : ''
571 content = (
572 <Box flexDirection="column" width={body}>
573 {list?.error && <Text color="error">next-order falló: {clip(list.error, body - 20)}</Text>}
574 {list && !list.error && visible.length === 0 && (
575 <Text dimColor>{tab === 'borrador' ? 'Sin borradores refinables.' : 'Sin issues lanzables.'}</Text>
576 )}
577 {visible.map((item, i) => {
578 const reason = reasonOf(item)
579 return (
580 <Box>
581 {tab === 'borrador' ? (
582 <Button
583 key={`item-${item.number}`}
584 hotkey={String(i + 5)}
585 plain
586 dimColor={page > 0 || i > 0}
587 label={`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
588 onPress={() => void fill($, `Refina el borrador #${item.number}`)}
589 />
590 ) : (
591 <Text dimColor={page > 0 || i > 0}>
592 {`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
593 </Text>
594 )}
595 <Text dimColor>{reason ? ` ${clip(reason, afterWidth)}` : ''}</Text>
596 </Box>
597 )
598 })}
599 {Array.from({ length: blankRows }, (_, i) => (
600 <Text key={`blank-${i}`}> </Text>
601 ))}
602 <Box justifyContent="space-between">
603 <Text dimColor>{hint}</Text>
604 <Box gap={2}>
605 {more !== '' && <Text dimColor>{more}</Text>}
606 {pages > 1 && (
607 <Button key="next-page" hotkey="0" plain dimColor label={`página ${page + 1}/${pages} ▸`} onPress={() => void nextPage($)} />
608 )}
609 </Box>
610 </Box>
611 </Box>
612 )
613 }
614
615 return (
616 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
617 {head}
618 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
619 {mascot}
620 <Box marginTop={1}>{content}</Box>
621 </Box>
622 </Box>
623 )
624 })
625}
626hooks/logic.ts 353 lines1import type { BoardItem, BoardList, IssueCard, OpenIssueBrief } from '../types'
2
3export const NEXT_ORDER = './.claude/scripts/mefisto-next-order.sh'
4/** Filas fijas de la lista: la banda no cambia de alto con la cantidad de issues. */
5export const LIST_ROWS = 5
6
7type NextOrderJson = {
8 items?: { number: number; title: string; after?: number[]; hasDepsSection?: boolean }[]
9 blocked?: unknown[]
10 cycles?: unknown[]
11 launch?: string | null
12}
13
14/** Exit 0 (hay orden) y 1 (vacio) traen JSON valido; 2 es un fallo de gh o de argumentos. */
15export function parseNextOrder(exitCode: number, stdout: string, stderr: string): BoardList {
16 if (exitCode !== 0 && exitCode !== 1) {
17 return { items: [], blockedCount: 0, cycleCount: 0, launch: null, error: firstLine(stderr) || `exit ${exitCode}` }
18 }
19 let json: NextOrderJson
20 try {
21 json = JSON.parse(stdout)
22 } catch {
23 return { items: [], blockedCount: 0, cycleCount: 0, launch: null, error: 'salida de next-order no es JSON' }
24 }
25 const items: BoardItem[] = (json.items ?? []).map(i => ({
26 number: i.number,
27 title: i.title,
28 after: i.after ?? [],
29 hasDepsSection: i.hasDepsSection !== false,
30 }))
31 return {
32 items,
33 blockedCount: (json.blocked ?? []).length,
34 cycleCount: (json.cycles ?? []).length,
35 launch: json.launch ?? null,
36 error: null,
37 }
38}
39
40type OpenIssue = { number: number; title?: string; updatedAt?: string; labels?: { name: string }[] }
41
42export function parseOpenIssues(stdout: string): OpenIssue[] | null {
43 try {
44 const list = JSON.parse(stdout)
45 return Array.isArray(list) ? list : null
46 } catch {
47 return null
48 }
49}
50
51/** Cambia cuando entra, sale o se edita un issue abierto (labels o body actualizan updatedAt). */
52export function signatureOf(issues: OpenIssue[]): string {
53 return issues
54 .map(i => `${i.number}:${i.updatedAt ?? ''}:${(i.labels ?? []).map(l => l.name).sort().join(',')}`)
55 .sort()
56 .join('|')
57}
58
59export type Arrival = { number: number; title: string; kind: 'borrador' | 'listo' }
60
61/** Issues abiertos nuevos con estado borrador o listo. Sin conocidos previos (primer refresco) no hay avisos. */
62export function arrivals(issues: OpenIssue[], known: number[]): Arrival[] {
63 if (known.length === 0) return []
64 const seen = new Set(known)
65 const out: Arrival[] = []
66 for (const i of issues) {
67 if (seen.has(i.number)) continue
68 const names = (i.labels ?? []).map(l => l.name)
69 const kind = names.includes('estado:borrador') ? 'borrador' : names.includes('estado:listo') ? 'listo' : null
70 if (kind) out.push({ number: i.number, title: i.title ?? '', kind })
71 }
72 return out
73}
74
75/** De que depende el issue (`tras #A #B`), o vacio si no depende de nada abierto. */
76export function reasonOf(item: BoardItem): string {
77 return item.after.length > 0 ? `tras ${item.after.map(n => `#${n}`).join(' ')}` : ''
78}
79
80/** Ancho comun de la columna de numeros (#801 y #1981 alineados). */
81export function numberWidth(items: BoardItem[]): number {
82 return Math.max(4, ...items.map(i => String(i.number).length + 1))
83}
84
85export function padEnd(text: string, width: number): string {
86 return text.length >= width ? text : text + ' '.repeat(width - text.length)
87}
88
89export function clip(text: string, max: number): string {
90 const one = text.replace(/\s+/g, ' ').trim()
91 return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
92}
93
94export function clockOf(ms: number): string {
95 if (ms === 0) return ''
96 const d = new Date(ms)
97 const p = (n: number) => String(n).padStart(2, '0')
98 return `${p(d.getHours())}:${p(d.getMinutes())}`
99}
100
101function firstLine(text: string): string {
102 return text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
103}
104
105const REFINE = /\brefin\w*\s+(?:el\s+)?(?:borrador\s+)?#(\d+)/i
106
107/** `refina #801`, `Refina el borrador #801`, `refinemos #801`: el issue a refinar, o null. */
108export function refineTargetOf(prompt: string): number | null {
109 const m = REFINE.exec(prompt)
110 return m ? Number(m[1]) : null
111}
112
113/** El tema de una exploracion: la primera linea con contenido del mensaje, recortada. */
114export function topicOf(prompt: string): string {
115 const line = prompt.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
116 return clip(line.replace(/^quiero explorar:\s*/i, ''), 160)
117}
118
119// Un comando cuenta solo donde se ejecuta: al inicio (tras ; && || | ( then do o un salto de linea), con
120// asignaciones de entorno delante, y fuera de heredocs. Un texto que lo nombra (git show, grep, echo) no cuenta.
121const AT_COMMAND = String.raw`(?:^|[;&|(\n]|\bthen\b|\bdo\b)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*`
122
123function withoutHeredocs(command: string): string {
124 return command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '<<heredoc')
125}
126
127const runs = (command: string, pattern: string) => new RegExp(`${AT_COMMAND}${pattern}`).exec(withoutHeredocs(command))
128
129/** Un cambio de issues hecho por el planner (crear, editar, cerrar, reabrir): sus listas pueden haber cambiado. */
130export function isIssueChange(command: string): boolean {
131 return runs(command, String.raw`gh\s+issue\s+(create|edit|close|reopen)\b`) !== null
132}
133
134export function isIssueCreate(command: string): boolean {
135 return runs(command, String.raw`gh\s+issue\s+create\b`) !== null
136}
137
138/** `gh issue edit 801 ... --add-label estado:listo`: el issue que pasa a listo, o null. */
139export function issueMarkedListo(command: string): number | null {
140 const m = runs(command, String.raw`gh\s+issue\s+edit\s+#?(\d+)\b[^\n;&|]*--add-label[=\s]+["']?[^"'\s]*estado:listo`)
141 return m ? Number(m[1]) : null
142}
143
144/** `gh issue close 801 ...`: el issue que se cierra, o null. */
145export function issueClosed(command: string): number | null {
146 const m = runs(command, String.raw`gh\s+issue\s+close\s+#?(\d+)\b`)
147 return m ? Number(m[1]) : null
148}
149
150/** Tiempo maximo que un issue en transicion se oculta de la lista de borradores. */
151export const PENDING_TTL_MS = 60_000
152
153/** La lista sin los issues dados (quita optimista: nunca reordena, solo saca). */
154export function withoutItems(list: BoardList | null, numbers: Iterable<number>): BoardList | null {
155 if (!list) return list
156 const hidden = new Set(numbers)
157 if (hidden.size === 0) return list
158 return { ...list, items: list.items.filter(i => !hidden.has(i.number)) }
159}
160
161/**
162 * Transiciones pendientes tras un refresco: un issue sale del conjunto cuando la lista fresca ya no lo trae
163 * como borrador o cuando vence su plazo. Mapa numero -> instante de vencimiento (ms). `freshDrafts` null (next-order
164 * fallo) no confirma nada: solo vence por plazo.
165 */
166export function settlePending(pending: ReadonlyMap<number, number>, freshDrafts: number[] | null, nowMs: number): Map<number, number> {
167 const fresh = freshDrafts && new Set(freshDrafts)
168 const out = new Map<number, number>()
169 for (const [n, untilMs] of pending) if (untilMs > nowMs && (!fresh || fresh.has(n))) out.set(n, untilMs)
170 return out
171}
172
173/** El cierre del planner: ejecuta mefisto-field-note.sh (leerlo con git show o grep no cierra nada). */
174export function isPlannerClosing(command: string): boolean {
175 return runs(command, String.raw`\S*mefisto-field-note\.sh\b`) !== null
176}
177
178/** Numero del issue que `gh issue create` imprime como URL. */
179export function createdIssueOf(output: string): number | null {
180 const m = /\/issues\/(\d+)/.exec(output)
181 return m ? Number(m[1]) : null
182}
183
184export function createdText(created: number[]): string {
185 return created.length > 0 ? `creó ${created.map(n => `#${n}`).join(' ')}` : ''
186}
187
188/** Issues por pagina, igual en las dos listas. */
189export function pageSizeOf(_tab: 'borrador' | 'listo'): number {
190 return LIST_ROWS
191}
192
193/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
194export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
195 const pages = Math.max(1, Math.ceil(total / size))
196 return { page: page >= 0 && page < pages ? page : 0, pages }
197}
198
199/** Ruta del transcript de una sesion: ~/.claude/projects/<cwd con lo no alfanumerico como '-'>/<id>.jsonl. */
200export function transcriptPathOf(configDir: string, cwd: string, sessionId: string): string {
201 return `${configDir}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
202}
203
204/**
205 * `claude --agent mefisto-planner` deja en el transcript de la sesion filas
206 * `{"type":"agent-setting","agentSetting":"mefisto-planner"}`; manda la ultima. Un planner lanzado como
207 * subagente escribe en el transcript del subagente, nunca en el de la sesion que lo lanzo.
208 */
209export function agentSettingOf(transcript: string): string | null {
210 let found: string | null = null
211 for (const line of transcript.split('\n')) {
212 if (!line.includes('"agent-setting"')) continue
213 try {
214 const row = JSON.parse(line)
215 if (row.type === 'agent-setting' && typeof row.agentSetting === 'string') found = row.agentSetting
216 } catch {
217 continue
218 }
219 }
220 return found
221}
222
223/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
224export function agentFlagOf(cmdline: string): string | null {
225 const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
226 return m ? (m[1] ?? null) : null
227}
228
229export type MascotState = 'planeando' | 'pensando' | 'listo'
230
231/** Siempre la mascota del planner: trabajando con herramientas planea, sin ellas piensa y al cerrar un foco queda lista. */
232export function mascotPose(isWorking: boolean, stepsInTurn: number, hasFlash: boolean): MascotState {
233 if (isWorking) return stepsInTurn > 0 ? 'planeando' : 'pensando'
234 if (hasFlash) return 'listo'
235 return 'planeando'
236}
237
238type Grid = readonly string[]
239
240/** Columnas con algun pixel visible en cualquiera de los cuadros: recorta el margen sin que la mascota salte. */
241export function usedColumns(grids: readonly Grid[]): { from: number; to: number } {
242 let from = Infinity
243 let to = -1
244 for (const g of grids) {
245 for (const row of g) {
246 for (let x = 0; x < row.length; x++) {
247 const c = row[x]
248 if (c !== '.' && c !== ' ') {
249 from = Math.min(from, x)
250 to = Math.max(to, x)
251 }
252 }
253 }
254 }
255 return to < 0 ? { from: 0, to: 0 } : { from, to }
256}
257
258export function cropGrid(grid: Grid, cols: { from: number; to: number }): Grid {
259 return grid.map(row => row.slice(cols.from, cols.to + 1).padEnd(cols.to - cols.from + 1, '.'))
260}
261
262// Misma regla que src/internal/scripts/lib/mefisto-deps.sh: solo 'Depende de #N' / 'Bloqueado por #N' al inicio
263// del item de '## Dependencias', y solo el primer numero de la linea.
264const DEPS_HEADER = /^##\s*dependencias/i
265const FORWARD_DEP = /^\s*(?:[-*]\s+)?(?:depende de|bloqueado por)\s+#(\d+)/i
266
267export function dependenciesOf(body: string): { deps: number[]; hasSection: boolean } {
268 const deps = new Set<number>()
269 let inSection = false
270 let hasSection = false
271 for (const line of body.split('\n')) {
272 if (DEPS_HEADER.test(line)) {
273 inSection = hasSection = true
274 continue
275 }
276 if (/^##\s/.test(line)) inSection = false
277 const m = inSection ? FORWARD_DEP.exec(line) : null
278 if (m) deps.add(Number(m[1]))
279 }
280 return { deps: [...deps].sort((a, b) => a - b), hasSection }
281}
282
283/** La salida de `gh issue view N --json number,title,labels,body`, o null si no es JSON. */
284export function parseIssueCard(stdout: string): IssueCard | null {
285 try {
286 const i = JSON.parse(stdout)
287 if (typeof i?.number !== 'number') return null
288 const { deps, hasSection } = dependenciesOf(i.body ?? '')
289 const labels = (i.labels ?? []).map((l: { name: string }) => l.name)
290 return { number: i.number, title: i.title ?? '', labels, deps, hasDepsSection: hasSection }
291 } catch {
292 return null
293 }
294}
295
296export function briefsOf(issues: OpenIssue[]): OpenIssueBrief[] {
297 return issues.map(i => ({ number: i.number, title: i.title ?? '', labels: (i.labels ?? []).map(l => l.name) }))
298}
299
300const LABEL_ORDER = ['tipo:', 'dom:', 'estado:']
301
302/** tipo, dom y estado primero; el resto despues, en orden alfabetico. */
303export function labelsText(labels: string[]): string {
304 const rank = (l: string) => {
305 const i = LABEL_ORDER.findIndex(p => l.startsWith(p))
306 return i === -1 ? LABEL_ORDER.length : i
307 }
308 return [...labels].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)).join(' · ')
309}
310
311/** Una dependencia cerrada esta cumplida; una abierta dice en que estado va. */
312export function depsText(card: IssueCard, open: OpenIssueBrief[]): string {
313 if (!card.hasDepsSection) return 'sin sección ## Dependencias'
314 if (card.deps.length === 0) return 'no depende de nada'
315 const byNumber = new Map(open.map(i => [i.number, i]))
316 const parts = card.deps.map(n => {
317 const dep = byNumber.get(n)
318 if (!dep) return `#${n} ✓`
319 const estado = dep.labels.find(l => l.startsWith('estado:'))
320 return `#${n} ${estado ? estado.slice('estado:'.length) : 'abierto'}`
321 })
322 return `depende de ${parts.join(' · ')}`
323}
324
325/** Los issues de las listas de next-order que van tras #N. */
326export function blockedBy(issue: number, lists: (BoardList | null)[]): number[] {
327 const out = new Set<number>()
328 for (const l of lists) for (const i of l?.items ?? []) if (i.after.includes(issue)) out.add(i.number)
329 return [...out].sort((a, b) => a - b)
330}
331
332export function blocksText(blocked: number[]): string {
333 return blocked.length > 0 ? `bloquea ${blocked.map(n => `#${n}`).join(' ')}` : 'no bloquea a nadie'
334}
335
336/** Ficha corta de un borrador creado en la sesion: `#N titulo · tipo:x`. */
337export function createdCardText(n: number, open: OpenIssueBrief[], max: number): string {
338 const issue = open.find(i => i.number === n)
339 if (!issue) return `#${n}`
340 const tipo = issue.labels.find(l => l.startsWith('tipo:'))
341 const tail = tipo ? ` · ${tipo}` : ''
342 return `#${n} ${clip(issue.title, Math.max(10, max - String(n).length - 2 - tail.length))}${tail}`
343}
344
345export function minutesSince(startedMs: number, nowMs: number): number {
346 return Math.max(0, Math.floor((nowMs - startedMs) / 60_000))
347}
348
349// En una exploracion con borradores creados el cuerpo de la banda es la lista de borradores (#2217).
350export function showsDraftList(focus: { kind: string; created: number[] } | null): boolean {
351 return focus?.kind === 'explorar' && focus.created.length > 0
352}
353hooks/sprites.ts 711 lines1/**
2 * mefisto-sprites.ts
3 * Mascota Mefisto para mods de Claude Code. El planner no es un Mefisto: es Fausto, el que decide
4 * (birrete de doctor, barba y tunica del mismo color que el Mefisto de su lado).
5 *
6 * Cada sprite es una cuadrícula de 18×12 píxeles escrita como texto:
7 * cada carácter es un píxel y apunta a un color de PALETTE.
8 * '.' píxel transparente (en EMOTIONS)
9 * ' ' "no tocar" (en las capas de ROLES: deja ver lo de abajo)
10 *
11 * Un sprite se arma así: emoción (cara) + capa del rol (objeto y detalles)
12 * Cada estado tiene dos cuadros, A (0) y B (1), que se alternan con cada instrucción.
13 *
14 * Salidas:
15 * toRasterCells(grid) -> celdas empacadas para el elemento Raster (terminal), 18 columnas × 6 filas
16 * toSvg(grid) -> documento SVG para el elemento Svg (app de escritorio)
17 * ONE_LINERS -> versión de una línea para la status line (elemento Text)
18 */
19
20export type Grid = readonly string[]
21export type Frame = 0 | 1
22
23export const WIDTH = 18
24export const HEIGHT = 12
25/** Filas que ocupa el sprite en la terminal (dos píxeles por fila con ▀ / ▄). */
26export const RASTER_ROWS = HEIGHT / 2
27
28/** Color por defecto de la terminal (transparente), según la documentación de Raster. */
29export const DEFAULT_COLOR = 0x01000000
30
31/**
32 * Paleta: letra -> color 0xRRGGBB. Los mods internos (este repo) usan un Mefisto blanco perla con cuernos dorados para no confundirse
33 * con los del consumidor, que conservan el rojo de mefisto-sprites.ts (cuernos/cola 0xA32D2D, cuerpo 0xE24B4A,
34 * boca 0x501313).
35 */
36export const PALETTE: Record<string, number> = {
37 "h": 0xD9A441, // cuernos (dorados)
38 "r": 0xEEEDF5, // cuerpo
39 "t": 0xD9A441, // cola (dorada)
40 "E": 0x2C2C2A, // ojos / pupilas
41 "M": 0x4A4A5E, // boca y cejas
42 "W": 0xF1EFE8, // colmillos
43 "p": 0xD4537E, // corazones y lengua
44 "s": 0xB4B2A9, // humo (enojado)
45 "B": 0x85B7EB, // Z y gota de sudor
46 "Y": 0xEF9F27, // bombillo, casco, flecha
47 "y": 0xFAC775, // destellos
48 "w": 0xFFF6D9, // brillo del bombillo
49 "G": 0x888780, // casquillo del bombillo
50 "o": 0xB4B2A9, // bombillo apagado
51 "D": 0x5F5E5A, // marco del portátil
52 "U": 0x378ADD, // pantalla
53 "u": 0xB5D4F4, // líneas de código
54 "q": 0xB4B2A9, // teclado / renglones
55 "Q": 0xFFFFFF, // blanco (pantalla, hoja)
56 "R": 0xE24B4A, // error / marcas rojas
57 "V": 0x639922, // verde de éxito
58 "g": 0x888780, // vidrio del matraz
59 "L": 0x97C459, // líquido del matraz
60 "l": 0xEAF3DE, // reflejo del líquido
61 "b": 0xC0DD97, // burbujas
62 "P": 0x7F77DD, // marco de la lupa
63 "e": 0xEEEDFE, // brillo de la lupa
64 "n": 0x854F0B, // mango de la lupa
65 "c": 0xB5D4F4, // nube
66 "C": 0x888780, // nube caída
67 "k": 0xF1C9A5, // piel de Fausto
68 "f": 0xD9A07F, // nariz de Fausto
69 "i": 0x8A6F5A, // bigote de Fausto
70 "K": 0xD9A441, // birrete de Fausto (dorado)
71 "z": 0xEEEDF5, // borla del birrete
72 "a": 0xB9B5AA, // barba y pelo de Fausto
73 "T": 0xEEEDF5, // tunica de Fausto (blanco perla)
74 "d": 0xD9A441, // ribete de la tunica
75}
76
77export type Emotion =
78 | 'normal'
79 | 'feliz'
80 | 'sonriente'
81 | 'enojado'
82 | 'dormido'
83 | 'asustado'
84 | 'pensando'
85 | 'preocupado'
86 | 'concentrado'
87
88/** Caras base: [cuadro A, cuadro B]. Solo 'pensando' cambia entre cuadros (bombillo encendido / apagado). */
89export const EMOTIONS: Record<Emotion, readonly [Grid, Grid]> = (() => {
90 const normalA: Grid = [
91 '..................',
92 '..................',
93 '....h..........h..',
94 '....hh........hh..',
95 '.....hrrrrrrrrh...',
96 '.....rrrrrrrrrr..t',
97 '.....rrEErrEErr.tt',
98 '.....rrEErrEErr..t',
99 '.....rrrrrrrrrr..t',
100 '......rrMMMMrr..t.',
101 '.......rrrrrrtt...',
102 '.......rr..rr.....',
103 ]
104 const felizA: Grid = [
105 '......p.p..p.p....',
106 '......ppp..ppp....',
107 '....h..p....p..h..',
108 '....hh........hh..',
109 '.....hrrrrrrrrh...',
110 '.....rrrrrrrrrr..t',
111 '.....rrEErrEErr.tt',
112 '.....rrrrrrrrrr..t',
113 '.....rMrrrrrrMr..t',
114 '......rMMWWMMr..t.',
115 '.......rrrrrrtt...',
116 '.......rr..rr.....',
117 ]
118 const sonrienteA: Grid = [
119 '..................',
120 '..................',
121 '....h..........h..',
122 '....hh........hh..',
123 '.....hrrrrrrrrh...',
124 '.....rrrrrrrrrr..t',
125 '.....rrEErrEErr.tt',
126 '.....rrrrrrrrrr..t',
127 '.....rMrrrrrrMr..t',
128 '......rMMWWMMr..t.',
129 '.......rrrrrrtt...',
130 '.......rr..rr.....',
131 ]
132 const enojadoA: Grid = [
133 '.....s.s......s.s.',
134 '......s........s..',
135 '....h..........h..',
136 '....hh........hh..',
137 '.....hrrrrrrrrh...',
138 '.....rMrrrrrrMr..t',
139 '.....rrMMrrMMrr.tt',
140 '.....rrEErrEErr..t',
141 '.....rrrrrrrrrr..t',
142 '......rrMMMMrr..t.',
143 '.......rMrrMrtt...',
144 '.......rr..rr.....',
145 ]
146 const dormidoA: Grid = [
147 '...........BBB....',
148 '............B.....',
149 '....h......BBB.h..',
150 '....hh........hh..',
151 '.....hrrrrrrrrh...',
152 '.....rrrrrrrrrr..t',
153 '.....rrrrrrrrrr.tt',
154 '.....rEErrrrEEr..t',
155 '.....rrrrrrrrrr..t',
156 '......rrrMMrrr..t.',
157 '.......rrrrrrtt...',
158 '.......rr..rr.....',
159 ]
160 const asustadoA: Grid = [
161 '.......y......y...',
162 '........y....y....',
163 '....h..........h..',
164 '....hh........hh..',
165 '.....hrMMrrMMrhB..',
166 '.....rrrrrrrrrrB.t',
167 '.....rrWWrrWWrr.tt',
168 '.....rrWErrWErr..t',
169 '.....rrrrMMrrrr..t',
170 '......rrrMMrrr..t.',
171 '.......rrrrrrtt...',
172 '.......rr..rr.....',
173 ]
174 const pensandoA: Grid = [
175 '......y..YY..y....',
176 '........YwYY......',
177 '....h...YYYY...h..',
178 '....hh...GG...hh..',
179 '.....hrrrrrrrrh...',
180 '.....rrrrrrrrrr..t',
181 '.....rrrEErrEEr.tt',
182 '.....rrrrrrrrrr..t',
183 '.....rrrrrrrrrr..t',
184 '......rrrMMrrr..t.',
185 '.......rrrrrrtt...',
186 '.......rr..rr.....',
187 ]
188 const pensandoB: Grid = [
189 '.........oo.......',
190 '........oooo......',
191 '....h...oooo...h..',
192 '....hh...GG...hh..',
193 '.....hrrrrrrrrh...',
194 '.....rrrrrrrrrr..t',
195 '.....rrrEErrEEr.tt',
196 '.....rrrrrrrrrr..t',
197 '.....rrrrrrrrrr..t',
198 '......rrrMMrrr..t.',
199 '.......rrrrrrtt...',
200 '.......rr..rr.....',
201 ]
202 const preocupadoA: Grid = [
203 '.................R',
204 '.................R',
205 '....h..........h..',
206 '....hh........hh.R',
207 '.....hrrrrrrrrh...',
208 '....BrrrMrrMrrr..t',
209 '....BrrMrrrrMrr.tt',
210 '.....rrEErrEErr..t',
211 '.....rrrrrrrrrr..t',
212 '......rrrMrMrr..t.',
213 '.......rMrMrrtt...',
214 '.......rr..rr.....',
215 ]
216 const concentradoA: Grid = [
217 '..................',
218 '..................',
219 '....h..........h..',
220 '....hh........hh..',
221 '.....hrrrrrrrrh...',
222 '.....rrrrrrrrrr..t',
223 '.....rrEErrMMrr.tt',
224 '.....rrEErrEErr..t',
225 '.....rrrrrrrrrr..t',
226 '......rrrMMrrr..t.',
227 '.......rrrprrtt...',
228 '.......rr..rr.....',
229 ]
230 return {
231 normal: [normalA, normalA],
232 feliz: [felizA, felizA],
233 sonriente: [sonrienteA, sonrienteA],
234 enojado: [enojadoA, enojadoA],
235 dormido: [dormidoA, dormidoA],
236 asustado: [asustadoA, asustadoA],
237 pensando: [pensandoA, pensandoB],
238 preocupado: [preocupadoA, preocupadoA],
239 concentrado: [concentradoA, concentradoA],
240 }
241})()
242
243export type Role = 'desarrollador' | 'tester' | 'revisor' | 'planner' | 'infraestructura'
244
245export interface RoleState {
246 /** Cara que usa este estado. */
247 emotion: Emotion
248 /** Capas del rol para el cuadro A y el B. ' ' deja ver la cara. */
249 layers: readonly [Grid, Grid]
250 /** Cara propia del rol en lugar de la de Mefisto (Fausto, el planner). */
251 face?: readonly [Grid, Grid]
252}
253
254/** Objetos y detalles de cada rol, por estado de trabajo. */
255export const ROLES: Record<Role, Record<string, RoleState>> = (() => {
256 // Desarrollador · portátil, azul
257 const desarrollador_trabajando_A: Grid = [
258 ' ',
259 ' ',
260 ' ',
261 ' ',
262 ' ',
263 ' ',
264 ' ',
265 'DDDDD ',
266 'DUuUD ',
267 'DuUUD ',
268 'DDDDD ',
269 'qqqqqqq ',
270 ]
271 const desarrollador_trabajando_B: Grid = [
272 ' ',
273 ' ',
274 ' ',
275 ' ',
276 ' ',
277 ' ',
278 ' ',
279 'DDDDD ',
280 'DuUUD ',
281 'DUUuD ',
282 'DDDDD ',
283 'qqqqqqq ',
284 ]
285 const desarrollador_pensando_A: Grid = [
286 ' ',
287 ' ',
288 ' ',
289 ' ',
290 ' ',
291 ' ',
292 ' ',
293 'DDDDD ',
294 'DUuUD ',
295 'DuUUD ',
296 'DDDDD ',
297 'qqqqqqq ',
298 ]
299 const desarrollador_error_A: Grid = [
300 ' ',
301 ' ',
302 ' ',
303 ' ',
304 ' ',
305 ' ',
306 ' ',
307 'DDDDD ',
308 'DQRQD ',
309 'DRQRD ',
310 'DDDDD ',
311 'qqqqqqq ',
312 ]
313 const desarrollador_error_B: Grid = [
314 ' ',
315 ' ',
316 ' ',
317 ' ',
318 ' ',
319 ' ',
320 ' ',
321 'DDDDD ',
322 'DRQRD ',
323 'DQRQD ',
324 'DDDDD ',
325 'qqqqqqq ',
326 ]
327 const desarrollador_resuelto_A: Grid = [
328 ' ',
329 ' ',
330 ' ',
331 ' ',
332 ' ',
333 ' ',
334 ' ',
335 'DDDDD ',
336 'DVVQD ',
337 'DQQVD ',
338 'DDDDD ',
339 'qqqqqqq ',
340 ]
341 // Tester · matraz, verde
342 const tester_trabajando_A: Grid = [
343 ' ',
344 ' ',
345 ' ',
346 ' ',
347 ' ',
348 ' b ',
349 ' b ',
350 ' ggg ',
351 ' g ',
352 ' LLL ',
353 'LlLLL ',
354 'LLLLL ',
355 ]
356 const tester_trabajando_B: Grid = [
357 ' ',
358 ' ',
359 ' ',
360 ' ',
361 ' ',
362 ' b ',
363 ' b ',
364 ' ggg ',
365 ' g ',
366 ' LLL ',
367 'LlLLL ',
368 'LLLLL ',
369 ]
370 const tester_pensando_A: Grid = [
371 ' ',
372 ' ',
373 ' ',
374 ' ',
375 ' ',
376 ' b ',
377 ' b ',
378 ' ggg ',
379 ' g ',
380 ' LLL ',
381 'LlLLL ',
382 'LLLLL ',
383 ]
384 const tester_feliz_A: Grid = [
385 ' ',
386 ' ',
387 ' ',
388 ' ',
389 ' ',
390 ' b ',
391 ' b ',
392 ' ggg ',
393 ' g ',
394 ' LLL ',
395 'LlLLL ',
396 'LLLLL ',
397 ]
398 const tester_feliz_B: Grid = [
399 ' ',
400 ' ',
401 ' ',
402 ' ',
403 ' ',
404 ' b ',
405 ' b ',
406 ' ggg ',
407 ' g ',
408 ' LLL ',
409 'LlLLL ',
410 'LLLLL ',
411 ]
412 // Revisor · lupa, morado
413 const revisor_trabajando_A: Grid = [
414 ' ',
415 ' ',
416 ' ',
417 ' ',
418 ' ',
419 ' PP ',
420 ' PeEP ',
421 ' PEEP ',
422 ' PP ',
423 ' n ',
424 ' n ',
425 ' n ',
426 ]
427 const revisor_trabajando_B: Grid = [
428 ' ',
429 ' ',
430 ' ',
431 ' ',
432 ' ',
433 ' PP ',
434 ' PEEP ',
435 ' PEeP ',
436 ' PP ',
437 ' n ',
438 ' n ',
439 ' n ',
440 ]
441 const revisor_pensando_A: Grid = [
442 ' ',
443 ' ',
444 ' ',
445 ' ',
446 ' ',
447 ' PP ',
448 ' PeEP ',
449 ' PEEP ',
450 ' PP ',
451 ' n ',
452 ' n ',
453 ' n ',
454 ]
455 const revisor_corrigiendo_A: Grid = [
456 ' ',
457 ' ',
458 ' ',
459 ' ',
460 ' ',
461 ' PP ',
462 ' PeEP ',
463 'QQQ PEEP ',
464 'qqR PP ',
465 'QQQ n ',
466 'qRq n ',
467 'QQQn ',
468 ]
469 const revisor_corrigiendo_B: Grid = [
470 ' ',
471 ' ',
472 ' ',
473 ' ',
474 ' ',
475 ' PP ',
476 ' PEEP ',
477 'QQQ PEeP ',
478 'qqR PP ',
479 'QQQ n ',
480 'qRq n ',
481 'QQQn ',
482 ]
483 const revisor_aprobado_A: Grid = [
484 ' ',
485 ' ',
486 ' ',
487 ' ',
488 ' ',
489 ' PP ',
490 ' PeEP ',
491 'QQQ PEEP ',
492 'QQV PP ',
493 'VQV n ',
494 'QVQ n ',
495 'QQQn ',
496 ]
497 // Planner · Fausto: mira a un lado y a otro mientras planea
498 const fausto_planeando_A: Grid = [
499 '..................',
500 '.....KKKKKKKKKK...',
501 '......KKKKKKKK.z..',
502 '.......KKKKKK..z..',
503 '......akkkkkka....',
504 '......akEkkEka....',
505 '......akkkkkka....',
506 '......akkffkka....',
507 '......aaiiiiaa....',
508 '.....TdaaaaaadT...',
509 '....TTTdaaaadTTT..',
510 '....TTTTdaadTTTT..',
511 ]
512 const fausto_planeando_B: Grid = fausto_planeando_A.map((row, y) => (y === 5 ? '......aEkkEkka....' : row))
513 const fausto_pensando_A: Grid = [
514 '.yYy..............',
515 '.YwY.KKKKKKKKKK...',
516 '..G...KKKKKKKK.z..',
517 ...fausto_planeando_B.slice(3),
518 ]
519 const fausto_pensando_B: Grid = ['.ooo..............', '.ooo.KKKKKKKKKK...', ...fausto_pensando_A.slice(2)]
520 const fausto_listo_A: Grid = fausto_planeando_A.map((row, y) =>
521 y === 8 ? '......aaiaaiaa....' : y === 9 ? '.....TdaaiiaadT...' : row,
522 )
523 const sin_capa: Grid = Array(HEIGHT).fill(' '.repeat(WIDTH))
524 // Infraestructura · nube y casco, amarillo
525 const infraestructura_desplegando_A: Grid = [
526 ' ',
527 ' ',
528 ' YyYY ',
529 ' YyYYYY ',
530 ' YYYYYYYY ',
531 ' cc ',
532 'cccc ',
533 'ccccc ',
534 'ccccc ',
535 ' Y ',
536 ' YYY ',
537 ' Y ',
538 ]
539 const infraestructura_desplegando_B: Grid = [
540 ' ',
541 ' ',
542 ' YyYY ',
543 ' YyYYYY ',
544 ' YYYYYYYY ',
545 ' cc ',
546 'cccc ',
547 'ccccc ',
548 'ccYcc ',
549 ' YYY ',
550 ' Y ',
551 ' ',
552 ]
553 const infraestructura_caido_A: Grid = [
554 ' ',
555 ' ',
556 ' YyYY ',
557 ' YyYYYY ',
558 ' YYYYYYYY ',
559 ' CC ',
560 'CCCC ',
561 'CCCCC ',
562 'CCCCC ',
563 ' RR ',
564 ' R ',
565 ' R ',
566 ]
567 const infraestructura_arriba_A: Grid = [
568 ' ',
569 ' ',
570 ' YyYY ',
571 ' YyYYYY ',
572 ' YYYYYYYY ',
573 ' cc ',
574 'cccc ',
575 'ccccc ',
576 'ccccc ',
577 ' V ',
578 ' V V ',
579 ' V ',
580 ]
581 return {
582 desarrollador: {
583 trabajando: { emotion: 'normal', layers: [desarrollador_trabajando_A, desarrollador_trabajando_B] },
584 pensando: { emotion: 'pensando', layers: [desarrollador_pensando_A, desarrollador_pensando_A] },
585 error: { emotion: 'preocupado', layers: [desarrollador_error_A, desarrollador_error_B] },
586 resuelto: { emotion: 'sonriente', layers: [desarrollador_resuelto_A, desarrollador_resuelto_A] },
587 },
588 tester: {
589 trabajando: { emotion: 'normal', layers: [tester_trabajando_A, tester_trabajando_B] },
590 pensando: { emotion: 'pensando', layers: [tester_pensando_A, tester_pensando_A] },
591 feliz: { emotion: 'feliz', layers: [tester_feliz_A, tester_feliz_B] },
592 },
593 revisor: {
594 trabajando: { emotion: 'normal', layers: [revisor_trabajando_A, revisor_trabajando_B] },
595 pensando: { emotion: 'pensando', layers: [revisor_pensando_A, revisor_pensando_A] },
596 corrigiendo: { emotion: 'concentrado', layers: [revisor_corrigiendo_A, revisor_corrigiendo_B] },
597 aprobado: { emotion: 'sonriente', layers: [revisor_aprobado_A, revisor_aprobado_A] },
598 },
599 planner: {
600 planeando: { emotion: 'normal', layers: [sin_capa, sin_capa], face: [fausto_planeando_A, fausto_planeando_B] },
601 pensando: { emotion: 'pensando', layers: [sin_capa, sin_capa], face: [fausto_pensando_A, fausto_pensando_B] },
602 listo: { emotion: 'sonriente', layers: [sin_capa, sin_capa], face: [fausto_listo_A, fausto_listo_A] },
603 },
604 infraestructura: {
605 desplegando: { emotion: 'normal', layers: [infraestructura_desplegando_A, infraestructura_desplegando_B] },
606 caido: { emotion: 'asustado', layers: [infraestructura_caido_A, infraestructura_caido_A] },
607 arriba: { emotion: 'sonriente', layers: [infraestructura_arriba_A, infraestructura_arriba_A] },
608 },
609 }
610})()
611
612/** Versión de una línea por emoción, para la status line. */
613export const ONE_LINERS: Record<Emotion, string> = {
614 normal: "ψ(•‿•)",
615 feliz: "ψ(^▽^)♥",
616 sonriente: "ψ(^▽^)",
617 enojado: "ψ(ಠ益ಠ)",
618 dormido: "ψ(-.-)zz",
619 asustado: "ψ(°o°)!",
620 pensando: "ψ(•_•)💡",
621 preocupado: "ψ(°~°);",
622 concentrado: "ψ(•ᴗ<)",
623}
624
625/** Pone una capa encima de una cuadrícula. En la capa, ' ' significa "no tocar". */
626export function overlay(base: Grid, layer: Grid): Grid {
627 return base.map((row, y) => {
628 const top = layer[y] ?? ''
629 let out = ''
630 for (let x = 0; x < WIDTH; x++) {
631 const c = top[x]
632 out += c && c !== ' ' ? c : row[x] ?? '.'
633 }
634 return out
635 })
636}
637
638/** Arma el sprite de un rol en un estado y un cuadro. */
639export function sprite(role: Role, state: string, frame: Frame = 0): Grid {
640 const def = ROLES[role]?.[state]
641 if (!def) throw new Error(`Estado desconocido: ${role}.${state}`)
642 return overlay((def.face ?? EMOTIONS[def.emotion])[frame], def.layers[frame])
643}
644
645/** Sprite solo con la cara, sin objeto de rol. */
646export function face(emotion: Emotion, frame: Frame = 0): Grid {
647 return EMOTIONS[emotion][frame]
648}
649
650function colorOf(ch: string | undefined): number | null {
651 if (!ch || ch === '.' || ch === ' ') return null
652 const c = PALETTE[ch]
653 if (c === undefined) throw new Error(`Letra sin color en PALETTE: '${ch}'`)
654 return c
655}
656
657const UPPER = '▀'.codePointAt(0)!
658const LOWER = '▄'.codePointAt(0)!
659const SPACE = ' '.codePointAt(0)!
660
661/**
662 * Convierte una cuadrícula en las celdas empacadas que recibe el elemento Raster.
663 * Cada celda junta dos píxeles verticales: ▀ con el de arriba como color y el de abajo como fondo.
664 * Resultado: columns = WIDTH (18), rows = RASTER_ROWS (6).
665 */
666export function toRasterCells(grid: Grid): string {
667 const numbers: number[] = []
668 for (let y = 0; y < HEIGHT; y += 2) {
669 for (let x = 0; x < WIDTH; x++) {
670 const top = colorOf(grid[y]?.[x])
671 const bottom = colorOf(grid[y + 1]?.[x])
672 if (top !== null && bottom !== null) numbers.push(UPPER, top, bottom)
673 else if (top !== null) numbers.push(UPPER, top, DEFAULT_COLOR)
674 else if (bottom !== null) numbers.push(LOWER, bottom, DEFAULT_COLOR)
675 else numbers.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
676 }
677 }
678 const bytes = new Uint8Array(Uint32Array.from(numbers).buffer)
679 const anyBytes = bytes as Uint8Array & { toBase64?: () => string }
680 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
681 let bin = ''
682 for (const b of bytes) bin += String.fromCharCode(b)
683 return btoa(bin)
684}
685
686/** Props listas para Raster({ ...rasterProps(grid, 'mefisto') }). */
687export function rasterProps(grid: Grid, key: string) {
688 return { key, columns: WIDTH, rows: RASTER_ROWS, cells: toRasterCells(grid) }
689}
690
691/** Convierte una cuadrícula en un documento SVG (para la app de escritorio). */
692export function toSvg(grid: Grid, scale = 6): string {
693 const rects: string[] = []
694 grid.forEach((row, y) => {
695 let x = 0
696 while (x < WIDTH) {
697 const ch = row[x]
698 const c = colorOf(ch)
699 if (c === null) { x++; continue }
700 let end = x
701 while (end < WIDTH && row[end] === ch) end++
702 const hex = '#' + c.toString(16).padStart(6, '0')
703 rects.push(`<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${hex}"/>`)
704 x = end
705 }
706 })
707 const w = WIDTH * scale
708 const h = HEIGHT * scale
709 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">${rects.join('')}</svg>`
710}
711types/index.d.ts 66 lines1export type BoardItem = {
2 number: number
3 title: string
4 after: number[]
5 hasDepsSection: boolean
6}
7
8export type BoardList = {
9 items: BoardItem[]
10 blockedCount: number
11 cycleCount: number
12 launch: string | null
13 error: string | null
14}
15
16export type BoardTab = 'borrador' | 'listo'
17
18/** Lo que el planner esta haciendo: refinar un borrador o explorar una idea. */
19export type BoardFocus = {
20 kind: 'refinar' | 'explorar'
21 issue: number | null
22 topic: string
23 created: number[]
24 startedMs: number
25}
26
27/** Ficha de un issue: lo que la conversacion no muestra (labels, de que depende y a quien bloquea). */
28export type IssueCard = {
29 number: number
30 title: string
31 labels: string[]
32 deps: number[]
33 hasDepsSection: boolean
34}
35
36/** Issue abierto, para saber si una dependencia sigue abierta y en que estado. */
37export type OpenIssueBrief = { number: number; title: string; labels: string[] }
38
39/** Resumen breve que la banda muestra al cerrar un foco. */
40export type BoardFlash = { text: string; untilMs: number }
41
42declare module 'claude-code' {
43 interface PluginState {
44 'mefisto-planner-board': {
45 isActive: boolean
46 isPlannerSession: boolean
47 isExpanded: boolean
48 tab: BoardTab
49 page: number
50 frame: 0 | 1
51 stepsInTurn: number
52 isWorking: boolean
53 focus: BoardFocus | null
54 card: IssueCard | null
55 open: OpenIssueBrief[]
56 flash: BoardFlash | null
57 refine: BoardList | null
58 develop: BoardList | null
59 updatedMs: number
60 signature: string
61 pendingCount: number
62 known: number[]
63 }
64 }
65}
66