SLOPSHOPPER

mefisto-monitor

Monitor en vivo de /mefisto-tooling dentro de la sesion de ejecucion: stages, live log, PR y merge.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mefisto-monitor
│ ┃ mefisto ✕ › fix the failing auth test and add an audit log call │ ┃ Sin corrida. Lanza /mefisto-tooling <issue> │ ┃ o /mefisto-monitor <issue>. ⏺ 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-monitor │ ⎿ mefisto-monitor: Monitor abierto. │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ mefisto · listos 0 │ │ │ │ ▀▄ ▄▀ next-order falló: salida de next-order no es JSON │ │ ▀▀▀▀▀▀▀▀▀▀ ▄ │ │ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀ │ │ ▄▀▀▄ ▀▀▀▀▀▀▀▀▀▀ ▄▀ │ │ ▀▄▄▀ ▀▀▀▀▀▀▀▀ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ mefisto · listos 0 │ │ │ │ ▀▄ ▄▀ next-order falló: salida de next-order no es JSON │ │ ▀▀▀▀▀▀▀▀▀▀ ▄ │ │ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀ │ │ ▄▀▀▄ ▀▀▀▀▀▀▀▀▀▀ ▄▀ │ │ ▀▄▄▀ ▀▀▀▀▀▀▀▀ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · mefisto
Sin corrida. Lanza /mefisto-tooling <issue> o /mefisto-monitor <issue>.
README

mefisto-monitor

Mod de Claude Code que sigue /mefisto-tooling dentro de la sesion de ejecucion (MEF-ADR-0055). Solo lee .mefisto/pipeline/; sus acciones delegan en /mefisto-merge y gh.

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

Uso

  • En reposo, en toda sesion interactiva que no sea la del planner, la banda lista los estado:listo en el orden de mefisto-next-order.sh (se refresca cada minuto y al cerrar una corrida). Con el prompt vacio, 1 escribe la linea de /mefisto-sequential con todos y 5-9 toma un solo issue: el dialogo nativo, junto al prompt, pregunta si va con merge (/mefisto-sequential) o solo PR (/mefisto-tooling). El comando queda en el prompt sin Enter para ajustarlo antes de lanzar; 0 pagina. Los bloqueados se ven al final, sin tecla. 2 (PRs N, los abiertos sin borradores) abre el dialogo nativo para mergear: todos (--all) o varios de los mas recientes, y otros numeros en la opcion de texto; al aceptar ejecuta /mefisto-merge de una vez. 3 (bitácora N: field notes en docs/bitacora/field-notes/ mas las de PRs sin mergear) pide integrarlas y le encarga a Claude mergear esos PRs, poner main al dia y correr /mefisto-bitacora. 4 (release N: issues con fragmentos en changelog.d/) ofrece /mefisto-release con el bump que sugiere SemVer (minor si hay added) o solo preparar el PR, y lo ejecuta al aceptar. Los PRs de field notes no cuentan en PRs. /mefisto-monitor refresh la actualiza a mano.
  • /mefisto-sequential (--batch) corre desacoplado y la banda lo sigue desde pipeline-status-mefisto-batch.json: avance N/M, la cola (✓ mergeado, ● en curso, ✗ fallido, ⏸ aplazado) y el issue en curso con su mascota, pasos y ultimas lineas. 1 detiene tras el issue en curso (confirma en el dialogo nativo junto al prompt; escribe la misma senal que /mefisto-batch-stop), 2 abre el PR del issue en curso o del ultimo mergeado, 3 el log (acumula todos los issues). Al terminar: resumen (mergeados, fallidos, aplazados, espera por rate limit), los PRs de cada issue y 4 cierra. Un toast avisa cada merge. /mefisto-monitor batch <issues> reengancha un sequential ya lanzado.
  • Mientras /mefisto-bitacora corre al subagente mefisto-historiador, la banda de espera muestra al historiador escribiendo en su libro (historiador escribiendo la bitácora MM:SS) y, al terminar, bitácora escrita.
  • Mientras corre /mefisto-release (lanzado con 4 o escrito a mano), la banda muestra un cohete: en reposo sobre su plataforma mientras se prepara, despegando con llama mientras corre mefisto-release.sh, y release terminado al cerrar el turno.
  • Si un agente espera por rate limit (la ultima linea de events.log es un [hold]), la banda de la corrida y la del sequential lo dicen: en espera por RATE_LIMIT · próxima sonda HH:MM · techo HH:MM.
  • Durante la corrida la misma banda muestra la mascota del agente activo (animada), issue, stage, tiempo, los pasos y las ultimas lineas del agente; 3 abre el monitor con el log completo. Al terminar, con el prompt vacio: 1 mergea el PR (confirma y encola /mefisto-merge), 2 lo abre en GitHub, 3 abre el monitor y 4 lo cierra y vuelve a los listos.
  • Cuando el PR queda mergeado (desde la banda, /mefisto-merge o GitHub), el monitor se cierra solo.
  • /mefisto-monitor [issue|merge|close] abre el monitor enfocado, sigue un issue o ejecuta la accion.
  • Dentro del monitor: m mergea, v abre el PR, c cierra; ctrl+x tab lo enfoca y esc vuelve al prompt.

Verificar un cambio

