@ingeniomaps/cauce 0.68.0 → 0.69.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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,26 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.69.0] - 2026-09-07
18
+
19
+ ### Agregado
20
+
21
+ - **`planning/RECURRING.md`: el trabajo que vuelve se declara una vez.** Actualizar dependencias, revisar
22
+ quién tiene acceso a producción, mirar el gasto del mes: una fila con su cadencia —`mensual`,
23
+ `trimestral`, `semestral` o `anual`— y la celda de tarea escrita como la cola de su línea de BACKLOG,
24
+ así que la aceptación se decide una vez y no se improvisa en cada vuelta.
25
+
26
+ **Nada se dispara.** No hay cron ni cola: el vencimiento se calcula cuando alguien corre el CLI, y rueda
27
+ desde el período que cerró la última vuelta en `DONE.md` en vez de una celda que haya que acordarse de
28
+ actualizar. `node tools/ops.js recurring planning` dice qué venció, y `--promote <qué>` emite la línea de
29
+ esa vuelta —la emite y no la escribe: pegarla en `BACKLOG.md` es el acto de promoción—. `check` rechaza
30
+ la fila ilegible y avisa la vencida sin frenar nada; `context` la nombra con `DUE`. Postergar se escribe
31
+ a mano con su razón, compra un período, y tres seguidas se avisan porque ahí lo que falla es la cadencia.
32
+
33
+ **Lo que te pide algo**: el archivo llega vacío con esta actualización y, hasta que declares una fila, el
34
+ motor no dice una palabra. Y si tu runner ya está andando, la regla nueva que necesita está en
35
+ `AGENTS.md`: una recurrencia vencida no la promueve él, por más que `context` la nombre sola.
36
+
17
37
  ## [0.68.0] - 2026-09-07
18
38
 
19
39
  ### Cambiado
package/README.md CHANGED
@@ -9,6 +9,8 @@ el contexto de cada empresa vive en su propia instancia.
9
9
  - Una tarea tiene una sola fuente de verdad durante todo su ciclo de vida.
10
10
  - Una sesión interrumpida se recupera desde `WIP.md`, sin reconstruir la intención.
11
11
  - Las ideas del agente no entran solas a la cola: quedan en `INBOX.md` hasta promoción humana.
12
+ - El trabajo que vuelve cada tanto se declara una vez en `RECURRING.md`; el CLI dice cuándo venció y
13
+ nadie lo encola solo.
12
14
  - Épicas, criterios, tareas y evidencia son validados de forma determinista.
13
15
  - Vive en su propia carpeta `ops/` dentro del repo, como sidecar `proyecto-ops` para varios repos, o
14
16
  embebido en la raíz.
@@ -175,6 +177,11 @@ idea → INBOX → roadmap → BACKLOG → WIP → DONE → done/epic-NNN.md
175
177
  5. Tras Build, Review, Verify y QA, mueve la entrada a `DONE.md` con evidencia.
176
178
  6. Al cerrar la épica, ejecuta `ops archive` para mover su evidencia a un histórico inmutable.
177
179
 
180
+ Lo que vuelve cada tanto —actualizar dependencias, revisar accesos, mirar el gasto del mes— entra por
181
+ un costado: se declara una vez en `RECURRING.md` con su cadencia, y `ops recurring planning` dice qué
182
+ venció y emite la línea de esa vuelta. Nada se dispara; pegarla en `BACKLOG.md` es el paso 3 de
183
+ arriba, hecho por una persona.
184
+
178
185
  Lee [template/planning/PROTOCOL.md](template/planning/PROTOCOL.md) para el contrato completo y
179
186
  [template/planning/FLOW.md](template/planning/FLOW.md) para operar el ciclo.
180
187
 
@@ -7,7 +7,7 @@
7
7
  // Banderas que consumen el argumento siguiente: su valor no es un posicional.
8
8
  const VALUED_FLAGS = new Set([
9
9
  '--name', '--mode', '--fixture', '--period', '--record', '--runner', '--integration',
10
- '--task',
10
+ '--task', '--promote',
11
11
  ])
12
12
 
13
13
  // Qué acepta cada comando, y a la vez qué comandos existen. Una bandera desconocida se rechaza en vez
