SLOPSHOPPER

dagyard

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…

newpanebandcommandtoastprocess
★ 1v0.5.0MITupdated 2026-10-13cofoundy/dagyard/packages/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dagyard
│ ┃ dagyard-menu ✕ › fix the failing auth test and add an audit log call │ ┃ No connection to │ ┃ https://dagyard.cofoundy-dev.workers.dev ⏺ Read(src/auth.ts) │ ┃ (loading…). ⎿ Read 6 lines │ ┃ [ Retry ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dagyard │ ⎿ dagyard: This repo is not linked to a Dagyard project. Add .dagy │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dagyard-menu
No connection to https://dagyard.cofoundy-dev.workers.dev (loading…). [ Retry ]
README

Dagyard en Claude Code

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.

  • Te espera: una decisión o una revisión se responden desde la banda o el menú, en dos pasos: marcas la opción y confirmas con «Sí, elegir» («Cancelar» la deja como estaba). Resolver no se deshace, así que ninguna tecla suelta elige: las opciones no tienen atajo y un dígito en el prompt vacío se queda en el prompt. Un acceso se da en el cielo: su valor nunca pasa por la terminal. «Darlo en el cielo» abre esa tarea con su ficha.
  • Ver el cielo: abre el proyecto del repo (o la tarea en la que estás trabajando).
  • Para tomar: lo pendiente, sin equipo y con todo lo que necesita ya listo. «Trabajar en esto» la arranca y fija su /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).
  • Menú (/dagyard o «Ver todo»): todo lo anterior a la vez.
  • Aviso: cuando se abre algo nuevo que te espera, un toast en ≤20 s.

La banda sobre el prompt Elegir una opción pide confirmarla; el foco empieza en «Cancelar» El menú abierto con /dagyard en una terminal de 120 columnas El aviso de un bloqueante nuevo, arriba a la derecha

Instalación (un paso)

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.

El CLI viene incluido

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.

La skill del protocolo

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.

El canal (opcional, apagado por defecto)

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.

A qué proyecto mira

El primero que encuentre, en este orden:

  1. DAGYARD_PROJECT (y DAGYARD_URL) en el entorno;
  2. el .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);
  3. ~/.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.

Idioma

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.

Desarrollo

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.

Source 3 files
hooks/register.tsx 596 lines
1// 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}
596
hooks/i18n.ts 197 lines
1// 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
197
hooks/view.ts 70 lines
1// 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