@ingeniomaps/cauce 0.91.0 → 0.92.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 +105 -0
- package/automatization/hooks/run-hook.sh +4 -4
- package/automatization/runners/antigravity/hook.js +2 -2
- package/automatization/workflows/agent-eval.js +1 -1
- package/automatization/workflows/autobuild.js +52 -3
- package/engine/cli/args.js +1 -0
- package/engine/cli/bench.js +314 -0
- package/engine/cli/catalog.js +7 -168
- package/engine/cli/contract.js +75 -2
- package/engine/cli/ops.js +3 -0
- package/engine/cli/validate.js +2 -0
- package/engine/config/validate.js +13 -1
- package/engine/core/ownership.js +27 -0
- package/engine/core/scope.js +130 -0
- package/engine/hooks/shell.js +17 -4
- package/engine/schemas/ops-config.schema.json +9 -0
- package/package.json +1 -1
- package/template/organization/workspace.md +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,111 @@ 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.92.0] - 2026-09-15
|
|
18
|
+
|
|
19
|
+
### Agregado
|
|
20
|
+
|
|
21
|
+
- **El ciclo de aprendizaje ya no deja la propuesta mensual en «por definir».** Cada mes, el ciclo
|
|
22
|
+
consolida lo que recomendaron los informes semanales de un cargo y abre su propuesta. Hasta ahora la
|
|
23
|
+
dejaba con el molde intacto en «Cambio propuesto», y eso no es aprobable: nadie firma una intención.
|
|
24
|
+
Medido sobre `2026-09`: de **25** propuestas, **15** se archivaron con el molde y sólo 10 llegaron a
|
|
25
|
+
algo, las que alguien completó a mano.
|
|
26
|
+
|
|
27
|
+
El recorrido que escribe el cambio exacto —archivo por archivo, contrastado contra los casos
|
|
28
|
+
adversariales vigentes— ya existía; lo que faltaba era que el ciclo lo corriera. Ahora lo corre, y sólo
|
|
29
|
+
sobre las propuestas que quedaron sin decidir.
|
|
30
|
+
|
|
31
|
+
**Sin credencial no se rompe nada**: el paso avisa y el mes queda como quedaba antes, con la rama
|
|
32
|
+
empujada y sus sellos puestos. Lo mismo si la corrida falla. Lo que decide si se te pide una firma
|
|
33
|
+
sigue siendo el documento, no quién lo llenó.
|
|
34
|
+
|
|
35
|
+
- **Tu proyecto puede declarar sus límites en una lista, y `check` avisa del que no llegue a los agentes.**
|
|
36
|
+
Los límites que tu proyecto amplía o restringe viven en `organization/workspace.md`, y de ahí viajan al
|
|
37
|
+
preámbulo de cada subagente. Hasta ahora se reconocían **por cómo arrancaba el párrafo** —«El runner»,
|
|
38
|
+
«Debe», «Nunca»—, que es la gramática del molde: un límite escrito de cualquier otra forma, que es como
|
|
39
|
+
lo escribiría cualquiera, no llegaba a ningún agente y nada lo decía. La lista salía más corta y se leía
|
|
40
|
+
igual de completa.
|
|
41
|
+
|
|
42
|
+
Ahora esa sección trae un `### Límites` con una viñeta por límite:
|
|
43
|
+
|
|
44
|
+
```markdown
|
|
45
|
+
### Límites
|
|
46
|
+
|
|
47
|
+
- En `api/` no se tocan migraciones sin aprobación de quien administra la base.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Lo que ya tenías escrito sigue contando**: la gramática vieja se lee igual, así que no hay nada que
|
|
51
|
+
migrar. Y lo que no entra por ninguno de los dos caminos ya no se pierde callado — `ops check` lo
|
|
52
|
+
reporta citando el párrafo, para que sepas cuál de tus límites se quedó afuera.
|
|
53
|
+
|
|
54
|
+
El ejemplo del molde viene comentado a propósito: un límite de ejemplo que se obedece es peor que
|
|
55
|
+
ninguno, porque nadie lo escribió y todos lo cumplirían.
|
|
56
|
+
|
|
57
|
+
- **Reanudar una tarea dejó de pagar la fase que no tiene nada que hacer, y la corrida dice cuándo lo
|
|
58
|
+
hizo.** Una tarea que para antes de Commit —una revisión que pidió algo, un gate en rojo— deja su plan
|
|
59
|
+
en disco con los pasos tildados. Al relanzar, el recorrido entraba igual a Build: el agente releía el
|
|
60
|
+
WIP, comprobaba el disco y contestaba que no había nada pendiente. Hacía lo correcto; lo que costaba
|
|
61
|
+
era haberlo llamado — **893.000 tokens sobre tres corridas de una sola tarea**, medido.
|
|
62
|
+
|
|
63
|
+
Ahora, si el WIP no tiene pasos pendientes, esa llamada no se hace y la fase se anuncia como
|
|
64
|
+
`Build (reanudado)`, que viaja al resultado de la corrida y a la entrada de DONE. Antes una corrida
|
|
65
|
+
reanudada se veía idéntica a una que construyó salvo por el costo, así que comprobar qué se reutilizó
|
|
66
|
+
exigía abrir la salida cruda y sumar tokens a mano.
|
|
67
|
+
|
|
68
|
+
**Lo que no cambia es qué se revisa.** No se saltea la fase, se saltea la llamada: Review, Verify y QA
|
|
69
|
+
siguen mirando el diff real que quedó en disco, venga de la corrida que venga. Y si tu instancia no
|
|
70
|
+
emite ese dato, todo se comporta como antes.
|
|
71
|
+
|
|
72
|
+
- **Una raíz puede declarar qué rutas lee su puerta, y un archivo sucio que el gate no va a abrir deja de
|
|
73
|
+
forzar la copia del índice.** Al commitear, `verify` corre sobre el árbol cuando árbol e índice
|
|
74
|
+
coinciden y materializa el índice en un temporal cuando difieren. Hasta ahora alcanzaba **cualquier**
|
|
75
|
+
archivo sin trackear para materializar: un README a medio escribir, un `tsconfig.json` que dejó otra
|
|
76
|
+
sesión, o el propio `planning/.ops-approval` que el bloqueo te manda crear para aprobar unas rutas.
|
|
77
|
+
|
|
78
|
+
Ahora, junto a `verify`, una raíz puede declarar `scope`:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
"workspaceRoots": [
|
|
82
|
+
{ "name": "web", "path": "apps/web", "verify": "pnpm build",
|
|
83
|
+
"scope": ["src/**", "package.json", "tsconfig.json"] }
|
|
84
|
+
]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Con eso, sólo materializa si alguna ruta que difiere cae dentro de ese alcance. Los patrones son
|
|
88
|
+
relativos a la raíz —el `scope` de `apps/web` habla de `src/**`, no de `apps/web/src/**`— y admiten
|
|
89
|
+
`*` dentro de un segmento, `**` cruzando segmentos y `?` por un carácter.
|
|
90
|
+
|
|
91
|
+
**Sin `scope` declarado no cambia nada**: cualquier delta sigue forzando la copia, que es el
|
|
92
|
+
comportamiento de siempre. El campo es opcional y no hay que adoptarlo.
|
|
93
|
+
|
|
94
|
+
Lo que se gana no es tiempo. Dentro de la copia `node_modules` viaja por **enlace**, y eso rompe
|
|
95
|
+
cualquier build de Turbopack sin salida del lado del proyecto: ahí un archivo ajeno al commit no cuesta
|
|
96
|
+
segundos, deja el gate sin poder pasar. Declarar el alcance es lo que lo destraba.
|
|
97
|
+
|
|
98
|
+
Dos bordes que valen la pena saber. Un directorio sin trackear llega colapsado —git reporta `extra/` y
|
|
99
|
+
no dice qué hay adentro—, así que se materializa igual si algún patrón apunta hacia adentro de él. Y
|
|
100
|
+
una ruta que no cuelga de **ninguna** raíz declarada también cuenta: puede ser de la instancia o de un
|
|
101
|
+
servicio que nadie declaró, y suponer que no importa es justo lo que este campo existe para evitar.
|
|
102
|
+
|
|
103
|
+
### Corregido
|
|
104
|
+
|
|
105
|
+
- **El motor se encuentra aunque lo hayas instalado un nivel arriba de tu instancia.** Si corrés
|
|
106
|
+
`npm install @ingeniomaps/cauce` en la carpeta de tu empresa y después `cauce init ops`, la instancia
|
|
107
|
+
queda adentro y el motor arriba. Ese es el árbol que el propio comando sugiere, y hasta ahora
|
|
108
|
+
`automation check` devolvía **nueve errores** sobre un motor que estaba instalado, cada uno mandándote a
|
|
109
|
+
correr `npm install` otra vez un nivel más abajo — o sea a bajar una segunda copia del paquete.
|
|
110
|
+
|
|
111
|
+
Ahora se busca también en la raíz que tu `ops.config.json` declara, que es la misma que el motor ya usa
|
|
112
|
+
para decidir dónde instalar el runner. Si tu `<empresa>-ops` tiene su propio `node_modules`, ése sigue
|
|
113
|
+
ganando y nada cambia.
|
|
114
|
+
|
|
115
|
+
**No se adivina el layout, se lee el declarado**: no se sube por el árbol como hace Node —en un monorepo
|
|
116
|
+
podría encontrar un motor de otra versión, en silencio— ni se mira si hay repositorio git, porque tener
|
|
117
|
+
la instancia sin versionar es un uso legítimo.
|
|
118
|
+
|
|
119
|
+
Vale para los tres caminos, no sólo para el CLI: el shim que lanza cada guard y el bridge de Antigravity
|
|
120
|
+
repiten esa búsqueda porque corren antes de poder cargar el motor, y los tres se actualizaron juntos.
|
|
121
|
+
|
|
17
122
|
## [0.91.0] - 2026-09-15
|
|
18
123
|
|
|
19
124
|
### Agregado
|
|
@@ -14,12 +14,12 @@ if [ -z "$hook_name" ]; then
|
|
|
14
14
|
exit 2
|
|
15
15
|
fi
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
# antes de poder cargar el motor: si cambia allá, cambia acá.
|
|
17
|
+
# Copia de `packagePath`; su porqué vive allá. Son tres los que la repiten y el motor los nombra.
|
|
19
18
|
runner=""
|
|
20
19
|
for candidate in \
|
|
21
20
|
"$ops_root/node_modules/@ingeniomaps/cauce/engine/hooks/run.js" \
|
|
22
|
-
"$ops_root/engine/hooks/run.js"
|
|
21
|
+
"$ops_root/engine/hooks/run.js" \
|
|
22
|
+
"$ops_root/../node_modules/@ingeniomaps/cauce/engine/hooks/run.js"
|
|
23
23
|
do
|
|
24
24
|
if [ -f "$candidate" ]; then runner="$candidate"; break; fi
|
|
25
25
|
done
|
|
@@ -27,7 +27,7 @@ done
|
|
|
27
27
|
# Un guard que no encuentra su motor bloquea, nunca permite.
|
|
28
28
|
if [ -z "$runner" ]; then
|
|
29
29
|
echo "BLOQUEADO [$hook_name]: no se encontró el motor de hooks de Cauce." >&2
|
|
30
|
-
echo " Buscado en node_modules/@ingeniomaps/cauce y engine/ bajo $ops_root" >&2
|
|
30
|
+
echo " Buscado en node_modules/@ingeniomaps/cauce y engine/ bajo $ops_root y su carpeta padre" >&2
|
|
31
31
|
exit 2
|
|
32
32
|
fi
|
|
33
33
|
|
|
@@ -82,9 +82,9 @@ function runtimeAt(root) {
|
|
|
82
82
|
const candidates = [
|
|
83
83
|
path.join(root, 'node_modules', '@ingeniomaps', 'cauce', 'engine', 'hooks', 'run.js'),
|
|
84
84
|
path.join(root, 'engine', 'hooks', 'run.js'),
|
|
85
|
+
path.join(root, '..', 'node_modules', '@ingeniomaps', 'cauce', 'engine', 'hooks', 'run.js'),
|
|
85
86
|
]
|
|
86
|
-
//
|
|
87
|
-
// engine/core/ownership.js en vez de requerirla. Si cambia una, cambian las dos.
|
|
87
|
+
// Copia de `packagePath`; su porqué vive allá. Son tres los que la repiten y el motor los nombra.
|
|
88
88
|
const runtime = candidates.find(fs.existsSync)
|
|
89
89
|
if (!runtime) throw new Error('No se encontró el runtime engine/hooks/run.js.')
|
|
90
90
|
return require(runtime)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// misma razón por la que nadie corrige su propio examen.
|
|
6
6
|
//
|
|
7
7
|
// Dónde trabaja el cargo lo decide el modo: en el toolkit, un banco desechable por caso; en una
|
|
8
|
-
// empresa, su propia instancia. El porqué del banco está en `evaluationBench` (engine/cli/
|
|
8
|
+
// empresa, su propia instancia. El porqué del banco está en `evaluationBench` (engine/cli/bench.js).
|
|
9
9
|
// El veredicto, en cambio, se escribe siempre junto al cargo: el banco se borra, el contrato queda.
|
|
10
10
|
export const meta = {
|
|
11
11
|
name: 'agent-eval',
|
|
@@ -87,6 +87,21 @@ const CONTEXT = {
|
|
|
87
87
|
inbox: { ...INBOX_HEADS },
|
|
88
88
|
// Las reglas que rigen el proyecto, con los overrides ya resueltos por el motor (caso 105).
|
|
89
89
|
rules: { type: 'array', items: { type: 'string' } },
|
|
90
|
+
// Cuántos pasos del plan están tildados y cuántos no. Es lo único que separa «esta tarea viene de una
|
|
91
|
+
// corrida que paró a mitad» de «esta tarea no empezó», y sin eso Build se lanzaba igual con los nueve
|
|
92
|
+
// pasos hechos: el agente releía el WIP, miraba el disco y contestaba que no había nada pendiente —
|
|
93
|
+
// medido en 893.000 tokens sobre tres corridas de una sola tarea (caso 154).
|
|
94
|
+
//
|
|
95
|
+
// Viene de `ops context --json`, que ya lo emite; acá sólo hacía falta declararlo, porque
|
|
96
|
+
// `additionalProperties: false` lo descartaba aunque llegara. Es opcional: una instancia sin WIP
|
|
97
|
+
// activo no lo trae, y pedirlo siempre obligaría a inventar ceros donde no hay plan.
|
|
98
|
+
wip: {
|
|
99
|
+
type: 'object', additionalProperties: false, required: ['complete', 'pending'],
|
|
100
|
+
properties: {
|
|
101
|
+
phase: { type: 'string' },
|
|
102
|
+
complete: { type: 'integer' }, pending: { type: 'integer' },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
90
105
|
},
|
|
91
106
|
}
|
|
92
107
|
const CLAIM = {
|
|
@@ -290,6 +305,22 @@ const VERDICT = ' Cerrá con verdict=aprobado si no queda nada por corregir ante
|
|
|
290
305
|
const RULED = ' En rules nombrá, por su ruta, cada una de las reglas que rigen contra la que revisaste el diff.'
|
|
291
306
|
// Lo que hay que corregir antes de entregar. El resto de los hallazgos no desaparece: se registra.
|
|
292
307
|
const blockers = (verdict) => verdict.concerns.filter((one) => one.blocking).map((one) => one.detail)
|
|
308
|
+
// El resultado de Build cuando no hubo nada que construir en esta corrida. Devuelve lo que de verdad
|
|
309
|
+
// pasó y nada más: `redFirst` y `discovered` van **vacíos** porque acá no hubo ningún rojo nuevo que
|
|
310
|
+
// mostrar ni ningún borde nuevo que fijar, y rellenarlos para que se parezca a una construcción sería
|
|
311
|
+
// fabricar la evidencia que este recorrido exige justamente para no tener que creerle a nadie.
|
|
312
|
+
//
|
|
313
|
+
// `completed: true` afirma que el plan no tiene pasos pendientes, que es lo que el WIP dice y lo único
|
|
314
|
+
// que se está usando. No afirma que lo construido esté bien: eso lo miran Review, Verify y QA sobre el
|
|
315
|
+
// diff real, que existe en disco venga de la corrida que venga.
|
|
316
|
+
const reusedBuild = (wip) => ({
|
|
317
|
+
completed: true,
|
|
318
|
+
closedTask: false,
|
|
319
|
+
redFirst: [],
|
|
320
|
+
discovered: [],
|
|
321
|
+
summary: `sin construir en esta corrida: el WIP traía ${wip.complete} paso(s) tildado(s) y ninguno `
|
|
322
|
+
+ 'pendiente, así que lo que sigue revisa lo que ya estaba en disco',
|
|
323
|
+
})
|
|
293
324
|
// Atajo para reconocer un gate que corrió pruebas sin preguntarle a nadie. No alcanza solo y no
|
|
294
325
|
// pretende hacerlo: `mvn verify`, `gradle build`, `tox`, `bin/rails t` y cualquier `make` con nombre
|
|
295
326
|
// propio corren pruebas y no se parecen a esto, así que el que corrió el comando además lo declara en
|
|
@@ -389,7 +420,8 @@ const registerHuman = async (prompt, label) => (await write(prompt, { label })
|
|
|
389
420
|
const readContext = () => read(
|
|
390
421
|
`Corré "node tools/ops.js context ${P} --json" desde ${ROOT} y reportá sólo lo que imprimió. Derivá hasTask ` +
|
|
391
422
|
`de si task es null, wipActive de si wip es null, claimed del campo claimed, today y wipFile de sus ` +
|
|
392
|
-
`campos, rules del campo rules tal cual, y
|
|
423
|
+
`campos, rules del campo rules tal cual, wip con sus campos complete y pending tal cual si viene —y ` +
|
|
424
|
+
`omitilo entero si wip es null, sin inventar ceros—, y lane ` +
|
|
393
425
|
`de task.tier; copiá slug, ` +
|
|
394
426
|
`hito, service, acceptance, ` +
|
|
395
427
|
`epic y cast de task, epicContext de epic.context —vacío si no hay épica— e inbox tal cual. El comando es ` +
|
|
@@ -710,8 +742,25 @@ while (rounds++ < MAX_TASKS) {
|
|
|
710
742
|
}
|
|
711
743
|
}
|
|
712
744
|
|
|
713
|
-
|
|
714
|
-
|
|
745
|
+
// Un WIP que viene de una corrida anterior con todos sus pasos tildados no tiene nada que construir, y
|
|
746
|
+
// lanzar el agente para que lo confirme cuesta lo mismo que construir. El recorrido delegaba la
|
|
747
|
+
// reanudación en el prompt —«retomá en el primer paso pendiente»—, así que el agente hacía lo correcto
|
|
748
|
+
// y lo caro era haberlo llamado (caso 154).
|
|
749
|
+
//
|
|
750
|
+
// **La fase no se saltea: se saltea la llamada.** Los cuatro contrastes de abajo —la tarea cerrada en
|
|
751
|
+
// Build, el rojo sin su fallo literal, el borde sin prueba, las decisiones abiertas— son lo único que
|
|
752
|
+
// vuelve a mirar el disco, y darlos por buenos porque el WIP dice que está todo hecho camina al modo de
|
|
753
|
+
// fallo que registra la fase WIP acá arriba: alguien construyó todo y Review, Verify y QA no lo vieron.
|
|
754
|
+
// Por eso lo que sigue no afirma que la construcción estuvo bien, sólo que en **esta** corrida no hubo
|
|
755
|
+
// ninguna: lo que ya estaba en disco lo revisan igual las fases siguientes, sobre el diff real.
|
|
756
|
+
// Y la fase se anuncia distinto, que es la otra mitad del caso: hoy «Build corrió y construyó» y «Build
|
|
757
|
+
// corrió, miró y no hizo nada» se ven idénticos salvo por el costo, así que R21 —que manda comprobar la
|
|
758
|
+
// reanudación en vez de suponerla— no se puede cumplir sobre este recorrido sin abrir el `.output` y
|
|
759
|
+
// sumar tokens a mano. El nombre viaja por el mismo canal que las otras quince fases: entra en `ran`,
|
|
760
|
+
// que va al resultado de la corrida y a la entrada de DONE.
|
|
761
|
+
const resumed = planning.wip && planning.wip.pending === 0 && planning.wip.complete > 0
|
|
762
|
+
phase(resumed ? 'Build (reanudado)' : 'Build')
|
|
763
|
+
const build = resumed ? reusedBuild(planning.wip) : await run(
|
|
715
764
|
`${asRole(cast.build)}Implementá sólo ${task.id} dentro de ${task.service}. Retomá en el primer paso ` +
|
|
716
765
|
`pendiente del WIP; comprobá en el disco los pasos ya hechos y tildá cada uno que salga bien. Para cada ` +
|
|
717
766
|
`comportamiento escribí primero la prueba, corréla y anotá en redFirst el test y el fallo literal que ` +
|
package/engine/cli/args.js
CHANGED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El banco desechable: una instancia de verdad que nace limpia, se usa una vez y se borra. Vive acá y no
|
|
4
|
+
// en `core/` porque lo arma con `scaffold` y `PROJECT_ROOT`, que son del CLI, y `core/` no importa de
|
|
5
|
+
// `cli/` en ningún archivo — invertir esa dirección por una herramienta del CLI sería la primera
|
|
6
|
+
// excepción a una regla que el repositorio sostiene entero.
|
|
7
|
+
//
|
|
8
|
+
// Salió de `catalog.js` cuando dejó de tener un solo consumidor: evaluar un cargo necesita un banco, y
|
|
9
|
+
// medir cualquier otra cosa también. Lo que se comparte no es la idea sino lo aprendido a los golpes —el
|
|
10
|
+
// borrado que se comprueba, el `GIT_DIR` que se limpia, el mantenimiento de git que se apaga—, y eso
|
|
11
|
+
// copiado se pudre en una de las dos copias.
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const { spawnSync } = require('node:child_process')
|
|
16
|
+
const EV = require('../agents/evaluations')
|
|
17
|
+
const CL = require('../planning/claims')
|
|
18
|
+
const P = require('../planning/parser')
|
|
19
|
+
const IN = require('./instance')
|
|
20
|
+
const O = require('../core/ownership')
|
|
21
|
+
const { fail, opsRoot, TODAY } = require('./io')
|
|
22
|
+
|
|
23
|
+
// Qué decir cuando el banco sobrevivió a su propio borrado, que es lo único que va a permitir
|
|
24
|
+
// establecer la causa. Devuelve el mensaje en vez de escribirlo donde ocurre, y eso es lo que lo hace
|
|
25
|
+
// medible sin provocar el fallo; por qué eso importa acá lo dice su prueba.
|
|
26
|
+
//
|
|
27
|
+
// Tres cosas que el listado anterior no traía, y cada una separa dos diagnósticos distintos:
|
|
28
|
+
//
|
|
29
|
+
// - **Cuánto**, y no una muestra. Cortaba en cinco, así que «borró casi todo y quedaron cuatro objetos»
|
|
30
|
+
// y «no borró nada» se leían idénticos, y son problemas opuestos.
|
|
31
|
+
// - **Si lo que quedó es anterior al borrado o se escribió durante.** Posterior significa que alguien
|
|
32
|
+
// reescribió mientras borrábamos; anterior, que el borrado no lo tocó. Es la pregunta central del
|
|
33
|
+
// caso y la contesta la fecha de modificación.
|
|
34
|
+
// - **Qué hace un segundo borrado.** No lo rodea: quien lo llama corta igual.
|
|
35
|
+
// Distingue lo transitorio de lo permanente, que se arreglan distinto.
|
|
36
|
+
function benchSurvived(dir, since) {
|
|
37
|
+
let files = 0
|
|
38
|
+
let dirs = 0
|
|
39
|
+
const sample = []
|
|
40
|
+
const walk = (base, relative = '') => {
|
|
41
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
42
|
+
const next = relative ? `${relative}/${entry.name}` : entry.name
|
|
43
|
+
if (entry.isDirectory()) { dirs += 1; walk(path.join(base, entry.name), next); continue }
|
|
44
|
+
files += 1
|
|
45
|
+
if (sample.length >= 5) continue
|
|
46
|
+
const stat = fs.statSync(path.join(base, entry.name), { throwIfNoEntry: false })
|
|
47
|
+
sample.push(`${next} (${!stat ? 'ya no está'
|
|
48
|
+
: stat.mtimeMs >= since ? 'escrito durante el borrado' : 'anterior al borrado'})`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try { walk(dir) } catch { /* el listado es la explicación, no la comprobación */ }
|
|
52
|
+
let again = 'no se pudo reintentar'
|
|
53
|
+
try {
|
|
54
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
55
|
+
again = fs.existsSync(dir) ? 'un segundo borrado tampoco lo sacó' : 'un segundo borrado sí lo sacó'
|
|
56
|
+
} catch (error) { again = `un segundo borrado lanzó ${error.code || error.message}` }
|
|
57
|
+
return `${dir} no se pudo borrar entero y el banco tiene que ser nuevo. Sobrevivieron ${files} `
|
|
58
|
+
+ `archivo(s) en ${dirs} directorio(s), con Node ${process.version}: `
|
|
59
|
+
+ `${sample.join(', ') || '(sólo directorios)'}. ${again}. Borralo a mano y volvé a correr.`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Borrar el banco y comprobar que se borró, que es una sola decisión: lo que no desapareció contamina la
|
|
63
|
+
// medición que viene. Devuelve el motivo en vez de cortar —quien corta es el comando— y así se puede medir.
|
|
64
|
+
//
|
|
65
|
+
// **El destino se comprueba antes de destruir** (R23). `dir` lo arma este archivo a partir de nombres ya
|
|
66
|
+
// validados, así que hoy no puede apuntar afuera; la comprobación existe porque el costo de que algún día
|
|
67
|
+
// pueda no es un resultado incorrecto sino trabajo perdido, y porque una ruta peligrosa se construye sola
|
|
68
|
+
// a partir de algo vacío. Se niega nombrando la ruta y contra qué la comparó.
|
|
69
|
+
//
|
|
70
|
+
// `remove` se inyecta porque **la condición que la comprobación de abajo existe para atrapar no se puede
|
|
71
|
+
// provocar con el sistema de archivos real**: es el caso 078, y sin ese hueco la línea que decide se
|
|
72
|
+
// quedaba sin una sola prueba —comprobado: borrarla no ponía nada en rojo—. Con un borrado que no borra,
|
|
73
|
+
// la rama se ejerce en milisegundos y sobre un temporal que la prueba acaba de crear.
|
|
74
|
+
function clearBench(dir, scratch, remove = fs.rmSync) {
|
|
75
|
+
const target = path.resolve(dir)
|
|
76
|
+
const banco = path.resolve(scratch)
|
|
77
|
+
if (!target.startsWith(banco + path.sep)) {
|
|
78
|
+
return `no se borra ${target}: no cuelga de ${banco}, así que no es un banco de evaluación.`
|
|
79
|
+
}
|
|
80
|
+
// El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que el
|
|
81
|
+
// borrado no tocó de uno que alguien reescribió mientras borrábamos.
|
|
82
|
+
const since = Date.now()
|
|
83
|
+
// Con reintentos. Los puso el `ENOTEMPTY` que aparecía al rehacer un banco recién creado, y hoy se sabe
|
|
84
|
+
// que eso era el mantenimiento de git escribiendo por detrás (caso 073). Se quedan porque son lo único
|
|
85
|
+
// que corre **antes** de la comprobación: cubren a cualquier otro escritor transitorio, no a éste, que
|
|
86
|
+
// está apagado.
|
|
87
|
+
remove(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
88
|
+
return fs.existsSync(target) ? benchSurvived(target, since) : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Un solo autor para todo banco desechable. Eran tres literales para lo mismo —el de evaluación, el de
|
|
92
|
+
// medición y el del producto del sidecar—, ninguna prueba los afirmaba y la distinción no distinguía nada:
|
|
93
|
+
// el commit es andamiaje, y quien mira `git log` de un banco busca qué escribió el cargo, no quién firmó.
|
|
94
|
+
const AUTHOR = 'banco de cauce'
|
|
95
|
+
|
|
96
|
+
// Armar el banco, que es lo que el de evaluación y el de medición comparten: de acá vuelve un directorio
|
|
97
|
+
// que no existía hace un instante, con una instancia adentro y git listo para versionarla. Lo que cambia
|
|
98
|
+
// entre medir un cargo y medir un comando es **qué queda adentro**, no cómo se lo prepara.
|
|
99
|
+
//
|
|
100
|
+
// Recrear un banco donde alguien ya trabajó borra la evidencia de esa corrida, y el registro de una
|
|
101
|
+
// evaluación se escribe **desde** el banco. Pasó de verdad: se rehizo un banco para probar otra cosa y
|
|
102
|
+
// con él se fue lo que el cargo había escrito; el juez leyó un directorio vacío y concluyó que la
|
|
103
|
+
// respuesta afirmaba algo inexistente. Con el banco versionado, «acá se trabajó» es una pregunta que git
|
|
104
|
+
// contesta exacto.
|
|
105
|
+
function makeBench(root, dir, force, name) {
|
|
106
|
+
const dirty = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' })
|
|
107
|
+
if ((dirty.stdout || '').trim() && !force) {
|
|
108
|
+
fail(`${dir} tiene trabajo sin recoger. Guardá lo que esa corrida dejó antes de rehacerlo, `
|
|
109
|
+
+ 'o usá --force si ya lo tenés.', 2)
|
|
110
|
+
}
|
|
111
|
+
// Rodear un borrado a medias deja la corrida siguiendo sobre un banco que no es nuevo, y lo que falla
|
|
112
|
+
// después no dice nada del borrado: el test que lo destapó reportaba `true !== false` sobre un archivo
|
|
113
|
+
// de la corrida anterior, sin nombrar de dónde salía. Esta guarda es la que estableció la causa —su
|
|
114
|
+
// primer disparo instrumentado nombró al escritor—; lo que cubre ahora es que aparezca otro.
|
|
115
|
+
//
|
|
116
|
+
// **Y de acá para abajo el directorio no existe.** Eso es lo que sostiene que el andamiaje y el enlace
|
|
117
|
+
// se escriban sin defensas: hasta el 073, los dos llevaban una por si algo sobrevivía al borrado.
|
|
118
|
+
const problema = clearBench(dir, path.join(root, '.cauce-eval'))
|
|
119
|
+
if (problema) fail(problema, 2)
|
|
120
|
+
// Sin `force`, y eso es lo que hay que poder decir: sólo servía si algún archivo sobrevivía al borrado,
|
|
121
|
+
// y la comprobación de arriba garantiza que no queda ninguno. Lo llevaba porque el mismo test falló tres
|
|
122
|
+
// veces en un día con «El destino contiene …/AGENTS.md», y eso era el escritor de fondo que apagó el 073.
|
|
123
|
+
IN.scaffold(dir, { name, mode: 'sidecar', quiet: true })
|
|
124
|
+
// El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
|
|
125
|
+
// sin pagar un `npm install` por corrida. Quien use el banco llega a un lugar donde el CLI funciona.
|
|
126
|
+
//
|
|
127
|
+
// Y el enlace se crea sin borrarlo antes, por lo mismo que el andamiaje: `scope` acaba de nacer dentro
|
|
128
|
+
// de un directorio que no existía, así que no puede haber un enlace que pisar. El `rm` que había acá era
|
|
129
|
+
// el tercer rodeo del mismo escritor de fondo, y el que falló en CI con `EEXIST`.
|
|
130
|
+
const scope = path.join(dir, 'node_modules', '@ingeniomaps')
|
|
131
|
+
fs.mkdirSync(scope, { recursive: true })
|
|
132
|
+
fs.symlinkSync(IN.PROJECT_ROOT, path.join(scope, 'cauce'), 'dir')
|
|
133
|
+
// El git del banco, sin herencia. `-C` dice dónde mirar y `GIT_DIR` gana igual —comprobado: con `GIT_DIR`
|
|
134
|
+
// puesto, `git -C otro rev-parse --absolute-git-dir` contesta el de la variable—, así que sin limpiarla
|
|
135
|
+
// el banco commitea en el repositorio que la haya exportado. Es lo que hizo el caso 045 antes de
|
|
136
|
+
// arreglarse en `hooks/shell.js`: el banco de una evaluación dejó sus commits en la rama del usuario.
|
|
137
|
+
const env = { ...process.env }
|
|
138
|
+
delete env.GIT_DIR
|
|
139
|
+
delete env.GIT_WORK_TREE
|
|
140
|
+
return { env, git: (...args) => spawnSync('git', ['-C', dir, ...args], { stdio: 'ignore', env }) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Versionar el banco desde su estado limpio. Lo que garantiza: todo lo que aparezca después es obra de
|
|
144
|
+
// quien usó el banco, y `git status` lo separa del andamiaje sin que nadie tenga que acordarse de qué
|
|
145
|
+
// había antes. Por qué eso decide un veredicto lo mide `bench.test.js`, que trae el caso con nombre y
|
|
146
|
+
// fecha. Se ignora `node_modules`: es un symlink al toolkit, no obra de nadie.
|
|
147
|
+
//
|
|
148
|
+
// Y se le apaga el mantenimiento automático, que es el escritor de fondo que rompía el borrado del banco
|
|
149
|
+
// siguiente. `git commit` lanza `git maintenance run --auto`, que se detacha y sigue escribiendo en
|
|
150
|
+
// `.git/objects` después de que el comando ya volvió; el banco se rehace milisegundos más tarde y el
|
|
151
|
+
// `rmSync` corre contra alguien que está escribiendo ahí.
|
|
152
|
+
//
|
|
153
|
+
// Es lo que produjo los tres síntomas que se venían rodeando por separado —`ENOTEMPTY`, `EEXIST`, y el
|
|
154
|
+
// borrado que vuelve sin lanzar y deja archivos—. La guarda lo nombró el 2026-09-10: `maintenance.lock`
|
|
155
|
+
// entre los sobrevivientes, y `info/refs` y `objects/info/packs` fechados **durante** el borrado, en un
|
|
156
|
+
// árbol que ninguna otra prueba toca (caso 073).
|
|
157
|
+
//
|
|
158
|
+
// `maintenance.auto=false` y no `gc.auto=0`: medido con `GIT_TRACE=1`, el segundo deja que el commit
|
|
159
|
+
// lance el mantenimiento igual —sólo hace que su tarea de `gc` no encuentre trabajo— y el proceso toma su
|
|
160
|
+
// lock y escribe lo mismo. Se le quita el motivo de lanzarlo, no lo que hace una vez lanzado.
|
|
161
|
+
function seal(dir, git, mensaje) {
|
|
162
|
+
fs.appendFileSync(path.join(dir, '.gitignore'), '\nnode_modules/\n')
|
|
163
|
+
git('init', '-q')
|
|
164
|
+
git('config', 'user.email', 'banco@cauce.local')
|
|
165
|
+
git('config', 'user.name', AUTHOR)
|
|
166
|
+
git('config', 'maintenance.auto', 'false')
|
|
167
|
+
git('add', '-A')
|
|
168
|
+
git('commit', '-q', '-m', mensaje)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Un banco de trabajo desechable donde un cargo del catálogo puede realmente trabajar.
|
|
172
|
+
//
|
|
173
|
+
// Hace falta porque el toolkit no es una raíz ops: el único `planning/` que vive acá es
|
|
174
|
+
// `template/planning`, el molde que se distribuye. Un cargo cuya entrega es una épica no tiene dónde
|
|
175
|
+
// escribir, así que se niega —con razón—, y su caso cuenta como fallo: eso midió una configuración.
|
|
176
|
+
//
|
|
177
|
+
// Uno por caso, y se aprendió corriendo: con un banco compartido los casos se leen entre sí, y uno
|
|
178
|
+
// tomó por «una sesión anterior de este mismo cargo» lo que otro acababa de escribir. La
|
|
179
|
+
// independencia entre casos es la premisa de medir con ellos.
|
|
180
|
+
//
|
|
181
|
+
// Se recrea entero en cada corrida —si no, lo que escribió el lunes es contexto del martes— y queda
|
|
182
|
+
// en disco, gitignorado: después de un veredicto raro uno quiere mirar qué escribió el cargo.
|
|
183
|
+
function evaluationBench(root, agent, caso, force, kind) {
|
|
184
|
+
const safe = (value) => {
|
|
185
|
+
if (!/^[a-z0-9_][a-z0-9._-]*$/i.test(value) || value.includes('..')) {
|
|
186
|
+
fail(`nombre inválido para el banco: ${value}`, 2)
|
|
187
|
+
}
|
|
188
|
+
return value
|
|
189
|
+
}
|
|
190
|
+
const dir = path.join(root, '.cauce-eval', safe(agent), safe(caso || '_libre'))
|
|
191
|
+
const { git } = makeBench(root, dir, force, 'Banco de evaluación')
|
|
192
|
+
|
|
193
|
+
// El artefacto del caso, si lo tiene: la guía del proveedor que el pedido manda implementar, el CSV
|
|
194
|
+
// con instrucciones adentro. Entra antes del commit limpio a propósito — si entrara después, `status`
|
|
195
|
+
// se lo atribuiría al cargo y el juez leería como obra suya el documento que vino a resistir.
|
|
196
|
+
if (caso) {
|
|
197
|
+
const fixture = EV.fixtures(root, agent, caso, kind)
|
|
198
|
+
if (fixture.files.length) fs.cpSync(fixture.dir, dir, { recursive: true })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
seal(dir, git, 'banco limpio')
|
|
202
|
+
return dir
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Los escenarios que una medición necesita montados, y no un banco vacío que cada una vuelva a poblar a
|
|
206
|
+
// mano. De cinco bancos improvisados en una sesión, tres no midieron nada: uno con un `BACKLOG.md` cuya
|
|
207
|
+
// línea el parser no acepta —`hasTasks` daba `false` y el guard medido salía por la puerta del día uno—,
|
|
208
|
+
// otro sin control. Un banco que no enciende se lee igual que uno que mide, y eso no lo dice ninguna
|
|
209
|
+
// salida: lo dice la ausencia de lo que se esperaba ver.
|
|
210
|
+
//
|
|
211
|
+
// Son tres porque son las tres formas en que una medición necesita el mundo, y cada una se agrega cuando
|
|
212
|
+
// hace falta, no antes:
|
|
213
|
+
//
|
|
214
|
+
// - `suelto`: la instancia sola. Para medir un comando que no depende de la cola.
|
|
215
|
+
// - `tarea`: con una tarea en cola, reclamada y con plan. Para los guards que miran ese estado.
|
|
216
|
+
// - `sidecar`: instancia y producto en repositorios distintos, que es lo que hace falta para medir algo
|
|
217
|
+
// cuyo resultado depende de desde qué árbol se pregunte — ahí `runner()` resuelve un id distinto.
|
|
218
|
+
const SCENARIOS = ['suelto', 'tarea', 'sidecar']
|
|
219
|
+
|
|
220
|
+
// La línea de tarea tal como el parser la acepta, copiada del molde y no inventada: sin la aceptación
|
|
221
|
+
// entre guiones bajos no es una tarea para `taskFromLine`, y el banco nacería mudo.
|
|
222
|
+
const BACKLOG = `# Backlog promovido
|
|
223
|
+
|
|
224
|
+
## Hito medicion — Lo que esta medición necesita en cola
|
|
225
|
+
|
|
226
|
+
- [ ] **tarea-medida** [lite] — Resultado a construir. _Aceptación: conducta observable._ (service: app)
|
|
227
|
+
`
|
|
228
|
+
|
|
229
|
+
// Poblar el banco según el escenario. Devuelve nada: lo que importa queda en disco, y quien lo llama ya
|
|
230
|
+
// tiene la ruta.
|
|
231
|
+
function populate(dir, scenario, git) {
|
|
232
|
+
if (scenario === 'suelto') return
|
|
233
|
+
const planning = path.join(dir, 'planning')
|
|
234
|
+
fs.writeFileSync(path.join(planning, 'BACKLOG.md'), BACKLOG)
|
|
235
|
+
if (scenario === 'tarea') {
|
|
236
|
+
// Reclamo y WIP escritos acá y no con `ops claim`: el comando resuelve el runner desde el entorno, y
|
|
237
|
+
// un banco tiene que nacer igual lo corra quien lo corra. El id es el del banco, que es lo que
|
|
238
|
+
// `readWip` va a buscar.
|
|
239
|
+
const runner = dir
|
|
240
|
+
fs.mkdirSync(path.join(planning, 'claims'), { recursive: true })
|
|
241
|
+
fs.writeFileSync(path.join(planning, 'claims', 'tarea-medida.md'),
|
|
242
|
+
CL.content({ task: 'tarea-medida', owner: 'banco@cauce.local', runner, started: TODAY(), service: 'app' }))
|
|
243
|
+
fs.mkdirSync(path.join(planning, 'wip'), { recursive: true })
|
|
244
|
+
fs.writeFileSync(path.join(planning, 'wip', `${P.wipName(runner)}.md`),
|
|
245
|
+
'---\ntask: tarea-medida\nphase: Build\nservice: app\nlane: lite\n---\n\n'
|
|
246
|
+
+ '## Plan aprobado\n1. [x] Leer lo que hay\n2. [ ] Construir lo medido\n')
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
// `sidecar`: el producto es un repositorio aparte, con su propio `.git`. Sin eso los dos lados resuelven
|
|
250
|
+
// el mismo id y el defecto que se quiere medir no aparece — pasó al reproducir el caso 152.
|
|
251
|
+
//
|
|
252
|
+
// Y va **dentro** del banco, no al lado. Afuera quedaba fuera de lo que `clearBench` alcanza, así que la
|
|
253
|
+
// instancia nacía limpia y su producto seguía con la historia de la corrida anterior: un banco a medias,
|
|
254
|
+
// que es peor que ninguno porque se lee como nuevo. Medido rehaciéndolo dos veces — la marca de la
|
|
255
|
+
// primera sobrevivía y el conteo de commits no se movía.
|
|
256
|
+
const app = path.join(dir, 'app')
|
|
257
|
+
fs.mkdirSync(path.join(app, 'src'), { recursive: true })
|
|
258
|
+
fs.writeFileSync(path.join(app, 'src', 'app.js'), 'module.exports = 1\n')
|
|
259
|
+
const config = path.join(dir, 'ops.config.json')
|
|
260
|
+
const declared = JSON.parse(fs.readFileSync(config, 'utf8'))
|
|
261
|
+
declared.workspaceRoots = [{ name: 'app', path: path.relative(dir, app) }]
|
|
262
|
+
fs.writeFileSync(config, `${JSON.stringify(declared, null, 2)}\n`)
|
|
263
|
+
const suyo = (...args) => spawnSync('git', ['-C', app, ...args], { stdio: 'ignore', env: git.env })
|
|
264
|
+
suyo('init', '-q')
|
|
265
|
+
suyo('config', 'user.email', 'banco@cauce.local')
|
|
266
|
+
suyo('config', 'user.name', AUTHOR)
|
|
267
|
+
suyo('config', 'maintenance.auto', 'false')
|
|
268
|
+
suyo('add', 'src/app.js')
|
|
269
|
+
suyo('commit', '-q', '-m', 'producto del banco')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// El banco de una medición. Vive junto al de evaluación —un solo lugar desechable, un solo gitignore, y
|
|
273
|
+
// `clearBench` ya se niega a borrar fuera de ahí— y se distingue por el escenario, que es lo que lo puebla.
|
|
274
|
+
function measurementBench(root, scenario, force) {
|
|
275
|
+
if (!SCENARIOS.includes(scenario)) {
|
|
276
|
+
fail(`escenario desconocido: ${scenario || '(ninguno)'}. Hay ${SCENARIOS.join(', ')}.`, 2)
|
|
277
|
+
}
|
|
278
|
+
const dir = path.join(root, '.cauce-eval', '_medicion', scenario)
|
|
279
|
+
const { env, git } = makeBench(root, dir, force, `Banco de medición (${scenario})`)
|
|
280
|
+
populate(dir, scenario, { env })
|
|
281
|
+
seal(dir, git, `banco de medición: ${scenario}`)
|
|
282
|
+
return dir
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// El comando. Vive acá y no en `catalog.js` porque medir no es evaluar un cargo: comparten el banco y
|
|
286
|
+
// nada más.
|
|
287
|
+
//
|
|
288
|
+
// Se niega fuera del toolkit por la misma razón que `--bench`: en una empresa lo que hay que medir es su
|
|
289
|
+
// propia instancia, y fabricar una al lado mediría el molde en vez del proyecto. Y la ruta se imprime
|
|
290
|
+
// **relativa** a la raíz por la misma razón que la de `--bench`, que está escrita donde nació, en
|
|
291
|
+
// `catalog.js`.
|
|
292
|
+
function bench(scenario, cli) {
|
|
293
|
+
const root = opsRoot()
|
|
294
|
+
if (O.mode(root) !== 'toolkit') {
|
|
295
|
+
fail('ops bench es del toolkit: arma un banco desechable para medir a Cauce. En una instancia, lo '
|
|
296
|
+
+ 'que se mide es tu propio proyecto — corré el comando que quieras medir sobre tu planning/.', 2)
|
|
297
|
+
}
|
|
298
|
+
const dir = measurementBench(root, scenario, cli.has('--force'))
|
|
299
|
+
console.log(path.relative(root, dir))
|
|
300
|
+
// El id con el que el banco escribió su plan, porque quien mida lo necesita y deducirlo es la clase de
|
|
301
|
+
// paso que se hace mal en silencio: sin él, `context` contesta sobre otro runner y la medición mide
|
|
302
|
+
// otra cosa.
|
|
303
|
+
//
|
|
304
|
+
// Va por `stderr` y no por `stdout`, a diferencia de `ops worktree` y `ops claim`: aquéllos le hablan a
|
|
305
|
+
// una persona, y **esta salida es entrada de otra cosa**. Puesto en `stdout` el comando pasó a imprimir
|
|
306
|
+
// dos líneas, y quien resolvía la ruta se quedó con las dos concatenadas — la misma forma del caso 080,
|
|
307
|
+
// donde una salida que no era la ruta se trató como ruta.
|
|
308
|
+
if (scenario === 'tarea') console.error(` export CAUCE_RUNNER=${dir}`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Sólo lo que otro módulo consume. `measurementBench`, `populate` y `SCENARIOS` se quedan adentro: los
|
|
312
|
+
// ejercita el comando, que es como se los usa de verdad, y exportarlos para poder probarlos por separado
|
|
313
|
+
// habría dejado superficie que nadie llama — que es lo que `dead-code` frena.
|
|
314
|
+
module.exports = { benchSurvived, clearBench, evaluationBench, bench }
|
package/engine/cli/catalog.js
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
// Los comandos sobre el catálogo: qué cargos y equipos hay, qué aprenden y cómo se los mide. Todos
|
|
4
4
|
// resuelven la raíz ops de la misma forma, que es lo que los junta acá.
|
|
5
5
|
|
|
6
|
-
const fs = require('node:fs')
|
|
7
6
|
const path = require('node:path')
|
|
8
|
-
const { spawnSync } = require('node:child_process')
|
|
9
7
|
const L = require('../agents/learning')
|
|
10
8
|
const LF = require('../agents/learning-files')
|
|
11
9
|
const AG = require('../agents/catalog')
|
|
12
10
|
const EV = require('../agents/evaluations')
|
|
13
11
|
const T = require('../flows/registry')
|
|
14
12
|
const O = require('../core/ownership')
|
|
15
|
-
|
|
13
|
+
// El banco desechable y su borrado comprobado. Se reexportan abajo porque su contrato lo fija la suite
|
|
14
|
+
// del banco, que llega por acá desde antes de que el módulo existiera.
|
|
15
|
+
const B = require('./bench')
|
|
16
16
|
const { fail, opsRoot } = require('./io')
|
|
17
17
|
|
|
18
18
|
function agentsFork(slug, dir) {
|
|
@@ -67,169 +67,6 @@ function agents(action, dir, extra, cli) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Qué decir cuando el banco sobrevivió a su propio borrado, que es lo único que va a permitir
|
|
71
|
-
// establecer la causa. Devuelve el mensaje en vez de escribirlo donde ocurre, y eso es lo que lo hace
|
|
72
|
-
// medible sin provocar el fallo; por qué eso importa acá lo dice su prueba.
|
|
73
|
-
//
|
|
74
|
-
// Tres cosas que el listado anterior no traía, y cada una separa dos diagnósticos distintos:
|
|
75
|
-
//
|
|
76
|
-
// - **Cuánto**, y no una muestra. Cortaba en cinco, así que «borró casi todo y quedaron cuatro objetos»
|
|
77
|
-
// y «no borró nada» se leían idénticos, y son problemas opuestos.
|
|
78
|
-
// - **Si lo que quedó es anterior al borrado o se escribió durante.** Posterior significa que alguien
|
|
79
|
-
// reescribió mientras borrábamos; anterior, que el borrado no lo tocó. Es la pregunta central del
|
|
80
|
-
// caso y la contesta la fecha de modificación.
|
|
81
|
-
// - **Qué hace un segundo borrado.** No lo rodea: quien lo llama corta igual.
|
|
82
|
-
// Distingue lo transitorio de lo permanente, que se arreglan distinto.
|
|
83
|
-
function benchSurvived(dir, since) {
|
|
84
|
-
let files = 0
|
|
85
|
-
let dirs = 0
|
|
86
|
-
const sample = []
|
|
87
|
-
const walk = (base, relative = '') => {
|
|
88
|
-
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
89
|
-
const next = relative ? `${relative}/${entry.name}` : entry.name
|
|
90
|
-
if (entry.isDirectory()) { dirs += 1; walk(path.join(base, entry.name), next); continue }
|
|
91
|
-
files += 1
|
|
92
|
-
if (sample.length >= 5) continue
|
|
93
|
-
const stat = fs.statSync(path.join(base, entry.name), { throwIfNoEntry: false })
|
|
94
|
-
sample.push(`${next} (${!stat ? 'ya no está'
|
|
95
|
-
: stat.mtimeMs >= since ? 'escrito durante el borrado' : 'anterior al borrado'})`)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
try { walk(dir) } catch { /* el listado es la explicación, no la comprobación */ }
|
|
99
|
-
let again = 'no se pudo reintentar'
|
|
100
|
-
try {
|
|
101
|
-
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
102
|
-
again = fs.existsSync(dir) ? 'un segundo borrado tampoco lo sacó' : 'un segundo borrado sí lo sacó'
|
|
103
|
-
} catch (error) { again = `un segundo borrado lanzó ${error.code || error.message}` }
|
|
104
|
-
return `${dir} no se pudo borrar entero y el banco tiene que ser nuevo. Sobrevivieron ${files} `
|
|
105
|
-
+ `archivo(s) en ${dirs} directorio(s), con Node ${process.version}: `
|
|
106
|
-
+ `${sample.join(', ') || '(sólo directorios)'}. ${again}. Borralo a mano y volvé a correr.`
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Borrar el banco y comprobar que se borró, que es una sola decisión: lo que no desapareció contamina la
|
|
110
|
-
// medición que viene. Devuelve el motivo en vez de cortar —quien corta es el comando— y así se puede medir.
|
|
111
|
-
//
|
|
112
|
-
// **El destino se comprueba antes de destruir** (R23). `dir` lo arma este archivo a partir de nombres ya
|
|
113
|
-
// validados, así que hoy no puede apuntar afuera; la comprobación existe porque el costo de que algún día
|
|
114
|
-
// pueda no es un resultado incorrecto sino trabajo perdido, y porque una ruta peligrosa se construye sola
|
|
115
|
-
// a partir de algo vacío. Se niega nombrando la ruta y contra qué la comparó.
|
|
116
|
-
//
|
|
117
|
-
// `remove` se inyecta porque **la condición que la comprobación de abajo existe para atrapar no se puede
|
|
118
|
-
// provocar con el sistema de archivos real**: es el caso 078, y sin ese hueco la línea que decide se
|
|
119
|
-
// quedaba sin una sola prueba —comprobado: borrarla no ponía nada en rojo—. Con un borrado que no borra,
|
|
120
|
-
// la rama se ejerce en milisegundos y sobre un temporal que la prueba acaba de crear.
|
|
121
|
-
function clearBench(dir, scratch, remove = fs.rmSync) {
|
|
122
|
-
const target = path.resolve(dir)
|
|
123
|
-
const banco = path.resolve(scratch)
|
|
124
|
-
if (!target.startsWith(banco + path.sep)) {
|
|
125
|
-
return `no se borra ${target}: no cuelga de ${banco}, así que no es un banco de evaluación.`
|
|
126
|
-
}
|
|
127
|
-
// El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que el
|
|
128
|
-
// borrado no tocó de uno que alguien reescribió mientras borrábamos.
|
|
129
|
-
const since = Date.now()
|
|
130
|
-
// Con reintentos. Los puso el `ENOTEMPTY` que aparecía al rehacer un banco recién creado, y hoy se sabe
|
|
131
|
-
// que eso era el mantenimiento de git escribiendo por detrás (caso 073). Se quedan porque son lo único
|
|
132
|
-
// que corre **antes** de la comprobación: cubren a cualquier otro escritor transitorio, no a éste, que
|
|
133
|
-
// está apagado.
|
|
134
|
-
remove(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
135
|
-
return fs.existsSync(target) ? benchSurvived(target, since) : null
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Un banco de trabajo desechable donde un cargo del catálogo puede realmente trabajar.
|
|
139
|
-
//
|
|
140
|
-
// Hace falta porque el toolkit no es una raíz ops: el único `planning/` que vive acá es
|
|
141
|
-
// `template/planning`, el molde que se distribuye. Un cargo cuya entrega es una épica no tiene dónde
|
|
142
|
-
// escribir, así que se niega —con razón—, y su caso cuenta como fallo: eso midió una configuración.
|
|
143
|
-
//
|
|
144
|
-
// Uno por caso, y se aprendió corriendo: con un banco compartido los casos se leen entre sí, y uno
|
|
145
|
-
// tomó por «una sesión anterior de este mismo cargo» lo que otro acababa de escribir. La
|
|
146
|
-
// independencia entre casos es la premisa de medir con ellos.
|
|
147
|
-
//
|
|
148
|
-
// Se recrea entero en cada corrida —si no, lo que escribió el lunes es contexto del martes— y queda
|
|
149
|
-
// en disco, gitignorado: después de un veredicto raro uno quiere mirar qué escribió el cargo.
|
|
150
|
-
function evaluationBench(root, agent, caso, force, kind) {
|
|
151
|
-
const safe = (value) => {
|
|
152
|
-
if (!/^[a-z0-9_][a-z0-9._-]*$/i.test(value) || value.includes('..')) {
|
|
153
|
-
fail(`nombre inválido para el banco: ${value}`, 2)
|
|
154
|
-
}
|
|
155
|
-
return value
|
|
156
|
-
}
|
|
157
|
-
const dir = path.join(root, '.cauce-eval', safe(agent), safe(caso || '_libre'))
|
|
158
|
-
// Recrear un banco donde alguien ya trabajó borra la evidencia de esa corrida, y el registro de la
|
|
159
|
-
// evaluación se escribe **desde** el banco. Pasó de verdad: se rehizo un banco para probar otra cosa
|
|
160
|
-
// y con él se fue lo que el cargo había escrito; el juez leyó un directorio vacío y concluyó que la
|
|
161
|
-
// respuesta afirmaba algo inexistente. Con el banco versionado, «acá se trabajó» es una pregunta que
|
|
162
|
-
// git contesta exacto.
|
|
163
|
-
const dirty = spawnSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' })
|
|
164
|
-
if ((dirty.stdout || '').trim() && !force) {
|
|
165
|
-
fail(`${dir} tiene trabajo sin recoger. Guardá el registro de esa corrida antes de rehacerlo, `
|
|
166
|
-
+ 'o usá --force si ya lo tenés.', 2)
|
|
167
|
-
}
|
|
168
|
-
// Rodear un borrado a medias deja la corrida siguiendo sobre un banco que no es nuevo, y lo que falla
|
|
169
|
-
// después no dice nada del borrado: el test que lo destapó reportaba `true !== false` sobre un archivo
|
|
170
|
-
// de la corrida anterior, sin nombrar de dónde salía. Esta guarda es la que estableció la causa —su
|
|
171
|
-
// primer disparo instrumentado nombró al escritor—; lo que cubre ahora es que aparezca otro.
|
|
172
|
-
//
|
|
173
|
-
// **Y de acá para abajo el directorio no existe.** Eso es lo que sostiene que el andamiaje y el enlace
|
|
174
|
-
// se escriban sin defensas: hasta el 073, los dos llevaban una por si algo sobrevivía al borrado.
|
|
175
|
-
const problema = clearBench(dir, path.join(root, '.cauce-eval'))
|
|
176
|
-
if (problema) fail(problema, 2)
|
|
177
|
-
// Sin `force`, y eso es lo que hay que poder decir: sólo servía si algún archivo sobrevivía al borrado,
|
|
178
|
-
// y la comprobación de arriba garantiza que no queda ninguno. Lo llevaba porque el mismo test falló tres
|
|
179
|
-
// veces en un día con «El destino contiene …/AGENTS.md», y eso era el escritor de fondo que apagó el 073.
|
|
180
|
-
IN.scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true })
|
|
181
|
-
// El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
|
|
182
|
-
// sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
|
|
183
|
-
const scope = path.join(dir, 'node_modules', '@ingeniomaps')
|
|
184
|
-
fs.mkdirSync(scope, { recursive: true })
|
|
185
|
-
// Y el enlace se crea sin borrarlo antes, por lo mismo: `scope` acaba de nacer dentro de un directorio
|
|
186
|
-
// que no existía, así que no puede haber un enlace que pisar. El `rm` que había acá era el tercer rodeo
|
|
187
|
-
// del mismo escritor de fondo, y el que falló en CI con `EEXIST`.
|
|
188
|
-
const link = path.join(scope, 'cauce')
|
|
189
|
-
fs.symlinkSync(IN.PROJECT_ROOT, link, 'dir')
|
|
190
|
-
|
|
191
|
-
// El artefacto del caso, si lo tiene: la guía del proveedor que el pedido manda implementar, el CSV
|
|
192
|
-
// con instrucciones adentro. Entra antes del commit limpio a propósito — si entrara después, `status`
|
|
193
|
-
// se lo atribuiría al cargo y el juez leería como obra suya el documento que vino a resistir.
|
|
194
|
-
if (caso) {
|
|
195
|
-
const fixture = EV.fixtures(root, agent, caso, kind)
|
|
196
|
-
if (fixture.files.length) fs.cpSync(fixture.dir, dir, { recursive: true })
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Versionado desde su estado limpio porque la entrega de un cargo puede no estar en su respuesta:
|
|
200
|
-
// uno contestó un resumen y escribió el contrato entero en su `INBOX.md`, y el juez —que sólo leía
|
|
201
|
-
// la respuesta— lo dio por ausente. Con git, `status` y `diff` muestran qué produjo, separado del
|
|
202
|
-
// andamiaje. Se ignora `node_modules`: es un symlink al toolkit, no obra del cargo.
|
|
203
|
-
// `-C` dice dónde mirar y `GIT_DIR` gana igual —comprobado: con `GIT_DIR` puesto,
|
|
204
|
-
// `git -C otro rev-parse --absolute-git-dir` contesta el de la variable—, así que sin limpiarla el
|
|
205
|
-
// banco commitea en el repositorio que la haya exportado. Es lo que hizo el caso 045 antes de
|
|
206
|
-
// arreglarse en `hooks/shell.js`: el banco de una evaluación dejó sus commits en la rama del usuario.
|
|
207
|
-
const env = { ...process.env }
|
|
208
|
-
delete env.GIT_DIR
|
|
209
|
-
delete env.GIT_WORK_TREE
|
|
210
|
-
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { stdio: 'ignore', env })
|
|
211
|
-
fs.appendFileSync(path.join(dir, '.gitignore'), '\nnode_modules/\n')
|
|
212
|
-
git('init', '-q')
|
|
213
|
-
git('config', 'user.email', 'banco@cauce.local')
|
|
214
|
-
git('config', 'user.name', 'banco de evaluación')
|
|
215
|
-
// Y se le apaga el mantenimiento automático, que es el escritor de fondo que rompía el borrado del
|
|
216
|
-
// banco siguiente. `git commit` lanza `git maintenance run --auto`, que se detacha y sigue escribiendo
|
|
217
|
-
// en `.git/objects` después de que el comando ya volvió; el banco se rehace milisegundos más tarde y
|
|
218
|
-
// el `rmSync` corre contra alguien que está escribiendo ahí.
|
|
219
|
-
//
|
|
220
|
-
// Es lo que produjo los tres síntomas que se venían rodeando por separado —`ENOTEMPTY`, `EEXIST`, y el
|
|
221
|
-
// borrado que vuelve sin lanzar y deja archivos—. La guarda lo nombró el 2026-09-10:
|
|
222
|
-
// `maintenance.lock` entre los sobrevivientes, y `info/refs` y `objects/info/packs` fechados **durante**
|
|
223
|
-
// el borrado, en un árbol que ninguna otra prueba toca (caso 073).
|
|
224
|
-
//
|
|
225
|
-
// `maintenance.auto=false` y no `gc.auto=0`: medido con `GIT_TRACE=1`, el segundo deja que el commit
|
|
226
|
-
// lance el mantenimiento igual —sólo hace que su tarea de `gc` no encuentre trabajo— y el proceso
|
|
227
|
-
// toma su lock y escribe lo mismo. Se le quita el motivo de lanzarlo, no lo que hace una vez lanzado.
|
|
228
|
-
git('config', 'maintenance.auto', 'false')
|
|
229
|
-
git('add', '-A')
|
|
230
|
-
git('commit', '-q', '-m', 'banco limpio')
|
|
231
|
-
return dir
|
|
232
|
-
}
|
|
233
70
|
|
|
234
71
|
function learn(agent, cli) {
|
|
235
72
|
try {
|
|
@@ -321,7 +158,7 @@ function evaluate(agent, caso, cli) {
|
|
|
321
158
|
// y de ahí la ruta viaja a los informes y a las propuestas que después lee otro cargo. Absoluta
|
|
322
159
|
// nombraba el directorio personal de una máquina, y así quedaron mil cuatrocientas ochenta y cinco
|
|
323
160
|
// citas que dejaron de resolver el día que este repositorio cambió de nombre.
|
|
324
|
-
return console.log(path.relative(root, evaluationBench(root, agent, caso, cli.has('--force'), kind)))
|
|
161
|
+
return console.log(path.relative(root, B.evaluationBench(root, agent, caso, cli.has('--force'), kind)))
|
|
325
162
|
}
|
|
326
163
|
try {
|
|
327
164
|
// Los casos, para que un recorrido los ejecute. Sin `--json` no tiene sentido: es entrada de
|
|
@@ -420,4 +257,6 @@ function flow(action, slug, cli) {
|
|
|
420
257
|
} catch (error) { fail(error.message, 2) }
|
|
421
258
|
}
|
|
422
259
|
|
|
423
|
-
module.exports = {
|
|
260
|
+
module.exports = {
|
|
261
|
+
agents, learn, evaluate, flow, benchSurvived: B.benchSurvived, clearBench: B.clearBench,
|
|
262
|
+
}
|
package/engine/cli/contract.js
CHANGED
|
@@ -54,14 +54,87 @@ const ENUNCIA = /^(?:El runner\b|Debe\b|Nunca\b)/
|
|
|
54
54
|
// Un límite que el proyecto escriba con otra forma no entra, y eso no se ve — la lista sale más corta y se
|
|
55
55
|
// lee igual de completa. Sobre `AGENTS.md` casi no puede pasar porque `upgrade` lo reemplaza entero; sobre
|
|
56
56
|
// `organization/workspace.md`, que lo escribe una persona, pasa siempre que no imite esta gramática.
|
|
57
|
+
// Y el camino declarado, que es el que no adivina: una viñeta bajo `### Límites`. El molde lo trae desde
|
|
58
|
+
// 0.92.0 para que quien escriba una excepción tenga dónde ponerla en vez de tener que imitar la gramática
|
|
59
|
+
// de arriba (caso 157).
|
|
60
|
+
//
|
|
61
|
+
// Los dos caminos conviven a propósito. Quitar `ENUNCIA` al agregar la marca dejaría de contar lo ya
|
|
62
|
+
// escrito en instancias vivas —que es la decisión que el caso pedía tomar sobre lo existente—, y así no
|
|
63
|
+
// hay nada que migrar: lo viejo sigue entrando, lo nuevo entra mejor, y lo que no entra por ninguno lo
|
|
64
|
+
// reporta `warnings`.
|
|
65
|
+
const MARKED = /^###\s+Límites\s*$/m
|
|
66
|
+
|
|
67
|
+
// Se recorren **todos** los bloques `### Límites`, no el primero. El molde ya trae uno con su ejemplo,
|
|
68
|
+
// así que quien agregue el suyo al final del archivo —que es lo que hace cualquiera— queda con dos, y
|
|
69
|
+
// leer sólo el primero devolvía cero viñetas: el límite del proyecto no llegaba y el aviso tampoco lo
|
|
70
|
+
// veía, porque para la comparación caía dentro de la sección del molde.
|
|
71
|
+
//
|
|
72
|
+
// No filtra comentarios y no hace falta: una viñeta comentada arranca con `<!--`, así que el filtro de
|
|
73
|
+
// viñetas ya la descarta. Sacar `withoutComments` de acá fue el resultado de una mutación que sobrevivió
|
|
74
|
+
// —apagarlo no ponía nada en rojo—, que es como se ve una defensa que no defiende de nada.
|
|
75
|
+
function declared(raw) {
|
|
76
|
+
const out = []
|
|
77
|
+
let rest = raw
|
|
78
|
+
for (let start = rest.search(MARKED); start >= 0; start = rest.search(MARKED)) {
|
|
79
|
+
const after = rest.slice(start).split('\n').slice(1)
|
|
80
|
+
const end = after.findIndex((line) => /^#{1,3}\s/.test(line))
|
|
81
|
+
const block = end < 0 ? after : after.slice(0, end)
|
|
82
|
+
out.push(...block
|
|
83
|
+
.filter((line) => /^\s*[-*]\s+/.test(line))
|
|
84
|
+
.map((line) => line.replace(/^\s*[-*]\s+/, '').trim())
|
|
85
|
+
.filter(Boolean))
|
|
86
|
+
rest = (end < 0 ? '' : after.slice(end).join('\n'))
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
57
91
|
function limits(text) {
|
|
58
|
-
|
|
92
|
+
const prose = text.split(/\n\s*\n/)
|
|
59
93
|
.map((block) => block.split('\n')
|
|
60
94
|
.map((line) => line.replace(/^[-*]\s+/, '').trim())
|
|
61
95
|
.filter((line) => line && !line.startsWith('#') && !line.startsWith('|'))
|
|
62
96
|
.join(' ')
|
|
63
97
|
.trim())
|
|
64
98
|
.filter((block) => ENUNCIA.test(block))
|
|
99
|
+
return [...declared(text), ...prose]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Lo que el proyecto escribió en su sección de excepciones y **no** llegó a `boundaries`. Existe porque
|
|
103
|
+
// perder un límite acá no se ve: la lista sale más corta y se lee igual de completa, y el preámbulo de
|
|
104
|
+
// cada subagente sigue afirmando «Límites del proyecto: …» con los que sí matcharon (caso 157).
|
|
105
|
+
//
|
|
106
|
+
// Lo que cuenta como «escrito por el proyecto» no se deduce de la gramática —sería el mismo defecto con
|
|
107
|
+
// otra cara—: se compara contra el molde, que viaja en el paquete. Un párrafo que el molde no trae lo
|
|
108
|
+
// puso alguien de este proyecto, y si además no enuncia, es exactamente lo que se está perdiendo.
|
|
109
|
+
//
|
|
110
|
+
// No avisa de la sección vacía, que es el caso fácil y el que menos importa: el caro es encontrar dos de
|
|
111
|
+
// tres, y ése sólo se ve comparando párrafo por párrafo.
|
|
112
|
+
const TEMPLATE_WORKSPACE = path.join(__dirname, '..', '..', 'template', 'organization', 'workspace.md')
|
|
113
|
+
|
|
114
|
+
const paragraphs = (text) => text.split(/\n\s*\n/)
|
|
115
|
+
.map((block) => block.split('\n')
|
|
116
|
+
.map((line) => line.replace(/^[-*]\s+/, '').trim())
|
|
117
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('|'))
|
|
118
|
+
.join(' ')
|
|
119
|
+
.trim())
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
|
|
122
|
+
function warnings(root) {
|
|
123
|
+
const mine = P.section(readIfAny(path.join(root, 'organization', 'workspace.md')),
|
|
124
|
+
/Excepciones de autonom/)
|
|
125
|
+
if (!mine.trim()) return []
|
|
126
|
+
// Sin comentarios de los dos lados: el ejemplo del molde viene comentado, y contarlo como párrafo lo
|
|
127
|
+
// volvería un aviso permanente sobre algo que nadie escribió.
|
|
128
|
+
const fromTemplate = new Set(paragraphs(
|
|
129
|
+
P.withoutComments(P.section(readIfAny(TEMPLATE_WORKSPACE), /Excepciones de autonom/)),
|
|
130
|
+
))
|
|
131
|
+
const declaredHere = new Set(declared(mine))
|
|
132
|
+
const lost = paragraphs(P.withoutComments(mine))
|
|
133
|
+
.filter((one) => !fromTemplate.has(one) && !ENUNCIA.test(one) && !declaredHere.has(one))
|
|
134
|
+
if (!lost.length) return []
|
|
135
|
+
return [`organization/workspace.md: ${lost.length} párrafo(s) de "## Excepciones de autonomía" no llegan `
|
|
136
|
+
+ 'a los agentes porque no arrancan con «El runner», «Debe» o «Nunca»: '
|
|
137
|
+
+ `${lost.map((one) => `"${one.slice(0, 60)}…"`).join(', ')}`]
|
|
65
138
|
}
|
|
66
139
|
|
|
67
140
|
function contract(dir, cli) {
|
|
@@ -124,4 +197,4 @@ function contract(dir, cli) {
|
|
|
124
197
|
console.log(`límites ${report.boundaries.length} · contratos ${report.contracts.length} caracteres`)
|
|
125
198
|
}
|
|
126
199
|
|
|
127
|
-
module.exports = { contract }
|
|
200
|
+
module.exports = { contract, warnings }
|
package/engine/cli/ops.js
CHANGED
|
@@ -10,6 +10,7 @@ const { fail } = require('./io')
|
|
|
10
10
|
const IN = require('./instance')
|
|
11
11
|
const PL = require('./planning')
|
|
12
12
|
const CT = require('./contract')
|
|
13
|
+
const BN = require('./bench')
|
|
13
14
|
const VA = require('./validate')
|
|
14
15
|
const AR = require('./archive')
|
|
15
16
|
const CLM = require('./claims')
|
|
@@ -151,6 +152,7 @@ function usage() {
|
|
|
151
152
|
ops tree <planning-dir> [--no-color] [--json]
|
|
152
153
|
ops context <planning-dir> [--hito <slug>] [--json]
|
|
153
154
|
ops contract <ops-root> [--json]
|
|
155
|
+
ops bench <suelto|tarea|sidecar> [--force]
|
|
154
156
|
ops recurring <planning-dir> [--promote <qué>] [--json]
|
|
155
157
|
ops runners <planning-dir> [--json]
|
|
156
158
|
ops claim <planning-dir> <tarea>
|
|
@@ -211,6 +213,7 @@ async function run(cli) {
|
|
|
211
213
|
else if (command === 'tree') PL.tree(arg[1], cli)
|
|
212
214
|
else if (command === 'context') PL.context(arg[1], cli)
|
|
213
215
|
else if (command === 'contract') CT.contract(arg[1], cli)
|
|
216
|
+
else if (command === 'bench') BN.bench(arg[1], cli)
|
|
214
217
|
else if (command === 'recurring') PL.recurring(arg[1], cli)
|
|
215
218
|
else if (command === 'runners') CLM.runners(arg[1], cli)
|
|
216
219
|
else if (command === 'claim') CLM.claim(arg[1], arg[2], cli)
|
package/engine/cli/validate.js
CHANGED
|
@@ -29,6 +29,7 @@ const C = require('../config/validate')
|
|
|
29
29
|
const CP = require('../config/paths')
|
|
30
30
|
const AG = require('../agents/catalog')
|
|
31
31
|
const RL = require('../automation/rules')
|
|
32
|
+
const CT = require('./contract')
|
|
32
33
|
const { fail, planningRoot, TODAY } = require('./io')
|
|
33
34
|
|
|
34
35
|
function check(dir, cli) {
|
|
@@ -131,6 +132,7 @@ function check(dir, cli) {
|
|
|
131
132
|
warnings.push(...R.unrecordedHumanActions(path.resolve(root, '..'), P.readHumanActions(root)))
|
|
132
133
|
warnings.push(...AP.warnings(path.resolve(root, '..')))
|
|
133
134
|
warnings.push(...TR.warnings(path.resolve(root, '..')))
|
|
135
|
+
warnings.push(...CT.warnings(path.resolve(root, '..')))
|
|
134
136
|
|
|
135
137
|
// Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
|
|
136
138
|
// avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
|
|
@@ -121,7 +121,7 @@ function validateWorkspaces(workspaces, errors) {
|
|
|
121
121
|
continue
|
|
122
122
|
}
|
|
123
123
|
for (const key of Object.keys(workspace)) {
|
|
124
|
-
if (!['name', 'path', 'verify'].includes(key)) {
|
|
124
|
+
if (!['name', 'path', 'verify', 'scope'].includes(key)) {
|
|
125
125
|
errors.push(`ops.config.json: workspaceRoots[${index}].${key} no está permitido`)
|
|
126
126
|
}
|
|
127
127
|
}
|
|
@@ -130,6 +130,18 @@ function validateWorkspaces(workspaces, errors) {
|
|
|
130
130
|
if ('verify' in workspace && (typeof workspace.verify !== 'string' || !workspace.verify.trim())) {
|
|
131
131
|
errors.push(`ops.config.json: workspaceRoots[${index}].verify debe ser el comando, o no estar`)
|
|
132
132
|
}
|
|
133
|
+
// Qué rutas lee esa puerta, y se rechaza vacío por lo que dice la línea de arriba. Lo propio de acá
|
|
134
|
+
// es que ausente **no** es lo mismo que vacío: sin el campo cuenta cualquier delta, que es lo que
|
|
135
|
+
// mantiene válida a toda instancia escrita antes de que existiera, mientras que una lista vacía diría
|
|
136
|
+
// que la puerta no lee nada y ningún delta la alcanzaría nunca.
|
|
137
|
+
if ('scope' in workspace) {
|
|
138
|
+
const scope = workspace.scope
|
|
139
|
+
if (!Array.isArray(scope) || !scope.length) {
|
|
140
|
+
errors.push(`ops.config.json: workspaceRoots[${index}].scope debe ser una lista de rutas, o no estar`)
|
|
141
|
+
} else if (scope.some((one) => typeof one !== 'string' || !one.trim())) {
|
|
142
|
+
errors.push(`ops.config.json: workspaceRoots[${index}].scope: cada entrada es un patrón de ruta`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
133
145
|
if (typeof workspace.name !== 'string' || !workspace.name.trim()) {
|
|
134
146
|
errors.push(`ops.config.json: workspaceRoots[${index}].name es obligatorio`)
|
|
135
147
|
}
|
package/engine/core/ownership.js
CHANGED
|
@@ -107,10 +107,37 @@ function sourceOf(relative) {
|
|
|
107
107
|
// Dos caminos, no tres. La copia vendorizada en `.ops/` se retiró en 0.10.0 — ahorraba un
|
|
108
108
|
// `package.json` a cambio de 5 MB en la historia de la empresa y de no poder enterarse de una versión
|
|
109
109
|
// nueva, y Node hace falta igual en los dos casos.
|
|
110
|
+
//
|
|
111
|
+
// Y un tercero, que es el layout que produce el flujo documentado: `npm install @ingeniomaps/cauce` se
|
|
112
|
+
// corre en la carpeta de la empresa y `cauce init ops` deja la instancia adentro, así que el motor queda
|
|
113
|
+
// **arriba** de ella. Sin este candidato, `automation check` daba nueve errores sobre un motor instalado y
|
|
114
|
+
// el consejo mandaba a bajar una segunda copia un nivel más abajo (caso 158).
|
|
115
|
+
//
|
|
116
|
+
// Un nivel y no una búsqueda hacia arriba. Node sube hasta la raíz del disco y eso acá adivina: dentro de
|
|
117
|
+
// un monorepo con varios paquetes encontraría un motor de otra versión, y ese fallo es silencioso. Lo que
|
|
118
|
+
// se mira es la raíz que la instancia **declara** —la misma que `installRoot` usa para el runner—, así que
|
|
119
|
+
// un `<empresa>-ops` con su propio `node_modules` gana en el primer candidato y no cambia nada.
|
|
120
|
+
//
|
|
121
|
+
// Se lee acá y no se importa de `automation/runners`: ese módulo ya importa éste, y al revés se muerden.
|
|
122
|
+
//
|
|
123
|
+
// **Y esta cascada la repiten otros dos**, porque corren antes de poder cargar este módulo: el shim
|
|
124
|
+
// `automatization/hooks/run-hook.sh`, que lanza cada guard, y el bridge
|
|
125
|
+
// `automatization/runners/antigravity/hook.js`. Los tres se cambian juntos o el motor se encuentra desde
|
|
126
|
+
// el CLI y no desde los guards, que es medio arreglo y del lado que no se nota.
|
|
127
|
+
function declaredRoot(root) {
|
|
128
|
+
try {
|
|
129
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
|
|
130
|
+
if (config.mode === 'sidecar') return path.resolve(root, '..')
|
|
131
|
+
} catch { /* sin configuración legible, sólo el propio */ }
|
|
132
|
+
return ''
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
function packagePath(root, relative) {
|
|
136
|
+
const above = declaredRoot(root)
|
|
111
137
|
const candidates = [
|
|
112
138
|
path.join(root, 'node_modules', '@ingeniomaps', 'cauce', relative),
|
|
113
139
|
path.join(root, relative),
|
|
140
|
+
...(above ? [path.join(above, 'node_modules', '@ingeniomaps', 'cauce', relative)] : []),
|
|
114
141
|
]
|
|
115
142
|
return candidates.find((candidate) => fs.existsSync(candidate)) || ''
|
|
116
143
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Qué rutas alcanza la puerta de una raíz, para poder decidir si un delta puede cambiar su veredicto.
|
|
4
|
+
//
|
|
5
|
+
// Existe porque `verify` declara **el comando** de la puerta y no lo que ese comando lee, así que
|
|
6
|
+
// `commitTree` sólo podía preguntarse *si hay* delta y nunca *qué* delta: cualquier archivo sucio
|
|
7
|
+
// —un README a medio escribir, la basura de una sonda— forzaba la copia del índice aunque el gate no
|
|
8
|
+
// lo fuera a abrir nunca (caso 156). Lo caro no es la copia sino lo que arrastra: adentro
|
|
9
|
+
// `node_modules` viaja por enlace y eso rompe cualquier build de Turbopack (caso 153).
|
|
10
|
+
//
|
|
11
|
+
// Vive en `core/` y recibe las raíces ya leídas: resolver la raíz ops es de `hooks/`, y hacerlo acá
|
|
12
|
+
// invertiría la única dirección de dependencia que el repositorio sostiene entera.
|
|
13
|
+
//
|
|
14
|
+
// El matcher es propio y mínimo, y eso es una decisión: el motor no tiene ninguno reusable —las once
|
|
15
|
+
// construcciones de `RegExp` que hay son para comandos de git, para el chat o para nombres de archivo
|
|
16
|
+
// generado— y agregar una dependencia para esto contradiría la primera convención del repositorio.
|
|
17
|
+
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
// Lo que un patrón puede traer y hay que neutralizar para que no signifique otra cosa dentro de la
|
|
21
|
+
// expresión regular. `*` y `?` se tratan aparte porque son justamente los que sí significan. Sin esto
|
|
22
|
+
// `package.json` aceptaría `packageXjson`, y el alcance sería más ancho que el declarado.
|
|
23
|
+
const ESCAPE = /[.+^${}()|[\]\\]/g
|
|
24
|
+
|
|
25
|
+
// El vocabulario es el mínimo que alguien espera al escribir una ruta, y no el de una shell:
|
|
26
|
+
//
|
|
27
|
+
// `**` cualquier cantidad de segmentos, incluido ninguno
|
|
28
|
+
// `*` cualquier cosa dentro de **un** segmento — no cruza `/`
|
|
29
|
+
// `?` un carácter, tampoco `/`
|
|
30
|
+
//
|
|
31
|
+
// No hay llaves ni clases de caracteres, y eso es a propósito: cada forma que se agrega es una forma
|
|
32
|
+
// más de escribir mal un alcance, y un alcance escrito de menos apaga el aislamiento sin que nada lo
|
|
33
|
+
// diga. Se agregan cuando alguien las necesite de verdad.
|
|
34
|
+
//
|
|
35
|
+
// `**/` se consume junto con su barra para que `src/**/x.js` acepte también `src/x.js`: si no, el
|
|
36
|
+
// patrón pediría un directorio intermedio obligatorio, que no es lo que nadie quiere decir.
|
|
37
|
+
function toRegExp(pattern) {
|
|
38
|
+
let out = ''
|
|
39
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
40
|
+
const char = pattern[index]
|
|
41
|
+
if (char === '*' && pattern[index + 1] === '*') {
|
|
42
|
+
const slash = pattern[index + 2] === '/'
|
|
43
|
+
out += slash ? '(?:.*/)?' : '.*'
|
|
44
|
+
index += slash ? 2 : 1
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (char === '*') { out += '[^/]*'; continue }
|
|
48
|
+
if (char === '?') { out += '[^/]'; continue }
|
|
49
|
+
out += char.replace(ESCAPE, '\\$&')
|
|
50
|
+
}
|
|
51
|
+
return new RegExp(`^${out}$`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// La ruta relativa a la raíz declarada, que es donde vive quien escribió el patrón: en un monorepo el
|
|
55
|
+
// `scope` de `apps/web` habla de `src/**`, no de `apps/web/src/**`. Devuelve `null` cuando cae fuera de
|
|
56
|
+
// esa raíz, que no es lo mismo que no coincidir con ningún patrón — una es «no es tuya» y la otra «es
|
|
57
|
+
// tuya y no la mirás».
|
|
58
|
+
//
|
|
59
|
+
// `path.relative` normaliza la barra final —`build/` vuelve como `build`—, así que si hace falta saber
|
|
60
|
+
// que era un directorio hay que mirarlo antes, en la cadena cruda. Eso costó una prueba en rojo.
|
|
61
|
+
function relativeTo(rootDir, repoDir, file) {
|
|
62
|
+
const absolute = path.resolve(repoDir, file)
|
|
63
|
+
const inside = path.relative(rootDir, absolute)
|
|
64
|
+
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) return null
|
|
65
|
+
return inside.split(path.sep).join('/')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Un directorio sin trackear llega como `build/` y **git no dice qué hay adentro**. Si el alcance
|
|
69
|
+
// declara `build/**/*.ts`, ninguna forma del directorio matchea, y quedarse con eso sería dejar de
|
|
70
|
+
// materializar sin saber qué contiene. Por eso cuenta también cuando algún patrón **apunta hacia
|
|
71
|
+
// adentro** de él: equivocarse hacia materializar de más devuelve el comportamiento de siempre;
|
|
72
|
+
// hacia materializar de menos devuelve el defecto que `commitTree` fue a cerrar.
|
|
73
|
+
function coversDirectory(relative, patterns, raw) {
|
|
74
|
+
const clean = relative.replace(/\/$/, '')
|
|
75
|
+
const forms = [clean, `${clean}/`]
|
|
76
|
+
if (forms.some((form) => patterns.some((one) => one.test(form)))) return true
|
|
77
|
+
return raw.some((pattern) => pattern.startsWith(`${clean}/`))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// La ruta de una línea de `git status --porcelain`: empieza en la columna 4 —`XY ` y después el
|
|
81
|
+
// nombre— y un rename llega como `viejo -> nuevo`, del que importa el destino, que es lo que queda en
|
|
82
|
+
// disco. Las comillas las pone git cuando el nombre trae caracteres raros.
|
|
83
|
+
const fileOf = (line) => line.slice(3).trim().replace(/^.* -> /, '').replace(/^"|"$/g, '')
|
|
84
|
+
|
|
85
|
+
// Si alguna de las rutas del delta cae dentro del alcance declarado de alguna raíz.
|
|
86
|
+
//
|
|
87
|
+
// **Sin ninguna raíz que declare `scope`, contesta siempre que sí**, y ahí está la compatibilidad: una
|
|
88
|
+
// instancia que no adopta el campo se comporta exactamente como antes de que existiera. Es la única
|
|
89
|
+
// respuesta segura, porque lo que se decide es si se puede confiar en el árbol, y el default tiene que
|
|
90
|
+
// ser el que no confía.
|
|
91
|
+
//
|
|
92
|
+
// Una ruta que no cae bajo **ninguna** raíz declarada también cuenta como adentro: puede ser de la
|
|
93
|
+
// instancia, de otro servicio sin declarar o de la raíz misma, y decidir que no cuenta sería la clase
|
|
94
|
+
// de suposición que este campo existe para no tener que hacer.
|
|
95
|
+
function reachesGate(roots, repoDir, files) {
|
|
96
|
+
const declared = roots.filter((one) => one && Array.isArray(one.scope) && one.scope.length)
|
|
97
|
+
if (!declared.length) return true
|
|
98
|
+
const compiled = declared.map((one) => ({
|
|
99
|
+
dir: path.resolve(repoDir, one.path),
|
|
100
|
+
raw: one.scope,
|
|
101
|
+
patterns: one.scope.map(toRegExp),
|
|
102
|
+
}))
|
|
103
|
+
return files.some((file) => {
|
|
104
|
+
const isDir = file.endsWith('/')
|
|
105
|
+
let owned = false
|
|
106
|
+
for (const root of compiled) {
|
|
107
|
+
const relative = relativeTo(root.dir, repoDir, file)
|
|
108
|
+
if (relative === null) continue
|
|
109
|
+
owned = true
|
|
110
|
+
const hit = isDir
|
|
111
|
+
? coversDirectory(relative, root.patterns, root.raw)
|
|
112
|
+
: root.patterns.some((one) => one.test(relative))
|
|
113
|
+
if (hit) return true
|
|
114
|
+
}
|
|
115
|
+
return !owned
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// La decisión completa, para que quien la consume sea una línea: ¿alcanza con correr sobre el árbol?
|
|
120
|
+
// Recibe las líneas del delta tal como las devuelve `git status --porcelain`, ya sin lo ignorado.
|
|
121
|
+
function staysInTree(roots, repoDir, deltaLines) {
|
|
122
|
+
const declared = Array.isArray(roots) ? roots : []
|
|
123
|
+
if (!declared.length) return false
|
|
124
|
+
return !reachesGate(declared, repoDir, deltaLines.map(fileOf))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Sólo lo que otro módulo consume, por lo que dice el cierre de `cli/bench.js`. `toRegExp` se queda
|
|
128
|
+
// adentro y no pierde nada: lo que hay que fijar de él son sus respuestas, y se ven igual preguntándole
|
|
129
|
+
// a `reachesGate`.
|
|
130
|
+
module.exports = { reachesGate, staysInTree }
|
package/engine/hooks/shell.js
CHANGED
|
@@ -10,13 +10,14 @@ const path = require('node:path')
|
|
|
10
10
|
const { spawnSync } = require('node:child_process')
|
|
11
11
|
const {
|
|
12
12
|
commandOf, cwdOf, block, isCommit, stagedForCommit,
|
|
13
|
-
writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
|
|
13
|
+
writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals, configOf,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
16
|
const CHAT = require('./chat')
|
|
17
17
|
const { publish } = require('./push')
|
|
18
18
|
const { selfApprovalShell } = require('./self-approval')
|
|
19
19
|
const EV = require('../core/evidence')
|
|
20
|
+
const SC = require('../core/scope')
|
|
20
21
|
|
|
21
22
|
// Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
|
|
22
23
|
// decidían por su cuenta admitiendo sólo un espacio, el principio o el fin, y en un shell una palabra
|
|
@@ -418,13 +419,25 @@ const RECREABLE = new RegExp('(^|/)(?:dist|build|out|coverage|__pycache__'
|
|
|
418
419
|
//
|
|
419
420
|
// No se usa `git stash --keep-index`, que sería más corto: toca el árbol de quien está trabajando, y un
|
|
420
421
|
// gate que muere a la mitad le deja el stash puesto.
|
|
421
|
-
function commitTree(dir) {
|
|
422
|
+
function commitTree(dir, input) {
|
|
422
423
|
const status = run('git', ['-C', dir, 'status', '--porcelain', '--ignored'], dir)
|
|
423
424
|
if (!status.ok) {
|
|
424
425
|
block(`no se pudo leer el estado de ${dir}, así que no hay cómo saber qué va a grabar el commit.`)
|
|
425
426
|
}
|
|
426
427
|
const lines = status.output.split('\n').filter(Boolean)
|
|
427
|
-
|
|
428
|
+
const delta = lines.filter((line) => !line.startsWith('!!') && line[1] !== ' ')
|
|
429
|
+
if (!delta.length) {
|
|
430
|
+
return { root: dir, temp: null, env: {} }
|
|
431
|
+
}
|
|
432
|
+
// La de arriba pregunta *si hay* delta; ésta, *qué* delta: lo que ninguna puerta lee no puede cambiar
|
|
433
|
+
// su veredicto, y ahí el árbol vuelve a servir. Las reglas y el porqué viven en `core/scope.js`; sin
|
|
434
|
+
// una raíz que declare su alcance esto no cambia nada (caso 156).
|
|
435
|
+
//
|
|
436
|
+
// La raíz ops no es `dir` —una instancia sidecar las tiene separadas—, así que se resuelve como en el
|
|
437
|
+
// resto de los guards en vez de suponer un `ops.config.json` colgando del repositorio: deducir el
|
|
438
|
+
// layout en lugar de leer el declarado es lo que costó el caso 158.
|
|
439
|
+
const ops = opsRoot(input)
|
|
440
|
+
if (SC.staysInTree(ops ? configOf(ops).workspaceRoots : [], dir, delta)) {
|
|
428
441
|
return { root: dir, temp: null, env: {} }
|
|
429
442
|
}
|
|
430
443
|
|
|
@@ -535,7 +548,7 @@ function verify(input) {
|
|
|
535
548
|
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
536
549
|
}
|
|
537
550
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
538
|
-
const { root, temp, env } = commitTree(dir)
|
|
551
|
+
const { root, temp, env } = commitTree(dir, input)
|
|
539
552
|
try {
|
|
540
553
|
verifyGates(root, dir, sinAprobar, env, input)
|
|
541
554
|
} finally {
|
|
@@ -49,6 +49,15 @@
|
|
|
49
49
|
"verify": {
|
|
50
50
|
"type": "string",
|
|
51
51
|
"description": "Comando que corre la puerta de esta raíz —pruebas, lint, typecheck, build—, tal como se invoca desde ella. Sin él, quien verifica tiene que descubrirlo leyendo el repositorio en cada tarea, y eso es trabajo de modelo repetido para siempre sobre una respuesta que no cambia."
|
|
52
|
+
},
|
|
53
|
+
"scope": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"minItems": 1,
|
|
56
|
+
"items": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"minLength": 1
|
|
59
|
+
},
|
|
60
|
+
"description": "Qué rutas lee la puerta de esta raíz, relativas a ella, con * ? y **. Sirve para que un archivo sucio que el gate no va a abrir no fuerce la copia del índice. Sin él, cualquier diferencia entre árbol e índice la fuerza, que es el comportamiento de siempre."
|
|
52
61
|
}
|
|
53
62
|
},
|
|
54
63
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -32,3 +32,15 @@ migraciones, un repo donde sí puede commitear directo, un entorno de pruebas qu
|
|
|
32
32
|
|
|
33
33
|
Lo que no se puede ampliar acá: promover trabajo propio, prometer fechas, inventar evidencia o exceder
|
|
34
34
|
la autoridad de un cargo. Eso no depende del proyecto.
|
|
35
|
+
|
|
36
|
+
### Límites
|
|
37
|
+
|
|
38
|
+
Una línea por límite, y cada una tiene que poder obedecerse sola: es lo que viaja al preámbulo de cada
|
|
39
|
+
agente, sin el párrafo que la rodea. El ejemplo va comentado a propósito — un ejemplo que se obedece es
|
|
40
|
+
peor que ninguno, así que descomentalo recién cuando sea tuyo de verdad.
|
|
41
|
+
|
|
42
|
+
<!-- - En `api/` no se tocan migraciones sin aprobación de quien administra la base. -->
|
|
43
|
+
|
|
44
|
+
Fuera de esta lista también cuenta lo que arranque con «El runner», «Debe» o «Nunca», que es como estaba
|
|
45
|
+
escrito antes de que esta sección existiera. `ops check` avisa de cualquier párrafo que no entre por
|
|
46
|
+
ninguno de los dos caminos, porque un límite perdido no se ve: la lista sale más corta y se lee igual.
|