@ingeniomaps/cauce 0.91.0 → 0.93.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.
@@ -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 }
@@ -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
- const IN = require('./instance')
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 = { agents, learn, evaluate, flow, benchSurvived, clearBench }
260
+ module.exports = {
261
+ agents, learn, evaluate, flow, benchSurvived: B.benchSurvived, clearBench: B.clearBench,
262
+ }
@@ -54,14 +54,114 @@ 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
+ // Un solo recorrido de los bloques: las viñetas, que son los límites declarados, y la prosa que las
76
+ // presenta. Las dos salen de acá porque dónde empieza y dónde termina un bloque se decide una vez; con
77
+ // dos recorridos, el que delimita para `limits` y el que delimita para `warnings` se despegan y nada
78
+ // falla.
79
+ //
80
+ // El intro se corta en la primera viñeta y no al final del bloque, y eso es lo único que separa este
81
+ // arreglo de un silenciador. A un bloque no lo cierra nada más que el próximo encabezado, así que el del
82
+ // molde se extiende hasta donde alguien escriba el suyo: medido sobre un banco, el primer bloque se
83
+ // llevaba adentro los cuatro párrafos que la persona había agregado al final de la sección. Descontar el
84
+ // bloque entero —que es lo que parecía el arreglo— apagaba justo lo que el aviso existe para encontrar.
85
+ const BULLET = /^\s*[-*]\s+/
86
+ function marked(raw) {
87
+ const bullets = []
88
+ const intros = []
89
+ let rest = raw
90
+ for (let start = rest.search(MARKED); start >= 0; start = rest.search(MARKED)) {
91
+ const after = rest.slice(start).split('\n').slice(1)
92
+ const end = after.findIndex((line) => /^#{1,3}\s/.test(line))
93
+ const block = end < 0 ? after : after.slice(0, end)
94
+ const first = block.findIndex((line) => BULLET.test(line))
95
+ if (first > 0) intros.push(...block.slice(0, first).map((line) => line.trim()).filter(Boolean))
96
+ bullets.push(...block
97
+ .filter((line) => BULLET.test(line))
98
+ .map((line) => line.replace(BULLET, '').trim())
99
+ .filter(Boolean))
100
+ rest = (end < 0 ? '' : after.slice(end).join('\n'))
101
+ }
102
+ return { bullets, intros }
103
+ }
104
+
105
+ const declared = (raw) => marked(raw).bullets
106
+
57
107
  function limits(text) {
58
- return text.split(/\n\s*\n/)
108
+ const prose = text.split(/\n\s*\n/)
59
109
  .map((block) => block.split('\n')
60
110
  .map((line) => line.replace(/^[-*]\s+/, '').trim())
61
111
  .filter((line) => line && !line.startsWith('#') && !line.startsWith('|'))
62
112
  .join(' ')
63
113
  .trim())
64
114
  .filter((block) => ENUNCIA.test(block))
115
+ return [...declared(text), ...prose]
116
+ }
117
+
118
+ // Lo que el proyecto escribió en su sección de excepciones y **no** llegó a `boundaries`. Existe porque
119
+ // 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
120
+ // cada subagente sigue afirmando «Límites del proyecto: …» con los que sí matcharon (caso 157).
121
+ //
122
+ // Lo que cuenta como «escrito por el proyecto» no se deduce de la gramática —sería el mismo defecto con
123
+ // otra cara—: se compara contra el molde, que viaja en el paquete. Un párrafo que el molde no trae lo
124
+ // puso alguien de este proyecto, y si además no enuncia, es exactamente lo que se está perdiendo.
125
+ //
126
+ // 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
127
+ // tres, y ése sólo se ve comparando párrafo por párrafo.
128
+ const TEMPLATE_WORKSPACE = path.join(__dirname, '..', '..', 'template', 'organization', 'workspace.md')
129
+
130
+ const paragraphs = (text) => text.split(/\n\s*\n/)
131
+ .map((block) => block.split('\n')
132
+ .map((line) => line.replace(/^[-*]\s+/, '').trim())
133
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('|'))
134
+ .join(' ')
135
+ .trim())
136
+ .filter(Boolean)
137
+
138
+ function warnings(root) {
139
+ const mine = P.section(readIfAny(path.join(root, 'organization', 'workspace.md')),
140
+ /Excepciones de autonom/)
141
+ if (!mine.trim()) return []
142
+ // Sin comentarios de los dos lados: el ejemplo del molde viene comentado, y contarlo como párrafo lo
143
+ // volvería un aviso permanente sobre algo que nadie escribió.
144
+ const fromTemplate = new Set(paragraphs(
145
+ P.withoutComments(P.section(readIfAny(TEMPLATE_WORKSPACE), /Excepciones de autonom/)),
146
+ ))
147
+ // Se descuenta por línea y no por párrafo, que es donde estaba el defecto: `paragraphs` saca el `- ` y
148
+ // une las viñetas seguidas en un párrafo solo, así que una lista declarada no era igual a ninguna
149
+ // entrada de `declared()` y se contaba entera como un límite perdido (caso 159).
150
+ const { bullets, intros } = marked(mine)
151
+ const suyo = new Set([...bullets, ...intros])
152
+ const outside = P.withoutComments(mine).split('\n')
153
+ .filter((line) => !suyo.has(line.replace(BULLET, '').trim()))
154
+ .join('\n')
155
+ const lost = paragraphs(outside)
156
+ .filter((one) => !fromTemplate.has(one) && !ENUNCIA.test(one))
157
+ if (!lost.length) return []
158
+ // El aviso nombra el camino declarado y no la gramática, aunque los dos sigan valiendo: desde 0.92.0
159
+ // hay una forma de arreglar esto que no pide imitar nada, y mandar a la otra es mandar al camino que
160
+ // el 157 existe para no tener que usar. Quien ya escribió «El runner…» no necesita el aviso — no le
161
+ // sale.
162
+ return [`organization/workspace.md: ${lost.length} párrafo(s) de "## Excepciones de autonomía" no llegan `
163
+ + 'a los agentes. El que sea un límite va como viñeta bajo `### Límites`: '
164
+ + `${lost.map((one) => `"${one.slice(0, 60)}…"`).join(', ')}`]
65
165
  }
66
166
 
67
167
  function contract(dir, cli) {
@@ -124,4 +224,4 @@ function contract(dir, cli) {
124
224
  console.log(`límites ${report.boundaries.length} · contratos ${report.contracts.length} caracteres`)
125
225
  }
126
226
 
127
- module.exports = { contract }
227
+ module.exports = { contract, warnings }