@ingeniomaps/cauce 0.68.0 → 0.70.0

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 (48) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +13 -6
  3. package/automatization/AGENTS.md +1 -1
  4. package/automatization/runners/antigravity/rules/cauce.md +1 -1
  5. package/automatization/runners/claude/CLAUDE.md +1 -1
  6. package/automatization/runners/codex/AGENTS.md +1 -1
  7. package/automatization/runners/gemini/GEMINI.md +1 -1
  8. package/automatization/shared/skills/autobuild/SKILL.md +1 -1
  9. package/automatization/workflows/autobuild.js +44 -8
  10. package/engine/cli/archive.js +87 -0
  11. package/engine/cli/args.js +7 -2
  12. package/engine/cli/catalog.js +9 -1
  13. package/engine/cli/claims.js +125 -0
  14. package/engine/cli/ops.js +17 -4
  15. package/engine/cli/planning.js +158 -102
  16. package/engine/cli/worktree.js +89 -0
  17. package/engine/core/ownership.js +16 -5
  18. package/engine/core/repos.js +67 -0
  19. package/engine/hooks/files.js +3 -2
  20. package/engine/planning/adoption.js +1 -1
  21. package/engine/planning/claims.js +153 -0
  22. package/engine/planning/contracts.js +43 -202
  23. package/engine/planning/parser.js +89 -33
  24. package/engine/planning/recurring.js +148 -0
  25. package/engine/planning/state.js +39 -6
  26. package/engine/planning/structure.js +220 -0
  27. package/package.json +1 -1
  28. package/template/.gitattributes +19 -0
  29. package/template/AGENTS.md +54 -5
  30. package/template/Makefile +4 -1
  31. package/template/automatization/AGENTS.md +1 -1
  32. package/template/gitignore +9 -0
  33. package/template/planning/BACKLOG.md +5 -0
  34. package/template/planning/FLOW.md +3 -1
  35. package/template/planning/PROTOCOL.md +25 -8
  36. package/template/planning/README.md +4 -3
  37. package/template/planning/RECURRING.md +77 -0
  38. package/template/planning/adr/system/OPS-001-planificacion-como-fuente-de-verdad.md +3 -2
  39. package/template/planning/business-rules/system/BR-OPS-001-una-sola-tarea-activa.md +7 -4
  40. package/template/planning/business-rules/system/BR-OPS-005-una-tarea-un-runner.md +44 -0
  41. package/template/planning/claims/README.md +70 -0
  42. package/template/planning/delivery/README.md +1 -0
  43. package/template/planning/delivery/multi-repo.md +11 -0
  44. package/template/planning/delivery/teamwork.md +162 -0
  45. package/template/planning/done/README.md +41 -0
  46. package/template/planning/wip/README.md +49 -0
  47. package/template/planning/DONE.md +0 -13
  48. package/template/planning/WIP.md +0 -22
