@ingeniomaps/cauce 0.94.0 → 0.96.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 (43) hide show
  1. package/CHANGELOG.md +238 -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 +8 -2
  8. package/engine/automation/config.js +9 -9
  9. package/engine/automation/index.js +19 -8
  10. package/engine/cli/archive.js +3 -6
  11. package/engine/cli/args.js +16 -2
  12. package/engine/cli/bench.js +9 -9
  13. package/engine/cli/catalog.js +36 -17
  14. package/engine/cli/claims.js +30 -32
  15. package/engine/cli/contract.js +29 -11
  16. package/engine/cli/dependency.js +2 -2
  17. package/engine/cli/instance.js +25 -24
  18. package/engine/cli/io.js +26 -4
  19. package/engine/cli/ops.js +24 -15
  20. package/engine/cli/planning.js +8 -8
  21. package/engine/cli/upgrade-report.js +10 -10
  22. package/engine/cli/validate.js +9 -9
  23. package/engine/cli/wiring.js +16 -16
  24. package/engine/cli/worktree.js +8 -8
  25. package/engine/core/onboarding.js +4 -1
  26. package/engine/core/ownership.js +9 -2
  27. package/engine/core/repos.js +18 -12
  28. package/engine/hooks/approval.js +9 -1
  29. package/engine/hooks/chat.js +27 -5
  30. package/engine/hooks/files.js +6 -6
  31. package/engine/hooks/input.js +5 -2
  32. package/engine/hooks/run.js +3 -3
  33. package/engine/hooks/shell.js +20 -6
  34. package/engine/hooks/verify.js +7 -7
  35. package/engine/integrations/proposals.js +5 -1
  36. package/engine/integrations/registry.js +7 -0
  37. package/engine/integrations/state.js +6 -3
  38. package/engine/planning/adoption.js +2 -2
  39. package/engine/planning/claims.js +8 -8
  40. package/engine/planning/parser.js +8 -8
  41. package/engine/planning/state.js +2 -2
  42. package/engine/planning/structure.js +7 -7
  43. package/package.json +1 -1
@@ -15,7 +15,7 @@ const ST = require('../planning/state')
15
15
  const CL = require('../planning/claims')
16
16
  const R = require('../core/repos')
17
17
  const O = require('../core/ownership')
18
- const { fail, planningRoot } = require('./io')
18
+ const { fail, planningRoot, USAGE, REFUSED } = require('./io')
19
19
 
20
20
  const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
21
21
 
@@ -34,38 +34,38 @@ function existing(repo, branch) {
34
34
 
35
35
  function worktree(dir, slug, cli) {
36
36
  const root = planningRoot(dir)
37
- if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', 2)
37
+ if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', USAGE)
38
38
  const state = ST.snapshot(root)
39
39
  const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
40
- if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, 2)
40
+ if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, USAGE)
41
41
 
42
42
  // No reserva —eso es `claim`— pero se niega a montar sobre lo de otro: preparar un árbol para una
43
43
  // tarea ajena es trabajo que se va a tirar, y el aviso cuesta menos que descubrirlo después.
44
44
  const taken = state.claims.find((one) => one.slug === slug)
45
45
  if (taken && taken.runner !== CL.runner()) {
46
- return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`)
46
+ return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`, REFUSED)
47
47
  }
48
48
 
49
49
  const candidatos = R.reposFor(path.join(root, '..'), task.service)
50
50
  if (candidatos.length > 1) {
51
51
  return fail(`${task.service || '.'} existe en más de un repositorio (${candidatos.join(', ')}), así que `
52
- + 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', 2)
52
+ + 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', USAGE)
53
53
  }
54
54
  const repo = candidatos[0]
55
55
  if (!repo) {
56
56
  return fail(`no encontré el repositorio de ${task.service || '(sin service)'}: revisá workspaceRoots `
57
- + 'en ops.config.json y que la ruta del servicio exista.', 2)
57
+ + 'en ops.config.json y que la ruta del servicio exista.', USAGE)
58
58
  }
59
59
 
60
60
  const branch = CL.branchOf(slug)
