@ingeniomaps/cauce 0.75.0 → 0.77.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 +150 -0
- package/automatization/workflows/autobuild.js +29 -4
- package/engine/cli/archive.js +3 -3
- package/engine/cli/catalog.js +45 -37
- package/engine/cli/claims.js +4 -4
- package/engine/cli/io.js +30 -1
- package/engine/cli/planning.js +8 -27
- package/engine/cli/worktree.js +2 -2
- package/engine/config/validate.js +33 -0
- package/engine/core/repos.js +56 -1
- package/engine/hooks/files.js +31 -1
- package/engine/hooks/run.js +2 -1
- package/engine/planning/contracts.js +54 -0
- package/engine/planning/parser.js +7 -2
- package/engine/planning/state.js +1 -1
- package/engine/schemas/ops-config.schema.json +16 -0
- package/package.json +1 -1
- package/template/AGENTS.md +11 -0
- package/template/planning/PROTOCOL.md +8 -1
- package/template/planning/done/README.md +23 -0
- package/template/planning/rules/README.md +2 -2
- package/template/planning/rules/system/conduct.md +41 -0
- package/template/planning/rules/system/process.md +15 -1
- package/template/planning/wip/README.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,156 @@ 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.77.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Corregido
|
|
20
|
+
|
|
21
|
+
- **`check` dice cuánto del trabajo que entró al repositorio quedó registrado.** Cuenta, por raíz de
|
|
22
|
+
trabajo, los commits que **ninguna entrada de DONE nombra** desde la última tarea cerrada. Es un aviso,
|
|
23
|
+
no un error: es un hecho del pasado que no se arregla editando nada.
|
|
24
|
+
|
|
25
|
+
El número no existía y sacarlo pedía cruzar a mano los `commit:` contra la historia de cada
|
|
26
|
+
repositorio. Hecho así sobre una instancia real: **312 commits, 75 registrados**. Y el desglose de los
|
|
27
|
+
que faltaban no era trabajo suelto — **69 `feat` y 51 `fix` de 173**—, ni tareas que produjeron varios
|
|
28
|
+
commits: de 80 entradas, una sola registra más de uno.
|
|
29
|
+
|
|
30
|
+
La ventana arranca en la última tarea cerrada y no en la primera, y esa decisión es la que hace que
|
|
31
|
+
sirva: contar toda la historia da una deuda que nunca baja y se lee como decorado. Así vuelve a cero
|
|
32
|
+
cada vez que el flujo se cierra, y lo que queda a la vista es la deriva de ahora.
|
|
33
|
+
|
|
34
|
+
**Lo que te pide algo**: si el número no es cero, ese trabajo no está en `planning/` y `OPS-001` dice
|
|
35
|
+
que ahí está la fuente de verdad. Qué hacer con él es tuyo — el aviso no propone cerrar nada
|
|
36
|
+
retroactivamente, porque escribir entradas de memoria sería inventar la evidencia que el registro
|
|
37
|
+
existe para tener.
|
|
38
|
+
|
|
39
|
+
- **Un plan que ninguna crítica aprueba deja de reintentarse a ciegas.** `autobuild` cortaba con
|
|
40
|
+
`plan-rejected` sin dejar rastro, así que relanzar repetía **la corrida entera** sobre la misma tarea:
|
|
41
|
+
Ready y Decompose la volvían a dejar pasar —su criterio no cambió y la tarea tampoco— y Critique la
|
|
42
|
+
volvía a rechazar. Medido en dos corridas consecutivas: idénticas, **9 agentes y ~780 k tokens cada
|
|
43
|
+
una, sin escribir una línea de código**.
|
|
44
|
+
|
|
45
|
+
Ahora la tarea queda registrada en `HUMAN_ACTIONS.md` con el motivo, y eso hace las dos cosas de una
|
|
46
|
+
vez: `context` deja de ofrecerla —una acción pendiente saca esa tarea de la cola y ofrece la siguiente,
|
|
47
|
+
sin frenar la corrida— y alguien ve la fila. Vale igual para `plan-blocked`, que cortaba igual de mudo.
|
|
48
|
+
|
|
49
|
+
**Lo que te pide algo**: esa fila es tuya. Lo que pide R17 es mirar si la unidad son dos resultados con
|
|
50
|
+
vidas distintas y partirla, o dejarla entera con la razón escrita.
|
|
51
|
+
|
|
52
|
+
- **El guard de migraciones deja de ser inerte en todo proyecto cuyas migraciones no sean `.sql`.** Filtraba
|
|
53
|
+
por ruta **y por extensión**, así que en TypeORM, Prisma, Django, Rails o Alembic no miraba nada: ni
|
|
54
|
+
frenaba el SQL destructivo, ni protegía una migración existente de ser reescrita. Y no lo decía — aparecía
|
|
55
|
+
cableado y en verde. Medido en una instancia real: **64 migraciones `.sql` cubiertas y 409 TypeORM `.ts`
|
|
56
|
+
invisibles**.
|
|
57
|
+
|
|
58
|
+
Ahora la extensión la declara el proyecto:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
"migrations": { "extensions": ["sql", "ts"] }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
El default sigue siendo `["sql"]`, así que nada cambia para quien no lo declare — y ampliarlo por
|
|
65
|
+
nuestra cuenta reintroduciría el falso positivo que 0.63.0 vino a cerrar: un archivo de lenguaje que
|
|
66
|
+
menciona `DROP TABLE` en un comentario. La ruta la sigue fijando el motor: `migrations/`, `migration/`
|
|
67
|
+
o `migrate/`.
|
|
68
|
+
|
|
69
|
+
Y la descripción del guard dejó de prometer de más: nombra el campo que amplía la cobertura y el
|
|
70
|
+
default de quien no lo declara.
|
|
71
|
+
|
|
72
|
+
**Lo que te pide algo**: si tus migraciones no son `.sql`, declaralas. Hasta que lo hagas, ese guard
|
|
73
|
+
no las mira — y ahora `automation list-hooks` te lo dice.
|
|
74
|
+
|
|
75
|
+
- **La tabla que dice qué ruta aprobar cuando un guard te frena estaba ofreciendo la salida ancha para el
|
|
76
|
+
freno más común.** `plan-first` —el que exige un plan escrito antes de cambiar el producto— no figuraba
|
|
77
|
+
entre las aprobaciones por ruta, y sí en la tabla de variables que apagan un guard **para toda la
|
|
78
|
+
sesión**. Quien se topaba con ese bloqueo encontraba documentado `OPS_PLAN_FIRST_OVERRIDE=1` y no la
|
|
79
|
+
aprobación acotada, que es la vía recomendada.
|
|
80
|
+
|
|
81
|
+
Ahora está la fila, con la pregunta que va antes —**¿esto es trabajo de una tarea?**— y con lo que
|
|
82
|
+
cuesta: si lo es, la salida no es aprobar la ruta sino escribir el WIP con su plan; si no lo es —un
|
|
83
|
+
typo, un umbral que corregís de paso—, aprobar es la respuesta correcta, y ese cambio entra **sin
|
|
84
|
+
entrada de DONE**, así que `planning/` no lo registra.
|
|
85
|
+
|
|
86
|
+
**Lo que te pide algo**: si venías apagando ese guard con la variable, la aprobación por ruta hace lo
|
|
87
|
+
mismo para el archivo que vas a tocar y se apaga sola en cuanto el conjunto cambia.
|
|
88
|
+
|
|
89
|
+
### Cambiado
|
|
90
|
+
|
|
91
|
+
- **R17 gana un tercer disparador de división: un plan que ninguna crítica aprueba.** Los dos que ya
|
|
92
|
+
tenía —cinco condiciones de aceptación, cuatro horas de esfuerzo— miran la unidad **escrita**. Éste mira
|
|
93
|
+
lo que pasó al intentarla, y por eso es la evidencia más directa de las tres y la única que no se puede
|
|
94
|
+
tener de antemano.
|
|
95
|
+
|
|
96
|
+
Lo que lo hace fácil de perder es que llega **después** de que las otras dos dieron el visto bueno, y
|
|
97
|
+
las dos acertaron: la aceptación era concreta y las condiciones no cruzaban el umbral. Una unidad puede
|
|
98
|
+
estar bien escrita y no ser planificable, y eso sólo se sabe habiéndolo intentado.
|
|
99
|
+
|
|
100
|
+
**Lo que te pide algo**: es una regla del sistema, así que baja a tu `planning/` en el próximo
|
|
101
|
+
`upgrade`. Como las otras dos, dispara una revisión y no una partición automática.
|
|
102
|
+
|
|
103
|
+
### Agregado
|
|
104
|
+
|
|
105
|
+
- **R23: un borrado se lee resuelto antes de correrlo, y sólo alcanza lo desechable.** Antes de destruir
|
|
106
|
+
—`rm -rf`, un borrado recursivo, un `DROP`, un `reset --hard`— se resuelve el objetivo y **se lee la
|
|
107
|
+
ruta final**, no la variable que la contiene. El destino tiene que colgar de algo desechable, y eso se
|
|
108
|
+
comprueba: la raíz de un repositorio, un directorio de trabajo, el home de nadie y `/` no lo son.
|
|
109
|
+
|
|
110
|
+
Con dos cosas que la regla dice y que son menos obvias. Las pruebas **no montan nada bajo el home**: un
|
|
111
|
+
banco ahí pone la carpeta personal de quien las corre dentro del alcance de todo lo que la suite borra.
|
|
112
|
+
Y **decidir se separa de destruir**: la función que juzga si algo se puede borrar no borra, así que
|
|
113
|
+
probarla con `/` o con la raíz de un repositorio no puede destruir nada. Mezcladas, la prueba que
|
|
114
|
+
ejerce la defensa tiene que apuntarle a rutas reales, y ahí apagar la defensa **es** el desastre.
|
|
115
|
+
|
|
116
|
+
**Lo que te pide algo**: es una regla del sistema, así que baja a tu `planning/` en el próximo
|
|
117
|
+
`upgrade`. Si tenés pruebas o scripts que borran, la pregunta que contesta es «¿de dónde salió esta
|
|
118
|
+
ruta?», no «¿está bien escrita esta línea?».
|
|
119
|
+
|
|
120
|
+
## [0.76.0] - 2026-09-10
|
|
121
|
+
|
|
122
|
+
### Corregido
|
|
123
|
+
|
|
124
|
+
- **Un comando que no encuentra tu planning lo dice, en vez de contestar un hecho sobre un directorio que
|
|
125
|
+
no existe.** `context` y `tree` ya lo hacían desde 0.71.0; los otros nueve no. `claim` contestaba «no
|
|
126
|
+
está en BACKLOG», `release` «no está tomada por nadie», `evidence` «DONE no tiene ninguna entrada»,
|
|
127
|
+
`runners` «ningún runner tiene trabajo abierto» — todos hechos concretos sobre lo que no pudieron leer.
|
|
128
|
+
**Cuatro de ellos salían con exit 0**, así que un script veía éxito.
|
|
129
|
+
|
|
130
|
+
El daño no es el mensaje sino lo que induce: en una corrida real, `claim` dijo «no está en BACKLOG»
|
|
131
|
+
sobre una tarea que **sí** estaba, y el recorrido mandó a una persona a promover lo único que ya estaba
|
|
132
|
+
bien, citando la regla correcta con la conclusión al revés. Ahora los once comandos que reciben un
|
|
133
|
+
planning fallan igual, con la ruta **resuelta** puesta — que es lo que hace falta cuando el error es de
|
|
134
|
+
resolución: en sidecar, `<empresa>-ops/planning` escrito desde adentro de la raíz apunta a
|
|
135
|
+
`<empresa>-ops/<empresa>-ops/planning`.
|
|
136
|
+
|
|
137
|
+
**Lo que te pide algo**: si tenías un script que trataba ese vacío como «nada que hacer», ahora falla.
|
|
138
|
+
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
|
|
139
|
+
decir eso en vez de «falta BACKLOG.md»: sale con 2 y nombra la ruta.
|
|
140
|
+
|
|
141
|
+
### Agregado
|
|
142
|
+
|
|
143
|
+
- **La entrada declara también `review:`, y `check` cruza los dos.** El carril dice cuánta ceremonia
|
|
144
|
+
**merecía** la tarea; `review:` dice cuánta **recibió** —el veredicto y quién revisó, o `n/a — razón`
|
|
145
|
+
cuando no corrió—. Es la dimensión que la propia ADR OPS-006 nombraba como la que falta: «se sabría
|
|
146
|
+
comparando hallazgos de review por carril, y hoy no se registra esa dimensión en DONE».
|
|
147
|
+
|
|
148
|
+
Con los dos campos, `check` avisa lo que hasta ahora no tenía cómo ver: una entrada cuyo carril convoca
|
|
149
|
+
revisor —`directo`, `lite`, `full`— y cuya revisión no corrió. `express` queda afuera porque es el único
|
|
150
|
+
que legítimamente no convoca a nadie. Avisa y no falla: es un hecho del pasado que no se arregla
|
|
151
|
+
editando la entrada, y el único camino al verde sería reescribir el registro.
|
|
152
|
+
|
|
153
|
+
- **La entrada de una tarea cerrada declara `lane:`, el carril con el que corrió.** El carril decide qué
|
|
154
|
+
fases recibe una tarea —`express` se saltea Ready, Plan y QA; `full` las corre todas— y viajaba sólo en
|
|
155
|
+
la línea del BACKLOG, que **se borra al cerrar**. Con eso, «¿esta tarea recibió la ceremonia que su
|
|
156
|
+
superficie pedía?» dejaba de tener dónde contestarse: en una instancia real, **0 de 79 entradas de DONE
|
|
157
|
+
registraban el carril**. Ahora queda en el registro, y `sin clasificar` es un valor y no un hueco — dice
|
|
158
|
+
que la línea no lo declaraba, que es distinto de que nadie llenara el campo.
|
|
159
|
+
|
|
160
|
+
El plan en vuelo también lo lleva: una corrida que se reanuda arma la tarea desde `wip/<runner>.md`, y
|
|
161
|
+
sin el campo ahí llegaba al cierre con el carril ya perdido aunque la tarea sí lo tuviera.
|
|
162
|
+
|
|
163
|
+
**Lo que te pide algo**: `ops check` **avisa** cuántas entradas no lo traen y **no falla** — las
|
|
164
|
+
escritas antes de esta versión no lo tienen y no hay de dónde sacárselo. Lo que sí falla es un valor
|
|
165
|
+
inventado. Si cerrás a mano, agregá `lane:` a la entrada; si cerrás con `autobuild`, ya lo escribe.
|
|
166
|
+
|
|
17
167
|
## [0.75.0] - 2026-09-10
|
|
18
168
|
|
|
19
169
|
### Cambiado
|
|
@@ -505,6 +505,29 @@ while (rounds++ < MAX_TASKS) {
|
|
|
505
505
|
`\`<path>/SKILL.md\`.\n\n`
|
|
506
506
|
: '')
|
|
507
507
|
|
|
508
|
+
// Un plan que no sobrevive a la crítica deja de reintentarse a ciegas. Se registra la tarea como acción
|
|
509
|
+
// humana, y eso hace dos cosas con un solo acto: `context` deja de ofrecerla —una acción pendiente saca
|
|
510
|
+
// esa tarea de la cola y ofrece la siguiente, sin frenar la corrida— y alguien ve la fila.
|
|
511
|
+
//
|
|
512
|
+
// Antes, relanzar repetía **la corrida entera**: Ready y Decompose volvían a pasarla, porque su criterio
|
|
513
|
+
// no cambió y la tarea tampoco, y Critique volvía a rechazarla. Medido en dos corridas consecutivas
|
|
514
|
+
// sobre la misma tarea: idénticas, 9 agentes y ~780 k tokens cada una, sin escribir una línea (caso 081).
|
|
515
|
+
//
|
|
516
|
+
// Es el razonamiento de `claim-stuck`: si repetir no puede cambiar el resultado, no se repite. Y la
|
|
517
|
+
// evidencia ya está completa dentro de una corrida —el rechazo llega después de una crítica, una
|
|
518
|
+
// corrección y una segunda crítica—, así que no hace falta contar entre corridas ni inventar dónde
|
|
519
|
+
// guardar ese contador: cuando esto ocurre, el WIP todavía no existe.
|
|
520
|
+
//
|
|
521
|
+
// Lo que la fila le pide a una persona lo dice R17: dos rechazos sobre lo mismo son el disparador
|
|
522
|
+
// posterior de división. No se parte acá porque partir es una decisión, y ésa no le toca al recorrido.
|
|
523
|
+
const planRejected = (reason, unit, found) => {
|
|
524
|
+
const detail = found.join('; ') || 'sin condiciones nombradas'
|
|
525
|
+
write(`Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
|
|
526
|
+
+ `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
|
|
527
|
+
+ `distintas y partirla —R17—, o dejarla entera con la razón escrita.`, { label: 'plan-human' })
|
|
528
|
+
return stop(reason, detail)
|
|
529
|
+
}
|
|
530
|
+
|
|
508
531
|
if (!planning.wipActive) {
|
|
509
532
|
if (!mechanical || !vouched) {
|
|
510
533
|
phase('Ready')
|
|
@@ -575,7 +598,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
575
598
|
// Un plan bloqueado no se corrige: lo que lo bloquea está fuera de lo que una segunda pasada puede
|
|
576
599
|
// tocar, así que insistir gasta dos llamadas para llegar al mismo lugar.
|
|
577
600
|
if (critique.verdict === 'bloqueado') {
|
|
578
|
-
return
|
|
601
|
+
return planRejected('plan-blocked', task, blockers(critique))
|
|
579
602
|
}
|
|
580
603
|
if (blockers(critique).length) {
|
|
581
604
|
plan = await read(
|
|
@@ -589,7 +612,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
589
612
|
)
|
|
590
613
|
if (!plan || !critique) return stop('agent-unavailable', 'la revisión del plan no devolvió resultado')
|
|
591
614
|
if (critique.verdict === 'bloqueado' || blockers(critique).length) {
|
|
592
|
-
return
|
|
615
|
+
return planRejected('plan-rejected', task, blockers(critique))
|
|
593
616
|
}
|
|
594
617
|
}
|
|
595
618
|
// Acá el plan ya está aprobado por los dos caminos posibles, así que el contraste va una sola vez.
|
|
@@ -607,7 +630,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
607
630
|
`Escribí el WIP y nada más: no toques código, no corras pruebas, no cierres la tarea y no escribas ` +
|
|
608
631
|
`en DONE. Los pasos van sin tildar porque todavía no ocurrieron. task=${task.id}, ` +
|
|
609
632
|
`hito=${JSON.stringify(task.hito)}, phase=Build, service=${task.service}, ` +
|
|
610
|
-
`acceptance=${JSON.stringify(task.acceptance)},
|
|
633
|
+
`acceptance=${JSON.stringify(task.acceptance)}, lane=${planning.lane || 'sin clasificar'}, ` +
|
|
634
|
+
`pasos sin tildar=${JSON.stringify(plan.steps)}. ` +
|
|
611
635
|
`Registrá el reparto de cargos ${JSON.stringify(cast)} en las decisiones del WIP, para que después se ` +
|
|
612
636
|
`pueda auditar quién revisó qué. Seguí el contrato de WIP exactamente y reportá con qué status quedó.`,
|
|
613
637
|
{ label: 'wip', schema: {
|
|
@@ -790,7 +814,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
790
814
|
phase('Done')
|
|
791
815
|
await write(
|
|
792
816
|
`Cerrá ${task.id} de forma atómica: escribí ${doneFile(task.id)} con su evidencia —acept, ` +
|
|
793
|
-
`fecha: ${planning.today}, done, qa, tests y
|
|
817
|
+
`fecha: ${planning.today}, done, qa, tests, commit, lane y review, en el formato de entrada que trae ` +
|
|
818
|
+
`este preámbulo—; ` +
|
|
794
819
|
`sacala junto con sus notas indentadas de ${BACKLOG}; cerrá su épica sólo si no queda ` +
|
|
795
820
|
`ninguna tarea etiquetada; dejá ${P}/${planning.wipFile} en status IDLE; y soltá la reserva corriendo ` +
|
|
796
821
|
`"node tools/ops.js release ${P} ${task.id}". En decisions no nombres una fase ni un cargo ` +
|
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
|
@@ -105,6 +105,35 @@ function benchSurvived(dir, since) {
|
|
|
105
105
|
+ `${sample.join(', ') || '(sólo directorios)'}. ${again}. Borralo a mano y volvé a correr.`
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Borrar el banco y comprobar que se borró, que es una sola decisión: lo que no desapareció contamina la
|
|
109
|
+
// medición que viene. Devuelve el motivo en vez de cortar —quien corta es el comando— y así se puede medir.
|
|
110
|
+
//
|
|
111
|
+
// **El destino se comprueba antes de destruir** (R23). `dir` lo arma este archivo a partir de nombres ya
|
|
112
|
+
// validados, así que hoy no puede apuntar afuera; la comprobación existe porque el costo de que algún día
|
|
113
|
+
// pueda no es un resultado incorrecto sino trabajo perdido, y porque una ruta peligrosa se construye sola
|
|
114
|
+
// a partir de algo vacío. Se niega nombrando la ruta y contra qué la comparó.
|
|
115
|
+
//
|
|
116
|
+
// `remove` se inyecta porque **la condición que la comprobación de abajo existe para atrapar no se puede
|
|
117
|
+
// provocar con el sistema de archivos real**: es el caso 078, y sin ese hueco la línea que decide se
|
|
118
|
+
// quedaba sin una sola prueba —comprobado: borrarla no ponía nada en rojo—. Con un borrado que no borra,
|
|
119
|
+
// la rama se ejerce en milisegundos y sobre un temporal que la prueba acaba de crear.
|
|
120
|
+
function clearBench(dir, scratch, remove = fs.rmSync) {
|
|
121
|
+
const target = path.resolve(dir)
|
|
122
|
+
const banco = path.resolve(scratch)
|
|
123
|
+
if (!target.startsWith(banco + path.sep)) {
|
|
124
|
+
return `no se borra ${target}: no cuelga de ${banco}, así que no es un banco de evaluación.`
|
|
125
|
+
}
|
|
126
|
+
// El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que el
|
|
127
|
+
// borrado no tocó de uno que alguien reescribió mientras borrábamos.
|
|
128
|
+
const since = Date.now()
|
|
129
|
+
// Con reintentos. Los puso el `ENOTEMPTY` que aparecía al rehacer un banco recién creado, y hoy se sabe
|
|
130
|
+
// que eso era el mantenimiento de git escribiendo por detrás (caso 073). Se quedan porque son lo único
|
|
131
|
+
// que corre **antes** de la comprobación: cubren a cualquier otro escritor transitorio, no a éste, que
|
|
132
|
+
// está apagado.
|
|
133
|
+
remove(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
134
|
+
return fs.existsSync(target) ? benchSurvived(target, since) : null
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
// Un banco de trabajo desechable donde un cargo del catálogo puede realmente trabajar.
|
|
109
138
|
//
|
|
110
139
|
// Hace falta porque el toolkit no es una raíz ops: el único `planning/` que vive acá es
|
|
@@ -135,48 +164,27 @@ function evaluationBench(root, agent, caso, force, kind) {
|
|
|
135
164
|
fail(`${dir} tiene trabajo sin recoger. Guardá el registro de esa corrida antes de rehacerlo, `
|
|
136
165
|
+ 'o usá --force si ya lo tenés.', 2)
|
|
137
166
|
}
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
//
|
|
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.
|
|
145
|
-
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
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—.
|
|
167
|
+
// Rodear un borrado a medias deja la corrida siguiendo sobre un banco que no es nuevo, y lo que falla
|
|
168
|
+
// después no dice nada del borrado: el test que lo destapó reportaba `true !== false` sobre un archivo
|
|
169
|
+
// de la corrida anterior, sin nombrar de dónde salía. Esta guarda es la que estableció la causa —su
|
|
170
|
+
// primer disparo instrumentado nombró al escritor—; lo que cubre ahora es que aparezca otro.
|
|
154
171
|
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
// No ablanda ninguna protección: la pregunta «¿acá alguien trabajó?» la contesta el `git status` de
|
|
164
|
-
// arriba, que exige `--force` explícito para seguir. Esta segunda puerta no la eligió nadie y sólo
|
|
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.
|
|
167
|
-
IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true, force: true })
|
|
172
|
+
// **Y de acá para abajo el directorio no existe.** Eso es lo que sostiene que el andamiaje y el enlace
|
|
173
|
+
// se escriban sin defensas: hasta el 073, los dos llevaban una por si algo sobrevivía al borrado.
|
|
174
|
+
const problema = clearBench(dir, path.join(root, '.cauce-eval'))
|
|
175
|
+
if (problema) fail(problema, 2)
|
|
176
|
+
// Sin `force`, y eso es lo que hay que poder decir: sólo servía si algún archivo sobrevivía al borrado,
|
|
177
|
+
// y la comprobación de arriba garantiza que no queda ninguno. Lo llevaba porque el mismo test falló tres
|
|
178
|
+
// veces en un día con «El destino contiene …/AGENTS.md», y eso era el escritor de fondo que apagó el 073.
|
|
179
|
+
IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true })
|
|
168
180
|
// El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
|
|
169
181
|
// sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
|
|
170
182
|
const scope = path.join(dir, 'node_modules', '@ingeniomaps')
|
|
171
183
|
fs.mkdirSync(scope, { recursive: true })
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// `rmSync` sobre un enlace lo quita a él y no a lo que apunta —que acá es la raíz del toolkit—, así que
|
|
177
|
-
// esto no puede llevarse por delante el repositorio.
|
|
184
|
+
// Y el enlace se crea sin borrarlo antes, por lo mismo: `scope` acaba de nacer dentro de un directorio
|
|
185
|
+
// que no existía, así que no puede haber un enlace que pisar. El `rm` que había acá era el tercer rodeo
|
|
186
|
+
// del mismo escritor de fondo, y el que falló en CI con `EEXIST`.
|
|
178
187
|
const link = path.join(scope, 'cauce')
|
|
179
|
-
fs.rmSync(link, { force: true })
|
|
180
188
|
fs.symlinkSync(IN.PROJECT_ROOT, link, 'dir')
|
|
181
189
|
|
|
182
190
|
// El artefacto del caso, si lo tiene: la guía del proveedor que el pedido manda implementar, el CSV
|
|
@@ -398,4 +406,4 @@ function flow(action, slug, cli) {
|
|
|
398
406
|
} catch (error) { fail(error.message, 2) }
|
|
399
407
|
}
|
|
400
408
|
|
|
401
|
-
module.exports = { agents, learn, evaluate, flow, benchSurvived }
|
|
409
|
+
module.exports = { agents, learn, evaluate, flow, benchSurvived, clearBench }
|
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,8 @@ 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)))
|
|
152
|
+
warnings.push(...R.coverageWarnings(path.resolve(root, '..'), done))
|
|
151
153
|
// Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
|
|
152
154
|
// no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
|
|
153
155
|
// en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
|
|
@@ -265,10 +267,9 @@ function treeJson({ epics, milestones, done, wips, inbox, queued, claims }) {
|
|
|
265
267
|
}
|
|
266
268
|
|
|
267
269
|
function tree(dir, cli) {
|
|
268
|
-
const root =
|
|
270
|
+
const root = planningRoot(dir)
|
|
269
271
|
// Mismo motivo que en `context`, y por eso comparten la comprobación: sin ella un planning ausente
|
|
270
272
|
// dibujaba un árbol vacío, que se lee como un roadmap sin épicas en vez de como una ruta equivocada.
|
|
271
|
-
assertPlanning(root)
|
|
272
273
|
const state = ST.snapshot(root)
|
|
273
274
|
if (cli.has('--json')) return treeJson(state)
|
|
274
275
|
const { epics, milestones, done, wips, inbox, queued, claims } = state
|
|
@@ -307,29 +308,9 @@ function tree(dir, cli) {
|
|
|
307
308
|
console.log(`${paint('1', 'DONE')} ${done.entries.length} tareas\n`)
|
|
308
309
|
}
|
|
309
310
|
|
|
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
311
|
// Contexto mínimo suficiente para ejecutar una tarea, en lugar de releer roadmap, BACKLOG y WIP enteros.
|
|
330
312
|
function context(dir, cli) {
|
|
331
|
-
const root =
|
|
332
|
-
assertPlanning(root)
|
|
313
|
+
const root = planningRoot(dir)
|
|
333
314
|
const state = ST.snapshot(root)
|
|
334
315
|
// Acotar la cola a un hito es como un equipo se reparte trabajo sin coordinarse: dos personas en hitos
|
|
335
316
|
// distintos casi nunca dependen entre sí ni tocan los mismos archivos. Lo que se acota es qué se
|
|
@@ -473,7 +454,7 @@ function context(dir, cli) {
|
|
|
473
454
|
// `BACKLOG.md` es la cola de lo aprobado y la escribe una persona — ningún comando del motor la toca,
|
|
474
455
|
// ni siquiera `integration promote`, que aterriza en el roadmap. Pegarla es el acto de promoción.
|
|
475
456
|
function recurring(dir, cli) {
|
|
476
|
-
const root =
|
|
457
|
+
const root = planningRoot(dir)
|
|
477
458
|
const file = RC.read(root)
|
|
478
459
|
if (!file.exists) return console.log(`= este planning no declara trabajo recurrente (${RC.FILE})`)
|
|
479
460
|
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)
|
|
@@ -20,6 +20,7 @@ function validateOpsConfig(config) {
|
|
|
20
20
|
// `cauceVersion` la escribe el toolkit, no la persona: registra de qué versión salió la instancia.
|
|
21
21
|
const allowed = new Set([
|
|
22
22
|
'$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'writableOutsideRoots', 'runner',
|
|
23
|
+
'migrations',
|
|
23
24
|
])
|
|
24
25
|
for (const key of Object.keys(config)) {
|
|
25
26
|
if (RETIRED[key]) errors.push(`ops.config.json: ${key} ya no se usa: ${RETIRED[key]}`)
|
|
@@ -32,9 +33,41 @@ function validateOpsConfig(config) {
|
|
|
32
33
|
validateWorkspaces(config.workspaceRoots, errors)
|
|
33
34
|
validateWritable(config.writableOutsideRoots, errors)
|
|
34
35
|
validateRunner(config.runner, errors)
|
|
36
|
+
validateMigrations(config.migrations, errors)
|
|
35
37
|
return errors
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
// Qué cuenta como migración para el guard. Sin declararlo, sólo `.sql` — y ése es el default que hace
|
|
41
|
+
// falta decir, porque un proyecto TypeORM, Prisma, Django o Rails tiene el guard cableado y en verde sin
|
|
42
|
+
// que mire una sola migración (caso 077).
|
|
43
|
+
//
|
|
44
|
+
// La extensión se valida contra `[a-z0-9]+` por dos razones que se juntan: entra en una expresión
|
|
45
|
+
// regular, así que un valor con metacaracteres la rompería o la ampliaría sin que nadie lo pidiera; y
|
|
46
|
+
// declarar `.SQL` o `sql;` es un error de tipeo que conviene que se vea acá y no como cobertura que no
|
|
47
|
+
// existe.
|
|
48
|
+
function validateMigrations(migrations, errors) {
|
|
49
|
+
if (migrations === undefined) return
|
|
50
|
+
if (!migrations || typeof migrations !== 'object' || Array.isArray(migrations)) {
|
|
51
|
+
errors.push('ops.config.json: migrations debe ser un objeto')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
for (const key of Object.keys(migrations)) {
|
|
55
|
+
if (key !== 'extensions') errors.push(`ops.config.json: migrations.${key} no está permitido`)
|
|
56
|
+
}
|
|
57
|
+
if (!('extensions' in migrations)) return
|
|
58
|
+
const declaradas = migrations.extensions
|
|
59
|
+
if (!Array.isArray(declaradas) || !declaradas.length) {
|
|
60
|
+
errors.push('ops.config.json: migrations.extensions debe listar al menos una extensión, o no estar')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
for (const one of declaradas) {
|
|
64
|
+
if (typeof one !== 'string' || !/^[a-z0-9]+$/.test(one)) {
|
|
65
|
+
errors.push(`ops.config.json: migrations.extensions "${one}" debe ser la extensión sin el punto `
|
|
66
|
+
+ 'y en minúscula, como "sql" o "ts"')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
38
71
|
function validateWorkspaces(workspaces, errors) {
|
|
39
72
|
if (!Array.isArray(workspaces) || !workspaces.length) {
|
|
40
73
|
errors.push('ops.config.json: workspaceRoots debe contener al menos una raíz')
|
package/engine/core/repos.js
CHANGED
|
@@ -64,4 +64,59 @@ function lastCommit(repo, branch) {
|
|
|
64
64
|
return shown.status === 0 ? shown.stdout.trim() : ''
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// Cuánto del trabajo que entró al repositorio quedó registrado. Devuelve, por raíz, los commits que
|
|
68
|
+
// ninguna entrada de DONE nombra desde la fecha que se le pase.
|
|
69
|
+
//
|
|
70
|
+
// Existe porque el número no se podía tener: sacarlo pedía cruzar a mano los `commit:` de `planning/done`
|
|
71
|
+
// contra la historia de cada repositorio. Hecho así sobre una instancia real dio **312 commits y 75
|
|
72
|
+
// registrados**, y el desglose de los que faltaban no era trabajo suelto: 69 `feat` y 51 `fix` de 173
|
|
73
|
+
// (caso 082).
|
|
74
|
+
//
|
|
75
|
+
// Se cuenta desde una fecha y no desde el principio a propósito: contar toda la historia da una deuda que
|
|
76
|
+
// nunca baja y que se termina leyendo como decorado. Desde la última tarea cerrada, en cambio, el número
|
|
77
|
+
// vuelve a cero cada vez que el flujo se cierra, y lo que queda visible es la deriva de ahora.
|
|
78
|
+
//
|
|
79
|
+
// Los merges quedan afuera: no son trabajo, son la forma de integrarlo.
|
|
80
|
+
function unrecordedCommits(repo, since, recorded) {
|
|
81
|
+
if (!repo || !since) return []
|
|
82
|
+
// La fecha se compara acá y no con `--since`, y eso lo encontró una prueba: `--since` **poda la
|
|
83
|
+
// caminata**, así que un commit con fecha vieja en la punta esconde todo lo que tiene detrás. Con un
|
|
84
|
+
// historial reescrito o un `commit --date` la cuenta daba cero sobre un repositorio lleno.
|
|
85
|
+
const log = git(repo, 'log', '--no-merges', '--date=short', '--format=%h %ad %s')
|
|
86
|
+
if (log.status !== 0) return []
|
|
87
|
+
const conocidos = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
|
|
88
|
+
return log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
|
|
89
|
+
.filter((line) => line.slice(8, 18) >= since)
|
|
90
|
+
.filter((line) => !conocidos.has(line.slice(0, 7)))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Cuánto del trabajo que entró a los repositorios quedó registrado, desde la última tarea cerrada. Avisa
|
|
94
|
+
// y no falla, por lo mismo que el resto de esta familia: es un hecho del pasado que no se arregla
|
|
95
|
+
// editando nada, y el único camino al verde sería escribir entradas de memoria.
|
|
96
|
+
//
|
|
97
|
+
// La ventana arranca en la entrada más reciente y no en la primera: contar toda la historia da una deuda
|
|
98
|
+
// que nunca baja y que se lee como decorado. Así el número vuelve a cero cada vez que se cierra una tarea,
|
|
99
|
+
// y lo que queda a la vista es la deriva de ahora. Comprobado sobre una instancia real: **0 desde la
|
|
100
|
+
// última tarea cerrada, 54 desde dos semanas antes** — el día que tuvo 64 commits y ninguna entrada.
|
|
101
|
+
//
|
|
102
|
+
// Y no dice cuántos *deberían* tener entrada, porque eso no se sabe desde acá: lo dice el desglose, y en
|
|
103
|
+
// la instancia medida 120 de 173 eran `feat` o `fix` (caso 082).
|
|
104
|
+
function coverageWarnings(opsRoot, done) {
|
|
105
|
+
const fechas = done.entries.map((entry) => entry.fecha).filter(Boolean).sort()
|
|
106
|
+
const desde = fechas[fechas.length - 1]
|
|
107
|
+
if (!desde) return []
|
|
108
|
+
const recorded = new Set()
|
|
109
|
+
for (const entry of done.entries) {
|
|
110
|
+
for (const sha of String(entry.commit || '').matchAll(/\b[0-9a-f]{7,40}\b/g)) recorded.add(sha[0])
|
|
111
|
+
}
|
|
112
|
+
const warnings = []
|
|
113
|
+
for (const repo of reposFor(opsRoot, '.')) {
|
|
114
|
+
const sueltos = unrecordedCommits(repo, desde, recorded)
|
|
115
|
+
if (!sueltos.length) continue
|
|
116
|
+
warnings.push(`${path.basename(repo)}: ${sueltos.length} commit(s) desde ${desde} que ninguna `
|
|
117
|
+
+ 'entrada de DONE nombra, así que ese trabajo no está en planning/ (OPS-001)')
|
|
118
|
+
}
|
|
119
|
+
return warnings
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { reposFor, repoOf, lastCommit, coverageWarnings }
|
package/engine/hooks/files.js
CHANGED
|
@@ -171,6 +171,35 @@ function workspaceBoundary(input) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Qué archivos son migraciones para este proyecto. La ruta la fija el motor —`migrations/`, `migration/`
|
|
175
|
+
// o `migrate/`, que es donde las ponen todas las herramientas— y **la extensión la declara el proyecto**,
|
|
176
|
+
// con `sql` de default.
|
|
177
|
+
//
|
|
178
|
+
// Sin esto el guard sólo veía `.sql`, así que en TypeORM, Prisma, Django, Rails o Alembic no miraba nada:
|
|
179
|
+
// ni frenaba el SQL destructivo, ni protegía una migración existente de ser reescrita. Y no lo decía —
|
|
180
|
+
// aparecía cableado y en verde—. Medido en una instancia real: 64 migraciones `.sql` cubiertas y **409
|
|
181
|
+
// TypeORM `.ts` invisibles** (caso 077).
|
|
182
|
+
//
|
|
183
|
+
// No se amplía el default a `.ts`/`.py`/`.rb` por su cuenta: eso reintroduciría el falso positivo del
|
|
184
|
+
// caso 039 —un archivo de lenguaje que menciona `DROP TABLE` en un comentario o en un string— por otra
|
|
185
|
+
// puerta. Declararlo es opt-in porque el que sabe si sus migraciones son de lenguaje es el proyecto, y
|
|
186
|
+
// porque así el costo lo elige quien lo paga.
|
|
187
|
+
//
|
|
188
|
+
// La extensión inválida no se descarta en silencio: descartarla dejaría al proyecto creyendo que declaró
|
|
189
|
+
// una cobertura que no tiene, que es exactamente el defecto que este helper vino a cerrar. La valida
|
|
190
|
+
// `validateOpsConfig`, y acá se ignora lo que no pasa ese filtro porque el guard no es el lugar donde se
|
|
191
|
+
// enseña a escribir la configuración.
|
|
192
|
+
const DEFAULT_MIGRATION_EXTENSIONS = ['sql']
|
|
193
|
+
|
|
194
|
+
function migrationPattern(input) {
|
|
195
|
+
const root = opsRoot(input)
|
|
196
|
+
const declared = root ? (configOf(root).migrations || {}).extensions : null
|
|
197
|
+
const extensions = (Array.isArray(declared) ? declared : DEFAULT_MIGRATION_EXTENSIONS)
|
|
198
|
+
.filter((one) => typeof one === 'string' && /^[a-z0-9]+$/.test(one))
|
|
199
|
+
const usable = extensions.length ? extensions : DEFAULT_MIGRATION_EXTENSIONS
|
|
200
|
+
return new RegExp(`(?:^|/)(?:migrations?|migrate)/.*\\.(?:${usable.join('|')})$`, 'i')
|
|
201
|
+
}
|
|
202
|
+
|
|
174
203
|
function migrations(input) {
|
|
175
204
|
if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
|
|
176
205
|
// Cada rama cierra su propio límite. Cuando el `\b` estaba al final del grupo se aplicaba a las tres, y
|
|
@@ -195,9 +224,10 @@ function migrations(input) {
|
|
|
195
224
|
// El precio de compartir el filtro es que una migración escrita fuera de un directorio con ese nombre
|
|
196
225
|
// deja de frenarse. Es deliberado: el otro chequeo ya vivía con esa convención, y dos condiciones de
|
|
197
226
|
// la misma función con dos alcances distintos es lo que hizo falta arreglar acá.
|
|
227
|
+
const esMigracion = migrationPattern(input)
|
|
198
228
|
for (const raw of filesOf(input)) {
|
|
199
229
|
const normalized = raw.replace(/\\/g, '/')
|
|
200
|
-
if (
|
|
230
|
+
if (!esMigracion.test(normalized)) continue
|
|
201
231
|
if (approved(input, normalized)) continue
|
|
202
232
|
if (destructiveSql.test(contentOf(input))) {
|
|
203
233
|
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE')}`)
|
package/engine/hooks/run.js
CHANGED
|
@@ -108,7 +108,8 @@ const hookMetadata = [
|
|
|
108
108
|
{
|
|
109
109
|
name: 'migrations',
|
|
110
110
|
event: 'PreToolUse · files',
|
|
111
|
-
purpose: 'Protege migraciones existentes y bloquea SQL destructivo
|
|
111
|
+
purpose: 'Protege migraciones existentes y bloquea SQL destructivo, sobre las extensiones que el '
|
|
112
|
+
+ 'proyecto declare en migrations.extensions — sólo .sql si no declara ninguna.',
|
|
112
113
|
},
|
|
113
114
|
{
|
|
114
115
|
name: 'integration-snapshot',
|
|
@@ -87,6 +87,12 @@ function validateDoneEntry(entry, cited = []) {
|
|
|
87
87
|
//
|
|
88
88
|
// Los criterios que la historia declaró cubrir los cita el roadmap y no la entrada, así que el cruce
|
|
89
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
|
+
|
|
90
96
|
function doneEntryErrors(entry, epics = []) {
|
|
91
97
|
const at = `${entry.source} ${entry.slug}`
|
|
92
98
|
const errors = []
|
|
@@ -97,11 +103,58 @@ function doneEntryErrors(entry, epics = []) {
|
|
|
97
103
|
if (!entry.done) errors.push(`${at}: falta done:`)
|
|
98
104
|
if (!entry.qa) errors.push(`${at}: falta qa:`)
|
|
99
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
|
+
}
|
|
100
113
|
const story = epics.find((epic) => epic.num === entry.epic)?.stories
|
|
101
114
|
.find((candidate) => candidate.slug === entry.slug)
|
|
102
115
|
return [...errors, ...validateDoneEntry(entry, story ? story.criteria : [])]
|
|
103
116
|
}
|
|
104
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
|
+
|
|
105
158
|
function duplicates(values) {
|
|
106
159
|
return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
|
|
107
160
|
}
|
|
@@ -308,6 +361,7 @@ function validateState({
|
|
|
308
361
|
module.exports = {
|
|
309
362
|
validateState,
|
|
310
363
|
doneEntryErrors,
|
|
364
|
+
doneCeremonyWarnings,
|
|
311
365
|
validCommitTrace,
|
|
312
366
|
validDecisionTrace,
|
|
313
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: [] }
|
|
@@ -86,6 +86,22 @@
|
|
|
86
86
|
}
|
|
87
87
|
},
|
|
88
88
|
"additionalProperties": false
|
|
89
|
+
},
|
|
90
|
+
"migrations": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"description": "Qué cuenta como migración para el guard `migrations`. Sin esto sólo juzga `.sql`, así que en un proyecto TypeORM, Prisma, Django, Rails o Alembic el guard no mira nada.",
|
|
93
|
+
"additionalProperties": false,
|
|
94
|
+
"properties": {
|
|
95
|
+
"extensions": {
|
|
96
|
+
"type": "array",
|
|
97
|
+
"description": "Extensiones, sin el punto y en minúscula. Por defecto [\"sql\"].",
|
|
98
|
+
"items": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"pattern": "^[a-z0-9]+$"
|
|
101
|
+
},
|
|
102
|
+
"minItems": 1
|
|
103
|
+
}
|
|
104
|
+
}
|
|
89
105
|
}
|
|
90
106
|
},
|
|
91
107
|
"additionalProperties": false
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -114,6 +114,7 @@ que escribís son rutas y quién las mira lo decide qué guard esté juzgando es
|
|
|
114
114
|
| borrar o apagar una prueba | la prueba |
|
|
115
115
|
| un manifiesto que va sin su lockfile, o al revés | el archivo que cambió |
|
|
116
116
|
| los gates del stack en rojo, o una fuente sin regenerar | **todo** lo que está en el índice |
|
|
117
|
+
| un cambio del producto sin plan escrito —el WIP en IDLE, o con la tarea puesta y ningún paso— | el archivo que vas a tocar |
|
|
117
118
|
|
|
118
119
|
**Vale para ese conjunto y para ningún otro.** Si después sumás un archivo, ese archivo no está aprobado
|
|
119
120
|
y el guard vuelve a frenarte nombrándolo. Eso es lo que la hace por operación sin fecha ni contador: no
|
|
@@ -126,6 +127,16 @@ mientras exista, y borrarla es parte de terminar.
|
|
|
126
127
|
**Publicar un paquete o instalar algo global no se aprueba así**, porque ahí no hay ninguna ruta sobre
|
|
127
128
|
la cual decidir. Esa sigue siendo una acción humana y su única llave es la variable de abajo.
|
|
128
129
|
|
|
130
|
+
**Y la última fila tiene una pregunta antes**: ¿esto es trabajo de una tarea? Si lo es —aunque sea
|
|
131
|
+
chico—, la salida no es aprobar la ruta sino escribir el WIP con su plan, y aprobar sería saltarse la
|
|
132
|
+
fase que iba a mirarlo. Si no lo es —un typo en un README, un umbral que corregís de paso, «esto lo
|
|
133
|
+
arreglo en dos minutos»—, aprobar la ruta **es** la respuesta correcta y no un rodeo.
|
|
134
|
+
|
|
135
|
+
Lo que conviene saber antes de tomarla: ese cambio entra **sin entrada de DONE**, así que no tiene
|
|
136
|
+
aceptación, ni evidencia, ni carril, ni revisión, y `planning/` no lo registra. Para un typo eso está
|
|
137
|
+
bien y para lo demás casi nunca; el día que empiece a pasar seguido, el que está mal es el flujo y no
|
|
138
|
+
quien aprueba la ruta.
|
|
139
|
+
|
|
129
140
|
### Las variables siguen existiendo, y son de sesión
|
|
130
141
|
|
|
131
142
|
Cada guard se puede apagar entero con su variable. Hay que decir su alcance porque no es el que uno
|
|
@@ -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
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
estado, medición y retomar lo interrumpido.
|
|
7
7
|
- `system/code-shape.md` — R5..R7, R11, R18: simplicidad y forma del cambio.
|
|
8
8
|
- `system/commits.md` — R8..R10: historia versionada y entrega.
|
|
9
|
-
- `system/conduct.md` — R12..R15, R19: trato con sistemas externos, lo que llega de ellos,
|
|
10
|
-
obligación de entregar al negarse.
|
|
9
|
+
- `system/conduct.md` — R12..R15, R19, R23: trato con sistemas externos, lo que llega de ellos, lo que
|
|
10
|
+
se destruye, y la obligación de entregar al negarse.
|
|
11
11
|
|
|
12
12
|
El número es el identificador: una regla se cita por él desde un cargo, un workflow o una entrada de
|
|
13
13
|
DONE, y por eso no se reordena ni se reusa.
|
|
@@ -52,6 +52,47 @@ cargo no tenga (R17). La pide elegir el objetivo, que es lo único que no se pue
|
|
|
52
52
|
Esto no afloja ningún límite: no promover, no prometer fechas, no inventar evidencia y no exceder la
|
|
53
53
|
autoridad del cargo siguen siendo absolutos. Lo que se cierra es la salida de cumplirlos sin entregar.
|
|
54
54
|
|
|
55
|
+
## R23 — Un borrado se lee resuelto antes de correrlo, y sólo alcanza lo desechable
|
|
56
|
+
|
|
57
|
+
Antes de ejecutar algo que destruye —un `rm -rf`, un borrado recursivo, un `DROP`, un `prune`, un
|
|
58
|
+
`reset --hard`— se **resuelve el objetivo y se lee**. No la variable que lo contiene ni el patrón que lo
|
|
59
|
+
arma: la ruta final, la que el sistema va a recibir.
|
|
60
|
+
|
|
61
|
+
**El destino cuelga de algo desechable, y eso se comprueba.** Desechable es el temporal del sistema, un
|
|
62
|
+
scratch declarado, un banco que se recrea en cada corrida. No lo son la raíz de un repositorio, un
|
|
63
|
+
directorio de trabajo, **el home de nadie**, ni `/`. Cuando el borrado lo hace código propio la
|
|
64
|
+
comprobación va en el código, y se niega **nombrando la ruta que iba a borrar y contra qué la comparó**:
|
|
65
|
+
un rechazo mudo deja sin saber de qué se salvó ni por qué la ruta salió mal.
|
|
66
|
+
|
|
67
|
+
Y las pruebas no son la excepción: **no montan nada bajo el home**. Un banco ahí pone la carpeta personal
|
|
68
|
+
de quien las corre dentro del alcance de todo lo que la suite borra, y ahí un defecto no cuesta una
|
|
69
|
+
corrida — cuesta el trabajo de alguien.
|
|
70
|
+
|
|
71
|
+
**Dónde se prueba importa tanto como qué se prueba.** Un cambio que puede hacer fallar la herramienta que
|
|
72
|
+
las pruebas invocan se ejercita en una copia, nunca en el árbol que contiene el trabajo. Y una mutación
|
|
73
|
+
que apaga una defensa se corre en una copia **siempre**: es, literalmente, ejecutar el código sin lo que
|
|
74
|
+
lo hace seguro.
|
|
75
|
+
|
|
76
|
+
De ahí sale la única forma que sostiene todo lo anterior cuando falla: **decidir y destruir se separan**.
|
|
77
|
+
La función que decide si algo se puede borrar no borra —recibe rutas y devuelve un motivo—, así que
|
|
78
|
+
probarla con `/`, con un home o con la raíz de un repositorio no puede destruir nada. Mezcladas, la
|
|
79
|
+
prueba que ejerce la defensa tiene que pasarle rutas reales y peligrosas a la función que borra, y ahí
|
|
80
|
+
apagar la defensa **es** el desastre. Con eso, la propiedad que hay que poder afirmar es ésta: *ninguna
|
|
81
|
+
prueba le pasa a la función que borra una ruta que no quiera perder*.
|
|
82
|
+
|
|
83
|
+
Lo que vuelve traicionera a esta clase es que la ruta peligrosa **se construye sola** a partir de algo
|
|
84
|
+
vacío. `path.resolve(raíz, '')` es la raíz. `rm -rf "$DIR/"*` con `DIR` sin definir es `/*`. `cd $X && rm
|
|
85
|
+
-rf .` con `X` inexistente borra donde estabas. Cada línea es correcta por separado y el desastre sale de
|
|
86
|
+
una salida que vino en blanco porque algo, más arriba, falló — que es exactamente lo que una prueba está
|
|
87
|
+
ahí para provocar. Por eso la comprobación es del destino y no de la intención: la intención siempre es
|
|
88
|
+
correcta.
|
|
89
|
+
|
|
90
|
+
El costo de equivocarse no es simétrico con nada. Un borrado mal apuntado no da un resultado incorrecto
|
|
91
|
+
que alguien pueda revisar: se lleva el trabajo, y con él la posibilidad de revisarlo. Sobrevive lo que
|
|
92
|
+
estaba empujado; lo que nunca viaja —credenciales locales, notas, lo que todavía no se commiteó— no
|
|
93
|
+
vuelve. Por eso esto no admite «pero acá es obvio que apunta bien»: si es obvio, leer la ruta resuelta
|
|
94
|
+
cuesta un segundo y confirma; si no lo es, acaba de salvarte.
|
|
95
|
+
|
|
55
96
|
## R14 — Una afirmación de mecanismo lleva su registro
|
|
56
97
|
|
|
57
98
|
El comportamiento de una herramienta, un motor, un formato, una norma o un sistema de terceros es material
|
|
@@ -48,7 +48,21 @@ Dos barras, y cada una encuentra lo que la otra deja pasar: **cinco condiciones
|
|
|
48
48
|
tarea, y **cuatro horas de esfuerzo humano**. Arriba de la tarea el conteo sigue: siete criterios en una
|
|
49
49
|
épica, nueve tareas en un hito.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Y hay una tercera que no se mide antes sino después: **un plan que ninguna crítica aprueba**. Las dos
|
|
52
|
+
primeras miran la unidad escrita; ésta mira lo que pasó al intentarla, y por eso es la evidencia más
|
|
53
|
+
directa de las tres — y la única que no se puede tener de antemano. Cuando nadie pudo escribir un plan
|
|
54
|
+
que sobreviva, lo que sigue no es escribir un tercero: es mirar la unidad.
|
|
55
|
+
|
|
56
|
+
Dos rechazos sobre lo mismo dicen más que dos rechazos sobre cosas distintas. Si los dos señalan la misma
|
|
57
|
+
dimensión de la aceptación, ahí está la costura por donde parte. En el caso que originó esto, una crítica
|
|
58
|
+
objetó cómo se probaba un número con unidades y la otra un estado que ya se cumplía: dos formas de
|
|
59
|
+
comprobar dentro de una sola aceptación, que es la definición de dos resultados con vidas distintas.
|
|
60
|
+
|
|
61
|
+
Lo que la hace fácil de perder es que llega **después** de que las otras dos dieron el visto bueno, y las
|
|
62
|
+
dos acertaron: la aceptación era concreta y las condiciones no cruzaban el umbral. Una unidad puede estar
|
|
63
|
+
bien escrita y no ser planificable, y eso sólo se sabe habiéndolo intentado.
|
|
64
|
+
|
|
65
|
+
Ninguna de las tres decide la división: la dispara. Al cruzarla se revisa si la unidad mezcla dos
|
|
52
66
|
resultados con vidas distintas, y recién ahí se parte, o se deja con la razón escrita —igual que R7 con
|
|
53
67
|
el código—. Un número usado como límite se cumple partiendo por la mitad lo que era una sola cosa.
|
|
54
68
|
|
|
@@ -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
|