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

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ó #….

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
5-9Borradores: Refina el borrador #N en el prompt. Listos es solo lectura: el planner no lanza trabajo
0Siguiente pagina

/mefisto-board [refresh|borradores|listos|cerrar|on|off] cubre lo mismo sin teclas.

Verificar un cambio

claude plugin validate .claude/mods/mefisto-planner-board
claude plugin test .claude/mods/mefisto-planner-board
Source 4 files
hooks/register.tsx 526 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  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}
526
hooks/logic.ts 232 lines
1import 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}
232
hooks/sprites.ts 734 lines
1/**
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}
734
types/index.d.ts 51 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/** 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