61
61
  const already = existing(repo, branch)
62
62
  const target = already || path.join(path.dirname(repo), `${path.basename(repo)}-${slug}`)
63
63
  if (!already) {
64
- if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`)
64
+ if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`, REFUSED)
65
65
  const hasBranch = git(repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`).status === 0
66
66
  const args = hasBranch ? ['worktree', 'add', target, branch] : ['worktree', 'add', '-b', branch, target]
67
67
  const added = git(repo, ...args)
68
- if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`)
68
+ if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`, REFUSED)
69
69
  }
70
70
 
71
71
  if (cli.has('--json')) {
@@ -119,7 +119,10 @@ function orphanCredentials(root) {
119
119
  path.join('planning', 'HUMAN_ACTIONS.md')]
120
120
  .map((file) => { try { return fs.readFileSync(path.join(root, file), 'utf8') } catch { return '' } })
121
121
  .join('\n')
122
- if (!contracts) return []
122
+ // Acá había una salida temprana por `contracts` vacío que no corría nunca —pegar tres cadenas con
123
+ // `\n` deja `"\n\n"` aunque las tres fallen—, y no se la repone: una credencial que ningún documento
124
+ // nombra está huérfana igual, y apagar el aviso porque el documento que debía nombrarla está vacío lo
125
+ // calla justo en la instancia que más lo necesita.
123
126
  const orphans = []
124
127
  const cut = []
125
128
  for (const service of inventory(root)) {
@@ -164,9 +164,16 @@ function engineAt(root, relative = '') {
164
164
  // de copiarse. Se reconocen por contener `system/`, que es el espacio del toolkit y no algo que un
165
165
  // proyecto deba crear. Una sola implementación para las dos, o divergen.
166
166
  function packageDir(root, name) {
167
+ // La raíz declarada va también acá, y no sólo en `packagePath`. Las dos contestan dónde está el paquete
168
+ // sobre la misma instancia y desde el 158 contestaban distinto: en el layout documentado —`npm install`
169
+ // en la carpeta de la empresa y la instancia adentro— el motor se resolvía y el catálogo no, así que
170
+ // `check` pasaba en verde y `agents list` devolvía una lista vacía con exit 0, indistinguible de «esta
171
+ // instancia no tiene cargos». `evaluate` era peor: culpaba a un `SKILL.md` que falta (caso 171).
172
+ const above = declaredRoot(root)
167
173
  const candidates = [
168
174
  path.join(root, 'node_modules', '@ingeniomaps', 'cauce', name),
169
175
  path.join(root, name),
176
+ ...(above ? [path.join(above, 'node_modules', '@ingeniomaps', 'cauce', name)] : []),
170
177
  ]
171
178
  return candidates.find((dir) => fs.existsSync(path.join(dir, 'system'))
172
179
  || fs.existsSync(path.join(dir, 'roles', 'system'))) || ''
@@ -299,7 +306,7 @@ const RETIRED = [
299
306
  //
300
307
  // El manifiesto no las puede desempatar: `trackedPaths()` nunca las registró, así que para el toolkit
301
308
  // todo su contenido es igual de ajeno. Sin poder demostrar que lo entregó, no lo borra.
302
- const RETIRED_COMPARTIDO = ['automatization/runners', 'automatization/workflows']
309
+ const RETIRED_SHARED = ['automatization/runners', 'automatization/workflows']
303
310
 
304
311
  function retiredWithLearning(root) {
305
312
  const found = []
@@ -407,7 +414,7 @@ module.exports = {
407
414
  deliveredFiles,
408
415
  effectiveRules,
409
416
  shippedFiles,
410
- RETIRED_COMPARTIDO,
417
+ RETIRED_SHARED,
411
418
  TEMPLATE_OWN,
412
419
  TEMPLATE_PREFIXES,
413
420
  addedPaths,
@@ -55,10 +55,10 @@ function repoOf(opsRoot, service) {
55
55
  // el árbol principal: los worktrees se crean desde ahí y ahí se queda.
56
56
  function lastCommit(repo, branch) {
57
57
  if (!repo) return ''
58
- const actual = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')
59
- const tronco = actual.status === 0 ? actual.stdout.trim() : ''
60
- const args = tronco && tronco !== branch
61
- ? ['log', '-1', '--format=%cs', branch, '--not', tronco, '--']
58
+ const head = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')
59
+ const trunk = head.status === 0 ? head.stdout.trim() : ''
60
+ const args = trunk && trunk !== branch
61
+ ? ['log', '-1', '--format=%cs', branch, '--not', trunk, '--']
62
62
  : ['log', '-1', '--format=%cs', branch, '--']
63
63
  const shown = git(repo, ...args)
64
64
  return shown.status === 0 ? shown.stdout.trim() : ''
@@ -85,9 +85,15 @@ function unrecordedCommits(repo, since, recorded) {
85
85
  const log = git(repo, 'log', '--no-merges', '--date=short', '--format=%h %ad %s')
86
86
  if (log.status !== 0) return []
87
87
  const conocidos = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
88
+ // El hash y la fecha se leen partiendo por espacios y no por columna: `%h` mide 7 por default y git lo
89
+ // sube solo cuando el repositorio crece —en éste mide 8—, así que una posición fija leía un espacio en
90
+ // vez de la fecha y ningún commit pasaba el filtro. El aviso quedaba mudo sin decirlo, que es la peor
91
+ // forma de que una puerta falle (caso 169).
88
92
  return log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
89
- .filter((line) => line.slice(8, 18) >= since)
90
- .filter((line) => !conocidos.has(line.slice(0, 7)))
93
+ .map((line) => ({ line, sha: line.split(/\s+/)[0], date: line.split(/\s+/)[1] }))
94
+ .filter((one) => one.date >= since)
95
+ .filter((one) => !conocidos.has(one.sha.slice(0, 7)))
96
+ .map((one) => one.line)
91
97
  }
92
98
 
93
99
  // Cuánto del trabajo que entró a los repositorios quedó registrado, desde la última tarea cerrada. Avisa
@@ -102,18 +108,18 @@ function unrecordedCommits(repo, since, recorded) {
102
108
  // Y no dice cuántos *deberían* tener entrada, porque eso no se sabe desde acá: lo dice el desglose, y en
103
109
  // la instancia medida 120 de 173 eran `feat` o `fix` (caso 082).
104
110
  function coverageWarnings(opsRoot, done) {
105
- const fechas = done.entries.map((entry) => entry.fecha).filter(Boolean).sort()
106
- const desde = fechas[fechas.length - 1]
107
- if (!desde) return []
111
+ const dates = done.entries.map((entry) => entry.fecha).filter(Boolean).sort()
112
+ const since = dates[dates.length - 1]
113
+ if (!since) return []
108
114
  const recorded = new Set()
109
115
  for (const entry of done.entries) {
110
116
  for (const sha of String(entry.commit || '').matchAll(/\b[0-9a-f]{7,40}\b/g)) recorded.add(sha[0])
111
117
  }
112
118
  const warnings = []
113
119
  for (const repo of reposFor(opsRoot, '.')) {
114
- const sueltos = unrecordedCommits(repo, desde, recorded)
115
- if (!sueltos.length) continue
116
- warnings.push(`${path.basename(repo)}: ${sueltos.length} commit(s) desde ${desde} que ninguna `
120
+ const unrecorded = unrecordedCommits(repo, since, recorded)
121
+ if (!unrecorded.length) continue
122
+ warnings.push(`${path.basename(repo)}: ${unrecorded.length} commit(s) desde ${since} que ninguna `
117
123
  + 'entrada de DONE nombra, así que ese trabajo no está en planning/ (OPS-001)')
118
124
  }
119
125
  return warnings
@@ -96,9 +96,17 @@ function where(input) {
96
96
  function HOW(variable, lines, input, pasteable = lines) {
97
97
  const chat = CHAT.hold(input, lines)
98
98
  const stuck = lines.filter((one) => !pasteable.includes(one))
99
+ // El alcance del «dale» va con la oferta y no después. La rama del pegado ya decía el suyo —«valen para
100
+ // ese conjunto»— y la del chat no decía ninguno, siendo la que se ofrece primero. Las dos mitades que
101
+ // faltaban no son la misma: que **cubre lo frenado y nada más** explica por qué un archivo nuevo vuelve a
102
+ // frenar, que desde afuera se lee como que el guard se olvidó; y que **sigue valiendo en los mensajes
103
+ // siguientes** es la dirección permisiva, la que nadie nota porque lo que no ocurre es un bloqueo.
104
+ // `check` la muestra al final de la corrida —es lo que trajo el 117—, y al concederla no la decía nadie
105
+ // (caso 170).
99
106
  const ask = chat
100
107
  ? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
101
- + 'pasa. '
108
+ + 'pasa. Ese «dale» cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo en '
109
+ + 'los mensajes siguientes hasta que ella lo niegue. '
102
110
  : ''
103
111
  const paste = pasteable.length
104
112
  ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
@@ -116,13 +116,13 @@ function mentions(text, item) {
116
116
  if (before && !/[\s'"`(/]/.test(before)) continue
