@ingeniomaps/cauce 0.94.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 (39) hide show
  1. package/CHANGELOG.md +196 -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/cli/archive.js +1 -4
  11. package/engine/cli/args.js +16 -2
  12. package/engine/cli/bench.js +3 -3
  13. package/engine/cli/catalog.js +3 -3
  14. package/engine/cli/claims.js +21 -23
  15. package/engine/cli/contract.js +24 -7
  16. package/engine/cli/instance.js +19 -19
  17. package/engine/cli/ops.js +7 -3
  18. package/engine/cli/planning.js +4 -4
  19. package/engine/cli/upgrade-report.js +10 -10
  20. package/engine/cli/validate.js +7 -7
  21. package/engine/core/onboarding.js +4 -1
  22. package/engine/core/ownership.js +2 -2
  23. package/engine/core/repos.js +18 -12
  24. package/engine/hooks/approval.js +9 -1
  25. package/engine/hooks/chat.js +27 -5
  26. package/engine/hooks/files.js +6 -6
  27. package/engine/hooks/input.js +5 -2
  28. package/engine/hooks/run.js +3 -3
  29. package/engine/hooks/shell.js +20 -6
  30. package/engine/hooks/verify.js +7 -7
  31. package/engine/integrations/proposals.js +5 -1
  32. package/engine/integrations/registry.js +7 -0
  33. package/engine/integrations/state.js +6 -3
  34. package/engine/planning/adoption.js +2 -2
  35. package/engine/planning/claims.js +8 -8
  36. package/engine/planning/parser.js +8 -8
  37. package/engine/planning/state.js +2 -2
  38. package/engine/planning/structure.js +7 -7
  39. package/package.json +1 -1
@@ -157,7 +157,10 @@ function gitDirectory(command, cwd) {
157
157
  // de correr: gobernanza, dependencias y generados. Cualquier variable delante alcanza, y la ironía es
158
158
  // que el prefijo que el procedimiento manda escribir para un commit de gobernanza es
159
159
  // `OPS_GOVERNANCE_OVERRIDE=1`: escrito ahí, el guard no lee el override, directamente no se ejecuta.
160
- const PREFIX = String.raw`(?:^|[;&|]\s*)(?:(?:env|sudo)\s+)*`
160
+ // El ancla admite el salto de línea y el `(` por lo mismo que admite `;`: los tres abren un comando. El
161
+ // salto es el que más caro salía —dos líneas en un solo Bash es como se escriben dos pasos— y el léxico
162
+ // de `shell.js` ya lo declaraba separador; acá faltaba (caso 165).
163
+ const PREFIX = String.raw`(?:^|[;&|(\n]\s*)(?:(?:env|sudo)\s+)*`
161
164
  + String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+)*`
162
165
  const COMMIT = new RegExp(PREFIX + String.raw`git\s+commit(?:\s|$)`)
163
166
 
@@ -230,7 +233,7 @@ function findOpsRoot(start) {
230
233
  // Sin raíz legible no hay lista, y quien pregunta se abstiene: el guard que no sabe dónde está no
231
234
  // inventa un límite.
232
235
  function writableRoots(input) {
233
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
236
+ const root = opsRoot(input)
234
237
  if (!root) return null
235
238
  const config = configOf(root)
236
239
  return [
@@ -9,7 +9,7 @@
9
9
  const fs = require('node:fs')
10
10
  const os = require('node:os')
11
11
  const path = require('node:path')
12
- const { readInput, cwdOf, block, findOpsRoot } = require('./input')
12
+ const { readInput, block, opsRoot } = require('./input')
13
13
  const shell = require('./shell')
14
14
  const { verify } = require('./verify')
15
15
  const files = require('./files')
@@ -18,7 +18,7 @@ const { secretsShell } = require('./secrets-shell')
18
18
  const { opsConfig, opsConfigShell } = require('./ops-config')
19
19
 
20
20
  function planningDrift(input) {
21
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
21
+ const root = opsRoot(input)
22
22
  if (!root) return
23
23
  const local = path.join(root, 'tools', 'ops.js')
24
24
  const source = path.join(root, 'engine', 'cli', 'ops.js')
@@ -202,5 +202,5 @@ if (require.main === module) {
202
202
  }
203
203
 
204
204
  module.exports = {
205
- execute, executeAll, findOpsRoot, guards, hookGroups, hookMetadata,
205
+ execute, executeAll, guards, hookGroups, hookMetadata,
206
206
  }
@@ -120,7 +120,13 @@ function destructive(input) {
120
120
  'Operación destructiva sobre disco o dispositivo.',
121
121
  ],
122
122
  [
123
- new RegExp(String.raw`\brm\s+(?:-[^\s]*r[^\s]*\s+)+(?:\/\*?|~\/?|\$HOME|\.\.)(?=${PALABRA})`),
123
+ // El destino se reconoce como lo escribe una persona y no sólo desnudo: entre comillas, con llaves,
124
+ // detrás de `--` y con barra final. Las comillas importan más que las otras tres — `rm -rf "$HOME"`
125
+ // es la forma *correcta* de escribirlo en bash, así que sin esto el hueco premiaba al que cita bien
126
+ // sus variables. Y `destructive` no desentrecomilla fuera de un commit, a propósito, así que la
127
+ // comilla tiene que entrar en el patrón (caso 167).
128
+ new RegExp(String.raw`\brm\s+(?:-[^\s]*r[^\s]*\s+)+(?:--\s+)?['"]?`
129
+ + String.raw`(?:\/\*?|~\/?|\$\{?HOME\}?\/?|\.\.)(?=${PALABRA})`),
124
130
  "'rm -r' sobre /, home o el directorio padre es catastrófico.",
