@ingeniomaps/cauce 0.76.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 CHANGED
@@ -14,6 +14,109 @@ 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
+
17
120
  ## [0.76.0] - 2026-09-10
18
121
 
19
122
  ### Corregido
@@ -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 stop('plan-blocked', blockers(critique).join('; ') || 'sin condiciones nombradas')
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 stop('plan-rejected', blockers(critique).join('; ') || 'sin condiciones nombradas')
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.
@@ -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
- // El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que
139
- // el borrado no tocó de uno que alguien reescribió mientras borrábamos.
140
- const since = Date.now()
141
- // Con reintentos. Los puso el `ENOTEMPTY` que aparecía al rehacer un banco recién creado, y hoy se
142
- // sabe que eso era el mantenimiento de git escribiendo por detrás —la causa está apagada quince líneas
143
- // más abajo, en la creación—. Se quedan porque cubren a cualquier otro escritor transitorio, no porque
144
- // sigan tapando éste; sacarlos es una decisión aparte y lo que la activaría es que nunca más disparen.
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
- // Falla en vez de seguir, porque un banco a medio borrar contamina la medición que viene, que es lo
156
- // que la recreación existe para evitar. Qué trae el mensaje y por qué, en `benchSurvived`.
157
- if (fs.existsSync(dir)) fail(benchSurvived(dir, since), 2)
158
- // Con `force`: el banco es desechable y se acaba de borrar, así que lo que sobreviva al `rmSync` se
159
- // pisa en vez de cortar la corrida. Sin esto, `copyTemplate` se niega ante cualquier archivo que
160
- // quede —«El destino contiene …/AGENTS.md»— y el mismo test falló así tres veces en un día, en las
161
- // dos patas de la matriz.
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
- // El enlace se pisa por lo mismo que el andamiaje de arriba: a veces sobrevive al borrado del banco, y
173
- // entonces crearlo corta la corrida con `EEXIST` en vez de rehacerlo. Es el único paso que no seguía esa
174
- // regla, y el que falló en CI rehaciendo el mismo `11-otro` que ya tiene reintentos por esto.
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 }
@@ -149,6 +149,7 @@ function check(dir, cli) {
149
149
  }))
150
150
  warnings.push(...AD.report({ done, epics, adopted }))
151
151
  warnings.push(...PC.doneCeremonyWarnings(done, new Set(adopted)))
152
+ warnings.push(...R.coverageWarnings(path.resolve(root, '..'), done))
152
153
  // Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
153
154
  // no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
154
155
  // en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
@@ -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')
@@ -64,4 +64,59 @@ function lastCommit(repo, branch) {
64
64
  return shown.status === 0 ? shown.stdout.trim() : ''
65
65
  }
66
66
 
67
- module.exports = { reposFor, repoOf, lastCommit }
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 }
@@ -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 (!/(?:^|\/)(?:migrations?|migrate)\/.*\.sql$/i.test(normalized)) continue
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')}`)
@@ -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 sin override.',
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',
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -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
@@ -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, y la
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
- Ninguna de las dos decide la división: la dispara. Al cruzarla se revisa si la unidad mezcla dos
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