Consola de operacion de Mefisto en la sesion de ejecucion: listos y lanzamiento, seguimiento de /mefisto-tooling y /mefisto-sequential, PRs, bitacora y release.

Consola de operacion de Mefisto (MEF-ADR-0055): un mod de Claude Code para toda sesion interactiva que no sea la del planner. Lista los listos y los lanza, sigue /mefisto-tooling y /mefisto-sequential, y ofrece PRs, bitacora y release. Solo lee .mefisto/pipeline/, gh y el repo; sus acciones delegan en los skills internos existentes. Su par en la sesion del planner es mefisto-planner-board.
El nombre viene del Prolog im Himmel del Fausto de Goethe: la apuesta que Mefisto le hace a Dios de que puede con Fausto. En este repo se le da a Mefisto lo que necesita para ganarla (MEF-ADR-0055, decision 9).
Una sola vez por checkout. Los mods se instalan en alcance local (.claude/settings.local.json, no versionado) desde el marketplace de carpeta .claude/mods/:
claude plugin marketplace add ./.claude/mods --scope local
claude plugin install mefisto-divine-wager@mefisto-mods --scope local
claude plugin install mefisto-planner-board@mefisto-mods --scope local
Desde ahi cargan solos en toda sesion de este checkout, sin --plugin-dir, y se leen de la carpeta: un cambio se aplica con /reload-plugins. Los worktrees de los pipelines no tienen settings.local.json, asi que sus sesiones -p no los cargan. Para probar una copia suelta: claude --plugin-dir .claude/mods/<mod>.
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-divine-wager 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-divine-wager batch <issues> reengancha un sequential ya lanzado./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./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.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.3 abre 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 log y 4 cierra la corrida y vuelve a los listos./mefisto-merge o GitHub), la corrida se cierra sola./mefisto-divine-wager [issue|batch <issues>|merge|close|refresh] abre el log enfocado, sigue una corrida o un sequential, o ejecuta la accion.m mergea, v abre el PR, c cierra; ctrl+x tab lo enfoca y esc vuelve al prompt.claude plugin validate .claude/mods/mefisto-divine-wager
claude plugin test .claude/mods/mefisto-divine-wagerhooks/register.tsx 1057 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BatchRun, LogLine, PipelineRun, 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 fitTitle,
28 summaryTitleWidth,
29 statsPending,
30 statsTotal,
31 issueMark,
32 mascotPose,
33 isReleasePrompt,
34 isReleaseRun,
35 holdOf,
36 holdText,
37 bitacoraPrompt,
38 changelogOf,
39 fieldNotesIn,
40 releaseArgsOf,
41 releaseOptions,
42 mergeArgsOf,
43 mergeOptions,
44 newlyMerged,
45 padEnd,
46 pageOf,
47 parseNextOrder,
48 parseOpenPrs,
49 readyRows,
50 sequentialOf,
51 titlesOf,
52 toolingOf,
53 waitingFace,
54 parseEvent,
55 pickEventsFile,
56 prNumber,
57 runFromStatus,
58 statusPath,
59 steps,
60 toolingIssueOf,
61 usedColumns,
62 withModUi,
63 historianFrom,
64 isAgentActive,
65} from './logic'
66import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, face, sprite } from './sprites'
67
68const PANE = 'mefisto-divine-wager'
69const MAX_LINES = 200
70const STATUS_GRACE_MS = 30_000
71// Un seguimiento que tras este tiempo sigue en setup sin status ni historial no corresponde a ninguna corrida.
72const ORPHAN_MS = 120_000
73const PANE_ROWS = 16
74const FIXED_ROWS = 7
75const PR_CHECK_MS = 15_000
76const READY_POLL_MS = 60_000
77const BAND_LOG_ROWS = 3
78const STATS_EVERY_MS = 5_000
79const HOLD_EVERY_MS = 5_000
80const HISTORIAN = 'mefisto-historiador'
81// Tras terminar, el historiador queda en pose de listo este tiempo antes de volver a la espera.
82const HISTORIAN_DONE_MS = 8_000
83const FIELD_NOTES_DIR = 'docs/bitacora/field-notes'
84const CHANGELOG_DIR = 'changelog.d'
85const SCRIPT_TIMEOUT_MS = 180_000
86
87// Recorte comun a todos los cuadros que usa la consola, calculado una vez: sin margen y sin saltos al alternar.
88const MASCOT_POSES = [
89 ['desarrollador', 'trabajando'],
90 ['desarrollador', 'pensando'],
91 ['desarrollador', 'error'],
92 ['revisor', 'trabajando'],
93 ['revisor', 'pensando'],
94 ['revisor', 'corrigiendo'],
95 ['revisor', 'aprobado'],
96 ['historiador', 'escribiendo'],
97 ['historiador', 'listo'],
98 ['release', 'reposo'],
99 ['release', 'despegando'],
100 ['release', 'listo'],
101] as const
102const MASCOT_COLS = usedColumns([
103 ...MASCOT_POSES.flatMap(([role, state]) => [sprite(role, state, 0), sprite(role, state, 1)]),
104 face('normal'),
105])
106const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
107
108/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
109function mascotCells(grid: readonly string[]): string {
110 const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
111 const words: number[] = []
112 for (let y = 0; y < HEIGHT; y += 2) {
113 for (let x = 0; x < MASCOT_WIDTH; x++) {
114 const top = color(grid[y]?.[x])
115 const bottom = color(grid[y + 1]?.[x])
116 if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
117 else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
118 else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
119 }
120 }
121 let bin = ''
122 for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
123 return btoa(bin)
124}
125
126const runAtom = atom({ plugin: 'mefisto-divine-wager', key: 'run' } as const, null)
127const linesAtom = atom({ plugin: 'mefisto-divine-wager', key: 'lines' } as const, [])
128const nowAtom = atom({ plugin: 'mefisto-divine-wager', key: 'now' } as const, 0)
129const executionAtom = atom({ plugin: 'mefisto-divine-wager', key: 'isExecutionSession' } as const, false)
130const readyAtom = atom({ plugin: 'mefisto-divine-wager', key: 'ready' } as const, null)
131const readyPageAtom = atom({ plugin: 'mefisto-divine-wager', key: 'readyPage' } as const, 0)
132const batchAtom = atom({ plugin: 'mefisto-divine-wager', key: 'batch' } as const, null)
133const openPrsAtom = atom({ plugin: 'mefisto-divine-wager', key: 'openPrs' } as const, null)
134const fieldNotesAtom = atom({ plugin: 'mefisto-divine-wager', key: 'fieldNotes' } as const, null)
135const changelogAtom = atom({ plugin: 'mefisto-divine-wager', key: 'changelog' } as const, null)
136const holdAtom = atom({ plugin: 'mefisto-divine-wager', key: 'hold' } as const, null)
137const historianAtom = atom({ plugin: 'mefisto-divine-wager', key: 'historian' } as const, null)
138const releaseAtom = atom({ plugin: 'mefisto-divine-wager', key: 'release' } as const, null)
139
140type Tail = { path: string; stream: AsyncGenerator<unknown, unknown> }
141
142let tail: Tail | null = null
143let watchSinceMs = 0
144let batchSinceMs = 0
145let lastStatsReadMs = 0
146let lastHoldReadMs = 0
147let isPolling = false
148// Una sesion no interactiva (los agentes de los pipelines) no tiene a nadie mirando: el mod no reescribe ni sigue nada.
149let isInteractive = false
150let lastPrCheckMs = 0
151let isLogOpen = false
152let isRefreshingReady = false
153let readyTimer: { cancel: () => void } | null = null
154
155async function stopTail() {
156 const current = tail
157 tail = null
158 if (current) await current.stream.return(undefined).catch(() => undefined)
159}
160
161async function pushLines($: EngineInterface, added: LogLine[]) {
162 if (added.length === 0) return
163 await update($, linesAtom, list => [...(list ?? []), ...added].slice(-MAX_LINES))
164}
165
166async function followTail($: EngineInterface, stream: AsyncGenerator<unknown, unknown>, path: string) {
167 let pending = ''
168 try {
169 for await (const chunk of stream) {
170 if (tail?.stream !== stream) break
171 const { stream: kind, text } = chunk as { stream: string; text: string }
172 if (kind !== 'stdout') continue
173 pending += text
174 const rows = pending.split('\n')
175 pending = rows.pop() ?? ''
176 await pushLines($, rows.map(parseEvent).filter((l): l is LogLine => l !== null))
177 }
178 } catch (err) {
179 $.ui.log(`tail ${path}: ${String(err)}`, { to: 'debug' })
180 }
181}
182
183async function startTail($: EngineInterface, name: string) {
184 await stopTail()
185 const path = `${LOG_DIR}/${name}`
186 const stream = $.process.spawn({ argv: ['tail', '-n', '40', '-F', path] })
187 tail = { path, stream }
188 await pushLines($, [{ ts: '', kind: 'start', text: `── ${agentOfEventsFile(name)} ──` }])
189 void followTail($, stream, path)
190}
191
192// En un batch cada eslabon se sigue igual que un /mefisto-tooling suelto, pero el log acumula todos los issues.
193async function watch($: EngineInterface, issue: string, sinceMs: number, keepLines = false) {
194 await stopTail()
195 watchSinceMs = sinceMs
196 const run: PipelineRun = {
197 issue,
198 title: '',
199 stage: 'setup',
200 state: 'running',
201 startedMs: Date.now(),
202 finishedMs: null,
203 agents: {},
204 pr: null,
205 lastError: null,
206 eventsFile: null,
207 }
208 await update($, runAtom, () => run)
209 if (keepLines) await pushLines($, [{ ts: '', kind: 'start', text: `══ #${issue} ══` }])
210 else await update($, linesAtom, () => [])
211 await poll($)
212}
213
214async function watchBatch($: EngineInterface, issues: string[], sinceMs: number) {
215 await stopTail()
216 batchSinceMs = sinceMs
217 const batch: BatchRun = {
218 issues: issues.map(issue => ({ issue, status: 'pendiente', pr: null })),
219 state: 'running',
220 current: null,
221 stopRequested: false,
222 holdSeconds: 0,
223 startedMs: Date.now(),
224 finishedMs: null,
225 stats: {},
226 }
227 await update($, runAtom, () => null)
228 await update($, linesAtom, () => [])
229 await update($, batchAtom, () => batch)
230}
231
232// Duracion, costo y titulo de cada terminado: se leen del historial cuando falta alguno o su titulo, a lo
233// sumo cada STATS_EVERY_MS (un fallido antes de escribir su historial no los tendra nunca).
234async function withStats($: EngineInterface, batch: BatchRun): Promise<BatchRun> {
235 const stats = batch.stats ?? {}
236 const finished = batch.issues.filter(i => ['done', 'failed'].includes(issueMark(i.status))).map(i => i.issue)
237 const missing = statsPending(finished, stats)
238 if (missing.length === 0 || Date.now() - lastStatsReadMs < STATS_EVERY_MS) return batch
239 lastStatsReadMs = Date.now()
240 const { stdout } = await $.process.run(['tail', '-n', '200', HISTORY]).catch(() => ({ stdout: '' }))
241 return { ...batch, stats: { ...stats, ...issueStatsFromHistory(stdout, missing, batch.startedMs) } }
242}
243
244async function pollBatch($: EngineInterface) {
245 const batch = await read($, batchAtom)
246 if (!batch) return
247 if (batch.state !== 'running') {
248 const filled = await withStats($, batch)
249 if (filled !== batch) await update($, batchAtom, () => filled)
250 return
251 }
252 let next = batch
253 if (await $.fs.exists(BATCH_STATUS)) next = batchFromStatus(await $.fs.read(BATCH_STATUS).catch(() => ''), batch, batchSinceMs)
254 next = { ...next, stopRequested: next.stopRequested || (await $.fs.exists(BATCH_STOP)) }
255 for (const i of newlyMerged(batch.issues, next.issues)) $.ui.toast(`#${i.issue} mergeado${i.pr ? ` · PR #${i.pr}` : ''}`)
256 next = await withStats($, next)
257 const settled = next
258 await update($, batchAtom, () => settled)
259 const run = await read($, runAtom)
260 if (settled.current && settled.current !== run?.issue) await watch($, settled.current, settled.startedMs, true)
261 const c = batchCounts(settled.issues)
262 if (settled.state === 'running') {
263 $.ui.status(`mefisto · sequential ${c.done + c.failed}/${c.total}${settled.current ? ` · #${settled.current}` : ''}`)
264 return
265 }
266 await stopTail()
267 $.ui.toast(`sequential terminado: ${batchSummary(settled)}`)
268 $.ui.status(settled.state === 'failed' ? `mefisto · sequential con fallos` : undefined)
269}
270
271// Parada suave: la misma senal que /mefisto-batch-stop. Termina el eslabon en curso (con su merge) y no sigue.
272async function requestStop($: EngineInterface) {
273 await $.fs.write(BATCH_STOP, '')
274 await update($, batchAtom, b => (b ? { ...b, stopRequested: true } : b))
275 $.ui.toast('parada pedida: termina el issue en curso y no arranca los siguientes')
276}
277
278async function finish($: EngineInterface, run: PipelineRun) {
279 await stopTail()
280 if (run.state === 'completed') {
281 const pr = prNumber(run.pr)
282 $.ui.toast(pr ? `#${run.issue} terminó · PR #${pr} listo` : `#${run.issue} terminó`)
283 $.ui.status(undefined)
284 } else {
285 $.ui.toast(`#${run.issue} falló en ${stageName(run.stage)}`)
286 $.ui.status(`mefisto · #${run.issue} falló`)
287 }
288}
289
290async function fromHistory($: EngineInterface, prev: PipelineRun): Promise<PipelineRun> {
291 const { stdout } = await $.process.run(['tail', '-n', '50', HISTORY]).catch(() => ({ stdout: '' }))
292 const done = finishedFromHistory(stdout, prev.issue, Math.min(prev.startedMs, watchSinceMs))
293 if (!done) return prev
294 const isFailed = done.state === 'failed'
295 return {
296 ...prev,
297 title: done.title ?? prev.title,
298 state: isFailed ? 'failed' : 'completed',
299 stage: isFailed ? (done.stage ?? prev.stage) : 'done',
300 pr: done.pr ?? null,
301 lastError: done.error ?? null,
302 finishedMs: Date.now(),
303 }
304}
305
306// Mientras /mefisto-bitacora corre al historiador, la banda de espera lo muestra escribiendo.
307async function pollHistorian($: EngineInterface) {
308 if (!isInteractive) return
309 const agents = await $.agent.list().catch(() => [])
310 const active = agents.some(a => a.type === HISTORIAN && isAgentActive(a.status))
311 const prev = await read($, historianAtom)
312 const next = historianFrom(prev, active, Date.now())
313 if (next === prev) return
314 await update($, historianAtom, () => next)
315 if (!next?.finishedMs) return
316 $.clock.after(HISTORIAN_DONE_MS, () => void update($, historianAtom, h => (h?.finishedMs ? null : h)))
317 void refreshReady($)
318}
319
320async function poll($: EngineInterface) {
321 if (isPolling) return
322 isPolling = true
323 try {
324 await update($, nowAtom, () => Date.now())
325 await pollBatch($)
326 await pollHistorian($)
327 const inBatch = (await read($, batchAtom)) !== null
328 const prev = await read($, runAtom)
329 if (prev?.state === 'completed' && !inBatch) await closeIfMerged($, prev)
330 if (!prev || prev.state !== 'running') return
331
332 let next: PipelineRun = prev
333 const status = statusPath(prev.issue)
334 if (await $.fs.exists(status)) {
335 const raw = await $.fs.read(status).catch(() => '')
336 const parsed = runFromStatus(raw, prev)
337 if (parsed && !(parsed.state !== 'running' && parsed.startedMs + 1000 < watchSinceMs)) next = parsed
338 } else if (watchSinceMs === 0 || Date.now() - watchSinceMs > STATUS_GRACE_MS || prev.stage !== 'setup') {
339 next = await fromHistory($, prev)
340 if (!inBatch && next === prev && prev.stage === 'setup' && Date.now() - prev.startedMs > ORPHAN_MS) {
341 $.ui.log(`mefisto-divine-wager: #${prev.issue} sin status ni historial tras ${ORPHAN_MS / 60_000} min; se deja de seguir`, { to: 'debug' })
342 await closeRun($)
343 return
344 }
345 }
346
347 if (next.state === 'running' && Date.now() - lastHoldReadMs >= HOLD_EVERY_MS) {
348 lastHoldReadMs = Date.now()
349 const { stdout } = await $.process.run(['tail', '-n', '3', EVENTS_LOG]).catch(() => ({ stdout: '' }))
350 const hold = holdOf(stdout)
351 await update($, holdAtom, () => hold)
352 } else if (next.state !== 'running') {
353 await update($, holdAtom, () => null)
354 }
355
356 if (next.state === 'running') {
357 const entries = await $.fs.list(LOG_DIR).catch(() => [])
358 const name = pickEventsFile(entries, next.issue, Math.min(next.startedMs, watchSinceMs))
359 if (name && name !== next.eventsFile) {
360 next = { ...next, eventsFile: name }
361 await startTail($, name)
362 }
363 if (!inBatch) $.ui.status(`mefisto · #${next.issue} ${stageName(next.stage)} ${elapsed(Date.now() - next.startedMs)}`)
364 }
365
366 const settled = next
367 await update($, runAtom, () => settled)
368 if (settled.state !== 'running') {
369 if (inBatch) await stopTail()
370 else await finish($, settled)
371 }
372 } finally {
373 isPolling = false
374 }
375}
376
377// El PR mergeado da la corrida por terminada: venga del boton, de /mefisto-merge tecleado o de GitHub.
378async function closeIfMerged($: EngineInterface, run: PipelineRun) {
379 const pr = prNumber(run.pr)
380 if (!pr || Date.now() - lastPrCheckMs < PR_CHECK_MS) return
381 lastPrCheckMs = Date.now()
382 const { exitCode, stdout } = await $.process
383 .run(['gh', 'pr', 'view', pr, '--json', 'state', '-q', '.state'])
384 .catch(() => ({ exitCode: 1, stdout: '' }))
385 if (exitCode !== 0 || stdout.trim() !== 'MERGED') return
386 await closeRun($)
387 $.ui.toast(`PR #${pr} mergeado · corrida de #${run.issue} cerrada`)
388}
389
390async function openPane($: EngineInterface, isFocused = false) {
391 isLogOpen = true
392 return $.ui.open({ id: PANE, title: 'mefisto', rows: PANE_ROWS, ...(isFocused ? { focus: true as const } : {}) })
393}
394
395async function closeLog($: EngineInterface) {
396 isLogOpen = false
397 await $.ui.close({ id: PANE })
398}
399
400// La tecla 3 de la banda abre el log o, si ya estaba abierto, lo cierra.
401async function toggleLog($: EngineInterface) {
402 if (isLogOpen) await closeLog($)
403 else await openPane($, true)
404}
405
406async function closeRun($: EngineInterface) {
407 await stopTail()
408 await update($, batchAtom, () => null)
409 await update($, runAtom, () => null)
410 await update($, linesAtom, () => [])
411 $.ui.status(undefined)
412 await $.ui.close({ id: PANE })
413 void refreshReady($)
414}
415
416async function openPr($: EngineInterface) {
417 const run = await read($, runAtom)
418 const batch = await read($, batchAtom)
419 const pr = batch ? batchPr(batch) : prNumber(run?.pr ?? null)
420 if (pr) await $.process.run(['gh', 'pr', 'view', pr, '--web']).catch(() => undefined)
421}
422
423async function merge($: EngineInterface) {
424 const run = await read($, runAtom)
425 const pr = prNumber(run?.pr ?? null)
426 if (!pr) return
427 const answer = await $.ui
428 .ask(`¿Mergear el PR #${pr} con /mefisto-merge?`, ['Mergear', 'Cancelar'])
429 .catch(() => 'Cancelar')
430 if (answer !== 'Mergear') return
431 $.ui.toast(`/mefisto-merge ${pr} en cola: la corrida se cierra cuando el PR quede mergeado`)
432 lastPrCheckMs = 0
433 await $.command.run({ command: 'mefisto-merge', args: pr })
434}
435
436// La sesion de ejecucion es cualquier sesion interactiva que no sea la del planner: el planner no lanza
437// trabajo (su tablero deja los listos en solo lectura). Se lee el --agent de la linea de comando del
438// proceso de Claude Code, padre del `sh` que corre aqui; si no se puede leer, la sesion cuenta como de ejecucion.
439async function detectExecution($: EngineInterface) {
440 const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
441 const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
442 if (agentFlagOf(cmdline) === PLANNER_AGENT) return
443 await update($, executionAtom, () => true)
444 readyTimer?.cancel()
445 readyTimer = $.clock.every(READY_POLL_MS, () => void refreshReady($))
446 void refreshReady($)
447}
448
449async function refreshReady($: EngineInterface) {
450 if (isRefreshingReady || !(await read($, executionAtom))) return
451 isRefreshingReady = true
452 try {
453 const [order, listos, prs] = await Promise.all([
454 $.process
455 .run([NEXT_ORDER, '--json'], { timeoutMs: SCRIPT_TIMEOUT_MS })
456 .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) })),
457 $.process
458 .run(['gh', 'issue', 'list', '--state', 'open', '--label', 'estado:listo', '--limit', '200', '--json', 'number,title'])
459 .catch(() => ({ exitCode: 1, stdout: '' })),
460 $.process
461 .run(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,title,isDraft,headRefName'])
462 .catch(() => ({ exitCode: 1, stdout: '' })),
463 ])
464 const openPrs = prs.exitCode === 0 ? parseOpenPrs(prs.stdout) : null
465 await update($, openPrsAtom, () => openPrs)
466 const notes = await $.fs.list(FIELD_NOTES_DIR).catch(() => null)
467 await update($, fieldNotesAtom, () => (notes ? fieldNotesIn(notes.filter(n => n.kind === 'file').map(n => n.name)) : null))
468 const fragments = await $.fs.list(CHANGELOG_DIR).catch(() => null)
469 await update($, changelogAtom, () => (fragments ? changelogOf(fragments.filter(n => n.kind === 'file').map(n => n.name)) : null))
470 const ready: ReadyList = parseNextOrder(order.exitCode, order.stdout, order.stderr, titlesOf(listos.stdout))
471 await update($, readyAtom, () => ready)
472 } finally {
473 isRefreshingReady = false
474 }
475}
476
477// La banda escribe el comando sin Enter: se lanza a mano, con la opcion de ajustarlo antes.
478async function fill($: EngineInterface, text: string) {
479 await $.prompt.fill({ text, mode: 'replace' })
480}
481
482// Las confirmaciones van en el dialogo nativo, abajo junto al prompt; la banda queda para informar. Esc cancela.
483const WITH_MERGE = 'Con merge (sequential)'
484const ONLY_PR = 'Solo PR (tooling)'
485
486async function choose($: EngineInterface, issue: number) {
487 const answer = await $.ui
488 .ask(`¿Cómo desarrollar #${issue}?`, { header: 'Lanzar', options: [WITH_MERGE, ONLY_PR] })
489 .catch(() => null)
490 if (answer === WITH_MERGE) await fill($, sequentialOf(issue))
491 else if (answer === ONLY_PR) await fill($, toolingOf(issue))
492}
493
494// Mergear desde la espera: todos o los PRs elegidos; en la opcion de texto se escriben otros numeros. Elegir en el
495// dialogo ya es la aprobacion, asi que /mefisto-merge se ejecuta de una vez.
496async function chooseMerge($: EngineInterface) {
497 const prs = ((await read($, openPrsAtom)) ?? []).filter(p => !p.isFieldNote)
498 if (prs.length === 0) return
499 const options = mergeOptions(prs)
500 const answer = await $.ui
501 .ask(`¿Qué mergear? ${prs.length} PRs abiertos${prs.length > 3 ? ' (otros números: escríbelos en la opción de texto)' : ''}`, {
502 header: 'Merge',
503 options,
504 ...(prs.length > 1 ? { multiSelect: true as const } : {}),
505 })
506 .catch(() => null)
507 const args = answer === null ? null : mergeArgsOf(answer, options)
508 if (!args) return
509 $.ui.toast(`/mefisto-merge ${args} en cola`)
510 await $.command.run({ command: 'mefisto-merge', args })
511}
512
513// Integrar la bitacora: las field notes de main y las que esperan en sus PRs. Elegir en el dialogo es la aprobacion.
514async function chooseBitacora($: EngineInterface) {
515 const local = (await read($, fieldNotesAtom)) ?? 0
516 const prs = ((await read($, openPrsAtom)) ?? []).filter(p => p.isFieldNote).map(p => p.number)
517 if (local + prs.length === 0) return
518 const integrate = prs.length > 0 ? `Mergear sus ${prs.length} PRs e integrar` : 'Integrar'
519 const answer = await $.ui
520 .ask(`¿Integrar la bitácora? ${local} field notes en main${prs.length > 0 ? ` y ${prs.length} en PRs sin mergear` : ''}`, {
521 header: 'Bitácora',
522 options: [integrate, 'Cancelar'],
523 })
524 .catch(() => null)
525 if (answer !== integrate) return
526 await $.prompt.submit({ text: bitacoraPrompt(prs), asUser: true })
527}
528
529// Publicar un release con los fragmentos de changelog.d/: SemVer sugiere el bump; tambien se puede solo preparar.
530async function chooseRelease($: EngineInterface) {
531 const summary = await read($, changelogAtom)
532 if (!summary || summary.issues === 0) return
533 const counts = [
534 summary.added > 0 ? `${summary.added} added` : '',
535 summary.changed > 0 ? `${summary.changed} changed` : '',
536 summary.fixed > 0 ? `${summary.fixed} fixed` : '',
537 summary.removed > 0 ? `${summary.removed} removed` : '',
538 ].filter(Boolean).join(', ')
539 const answer = await $.ui
540 .ask(`¿Publicar un release? ${summary.issues} issues con cambios (${counts})`, { header: 'Release', options: releaseOptions(summary) })
541 .catch(() => null)
542 const args = answer === null ? null : releaseArgsOf(answer)
543 if (!args) return
544 $.ui.toast(`/mefisto-release ${args} en cola`)
545 await startRelease($)
546 await $.command.run({ command: 'mefisto-release', args })
547}
548
549async function startRelease($: EngineInterface) {
550 await update($, releaseAtom, r => (r && !r.finishedMs ? r : { phase: 'reposo', startedMs: Date.now(), finishedMs: null }))
551}
552
553async function confirmStop($: EngineInterface) {
554 const current = (await read($, batchAtom))?.current
555 const answer = await $.ui
556 .ask(`¿Detener el sequential tras ${current ? `#${current}` : 'el issue en curso'}?`, {
557 header: 'Detener',
558 options: ['Detener tras el actual', 'Seguir'],
559 })
560 .catch(() => null)
561 if (answer === 'Detener tras el actual') await requestStop($)
562}
563
564async function nextReadyPage($: EngineInterface) {
565 const ready = await read($, readyAtom)
566 const total = ready ? readyRows(ready).length : 0
567 await update($, readyPageAtom, p => pageOf((p ?? 0) + 1, total, READY_ROWS).page)
568}
569
570export const register: Register = on => {
571 on('session.start', async ($, e, next) => {
572 isInteractive = e.isInteractive
573 await $.command.register({
574 name: 'mefisto-divine-wager',
575 description: 'Consola de Mefisto: sin argumentos abre el log; <issue> sigue esa corrida; batch <issues> sigue un sequential; merge | close | refresh',
576 argumentHint: '[issue|batch <issues>|merge|close|refresh]',
577 immediate: true,
578 })
579 // Tras un hot-reload el tail del modulo anterior murio: se fuerza a reabrirlo.
580 await update($, runAtom, run => (run && run.state === 'running' ? { ...run, eventsFile: null } : run))
581 $.clock.every(1000, () => void poll($))
582 if (isInteractive) void detectExecution($)
583 return next(e)
584 })
585
586 // El cohete del release: en reposo desde que se pide /mefisto-release, despegando mientras corre su script y
587 // listo cuando termina el turno que lo ejecuto.
588 on('prompt.submit', async ($, e, next) => {
589 if (isInteractive && isReleasePrompt(e.text)) await startRelease($)
590 return next(e)
591 })
592
593 on('turn.complete', async ($, e, next) => {
594 const release = await read($, releaseAtom)
595 if (release && !release.finishedMs) {
596 await update($, releaseAtom, r => (r ? { ...r, phase: 'reposo', finishedMs: Date.now() } : r))
597 $.clock.after(HISTORIAN_DONE_MS, () => void update($, releaseAtom, r => (r?.finishedMs ? null : r)))
598 void refreshReady($)
599 }
600 return next(e)
601 })
602
603 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
604 if (isInteractive && isReleaseRun(e.command)) {
605 await startRelease($)
606 await update($, releaseAtom, r => (r ? { ...r, phase: 'despegando' } : r))
607 try {
608 return await next(e)
609 } finally {
610 await update($, releaseAtom, r => (r ? { ...r, phase: 'reposo' } : r))
611 }
612 }
613 const batchIssues = isInteractive ? batchIssuesOf(e.command) : null
614 if (batchIssues && batchIssues.length > 0) {
615 const ran = await next({ ...e, command: withModUi(e.command) })
616 if (ran.deny === undefined && ran.isError !== true) await watchBatch($, batchIssues, Date.now() - 5_000)
617 return ran
618 }
619 const issue = isInteractive ? toolingIssueOf(e.command) : null
620 if (!issue) return next(e)
621 const ran = await next({ ...e, command: withModUi(e.command) })
622 if (ran.deny === undefined && ran.isError !== true) {
623 await watch($, issue, Date.now() - 5_000)
624 }
625 return ran
626 })
627
628 on('command.run', { command: 'mefisto-divine-wager' }, async ($, e) => {
629 const arg = e.args.trim()
630 if (arg === 'close') {
631 await closeRun($)
632 return { text: 'Corrida cerrada.' }
633 }
634 if (arg === 'refresh') {
635 await refreshReady($)
636 return { text: 'Listos actualizados.' }
637 }
638 if (arg === 'merge') {
639 await merge($)
640 return { text: 'Merge solicitado.' }
641 }
642 // Reengancha un sequential ya lanzado (tras reiniciar la sesion): `/mefisto-divine-wager batch 12 13`.
643 const batchArgs = /^batch((?:\s+#?\d+)+)$/.exec(arg)
644 if (batchArgs?.[1]) {
645 const issues = batchArgs[1].match(/\d+/g) ?? []
646 await watchBatch($, issues, 0)
647 return { text: `Siguiendo el sequential ${issues.map(n => `#${n}`).join(' ')} en la banda.` }
648 }
649 // La banda sigue la corrida; el pane es solo el log completo y se abre a pedido.
650 if (/^#?\d+$/.test(arg)) {
651 await watch($, arg.replace('#', ''), 0)
652 return { text: `Siguiendo #${arg.replace('#', '')} en la banda.` }
653 }
654 await openPane($, true)
655 return { text: 'Log abierto.' }
656 })
657
658 // Cerrar el log solo cierra el pane: la banda conserva la corrida (y su PR) hasta que se cierre con 4.
659 on('ui.close', { id: PANE }, async ($, e, next) => {
660 isLogOpen = false
661 return next(e)
662 })
663
664 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
665 const run = await read($, runAtom)
666 const batch = await read($, batchAtom)
667 if (e.props.hasSurvey) return next(e)
668 if (!run && !batch && !(await read($, executionAtom))) return next(e)
669 const now = await read($, nowAtom)
670 const { Box, Text, Button } = $.ui.resolve(e)
671 if (batch) {
672 // El sequential: avance y cola arriba, el eslabon en curso abajo con su mascota, pasos y ultimas lineas.
673 const lines = await read($, linesAtom)
674 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
675 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
676 const c = batchCounts(batch.issues)
677 const end = batch.finishedMs ?? (now || Date.now())
678 const isRunning = batch.state === 'running'
679 const pr = batchPr(batch)
680 const pose =
681 isRunning && run ? mascotPose(run, lines[lines.length - 1])
682 : batch.state === 'failed' ? { role: 'desarrollador' as const, state: 'error' }
683 : isRunning ? { role: 'desarrollador' as const, state: 'pensando' }
684 : { role: 'revisor' as const, state: 'aprobado' }
685 const frame = isRunning ? (Math.floor((now || Date.now()) / 1000) % 2 === 0 ? 0 : 1) : 0
686 const grid = cropGrid(sprite(pose.role, pose.state, frame), MASCOT_COLS)
687 const elements = $.ui.resolve(e)
688 const mascot =
689 'Raster' in elements ? (
690 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
691 ) : (
692 <Box width={MASCOT_WIDTH} />
693 )
694 const hold = await read($, holdAtom)
695 const recent = lines.filter(l => l.ts !== '').slice(-3)
696 const markText = (m: ReturnType<typeof issueMark>) =>
697 m === 'done' ? '✓' : m === 'failed' ? '✗' : m === 'deferred' ? '⏸' : m === 'current' ? '●' : '○'
698 const markColor = (m: ReturnType<typeof issueMark>) =>
699 m === 'done' ? ('success' as const) : m === 'failed' ? ('error' as const) : m === 'current' ? ('warning' as const) : undefined
700 const failedIssue = batch.issues.find(i => issueMark(i.status) === 'failed')
701 const doneRows = batch.issues.filter(i => ['done', 'failed'].includes(issueMark(i.status)))
702 const stats = batch.stats ?? {}
703 const titleW = summaryTitleWidth(inner)
704 const titleCol = titleW > 0 ? titleW + 1 : 0
705 const total = statsTotal(doneRows.map(i => stats[i.issue]).filter(st => st !== undefined))
706 return (
707 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
708 <Box justifyContent="space-between">
709 <Text wrap="truncate-end">
710 <Text color="claude">mefisto</Text>
711 <Text> · sequential {c.done + c.failed}/{c.total}</Text>
712 {isRunning && batch.stopRequested && <Text color="warning"> · se detiene tras #{batch.current ?? '…'}</Text>}
713 {batch.state === 'completed' && <Text color="success"> · terminado</Text>}
714 {batch.state === 'stopped' && <Text color="warning"> · detenido</Text>}
715 {batch.state === 'failed' && <Text color="error"> · con fallos</Text>}
716 <Text dimColor> {elapsed(end - batch.startedMs)}</Text>
717 </Text>
718 <Box gap={2}>
719 {isRunning && !batch.stopRequested && (
720 <Button key="batch-stop" hotkey="1" plain label="detener" onPress={() => void confirmStop($)} />
721 )}
722 {pr && <Button key="batch-pr" hotkey="2" plain label={`ver PR #${pr}`} onPress={() => void openPr($)} />}
723 <Button key="batch-log" hotkey="3" plain dimColor={isRunning} label="log" onPress={() => void toggleLog($)} />
724 {!isRunning && <Button key="batch-close" hotkey="4" plain label="cerrar" onPress={() => void closeRun($)} />}
725 </Box>
726 </Box>
727 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
728 {mascot}
729 <Box flexDirection="column" width={body} marginTop={1}>
730 <Text wrap="truncate-end">
731 {batch.issues.map((i, k) => {
732 const m = issueMark(i.status)
733 return (
734 <Text color={markColor(m)} dimColor={m === 'pending' || m === 'deferred'}>
735 {k > 0 ? ' ─ ' : ''}
736 {markText(m)} #{i.issue}
737 {m === 'current' && run && run.issue === i.issue ? ` ${stageName(run.stage)}` : ''}
738 </Text>
739 )
740 })}
741 </Text>
742 {isRunning && run && run.title !== '' && <Text dimColor wrap="truncate-end">#{run.issue} {run.title}</Text>}
743 {isRunning && hold && <Text color="warning" wrap="truncate-end">{holdText(hold)}</Text>}
744 {isRunning && run && !hold && <Text> </Text>}
745 {isRunning && !run && <Text dimColor>preparando el primer issue…</Text>}
746 {isRunning &&
747 recent.map(line => (
748 <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
749 {`${line.ts} ${line.kind === 'text' ? '» ' : ''}${line.text}`}
750 </Text>
751 ))}
752 {!isRunning && <Text wrap="truncate-end">{batchSummary(batch)}</Text>}
753 {!isRunning && failedIssue && (
754 <Text color="error" wrap="truncate-end">#{failedIssue.issue} {failedIssue.status.replace(/^ERROR:\s*/, '')}</Text>
755 )}
756 </Box>
757 </Box>
758 {doneRows.length > 0 && (
759 <Box flexDirection="column">
760 {doneRows.map(i => {
761 const st = stats[i.issue]
762 const m = issueMark(i.status)
763 return (
764 <Text wrap="truncate-end" color={markColor(m)}>
765 {`${markText(m)} ${padEnd(`#${i.issue}`, 7)} ${titleW > 0 ? `${fitTitle(st?.title, titleW)} ` : ''}${padEnd(i.pr ? `PR #${i.pr}` : 'sin PR', 10)} `}
766 <Text dimColor>{st ? `${padEnd(elapsed(st.durationMs), 8)} ${fmtCost(st.costUsd)}` : '…'}</Text>
767 </Text>
768 )
769 })}
770 {doneRows.length > 1 && (
771 <Text dimColor wrap="truncate-end">
772 {` total ${padEnd(String(doneRows.length), 12 + titleCol)} ${padEnd(elapsed(total.durationMs), 8)} ${fmtCost(total.costUsd)}`}
773 </Text>
774 )}
775 </Box>
776 )}
777 </Box>
778 )
779 }
780 if (!run) {
781 const ready = await read($, readyAtom)
782 const openPrs = (await read($, openPrsAtom)) ?? []
783 const workPrs = openPrs.filter(p => !p.isFieldNote).length
784 const pendingNotes = ((await read($, fieldNotesAtom)) ?? 0) + openPrs.filter(p => p.isFieldNote).length
785 const changelog = await read($, changelogAtom)
786 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
787 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
788 const { page, pages } = pageOf(await read($, readyPageAtom), ready ? readyRows(ready).length : 0, READY_ROWS)
789 const rows = ready ? readyRows(ready) : []
790 const visible = rows.slice(page * READY_ROWS, (page + 1) * READY_ROWS)
791 const numWidth = Math.max(4, ...visible.map(i => String(i.number).length + 1))
792 const afterWidth = Math.min(24, Math.max(0, ...visible.map(i => i.reason.length)))
793 // El prefijo '5: ' de la tecla, el numero, un espacio y, si alguno depende de otro, la columna 'tras #N'.
794 const titleWidth = Math.max(12, body - 3 - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
795 const more = ready
796 ? [
797 ready.cycleCount > 0 ? `${ready.cycleCount} en ciclo` : '',
798 ].filter(Boolean).join(' · ')
799 : ''
800 // En espera de que se lance algo; mientras Claude trabaja en la sesion, mira de lado a lado y corre la arena.
801 // Si el historiador escribe la bitacora, es el quien aparece, con la pluma en movimiento.
802 const historian = await read($, historianAtom)
803 const tick = e.props.isWorking ? Math.floor((now || Date.now()) / 1000) : null
804 const release = await read($, releaseAtom)
805 const frame = (tick ?? 0) % 2 === 0 ? 0 : 1
806 const grid = release
807 ? cropGrid(sprite('release', release.finishedMs ? 'listo' : release.phase, frame), MASCOT_COLS)
808 : historian
809 ? cropGrid(sprite('historiador', historian.finishedMs ? 'listo' : 'escribiendo', frame), MASCOT_COLS)
810 : cropGrid(waitingFace(face('normal'), tick), MASCOT_COLS)
811 const elements = $.ui.resolve(e)
812 const mascot =
813 'Raster' in elements ? (
814 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
815 ) : (
816 <Box width={MASCOT_WIDTH} />
817 )
818 return (
819 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
820 <Box justifyContent="space-between">
821 <Text>
822 <Text color="claude">mefisto</Text>
823 <Text dimColor> · listos {ready ? readyRows(ready).length : '…'}</Text>
824 {historian && !historian.finishedMs && (
825 <Text color="warning"> · historiador escribiendo la bitácora {elapsed((now || Date.now()) - historian.startedMs)}</Text>
826 )}
827 {historian?.finishedMs && <Text color="success"> · bitácora escrita</Text>}
828 {release && !release.finishedMs && (
829 <Text color="warning">
830 {release.phase === 'despegando' ? ' · release despegando' : ' · release en preparación'} {elapsed((now || Date.now()) - release.startedMs)}
831 </Text>
832 )}
833 {release?.finishedMs && <Text color="success"> · release terminado</Text>}
834 </Text>
835 <Box gap={2}>
836 {workPrs > 0 && (
837 <Button key="merge-prs" hotkey="2" plain label={`PRs ${workPrs}`} onPress={() => void chooseMerge($)} />
838 )}
839 {pendingNotes > 0 && (
840 <Button key="bitacora" hotkey="3" plain label={`bitácora ${pendingNotes}`} onPress={() => void chooseBitacora($)} />
841 )}
842 {changelog && changelog.issues > 0 && (
843 <Button key="release" hotkey="4" plain label={`release ${changelog.issues}`} onPress={() => void chooseRelease($)} />
844 )}
845 </Box>
846 </Box>
847 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
848 {mascot}
849 <Box flexDirection="column" width={body} marginTop={1}>
850 {!ready && <Text dimColor>cargando…</Text>}
851 {ready?.error && <Text color="error">next-order falló: {clip(ready.error, body - 20)}</Text>}
852 {ready && !ready.error && visible.length === 0 && <Text dimColor>Sin issues en estado:listo.</Text>}
853 {visible.map((item, i) => {
854 const label = `${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`
855 return (
856 <Box>
857 {item.isLaunchable ? (
858 <Button
859 key={`ready-${item.number}`}
860 hotkey={String(i + 5)}
861 plain
862 dimColor={page > 0 || i > 0}
863 label={label}
864 onPress={() => void choose($, item.number)}
865 />
866 ) : (
867 <Text dimColor>{` ${label}`}</Text>
868 )}
869 <Text dimColor>{item.reason ? ` ${clip(item.reason, afterWidth)}` : ''}</Text>
870 </Box>
871 )
872 })}
873 {Array.from({ length: Math.max(0, READY_ROWS - Math.max(visible.length, 1)) }, (_, i) => (
874 <Text key={`ready-blank-${i}`}> </Text>
875 ))}
876 <Box justifyContent="space-between">
877 <Text> </Text>
878 <Box gap={2}>
879 {more !== '' && <Text dimColor>{more}</Text>}
880 {pages > 1 && (
881 <Button key="ready-page" hotkey="0" plain dimColor label={`página ${page + 1}/${pages} ▸`}
882 onPress={() => void nextReadyPage($)} />
883 )}
884 </Box>
885 </Box>
886 </Box>
887 </Box>
888 {/* La linea de todos los listos crece con la cola: va abajo, a lo ancho, y no empuja los menus fijos. */}
889 {ready?.launch && (
890 <Button key="ready-all" hotkey="1" plain label={clip(ready.launch.replace(/^\/mefisto-/, ''), inner - 3)}
891 onPress={() => void fill($, ready.launch ?? '')} />
892 )}
893 </Box>
894 )
895 }
896 // La corrida ocupa la misma banda que la espera: la mascota del agente activo, los pasos y lo ultimo que hizo.
897 const lines = await read($, linesAtom)
898 const hold = await read($, holdAtom)
899 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
900 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
901 const end = run.finishedMs ?? (now || Date.now())
902 const pr = prNumber(run.pr)
903 const pose = mascotPose(run, lines[lines.length - 1])
904 const frame = run.state === 'running' ? (Math.floor((now || Date.now()) / 1000) % 2 === 0 ? 0 : 1) : 0
905 const grid = cropGrid(sprite(pose.role, pose.state, frame), MASCOT_COLS)
906 const elements = $.ui.resolve(e)
907 const mascot =
908 'Raster' in elements ? (
909 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
910 ) : (
911 <Box width={MASCOT_WIDTH} />
912 )
913 const recent = lines.filter(l => l.ts !== '').slice(-BAND_LOG_ROWS)
914 return (
915 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
916 <Box justifyContent="space-between">
917 <Text wrap="truncate-end">
918 <Text color="claude">mefisto</Text>
919 {run.state === 'running' && <Text> · #{run.issue} {stageName(run.stage)}</Text>}
920 {run.state === 'completed' && <Text color="success"> · #{run.issue} ✓ {pr ? `PR #${pr}` : 'terminado'}</Text>}
921 {run.state === 'failed' && <Text color="error"> · #{run.issue} ✗ {stageName(run.stage)}</Text>}
922 <Text dimColor> {elapsed(end - run.startedMs)}</Text>
923 </Text>
924 <Box gap={2}>
925 {run.state === 'completed' && pr && (
926 <Button key="band-merge" hotkey="1" plain variant="primary" label="mergear" onPress={() => void merge($)} />
927 )}
928 {run.state === 'completed' && pr && <Button key="band-web" hotkey="2" plain label="ver PR" onPress={() => void openPr($)} />}
929 <Button key="band-open" hotkey="3" plain dimColor={run.state === 'running'} label="log" onPress={() => void toggleLog($)} />
930 {run.state !== 'running' && <Button key="band-close" hotkey="4" plain label="cerrar" onPress={() => void closeRun($)} />}
931 </Box>
932 </Box>
933 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
934 {mascot}
935 <Box flexDirection="column" width={body} marginTop={1}>
936 <Text wrap="truncate-end">
937 {steps(run).map((st, i) => (
938 <Text
939 color={st.mark === 'done' ? 'success' : st.mark === 'failed' ? 'error' : st.mark === 'current' ? 'warning' : undefined}
940 dimColor={st.mark === 'pending'}
941 >
942 {i > 0 ? ' ─ ' : ''}
943 {st.mark === 'done' ? '✓' : st.mark === 'failed' ? '✗' : st.mark === 'current' ? '●' : '○'} {st.name}
944 </Text>
945 ))}
946 </Text>
947 {run.title !== '' && <Text dimColor wrap="truncate-end">{run.title}</Text>}
948 {run.state === 'running' && hold && <Text color="warning" wrap="truncate-end">{holdText(hold)}</Text>}
949 {run.state === 'failed' && run.lastError && <Text color="error" wrap="truncate-end">{run.lastError}</Text>}
950 {run.state === 'running' && recent.length === 0 && <Text dimColor>esperando eventos del agente…</Text>}
951 {run.state === 'running' &&
952 recent.map(line => (
953 <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
954 {`${line.ts} ${line.kind === 'text' ? '» ' : ''}${line.text}`}
955 </Text>
956 ))}
957 </Box>
958 </Box>
959 </Box>
960 )
961 })
962
963 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
964 const { Box, Text, Button } = $.ui.resolve(e)
965 const run = await read($, runAtom)
966 if (!run) {
967 return (
968 <Box flexDirection="column">
969 <Text dimColor>Sin corrida. Lanza /mefisto-tooling <issue> o /mefisto-divine-wager <issue>.</Text>
970 </Box>
971 )
972 }
973 const lines = await read($, linesAtom)
974 const now = await read($, nowAtom)
975 const width = Math.max(20, e.props.bodyColumns ?? 60)
976 const rows = Math.max(3, (e.props.scroll?.bodyRows ?? PANE_ROWS) - FIXED_ROWS)
977 const end = run.finishedMs ?? (now || Date.now())
978 const pr = prNumber(run.pr)
979
980 return (
981 <Box flexDirection="column" width={width}>
982 <Box justifyContent="space-between">
983 <Text bold color="claude">mefisto-tooling #{run.issue}</Text>
984 <Text dimColor>{elapsed(end - run.startedMs)}</Text>
985 </Box>
986 {run.title !== '' && <Text wrap="truncate-end">{run.title}</Text>}
987 <Box>
988 {steps(run).map((s, i) => (
989 <Text
990 color={s.mark === 'done' ? 'success' : s.mark === 'failed' ? 'error' : s.mark === 'current' ? 'warning' : undefined}
991 dimColor={s.mark === 'pending'}
992 >
993 {i > 0 ? ' ─ ' : ''}
994 {s.mark === 'done' ? '✓' : s.mark === 'failed' ? '✗' : s.mark === 'current' ? '●' : '○'} {s.name}
995 {agentDuration(run, s.name)}
996 </Text>
997 ))}
998 </Box>
999 {run.state === 'completed' && (
1000 <Box flexDirection="column">
1001 <Box gap={3}>
1002 <Text color="success" bold>✓ {pr ? `PR #${pr} creado` : 'terminado'}</Text>
1003 {pr && <Button key="merge" hotkey="m" plain variant="primary" label={`Mergear #${pr}`} onPress={() => void merge($)} />}
1004 {pr && <Button key="web" hotkey="v" plain label="Ver en GitHub" onPress={() => void openPr($)} />}
1005 <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1006 </Box>
1007 <Text dimColor wrap="truncate-end">
1008 {pr ? `m: /mefisto-merge ${pr} (squash + borra rama) · ` : ''}c: cierra el log, la banda sigue · ctrl+x tab enfoca · esc vuelve al prompt
1009 </Text>
1010 </Box>
1011 )}
1012 {run.state === 'failed' && (
1013 <Box flexDirection="column">
1014 <Text color="error" wrap="wrap">✗ falló en {stageName(run.stage)}{run.lastError ? `: ${run.lastError}` : ''}</Text>
1015 <Box gap={2}>
1016 <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1017 </Box>
1018 </Box>
1019 )}
1020 {run.state === 'running' && (
1021 <Box gap={2}>
1022 <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1023 <Text dimColor>3 en la banda también lo cierra · ctrl+x tab enfoca · esc vuelve al prompt</Text>
1024 </Box>
1025 )}
1026 <Text dimColor>{'─'.repeat(Math.min(width, 80))}</Text>
1027 <Box flexDirection="column">
1028 {lines.length === 0 && <Text dimColor>esperando eventos del agente…</Text>}
1029 {lines.slice(-rows).map(line => (
1030 <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
1031 {line.ts !== '' ? `${line.ts} ` : ''}
1032 {line.kind === 'text' ? '» ' : ''}
1033 {line.text}
1034 </Text>
1035 ))}
1036 </Box>
1037 </Box>
1038 )
1039 })
1040}
1041
1042function stageName(stage: string): string {
1043 return stage.replace(/^\d+-/, '')
1044}
1045
1046function agentDuration(run: PipelineRun, step: string): string {
1047 const d = run.agents[step]?.duration
1048 return typeof d === 'number' && d > 0 ? ` ${Math.round(d / 60)}m` : ''
1049}
1050
1051function lineColor(line: LogLine) {
1052 if (line.kind === 'fail') return 'error' as const
1053 if (line.kind === 'done') return 'success' as const
1054 if (line.kind === 'start') return 'claude' as const
1055 return undefined
1056}
1057hooks/logic.ts 646 lines1import type { BatchIssue, BatchRun, ChangelogSummary, Historian, Hold, IssueStats, OpenPr, BlockedItem, LogLine, RunAgent, PipelineRun, 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, RunAgent>
73 pr?: string | null
74 last_error?: string | null
75}
76
77export function runFromStatus(raw: string, prev: PipelineRun | null): PipelineRun | 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: PipelineRun): 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: PipelineRun, 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/** Titulo recortado con `…` a `width` columnas y rellenado a ese ancho (vacio si no hay titulo o no hay espacio). */
348export function fitTitle(title: string | null | undefined, width: number): string {
349 if (width <= 0) return ''
350 const t = (title ?? '').trim()
351 return padEnd(t.length > width ? `${t.slice(0, width - 1)}…` : t, width)
352}
353
354/** Ancho de la columna de titulo del resumen: lo que sobra de la banda tras marca, issue, PR, tiempo y costo; 0 si no cabe un titulo legible. */
355export function summaryTitleWidth(inner: number): number {
356 const w = Math.min(60, inner - 38)
357 return w < 8 ? 0 : w
358}
359
360/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
361export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
362 const pages = Math.max(1, Math.ceil(total / size))
363 return { page: page >= 0 && page < pages ? page : 0, pages }
364}
365
366/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
367export function agentFlagOf(cmdline: string): string | null {
368 const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
369 return m ? (m[1] ?? null) : null
370}
371
372/**
373 * Mefisto en espera, sobre la cara 'normal': boca plana corrida a un lado y un reloj de arena a la izquierda.
374 * Mientras Claude trabaja en la sesion, en cada tick los ojos miran de un lado al otro y la arena cambia de
375 * mitad; en reposo mira al frente con la arena arriba.
376 */
377const HOURGLASS = [
378 ['YYYY', 'gyyg', '.yg.', 'g..g', 'g..g', 'YYYY'],
379 ['YYYY', 'g..g', '.gg.', 'g..g', 'gyyg', 'YYYY'],
380] as const
381
382export function waitingFace(base: Grid, tick: number | null): Grid {
383 const eyes =
384 tick === null ? ['.....rrEErrEErr.tt', '.....rrEErrEErr..t']
385 : tick % 2 === 0 ? ['.....rEErrEErrr.tt', '.....rEErrEErrr..t']
386 : ['.....rrrEErrEEr.tt', '.....rrrEErrEEr..t']
387 const glass = HOURGLASS[tick !== null && tick % 2 === 1 ? 1 : 0]
388 return base.map((row, y) => {
389 let out = row
390 if (y === 6 || y === 7) out = eyes[y - 6] ?? out
391 if (y === 9) out = '......rrrrMMMr..t.'
392 const g = y >= 6 ? glass[y - 6] : undefined
393 if (g) out = [...out].map((c, x) => (x < g.length && g[x] !== '.' ? g[x] : c)).join('')
394 return out
395 })
396}
397
398type BatchStatusFile = {
399 started?: string
400 state?: string
401 current?: string | null
402 stop_requested?: boolean
403 hold_seconds?: number
404 issues?: { issue: string; status: string; pr: string | null }[]
405}
406
407/** El estado publicado del batch; un archivo de un batch anterior (arrancado antes de `sinceMs`) se ignora. */
408export function batchFromStatus(raw: string, prev: BatchRun, sinceMs: number): BatchRun {
409 let s: BatchStatusFile
410 try {
411 s = JSON.parse(raw)
412 } catch {
413 return prev
414 }
415 const startedMs = s.started ? stampToMs(s.started) : 0
416 if (startedMs + 1000 < sinceMs) return prev
417 const state = s.state === 'completed' || s.state === 'failed' || s.state === 'stopped' ? s.state : 'running'
418 return {
419 issues: (s.issues ?? []).map(i => ({ issue: String(i.issue), status: i.status ?? '', pr: i.pr ?? null })),
420 state,
421 current: s.current ? String(s.current) : null,
422 stopRequested: s.stop_requested === true || prev.stopRequested,
423 holdSeconds: s.hold_seconds ?? 0,
424 startedMs: startedMs || prev.startedMs,
425 finishedMs: state === 'running' ? null : (prev.finishedMs ?? Date.now()),
426 stats: prev.stats ?? {},
427 }
428}
429
430export type IssueMark = 'done' | 'current' | 'failed' | 'deferred' | 'pending'
431
432export function issueMark(status: string): IssueMark {
433 if (status.startsWith('completado')) return 'done'
434 if (status.startsWith('ERROR')) return 'failed'
435 if (status.startsWith('aplazado')) return 'deferred'
436 if (status.startsWith('en curso')) return 'current'
437 return 'pending'
438}
439
440export function batchCounts(issues: BatchIssue[]) {
441 const marks = issues.map(i => issueMark(i.status))
442 return {
443 total: issues.length,
444 done: marks.filter(m => m === 'done').length,
445 failed: marks.filter(m => m === 'failed').length,
446 deferred: marks.filter(m => m === 'deferred').length,
447 }
448}
449
450/** Los issues que pasaron a mergeados entre dos lecturas: un toast por cada uno. */
451export function newlyMerged(prev: BatchIssue[], next: BatchIssue[]): BatchIssue[] {
452 return next.filter(i => issueMark(i.status) === 'done' && issueMark(prev.find(p => p.issue === i.issue)?.status ?? '') !== 'done')
453}
454
455/** El PR para abrir: el del issue en curso si ya lo tiene, o el del ultimo mergeado. */
456export function batchPr(batch: BatchRun): string | null {
457 const current = batch.issues.find(i => i.issue === batch.current)
458 if (current?.pr) return current.pr
459 return [...batch.issues].reverse().find(i => i.pr)?.pr ?? null
460}
461
462/** Resumen del cierre: mergeados, fallidos, aplazados y la espera por rate limit si la hubo. */
463export function batchSummary(batch: BatchRun): string {
464 const c = batchCounts(batch.issues)
465 const hold = batch.holdSeconds > 0 ? `espera ${Math.round(batch.holdSeconds / 60)}m` : ''
466 return [
467 `✓ ${c.done} mergeados`,
468 c.failed > 0 ? `✗ ${c.failed} fallidos` : '',
469 c.deferred > 0 ? `⏸ ${c.deferred} aplazados` : '',
470 hold,
471 ].filter(Boolean).join(' · ')
472}
473
474type StatsEntry = {
475 issue?: string
476 pipeline?: string
477 started?: string
478 finished?: string
479 title?: string
480 agents?: Record<string, { metrics?: { estimated_cost_usd?: number | null } }>
481}
482
483/**
484 * Duracion y costo de las corridas de tooling de estos issues, desde el final del historial: la ultima de cada
485 * issue arrancada en o despues de `sinceMs`. El costo suma el estimado de cada agente (null si ninguno lo da).
486 */
487export function issueStatsFromHistory(tail: string, issues: string[], sinceMs: number): Record<string, IssueStats> {
488 const out: Record<string, IssueStats> = {}
489 for (const line of tail.split('\n')) {
490 let h: StatsEntry
491 try {
492 h = JSON.parse(line)
493 } catch {
494 continue
495 }
496 const issue = String(h.issue ?? '')
497 if (h.pipeline !== 'mefisto-tooling' || !issues.includes(issue) || !h.started || !h.finished) continue
498 const startedMs = stampToMs(h.started)
499 const finishedMs = new Date(h.finished).getTime()
500 if (startedMs + 1000 < sinceMs || Number.isNaN(finishedMs)) continue
501 const costs = Object.values(h.agents ?? {})
502 .map(a => a.metrics?.estimated_cost_usd)
503 .filter((c): c is number => typeof c === 'number')
504 out[issue] = {
505 durationMs: Math.max(0, finishedMs - startedMs),
506 costUsd: costs.length > 0 ? costs.reduce((a, b) => a + b, 0) : null,
507 title: typeof h.title === 'string' && h.title !== '' ? h.title : null,
508 }
509 }
510 return out
511}
512
513/** Issues sin estadisticas o con estadisticas sin titulo: un titulo vacio en cache no se corrige solo. */
514export function statsPending(issues: readonly string[], stats: Record<string, IssueStats>): string[] {
515 return issues.filter(issue => !stats[issue] || stats[issue].title === null)
516}
517
518export const fmtCost = (usd: number | null) => (usd === null ? '$?' : `$${usd.toFixed(2)}`)
519
520/** Total de lo terminado: duracion sumada y costo sumado (sin contar los que no lo informan). */
521export function statsTotal(stats: IssueStats[]): IssueStats {
522 const costs = stats.map(s => s.costUsd).filter((c): c is number => c !== null)
523 return {
524 durationMs: stats.reduce((a, s) => a + s.durationMs, 0),
525 costUsd: costs.length > 0 ? costs.reduce((a, b) => a + b, 0) : null,
526 title: null,
527 }
528}
529
530/**
531 * PRs abiertos (`gh pr list --json number,title,isDraft,headRefName`) sin los borradores, el mas reciente primero.
532 * Los de field notes (rama `docs/<agente>-field-note-<sesion>` de mefisto-field-note.sh) se marcan aparte.
533 */
534export function parseOpenPrs(stdout: string): OpenPr[] | null {
535 try {
536 const rows = JSON.parse(stdout) as { number: number; title: string; isDraft?: boolean; headRefName?: string }[]
537 return rows
538 .filter(r => !r.isDraft)
539 .map(r => ({ number: r.number, title: r.title, isFieldNote: /-field-note-/.test(r.headRefName ?? '') }))
540 .sort((a, b) => b.number - a.number)
541 } catch {
542 return null
543 }
544}
545
546export const MERGE_ALL = 'Todos (--all)'
547
548/** Opciones del dialogo de merge (2-4): todos y los PRs mas recientes; con uno solo, ese y cancelar. */
549export function mergeOptions(prs: OpenPr[]): string[] {
550 const label = (p: OpenPr) => clip(`#${p.number} ${p.title}`, 60)
551 if (prs.length === 1 && prs[0]) return [label(prs[0]), 'Cancelar']
552 return [MERGE_ALL, ...prs.slice(0, 3).map(label)]
553}
554
555/**
556 * Los argumentos de /mefisto-merge que salen de la respuesta (las opciones marcadas, unidas por ", "): --all si
557 * se eligio todos; de cada opcion solo su #N (el titulo puede traer fechas); del texto libre, sus numeros.
558 * Null si no queda ningun PR.
559 */
560export function mergeArgsOf(answer: string, options: string[]): string | null {
561 if (answer.includes(MERGE_ALL)) return '--all'
562 let rest = answer
563 const numbers: string[] = []
564 for (const option of options) {
565 if (!rest.includes(option)) continue
566 rest = rest.replace(option, '')
567 const n = /^#(\d+)/.exec(option)?.[1]
568 if (n) numbers.push(n)
569 }
570 numbers.push(...(rest.match(/\d+/g) ?? []))
571 const unique = [...new Set(numbers)]
572 return unique.length > 0 ? unique.join(' ') : null
573}
574
575/** Field notes en `docs/bitacora/field-notes/` sin procesar (las de `procesadas/` quedan fuera del listado). */
576export function fieldNotesIn(names: string[]): number {
577 return names.filter(n => n.endsWith('.md')).length
578}
579
580/** Resumen de `changelog.d/`: issues con fragmentos (sin README) y entradas por categoria de Keep a Changelog. */
581export function changelogOf(names: string[]): ChangelogSummary {
582 const fragments = names.filter(n => n !== 'README.md' && n.endsWith('.md'))
583 const by = (cat: string) => fragments.filter(n => n.endsWith(`.${cat}.md`)).length
584 return {
585 issues: new Set(fragments.map(n => n.split('.')[0])).size,
586 added: by('added'),
587 changed: by('changed'),
588 fixed: by('fixed'),
589 removed: by('removed'),
590 }
591}
592
593/** SemVer: algo agregado pide minor; solo cambios, arreglos o retiros, patch. El recomendado va primero. */
594export function releaseOptions(summary: ChangelogSummary): string[] {
595 const bump = summary.added > 0 ? 'minor' : 'patch'
596 const other = bump === 'minor' ? 'patch' : 'minor'
597 return [`${bump} (recomendado)`, other, `Solo preparar el PR (${bump} --prepare-only)`]
598}
599
600/** Los argumentos de /mefisto-release de la opcion elegida, o null. */
601export function releaseArgsOf(answer: string): string | null {
602 const bump = /\b(major|minor|patch)\b/.exec(answer)?.[1]
603 if (!bump) return null
604 return answer.includes('--prepare-only') ? `${bump} --prepare-only` : bump
605}
606
607/**
608 * La instruccion para integrar la bitacora. El historiador lee las field notes del checkout local, asi que antes
609 * hay que mergear los PRs de field notes que esperan y traer main al dia (las de PRs ya mergeados viven en
610 * origin/main hasta el pull).
611 */
612export function bitacoraPrompt(fieldNotePrs: number[]): string {
613 const merge = fieldNotePrs.length > 0
614 ? `mergea los PRs de field notes ${fieldNotePrs.map(n => `#${n}`).join(' ')} con /mefisto-merge, `
615 : ''
616 return `Integra la bitacora: ${merge}pon el checkout en main al dia (git switch main && git pull --ff-only) y luego corre /mefisto-bitacora.`
617}
618
619const HOLD = /^\[\d{2}:\d{2}:\d{2}\]\[hold\] (\S+): esperando, proxima sonda (\d{2}:\d{2})(?::\d{2})? \(techo (\d{2}:\d{2})\)/
620
621/**
622 * La espera en curso: la ultima linea de events.log es un `[hold]` (mefisto-tooling-pipeline.sh la escribe en
623 * cada sonda; cualquier actividad posterior, incluido `[hold][resume]`, la da por terminada).
624 */
625export function holdOf(tail: string): Hold | null {
626 const last = tail.split('\n').filter(l => l.trim() !== '').pop() ?? ''
627 const m = HOLD.exec(last)
628 return m ? { family: m[1] ?? '', nextProbe: m[2] ?? '', deadline: m[3] ?? '' } : null
629}
630
631export const holdText = (hold: Hold) => `en espera por ${hold.family} · próxima sonda ${hold.nextProbe} · techo ${hold.deadline}`
632
633const AGENT_ACTIVE = new Set(['pending', 'running', 'waiting'])
634
635/** Un agente de `$.agent.list()` que aun no termina (AgentStatus). */
636export const isAgentActive = (status: string) => AGENT_ACTIVE.has(status)
637
638/**
639 * El historiador corre en primer o en segundo plano: se sigue su estado en `$.agent.list()`, no la llamada Agent,
640 * que en segundo plano vuelve en cuanto arranca. Devuelve `prev` tal cual si nada cambio.
641 */
642export function historianFrom(prev: Historian | null, active: boolean, now: number): Historian | null {
643 if (active) return prev && !prev.finishedMs ? prev : { startedMs: now, finishedMs: null }
644 return prev && !prev.finishedMs ? { ...prev, finishedMs: now } : prev
645}
646hooks/sprites.ts 831 lines1/**
2 * mefisto-sprites.ts
3 * Mascota Mefisto para mods de Claude Code.
4 *
5 * Cada sprite es una cuadrícula de 18×12 píxeles escrita como texto:
6 * cada carácter es un píxel y apunta a un color de PALETTE.
7 * '.' píxel transparente (en EMOTIONS)
8 * ' ' "no tocar" (en las capas de ROLES: deja ver lo de abajo)
9 *
10 * Un sprite se arma así: emoción (cara) + capa del rol (objeto y detalles)
11 * Cada estado tiene dos cuadros, A (0) y B (1), que se alternan con cada instrucción.
12 *
13 * Salidas:
14 * toRasterCells(grid) -> celdas empacadas para el elemento Raster (terminal), 18 columnas × 6 filas
15 * toSvg(grid) -> documento SVG para el elemento Svg (app de escritorio)
16 * ONE_LINERS -> versión de una línea para la status line (elemento Text)
17 */
18
19export type Grid = readonly string[]
20export type Frame = 0 | 1
21
22export const WIDTH = 18
23export const HEIGHT = 12
24/** Filas que ocupa el sprite en la terminal (dos píxeles por fila con ▀ / ▄). */
25export const RASTER_ROWS = HEIGHT / 2
26
27/** Color por defecto de la terminal (transparente), según la documentación de Raster. */
28export const DEFAULT_COLOR = 0x01000000
29
30/**
31 * Paleta: letra -> color 0xRRGGBB. Los mods internos (este repo) usan un Mefisto blanco perla con cuernos dorados para no confundirse
32 * con los del consumidor, que conservan el rojo de mefisto-sprites.ts (cuernos/cola 0xA32D2D, cuerpo 0xE24B4A,
33 * boca 0x501313).
34 */
35export const PALETTE: Record<string, number> = {
36 "h": 0xD9A441, // cuernos (dorados)
37 "r": 0xEEEDF5, // cuerpo
38 "t": 0xD9A441, // cola (dorada)
39 "E": 0x2C2C2A, // ojos / pupilas
40 "M": 0x4A4A5E, // boca y cejas
41 "W": 0xF1EFE8, // colmillos
42 "p": 0xD4537E, // corazones y lengua
43 "s": 0xB4B2A9, // humo (enojado)
44 "B": 0x85B7EB, // Z y gota de sudor
45 "Y": 0xEF9F27, // bombillo, casco, flecha
46 "y": 0xFAC775, // destellos
47 "w": 0xFFF6D9, // brillo del bombillo
48 "G": 0x888780, // casquillo del bombillo
49 "o": 0xB4B2A9, // bombillo apagado
50 "D": 0x5F5E5A, // marco del portátil
51 "U": 0x378ADD, // pantalla
52 "u": 0xB5D4F4, // líneas de código
53 "q": 0xB4B2A9, // teclado / renglones
54 "Q": 0xFFFFFF, // blanco (pantalla, hoja)
55 "R": 0xE24B4A, // error / marcas rojas
56 "V": 0x639922, // verde de éxito
57 "g": 0x888780, // vidrio del matraz
58 "L": 0x97C459, // líquido del matraz
59 "l": 0xEAF3DE, // reflejo del líquido
60 "b": 0xC0DD97, // burbujas
61 "P": 0x7F77DD, // marco de la lupa
62 "e": 0xEEEDFE, // brillo de la lupa
63 "n": 0x854F0B, // mango de la lupa
64 "m": 0xE8C98E, // mapa claro
65 "j": 0xC9A866, // mapa oscuro
66 "Z": 0x444441, // ruta del mapa
67 "O": 0xD85A30, // destino del mapa
68 "c": 0xB5D4F4, // nube
69 "C": 0x888780, // nube caída
70}
71
72export type Emotion =
73 | 'normal'
74 | 'feliz'
75 | 'sonriente'
76 | 'enojado'
77 | 'dormido'
78 | 'asustado'
79 | 'pensando'
80 | 'preocupado'
81 | 'concentrado'
82
83/** Caras base: [cuadro A, cuadro B]. Solo 'pensando' cambia entre cuadros (bombillo encendido / apagado). */
84export const EMOTIONS: Record<Emotion, readonly [Grid, Grid]> = (() => {
85 const normalA: Grid = [
86 '..................',
87 '..................',
88 '....h..........h..',
89 '....hh........hh..',
90 '.....hrrrrrrrrh...',
91 '.....rrrrrrrrrr..t',
92 '.....rrEErrEErr.tt',
93 '.....rrEErrEErr..t',
94 '.....rrrrrrrrrr..t',
95 '......rrMMMMrr..t.',
96 '.......rrrrrrtt...',
97 '.......rr..rr.....',
98 ]
99 const felizA: Grid = [
100 '......p.p..p.p....',
101 '......ppp..ppp....',
102 '....h..p....p..h..',
103 '....hh........hh..',
104 '.....hrrrrrrrrh...',
105 '.....rrrrrrrrrr..t',
106 '.....rrEErrEErr.tt',
107 '.....rrrrrrrrrr..t',
108 '.....rMrrrrrrMr..t',
109 '......rMMWWMMr..t.',
110 '.......rrrrrrtt...',
111 '.......rr..rr.....',
112 ]
113 const sonrienteA: Grid = [
114 '..................',
115 '..................',
116 '....h..........h..',
117 '....hh........hh..',
118 '.....hrrrrrrrrh...',
119 '.....rrrrrrrrrr..t',
120 '.....rrEErrEErr.tt',
121 '.....rrrrrrrrrr..t',
122 '.....rMrrrrrrMr..t',
123 '......rMMWWMMr..t.',
124 '.......rrrrrrtt...',
125 '.......rr..rr.....',
126 ]
127 const enojadoA: Grid = [
128 '.....s.s......s.s.',
129 '......s........s..',
130 '....h..........h..',
131 '....hh........hh..',
132 '.....hrrrrrrrrh...',
133 '.....rMrrrrrrMr..t',
134 '.....rrMMrrMMrr.tt',
135 '.....rrEErrEErr..t',
136 '.....rrrrrrrrrr..t',
137 '......rrMMMMrr..t.',
138 '.......rMrrMrtt...',
139 '.......rr..rr.....',
140 ]
141 const dormidoA: Grid = [
142 '...........BBB....',
143 '............B.....',
144 '....h......BBB.h..',
145 '....hh........hh..',
146 '.....hrrrrrrrrh...',
147 '.....rrrrrrrrrr..t',
148 '.....rrrrrrrrrr.tt',
149 '.....rEErrrrEEr..t',
150 '.....rrrrrrrrrr..t',
151 '......rrrMMrrr..t.',
152 '.......rrrrrrtt...',
153 '.......rr..rr.....',
154 ]
155 const asustadoA: Grid = [
156 '.......y......y...',
157 '........y....y....',
158 '....h..........h..',
159 '....hh........hh..',
160 '.....hrMMrrMMrhB..',
161 '.....rrrrrrrrrrB.t',
162 '.....rrWWrrWWrr.tt',
163 '.....rrWErrWErr..t',
164 '.....rrrrMMrrrr..t',
165 '......rrrMMrrr..t.',
166 '.......rrrrrrtt...',
167 '.......rr..rr.....',
168 ]
169 const pensandoA: Grid = [
170 '......y..YY..y....',
171 '........YwYY......',
172 '....h...YYYY...h..',
173 '....hh...GG...hh..',
174 '.....hrrrrrrrrh...',
175 '.....rrrrrrrrrr..t',
176 '.....rrrEErrEEr.tt',
177 '.....rrrrrrrrrr..t',
178 '.....rrrrrrrrrr..t',
179 '......rrrMMrrr..t.',
180 '.......rrrrrrtt...',
181 '.......rr..rr.....',
182 ]
183 const pensandoB: Grid = [
184 '.........oo.......',
185 '........oooo......',
186 '....h...oooo...h..',
187 '....hh...GG...hh..',
188 '.....hrrrrrrrrh...',
189 '.....rrrrrrrrrr..t',
190 '.....rrrEErrEEr.tt',
191 '.....rrrrrrrrrr..t',
192 '.....rrrrrrrrrr..t',
193 '......rrrMMrrr..t.',
194 '.......rrrrrrtt...',
195 '.......rr..rr.....',
196 ]
197 const preocupadoA: Grid = [
198 '.................R',
199 '.................R',
200 '....h..........h..',
201 '....hh........hh.R',
202 '.....hrrrrrrrrh...',
203 '....BrrrMrrMrrr..t',
204 '....BrrMrrrrMrr.tt',
205 '.....rrEErrEErr..t',
206 '.....rrrrrrrrrr..t',
207 '......rrrMrMrr..t.',
208 '.......rMrMrrtt...',
209 '.......rr..rr.....',
210 ]
211 const concentradoA: Grid = [
212 '..................',
213 '..................',
214 '....h..........h..',
215 '....hh........hh..',
216 '.....hrrrrrrrrh...',
217 '.....rrrrrrrrrr..t',
218 '.....rrEErrMMrr.tt',
219 '.....rrEErrEErr..t',
220 '.....rrrrrrrrrr..t',
221 '......rrrMMrrr..t.',
222 '.......rrrprrtt...',
223 '.......rr..rr.....',
224 ]
225 return {
226 normal: [normalA, normalA],
227 feliz: [felizA, felizA],
228 sonriente: [sonrienteA, sonrienteA],
229 enojado: [enojadoA, enojadoA],
230 dormido: [dormidoA, dormidoA],
231 asustado: [asustadoA, asustadoA],
232 pensando: [pensandoA, pensandoB],
233 preocupado: [preocupadoA, preocupadoA],
234 concentrado: [concentradoA, concentradoA],
235 }
236})()
237
238export type Role = 'desarrollador' | 'tester' | 'revisor' | 'planner' | 'infraestructura' | '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}
831types/index.d.ts 78 lines1export type RunAgent = { duration: number | null; result: string }
2
3export type PipelineRun = {
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, RunAgent>
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; title: string | 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-divine-wager': {
62 run: PipelineRun | 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