@@ -20,6 +20,7 @@ const FLAGS = {
20
20
  check: ['--json'],
21
21
  tree: ['--json', '--no-color'],
22
22
  context: ['--json'],
23
+ recurring: ['--json', '--promote'],
23
24
  evidence: ['--json', '--task'],
24
25
  upgrade: ['--check', '--force'],
25
26
  destroy: ['--force'],
package/engine/cli/ops.js CHANGED
@@ -143,6 +143,7 @@ function usage() {
143
143
  ops check <planning-dir> [--json]
144
144
  ops tree <planning-dir> [--no-color] [--json]
145
145
  ops context <planning-dir> [--json]
146
+ ops recurring <planning-dir> [--promote <qué>] [--json]
146
147
  ops evidence <planning-dir> [--task <slug>] [--json]
147
148
  ops upgrade <ops-root> [--check] [--force]
148
149
  ops destroy <ops-root> [--force]
@@ -196,6 +197,7 @@ async function run(cli) {
196
197
  else if (command === 'check') PL.check(arg[1], cli)
197
198
  else if (command === 'tree') PL.tree(arg[1], cli)
198
199
  else if (command === 'context') PL.context(arg[1], cli)
200
+ else if (command === 'recurring') PL.recurring(arg[1], cli)
199
201
  else if (command === 'evidence') PL.evidence(arg[1], cli)
200
202
  else if (command === 'upgrade') IN.upgrade(arg[1], cli)
201
203
  else if (command === 'destroy') IN.destroy(arg[1], cli)
@@ -9,6 +9,7 @@ const P = require('../planning/parser')
9
9
  const B = require('../planning/business-rules')
10
10
  const PC = require('../planning/contracts')
11
11
  const SZ = require('../planning/sizing')
12
+ const RC = require('../planning/recurring')
12
13
  const ST = require('../planning/state')
13
14
  const AD = require('../planning/adoption')
14
15
  const AP = require('../hooks/approval')
@@ -66,6 +67,10 @@ function evidence(dir, cli) {
66
67
  + 'que la prueba nombrada haya corrido: eso depende del runner, y varios no la nombran al pasar.')
67
68
  }
68
69
 
70
+ // La fecha de hoy, en un solo lugar: los comandos que la usan tienen que estar mirando el mismo día, y
71
+ // el módulo que calcula vencimientos la recibe en vez de preguntarla.
72
+ const TODAY = () => new Date().toISOString().slice(0, 10)
73
+
69
74
  function check(dir, cli) {
70
75
  const root = path.resolve(dir || '.')
71
76
  const errors = []
@@ -125,6 +130,12 @@ function check(dir, cli) {
125
130
  epics, milestones, done, wip, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
126
131
  }))
127
132
  warnings.push(...AD.report({ done, epics, adopted }))
133
+ // Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
134
+ // no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
135
+ // en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
136
+ const recurring = RC.read(root)
137
+ errors.push(...RC.validate(recurring))
138
+ warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
128
139
  warnings.push(...AD.sealWarnings(root))
129
140
  // Una aprobación vale para el conjunto que nombra, así que olvidada sigue autorizando
130
141
  // esas mismas rutas la próxima vez que alguien las stagee. No caduca sola: lo que la cierra es que se
@@ -284,6 +295,11 @@ function context(dir, cli) {
284
295
  queued: state.milestones.reduce((total, milestone) => total + milestone.tasks.length, 0),
285
296
  blockedTasks: skipped,
286
297
  humanActions,
298
+ // Sólo las vencidas: la fila que todavía no vence no tiene nada que decirle a quien va a tomar una
299
+ // tarea, y una recurrencia que hablara siempre sería ruido en el único comando que se corre en cada
300
+ // vuelta. Que aparezca es la señal.
301
+ recurring: RC.status({ ...RC.read(root), done: state.done, today: TODAY() })
302
+ .filter((one) => one.overdue),
287
303
  }
288
304
  if (cli.has('--json')) return console.log(JSON.stringify(report))
289
305
 
@@ -299,9 +315,16 @@ function context(dir, cli) {
299
315
  // que una instancia recién arrancada —`onboard` deja filas pendientes y ninguna tarea todavía—
300
316
  // respondía «sin tarea disponible» y se tragaba las siete cosas que una persona tenía que desbloquear.
301
317
  // Es el comando que existe para decir qué toca ahora, contestando «nada» cuando lo que toca es eso.
318
+ const due = () => {
319
+ for (const one of report.recurring) {
320
+ const when = one.overdueDays === 0 ? 'vence hoy' : `vencida hace ${one.overdueDays} día(s)`
321
+ console.log(`DUE ${one.id}: ${when}`)
322
+ }
323
+ }
302
324
  if (!report.task) {
303
325
  console.log('TASK (sin tarea disponible)')
304
326
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
327
+ due()
305
328
  return
306
329
  }
307
330
  console.log(`TASK ${report.task.slug}${report.task.tier ? ` [${report.task.tier}]` : ''}` +
@@ -324,6 +347,36 @@ function context(dir, cli) {
324
347
  console.log(`WIP ${wip}`)
325
348
  if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
326
349
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
350
+ due()
351
+ }
352
+
353
+ // Qué trabajo recurrente vence, y la línea con la que se promueve. Emite esa línea y no la escribe:
354
+ // `BACKLOG.md` es la cola de lo aprobado y la escribe una persona — ningún comando del motor la toca,
355
+ // ni siquiera `integration promote`, que aterriza en el roadmap. Pegarla es el acto de promoción.
356
+ function recurring(dir, cli) {
357
+ const root = path.resolve(dir || '.')
358
+ const file = RC.read(root)
359
+ if (!file.exists) return console.log(`= este planning no declara trabajo recurrente (${RC.FILE})`)
360
+ const state = RC.status({ ...file, done: P.readDone(root), today: TODAY() })
361
+ const promote = cli.value('--promote')
362
+ if (promote) {
363
+ const one = state.find((candidate) => candidate.id === promote)
364
+ if (!one) return fail(`${RC.FILE} no declara ${promote}`, 2)
365
+ // La línea sale sola por stdout para que se pueda pegar o redirigir sin recortar nada; el destino,
366
+ // que es lo único que falta decidir, va por stderr.
367
+ console.error(`Pegala en el hito que corresponda de BACKLOG.md:`)
368
+ return console.log(RC.taskLine(one, TODAY().slice(0, 7)))
369
+ }
370
+ if (cli.has('--json')) return console.log(JSON.stringify(state))
371
+ if (!state.length) return console.log(`= ${RC.FILE} no declara ninguna fila legible`)
372
+ for (const one of state) {
373
+ const when = one.overdue
374
+ ? (one.overdueDays === 0 ? 'vence hoy' : `vencida hace ${one.overdueDays} día(s)`)
375
+ : `vence ${one.due}`
376
+ const last = one.last ? `última ${one.last}` : 'nunca corrió'
377
+ const held = one.postponed ? `, postergada ${one.postponed}` : ''
378
+ console.log(`${one.overdue ? 'DUE' : 'OK '} ${one.id.padEnd(16)} ${when} (${last}${held})`)
379
+ }
327
380
  }
328
381
 
329
382
  // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
@@ -355,7 +408,7 @@ function adopt(dir) {
355
408
  if (!pending.length) {
356
409
  return console.log('= no hay nada que exentar: todas las entradas de DONE cumplen el contrato')
357
410
  }
358
- const today = new Date().toISOString().slice(0, 10)
411
+ const today = TODAY()
359
412
  const slugs = pending.map((entry) => entry.slug)
360
413
  F.atomicWrite(target, `# Entradas anteriores a la adopción de Cauce (${today}). No se agregan nuevas:\n`
361
414
  + '# desde esa fecha rige el contrato completo, y `check` avisa cuando una de éstas pasa a\n'
@@ -413,4 +466,4 @@ function archive(dir, rawNum) {
413
466
  console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
414
467
  }
415
468
 
416
- module.exports = { check, evidence, tree, context, archive, adopt }
469
+ module.exports = { check, evidence, tree, context, archive, adopt, recurring }
@@ -205,6 +205,9 @@ const TEMPLATE_OWN = {
205
205
  'planning/DONE.md': 'init',
206
206
  'planning/HUMAN_ACTIONS.md': 'init',
207
207
  'planning/INBOX.md': 'init',
208
+ // 0.69.0. El contrato nace con esta versión, así que ninguna instancia anterior lo tiene: por `init`
209
+ // no llegaría nunca a la que ya existe, que es justo la que iba a usarlo.
210
+ 'planning/RECURRING.md': 'upgrade',
208
211
  'planning/WIP.md': 'init',
209
212
  'planning/delivery/project.md': 'init',
210
213
  'planning/done/.gitkeep': 'init',
@@ -193,6 +193,26 @@ function acceptanceConditions(value) {
193
193
  return text.split(';').map((one) => one.trim()).filter(Boolean).length
194
194
  }
195
195
 
196
+ // Una línea de tarea, leída en un solo lugar: `readBacklog` la usa para armar la cola y `recurring.js`
197
+ // para juzgar la línea que va a emitir. Es el mismo motivo por el que `TASK_LINE` no está duplicado —
198
+ // lo que emite una y lee la otra tiene que ser la misma forma, o el emisor produce lo que el lector
199
+ // descarta.
200
+ function taskFromLine(line) {
201
+ const task = line.match(TASK_LINE)
202
+ if (!task) return null
203
+ const rest = task[3]
204
+ const acceptance = ((rest.match(ACCEPTANCE) || [])[1] || '').trim()
205
+ return {
206
+ slug: task[1].trim(), tier: task[2] || '', cast: readCast(rest),
207
+ epic: ((rest.match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
208
+ service: ((rest.match(/\(service:\s*([^)]+)\)/) || [])[1] || '').trim(),
209
+ acceptance,
210
+ conditions: acceptanceConditions(acceptance),
211
+ criteria: criteriaRefs(rest),
212
+ noSplit: noSplitReason(rest),
213
+ }
214
+ }
215
+
196
216
  function readBacklog(dir) {
197
217
  const text = withoutComments(read(path.join(dir, 'BACKLOG.md')))
198
218
  const milestones = []
@@ -208,19 +228,8 @@ function readBacklog(dir) {
208
228
  continue
209
229
  }
210
230
  if (/^##\s+/.test(line)) current = null
211
- const task = line.match(TASK_LINE)
212
- if (!task || !current) continue
213
- const rest = task[3]
214
- const acceptance = ((rest.match(ACCEPTANCE) || [])[1] || '').trim()
215
- current.tasks.push({
216
- slug: task[1].trim(), tier: task[2] || '', cast: readCast(rest),
217
- epic: ((rest.match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
218
- service: ((rest.match(/\(service:\s*([^)]+)\)/) || [])[1] || '').trim(),
219
- acceptance,
220
- conditions: acceptanceConditions(acceptance),
221
- criteria: criteriaRefs(rest),
222
- noSplit: noSplitReason(rest),
223
- })
231
+ const task = current ? taskFromLine(line) : null
232
+ if (task) current.tasks.push(task)
224
233
  }
225
234
  return milestones
226
235
  }
@@ -302,16 +311,24 @@ const SEPARADORES = /^\|\s*:?-+/
302
311
  // afectada —`archive` reescribe `raw`, la línea original, no las celdas—, así que quitarlo no pierde nada.
303
312
  const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
304
313
 
305
- function readHumanActions(dir) {
306
- const lineas = withoutComments(read(path.join(dir, 'HUMAN_ACTIONS.md'))).split('\n')
307
- // En markdown la cabecera es la fila anterior a la de separadores, diga lo que diga su primera celda.
308
- // Se marcan todas y no la primera: un archivo con una tabla por sección tiene una cabecera por tabla,
309
- // y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
310
- // fila de datos nunca está inmediatamente antes de los guiones.
314
+ // Las filas de datos de las tablas de un texto, con las celdas ya normalizadas. Vive acá y no en cada
315
+ // lector porque el escape del pipe tiene que leerse igual en los dos archivos que traen tabla: escrito
316
+ // dos veces, una de las dos copias se pudre sin que nada falle.
317
+ //
318
+ // En markdown la cabecera es la fila anterior a la de separadores, diga lo que diga su primera celda.
319
+ // Se marcan todas y no la primera: un archivo con una tabla por sección tiene una cabecera por tabla,
320
+ // y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
321
+ // fila de datos nunca está inmediatamente antes de los guiones.
322
+ function tableRows(text) {
323
+ const lineas = text.split('\n')
311
324
  const cabeceras = new Set(lineas.map((line, i) => (SEPARADORES.test(line) ? i - 1 : -1)))
312
- const rows = lineas
325
+ return lineas
313
326
  .filter((line, i) => /^\|/.test(line) && !SEPARADORES.test(line) && !cabeceras.has(i))
314
327
  .map((line) => ({ line, cells: line.split(SEPARADOR).slice(1, -1).map(celda) }))
328
+ }
329
+
330
+ function readHumanActions(dir) {
331
+ const rows = tableRows(withoutComments(read(path.join(dir, 'HUMAN_ACTIONS.md'))))
315
332
  // El literal queda como resguardo de la tabla escrita sin su fila de separadores: markdown no la
316
333
  // renderiza como tabla, y este parser lee sus filas igual.
317
334
  return rows.filter(({ cells }) => cells.length >= 4 && !/^tarea$/i.test(cells[0]))
@@ -362,6 +379,6 @@ module.exports = {
362
379
  EPIC_STATES, HUMAN_ACTION_STATES, LANES, MILESTONE_HEADING, STOP_REASONS,
363
380
  TASK_LINE, TASK_LINE_ANY_LANE,
364
381
  read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip,
365
- acceptanceConditions,
382
+ acceptanceConditions, tableRows, taskFromLine,
366
383
  readInbox, readHumanActions,
367
384
  }
@@ -0,0 +1,148 @@
1
+ 'use strict'
2
+
3
+ // El contrato de `RECURRING.md`: qué se declaró que vuelve, cuándo vence y qué línea se promueve.
4
+ // No ejecuta ni encola —eso lo decide una persona editando `BACKLOG.md`—; acá se lee el archivo y se
5
+ // calcula una fecha, que es todo lo que la máquina puede aportar sin decidir por nadie.
6
+ //
7
+ // La fecha de hoy entra por parámetro. Preguntarla adentro dejaría cada prueba de vencimiento válida
8
+ // sólo el día que se escribió, y el vencimiento es justamente lo único que este módulo calcula.
9
+
10
+ const path = require('node:path')
11
+ const P = require('./parser')
12
+
13
+ const FILE = 'RECURRING.md'
14
+
15
+ // Vocabulario cerrado, en meses, por la misma razón que el estado de `HUMAN_ACTIONS.md`. `semanal` no
16
+ // está y no es un olvido: el período viaja en el slug de cada vuelta con grano de mes, así que dos
17
+ // vueltas de la misma semana no se distinguirían.
18
+ const CADENCES = { mensual: 1, trimestral: 3, semestral: 6, anual: 12 }
19
+
20
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
21
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
22
+ // `- **qué** AAAA-MM-DD — razón`. El nombre en negrita adelante es la misma convención del INBOX, y por
23
+ // el mismo motivo: es con lo que se cita la fila desde otro lado.
24
+ const POSTPONEMENT = /^-\s+\*\*([^*]+)\*\*\s+(\S+)\s+[—-]\s+(.+)$/
25
+
26
+ // Suma meses sobre `AAAA-MM-DD` en UTC. El día se recorta al último del mes destino: un ancla escrita
27
+ // el 31 no puede caer en un 31 de febrero, y correrla al 3 de marzo movería la vuelta de mes.
28
+ function addMonths(iso, months) {
29
+ const [year, month, day] = iso.split('-').map(Number)
30
+ const target = new Date(Date.UTC(year, month - 1 + months, 1))
31
+ const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
32
+ target.setUTCDate(Math.min(day, last))
33
+ return target.toISOString().slice(0, 10)
34
+ }
35
+
36
+ function daysBetween(from, to) {
37
+ return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000)
38
+ }
39
+
40
+ // Las dos listas del archivo, cada una acotada a su sección. Buscarlas en el texto entero haría que una
41
+ // viñeta de la prosa —el archivo explica sus propias columnas con viñetas— entrara como postergación.
42
+ function read(dir) {
43
+ const text = P.withoutComments(P.read(path.join(dir, FILE)))
44
+ if (!text.trim()) return { exists: false, rows: [], postponements: [] }
45
+ const rows = P.tableRows(P.section(text, /Recurrencias/))
46
+ // El literal, por el mismo motivo que en `readHumanActions`, donde está escrito.
47
+ .filter(({ cells }) => cells.length >= 4 && !/^qué$/i.test(cells[0]))
48
+ .map(({ line, cells }) => ({
49
+ id: cells[0], cadence: cells[1], since: cells[2], task: cells[3], raw: line,
50
+ }))
51
+ const postponements = P.section(text, /Postergaciones/).split('\n')
52
+ .filter((line) => /^-\s/.test(line))
53
+ .map((line) => {
54
+ const match = line.match(POSTPONEMENT)
55
+ return match
56
+ ? { id: match[1].trim(), date: match[2], reason: match[3].trim(), raw: line }
57
+ : { id: '', date: '', reason: '', raw: line }
58
+ })
59
+ return { exists: true, rows, postponements }
60
+ }
61
+
62
+ // La línea que se pega en BACKLOG. La celda de la tabla es literalmente la cola de esa línea, así que
63
+ // lo que una persona escribió una vez es lo que se promueve, sin retipear.
64
+ function taskLine(row, period) {
65
+ return `- [ ] **${row.id}-${period}** — ${row.task}`
66
+ }
67
+
68
+ function validate({ exists, rows, postponements }) {
69
+ if (!exists) return []
70
+ const errors = []
71
+ const seen = new Set()
72
+ for (const row of rows) {
73
+ const at = `${FILE} ${row.id || '(fila sin nombre)'}`
74
+ if (!ID.test(row.id)) errors.push(`${at}: el identificador va en minúsculas, sin espacios`)
75
+ else if (seen.has(row.id)) errors.push(`${FILE}: identificador duplicado ${row.id}`)
76
+ seen.add(row.id)
77
+ if (!CADENCES[row.cadence]) {
78
+ errors.push(`${at}: cadencia "${row.cadence}" fuera de ${Object.keys(CADENCES).join(' | ')}`)
79
+ }
80
+ if (!DATE.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
81
+ // 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
+ // leer es el mismo lector de BACKLOG. Una celda que pasa acá y una línea que BACKLOG rechaza es el
83
+ // error que aparece un mes después, con la tarea ya pegada.
84
+ const task = P.taskFromLine(taskLine(row, '0000-00'))
85
+ if (!task) errors.push(`${at}: la celda no arma una línea de tarea`)
86
+ else {
87
+ if (!task.acceptance) errors.push(`${at}: la tarea no declara _Aceptación: ..._`)
88
+ if (!task.service) errors.push(`${at}: la tarea no declara (service: <ruta>)`)
89
+ }
90
+ }
91
+ for (const one of postponements) {
92
+ if (!one.id) {
93
+ errors.push(`${FILE}: una postergación se escribe "- **qué** AAAA-MM-DD — razón": ${one.raw.trim()}`)
94
+ continue
95
+ }
96
+ if (!DATE.test(one.date)) errors.push(`${FILE}: postergación de ${one.id}: la fecha va en AAAA-MM-DD`)
97
+ if (!seen.has(one.id)) errors.push(`${FILE}: postergación de ${one.id}, que la tabla no declara`)
98
+ }
99
+ return errors
100
+ }
101
+
102
+ // El último período cerrado, leído de DONE y no de una celda: una fecha que alguien tiene que acordarse
103
+ // de actualizar miente a los tres meses, y la entrada de DONE es además la evidencia de que se hizo.
104
+ function lastPeriod(id, done) {
105
+ const pattern = new RegExp(`^${id}-(\\d{4}-\\d{2})$`)
106
+ const periods = done.entries.map((entry) => (entry.slug.match(pattern) || [])[1]).filter(Boolean)
107
+ return periods.sort().pop() || ''
108
+ }
109
+
110
+ function status({ rows, postponements, done, today }) {
111
+ return rows
112
+ .filter((row) => ID.test(row.id) && CADENCES[row.cadence] && DATE.test(row.since))
113
+ .map((row) => {
114
+ const months = CADENCES[row.cadence]
115
+ const last = lastPeriod(row.id, done)
116
+ // Cerrada una vuelta, la siguiente se cuenta desde el período que la cerró; sin ninguna, desde el
117
+ // ancla escrita al declarar la fila. Es lo que hace rodante al vencimiento: saltearse una vuelta
118
+ // corre la próxima en vez de deber dos.
119
+ const base = last ? addMonths(`${last}-01`, months) : row.since
120
+ // Y las postergaciones que cuentan son las escritas después de que terminó el período cerrado:
121
+ // las anteriores ya las absorbió ese cierre, que es lo que reinicia el contador.
122
+ const from = last ? addMonths(`${last}-01`, 1) : row.since
123
+ const postponed = postponements.filter((one) => one.id === row.id && one.date >= from).length
124
+ const due = addMonths(base, months * postponed)
125
+ const overdueDays = daysBetween(due, today)
126
+ return {
127
+ id: row.id, cadence: row.cadence, task: row.task, last, due, postponed,
128
+ overdueDays, overdue: overdueDays >= 0,
129
+ }
130
+ })
131
+ }
132
+
133
+ function warnings(state) {
134
+ const lines = []
135
+ for (const one of state.filter((candidate) => candidate.overdue)) {
136
+ const when = one.overdueDays === 0 ? 'vence hoy' : `vencida hace ${one.overdueDays} día(s)`
137
+ lines.push(`${FILE}: ${one.id} ${when} (${one.due})`)
138
+ }
139
+ // Tres seguidas no son un atraso: son una fila que no se va a hacer con la cadencia que declara. El
140
+ // contador es la alarma, y por eso se cuenta desde el último cierre y no desde siempre.
141
+ for (const one of state.filter((candidate) => candidate.postponed >= 3)) {
142
+ lines.push(`${FILE}: ${one.id} postergada ${one.postponed} veces desde el último cierre; `
143
+ + 'revisá la cadencia o la fila')
144
+ }
145
+ return lines
146
+ }
147
+
148
+ module.exports = { FILE, read, validate, status, warnings, taskLine }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -153,6 +153,8 @@ muta nada.
153
153
  - `node tools/ops.js context planning` — gate, mutex de WIP y la tarea que corresponde ahora, con su
154
154
  aceptación y sus criterios. Es la entrada correcta para empezar a trabajar.
155
155
  - `node tools/ops.js tree planning` — panorama de roadmap, backlog, WIP, inbox y done.
156
+ - `node tools/ops.js recurring planning [--promote <qué>]` — qué trabajo recurrente venció y con
157
+ qué línea se promueve. Emite esa línea; escribirla en `BACKLOG.md` es de una persona.
156
158
  - `node tools/ops.js check planning` — validación de contratos y trazabilidad.
157
159
  - `node tools/ops.js evidence planning [--task <slug>]` — contrasta la evidencia de una entrada de DONE
158
160
  contra lo que no escribió su autor: si el artefacto que `tests:` nombra existe en las raíces de
@@ -161,8 +163,8 @@ muta nada.
161
163
  nombrada haya corrido —eso depende del runner, y varios no la nombran al pasar— ni reemplaza a leer
162
164
  su fuente, que es lo que R9 pide.
163
165
 
164
- Los cuatro aceptan `--json`. Leer `BACKLOG.md`, `WIP.md` o `HUMAN_ACTIONS.md` completos sólo cuando haga
165
- falta editarlos o cuando el CLI no responda la pregunta.
166
+ Los cinco aceptan `--json`. Leer `BACKLOG.md`, `WIP.md`, `HUMAN_ACTIONS.md` o `RECURRING.md` completos
167
+ sólo cuando haga falta editarlos o cuando el CLI no responda la pregunta.
166
168
 
167
169
  ## Autonomía
168
170
 
@@ -178,6 +180,11 @@ Nunca amplía el alcance, promueve sus propias ideas, reescribe el proceso duran
178
180
  `git add .`/`git add -A`, reescribe historia con `--force` o `--amend`, ni afirma éxito sin evidencia
179
181
  real. Tampoco publica: sin autorización no hay `push`.
180
182
 
183
+ Una recurrencia vencida tampoco la promueve, y ésta es la que más se parece a una excepción: la
184
+ aceptación ya está escrita, la fecha la calculó el CLI y `context` la nombra sola. Nada de eso es la
185
+ aprobación que pide BR-OPS-002 — `context` la nombra para que la vea una persona, y quien la pega en
186
+ `BACKLOG.md` es esa persona.
187
+
181
188
  Publicar es lo único de todo eso que este proyecto puede habilitar, y `runner.allowPush` en
182
189
  `ops.config.json` es la autorización que R10 pide. Reescribir historia publicada no entra en el trato:
183
190
  un `push --force` se frena con la llave prendida o apagada.
package/template/Makefile CHANGED
@@ -1,6 +1,6 @@
1
1
  .DEFAULT_GOAL := help
2
2
 
3
- .PHONY: help check tree context upgrade destroy automation-check
3
+ .PHONY: help check tree context recurring upgrade destroy automation-check
4
4
  .PHONY: integration-check integration-sync require-key integration-promote
5
5
  .PHONY: require-agent agent-learn agent-propose agent-evaluate require-flow flow-list flow-check flow-show
6
6
  .PHONY: install-claude install-codex install-gemini install-antigravity
@@ -23,6 +23,9 @@ tree: ## Muestra roadmap, backlog, WIP y Done
23
23
  context: ## Muestra el contexto mínimo de la tarea vigente
24
24
  @node tools/ops.js context planning
25
25
 
26
+ recurring: ## Muestra qué trabajo recurrente vence y con qué línea se promueve
27
+ @node tools/ops.js recurring planning
28
+
26
29
  # `init` fija la versión exacta del motor, así que `npm update` no la mueve: hay que pedir @latest.
27
30
  # Sin este primer paso `upgrade` compara contra el motor instalado y contesta «al día» para siempre.
28
31
  destroy: ## Muestra qué se pierde al borrar esta instancia (borrar exige --force a mano)
@@ -15,6 +15,8 @@ INBOX ──promoción humana──▶ roadmap ──historias listas──▶ B
15
15
 
16
16
  ## Preparar
17
17
 
18
+ 0. Si `ops recurring planning` nombra una recurrencia vencida, decidir si esta vuelta se promueve —el
19
+ comando emite su línea— o se posterga con su razón.
18
20
  1. Curar una idea desde INBOX.
19
21
  2. Escribir una épica con resultados observables, contexto actual y criterios `C1..CN`.
20
22
  3. Descomponerla en historias de máximo cuatro horas, cada una rastreada a uno o más criterios.
@@ -20,6 +20,11 @@ invariantes.
20
20
  en el vocabulario cerrado `pendiente | resuelta` —la fecha puede ir detrás—. Mientras la fila no
21
21
  esté resuelta, su tarea no se toma; un estado fuera del vocabulario es un error de `check` y no un
22
22
  bloqueo silencioso.
23
+ - Recurrencia: fila `| qué | cada | desde | tarea y aceptación |` bajo `## Recurrencias`, con `cada` en
24
+ el vocabulario cerrado `mensual | trimestral | semestral | anual` y la celda de tarea escrita como la
25
+ cola de su línea de BACKLOG. Vencer no bloquea: cada vuelta se promueve con el período en el slug
26
+ —`<qué>-AAAA-MM`— y esa promoción la escribe una persona. Postergar se registra bajo
27
+ `## Postergaciones` con `- **qué** AAAA-MM-DD — razón`.
23
28
  - WIP activo: frontmatter y checklist; inactivo: `status: IDLE`.
24
29
 
25
30
  ## Gates de arranque
@@ -19,6 +19,7 @@ Se decide antes de ejecutar y no cambia dentro de una tarea.
19
19
  | Pieza | Responsabilidad |
20
20
  |---|---|
21
21
  | `INBOX.md` | Ideas y deuda sin autorización de ejecución. |
22
+ | `RECURRING.md` | Trabajo que vuelve cada tanto; declarado, nunca encolado solo. |
22
23
  | `roadmap/` | Especificaciones de épicas y criterios del QUÉ. |
23
24
  | `adr/` | Decisiones arquitectónicas durables. |
24
25
  | `business-rules/` | Invariantes observables de negocio y operación. |
@@ -0,0 +1,77 @@
1
+ # Trabajo que vuelve
2
+
3
+ Lo que hay que hacer cada tanto y no una vez: actualizar dependencias, revisar quién tiene acceso a
4
+ producción, mirar el gasto del mes, recorrer el INBOX entero. No es una categoría de trabajo —puede ser
5
+ cualquier cosa— sino una forma de declararlo: acá vive el enunciado, y cada vuelta se promueve a
6
+ `BACKLOG.md` como una tarea más.
7
+
8
+ **Nada se dispara.** Este archivo no ejecuta ni encola: declara cada cuánto algo debería mirarse, y el
9
+ vencimiento se calcula cuando alguien corre el CLI. Promover sigue siendo un acto humano, igual que en
10
+ `INBOX.md`. Lo que la máquina aporta es que no se te pase, no decidir por vos.
11
+
12
+ ## Cada fila
13
+
14
+ - **Qué** — un identificador estable, en minúsculas y sin espacios. No cambia nunca: es lo que ata la
15
+ fila a las vueltas que ya se hicieron.
16
+ - **Cada** — vocabulario cerrado: `mensual`, `trimestral`, `semestral`, `anual`. No hay expresiones de
17
+ cron, y esa ausencia es el enunciado: si hiciera falta una, lo que estarías declarando es otra cosa.
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.
20
+ - **Tarea y aceptación** — lo que se va a promover, con su aceptación observable escrita una sola vez y
21
+ con calma. Improvisada en cada vuelta, la misma recurrencia termina significando cosas distintas sin
22
+ que nadie lo decida.
23
+
24
+ ## Cuándo vence
25
+
26
+ Se cuenta desde la última vez que se cerró, no desde un día fijo del calendario: una recurrencia
27
+ atrasada no debe tres vueltas, debe una, la que no se hizo.
28
+
29
+ La fecha de ese último cierre **no se escribe acá**. Sale de `DONE.md` — la entrada más nueva cuyo slug
30
+ sea `<qué>-AAAA-MM`—, que es la evidencia de que efectivamente se hizo y no la afirmación de que se
31
+ hizo. Una celda que alguien tiene que acordarse de actualizar miente a los tres meses, y el estado no se
32
+ copia para representar progreso.
33
+
34
+ Por eso cada vuelta se promueve con su período en el slug —`deps-2026-10`, después `deps-2026-11`— y no
35
+ con el identificador pelado. Reusarlo tiene además su propio castigo: dos entradas de DONE con el mismo
36
+ slug son un error de `check`, y ese error llega un mes tarde.
37
+
38
+ ## Lo que este archivo no es
39
+
40
+ - **No bloquea.** Una recurrencia vencida no frena ninguna tarea. Lo que sí frena vive en
41
+ `HUMAN_ACTIONS.md`; ponerlo acá entrena a ignorar lo que vence, que es lo único que este archivo hace.
42
+ - **No es una cola.** Nada de acá está aprobado para ejecutarse. `BACKLOG.md` sigue siendo la única cola
43
+ y se escribe a mano.
44
+ - **No es el INBOX.** Una idea se promueve una vez y se borra; esto vuelve, y por eso se queda.
45
+
46
+ ## Recurrencias
47
+
48
+ | Qué | Cada | Desde | Tarea y aceptación observable |
49
+ |---|---|---|---|
50
+
51
+ <!--
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._ |
56
+ -->
57
+
58
+ ## Postergaciones
59
+
60
+ Saltear una vuelta es legítimo y se escribe. Lo que no puede perderse es la razón: sin ella queda una
61
+ recurrencia atrasada y nadie sabe si fue una decisión o un olvido.
62
+
63
+ Postergar compra **un período**, no una fecha elegida: la vuelta siguiente vuelve a preguntar. Las
64
+ postergaciones se cuentan desde el último cierre y el contador se reinicia al cerrar. Tres seguidas no
65
+ son un atraso — son una recurrencia mal declarada, y lo que hay que revisar es la cadencia o la fila
66
+ entera.
67
+
68
+ La salida definitiva es borrar la fila. «Esto ya no lo hacemos» es un diff que alguien revisa; un estado
69
+ `retirada` es una línea que nadie vuelve a leer.
70
+
71
+ Cada una se escribe con el nombre de su fila en negrita, la fecha y la razón —`- **qué** AAAA-MM-DD —
72
+ razón`—, igual que un ítem del INBOX y por el mismo motivo: el nombre es con lo que se cita la fila.
73
+ Sólo se agrega al final; una línea escrita acá no se edita ni se borra.
74
+
75
+ <!--
76
+ - **deps** 2026-10-02 — Esperando el release de la 2.0; subir dependencias antes lo ensucia.
77
+ -->