@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 +20 -0
- package/README.md +7 -0
- package/engine/cli/args.js +2 -1
- package/engine/cli/ops.js +2 -0
- package/engine/cli/planning.js +55 -2
- package/engine/core/ownership.js +3 -0
- package/engine/planning/parser.js +38 -21
- package/engine/planning/recurring.js +148 -0
- package/package.json +1 -1
- package/template/AGENTS.md +9 -2
- package/template/Makefile +4 -1
- package/template/planning/FLOW.md +2 -0
- package/template/planning/PROTOCOL.md +5 -0
- package/template/planning/README.md +1 -0
- package/template/planning/RECURRING.md +77 -0
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
|
|
package/engine/cli/args.js
CHANGED
|
@@ -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)
|
package/engine/cli/planning.js
CHANGED
|
@@ -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 =
|
|
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 }
|
package/engine/core/ownership.js
CHANGED
|
@@ -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
|
|
212
|
-
if (
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
package/template/AGENTS.md
CHANGED
|
@@ -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
|
|
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
|
+
-->
|