package/CHANGELOG.md CHANGED
@@ -14,6 +14,165 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.70.0] - 2026-09-08
18
+
19
+ ### Agregado
20
+
21
+ - **`planning/claims/`: quién tomó qué, para que dos runners no construyan lo mismo.** Un archivo por tarea
22
+ tomada, con el slug de la tarea como nombre: `ops claim planning <tarea>` lo crea y `ops release` lo borra.
23
+ `ops context` deja de ofrecer una tarea con reclamo ajeno —antes le entregaba la misma a los dos y ninguno
24
+ se enteraba—, nombra quién la tiene y devuelve antes lo que vos reclamaste que lo que está libre.
25
+
26
+ Es un archivo por tarea y no uno por persona a propósito: así dos personas en tareas distintas no tocan
27
+ nunca el mismo archivo, y dos que toman la misma chocan en git, que es donde el choque significa algo.
28
+ `check` rechaza el reclamo que nombra una tarea que no existe, avisa a los tres días de tomada y avisa
29
+ cuando hay dos reclamos sobre el mismo `service:` — avisa y no frena, porque frenar serializaría a un
30
+ equipo entero sobre un servicio.
31
+
32
+ El reclamo distingue `owner` —la persona, a quién preguntarle— de `runner` —el agente que la hace—, y
33
+ lo segundo es lo que decide de quién es una tarea. Con varios agentes en una máquina la persona es la
34
+ misma y el árbol de trabajo no: sin esa distinción, el segundo agente tomaría por propia la tarea del
35
+ primero. Se crea con exclusión —el archivo se abre en modo exclusivo, así que dos reclamos simultáneos
36
+ no se pisan— y un runner lleva una tarea a la vez.
37
+
38
+ **Lo que te pide algo**: el reclamo hay que commitearlo y empujarlo — sin eso, el otro runner lee lo
39
+ que hay en su copia y la reserva no existe para nadie más—. Y si corrés varios agentes en la misma
40
+ máquina, cada uno exporta `CAUCE_RUNNER` con un valor propio.
41
+
42
+ - **`autobuild` reserva la tarea antes de construirla y la suelta al cerrarla.** Es lo que hace que dos
43
+ corridas en paralelo dejen de trabajar lo mismo: entre preguntar qué toca y reservarlo hay una ventana, y
44
+ perder esa carrera no frena la corrida — relee y sigue con la que quedó libre.
45
+
46
+ - **El aviso de reclamo viejo mira si la rama avanzó, no cuánto hace que se tomó.** El tiempo transcurrido
47
+ no distingue una tarea larga de una abandonada, y equivocarse cuesta en los dos sentidos: apurar a quien
48
+ está trabajando, o dejar bloqueada para siempre la tarea de quien se fue. Ahora `check` mira el último
49
+ commit **propio** de `task/<tarea>` —los que no están en el tronco, porque una rama recién creada hereda
50
+ su historia entera y sin esa distinción toda rama parecería haber avanzado el día que se creó—: tres días
51
+ sin ninguno avisan, y una tarea que recibe commits no se apura nunca
52
+ aunque lleve semanas tomada. Sin repositorio resoluble el aviso vuelve a mirar sólo la fecha — degrada a
53
+ lo que había, no rompe.
54
+
55
+ - **`ops context --hito <slug>` acota la cola a un hito.** Es la forma más barata de que dos personas o dos
56
+ agentes no se crucen: en hitos distintos casi nunca dependen entre sí ni tocan los mismos archivos. Lo
57
+ que se acota es qué se ofrece, no qué se sabe — una dependencia que vive en otro hito se sigue juzgando
58
+ igual—, y un hito mal escrito lo dice en vez de contestar «sin tarea disponible», que es indistinguible
59
+ de un hito terminado.
60
+
61
+ - **El plan en vuelo es uno por runner: `planning/wip/<runner>.md`.** Con `mode: sidecar` hay un solo
62
+ `planning/` por máquina, así que un plan compartido lo escribían todos los agentes que corren ahí: el
63
+ segundo pisaba el del primero, y `ops context` le entregaba la tarea que el primero estaba construyendo
64
+ —con el plan ajeno adentro y diciéndole que estaba libre—. `context` honra sólo el tuyo y `check` los
65
+ recorre todos.
66
+
67
+ **Lo que te pide algo**: `planning/WIP.md` se retiró. Mové tu plan a `planning/wip/<runner>.md` —el
68
+ nombre sale de tu `CAUCE_RUNNER`, aplanado; `ops context --json` lo dice en `wipFile`— y borrá el
69
+ archivo viejo, que mientras esté `ops check` lo nombra. El `.gitignore` nuevo excluye `planning/wip/*.md`
70
+ y conserva su README; si venías con la línea de `planning/WIP.md`, cambiala.
71
+
72
+ - **Un `service:` ambiguo entre varias raíces se nombra en vez de elegirse.** Con más de un
73
+ `workspaceRoots`, un servicio que existe en dos —`.` existe en todas— resolvía al primero: el árbol de
74
+ trabajo terminaba en el repositorio que no era, y el aviso de avance miraba las ramas de otro. Ahora
75
+ `ops worktree` nombra los candidatos y se niega, y el aviso degrada a mirar sólo la fecha.
76
+
77
+ - **`ops worktree` avisa cuando la instancia está embebida.** Con `mode: embedded` cada árbol se lleva su
78
+ propia copia de `planning/`, así que los reclamos de un agente no los ve el otro hasta mergear y la
79
+ coordinación entre varios deja de existir sin que nada falle. No lo frena: un árbol por rama con un solo
80
+ agente es un uso legítimo.
81
+
82
+ - **`ops runners <planning>`: qué runners tienen trabajo abierto, para que un agente pueda preguntar.**
83
+ Elegir con qué runner se arranca es lo primero de una sesión y `ops context` no lo contesta: responde
84
+ «qué hago» para un runner ya elegido. Sin esa lista, un agente se inventa un id y deja huérfano el
85
+ trabajo de ayer, o se lo pisa a otro que sigue corriendo.
86
+
87
+ **Lo que te pide algo**: nada, y es el punto. `AGENTS.md` le dice al runner que mire esa lista al abrir
88
+ la sesión, que **pregunte** cuál se retoma o si arranca uno nuevo, y que **exporte el id él mismo**. A
89
+ una persona no se le pide que escriba una variable de entorno.
90
+
91
+ - **Tu propio reclamo desde otro runner se reconoce en vez de resolverse solo.** Volver al día siguiente
92
+ sin reponer `CAUCE_RUNNER` y correr un segundo agente tuyo se ven idénticos desde el archivo, y las dos
93
+ salidas automáticas rompen trabajo: retomar sola le saca la tarea al otro agente, y crear un runner
94
+ nuevo deja dos construyendo lo mismo. `ops claim` dice cuál es cuál y con qué id se retoma; `ops context`
95
+ marca esas tareas como «vos, desde otro runner».
96
+
97
+ - **La evidencia de una tarea cerrada vive en su propio archivo: `planning/done/<slug>.md`.** Cerrar es lo
98
+ que más se hace, y mientras la evidencia se acumulaba en un `DONE.md` compartido, cerrar era agregarle
99
+ una entrada a algo que otro también estaba tocando. Ahora dos personas —o dos agentes— que cierran a la
100
+ vez escriben archivos distintos: no hay conflicto que resolver ni regla de merge que aplicar.
101
+
102
+ La entrada declara `fecha:`, la del cierre. Mientras vivían en un archivo, «la última» era la última del
103
+ archivo; con archivos sueltos el orden lo daría el listado del directorio, que es alfabético, y la
104
+ respuesta equivocada se leería igual de bien que la correcta. `ops evidence` sin `--task` ordena por ese
105
+ campo, y el contrato de una entrada está en `planning/done/README.md`.
106
+
107
+ **Lo que te pide algo**: `planning/DONE.md` se retiró. Pasá cada entrada a su propio
108
+ `planning/done/<slug>.md` con su `fecha:` y borrá el archivo — mientras esté, `ops check` lo dice en vez
109
+ de ignorarlo, porque un `DONE.md` que ya nadie lee deja a sus épicas sin poder cerrar y a sus historias
110
+ figurando sin evidencia.
111
+
112
+ - **`ops archive <NNN>` se retiró; `ops archive human-actions` se queda.** Archivar una épica existía para
113
+ descongestionar un `DONE.md` que se hinchaba con una entrada por tarea; con un archivo por tarea no hay
114
+ nada que descongestionar, y mover esos archivos a una carpeta por épica sería reintroducir el movimiento
115
+ que esto vino a sacar. El comando lo dice, en vez de contestar «la épica debe ser NNN».
116
+
117
+ - **`(depende: slug)` en una línea de tarea: lo que sigue no se le ofrece a otro.** El orden del BACKLOG
118
+ era la dependencia y alcanzaba mientras hubiera un runner; con dos, el segundo toma la que sigue mientras
119
+ el primero construye aquella de la que depende, y las dos ramas se pisan al integrar. Una tarea con
120
+ dependencias sin cerrar no se ofrece ni se puede tomar, y `context` la muestra con una línea `WAIT` que
121
+ nombra la dependencia y quién la tiene — una cola trabada no se lee como una cola vacía. `check` rechaza
122
+ la dependencia que no existe y nombra el ciclo entero cuando lo hay.
123
+
124
+ - **`ops worktree <planning> <tarea>`: un árbol de trabajo por agente, sin clonar el repositorio.** Resuelve
125
+ en qué raíz de `workspaceRoots` vive el `service:` de la tarea, crea la rama `task/<slug>` y el árbol al
126
+ lado, y devuelve la ruta con el `export CAUCE_RUNNER` ya escrito. `git worktree` comparte el mismo `.git`
127
+ y el mismo historial, así que no hay una segunda copia del repositorio: lo que hay es un segundo
128
+ directorio de archivos fijado a su rama, y por eso **ningún agente hace `checkout`** sobre el trabajo de
129
+ otro. Repetirlo devuelve el árbol que ya existe.
130
+
131
+ - **`.gitattributes`: `DONE.md` y `HUMAN_ACTIONS.md` se concatenan en vez de conflictuar.** Dos personas
132
+ cerrando trabajo el mismo día chocaban siempre, y ese conflicto no significaba nada: las dos entradas son
133
+ buenas y van las dos. Lo que `union` no hace es deduplicar, y esa falla ya la atrapa `DONE duplicado`.
134
+
135
+ - **`planning/delivery/teamwork.md`**: qué comparte el equipo y qué no, por qué dos agentes necesitan un
136
+ `git worktree` cada uno, cómo repartir trabajo, y qué se rompe primero según el tamaño del equipo.
137
+
138
+ - **BR-OPS-005 — una tarea, un runner.** La contracara de BR-OPS-001: aquélla impide que un runner lleve dos
139
+ tareas, ésta que dos runners lleven la misma.
140
+
141
+ ### Cambiado
142
+
143
+ - **`planning/WIP.md` pasa a ser local y deja de viajar por git.** Existe para recuperar la sesión de quien
144
+ lo escribió —nadie más puede retomarla— y cambia en cada paso, así que compartirlo era un conflicto por
145
+ commit a cambio de nada. `check` deja de exigir que exista: ausente se lee como IDLE, que es lo que
146
+ significa, y un clon nuevo ya no falla por no traerlo.
147
+
148
+ **Lo que te pide algo**: el molde nuevo lo gitignorea, pero tu `.gitignore` es tuyo y `upgrade` no lo toca.
149
+ Para aprovecharlo, agregale `planning/WIP.md` y sacalo del índice con `git rm --cached planning/WIP.md`.
150
+ Sin hacer nada, todo sigue funcionando como antes.
151
+
152
+ - **BR-OPS-001 se acota al runner.** Decía que WIP es el mutex sin decir de quién, y con equipo eso se leía
153
+ como «trabaja uno por vez». Ahora dice que un runner no toma dos tareas; que dos runners no tomen la misma
154
+ es BR-OPS-005.
155
+
156
+ ## [0.69.0] - 2026-09-07
157
+
158
+ ### Agregado
159
+
160
+ - **`planning/RECURRING.md`: el trabajo que vuelve se declara una vez.** Actualizar dependencias, revisar
161
+ quién tiene acceso a producción, mirar el gasto del mes: una fila con su cadencia —`mensual`,
162
+ `trimestral`, `semestral` o `anual`— y la celda de tarea escrita como la cola de su línea de BACKLOG,
163
+ así que la aceptación se decide una vez y no se improvisa en cada vuelta.
164
+
165
+ **Nada se dispara.** No hay cron ni cola: el vencimiento se calcula cuando alguien corre el CLI, y rueda
166
+ desde el período que cerró la última vuelta en `DONE.md` en vez de una celda que haya que acordarse de
167
+ actualizar. `node tools/ops.js recurring planning` dice qué venció, y `--promote <qué>` emite la línea de
168
+ esa vuelta —la emite y no la escribe: pegarla en `BACKLOG.md` es el acto de promoción—. `check` rechaza
169
+ la fila ilegible y avisa la vencida sin frenar nada; `context` la nombra con `DUE`. Postergar se escribe
170
+ a mano con su razón, compra un período, y tres seguidas se avisan porque ahí lo que falla es la cadencia.
171
+
172
+ **Lo que te pide algo**: el archivo llega vacío con esta actualización y, hasta que declares una fila, el
173
+ motor no dice una palabra. Y si tu runner ya está andando, la regla nueva que necesita está en
174
+ `AGENTS.md`: una recurrencia vencida no la promueve él, por más que `context` la nombre sola.
175
+
17
176
  ## [0.68.0] - 2026-09-07
