@milpa/comal 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +213 -55
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +38 -1
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +97 -4
  45. package/src/plugin-config.mjs +18 -1
  46. package/src/plugin-controls.mjs +31 -8
  47. package/src/plugin-trust.mjs +15 -6
  48. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  49. package/src/plugins/chrome-lab/client.mjs +242 -12
  50. package/src/plugins/chrome-lab/index.mjs +106 -24
  51. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  52. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  53. package/src/plugins/open-relay/actions.mjs +60 -9
  54. package/src/plugins/open-relay/client.mjs +3 -3
  55. package/src/plugins/open-relay/decision.mjs +3 -2
  56. package/src/plugins/open-relay/index.mjs +17 -7
  57. package/src/plugins/open-relay/lookup.mjs +41 -4
  58. package/src/plugins/open-relay/notices.mjs +44 -3
  59. package/src/plugins/open-relay/panel.mjs +4 -1
  60. package/src/plugins/open-relay/watch.mjs +27 -8
  61. package/src/process-lock.mjs +20 -12
  62. package/src/provider-auth.mjs +6 -3
  63. package/src/provider-profiles.mjs +13 -3
  64. package/src/provider.mjs +175 -30
  65. package/src/read-only-check.mjs +6 -1
  66. package/src/relay/broker.mjs +160 -0
  67. package/src/relay/cards.mjs +702 -0
  68. package/src/relay/flow.mjs +886 -0
  69. package/src/relay/follow.mjs +119 -0
  70. package/src/relay/git.mjs +23 -0
  71. package/src/relay/hub-client.mjs +163 -0
  72. package/src/relay/state.mjs +55 -0
  73. package/src/remote-control.mjs +115 -12
  74. package/src/remote-relay.mjs +468 -0
  75. package/src/session-bus.mjs +13 -3
  76. package/src/session-provider-settings.mjs +2 -2
  77. package/src/skill-runtime.mjs +8 -6
  78. package/src/source-ownership.mjs +3 -1
  79. package/src/tool-manifest.mjs +71 -0
  80. package/src/tool-packs.mjs +22 -6
  81. package/src/tool-schema.mjs +14 -2
  82. package/src/tools.mjs +96 -27
  83. package/src/tui/controller.mjs +4 -1
  84. package/src/tui/discovery.mjs +11 -7
  85. package/src/tui/inbound-blocks.mjs +5 -1
  86. package/src/tui/screen.mjs +53 -14
  87. package/src/tui/swarm-panel.mjs +19 -8
  88. package/src/turn-timing.mjs +82 -0
  89. package/src/usage-accounting.mjs +12 -1
  90. package/src/vision.mjs +6 -1
  91. package/src/visual/chrome.mjs +379 -0
  92. package/src/visual/fingerprint.mjs +30 -0
  93. package/src/visual/fixtures.mjs +201 -0
  94. package/src/visual/gate.mjs +92 -0
  95. package/src/visual/judge.mjs +114 -0
  96. package/src/visual/ledger.mjs +160 -0
  97. package/src/visual/measure.mjs +145 -0
  98. package/src/visual/names.mjs +7 -0
  99. package/src/visual/operations.mjs +211 -0
  100. package/src/visual/pixel-diff.mjs +96 -0
  101. package/src/visual/png.mjs +103 -0
  102. package/src/visual/registry.mjs +265 -0
  103. package/src/visual/report.mjs +64 -0
  104. package/src/visual/verify.mjs +292 -0
  105. package/src/work-assertions.mjs +15 -4
  106. package/src/work-plan.mjs +166 -30
  107. package/src/work-state.mjs +4 -4
  108. package/src/workflows/chrome-task-adoption.mjs +7 -3
  109. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,702 @@