117
117
  if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
118
118
  const clause = lower.slice(0, at).split(CLAUSE).pop()
119
- const frase = `${clause} ${rest.split(CLAUSE)[0]}`
119
+ const phrase = `${clause} ${rest.split(CLAUSE)[0]}`
120
120
  found.push({
121
121
  denied: NEGATION.test(clause),
122
- asked: asks(frase),
122
+ asked: asks(phrase),
123
123
  // El alcance se lee de la misma frase que decide si la ruta fue pedida, y no del mensaje entero:
124
124
  // un «mientras dure la tarea t-014» que hable de otra cosa, en otra oración, no acota a ésta.
125
- scope: (frase.match(SCOPE) || [])[1] || '',
125
+ scope: (phrase.match(SCOPE) || [])[1] || '',
126
126
  })
127
127
  }
128
128
  }
@@ -183,11 +183,19 @@ function record(input) {
183
183
  // alcance que ya no acota a nadie.
184
184
  const scopes = {}
185
185
  for (const one of granted) if (previous.scopes && previous.scopes[one]) scopes[one] = previous.scopes[one]
186
+ // Que haya alguien a quien preguntarle es de la sesión y no de este mensaje. Una notificación de una
187
+ // tarea de fondo entra por este mismo hook —es un `UserPromptSubmit` con el texto
188
+ // `<task-notification>…`, medido sobre los registros reales— así que se anota con `human: false`, y
189
+ // sin esto borraba del registro a la persona que está mirando el bloqueo en ese instante. Se arrastra
190
+ // igual que `granted` y por la misma razón: un aviso del runner en el medio no se lleva puesto lo que
191
+ // ya era cierto. Lo vuelve a decidir el mensaje humano siguiente (caso 166).
192
+ const flow = flowCommand(text)
193
+ const askable = human ? !flow : Boolean(previous && previous.askable)
186
194
  fs.mkdirSync(DIR, { recursive: true })
187
195
  // Sobre qué instancia se está hablando, que es lo que después deja filtrar lo concedido: por qué hace
188
196
  // falta, en `grantedIn`.
189
197
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
190
- { id: idOf(input), text, human, flow: flowCommand(text), root: opsRoot(input), approved, granted,
198
+ { id: idOf(input), text, human, askable, flow, root: opsRoot(input), approved, granted,
191
199
  scopes, pending: [] }))