18
177
 
19
178
  ### Cambiado
package/README.md CHANGED
@@ -7,8 +7,10 @@ el contexto de cada empresa vive en su propia instancia.
7
7
  ## Qué resuelve
8
8
 
9
9
  - Una tarea tiene una sola fuente de verdad durante todo su ciclo de vida.
10
- - Una sesión interrumpida se recupera desde `WIP.md`, sin reconstruir la intención.
10
+ - Una sesión interrumpida se recupera desde el plan del runner en `planning/wip/`, sin reconstruir la intención.
11
11
  - Las ideas del agente no entran solas a la cola: quedan en `INBOX.md` hasta promoción humana.
12
+ - El trabajo que vuelve cada tanto se declara una vez en `RECURRING.md`; el CLI dice cuándo venció y
13
+ nadie lo encola solo.
12
14
  - Épicas, criterios, tareas y evidencia son validados de forma determinista.
13
15
  - Vive en su propia carpeta `ops/` dentro del repo, como sidecar `proyecto-ops` para varios repos, o
14
16
  embebido en la raíz.
@@ -162,8 +164,8 @@ cualquiera de esas formas.
162
164
  ## Flujo
163
165
 
164
166
  ```text
165
- idea → INBOX → roadmap → BACKLOG → WIP → DONE → done/epic-NNN.md
166
- aprobación ejecución archivo histórico
167
+ idea → INBOX → roadmap → BACKLOG → claim → WIP → done/<tarea>.md
168
+ aprobación reserva ejecución evidencia
167
169
  ```
168
170
 
169
171
  1. Captura ideas, deuda o lecciones en `INBOX.md`.
@@ -171,9 +173,14 @@ idea → INBOX → roadmap → BACKLOG → WIP → DONE → done/epic-NNN.md
171
173
  un recorrido —una etapa por dueño de decisión, con su exit gate— y dejar la épica candidata escrita;
172
174
  si falta evidencia o autoridad, para y registra la acción humana en vez de suponer.
173
175
  3. Promueve historias listas a un `## Hito` de `BACKLOG.md`.
