@ingeniomaps/cauce 0.80.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 (48) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/automatization/hooks/README.md +41 -11
  3. package/automatization/hooks/guard-chat.sh +5 -0
  4. package/automatization/hooks/guard-secrets-shell.sh +3 -0
  5. package/automatization/runners/antigravity/rules/cauce.md +7 -2
  6. package/automatization/runners/claude/CLAUDE.md +1 -4
  7. package/automatization/runners/claude/README.md +5 -4
  8. package/automatization/runners/claude/manifest.json +26 -1
  9. package/automatization/runners/claude/settings.json +8 -24
  10. package/automatization/runners/codex/AGENTS.md +7 -3
  11. package/automatization/runners/codex/README.md +4 -0
  12. package/automatization/runners/codex/hooks.json +7 -0
  13. package/automatization/runners/gemini/GEMINI.md +1 -4
  14. package/automatization/runners/gemini/README.md +5 -2
  15. package/automatization/runners/gemini/settings.json +11 -1
  16. package/automatization/shared/inbox.js +28 -0
  17. package/automatization/workflows/agent-eval.js +1 -1
  18. package/automatization/workflows/autobuild.js +52 -18
  19. package/automatization/workflows/flow.js +33 -8
  20. package/automatization/workflows/onboard.js +25 -3
  21. package/engine/automation/index.js +28 -5
  22. package/engine/automation/rules.js +122 -0
  23. package/engine/automation/runners.js +3 -1
  24. package/engine/cli/instance.js +35 -6
  25. package/engine/cli/planning.js +16 -2
  26. package/engine/config/validate.js +53 -3
  27. package/engine/core/onboarding.js +37 -7
  28. package/engine/core/ownership.js +64 -1
  29. package/engine/core/scan.js +23 -9
  30. package/engine/hooks/approval.js +40 -9
  31. package/engine/hooks/chat.js +171 -0
  32. package/engine/hooks/files.js +28 -16
  33. package/engine/hooks/input.js +8 -12
  34. package/engine/hooks/push.js +147 -0
  35. package/engine/hooks/run.js +23 -3
  36. package/engine/hooks/secrets-shell.js +62 -0
  37. package/engine/hooks/self-approval.js +31 -0
  38. package/engine/hooks/shell.js +37 -31
  39. package/engine/integrations/registry.js +13 -1
  40. package/engine/planning/inbox.js +36 -0
  41. package/engine/planning/parser.js +22 -9
  42. package/engine/planning/recurring.js +13 -2
  43. package/engine/schemas/ops-config.schema.json +21 -0
  44. package/package.json +1 -1
  45. package/template/AGENTS.md +23 -7
  46. package/template/planning/INBOX.md +2 -1
  47. package/template/planning/RECURRING.md +6 -5
  48. package/template/planning/rules/system/commits.md +3 -1
@@ -9,10 +9,12 @@ const os = require('node:os')
9
9
  const path = require('node:path')
10
10
  const { spawnSync } = require('node:child_process')
11
11
  const {
12
- commandOf, cwdOf, block, isCommit, stagedForCommit, pushAllowed,
13
- writableRoots, outsideRoots, DECLARE_IT, unquoted, findOpsRoot, withoutGitGlobals,
12
+ commandOf, cwdOf, block, isCommit, stagedForCommit,
13
+ writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
14
14
  } = require('./input')
15
15
  const AP = require('./approval')
16
+ const { publish } = require('./push')
17
+ const { selfApproval } = require('./self-approval')
16
18
  const EV = require('../core/evidence')
17
19
 
18
20
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
@@ -48,12 +50,6 @@ const MISMO = String.raw`[^;&|\n]`
48
50
  // `bash -c "git push origin main"` y `eval "git reset --hard"` siguen cayendo, comprobado. Queda afuera
49
51
  // la sustitución dentro del propio mensaje —`git commit -m "$(...)"` corre y ya no se ve—, que es
50
52
  // evasión y no la forma habitual.