192
200
  } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
193
201
  }
@@ -203,6 +211,20 @@ function said(input) {
203
211
  return current && saved.id && current !== saved.id ? null : saved
204
212
  }
205
213
 
214
+ // Hay una persona en esta sesión a quien preguntarle, que no es lo mismo que «este mensaje lo escribió
215
+ // ella». `said` contesta la segunda porque de ella depende **conceder**; ésta contesta la primera, que es
216
+ // todo lo que hace falta para decidir si el bloqueo ofrece contestar «dale» o manda a pegar líneas en un
217
+ // archivo. Preguntar no es conceder, y fundir las dos preguntas le cobraba un copiar y pegar a la persona
218
+ // justo cuando estaba mirando (caso 166).
219
+ //
220
+ // Un registro escrito antes de que `askable` existiera no lo trae y queda afuera: la sesión pierde la
221
+ // salida por chat hasta el mensaje siguiente, que la vuelve a escribir. Es la dirección barata del error.
222
+ function present(input) {
223
+ if (process.env.CI || input.agent_id || !input.session_id) return null
224
+ const saved = load(input.session_id)
225
+ return saved && saved.askable ? saved : null
226
+ }
227
+
206
228
  // Con qué autorización pasa un ítem, o vacío si no pasa: lo pidió este mensaje, un «dale» aprobó lo que
