@ingeniomaps/cauce 0.93.0 → 0.95.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/automatization/workflows/autobuild.js +28 -1
  3. package/engine/agents/evaluations.js +20 -5
  4. package/engine/agents/fork.js +21 -8
  5. package/engine/agents/learning-sources.js +7 -7
  6. package/engine/agents/learning.js +15 -6
  7. package/engine/automation/check.js +2 -2
  8. package/engine/automation/config.js +9 -9
  9. package/engine/automation/index.js +19 -8
  10. package/engine/automation/rules.js +27 -9
  11. package/engine/cli/archive.js +1 -4
  12. package/engine/cli/args.js +16 -2
  13. package/engine/cli/bench.js +3 -3
  14. package/engine/cli/catalog.js +3 -3
  15. package/engine/cli/claims.js +21 -23
  16. package/engine/cli/contract.js +24 -7
  17. package/engine/cli/instance.js +19 -19
  18. package/engine/cli/ops.js +7 -3
  19. package/engine/cli/planning.js +4 -4
  20. package/engine/cli/upgrade-report.js +10 -10
  21. package/engine/cli/validate.js +7 -7
  22. package/engine/core/onboarding.js +4 -1
  23. package/engine/core/ownership.js +2 -2
  24. package/engine/core/repos.js +18 -12
  25. package/engine/hooks/approval.js +9 -1
  26. package/engine/hooks/chat.js +27 -5
  27. package/engine/hooks/files.js +6 -6
  28. package/engine/hooks/input.js +5 -2
  29. package/engine/hooks/run.js +3 -3
  30. package/engine/hooks/shell.js +20 -6
  31. package/engine/hooks/verify.js +7 -7
  32. package/engine/integrations/proposals.js +5 -1
  33. package/engine/integrations/registry.js +7 -0
  34. package/engine/integrations/state.js +6 -3
  35. package/engine/planning/adoption.js +2 -2
  36. package/engine/planning/claims.js +8 -8
  37. package/engine/planning/parser.js +8 -8
  38. package/engine/planning/state.js +2 -2
  39. package/engine/planning/structure.js +7 -7
  40. package/package.json +1 -1
  41. package/template/planning/rules/README.md +8 -5
  42. package/template/planning/rules/system/code-shape.md +19 -0
  43. package/template/planning/rules/system/commits.md +35 -0
  44. package/template/planning/rules/system/conduct.md +23 -0
  45. package/template/planning/rules/system/process.md +48 -118
  46. package/template/planning/rules/system/runs.md +153 -0