174
- 4. Un runner toma una sola tarea y persiste su plan en `WIP.md`.
175
- 5. Tras Build, Review, Verify y QA, mueve la entrada a `DONE.md` con evidencia.
176
- 6. Al cerrar la épica, ejecuta `ops archive` para mover su evidencia a un histórico inmutable.
176
+ 4. Un runner reclama la tarea con `ops claim`, para que otro no la tome, y persiste su plan en `wip/<runner>.md`.
177
+ 5. Tras Build, Review, Verify y QA, escribe la evidencia en `done/<slug>.md` y suelta el reclamo.
178
+ 6. Al cerrar la última historia, la épica pasa a `closed`.
179
+
180
+ Lo que vuelve cada tanto —actualizar dependencias, revisar accesos, mirar el gasto del mes— entra por
181
+ un costado: se declara una vez en `RECURRING.md` con su cadencia, y `ops recurring planning` dice qué
182
+ venció y emite la línea de esa vuelta. Nada se dispara; pegarla en `BACKLOG.md` es el paso 3 de
183
+ arriba, hecho por una persona.
177
184
 
178
185
  Lee [template/planning/PROTOCOL.md](template/planning/PROTOCOL.md) para el contrato completo y
179
186
  [template/planning/FLOW.md](template/planning/FLOW.md) para operar el ciclo.
@@ -65,6 +65,6 @@ cambia instalación o materialización, valida `ops init` en un directorio tempo
65
65
 
66
66
  ## Límites
67
67
 
68
- No edites `planning/BACKLOG.md`, `WIP.md` o `DONE.md` desde hooks o instaladores. No hagas que `ops init` active
68
+ No edites `planning/BACKLOG.md`, `planning/wip/` ni `planning/done/` desde hooks o instaladores. No hagas que `ops init` active
69
69
  un runner silenciosamente. No agregues lógica de negocio, nombres de servicios de un proyecto, tokens, rutas
70
70
  personales ni modelos concretos a esta capa reusable.
@@ -1,7 +1,7 @@
1
1
  # Cauce
2
2
 
3
3
  Lee y cumple `{{OPS_DIR}}AGENTS.md`, `{{OPS_DIR}}planning/PROTOCOL.md` y `{{OPS_DIR}}planning/rules/system/` antes
4
- de ejecutar trabajo. `{{OPS_DIR}}planning/WIP.md` es el mutex de
4
+ de ejecutar trabajo. `{{OPS_DIR}}planning/wip/<runner>.md` es el mutex de
5
5
  ejecución y `{{OPS_DIR}}planning/AWAITING_REVIEW.md` bloquea una corrida nueva. No promociones ideas desde INBOX, no
6
6
  inventes aprobaciones o credenciales y no hagas push ni deploy. Cierra cada tarea con verificación real y
7
7
  evidencia en DONE.
@@ -20,5 +20,5 @@ misma pregunta más caro. El arranque busca entender qué es el proyecto y dejar
20
20
  intención es viable y propone una épica, y `/autobuild` ejecuta trabajo ya promovido; `/integration-sync` e
21
21
  `/integration-promote` gestionan staging local sin escritura remota. Ninguno promueve al BACKLOG.
22
22
 
23
- Antes de iniciar, respeta `{{OPS_DIR}}planning/AWAITING_REVIEW.md` y el mutex de `{{OPS_DIR}}planning/WIP.md`. Si el protocolo y
23
+ Antes de iniciar, respeta `{{OPS_DIR}}planning/AWAITING_REVIEW.md` y el mutex de `{{OPS_DIR}}planning/wip/<runner>.md`. Si el protocolo y
24
24
  un workflow difieren, manda el protocolo y la diferencia se registra en `{{OPS_DIR}}planning/INBOX.md`.