1
+ import { t, registerMessages, formatNumber } from '../i18n/index.mjs';
2
+
3
+ // Tarjetas de /relay: funciones puras que convierten datos (del hub, de relayd y del repo)
4
+ // en {title, description, items, keys, hint} para TerminalScreen.setupChoice. Son del
5
+ // HUMANO: el modelo no las ve ni las puede contestar. Todo texto que viene de los agentes
6
+ // (resúmenes, títulos, preguntas) se marca como no confiable y se acota; la pantalla vuelve
7
+ // a limpiarlo (safeText) al pintarlo.
8
+ // Revisor de open-relay (F7): criterios de éxito, escalaciones, plan revisado y veredicto final.
9
+ registerMessages({
10
+ 'Success criteria · {swarm}': 'Criterios de éxito · {swarm}',
11
+ // open-relay v2.33: criterios con su cita del enunciado.
12
+ 'Review before accepting ({n})': 'Revisa antes de aceptar ({n})',
13
+ ' ⚠ {id}: asks more than the statement: «{beyond}»': ' ⚠ {id}: exige más que el enunciado: «{beyond}»',
14
+ ' ⚠ {id}: the quote is not in {source}: «{quote}»': ' ⚠ {id}: la cita no aparece en {source}: «{quote}»',
15
+ ' ○ {id}: no quote from the statement': ' ○ {id}: sin cita del enunciado',
16
+ ' quote ({source}): «{quote}»': ' cita ({source}): «{quote}»',
17
+ ' quote ({source}, not found): «{quote}»': ' cita ({source}, no aparece): «{quote}»',
18
+ ' beyond the statement: «{beyond}»': ' además del enunciado: «{beyond}»',
19
+ 'asks more than the statement': 'exige más que el enunciado',
20
+ 'quote not found': 'cita que no aparece',
21
+ 'no quote': 'sin cita',
22
+ 'Accept with {n} warnings': 'Aceptar con {n} avisos',
23
+ 'criteria.accept with this digest despite the warnings above.': 'criteria.accept con este digest pese a los avisos de arriba.',
24
+ 'Enter rejects with a reason · accepting asks for confirmation · PgUp/PgDn · Esc later': 'Enter rechaza con un motivo · aceptar pide confirmación · PgUp/PgDn · Esc más tarde',
25
+ 'Asks for confirmation first.': 'Antes pide confirmación.',
26
+ 'Accept with {n} warnings? · {swarm}': '¿Aceptar con {n} avisos? · {swarm}',
27
+ 'The lead plans against these criteria and the reviewer judges with them, warnings included.': 'El líder planea contra estos criterios y el revisor juzga con ellos, avisos incluidos.',
28
+ 'Go back without accepting': 'Volver sin aceptar',
29
+ '/relay shows the criteria again.': '/relay vuelve a mostrar los criterios.',
30
+ 'Accept anyway': 'Aceptar de todos modos',
31
+ 'Enter goes back · ↓ then Enter accepts': 'Enter vuelve · ↓ y Enter acepta',
32
+ 'Reviewer {runtime} · {model} · eyes {eyes} · {share} % of the budget (if you start with it)': 'Revisor {runtime} · {model} · ojos {eyes} · {share} % del presupuesto (si arrancas con él)',
33
+ 'Start with an independent reviewer': 'Arrancar con revisor independiente',
34
+ 'A second agent writes the success criteria, reviews the plan and judges each result with evidence before it closes. You accept the criteria and the plan.': 'Un segundo agente redacta los criterios de éxito, revisa el plan y juzga cada resultado con evidencia antes de cerrarlo. Tú aceptas los criterios y el plan.',
35
+ 'Start without reviewer': 'Arrancar sin revisor',
36
+ 'Version {version} by {author} · agent text, untrusted': 'Versión {version} de {author} · texto de agentes, no confiable',
37
+ 'Mid-swarm renegotiation: v{version} stays in force until you accept this one; reviews in progress then restart with it and approved tasks stay done.': 'Renegociación a medio swarm: la v{version} sigue vigente hasta que aceptes esta; las revisiones en curso se repiten entonces con ella y las tareas aprobadas siguen hechas.',
38
+ 'Reopen the success criteria of {swarm}': 'Reabrir los criterios de éxito de {swarm}',
39
+ 'Why reopen the criteria': 'Por qué reabrir los criterios',
40
+ 'The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.': 'El revisor despierta con este texto exacto y propone otra versión; la v{version} sigue vigente hasta que aceptes la nueva.',
41
+ 'Success criteria of {swarm} reopened ({status}); the reviewer got your reason.': 'Criterios de éxito de {swarm} reabiertos ({status}); el revisor recibió tu motivo.',
42
+ 'The lead endorses it: the reviewer wrote it, the lead agrees it is feasible.': 'El líder la respalda: la redactó el revisor y el líder la considera viable.',
43
+ 'The lead objected {n} times (rounds exhausted): you decide over the objection.': 'El líder objetó {n} veces (rondas agotadas): decides tú sobre la objeción.',
44
+ 'Lead objection (v{version}): «{reasons}»': 'Objeción del líder (v{version}): «{reasons}»',
45
+ 'Automatic (a check proves them)': 'Automáticos (los prueba un check)',
46
+ 'Judgment (the reviewer decides with evidence)': 'De juicio (los decide el revisor con evidencia)',
47
+ ' evidence: {evidence}{viewports}': ' evidencia: {evidence}{viewports}',
48
+ ' · viewports {viewports}': ' · viewports {viewports}',
49
+ 'Out of scope': 'Fuera de alcance',
50
+ 'Design: {files}': 'Diseño: {files}',
51
+ 'Accepting fixes this version (digest {digest}…): the lead plans against it and the reviewer judges with it.': 'Aceptar fija esta versión (digest {digest}…): el líder planea con ella y el revisor juzga con ella.',
52
+ 'Accept': 'Aceptar',
53
+ 'criteria.accept with this digest; the lead can propose the plan.': 'criteria.accept con este digest; el líder ya puede proponer el plan.',
54
+ 'The reviewer is woken up with your reason and proposes a new version.': 'El revisor despierta con tu motivo y propone una versión nueva.',
55
+ 'Enter accepts · r rejects with a reason · PgUp/PgDn · Esc later': 'Enter acepta · r rechaza con un motivo · PgUp/PgDn · Esc más tarde',
56
+ 'Reason for the reviewer': 'Motivo para el revisor',
57
+ 'The reviewer is woken up with exactly this text and proposes again.': 'El revisor despierta con exactamente este texto y vuelve a proponer.',
58
+ 'Review escalated · {task}': 'Revisión escalada · {task}',
59
+ 'Task {task}{title} · {kind}': 'Tarea {task}{title} · {kind}',
60
+ 'task review': 'revisión de tarea', 'integration review': 'revisión de integración', 'final review': 'revisión final',
61
+ 'The reviewer objected {n} times (rounds exhausted): the candidate waits for you.': 'El revisor objetó {n} veces (rondas agotadas): el candidato te espera.',
62
+ 'The review ended without a verdict (reviewer unavailable): the candidate waits for you.': 'La revisión terminó sin veredicto (revisor no disponible): el candidato te espera.',
63
+ 'Last verdict (reviewer text, untrusted):': 'Último veredicto (texto del revisor, no confiable):',
64
+ ' · {id} FAIL: {why}': ' · {id} FAIL: {why}',
65
+ ' · correction {id} [{severity}{fixBy}]: {what}{where}': ' · corrección {id} [{severity}{fixBy}]: {what}{where}',
66
+ ' (in {where})': ' (en {where})',
67
+ 'Notes: «{notes}»': 'Notas: «{notes}»',
68
+ 'Accepting over the reviewer is recorded in relayd as your decision, with your reason. An integration is then applied to the branch; the final review closes the swarm.': 'Aceptar sobre el revisor queda en relayd como tu decisión, con tu motivo. Una integración se aplica entonces a la rama; la revisión final cierra el enjambre.',
69
+ 'To cancel or revise the task: relay task cancel --task {task} (or task revise), in your terminal.': 'Para cancelar o revisar la tarea: relay task cancel --task {task} (o task revise), en tu terminal.',
70
+ 'Accept over the objection': 'Aceptar sobre la objeción',
71
+ 'review.override with your reason (recorded in relayd).': 'review.override con tu motivo (queda en relayd).',
72
+ 'Enter accepts over the objection · Esc later': 'Enter acepta sobre la objeción · Esc más tarde',
73
+ 'Ask for another review': 'Pedir otra revisión',
74
+ 'One more round': 'Una ronda más',
75
+ 'review.retry: the same candidate goes back to the reviewer, with your reason (recorded in relayd).': 'review.retry: el mismo candidato vuelve al revisor, con tu motivo (queda en relayd).',
76
+ 'Enter chooses · ↑/↓ · Esc later': 'Enter elige · ↑/↓ · Esc más tarde',
77
+ 'Why you accept over the reviewer': 'Por qué aceptas sobre el revisor',
78
+ 'Recorded in relayd as your decision (review.override) with exactly this text.': 'Queda en relayd como tu decisión (review.override) con exactamente este texto.',
79
+ 'Reviewer: endorses the plan.': 'Revisor: respalda el plan.',
80
+ 'Reviewer: objected twice (rounds exhausted), you decide over it: «{reasons}»': 'Revisor: lo objetó dos veces (rondas agotadas), decides tú sobre la objeción: «{reasons}»',
81
+ 'Reviewer: has not reviewed it yet.': 'Revisor: aún no lo revisó.',
82
+ 'Correction plan of the reviewer for {task}: relayd approves it on its own.': 'Plan de corrección del revisor para {task}: relayd lo aprueba solo.',
83
+ ' covers: {criteria}': ' cubre: {criteria}',
84
+ ' corrects: {objections}': ' corrige: {objections}',
85
+ 'Final review: approved by the reviewer ({n} criteria pass).': 'Revisión final: aprobada por el revisor ({n} criterios en pass).',
86
+ 'Final review: accepted by you over the reviewer («{reason}»).': 'Revisión final: aceptada por ti sobre el revisor («{reason}»).',
87
+ 'Reviewer evidence (metadata; the files stay in relayd):': 'Evidencia del revisor (metadatos; los archivos quedan en relayd):',
88
+ ' · {path} · {media} · {kib} KiB · b3 {hash}…': ' · {path} · {media} · {kib} KiB · b3 {hash}…',
89
+ });
90
+
91
+ registerMessages({
92
+ 'New swarm project · {repo}': 'Proyecto de enjambre nuevo · {repo}',
93
+ 'Project {name} · {repo}': 'Proyecto {name} · {repo}',
94
+ 'Work branch {branch} from {base} ({commit}) · created now; your working tree is not touched': 'Rama {branch} desde {base} ({commit}) · se crea ahora; tu árbol de trabajo no se toca',
95
+ 'Work branch {branch} (already exists, it is used as is)': 'Rama {branch} (ya existe: se usa tal cual)',
96
+ 'Agents {runtime} · {model} (lead: the same)': 'Agentes {runtime} · {model} (líder: el mismo)',
97
+ // Ojos (host remoto con Chrome, evidencia del verificador)
98
+ 'Eyes {eyes}': 'Ojos {eyes}',
99
+ 'Eyes {eyes}': 'Ojos {eyes}',
100
+ '{host} · {chrome} · available': '{host} · {chrome} · disponible',
101
+ '{host} · {chrome} · not responding ({reason}, {time})': '{host} · {chrome} · no responde ({reason}, {time})',
102
+ 'Chrome {version} (CDP {port} over ssh)': 'Chrome {version} (CDP {port} por ssh)',
103
+ '{host} · none': '{host} · ninguno',
104
+ '{host} · free': '{host} · libre',
105
+ '{host} · in use by {task}': '{host} · en uso por {task}',
106
+ '{host} · in use by {task} ({n} queued)': '{host} · en uso por {task} ({n} en cola)',
107
+ '{host} · not responding': '{host} · no responde',
108
+ 'Eyes (verifier evidence):': 'Ojos (evidencia del verificador):',
109
+ ' · {task} · {check} {outcome} · capture {kib} KiB saved in {path}': ' · {task} · {check} {outcome} · captura {kib} KiB guardada en {path}',
110
+ ' · {task} · {check} {outcome}': ' · {task} · {check} {outcome}',
111
+ 'Task blocked · {task}': 'Tarea bloqueada · {task}',
112
+ 'Task {task} «{title}»': 'Tarea {task} «{title}»',
113
+ 'The verifier could not run the check: the task stays blocked and nothing is retried on its own.': 'El verificador no pudo correr el check: la tarea sigue bloqueada y nada se reintenta solo.',
114
+ 'Reason: {reason}': 'Motivo: {reason}',
115
+ 'Check: {check}': 'Check: {check}',
116
+ 'Blocked at {time}': 'Bloqueada a las {time}',
117
+ 'Retry': 'Reintentar',
118
+ 'The task stays blocked until the operator resumes it: nothing is retried on its own.': 'La tarea sigue bloqueada hasta que el operador la reanude: nada se reintenta solo.',
119
+ 'Blocked: {why}': 'Bloqueo: {why}',
120
+ 'Retry is the operator\'s (task.resume is never delegated to Comal): run it yourself, exactly: {command}': 'Reintentar es del operador (task.resume nunca se delega a Comal): ejecútalo tú, exactamente: {command}',
121
+ 'Enter shows the exact command · Esc: later': 'Enter muestra el comando exacto · Esc: más tarde',
122
+ 'Run it yourself, exactly: {command}': 'Ejecútalo tú, exactamente: {command}',
123
+ // Diseño de referencia (tarjeta de arranque, open-relay >= 2.12)
124
+ 'Attach a reference design (image)': 'Adjuntar un diseño (imagen)',
125
+ 'project.attach with the path of a local image; the lead and the agents read it in _relay/design/.': 'project.attach con la ruta de una imagen local; el líder y los agentes lo leen en _relay/design/.',
126
+ 'Design {file} ({size}) · the lead and the agents read it in `{mirror}`': 'Diseño {file} ({size}) · el líder y los agentes lo leen en `{mirror}`',
127
+ 'Checks {checks}': 'Checks {checks}',
128
+ 'Budget {budget} per swarm': 'Presupuesto {budget} por enjambre',
129
+ 'Excludes {list}': 'Excluye {list}',
130
+ 'Web UI http://127.0.0.1:{port}': 'UI web http://127.0.0.1:{port}',
131
+ 'The result always lands on {branch}; merging it is your decision.': 'El resultado siempre llega a {branch}; fusionarlo es tu decisión.',
132
+ 'Notes: {notes}': 'Notas: {notes}',
133
+ 'Create and start': 'Crear y arrancar',
134
+ 'Creates the project in relay-hub and starts its relayd (systemd user unit).': 'Crea el proyecto en relay-hub y arranca su relayd (unidad de systemd de usuario).',
135
+ 'Edit a field': 'Editar un campo',
136
+ 'Name, base, model, checks, budget or excludes.': 'Nombre, base, modelo, checks, presupuesto o exclusiones.',
137
+ 'Use the existing project {name}': 'Usar el proyecto existente {name}',
138
+ 'Already in relay-hub for this repo ({state}).': 'Ya está en relay-hub para este repo ({state}).',
139
+ 'Cancel': 'Cancelar',
140
+ 'Nothing is created.': 'No se crea nada.',
141
+ 'Enter creates and starts · e edits · ↑/↓ choose · Esc cancels': 'Enter crea y arranca · e edita · ↑/↓ elige · Esc cancela',
142
+ '{calls} calls · {tokens} tokens · {wall}': '{calls} llamadas · {tokens} tokens · {wall}',
143
+ '{n} h': '{n} h', '{n} min': '{n} min',
144
+ '(none)': '(ninguno)',
145
+ '… and {count} more': '… y {count} más',
146
+ // Confianza
147
+ 'Connect Comal to the project {project}': 'Conectar Comal con el proyecto {project}',
148
+ 'What you trust': 'Qué confías',
149
+ ' · The open-relay plugin bundled with Comal {version}, in this repo. It is asked again once per Comal version.': ' · El plugin open-relay incluido en Comal {version}, en este repo. Se vuelve a preguntar una vez por versión de Comal.',
150
+ ' Installed package {name}@{version} · integrity {integrity} (sha256 of its files).': ' Paquete instalado {name}@{version} · integridad {integrity} (sha256 de sus archivos).',
151
+ ' Development copy (not an installed package): code fingerprint {fingerprint}; asked again if the code changes.': ' Copia de desarrollo (no es un paquete instalado): huella del código {fingerprint}; se vuelve a preguntar si el código cambia.',
152
+ ' · The relayd of the project: {dir}': ' · El relayd del proyecto: {dir}',
153
+ 'What Comal may do (relayd delegate session, {days} days, signed «operator via comal»)': 'Qué puede hacer Comal (sesión delegada de relayd, {days} días, firmada «operador vía comal»)',
154
+ ' · read read the swarm: tasks, plans, messages, events (without asking)': ' · read leer el enjambre: tareas, planes, mensajes, eventos (sin preguntar)',
155
+ ' · converse answer the swarm questions (on a card of yours)': ' · converse responder las preguntas del enjambre (en una tarjeta tuya)',
156
+ ' · plans approve or reject plans (on a card of yours)': ' · plans aprobar o rechazar planes (en una tarjeta tuya)',
157
+ ' · decide record your decisions as ADRs (on a card of yours)': ' · decide registrar tus decisiones como ADR (en una tarjeta tuya)',
158
+ 'Never: starting swarms, creating projects, grants or budgets. Only you do that, with /relay; the model gets no tool for it.': 'Nunca: arrancar enjambres, crear proyectos, grants ni presupuestos. Eso sólo lo haces tú, con /relay; el modelo no recibe ninguna herramienta para ello.',
159
+ 'The token stays in {file} (0600), outside the repo; Comal never uses admin.token.': 'El token queda en {file} (0600), fuera del repo; Comal nunca usa admin.token.',
160
+ 'The previous delegation is no longer valid ({reason}): a new one is issued.': 'La delegación anterior ya no vale ({reason}): se emite una nueva.',
161
+ 'Trust and connect': 'Confiar y conectar',
162
+ 'Issues the delegation and enables the plugin for this repo.': 'Emite la delegación y activa el plugin para este repo.',
163
+ 'Not now': 'Ahora no',
164
+ 'Nothing is issued or enabled.': 'No se emite ni se activa nada.',
165
+ 'Enter trusts and connects · Esc: not now': 'Enter confía y conecta · Esc: ahora no',
166
+ // Objetivo y arranque
167
+ 'Swarm objective · {project}': 'Objetivo del enjambre · {project}',
168
+ 'Say it in plain language: what should change and how you will know it is right. The lead ({lead}) turns it into a plan that you approve; nothing runs before that.': 'Dilo en lenguaje natural: qué debe cambiar y cómo sabrás que está bien. El líder ({lead}) lo convierte en un plan que tú apruebas; nada corre antes.',
169
+ 'Start the swarm': 'Arrancar el enjambre',
170
+ 'Objective «{objective}»': 'Objetivo «{objective}»',
171
+ ' (drafted by the model in the conversation: review it)': ' (lo redactó el modelo en la conversación: revísalo)',
172
+ 'Lead {runtime} · {model}': 'Líder {runtime} · {model}',
173
+ 'Budget {budget}': 'Presupuesto {budget}',
174
+ 'Project {project} · the result lands on {branch}': 'Proyecto {project} · el resultado llega a {branch}',
175
+ 'The lead reads the repo and proposes a plan: it shows up here as a card.': 'El líder lee el repo y propone un plan: aparecerá aquí como tarjeta.',
176
+ 'Start': 'Arrancar',
177
+ 'swarm.start in the relayd of the project, with this objective and budget.': 'swarm.start en el relayd del proyecto, con este objetivo y presupuesto.',
178
+ 'Edit the objective': 'Editar el objetivo',
179
+ 'Enter starts · e edits the objective · Esc cancels': 'Enter arranca · e edita el objetivo · Esc cancela',
180
+ // Plan
181
+ 'Plan proposed · {swarm}': 'Plan propuesto · {swarm}',
182
+ 'From {role} · {tasks} tasks · {roles} new roles · agent text, untrusted': 'De {role} · {tasks} tareas · {roles} roles nuevos · texto del agente, no confiable',
183
+ 'Summary «{summary}»': 'Resumen «{summary}»',
184
+ 'Roles': 'Roles',
185
+ 'Tasks and how each one is checked': 'Tareas y cómo se comprueba cada una',
186
+ 'Retires (cancels and stops requiring) {count}': 'Retira (cancela y deja de exigir) {count}',
187
+ ' · ⚠ retires {count} tasks': ' · ⚠ retira {count} tareas',
188
+ 'On approval relayd cancels these {count} tasks and stops requiring them.': 'Al aprobar, relayd cancela estas {count} tareas y deja de exigirlas.',
189
+ ' files: {paths}': ' archivos: {paths}',
190
+ ' checked by: {checks}': ' se comprueba con: {checks}',
191
+ ' checked by: nothing declared (the task is not verified by a check)': ' se comprueba con: nada declarado (ningún check verifica la tarea)',
192
+ ' after: {deps}': ' después de: {deps}',
193
+ ' → {role} · up to {n} attempts': ' → {role} · hasta {n} intentos',
194
+ 'On approval relayd creates the roles, grants and tasks at once (digest {digest}…).': 'Al aprobar, relayd crea los roles, grants y tareas en el acto (digest {digest}…).',
195
+ '⚠ relayd: {problem}': '⚠ relayd: {problem}',
196
+ 'Approve': 'Aprobar',
197
+ 'plan.approve with this digest; the swarm starts working.': 'plan.approve con este digest; el enjambre se pone a trabajar.',
198
+ 'Reject with a reason': 'Rechazar con un motivo',
199
+ 'The lead is woken up with your reason and proposes again.': 'El líder despierta con tu motivo y vuelve a proponer.',
200
+ 'Later': 'Más tarde',
201
+ '/relay shows it again.': '/relay la vuelve a mostrar.',
202
+ 'Enter approves · r rejects with a reason · PgUp/PgDn · Esc later': 'Enter aprueba · r rechaza con motivo · PgUp/PgDn · Esc más tarde',
203
+ // Pregunta
204
+ 'Question from the swarm · {from}': 'Pregunta del enjambre · {from}',
205
+ 'Question (agent text, untrusted): «{question}»': 'Pregunta (texto del agente, no confiable): «{question}»',
206
+ 'Message (agent text, untrusted): «{body}»': 'Mensaje (texto del agente, no confiable): «{body}»',
207
+ 'Blocks task {task}{title}': 'Bloquea la tarea {task}{title}',
208
+ 'Agent recommendation: «{option}» — {why}': 'Recomendación del agente: «{option}» — {why}',
209
+ 'Scope {scope} · reversible: {reversible} · cost of waiting: {cost}': 'Alcance {scope} · reversible: {reversible} · coste de esperar: {cost}',
210
+ 'Your choice is recorded as an ADR in relayd and sent as the answer.': 'Tu elección queda como ADR en relayd y se envía como respuesta.',
211
+ 'yes': 'sí', 'no': 'no', 'not stated': 'no indicado',
212
+ 'recommended by the agent': 'la recomienda el agente',
213
+ 'Answer with my own text': 'Responder con mi texto',
214
+ 'msg.reply with the exact text you write.': 'msg.reply con el texto exacto que escribas.',
215
+ 'Answer': 'Responder',
216
+ 'Mark as read': 'Dar por leído',
217
+ 'msg.ack: it stops being pending; nothing is answered.': 'msg.ack: deja de estar pendiente; no se responde nada.',
218
+ '↑/↓ choose an option · Enter answers · Esc later': '↑/↓ elige una opción · Enter responde · Esc más tarde',
219
+ // Final
220
+ 'Result ready on {branch}': 'Resultado listo en {branch}',
221
+ 'The swarm finished with problems · {branch}': 'El enjambre terminó con problemas · {branch}',
222
+ 'Swarm {swarm} · «{objective}»': 'Enjambre {swarm} · «{objective}»',
223
+ 'Tasks: {done} done · {failed} failed · {other} other': 'Tareas: {done} hechas · {failed} fallidas · {other} otras',
224
+ 'Changes ({command}):': 'Cambios ({command}):',
225
+ ' (no changes on the branch yet)': ' (la rama aún no tiene cambios)',
226
+ 'Merging is your decision: git merge {branch} (review it first with git diff {base}..{branch}).': 'Fusionarlo es tu decisión: git merge {branch} (revísalo antes con git diff {base}..{branch}).',
227
+ 'Show the diff in the conversation': 'Ver el diff en la conversación',
228
+ 'git diff of the branch, read-only.': 'git diff de la rama, sólo lectura.',
229
+ 'Got it': 'Entendido',
230
+ 'Nothing is merged.': 'No se fusiona nada.',
231
+ // Estado
232
+ 'open-relay · {project}': 'open-relay · {project}',
233
+ 'relayd {state} (unit {unit}) · UI {web}': 'relayd {state} (unidad {unit}) · UI {web}',
234
+ 'Branch {branch} · repo {repo}': 'Rama {branch} · repo {repo}',
235
+ 'Comal connected · delegation of {principal} · caps {caps}': 'Comal conectado · delegación de {principal} · caps {caps}',
236
+ 'Comal is not connected to this project: {reason}': 'Comal no está conectado a este proyecto: {reason}',
237
+ 'Swarms': 'Enjambres',
238
+ ' · {id} «{objective}» · {done}/{total} tasks done{waiting}': ' · {id} «{objective}» · {done}/{total} tareas hechas{waiting}',
239
+ ' · {n} waiting for you': ' · {n} para ti',
240
+ ' (no swarms yet)': ' (todavía no hay enjambres)',
241
+ 'Review the plan {id}': 'Revisar el plan {id}',
242
+ 'Answer {from}': 'Responder a {from}',
243
+ 'New swarm': 'Nuevo enjambre',
244
+ 'Write an objective and start it (start card).': 'Escribe un objetivo y arráncalo (tarjeta de arranque).',
245
+ 'Live board (/swarm)': 'Tablero en vivo (/swarm)',
246
+ 'Tasks, questions and plans, updated by events.wait.': 'Tareas, preguntas y planes, al día con events.wait.',
247
+ 'Connect Comal': 'Conectar Comal',
248
+ 'Trust card: plugin and delegation for this project.': 'Tarjeta de confianza: plugin y delegación para este proyecto.',
249
+ 'Stop the project': 'Parar el proyecto',
250
+ 'Stops its relayd (the unit); swarms and branch stay.': 'Para su relayd (la unidad); los enjambres y la rama se quedan.',
251
+ 'Start the project': 'Arrancar el proyecto',
252
+ 'Starts its relayd again.': 'Vuelve a arrancar su relayd.',
253
+ 'Another project for this repo': 'Otro proyecto para este repo',
254
+ 'Proposal card for a new project.': 'Tarjeta de propuesta de un proyecto nuevo.',
255
+ 'Close': 'Cerrar',
256
+ 'relayd running': 'en marcha', 'relayd stopped': 'parado',
257
+ });
258
+
259
+ const ONE = 160;
260
+ const control = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g;
261
+ /** Una línea de texto ajeno, limpia y acotada. */
262
+ export const line = (value, limit = ONE) => { const text = String(value ?? '').replace(control, ' ').replace(/\s+/g, ' ').trim(); const chars = [...text]; return chars.length > limit ? chars.slice(0, limit).join('') + '…' : text; };
263
+ const short = (value, n = 12) => String(value || '').slice(0, n);
264
+ const num = value => { const n = Number(value); return Number.isFinite(n) ? n : 0; };
265
+ const tokens = value => { const n = num(value); return n >= 1e6 ? `${formatNumber(Math.round(n / 1e5) / 10)} M` : n >= 1e3 ? `${formatNumber(Math.round(n / 100) / 10)} k` : formatNumber(n); };
266
+ const wall = ms => { const minutes = Math.round(num(ms) / 60000); return minutes >= 60 && minutes % 60 === 0 ? t('{n} h', { n: minutes / 60 }) : minutes >= 90 ? t('{n} h', { n: formatNumber(Math.round(minutes / 6) / 10) }) : t('{n} min', { n: minutes }); };
267
+ export const budgetText = budget => t('{calls} calls · {tokens} tokens · {wall}', { calls: formatNumber(num(budget?.calls)), tokens: tokens(budget?.tokens), wall: wall(budget?.wall_time_ms ?? budget?.wall_ms) });
268
+ const listText = (items, limit = 6) => items.length > limit ? `${items.slice(0, limit).join(', ')} ${t('… and {count} more', { count: items.length - limit })}` : items.join(', ') || t('(none)');
269
+ const checkText = check => `${line(check.id, 40)}: ${line(String(check.description || '').replace(/`/g, ''), 90)}`;
270
+
271
+ // Formas de datos que el flujo (flow.mjs) pasa a estas tarjetas; solo metadatos, nunca píxeles:
272
+ // proposal.eyes contrato de relay-hub (open-relay v2.12, project.detect):
273
+ // { host:'win11', product:'Chrome/156.0.8078.73', available:true|false,
274
+ // checked_at:<ms>, error?:'ssh: …' } → línea «Ojos …»
275
+ // (también la forma previa { state:'available'|'down'|'none', version,
276
+ // port, reason, at }; si vienen las dos, gana state/version/reason/at)
277
+ // status.eyes igual, más task:'t-x' y queued:N; status.remote_hosts:['win11'] da el host
278
+ // design { name:'home.png', width:1440, height:900, bytes:131072 } (metadato de project.attach)
279
+ // canAttachDesign true si el hub admite project.attach (open-relay >= 2.12; si no, no se ofrece)
280
+ // evidence [{ task:'t-x', check:'c-web', outcome:'PASS', capture:{ bytes, path } }]
281
+ // blockedCard { task:{ id, title }, reason, check, at, retry:{ delegated:true } | { command:'…' } }
282
+ const pad2 = value => String(value).padStart(2, '0');
283
+ /** hh:mm local de un instante (ms, ISO o Date); el motivo de la línea de ojos va acotado. */
284
+ export const hhmm = value => {
285
+ const raw = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value;
286
+ const date = raw instanceof Date ? raw : new Date(raw ?? Date.now());
287
+ return Number.isFinite(date.getTime()) ? `${pad2(date.getHours())}:${pad2(date.getMinutes())}` : '--:--';
288
+ };
289
+ const EYES_NONE = new Set(['', 'none', 'absent', 'missing', 'off', 'disabled']);
290
+ const EYES_DOWN = new Set(['down', 'error', 'unavailable', 'no-response', 'no_response', 'not-responding', 'not_responding', 'timeout', 'failed']);
291
+ const EYES_BUSY = new Set(['busy', 'in-use', 'in_use', 'used', 'locked', 'leased']);
292
+ /** Estado de los ojos: state/status si vienen; si no, el booleano `available` de relay-hub. */
293
+ export const eyesState = eyes => String(eyes?.state ?? eyes?.status ?? (typeof eyes?.available === 'boolean' ? (eyes.available ? 'available' : 'down') : '')).trim().toLowerCase();
294
+ const eyesReason = eyes => eyes?.reason || eyes?.error || '';
295
+ const eyesVersion = eyes => eyes?.version || eyes?.chrome || String(eyes?.product || '').replace(/^[A-Za-z ]*Chrome\//, '') || '?';
296
+ const eyesHost = eyes => line(eyes?.host || eyes?.remote_host || eyes?.name || 'win11', 24);
297
+ const eyesDown = eyes => Boolean(eyesReason(eyes)) || EYES_DOWN.has(eyesState(eyes));
298
+ const chromeText = eyes => t('Chrome {version} (CDP {port} over ssh)', { version: line(eyesVersion(eyes), 40), port: num(eyes?.port) || 9222 });
299
+
300
+ /** Línea de ojos de la propuesta (proposal.eyes de project.detect): disponible, no responde o ninguno. */
301
+ export function eyesText(eyes) {
302
+ const host = eyesHost(eyes);
303
+ if (!eyes || EYES_NONE.has(eyesState(eyes))) return t('{host} · none', { host });
304
+ if (eyesDown(eyes)) return t('{host} · {chrome} · not responding ({reason}, {time})', { host, chrome: chromeText(eyes), reason: line(eyesReason(eyes) || '?', 80), time: hhmm(eyes.at ?? eyes.checked_at) });
305
+ return t('{host} · {chrome} · available', { host, chrome: chromeText(eyes) });
306
+ }
307
+
308
+ const firstHost = hosts => {
309
+ const list = Array.isArray(hosts) ? hosts : hosts ? [hosts] : [];
310
+ const first = list[0];
311
+ return typeof first === 'string' ? first : first?.host || first?.name || first?.id || '';
312
+ };
313
+ /** status.eyes (+ status.remote_hosts como host por omisión); null si el hub no informa de ojos. */
314
+ export const statusEyes = status => {
315
+ const eyes = status?.eyes;
316
+ if (!eyes) return null;
317
+ const host = firstHost(status?.remote_hosts);
318
+ return eyes.host || !host ? eyes : { ...eyes, host };
319
+ };
320
+
321
+ /** Línea de ojos del estado: libre, en uso por una tarea (N en cola) o no responde. */
322
+ export function eyesStatus(eyes) {
323
+ const host = eyesHost(eyes);
324
+ const state = eyesState(eyes), task = eyes?.task || eyes?.in_use_by || eyes?.lease;
325
+ if (EYES_NONE.has(state) && !task) return t('{host} · none', { host });
326
+ if (eyesDown(eyes)) return t('{host} · not responding', { host });
327
+ if (task || EYES_BUSY.has(state)) {
328
+ const queued = num(eyes?.queued);
329
+ return queued > 0 ? t('{host} · in use by {task} ({n} queued)', { host, task: line(task || '?', 60), n: queued }) : t('{host} · in use by {task}', { host, task: line(task || '?', 60) });
330
+ }
331
+ return t('{host} · free', { host });
332
+ }
333
+
334
+ /** Metadatos del diseño adjunto: archivo en el repo, espejo legible por los agentes y tamaño. */
335
+ export const designText = design => {
336
+ const name = line(design?.name || design?.file || '?', 80);
337
+ const width = num(design?.width), height = num(design?.height), kib = Math.round(num(design?.bytes ?? design?.size) / 1024);
338
+ return { file: `design/${name}`, mirror: `_relay/design/${name}`, size: `${width && height ? `${width}×${height}` : '?'}, ${kib > 0 ? `${kib} KiB` : '?'}` };
339
+ };
340
+
341
+ /** Propuesta de proyecto (project.detect) con todo resuelto. */
342
+ export function proposalCard({ proposal, existing = [], repoLabel }) {
343
+ const runtime = proposal.runtimes?.[0] || {};
344
+ const lines = [
345
+ t('Project {name} · {repo}', { name: proposal.name, repo: proposal.repo }),
346
+ proposal.branch_exists ? t('Work branch {branch} (already exists, it is used as is)', { branch: proposal.branch }) : t('Work branch {branch} from {base} ({commit}) · created now; your working tree is not touched', { branch: proposal.branch, base: proposal.base || 'HEAD', commit: short(proposal.base_commit, 8) }),
347
+ t('Agents {runtime} · {model} (lead: the same)', { runtime: runtime.runtime || 'comal', model: runtime.model || '?' }),
348
+ t('Eyes {eyes}', { eyes: eyesText(proposal.eyes) }),
349
+ t('Checks {checks}', { checks: (proposal.checks || []).map(checkText).join(' · ') || t('(none)') }),
350
+ t('Budget {budget} per swarm', { budget: budgetText(proposal.budget) }),
351
+ t('Excludes {list}', { list: listText((proposal.excludes || []).map(item => line(item, 60))) }),
352
+ ...(proposal.port ? [t('Web UI http://127.0.0.1:{port}', { port: proposal.port })] : []),
353
+ '',
354
+ t('The result always lands on {branch}; merging it is your decision.', { branch: proposal.branch }),
355
+ ];
356
+ const items = [
357
+ { id: 'create', label: t('Create and start'), description: t('Creates the project in relay-hub and starts its relayd (systemd user unit).') },
358
+ { id: 'edit', label: t('Edit a field'), description: t('Name, base, model, checks, budget or excludes.'), badge: 'e' },
359
+ ...existing.slice(0, 8).map(item => ({ id: `existing:${item.name}`, label: t('Use the existing project {name}', { name: item.name }), description: t('Already in relay-hub for this repo ({state}).', { state: t(item.active === 'active' ? 'relayd running' : 'relayd stopped') }) })),
360
+ { id: 'cancel', label: t('Cancel'), description: t('Nothing is created.') },
361
+ ];
362
+ return { title: t('New swarm project · {repo}', { repo: repoLabel || proposal.name }), description: lines.join('\n'), items, keys: { e: 'edit' }, hint: t('Enter creates and starts · e edits · ↑/↓ choose · Esc cancels') };
363
+ }
364
+
365
+ /** Confianza: plugin de esta versión de Comal + delegación con la que opera en el proyecto. */
366
+ export function trustCard({ project, controlDir, tokenFile, version, pkg, fingerprint, ttlDays = 30, reissue }) {
367
+ const lines = [
368
+ t('What you trust'),
369
+ t(' · The open-relay plugin bundled with Comal {version}, in this repo. It is asked again once per Comal version.', { version }),
370
+ pkg ? t(' Installed package {name}@{version} · integrity {integrity} (sha256 of its files).', { name: line(pkg.name, 60), version: line(pkg.version, 40), integrity: short(pkg.integrity, 16) }) : t(' Development copy (not an installed package): code fingerprint {fingerprint}; asked again if the code changes.', { fingerprint: short(fingerprint, 16) || '?' }),
371
+ t(' · The relayd of the project: {dir}', { dir: controlDir }),
372
+ '',
373
+ t('What Comal may do (relayd delegate session, {days} days, signed «operator via comal»)', { days: ttlDays }),
374
+ t(' · read read the swarm: tasks, plans, messages, events (without asking)'),
375
+ t(' · converse answer the swarm questions (on a card of yours)'),
376
+ t(' · plans approve or reject plans (on a card of yours)'),
377
+ t(' · decide record your decisions as ADRs (on a card of yours)'),
378
+ t('Never: starting swarms, creating projects, grants or budgets. Only you do that, with /relay; the model gets no tool for it.'),
379
+ '',
380
+ t('The token stays in {file} (0600), outside the repo; Comal never uses admin.token.', { file: tokenFile }),
381
+ ...(reissue ? [t('The previous delegation is no longer valid ({reason}): a new one is issued.', { reason: line(reissue, 120) })] : []),
382
+ ];
383
+ return { title: t('Connect Comal to the project {project}', { project }), description: lines.join('\n'), items: [
384
+ { id: 'trust', label: t('Trust and connect'), description: t('Issues the delegation and enables the plugin for this repo.') },
385
+ { id: 'cancel', label: t('Not now'), description: t('Nothing is issued or enabled.') },
386
+ ], hint: t('Enter trusts and connects · Esc: not now') };
387
+ }
388
+
389
+ export function objectiveInput({ project, lead }) {
390
+ return { title: t('Swarm objective · {project}', { project }), description: t('Say it in plain language: what should change and how you will know it is right. The lead ({lead}) turns it into a plan that you approve; nothing runs before that.', { lead }) };
391
+ }
392
+
393
+ export function startCard({ project, objective, lead, budget, branch, drafted = false, design, canAttachDesign = false, reviewer = null }) {
394
+ const attached = design ? designText(design) : null;
395
+ const lines = [
396
+ t('Objective «{objective}»', { objective: line(objective, 600) }),
397
+ ...(drafted ? [t(' (drafted by the model in the conversation: review it)')] : []),
398
+ t('Lead {runtime} · {model}', { runtime: lead.runtime || 'comal', model: lead.model || '?' }),
399
+ t('Budget {budget}', { budget: budgetText(budget) }),
400
+ t('Project {project} · the result lands on {branch}', { project, branch }),
401
+ ...(attached ? [t('Design {file} ({size}) · the lead and the agents read it in `{mirror}`', attached)] : []),
402
+ // Revisor de open-relay (F1–F8): mismo runtime y modelo que el líder, con los ojos del proyecto.
403
+ ...(reviewer ? [t('Reviewer {runtime} · {model} · eyes {eyes} · {share} % of the budget (if you start with it)', { runtime: line(reviewer.runtime || lead.runtime || 'comal', 20), model: line(reviewer.model || lead.model || '?', 40), eyes: line(reviewer.eyes || '—', 40), share: num(reviewer.share) || 40 })] : []),
404
+ '',
405
+ t('The lead reads the repo and proposes a plan: it shows up here as a card.'),
406
+ ];
407
+ return { title: t('Start the swarm'), description: lines.join('\n'), items: [
408
+ ...(reviewer ? [{ id: 'start-review', label: t('Start with an independent reviewer'), description: t('A second agent writes the success criteria, reviews the plan and judges each result with evidence before it closes. You accept the criteria and the plan.') }] : []),
409
+ { id: 'start', label: reviewer ? t('Start without reviewer') : t('Start'), description: t('swarm.start in the relayd of the project, with this objective and budget.') },
410
+ ...(canAttachDesign ? [{ id: 'design', label: t('Attach a reference design (image)'), description: t('project.attach with the path of a local image; the lead and the agents read it in _relay/design/.') }] : []),
411
+ { id: 'edit', label: t('Edit the objective'), badge: 'e' },
412
+ { id: 'cancel', label: t('Cancel') },
413
+ ], keys: { e: 'edit' }, hint: t('Enter starts · e edits the objective · Esc cancels') };
414
+ }
415
+
416
+ const criteriaOf = task => (Array.isArray(task?.criteria) ? task.criteria : []).map(item => typeof item === 'string' ? item : item?.id).filter(Boolean);
417
+ /** Plan del líder (plan.get) legible: roles, tareas y con qué check se comprueba cada una. */
418
+ export function planCard({ got, checks = [] }) {
419
+ const proposal = got?.proposal || {}, plan = proposal.plan || {}, tasks = Array.isArray(plan.tasks) ? plan.tasks : [], roles = Array.isArray(plan.roles) ? plan.roles : [];
420
+ const catalog = new Map((Array.isArray(checks) ? checks : []).map(check => [check?.id, check]));
421
+ // open-relay `plan.retire`: hijas de la tarea descompuesta que el plan cancela y deja de exigir.
422
+ // Va en la cabecera y antes de roles y tareas: la descripción se lee desde arriba con scroll.
423
+ const retire = (Array.isArray(plan.retire) ? plan.retire : []).filter(id => typeof id === 'string');
424
+ const lines = [t('From {role} · {tasks} tasks · {roles} new roles · agent text, untrusted', { role: line(proposal.proposer_role || '?', 60), tasks: tasks.length, roles: roles.length }) + (retire.length ? t(' · ⚠ retires {count} tasks', { count: retire.length }) : '')];
425
+ const summary = proposal.summary || plan.summary; if (summary) lines.push(t('Summary «{summary}»', { summary: line(summary, 400) }));
426
+ // Revisor (open-relay F3/F7): su respaldo u objeción, y los planes de corrección suyos.
427
+ const review = proposal.review;
428
+ if (review?.status === 'endorsed') lines.push(t('Reviewer: endorses the plan.'));
429
+ else if (review?.status === 'escalated') lines.push(t('Reviewer: objected twice (rounds exhausted), you decide over it: «{reasons}»', { reasons: line((review.reasons || []).join('; '), 400) }));
430
+ else if (review?.status === 'awaiting') lines.push(t('Reviewer: has not reviewed it yet.'));
431
+ if (plan.correction_of) lines.push(t('Correction plan of the reviewer for {task}: relayd approves it on its own.', { task: line(plan.correction_of, 60) }));
432
+ if (retire.length) {
433
+ lines.push('', t('Retires (cancels and stops requiring) {count}', { count: retire.length }));
434
+ for (const id of retire.slice(0, 20)) lines.push(` · ${line(id, 60)}`);
435
+ if (retire.length > 20) lines.push(' ' + t('… and {count} more', { count: retire.length - 20 }));
436
+ }
437
+ if (roles.length) {
438
+ lines.push('', t('Roles'));
439
+ for (const role of roles.slice(0, 12)) lines.push(` · ${line(role.id, 60)}${role.name && role.name !== role.id ? ` (${line(role.name, 40)})` : ''} · ${line(role.runtime || role.bind?.runtime || 'comal', 20)}${role.model || role.bind?.model ? ` · ${line(role.model || role.bind.model, 40)}` : ''}`);
440
+ if (roles.length > 12) lines.push(' ' + t('… and {count} more', { count: roles.length - 12 }));
441
+ }
442
+ lines.push('', t('Tasks and how each one is checked'));
443
+ tasks.slice(0, 20).forEach((task, index) => {
444
+ lines.push(` ${index + 1}. ${line(task.id, 60)}${task.title ? ` «${line(task.title, 120)}»` : ''}${t(' → {role} · up to {n} attempts', { role: line(task.role || '?', 60), n: num(task.max_attempts) || 1 })}`);
445
+ const paths = Array.isArray(task.paths) ? task.paths.map(item => line(typeof item === 'string' ? item : item?.path, 80)).filter(Boolean) : [];
446
+ if (paths.length) lines.push(t(' files: {paths}', { paths: listText(paths, 6) }));
447
+ const criteria = criteriaOf(task);
448
+ lines.push(criteria.length ? t(' checked by: {checks}', { checks: criteria.map(id => catalog.get(id)?.description ? `${line(id, 40)} (${line(String(catalog.get(id).description).replace(/`/g, ''), 70)})` : line(id, 40)).join(', ') }) : t(' checked by: nothing declared (the task is not verified by a check)'));
449
+ if (Array.isArray(task.deps) && task.deps.length) lines.push(t(' after: {deps}', { deps: task.deps.map(dep => line(dep, 40)).join(', ') }));
450
+ if (Array.isArray(task.covers) && task.covers.length) lines.push(t(' covers: {criteria}', { criteria: listText(task.covers.map(id => line(id, 40)), 8) }));
451
+ if (Array.isArray(task.corrects) && task.corrects.length) lines.push(t(' corrects: {objections}', { objections: listText(task.corrects.map(id => line(id, 40)), 8) }));
452
+ });
453
+ if (tasks.length > 20) lines.push(' ' + t('… and {count} more', { count: tasks.length - 20 }));
454
+ lines.push('', t('On approval relayd creates the roles, grants and tasks at once (digest {digest}…).', { digest: short(proposal.digest, 12) }));
455
+ if (retire.length) lines.push(t('On approval relayd cancels these {count} tasks and stops requiring them.', { count: retire.length }));
456
+ for (const problem of (Array.isArray(got?.problems) ? got.problems : []).slice(0, 6)) lines.push(t('⚠ relayd: {problem}', { problem: line(problem, 200) }));
457
+ return { title: t('Plan proposed · {swarm}', { swarm: line(proposal.swarm_id || '?', 60) }), description: lines.join('\n'), items: [
458
+ { id: 'approve', label: t('Approve'), description: t('plan.approve with this digest; the swarm starts working.') },
459
+ { id: 'reject', label: t('Reject with a reason'), description: t('The lead is woken up with your reason and proposes again.'), badge: 'r' },
460
+ { id: 'later', label: t('Later'), description: t('/relay shows it again.') },
461
+ ], keys: { r: 'reject' }, hint: t('Enter approves · r rejects with a reason · PgUp/PgDn · Esc later') };
462
+ }
463
+
464
+ /** Pregunta del enjambre: opciones con flechas (estructurada → ADR) o respuesta libre. */
465
+ export function questionCard({ message, block, task }) {
466
+ const from = message?.from?.role ? line(message.from.role, 60) : line(message?.from?.kind || '?', 40);
467
+ const lines = [];
468
+ if (block) {
469
+ lines.push(t('Question (agent text, untrusted): «{question}»', { question: line(block.question, 300) }));
470
+ if (message?.body && line(message.body, 300) !== line(block.question, 300)) lines.push(t('Message (agent text, untrusted): «{body}»', { body: line(message.body, 500) }));
471
+ } else lines.push(t('Message (agent text, untrusted): «{body}»', { body: line(message?.body || '', 900) }));
472
+ if (task) lines.push(t('Blocks task {task}{title}', { task: line(task.id, 60), title: task.title ? ` «${line(task.title, 100)}»` : '' }));
473
+ if (block?.recommendation) lines.push(t('Agent recommendation: «{option}» — {why}', { option: line(block.recommendation.option, 120), why: line(block.recommendation.why, 300) }));
474
+ if (block) lines.push(t('Scope {scope} · reversible: {reversible} · cost of waiting: {cost}', { scope: line(block.scope, 20), reversible: t(block.reversible ? 'yes' : 'no'), cost: block.cost_of_waiting ? line(block.cost_of_waiting, 200) : t('not stated') }), '', t('Your choice is recorded as an ADR in relayd and sent as the answer.'));
475
+ const items = block ? block.options.map((option, index) => ({ id: `option:${index}`, label: line(option.label, 120), description: option.consequences ? line(option.consequences, 240) : '', badge: option.label === block.recommendation?.option ? t('recommended by the agent') : '' })) : [];
476
+ items.push({ id: 'reply', label: block ? t('Answer with my own text') : t('Answer'), description: t('msg.reply with the exact text you write.') });
477
+ if (!block) items.push({ id: 'ack', label: t('Mark as read'), description: t('msg.ack: it stops being pending; nothing is answered.') });
478
+ items.push({ id: 'later', label: t('Later'), description: t('/relay shows it again.') });
479
+ return { title: t('Question from the swarm · {from}', { from }), description: lines.join('\n'), items, hint: t('↑/↓ choose an option · Enter answers · Esc later') };
480
+ }
481
+
482
+ /** ¿Esperan al humano estos criterios? La versión respaldada por el líder o escalada tras sus
483
+ * objeciones y, a medio swarm (open-relay v2.31), el borrador del revisor mientras otra versión
484
+ * aceptada sigue vigente (`accepted` en criteria.get; `renegotiating` en la foto del panel). */
485
+ export function criteriaWaiting(got) {
486
+ const status = got?.status;
487
+ return status === 'endorsed' || status === 'escalated' || (status === 'draft' && Boolean(got?.accepted || got?.renegotiating));
488
+ }
489
+
490
+ /** open-relay v2.33: avisos por criterio, en el orden de la versión. `beyond` (pide más que el
491
+ * enunciado, declarado), `unfound` (una cita que relayd no encontró en su fuente) y `uncited`
492
+ * (un criterio de juicio sin cita). `uncited` avisa si relayd comprueba citas (`checked`, de
493
+ * `criteria.get.basis_checked`) o si la versión usa alguna: un relayd anterior a v2.33 no las
494
+ * manda y la tarjeta queda como siempre. Un criterio `check` sin cita no avisa. */
495
+ export function criteriaWarnings(items = [], { checked = false } = {}) {
496
+ const list = Array.isArray(items) ? items : [], usesBasis = checked === true || list.some(item => Array.isArray(item?.basis) && item.basis.length);
497
+ const out = new Map();
498
+ for (const item of list) {
499
+ const basis = Array.isArray(item?.basis) ? item.basis : [], found = [];
500
+ if (typeof item?.beyond === 'string' && item.beyond.trim()) found.push({ kind: 'beyond', text: item.beyond });
501
+ for (const quote of basis) if (quote?.found === false) found.push({ kind: 'unfound', source: quote.source, quote: quote.quote });
502
+ if (usesBasis && !basis.length && item?.kind === 'judgment') found.push({ kind: 'uncited' });
503
+ if (found.length) out.set(item?.id || '?', found);
504
+ }
505
+ return out;
506
+ }
507
+
508
+ function warningLines(id, warnings) {
509
+ return warnings.map(warning => warning.kind === 'beyond' ? t(' ⚠ {id}: asks more than the statement: «{beyond}»', { id: line(id, 40), beyond: line(warning.text || '', 300) })
510
+ : warning.kind === 'unfound' ? t(' ⚠ {id}: the quote is not in {source}: «{quote}»', { id: line(id, 40), source: line(warning.source || '?', 80), quote: line(warning.quote || '', 200) })
511
+ : t(' ○ {id}: no quote from the statement', { id: line(id, 40) }));
512
+ }
513
+
514
+ /** Resumen corto de los avisos de un criterio (texto de la tarjeta remota). */
515
+ export function warningSummary(warnings = []) {
516
+ return warnings.map(warning => warning.kind === 'beyond' ? `${t('asks more than the statement')}: ${warning.text || ''}` : warning.kind === 'unfound' ? t('quote not found') : t('no quote')).join('; ');
517
+ }
518
+
519
+ /** Confirmación de aceptar una versión con avisos: por omisión vuelve sin aceptar y ninguna letra
520
+ * confirma (la tarjeta de criterios se abre sola y se puede estar escribiendo). */
521
+ export function criteriaConfirmCard({ got, swarm, warnings }) {
522
+ const list = warnings instanceof Map ? warnings : criteriaWarnings(got?.current?.items, { checked: got?.basis_checked === true });
523
+ return {
524
+ title: t('Accept with {n} warnings? · {swarm}', { n: list.size, swarm: line(swarm || got?.swarm_id || '?', 60) }),
525
+ description: [...[...list].slice(0, 24).flatMap(([id, items]) => warningLines(id, items)), '', t('The lead plans against these criteria and the reviewer judges with them, warnings included.')].join('\n'),
526
+ items: [
527
+ { id: 'back', label: t('Go back without accepting'), description: t('/relay shows the criteria again.') },
528
+ { id: 'accept', label: t('Accept anyway'), description: t('criteria.accept with this digest despite the warnings above.') },
529
+ ],
530
+ hint: t('Enter goes back · ↓ then Enter accepts'),
531
+ };
532
+ }
533
+
534
+ /** Criterios de éxito del swarm (criteria.get) que esperan al humano (criteriaWaiting). Se
535
+ * acepta el digest exacto que se muestra. */
536
+ export function criteriaCard({ got, swarm }) {
537
+ const current = got?.current || {}, items = Array.isArray(current.items) ? current.items : [];
538
+ const lines = [t('Version {version} by {author} · agent text, untrusted', { version: num(current.version), author: line(current.author || '?', 60) })];
539
+ if (got?.accepted) lines.push(t('Mid-swarm renegotiation: v{version} stays in force until you accept this one; reviews in progress then restart with it and approved tasks stay done.', { version: num(got.accepted.version) }));
540
+ if (got?.status === 'endorsed') lines.push(t('The lead endorses it: the reviewer wrote it, the lead agrees it is feasible.'));
541
+ if (got?.status === 'escalated') {
542
+ lines.push(t('The lead objected {n} times (rounds exhausted): you decide over the objection.', { n: num(got.rounds) }));
543
+ for (const objection of (Array.isArray(got.objections) ? got.objections : []).slice(-2)) lines.push(t('Lead objection (v{version}): «{reasons}»', { version: num(objection?.version), reasons: line((objection?.reasons || []).join('; '), 400) }));
544
+ }
545
+ const show = item => {
546
+ const stages = (Array.isArray(item?.stage) ? item.stage : []).map(stage => line(stage, 12)).join(',');
547
+ const out = [` · ${line(item?.id || '?', 40)}${item?.check ? ` (${line(item.check, 40)})` : ''}${stages ? ` [${stages}]` : ''}: ${line(item?.statement || '', 240)}`];
548
+ const evidence = (Array.isArray(item?.evidence_required) ? item.evidence_required : []).map(value => line(value, 30));
549
+ const viewports = (Array.isArray(item?.viewports) ? item.viewports : []).map(value => line(typeof value === 'string' ? value : `${num(value?.width)}×${num(value?.height)}`, 20));
550
+ if (evidence.length || viewports.length) out.push(t(' evidence: {evidence}{viewports}', { evidence: evidence.join(', ') || '—', viewports: viewports.length ? t(' · viewports {viewports}', { viewports: viewports.join(', ') }) : '' }));
551
+ for (const quote of (Array.isArray(item?.basis) ? item.basis : []).slice(0, 3)) out.push(t(quote?.found === false ? ' quote ({source}, not found): «{quote}»' : ' quote ({source}): «{quote}»', { source: line(quote?.source || '?', 80), quote: line(quote?.quote || '', 200) }));
552
+ if (typeof item?.beyond === 'string' && item.beyond.trim()) out.push(t(' beyond the statement: «{beyond}»', { beyond: line(item.beyond, 300) }));
553
+ return out;
554
+ };
555
+ const warnings = criteriaWarnings(items, { checked: got?.basis_checked === true });
556
+ if (warnings.size) lines.push('', t('Review before accepting ({n})', { n: warnings.size }), ...[...warnings].slice(0, 24).flatMap(([id, list]) => warningLines(id, list)));
557
+ const checks = items.filter(item => item?.kind !== 'judgment'), judged = items.filter(item => item?.kind === 'judgment');
558
+ if (checks.length) lines.push('', t('Automatic (a check proves them)'), ...checks.slice(0, 24).flatMap(show));
559
+ if (judged.length) lines.push('', t('Judgment (the reviewer decides with evidence)'), ...judged.slice(0, 24).flatMap(show));
560
+ const out = (Array.isArray(current.out_of_scope) ? current.out_of_scope : []).slice(0, 12).map(value => ` · ${line(value, 200)}`);
561
+ if (out.length) lines.push('', t('Out of scope'), ...out);
562
+ lines.push('', t('Accepting fixes this version (digest {digest}…): the lead plans against it and the reviewer judges with it.', { digest: short(current.digest, 12) }));
563
+ const title = t('Success criteria · {swarm}', { swarm: line(swarm || got?.swarm_id || '?', 60) });
564
+ const reject = { id: 'reject', label: t('Reject with a reason'), description: t('The reviewer is woken up with your reason and proposes a new version.'), badge: 'r' };
565
+ const later = { id: 'later', label: t('Later'), description: t('/relay shows it again.') };
566
+ // Con avisos, Enter rechaza y ninguna letra acepta: aceptar es bajar a esa opción y confirmar.
567
+ if (warnings.size) return { title, description: lines.join('\n'), items: [
568
+ reject,
569
+ { id: 'accept', label: t('Accept with {n} warnings', { n: warnings.size }), description: t('Asks for confirmation first.') },
570
+ later,
571
+ ], keys: { r: 'reject' }, hint: t('Enter rejects with a reason · accepting asks for confirmation · PgUp/PgDn · Esc later') };
572
+ return { title, description: lines.join('\n'), items: [
573
+ { id: 'accept', label: t('Accept'), description: t('criteria.accept with this digest; the lead can propose the plan.') },
574
+ reject,
575
+ later,
576
+ ], keys: { r: 'reject' }, hint: t('Enter accepts · r rejects with a reason · PgUp/PgDn · Esc later') };
577
+ }
578
+
579
+ /** Revisión escalada (task.get): rondas agotadas o revisión sin veredicto, con el candidato
580
+ * esperando. Aceptar sobre la objeción (review.override) es decisión del humano, con motivo. */
581
+ export function escalationCard({ task }) {
582
+ const review = task?.review || {}, last = review.last || null, pending = review.pending || {};
583
+ const id = line(task?.id || '?', 60), kind = t({ task: 'task review', integration: 'integration review', final: 'final review' }[pending.kind] || 'task review');
584
+ const lines = [t('Task {task}{title} · {kind}', { task: id, title: task?.title ? ` «${line(task.title, 120)}»` : '', kind })];
585
+ lines.push(last?.effective === 'object' ? t('The reviewer objected {n} times (rounds exhausted): the candidate waits for you.', { n: num(review.rounds) }) : t('The review ended without a verdict (reviewer unavailable): the candidate waits for you.'));
586
+ if (last) {
587
+ lines.push('', t('Last verdict (reviewer text, untrusted):'));
588
+ for (const item of (Array.isArray(last.criteria) ? last.criteria : []).filter(item => item?.result === 'fail').slice(0, 8)) lines.push(t(' · {id} FAIL: {why}', { id: line(item.id, 40), why: line(item.rationale || '?', 240) }));
589
+ for (const item of (Array.isArray(last.corrections) ? last.corrections : []).slice(0, 8)) lines.push(t(' · correction {id} [{severity}{fixBy}]: {what}{where}', { id: line(item?.id || '?', 20), severity: line(item?.severity || '?', 10), fixBy: item?.fix_by ? `, ${line(item.fix_by, 12)}` : '', what: line(item?.what || '', 240), where: Array.isArray(item?.where) && item.where.length ? t(' (in {where})', { where: listText(item.where.map(value => line(value, 80)), 4) }) : '' }));
590
+ if (last.notes) lines.push(t('Notes: «{notes}»', { notes: line(last.notes, 300) }));
591
+ }
592
+ lines.push('', t('Accepting over the reviewer is recorded in relayd as your decision, with your reason. An integration is then applied to the branch; the final review closes the swarm.'), t('To cancel or revise the task: relay task cancel --task {task} (or task revise), in your terminal.', { task: id }));
593
+ // Gate 3: sin veredicto (revisor caído, intento perdido) lo primero es otra revisión del mismo
594
+ // candidato; con las rondas agotadas, decidir sobre la objeción, o darle una ronda más.
595
+ const objected = last?.effective === 'object';
596
+ const override = { id: 'override', label: t('Accept over the objection'), description: t('review.override with your reason (recorded in relayd).') };
597
+ const retry = { id: 'retry', label: t(objected ? 'One more round' : 'Ask for another review'), description: t('review.retry: the same candidate goes back to the reviewer, with your reason (recorded in relayd).') };
598
+ return { title: t('Review escalated · {task}', { task: id }), description: lines.join('\n'), items: [
599
+ ...(objected ? [override, retry] : [retry, override]),
600
+ { id: 'later', label: t('Later'), description: t('/relay shows it again.') },
601
+ ], hint: t('Enter chooses · ↑/↓ · Esc later') };
602
+ }
603
+
604
+ /** Final del enjambre: resumen, cambios en la rama y la fusión como decisión del humano.
605
+ * `evidence` son metadatos de attestation.list/captura (tarea, check, resultado, bytes y ruta):
606
+ * la tarjeta nunca lleva los píxeles, solo dónde quedó la captura para compararla con el diseño. */
607
+ export function finishCard({ swarm, objective, branch, counts, diffStat = '', base, ok = true, diffCommand, evidence = [], review = null }) {
608
+ const evidenceLines = (Array.isArray(evidence) ? evidence : []).slice(0, 8).map(item => {
609
+ const capture = item?.capture || {}, kib = Math.round(num(capture.bytes ?? item?.capture_bytes) / 1024);
610
+ const values = { task: line(item?.task || item?.task_id || '?', 60), check: line(item?.check || item?.criterion || '?', 40), outcome: line(item?.outcome || item?.result || 'PASS', 24) };
611
+ const saved = capture.path || capture.file || item?.capture_path;
612
+ return kib > 0 && saved ? t(' · {task} · {check} {outcome} · capture {kib} KiB saved in {path}', { ...values, kib, path: line(saved, 200) }) : t(' · {task} · {check} {outcome}', values);
613
+ });
614
+ const lines = [
615
+ t('Swarm {swarm} · «{objective}»', { swarm: line(swarm, 60), objective: line(objective || '', 300) }),
616
+ t('Tasks: {done} done · {failed} failed · {other} other', { done: counts.done || 0, failed: counts.failed || 0, other: counts.other || 0 }),
617
+ ...(evidenceLines.length ? ['', t('Eyes (verifier evidence):'), ...evidenceLines] : []),
618
+ ...reviewLines(review),
619
+ '',
620
+ t('Changes ({command}):', { command: diffCommand || `git diff --stat ${short(base, 8)}..${branch}` }),
621
+ ...(diffStat.trim() ? diffStat.trimEnd().split('\n').slice(0, 30).map(text => ' ' + line(text, 200)) : [t(' (no changes on the branch yet)')]),
622
+ '',
623
+ t('Merging is your decision: git merge {branch} (review it first with git diff {base}..{branch}).', { branch, base: short(base, 8) }),
624
+ ];
625
+ return { title: t(ok ? 'Result ready on {branch}' : 'The swarm finished with problems · {branch}', { branch }), description: lines.join('\n'), items: [
626
+ { id: 'diff', label: t('Show the diff in the conversation'), description: t('git diff of the branch, read-only.') },
627
+ { id: 'ok', label: t('Got it'), description: t('Nothing is merged.') },
628
+ ] };
629
+ }
630
+
631
+ /** Veredicto final del revisor (task.get de la raíz, `review`): quién aceptó y la evidencia,
632
+ * solo metadatos (ruta bajo _review/, tipo, KiB y hash); nunca los archivos ni los píxeles. */
633
+ function reviewLines(review) {
634
+ if (!review || typeof review !== 'object') return [];
635
+ const last = review.last || {}, pass = (Array.isArray(last.criteria) ? last.criteria : []).filter(item => item?.result === 'pass').length;
636
+ const head = review.overridden ? t('Final review: accepted by you over the reviewer («{reason}»).', { reason: line(review.overridden, 200) }) : last.effective === 'approve' ? t('Final review: approved by the reviewer ({n} criteria pass).', { n: pass }) : null;
637
+ if (!head) return [];
638
+ const files = (Array.isArray(last.evidence) ? last.evidence : []).slice(0, 8).map(item => t(' · {path} · {media} · {kib} KiB · b3 {hash}…', { path: line(item?.path || '?', 120), media: line(item?.media || '?', 40), kib: Math.max(1, Math.round(num(item?.bytes) / 1024)), hash: short(item?.blake3, 12) }));
639
+ return ['', head, ...(files.length ? [t('Reviewer evidence (metadata; the files stay in relayd):'), ...files] : [])];
640
+ }
641
+
642
+ const DONE = new Set(['done']), FAILED = new Set(['failed', 'cancelled']);
643
+ /** Conteo de tareas por estado final. */
644
+ export function taskCounts(tasks = []) {
645
+ const counts = { done: 0, failed: 0, other: 0 };
646
+ for (const task of tasks) counts[DONE.has(task.state) ? 'done' : FAILED.has(task.state) ? 'failed' : 'other']++;
647
+ return counts;
648
+ }
649
+
650
+ /** Estado del proyecto ligado y lo que se puede hacer (todo control humano). */
651
+ export function statusCard({ project, status = {}, connected, swarms = [], pending = [] }) {
652
+ const unit = status.unit || {}, active = unit.active === 'active', eyes = statusEyes(status);
653
+ const lines = [
654
+ t('relayd {state} (unit {unit}) · UI {web}', { state: t(active ? 'relayd running' : 'relayd stopped'), unit: line(unit.unit || `open-relay-${project}`, 60), web: line(unit.web ? (/^https?:/.test(unit.web) ? unit.web : `http://${unit.web}`) : '?', 60) }),
655
+ t('Branch {branch} · repo {repo}', { branch: line(status.branch || '?', 80), repo: line(status.repo || '?', 200) }),
656
+ ...(eyes ? [t('Eyes {eyes}', { eyes: eyesStatus(eyes) })] : []),
657
+ connected?.ok ? t('Comal connected · delegation of {principal} · caps {caps}', { principal: line(connected.principal || '?', 40), caps: (connected.caps || []).join(',') }) : t('Comal is not connected to this project: {reason}', { reason: line(connected?.reason || '?', 200) }),
658
+ '', t('Swarms'),
659
+ ];
660
+ for (const swarm of swarms.slice(0, 8)) {
661
+ const counts = taskCounts(swarm.tasks || []), waiting = (swarm.pending || []).filter(item => item.kind === 'question').length + (swarm.plans?.length || 0);
662
+ lines.push(t(' · {id} «{objective}» · {done}/{total} tasks done{waiting}', { id: line(swarm.id, 60), objective: line(swarm.objective || '', 80), done: counts.done, total: (swarm.tasks || []).length, waiting: waiting ? t(' · {n} waiting for you', { n: waiting }) : '' }));
663
+ }
664
+ if (!swarms.length) lines.push(t(' (no swarms yet)'));
665
+ const items = [...pending];
666
+ if (active && connected?.ok) items.push({ id: 'swarm', label: t('New swarm'), description: t('Write an objective and start it (start card).') });
667
+ if (active && connected?.ok) items.push({ id: 'board', label: t('Live board (/swarm)'), description: t('Tasks, questions and plans, updated by events.wait.') });
668
+ if (active && !connected?.ok) items.push({ id: 'trust', label: t('Connect Comal'), description: t('Trust card: plugin and delegation for this project.') });
669
+ items.push(active ? { id: 'stop', label: t('Stop the project'), description: t('Stops its relayd (the unit); swarms and branch stay.') } : { id: 'start', label: t('Start the project'), description: t('Starts its relayd again.') });
670
+ items.push({ id: 'new', label: t('Another project for this repo'), description: t('Proposal card for a new project.') }, { id: 'close', label: t('Close') });
671
+ return { title: t('open-relay · {project}', { project }), description: lines.join('\n'), items };
672
+ }
673
+
674
+ /** Tarea bloqueada porque el check no corrió (host remoto no disponible, LEASE_TIMEOUT…).
675
+ * El bloqueo nunca se reintenta solo: el humano elige «Reintentar» —por la sesión delegada
676
+ * si la capacidad lo permite (retry.delegated) o con el comando exacto (retry.command)— o
677
+ * «Más tarde». `reason` es el motivo tal cual lo dio el verificador (acotado con line()). */
678
+ /** Tarea bloqueada que espera al operador (el verificador no pudo correr el check, configuración,
679
+ * entorno, presupuesto o reintentos de infraestructura agotados). task.resume NUNCA es delegable
680
+ * en open-relay: «Reintentar» enseña el comando exacto para la terminal del humano, sin llamar a nada. */
681
+ export function blockedCard({ task, reason, check, at, retry, why }) {
682
+ const id = line(task?.id || '?', 60), title = line(task?.title || '', 120);
683
+ const command = line(retry?.command || `relay task resume ${task?.id || '?'}`, 300);
684
+ const lines = [
685
+ t('Task {task} «{title}»', { task: id, title: title || '?' }),
686
+ check ? t('The verifier could not run the check: the task stays blocked and nothing is retried on its own.') : t('The task stays blocked until the operator resumes it: nothing is retried on its own.'),
687
+ ...(why ? [t('Blocked: {why}', { why: line(why, 80) })] : []),
688
+ '',
689
+ t('Reason: {reason}', { reason: line(reason || '?', 200) }),
690
+ ...(check ? [t('Check: {check}', { check: line(check, 40) })] : []),
691
+ ...(at ? [t('Blocked at {time}', { time: hhmm(at) })] : []),
692
+ ];
693
+ return {
694
+ title: t('Task blocked · {task}', { task: id }),
695
+ description: lines.join('\n'),
696
+ items: [
697
+ { id: 'retry', label: t('Retry'), description: t('Retry is the operator\'s (task.resume is never delegated to Comal): run it yourself, exactly: {command}', { command }) },
698
+ { id: 'later', label: t('Later'), description: t('/relay shows it again.') },
699
+ ],
700
+ hint: t('Enter shows the exact command · Esc: later'),
701
+ };
702
+ }