51
- // La raíz donde vive `planning/`, que es donde se busca la aprobación. Los cuatro guards que la
52
- // consultan la resuelven igual, así que se resuelve una vez.
53
- function opsRoot(input) {
54
- return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
55
- }
56
-
57
53
  function destructive(input) {
58
54
  const raw = commandOf(input)
59
55
  // Las opciones globales de `git` se sacan acá y no en cada regla: toda regla de abajo que mire un
@@ -69,14 +65,15 @@ function destructive(input) {
69
65
  // matchea igual las dos formas, así que `allowPush` habilitaba el force-push sin que nadie lo decidiera
70
66
  // y el párrafo de autonomía de `AGENTS.md` tenía que confesarlo. R8 prohíbe `force` sin excepción
71
67
  // configurable, así que esta rama va antes del permiso y no lo consulta.
72
- if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:-f|--force(?:-with-lease|-if-includes)?)\b`)
68
+ //
69
+ // El `+` delante de una rama es el mismo force escrito en el refspec —`git push origin +main`—, y
70
+ // pasaba como un push normal: la regla miraba sólo las banderas (caso 103).
71
+ if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:(?:-f|--force(?:-with-lease|-if-includes)?)\b|\+\S)`)
73
72
  .test(command)) {
74
73
  block("'git push --force' reescribe historia ya publicada. R8 lo prohíbe y runner.allowPush no lo "
75
74
  + 'habilita: publicá con un push normal, o registrá una acción humana.')
76
75
  }
77
- if (/\bgit\s+push\b/.test(command) && !pushAllowed(input)) {
78
- block("'git push' publica cambios y requiere una acción humana. Se habilita con runner.allowPush.")
79
- }
76
+ publish(input, command)
80
77
  const rules = [
81
78
  [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
82
79
  // R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
@@ -181,7 +178,7 @@ function dependencies(input) {
181
178
  // `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
182
179
  // conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
183
180
  const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
184
- names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
181
+ names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
185
182
  // Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
186
183
  // disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
187
184
  // en el próximo commit— y ahí la comprobación dejaba de dispararse justo cuando más hacía falta. Es
@@ -204,12 +201,12 @@ function dependencies(input) {
204
201
  const manifests = sinAprobar(parent, state.manifests)
205
202
  if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
206
203
  block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
207
- + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests))
204
+ + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
208
205
  }
209
206
  const lockfiles = sinAprobar(parent, state.locks)
210
207
  if (state.locks.length && !state.manifests.length && lockfiles.length) {
211
208
  block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.\n`
212
- + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles))
209
+ + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles, input))
213
210
  }
214
211
  }
215
212
  }
@@ -306,17 +303,21 @@ function writesWithBase(command, cwd) {
306
303
 
307
304
  function shellBoundary(input) {
308
305
  const allowed = writableRoots(input)
309
- if (!allowed) return
310
306
  for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
311
307
  // Una ruta absoluta no depende del `cd`, así que un destino que no se sabe no la vuelve injuzgable.
312
308
  // Al revés sí: sin saber desde dónde se resuelve, una relativa no se puede verificar, y un guard que
313
309
  // no puede verificar no autoriza —el criterio que fijó el 031 para el índice—.
314
310
  if (!path.isAbsolute(raw) && base === null) {
311
+ if (!allowed) continue
315
312
  block(`el comando hace \`cd\` a un destino que no se puede resolver acá, así que no hay contra qué `
316
313
  + `resolver ${raw}. Escribí la ruta absoluta, o hacé el \`cd\` en un comando aparte.`)
317
314
  }
318
- const file = path.resolve(base, raw)
319
- if (NEUTRAL.some((pattern) => pattern.test(file))) continue
315
+ const file = path.resolve(base || '/', raw)
316
+ // El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
317
+ // declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
318
+ const own = selfApproval(input, file)
319
+ if (own) block(own)
320
+ if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
320
321
  if (outsideRoots(file, allowed)) {
321
322
  block(`el comando escribe en ${file}, fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
322
323
  }
@@ -347,9 +348,9 @@ function governance(input) {
347
348
  // La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
348
349
  // reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
349
350
  // entre una llave por operación y una puerta que quedó abierta.
350
- const pendientes = AP.pending(opsRoot(input), governed)
351
+ const pendientes = AP.pending(opsRoot(input), governed, input)
351
352
  if (!pendientes.length) return
352
- block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes)}`)
353
+ block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
353
354
  }
354
355
 