@@ -45,9 +45,9 @@ function probeBridge(paths, runner) {
45
45
  const problems = []
46
46
  // Desde la raíz y desde una carpeta de adentro: si el runner lanza el hook con otro cwd, la ruta
47
47
  // relativa de su configuración deja de resolver y eso hay que verlo acá, no en la primera sesión.
48
- const sourceRoot = [paths.install, path.dirname(script)]
48
+ const launchDirs = [paths.install, path.dirname(script)]
49
49
  for (const event of hookEvents) {
50
- for (const cwd of sourceRoot) {
50
+ for (const cwd of launchDirs) {
51
51
  const payload = JSON.stringify({ toolCall: { args: { CommandLine: 'ls', Cwd: paths.install } } })
52
52
  const result = spawnSync(process.execPath, [script, event], { cwd, input: payload, encoding: 'utf8' })
53
53
  let response = {}
@@ -75,11 +75,11 @@ function doctor(root, name, output = console) {
75
75
  const warnings = []
76
76
  try {
77
77
  const expected = runnerConfig(paths, root)
78
- const actual = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
79
- if (!includesConfig(actual, expected)) {
78
+ const installed = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
79
+ if (!includesConfig(installed, expected)) {
80
80
  errors.push(`${runner.config.target}: configuración instalada incompleta o divergente`)
81
81
  }
82
- const legacy = legacyGuardWiring(actual)
82
+ const legacy = legacyGuardWiring(installed)
83
83
  if (legacy.length) {
84
84
  warnings.push(`${runner.config.target}: ${legacy.join(', ')} siguen registrados sueltos junto al grupo; `
85
85
  + 'cada uno se ejecuta dos veces. Borrá esas entradas del archivo para quedarte sólo con el grupo')
@@ -221,6 +221,18 @@ function uninstall(root, name, output = console) {
221
221
  }
222
222
  const runner = runnerManifest(root, name)
223
223
  const paths = runnerPaths(root, name, runner)
224
+ // La configuración se lee **antes** de borrar nada, aunque se use al final. Leyéndola al final, un
225
+ // `settings.json` a medio fusionar hacía morir el comando con el mensaje crudo del parser —que no
226
+ // nombra ni un archivo— con los workflows y los cargos ya borrados: la instancia quedaba a medio
227
+ // desinstalar, con la configuración registrando guards que no existen. Acá no hay nada que deshacer
228
+ // porque todavía no se tocó el disco, que es más barato que cualquier rollback.
229
+ const hasConfig = fs.existsSync(paths.configTarget)
230
+ let config
231
+ if (hasConfig) {
232
+ try { config = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8')) } catch (error) {
233
+ throw new Error(`${runner.config.target} contiene JSON inválido (${error.message})`)
234
+ }
235
+ }
224
236
  const prefix = opsPrefix(root)
225
237
  const recorded = M.readRunners(root)
226
238
  const kept = []
@@ -266,9 +278,8 @@ function uninstall(root, name, output = console) {
266
278
  }
267
279
  }
268
280
 
269
- if (fs.existsSync(paths.configTarget)) {
270
- const current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
271
- const clean = unmergeConfig(unmergeConfig(current, runnerConfig(paths, root)), runner.config.retired || {})
281
+ if (hasConfig) {
282
+ const clean = unmergeConfig(unmergeConfig(config, runnerConfig(paths, root)), runner.config.retired || {})
272
283
  if (clean && Object.keys(clean).length) F.atomicWriteJson(paths.configTarget, clean)
273
284
  else { removeFile(paths.configTarget, paths.install); removed += 1 }
274
285
  output.log(`✓ ${name}: ${runner.config.target} sin las entradas de Cauce`)
@@ -67,38 +67,56 @@ function split(root) {
67
67
  function weight(root) {
68
68
  const { loaded } = split(root)
69
69
  let bytes = 0
70
+ let own = 0
70
71
  const files = []
71
72
  for (const file of loaded) {
72
73
  try {
73
74
  const size = fs.statSync(path.join(root, file)).size
74
75
  bytes += size
76
+ // Lo propio es lo que no vive en `rules/system/`, que es exactamente lo que el proyecto escribió:
77
+ // una regla del sistema que sobrescribió deja de cargarse y la suya ocupa su lugar, así que
78
+ // contarla como propia es correcto — la escribió él y la puede achicar.
79
+ if (!file.includes('/system/')) own += size
75
80
  files.push({ file, size })
76
81
  } catch { /* la que no está en disco ya la reporta `check` por su lado */ }
77
82
  }
78
- return { count: loaded.length, bytes, files: files.sort((a, b) => b.size - a.size) }
83
+ return { count: loaded.length, bytes, own, files: files.sort((a, b) => b.size - a.size) }
79
84
  }
80
85
 
81
86
  const KB = (bytes) => `${(bytes / 1024).toFixed(1)} KB`
82
87
 
83
- // A partir de dónde el peso deja de ser el costo de arrancar y pasa a ser una decisión que conviene mirar.
84
- // El piso que Cauce impone —las cuatro reglas del sistema— son 38,3 KB, así que un umbral por debajo de
85
- // eso avisaría en toda instancia recién creada y se apagaría por ruido el primer día: eso descartó los
86
- // 60 KB que el caso 141 proponía. 64 KB deja ~26 KB para lo propio, que son varias reglas de tamaño
87
- // normal, antes de que el aviso hable.
88
+ // A partir de dónde lo que el proyecto agregó deja de ser el costo de arrancar y pasa a ser una decisión
89
+ // que conviene mirar. **Se compara contra lo propio y no contra el total**, y esa es la diferencia que
90
+ // hace al número significar algo.
91
+ //
92
+ // Contra el total, el piso del toolkit y las reglas de la empresa salían del mismo bolsillo: el aviso
93
+ // decía «tu bloque pesa» cuando la mitad la habíamos puesto nosotros, y cada regla que Cauce agregaba le
94
+ // achicaba el margen sin que nadie lo decidiera. El 64 tampoco salió de un costo medido: salió de
95
+ // esquivar nuestro propio piso —el caso 141 proponía 60 y se subió porque lo que Cauce ponía ya eran
96
+ // 61,9 KB—, así que había que reelegirlo cada vez que el toolkit enseñaba algo. Medido sobre lo propio,
97
+ // el número deja de depender de nosotros y no se toca cuando el piso crece.
98
+ //
99
+ // El valor sigue siendo el que había, y eso es a propósito: cambiar qué se mide y cuánto a la vez deja
100
+ // sin saber cuál de los dos movió el resultado. Lo que se sabe hoy es que **64 está por debajo de lo que
101
+ // una empresa real usa**: una instancia medida tiene 93,8 KB de reglas propias, así que el aviso le sale
102
+ // desde el día que instaló. Elegir el número con esa evidencia es una decisión aparte, y la cuenta que la
103
+ // habilita está en el 141: 61,9 KB ≈ 15,9 K tokens, o sea ~3,9 KB por 1K tokens en **cada** agente.
88
104
  const HEAVY = 64 * 1024
89
105
 
90
106
  // La línea que declara el peso, para que la digan igual `install` y `check`. Nombra las dos más grandes
91
107
  // porque es lo accionable: saber que el bloque pesa no dice cuál conviene declarar por superficie.
92
108
  function weightLine(root) {
93
- const { count, bytes, files } = weight(root)
109
+ const { count, bytes, own, files } = weight(root)
94
110
  const top = files.slice(0, 2).map((one) => path.basename(one.file)).join(', ')
111
+ // El total es lo que paga el agente y lo propio es lo único sobre lo que el proyecto puede hacer algo,
112
+ // así que van los dos: con uno solo, o el número no es el costo real o no es accionable.
95
113
  return `el bloque de reglas carga ${count} archivo(s), ${KB(bytes)} en cada agente`
96
- + (top ? ` (las más grandes: ${top})` : '')
114
+ + ` (${KB(own)} propias)${top ? ` (las más grandes: ${top})` : ''}`
97
115
  }
98
116
 
99
117
  // Sólo cuando pasó el umbral. Devuelve lista porque es lo que `check` empalma con el resto de avisos.
100
118
  function heavyRules(root) {
101
- return weight(root).bytes > HEAVY ? [weightLine(root)] : []
119
+ return weight(root).own > HEAVY ? [weightLine(root)] : []
102
120
  }
103
121
 
104
122
  // Sin raíz el marcador queda como está —así lo leen las pruebas que revisan el texto de un adaptador—: un
@@ -11,10 +11,7 @@ const P = require('../planning/parser')
11
11
  const PC = require('../planning/contracts')
12
12
  const AD = require('../planning/adoption')
13
13
  const F = require('../core/files')
14
- const { fail, planningRoot } = require('./io')
15
-
16
- // La fecha de hoy, la misma que usan los comandos que leen.
17
- const TODAY = () => new Date().toISOString().slice(0, 10)
14
+ const { fail, planningRoot, TODAY } = require('./io')
18
15
 
19
16
  // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
20
17
  // a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
@@ -41,6 +41,12 @@ const FLAGS = {
41
41
  flow: ['--json'],
42
42
  }
43
43
 
44
+ // Qué banderas acepta un comando, y `[]` si el comando no existe. `FLAGS[nombre]` a secas resuelve
45
+ // contra `Object.prototype`, así que `constructor` contestaba una función —que es verdadera, y el CLI
46
+ // daba el comando por bueno— y `toString` contestaba un método sin `.includes`, que reventaba acá
47
+ // mismo. No es un nombre exótico: es lo que sale de pasarle a `ops` una variable que vino vacía.
48
+ const accepted = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : [])
49
+
44
50
  // La línea de comandos, leída una sola vez. Antes cada función buscaba sus banderas en
45
51
  // `process.argv`, veinticinco veces y a cualquier profundidad: `evaluationBench` sacaba `--force` de
46
52
  // ahí en vez de recibirlo, así que su firma no decía de qué dependía y probar un comando exigía
@@ -51,6 +57,10 @@ function parse(argv) {
51
57
  const positional = []
52
58
  for (let index = 0; index < argv.length; index += 1) {
53
59
  const value = argv[index]
60
+ // `-h` es la única bandera corta que el CLI anuncia, y como no empieza con `--` caía de posicional:
61
+ // `ops check -h` tomaba `-h` por la raíz del planning y contestaba que ahí no había ninguno. Se
62
+ // normaliza a `--help` para que adentro haya una sola grafía y nadie tenga que preguntar por las dos.
63
+ if (value === '-h') { flags.add('--help'); continue }
54
64
  if (!value.startsWith('--')) { positional.push(value); continue }
55
65
  flags.add(value)
56
66
  if (VALUED_FLAGS.has(value)) { values[value] = argv[index + 1] || ''; index += 1 }
@@ -60,8 +70,12 @@ function parse(argv) {
60
70
  has: (flag) => flags.has(flag),
61
71
  value: (flag, fallback = '') => values[flag] || fallback,
62
72
  // Lo que el comando no declara en `FLAGS`. Se calcula sobre `flags` y no sobre argv crudo para
63
- // que el valor de una bandera con valor no se confunda con una bandera suelta.
64
- unknown: (command) => [...flags].filter((flag) => !FLAGS[command].includes(flag)),
73
+ // que el valor de una bandera con valor no se confunda con una bandera suelta. `--help` la acepta
74
+ // cualquier comando y por eso no la declara ninguno: sin esta excepción, pedir ayuda sería una
75
+ // bandera desconocida.
76
+ unknown: (command) => [...flags]
77
+ .filter((flag) => flag !== '--help')
78
+ .filter((flag) => !accepted(command).includes(flag)),
65
79
  }
66
80
  }
67
81
 
@@ -73,9 +73,9 @@ function benchSurvived(dir, since) {
73
73
  // la rama se ejerce en milisegundos y sobre un temporal que la prueba acaba de crear.
74
74
  function clearBench(dir, scratch, remove = fs.rmSync) {
75
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.`
76
+ const benchRoot = path.resolve(scratch)
77
+ if (!target.startsWith(benchRoot + path.sep)) {
78
+ return `no se borra ${target}: no cuelga de ${benchRoot}, así que no es un banco de evaluación.`
79
79
  }
80
80
  // El instante de arranque, para poder fechar lo que sobreviva: es lo único que separa un archivo que el
81
81
  // borrado no tocó de uno que alguien reescribió mientras borrábamos.
@@ -13,13 +13,13 @@ const O = require('../core/ownership')
13
13
  // El banco desechable y su borrado comprobado. Se reexportan abajo porque su contrato lo fija la suite
14
14
  // del banco, que llega por acá desde antes de que el módulo existiera.
15
15
  const B = require('./bench')
16
- const { fail, opsRoot } = require('./io')
16
+ const { fail, opsRoot, TODAY } = require('./io')
17
17
 
18
18
  function agentsFork(slug, dir) {
19
19
  const root = opsRoot(dir)
20
20
  if (!slug) fail('Falta el cargo: ops agents fork <cargo> [ops-root]', 2)
21
21
  let result
22
- const date = new Date().toISOString().slice(0, 10)
22
+ const date = TODAY()
23
23
  try { result = require('../agents/fork').fork(root, slug, date) } catch (error) { fail(error.message, 2) }
24
24
  console.log(`+ ${path.relative(root, result.dir)} (${result.files.length} archivo(s))`)
25
25
  if (result.skipped.length) {
@@ -179,7 +179,7 @@ function evaluate(agent, caso, cli) {
179
179
  // Dónde escribir el registro de esta corrida. Lo pregunta el recorrido en vez de componer el
180
180
  // nombre, que es lo que hacía que la segunda corrida de un día borrara a la primera.
181
181
  if (cli.has('--record')) {
182
- const day = cli.value('--record') || new Date().toISOString().slice(0, 10)
182
+ const day = cli.value('--record') || TODAY()
183
183
  return console.log(path.relative(root,
184
184
  path.join(EV.resultsDir(root, agent, kind), EV.nextResult(root, agent, day, kind))))
185
185
  }
@@ -9,9 +9,7 @@ const path = require('node:path')
9
9
  const CL = require('../planning/claims')
10
10
  const R = require('../core/repos')
11
11
  const ST = require('../planning/state')
12
- const { fail, planningRoot } = require('./io')
13
-
14
- const TODAY = () => new Date().toISOString().slice(0, 10)
12
+ const { fail, planningRoot, TODAY } = require('./io')
15
13
 
16
14
  function claim(dir, slug, cli) {
17
15
  const root = planningRoot(dir)
@@ -22,8 +20,8 @@ function claim(dir, slug, cli) {
22
20
 
23
21
  // No se reserva lo que todavía no se puede empezar: una tarea tomada con su dependencia en vuelo
24
22
  // bloquea la cola sin que nadie pueda avanzarla, y el runner que la tomó se queda sin poder tomar otra.
25
- const falta = task.depends.find((dep) => !state.done.set.has(dep))
26
- if (falta) return fail(`${slug} depende de ${falta}, que todavía no está en DONE.`)
23
+ const blocker = task.depends.find((dep) => !state.done.set.has(dep))
24
+ if (blocker) return fail(`${slug} depende de ${blocker}, que todavía no está en DONE.`)
27
25
 
28
26
  const me = CL.owner(root)
29
27
  const from = CL.runner()
@@ -45,7 +43,7 @@ function claim(dir, slug, cli) {
45
43
 
46
44
  const target = CL.file(root, slug)
47
45
  fs.mkdirSync(path.dirname(target), { recursive: true })
48
- const cuerpo = CL.content({ task: slug, owner: me, runner: from, started: TODAY(), service: task.service })
46
+ const body = CL.content({ task: slug, owner: me, runner: from, started: TODAY(), service: task.service })
49
47
  try {
50
48
  // Reservar **es** crear el archivo, así que el único juez de quién la tiene es el archivo. `wx` falla
51
49
  // si ya está, y de ahí sale la respuesta entera: propia, ajena o perdida en la carrera.
@@ -54,25 +52,25 @@ function claim(dir, slug, cli) {
54
52
  // de diferencia ganarían los dos sin que ninguno se entere. Y una comprobación previa tampoco
55
53
  // alcanzaría —entre mirar y escribir queda la misma ventana—, así que sería un segundo juez que
56
54
  // adelanta un veredicto que este bloque tiene que volver a dar igual.
57
- fs.writeFileSync(target, cuerpo, { flag: 'wx' })
55
+ fs.writeFileSync(target, body, { flag: 'wx' })
58
56
  } catch (error) {
59
57
  if (error.code !== 'EEXIST') throw error
60
- const dueño = CL.read(root).find((one) => one.slug === slug)
58
+ const holder = CL.read(root).find((one) => one.slug === slug)
61
59
  // Existía al crear y ya no está: alguien la soltó entre las dos operaciones. Es una ventana de
62
60
  // microsegundos y aun así tiene respuesta, porque la alternativa es reventar con un TypeError.
63
- if (!dueño) return fail(`${slug} cambió de manos mientras la pedías; volvé a intentarlo.`)
64
- if (dueño.runner === from) return console.log(`= ${slug} ya era tuya desde ${dueño.started}`)
61
+ if (!holder) return fail(`${slug} cambió de manos mientras la pedías; volvé a intentarlo.`)
62
+ if (holder.runner === from) return console.log(`= ${slug} ya era tuya desde ${holder.started}`)
65
63
  // Mismo dueño y otro runner son dos situaciones que se ven idénticas desde acá —vos retomando la
66
64
  // sesión de ayer, o un segundo agente tuyo— y ninguna se puede distinguir mirando el archivo.
67
65
  // Retomarla sola le sacaría la tarea al otro agente; crear un runner nuevo dejaría dos trabajando lo
68
66
  // mismo. Las dos rompen trabajo, así que decide una persona y acá sólo se dice cuál es cuál.
69
- if (dueño.owner === me) {
70
- return fail(`${slug} la tenés vos, tomada el ${dueño.started} desde el runner ${dueño.runner}. `
67
+ if (holder.owner === me) {
68
+ return fail(`${slug} la tenés vos, tomada el ${holder.started} desde el runner ${holder.runner}. `
71
69
  + 'Preguntá si se retoma esa sesión —y entonces corré con ese id— o si es otro agente en '
72
70
  + `paralelo, que toma otra tarea. \`ops runners ${path.relative(process.cwd(), root) || '.'}\` `
73
71
  + 'lista lo que hay abierto.')
74
72
  }
75
- return fail(`${slug} la tomó ${dueño.owner} el ${dueño.started}. Si se abandonó, borrá `
73
+ return fail(`${slug} la tomó ${holder.owner} el ${holder.started}. Si se abandonó, borrá `
76
74
  + `${CL.DIR}/${slug}.md a mano: soltar lo de otro es una decisión, no un comando.`)
77
75
  }
78
76
  console.log(`✓ ${slug} tomada por ${me}`)
@@ -111,20 +109,20 @@ function release(dir, slug) {
111
109
  function runners(dir, cli) {
112
110
  const root = planningRoot(dir)
113
111
  const done = ST.snapshot(root).done
114
- const abiertos = CL.read(root).filter((one) => !done.set.has(one.slug))
115
- const hoy = TODAY()
116
- const filas = abiertos.map((one) => {
112
+ const open = CL.read(root).filter((one) => !done.set.has(one.slug))
113
+ const today = TODAY()
114
+ const rows = open.map((one) => {
117
115
  const commit = R.lastCommit(R.repoOf(path.join(root, '..'), one.service), CL.branchOf(one.slug))
118
116
  return { runner: one.runner, task: one.slug, owner: one.owner, started: one.started, lastCommit: commit }
119
117
  })
120
- if (cli.has('--json')) return console.log(JSON.stringify(filas))
121
- if (!filas.length) return console.log('= ningún runner tiene trabajo abierto: arrancá con un id propio')
122
- const ancho = Math.max(...filas.map((one) => one.runner.length))
123
- for (const una of filas) {
124
- const avance = una.lastCommit ? `último commit ${una.lastCommit}` : 'sin commits en su rama'
125
- console.log(`${una.runner.padEnd(ancho)} ${una.task} (${una.owner}, desde ${una.started}; ${avance})`)
118
+ if (cli.has('--json')) return console.log(JSON.stringify(rows))
119
+ if (!rows.length) return console.log('= ningún runner tiene trabajo abierto: arrancá con un id propio')
120
+ const width = Math.max(...rows.map((one) => one.runner.length))
121
+ for (const row of rows) {
122
+ const progress = row.lastCommit ? `último commit ${row.lastCommit}` : 'sin commits en su rama'
123
+ console.log(`${row.runner.padEnd(width)} ${row.task} (${row.owner}, desde ${row.started}; ${progress})`)
126
124
  }
127
- console.log(`\n${filas.length} runner(s) con trabajo abierto al ${hoy}. `
125
+ console.log(`\n${rows.length} runner(s) con trabajo abierto al ${today}. `
128
126
  + 'Retomá uno usando su id, o arrancá con uno propio.')
129
127
  }
130
128
 
@@ -83,9 +83,18 @@ const MARKED = /^###\s+Límites\s*$/m
83
83
  // llevaba adentro los cuatro párrafos que la persona había agregado al final de la sección. Descontar el
84
84
  // bloque entero —que es lo que parecía el arreglo— apagaba justo lo que el aviso existe para encontrar.
85
85
  const BULLET = /^\s*[-*]\s+/
86
+ // Una viñeta es la viñeta entera, no su primera línea. Un límite de verdad no entra en el ancho del
87
+ // archivo, así que se escribe en dos, y recorriendo línea por línea lo que viajaba al preámbulo de cada
88
+ // subagente era la mitad — cortada justo donde suele estar lo que el límite decide (caso 168).
89
+ //
90
+ // `own` son las líneas **sin plegar**, y existe para `warnings`: descuenta por línea contra lo que el
91
+ // archivo tiene escrito (caso 159), y una viñeta ya plegada no coincide con ninguna de esas líneas, así
92
+ // que su continuación volvía a contarse como un párrafo perdido. Son dos preguntas distintas sobre el
93
+ // mismo bloque: qué dice cada límite, y qué líneas ya están cubiertas.
86
94
  function marked(raw) {
87
95
  const bullets = []
88
96
  const intros = []
97
+ const own = []
89
98
  let rest = raw
90
99
  for (let start = rest.search(MARKED); start >= 0; start = rest.search(MARKED)) {
91
100
  const after = rest.slice(start).split('\n').slice(1)
@@ -93,13 +102,21 @@ function marked(raw) {
93
102
  const block = end < 0 ? after : after.slice(0, end)
94
103
  const first = block.findIndex((line) => BULLET.test(line))
95
104
  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))
105
+ // Una línea en blanco cierra la viñeta abierta: lo que venga después es otra cosa y pegarlo ahí
106
+ // uniría dos límites en uno.
107
+ let open = -1
108
+ for (const line of first < 0 ? [] : block.slice(first)) {
109
+ const text = line.replace(BULLET, '').trim()
110
+ if (!text) { open = -1; continue }
111
+ // A `own` va lo que pertenece a una viñeta y nada más. Empujar toda línea no vacía haría que la
112
+ // prosa suelta que quedó bajo el encabezado contara como declarada y `warnings` dejara de nombrarla:
113
+ // el plegado apagaría el aviso en vez de arreglar el límite.
114
+ if (BULLET.test(line)) { bullets.push(text); open = bullets.length - 1; own.push(text) }
115
+ else if (open >= 0) { bullets[open] += ` ${text}`; own.push(text) }
116
+ }
100
117
  rest = (end < 0 ? '' : after.slice(end).join('\n'))
101
118
  }
102
- return { bullets, intros }
119
+ return { bullets, intros, own }
103
120
  }
104
121
 
105
122
  const declared = (raw) => marked(raw).bullets
@@ -147,8 +164,8 @@ function warnings(root) {
147
164
  // Se descuenta por línea y no por párrafo, que es donde estaba el defecto: `paragraphs` saca el `- ` y
148
165
  // une las viñetas seguidas en un párrafo solo, así que una lista declarada no era igual a ninguna
149
166
  // 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])
167
+ const { own, intros } = marked(mine)
168
+ const suyo = new Set([...own, ...intros])
152
169
  const outside = P.withoutComments(mine).split('\n')
153
170
  .filter((line) => !suyo.has(line.replace(BULLET, '').trim()))
154
171
  .join('\n')
@@ -15,7 +15,7 @@ const P = require('../planning/parser')
15
15
  const ST = require('../planning/state')
16
16
  const RC = require('../planning/recurring')
17
17
  const A = require('../automation')
18
- const { fail } = require('./io')
18
+ const { fail, TODAY } = require('./io')
19
19
  const { declareEngine, pinEngine, undeclareEngine } = require('./dependency')
20
20
  const { adviceFor, previewUpgrade, reportUpgrade } = require('./upgrade-report')
21
21
 
@@ -98,7 +98,7 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
98
98
  '{{PROJECT_NAME}}': name,
99
99
  '{{MODE}}': mode,
100
100
  '{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
101
- ...RC.sinceValues(new Date().toISOString().slice(0, 10)),
101
+ ...RC.sinceValues(TODAY()),
102
102
  }, force, providerNames(), quiet)
103
103
  // No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
104
104
  // el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando lo que no aplica dejaba
@@ -309,17 +309,17 @@ function upgrade(dir, cli) {
309
309
  //
310
310
  // El aviso sale en **cada** corrida y no sólo la primera: una instancia con medio molde congelado y
311
311
  // sin enterarse es el otro modo de fallo, y es silencioso.
312
- const conservados = force ? new Set() : new Set(changed)
313
- if (conservados.size) {
314
- for (const file of conservados) console.log(`= conservado ${file} (editado localmente)`)
315
- console.log(`\n${conservados.size} archivo(s) del molde quedan congelados por tu edición.`)
316
- console.log(`${adviceFor([...conservados])}\n`)
312
+ const keptFiles = force ? new Set() : new Set(changed)
313
+ if (keptFiles.size) {
314
+ for (const file of keptFiles) console.log(`= conservado ${file} (editado localmente)`)
315
+ console.log(`\n${keptFiles.size} archivo(s) del molde quedan congelados por tu edición.`)
316
+ console.log(`${adviceFor([...keptFiles])}\n`)
317
317
  console.log('Para tomar la versión nueva y descartar la tuya, repetí con --force.\n')
318
318
  }
319
319
  // Un archivo propio que se llama como uno que el paquete empieza a traer (caso 110): se conserva y se
320
320
  // dice, igual que una edición local, y `--force` lo reemplaza diciéndolo.
321
- const choques = new Set(force ? [] : colliding)
322
- for (const file of choques) {
321
+ const collisions = new Set(force ? [] : colliding)
322
+ for (const file of collisions) {
323
323
  console.log(`= conservado ${file}: ya existía y Cauce no lo entregó, así que el del paquete no se `
324
324
  + 'instaló. Renombrá el tuyo y repetí, o repetí con --force para reemplazarlo.')
325
325
  }
@@ -345,7 +345,7 @@ function upgrade(dir, cli) {
345
345
  '{{PROJECT_NAME}}': config.project || path.basename(root),
346
346
  '{{MODE}}': O.mode(root),
347
347
  '{{WORKSPACE_PATH}}': O.mode(root) === 'embedded' ? '.' : '..',
348
- ...RC.sinceValues(new Date().toISOString().slice(0, 10)),
348
+ ...RC.sinceValues(TODAY()),
349
349
  })) content = content.replaceAll(key, value)
350
350
  F.atomicWrite(target, content)
351
351
  added.push(relative)
@@ -368,13 +368,13 @@ function upgrade(dir, cli) {
368
368
 
369
369
  const conservar = (file) => {
370
370
  const relative = path.relative(root, file).replace(/\\/g, '/')
371
- return conservados.has(relative) || choques.has(relative)
371
+ return keptFiles.has(relative) || collisions.has(relative)
372
372
  }
373
373
  for (const relative of [...system, ...O.RUNTIME_PATHS]) {
374
374
  const origin = path.join(PROJECT_ROOT, O.sourceOf(relative))
375
375
  if (!fs.existsSync(origin)) continue
376
376
  const target = path.join(root, relative)
377
- if (conservados.has(relative)) continue
377
+ if (keptFiles.has(relative)) continue
378
378
  // Sobrescribe lo que trae el paquete y deja intacto lo demás: un guard propio de la empresa,
379
379
  // o un adaptador de runner que el toolkit no conoce, sobreviven a la actualización.
380
380
  if (fs.statSync(origin).isDirectory()) copyRuntime(origin, target, false, root, [], conservar)
@@ -389,7 +389,7 @@ function upgrade(dir, cli) {
389
389
 
390
390
  // Retirar lo que el toolkit ya no distribuye, después de haber actualizado lo que sí.
391
391
  const retired = []
392
- const pendientes = []
392
+ const pending = []
393
393
  for (const relative of O.RETIRED) {
394
394
  const target = path.join(root, relative)
395
395
  if (!fs.existsSync(target)) continue
@@ -398,10 +398,10 @@ function upgrade(dir, cli) {
398
398
  // también usa para lo suyo, borrar el directorio entero se llevaba puesto contenido que nadie
399
399
  // había entregado —un `autobuild.js` propio, los workflows de una empresa— sin confirmación y sin
400
400
  // vuelta atrás. Se conserva y se nombra; `--force` es la salida, igual que para una edición local.
401
- const contenido = O.RETIRED_COMPARTIDO.includes(relative) && fs.statSync(target).isDirectory()
401
+ const leftover = O.RETIRED_SHARED.includes(relative) && fs.statSync(target).isDirectory()
402
402
  ? O.treeFiles(target)
403
403
  : []
404
- if (contenido.length && !force) { pendientes.push({ relative, files: contenido }); continue }
404
+ if (leftover.length && !force) { pending.push({ relative, files: leftover }); continue }
405
405
  fs.rmSync(target, { recursive: true, force: true })
406
406
  retired.push(relative)
407
407
  }
@@ -413,7 +413,7 @@ function upgrade(dir, cli) {
413
413
  // conservar **ese** digest: registrar el de disco lo volvería idéntico a lo entregado, dejaría de
414
414
  // detectarse como editado y la corrida siguiente lo pisaría sin decir nada. Es el 001 de vuelta por
415
415
  // la puerta de atrás, y no se ve mirando el archivo — se ve dos upgrades después.
416
- const entregado = { ...record }
416
+ const delivered = { ...record }
417
417
  for (const relative of O.trackedPaths()) {
418
418
  if (fs.existsSync(path.join(root, relative))) {
419
419
  record = M.record(root, relative, O.deliveredFiles(root, relative), record)
@@ -428,8 +428,8 @@ function upgrade(dir, cli) {
428
428
  }
429
429
  }
430
430
  record = M.recordPaths(root, O.SYSTEM_FILES, record)
431
- for (const file of conservados) if (entregado[file]) record[file] = entregado[file]
432
- for (const file of choques) delete record[file]
431
+ for (const file of keptFiles) if (delivered[file]) record[file] = delivered[file]
432
+ for (const file of collisions) delete record[file]
433
433
  // El registro de forks se poda igual que el de archivos: un cargo devuelto al catálogo deja su
434
434
  // entrada, y una entrada sin copia sólo puede producir avisos sobre algo que no está.
435
435
  const kept = Object.fromEntries(Object.entries(M.readForks(root)).filter(
@@ -447,7 +447,7 @@ function upgrade(dir, cli) {
447
447
  // que no ocurrieron (caso 048).
448
448
  reportUpgrade({
449
449
  root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
450
- descartados: force ? changed : [], conservados: [...conservados], pendientes,
450
+ discarded: force ? changed : [], keptFiles: [...keptFiles], pending,
451
451
  })
452
452
  }
453
453
 
package/engine/cli/ops.js CHANGED
@@ -195,11 +195,15 @@ const NO_FLAGS = { has: () => false, value: (_flag, fallback = '') => fallback }
195
195
 
196
196
  async function run(cli) {
197
197
  const [command] = cli.positional
198
- if (!command || ['help', '--help', '-h'].includes(command)) return usage()
199
- if (!FLAGS[command]) { usage(); fail(`Comando desconocido: ${command}`, 2) }
198
+ // `--help` y `-h` nunca llegan acá: `parse` los reconoce como banderas, así que el posicional queda
199
+ // vacío y lo atiende el `!command` de al lado. Lo que sí es un posicional es `help` a secas.
200
+ if (!command || command === 'help') return usage()
201
+ // `Object.hasOwn` y no `FLAGS[command]`: `constructor` heredado de `Object.prototype` pasaba por
202
+ // comando válido y el CLI salía con 0 sin hacer nada.
203
+ if (!Object.hasOwn(FLAGS, command)) { usage(); fail(`Comando desconocido: ${command}`, 2) }
200
204
  // `--help` valía sólo como primer argumento: `check --help` corría `check` contra el directorio
201
205
  // actual en vez de explicarse.
202
- if (cli.has('--help') || cli.has('-h')) return usage()
206
+ if (cli.has('--help')) return usage()
203
207
  const unknown = cli.unknown(command)
204
208
  if (unknown.length) {
205
209
  const accepts = FLAGS[command].length ? `Acepta: ${FLAGS[command].join(', ')}.` : 'No acepta banderas.'
@@ -141,7 +141,7 @@ function context(dir, cli) {
141
141
  // Un filtro elige dónde buscar trabajo **nuevo**; no puede esconder el que ya tenés. Sin esto, pedir
142
142
  // otro hito mientras sostenías una tarea ofrecía una segunda que `claim` después se niega a dar: el
143
143
  // comando que dice qué hacer y el que lo autoriza contestaban distinto, y sólo se veía al reclamar.
144
- const propio = state.claims.find((one) => one.runner === from && !state.done.set.has(one.slug))
144
+ const own = state.claims.find((one) => one.runner === from && !state.done.set.has(one.slug))
145
145
  let hitoOmitido = ''
146
146
  if (hito) {
147
147
  const existe = state.milestones.some((one) => one.slug === hito)
@@ -150,7 +150,7 @@ function context(dir, cli) {
150
150
  const hay = state.milestones.map((one) => one.slug).join(', ') || '(ninguno)'
151
151
  return fail(`el hito ${hito} no existe. Hay: ${hay}`, 2)
152
152
  }
153
- if (propio) hitoOmitido = `${hito} no se aplica: ya tenés ${propio.slug} tomada`
153
+ if (own) hitoOmitido = `${hito} no se aplica: ya tenés ${own.slug} tomada`
154
154
  else state.milestones = state.milestones.filter((one) => one.slug === hito)
155
155
  }
156
156
  const gate = path.join(root, 'AWAITING_REVIEW.md')
@@ -222,13 +222,13 @@ function context(dir, cli) {
222
222
  // Es el comando que existe para decir qué toca ahora, contestando «nada» cuando lo que toca es eso.
223
223
  // Tu propio nombre en una tarea «ajena» es la señal de que sos vos desde otro runner, y sin decirlo se
224
224
  // lee como que alguien te ganó la tarea.
225
- const dueño = (one) => (one.owner === report.owner ? `${one.owner} — vos, desde otro runner` : one.owner)
225
+ const ownerLabel = (one) => (one.owner === report.owner ? `${one.owner} — vos, desde otro runner` : one.owner)
226
226
  // Que el reclamo sea tuyo desde otro id ya se decía; que además haya un **plan escrito** bajo ese id, no.
227
227
  // Esa es la mitad que cuesta la sesión: los pasos ya hechos están en un archivo que nadie nombra, y el id
228
228
  // que lo recupera es justo el que esta sesión no supo deducir. Los dos datos están en el reclamo.
229
229
  const tomadas = () => {
230
230
  for (const one of report.taken) {
231
- console.log(`TAKEN ${one.slug} (${dueño(one)})`)
231
+ console.log(`TAKEN ${one.slug} (${ownerLabel(one)})`)
232
232
  if (one.owner === report.owner && one.wip) {
233
233
  console.log(`PLAN ${one.slug}: su plan está en wip/${one.wip} — retomalo con `
234
234
  + `\`export CAUCE_RUNNER=${one.runner}\``)
@@ -26,7 +26,7 @@ function adviceFor(changed) {
26
26
  const advice = []
27
27
  if (ruleFiles.length) {
28
28
  advice.push(
29
- 'Las ruleFiles y decisiones bajo system/ son del toolkit. Para cambiar una, escribí la tuya al\n'
29
+ 'Las reglas y decisiones bajo system/ son del toolkit. Para cambiar una, escribí la tuya al\n'
30
30
  + 'lado con el mismo ID: el proyecto manda y `check` lo reporta como override explícito.',
31
31
  )
32
32
  }
@@ -47,11 +47,11 @@ function adviceFor(changed) {
47
47
  // El consejo de arriba manda mudar lo propio a donde sí es del proyecto, y para estos cuatro no hay
48
48
  // adónde: no existe un PROTOCOL de la empresa que le gane al del toolkit como sí lo hay en `rules/`.
49
49
  // Sin decirlo, quien adoptó Cauce sobre su propio proceso lee un consejo que no puede seguir.
50
- const SIN_CONTRAPARTE = ['planning/PROTOCOL.md', 'planning/METHODOLOGY.md', 'planning/FLOW.md', 'Makefile']
51
- const propios = docs.filter((file) => SIN_CONTRAPARTE.includes(file))
52
- if (propios.length) {
50
+ const NO_COUNTERPART = ['planning/PROTOCOL.md', 'planning/METHODOLOGY.md', 'planning/FLOW.md', 'Makefile']
51
+ const ownDocs = docs.filter((file) => NO_COUNTERPART.includes(file))
52
+ if (ownDocs.length) {
53
53
  advice.push(
54
- `${propios.join(', ')} no tienen contraparte propia adónde mudarse: son del toolkit y no hay\n`
54
+ `${ownDocs.join(', ')} no tienen contraparte propia adónde mudarse: son del toolkit y no hay\n`
55
55
  + 'una versión del proyecto que le gane. Quedan congelados con tu versión y el resto se actualiza\n'
56
56
  + 'igual. Adoptar el del toolkit es trabajo propio —comparar los dos procesos y decidir—, no un flag.',
57
57
  )
@@ -74,18 +74,18 @@ function adviceFor(changed) {
74
74
  // entero en vez de recalcular nada: lo que se informa es exactamente lo que ocurrió.
75
75
  function reportUpgrade({
76
76
  root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
77
- descartados, conservados, pendientes,
77
+ discarded, keptFiles, pending,
78
78
  }) {
79
79
  console.log(`✓ Cauce ${from || '(previa)'} → ${to}`)
80
80
  // Descartar con --force es legítimo; hacerlo sin dejar rastro no. Queda en la salida del comando,
81
81
  // que es la evidencia que el protocolo pide para cualquier cambio. Y lo que se conservó no se vuelve
82
82
  // a enumerar acá: ya salió con su consejo antes de escribir nada, y repetirlo con el glifo del
83
83
  // descarte es lo que volvía ilegible el bloque.
84
- for (const file of descartados) console.log(`− descartado tu cambio en ${file}`)
85
- if (conservados.length) console.log(`= ${conservados.length} archivo(s) conservados por tu edición`)
84
+ for (const file of discarded) console.log(`− descartado tu cambio en ${file}`)
85
+ if (keptFiles.length) console.log(`= ${keptFiles.length} archivo(s) conservados por tu edición`)
86
86
  for (const relative of retired) console.log(`− retirado ${relative}: Cauce ya no lo distribuye`)
87
87
  // Sin glifo de acción porque no hubo ninguna: la ruta sigue ahí y el contenido también.
88
- for (const { relative, files } of pendientes) {
88
+ for (const { relative, files } of pending) {
89
89
  console.log(` ${relative}: ${files.length} archivo(s) que Cauce no entregó; la ruta se retiró y `
90
90
  + 'el contenido queda. Movelo adonde lo quieras y borrala, o repetí con --force.')
91
91
  }
@@ -103,7 +103,7 @@ function reportUpgrade({
103
103
  }
104
104
  // Sólo cuando es cierto, y ahora lo decide lo que pasó y no una condición: se dice justo cuando no
105
105
  // se descartó nada, que incluye la corrida que conservó veinte archivos.
106
- if (!descartados.length) console.log(' planning, organization y todo lo propio quedaron intactos')
106
+ if (!discarded.length) console.log(' planning, organization y todo lo propio quedaron intactos')
107
107
  // No se borra: sin la dependencia declarada, quitarle `.ops/` la dejaría sin motor. Se avisa y
108
108
  // decide una persona.
109
109
  if (fs.existsSync(path.join(root, '.ops', 'engine'))) {
@@ -137,19 +137,19 @@ function check(dir, cli) {
137
137
  // Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
138
138
  // avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
139
139
  // en cada corrida y no sólo el día que alguien actualiza.
140
- const congelados = O.localChanges(path.resolve(root, '..'))
141
- if (congelados.length) {
142
- warnings.push(`${congelados.length} archivo(s) del molde congelados por edición local; `
140
+ const frozen = O.localChanges(path.resolve(root, '..'))
141
+ if (frozen.length) {
142
+ warnings.push(`${frozen.length} archivo(s) del molde congelados por edición local; `
143
143
  + '`upgrade` los conserva y no les trae mejoras')
144
144
  }
145
145
 
146
146
  // Y lo que `upgrade` no retiró porque no pudo demostrar que fuera suyo: queda ahí, sin colgar de
147
147
  // ningún mecanismo, hasta que alguien lo mueva o lo borre. Se cuenta por lo mismo que los congelados
148
148
  // — un resto que no se ve se vuelve permanente.
149
- const restos = O.RETIRED_COMPARTIDO.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
150
- if (restos.length) {
151
- warnings.push(`${restos.length} ruta(s) retiradas siguen en disco con contenido tuyo `
152
- + `(${restos.join(', ')}); Cauce ya no las distribuye ni las toca`)
149
+ const leftovers = O.RETIRED_SHARED.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
150
+ if (leftovers.length) {
151
+ warnings.push(`${leftovers.length} ruta(s) retiradas siguen en disco con contenido tuyo `
152
+ + `(${leftovers.join(', ')}); Cauce ya no las distribuye ni las toca`)
153
153
  }
154
154
 
155
155
  const integration = I.validate(path.resolve(root, '..'))