@ingeniomaps/cauce 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,61 @@ esa operación sea confiable en vez de sólo cómoda: acá se lee qué cambió a
8
8
  un cambio en el protocolo, en las reglas del sistema o en un guard es visible para el usuario y sube
9
9
  minor aunque no toque una sola línea de código.
10
10
 
11
+ ## [0.18.0] - 2026-08-16
12
+
13
+ ### Agregado
14
+
15
+ - **`ops evaluate <cargo> --bench`: un banco desechable donde un cargo del catálogo puede realmente
16
+ trabajar.** El toolkit no es una raíz ops y no puede serlo —el único `planning/` que vive acá es
17
+ `template/planning`, el molde que se distribuye—. Un cargo cuya entrega es una épica o una entrada de
18
+ INBOX no tenía dónde escribir, se negaba con razón, y su caso lo contaba como fallo: el número
19
+ describía el lugar, no al cargo.
20
+
21
+ El banco es una instancia de verdad: `check` pasa, el catálogo resuelve desde adentro y `planning/`
22
+ está vacío y escribible. Se recrea entero en cada corrida —reutilizarlo dejaría que lo que un cargo
23
+ escribió el lunes sea contexto del que responde el martes— y queda en disco al terminar, gitignorado,
24
+ porque después de un veredicto raro lo primero que uno quiere es mirar qué escribió el cargo.
25
+
26
+ ### Cambiado
27
+
28
+ - **La evaluación corre sobre el banco en vez de negarse.** La 0.16.0 detuvo el recorrido dentro del
29
+ toolkit: acertó el diagnóstico y erró el remedio, porque negarse dejó al catálogo sin ninguna forma
30
+ de medirse, y el catálogo es nuestro y nos toca medirlo.
31
+
32
+ El veredicto se escribe junto al cargo, no en el banco: el banco se borra en la corrida siguiente
33
+ —es donde el cargo trabajó, no donde vive— y el veredicto pertenece al contrato que lo rindió.
34
+
35
+ En una empresa no hay banco ni hace falta: su instancia ya es el lugar. Lo que se exige ahí es que el
36
+ cargo sea suyo —propio o adoptado con `agents fork`—, porque evaluar uno del catálogo mediría su
37
+ configuración y dejaría el registro sin dónde vivir.
38
+
39
+ ## [0.17.0] - 2026-08-16
40
+
41
+ ### Agregado
42
+
43
+ - **`ops agents fork <cargo>`: una empresa se lleva un cargo del catálogo y lo mantiene desde su
44
+ carpeta.** El tercer camino ya resolvía —un cargo propio con el mismo slug tapa al del sistema, en el
45
+ listado, en `learn` y en el puntero que instala el runner—, pero llegar hasta ahí era copiar a mano, y
46
+ eso sale mal de una forma que no se nota: se agarra el `SKILL.md`, que es lo que se ve, y quedan atrás
47
+ los casos adversariales, las fuentes y el modelo operativo. El cargo responde igual y ya no se puede
48
+ evaluar, sin ningún aviso.
49
+
50
+ No se heredan los informes de aprendizaje, las propuestas ni los veredictos de evaluación. Un veredicto
51
+ pertenece al contrato que lo ganó, y el fork nace para dejar de ser ese contrato; una propuesta
52
+ pendiente arrastraría a la empresa a firmar una decisión que era nuestra.
53
+
54
+ En el toolkit se niega: acá el catálogo se edita, no se lo copia. Dejarlo pasar creaba un duplicado que
55
+ tapaba al original, y el trabajo siguiente se hacía sobre la copia mientras la versión que se publica
56
+ quedaba quieta.
57
+
58
+ - **`check` y `upgrade` avisan cuando el cargo que forkeaste mejoró río arriba.** El mecanismo ya existía
59
+ para ADRs y reglas —«sobrescribir es legítimo; lo que no puede pasar es que ocurra en silencio»— pero
60
+ no cubría los cargos, que es donde más caro sale: un fork se hace una vez y se olvida.
61
+
62
+ Se compara contra los digests guardados al forkear, nunca contra la copia: la copia está editada a
63
+ propósito, así que medir contra ella devolvería «todo cambió» desde el primer ajuste. Editar lo propio
64
+ no dispara nada, y esa mitad es la que decide si el aviso se lee o se ignora.
65
+
11
66
  ## [0.16.1] - 2026-08-16
12
67
 
13
68
  ### Corregido
package/README.md CHANGED
@@ -74,6 +74,7 @@ Lee [template/planning/PROTOCOL.md](template/planning/PROTOCOL.md) para el contr
74
74
  | `ops tree <planning>` | Muestra roadmap, backlog, WIP, inbox y done sin mutar nada. |
75
75
  | `ops context <planning>` | Emite el contexto mínimo de la tarea vigente para un runner. |
76
76
  | `ops agents list <ops-root>` | Lista los cargos visibles resolviendo la precedencia. |
