@ingeniomaps/cauce 0.81.0 → 0.82.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 (35) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/automatization/hooks/README.md +2 -2
  3. package/automatization/runners/antigravity/rules/cauce.md +7 -2
  4. package/automatization/runners/claude/CLAUDE.md +1 -4
  5. package/automatization/runners/codex/AGENTS.md +7 -3
  6. package/automatization/runners/gemini/GEMINI.md +1 -4
  7. package/automatization/shared/inbox.js +28 -0
  8. package/automatization/workflows/agent-eval.js +1 -1
  9. package/automatization/workflows/autobuild.js +52 -18
  10. package/automatization/workflows/flow.js +33 -8
  11. package/automatization/workflows/onboard.js +25 -3
  12. package/engine/automation/index.js +19 -4
  13. package/engine/automation/rules.js +122 -0
  14. package/engine/automation/runners.js +3 -1
  15. package/engine/cli/instance.js +35 -6
  16. package/engine/cli/planning.js +16 -2
  17. package/engine/config/validate.js +53 -3
  18. package/engine/core/onboarding.js +37 -7
  19. package/engine/core/ownership.js +64 -1
  20. package/engine/core/scan.js +23 -9
  21. package/engine/hooks/approval.js +3 -2
  22. package/engine/hooks/chat.js +59 -10
  23. package/engine/hooks/input.js +1 -11
  24. package/engine/hooks/push.js +147 -0
  25. package/engine/hooks/shell.js +7 -5
  26. package/engine/integrations/registry.js +13 -1
  27. package/engine/planning/inbox.js +36 -0
  28. package/engine/planning/parser.js +22 -9
  29. package/engine/planning/recurring.js +13 -2
  30. package/engine/schemas/ops-config.schema.json +21 -0
  31. package/package.json +1 -1
  32. package/template/AGENTS.md +10 -4
  33. package/template/planning/INBOX.md +2 -1
  34. package/template/planning/RECURRING.md +6 -5
  35. package/template/planning/rules/system/commits.md +3 -1
@@ -7,6 +7,7 @@ const F = require('../core/files')
7
7
  const catalog = require('../agents/catalog')
8
8
  const O = require('../core/ownership')
9
9
  const M = require('../core/manifest')
10
+ const RL = require('./rules')
10
11
  const {
11
12
  RUNNER_NAMES, OPS_DIR, OPS_ROOT, packagedAutomation, runnerManifest, installRoot, opsPrefix,
12
13
  runnerPaths, resolveItem, inline, render, runnerConfig, activated,
@@ -51,7 +52,15 @@ function check(root) {
51
52
  errors.push(`falta automatization/workflows/${name}: corré "npm install" en la raíz del repo ops`)
52
53
  }
53
54
  }
