SLOPSHOPPER

mefisto-planner-board

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

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mefisto-planner-board
› fix the failing auth test and add an audit log call ● mefisto-planner-board: [mefisto-planner-board] render kind=reposo hotkeys=[1,3,4] isWorking=false hasSurvey=false maxRows=8 ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mefisto-planner-board ⎿ mefisto-planner-board: Tablero del planner activo en esta sesión. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 1: explorar 3: borradores … 4: listos … │ │ ▄▄▄▄▄▄▄▄▄▄ │ │ ▀▀▀▀▀▀▀▀ ▀ siguiente a refinar │ │ ▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀ │ │ ▄▀▀▀▀▀▀▀▀▄ siguiente a desarrollar │ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ │ cargando… │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 1: explorar 3: borradores … 4: listos … │ │ ▄▄▄▄▄▄▄▄▄▄ │ │ ▀▀▀▀▀▀▀▀ ▀ siguiente a refinar │ │ ▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀ │ │ ▄▀▀▀▀▀▀▀▀▄ siguiente a desarrollar │ │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ │ cargando… │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

mefisto-planner-board

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.

Cargarlo

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.

Estados

  • refinar #N: empieza con un mensaje que dice refina #N; termina cuando #N pasa a estado:listo o con el cierre del planner (mefisto-field-note.sh).
  • explorar: cualquier otro mensaje desde reposo; el tema es su primera linea. Termina con el cierre.
  • Los 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:

  • refinar: el titulo completo y la ficha del issue: labels, de que depende (#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.
  • explorar: el tema completo y una linea por borrador creado (#N titulo · tipo:x).

Teclas (prompt vacio)

TeclaAccion
1 / 2En reposo: Quiero explorar: / Refina el borrador #N en el prompt
3 / 4Abre 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-9Borradores: Refina el borrador #N en el prompt. Listos es solo lectura: el planner no lanza trabajo
0Siguiente 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).

Cuando un digito llega como texto

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.

Verificar un cambio

claude plugin validate .claude/mods/mefisto-planner-board
claude plugin test .claude/mods/mefisto-planner-board

Quita optimista al refinar

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).

Source 4 files
hooks/register.tsx 626 lines
1import { 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}
626
hooks/logic.ts 353 lines
1import 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}
353
hooks/sprites.ts 711 lines
1/**
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}
711
types/index.d.ts 66 lines
1export 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