77
+ | `ops agents fork <cargo>` | Copia un cargo del catálogo a la empresa, que pasa a mantenerlo. |
77
78
  | `ops upgrade <ops-root>` | Actualiza `system/` y el runtime sin tocar lo del proyecto. |
78
79
  | `ops archive <planning> <NNN>` | Archiva el DONE de una épica cerrada de forma idempotente. |
79
80
  | `ops learn <agent>` | Prepara el informe semanal que completa la automatización de Codex. |
@@ -183,6 +184,25 @@ todas las empresas: investigarla una vez y bien es mejor que repetirla en cada i
183
184
  Por eso `learn` falla si lo corrés sobre un cargo del catálogo dentro de una instancia: escribiría en
184
185
  el paquete y se perdería. El ciclo mensual de aprendizaje tampoco se distribuye — vive sólo acá.
185
186
 
187
+ #### Quedarse con una versión propia de un cargo del catálogo
188
+
189
+ ```bash
190
+ npm run ops -- agents fork product-manager
191
+ ```
192
+
193
+ Copia el cargo entero a `agents/roles/<slug>/` y desde ahí lo mantenés vos: `learn`, `evaluate` y el
194
+ puntero que instala el runner pasan a resolver contra tu copia. **Copiarlo a mano no es equivalente**
195
+ —se agarra el `SKILL.md`, que es lo que se ve, y quedan atrás los casos, las fuentes y el modelo
196
+ operativo: el cargo responde igual y ya no se puede evaluar—.
197
+
198
+ Lo que no viaja son los informes de aprendizaje, las propuestas y los veredictos de evaluación. Un
199
+ veredicto pertenece al contrato que lo ganó, y el fork nace para dejar de ser ese contrato.
200
+
201
+ A partir de ahí tu copia deja de recibir las mejoras del catálogo, que es lo que elegiste, pero no en
202
+ silencio: `check` y `upgrade` avisan cuando el original cambia río arriba. Editar tu propia copia no
203
+ dispara nada — se compara contra lo que el catálogo tenía el día del fork, no contra lo que vos
204
+ escribiste después.
205
+
186
206
  ### Versionado
187
207
 
188
208
  Como `upgrade` reemplaza `system/` sin pedir confirmación, un cambio en el protocolo, en una regla del
@@ -7,15 +7,18 @@
7
7
  // Si los viera, el caso mediría su capacidad de repetirlos. Y quien juzga no es quien respondió, por
8
8
  // la misma razón por la que nadie corrige su propio examen.
9
9
  //
10
- // **Dónde se corre importa.** Un cargo cuyo trabajo es producir artefactos de planning una épica, una
11
- // entrada de INBOX— necesita un `planning/` donde escribir sea legítimo. Corrido dentro del repositorio
12
- // del toolkit, ese directorio es `template/planning`, que se distribuye a cada instalación: el cargo se
13
- // niega, con razón, y su caso lo cuenta como fallo. Medido así, `product-manager` falla exactamente los
14
- // dos casos que piden escribir, y ninguno de los otros tres.
10
+ // **Un cargo necesita un lugar donde trabajar.** Su entrega puede ser una épica o una entrada de
11
+ // INBOX, y para eso hace falta un `planning/` donde escribir sea legítimo. El toolkit no lo tiene ni
12
+ // puede tenerlo: el único `planning/` que vive acá es `template/planning`, el molde que se distribuye.
13
+ // Medido así, `product-manager` fallaba exactamente los dos casos que piden escribir y ninguno de los
14
+ // otros tres el número no hablaba del cargo sino del lugar.
15
15
  //
16
- // Por eso el recorrido se niega cuando `mode` es `toolkit`. Dejarlo escrito en este comentario no
17
- // alcanzaba: un comentario no impide nada, y la primera vez que pasó fue justamente porque estaba
18
- // documentado y nadie lo leyó a tiempo. Evaluar los cargos que Cauce distribuye exige una instancia.
16
+ // Por eso en el toolkit se le arma un banco desechable —`evaluate <cargo> --bench`— y el cargo trabaja
17
+ // ahí. El veredicto, en cambio, se escribe junto al cargo: el banco se borra, el contrato queda.
18
+ //
19
+ // En una empresa no hay banco ni hace falta: su instancia ya es el lugar. Lo que se exige ahí es que el
20
+ // cargo sea suyo —propio o adoptado con `agents fork`—, porque evaluar uno del catálogo mediría su
21
+ // configuración y dejaría el registro sin dónde vivir.
19
22
  //
20
23
  // La respuesta no lleva tope de extensión, y eso se probó: con un tope de doce líneas, dos casos que
21
24
  // pasan fallaban. Un comportamiento esperado puede exigir seis elementos —«versión, entorno, datos,
@@ -52,9 +55,15 @@ const CASES = {
52
55
  } },
53
56
  skill: { type: 'string' },
54
57
  mode: { type: 'string' },
