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-monitor@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-board on lo enciende a mano.
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ó #….| 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 |
5-9 | Borradores: Refina el borrador #N en el prompt. Listos es solo lectura: el planner no lanza trabajo |
0 | Siguiente pagina |
/mefisto-board [refresh|borradores|listos|cerrar|on|off] cubre lo mismo sin teclas.
claude plugin validate .claude/mods/mefisto-planner-board
claude plugin test .claude/mods/mefisto-planner-boardhooks/register.tsx 526 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 clip,
12 createdIssueOf,
13 createdText,
14 cropGrid,
15 isIssueChange,
16 isIssueCreate,
17 usedColumns,
18 mascotPose,
19 isPlannerClosing,
20 issueMarkedListo,
21 numberWidth,
22 padEnd,
23 pageOf,
24 pageSizeOf,
25 parseNextOrder,
26 parseOpenIssues,
27 reasonOf,
28 refineTargetOf,
29 signatureOf,
30 topicOf,
31} from './logic'
32import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, sprite } from './sprites'
33
34const PLANNER_AGENT = 'mefisto-planner'
35const POLL_MS = 60_000
36const SCRIPT_TIMEOUT_MS = 180_000
37const FLASH_MS = 8_000
38// GitHub tarda en reflejar un cambio en todas sus consultas: un refresco inmediato puede guardar la firma nueva
39// con listas viejas de next-order y el poll ya no vuelve a refrescar. Se repite forzado tras este margen.
40const SETTLE_MS = 15_000
41
42const activeAtom = atom({ plugin: 'mefisto-planner-board', key: 'isActive' } as const, false)
43const plannerAtom = atom({ plugin: 'mefisto-planner-board', key: 'isPlannerSession' } as const, false)
44const expandedAtom = atom({ plugin: 'mefisto-planner-board', key: 'isExpanded' } as const, false)
45const pageAtom = atom({ plugin: 'mefisto-planner-board', key: 'page' } as const, 0)
46const tabAtom = atom({ plugin: 'mefisto-planner-board', key: 'tab' } as const, 'borrador')
47const focusAtom = atom({ plugin: 'mefisto-planner-board', key: 'focus' } as const, null)
48const flashAtom = atom({ plugin: 'mefisto-planner-board', key: 'flash' } as const, null)
49const refineAtom = atom({ plugin: 'mefisto-planner-board', key: 'refine' } as const, null)
50const developAtom = atom({ plugin: 'mefisto-planner-board', key: 'develop' } as const, null)
51const updatedAtom = atom({ plugin: 'mefisto-planner-board', key: 'updatedMs' } as const, 0)
52const signatureAtom = atom({ plugin: 'mefisto-planner-board', key: 'signature' } as const, '')
53const frameAtom = atom({ plugin: 'mefisto-planner-board', key: 'frame' } as const, 0)
54const stepsAtom = atom({ plugin: 'mefisto-planner-board', key: 'stepsInTurn' } as const, 0)
55const workingAtom = atom({ plugin: 'mefisto-planner-board', key: 'isWorking' } as const, false)
56const knownAtom = atom({ plugin: 'mefisto-planner-board', key: 'known' } as const, [])
57
58let isInteractive = false
59let isRefreshing = false
60let settle: { cancel: () => void } | null = null
61let timer: { cancel: () => void } | null = null
62let animation: { cancel: () => void } | null = null
63const ANIMATION_MS = 600
64
65async function runNextOrder($: EngineInterface, args: string[]): Promise<BoardList> {
66 const { exitCode, stdout, stderr } = await $.process
67 .run([NEXT_ORDER, ...args], { timeoutMs: SCRIPT_TIMEOUT_MS })
68 .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) }))
69 return parseNextOrder(exitCode, stdout, stderr)
70}
71
72async function refresh($: EngineInterface, isForced: boolean) {
73 if (isRefreshing || !(await read($, activeAtom))) return
74 isRefreshing = true
75 try {
76 const { exitCode, stdout } = await $.process
77 .run(['gh', 'issue', 'list', '--state', 'open', '--limit', '300', '--json', 'number,title,labels,updatedAt'])
78 .catch(() => ({ exitCode: 1, stdout: '' }))
79 const issues = exitCode === 0 ? parseOpenIssues(stdout) : null
80 if (!issues) return
81
82 const signature = signatureOf(issues)
83 if (!isForced && signature === (await read($, signatureAtom))) return
84
85 const focus = await read($, focusAtom)
86 for (const a of arrivals(issues, await read($, knownAtom))) {
87 if (!focus?.created.includes(a.number)) $.ui.toast(`Nuevo ${a.kind} #${a.number}: ${clip(a.title, 60)}`)
88 }
89 const [refine, develop] = await Promise.all([runNextOrder($, ['--refinement', '--json']), runNextOrder($, ['--json'])])
90 await update($, refineAtom, () => refine)
91 await update($, developAtom, () => develop)
92 await update($, signatureAtom, () => signature)
93 await update($, knownAtom, () => issues.map(i => i.number))
94 await update($, updatedAtom, () => Date.now())
95 } finally {
96 isRefreshing = false
97 }
98}
99
100async function activate($: EngineInterface) {
101 await update($, activeAtom, () => true)
102 timer?.cancel()
103 timer = $.clock.every(POLL_MS, () => void refresh($, false))
104 void refresh($, true)
105}
106
107async function deactivate($: EngineInterface) {
108 timer?.cancel()
109 timer = null
110 await update($, activeAtom, () => false)
111}
112
113const DETECT_TRIES = 20
114const DETECT_EVERY_MS = 3_000
115
116// El agente principal de la sesion. Los eventos clasicos no llegan a un modulo cargado con --plugin-dir,
117// asi que se lee primero la linea de comando del proceso de Claude Code (padre del `sh` que corre aqui) y,
118// si no se puede, el inicio del transcript, que solo existe tras el primer mensaje: por eso se reintenta.
119async function detectPlanner($: EngineInterface, triesLeft: number) {
120 if (!isInteractive || (await read($, activeAtom))) return
121 const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
122 const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
123 if (/\bclaude\b/.test(cmdline)) {
124 if (agentFlagOf(cmdline) === PLANNER_AGENT) await claimPlanner($)
125 return
126 }
127 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
128 const path = transcriptPathOf(home, await $.session.cwd(), await $.session.id())
129 const { stdout } = await $.process.run(['head', '-c', '200000', path]).catch(() => ({ stdout: '' }))
130 const agent = agentSettingOf(stdout)
131 if (agent === PLANNER_AGENT) {
132 await claimPlanner($)
133 return
134 }
135 if (agent === null && triesLeft > 0) $.clock.after(DETECT_EVERY_MS, () => void detectPlanner($, triesLeft - 1))
136}
137
138// Marca la sesion como del planner y la activa si ya se sabe que es interactiva. Devuelve si la activo ahora.
139async function claimPlanner($: EngineInterface): Promise<boolean> {
140 await update($, plannerAtom, () => true)
141 if (!isInteractive || (await read($, activeAtom))) return false
142 await activate($)
143 return true
144}
145
146// Cada instruccion del agente (una herramienta, o tu mensaje que abre el turno) alterna el cuadro A/B.
147async function step($: EngineInterface, isNewTurn: boolean) {
148 await update($, frameAtom, f => (f === 1 ? 0 : 1))
149 await update($, stepsAtom, n => (isNewTurn ? 0 : (n ?? 0) + 1))
150}
151
152// Recorte comun a todos los cuadros del planner, calculado una vez: sin margen y sin saltos al alternar.
153const MASCOT_STATES = ['planeando', 'pensando', 'listo'] as const
154const MASCOT_COLS = usedColumns(MASCOT_STATES.flatMap(state => [sprite('planner', state, 0), sprite('planner', state, 1)]))
155const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
156
157/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
158function mascotCells(grid: readonly string[]): string {
159 const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
160 const words: number[] = []
161 for (let y = 0; y < HEIGHT; y += 2) {
162 for (let x = 0; x < MASCOT_WIDTH; x++) {
163 const top = color(grid[y]?.[x])
164 const bottom = color(grid[y + 1]?.[x])
165 if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
166 else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
167 else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
168 }
169 }
170 let bin = ''
171 for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
172 return btoa(bin)
173}
174
175// Mientras Claude trabaja (pensando o con herramientas) la mascota alterna sola; en reposo queda quieta.
176async function setWorking($: EngineInterface, isWorking: boolean) {
177 await update($, workingAtom, () => isWorking)
178 animation?.cancel()
179 animation = isWorking ? $.clock.every(ANIMATION_MS, () => void update($, frameAtom, f => (f === 1 ? 0 : 1))) : null
180}
181
182async function startFocus($: EngineInterface, focus: BoardFocus) {
183 await update($, flashAtom, () => null)
184 await update($, focusAtom, () => focus)
185}
186
187async function closeFocus($: EngineInterface, summary: string) {
188 await update($, focusAtom, () => null)
189 await update($, flashAtom, () => ({ text: summary, untilMs: Date.now() + FLASH_MS }))
190 $.clock.after(FLASH_MS, () => void update($, flashAtom, () => null))
191 void refresh($, true)
192}
193
194function summaryOf(focus: BoardFocus, isListo: boolean): string {
195 const created = createdText(focus.created)
196 const head =
197 focus.kind === 'refinar'
198 ? `✓ #${focus.issue}${isListo ? ' → listo' : ' · refinamiento cerrado'}`
199 : `✓ exploración «${clip(focus.topic, 40)}»`
200 return [head, created].filter(Boolean).join(' · ')
201}
202
203async function onPrompt($: EngineInterface, text: string) {
204 const focus = await read($, focusAtom)
205 const target = refineTargetOf(text)
206 if (target !== null && !(focus?.kind === 'refinar' && focus.issue === target)) {
207 const known = (await read($, refineAtom))?.items.find(i => i.number === target)
208 await startFocus($, { kind: 'refinar', issue: target, topic: known?.title ?? '', created: [], startedMs: Date.now() })
209 return
210 }
211 if (!focus && target === null && text.trim() !== '' && !text.trim().startsWith('/')) {
212 await startFocus($, { kind: 'explorar', issue: null, topic: topicOf(text), created: [], startedMs: Date.now() })
213 }
214}
215
216function refreshAfterSettle($: EngineInterface) {
217 settle?.cancel()
218 settle = $.clock.after(SETTLE_MS, () => void refresh($, true))
219}
220
221async function onBash($: EngineInterface, command: string, output: string) {
222 if (isIssueChange(command)) refreshAfterSettle($)
223 const focus = await read($, focusAtom)
224 if (!focus) return
225 // Un mismo comando puede crear un borrador y pasar el foco a listo: se revisan las dos cosas.
226 if (isIssueCreate(command)) {
227 const created = createdIssueOf(output)
228 if (created !== null && !focus.created.includes(created)) {
229 await update($, focusAtom, f => (f ? { ...f, created: [...f.created, created] } : f))
230 }
231 }
232 const listo = issueMarkedListo(command)
233 if (focus.kind === 'refinar' && listo !== null && listo === focus.issue) {
234 const updated = (await read($, focusAtom)) ?? focus
235 await closeFocus($, summaryOf(updated, true))
236 return
237 }
238 if (isPlannerClosing(command)) await closeFocus($, summaryOf(focus, false))
239}
240
241// Cierre de la exploracion con una tecla: pulsarla ya es la autorizacion, asi que le pide al planner su rutina
242// de cierre (resumen + field note) sin confirmar. El foco se cierra solo cuando el planner corre
243// mefisto-field-note.sh.
244async function closeSession($: EngineInterface) {
245 await $.prompt.submit({ text: 'Cierra la sesión ahora, sin pedir confirmación: resumen, field note y entrega.', asUser: true })
246}
247
248// La misma tecla abre su lista o, si ya estaba abierta, la cierra.
249async function toggleList($: EngineInterface, tab: BoardTab) {
250 if (await read($, focusAtom)) return
251 const isOpen = (await read($, expandedAtom)) && (await read($, tabAtom)) === tab
252 await update($, tabAtom, () => tab)
253 await update($, pageAtom, () => 0)
254 await update($, expandedAtom, () => !isOpen)
255}
256
257// Avanza de pagina y da la vuelta al final.
258async function nextPage($: EngineInterface) {
259 const list = (await read($, tabAtom)) === 'borrador' ? await read($, refineAtom) : await read($, developAtom)
260 const tab = await read($, tabAtom)
261 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, pageSizeOf(tab))
262 await update($, pageAtom, () => (page + 1) % pages)
263}
264
265async function fill($: EngineInterface, text: string) {
266 await $.prompt.fill({ text, mode: 'replace' })
267}
268
269export const register: Register = on => {
270 on('session.start', async ($, e, next) => {
271 isInteractive = e.isInteractive
272 await $.command.register({
273 name: 'mefisto-board',
274 description: 'Tablero del planner: refresh | borradores | listos | cerrar | on | off',
275 argumentHint: '[refresh|borradores|listos|cerrar|on|off]',
276 immediate: true,
277 })
278 const isWanted = (await read($, activeAtom)) || (await read($, plannerAtom))
279 if (isInteractive && isWanted) await activate($)
280 if (isInteractive && (await read($, workingAtom))) await setWorking($, true)
281 else if (isInteractive) void detectPlanner($, DETECT_TRIES)
282 return next(e)
283 })
284
285 on('prompt.submit', async ($, e, next) => {
286 if (e.origin.kind === 'composer' && !(await read($, activeAtom))) await detectPlanner($, 0)
287 if (await read($, activeAtom)) {
288 await step($, true)
289 await setWorking($, true)
290 }
291 if (e.origin.kind === 'composer' && (await read($, activeAtom))) await onPrompt($, e.text)
292 return next(e)
293 }).catch(($, e, next) => next(e))
294
295 on('tool.call', async ($, e, next) => {
296 if (await read($, activeAtom)) await step($, false)
297 return next(e)
298 })
299
300 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
301 const ran = await next(e)
302 if (await read($, activeAtom)) {
303 const output = ran.text ?? JSON.stringify(ran.result ?? '')
304 await onBash($, e.command, output).catch(() => undefined)
305 }
306 return ran
307 })
308
309 on('turn.complete', async ($, e, next) => {
310 const done = await next(e)
311 if (await read($, activeAtom)) await setWorking($, false)
312 return done
313 })
314
315 on('command.run', { command: 'mefisto-board' }, async ($, e) => {
316 const arg = e.args.trim()
317 if (arg === 'off') {
318 await deactivate($)
319 return { text: 'Tablero del planner apagado en esta sesión.' }
320 }
321 if (arg === 'on' || !(await read($, activeAtom))) {
322 await activate($)
323 return { text: 'Tablero del planner activo en esta sesión.' }
324 }
325 if (arg === 'borradores' || arg === 'listos') {
326 await toggleList($, arg === 'borradores' ? 'borrador' : 'listo')
327 return { text: `Lista de ${arg} alternada.` }
328 }
329 if (arg === 'cerrar') {
330 const focus = await read($, focusAtom)
331 if (focus) await closeFocus($, summaryOf(focus, false))
332 return { text: focus ? 'Foco cerrado.' : 'No había foco abierto.' }
333 }
334 await refresh($, true)
335 return { text: 'Tablero actualizado.' }
336 })
337
338 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
339 if (!(await read($, activeAtom)) || e.props.hasSurvey) return next(e)
340 const { Box, Text, Button } = $.ui.resolve(e)
341 const focus = await read($, focusAtom)
342 const flash = await read($, flashAtom)
343 const isExpanded = await read($, expandedAtom)
344 const refine = await read($, refineAtom)
345 const develop = await read($, developAtom)
346 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
347 const count = (l: BoardList | null) => (l ? String(l.items.length) : '…')
348 const tab = await read($, tabAtom)
349 const suggested = refine?.items[0]?.number ?? null
350 const listButtons = (
351 <Box gap={2}>
352 <Button key="list-borrador" hotkey="3" plain variant={isExpanded && tab === 'borrador' ? 'primary' : undefined}
353 dimColor={!(isExpanded && tab === 'borrador')} label={`borradores ${count(refine)}`}
354 onPress={() => void toggleList($, 'borrador')} />
355 <Button key="list-listo" hotkey="4" plain variant={isExpanded && tab === 'listo' ? 'primary' : undefined}
356 dimColor={!(isExpanded && tab === 'listo')} label={`listos ${count(develop)}`}
357 onPress={() => void toggleList($, 'listo')} />
358 </Box>
359 )
360
361 let head
362 if (focus) {
363 const label =
364 focus.kind === 'refinar'
365 ? `refinar #${focus.issue}${focus.topic ? ` · ${focus.topic}` : ''}`
366 : `explorar · ${focus.topic}`
367 const created = createdText(focus.created)
368 head = (
369 <Box justifyContent="space-between">
370 <Box gap={2}>
371 <Text color="claude">●</Text>
372 <Text bold wrap="truncate-end">{clip(label, Math.max(20, inner - created.length - 46))}</Text>
373 {created !== '' && <Text color="success">{created}</Text>}
374 </Box>
375 {focus.kind === 'explorar' && (
376 <Button key="close-session" hotkey="1" plain label="cerrar sesión" onPress={() => void closeSession($)} />
377 )}
378 </Box>
379 )
380 } else if (flash && flash.untilMs > Date.now()) {
381 head = (
382 <Box justifyContent="space-between">
383 <Text color="success">{clip(flash.text, inner - 34)}</Text>
384 {listButtons}
385 </Box>
386 )
387 } else {
388 head = (
389 <Box justifyContent="space-between">
390 <Box gap={3}>
391 <Button key="mode-explorar" hotkey="1" plain label="explorar" onPress={() => void fill($, 'Quiero explorar: ')} />
392 {suggested !== null && (
393 <Button key="mode-refinar" hotkey="2" plain label={`refinar #${suggested}`}
394 onPress={() => void fill($, `Refina el borrador #${suggested}`)} />
395 )}
396 </Box>
397 {listButtons}
398 </Box>
399 )
400 }
401
402 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
403 const frame = await read($, frameAtom)
404 const isWorking = e.props.isWorking || (await read($, workingAtom))
405 const state = mascotPose(isWorking, await read($, stepsAtom), !!flash && flash.untilMs > Date.now())
406 const grid = cropGrid(sprite('planner', state, frame), MASCOT_COLS)
407 const elements = $.ui.resolve(e)
408 const mascot =
409 'Raster' in elements ? (
410 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
411 ) : (
412 <Box width={MASCOT_WIDTH} />
413 )
414
415 const updatedMs = await read($, updatedAtom)
416 let content
417 if (focus) {
418 const minutes = Math.max(0, Math.floor((Date.now() - (focus.startedMs ?? Date.now())) / 60_000))
419 const what = focus.kind === 'refinar' ? `Refinando #${focus.issue}` : 'Explorando'
420 const ends =
421 focus.kind === 'refinar'
422 ? `termina al pasar #${focus.issue} a estado:listo o con el cierre`
423 : 'termina con el cierre del planner'
424 content = (
425 <Box flexDirection="column" width={body}>
426 <Text bold color="claude">{what}{minutes > 0 ? ` · ${minutes} min` : ''}</Text>
427 <Text wrap="wrap">{clip(focus.topic || ' ', body * 2 - 2)}</Text>
428 <Text color={focus.created.length > 0 ? 'success' : undefined} dimColor={focus.created.length === 0}>
429 {focus.created.length > 0 ? createdText(focus.created) : 'sin borradores nuevos'}
430 </Text>
431 <Text dimColor wrap="truncate-end">{ends}</Text>
432 <Text dimColor>{focus.kind === 'explorar' ? '1 cierra la sesión' : '/mefisto-board cerrar lo cierra a mano'}</Text>
433 </Box>
434 )
435 } else if (!isExpanded) {
436 const nextRefine = refine?.items.slice(0, 2) ?? []
437 const nextDevelop = develop?.items[0]
438 content = (
439 <Box flexDirection="column" width={body}>
440 <Text dimColor>siguiente a refinar</Text>
441 {[0, 1].map(i => (
442 <Text key={`r-${i}`} dimColor={i > 0} wrap="truncate-end">
443 {nextRefine[i] ? `#${nextRefine[i].number} ${clip(nextRefine[i].title, body - 8)}` : ' '}
444 </Text>
445 ))}
446 <Text dimColor>siguiente a desarrollar</Text>
447 <Text wrap="truncate-end">{nextDevelop ? `#${nextDevelop.number} ${clip(nextDevelop.title, body - 8)}` : ' '}</Text>
448 <Text dimColor>{updatedMs ? ' ' : 'cargando…'}</Text>
449 </Box>
450 )
451 } else {
452 const list = tab === 'borrador' ? refine : develop
453 const size = pageSizeOf(tab)
454 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, size)
455 const visible = list?.items.slice(page * size, (page + 1) * size) ?? []
456 const blankRows = Math.max(0, size - Math.max(visible.length, 1))
457 const numWidth = numberWidth(visible)
458 // Solo 'tras #N' ocupa columna: sin dependencias no se marca.
459 const afterWidth = Math.min(16, Math.max(0, ...visible.map(i => reasonOf(i).length)))
460 // Ancho exacto de la fila: el prefijo '5: ' de la tecla (solo en borradores), el numero, un espacio
461 // y, si alguno depende de otro, la columna 'tras #N'. El titulo ocupa el resto hasta el marco.
462 const keyPrefix = tab === 'borrador' ? 3 : 0
463 const titleWidth = Math.max(12, body - keyPrefix - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
464 const more = list
465 ? [
466 list.blockedCount > 0 ? `${list.blockedCount} bloqueados` : '',
467 list.cycleCount > 0 ? `${list.cycleCount} en ciclo` : '',
468 ].filter(Boolean).join(' · ')
469 : ''
470 const hint = tab === 'borrador' ? '5-9 al prompt' : ''
471 content = (
472 <Box flexDirection="column" width={body}>
473 {list?.error && <Text color="error">next-order falló: {clip(list.error, body - 20)}</Text>}
474 {list && !list.error && visible.length === 0 && (
475 <Text dimColor>{tab === 'borrador' ? 'Sin borradores refinables.' : 'Sin issues lanzables.'}</Text>
476 )}
477 {visible.map((item, i) => {
478 const reason = reasonOf(item)
479 return (
480 <Box>
481 {tab === 'borrador' ? (
482 <Button
483 key={`item-${item.number}`}
484 hotkey={String(i + 5)}
485 plain
486 dimColor={page > 0 || i > 0}
487 label={`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
488 onPress={() => void fill($, `Refina el borrador #${item.number}`)}
489 />
490 ) : (
491 <Text dimColor={page > 0 || i > 0}>
492 {`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
493 </Text>
494 )}
495 <Text dimColor>{reason ? ` ${clip(reason, afterWidth)}` : ''}</Text>
496 </Box>
497 )
498 })}
499 {Array.from({ length: blankRows }, (_, i) => (
500 <Text key={`blank-${i}`}> </Text>
501 ))}
502 <Box justifyContent="space-between">
503 <Text dimColor>{hint}</Text>
504 <Box gap={2}>
505 {more !== '' && <Text dimColor>{more}</Text>}
506 {pages > 1 && (
507 <Button key="next-page" hotkey="0" plain dimColor label={`página ${page + 1}/${pages} ▸`} onPress={() => void nextPage($)} />
508 )}
509 </Box>
510 </Box>
511 </Box>
512 )
513 }
514
515 return (
516 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
517 {head}
518 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
519 {mascot}
520 <Box marginTop={1}>{content}</Box>
521 </Box>
522 </Box>
523 )
524 })
525}
526hooks/logic.ts 232 lines1import type { BoardItem, BoardList } 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, ''), 70)
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/** El cierre del planner: ejecuta mefisto-field-note.sh (leerlo con git show o grep no cierra nada). */
145export function isPlannerClosing(command: string): boolean {
146 return runs(command, String.raw`\S*mefisto-field-note\.sh\b`) !== null
147}
148
149/** Numero del issue que `gh issue create` imprime como URL. */
150export function createdIssueOf(output: string): number | null {
151 const m = /\/issues\/(\d+)/.exec(output)
152 return m ? Number(m[1]) : null
153}
154
155export function createdText(created: number[]): string {
156 return created.length > 0 ? `creó ${created.map(n => `#${n}`).join(' ')}` : ''
157}
158
159/** Issues por pagina, igual en las dos listas. */
160export function pageSizeOf(_tab: 'borrador' | 'listo'): number {
161 return LIST_ROWS
162}
163
164/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
165export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
166 const pages = Math.max(1, Math.ceil(total / size))
167 return { page: page >= 0 && page < pages ? page : 0, pages }
168}
169
170/** Ruta del transcript de una sesion: ~/.claude/projects/<cwd con lo no alfanumerico como '-'>/<id>.jsonl. */
171export function transcriptPathOf(configDir: string, cwd: string, sessionId: string): string {
172 return `${configDir}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
173}
174
175/**
176 * `claude --agent mefisto-planner` deja en el transcript de la sesion filas
177 * `{"type":"agent-setting","agentSetting":"mefisto-planner"}`; manda la ultima. Un planner lanzado como
178 * subagente escribe en el transcript del subagente, nunca en el de la sesion que lo lanzo.
179 */
180export function agentSettingOf(transcript: string): string | null {
181 let found: string | null = null
182 for (const line of transcript.split('\n')) {
183 if (!line.includes('"agent-setting"')) continue
184 try {
185 const row = JSON.parse(line)
186 if (row.type === 'agent-setting' && typeof row.agentSetting === 'string') found = row.agentSetting
187 } catch {
188 continue
189 }
190 }
191 return found
192}
193
194/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
195export function agentFlagOf(cmdline: string): string | null {
196 const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
197 return m ? (m[1] ?? null) : null
198}
199
200export type MascotState = 'planeando' | 'pensando' | 'listo'
201
202/** Siempre la mascota del planner: trabajando con herramientas planea, sin ellas piensa y al cerrar un foco queda lista. */
203export function mascotPose(isWorking: boolean, stepsInTurn: number, hasFlash: boolean): MascotState {
204 if (isWorking) return stepsInTurn > 0 ? 'planeando' : 'pensando'
205 if (hasFlash) return 'listo'
206 return 'planeando'
207}
208
209type Grid = readonly string[]
210
211/** Columnas con algun pixel visible en cualquiera de los cuadros: recorta el margen sin que la mascota salte. */
212export function usedColumns(grids: readonly Grid[]): { from: number; to: number } {
213 let from = Infinity
214 let to = -1
215 for (const g of grids) {
216 for (const row of g) {
217 for (let x = 0; x < row.length; x++) {
218 const c = row[x]
219 if (c !== '.' && c !== ' ') {
220 from = Math.min(from, x)
221 to = Math.max(to, x)
222 }
223 }
224 }
225 }
226 return to < 0 ? { from: 0, to: 0 } : { from, to }
227}
228
229export function cropGrid(grid: Grid, cols: { from: number; to: number }): Grid {
230 return grid.map(row => row.slice(cols.from, cols.to + 1).padEnd(cols.to - cols.from + 1, '.'))
231}
232hooks/sprites.ts 734 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/**
31 * Paleta: letra -> color 0xRRGGBB. Los mods internos (este repo) usan un Mefisto blanco perla con cuernos dorados para no confundirse
32 * con los del consumidor, que conservan el rojo de mefisto-sprites.ts (cuernos/cola 0xA32D2D, cuerpo 0xE24B4A,
33 * boca 0x501313).
34 */
35export const PALETTE: Record<string, number> = {
36 "h": 0xD9A441, // cuernos (dorados)
37 "r": 0xEEEDF5, // cuerpo
38 "t": 0xD9A441, // cola (dorada)
39 "E": 0x2C2C2A, // ojos / pupilas
40 "M": 0x4A4A5E, // boca y cejas
41 "W": 0xF1EFE8, // colmillos
42 "p": 0xD4537E, // corazones y lengua
43 "s": 0xB4B2A9, // humo (enojado)
44 "B": 0x85B7EB, // Z y gota de sudor
45 "Y": 0xEF9F27, // bombillo, casco, flecha
46 "y": 0xFAC775, // destellos
47 "w": 0xFFF6D9, // brillo del bombillo
48 "G": 0x888780, // casquillo del bombillo
49 "o": 0xB4B2A9, // bombillo apagado
50 "D": 0x5F5E5A, // marco del portátil
51 "U": 0x378ADD, // pantalla
52 "u": 0xB5D4F4, // líneas de código
53 "q": 0xB4B2A9, // teclado / renglones
54 "Q": 0xFFFFFF, // blanco (pantalla, hoja)
55 "R": 0xE24B4A, // error / marcas rojas
56 "V": 0x639922, // verde de éxito
57 "g": 0x888780, // vidrio del matraz
58 "L": 0x97C459, // líquido del matraz
59 "l": 0xEAF3DE, // reflejo del líquido
60 "b": 0xC0DD97, // burbujas
61 "P": 0x7F77DD, // marco de la lupa
62 "e": 0xEEEDFE, // brillo de la lupa
63 "n": 0x854F0B, // mango de la lupa
64 "m": 0xE8C98E, // mapa claro
65 "j": 0xC9A866, // mapa oscuro
66 "Z": 0x444441, // ruta del mapa
67 "O": 0xD85A30, // destino del mapa
68 "c": 0xB5D4F4, // nube
69 "C": 0x888780, // nube caída
70}
71
72export type Emotion =
73 | 'normal'
74 | 'feliz'
75 | 'sonriente'
76 | 'enojado'
77 | 'dormido'
78 | 'asustado'
79 | 'pensando'
80 | 'preocupado'
81 | 'concentrado'
82
83/** Caras base: [cuadro A, cuadro B]. Solo 'pensando' cambia entre cuadros (bombillo encendido / apagado). */
84export const EMOTIONS: Record<Emotion, readonly [Grid, Grid]> = (() => {
85 const normalA: Grid = [
86 '..................',
87 '..................',
88 '....h..........h..',
89 '....hh........hh..',
90 '.....hrrrrrrrrh...',
91 '.....rrrrrrrrrr..t',
92 '.....rrEErrEErr.tt',
93 '.....rrEErrEErr..t',
94 '.....rrrrrrrrrr..t',
95 '......rrMMMMrr..t.',
96 '.......rrrrrrtt...',
97 '.......rr..rr.....',
98 ]
99 const felizA: Grid = [
100 '......p.p..p.p....',
101 '......ppp..ppp....',
102 '....h..p....p..h..',
103 '....hh........hh..',
104 '.....hrrrrrrrrh...',
105 '.....rrrrrrrrrr..t',
106 '.....rrEErrEErr.tt',
107 '.....rrrrrrrrrr..t',
108 '.....rMrrrrrrMr..t',
109 '......rMMWWMMr..t.',
110 '.......rrrrrrtt...',
111 '.......rr..rr.....',
112 ]
113 const sonrienteA: Grid = [
114 '..................',
115 '..................',
116 '....h..........h..',
117 '....hh........hh..',
118 '.....hrrrrrrrrh...',
119 '.....rrrrrrrrrr..t',
120 '.....rrEErrEErr.tt',
121 '.....rrrrrrrrrr..t',
122 '.....rMrrrrrrMr..t',
123 '......rMMWWMMr..t.',
124 '.......rrrrrrtt...',
125 '.......rr..rr.....',
126 ]
127 const enojadoA: Grid = [
128 '.....s.s......s.s.',
129 '......s........s..',
130 '....h..........h..',
131 '....hh........hh..',
132 '.....hrrrrrrrrh...',
133 '.....rMrrrrrrMr..t',
134 '.....rrMMrrMMrr.tt',
135 '.....rrEErrEErr..t',
136 '.....rrrrrrrrrr..t',
137 '......rrMMMMrr..t.',
138 '.......rMrrMrtt...',
139 '.......rr..rr.....',
140 ]
141 const dormidoA: Grid = [
142 '...........BBB....',
143 '............B.....',
144 '....h......BBB.h..',
145 '....hh........hh..',
146 '.....hrrrrrrrrh...',
147 '.....rrrrrrrrrr..t',
148 '.....rrrrrrrrrr.tt',
149 '.....rEErrrrEEr..t',
150 '.....rrrrrrrrrr..t',
151 '......rrrMMrrr..t.',
152 '.......rrrrrrtt...',
153 '.......rr..rr.....',
154 ]
155 const asustadoA: Grid = [
156 '.......y......y...',
157 '........y....y....',
158 '....h..........h..',
159 '....hh........hh..',
160 '.....hrMMrrMMrhB..',
161 '.....rrrrrrrrrrB.t',
162 '.....rrWWrrWWrr.tt',
163 '.....rrWErrWErr..t',
164 '.....rrrrMMrrrr..t',
165 '......rrrMMrrr..t.',
166 '.......rrrrrrtt...',
167 '.......rr..rr.....',
168 ]
169 const pensandoA: Grid = [
170 '......y..YY..y....',
171 '........YwYY......',
172 '....h...YYYY...h..',
173 '....hh...GG...hh..',
174 '.....hrrrrrrrrh...',
175 '.....rrrrrrrrrr..t',
176 '.....rrrEErrEEr.tt',
177 '.....rrrrrrrrrr..t',
178 '.....rrrrrrrrrr..t',
179 '......rrrMMrrr..t.',
180 '.......rrrrrrtt...',
181 '.......rr..rr.....',
182 ]
183 const pensandoB: Grid = [
184 '.........oo.......',
185 '........oooo......',
186 '....h...oooo...h..',
187 '....hh...GG...hh..',
188 '.....hrrrrrrrrh...',
189 '.....rrrrrrrrrr..t',
190 '.....rrrEErrEEr.tt',
191 '.....rrrrrrrrrr..t',
192 '.....rrrrrrrrrr..t',
193 '......rrrMMrrr..t.',
194 '.......rrrrrrtt...',
195 '.......rr..rr.....',
196 ]
197 const preocupadoA: Grid = [
198 '.................R',
199 '.................R',
200 '....h..........h..',
201 '....hh........hh.R',
202 '.....hrrrrrrrrh...',
203 '....BrrrMrrMrrr..t',
204 '....BrrMrrrrMrr.tt',
205 '.....rrEErrEErr..t',
206 '.....rrrrrrrrrr..t',
207 '......rrrMrMrr..t.',
208 '.......rMrMrrtt...',
209 '.......rr..rr.....',
210 ]
211 const concentradoA: Grid = [
212 '..................',
213 '..................',
214 '....h..........h..',
215 '....hh........hh..',
216 '.....hrrrrrrrrh...',
217 '.....rrrrrrrrrr..t',
218 '.....rrEErrMMrr.tt',
219 '.....rrEErrEErr..t',
220 '.....rrrrrrrrrr..t',
221 '......rrrMMrrr..t.',
222 '.......rrrprrtt...',
223 '.......rr..rr.....',
224 ]
225 return {
226 normal: [normalA, normalA],
227 feliz: [felizA, felizA],
228 sonriente: [sonrienteA, sonrienteA],
229 enojado: [enojadoA, enojadoA],
230 dormido: [dormidoA, dormidoA],
231 asustado: [asustadoA, asustadoA],
232 pensando: [pensandoA, pensandoB],
233 preocupado: [preocupadoA, preocupadoA],
234 concentrado: [concentradoA, concentradoA],
235 }
236})()
237
238export type Role = 'desarrollador' | 'tester' | 'revisor' | 'planner' | 'infraestructura'
239
240export interface RoleState {
241 /** Cara que usa este estado. */
242 emotion: Emotion
243 /** Capas del rol para el cuadro A y el B. ' ' deja ver la cara. */
244 layers: readonly [Grid, Grid]
245}
246
247/** Objetos y detalles de cada rol, por estado de trabajo. */
248export const ROLES: Record<Role, Record<string, RoleState>> = (() => {
249 // Desarrollador · portátil, azul
250 const desarrollador_trabajando_A: Grid = [
251 ' ',
252 ' ',
253 ' ',
254 ' ',
255 ' ',
256 ' ',
257 ' ',
258 'DDDDD ',
259 'DUuUD ',
260 'DuUUD ',
261 'DDDDD ',
262 'qqqqqqq ',
263 ]
264 const desarrollador_trabajando_B: Grid = [
265 ' ',
266 ' ',
267 ' ',
268 ' ',
269 ' ',
270 ' ',
271 ' ',
272 'DDDDD ',
273 'DuUUD ',
274 'DUUuD ',
275 'DDDDD ',
276 'qqqqqqq ',
277 ]
278 const desarrollador_pensando_A: Grid = [
279 ' ',
280 ' ',
281 ' ',
282 ' ',
283 ' ',
284 ' ',
285 ' ',
286 'DDDDD ',
287 'DUuUD ',
288 'DuUUD ',
289 'DDDDD ',
290 'qqqqqqq ',
291 ]
292 const desarrollador_error_A: Grid = [
293 ' ',
294 ' ',
295 ' ',
296 ' ',
297 ' ',
298 ' ',
299 ' ',
300 'DDDDD ',
301 'DQRQD ',
302 'DRQRD ',
303 'DDDDD ',
304 'qqqqqqq ',
305 ]
306 const desarrollador_error_B: Grid = [
307 ' ',
308 ' ',
309 ' ',
310 ' ',
311 ' ',
312 ' ',
313 ' ',
314 'DDDDD ',
315 'DRQRD ',
316 'DQRQD ',
317 'DDDDD ',
318 'qqqqqqq ',
319 ]
320 const desarrollador_resuelto_A: Grid = [
321 ' ',
322 ' ',
323 ' ',
324 ' ',
325 ' ',
326 ' ',
327 ' ',
328 'DDDDD ',
329 'DVVQD ',
330 'DQQVD ',
331 'DDDDD ',
332 'qqqqqqq ',
333 ]
334 // Tester · matraz, verde
335 const tester_trabajando_A: Grid = [
336 ' ',
337 ' ',
338 ' ',
339 ' ',
340 ' ',
341 ' b ',
342 ' b ',
343 ' ggg ',
344 ' g ',
345 ' LLL ',
346 'LlLLL ',
347 'LLLLL ',
348 ]
349 const tester_trabajando_B: Grid = [
350 ' ',
351 ' ',
352 ' ',
353 ' ',
354 ' ',
355 ' b ',
356 ' b ',
357 ' ggg ',
358 ' g ',
359 ' LLL ',
360 'LlLLL ',
361 'LLLLL ',
362 ]
363 const tester_pensando_A: Grid = [
364 ' ',
365 ' ',
366 ' ',
367 ' ',
368 ' ',
369 ' b ',
370 ' b ',
371 ' ggg ',
372 ' g ',
373 ' LLL ',
374 'LlLLL ',
375 'LLLLL ',
376 ]
377 const tester_feliz_A: Grid = [
378 ' ',
379 ' ',
380 ' ',
381 ' ',
382 ' ',
383 ' b ',
384 ' b ',
385 ' ggg ',
386 ' g ',
387 ' LLL ',
388 'LlLLL ',
389 'LLLLL ',
390 ]
391 const tester_feliz_B: Grid = [
392 ' ',
393 ' ',
394 ' ',
395 ' ',
396 ' ',
397 ' b ',
398 ' b ',
399 ' ggg ',
400 ' g ',
401 ' LLL ',
402 'LlLLL ',
403 'LLLLL ',
404 ]
405 // Revisor · lupa, morado
406 const revisor_trabajando_A: Grid = [
407 ' ',
408 ' ',
409 ' ',
410 ' ',
411 ' ',
412 ' PP ',
413 ' PeEP ',
414 ' PEEP ',
415 ' PP ',
416 ' n ',
417 ' n ',
418 ' n ',
419 ]
420 const revisor_trabajando_B: Grid = [
421 ' ',
422 ' ',
423 ' ',
424 ' ',
425 ' ',
426 ' PP ',
427 ' PEEP ',
428 ' PEeP ',
429 ' PP ',
430 ' n ',
431 ' n ',
432 ' n ',
433 ]
434 const revisor_pensando_A: Grid = [
435 ' ',
436 ' ',
437 ' ',
438 ' ',
439 ' ',
440 ' PP ',
441 ' PeEP ',
442 ' PEEP ',
443 ' PP ',
444 ' n ',
445 ' n ',
446 ' n ',
447 ]
448 const revisor_corrigiendo_A: Grid = [
449 ' ',
450 ' ',
451 ' ',
452 ' ',
453 ' ',
454 ' PP ',
455 ' PeEP ',
456 'QQQ PEEP ',
457 'qqR PP ',
458 'QQQ n ',
459 'qRq n ',
460 'QQQn ',
461 ]
462 const revisor_corrigiendo_B: Grid = [
463 ' ',
464 ' ',
465 ' ',
466 ' ',
467 ' ',
468 ' PP ',
469 ' PEEP ',
470 'QQQ PEeP ',
471 'qqR PP ',
472 'QQQ n ',
473 'qRq n ',
474 'QQQn ',
475 ]
476 const revisor_aprobado_A: Grid = [
477 ' ',
478 ' ',
479 ' ',
480 ' ',
481 ' ',
482 ' PP ',
483 ' PeEP ',
484 'QQQ PEEP ',
485 'QQV PP ',
486 'VQV n ',
487 'QVQ n ',
488 'QQQn ',
489 ]
490 // Planner · mapa, coral
491 const planner_planeando_A: Grid = [
492 ' ',
493 ' ',
494 ' ',
495 ' ',
496 ' ',
497 ' ',
498 'mjmjO ',
499 'mjmjm ',
500 'mjmjm ',
501 'mZmjm ',
502 'Zjmjm ',
503 ' ',
504 ]
505 const planner_planeando_B: Grid = [
506 ' ',
507 ' ',
508 ' ',
509 ' ',
510 ' ',
511 ' ',
512 'mjmjO ',
513 'mjmjm ',
514 'mjZjm ',
515 'mZmjm ',
516 'Zjmjm ',
517 ' ',
518 ]
519 const planner_pensando_A: Grid = [
520 ' ',
521 ' ',
522 ' ',
523 ' ',
524 ' ',
525 ' ',
526 'mjmjO ',
527 'mjmjm ',
528 'mjZjm ',
529 'mZmjm ',
530 'Zjmjm ',
531 ' ',
532 ]
533 const planner_listo_A: Grid = [
534 ' ',
535 ' ',
536 ' ',
537 ' ',
538 ' ',
539 ' ',
540 'mjmjV ',
541 'mjmZm ',
542 'mjZjm ',
543 'mZmjm ',
544 'Zjmjm ',
545 ' ',
546 ]
547 // Infraestructura · nube y casco, amarillo
548 const infraestructura_desplegando_A: Grid = [
549 ' ',
550 ' ',
551 ' YyYY ',
552 ' YyYYYY ',
553 ' YYYYYYYY ',
554 ' cc ',
555 'cccc ',
556 'ccccc ',
557 'ccccc ',
558 ' Y ',
559 ' YYY ',
560 ' Y ',
561 ]
562 const infraestructura_desplegando_B: Grid = [
563 ' ',
564 ' ',
565 ' YyYY ',
566 ' YyYYYY ',
567 ' YYYYYYYY ',
568 ' cc ',
569 'cccc ',
570 'ccccc ',
571 'ccYcc ',
572 ' YYY ',
573 ' Y ',
574 ' ',
575 ]
576 const infraestructura_caido_A: Grid = [
577 ' ',
578 ' ',
579 ' YyYY ',
580 ' YyYYYY ',
581 ' YYYYYYYY ',
582 ' CC ',
583 'CCCC ',
584 'CCCCC ',
585 'CCCCC ',
586 ' RR ',
587 ' R ',
588 ' R ',
589 ]
590 const infraestructura_arriba_A: Grid = [
591 ' ',
592 ' ',
593 ' YyYY ',
594 ' YyYYYY ',
595 ' YYYYYYYY ',
596 ' cc ',
597 'cccc ',
598 'ccccc ',
599 'ccccc ',
600 ' V ',
601 ' V V ',
602 ' V ',
603 ]
604 return {
605 desarrollador: {
606 trabajando: { emotion: 'normal', layers: [desarrollador_trabajando_A, desarrollador_trabajando_B] },
607 pensando: { emotion: 'pensando', layers: [desarrollador_pensando_A, desarrollador_pensando_A] },
608 error: { emotion: 'preocupado', layers: [desarrollador_error_A, desarrollador_error_B] },
609 resuelto: { emotion: 'sonriente', layers: [desarrollador_resuelto_A, desarrollador_resuelto_A] },
610 },
611 tester: {
612 trabajando: { emotion: 'normal', layers: [tester_trabajando_A, tester_trabajando_B] },
613 pensando: { emotion: 'pensando', layers: [tester_pensando_A, tester_pensando_A] },
614 feliz: { emotion: 'feliz', layers: [tester_feliz_A, tester_feliz_B] },
615 },
616 revisor: {
617 trabajando: { emotion: 'normal', layers: [revisor_trabajando_A, revisor_trabajando_B] },
618 pensando: { emotion: 'pensando', layers: [revisor_pensando_A, revisor_pensando_A] },
619 corrigiendo: { emotion: 'concentrado', layers: [revisor_corrigiendo_A, revisor_corrigiendo_B] },
620 aprobado: { emotion: 'sonriente', layers: [revisor_aprobado_A, revisor_aprobado_A] },
621 },
622 planner: {
623 planeando: { emotion: 'normal', layers: [planner_planeando_A, planner_planeando_B] },
624 pensando: { emotion: 'pensando', layers: [planner_pensando_A, planner_pensando_A] },
625 listo: { emotion: 'sonriente', layers: [planner_listo_A, planner_listo_A] },
626 },
627 infraestructura: {
628 desplegando: { emotion: 'normal', layers: [infraestructura_desplegando_A, infraestructura_desplegando_B] },
629 caido: { emotion: 'asustado', layers: [infraestructura_caido_A, infraestructura_caido_A] },
630 arriba: { emotion: 'sonriente', layers: [infraestructura_arriba_A, infraestructura_arriba_A] },
631 },
632 }
633})()
634
635/** Versión de una línea por emoción, para la status line. */
636export const ONE_LINERS: Record<Emotion, string> = {
637 normal: "ψ(•‿•)",
638 feliz: "ψ(^▽^)♥",
639 sonriente: "ψ(^▽^)",
640 enojado: "ψ(ಠ益ಠ)",
641 dormido: "ψ(-.-)zz",
642 asustado: "ψ(°o°)!",
643 pensando: "ψ(•_•)💡",
644 preocupado: "ψ(°~°);",
645 concentrado: "ψ(•ᴗ<)",
646}
647
648/** Pone una capa encima de una cuadrícula. En la capa, ' ' significa "no tocar". */
649export function overlay(base: Grid, layer: Grid): Grid {
650 return base.map((row, y) => {
651 const top = layer[y] ?? ''
652 let out = ''
653 for (let x = 0; x < WIDTH; x++) {
654 const c = top[x]
655 out += c && c !== ' ' ? c : row[x] ?? '.'
656 }
657 return out
658 })
659}
660
661/** Arma el sprite de un rol en un estado y un cuadro. */
662export function sprite(role: Role, state: string, frame: Frame = 0): Grid {
663 const def = ROLES[role]?.[state]
664 if (!def) throw new Error(`Estado desconocido: ${role}.${state}`)
665 return overlay(EMOTIONS[def.emotion][frame], def.layers[frame])
666}
667
668/** Sprite solo con la cara, sin objeto de rol. */
669export function face(emotion: Emotion, frame: Frame = 0): Grid {
670 return EMOTIONS[emotion][frame]
671}
672
673function colorOf(ch: string | undefined): number | null {
674 if (!ch || ch === '.' || ch === ' ') return null
675 const c = PALETTE[ch]
676 if (c === undefined) throw new Error(`Letra sin color en PALETTE: '${ch}'`)
677 return c
678}
679
680const UPPER = '▀'.codePointAt(0)!
681const LOWER = '▄'.codePointAt(0)!
682const SPACE = ' '.codePointAt(0)!
683
684/**
685 * Convierte una cuadrícula en las celdas empacadas que recibe el elemento Raster.
686 * Cada celda junta dos píxeles verticales: ▀ con el de arriba como color y el de abajo como fondo.
687 * Resultado: columns = WIDTH (18), rows = RASTER_ROWS (6).
688 */
689export function toRasterCells(grid: Grid): string {
690 const numbers: number[] = []
691 for (let y = 0; y < HEIGHT; y += 2) {
692 for (let x = 0; x < WIDTH; x++) {
693 const top = colorOf(grid[y]?.[x])
694 const bottom = colorOf(grid[y + 1]?.[x])
695 if (top !== null && bottom !== null) numbers.push(UPPER, top, bottom)
696 else if (top !== null) numbers.push(UPPER, top, DEFAULT_COLOR)
697 else if (bottom !== null) numbers.push(LOWER, bottom, DEFAULT_COLOR)
698 else numbers.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
699 }
700 }
701 const bytes = new Uint8Array(Uint32Array.from(numbers).buffer)
702 const anyBytes = bytes as Uint8Array & { toBase64?: () => string }
703 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
704 let bin = ''
705 for (const b of bytes) bin += String.fromCharCode(b)
706 return btoa(bin)
707}
708
709/** Props listas para Raster({ ...rasterProps(grid, 'mefisto') }). */
710export function rasterProps(grid: Grid, key: string) {
711 return { key, columns: WIDTH, rows: RASTER_ROWS, cells: toRasterCells(grid) }
712}
713
714/** Convierte una cuadrícula en un documento SVG (para la app de escritorio). */
715export function toSvg(grid: Grid, scale = 6): string {
716 const rects: string[] = []
717 grid.forEach((row, y) => {
718 let x = 0
719 while (x < WIDTH) {
720 const ch = row[x]
721 const c = colorOf(ch)
722 if (c === null) { x++; continue }
723 let end = x
724 while (end < WIDTH && row[end] === ch) end++
725 const hex = '#' + c.toString(16).padStart(6, '0')
726 rects.push(`<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${hex}"/>`)
727 x = end
728 }
729 })
730 const w = WIDTH * scale
731 const h = HEIGHT * scale
732 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">${rects.join('')}</svg>`
733}
734types/index.d.ts 51 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/** Resumen breve que la banda muestra al cerrar un foco. */
28export type BoardFlash = { text: string; untilMs: number }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'mefisto-planner-board': {
33 isActive: boolean
34 isPlannerSession: boolean
35 isExpanded: boolean
36 tab: BoardTab
37 page: number
38 frame: 0 | 1
39 stepsInTurn: number
40 isWorking: boolean
41 focus: BoardFocus | null
42 flash: BoardFlash | null
43 refine: BoardList | null
44 develop: BoardList | null
45 updatedMs: number
46 signature: string
47 known: number[]
48 }
49 }
50}
51