@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
@@ -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, '..'))
@@ -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)) {
@@ -299,7 +299,7 @@ const RETIRED = [
299
299
  //
300
300
  // El manifiesto no las puede desempatar: `trackedPaths()` nunca las registró, así que para el toolkit
301
301
  // todo su contenido es igual de ajeno. Sin poder demostrar que lo entregó, no lo borra.
302
- const RETIRED_COMPARTIDO = ['automatization/runners', 'automatization/workflows']
302
+ const RETIRED_SHARED = ['automatization/runners', 'automatization/workflows']
303
303
 
304
304
  function retiredWithLearning(root) {
305
305
  const found = []
@@ -407,7 +407,7 @@ module.exports = {
407
407
  deliveredFiles,
408
408
  effectiveRules,
409
409
  shippedFiles,
410
- RETIRED_COMPARTIDO,
410
+ RETIRED_SHARED,
411
411
  TEMPLATE_OWN,
412
412
  TEMPLATE_PREFIXES,
413
413
  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