@@ -15,7 +15,7 @@ Los cinco recorridos llegan como skills en `.agents/skills/` y se invocan con `$
15
15
  acá —son referencia, no un runtime compatible—, así que cada recorrido se ejecuta fase por fase
16
16
  siguiendo el protocolo.
17
17
 
18
- `{{OPS_DIR}}planning/WIP.md` es el mutex: una sola tarea activa. `{{OPS_DIR}}planning/AWAITING_REVIEW.md`
18
+ `{{OPS_DIR}}planning/wip/<runner>.md` es el mutex: una sola tarea activa. `{{OPS_DIR}}planning/AWAITING_REVIEW.md`
19
19
  bloquea una corrida nueva hasta que un humano revise. Nada se promueve desde `INBOX.md` sin aprobación.
20
20
 
21
21
  Antes de cerrar, corré `node {{OPS_DIR}}tools/ops.js check {{OPS_DIR}}planning`.
@@ -8,7 +8,7 @@
8
8
  @{{OPS_DIR}}planning/rules/system/conduct.md
9
9
 
10
10
  `{{OPS_DIR}}planning/PROTOCOL.md` es la fuente de verdad. Ejecuta `/cauce:autobuild` fase por fase; los
11
- workflows JS de Claude son referencia, no un runtime compatible. `{{OPS_DIR}}planning/WIP.md` es el mutex
11
+ workflows JS de Claude son referencia, no un runtime compatible. `{{OPS_DIR}}planning/wip/<runner>.md` es el mutex
12
12
  y `{{OPS_DIR}}planning/AWAITING_REVIEW.md` bloquea una corrida nueva.
13
13
 
14
14
  Los hooks de `.gemini/settings.json` son obligatorios y bloquean por su cuenta. **Requieren que la
@@ -3,7 +3,7 @@ name: autobuild
3
3
  description: Ejecuta el siguiente hito aprobado siguiendo el protocolo Cauce.
4
4
  ---
5
5
 
6
- Lee completos `{{OPS_DIR}}AGENTS.md`, `{{OPS_DIR}}planning/PROTOCOL.md` y `{{OPS_DIR}}planning/WIP.md`. Si existe
6
+ Lee completos `{{OPS_DIR}}AGENTS.md`, `{{OPS_DIR}}planning/PROTOCOL.md` y `{{OPS_DIR}}planning/wip/`. Si existe
7
7
  `{{OPS_DIR}}planning/AWAITING_REVIEW.md`, detente y explica la acción humana pendiente. Toma solamente la primera tarea
8
8
  aprobada, persiste el estado en WIP y ejecuta Build, Review, Verify y QA en orden. Usa los comandos reales del
9
9
  servicio, registra evidencia verificable en DONE y detente en el checkpoint entre hitos. No hagas push ni
@@ -21,7 +21,7 @@ export const meta = {
21
21
  { title: 'Verify', detail: 'Los gates del servicio y la aceptación que ninguna prueba codifica' },
22
22
  { title: 'QA', detail: 'El comportamiento ejercitado como lo ve quien lo usa' },
23
23
  { title: 'Commit', detail: 'Conventional Commits, uno por naturaleza del diff, sin push' },
24
- { title: 'Done', detail: 'Cierre atómico en DONE con el WIP en IDLE' },
24
+ { title: 'Done', detail: 'Cierre atómico: evidencia escrita, cola y plan limpios, reserva suelta' },
25
25
  { title: 'Closing', detail: 'Check de planning y checkpoint humano del hito' },
26
26
  ],
27
27
  }
@@ -31,8 +31,8 @@ const CONFIG = `${ROOT}/ops.config.json`
31
31
  const P = `${ROOT}/planning`
32
32
  const ORG = `${ROOT}/organization`
33
33
  const BACKLOG = `${P}/BACKLOG.md`
34
- const DONE = `${P}/DONE.md`
35
- const WIP = `${P}/WIP.md`
34
+ // Una tarea cerrada escribe su propio archivo, así que dos corridas en paralelo no comparten ninguno.
35
+ const doneFile = (slug) => `${P}/done/${slug}.md`
36
36
  const HUMAN = `${P}/HUMAN_ACTIONS.md`
37
37
  const GATE = `${P}/AWAITING_REVIEW.md`
38
38
  const ROADMAP = `${P}/roadmap`
@@ -58,8 +58,21 @@ const CONTEXT = {
58
58
  properties: { build: { type: 'string' }, review: { type: 'array', items: { type: 'string' } } },
59
59
  },
60
60
  blockedTasks: { type: 'array', items: { type: 'string' } },
61
+ // Si la tarea que `context` devolvió ya está reservada a nombre de este runner. Libre no significa
62
+ // que sea nuestra: significa que todavía la puede tomar cualquiera, y dos corridas en paralelo la
63
+ // reciben las dos.
64
+ claimed: { type: 'boolean' },
65
+ // La fecha de hoy según el motor. Este recorrido no tiene reloj propio a propósito.
66
+ today: { type: 'string' },
67
+ // Dónde va el plan de este runner. El nombre sale de su id y el recorrido no lo deriva: lo
68
+ // pregunta, igual que la fecha.
69
+ wipFile: { type: 'string' },
61
70
  },
62
71
  }
72
+ const CLAIM = {
73
+ type: 'object', additionalProperties: false, required: ['claimed'],
74
+ properties: { claimed: { type: 'boolean' }, details: { type: 'string' } },
75
+ }
63
76
  const EXPANSION = {
64
77
  type: 'object', additionalProperties: false, required: ['expanded'],
65
78
  properties: { expanded: { type: 'boolean' }, hito: { type: 'string' }, reason: { type: 'string' } },
@@ -311,7 +324,10 @@ const write = (prompt, options = {}) => agent(`${LEDGER}\n\n${prompt}`, options)
311
324
  // WIP y HUMAN_ACTIONS nunca entran al contexto de un modelo, y su tamaño deja de costar tokens.
312
325
  const readContext = () => read(
313
326
  `Corré "node tools/ops.js context ${P} --json" desde ${ROOT} y reportá sólo lo que imprimió. Derivá hasTask ` +
314
- `de si task es null, wipActive de si wip es null y lane de task.tier; copiá slug, hito, service, acceptance, ` +
327
+ `de si task es null, wipActive de si wip es null, claimed del campo claimed, today y wipFile de sus ` +
328
+ `campos, y lane ` +
329
+ `de task.tier; copiá slug, ` +
330
+ `hito, service, acceptance, ` +
315
331
  `epic y cast de task, y epicContext de epic.context —vacío si no hay épica—. El comando es la fuente de ` +
316
332
  `verdad: no abras archivos de planning para completarlo.`,
317
333
  { schema: CONTEXT, label: 'planning-context' },
@@ -351,6 +367,24 @@ while (rounds++ < MAX_TASKS) {
351
367
  id: planning.slug, hito: planning.hito, service: planning.service,
352
368
  acceptance: planning.acceptance, epic: planning.epic, epicContext: planning.epicContext || '',
353
369
  }
370
+ // Reservar antes de construir, y antes de fijar el hito de la corrida. Sin esto dos corridas en
371
+ // paralelo trabajan la misma tarea: `context` sólo puede saltear lo que alguien ya reclamó, y el
372
+ // primero en preguntar todavía no reclamó nada. La ventana entre preguntar y reservar existe igual, y
373
+ // por eso perder la carrera no es un error: se relee y se sigue con la que quedó libre.
374
+ if (!planning.claimed && !planning.wipActive) {
375
+ phase('Claim')
376
+ const reserva = await write(
377
+ `Corré "node tools/ops.js claim ${P} ${task.id}" desde ${ROOT}. No escribas ningún archivo vos: lo ` +
378
+ `escribe el comando. claimed=true sólo con exit 0; si falla porque la tomó otro, claimed=false y ` +
379
+ `copiá el mensaje en details.`,
380
+ { schema: CLAIM, label: `claim:${task.id}` },
381
+ )
382
+ if (!reserva || !reserva.claimed) {
383
+ planning = await readContext()
384
+ if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
385
+ continue
386
+ }
387
+ }
354
388
  currentMilestone = task.hito
355
389
 
356
390
  // Ver la tarea y, si no está clasificada, clasificarla antes de ejecutarla. Se hace una vez por
@@ -535,7 +569,7 @@ while (rounds++ < MAX_TASKS) {
535
569
  `dio; recién después implementá. Un test que pasa antes de que exista el código no asercia lo que dice ` +
536
570
  `aserciar: endurecelo y volvé a correr hasta verlo fallar. Corré las pruebas que necesites para ver ese ` +
537
571
  `rojo y ese verde, y nada más: los gates completos, el QA, el commit y el cierre son fases posteriores, ` +
538
- `así que no toques ${DONE} ni ${BACKLOG} ni el status del WIP. Lo que el plan no previó va en discovered y ` +
572
+ `así que no toques ${P}/done/ ni ${BACKLOG} ni el status del WIP. Lo que el plan no previó va en discovered y ` +
539
573
  `no en el código a secas: kind=edge si esta tarea lo puede fijar —y entonces entra con su prueba, que ` +
540
574
  `nombrás en test y anotás en redFirst—, kind=open si lo notaste y no impide entregar la aceptación: se ` +
541
575
  `registra para que lo decida quien corresponde y el recorrido sigue. Si de verdad no podés entregar sin ` +
@@ -693,9 +727,11 @@ while (rounds++ < MAX_TASKS) {
693
727
 
694
728
  phase('Done')
695
729
  await write(
696
- `Cerrá ${task.id} de forma atómica: agregala bajo su hito en ${DONE} con evidencia de acept, done, qa, ` +
697
- `tests y commit; sacala junto con sus notas indentadas de ${BACKLOG}; cerrá su épica sólo si no queda ` +
698
- `ninguna tarea etiquetada; y dejá ${WIP} en status IDLE. En decisions no nombres una fase ni un cargo ` +
730
+ `Cerrá ${task.id} de forma atómica: escribí ${doneFile(task.id)} con su evidencia —acept, ` +
731
+ `fecha: ${planning.today}, done, qa, tests y commit, en el formato de entrada que trae este preámbulo—; ` +
732
+ `sacala junto con sus notas indentadas de ${BACKLOG}; cerrá su épica sólo si no queda ` +
733
+ `ninguna tarea etiquetada; dejá ${P}/${planning.wipFile} en status IDLE; y soltá la reserva corriendo ` +
734
+ `"node tools/ops.js release ${P} ${task.id}". En decisions no nombres una fase ni un cargo ` +
699
735
  `que no figure en estos hechos. Hechos: lane=${planning.lane || 'sin clasificar'}; ` +
700
736
  `review=${reviewFact}; fases=${ran.join(' → ')}; build=${build.summary}; ` +
701
737
  `verify=${JSON.stringify(verified.commands)}; qa=${qa.evidence}; commit=${commit.hash || commit.reason}.`,
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+
3
+ // Los comandos que mueven estado ya decidido: archivar lo que cerró y declarar de una vez qué historia
4
+ // llegó con el proyecto. Viven aparte de `planning.js` porque son la otra mitad de una costura: aquéllos
5
+ // leen y juzgan sin mutar nada, éstos escriben. Se separaron cuando el archivo cruzó las 500 líneas, y
6
+ // lo que decidió el corte fue eso y no el número.
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+ const P = require('../planning/parser')
11
+ const PC = require('../planning/contracts')
12
+ const AD = require('../planning/adoption')
13
+ const F = require('../core/files')
14
+ const { fail } = require('./io')
15
+
16
+ // La fecha de hoy, la misma que usan los comandos que leen.
17
+ const TODAY = () => new Date().toISOString().slice(0, 10)
18
+
19
+ // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
20
+ // a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
21
+ // cerró ninguna —que es justo cuando el archivo se vuelve ilegible—.
22
+ // Adoptar es declarar de una vez qué historia llegó con el proyecto. Se genera con lo que hoy no cumple
23
+ // y no se vuelve a correr: un baseline que se regenera perdona de nuevo lo que alguien ya se tomó el
24
+ // trabajo de arreglar, y uno que crece a mano deja de ser una lista de perdones para ser una amnistía.
25
+ // Achicarlo sí es a mano, borrando el renglón que `check` señala.
26
+ function adopt(dir) {
27
+ const root = path.resolve(dir || '.')
28
+ const target = path.join(root, AD.BASELINE)
29
+ if (fs.existsSync(target)) {
30
+ // Un baseline que ya trae huella no se toca: regenerarlo es exactamente lo que la huella impide.
31
+ // Uno sin huella lo generó una versión anterior, y sellarlo no es regenerar nada — se calcula sobre
32
+ // lo que ya está—, así que es la única salida de un aviso que si no no tendría ninguna.
33
+ const existing = fs.readFileSync(target, 'utf8')
34
+ if (!AD.sealWarnings(root).some((one) => /sin huella/.test(one))) {
35
+ fail(`${AD.BASELINE} ya existe: se genera una vez. Para retirar un renglón, ponele \`#~\` `
36
+ + 'delante; `check` marca los que ya cumplen.')
37
+ }
38
+ const slugs = AD.declared(existing)
39
+ F.atomicWrite(target, existing.replace(/\n?$/, `\n# huella: ${slugs.length} entradas · `
40
+ + `sha256:${AD.digest(slugs)}\n`))
41
+ return console.log(`✓ ${AD.BASELINE} sellado con ${slugs.length} entrada(s); la lista no cambió`)
42
+ }
43
+ const epics = P.readEpics(root)
44
+ const pending = P.readDone(root).entries.filter((entry) => PC.doneEntryErrors(entry, epics).length)
45
+ if (!pending.length) {
46
+ return console.log('= no hay nada que exentar: todas las entradas de DONE cumplen el contrato')
47
+ }
48
+ const today = TODAY()
49
+ const slugs = pending.map((entry) => entry.slug)
50
+ F.atomicWrite(target, `# Entradas anteriores a la adopción de Cauce (${today}). No se agregan nuevas:\n`
51
+ + '# desde esa fecha rige el contrato completo, y `check` avisa cuando una de éstas pasa a\n'
52
+ + '# cumplirlo para que le pongas `#~` delante y quede retirada.\n'
53
+ + `# huella: ${slugs.length} entradas · sha256:${AD.digest(slugs)}\n`
54
+ + `${slugs.join('\n')}\n`)
55
+ console.log(`✓ ${pending.length} entrada(s) exentas en ${AD.BASELINE}`)
56
+ return console.log(' revisá la lista: lo que sí cumple el contrato no tiene por qué estar ahí')
57
+ }
58
+
59
+ function archiveHumanActions(root) {
60
+ const source = path.join(root, 'HUMAN_ACTIONS.md')
61
+ const rows = P.readHumanActions(root).filter((row) => row.resolved)
62
+ if (!rows.length) return console.log('= no hay filas resueltas')
63
+ const target = path.join(root, 'done', 'human-actions.md')
64
+ const header = '| Tarea | Estado | Origen | Acción concreta y condición de desbloqueo |\n|---|---|---|---|'
65
+ const previous = P.read(target).trimEnd()
66
+ const head = previous || `---\nstatus: archived\n---\n\n# Acciones humanas resueltas\n\n${header}`
67
+ fs.mkdirSync(path.dirname(target), { recursive: true })
68
+ F.atomicWrite(target, `${head}\n${rows.map((row) => row.raw).join('\n')}\n`)
69
+ const drop = new Set(rows.map((row) => row.raw))
70
+ const kept = P.read(source).split('\n').filter((line) => !drop.has(line))
71
+ F.atomicWrite(source, `${kept.join('\n').trimEnd()}\n`)
72
+ return console.log(`✓ ${rows.length} fila(s) archivadas`)
73
+ }
74
+ // Archivar una épica se retiró en 0.71.0. Existía para descongestionar un `DONE.md` que se hinchaba con
75
+ // una entrada por tarea; con un archivo por tarea no hay nada que descongestionar, y mover esos archivos
76
+ // a una carpeta por épica sería reintroducir el movimiento que la mudanza vino a sacar.
77
+ //
78
+ // El comando se queda para lo que sí sigue archivándose, y para decirle a quien escriba el número que ya
79
+ // no hace falta: sin esto contestaría «La épica debe ser NNN», que manda a corregir la forma de algo que
80
+ // no existe.
81
+ function archive(dir, rawNum) {
82
+ if (String(rawNum || '') === 'human-actions') return archiveHumanActions(path.resolve(dir || '.'))
83
+ return fail('Sólo se archiva `human-actions`. La evidencia de una tarea ya vive en su propio archivo '
84
+ + 'de `done/`, así que archivar una épica dejó de tener sentido.', 2)
85
+ }
86
+
87
+ module.exports = { archive, adopt }
@@ -7,7 +7,7 @@
7
7
  // Banderas que consumen el argumento siguiente: su valor no es un posicional.
