Dagyard en Claude Code: lo que te espera y lo que está para tomar sobre el prompt, el menú /dagyard, un aviso cuando algo nuevo te espera, el CLI dagyard en el…

Una banda sobre el prompt con lo que le toca a esta sesión, un menú para responderlo todo de corrido y un aviso cuando algo nuevo te espera. Todo lo que haces aquí se ve al instante en el cielo (la web), porque escribe en la misma API.
/goal en la sesión. Como todo lo que escribe («Hecha», «Soltarla»), pide confirmar y no tiene atajo; los atajos que quedan solo miran (s otra, m ver todo, o el cielo)./dagyard o «Ver todo»): todo lo anterior a la vez.

Sin clonar nada:
claude plugin marketplace add cofoundy/dagyard && claude plugin install dagyard@dagyard
Desde un clon del repo, en su raíz (queda leyendo esa carpeta: un git pull lo actualiza; en una sesión abierta, /reload-plugins):
claude plugin marketplace add "$PWD" && claude plugin install dagyard@dagyard
Queda instalado para todas tus sesiones. Para probarlo sin instalar: claude --plugin-dir "$PWD/packages/mod".
Quitarlo: claude plugin uninstall dagyard@dagyard && claude plugin marketplace remove dagyard.
Mientras el plugin está activo, dagyard está en el PATH de los comandos que corre el agente (bin/dagyard, el bundle de packages/cli, sin dependencias, Node ≥22). Se llama con el comando primero y las opciones después: dagyard msg pagos "listo el cobro" --project mi-proyecto.
skills/protocol/SKILL.md (dagyard:protocol) le enseña a cualquier agente a trabajar a la vista: arrancar su nodo, dejar un mensaje en cada hito, pedir lo que es de una persona con block, hacer nacer cada nodo de otro o de un objetivo con su porqué, anotar lo que pidió una persona con sus palabras y cerrar con motivo. Carga sola cuando el repo tiene .dagyard.json o cuando el pedido habla de Dagyard.
Un segundo plugin del mismo marketplace, dagyard-channel, lleva a la sesión la respuesta del dueño a tu bloqueante sin que el agente espere con dagyard wait, y le da la tool reply para dejar un mensaje en su nodo. Instalarlo es la decisión: corre un proceso MCP por sesión, así que solo lo instala quien lo usa.
claude plugin install dagyard-channel@dagyard
claude --dangerously-load-development-channels plugin:dagyard-channel@dagyard
El flag hace falta porque es un canal propio, fuera de la lista de canales aprobados (research preview); Claude Code pide confirmarlo al arrancar. Lee el proyecto igual que el CLI (entorno, .dagyard.json, ~/.config/dagyard). Detalle: packages/channel/README.md.
El primero que encuentre, en este orden:
DAGYARD_PROJECT (y DAGYARD_URL) en el entorno;.dagyard.json más cercano subiendo desde la carpeta de la sesión, p. ej. {"project": "dagyard", "url": "https://dagyard.cofoundy-dev.workers.dev"} (url es opcional);~/.config/dagyard/project (y url).Sin proyecto, el mod calla: ni banda ni llamadas; /dagyard dice cómo enlazar el repo. Así puede quedar instalado en todas las sesiones sin ensuciar los repos que no usan Dagyard. El servidor, si nada lo dice, es https://dagyard.cofoundy-dev.workers.dev.
Las claves salen de ~/.config/dagyard/agent-key (leer y tomar tareas) y owner-token (responder lo que te espera). Nunca se imprimen.
Habla inglés; español si el entorno de Claude Code lo pide (LC_ALL > LC_MESSAGES > LANG, con el mismo criterio que el CLI). Lo que escribe una persona (títulos, preguntas, opciones) no se traduce, y los atajos de teclado son los mismos en los dos idiomas.
claude plugin validate packages/mod # manifiesto y módulo, como los lee el motor
claude plugin test packages/mod # hooks/*.test.ts(x)
node scripts/plugin-bundle.mjs # tras cambiar packages/cli o packages/channel: regenera los bin/
Los bin/ de los dos plugins van commiteados (un plugin instalado desde git no corre build); el CI corre node scripts/plugin-bundle.mjs --check y falla si no son el build de hoy.
hooks/view.ts calcula la vista desde el snapshot (puro); hooks/register.tsx es todo lo que toca el motor: la banda (AbovePrompt), el menú (Pane dagyard-menu), el comando y el sondeo cada 10 s. Los tipos del motor los escribe Claude Code en .claude-plugin/types/ al cargar el mod (ignorados por git); con ellos, npx tsc -p packages/mod revisa los tipos.
hooks/register.tsx 596 lines1// Dagyard en Claude Code — una banda sobre el prompt con lo que le toca a esta sesión:
2// · lo que TE ESPERA (bloqueantes abiertos): una decisión o una revisión se resuelven desde aquí;
3// un acceso se da en el cielo (la clave no pasa por la terminal).
4// · lo que está PARA TOMAR (pendiente, sin equipo y con todo lo que necesita ya listo):
5// «Trabajar en esto» la arranca en el servidor y fija su /goal en esta sesión.
6// · el MENÚ (/dagyard o «Ver todo»): todo lo anterior a la vez, en un panel, para responderlo de corrido.
7// · un AVISO (toast) cuando aparece algo nuevo que te espera.
8// El proyecto sale del entorno, del .dagyard.json más cercano subiendo desde el cwd, o de
9// ~/.config/dagyard; sin ninguno el mod calla (instalado para todas las sesiones, no ensucia otros repos).
10// Todo cambio se ve en el cielo (la UI) en tiempo real, porque escribe en la misma API.
11// Habla inglés, o español si el entorno lo pide (LC_ALL > LC_MESSAGES > LANG, #73): ver ./i18n.
12import type { Register } from 'claude-code'
13
14import { langFromEnv, REQUEST_CHANGES, text } from './i18n'
15import type { Strings } from './i18n'
16
17import { skyLink, viewOf } from './view'
18import type { Item, Node, Snapshot, View } from './view'
19
20const URL_DEFAULT = 'https://dagyard.cofoundy-dev.workers.dev'
21const LINK_FILE = '.dagyard.json'
22const POLL_MS = 10_000 // un bloqueante nuevo avisa en ≤20 s aun con la red lenta
23const TEAM = 'Claude Code'
24const MENU = 'dagyard-menu'
25
26const AMBER = '#ffb547'
27const BLUE = '#9fd3ff'
28const MUTED = '#6b7a93'
29
30let base = URL_DEFAULT
31let project: string | null = null
32let seen: Set<string> | null = null // los bloqueantes abiertos ya vistos; null hasta la primera carga
33let view: View | null = null
34let cursor = 0
35let working: Node | null = null
36let hidden = false
37let busy = false
38let lastError = ''
39let loading = false
40let commenting: string | null = null // la revisión a la que se le está escribiendo «qué cambiarías»
41// La opción elegida que espera «Sí, elegir»: resolver no se deshace (el servidor da 409), así que ninguna
42// opción lleva hotkey ni se manda de un toque; un dígito en el prompt vacío arma los de la banda (#89).
43let picked: { id: string; choice: number } | null = null
44// Tomar, terminar o soltar una tarea también escriben (y tomar fija un /goal en la sesión): pasan por la
45// misma confirmación, sin hotkey. Medido 2026-10-04 18:32: un «t» suelto tomó gh-89 desde la banda.
46type Act = 'take' | 'done' | 'release'
47let asking: { act: Act; id: string } | null = null
48let s: Strings = text('en') // los textos en el idioma del entorno; se fija en session.start
49
50async function home($: any): Promise<string> {
51 const fromEnv = await $.env.get('HOME')
52 if (fromEnv) return fromEnv
53 const out = await $.process.run(['printenv', 'HOME']).catch(() => null)
54 return out?.stdout?.trim() ?? ''
55}
56
57async function key($: any, file: string): Promise<string | null> {
58 const dir = await home($)
59 try {
60 return (await $.fs.read(`${dir}/.config/dagyard/${file}`)).trim() || null
61 } catch {
62 return null
63 }
64}
65
66/** El .dagyard.json más cercano subiendo desde `cwd` (el que escribe el repo, como el CLI). */
67async function linkOf($: any, cwd: string): Promise<{ project?: string; url?: string }> {
68 let dir = cwd.replace(/\/+$/, '')
69 for (;;) {
70 try {
71 const raw = JSON.parse(await $.fs.read(`${dir || ''}/${LINK_FILE}`))
72 return {
73 project: typeof raw?.project === 'string' ? raw.project.trim() || undefined : undefined,
74 url: typeof raw?.url === 'string' ? raw.url.trim() || undefined : undefined,
75 }
76 } catch {
77 // no está aquí (o no es JSON): sube
78 }
79 if (!dir) return {}
80 dir = dir.slice(0, dir.lastIndexOf('/'))
81 }
82}
83
84/** Avisa lo que te espera y no estaba la vez anterior; la primera carga solo toma nota. */
85function notice($: any, v: View): void {
86 const open = new Set(v.waiting.map(it => (it.kind === 'waiting' ? it.blocker.id : '')))
87 const fresh = seen ? v.waiting.filter(it => it.kind === 'waiting' && !seen!.has(it.blocker.id)) : []
88 seen = open
89 const first = fresh[0]
90 if (fresh.length === 1 && first?.kind === 'waiting') {
91 const { node, blocker: b } = first
92 $.ui.toast(s.needsYou(node.title, b.kind, b.question))
93 } else if (fresh.length > 1) {
94 $.ui.toast(s.manyNew(fresh.length))
95 }
96}
97
98/**
99 * Esperas base entre intentos ante un `503 unavailable` o un corte de red (≈1,2 s: lo que dura el corte de
100 * un deploy), con jitter de ×0,5 a ×1,5. Las mismas que `packages/cli/src/api.ts` (el mod no lo importa:
101 * corre con `$.http.fetch`, no se empaqueta).
102 */
103const RETRY_DELAYS_MS = [300, 900]
104const jitter = (ms: number) => Math.round(ms * (0.5 + Math.random()))
105
106/**
107 * ¿Repetir es seguro y útil? Un corte de red o un `503` del Worker que no escribió (`unavailable`) o que no
108 * llegó al Worker (sin cuerpo de error). `overloaded` (reintentar empeora) y `uncertain` (la escritura pudo
109 * quedar sin su clave) no se repiten.
110 */
111function transient(res: { status: number; text: string } | null): boolean {
112 if (!res) return true
113 if (res.status !== 503) return false
114 try {
115 const code = JSON.parse(res.text)?.error?.code
116 return !code || code === 'unavailable'
117 } catch {
118 return true
119 }
120}
121
122/**
123 * Una invocación de la API. Toda escritura lleva una `Idempotency-Key` nueva, la misma en cada reintento:
124 * así reintentar un `503 unavailable` o un corte de red (la escritura pudo quedar) nunca la duplica.
125 */
126async function api($: any, path: string, method: string, body: unknown, owner: boolean): Promise<any> {
127 const token = await key($, owner ? 'owner-token' : 'agent-key')
128 if (!token) throw new Error(s.missingKey)
129 const headers: Record<string, string> = { authorization: `Bearer ${token}`, 'user-agent': 'dagyard-mod/0.2' }
130 if (body !== undefined) headers['content-type'] = 'application/json'
131 if (method !== 'GET' && method !== 'HEAD') headers['idempotency-key'] = crypto.randomUUID()
132 const init = { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }
133 for (let attempt = 0; ; attempt++) {
134 let res: { status: number; ok: boolean; text: string } | null = null
135 let cut: unknown = null
136 try {
137 res = await $.http.fetch(`${base}${path}`, init)
138 } catch (err) {
139 cut = err
140 }
141 if (res?.ok) return res.text ? JSON.parse(res.text) : null
142 if (!transient(res) || attempt >= RETRY_DELAYS_MS.length) {
143 if (res) throw new Error(String(res.status))
144 throw cut instanceof Error ? cut : new Error(String(cut))
145 }
146 await $.clock.sleep(jitter(RETRY_DELAYS_MS[attempt]!))
147 }
148}
149
150async function refresh($: any): Promise<void> {
151 if (!project) return
152 loading = true
153 try {
154 const snap = (await api($, `/api/projects/${project}`, 'GET', undefined, false)) as Snapshot
155 view = viewOf(snap)
156 notice($, view)
157 const p = picked
158 if (p && !view.waiting.some(it => it.kind === 'waiting' && it.blocker.id === p.id)) picked = null
159 const w = working
160 if (w) working = snap.nodes.find(n => n.id === w.id && n.status === 'working') ?? null
161 lastError = ''
162 } catch (err) {
163 view = null
164 lastError = (err as Error)?.message ?? String(err)
165 }
166 loading = false
167 $.ui.invalidate('ui.render')
168}
169
170async function settle($: any, err: unknown): Promise<void> {
171 if (err) $.ui.toast(s.noAnswer((err as Error).message))
172 busy = false
173 await refresh($)
174}
175
176async function resolve($: any, it: Extract<Item, { kind: 'waiting' }>, choice: number, note: string): Promise<void> {
177 if (busy) return
178 busy = true
179 $.ui.invalidate('ui.render')
180 let err: unknown = null
181 try {
182 await api($, `/api/projects/${project}/blockers/${it.blocker.id}/resolve`, 'POST', { choice, note: note || 'Desde Claude Code' }, true)
183 commenting = null
184 picked = null
185 $.ui.toast(s.resolved(it.node.title, it.blocker.options[choice] ?? ''))
186 } catch (e) {
187 err = e
188 }
189 await settle($, err)
190}
191
192async function take($: any, node: Node): Promise<void> {
193 if (busy) return
194 busy = true
195 $.ui.invalidate('ui.render')
196 let err: unknown = null
197 try {
198 await api($, `/api/projects/${project}/nodes/${node.id}`, 'PATCH', { status: 'working', team: TEAM, progress: 0.05 }, false)
199 working = node
200 $.ui.toast(s.took(node.title))
201 } catch (e) {
202 err = e
203 }
204 await settle($, err)
205 if (!err) await $.command.run({ command: 'goal', args: node.goal ?? s.finish(node.title) })
206}
207
208async function patchWorking($: any, node: Node, body: unknown, toast: string): Promise<void> {
209 if (busy) return
210 busy = true
211 $.ui.invalidate('ui.render')
212 let err: unknown = null
213 try {
214 await api($, `/api/projects/${project}/nodes/${node.id}`, 'PATCH', body, false)
215 working = null
216 if (toast) $.ui.toast(toast)
217 } catch (e) {
218 err = e
219 }
220 await settle($, err)
221}
222
223/** Abre el cielo en el proyecto de este repo y, si se pasa, directo en esa tarea con su ficha. */
224async function openSky($: any, node?: string): Promise<void> {
225 await $.process.run(['open', skyLink(base, project, node)]).catch(() => undefined)
226}
227
228// Primero abre y después carga: el motor coloca el panel a cualquier ancho solo si el clic de la
229// persona está detrás; una espera de red antes del open lo vuelve «no pedido» (y bajo 144 columnas espera).
230async function openMenu($: any): Promise<void> {
231 const opened = await $.ui.open({ id: MENU, title: 'Dagyard', focus: true, closeOnEscape: true })
232 if (!opened.isPlaced) $.ui.toast(s.menuDoesNotFit(opened.reason))
233 void refresh($)
234}
235
236async function comment($: any, blockerId: string | null): Promise<void> {
237 commenting = blockerId
238 picked = null
239 $.ui.invalidate('ui.render')
240}
241
242/** Primer paso de tomar, terminar o soltar: pregunta; el segundo es el «Sí, …». `null` cancela. */
243async function ask($: any, a: { act: Act; id: string } | null): Promise<void> {
244 asking = a
245 $.ui.invalidate('ui.render')
246}
247
248/** Lo que hace cada acción confirmada sobre su tarea. */
249function act($: any, a: Act, node: Node): void {
250 asking = null
251 if (a === 'take') void take($, node)
252 else if (a === 'done') void patchWorking($, node, { status: 'done' }, s.isDone(node.title))
253 else void patchWorking($, node, { status: 'pending', team: null, progress: 0 }, '')
254}
255
256const question = (a: Act, n: Node) => (a === 'take' ? s.confirmTake(n.title) : a === 'done' ? s.confirmDone(n.title) : s.confirmRelease(n.title))
257const yes = (a: Act) => (a === 'take' ? s.yesTake : a === 'done' ? s.yesDone : s.yesRelease)
258
259/** Primer paso de elegir: marca la opción; el segundo es «Sí, elegir». `null` cancela. */
260async function pick($: any, choice: { id: string; choice: number } | null): Promise<void> {
261 picked = choice
262 $.ui.invalidate('ui.render')
263}
264
265async function toggleBand($: any): Promise<void> {
266 hidden = !hidden
267 $.ui.invalidate('ui.render')
268}
269
270async function skip($: any): Promise<void> {
271 cursor += 1
272 picked = null
273 asking = null
274 $.ui.invalidate('ui.render')
275}
276
277export const register: Register = on => {
278 on('session.start', async ($, e, next) => {
279 const link = await linkOf($, e.cwd)
280 base = ((await $.env.get('DAGYARD_URL')) || link.url || (await key($, 'url')) || URL_DEFAULT).replace(/\/+$/, '')
281 project = (await $.env.get('DAGYARD_PROJECT')) || link.project || (await key($, 'project')) || null
282 s = text(langFromEnv({ LC_ALL: await $.env.get('LC_ALL'), LC_MESSAGES: await $.env.get('LC_MESSAGES'), LANG: await $.env.get('LANG') }))
283 view = null
284 seen = null
285 // Si el motor rechaza /dagyard (otra pieza ya ocupa el nombre), la banda y el aviso siguen: el menú
286 // también se abre con «Ver todo».
287 await $.command.register({ name: 'dagyard', description: s.commandDescription }).catch(() => undefined)
288 if (project) {
289 $.clock.every(POLL_MS, () => void refresh($))
290 void refresh($)
291 }
292 return next(e)
293 })
294
295 on('command.run', { command: 'dagyard' }, async $ => {
296 if (!project)
297 return {
298 text: s.notLinked(LINK_FILE),
299 }
300 await openMenu($)
301 const v = view
302 return {
303 text: v
304 ? s.summary(v.project, v.waiting.length, v.startable.length)
305 : `${s.noAnswer(lastError || s.noDetail)} · ${base}/api/projects/${project}`,
306 }
307 })
308
309 on('turn.complete', async ($, e, next) => {
310 const done = await next(e)
311 await refresh($)
312 return done
313 })
314
315 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316 const below = await next(e)
317 const v = view
318 if (!project || e.props.hasSurvey || e.props.isWorking || hidden) return below
319 if (!v) {
320 if (!loading && !lastError) void refresh($)
321 const { Box: B, Text: T } = $.ui.resolve(e)
322 return (
323 <B flexDirection="column">
324 <T color={MUTED}>{s.offline(base, lastError || s.loading)}</T>
325 {below}
326 </B>
327 )
328 }
329
330 const { Box, Button, Text } = $.ui.resolve(e)
331 // la pregunta de una acción que escribe y su [Cancelar][Sí, …]; Cancelar primero: un Enter de más cancela
332 const confirmRow = (prefix: string, a: Act, n: Node) => (
333 <Box flexDirection="column">
334 <Text wrap="truncate-end">
335 <Text bold>{question(a, n)}</Text>
336 </Text>
337 <Box flexWrap="wrap">
338 <Button key={`${prefix}-cancel`} label={s.cancel} dimColor onPress={() => void ask($, null)} />
339 <Button key={`${prefix}-confirm`} label={yes(a)} variant="primary" onPress={() => act($, a, n)} />
340 </Box>
341 </Box>
342 )
343 const list: Item[] = [...v.waiting, ...v.startable]
344
345 const head = (
346 <Text wrap="truncate-end">
347 <Text color={BLUE}>◆ dagyard </Text>
348 <Text bold>{v.project}</Text>
349 <Text color={MUTED}>{s.progress(v.done, v.total, v.now)} · </Text>
350 <Text color={v.waiting.length ? AMBER : MUTED}>{s.waitingCount(v.waiting.length)}</Text>
351 <Text color={MUTED}> · </Text>
352 <Text color={v.startable.length ? BLUE : MUTED}>{s.startableCount(v.startable.length)}</Text>
353 </Text>
354 )
355
356 let body = null
357 const w = working
358 if (busy) {
359 body = <Text color={MUTED}>…</Text>
360 } else if (w) {
361 body = (
362 <Box flexDirection="column">
363 <Text wrap="truncate-end">
364 <Text color={BLUE}>{s.workingOn}</Text>
365 <Text bold>{w.title}</Text>
366 </Text>
367 {asking && asking.id === w.id && asking.act !== 'take' ? (
368 confirmRow('dy', asking.act, w)
369 ) : (
370 <Box flexWrap="wrap">
371 <Button key="dy-done" label={s.done} variant="primary" onPress={() => void ask($, { act: 'done', id: w.id })} />
372 <Button key="dy-release" label={s.release} onPress={() => void ask($, { act: 'release', id: w.id })} />
373 <Button key="dy-open" label={s.openSky} hotkey="o" dimColor onPress={() => void openSky($, w.id)} />
374 </Box>
375 )}
376 </Box>
377 )
378 } else if (list.length > 0) {
379 const i = cursor % list.length
380 const it = list[i]!
381 const more =
382 list.length > 1 ? (
383 <Button key="dy-next" label={s.other(i + 1, list.length)} hotkey="s" dimColor onPress={() => void skip($)} />
384 ) : null
385 if (it.kind === 'waiting') {
386 const b = it.blocker
387 const label = s.waitingLabel(b.kind)
388 const p = picked && picked.id === b.id && b.kind !== 'access' ? picked : null
389 // las opciones no llevan hotkey: elegir es marcar y después «Sí, elegir» (#89)
390 const buttons =
391 b.kind === 'access'
392 ? [<Button key="dy-sky" label={s.grantInSky} hotkey="o" variant="primary" onPress={() => void openSky($, it.node.id)} />]
393 : b.options.slice(0, 3).map((opt, k) => (
394 <Button key={`dy-opt-${k}`} label={opt} variant={k === 0 ? 'primary' : undefined} onPress={() => void pick($, { id: b.id, choice: k })} />
395 ))
396 body = (
397 <Box flexDirection="column">
398 <Text wrap="truncate-end">
399 <Text color={AMBER}>{label} · </Text>
400 <Text bold>{it.node.title}</Text>
401 <Text color={MUTED}> — {b.kind === 'access' ? b.accessLabel ?? b.question : b.question}</Text>
402 </Text>
403 {p ? (
404 <Box flexDirection="column">
405 <Text wrap="truncate-end">
406 <Text bold>{s.confirmPick(b.options[p.choice] ?? '')}</Text>
407 <Text color={MUTED}> {s.cannotUndo}</Text>
408 </Text>
409 {/* Cancelar primero: un Enter de más sobre el mismo lugar cancela en vez de resolver */}
410 <Box flexWrap="wrap">
411 <Button key="dy-cancel" label={s.cancel} dimColor onPress={() => void pick($, null)} />
412 <Button key="dy-confirm" label={s.yesChoose} variant="primary" onPress={() => void resolve($, it, p.choice, '')} />
413 </Box>
414 </Box>
415 ) : (
416 <Box flexWrap="wrap">
417 {buttons}
418 {more}
419 <Button key="dy-menu" label={s.seeAll} hotkey="m" dimColor onPress={() => void openMenu($)} />
420 </Box>
421 )}
422 </Box>
423 )
424 } else {
425 const n = it.node
426 body = (
427 <Box flexDirection="column">
428 <Text wrap="truncate-end">
429 <Text color={BLUE}>{s.readyToTake}</Text>
430 <Text bold>{n.title}</Text>
431 {n.goal ? <Text color={MUTED}> — /goal {n.goal}</Text> : null}
432 </Text>
433 {asking && asking.id === n.id && asking.act === 'take' ? (
434 confirmRow('dy', 'take', n)
435 ) : (
436 <Box flexWrap="wrap">
437 <Button key="dy-take" label={s.workOnThis} variant="primary" onPress={() => void ask($, { act: 'take', id: n.id })} />
438 {more}
439 <Button key="dy-menu" label={s.seeAll} hotkey="m" dimColor onPress={() => void openMenu($)} />
440 </Box>
441 )}
442 </Box>
443 )
444 }
445 }
446
447 return (
448 <Box flexDirection="column">
449 {head}
450 {body}
451 {below}
452 </Box>
453 )
454 })
455
456 on('ui.render', { component: 'Pane', requestId: MENU }, async ($, e) => {
457 const els = $.ui.resolve(e)
458 const { Box, Button, Text } = els
459 // la pregunta de una acción que escribe y su [Cancelar][Sí, …]; Cancelar primero: un Enter de más cancela
460 const confirmRow = (prefix: string, a: Act, n: Node) => (
461 <Box flexDirection="column">
462 <Text wrap="wrap">
463 <Text bold>{question(a, n)}</Text>
464 </Text>
465 <Box flexWrap="wrap">
466 <Button key={`${prefix}-cancel`} label={s.cancel} dimColor onPress={() => void ask($, null)} />
467 <Button key={`${prefix}-confirm`} label={yes(a)} variant="primary" onPress={() => act($, a, n)} />
468 </Box>
469 </Box>
470 )
471 // «Qué cambiarías» se escribe donde la superficie tiene campo de texto (el celular no lo dibuja);
472 // donde no, «Pedir cambios» se manda sin nota en vez de dejar un «Cancelar» solo
473 const Input = e.surface !== 'mobile' && 'Input' in els ? els.Input : null
474 const v = view
475 if (!v) {
476 return (
477 <Box flexDirection="column">
478 <Text color={MUTED}>{s.offlineMenu(base, lastError || s.loading)}</Text>
479 <Button key="mn-retry" label={s.retry} onPress={() => void refresh($)} />
480 </Box>
481 )
482 }
483 const w = working
484 const rows: any[] = []
485
486 rows.push(
487 <Text key="mn-head" wrap="truncate-end">
488 <Text bold>{v.project}</Text>
489 <Text color={MUTED}>{s.progress(v.done, v.total, v.now)}</Text>
490 </Text>,
491 )
492
493 if (w) {
494 rows.push(<Text key="mn-w-sec" color={BLUE}>{'\n'}{s.working}</Text>)
495 rows.push(
496 <Box key="mn-w" flexDirection="column">
497 <Text bold>{w.title}</Text>
498 {asking && asking.id === w.id && asking.act !== 'take' ? (
499 confirmRow('mn-w', asking.act, w)
500 ) : (
501 <Box flexWrap="wrap">
502 <Button key="mn-w-done" label={s.done} variant="primary" onPress={() => void ask($, { act: 'done', id: w.id })} />
503 <Button key="mn-w-release" label={s.release} onPress={() => void ask($, { act: 'release', id: w.id })} />
504 </Box>
505 )}
506 </Box>,
507 )
508 }
509
510 rows.push(<Text key="mn-wait-sec" color={v.waiting.length ? AMBER : MUTED}>{'\n'}{s.waitingSection(v.waiting.length)}</Text>)
511 if (v.waiting.length === 0) rows.push(<Text key="mn-wait-none" color={MUTED}>{s.nothingWaiting}</Text>)
512 v.waiting.forEach(it => {
513 if (it.kind !== 'waiting') return
514 const b = it.blocker
515 const kind = s.kindName(b.kind)
516 const id = b.id
517 let actions: any
518 if (b.kind === 'access') {
519 actions = <Button key={`mn-${id}-sky`} label={s.grantInSky} variant="primary" onPress={() => void openSky($, it.node.id)} />
520 } else if (commenting === id && Input) {
521 const k = b.options.findIndex(o => REQUEST_CHANGES.test(o))
522 actions = (
523 <Box flexDirection="column">
524 <Input key={`mn-${id}-note`} label={s.whatWouldYouChange} placeholder={s.oneLine} submitLabel={s.send} autoFocus onSubmit={(value: string) => void resolve($, it, k < 0 ? b.options.length - 1 : k, value)} />
525 <Button key={`mn-${id}-cancel`} label={s.cancel} dimColor onPress={() => void comment($, null)} />
526 </Box>
527 )
528 } else if (picked && picked.id === id) {
529 const choice = picked.choice
530 actions = (
531 <Box flexDirection="column">
532 <Text wrap="wrap">
533 <Text bold>{s.confirmPick(b.options[choice] ?? '')}</Text>
534 <Text color={MUTED}> {s.cannotUndo}</Text>
535 </Text>
536 <Box flexWrap="wrap">
537 <Button key={`mn-${id}-cancel`} label={s.cancel} dimColor onPress={() => void pick($, null)} />
538 <Button key={`mn-${id}-confirm`} label={s.yesChoose} variant="primary" onPress={() => void resolve($, it, choice, '')} />
539 </Box>
540 </Box>
541 )
542 } else {
543 // «Pedir cambios» ya son dos pasos (abrir el campo y enviar); el resto marca y pide «Sí, elegir» (#89)
544 actions = (
545 <Box flexWrap="wrap">
546 {b.options.map((opt, k) =>
547 b.kind === 'review' && Input && REQUEST_CHANGES.test(opt) ? (
548 <Button key={`mn-${id}-${k}`} label={opt} onPress={() => void comment($, id)} />
549 ) : (
550 <Button key={`mn-${id}-${k}`} label={opt} variant={k === 0 ? 'primary' : undefined} onPress={() => void pick($, { id, choice: k })} />
551 ),
552 )}
553 </Box>
554 )
555 }
556 rows.push(
557 <Box key={`mn-${id}`} flexDirection="column">
558 <Text wrap="wrap">
559 <Text color={AMBER}>{kind} · </Text>
560 <Text bold>{it.node.title}</Text>
561 </Text>
562 <Text wrap="wrap" color={MUTED}>{b.kind === 'access' ? `${b.question} (${b.accessLabel ?? s.access})` : b.question}</Text>
563 {actions}
564 </Box>,
565 )
566 })
567
568 rows.push(<Text key="mn-take-sec" color={v.startable.length ? BLUE : MUTED}>{'\n'}{s.startableSection(v.startable.length)}</Text>)
569 if (v.startable.length === 0) rows.push(<Text key="mn-take-none" color={MUTED}>{s.nothingStartable}</Text>)
570 v.startable.forEach(it => {
571 const n = it.node
572 rows.push(
573 <Box key={`mn-t-${n.id}`} flexDirection="column">
574 <Text bold>{n.title}</Text>
575 {n.goal ? <Text wrap="wrap" color={MUTED}>/goal {n.goal}</Text> : null}
576 {asking && asking.id === n.id && asking.act === 'take' ? (
577 confirmRow(`mn-t-${n.id}`, 'take', n)
578 ) : (
579 <Button key={`mn-t-${n.id}-take`} label={s.workOnThis} variant="primary" onPress={() => void ask($, { act: 'take', id: n.id })} />
580 )}
581 </Box>,
582 )
583 })
584
585 rows.push(
586 <Box key="mn-foot">
587 <Text>{'\n'}</Text>
588 <Button key="mn-sky" label={s.openSky} dimColor onPress={() => void openSky($, w?.id)} />
589 <Button key="mn-band" label={hidden ? s.showBand : s.hideBand} dimColor onPress={() => void toggleBand($)} />
590 </Box>,
591 )
592
593 return <Box flexDirection="column">{rows}</Box>
594 })
595}
596hooks/i18n.ts 197 lines1// Los textos del mod en inglés (por defecto) y en español (#73). El idioma sale del entorno del proceso,
2// con el mismo criterio que el CLI (`packages/cli/src/i18n.ts`; el mod no lo importa: corre sin empaquetar).
3// Lo que escribe una persona (títulos, preguntas, opciones) y lo que el mod manda al servidor no se traduce.
4
5export type Lang = 'en' | 'es'
6
7/**
8 * POSIX: `LC_ALL` > `LC_MESSAGES` > `LANG`, y manda el primero no vacío. `es`, `es_PE.UTF-8`, `es-419` → 'es';
9 * todo lo demás (incluidos `C`, `POSIX` y nada) → 'en'.
10 */
11export function langFromEnv(env: Record<string, string | null | undefined>): Lang {
12 const raw = [env.LC_ALL, env.LC_MESSAGES, env.LANG].map(v => v?.trim() ?? '').find(v => v !== '') ?? ''
13 return /^es(?:$|[_.@-])/i.test(raw) ? 'es' : 'en'
14}
15
16export type Kind = 'decision' | 'review' | 'access'
17
18export type Strings = {
19 commandDescription: string
20 notLinked: (file: string) => string
21 summary: (project: string, waiting: number, startable: number) => string
22 noAnswer: (detail: string) => string
23 noDetail: string
24 missingKey: string
25 menuDoesNotFit: (reason: string) => string
26 /** toast de un bloqueante nuevo */
27 needsYou: (title: string, kind: Kind, question: string) => string
28 manyNew: (n: number) => string
29 resolved: (title: string, option: string) => string
30 took: (title: string) => string
31 finish: (title: string) => string
32 isDone: (title: string) => string
33 offline: (base: string, detail: string) => string
34 offlineMenu: (base: string, detail: string) => string
35 loading: string
36 retry: string
37 progress: (done: number, total: number, now: string) => string
38 waitingCount: (n: number) => string
39 startableCount: (n: number) => string
40 workingOn: string
41 done: string
42 release: string
43 openSky: string
44 other: (i: number, n: number) => string
45 /** «Needs your decision» en la banda */
46 waitingLabel: (kind: Kind) => string
47 grantInSky: string
48 seeAll: string
49 readyToTake: string
50 workOnThis: string
51 working: string
52 waitingSection: (n: number) => string
53 nothingWaiting: string
54 kindName: (kind: Kind) => string
55 access: string
56 whatWouldYouChange: string
57 oneLine: string
58 send: string
59 cancel: string
60 /** elegir una opción de una decisión o una revisión pide confirmar (#89) */
61 confirmPick: (option: string) => string
62 cannotUndo: string
63 yesChoose: string
64 /** tomar, terminar o soltar una tarea también pide confirmar: escriben en el servidor */
65 confirmTake: (title: string) => string
66 confirmDone: (title: string) => string
67 confirmRelease: (title: string) => string
68 yesTake: string
69 yesDone: string
70 yesRelease: string
71 startableSection: (n: number) => string
72 nothingStartable: string
73 showBand: string
74 hideBand: string
75}
76
77const EN: Strings = {
78 commandDescription: 'Opens the Dagyard menu: everything that needs you and everything ready to take',
79 notLinked: file =>
80 `This repo is not linked to a Dagyard project. Add ${file} at its root with {"project": "<id>"} (or DAGYARD_PROJECT) and open a new session.`,
81 summary: (project, waiting, startable) => `${project}: ${waiting} need you, ${startable} ready to take.`,
82 noAnswer: detail => `Dagyard did not respond (${detail})`,
83 noDetail: 'no details',
84 missingKey: 'the key is missing in ~/.config/dagyard',
85 menuDoesNotFit: reason => `Dagyard: the menu does not fit here (${reason})`,
86 needsYou: (title, kind, question) =>
87 `«${title}» needs ${kind === 'decision' ? 'your decision' : kind === 'review' ? 'your review' : 'an access'}: ${question}`,
88 manyNew: n => `${n} new things need you · /dagyard`,
89 resolved: (title, option) => `«${title}»: ${option}. The team carries on.`,
90 took: title => `You took «${title}». It now glows blue in the sky.`,
91 finish: title => `Finish «${title}»`,
92 isDone: title => `«${title}» is done.`,
93 offline: (base, detail) => `◆ dagyard · no connection to ${base} (${detail}) · /dagyard retries`,
94 offlineMenu: (base, detail) => `No connection to ${base} (${detail}).`,
95 loading: 'loading…',
96 retry: 'Retry',
97 progress: (done, total, now) => ` · ${done} of ${total} done${now ? ` · now in ${now}` : ''}`,
98 waitingCount: n => `${n} need you`,
99 startableCount: n => `${n} ready to take`,
100 workingOn: 'Working on ',
101 done: 'Done',
102 release: 'Release it',
103 openSky: 'See the sky',
104 other: (i, n) => `Next (${i}/${n})`,
105 waitingLabel: kind => (kind === 'decision' ? 'Needs your decision' : kind === 'review' ? 'Needs your review' : 'Needs an access'),
106 grantInSky: 'Grant it in the sky',
107 seeAll: 'See all',
108 readyToTake: 'Ready to take · ',
109 workOnThis: 'Work on this',
110 working: 'WORKING',
111 waitingSection: n => `NEEDS YOU (${n})`,
112 nothingWaiting: 'Nothing needs you.',
113 kindName: kind => (kind === 'decision' ? 'Decision' : kind === 'review' ? 'Review' : 'Access'),
114 access: 'access',
115 whatWouldYouChange: 'What would you change: ',
116 oneLine: 'in one line',
117 send: 'send',
118 cancel: 'Cancel',
119 confirmPick: option => `Choose «${option}»?`,
120 cannotUndo: 'It cannot be undone.',
121 yesChoose: 'Yes, choose',
122 confirmTake: title => `Work on «${title}»? It sets its /goal in this session.`,
123 confirmDone: title => `Is «${title}» done?`,
124 confirmRelease: title => `Release «${title}»? It goes back to pending, with no team.`,
125 yesTake: 'Yes, work on it',
126 yesDone: 'Yes, it is done',
127 yesRelease: 'Yes, release it',
128 startableSection: n => `READY TO TAKE (${n})`,
129 nothingStartable: 'Nothing unblocked left to take.',
130 showBand: 'Show the bar',
131 hideBand: 'Hide the bar',
132}
133
134const ES: Strings = {
135 commandDescription: 'Abre el menú de Dagyard: todo lo que te espera y lo que está para tomar',
136 notLinked: file =>
137 `Este repo no está enlazado a un proyecto de Dagyard. Agrega ${file} en su raíz con {"project": "<id>"} (o DAGYARD_PROJECT) y abre otra sesión.`,
138 summary: (project, waiting, startable) => `${project}: ${waiting} te esperan, ${startable} para tomar.`,
139 noAnswer: detail => `Dagyard no respondió (${detail})`,
140 noDetail: 'sin detalle',
141 missingKey: 'falta la clave en ~/.config/dagyard',
142 menuDoesNotFit: reason => `Dagyard: el menú no cabe aquí (${reason})`,
143 needsYou: (title, kind, question) =>
144 `«${title}» espera ${kind === 'decision' ? 'tu decisión' : kind === 'review' ? 'tu revisión' : 'un acceso'}: ${question}`,
145 manyNew: n => `${n} cosas nuevas te esperan · /dagyard`,
146 resolved: (title, option) => `«${title}»: ${option}. El equipo sigue.`,
147 took: title => `Tomaste «${title}». Ya brilla en azul en el cielo.`,
148 finish: title => `Termina «${title}»`,
149 isDone: title => `«${title}» está lista.`,
150 offline: (base, detail) => `◆ dagyard · sin conexión con ${base} (${detail}) · /dagyard reintenta`,
151 offlineMenu: (base, detail) => `Sin conexión con ${base} (${detail}).`,
152 loading: 'cargando…',
153 retry: 'Reintentar',
154 progress: (done, total, now) => ` · ${done} de ${total} listas${now ? ` · ahora en ${now}` : ''}`,
155 waitingCount: n => `${n} te esperan`,
156 startableCount: n => `${n} para tomar`,
157 workingOn: 'Trabajando en ',
158 done: 'Hecha',
159 release: 'Soltarla',
160 openSky: 'Ver el cielo',
161 other: (i, n) => `Otra (${i}/${n})`,
162 waitingLabel: kind => (kind === 'decision' ? 'Te espera tu decisión' : kind === 'review' ? 'Te espera tu revisión' : 'Te espera un acceso'),
163 grantInSky: 'Darlo en el cielo',
164 seeAll: 'Ver todo',
165 readyToTake: 'Para tomar · ',
166 workOnThis: 'Trabajar en esto',
167 working: 'TRABAJANDO',
168 waitingSection: n => `TE ESPERAN (${n})`,
169 nothingWaiting: 'Nada te espera.',
170 kindName: kind => (kind === 'decision' ? 'Decisión' : kind === 'review' ? 'Revisión' : 'Acceso'),
171 access: 'acceso',
172 whatWouldYouChange: 'Qué cambiarías: ',
173 oneLine: 'en una línea',
174 send: 'enviar',
175 cancel: 'Cancelar',
176 confirmPick: option => `¿Eliges «${option}»?`,
177 cannotUndo: 'No se puede deshacer.',
178 yesChoose: 'Sí, elegir',
179 confirmTake: title => `¿Trabajas en «${title}»? Fija su /goal en esta sesión.`,
180 confirmDone: title => `¿«${title}» está hecha?`,
181 confirmRelease: title => `¿Sueltas «${title}»? Vuelve a pendiente, sin equipo.`,
182 yesTake: 'Sí, trabajar en esto',
183 yesDone: 'Sí, está hecha',
184 yesRelease: 'Sí, soltarla',
185 startableSection: n => `PARA TOMAR (${n})`,
186 nothingStartable: 'Nada desbloqueado sin tomar.',
187 showBand: 'Mostrar la banda',
188 hideBand: 'Ocultar la banda',
189}
190
191export function text(lang: Lang): Strings {
192 return lang === 'es' ? ES : EN
193}
194
195/** La opción «Pedir cambios» de una revisión, en el idioma en que se haya escrito. */
196export const REQUEST_CHANGES = /cambio|change/i
197hooks/view.ts 70 lines1// La vista de la banda, calculada desde el snapshot del proyecto. Pura, para poder probarla.
2
3export type Status = 'pending' | 'working' | 'blocked' | 'done'
4export type Node = { id: string; title: string; stage: string; status: Status; team: string | null; goal: string | null }
5export type Blocker = {
6 id: string
7 nodeId: string
8 kind: 'decision' | 'review' | 'access'
9 question: string
10 options: string[]
11 accessLabel: string | null
12 status: 'open' | 'resolved'
13}
14export type Snapshot = {
15 project: { id: string; name: string; stages: { id: string; name: string }[] }
16 nodes: Node[]
17 edges: { from: string; to: string }[]
18 blockers: Blocker[]
19}
20
21/** Lo que la banda muestra, uno a la vez: primero lo que te espera, después lo que está para tomar. */
22export type Item = { kind: 'waiting'; blocker: Blocker; node: Node } | { kind: 'startable'; node: Node }
23
24export type View = {
25 project: string
26 now: string
27 done: number
28 total: number
29 waiting: Item[]
30 startable: Item[]
31}
32
33export function viewOf(s: Snapshot): View {
34 const byId = new Map(s.nodes.map(n => [n.id, n]))
35 const deps = new Map<string, string[]>()
36 for (const e of s.edges) deps.set(e.to, [...(deps.get(e.to) ?? []), e.from])
37
38 const waiting: Item[] = []
39 for (const b of s.blockers) {
40 const node = byId.get(b.nodeId)
41 if (b.status === 'open' && node) waiting.push({ kind: 'waiting', blocker: b, node })
42 }
43
44 // Para tomar: pendiente, nadie la tomó y todo lo que necesita ya está listo.
45 const startable: Item[] = s.nodes
46 .filter(n => n.status === 'pending' && !n.team)
47 .filter(n => (deps.get(n.id) ?? []).every(d => byId.get(d)?.status === 'done'))
48 .map(n => ({ kind: 'startable', node: n }))
49
50 const live = s.project.stages.filter(st =>
51 s.nodes.some(n => n.stage === st.id && (n.status === 'working' || n.status === 'blocked')),
52 )
53
54 return {
55 project: s.project.name,
56 now: live.map(st => st.name).join(' · '),
57 done: s.nodes.filter(n => n.status === 'done').length,
58 total: s.nodes.length,
59 waiting,
60 startable,
61 }
62}
63
64/** El link del cielo a un proyecto (`/?p=`) o a una tarea suya (`&n=`), el mismo que arma la web. Sin proyecto, la portada. */
65export function skyLink(base: string, project: string | null, node?: string | null): string {
66 const root = `${base.replace(/\/+$/, '')}/`
67 if (!project) return root
68 return `${root}?p=${encodeURIComponent(project)}${node ? `&n=${encodeURIComponent(node)}` : ''}`
69}
70