@ingeniomaps/cauce 0.67.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,56 @@ 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
+
37
+ ## [0.68.0] - 2026-09-07
38
+
39
+ ### Cambiado
40
+
41
+ - **`upgrade` deja de borrar una ruta retirada cuyo contenido no puede probar suyo.** Retirar una ruta
42
+ nunca volvió al toolkit dueño de lo que hay adentro, y hasta acá se borraba el directorio entero: quien
43
+ tenía sus propios workflows en `automatization/workflows` los perdía sin confirmación y sin vuelta
44
+ atrás. De las seis rutas retiradas, cuatro viven bajo `system/` o son un archivo con nombre del
45
+ toolkit y se siguen retirando igual; las dos que un proyecto también usa para lo suyo
46
+ —`automatization/runners` y `automatization/workflows`— se conservan.
47
+
48
+ **Lo que te pide algo**: esas dos rutas quedan en disco con lo que tengas adentro, la corrida te dice
49
+ cuántos archivos son, y `check` las cuenta en cada corrida hasta que las muevas o las borres. Si lo
50
+ que tenés ahí son restos viejos del toolkit y querés limpiarlos, `--force` las retira.
51
+
52
+ - **La regla R9 dice cómo se prueba una quita.** Una prueba que comprueba que aparece lo nuevo no
53
+ comprueba que desapareció lo viejo, y lo que se quita tiene dependientes que no se anuncian —el
54
+ mensaje que afirmaba la invariante, la condición que la deducía—. Sale de una regresión de 0.67.0 que
55
+ entró exactamente por ahí.
56
+
57
+ ### Corregido
58
+
59
+ - **El informe de `upgrade` afirmaba descartes que no ocurrieron.** 0.67.0 pasó a conservar los archivos
60
+ editados y la salida siguió enumerándolos como `− descartado tu cambio en …`, uno por uno, después de
61
+ la línea que anuncia que terminó bien. En una instancia real fueron diecinueve renglones falsos, y
62
+ entre ellos iba la única línea destructiva verdadera de esa corrida, indistinguible. Ahora el informe
63
+ recibe qué pasó —descartado, conservado, pendiente— en vez de deducirlo de una condición que dejó de
64
+ valer, y la línea «planning, organization y todo lo propio quedaron intactos» sale justo cuando es
65
+ cierto, que es cuando se conservó algo.
66
+
17
67
  ## [0.67.0] - 2026-09-07
18
68
 
19
69
  ### Agregado
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'],
@@ -370,10 +370,19 @@ function upgrade(dir, cli) {
370
370
 
371
371
  // Retirar lo que el toolkit ya no distribuye, después de haber actualizado lo que sí.
372
372
  const retired = []
373
+ const pendientes = []
373
374
  for (const relative of O.RETIRED) {
374
375
  const target = path.join(root, relative)
375
376
  if (!fs.existsSync(target)) continue
376
377
  F.assertNoSymlinkPath(root, target)
378
+ // Retirar una ruta no vuelve al toolkit dueño de lo que hay adentro. En las dos que un proyecto
379
+ // también usa para lo suyo, borrar el directorio entero se llevaba puesto contenido que nadie
380
+ // había entregado —un `autobuild.js` propio, los workflows de una empresa— sin confirmación y sin
381
+ // vuelta atrás. Se conserva y se nombra; `--force` es la salida, igual que para una edición local.
382
+ const contenido = O.RETIRED_COMPARTIDO.includes(relative) && fs.statSync(target).isDirectory()
383
+ ? O.treeFiles(target)
384
+ : []
385
+ if (contenido.length && !force) { pendientes.push({ relative, files: contenido }); continue }
377
386
  fs.rmSync(target, { recursive: true, force: true })
378
387
  retired.push(relative)
379
388
  }
@@ -404,7 +413,13 @@ function upgrade(dir, cli) {
404
413
  // Acá es donde las tres versiones se saben a la vez, así que es donde pueden quedar diciendo lo mismo.
405
414
  const pinned = pinEngine(root, to)
406
415
 
407
- reportUpgrade({ root, from, to, system, changed, retired, added, overrides, pinned, droppedBlocks })
416
+ // El informe recibe qué pasó y no una condición de la que deducirlo: `changed` es «lo que estaba
417
+ // editado», y sin `--force` eso se conserva. Deducirlo hizo que 0.67.0 afirmara diecinueve descartes
418
+ // que no ocurrieron (caso 048).
419
+ reportUpgrade({
420
+ root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
421
+ descartados: force ? changed : [], conservados: [...conservados], pendientes,
422
+ })
408
423
  }
409
424
 
410
425
  module.exports = { copyTemplate, scaffold, providerNames, upgrade, destroy, PROJECT_ROOT }
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
@@ -144,6 +155,15 @@ function check(dir, cli) {
144
155
  + '`upgrade` los conserva y no les trae mejoras')
145
156
  }
