SLOPSHOPPER

mefisto-divine-wager

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mefisto-divine-wager
│ ┃ mefisto ✕ › fix the failing auth test and add an audit log call │ ┃ Sin corrida. Lanza /mefisto-tooling <issue> │ ┃ o /mefisto-divine-wager <issue>. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mefisto-divine-wager │ ⎿ mefisto-divine-wager: Log abierto. │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ mefisto · listos 0 │ │ │ │ ▀▄ ▄▀ next-order falló: salida de next-order no es JSON │ │ ▀▀▀▀▀▀▀▀▀▀ ▄ │ │ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀ │ │ ▄▀▀▄ ▀▀▀▀▀▀▀▀▀▀ ▄▀ │ │ ▀▄▄▀ ▀▀▀▀▀▀▀▀ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

mefisto-divine-wager

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

Cargarlo

Una sola vez por checkout. Los mods se instalan en alcance local (.claude/settings.local.json, no versionado) desde el marketplace de carpeta .claude/mods/:

claude plugin marketplace add ./.claude/mods --scope local
claude plugin install mefisto-divine-wager@mefisto-mods --scope local
claude plugin install mefisto-planner-board@mefisto-mods --scope local

Desde ahi cargan solos en toda sesion de este checkout, sin --plugin-dir, y se leen de la carpeta: un cambio se aplica con /reload-plugins. Los worktrees de los pipelines no tienen settings.local.json, asi que sus sesiones -p no los cargan. Para probar una copia suelta: claude --plugin-dir .claude/mods/<mod>.