claude plugin validate .claude/mods/mefisto-monitor
claude plugin test .claude/mods/mefisto-monitor
Source 4 files
hooks/register.tsx 1047 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BatchRun, LogLine, MonitorRun, ReadyList } from '../types'
5import {
6  BATCH_STATUS,
7  EVENTS_LOG,
8  BATCH_STOP,
9  HISTORY,
10  LOG_DIR,
11  NEXT_ORDER,
12  PLANNER_AGENT,
13  READY_ROWS,
14  agentFlagOf,
15  agentOfEventsFile,
16  batchCounts,
17  batchFromStatus,
18  batchIssuesOf,
19  batchPr,
20  batchSummary,
21  clip,
22  elapsed,
23  cropGrid,
24  finishedFromHistory,
25  fmtCost,
26  issueStatsFromHistory,
27  statsTotal,
28  issueMark,
29  mascotPose,
30  isReleasePrompt,
31  isReleaseRun,
32  holdOf,
33  holdText,
34  bitacoraPrompt,
35  changelogOf,
36  fieldNotesIn,
37  releaseArgsOf,
38  releaseOptions,
39  mergeArgsOf,
40  mergeOptions,
41  newlyMerged,
42  padEnd,
43  pageOf,
44  parseNextOrder,
45  parseOpenPrs,
46  readyRows,
47  sequentialOf,
48  titlesOf,
49  toolingOf,
50  waitingFace,
51  parseEvent,
52  pickEventsFile,
53  prNumber,
54  runFromStatus,
55  statusPath,
56  steps,
57  toolingIssueOf,
58  usedColumns,
59  withModUi,
60} from './logic'
61import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, face, sprite } from './sprites'
62
63const PANE = 'mefisto-monitor'
64const MAX_LINES = 200
65const STATUS_GRACE_MS = 30_000
66// Un seguimiento que tras este tiempo sigue en setup sin status ni historial no corresponde a ninguna corrida.
67const ORPHAN_MS = 120_000
68const PANE_ROWS = 16
69const FIXED_ROWS = 7
70const PR_CHECK_MS = 15_000
71const READY_POLL_MS = 60_000
72const BAND_LOG_ROWS = 3
73const STATS_EVERY_MS = 5_000
74const HOLD_EVERY_MS = 5_000
75const HISTORIAN = 'mefisto-historiador'
76// Tras terminar, el historiador queda en pose de listo este tiempo antes de volver a la espera.
77const HISTORIAN_DONE_MS = 8_000
78const FIELD_NOTES_DIR = 'docs/bitacora/field-notes'
79const CHANGELOG_DIR = 'changelog.d'
80const SCRIPT_TIMEOUT_MS = 180_000
81
82// Recorte comun a todos los cuadros que usa el monitor, calculado una vez: sin margen y sin saltos al alternar.
83const MASCOT_POSES = [
84  ['desarrollador', 'trabajando'],
85  ['desarrollador', 'pensando'],
86  ['desarrollador', 'error'],
87  ['revisor', 'trabajando'],
88  ['revisor', 'pensando'],
89  ['revisor', 'corrigiendo'],
90  ['revisor', 'aprobado'],
91  ['historiador', 'escribiendo'],
92  ['historiador', 'listo'],
93  ['release', 'reposo'],
94  ['release', 'despegando'],
95  ['release', 'listo'],
96] as const
97const MASCOT_COLS = usedColumns([
98  ...MASCOT_POSES.flatMap(([role, state]) => [sprite(role, state, 0), sprite(role, state, 1)]),
99  face('normal'),
100])
101const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
102
103/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
104function mascotCells(grid: readonly string[]): string {
105  const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
106  const words: number[] = []
107  for (let y = 0; y < HEIGHT; y += 2) {
108    for (let x = 0; x < MASCOT_WIDTH; x++) {
109      const top = color(grid[y]?.[x])
110      const bottom = color(grid[y + 1]?.[x])
111      if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
112      else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
113      else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
114    }
115  }
116  let bin = ''
117  for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
118  return btoa(bin)
119}
120
121const runAtom = atom({ plugin: 'mefisto-monitor', key: 'run' } as const, null)
122const linesAtom = atom({ plugin: 'mefisto-monitor', key: 'lines' } as const, [])
123const nowAtom = atom({ plugin: 'mefisto-monitor', key: 'now' } as const, 0)
124const executionAtom = atom({ plugin: 'mefisto-monitor', key: 'isExecutionSession' } as const, false)
125const readyAtom = atom({ plugin: 'mefisto-monitor', key: 'ready' } as const, null)
126const readyPageAtom = atom({ plugin: 'mefisto-monitor', key: 'readyPage' } as const, 0)
127const batchAtom = atom({ plugin: 'mefisto-monitor', key: 'batch' } as const, null)
128const openPrsAtom = atom({ plugin: 'mefisto-monitor', key: 'openPrs' } as const, null)
129const fieldNotesAtom = atom({ plugin: 'mefisto-monitor', key: 'fieldNotes' } as const, null)
130const changelogAtom = atom({ plugin: 'mefisto-monitor', key: 'changelog' } as const, null)
131const holdAtom = atom({ plugin: 'mefisto-monitor', key: 'hold' } as const, null)
132const historianAtom = atom({ plugin: 'mefisto-monitor', key: 'historian' } as const, null)
133const releaseAtom = atom({ plugin: 'mefisto-monitor', key: 'release' } as const, null)
134
135type Tail = { path: string; stream: AsyncGenerator<unknown, unknown> }
136
137let tail: Tail | null = null
138let watchSinceMs = 0
139let batchSinceMs = 0
140let lastStatsReadMs = 0
141let lastHoldReadMs = 0
142let isPolling = false
143// Una sesion no interactiva (los agentes de los pipelines) no tiene a nadie mirando: el mod no reescribe ni sigue nada.
144let isInteractive = false
145let lastPrCheckMs = 0
146let isLogOpen = false
147let isRefreshingReady = false
148let readyTimer: { cancel: () => void } | null = null
149
150async function stopTail() {
151  const current = tail
152  tail = null
153  if (current) await current.stream.return(undefined).catch(() => undefined)
154}
155
156async function pushLines($: EngineInterface, added: LogLine[]) {
157  if (added.length === 0) return
158  await update($, linesAtom, list => [...(list ?? []), ...added].slice(-MAX_LINES))
159}
160
161async function followTail($: EngineInterface, stream: AsyncGenerator<unknown, unknown>, path: string) {
162  let pending = ''
163  try {
164    for await (const chunk of stream) {
165      if (tail?.stream !== stream) break
166      const { stream: kind, text } = chunk as { stream: string; text: string }
167      if (kind !== 'stdout') continue
168      pending += text
169      const rows = pending.split('\n')
170      pending = rows.pop() ?? ''
171      await pushLines($, rows.map(parseEvent).filter((l): l is LogLine => l !== null))
172    }
173  } catch (err) {
174    $.ui.log(`tail ${path}: ${String(err)}`, { to: 'debug' })
175  }
176}
177
178async function startTail($: EngineInterface, name: string) {
179  await stopTail()
180  const path = `${LOG_DIR}/${name}`
181  const stream = $.process.spawn({ argv: ['tail', '-n', '40', '-F', path] })
182  tail = { path, stream }
183  await pushLines($, [{ ts: '', kind: 'start', text: `── ${agentOfEventsFile(name)} ──` }])
184  void followTail($, stream, path)
185}
186
187// En un batch cada eslabon se sigue igual que un /mefisto-tooling suelto, pero el log acumula todos los issues.
188async function watch($: EngineInterface, issue: string, sinceMs: number, keepLines = false) {
189  await stopTail()
190  watchSinceMs = sinceMs
191  const run: MonitorRun = {
192    issue,
193    title: '',
194    stage: 'setup',
195    state: 'running',
196    startedMs: Date.now(),
197    finishedMs: null,
198    agents: {},
199    pr: null,
200    lastError: null,
201    eventsFile: null,
202  }
203  await update($, runAtom, () => run)
204  if (keepLines) await pushLines($, [{ ts: '', kind: 'start', text: `══ #${issue} ══` }])
205  else await update($, linesAtom, () => [])
206  await poll($)
207}
208
209async function watchBatch($: EngineInterface, issues: string[], sinceMs: number) {
210  await stopTail()
211  batchSinceMs = sinceMs
212  const batch: BatchRun = {
213    issues: issues.map(issue => ({ issue, status: 'pendiente', pr: null })),
214    state: 'running',
215    current: null,
216    stopRequested: false,
217    holdSeconds: 0,
218    startedMs: Date.now(),
219    finishedMs: null,
220    stats: {},
221  }
222  await update($, runAtom, () => null)
223  await update($, linesAtom, () => [])
224  await update($, batchAtom, () => batch)
225}
226
227// Duracion y costo de cada terminado: se leen del historial cuando aparece uno que todavia no los tiene, a lo
228// sumo cada STATS_EVERY_MS (un fallido antes de escribir su historial no los tendra nunca).
229async function withStats($: EngineInterface, batch: BatchRun): Promise<BatchRun> {
230  const stats = batch.stats ?? {}
231  const missing = batch.issues.filter(i => ['done', 'failed'].includes(issueMark(i.status)) && !stats[i.issue])
232  if (missing.length === 0 || Date.now() - lastStatsReadMs < STATS_EVERY_MS) return batch
233  lastStatsReadMs = Date.now()
234  const { stdout } = await $.process.run(['tail', '-n', '200', HISTORY]).catch(() => ({ stdout: '' }))
235  return { ...batch, stats: { ...stats, ...issueStatsFromHistory(stdout, missing.map(i => i.issue), batch.startedMs) } }
236}
237
238async function pollBatch($: EngineInterface) {
239  const batch = await read($, batchAtom)
240  if (!batch) return
241  if (batch.state !== 'running') {
242    const filled = await withStats($, batch)
243    if (filled !== batch) await update($, batchAtom, () => filled)
244    return
245  }
246  let next = batch
247  if (await $.fs.exists(BATCH_STATUS)) next = batchFromStatus(await $.fs.read(BATCH_STATUS).catch(() => ''), batch, batchSinceMs)
248  next = { ...next, stopRequested: next.stopRequested || (await $.fs.exists(BATCH_STOP)) }
249  for (const i of newlyMerged(batch.issues, next.issues)) $.ui.toast(`#${i.issue} mergeado${i.pr ? ` · PR #${i.pr}` : ''}`)
250  next = await withStats($, next)
251  const settled = next
252  await update($, batchAtom, () => settled)
253  const run = await read($, runAtom)
254  if (settled.current && settled.current !== run?.issue) await watch($, settled.current, settled.startedMs, true)
255  const c = batchCounts(settled.issues)
256  if (settled.state === 'running') {
257    $.ui.status(`mefisto · sequential ${c.done + c.failed}/${c.total}${settled.current ? ` · #${settled.current}` : ''}`)
258    return
259  }
260  await stopTail()
261  $.ui.toast(`sequential terminado: ${batchSummary(settled)}`)
262  $.ui.status(settled.state === 'failed' ? `mefisto · sequential con fallos` : undefined)
263}
264
265// Parada suave: la misma senal que /mefisto-batch-stop. Termina el eslabon en curso (con su merge) y no sigue.
266async function requestStop($: EngineInterface) {
267  await $.fs.write(BATCH_STOP, '')
268  await update($, batchAtom, b => (b ? { ...b, stopRequested: true } : b))
269  $.ui.toast('parada pedida: termina el issue en curso y no arranca los siguientes')
270}
271
272async function finish($: EngineInterface, run: MonitorRun) {
273  await stopTail()
274  if (run.state === 'completed') {
275    const pr = prNumber(run.pr)
276    $.ui.toast(pr ? `#${run.issue} terminó · PR #${pr} listo` : `#${run.issue} terminó`)
277    $.ui.status(undefined)
278  } else {
279    $.ui.toast(`#${run.issue} falló en ${stageName(run.stage)}`)
280    $.ui.status(`mefisto · #${run.issue} falló`)
281  }
282}
283
284async function fromHistory($: EngineInterface, prev: MonitorRun): Promise<MonitorRun> {
285  const { stdout } = await $.process.run(['tail', '-n', '50', HISTORY]).catch(() => ({ stdout: '' }))
286  const done = finishedFromHistory(stdout, prev.issue, Math.min(prev.startedMs, watchSinceMs))
287  if (!done) return prev
288  const isFailed = done.state === 'failed'
289  return {
290    ...prev,
291    title: done.title ?? prev.title,
292    state: isFailed ? 'failed' : 'completed',
293    stage: isFailed ? (done.stage ?? prev.stage) : 'done',
294    pr: done.pr ?? null,
295    lastError: done.error ?? null,
296    finishedMs: Date.now(),
297  }
298}
299
300async function poll($: EngineInterface) {
301  if (isPolling) return
302  isPolling = true
303  try {
304    await update($, nowAtom, () => Date.now())
305    await pollBatch($)
306    const inBatch = (await read($, batchAtom)) !== null
307    const prev = await read($, runAtom)
308    if (prev?.state === 'completed' && !inBatch) await closeIfMerged($, prev)
309    if (!prev || prev.state !== 'running') return
310
311    let next: MonitorRun = prev
312    const status = statusPath(prev.issue)
313    if (await $.fs.exists(status)) {
314      const raw = await $.fs.read(status).catch(() => '')
315      const parsed = runFromStatus(raw, prev)
316      if (parsed && !(parsed.state !== 'running' && parsed.startedMs + 1000 < watchSinceMs)) next = parsed
317    } else if (watchSinceMs === 0 || Date.now() - watchSinceMs > STATUS_GRACE_MS || prev.stage !== 'setup') {
318      next = await fromHistory($, prev)
319      if (!inBatch && next === prev && prev.stage === 'setup' && Date.now() - prev.startedMs > ORPHAN_MS) {
320        $.ui.log(`mefisto-monitor: #${prev.issue} sin status ni historial tras ${ORPHAN_MS / 60_000} min; se deja de seguir`, { to: 'debug' })
321        await closeRun($)
322        return
323      }
324    }
325
326    if (next.state === 'running' && Date.now() - lastHoldReadMs >= HOLD_EVERY_MS) {
327      lastHoldReadMs = Date.now()
328      const { stdout } = await $.process.run(['tail', '-n', '3', EVENTS_LOG]).catch(() => ({ stdout: '' }))
329      const hold = holdOf(stdout)
330      await update($, holdAtom, () => hold)
331    } else if (next.state !== 'running') {
332      await update($, holdAtom, () => null)
333    }
334
335    if (next.state === 'running') {
336      const entries = await $.fs.list(LOG_DIR).catch(() => [])
337      const name = pickEventsFile(entries, next.issue, Math.min(next.startedMs, watchSinceMs))
338      if (name && name !== next.eventsFile) {
339        next = { ...next, eventsFile: name }
340        await startTail($, name)
341      }
342      if (!inBatch) $.ui.status(`mefisto · #${next.issue} ${stageName(next.stage)} ${elapsed(Date.now() - next.startedMs)}`)
343    }
344
345    const settled = next
346    await update($, runAtom, () => settled)
347    if (settled.state !== 'running') {
348      if (inBatch) await stopTail()
349      else await finish($, settled)
350    }
351  } finally {
352    isPolling = false
353  }
354}
355
356// El PR mergeado da la corrida por terminada: venga del boton, de /mefisto-merge tecleado o de GitHub.
357async function closeIfMerged($: EngineInterface, run: MonitorRun) {
358  const pr = prNumber(run.pr)
359  if (!pr || Date.now() - lastPrCheckMs < PR_CHECK_MS) return
360  lastPrCheckMs = Date.now()
361  const { exitCode, stdout } = await $.process
362    .run(['gh', 'pr', 'view', pr, '--json', 'state', '-q', '.state'])
363    .catch(() => ({ exitCode: 1, stdout: '' }))
364  if (exitCode !== 0 || stdout.trim() !== 'MERGED') return
365  await closeRun($)
366  $.ui.toast(`PR #${pr} mergeado · monitor de #${run.issue} cerrado`)
367}
368
369async function openPane($: EngineInterface, isFocused = false) {
370  isLogOpen = true
371  return $.ui.open({ id: PANE, title: 'mefisto', rows: PANE_ROWS, ...(isFocused ? { focus: true as const } : {}) })
372}
373
374async function closeLog($: EngineInterface) {
375  isLogOpen = false
376  await $.ui.close({ id: PANE })
377}
378
379// La tecla 3 de la banda abre el log o, si ya estaba abierto, lo cierra.
380async function toggleLog($: EngineInterface) {
381  if (isLogOpen) await closeLog($)
382  else await openPane($, true)
383}
384
385async function closeRun($: EngineInterface) {
386  await stopTail()
387  await update($, batchAtom, () => null)
388  await update($, runAtom, () => null)
389  await update($, linesAtom, () => [])
390  $.ui.status(undefined)
391  await $.ui.close({ id: PANE })
392  void refreshReady($)
393}
394
395async function openPr($: EngineInterface) {
396  const run = await read($, runAtom)
397  const batch = await read($, batchAtom)
398  const pr = batch ? batchPr(batch) : prNumber(run?.pr ?? null)
399  if (pr) await $.process.run(['gh', 'pr', 'view', pr, '--web']).catch(() => undefined)
400}
401
402async function merge($: EngineInterface) {
403  const run = await read($, runAtom)
404  const pr = prNumber(run?.pr ?? null)
405  if (!pr) return
406  const answer = await $.ui
407    .ask(`¿Mergear el PR #${pr} con /mefisto-merge?`, ['Mergear', 'Cancelar'])
408    .catch(() => 'Cancelar')
409  if (answer !== 'Mergear') return
410  $.ui.toast(`/mefisto-merge ${pr} en cola: el monitor se cierra cuando el PR quede mergeado`)
411  lastPrCheckMs = 0
412  await $.command.run({ command: 'mefisto-merge', args: pr })
413}
414
415// La sesion de ejecucion es cualquier sesion interactiva que no sea la del planner: el planner no lanza
416// trabajo (su tablero deja los listos en solo lectura). Se lee el --agent de la linea de comando del
417// proceso de Claude Code, padre del `sh` que corre aqui; si no se puede leer, la sesion cuenta como de ejecucion.
418async function detectExecution($: EngineInterface) {
419  const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
420  const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
421  if (agentFlagOf(cmdline) === PLANNER_AGENT) return
422  await update($, executionAtom, () => true)
423  readyTimer?.cancel()
424  readyTimer = $.clock.every(READY_POLL_MS, () => void refreshReady($))
425  void refreshReady($)
426}
427
428async function refreshReady($: EngineInterface) {
429  if (isRefreshingReady || !(await read($, executionAtom))) return
430  isRefreshingReady = true
431  try {
432    const [order, listos, prs] = await Promise.all([
433      $.process
434        .run([NEXT_ORDER, '--json'], { timeoutMs: SCRIPT_TIMEOUT_MS })
435        .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) })),
436      $.process
437        .run(['gh', 'issue', 'list', '--state', 'open', '--label', 'estado:listo', '--limit', '200', '--json', 'number,title'])
438        .catch(() => ({ exitCode: 1, stdout: '' })),
439      $.process
440        .run(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,title,isDraft,headRefName'])
441        .catch(() => ({ exitCode: 1, stdout: '' })),
442    ])
443    const openPrs = prs.exitCode === 0 ? parseOpenPrs(prs.stdout) : null
444    await update($, openPrsAtom, () => openPrs)
445    const notes = await $.fs.list(FIELD_NOTES_DIR).catch(() => null)
446    await update($, fieldNotesAtom, () => (notes ? fieldNotesIn(notes.filter(n => n.kind === 'file').map(n => n.name)) : null))
447    const fragments = await $.fs.list(CHANGELOG_DIR).catch(() => null)
448    await update($, changelogAtom, () => (fragments ? changelogOf(fragments.filter(n => n.kind === 'file').map(n => n.name)) : null))
449    const ready: ReadyList = parseNextOrder(order.exitCode, order.stdout, order.stderr, titlesOf(listos.stdout))
450    await update($, readyAtom, () => ready)
451  } finally {
452    isRefreshingReady = false
453  }
454}
455
456// La banda escribe el comando sin Enter: se lanza a mano, con la opcion de ajustarlo antes.
457async function fill($: EngineInterface, text: string) {
458  await $.prompt.fill({ text, mode: 'replace' })
459}
460
461// Las confirmaciones van en el dialogo nativo, abajo junto al prompt; la banda queda para informar. Esc cancela.
462const WITH_MERGE = 'Con merge (sequential)'
463const ONLY_PR = 'Solo PR (tooling)'
464
465async function choose($: EngineInterface, issue: number) {
466  const answer = await $.ui
467    .ask(`¿Cómo desarrollar #${issue}?`, { header: 'Lanzar', options: [WITH_MERGE, ONLY_PR] })
468    .catch(() => null)
469  if (answer === WITH_MERGE) await fill($, sequentialOf(issue))
470  else if (answer === ONLY_PR) await fill($, toolingOf(issue))
471}
472
473// Mergear desde la espera: todos o los PRs elegidos; en la opcion de texto se escriben otros numeros. Elegir en el
474// dialogo ya es la aprobacion, asi que /mefisto-merge se ejecuta de una vez.
475async function chooseMerge($: EngineInterface) {
476  const prs = ((await read($, openPrsAtom)) ?? []).filter(p => !p.isFieldNote)
477  if (prs.length === 0) return
478  const options = mergeOptions(prs)
479  const answer = await $.ui
480    .ask(`¿Qué mergear? ${prs.length} PRs abiertos${prs.length > 3 ? ' (otros números: escríbelos en la opción de texto)' : ''}`, {
481      header: 'Merge',
482      options,
483      ...(prs.length > 1 ? { multiSelect: true as const } : {}),
484    })
485    .catch(() => null)
486  const args = answer === null ? null : mergeArgsOf(answer, options)
487  if (!args) return
488  $.ui.toast(`/mefisto-merge ${args} en cola`)
489  await $.command.run({ command: 'mefisto-merge', args })
490}
491
492// Integrar la bitacora: las field notes de main y las que esperan en sus PRs. Elegir en el dialogo es la aprobacion.
493async function chooseBitacora($: EngineInterface) {
494  const local = (await read($, fieldNotesAtom)) ?? 0
495  const prs = ((await read($, openPrsAtom)) ?? []).filter(p => p.isFieldNote).map(p => p.number)
496  if (local + prs.length === 0) return
497  const integrate = prs.length > 0 ? `Mergear sus ${prs.length} PRs e integrar` : 'Integrar'
498  const answer = await $.ui
499    .ask(`¿Integrar la bitácora? ${local} field notes en main${prs.length > 0 ? ` y ${prs.length} en PRs sin mergear` : ''}`, {
500      header: 'Bitácora',
501      options: [integrate, 'Cancelar'],
502    })
503    .catch(() => null)
504  if (answer !== integrate) return
505  await $.prompt.submit({ text: bitacoraPrompt(prs), asUser: true })
506}
507
508// Publicar un release con los fragmentos de changelog.d/: SemVer sugiere el bump; tambien se puede solo preparar.
509async function chooseRelease($: EngineInterface) {
510  const summary = await read($, changelogAtom)
511  if (!summary || summary.issues === 0) return
512  const counts = [
513    summary.added > 0 ? `${summary.added} added` : '',
514    summary.changed > 0 ? `${summary.changed} changed` : '',
515    summary.fixed > 0 ? `${summary.fixed} fixed` : '',
516    summary.removed > 0 ? `${summary.removed} removed` : '',
517  ].filter(Boolean).join(', ')
518  const answer = await $.ui
519    .ask(`¿Publicar un release? ${summary.issues} issues con cambios (${counts})`, { header: 'Release', options: releaseOptions(summary) })
520    .catch(() => null)
521  const args = answer === null ? null : releaseArgsOf(answer)
522  if (!args) return
523  $.ui.toast(`/mefisto-release ${args} en cola`)
524  await startRelease($)
525  await $.command.run({ command: 'mefisto-release', args })
526}
527
528async function startRelease($: EngineInterface) {
529  await update($, releaseAtom, r => (r && !r.finishedMs ? r : { phase: 'reposo', startedMs: Date.now(), finishedMs: null }))
530}
531
532async function confirmStop($: EngineInterface) {
533  const current = (await read($, batchAtom))?.current
534  const answer = await $.ui
535    .ask(`¿Detener el sequential tras ${current ? `#${current}` : 'el issue en curso'}?`, {
536      header: 'Detener',
537      options: ['Detener tras el actual', 'Seguir'],
538    })
539    .catch(() => null)
540  if (answer === 'Detener tras el actual') await requestStop($)
541}
542
543async function nextReadyPage($: EngineInterface) {
544  const ready = await read($, readyAtom)
545  const total = ready ? readyRows(ready).length : 0
546  await update($, readyPageAtom, p => pageOf((p ?? 0) + 1, total, READY_ROWS).page)
547}
548
549export const register: Register = on => {
550  on('session.start', async ($, e, next) => {
551    isInteractive = e.isInteractive
552    await $.command.register({
553      name: 'mefisto-monitor',
554      description: 'Abre el monitor de /mefisto-tooling (con <issue> sigue esa corrida; batch <issues> sigue un sequential; merge | close | refresh)',
555      argumentHint: '[issue|batch <issues>|merge|close|refresh]',
556      immediate: true,
557    })
558    // Tras un hot-reload el tail del modulo anterior murio: se fuerza a reabrirlo.
559    await update($, runAtom, run => (run && run.state === 'running' ? { ...run, eventsFile: null } : run))
560    $.clock.every(1000, () => void poll($))
561    if (isInteractive) void detectExecution($)
562    return next(e)
563  })
564
565  // /mefisto-bitacora corre al historiador en primer plano: mientras dura la llamada, la banda de espera lo muestra.
566  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
567    if (!isInteractive || e.subagent_type !== HISTORIAN) return next(e)
568    await update($, historianAtom, () => ({ startedMs: Date.now(), finishedMs: null }))
569    try {
570      return await next(e)
571    } finally {
572      await update($, historianAtom, h => (h ? { ...h, finishedMs: Date.now() } : h))
573      $.clock.after(HISTORIAN_DONE_MS, () => void update($, historianAtom, h => (h?.finishedMs ? null : h)))
574      void refreshReady($)
575    }
576  })
577
578  // El cohete del release: en reposo desde que se pide /mefisto-release, despegando mientras corre su script y
579  // listo cuando termina el turno que lo ejecuto.
580  on('prompt.submit', async ($, e, next) => {
581    if (isInteractive && isReleasePrompt(e.text)) await startRelease($)
582    return next(e)
583  })
584
585  on('turn.complete', async ($, e, next) => {
586    const release = await read($, releaseAtom)
587    if (release && !release.finishedMs) {
588      await update($, releaseAtom, r => (r ? { ...r, phase: 'reposo', finishedMs: Date.now() } : r))
589      $.clock.after(HISTORIAN_DONE_MS, () => void update($, releaseAtom, r => (r?.finishedMs ? null : r)))
590      void refreshReady($)
591    }
592    return next(e)
593  })
594
595  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
596    if (isInteractive && isReleaseRun(e.command)) {
597      await startRelease($)
598      await update($, releaseAtom, r => (r ? { ...r, phase: 'despegando' } : r))
599      try {
600        return await next(e)
601      } finally {
602        await update($, releaseAtom, r => (r ? { ...r, phase: 'reposo' } : r))
603      }
604    }
605    const batchIssues = isInteractive ? batchIssuesOf(e.command) : null
606    if (batchIssues && batchIssues.length > 0) {
607      const ran = await next({ ...e, command: withModUi(e.command) })
608      if (ran.deny === undefined && ran.isError !== true) await watchBatch($, batchIssues, Date.now() - 5_000)
609      return ran
610    }
611    const issue = isInteractive ? toolingIssueOf(e.command) : null
612    if (!issue) return next(e)
613    const ran = await next({ ...e, command: withModUi(e.command) })
614    if (ran.deny === undefined && ran.isError !== true) {
615      await watch($, issue, Date.now() - 5_000)
616    }
617    return ran
618  })
619
620  on('command.run', { command: 'mefisto-monitor' }, async ($, e) => {
621    const arg = e.args.trim()
622    if (arg === 'close') {
623      await closeRun($)
624      return { text: 'Monitor cerrado.' }
625    }
626    if (arg === 'refresh') {
627      await refreshReady($)
628      return { text: 'Listos actualizados.' }
629    }
630    if (arg === 'merge') {
631      await merge($)
632      return { text: 'Merge solicitado.' }
633    }
634    // Reengancha un sequential ya lanzado (tras reiniciar la sesion): `/mefisto-monitor batch 12 13`.
635    const batchArgs = /^batch((?:\s+#?\d+)+)$/.exec(arg)
636    if (batchArgs?.[1]) {
637      const issues = batchArgs[1].match(/\d+/g) ?? []
638      await watchBatch($, issues, 0)
639      return { text: `Siguiendo el sequential ${issues.map(n => `#${n}`).join(' ')} en la banda.` }
640    }
641    // La banda sigue la corrida; el pane es solo el log completo y se abre a pedido.
642    if (/^#?\d+$/.test(arg)) {
643      await watch($, arg.replace('#', ''), 0)
644      return { text: `Siguiendo #${arg.replace('#', '')} en la banda.` }
645    }
646    await openPane($, true)
647    return { text: 'Monitor abierto.' }
648  })
649
650  // Cerrar el log solo cierra el pane: la banda conserva la corrida (y su PR) hasta que se cierre con 4.
651  on('ui.close', { id: PANE }, async ($, e, next) => {
652    isLogOpen = false
653    return next(e)
654  })
655
656  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
657    const run = await read($, runAtom)
658    const batch = await read($, batchAtom)
659    if (e.props.hasSurvey) return next(e)
660    if (!run && !batch && !(await read($, executionAtom))) return next(e)
661    const now = await read($, nowAtom)
662    const { Box, Text, Button } = $.ui.resolve(e)
663    if (batch) {
664      // El sequential: avance y cola arriba, el eslabon en curso abajo con su mascota, pasos y ultimas lineas.
665      const lines = await read($, linesAtom)
666      const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
667      const body = Math.max(30, inner - MASCOT_WIDTH - 2)
668      const c = batchCounts(batch.issues)
669      const end = batch.finishedMs ?? (now || Date.now())
670      const isRunning = batch.state === 'running'
671      const pr = batchPr(batch)
672      const pose =
673        isRunning && run ? mascotPose(run, lines[lines.length - 1])
674        : batch.state === 'failed' ? { role: 'desarrollador' as const, state: 'error' }
675        : isRunning ? { role: 'desarrollador' as const, state: 'pensando' }
676        : { role: 'revisor' as const, state: 'aprobado' }
677      const frame = isRunning ? (Math.floor((now || Date.now()) / 1000) % 2 === 0 ? 0 : 1) : 0
678      const grid = cropGrid(sprite(pose.role, pose.state, frame), MASCOT_COLS)
679      const elements = $.ui.resolve(e)
680      const mascot =
681        'Raster' in elements ? (
682          <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
683        ) : (
684          <Box width={MASCOT_WIDTH} />
685        )
686      const hold = await read($, holdAtom)
687      const recent = lines.filter(l => l.ts !== '').slice(-3)
688      const markText = (m: ReturnType<typeof issueMark>) =>
689        m === 'done' ? '✓' : m === 'failed' ? '✗' : m === 'deferred' ? '⏸' : m === 'current' ? '●' : '○'
690      const markColor = (m: ReturnType<typeof issueMark>) =>
691        m === 'done' ? ('success' as const) : m === 'failed' ? ('error' as const) : m === 'current' ? ('warning' as const) : undefined
692      const failedIssue = batch.issues.find(i => issueMark(i.status) === 'failed')
693      const doneRows = batch.issues.filter(i => ['done', 'failed'].includes(issueMark(i.status)))
694      const stats = batch.stats ?? {}
695      const total = statsTotal(doneRows.map(i => stats[i.issue]).filter(st => st !== undefined))
696      return (
697        <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
698          <Box justifyContent="space-between">
699            <Text wrap="truncate-end">
700              <Text color="claude">mefisto</Text>
701              <Text> · sequential {c.done + c.failed}/{c.total}</Text>
702              {isRunning && batch.stopRequested && <Text color="warning"> · se detiene tras #{batch.current ?? '…'}</Text>}
703              {batch.state === 'completed' && <Text color="success"> · terminado</Text>}
704              {batch.state === 'stopped' && <Text color="warning"> · detenido</Text>}
705              {batch.state === 'failed' && <Text color="error"> · con fallos</Text>}
706              <Text dimColor> {elapsed(end - batch.startedMs)}</Text>
707            </Text>
708            <Box gap={2}>
709              {isRunning && !batch.stopRequested && (
710                <Button key="batch-stop" hotkey="1" plain label="detener" onPress={() => void confirmStop($)} />
711              )}
712              {pr && <Button key="batch-pr" hotkey="2" plain label={`ver PR #${pr}`} onPress={() => void openPr($)} />}
713              <Button key="batch-log" hotkey="3" plain dimColor={isRunning} label="log" onPress={() => void toggleLog($)} />
714              {!isRunning && <Button key="batch-close" hotkey="4" plain label="cerrar" onPress={() => void closeRun($)} />}
715            </Box>
716          </Box>
717          <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
718            {mascot}
719            <Box flexDirection="column" width={body} marginTop={1}>
720              <Text wrap="truncate-end">
721                {batch.issues.map((i, k) => {
722                  const m = issueMark(i.status)
723                  return (
724                    <Text color={markColor(m)} dimColor={m === 'pending' || m === 'deferred'}>
725                      {k > 0 ? ' ─ ' : ''}
726                      {markText(m)} #{i.issue}
727                      {m === 'current' && run && run.issue === i.issue ? ` ${stageName(run.stage)}` : ''}
728                    </Text>
729                  )
730                })}
731              </Text>
732              {isRunning && run && run.title !== '' && <Text dimColor wrap="truncate-end">#{run.issue} {run.title}</Text>}
733              {isRunning && hold && <Text color="warning" wrap="truncate-end">{holdText(hold)}</Text>}
734              {isRunning && run && !hold && <Text> </Text>}
735              {isRunning && !run && <Text dimColor>preparando el primer issue…</Text>}
736              {isRunning &&
737                recent.map(line => (
738                  <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
739                    {`${line.ts} ${line.kind === 'text' ? '» ' : ''}${line.text}`}
740                  </Text>
741                ))}
742              {!isRunning && <Text wrap="truncate-end">{batchSummary(batch)}</Text>}
743              {!isRunning && failedIssue && (
744                <Text color="error" wrap="truncate-end">#{failedIssue.issue} {failedIssue.status.replace(/^ERROR:\s*/, '')}</Text>
745              )}
746            </Box>
747          </Box>
748          {doneRows.length > 0 && (
749            <Box flexDirection="column">
750              {doneRows.map(i => {
751                const st = stats[i.issue]
752                const m = issueMark(i.status)
753                return (
754                  <Text wrap="truncate-end" color={markColor(m)}>
755                    {`${markText(m)} ${padEnd(`#${i.issue}`, 7)} ${padEnd(i.pr ? `PR #${i.pr}` : 'sin PR', 10)} `}
756                    <Text dimColor>{st ? `${padEnd(elapsed(st.durationMs), 8)} ${fmtCost(st.costUsd)}` : '…'}</Text>
757                  </Text>
758                )
759              })}
760              {doneRows.length > 1 && (
761                <Text dimColor wrap="truncate-end">
762                  {`  total ${padEnd(String(doneRows.length), 15)} ${padEnd(elapsed(total.durationMs), 8)} ${fmtCost(total.costUsd)}`}
763                </Text>
764              )}
765            </Box>
766          )}
767        </Box>
768      )
769    }
770    if (!run) {
771      const ready = await read($, readyAtom)
772      const openPrs = (await read($, openPrsAtom)) ?? []
773      const workPrs = openPrs.filter(p => !p.isFieldNote).length
774      const pendingNotes = ((await read($, fieldNotesAtom)) ?? 0) + openPrs.filter(p => p.isFieldNote).length
775      const changelog = await read($, changelogAtom)
776      const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
777      const body = Math.max(30, inner - MASCOT_WIDTH - 2)
778      const { page, pages } = pageOf(await read($, readyPageAtom), ready ? readyRows(ready).length : 0, READY_ROWS)
779      const rows = ready ? readyRows(ready) : []
780      const visible = rows.slice(page * READY_ROWS, (page + 1) * READY_ROWS)
781      const numWidth = Math.max(4, ...visible.map(i => String(i.number).length + 1))
782      const afterWidth = Math.min(24, Math.max(0, ...visible.map(i => i.reason.length)))
783      // El prefijo '5: ' de la tecla, el numero, un espacio y, si alguno depende de otro, la columna 'tras #N'.
784      const titleWidth = Math.max(12, body - 3 - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
785      const more = ready
786        ? [
787            ready.cycleCount > 0 ? `${ready.cycleCount} en ciclo` : '',
788          ].filter(Boolean).join(' · ')
789        : ''
790      // En espera de que se lance algo; mientras Claude trabaja en la sesion, mira de lado a lado y corre la arena.
791      // Si el historiador escribe la bitacora, es el quien aparece, con la pluma en movimiento.
792      const historian = await read($, historianAtom)
793      const tick = e.props.isWorking ? Math.floor((now || Date.now()) / 1000) : null
794      const release = await read($, releaseAtom)
795      const frame = (tick ?? 0) % 2 === 0 ? 0 : 1
796      const grid = release
797        ? cropGrid(sprite('release', release.finishedMs ? 'listo' : release.phase, frame), MASCOT_COLS)
798        : historian
799          ? cropGrid(sprite('historiador', historian.finishedMs ? 'listo' : 'escribiendo', frame), MASCOT_COLS)
800          : cropGrid(waitingFace(face('normal'), tick), MASCOT_COLS)
801      const elements = $.ui.resolve(e)
802      const mascot =
803        'Raster' in elements ? (
804          <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
805        ) : (
806          <Box width={MASCOT_WIDTH} />
807        )
808      return (
809        <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
810          <Box justifyContent="space-between">
811            <Text>
812              <Text color="claude">mefisto</Text>
813              <Text dimColor> · listos {ready ? readyRows(ready).length : '…'}</Text>
814              {historian && !historian.finishedMs && (
815                <Text color="warning"> · historiador escribiendo la bitácora {elapsed((now || Date.now()) - historian.startedMs)}</Text>
816              )}
817              {historian?.finishedMs && <Text color="success"> · bitácora escrita</Text>}
818              {release && !release.finishedMs && (
819                <Text color="warning">
820                  {release.phase === 'despegando' ? ' · release despegando' : ' · release en preparación'} {elapsed((now || Date.now()) - release.startedMs)}
821                </Text>
822              )}
823              {release?.finishedMs && <Text color="success"> · release terminado</Text>}
824            </Text>
825            <Box gap={2}>
826              {workPrs > 0 && (
827                <Button key="merge-prs" hotkey="2" plain label={`PRs ${workPrs}`} onPress={() => void chooseMerge($)} />
828              )}
829              {pendingNotes > 0 && (
830                <Button key="bitacora" hotkey="3" plain label={`bitácora ${pendingNotes}`} onPress={() => void chooseBitacora($)} />
831              )}
832              {changelog && changelog.issues > 0 && (
833                <Button key="release" hotkey="4" plain label={`release ${changelog.issues}`} onPress={() => void chooseRelease($)} />
834              )}
835            </Box>
836          </Box>
837          <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
838            {mascot}
839            <Box flexDirection="column" width={body} marginTop={1}>
840              {!ready && <Text dimColor>cargando…</Text>}
841              {ready?.error && <Text color="error">next-order falló: {clip(ready.error, body - 20)}</Text>}
842              {ready && !ready.error && visible.length === 0 && <Text dimColor>Sin issues en estado:listo.</Text>}
843              {visible.map((item, i) => {
844                const label = `${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`
845                return (
846                  <Box>
847                    {item.isLaunchable ? (
848                      <Button
849                        key={`ready-${item.number}`}
850                        hotkey={String(i + 5)}
851                        plain
852                        dimColor={page > 0 || i > 0}
853                        label={label}
854                        onPress={() => void choose($, item.number)}
855                      />
856                    ) : (
857                      <Text dimColor>{`   ${label}`}</Text>
858                    )}
859                    <Text dimColor>{item.reason ? `  ${clip(item.reason, afterWidth)}` : ''}</Text>
860                  </Box>
861                )
862              })}
863              {Array.from({ length: Math.max(0, READY_ROWS - Math.max(visible.length, 1)) }, (_, i) => (
864                <Text key={`ready-blank-${i}`}> </Text>
865              ))}
866              <Box justifyContent="space-between">
867                <Text> </Text>
868                <Box gap={2}>
869                  {more !== '' && <Text dimColor>{more}</Text>}
870                  {pages > 1 && (
871                    <Button key="ready-page" hotkey="0" plain dimColor label={`página ${page + 1}/${pages} ▸`}
872                      onPress={() => void nextReadyPage($)} />
873                  )}
874                </Box>
875              </Box>
876            </Box>
877          </Box>
878          {/* La linea de todos los listos crece con la cola: va abajo, a lo ancho, y no empuja los menus fijos. */}
879          {ready?.launch && (
880            <Button key="ready-all" hotkey="1" plain label={clip(ready.launch.replace(/^\/mefisto-/, ''), inner - 3)}
881              onPress={() => void fill($, ready.launch ?? '')} />
882          )}
883        </Box>
884      )
885    }
886    // La corrida ocupa la misma banda que la espera: la mascota del agente activo, los pasos y lo ultimo que hizo.
887    const lines = await read($, linesAtom)
888    const hold = await read($, holdAtom)
889    const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
890    const body = Math.max(30, inner - MASCOT_WIDTH - 2)
891    const end = run.finishedMs ?? (now || Date.now())
892    const pr = prNumber(run.pr)
893    const pose = mascotPose(run, lines[lines.length - 1])
894    const frame = run.state === 'running' ? (Math.floor((now || Date.now()) / 1000) % 2 === 0 ? 0 : 1) : 0
895    const grid = cropGrid(sprite(pose.role, pose.state, frame), MASCOT_COLS)
896    const elements = $.ui.resolve(e)
897    const mascot =
898      'Raster' in elements ? (
899        <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
900      ) : (
901        <Box width={MASCOT_WIDTH} />
902      )
903    const recent = lines.filter(l => l.ts !== '').slice(-BAND_LOG_ROWS)
904    return (
905      <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
906        <Box justifyContent="space-between">
907          <Text wrap="truncate-end">
908            <Text color="claude">mefisto</Text>
909            {run.state === 'running' && <Text> · #{run.issue} {stageName(run.stage)}</Text>}
910            {run.state === 'completed' && <Text color="success"> · #{run.issue} ✓ {pr ? `PR #${pr}` : 'terminado'}</Text>}
911            {run.state === 'failed' && <Text color="error"> · #{run.issue} ✗ {stageName(run.stage)}</Text>}
912            <Text dimColor> {elapsed(end - run.startedMs)}</Text>
913          </Text>
914          <Box gap={2}>
915            {run.state === 'completed' && pr && (
916              <Button key="band-merge" hotkey="1" plain variant="primary" label="mergear" onPress={() => void merge($)} />
917            )}
918            {run.state === 'completed' && pr && <Button key="band-web" hotkey="2" plain label="ver PR" onPress={() => void openPr($)} />}
919            <Button key="band-open" hotkey="3" plain dimColor={run.state === 'running'} label="log" onPress={() => void toggleLog($)} />
920            {run.state !== 'running' && <Button key="band-close" hotkey="4" plain label="cerrar" onPress={() => void closeRun($)} />}
921          </Box>
922        </Box>
923        <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
924          {mascot}
925          <Box flexDirection="column" width={body} marginTop={1}>
926            <Text wrap="truncate-end">
927              {steps(run).map((st, i) => (
928                <Text
929                  color={st.mark === 'done' ? 'success' : st.mark === 'failed' ? 'error' : st.mark === 'current' ? 'warning' : undefined}
930                  dimColor={st.mark === 'pending'}
931                >
932                  {i > 0 ? ' ─ ' : ''}
933                  {st.mark === 'done' ? '✓' : st.mark === 'failed' ? '✗' : st.mark === 'current' ? '●' : '○'} {st.name}
934                </Text>
935              ))}
936            </Text>
937            {run.title !== '' && <Text dimColor wrap="truncate-end">{run.title}</Text>}
938            {run.state === 'running' && hold && <Text color="warning" wrap="truncate-end">{holdText(hold)}</Text>}
939            {run.state === 'failed' && run.lastError && <Text color="error" wrap="truncate-end">{run.lastError}</Text>}
940            {run.state === 'running' && recent.length === 0 && <Text dimColor>esperando eventos del agente…</Text>}
941            {run.state === 'running' &&
942              recent.map(line => (
943                <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
944                  {`${line.ts} ${line.kind === 'text' ? '» ' : ''}${line.text}`}
945                </Text>
946              ))}
947          </Box>
948        </Box>
949      </Box>
950    )
951  })
952
953  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
954    const { Box, Text, Button } = $.ui.resolve(e)
955    const run = await read($, runAtom)
956    if (!run) {
957      return (
958        <Box flexDirection="column">
959          <Text dimColor>Sin corrida. Lanza /mefisto-tooling &lt;issue&gt; o /mefisto-monitor &lt;issue&gt;.</Text>
960        </Box>
961      )
962    }
963    const lines = await read($, linesAtom)
964    const now = await read($, nowAtom)
965    const width = Math.max(20, e.props.bodyColumns ?? 60)
966    const rows = Math.max(3, (e.props.scroll?.bodyRows ?? PANE_ROWS) - FIXED_ROWS)
967    const end = run.finishedMs ?? (now || Date.now())
968    const pr = prNumber(run.pr)
969
970    return (
971      <Box flexDirection="column" width={width}>
972        <Box justifyContent="space-between">
973          <Text bold color="claude">mefisto-tooling #{run.issue}</Text>
974          <Text dimColor>{elapsed(end - run.startedMs)}</Text>
975        </Box>
976        {run.title !== '' && <Text wrap="truncate-end">{run.title}</Text>}
977        <Box>
978          {steps(run).map((s, i) => (
979            <Text
980              color={s.mark === 'done' ? 'success' : s.mark === 'failed' ? 'error' : s.mark === 'current' ? 'warning' : undefined}
981              dimColor={s.mark === 'pending'}
982            >
983              {i > 0 ? ' ─ ' : ''}
984              {s.mark === 'done' ? '✓' : s.mark === 'failed' ? '✗' : s.mark === 'current' ? '●' : '○'} {s.name}
985              {agentDuration(run, s.name)}
986            </Text>
987          ))}
988        </Box>
989        {run.state === 'completed' && (
990          <Box flexDirection="column">
991            <Box gap={3}>
992              <Text color="success" bold>✓ {pr ? `PR #${pr} creado` : 'terminado'}</Text>
993              {pr && <Button key="merge" hotkey="m" plain variant="primary" label={`Mergear #${pr}`} onPress={() => void merge($)} />}
994              {pr && <Button key="web" hotkey="v" plain label="Ver en GitHub" onPress={() => void openPr($)} />}
995              <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
996            </Box>
997            <Text dimColor wrap="truncate-end">
998              {pr ? `m: /mefisto-merge ${pr} (squash + borra rama) · ` : ''}c: cierra el log, la banda sigue · ctrl+x tab enfoca · esc vuelve al prompt
999            </Text>
1000          </Box>
1001        )}
1002        {run.state === 'failed' && (
1003          <Box flexDirection="column">
1004            <Text color="error" wrap="wrap">✗ falló en {stageName(run.stage)}{run.lastError ? `: ${run.lastError}` : ''}</Text>
1005            <Box gap={2}>
1006              <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1007            </Box>
1008          </Box>
1009        )}
1010        {run.state === 'running' && (
1011          <Box gap={2}>
1012            <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1013            <Text dimColor>3 en la banda también lo cierra · ctrl+x tab enfoca · esc vuelve al prompt</Text>
1014          </Box>
1015        )}
1016        <Text dimColor>{'─'.repeat(Math.min(width, 80))}</Text>
1017        <Box flexDirection="column">
1018          {lines.length === 0 && <Text dimColor>esperando eventos del agente…</Text>}
1019          {lines.slice(-rows).map(line => (
1020            <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
1021              {line.ts !== '' ? `${line.ts} ` : ''}
1022              {line.kind === 'text' ? '» ' : ''}
1023              {line.text}
1024            </Text>
1025          ))}
1026        </Box>
1027      </Box>
1028    )
1029  })
1030}
1031
1032function stageName(stage: string): string {
1033  return stage.replace(/^\d+-/, '')
1034}
1035
1036function agentDuration(run: MonitorRun, step: string): string {
1037  const d = run.agents[step]?.duration
1038  return typeof d === 'number' && d > 0 ? ` ${Math.round(d / 60)}m` : ''
1039}
1040
1041function lineColor(line: LogLine) {
1042  if (line.kind === 'fail') return 'error' as const
1043  if (line.kind === 'done') return 'success' as const
1044  if (line.kind === 'start') return 'claude' as const
1045  return undefined
1046}
1047
hooks/logic.ts 608 lines
1import type { BatchIssue, BatchRun, ChangelogSummary, Hold, IssueStats, OpenPr, BlockedItem, LogLine, MonitorAgent, MonitorRun, ReadyItem, ReadyList } from '../types'
2
3export const STATE_DIR = '.mefisto/pipeline'
4export const LOG_DIR = `${STATE_DIR}/logs`
5export const HISTORY = `${STATE_DIR}/pipeline-history.jsonl`
6export const EVENTS_LOG = `${STATE_DIR}/events.log`
7
8export const BATCH_STATUS = `${STATE_DIR}/pipeline-status-mefisto-batch.json`
9/** Senal de parada suave del batch (la misma que escribe /mefisto-batch-stop). */
10export const BATCH_STOP = `${STATE_DIR}/batch-stop`
11
12export const statusPath = (issue: string) =>
13  `${STATE_DIR}/pipeline-status-mefisto-tooling-${issue}.json`
14
15// El wrapper cuenta solo donde se ejecuta: al inicio de un comando (tras ; && || | ( then do o un salto de
16// linea), con asignaciones de entorno delante. Un texto que lo nombra (un heredoc, un grep, un echo) no lanza nada.
17const AT_COMMAND = String.raw`(?:^|[;&|(\n]|\bthen\b|\bdo\b)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*`
18const WRAPPER = String.raw`\S*mefisto-tmux-pipeline\.sh`
19const LAUNCH = new RegExp(`${AT_COMMAND}${WRAPPER}\\s+--tooling\\s+#?(\\d+)`)
20
21const RELEASE_RUN = new RegExp(`${AT_COMMAND}\\S*mefisto-release\\.sh\\b`)
22
23/** El comando ejecuta el script de /mefisto-release (no basta con nombrarlo en un texto). */
24export function isReleaseRun(command: string): boolean {
25  return RELEASE_RUN.test(withoutHeredocs(command))
26}
27
28/** El mensaje que lanza /mefisto-release (con o sin argumentos). */
29export const isReleasePrompt = (text: string) => /^\/mefisto-release(\s|$)/.test(text.trim())
30
31/** El comando sin el cuerpo de sus heredocs, que es texto y no se ejecuta. */
32export function withoutHeredocs(command: string): string {
33  return command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '<<heredoc')
34}
35
36export function toolingIssueOf(command: string): string | null {
37  return LAUNCH.exec(withoutHeredocs(command))?.[1] ?? null
38}
39
40const BATCH_LAUNCH = new RegExp(`${AT_COMMAND}${WRAPPER}\\s+--batch((?:\\s+#?\\d+)+)`)
41
42/** Los issues de un `mefisto-tmux-pipeline.sh --batch N M ...` (lo que corre /mefisto-sequential), en orden. */
43export function batchIssuesOf(command: string): string[] | null {
44  const m = BATCH_LAUNCH.exec(withoutHeredocs(command))
45  return m?.[1] ? (m[1].match(/\d+/g) ?? []) : null
46}
47
48/**
49 * Pone MEFISTO_UI=mod justo delante de cada invocacion del wrapper: corre el pipeline sin pane y este mod es el
50 * visor (MEF-ADR-0055). Al inicio del comando no basta: /mefisto-sequential lanza un comando compuesto
51 * (`validador ...; ... && MEFISTO_RUNTIME=claude ./mefisto-tmux-pipeline.sh --batch ...`) y la asignacion solo
52 * llegaria al primer comando.
53 */
54export function withModUi(command: string): string {
55  if (/(^|\s)MEFISTO_UI=/.test(command)) return command
56  return command.replace(new RegExp(`(${AT_COMMAND})(${WRAPPER})`, 'g'), '$1MEFISTO_UI=mod $2')
57}
58
59export function stampToMs(stamp: string): number {
60  const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(stamp)
61  if (!m) return 0
62  const [y = 0, mo = 1, d = 1, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
63  return new Date(y, mo - 1, d, h, mi, s).getTime()
64}
65
66type StatusFile = {
67  issue: string
68  title?: string
69  stage?: string
70  state?: string
71  started?: string
72  agents?: Record<string, MonitorAgent>
73  pr?: string | null
74  last_error?: string | null
75}
76
77export function runFromStatus(raw: string, prev: MonitorRun | null): MonitorRun | null {
78  let s: StatusFile
79  try {
80    s = JSON.parse(raw)
81  } catch {
82    return prev
83  }
84  const state = s.state === 'failed' ? 'failed' : s.state === 'completed' ? 'completed' : 'running'
85  return {
86    issue: String(s.issue),
87    title: s.title ?? prev?.title ?? '',
88    stage: s.stage ?? 'setup',
89    state,
90    startedMs: s.started ? stampToMs(s.started) : (prev?.startedMs ?? 0),
91    finishedMs: state === 'running' ? null : (prev?.finishedMs ?? Date.now()),
92    agents: s.agents ?? {},
93    pr: s.pr ?? null,
94    lastError: s.last_error ?? null,
95    eventsFile: prev?.eventsFile ?? null,
96  }
97}
98
99type HistoryEntry = {
100  issue: string
101  title?: string
102  pipeline?: string
103  started?: string
104  finished?: string
105  state?: string
106  stage?: string | null
107  pr?: string | null
108  error?: string | null
109}
110
111/** La ultima linea del historial de esta corrida: mismo issue, arrancada en o despues de `sinceMs`. */
112export function finishedFromHistory(tail: string, issue: string, sinceMs: number): HistoryEntry | null {
113  const lines = tail.split('\n').filter(Boolean).reverse()
114  for (const line of lines) {
115    let h: HistoryEntry
116    try {
117      h = JSON.parse(line)
118    } catch {
119      continue
120    }
121    if (h.pipeline !== 'mefisto-tooling' || String(h.issue) !== issue) continue
122    if (h.started && stampToMs(h.started) + 1000 < sinceMs) return null
123    return h
124  }
125  return null
126}
127
128const EVENTS = /^mefisto-tooling-stage-(\d+|merge)-([a-z-]+?)-(\d{8}-\d{6})-issue-(\d+)(?:-[^.]+)?(?:\.attempt-\d+)?\.events\.jsonl$/
129
130export type Entry = { name: string; mtimeMs: number }
131
132export function pickEventsFile(entries: Entry[], issue: string, sinceMs: number): string | null {
133  let best: Entry | null = null
134  for (const entry of entries) {
135    const m = EVENTS.exec(entry.name)
136    if (!m || m[4] !== issue) continue
137    if (stampToMs(m[3] ?? '') + 1000 < sinceMs) continue
138    if (!best || entry.mtimeMs > best.mtimeMs) best = entry
139  }
140  return best?.name ?? null
141}
142
143export function agentOfEventsFile(name: string): string {
144  return EVENTS.exec(name)?.[2] ?? ''
145}
146
147export const clip = (text: string, max: number) => {
148  const one = text.replace(/\s+/g, ' ').trim()
149  return one.length > max ? `${one.slice(0, max - 1)}…` : one
150}
151
152const WORKTREE = /\S*\/worktree-[^/\s]+\//g
153
154export const relative = (text: string) => text.replace(WORKTREE, '')
155
156const clock = (ts: unknown) => {
157  if (typeof ts !== 'string') return ''
158  const d = new Date(ts)
159  if (Number.isNaN(d.getTime())) return ''
160  const p = (n: number) => String(n).padStart(2, '0')
161  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
162}
163
164/** Filtro del visor (mefisto-stream-watch.sh) portado: una linea por evento relevante del contrato run-events v1. */
165export function parseEvent(raw: string): LogLine | null {
166  let ev: Record<string, any>
167  try {
168    ev = JSON.parse(raw)
169  } catch {
170    return null
171  }
172  const ts = clock(ev.ts)
173  switch (ev.type) {
174    case 'run.started':
175      return { ts, kind: 'start', text: `${ev.agent ?? 'agente'} · ${ev.model ?? ''}`.trim() }
176    case 'message':
177      if (ev.role !== 'assistant' || typeof ev.text !== 'string' || ev.text.trim() === '') return null
178      return { ts, kind: 'text', text: clip(ev.text, 400) }
179    case 'tool.started':
180      return { ts, kind: 'tool', text: clip(`${ev.tool ?? '?'} ${relative(String(ev.input_summary ?? ''))}`, 400) }
181    case 'tool.completed':
182      return ev.ok === false ? { ts, kind: 'fail', text: `${ev.tool ?? '?'} falló` } : null
183    case 'run.completed': {
184      const cost = typeof ev.estimated_cost_usd === 'number' ? ` · $${ev.estimated_cost_usd.toFixed(2)}` : ''
185      const turns = typeof ev.turns === 'number' ? ` · ${ev.turns} turnos` : ''
186      return { ts, kind: ev.status === 'success' ? 'done' : 'fail', text: `${ev.status ?? 'fin'}${turns}${cost}` }
187    }
188    case 'run.failed':
189      return { ts, kind: 'fail', text: clip(String(ev.error ?? 'fallo'), 400) }
190    default:
191      return null
192  }
193}
194
195export type Step = { name: string; mark: 'done' | 'current' | 'failed' | 'pending' }
196
197const STAGES = [
198  { id: 'setup', name: 'setup' },
199  { id: '1-writer', name: 'writer' },
200  { id: '2-reviewer', name: 'reviewer' },
201  { id: 'done', name: 'done' },
202]
203
204export function steps(run: MonitorRun): Step[] {
205  const stage = run.stage === 'merge-writer' ? '2-reviewer' : run.stage
206  const at = Math.max(0, STAGES.findIndex(s => s.id === stage))
207  return STAGES.map((s, i) => {
208    if (run.state === 'completed') return { name: s.name, mark: 'done' }
209    if (i < at) return { name: s.name, mark: 'done' }
210    if (i === at) return { name: s.name, mark: run.state === 'failed' ? 'failed' : 'current' }
211    return { name: s.name, mark: 'pending' }
212  })
213}
214
215export function elapsed(ms: number): string {
216  const total = Math.max(0, Math.floor(ms / 1000))
217  const h = Math.floor(total / 3600)
218  const m = Math.floor((total % 3600) / 60)
219  const s = total % 60
220  const p = (n: number) => String(n).padStart(2, '0')
221  return h > 0 ? `${h}:${p(m)}:${p(s)}` : `${p(m)}:${p(s)}`
222}
223
224export function prNumber(pr: string | null): string | null {
225  return pr ? (/(\d+)\/?$/.exec(pr)?.[1] ?? null) : null
226}
227
228export type MascotPose = { role: 'desarrollador' | 'revisor'; state: string }
229
230const EDITS = /^(Edit|Write|MultiEdit|NotebookEdit)\b/
231
232/** El rol sale del stage y la pose del ultimo evento: herramienta trabaja, texto piensa, edicion del reviewer corrige. */
233export function mascotPose(run: MonitorRun, last: LogLine | undefined): MascotPose {
234  if (run.state === 'completed') return { role: 'revisor', state: 'aprobado' }
235  if (run.state === 'failed') return { role: 'desarrollador', state: 'error' }
236  const role = run.stage === '2-reviewer' ? 'revisor' : 'desarrollador'
237  if (!last || last.kind !== 'tool') return { role, state: 'pensando' }
238  if (role === 'revisor' && EDITS.test(last.text)) return { role, state: 'corrigiendo' }
239  return { role, state: 'trabajando' }
240}
241
242type Grid = readonly string[]
243
244/** Columnas con algun pixel visible en cualquiera de los cuadros: recorta el margen sin que la mascota salte. */
245export function usedColumns(grids: readonly Grid[]): { from: number; to: number } {
246  let from = Infinity
247  let to = -1
248  for (const g of grids) {
249    for (const row of g) {
250      for (let x = 0; x < row.length; x++) {
251        const c = row[x]
252        if (c !== '.' && c !== ' ') {
253          from = Math.min(from, x)
254          to = Math.max(to, x)
255        }
256      }
257    }
258  }
259  return to < 0 ? { from: 0, to: 0 } : { from, to }
260}
261
262export function cropGrid(grid: Grid, cols: { from: number; to: number }): Grid {
263  return grid.map(row => row.slice(cols.from, cols.to + 1).padEnd(cols.to - cols.from + 1, '.'))
264}
265
266export const NEXT_ORDER = './.claude/scripts/mefisto-next-order.sh'
267export const PLANNER_AGENT = 'mefisto-planner'
268/** Filas fijas de la lista de listos: la banda no cambia de alto con la cantidad de issues. */
269export const READY_ROWS = 5
270
271type NextOrderJson = {
272  items?: { number: number; title: string; after?: number[] }[]
273  blocked?: { number?: number; by?: number }[]
274  cycles?: unknown[]
275  launch?: string | null
276}
277
278/** Exit 0 (hay orden) y 1 (vacio) traen JSON valido; 2 es un fallo de gh o de argumentos. */
279export function parseNextOrder(exitCode: number, stdout: string, stderr: string, titles: Record<number, string> = {}): ReadyList {
280  const empty = { items: [], blocked: [], cycleCount: 0, launch: null }
281  if (exitCode !== 0 && exitCode !== 1) {
282    return { ...empty, error: stderr.split('\n').find(l => l.trim() !== '')?.trim() || `exit ${exitCode}` }
283  }
284  let json: NextOrderJson
285  try {
286    json = JSON.parse(stdout)
287  } catch {
288    return { ...empty, error: 'salida de next-order no es JSON' }
289  }
290  return {
291    items: (json.items ?? []).map(i => ({ number: i.number, title: i.title, after: i.after ?? [] })),
292    blocked: blockedOf(json.blocked ?? [], titles),
293    cycleCount: (json.cycles ?? []).length,
294    launch: json.launch ?? null,
295    error: null,
296  }
297}
298
299/** next-order da una fila por par issue-dependencia: se agrupan por issue para contar issues, no pares. */
300function blockedOf(rows: { number?: number; by?: number }[], titles: Record<number, string>): BlockedItem[] {
301  const out: BlockedItem[] = []
302  for (const r of rows) {
303    if (typeof r.number !== 'number') continue
304    const item = out.find(b => b.number === r.number) ?? out[out.push({ number: r.number, title: titles[r.number] ?? '', by: [] }) - 1]
305    if (item && typeof r.by === 'number' && !item.by.includes(r.by)) item.by.push(r.by)
306  }
307  return out
308}
309
310export type ReadyRow = { number: number; title: string; reason: string; isLaunchable: boolean }
311
312/**
313 * Todos los estado:listo de la banda: primero los lanzables en el orden de next-order y al final los bloqueados,
314 * visibles para armar un sequential a mano pero atenuados y sin tecla.
315 */
316export function readyRows(list: ReadyList): ReadyRow[] {
317  return [
318    ...list.items.map(i => ({ number: i.number, title: i.title, reason: reasonOf(i), isLaunchable: true })),
319    ...list.blocked.map(b => ({ number: b.number, title: b.title, reason: '', isLaunchable: false })),
320  ]
321}
322
323/** Titulos de los estado:listo abiertos (`gh issue list --json number,title`): next-order no los da para los bloqueados. */
324export function titlesOf(stdout: string): Record<number, string> {
325  try {
326    const rows = JSON.parse(stdout) as { number: number; title: string }[]
327    return Object.fromEntries(rows.map(r => [r.number, r.title]))
328  } catch {
329    return {}
330  }
331}
332
333/** Todo se lanza por /mefisto-sequential, aunque sea un solo issue: la cadena mergea y sincroniza main. */
334export const sequentialOf = (issue: number) => `/mefisto-sequential ${issue}`
335
336/** Un solo issue sin merge automatico: /mefisto-tooling deja el PR abierto para revisarlo. */
337export const toolingOf = (issue: number) => `/mefisto-tooling ${issue}`
338
339export function reasonOf(item: ReadyItem): string {
340  return item.after.length > 0 ? `tras ${item.after.map(n => `#${n}`).join(' ')}` : ''
341}
342
343export function padEnd(text: string, width: number): string {
344  return text.length >= width ? text : text + ' '.repeat(width - text.length)
345}
346
347/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
348export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
349  const pages = Math.max(1, Math.ceil(total / size))
350  return { page: page >= 0 && page < pages ? page : 0, pages }
351}
352
353/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
354export function agentFlagOf(cmdline: string): string | null {
355  const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
356  return m ? (m[1] ?? null) : null
357}
358
359/**
360 * Mefisto en espera, sobre la cara 'normal': boca plana corrida a un lado y un reloj de arena a la izquierda.
361 * Mientras Claude trabaja en la sesion, en cada tick los ojos miran de un lado al otro y la arena cambia de
362 * mitad; en reposo mira al frente con la arena arriba.
363 */
364const HOURGLASS = [
365  ['YYYY', 'gyyg', '.yg.', 'g..g', 'g..g', 'YYYY'],
366  ['YYYY', 'g..g', '.gg.', 'g..g', 'gyyg', 'YYYY'],
367] as const
368
369export function waitingFace(base: Grid, tick: number | null): Grid {
370  const eyes =
371    tick === null ? ['.....rrEErrEErr.tt', '.....rrEErrEErr..t']
372    : tick % 2 === 0 ? ['.....rEErrEErrr.tt', '.....rEErrEErrr..t']
373    : ['.....rrrEErrEEr.tt', '.....rrrEErrEEr..t']
374  const glass = HOURGLASS[tick !== null && tick % 2 === 1 ? 1 : 0]
375  return base.map((row, y) => {
376    let out = row
377    if (y === 6 || y === 7) out = eyes[y - 6] ?? out
378    if (y === 9) out = '......rrrrMMMr..t.'
379    const g = y >= 6 ? glass[y - 6] : undefined
380    if (g) out = [...out].map((c, x) => (x < g.length && g[x] !== '.' ? g[x] : c)).join('')
381    return out
382  })
383}
384
385type BatchStatusFile = {
386  started?: string
387  state?: string
388  current?: string | null
389  stop_requested?: boolean
390  hold_seconds?: number
391  issues?: { issue: string; status: string; pr: string | null }[]
392}
393
394/** El estado publicado del batch; un archivo de un batch anterior (arrancado antes de `sinceMs`) se ignora. */
395export function batchFromStatus(raw: string, prev: BatchRun, sinceMs: number): BatchRun {
396  let s: BatchStatusFile
397  try {
398    s = JSON.parse(raw)
399  } catch {
400    return prev
401  }
402  const startedMs = s.started ? stampToMs(s.started) : 0
403  if (startedMs + 1000 < sinceMs) return prev
404  const state = s.state === 'completed' || s.state === 'failed' || s.state === 'stopped' ? s.state : 'running'
405  return {
406    issues: (s.issues ?? []).map(i => ({ issue: String(i.issue), status: i.status ?? '', pr: i.pr ?? null })),
407    state,
408    current: s.current ? String(s.current) : null,
409    stopRequested: s.stop_requested === true || prev.stopRequested,
410    holdSeconds: s.hold_seconds ?? 0,
411    startedMs: startedMs || prev.startedMs,
412    finishedMs: state === 'running' ? null : (prev.finishedMs ?? Date.now()),
413    stats: prev.stats ?? {},
414  }
415}
416
417export type IssueMark = 'done' | 'current' | 'failed' | 'deferred' | 'pending'
418
419export function issueMark(status: string): IssueMark {
420  if (status.startsWith('completado')) return 'done'
421  if (status.startsWith('ERROR')) return 'failed'
422  if (status.startsWith('aplazado')) return 'deferred'
423  if (status.startsWith('en curso')) return 'current'
424  return 'pending'
425}
426
427export function batchCounts(issues: BatchIssue[]) {
428  const marks = issues.map(i => issueMark(i.status))
429  return {
430    total: issues.length,
431    done: marks.filter(m => m === 'done').length,
432    failed: marks.filter(m => m === 'failed').length,
433    deferred: marks.filter(m => m === 'deferred').length,
434  }
435}
436
437/** Los issues que pasaron a mergeados entre dos lecturas: un toast por cada uno. */
438export function newlyMerged(prev: BatchIssue[], next: BatchIssue[]): BatchIssue[] {
439  return next.filter(i => issueMark(i.status) === 'done' && issueMark(prev.find(p => p.issue === i.issue)?.status ?? '') !== 'done')
440}
441
442/** El PR para abrir: el del issue en curso si ya lo tiene, o el del ultimo mergeado. */
443export function batchPr(batch: BatchRun): string | null {
444  const current = batch.issues.find(i => i.issue === batch.current)
445  if (current?.pr) return current.pr
446  return [...batch.issues].reverse().find(i => i.pr)?.pr ?? null
447}
448
449/** Resumen del cierre: mergeados, fallidos, aplazados y la espera por rate limit si la hubo. */
450export function batchSummary(batch: BatchRun): string {
451  const c = batchCounts(batch.issues)
452  const hold = batch.holdSeconds > 0 ? `espera ${Math.round(batch.holdSeconds / 60)}m` : ''
453  return [
454    `✓ ${c.done} mergeados`,
455    c.failed > 0 ? `✗ ${c.failed} fallidos` : '',
456    c.deferred > 0 ? `⏸ ${c.deferred} aplazados` : '',
457    hold,
458  ].filter(Boolean).join(' · ')
459}
460
461type StatsEntry = {
462  issue?: string
463  pipeline?: string
464  started?: string
465  finished?: string
466  agents?: Record<string, { metrics?: { estimated_cost_usd?: number | null } }>
467}
468
469/**
470 * Duracion y costo de las corridas de tooling de estos issues, desde el final del historial: la ultima de cada
471 * issue arrancada en o despues de `sinceMs`. El costo suma el estimado de cada agente (null si ninguno lo da).
472 */
473export function issueStatsFromHistory(tail: string, issues: string[], sinceMs: number): Record<string, IssueStats> {
474  const out: Record<string, IssueStats> = {}
475  for (const line of tail.split('\n')) {
476    let h: StatsEntry
477    try {
478      h = JSON.parse(line)
479    } catch {
480      continue
481    }
482    const issue = String(h.issue ?? '')
483    if (h.pipeline !== 'mefisto-tooling' || !issues.includes(issue) || !h.started || !h.finished) continue
484    const startedMs = stampToMs(h.started)
485    const finishedMs = new Date(h.finished).getTime()
486    if (startedMs + 1000 < sinceMs || Number.isNaN(finishedMs)) continue
487    const costs = Object.values(h.agents ?? {})
488      .map(a => a.metrics?.estimated_cost_usd)
489      .filter((c): c is number => typeof c === 'number')
490    out[issue] = { durationMs: Math.max(0, finishedMs - startedMs), costUsd: costs.length > 0 ? costs.reduce((a, b) => a + b, 0) : null }
491  }
492  return out
493}
494
495export const fmtCost = (usd: number | null) => (usd === null ? '$?' : `$${usd.toFixed(2)}`)
496
497/** Total de lo terminado: duracion sumada y costo sumado (sin contar los que no lo informan). */
498export function statsTotal(stats: IssueStats[]): IssueStats {
499  const costs = stats.map(s => s.costUsd).filter((c): c is number => c !== null)
500  return {
501    durationMs: stats.reduce((a, s) => a + s.durationMs, 0),
502    costUsd: costs.length > 0 ? costs.reduce((a, b) => a + b, 0) : null,
503  }
504}
505
506/**
507 * PRs abiertos (`gh pr list --json number,title,isDraft,headRefName`) sin los borradores, el mas reciente primero.
508 * Los de field notes (rama `docs/<agente>-field-note-<sesion>` de mefisto-field-note.sh) se marcan aparte.
509 */
510export function parseOpenPrs(stdout: string): OpenPr[] | null {
511  try {
512    const rows = JSON.parse(stdout) as { number: number; title: string; isDraft?: boolean; headRefName?: string }[]
513    return rows
514      .filter(r => !r.isDraft)
515      .map(r => ({ number: r.number, title: r.title, isFieldNote: /-field-note-/.test(r.headRefName ?? '') }))
516      .sort((a, b) => b.number - a.number)
517  } catch {
518    return null
519  }
520}
521
522export const MERGE_ALL = 'Todos (--all)'
523
524/** Opciones del dialogo de merge (2-4): todos y los PRs mas recientes; con uno solo, ese y cancelar. */
525export function mergeOptions(prs: OpenPr[]): string[] {
526  const label = (p: OpenPr) => clip(`#${p.number} ${p.title}`, 60)
527  if (prs.length === 1 && prs[0]) return [label(prs[0]), 'Cancelar']
528  return [MERGE_ALL, ...prs.slice(0, 3).map(label)]
529}
530
531/**
532 * Los argumentos de /mefisto-merge que salen de la respuesta (las opciones marcadas, unidas por ", "): --all si
533 * se eligio todos; de cada opcion solo su #N (el titulo puede traer fechas); del texto libre, sus numeros.
534 * Null si no queda ningun PR.
535 */
536export function mergeArgsOf(answer: string, options: string[]): string | null {
537  if (answer.includes(MERGE_ALL)) return '--all'
538  let rest = answer
539  const numbers: string[] = []
540  for (const option of options) {
541    if (!rest.includes(option)) continue
542    rest = rest.replace(option, '')
543    const n = /^#(\d+)/.exec(option)?.[1]
544    if (n) numbers.push(n)
545  }
546  numbers.push(...(rest.match(/\d+/g) ?? []))
547  const unique = [...new Set(numbers)]
548  return unique.length > 0 ? unique.join(' ') : null
549}
550
551/** Field notes en `docs/bitacora/field-notes/` sin procesar (las de `procesadas/` quedan fuera del listado). */
552export function fieldNotesIn(names: string[]): number {
553  return names.filter(n => n.endsWith('.md')).length
554}
555
556/** Resumen de `changelog.d/`: issues con fragmentos (sin README) y entradas por categoria de Keep a Changelog. */
557export function changelogOf(names: string[]): ChangelogSummary {
558  const fragments = names.filter(n => n !== 'README.md' && n.endsWith('.md'))
559  const by = (cat: string) => fragments.filter(n => n.endsWith(`.${cat}.md`)).length
560  return {
561    issues: new Set(fragments.map(n => n.split('.')[0])).size,
562    added: by('added'),
563    changed: by('changed'),
564    fixed: by('fixed'),
565    removed: by('removed'),
566  }
567}
568
569/** SemVer: algo agregado pide minor; solo cambios, arreglos o retiros, patch. El recomendado va primero. */
570export function releaseOptions(summary: ChangelogSummary): string[] {
571  const bump = summary.added > 0 ? 'minor' : 'patch'
572  const other = bump === 'minor' ? 'patch' : 'minor'
573  return [`${bump} (recomendado)`, other, `Solo preparar el PR (${bump} --prepare-only)`]
574}
575
576/** Los argumentos de /mefisto-release de la opcion elegida, o null. */
577export function releaseArgsOf(answer: string): string | null {
578  const bump = /\b(major|minor|patch)\b/.exec(answer)?.[1]
579  if (!bump) return null
580  return answer.includes('--prepare-only') ? `${bump} --prepare-only` : bump
581}
582
583/**
584 * La instruccion para integrar la bitacora. El historiador lee las field notes del checkout local, asi que antes
585 * hay que mergear los PRs de field notes que esperan y traer main al dia (las de PRs ya mergeados viven en
586 * origin/main hasta el pull).
587 */
588export function bitacoraPrompt(fieldNotePrs: number[]): string {
589  const merge = fieldNotePrs.length > 0
590    ? `mergea los PRs de field notes ${fieldNotePrs.map(n => `#${n}`).join(' ')} con /mefisto-merge, `
591    : ''
592  return `Integra la bitacora: ${merge}pon el checkout en main al dia (git switch main && git pull --ff-only) y luego corre /mefisto-bitacora.`
593}
594
595const HOLD = /^\[\d{2}:\d{2}:\d{2}\]\[hold\] (\S+): esperando, proxima sonda (\d{2}:\d{2})(?::\d{2})? \(techo (\d{2}:\d{2})\)/
596
597/**
598 * La espera en curso: la ultima linea de events.log es un `[hold]` (mefisto-tooling-pipeline.sh la escribe en
599 * cada sonda; cualquier actividad posterior, incluido `[hold][resume]`, la da por terminada).
600 */
601export function holdOf(tail: string): Hold | null {
602  const last = tail.split('\n').filter(l => l.trim() !== '').pop() ?? ''
603  const m = HOLD.exec(last)
604  return m ? { family: m[1] ?? '', nextProbe: m[2] ?? '', deadline: m[3] ?? '' } : null
605}
606
607export const holdText = (hold: Hold) => `en espera por ${hold.family} · próxima sonda ${hold.nextProbe} · techo ${hold.deadline}`
608
hooks/sprites.ts 831 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' | 'historiador' | 'release'
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  // Historiador · pluma y tintero: la pluma sube y baja a mojarse en la tinta (mods internos: /mefisto-bitacora)
605  const historiador_escribiendo_A: Grid = [
606    '                  ',
607    '                  ',
608    '  B               ',
609    ' BU               ',
610    ' BU               ',
611    ' BU               ',
612    '  U               ',
613    '  U               ',
614    '  E               ',
615    ' GGG              ',
616    'DDDDD             ',
617    'DDDDD             ',
618  ]
619  const historiador_escribiendo_B: Grid = [
620    '                  ',
621    '                  ',
622    '                  ',
623    '  B               ',
624    ' BU               ',
625    ' BU               ',
626    ' BU               ',
627    '  U               ',
628    '  U               ',
629    ' GUG              ',
630    'DDDDD             ',
631    'DDDDD             ',
632  ]
633  const historiador_listo_A: Grid = [
634    '                  ',
635    '                  ',
636    '  B               ',
637    ' BU               ',
638    ' BU               ',
639    ' BU               ',
640    '  U               ',
641    '  U               ',
642    '  E               ',
643    ' GGG              ',
644    'DDDDD             ',
645    'DDDDD             ',
646  ]
647
648  // Release · cohete en su plataforma: en reposo mientras se prepara, despegando con llama mientras publica
649  const release_reposo_A: Grid = [
650    '                  ',
651    '                  ',
652    '                  ',
653    '                  ',
654    '  R               ',
655    ' QQQ              ',
656    ' QUQ              ',
657    ' QQQ              ',
658    ' QQQ              ',
659    'RQQQR             ',
660    'R   R             ',
661    'GGGGG             ',
662  ]
663  const release_despegando_A: Grid = [
664    '  R               ',
665    ' QQQ              ',
666    ' QUQ              ',
667    ' QQQ              ',
668    ' QQQ              ',
669    'RQQQR             ',
670    'R   R             ',
671    ' YyY              ',
672    '  Y               ',
673    '                  ',
674    's s s             ',
675    'GGGGG             ',
676  ]
677  const release_despegando_B: Grid = [
678    '  R               ',
679    ' QQQ              ',
680    ' QUQ              ',
681    ' QQQ              ',
682    ' QQQ              ',
683    'RQQQR             ',
684    'R   R             ',
685    ' yYy              ',
686    ' yYy              ',
687    '  y               ',
688    ' s s              ',
689    'GGGGG             ',
690  ]
691
692  return {
693    desarrollador: {
694      trabajando: { emotion: 'normal', layers: [desarrollador_trabajando_A, desarrollador_trabajando_B] },
695      pensando: { emotion: 'pensando', layers: [desarrollador_pensando_A, desarrollador_pensando_A] },
696      error: { emotion: 'preocupado', layers: [desarrollador_error_A, desarrollador_error_B] },
697      resuelto: { emotion: 'sonriente', layers: [desarrollador_resuelto_A, desarrollador_resuelto_A] },
698    },
699    tester: {
700      trabajando: { emotion: 'normal', layers: [tester_trabajando_A, tester_trabajando_B] },
701      pensando: { emotion: 'pensando', layers: [tester_pensando_A, tester_pensando_A] },
702      feliz: { emotion: 'feliz', layers: [tester_feliz_A, tester_feliz_B] },
703    },
704    revisor: {
705      trabajando: { emotion: 'normal', layers: [revisor_trabajando_A, revisor_trabajando_B] },
706      pensando: { emotion: 'pensando', layers: [revisor_pensando_A, revisor_pensando_A] },
707      corrigiendo: { emotion: 'concentrado', layers: [revisor_corrigiendo_A, revisor_corrigiendo_B] },
708      aprobado: { emotion: 'sonriente', layers: [revisor_aprobado_A, revisor_aprobado_A] },
709    },
710    planner: {
711      planeando: { emotion: 'normal', layers: [planner_planeando_A, planner_planeando_B] },
712      pensando: { emotion: 'pensando', layers: [planner_pensando_A, planner_pensando_A] },
713      listo: { emotion: 'sonriente', layers: [planner_listo_A, planner_listo_A] },
714    },
715    infraestructura: {
716      desplegando: { emotion: 'normal', layers: [infraestructura_desplegando_A, infraestructura_desplegando_B] },
717      caido: { emotion: 'asustado', layers: [infraestructura_caido_A, infraestructura_caido_A] },
718      arriba: { emotion: 'sonriente', layers: [infraestructura_arriba_A, infraestructura_arriba_A] },
719    },
720    historiador: {
721      escribiendo: { emotion: 'normal', layers: [historiador_escribiendo_A, historiador_escribiendo_B] },
722      listo: { emotion: 'sonriente', layers: [historiador_listo_A, historiador_listo_A] },
723    },
724    release: {
725      reposo: { emotion: 'normal', layers: [release_reposo_A, release_reposo_A] },
726      despegando: { emotion: 'sonriente', layers: [release_despegando_A, release_despegando_B] },
727      listo: { emotion: 'feliz', layers: [release_reposo_A, release_reposo_A] },
728    },
729  }
730})()
731
732/** Versión de una línea por emoción, para la status line. */
733export const ONE_LINERS: Record<Emotion, string> = {
734  normal: "ψ(•‿•)",
735  feliz: "ψ(^▽^)♥",
736  sonriente: "ψ(^▽^)",
737  enojado: "ψ(ಠ益ಠ)",
738  dormido: "ψ(-.-)zz",
739  asustado: "ψ(°o°)!",
740  pensando: "ψ(•_•)💡",
741  preocupado: "ψ(°~°);",
742  concentrado: "ψ(•ᴗ<)",
743}
744
745/** Pone una capa encima de una cuadrícula. En la capa, ' ' significa "no tocar". */
746export function overlay(base: Grid, layer: Grid): Grid {
747  return base.map((row, y) => {
748    const top = layer[y] ?? ''
749    let out = ''
750    for (let x = 0; x < WIDTH; x++) {
751      const c = top[x]
752      out += c && c !== ' ' ? c : row[x] ?? '.'
753    }
754    return out
755  })
756}
757
758/** Arma el sprite de un rol en un estado y un cuadro. */
759export function sprite(role: Role, state: string, frame: Frame = 0): Grid {
760  const def = ROLES[role]?.[state]
761  if (!def) throw new Error(`Estado desconocido: ${role}.${state}`)
762  return overlay(EMOTIONS[def.emotion][frame], def.layers[frame])
763}
764
765/** Sprite solo con la cara, sin objeto de rol. */
766export function face(emotion: Emotion, frame: Frame = 0): Grid {
767  return EMOTIONS[emotion][frame]
768}
769
770function colorOf(ch: string | undefined): number | null {
771  if (!ch || ch === '.' || ch === ' ') return null
772  const c = PALETTE[ch]
773  if (c === undefined) throw new Error(`Letra sin color en PALETTE: '${ch}'`)
774  return c
775}
776
777const UPPER = '▀'.codePointAt(0)!
778const LOWER = '▄'.codePointAt(0)!
779const SPACE = ' '.codePointAt(0)!
780
781/**
782 * Convierte una cuadrícula en las celdas empacadas que recibe el elemento Raster.
783 * Cada celda junta dos píxeles verticales: ▀ con el de arriba como color y el de abajo como fondo.
784 * Resultado: columns = WIDTH (18), rows = RASTER_ROWS (6).
785 */
786export function toRasterCells(grid: Grid): string {
787  const numbers: number[] = []
788  for (let y = 0; y < HEIGHT; y += 2) {
789    for (let x = 0; x < WIDTH; x++) {
790      const top = colorOf(grid[y]?.[x])
791      const bottom = colorOf(grid[y + 1]?.[x])
792      if (top !== null && bottom !== null) numbers.push(UPPER, top, bottom)
793      else if (top !== null) numbers.push(UPPER, top, DEFAULT_COLOR)
794      else if (bottom !== null) numbers.push(LOWER, bottom, DEFAULT_COLOR)
795      else numbers.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
796    }
797  }
798  const bytes = new Uint8Array(Uint32Array.from(numbers).buffer)
799  const anyBytes = bytes as Uint8Array & { toBase64?: () => string }
800  if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
801  let bin = ''
802  for (const b of bytes) bin += String.fromCharCode(b)
803  return btoa(bin)
804}
805
806/** Props listas para Raster({ ...rasterProps(grid, 'mefisto') }). */
807export function rasterProps(grid: Grid, key: string) {
808  return { key, columns: WIDTH, rows: RASTER_ROWS, cells: toRasterCells(grid) }
809}
810
811/** Convierte una cuadrícula en un documento SVG (para la app de escritorio). */
812export function toSvg(grid: Grid, scale = 6): string {
813  const rects: string[] = []
814  grid.forEach((row, y) => {
815    let x = 0
816    while (x < WIDTH) {
817      const ch = row[x]
818      const c = colorOf(ch)
819      if (c === null) { x++; continue }
820      let end = x
821      while (end < WIDTH && row[end] === ch) end++
822      const hex = '#' + c.toString(16).padStart(6, '0')
823      rects.push(`<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${hex}"/>`)
824      x = end
825    }
826  })
827  const w = WIDTH * scale
828  const h = HEIGHT * scale
829  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">${rects.join('')}</svg>`
830}
831
types/index.d.ts 78 lines
1export type MonitorAgent = { duration: number | null; result: string }
2
3export type MonitorRun = {
4  issue: string
5  title: string
6  stage: string
7  state: 'running' | 'completed' | 'failed'
8  startedMs: number
9  finishedMs: number | null
10  agents: Record<string, MonitorAgent>
11  pr: string | null
12  lastError: string | null
13  eventsFile: string | null
14}
15
16export type ReadyItem = { number: number; title: string; after: number[] }
17
18/** Listos en el orden de mefisto-next-order.sh, con la linea de /mefisto-sequential que los lanza a todos. */
19export type BlockedItem = { number: number; title: string; by: number[] }
20
21export type ReadyList = { items: ReadyItem[]; blocked: BlockedItem[]; cycleCount: number; launch: string | null; error: string | null }
22
23export type BatchIssue = { issue: string; status: string; pr: string | null }
24
25/** Duracion y costo estimado de la corrida de tooling de un issue, del pipeline-history.jsonl. */
26export type IssueStats = { durationMs: number; costUsd: number | null }
27
28/** Un /mefisto-sequential en curso o terminado, leido de pipeline-status-mefisto-batch.json. */
29export type BatchRun = {
30  issues: BatchIssue[]
31  state: 'running' | 'completed' | 'failed' | 'stopped'
32  current: string | null
33  stopRequested: boolean
34  holdSeconds: number
35  startedMs: number
36  finishedMs: number | null
37  stats: Record<string, IssueStats>
38}
39
40/** PR abierto sin borradores; los de field notes de la bitacora se cuentan aparte. */
41export type OpenPr = { number: number; title: string; isFieldNote: boolean }
42
43/** Fragmentos de changelog.d/ por consolidar en el proximo release. */
44export type ChangelogSummary = { issues: number; added: number; changed: number; fixed: number; removed: number }
45
46/** Espera por rate limit en curso, de la ultima linea de events.log. */
47export type Hold = { family: string; nextProbe: string; deadline: string }
48
49/** El subagente mefisto-historiador escribiendo la bitacora en esta sesion (lo lanza /mefisto-bitacora). */
50export type Historian = { startedMs: number; finishedMs: number | null }
51
52/** Un /mefisto-release en esta sesion: en reposo mientras se prepara, despegando mientras corre su script. */
53export type Release = { phase: 'reposo' | 'despegando'; startedMs: number; finishedMs: number | null }
54
55export type LogKind = 'start' | 'tool' | 'text' | 'fail' | 'done'
56
57export type LogLine = { ts: string; kind: LogKind; text: string }
58
59declare module 'claude-code' {
60  interface PluginState {
61    'mefisto-monitor': {
62      run: MonitorRun | null
63      lines: LogLine[]
64      now: number
65      isExecutionSession: boolean
66      ready: ReadyList | null
67      readyPage: number
68      batch: BatchRun | null
69      openPrs: OpenPr[] | null
70      fieldNotes: number | null
71      changelog: ChangelogSummary | null
72      hold: Hold | null
73      historian: Historian | null
74      release: Release | null
75    }
76  }
77}
78