146
157
 
158
+ // Y lo que `upgrade` no retiró porque no pudo demostrar que fuera suyo: queda ahí, sin colgar de
159
+ // ningún mecanismo, hasta que alguien lo mueva o lo borre. Se cuenta por lo mismo que los congelados
160
+ // — un resto que no se ve se vuelve permanente.
161
+ const restos = O.RETIRED_COMPARTIDO.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
162
+ if (restos.length) {
163
+ warnings.push(`${restos.length} ruta(s) retiradas siguen en disco con contenido tuyo `
164
+ + `(${restos.join(', ')}); Cauce ya no las distribuye ni las toca`)
165
+ }
166
+
147
167
  const integration = I.validate(path.resolve(root, '..'))
148
168
  errors.push(...integration.errors)
149
169
  warnings.push(...integration.warnings)
@@ -275,6 +295,11 @@ function context(dir, cli) {
275
295
  queued: state.milestones.reduce((total, milestone) => total + milestone.tasks.length, 0),
276
296
  blockedTasks: skipped,
277
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),
278
303
  }
279
304
  if (cli.has('--json')) return console.log(JSON.stringify(report))
280
305
 
@@ -290,9 +315,16 @@ function context(dir, cli) {
290
315
  // que una instancia recién arrancada —`onboard` deja filas pendientes y ninguna tarea todavía—
291
316
  // respondía «sin tarea disponible» y se tragaba las siete cosas que una persona tenía que desbloquear.
292
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
+ }
293
324
  if (!report.task) {
294
325
  console.log('TASK (sin tarea disponible)')
295
326
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
327
+ due()
296
328
  return
297
329
  }
298
330
  console.log(`TASK ${report.task.slug}${report.task.tier ? ` [${report.task.tier}]` : ''}` +
@@ -315,6 +347,36 @@ function context(dir, cli) {
315
347
  console.log(`WIP ${wip}`)
316
348
  if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
317
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
+ }
318
380
  }
319
381
 
320
382
  // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