55
+ // Un choque que `upgrade` conservó (caso 110) también queda distinto del paquete, y mandarlo a correr
56
+ // `upgrade` era una vuelta sin salida: lo conservaría otra vez. Se dice qué es y qué hacer.
57
+ const choques = new Set(O.collisions(root))
54
58
  for (const { file, edited } of staleHooks(root)) {
59
+ if (choques.has(`automatization/hooks/${file}`)) {
60
+ errors.push(`automatization/hooks/${file}: es tuyo y se llama como uno que trae el paquete, así que el `
61
+ + "del paquete no está instalado; renombrá el tuyo y corré `cauce upgrade`")
62
+ continue
63
+ }
55
64
  errors.push(edited
56
65
  ? `automatization/hooks/${file}: lo editaste y es del toolkit; agregá un guard propio al lado `
57
66
  + 'o descartá tu cambio con `cauce upgrade --force`'
@@ -145,6 +154,10 @@ function doctor(root, name, output = console) {
145
154
  } catch (error) {
146
155
  errors.push(`${runner.config.target}: ${error.message}`)
147
156
  }
157
+ // Una regla nueva cambia el render: se dice cuál, y calla el genérico, que mandaba a buscar un cambio de Cauce.
158
+ const ruled = RL.drift(root, name)
159
+ warnings.push(...ruled.map((one) => RL.driftLine(name, one)))
160
+ const quiet = (item) => ruled.some((one) => one.target === item.target)
148
161
  for (const item of runner.instructions || []) {
149
162
  const resolved = { item, ...resolveItem(paths, root, name, item) }
150
163
  // El archivo compartido no se compara entero: alrededor del bloque vive el texto de la empresa, así
@@ -155,7 +168,7 @@ function doctor(root, name, output = console) {
155
168
  if (!fs.existsSync(resolved.target)) errors.push(`falta ${item.target}`)
156
169
  else if (!fs.readFileSync(resolved.target, 'utf8').includes(blockStart(name))) {
157
170
  errors.push(`${item.target}: no tiene las instrucciones de Cauce; reinstalá el adaptador`)
158
- } else if (!blockUpToDate(resolved.target, name, content)) {
171
+ } else if (!quiet(item) && !blockUpToDate(resolved.target, name, content)) {
159
172
  warnings.push(`${item.target}: su bloque de Cauce quedó viejo; reinstalá el adaptador`)
160
173
  }
161
174
  continue
@@ -168,7 +181,7 @@ function doctor(root, name, output = console) {
168
181
  else if (!ownFile && !fs.readFileSync(resolved.target, 'utf8').includes('AGENTS.md')) {
169
182
  warnings.push(`${item.target}: no referencia AGENTS.md; verifica las reglas globales`)
170
183
  }
171
- if (deliveryState(M.readRunners(root), name, resolved, opsPrefix(root)) === 'desactualizado') {
184
+ if (!quiet(item) && deliveryState(M.readRunners(root), name, resolved, opsPrefix(root)) === 'desactualizado') {
172
185
  warnings.push(`${item.target}: Cauce trae una versión más nueva y vos no lo tocaste; reinstalá`)
173
186
  }
174
187
  }
@@ -179,7 +192,7 @@ function doctor(root, name, output = console) {
179
192
  const resolved = { item, ...resolveItem(paths, root, name, item) }
180
193
  const status = deliveryState(recorded, name, resolved, opsPrefix(root))
181
194
  if (status === 'nuevo') errors.push(`falta ${item.target}`)
182
- else if (status === 'desactualizado') {
195
+ else if (status === 'desactualizado' && !quiet(item)) {
183
196
  warnings.push(`${item.target}: hay una versión más nueva en Cauce; reinstalá el adaptador`)
184
197
  } else if (status === 'ajeno') {
185
198
  warnings.push(`${item.target}: lo editaste y es del toolkit; `
@@ -432,7 +445,9 @@ function install(root, name, output = console, options = {}) {
432
445
  continue
433
446
  }
434
447
  if (status === 'ajeno' && ownFile) {
435
- output.log(`= ${name}: conservado ${resolved.item.target} (tiene cambios tuyos)`)
448
+ output.log(RL.refresh(resolved.target, render(resolved.source, prefix, resolved.automationRoot, resolved.opsRoot))
449
+ ? `✓ ${name}: ${resolved.item.target} conserva tus cambios y recibió las reglas vigentes`
450
+ : `= ${name}: conservado ${resolved.item.target} (tiene cambios tuyos)`)
436
451
  } else if (status === 'al día') {
437
452
  output.log(`= ${name}: ${resolved.item.target} ya está al día`)
438
453
  } else {
@@ -0,0 +1,122 @@
1
+ 'use strict'
2
+
3
+ // Cómo llegan a un runner las reglas que rigen la instancia (casos 099 y 105). Su archivo de instrucciones las
4
+ // nombraba fijas —las cuatro de `system/`—, así que la sesión cargaba la que la empresa había sobrescrito y
5
+ // ninguna de las propias. El adaptador trae un marcador y el motor lo resuelve contra `effectiveRules` al
6
+ // instalar. Y como `upgrade` no reinstala, `check` y `doctor` comparan lo instalado con lo vigente.
7
+
8
+ const fs = require('node:fs')
9
+ const F = require('../core/files')
10
+ const O = require('../core/ownership')
11
+
12
+ // `imports` para el runner que carga archivos con `@ruta`; `list` para el que sólo lee prosa, donde un `@` no
13
+ // significa nada.
14
+ const MARKER = /\{\{RULES:(imports|list)\}\}/g
15
+ const START = '<!-- cauce:reglas inicio — lo reescribe "automation install" con las reglas vigentes -->'
16
+ const END = '<!-- cauce:reglas fin -->'
17
+ // Se reconoce por el arranque y no por la línea entera: quien quiera el bloque en un archivo propio escribe las
18
+ // dos marcas a mano, y pedirle el texto exacto de la primera es pedirle que acierte un guion largo.
19
+ const START_AT = '<!-- cauce:reglas inicio'
20
+ // Lo que un `CLAUDE.md` o un `GEMINI.md` instalado antes de 0.82.0 trae en el lugar del bloque.
21
+ const LEGACY_IMPORT = /^@\S*planning\/rules\/\S+\.md\s*$/
22
+
23
+ // Sin raíz el marcador queda como está —así lo leen las pruebas que revisan el texto de un adaptador—: un
24
+ // bloque vacío se leería igual que un proyecto sin reglas, y un marcador sin resolver se ve.
25
+ function fill(text, root) {
26
+ if (!root || !text.includes('{{RULES:')) return text
27
+ const rules = O.effectiveRules(root)
28
+ return text.replace(MARKER, (_, format) => [START, ...rules.map((file) => (format === 'imports'
29
+ ? `@{{OPS_DIR}}${file}`
30
+ : `- \`{{OPS_DIR}}${file}\``)), END].join('\n'))
31
+ }
32
+
33
+ function blockOf(text) {
34
+ const start = text.indexOf(START_AT)
35
+ if (start === -1) return null
36
+ const end = text.indexOf(END, start)
37
+ if (end === -1) return null
38
+ return { start, end: end + END.length, body: text.slice(start, end + END.length) }
39
+ }
40
+
41
+ // Las reglas que nombra un archivo instalado, relativas a la raíz ops: las del bloque o, si es anterior al
42
+ // bloque, sus imports sueltos.
43
+ function listed(text) {
44
+ const found = blockOf(text)
45
+ const lines = found ? found.body.split('\n') : text.split('\n').filter((line) => LEGACY_IMPORT.test(line))
46
+ return lines.map((line) => (line.match(/planning\/rules\/[^\s`]+\.md/) || [])[0]).filter(Boolean)
47
+ }
48
+
49
+ // Un archivo de instrucciones con cambios de la empresa se conserva, y hasta acá eso lo dejaba sin reglas
50
+ // nuevas para siempre. Recibe el bloque donde estaba el suyo, o donde estaban los imports fijos de antes, y el
51
+ // resto queda como lo dejó quien lo editó. Devuelve null si el archivo no tiene dónde recibirlo: uno propio,
52
+ // sin marcas ni imports de Cauce, no se toca.
53
+ function withBlock(text, rendered) {
54
+ const fresh = blockOf(rendered)
55
+ if (!fresh) return null
56
+ const current = blockOf(text)
57
+ if (current) return `${text.slice(0, current.start)}${fresh.body}${text.slice(current.end)}`
58
+ const lines = text.split('\n')
59
+ const first = lines.findIndex((line) => LEGACY_IMPORT.test(line))
60
+ if (first === -1) return null
61
+ const kept = lines.filter((line, index) => index === first || !LEGACY_IMPORT.test(line))
62
+ kept[first] = fresh.body
63
+ return kept.join('\n')
64
+ }
65
+
66
+ // Qué archivos de un runner nombran otras reglas que las vigentes: sólo los que su adaptador entrega con el
67
+ // marcador y que están en disco. Se carga tarde porque `runners` usa `fill` para renderizar.
68
+ function drift(root, name) {
69
+ const { runnerManifest, runnerPaths, resolveItem } = require('./runners')
70
+ const runner = runnerManifest(root, name)
71
+ const paths = runnerPaths(root, name, runner)
72
+ const expected = O.effectiveRules(root)
73
+ const found = []
74
+ for (const item of [...(runner.instructions || []), ...(runner.artifacts || [])]) {
75
+ const { source, target } = resolveItem(paths, root, name, item)
76
+ if (!fs.existsSync(target) || !fs.readFileSync(source, 'utf8').includes('{{RULES:')) continue
77
+ const text = fs.readFileSync(target, 'utf8')
78
+ const have = listed(text)
79
+ const missing = expected.filter((file) => !have.includes(file))
80
+ const extra = have.filter((file) => !expected.includes(file))
81
+ if (!blockOf(text) && !have.length) found.push({ target: item.target, bare: true, missing, extra })
82
+ else if (missing.length || extra.length) found.push({ target: item.target, bare: false, missing, extra })
83
+ }
84
+ return found
85
+ }
86
+
87
+ // Escribe el bloque nuevo dentro de un archivo con cambios propios; dice si hubo algo que escribir.
88
+ function refresh(file, rendered) {
89
+ const current = fs.readFileSync(file, 'utf8')
90
+ const updated = withBlock(current, rendered)
91
+ if (updated === null || updated === current) return false
92
+ F.atomicWrite(file, updated)
93
+ return true
94
+ }
95
+
96
+ function driftLine(name, one) {
97
+ if (one.bare) {
98
+ return `${one.target} no carga las reglas vigentes; reinstalá el adaptador (make install-${name}), `
99
+ + `y si ese archivo es tuyo, marcá dónde va el bloque con ${START_AT} --> y ${END}`
100
+ }
101
+ const parts = [
102
+ one.missing.length ? `no carga ${one.missing.join(', ')}` : '',
103
+ one.extra.length ? `carga ${one.extra.join(', ')}, que ya no rige` : '',
104
+ ].filter(Boolean)
105
+ return `${one.target} ${parts.join(' y ')}; reinstalá el adaptador (make install-${name})`
106
+ }
107
+
108
+ // Lo mismo para cada runner que esta instancia instaló, que es lo que `check` mira en cada corrida: una regla
109
+ // escrita después de instalar, o traída por un `upgrade`, no llega a ninguna sesión hasta reinstalar.
110
+ function staleLines(root) {
111
+ const { RUNNER_NAMES } = require('./runners')
112
+ const recorded = Object.keys(require('../core/manifest').readRunners(root))
113
+ const lines = []
114
+ for (const name of RUNNER_NAMES) {
115
+ if (!recorded.some((key) => key.startsWith(`${name}/`))) continue
116
+ // Sin el paquete no hay adaptador contra el cual comparar, y eso ya lo dice `automation doctor`.
117
+ try { for (const one of drift(root, name)) lines.push(`${name}: ${driftLine(name, one)}`) } catch { continue }
118
+ }
119
+ return lines
120
+ }
121
+
122
+ module.exports = { fill, refresh, drift, driftLine, staleLines }
@@ -10,6 +10,7 @@ const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const F = require('../core/files')
12
12
  const O = require('../core/ownership')
13
+ const { fill } = require('./rules')
13
14
 
14
15
  const RUNNER_NAMES = ['claude', 'codex', 'gemini', 'antigravity']
15
16
 
@@ -126,8 +127,9 @@ function inline(text, automationRoot) {
126
127
  // archivo se rompe si el proyecto se mueve, así que la lleva sólo el que se queda sin alternativa.
127
128
  const OPS_ROOT = '{{OPS_ROOT}}'
128
129
 
130
+ // `{{RULES:…}}` va antes que `{{OPS_DIR}}` por lo mismo que el include: las rutas que escribe llevan el prefijo.
129
131
  function render(file, prefix, automationRoot, opsRoot = '') {
130
- return inline(fs.readFileSync(file, 'utf8'), automationRoot)
132
+ return fill(inline(fs.readFileSync(file, 'utf8'), automationRoot), opsRoot)
131
133
  .split(OPS_ROOT).join(opsRoot)
132
134
  .split(OPS_DIR).join(prefix)
133
135
  }
@@ -13,6 +13,7 @@ const M = require('../core/manifest')
13
13
  const OB = require('../core/onboarding')
14
14
  const P = require('../planning/parser')
15
15
  const ST = require('../planning/state')
16
+ const RC = require('../planning/recurring')
16
17
  const A = require('../automation')
17
18
  const { fail } = require('./io')
18
19
  const { declareEngine, pinEngine, undeclareEngine } = require('./dependency')
@@ -97,6 +98,7 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
97
98
  '{{PROJECT_NAME}}': name,
98
99
  '{{MODE}}': mode,
99
100
  '{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
101
+ ...RC.sinceValues(new Date().toISOString().slice(0, 10)),
100
102
  }, force, providerNames(), quiet)
101
103
  // No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
102
104
  // el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando lo que no aplica dejaba
@@ -113,8 +115,9 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
113
115
  declareEngine(path.join(root, 'package.json'), version)
114
116
  let deliveredPaths = {}
115
117
  for (const relative of O.trackedPaths()) {
116
- const dir = path.join(root, relative)
117
- if (fs.existsSync(dir)) deliveredPaths = M.record(root, relative, O.treeFiles(dir), deliveredPaths)
118
+ if (fs.existsSync(path.join(root, relative))) {
119
+ deliveredPaths = M.record(root, relative, O.deliveredFiles(root, relative), deliveredPaths)
120
+ }
118
121
  }
119
122
  deliveredPaths = M.recordPaths(root, O.SYSTEM_FILES, deliveredPaths)
120
123
  // Adoptar Cauce en un repositorio con contenido es `init --force`, y lo que se conserva ahí lo
@@ -277,12 +280,14 @@ function upgrade(dir, cli) {
277
280
  const to = require(path.join(PROJECT_ROOT, 'package.json')).version
278
281
  const system = O.systemPaths(root)
279
282
  const changed = O.localChanges(root)
283
+ const colliding = O.collisions(root)
280
284
  const overrides = O.overrides(root)
281
285
 
282
286
  // El código de salida lo aplica acá y no adentro: `--check` mira y cuenta, y quien decide qué hacer
283
287
  // con lo que vio es el comando. Escondido en la función que informa, el corte del flujo no se ve.
284
288
  if (dry) {
285
- const code = previewUpgrade({ from, to, changed })
289
+ for (const file of colliding) console.log(` choca con uno tuyo: ${file}`)
290
+ const code = previewUpgrade({ from, to, changed }) || (colliding.length ? 1 : 0)
286
291
  if (code) process.exit(code)
287
292
  return
288
293
  }
@@ -311,6 +316,16 @@ function upgrade(dir, cli) {
311
316
  console.log(`${adviceFor([...conservados])}\n`)
312
317
  console.log('Para tomar la versión nueva y descartar la tuya, repetí con --force.\n')
313
318
  }
319
+ // Un archivo propio que se llama como uno que el paquete empieza a traer (caso 110): se conserva y se
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) {
323
+ console.log(`= conservado ${file}: ya existía y Cauce no lo entregó, así que el del paquete no se `
324
+ + 'instaló. Renombrá el tuyo y repetí, o repetí con --force para reemplazarlo.')
325
+ }
326
+ if (force) {
327
+ for (const file of colliding) console.log(`− reemplazado ${file}, que era tuyo y se llamaba como uno del paquete`)
328
+ }
314
329
 
315
330
  // Lo que una versión agrega y es del proyecto: se crea si falta y nunca se pisa. `systemPaths` no lo
316
331
  // incluye a propósito —lo reemplazaría en cada actualización, que es lo que un archivo del proyecto
@@ -330,6 +345,7 @@ function upgrade(dir, cli) {
330
345
  '{{PROJECT_NAME}}': config.project || path.basename(root),
331
346
  '{{MODE}}': O.mode(root),
332
347
  '{{WORKSPACE_PATH}}': O.mode(root) === 'embedded' ? '.' : '..',
348
+ ...RC.sinceValues(new Date().toISOString().slice(0, 10)),
333
349
  })) content = content.replaceAll(key, value)
334
350
  F.atomicWrite(target, content)
335
351
  added.push(relative)
@@ -350,7 +366,10 @@ function upgrade(dir, cli) {
350
366
  }
351
367
  }
352
368
 
353
- const conservar = (file) => conservados.has(path.relative(root, file).replace(/\\/g, '/'))
369
+ const conservar = (file) => {
370
+ const relative = path.relative(root, file).replace(/\\/g, '/')
371
+ return conservados.has(relative) || choques.has(relative)
372
+ }
354
373
  for (const relative of [...system, ...O.RUNTIME_PATHS]) {
355
374
  const origin = path.join(PROJECT_ROOT, O.sourceOf(relative))
356
375
  if (!fs.existsSync(origin)) continue
@@ -396,11 +415,21 @@ function upgrade(dir, cli) {
396
415
  // la puerta de atrás, y no se ve mirando el archivo — se ve dos upgrades después.
397
416
  const entregado = { ...record }
398
417
  for (const relative of O.trackedPaths()) {
399
- const dir = path.join(root, relative)
400
- if (fs.existsSync(dir)) record = M.record(root, relative, O.treeFiles(dir), record)
418
+ if (fs.existsSync(path.join(root, relative))) {
419
+ record = M.record(root, relative, O.deliveredFiles(root, relative), record)
420
+ }
421
+ }
422
+ // Migración: una versión anterior pudo haber registrado un guard propio —qué cuenta, en `deliveredFiles`—.
423
+ // Se olvida acá. Y un choque que se conservó no se registra, para que la corrida siguiente lo vuelva a ver.
424
+ for (const relative of O.RUNTIME_PATHS) {
425
+ const shipped = O.shippedFiles(relative)
426
+ for (const key of Object.keys(record)) {
427
+ if (key.startsWith(`${relative}/`) && !shipped.has(key.slice(relative.length + 1))) delete record[key]
428
+ }
401
429
  }
402
430
  record = M.recordPaths(root, O.SYSTEM_FILES, record)
403
431
  for (const file of conservados) if (entregado[file]) record[file] = entregado[file]
432
+ for (const file of choques) delete record[file]
404
433
  // El registro de forks se poda igual que el de archivos: un cargo devuelto al catálogo deja su
405
434
  // entrada, y una entrada sin copia sólo puede producir avisos sobre algo que no está.
406
435
  const kept = Object.fromEntries(Object.entries(M.readForks(root)).filter(
@@ -11,6 +11,7 @@ const PC = require('../planning/contracts')
11
11
  const SR = require('../planning/structure')
12
12
  const SZ = require('../planning/sizing')
13
13
  const RC = require('../planning/recurring')
14
+ const IB = require('../planning/inbox')
14
15
  const CL = require('../planning/claims')
15
16
  const R = require('../core/repos')
16
17
  const ST = require('../planning/state')
@@ -23,6 +24,7 @@ const OB = require('../core/onboarding')
23
24
  const C = require('../config/validate')
24
25
  const CP = require('../config/paths')
25
26
  const AG = require('../agents/catalog')
27
+ const RL = require('../automation/rules')
26
28
  const { fail, planningRoot } = require('./io')
27
29
 
28
30
  // Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
@@ -97,12 +99,14 @@ function check(dir, cli) {
97
99
  for (const file of required) if (!fs.existsSync(path.join(root, file))) errors.push(`falta ${file}`)
98
100
 
99
101
  const configPath = path.join(root, '..', 'ops.config.json')
102
+ let config = null
100
103
  if (fs.existsSync(configPath)) {
101
104
  try {
102
105
  const raw = fs.readFileSync(configPath, 'utf8')
103
- const config = JSON.parse(raw)
106
+ config = JSON.parse(raw)
104
107
  if (!raw.includes('{{')) {
105
108
  errors.push(...C.validateOpsConfig(config))
109
+ warnings.push(...C.configWarnings(config))
106
110
  if (Array.isArray(config.workspaceRoots)) {
107
111
  for (const workspace of config.workspaceRoots) {
108
112
  if (workspace && workspace.name && workspace.path
@@ -169,6 +173,7 @@ function check(dir, cli) {
169
173
  const recurring = RC.read(root)
170
174
  errors.push(...RC.validate(recurring))
171
175
  warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
176
+ warnings.push(...IB.warnings(root, done, config))
172
177
  warnings.push(...AD.sealWarnings(root))
173
178
  // Una aprobación vale para el conjunto que nombra, así que olvidada sigue autorizando
174
179
  // esas mismas rutas la próxima vez que alguien las stagee. No caduca sola: lo que la cierra es que se
@@ -213,6 +218,8 @@ function check(dir, cli) {
213
218
  warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} `
214
219
  + `(override explícito)${retired.length ? `; deja de regir ${retired.join(', ')}` : ''}`)
215
220
  }
221
+ // Y lo que instaló cada runner, contra esas mismas reglas (caso 099).
222
+ warnings.push(...RL.staleLines(path.resolve(root, '..')))
216
223
  // Misma regla para los cargos, que es donde más caro sale: un fork se hace una vez y se olvida.
217
224
  const FK = require('../agents/fork')
218
225
  for (const entry of FK.drift(path.resolve(root, '..'))) warnings.push(FK.driftLine(entry))
@@ -380,8 +387,14 @@ function context(dir, cli) {
380
387
  // mismo criterio. Por qué no la encola una máquina está en la fase Pick de `autobuild`.
381
388
  nextEpic: (!task && [...state.epics].filter((one) => one.status === 'open')
382
389
  .sort((a, b) => String(a.num).localeCompare(String(b.num)))[0]) || null,
390
+ // Sólo en el JSON: lo leen los recorridos que escriben en el INBOX, para no repetir un nombre.
391
+ inbox: P.inboxHeads(root),
392
+ // Las reglas que rigen, con los overrides resueltos. Van acá porque `autobuild` ya lee este comando y
393
+ // tiene prohibido abrir otros archivos para completar su contrato (caso 105).
394
+ rules: O.effectiveRules(path.resolve(root, '..')),
383
395
  }
384
396
  if (cli.has('--json')) return console.log(JSON.stringify(report))
397
+ const reglas = () => console.log(`RULES ${report.rules.join(', ') || '(ninguna)'}`)
385
398
 
386
399
  if (report.blocked === 'awaiting-review') {
387
400
  const first = P.read(gate).split('\n').find((line) => line.trim() && !line.startsWith('#')) || ''
@@ -419,7 +432,7 @@ function context(dir, cli) {
419
432
  espera()
420
433
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
421
434
  due()
422
- return
435
+ return reglas()
423
436
  }
424
437
  console.log(`TASK ${report.task.slug}${report.task.tier ? ` [${report.task.tier}]` : ''}` +
425
438
  `${report.task.service ? ` service: ${report.task.service}` : ''}` +
@@ -448,6 +461,7 @@ function context(dir, cli) {
448
461
  if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
449
462
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
450
463
  due()
464
+ reglas()
451
465
  }
452
466
 
453
467
  // Qué trabajo recurrente vence, y la línea con la que se promueve. Emite esa línea y no la escribe:
@@ -20,7 +20,7 @@ function validateOpsConfig(config) {
20
20
  // `cauceVersion` la escribe el toolkit, no la persona: registra de qué versión salió la instancia.
21
21
  const allowed = new Set([
22
22
  '$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'writableOutsideRoots', 'runner',
23
- 'migrations',
23
+ 'migrations', 'inbox',
24
24
  ])
25
25
  for (const key of Object.keys(config)) {
26
26
  if (RETIRED[key]) errors.push(`ops.config.json: ${key} ya no se usa: ${RETIRED[key]}`)
@@ -34,9 +34,26 @@ function validateOpsConfig(config) {
34
34
  validateWritable(config.writableOutsideRoots, errors)
35
35
  validateRunner(config.runner, errors)
36
36
  validateMigrations(config.migrations, errors)
37
+ validateInbox(config.inbox, errors)
37
38
  return errors
38
39
  }
39
40
 
41
+ // El umbral del aviso de tamaño del INBOX. Sólo un entero positivo: un cero o un texto se leerían como
42
+ // «avisá siempre» o «nunca», y ninguno de los dos es algo que alguien escriba a propósito.
43
+ function validateInbox(inbox, errors) {
44
+ if (inbox === undefined) return
45
+ if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox)) {
46
+ errors.push('ops.config.json: inbox debe ser un objeto')
47
+ return
48
+ }
49
+ for (const key of Object.keys(inbox)) {
50
+ if (key !== 'warnLines') errors.push(`ops.config.json: inbox.${key} no está permitido`)
51
+ }
52
+ if ('warnLines' in inbox && !(Number.isInteger(inbox.warnLines) && inbox.warnLines > 0)) {
53
+ errors.push('ops.config.json: inbox.warnLines debe ser un entero mayor que cero, o no estar')
54
+ }
55
+ }
56
+
40
57
  // Qué cuenta como migración para el guard. Sin declararlo, sólo `.sql` — y ése es el default que hace
41
58
  // falta decir, porque un proyecto TypeORM, Prisma, Django o Rails tiene el guard cableado y en verde sin
42
59
  // que mire una sola migración (caso 077).
@@ -68,11 +85,36 @@ function validateMigrations(migrations, errors) {
68
85
  }
69
86
  }
70
87
 
88
+ // Lo que la configuración tiene de dudoso y no de inválido. Va aparte de `validateOpsConfig` porque lo que
89
+ // aquélla devuelve son errores y la leen también los guards: una advertencia ahí sería un rechazo.
90
+ //
91
+ // Hoy sólo el nombre repetido: dos raíces con el mismo `name` dejan dos servicios indistinguibles en
92
+ // `check`, `onboard` y `scan`, y una credencial que no se puede atribuir a ninguno (caso 113). Avisa y no
93
+ // falla porque nadie eligió su `name` pensando que fuera único, así que rechazarlo le rompería la puerta a
94
+ // una instancia que hoy funciona, y lo que está en juego es un nombre ambiguo, no algo que se pierda.
95
+ function configWarnings(config) {
96
+ const roots = Array.isArray(config && config.workspaceRoots) ? config.workspaceRoots : []
97
+ const found = []
98
+ const named = new Map()
99
+ for (const workspace of roots) {
100
+ const name = workspace && typeof workspace.name === 'string' ? workspace.name.trim() : ''
101
+ if (!name) continue
102
+ if (named.has(name)) {
103
+ found.push(`ops.config.json: ${name} nombra dos raíces (${named.get(name)} y ${workspace.path}): `
104
+ + 'sus servicios salen con el mismo nombre. Renombrá una')
105
+ } else named.set(name, workspace.path)
106
+ }
107
+ return found
108
+ }
109
+
71
110
  function validateWorkspaces(workspaces, errors) {
72
111
  if (!Array.isArray(workspaces) || !workspaces.length) {
73
112
  errors.push('ops.config.json: workspaceRoots debe contener al menos una raíz')
74
113
  return
75
114
  }
115
+ // El nombre repetido no se rechaza acá: lo avisa `check`, que es quien puede hacerlo sin romperle la
116
+ // puerta a una instancia que hoy funciona (caso 113). Este validador lo leen también los guards, y lo
117
+ // que devuelve son errores: meterlo acá era decidir que la configuración es inválida.
76
118
  for (const [index, workspace] of workspaces.entries()) {
77
119
  if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) {
78
120
  errors.push(`ops.config.json: workspaceRoots[${index}] debe ser un objeto`)
@@ -119,10 +161,18 @@ function validateRunner(runner, errors) {
119
161
  return
120
162
  }
121
163
  const booleans = ['humanCheckpointBetweenMilestones', 'commitPerTask', 'allowPush']
122
- const allowed = new Set(['maxTaskHours', ...booleans])
164
+ const allowed = new Set(['maxTaskHours', 'pushToLiveBranches', ...booleans])
123
165
  for (const key of Object.keys(runner)) {
124
166
  if (!allowed.has(key)) errors.push(`ops.config.json: runner.${key} no está permitido`)
125
167
  }
168
+ // Un patrón haría de un permiso por rama un permiso por familia, que es lo que el campo vino a evitar
169
+ // (caso 108): el guard compara el nombre tal cual, y `release/*` no publicaría en ninguna.
170
+ const live = runner.pushToLiveBranches
171
+ if (live !== undefined && (!Array.isArray(live)
172
+ || live.some((branch) => typeof branch !== 'string' || !/^[^\s*?[]+$/.test(branch)))) {
173
+ errors.push('ops.config.json: runner.pushToLiveBranches debe ser una lista de nombres de rama exactos, '
174
+ + 'sin espacios ni patrones')
175
+ }
126
176
  if (typeof runner.maxTaskHours !== 'number' || runner.maxTaskHours <= 0) {
127
177
  errors.push('ops.config.json: runner.maxTaskHours debe ser mayor que cero')
128
178
  }
@@ -133,4 +183,4 @@ function validateRunner(runner, errors) {
133
183
  }
134
184
  }
135
185
 
136
- module.exports = { validateOpsConfig }
186
+ module.exports = { configWarnings, validateOpsConfig }
@@ -9,6 +9,7 @@
9
9
  const fs = require('node:fs')
10
10
  const path = require('node:path')
11
11
  const { inventory } = require('./scan')
12
+ const { sensitiveKey } = require('../integrations/registry')
12
13
 
13
14
  // La raíz del paquete: el molde contra el que se compara lo que una instancia escribió.
14
15
  const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
@@ -91,11 +92,23 @@ function missingSections(root) {
91
92
  return warnings
92
93
  }
93
94
 
95
+ // Una aparición cuenta sólo si lo que la rodea no puede ser parte de otro nombre de variable: buscada
96
+ // como subcadena, API_SECRET_ROTATION daba por cargada a API_SECRET (caso 111). El escaneo sólo deja
97
+ // pasar identificadores, así que el nombre entra a la expresión sin nada que escapar.
98
+ function named(text, name) {
99
+ return new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(text)
100
+ }
101
+
94
102
  // Credenciales que el proyecto declara y que no aparecen en ningún contrato. El arranque tiene que
95
103
  // dejar una fila por cada una —quién la carga y dónde— y en la práctica cubre las que se hablaron en la
96
104
  // conversación: las que sólo estaban en el inventario se pierden, y con ellas el servicio externo que
97
105
  // hay detrás. Una variable sin dueño no rompe nada hoy; rompe el día que alguien tiene que desplegar.
98
106
  //
107
+ // Credencial es lo que tiene nombre de secreto según `sensitiveKey`, la misma regla con la que la
108
+ // declaración de secretos y la configuración de una integración rechazan un valor. Contando cualquier
109
+ // variable, el aviso listaba ciento ocho nombres de build y la única credencial quedaba en «y 1 más»
110
+ // (caso 102). El precio es el secreto con nombre de configuración, y el aviso lo dice.
111
+ //
99
112
  // Sólo cuando la instancia ya tiene contexto escrito: antes del arranque no hay dónde estuvieran.
100
113
  function orphanCredentials(root) {
101
114
  if (guide(root).fresh) return []
@@ -108,16 +121,33 @@ function orphanCredentials(root) {
108
121
  .join('\n')
109
122
  if (!contracts) return []
110
123
  const orphans = []
124
+ const cut = []
111
125
  for (const service of inventory(root)) {
112
- for (const name of (service.env || {}).names || []) {
113
- if (!contracts.includes(name)) orphans.push(`${name} (${service.path})`)
126
+ const env = service.env || {}
127
+ for (const name of env.names || []) {
128
+ if (sensitiveKey(name) && !named(contracts, name)) orphans.push({ name, service: service.path })
114
129
  }
130
+ if (env.truncated) cut.push(`${service.path} (${env.truncated} de ${env.names.length + env.truncated})`)
115
131
  }
116
- if (!orphans.length) return []
117
- const summary = orphans.length > 4
118
- ? `${orphans.slice(0, 4).join(', ')} y ${orphans.length - 4} más`
119
- : orphans.join(', ')
120
- return [`el proyecto declara ${summary} y no aparecen en el mapa ni en HUMAN_ACTIONS: nadie las carga`]
132
+ const warnings = []
133
+ if (orphans.length) {
134
+ const listed = orphans.map((one) => `${one.name} (${one.service})`)
135
+ const summary = listed.length > 4
136
+ ? `${listed.slice(0, 4).join(', ')} y ${listed.length - 4} más`
137
+ : listed.join(', ')
138
+ const services = [...new Set(orphans.map((one) => one.service))].join(', ')
139
+ warnings.push(`credenciales por nombre sin dueño (${orphans.length}, en ${services}): ${summary} — no `
140
+ + 'aparecen en el mapa ni en HUMAN_ACTIONS: nadie las carga. El dueño se escribe en '
141
+ + 'organization/workspace.md o en una fila de planning/HUMAN_ACTIONS.md; el criterio es el nombre, así '
142
+ + 'que una credencial con nombre de configuración no aparece acá')
143
+ }
144
+ // El escaneo corta cada ejemplo en un tope, y lo que quedó afuera no se miró: con el filtro, puede ser
145
+ // justo la credencial.
146
+ if (cut.length) {
147
+ warnings.push(`sin revisar por credenciales sin dueño, pasado el tope de variables por servicio: `
148
+ + `${cut.join(', ')} — lo que quedó afuera puede incluir una credencial que nadie carga`)
149
+ }
150
+ return warnings
121
151
  }
122
152
 
123
153
  module.exports = {