355
356
  function run(program, args, cwd, extra = {}) {
@@ -488,20 +489,20 @@ function verify(input) {
488
489
  // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
489
490
  // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
490
491
  // y no de una regla, que es lo que la vuelve una operación y no un permiso.
491
- const sinAprobar = AP.pending(opsRoot(input), staged)
492
+ const sinAprobar = AP.pending(opsRoot(input), staged, input)
492
493
  const aprobado = !sinAprobar.length
493
494
  if (changedOpenApi && !hasApiGenerated && !aprobado) {
494
495
  block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
495
- + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar)}`)
496
+ + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
496
497
  }
497
498
  if (changedSqlSource && !hasSqlGenerated && !aprobado) {
498
499
  block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
499
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
500
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
500
501
  }
501
502
  if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
502
503
  const { root, temp, env } = commitTree(dir)
503
504
  try {
504
- verifyGates(root, dir, sinAprobar, env, opsRoot(input))
505
+ verifyGates(root, dir, sinAprobar, env, input)
505
506
  } finally {
506
507
  if (temp) fs.rmSync(temp, { recursive: true, force: true })
507
508
  }
@@ -522,11 +523,15 @@ function verify(input) {
522
523
  // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
523
524
  // que hace falta para diagnosticar es la primera línea de error, no el volcado.
524
525
  const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
525
- // Cómo marca un reporte de pruebas cada resultado: `node --test` en spec y en TAP, y `go test`. Van sólo
526
- // las comprobadas contra la herramienta (caso 094): el nombre de una prueba verde puede decir «error», y
527
- // sin mirar la marca la búsqueda por palabra se quedaba con ella y el mensaje escondía la roja.
528
- const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:)/
529
- const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)/
526
+ // Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
527
+ // 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
528
+ // quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
529
+ // `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
530
+ // `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
531
+ // pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
532
+ // no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
533
+ const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
534
+ const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
530
535
  const MAX_LINE = 160
531
536
  function fallo(gate, result) {
532
537
  // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
@@ -555,7 +560,8 @@ function comoSeLee(failures) {
555
560
  + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
556
561
  }
557
562
 
558
- function verifyGates(root, dir, sinAprobar, env, ops) {
563
+ function verifyGates(root, dir, sinAprobar, env, input) {
564
+ const ops = opsRoot(input)
559
565
  const failures = []
560
566
  if (fs.existsSync(path.join(root, 'package.json'))) {
561
567
  const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
@@ -595,7 +601,7 @@ function verifyGates(root, dir, sinAprobar, env, ops) {
595
601
  const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
596
602
  + 'directorio pasa, es que en disco tenés algo que no está staged.'
597
603
  block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
598
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
604
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
599
605
  }
600
606
 
601
607
  module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
@@ -64,11 +64,22 @@ function adapter(root, name, entry = {}) {
64
64
  return impl
65
65
  }
66
66
 
67
+ // Qué nombre tiene forma de secreto. Es una sola regla para los tres que la necesitan: la configuración
68
+ // de una integración, la declaración de secretos y el aviso de credenciales sin dueño de «ops check».
69
+ // La clave cuenta sólo como palabra propia —API_KEY sí, projectKey no—, porque el campo de Jira con la
70
+ // clave del proyecto está en instancias reales y rechazarlo les rompería la validación (caso 102, donde
71
+ // está la medición).
72
+ const SENSITIVE = /(password|secret|token|authorization|cookie|dsn|credentials?|(?:^|_)key)$/i
73
+
74
+ function sensitiveKey(key) {
75
+ return SENSITIVE.test(key)
76
+ }
77
+
67
78
  function sensitivePath(value, trail = '') {
68
79
  if (!value || typeof value !== 'object') return ''
69
80
  for (const [key, child] of Object.entries(value)) {
70
81
  const next = trail ? `${trail}.${key}` : key
71
- if (/(password|secret|token|authorization|cookie)$/i.test(key) && !/Env$/i.test(key)) return next
82
+ if (sensitiveKey(key)) return next
72
83
  const nested = sensitivePath(child, next)
73
84
  if (nested) return nested
74
85
  }
@@ -442,6 +453,7 @@ module.exports = {
442
453
  providerConfig,
443
454
  reconcile,
444
455
  safeSegment,
456
+ sensitiveKey,
445
457
  sensitivePath,
446
458
  sync,
447
459
  validate,
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+
3
+ // Lo que `check` dice sobre el INBOX. Son advertencias y nunca errores: el INBOX es de la persona, y un
4
+ // `check` rojo por lo que tiene adentro la frenaría a ella por lo que escribió un recorrido (caso 101).
5
+
6
+ const path = require('node:path')
7
+ const P = require('./parser')
8
+
9
+ const FILE = 'INBOX.md'
10
+ // Un archivo que todavía se recorre de una sentada; el molde vacío ocupa menos de treinta. La instancia
11
+ // lo cambia en `inbox.warnLines`.
12
+ const WARN_LINES = 300
13
+
14
+ function warnings(root, done, config) {
15
+ const text = P.read(path.join(root, FILE))
16
+ if (!text.trim()) return []
17
+ const found = []
18
+ const declared = config && config.inbox && config.inbox.warnLines
19
+ const limit = Number.isInteger(declared) && declared > 0 ? declared : WARN_LINES
20
+ const count = text.replace(/\n$/, '').split('\n').length
21
+ if (count > limit) {
22
+ found.push(`${FILE}: ${count} líneas, más que el umbral de ${limit} (inbox.warnLines en ops.config.json); `
23
+ + 'recorrelo y borrá lo que ya se decidió')
24
+ }
25
+ // Una entrada que se llama como una tarea cerrada probablemente se promovió y nadie la borró. Es un
26
+ // indicio y no una prueba —el molde no obliga a que la tarea conserve el nombre del ítem—, y por eso
27
+ // avisa en vez de fallar (caso 106).
28
+ for (const name of Object.values(P.inboxHeads(root)).flat()) {
29
+ if (done.set.has(name)) {
30
+ found.push(`${FILE}: **${name}** se llama como done/${name}.md; si ya se promovió, borrala`)
31
+ }
32
+ }
33
+ return found
34
+ }
35
+
36
+ module.exports = { warnings }
@@ -405,18 +405,31 @@ function readWips(dir) {
405
405
  // La plantilla no traía ningún ejemplo, así que quien escribía viñetas planas veía cero ítems sobre un
406
406
  // archivo con doce y nada se lo decía. `skipped` es lo que vuelve visible esa diferencia.
407
407
  function readInbox(dir) {
408
- const result = { deuda: 0, ideas: 0, propuestas: 0, lecciones: 0, skipped: 0 }
408
+ const { heads, skipped } = inboxSections(dir)
409
+ return { ...Object.fromEntries(Object.entries(heads).map(([key, names]) => [key, names.length])), skipped }
410
+ }
411
+
412
+ // Los nombres en negrita de cada sección, que es lo que un recorrido necesita para no volver a escribir
413
+ // lo que ya está: el nombre y no la entrada, porque pasar el archivo entero a cada tarea cuesta lo que
414
+ // el INBOX pesa (caso 101).
415
+ function inboxHeads(dir) {
416
+ return inboxSections(dir).heads
417
+ }
418
+
419
+ function inboxSections(dir) {
420
+ const heads = { deuda: [], ideas: [], propuestas: [], lecciones: [] }
421
+ let skipped = 0
409
422
  for (const part of read(path.join(dir, 'INBOX.md')).split(/^##\s+/m)) {
410
423
  const title = part.split('\n')[0]
411
424
  const bullets = (part.match(/^[-*]\s+(?:\[[ xX]\]\s+)?/gm) || []).length
412
- const count = (part.match(/^[-*]\s+(?:\[[ xX]\]\s+)?\*\*/gm) || []).length
413
- if (/Deuda|Ideas|Visi[oó]n|Propuestas|Lecciones/i.test(title)) result.skipped += bullets - count
414
- if (/Deuda/i.test(title)) result.deuda = count
415
- if (/Ideas|Visi[oó]n/i.test(title)) result.ideas = count
416
- if (/Propuestas/i.test(title)) result.propuestas = count
417
- if (/Lecciones/i.test(title)) result.lecciones = count
425
+ const names = [...part.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?\*\*([^*\n]*)/gm)].map((hit) => hit[1].trim())
426
+ if (/Deuda|Ideas|Visi[oó]n|Propuestas|Lecciones/i.test(title)) skipped += bullets - names.length
427
+ if (/Deuda/i.test(title)) heads.deuda = names
428
+ if (/Ideas|Visi[oó]n/i.test(title)) heads.ideas = names
429
+ if (/Propuestas/i.test(title)) heads.propuestas = names
430
+ if (/Lecciones/i.test(title)) heads.lecciones = names
418
431
  }
419
- return result
432
+ return { heads, skipped }
420
433
  }
421
434
 
422
435
  module.exports = {
@@ -424,5 +437,5 @@ module.exports = {
424
437
  TASK_LINE, TASK_LINE_ANY_LANE,
425
438
  read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip, readWips, wipName,
426
439
  acceptanceConditions, tableRows, taskFromLine,
427
- readInbox, readHumanActions,
440
+ readInbox, inboxHeads, readHumanActions,
428
441
  }
@@ -19,6 +19,10 @@ const CADENCES = { mensual: 1, trimestral: 3, semestral: 6, anual: 12 }
19
19
 
20
20
  const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
21
21
  const DATE = /^\d{4}-\d{2}-\d{2}$/
22
+ // La fila que el molde trae activa escribe su `Desde` como marcador, porque la fecha depende del día en
23
+ // que se crea la instancia y la reemplazan `init` y `upgrade`. Sin resolver sólo existe en el molde
24
+ // mismo —el que `npm run check` valida en el toolkit—, y ahí no vence: `status` la descarta por fecha.
25
+ const PLACEHOLDER = /^\{\{[A-Z_]+\}\}$/
22
26
  // `- **qué** AAAA-MM-DD — razón`. El nombre en negrita adelante es la misma convención del INBOX, y por
23
27
  // el mismo motivo: es con lo que se cita la fila desde otro lado.
24
28
  const POSTPONEMENT = /^-\s+\*\*([^*]+)\*\*\s+(\S+)\s+[—-]\s+(.+)$/
@@ -77,7 +81,7 @@ function validate({ exists, rows, postponements }) {
77
81
  if (!CADENCES[row.cadence]) {
78
82
  errors.push(`${at}: cadencia "${row.cadence}" fuera de ${Object.keys(CADENCES).join(' | ')}`)
79
83
  }
80
- if (!DATE.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
84
+ if (!DATE.test(row.since) && !PLACEHOLDER.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
81
85
  // Se juzga la línea armada y no la celda suelta: lo que se promueve es esa línea, y quien la va a
82
86
  // leer es el mismo lector de BACKLOG. Una celda que pasa acá y una línea que BACKLOG rechaza es el
83
87
  // error que aparece un mes después, con la tarea ya pegada.
@@ -145,4 +149,11 @@ function warnings(state) {
145
149
  return lines
146
150
  }
147
151
 
148
- module.exports = { FILE, read, validate, status, warnings, taskLine }
152
+ // Los marcadores de fecha del molde, resueltos para una instancia que nace hoy. `Desde` es la primera
153
+ // fecha de vencimiento y no el día en que se declara la fila, así que va un período después: con la
154
+ // fecha de hoy la fila nacía «vence hoy» (caso 106). Trimestral es la cadencia de la fila del molde.
155
+ function sinceValues(today) {
156
+ return { '{{INBOX_SINCE}}': addMonths(today, CADENCES.trimestral) }
157
+ }
158
+
159
+ module.exports = { FILE, read, validate, status, warnings, taskLine, sinceValues }
@@ -83,6 +83,15 @@
83
83
  },
84
84
  "allowPush": {
85
85
  "type": "boolean"
86
+ },
87
+ "pushToLiveBranches": {
88
+ "type": "array",
89
+ "description": "Las ramas vivas —main, master y la rama por defecto de cada remoto— en las que se puede publicar. Sin nombrarla acá, ni allowPush ni una orden en el chat llegan a una rama viva. Nombres exactos, sin patrones.",
90
+ "items": {
91
+ "type": "string",
92
+ "pattern": "^[^\\s*?\\[]+$"
93
+ },
94
+ "uniqueItems": true
86
95
  }
87
96
  },
88
97
  "additionalProperties": false
@@ -102,6 +111,18 @@
102
111
  "minItems": 1
103
112
  }
104
113
  }
114
+ },
115
+ "inbox": {
116
+ "type": "object",
117
+ "description": "El aviso de tamaño del INBOX. `check` advierte —nunca falla— cuando `planning/INBOX.md` pasa de este número de líneas: el INBOX es de la persona, y lo que el aviso pide es recorrerlo, no dejar de escribir.",
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "warnLines": {
121
+ "type": "integer",
122
+ "minimum": 1,
123
+ "description": "Líneas a partir de las cuales `check` avisa. Por defecto 300."
124
+ }
125
+ }
105
126
  }
106
127
  },
107
128
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -2,7 +2,9 @@
2
2
 
3
3
  Este archivo gobierna el qué y el cuándo. `planning/PROTOCOL.md` gobierna el flujo y
4
4
  `planning/rules/` el cómo: sus reglas rigen cada tarea y se leen antes de empezar, no cuando algo sale
5
- mal. Los tres los mantiene Cauce y valen para cualquier proyecto. Lo que este proyecto tiene de propio
5
+ mal. Este archivo, el protocolo y `planning/rules/system/` los mantiene Cauce y valen para cualquier
6
+ proyecto; las reglas propias viven junto a `system/`, y donde una de ellas sobrescribe, contradice o
7
+ restringe una del sistema, rige la del proyecto. Lo que este proyecto tiene de propio
6
8
  —su mapa, sus integraciones, hasta dónde llega la autonomía acá— vive en `organization/workspace.md`.
7
9
 
8
10
  ## Qué sabe este proyecto y no este archivo
@@ -103,9 +105,19 @@ Un guard existente **no se edita**: `upgrade` detecta el cambio y se detiene ant
103
105
  Algunos bloqueos tienen salida, y conviene saber cuál antes de necesitarla — el momento en que un guard
104
106
  te frena es el peor para elegir bien.
105
107
 
106
- **La salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que autorizás,
107
- una por línea, con `#` para lo que no sea una ruta. Es un solo archivo para todos los guards, porque lo
108
- que escribís son rutas y quién las mira lo decide qué guard esté juzgando esa ruta.
108
+ **Si lo pediste vos en el chat, no hace falta nada.** Los guards contienen al agente cuando decide solo o
109
+ cuando trabaja dentro de un recorrido; lo que vos pedís directo no se frena. Nombrá lo que querés que
110
+ toque —«borrá la prueba de altas», «reescribí la migración 004»— y pasa sin preguntarte de nuevo. Si tu
111
+ pedido no lo nombraba y algo se frena, el agente te dice qué y por qué: contestá «dale» y pasa exactamente
112
+ eso. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
113
+ por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
114
+ Antigravity, y cuando nadie está en el chat —CI, un recorrido, un subagente—, queda el archivo de abajo.
115
+
116
+ **Sin chat, la salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que
117
+ autorizás, una por línea, con `#` para lo que no sea una ruta. En sidecar es el `planning/` de la
118
+ instancia y no una carpeta al lado de tus proyectos; el bloqueo dice la ruta exacta. Es un solo archivo
119
+ para todos los guards, porque lo que escribís son rutas y quién las mira lo decide qué guard esté
120
+ juzgando esa ruta. Lo escribís vos: si el agente intenta escribírselo, un guard lo frena.
109
121
 
110
122
  | lo que te frena | qué ruta aprobás |
111
123
  |---|---|
@@ -240,12 +252,16 @@ aprobación que pide BR-OPS-002 — `context` la nombra para que la vea una pers
240
252
  `BACKLOG.md` es esa persona.
241
253
 
242
254
  Publicar es lo único de todo eso que este proyecto puede habilitar, y `runner.allowPush` en
243
- `ops.config.json` es la autorización que R10 pide. Reescribir historia publicada no entra en el trato:
244
- un `push --force` se frena con la llave prendida o apagada.
255
+ `ops.config.json` es la autorización que R10 pide para las ramas de trabajo. La rama viva —`main`,
256
+ `master` o la rama por defecto del remoto— no la alcanza si el proyecto no la nombra en
257
+ `runner.pushToLiveBranches`, y un subagente no publica con ningún permiso. Sin la llave, pasa el push
258
+ que la persona pide en el chat nombrando el remoto y la rama, o el que ella aprueba contestando «dale».
259
+ Reescribir historia publicada no entra en el trato: un `push --force` se frena con la llave prendida o
260
+ apagada.
245
261
 
246
262
  Eso rige sin que nadie escriba nada. Lo que este proyecto amplíe o restrinja va en
247
263
  `organization/workspace.md`, con su razón; ninguna de esas prohibiciones se amplía ahí, y la
248
- publicación tampoco se decide ahí: la decide `allowPush`.
264
+ publicación tampoco se decide ahí: la deciden `allowPush` y `pushToLiveBranches`.
249
265
 
250
266
  ## Definición de terminado
251
267
 
@@ -14,7 +14,8 @@ Lo que separa a las cuatro secciones es el sujeto del ítem, no su tamaño ni su
14
14
 
15
15
  - **Deuda** — un costo que ya estamos cargando en código nuestro, conocido y no bloqueante.
16
16
  - **Ideas** — una pregunta abierta, sin respuesta propuesta.
17
- - **Propuestas** — un cambio concreto del producto, con su evidencia y su fix propuesto.
17
+ - **Propuestas** — un cambio concreto del producto y su fix propuesto. La evidencia no se copia acá: se
18
+ cita dónde vive —el `done/` de la tarea, el informe—.
18
19
  - **Lecciones** — sobre cómo trabajamos; es lo que alimenta reglas y propuestas de cargo.
19
20
 
20
21
  Ideas y Propuestas se separan por si hay una respuesta propuesta. Deuda y Propuestas, por si el costo
@@ -16,7 +16,8 @@ vencimiento se calcula cuando alguien corre el CLI. Promover sigue siendo un act
16
16
  - **Cada** — vocabulario cerrado: `mensual`, `trimestral`, `semestral`, `anual`. No hay expresiones de
17
17
  cron, y esa ausencia es el enunciado: si hiciera falta una, lo que estarías declarando es otra cosa.
18
18
  Tampoco hay `semanal`, porque el slug de cada vuelta tiene grano de mes.
19
- - **Desde** — `AAAA-MM-DD`. Ancla la primera vuelta y después no se toca.
19
+ - **Desde** — `AAAA-MM-DD`. Ancla la primera vuelta y después no se toca. Es la primera fecha en que
20
+ vence, no el día en que se escribe la fila: con la fecha de hoy nace vencida.
20
21
  - **Tarea y aceptación** — lo que se va a promover, con su aceptación observable escrita una sola vez y
21
22
  con calma. Improvisada en cada vuelta, la misma recurrencia termina significando cosas distintas sin
22
23
  que nadie lo decida.
@@ -47,12 +48,12 @@ slug son un error de `check`, y ese error llega un mes tarde.
47
48
 
48
49
  | Qué | Cada | Desde | Tarea y aceptación observable |
49
50
  |---|---|---|---|
51
+ | inbox | trimestral | {{INBOX_SINCE}} | Recorrer el INBOX entero. _Aceptación: ninguna viñeta queda sin decisión de promover, dejar o borrar._ (service: planning) |
50
52
 
51
53
  <!--
52
- | deps | mensual | 2026-09-01 | Actualizar dependencias. _Aceptación: `npm outdated` no deja una versión mayor sin decisión escrita y la puerta queda verde._ |
53
- | accesos | trimestral | 2026-07-01 | Revisar quién tiene acceso a producción. _Aceptación: cada cuenta activa figura en `organization/`, y las demás están dadas de baja._ |
54
- | costos | mensual | 2026-09-01 | Revisar el gasto de infraestructura del mes. _Aceptación: cada línea que subió más de 20% tiene una razón escrita._ |
55
- | inbox | trimestral | 2026-08-01 | Recorrer el INBOX entero. _Aceptación: ninguna viñeta queda sin decisión de promover, dejar o borrar._ |
54
+ | deps | mensual | 2026-09-01 | Actualizar dependencias. _Aceptación: `npm outdated` no deja una versión mayor sin decisión escrita y la puerta queda verde._ (service: .) |
55
+ | accesos | trimestral | 2026-07-01 | Revisar quién tiene acceso a producción. _Aceptación: cada cuenta activa figura en `organization/`, y las demás están dadas de baja._ (service: organization) |
56
+ | costos | mensual | 2026-09-01 | Revisar el gasto de infraestructura del mes. _Aceptación: cada línea que subió más de 20% tiene una razón escrita._ (service: .) |
56
57
  -->
57
58
 
58
59
  ## Postergaciones
@@ -65,7 +65,9 @@ dice qué había que hacer, no qué se apoyaba en lo que había.
65
65
 
66
66
  Push, PR, merge, tags, deploy y rollback requieren la autorización configurada para el proyecto.
67
67
 
68
- De esos seis, el motor comprueba uno: el push, contra `runner.allowPush`. Reescribir historia publicada
68
+ De esos seis, el motor comprueba uno: el push, contra `runner.allowPush` —que no llega a la rama viva
69
+ sin `runner.pushToLiveBranches`, ni a un subagente— o contra la orden que la persona da en el chat
70
+ nombrando el remoto y la rama. Reescribir historia publicada
69
71
  no entra en esa autorización y se frena siempre, igual que `--amend`. Los otros cinco no tienen una
70
72
  forma reconocible en un comando —un deploy es `kubectl`, `terraform`, un script o un botón— y los
71
73
  sostiene esta regla y el review, no un guard.