Panel con el roadmap del plan de superpowers activo y las tasks y subagentes de la sesión

Mod de Claude Code: panel lateral con el roadmap del plan de superpowers activo (docs/superpowers/plans/*.md) y las tasks y subagentes de la sesión, más un resumen en la barra de estado.
✻, naranja; gira al trabajar) sobre la fase actual: camina al siguiente tramo y celebra en cada hito, y al cerrar (gh pr create/merge, git [-C ruta] merge, desde Revisión o Cierre) celebra (el hueco de la estación parpadea y la fila de la Task muestra «¡hito!») con 🎉. Las fases avanzan solas por las skills de superpowers (brainstorming, writing-plans, subagent-driven-development/executing-plans, code review, finishing-a-development-branch), al escribir o leer specs y planes, al lanzar agentes «Task N» y al completar todas las Tasks del plan. Un ciclo nuevo en la misma rama, sin merge entre medias, empieza al invocar brainstorming o writing-plans con la implementación ya en marcha, o al escribir otro spec; suelta el plan terminado. El plan también se detecta cuando la sesión solo lo nombra por Bash (cat/grep/sed del plan o la carpeta .superpowers/sdd/<plan>/). Debajo, la cabecera (título, worktree y rama), la línea de metro con las Tasks (destello blanco al completar una), la Task en curso y los agentes. Se ve con una fase en curso (hasta 5 s tras cerrarla), con el plan fijado o con alguna task de la sesión pendiente (modo solo tasks); si no, cede al motor. /roadmap franja la oculta o muestra. Colores: violeta (hecho #a78bfa, actual #e9d5ff/#7c3aed, pendiente #5b4b8a, borde #7c3aed)./roadmap abre o cierra el panel lateral de detalle (ya no se abre solo)./roadmap <ruta> fija un plan para la sesión./roadmap reset borra el progreso guardado del plan actual./roadmap fin termina el ciclo de fases (quita la fila de fases) sin tocar el progreso guardado del plan.claude plugin marketplace add theglobedev/claude-roadmap
claude plugin install roadmap@theglobedev-roadmap
Reinicia Claude Code. Para actualizar: claude plugin update roadmap.
Clona el repo y apunta a él en el env de ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/ruta/a/claude-roadmap" }
claude plugin test /ruta/a/claude-roadmaphooks/register.tsx 658 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { ActiveAgent, Milestone, PhaseId, PhaseState, Plan, SessionTask, TaskFlash, TaskStatus } from '../types'
4import type { EngineInterface } from 'claude-code'
5import { parsePlan } from './lib/planParser'
6import { buildRoadmapFor, keepLiveAgents, matchTask, newlyCompleted, stampTodos, todosNewlyCompleted } from './lib/taskMatcher'
7import { agentTask, CLOSED_SHOW_MS, FLASH_MS, metroBand } from './lib/metro'
8import { MILESTONE_MS } from './lib/clawd'
9import { EMPTY_PHASE, PHASES, SPEC_RE, advance, isCloseCommand, phaseForSkill, specTitleFrom } from './lib/phases'
10import { PALETTE } from './lib/palette'
11import { progressBar, visibleRows } from './lib/summary'
12import { completedFromLedger, isLedgerFile, ledgerPath, touchesLedgerCommand } from './lib/ledger'
13import { PLAN_RE, absolutePath, cdTarget, planDir, planRefsInCommand, repoFromGit, repoRoot, storeKey } from './lib/state'
14
15const tasks = atom({ plugin: 'roadmap', key: 'tasks' } as const, [] as SessionTask[])
16const todos = atom({ plugin: 'roadmap', key: 'todos' } as const, [] as SessionTask[])
17const agents = atom({ plugin: 'roadmap', key: 'agents' } as const, [] as ActiveAgent[])
18const planPath = atom({ plugin: 'roadmap', key: 'planPath' } as const, null as string | null)
19const pinned = atom({ plugin: 'roadmap', key: 'pinned' } as const, false)
20const plan = atom({ plugin: 'roadmap', key: 'plan' } as const, null as Plan | null)
21const planError = atom({ plugin: 'roadmap', key: 'planError' } as const, null as string | null)
22const doneStored = atom({ plugin: 'roadmap', key: 'doneStored' } as const, [] as number[])
23const isOpen = atom({ plugin: 'roadmap', key: 'isOpen' } as const, false)
24const storeOk = atom({ plugin: 'roadmap', key: 'storeOk' } as const, true)
25const tick = atom({ plugin: 'roadmap', key: 'tick' } as const, 0)
26const phase = atom({ plugin: 'roadmap', key: 'phase' } as const, EMPTY_PHASE as PhaseState)
27const milestone = atom({ plugin: 'roadmap', key: 'milestone' } as const, null as Milestone | null)
28const flash = atom({ plugin: 'roadmap', key: 'flash' } as const, null as TaskFlash | null)
29const repo = atom({ plugin: 'roadmap', key: 'repo' } as const, null as { worktree: string; branch: string } | null)
30const bandHidden = atom({ plugin: 'roadmap', key: 'bandHidden' } as const, false)
31
32// El motor sigue `$` solo dentro de funciones declaradas en este mismo fichero.
33type Eng = EngineInterface
34
35// Worktree y rama del plan activo (la carpeta del plan, no el cwd de la sesión). Cualquier fallo -> null.
36async function refreshRepo($: Eng): Promise<void> {
37 try {
38 const path = await read($, planPath)
39 const source = (await read($, phase)).source
40 // sin plan: la carpeta del spec y, si tampoco hay, el cwd de la sesión
41 const dir = path ? planDir(path) : source ? planDir(source) : await $.session.cwd()
42 const git = async (...args: string[]) => {
43 const r = await $.process.run(['git', '-C', dir, ...args])
44 return r.exitCode === 0 ? r.stdout : null
45 }
46 const top = await git('rev-parse', '--show-toplevel')
47 let branch = top === null ? null : await git('branch', '--show-current')
48 let sha = ''
49 if (top !== null && branch !== null && !branch.trim()) sha = (await git('rev-parse', '--short', 'HEAD')) ?? ''
50 const found = top === null || branch === null ? null : repoFromGit(top, branch, sha)
51 await update($, repo, () => found)
52 } catch {
53 try {
54 await update($, repo, () => null)
55 } catch {
56 // nunca romper la sesión
57 }
58 }
59}
60
61async function loadPlan($: Eng, path: string): Promise<void> {
62 const abs = absolutePath(await $.session.cwd(), path)
63 try {
64 const parsed = parsePlan(await $.fs.read(abs))
65 await update($, plan, () => parsed ?? null)
66 await update($, planError, () => (parsed ? null : 'plan no legible'))
67 } catch {
68 await update($, plan, () => null)
69 await update($, planError, () => 'plan no legible')
70 }
71 try {
72 const stored = await $.store.get(storeKey(abs))
73 await update($, doneStored, () => (Array.isArray(stored) ? stored.filter(n => typeof n === 'number') : []))
74 await update($, storeOk, () => true)
75 } catch {
76 // sin lectura no se escribe: no se pisa el progreso real guardado (spec §10)
77 await update($, doneStored, () => [])
78 await update($, storeOk, () => false)
79 }
80 // la ruta se fija la última: ningún refresh concurrente mezcla planes
81 await update($, planPath, () => abs)
82 await refreshRepo($)
83 await syncLedger($)
84}
85
86// Incorpora las Ns nuevas como hechas: estado, almacén, destello y, con el plan entero hecho en impl, revisión.
87async function markDone($: Eng, current: Plan, path: string, fresh: number[]): Promise<void> {
88 if (fresh.length === 0) return
89 // el plan activo pudo cambiar mientras tanto: no se escribe en otro plan
90 if ((await read($, planPath)) !== path || JSON.stringify(await read($, plan)) !== JSON.stringify(current)) return
91 let merged: number[] = []
92 let added: number[] = []
93 await update($, doneStored, s => {
94 added = fresh.filter((n, i) => !s.includes(n) && fresh.indexOf(n) === i)
95 merged = added.length > 0 ? [...s, ...added] : s
96 return merged
97 })
98 if (added.length === 0) return
99 const stamp = { n: added[added.length - 1]!, at: await nowMs($) }
100 await update($, flash, () => stamp)
101 ensureTicker($)
102 if (await read($, storeOk)) {
103 try {
104 await $.store.set(storeKey(path), merged)
105 } catch {
106 // sin persistencia: se sigue en memoria
107 }
108 }
109 // plan entero hecho en implementación -> revisión
110 const everything = [...(await read($, tasks)), ...(await read($, todos))]
111 const r = buildRoadmapFor(current, everything, merged, path)
112 if (r.total > 0 && r.done === r.total && (await read($, phase)).current === 'impl') await advancePhase($, 'review')
113}
114
115// Lee el ledger SDD del plan activo y marca las Tasks completas que aún no constan.
116async function syncLedger($: Eng): Promise<void> {
117 try {
118 const current = await read($, plan)
119 const path = await read($, planPath)
120 if (!current || !path) return
121 const lp = ledgerPath(path)
122 if (!lp) return
123 let text: string
124 try {
125 text = await $.fs.read(lp)
126 } catch {
127 return // sin ledger todavía
128 }
129 // el plan activo cambió durante la lectura: este ledger ya no es el vigente
130 if ((await read($, planPath)) !== path || JSON.stringify(await read($, plan)) !== JSON.stringify(current)) return
131 const stored = await read($, doneStored)
132 const fresh = completedFromLedger(text).filter(n => current.tasks.some(t => t.n === n) && !stored.includes(n))
133 await markDone($, current, path, fresh)
134 } catch {
135 // nunca romper la sesión
136 }
137}
138
139// Persiste solo las tasks que acaban de pasar a hecha, contra el plan activo en ese momento.
140async function persistCompleted($: Eng, done: SessionTask[]): Promise<void> {
141 try {
142 const current = await read($, plan)
143 const path = await read($, planPath)
144 if (!current || !path || done.length === 0) return
145 const stored = await read($, doneStored)
146 const fresh: number[] = []
147 for (const t of done) {
148 if (t.planPath != null && t.planPath !== path) continue
149 const n = matchTask(t.subject, current)
150 if (n !== undefined && !stored.includes(n) && !fresh.includes(n)) fresh.push(n)
151 }
152 await markDone($, current, path, fresh)
153 } catch {
154 // nunca romper la sesión
155 }
156}
157
158const nowMs = ($: Eng): Promise<number> => $.clock.now().catch(() => Date.now())
159
160// Avanza la fase (solo hacia delante). Un cambio dentro de un ciclo abierto deja un hito.
161async function advancePhase($: Eng, to: PhaseId, refresh = true, reopen = false): Promise<void> {
162 const prev = await read($, phase)
163 const next = advance(prev, to, reopen)
164 if (next === prev) return
165 await update($, phase, () => next)
166 const reopened =
167 prev.current !== null && next.current !== null && PHASES.indexOf(next.current) < PHASES.indexOf(prev.current)
168 if (prev.closedAt !== null || reopened) {
169 // ciclo nuevo: sin hito ni destello heredados, y un spec o plan nuevo suelta el plan anterior
170 await update($, milestone, () => null)
171 await update($, flash, () => null)
172 if (to === 'spec' || to === 'plan') await dropPlan($)
173 } else if (prev.current !== null) {
174 const step: Milestone = { from: prev.current, to, at: await nowMs($) }
175 await update($, milestone, () => step)
176 }
177 ensureTicker($)
178 if (refresh) await refreshRepo($)
179}
180
181// Leer o escribir un spec abre el ciclo (o refresca su título); en plan o después no reinicia nada.
182async function onSpec($: Eng, abs: string, wrote: boolean): Promise<void> {
183 const prev = await read($, phase)
184 // escribir otro spec con la implementación ya en marcha abre un ciclo nuevo; leerlo, no
185 const another = wrote && abs !== prev.source && prev.current !== null && PHASES.indexOf(prev.current) >= PHASES.indexOf('impl')
186 const closed = prev.closedAt !== null || another
187 const early = prev.current === null || prev.current === 'spec' || closed
188 if (!early) return
189 let title: string | null = null
190 try {
191 title = specTitleFrom(await $.fs.read(abs))
192 } catch {
193 // sin título
194 }
195 if (closed || abs !== prev.source) {
196 await update($, phase, (): PhaseState => ({ ...EMPTY_PHASE, current: 'spec', source: abs, specTitle: title }))
197 await update($, milestone, () => null)
198 await update($, flash, () => null)
199 if (closed && prev.current !== 'spec') await dropPlan($)
200 await refreshRepo($)
201 return
202 }
203 await update($, phase, p => ({ ...p, specTitle: title ?? p.specTitle }))
204 await advancePhase($, 'spec')
205}
206
207// Suelta el plan del ciclo anterior (salvo fijado): la franja no sigue enseñando sus Tasks ya terminadas.
208async function dropPlan($: Eng): Promise<void> {
209 if (await read($, pinned)) return
210 await update($, planPath, () => null)
211 await update($, plan, () => null)
212 await update($, planError, () => null)
213 await update($, doneStored, () => [])
214}
215
216// Un plan leído o escrito por la sesión principal: lo carga y, si es otro que el activo, reinicia la fase a «plan».
217async function onPlan($: Eng, abs: string): Promise<void> {
218 // fijado: solo se relee el plan fijado; otros planes se ignoran
219 if ((await read($, pinned)) && abs !== (await read($, planPath))) return
220 const old = await read($, planPath)
221 if (old !== null && abs !== old) {
222 const prev = await read($, phase)
223 await update($, phase, (): PhaseState => ({ ...EMPTY_PHASE, current: 'plan', source: abs, specTitle: prev.specTitle }))
224 await update($, milestone, () => null)
225 await update($, flash, () => null)
226 }
227 await loadPlan($, abs)
228 await advancePhase($, 'plan', false) // loadPlan ya refrescó el repo
229}
230
231// Un Bash que nombra un plan (o la carpeta de su ledger SDD) lo carga como si se hubiera leído. La ruta relativa
232// se prueba contra el `cd` inicial, la raíz del plan y del spec activos y el cwd de la sesión; solo cuenta si se lee.
233async function planFromBash($: Eng, cmd: string): Promise<void> {
234 if (await read($, pinned)) return
235 const refs = planRefsInCommand(cmd)
236 if (refs.length === 0) return
237 const cwd = await $.session.cwd()
238 const cd = cdTarget(cmd)
239 const bases = [cd === null ? null : absolutePath(cwd, cd), repoRoot(await read($, planPath)), repoRoot((await read($, phase)).source), cwd]
240 const current = await read($, planPath)
241 for (const ref of refs) {
242 const candidates = ref.startsWith('/') ? [ref] : bases.filter((b): b is string => b !== null).map(b => absolutePath(b, ref))
243 for (const abs of [...new Set(candidates)]) {
244 if (abs === current) return
245 let parsed: Plan | undefined
246 try {
247 parsed = parsePlan(await $.fs.read(abs))
248 } catch {
249 parsed = undefined
250 }
251 if (parsed) {
252 await onPlan($, abs)
253 return
254 }
255 }
256 }
257}
258
259const PANE = 'roadmap'
260const LIVE = ['pending', 'running', 'waiting']
261const TICK_MS = 250
262const BAND_KEY = 'bandHidden'
263
264// Un solo reloj de franja por carga del mod: se cancela al quedarse sin agentes, hito ni destello
265// y se rearma al llegar el siguiente, así no se apila ni escribe en vacío.
266let ticker: { cancel: () => void } | undefined
267let poller: { cancel: () => void } | undefined
268
269function ensureTicker($: Eng): void {
270 if (ticker) return
271 let wasAlive = false
272 try {
273 ticker = $.clock.every(TICK_MS, async () => {
274 try {
275 const alive = async (): Promise<boolean> => {
276 const t = await nowMs($)
277 const m = await read($, milestone)
278 const f = await read($, flash)
279 const ph = await read($, phase)
280 return (
281 (await read($, agents)).length > 0 ||
282 (m !== null && t - m.at < MILESTONE_MS) ||
283 (f !== null && t - f.at < FLASH_MS) ||
284 (ph.closedAt !== null && t - ph.closedAt < CLOSED_SHOW_MS)
285 )
286 }
287 if (!(await alive())) {
288 // último repintado: destello, hito o franja de cierre no se quedan congelados
289 if (wasAlive) await update($, tick, n => n + 1)
290 wasAlive = false
291 if (!(await alive())) {
292 ticker?.cancel()
293 ticker = undefined
294 }
295 return
296 }
297 wasAlive = true
298 await update($, tick, n => n + 1)
299 } catch {
300 // el reloj es solo decorado
301 }
302 })
303 } catch {
304 ticker = undefined
305 }
306}
307
308export const register: Register = on => {
309 on('session.start', async ($, e, next) => {
310 const ran = await next(e)
311 try {
312 await $.command.register({
313 name: 'roadmap',
314 description:
315 'Roadmap del plan: abre/cierra el panel, oculta/muestra la franja (/roadmap franja), fija un plan (/roadmap <ruta>), borra su progreso (/roadmap reset) o termina el ciclo de fases (/roadmap fin)',
316 })
317 } catch {
318 // sin comando, el resto sigue
319 }
320 try {
321 // limpia una entrada de estado heredada de una carga anterior; el mod ya no dibuja barra de estado
322 $.ui.status(undefined)
323 } catch {
324 // sin barra de estado que limpiar
325 }
326 try {
327 await update($, bandHidden, () => false)
328 if ((await $.store.get(BAND_KEY)) === true) await update($, bandHidden, () => true)
329 } catch {
330 // sin almacén, la franja queda visible
331 }
332 try {
333 poller?.cancel()
334 poller = $.clock.every(5000, async () => {
335 try {
336 const cur = (await read($, phase)).current
337 if (cur === 'impl' || cur === 'review') await syncLedger($)
338 const bg = (await read($, agents)).filter(a => a.background)
339 if (bg.length === 0) return
340 const live = (await $.agent.list()).filter(a => LIVE.includes(a.status))
341 await update($, agents, l => keepLiveAgents(l, live))
342 } catch {
343 // sondeo best effort
344 }
345 })
346 } catch {
347 // sin sondeo
348 }
349 ensureTicker($)
350 return ran
351 })
352
353 on('agent.spawn', async ($, e, next) => {
354 const ran = await next(e)
355 try {
356 if (ran.deny === undefined) {
357 const row: ActiveAgent = {
358 id: e.tool_use_id,
359 description: e.description,
360 model: e.model ?? e.parentModel,
361 background: e.background,
362 agentId: ran.agentId,
363 startedAt: await $.clock.now().catch(() => Date.now()),
364 taskN: agentTask(e.description, (await read($, plan)) ?? undefined),
365 }
366 await update($, agents, l => [...l.filter(x => !(x.id === row.id && x.description === row.description)), row])
367 if (row.taskN !== undefined) {
368 await advancePhase($, 'impl')
369 }
370 ensureTicker($)
371 }
372 } catch {
373 // observar nunca bloquea
374 }
375 return ran
376 })
377
378 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
379 const ran = await next(e)
380 try {
381 await update($, agents, l => l.filter(x => x.id !== e.tool_use_id || x.background))
382 } catch {
383 // observar nunca bloquea
384 }
385 return ran
386 })
387
388 on('command.run', { command: 'roadmap' }, async ($, e) => {
389 try {
390 const arg = e.args.trim()
391 if (arg === 'reset') {
392 await update($, phase, () => EMPTY_PHASE)
393 await update($, milestone, () => null)
394 await update($, flash, () => null)
395 const path = await read($, planPath)
396 if (!path) return { text: 'No hay plan activo.' }
397 await $.store.delete(storeKey(path))
398 await update($, doneStored, () => [])
399 return { text: 'Progreso guardado borrado para este plan.' }
400 }
401 if (arg === 'fin') {
402 // termina el ciclo de fases sin tocar el progreso guardado del plan
403 await update($, phase, () => EMPTY_PHASE)
404 await update($, milestone, () => null)
405 await update($, flash, () => null)
406 return { text: 'Ciclo de fases terminado.' }
407 }
408 if (arg === 'franja') {
409 const hidden = !(await read($, bandHidden))
410 await update($, bandHidden, () => hidden)
411 try {
412 await $.store.set(BAND_KEY, hidden)
413 } catch {
414 // sin persistencia: vale para esta sesión
415 }
416 return { text: hidden ? 'Franja oculta.' : 'Franja visible.' }
417 }
418 if (arg) {
419 await update($, pinned, () => true)
420 await loadPlan($, arg)
421 await advancePhase($, 'impl', false)
422 await $.ui.open({ id: PANE, title: 'Roadmap' })
423 await update($, isOpen, () => true)
424 const abs = absolutePath(await $.session.cwd(), arg)
425 return { text: (await read($, planError)) ? `Plan no legible: ${abs}` : `Plan fijado: ${abs}` }
426 }
427 if (await read($, isOpen)) {
428 await $.ui.close({ id: PANE })
429 await update($, isOpen, () => false)
430 return { text: 'Panel de roadmap cerrado.' }
431 }
432 await $.ui.open({ id: PANE, title: 'Roadmap' })
433 await update($, isOpen, () => true)
434 return { text: 'Panel de roadmap abierto.' }
435 } catch {
436 return { text: 'No se pudo ejecutar /roadmap.' }
437 }
438 })
439
440 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
441 try {
442 if (e.props.hasSurvey || (await read($, bandHidden))) return next(e)
443 const current = (await read($, plan)) ?? undefined
444 const all = [...(await read($, tasks)), ...(await read($, todos))]
445 const r = buildRoadmapFor(current, all, await read($, doneStored), await read($, planPath))
446 const live = (await read($, agents)).map(a => ({ ...a, taskN: a.taskN ?? agentTask(a.description, current) }))
447 const now = await nowMs($)
448 const ph = await read($, phase)
449 // fijada, o con fase en curso (hasta 5 s tras cerrarla), o con alguna task de la sesión viva
450 const showBand =
451 (await read($, pinned)) ||
452 (ph.current !== null && !(ph.closedAt !== null && now - ph.closedAt > CLOSED_SHOW_MS)) ||
453 all.some(t => t.status !== 'completed')
454 if (!showBand) return next(e)
455 const columns = e.props.bodyColumns || e.viewport?.columns || 80
456 const rows = metroBand({
457 title: current?.title,
458 repo: await read($, repo),
459 roadmap: r,
460 agents: live,
461 width: Math.max(1, columns - 4), // borde (2) + relleno (2)
462 maxRows: e.props.maxRows - 2, // el borde se lleva dos filas
463 tick: await read($, tick),
464 now,
465 phase: ph,
466 milestone: await read($, milestone),
467 flash: await read($, flash),
468 looseTasks: all,
469 })
470 if (rows.length === 0) return next(e)
471 const { Box, Text } = $.ui.resolve(e)
472 return (
473 <Box borderStyle="round" borderColor={PALETTE.border} flexDirection="column" paddingX={1} width={columns}>
474 {rows.map(row => (
475 <Text wrap="truncate">
476 {row.map(s => (
477 <Text color={s.color} bold={s.bold} dimColor={s.dim}>
478 {s.text}
479 </Text>
480 ))}
481 </Text>
482 ))}
483 </Box>
484 )
485 } catch {
486 return next(e)
487 }
488 })
489
490 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
491 const { Box, Text } = $.ui.resolve(e)
492 const current = await read($, plan)
493 const error = await read($, planError)
494 const all = [...(await read($, tasks)), ...(await read($, todos))]
495 const r = buildRoadmapFor(current ?? undefined, all, await read($, doneStored), await read($, planPath))
496 const looseLive = r.loose.filter(t => t.status !== 'completed')
497 const hiddenDone = r.loose.length - looseLive.length
498 const running = await read($, agents)
499 const reserved = 6 + looseLive.length + (hiddenDone > 0 ? 1 : 0) + running.length
500 const room = Math.max(3, (e.viewport?.rows ?? 30) - reserved)
501 const win = visibleRows(r.rows, room)
502 const mark = { done: '✔', active: '▶', pending: '○' } as const
503
504 return (
505 <Box flexDirection="column">
506 {current ? (
507 <Box flexDirection="column">
508 <Text bold>plan: {current.title}</Text>
509 <Text>
510 {progressBar(r.done, r.total)} {r.done}/{r.total}
511 </Text>
512 {win.hiddenBefore > 0 && <Text dimColor>… {win.hiddenBefore} más</Text>}
513 {win.rows.map(row => (
514 <Text dimColor={row.state === 'done'} bold={row.state === 'active'}>
515 {mark[row.state]} {row.n} {row.title}
516 </Text>
517 ))}
518 {win.hiddenAfter > 0 && <Text dimColor>… {win.hiddenAfter} más</Text>}
519 </Box>
520 ) : (
521 <Text dimColor>{error ?? 'Sin plan detectado'}</Text>
522 )}
523 {r.loose.length > 0 && <Text dimColor>─ Tasks sesión ─</Text>}
524 {looseLive.map(t => (
525 <Text bold={t.status === 'in_progress'}>
526 {t.status === 'in_progress' ? '▶' : '○'} {t.subject}
527 </Text>
528 ))}
529 {hiddenDone > 0 && <Text dimColor>✔ {hiddenDone} completadas</Text>}
530 {running.length > 0 && <Text dimColor>─ Subagentes ─</Text>}
531 {running.map(a => (
532 <Text>
533 ⚙ {a.description} · {a.model}
534 </Text>
535 ))}
536 </Box>
537 )
538 })
539
540 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
541 const ran = await next(e)
542 try {
543 const task = (ran.result as { task?: { id?: string; subject?: string } } | undefined)?.task
544 if (!e.agentId && ran.deny === undefined && !ran.isError && task?.id) {
545 const stamp = await read($, planPath)
546 const row: SessionTask = { id: task.id, subject: task.subject ?? e.subject, status: 'pending', planPath: stamp }
547 await update($, tasks, l => [...l.filter(x => x.id !== row.id), row])
548 }
549 } catch {
550 // observar nunca bloquea
551 }
552 return ran
553 })
554
555 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
556 const ran = await next(e)
557 try {
558 if (!e.agentId && ran.deny === undefined && !ran.isError) {
559 const prev = await read($, tasks)
560 const stamp = await read($, planPath)
561 let next2: SessionTask[]
562 if (e.status === 'deleted') {
563 next2 = prev.filter(x => x.id !== e.taskId)
564 } else if (!prev.some(x => x.id === e.taskId)) {
565 next2 = e.subject
566 ? [...prev, { id: e.taskId, subject: e.subject, status: (e.status ?? 'pending') as TaskStatus, planPath: stamp }]
567 : prev
568 } else {
569 next2 = prev.map(x =>
570 x.id === e.taskId
571 ? {
572 ...x,
573 subject: e.subject ?? x.subject,
574 status: (e.status ?? x.status) as TaskStatus,
575 planPath: x.planPath ?? (e.status === 'completed' ? stamp : x.planPath),
576 }
577 : x,
578 )
579 }
580 await update($, tasks, () => next2)
581 await persistCompleted($, newlyCompleted(prev, next2))
582 }
583 } catch {
584 // observar nunca bloquea
585 }
586 return ran
587 })
588
589 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
590 const ran = await next(e)
591 try {
592 if (!e.agentId && ran.deny === undefined && !ran.isError) {
593 const prev = await read($, todos)
594 const stamp = await read($, planPath)
595 const fresh = stampTodos(
596 prev,
597 e.todos.map((t, i): SessionTask => ({ id: `todo:${i}`, subject: t.content, status: t.status })),
598 stamp,
599 )
600 await update($, todos, () => fresh)
601 await persistCompleted($, todosNewlyCompleted(prev, fresh))
602 }
603 } catch {
604 // observar nunca bloquea
605 }
606 return ran
607 })
608
609 on('tool.call', { tool: ['Read', 'Write', 'Edit'] }, async ($, e, next) => {
610 const ran = await next(e)
611 try {
612 if (!e.agentId && ran.deny === undefined && !ran.isError && isLedgerFile(e.file_path)) {
613 await syncLedger($)
614 } else if (!e.agentId && ran.deny === undefined && !ran.isError && PLAN_RE.test(e.file_path)) {
615 await onPlan($, absolutePath(await $.session.cwd(), e.file_path))
616 } else if (!e.agentId && ran.deny === undefined && !ran.isError && SPEC_RE.test(e.file_path)) {
617 await onSpec($, absolutePath(await $.session.cwd(), e.file_path), e.tool !== 'Read')
618 }
619 } catch {
620 // observar nunca bloquea
621 }
622 return ran
623 })
624
625 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
626 const ran = await next(e)
627 try {
628 if (!e.agentId && ran.deny === undefined && !ran.isError) {
629 const to = phaseForSkill(e.skill)
630 if (to) await advancePhase($, to, true, true)
631 }
632 } catch {
633 // observar nunca bloquea
634 }
635 return ran
636 })
637
638 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
639 const ran = await next(e)
640 try {
641 if (!e.agentId && ran.deny === undefined && !ran.isError) await planFromBash($, e.command)
642 if (!e.agentId && ran.deny === undefined && !ran.isError && touchesLedgerCommand(e.command)) await syncLedger($)
643 if (!e.agentId && ran.deny === undefined && !ran.isError && isCloseCommand(e.command)) {
644 const prev = await read($, phase)
645 if ((prev.current === 'close' || prev.current === 'review') && prev.closedAt === null) {
646 const at = await nowMs($)
647 await update($, phase, p => ({ ...p, closedAt: at }))
648 await update($, milestone, () => ({ from: prev.current, to: 'done', at }))
649 ensureTicker($)
650 }
651 }
652 } catch {
653 // observar nunca bloquea
654 }
655 return ran
656 })
657}
658hooks/lib/planParser.ts 60 lines1import type { Plan, PlanTask } from '../../types'
2
3const TITLE_RE = /^#\s+(.+?)\s*$/
4const TASK_RE = /^#{2,4}\s+Task\s+(\d+)\s*:\s*(.+?)\s*$/i
5const HEADING_RE = /^#{1,4}\s/
6const STEP_RE = /^\s*-\s+\[( |x|X)\]/
7const FENCE_RE = /^\s*(```|~~~)/
8
9function cleanTitle(raw: string): string {
10 return raw
11 .replace(/\s+[—–-]\s+Plan de implementaci[oó]n$/i, '')
12 .replace(/\s+Implementation Plan$/i, '')
13 .replace(/\s+[—–-]\s+plan$/i, '')
14 .trim()
15}
16
17export function parsePlan(text: string): Plan | undefined {
18 let title = ''
19 let inFence = false
20 let current: PlanTask | undefined
21 const tasks: PlanTask[] = []
22
23 for (const line of text.split(/\r?\n/)) {
24 if (FENCE_RE.test(line)) {
25 inFence = !inFence
26 continue
27 }
28 if (inFence) continue
29
30 const task = TASK_RE.exec(line)
31 if (task) {
32 current = { n: Number(task[1]), title: task[2] ?? '', steps: 0, stepsDone: 0 }
33 tasks.push(current)
34 continue
35 }
36 if (!title) {
37 const t = TITLE_RE.exec(line)
38 if (t) {
39 title = cleanTitle(t[1] ?? '')
40 continue
41 }
42 }
43 if (HEADING_RE.test(line)) {
44 current = undefined
45 continue
46 }
47 const step = STEP_RE.exec(line)
48 if (step && current) {
49 current.steps++
50 if (step[1] !== ' ') current.stepsDone++
51 }
52 }
53
54 return tasks.length > 0 ? { title: title || 'Plan', tasks } : undefined
55}
56
57export function isTaskDoneInFile(t: PlanTask): boolean {
58 return t.steps > 0 && t.stepsDone === t.steps
59}
60hooks/lib/taskMatcher.ts 120 lines1import type { ActiveAgent, Plan, SessionTask } from '../../types'
2import { isTaskDoneInFile } from './planParser'
3
4export type RowState = 'done' | 'active' | 'pending'
5export type RoadmapRow = { n: number; title: string; state: RowState }
6export type Roadmap = {
7 rows: RoadmapRow[]
8 done: number
9 total: number
10 loose: SessionTask[]
11 completedByTasks: number[]
12}
13
14const MIN_TITLE_MATCH = 8
15
16export function normalize(s: string): string {
17 return s
18 .normalize('NFD')
19 .replace(/[̀-ͯ]/g, '')
20 .toLowerCase()
21 .replace(/[^a-z0-9]+/g, ' ')
22 .trim()
23}
24
25export function matchTask(subject: string, plan: Plan): number | undefined {
26 const byNumber = /\btask\s+(\d+)\b/i.exec(subject)
27 if (byNumber) {
28 const n = Number(byNumber[1])
29 if (plan.tasks.some(x => x.n === n)) return n
30 }
31 const s = normalize(subject)
32 for (const task of plan.tasks) {
33 const title = normalize(task.title)
34 const shorter = s.length < title.length ? s : title
35 if (shorter.length < MIN_TITLE_MATCH) continue
36 if (s.includes(title) || title.includes(s)) return task.n
37 }
38 return undefined
39}
40
41export function buildRoadmap(plan: Plan | undefined, tasks: SessionTask[], stored: number[]): Roadmap {
42 if (!plan) return { rows: [], done: 0, total: 0, loose: [...tasks], completedByTasks: [] }
43
44 const matched = new Map<number, SessionTask[]>()
45 const loose: SessionTask[] = []
46 for (const task of tasks) {
47 const n = matchTask(task.subject, plan)
48 if (n === undefined) loose.push(task)
49 else matched.set(n, [...(matched.get(n) ?? []), task])
50 }
51
52 const completedByTasks: number[] = []
53 const rows = plan.tasks.map((pt): RoadmapRow => {
54 const mine = matched.get(pt.n) ?? []
55 const byTasks = mine.some(x => x.status === 'completed')
56 if (byTasks) completedByTasks.push(pt.n)
57 const isDone = byTasks || stored.includes(pt.n) || isTaskDoneInFile(pt)
58 const isActive = !isDone && mine.some(x => x.status === 'in_progress')
59 return { n: pt.n, title: pt.title, state: isDone ? 'done' : isActive ? 'active' : 'pending' }
60 })
61
62 return {
63 rows,
64 done: rows.filter(r => r.state === 'done').length,
65 total: rows.length,
66 loose,
67 completedByTasks,
68 }
69}
70
71/** Tasks que cuentan para el plan activo: las de ese plan y las creadas sin plan. */
72export function belongsToPlan(task: SessionTask, planPath: string | null): boolean {
73 return task.planPath == null || task.planPath === planPath
74}
75
76/** Como buildRoadmap, pero las tasks de otro plan no puntúan: pasan a sueltas. */
77export function buildRoadmapFor(
78 plan: Plan | undefined,
79 tasks: SessionTask[],
80 stored: number[],
81 planPath: string | null,
82): Roadmap {
83 const mine = tasks.filter(t => belongsToPlan(t, planPath))
84 const foreign = tasks.filter(t => !belongsToPlan(t, planPath))
85 const r = buildRoadmap(plan, mine, stored)
86 return { ...r, loose: [...r.loose, ...foreign] }
87}
88
89/** Tasks que han pasado a `completed` entre dos listas (por id). */
90export function newlyCompleted(prev: SessionTask[], next: SessionTask[]): SessionTask[] {
91 return next.filter(t => t.status === 'completed' && prev.find(p => p.id === t.id)?.status !== 'completed')
92}
93
94/** Todos nuevos: heredan el sello de plan del todo previo con el mismo texto, o el plan activo. */
95export function stampTodos(prev: SessionTask[], next: SessionTask[], planPath: string | null): SessionTask[] {
96 return next.map(t => {
97 const before = prev.find(p => p.subject === t.subject)
98 return { ...t, planPath: before ? before.planPath : planPath }
99 })
100}
101
102/** Quita los todos que ya estaban completados antes (mismo texto): no son transición. */
103export function todosNewlyCompleted(prev: SessionTask[], next: SessionTask[]): SessionTask[] {
104 return next.filter(
105 t => t.status === 'completed' && !prev.some(p => p.subject === t.subject && p.status === 'completed'),
106 )
107}
108
109/** Agentes en segundo plano que siguen vivos: por agentId, o por descripción si no hay id. */
110export function keepLiveAgents(
111 agents: ActiveAgent[],
112 live: { id: string; description: string }[],
113): ActiveAgent[] {
114 return agents.filter(
115 a =>
116 !a.background ||
117 (a.agentId ? live.some(x => x.id === a.agentId) : live.some(x => x.description === a.description)),
118 )
119}
120hooks/lib/metro.ts 421 lines1import type { ActiveAgent, Milestone, Plan, PhaseState, SessionTask, TaskFlash } from '../../types'
2import { milestoneStage } from './clawd'
3import { PALETTE } from './palette'
4import { PHASE_LABEL, phaseRow } from './phases'
5import type { Roadmap, RoadmapRow } from './taskMatcher'
6import { cellWidth, fitRow, pulseOn, rowWidth, truncate } from './text'
7import type { BandRow, Seg } from './text'
8
9// Lógica pura de la franja «línea de metro»: sin `$`, sin átomos, sin estado.
10
11export { cellWidth, pulseOn } from './text'
12export type { BandRow, Seg } from './text'
13
14export type BandAgent = Pick<ActiveAgent, 'id' | 'description' | 'model'> & {
15 startedAt?: number
16 taskN?: number
17}
18
19export type RepoInfo = { worktree: string; branch: string }
20
21export type MetroInput = {
22 /** Título del plan activo (cabecera). */
23 title?: string
24 /** Worktree y rama del plan activo; si falta (o es null) la cabecera muestra el título. */
25 repo?: RepoInfo | null
26 roadmap: Roadmap
27 agents: BandAgent[]
28 /** Columnas útiles dentro de la caja (ya sin borde ni relleno). */
29 width: number
30 /** Filas útiles dentro de la caja (ya sin el borde). */
31 maxRows: number
32 tick: number
33 now: number
34 /** Fase del desarrollo; con `current` la primera fila es la de fases. */
35 phase?: PhaseState
36 /** Hito en curso (la chispa camina y celebra). */
37 milestone?: Milestone | null
38 /** Destello de la estación de la Task recién completada. */
39 flash?: TaskFlash | null
40 /** Tasks de la sesión, para el modo «solo tasks» (sin plan ni fase). */
41 looseTasks?: SessionTask[]
42}
43
44const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
45const MAX_TREE = 3
46const DONE = PALETTE.done
47const CURRENT = PALETTE.currentStrong
48const CURRENT_WEAK = PALETTE.currentWeak
49const PENDING = PALETTE.pending
50const ACCENT = PALETTE.accent
51const FLOW = PALETTE.flow
52const SOFT = PALETTE.soft
53const FLOW_LEN = 3
54export const FLASH_MS = 800
55export const CLOSED_SHOW_MS = 5000
56
57export function elapsed(ms: number): string {
58 const total = Math.max(0, Math.floor(ms / 1000))
59 const m = Math.floor(total / 60)
60 const s = total % 60
61 return `${m}m ${String(s).padStart(2, '0')}s`
62}
63
64export function modelName(model: string): string {
65 const lower = model.toLowerCase()
66 for (const k of ['opus', 'sonnet', 'haiku']) if (lower.includes(k)) return k
67 return model
68}
69
70/** Task del plan que menciona la descripción de un agente («… Task 8 …»). */
71export function agentTask(description: string, plan: Plan | undefined): number | undefined {
72 const m = /\btask\s+(\d+)\b/i.exec(description)
73 if (!m || !plan) return undefined
74 const n = Number(m[1])
75 return plan.tasks.some(t => t.n === n) ? n : undefined
76}
77
78// ---------- línea de metro ----------
79
80type Place = { idx: number; pos: number; label: string }
81
82const overlaps = (a: { pos: number; len: number }, b: { pos: number; len: number }) =>
83 !(a.pos + a.len + 1 <= b.pos || b.pos + b.len + 1 <= a.pos)
84
85/** Columna de cada estación al repartir `width` entre `n` (resto a los primeros huecos). */
86export function stationColumns(n: number, width: number): number[] {
87 if (n <= 1) return [0]
88 const spare = Math.max(0, width - n)
89 const base = Math.floor(spare / (n - 1))
90 const extra = spare % (n - 1)
91 const cols = [0]
92 for (let i = 1; i < n; i++) cols.push(cols[i - 1]! + 1 + base + (i - 1 < extra ? 1 : 0))
93 return cols
94}
95
96/** Etiquetas numéricas bajo su estación: todas si caben; si no, primero, último, actual y múltiplos de 5. */
97function placeLabels(items: RoadmapRow[], cols: number[], width: number, current: number): Place[] {
98 const all = items.map((r, idx) => {
99 const label = String(r.n)
100 return { idx, pos: Math.max(0, Math.min(cols[idx]!, width - label.length)), label }
101 })
102 const clash = all.some((p, i) => i > 0 && overlaps({ pos: p.pos, len: p.label.length }, { pos: all[i - 1]!.pos, len: all[i - 1]!.label.length }))
103 if (!clash) return all
104 const order: number[] = []
105 const add = (i: number) => {
106 if (i >= 0 && i < items.length && !order.includes(i)) order.push(i)
107 }
108 add(current)
109 add(0)
110 add(items.length - 1)
111 items.forEach((r, i) => r.n % 5 === 0 && add(i))
112 const placed: Place[] = []
113 for (const i of order) {
114 const p = all[i]!
115 if (!placed.some(q => overlaps({ pos: p.pos, len: p.label.length }, { pos: q.pos, len: q.label.length }))) placed.push(p)
116 }
117 return placed.sort((a, b) => a.pos - b.pos)
118}
119
120/** Desplazamiento del brillo que «fluye» por el tramo pendiente: función pura del tick. */
121export function flowOffset(tick: number, len: number): number {
122 return len > 0 ? ((Math.floor(tick) % len) + len) % len : 0
123}
124
125type Anim = { tick: number; live: boolean; flashN?: number }
126
127function stationRow(items: RoadmapRow[], cols: number[], anim: Anim): BandRow {
128 const out: BandRow = []
129 const current = items.findIndex(r => r.state === 'active')
130 // el tramo pendiente tras la actual: un único «carril» por el que corre el brillo
131 let strip = 0
132 if (anim.live && current >= 0) for (let i = current; i < items.length - 1; i++) strip += cols[i + 1]! - cols[i]! - 1
133 const offset = flowOffset(anim.tick, strip)
134 let g = 0
135 items.forEach((r, i) => {
136 if (anim.flashN === r.n) out.push({ text: '●', color: PALETTE.flash, bold: true })
137 else if (r.state === 'done') out.push({ text: '●', color: DONE })
138 else if (r.state === 'active') {
139 if (anim.live && !pulseOn(anim.tick)) out.push({ text: '●', color: CURRENT_WEAK })
140 else out.push({ text: '◉', color: CURRENT, bold: true })
141 } else out.push({ text: '○', color: PENDING })
142 if (i < items.length - 1) {
143 const len = cols[i + 1]! - cols[i]! - 1
144 if (r.state === 'done') {
145 out.push({ text: '━'.repeat(len), color: DONE })
146 } else if (strip > 0 && i >= current) {
147 let run = ''
148 let bright = false
149 for (let c = 0; c < len; c++, g++) {
150 const hot = (((g - offset) % strip) + strip) % strip < FLOW_LEN
151 if (c > 0 && hot !== bright) {
152 out.push(bright ? { text: run, color: FLOW, bold: true } : { text: run, color: PENDING })
153 run = ''
154 }
155 bright = hot
156 run += '┄'
157 }
158 out.push(bright ? { text: run, color: FLOW, bold: true } : { text: run, color: PENDING })
159 } else {
160 out.push({ text: '┄'.repeat(len), color: PENDING })
161 }
162 }
163 })
164 return out
165}
166
167function numberRow(items: RoadmapRow[], cols: number[], width: number, current: number, offset: number): BandRow {
168 const out: BandRow = []
169 if (offset > 0) out.push({ text: ' '.repeat(offset) })
170 let col = 0
171 for (const p of placeLabels(items, cols, width, current)) {
172 if (p.pos > col) out.push({ text: ' '.repeat(p.pos - col) })
173 const isCurrent = p.idx === current
174 out.push(isCurrent ? { text: p.label, color: CURRENT, bold: true } : { text: p.label, dim: true })
175 col = p.pos + p.label.length
176 }
177 return out
178}
179
180const MIN_STEP = 3 // estación + conector de al menos 2 columnas
181
182function track(rows: RoadmapRow[], width: number, anim: Anim): { line: BandRow; numbers: BandRow } {
183 const n = rows.length
184 const current = rows.findIndex(r => r.state === 'active')
185 if ((n - 1) * MIN_STEP + 1 <= width) {
186 const cols = stationColumns(n, width)
187 return { line: stationRow(rows, cols, anim), numbers: numberRow(rows, cols, width, current, 0) }
188 }
189 // ventana alrededor de la actual (o de la primera pendiente) con ‹N y N›, a todo el ancho
190 let focus = current
191 if (focus < 0) focus = rows.findIndex(r => r.state === 'pending')
192 if (focus < 0) focus = n - 1
193 for (let k = n - 1; k >= 1; k--) {
194 const start = Math.max(0, Math.min(focus - Math.floor((k - 1) / 2), n - k))
195 const ha = n - start - k
196 const left = start > 0 ? `‹${start} ` : ''
197 const right = ha > 0 ? ` ${ha}›` : ''
198 const inner = width - left.length - right.length
199 if ((k - 1) * MIN_STEP + 1 <= inner || k === 1) {
200 const visible = rows.slice(start, start + k)
201 const cols = stationColumns(k, Math.max(1, inner))
202 const line: BandRow = []
203 if (left) line.push({ text: left, dim: true })
204 line.push(...stationRow(visible, cols, anim))
205 if (right) line.push({ text: right, dim: true })
206 return { line, numbers: numberRow(visible, cols, Math.max(1, inner), focus - start, left.length) }
207 }
208 }
209 const cols = stationColumns(n, width)
210 return { line: stationRow(rows, cols, anim), numbers: numberRow(rows, cols, width, current, 0) }
211}
212
213// ---------- franja ----------
214
215const isReview = (d: string) => /^\s*(review|revis)/i.test(d)
216
217function agentRow(prefix: string, a: BandAgent, now: number, width: number, tick: number): BandRow {
218 const icon = isReview(a.description) ? '🔍' : '⚙'
219 const spin = SPINNER[Math.abs(tick) % SPINNER.length]!
220 const model = modelName(a.model)
221 const time = elapsed(now - (a.startedAt ?? now))
222 const fixed = cellWidth(prefix) + 1 + 1 + cellWidth(icon) + 1 + cellWidth(` · ${model} · ${time}`)
223 const desc = truncate(a.description, Math.max(1, width - fixed))
224 return [
225 { text: prefix, dim: true },
226 { text: spin, color: CURRENT },
227 { text: ` ${icon} ${desc}` },
228 { text: ' · ', dim: true },
229 { text: model, color: SOFT },
230 { text: ` · ${time}`, dim: true },
231 ]
232}
233
234function pctColor(pct: number): string {
235 return pct >= 100 ? DONE : pct >= 34 ? CURRENT : PENDING
236}
237
238const BAR_CELLS = 8
239
240/** Barra de 8 celdas y «hechas/total · %» (parte derecha de la cabecera). */
241function progressRight(done: number, total: number): BandRow {
242 const pct = total > 0 ? Math.round((done / total) * 100) : 0
243 const filled = total > 0 ? Math.round((done / total) * BAR_CELLS) : 0
244 return [
245 { text: '█'.repeat(filled), color: DONE },
246 { text: '░'.repeat(BAR_CELLS - filled), dim: true },
247 { text: ` ${done}/${total} · `, dim: true },
248 { text: `${pct}%`, color: pctColor(pct), bold: true },
249 ]
250}
251
252/** Nombre de la cabecera: con `live`, una ventana de 3 letras en `soft` lo recorre (función pura del tick). */
253function nameSegs(text: string, tick: number, live: boolean): Seg[] {
254 if (!live) return [{ text, color: ACCENT, bold: true }]
255 const chars = [...text]
256 const p = Math.abs(Math.floor(tick)) % (chars.length + 8)
257 return [
258 { text: chars.slice(0, p).join(''), color: ACCENT, bold: true },
259 { text: chars.slice(p, p + 3).join(''), color: SOFT, bold: true },
260 { text: chars.slice(p + 3).join(''), color: ACCENT, bold: true },
261 ].filter(sg => sg.text)
262}
263
264type HeaderOpts = {
265 title: string
266 repo: RepoInfo | null | undefined
267 right: BandRow
268 complete: boolean
269 width: number
270 tick: number
271 live: boolean
272}
273
274function header({ title, repo, right, complete, width, tick, live }: HeaderOpts): BandRow {
275 const rw = rowWidth(right)
276 const badge = complete ? ' ✓ completado' : ''
277 const room = Math.max(1, width - rw - 3)
278 let leftSegs: BandRow
279 if (repo) {
280 // «◆ worktree ⎇ rama»: se recorta primero la rama y luego el worktree
281 const avail = Math.max(2, room - cellWidth('◆ ') - cellWidth(' ⎇ '))
282 const branch = truncate(repo.branch, Math.max(1, avail - cellWidth(repo.worktree)))
283 const worktree = truncate(repo.worktree, Math.max(1, avail - cellWidth(branch)))
284 leftSegs = [
285 { text: '◆ ', color: ACCENT },
286 ...nameSegs(worktree, tick, live),
287 { text: ' ⎇ ', color: SOFT },
288 { text: branch, color: SOFT },
289 ]
290 const showBadge = complete && rowWidth(leftSegs) + cellWidth(badge) <= room
291 if (showBadge) leftSegs.push({ text: badge, color: DONE, bold: true })
292 const tail = ` ${title}`
293 if (title && rowWidth(leftSegs) + cellWidth(tail) <= room) leftSegs.push({ text: tail, dim: true })
294 } else {
295 const showBadge = complete && cellWidth(title) + cellWidth(badge) <= room
296 const left = truncate(title, showBadge ? room - cellWidth(badge) : room)
297 leftSegs = [
298 { text: '◆ ', color: ACCENT },
299 ...nameSegs(left, tick, live),
300 ...(showBadge ? [{ text: badge, color: DONE, bold: true }] : []),
301 ]
302 }
303 const pad = Math.max(1, width - rowWidth(leftSegs) - rw)
304 return [...leftSegs, { text: ' '.repeat(pad) }, ...right]
305}
306
307function looseRow(t: SessionTask, doing: boolean, spin: string, width: number): BandRow {
308 const subject = truncate(t.subject, Math.max(1, width - 2))
309 return doing ? [{ text: `${spin} `, color: CURRENT, bold: true }, { text: subject, bold: true }] : [{ text: '○ ', color: PENDING }, { text: subject }]
310}
311
312type CoreKind = 'phases' | 'header' | 'line' | 'numbers' | 'active'
313
314export function metroBand(input: MetroInput): BandRow[] {
315 const { roadmap, agents, width, maxRows, tick, now, phase, milestone, flash, looseTasks } = input
316 if (maxRows <= 0 || width <= 0) return []
317 const hasPlan = roadmap.rows.length > 0
318 const current = phase?.current ?? null
319 const phaseMode = !hasPlan && current !== null
320 const pendingTasks = (looseTasks ?? []).filter(t => t.status !== 'completed')
321 const tasksMode = !hasPlan && current === null && pendingTasks.length > 0
322 const looseActive = roadmap.loose.find(t => t.status === 'in_progress')
323 if (!hasPlan && !phaseMode && !tasksMode && !looseActive) return []
324
325 const live = agents.length > 0 || milestoneStage(now, milestone ?? null) !== null
326 const spin = agents.length > 0 ? SPINNER[Math.abs(tick) % SPINNER.length]! : '▶'
327 const activeRow = roadmap.rows.find(r => r.state === 'active')
328
329 const core: { kind: CoreKind; row: BandRow }[] = []
330 const expired = phase?.closedAt != null && now - phase.closedAt > CLOSED_SHOW_MS
331 if (phase && current && !expired) {
332 const row = phaseRow({ phase, milestone: milestone ?? null, implDone: roadmap.done, implTotal: roadmap.total, width, tick, now, working: agents.length > 0 })
333 if (row.length > 0) core.push({ kind: 'phases', row })
334 }
335 let mine: BandAgent[] = []
336 let withoutTask: BandAgent[] = []
337 let otherTask: BandAgent[] = []
338 if (hasPlan) {
339 const flashN = flash && now - flash.at >= 0 && now - flash.at < FLASH_MS ? flash.n : undefined
340 const t = track(roadmap.rows, width, { tick, live, flashN })
341 const complete = roadmap.total > 0 && roadmap.done === roadmap.total
342 core.push({ kind: 'header', row: header({ title: input.title || 'Plan', repo: input.repo, right: progressRight(roadmap.done, roadmap.total), complete, width, tick, live }) })
343 core.push({ kind: 'line', row: t.line })
344 core.push({ kind: 'numbers', row: t.numbers })
345 if (activeRow) {
346 core.push({
347 kind: 'active',
348 row: [{ text: `${spin} `, color: CURRENT, bold: true }, { text: `${activeRow.n} ${truncate(activeRow.title, Math.max(1, width - 5))}`, bold: true }],
349 })
350 }
351 mine = activeRow ? agents.filter(a => a.taskN === activeRow.n) : []
352 withoutTask = agents.filter(a => a.taskN === undefined)
353 otherTask = agents.filter(a => a.taskN !== undefined && a.taskN !== activeRow?.n)
354 } else if (phaseMode && current) {
355 // sin título: el worktree basta; sin repo, «Desarrollo» (la fase ya va a la derecha)
356 const title = phase?.specTitle ?? (current === 'spec' ? 'Spec' : input.title || (input.repo ? '' : 'Desarrollo'))
357 core.push({ kind: 'header', row: header({ title, repo: input.repo, right: [{ text: PHASE_LABEL[current], color: SOFT }], complete: false, width, tick, live }) })
358 // sin plan: la task en curso o, si no hay, la primera pendiente
359 const doing = pendingTasks.find(t => t.status === 'in_progress') ?? looseActive
360 const shown = doing ?? pendingTasks[0]
361 if (shown) core.push({ kind: 'active', row: looseRow(shown, doing !== undefined, spin, width) })
362 mine = agents
363 } else if (tasksMode) {
364 const all = looseTasks ?? []
365 const done = all.filter(t => t.status === 'completed').length
366 core.push({ kind: 'header', row: header({ title: input.repo ? '' : 'Tasks', repo: input.repo, right: progressRight(done, all.length), complete: false, width, tick, live }) })
367 const doing = pendingTasks.find(t => t.status === 'in_progress')
368 const shown = doing ?? pendingTasks[0]!
369 core.push({ kind: 'active', row: looseRow(shown, doing !== undefined, spin, width) })
370 mine = agents
371 } else if (looseActive) {
372 core.push({
373 kind: 'active',
374 row: [{ text: `${spin} `, color: CURRENT, bold: true }, { text: truncate(looseActive.subject, Math.max(1, width - 2)), bold: true }],
375 })
376 mine = agents
377 }
378
379 // el alto manda: primero se sacrifican los números y la línea, luego las fases, nunca la cabecera
380 // en la celebración, la fila de la Task activa pasa a ser «✦ ¡hito! <fase completada> ✔» (la fila de fases no se toca)
381 if (milestone && milestoneStage(now, milestone)?.stage === 'celebrate' && (milestone.to === current || milestone.to === 'done')) {
382 const row: BandRow = [{ text: truncate(`✦ ¡hito! ${PHASE_LABEL[milestone.from]} ✔`, width), color: ACCENT, bold: true }]
383 const at = core.findIndex(c => c.kind === 'active')
384 if (at >= 0) core[at] = { kind: 'active', row } // sin fila activa no se añade nada: el hueco animado es la celebración
385 }
386
387 let budget = maxRows
388 let kept = core
389 for (const drop of ['numbers', 'line', 'phases', 'active'] as const) {
390 if (kept.length <= budget) break
391 kept = kept.filter(c => c.kind !== drop)
392 }
393 kept = kept.slice(0, budget)
394 budget -= kept.length
395
396 const out: BandRow[] = kept.map(c => c.row)
397 const total = mine.length + withoutTask.length + otherTask.length
398 if (budget > 0 && total > 0) {
399 let treeShow = mine.length > MAX_TREE ? MAX_TREE - 1 : mine.length
400 const extra = (otherTask.length > 0 ? 1 : 0) + (withoutTask.length > 0 ? 1 : 0)
401 const wanted = treeShow + (mine.length > treeShow ? 1 : 0) + extra
402 let summary = mine.length - treeShow
403 let tail: BandRow[] = []
404 if (wanted > budget) {
405 treeShow = Math.min(treeShow, budget - 1)
406 summary = total - treeShow
407 } else {
408 if (withoutTask.length > 0) tail.push([{ text: `⚙ ${withoutTask.length} más (sin Task)`, dim: true }])
409 if (otherTask.length > 0) tail.push([{ text: `⚙ ${otherTask.length} más (otra Task)`, dim: true }])
410 }
411 const rows: BandRow[] = []
412 mine.slice(0, treeShow).forEach((a, i) => {
413 const last = i === treeShow - 1 && summary <= 0
414 rows.push(agentRow(last ? ' └─ ' : ' ├─ ', a, now, width, tick))
415 })
416 if (summary > 0) rows.push([{ text: ` └─ +${summary} más`, dim: true }])
417 out.push(...rows, ...tail)
418 }
419 return out.slice(0, maxRows).map(r => fitRow(r, width))
420}
421hooks/lib/clawd.ts 50 lines1import type { Milestone } from '../../types'
2import { PALETTE } from './palette'
3import type { Seg } from './text'
4
5/** Chispa de Claude: frames del giro (todos miden 1 columna, para no mover la fila de fases). */
6export const SPARK = ['·', '✢', '✳', '✶', '✻', '✽']
7export const SPARK_IDLE = '✻'
8/** Hueco de la estación al celebrar: alterna estos frames (1 columna) con los colores de `sparkles`. */
9export const CELEBRATE_FRAMES = ['✽', '✻', '✶', '✳']
10
11export const WALK_MS = 1500
12export const CELEBRATE_MS = 1000
13export const MILESTONE_MS = 2500
14
15/** Frame de giro de la chispa según el tick. */
16export const sparkSpin = (tick: number): string => SPARK[Math.abs(Math.floor(tick)) % SPARK.length]!
17
18/** Chispa en reposo: fija (✻) y, trabajando, gira con el tick. */
19export function clawdIdle(tick: number, working: boolean): string {
20 return working ? sparkSpin(tick) : SPARK_IDLE
21}
22
23export type MilestoneStage = { stage: 'walk'; progress: number } | { stage: 'celebrate' }
24
25/** Tramo del hito en curso: camina (progreso 0..1), celebra, o null si ya pasó (o no hay hito). */
26export function milestoneStage(now: number, m: Milestone | null): MilestoneStage | null {
27 if (!m) return null
28 const dt = Math.max(0, now - m.at)
29 if (dt < WALK_MS) return { stage: 'walk', progress: dt / WALK_MS }
30 if (dt < MILESTONE_MS) return { stage: 'celebrate' }
31 return null
32}
33
34/** Frame de celebración del hueco y su color, según el tick. */
35export function celebrateSpark(tick: number): Seg {
36 const t = Math.abs(Math.floor(tick))
37 return { text: CELEBRATE_FRAMES[t % CELEBRATE_FRAMES.length]!, color: PALETTE.sparkles[t % PALETTE.sparkles.length] }
38}
39
40/** Dos chispas que alternan ✦/✧ y rotan de color con el tick. */
41export function sparkles(tick: number): [Seg, Seg] {
42 const t = Math.abs(Math.floor(tick))
43 const glyph = t % 2 === 0 ? '✦' : '✧'
44 const n = PALETTE.sparkles.length
45 return [
46 { text: glyph, color: PALETTE.sparkles[t % n] },
47 { text: glyph, color: PALETTE.sparkles[(t + 1) % n] },
48 ]
49}
50hooks/lib/phases.ts 155 lines1import type { Milestone, PhaseId, PhaseState } from '../../types'
2import { celebrateSpark, clawdIdle, milestoneStage, sparkSpin } from './clawd'
3import { PALETTE } from './palette'
4import { fitRow, pulseOn, rowWidth } from './text'
5import type { BandRow, Seg } from './text'
6
7// Lógica pura de las fases del desarrollo (Spec → Plan → Implementación → Revisión → Cierre).
8
9export const PHASES: PhaseId[] = ['spec', 'plan', 'impl', 'review', 'close']
10export const PHASE_LABEL: Record<PhaseId, string> = {
11 spec: 'Spec',
12 plan: 'Plan',
13 impl: 'Implementación',
14 review: 'Revisión',
15 close: 'Cierre',
16}
17export const PHASE_SHORT: Record<PhaseId, string> = { spec: 'Spec', plan: 'Plan', impl: 'Impl', review: 'Rev', close: 'Cierre' }
18
19export const EMPTY_PHASE: PhaseState = { current: null, closedAt: null, source: null, specTitle: null }
20
21export const SPEC_RE = /(^|\/)docs\/superpowers\/specs\/[^/]+\.md$/
22
23/** Fase que abre una skill (con o sin prefijo `superpowers:`); null si no es de ninguna. */
24export function phaseForSkill(skill: string): PhaseId | null {
25 if (skill.includes('receiving-code-review')) return null
26 if (skill.includes('brainstorming')) return 'spec'
27 if (skill.includes('writing-plans')) return 'plan'
28 if (skill.includes('subagent-driven-development') || skill.includes('executing-plans')) return 'impl'
29 if (skill.includes('requesting-code-review') || skill.includes('code-review')) return 'review'
30 if (skill.includes('finishing-a-development-branch')) return 'close'
31 return null
32}
33
34// `git [-C ruta]… merge` (no merge-base ni --abort) o `gh pr create|merge`.
35export const isCloseCommand = (cmd: string): boolean =>
36 (/\bgit\s+(?:-C\s+\S+\s+)*merge(?![-\w])/.test(cmd) && !/--abort\b/.test(cmd)) || /\bgh\s+pr\s+(create|merge)\b/.test(cmd)
37
38/**
39 * Solo avanza; devuelve el mismo objeto si no cambia. Revisión y cierre no abren ciclo (ni desde cero ni tras cerrar).
40 * Con `reopen` (lo abre una skill, no la lectura de un fichero), spec o plan desde implementación, revisión o
41 * cierre abren un ciclo nuevo.
42 */
43export function advance(s: PhaseState, to: PhaseId, reopen = false): PhaseState {
44 const opens = to !== 'review' && to !== 'close'
45 if (s.closedAt !== null) return opens ? { ...EMPTY_PHASE, current: to } : s
46 if (s.current === null) return opens ? { ...s, current: to } : s
47 // otro spec o plan con la implementación ya en marcha: ciclo nuevo en la misma rama, sin merge entre medias
48 if (reopen && (to === 'spec' || to === 'plan') && PHASES.indexOf(s.current) >= PHASES.indexOf('impl')) return { ...EMPTY_PHASE, current: to }
49 if (PHASES.indexOf(s.current) < PHASES.indexOf(to)) return { ...s, current: to }
50 return s
51}
52
53/** Texto del primer `# ` del documento, recortado. */
54export function specTitleFrom(text: string): string | null {
55 const m = /^# +(.+)$/m.exec(text)
56 const t = m?.[1]?.trim()
57 return t ? t : null
58}
59
60export type PhaseRowInput = {
61 phase: PhaseState
62 milestone: Milestone | null
63 implDone: number
64 implTotal: number
65 width: number
66 tick: number
67 now: number
68 working: boolean
69}
70
71const MIN_RUN = 2
72const GAP = 1 // espacio a cada lado del tramo
73
74/**
75 * Fila de fases: estaciones con su conector. La maqueta (etiquetas y longitud de los tramos) es la del estado
76 * de llegada y no depende de `tick`, de la etapa del hito ni de `working`: las animaciones se pintan encima
77 * (hueco de 1 columna, icono que sustituye un carácter del tramo) sin cambiar ningún ancho.
78 */
79export function phaseRow(input: PhaseRowInput): BandRow {
80 const { phase, milestone, implDone, implTotal, width, tick, now, working } = input
81 if (phase.current === null) return []
82 const cur = PHASES.indexOf(phase.current)
83 const stage = milestone && (milestone.to === phase.current || milestone.to === 'done') ? milestoneStage(now, milestone) : null
84 const toDone = !!milestone && milestone.to === 'done' && !!stage
85 const allDone = phase.closedAt !== null || toDone
86 const walk = !toDone && stage?.stage === 'walk' ? stage : null
87 const celebrate = toDone || stage?.stage === 'celebrate'
88 const digits = String(Math.max(0, implTotal)).length
89 const counter = implTotal > 0 ? ` ${String(implDone).padStart(digits)}/${implTotal}` : ''
90 const labelColor = pulseOn(tick) ? PALETTE.currentStrong : PALETTE.currentWeak
91 const spin = sparkSpin(tick)
92 // hueco de 1 columna: en blanco al caminar, destellos al celebrar, giro al trabajar, ✻ fijo en reposo
93 const hole: Seg = walk ? { text: ' ' } : celebrate ? celebrateSpark(tick) : { text: clawdIdle(tick, working), color: PALETTE.clawd }
94 const tail = (): BandRow => (allDone ? [{ text: ' ' }, hole, { text: ' 🎉' }] : [])
95
96 const isDone = (i: number) => allDone || i < cur
97
98 const station = (i: number, labels: Record<PhaseId, string>): BandRow => {
99 const id = PHASES[i]!
100 const label = labels[id]
101 if (isDone(i)) return [{ text: `${label} ✔`, color: PALETTE.done }]
102 if (i > cur) return [{ text: label, color: PALETTE.pending }]
103 const text = id === 'impl' ? `${label}${counter}` : label
104 return [hole, { text: ' ' }, { text, color: labelColor, bold: true }]
105 }
106
107 const run = (g: number, len: number): BandRow => {
108 if (walk && g === cur - 1) {
109 // el icono sustituye un carácter del tramo (no se inserta)
110 const pos = Math.round(walk.progress * (len - 1))
111 return [
112 { text: '━'.repeat(pos), color: PALETTE.done },
113 { text: spin, color: PALETTE.clawd },
114 { text: '┄'.repeat(len - pos - 1), color: PALETTE.pending },
115 ].filter(s => s.text)
116 }
117 return [isDone(g) ? { text: '━'.repeat(len), color: PALETTE.done } : { text: '┄'.repeat(len), color: PALETTE.pending }]
118 }
119
120 const build = (labels: Record<PhaseId, string>): BandRow | null => {
121 const stations = PHASES.map((_, i) => station(i, labels))
122 const end = tail()
123 const gaps = PHASES.length - 1
124 const fixed = stations.reduce((n, s) => n + rowWidth(s), 0) + rowWidth(end)
125 const spare = width - fixed - gaps * (GAP * 2 + MIN_RUN)
126 if (spare < 0) return null
127 const out: BandRow = []
128 stations.forEach((s, i) => {
129 out.push(...s)
130 if (i < gaps) {
131 const len = MIN_RUN + Math.floor(spare / gaps) + (i < spare % gaps ? 1 : 0)
132 out.push({ text: ' ' }, ...run(i, len), { text: ' ' })
133 }
134 })
135 return [...out, ...end]
136 }
137
138 for (const labels of [PHASE_LABEL, PHASE_SHORT]) {
139 const r = build(labels)
140 if (r) return r
141 }
142
143 // una sola estación: «<hueco> Etiqueta [n/m] · fase i/5»
144 const id = allDone ? 'close' : phase.current
145 const idx = PHASES.indexOf(id)
146 const label = PHASE_SHORT[id]
147 const phaseTag: Seg = { text: ` · fase ${idx + 1}/${PHASES.length}`, dim: true }
148 const head: BandRow = allDone
149 ? [{ text: `${label} ✔`, color: PALETTE.done }, ...tail()]
150 : [hole, { text: ' ' }, { text: label, color: labelColor, bold: true }]
151 const withCounter: BandRow = [...head, ...(!allDone && id === 'impl' && counter ? [{ text: counter, color: labelColor, bold: true }] : []), phaseTag]
152 if (rowWidth(withCounter) <= width) return withCounter
153 return fitRow([...head, phaseTag], width)
154}
155hooks/lib/palette.ts 15 lines1// Paleta violeta de la franja; `clawd` usa el color de tema (naranja).
2export const PALETTE = {
3 done: '#a78bfa',
4 currentStrong: '#e9d5ff',
5 currentWeak: '#7c3aed',
6 pending: '#5b4b8a',
7 flow: '#c084fc',
8 accent: '#8b5cf6',
9 soft: '#c4b5fd',
10 border: '#7c3aed',
11 flash: '#ffffff',
12 clawd: 'claude',
13 sparkles: ['#c084fc', '#e9d5ff', '#a78bfa'],
14}
15hooks/lib/summary.ts 23 lines1import type { Roadmap, RoadmapRow } from './taskMatcher'
2
3export function progressBar(done: number, total: number, width = 12): string {
4 const filled = total > 0 ? Math.round((done / total) * width) : 0
5 return '█'.repeat(filled) + '░'.repeat(width - filled)
6}
7
8export function visibleRows(
9 rows: RoadmapRow[],
10 room: number,
11): { rows: RoadmapRow[]; hiddenBefore: number; hiddenAfter: number } {
12 if (rows.length <= room) return { rows, hiddenBefore: 0, hiddenAfter: 0 }
13 let focus = rows.findIndex(x => x.state === 'active')
14 if (focus < 0) focus = rows.findIndex(x => x.state === 'pending')
15 if (focus < 0) focus = rows.length - 1
16 const start = Math.max(0, Math.min(focus - Math.floor((room - 1) / 2), rows.length - room))
17 return {
18 rows: rows.slice(start, start + room),
19 hiddenBefore: start,
20 hiddenAfter: rows.length - start - room,
21 }
22}
23hooks/lib/ledger.ts 27 lines1// Lógica pura del ledger de subagent-driven-development: <raíz>/.superpowers/sdd/<plan>/progress.md
2
3const PLAN_PATH_RE = /^(.*)\/docs\/superpowers\/plans\/([^/]+)\.md$/
4
5/** Ruta del ledger del plan; null si la ruta no tiene la forma `…/docs/superpowers/plans/<x>.md`. */
6export function ledgerPath(planAbs: string): string | null {
7 const m = PLAN_PATH_RE.exec(planAbs)
8 return m ? `${m[1]}/.superpowers/sdd/${m[2]}/progress.md` : null
9}
10
11/** Números N de las líneas `Task N: complete`, sin duplicados y en orden de aparición. */
12export function completedFromLedger(text: string): number[] {
13 const out: number[] = []
14 for (const line of text.split(/\r?\n/)) {
15 const m = /^Task (\d+): complete\b/.exec(line)
16 if (m) {
17 const n = Number(m[1])
18 if (!out.includes(n)) out.push(n)
19 }
20 }
21 return out
22}
23
24/** ¿Menciona el ledger este comando Bash o es esta ruta del ledger (Write/Edit)? */
25export const touchesLedgerCommand = (cmd: string): boolean => cmd.includes('progress.md')
26export const isLedgerFile = (p: string): boolean => /\/\.superpowers\/sdd\/[^/]+\/progress\.md$/.test(p)
27hooks/lib/state.ts 52 lines1// Helpers puros: el motor solo sigue `$` y los átomos dentro de register.tsx.
2
3export const PLAN_RE = /(^|\/)docs\/superpowers\/plans\/[^/]+\.md$/
4
5export const storeKey = (path: string) => `done:${path}`
6
7/** Ruta absoluta a partir del cwd (pura: `$` no puede cruzar imports). */
8export function absolutePath(cwd: string, path: string): string {
9 if (path.startsWith('/')) return path
10 return `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
11}
12
13/** Carpeta que contiene el plan (dirname puro). */
14export function planDir(abs: string): string {
15 const i = abs.lastIndexOf('/')
16 return i > 0 ? abs.slice(0, i) : '/'
17}
18
19/** Worktree (basename del toplevel) y rama; HEAD desacoplado -> `HEAD@<sha>`. Null si falta algo. */
20export function repoFromGit(top: string, branch: string, sha: string): { worktree: string; branch: string } | null {
21 const worktree = top.trim().replace(/\/+$/, '').split('/').pop() ?? ''
22 const name = branch.trim() || (sha.trim() ? `HEAD@${sha.trim()}` : '')
23 return worktree && name ? { worktree, branch: name } : null
24}
25
26// Rutas sin comillas, espacios, `$` ni separadores de shell; el `=` corta asignaciones (`P=docs/…`).
27const PLAN_IN_CMD = /([^\s'"=;|&<>()$]*?)docs\/superpowers\/plans\/([^\s'"=;|&<>()$/]+\.md)/g
28const LEDGER_IN_CMD = /([^\s'"=;|&<>()$]*?)\.superpowers\/sdd\/([^\s'"=;|&<>()$/]+)/g
29
30/** Planes que nombra un comando Bash: por su ruta o por la carpeta de su ledger SDD. Sin duplicados. */
31export function planRefsInCommand(cmd: string): string[] {
32 const out: string[] = []
33 const push = (p: string) => {
34 if (!out.includes(p)) out.push(p)
35 }
36 for (const m of cmd.matchAll(PLAN_IN_CMD)) push(`${m[1]}docs/superpowers/plans/${m[2]}`)
37 for (const m of cmd.matchAll(LEDGER_IN_CMD)) push(`${m[1]}docs/superpowers/plans/${m[2]}.md`)
38 return out
39}
40
41/** Directorio de un `cd` al principio del comando; null si no empieza así. */
42export function cdTarget(cmd: string): string | null {
43 const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/.exec(cmd)
44 return m ? (m[1] ?? m[2] ?? m[3] ?? null) : null
45}
46
47/** Raíz del repo que contiene `docs/superpowers/…`; null si la ruta no tiene esa forma. */
48export function repoRoot(abs: string | null): string | null {
49 const m = abs ? /^(.*)\/docs\/superpowers\//.exec(abs) : null
50 return m ? m[1]! || '/' : null
51}
52hooks/lib/text.ts 66 lines1// Primitivas de texto compartidas por la franja y las fases: puras, sin `$` ni estado.
2
3export type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
4export type BandRow = Seg[]
5
6// Puntos de 0x2300–0x2BFF con East Asian Width=W (presentación emoji); el resto de ese bloque mide 1.
7const WIDE_SYMBOLS = new Set<number>([0x231a, 0x231b, 0x25fd, 0x25fe, 0x2614, 0x2615, 0x267f, 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26fa, 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2757, 0x27b0, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55])
8const isWideSymbol = (cp: number) =>
9 WIDE_SYMBOLS.has(cp) || (cp >= 0x23e9 && cp <= 0x23ec) || cp === 0x23f0 || cp === 0x23f3 || (cp >= 0x2648 && cp <= 0x2653) || (cp >= 0x2753 && cp <= 0x2755) || (cp >= 0x2795 && cp <= 0x2797)
10
11/** Columnas que ocupa un texto, según East Asian Width (wcwidth): emojis y CJK 2; ✔ ✻ ⚙ y demás símbolos 1. */
12export function cellWidth(s: string): number {
13 let w = 0
14 for (const ch of s) {
15 const cp = ch.codePointAt(0) ?? 0
16 if (cp === 0xfe0f || cp === 0x200d) continue
17 const wide =
18 cp >= 0x1f000 ||
19 isWideSymbol(cp) ||
20 (cp >= 0x1100 && cp <= 0x115f) ||
21 (cp >= 0x2e80 && cp <= 0xa4cf) ||
22 (cp >= 0xac00 && cp <= 0xd7a3) ||
23 (cp >= 0xff00 && cp <= 0xff60)
24 w += wide ? 2 : 1
25 }
26 return w
27}
28
29export function truncate(s: string, max: number): string {
30 if (max <= 0) return ''
31 if (cellWidth(s) <= max) return s
32 let out = ''
33 let w = 0
34 for (const ch of s) {
35 const cw = cellWidth(ch)
36 if (w + cw > max - 1) break
37 out += ch
38 w += cw
39 }
40 return `${out}…`
41}
42
43export const rowWidth = (r: BandRow) => r.reduce((n, s) => n + cellWidth(s.text), 0)
44
45/** Recorta una fila a `max` columnas (último recurso: ninguna fila excede el ancho). */
46export function fitRow(r: BandRow, max: number): BandRow {
47 if (rowWidth(r) <= max) return r
48 const out: BandRow = []
49 let left = max
50 for (const s of r) {
51 if (left <= 0) break
52 const w = cellWidth(s.text)
53 if (w <= left) {
54 out.push(s)
55 left -= w
56 } else {
57 out.push({ ...s, text: truncate(s.text, left) })
58 left = 0
59 }
60 }
61 return out
62}
63
64/** Pulso de la estación actual: alterna cada 2 ticks (fuerte, tenue). */
65export const pulseOn = (tick: number) => Math.floor(Math.abs(tick) / 2) % 2 === 0
66types/index.d.ts 63 lines1export type PlanTask = { n: number; title: string; steps: number; stepsDone: number }
2export type Plan = { title: string; tasks: PlanTask[] }
3export type TaskStatus = 'pending' | 'in_progress' | 'completed'
4export type SessionTask = {
5 id: string
6 subject: string
7 status: TaskStatus
8 /** Plan activo cuando se creó la task (null: aún no había plan). */
9 planPath?: string | null
10}
11export type ActiveAgent = {
12 id: string
13 description: string
14 model: string
15 background: boolean
16 /** Id del subagente (resultado de agent.spawn); casa con $.agent.list(). */
17 agentId?: string
18 /** Instante de arranque (ms, `$.clock.now()`), para el tiempo transcurrido. */
19 startedAt?: number
20 /** Task del plan activo que menciona la descripción («… Task 8 …»). */
21 taskN?: number
22}
23
24export type PhaseId = 'spec' | 'plan' | 'impl' | 'review' | 'close'
25export type PhaseState = {
26 /** Fase en curso; null: no hay desarrollo en marcha. */
27 current: PhaseId | null
28 /** Instante (ms) en que se completó el Cierre; null si no. */
29 closedAt: number | null
30 /** Ruta absoluta del spec o plan que abrió el ciclo. */
31 source: string | null
32 /** Título del spec (primer `# `), para la cabecera antes del plan. */
33 specTitle: string | null
34}
35/** Paso de un hito: de una fase a la siguiente, o 'done' al completar el Cierre. */
36export type Milestone = { from: PhaseId; to: PhaseId | 'done'; at: number }
37/** Destello de la estación de una Task recién completada. */
38export type TaskFlash = { n: number; at: number }
39
40declare module 'claude-code' {
41 interface PluginState {
42 roadmap: {
43 tasks: SessionTask[]
44 todos: SessionTask[]
45 agents: ActiveAgent[]
46 planPath: string | null
47 pinned: boolean
48 plan: Plan | null
49 planError: string | null
50 doneStored: number[]
51 isOpen: boolean
52 storeOk: boolean
53 tick: number
54 bandHidden: boolean
55 phase: PhaseState
56 milestone: Milestone | null
57 flash: TaskFlash | null
58 /** Worktree y rama donde vive el plan activo (git -C <carpeta del plan>); null si no se pudo leer. */
59 repo: { worktree: string; branch: string } | null
60 }
61 }
62}
63