Harness opinionado de Claude Code para construir aplicaciones serverless en Azure con Event Driven Architecture, Event Sourcing (Marten + Wolverine), Postgres…

<img src="docs/assets/mefisto-banner.svg" alt="Mefisto" width="100%">
<a href="https://github.com/augusto-romero-arango/mefisto/releases"><img alt="Última release" src="https://img.shields.io/github/v/release/augusto-romero-arango/mefisto"></a> <a href="LICENSE"><img alt="Licencia" src="https://img.shields.io/github/license/augusto-romero-arango/mefisto"></a> <a href="https://mefisto.io"><img alt="mefisto.io" src="https://img.shields.io/badge/sitio-mefisto.io-blueviolet"></a>
Mefisto es un harness opinionado para agentes de código que orquesta el desarrollo asistido de aplicaciones .NET 10 serverless en Azure con Event Driven Architecture y Event Sourcing.
Conoce más en mefisto.io.
Un guiño a Mefistófeles, el espíritu de Fausto de Goethe: quien invoca el harness encarna a Fausto, fija la intención y firma el pacto; el plugin, como Mefisto, la ejecuta bajo las reglas del marco (EDA, Event Sourcing, TDD). Fausto decide, Mefisto ejecuta (MEF-ADR-0055).
Los agentes de código improvisan arquitectura: cada sesión reinventa convenciones, tests y pipelines. Mefisto fija el marco una sola vez (ADRs, skills, agentes y pipelines) para equipos que construyen sistemas .NET sobre Azure con Event Sourcing y quieren que el agente trabaje dentro de esas reglas, de la idea al PR.
tmux y git worktree, con ejecución paralela y secuencial.MEF-ADR-) como fuente de verdad arquitectónica..NET 10 · Azure Functions (isolated worker) · PostgreSQL + Marten · Wolverine · Azure Service Bus · Terraform · GitHub Actions.
Claude Code y OpenCode, ambos con adaptador en el repo y sin privilegiar a uno (MEF-ADR-0049).
bash 3.2+, jq, gh autenticado, git 2.x con worktrees, dotnet 10.x, terraform 1.6+ y tmux (pipelines paralelos).
Claude Code (>= 2.1.287): Mefisto se instala una vez por usuario deshabilitado, y solo se activa en los repos que lo habilitan en su .claude/settings.json commiteado. Así no se carga en repos que no lo usan (MEF-ADR-0053, decisión 2). Registra el marketplace dentro de la sesión y, desde una terminal, instálalo y deshabilítalo a nivel usuario:
/plugin marketplace add augusto-romero-arango-harness
claude plugin install mefisto@augusto-romero-arango-harness --scope user
claude plugin disable mefisto@augusto-romero-arango-harness --scope user
En cada repo consumidor, commitea el .claude/settings.json con enabledPlugins (ver la guía del consumidor, paso 1). Los pipelines entregan a sus agentes la raíz de su propia versión, así que los worktrees no dependen de esta instalación.
OpenCode: desde Claude Code, /mefisto:upgrade instala la release OpenCode por usuario; cada repo consumidor la activa commiteando .opencode/plugins/mefisto.js, que /mefisto:onboard escribe. El procedimiento directo está en la guía del consumidor.
/mefisto:onboard para diagnosticar config, labels y CI del repo./mefisto:draft, refínala hasta estado:listo y córrela con /mefisto:implement <issue>: del issue al PR.El trabajo sobre el propio plugin nace como issue: /mefisto-plan captura y refina, el pipeline interno implementa en una rama propia (nunca contra main) y se entrega por Pull Request con Closes #<n>. Cada cambio notable se anota como fragmento en changelog.d/, sin editar CHANGELOG.md. Detalle en Desarrollo del plugin.
Apache-2.0. Ver LICENSE y NOTICE.
Se permite uso, modificación y uso comercial conservando los avisos de licencia y atribución.
Augusto Romero Arango · @augusto-romero-arango
hooks/index.tsx 12 lines1import type { Register } from 'claude-code'
2
3import { register as pact } from './fausto-blood-pact/register'
4import { register as board } from './register'
5
6// Claude Code carga un solo modulo por plugin (`claude plugin validate` rechaza una segunda entrada de `modules`):
7// este punto de entrada compone el tablero y la consola, que siguen siendo modulos independientes.
8export const register: Register = (on, options) => {
9 board(on, options)
10 pact(on, options)
11}
12hooks/fausto-blood-pact/register.tsx 1615 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { RASTER_ROWS, ROLES, face, sprite, toRasterCells, waitingFace } from '../sprites'
5import type { Role } from '../sprites'
6import { cropGrid, numberWidth, padEnd, reasonOf, usedColumns } from '../logic'
7import type { BoardList, Historian, PaneLine, PactMerge, PipelineResult, PipelineRun } from '../types'
8import type { IssueStats } from './logic'
9import type { BatchStatus, CurrentRow, LastEvent, LaunchKind, LaunchPlan, MascotPose, OpenPr } from './logic'
10import {
11 BAND_FETCH_ROWS,
12 BAND_LOG_ROWS,
13 PANE_MAX_LINES,
14 agentOf,
15 batchSeparator,
16 cleanLogLine,
17 durationText,
18 followTargetsOf,
19 isFailure,
20 mergePaneLines,
21 minutesSince,
22 paneHeaderOf,
23 parseEvent,
24 LAUNCH_POLL_WINDOW_MS,
25 LOG_TAIL_BYTES,
26 LOGS_DIR,
27 bandLinesOf,
28 eventLineOf,
29 isPipelineLaunch,
30 logTailLines,
31 runHeader,
32 stepsOf,
33 activeRunOf,
34 bandViewOf,
35 resultsFooterText,
36 eventsFileOf,
37 lastEventOf,
38 poseOfBatch,
39 poseOfResults,
40 poseOfRun,
41 DISMISSED_STORE_KEY,
42 HISTORY_FILE,
43 PAGE_ROWS,
44 PLANNER_AGENT,
45 agentFlagOf,
46 agentSettingOf,
47 NEXT_ORDER_ARGS,
48 ROW_KEYS,
49 answerText,
50 parseLaunchArg,
51 planIssue,
52 planLaunch,
53 clip,
54 footerRestOf,
55 launchCountOf,
56 isMefistoManifest,
57 nextOrderPath,
58 RUNS_POLL_MS,
59 dismissKey,
60 parseHistory,
61 parseStatus,
62 pruneDismissed,
63 reconcile,
64 repoRootOf,
65 resultLine,
66 runLine,
67 stateDirOf,
68 pageOf,
69 parseNextOrder,
70 rowParts,
71 transcriptPathOf,
72 PR_CHECK_MS,
73 mergeArgsOf,
74 mergeOptions,
75 mergeToast,
76 OPEN_PRS_ARGS,
77 openPrArgsOf,
78 openPrOptions,
79 parseOpenPrs,
80 parsePrArgs,
81 resultPrs,
82 viewOptions,
83 viewPrOf,
84 BATCH_KEEP_ANSWER,
85 BATCH_STOP_ANSWER,
86 batchCounts,
87 batchDismissKey,
88 batchCurrentOf,
89 batchHoldLine,
90 batchMark,
91 batchQueueMarks,
92 batchRunningHeader,
93 recentLines,
94 batchSummaryMarks,
95 batchEndLabel,
96 batchEndFromStats,
97 closeRowText,
98 closeTotalText,
99 nextCurrentRow,
100 runningRowText,
101 totalWithRunning,
102 elapsedText,
103 issueStatsFromHistory,
104 statsPending,
105 statsTotal,
106 stampToMs,
107 summaryTitleWidth,
108 batchPrOf,
109 batchRunOf,
110 batchStatusPath,
111 batchStopPath,
112 canStopBatch,
113 isBatchRunning,
114 newlyMerged,
115 parseBatchStatus,
116 stopQuestion,
117 MERGE_POLL_MS,
118 finishMerge,
119 mergeRunArgsOf,
120 mergePrsOf,
121 mergeRibbonHeader,
122 mergeRibbonLine,
123 mergeRibbonRows,
124 mergeRibbonShown,
125 mergeSummaryText,
126 withMerged,
127 BITACORA_PR_ARGS,
128 FIELD_NOTES_DIR,
129 NOTES_POLL_MS,
130 PROCESSED_NOTES_DIR,
131 bitacoraCount,
132 bitacoraIntegrateLabel,
133 bitacoraPrOf,
134 bitacoraPrompt,
135 bitacoraQuestion,
136 fieldNoteNamesOf,
137 fieldNotesIn,
138 historianFrom,
139 historianHeader,
140 isAgentActive,
141 isHistorianType,
142 liveHistorian,
143 noteLine,
144 orderedNotes,
145 pendingNotes,
146 withProcessedNotes,
147} from './logic'
148import { shouldRearm, versionLabel } from '../logic'
149import type { ResultPr } from './logic'
150
151const POLL_MS = 60_000
152const STATE_DIR = '.mefisto/pipeline'
153const SCRIPT_TIMEOUT_MS = 180_000
154
155const activeAtom = atom({ plugin: 'mefisto', key: 'pactIsActive' } as const, false)
156const listAtom = atom({ plugin: 'mefisto', key: 'pactList' } as const, null)
157const pageAtom = atom({ plugin: 'mefisto', key: 'pactPage' } as const, 0)
158const runsAtom = atom({ plugin: 'mefisto', key: 'pactRuns' } as const, [] as PipelineRun[])
159const resultsAtom = atom({ plugin: 'mefisto', key: 'pactResults' } as const, [] as PipelineResult[])
160const lastEventAtom = atom({ plugin: 'mefisto', key: 'pactLastEvent' } as const, null as LastEvent | null)
161const openPrsAtom = atom({ plugin: 'mefisto', key: 'pactOpenPrs' } as const, [] as OpenPr[])
162const batchAtom = atom({ plugin: 'mefisto', key: 'pactBatch' } as const, null as BatchStatus | null)
163type BatchStats = { stats: Record<string, IssueStats>; endedMs: number | null }
164const batchStatsAtom = atom({ plugin: 'mefisto', key: 'pactBatchStats' } as const, { stats: {}, endedMs: null } as BatchStats)
165const batchCurrentAtom = atom({ plugin: 'mefisto', key: 'pactBatchCurrent' } as const, null as CurrentRow | null)
166const mergeAtom = atom({ plugin: 'mefisto', key: 'pactMerge' } as const, null as PactMerge | null)
167const fieldNotesAtom = atom({ plugin: 'mefisto', key: 'pactFieldNotes' } as const, null as number | null)
168const historianAtom = atom({ plugin: 'mefisto', key: 'pactHistorian' } as const, null as Historian | null)
169const bandLinesAtom = atom({ plugin: 'mefisto', key: 'pactBandLines' } as const, [] as string[])
170const logLinesAtom = atom({ plugin: 'mefisto', key: 'pactLogLines' } as const, [] as PaneLine[])
171const paneRunAtom = atom({ plugin: 'mefisto', key: 'pactPaneRun' } as const, null as PipelineRun | null)
172const tickAtom = atom({ plugin: 'mefisto', key: 'pactTick' } as const, 0)
173
174// Columnas comunes a todos los cuadros de la mascota: el recorte no cambia al alternar poses.
175const MASCOT_GRIDS = [
176 ...(Object.keys(ROLES) as Role[])
177 .filter(role => role !== 'planner')
178 .flatMap(role => Object.keys(ROLES[role]).flatMap(state => [sprite(role, state, 0), sprite(role, state, 1)])),
179 ...[null, 0, 1].map(tick => waitingFace(face('normal'), tick)),
180]
181const MASCOT_COLS = usedColumns(MASCOT_GRIDS)
182const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
183
184const mascotGrid = (pose: MascotPose, frame: 0 | 1) => cropGrid(sprite(pose.role, pose.state, frame), MASCOT_COLS)
185let isAnimating = false
186// Tras ver el lanzamiento de un pipeline, el sondeo de corridas corre a 5 s hasta este instante aunque aun no haya status (#2320).
187let launchPollUntilMs = 0
188let lastNotesPollMs = 0
189let lastHistorianPollMs = 0
190let lastBitacoraPrCheckMs = 0
191let isPollingHistorian = false
192const HISTORIAN_POLL_MS = 2_000
193
194// La intencion on/off y la elegibilidad viven aqui: /clear no dispara session.start y reinicia los atoms.
195let isEligible = false
196let isWanted = true
197let isHealing = false
198let healTimer: { cancel: () => void } | null = null
199const HEAL_MS = 1_000
200let isRefreshing = false
201let timer: { cancel: () => void } | null = null
202let runsTimer: { cancel: () => void } | null = null
203let tickTimer: { cancel: () => void } | null = null
204let prTimer: { cancel: () => void } | null = null
205let isCheckingPrs = false
206let isRefreshingOpenPrs = false
207let isRefreshingRuns = false
208let mergeTimer: { cancel: () => void } | null = null
209let isPollingMerge = false
210// La llamada Bash a pr-sync.sh ya empezo; sin verla, el reloj cierra la cinta arrancada por teclas a los MERGE_STALE_MS.
211let isMergeScriptSeen = false
212let mergeStartedMs = 0
213const MERGE_STALE_MS = 60_000
214// Corridas que esta sesion vio activas y resultados `✓` ya derivados; viven aqui porque /clear reinicia los atoms.
215let seenRuns: Record<string, PipelineRun> = {}
216let carriedResults: PipelineResult[] = []
217let runsRepo = ''
218// Ultimo estado del lote leido: base para anunciar con un toast cada issue que pasa a `mergeado`.
219let lastBatchIssues: BatchStatus['issues'] | null = null
220// Estadisticas del lote (del historial: titulo, duracion y costo); se reinician al cambiar el `started` del lote.
221let batchStatsFor = ''
222// Primer instante en que se vio el lote ya terminado: cierra el reloj de la cabecera si el historial no lo da.
223let batchSeenEndedMs: number | null = null
224// El historial se relee para estadisticas faltantes a lo sumo cada BATCH_STATS_EVERY_MS (como `withStats` en la cinta interna).
225const BATCH_STATS_EVERY_MS = 5_000
226let lastBatchStatsReadMs = 0
227// Si al arrancar no se pudo saber el agente (sin linea de comando y sin transcript aun), se reintenta en cada refresh.
228let agentChecksLeft = 0
229const AGENT_CHECKS = 20
230
231async function pluginRoot($: EngineInterface): Promise<string | null> {
232 const own = $.plugin.root
233 if (own) return own
234 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => ({ exitCode: 1, stdout: '' }))
235 const base = top.exitCode === 0 ? top.stdout.trim() : await $.session.cwd()
236 const root = await $.fs.read(`${base}/.mefisto/pipeline/.plugin-root`).catch(() => '')
237 return typeof root === 'string' && root.trim() !== '' ? root.trim() : null
238}
239
240const versionAtom = atom({ plugin: 'mefisto', key: 'pactVersion' } as const, null as string | null)
241
242async function loadVersion($: EngineInterface) {
243 const root = await pluginRoot($)
244 const base = root ? root.replace(/\/+$/, '') : null
245 const read1 = async (file: string) => (base ? await $.fs.read(`${base}/${file}`).catch(() => null) : null)
246 const label = versionLabel(await read1('mefisto-manifest.json'), await read1('.claude-plugin/plugin.json'))
247 await update($, versionAtom, () => label)
248}
249
250async function runNextOrder($: EngineInterface): Promise<BoardList> {
251 const root = await pluginRoot($)
252 if (!root) return parseNextOrder(2, '', 'raiz del plugin desconocida')
253 const { exitCode, stdout, stderr } = await $.process
254 .run([nextOrderPath(root), ...NEXT_ORDER_ARGS], { timeoutMs: SCRIPT_TIMEOUT_MS })
255 .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) }))
256 return parseNextOrder(exitCode, stdout, stderr)
257}
258
259async function readDismissed($: EngineInterface): Promise<string[]> {
260 const raw = await $.store.get(DISMISSED_STORE_KEY).catch(() => null)
261 return Array.isArray(raw) ? raw.filter((k): k is string => typeof k === 'string') : []
262}
263
264async function saveDismissed($: EngineInterface, keys: string[]) {
265 await $.store.set(DISMISSED_STORE_KEY, keys).catch(() => undefined)
266}
267
268// Lector puro: solo lee `.mefisto/pipeline/` del checkout principal; la ruta legada se ignora (MEF-ADR-0053).
269async function readRuns($: EngineInterface): Promise<PipelineRun[]> {
270 const cwd = await $.session.cwd()
271 const common = await $.process.run(['git', 'rev-parse', '--git-common-dir']).catch(() => ({ exitCode: 1, stdout: '' }))
272 const dir = stateDirOf(common.exitCode === 0 ? common.stdout.trim() : '.git', cwd)
273 runsRepo = repoRootOf(dir)
274 const ls = await $.process.run(['ls', dir]).catch(() => ({ exitCode: 1, stdout: '' }))
275 if (ls.exitCode !== 0) return []
276 const runs: PipelineRun[] = []
277 for (const name of ls.stdout.split('\n')) {
278 const file = name.trim()
279 if (!file.startsWith('pipeline-status-')) continue
280 const raw = await $.fs.read(`${dir}/${file}`).catch(() => '')
281 const run = typeof raw === 'string' ? parseStatus(file, raw) : null
282 if (run) runs.push(run)
283 }
284 return runs
285}
286
287// Cola del `events.jsonl` del ultimo intento del agente activo; lector puro de `<estado>/logs/*.events.jsonl`.
288async function readEventsTail($: EngineInterface, run: PipelineRun | null): Promise<string | null> {
289 if (!run) return null
290 const dir = `${runsRepo}/${STATE_DIR}/${LOGS_DIR}`
291 const ls = await $.process.run(['ls', dir]).catch(() => ({ exitCode: 1, stdout: '' }))
292 if (ls.exitCode !== 0) return null
293 const file = eventsFileOf(run, ls.stdout.split('\n'))
294 if (!file) return null
295 const tail = await $.process.run(['tail', '-c', '65536', `${dir}/${file}`]).catch(() => ({ exitCode: 1, stdout: '' }))
296 return tail.exitCode === 0 ? tail.stdout : null
297}
298
299// Ultimas lineas utiles: cola del log del pipeline (sin ANSI) y ultimo evento del agente; se calculan aqui, nunca al dibujar.
300async function readBandLines($: EngineInterface, run: PipelineRun | null, eventsTail: string | null): Promise<string[]> {
301 if (!run) return []
302 let logLines: string[] = []
303 if (run.log) {
304 const path = run.log.startsWith('/') ? run.log : `${runsRepo}/${run.log}`
305 const tail = await $.process.run(['tail', '-c', String(LOG_TAIL_BYTES), path]).catch(() => ({ exitCode: 1, stdout: '' }))
306 if (tail.exitCode === 0) logLines = logTailLines(tail.stdout, BAND_FETCH_ROWS)
307 }
308 return bandLinesOf(logLines, eventsTail ? eventLineOf(eventsTail) : null, BAND_FETCH_ROWS)
309}
310
311// Solo `.mefisto/pipeline/` (MEF-ADR-0053): la ruta legada ya no la escribe ningun pipeline.
312async function readHistory($: EngineInterface): Promise<string> {
313 const raw = await $.fs.read(`${runsRepo}/${STATE_DIR}/${HISTORY_FILE}`).catch(() => '')
314 return typeof raw === 'string' ? raw : ''
315}
316
317// Lector puro del status del lote (#2202); un lote terminado y cerrado (clave repo + `started` en el store) no se muestra.
318async function readBatch($: EngineInterface): Promise<BatchStatus | null> {
319 const raw = await $.fs.read(batchStatusPath(`${runsRepo}/${STATE_DIR}`)).catch(() => '')
320 return typeof raw === 'string' && raw !== '' ? parseBatchStatus(raw) : null
321}
322
323async function refreshBatch($: EngineInterface) {
324 const batch = await readBatch($)
325 if (!batch) {
326 lastBatchIssues = null
327 await update($, batchCurrentAtom, () => null)
328 await update($, batchAtom, () => null)
329 return
330 }
331 for (const i of newlyMerged(lastBatchIssues, batch.issues)) $.ui.toast(`#${i.issue} mergeado${i.pr ? ` · PR #${i.pr}` : ''}`)
332 lastBatchIssues = batch.issues
333 if (batchStatsFor !== batch.started) {
334 batchStatsFor = batch.started
335 batchSeenEndedMs = null
336 lastBatchStatsReadMs = 0
337 await update($, batchStatsAtom, () => ({ stats: {}, endedMs: null }))
338 }
339 const finished = (i: BatchStatus['issues'][number]) => i.status !== 'pendiente' && i.status !== 'en-curso'
340 const known = await read($, batchStatsAtom)
341 const missing = statsPending(batch.issues.filter(finished).map(i => String(i.issue)), known.stats)
342 let stats = known.stats
343 if (missing.length > 0 && Date.now() - lastBatchStatsReadMs >= BATCH_STATS_EVERY_MS) {
344 lastBatchStatsReadMs = Date.now()
345 stats = { ...stats, ...issueStatsFromHistory(await readHistory($), missing, stampToMs(batch.started)) }
346 }
347 if (isBatchRunning(batch)) batchSeenEndedMs = null
348 else batchSeenEndedMs ??= Date.now()
349 const endedMs = isBatchRunning(batch) ? null : (batchEndFromStats(stats) ?? batchSeenEndedMs)
350 if (stats !== known.stats || endedMs !== known.endedMs) await update($, batchStatsAtom, () => ({ stats, endedMs }))
351 const prevRow = await read($, batchCurrentAtom)
352 const nextRow = nextCurrentRow(batch, batchRunOf(batch, await read($, runsAtom)), prevRow, Date.now())
353 if (nextRow !== prevRow) await update($, batchCurrentAtom, () => nextRow)
354 const closed = !isBatchRunning(batch) && (await readDismissed($)).includes(batchDismissKey(runsRepo, batch))
355 await update($, batchAtom, () => (closed ? null : batch))
356}
357
358// Parada suave (MEF-ADR-0017): la misma senal vacia que /mefisto:batch-stop, en la raiz del checkout principal.
359async function requestStop($: EngineInterface): Promise<string> {
360 const batch = await read($, batchAtom)
361 if (!batch || !canStopBatch(batch)) return 'No hay un sequential en curso que detener.'
362 await $.fs.write(batchStopPath(runsRepo), '')
363 await update($, batchAtom, b => (b ? { ...b, stopRequested: true } : b))
364 $.ui.toast('parada pedida: termina el issue en curso y no arranca los siguientes')
365 return 'Parada pedida: termina el issue en curso y no arranca los siguientes.'
366}
367
368async function confirmStop($: EngineInterface): Promise<string> {
369 const batch = await read($, batchAtom)
370 if (batch && isBatchRunning(batch) && batch.stopRequested) return 'La parada ya esta pedida: termina el issue en curso.'
371 if (!batch || !canStopBatch(batch)) return 'No hay un sequential en curso que detener.'
372 const answer = await $.ui
373 .ask(stopQuestion(batch), { header: 'Detener', options: [BATCH_STOP_ANSWER, BATCH_KEEP_ANSWER] })
374 .catch(() => null)
375 return answer === BATCH_STOP_ANSWER ? requestStop($) : 'Sin cambios.'
376}
377
378async function viewBatchPr($: EngineInterface): Promise<string> {
379 const batch = await read($, batchAtom)
380 const pr = batch ? batchPrOf(batch) : null
381 if (!pr) return 'El lote no tiene PR que abrir.'
382 await $.process.run(['gh', 'pr', 'view', pr, '--web']).catch(() => undefined)
383 return `PR #${pr} abierto en GitHub.`
384}
385
386async function closeBatch($: EngineInterface) {
387 const batch = await read($, batchAtom)
388 if (!batch || isBatchRunning(batch)) return
389 const keys = new Set(await readDismissed($))
390 keys.add(batchDismissKey(runsRepo, batch))
391 await saveDismissed($, [...keys])
392 await update($, batchAtom, () => null)
393 await closeLogIfGone($)
394}
395
396// ---- Pane de log (#2321): dos `tail -F` (eventos del agente y log del pipeline) mezclados en un mismo atom ----
397
398const PANE = 'fausto-blood-pact'
399const PANE_ROWS = 18
400const PANE_FIXED_ROWS = 7
401type Follow = { path: string; stream: AsyncGenerator<unknown, unknown> }
402type FollowKind = 'events' | 'log'
403
404let isLogOpen = false
405let isSyncingLog = false
406let eventsFollow: Follow | null = null
407let logFollow: Follow | null = null
408let separatedIssue: number | null = null
409
410const followOf = (kind: FollowKind) => (kind === 'events' ? eventsFollow : logFollow)
411
412async function stopFollow(kind: FollowKind) {
413 const current = followOf(kind)
414 if (kind === 'events') eventsFollow = null
415 else logFollow = null
416 if (current) await current.stream.return(undefined).catch(() => undefined)
417}
418
419async function stopFollows() {
420 await stopFollow('events')
421 await stopFollow('log')
422}
423
424async function consumeFollow($: EngineInterface, kind: FollowKind, follow: Follow) {
425 let pending = ''
426 try {
427 for await (const chunk of follow.stream) {
428 if (followOf(kind) !== follow) break
429 const { stream: channel, text } = chunk as { stream: string; text: string }
430 if (channel !== 'stdout') continue
431 pending += text
432 const rows = pending.split('\n')
433 pending = rows.pop() ?? ''
434 const added = rows
435 .map((row): PaneLine | null => {
436 if (kind === 'events') return parseEvent(row)
437 const clean = cleanLogLine(row)
438 return clean ? { kind: 'log', text: clean } : null
439 })
440 .filter((l): l is PaneLine => l !== null)
441 if (added.length > 0) await update($, logLinesAtom, list => mergePaneLines(list, added, PANE_MAX_LINES))
442 }
443 } catch (err) {
444 $.ui.log(`tail ${follow.path}: ${String(err)}`, { to: 'debug' })
445 }
446}
447
448async function startFollow($: EngineInterface, kind: FollowKind, path: string) {
449 await stopFollow(kind)
450 const stream = $.process.spawn({ argv: ['tail', '-n', '40', '-F', path], ...(runsRepo ? { cwd: runsRepo } : {}) })
451 const follow: Follow = { path, stream }
452 if (kind === 'events') eventsFollow = follow
453 else logFollow = follow
454 void consumeFollow($, kind, follow)
455}
456
457const isSameRun = (a: PipelineRun, b: PipelineRun) => a.issue === b.issue && a.pipeline === b.pipeline && a.variant === b.variant
458
459async function paneTarget($: EngineInterface): Promise<PipelineRun | null> {
460 const batch = await read($, batchAtom)
461 const runs = await read($, runsAtom)
462 return batch ? batchRunOf(batch, runs) : activeRunOf(runs)
463}
464
465// Cada sondeo: elige los archivos a seguir (intento vigente del stage activo, log de la corrida o del lote) y pasa al nuevo si cambian.
466async function syncLog($: EngineInterface) {
467 if (!isLogOpen || isSyncingLog) return
468 isSyncingLog = true
469 try {
470 const batch = await read($, batchAtom)
471 const active = await paneTarget($)
472 let run = active ?? (await read($, paneRunAtom))
473 if (!active && run) {
474 const known = run
475 run = (await readRuns($)).find(r => isSameRun(r, known)) ?? run
476 }
477 if (run) await update($, paneRunAtom, () => run)
478 const ls = run ? await $.process.run(['ls', `${runsRepo}/${STATE_DIR}/${LOGS_DIR}`]).catch(() => ({ exitCode: 1, stdout: '' })) : null
479 const names = ls && ls.exitCode === 0 ? ls.stdout.split('\n') : []
480 const targets = followTargetsOf(run, batch, names, runsRepo)
481 if (batch && active && active.issue !== separatedIssue) {
482 separatedIssue = active.issue
483 await update($, logLinesAtom, list => mergePaneLines(list, [batchSeparator(active.issue)], PANE_MAX_LINES))
484 }
485 if (!isLogOpen) return
486 if (targets.events && targets.events !== eventsFollow?.path) await startFollow($, 'events', targets.events)
487 if (targets.log && targets.log !== logFollow?.path) await startFollow($, 'log', targets.log)
488 } finally {
489 isSyncingLog = false
490 }
491}
492
493async function closeLog($: EngineInterface) {
494 isLogOpen = false
495 await stopFollows()
496 separatedIssue = null
497 await update($, logLinesAtom, () => [])
498 await update($, paneRunAtom, () => null)
499 await $.ui.close({ id: PANE })
500}
501
502async function openLog($: EngineInterface): Promise<boolean> {
503 const run = await paneTarget($)
504 if (!run) return false
505 isLogOpen = true
506 separatedIssue = null
507 await update($, logLinesAtom, () => [])
508 await update($, paneRunAtom, () => run)
509 await $.ui.open({ id: PANE, title: 'mefisto', rows: PANE_ROWS, focus: true as const })
510 await syncLog($)
511 return true
512}
513
514// La tecla 3 abre el log o, si ya estaba abierto, lo cierra.
515async function toggleLog($: EngineInterface) {
516 if (isLogOpen) await closeLog($)
517 else if (!(await openLog($))) $.ui.toast('No hay corrida que seguir')
518}
519
520// Cuando la vista se va (cerrar, ocultar o una corrida que cierra sola) el pane y sus `tail` se van con ella.
521async function closeLogIfGone($: EngineInterface) {
522 if (!isLogOpen) return
523 const batch = await read($, batchAtom)
524 const runs = await read($, runsAtom)
525 const results = await read($, resultsAtom)
526 if (!batch && runs.length === 0 && results.length === 0) await closeLog($)
527}
528
529async function refreshRuns($: EngineInterface) {
530 if (isRefreshingRuns || !isEligible || !isWanted) return
531 isRefreshingRuns = true
532 try {
533 const statuses = await readRuns($)
534 const dismissed = await readDismissed($)
535 const wasSeen = seenRuns
536 const historyRaw = await readHistory($)
537 const view = reconcile(
538 runsRepo,
539 statuses,
540 wasSeen,
541 parseHistory(typeof historyRaw === 'string' ? historyRaw : ''),
542 new Set(dismissed),
543 carriedResults,
544 )
545 seenRuns = view.seen
546 carriedResults = view.results.filter(r => r.ok)
547 const live = new Set(statuses.map(s => dismissKey(runsRepo, s)))
548 const pruned = pruneDismissed(dismissed, runsRepo, live, Date.now())
549 if (pruned.length !== dismissed.length) await saveDismissed($, pruned)
550 const target = activeRunOf(view.runs)
551 const eventsTail = await readEventsTail($, target)
552 const last = eventsTail ? lastEventOf(eventsTail) : null
553 const lines = await readBandLines($, target, eventsTail)
554 await update($, lastEventAtom, () => last)
555 await update($, bandLinesAtom, () => lines)
556 await update($, runsAtom, () => view.runs)
557 await refreshBatch($)
558 await update($, resultsAtom, () => view.results)
559 await syncLog($)
560 await closeLogIfGone($)
561 } finally {
562 isRefreshingRuns = false
563 }
564}
565
566async function dismissResults($: EngineInterface): Promise<number> {
567 const results = await read($, resultsAtom)
568 if (results.length === 0) return 0
569 const keys = new Set(await readDismissed($))
570 for (const r of results) keys.add(r.key)
571 await saveDismissed($, [...keys])
572 carriedResults = []
573 await update($, resultsAtom, () => [])
574 await update($, pageAtom, () => 0)
575 await closeLogIfGone($)
576 return results.length
577}
578
579// Un PR `MERGED` quita su linea sola: se guarda en `$.store` (nunca en archivos de estado) para que no reaparezca.
580async function closeMergedPrs($: EngineInterface) {
581 if (isCheckingPrs || !isEligible || !isWanted) return
582 isCheckingPrs = true
583 try {
584 const results = await read($, resultsAtom)
585 const merged: ResultPr[] = []
586 for (const p of resultPrs(results)) {
587 const { exitCode, stdout } = await $.process
588 .run(['gh', 'pr', 'view', p.pr, '--json', 'state', '-q', '.state'])
589 .catch(() => ({ exitCode: 1, stdout: '' }))
590 if (exitCode === 0 && stdout.trim() === 'MERGED') merged.push(p)
591 }
592 if (merged.length === 0) return
593 const keys = new Set(await readDismissed($))
594 for (const p of merged) keys.add(p.key)
595 await saveDismissed($, [...keys])
596 const gone = new Set(merged.map(p => p.key))
597 carriedResults = carriedResults.filter(r => !gone.has(r.key))
598 await update($, resultsAtom, rs => rs.filter(r => !gone.has(r.key)))
599 await update($, pageAtom, () => 0)
600 void refreshOpenPrs($)
601 for (const p of merged) {
602 $.ui.toast(`PR #${p.pr} mergeado${p.pipeline === 'infra' ? ' · el issue se cierra cuando termine el apply de CI' : ''}`)
603 }
604 } finally {
605 isCheckingPrs = false
606 }
607}
608
609// La cinta de merge: arranca con las teclas o al ver la llamada Bash a pr-sync.sh --merge; con --all la lista sale de los PRs abiertos de ese momento.
610async function startMerge($: EngineInterface, args: string) {
611 const current = await read($, mergeAtom)
612 if (current && current.finishedMs === null) return
613 let open = await read($, openPrsAtom)
614 if (/(^|\s)--all(\s|$)/.test(args)) {
615 const listed = await $.process.run(['gh', ...OPEN_PRS_ARGS]).catch(() => ({ exitCode: 1, stdout: '' }))
616 if (listed.exitCode === 0) open = parseOpenPrs(listed.stdout)
617 }
618 const prs = mergePrsOf(args, open)
619 if (prs.length === 0) return
620 isMergeScriptSeen = false
621 mergeStartedMs = Date.now()
622 await update($, mergeAtom, () => ({ prs, startedMs: mergeStartedMs, finishedMs: null }))
623}
624
625async function pollMerge($: EngineInterface) {
626 if (isPollingMerge) return
627 isPollingMerge = true
628 try {
629 const run = await read($, mergeAtom)
630 if (!run || run.finishedMs !== null) return
631 const pending = run.prs.filter(p => p.estado === 'pendiente').map(p => p.num)
632 const states = await Promise.all(
633 pending.map(n => $.process.run(['gh', 'pr', 'view', n, '--json', 'state', '-q', '.state']).catch(() => ({ exitCode: 1, stdout: '' }))),
634 )
635 const merged = pending.filter((_, i) => states[i]?.exitCode === 0 && states[i]?.stdout.trim() === 'MERGED')
636 if (merged.length > 0) await update($, mergeAtom, r => (r ? withMerged(r, merged) : r))
637 } finally {
638 isPollingMerge = false
639 }
640}
641
642async function endMerge($: EngineInterface) {
643 await pollMerge($)
644 const run = await read($, mergeAtom)
645 if (!run || run.finishedMs !== null) return
646 const done = finishMerge(run, Date.now())
647 await update($, mergeAtom, () => done)
648 $.ui.toast(mergeSummaryText(done))
649 void refreshOpenPrs($)
650}
651
652async function closeMerge($: EngineInterface) {
653 const run = await read($, mergeAtom)
654 if (run && run.finishedMs !== null) await update($, mergeAtom, () => null)
655}
656
657// Mergear: la confirmacion del dialogo es la unica; /mefisto:merge no pide otra. Nunca `--all`.
658async function mergePrs($: EngineInterface, preset: string[] | null = null): Promise<string> {
659 const prs = resultPrs(await read($, resultsAtom))
660 if (prs.length === 0) return 'No hay PRs de corridas terminadas.'
661 const scope = preset ? prs.filter(p => preset.includes(p.pr)) : prs
662 const options = mergeOptions(scope)
663 const answer = await $.ui
664 .ask(
665 scope.length === 1 ? `¿Mergear el PR #${scope[0]!.pr}?` : `¿Qué mergear? ${scope.length} PRs${scope.length > 3 ? ' (otros números: escríbelos en la opción de texto)' : ''}`,
666 scope.length === 1 ? options : { header: 'Merge', options, multiSelect: true as const },
667 )
668 .catch(() => null)
669 const args = mergeArgsOf(answer, options, scope)
670 if (!args) return 'Merge cancelado.'
671 $.ui.toast(mergeToast(args, prs))
672 await startMerge($, args)
673 await $.command.run({ command: 'mefisto:merge', args })
674 void refreshOpenPrs($)
675 return `/mefisto:merge ${args} en cola.`
676}
677
678async function refreshOpenPrs($: EngineInterface) {
679 if (isRefreshingOpenPrs || !isEligible || !isWanted) return
680 isRefreshingOpenPrs = true
681 try {
682 const { exitCode, stdout } = await $.process.run(['gh', ...OPEN_PRS_ARGS]).catch(() => ({ exitCode: 1, stdout: '' }))
683 if (exitCode === 0) await update($, openPrsAtom, () => parseOpenPrs(stdout))
684 } finally {
685 isRefreshingOpenPrs = false
686 }
687}
688
689// Reposo: la lista es de los PRs abiertos que no son borradores (field notes incluidos); "Todos" pasa `--all`.
690async function mergeOpenPrs($: EngineInterface): Promise<string> {
691 await refreshOpenPrs($)
692 const prs = await read($, openPrsAtom)
693 if (prs.length === 0) return 'No hay PRs abiertos.'
694 const options = openPrOptions(prs)
695 const answer = await $.ui
696 .ask(
697 prs.length === 1 ? `¿Mergear el PR #${prs[0]!.number}?` : `¿Qué mergear? ${prs.length} PRs${prs.length > 3 ? ' (otros números: escríbelos en la opción de texto)' : ''}`,
698 prs.length === 1 ? options : { header: 'Merge', options, multiSelect: true as const },
699 )
700 .catch(() => null)
701 const args = openPrArgsOf(typeof answer === 'string' ? answer : null, options, prs)
702 if (!args) return 'Merge cancelado.'
703 $.ui.toast(`/mefisto:merge ${args} en cola`)
704 await startMerge($, args)
705 await $.command.run({ command: 'mefisto:merge', args })
706 void refreshOpenPrs($)
707 return `/mefisto:merge ${args} en cola.`
708}
709
710async function viewPr($: EngineInterface, preset: string | null = null): Promise<string> {
711 const prs = resultPrs(await read($, resultsAtom))
712 if (prs.length === 0) return 'No hay PRs de corridas terminadas.'
713 let pr = preset
714 if (!pr) {
715 if (prs.length === 1) pr = prs[0]!.pr
716 else {
717 const options = viewOptions(prs)
718 const answer = await $.ui.ask('¿Cuál PR ver?', { header: 'PR', options }).catch(() => null)
719 pr = viewPrOf(answer, prs)
720 }
721 }
722 if (!pr) return 'Sin PR elegido.'
723 await $.process.run(['gh', 'pr', 'view', pr, '--web']).catch(() => undefined)
724 return `PR #${pr} abierto en GitHub.`
725}
726
727// La carpeta de las field notes es la del checkout principal, donde trabaja el historiador del consumidor.
728async function repoDir($: EngineInterface): Promise<string> {
729 return runsRepo || (await $.session.cwd())
730}
731
732async function refreshFieldNotes($: EngineInterface) {
733 const entries = await $.fs.list(`${await repoDir($)}/${FIELD_NOTES_DIR}`).catch(() => null)
734 await update($, fieldNotesAtom, () => (entries ? fieldNotesIn(entries.filter(n => n.kind === 'file').map(n => n.name)) : 0))
735}
736
737// Mientras el historiador corre (se sondea `$.agent.list()`: el tipo lleva el prefijo del plugin), la banda lo muestra
738// escribiendo con sus field notes: pendientes hasta que aparecen en `procesadas/`.
739async function pollHistorian($: EngineInterface) {
740 if (isPollingHistorian || !isEligible || !isWanted || Date.now() - lastHistorianPollMs < HISTORIAN_POLL_MS) return
741 isPollingHistorian = true
742 lastHistorianPollMs = Date.now()
743 try {
744 const agents = await $.agent.list().catch(() => [])
745 const active = agents.some(a => isHistorianType(a.type) && isAgentActive(a.status))
746 const prev = await read($, historianAtom)
747 const next = historianFrom(prev, active, Date.now())
748 if (next !== prev) {
749 await update($, historianAtom, () => next)
750 if (next && !next.finishedMs) {
751 await captureNotes($)
752 return
753 }
754 if (next?.finishedMs) {
755 lastNotesPollMs = 0
756 await pollNotes($)
757 await findBitacoraPr($)
758 void refreshOpenPrs($)
759 void refreshFieldNotes($)
760 }
761 return
762 }
763 if (next && !next.finishedMs) await pollNotes($)
764 if (next?.pr) await checkBitacoraPr($, next.pr.number)
765 if (next && next.finishedMs && !next.pr && !liveHistorian(next, Date.now())) await update($, historianAtom, () => null)
766 } finally {
767 isPollingHistorian = false
768 }
769}
770
771async function captureNotes($: EngineInterface) {
772 const local = await $.fs.list(`${await repoDir($)}/${FIELD_NOTES_DIR}`).catch(() => [])
773 const notePrs = (await read($, openPrsAtom)).filter(p => p.isFieldNote).map(p => p.number)
774 const files = await Promise.all(
775 notePrs.map(n => $.process.run(['gh', 'pr', 'view', n, '--json', 'files', '-q', '.files[].path']).catch(() => ({ exitCode: 1, stdout: '' }))),
776 )
777 const paths = files.flatMap(f => (f.exitCode === 0 ? f.stdout.split('\n').filter(Boolean) : []))
778 const names = fieldNoteNamesOf(local.filter(n => n.kind === 'file').map(n => n.name), paths)
779 await update($, historianAtom, h => (h && h.notes.length === 0 ? { ...h, notes: pendingNotes(names) } : h))
780 lastNotesPollMs = 0
781 await pollNotes($)
782}
783
784async function pollNotes($: EngineInterface) {
785 const h = await read($, historianAtom)
786 if (!h || !h.notes.some(n => n.estado === 'pendiente') || Date.now() - lastNotesPollMs < NOTES_POLL_MS) return
787 lastNotesPollMs = Date.now()
788 const listed = await $.fs.list(`${await repoDir($)}/${PROCESSED_NOTES_DIR}`).catch(() => [])
789 const processed = listed.filter(n => n.kind === 'file').map(n => n.name)
790 await update($, historianAtom, cur => (cur ? withProcessedNotes(cur, processed) : cur))
791}
792
793// Al terminar, un PR de bitacora abierto se ofrece para mergear, ver o cerrar; si no queda ninguno (el skill ya lo
794// mergeo), la cinta se va sola a los 8 s (el vencimiento tambien se calcula al dibujar).
795async function findBitacoraPr($: EngineInterface) {
796 const { exitCode, stdout } = await $.process.run(['gh', ...BITACORA_PR_ARGS]).catch(() => ({ exitCode: 1, stdout: '' }))
797 const pr = exitCode === 0 ? bitacoraPrOf(stdout) : null
798 if (!pr) return
799 lastBitacoraPrCheckMs = Date.now()
800 await update($, historianAtom, h => (h ? { ...h, pr } : h))
801}
802
803// Si el PR ofrecido se mergeo o cerro por fuera, la cinta se va en el siguiente chequeo.
804async function checkBitacoraPr($: EngineInterface, pr: number) {
805 if (Date.now() - lastBitacoraPrCheckMs < PR_CHECK_MS) return
806 lastBitacoraPrCheckMs = Date.now()
807 const { exitCode, stdout } = await $.process.run(['gh', 'pr', 'view', String(pr), '--json', 'state', '-q', '.state']).catch(() => ({ exitCode: 1, stdout: '' }))
808 if (exitCode === 0 && stdout.trim() !== 'OPEN') await dismissHistorian($)
809}
810
811async function dismissHistorian($: EngineInterface) {
812 await update($, historianAtom, () => null)
813 void refreshOpenPrs($)
814}
815
816async function mergeBitacoraPr($: EngineInterface) {
817 const pr = (await read($, historianAtom))?.pr
818 if (!pr) return
819 await dismissHistorian($)
820 const args = String(pr.number)
821 await startMerge($, args)
822 await $.command.run({ command: 'mefisto:merge', args })
823}
824
825async function openBitacoraPr($: EngineInterface) {
826 const pr = (await read($, historianAtom))?.pr
827 if (pr) await $.process.run(['gh', 'pr', 'view', String(pr.number), '--web']).catch(() => undefined)
828}
829
830async function closeBitacoraPr($: EngineInterface) {
831 const pr = (await read($, historianAtom))?.pr
832 if (!pr) return
833 const answer = await $.ui
834 .ask(`¿Cerrar el PR #${pr.number} de la bitácora sin mergear y borrar su rama?`, { header: 'Cerrar PR', options: ['Cerrar PR', 'Cancelar'] })
835 .catch(() => null)
836 if (answer !== 'Cerrar PR') return
837 const { exitCode, stderr } = await $.process
838 .run(['gh', 'pr', 'close', String(pr.number), '--delete-branch'])
839 .catch(err => ({ exitCode: 1, stderr: String(err) }))
840 if (exitCode !== 0) {
841 $.ui.toast(`no se pudo cerrar el PR #${pr.number}: ${clip(stderr ?? '', 80)}`)
842 return
843 }
844 $.ui.toast(`PR #${pr.number} cerrado`)
845 await dismissHistorian($)
846}
847
848// Integrar la bitacora: elegir en el dialogo es la aprobacion; el prompt se envia como mensaje del usuario.
849async function chooseBitacora($: EngineInterface) {
850 const local = (await read($, fieldNotesAtom)) ?? 0
851 const prs = (await read($, openPrsAtom)).filter(p => p.isFieldNote).map(p => p.number)
852 if (local + prs.length === 0) return
853 const integrate = bitacoraIntegrateLabel(prs.length)
854 const answer = await $.ui.ask(bitacoraQuestion(local, prs.length), { header: 'Bitácora', options: [integrate, 'Cancelar'] }).catch(() => null)
855 if (answer !== integrate) return
856 await $.prompt.submit({ text: bitacoraPrompt(prs), asUser: true })
857}
858
859async function refresh($: EngineInterface) {
860 if (isRefreshing || !isEligible || !isWanted) return
861 isRefreshing = true
862 try {
863 if (agentChecksLeft > 0) {
864 agentChecksLeft -= 1
865 const agent = await plannerSession($)
866 if (agent !== null) agentChecksLeft = 0
867 if (agent === true) {
868 isEligible = false
869 await deactivate($)
870 isWanted = true
871 return
872 }
873 }
874 await refreshRuns($)
875 await refreshOpenPrs($)
876 await refreshFieldNotes($)
877 const list = await runNextOrder($)
878 await update($, listAtom, () => list)
879 } finally {
880 isRefreshing = false
881 }
882}
883
884// El comando queda escrito sin Enter: la persona lo revisa y lo envia (MEF-ADR-0055 decision 2).
885async function applyPlan($: EngineInterface, plan: LaunchPlan): Promise<boolean> {
886 if (plan.kind === 'none') {
887 $.ui.toast(plan.message)
888 return false
889 }
890 const text = plan.kind === 'fill'
891 ? plan.text
892 : answerText(plan.options, await $.ui.ask(plan.question, plan.options.map(o => o.label)).catch(() => null))
893 if (text === null) return false
894 await $.prompt.fill({ text, mode: 'replace' })
895 return true
896}
897
898async function launchKey($: EngineInterface, kind: LaunchKind) {
899 const list = await read($, listAtom)
900 if (list && !list.error) await applyPlan($, planLaunch(list, kind))
901}
902
903async function launchRow($: EngineInterface, issue: number) {
904 const item = (await read($, listAtom))?.items.find(i => i.number === issue)
905 if (item) await applyPlan($, planIssue(item))
906}
907
908async function activate($: EngineInterface) {
909 isWanted = true
910 await update($, activeAtom, () => true)
911 timer?.cancel()
912 timer = $.clock.every(POLL_MS, () => void refresh($))
913 void loadVersion($)
914 runsTimer?.cancel()
915 // Ritmo rapido solo con corridas activas; sin ellas, `refresh` las relee al ritmo de los listos (CA-5).
916 runsTimer = $.clock.every(RUNS_POLL_MS, () => {
917 if (Object.keys(seenRuns).length > 0 || lastBatchIssues !== null || Date.now() < launchPollUntilMs) void refreshRuns($)
918 })
919 prTimer?.cancel()
920 prTimer = $.clock.every(PR_CHECK_MS, () => void closeMergedPrs($))
921 mergeTimer?.cancel()
922 mergeTimer = $.clock.every(MERGE_POLL_MS, () => void pollMerge($))
923 tickTimer?.cancel()
924 // La mascota anima solo con corrida activa o con Claude trabajando; en reposo no hay re-render por segundo.
925 tickTimer = $.clock.every(1000, () => {
926 if (isAnimating) void update($, tickAtom, () => Math.floor(Date.now() / 1000))
927 void closeStaleMerge($)
928 void pollHistorian($)
929 })
930 void refresh($)
931}
932
933async function deactivate($: EngineInterface) {
934 isWanted = false
935 if (isLogOpen) await closeLog($)
936 timer?.cancel()
937 timer = null
938 runsTimer?.cancel()
939 runsTimer = null
940 tickTimer?.cancel()
941 tickTimer = null
942 mergeTimer?.cancel()
943 mergeTimer = null
944 prTimer?.cancel()
945 prTimer = null
946 await update($, activeAtom, () => false)
947}
948
949async function isMefistoRepo($: EngineInterface): Promise<boolean> {
950 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => ({ exitCode: 1, stdout: '' }))
951 if (top.exitCode !== 0) return false
952 const raw = await $.fs.read(`${top.stdout.trim()}/.claude-plugin/plugin.json`).catch(() => '')
953 return typeof raw === 'string' && isMefistoManifest(raw)
954}
955
956// Agente principal de la sesion: primero la linea de comando de Claude Code, luego el transcript si ya existe.
957async function plannerSession($: EngineInterface): Promise<boolean | null> {
958 const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
959 const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
960 if (/\bclaude\b/.test(cmdline)) return agentFlagOf(cmdline) === PLANNER_AGENT
961 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
962 const path = transcriptPathOf(home, await $.session.cwd(), await $.session.id())
963 const { stdout } = await $.process.run(['head', '-c', '200000', path]).catch(() => ({ stdout: '' }))
964 const agent = agentSettingOf(stdout)
965 return agent === null ? null : agent === PLANNER_AGENT
966}
967
968// El engine no entrega classic.* a un plugin de tier user y session.start no se repite tras /clear, /resume ni /branch:
969// cada segundo se compara la intencion del modulo con el atom y se rearma la banda. Corre desde el reloj, donde escribir esta permitido.
970async function heal($: EngineInterface) {
971 if (isHealing) return
972 isHealing = true
973 try {
974 if (!shouldRearm({ isEligible, isWanted, isActive: await read($, activeAtom) })) return
975 await activate($)
976 await $.ui.invalidate('ui.render')
977 } finally {
978 isHealing = false
979 }
980}
981
982// Cinta vencida: una arrancada por teclas cuyo skill nunca invoco pr-sync.sh (p. ej. descarto todos los PRs).
983async function closeStaleMerge($: EngineInterface) {
984 if (!isEligible || !isWanted || isMergeScriptSeen) return
985 const run = await read($, mergeAtom)
986 if (run && run.finishedMs === null && Date.now() - run.startedMs > MERGE_STALE_MS) await endMerge($)
987}
988
989export const register: Register = on => {
990 // La senal del merge es la llamada Bash a pr-sync.sh: el plugin carga en tier user y el engine no le entrega classic.*.
991 // Convive con el tool.call Bash del tablero (mismo matcher, #2302).
992 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
993 if (isEligible && isWanted && isPipelineLaunch(e.command)) {
994 launchPollUntilMs = Date.now() + LAUNCH_POLL_WINDOW_MS
995 try {
996 return await next(e)
997 } finally {
998 void refreshRuns($)
999 }
1000 }
1001 const mergeArgs = isEligible && isWanted ? mergeRunArgsOf(e.command) : null
1002 if (mergeArgs === null) return next(e)
1003 await startMerge($, mergeArgs)
1004 isMergeScriptSeen = true
1005 try {
1006 return await next(e)
1007 } finally {
1008 await endMerge($)
1009 }
1010 }).catch(($, e, next) => next(e))
1011
1012 // Con matcher: el engine admite un solo session.start sin matcher por plugin (el del tablero), y asi `-p` ni lo dispara.
1013 on('session.start', { isInteractive: true }, async ($, e, next) => {
1014 isEligible = false
1015 const agent = (await isMefistoRepo($)) ? true : await plannerSession($)
1016 if (agent === true) {
1017 await deactivate($)
1018 isWanted = true
1019 return next(e)
1020 }
1021 isEligible = true
1022 agentChecksLeft = agent === null ? AGENT_CHECKS : 0
1023 await $.command.register({
1024 name: 'fausto-blood-pact',
1025 description: 'Consola de Fausto: refresh | on | off | ocultar | detener | lanzar | merge | prs | pr | log',
1026 argumentHint: '[refresh|on|off|ocultar|detener|lanzar [sequential|parallel|<n>]|merge [<pr>...]|prs|pr [<pr>]|log]',
1027 immediate: true,
1028 })
1029 if (isWanted) await activate($)
1030 healTimer ??= $.clock.every(HEAL_MS, () => void heal($))
1031 await $.ui.invalidate('ui.render')
1032 return next(e)
1033 })
1034
1035 on('command.run', { command: 'fausto-blood-pact' }, async ($, e) => {
1036 const arg = e.args.trim()
1037 if (!isEligible) return { text: 'La consola no aplica en esta sesión.' }
1038 if (arg === 'off') {
1039 await deactivate($)
1040 return { text: 'Consola de Fausto apagada.' }
1041 }
1042 if (arg === 'ocultar' || arg === 'descartar') {
1043 const n = await dismissResults($)
1044 return { text: n > 0 ? `Resultados ocultados: ${n}.` : 'No hay resultados que ocultar.' }
1045 }
1046 if (arg === 'detener') {
1047 const batch = await read($, batchAtom)
1048 if (!batch || !isBatchRunning(batch)) return { text: 'No hay un sequential en curso.' }
1049 return { text: await confirmStop($) }
1050 }
1051 if (arg === 'merge' || arg.startsWith('merge ')) {
1052 const given = arg.slice('merge'.length).trim()
1053 if (given === '') return { text: await mergePrs($) }
1054 const { prs, invalid } = parsePrArgs(given, resultPrs(await read($, resultsAtom)))
1055 if (prs.length === 0) return { text: `Sin ✓ con PR para: ${invalid.join(' ')}.` }
1056 return { text: await mergePrs($, prs) }
1057 }
1058 if (arg === 'log') {
1059 if (isLogOpen) return { text: 'El log ya está abierto.' }
1060 return { text: (await openLog($)) ? 'Log abierto.' : 'No hay corrida que seguir.' }
1061 }
1062 if (arg === 'prs') return { text: await mergeOpenPrs($) }
1063 if (arg === 'pr' || arg.startsWith('pr ')) {
1064 const given = arg.slice('pr'.length).trim()
1065 if (given === '') return { text: await viewPr($) }
1066 const { prs, invalid } = parsePrArgs(given, resultPrs(await read($, resultsAtom)))
1067 return { text: prs[0] ? await viewPr($, prs[0]) : `Sin ✓ con PR para: ${invalid.join(' ')}.` }
1068 }
1069 if (arg === 'lanzar' || arg.startsWith('lanzar ')) {
1070 const list = await read($, listAtom)
1071 if (!list || list.error) return { text: 'No hay lista de lanzables.' }
1072 const target = parseLaunchArg(arg.slice('lanzar'.length), list)
1073 if (target.kind === 'invalid') return { text: target.message }
1074 const plan = target.kind === 'launch' ? planLaunch(list, target.launch) : planIssue(list.items.find(i => i.number === target.issue)!)
1075 if (plan.kind === 'none') return { text: plan.message }
1076 return { text: (await applyPlan($, plan)) ? 'Comando escrito en el prompt.' : 'Sin cambios en el prompt.' }
1077 }
1078 if (arg === 'on' || !isWanted) {
1079 await activate($)
1080 return { text: 'Consola de Fausto activa.' }
1081 }
1082 await loadVersion($)
1083 await refresh($)
1084 return { text: 'Consola actualizada.' }
1085 })
1086
1087 // Cerrar el pane por su cuenta (esc o ui.close) tambien suelta los `tail`.
1088 on('ui.close', { id: PANE }, async ($, e, next) => {
1089 isLogOpen = false
1090 await stopFollows()
1091 return next(e)
1092 })
1093
1094 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1095 const { Box, Text, Button } = $.ui.resolve(e)
1096 const run = await read($, paneRunAtom)
1097 if (!run) {
1098 return (
1099 <Box flexDirection="column">
1100 <Text dimColor>Sin corrida que seguir.</Text>
1101 </Box>
1102 )
1103 }
1104 const batch = await read($, batchAtom)
1105 const results = await read($, resultsAtom)
1106 const lines = await read($, logLinesAtom)
1107 await read($, tickAtom)
1108 const width = Math.max(20, e.props.bodyColumns ?? 60)
1109 const rows = Math.max(3, (e.props.scroll?.bodyRows ?? PANE_ROWS) - PANE_FIXED_ROWS)
1110 const ended = run.state !== 'running' && run.state !== 'hold'
1111 const endMs = ended ? Date.parse(run.updated) || Date.now() : Date.now()
1112 const mins = minutesSince(run.started, endMs)
1113 const pr = resultPrs(results).find(p => p.issue === run.issue)?.pr ?? (batch ? batchPrOf(batch) : null)
1114 const colorOf = (l: PaneLine) => (l.kind === 'fail' ? 'error' : l.kind === 'done' ? 'success' : l.kind === 'start' ? 'claude' : undefined)
1115 return (
1116 <Box flexDirection="column" width={width}>
1117 <Box justifyContent="space-between">
1118 <Text bold color="claude" wrap="truncate-end">{paneHeaderOf(run, batch)}</Text>
1119 <Text dimColor>{mins === null ? '' : `${mins}m`}</Text>
1120 </Box>
1121 {run.title ? <Text wrap="truncate-end">{run.title}</Text> : null}
1122 <Text wrap="truncate-end">
1123 {stepsOf(run).map((st, i) => (
1124 <Text key={`pane-step-${i}`} color={st.color} dimColor={st.dim}>
1125 {`${i > 0 ? ' ─ ' : ''}${st.mark} ${st.name}`}
1126 {(() => {
1127 const d = durationText(run.agents?.[i]?.duration)
1128 return d ? ` ${d}` : ''
1129 })()}
1130 </Text>
1131 ))}
1132 </Text>
1133 {run.state === 'completed' && (
1134 <Box flexDirection="column">
1135 <Box gap={3}>
1136 <Text color="success" bold>✓ {pr ? `PR #${pr} creado` : 'terminado'}</Text>
1137 {pr && <Button key="merge" hotkey="m" plain variant="primary" label={`Mergear #${pr}`} onPress={() => void mergePrs($, [pr])} />}
1138 {pr && <Button key="web" hotkey="v" plain label="Ver en GitHub" onPress={() => void viewPr($, pr)} />}
1139 <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1140 </Box>
1141 <Text dimColor wrap="truncate-end">ctrl+x tab enfoca · esc vuelve al prompt</Text>
1142 </Box>
1143 )}
1144 {isFailure(run) && (
1145 <Box flexDirection="column">
1146 <Text color="error" wrap="wrap">✗ falló en {agentOf(run.stage)}{run.lastError ? `: ${run.lastError}` : ''}</Text>
1147 <Box gap={2}>
1148 <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1149 </Box>
1150 </Box>
1151 )}
1152 {!ended && (
1153 <Box gap={2}>
1154 <Button key="close" hotkey="c" plain label="Cerrar log" role="dismiss" onPress={() => void closeLog($)} />
1155 <Text dimColor>3 en la banda también lo cierra · ctrl+x tab enfoca · esc vuelve al prompt</Text>
1156 </Box>
1157 )}
1158 <Text dimColor>{'─'.repeat(Math.min(width, 80))}</Text>
1159 <Box flexDirection="column">
1160 {lines.length === 0 && <Text dimColor>esperando eventos del agente…</Text>}
1161 {lines.slice(-rows).map((line, i) => (
1162 <Text key={`pane-line-${i}`} wrap="truncate-end" color={colorOf(line)} dimColor={line.kind === 'log'}>
1163 {line.text}
1164 </Text>
1165 ))}
1166 </Box>
1167 </Box>
1168 )
1169 })
1170
1171 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1172 const isActive = await read($, activeAtom)
1173 if (!isEligible || !isWanted || !isActive || e.props.hasSurvey) return next(e)
1174 const elements = $.ui.resolve(e)
1175 const { Box, Text, Button } = elements
1176 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
1177 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
1178 const runs = await read($, runsAtom)
1179 const results = await read($, resultsAtom)
1180 const batch = await read($, batchAtom)
1181 const bandLines = await read($, bandLinesAtom)
1182 const openPrs = await read($, openPrsAtom)
1183 const mergeRun = await read($, mergeAtom)
1184 await read($, tickAtom)
1185 const isWorking = Boolean(e.props.isWorking)
1186 const sec = Math.floor(Date.now() / 1000)
1187 const batchRun = batch ? batchRunOf(batch, runs) : null
1188 const active = batch ? batchRun : activeRunOf(runs)
1189 // El vencimiento se calcula al dibujar: el render nunca escribe estado. La cinta terminada sigue pidiendo
1190 // re-render por segundo hasta vencer, asi se oculta a los 15 s sin depender de $.clock.after.
1191 const ribbon = mergeRibbonShown(batch, mergeRun, Date.now())
1192 const historian = liveHistorian(await read($, historianAtom), Date.now())
1193 isAnimating = isWorking || active !== null || (batch !== null && isBatchRunning(batch)) || ribbon !== null || historian !== null
1194 const pose: MascotPose | null = active
1195 ? poseOfRun(active, await read($, lastEventAtom))
1196 : batch
1197 ? poseOfBatch(batch)
1198 : poseOfResults(results)
1199 const grid = ribbon
1200 ? cropGrid(sprite('merge', ribbon.finishedMs === null ? 'cosiendo' : 'listo', ribbon.finishedMs === null ? (sec % 2 === 0 ? 0 : 1) : 0), MASCOT_COLS)hooks/register.tsx 649 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardFocus, BoardList, BoardTab } from './types'
5import {
6 nextOrderPath,
7 typeBadge,
8 agentFlagOf,
9 isMefistoManifest,
10 agentSettingOf,
11 transcriptPathOf,
12 arrivals,
13 clip,
14 createdIssueOf,
15 showsDraftList,
16 createdText,
17 cropGrid,
18 isIssueChange,
19 issueClosed,
20 addPending,
21 applyPending,
22 dropIssue,
23 type Pending,
24 isIssueCreate,
25 usedColumns,
26 mascotPose,
27 isPlannerClosing,
28 issueMarkedListo,
29 numberWidth,
30 padEnd,
31 pageOf,
32 pageSizeOf,
33 parseNextOrder,
34 parseOpenIssues,
35 reasonOf,
36 refineTargetOf,
37 signatureOf,
38 topicOf,
39 versionLabel,
40 shouldRearm,
41} from './logic'
42import { DEFAULT_COLOR, HEIGHT, PALETTE, RASTER_ROWS, sprite } from './sprites'
43
44const PLANNER_AGENT = 'mefisto:planner'
45const POLL_MS = 60_000
46const SCRIPT_TIMEOUT_MS = 180_000
47const FLASH_MS = 8_000
48// GitHub tarda en reflejar un cambio en todas sus consultas: un refresco inmediato puede guardar la firma nueva
49// con listas viejas de next-order y el poll ya no vuelve a refrescar. Se repite forzado tras este margen.
50const SETTLE_MS = 15_000
51
52const activeAtom = atom({ plugin: 'mefisto', key: 'isActive' } as const, false)
53const plannerAtom = atom({ plugin: 'mefisto', key: 'isPlannerSession' } as const, false)
54const expandedAtom = atom({ plugin: 'mefisto', key: 'isExpanded' } as const, false)
55const pageAtom = atom({ plugin: 'mefisto', key: 'page' } as const, 0)
56const tabAtom = atom({ plugin: 'mefisto', key: 'tab' } as const, 'borrador')
57const focusAtom = atom({ plugin: 'mefisto', key: 'focus' } as const, null)
58const confirmAtom = atom({ plugin: 'mefisto', key: 'isConfirmingClose' } as const, false)
59const flashAtom = atom({ plugin: 'mefisto', key: 'flash' } as const, null)
60const refineAtom = atom({ plugin: 'mefisto', key: 'refine' } as const, null)
61const developAtom = atom({ plugin: 'mefisto', key: 'develop' } as const, null)
62const updatedAtom = atom({ plugin: 'mefisto', key: 'updatedMs' } as const, 0)
63const signatureAtom = atom({ plugin: 'mefisto', key: 'signature' } as const, '')
64const frameAtom = atom({ plugin: 'mefisto', key: 'frame' } as const, 0)
65const stepsAtom = atom({ plugin: 'mefisto', key: 'stepsInTurn' } as const, 0)
66const workingAtom = atom({ plugin: 'mefisto', key: 'isWorking' } as const, false)
67const knownAtom = atom({ plugin: 'mefisto', key: 'known' } as const, [])
68const pendingAtom = atom({ plugin: 'mefisto', key: 'pendingCount' } as const, 0)
69
70let isInteractive = false
71// La intencion vive aqui: /clear no dispara session.start y reinicia los atoms.
72let isBoardOn = false
73let isTurnedOff = false
74let isHealing = false
75let healTimer: { cancel: () => void } | null = null
76const HEAL_MS = 1_000
77let pending: Pending = new Map()
78let isRefreshing = false
79let settle: { cancel: () => void } | null = null
80let timer: { cancel: () => void } | null = null
81let animation: { cancel: () => void } | null = null
82const ANIMATION_MS = 600
83
84// Raiz del plugin: la que el engine expone como $.plugin.root; si faltara, el archivo que escribe el SessionStart del plugin.
85async function pluginRoot($: EngineInterface): Promise<string | null> {
86 const own = $.plugin.root
87 if (own) return own
88 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => ({ exitCode: 1, stdout: '' }))
89 const base = top.exitCode === 0 ? top.stdout.trim() : await $.session.cwd()
90 const root = await $.fs.read(`${base}/.mefisto/pipeline/.plugin-root`).catch(() => '')
91 return typeof root === 'string' && root.trim() !== '' ? root.trim() : null
92}
93
94const versionAtom = atom({ plugin: 'mefisto', key: 'version' } as const, null as string | null)
95
96async function loadVersion($: EngineInterface) {
97 const root = await pluginRoot($)
98 const base = root ? root.replace(/\/+$/, '') : null
99 const read1 = async (file: string) => (base ? await $.fs.read(`${base}/${file}`).catch(() => null) : null)
100 const label = versionLabel(await read1('mefisto-manifest.json'), await read1('.claude-plugin/plugin.json'))
101 await update($, versionAtom, () => label)
102}
103
104async function runNextOrder($: EngineInterface, args: string[]): Promise<BoardList> {
105 const root = await pluginRoot($)
106 if (!root) return parseNextOrder(2, '', 'raiz del plugin desconocida')
107 const { exitCode, stdout, stderr } = await $.process
108 .run([nextOrderPath(root), ...args], { timeoutMs: SCRIPT_TIMEOUT_MS })
109 .catch(err => ({ exitCode: 2, stdout: '', stderr: String(err) }))
110 return parseNextOrder(exitCode, stdout, stderr)
111}
112
113async function refresh($: EngineInterface, isForced: boolean) {
114 if (isRefreshing || !(await read($, activeAtom))) return
115 isRefreshing = true
116 try {
117 const { exitCode, stdout } = await $.process
118 .run(['gh', 'issue', 'list', '--state', 'open', '--limit', '300', '--json', 'number,title,labels,updatedAt'])
119 .catch(() => ({ exitCode: 1, stdout: '' }))
120 const issues = exitCode === 0 ? parseOpenIssues(stdout) : null
121 if (!issues) return
122
123 const signature = signatureOf(issues)
124 if (!isForced && pending.size === 0 && signature === (await read($, signatureAtom))) return
125
126 const focus = await read($, focusAtom)
127 for (const a of arrivals(issues, await read($, knownAtom))) {
128 if (!focus?.created.includes(a.number)) $.ui.toast(`Nuevo ${a.kind} #${a.number}: ${clip(a.title, 60)}`)
129 }
130 const [refine, develop] = await Promise.all([runNextOrder($, ['--refinement', '--json']), runNextOrder($, ['--json'])])
131 const applied = applyPending(refine, pending, Date.now())
132 pending = applied.pending
133 await update($, refineAtom, () => applied.list)
134 await update($, pendingAtom, () => pending.size)
135 await update($, developAtom, () => develop)
136 await update($, signatureAtom, () => signature)
137 await update($, knownAtom, () => issues.map(i => i.number))
138 await update($, updatedAtom, () => Date.now())
139 } finally {
140 isRefreshing = false
141 }
142}
143
144async function activate($: EngineInterface) {
145 isBoardOn = true
146 isTurnedOff = false
147 await update($, activeAtom, () => true)
148 timer?.cancel()
149 timer = $.clock.every(POLL_MS, () => void refresh($, false))
150 void loadVersion($)
151 void refresh($, true)
152}
153
154async function deactivate($: EngineInterface) {
155 isTurnedOff = true
156 pending = new Map()
157 await update($, pendingAtom, () => 0)
158 timer?.cancel()
159 timer = null
160 await update($, activeAtom, () => false)
161}
162
163// El engine no entrega classic.* a un plugin de tier user y session.start no se repite tras /clear, /resume ni /branch:
164// cada segundo se compara la intencion del modulo con el atom y se rearma el tablero. Corre desde el reloj, donde escribir esta permitido.
165async function heal($: EngineInterface) {
166 if (isHealing) return
167 isHealing = true
168 try {
169 if (!shouldRearm({ isEligible: isBoardOn, isWanted: !isTurnedOff, isActive: await read($, activeAtom) })) return
170 await activate($)
171 await $.ui.invalidate('ui.render')
172 } finally {
173 isHealing = false
174 }
175}
176
177const DETECT_TRIES = 20
178const DETECT_EVERY_MS = 3_000
179
180// El agente principal de la sesion. Los eventos clasicos no llegan a un modulo cargado con --plugin-dir,
181// asi que se lee primero la linea de comando del proceso de Claude Code (padre del `sh` que corre aqui) y,
182// si no se puede, el inicio del transcript, que solo existe tras el primer mensaje: por eso se reintenta.
183async function detectPlanner($: EngineInterface, triesLeft: number) {
184 if (!isInteractive || isTurnedOff || (await read($, activeAtom))) return
185 const ps = await $.process.run(['sh', '-c', 'ps -o args= -p "$PPID"']).catch(() => ({ exitCode: 1, stdout: '' }))
186 const cmdline = ps.exitCode === 0 ? ps.stdout.trim() : ''
187 if (/\bclaude\b/.test(cmdline)) {
188 if (agentFlagOf(cmdline) === PLANNER_AGENT) await claimPlanner($)
189 return
190 }
191 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
192 const path = transcriptPathOf(home, await $.session.cwd(), await $.session.id())
193 const { stdout } = await $.process.run(['head', '-c', '200000', path]).catch(() => ({ stdout: '' }))
194 const agent = agentSettingOf(stdout)
195 if (agent === PLANNER_AGENT) {
196 await claimPlanner($)
197 return
198 }
199 if (agent === null && triesLeft > 0) $.clock.after(DETECT_EVERY_MS, () => void detectPlanner($, triesLeft - 1))
200}
201
202// Marca la sesion como del planner y la activa si ya se sabe que es interactiva. Devuelve si la activo ahora.
203async function claimPlanner($: EngineInterface): Promise<boolean> {
204 await update($, plannerAtom, () => true)
205 if (!isInteractive || (await read($, activeAtom))) return false
206 await activate($)
207 return true
208}
209
210// Cada instruccion del agente (una herramienta, o tu mensaje que abre el turno) alterna el cuadro A/B.
211async function step($: EngineInterface, isNewTurn: boolean) {
212 await update($, frameAtom, f => (f === 1 ? 0 : 1))
213 await update($, stepsAtom, n => (isNewTurn ? 0 : (n ?? 0) + 1))
214}
215
216// Recorte comun a todos los cuadros del planner, calculado una vez: sin margen y sin saltos al alternar.
217const MASCOT_STATES = ['planeando', 'pensando', 'listo'] as const
218const MASCOT_COLS = usedColumns(MASCOT_STATES.flatMap(state => [sprite('planner', state, 0), sprite('planner', state, 1)]))
219const MASCOT_WIDTH = MASCOT_COLS.to - MASCOT_COLS.from + 1
220
221/** Celdas del Raster (dos pixeles por celda con ▀/▄), como toRasterCells de sprites.ts pero con el ancho recortado. */
222function mascotCells(grid: readonly string[]): string {
223 const color = (ch: string | undefined) => (!ch || ch === '.' || ch === ' ' ? null : (PALETTE[ch] ?? null))
224 const words: number[] = []
225 for (let y = 0; y < HEIGHT; y += 2) {
226 for (let x = 0; x < MASCOT_WIDTH; x++) {
227 const top = color(grid[y]?.[x])
228 const bottom = color(grid[y + 1]?.[x])
229 if (top !== null) words.push(0x2580, top, bottom ?? DEFAULT_COLOR)
230 else if (bottom !== null) words.push(0x2584, bottom, DEFAULT_COLOR)
231 else words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
232 }
233 }
234 let bin = ''
235 for (const b of new Uint8Array(Uint32Array.from(words).buffer)) bin += String.fromCharCode(b)
236 return btoa(bin)
237}
238
239// Mientras Claude trabaja (pensando o con herramientas) la mascota alterna sola; en reposo queda quieta.
240async function setWorking($: EngineInterface, isWorking: boolean) {
241 await update($, workingAtom, () => isWorking)
242 animation?.cancel()
243 animation = isWorking ? $.clock.every(ANIMATION_MS, () => void update($, frameAtom, f => (f === 1 ? 0 : 1))) : null
244}
245
246async function startFocus($: EngineInterface, focus: BoardFocus) {
247 await update($, confirmAtom, () => false)
248 await update($, flashAtom, () => null)
249 await update($, focusAtom, () => focus)
250}
251
252async function closeFocus($: EngineInterface, summary: string) {
253 await update($, confirmAtom, () => false)
254 await update($, focusAtom, () => null)
255 await update($, flashAtom, () => ({ text: summary, untilMs: Date.now() + FLASH_MS }))
256 $.clock.after(FLASH_MS, () => void update($, flashAtom, () => null))
257 void refresh($, true)
258}
259
260function summaryOf(focus: BoardFocus, isListo: boolean): string {
261 const created = createdText(focus.created)
262 const head =
263 focus.kind === 'refinar'
264 ? `✓ #${focus.issue}${isListo ? ' → listo' : ' · refinamiento cerrado'}`
265 : `✓ exploración «${clip(focus.topic, 40)}»`
266 return [head, created].filter(Boolean).join(' · ')
267}
268
269async function onPrompt($: EngineInterface, text: string) {
270 const focus = await read($, focusAtom)
271 const target = refineTargetOf(text)
272 if (target !== null && !(focus?.kind === 'refinar' && focus.issue === target)) {
273 const known = (await read($, refineAtom))?.items.find(i => i.number === target)
274 await startFocus($, { kind: 'refinar', issue: target, topic: known?.title ?? '', created: [], startedMs: Date.now() })
275 return
276 }
277 if (!focus && target === null && text.trim() !== '' && !text.trim().startsWith('/')) {
278 await startFocus($, { kind: 'explorar', issue: null, topic: topicOf(text), created: [], startedMs: Date.now() })
279 }
280}
281
282function refreshAfterSettle($: EngineInterface) {
283 settle?.cancel()
284 settle = $.clock.after(SETTLE_MS, () => void refresh($, true))
285}
286
287// Quita #N de la lista de borradores en el acto y lo oculta en los refrescos siguientes hasta que se confirme.
288async function hideFromDrafts($: EngineInterface, issue: number) {
289 pending = addPending(pending, issue, Date.now())
290 await update($, refineAtom, list => dropIssue(list, issue))
291 await update($, pendingAtom, () => pending.size)
292}
293
294async function onBash($: EngineInterface, command: string, output: string) {
295 const transitioned = issueMarkedListo(command) ?? issueClosed(command)
296 if (transitioned !== null) await hideFromDrafts($, transitioned)
297 if (isIssueChange(command)) refreshAfterSettle($)
298 const focus = await read($, focusAtom)
299 if (!focus) return
300 // Un mismo comando puede crear un borrador y pasar el foco a listo: se revisan las dos cosas.
301 if (isIssueCreate(command)) {
302 const created = createdIssueOf(output)
303 if (created !== null && !focus.created.includes(created)) {
304 if (focus.kind === 'explorar' && focus.created.length === 0) await update($, pageAtom, () => 0)
305 await update($, focusAtom, f => (f ? { ...f, created: [...f.created, created] } : f))
306 void refresh($, true)
307 }
308 }
309 const listo = issueMarkedListo(command)
310 const updated = (await read($, focusAtom)) ?? focus
311 if (focus.kind === 'refinar' && listo !== null && listo === focus.issue) {
312 await closeFocus($, summaryOf(updated, true))
313 return
314 }
315 if (isPlannerClosing(command)) await closeFocus($, summaryOf(updated, false))
316}
317
318// Cierre de la exploracion en dos teclas dentro de la banda: el planner corre sin AskUserQuestion, asi
319// que $.ui.ask se rechaza. Al confirmar le pide su rutina de cierre (resumen + field note); el foco se
320// cierra solo cuando el planner corre field-note.sh.
321async function closeSession($: EngineInterface, isConfirmed: boolean) {
322 if (!isConfirmed) {
323 await update($, confirmAtom, () => true)
324 return
325 }
326 await update($, confirmAtom, () => false)
327 await $.prompt.submit({ text: 'Cerremos la sesión.', asUser: true })
328}
329
330// La misma tecla abre su lista o, si ya estaba abierta, la cierra.
331async function toggleList($: EngineInterface, tab: BoardTab) {
332 if (await read($, focusAtom)) return
333 const isOpen = (await read($, expandedAtom)) && (await read($, tabAtom)) === tab
334 await update($, tabAtom, () => tab)
335 await update($, pageAtom, () => 0)
336 await update($, expandedAtom, () => !isOpen)
337}
338
339// Avanza de pagina y da la vuelta al final.
340async function nextPage($: EngineInterface) {
341 const tab = showsDraftList(await read($, focusAtom)) ? 'borrador' : await read($, tabAtom)
342 const list = tab === 'borrador' ? await read($, refineAtom) : await read($, developAtom)
343 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, pageSizeOf(tab))
344 await update($, pageAtom, () => (page + 1) % pages)
345}
346
347async function fill($: EngineInterface, text: string) {
348 await $.prompt.fill({ text, mode: 'replace' })
349}
350
351// En el repo del propio Mefisto el tablero es el mod interno (/mefisto-planner-board): el publicado no se registra
352// ni se activa, porque su next-order.sh alli se niega a correr y la banda solo mostraria el fallo.
353async function isMefistoRepo($: EngineInterface): Promise<boolean> {
354 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => ({ exitCode: 1, stdout: '' }))
355 if (top.exitCode !== 0) return false
356 const raw = await $.fs.read(`${top.stdout.trim()}/.claude-plugin/plugin.json`).catch(() => '')
357 return typeof raw === 'string' && isMefistoManifest(raw)
358}
359
360export const register: Register = on => {
361 on('session.start', async ($, e, next) => {
362 isInteractive = e.isInteractive
363 if (await isMefistoRepo($)) {
364 await deactivate($)
365 return next(e)
366 }
367 await $.command.register({
368 name: 'fausto-board',
369 description: 'Tablero de Fausto (planner): refresh | borradores | listos | cerrar | on | off',
370 argumentHint: '[refresh|borradores|listos|cerrar|on|off]',
371 immediate: true,
372 })
373 const isWanted = (await read($, activeAtom)) || (await read($, plannerAtom))
374 if (isInteractive && isWanted) await activate($)
375 if (isInteractive && (await read($, workingAtom))) await setWorking($, true)
376 else if (isInteractive) void detectPlanner($, DETECT_TRIES)
377 if (isInteractive) healTimer ??= $.clock.every(HEAL_MS, () => void heal($))
378 return next(e)
379 })
380
381 on('prompt.submit', async ($, e, next) => {
382 if (e.origin.kind === 'composer' && !(await read($, activeAtom))) await detectPlanner($, 0)
383 if (await read($, activeAtom)) {
384 await step($, true)
385 await setWorking($, true)
386 }
387 if (e.origin.kind === 'composer' && (await read($, activeAtom))) await onPrompt($, e.text)
388 return next(e)
389 }).catch(($, e, next) => next(e))
390
391 on('tool.call', async ($, e, next) => {
392 if (await read($, activeAtom)) await step($, false)
393 return next(e)
394 })
395
396 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
397 const ran = await next(e)
398 if (await read($, activeAtom)) {
399 const output = ran.text ?? JSON.stringify(ran.result ?? '')
400 await onBash($, e.command, output).catch(() => undefined)
401 }
402 return ran
403 })
404
405 on('turn.complete', async ($, e, next) => {
406 const done = await next(e)
407 if (await read($, activeAtom)) await setWorking($, false)
408 return done
409 })
410
411 on('command.run', { command: 'fausto-board' }, async ($, e) => {
412 const arg = e.args.trim()
413 if (arg === 'off') {
414 await deactivate($)
415 return { text: 'Tablero del planner apagado en esta sesión.' }
416 }
417 if (arg === 'on' || !(await read($, activeAtom))) {
418 await activate($)
419 return { text: 'Tablero del planner activo en esta sesión.' }
420 }
421 if (arg === 'borradores' || arg === 'listos') {
422 await toggleList($, arg === 'borradores' ? 'borrador' : 'listo')
423 return { text: `Lista de ${arg} alternada.` }
424 }
425 if (arg === 'cerrar') {
426 const focus = await read($, focusAtom)
427 if (focus) await closeFocus($, summaryOf(focus, false))
428 return { text: focus ? 'Foco cerrado.' : 'No había foco abierto.' }
429 }
430 await loadVersion($)
431 await refresh($, true)
432 return { text: 'Tablero actualizado.' }
433 })
434
435 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
436 if (!(await read($, activeAtom)) || e.props.hasSurvey) return next(e)
437 const { Box, Text, Button } = $.ui.resolve(e)
438 const focus = await read($, focusAtom)
439 const isConfirmingClose = await read($, confirmAtom)
440 const flash = await read($, flashAtom)
441 const showList = showsDraftList(focus)
442 const isExpanded = (await read($, expandedAtom)) || showList
443 const refine = await read($, refineAtom)
444 const develop = await read($, developAtom)
445 const inner = Math.max(40, (e.props.bodyColumns ?? 80) - 4)
446 const count = (l: BoardList | null) => (l ? String(l.items.length) : '…')
447 const isUpdating = (await read($, pendingAtom)) > 0
448 const tab = showList ? 'borrador' : await read($, tabAtom)
449 const suggested = refine?.items[0]?.number ?? null
450 const listButtons = (
451 <Box gap={2}>
452 <Button key="list-borrador" hotkey="3" plain variant={isExpanded && tab === 'borrador' ? 'primary' : undefined}
453 dimColor={!(isExpanded && tab === 'borrador')} label={`borradores ${count(refine)}`}
454 onPress={() => void toggleList($, 'borrador')} />
455 {isUpdating && <Text dimColor>actualizando…</Text>}
456 <Button key="list-listo" hotkey="4" plain variant={isExpanded && tab === 'listo' ? 'primary' : undefined}
457 dimColor={!(isExpanded && tab === 'listo')} label={`listos ${count(develop)}`}
458 onPress={() => void toggleList($, 'listo')} />
459 </Box>
460 )
461
462 let head
463 if (focus) {
464 const label =
465 focus.kind === 'refinar'
466 ? `refinar #${focus.issue}${focus.topic ? ` · ${focus.topic}` : ''}`
467 : `explorar · ${focus.topic}`
468 const created = createdText(focus.created)
469 head = (
470 <Box justifyContent="space-between">
471 <Box gap={2}>
472 <Text color="claude">●</Text>
473 <Text bold wrap="truncate-end">{clip(label, Math.max(20, inner - created.length - 46))}</Text>
474 {created !== '' && <Text color="success">{created}</Text>}
475 </Box>
476 {focus.kind === 'explorar' && !isConfirmingClose && (
477 <Button key="close-session" hotkey="1" plain label="cerrar sesión" onPress={() => void closeSession($, false)} />
478 )}
479 {focus.kind === 'explorar' && isConfirmingClose && (
480 <Box gap={2}>
481 <Text color="warning">¿cerrar? hará el resumen y la field note</Text>
482 <Button key="close-confirm" hotkey="1" plain label="sí, cerrar" onPress={() => void closeSession($, true)} />
483 <Button key="close-cancel" hotkey="2" plain label="seguir"
484 onPress={() => void update($, confirmAtom, () => false)} />
485 </Box>
486 )}
487 </Box>
488 )
489 } else if (flash && flash.untilMs > Date.now()) {
490 head = (
491 <Box justifyContent="space-between">
492 <Text color="success">{clip(flash.text, inner - 34)}</Text>
493 {listButtons}
494 </Box>
495 )
496 } else {
497 head = (
498 <Box justifyContent="space-between">
499 <Box gap={3}>
500 <Button key="mode-explorar" hotkey="1" plain label="explorar" onPress={() => void fill($, 'Quiero explorar: ')} />
501 {suggested !== null && (
502 <Button key="mode-refinar" hotkey="2" plain label={`refinar #${suggested}`}
503 onPress={() => void fill($, `Refina el borrador #${suggested}`)} />
504 )}
505 </Box>
506 {listButtons}
507 </Box>
508 )
509 }
510
511 const body = Math.max(30, inner - MASCOT_WIDTH - 2)
512 const frame = await read($, frameAtom)
513 const isWorking = e.props.isWorking || (await read($, workingAtom))
514 const state = mascotPose(isWorking, await read($, stepsAtom), !!flash && flash.untilMs > Date.now())
515 const grid = cropGrid(sprite('planner', state, frame), MASCOT_COLS)
516 const elements = $.ui.resolve(e)
517 const version = await read($, versionAtom)
518 const raster =
519 'Raster' in elements ? (
520 <elements.Raster key="mefisto-mascota" columns={MASCOT_WIDTH} rows={RASTER_ROWS} cells={mascotCells(grid)} />
521 ) : (
522 <Box width={MASCOT_WIDTH} />
523 )
524 const mascot = version ? (
525 <Box key="mefisto-mascota-version" flexDirection="column" width={MASCOT_WIDTH}>
526 {raster}
527 <Box width={MASCOT_WIDTH} justifyContent="center">
528 <Text dimColor wrap="truncate-end">{version}</Text>
529 </Box>
530 </Box>
531 ) : (
532 raster
533 )
534
535 const updatedMs = await read($, updatedAtom)
536 let content
537 if (focus && !showList) {
538 const minutes = Math.max(0, Math.floor((Date.now() - (focus.startedMs ?? Date.now())) / 60_000))
539 const what = focus.kind === 'refinar' ? `Refinando #${focus.issue}` : 'Explorando'
540 const ends =
541 focus.kind === 'refinar'
542 ? `termina al pasar #${focus.issue} a estado:listo o con el cierre`
543 : 'termina con el cierre del planner'
544 content = (
545 <Box flexDirection="column" width={body}>
546 <Text bold color="claude">{what}{minutes > 0 ? ` · ${minutes} min` : ''}</Text>
547 <Text wrap="wrap">{clip(focus.topic || ' ', body * 2 - 2)}</Text>
548 <Text color={focus.created.length > 0 ? 'success' : undefined} dimColor={focus.created.length === 0}>
549 {focus.created.length > 0 ? createdText(focus.created) : 'sin borradores nuevos'}
550 </Text>
551 <Text dimColor wrap="truncate-end">{ends}</Text>
552 <Text dimColor>{focus.kind === 'explorar' ? '1 cierra la sesión (pide confirmar)' : '/fausto-board cerrar lo cierra a mano'}</Text>
553 </Box>
554 )
555 } else if (!isExpanded) {
556 const nextRefine = refine?.items.slice(0, 2) ?? []
557 const nextDevelop = develop?.items[0]
558 content = (
559 <Box flexDirection="column" width={body}>
560 <Text dimColor>siguiente a refinar</Text>
561 {[0, 1].map(i => (
562 <Text key={`r-${i}`} dimColor={i > 0} wrap="truncate-end">
563 {nextRefine[i] ? `#${nextRefine[i].number} ${clip(nextRefine[i].title, body - 8)}` : ' '}
564 </Text>
565 ))}
566 <Text dimColor>siguiente a desarrollar</Text>
567 <Text wrap="truncate-end">{nextDevelop ? `#${nextDevelop.number} ${clip(nextDevelop.title, body - 8)}` : ' '}</Text>
568 <Text dimColor>{updatedMs ? ' ' : 'cargando…'}</Text>
569 </Box>
570 )
571 } else {
572 const list = tab === 'borrador' ? refine : develop
573 const size = pageSizeOf(tab)
574 const { page, pages } = pageOf(await read($, pageAtom), list?.items.length ?? 0, size)
575 const visible = list?.items.slice(page * size, (page + 1) * size) ?? []
576 const blankRows = Math.max(0, size - Math.max(visible.length, 1))
577 const numWidth = numberWidth(visible)
578 // Solo 'tras #N' ocupa columna: sin dependencias no se marca.
579 const afterWidth = Math.min(16, Math.max(0, ...visible.map(i => reasonOf(i).length)))
580 // Ancho exacto de la fila: el prefijo '5: ' de la tecla (solo en borradores), el numero, un espacio
581 // y, si alguno depende de otro, la columna 'tras #N'. El titulo ocupa el resto hasta el marco.
582 const keyPrefix = tab === 'borrador' ? 3 : 0
583 const badgeWidth = 2
584 const titleWidth = Math.max(12, body - keyPrefix - badgeWidth - numWidth - 1 - (afterWidth > 0 ? afterWidth + 2 : 0))
585 const more = list
586 ? [
587 list.blockedCount > 0 ? `${list.blockedCount} bloqueados` : '',
588 list.cycleCount > 0 ? `${list.cycleCount} en ciclo` : '',
589 ].filter(Boolean).join(' · ')
590 : ''
591 const hint = tab === 'borrador' ? '5-9 al prompt' : ''
592 content = (
593 <Box flexDirection="column" width={body}>
594 {list?.error && <Text color="error">next-order falló: {clip(list.error, body - 20)}</Text>}
595 {list && !list.error && visible.length === 0 && (
596 <Text dimColor>{tab === 'borrador' ? 'Sin borradores refinables.' : 'Sin issues lanzables.'}</Text>
597 )}
598 {visible.map((item, i) => {
599 const reason = reasonOf(item)
600 const badge = typeBadge(item.tipo)
601 return (
602 <Box>
603 <Text color={badge.color}>{`${badge.letter} `}</Text>
604 {tab === 'borrador' ? (
605 <Button
606 key={`item-${item.number}`}
607 hotkey={String(i + 5)}
608 plain
609 dimColor={page > 0 || i > 0}
610 label={`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
611 onPress={() => void fill($, `Refina el borrador #${item.number}`)}
612 />
613 ) : (
614 <Text dimColor={page > 0 || i > 0}>
615 {`${padEnd(`#${item.number}`, numWidth)} ${padEnd(clip(item.title, titleWidth), titleWidth)}`}
616 </Text>
617 )}
618 <Text dimColor>{reason ? ` ${clip(reason, afterWidth)}` : ''}</Text>
619 </Box>
620 )
621 })}
622 {Array.from({ length: blankRows }, (_, i) => (
623 <Text key={`blank-${i}`}> </Text>
624 ))}
625 <Box justifyContent="space-between">
626 <Text dimColor>{hint}</Text>
627 <Box gap={2}>
628 {more !== '' && <Text dimColor>{more}</Text>}
629 {pages > 1 && (
630 <Button key="next-page" hotkey="0" plain dimColor label={`página ${page + 1}/${pages} ▸`} onPress={() => void nextPage($)} />
631 )}
632 </Box>
633 </Box>
634 </Box>
635 )
636 }
637
638 return (
639 <Box flexDirection="column" borderStyle="round" borderColor="claude" borderDimColor paddingX={1}>
640 {head}
641 <Box gap={2} height={RASTER_ROWS + 1} alignItems="flex-start">
642 {mascot}
643 <Box marginTop={1}>{content}</Box>
644 </Box>
645 </Box>
646 )
647 })
648}
649hooks/sprites.ts 830 lines1/**
2 * mefisto-sprites.ts
3 * Mascota Mefisto para mods de Claude Code. El planner no es un Mefisto: es Fausto, el que decide
4 * (birrete de doctor, barba y tunica del mismo color que el Mefisto de su lado).
5 *
6 * Cada sprite es una cuadrícula de 18×12 píxeles escrita como texto:
7 * cada carácter es un píxel y apunta a un color de PALETTE.
8 * '.' píxel transparente (en EMOTIONS)
9 * ' ' "no tocar" (en las capas de ROLES: deja ver lo de abajo)
10 *
11 * Un sprite se arma así: emoción (cara) + capa del rol (objeto y detalles)
12 * Cada estado tiene dos cuadros, A (0) y B (1), que se alternan con cada instrucción.
13 *
14 * Salidas:
15 * toRasterCells(grid) -> celdas empacadas para el elemento Raster (terminal), 18 columnas × 6 filas
16 * toSvg(grid) -> documento SVG para el elemento Svg (app de escritorio)
17 * ONE_LINERS -> versión de una línea para la status line (elemento Text)
18 */
19
20export type Grid = readonly string[]
21export type Frame = 0 | 1
22
23export const WIDTH = 18
24export const HEIGHT = 12
25/** Filas que ocupa el sprite en la terminal (dos píxeles por fila con ▀ / ▄). */
26export const RASTER_ROWS = HEIGHT / 2
27
28/** Color por defecto de la terminal (transparente), según la documentación de Raster. */
29export const DEFAULT_COLOR = 0x01000000
30
31/** Paleta: letra -> color 0xRRGGBB. */
32export const PALETTE: Record<string, number> = {
33 "h": 0xA32D2D, // cuernos
34 "r": 0xC4504D, // cuerpo (un poco mas opaco que el original 0xE24B4A)
35 "t": 0xA32D2D, // cola
36 "E": 0x2C2C2A, // ojos / pupilas
37 "M": 0x501313, // boca y cejas
38 "W": 0xF1EFE8, // colmillos
39 "p": 0xD4537E, // corazones y lengua
40 "s": 0xB4B2A9, // humo (enojado)
41 "B": 0x85B7EB, // Z y gota de sudor
42 "Y": 0xEF9F27, // bombillo, casco, flecha
43 "y": 0xFAC775, // destellos
44 "w": 0xFFF6D9, // brillo del bombillo
45 "G": 0x888780, // casquillo del bombillo
46 "o": 0xB4B2A9, // bombillo apagado
47 "D": 0x5F5E5A, // marco del portátil
48 "U": 0x378ADD, // pantalla
49 "u": 0xB5D4F4, // líneas de código
50 "q": 0xB4B2A9, // teclado / renglones
51 "Q": 0xFFFFFF, // blanco (pantalla, hoja)
52 "R": 0xE24B4A, // error / marcas rojas
53 "V": 0x639922, // verde de éxito
54 "g": 0x888780, // vidrio del matraz
55 "L": 0x97C459, // líquido del matraz
56 "l": 0xEAF3DE, // reflejo del líquido
57 "b": 0xC0DD97, // burbujas
58 "P": 0x7F77DD, // marco de la lupa
59 "e": 0xEEEDFE, // brillo de la lupa
60 "n": 0x854F0B, // mango de la lupa
61 "c": 0xB5D4F4, // nube
62 "C": 0x888780, // nube caída
63 "k": 0xF1C9A5, // piel de Fausto
64 "f": 0xD9A07F, // nariz de Fausto
65 "i": 0x8A6F5A, // bigote de Fausto
66 "K": 0x2C2C2A, // birrete de Fausto (negro)
67 "z": 0xC4504D, // borla del birrete
68 "a": 0x9C978C, // barba y pelo de Fausto
69 "T": 0xA32D2D, // tunica de Fausto (roja)
70 "d": 0x501313, // ribete de la tunica
71}
72
73export type Emotion =
74 | 'normal'
75 | 'feliz'
76 | 'sonriente'
77 | 'enojado'
78 | 'dormido'
79 | 'asustado'
80 | 'pensando'
81 | 'preocupado'
82 | 'concentrado'
83
84/** Caras base: [cuadro A, cuadro B]. Solo 'pensando' cambia entre cuadros (bombillo encendido / apagado). */
85export const EMOTIONS: Record<Emotion, readonly [Grid, Grid]> = (() => {
86 const normalA: Grid = [
87 '..................',
88 '..................',
89 '....h..........h..',
90 '....hh........hh..',
91 '.....hrrrrrrrrh...',
92 '.....rrrrrrrrrr..t',
93 '.....rrEErrEErr.tt',
94 '.....rrEErrEErr..t',
95 '.....rrrrrrrrrr..t',
96 '......rrMMMMrr..t.',
97 '.......rrrrrrtt...',
98 '.......rr..rr.....',
99 ]
100 const felizA: Grid = [
101 '......p.p..p.p....',
102 '......ppp..ppp....',
103 '....h..p....p..h..',
104 '....hh........hh..',
105 '.....hrrrrrrrrh...',
106 '.....rrrrrrrrrr..t',
107 '.....rrEErrEErr.tt',
108 '.....rrrrrrrrrr..t',
109 '.....rMrrrrrrMr..t',
110 '......rMMWWMMr..t.',
111 '.......rrrrrrtt...',
112 '.......rr..rr.....',
113 ]
114 const sonrienteA: Grid = [
115 '..................',
116 '..................',
117 '....h..........h..',
118 '....hh........hh..',
119 '.....hrrrrrrrrh...',
120 '.....rrrrrrrrrr..t',
121 '.....rrEErrEErr.tt',
122 '.....rrrrrrrrrr..t',
123 '.....rMrrrrrrMr..t',
124 '......rMMWWMMr..t.',
125 '.......rrrrrrtt...',
126 '.......rr..rr.....',
127 ]
128 const enojadoA: Grid = [
129 '.....s.s......s.s.',
130 '......s........s..',
131 '....h..........h..',
132 '....hh........hh..',
133 '.....hrrrrrrrrh...',
134 '.....rMrrrrrrMr..t',
135 '.....rrMMrrMMrr.tt',
136 '.....rrEErrEErr..t',
137 '.....rrrrrrrrrr..t',
138 '......rrMMMMrr..t.',
139 '.......rMrrMrtt...',
140 '.......rr..rr.....',
141 ]
142 const dormidoA: Grid = [
143 '...........BBB....',
144 '............B.....',
145 '....h......BBB.h..',
146 '....hh........hh..',
147 '.....hrrrrrrrrh...',
148 '.....rrrrrrrrrr..t',
149 '.....rrrrrrrrrr.tt',
150 '.....rEErrrrEEr..t',
151 '.....rrrrrrrrrr..t',
152 '......rrrMMrrr..t.',
153 '.......rrrrrrtt...',
154 '.......rr..rr.....',
155 ]
156 const asustadoA: Grid = [
157 '.......y......y...',
158 '........y....y....',
159 '....h..........h..',
160 '....hh........hh..',
161 '.....hrMMrrMMrhB..',
162 '.....rrrrrrrrrrB.t',
163 '.....rrWWrrWWrr.tt',
164 '.....rrWErrWErr..t',
165 '.....rrrrMMrrrr..t',
166 '......rrrMMrrr..t.',
167 '.......rrrrrrtt...',
168 '.......rr..rr.....',
169 ]
170 const pensandoA: Grid = [
171 '......y..YY..y....',
172 '........YwYY......',
173 '....h...YYYY...h..',
174 '....hh...GG...hh..',
175 '.....hrrrrrrrrh...',
176 '.....rrrrrrrrrr..t',
177 '.....rrrEErrEEr.tt',
178 '.....rrrrrrrrrr..t',
179 '.....rrrrrrrrrr..t',
180 '......rrrMMrrr..t.',
181 '.......rrrrrrtt...',
182 '.......rr..rr.....',
183 ]
184 const pensandoB: Grid = [
185 '.........oo.......',
186 '........oooo......',
187 '....h...oooo...h..',
188 '....hh...GG...hh..',
189 '.....hrrrrrrrrh...',
190 '.....rrrrrrrrrr..t',
191 '.....rrrEErrEEr.tt',
192 '.....rrrrrrrrrr..t',
193 '.....rrrrrrrrrr..t',
194 '......rrrMMrrr..t.',
195 '.......rrrrrrtt...',
196 '.......rr..rr.....',
197 ]
198 const preocupadoA: Grid = [
199 '.................R',
200 '.................R',
201 '....h..........h..',
202 '....hh........hh.R',
203 '.....hrrrrrrrrh...',
204 '....BrrrMrrMrrr..t',
205 '....BrrMrrrrMrr.tt',
206 '.....rrEErrEErr..t',
207 '.....rrrrrrrrrr..t',
208 '......rrrMrMrr..t.',
209 '.......rMrMrrtt...',
210 '.......rr..rr.....',
211 ]
212 const concentradoA: Grid = [
213 '..................',
214 '..................',
215 '....h..........h..',
216 '....hh........hh..',
217 '.....hrrrrrrrrh...',
218 '.....rrrrrrrrrr..t',
219 '.....rrEErrMMrr.tt',
220 '.....rrEErrEErr..t',
221 '.....rrrrrrrrrr..t',
222 '......rrrMMrrr..t.',
223 '.......rrrprrtt...',
224 '.......rr..rr.....',
225 ]
226 return {
227 normal: [normalA, normalA],
228 feliz: [felizA, felizA],
229 sonriente: [sonrienteA, sonrienteA],
230 enojado: [enojadoA, enojadoA],
231 dormido: [dormidoA, dormidoA],
232 asustado: [asustadoA, asustadoA],
233 pensando: [pensandoA, pensandoB],
234 preocupado: [preocupadoA, preocupadoA],
235 concentrado: [concentradoA, concentradoA],
236 }
237})()
238
239export type Role = 'desarrollador' | 'tester' | 'revisor' | 'planner' | 'infraestructura' | 'historiador' | 'merge'
240
241export interface RoleState {
242 /** Cara que usa este estado. */
243 emotion: Emotion
244 /** Capas del rol para el cuadro A y el B. ' ' deja ver la cara. */
245 layers: readonly [Grid, Grid]
246 /** Cara propia del rol en lugar de la de Mefisto (Fausto, el planner). */
247 face?: readonly [Grid, Grid]
248}
249
250/** Objetos y detalles de cada rol, por estado de trabajo. */
251export const ROLES: Record<Role, Record<string, RoleState>> = (() => {
252 // Desarrollador · portátil, azul
253 const desarrollador_trabajando_A: Grid = [
254 ' ',
255 ' ',
256 ' ',
257 ' ',
258 ' ',
259 ' ',
260 ' ',
261 'DDDDD ',
262 'DUuUD ',
263 'DuUUD ',
264 'DDDDD ',
265 'qqqqqqq ',
266 ]
267 const desarrollador_trabajando_B: Grid = [
268 ' ',
269 ' ',
270 ' ',
271 ' ',
272 ' ',
273 ' ',
274 ' ',
275 'DDDDD ',
276 'DuUUD ',
277 'DUUuD ',
278 'DDDDD ',
279 'qqqqqqq ',
280 ]
281 const desarrollador_pensando_A: Grid = [
282 ' ',
283 ' ',
284 ' ',
285 ' ',
286 ' ',
287 ' ',
288 ' ',
289 'DDDDD ',
290 'DUuUD ',
291 'DuUUD ',
292 'DDDDD ',
293 'qqqqqqq ',
294 ]
295 const desarrollador_error_A: Grid = [
296 ' ',
297 ' ',
298 ' ',
299 ' ',
300 ' ',
301 ' ',
302 ' ',
303 'DDDDD ',
304 'DQRQD ',
305 'DRQRD ',
306 'DDDDD ',
307 'qqqqqqq ',
308 ]
309 const desarrollador_error_B: Grid = [
310 ' ',
311 ' ',
312 ' ',
313 ' ',
314 ' ',
315 ' ',
316 ' ',
317 'DDDDD ',
318 'DRQRD ',
319 'DQRQD ',
320 'DDDDD ',
321 'qqqqqqq ',
322 ]
323 const desarrollador_resuelto_A: Grid = [
324 ' ',
325 ' ',
326 ' ',
327 ' ',
328 ' ',
329 ' ',
330 ' ',
331 'DDDDD ',
332 'DVVQD ',
333 'DQQVD ',
334 'DDDDD ',
335 'qqqqqqq ',
336 ]
337 // Tester · matraz, verde
338 const tester_trabajando_A: Grid = [
339 ' ',
340 ' ',
341 ' ',
342 ' ',
343 ' ',
344 ' b ',
345 ' b ',
346 ' ggg ',
347 ' g ',
348 ' LLL ',
349 'LlLLL ',
350 'LLLLL ',
351 ]
352 const tester_trabajando_B: Grid = [
353 ' ',
354 ' ',
355 ' ',
356 ' ',
357 ' ',
358 ' b ',
359 ' b ',
360 ' ggg ',
361 ' g ',
362 ' LLL ',
363 'LlLLL ',
364 'LLLLL ',
365 ]
366 const tester_pensando_A: Grid = [
367 ' ',
368 ' ',
369 ' ',
370 ' ',
371 ' ',
372 ' b ',
373 ' b ',
374 ' ggg ',
375 ' g ',
376 ' LLL ',
377 'LlLLL ',
378 'LLLLL ',
379 ]
380 const tester_feliz_A: Grid = [
381 ' ',
382 ' ',
383 ' ',
384 ' ',
385 ' ',
386 ' b ',
387 ' b ',
388 ' ggg ',
389 ' g ',
390 ' LLL ',
391 'LlLLL ',
392 'LLLLL ',
393 ]
394 const tester_feliz_B: Grid = [
395 ' ',
396 ' ',
397 ' ',
398 ' ',
399 ' ',
400 ' b ',
401 ' b ',
402 ' ggg ',
403 ' g ',
404 ' LLL ',
405 'LlLLL ',
406 'LLLLL ',
407 ]
408 // Revisor · lupa, morado
409 const revisor_trabajando_A: Grid = [
410 ' ',
411 ' ',
412 ' ',
413 ' ',
414 ' ',
415 ' PP ',
416 ' PeEP ',
417 ' PEEP ',
418 ' PP ',
419 ' n ',
420 ' n ',
421 ' n ',
422 ]
423 const revisor_trabajando_B: Grid = [
424 ' ',
425 ' ',
426 ' ',
427 ' ',
428 ' ',
429 ' PP ',
430 ' PEEP ',
431 ' PEeP ',
432 ' PP ',
433 ' n ',
434 ' n ',
435 ' n ',
436 ]
437 const revisor_pensando_A: Grid = [
438 ' ',
439 ' ',
440 ' ',
441 ' ',
442 ' ',
443 ' PP ',
444 ' PeEP ',
445 ' PEEP ',
446 ' PP ',
447 ' n ',
448 ' n ',
449 ' n ',
450 ]
451 const revisor_corrigiendo_A: Grid = [
452 ' ',
453 ' ',
454 ' ',
455 ' ',
456 ' ',
457 ' PP ',
458 ' PeEP ',
459 'QQQ PEEP ',
460 'qqR PP ',
461 'QQQ n ',
462 'qRq n ',
463 'QQQn ',
464 ]
465 const revisor_corrigiendo_B: Grid = [
466 ' ',
467 ' ',
468 ' ',
469 ' ',
470 ' ',
471 ' PP ',
472 ' PEEP ',
473 'QQQ PEeP ',
474 'qqR PP ',
475 'QQQ n ',
476 'qRq n ',
477 'QQQn ',
478 ]
479 const revisor_aprobado_A: Grid = [
480 ' ',
481 ' ',
482 ' ',
483 ' ',
484 ' ',
485 ' PP ',
486 ' PeEP ',
487 'QQQ PEEP ',
488 'QQV PP ',
489 'VQV n ',
490 'QVQ n ',
491 'QQQn ',
492 ]
493 // Planner · Fausto: mira a un lado y a otro mientras planea
494 const fausto_planeando_A: Grid = [
495 '..................',
496 '.....KKKKKKKKKK...',
497 '......KKKKKKKK.z..',
498 '.......KKKKKK..z..',
499 '......akkkkkka....',
500 '......akEkkEka....',
501 '......akkkkkka....',
502 '......akkffkka....',
503 '......aaiiiiaa....',
504 '.....TdaaaaaadT...',
505 '....TTTdaaaadTTT..',
506 '....TTTTdaadTTTT..',
507 ]
508 const fausto_planeando_B: Grid = fausto_planeando_A.map((row, y) => (y === 5 ? '......aEkkEkka....' : row))
509 const fausto_pensando_A: Grid = [
510 '.yYy..............',
511 '.YwY.KKKKKKKKKK...',
512 '..G...KKKKKKKK.z..',
513 ...fausto_planeando_B.slice(3),
514 ]
515 const fausto_pensando_B: Grid = ['.ooo..............', '.ooo.KKKKKKKKKK...', ...fausto_pensando_A.slice(2)]
516 const fausto_listo_A: Grid = fausto_planeando_A.map((row, y) =>
517 y === 8 ? '......aaiaaiaa....' : y === 9 ? '.....TdaaiiaadT...' : row,
518 )
519 const sin_capa: Grid = Array(HEIGHT).fill(' '.repeat(WIDTH))
520 // Infraestructura · nube y casco, amarillo
521 const infraestructura_desplegando_A: Grid = [
522 ' ',
523 ' ',
524 ' YyYY ',
525 ' YyYYYY ',
526 ' YYYYYYYY ',
527 ' cc ',
528 'cccc ',
529 'ccccc ',
530 'ccccc ',
531 ' Y ',
532 ' YYY ',
533 ' Y ',
534 ]
535 const infraestructura_desplegando_B: Grid = [
536 ' ',
537 ' ',
538 ' YyYY ',
539 ' YyYYYY ',
540 ' YYYYYYYY ',
541 ' cc ',
542 'cccc ',
543 'ccccc ',
544 'ccYcc ',
545 ' YYY ',
546 ' Y ',
547 ' ',
548 ]
549 const infraestructura_caido_A: Grid = [
550 ' ',
551 ' ',
552 ' YyYY ',
553 ' YyYYYY ',
554 ' YYYYYYYY ',
555 ' CC ',
556 'CCCC ',
557 'CCCCC ',
558 'CCCCC ',
559 ' RR ',
560 ' R ',
561 ' R ',
562 ]
563 const infraestructura_arriba_A: Grid = [
564 ' ',
565 ' ',
566 ' YyYY ',
567 ' YyYYYY ',
568 ' YYYYYYYY ',
569 ' cc ',
570 'cccc ',
571 'ccccc ',
572 'ccccc ',
573 ' V ',
574 ' V V ',
575 ' V ',
576 ]
577 // Merge · cremallera: el hilo de la rama (azul) y el de main (verde) bajan y se cierran diente a diente; los
578 // dientes corren hacia abajo mientras /mefisto:merge trabaja y en listo queda cerrada de punta a punta
579 const merge_cosiendo_A: Grid = [
580 'U V ',
581 'U V ',
582 ' U V ',
583 ' U V ',
584 ' UV ',
585 ' VU ',
586 ' UV ',
587 ' VU ',
588 ' UV ',
589 ' VU ',
590 ' UV ',
591 ' ',
592 ]
593 const merge_cosiendo_B: Grid = [
594 'U V ',
595 ' U V ',
596 ' U V ',
597 ' UV ',
598 ' VU ',
599 ' UV ',
600 ' VU ',
601 ' UV ',
602 ' VU ',
603 ' UV ',
604 ' VU ',
605 ' ',
606 ]
607 const merge_listo_A: Grid = [
608 'y y ',
609 ' UV ',
610 ' VU ',
611 ' UV ',
612 ' VU ',
613 'yUVy ',
614 ' VU ',
615 ' UV ',
616 ' VU ',
617 ' UV ',
618 ' VU ',
619 ' ',
620 ]
621 // Historiador · pluma y tintero: la pluma sube y baja a mojarse en la tinta (/mefisto:bitacora)
622 const historiador_escribiendo_A: Grid = [
623 ' ',
624 ' ',
625 ' B ',
626 ' BU ',
627 ' BU ',
628 ' BU ',
629 ' U ',
630 ' U ',
631 ' E ',
632 ' GGG ',
633 'DDDDD ',
634 'DDDDD ',
635 ]
636 const historiador_escribiendo_B: Grid = [
637 ' ',
638 ' ',
639 ' ',
640 ' B ',
641 ' BU ',
642 ' BU ',
643 ' BU ',
644 ' U ',
645 ' U ',
646 ' GUG ',
647 'DDDDD ',
648 'DDDDD ',
649 ]
650 const historiador_listo_A: Grid = [
651 ' ',
652 ' ',
653 ' B ',
654 ' BU ',
655 ' BU ',
656 ' BU ',
657 ' U ',
658 ' U ',
659 ' E ',
660 ' GGG ',
661 'DDDDD ',
662 'DDDDD ',
663 ]
664
665 return {
666 desarrollador: {
667 trabajando: { emotion: 'normal', layers: [desarrollador_trabajando_A, desarrollador_trabajando_B] },
668 pensando: { emotion: 'pensando', layers: [desarrollador_pensando_A, desarrollador_pensando_A] },
669 error: { emotion: 'preocupado', layers: [desarrollador_error_A, desarrollador_error_B] },
670 resuelto: { emotion: 'sonriente', layers: [desarrollador_resuelto_A, desarrollador_resuelto_A] },
671 },
672 tester: {
673 trabajando: { emotion: 'normal', layers: [tester_trabajando_A, tester_trabajando_B] },
674 pensando: { emotion: 'pensando', layers: [tester_pensando_A, tester_pensando_A] },
675 feliz: { emotion: 'feliz', layers: [tester_feliz_A, tester_feliz_B] },
676 },
677 revisor: {
678 trabajando: { emotion: 'normal', layers: [revisor_trabajando_A, revisor_trabajando_B] },
679 pensando: { emotion: 'pensando', layers: [revisor_pensando_A, revisor_pensando_A] },
680 corrigiendo: { emotion: 'concentrado', layers: [revisor_corrigiendo_A, revisor_corrigiendo_B] },
681 aprobado: { emotion: 'sonriente', layers: [revisor_aprobado_A, revisor_aprobado_A] },
682 },
683 planner: {
684 planeando: { emotion: 'normal', layers: [sin_capa, sin_capa], face: [fausto_planeando_A, fausto_planeando_B] },
685 pensando: { emotion: 'pensando', layers: [sin_capa, sin_capa], face: [fausto_pensando_A, fausto_pensando_B] },
686 listo: { emotion: 'sonriente', layers: [sin_capa, sin_capa], face: [fausto_listo_A, fausto_listo_A] },
687 },
688 infraestructura: {
689 desplegando: { emotion: 'normal', layers: [infraestructura_desplegando_A, infraestructura_desplegando_B] },
690 caido: { emotion: 'asustado', layers: [infraestructura_caido_A, infraestructura_caido_A] },
691 arriba: { emotion: 'sonriente', layers: [infraestructura_arriba_A, infraestructura_arriba_A] },
692 },
693 historiador: {
694 escribiendo: { emotion: 'normal', layers: [historiador_escribiendo_A, historiador_escribiendo_B] },
695 listo: { emotion: 'sonriente', layers: [historiador_listo_A, historiador_listo_A] },
696 },
697 merge: {
698 cosiendo: { emotion: 'concentrado', layers: [merge_cosiendo_A, merge_cosiendo_B] },
699 listo: { emotion: 'sonriente', layers: [merge_listo_A, merge_listo_A] },
700 },
701 }
702})()
703
704/** Versión de una línea por emoción, para la status line. */
705export const ONE_LINERS: Record<Emotion, string> = {
706 normal: "ψ(•‿•)",
707 feliz: "ψ(^▽^)♥",
708 sonriente: "ψ(^▽^)",
709 enojado: "ψ(ಠ益ಠ)",
710 dormido: "ψ(-.-)zz",
711 asustado: "ψ(°o°)!",
712 pensando: "ψ(•_•)💡",
713 preocupado: "ψ(°~°);",
714 concentrado: "ψ(•ᴗ<)",
715}
716
717/** Pone una capa encima de una cuadrícula. En la capa, ' ' significa "no tocar". */
718export function overlay(base: Grid, layer: Grid): Grid {
719 return base.map((row, y) => {
720 const top = layer[y] ?? ''
721 let out = ''
722 for (let x = 0; x < WIDTH; x++) {
723 const c = top[x]
724 out += c && c !== ' ' ? c : row[x] ?? '.'
725 }
726 return out
727 })
728}
729
730/** Arma el sprite de un rol en un estado y un cuadro. */
731export function sprite(role: Role, state: string, frame: Frame = 0): Grid {
732 const def = ROLES[role]?.[state]
733 if (!def) throw new Error(`Estado desconocido: ${role}.${state}`)
734 return overlay((def.face ?? EMOTIONS[def.emotion])[frame], def.layers[frame])
735}
736
737/** Sprite solo con la cara, sin objeto de rol. */
738export function face(emotion: Emotion, frame: Frame = 0): Grid {
739 return EMOTIONS[emotion][frame]
740}
741
742function colorOf(ch: string | undefined): number | null {
743 if (!ch || ch === '.' || ch === ' ') return null
744 const c = PALETTE[ch]
745 if (c === undefined) throw new Error(`Letra sin color en PALETTE: '${ch}'`)
746 return c
747}
748
749const UPPER = '▀'.codePointAt(0)!
750const LOWER = '▄'.codePointAt(0)!
751const SPACE = ' '.codePointAt(0)!
752
753/**
754 * Convierte una cuadrícula en las celdas empacadas que recibe el elemento Raster.
755 * Cada celda junta dos píxeles verticales: ▀ con el de arriba como color y el de abajo como fondo.
756 * Resultado: columns = `width` (WIDTH, 18, o el ancho ya recortado por `cropGrid`), rows = RASTER_ROWS (6).
757 */
758export function toRasterCells(grid: Grid, width: number = WIDTH): string {
759 const numbers: number[] = []
760 for (let y = 0; y < HEIGHT; y += 2) {
761 for (let x = 0; x < width; x++) {
762 const top = colorOf(grid[y]?.[x])
763 const bottom = colorOf(grid[y + 1]?.[x])
764 if (top !== null && bottom !== null) numbers.push(UPPER, top, bottom)
765 else if (top !== null) numbers.push(UPPER, top, DEFAULT_COLOR)
766 else if (bottom !== null) numbers.push(LOWER, bottom, DEFAULT_COLOR)
767 else numbers.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR)
768 }
769 }
770 const bytes = new Uint8Array(Uint32Array.from(numbers).buffer)
771 const anyBytes = bytes as Uint8Array & { toBase64?: () => string }
772 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
773 let bin = ''
774 for (const b of bytes) bin += String.fromCharCode(b)
775 return btoa(bin)
776}
777
778/** Props listas para Raster({ ...rasterProps(grid, 'mefisto') }). */
779export function rasterProps(grid: Grid, key: string) {
780 return { key, columns: WIDTH, rows: RASTER_ROWS, cells: toRasterCells(grid) }
781}
782
783/** Convierte una cuadrícula en un documento SVG (para la app de escritorio). */
784export function toSvg(grid: Grid, scale = 6): string {
785 const rects: string[] = []
786 grid.forEach((row, y) => {
787 let x = 0
788 while (x < WIDTH) {
789 const ch = row[x]
790 const c = colorOf(ch)
791 if (c === null) { x++; continue }
792 let end = x
793 while (end < WIDTH && row[end] === ch) end++
794 const hex = '#' + c.toString(16).padStart(6, '0')
795 rects.push(`<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${hex}"/>`)
796 x = end
797 }
798 })
799 const w = WIDTH * scale
800 const h = HEIGHT * scale
801 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" shape-rendering="crispEdges">${rects.join('')}</svg>`
802}
803
804/** Reloj de arena de la espera: dos cuadros (arena arriba / abajo), 4 columnas por 6 pixeles. */
805const HOURGLASS = [
806 ['YYYY', 'gyyg', '.yg.', 'g..g', 'g..g', 'YYYY'],
807 ['YYYY', 'g..g', '.gg.', 'g..g', 'gyyg', 'YYYY'],
808] as const
809
810/**
811 * Mefisto en espera, sobre la cara 'normal': boca plana corrida y un reloj de arena a la izquierda.
812 * Con `tick` (Claude trabaja) los ojos miran de lado a lado y la arena se voltea en cada tick;
813 * con `tick` null (reposo) mira de frente, quieto, con la arena arriba.
814 */
815export function waitingFace(base: Grid, tick: number | null): Grid {
816 const eyes =
817 tick === null ? ['.....rrEErrEErr.tt', '.....rrEErrEErr..t']
818 : tick % 2 === 0 ? ['.....rEErrEErrr.tt', '.....rEErrEErrr..t']
819 : ['.....rrrEErrEEr.tt', '.....rrrEErrEEr..t']
820 const glass = HOURGLASS[tick !== null && tick % 2 === 1 ? 1 : 0]
821 return base.map((row, y) => {
822 let out = row
823 if (y === 6 || y === 7) out = eyes[y - 6] ?? out
824 if (y === 9) out = '......rrrrMMMr..t.'
825 const g = y >= 6 ? glass[y - 6] : undefined
826 if (g) out = [...out].map((c, x) => (x < g.length && g[x] !== '.' ? g[x] : c)).join('')
827 return out
828 })
829}
830hooks/logic.ts 329 lines1import type { BoardItem, BoardList } from './types'
2
3export const NEXT_ORDER_FILE = 'scripts/next-order.sh'
4
5/** Ruta de next-order.sh bajo la raiz del plugin (`root` sin barra final). */
6export function nextOrderPath(root: string): string {
7 return `${root.replace(/\/+$/, '')}/${NEXT_ORDER_FILE}`
8}
9
10const TYPE_LETTERS: Record<string, { letter: string; color: string }> = {
11 feature: { letter: 'F', color: 'success' },
12 refactor: { letter: 'R', color: 'warning' },
13 projection: { letter: 'P', color: 'cyan' },
14 tooling: { letter: 'T', color: 'magenta' },
15 infra: { letter: 'I', color: 'blue' },
16}
17
18/** Letra y color del tipo (`tipo` de next-order --json); `?` gris si falta o es desconocido. */
19export function typeBadge(tipo: string | null | undefined): { letter: string; color: string } {
20 return (tipo ? TYPE_LETTERS[tipo] : undefined) ?? { letter: '?', color: 'gray' }
21}
22/** Filas fijas de la lista: la banda no cambia de alto con la cantidad de issues. */
23export const LIST_ROWS = 5
24
25type NextOrderJson = {
26 items?: { number: number; title: string; tipo?: string | null; after?: number[]; hasDepsSection?: boolean }[]
27 blocked?: unknown[]
28 cycles?: unknown[]
29 launch?: string | null
30}
31
32/** Exit 0 (hay orden) y 1 (vacio) traen JSON valido; 2 es un fallo de gh o de argumentos. */
33export function parseNextOrder(exitCode: number, stdout: string, stderr: string): BoardList {
34 if (exitCode !== 0 && exitCode !== 1) {
35 return { items: [], blockedCount: 0, cycleCount: 0, launch: null, error: firstLine(stderr) || `exit ${exitCode}` }
36 }
37 let json: NextOrderJson
38 try {
39 json = JSON.parse(stdout)
40 } catch {
41 return { items: [], blockedCount: 0, cycleCount: 0, launch: null, error: 'salida de next-order no es JSON' }
42 }
43 const items: BoardItem[] = (json.items ?? []).map(i => ({
44 number: i.number,
45 title: i.title,
46 tipo: i.tipo ?? null,
47 after: i.after ?? [],
48 hasDepsSection: i.hasDepsSection !== false,
49 }))
50 return {
51 items,
52 blockedCount: (json.blocked ?? []).length,
53 cycleCount: (json.cycles ?? []).length,
54 launch: json.launch ?? null,
55 error: null,
56 }
57}
58
59type OpenIssue = { number: number; title?: string; updatedAt?: string; labels?: { name: string }[] }
60
61export function parseOpenIssues(stdout: string): OpenIssue[] | null {
62 try {
63 const list = JSON.parse(stdout)
64 return Array.isArray(list) ? list : null
65 } catch {
66 return null
67 }
68}
69
70/** Cambia cuando entra, sale o se edita un issue abierto (labels o body actualizan updatedAt). */
71export function signatureOf(issues: OpenIssue[]): string {
72 return issues
73 .map(i => `${i.number}:${i.updatedAt ?? ''}:${(i.labels ?? []).map(l => l.name).sort().join(',')}`)
74 .sort()
75 .join('|')
76}
77
78export type Arrival = { number: number; title: string; kind: 'borrador' | 'listo' }
79
80/** Issues abiertos nuevos con estado borrador o listo. Sin conocidos previos (primer refresco) no hay avisos. */
81export function arrivals(issues: OpenIssue[], known: number[]): Arrival[] {
82 if (known.length === 0) return []
83 const seen = new Set(known)
84 const out: Arrival[] = []
85 for (const i of issues) {
86 if (seen.has(i.number)) continue
87 const names = (i.labels ?? []).map(l => l.name)
88 const kind = names.includes('estado:borrador') ? 'borrador' : names.includes('estado:listo') ? 'listo' : null
89 if (kind) out.push({ number: i.number, title: i.title ?? '', kind })
90 }
91 return out
92}
93
94/** De que depende el issue (`tras #A #B`), o vacio si no depende de nada abierto. */
95export function reasonOf(item: BoardItem): string {
96 return item.after.length > 0 ? `tras ${item.after.map(n => `#${n}`).join(' ')}` : ''
97}
98
99/** Ancho comun de la columna de numeros (#801 y #1981 alineados). */
100export function numberWidth(items: BoardItem[]): number {
101 return Math.max(4, ...items.map(i => String(i.number).length + 1))
102}
103
104export function padEnd(text: string, width: number): string {
105 return text.length >= width ? text : text + ' '.repeat(width - text.length)
106}
107
108export function clip(text: string, max: number): string {
109 const one = text.replace(/\s+/g, ' ').trim()
110 return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
111}
112
113export function clockOf(ms: number): string {
114 if (ms === 0) return ''
115 const d = new Date(ms)
116 const p = (n: number) => String(n).padStart(2, '0')
117 return `${p(d.getHours())}:${p(d.getMinutes())}`
118}
119
120function firstLine(text: string): string {
121 return text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
122}
123
124const REFINE = /\brefin\w*\s+(?:el\s+)?(?:borrador\s+)?#(\d+)/i
125
126/** `refina #801`, `Refina el borrador #801`, `refinemos #801`: el issue a refinar, o null. */
127export function refineTargetOf(prompt: string): number | null {
128 const m = REFINE.exec(prompt)
129 return m ? Number(m[1]) : null
130}
131
132/** El tema de una exploracion: la primera linea con contenido del mensaje, recortada. */
133export function topicOf(prompt: string): string {
134 const line = prompt.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
135 return clip(line.replace(/^quiero explorar:\s*/i, ''), 70)
136}
137
138// Un comando cuenta solo donde se ejecuta: al inicio (tras ; && || | ( then do o un salto de linea), con
139// asignaciones de entorno delante, y fuera de heredocs. Un texto que lo nombra (git show, grep, echo) no cuenta.
140const AT_COMMAND = String.raw`(?:^|[;&|(\n]|\bthen\b|\bdo\b)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*`
141
142function withoutHeredocs(command: string): string {
143 return command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '<<heredoc')
144}
145
146const runs = (command: string, pattern: string) => new RegExp(`${AT_COMMAND}${pattern}`).exec(withoutHeredocs(command))
147
148/** Un cambio de issues hecho por el planner (crear, editar, cerrar, reabrir): sus listas pueden haber cambiado. */
149export function isIssueChange(command: string): boolean {
150 return runs(command, String.raw`gh\s+issue\s+(create|edit|close|reopen)\b`) !== null
151}
152
153export function isIssueCreate(command: string): boolean {
154 return runs(command, String.raw`gh\s+issue\s+create\b`) !== null
155}
156
157/** `gh issue edit 801 ... --add-label estado:listo`: el issue que pasa a listo, o null. */
158export function issueMarkedListo(command: string): number | null {
159 const m = runs(command, String.raw`gh\s+issue\s+edit\s+#?(\d+)\b[^\n;&|]*--add-label[=\s]+["']?[^"'\s]*estado:listo`)
160 return m ? Number(m[1]) : null
161}
162
163/** `gh issue close 801 ...`: el issue que se cierra, o null. */
164export function issueClosed(command: string): number | null {
165 const m = runs(command, String.raw`gh\s+issue\s+close\s+#?(\d+)\b`)
166 return m ? Number(m[1]) : null
167}
168
169/** Cuanto tiempo se oculta un issue en transicion si ningun refresco confirma el cambio. */
170export const PENDING_TTL_MS = 60_000
171
172/** Transiciones pendientes: numero de issue -> instante (ms) en que se registro. */
173export type Pending = ReadonlyMap<number, number>
174
175export function addPending(pending: Pending, issue: number, nowMs: number): Pending {
176 return new Map(pending).set(issue, nowMs)
177}
178
179/** Quita un issue de la lista de borradores sin reordenar ni tocar el resto. */
180export function dropIssue(list: BoardList | null, issue: number): BoardList | null {
181 if (!list || !list.items.some(i => i.number === issue)) return list
182 return { ...list, items: list.items.filter(i => i.number !== issue) }
183}
184
185/**
186 * Aplica las transiciones pendientes a una lista recien refrescada: filtra los pendientes vigentes y devuelve
187 * las que siguen pendientes (salen si el refresco ya no los trae como borrador, o al pasar PENDING_TTL_MS).
188 * Una lista con error de next-order no confirma nada: sus pendientes solo vencen por plazo.
189 */
190export function applyPending(list: BoardList, pending: Pending, nowMs: number): { list: BoardList; pending: Pending } {
191 const present = new Set(list.items.map(i => i.number))
192 const kept = new Map<number, number>()
193 for (const [n, since] of pending) {
194 if (nowMs - since < PENDING_TTL_MS && (list.error !== null || present.has(n))) kept.set(n, since)
195 }
196 const items = kept.size > 0 ? list.items.filter(i => !kept.has(i.number)) : list.items
197 return { list: items === list.items ? list : { ...list, items }, pending: kept }
198}
199
200/** El cierre del planner: ejecuta field-note.sh, con ruta sin comillas o entre comillas (puede tener espacios). */
201export function isPlannerClosing(command: string): boolean {
202 return runs(command, String.raw`(?:\S*/|"(?:[^"]*/)?|'(?:[^']*/)?)?field-note\.sh\b`) !== null
203}
204
205/** Numero del issue que `gh issue create` imprime como URL. */
206export function createdIssueOf(output: string): number | null {
207 const m = /\/issues\/(\d+)/.exec(output)
208 return m ? Number(m[1]) : null
209}
210
211export function createdText(created: number[]): string {
212 return created.length > 0 ? `creó ${created.map(n => `#${n}`).join(' ')}` : ''
213}
214
215/** Issues por pagina, igual en las dos listas. */
216export function pageSizeOf(_tab: 'borrador' | 'listo'): number {
217 return LIST_ROWS
218}
219
220/** Pagina valida (0-based) para una lista de `total` items; vuelve a 0 si la lista se achico. */
221export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
222 const pages = Math.max(1, Math.ceil(total / size))
223 return { page: page >= 0 && page < pages ? page : 0, pages }
224}
225
226/** Ruta del transcript de una sesion: ~/.claude/projects/<cwd con lo no alfanumerico como '-'>/<id>.jsonl. */
227export function transcriptPathOf(configDir: string, cwd: string, sessionId: string): string {
228 return `${configDir}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
229}
230
231/**
232 * `claude --agent mefisto:planner` deja en el transcript de la sesion filas
233 * `{"type":"agent-setting","agentSetting":"mefisto:planner"}`; manda la ultima. Un planner lanzado como
234 * subagente escribe en el transcript del subagente, nunca en el de la sesion que lo lanzo.
235 */
236export function agentSettingOf(transcript: string): string | null {
237 let found: string | null = null
238 for (const line of transcript.split('\n')) {
239 if (!line.includes('"agent-setting"')) continue
240 try {
241 const row = JSON.parse(line)
242 if (row.type === 'agent-setting' && typeof row.agentSetting === 'string') found = row.agentSetting
243 } catch {
244 continue
245 }
246 }
247 return found
248}
249
250/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
251export function agentFlagOf(cmdline: string): string | null {
252 const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
253 return m ? (m[1] ?? null) : null
254}
255
256export type MascotState = 'planeando' | 'pensando' | 'listo'
257
258/** Siempre la mascota del planner: trabajando con herramientas planea, sin ellas piensa y al cerrar un foco queda lista. */
259export function mascotPose(isWorking: boolean, stepsInTurn: number, hasFlash: boolean): MascotState {
260 if (isWorking) return stepsInTurn > 0 ? 'planeando' : 'pensando'
261 if (hasFlash) return 'listo'
262 return 'planeando'
263}
264
265type Grid = readonly string[]
266
267/** Columnas con algun pixel visible en cualquiera de los cuadros: recorta el margen sin que la mascota salte. */
268export function usedColumns(grids: readonly Grid[]): { from: number; to: number } {
269 let from = Infinity
270 let to = -1
271 for (const g of grids) {
272 for (const row of g) {
273 for (let x = 0; x < row.length; x++) {
274 const c = row[x]
275 if (c !== '.' && c !== ' ') {
276 from = Math.min(from, x)
277 to = Math.max(to, x)
278 }
279 }
280 }
281 }
282 return to < 0 ? { from: 0, to: 0 } : { from, to }
283}
284
285export function cropGrid(grid: Grid, cols: { from: number; to: number }): Grid {
286 return grid.map(row => row.slice(cols.from, cols.to + 1).padEnd(cols.to - cols.from + 1, '.'))
287}
288
289/**
290 * El manifiesto `.claude-plugin/plugin.json` en la raiz del repo es el del propio plugin Mefisto: ahi el tablero
291 * es el mod interno (`.claude/mods/mefisto-planner-board`) y el publicado no aplica (su next-order.sh es solo del
292 * consumidor).
293 */
294export function isMefistoManifest(raw: string): boolean {
295 try {
296 return (JSON.parse(raw) as { name?: unknown }).name === 'mefisto'
297 } catch {
298 return false
299 }
300}
301
302// Una exploracion que ya creo borradores muestra la lista de borradores como cuerpo de la banda.
303export function showsDraftList(focus: { kind: 'explorar' | 'refinar'; created: number[] } | null | undefined): boolean {
304 return focus?.kind === 'explorar' && focus.created.length > 0
305}
306
307const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
308
309function semverOf(raw: string | null | undefined): string | null {
310 if (typeof raw !== 'string') return null
311 try {
312 const version = (JSON.parse(raw) as { version?: unknown }).version
313 return typeof version === 'string' && SEMVER.test(version) ? version : null
314 } catch {
315 return null
316 }
317}
318
319/** `v<version>` del release cargado: la de `mefisto-manifest.json` si es SemVer valida, si no la de `plugin.json`, si no `null`. */
320export function versionLabel(manifestRaw: string | null | undefined, pluginRaw: string | null | undefined): string | null {
321 const version = semverOf(manifestRaw) ?? semverOf(pluginRaw)
322 return version === null ? null : `v${version}`
323}
324
325/** Rearme tras /clear, /resume o /branch: el modulo quiere el mod activo (elegible y no apagado) pero el atom quedo en blanco. */
326export function shouldRearm(s: { isEligible: boolean; isWanted: boolean; isActive: boolean }): boolean {
327 return s.isEligible && s.isWanted && !s.isActive
328}
329hooks/fausto-blood-pact/logic.ts 1378 lines1import type { Role } from '../sprites'
2import { padEnd, reasonOf, typeBadge } from '../logic'
3import type { BitacoraNote, BitacoraPr, BoardItem, BoardList, Historian, PactMerge, PactMergePr, PipelineKind, PaneLine, PipelineResult, PipelineRun, RunStep } from '../types'
4
5export const NEXT_ORDER_FILE = 'scripts/next-order.sh'
6export const PLANNER_AGENT = 'mefisto:planner'
7export const PAGE_ROWS = 5
8
9export function nextOrderPath(root: string): string {
10 return `${root.replace(/\/+$/, '')}/${NEXT_ORDER_FILE}`
11}
12
13type NextOrderJson = {
14 items?: { number: number; title: string; tipo?: string | null; after?: number[]; hasDepsSection?: boolean }[]
15 blocked?: unknown[]
16 cycles?: unknown[]
17 launch?: string | null
18 infra?: number[]
19 parallel?: { issues?: number[]; launch?: string | null } | null
20}
21
22function firstLine(text: string): string {
23 return text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
24}
25
26/** Exit 0 (hay orden) y 1 (vacio) traen JSON valido; 2 u otro es un fallo. */
27export function parseNextOrder(exitCode: number, stdout: string, stderr: string): BoardList {
28 const failed = (error: string): BoardList => ({ items: [], blockedCount: 0, cycleCount: 0, launch: null, infra: [], parallel: null, error })
29 if (exitCode !== 0 && exitCode !== 1) return failed(firstLine(stderr) || `exit ${exitCode}`)
30 let json: NextOrderJson
31 try {
32 json = JSON.parse(stdout)
33 } catch {
34 return failed('salida de next-order no es JSON')
35 }
36 const items: BoardItem[] = (json.items ?? []).map(i => ({
37 number: i.number,
38 title: i.title,
39 tipo: i.tipo ?? null,
40 after: i.after ?? [],
41 hasDepsSection: i.hasDepsSection !== false,
42 }))
43 return {
44 items,
45 blockedCount: (json.blocked ?? []).length,
46 cycleCount: (json.cycles ?? []).length,
47 launch: json.launch ?? null,
48 infra: json.infra ?? [],
49 parallel: json.parallel ? { issues: json.parallel.issues ?? [], launch: json.parallel.launch ?? null } : null,
50 error: null,
51 }
52}
53
54export function clip(text: string, max: number): string {
55 const one = text.replace(/\s+/g, ' ').trim()
56 return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
57}
58
59/** Pagina vigente (acotada) y total de paginas; siempre al menos una. */
60export function pageOf(page: number, total: number, size: number): { page: number; pages: number } {
61 const pages = Math.max(1, Math.ceil(total / size))
62 return { page: Math.min(Math.max(0, page), pages - 1), pages }
63}
64
65/** Partes de una fila como en fausto-board: letra de tipo con color, etiqueta `#N titulo` (sin tecla ni posicion) y `tras #M`. */
66export function rowParts(
67 item: BoardItem,
68 numWidth: number,
69 titleMax: number,
70): { letter: string; color: string; label: string; after: string } {
71 const badge = typeBadge(item.tipo)
72 return {
73 letter: badge.letter,
74 color: badge.color,
75 label: rowText(item, numWidth, titleMax),
76 after: reasonOf(item),
77 }
78}
79
80/** Etiqueta del boton: `#issue titulo`; la tecla la dibuja el boton y el tipo va en su letra. */
81export function rowText(item: BoardItem, numWidth: number, titleMax: number): string {
82 return `${padEnd(`#${item.number}`, numWidth)} ${clip(item.title, titleMax)}`
83}
84
85/** Pie sin las teclas `1`/`4` (la consola las pinta como botones): solo infra y ciclos, que condicionan el lanzamiento. */
86export function footerRestOf(list: BoardList, afterButtons: boolean): string {
87 const rest = [
88 list.infra.length > 0 ? `infra: ${list.infra.map(n => `#${n}`).join(' ')}` : '',
89 list.cycleCount > 0 ? `${list.cycleCount} en ciclo` : '',
90 ]
91 .filter(Boolean)
92 .join(' · ')
93 return rest && afterButtons ? ` · ${rest}` : rest
94}
95
96/** Cantidad de issues de una linea de lanzamiento (`/mefisto:sequential 7 8` -> 2). */
97export function launchCountOf(line: string | null): number {
98 return (line ?? '').split(/\s+/).filter(t => /^\d+$/.test(t)).length
99}
100
101export function isMefistoManifest(raw: string): boolean {
102 try {
103 return (JSON.parse(raw) as { name?: unknown }).name === 'mefisto'
104 } catch {
105 return false
106 }
107}
108
109export function transcriptPathOf(configDir: string, cwd: string, sessionId: string): string {
110 return `${configDir}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
111}
112
113/** La ultima fila `agent-setting` del transcript de la sesion, o null. */
114export function agentSettingOf(transcript: string): string | null {
115 let found: string | null = null
116 for (const line of transcript.split('\n')) {
117 if (!line.includes('"agent-setting"')) continue
118 try {
119 const row = JSON.parse(line)
120 if (row.type === 'agent-setting' && typeof row.agentSetting === 'string') found = row.agentSetting
121 } catch {
122 continue
123 }
124 }
125 return found
126}
127
128/** El `--agent` de una linea de comando de Claude Code (`--agent x` o `--agent=x`), o null. */
129export function agentFlagOf(cmdline: string): string | null {
130 const m = /(?:^|\s)--agent(?:=|\s+)(\S+)/.exec(cmdline)
131 return m ? (m[1] ?? null) : null
132}
133
134// ---- Incremento 2: seguimiento de corridas (MEF-ADR-0055 decision 1: lector puro) ----
135
136export const RUNS_POLL_MS = 5_000
137export const STATUS_FILE_RE = /^pipeline-status-(tdd|tooling|infra)-(\d+)(?:-(.+))?\.json$/
138export const HISTORY_FILE = 'pipeline-history.jsonl'
139export const DISMISSED_STORE_KEY = 'fausto-blood-pact.dismissed'
140const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000
141
142/** Directorio de estado del checkout principal: el padre de `--git-common-dir`, nunca el worktree. */
143export function stateDirOf(gitCommonDir: string, cwd: string): string {
144 const abs = gitCommonDir.startsWith('/') ? gitCommonDir : `${cwd.replace(/\/+$/, '')}/${gitCommonDir}`
145 const common = abs.replace(/\/+$/, '').replace(/\/\.git$/, '')
146 return `${common}/.mefisto/pipeline`
147}
148
149/** Raiz del repo (checkout principal) a partir del directorio de estado. */
150export function repoRootOf(stateDir: string): string {
151 return stateDir.replace(/\/\.mefisto\/pipeline$/, '')
152}
153
154type StatusJson = {
155 issue?: string | number | null
156 pipeline?: string
157 variant?: string | null
158 started?: string
159 updated?: string
160 stage?: string
161 state?: string
162 hold?: { next_probe?: string | null } | null
163 agents?: Record<string, { result?: string | null; duration?: number | null } | null> | null
164 log?: string | null
165 title?: string | null
166 last_error?: string | null
167}
168
169/** Parseo de un status; null si no es de un pipeline conocido o no es JSON valido. */
170export function parseStatus(fileName: string, raw: string): PipelineRun | null {
171 const m = STATUS_FILE_RE.exec(fileName)
172 if (!m) return null
173 let json: StatusJson
174 try {
175 json = JSON.parse(raw) as StatusJson
176 } catch {
177 return null
178 }
179 const issue = Number(json.issue ?? m[2])
180 if (!Number.isFinite(issue)) return null
181 return {
182 issue,
183 pipeline: m[1] as PipelineKind,
184 variant: json.variant ?? m[3] ?? null,
185 started: json.started ?? '',
186 updated: json.updated ?? '',
187 stage: json.stage ?? '',
188 state: json.state ?? '',
189 nextProbe: json.hold?.next_probe ?? null,
190 agents: stepsOfAgents(json.agents),
191 title: typeof json.title === 'string' ? json.title : '',
192 log: typeof json.log === 'string' && json.log !== '' ? json.log : null,
193 ...(typeof json.last_error === 'string' && json.last_error !== '' ? { lastError: json.last_error } : {}),
194 }
195}
196
197/** Pasos de `status.agents` en su orden; un `result` desconocido o vacio es `pending`. */
198export function stepsOfAgents(agents: StatusJson['agents']): RunStep[] {
199 if (!agents || typeof agents !== 'object') return []
200 return Object.entries(agents).map(([name, v]) => {
201 const r = v?.result
202 const result: RunStep['result'] = r === 'passed' || r === 'running' || r === 'failed' ? r : 'pending'
203 const d = v?.duration
204 return typeof d === 'number' && d > 0 ? { name, result, duration: d } : { name, result }
205 })
206}
207
208export const isActive = (r: PipelineRun) => r.state === 'running' || r.state === 'hold'
209export const isFailure = (r: PipelineRun) => r.state === 'failed' || r.state === 'blocked' || r.state === 'gaps'
210
211/** Agente en curso: `<n>-<agente>` -> agente; `setup`/`scaffold` y cualquier otro stage, tal cual. */
212export function agentOf(stage: string): string {
213 const m = /^\d+-(.+)$/.exec(stage)
214 return m ? (m[1] as string) : stage
215}
216
217/** Hora local HH:MM de un timestamp ISO (`next_probe` viaja en UTC con `Z`); el texto original si no se puede leer. */
218export function clockOf(iso: string): string {
219 const ms = Date.parse(iso)
220 if (Number.isNaN(ms)) return iso
221 const d = new Date(ms)
222 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
223}
224
225/** Minutos enteros desde `started` (`YYYYMMDD-HHMMSS` de los pipelines o ISO, hora local) hasta `nowMs`; null si no parsea. */
226export function minutesSince(started: string, nowMs: number): number | null {
227 const ms = Date.parse(/^\d{8}-\d{6}$/.test(started)
228 ? `${started.slice(0, 4)}-${started.slice(4, 6)}-${started.slice(6, 8)}T${started.slice(9, 11)}:${started.slice(11, 13)}:${started.slice(13, 15)}`
229 : started)
230 return Number.isNaN(ms) ? null : Math.max(0, Math.floor((nowMs - ms) / 60_000))
231}
232
233/** `#N pipeline agente Mm`; en `hold`, `rate limit · sonda HH:MM` en lugar del agente. */
234export function runLine(run: PipelineRun, nowMs: number): string {
235 const what = run.state === 'hold' ? `rate limit · sonda ${run.nextProbe ? clockOf(run.nextProbe) : '?'}` : agentOf(run.stage)
236 const mins = minutesSince(run.started, nowMs)
237 return `#${run.issue} ${run.pipeline}${run.variant ? `/${run.variant}` : ''} ${what}${mins === null ? '' : ` ${mins}m`}`
238}
239
240export function runKey(repo: string, run: PipelineRun): string {
241 return [repo, run.pipeline, run.issue, run.variant ?? '', run.started].join('|')
242}
243
244/** Clave de descarte (repo + pipeline + issue + variante + started). */
245export const dismissKey = runKey
246
247export type HistoryEntry = { issue: string; pipeline: string; variant: string | null; started: string; pr: string | null; title?: string }
248
249export function parseHistory(raw: string): HistoryEntry[] {
250 const out: HistoryEntry[] = []
251 for (const line of raw.split('\n')) {
252 if (line.trim() === '') continue
253 try {
254 const j = JSON.parse(line) as Record<string, unknown>
255 out.push({
256 issue: String(j.issue ?? ''),
257 pipeline: String(j.pipeline ?? ''),
258 variant: typeof j.variant === 'string' ? j.variant : null,
259 started: String(j.started ?? ''),
260 pr: typeof j.pr === 'string' || typeof j.pr === 'number' ? String(j.pr) : null,
261 title: typeof j.title === 'string' ? j.title : '',
262 })
263 } catch {
264 continue
265 }
266 }
267 return out
268}
269
270/** Numero de PR de la ultima entrada del historial para ese issue, pipeline y `started`; null si no hay. */
271export function prOf(entries: HistoryEntry[], run: PipelineRun): string | null {
272 for (let i = entries.length - 1; i >= 0; i -= 1) {
273 const e = entries[i] as HistoryEntry
274 if (e.issue === String(run.issue) && e.pipeline === run.pipeline && e.started === run.started) {
275 const m = e.pr ? /(\d+)\s*$/.exec(e.pr) : null
276 return m ? (m[1] as string) : null
277 }
278 }
279 return null
280}
281
282export type RunsView = { runs: PipelineRun[]; results: PipelineResult[]; seen: Record<string, PipelineRun> }
283
284/**
285 * Une el status actual con lo que la sesion vio activo: un status de fallo es `✗ <stage>`;
286 * una corrida vista activa cuyo status desaparecio es `✓ PR #X` (el exito se reconoce por la desaparicion).
287 */
288export function reconcile(
289 repo: string,
290 statuses: PipelineRun[],
291 seen: Record<string, PipelineRun>,
292 history: HistoryEntry[],
293 dismissed: ReadonlySet<string>,
294 carried: PipelineResult[] = [],
295): RunsView {
296 const present = new Set(statuses.map(r => runKey(repo, r)))
297 const results: PipelineResult[] = carried.filter(r => !present.has(r.key))
298 const nextSeen: Record<string, PipelineRun> = {}
299 for (const [key, run] of Object.entries(seen)) {
300 if (present.has(key)) continue
301 const pr = prOf(history, run)
302 results.push({ key, issue: run.issue, pipeline: run.pipeline, ok: true, text: pr ? `✓ PR #${pr}` : '✓ completado' })
303 }
304 const runs: PipelineRun[] = []
305 for (const run of statuses) {
306 const key = runKey(repo, run)
307 if (isActive(run)) {
308 runs.push(run)
309 nextSeen[key] = run
310 } else if (isFailure(run)) {
311 results.push({ key, issue: run.issue, pipeline: run.pipeline, ok: false, text: `✗ ${run.stage}` })
312 }
313 }
314 runs.sort((a, b) => a.started.localeCompare(b.started) || a.issue - b.issue)
315 return { runs, results: results.filter(r => !dismissed.has(r.key)), seen: nextSeen }
316}
317
318/** Linea de un resultado: `#N pipeline ✗ stage` o `#N pipeline ✓ PR #X`. */
319export function resultLine(r: PipelineResult): string {
320 return `#${r.issue} ${r.pipeline} ${r.text}`
321}
322
323export type BandView = 'runs' | 'ready-with-results' | 'ready'
324
325/** Las corridas toman la banda solo con al menos una activa; sin activas se ven los listos (y los resultados al pie). */
326export function bandViewOf(runs: readonly PipelineRun[], results: readonly PipelineResult[]): BandView {
327 if (runs.some(isActive)) return 'runs'
328 return results.length > 0 ? 'ready-with-results' : 'ready'
329}
330
331/** Texto de un resultado en el pie: `✓#N PR #X` o `✗#M <stage>`. */
332export function resultFooterItem(r: PipelineResult): string {
333 const m = /^([✓✗])\s*(.*)$/u.exec(r.text)
334 return m ? `${m[1]}#${r.issue}${m[2] ? ` ${m[2]}` : ''}` : `#${r.issue} ${r.text}`
335}
336
337/** `resultados: ✓#N PR #X · ✗#M stage` recortado a `max` columnas; lo que no cabe se resume en `+N`. */
338export function resultsFooterText(results: readonly PipelineResult[], max: number): string {
339 const head = 'resultados: '
340 const items = results.map(resultFooterItem)
341 for (let shown = items.length; shown >= 1; shown -= 1) {
342 const hidden = items.length - shown
343 const text = `${head}${items.slice(0, shown).join(' · ')}${hidden > 0 ? ` +${hidden}` : ''}`
344 if (text.length <= max) return text
345 }
346 return clip(`${head}${items[0] ?? ''}${items.length > 1 ? ` +${items.length - 1}` : ''}`, max)
347}
348
349/** Descarta de `dismissed` las claves del repo sin status vivo y con mas de un dia (gracia para otros panes). */
350export function pruneDismissed(dismissed: string[], repo: string, liveKeys: ReadonlySet<string>, nowMs: number): string[] {
351 return dismissed.filter(key => {
352 if (!key.startsWith(`${repo}|`) || liveKeys.has(key)) return true
353 const mins = minutesSince(key.split('|')[4] ?? '', nowMs)
354 return mins === null || mins * 60_000 < PRUNE_GRACE_MS
355 })
356}
357
358// ---- Incremento 3: lanzar trabajo (el comando se escribe en el prompt, nunca se envia) ----
359
360export const SEQUENTIAL_COMMAND = '/mefisto:sequential'
361export const PARALLEL_COMMAND = '/mefisto:parallel'
362export const ROW_KEYS = ['5', '6', '7', '8', '9'] as const
363export const CANCEL = 'Cancelar'
364export const INFRA_FIRST = 'Infra primero'
365export const WITHOUT_INFRA = 'Seguir sin infra'
366export const WITH_MERGE = 'Con merge'
367export const ONLY_PR = 'Solo PR'
368
369export type LaunchKind = 'sequential' | 'parallel'
370export type LaunchOption = { label: string; text: string | null }
371export type LaunchPlan =
372 | { kind: 'fill'; text: string }
373 | { kind: 'ask'; question: string; options: LaunchOption[] }
374 | { kind: 'none'; message: string }
375
376/** Flags de next-order que fijan las lineas de lanzamiento que la consola escribe. */
377export const NEXT_ORDER_ARGS = ['--json', '--launch-command', SEQUENTIAL_COMMAND, '--parallel-command', PARALLEL_COMMAND]
378
379/** La linea de la tecla (`1` sequential, `4` parallel) tal como la entrego next-order; null si no hay. */
380export function launchLineOf(list: BoardList, kind: LaunchKind): string | null {
381 return kind === 'sequential' ? list.launch : (list.parallel?.launch ?? null)
382}
383
384/** `1`/`4`: escribe la linea; con infra lanzable ofrece desarrollarla antes (sin exigirlo). */
385export function planLaunch(list: BoardList, kind: LaunchKind): LaunchPlan {
386 const line = launchLineOf(list, kind)
387 if (!line) return { kind: 'none', message: `No hay linea de ${kind} para lanzar.` }
388 const first = list.infra[0]
389 if (first === undefined) return { kind: 'fill', text: line }
390 return {
391 kind: 'ask',
392 question: `Hay ${list.infra.length} issue(s) de infra lanzables (${list.infra.map(n => `#${n}`).join(' ')}). ¿Desarrollarlos antes?`,
393 options: [
394 { label: INFRA_FIRST, text: `/mefisto:infra ${first}` },
395 { label: WITHOUT_INFRA, text: line },
396 { label: CANCEL, text: null },
397 ],
398 }
399}
400
401/** `5`-`9` y `lanzar <n>`: opciones segun el `tipo` del issue; sin tipo lanzable no hay dialogo. */
402export function planIssue(item: BoardItem): LaunchPlan {
403 const n = item.number
404 const options = (pr: LaunchOption[]): LaunchPlan => ({
405 kind: 'ask',
406 question: `¿Cómo desarrollar #${n}?`,
407 options: [...pr, { label: CANCEL, text: null }],
408 })
409 switch (item.tipo) {
410 case 'feature':
411 case 'refactor':
412 case 'projection':
413 return options([
414 { label: WITH_MERGE, text: `${SEQUENTIAL_COMMAND} ${n}` },
415 { label: ONLY_PR, text: `/mefisto:implement ${n}` },
416 ])
417 case 'tooling':
418 return options([
419 { label: WITH_MERGE, text: `${SEQUENTIAL_COMMAND} ${n}` },
420 { label: ONLY_PR, text: `/mefisto:tooling ${n}` },
421 ])
422 case 'infra':
423 return options([{ label: ONLY_PR, text: `/mefisto:infra ${n}` }])
424 default:
425 return { kind: 'none', message: `#${n} no tiene un tipo lanzable.` }
426 }
427}
428
429/** Texto a escribir segun la respuesta del dialogo: comparacion exacta; cierre o texto libre no escriben nada. */
430export function answerText(options: LaunchOption[], answer: unknown): string | null {
431 if (typeof answer !== 'string') return null
432 return options.find(o => o.label === answer)?.text ?? null
433}
434
435export type LaunchTarget = { kind: 'launch'; launch: LaunchKind } | { kind: 'issue'; issue: number } | { kind: 'invalid'; message: string }
436
437/** Argumento de `/fausto-blood-pact lanzar`: vacio o `sequential` -> 1, `parallel` -> 4, `<n>` -> ese issue si es lanzable. */
438export function parseLaunchArg(arg: string, list: BoardList): LaunchTarget {
439 const a = arg.trim()
440 if (a === '' || a === 'sequential') return { kind: 'launch', launch: 'sequential' }
441 if (a === 'parallel') return { kind: 'launch', launch: 'parallel' }
442 if (/^\d+$/.test(a)) {
443 const n = Number(a)
444 return list.items.some(i => i.number === n)
445 ? { kind: 'issue', issue: n }
446 : { kind: 'invalid', message: `#${n} no está entre los lanzables.` }
447 }
448 return { kind: 'invalid', message: 'Uso: /fausto-blood-pact lanzar [sequential|parallel|<n>]' }
449}
450
451// ---- Incremento 4: la mascota (MEF-ADR-0055 decision 10/11; lector puro: pose del status y de los events.jsonl) ----
452
453export type MascotPose = { role: Role; state: string }
454export type LastEvent = { kind: 'tool' | 'text'; tool: string }
455
456/** Rol de la mascota segun el sufijo del agente (el prefijo `<n>-` del stage se ignora); sin agente conocido, desarrollador. */
457export function roleOfAgent(agent: string): Role {
458 const name = agentOf(agent)
459 if (/(^|-)(test-writer|coverage-gate)$/.test(name)) return 'tester'
460 if (/(^|-)reviewer$/.test(name)) return 'revisor'
461 if (/(^|-)infra-writer$/.test(name)) return 'infraestructura'
462 return 'desarrollador'
463}
464
465/** La corrida activa con `updated` mas reciente (a igualdad, la de `started` mas reciente); null sin corridas activas. */
466export function activeRunOf(runs: readonly PipelineRun[]): PipelineRun | null {
467 let best: PipelineRun | null = null
468 for (const r of runs) {
469 if (!isActive(r)) continue
470 if (!best || r.updated.localeCompare(best.updated) > 0 || (r.updated === best.updated && r.started.localeCompare(best.started) > 0)) best = r
471 }
472 return best
473}
474
475export const LOGS_DIR = 'logs'
476
477/** Prefijo del `log_base` por pipeline: "" en tdd, `tooling-` e `iac-`. */
478export function logPrefixOf(pipeline: PipelineKind): string {
479 return pipeline === 'tooling' ? 'tooling-' : pipeline === 'infra' ? 'iac-' : ''
480}
481
482/** `<prefijo>stage-<stage>-<started>-issue-<n>[-<variante>]`: lo que precede a `-attempt-<k>.events.jsonl`. */
483export function eventsLogBase(run: PipelineRun): string {
484 return `${logPrefixOf(run.pipeline)}stage-${run.stage}-${run.started}-issue-${run.issue}${run.variant ? `-${run.variant}` : ''}`
485}
486
487/** Nombre del `events.jsonl` del intento mas alto de la corrida entre los nombres del directorio de logs; null si no hay. */
488export function eventsFileOf(run: PipelineRun, names: readonly string[]): string | null {
489 const base = `${eventsLogBase(run)}-attempt-`
490 let best: string | null = null
491 let bestAttempt = -1
492 for (const raw of names) {
493 const name = raw.trim()
494 if (!name.startsWith(base)) continue
495 const m = /^(\d+)\.events\.jsonl$/.exec(name.slice(base.length))
496 if (!m) continue
497 const attempt = Number(m[1])
498 if (attempt > bestAttempt) {
499 bestAttempt = attempt
500 best = name
501 }
502 }
503 return best
504}
505
506/** Ultimo evento relevante del `events.jsonl` (run-events v1): `tool.started` o `message` del assistant con texto; null si no hay. */
507export function lastEventOf(raw: string): LastEvent | null {
508 const lines = raw.split('\n')
509 for (let i = lines.length - 1; i >= 0; i -= 1) {
510 const line = (lines[i] ?? '').trim()
511 if (line === '') continue
512 let ev: Record<string, unknown>
513 try {
514 ev = JSON.parse(line) as Record<string, unknown>
515 } catch {
516 continue
517 }
518 if (ev.type === 'tool.started') return { kind: 'tool', tool: typeof ev.tool === 'string' ? ev.tool : '' }
519 if (ev.type === 'message' && ev.role === 'assistant' && typeof ev.text === 'string' && ev.text.trim() !== '') return { kind: 'text', tool: '' }
520 }
521 return null
522}
523
524const EDIT_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
525
526/** Pose de la corrida activa: herramienta trabaja (o despliega), texto o sin eventos piensa, edicion del revisor corrige. */
527export function poseOfRun(run: PipelineRun, last: LastEvent | null): MascotPose {
528 const role = roleOfAgent(agentOf(run.stage))
529 if (role === 'infraestructura') return { role, state: 'desplegando' }
530 if (last?.kind !== 'tool') return { role, state: 'pensando' }
531 if (role === 'revisor' && EDIT_TOOLS.test(last.tool)) return { role, state: 'corrigiendo' }
532 return { role, state: 'trabajando' }
533}
534
535/** Pose con solo resultados: `✓` revisor aprobado (infra arriba), `✗` desarrollador error (infra caido); null sin resultados. */
536export function poseOfResults(results: readonly PipelineResult[]): MascotPose | null {
537 const r = results[results.length - 1]
538 if (!r) return null
539 const isInfra = r.pipeline === 'infra'
540 if (r.ok) return isInfra ? { role: 'infraestructura', state: 'arriba' } : { role: 'revisor', state: 'aprobado' }
541 return isInfra ? { role: 'infraestructura', state: 'caido' } : { role: 'desarrollador', state: 'error' }
542}
543
544// ---- Incremento 5: mergear / ver el PR de una corrida terminada ----
545export const PR_CHECK_MS = 15_000
546export const MERGE_ALL = 'Todos'
547export const MERGE_CANCEL = 'Cancelar'
548const MERGE_MAX_OPTIONS = 3
549
550export type ResultPr = { pr: string; issue: number; pipeline: PipelineKind; key: string }
551
552/** Numero de PR de un resultado `✓ PR #X`; null si es `✗` o `✓ completado`. */
553export function prOfResult(r: PipelineResult): string | null {
554 return r.ok ? (/^✓ PR #(\d+)/.exec(r.text)?.[1] ?? null) : null
555}
556
557/** PRs de los `✓` visibles, sin repetir, del mas reciente (numero mayor) al mas antiguo. */
558export function resultPrs(results: PipelineResult[]): ResultPr[] {
559 const found = new Map<string, ResultPr>()
560 for (const r of results) {
561 const pr = prOfResult(r)
562 if (pr && !found.has(pr)) found.set(pr, { pr, issue: r.issue, pipeline: r.pipeline, key: r.key })
563 }
564 return [...found.values()].sort((a, b) => Number(b.pr) - Number(a.pr))
565}
566
567const prLabel = (p: ResultPr) => `#${p.pr} · #${p.issue} ${p.pipeline}`
568
569/** Opciones del dialogo de merge: con un PR, ese y cancelar; con varios, "Todos" y hasta 3 PRs mas recientes. */
570export function mergeOptions(prs: ResultPr[]): string[] {
571 if (prs.length === 1 && prs[0]) return [`Mergear #${prs[0].pr}`, MERGE_CANCEL]
572 return [MERGE_ALL, ...prs.slice(0, MERGE_MAX_OPTIONS).map(prLabel)]
573}
574
575/** Opciones de "cual PR ver": hasta 3 PRs mas recientes; los demas numeros van en la opcion de texto. */
576export function viewOptions(prs: ResultPr[]): string[] {
577 return prs.slice(0, MERGE_MAX_OPTIONS).map(prLabel)
578}
579
580/**
581 * Argumentos de /mefisto:merge a partir de la respuesta (opciones marcadas unidas por ", " y/o texto libre):
582 * solo numeros de PRs `✓` conocidos, nunca `--all`. Null si no queda ninguno (cancelar o nada elegido).
583 */
584export function mergeArgsOf(answer: string | null, options: string[], prs: ResultPr[]): string | null {
585 return argsOfAnswer(answer, options, prs.map(p => p.pr))
586}
587
588/** Comun a ambos dialogos de merge: un solo numero exige su opcion; "Todos" pasa `allArg` si lo hay, o todos los listados; si no, elegidos + texto libre. */
589function argsOfAnswer(answer: string | null, options: string[], numbersListed: string[], allArg: string | null = null): string | null {
590 if (answer === null) return null
591 if (numbersListed.length === 1) return answer === options[0] && numbersListed[0] ? numbersListed[0] : null
592 if (answer.includes(MERGE_ALL)) return allArg ?? numbersListed.join(' ')
593 const known = new Set(numbersListed)
594 let rest = answer
595 const numbers: string[] = []
596 for (const option of options) {
597 if (option === MERGE_ALL || !rest.includes(option)) continue
598 rest = rest.replace(option, '')
599 const n = /^#(\d+)/.exec(option)?.[1]
600 if (n) numbers.push(n)
601 }
602 numbers.push(...(rest.match(/\d+/g) ?? []))
603 const picked = [...new Set(numbers)].filter(n => known.has(n))
604 return picked.length > 0 ? picked.join(' ') : null
605}
606
607/** El PR que eligio la persona para verlo: un numero de la respuesta que sea de un `✓`. */
608export function viewPrOf(answer: string | null, prs: ResultPr[]): string | null {
609 if (answer === null) return null
610 const known = new Set(prs.map(p => p.pr))
611 const n = (/#(\d+)/.exec(answer)?.[1] ?? /\d+/.exec(answer)?.[0]) ?? null
612 return n && known.has(n) ? n : null
613}
614
615/** Numeros de PR de un argumento de comando (`merge 12 13`, `#12`); invalid = los que no son de un `✓`. */
616export function parsePrArgs(arg: string, prs: ResultPr[]): { prs: string[]; invalid: string[] } {
617 const known = new Set(prs.map(p => p.pr))
618 const given = [...new Set((arg.match(/\d+/g) ?? []))]
619 return { prs: given.filter(n => known.has(n)), invalid: given.filter(n => !known.has(n)) }
620}
621
622/** Toast del merge: en infra el issue lo cierra el job `apply` de CI tras el merge (MEF-ADR-0022). */
623export function mergeToast(args: string, prs: ResultPr[]): string {
624 const nums = args.split(' ')
625 const hasInfra = prs.some(p => nums.includes(p.pr) && p.pipeline === 'infra')
626 return `/mefisto:merge ${args} en cola${hasInfra ? ' · el issue de infra se cierra cuando termine el apply de CI' : ''}`
627}
628
629// ---- Incremento 6: mergear PRs abiertos desde el reposo (decision 11: referencia de experiencia) ----
630export const OPEN_PRS_ARGS = ['pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,title,isDraft,headRefName']
631
632export type OpenPr = { number: string; title: string; isFieldNote: boolean }
633
634/** Rama de los PRs de field notes del planner: `docs/planner-field-notes-*`. */
635export const isFieldNoteBranch = (branch: string): boolean => /^docs\/planner-field-notes-/.test(branch)
636
637/** PRs abiertos que no son borradores (field notes marcadas con `isFieldNote`); el mas reciente (numero mayor) primero. Vacio si no es JSON. */
638export function parseOpenPrs(raw: string): OpenPr[] {
639 let rows: unknown
640 try {
641 rows = JSON.parse(raw)
642 } catch {
643 return []
644 }
645 if (!Array.isArray(rows)) return []
646 const out: OpenPr[] = []
647 for (const r of rows as Record<string, unknown>[]) {
648 if (!r || typeof r.number !== 'number' || r.isDraft === true) continue
649 out.push({
650 number: String(r.number),
651 title: typeof r.title === 'string' ? r.title : '',
652 isFieldNote: typeof r.headRefName === 'string' && isFieldNoteBranch(r.headRefName),
653 })
654 }
655 return out.sort((a, b) => Number(b.number) - Number(a.number))
656}
657
658const openPrLabel = (p: OpenPr) => `#${p.number} ${clip(p.title, 60)}`.trim()
659
660/** Opciones del dialogo: con un PR, ese y cancelar; con varios, "Todos" y los 3 mas recientes. */
661export function openPrOptions(prs: OpenPr[]): string[] {
662 if (prs.length === 1 && prs[0]) return [openPrLabel(prs[0]), MERGE_CANCEL]
663 return [MERGE_ALL, ...prs.slice(0, MERGE_MAX_OPTIONS).map(openPrLabel)]
664}
665
666/** Argumentos de /mefisto:merge: "Todos" es `--all`; si no, los elegidos y los del texto libre. */
667export function openPrArgsOf(answer: string | null, options: string[], prs: OpenPr[]): string | null {
668 return argsOfAnswer(answer, options, prs.map(p => p.number), '--all')
669}
670
671// ---- Incremento 7: seguir un sequential como lote (MEF-ADR-0055 decision 10/11; #2202 escribe el status) ----
672export const BATCH_STATUS_FILE = 'pipeline-status-batch.json'
673/** Senal de parada suave (MEF-ADR-0017), relativa a la raiz del checkout principal. */
674export const BATCH_STOP_FILE = 'pipeline-state/batch-stop'
675export const BATCH_STOP_ANSWER = 'Detener tras el actual'
676export const BATCH_KEEP_ANSWER = 'Seguir'
677
678export type BatchIssueStatus = 'pendiente' | 'en-curso' | 'mergeado' | 'fallido' | 'aplazado' | 'saltado'
679export type BatchState = 'running' | 'completed' | 'failed' | 'stopped'
680export type BatchIssue = { issue: number; status: BatchIssueStatus; pr: string | null; detail: string }
681export type BatchStatus = {
682 pipeline: string
683 started: string
684 state: BatchState
685 current: number | null
686 stopRequested: boolean
687 holdSeconds: number
688 issues: BatchIssue[]
689 /** Ruta del log del lote (`log` del status), que acumula todos los issues. */
690 log?: string
691}
692
693const BATCH_ISSUE_STATUSES: readonly string[] = ['pendiente', 'en-curso', 'mergeado', 'fallido', 'aplazado', 'saltado']
694const BATCH_STATES: readonly string[] = ['running', 'completed', 'failed', 'stopped']
695
696export function batchStatusPath(stateDir: string): string {
697 return `${stateDir.replace(/\/+$/, '')}/${BATCH_STATUS_FILE}`
698}
699
700/** Ruta de la senal de parada: la raiz del checkout principal (donde `batch-pipeline.sh` lee su cwd), nunca el worktree. */
701export function batchStopPath(repoRoot: string): string {
702 return `${repoRoot.replace(/\/+$/, '')}/${BATCH_STOP_FILE}`
703}
704
705function prNumberOf(value: unknown): string | null {
706 if (typeof value === 'number') return String(value)
707 if (typeof value !== 'string') return null
708 return /(\d+)\s*$/.exec(value)?.[1] ?? null
709}
710
711/** Parseo de `pipeline-status-batch.json`; null si no es JSON valido o no trae un `state` conocido. */
712export function parseBatchStatus(raw: string): BatchStatus | null {
713 let json: Record<string, unknown>
714 try {
715 const parsed: unknown = JSON.parse(raw)
716 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
717 json = parsed as Record<string, unknown>
718 } catch {
719 return null
720 }
721 if (typeof json.state !== 'string' || !BATCH_STATES.includes(json.state)) return null
722 const issues: BatchIssue[] = []
723 for (const row of Array.isArray(json.issues) ? (json.issues as Record<string, unknown>[]) : []) {
724 const issue = Number(row?.issue)
725 if (!Number.isFinite(issue)) continue
726 const status = typeof row.status === 'string' && BATCH_ISSUE_STATUSES.includes(row.status) ? (row.status as BatchIssueStatus) : 'pendiente'
727 issues.push({ issue, status, pr: prNumberOf(row.pr), detail: typeof row.detail === 'string' ? row.detail : '' })
728 }
729 const current = Number(json.current)
730 return {
731 pipeline: typeof json.pipeline === 'string' ? json.pipeline : 'sequential',
732 started: typeof json.started === 'string' ? json.started : '',
733 state: json.state as BatchState,
734 current: json.current !== null && json.current !== undefined && json.current !== '' && Number.isFinite(current) ? current : null,
735 stopRequested: json.stop_requested === true,
736 holdSeconds: typeof json.hold_seconds === 'number' && json.hold_seconds > 0 ? json.hold_seconds : 0,
737 issues,
738 ...(typeof json.log === 'string' && json.log !== '' ? { log: json.log } : {}),
739 }
740}
741
742export const isBatchRunning = (b: BatchStatus) => b.state === 'running'
743
744/** Pose del lote sin corrida activa, como la cinta interna: terminado es el revisor aprobado; el desarrollador no tiene `aprobado`. */
745export function poseOfBatch(b: BatchStatus): MascotPose {
746 if (b.state === 'failed') return { role: 'desarrollador', state: 'error' }
747 if (b.state === 'completed') return { role: 'revisor', state: 'aprobado' }
748 return { role: 'desarrollador', state: 'pensando' }
749}
750
751export type BatchCounts = { merged: number; failed: number; deferred: number; skipped: number; total: number }
752
753export function batchCounts(b: BatchStatus): BatchCounts {
754 const count = (s: BatchIssueStatus) => b.issues.filter(i => i.status === s).length
755 return { merged: count('mergeado'), failed: count('fallido'), deferred: count('aplazado'), skipped: count('saltado'), total: b.issues.length }
756}
757
758/** Marca de la cola: `✓` mergeado, `●` en curso, `✗` fallido, `⏸` aplazado, `-` saltado, `·` pendiente. */
759export function batchMark(status: BatchIssueStatus): string {
760 switch (status) {
761 case 'mergeado':
762 return '✓'
763 case 'en-curso':
764 return '●'
765 case 'fallido':
766 return '✗'
767 case 'aplazado':
768 return '⏸'
769 case 'saltado':
770 return '-'
771 default:
772 return '·'
773 }
774}
775
776/** Cola en una linea, separada por ` ─ `: `✓ #12 ─ ● #13 reviewer ─ ⏸ #14`; el issue en curso lleva su paso si se conoce. */
777export function batchQueueText(b: BatchStatus, step?: string | null): string {
778 return batchQueueMarks(b, step).map((m, n) => `${n > 0 ? ' \u2500 ' : ''}${m.text}`).join('')
779}
780
781/** Marcas de la cola con color (undefined = atenuado): `✓` success, `●` warning, `✗` error, `⏸` y `○` atenuados. */
782export function batchQueueMarks(b: BatchStatus, step?: string | null): { text: string; color: string | undefined }[] {
783 const cur = batchCurrentOf(b)
784 return b.issues.map(i => {
785 const color = i.status === 'mergeado' ? 'success' : i.status === 'en-curso' ? 'warning' : i.status === 'fallido' ? 'error' : undefined
786 const mark = i.status === 'pendiente' ? '\u25cb' : batchMark(i.status)
787 const withStep = step && cur && cur.issue === i.issue && i.status === 'en-curso' ? ` ${step}` : ''
788 return { text: `${mark} #${i.issue}${withStep}`, color }
789 })
790}
791
792/** Cabecera del lote en curso: `sequential M/N` (M = mergeados + fallidos), tiempo `MM:SS` desde `started` y, con parada pedida, `se detiene tras #N`. */
793export function batchRunningHeader(b: BatchStatus, nowMs: number): { progress: string; time: string; stopNote: string } {
794 const c = batchCounts(b)
795 const parsed = stampToMs(b.started) || Date.parse(b.started)
796 const startedMs = Number.isNaN(parsed) ? 0 : parsed
797 return {
798 progress: `sequential ${c.merged + c.failed}/${c.total}`,
799 time: startedMs > 0 ? elapsedText(nowMs - startedMs) : '',
800 stopNote: b.stopRequested ? `se detiene tras #${b.current ?? batchCurrentOf(b)?.issue ?? '\u2026'}` : '',
801 }
802}
803
804/** El eslabon en curso: `current` del status o, si no, el primero `en-curso`; null si no hay. */
805export function batchCurrentOf(b: BatchStatus): BatchIssue | null {
806 return b.issues.find(i => i.issue === b.current) ?? b.issues.find(i => i.status === 'en-curso') ?? null
807}
808
809/** PR del eslabon en curso o, si aun no tiene, el del ultimo `mergeado` con PR; null sin PR. */
810export function batchPrOf(b: BatchStatus): string | null {
811 const current = batchCurrentOf(b)
812 if (current?.pr) return current.pr
813 for (let i = b.issues.length - 1; i >= 0; i -= 1) {
814 const row = b.issues[i] as BatchIssue
815 if (row.status === 'mergeado' && row.pr) return row.pr
816 }
817 return null
818}
819
820/** Corrida suelta del eslabon en curso (su propio status), para pipeline, agente, tiempo y mascota. */
821export function batchRunOf(b: BatchStatus, runs: readonly PipelineRun[]): PipelineRun | null {
822 const current = batchCurrentOf(b)
823 if (!current) return null
824 return runs.filter(r => r.issue === current.issue && isActive(r)).sort((a, c) => c.updated.localeCompare(a.updated))[0] ?? null
825}
826
827/** Espera por rate limit en curso del eslabon: `en espera por rate limit · próxima sonda HH:MM`; vacio si no esta en `hold`. */
828export function batchHoldLine(run: PipelineRun | null): string {
829 if (!run || run.state !== 'hold') return ''
830 return `en espera por rate limit · próxima sonda ${run.nextProbe ? clockOf(run.nextProbe) : '?'}`
831}
832
833/** Minutos de espera por rate limit acumulados, como `Mm`; vacio sin espera. */
834export function holdText(holdSeconds: number): string {
835 return holdSeconds > 0 ? `${Math.max(1, Math.round(holdSeconds / 60))}m` : ''
836}
837
838export type IssueStats = { durationMs: number; costUsd: number | null; title: string | null; finishedMs: number }
839
840/** `YYYYMMDD-HHMMSS` a milisegundos locales; 0 si no tiene ese formato. */
841export function stampToMs(stamp: string): number {
842 const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(stamp)
843 if (!m) return 0
844 const [y = 0, mo = 1, d = 1, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
845 return new Date(y, mo - 1, d, h, mi, s).getTime()
846}
847
848/** Pipelines publicados que escriben el historial de un issue del lote. */
849export const BATCH_HISTORY_PIPELINES = ['tdd', 'tooling', 'iac']
850
851type StatsEntry = {
852 issue?: string
853 pipeline?: string
854 started?: string
855 finished?: string
856 title?: string
857 agents?: Record<string, { metrics?: { estimated_cost_usd?: number | null } }>
858}
859
860/**
861 * Duracion y costo de la ultima corrida de cada issue en el historial, arrancada en o despues de `sinceMs`.
862 * El costo suma `agents.*.metrics.estimated_cost_usd` (null si ningun agente lo informa).
863 */
864export function issueStatsFromHistory(raw: string, issues: readonly string[], sinceMs: number): Record<string, IssueStats> {
865 const out: Record<string, IssueStats> = {}
866 for (const line of raw.split('\n')) {
867 let h: StatsEntry
868 try {
869 h = JSON.parse(line)
870 } catch {
871 continue
872 }
873 const issue = String(h.issue ?? '')
874 if (!BATCH_HISTORY_PIPELINES.includes(h.pipeline ?? '') || !issues.includes(issue) || !h.started || !h.finished) continue
875 const startedMs = stampToMs(h.started)
876 const finishedMs = new Date(h.finished).getTime()
877 if (startedMs + 1000 < sinceMs || Number.isNaN(finishedMs)) continue
878 const costs = Object.values(h.agents ?? {})
879 .map(a => a.metrics?.estimated_cost_usd)
880 .filter((c): c is number => typeof c === 'number')
881 out[issue] = {
882 durationMs: Math.max(0, finishedMs - startedMs),
883 costUsd: costs.length > 0 ? costs.reduce((x, y) => x + y, 0) : null,
884 title: typeof h.title === 'string' && h.title !== '' ? h.title : null,
885 finishedMs,
886 }
887 }
888 return out
889}
890
891/** Issues sin estadisticas o con estadisticas sin titulo: un titulo vacio en cache no se corrige solo. */
892export function statsPending(issues: readonly string[], stats: Record<string, IssueStats>): string[] {
893 return issues.filter(issue => !stats[issue] || stats[issue].title === null)
894}
895
896export const fmtCost = (usd: number | null) => (usd === null ? '$?' : `$${usd.toFixed(2)}`)
897
898/** Total de lo terminado: duracion sumada y costo sumado (sin contar los que no lo informan). */
899export function statsTotal(stats: IssueStats[]): IssueStats {
900 const costs = stats.map(s => s.costUsd).filter((c): c is number => c !== null)
901 return {
902 durationMs: stats.reduce((a, s) => a + s.durationMs, 0),
903 costUsd: costs.length > 0 ? costs.reduce((a, c) => a + c, 0) : null,
904 title: null,
905 finishedMs: 0,
906 }
907}
908
909/** Titulo recortado con `...` a `width` columnas y rellenado a ese ancho (vacio si no hay titulo o espacio). */
910export function fitTitle(title: string | null | undefined, width: number): string {
911 if (width <= 0) return ''
912 const t = (title ?? '').trim()
913 return padEnd(t.length > width ? `${t.slice(0, width - 1)}…` : t, width)
914}
915
916/** Ancho de la columna de titulo de la tabla de cierre; 0 si no cabe un titulo legible. */
917export function summaryTitleWidth(inner: number): number {
918 const w = Math.min(60, inner - 38)
919 return w < 8 ? 0 : w
920}
921
922/** Resumen de cierre con marcas: `✓ N mergeados · ✗ N fallidos · ⏸ N aplazados · espera Xm` (omite lo que esta en cero). */
923export function batchSummaryMarks(b: BatchStatus): string {
924 const c = batchCounts(b)
925 const hold = holdText(b.holdSeconds)
926 return [
927 `✓ ${c.merged} mergeados`,
928 c.failed > 0 ? `✗ ${c.failed} fallidos` : '',
929 c.deferred > 0 ? `⏸ ${c.deferred} aplazados` : '',
930 hold ? `espera ${hold}` : '',
931 ]
932 .filter(Boolean)
933 .join(' · ')
934}
935
936/** Estado del cierre en la cabecera: `terminado`, `detenido` o `con fallos`. */
937export function batchEndLabel(state: BatchState): string {
938 return state === 'completed' ? 'terminado' : state === 'stopped' ? 'detenido' : 'con fallos'
939}
940
941/** Fila de la tabla de cierre (sin la marca): issue, titulo, PR, duracion y costo; `…` sin entrada de historial. */
942export function closeRowText(i: BatchIssue, st: IssueStats | undefined, titleW: number): { lead: string; trail: string } {
943 const lead = `${padEnd(`#${i.issue}`, 7)} ${titleW > 0 ? `${fitTitle(st?.title, titleW)} ` : ''}${padEnd(i.pr ? `PR #${i.pr}` : 'sin PR', 10)} `
944 return { lead, trail: st ? `${padEnd(elapsedText(st.durationMs), 8)} ${fmtCost(st.costUsd)}` : '…' }
945}
946
947/** Fila `●` del issue en curso: `#N`, titulo, `PR #X` o `…`, tiempo desde `startedMs` y `…` de costo. */
948export function runningRowText(issue: number, pr: string | null, title: string | null, startedMs: number, nowMs: number, titleW: number): { lead: string; trail: string } {
949 const lead = `${padEnd(`#${issue}`, 7)} ${titleW > 0 ? `${fitTitle(title, titleW)} ` : ''}${padEnd(pr ? `PR #${pr}` : '…', 10)} `
950 return { lead, trail: `${padEnd(startedMs > 0 ? elapsedText(nowMs - startedMs) : '…', 8)} …` }
951}
952
953/** Corrida de la fila `●` y, si ya termino, el instante en que se congela su tiempo. */
954export type CurrentRow = { run: PipelineRun; endMs: number | null }
955
956/**
957 * Fila `●` del lote en curso: la corrida activa del eslabon; si su status ya no esta activo mientras el issue
958 * espera su merge, la ultima vista, con el tiempo congelado en `nowMs` (como la cinta interna). Null sin lote en curso.
959 */
960export function nextCurrentRow(b: BatchStatus, live: PipelineRun | null, prev: CurrentRow | null, nowMs: number): CurrentRow | null {
961 if (!isBatchRunning(b)) return null
962 if (live) return { run: live, endMs: null }
963 const cur = batchCurrentOf(b)
964 if (!prev || !cur || prev.run.issue !== cur.issue || stampToMs(prev.run.started) < stampToMs(b.started)) return null
965 return prev.endMs === null ? { run: prev.run, endMs: nowMs } : prev
966}
967
968/** Total con el issue en curso: suma su tiempo transcurrido a los terminados; el costo sigue siendo solo el de los terminados. */
969export function totalWithRunning(total: IssueStats, startedMs: number, nowMs: number): IssueStats {
970 return startedMs > 0 ? { ...total, durationMs: total.durationMs + Math.max(0, nowMs - startedMs) } : total
971}
972
973/** Fila `total` de la tabla de cierre, alineada con las columnas de `closeRowText`. */
974export function closeTotalText(count: number, total: IssueStats, titleW: number): string {
975 const titleCol = titleW > 0 ? titleW + 1 : 0
976 return ` total ${padEnd(String(count), 12 + titleCol)} ${padEnd(elapsedText(total.durationMs), 8)} ${fmtCost(total.costUsd)}`
977}
978
979/** Momento de cierre del lote: el ultimo `finished` del historial entre sus issues; null sin entradas. */
980export function batchEndFromStats(stats: Record<string, IssueStats>): number | null {
981 const all = Object.values(stats).map(s => s.finishedMs)
982 return all.length > 0 ? Math.max(...all) : null
983}
984
985/** Issues que pasaron a `mergeado` entre dos lecturas; con `prev` vacio no hay toasts (el primer vistazo no anuncia). */
986export function newlyMerged(prev: readonly BatchIssue[] | null, next: readonly BatchIssue[]): BatchIssue[] {
987 if (!prev) return []
988 const before = new Map(prev.map(i => [i.issue, i.status]))
989 return next.filter(i => i.status === 'mergeado' && before.get(i.issue) !== 'mergeado')
990}
991
992/** Clave de descarte persistente del lote: repo + `started`. */
993export function batchDismissKey(repo: string, b: BatchStatus): string {
994 return [repo, 'batch', b.started].join('|')
995}
996
997/** Pregunta del dialogo de parada. */
998export function stopQuestion(b: BatchStatus): string {
999 return `¿Detener el sequential tras ${b.current ? `#${b.current}` : 'el issue en curso'}?`
1000}
1001
1002/** `1` se ofrece solo con el lote corriendo y sin parada pedida. */
1003export const canStopBatch = (b: BatchStatus | null): boolean => b !== null && isBatchRunning(b) && !b.stopRequested
1004
1005// ---- Incremento 8: la cinta de merge en vivo (el estado vive en $.state; no toca `.mefisto/pipeline/`) ----
1006export const MERGE_POLL_MS = 5_000
1007export const MERGE_RIBBON_HIDE_MS = 15_000
1008
1009/** Argumentos de un merge de `pr-sync.sh` (`--merge`): los numeros de PR o `--all`; null si el comando no es un merge de ese script. */
1010export function mergeRunArgsOf(command: string): string | null {
1011 const m = /pr-sync\.sh["']?(?=\s|$|[;&|])([^;&|]*)/.exec(command)
1012 if (!m) return null
1013 const tokens = (m[1] ?? '').split(/\s+/).filter(t => t !== '')
1014 if (!tokens.includes('--merge')) return null
1015 if (tokens.includes('--all')) return '--all'
1016 return tokens.filter(t => /^\d+$/.test(t)).join(' ')
1017}
1018
1019/** Los PRs de la cinta: con `--all`, los abiertos que se pasen; si no, los numeros de los argumentos, con el titulo que se conozca. */
1020export function mergePrsOf(args: string, open: readonly OpenPr[]): PactMergePr[] {
1021 const pending = (num: string, title: string): PactMergePr => ({ num, title, estado: 'pendiente' })
1022 if (/(^|\s)--all(\s|$)/.test(args)) return open.map(p => pending(p.number, p.title))
1023 const nums = [...new Set(args.match(/\d+/g) ?? [])]
1024 return nums.map(n => pending(n, open.find(p => p.number === n)?.title ?? ''))
1025}
1026
1027/** Pasa a `mergeado` los PRs pendientes indicados; devuelve la misma cinta si nada cambio. Un mergeado no vuelve atras. */
1028export function withMerged(run: PactMerge, merged: readonly string[]): PactMerge {
1029 if (!run.prs.some(p => p.estado === 'pendiente' && merged.includes(p.num))) return run
1030 return { ...run, prs: run.prs.map(p => (p.estado === 'pendiente' && merged.includes(p.num) ? { ...p, estado: 'mergeado' } : p)) }
1031}
1032
1033/** Cierra la cinta: lo que siga pendiente queda `fallido` (`✗ no mergeado`). */
1034export function finishMerge(run: PactMerge, now: number): PactMerge {
1035 if (run.finishedMs !== null) return run
1036 return { ...run, prs: run.prs.map(p => (p.estado === 'pendiente' ? { ...p, estado: 'fallido' } : p)), finishedMs: now }
1037}
1038
1039export function mergeCounts(run: PactMerge) {
1040 return {
1041 pending: run.prs.filter(p => p.estado === 'pendiente').length,
1042 merged: run.prs.filter(p => p.estado === 'mergeado').length,
1043 failed: run.prs.filter(p => p.estado === 'fallido').length,
1044 }
1045}
1046
1047/** Cabecera de la cinta: `merge N/M` (N cuenta los mergeados) y, al terminar, `terminado`. */
1048export function mergeRibbonHeader(run: PactMerge, nowMs: number): string {
1049 const c = mergeCounts(run)
1050 const mins = Math.max(0, Math.floor(((run.finishedMs ?? nowMs) - run.startedMs) / 60_000))
1051 return `merge ${c.merged}/${run.prs.length} · ${mins}m${run.finishedMs === null ? '' : ' · terminado'}`
1052}
1053
1054/** Linea de un PR: `· #12 titulo` pendiente, `✓` mergeado, `✗ ... no mergeado` fallido. */
1055export function mergeRibbonLine(p: PactMergePr, max: number): string {
1056 const mark = p.estado === 'mergeado' ? '✓' : p.estado === 'fallido' ? '✗' : '·'
1057 const tail = p.estado === 'fallido' ? ' · no mergeado' : ''
1058 const head = `${mark} #${p.num}`
1059 const room = max - head.length - tail.length - 1
1060 const title = p.title.trim() === '' || room < 2 ? '' : ` ${clip(p.title, room)}`
1061 return `${head}${title}${tail}`
1062}
1063
1064/** Filas de la cinta: los pendientes primero, luego los fallidos y, al final, los mergeados. */
1065export function mergeRibbonRows(run: PactMerge): PactMergePr[] {
1066 return [
1067 ...run.prs.filter(p => p.estado === 'pendiente'),
1068 ...run.prs.filter(p => p.estado === 'fallido'),
1069 ...run.prs.filter(p => p.estado === 'mergeado'),
1070 ]
1071}
1072
1073/** Toast de cierre: `N mergeados · M fallidos`. */
1074export function mergeSummaryText(run: PactMerge): string {
1075 const c = mergeCounts(run)
1076 return `${c.merged} mergeados · ${c.failed} fallidos`
1077}
1078
1079/** Vencimiento al dibujar: una cinta terminada hace mas de 15 s se oculta; una en curso, nunca. */
1080export function isMergeRibbonExpired(run: PactMerge | null, nowMs: number): boolean {
1081 return run !== null && run.finishedMs !== null && nowMs - run.finishedMs > MERGE_RIBBON_HIDE_MS
1082}
1083
1084/** Precedencia de la banda: el lote de sequential primero, luego la cinta de merge vigente, luego corridas y reposo. */
1085export function mergeRibbonShown(batch: BatchStatus | null, run: PactMerge | null, nowMs: number): PactMerge | null {
1086 return batch === null && run !== null && !isMergeRibbonExpired(run, nowMs) ? run : null
1087}
1088
1089// ---- Incremento 9: la bitacora (boton `3: bitacora N` y la cinta del historiador) ----
1090export const HISTORIAN_DONE_MS = 8_000
1091export const NOTES_POLL_MS = 3_000
1092export const FIELD_NOTES_DIR = 'docs/bitacora/field-notes'
1093export const PROCESSED_NOTES_DIR = `${FIELD_NOTES_DIR}/procesadas`
1094export const BITACORA_PR_ARGS = ['pr', 'list', '--state', 'open', '--limit', '50', '--json', 'number,title,headRefName,files']
1095const AGENT_ACTIVE = new Set(['pending', 'running', 'waiting'])
1096
1097/** El tipo del agente lleva el prefijo del plugin (`mefisto:historiador`); se acepta con o sin el. */
1098export const isHistorianType = (type: string): boolean => /(^|:)historiador$/.test(type)
1099
1100/** Un agente de `$.agent.list()` que aun no termina. */
1101export const isAgentActive = (status: string): boolean => AGENT_ACTIVE.has(status)
1102
1103/** Field notes `.md` de `docs/bitacora/field-notes/` (sin bajar a `procesadas/`). */
1104export function fieldNotesIn(names: readonly string[]): number {
1105 return names.filter(n => n.endsWith('.md')).length
1106}
1107
1108/** N de `3: bitacora N`: las field notes de main mas los PRs de field notes abiertos. */
1109export const bitacoraCount = (fieldNotes: number | null, prs: readonly OpenPr[]): number => (fieldNotes ?? 0) + prs.filter(p => p.isFieldNote).length
1110
1111/** Pregunta del dialogo de integrar la bitacora. */
1112export function bitacoraQuestion(local: number, prCount: number): string {
1113 return `¿Integrar la bitácora? ${local} field notes en main${prCount > 0 ? ` y ${prCount} en PRs sin mergear` : ''}`
1114}
1115
1116export const bitacoraIntegrateLabel = (prCount: number): string => (prCount > 0 ? `Mergear sus ${prCount} PRs e integrar` : 'Integrar')
1117
1118/** El prompt de usuario: mergear los PRs de field notes, traer main al dia y correr /mefisto:bitacora. */
1119export function bitacoraPrompt(fieldNotePrs: readonly (number | string)[]): string {
1120 const merge = fieldNotePrs.length > 0
1121 ? `mergea los PRs de field notes ${fieldNotePrs.map(n => `#${n}`).join(' ')} con /mefisto:merge, `
1122 : ''
1123 return `Integra la bitacora: ${merge}pon el checkout en main al dia (git switch main && git pull --ff-only) y luego corre /mefisto:bitacora.`
1124}
1125
1126/** Estado de la cinta del historiador; `prev` tal cual si nada cambio. */
1127export function historianFrom(prev: Historian | null, active: boolean, now: number): Historian | null {
1128 if (active) return prev && !prev.finishedMs ? prev : { startedMs: now, finishedMs: null, notes: [], pr: null }
1129 return prev && !prev.finishedMs ? { ...prev, finishedMs: now } : prev
1130}
1131
1132/** Terminada hace mas de `HISTORIAN_DONE_MS` (sin PR esperando al usuario) la cinta no existe; en curso nunca vence. */
1133export function liveHistorian(h: Historian | null, nowMs: number): Historian | null {
1134 if (!h || !h.finishedMs || h.pr) return h
1135 return nowMs - h.finishedMs > HISTORIAN_DONE_MS ? null : h
1136}
1137
1138const FIELD_NOTE_PATH = /^docs\/bitacora\/field-notes\/([^/]+\.md)$/
1139
1140/** Field notes a integrar: las locales (`.md`) y las de las rutas de los PRs de field notes, sin repetir. */
1141export function fieldNoteNamesOf(local: readonly string[], prPaths: readonly string[]): string[] {
1142 const fromPrs = prPaths.map(p => FIELD_NOTE_PATH.exec(p)?.[1]).filter((n): n is string => !!n)
1143 return [...new Set([...local.filter(n => n.endsWith('.md')), ...fromPrs])].sort()
1144}
1145
1146export const pendingNotes = (names: readonly string[]): BitacoraNote[] => names.map(name => ({ name, estado: 'pendiente' }))
1147
1148/** Pasa a procesada cada nota que ya aparece en `procesadas/`; `h` tal cual si nada cambio. */
1149export function withProcessedNotes<T extends { notes: BitacoraNote[] }>(h: T, processed: readonly string[]): T {
1150 const done = new Set(processed)
1151 if (!h.notes.some(n => n.estado === 'pendiente' && done.has(n.name))) return h
1152 return { ...h, notes: h.notes.map(n => (n.estado === 'pendiente' && done.has(n.name) ? { ...n, estado: 'procesada' } : n)) }
1153}
1154
1155/** Filas de la cinta: las pendientes `●` primero y luego las procesadas `✓`. */
1156export function orderedNotes(notes: readonly BitacoraNote[]): BitacoraNote[] {
1157 return [...notes.filter(n => n.estado === 'pendiente'), ...notes.filter(n => n.estado === 'procesada')]
1158}
1159
1160export const noteLine = (n: BitacoraNote): string => `${n.estado === 'procesada' ? '✓' : '●'} ${n.name}`
1161
1162/** `mm:ss` (o `h:mm:ss`). */
1163export function elapsedText(ms: number): string {
1164 const total = Math.max(0, Math.floor(ms / 1000))
1165 const h = Math.floor(total / 3600)
1166 const m = Math.floor((total % 3600) / 60)
1167 const p = (n: number) => String(n).padStart(2, '0')
1168 return h > 0 ? `${h}:${p(m)}:${p(total % 60)}` : `${p(m)}:${p(total % 60)}`
1169}
1170
1171/** `historiador escribiendo la bitácora k/n mm:ss` (sin `k/n` mientras no se conocen las notas). */
1172export function historianHeader(h: Historian, nowMs: number): string {
1173 const done = h.notes.filter(n => n.estado === 'procesada').length
1174 return `historiador escribiendo la bitácora ${h.notes.length > 0 ? `${done}/${h.notes.length} ` : ''}${elapsedText(nowMs - h.startedMs)}`
1175}
1176
1177/** El PR de bitacora abierto (`gh pr list --json number,title,headRefName,files`): solo toca `docs/bitacora/` y no es de la rama de field notes. El mas reciente. */
1178export function bitacoraPrOf(stdout: string): BitacoraPr | null {
1179 try {
1180 const rows = JSON.parse(stdout) as { number: number; title: string; headRefName?: string; files?: { path: string }[] }[]
1181 const pr = rows
1182 .filter(r => !isFieldNoteBranch(r.headRefName ?? ''))
1183 .filter(r => (r.files ?? []).length > 0 && (r.files ?? []).every(f => f.path.startsWith('docs/bitacora/')))
1184 .sort((a, b) => b.number - a.number)[0]
1185 return pr ? { number: pr.number, title: pr.title } : null
1186 } catch {
1187 return null
1188 }
1189}
1190
1191// ---- Vista de corrida con pasos y ultimas lineas (#2320; lector puro de status, log del pipeline y events.jsonl) ----
1192
1193export const BAND_LOG_ROWS = 3
1194/** Lineas que se leen del log: el filtro de la vista del lote descarta separadores y lineas sin hora. */
1195export const BAND_FETCH_ROWS = 12
1196export const LAUNCH_POLL_WINDOW_MS = 120_000
1197export const LOG_TAIL_BYTES = 8192
1198
1199const PIPELINE_SCRIPTS = /(^|[\s/"'=])(batch|tmux|tdd|tooling|iac)-pipeline\.sh(\s|$|["';|&])/
1200hooks/types/index.d.ts 135 lines1export type BoardItem = {
2 number: number
3 title: string
4 tipo: string | null
5 after: number[]
6 hasDepsSection: boolean
7}
8
9export type BoardList = {
10 items: BoardItem[]
11 blockedCount: number
12 cycleCount: number
13 launch: string | null
14 /** Numeros de los `tipo:infra` lanzables, en el orden de items. */
15 infra: number[]
16 /** Lote de `/mefisto:parallel` calculado por next-order. */
17 parallel: { issues: number[]; launch: string | null } | null
18 error: string | null
19}
20
21export type PipelineKind = 'tdd' | 'tooling' | 'infra'
22
23/** Un `pipeline-status-*.json` ya parseado; lo que la consola muestra de una corrida. */
24export type PipelineRun = {
25 issue: number
26 pipeline: PipelineKind
27 variant: string | null
28 started: string
29 updated: string
30 stage: string
31 state: string
32 nextProbe: string | null
33 /** Pasos de `status.agents` en su orden (#2320). */
34 agents?: RunStep[]
35 /** Ruta del log del pipeline (`status.log`). */
36 log?: string | null
37 /** Titulo del issue (`status.title`). */
38 title?: string
39 /** Ultimo error del status (`last_error`) de una corrida fallida. */
40 lastError?: string
41}
42
43/** Una linea del pane de log: evento del agente o linea del log del pipeline (`log`, atenuada). */
44export type PaneLine = { kind: 'start' | 'tool' | 'fail' | 'done' | 'log'; text: string }
45
46/** Un agente del status: `pending` cubre todo `result` vacio o desconocido. */
47export type RunStep = { name: string; result: 'passed' | 'running' | 'failed' | 'pending'; /** Segundos del paso (`agents.<x>.duration`). */ duration?: number }
48
49/** Una corrida terminada pendiente de descartar: `✗ <stage>` o `✓ PR #X`. */
50export type PipelineResult = { key: string; issue: number; pipeline: PipelineKind; text: string; ok: boolean }
51
52/** Una fila de la cinta de merge de la consola. */
53export type PactMergePr = { num: string; title: string; estado: 'pendiente' | 'mergeado' | 'fallido' }
54
55/** La cinta de merge en curso de la consola; `finishedMs` null mientras corre. */
56export type PactMerge = { prs: PactMergePr[]; startedMs: number; finishedMs: number | null }
57
58/** Una field note que integra el historiador: pendiente hasta que aparece en `procesadas/`. */
59export type BitacoraNote = { name: string; estado: 'pendiente' | 'procesada' }
60
61/** El PR de bitacora que dejo el historiador, abierto a la espera de mergearlo o cerrarlo. */
62export type BitacoraPr = { number: number; title: string }
63
64/** El subagente historiador escribiendo la bitacora en esta sesion (lo lanza /mefisto:bitacora). */
65export type Historian = { startedMs: number; finishedMs: number | null; notes: BitacoraNote[]; pr: BitacoraPr | null }
66
67export type BoardTab = 'borrador' | 'listo'
68
69/** Lo que el planner esta haciendo: refinar un borrador o explorar una idea. */
70export type BoardFocus = {
71 kind: 'refinar' | 'explorar'
72 issue: number | null
73 topic: string
74 created: number[]
75 startedMs: number
76}
77
78/** Resumen breve que la banda muestra al cerrar un foco. */
79export type BoardFlash = { text: string; untilMs: number }
80
81declare module 'claude-code' {
82 interface PluginState {
83 mefisto: {
84 isActive: boolean
85 isPlannerSession: boolean
86 isExpanded: boolean
87 tab: BoardTab
88 page: number
89 frame: 0 | 1
90 stepsInTurn: number
91 isWorking: boolean
92 focus: BoardFocus | null
93 isConfirmingClose: boolean
94 flash: BoardFlash | null
95 refine: BoardList | null
96 develop: BoardList | null
97 updatedMs: number
98 signature: string
99 known: number[]
100 pendingCount: number
101 version: string | null
102 pactIsActive: boolean
103 pactList: BoardList | null
104 pactPage: number
105 pactRuns: PipelineRun[]
106 pactResults: PipelineResult[]
107 pactLastEvent: { kind: 'tool' | 'text'; tool: string } | null
108 pactBandLines: string[]
109 pactOpenPrs: { number: string; title: string; isFieldNote: boolean }[]
110 pactFieldNotes: number | null
111 pactHistorian: Historian | null
112 pactBatch: {
113 pipeline: string
114 started: string
115 state: 'running' | 'completed' | 'failed' | 'stopped'
116 current: number | null
117 stopRequested: boolean
118 holdSeconds: number
119 issues: { issue: number; status: 'pendiente' | 'en-curso' | 'mergeado' | 'fallido' | 'aplazado' | 'saltado'; pr: string | null; detail: string }[]
120 } | null
121 pactBatchStats: {
122 stats: Record<string, { durationMs: number; costUsd: number | null; title: string | null; finishedMs: number }>
123 endedMs: number | null
124 }
125 /** Fila `●` del sequential en curso: su corrida y, si espera el merge, el instante en que se congela su tiempo (#2361). */
126 pactBatchCurrent: { run: PipelineRun; endMs: number | null } | null
127 pactMerge: PactMerge | null
128 pactLogLines: PaneLine[]
129 pactPaneRun: PipelineRun | null
130 pactTick: number
131 pactVersion: string | null
132 }
133 }
134}
135