SLOPSHOPPER

dagyard

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

newpanebandcommandtoastprocess
v0.2.0MITupdated 2026-10-04cofoundy/dagyard-oss/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ú. 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.
  • 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 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)

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.

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)

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 487 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»
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}
487
hooks/i18n.ts 168 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  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
168
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