207
229
  // había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
208
230
  // se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
@@ -303,7 +325,7 @@ function unauthorizedNow(input, items) {
303
325
  // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
304
326
  // Devuelve si hay una persona a quien preguntarle.
305
327
  function hold(input, items) {
306
- const saved = said(input)
328
+ const saved = present(input)
307
329
  if (!saved) return false
308
330
  try {
309
331
  saved.pending = [...new Set([...saved.pending, ...items])]
@@ -8,7 +8,7 @@ const fs = require('node:fs')
8
8
  const path = require('node:path')
9
9
  const { spawnSync } = require('node:child_process')
10
10
  const {
11
- patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot, opsRoot,
11
+ patchOf, filesOf, contentOf, cwdOf, block, configOf, opsRoot,
12
12
  writableRoots, outsideRoots, DECLARE_IT,
13
13
  } = require('./input')
14
14
  const AP = require('./approval')
@@ -242,10 +242,10 @@ function planFirst(input) {
242
242
  //
243
243
  // Se lista recién acá, después de las dos salidas de arriba: quien tiene su plan sale por la primera y
244
244
  // no paga esta lectura, que es la misma razón por la que `hasTasks` se pregunta donde se pregunta.
245
- const ajenos = readWips(planning).filter((one) => one.complete + one.pending > 0)
245
+ const foreign = readWips(planning).filter((one) => one.complete + one.pending > 0)
246
246
  const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
247
- const why = ajenos.length
248
- ? `hay plan escrito, pero bajo otro id: ${ajenos.map((one) => `${one.runner} (${one.task})`).join(', ')}.\n`
247
+ const why = foreign.length
248
+ ? `hay plan escrito, pero bajo otro id: ${foreign.map((one) => `${one.runner} (${one.task})`).join(', ')}.\n`
249
249
  + 'Si ese plan es tuyo, volvé a su id con `export CAUCE_RUNNER=<id>` —`ops runners <planning>` los lista '
250
250
  + 'con su tarea y su avance— y repetí el cambio. Si vas a trabajar en paralelo, montá tu propio árbol '
251
251
  + 'con `ops worktree <planning> <tarea>`, que te devuelve el id hecho.\n'
@@ -258,7 +258,7 @@ function planFirst(input) {
258
258
  // Con un plan a la vista no se ofrece ninguna de las dos salidas: aprobar la ruta escribe «esto no es
259
259
  // trabajo de una tarea», que ahí es falso, y anunciar la variable ofrece el permiso más ancho cuando
260
260
  // la acción correcta es angosta y concreta —el mismo criterio con que `HOW` decide no nombrarla—.
261
- const how = ajenos.length
261
+ const how = foreign.length
262
262
  ? AP.HOW(null, [], input, [])
263
263
  : AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)
264
264
  block(`${raw} cambia el producto sin plan. ${why}${how}`)
@@ -359,7 +359,7 @@ function migrations(input) {
359
359
  // empresa corre un motor que no coincide con la versión que declara —la clase de diferencia que
360
360
  // aparece como un bug irreproducible—. En modo `toolkit` no aplica: ahí el motor es el producto.
361
361
  function engineWrites(input) {
362
- const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
362
+ const root = opsRoot(input)
363
363
  if (!root) return
364
364
  const config = configOf(root)
365
365
  if (config.mode === 'toolkit') return
@@ -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.96.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",