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

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./dagyard o «Ver todo»): todo lo anterior a la vez.

Desde la raíz de tu clon del repo:
claude plugin marketplace add "$PWD/packages/mod" && claude plugin install dagyard@dagyard
Queda instalado para todas tus sesiones y se lee desde esta carpeta: un git pull del repo lo actualiza (en una sesión abierta, /reload-plugins). Para probarlo sin instalar: claude --plugin-dir "$PWD/packages/mod".
Quitarlo: claude plugin uninstall dagyard@dagyard && claude plugin marketplace remove dagyard.
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)
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 487 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»
41let s: Strings = text('en') // los textos en el idioma del entorno; se fija en session.start
42
43async function home($: any): Promise<string> {
44 const fromEnv = await $.env.get('HOME')
45 if (fromEnv) return fromEnv
46 const out = await $.process.run(['printenv', 'HOME']).catch(() => null)
47 return out?.stdout?.trim() ?? ''
48}
49
50async function key($: any, file: string): Promise<string | null> {
51 const dir = await home($)
52 try {
53 return (await $.fs.read(`${dir}/.config/dagyard/${file}`)).trim() || null
54 } catch {
55 return null
56 }
57}
58
59/** El .dagyard.json más cercano subiendo desde `cwd` (el que escribe el repo, como el CLI). */
60async function linkOf($: any, cwd: string): Promise<{ project?: string; url?: string }> {
61 let dir = cwd.replace(/\/+$/, '')
62 for (;;) {
63 try {
64 const raw = JSON.parse(await $.fs.read(`${dir || ''}/${LINK_FILE}`))
65 return {
66 project: typeof raw?.project === 'string' ? raw.project.trim() || undefined : undefined,
67 url: typeof raw?.url === 'string' ? raw.url.trim() || undefined : undefined,
68 }
69 } catch {
70 // no está aquí (o no es JSON): sube
71 }
72 if (!dir) return {}
73 dir = dir.slice(0, dir.lastIndexOf('/'))
74 }
75}
76
77/** Avisa lo que te espera y no estaba la vez anterior; la primera carga solo toma nota. */
78function notice($: any, v: View): void {
79 const open = new Set(v.waiting.map(it => (it.kind === 'waiting' ? it.blocker.id : '')))
80 const fresh = seen ? v.waiting.filter(it => it.kind === 'waiting' && !seen!.has(it.blocker.id)) : []
81 seen = open
82 const first = fresh[0]
83 if (fresh.length === 1 && first?.kind === 'waiting') {
84 const { node, blocker: b } = first
85 $.ui.toast(s.needsYou(node.title, b.kind, b.question))
86 } else if (fresh.length > 1) {
87 $.ui.toast(s.manyNew(fresh.length))
88 }
89}
90
91/**
92 * Esperas base entre intentos ante un `503 unavailable` o un corte de red (≈1,2 s: lo que dura el corte de
93 * un deploy), con jitter de ×0,5 a ×1,5. Las mismas que `packages/cli/src/api.ts` (el mod no lo importa:
94 * corre con `$.http.fetch`, no se empaqueta).
95 */
96const RETRY_DELAYS_MS = [300, 900]
97const jitter = (ms: number) => Math.round(ms * (0.5 + Math.random()))
98
99/**
100 * ¿Repetir es seguro y útil? Un corte de red o un `503` del Worker que no escribió (`unavailable`) o que no
101 * llegó al Worker (sin cuerpo de error). `overloaded` (reintentar empeora) y `uncertain` (la escritura pudo
102 * quedar sin su clave) no se repiten.
103 */
104function transient(res: { status: number; text: string } | null): boolean {
105 if (!res) return true
106 if (res.status !== 503) return false
107 try {
108 const code = JSON.parse(res.text)?.error?.code
109 return !code || code === 'unavailable'
110 } catch {
111 return true
112 }
113}
114
115/**
116 * Una invocación de la API. Toda escritura lleva una `Idempotency-Key` nueva, la misma en cada reintento:
117 * así reintentar un `503 unavailable` o un corte de red (la escritura pudo quedar) nunca la duplica.
118 */
119async function api($: any, path: string, method: string, body: unknown, owner: boolean): Promise<any> {
120 const token = await key($, owner ? 'owner-token' : 'agent-key')
121 if (!token) throw new Error(s.missingKey)
122 const headers: Record<string, string> = { authorization: `Bearer ${token}`, 'user-agent': 'dagyard-mod/0.2' }
123 if (body !== undefined) headers['content-type'] = 'application/json'
124 if (method !== 'GET' && method !== 'HEAD') headers['idempotency-key'] = crypto.randomUUID()
125 const init = { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }
126 for (let attempt = 0; ; attempt++) {
127 let res: { status: number; ok: boolean; text: string } | null = null
128 let cut: unknown = null
129 try {
130 res = await $.http.fetch(`${base}${path}`, init)
131 } catch (err) {
132 cut = err
133 }
134 if (res?.ok) return res.text ? JSON.parse(res.text) : null
135 if (!transient(res) || attempt >= RETRY_DELAYS_MS.length) {
136 if (res) throw new Error(String(res.status))
137 throw cut instanceof Error ? cut : new Error(String(cut))
138 }
139 await $.clock.sleep(jitter(RETRY_DELAYS_MS[attempt]!))
140 }
141}
142
143async function refresh($: any): Promise<void> {
144 if (!project) return
145 loading = true
146 try {
147 const snap = (await api($, `/api/projects/${project}`, 'GET', undefined, false)) as Snapshot
148 view = viewOf(snap)
149 notice($, view)
150 const w = working
151 if (w) working = snap.nodes.find(n => n.id === w.id && n.status === 'working') ?? null
152 lastError = ''
153 } catch (err) {
154 view = null
155 lastError = (err as Error)?.message ?? String(err)
156 }
157 loading = false
158 $.ui.invalidate('ui.render')
159}
160
161async function settle($: any, err: unknown): Promise<void> {
162 if (err) $.ui.toast(s.noAnswer((err as Error).message))
163 busy = false
164 await refresh($)
165}
166
167async function resolve($: any, it: Extract<Item, { kind: 'waiting' }>, choice: number, note: string): Promise<void> {
168 if (busy) return
169 busy = true
170 $.ui.invalidate('ui.render')
171 let err: unknown = null
172 try {
173 await api($, `/api/projects/${project}/blockers/${it.blocker.id}/resolve`, 'POST', { choice, note: note || 'Desde Claude Code' }, true)
174 commenting = null
175 $.ui.toast(s.resolved(it.node.title, it.blocker.options[choice] ?? ''))
176 } catch (e) {
177 err = e
178 }
179 await settle($, err)
180}
181
182async function take($: any, node: Node): Promise<void> {
183 if (busy) return
184 busy = true
185 $.ui.invalidate('ui.render')
186 let err: unknown = null
187 try {
188 await api($, `/api/projects/${project}/nodes/${node.id}`, 'PATCH', { status: 'working', team: TEAM, progress: 0.05 }, false)
189 working = node
190 $.ui.toast(s.took(node.title))
191 } catch (e) {
192 err = e
193 }
194 await settle($, err)
195 if (!err) await $.command.run({ command: 'goal', args: node.goal ?? s.finish(node.title) })
196}
197
198async function patchWorking($: any, node: Node, body: unknown, toast: string): Promise<void> {
199 if (busy) return
200 busy = true
201 $.ui.invalidate('ui.render')
202 let err: unknown = null
203 try {
204 await api($, `/api/projects/${project}/nodes/${node.id}`, 'PATCH', body, false)
205 working = null
206 if (toast) $.ui.toast(toast)
207 } catch (e) {
208 err = e
209 }
210 await settle($, err)
211}
212
213/** Abre el cielo en el proyecto de este repo y, si se pasa, directo en esa tarea con su ficha. */
214async function openSky($: any, node?: string): Promise<void> {
215 await $.process.run(['open', skyLink(base, project, node)]).catch(() => undefined)
216}
217
218// Primero abre y después carga: el motor coloca el panel a cualquier ancho solo si el clic de la
219// persona está detrás; una espera de red antes del open lo vuelve «no pedido» (y bajo 144 columnas espera).
220async function openMenu($: any): Promise<void> {
221 const opened = await $.ui.open({ id: MENU, title: 'Dagyard', focus: true, closeOnEscape: true })
222 if (!opened.isPlaced) $.ui.toast(s.menuDoesNotFit(opened.reason))
223 void refresh($)
224}
225
226async function comment($: any, blockerId: string | null): Promise<void> {
227 commenting = blockerId
228 $.ui.invalidate('ui.render')
229}
230
231async function toggleBand($: any): Promise<void> {
232 hidden = !hidden
233 $.ui.invalidate('ui.render')
234}
235
236async function skip($: any): Promise<void> {
237 cursor += 1
238 $.ui.invalidate('ui.render')
239}
240
241export const register: Register = on => {
242 on('session.start', async ($, e, next) => {
243 const link = await linkOf($, e.cwd)
244 base = ((await $.env.get('DAGYARD_URL')) || link.url || (await key($, 'url')) || URL_DEFAULT).replace(/\/+$/, '')
245 project = (await $.env.get('DAGYARD_PROJECT')) || link.project || (await key($, 'project')) || null
246 s = text(langFromEnv({ LC_ALL: await $.env.get('LC_ALL'), LC_MESSAGES: await $.env.get('LC_MESSAGES'), LANG: await $.env.get('LANG') }))
247 view = null
248 seen = null
249 await $.command.register({ name: 'dagyard', description: s.commandDescription })
250 if (project) {
251 $.clock.every(POLL_MS, () => void refresh($))
252 void refresh($)
253 }
254 return next(e)
255 })
256
257 on('command.run', { command: 'dagyard' }, async $ => {
258 if (!project)
259 return {
260 text: s.notLinked(LINK_FILE),
261 }
262 await openMenu($)
263 const v = view
264 return {
265 text: v
266 ? s.summary(v.project, v.waiting.length, v.startable.length)
267 : `${s.noAnswer(lastError || s.noDetail)} · ${base}/api/projects/${project}`,
268 }
269 })
270
271 on('turn.complete', async ($, e, next) => {
272 const done = await next(e)
273 await refresh($)
274 return done
275 })
276
277 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
278 const below = await next(e)
279 const v = view
280 if (!project || e.props.hasSurvey || e.props.isWorking || hidden) return below
281 if (!v) {
282 if (!loading && !lastError) void refresh($)
283 const { Box: B, Text: T } = $.ui.resolve(e)
284 return (
285 <B flexDirection="column">
286 <T color={MUTED}>{s.offline(base, lastError || s.loading)}</T>
287 {below}
288 </B>
289 )
290 }
291
292 const { Box, Button, Text } = $.ui.resolve(e)
293 const list: Item[] = [...v.waiting, ...v.startable]
294
295 const head = (
296 <Text wrap="truncate-end">
297 <Text color={BLUE}>◆ dagyard </Text>
298 <Text bold>{v.project}</Text>
299 <Text color={MUTED}>{s.progress(v.done, v.total, v.now)} · </Text>
300 <Text color={v.waiting.length ? AMBER : MUTED}>{s.waitingCount(v.waiting.length)}</Text>
301 <Text color={MUTED}> · </Text>
302 <Text color={v.startable.length ? BLUE : MUTED}>{s.startableCount(v.startable.length)}</Text>
303 </Text>
304 )
305
306 let body = null
307 const w = working
308 if (busy) {
309 body = <Text color={MUTED}>…</Text>
310 } else if (w) {
311 body = (
312 <Box flexDirection="column">
313 <Text wrap="truncate-end">
314 <Text color={BLUE}>{s.workingOn}</Text>
315 <Text bold>{w.title}</Text>
316 </Text>
317 <Box flexWrap="wrap">
318 <Button key="dy-done" label={s.done} hotkey="h" variant="primary" onPress={() => void patchWorking($, w, { status: 'done' }, s.isDone(w.title))} />
319 <Button key="dy-release" label={s.release} hotkey="l" onPress={() => void patchWorking($, w, { status: 'pending', team: null, progress: 0 }, '')} />
320 <Button key="dy-open" label={s.openSky} hotkey="o" dimColor onPress={() => void openSky($, w.id)} />
321 </Box>
322 </Box>
323 )
324 } else if (list.length > 0) {
325 const i = cursor % list.length
326 const it = list[i]!
327 const more =
328 list.length > 1 ? (
329 <Button key="dy-next" label={s.other(i + 1, list.length)} hotkey="s" dimColor onPress={() => void skip($)} />
330 ) : null
331 if (it.kind === 'waiting') {
332 const b = it.blocker
333 const label = s.waitingLabel(b.kind)
334 const buttons =
335 b.kind === 'access'
336 ? [<Button key="dy-sky" label={s.grantInSky} hotkey="o" variant="primary" onPress={() => void openSky($, it.node.id)} />]
337 : b.options.slice(0, 3).map((opt, k) => (
338 <Button key={`dy-opt-${k}`} label={opt} hotkey={String(k + 1)} variant={k === 0 ? 'primary' : undefined} onPress={() => void resolve($, it, k, '')} />
339 ))
340 body = (
341 <Box flexDirection="column">
342 <Text wrap="truncate-end">
343 <Text color={AMBER}>{label} · </Text>
344 <Text bold>{it.node.title}</Text>
345 <Text color={MUTED}> — {b.kind === 'access' ? b.accessLabel ?? b.question : b.question}</Text>
346 </Text>
347 <Box flexWrap="wrap">
348 {buttons}
349 {more}
350 <Button key="dy-menu" label={s.seeAll} hotkey="m" dimColor onPress={() => void openMenu($)} />
351 </Box>
352 </Box>
353 )
354 } else {
355 const n = it.node
356 body = (
357 <Box flexDirection="column">
358 <Text wrap="truncate-end">
359 <Text color={BLUE}>{s.readyToTake}</Text>
360 <Text bold>{n.title}</Text>
361 {n.goal ? <Text color={MUTED}> — /goal {n.goal}</Text> : null}
362 </Text>
363 <Box flexWrap="wrap">
364 <Button key="dy-take" label={s.workOnThis} hotkey="t" variant="primary" onPress={() => void take($, n)} />
365 {more}
366 <Button key="dy-menu" label={s.seeAll} hotkey="m" dimColor onPress={() => void openMenu($)} />
367 </Box>
368 </Box>
369 )
370 }
371 }
372
373 return (
374 <Box flexDirection="column">
375 {head}
376 {body}
377 {below}
378 </Box>
379 )
380 })
381
382 on('ui.render', { component: 'Pane', requestId: MENU }, async ($, e) => {
383 const els = $.ui.resolve(e)
384 const { Box, Button, Text } = els
385 // «Qué cambiarías» se escribe donde la superficie tiene campo de texto (el celular no lo dibuja);
386 // donde no, «Pedir cambios» se manda sin nota en vez de dejar un «Cancelar» solo
387 const Input = e.surface !== 'mobile' && 'Input' in els ? els.Input : null
388 const v = view
389 if (!v) {
390 return (
391 <Box flexDirection="column">
392 <Text color={MUTED}>{s.offlineMenu(base, lastError || s.loading)}</Text>
393 <Button key="mn-retry" label={s.retry} onPress={() => void refresh($)} />
394 </Box>
395 )
396 }
397 const w = working
398 const rows: any[] = []
399
400 rows.push(
401 <Text key="mn-head" wrap="truncate-end">
402 <Text bold>{v.project}</Text>
403 <Text color={MUTED}>{s.progress(v.done, v.total, v.now)}</Text>
404 </Text>,
405 )
406
407 if (w) {
408 rows.push(<Text key="mn-w-sec" color={BLUE}>{'\n'}{s.working}</Text>)
409 rows.push(
410 <Box key="mn-w" flexDirection="column">
411 <Text bold>{w.title}</Text>
412 <Box flexWrap="wrap">
413 <Button key="mn-w-done" label={s.done} variant="primary" onPress={() => void patchWorking($, w, { status: 'done' }, s.isDone(w.title))} />
414 <Button key="mn-w-release" label={s.release} onPress={() => void patchWorking($, w, { status: 'pending', team: null, progress: 0 }, '')} />
415 </Box>
416 </Box>,
417 )
418 }
419
420 rows.push(<Text key="mn-wait-sec" color={v.waiting.length ? AMBER : MUTED}>{'\n'}{s.waitingSection(v.waiting.length)}</Text>)
421 if (v.waiting.length === 0) rows.push(<Text key="mn-wait-none" color={MUTED}>{s.nothingWaiting}</Text>)
422 v.waiting.forEach(it => {
423 if (it.kind !== 'waiting') return
424 const b = it.blocker
425 const kind = s.kindName(b.kind)
426 const id = b.id
427 let actions: any
428 if (b.kind === 'access') {
429 actions = <Button key={`mn-${id}-sky`} label={s.grantInSky} variant="primary" onPress={() => void openSky($, it.node.id)} />
430 } else if (commenting === id && Input) {
431 const k = b.options.findIndex(o => REQUEST_CHANGES.test(o))
432 actions = (
433 <Box flexDirection="column">
434 <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)} />
435 <Button key={`mn-${id}-cancel`} label={s.cancel} dimColor onPress={() => void comment($, null)} />
436 </Box>
437 )
438 } else {
439 actions = (
440 <Box flexWrap="wrap">
441 {b.options.map((opt, k) =>
442 b.kind === 'review' && Input && REQUEST_CHANGES.test(opt) ? (
443 <Button key={`mn-${id}-${k}`} label={opt} onPress={() => void comment($, id)} />
444 ) : (
445 <Button key={`mn-${id}-${k}`} label={opt} variant={k === 0 ? 'primary' : undefined} onPress={() => void resolve($, it, k, '')} />
446 ),
447 )}
448 </Box>
449 )
450 }
451 rows.push(
452 <Box key={`mn-${id}`} flexDirection="column">
453 <Text wrap="wrap">
454 <Text color={AMBER}>{kind} · </Text>
455 <Text bold>{it.node.title}</Text>
456 </Text>
457 <Text wrap="wrap" color={MUTED}>{b.kind === 'access' ? `${b.question} (${b.accessLabel ?? s.access})` : b.question}</Text>
458 {actions}
459 </Box>,
460 )
461 })
462
463 rows.push(<Text key="mn-take-sec" color={v.startable.length ? BLUE : MUTED}>{'\n'}{s.startableSection(v.startable.length)}</Text>)
464 if (v.startable.length === 0) rows.push(<Text key="mn-take-none" color={MUTED}>{s.nothingStartable}</Text>)
465 v.startable.forEach(it => {
466 const n = it.node
467 rows.push(
468 <Box key={`mn-t-${n.id}`} flexDirection="column">
469 <Text bold>{n.title}</Text>
470 {n.goal ? <Text wrap="wrap" color={MUTED}>/goal {n.goal}</Text> : null}
471 <Button key={`mn-t-${n.id}-take`} label={s.workOnThis} variant="primary" onPress={() => void take($, n)} />
472 </Box>,
473 )
474 })
475
476 rows.push(
477 <Box key="mn-foot">
478 <Text>{'\n'}</Text>
479 <Button key="mn-sky" label={s.openSky} dimColor onPress={() => void openSky($, w?.id)} />
480 <Button key="mn-band" label={hidden ? s.showBand : s.hideBand} dimColor onPress={() => void toggleBand($)} />
481 </Box>,
482 )
483
484 return <Box flexDirection="column">{rows}</Box>
485 })
486}
487hooks/i18n.ts 168 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 startableSection: (n: number) => string
61 nothingStartable: string
62 showBand: string
63 hideBand: string
64}
65
66const EN: Strings = {
67 commandDescription: 'Opens the Dagyard menu: everything that needs you and everything ready to take',
68 notLinked: file =>
69 `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.`,
70 summary: (project, waiting, startable) => `${project}: ${waiting} need you, ${startable} ready to take.`,
71 noAnswer: detail => `Dagyard did not respond (${detail})`,
72 noDetail: 'no details',
73 missingKey: 'the key is missing in ~/.config/dagyard',
74 menuDoesNotFit: reason => `Dagyard: the menu does not fit here (${reason})`,
75 needsYou: (title, kind, question) =>
76 `«${title}» needs ${kind === 'decision' ? 'your decision' : kind === 'review' ? 'your review' : 'an access'}: ${question}`,
77 manyNew: n => `${n} new things need you · /dagyard`,
78 resolved: (title, option) => `«${title}»: ${option}. The team carries on.`,
79 took: title => `You took «${title}». It now glows blue in the sky.`,
80 finish: title => `Finish «${title}»`,
81 isDone: title => `«${title}» is done.`,
82 offline: (base, detail) => `◆ dagyard · no connection to ${base} (${detail}) · /dagyard retries`,
83 offlineMenu: (base, detail) => `No connection to ${base} (${detail}).`,
84 loading: 'loading…',
85 retry: 'Retry',
86 progress: (done, total, now) => ` · ${done} of ${total} done${now ? ` · now in ${now}` : ''}`,
87 waitingCount: n => `${n} need you`,
88 startableCount: n => `${n} ready to take`,
89 workingOn: 'Working on ',
90 done: 'Done',
91 release: 'Release it',
92 openSky: 'See the sky',
93 other: (i, n) => `Next (${i}/${n})`,
94 waitingLabel: kind => (kind === 'decision' ? 'Needs your decision' : kind === 'review' ? 'Needs your review' : 'Needs an access'),
95 grantInSky: 'Grant it in the sky',
96 seeAll: 'See all',
97 readyToTake: 'Ready to take · ',
98 workOnThis: 'Work on this',
99 working: 'WORKING',
100 waitingSection: n => `NEEDS YOU (${n})`,
101 nothingWaiting: 'Nothing needs you.',
102 kindName: kind => (kind === 'decision' ? 'Decision' : kind === 'review' ? 'Review' : 'Access'),
103 access: 'access',
104 whatWouldYouChange: 'What would you change: ',
105 oneLine: 'in one line',
106 send: 'send',
107 cancel: 'Cancel',
108 startableSection: n => `READY TO TAKE (${n})`,
109 nothingStartable: 'Nothing unblocked left to take.',
110 showBand: 'Show the bar',
111 hideBand: 'Hide the bar',
112}
113
114const ES: Strings = {
115 commandDescription: 'Abre el menú de Dagyard: todo lo que te espera y lo que está para tomar',
116 notLinked: file =>
117 `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.`,
118 summary: (project, waiting, startable) => `${project}: ${waiting} te esperan, ${startable} para tomar.`,
119 noAnswer: detail => `Dagyard no respondió (${detail})`,
120 noDetail: 'sin detalle',
121 missingKey: 'falta la clave en ~/.config/dagyard',
122 menuDoesNotFit: reason => `Dagyard: el menú no cabe aquí (${reason})`,
123 needsYou: (title, kind, question) =>
124 `«${title}» espera ${kind === 'decision' ? 'tu decisión' : kind === 'review' ? 'tu revisión' : 'un acceso'}: ${question}`,
125 manyNew: n => `${n} cosas nuevas te esperan · /dagyard`,
126 resolved: (title, option) => `«${title}»: ${option}. El equipo sigue.`,
127 took: title => `Tomaste «${title}». Ya brilla en azul en el cielo.`,
128 finish: title => `Termina «${title}»`,
129 isDone: title => `«${title}» está lista.`,
130 offline: (base, detail) => `◆ dagyard · sin conexión con ${base} (${detail}) · /dagyard reintenta`,
131 offlineMenu: (base, detail) => `Sin conexión con ${base} (${detail}).`,
132 loading: 'cargando…',
133 retry: 'Reintentar',
134 progress: (done, total, now) => ` · ${done} de ${total} listas${now ? ` · ahora en ${now}` : ''}`,
135 waitingCount: n => `${n} te esperan`,
136 startableCount: n => `${n} para tomar`,
137 workingOn: 'Trabajando en ',
138 done: 'Hecha',
139 release: 'Soltarla',
140 openSky: 'Ver el cielo',
141 other: (i, n) => `Otra (${i}/${n})`,
142 waitingLabel: kind => (kind === 'decision' ? 'Te espera tu decisión' : kind === 'review' ? 'Te espera tu revisión' : 'Te espera un acceso'),
143 grantInSky: 'Darlo en el cielo',
144 seeAll: 'Ver todo',
145 readyToTake: 'Para tomar · ',
146 workOnThis: 'Trabajar en esto',
147 working: 'TRABAJANDO',
148 waitingSection: n => `TE ESPERAN (${n})`,
149 nothingWaiting: 'Nada te espera.',
150 kindName: kind => (kind === 'decision' ? 'Decisión' : kind === 'review' ? 'Revisión' : 'Acceso'),
151 access: 'acceso',
152 whatWouldYouChange: 'Qué cambiarías: ',
153 oneLine: 'en una línea',
154 send: 'enviar',
155 cancel: 'Cancelar',
156 startableSection: n => `PARA TOMAR (${n})`,
157 nothingStartable: 'Nada desbloqueado sin tomar.',
158 showBand: 'Mostrar la banda',
159 hideBand: 'Ocultar la banda',
160}
161
162export function text(lang: Lang): Strings {
163 return lang === 'es' ? ES : EN
164}
165
166/** La opción «Pedir cambios» de una revisión, en el idioma en que se haya escrito. */
167export const REQUEST_CHANGES = /cambio|change/i
168hooks/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