58
+ system: { type: 'boolean' },
55
59
  },
56
60
  }
57
61
 
62
+ const BENCH = {
63
+ type: 'object', additionalProperties: false, required: ['path'],
64
+ properties: { path: { type: 'string' } },
65
+ }
66
+
58
67
  const ANSWER = {
59
68
  type: 'object', additionalProperties: false, required: ['response'],
60
69
  properties: { response: { type: 'string' } },
@@ -92,17 +101,31 @@ const contexto = await agent(
92
101
  `2. "node tools/ops.js agents list --json" — set skill to "${ROOT}/<path>/SKILL.md" using the path it ` +
93
102
  `printed for ${AGENT}. That command prints paths relative to ${ROOT} and the next agents run from ` +
94
103
  `elsewhere, so the prefix is not optional.\n` +
95
- `Then read ${ROOT}/ops.config.json and set mode to its "mode" field, verbatim.`,
104
+ `Then read ${ROOT}/ops.config.json and set mode to its "mode" field, verbatim, and set system ` +
105
+ `to what "agents list --json" reported for ${AGENT} in its "system" field.`,
96
106
  { schema: CASES, label: 'cases' },
97
107
  )
98
108
  if (!contexto || !contexto.items || !contexto.items.length) {
99
109
  return stop('sin-casos', `${AGENT} no tiene casos, o no se pudieron leer`)
100
110
  }
111
+ // Dónde trabaja el cargo mientras responde. En el toolkit no puede ser acá: no hay `planning/` que
112
+ // valga, así que se le arma un banco desechable. En una empresa es su propia instancia, y el cargo
113
+ // tiene que ser suyo —propio o adoptado—: uno del catálogo se evalúa arriba, no acá.
114
+ let WORK = ROOT
101
115
  if (contexto.mode === 'toolkit') {
102
- return stop('en-el-toolkit',
103
- 'este recorrido mide cargos trabajando, y acá no pueden: `planningDir` apunta a la plantilla que ' +
104
- 'se distribuye, así que un cargo que deba escribir en planning se niega —con razón— y su caso lo ' +
105
- 'cuenta como fallo. Medido así el resultado no dice nada del cargo. Corrélo desde una instancia.')
116
+ const banco = await agent(
117
+ `From ${ROOT}, run "node tools/ops.js evaluate ${AGENT} --bench" and report the absolute path it ` +
118
+ `printed, nothing else. It recreates a disposable instance where writing to planning/ is legitimate.`,
119
+ { schema: BENCH, label: 'banco' },
120
+ )
121
+ if (!banco || !banco.path) return stop('sin-banco', 'no se pudo preparar el banco de evaluación')
122
+ WORK = banco.path
123
+ log(`Banco: ${WORK}`)
124
+ } else if (contexto.system) {
125
+ return stop('cargo-del-catalogo',
126
+ `${AGENT} lo mantiene Cauce, no esta empresa: evaluarlo acá mediría tu configuración y el ` +
127
+ `registro no tendría dónde vivir. Si querés una versión tuya, adoptalo con ` +
128
+ `"node tools/ops.js agents fork ${AGENT}" y evaluá esa.`)
106
129
  }
107
130
  log(`${contexto.items.length} caso(s) de ${AGENT}`)
108
131
 
@@ -111,8 +134,10 @@ const veredictos = await pipeline(
111
134
 
112
135
  // Responde el cargo. Recibe su contrato y el pedido; nunca los comportamientos esperados.
113
136
  (item) => agent(
137
+ `Trabajás en ${WORK}: esa es tu instancia, con su planning/, su organization/ y su AGENTS.md. ` +
138
+ `Todo lo que escribas va ahí.\n\n` +
114
139
  `Actuá como el cargo ${AGENT}, respetando el contrato de ${contexto.skill}: cuándo actuar, qué ` +
115
- `decide, qué no le corresponde y cuál es su entrega mínima. Leé también ${ROOT}/AGENTS.md: son las ` +
140
+ `decide, qué no le corresponde y cuál es su entrega mínima. Leé también ${WORK}/AGENTS.md: son las ` +
116
141
  `reglas que todo cargo obedece, y un cargo corre siempre con las dos cosas —medirlo sólo contra su ` +
117
142
  `SKILL.md lo evaluaba en una situación que nunca ocurre—. No leas ningún archivo bajo ` +
118
143
  `evaluations/: no te corresponde y contaminaría la respuesta.\n\n` +
@@ -149,10 +174,11 @@ const filas = hechos.map((one) => {
149
174
  }).join('\n\n')
150
175
 
151
176
  await agent(
152
- `Escribí ${ROOT}/agents/roles/${AGENT}/evaluations/results/<fecha>.md, o la ruta equivalente si el ` +
153
- `cargo vive en el paquete —usá el directorio del cargo que ya conocés por ${contexto.skill}, ` +
154
- `reemplazando SKILL.md por evaluations/results/—. La fecha es la de hoy en formato AAAA-MM-DD; ` +
155
- `obtenela con "date +%F". Creá el directorio si no existe.\n\n` +
177
+ `Escribí el registro junto al cargo: tomá ${contexto.skill} y reemplazá SKILL.md por ` +
178
+ `evaluations/results/<fecha>.md. Creá el directorio si no existe.\n\n` +
179
+ `Ahí y no en el banco de trabajo. El banco se borra en la próxima corrida —es donde el cargo ` +
180
+ `trabajó, no donde vive—, mientras que el veredicto pertenece al contrato que lo rindió y viaja ` +
181
+ `con él. La fecha es la de hoy en formato AAAA-MM-DD; obtenela con "date +%F".\n\n` +
156
182
  `El archivo lleva este frontmatter y después el contenido tal cual te lo paso, sin reescribirlo ni ` +
157
183
  `resumirlo:\n\n---\nagent: ${AGENT}\ndate: <fecha>\npassed: ${pasan.length}\ntotal: ${hechos.length}\n---\n\n` +
158
184
  `# Casos adversariales — <fecha>\n\n${filas}\n\n` +
@@ -0,0 +1,152 @@
1
+ 'use strict'
2
+
3
+ // Llevarse un cargo del catálogo a la carpeta de la empresa para mantenerlo desde ahí.
4
+ //
5
+ // Existe porque copiar a mano sale mal de una forma que no se nota: quien copia agarra el `SKILL.md`
6
+ // —que es lo que se ve— y deja atrás los casos adversariales, las fuentes de aprendizaje y el modelo
7
+ // operativo. Queda un cargo que responde igual y ya no se puede evaluar, sin ningún aviso.
8
+ //
9
+ // **Qué no se hereda.** Los informes de aprendizaje, las propuestas y los veredictos de evaluación se
10
+ // quedan en el catálogo. No es prolijidad: un veredicto pertenece al contrato que lo ganó, y el fork
11
+ // nace para dejar de ser ese contrato. Heredarlos le daría a la copia una garantía que no rindió, y
12
+ // una propuesta pendiente arrastraría a la empresa a firmar una decisión que era nuestra.
13
+ //
14
+ // Lo que sí se hereda entero es el contrato y todo lo que lo hace verificable: casos, conductas
15
+ // esperadas, fuentes, automatización y referencias. La empresa recibe un cargo evaluable desde el
16
+ // primer minuto, y a partir de ahí lo mantiene.
17
+
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+
21
+ const catalog = require('./catalog')
22
+ const manifest = require('../core/manifest')
23
+ const ownership = require('../core/ownership')
24
+
25
+ // Se comparan contra la ruta relativa dentro del cargo. `_template.md` sobrevive en las dos carpetas
26
+ // porque es andamiaje del mecanismo, no un artefacto de nadie.
27
+ function inherited(relative) {
28
+ if (/^learning\/(reports|proposals)\//.test(relative)) return path.basename(relative) === '_template.md'
29
+ if (relative.startsWith('evaluations/results/')) return false
30
+ return true
31
+ }
32
+
33
+ function tree(dir, prefix = '') {
34
+ const found = []
35
+ let list = []
36
+ try { list = fs.readdirSync(dir, { withFileTypes: true }) } catch { return found }
37
+ for (const entry of list) {
38
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name
39
+ if (entry.isDirectory()) found.push(...tree(path.join(dir, entry.name), relative))
40
+ else found.push(relative)
41
+ }
42
+ return found.sort()
43
+ }
44
+
45
+ // La misma resolución que usa todo lo demás —paquete primero, toolkit después— en vez de deducirla
46
+ // del directorio del motor: la versión que interesa es la del catálogo del que sale la copia.
47
+ function packageVersion(root) {
48
+ const file = ownership.packagePath(root, 'package.json')
49
+ if (!file) return ''
50
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')).version || '' } catch { return '' }
51
+ }
52
+
53
+ // La fila deja el límite por escrito: arriba, cómo llegó el contrato a ser lo que es —decisiones que
54
+ // tomamos nosotros—; abajo, lo que decida la empresa. Sin ese renglón, la primera fila propia parece
55
+ // continuación de una conversación ajena.
56
+ function markHistory(target, slug, version, date) {
57
+ const file = path.join(target, 'learning', 'HISTORY.md')
58
+ if (!fs.existsSync(file)) return
59
+ const origen = version ? `del catálogo de Cauce ${version}` : 'del catálogo de Cauce'
60
+ const row = `| ${date} | — | Copiado ${origen} | — | ${slug} pasa a mantenerlo esta empresa |\n`
61
+ const content = fs.readFileSync(file, 'utf8')
62
+ fs.writeFileSync(file, content.endsWith('\n') ? content + row : `${content}\n${row}`)
63
+ }
64
+
65
+ // `date` entra por parámetro en vez de leerse acá: la salida tiene que ser reproducible en una prueba.
66
+ function fork(root, slug, date) {
67
+ // En el toolkit no hay a quién copiarle: el catálogo se mantiene acá, directo. Dejarlo pasar creaba
68
+ // un duplicado en `agents/roles/` que tapaba al original, y el trabajo siguiente se hacía sobre la
69
+ // copia mientras la versión que se publica quedaba quieta.
70
+ try {
71
+ const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
72
+ if (config.mode === 'toolkit') {
73
+ throw new Error(`${slug} vive acá: en el toolkit se edita el catálogo, no se lo copia`)
74
+ }
75
+ } catch (error) { if (error instanceof Error && error.message.includes('vive acá')) throw error }
76
+
77
+ const found = catalog.find(root, slug)
78
+ if (!found.system) {
79
+ throw new Error(`${slug} ya lo mantiene esta empresa: ${path.relative(root, found.dir)}`)
80
+ }
81
+ const type = path.basename(path.dirname(path.dirname(found.dir)))
82
+ const target = path.join(root, 'agents', type, slug)
83
+ if (fs.existsSync(target)) throw new Error(`${path.relative(root, target)} ya existe`)
84
+
85
+ const files = tree(found.dir).filter(inherited)
86
+ if (!files.includes('SKILL.md')) throw new Error(`${slug} no tiene SKILL.md: no hay contrato que copiar`)
87
+
88
+ // El digest sale del origen, no de la copia. Es lo que el catálogo tenía al momento del fork, y es
89
+ // contra eso que después se responde «esto mejoró río arriba»; medirlo sobre la copia lo ataría a
90
+ // cualquier cosa que le hagamos acá —empezando por la fila de historial que se escribe abajo—.
91
+ const digests = {}
92
+ for (const relative of files) {
93
+ const from = path.join(found.dir, relative)
94
+ const to = path.join(target, relative)
95
+ fs.mkdirSync(path.dirname(to), { recursive: true })
96
+ fs.copyFileSync(from, to)
97
+ digests[relative] = manifest.digest(from)
98
+ }
99
+
100
+ const version = packageVersion(root)
101
+ markHistory(target, slug, version, date)
102
+
103
+ const forks = { ...manifest.readForks(root) }
104
+ forks[slug] = { type, version, files: digests }
105
+ manifest.write(root, null, null, forks)
106
+
107
+ return { slug, type, dir: target, files, version, skipped: tree(found.dir).filter((one) => !inherited(one)) }
108
+ }
109
+
110
+ // Qué cambió en el catálogo desde que la empresa se llevó su copia.
111
+ //
112
+ // El fork es legítimo y esperado —para eso está el comando—, pero deja de recibir las mejoras del
113
+ // toolkit, y eso no puede pasar en silencio: la empresa decidió mantener un cargo, no quedarse sin
114
+ // enterarse de que el original mejoró. Nadie va a comparar 14 archivos a mano en cada actualización.
115
+ //
116
+ // Se compara contra los digests guardados al forkear, nunca contra la copia: la copia está editada a
117
+ // propósito, así que medir contra ella devolvería «todo cambió» desde el primer ajuste. Y el aviso no
118
+ // dice qué hacer —integrar o no es decisión de la empresa—, sólo que hay algo que mirar.
119
+ function drift(root) {
120
+ const forks = manifest.readForks(root)
121
+ const catalogDir = ownership.packageDir(root, 'agents')
122
+ const found = []
123
+ for (const [slug, record] of Object.entries(forks)) {
124
+ if (!record || !record.files) continue
125
+ const source = catalogDir ? path.join(catalogDir, record.type || 'roles', 'system', slug) : ''
126
+ if (!source || !fs.existsSync(source)) continue
127
+ const current = tree(source).filter(inherited)
128
+ const changed = []
129
+ const removed = []
130
+ for (const [relative, recorded] of Object.entries(record.files)) {
131
+ if (!current.includes(relative)) { removed.push(relative); continue }
132
+ if (manifest.digest(path.join(source, relative)) !== recorded) changed.push(relative)
133
+ }
134
+ const added = current.filter((relative) => !(relative in record.files))
135
+ if (changed.length || added.length || removed.length) {
136
+ found.push({ slug, version: record.version || '', changed, added, removed })
137
+ }
138
+ }
139
+ return found.sort((left, right) => left.slug.localeCompare(right.slug))
140
+ }
141
+
142
+ // Una línea por fork, para que `check` y `upgrade` digan lo mismo con las mismas palabras.
143
+ function driftLine(entry) {
144
+ const parts = []
145
+ if (entry.changed.length) parts.push(`${entry.changed.length} archivo(s) cambiaron`)
146
+ if (entry.added.length) parts.push(`${entry.added.length} nuevo(s)`)
147
+ if (entry.removed.length) parts.push(`${entry.removed.length} retirado(s)`)
148
+ const desde = entry.version ? ` desde ${entry.version}` : ''
149
+ return `${entry.slug}: tu copia no recibe mejoras del catálogo y ahí${desde} ${parts.join(', ')}`
150
+ }
151
+
152
+ module.exports = { drift, driftLine, fork, inherited }
package/engine/cli/ops.js CHANGED
@@ -49,8 +49,9 @@ function usage() {
49
49
  ops automation doctor <ops-root> claude|codex|gemini|antigravity
50
50
  ops automation install <ops-root> claude|codex|gemini|antigravity
51
51
  ops learn <agent> [--proposal]
52
- ops evaluate <agent> [--cases [--json]]
52
+ ops evaluate <agent> [--cases [--json]] [--bench]
53
53
  ops agents list [ops-root] [--own|--system] [--json]
54
+ ops agents fork <cargo> [ops-root]
54
55
  ops team list
55
56
  ops team check <team>
56
57
  ops team show <team>`)
@@ -78,7 +79,7 @@ function option(name, fallback = '') {
78
79
  return index >= 0 ? process.argv[index + 1] || fallback : fallback
79
80
  }
80
81
 
81
- function copyTemplate(source, target, replacements, force, skip = []) {
82
+ function copyTemplate(source, target, replacements, force, skip = [], quiet = false) {
82
83
  F.assertNoSymlinkPath(path.dirname(target), target)
83
84
  fs.mkdirSync(target, { recursive: true })
84
85
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
@@ -87,18 +88,18 @@ function copyTemplate(source, target, replacements, force, skip = []) {
87
88
  // npm no incluye un `.gitignore` dentro de un tarball, así que viaja sin punto y se restituye
88
89
  // acá. Sin esto el archivo existe en el repo del toolkit y desaparece para todo consumidor real.
89
90
  const to = path.join(target, entry.name === 'gitignore' ? '.gitignore' : entry.name)
90
- if (entry.isDirectory()) copyTemplate(from, to, replacements, force, skip)
91
+ if (entry.isDirectory()) copyTemplate(from, to, replacements, force, skip, quiet)
91
92
  else {
92
93
  if (fs.existsSync(to)) {
93
94
  if (!force) fail(`El destino contiene ${to}. Usa un directorio vacío o --force.`)
94
- console.log(`= conservado ${to}`)
95
+ if (!quiet) console.log(`= conservado ${to}`)
95
96
  continue
96
97
  }
97
98
  let content = fs.readFileSync(from, 'utf8')
98
99
  for (const [key, value] of Object.entries(replacements)) content = content.replaceAll(key, value)
99
100
  F.atomicWrite(to, content)
100
101
  if (entry.name.endsWith('.js')) fs.chmodSync(to, 0o755)
101
- console.log(`+ ${to}`)
102
+ if (!quiet) console.log(`+ ${to}`)
102
103
  }
103
104
  }
104
105
  }
@@ -142,29 +143,22 @@ function providerNames() {
142
143
  } catch { return [] }
143
144
  }
144
145
 
145
- function init(target) {
146
- if (!target) fail('Falta <destino>.', 2)
147
- const mode = option('--mode', 'embedded')
148
- if (!['embedded', 'sidecar'].includes(mode)) fail('--mode debe ser embedded o sidecar.', 2)
149
- const name = option('--name', path.basename(path.resolve(target)).replace(/-ops$/, ''))
150
- const root = path.resolve(target)
151
- const existing = fs.existsSync(root) ? fs.readdirSync(root) : []
152
- if (existing.length && !process.argv.includes('--force')) {
153
- fail(`El destino no está vacío: ${root}. Usa --force para agregar solo archivos faltantes.`)
154
- }
146
+ // El andamiaje de una instancia, sin leer argv. `init` es la cáscara que traduce banderas a esto, y
147
+ // el banco de evaluación lo llama directo: crear una instancia programáticamente no puede depender de
148
+ // cómo venga escrita la línea de comandos.
149
+ function scaffold(root, { name, mode, force = false, quiet = false }) {
155
150
  copyTemplate(path.join(PROJECT_ROOT, 'template'), root, {
156
151
  '{{PROJECT_NAME}}': name,
157
152
  '{{MODE}}': mode,
158
153
  '{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
159
- }, process.argv.includes('--force'), providerNames())
154
+ }, force, providerNames(), quiet)
160
155
  // No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
161
156
  // el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando los dos únicos archivos
162
157
  // que existen dejaba `.github/workflows/` vacío en cada instancia.
163
- const preserve = process.argv.includes('--force')
164
158
  copyRuntime(
165
159
  path.join(PROJECT_ROOT, 'automatization', 'hooks'),
166
160
  path.join(root, 'automatization', 'hooks'),
167
- preserve,
161
+ force,
168
162
  root,
169
163
  )
170
164
  const version = require(path.join(PROJECT_ROOT, 'package.json')).version
@@ -187,6 +181,44 @@ function init(target) {
187
181
  config.cauceVersion = version
188
182
  config.$schema = 'node_modules/@ingeniomaps/cauce/engine/schemas/ops-config.schema.json'
189
183
  F.atomicWriteJson(configFile, config)
184
+ return root
185
+ }
186
+
187
+ // Un banco de trabajo desechable donde un cargo del catálogo puede realmente trabajar.
188
+ //
189
+ // Hace falta porque el toolkit no es una raíz ops: no tiene `planning/`, y no puede tenerlo —el único
190
+ // `planning/` que vive acá es `template/planning`, el molde que se distribuye—. Un cargo cuya entrega
191
+ // es una épica o una entrada de INBOX no tiene dónde escribir, así que se niega. Con razón, y su caso
192
+ // lo cuenta como fallo: eso midió una configuración, no al cargo.
193
+ //
194
+ // Se recrea entero en cada corrida. Reutilizarlo dejaría que lo que un cargo escribió el lunes sea
195
+ // contexto del que responde el martes, y dos corridas del mismo caso dejarían de ser comparables.
196
+ // Queda en disco al terminar, gitignorado, porque después de un veredicto raro lo primero que uno
197
+ // quiere es mirar qué escribió el cargo.
198
+ function evaluationBench(root) {
199
+ const dir = path.join(root, '.cauce-eval')
200
+ fs.rmSync(dir, { recursive: true, force: true })
201
+ scaffold(dir, { name: 'Banco de evaluación', mode: 'sidecar', quiet: true })
202
+ // El motor por symlink: la misma resolución que en una instancia real —`node_modules/@ingeniomaps`—
203
+ // sin pagar un `npm install` por corrida. El cargo llega a un banco donde el CLI funciona.
204
+ const scope = path.join(dir, 'node_modules', '@ingeniomaps')
205
+ fs.mkdirSync(scope, { recursive: true })
206
+ fs.symlinkSync(PROJECT_ROOT, path.join(scope, 'cauce'), 'dir')
207
+ return dir
208
+ }
209
+
210
+ function init(target) {
211
+ if (!target) fail('Falta <destino>.', 2)
212
+ const mode = option('--mode', 'embedded')
213
+ if (!['embedded', 'sidecar'].includes(mode)) fail('--mode debe ser embedded o sidecar.', 2)
214
+ const name = option('--name', path.basename(path.resolve(target)).replace(/-ops$/, ''))
215
+ const root = path.resolve(target)
216
+ const force = process.argv.includes('--force')
217
+ const existing = fs.existsSync(root) ? fs.readdirSync(root) : []
218
+ if (existing.length && !force) {
219
+ fail(`El destino no está vacío: ${root}. Usa --force para agregar solo archivos faltantes.`)
220
+ }
221
+ scaffold(root, { name, mode, force })
190
222
  console.log(`\n✓ ${name}: sistema ops creado en ${root}`)
191
223
  console.log(' siguiente: npm install (el motor viene de la dependencia)')
192
224
  console.log(` siguiente: node ${path.join(root, 'tools', 'ops.js')} check ${path.join(root, 'planning')}`)
@@ -300,6 +332,9 @@ function check(dir) {
300
332
  for (const override of O.overrides(path.resolve(root, '..'))) {
301
333
  warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} (override explícito)`)
302
334
  }
335
+ // Misma regla para los cargos, que es donde más caro sale: un fork se hace una vez y se olvida.
336
+ const FK = require('../agents/fork')
337
+ for (const entry of FK.drift(path.resolve(root, '..'))) warnings.push(FK.driftLine(entry))
303
338
 
304
339
  if (process.argv.includes('--json')) {
305
340
  console.log(JSON.stringify({
@@ -604,6 +639,10 @@ function upgrade(dir) {
604
639
  for (const name of runners) {
605
640
  console.log(` reinstalá tu runner para que el wiring quede al día: make install-${name}`)
606
641
  }
642
+ // Después de aplicar, no antes: recién acá el paquete tiene la versión nueva y la comparación dice
643
+ // algo. Es además el momento en que alguien está mirando qué le trajo la actualización.
644
+ const FK = require('../agents/fork')
645
+ for (const entry of FK.drift(root)) console.log(` ⚠ ${FK.driftLine(entry)}`)
607
646
  }
608
647
 
609
648
  // Lista los cargos visibles resolviendo la precedencia; evita que cada consumidor —CI incluido—
@@ -614,7 +653,22 @@ function opsRoot(dir) {
614
653
  return path.resolve(dir || process.env.OPS_ROOT || '.')
615
654
  }
616
655
 
617
- function agents(action, dir) {
656
+ function agentsFork(slug, dir) {
657
+ const root = opsRoot(dir)
658
+ if (!slug) fail('Falta el cargo: ops agents fork <cargo> [ops-root]', 2)
659
+ let result
660
+ const date = new Date().toISOString().slice(0, 10)
661
+ try { result = require('../agents/fork').fork(root, slug, date) } catch (error) { fail(error.message, 2) }
662
+ console.log(`+ ${path.relative(root, result.dir)} (${result.files.length} archivo(s))`)
663
+ if (result.skipped.length) {
664
+ console.log(` quedan en el catálogo: ${result.skipped.length} artefacto(s) que ganó su versión`)
665
+ }
666
+ console.log(` copiado de Cauce ${result.version || '(versión desconocida)'}; desde ahora lo mantenés vos`)
667
+ console.log(` reinstalá tu runner para que ${slug} apunte a tu copia`)
668
+ }
669
+
670
+ function agents(action, dir, extra) {
671
+ if (action === 'fork') return agentsFork(dir, extra)
618
672
  if (action !== 'list') fail(`Acción de agents desconocida: ${action || '(vacía)'}`, 2)
619
673
  const root = opsRoot(dir)
620
674
  // Una empresa mantiene sus cargos, no los nuestros: `learn` sobre uno del catálogo se niega, así que
@@ -827,6 +881,17 @@ function learn(agent) {
827
881
 
828
882
  function evaluate(agent) {
829
883
  const root = opsRoot()
884
+ // El banco sólo tiene sentido acá: en una empresa el cargo que se evalúa es suyo —propio o
885
+ // adoptado— y su `planning/` ya es el lugar legítimo donde trabajar.
886
+ if (process.argv.includes('--bench')) {
887
+ let mode = ''
888
+ try { mode = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8')).mode } catch { /* sin config */ }
889
+ if (mode !== 'toolkit') {
890
+ fail('--bench es del toolkit. En una instancia, el cargo trabaja sobre tu planning/: si es del '
891
+ + `catálogo, adoptalo primero con "ops agents fork ${agent}".`, 2)
892
+ }
893
+ return console.log(evaluationBench(root))
894
+ }
830
895
  try {
831
896
  // Los casos, para que un recorrido los ejecute. Sin `--json` no tiene sentido: es entrada de
832
897
  // máquina, no de persona.
@@ -884,7 +949,7 @@ async function main() {
884
949
  else if (command === 'tree') tree(arg[1])
885
950
  else if (command === 'context') context(arg[1])
886
951
  else if (command === 'upgrade') upgrade(arg[1])
887
- else if (command === 'agents') agents(arg[1], arg[2])
952
+ else if (command === 'agents') agents(arg[1], arg[2], arg[3])
888
953
  else if (command === 'archive') archive(arg[1], arg[2])
889
954
  else if (command === 'integration') {
890
955
  await integration(arg[1], arg[2], arg[3], arg[4])
@@ -30,17 +30,24 @@ function readAll(root) {
30
30
  return {
31
31
  files: typeof data.files === 'object' && data.files ? data.files : {},
32
32
  runners: typeof data.runners === 'object' && data.runners ? data.runners : {},
33
+ forks: typeof data.forks === 'object' && data.forks ? data.forks : {},
33
34
  }
34
- } catch { return { files: {}, runners: {} } }
35
+ } catch { return { files: {}, runners: {}, forks: {} } }
35
36
  }
36
37
 
37
38
  function read(root) { return readAll(root).files }
38
39
 
39
40
  function readRunners(root) { return readAll(root).runners }
40
41
 
42
+ // Tercera sección, y de otra naturaleza que las dos anteriores: `files` y `runners` registran lo que
43
+ // Cauce entregó, mientras `forks` registra lo que la empresa se llevó. Guarda el contenido del cargo
44
+ // del sistema **en el momento de la copia**, que es lo único contra lo que se puede decir después
45
+ // «esto mejoró río arriba»: comparar contra el fork mismo mediría las ediciones de la empresa.
46
+ function readForks(root) { return readAll(root).forks }
47
+
41
48
  // Cada sección que no se pasa se conserva: `upgrade` no sabe del wiring y `install` no sabe de la
42
49
  // instancia, y ninguno de los dos debería borrar lo que el otro anotó.
43
- function write(root, files, runners) {
50
+ function write(root, files, runners, forks) {
44
51
  const current = readAll(root)
45
52
  const target = path.join(root, FILE)
46
53
  fs.mkdirSync(path.dirname(target), { recursive: true })
@@ -51,6 +58,7 @@ function write(root, files, runners) {
51
58
  version: 1,
52
59
  files: ordered(files || current.files),
53
60
  runners: ordered(runners || current.runners),
61
+ forks: ordered(forks || current.forks),
54
62
  }
55
63
  fs.writeFileSync(target, `${JSON.stringify(data, null, 2)}\n`)
56
64
  }
@@ -85,4 +93,6 @@ function edited(root, relative, files) {
85
93
  })
86
94
  }
87
95
 
88
- module.exports = { FILE, digest, digestText, edited, prune, read, readRunners, record, write }
96
+ module.exports = {
97
+ FILE, digest, digestText, edited, prune, read, readForks, readRunners, record, write,
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "type": "commonjs",
6
6
  "bin": {