125
131
  ],
126
132
  ]
@@ -252,7 +258,12 @@ function dependencies(input) {
252
258
  // era la causa —sólo hacía que la lectura frenara en un token que sobrevive—: sin él la lista cruzaba
253
259
  // igual y el último token era la marca de lo entrecomillado, que el filtro final descarta. O sea que
254
260
  // pasaba de casualidad, y aserciar que pasa no fijaba nada.
255
- const REDIRECT = /(?:^|[\s(])&?\d*>>?\s*(?![&(])([^\s;|&<>()]+)/g
261
+ // Y el `|` después de `>` es el override de `noclobber`, no una tubería: `>| destino` escribe igual que
262
+ // `> destino` aunque el shell esté configurado para no pisar. Sin admitirlo acá el guard no veía ese
263
+ // destino —el `|` está fuera de la clase que captura la ruta, así que la coincidencia moría—, y con él
264
+ // se perdían los tres guards que salen de `writesWithBase` a la vez. Lo que un agente se escribía por
265
+ // ese hueco era su propia `.ops-approval` (caso 164).
266
+ const REDIRECT = /(?:^|[\s(])&?\d*>>?\|?\s*(?![&(])([^\s;|&<>()]+)/g
256
267
  const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()\n]+)/g
257
268
  const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()\n]+)/g
258
269
  const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()\n]+)/g