@@ -346,7 +408,7 @@ function adopt(dir) {
346
408
  if (!pending.length) {
347
409
  return console.log('= no hay nada que exentar: todas las entradas de DONE cumplen el contrato')
348
410
  }
349
- const today = new Date().toISOString().slice(0, 10)
411
+ const today = TODAY()
350
412
  const slugs = pending.map((entry) => entry.slug)
351
413
  F.atomicWrite(target, `# Entradas anteriores a la adopción de Cauce (${today}). No se agregan nuevas:\n`
352
414
  + '# desde esa fecha rige el contrato completo, y `check` avisa cuando una de éstas pasa a\n'
@@ -404,4 +466,4 @@ function archive(dir, rawNum) {
404
466
  console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
405
467
  }
406
468
 
407
- module.exports = { check, evidence, tree, context, archive, adopt }
469
+ module.exports = { check, evidence, tree, context, archive, adopt, recurring }
@@ -72,12 +72,23 @@ function adviceFor(changed) {
72
72
  // propias, agentes editados— queda intacto por construcción, no por comparación.
73
73
  // Qué le pasó a la instancia en esta actualización, contado a quien la corrió. Recibe el resultado
74
74
  // entero en vez de recalcular nada: lo que se informa es exactamente lo que ocurrió.
75
- function reportUpgrade({ root, from, to, system, changed, retired, added, overrides, pinned, droppedBlocks }) {
75
+ function reportUpgrade({
76
+ root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
77
+ descartados, conservados, pendientes,
78
+ }) {
76
79
  console.log(`✓ Cauce ${from || '(previa)'} → ${to}`)
77
80
  // Descartar con --force es legítimo; hacerlo sin dejar rastro no. Queda en la salida del comando,
78
- // que es la evidencia que el protocolo pide para cualquier cambio.
79
- for (const file of changed) console.log(`− descartado tu cambio en ${file}`)
81
+ // que es la evidencia que el protocolo pide para cualquier cambio. Y lo que se conservó no se vuelve
82
+ // a enumerar acá: ya salió con su consejo antes de escribir nada, y repetirlo con el glifo del
83
+ // descarte es lo que volvía ilegible el bloque.
84
+ for (const file of descartados) console.log(`− descartado tu cambio en ${file}`)
85
+ if (conservados.length) console.log(`= ${conservados.length} archivo(s) conservados por tu edición`)
80
86
  for (const relative of retired) console.log(`− retirado ${relative}: Cauce ya no lo distribuye`)
87
+ // Sin glifo de acción porque no hubo ninguna: la ruta sigue ahí y el contenido también.
88
+ for (const { relative, files } of pendientes) {
89
+ console.log(` ${relative}: ${files.length} archivo(s) que Cauce no entregó; la ruta se retiró y `
90
+ + 'el contenido queda. Movelo adonde lo quieras y borrala, o repetí con --force.')
91
+ }
81
92
  // Nombrarlos importa tanto como crearlos: existen para que los completes, y uno que aparece sin
82
93
  // aviso no lo completa nadie.
83
94
  for (const relative of added) console.log(`+ ${relative}: lo agrega esta versión, completalo`)
@@ -90,9 +101,9 @@ function reportUpgrade({ root, from, to, system, changed, retired, added, overri
90
101
  for (const override of overrides) {
91
102
  console.log(`= conservado ${override.collection}/${override.project}: sobrescribe ${override.system}`)
92
103
  }
93
- // Sólo cuando es cierto: llegar acá con algo en `changed` es haber descartado contenido de la
94
- // empresa con --force, que las líneas de arriba enumeran.
95
- if (!changed.length) console.log(' planning, organization y todo lo propio quedaron intactos')
104
+ // Sólo cuando es cierto, y ahora lo decide lo que pasó y no una condición: se dice justo cuando no
105
+ // se descartó nada, que incluye la corrida que conservó veinte archivos.
106
+ if (!descartados.length) console.log(' planning, organization y todo lo propio quedaron intactos')
96
107
  // No se borra: sin la dependencia declarada, quitarle `.ops/` la dejaría sin motor. Se avisa y
97
108
  // decide una persona.
98
109
  if (fs.existsSync(path.join(root, '.ops', 'engine'))) {
@@ -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',
@@ -231,6 +234,14 @@ const RETIRED = [
231
234
 
232
235
  // Aprendizaje que quedó dentro de una ruta retirada. Es lo único ahí que no se puede reponer, así
233
236
  // que se detecta antes de borrar nada: perderlo en silencio sería peor que dejar el directorio.
237
+ // De las rutas retiradas, las que un proyecto también usa para lo suyo. El resto de la lista vive bajo
238
+ // `system/` o es un archivo con nombre propio del toolkit, y ahí nadie más escribe; éstas dos se llaman
239
+ // como el concepto general, así que quien adoptó Cauce cuando se distribuían tiene los suyos justo ahí.
240
+ //
241
+ // El manifiesto no las puede desempatar: `trackedPaths()` nunca las registró, así que para el toolkit
242
+ // todo su contenido es igual de ajeno. Sin poder demostrar que lo entregó, no lo borra.
243
+ const RETIRED_COMPARTIDO = ['automatization/runners', 'automatization/workflows']
244
+
234
245
  function retiredWithLearning(root) {
235
246
  const found = []
236
247
  for (const relative of RETIRED) {
@@ -294,6 +305,7 @@ function localChanges(root) {
294
305
 
295
306
  module.exports = {
296
307
  RETIRED,
308
+ RETIRED_COMPARTIDO,
297
309
  TEMPLATE_OWN,
298
310
  TEMPLATE_PREFIXES,
299
311
  addedPaths,
@@ -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.67.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
+ -->
@@ -31,6 +31,18 @@ y la cobertura no lo va a decir: mide qué líneas se ejecutan, no qué defectos
31
31
  La precondición del caso también cuenta. Si el estado en que arranca no puede ocurrir por el camino de
32
32
  producción, lo que prueba tampoco: queda verde para siempre sobre algo que nadie va a vivir.
33
33
 
34
+ **Y quitar un comportamiento se prueba al revés que agregarlo.** Una prueba que comprueba que aparece lo
35
+ nuevo no comprueba que desapareció lo viejo: los dos pueden convivir, y ahí el verde dice que la mitad
36
+ del cambio ocurrió. La aserción que hace falta es de ausencia —que la salida vieja ya no esté, que la
37
+ rama vieja ya no corra—, y es la que no se escribe sola porque nadie la extraña.
38
+
39
+ Lo que se quita, además, tiene dependientes, y no se anuncian. Una invariante que deja de valer se lleva
40
+ puesto a quien la daba por cierta: el mensaje que la afirmaba, la condición que la deducía, el comentario
41
+ que la explicaba. Suelen vivir en otro archivo, que es donde una premisa vieja se pudre sin que nada
42
+ falle. Antes de entregar una quita se busca quién dependía de ella —qué la afirmaba, qué la deducía— y
43
+ cada uno se corrige o se declara. Recorrer la enumeración de la tarea no encuentra esto: la enumeración
44
+ dice qué había que hacer, no qué se apoyaba en lo que había.
45
+
34
46
  ## R10 — Publicación humana por defecto
35
47
 
36
48
  Push, PR, merge, tags, deploy y rollback requieren la autorización configurada para el proyecto.