@ingeniomaps/cauce 0.74.0 → 0.76.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.
- package/CHANGELOG.md +112 -0
- package/automatization/workflows/autobuild.js +73 -24
- package/engine/cli/archive.js +3 -3
- package/engine/cli/catalog.js +74 -29
- package/engine/cli/claims.js +4 -4
- package/engine/cli/io.js +30 -1
- package/engine/cli/planning.js +7 -27
- package/engine/cli/worktree.js +2 -2
- package/engine/hooks/shell.js +20 -10
- package/engine/planning/contracts.js +74 -3
- package/engine/planning/parser.js +7 -2
- package/engine/planning/state.js +1 -1
- package/package.json +1 -1
- package/template/planning/PROTOCOL.md +8 -1
- package/template/planning/done/README.md +23 -0
- package/template/planning/rules/system/commits.md +18 -0
- package/template/planning/wip/README.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,118 @@ 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.76.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Corregido
|
|
20
|
+
|
|
21
|
+
- **Un comando que no encuentra tu planning lo dice, en vez de contestar un hecho sobre un directorio que
|
|
22
|
+
no existe.** `context` y `tree` ya lo hacían desde 0.71.0; los otros nueve no. `claim` contestaba «no
|
|
23
|
+
está en BACKLOG», `release` «no está tomada por nadie», `evidence` «DONE no tiene ninguna entrada»,
|
|
24
|
+
`runners` «ningún runner tiene trabajo abierto» — todos hechos concretos sobre lo que no pudieron leer.
|
|
25
|
+
**Cuatro de ellos salían con exit 0**, así que un script veía éxito.
|
|
26
|
+
|
|
27
|
+
El daño no es el mensaje sino lo que induce: en una corrida real, `claim` dijo «no está en BACKLOG»
|
|
28
|
+
sobre una tarea que **sí** estaba, y el recorrido mandó a una persona a promover lo único que ya estaba
|
|
29
|
+
bien, citando la regla correcta con la conclusión al revés. Ahora los once comandos que reciben un
|
|
30
|
+
planning fallan igual, con la ruta **resuelta** puesta — que es lo que hace falta cuando el error es de
|
|
31
|
+
resolución: en sidecar, `<empresa>-ops/planning` escrito desde adentro de la raíz apunta a
|
|
32
|
+
`<empresa>-ops/<empresa>-ops/planning`.
|
|
33
|
+
|
|
34
|
+
**Lo que te pide algo**: si tenías un script que trataba ese vacío como «nada que hacer», ahora falla.
|
|
35
|
+
Es la misma dirección que 0.63.0 y 0.71.0 ya tomaron. Y `ops check` sobre una ruta que no existe pasa a
|
|
36
|
+
decir eso en vez de «falta BACKLOG.md»: sale con 2 y nombra la ruta.
|
|
37
|
+
|
|
38
|
+
### Agregado
|
|
39
|
+
|
|
40
|
+
- **La entrada declara también `review:`, y `check` cruza los dos.** El carril dice cuánta ceremonia
|
|
41
|
+
**merecía** la tarea; `review:` dice cuánta **recibió** —el veredicto y quién revisó, o `n/a — razón`
|
|
42
|
+
cuando no corrió—. Es la dimensión que la propia ADR OPS-006 nombraba como la que falta: «se sabría
|
|
43
|
+
comparando hallazgos de review por carril, y hoy no se registra esa dimensión en DONE».
|
|
44
|
+
|
|
45
|
+
Con los dos campos, `check` avisa lo que hasta ahora no tenía cómo ver: una entrada cuyo carril convoca
|
|
46
|
+
revisor —`directo`, `lite`, `full`— y cuya revisión no corrió. `express` queda afuera porque es el único
|
|
47
|
+
que legítimamente no convoca a nadie. Avisa y no falla: es un hecho del pasado que no se arregla
|
|
48
|
+
editando la entrada, y el único camino al verde sería reescribir el registro.
|
|
49
|
+
|
|
50
|
+
- **La entrada de una tarea cerrada declara `lane:`, el carril con el que corrió.** El carril decide qué
|
|
51
|
+
fases recibe una tarea —`express` se saltea Ready, Plan y QA; `full` las corre todas— y viajaba sólo en
|
|
52
|
+
la línea del BACKLOG, que **se borra al cerrar**. Con eso, «¿esta tarea recibió la ceremonia que su
|
|
53
|
+
superficie pedía?» dejaba de tener dónde contestarse: en una instancia real, **0 de 79 entradas de DONE
|
|
54
|
+
registraban el carril**. Ahora queda en el registro, y `sin clasificar` es un valor y no un hueco — dice
|
|
55
|
+
que la línea no lo declaraba, que es distinto de que nadie llenara el campo.
|
|
56
|
+
|
|
57
|
+
El plan en vuelo también lo lleva: una corrida que se reanuda arma la tarea desde `wip/<runner>.md`, y
|
|
58
|
+
sin el campo ahí llegaba al cierre con el carril ya perdido aunque la tarea sí lo tuviera.
|
|
59
|
+
|
|
60
|
+
**Lo que te pide algo**: `ops check` **avisa** cuántas entradas no lo traen y **no falla** — las
|
|
61
|
+
escritas antes de esta versión no lo tienen y no hay de dónde sacárselo. Lo que sí falla es un valor
|
|
62
|
+
inventado. Si cerrás a mano, agregá `lane:` a la entrada; si cerrás con `autobuild`, ya lo escribe.
|
|
63
|
+
|
|
64
|
+
## [0.75.0] - 2026-09-10
|
|
65
|
+
|
|
66
|
+
### Cambiado
|
|
67
|
+
|
|
68
|
+
- **R9 dice ahora que una quita casi nunca se ve como una quita, y que eso no admite excepción.** Se
|
|
69
|
+
escribe como un agregado —una bandera que se pone, una variable que se exporta— y lo que la delata es
|
|
70
|
+
la frase que la justifica: «lo agrego **para que** deje de …». Silenciar un aviso, saltear una rama o
|
|
71
|
+
desarmar una confirmación son quitas, y una quita no se entrega sin su aserción de ausencia vista en
|
|
72
|
+
rojo devolviendo lo quitado.
|
|
73
|
+
|
|
74
|
+
Con un renglón propio para el caso que más se disfraza: **una confirmación que estorba casi siempre
|
|
75
|
+
está cuidando algo**, y si no sabés qué, eso es el resultado de la medición y no un permiso para
|
|
76
|
+
seguir. Lo que corresponde es quitarle a la herramienta el motivo de preguntar, no la pregunta.
|
|
77
|
+
|
|
78
|
+
**Lo que te pide algo**: es una regla del sistema, así que baja a tu `planning/` en el próximo
|
|
79
|
+
`upgrade` y aplica a todo cambio, no sólo a los del toolkit.
|
|
80
|
+
|
|
81
|
+
- **Un punto y coma dentro de la prosa de `tests:` ya no parte la traza.** El `;` es el separador que R8
|
|
82
|
+
fija y a la vez el signo más común de la prosa española, y ese campo pide las dos cosas: el contrato
|
|
83
|
+
pide `CN → prueba` y R9 pide decir cómo se vio fallar esa prueba. Una sola traza con prosa se partía en
|
|
84
|
+
tres y `check` rechazaba el campo entero mandando a revisar la traza, que era lo único que estaba bien
|
|
85
|
+
— y la salida fácil era acortar la prosa hasta que pasara, o sea empobrecer justo la evidencia.
|
|
86
|
+
|
|
87
|
+
Ahora se corta sólo cuando detrás **empieza otra traza**, que es la misma decisión que `commit:` ya
|
|
88
|
+
tomaba para el sha. Las entradas con varias trazas siguen valiendo igual.
|
|
89
|
+
|
|
90
|
+
**Lo que te pide algo**: si tu prosa contiene literalmente `; A → algo`, se va a partir ahí — desde
|
|
91
|
+
afuera es indistinguible de dos trazas. Es la misma concesión que `commit:` aceptó.
|
|
92
|
+
|
|
93
|
+
- **Un caso no se cierra sin haberlo probado corriendo.** El `## Cierre` nombra qué se corrió y qué
|
|
94
|
+
devolvió —la salida, la mutación vista en rojo, el número medido—; «la suite pasa» no cuenta, porque
|
|
95
|
+
dice que nada de lo que ya había se rompió y no que esto funcione. Vale igual cuando se decide no
|
|
96
|
+
arreglar: ahí se prueba el dato que sostiene la decisión. La puerta lo comprueba desde esta versión y
|
|
97
|
+
no hacia atrás, por lo mismo que el contraste rige desde 0.65.0.
|
|
98
|
+
|
|
99
|
+
### Corregido
|
|
100
|
+
|
|
101
|
+
- **`autobuild` deja de reintentar un reclamo que no puede salir bien.** Cuando la cola vuelve a ofrecer
|
|
102
|
+
la misma tarea después de un `claim` fallido, nadie la tomó: el reclamo falló por su cuenta y repetirlo
|
|
103
|
+
no cambia nada. Antes se repetía hasta agotar el cupo de tareas de la corrida — en una corrida real
|
|
104
|
+
**28 de 50 agentes** se fueron ahí, sin construir nada y sin que nada lo dijera. Ahora para con
|
|
105
|
+
`claim-stuck`, y el motivo lleva el slug que la cola ofreció y lo que contestó el reclamo. Perder la
|
|
106
|
+
carrera de verdad sigue sin frenar: ahí la cola pasa a ofrecer otra tarea, y ahora la corrida dice con
|
|
107
|
+
cuál sigue. Lo mismo en Decompose: si tras pedir la partición la cola sigue ofreciendo la tarea sin
|
|
108
|
+
partir, la escritura no ocurrió y para con `split-not-applied`.
|
|
109
|
+
|
|
110
|
+
- **El registro de una corrida dice qué hace cada agente.** Las llamadas sin nombre se veían como el
|
|
111
|
+
arranque del preámbulo compartido, que es igual en todas: la misma cadena repetida treinta veces, y un
|
|
112
|
+
bucle de veintiocho agentes indistinguible de trabajo. Las veintisiete llamadas del recorrido llevan
|
|
113
|
+
etiqueta propia.
|
|
114
|
+
|
|
115
|
+
- **Un gate ya no puede borrar el `node_modules` de tu proyecto, y se revierte el `CI=true` de 0.74.0.**
|
|
116
|
+
Esa variable resolvía el síntoma del caso anterior —pnpm dejaba de preguntar antes de purgar—
|
|
117
|
+
desarmando la confirmación en vez de quitarle el motivo. Y esa confirmación era lo único que protegía
|
|
118
|
+
al árbol enlazado: sin ella la reinstalación avanza y **borra por el enlace**. Medido: `require()`
|
|
119
|
+
dejaba de encontrar las dependencias del proyecto, y la instalación ni siquiera necesita completarse
|
|
120
|
+
para hacerlo — en la corrida medida abortó por `frozen-lockfile` y para entonces ya había borrado.
|
|
121
|
+
|
|
122
|
+
Ahora la copia lleva `verify-deps-before-run=false`, que le dice a pnpm que no sincronice nada antes de
|
|
123
|
+
correr el script. No hay purga que confirmar, así que no hay confirmación que desarmar.
|
|
124
|
+
|
|
125
|
+
**Lo que te pide algo**: si tenías un gate que se portaba distinto bajo `CI`, deja de verla. Vuelven el
|
|
126
|
+
color y los prompts de otras herramientas — y un prompt en un proceso sin terminal aborta, que es la
|
|
127
|
+
barrera que se quiere de vuelta.
|
|
128
|
+
|
|
17
129
|
## [0.74.0] - 2026-09-10
|
|
18
130
|
|
|
19
131
|
### Corregido
|
|
@@ -332,6 +332,14 @@ const LEDGER = `${SCOPE}\n\nContratos de planning, textuales de ${P}/PROTOCOL.md
|
|
|
332
332
|
// con un TypeError en la fase que sea, y lo que quedó a medias es una tarea con WIP escrito y código
|
|
333
333
|
// sin revisar. Cada llamada corta con su etapa puesta: «no contestó» no es lo mismo que «dijo que no»,
|
|
334
334
|
// y sólo la segunda significa que alguien juzgó algo.
|
|
335
|
+
//
|
|
336
|
+
// Y cada llamada lleva su `label`. Sin él el runtime muestra el arranque del prompt, que acá es el
|
|
337
|
+
// preámbulo compartido: en cinco corridas reales el journal repitió treinta veces la misma cadena
|
|
338
|
+
// —«Nunca inventes credenciales ni decisiones; registrá »— y un bucle de veintiocho agentes pidiendo
|
|
339
|
+
// la misma tarea se vio igual que trabajo. Al revés que `phase`, esto no se puede envolver: el nombre
|
|
340
|
+
// de la fase no alcanza —Critique planifica y critica, Review revisa y manda a corregir— y esa es
|
|
341
|
+
// justo la distinción que hace falta. Lo que evita el olvido es el arnés, que rechaza la llamada sin
|
|
342
|
+
// etiqueta en las cuatro suites del recorrido.
|
|
335
343
|
const read = (prompt, options = {}) => agent(`${BASE}\n\n${prompt}`, options)
|
|
336
344
|
const run = (prompt, options = {}) => agent(`${SCOPE}\n\n${prompt}`, options)
|
|
337
345
|
const write = (prompt, options = {}) => agent(`${LEDGER}\n\n${prompt}`, options)
|
|
@@ -365,6 +373,9 @@ const completed = []
|
|
|
365
373
|
// para siempre. Se corta con motivo porque agotarlo en silencio se lee igual que haber terminado.
|
|
366
374
|
const MAX_TASKS = 50
|
|
367
375
|
let rounds = 0
|
|
376
|
+
// Lo dicen las dos vueltas que cambian de tarea a mitad de corrida —la carrera perdida y la partición—,
|
|
377
|
+
// que no son errores y por eso no salen por `stop`.
|
|
378
|
+
const nextUp = (state) => (state.hasTask ? state.slug : '(nada más en cola)')
|
|
368
379
|
// Tareas que ya pasaron por el clasificador en esta corrida. Sin esto, una que vuelve sin lane
|
|
369
380
|
// —porque la escritura falló o el modelo la salteó— se reclasifica en cada vuelta del bucle.
|
|
370
381
|
const classified = new Set()
|
|
@@ -400,6 +411,28 @@ while (rounds++ < MAX_TASKS) {
|
|
|
400
411
|
if (!reserva || !reserva.claimed) {
|
|
401
412
|
planning = await readContext()
|
|
402
413
|
if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
|
|
414
|
+
// Perder la carrera es legítimo y se ve en que la cola pasa a ofrecer **otra** tarea: quien la
|
|
415
|
+
// tomó ya la reclamó, así que `context` la saltea. Que vuelva a ofrecer la misma significa lo
|
|
416
|
+
// contrario — que nadie la tiene y el reclamo falló por su cuenta—, y eso no mejora repitiendo.
|
|
417
|
+
//
|
|
418
|
+
// Reintentar igual costó 28 de los 50 agentes de una corrida real, trece sobre un slug y quince
|
|
419
|
+
// sobre otro, sin construir nada y sin que nada lo dijera: el único tope es `MAX_TASKS`, así que
|
|
420
|
+
// el presupuesto de reintentos **es** el de tareas y una tarea irreclamable se lleva la corrida
|
|
421
|
+
// (caso 071).
|
|
422
|
+
//
|
|
423
|
+
// La parada nombra las dos cosas que hacen falta para saber cuál de los dos defectos fue: el slug
|
|
424
|
+
// que `context` entregó y lo que el reclamo contestó. Si el mensaje dice que ese slug no está en
|
|
425
|
+
// BACKLOG, los dos comandos discrepan sobre la misma cola; si dice otra cosa, el comando se compuso
|
|
426
|
+
// distinto del que se pidió.
|
|
427
|
+
if (planning.hasTask && planning.slug === task.id && !planning.claimed) {
|
|
428
|
+
return stop('claim-stuck', `${task.id} sigue siendo la próxima tarea y no se pudo reclamar. `
|
|
429
|
+
+ `context la ofrece y claim la rechaza, así que repetir no cambia nada. `
|
|
430
|
+
+ `El reclamo contestó: ${(reserva && reserva.details) || '(sin detalle)'}`)
|
|
431
|
+
}
|
|
432
|
+
// Con qué sigue, que es lo que cambia respecto de lo que esperaba quien autorizó la corrida: se
|
|
433
|
+
// pidió un hito y se va a construir otra tarea de ese hito. Sin decirlo, el cambio sólo aparece al
|
|
434
|
+
// final, en un cierre que nombra algo que nadie mandó a hacer.
|
|
435
|
+
log(`${task.id} la tomó otro: la corrida sigue con ${nextUp(planning)}`)
|
|
403
436
|
continue
|
|
404
437
|
}
|
|
405
438
|
}
|
|
@@ -424,7 +457,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
424
457
|
`corchetes después del slug y el reparto al final de la línea, con la forma ` +
|
|
425
458
|
`"(cast: quien-entrega → quien-revisa, otro)". No toques nada más de la línea, ni el orden del hito, ` +
|
|
426
459
|
`ni las tareas que ya declaran las dos cosas. Reportá lo que escribiste.`,
|
|
427
|
-
{ schema: CLASSIFICATION },
|
|
460
|
+
{ schema: CLASSIFICATION, label: 'classify' },
|
|
428
461
|
)
|
|
429
462
|
if (classification && classification.classified.length) {
|
|
430
463
|
log(`Clasificadas: ${classification.classified
|
|
@@ -479,11 +512,12 @@ while (rounds++ < MAX_TASKS) {
|
|
|
479
512
|
`${asRole(OWNERS.ready)}Revisá que ${task.id} tenga aceptación concreta, dependencias resueltas y ` +
|
|
480
513
|
`ninguna decisión pendiente: ${task.acceptance}. Aclará la redacción y nada más; nunca amplíes el ` +
|
|
481
514
|
`alcance.`,
|
|
482
|
-
{ schema: READY },
|
|
515
|
+
{ schema: READY, label: 'ready' },
|
|
483
516
|
)
|
|
484
517
|
if (!ready) return stop('agent-unavailable', 'Ready no devolvió resultado')
|
|
485
518
|
if (!ready.ready) {
|
|
486
|
-
await write(`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}
|
|
519
|
+
await write(`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
|
|
520
|
+
{ label: 'ready-human' })
|
|
487
521
|
return stop('not-ready', ready.reason)
|
|
488
522
|
}
|
|
489
523
|
if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
|
|
@@ -493,14 +527,22 @@ while (rounds++ < MAX_TASKS) {
|
|
|
493
527
|
phase('Decompose')
|
|
494
528
|
const estimate = await run(
|
|
495
529
|
`Inspeccioná ${task.service} y estimá ${task.id}. Partila sólo si supera ${contract.maxTaskHours} horas.`,
|
|
496
|
-
{ schema: ESTIMATE },
|
|
530
|
+
{ schema: ESTIMATE, label: 'estimate' },
|
|
497
531
|
)
|
|
498
532
|
if (!estimate) return stop('agent-unavailable', 'Decompose no devolvió resultado')
|
|
499
533
|
if (estimate.needsSplit) {
|
|
500
534
|
await write(`Reemplazá sólo ${task.id} en ${BACKLOG} por subtareas ordenadas y verificables de forma ` +
|
|
501
|
-
`independiente: ${JSON.stringify(estimate.subtasks)}
|
|
535
|
+
`independiente: ${JSON.stringify(estimate.subtasks)}.`, { label: 'split' })
|
|
502
536
|
planning = await readContext()
|
|
503
537
|
if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
|
|
538
|
+
// Misma forma que en Claim: si la cola sigue ofreciendo lo mismo, el estado no cambió y repetir
|
|
539
|
+
// no lo va a cambiar. Acá lo que no cambió es una escritura que se le pidió a un agente, y darla
|
|
540
|
+
// por hecha manda al bucle a partir la misma tarea otra vez.
|
|
541
|
+
if (planning.hasTask && planning.slug === task.id) {
|
|
542
|
+
return stop('split-not-applied', `se pidió reemplazar ${task.id} en ${BACKLOG} por sus `
|
|
543
|
+
+ 'subtareas y la cola sigue ofreciéndola: la escritura no ocurrió como se pidió.')
|
|
544
|
+
}
|
|
545
|
+
log(`${task.id} quedó partida: la corrida sigue con ${nextUp(planning)}`)
|
|
504
546
|
continue
|
|
505
547
|
}
|
|
506
548
|
}
|
|
@@ -519,7 +561,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
519
561
|
`sólo el cambio dentro de ${task.service}: correr los gates del repositorio, hacer QA, commitear y ` +
|
|
520
562
|
`cerrar la tarea son fases posteriores de este recorrido, cada una con su dueño, así que no van como ` +
|
|
521
563
|
`pasos.`,
|
|
522
|
-
{ schema: PLAN },
|
|
564
|
+
{ schema: PLAN, label: 'plan' },
|
|
523
565
|
)
|
|
524
566
|
if (!plan) return stop('agent-unavailable', 'Plan no devolvió resultado')
|
|
525
567
|
if (!lite) {
|
|
@@ -527,7 +569,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
527
569
|
let critique = await read(
|
|
528
570
|
`Atacá este plan por correctitud, alcance, seguridad, pruebas y conflictos con el código ` +
|
|
529
571
|
`existente.${MANIFEST}${VERDICT} Plan: ${JSON.stringify(plan)}`,
|
|
530
|
-
{ schema: DECISION },
|
|
572
|
+
{ schema: DECISION, label: 'critique' },
|
|
531
573
|
)
|
|
532
574
|
if (!critique) return stop('agent-unavailable', 'Critique no devolvió resultado')
|
|
533
575
|
// Un plan bloqueado no se corrige: lo que lo bloquea está fuera de lo que una segunda pasada puede
|
|
@@ -538,12 +580,12 @@ while (rounds++ < MAX_TASKS) {
|
|
|
538
580
|
if (blockers(critique).length) {
|
|
539
581
|
plan = await read(
|
|
540
582
|
`Corregí el plan una vez por: ${blockers(critique).join('; ')}. Plan: ${JSON.stringify(plan)}`,
|
|
541
|
-
{ schema: PLAN },
|
|
583
|
+
{ schema: PLAN, label: 'replan' },
|
|
542
584
|
)
|
|
543
585
|
critique = await read(
|
|
544
586
|
`Volvé a criticar el plan corregido contra ${task.acceptance}.${MANIFEST}${VERDICT} ` +
|
|
545
587
|
`Plan: ${JSON.stringify(plan)}`,
|
|
546
|
-
{ schema: DECISION },
|
|
588
|
+
{ schema: DECISION, label: 'critique' },
|
|
547
589
|
)
|
|
548
590
|
if (!plan || !critique) return stop('agent-unavailable', 'la revisión del plan no devolvió resultado')
|
|
549
591
|
if (critique.verdict === 'bloqueado' || blockers(critique).length) {
|
|
@@ -565,10 +607,11 @@ while (rounds++ < MAX_TASKS) {
|
|
|
565
607
|
`Escribí el WIP y nada más: no toques código, no corras pruebas, no cierres la tarea y no escribas ` +
|
|
566
608
|
`en DONE. Los pasos van sin tildar porque todavía no ocurrieron. task=${task.id}, ` +
|
|
567
609
|
`hito=${JSON.stringify(task.hito)}, phase=Build, service=${task.service}, ` +
|
|
568
|
-
`acceptance=${JSON.stringify(task.acceptance)},
|
|
610
|
+
`acceptance=${JSON.stringify(task.acceptance)}, lane=${planning.lane || 'sin clasificar'}, ` +
|
|
611
|
+
`pasos sin tildar=${JSON.stringify(plan.steps)}. ` +
|
|
569
612
|
`Registrá el reparto de cargos ${JSON.stringify(cast)} en las decisiones del WIP, para que después se ` +
|
|
570
613
|
`pueda auditar quién revisó qué. Seguí el contrato de WIP exactamente y reportá con qué status quedó.`,
|
|
571
|
-
{ schema: {
|
|
614
|
+
{ label: 'wip', schema: {
|
|
572
615
|
type: 'object', additionalProperties: false, required: ['wipActive'],
|
|
573
616
|
properties: { wipActive: { type: 'boolean' }, note: { type: 'string' } },
|
|
574
617
|
} },
|
|
@@ -593,7 +636,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
593
636
|
`registra para que lo decida quien corresponde y el recorrido sigue. Si de verdad no podés entregar sin ` +
|
|
594
637
|
`esa decisión, eso no va en discovered: es completed=false con su blocker. ` +
|
|
595
638
|
`Aceptación: ${task.acceptance}.`,
|
|
596
|
-
{ schema: BUILD },
|
|
639
|
+
{ schema: BUILD, label: 'build' },
|
|
597
640
|
)
|
|
598
641
|
if (!build) return stop('agent-unavailable', 'Build no devolvió resultado')
|
|
599
642
|
if (!build.completed) return stop('build-blocked', (build.blockers || []).join('; ') || build.summary)
|
|
@@ -615,7 +658,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
615
658
|
if (openDecisions.length) {
|
|
616
659
|
await write(`Registrá en ${HUMAN} una fila por cada decisión que ${task.id} dejó abierta, con qué la ` +
|
|
617
660
|
`cierra y quién puede tomarla. No inventes responsables ni fechas: ` +
|
|
618
|
-
`${JSON.stringify(openDecisions.map((entry) => entry.detail))}
|
|
661
|
+
`${JSON.stringify(openDecisions.map((entry) => entry.detail))}`, { label: 'open-decisions' })
|
|
619
662
|
}
|
|
620
663
|
// Y un caso que sí se fijó acá entra con su prueba o no entró: sin ella el comportamiento nuevo queda
|
|
621
664
|
// sin nada que lo sostenga, y nadie sabe después que debía existir.
|
|
@@ -639,16 +682,17 @@ while (rounds++ < MAX_TASKS) {
|
|
|
639
682
|
`${asRole(cast.review)}Revisá el diff real por aceptación, regresiones, seguridad, arquitectura, código ` +
|
|
640
683
|
`generado, migraciones y alcance accidental. Cada cargo revisa su dominio, no el ajeno.${MANIFEST}` +
|
|
641
684
|
`${VERDICT}`,
|
|
642
|
-
{ schema: DECISION },
|
|
685
|
+
{ schema: DECISION, label: 'review' },
|
|
643
686
|
)
|
|
644
687
|
if (!review) return stop('agent-unavailable', 'Review no devolvió resultado')
|
|
645
688
|
if (review.verdict === 'bloqueado') {
|
|
646
689
|
return stop('review-blocked', blockers(review).join('; ') || 'sin condiciones nombradas')
|
|
647
690
|
}
|
|
648
691
|
if (blockers(review).length) {
|
|
649
|
-
await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}
|
|
692
|
+
await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}`,
|
|
693
|
+
{ label: 'review-fix' })
|
|
650
694
|
review = await run(`Volvé a revisar el diff corregido de ${task.id}.${MANIFEST}${VERDICT}`,
|
|
651
|
-
{ schema: DECISION })
|
|
695
|
+
{ schema: DECISION, label: 'review' })
|
|
652
696
|
if (!review) return stop('agent-unavailable', 'la re-revisión no devolvió resultado')
|
|
653
697
|
if (review.verdict === 'bloqueado' || blockers(review).length) {
|
|
654
698
|
return stop('review-failed', blockers(review).join('; ') || 'sin condiciones nombradas')
|
|
@@ -664,7 +708,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
664
708
|
const noted = review.concerns.filter((one) => !one.blocking).map((one) => one.detail)
|
|
665
709
|
if (noted.length) {
|
|
666
710
|
await write(`Registrá en la sección Propuestas de ${P}/INBOX.md lo que la revisión de ${task.id} dejó ` +
|
|
667
|
-
`anotado sin frenar la entrega, sin promover ninguna: ${JSON.stringify(noted)}
|
|
711
|
+
`anotado sin frenar la entrega, sin promover ninguna: ${JSON.stringify(noted)}`, { label: 'review-noted' })
|
|
668
712
|
}
|
|
669
713
|
}
|
|
670
714
|
|
|
@@ -687,7 +731,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
687
731
|
`passed=true exige comandos corridos y ninguna regresión causada por la tarea. Marcá ranTests en el ` +
|
|
688
732
|
`comando que haya corrido las pruebas, sea cual sea su nombre. ` +
|
|
689
733
|
`Aceptación: ${task.acceptance}.`
|
|
690
|
-
let verified = await run(VERIFY_ASK, { schema: VERIFY })
|
|
734
|
+
let verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
|
|
691
735
|
if (!verified) return stop('agent-unavailable', 'Verify no devolvió resultado')
|
|
692
736
|
// Un criterio que nadie sabe cómo aserciar no es trabajo que falta sino una definición que falta, y
|
|
693
737
|
// definirla acá sería inventarla. Escribir la prueba que falta, en cambio, es trabajo del recorrido:
|
|
@@ -695,13 +739,14 @@ while (rounds++ < MAX_TASKS) {
|
|
|
695
739
|
const ambiguous = verified.uncovered.find((entry) => entry.cause === 'ambiguous')
|
|
696
740
|
if (ambiguous) {
|
|
697
741
|
await write(`Registrá ${task.id} en ${HUMAN}: el criterio "${ambiguous.criterion}" no dice qué habría ` +
|
|
698
|
-
`que aserciar, y hace falta la decisión que lo fija
|
|
742
|
+
`que aserciar, y hace falta la decisión que lo fija.`, { label: 'verify-human' })
|
|
699
743
|
return stop('acceptance-ambiguous', ambiguous.criterion)
|
|
700
744
|
}
|
|
701
745
|
if (verified.uncovered.length) {
|
|
702
746
|
await run(`${asRole(cast.build)}Escribí sólo las pruebas que faltan en ${task.id}, con el mismo rojo ` +
|
|
703
|
-
`previo, y no toques el código de producción: ${verified.uncovered.map((e) => e.criterion).join('; ')}
|
|
704
|
-
|
|
747
|
+
`previo, y no toques el código de producción: ${verified.uncovered.map((e) => e.criterion).join('; ')}`,
|
|
748
|
+
{ label: 'missing-tests' })
|
|
749
|
+
verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
|
|
705
750
|
if (!verified) return stop('agent-unavailable', 'la segunda pasada de Verify no devolvió resultado')
|
|
706
751
|
}
|
|
707
752
|
if (!verified.passed || !verified.commands.length) return stop('verify-failed', verified.details)
|
|
@@ -726,7 +771,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
726
771
|
: 'Ejercitá el comportamiento real que ve quien lo usa'} para ` +
|
|
727
772
|
`${task.id}. Las pruebas unitarias solas no son QA. Levantá el mínimo runtime necesario y bajalo ` +
|
|
728
773
|
`después. Aceptación: ${task.acceptance}.`,
|
|
729
|
-
{ schema: QA },
|
|
774
|
+
{ schema: QA, label: 'qa' },
|
|
730
775
|
)
|
|
731
776
|
if (!qa) return stop('agent-unavailable', 'QA no devolvió resultado')
|
|
732
777
|
if (!qa.passed) return stop('qa-failed', qa.evidence)
|
|
@@ -738,7 +783,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
738
783
|
`stageá por nombre los archivos de la tarea, creá un solo Conventional Commit con el footer ` +
|
|
739
784
|
`"Task: ${task.id}" y después verificá log y status. Nunca amend ni push; reportá lo que quedó suelto ` +
|
|
740
785
|
`y no era de la tarea.`,
|
|
741
|
-
{ schema: COMMIT },
|
|
786
|
+
{ schema: COMMIT, label: 'commit' },
|
|
742
787
|
) : { committed: true, reason: 'runner.commitPerTask está apagado' }
|
|
743
788
|
if (!commit) return stop('agent-unavailable', 'Commit no devolvió resultado')
|
|
744
789
|
if (!commit.committed) return stop('commit-failed', commit.reason)
|
|
@@ -746,13 +791,15 @@ while (rounds++ < MAX_TASKS) {
|
|
|
746
791
|
phase('Done')
|
|
747
792
|
await write(
|
|
748
793
|
`Cerrá ${task.id} de forma atómica: escribí ${doneFile(task.id)} con su evidencia —acept, ` +
|
|
749
|
-
`fecha: ${planning.today}, done, qa, tests y
|
|
794
|
+
`fecha: ${planning.today}, done, qa, tests, commit, lane y review, en el formato de entrada que trae ` +
|
|
795
|
+
`este preámbulo—; ` +
|
|
750
796
|
`sacala junto con sus notas indentadas de ${BACKLOG}; cerrá su épica sólo si no queda ` +
|
|
751
797
|
`ninguna tarea etiquetada; dejá ${P}/${planning.wipFile} en status IDLE; y soltá la reserva corriendo ` +
|
|
752
798
|
`"node tools/ops.js release ${P} ${task.id}". En decisions no nombres una fase ni un cargo ` +
|
|
753
799
|
`que no figure en estos hechos. Hechos: lane=${planning.lane || 'sin clasificar'}; ` +
|
|
754
800
|
`review=${reviewFact}; fases=${ran.join(' → ')}; build=${build.summary}; ` +
|
|
755
801
|
`verify=${JSON.stringify(verified.commands)}; qa=${qa.evidence}; commit=${commit.hash || commit.reason}.`,
|
|
802
|
+
{ label: 'done' },
|
|
756
803
|
)
|
|
757
804
|
completed.push(task.id)
|
|
758
805
|
planning = await readContext()
|
|
@@ -767,6 +814,7 @@ phase('Closing')
|
|
|
767
814
|
const closing = await write(
|
|
768
815
|
`Corré "node tools/ops.js check ${P}" desde ${ROOT}. Si sale en rojo, reparás sólo estado derivado ` +
|
|
769
816
|
`determinista; nunca reescribas aceptación ni decisiones para forzar el verde.`, {
|
|
817
|
+
label: 'closing',
|
|
770
818
|
schema: {
|
|
771
819
|
type: 'object', required: ['passed', 'details'],
|
|
772
820
|
properties: { passed: { type: 'boolean' }, details: { type: 'string' } },
|
|
@@ -778,5 +826,6 @@ if (!closing.passed) return stop('planning-check-failed', closing.details)
|
|
|
778
826
|
if (completed.length && contract.humanCheckpoint) await write(
|
|
779
827
|
`Creá ${GATE} con el hito terminado, las tareas ${completed.join(', ')}, la evidencia, las acciones humanas ` +
|
|
780
828
|
`pendientes y las instrucciones exactas para continuar. Nunca hagas push ni deploy.`,
|
|
829
|
+
{ label: 'human-checkpoint' },
|
|
781
830
|
)
|
|
782
831
|
return finish({ done: completed, count: completed.length, hito: currentMilestone, phases: ran })
|
package/engine/cli/archive.js
CHANGED
|
@@ -11,7 +11,7 @@ const P = require('../planning/parser')
|
|
|
11
11
|
const PC = require('../planning/contracts')
|
|
12
12
|
const AD = require('../planning/adoption')
|
|
13
13
|
const F = require('../core/files')
|
|
14
|
-
const { fail } = require('./io')
|
|
14
|
+
const { fail, planningRoot } = require('./io')
|
|
15
15
|
|
|
16
16
|
// La fecha de hoy, la misma que usan los comandos que leen.
|
|
17
17
|
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
@@ -24,7 +24,7 @@ const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
|
24
24
|
// trabajo de arreglar, y uno que crece a mano deja de ser una lista de perdones para ser una amnistía.
|
|
25
25
|
// Achicarlo sí es a mano, borrando el renglón que `check` señala.
|
|
26
26
|
function adopt(dir) {
|
|
27
|
-
const root =
|
|
27
|
+
const root = planningRoot(dir)
|
|
28
28
|
const target = path.join(root, AD.BASELINE)
|
|
29
29
|
if (fs.existsSync(target)) {
|
|
30
30
|
// Un baseline que ya trae huella no se toca: regenerarlo es exactamente lo que la huella impide.
|
|
@@ -79,7 +79,7 @@ function archiveHumanActions(root) {
|
|
|
79
79
|
// no hace falta: sin esto contestaría «La épica debe ser NNN», que manda a corregir la forma de algo que
|
|
80
80
|
// no existe.
|
|
81
81
|
function archive(dir, rawNum) {
|
|
82
|
-
if (String(rawNum || '') === 'human-actions') return archiveHumanActions(
|
|
82
|
+
if (String(rawNum || '') === 'human-actions') return archiveHumanActions(planningRoot(dir))
|
|
83
83
|
return fail('Sólo se archiva `human-actions`. La evidencia de una tarea ya vive en su propio archivo '
|
|
84
84
|
+ 'de `done/`, así que archivar una épica dejó de tener sentido.', 2)
|
|
85
85
|
}
|
package/engine/cli/catalog.js
CHANGED
|
@@ -66,6 +66,45 @@ function agents(action, dir, extra, cli) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Qué decir cuando el banco sobrevivió a su propio borrado, que es lo único que va a permitir
|
|
70
|
+
// establecer la causa. Devuelve el mensaje en vez de escribirlo donde ocurre, y eso es lo que lo hace
|
|
71
|
+
// medible sin provocar el fallo; por qué eso importa acá lo dice su prueba.
|
|
72
|
+
//
|
|
73
|
+
// Tres cosas que el listado anterior no traía, y cada una separa dos diagnósticos distintos:
|
|
74
|
+
//
|
|
75
|
+
// - **Cuánto**, y no una muestra. Cortaba en cinco, así que «borró casi todo y quedaron cuatro objetos»
|
|
76
|
+
// y «no borró nada» se leían idénticos, y son problemas opuestos.
|
|
77
|
+
// - **Si lo que quedó es anterior al borrado o se escribió durante.** Posterior significa que alguien
|
|
78
|
+
// reescribió mientras borrábamos; anterior, que el borrado no lo tocó. Es la pregunta central del
|
|
79
|
+
// caso y la contesta la fecha de modificación.
|
|
80
|
+
// - **Qué hace un segundo borrado.** No lo rodea: quien lo llama corta igual.
|
|
81
|
+
// Distingue lo transitorio de lo permanente, que se arreglan distinto.
|
|
82
|
+
function benchSurvived(dir, since) {
|
|
83
|
+
let files = 0
|
|
84
|
+
let dirs = 0
|
|
85
|
+
const sample = []
|
|
86
|
+
const walk = (base, relative = '') => {
|
|
87
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
88
|
+
const next = relative ? `${relative}/${entry.name}` : entry.name
|
|
89
|
+
if (entry.isDirectory()) { dirs += 1; walk(path.join(base, entry.name), next); continue }
|
|
90
|
+
files += 1
|
|
91
|
+
if (sample.length >= 5) continue
|
|
92
|
+
const stat = fs.statSync(path.join(base, entry.name), { throwIfNoEntry: false })
|
|
93
|
+
sample.push(`${next} (${!stat ? 'ya no está'
|
|
94
|
+
: stat.mtimeMs >= since ? 'escrito durante el borrado' : 'anterior al borrado'})`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try { walk(dir) } catch { /* el listado es la explicación, no la comprobación */ }
|
|
98
|
+
let again = 'no se pudo reintentar'
|
|
99
|
+
try {
|
|
100
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
101
|
+
again = fs.existsSync(dir) ? 'un segundo borrado tampoco lo sacó' : 'un segundo borrado sí lo sacó'
|
|
102
|
+
} catch (error) { again = `un segundo borrado lanzó ${error.code || error.message}` }
|
|
103
|
+
return `${dir} no se pudo borrar entero y el banco tiene que ser nuevo. Sobrevivieron ${files} `
|
|
104
|
+
+ `archivo(s) en ${dirs} directorio(s), con Node ${process.version}: `
|
|
105
|
+
+ `${sample.join(', ') || '(sólo directorios)'}. ${again}. Borralo a mano y volvé a correr.`
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
// Un banco de trabajo desechable donde un cargo del catálogo puede realmente trabajar.
|
|
70
109
|
//
|
|
71
110
|
// Hace falta porque el toolkit no es una raíz ops: el único `planning/` que vive acá es
|
|
@@ -96,43 +135,35 @@ function evaluationBench(root, agent, caso, force, kind) {
|
|
|
96
135
|
fail(`${dir} tiene trabajo sin recoger. Guardá el registro de esa corrida antes de rehacerlo, `
|
|
97
136
|
+ 'o usá --force si ya lo tenés.', 2)
|
|
98
137
|
}
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
// reintentos
|
|
103
|
-
//
|
|
138
|
+
// El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que
|
|
139
|
+
// el borrado no tocó de uno que alguien reescribió mientras borrábamos.
|
|
140
|
+
const since = Date.now()
|
|
141
|
+
// Con reintentos. Los puso el `ENOTEMPTY` que aparecía al rehacer un banco recién creado, y hoy se
|
|
142
|
+
// sabe que eso era el mantenimiento de git escribiendo por detrás —la causa está apagada quince líneas
|
|
143
|
+
// más abajo, en la creación—. Se quedan porque cubren a cualquier otro escritor transitorio, no porque
|
|
144
|
+
// sigan tapando éste; sacarlos es una decisión aparte y lo que la activaría es que nunca más disparen.
|
|
104
145
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
105
|
-
// Y se comprueba que haya borrado. `rmSync` puede volver sin lanzar y dejar cosas
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
146
|
+
// Y se comprueba que haya borrado. `rmSync` puede volver sin lanzar y dejar cosas, y hasta acá cada
|
|
147
|
+
// síntoma se rodeaba por separado: `force` en el andamiaje, un `rm` antes del enlace. Rodearlo deja la
|
|
148
|
+
// corrida siguiendo sobre un banco que no es nuevo, y lo que falla después no dice nada del borrado: el
|
|
149
|
+
// test que lo destapó reportaba `true !== false` sobre un archivo de la corrida anterior, sin nombrar
|
|
150
|
+
// de dónde salía.
|
|
151
|
+
//
|
|
152
|
+
// Esta guarda es la que estableció la causa: fue su primer disparo instrumentado el que nombró al
|
|
153
|
+
// escritor. Se queda igual —lo que cubre ahora es que aparezca otro—.
|
|
110
154
|
//
|
|
111
155
|
// Falla en vez de seguir, porque un banco a medio borrar contamina la medición que viene, que es lo
|
|
112
|
-
// que la recreación existe para evitar.
|
|
113
|
-
|
|
114
|
-
if (fs.existsSync(dir)) {
|
|
115
|
-
const sobreviven = []
|
|
116
|
-
const recorrer = (base, relative = '') => {
|
|
117
|
-
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
118
|
-
if (sobreviven.length >= 5) return
|
|
119
|
-
const next = relative ? `${relative}/${entry.name}` : entry.name
|
|
120
|
-
if (entry.isDirectory()) recorrer(path.join(base, entry.name), next)
|
|
121
|
-
else sobreviven.push(next)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
try { recorrer(dir) } catch { /* el listado es la explicación, no la comprobación */ }
|
|
125
|
-
fail(`${dir} no se pudo borrar entero y el banco tiene que ser nuevo. Sobrevivieron al borrado: `
|
|
126
|
-
+ `${sobreviven.join(', ') || '(sólo directorios)'}. Borralo a mano y volvé a correr.`, 2)
|
|
127
|
-
}
|
|
156
|
+
// que la recreación existe para evitar. Qué trae el mensaje y por qué, en `benchSurvived`.
|
|
157
|
+
if (fs.existsSync(dir)) fail(benchSurvived(dir, since), 2)
|
|
128
158
|
// Con `force`: el banco es desechable y se acaba de borrar, así que lo que sobreviva al `rmSync` se
|
|
129
159
|
// pisa en vez de cortar la corrida. Sin esto, `copyTemplate` se niega ante cualquier archivo que
|
|
130
160
|
// quede —«El destino contiene …/AGENTS.md»— y el mismo test falló así tres veces en un día, en las
|
|
131
|
-
// dos patas de la matriz.
|
|
161
|
+
// dos patas de la matriz.
|
|
132
162
|
//
|
|
133
163
|
// No ablanda ninguna protección: la pregunta «¿acá alguien trabajó?» la contesta el `git status` de
|
|
134
164
|
// arriba, que exige `--force` explícito para seguir. Esta segunda puerta no la eligió nadie y sólo
|
|
135
|
-
// se cerraba a veces, que es la clase de freno que enseña a re-correr sin leer.
|
|
165
|
+
// se cerraba a veces, que es la clase de freno que enseña a re-correr sin leer. Ese «a veces» era el
|
|
166
|
+
// mismo escritor de fondo; con la causa apagada, esto cubre el residuo.
|
|
136
167
|
IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true, force: true })
|
|
137
168
|
// El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
|
|
138
169
|
// sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
|
|
@@ -172,6 +203,20 @@ function evaluationBench(root, agent, caso, force, kind) {
|
|
|
172
203
|
git('init', '-q')
|
|
173
204
|
git('config', 'user.email', 'banco@cauce.local')
|
|
174
205
|
git('config', 'user.name', 'banco de evaluación')
|
|
206
|
+
// Y se le apaga el mantenimiento automático, que es el escritor de fondo que rompía el borrado del
|
|
207
|
+
// banco siguiente. `git commit` lanza `git maintenance run --auto`, que se detacha y sigue escribiendo
|
|
208
|
+
// en `.git/objects` después de que el comando ya volvió; el banco se rehace milisegundos más tarde y
|
|
209
|
+
// el `rmSync` corre contra alguien que está escribiendo ahí.
|
|
210
|
+
//
|
|
211
|
+
// Es lo que produjo los tres síntomas que se venían rodeando por separado —`ENOTEMPTY`, `EEXIST`, y el
|
|
212
|
+
// borrado que vuelve sin lanzar y deja archivos—. La guarda lo nombró el 2026-09-10:
|
|
213
|
+
// `maintenance.lock` entre los sobrevivientes, y `info/refs` y `objects/info/packs` fechados **durante**
|
|
214
|
+
// el borrado, en un árbol que ninguna otra prueba toca (caso 073).
|
|
215
|
+
//
|
|
216
|
+
// `maintenance.auto=false` y no `gc.auto=0`: medido con `GIT_TRACE=1`, el segundo deja que el commit
|
|
217
|
+
// lance el mantenimiento igual —sólo hace que su tarea de `gc` no encuentre trabajo— y el proceso
|
|
218
|
+
// toma su lock y escribe lo mismo. Se le quita el motivo de lanzarlo, no lo que hace una vez lanzado.
|
|
219
|
+
git('config', 'maintenance.auto', 'false')
|
|
175
220
|
git('add', '-A')
|
|
176
221
|
git('commit', '-q', '-m', 'banco limpio')
|
|
177
222
|
return dir
|
|
@@ -353,4 +398,4 @@ function flow(action, slug, cli) {
|
|
|
353
398
|
} catch (error) { fail(error.message, 2) }
|
|
354
399
|
}
|
|
355
400
|
|
|
356
|
-
module.exports = { agents, learn, evaluate, flow }
|
|
401
|
+
module.exports = { agents, learn, evaluate, flow, benchSurvived }
|
package/engine/cli/claims.js
CHANGED
|
@@ -9,12 +9,12 @@ const path = require('node:path')
|
|
|
9
9
|
const CL = require('../planning/claims')
|
|
10
10
|
const R = require('../core/repos')
|
|
11
11
|
const ST = require('../planning/state')
|
|
12
|
-
const { fail } = require('./io')
|
|
12
|
+
const { fail, planningRoot } = require('./io')
|
|
13
13
|
|
|
14
14
|
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
15
15
|
|
|
16
16
|
function claim(dir, slug, cli) {
|
|
17
|
-
const root =
|
|
17
|
+
const root = planningRoot(dir)
|
|
18
18
|
if (!slug) return fail('Falta el slug. `ops claim <planning-dir> <tarea>`', 2)
|
|
19
19
|
const state = ST.snapshot(root)
|
|
20
20
|
const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
|
|
@@ -82,7 +82,7 @@ function claim(dir, slug, cli) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function release(dir, slug) {
|
|
85
|
-
const root =
|
|
85
|
+
const root = planningRoot(dir)
|
|
86
86
|
if (!slug) return fail('Falta el slug. `ops release <planning-dir> <tarea>`', 2)
|
|
87
87
|
const from = CL.runner()
|
|
88
88
|
const taken = CL.read(root).find((one) => one.slug === slug)
|
|
@@ -103,7 +103,7 @@ function release(dir, slug) {
|
|
|
103
103
|
// La persona elige; el agente exporta. Pedirle a una persona que escriba una variable de entorno para
|
|
104
104
|
// retomar su propio trabajo es hacerle hacer de intérprete.
|
|
105
105
|
function runners(dir, cli) {
|
|
106
|
-
const root =
|
|
106
|
+
const root = planningRoot(dir)
|
|
107
107
|
const done = ST.snapshot(root).done
|
|
108
108
|
const abiertos = CL.read(root).filter((one) => !done.set.has(one.slug))
|
|
109
109
|
const hoy = TODAY()
|
package/engine/cli/io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs')
|
|
3
4
|
const path = require('node:path')
|
|
4
5
|
|
|
5
6
|
// Terminar la corrida con un mensaje y un código. Vive aparte porque lo usa cada familia de comandos, y
|
|
@@ -15,4 +16,32 @@ function opsRoot(dir) {
|
|
|
15
16
|
return path.resolve(dir || process.env.OPS_ROOT || '.')
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// La raíz de planning de un comando: resuelta **y comprobada**, en un solo lugar. Lo que no se pudo leer
|
|
20
|
+
// no contesta como si se hubiera leído, y eso no puede depender de que cada comando se acuerde: sobre un
|
|
21
|
+
// directorio ausente, `claim` no encuentra el slug, `evidence` no encuentra entradas y `runners` no
|
|
22
|
+
// encuentra runners, y los tres reportan ese vacío como un hecho del dominio.
|
|
23
|
+
//
|
|
24
|
+
// El daño no es el mensaje sino la acción que induce. Medido en una corrida real: `claim` contestó «no
|
|
25
|
+
// está en BACKLOG» sobre una tarea que **sí** estaba, y el recorrido mandó a una persona a promover lo
|
|
26
|
+
// único que ya estaba bien, citando la regla correcta con la conclusión al revés (caso 075). Y cuatro de
|
|
27
|
+
// los ocho comandos que lo hacían salían con **exit 0**, así que un script veía éxito.
|
|
28
|
+
//
|
|
29
|
+
// Va en la resolución y no en cada comando porque es lo que cierra la clase en vez de la instancia: la
|
|
30
|
+
// misma se arregló de a una en `stagedFiles` (0.63.0) y en `context` (0.71.0), y volvió las dos veces.
|
|
31
|
+
//
|
|
32
|
+
// La ruta va **resuelta** y no como se escribió, porque el error que ataca es de resolución: en sidecar
|
|
33
|
+
// `<empresa>-ops/planning` desde adentro de la raíz apunta a `<empresa>-ops/<empresa>-ops/planning`.
|
|
34
|
+
function planningRoot(dir) {
|
|
35
|
+
const root = path.resolve(dir || '.')
|
|
36
|
+
if (!fs.existsSync(root)) {
|
|
37
|
+
return fail(`no existe el planning en ${root} (ruta resuelta). Comprobá desde dónde estás invocando.`, 2)
|
|
38
|
+
}
|
|
39
|
+
// Existir no alcanza: un directorio cualquiera contestaría cola vacía igual. `BACKLOG.md` es el archivo
|
|
40
|
+
// del que sale la cola, así que sin él la respuesta no significa nada.
|
|
41
|
+
if (!fs.existsSync(path.join(root, 'BACKLOG.md'))) {
|
|
42
|
+
return fail(`${root} (ruta resuelta) no es un planning: falta BACKLOG.md.`, 2)
|
|
43
|
+
}
|
|
44
|
+
return root
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { fail, opsRoot, planningRoot }
|
package/engine/cli/planning.js
CHANGED
|
@@ -23,7 +23,7 @@ const OB = require('../core/onboarding')
|
|
|
23
23
|
const C = require('../config/validate')
|
|
24
24
|
const CP = require('../config/paths')
|
|
25
25
|
const AG = require('../agents/catalog')
|
|
26
|
-
const { fail } = require('./io')
|
|
26
|
+
const { fail, planningRoot } = require('./io')
|
|
27
27
|
|
|
28
28
|
// Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
|
|
29
29
|
// esos archivos tiende a quedarse con el contenido y perder la estructura: el resultado se lee entero y
|
|
@@ -36,7 +36,7 @@ const { fail } = require('./io')
|
|
|
36
36
|
// entrada de hace tres meses no tiene con qué cruzarse—. Acá se pregunta por una entrada, que es como
|
|
37
37
|
// se cierra una tarea: se escribe la evidencia y se la mira contra el árbol y contra lo que corrió.
|
|
38
38
|
function evidence(dir, cli) {
|
|
39
|
-
const root =
|
|
39
|
+
const root = planningRoot(dir)
|
|
40
40
|
const opsDir = path.join(root, '..')
|
|
41
41
|
const entries = P.readDone(root).entries
|
|
42
42
|
const slug = cli.value('--task')
|
|
@@ -76,7 +76,7 @@ function evidence(dir, cli) {
|
|
|
76
76
|
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
77
77
|
|
|
78
78
|
function check(dir, cli) {
|
|
79
|
-
const root =
|
|
79
|
+
const root = planningRoot(dir)
|
|
80
80
|
const errors = []
|
|
81
81
|
const warnings = []
|
|
82
82
|
// El plan no está: `wip/` es local y gitignoreado, así que un clon nuevo no lo trae y eso no es un
|
|
@@ -148,6 +148,7 @@ function check(dir, cli) {
|
|
|
148
148
|
epics, milestones, done, wips, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
|
|
149
149
|
}))
|
|
150
150
|
warnings.push(...AD.report({ done, epics, adopted }))
|
|
151
|
+
warnings.push(...PC.doneCeremonyWarnings(done, new Set(adopted)))
|
|
151
152
|
// Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
|
|
152
153
|
// no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
|
|
153
154
|
// en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
|
|
@@ -265,10 +266,9 @@ function treeJson({ epics, milestones, done, wips, inbox, queued, claims }) {
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
function tree(dir, cli) {
|
|
268
|
-
const root =
|
|
269
|
+
const root = planningRoot(dir)
|
|
269
270
|
// Mismo motivo que en `context`, y por eso comparten la comprobación: sin ella un planning ausente
|
|
270
271
|
// dibujaba un árbol vacío, que se lee como un roadmap sin épicas en vez de como una ruta equivocada.
|
|
271
|
-
assertPlanning(root)
|
|
272
272
|
const state = ST.snapshot(root)
|
|
273
273
|
if (cli.has('--json')) return treeJson(state)
|
|
274
274
|
const { epics, milestones, done, wips, inbox, queued, claims } = state
|
|
@@ -307,29 +307,9 @@ function tree(dir, cli) {
|
|
|
307
307
|
console.log(`${paint('1', 'DONE')} ${done.entries.length} tareas\n`)
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
// Lo que no se pudo leer no contesta como si se hubiera leído: la misma regla que `stagedFiles` aplica
|
|
311
|
-
// sobre el índice de git, y que esta familia ya aplicaba al `--hito` inexistente. Faltaba la raíz, y ahí
|
|
312
|
-
// pesa más, porque el consumidor no siempre es una persona: `autobuild` toma la cola vacía como permiso
|
|
313
|
-
// para expandir una épica, así que un error de ruta promovía trabajo en vez de fallar.
|
|
314
|
-
//
|
|
315
|
-
// La ruta va **resuelta** y no como se escribió, porque el error que ataca es de resolución: en sidecar
|
|
316
|
-
// `<empresa>-ops/planning` desde adentro de la raíz apunta a `<empresa>-ops/<empresa>-ops/planning`.
|
|
317
|
-
function assertPlanning(root) {
|
|
318
|
-
if (!fs.existsSync(root)) {
|
|
319
|
-
return fail(`no existe el planning en ${root} (ruta resuelta). Comprobá desde dónde estás invocando.`, 2)
|
|
320
|
-
}
|
|
321
|
-
// Existir no alcanza: un directorio cualquiera contestaría cola vacía igual. `BACKLOG.md` es el archivo
|
|
322
|
-
// del que sale la cola, así que sin él la respuesta no significa nada.
|
|
323
|
-
if (!fs.existsSync(path.join(root, 'BACKLOG.md'))) {
|
|
324
|
-
return fail(`${root} (ruta resuelta) no es un planning: falta BACKLOG.md.`, 2)
|
|
325
|
-
}
|
|
326
|
-
return null
|
|
327
|
-
}
|
|
328
|
-
|
|
329
310
|
// Contexto mínimo suficiente para ejecutar una tarea, en lugar de releer roadmap, BACKLOG y WIP enteros.
|
|
330
311
|
function context(dir, cli) {
|
|
331
|
-
const root =
|
|
332
|
-
assertPlanning(root)
|
|
312
|
+
const root = planningRoot(dir)
|
|
333
313
|
const state = ST.snapshot(root)
|
|
334
314
|
// Acotar la cola a un hito es como un equipo se reparte trabajo sin coordinarse: dos personas en hitos
|
|
335
315
|
// distintos casi nunca dependen entre sí ni tocan los mismos archivos. Lo que se acota es qué se
|
|
@@ -473,7 +453,7 @@ function context(dir, cli) {
|
|
|
473
453
|
// `BACKLOG.md` es la cola de lo aprobado y la escribe una persona — ningún comando del motor la toca,
|
|
474
454
|
// ni siquiera `integration promote`, que aterriza en el roadmap. Pegarla es el acto de promoción.
|
|
475
455
|
function recurring(dir, cli) {
|
|
476
|
-
const root =
|
|
456
|
+
const root = planningRoot(dir)
|
|
477
457
|
const file = RC.read(root)
|
|
478
458
|
if (!file.exists) return console.log(`= este planning no declara trabajo recurrente (${RC.FILE})`)
|
|
479
459
|
const state = RC.status({ ...file, done: P.readDone(root), today: TODAY() })
|
package/engine/cli/worktree.js
CHANGED
|
@@ -15,7 +15,7 @@ const ST = require('../planning/state')
|
|
|
15
15
|
const CL = require('../planning/claims')
|
|
16
16
|
const R = require('../core/repos')
|
|
17
17
|
const O = require('../core/ownership')
|
|
18
|
-
const { fail } = require('./io')
|
|
18
|
+
const { fail, planningRoot } = require('./io')
|
|
19
19
|
|
|
20
20
|
const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ function existing(repo, branch) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function worktree(dir, slug, cli) {
|
|
36
|
-
const root =
|
|
36
|
+
const root = planningRoot(dir)
|
|
37
37
|
if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', 2)
|
|
38
38
|
const state = ST.snapshot(root)
|
|
39
39
|
const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
|
package/engine/hooks/shell.js
CHANGED
|
@@ -442,17 +442,27 @@ function commitTree(dir) {
|
|
|
442
442
|
// quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
|
|
443
443
|
const started = run('git', ['init', '--quiet'], temp)
|
|
444
444
|
if (started.ok) run('git', ['add', '--all'], temp)
|
|
445
|
-
// Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
// el
|
|
449
|
-
// `node_modules` **del proyecto**. Lo único que hoy lo detiene es que `run` lanza con `stdio: 'pipe'`
|
|
450
|
-
// y el hijo no ve una terminal (caso 068).
|
|
445
|
+
// Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
|
|
446
|
+
// y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
|
|
447
|
+
// sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
|
|
448
|
+
// el lockfile de la copia y reinstala, lo que **empieza borrando** el `node_modules` del proyecto.
|
|
451
449
|
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
450
|
+
// Lo que se apaga es esa comprobación previa, que es el motivo por el que quiere tocar nada.
|
|
451
|
+
// `verify-deps-before-run` la gobierna y tiene tres valores: `install` reinstala solo —el que trae
|
|
452
|
+
// pnpm 11 y el que hace el daño—, `error` se niega y frena el gate cuando el lockfile de la copia
|
|
453
|
+
// difiere de lo instalado, que es justo lo que pasa al commitear un cambio de lockfile por partes, y
|
|
454
|
+
// `false` corre el script sin mirar. La copia no tiene que sincronizar nada: tiene que medir el
|
|
455
|
+
// código.
|
|
456
|
+
//
|
|
457
|
+
// Acá estuvo `CI: 'true'` y fue una regresión (caso 070). Resolvía el síntoma del 068 —pnpm dejaba de
|
|
458
|
+
// preguntar antes de purgar— desarmando la confirmación en vez de quitarle el motivo, y esa
|
|
459
|
+
// confirmación era lo único que protegía al `node_modules` del proyecto: sin ella la reinstalación
|
|
460
|
+
// avanza y borra por el enlace. Además encendía `frozen-lockfile`, que el propio pnpm anuncia al
|
|
461
|
+
// fallar, así que la variable armaba y desarmaba guardas distintas a la vez.
|
|
462
|
+
//
|
|
463
|
+
// La regla que queda: no se desarma la confirmación de una herramienta, se le quita el motivo de
|
|
464
|
+
// preguntar. Una confirmación que estorba casi siempre está cuidando algo.
|
|
465
|
+
return { root: temp, temp, env: { npm_config_verify_deps_before_run: 'false' } }
|
|
456
466
|
}
|
|
457
467
|
|
|
458
468
|
function verify(input) {
|
|
@@ -9,9 +9,26 @@ const TEST_TRACE = /^(?:n\/a\s*[—-]\s*.+|(?:A|C\d+)\s*(?:→|->)\s*\S.+)$/i
|
|
|
9
9
|
const DECISION_TRACE = /\[(?:fuente|supuesto):\s*[^\]]+\]/i
|
|
10
10
|
const COMMIT_TRACE = /^(?:n\/a\s*[—-]\s*.+|[0-9a-f]{7,40}\s+\S.*)$/i
|
|
11
11
|
|
|
12
|
+
// Se corta en `;` sólo cuando detrás **empieza otra traza**. Es la misma decisión que `validCommitTrace`
|
|
13
|
+
// toma unas líneas más abajo para el sha, y por el mismo motivo: el `;` es el separador que R8 fija y a
|
|
14
|
+
// la vez el signo más común de la prosa española, y este campo pide las dos cosas — el contrato pide
|
|
15
|
+
// `CN → prueba` y R9 pide decir cómo se vio fallar esa prueba.
|
|
16
|
+
//
|
|
17
|
+
// Con el corte a secas, una sola traza con prosa se partía en tres y el campo se rechazaba entero: el
|
|
18
|
+
// mensaje mandaba a revisar la traza, que era lo único que estaba bien, y la salida fácil era acortar la
|
|
19
|
+
// prosa hasta que pasara — o sea empobrecer justo la evidencia que R9 exige. Pasó dos veces en dos días
|
|
20
|
+
// en una instancia real (caso 072).
|
|
21
|
+
//
|
|
22
|
+
// Lo comparten `validTestTrace` y `testedCriteria` porque «dónde termina una traza» es una sola decisión
|
|
23
|
+
// y no dos. Con los datos de hoy las dos formas de partir dan el mismo resultado —un `;` que no abre
|
|
24
|
+
// traza no produce un fragmento que empiece por `Cn →`, así que el rastreo no cambia—, y por eso ninguna
|
|
25
|
+
// prueba lo distingue: comprobado con una mutación que hace partir distinto a cada uno y sobrevive. Se
|
|
26
|
+
// comparte igual, para que no se separen el día que una de las dos cambie.
|
|
27
|
+
const TRACE_SPLIT = /\s*;(?=\s*(?:(?:A|C\d+)\s*(?:→|->)|n\/a\s*[—-]))\s*/i
|
|
28
|
+
const splitTraces = (value) => String(value || '').split(TRACE_SPLIT).map((one) => one.trim()).filter(Boolean)
|
|
29
|
+
|
|
12
30
|
function validTestTrace(value) {
|
|
13
|
-
return
|
|
14
|
-
.every((item) => TEST_TRACE.test(item))
|
|
31
|
+
return splitTraces(value).every((item) => TEST_TRACE.test(item))
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
function validDecisionTrace(value) {
|
|
@@ -34,7 +51,7 @@ function validCommitTrace(value) {
|
|
|
34
51
|
// Los criterios que la evidencia realmente rastrea. `n/a — razón` no rastrea ninguno a propósito: es
|
|
35
52
|
// la salida explícita, y como lleva su razón escrita se lee en el propio DONE sin que nadie la cruce.
|
|
36
53
|
function testedCriteria(value) {
|
|
37
|
-
return
|
|
54
|
+
return splitTraces(value)
|
|
38
55
|
.map((item) => ((item.match(/^(C\d+)\s*(?:→|->)/i) || [])[1] || '').toUpperCase())
|
|
39
56
|
.filter(Boolean)
|
|
40
57
|
}
|
|
@@ -70,6 +87,12 @@ function validateDoneEntry(entry, cited = []) {
|
|
|
70
87
|
//
|
|
71
88
|
// Los criterios que la historia declaró cubrir los cita el roadmap y no la entrada, así que el cruce
|
|
72
89
|
// sólo existe si la entrada dice de qué épica viene.
|
|
90
|
+
// El vocabulario del carril tal como se escribe en una entrada de DONE: los cuatro de la línea del
|
|
91
|
+
// BACKLOG más el que dice que la línea no lo declaraba. `sin clasificar` no es un hueco disimulado — es
|
|
92
|
+
// el estado que `PROTOCOL.md` ya llama estado y no error, y escribirlo distingue «corrió sin carril» de
|
|
93
|
+
// «nadie escribió el campo», que es justo lo que este campo vino a poder contestar.
|
|
94
|
+
const LANE_VALUES = [...P.LANES, 'sin clasificar']
|
|
95
|
+
|
|
73
96
|
function doneEntryErrors(entry, epics = []) {
|
|
74
97
|
const at = `${entry.source} ${entry.slug}`
|
|
75
98
|
const errors = []
|
|
@@ -80,11 +103,58 @@ function doneEntryErrors(entry, epics = []) {
|
|
|
80
103
|
if (!entry.done) errors.push(`${at}: falta done:`)
|
|
81
104
|
if (!entry.qa) errors.push(`${at}: falta qa:`)
|
|
82
105
|
if (!entry.commit) errors.push(`${at}: falta commit:`)
|
|
106
|
+
// El carril con el que la tarea corrió. Ausente **avisa** y no frena, porque toda entrada escrita antes
|
|
107
|
+
// de que el campo existiera lo está y no hay de dónde sacárselo: exigirlo pondría en rojo el `check` de
|
|
108
|
+
// cada instancia que actualiza, por algo que nadie puede arreglar. Escrito mal sí frena, porque eso es
|
|
109
|
+
// un valor que alguien puso y de él depende leer si la ceremonia fue la que correspondía (OPS-006).
|
|
110
|
+
if (entry.lane && !LANE_VALUES.includes(entry.lane)) {
|
|
111
|
+
errors.push(`${at}: lane "${entry.lane}" no existe; usá ${LANE_VALUES.join(' | ')}`)
|
|
112
|
+
}
|
|
83
113
|
const story = epics.find((epic) => epic.num === entry.epic)?.stories
|
|
84
114
|
.find((candidate) => candidate.slug === entry.slug)
|
|
85
115
|
return [...errors, ...validateDoneEntry(entry, story ? story.criteria : [])]
|
|
86
116
|
}
|
|
87
117
|
|
|
118
|
+
// Un carril declara **cuánta ceremonia** merecía la tarea; `n/a` en `review:` dice que la revisión no
|
|
119
|
+
// corrió. `express` es el único que no convoca revisor, así que en los otros tres esa combinación es la
|
|
120
|
+
// ADR incumplida, escrita en el propio registro.
|
|
121
|
+
const CONVOCAN_REVISOR = ['directo', 'lite', 'full']
|
|
122
|
+
const SIN_REVISION = /^n\/a\b/i
|
|
123
|
+
|
|
124
|
+
// Lo que el registro puede decir sobre la ceremonia, y lo que todavía no. Los dos campos avisan en vez de
|
|
125
|
+
// fallar por lo que dice `doneEntryErrors`, y cuentan en vez de listar porque al principio son todas: lo
|
|
126
|
+
// que se lee es que el número baje. Cuando llegue a cero, exigirlos deja de costarle nada a nadie.
|
|
127
|
+
//
|
|
128
|
+
// El cruce sí nombra las tareas, porque son pocas y cada una es una pregunta concreta para una persona.
|
|
129
|
+
// Y también avisa en vez de fallar, por una razón distinta de la de los campos: es un hecho del pasado
|
|
130
|
+
// que no se arregla editando la entrada, así que el único camino al verde sería reescribir el registro.
|
|
131
|
+
// Un gate que se apaga mintiendo es peor que no tenerlo.
|
|
132
|
+
//
|
|
133
|
+
// `sin clasificar` queda afuera del cruce a propósito: el recorrido corre esas tareas por el carril
|
|
134
|
+
// completo, pero eso lo sabe el recorrido y no la entrada. Avisar sobre lo que hay que deducir es lo que
|
|
135
|
+
// llena de ruido un aviso que después nadie mira.
|
|
136
|
+
function doneCeremonyWarnings(done, adopted = new Set()) {
|
|
137
|
+
const propias = done.entries.filter((entry) => !adopted.has(entry.slug))
|
|
138
|
+
const warnings = []
|
|
139
|
+
const sinLane = propias.filter((entry) => !entry.lane)
|
|
140
|
+
if (sinLane.length) {
|
|
141
|
+
warnings.push(`planning/done: ${sinLane.length} entrada(s) sin lane:, así que no se puede comprobar `
|
|
142
|
+
+ 'sobre el registro que la ceremonia que recibieron fue la que su superficie pedía (OPS-006)')
|
|
143
|
+
}
|
|
144
|
+
const sinReview = propias.filter((entry) => !entry.review)
|
|
145
|
+
if (sinReview.length) {
|
|
146
|
+
warnings.push(`planning/done: ${sinReview.length} entrada(s) sin review:, que es la dimensión con la `
|
|
147
|
+
+ 'que OPS-006 dice que se mide si el carril elegido fue el correcto')
|
|
148
|
+
}
|
|
149
|
+
const saltadas = propias.filter((entry) => CONVOCAN_REVISOR.includes(entry.lane)
|
|
150
|
+
&& entry.review && SIN_REVISION.test(entry.review))
|
|
151
|
+
if (saltadas.length) {
|
|
152
|
+
warnings.push(`planning/done: ${saltadas.map((entry) => entry.slug).join(', ')} declara(n) un carril `
|
|
153
|
+
+ 'que convoca revisor y una revisión que no corrió: el carril reduce ceremonia, nunca evidencia')
|
|
154
|
+
}
|
|
155
|
+
return warnings
|
|
156
|
+
}
|
|
157
|
+
|
|
88
158
|
function duplicates(values) {
|
|
89
159
|
return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
|
|
90
160
|
}
|
|
@@ -291,6 +361,7 @@ function validateState({
|
|
|
291
361
|
module.exports = {
|
|
292
362
|
validateState,
|
|
293
363
|
doneEntryErrors,
|
|
364
|
+
doneCeremonyWarnings,
|
|
294
365
|
validCommitTrace,
|
|
295
366
|
validDecisionTrace,
|
|
296
367
|
validTestTrace,
|
|
@@ -254,7 +254,7 @@ function doneFiles(dir) {
|
|
|
254
254
|
|
|
255
255
|
// `fecha` entra al vocabulario porque un campo que no esté acá no corta al anterior: sin nombrarlo, el
|
|
256
256
|
// `done:` de la entrada se lo tragaría entero como parte de su propio texto.
|
|
257
|
-
const DONE_FIELDS = 'acept|fecha|done|qa|tests|decisions|commit'
|
|
257
|
+
const DONE_FIELDS = 'acept|fecha|done|qa|tests|decisions|commit|lane|review'
|
|
258
258
|
|
|
259
259
|
// Un campo vale hasta el próximo campo, una línea en blanco o el fin de la entrada. Mismo corte que ya
|
|
260
260
|
// se arregló para los criterios y las historias, con el mismo síntoma: el valor es prosa y se envuelve a
|
|
@@ -287,7 +287,8 @@ function readDone(dir) {
|
|
|
287
287
|
epic: ((match[2].match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
|
|
288
288
|
acceptance: field('acept'), fecha: field('fecha'),
|
|
289
289
|
done: field('done'), qa: field('qa'), tests: field('tests'),
|
|
290
|
-
decisions: field('decisions'), commit: field('commit'),
|
|
290
|
+
decisions: field('decisions'), commit: field('commit'), lane: field('lane'),
|
|
291
|
+
review: field('review'),
|
|
291
292
|
source: path.relative(dir, file), raw: match[0].trimEnd(),
|
|
292
293
|
})
|
|
293
294
|
}
|
|
@@ -370,6 +371,10 @@ function parseWip(text, runner) {
|
|
|
370
371
|
if (!task) return null
|
|
371
372
|
return {
|
|
372
373
|
task, runner, phase: field('phase') || '?', service: field('service'),
|
|
374
|
+
// El carril viaja en el WIP porque la línea del BACKLOG deja de existir al cerrar, y sin esto una
|
|
375
|
+
// corrida que se reanuda llega al cierre con el carril ya perdido: `currentTask` arma la tarea desde
|
|
376
|
+
// el WIP y le pone `tier` vacío. Medido — la tarea reanudada devolvía `""` (caso 074).
|
|
377
|
+
lane: field('lane'),
|
|
373
378
|
complete: (text.match(/^\d+\.\s+\[[xX]\]/gm) || []).length,
|
|
374
379
|
pending: (text.match(/^\d+\.\s+\[\s\]/gm) || []).length,
|
|
375
380
|
}
|
package/engine/planning/state.js
CHANGED
|
@@ -50,7 +50,7 @@ function currentTask({ milestones, done, wips = [], claims = [] }, blockers = []
|
|
|
50
50
|
if (wip) {
|
|
51
51
|
const active = queue.find((task) => task.slug === wip.task)
|
|
52
52
|
|| {
|
|
53
|
-
slug: wip.task, hito: '', tier: '', cast: { build: '', review: [] },
|
|
53
|
+
slug: wip.task, hito: '', tier: wip.lane || '', cast: { build: '', review: [] },
|
|
54
54
|
service: wip.service, acceptance: '', epic: '', criteria: [],
|
|
55
55
|
}
|
|
56
56
|
return { task: active, claimed: mine.has(wip.task), skipped: [], taken: [], waiting: [] }
|
package/package.json
CHANGED
|
@@ -13,7 +13,14 @@ invariantes.
|
|
|
13
13
|
opcionales: sin ellos la tarea está sin clasificar, que es un estado y no un error. Una tarea con
|
|
14
14
|
dependencias no se ofrece ni se toma hasta que todas estén en DONE.
|
|
15
15
|
- DONE: un archivo por tarea cerrada, `done/<slug>.md`, con su entrada `[x]` y los campos `acept:`,
|
|
16
|
-
`fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:` y `
|
|
16
|
+
`fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:`, `commit:` y `lane:`. `lane:` repite el carril con el
|
|
17
|
+
que la tarea corrió —`express`, `directo`, `lite`, `full`— o `sin clasificar` si su línea no lo
|
|
18
|
+
declaraba, y existe porque el carril decide qué fases corren y su línea del BACKLOG se borra al cerrar:
|
|
19
|
+
sin él, si una tarea recibió la ceremonia que le tocaba sólo lo sabe quien estuvo en la sesión.
|
|
20
|
+
`check` avisa cuántas entradas no lo traen y falla si trae un valor que no existe. `review:` dice qué
|
|
21
|
+
pasó con la revisión —el veredicto y quién revisó— o `n/a — razón` cuando no corrió; es la dimensión con
|
|
22
|
+
la que OPS-006 dice que se mide si el carril elegido fue el correcto, y `check` cruza los dos: un carril
|
|
23
|
+
que convoca revisor con una revisión que no corrió es la ADR incumplida, escrita en el propio registro. La fecha es la del cierre, y es lo que
|
|
17
24
|
ordena una evidencia que ya no depende de su posición dentro de un archivo. `tests:` enlaza cada criterio
|
|
18
25
|
mediante `CN → prueba`; usa `A → prueba` cuando no hay épica o `n/a — razón` si no existe una
|
|
19
26
|
superficie ejecutable. `decisions:` es opcional y, si aparece, cita `[fuente: ...]` o
|
|
@@ -12,6 +12,8 @@ tarea entregó y con qué se comprueba.
|
|
|
12
12
|
tests: C1 → nombre de prueba o comando; C2 → nombre de prueba o comando
|
|
13
13
|
decisions: decisión no obvia [fuente: ruta/archivo] o [supuesto: motivo verificable]
|
|
14
14
|
commit: abc1234 feat(scope): subject (repo@branch)
|
|
15
|
+
lane: full
|
|
16
|
+
review: aprobado por tech-lead, sobre api/alta.go
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
El contrato completo de esos campos está en `../PROTOCOL.md`; acá va por qué el archivo es uno por tarea.
|
|
@@ -27,6 +29,27 @@ El nombre del archivo es una conveniencia; lo que identifica la tarea es el slug
|
|
|
27
29
|
el archivo no cambia de qué tarea habla, y cerrar dos veces la misma sigue siendo un error que `check`
|
|
28
30
|
rechaza, ahora entre archivos.
|
|
29
31
|
|
|
32
|
+
## Por qué el carril
|
|
33
|
+
|
|
34
|
+
El carril decide qué fases corre una tarea: `express` se saltea Ready, Plan y QA; `full` las corre
|
|
35
|
+
todas. Ese dato vive en la línea del BACKLOG, y la línea **se borra al cerrar** — así que la pregunta
|
|
36
|
+
«¿esta tarea recibió la ceremonia que su superficie pedía?» dejaba de tener dónde contestarse, y quedaba
|
|
37
|
+
en la memoria de quien estuvo en la sesión. `lane:` la devuelve al registro.
|
|
38
|
+
|
|
39
|
+
Se escribe aunque sea `sin clasificar`, que es distinto de no escribirlo: uno dice que la tarea corrió
|
|
40
|
+
sin carril declarado y el otro, que nadie llenó el campo.
|
|
41
|
+
|
|
42
|
+
## Por qué la revisión
|
|
43
|
+
|
|
44
|
+
El carril dice cuánta ceremonia **merecía** la tarea; `review:` dice cuánta **recibió**. Con los dos, la
|
|
45
|
+
pregunta que OPS-006 dejó pendiente —«¿el carril elegido fue el correcto?»— se contesta desde el registro
|
|
46
|
+
en vez de desde la memoria de la sesión.
|
|
47
|
+
|
|
48
|
+
`express` es el único carril que no convoca revisor, así que ahí `n/a — razón` es lo correcto. En
|
|
49
|
+
`directo`, `lite` y `full` una revisión que no corrió es la ADR incumplida, y `check` lo avisa nombrando
|
|
50
|
+
la tarea. Avisa y no falla: es un hecho del pasado que no se arregla editando la entrada, y el único
|
|
51
|
+
camino al verde sería reescribir el registro.
|
|
52
|
+
|
|
30
53
|
## Por qué la fecha
|
|
31
54
|
|
|
32
55
|
Mientras las entradas vivían en un archivo, «la última» era la última del archivo. Con archivos sueltos
|
|
@@ -36,6 +36,24 @@ nuevo no comprueba que desapareció lo viejo: los dos pueden convivir, y ahí el
|
|
|
36
36
|
del cambio ocurrió. La aserción que hace falta es de ausencia —que la salida vieja ya no esté, que la
|
|
37
37
|
rama vieja ya no corra—, y es la que no se escribe sola porque nadie la extraña.
|
|
38
38
|
|
|
39
|
+
**Y lo más caro es que una quita casi nunca se ve como una quita.** Se escribe como un agregado: una
|
|
40
|
+
bandera que se pone, una variable que se exporta, una condición que se añade. Lo que la delata es la
|
|
41
|
+
frase que la justifica — «lo agrego **para que** deje de …». Ahí el sujeto es lo que entra y el objeto es
|
|
42
|
+
lo que desaparece, y lo que desaparece es lo que hay que probar. Silenciar un aviso, saltear una rama,
|
|
43
|
+
desarmar una confirmación: los tres se escriben sumando y los tres son quitas.
|
|
44
|
+
|
|
45
|
+
Esto no admite excepción y por eso se dice acá y no en una guía: **una quita no se entrega sin su
|
|
46
|
+
aserción de ausencia, y esa aserción se vio en rojo devolviendo lo quitado.** Sin ese rojo no está
|
|
47
|
+
probado que la aserción mire lo que dice mirar — es el mismo rojo previo del párrafo de arriba, aplicado
|
|
48
|
+
al revés.
|
|
49
|
+
|
|
50
|
+
Y hay un caso particular que merece su renglón porque es el que más se disfraza: **una confirmación que
|
|
51
|
+
estorba casi siempre está cuidando algo.** Antes de callarla se establece qué protege. Si la respuesta es
|
|
52
|
+
«no sé», eso **es** el resultado de la medición y no un permiso para seguir: lo que corresponde es
|
|
53
|
+
quitarle a la herramienta el motivo de preguntar, no la pregunta. Poner `CI=true` para que un gestor de
|
|
54
|
+
paquetes dejara de confirmar antes de purgar borró el árbol de dependencias de un proyecto real, y el
|
|
55
|
+
cambio se había probado —el gate arrancaba— midiendo sólo lo que aparecía.
|
|
56
|
+
|
|
39
57
|
Lo que se quita, además, tiene dependientes, y no se anuncian. Una invariante que deja de valer se lleva
|
|
40
58
|
puesto a quien la daba por cierta: el mensaje que la afirmaba, la condición que la deducía, el comentario
|
|
41
59
|
que la explicaba. Suelen vivir en otro archivo, que es donde una premisa vieja se pudre sin que nada
|
|
@@ -12,6 +12,7 @@ phase: Build
|
|
|
12
12
|
started: AAAA-MM-DD
|
|
13
13
|
service: ruta
|
|
14
14
|
acceptance: "criterio observable"
|
|
15
|
+
lane: full
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
## Plan aprobado
|
|
@@ -24,6 +25,10 @@ acceptance: "criterio observable"
|
|
|
24
25
|
- (ninguno)
|
|
25
26
|
```
|
|
26
27
|
|
|
28
|
+
`lane:` viaja acá por la misma razón por la que existe en DONE: la línea del BACKLOG se borra al
|
|
29
|
+
cerrar, y una corrida que se reanuda arma la tarea desde este archivo. Sin el campo, el cierre de una
|
|
30
|
+
corrida reanudada escribe `sin clasificar` sobre una tarea que sí tenía carril.
|
|
31
|
+
|
|
27
32
|
Sin archivo, el runner está en IDLE: un clon nuevo no trae ninguno y eso no es un error.
|
|
28
33
|
|
|
29
34
|
## Por qué uno por runner y no uno solo
|