@@ -315,7 +326,10 @@ function cdTarget(argument, base) {
315
326
  function writesWithBase(command, cwd) {
316
327
  const found = []
317
328
  let base = cwd
318
- for (const segment of unquoted(command).split(/[;&|\n]+/)) {
329
+ // El `|` que sigue a un `>` no parte nada: es el override de `noclobber`, no una tubería. Partir ahí
330
+ // separaba la redirección de su destino —`echo x >| ruta` quedaba como `echo x >` y ` ruta`— y el
331
+ // destino no lo veía nadie, que es por donde se colaba escribir la propia `.ops-approval` (caso 164).
332
+ for (const segment of unquoted(command).split(/[;&\n]+|(?<!>)\|+/)) {
319
333
  const cd = segment.match(/^\s*cd(?:\s+(\S+))?\s*$/)
320
334
  if (cd) { base = base === null ? null : cdTarget(cd[1], base); continue }
321
335
  for (const raw of writeTargets(segment)) found.push({ raw, base })
@@ -380,9 +394,9 @@ function governance(input) {
380
394
  // La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
381
395
  // reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
382
396
  // entre una llave por operación y una puerta que quedó abierta.
383
- const pendientes = AP.pendingNow(opsRoot(input), governed, input)
384
- if (!pendientes.length) return
385
- block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
397
+ const pending = AP.pendingNow(opsRoot(input), governed, input)
398
+ if (!pending.length) return
399
+ block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pending, input)}`)
386
400
  }
387
401
 
388
402
  function run(program, args, cwd, extra = {}) {
@@ -216,15 +216,15 @@ function fallo(gate, result) {
216
216
  // Un gate que vuelve en menos de esto no corrió una suite. No se afirma que **no** haya corrido —un
217
217
  // lint puede fallar rápido y de verdad— y por eso lo que se agrega es el número, no un veredicto: los
218
218
  // tres gates del caso 068 volvieron a un segundo uno de otro contra los trece de la corrida real.
219
- const DEMASIADO_RAPIDO = 2000
220
- function comoSeLee(failures) {
221
- const texto = failures
219
+ const TOO_FAST = 2000
220
+ function howItReads(failures) {
221
+ const summary = failures
222
222
  .map((one) => `${one.gate} (exit ${one.status}, ${(one.ms / 1000).toFixed(1)} s)`
223
223
  + `${one.line ? `: ${one.line}` : ''}`)
224
224
  .join('; ')
225
- if (!failures.every((one) => one.ms < DEMASIADO_RAPIDO)) return texto
226
- const cuantos = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
227
- return `${texto}\n${cuantos} en menos de ${DEMASIADO_RAPIDO / 1000} s: eso no alcanza para correr `
225
+ if (!failures.every((one) => one.ms < TOO_FAST)) return summary
226
+ const lead = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
227
+ return `${summary}\n${lead} en menos de ${TOO_FAST / 1000} s: eso no alcanza para correr `
228
228
  + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
229
229
  }
230
230
 
@@ -268,7 +268,7 @@ function verifyGates(root, dir, sinAprobar, env, input) {
268
268
  // comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
269
269
  const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
270
270
  + 'directorio pasa, es que en disco tenés algo que no está staged.'
271
- block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
271
+ block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${donde}\n`
272
272
  + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
273
273
  }
274
274
 
@@ -40,7 +40,11 @@ function validate(root, provider, workspaces = []) {
40
40
  let maxHours = 4
41
41
  try {
42
42
  const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
43
- maxHours = config.runner.maxTaskHours
43
+ // `|| maxHours` y no a secas: un `runner` sin `maxTaskHours` no lanza, así que el `catch` no lo ve
44
+ // y el tope quedaba en `undefined`. `estimate > undefined` es falso siempre, o sea que el límite
45
+ // desaparecía entero y en silencio — el modo de fallo que menos se nota, porque lo que deja de
46
+ // pasar es un rechazo.
47
+ maxHours = config.runner.maxTaskHours || maxHours
44
48
  } catch { /* reported by the main validator */ }
45
49
  for (const proposal of read(root, provider)) {
46
50
  const at = `integrations/${provider}/proposed/${proposal.file}`
@@ -197,6 +197,13 @@ function validate(root, onlyProvider = '') {
197
197
  if (fields.promotionKind === 'story' && (!/^\d{3}$/.test(fields.promotionEpic) || !fields.promotionCriteria)) {
198
198
  errors.push(`${at}: story exige promotionEpic NNN y promotionCriteria`)
199
199
  }
200
+ // Para una épica el campo es opcional —sin él el número lo elige el motor—, y por eso nadie lo
201
+ // miraba: el único caso sin validar era justo el que arma el nombre del archivo con lo que diga.
202
+ // `promotionEpic: 7` producía `epic-7-…`, que el roadmap no reconoce como épica, y un `..` sacaba
203
+ // la épica de `roadmap/` entero con `promote` devolviendo 0 y anunciando que promovió.
204
+ if (fields.promotionKind === 'epic' && fields.promotionEpic && !/^\d{3}$/.test(fields.promotionEpic)) {
205
+ errors.push(`${at}: promotionEpic debe ser NNN`)
206
+ }
200
207
  if (!sections.Aceptación || /^(por definir|n\/a|pendiente)\.?$/i.test(sections.Aceptación)) {
201
208
  errors.push(`${at}: ready exige aceptación concreta`)
202
209
  }
@@ -107,9 +107,12 @@ function derive(snapshot, draft) {
107
107
  }
108
108
 
109
109
  function renderDraft(item, config, state = 'pending') {
110
- const service = config.serviceFrom === 'component' && item.components.length === 1
111
- ? item.components[0]
112
- : ''
110
+ // `|| []` porque el item lo arma el adaptador y el README declara el contrato por sus tres funciones,
111
+ // sin decir qué campos trae un item. Uno propio —que es lo que ese README invita a escribir— puede no
112
+ // traer `components`, y con `serviceFrom: "component"`, que es lo que trae el molde, eso era un
113
+ // TypeError sin nada que lo atribuya. Sin campo la respuesta es la que el borrador ya sabe dar.
114
+ const components = item.components || []
115
+ const service = config.serviceFrom === 'component' && components.length === 1 ? components[0] : ''
113
116
  const acceptance = item.acceptance || 'Por definir.'
114
117
  const issues = []
115
118
  if (!item.acceptance) issues.push('La incidencia no contiene aceptación concreta.')
@@ -74,9 +74,9 @@ function sealWarnings(dir) {
74
74
  if (extra > 0) {
75
75
  // Cuáles sobran no se puede decir sin el conjunto original, y la cuenta sí: se nombran los últimos,
76
76
  // que es donde se agrega a mano. Decir «alguna de éstas» es más honesto que señalar la equivocada.
77
- const últimos = slugs.slice(-extra).join(', ')
77
+ const lastOnes = slugs.slice(-extra).join(', ')
78
78
  return [`${BASELINE}: la lista creció. La huella sella ${seal[1]} entrada(s) y hay ${slugs.length}; `
79
- + `sobra(n) ${extra}, probablemente ${últimos}. Una entrada agregada a mano queda exenta para `
79
+ + `sobra(n) ${extra}, probablemente ${lastOnes}. Una entrada agregada a mano queda exenta para `
80
80
  + 'siempre y nada más lo dice.']
81
81
  }
82
82
  return [`${BASELINE}: la huella no coincide con la lista. Sella ${seal[1]} entrada(s) y hay `
@@ -109,7 +109,7 @@ function validate({ claims, milestones, done }) {
109
109
  return errors
110
110
  }
111
111
 
112
- const days = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`))
112
+ const daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`))
113
113
  / 86400000)
114
114
 
115
115
  // `activity` mapea el slug de una tarea a la fecha del último commit de su rama. Llega de afuera porque
@@ -125,14 +125,14 @@ function warnings({ claims, done, today, activity = new Map() }) {
125
125
  }
126
126
  if (!DATE.test(claim.started)) continue
127
127
  const commit = activity.get(claim.slug) || ''
128
- const ultima = commit && commit > claim.started ? commit : claim.started
129
- const quieta = days(ultima, today)
130
- if (quieta > STALE_DAYS) {
131
- const senal = commit
132
- ? `último commit hace ${days(commit, today)} días`
128
+ const lastSeen = commit && commit > claim.started ? commit : claim.started
129
+ const idleDays = daysBetween(lastSeen, today)
130
+ if (idleDays > STALE_DAYS) {
131
+ const signal = commit
132
+ ? `último commit hace ${daysBetween(commit, today)} días`
133
133
  : 'la rama de la tarea no tiene commits'
134
- lines.push(`${claim.at}: ${claim.slug} sin avanzar hace ${quieta} días `
135
- + `(${claim.owner}, tomada hace ${days(claim.started, today)}; ${senal}); mirá si sigue viva`)
134
+ lines.push(`${claim.at}: ${claim.slug} sin avanzar hace ${idleDays} días `
135
+ + `(${claim.owner}, tomada hace ${daysBetween(claim.started, today)}; ${signal}); mirá si sigue viva`)
136
136
  }
137
137
  }
138
138
  // Dos tareas del mismo servicio pueden tocar los mismos archivos, y eso no se puede saber antes de
@@ -315,13 +315,13 @@ const HUMAN_ACTION_STATES = ['pendiente', 'resuelta']
315
315
  // Lo que no cubre: una celda que termine en una barra invertida literal. En markdown eso se escribe
316
316
  // `\\` y acá se leería como escape del separador. Es un borde que nadie escribe y taparlo pedía un
317
317
  // parser de verdad; queda dicho en vez de supuesto.
318
- const SEPARADOR = /(?<!\\)\|/
319
- const SEPARADORES = /^\|\s*:?-+/
318
+ const CELL_SPLIT = /(?<!\\)\|/
319
+ const SEPARATORS = /^\|\s*:?-+/
320
320
 
321
321
  // El escape se quita al normalizar. Esta columna existe para que una persona lea qué tiene que hacer, y
322
322
  // `\|` no es parte de lo que quiso decir: es cómo markdown escribe un pipe. La fila archivada no se ve
323
323
  // afectada —`archive` reescribe `raw`, la línea original, no las celdas—, así que quitarlo no pierde nada.
324
- const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
324
+ const cellOf = (cell) => cell.trim().replace(/\\\|/g, '|')
325
325
 
326
326
  // Las filas de datos de las tablas de un texto, con las celdas ya normalizadas. Vive acá y no en cada
327
327
  // lector porque el escape del pipe tiene que leerse igual en los dos archivos que traen tabla: escrito
@@ -332,11 +332,11 @@ const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
332
332
  // y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
333
333
  // fila de datos nunca está inmediatamente antes de los guiones.
334
334
  function tableRows(text) {
335
- const lineas = text.split('\n')
336
- const cabeceras = new Set(lineas.map((line, i) => (SEPARADORES.test(line) ? i - 1 : -1)))
337
- return lineas
338
- .filter((line, i) => /^\|/.test(line) && !SEPARADORES.test(line) && !cabeceras.has(i))
339
- .map((line) => ({ line, cells: line.split(SEPARADOR).slice(1, -1).map(celda) }))
335
+ const lines = text.split('\n')
336
+ const headers = new Set(lines.map((line, i) => (SEPARATORS.test(line) ? i - 1 : -1)))
337
+ return lines
338
+ .filter((line, i) => /^\|/.test(line) && !SEPARATORS.test(line) && !headers.has(i))
339
+ .map((line) => ({ line, cells: line.split(CELL_SPLIT).slice(1, -1).map(cellOf) }))
340
340
  }
341
341
 
342
342
  function readHumanActions(dir) {
@@ -86,8 +86,8 @@ function currentTask({ milestones, done, wips = [], claims = [] }, blockers = []
86
86
  // estaría lista y no acá—, así que buscarla no puede fallar y no lleva defensa.
87
87
  waiting: pending.filter((task) => !others.has(task.slug) && !ready(task)).map((task) => {
88
88
  const dep = task.depends.find((one) => !done.set.has(one))
89
- const otro = others.get(dep)
90
- return { slug: task.slug, dep, owner: otro ? otro.owner : (mine.has(dep) ? 'vos' : '') }
89
+ const other = others.get(dep)
90
+ return { slug: task.slug, dep, owner: other ? other.owner : (mine.has(dep) ? 'vos' : '') }
91
91
  }),
92
92
  }
93
93
  }
@@ -98,23 +98,23 @@ function ruleIds(file) {
98
98
  // escribir, y ahí el aviso es lo único que lo dice.
99
99
  function competingSections(dir) {
100
100
  const roadmap = path.join(dir, 'roadmap')
101
- const avisos = []
101
+ const warnings = []
102
102
  let files = []
103
103
  try { files = fs.readdirSync(roadmap).filter((file) => /^epic-\d{3}-/.test(file)) } catch { return [] }
104
104
  for (const file of files.sort()) {
105
105
  const text = P.read(path.join(roadmap, file))
106
106
  const titles = [...text.matchAll(/^##\s+(.+)$/gm)].map((hit) => hit[1].trim())
107
107
  for (const role of [/Criterios/i, /Historias/i]) {
108
- const casan = titles.filter((title) => role.test(title))
109
- if (casan.length < 2) continue
108
+ const matching = titles.filter((title) => role.test(title))
109
+ if (matching.length < 2) continue
110
110
  const exact = new RegExp(`^${role.source}$`, role.flags)
111
- const gana = casan.find((title) => exact.test(title)) || casan[0]
112
- const ignoradas = casan.filter((title) => title !== gana)
113
- avisos.push(`roadmap/${file}: "## ${gana}" convive con "## ${ignoradas.join('", "## ')}"; `
111
+ const winner = matching.find((title) => exact.test(title)) || matching[0]
112
+ const ignored = matching.filter((title) => title !== winner)
113
+ warnings.push(`roadmap/${file}: "## ${winner}" convive con "## ${ignored.join('", "## ')}"; `
114
114
  + 'sólo se lee la primera y el resto se ignora entero')
115
115
  }
116
116
  }
117
- return avisos
117
+ return warnings
118
118
  }
119
119
 
120
120
  function retiredByOverride(dir, name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.94.0",
3
+ "version": "0.95.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",