@ingeniomaps/cauce 0.96.0 → 0.97.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 +108 -0
- package/automatization/workflows/autobuild.js +168 -18
- package/automatization/workflows/flow.js +30 -14
- package/engine/cli/planning.js +4 -1
- package/engine/hooks/shell.js +29 -6
- package/engine/planning/contracts.js +19 -0
- package/engine/planning/parser.js +35 -0
- package/package.json +1 -1
- package/template/planning/rules/system/commits.md +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,114 @@ 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.97.0] - 2026-09-17
|
|
18
|
+
|
|
19
|
+
### Agregado
|
|
20
|
+
|
|
21
|
+
- **La revisión de `autobuild` tiene dónde poner una decisión que no le toca tomar.** Tenía dos puertas:
|
|
22
|
+
marcar el hallazgo como bloqueante —y mandar a tocar código— o dejarlo en el INBOX como propuesta, con
|
|
23
|
+
tope de tres. Un contrato público que alguien tiene que definir no es ninguna de las dos, así que el
|
|
24
|
+
revisor elegía frenar, que de las dos es la correcta y costaba la corrida entera sobre trabajo que
|
|
25
|
+
estaba bien.
|
|
26
|
+
|
|
27
|
+
Ahora ese hallazgo va a `HUMAN_ACTIONS.md` con qué lo cierra y quién puede tomarlo, nombrando la épica o
|
|
28
|
+
el hito al que alcanza —nunca la tarea, para no bloquear a la que lo encontró—, y la corrida sigue. La
|
|
29
|
+
entrada de DONE cuenta cuántas quedaron registradas.
|
|
30
|
+
|
|
31
|
+
### Corregido
|
|
32
|
+
|
|
33
|
+
- **Una parada que espera una decisión tuya suelta la tarea que había reservado.** Al registrar la fila
|
|
34
|
+
en `HUMAN_ACTIONS.md`, la tarea queda bloqueada; si además seguía reservada, el runner quedaba ocupado
|
|
35
|
+
por algo que nadie podía tomar y la corrida siguiente moría reclamando la que sigue —«este runner ya
|
|
36
|
+
tiene …»—. Pasaba en toda parada anterior a Build, que son las más frecuentes mientras una tarea
|
|
37
|
+
todavía se está definiendo, y costaba una corrida entera por vez.
|
|
38
|
+
|
|
39
|
+
Las paradas de después de construir **no** sueltan nada: ahí el WIP existe y es resumible, y la reserva
|
|
40
|
+
es lo único que dice de quién es ese trabajo.
|
|
41
|
+
|
|
42
|
+
- **Lo que la línea de una tarea ya decidió llega a las fases que deciden.** La descripción —dónde vive un
|
|
43
|
+
símbolo, qué queda fuera de alcance, con qué se produce la evidencia— se leía del BACKLOG y se
|
|
44
|
+
descartaba, así que Plan la volvía a decidir por su cuenta y decidía distinto. Critique **sí** abre el
|
|
45
|
+
BACKLOG, y bloqueaba el plan citando la línea palabra por palabra: una compuerta juzgando contra un
|
|
46
|
+
texto que la otra no recibió. Dos corridas medidas se pagaron enteras por esto, 1,10 M y 815 k tokens,
|
|
47
|
+
sin escribir una línea.
|
|
48
|
+
|
|
49
|
+
Ahora `ops context` la emite con la tarea y llega a Ready, Plan, Critique y Build, dicha como lo que es:
|
|
50
|
+
decisiones ya tomadas, que no se re-deciden. **No tenés que cambiar nada en tus líneas** — es el texto
|
|
51
|
+
que ya escribías antes de la aceptación.
|
|
52
|
+
|
|
53
|
+
- **`git commit --amend` se frena sobre historia publicada, y ya no sobre la que nadie vio.** Lo que R8
|
|
54
|
+
protege es la historia que otro leyó —lo mismo que ya decía del force-push—, y el guard lo bloqueaba
|
|
55
|
+
siempre «por política y no por daño». Eso no impedía el resultado: `git reset --soft HEAD~1` y volver a
|
|
56
|
+
commitear produce exactamente lo mismo, no lo frena nada y no deja constancia. Sólo impedía el comando
|
|
57
|
+
que lo nombra.
|
|
58
|
+
|
|
59
|
+
Ahora el guard mira si algún remoto alcanza al commit, con una lectura local que no habla con ningún
|
|
60
|
+
servidor. Publicado sigue cerrado y sin salida por chat, igual que el force-push. **R8 también cambió**:
|
|
61
|
+
pasa a prohibir reescribir historia que otro ya leyó, en vez de nombrar el comando.
|
|
62
|
+
|
|
63
|
+
- **Una etapa de un recorrido ya no muere por escribir de más.** Pasado cierto tamaño el modelo deja de
|
|
64
|
+
emitir los campos y devuelve el JSON envuelto como texto, que no valida; cinco reintentos después la
|
|
65
|
+
etapa se cae y la corrida entera se para. Cada campo de la respuesta lleva ahora su tope declarado
|
|
66
|
+
—`summary` 1000 caracteres, `missing` y `humanAction` 500, cada evidencia 200—, así que el rechazo
|
|
67
|
+
nombra el campo y el número en vez de decir que no se pudo parsear.
|
|
68
|
+
|
|
69
|
+
Lo que no entra no se pierde: va al archivo de análisis, que es lo que lee quien sintetiza al final.
|
|
70
|
+
Si escribís un recorrido propio, no tenés que hacer nada — el tope es del esquema, no del contrato.
|
|
71
|
+
|
|
72
|
+
- **`autobuild` ya no frena una tarea correcta porque el nombre del test venga anotado de dos formas.**
|
|
73
|
+
La puerta comprueba que un borde que el build arregló aparezca en algún rojo declarado, comparando los
|
|
74
|
+
dos nombres por contención. Eso cubría que uno fuera prefijo del otro, y dejaba afuera la forma que
|
|
75
|
+
aparece de verdad: los dos nombran el mismo test y cada uno le agrega **su propia** anotación entre
|
|
76
|
+
paréntesis —dónde está la línea de un lado, por qué se vio en rojo del otro—. Ahí ninguno contenía al
|
|
77
|
+
otro y la corrida paraba con `edge-unproven` sobre trabajo terminado y en verde.
|
|
78
|
+
|
|
79
|
+
Si te pasó, la tarea estaba bien: no hacía falta rehacerla. Y el motivo de la parada ya no se contradice
|
|
80
|
+
solo — dice qué comparó la puerta en vez de afirmar que faltaba la prueba.
|
|
81
|
+
|
|
82
|
+
- **Una corrección de Review se lleva también lo que ella misma dejó desactualizado.** «Corregí sólo estos
|
|
83
|
+
hallazgos» acota el alcance y dejaba un cabo suelto: el conteo que enumeraba lo que cambió, el
|
|
84
|
+
comentario que describía la forma vieja, la fila que la afirmaba. Como Review tiene una sola vuelta de
|
|
85
|
+
corrección, esa deriva aterrizaba en la re-revisión y la corrida frenaba sobre trabajo correcto.
|
|
86
|
+
|
|
87
|
+
- **El checkpoint de hito se destraba escribiendo, no borrando.** `AWAITING_REVIEW.md` frenaba a todos los
|
|
88
|
+
runners por el hecho de existir, así que resolverlo exigía acordarse de borrarlo — y mientras tanto
|
|
89
|
+
quien lo leía podía ver ahí escrito que ya estaba revisado. Es lo que R28 prohíbe, y el propio archivo
|
|
90
|
+
lo decía de sí mismo.
|
|
91
|
+
|
|
92
|
+
Ahora lleva `status: pendiente` en su frontmatter y se destraba cambiándolo a `resuelta`, con lo cual el
|
|
93
|
+
archivo **se queda** y se puede leer después qué se revisó. Borrarlo sigue funcionando.
|
|
94
|
+
|
|
95
|
+
**Tu archivo actual no cambia de conducta**: sin `status` sigue frenando, que es lo que corresponde
|
|
96
|
+
—abrirlo al actualizar sería destrabar una compuerta en silencio—. Agregale la línea cuando quieras
|
|
97
|
+
resolverlo sin perderlo; los que escriba `autobuild` de acá en más ya vienen con ella.
|
|
98
|
+
|
|
99
|
+
- **`check` rechaza una fila de `HUMAN_ACTIONS.md` que nombra a su tarea sin ser su slug.** El motor
|
|
100
|
+
bloquea por esa primera columna **exacta**, así que `| **alta-de-cliente: falta el token** | pendiente |`
|
|
101
|
+
se escribía sin error, salía en `ops context` bajo `HUMAN` como si estuviera registrada, y la tarea se
|
|
102
|
+
seguía ofreciendo. Es la forma que sale natural, porque esa tabla la lee una persona; en la instancia
|
|
103
|
+
donde se midió, **once filas pendientes bloqueaban cero tareas** y la corrida siguiente volvía a elegir
|
|
104
|
+
lo mismo y a pagar la misma fase.
|
|
105
|
+
|
|
106
|
+
Si te aparece, el mensaje dice qué fila y a qué tarea apuntaba: dejá el slug solo en esa celda y contá
|
|
107
|
+
el resto en la columna de la acción. Las otras dos formas no cambian — el slug exacto sigue bloqueando,
|
|
108
|
+
y nombrar la épica, el hito o el recorrido sigue siendo válido cuando lo que se frena no es una tarea.
|
|
109
|
+
|
|
110
|
+
- **Una decisión que `autobuild` deja abierta ya no bloquea la tarea que la encontró.** Esas filas se
|
|
111
|
+
registran para que la corrida siga —lo que de verdad frena tiene otro camino—, pero la fase que las
|
|
112
|
+
escribe podía poner en la primera columna el slug de la tarea en construcción y bloquearla. No se veía
|
|
113
|
+
mientras la corrida vivía, porque un WIP activo manda sobre la acción humana; aparecía al cerrar el WIP.
|
|
114
|
+
|
|
115
|
+
- **Una corrida de `autobuild` que no puede tomar nada lo dice, en vez de terminar como si la cola
|
|
116
|
+
estuviera vacía.** Eran dos estados distintos con la misma salida: la cola terminada, y la cola cuyas
|
|
117
|
+
tareas están todas reclamadas por otro runner o esperando una dependencia. La corrida informaba que no
|
|
118
|
+
había nada que hacer sobre una cola que sí tenía trabajo.
|
|
119
|
+
|
|
120
|
+
Se paga después de una parada que espera una decisión tuya: ahí el reclamo se queda puesto a propósito
|
|
121
|
+
—quien paró va a volver y su `claim` es idempotente—, así que cuando contestás, **otro** runner pregunta
|
|
122
|
+
y se va sin nada. Ahora para con `queue-unavailable` y dice cuántas hay; la línea `TAKEN` de
|
|
123
|
+
`ops context` nombra quién tiene cada una, y se sueltan con `ops release`.
|
|
124
|
+
|
|
17
125
|
## [0.96.0] - 2026-09-16
|
|
18
126
|
|
|
19
127
|
### Corregido
|
|
@@ -60,6 +60,9 @@ const CONTEXT = {
|
|
|
60
60
|
hasTask: { type: 'boolean' }, wipActive: { type: 'boolean' },
|
|
61
61
|
queued: { type: 'integer' }, slug: { type: 'string' }, hito: { type: 'string' },
|
|
62
62
|
service: { type: 'string' }, acceptance: { type: 'string' }, epic: { type: 'string' },
|
|
63
|
+
// Sin declararlo acá no llega, igual que le pasó a `epicContext`: `additionalProperties: false` lo
|
|
64
|
+
// descarta y las fases lo reciben vacío para siempre.
|
|
65
|
+
description: { type: 'string' },
|
|
63
66
|
// Sin declararlo acá no llega: `additionalProperties: false` lo descartaría, y el aplanado de la
|
|
64
67
|
// épica a su número —dos líneas arriba— hace fácil creer que ya viene. La fase Plan lo pedía en su
|
|
65
68
|
// prompt y planificaba contra el título; `readEpics` cuenta de dónde sale.
|
|
@@ -153,8 +156,29 @@ const DECISION = {
|
|
|
153
156
|
}
|
|
154
157
|
// Review nombra contra qué reglas revisó (caso 105): recibir las rutas no garantiza abrirlas, y esto es lo único
|
|
155
158
|
// que deja rastro de que se hizo. Critique no lo lleva porque no recibe la lista.
|
|
159
|
+
//
|
|
160
|
+
// Y `decision` es el tercer destino, el que R6 le da a lo que aparece y no le toca a este cargo: queda
|
|
161
|
+
// registrado con qué lo cierra y quién puede tomarlo. Las otras fases lo tienen —`ready-human`,
|
|
162
|
+
// `plan-human`, `verify-human`, y `open-decisions` en Build— y Review no, así que un revisor que
|
|
163
|
+
// encontraba una decisión elegía entre frenar la corrida o degradarla a propuesta con tope de tres.
|
|
164
|
+
// Elegía frenar, que de las dos es la correcta, y costaba la corrida entera sobre trabajo que estaba
|
|
165
|
+
// bien. Es la misma separación que `uncovered` hace en Verify, en el otro extremo del recorrido.
|
|
166
|
+
//
|
|
167
|
+
// Medido sobre un banco desechable el 2026-09-17, corridas `wf_99130468-2c4` y `wf_5dcc3828-a9f`: las dos
|
|
168
|
+
// terminaron en `review-failed` con la suite del producto en verde y los tres casos de la aceptación
|
|
169
|
+
// dando lo pedido, y las dos por un hallazgo que el propio revisor describió como una decisión que no le
|
|
170
|
+
// tocaba.
|
|
156
171
|
const REVIEWED = { ...DECISION, required: [...DECISION.required, 'rules'],
|
|
157
|
-
properties: {
|
|
172
|
+
properties: {
|
|
173
|
+
...DECISION.properties,
|
|
174
|
+
rules: { type: 'array', items: { type: 'string' } },
|
|
175
|
+
// Extiende el concern de `DECISION` en vez de reescribirlo: copiado entero, un campo nuevo allá no
|
|
176
|
+
// llegaría acá y ninguna prueba lo notaría.
|
|
177
|
+
concerns: { ...DECISION.properties.concerns,
|
|
178
|
+
items: { ...DECISION.properties.concerns.items,
|
|
179
|
+
properties: { ...DECISION.properties.concerns.items.properties, decision: { type: 'boolean' } },
|
|
180
|
+
} },
|
|
181
|
+
} }
|
|
158
182
|
// Un exit code dice que el test corrió, no que pruebe lo que la tarea prometió: un test que asercia de
|
|
159
183
|
// menos —o que ni existe— sale verde igual, y el guard de verify tampoco lo ve porque también mira exit
|
|
160
184
|
// codes. Por eso `uncovered` se contrasta contra la aceptación leyendo el fuente, no la salida (R9).
|
|
@@ -301,9 +325,19 @@ const VERDICT = ' Cerrá con verdict=aprobado si no queda nada por corregir ante
|
|
|
301
325
|
'alcance—. Marcá blocking=true sólo en el hallazgo que impide entregar: el resto queda registrado y no ' +
|
|
302
326
|
'manda a tocar código.'
|
|
303
327
|
// Acompaña a todo prompt con schema REVIEWED.
|
|
304
|
-
const RULED = ' En rules nombrá, por su ruta, cada una de las reglas que rigen contra la que revisaste
|
|
328
|
+
const RULED = ' En rules nombrá, por su ruta, cada una de las reglas que rigen contra la que revisaste'
|
|
329
|
+
+ ' el diff. Y marcá decision=true en el hallazgo que no te toca resolver a vos —una definición de'
|
|
330
|
+
+ ' producto, un contrato público, una autoridad que el cargo no tiene—: ése se registra para una'
|
|
331
|
+
+ ' persona y no manda a tocar código.'
|
|
305
332
|
// Lo que hay que corregir antes de entregar. El resto de los hallazgos no desaparece: se registra.
|
|
306
|
-
|
|
333
|
+
// Una decisión no cuenta como bloqueante aunque venga marcada: su destino es la fila, no la corrección.
|
|
334
|
+
// Critique no la emite —no está en su esquema— así que para él `one.decision` es siempre `undefined`.
|
|
335
|
+
const blockers = (verdict) => verdict.concerns
|
|
336
|
+
.filter((one) => one.blocking && !one.decision).map((one) => one.detail)
|
|
337
|
+
// Lo que una parada tiene que nombrar es todo lo que el revisor señaló, no sólo lo que manda a corregir:
|
|
338
|
+
// con `blockers` solo, un veredicto cuyo único hallazgo es una decisión paraba diciendo que nadie nombró
|
|
339
|
+
// ninguna condición. El motivo es lo único que queda para leer cuando la corrida terminó.
|
|
340
|
+
const named = (verdict) => verdict.concerns.filter((one) => one.blocking).map((one) => one.detail)
|
|
307
341
|
// El resultado de Build cuando no hubo nada que construir en esta corrida. Devuelve lo que de verdad
|
|
308
342
|
// pasó y nada más: `redFirst` y `discovered` van **vacíos** porque acá no hubo ningún rojo nuevo que
|
|
309
343
|
// mostrar ni ningún borde nuevo que fijar, y rellenarlos para que se parezca a una construcción sería
|
|
@@ -423,7 +457,8 @@ const readContext = () => read(
|
|
|
423
457
|
`omitilo entero si wip es null, sin inventar ceros—, y lane ` +
|
|
424
458
|
`de task.tier; copiá slug, ` +
|
|
425
459
|
`hito, service, acceptance, ` +
|
|
426
|
-
`epic y
|
|
460
|
+
`epic, cast y description de task, epicContext de epic.context —vacío si no hay épica— e inbox tal `
|
|
461
|
+
+ `cual. El comando es ` +
|
|
427
462
|
`la fuente de ` +
|
|
428
463
|
`verdad: no abras archivos de planning para completarlo. Poné readOk en true sólo si el comando salió ` +
|
|
429
464
|
`con código 0 y devolvió JSON; si falló, readOk en false y el resto en sus valores vacíos, sin ` +
|
|
@@ -485,10 +520,31 @@ while (rounds++ < MAX_TASKS) {
|
|
|
485
520
|
//
|
|
486
521
|
// `context` nombra la que sigue, igual que nombra una recurrencia vencida y por el mismo motivo: la
|
|
487
522
|
// máquina calcula y la persona encola.
|
|
523
|
+
// Una cola con trabajo y sin tarea disponible no es una cola terminada. Romper el bucle igual deja la
|
|
524
|
+
// corrida informando que no había nada que hacer sobre una cola que sí tiene tareas —reclamadas por otro
|
|
525
|
+
// runner, o esperando una dependencia—, que es la forma del caso 075: una respuesta vacía que se lee
|
|
526
|
+
// como un hecho del dominio. El dato ya venía: `queued` cuenta la cola entera, y `context` además la
|
|
527
|
+
// nombra con su dueño en la línea TAKEN.
|
|
528
|
+
//
|
|
529
|
+
// Se paga sobre todo después de una parada que espera a una persona: ahí el reclamo se queda puesto a
|
|
530
|
+
// propósito —quien paró va a volver, y `claim` le es idempotente—, así que cuando la persona contesta es
|
|
531
|
+
// un runner distinto el que pregunta y el que se va sin nada.
|
|
532
|
+
if (!planning.hasTask && planning.queued > 0) {
|
|
533
|
+
return stop('queue-unavailable', `la cola tiene ${planning.queued} tarea(s) y ninguna disponible para `
|
|
534
|
+
+ 'este runner. Mirá la línea TAKEN de "ops context": lo reclamado por otro se suelta con '
|
|
535
|
+
+ '"ops release" o se retoma desde el runner que lo tiene; lo que espera una dependencia, no.')
|
|
536
|
+
}
|
|
488
537
|
if (!planning.hasTask || (currentMilestone && planning.hito !== currentMilestone)) break
|
|
538
|
+
// Las decisiones que la línea ya tomó, dichas como lo que son. Sin ese rótulo se leen como contexto
|
|
539
|
+
// opinable y el que planifica las re-decide igual, que es el defecto entero: la crítica abre el
|
|
540
|
+
// BACKLOG por su cuenta y bloquea el plan citando la línea palabra por palabra (caso 177).
|
|
541
|
+
const DECIDED = () => (task.description
|
|
542
|
+
? ` Lo que la línea de la tarea ya decidió, y no se re-decide acá: ${task.description}`
|
|
543
|
+
: '')
|
|
489
544
|
const task = {
|
|
490
545
|
id: planning.slug, hito: planning.hito, service: planning.service,
|
|
491
546
|
acceptance: planning.acceptance, epic: planning.epic, epicContext: planning.epicContext || '',
|
|
547
|
+
description: planning.description || '',
|
|
492
548
|
}
|
|
493
549
|
// Reservar antes de construir, y antes de fijar el hito de la corrida. Sin esto dos corridas en
|
|
494
550
|
// paralelo trabajan la misma tarea: `context` sólo puede saltear lo que alguien ya reclamó, y el
|
|
@@ -614,12 +670,32 @@ while (rounds++ < MAX_TASKS) {
|
|
|
614
670
|
//
|
|
615
671
|
// Lo que la fila le pide a una persona lo dice R17: dos rechazos sobre lo mismo son el disparador
|
|
616
672
|
// posterior de división. No se parte acá porque partir es una decisión, y ésa no le toca al recorrido.
|
|
673
|
+
// Una parada que registra la fila **bloquea** la tarea: la fila pendiente la saca de la cola. Si además
|
|
674
|
+
// el reclamo se queda puesto, el runner queda ocupado por algo que nadie puede tomar, y la corrida
|
|
675
|
+
// siguiente —que hace bien en no reintentarla— muere reclamando la que sigue: `claim` le contesta «este
|
|
676
|
+
// runner ya tiene …». Es la forma del 163 por la otra puerta, y la diferencia es que el estado lo creó
|
|
677
|
+
// esta corrida, así que soltarlo también le toca (caso 176).
|
|
678
|
+
//
|
|
679
|
+
// Sólo las paradas de **antes** de construir. Las de después —`verify-regression`, `qa-failed`,
|
|
680
|
+
// `commit-failed`— dejan el WIP en disco y es resumible: ahí el reclamo es lo único que dice de quién es
|
|
681
|
+
// ese trabajo, y soltarlo lo abandonaría.
|
|
682
|
+
//
|
|
683
|
+
// Cuesta un agente, porque el recorrido no tiene con qué correr un comando. Un agente contra una
|
|
684
|
+
// corrida entera.
|
|
685
|
+
const releaseBlocked = async () => write(
|
|
686
|
+
`Corré "node tools/ops.js release ${P} ${task.id}" desde ${ROOT}: quedó bloqueada por la fila que `
|
|
687
|
+
+ 'acabás de registrar y todavía no hay nada construido, así que su reserva no reserva trabajo. No '
|
|
688
|
+
+ 'escribas ningún archivo vos: lo escribe el comando.',
|
|
689
|
+
{ label: `release:${task.id}` },
|
|
690
|
+
)
|
|
691
|
+
|
|
617
692
|
const planRejected = async (reason, unit, found) => {
|
|
618
693
|
const detail = found.join('; ') || 'sin condiciones nombradas'
|
|
619
694
|
const nota = await registerHuman(
|
|
620
695
|
`Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
|
|
621
696
|
+ `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
|
|
622
697
|
+ `distintas y partirla, o dejarla entera con la razón escrita.`, 'plan-human')
|
|
698
|
+
await releaseBlocked()
|
|
623
699
|
return stop(reason, `${detail}${nota}`)
|
|
624
700
|
}
|
|
625
701
|
|
|
@@ -628,7 +704,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
628
704
|
phase('Ready')
|
|
629
705
|
const ready = await read(
|
|
630
706
|
`${asRole(OWNERS.ready)}Revisá que ${task.id} tenga aceptación concreta, dependencias resueltas y ` +
|
|
631
|
-
`ninguna decisión pendiente: ${task.acceptance}
|
|
707
|
+
`ninguna decisión pendiente: ${task.acceptance}.${DECIDED()} Aclará la redacción y nada más; ` +
|
|
708
|
+
`nunca amplíes el ` +
|
|
632
709
|
`alcance.`,
|
|
633
710
|
{ schema: READY, label: 'ready' },
|
|
634
711
|
)
|
|
@@ -637,6 +714,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
637
714
|
const nota = await registerHuman(
|
|
638
715
|
`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
|
|
639
716
|
'ready-human')
|
|
717
|
+
await releaseBlocked()
|
|
640
718
|
return stop('not-ready', `${ready.reason}${nota}`)
|
|
641
719
|
}
|
|
642
720
|
if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
|
|
@@ -703,7 +781,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
703
781
|
`vecinas y el git status de ${task.id}.` +
|
|
704
782
|
`${task.epicContext ? ` Contexto de la épica: ${task.epicContext}` : ''}` +
|
|
705
783
|
` Producí el plan más chico que satisfaga ` +
|
|
706
|
-
`${task.acceptance}
|
|
784
|
+
`${task.acceptance}.${DECIDED()} Un archivo de planning no puede ser un archivo de implementación. ` +
|
|
785
|
+
`El plan cubre ` +
|
|
707
786
|
`sólo el cambio dentro de ${task.service}: correr los gates del repositorio, hacer QA, commitear y ` +
|
|
708
787
|
`cerrar la tarea son fases posteriores de este recorrido, cada una con su dueño, así que no van como ` +
|
|
709
788
|
`pasos.`,
|
|
@@ -714,7 +793,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
714
793
|
phase('Critique')
|
|
715
794
|
let critique = await read(
|
|
716
795
|
`Atacá este plan por correctitud, alcance, seguridad, pruebas y conflictos con el código ` +
|
|
717
|
-
`existente.${MANIFEST}${VERDICT} Plan: ${JSON.stringify(plan)}`,
|
|
796
|
+
`existente.${DECIDED()}${MANIFEST}${VERDICT} Plan: ${JSON.stringify(plan)}`,
|
|
718
797
|
{ schema: DECISION, label: 'critique' },
|
|
719
798
|
)
|
|
720
799
|
if (!critique) return stop('agent-unavailable', 'Critique no devolvió resultado')
|
|
@@ -729,7 +808,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
729
808
|
{ schema: PLAN, label: 'replan' },
|
|
730
809
|
)
|
|
731
810
|
critique = await read(
|
|
732
|
-
`Volvé a criticar el plan corregido contra ${task.acceptance}.${MANIFEST}${VERDICT} ` +
|
|
811
|
+
`Volvé a criticar el plan corregido contra ${task.acceptance}.${DECIDED()}${MANIFEST}${VERDICT} ` +
|
|
733
812
|
`Plan: ${JSON.stringify(plan)}`,
|
|
734
813
|
{ schema: DECISION, label: 'critique' },
|
|
735
814
|
)
|
|
@@ -798,7 +877,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
798
877
|
`nombrás en test y anotás en redFirst—, kind=open si lo notaste y no impide entregar la aceptación: se ` +
|
|
799
878
|
`registra para que lo decida quien corresponde y el recorrido sigue. Si de verdad no podés entregar sin ` +
|
|
800
879
|
`esa decisión, eso no va en discovered: es completed=false con su blocker. ` +
|
|
801
|
-
`Aceptación: ${task.acceptance}
|
|
880
|
+
`Aceptación: ${task.acceptance}.${DECIDED()}`,
|
|
802
881
|
{ schema: BUILD, label: 'build' },
|
|
803
882
|
)
|
|
804
883
|
if (!build) return stop('agent-unavailable', 'Build no devolvió resultado')
|
|
@@ -819,8 +898,16 @@ while (rounds++ < MAX_TASKS) {
|
|
|
819
898
|
// tiene camino —`completed: false` con su blocker—; esto se registra y sigue.
|
|
820
899
|
const openDecisions = build.discovered.filter((entry) => entry.kind === 'open')
|
|
821
900
|
if (openDecisions.length) {
|
|
901
|
+
// Y la fila no puede nombrar a la tarea que la produjo. El motor bloquea por esa primera celda
|
|
902
|
+
// exacta, así que escribirla ahí registra «esto no impide entregar» y produce el bloqueo igual —lo
|
|
903
|
+
// contrario de lo que este registro decidió dos líneas arriba—. No se nota mientras la corrida vive,
|
|
904
|
+
// porque el WIP activo manda sobre la acción humana; aparece cuando el WIP cierra, y entonces la
|
|
905
|
+
// tarea queda frenada por una pregunta que ya se había resuelto seguir sin contestar. En la corrida
|
|
906
|
+
// que lo mostró la atrapó Review, tres fases después de escribirla.
|
|
822
907
|
await write(`Registrá en ${HUMAN} una fila por cada decisión que ${task.id} dejó abierta, con qué la ` +
|
|
823
|
-
`cierra y quién puede tomarla.
|
|
908
|
+
`cierra y quién puede tomarla. La primera columna nunca es ${task.id}: el motor bloquea por esa ` +
|
|
909
|
+
`celda exacta y estas decisiones no impiden entregarla. Va la épica, el hito o el recorrido al que ` +
|
|
910
|
+
`alcanza la decisión. No inventes responsables ni fechas: ` +
|
|
824
911
|
`${JSON.stringify(openDecisions.map((entry) => entry.detail))}`, { label: 'open-decisions' })
|
|
825
912
|
}
|
|
826
913
|
// Y un caso que sí se fijó acá entra con su prueba o no entró: sin ella el comportamiento nuevo queda
|
|
@@ -830,11 +917,25 @@ while (rounds++ < MAX_TASKS) {
|
|
|
830
917
|
// cadena exacta frena una tarea correcta por haber nombrado el test de dos formas —`TestAlta` acá y
|
|
831
918
|
// `users_test.go::TestAlta` allá—. Alcanza con que uno nombre al otro; lo que sigue frenando, que es de
|
|
832
919
|
// lo que se trata, es el caso que no aparece en ningún rojo.
|
|
833
|
-
|
|
834
|
-
|
|
920
|
+
//
|
|
921
|
+
// Y la contención sola no alcanza, porque el caso que aparece no es que uno esté contenido en el otro:
|
|
922
|
+
// los dos nombran el mismo test y cada uno le agrega **su propia** anotación entre paréntesis —dónde
|
|
923
|
+
// está la línea de un lado, por qué se vio en rojo del otro—. Ahí ninguno contiene al otro y la puerta
|
|
924
|
+
// frenaba una entrega correcta, que es lo que R26 dice que termina apagándola. Se compara sin esa
|
|
925
|
+
// anotación final; una que esté en el medio se conserva, porque ahí sí es parte del nombre.
|
|
926
|
+
const core = (name) => String(name || '').replace(/\s*\([^)]*\)\s*$/, '').trim()
|
|
927
|
+
const namesTest = (red, item) => Boolean(core(item.test))
|
|
928
|
+
&& (core(red.test).includes(core(item.test)) || core(item.test).includes(core(red.test)))
|
|
835
929
|
const loose = build.discovered.find((entry) => entry.kind === 'edge'
|
|
836
930
|
&& !build.redFirst.some((red) => namesTest(red, entry)))
|
|
837
|
-
|
|
931
|
+
// El motivo dice qué comprobó la puerta y no una conclusión sobre el trabajo: pegarle al detalle del
|
|
932
|
+
// build un «entró sin la prueba que lo fija» producía una parada que se contradecía sola cuando el
|
|
933
|
+
// detalle contaba que la prueba sí estaba —la frase del agente y la de la puerta hablaban de cosas
|
|
934
|
+
// distintas y se leían como una—.
|
|
935
|
+
if (loose) {
|
|
936
|
+
return stop('edge-unproven', `${loose.detail} — su campo "test" (${loose.test || 'vacío'}) no nombra `
|
|
937
|
+
+ `ninguno de los rojos declarados: ${build.redFirst.map((red) => red.test).join(' | ') || '(ninguno)'}`)
|
|
938
|
+
}
|
|
838
939
|
|
|
839
940
|
// Qué revisión hubo, para que el cierre no pueda inventar una. Nace diciendo que no hubo porque
|
|
840
941
|
// `express` no convoca a nadie, y ése es el caso que se escribió como si un cargo hubiera aprobado.
|
|
@@ -848,17 +949,35 @@ while (rounds++ < MAX_TASKS) {
|
|
|
848
949
|
{ schema: REVIEWED, label: 'review' },
|
|
849
950
|
)
|
|
850
951
|
if (!review) return stop('agent-unavailable', 'Review no devolvió resultado')
|
|
952
|
+
// Se junta apenas cada pasada contesta, y no al final: las paradas de abajo salen antes de llegar al
|
|
953
|
+
// registro, y sin esto lo que el revisor señaló se iba con la corrida.
|
|
954
|
+
const reviewDecisions = review.concerns.filter((one) => one.decision)
|
|
955
|
+
let decidedNote = ''
|
|
851
956
|
if (review.verdict === 'bloqueado') {
|
|
852
|
-
return stop('review-blocked',
|
|
957
|
+
return stop('review-blocked', named(review).join('; ') || 'sin condiciones nombradas')
|
|
853
958
|
}
|
|
854
959
|
if (blockers(review).length) {
|
|
855
|
-
|
|
960
|
+
// «Sólo estos hallazgos» acota el alcance (R6) y por sí solo deja un cabo suelto: una corrección
|
|
961
|
+
// tiene dependientes y no se anuncian —el conteo que enumeraba lo que cambió, el comentario que
|
|
962
|
+
// describía la forma vieja, la fila que la afirmaba—. Es R9 leído al derecho: lo que deja de valer
|
|
963
|
+
// se lleva puesto a quien lo daba por cierto, y eso vive casi siempre en otro archivo.
|
|
964
|
+
//
|
|
965
|
+
// Sin pedirlo, la vuelta siguiente rechaza por la deriva que la corrección acabó de crear, y como
|
|
966
|
+
// la vuelta es una sola eso termina en `review-failed` sobre trabajo correcto. Medido: una
|
|
967
|
+
// corrección agregó una mutación y un caso de prueba, y la re-revisión frenó porque la fila de
|
|
968
|
+
// acciones humanas seguía diciendo el número viejo y dos comentarios seguían contando los casos
|
|
969
|
+
// anteriores —corrida `wf_99130468-2c4`, 2026-09-17, sobre un banco desechable—. Traerlos no
|
|
970
|
+
// amplía el alcance: es terminar la corrección.
|
|
971
|
+
await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}. `
|
|
972
|
+
+ 'Traé también lo que tu propia corrección deje desactualizado —un conteo, un comentario que '
|
|
973
|
+
+ 'describa la forma vieja, una fila que la enumere— y nada más que eso.',
|
|
856
974
|
{ label: 'review-fix' })
|
|
857
975
|
review = await run(`Volvé a revisar el diff corregido de ${task.id}.${MANIFEST}${VERDICT}${RULED}`,
|
|
858
976
|
{ schema: REVIEWED, label: 'review' })
|
|
859
977
|
if (!review) return stop('agent-unavailable', 'la re-revisión no devolvió resultado')
|
|
978
|
+
reviewDecisions.push(...review.concerns.filter((one) => one.decision))
|
|
860
979
|
if (review.verdict === 'bloqueado' || blockers(review).length) {
|
|
861
|
-
return stop('review-failed',
|
|
980
|
+
return stop('review-failed', named(review).join('; ') || 'sin condiciones nombradas')
|
|
862
981
|
}
|
|
863
982
|
}
|
|
864
983
|
// Con reglas que rigen, aprobar sin nombrar contra cuáles es la misma falla que la de abajo en otro eje.
|
|
@@ -871,7 +990,30 @@ while (rounds++ < MAX_TASKS) {
|
|
|
871
990
|
// Contra qué reglas se revisó va también a la entrada de DONE, y no sólo al journal: el journal muere
|
|
872
991
|
// con la corrida y la entrada queda, así que sin esto no había cómo reconstruir contra cuáles se
|
|
873
992
|
// revisó cuando alguien audita la entrega meses después (caso 122).
|
|
993
|
+
// Lo que la revisión encontró y no le toca resolver va a la fila que una persona lee. No al INBOX, que
|
|
994
|
+
// es para propuestas: quien lo lea ahí puede promoverlo, y esto no se promueve, se decide.
|
|
995
|
+
//
|
|
996
|
+
// Se acumula desde la primera pasada y no se lee sólo de la última: al corrector se le pasa nada más
|
|
997
|
+
// lo que manda a corregir, así que nunca se entera de la decisión y no la puede cerrar. Leyendo sólo
|
|
998
|
+
// la re-revisión, una decisión que el agente no repitiera desaparecía sin dejar rastro, y el mismo
|
|
999
|
+
// camino la perdía entera cuando la revisión paraba antes de llegar acá.
|
|
1000
|
+
//
|
|
1001
|
+
// El tope es el mismo del INBOX y por la misma razón: una revisión que deja treinta filas en una tabla
|
|
1002
|
+
// que lee una persona no está priorizando (caso 101). Lo que no entra queda contado en el hecho.
|
|
1003
|
+
const decided = [...new Set(reviewDecisions.map((one) => one.detail))]
|
|
1004
|
+
const filed = decided.slice(0, INBOX_CAP)
|
|
1005
|
+
if (filed.length) {
|
|
1006
|
+
// La nota que devuelve viaja al hecho: sin ella la entrega afirma una fila que el disco no tiene,
|
|
1007
|
+
// que es el caso 087 entrando por otra puerta.
|
|
1008
|
+
const nota = await registerHuman(`Registrá en ${HUMAN} una fila por cada decisión que la revisión de `
|
|
1009
|
+
+ `${task.id} dejó abierta, con qué la cierra y quién puede tomarla. La primera columna nunca es `
|
|
1010
|
+
+ `${task.id} —el porqué es el mismo que en Build—: va la épica, el hito o el recorrido al que `
|
|
1011
|
+
+ `alcanza. No inventes responsables ni fechas: ${JSON.stringify(filed)}`, 'review-human')
|
|
1012
|
+
decidedNote = `${nota}`
|
|
1013
|
+
}
|
|
874
1014
|
reviewFact = `${review.verdict} por ${cast.review}, sobre ${review.consulted.join(', ')}`
|
|
1015
|
+
+ (filed.length ? ` · ${filed.length} decisión(es) registrada(s)${decidedNote}` : '')
|
|
1016
|
+
+ (decided.length > filed.length ? ` · ${decided.length - filed.length} decisión(es) sin volcar` : '')
|
|
875
1017
|
+ ((review.rules || []).length ? ` · reglas: ${review.rules.join(', ')}` : '')
|
|
876
1018
|
// Lo que no impide entregar no manda a tocar código, y tampoco desaparece: la mejora opinable que se
|
|
877
1019
|
// corrige a las apuradas cuesta una vuelta y un riesgo que nadie pidió. Va a Propuestas y no a
|
|
@@ -881,7 +1023,10 @@ while (rounds++ < MAX_TASKS) {
|
|
|
881
1023
|
// De qué vía salió cada una: este recorrido, la tarea que la dejó anotada y la fecha del motor. La
|
|
882
1024
|
// arma el recorrido, que es el único de los dos que sabe las tres cosas (caso 115).
|
|
883
1025
|
const origin = inboxOrigin('autobuild', task.id, planning.today)
|
|
884
|
-
|
|
1026
|
+
// Lo marcado como decisión ya tiene destino y no se duplica acá: escrito en los dos lados, además de
|
|
1027
|
+
// aparecer dos veces, le come una ranura del tope a una propuesta que sí lo era.
|
|
1028
|
+
const noted = review.concerns.filter((one) => !one.blocking && !one.decision)
|
|
1029
|
+
.map((one) => withOrigin(one.detail, origin))
|
|
885
1030
|
const kept = noted.slice(0, INBOX_CAP)
|
|
886
1031
|
// Lo que pasa del tope no se escribe y tampoco desaparece: queda contado en el hecho de revisión, que
|
|
887
1032
|
// viaja a `done/`. Una revisión que anota treinta y seis cosas no está priorizando, y el INBOX no las
|
|
@@ -1009,9 +1154,14 @@ const closing = await write(
|
|
|
1009
1154
|
)
|
|
1010
1155
|
if (!closing) return stop('agent-unavailable', 'Closing no devolvió resultado')
|
|
1011
1156
|
if (!closing.passed) return stop('planning-check-failed', closing.details)
|
|
1157
|
+
// El archivo nace con su estado escrito porque la compuerta lo lee de ahí —R28, y el porqué vive junto a
|
|
1158
|
+
// esa lectura—. Sin decirlo acá la fase escribe prosa sin `status`, y la instancia queda con una compuerta
|
|
1159
|
+
// que sólo se destraba borrando: la forma que la regla prohíbe, escrita por el propio recorrido.
|
|
1012
1160
|
if (completed.length && contract.humanCheckpoint) await write(
|
|
1013
1161
|
`Creá ${GATE} con el hito terminado, las tareas ${completed.join(', ')}, la evidencia, las acciones humanas ` +
|
|
1014
|
-
`pendientes y las instrucciones exactas para continuar.
|
|
1162
|
+
`pendientes y las instrucciones exactas para continuar. Arrancá el archivo con un frontmatter ` +
|
|
1163
|
+
`"status: pendiente", y decí que se destraba cambiándolo a "resuelta" —no borrando el archivo, que es ` +
|
|
1164
|
+
`lo que deja leer después qué se revisó—. Nunca hagas push ni deploy.`,
|
|
1015
1165
|
{ label: 'human-checkpoint' },
|
|
1016
1166
|
)
|
|
1017
1167
|
return finish({ done: completed, count: completed.length, hito: currentMilestone, phases: ran })
|
|
@@ -111,16 +111,30 @@ const STAGE = {
|
|
|
111
111
|
properties: {
|
|
112
112
|
gate: { type: 'string', enum: ['cumplido', 'con-condiciones', 'no-cumplido'] },
|
|
113
113
|
// La ruta del análisis, no el análisis. Ver el comentario de arriba: mientras el campo pudo
|
|
114
|
-
// contener el texto entero, lo contuvo, y la respuesta no llegaba.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
// contener el texto entero, lo contuvo, y la respuesta no llegaba. Es la única excepción al techo:
|
|
115
|
+
// una ruta se acota sola.
|
|
116
|
+
analysis: { type: 'string' },
|
|
117
|
+
// Y el resto lleva su techo por la misma razón, que el arreglo de `analysis` no alcanzó a cubrir: la
|
|
118
|
+
// etapa que **frena** escribe largo en `missing` y `humanAction` —lo dice el párrafo de arriba y lo
|
|
119
|
+
// volvió a mostrar una corrida sobre un banco, con los cinco reintentos envueltos—. Sin tope, cada
|
|
120
|
+
// campo nuevo nace sin él y lo que revienta es la etapa que menos se ejercita.
|
|
121
|
+
//
|
|
122
|
+
// Los números salen de lo que el prompt ya pedía —`summary` en 150 palabras o menos, que es del orden
|
|
123
|
+
// de mil caracteres— y de R16: entre etapas viaja lo que la siguiente necesita para decidir, no todo
|
|
124
|
+
// lo que la anterior produjo. Lo que no entra no se pierde: vive en el archivo de análisis.
|
|
125
|
+
//
|
|
126
|
+
// Un techo por debajo de lo que el prompt pide no acota: contradice. Medido en una corrida real con
|
|
127
|
+
// `summary` en 400, el agente achicó en cada reintento —2766, 2078, 2064, 1934, 1854— y murió
|
|
128
|
+
// convergiendo hacia un número siete veces menor que el que la misma instrucción le pedía escribir.
|
|
129
|
+
summary: { type: 'string', maxLength: 1000 },
|
|
130
|
+
evidence: { type: 'array', maxItems: 5, items: { type: 'string', maxLength: 200 } },
|
|
131
|
+
assumptions: { type: 'array', maxItems: 4, items: { type: 'string', maxLength: 160 } },
|
|
132
|
+
openQuestions: { type: 'array', maxItems: 4, items: { type: 'object', additionalProperties: false,
|
|
119
133
|
required: ['detail', 'blocking'],
|
|
120
|
-
properties: { detail: { type: 'string' }, blocking: { type: 'boolean' } },
|
|
134
|
+
properties: { detail: { type: 'string', maxLength: 200 }, blocking: { type: 'boolean' } },
|
|
121
135
|
} },
|
|
122
|
-
missing: { type: 'string' },
|
|
123
|
-
humanAction: { type: 'string' },
|
|
136
|
+
missing: { type: 'string', maxLength: 500 },
|
|
137
|
+
humanAction: { type: 'string', maxLength: 500 },
|
|
124
138
|
},
|
|
125
139
|
}
|
|
126
140
|
// Tres salidas porque el contrato del equipo enumera tres —«hacer, no hacer o investigar»— y con un
|
|
@@ -281,14 +295,16 @@ const runStage = (stage, index) => {
|
|
|
281
295
|
`condiciona la decisión siguiente. ` +
|
|
282
296
|
`Escribí primero tu análisis completo en ${REPORTS} como ${stage.id}-analisis.md —ahí no hay ` +
|
|
283
297
|
`límite de extensión y es lo que lee quien sintetiza al final— y devolvé esa ruta en analysis.\n` +
|
|
284
|
-
`Lo que devolvés en el esquema es corto
|
|
285
|
-
`
|
|
286
|
-
`
|
|
298
|
+
`Lo que devolvés en el esquema es corto y cada campo tiene su tope, que el esquema rechaza si lo `
|
|
299
|
+
+ `pasás: summary 1000 caracteres, missing y humanAction 500, cada evidencia 200. Si algo no entra, `
|
|
300
|
+
+ `va al archivo y en el campo queda lo esencial. El tope rige sobre todo cuando el gate no se `
|
|
301
|
+
+ `cumple, que es cuando más se escribe. ` +
|
|
287
302
|
`Devolvé los campos directamente: nunca envuelvas la respuesta en {"raw": ..., "len": ...} ni ` +
|
|
288
303
|
`mandes el JSON como string adentro de un campo, porque eso no valida. ` +
|
|
289
|
-
`En summary va, en
|
|
290
|
-
`
|
|
291
|
-
`
|
|
304
|
+
`En summary va, en los 1000 caracteres que el esquema admite, lo que la etapa siguiente necesita ` +
|
|
305
|
+
`para decidir —no un resumen de tu análisis, sino lo que le cambia el trabajo—, porque eso se le ` +
|
|
306
|
+
`reenvía a cada etapa posterior. El límite va en caracteres y no en palabras porque es lo que el ` +
|
|
307
|
+
`esquema mide.`,
|
|
292
308
|
{ schema: STAGE, label: `stage:${stage.id}` })
|
|
293
309
|
}
|
|
294
310
|
|
package/engine/cli/planning.js
CHANGED
|
@@ -162,11 +162,14 @@ function context(dir, cli) {
|
|
|
162
162
|
const report = {
|
|
163
163
|
// Toda la cola trabada por una persona no es lo mismo que no tener cola, y decir lo segundo manda a
|
|
164
164
|
// buscar trabajo que no existe en vez de a resolver la fila que lo destraba.
|
|
165
|
-
blocked:
|
|
165
|
+
blocked: P.checkpointHolds(root) ? 'awaiting-review' : (!task && skipped.length ? 'blocked-on-human' : ''),
|
|
166
166
|
task: task && {
|
|
167
167
|
slug: task.slug, hito: task.hito, tier: task.tier, cast: task.cast, service: task.service,
|
|
168
168
|
// Una tarea puede heredar su aceptación del criterio citado; el runner necesita el texto, no la cita.
|
|
169
169
|
acceptance: task.acceptance || criteria.map((criterion) => criterion.text).join(' '),
|
|
170
|
+
// Las decisiones que la línea ya tomó. Viaja con la tarea y no aparte porque es de ella: quien la
|
|
171
|
+
// reciba tiene que poder leerla al lado de su aceptación, que es contra lo que se contrasta.
|
|
172
|
+
description: task.description || '',
|
|
170
173
|
epic: task.epic,
|
|
171
174
|
},
|
|
172
175
|
criteria,
|
package/engine/hooks/shell.js
CHANGED
|
@@ -45,6 +45,20 @@ const COMANDO = String.raw`$|[;&|)'"\`]`
|
|
|
45
45
|
// de frenar trabajo legítimo nombraba una violación que no estaba.
|
|
46
46
|
const MISMO = String.raw`[^;&|\n]`
|
|
47
47
|
|
|
48
|
+
// Si algún remoto alcanza al commit que está en HEAD. Lectura local e inocua: `branch -r` mira lo que ya
|
|
49
|
+
// está en `refs/remotes/`, no habla con ningún servidor. Un `fetch` que nadie corrió deja la respuesta
|
|
50
|
+
// desactualizada hacia el lado seguro — cree que no está publicado algo que sí lo está—, y por eso esto
|
|
51
|
+
// acota un bloqueo y no autoriza nada: lo que decide publicar sigue siendo `push`, que mira otra cosa.
|
|
52
|
+
//
|
|
53
|
+
// Sin git, sin repositorio o con el comando fallando, la respuesta es **sí**: cerrado por defecto (R27).
|
|
54
|
+
// No saber si algo se publicó no es lo mismo que saber que no.
|
|
55
|
+
function publishedHead(input) {
|
|
56
|
+
const cwd = (input && input.cwd) || process.cwd()
|
|
57
|
+
const result = spawnSync('git', ['branch', '-r', '--contains', 'HEAD'], { cwd, encoding: 'utf8' })
|
|
58
|
+
if (result.status !== 0) return true
|
|
59
|
+
return Boolean(result.stdout.trim())
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
// Un mensaje de commit es dato, no código. `git commit -m "fix: bloquear git push --force"` disparaba
|
|
49
63
|
// el guard de publicación, y lo mismo `rm -rf /` nombrado en una explicación; con el heredoc que se usa
|
|
50
64
|
// para un mensaje largo, el cuerpo entero entra en el comando, así que la línea que arregla esto no se
|
|
@@ -80,13 +94,22 @@ function destructive(input) {
|
|
|
80
94
|
publish(input, command)
|
|
81
95
|
const rules = [
|
|
82
96
|
[/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales.", true],
|
|
83
|
-
// R8
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
97
|
+
// Lo que R8 protege es la historia que **otro ya leyó**, y el motor ya hace esa distinción para el
|
|
98
|
+
// push: publicar se autoriza, reescribir lo publicado no. Acá se hace la misma, mirando si algún
|
|
99
|
+
// remoto alcanza a HEAD.
|
|
100
|
+
//
|
|
101
|
+
// Antes se bloqueaba siempre, «por política y no por daño», y eso no impedía el resultado sino el
|
|
102
|
+
// comando que lo nombra: `git reset --soft HEAD~1` y volver a commitear produce exactamente lo mismo,
|
|
103
|
+
// no lo frena ningún guard y no deja constancia de nada. Una regla que se cumple mejor esquivándola se
|
|
104
|
+
// termina esquivando siempre (caso 178).
|
|
105
|
+
//
|
|
106
|
+
// Y no lleva salida por chat ni publicado ni sin publicar: sin publicar no la necesita, y publicado es
|
|
107
|
+
// la misma reescritura que el force-push, que tampoco la tiene.
|
|
108
|
+
...(publishedHead(input) ? [[
|
|
87
109
|
new RegExp(String.raw`\bgit\s+commit\b${MISMO}*\s--amend\b`),
|
|
88
|
-
"'git commit --amend'
|
|
89
|
-
|
|
110
|
+
"'git commit --amend' sobre un commit ya publicado reescribe historia que otro leyó. R8 lo "
|
|
111
|
+
+ 'prohíbe: hacé otro commit encima.',
|
|
112
|
+
]] : []),
|
|
90
113
|
[/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento.", true],
|
|
91
114
|
// `git checkout -- .` destruye lo mismo que `reset --hard` y sin recuperación, pero se escribe como
|
|
92
115
|
// una limpieza. Se bloquea sólo la forma ancha —`.`, `*`, `:/`, o sin ruta—: revertir un archivo
|
|
@@ -383,6 +383,25 @@ function validateState({
|
|
|
383
383
|
if (!row.valid) {
|
|
384
384
|
errors.push(`HUMAN_ACTIONS ${row.task}: estado "${row.state}" fuera de `
|
|
385
385
|
+ `${P.HUMAN_ACTION_STATES.join(' | ')}; mientras no se entienda, la tarea queda bloqueada`)
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
// La primera columna es a la vez lo que una persona lee y la clave con la que el motor bloquea:
|
|
389
|
+
// la selección de tarea (`state.js`) arma su conjunto de bloqueadas con ella tal cual. Esa doble
|
|
390
|
+
// función es libre a propósito —el molde manda
|
|
391
|
+
// nombrar la épica o el recorrido cuando la tarea todavía no existe— así que lo que no se puede
|
|
392
|
+
// recortar es la libertad, y lo que sí se puede es la forma intermedia: la celda que **menciona** una
|
|
393
|
+
// tarea de la cola sin ser su slug.
|
|
394
|
+
//
|
|
395
|
+
// Es la peor de las tres porque promete un bloqueo que no ocurre, y nada lo dice: la fila se escribe
|
|
396
|
+
// sin error, sale en `ops context` bajo HUMAN como si estuviera registrada, y la tarea se sigue
|
|
397
|
+
// ofreciendo. `**slug: de qué se trata**` es la que sale natural, porque esta tabla la lee una
|
|
398
|
+
// persona. Sin esto la ausencia no deja rastro, que es la forma de R15 aplicada a un mecanismo.
|
|
399
|
+
if (backlogSlugs.has(row.task)) continue
|
|
400
|
+
const casi = [...backlogSlugs].find((slug) => new RegExp(`\\b${slug}\\b`).test(row.task))
|
|
401
|
+
if (casi) {
|
|
402
|
+
errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${casi} y no bloquea nada, porque el `
|
|
403
|
+
+ `motor bloquea por la primera columna exacta. Dejá "${casi}" sola ahí y contá el resto en la `
|
|
404
|
+
+ 'acción, o nombrá la épica o el recorrido si lo que se frena no es esa tarea')
|
|
386
405
|
}
|
|
387
406
|
}
|
|
388
407
|
|
|
@@ -176,6 +176,10 @@ function readCast(rest) {
|
|
|
176
176
|
// devolvía `MAX` con `check` en verde, que es la forma cara del error —la tarea se lee completa y no
|
|
177
177
|
// lo está—. Cierra el `_` que markdown cerraría: el que no está entre caracteres de palabra.
|
|
178
178
|
const ACCEPTANCE = /_Aceptaci[oó]n:\s*(.*?\S)_(?![A-Za-z0-9])/i
|
|
179
|
+
// Los paréntesis del contrato de una línea de tarea. Se enumeran por su clave y no como «cualquier
|
|
180
|
+
// paréntesis» para no llevarse puesta una aclaración de la prosa: la descripción usa paréntesis igual
|
|
181
|
+
// que cualquier texto, y el criterio `(→ C1)` va acá porque `criteria` ya lo extrae.
|
|
182
|
+
const MARKERS = /\((?:→|->|criterios?\s|epic:|service:|cast:|depende:|sin partir:)[^)]*\)/gi
|
|
179
183
|
|
|
180
184
|
// Cuántas condiciones tiene una aceptación escrita en prosa. Estuvo mucho tiempo sin contarse con una
|
|
181
185
|
// razón buena —contar condiciones en una frase es una lectura, y un número inventado es peor que
|
|
@@ -207,6 +211,19 @@ function taskFromLine(line) {
|
|
|
207
211
|
epic: ((rest.match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
|
|
208
212
|
service: ((rest.match(/\(service:\s*([^)]+)\)/) || [])[1] || '').trim(),
|
|
209
213
|
acceptance,
|
|
214
|
+
// Lo que la aceptación no puede decir y alguien ya decidió: dónde vive un símbolo, qué queda fuera de
|
|
215
|
+
// alcance, con qué se produce la evidencia. Por contrato la aceptación describe estado observable del
|
|
216
|
+
// producto, así que una decisión de diseño no cabe ahí — y hasta acá tampoco salía del BACKLOG: el
|
|
217
|
+
// texto vivía en `rest` y se descartaba.
|
|
218
|
+
//
|
|
219
|
+
// Sin ella el que planifica vuelve a decidir lo ya decidido, y decide distinto. La crítica **sí** abre
|
|
220
|
+
// el BACKLOG y bloquea el plan citando la línea palabra por palabra: una compuerta juzga contra un
|
|
221
|
+
// texto que la otra no recibió. Dos corridas reales se pagaron enteras para descubrirlo, 1,10 M y
|
|
222
|
+
// 815 k tokens, la segunda con sus cuatro objeciones diciendo lo mismo (caso 177).
|
|
223
|
+
//
|
|
224
|
+
// Se recorta la aceptación y los marcadores del contrato porque cada uno ya tiene su campo: repetirlos
|
|
225
|
+
// acá los pone dos veces en el prompt de cada fase, que es lo que R16 cobra una vez por etapa.
|
|
226
|
+
description: rest.replace(ACCEPTANCE, '').replace(MARKERS, '').replace(/\s+/g, ' ').trim(),
|
|
210
227
|
conditions: acceptanceConditions(acceptance),
|
|
211
228
|
criteria: criteriaRefs(rest),
|
|
212
229
|
// De qué otras tareas depende. El orden del BACKLOG alcanzaba mientras hubiera un runner: con dos,
|
|
@@ -388,6 +405,23 @@ function readWip(dir, runner) {
|
|
|
388
405
|
return parseWip(read(path.join(dir, 'wip', `${name}.md`)), name)
|
|
389
406
|
}
|
|
390
407
|
|
|
408
|
+
// Si el checkpoint de hito sigue frenando, leído del archivo y no de que el archivo esté. Es la misma
|
|
409
|
+
// forma que el WIP de acá arriba —`status: IDLE` es un estado escrito— y la razón es R28: un centinela
|
|
410
|
+
// cuya única información es existir obliga a que el borrado sea parte de la resolución, y esa es una
|
|
411
|
+
// convención que alguien va a olvidar. Cuando se olvida, quien revisa lee que el hito ya se revisó
|
|
412
|
+
// mientras el mecanismo sigue leyendo que el archivo está, y la corrida siguiente muere en la puerta de
|
|
413
|
+
// entrada habiendo cargado el estado entero.
|
|
414
|
+
//
|
|
415
|
+
// Cerrado por defecto (R27): frena salvo que diga `resuelta`. Un archivo de una instancia anterior no
|
|
416
|
+
// trae `status`, y abrirlo por eso destrabaría en silencio al actualizar, que es la quita escrita como
|
|
417
|
+
// agregado que R9 nombra. El vocabulario es el mismo de `HUMAN_ACTIONS.md` a propósito: es el mismo acto
|
|
418
|
+
// —una persona contesta— y dos palabras para eso serían dos convenciones que aprender.
|
|
419
|
+
function checkpointHolds(dir) {
|
|
420
|
+
const text = read(path.join(dir, 'AWAITING_REVIEW.md'))
|
|
421
|
+
if (!text) return false
|
|
422
|
+
return !/^status:\s*resuelta\b/mi.test(text)
|
|
423
|
+
}
|
|
424
|
+
|
|
391
425
|
// Todos los que hay. Lo pregunta `check`, que juzga si cada plan apunta a una tarea que existe, y `tree`,
|
|
392
426
|
// que muestra qué está en vuelo: las dos son preguntas sobre la instancia y no sobre quien pregunta.
|
|
393
427
|
function readWips(dir) {
|
|
@@ -436,6 +470,7 @@ module.exports = {
|
|
|
436
470
|
EPIC_STATES, HUMAN_ACTION_STATES, LANES, MILESTONE_HEADING, STOP_REASONS,
|
|
437
471
|
TASK_LINE, TASK_LINE_ANY_LANE,
|
|
438
472
|
read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip, readWips, wipName,
|
|
473
|
+
checkpointHolds,
|
|
439
474
|
acceptanceConditions, tableRows, taskFromLine,
|
|
440
475
|
readInbox, inboxHeads, readHumanActions,
|
|
441
476
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
## R8 — Un commit por naturaleza
|
|
4
4
|
|
|
5
5
|
Stagear rutas explícitas, revisar el diff staged y crear un Conventional Commit en inglés. No usar
|
|
6
|
-
`git add .`, `git add -A`,
|
|
6
|
+
`git add .`, `git add -A`, force ni trailers de IA.
|
|
7
|
+
|
|
8
|
+
**Y no reescribir historia que otro ya leyó.** Lo que se protege es eso, no el comando: un `--amend`
|
|
9
|
+
sobre un commit publicado es la misma reescritura que un force-push y se frena igual; sobre uno que no
|
|
10
|
+
salió de tu máquina es la corrección, y prohibirlo no impide el resultado —`git reset --soft HEAD~1` y
|
|
11
|
+
volver a commitear produce exactamente lo mismo— sino el comando que lo nombra. Una regla que se cumple
|
|
12
|
+
mejor esquivándola se termina esquivando siempre.
|
|
7
13
|
|
|
8
14
|
Dónde corta un commit lo decide la naturaleza del diff, no su tamaño ni un conteo. Una tarea suele
|
|
9
15
|
tener una sola, y por eso un commit por tarea es lo habitual; cuando tiene dos, se hacen dos. Un
|
|
@@ -90,7 +96,8 @@ Push, PR, merge, tags, deploy y rollback requieren la autorización configurada
|
|
|
90
96
|
De esos seis, el motor comprueba uno: el push, contra `runner.allowPush` —que no llega a la rama viva
|
|
91
97
|
sin `runner.pushToLiveBranches`, ni a un subagente— o contra la orden que la persona da en el chat
|
|
92
98
|
nombrando el remoto y la rama. Reescribir historia publicada
|
|
93
|
-
no entra en esa autorización y se frena siempre
|
|
99
|
+
no entra en esa autorización y se frena siempre — también cuando la reescritura es un `--amend`, que el
|
|
100
|
+
guard distingue mirando si algún remoto alcanza al commit. Los otros cinco no tienen una
|
|
94
101
|
forma reconocible en un comando —un deploy es `kubectl`, `terraform`, un script o un botón— y los
|
|
95
102
|
sostiene esta regla y el review, no un guard.
|
|
96
103
|
|