@ingeniomaps/cauce 0.90.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.
@@ -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
+ }
@@ -0,0 +1,200 @@
1
+ 'use strict'
2
+
3
+ // El contrato del proyecto, derivado del disco. Es lo que un recorrido necesita saber antes de la primera
4
+ // fase —cómo se llama el proyecto, dónde puede escribir, con qué se verifica, qué límites rigen y qué
5
+ // formatos exige planning— y hasta 0.91.0 lo producía un agente que leía cuatro archivos y los transcribía.
6
+ //
7
+ // Transcribir no es decidir: los diez campos salen de parsear. Seis son valores de `ops.config.json`, uno
8
+ // es copiar una sección de `PROTOCOL.md` y dos son secciones de `AGENTS.md` y `organization/workspace.md`.
9
+ // Pagarle a un modelo por eso cuesta una llamada por corrida y, sobre todo, hace que lo que viaja al
10
+ // preámbulo de cada subagente sea **lo que alguien transcribió** en vez de lo que el archivo dice (caso 154).
11
+ //
12
+ // El porqué de resolverlo con un comando y no con un agente ya está escrito donde se decidió la primera
13
+ // vez: el comentario de `readContext`, en el recorrido. Acá sólo se repite la elección, no su razón.
14
+
15
+ const fs = require('node:fs')
16
+ const path = require('node:path')
17
+ const P = require('../planning/parser')
18
+ const { fail, opsRoot } = require('./io')
19
+
20
+ // Los cuatro que componen el contrato, con la ruta relativa a la raíz de la instancia. El orden es el que
21
+ // usa el mensaje de error: se nombra el primero que falte y no los cuatro, porque arreglar uno suele
22
+ // arreglar la causa de los demás —una raíz mal apuntada los pierde todos a la vez—.
23
+ const SOURCES = ['AGENTS.md', path.join('organization', 'workspace.md'), 'ops.config.json',
24
+ path.join('planning', 'PROTOCOL.md')]
25
+
26
+ // Qué sección sostiene cada campo de texto, y de quién es el archivo que la trae. La diferencia decide qué
27
+ // pasa cuando falta, y no es una preferencia: `AGENTS.md` y `PROTOCOL.md` están en `TEMPLATE_FILES`, así que
28
+ // `upgrade` los reemplaza enteros y su sección no puede faltar en una instancia viva —si falta, algo se
29
+ // rompió y seguir entregaría límites vacíos a cada subagente—. `organization/workspace.md` es del proyecto:
30
+ // que no declare excepciones es un estado legítimo y el molde lo dice.
31
+ const REQUIRED_SECTIONS = [
32
+ { file: 'AGENTS.md', heading: /Autonom/, name: '## Autonomía' },
33
+ { file: path.join('planning', 'PROTOCOL.md'), heading: /Contratos/, name: '## Contratos' },
34
+ ]
35
+
36
+ const readIfAny = (file) => {
37
+ try { return fs.readFileSync(file, 'utf8') } catch { return '' }
38
+ }
39
+
40
+ // Con qué arranca un párrafo que **enuncia** un límite, frente a uno que lo explica. Es vocabulario
41
+ // cerrado, igual que `lane` o `blocked`, y por la misma razón: lo que sigue es una lista de la que un
42
+ // agente tiene que poder obedecer cada entrada, y una heurística abierta admite cualquier cosa.
43
+ const ENUNCIA = /^(?:El runner\b|Debe\b|Nunca\b)/
44
+ // Un límite por entrada y no la sección cruda. `SCOPE()` las une con `; ` en una sola frase del preámbulo
45
+ // que se reenvía a **cada** subagente, así que el tamaño no se paga una vez: volcar ahí los dos mil bytes
46
+ // de la sección entera metía encabezados, conectores —«Eso rige sin que nadie escriba nada.»— y los
47
+ // párrafos que razonan sobre `BR-OPS-002` y `runner.allowPush`, que son prosa para una persona.
48
+ //
49
+ // Se corta por párrafo y por su sujeto, no por posición ni por oración. Partir por oración fue el primer
50
+ // intento y devolvía diecisiete entradas de las que cuatro eran límites; por posición habría funcionado
51
+ // sobre el molde de hoy y se habría roto con el primero que agregue un párrafo.
52
+ //
53
+ // Lo que esto no resuelve, y por eso existe el caso 157: que sea una deducción gramatical y no una marca.
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
+ // lee igual de completa. Sobre `AGENTS.md` casi no puede pasar porque `upgrade` lo reemplaza entero; sobre
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
+
91
+ function limits(text) {
92
+ const prose = text.split(/\n\s*\n/)
93
+ .map((block) => block.split('\n')
94
+ .map((line) => line.replace(/^[-*]\s+/, '').trim())
95
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('|'))
96
+ .join(' ')
97
+ .trim())
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(', ')}`]
138
+ }
139
+
140
+ function contract(dir, cli) {
141
+ const root = opsRoot(dir)
142
+ const missing = SOURCES.find((name) => !fs.existsSync(path.join(root, name)))
143
+ // Sin los cuatro no hay contrato que derivar, y contestar uno a medias es peor que no contestar: lo que
144
+ // se pierde no se ve en la salida, se ve tres fases después en lo que un subagente creyó que podía tocar.
145
+ if (missing) {
146
+ return fail(`${root} no tiene ${missing}, así que no hay contrato que derivar. Es la raíz que escribió `
147
+ + '`automation install`: comprobá que exista y, si moviste el proyecto de carpeta, reinstalá el adaptador.', 2)
148
+ }
149
+
150
+ let config
151
+ try {
152
+ config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
153
+ } catch (error) {
154
+ return fail(`ops.config.json no se pudo leer como JSON: ${error.message}`, 2)
155
+ }
156
+
157
+ const sections = {}
158
+ for (const { file, heading, name } of REQUIRED_SECTIONS) {
159
+ const found = P.section(readIfAny(path.join(root, file)), heading)
160
+ // Nombrar la sección y el archivo es lo que separa este error de «algo salió mal»: quien lo lee tiene
161
+ // que poder abrir el archivo y ver qué encabezado falta, y el arreglo es restaurarlo con `upgrade`.
162
+ if (!found.trim()) {
163
+ return fail(`${file} no tiene la sección ${name}, y de ahí sale el contrato que reciben los agentes. `
164
+ + 'Ese archivo lo reemplaza `ops upgrade` entero: corrélo para restaurarlo.', 2)
165
+ }
166
+ sections[file] = found
167
+ }
168
+
169
+ const roots = Array.isArray(config.workspaceRoots) ? config.workspaceRoots : []
170
+ const runner = config.runner || {}
171
+ const excepciones = P.section(readIfAny(path.join(root, 'organization', 'workspace.md')),
172
+ /Excepciones de autonom/)
173
+ const report = {
174
+ rootOk: true,
175
+ project: String(config.project || ''),
176
+ // «nombre → ruta», que es la forma con la que el preámbulo las enumera como límite de escritura.
177
+ workspaceRoots: roots.filter((one) => one && one.name && one.path).map((one) => `${one.name} → ${one.path}`),
178
+ // Una entrada por raíz que declare `verify`, y ninguna por las que no: la lista vacía significa que el
179
+ // proyecto no dice con qué se verifica, que es distinto de no haberlo mirado.
180
+ gates: roots.filter((one) => one && one.path && one.verify).map((one) => `${one.path} → ${one.verify}`),
181
+ maxTaskHours: Number(runner.maxTaskHours || 0),
182
+ commitPerTask: Boolean(runner.commitPerTask),
183
+ humanCheckpoint: Boolean(runner.humanCheckpointBetweenMilestones),
184
+ // Textual y sin reformular: es el formato contra el que se escribe roadmap, BACKLOG, WIP y DONE, y un
185
+ // resumen de un formato no sirve para cumplirlo.
186
+ contracts: sections[path.join('planning', 'PROTOCOL.md')].trim(),
187
+ // Los del toolkit primero y los del proyecto después, que es el orden en que se leen: lo segundo amplía
188
+ // o restringe lo primero, y al revés se leería como si el proyecto fijara la base.
189
+ boundaries: [...limits(sections['AGENTS.md']), ...limits(excepciones)],
190
+ }
191
+ if (cli.has('--json')) return console.log(JSON.stringify(report))
192
+ console.log(`${report.project} (${report.workspaceRoots.join('; ') || 'sin raíces declaradas'})`)
193
+ console.log(`gates ${report.gates.join('; ') || 'ninguno declarado'}`)
194
+ console.log(`runner ${report.maxTaskHours} h por tarea · `
195
+ + `commit ${report.commitPerTask ? 'por tarea' : 'libre'} · `
196
+ + `checkpoint ${report.humanCheckpoint ? 'entre hitos' : 'no'}`)
197
+ console.log(`límites ${report.boundaries.length} · contratos ${report.contracts.length} caracteres`)
198
+ }
199
+
200
+ module.exports = { contract, warnings }
package/engine/cli/ops.js CHANGED
@@ -9,6 +9,8 @@ const { FLAGS, parse } = require('./args')
9
9
  const { fail } = require('./io')
10
10
  const IN = require('./instance')
11
11
  const PL = require('./planning')
12
+ const CT = require('./contract')
13
+ const BN = require('./bench')
12
14
  const VA = require('./validate')
13
15
  const AR = require('./archive')
14
16
  const CLM = require('./claims')
@@ -149,6 +151,8 @@ function usage() {
149
151
  ops check <planning-dir> [--json]
150
152
  ops tree <planning-dir> [--no-color] [--json]
151
153
  ops context <planning-dir> [--hito <slug>] [--json]
154
+ ops contract <ops-root> [--json]
155
+ ops bench <suelto|tarea|sidecar> [--force]
152
156
  ops recurring <planning-dir> [--promote <qué>] [--json]
153
157
  ops runners <planning-dir> [--json]
154
158
  ops claim <planning-dir> <tarea>
@@ -208,6 +212,8 @@ async function run(cli) {
208
212
  else if (command === 'check') VA.check(arg[1], cli)
209
213
  else if (command === 'tree') PL.tree(arg[1], cli)
210
214
  else if (command === 'context') PL.context(arg[1], cli)
215
+ else if (command === 'contract') CT.contract(arg[1], cli)
216
+ else if (command === 'bench') BN.bench(arg[1], cli)
211
217
  else if (command === 'recurring') PL.recurring(arg[1], cli)
212
218
  else if (command === 'runners') CLM.runners(arg[1], cli)
213
219
  else if (command === 'claim') CLM.claim(arg[1], arg[2], cli)
@@ -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
  }
@@ -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 }