8
8
  const VALUED_FLAGS = new Set([
9
9
  '--name', '--mode', '--fixture', '--period', '--record', '--runner', '--integration',
10
- '--task',
10
+ '--task', '--promote', '--hito',
11
11
  ])
12
12
 
13
13
  // Qué acepta cada comando, y a la vez qué comandos existen. Una bandera desconocida se rechaza en vez
@@ -19,7 +19,12 @@ const FLAGS = {
19
19
  onboard: ['--json'],
20
20
  check: ['--json'],
21
21
  tree: ['--json', '--no-color'],
22
- context: ['--json'],
22
+ context: ['--json', '--hito'],
23
+ recurring: ['--json', '--promote'],
24
+ claim: ['--json'],
25
+ runners: ['--json'],
26
+ worktree: ['--json'],
27
+ release: [],
23
28
  evidence: ['--json', '--task'],
24
29
  upgrade: ['--check', '--force'],
25
30
  destroy: ['--force'],
@@ -115,7 +115,15 @@ function evaluationBench(root, agent, caso, force, kind) {
115
115
  // sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
116
116
  const scope = path.join(dir, 'node_modules', '@ingeniomaps')
117
117
  fs.mkdirSync(scope, { recursive: true })
118
- fs.symlinkSync(IN.PROJECT_ROOT, path.join(scope, 'cauce'), 'dir')
118
+ // El enlace se pisa por lo mismo que el andamiaje de arriba: a veces sobrevive al borrado del banco, y
119
+ // entonces crearlo corta la corrida con `EEXIST` en vez de rehacerlo. Es el único paso que no seguía esa
120
+ // regla, y el que falló en CI rehaciendo el mismo `11-otro` que ya tiene reintentos por esto.
121
+ //
122
+ // `rmSync` sobre un enlace lo quita a él y no a lo que apunta —que acá es la raíz del toolkit—, así que
123
+ // esto no puede llevarse por delante el repositorio.
124
+ const link = path.join(scope, 'cauce')
125
+ fs.rmSync(link, { force: true })
126
+ fs.symlinkSync(IN.PROJECT_ROOT, link, 'dir')
119
127
 
120
128
  // El artefacto del caso, si lo tiene: la guía del proveedor que el pedido manda implementar, el CSV
121
129
  // con instrucciones adentro. Entra antes del commit limpio a propósito — si entrara después, `status`
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+
3
+ // Tomar y soltar una tarea, y ver quién tiene trabajo abierto. Los dos primeros son los únicos comandos
4
+ // del motor que escriben un archivo de coordinación, y escriben **el propio**: `BACKLOG.md` no lo toca
5
+ // ninguno, porque la cola es de lo aprobado y la escribe una persona.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const CL = require('../planning/claims')
10
+ const R = require('../core/repos')
11
+ const ST = require('../planning/state')
12
+ const { fail } = require('./io')
13
+
14
+ const TODAY = () => new Date().toISOString().slice(0, 10)
15
+
16
+ function claim(dir, slug, cli) {
17
+ const root = path.resolve(dir || '.')
18
+ if (!slug) return fail('Falta el slug. `ops claim <planning-dir> <tarea>`', 2)
19
+ const state = ST.snapshot(root)
20
+ const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
21
+ if (!task) return fail(`${slug} no está en BACKLOG: sólo se toma trabajo ya promovido.`, 2)
22
+
23
+ // No se reserva lo que todavía no se puede empezar: una tarea tomada con su dependencia en vuelo
24
+ // bloquea la cola sin que nadie pueda avanzarla, y el runner que la tomó se queda sin poder tomar otra.
25
+ const falta = task.depends.find((dep) => !state.done.set.has(dep))
26
+ if (falta) return fail(`${slug} depende de ${falta}, que todavía no está en DONE.`)
27
+
28
+ const me = CL.owner(root)
29
+ const from = CL.runner()
30
+ if (!me) {
31
+ return fail('No sé quién sos. Configurá `git config user.email` o exportá CAUCE_OWNER: '
32
+ + 'un reclamo anónimo no dice a quién preguntarle.', 2)
33
+ }
34
+ // Un runner lleva una tarea a la vez (BR-OPS-001), y exigirlo acá hace ruidosa la única forma en que
35
+ // este contrato falla en silencio: dos agentes de la misma máquina compartiendo id porque nadie puso
36
+ // `CAUCE_RUNNER`. Sin esto, el segundo se llevaría la tarea del primero creyéndola suya.
37
+ //
38
+ // La que se está pidiendo queda afuera del conteo: volver a pedir la propia es reintentar, no llevar dos.
39
+ const ocupado = state.claims
40
+ .find((one) => one.runner === from && one.slug !== slug && !state.done.set.has(one.slug))
41
+ if (ocupado) {
42
+ return fail(`este runner ya tiene ${ocupado.slug}. Cerrala o soltala primero; y si sos otro agente `
43
+ + 'en la misma máquina, exportá CAUCE_RUNNER con un valor propio.')
44
+ }
45
+
46
+ const target = CL.file(root, slug)
47
+ fs.mkdirSync(path.dirname(target), { recursive: true })
48
+ const cuerpo = CL.content({ task: slug, owner: me, runner: from, started: TODAY(), service: task.service })
49
+ try {
50
+ // Reservar **es** crear el archivo, así que el único juez de quién la tiene es el archivo. `wx` falla
51
+ // si ya está, y de ahí sale la respuesta entera: propia, ajena o perdida en la carrera.
52
+ //
53
+ // Sin `wx` no habría reserva: `atomicWrite` renombra encima, y dos agentes que arrancan con segundos
54
+ // de diferencia ganarían los dos sin que ninguno se entere. Y una comprobación previa tampoco
55
+ // alcanzaría —entre mirar y escribir queda la misma ventana—, así que sería un segundo juez que
56
+ // adelanta un veredicto que este bloque tiene que volver a dar igual.
57
+ fs.writeFileSync(target, cuerpo, { flag: 'wx' })
58
+ } catch (error) {
59
+ if (error.code !== 'EEXIST') throw error
60
+ const dueño = CL.read(root).find((one) => one.slug === slug)
61
+ // Existía al crear y ya no está: alguien la soltó entre las dos operaciones. Es una ventana de
62
+ // microsegundos y aun así tiene respuesta, porque la alternativa es reventar con un TypeError.
63
+ if (!dueño) return fail(`${slug} cambió de manos mientras la pedías; volvé a intentarlo.`)
64
+ if (dueño.runner === from) return console.log(`= ${slug} ya era tuya desde ${dueño.started}`)
65
+ // Mismo dueño y otro runner son dos situaciones que se ven idénticas desde acá —vos retomando la
66
+ // sesión de ayer, o un segundo agente tuyo— y ninguna se puede distinguir mirando el archivo.
67
+ // Retomarla sola le sacaría la tarea al otro agente; crear un runner nuevo dejaría dos trabajando lo
68
+ // mismo. Las dos rompen trabajo, así que decide una persona y acá sólo se dice cuál es cuál.
69
+ if (dueño.owner === me) {
70
+ return fail(`${slug} la tenés vos, tomada el ${dueño.started} desde el runner ${dueño.runner}. `
71
+ + 'Preguntá si se retoma esa sesión —y entonces corré con ese id— o si es otro agente en '
72
+ + `paralelo, que toma otra tarea. \`ops runners ${path.relative(process.cwd(), root) || '.'}\` `
73
+ + 'lista lo que hay abierto.')
74
+ }
75
+ return fail(`${slug} la tomó ${dueño.owner} el ${dueño.started}. Si se abandonó, borrá `
76
+ + `${CL.DIR}/${slug}.md a mano: soltar lo de otro es una decisión, no un comando.`)
77
+ }
78
+ console.log(`✓ ${slug} tomada por ${me}`)
79
+ // Un reclamo sin empujar no protege de nada: el otro runner lee lo que hay en su copia. Decirlo acá
80
+ // es lo único que separa «tomé la tarea» de «creí que la había tomado».
81
+ if (!cli.has('--json')) console.log(` commiteá y empujá ${CL.DIR}/${slug}.md para que el equipo lo vea`)
82
+ }
83
+
84
+ function release(dir, slug) {
85
+ const root = path.resolve(dir || '.')
86
+ if (!slug) return fail('Falta el slug. `ops release <planning-dir> <tarea>`', 2)
87
+ const from = CL.runner()
88
+ const taken = CL.read(root).find((one) => one.slug === slug)
89
+ if (!taken) return fail(`${slug} no está tomada por nadie.`, 2)
90
+ if (taken.runner !== from) {
91
+ return fail(`${slug} es de ${taken.owner}. Si se abandonó, borrá ${taken.at} a mano.`)
92
+ }
93
+ fs.rmSync(CL.file(root, slug))
94
+ console.log(`✓ ${slug} soltada; volvió a la cola`)
95
+ }
96
+
97
+ // Qué runners tienen trabajo abierto, para que un agente pueda preguntar antes de inventarse un id.
98
+ //
99
+ // Es lo primero de una sesión y no lo contesta `context`: `context` responde «qué hago» para un runner ya
100
+ // elegido, y elegirlo viene antes. Sin esta lista, un agente que arranca sin `CAUCE_RUNNER` se crea uno
101
+ // nuevo y deja el trabajo de ayer huérfano, o peor, se lo pisa a otro agente que sigue corriendo.
102
+ //
103
+ // La persona elige; el agente exporta. Pedirle a una persona que escriba una variable de entorno para
104
+ // retomar su propio trabajo es hacerle hacer de intérprete.
105
+ function runners(dir, cli) {
106
+ const root = path.resolve(dir || '.')
107
+ const done = ST.snapshot(root).done
108
+ const abiertos = CL.read(root).filter((one) => !done.set.has(one.slug))
109
+ const hoy = TODAY()
110
+ const filas = abiertos.map((one) => {
111
+ const commit = R.lastCommit(R.repoOf(path.join(root, '..'), one.service), CL.branchOf(one.slug))
112
+ return { runner: one.runner, task: one.slug, owner: one.owner, started: one.started, lastCommit: commit }
113
+ })
114
+ if (cli.has('--json')) return console.log(JSON.stringify(filas))
115
+ if (!filas.length) return console.log('= ningún runner tiene trabajo abierto: arrancá con un id propio')
116
+ const ancho = Math.max(...filas.map((one) => one.runner.length))
117
+ for (const una of filas) {
118
+ const avance = una.lastCommit ? `último commit ${una.lastCommit}` : 'sin commits en su rama'
119
+ console.log(`${una.runner.padEnd(ancho)} ${una.task} (${una.owner}, desde ${una.started}; ${avance})`)
120
+ }
121
+ console.log(`\n${filas.length} runner(s) con trabajo abierto al ${hoy}. `
122
+ + 'Retomá uno usando su id, o arrancá con uno propio.')
123
+ }
124
+
125
+ module.exports = { claim, release, runners }