Uso

  • En reposo, en toda sesion interactiva que no sea la del planner, la banda lista los estado:listo en el orden de mefisto-next-order.sh (se refresca cada minuto y al cerrar una corrida). Con el prompt vacio, 1 escribe la linea de /mefisto-sequential con todos y 5-9 toma un solo issue: el dialogo nativo, junto al prompt, pregunta si va con merge (/mefisto-sequential) o solo PR (/mefisto-tooling). El comando queda en el prompt sin Enter para ajustarlo antes de lanzar; 0 pagina. Los bloqueados se ven al final, sin tecla. 2 (PRs N, los abiertos sin borradores) abre el dialogo nativo para mergear: todos (--all) o varios de los mas recientes, y otros numeros en la opcion de texto; al aceptar ejecuta /mefisto-merge de una vez. 3 (bitácora N: field notes en docs/bitacora/field-notes/ mas las de PRs sin mergear) pide integrarlas y le encarga a Claude mergear esos PRs, poner main al dia y correr /mefisto-bitacora. 4 (release N: issues con fragmentos en changelog.d/) ofrece /mefisto-release con el bump que sugiere SemVer (minor si hay added) o solo preparar el PR, y lo ejecuta al aceptar. PRs incluye los de field notes, como --all. /mefisto-divine-wager refresh la actualiza a mano.
  • /clear, /resume y /branch reinician el estado de la sesion: la banda vuelve sola a reposo en menos de un segundo con los listos, PRs N, bitácora N y release N recargados en el acto, sin esperar al siguiente minuto. La rearma el poll de cada segundo al ver los atoms en blanco: session.start no se repite en esos casos y el engine no entrega eventos classic.* (classic.SessionStart incluido) a un plugin de tier user. Una corrida o un sequential en curso no se conserva: se reengancha con /mefisto-divine-wager <issue> o batch <issues>.
  • /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. Bajo la cola, la tabla lista los terminados (duracion y costo) y, mientras corre, una fila ● del issue en curso (titulo, PR #N o …, tiempo desde su inicio con el reloj de la cabecera, congelado mientras espera su merge, y costo …); el total cuenta esa fila y suma su tiempo. /mefisto-divine-wager batch <issues> reengancha un sequential ya lanzado.
  • Mientras /mefisto-bitacora corre al subagente mefisto-historiador, la banda de espera muestra al historiador escribiendo (historiador escribiendo la bitácora N/M MM:SS) y la cinta de sus field notes: al arrancar toma las de docs/bitacora/field-notes/ y las de los PRs de field notes sin mergear (la misma fuente que bitácora N), y cada una pasa de ● pendiente a ✓ procesada cuando aparece en procesadas/, en el checkout o en el worktree del historiador (.mefisto/pipeline/summaries/bitacora-<fecha>/, de mefisto-bitacora-worktree.sh). Al terminar, bitácora escrita; si su PR quedo abierto, la banda lo ofrece: 1 mergea (/mefisto-merge N, con su cinta), 2 lo abre en GitHub y 3 lo cierra sin mergear y borra la rama (confirma en el dialogo nativo). Si el PR ya no esta abierto (el skill lo mergeo), la cinta se limpia sola a los pocos segundos.
  • Mientras corre /mefisto-release (lanzado con 4 o escrito a mano), la banda muestra un cohete: en reposo sobre su plataforma mientras se prepara y despegando con llama mientras corre mefisto-release.sh. Al lado va la cinta del release:
  • Encabezado: release vX.Y.Z con la version destino, calculada como bump_version del script a partir de .claude-plugin/plugin.json y del bump pedido.
  • Lista: la foto de changelog.d/ (sin README.md) al arrancar, agrupada por issue, como ● #N título · added/changed. Los titulos salen de gh issue view, en paralelo.
  • Checklist de fases: en una linea debajo, rama → consolidar → PR → merge → tag → GitHub Release. --prepare-only se detiene en el PR, y sin bump (la fase publish) solo quedan tag y release.

Las fases se detectan por sondeo cada 5 s, no por la salida del Bash, que llega entera al terminar:

FaseSe da por hecha cuando
ramagit ls-remote --heads origin release/vX.Y.Z devuelve la rama
consolidarla rama remota ya no tiene esos fragmentos; toda la lista pasa a ✓
PRgh pr list --head <rama> encuentra el PR
mergeese PR esta MERGED
taggit ls-remote --tags origin vX.Y.Z devuelve el tag
GitHub Releasegh release view vX.Y.Z lo encuentra publicado

Una fase observada da por hechas las anteriores: la rama se borra al mergear, y un sondeo puede saltarse una fase rapida. Los nombres de la rama y del tag son los de RELEASE_BRANCH y NEW_TAG en mefisto-release.sh. Al cerrar el turno que corrio el script:

  • Exito: muestra release vX.Y.Z publicado (o PR #N listo con --prepare-only), y 2 abre la GitHub Release o el PR durante 15 segundos.
  • Fallo: si faltaron fases, la fase en curso queda con ✗ y el encabezado dice falló en <fase>.
  • Mientras corre /mefisto-merge (lanzado con 2, con 1 desde la corrida o escrito a mano), la banda de espera muestra la cinta: el hilo de la rama y el de main bajan y se cierran como una cremallera, cosiendo N PRs MM:SS y una linea ● #N título por PR pendiente (con --all, todos los abiertos). Un PR sale de la lista al quedar mergeado: lo dice la salida del Bash (gh pr merge N con exit 0, o el aviso de gh) y, como respaldo, gh pr view N --json state cada 5 s; si su merge falla queda con ✗. Al terminar el turno lo que siga pendiente cuenta como fallido, la cremallera queda cerrada y la cabecera dice N mergeados (y los fallidos); se limpia sola a los pocos segundos.
  • Las cintas terminadas de release, merge y bitácora vencen al dibujar, sin depender del temporizador: si un hot-reload del mod mata el clock.after que las limpiaba, la banda vuelve igual a reposo y el siguiente hook que escribe estado (session.start, prompt.submit, tool.call) borra la cinta vencida. La de bitácora con un PR abierto no vence: espera a que lo mergees, lo veas o lo cierres.
  • Si un agente espera por rate limit (la ultima linea de events.log es un [hold]), la banda de la corrida y la del sequential lo dicen: en espera por RATE_LIMIT · próxima sonda HH:MM · techo HH:MM.
  • Durante la corrida la misma banda muestra la mascota del agente activo (animada), issue, stage, tiempo, los pasos y las ultimas lineas del agente; 3 abre el 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.
  • Cuando el PR queda mergeado (desde la banda, /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.
  • Dentro del log: m mergea, v abre el PR, c cierra; ctrl+x tab lo enfoca y esc vuelve al prompt.

Verificar un cambio

claude plugin validate .claude/mods/mefisto-divine-wager
claude plugin test .claude/mods/mefisto-divine-wager
Source 4 files
hooks/register.tsx 1542 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BatchRun, LogLine, PipelineRun, ReadyList, Release, ReleaseStep } 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  currentRowParts,
31  totalRowText,
32  issueMark,
33  mascotPose,
34  isReleasePrompt,
35  isReleaseRun,
36  holdOf,
37  holdText,
38  bitacoraPrompt,
39  changelogOf,
40  fieldNotesIn,
41  releaseArgsOf,
42  releaseOptions,
43  releaseInvocationOf,
44  releasePlan,
45  releaseIssuesOf,
46  releaseBranchOf,
47  releaseTagOf,
48  releaseChecklist,
49  releaseHeader,
50  isConsolidated,
51  withObservedSteps,
52  finishRelease,
53  mergeArgsOf,
54  mergeOptions,
55  newlyMerged,
56  padEnd,
57  pageOf,
58  parseNextOrder,
59  parseOpenPrs,
60  readyRows,
61  sequentialOf,
62  titlesOf,
63  toolingOf,
64  waitingFace,
65  parseEvent,
66  pickEventsFile,
67  prNumber,
68  runFromStatus,
69  statusPath,
70  steps,
71  toolingIssueOf,
72  usedColumns,
73  withModUi,
74  historianFrom,
75  isAgentActive,
76  bitacoraPrOf,
77  fieldNoteNamesOf,
78  pendingNotes,
79  processedDirsOf,
80  withProcessedNotes,
81  finishMerge,
82  ghMergeNumbersOf,
83  isGhMergeRun,
84  mergeCounts,
85  mergeHeader,
86  mergeOutcomesOf,
87  mergePromptArgs,
88  mergePrsOf,
89  withMergeOutcomes,
90  liveTape,
91} from './logic'
92import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, face, sprite } from './sprites'
93
94const PANE = 'mefisto-divine-wager'
95const MAX_LINES = 200
96const STATUS_GRACE_MS = 30_000
97// Un seguimiento que tras este tiempo sigue en setup sin status ni historial no corresponde a ninguna corrida.
98const ORPHAN_MS = 120_000
99const PANE_ROWS = 16
100const FIXED_ROWS = 7
101const PR_CHECK_MS = 15_000
102const READY_POLL_MS = 60_000
103const BAND_LOG_ROWS = 3
104const STATS_EVERY_MS = 5_000
105const HOLD_EVERY_MS = 5_000
106const HISTORIAN = 'mefisto-historiador'
107// Tras terminar, el historiador queda en pose de listo este tiempo antes de volver a la espera.
108const HISTORIAN_DONE_MS = 8_000
109const FIELD_NOTES_DIR = 'docs/bitacora/field-notes'
110const SUMMARIES_DIR = '.mefisto/pipeline/summaries'
111const NOTES_POLL_MS = 3_000
112const CHANGELOG_DIR = 'changelog.d'
113
114// Las rutas del mod son relativas a la raiz del repo, no al cwd de la sesion: este cambia con cada `cd` del
115// asistente, y desde otro directorio un script relativo no arranca y $.fs no encuentra el estado del pipeline.
116let repoRootCache: string | null = null
117async function repoRoot($: EngineInterface): Promise<string | null> {
118  if (!repoRootCache) repoRootCache = (await $.session.repo().catch(() => null))?.root ?? null
119  return repoRootCache
120}
121async function inRepo($: EngineInterface, path: string): Promise<string> {
122  const root = await repoRoot($)
123  return root && !path.startsWith('/') ? `${root}/${path}` : path
124}
125async function runInRepo($: EngineInterface, argv: string[], init: { timeoutMs?: number } = {}) {
126  const root = await repoRoot($)
127  return $.process.run(argv, root ? { ...init, cwd: root } : init)
128}
129const SCRIPT_TIMEOUT_MS = 180_000
130// Respaldo del avance de la cinta de merge: el bucle de /mefisto-merge es un solo Bash y su salida llega al final.
131const MERGE_POLL_MS = 5_000
132// Sin ningun `gh pr merge` a la vista, una cinta que pasa este tiempo se cierra al terminar el turno.
133const MERGE_STALE_MS = 10 * 60_000
134/** Cada cuanto se sondea la fase del release en GitHub mientras corre su script. */
135const RELEASE_POLL_MS = 5_000
136/** Cuanto queda en la banda el release terminado, con su boton para abrirlo. */
137const RELEASE_DONE_MS = 15_000
138const PLUGIN_JSON = '.claude-plugin/plugin.json'
139
140// Recorte comun a todos los cuadros que usa la consola, calculado una vez: sin margen y sin saltos al alternar.
141const MASCOT_POSES = [
142  ['desarrollador', 'trabajando'],
143  ['desarrollador', 'pensando'],
144  ['desarrollador', 'error'],
145  ['revisor', 'trabajando'],
146  ['revisor', 'pensando'],
147  ['revisor', 'corrigiendo'],
148  ['revisor', 'aprobado'],
149  ['historiador', 'escribiendo'],
150  ['historiador', 'listo'],
151  ['release', 'reposo'],
152  ['release', 'despegando'],
153  ['release', 'listo'],
154  ['merge', 'cosiendo'],
155  ['merge', 'listo'],
156] as const
157const MASCOT_COLS = usedColumns([
158  ...MASCOT_POSES.flatMap(([role, state]) => [sprite(role, state, 0), sprite(role, state, 1)]),
159  face('normal'),
160])
161const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
162
163/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
164function mascotCells(grid: readonly string[]): string {
165  const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
166  const words: number[] = []
167  for (let y = 0; y < HEIGHT; y += 2) {
168    for (let x = 0; x < MASCOT_WIDTH; x++) {
169      const top = color(grid[y]?.[x])
170      const bottom = color(grid[y + 1]?.[x])
171      if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
172      else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
173      else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
174    }
175  }
176  let bin = ''
177  for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
178  return btoa(bin)
179}
180
181const runAtom = atom({ plugin: 'mefisto-divine-wager', key: 'run' } as const, null)
182const linesAtom = atom({ plugin: 'mefisto-divine-wager', key: 'lines' } as const, [])
183const nowAtom = atom({ plugin: 'mefisto-divine-wager', key: 'now' } as const, 0)
184const executionAtom = atom({ plugin: 'mefisto-divine-wager', key: 'isExecutionSession' } as const, false)
185const readyAtom = atom({ plugin: 'mefisto-divine-wager', key: 'ready' } as const, null)
186const readyPageAtom = atom({ plugin: 'mefisto-divine-wager', key: 'readyPage' } as const, 0)
187const batchAtom = atom({ plugin: 'mefisto-divine-wager', key: 'batch' } as const, null)
188const openPrsAtom = atom({ plugin: 'mefisto-divine-wager', key: 'openPrs' } as const, null)
189const fieldNotesAtom = atom({ plugin: 'mefisto-divine-wager', key: 'fieldNotes' } as const, null)
190const changelogAtom = atom({ plugin: 'mefisto-divine-wager', key: 'changelog' } as const, null)
191const holdAtom = atom({ plugin: 'mefisto-divine-wager', key: 'hold' } as const, null)
192const historianAtom = atom({ plugin: 'mefisto-divine-wager', key: 'historian' } as const, null)
193const releaseAtom = atom({ plugin: 'mefisto-divine-wager', key: 'release' } as const, null)
194const mergeAtom = atom({ plugin: 'mefisto-divine-wager', key: 'merge' } as const, null)
195
196type Tail = { path: string; stream: AsyncGenerator<unknown, unknown> }
197
198let tail: Tail | null = null
199let watchSinceMs = 0
200let batchSinceMs = 0
201let lastStatsReadMs = 0
202let lastHoldReadMs = 0
203let isPolling = false
204// Una sesion no interactiva (los agentes de los pipelines) no tiene a nadie mirando: el mod no reescribe ni sigue nada.
205let isInteractive = false
206let lastPrCheckMs = 0
207let isLogOpen = false
208let isRefreshingReady = false
209let readyTimer: { cancel: () => void } | null = null
210// La banda en reposo vive en el modulo, no en atoms: /clear, /resume y /branch los reinician y el modulo sigue.
211let isExecutionOn = false
212let lastMergePollMs = 0
213let isMergeSeen = false
214let lastReleasePollMs = 0
215let isReleaseScriptSeen = false
216let lastNotesPollMs = 0
217let lastBitacoraPrCheckMs = 0
218
219async function stopTail() {
220  const current = tail
221  tail = null
222  if (current) await current.stream.return(undefined).catch(() => undefined)
223}
224
225async function pushLines($: EngineInterface, added: LogLine[]) {
226  if (added.length === 0) return
227  await update($, linesAtom, list => [...(list ?? []), ...added].slice(-MAX_LINES))
228}
229
230async function followTail($: EngineInterface, stream: AsyncGenerator<unknown, unknown>, path: string) {
231  let pending = ''
232  try {
233    for await (const chunk of stream) {
234      if (tail?.stream !== stream) break
235      const { stream: kind, text } = chunk as { stream: string; text: string }
236      if (kind !== 'stdout') continue
237      pending += text
238      const rows = pending.split('\n')
239      pending = rows.pop() ?? ''
240      await pushLines($, rows.map(parseEvent).filter((l): l is LogLine => l !== null))
241    }
242  } catch (err) {
243    $.ui.log(`tail ${path}: ${String(err)}`, { to: 'debug' })
244  }
245}
246
247async function startTail($: EngineInterface, name: string) {
248  await stopTail()
249  const path = `${LOG_DIR}/${name}`
250  const root = await repoRoot($)
251  const stream = $.process.spawn({ argv: ['tail', '-n', '40', '-F', path], ...(root ? { cwd: root } : {}) })
252  tail = { path, stream }
253  await pushLines($, [{ ts: '', kind: 'start', text: `── ${agentOfEventsFile(name)} ──` }])
254  void followTail($, stream, path)
255}
256
257// En un batch cada eslabon se sigue igual que un /mefisto-tooling suelto, pero el log acumula todos los issues.
258async function watch($: EngineInterface, issue: string, sinceMs: number, keepLines = false) {
259  await stopTail()
260  watchSinceMs = sinceMs
261  const run: PipelineRun = {
262    issue,
263    title: '',
264    stage: 'setup',
265    state: 'running',
266    startedMs: Date.now(),
267    finishedMs: null,
268    agents: {},
269    pr: null,
270    lastError: null,
271    eventsFile: null,
272  }
273  await update($, runAtom, () => run)
274  if (keepLines) await pushLines($, [{ ts: '', kind: 'start', text: `══ #${issue} ══` }])
275  else await update($, linesAtom, () => [])
276  await poll($)
277}
278
279async function watchBatch($: EngineInterface, issues: string[], sinceMs: number) {
280  await stopTail()
281  batchSinceMs = sinceMs
282  const batch: BatchRun = {
283    issues: issues.map(issue => ({ issue, status: 'pendiente', pr: null })),
284    state: 'running',
285    current: null,
286    stopRequested: false,
287    holdSeconds: 0,
288    startedMs: Date.now(),
289    finishedMs: null,
290    stats: {},
291  }
292  await update($, runAtom, () => null)
293  await update($, linesAtom, () => [])
294  await update($, batchAtom, () => batch)
295}
296
297// Duracion, costo y titulo de cada terminado: se leen del historial cuando falta alguno o su titulo, a lo
298// sumo cada STATS_EVERY_MS (un fallido antes de escribir su historial no los tendra nunca).
299async function withStats($: EngineInterface, batch: BatchRun): Promise<BatchRun> {
300  const stats = batch.stats ?? {}
301  const finished = batch.issues.filter(i => ['done', 'failed'].includes(issueMark(i.status))).map(i => i.issue)
302  const missing = statsPending(finished, stats)
303  if (missing.length === 0 || Date.now() - lastStatsReadMs < STATS_EVERY_MS) return batch
304  lastStatsReadMs = Date.now()
305  const { stdout } = await runInRepo($, ['tail', '-n', '200', HISTORY]).catch(() => ({ stdout: '' }))
306  return { ...batch, stats: { ...stats, ...issueStatsFromHistory(stdout, missing, batch.startedMs) } }
307}
308
309async function pollBatch($: EngineInterface) {
310  const batch = await read($, batchAtom)
311  if (!batch) return
312  if (batch.state !== 'running') {
313    const filled = await withStats($, batch)
314    if (filled !== batch) await update($, batchAtom, () => filled)
315    return
316  }
317  let next = batch
318  if (await $.fs.exists(await inRepo($, BATCH_STATUS))) next = batchFromStatus(await $.fs.read(await inRepo($, BATCH_STATUS)).catch(() => ''), batch, batchSinceMs)
319  next = { ...next, stopRequested: next.stopRequested || (await $.fs.exists(await inRepo($, BATCH_STOP))) }
320  for (const i of newlyMerged(batch.issues, next.issues)) $.ui.toast(`#${i.issue} mergeado${i.pr ? ` · PR #${i.pr}` : ''}`)
321  next = await withStats($, next)
322  const settled = next
323  await update($, batchAtom, () => settled)
324  const run = await read($, runAtom)
325  if (settled.current && settled.current !== run?.issue) await watch($, settled.current, settled.startedMs, true)
326  const c = batchCounts(settled.issues)
327  if (settled.state === 'running') {
328    $.ui.status(`mefisto · sequential ${c.done + c.failed}/${c.total}${settled.current ? ` · #${settled.current}` : ''}`)
329    return
330  }
331  await stopTail()
332  $.ui.toast(`sequential terminado: ${batchSummary(settled)}`)
333  $.ui.status(settled.state === 'failed' ? `mefisto · sequential con fallos` : undefined)
334}
335
336// Parada suave: la misma senal que /mefisto-batch-stop. Termina el eslabon en curso (con su merge) y no sigue.
337async function requestStop($: EngineInterface) {
338  await $.fs.write(await inRepo($, BATCH_STOP), '')
339  await update($, batchAtom, b => (b ? { ...b, stopRequested: true } : b))
340  $.ui.toast('parada pedida: termina el issue en curso y no arranca los siguientes')
341}
342
343async function finish($: EngineInterface, run: PipelineRun) {
344  await stopTail()
345  if (run.state === 'completed') {
346    const pr = prNumber(run.pr)
347    $.ui.toast(pr ? `#${run.issue} terminó · PR #${pr} listo` : `#${run.issue} terminó`)
348    $.ui.status(undefined)
349  } else {
350    $.ui.toast(`#${run.issue} falló en ${stageName(run.stage)}`)
351    $.ui.status(`mefisto · #${run.issue} falló`)
352  }
353}
354
355async function fromHistory($: EngineInterface, prev: PipelineRun): Promise<PipelineRun> {
356  const { stdout } = await runInRepo($, ['tail', '-n', '50', HISTORY]).catch(() => ({ stdout: '' }))
357  const done = finishedFromHistory(stdout, prev.issue, Math.min(prev.startedMs, watchSinceMs))
358  if (!done) return prev
359  const isFailed = done.state === 'failed'
360  return {
361    ...prev,
362    title: done.title ?? prev.title,
363    state: isFailed ? 'failed' : 'completed',
364    stage: isFailed ? (done.stage ?? prev.stage) : 'done',
365    pr: done.pr ?? null,
366    lastError: done.error ?? null,
367    finishedMs: Date.now(),
368  }
369}
370
371// Mientras /mefisto-bitacora corre al historiador, la banda de espera lo muestra escribiendo y lleva la cinta de
372// sus field notes: pendientes hasta que aparecen en algun procesadas/.
373async function pollHistorian($: EngineInterface) {
374  if (!isInteractive) return
375  const agents = await $.agent.list().catch(() => [])
376  const active = agents.some(a => a.type === HISTORIAN && isAgentActive(a.status))
377  const prev = await read($, historianAtom)
378  const next = historianFrom(prev, active, Date.now())
379  if (next !== prev) {
380    await update($, historianAtom, () => next)
381    if (next && !next.finishedMs) {
382      void captureNotes($)
383      return
384    }
385    if (next?.finishedMs) {
386      lastNotesPollMs = 0
387      await pollNotes($)
388      await findBitacoraPr($)
389      void refreshReady($)
390    }
391    return
392  }
393  if (next && !next.finishedMs) await pollNotes($)
394  if (next?.pr) await checkBitacoraPr($, next.pr.number)
395}
396
397async function captureNotes($: EngineInterface) {
398  const local = await $.fs.list(await inRepo($, FIELD_NOTES_DIR)).catch(() => [])
399  const notePrs = ((await read($, openPrsAtom)) ?? []).filter(p => p.isFieldNote).map(p => String(p.number))
400  const files = await Promise.all(
401    notePrs.map(n =>
402      runInRepo($, ['gh', 'pr', 'view', n, '--json', 'files', '-q', '.files[].path']).catch(() => ({ exitCode: 1, stdout: '' })),
403    ),
404  )
405  const paths = files.flatMap(f => (f.exitCode === 0 ? f.stdout.split('\n').filter(Boolean) : []))
406  const names = fieldNoteNamesOf(local.filter(n => n.kind === 'file').map(n => n.name), paths)
407  await update($, historianAtom, h => (h && h.notes.length === 0 ? { ...h, notes: pendingNotes(names) } : h))
408  lastNotesPollMs = 0
409  await pollNotes($)
410}
411
412async function pollNotes($: EngineInterface) {
413  const h = await read($, historianAtom)
414  if (!h || !h.notes.some(n => n.estado === 'pendiente') || Date.now() - lastNotesPollMs < NOTES_POLL_MS) return
415  lastNotesPollMs = Date.now()
416  const summaries = await $.fs.list(await inRepo($, SUMMARIES_DIR)).catch(() => [])
417  const dirs = processedDirsOf(summaries.filter(n => n.kind === 'dir').map(n => n.name))
418  const listed = await Promise.all(dirs.map(async d => $.fs.list(await inRepo($, d)).catch(() => [])))
419  const processed = listed.flat().filter(n => n.kind === 'file').map(n => n.name)
420  await update($, historianAtom, cur => (cur ? withProcessedNotes(cur, processed) : cur))
421}
422
423// Al terminar, el PR de bitacora que quedo abierto se ofrece para mergear, ver o cerrar. Si no hay (el skill ya lo
424// mergeo), la cinta se limpia sola como antes.
425async function findBitacoraPr($: EngineInterface) {
426  const { exitCode, stdout } = await runInRepo($, ['gh', 'pr', 'list', '--state', 'open', '--limit', '50', '--json', 'number,title,headRefName,files'])
427    .catch(() => ({ exitCode: 1, stdout: '' }))
428  const pr = exitCode === 0 ? bitacoraPrOf(stdout) : null
429  if (pr) {
430    lastBitacoraPrCheckMs = Date.now()
431    await update($, historianAtom, h => (h ? { ...h, pr } : h))
432    return
433  }
434  $.clock.after(HISTORIAN_DONE_MS, () => void update($, historianAtom, h => (h?.finishedMs && !h.pr ? null : h)))
435}
436
437// El PR ofrecido sigue abierto: si se mergeo o cerro por fuera (el skill, GitHub), la cinta se va.
438async function checkBitacoraPr($: EngineInterface, pr: number) {
439  if (Date.now() - lastBitacoraPrCheckMs < PR_CHECK_MS) return
440  lastBitacoraPrCheckMs = Date.now()
441  const { exitCode, stdout } = await runInRepo($, ['gh', 'pr', 'view', String(pr), '--json', 'state', '-q', '.state'])
442    .catch(() => ({ exitCode: 1, stdout: '' }))
443  if (exitCode === 0 && stdout.trim() !== 'OPEN') await dismissHistorian($)
444}
445
446async function dismissHistorian($: EngineInterface) {
447  await update($, historianAtom, () => null)
448  void refreshReady($)
449}
450
451async function mergeBitacoraPr($: EngineInterface) {
452  const pr = (await read($, historianAtom))?.pr
453  if (!pr) return
454  await dismissHistorian($)
455  await startMerge($, String(pr.number))
456  await $.command.run({ command: 'mefisto-merge', args: String(pr.number) })
457}
458
459async function openBitacoraPr($: EngineInterface) {
460  const pr = (await read($, historianAtom))?.pr
461  if (pr) await runInRepo($, ['gh', 'pr', 'view', String(pr.number), '--web']).catch(() => undefined)
462}
463
464async function closeBitacoraPr($: EngineInterface) {
465  const pr = (await read($, historianAtom))?.pr
466  if (!pr) return
467  const answer = await $.ui
468    .ask(`¿Cerrar el PR #${pr.number} de la bitácora sin mergear y borrar su rama?`, {
469      header: 'Cerrar PR',
470      options: ['Cerrar PR', 'Cancelar'],
471    })
472    .catch(() => null)
473  if (answer !== 'Cerrar PR') return
474  const { exitCode, stderr } = await runInRepo($, ['gh', 'pr', 'close', String(pr.number), '--delete-branch'])
475    .catch(err => ({ exitCode: 1, stderr: String(err) }))
476  if (exitCode !== 0) {
477    $.ui.toast(`no se pudo cerrar el PR #${pr.number}: ${clip(stderr ?? '', 80)}`)
478    return
479  }
480  $.ui.toast(`PR #${pr.number} cerrado`)
481  await dismissHistorian($)
482}
483
484async function poll($: EngineInterface) {
485  if (isPolling) return
486  isPolling = true
487  try {
488    if (isExecutionOn && !(await read($, executionAtom))) await restoreExecution($)
489    await update($, nowAtom, () => Date.now())
490    await pollBatch($)
491    await pollHistorian($)
492    await pollMerge($)
493    await pollRelease($)
494    const inBatch = (await read($, batchAtom)) !== null
495    const prev = await read($, runAtom)
496    if (prev?.state === 'completed' && !inBatch) await closeIfMerged($, prev)
497    if (!prev || prev.state !== 'running') return
498
499    let next: PipelineRun = prev
500    const status = statusPath(prev.issue)
501    if (await $.fs.exists(await inRepo($, status))) {
502      const raw = await $.fs.read(await inRepo($, status)).catch(() => '')
503      const parsed = runFromStatus(raw, prev)
504      if (parsed && !(parsed.state !== 'running' && parsed.startedMs + 1000 < watchSinceMs)) next = parsed
505    } else if (watchSinceMs === 0 || Date.now() - watchSinceMs > STATUS_GRACE_MS || prev.stage !== 'setup') {
506      next = await fromHistory($, prev)
507      if (!inBatch && next === prev && prev.stage === 'setup' && Date.now() - prev.startedMs > ORPHAN_MS) {
508        $.ui.log(`mefisto-divine-wager: #${prev.issue} sin status ni historial tras ${ORPHAN_MS / 60_000} min; se deja de seguir`, { to: 'debug' })
509        await closeRun($)
510        return
511      }
512    }
513
514    if (next.state === 'running' && Date.now() - lastHoldReadMs >= HOLD_EVERY_MS) {
515      lastHoldReadMs = Date.now()
516      const { stdout } = await runInRepo($, ['tail', '-n', '3', EVENTS_LOG]).catch(() => ({ stdout: '' }))
517      const hold = holdOf(stdout)
518      await update($, holdAtom, () => hold)
519    } else if (next.state !== 'running') {
520      await update($, holdAtom, () => null)
521    }
522
523    if (next.state === 'running') {
524      const entries = await $.fs.list(await inRepo($, LOG_DIR)).catch(() => [])
525      const name = pickEventsFile(entries, next.issue, Math.min(next.startedMs, watchSinceMs))
526      if (name && name !== next.eventsFile) {
527        next = { ...next, eventsFile: name }
528        await startTail($, name)
529      }
530      if (!inBatch) $.ui.status(`mefisto · #${next.issue} ${stageName(next.stage)} ${elapsed(Date.now() - next.startedMs)}`)
531    }
532
533    const settled = next
534    await update($, runAtom, () => settled)
535    if (settled.state !== 'running') {
536      if (inBatch) await stopTail()
537      else await finish($, settled)
538    }
539  } finally {
540    isPolling = false
541  }
542}
543
544// El PR mergeado da la corrida por terminada: venga del boton, de /mefisto-merge tecleado o de GitHub.
545async function closeIfMerged($: EngineInterface, run: PipelineRun) {
546  const pr = prNumber(run.pr)
547  if (!pr || Date.now() - lastPrCheckMs < PR_CHECK_MS) return
548  lastPrCheckMs = Date.now()
549  const { exitCode, stdout } = await runInRepo($, ['gh', 'pr', 'view', pr, '--json', 'state', '-q', '.state'])
550    .catch(() => ({ exitCode: 1, stdout: '' }))
551  if (exitCode !== 0 || stdout.trim() !== 'MERGED') return
552  await closeRun($)
553  $.ui.toast(`PR #${pr} mergeado · corrida de #${run.issue} cerrada`)
554}
555
556async function openPane($: EngineInterface, isFocused = false) {
557  isLogOpen = true
558  return $.ui.open({ id: PANE, title: 'mefisto', rows: PANE_ROWS, ...(isFocused ? { focus: true as const } : {}) })
559}
560
561async function closeLog($: EngineInterface) {
562  isLogOpen = false
563  await $.ui.close({ id: PANE })
564}
565
566// La tecla 3 de la banda abre el log o, si ya estaba abierto, lo cierra.
567async function toggleLog($: EngineInterface) {
568  if (isLogOpen) await closeLog($)
569  else await openPane($, true)
570}
571
572async function closeRun($: EngineInterface) {
573  await stopTail()
574  await update($, batchAtom, () => null)
575  await update($, runAtom, () => null)
576  await update($, linesAtom, () => [])
577  $.ui.status(undefined)
578  await $.ui.close({ id: PANE })
579  void refreshReady($)
580}
581
582async function openPr($: EngineInterface) {
583  const run = await read($, runAtom)
584  const batch = await read($, batchAtom)
585  const pr = batch ? batchPr(batch) : prNumber(run?.pr ?? null)
586  if (pr) await runInRepo($, ['gh', 'pr', 'view', pr, '--web']).catch(() => undefined)
587}
588
589async function merge($: EngineInterface) {
590  const run = await read($, runAtom)
591  const pr = prNumber(run?.pr ?? null)
592  if (!pr) return
593  const answer = await $.ui
594    .ask(`¿Mergear el PR #${pr} con /mefisto-merge?`, ['Mergear', 'Cancelar'])
595    .catch(() => 'Cancelar')
596  if (answer !== 'Mergear') return
597  $.ui.toast(`/mefisto-merge ${pr} en cola: la corrida se cierra cuando el PR quede mergeado`)
598  lastPrCheckMs = 0
599  await startMerge($, pr)
600  await $.command.run({ command: 'mefisto-merge', args: pr })
601}
602
603// La sesion de ejecucion es cualquier sesion interactiva que no sea la del planner: el planner no lanza
604// trabajo (su tablero deja los listos en solo lectura). Se lee el --agent de la linea de comando del
605// proceso de Claude Code, padre del `sh` que corre aqui; si no se puede leer, la sesion cuenta como de ejecucion.
606async function detectExecution($: EngineInterface) {
607  const ps = await runInRepo($, ['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
608  const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
609  if (agentFlagOf(cmdline) === PLANNER_AGENT) return
610  isExecutionOn = true
611  await update($, executionAtom, () => true)
612  readyTimer?.cancel()
613  readyTimer = $.clock.every(READY_POLL_MS, () => void refreshReady($))
614  void refreshReady($)
615}
616
617// El engine no entrega eventos classic.* a un plugin de tier user y session.start no se repite tras /clear: el
618// poll de cada segundo nota los atoms en blanco y rearma la banda con listos, PRs, field notes y fragmentos.
619async function restoreExecution($: EngineInterface) {
620  await update($, executionAtom, () => true)
621  void refreshReady($)
622}
623
624async function refreshReady($: EngineInterface) {
625  if (isRefreshingReady || !(await read($, executionAtom))) return
626  isRefreshingReady = true
627  try {
628    const [order, listos, prs] = await Promise.all([
629      runInRepo($, [NEXT_ORDER, '--json'], { timeoutMs: SCRIPT_TIMEOUT_MS })
630        .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) })),
631      runInRepo($, ['gh', 'issue', 'list', '--state', 'open', '--label', 'estado:listo', '--limit', '200', '--json', 'number,title'])
632        .catch(() => ({ exitCode: 1, stdout: '' })),
633      runInRepo($, ['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,title,isDraft,headRefName'])
634        .catch(() => ({ exitCode: 1, stdout: '' })),
635    ])
636    const openPrs = prs.exitCode === 0 ? parseOpenPrs(prs.stdout) : null
637    await update($, openPrsAtom, () => openPrs)
638    const notes = await $.fs.list(await inRepo($, FIELD_NOTES_DIR)).catch(() => null)
639    await update($, fieldNotesAtom, () => (notes ? fieldNotesIn(notes.filter(n => n.kind === 'file').map(n => n.name)) : null))
640    const fragments = await $.fs.list(await inRepo($, CHANGELOG_DIR)).catch(() => null)
641    await update($, changelogAtom, () => (fragments ? changelogOf(fragments.filter(n => n.kind === 'file').map(n => n.name)) : null))
642    const ready: ReadyList = parseNextOrder(order.exitCode, order.stdout, order.stderr, titlesOf(listos.stdout))
643    await update($, readyAtom, () => ready)
644  } finally {
645    isRefreshingReady = false
646  }
647}
648
649// La banda escribe el comando sin Enter: se lanza a mano, con la opcion de ajustarlo antes.
650async function fill($: EngineInterface, text: string) {
651  await $.prompt.fill({ text, mode: 'replace' })
652}
653
654// Las confirmaciones van en el dialogo nativo, abajo junto al prompt; la banda queda para informar. Esc cancela.
655const WITH_MERGE = 'Con merge (sequential)'
656const ONLY_PR = 'Solo PR (tooling)'
657
658async function choose($: EngineInterface, issue: number) {
659  const answer = await $.ui
660    .ask(`¿Cómo desarrollar #${issue}?`, { header: 'Lanzar', options: [WITH_MERGE, ONLY_PR] })
661    .catch(() => null)
662  if (answer === WITH_MERGE) await fill($, sequentialOf(issue))
663  else if (answer === ONLY_PR) await fill($, toolingOf(issue))
664}
665
666// Mergear desde la espera: todos o los PRs elegidos; en la opcion de texto se escriben otros numeros. Elegir en el
667// dialogo ya es la aprobacion, asi que /mefisto-merge se ejecuta de una vez.
668async function chooseMerge($: EngineInterface) {
669  const prs = (await read($, openPrsAtom)) ?? []
670  if (prs.length === 0) return
671  const options = mergeOptions(prs)
672  const answer = await $.ui
673    .ask(`¿Qué mergear? ${prs.length} PRs abiertos${prs.length > 3 ? ' (otros números: escríbelos en la opción de texto)' : ''}`, {
674      header: 'Merge',
675      options,
676      ...(prs.length > 1 ? { multiSelect: true as const } : {}),
677    })
678    .catch(() => null)
679  const args = answer === null ? null : mergeArgsOf(answer, options)
680  if (!args) return
681  $.ui.toast(`/mefisto-merge ${args} en cola`)
682  await startMerge($, args)
683  await $.command.run({ command: 'mefisto-merge', args })
684}
685
686// La cinta de /mefisto-merge: la lista sale de los argumentos; con --all, de los PRs abiertos al momento.
687async function startMerge($: EngineInterface, args: string) {
688  const current = await read($, mergeAtom)
689  if (current && !current.finishedMs) return
690  let open = (await read($, openPrsAtom)) ?? []
691  if (/(^|\s)--all(\s|$)/.test(args)) {
692    const prs = await runInRepo($, ['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,title,isDraft,headRefName'])
693      .catch(() => ({ exitCode: 1, stdout: '' }))
694    open = (prs.exitCode === 0 ? parseOpenPrs(prs.stdout) : null) ?? open
695  }
696  const prs = mergePrsOf(args, open)
697  if (prs.length === 0) return
698  isMergeSeen = false
699  lastMergePollMs = Date.now()
700  await update($, mergeAtom, () => ({ prs, startedMs: Date.now(), finishedMs: null }))
701}
702
703async function settleMerge($: EngineInterface, merged: readonly string[], failed: readonly string[]) {
704  const prev = await read($, mergeAtom)
705  if (!prev || prev.finishedMs) return
706  const next = withMergeOutcomes(prev, merged, failed, Date.now())
707  if (next === prev) return
708  await update($, mergeAtom, () => next)
709  if (next.finishedMs) await endMerge($)
710}
711
712async function endMerge($: EngineInterface) {
713  const run = await read($, mergeAtom)
714  if (!run) return
715  const done = finishMerge(run, Date.now())
716  if (done !== run) await update($, mergeAtom, () => done)
717  $.ui.toast(`merge: ${mergeHeader(done, '')}`)
718  $.clock.after(HISTORIAN_DONE_MS, () => void update($, mergeAtom, m => (m?.finishedMs ? null : m)))
719  void refreshReady($)
720}
721
722// Respaldo: el estado de cada pendiente en GitHub, cada MERGE_POLL_MS.
723async function pollMerge($: EngineInterface) {
724  const run = await read($, mergeAtom)
725  if (!run || run.finishedMs || Date.now() - lastMergePollMs < MERGE_POLL_MS) return
726  lastMergePollMs = Date.now()
727  const pending = run.prs.filter(p => p.estado === 'pendiente').map(p => p.num)
728  const states = await Promise.all(
729    pending.map(n =>
730      runInRepo($, ['gh', 'pr', 'view', n, '--json', 'state', '-q', '.state']).catch(() => ({ exitCode: 1, stdout: '' })),
731    ),
732  )
733  const merged = pending.filter((_, i) => states[i]?.exitCode === 0 && states[i]?.stdout.trim() === 'MERGED')
734  if (merged.length > 0) await settleMerge($, merged, [])
735}
736
737// Integrar la bitacora: las field notes de main y las que esperan en sus PRs. Elegir en el dialogo es la aprobacion.
738async function chooseBitacora($: EngineInterface) {
739  const local = (await read($, fieldNotesAtom)) ?? 0
740  const prs = ((await read($, openPrsAtom)) ?? []).filter(p => p.isFieldNote).map(p => p.number)
741  if (local + prs.length === 0) return
742  const integrate = prs.length > 0 ? `Mergear sus ${prs.length} PRs e integrar` : 'Integrar'
743  const answer = await $.ui
744    .ask(`¿Integrar la bitácora? ${local} field notes en main${prs.length > 0 ? ` y ${prs.length} en PRs sin mergear` : ''}`, {
745      header: 'Bitácora',
746      options: [integrate, 'Cancelar'],
747    })
748    .catch(() => null)
749  if (answer !== integrate) return
750  await $.prompt.submit({ text: bitacoraPrompt(prs), asUser: true })
751}
752
753// Publicar un release con los fragmentos de changelog.d/: SemVer sugiere el bump; tambien se puede solo preparar.
754async function chooseRelease($: EngineInterface) {
755  const summary = await read($, changelogAtom)
756  if (!summary || summary.issues === 0) return
757  const counts = [
758    summary.added > 0 ? `${summary.added} added` : '',
759    summary.changed > 0 ? `${summary.changed} changed` : '',
760    summary.fixed > 0 ? `${summary.fixed} fixed` : '',
761    summary.removed > 0 ? `${summary.removed} removed` : '',
762  ].filter(Boolean).join(', ')
763  const answer = await $.ui
764    .ask(`¿Publicar un release? ${summary.issues} issues con cambios (${counts})`, { header: 'Release', options: releaseOptions(summary) })
765    .catch(() => null)
766  const args = answer === null ? null : releaseArgsOf(answer)
767  if (!args) return
768  $.ui.toast(`/mefisto-release ${args} en cola`)
769  await startRelease($, `/mefisto-release ${args}`)
770  await $.command.run({ command: 'mefisto-release', args })
771}
772
773// La cinta del release: la foto de changelog.d/ al arrancar, agrupada por issue, y las fases que recorre el script.
774async function startRelease($: EngineInterface, invocationText: string) {
775  const current = await read($, releaseAtom)
776  if (current && !current.finishedMs && Array.isArray(current.steps)) return
777  let version: string | null = null
778  try {
779    const parsed = JSON.parse(await $.fs.read(await inRepo($, PLUGIN_JSON)).catch(() => ''))
780    version = typeof parsed?.version === 'string' ? parsed.version : null
781  } catch {
782    version = null
783  }
784  const plan = releasePlan(version, releaseInvocationOf(invocationText))
785  const listed = await $.fs.list(await inRepo($, CHANGELOG_DIR)).catch(() => [])
786  const fragments = listed.filter(n => n.kind === 'file' && n.name !== 'README.md' && n.name.endsWith('.md')).map(n => n.name)
787  const issues = releaseIssuesOf(fragments)
788  const release: Release = {
789    phase: 'reposo',
790    startedMs: Date.now(),
791    finishedMs: null,
792    version: plan.version,
793    steps: plan.steps,
794    done: [],
795    failed: null,
796    issues,
797    fragments,
798    pr: null,
799    url: null,
800  }
801  isReleaseScriptSeen = false
802  lastReleasePollMs = 0
803  await update($, releaseAtom, () => release)
804  void fillReleaseTitles($, issues.map(i => i.issue))
805}
806
807// El titulo de cada issue de la foto, en paralelo; los que gh no devuelve se quedan con el numero solo.
808async function fillReleaseTitles($: EngineInterface, issues: string[]) {
809  const titles = await Promise.all(
810    issues.map(n =>
811      runInRepo($, ['gh', 'issue', 'view', n, '--json', 'title', '-q', '.title']).catch(() => ({ exitCode: 1, stdout: '' })),
812    ),
813  )
814  const byIssue = new Map(issues.map((n, i) => [n, titles[i]?.exitCode === 0 ? titles[i].stdout.trim() : '']))
815  await update($, releaseAtom, r =>
816    r && Array.isArray(r.issues) ? { ...r, issues: r.issues.map(i => (byIssue.get(i.issue) ? { ...i, title: byIssue.get(i.issue) ?? '' } : i)) } : r,
817  )
818}
819
820// Las fases por sondeo de GitHub, no por la salida del Bash (que llega entera al terminar el script).
821async function pollRelease($: EngineInterface, isForced = false) {
822  const release = await read($, releaseAtom)
823  if (!release || release.finishedMs || !release.version || !Array.isArray(release.steps)) return
824  if (!isForced && Date.now() - lastReleasePollMs < RELEASE_POLL_MS) return
825  lastReleasePollMs = Date.now()
826  const branch = releaseBranchOf(release.version)
827  const tag = releaseTagOf(release.version)
828  const pending = (step: ReleaseStep) => release.steps.includes(step) && !release.done.includes(step)
829  const none = Promise.resolve(null)
830  const probe = (argv: string[]) => runInRepo($, argv).catch(() => null)
831  const [heads, contents, prs, tags, published] = await Promise.all([
832    pending('rama') ? probe(['git', 'ls-remote', '--heads', 'origin', branch]) : none,
833    pending('consolidar') ? probe(['gh', 'api', `repos/{owner}/{repo}/contents/changelog.d?ref=${branch}`, '--jq', '.[].name']) : none,
834    pending('pr') || pending('merge') ? probe(['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', 'number,state']) : none,
835    pending('tag') ? probe(['git', 'ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) : none,
836    pending('release') ? probe(['gh', 'release', 'view', tag, '--json', 'url,isDraft']) : none,
837  ])
838  const observed: ReleaseStep[] = []
839  let pr = release.pr
840  let url = release.url
841  if (heads?.exitCode === 0 && heads.stdout.trim() !== '') observed.push('rama')
842  if (contents?.exitCode === 0 && isConsolidated(release.fragments, contents.stdout.split('\n').map(l => l.trim()).filter(Boolean))) {
843    observed.push('consolidar')
844  }
845  if (prs?.exitCode === 0) {
846    try {
847      const first = (JSON.parse(prs.stdout) as { number: number; state: string }[])[0]
848      if (first) {
849        pr = String(first.number)
850        observed.push(first.state === 'MERGED' ? 'merge' : 'pr')
851      }
852    } catch {
853      // salida inesperada: se reintenta en el siguiente sondeo
854    }
855  }
856  if (tags?.exitCode === 0 && tags.stdout.trim() !== '') observed.push('tag')
857  if (published?.exitCode === 0) {
858    try {
859      const info = JSON.parse(published.stdout) as { url?: string; isDraft?: boolean }
860      if (!info.isDraft) {
861        url = info.url ?? null
862        observed.push('release')
863      }
864    } catch {
865      // idem
866    }
867  }
868  await update($, releaseAtom, r => {
869    if (!r || r.finishedMs || !Array.isArray(r.steps)) return r
870    const next = withObservedSteps(r, observed)
871    return next === r && pr === r.pr && url === r.url ? r : { ...next, pr, url }
872  })
873}
874
875// Las cintas terminadas vencen al dibujar, sin depender de que su clock.after sobreviva un hot-reload; el siguiente
876// hook que puede escribir estado limpia el atom vencido. La bitacora con un PR abierto espera al usuario.
877async function liveTapes($: EngineInterface, nowMs: number) {
878  const historian = await read($, historianAtom)
879  return {
880    historian: liveTape(historian, HISTORIAN_DONE_MS, nowMs, Boolean(historian?.pr)),
881    release: liveTape(await read($, releaseAtom), RELEASE_DONE_MS, nowMs),
882    merge: liveTape(await read($, mergeAtom), HISTORIAN_DONE_MS, nowMs),
883  }
884}
885
886async function sweepTapes($: EngineInterface) {
887  const now = Date.now()
888  const live = await liveTapes($, now)
889  if (!live.historian && (await read($, historianAtom))) await update($, historianAtom, h => liveTape(h, HISTORIAN_DONE_MS, now, Boolean(h?.pr)))
890  if (!live.release && (await read($, releaseAtom))) await update($, releaseAtom, r => liveTape(r, RELEASE_DONE_MS, now))
891  if (!live.merge && (await read($, mergeAtom))) await update($, mergeAtom, m => liveTape(m, HISTORIAN_DONE_MS, now))
892}
893
894async function openRelease($: EngineInterface) {
895  const release = await read($, releaseAtom)
896  if (!release?.version) return
897  if (release.url) await runInRepo($, ['gh', 'release', 'view', releaseTagOf(release.version), '--web']).catch(() => undefined)
898  else if (release.pr) await runInRepo($, ['gh', 'pr', 'view', release.pr, '--web']).catch(() => undefined)
899}
900
901async function confirmStop($: EngineInterface) {
902  const current = (await read($, batchAtom))?.current
903  const answer = await $.ui
904    .ask(`¿Detener el sequential tras ${current ? `#${current}` : 'el issue en curso'}?`, {
905      header: 'Detener',
906      options: ['Detener tras el actual', 'Seguir'],
907    })
908    .catch(() => null)
909  if (answer === 'Detener tras el actual') await requestStop($)
910}
911
912async function nextReadyPage($: EngineInterface) {
913  const ready = await read($, readyAtom)
914  const total = ready ? readyRows(ready).length : 0
915  await update($, readyPageAtom, p => pageOf((p ?? 0) + 1, total, READY_ROWS).page)
916}
917
918export const register: Register = on => {
919  on('session.start', async ($, e, next) => {
920    isInteractive = e.isInteractive
921    await $.command.register({
922      name: 'mefisto-divine-wager',
923      description: 'Consola de Mefisto: sin argumentos abre el log; <issue> sigue esa corrida; batch <issues> sigue un sequential; merge | close | refresh',
924      argumentHint: '[issue|batch <issues>|merge|close|refresh]',
925      immediate: true,
926    })
927    // Tras un hot-reload el tail del modulo anterior murio: se fuerza a reabrirlo.
928    await update($, runAtom, run => (run && run.state === 'running' ? { ...run, eventsFile: null } : run))
929    await sweepTapes($)
930    $.clock.every(1000, () => void poll($))
931    if (isInteractive) void detectExecution($)
932    return next(e)
933  })
934
935  // El cohete del release: en reposo desde que se pide /mefisto-release, despegando mientras corre su script y
936  // listo cuando termina el turno que lo ejecuto.
937  on('prompt.submit', async ($, e, next) => {
938    await sweepTapes($)
939    if (isInteractive && isReleasePrompt(e.text)) await startRelease($, e.text)
940    const mergeArgs = isInteractive ? mergePromptArgs(e.text) : null
941    if (mergeArgs) await startMerge($, mergeArgs)
942    return next(e)
943  })
944
945  on('turn.complete', async ($, e, next) => {
946    // Solo cierra el turno que corrio el script: un /mefisto-release encolado arranca en un turno posterior.
947    const release = await read($, releaseAtom)
948    if (release && !release.finishedMs && (isReleaseScriptSeen || Date.now() - release.startedMs > MERGE_STALE_MS)) {
949      await pollRelease($, true)
950      await update($, releaseAtom, r => (r ? (Array.isArray(r.steps) ? finishRelease(r, Date.now()) : { ...r, phase: 'reposo', finishedMs: Date.now() }) : r))
951      const done = await read($, releaseAtom)
952      if (done?.version) $.ui.toast(releaseHeader(done, ''))
953      $.clock.after(RELEASE_DONE_MS, () => void update($, releaseAtom, r => (r?.finishedMs ? null : r)))
954      void refreshReady($)
955    }
956    // El turno de /mefisto-merge termino: lo que siga pendiente lo descarto el skill o no se llego a mergear.
957    const merge = await read($, mergeAtom)
958    if (merge && !merge.finishedMs && (isMergeSeen || Date.now() - merge.startedMs > MERGE_STALE_MS)) {
959      lastMergePollMs = 0
960      await pollMerge($)
961      await endMerge($)
962    }
963    return next(e)
964  })
965
966  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
967    await sweepTapes($)
968    if (isInteractive && isReleaseRun(e.command)) {
969      await startRelease($, e.command)
970      isReleaseScriptSeen = true
971      await update($, releaseAtom, r => (r ? { ...r, phase: 'despegando' } : r))
972      try {
973        return await next(e)
974      } finally {
975        await update($, releaseAtom, r => (r ? { ...r, phase: 'reposo' } : r))
976      }
977    }
978    if (isInteractive && isGhMergeRun(e.command)) {
979      const literal = ghMergeNumbersOf(e.command)
980      if (literal.length > 0) await startMerge($, literal.join(' '))
981      isMergeSeen = true
982      const ran = await next(e)
983      if (ran.deny === undefined) {
984        const { merged, failed } = mergeOutcomesOf(e.command, ran.text ?? '', ran.isError === true)
985        await settleMerge($, merged, failed)
986      }
987      return ran
988    }
989    // El instante se toma antes de correr: sin pane, la llamada vuelve cuando el batch ya avanzo (o por
990    // timeout), y un estado publicado antes de esa vuelta se descartaria como de un batch anterior.
991    const launchedMs = Date.now() - 5_000
992    const batchIssues = isInteractive ? batchIssuesOf(e.command) : null
993    if (batchIssues && batchIssues.length > 0) {
994      const ran = await next({ ...e, command: withModUi(e.command) })
995      if (ran.deny === undefined && ran.isError !== true) await watchBatch($, batchIssues, launchedMs)
996      return ran
997    }
998    const issue = isInteractive ? toolingIssueOf(e.command) : null
999    if (!issue) return next(e)
1000    const ran = await next({ ...e, command: withModUi(e.command) })
1001    if (ran.deny === undefined && ran.isError !== true) {
1002      await watch($, issue, launchedMs)
1003    }
1004    return ran
1005  })
1006
1007  on('command.run', { command: 'mefisto-divine-wager' }, async ($, e) => {
1008    const arg = e.args.trim()
1009    if (arg === 'close') {
1010      await closeRun($)
1011      return { text: 'Corrida cerrada.' }
1012    }
1013    if (arg === 'refresh') {
1014      await refreshReady($)
1015      return { text: 'Listos actualizados.' }
1016    }
1017    if (arg === 'merge') {
1018      await merge($)
1019      return { text: 'Merge solicitado.' }
1020    }
1021    // Reengancha un sequential ya lanzado (tras reiniciar la sesion): `/mefisto-divine-wager batch 12 13`.
1022    const batchArgs = /^batch((?:\s+#?\d+)+)$/.exec(arg)
1023    if (batchArgs?.[1]) {
1024      const issues = batchArgs[1].match(/\d+/g) ?? []
1025      await watchBatch($, issues, 0)
1026      return { text: `Siguiendo el sequential ${issues.map(n => `#${n}`).join(' ')} en la banda.` }
1027    }
1028    // La banda sigue la corrida; el pane es solo el log completo y se abre a pedido.
1029    if (/^#?\d+$/.test(arg)) {
1030      await watch($, arg.replace('#', ''), 0)
1031      return { text: `Siguiendo #${arg.replace('#', '')} en la banda.` }
1032    }
1033    await openPane($, true)
1034    return { text: 'Log abierto.' }
1035  })
1036
1037  // Cerrar el log solo cierra el pane: la banda conserva la corrida (y su PR) hasta que se cierre con 4.
1038  on('ui.close', { id: PANE }, async ($, e, next) => {
1039    isLogOpen = false
1040    return next(e)
1041  })
1042
1043  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1044    const run = await read($, runAtom)
1045    const batch = await read($, batchAtom)
1046    if (e.props.hasSurvey) return next(e)
1047    if (!run && !batch && !(await read($, executionAtom))) return next(e)
1048    const now = await read($, nowAtom)
1049    const { Box, Text, Button } = $.ui.resolve(e)
1050    if (batch) {
1051      // El sequential: avance y cola arriba, el eslabon en curso abajo con su mascota, pasos y ultimas lineas.
1052      const lines = await read($, linesAtom)
1053      const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
1054      const body = Math.max(30, inner - MASCOT_WIDTH - 2)
1055      const c = batchCounts(batch.issues)
1056      const end = batch.finishedMs ?? (now || Date.now())
1057      const isRunning = batch.state === 'running'
1058      const pr = batchPr(batch)
1059      const pose =
1060        isRunning && run ? mascotPose(run, lines[lines.length - 1])
1061        : batch.state === 'failed' ? { role: 'desarrollador' as const, state: 'error' }
1062        : isRunning ? { role: 'desarrollador' as const, state: 'pensando' }
1063        : { role: 'revisor' as const, state: 'aprobado' }
1064      const frame = isRunning ? (Math.floor((now || Date.now()) / 1000) % 2 === 0 ? 0 : 1) : 0
1065      const grid = cropGrid(sprite(pose.role, pose.state, frame), MASCOT_COLS)
1066      const elements = $.ui.resolve(e)
1067      const mascot =
1068        'Raster' in elements ? (
1069          <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
1070        ) : (
1071          <Box width={MASCOT_WIDTH} />
1072        )
1073      const hold = await read($, holdAtom)
1074      const recent = lines.filter(l => l.ts !== '').slice(-3)
1075      const markText = (m: ReturnType<typeof issueMark>) =>
1076        m === 'done' ? '✓' : m === 'failed' ? '✗' : m === 'deferred' ? '⏸' : m === 'current' ? '●' : '○'
1077      const markColor = (m: ReturnType<typeof issueMark>) =>
1078        m === 'done' ? ('success' as const) : m === 'failed' ? ('error' as const) : m === 'current' ? ('warning' as const) : undefined
1079      const failedIssue = batch.issues.find(i => issueMark(i.status) === 'failed')
1080      const doneRows = batch.issues.filter(i => ['done', 'failed'].includes(issueMark(i.status)))
1081      const stats = batch.stats ?? {}
1082      const titleW = summaryTitleWidth(inner)
1083      const doneStats = doneRows.map(i => stats[i.issue]).filter(st => st !== undefined)
1084      // Sin exigir `!run.finishedMs`: el historial se escribe antes del merge del batch, y la fila no debe desaparecer
1085      // mientras el issue espera su merge; ahi su tiempo queda congelado en el fin de la corrida.
1086      const currentRun =
1087        isRunning && run && run.issue === batch.current && !doneRows.some(i => i.issue === run.issue) ? run : null
1088      const currentMs = currentRun ? Math.max(0, (currentRun.finishedMs ?? (now || Date.now())) - currentRun.startedMs) : null
1089      const currentParts = currentRun ? currentRowParts(currentRun.issue, currentRun.title, prNumber(currentRun.pr), currentMs ?? 0, titleW) : null
1090      return (
1091        <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
1092          <Box justifyContent="space-between">
1093            <Text wrap="truncate-end">
1094              <Text color="claude">mefisto</Text>
1095              <Text> · sequential {c.done + c.failed}/{c.total}</Text>
1096              {isRunning && batch.stopRequested && <Text color="warning"> · se detiene tras #{batch.current ?? '…'}</Text>}
1097              {batch.state === 'completed' && <Text color="success"> · terminado</Text>}
1098              {batch.state === 'stopped' && <Text color="warning"> · detenido</Text>}
1099              {batch.state === 'failed' && <Text color="error"> · con fallos</Text>}
1100              <Text dimColor> {elapsed(end - batch.startedMs)}</Text>
1101            </Text>
1102            <Box gap={2}>
1103              {isRunning && !batch.stopRequested && (
1104                <Button key="batch-stop" hotkey="1" plain label="detener" onPress={() => void confirmStop($)} />
1105              )}
1106              {pr && <Button key="batch-pr" hotkey="2" plain label={`ver PR #${pr}`} onPress={() => void openPr($)} />}
1107              <Button key="batch-log" hotkey="3" plain dimColor={isRunning} label="log" onPress={() => void toggleLog($)} />
1108              {!isRunning && <Button key="batch-close" hotkey="4" plain label="cerrar" onPress={() => void closeRun($)} />}
1109            </Box>
1110          </Box>
1111          <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
1112            {mascot}
1113            <Box flexDirection="column" width={body} marginTop={1}>
1114              <Text wrap="truncate-end">
1115                {batch.issues.map((i, k) => {
1116                  const m = issueMark(i.status)
1117                  return (
1118                    <Text color={markColor(m)} dimColor={m === 'pending' || m === 'deferred'}>
1119                      {k > 0 ? ' ─ ' : ''}
1120                      {markText(m)} #{i.issue}
1121                      {m === 'current' && run && run.issue === i.issue ? ` ${stageName(run.stage)}` : ''}
1122                    </Text>
1123                  )
1124                })}
1125              </Text>
1126              {isRunning && run && run.title !== '' && <Text dimColor wrap="truncate-end">#{run.issue} {run.title}</Text>}
1127              {isRunning && hold && <Text color="warning" wrap="truncate-end">{holdText(hold)}</Text>}
1128              {isRunning && run && !hold && <Text> </Text>}
1129              {isRunning && !run && <Text dimColor>preparando el primer issue…</Text>}
1130              {isRunning &&
1131                recent.map(line => (
1132                  <Text wrap="truncate-end" color={lineColor(line)} dimColor={line.kind === 'text'}>
1133                    {`${line.ts} ${line.kind === 'text' ? '» ' : ''}${line.text}`}
1134                  </Text>
1135                ))}
1136              {!isRunning && <Text wrap="truncate-end">{batchSummary(batch)}</Text>}
1137              {!isRunning && failedIssue && (
1138                <Text color="error" wrap="truncate-end">#{failedIssue.issue} {failedIssue.status.replace(/^ERROR:\s*/, '')}</Text>
1139              )}
1140            </Box>
1141          </Box>
1142          {(doneRows.length > 0 || currentParts) && (
1143            <Box flexDirection="column">
1144              {doneRows.map(i => {
1145                const st = stats[i.issue]
1146                const m = issueMark(i.status)
1147                return (
1148                  <Text wrap="truncate-end" color={markColor(m)}>
1149                    {`${markText(m)} ${padEnd(`#${i.issue}`, 7)} ${titleW > 0 ? `${fitTitle(st?.title, titleW)} ` : ''}${padEnd(i.pr ? `PR #${i.pr}` : 'sin PR', 10)} `}
1150                    <Text dimColor>{st ? `${padEnd(elapsed(st.durationMs), 8)} ${fmtCost(st.costUsd)}` : '…'}</Text>
1151                  </Text>
1152                )
1153              })}
1154              {currentParts && (
1155                <Text wrap="truncate-end" color="warning">
1156                  {currentParts.head}
1157                  <Text dimColor>{currentParts.tail}</Text>
1158                </Text>
1159              )}
1160              {doneRows.length + (currentParts ? 1 : 0) > 1 && (
1161                <Text dimColor wrap="truncate-end">
1162                  {totalRowText(doneStats, currentMs, titleW)}
1163                </Text>
1164              )}
1165            </Box>
1166          )}
1167        </Box>
1168      )
1169    }
1170    if (!run) {
1171      const ready = await read($, readyAtom)
1172      const openPrs = (await read($, openPrsAtom)) ?? []
1173      const workPrs = openPrs.length
1174      const pendingNotes = ((await read($, fieldNotesAtom)) ?? 0) + openPrs.filter(p => p.isFieldNote).length
1175      const changelog = await read($, changelogAtom)
1176      const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
1177      const body = Math.max(30, inner - MASCOT_WIDTH - 2)
1178      const { page, pages } = pageOf(await read($, readyPageAtom), ready ? readyRows(ready).length : 0, READY_ROWS)
1179      const rows = ready ? readyRows(ready) : []
1180      const visible = rows.slice(page * READY_ROWS, (page + 1) * READY_ROWS)
1181      const numWidth = Math.max(4, ...visible.map(i => String(i.number).length + 1))
1182      const afterWidth = Math.min(24, Math.max(0, ...visible.map(i => i.reason.length)))
1183      // El prefijo '5: ' de la tecla, el numero, un espacio y, si alguno depende de otro, la columna 'tras #N'.
1184      const titleWidth = Math.max(12, body - 3 - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
1185      const more = ready
1186        ? [
1187            ready.cycleCount > 0 ? `${ready.cycleCount} en ciclo` : '',
1188          ].filter(Boolean).join(' · ')
1189        : ''
1190      // En espera de que se lance algo; mientras Claude trabaja en la sesion, mira de lado a lado y corre la arena.
1191      // Si el historiador escribe la bitacora, es el quien aparece, con la pluma en movimiento.
1192      const tapes = await liveTapes($, now || Date.now())
1193      const historian = tapes.historian
1194      const tick = e.props.isWorking ? Math.floor((now || Date.now()) / 1000) : null
1195      const release = tapes.release
1196      // La cinta de merge cose al ritmo del reloj aunque el turno este en un Bash largo.
1197      const mergeRun = tapes.merge
1198      const mergeTick = mergeRun && !mergeRun.finishedMs ? Math.floor((now || Date.now()) / 1000) : tick
1199      const frame = (mergeTick ?? 0) % 2 === 0 ? 0 : 1
1200      const grid = mergeRun
hooks/logic.ts 901 lines
1import type { BitacoraNote, BitacoraPr, BatchIssue, BatchRun, ChangelogSummary, Historian, Hold, IssueStats, MergePr, MergeRun, OpenPr, BlockedItem, LogLine, Release, ReleaseIssue, ReleaseStep, 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
40// Cada issue es un argumento completo: el `2` de un `2>&1` posterior no es un issue.
41const BATCH_LAUNCH = new RegExp(`${AT_COMMAND}${WRAPPER}\\s+--batch((?:\\s+#?\\d+(?=\\s|$|[;&|)]))+)`)
42
43/** Los issues de un `mefisto-tmux-pipeline.sh --batch N M ...` (lo que corre /mefisto-sequential), en orden. */
44export function batchIssuesOf(command: string): string[] | null {
45  const m = BATCH_LAUNCH.exec(withoutHeredocs(command))
46  return m?.[1] ? (m[1].match(/\d+/g) ?? []) : null
47}
48
49/**
50 * Pone MEFISTO_UI=mod justo delante de cada invocacion del wrapper: corre el pipeline sin pane y este mod es el
51 * visor (MEF-ADR-0055). Al inicio del comando no basta: /mefisto-sequential lanza un comando compuesto
52 * (`validador ...; ... && MEFISTO_RUNTIME=claude ./mefisto-tmux-pipeline.sh --batch ...`) y la asignacion solo
53 * llegaria al primer comando.
54 */
55export function withModUi(command: string): string {
56  if (/(^|\s)MEFISTO_UI=/.test(command)) return command
57  return command.replace(new RegExp(`(${AT_COMMAND})(${WRAPPER})`, 'g'), '$1MEFISTO_UI=mod $2')
58}
59
60export function stampToMs(stamp: string): number {
61  const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(stamp)
62  if (!m) return 0
63  const [y = 0, mo = 1, d = 1, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
64  return new Date(y, mo - 1, d, h, mi, s).getTime()
65}
66
67type StatusFile = {
68  issue: string
69  title?: string
70  stage?: string
71  state?: string
72  started?: string
73  agents?: Record<string, RunAgent>
74  pr?: string | null
75  last_error?: string | null
76}
77
78export function runFromStatus(raw: string, prev: PipelineRun | null): PipelineRun | null {
79  let s: StatusFile
80  try {
81    s = JSON.parse(raw)
82  } catch {
83    return prev
84  }
85  const state = s.state === 'failed' ? 'failed' : s.state === 'completed' ? 'completed' : 'running'
86  return {
87    issue: String(s.issue),
88    title: s.title ?? prev?.title ?? '',
89    stage: s.stage ?? 'setup',
90    state,
91    startedMs: s.started ? stampToMs(s.started) : (prev?.startedMs ?? 0),
92    finishedMs: state === 'running' ? null : (prev?.finishedMs ?? Date.now()),
93    agents: s.agents ?? {},
94    pr: s.pr ?? null,
95    lastError: s.last_error ?? null,
96    eventsFile: prev?.eventsFile ?? null,
97  }
98}
99
100type HistoryEntry = {
101  issue: string
102  title?: string
103  pipeline?: string
104  started?: string
105  finished?: string
106  state?: string
107  stage?: string | null
108  pr?: string | null
109  error?: string | null
110}
111
112/** La ultima linea del historial de esta corrida: mismo issue, arrancada en o despues de `sinceMs`. */
113export function finishedFromHistory(tail: string, issue: string, sinceMs: number): HistoryEntry | null {
114  const lines = tail.split('\n').filter(Boolean).reverse()
115  for (const line of lines) {
116    let h: HistoryEntry
117    try {
118      h = JSON.parse(line)
119    } catch {
120      continue
121    }
122    if (h.pipeline !== 'mefisto-tooling' || String(h.issue) !== issue) continue
123    if (h.started && stampToMs(h.started) + 1000 < sinceMs) return null
124    return h
125  }
126  return null
127}
128
129const EVENTS = /^mefisto-tooling-stage-(\d+|merge)-([a-z-]+?)-(\d{8}-\d{6})-issue-(\d+)(?:-[^.]+)?(?:\.attempt-\d+)?\.events\.jsonl$/
130
131export type Entry = { name: string; mtimeMs: number }
132
133export function pickEventsFile(entries: Entry[], issue: string, sinceMs: number): string | null {
134  let best: Entry | null = null
135  for (const entry of entries) {
136    const m = EVENTS.exec(entry.name)
137    if (!m || m[4] !== issue) continue
138    if (stampToMs(m[3] ?? '') + 1000 < sinceMs) continue
139    if (!best || entry.mtimeMs > best.mtimeMs) best = entry
140  }
141  return best?.name ?? null
142}
143
144export function agentOfEventsFile(name: string): string {
145  return EVENTS.exec(name)?.[2] ?? ''
146}
147
148export const clip = (text: string, max: number) => {
149  const one = text.replace(/\s+/g, ' ').trim()
150  return one.length > max ? `${one.slice(0, max - 1)}…` : one
151}
152
153const WORKTREE = /\S*\/worktree-[^/\s]+\//g
154
155export const relative = (text: string) => text.replace(WORKTREE, '')
156
157const clock = (ts: unknown) => {
158  if (typeof ts !== 'string') return ''
159  const d = new Date(ts)
160  if (Number.isNaN(d.getTime())) return ''
161  const p = (n: number) => String(n).padStart(2, '0')
162  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
163}
164
165/** Filtro del visor (mefisto-stream-watch.sh) portado: una linea por evento relevante del contrato run-events v1. */
166export function parseEvent(raw: string): LogLine | null {
167  let ev: Record<string, any>
168  try {
169    ev = JSON.parse(raw)
170  } catch {
171    return null
172  }
173  const ts = clock(ev.ts)
174  switch (ev.type) {
175    case 'run.started':
176      return { ts, kind: 'start', text: `${ev.agent ?? 'agente'} · ${ev.model ?? ''}`.trim() }
177    case 'message':
178      if (ev.role !== 'assistant' || typeof ev.text !== 'string' || ev.text.trim() === '') return null
179      return { ts, kind: 'text', text: clip(ev.text, 400) }
180    case 'tool.started':
181      return { ts, kind: 'tool', text: clip(`${ev.tool ?? '?'} ${relative(String(ev.input_summary ?? ''))}`, 400) }
182    case 'tool.completed':
183      return ev.ok === false ? { ts, kind: 'fail', text: `${ev.tool ?? '?'} falló` } : null
184    case 'run.completed': {
185      const cost = typeof ev.estimated_cost_usd === 'number' ? ` · $${ev.estimated_cost_usd.toFixed(2)}` : ''
186      const turns = typeof ev.turns === 'number' ? ` · ${ev.turns} turnos` : ''
187      return { ts, kind: ev.status === 'success' ? 'done' : 'fail', text: `${ev.status ?? 'fin'}${turns}${cost}` }
188    }
189    case 'run.failed':
190      return { ts, kind: 'fail', text: clip(String(ev.error ?? 'fallo'), 400) }
191    default:
192      return null
193  }
194}
195
196export type Step = { name: string; mark: 'done' | 'current' | 'failed' | 'pending' }
197
198const STAGES = [
199  { id: 'setup', name: 'setup' },
200  { id: '1-writer', name: 'writer' },
201  { id: '2-reviewer', name: 'reviewer' },
202  { id: 'done', name: 'done' },
203]
204
205export function steps(run: PipelineRun): Step[] {
206  const stage = run.stage === 'merge-writer' ? '2-reviewer' : run.stage
207  const at = Math.max(0, STAGES.findIndex(s => s.id === stage))
208  return STAGES.map((s, i) => {
209    if (run.state === 'completed') return { name: s.name, mark: 'done' }
210    if (i < at) return { name: s.name, mark: 'done' }
211    if (i === at) return { name: s.name, mark: run.state === 'failed' ? 'failed' : 'current' }
212    return { name: s.name, mark: 'pending' }
213  })
214}
215
216export function elapsed(ms: number): string {
217  const total = Math.max(0, Math.floor(ms / 1000))
218  const h = Math.floor(total / 3600)
219  const m = Math.floor((total % 3600) / 60)
220  const s = total % 60
221  const p = (n: number) => String(n).padStart(2, '0')
222  return h > 0 ? `${h}:${p(m)}:${p(s)}` : `${p(m)}:${p(s)}`
223}
224
225export function prNumber(pr: string | null): string | null {
226  return pr ? (/(\d+)\/?$/.exec(pr)?.[1] ?? null) : null
227}
228
229export type MascotPose = { role: 'desarrollador' | 'revisor'; state: string }
230
231const EDITS = /^(Edit|Write|MultiEdit|NotebookEdit)\b/
232
233/** El rol sale del stage y la pose del ultimo evento: herramienta trabaja, texto piensa, edicion del reviewer corrige. */
234export function mascotPose(run: PipelineRun, last: LogLine | undefined): MascotPose {
235  if (run.state === 'completed') return { role: 'revisor', state: 'aprobado' }
236  if (run.state === 'failed') return { role: 'desarrollador', state: 'error' }
237  const role = run.stage === '2-reviewer' ? 'revisor' : 'desarrollador'
238  if (!last || last.kind !== 'tool') return { role, state: 'pensando' }
239  if (role === 'revisor' && EDITS.test(last.text)) return { role, state: 'corrigiendo' }
240  return { role, state: 'trabajando' }
241}
242
243type Grid = readonly string[]
244
245/** Columnas con algun pixel visible en cualquiera de los cuadros: recorta el margen sin que la mascota salte. */
246export function usedColumns(grids: readonly Grid[]): { from: number; to: number } {
247  let from = Infinity
248  let to = -1
249  for (const g of grids) {
250    for (const row of g) {
251      for (let x = 0; x < row.length; x++) {
252        const c = row[x]
253        if (c !== '.' && c !== ' ') {
254          from = Math.min(from, x)
255          to = Math.max(to, x)
256        }
257      }
258    }
259  }
260  return to < 0 ? { from: 0, to: 0 } : { from, to }
261}
262
263export function cropGrid(grid: Grid, cols: { from: number; to: number }): Grid {
264  return grid.map(row => row.slice(cols.from, cols.to + 1).padEnd(cols.to - cols.from + 1, '.'))
265}
266
267export const NEXT_ORDER = './.claude/scripts/mefisto-next-order.sh'
268export const PLANNER_AGENT = 'mefisto-planner'
269/** Filas fijas de la lista de listos: la banda no cambia de alto con la cantidad de issues. */
270export const READY_ROWS = 5
271
272type NextOrderJson = {
273  items?: { number: number; title: string; after?: number[] }[]
274  blocked?: { number?: number; by?: number }[]
275  cycles?: unknown[]
276  launch?: string | null
277}
278
279/** Exit 0 (hay orden) y 1 (vacio) traen JSON valido; 2 es un fallo de gh o de argumentos. */
280export function parseNextOrder(exitCode: number, stdout: string, stderr: string, titles: Record<number, string> = {}): ReadyList {
281  const empty = { items: [], blocked: [], cycleCount: 0, launch: null }
282  if (exitCode !== 0 && exitCode !== 1) {
283    return { ...empty, error: stderr.split('\n').find(l => l.trim() !== '')?.trim() || `exit ${exitCode}` }
284  }
285  let json: NextOrderJson
286  try {
287    json = JSON.parse(stdout)
288  } catch {
289    return { ...empty, error: 'salida de next-order no es JSON' }
290  }
291  return {
292    items: (json.items ?? []).map(i => ({ number: i.number, title: i.title, after: i.after ?? [] })),
293    blocked: blockedOf(json.blocked ?? [], titles),
294    cycleCount: (json.cycles ?? []).length,
295    launch: json.launch ?? null,
296    error: null,
297  }
298}
299
300/** next-order da una fila por par issue-dependencia: se agrupan por issue para contar issues, no pares. */
301function blockedOf(rows: { number?: number; by?: number }[], titles: Record<number, string>): BlockedItem[] {
302  const out: BlockedItem[] = []
303  for (const r of rows) {
304    if (typeof r.number !== 'number') continue
305    const item = out.find(b => b.number === r.number) ?? out[out.push({ number: r.number, title: titles[r.number] ?? '', by: [] }) - 1]
306    if (item && typeof r.by === 'number' && !item.by.includes(r.by)) item.by.push(r.by)
307  }
308  return out
309}
310
311export type ReadyRow = { number: number; title: string; reason: string; isLaunchable: boolean }
312
313/**
314 * Todos los estado:listo de la banda: primero los lanzables en el orden de next-order y al final los bloqueados,
315 * visibles para armar un sequential a mano pero atenuados y sin tecla.
316 */
317export function readyRows(list: ReadyList): ReadyRow[] {
318  return [
319    ...list.items.map(i => ({ number: i.number, title: i.title, reason: reasonOf(i), isLaunchable: true })),
320    ...list.blocked.map(b => ({ number: b.number, title: b.title, reason: '', isLaunchable: false })),
321  ]
322}
323
324/** Titulos de los estado:listo abiertos (`gh issue list --json number,title`): next-order no los da para los bloqueados. */
325export function titlesOf(stdout: string): Record<number, string> {
326  try {
327    const rows = JSON.parse(stdout) as { number: number; title: string }[]
328    return Object.fromEntries(rows.map(r => [r.number, r.title]))
329  } catch {
330    return {}
331  }
332}
333
334/** Todo se lanza por /mefisto-sequential, aunque sea un solo issue: la cadena mergea y sincroniza main. */
335export const sequentialOf = (issue: number) => `/mefisto-sequential ${issue}`
336
337/** Un solo issue sin merge automatico: /mefisto-tooling deja el PR abierto para revisarlo. */
338export const toolingOf = (issue: number) => `/mefisto-tooling ${issue}`
339
340export function reasonOf(item: ReadyItem): string {
341  return item.after.length > 0 ? `tras ${item.after.map(n => `#${n}`).join(' ')}` : ''
342}
343
344export function padEnd(text: string, width: number): string {
345  return text.length >= width ? text : text + ' '.repeat(width - text.length)
346}
347
348/** Titulo recortado con `…` a `width` columnas y rellenado a ese ancho (vacio si no hay titulo o no hay espacio). */
349export function fitTitle(title: string | null | undefined, width: number): string {
350  if (width <= 0) return ''
351  const t = (title ?? '').trim()
352  return padEnd(t.length > width ? `${t.slice(0, width - 1)}…` : t, width)
353}
354
355/** 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. */
356export function summaryTitleWidth(inner: number): number {
357  const w = Math.min(60, inner - 38)
358  return w < 8 ? 0 : w
359}
360
361/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
362export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
363  const pages = Math.max(1, Math.ceil(total / size))
364  return { page: page >= 0 && page < pages ? page : 0, pages }
365}
366
367/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
368export function agentFlagOf(cmdline: string): string | null {
369  const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
370  return m ? (m[1] ?? null) : null
371}
372
373/**
374 * Mefisto en espera, sobre la cara 'normal': boca plana corrida a un lado y un reloj de arena a la izquierda.
375 * Mientras Claude trabaja en la sesion, en cada tick los ojos miran de un lado al otro y la arena cambia de
376 * mitad; en reposo mira al frente con la arena arriba.
377 */
378const HOURGLASS = [
379  ['YYYY', 'gyyg', '.yg.', 'g..g', 'g..g', 'YYYY'],
380  ['YYYY', 'g..g', '.gg.', 'g..g', 'gyyg', 'YYYY'],
381] as const
382
383export function waitingFace(base: Grid, tick: number | null): Grid {
384  const eyes =
385    tick === null ? ['.....rrEErrEErr.tt', '.....rrEErrEErr..t']
386    : tick % 2 === 0 ? ['.....rEErrEErrr.tt', '.....rEErrEErrr..t']
387    : ['.....rrrEErrEEr.tt', '.....rrrEErrEEr..t']
388  const glass = HOURGLASS[tick !== null && tick % 2 === 1 ? 1 : 0]
389  return base.map((row, y) => {
390    let out = row
391    if (y === 6 || y === 7) out = eyes[y - 6] ?? out
392    if (y === 9) out = '......rrrrMMMr..t.'
393    const g = y >= 6 ? glass[y - 6] : undefined
394    if (g) out = [...out].map((c, x) => (x < g.length && g[x] !== '.' ? g[x] : c)).join('')
395    return out
396  })
397}
398
399type BatchStatusFile = {
400  started?: string
401  state?: string
402  current?: string | null
403  stop_requested?: boolean
404  hold_seconds?: number
405  issues?: { issue: string; status: string; pr: string | null }[]
406}
407
408/** El estado publicado del batch; un archivo de un batch anterior (arrancado antes de `sinceMs`) se ignora. */
409export function batchFromStatus(raw: string, prev: BatchRun, sinceMs: number): BatchRun {
410  let s: BatchStatusFile
411  try {
412    s = JSON.parse(raw)
413  } catch {
414    return prev
415  }
416  const startedMs = s.started ? stampToMs(s.started) : 0
417  if (startedMs + 1000 < sinceMs) return prev
418  const state = s.state === 'completed' || s.state === 'failed' || s.state === 'stopped' ? s.state : 'running'
419  return {
420    issues: (s.issues ?? []).map(i => ({ issue: String(i.issue), status: i.status ?? '', pr: i.pr ?? null })),
421    state,
422    current: s.current ? String(s.current) : null,
423    stopRequested: s.stop_requested === true || prev.stopRequested,
424    holdSeconds: s.hold_seconds ?? 0,
425    startedMs: startedMs || prev.startedMs,
426    finishedMs: state === 'running' ? null : (prev.finishedMs ?? Date.now()),
427    stats: prev.stats ?? {},
428  }
429}
430
431export type IssueMark = 'done' | 'current' | 'failed' | 'deferred' | 'pending'
432
433export function issueMark(status: string): IssueMark {
434  if (status.startsWith('completado')) return 'done'
435  if (status.startsWith('ERROR')) return 'failed'
436  if (status.startsWith('aplazado')) return 'deferred'
437  if (status.startsWith('en curso')) return 'current'
438  return 'pending'
439}
440
441export function batchCounts(issues: BatchIssue[]) {
442  const marks = issues.map(i => issueMark(i.status))
443  return {
444    total: issues.length,
445    done: marks.filter(m => m === 'done').length,
446    failed: marks.filter(m => m === 'failed').length,
447    deferred: marks.filter(m => m === 'deferred').length,
448  }
449}
450
451/** Los issues que pasaron a mergeados entre dos lecturas: un toast por cada uno. */
452export function newlyMerged(prev: BatchIssue[], next: BatchIssue[]): BatchIssue[] {
453  return next.filter(i => issueMark(i.status) === 'done' && issueMark(prev.find(p => p.issue === i.issue)?.status ?? '') !== 'done')
454}
455
456/** El PR para abrir: el del issue en curso si ya lo tiene, o el del ultimo mergeado. */
457export function batchPr(batch: BatchRun): string | null {
458  const current = batch.issues.find(i => i.issue === batch.current)
459  if (current?.pr) return current.pr
460  return [...batch.issues].reverse().find(i => i.pr)?.pr ?? null
461}
462
463/** Resumen del cierre: mergeados, fallidos, aplazados y la espera por rate limit si la hubo. */
464export function batchSummary(batch: BatchRun): string {
465  const c = batchCounts(batch.issues)
466  const hold = batch.holdSeconds > 0 ? `espera ${Math.round(batch.holdSeconds / 60)}m` : ''
467  return [
468    `✓ ${c.done} mergeados`,
469    c.failed > 0 ? `✗ ${c.failed} fallidos` : '',
470    c.deferred > 0 ? `⏸ ${c.deferred} aplazados` : '',
471    hold,
472  ].filter(Boolean).join(' · ')
473}
474
475type StatsEntry = {
476  issue?: string
477  pipeline?: string
478  started?: string
479  finished?: string
480  title?: string
481  agents?: Record<string, { metrics?: { estimated_cost_usd?: number | null } }>
482}
483
484/**
485 * Duracion y costo de las corridas de tooling de estos issues, desde el final del historial: la ultima de cada
486 * issue arrancada en o despues de `sinceMs`. El costo suma el estimado de cada agente (null si ninguno lo da).
487 */
488export function issueStatsFromHistory(tail: string, issues: string[], sinceMs: number): Record<string, IssueStats> {
489  const out: Record<string, IssueStats> = {}
490  for (const line of tail.split('\n')) {
491    let h: StatsEntry
492    try {
493      h = JSON.parse(line)
494    } catch {
495      continue
496    }
497    const issue = String(h.issue ?? '')
498    if (h.pipeline !== 'mefisto-tooling' || !issues.includes(issue) || !h.started || !h.finished) continue
499    const startedMs = stampToMs(h.started)
500    const finishedMs = new Date(h.finished).getTime()
501    if (startedMs + 1000 < sinceMs || Number.isNaN(finishedMs)) continue
502    const costs = Object.values(h.agents ?? {})
503      .map(a => a.metrics?.estimated_cost_usd)
504      .filter((c): c is number => typeof c === 'number')
505    out[issue] = {
506      durationMs: Math.max(0, finishedMs - startedMs),
507      costUsd: costs.length > 0 ? costs.reduce((a, b) => a + b, 0) : null,
508      title: typeof h.title === 'string' && h.title !== '' ? h.title : null,
509    }
510  }
511  return out
512}
513
514/** Issues sin estadisticas o con estadisticas sin titulo: un titulo vacio en cache no se corrige solo. */
515export function statsPending(issues: readonly string[], stats: Record<string, IssueStats>): string[] {
516  return issues.filter(issue => !stats[issue] || stats[issue].title === null)
517}
518
519export const fmtCost = (usd: number | null) => (usd === null ? '$?' : `$${usd.toFixed(2)}`)
520
521/** Total de lo terminado: duracion sumada y costo sumado (sin contar los que no lo informan). */
522export function statsTotal(stats: IssueStats[]): IssueStats {
523  const costs = stats.map(s => s.costUsd).filter((c): c is number => c !== null)
524  return {
525    durationMs: stats.reduce((a, s) => a + s.durationMs, 0),
526    costUsd: costs.length > 0 ? costs.reduce((a, b) => a + b, 0) : null,
527    title: null,
528  }
529}
530
531/** Fila `●` del issue en curso: mismas columnas que los terminados; costo `…` (solo se conoce al escribirse el historial). */
532export function currentRowParts(
533  issue: string,
534  title: string | null | undefined,
535  pr: string | null,
536  elapsedMs: number,
537  titleW: number,
538): { head: string; tail: string } {
539  const head = `● ${padEnd(`#${issue}`, 7)} ${titleW > 0 ? `${fitTitle(title, titleW)} ` : ''}${padEnd(pr ? `PR #${pr}` : '…', 10)} `
540  return { head, tail: `${padEnd(elapsed(elapsedMs), 8)} …` }
541}
542
543/** Fila `total` con el issue en curso: cuenta todas las filas, suma su tiempo y, en costo, solo los terminados. */
544export function totalRowText(done: IssueStats[], currentMs: number | null, titleW: number): string {
545  const total = statsTotal(done)
546  const count = done.length + (currentMs === null ? 0 : 1)
547  const duration = total.durationMs + (currentMs ?? 0)
548  return `  total ${padEnd(String(count), 12 + (titleW > 0 ? titleW + 1 : 0))} ${padEnd(elapsed(duration), 8)} ${fmtCost(total.costUsd)}`
549}
550
551/**
552 * PRs abiertos (`gh pr list --json number,title,isDraft,headRefName`) sin los borradores, el mas reciente primero.
553 * Los de field notes (rama `docs/<agente>-field-note-<sesion>` de mefisto-field-note.sh) se marcan aparte.
554 */
555export function parseOpenPrs(stdout: string): OpenPr[] | null {
556  try {
557    const rows = JSON.parse(stdout) as { number: number; title: string; isDraft?: boolean; headRefName?: string }[]
558    return rows
559      .filter(r => !r.isDraft)
560      .map(r => ({ number: r.number, title: r.title, isFieldNote: /-field-note-/.test(r.headRefName ?? '') }))
561      .sort((a, b) => b.number - a.number)
562  } catch {
563    return null
564  }
565}
566
567export const MERGE_ALL = 'Todos (--all)'
568
569/** Opciones del dialogo de merge (2-4): todos y los PRs mas recientes; con uno solo, ese y cancelar. */
570export function mergeOptions(prs: OpenPr[]): string[] {
571  const label = (p: OpenPr) => clip(`#${p.number} ${p.title}`, 60)
572  if (prs.length === 1 && prs[0]) return [label(prs[0]), 'Cancelar']
573  return [MERGE_ALL, ...prs.slice(0, 3).map(label)]
574}
575
576/**
577 * Los argumentos de /mefisto-merge que salen de la respuesta (las opciones marcadas, unidas por ", "): --all si
578 * se eligio todos; de cada opcion solo su #N (el titulo puede traer fechas); del texto libre, sus numeros.
579 * Null si no queda ningun PR.
580 */
581export function mergeArgsOf(answer: string, options: string[]): string | null {
582  if (answer.includes(MERGE_ALL)) return '--all'
583  let rest = answer
584  const numbers: string[] = []
585  for (const option of options) {
586    if (!rest.includes(option)) continue
587    rest = rest.replace(option, '')
588    const n = /^#(\d+)/.exec(option)?.[1]
589    if (n) numbers.push(n)
590  }
591  numbers.push(...(rest.match(/\d+/g) ?? []))
592  const unique = [...new Set(numbers)]
593  return unique.length > 0 ? unique.join(' ') : null
594}
595
596/** Field notes en `docs/bitacora/field-notes/` sin procesar (las de `procesadas/` quedan fuera del listado). */
597export function fieldNotesIn(names: string[]): number {
598  return names.filter(n => n.endsWith('.md')).length
599}
600
601/** Resumen de `changelog.d/`: issues con fragmentos (sin README) y entradas por categoria de Keep a Changelog. */
602export function changelogOf(names: string[]): ChangelogSummary {
603  const fragments = names.filter(n => n !== 'README.md' && n.endsWith('.md'))
604  const by = (cat: string) => fragments.filter(n => n.endsWith(`.${cat}.md`)).length
605  return {
606    issues: new Set(fragments.map(n => n.split('.')[0])).size,
607    added: by('added'),
608    changed: by('changed'),
609    fixed: by('fixed'),
610    removed: by('removed'),
611  }
612}
613
614/** SemVer: algo agregado pide minor; solo cambios, arreglos o retiros, patch. El recomendado va primero. */
615export function releaseOptions(summary: ChangelogSummary): string[] {
616  const bump = summary.added > 0 ? 'minor' : 'patch'
617  const other = bump === 'minor' ? 'patch' : 'minor'
618  return [`${bump} (recomendado)`, other, `Solo preparar el PR (${bump} --prepare-only)`]
619}
620
621/** Los argumentos de /mefisto-release de la opcion elegida, o null. */
622export function releaseArgsOf(answer: string): string | null {
623  const bump = /\b(major|minor|patch)\b/.exec(answer)?.[1]
624  if (!bump) return null
625  return answer.includes('--prepare-only') ? `${bump} --prepare-only` : bump
626}
627
628export const RELEASE_STEPS: readonly ReleaseStep[] = ['rama', 'consolidar', 'pr', 'merge', 'tag', 'release']
629const RELEASE_STEP_LABEL: Record<ReleaseStep, string> = {
630  rama: 'rama', consolidar: 'consolidar', pr: 'PR', merge: 'merge', tag: 'tag', release: 'GitHub Release',
631}
632const KEEP_CATEGORIES = ['added', 'changed', 'fixed', 'removed']
633
634/** El bump y el modo de un /mefisto-release o de su script, leidos de sus argumentos. Sin bump es la fase publish. */
635export function releaseInvocationOf(text: string): { bump: 'major' | 'minor' | 'patch' | null; prepareOnly: boolean } {
636  const args = withoutHeredocs(text).replace(/^[\s\S]*?(?:\/mefisto-release|mefisto-release\.sh)\b/, '')
637  const bump = /^[^;&|\n]*?\b(major|minor|patch)\b/.exec(args)?.[1] as 'major' | 'minor' | 'patch' | undefined
638  return { bump: bump ?? null, prepareOnly: /^[^;&|\n]*--prepare-only\b/.test(args) }
639}
640
641/** Mismo calculo que bump_version de mefisto-release.sh. */
642export function bumpVersion(current: string, part: 'major' | 'minor' | 'patch'): string | null {
643  const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(current.trim())
644  if (!m) return null
645  const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]
646  if (part === 'major') return `${major + 1}.0.0`
647  if (part === 'minor') return `${major}.${minor + 1}.0`
648  return `${major}.${minor}.${patch + 1}`
649}
650
651/** Nombres de mefisto-release.sh: RELEASE_BRANCH="release/${NEW_TAG}" y NEW_TAG="v${NEW_VERSION}". */
652export const releaseTagOf = (version: string) => `v${version}`
653export const releaseBranchOf = (version: string) => `release/${releaseTagOf(version)}`
654
655/**
656 * Version destino y fases que recorre la corrida: con bump prepara desde la version de plugin.json; sin bump
657 * plugin.json ya trae la version a publicar y solo quedan tag y release; --prepare-only se detiene en el PR.
658 */
659export function releasePlan(current: string | null, invocation: { bump: 'major' | 'minor' | 'patch' | null; prepareOnly: boolean }) {
660  if (!current) return { version: null, steps: [...RELEASE_STEPS] }
661  if (!invocation.bump) return { version: current.trim(), steps: RELEASE_STEPS.slice(4) }
662  const version = bumpVersion(current, invocation.bump)
663  return { version, steps: invocation.prepareOnly ? RELEASE_STEPS.slice(0, 3) : [...RELEASE_STEPS] }
664}
665
666/** Los issues con fragmentos en changelog.d/ (sin README.md), en orden numerico, con sus categorias Keep a Changelog. */
667export function releaseIssuesOf(names: string[]): ReleaseIssue[] {
668  const by = new Map<string, string[]>()
669  for (const name of names) {
670    const [, issue, category] = /^(\d+)\.([a-z-]+)\.md$/.exec(name) ?? []
671    if (!issue || !category) continue
672    const categories = by.get(issue) ?? []
673    if (KEEP_CATEGORIES.includes(category) && !categories.includes(category)) categories.push(category)
674    by.set(issue, categories)
675  }
676  return [...by.entries()]
677    .sort((a, b) => Number(a[0]) - Number(b[0]))
678    .map(([issue, categories]) => ({ issue, title: '', categories: KEEP_CATEGORIES.filter(c => categories.includes(c)) }))
679}
680
681/** Los fragmentos de la foto ya no estan en la rama de release: la consolidacion los volco a CHANGELOG.md. */
682export function isConsolidated(snapshot: readonly string[], remote: readonly string[]): boolean {
683  return snapshot.length > 0 && snapshot.every(name => !remote.includes(name))
684}
685
686/**
687 * Las fases son monotonas y en orden: observar una implica las anteriores (la rama se borra al mergear, y un
688 * sondeo puede saltarse una fase rapida). Devuelve el mismo objeto si nada avanzo.
689 */
690export function withObservedSteps(release: Release, observed: readonly ReleaseStep[]): Release {
691  const last = Math.max(-1, ...observed.map(step => release.steps.indexOf(step)))
692  const done = release.steps.slice(0, Math.max(last + 1, release.done.length))
693  return done.length === release.done.length ? release : { ...release, done }
694}
695
696/** La fase en curso: la primera de esta corrida que no se observo; null si todas estan hechas. */
697export function currentReleaseStep(release: Release): ReleaseStep | null {
698  return release.steps.find(step => !release.done.includes(step)) ?? null
699}
700
701/** El checklist de fases en una linea: ✓ hechas, ● la en curso, ✗ la que fallo, sin marca las que faltan. */
702export function releaseChecklist(release: Release): { step: ReleaseStep; label: string; mark: '✓' | '●' | '✗' | '·' }[] {
703  const current = release.finishedMs ? null : currentReleaseStep(release)
704  return release.steps.map(step => ({
705    step,
706    label: RELEASE_STEP_LABEL[step],
707    mark: release.done.includes(step) ? '✓' : release.failed === step ? '✗' : step === current ? '●' : '·',
708  }))
709}
710
711/** El encabezado de la cinta: version y estado, o la fase que fallo. */
712export function releaseHeader(release: Release, elapsedText: string): string {
713  const name = release.version ? `release ${releaseTagOf(release.version)}` : 'release'
714  if (release.failed) return `${name} falló en ${RELEASE_STEP_LABEL[release.failed]}`
715  if (release.finishedMs) {
716    const last = release.steps[release.steps.length - 1]
717    if (last === 'pr' && release.pr) return `${name} · PR #${release.pr} listo`
718    return `${name} publicado`
719  }
720  return `${name} ${release.phase === 'despegando' ? 'despegando' : 'en preparación'} ${elapsedText}`.trimEnd()
721}
722
723/** Al terminar el turno: la fase en curso queda con ✗ si la corrida no las completo todas. */
724export function finishRelease(release: Release, nowMs: number): Release {
725  if (release.finishedMs) return release
726  return { ...release, phase: 'reposo', finishedMs: nowMs, failed: currentReleaseStep(release) }
727}
728
729/**
730 * La instruccion para integrar la bitacora. El historiador lee las field notes del checkout local, asi que antes
731 * hay que mergear los PRs de field notes que esperan y traer main al dia (las de PRs ya mergeados viven en
732 * origin/main hasta el pull).
733 */
734export function bitacoraPrompt(fieldNotePrs: number[]): string {
735  const merge = fieldNotePrs.length > 0
736    ? `mergea los PRs de field notes ${fieldNotePrs.map(n => `#${n}`).join(' ')} con /mefisto-merge, `
737    : ''
738  return `Integra la bitacora: ${merge}pon el checkout en main al dia (git switch main && git pull --ff-only) y luego corre /mefisto-bitacora.`
739}
740
741const HOLD = /^\[\d{2}:\d{2}:\d{2}\]\[hold\] (\S+): esperando, proxima sonda (\d{2}:\d{2})(?::\d{2})? \(techo (\d{2}:\d{2})\)/
742
743/**
744 * La espera en curso: la ultima linea de events.log es un `[hold]` (mefisto-tooling-pipeline.sh la escribe en
745 * cada sonda; cualquier actividad posterior, incluido `[hold][resume]`, la da por terminada).
746 */
747export function holdOf(tail: string): Hold | null {
748  const last = tail.split('\n').filter(l => l.trim() !== '').pop() ?? ''
749  const m = HOLD.exec(last)
750  return m ? { family: m[1] ?? '', nextProbe: m[2] ?? '', deadline: m[3] ?? '' } : null
751}
752
753export const holdText = (hold: Hold) => `en espera por ${hold.family} · próxima sonda ${hold.nextProbe} · techo ${hold.deadline}`
754
755const AGENT_ACTIVE = new Set(['pending', 'running', 'waiting'])
756
757/** Un agente de `$.agent.list()` que aun no termina (AgentStatus). */
758export const isAgentActive = (status: string) => AGENT_ACTIVE.has(status)
759
760/**
761 * El historiador corre en primer o en segundo plano: se sigue su estado en `$.agent.list()`, no la llamada Agent,
762 * que en segundo plano vuelve en cuanto arranca. Devuelve `prev` tal cual si nada cambio.
763 */
764export function historianFrom(prev: Historian | null, active: boolean, now: number): Historian | null {
765  if (active) return prev && !prev.finishedMs ? prev : { startedMs: now, finishedMs: null, notes: [], pr: null }
766  return prev && !prev.finishedMs ? { ...prev, finishedMs: now } : prev
767}
768
769/**
770 * La cinta tal como se dibuja: terminada hace mas de `doneMs` no existe, aunque su atom siga ahi porque el
771 * `clock.after` que la limpiaba murio con un hot-reload. En curso nunca vence, ni la que espera al usuario.
772 */
773export function liveTape<T extends { finishedMs: number | null }>(tape: T | null, doneMs: number, nowMs: number, isAwaitingUser = false): T | null {
774  if (!tape || !tape.finishedMs || isAwaitingUser) return tape
775  return nowMs - tape.finishedMs > doneMs ? null : tape
776}
777
778/** Los argumentos del mensaje que lanza /mefisto-merge (`''` sin argumentos), o null si no es ese comando. */
779export function mergePromptArgs(text: string): string | null {
780  const m = /^\/mefisto-merge(?:\s+([\s\S]*))?$/.exec(text.trim())
781  return m ? (m[1] ?? '').trim() : null
782}
783
784/**
785 * Los PRs que va a coser /mefisto-merge: con --all, todos los abiertos (los mismos que cuenta la
786 * tecla 2); si no, los numeros de los argumentos, con el titulo que se conozca de los abiertos.
787 */
788export function mergePrsOf(args: string, open: OpenPr[]): MergePr[] {
789  const pending = (num: string, title: string): MergePr => ({ num, title, estado: 'pendiente' })
790  if (/(^|\s)--all(\s|$)/.test(args)) return open.map(p => pending(String(p.number), p.title))
791  const nums = [...new Set(args.match(/\d+/g) ?? [])]
792  return nums.map(n => pending(n, open.find(p => String(p.number) === n)?.title ?? ''))
793}
794
795const GH_MERGE = new RegExp(`${AT_COMMAND}gh\\s+pr\\s+merge\\s+#?(\\d+)`, 'g')
796
797/** Los PRs que el comando mergea con un numero literal (`gh pr merge 12`); el bucle de /mefisto-merge usa "$pr" y no cuenta. */
798export function ghMergeNumbersOf(command: string): string[] {
799  return [...withoutHeredocs(command).matchAll(GH_MERGE)].map(m => m[1] ?? '').filter(Boolean)
800}
801
802/** El comando corre `gh pr merge`, con numero literal o con variable. */
803export const isGhMergeRun = (command: string) => new RegExp(`${AT_COMMAND}gh\\s+pr\\s+merge\\b`).test(withoutHeredocs(command))
804
805/**
806 * Lo que un Bash termino diciendo de cada PR: el `gh pr merge N` literal sale con exit 0 (mergeado) o no (fallido,
807 * si era el unico del comando); de la salida, el aviso de gh al mergear y el `Fallo al mergear #N` del bucle.
808 */
809export function mergeOutcomesOf(command: string, output: string, isError: boolean): { merged: string[]; failed: string[] } {
810  const failed = new Set([...output.matchAll(/Fallo al mergear #(\d+)/g)].map(m => m[1] ?? ''))
811  const merged = new Set([...output.matchAll(/merged pull request \S*#(\d+)/gi)].map(m => m[1] ?? ''))
812  const literal = ghMergeNumbersOf(command)
813  if (!isError) {
814    for (const n of literal) if (!failed.has(n)) merged.add(n)
815  } else if (literal.length === 1 && literal[0] && !merged.has(literal[0])) {
816    failed.add(literal[0])
817  }
818  for (const n of merged) failed.delete(n)
819  return { merged: [...merged].filter(Boolean), failed: [...failed].filter(Boolean) }
820}
821
822/** Marca los PRs; un mergeado no vuelve atras. Termina la cinta cuando no queda ninguno pendiente. */
823export function withMergeOutcomes(run: MergeRun, merged: readonly string[], failed: readonly string[], now: number): MergeRun {
824  let changed = false
825  const prs = run.prs.map(p => {
826    const estado = merged.includes(p.num) ? 'mergeado' : p.estado === 'pendiente' && failed.includes(p.num) ? 'fallido' : p.estado
827    if (estado === p.estado) return p
828    changed = true
829    return { ...p, estado }
830  })
831  if (!changed) return run
832  const isDone = prs.every(p => p.estado !== 'pendiente')
833  return { ...run, prs, finishedMs: isDone ? (run.finishedMs ?? now) : null }
834}
835
836/** Cierre de la cinta: lo que quedo pendiente cuenta como fallido (el skill lo descarto o no llego a mergearlo). */
837export function finishMerge(run: MergeRun, now: number): MergeRun {
838  if (run.finishedMs) return run
839  return { ...run, prs: run.prs.map(p => (p.estado === 'pendiente' ? { ...p, estado: 'fallido' } : p)), finishedMs: now }
840}
841
842export function mergeCounts(run: MergeRun) {
843  return {
844    pending: run.prs.filter(p => p.estado === 'pendiente').length,
845    merged: run.prs.filter(p => p.estado === 'mergeado').length,
846    failed: run.prs.filter(p => p.estado === 'fallido').length,
847  }
848}
849
850/** La cabecera de la cinta: cosiendo mientras queden pendientes; al terminar, mergeados y fallidos. */
851export function mergeHeader(run: MergeRun, elapsedText: string): string {
852  const c = mergeCounts(run)
853  if (!run.finishedMs) return `cosiendo ${c.pending} PR${c.pending === 1 ? '' : 's'} ${elapsedText}`
854  return [`${c.merged} mergeado${c.merged === 1 ? '' : 's'}`, c.failed > 0 ? `✗ ${c.failed} fallido${c.failed === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')
855}
856
857const FIELD_NOTE_PATH = /^docs\/bitacora\/field-notes\/([^/]+\.md)$/
858
859/**
860 * Las field notes que va a integrar el historiador: las de `docs/bitacora/field-notes/` (sin `procesadas/`, que el
861 * listado no baja) y las de los PRs de field notes sin mergear (rutas de `gh pr view --json files`), sin repetir.
862 */
863export function fieldNoteNamesOf(local: readonly string[], prPaths: readonly string[]): string[] {
864  const fromPrs = prPaths.map(p => FIELD_NOTE_PATH.exec(p)?.[1]).filter((n): n is string => !!n)
865  return [...new Set([...local.filter(n => n.endsWith('.md')), ...fromPrs])].sort()
866}
867
868export const pendingNotes = (names: readonly string[]): BitacoraNote[] => names.map(name => ({ name, estado: 'pendiente' }))
869
870/** Pasa a procesada cada nota que ya aparece en algun `procesadas/`; devuelve `h` tal cual si nada cambio. */
871export function withProcessedNotes<T extends { notes: BitacoraNote[] }>(h: T, processed: readonly string[]): T {
872  const done = new Set(processed)
873  if (!h.notes.some(n => n.estado === 'pendiente' && done.has(n.name))) return h
874  return { ...h, notes: h.notes.map(n => (n.estado === 'pendiente' && done.has(n.name) ? { ...n, estado: 'procesada' } : n)) }
875}
876
877/** Los `procesadas/` donde puede caer una nota: el del checkout y el de cada worktree de mefisto-bitacora-worktree.sh. */
878export function processedDirsOf(summaries: readonly string[]): string[] {
879  return [
880    'docs/bitacora/field-notes/procesadas',
881    ...summaries.filter(n => n.startsWith('bitacora-')).map(n => `.mefisto/pipeline/summaries/${n}/docs/bitacora/field-notes/procesadas`),
882  ]
883}
884
885/**
886 * El PR de bitacora abierto (`gh pr list --json number,title,headRefName,files`): toca solo `docs/bitacora/` y no es
887 * de field notes (rama `-field-note-`, que tambien cae ahi). El mas reciente si hay varios.
888 */
889export function bitacoraPrOf(stdout: string): BitacoraPr | null {
890  try {
891    const rows = JSON.parse(stdout) as { number: number; title: string; headRefName?: string; files?: { path: string }[] }[]
892    const pr = rows
893      .filter(r => !/-field-note-/.test(r.headRefName ?? ''))
894      .filter(r => (r.files ?? []).length > 0 && (r.files ?? []).every(f => f.path.startsWith('docs/bitacora/')))
895      .sort((a, b) => b.number - a.number)[0]
896    return pr ? { number: pr.number, title: pr.title } : null
897  } catch {
898    return null
899  }
900}
901
hooks/sprites.ts 880 lines
1/**
2 * mefisto-sprites.ts
3 * Mascota Mefisto para mods de Claude Code.
4 *
5 * Cada sprite es una cuadrícula de 18×12 píxeles escrita como texto:
6 * cada carácter es un píxel y apunta a un color de PALETTE.
7 *   '.'  píxel transparente (en EMOTIONS)
8 *   ' '  "no tocar" (en las capas de ROLES: deja ver lo de abajo)
9 *
10 * Un sprite se arma así:  emoción (cara)  +  capa del rol (objeto y detalles)
11 * Cada estado tiene dos cuadros, A (0) y B (1), que se alternan con cada instrucción.
12 *
13 * Salidas:
14 *   toRasterCells(grid) -> celdas empacadas para el elemento Raster (terminal), 18 columnas × 6 filas
15 *   toSvg(grid)         -> documento SVG para el elemento Svg (app de escritorio)
16 *   ONE_LINERS          -> versión de una línea para la status line (elemento Text)
17 */
18
19export type Grid = readonly string[]
20export type Frame = 0 | 1
21
22export const WIDTH = 18
23export const HEIGHT = 12
24/** Filas que ocupa el sprite en la terminal (dos píxeles por fila con ▀ / ▄). */
25export const RASTER_ROWS = HEIGHT / 2
26
27/** Color por defecto de la terminal (transparente), según la documentación de Raster. */
28export const DEFAULT_COLOR = 0x01000000
29
30/**
31 * Paleta: letra -> color 0xRRGGBB. Los mods internos (este repo) usan un Mefisto blanco perla con cuernos dorados para no confundirse
32 * con los del consumidor, que conservan el rojo de mefisto-sprites.ts (cuernos/cola 0xA32D2D, cuerpo 0xE24B4A,
33 * boca 0x501313).
34 */
35export const PALETTE: Record<string, number> = {
36  "h": 0xD9A441, // cuernos (dorados)
37  "r": 0xEEEDF5, // cuerpo
38  "t": 0xD9A441, // cola (dorada)
39  "E": 0x2C2C2A, // ojos / pupilas
40  "M": 0x4A4A5E, // boca y cejas
41  "W": 0xF1EFE8, // colmillos
42  "p": 0xD4537E, // corazones y lengua
43  "s": 0xB4B2A9, // humo (enojado)
44  "B": 0x85B7EB, // Z y gota de sudor
45  "Y": 0xEF9F27, // bombillo, casco, flecha
46  "y": 0xFAC775, // destellos
47  "w": 0xFFF6D9, // brillo del bombillo
48  "G": 0x888780, // casquillo del bombillo
49  "o": 0xB4B2A9, // bombillo apagado
50  "D": 0x5F5E5A, // marco del portátil
51  "U": 0x378ADD, // pantalla
52  "u": 0xB5D4F4, // líneas de código
53  "q": 0xB4B2A9, // teclado / renglones
54  "Q": 0xFFFFFF, // blanco (pantalla, hoja)
55  "R": 0xE24B4A, // error / marcas rojas
56  "V": 0x639922, // verde de éxito
57  "g": 0x888780, // vidrio del matraz
58  "L": 0x97C459, // líquido del matraz
59  "l": 0xEAF3DE, // reflejo del líquido
60  "b": 0xC0DD97, // burbujas
61  "P": 0x7F77DD, // marco de la lupa
62  "e": 0xEEEDFE, // brillo de la lupa
63  "n": 0x854F0B, // mango de la lupa
64  "m": 0xE8C98E, // mapa claro
65  "j": 0xC9A866, // mapa oscuro
66  "Z": 0x444441, // ruta del mapa
67  "O": 0xD85A30, // destino del mapa
68  "c": 0xB5D4F4, // nube
69  "C": 0x888780, // nube caída
70}
71
72export type Emotion =
73  | 'normal'
74  | 'feliz'
75  | 'sonriente'
76  | 'enojado'
77  | 'dormido'
78  | 'asustado'
79  | 'pensando'
80  | 'preocupado'
81  | 'concentrado'
82
83/** Caras base: [cuadro A, cuadro B]. Solo 'pensando' cambia entre cuadros (bombillo encendido / apagado). */
84export const EMOTIONS: Record<Emotion, readonly [Grid, Grid]> = (() => {
85  const normalA: Grid = [
86    '..................',
87    '..................',
88    '....h..........h..',
89    '....hh........hh..',
90    '.....hrrrrrrrrh...',
91    '.....rrrrrrrrrr..t',
92    '.....rrEErrEErr.tt',
93    '.....rrEErrEErr..t',
94    '.....rrrrrrrrrr..t',
95    '......rrMMMMrr..t.',
96    '.......rrrrrrtt...',
97    '.......rr..rr.....',
98  ]
99  const felizA: Grid = [
100    '......p.p..p.p....',
101    '......ppp..ppp....',
102    '....h..p....p..h..',
103    '....hh........hh..',
104    '.....hrrrrrrrrh...',
105    '.....rrrrrrrrrr..t',
106    '.....rrEErrEErr.tt',
107    '.....rrrrrrrrrr..t',
108    '.....rMrrrrrrMr..t',
109    '......rMMWWMMr..t.',
110    '.......rrrrrrtt...',
111    '.......rr..rr.....',
112  ]
113  const sonrienteA: Grid = [
114    '..................',
115    '..................',
116    '....h..........h..',
117    '....hh........hh..',
118    '.....hrrrrrrrrh...',
119    '.....rrrrrrrrrr..t',
120    '.....rrEErrEErr.tt',
121    '.....rrrrrrrrrr..t',
122    '.....rMrrrrrrMr..t',
123    '......rMMWWMMr..t.',
124    '.......rrrrrrtt...',
125    '.......rr..rr.....',
126  ]
127  const enojadoA: Grid = [
128    '.....s.s......s.s.',
129    '......s........s..',
130    '....h..........h..',
131    '....hh........hh..',
132    '.....hrrrrrrrrh...',
133    '.....rMrrrrrrMr..t',
134    '.....rrMMrrMMrr.tt',
135    '.....rrEErrEErr..t',
136    '.....rrrrrrrrrr..t',
137    '......rrMMMMrr..t.',
138    '.......rMrrMrtt...',
139    '.......rr..rr.....',
140  ]
141  const dormidoA: Grid = [
142    '...........BBB....',
143    '............B.....',
144    '....h......BBB.h..',
145    '....hh........hh..',
146    '.....hrrrrrrrrh...',
147    '.....rrrrrrrrrr..t',
148    '.....rrrrrrrrrr.tt',
149    '.....rEErrrrEEr..t',
150    '.....rrrrrrrrrr..t',
151    '......rrrMMrrr..t.',
152    '.......rrrrrrtt...',
153    '.......rr..rr.....',
154  ]
155  const asustadoA: Grid = [
156    '.......y......y...',
157    '........y....y....',
158    '....h..........h..',
159    '....hh........hh..',
160    '.....hrMMrrMMrhB..',
161    '.....rrrrrrrrrrB.t',
162    '.....rrWWrrWWrr.tt',
163    '.....rrWErrWErr..t',
164    '.....rrrrMMrrrr..t',
165    '......rrrMMrrr..t.',
166    '.......rrrrrrtt...',
167    '.......rr..rr.....',
168  ]
169  const pensandoA: Grid = [
170    '......y..YY..y....',
171    '........YwYY......',
172    '....h...YYYY...h..',
173    '....hh...GG...hh..',
174    '.....hrrrrrrrrh...',
175    '.....rrrrrrrrrr..t',
176    '.....rrrEErrEEr.tt',
177    '.....rrrrrrrrrr..t',
178    '.....rrrrrrrrrr..t',
179    '......rrrMMrrr..t.',
180    '.......rrrrrrtt...',
181    '.......rr..rr.....',
182  ]
183  const pensandoB: Grid = [
184    '.........oo.......',
185    '........oooo......',
186    '....h...oooo...h..',
187    '....hh...GG...hh..',
188    '.....hrrrrrrrrh...',
189    '.....rrrrrrrrrr..t',
190    '.....rrrEErrEEr.tt',
191    '.....rrrrrrrrrr..t',
192    '.....rrrrrrrrrr..t',
193    '......rrrMMrrr..t.',
194    '.......rrrrrrtt...',
195    '.......rr..rr.....',
196  ]
197  const preocupadoA: Grid = [
198    '.................R',
199    '.................R',
200    '....h..........h..',
201    '....hh........hh.R',
202    '.....hrrrrrrrrh...',
203    '....BrrrMrrMrrr..t',
204    '....BrrMrrrrMrr.tt',
205    '.....rrEErrEErr..t',
206    '.....rrrrrrrrrr..t',
207    '......rrrMrMrr..t.',
208    '.......rMrMrrtt...',
209    '.......rr..rr.....',
210  ]
211  const concentradoA: Grid = [
212    '..................',
213    '..................',
214    '....h..........h..',
215    '....hh........hh..',
216    '.....hrrrrrrrrh...',
217    '.....rrrrrrrrrr..t',
218    '.....rrEErrMMrr.tt',
219    '.....rrEErrEErr..t',
220    '.....rrrrrrrrrr..t',
221    '......rrrMMrrr..t.',
222    '.......rrrprrtt...',
223    '.......rr..rr.....',
224  ]
225  return {
226    normal: [normalA, normalA],
227    feliz: [felizA, felizA],
228    sonriente: [sonrienteA, sonrienteA],
229    enojado: [enojadoA, enojadoA],
230    dormido: [dormidoA, dormidoA],
231    asustado: [asustadoA, asustadoA],
232    pensando: [pensandoA, pensandoB],
233    preocupado: [preocupadoA, preocupadoA],
234    concentrado: [concentradoA, concentradoA],
235  }
236})()
237
238export type Role = 'desarrollador' | 'tester' | 'revisor' | 'planner' | 'infraestructura' | 'historiador' | 'release' | 'merge'
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  // Merge · cremallera: el hilo de la rama (azul) y el de main (verde) bajan y se cierran diente a diente; los
693  // dientes corren hacia abajo mientras /mefisto-merge trabaja y en listo queda cerrada de punta a punta (mods internos)
694  const merge_cosiendo_A: Grid = [
695    'U   V             ',
696    'U   V             ',
697    ' U V              ',
698    ' U V              ',
699    ' UV               ',
700    ' VU               ',
701    ' UV               ',
702    ' VU               ',
703    ' UV               ',
704    ' VU               ',
705    ' UV               ',
706    '                  ',
707  ]
708  const merge_cosiendo_B: Grid = [
709    'U   V             ',
710    ' U V              ',
711    ' U V              ',
712    ' UV               ',
713    ' VU               ',
714    ' UV               ',
715    ' VU               ',
716    ' UV               ',
717    ' VU               ',
718    ' UV               ',
719    ' VU               ',
720    '                  ',
721  ]
722  const merge_listo_A: Grid = [
723    'y  y              ',
724    ' UV               ',
725    ' VU               ',
726    ' UV               ',
727    ' VU               ',
728    'yUVy              ',
729    ' VU               ',
730    ' UV               ',
731    ' VU               ',
732    ' UV               ',
733    ' VU               ',
734    '                  ',
735  ]
736
737  return {
738    desarrollador: {
739      trabajando: { emotion: 'normal', layers: [desarrollador_trabajando_A, desarrollador_trabajando_B] },
740      pensando: { emotion: 'pensando', layers: [desarrollador_pensando_A, desarrollador_pensando_A] },
741      error: { emotion: 'preocupado', layers: [desarrollador_error_A, desarrollador_error_B] },
742      resuelto: { emotion: 'sonriente', layers: [desarrollador_resuelto_A, desarrollador_resuelto_A] },
743    },
744    tester: {
745      trabajando: { emotion: 'normal', layers: [tester_trabajando_A, tester_trabajando_B] },
746      pensando: { emotion: 'pensando', layers: [tester_pensando_A, tester_pensando_A] },
747      feliz: { emotion: 'feliz', layers: [tester_feliz_A, tester_feliz_B] },
748    },
749    revisor: {
750      trabajando: { emotion: 'normal', layers: [revisor_trabajando_A, revisor_trabajando_B] },
751      pensando: { emotion: 'pensando', layers: [revisor_pensando_A, revisor_pensando_A] },
752      corrigiendo: { emotion: 'concentrado', layers: [revisor_corrigiendo_A, revisor_corrigiendo_B] },
753      aprobado: { emotion: 'sonriente', layers: [revisor_aprobado_A, revisor_aprobado_A] },
754    },
755    planner: {
756      planeando: { emotion: 'normal', layers: [planner_planeando_A, planner_planeando_B] },
757      pensando: { emotion: 'pensando', layers: [planner_pensando_A, planner_pensando_A] },
758      listo: { emotion: 'sonriente', layers: [planner_listo_A, planner_listo_A] },
759    },
760    infraestructura: {
761      desplegando: { emotion: 'normal', layers: [infraestructura_desplegando_A, infraestructura_desplegando_B] },
762      caido: { emotion: 'asustado', layers: [infraestructura_caido_A, infraestructura_caido_A] },
763      arriba: { emotion: 'sonriente', layers: [infraestructura_arriba_A, infraestructura_arriba_A] },
764    },
765    historiador: {
766      escribiendo: { emotion: 'normal', layers: [historiador_escribiendo_A, historiador_escribiendo_B] },
767      listo: { emotion: 'sonriente', layers: [historiador_listo_A, historiador_listo_A] },
768    },
769    release: {
770      reposo: { emotion: 'normal', layers: [release_reposo_A, release_reposo_A] },
771      despegando: { emotion: 'sonriente', layers: [release_despegando_A, release_despegando_B] },
772      listo: { emotion: 'feliz', layers: [release_reposo_A, release_reposo_A] },
773    },
774    merge: {
775      cosiendo: { emotion: 'concentrado', layers: [merge_cosiendo_A, merge_cosiendo_B] },
776      listo: { emotion: 'sonriente', layers: [merge_listo_A, merge_listo_A] },
777    },
778  }
779})()
780
781/** Versión de una línea por emoción, para la status line. */
782export const ONE_LINERS: Record<Emotion, string> = {
783  normal: "ψ(•‿•)",
784  feliz: "ψ(^▽^)♥",
785  sonriente: "ψ(^▽^)",
786  enojado: "ψ(ಠ益ಠ)",
787  dormido: "ψ(-.-)zz",
788  asustado: "ψ(°o°)!",
789  pensando: "ψ(•_•)💡",
790  preocupado: "ψ(°~°);",
791  concentrado: "ψ(•ᴗ<)",
792}
793
794/** Pone una capa encima de una cuadrícula. En la capa, ' ' significa "no tocar". */
795export function overlay(base: Grid, layer: Grid): Grid {
796  return base.map((row, y) => {
797    const top = layer[y] ?? ''
798    let out = ''
799    for (let x = 0; x < WIDTH; x++) {
800      const c = top[x]
801      out += c && c !== ' ' ? c : row[x] ?? '.'
802    }
803    return out
804  })
805}
806
807/** Arma el sprite de un rol en un estado y un cuadro. */
808export function sprite(role: Role, state: string, frame: Frame = 0): Grid {
809  const def = ROLES[role]?.[state]
810  if (!def) throw new Error(`Estado desconocido: ${role}.${state}`)
811  return overlay(EMOTIONS[def.emotion][frame], def.layers[frame])
812}
813
814/** Sprite solo con la cara, sin objeto de rol. */
815export function face(emotion: Emotion, frame: Frame = 0): Grid {
816  return EMOTIONS[emotion][frame]
817}
818
819function colorOf(ch: string | undefined): number | null {
820  if (!ch || ch === '.' || ch === ' ') return null
821  const c = PALETTE[ch]
822  if (c === undefined) throw new Error(`Letra sin color en PALETTE: '${ch}'`)
823  return c
824}
825
826const UPPER = '▀'.codePointAt(0)!
827const LOWER = '▄'.codePointAt(0)!
828const SPACE = ' '.codePointAt(0)!
829
830/**
831 * Convierte una cuadrícula en las celdas empacadas que recibe el elemento Raster.
832 * Cada celda junta dos píxeles verticales: ▀ con el de arriba como color y el de abajo como fondo.
833 * Resultado: columns = WIDTH (18), rows = RASTER_ROWS (6).
834 */
835export function toRasterCells(grid: Grid): string {
836  const numbers: number[] = []
837  for (let y = 0; y < HEIGHT; y += 2) {
838    for (let x = 0; x < WIDTH; x++) {
839      const top = colorOf(grid[y]?.[x])
840      const bottom = colorOf(grid[y + 1]?.[x])
841      if (top !== null && bottom !== null) numbers.push(UPPER, top, bottom)
842      else if (top !== null) numbers.push(UPPER, top, DEFAULT_COLOR)
843      else if (bottom !== null) numbers.push(LOWER, bottom, DEFAULT_COLOR)
844      else numbers.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
845    }
846  }
847  const bytes = new Uint8Array(Uint32Array.from(numbers).buffer)
848  const anyBytes = bytes as Uint8Array & { toBase64?: () => string }
849  if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
850  let bin = ''
851  for (const b of bytes) bin += String.fromCharCode(b)
852  return btoa(bin)
853}
854
855/** Props listas para Raster({ ...rasterProps(grid, 'mefisto') }). */
856export function rasterProps(grid: Grid, key: string) {
857  return { key, columns: WIDTH, rows: RASTER_ROWS, cells: toRasterCells(grid) }
858}
859
860/** Convierte una cuadrícula en un documento SVG (para la app de escritorio). */
861export function toSvg(grid: Grid, scale = 6): string {
862  const rects: string[] = []
863  grid.forEach((row, y) => {
864    let x = 0
865    while (x < WIDTH) {
866      const ch = row[x]
867      const c = colorOf(ch)
868      if (c === null) { x++; continue }
869      let end = x
870      while (end < WIDTH && row[end] === ch) end++
871      const hex = '#' + c.toString(16).padStart(6, '0')
872      rects.push(`<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${hex}"/>`)
873      x = end
874    }
875  })
876  const w = WIDTH * scale
877  const h = HEIGHT * scale
878  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">${rects.join('')}</svg>`
879}
880
types/index.d.ts 113 lines
1export 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/** Una field note de la cinta de la bitacora: pendiente hasta que aparece en `procesadas/`. */
50export type BitacoraNote = { name: string; estado: 'pendiente' | 'procesada' }
51
52/** El PR de bitacora que dejo el historiador, abierto a la espera de mergearlo o cerrarlo. */
53export type BitacoraPr = { number: number; title: string }
54
55/** El subagente mefisto-historiador escribiendo la bitacora en esta sesion (lo lanza /mefisto-bitacora). */
56export type Historian = { startedMs: number; finishedMs: number | null; notes: BitacoraNote[]; pr: BitacoraPr | null }
57
58/** Una fase de mefisto-release.sh, en el orden en que ocurren. */
59export type ReleaseStep = 'rama' | 'consolidar' | 'pr' | 'merge' | 'tag' | 'release'
60
61/** Un issue con fragmentos en changelog.d/ al arrancar el release, y sus categorias de Keep a Changelog. */
62export type ReleaseIssue = { issue: string; title: string; categories: string[] }
63
64/**
65 * Un /mefisto-release en esta sesion: en reposo mientras se prepara, despegando mientras corre su script.
66 * `steps` son las fases que esta corrida recorre (--prepare-only se detiene en el PR; sin bump solo publica);
67 * `done` las ya observadas por sondeo y `failed` la que quedo en curso si el turno termino sin completarlas.
68 */
69export type Release = {
70  phase: 'reposo' | 'despegando'
71  startedMs: number
72  finishedMs: number | null
73  version: string | null
74  steps: ReleaseStep[]
75  done: ReleaseStep[]
76  failed: ReleaseStep | null
77  issues: ReleaseIssue[]
78  fragments: string[]
79  pr: string | null
80  url: string | null
81}
82
83/** Un PR de la cinta de /mefisto-merge: sale de la lista al quedar mergeado; si su merge falla queda con ✗. */
84export type MergePr = { num: string; title: string; estado: 'pendiente' | 'mergeado' | 'fallido' }
85
86/** Un /mefisto-merge en esta sesion: los PRs que cose y cuando termino. */
87export type MergeRun = { prs: MergePr[]; startedMs: number; finishedMs: number | null }
88
89export type LogKind = 'start' | 'tool' | 'text' | 'fail' | 'done'
90
91export type LogLine = { ts: string; kind: LogKind; text: string }
92
93declare module 'claude-code' {
94  interface PluginState {
95    'mefisto-divine-wager': {
96      run: PipelineRun | null
97      lines: LogLine[]
98      now: number
99      isExecutionSession: boolean
100      ready: ReadyList | null
101      readyPage: number
102      batch: BatchRun | null
103      openPrs: OpenPr[] | null
104      fieldNotes: number | null
105      changelog: ChangelogSummary | null
106      hold: Hold | null
107      historian: Historian | null
108      release: Release | null
109      merge: MergeRun | null
110    }
111  }
112}
113