@ingeniomaps/cauce 0.75.0 → 0.76.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 +47 -0
- package/automatization/workflows/autobuild.js +4 -2
- package/engine/cli/archive.js +3 -3
- package/engine/cli/claims.js +4 -4
- package/engine/cli/io.js +30 -1
- package/engine/cli/planning.js +7 -27
- package/engine/cli/worktree.js +2 -2
- package/engine/planning/contracts.js +54 -0
- package/engine/planning/parser.js +7 -2
- package/engine/planning/state.js +1 -1
- package/package.json +1 -1
- package/template/planning/PROTOCOL.md +8 -1
- package/template/planning/done/README.md +23 -0
- package/template/planning/wip/README.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,53 @@ 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.76.0] - 2026-09-10
|
|
18
|
+
|
|
19
|
+
### Corregido
|
|
20
|
+
|
|
21
|
+
- **Un comando que no encuentra tu planning lo dice, en vez de contestar un hecho sobre un directorio que
|
|
22
|
+
no existe.** `context` y `tree` ya lo hacían desde 0.71.0; los otros nueve no. `claim` contestaba «no
|
|
23
|
+
está en BACKLOG», `release` «no está tomada por nadie», `evidence` «DONE no tiene ninguna entrada»,
|
|
24
|
+
`runners` «ningún runner tiene trabajo abierto» — todos hechos concretos sobre lo que no pudieron leer.
|
|
25
|
+
**Cuatro de ellos salían con exit 0**, así que un script veía éxito.
|
|
26
|
+
|
|
27
|
+
El daño no es el mensaje sino lo que induce: en una corrida real, `claim` dijo «no está en BACKLOG»
|
|
28
|
+
sobre una tarea que **sí** estaba, y el recorrido mandó a una persona a promover lo único que ya estaba
|
|
29
|
+
bien, citando la regla correcta con la conclusión al revés. Ahora los once comandos que reciben un
|
|
30
|
+
planning fallan igual, con la ruta **resuelta** puesta — que es lo que hace falta cuando el error es de
|
|
31
|
+
resolución: en sidecar, `<empresa>-ops/planning` escrito desde adentro de la raíz apunta a
|
|
32
|
+
`<empresa>-ops/<empresa>-ops/planning`.
|
|
33
|
+
|
|
34
|
+
**Lo que te pide algo**: si tenías un script que trataba ese vacío como «nada que hacer», ahora falla.
|
|
35
|
+
Es la misma dirección que 0.63.0 y 0.71.0 ya tomaron. Y `ops check` sobre una ruta que no existe pasa a
|
|
36
|
+
decir eso en vez de «falta BACKLOG.md»: sale con 2 y nombra la ruta.
|
|
37
|
+
|
|
38
|
+
### Agregado
|
|
39
|
+
|
|
40
|
+
- **La entrada declara también `review:`, y `check` cruza los dos.** El carril dice cuánta ceremonia
|
|
41
|
+
**merecía** la tarea; `review:` dice cuánta **recibió** —el veredicto y quién revisó, o `n/a — razón`
|
|
42
|
+
cuando no corrió—. Es la dimensión que la propia ADR OPS-006 nombraba como la que falta: «se sabría
|
|
43
|
+
comparando hallazgos de review por carril, y hoy no se registra esa dimensión en DONE».
|
|
44
|
+
|
|
45
|
+
Con los dos campos, `check` avisa lo que hasta ahora no tenía cómo ver: una entrada cuyo carril convoca
|
|
46
|
+
revisor —`directo`, `lite`, `full`— y cuya revisión no corrió. `express` queda afuera porque es el único
|
|
47
|
+
que legítimamente no convoca a nadie. Avisa y no falla: es un hecho del pasado que no se arregla
|
|
48
|
+
editando la entrada, y el único camino al verde sería reescribir el registro.
|
|
49
|
+
|
|
50
|
+
- **La entrada de una tarea cerrada declara `lane:`, el carril con el que corrió.** El carril decide qué
|
|
51
|
+
fases recibe una tarea —`express` se saltea Ready, Plan y QA; `full` las corre todas— y viajaba sólo en
|
|
52
|
+
la línea del BACKLOG, que **se borra al cerrar**. Con eso, «¿esta tarea recibió la ceremonia que su
|
|
53
|
+
superficie pedía?» dejaba de tener dónde contestarse: en una instancia real, **0 de 79 entradas de DONE
|
|
54
|
+
registraban el carril**. Ahora queda en el registro, y `sin clasificar` es un valor y no un hueco — dice
|
|
55
|
+
que la línea no lo declaraba, que es distinto de que nadie llenara el campo.
|
|
56
|
+
|
|
57
|
+
El plan en vuelo también lo lleva: una corrida que se reanuda arma la tarea desde `wip/<runner>.md`, y
|
|
58
|
+
sin el campo ahí llegaba al cierre con el carril ya perdido aunque la tarea sí lo tuviera.
|
|
59
|
+
|
|
60
|
+
**Lo que te pide algo**: `ops check` **avisa** cuántas entradas no lo traen y **no falla** — las
|
|
61
|
+
escritas antes de esta versión no lo tienen y no hay de dónde sacárselo. Lo que sí falla es un valor
|
|
62
|
+
inventado. Si cerrás a mano, agregá `lane:` a la entrada; si cerrás con `autobuild`, ya lo escribe.
|
|
63
|
+
|
|
17
64
|
## [0.75.0] - 2026-09-10
|
|
18
65
|
|
|
19
66
|
### Cambiado
|
|
@@ -607,7 +607,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
607
607
|
`Escribí el WIP y nada más: no toques código, no corras pruebas, no cierres la tarea y no escribas ` +
|
|
608
608
|
`en DONE. Los pasos van sin tildar porque todavía no ocurrieron. task=${task.id}, ` +
|
|
609
609
|
`hito=${JSON.stringify(task.hito)}, phase=Build, service=${task.service}, ` +
|
|
610
|
-
`acceptance=${JSON.stringify(task.acceptance)},
|
|
610
|
+
`acceptance=${JSON.stringify(task.acceptance)}, lane=${planning.lane || 'sin clasificar'}, ` +
|
|
611
|
+
`pasos sin tildar=${JSON.stringify(plan.steps)}. ` +
|
|
611
612
|
`Registrá el reparto de cargos ${JSON.stringify(cast)} en las decisiones del WIP, para que después se ` +
|
|
612
613
|
`pueda auditar quién revisó qué. Seguí el contrato de WIP exactamente y reportá con qué status quedó.`,
|
|
613
614
|
{ label: 'wip', schema: {
|
|
@@ -790,7 +791,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
790
791
|
phase('Done')
|
|
791
792
|
await write(
|
|
792
793
|
`Cerrá ${task.id} de forma atómica: escribí ${doneFile(task.id)} con su evidencia —acept, ` +
|
|
793
|
-
`fecha: ${planning.today}, done, qa, tests y
|
|
794
|
+
`fecha: ${planning.today}, done, qa, tests, commit, lane y review, en el formato de entrada que trae ` +
|
|
795
|
+
`este preámbulo—; ` +
|
|
794
796
|
`sacala junto con sus notas indentadas de ${BACKLOG}; cerrá su épica sólo si no queda ` +
|
|
795
797
|
`ninguna tarea etiquetada; dejá ${P}/${planning.wipFile} en status IDLE; y soltá la reserva corriendo ` +
|
|
796
798
|
`"node tools/ops.js release ${P} ${task.id}". En decisions no nombres una fase ni un cargo ` +
|
package/engine/cli/archive.js
CHANGED
|
@@ -11,7 +11,7 @@ const P = require('../planning/parser')
|
|
|
11
11
|
const PC = require('../planning/contracts')
|
|
12
12
|
const AD = require('../planning/adoption')
|
|
13
13
|
const F = require('../core/files')
|
|
14
|
-
const { fail } = require('./io')
|
|
14
|
+
const { fail, planningRoot } = require('./io')
|
|
15
15
|
|
|
16
16
|
// La fecha de hoy, la misma que usan los comandos que leen.
|
|
17
17
|
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
@@ -24,7 +24,7 @@ const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
|
24
24
|
// trabajo de arreglar, y uno que crece a mano deja de ser una lista de perdones para ser una amnistía.
|
|
25
25
|
// Achicarlo sí es a mano, borrando el renglón que `check` señala.
|
|
26
26
|
function adopt(dir) {
|
|
27
|
-
const root =
|
|
27
|
+
const root = planningRoot(dir)
|
|
28
28
|
const target = path.join(root, AD.BASELINE)
|
|
29
29
|
if (fs.existsSync(target)) {
|
|
30
30
|
// Un baseline que ya trae huella no se toca: regenerarlo es exactamente lo que la huella impide.
|
|
@@ -79,7 +79,7 @@ function archiveHumanActions(root) {
|
|
|
79
79
|
// no hace falta: sin esto contestaría «La épica debe ser NNN», que manda a corregir la forma de algo que
|
|
80
80
|
// no existe.
|
|
81
81
|
function archive(dir, rawNum) {
|
|
82
|
-
if (String(rawNum || '') === 'human-actions') return archiveHumanActions(
|
|
82
|
+
if (String(rawNum || '') === 'human-actions') return archiveHumanActions(planningRoot(dir))
|
|
83
83
|
return fail('Sólo se archiva `human-actions`. La evidencia de una tarea ya vive en su propio archivo '
|
|
84
84
|
+ 'de `done/`, así que archivar una épica dejó de tener sentido.', 2)
|
|
85
85
|
}
|
package/engine/cli/claims.js
CHANGED
|
@@ -9,12 +9,12 @@ const path = require('node:path')
|
|
|
9
9
|
const CL = require('../planning/claims')
|
|
10
10
|
const R = require('../core/repos')
|
|
11
11
|
const ST = require('../planning/state')
|
|
12
|
-
const { fail } = require('./io')
|
|
12
|
+
const { fail, planningRoot } = require('./io')
|
|
13
13
|
|
|
14
14
|
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
15
15
|
|
|
16
16
|
function claim(dir, slug, cli) {
|
|
17
|
-
const root =
|
|
17
|
+
const root = planningRoot(dir)
|
|
18
18
|
if (!slug) return fail('Falta el slug. `ops claim <planning-dir> <tarea>`', 2)
|
|
19
19
|
const state = ST.snapshot(root)
|
|
20
20
|
const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
|
|
@@ -82,7 +82,7 @@ function claim(dir, slug, cli) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function release(dir, slug) {
|
|
85
|
-
const root =
|
|
85
|
+
const root = planningRoot(dir)
|
|
86
86
|
if (!slug) return fail('Falta el slug. `ops release <planning-dir> <tarea>`', 2)
|
|
87
87
|
const from = CL.runner()
|
|
88
88
|
const taken = CL.read(root).find((one) => one.slug === slug)
|
|
@@ -103,7 +103,7 @@ function release(dir, slug) {
|
|
|
103
103
|
// La persona elige; el agente exporta. Pedirle a una persona que escriba una variable de entorno para
|
|
104
104
|
// retomar su propio trabajo es hacerle hacer de intérprete.
|
|
105
105
|
function runners(dir, cli) {
|
|
106
|
-
const root =
|
|
106
|
+
const root = planningRoot(dir)
|
|
107
107
|
const done = ST.snapshot(root).done
|
|
108
108
|
const abiertos = CL.read(root).filter((one) => !done.set.has(one.slug))
|
|
109
109
|
const hoy = TODAY()
|
package/engine/cli/io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs')
|
|
3
4
|
const path = require('node:path')
|
|
4
5
|
|
|
5
6
|
// Terminar la corrida con un mensaje y un código. Vive aparte porque lo usa cada familia de comandos, y
|
|
@@ -15,4 +16,32 @@ function opsRoot(dir) {
|
|
|
15
16
|
return path.resolve(dir || process.env.OPS_ROOT || '.')
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// La raíz de planning de un comando: resuelta **y comprobada**, en un solo lugar. Lo que no se pudo leer
|
|
20
|
+
// no contesta como si se hubiera leído, y eso no puede depender de que cada comando se acuerde: sobre un
|
|
21
|
+
// directorio ausente, `claim` no encuentra el slug, `evidence` no encuentra entradas y `runners` no
|
|
22
|
+
// encuentra runners, y los tres reportan ese vacío como un hecho del dominio.
|
|
23
|
+
//
|
|
24
|
+
// El daño no es el mensaje sino la acción que induce. Medido en una corrida real: `claim` contestó «no
|
|
25
|
+
// está en BACKLOG» sobre una tarea que **sí** estaba, y el recorrido mandó a una persona a promover lo
|
|
26
|
+
// único que ya estaba bien, citando la regla correcta con la conclusión al revés (caso 075). Y cuatro de
|
|
27
|
+
// los ocho comandos que lo hacían salían con **exit 0**, así que un script veía éxito.
|
|
28
|
+
//
|
|
29
|
+
// Va en la resolución y no en cada comando porque es lo que cierra la clase en vez de la instancia: la
|
|
30
|
+
// misma se arregló de a una en `stagedFiles` (0.63.0) y en `context` (0.71.0), y volvió las dos veces.
|
|
31
|
+
//
|
|
32
|
+
// La ruta va **resuelta** y no como se escribió, porque el error que ataca es de resolución: en sidecar
|
|
33
|
+
// `<empresa>-ops/planning` desde adentro de la raíz apunta a `<empresa>-ops/<empresa>-ops/planning`.
|
|
34
|
+
function planningRoot(dir) {
|
|
35
|
+
const root = path.resolve(dir || '.')
|
|
36
|
+
if (!fs.existsSync(root)) {
|
|
37
|
+
return fail(`no existe el planning en ${root} (ruta resuelta). Comprobá desde dónde estás invocando.`, 2)
|
|
38
|
+
}
|
|
39
|
+
// Existir no alcanza: un directorio cualquiera contestaría cola vacía igual. `BACKLOG.md` es el archivo
|
|
40
|
+
// del que sale la cola, así que sin él la respuesta no significa nada.
|
|
41
|
+
if (!fs.existsSync(path.join(root, 'BACKLOG.md'))) {
|
|
42
|
+
return fail(`${root} (ruta resuelta) no es un planning: falta BACKLOG.md.`, 2)
|
|
43
|
+
}
|
|
44
|
+
return root
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { fail, opsRoot, planningRoot }
|
package/engine/cli/planning.js
CHANGED
|
@@ -23,7 +23,7 @@ const OB = require('../core/onboarding')
|
|
|
23
23
|
const C = require('../config/validate')
|
|
24
24
|
const CP = require('../config/paths')
|
|
25
25
|
const AG = require('../agents/catalog')
|
|
26
|
-
const { fail } = require('./io')
|
|
26
|
+
const { fail, planningRoot } = require('./io')
|
|
27
27
|
|
|
28
28
|
// Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
|
|
29
29
|
// esos archivos tiende a quedarse con el contenido y perder la estructura: el resultado se lee entero y
|
|
@@ -36,7 +36,7 @@ const { fail } = require('./io')
|
|
|
36
36
|
// entrada de hace tres meses no tiene con qué cruzarse—. Acá se pregunta por una entrada, que es como
|
|
37
37
|
// se cierra una tarea: se escribe la evidencia y se la mira contra el árbol y contra lo que corrió.
|
|
38
38
|
function evidence(dir, cli) {
|
|
39
|
-
const root =
|
|
39
|
+
const root = planningRoot(dir)
|
|
40
40
|
const opsDir = path.join(root, '..')
|
|
41
41
|
const entries = P.readDone(root).entries
|
|
42
42
|
const slug = cli.value('--task')
|
|
@@ -76,7 +76,7 @@ function evidence(dir, cli) {
|
|
|
76
76
|
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
77
77
|
|
|
78
78
|
function check(dir, cli) {
|
|
79
|
-
const root =
|
|
79
|
+
const root = planningRoot(dir)
|
|
80
80
|
const errors = []
|
|
81
81
|
const warnings = []
|
|
82
82
|
// El plan no está: `wip/` es local y gitignoreado, así que un clon nuevo no lo trae y eso no es un
|
|
@@ -148,6 +148,7 @@ function check(dir, cli) {
|
|
|
148
148
|
epics, milestones, done, wips, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
|
|
149
149
|
}))
|
|
150
150
|
warnings.push(...AD.report({ done, epics, adopted }))
|
|
151
|
+
warnings.push(...PC.doneCeremonyWarnings(done, new Set(adopted)))
|
|
151
152
|
// Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
|
|
152
153
|
// no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
|
|
153
154
|
// en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
|
|
@@ -265,10 +266,9 @@ function treeJson({ epics, milestones, done, wips, inbox, queued, claims }) {
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
function tree(dir, cli) {
|
|
268
|
-
const root =
|
|
269
|
+
const root = planningRoot(dir)
|
|
269
270
|
// Mismo motivo que en `context`, y por eso comparten la comprobación: sin ella un planning ausente
|
|
270
271
|
// dibujaba un árbol vacío, que se lee como un roadmap sin épicas en vez de como una ruta equivocada.
|
|
271
|
-
assertPlanning(root)
|
|
272
272
|
const state = ST.snapshot(root)
|
|
273
273
|
if (cli.has('--json')) return treeJson(state)
|
|
274
274
|
const { epics, milestones, done, wips, inbox, queued, claims } = state
|
|
@@ -307,29 +307,9 @@ function tree(dir, cli) {
|
|
|
307
307
|
console.log(`${paint('1', 'DONE')} ${done.entries.length} tareas\n`)
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
// Lo que no se pudo leer no contesta como si se hubiera leído: la misma regla que `stagedFiles` aplica
|
|
311
|
-
// sobre el índice de git, y que esta familia ya aplicaba al `--hito` inexistente. Faltaba la raíz, y ahí
|
|
312
|
-
// pesa más, porque el consumidor no siempre es una persona: `autobuild` toma la cola vacía como permiso
|
|
313
|
-
// para expandir una épica, así que un error de ruta promovía trabajo en vez de fallar.
|
|
314
|
-
//
|
|
315
|
-
// La ruta va **resuelta** y no como se escribió, porque el error que ataca es de resolución: en sidecar
|
|
316
|
-
// `<empresa>-ops/planning` desde adentro de la raíz apunta a `<empresa>-ops/<empresa>-ops/planning`.
|
|
317
|
-
function assertPlanning(root) {
|
|
318
|
-
if (!fs.existsSync(root)) {
|
|
319
|
-
return fail(`no existe el planning en ${root} (ruta resuelta). Comprobá desde dónde estás invocando.`, 2)
|
|
320
|
-
}
|
|
321
|
-
// Existir no alcanza: un directorio cualquiera contestaría cola vacía igual. `BACKLOG.md` es el archivo
|
|
322
|
-
// del que sale la cola, así que sin él la respuesta no significa nada.
|
|
323
|
-
if (!fs.existsSync(path.join(root, 'BACKLOG.md'))) {
|
|
324
|
-
return fail(`${root} (ruta resuelta) no es un planning: falta BACKLOG.md.`, 2)
|
|
325
|
-
}
|
|
326
|
-
return null
|
|
327
|
-
}
|
|
328
|
-
|
|
329
310
|
// Contexto mínimo suficiente para ejecutar una tarea, en lugar de releer roadmap, BACKLOG y WIP enteros.
|
|
330
311
|
function context(dir, cli) {
|
|
331
|
-
const root =
|
|
332
|
-
assertPlanning(root)
|
|
312
|
+
const root = planningRoot(dir)
|
|
333
313
|
const state = ST.snapshot(root)
|
|
334
314
|
// Acotar la cola a un hito es como un equipo se reparte trabajo sin coordinarse: dos personas en hitos
|
|
335
315
|
// distintos casi nunca dependen entre sí ni tocan los mismos archivos. Lo que se acota es qué se
|
|
@@ -473,7 +453,7 @@ function context(dir, cli) {
|
|
|
473
453
|
// `BACKLOG.md` es la cola de lo aprobado y la escribe una persona — ningún comando del motor la toca,
|
|
474
454
|
// ni siquiera `integration promote`, que aterriza en el roadmap. Pegarla es el acto de promoción.
|
|
475
455
|
function recurring(dir, cli) {
|
|
476
|
-
const root =
|
|
456
|
+
const root = planningRoot(dir)
|
|
477
457
|
const file = RC.read(root)
|
|
478
458
|
if (!file.exists) return console.log(`= este planning no declara trabajo recurrente (${RC.FILE})`)
|
|
479
459
|
const state = RC.status({ ...file, done: P.readDone(root), today: TODAY() })
|
package/engine/cli/worktree.js
CHANGED
|
@@ -15,7 +15,7 @@ const ST = require('../planning/state')
|
|
|
15
15
|
const CL = require('../planning/claims')
|
|
16
16
|
const R = require('../core/repos')
|
|
17
17
|
const O = require('../core/ownership')
|
|
18
|
-
const { fail } = require('./io')
|
|
18
|
+
const { fail, planningRoot } = require('./io')
|
|
19
19
|
|
|
20
20
|
const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ function existing(repo, branch) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function worktree(dir, slug, cli) {
|
|
36
|
-
const root =
|
|
36
|
+
const root = planningRoot(dir)
|
|
37
37
|
if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', 2)
|
|
38
38
|
const state = ST.snapshot(root)
|
|
39
39
|
const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
|
|
@@ -87,6 +87,12 @@ function validateDoneEntry(entry, cited = []) {
|
|
|
87
87
|
//
|
|
88
88
|
// Los criterios que la historia declaró cubrir los cita el roadmap y no la entrada, así que el cruce
|
|
89
89
|
// sólo existe si la entrada dice de qué épica viene.
|
|
90
|
+
// El vocabulario del carril tal como se escribe en una entrada de DONE: los cuatro de la línea del
|
|
91
|
+
// BACKLOG más el que dice que la línea no lo declaraba. `sin clasificar` no es un hueco disimulado — es
|
|
92
|
+
// el estado que `PROTOCOL.md` ya llama estado y no error, y escribirlo distingue «corrió sin carril» de
|
|
93
|
+
// «nadie escribió el campo», que es justo lo que este campo vino a poder contestar.
|
|
94
|
+
const LANE_VALUES = [...P.LANES, 'sin clasificar']
|
|
95
|
+
|
|
90
96
|
function doneEntryErrors(entry, epics = []) {
|
|
91
97
|
const at = `${entry.source} ${entry.slug}`
|
|
92
98
|
const errors = []
|
|
@@ -97,11 +103,58 @@ function doneEntryErrors(entry, epics = []) {
|
|
|
97
103
|
if (!entry.done) errors.push(`${at}: falta done:`)
|
|
98
104
|
if (!entry.qa) errors.push(`${at}: falta qa:`)
|
|
99
105
|
if (!entry.commit) errors.push(`${at}: falta commit:`)
|
|
106
|
+
// El carril con el que la tarea corrió. Ausente **avisa** y no frena, porque toda entrada escrita antes
|
|
107
|
+
// de que el campo existiera lo está y no hay de dónde sacárselo: exigirlo pondría en rojo el `check` de
|
|
108
|
+
// cada instancia que actualiza, por algo que nadie puede arreglar. Escrito mal sí frena, porque eso es
|
|
109
|
+
// un valor que alguien puso y de él depende leer si la ceremonia fue la que correspondía (OPS-006).
|
|
110
|
+
if (entry.lane && !LANE_VALUES.includes(entry.lane)) {
|
|
111
|
+
errors.push(`${at}: lane "${entry.lane}" no existe; usá ${LANE_VALUES.join(' | ')}`)
|
|
112
|
+
}
|
|
100
113
|
const story = epics.find((epic) => epic.num === entry.epic)?.stories
|
|
101
114
|
.find((candidate) => candidate.slug === entry.slug)
|
|
102
115
|
return [...errors, ...validateDoneEntry(entry, story ? story.criteria : [])]
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
// Un carril declara **cuánta ceremonia** merecía la tarea; `n/a` en `review:` dice que la revisión no
|
|
119
|
+
// corrió. `express` es el único que no convoca revisor, así que en los otros tres esa combinación es la
|
|
120
|
+
// ADR incumplida, escrita en el propio registro.
|
|
121
|
+
const CONVOCAN_REVISOR = ['directo', 'lite', 'full']
|
|
122
|
+
const SIN_REVISION = /^n\/a\b/i
|
|
123
|
+
|
|
124
|
+
// Lo que el registro puede decir sobre la ceremonia, y lo que todavía no. Los dos campos avisan en vez de
|
|
125
|
+
// fallar por lo que dice `doneEntryErrors`, y cuentan en vez de listar porque al principio son todas: lo
|
|
126
|
+
// que se lee es que el número baje. Cuando llegue a cero, exigirlos deja de costarle nada a nadie.
|
|
127
|
+
//
|
|
128
|
+
// El cruce sí nombra las tareas, porque son pocas y cada una es una pregunta concreta para una persona.
|
|
129
|
+
// Y también avisa en vez de fallar, por una razón distinta de la de los campos: es un hecho del pasado
|
|
130
|
+
// que no se arregla editando la entrada, así que el único camino al verde sería reescribir el registro.
|
|
131
|
+
// Un gate que se apaga mintiendo es peor que no tenerlo.
|
|
132
|
+
//
|
|
133
|
+
// `sin clasificar` queda afuera del cruce a propósito: el recorrido corre esas tareas por el carril
|
|
134
|
+
// completo, pero eso lo sabe el recorrido y no la entrada. Avisar sobre lo que hay que deducir es lo que
|
|
135
|
+
// llena de ruido un aviso que después nadie mira.
|
|
136
|
+
function doneCeremonyWarnings(done, adopted = new Set()) {
|
|
137
|
+
const propias = done.entries.filter((entry) => !adopted.has(entry.slug))
|
|
138
|
+
const warnings = []
|
|
139
|
+
const sinLane = propias.filter((entry) => !entry.lane)
|
|
140
|
+
if (sinLane.length) {
|
|
141
|
+
warnings.push(`planning/done: ${sinLane.length} entrada(s) sin lane:, así que no se puede comprobar `
|
|
142
|
+
+ 'sobre el registro que la ceremonia que recibieron fue la que su superficie pedía (OPS-006)')
|
|
143
|
+
}
|
|
144
|
+
const sinReview = propias.filter((entry) => !entry.review)
|
|
145
|
+
if (sinReview.length) {
|
|
146
|
+
warnings.push(`planning/done: ${sinReview.length} entrada(s) sin review:, que es la dimensión con la `
|
|
147
|
+
+ 'que OPS-006 dice que se mide si el carril elegido fue el correcto')
|
|
148
|
+
}
|
|
149
|
+
const saltadas = propias.filter((entry) => CONVOCAN_REVISOR.includes(entry.lane)
|
|
150
|
+
&& entry.review && SIN_REVISION.test(entry.review))
|
|
151
|
+
if (saltadas.length) {
|
|
152
|
+
warnings.push(`planning/done: ${saltadas.map((entry) => entry.slug).join(', ')} declara(n) un carril `
|
|
153
|
+
+ 'que convoca revisor y una revisión que no corrió: el carril reduce ceremonia, nunca evidencia')
|
|
154
|
+
}
|
|
155
|
+
return warnings
|
|
156
|
+
}
|
|
157
|
+
|
|
105
158
|
function duplicates(values) {
|
|
106
159
|
return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
|
|
107
160
|
}
|
|
@@ -308,6 +361,7 @@ function validateState({
|
|
|
308
361
|
module.exports = {
|
|
309
362
|
validateState,
|
|
310
363
|
doneEntryErrors,
|
|
364
|
+
doneCeremonyWarnings,
|
|
311
365
|
validCommitTrace,
|
|
312
366
|
validDecisionTrace,
|
|
313
367
|
validTestTrace,
|
|
@@ -254,7 +254,7 @@ function doneFiles(dir) {
|
|
|
254
254
|
|
|
255
255
|
// `fecha` entra al vocabulario porque un campo que no esté acá no corta al anterior: sin nombrarlo, el
|
|
256
256
|
// `done:` de la entrada se lo tragaría entero como parte de su propio texto.
|
|
257
|
-
const DONE_FIELDS = 'acept|fecha|done|qa|tests|decisions|commit'
|
|
257
|
+
const DONE_FIELDS = 'acept|fecha|done|qa|tests|decisions|commit|lane|review'
|
|
258
258
|
|
|
259
259
|
// Un campo vale hasta el próximo campo, una línea en blanco o el fin de la entrada. Mismo corte que ya
|
|
260
260
|
// se arregló para los criterios y las historias, con el mismo síntoma: el valor es prosa y se envuelve a
|
|
@@ -287,7 +287,8 @@ function readDone(dir) {
|
|
|
287
287
|
epic: ((match[2].match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
|
|
288
288
|
acceptance: field('acept'), fecha: field('fecha'),
|
|
289
289
|
done: field('done'), qa: field('qa'), tests: field('tests'),
|
|
290
|
-
decisions: field('decisions'), commit: field('commit'),
|
|
290
|
+
decisions: field('decisions'), commit: field('commit'), lane: field('lane'),
|
|
291
|
+
review: field('review'),
|
|
291
292
|
source: path.relative(dir, file), raw: match[0].trimEnd(),
|
|
292
293
|
})
|
|
293
294
|
}
|
|
@@ -370,6 +371,10 @@ function parseWip(text, runner) {
|
|
|
370
371
|
if (!task) return null
|
|
371
372
|
return {
|
|
372
373
|
task, runner, phase: field('phase') || '?', service: field('service'),
|
|
374
|
+
// El carril viaja en el WIP porque la línea del BACKLOG deja de existir al cerrar, y sin esto una
|
|
375
|
+
// corrida que se reanuda llega al cierre con el carril ya perdido: `currentTask` arma la tarea desde
|
|
376
|
+
// el WIP y le pone `tier` vacío. Medido — la tarea reanudada devolvía `""` (caso 074).
|
|
377
|
+
lane: field('lane'),
|
|
373
378
|
complete: (text.match(/^\d+\.\s+\[[xX]\]/gm) || []).length,
|
|
374
379
|
pending: (text.match(/^\d+\.\s+\[\s\]/gm) || []).length,
|
|
375
380
|
}
|
package/engine/planning/state.js
CHANGED
|
@@ -50,7 +50,7 @@ function currentTask({ milestones, done, wips = [], claims = [] }, blockers = []
|
|
|
50
50
|
if (wip) {
|
|
51
51
|
const active = queue.find((task) => task.slug === wip.task)
|
|
52
52
|
|| {
|
|
53
|
-
slug: wip.task, hito: '', tier: '', cast: { build: '', review: [] },
|
|
53
|
+
slug: wip.task, hito: '', tier: wip.lane || '', cast: { build: '', review: [] },
|
|
54
54
|
service: wip.service, acceptance: '', epic: '', criteria: [],
|
|
55
55
|
}
|
|
56
56
|
return { task: active, claimed: mine.has(wip.task), skipped: [], taken: [], waiting: [] }
|
package/package.json
CHANGED
|
@@ -13,7 +13,14 @@ invariantes.
|
|
|
13
13
|
opcionales: sin ellos la tarea está sin clasificar, que es un estado y no un error. Una tarea con
|
|
14
14
|
dependencias no se ofrece ni se toma hasta que todas estén en DONE.
|
|
15
15
|
- DONE: un archivo por tarea cerrada, `done/<slug>.md`, con su entrada `[x]` y los campos `acept:`,
|
|
16
|
-
`fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:` y `
|
|
16
|
+
`fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:`, `commit:` y `lane:`. `lane:` repite el carril con el
|
|
17
|
+
que la tarea corrió —`express`, `directo`, `lite`, `full`— o `sin clasificar` si su línea no lo
|
|
18
|
+
declaraba, y existe porque el carril decide qué fases corren y su línea del BACKLOG se borra al cerrar:
|
|
19
|
+
sin él, si una tarea recibió la ceremonia que le tocaba sólo lo sabe quien estuvo en la sesión.
|
|
20
|
+
`check` avisa cuántas entradas no lo traen y falla si trae un valor que no existe. `review:` dice qué
|
|
21
|
+
pasó con la revisión —el veredicto y quién revisó— o `n/a — razón` cuando no corrió; es la dimensión con
|
|
22
|
+
la que OPS-006 dice que se mide si el carril elegido fue el correcto, y `check` cruza los dos: un carril
|
|
23
|
+
que convoca revisor con una revisión que no corrió es la ADR incumplida, escrita en el propio registro. La fecha es la del cierre, y es lo que
|
|
17
24
|
ordena una evidencia que ya no depende de su posición dentro de un archivo. `tests:` enlaza cada criterio
|
|
18
25
|
mediante `CN → prueba`; usa `A → prueba` cuando no hay épica o `n/a — razón` si no existe una
|
|
19
26
|
superficie ejecutable. `decisions:` es opcional y, si aparece, cita `[fuente: ...]` o
|
|
@@ -12,6 +12,8 @@ tarea entregó y con qué se comprueba.
|
|
|
12
12
|
tests: C1 → nombre de prueba o comando; C2 → nombre de prueba o comando
|
|
13
13
|
decisions: decisión no obvia [fuente: ruta/archivo] o [supuesto: motivo verificable]
|
|
14
14
|
commit: abc1234 feat(scope): subject (repo@branch)
|
|
15
|
+
lane: full
|
|
16
|
+
review: aprobado por tech-lead, sobre api/alta.go
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
El contrato completo de esos campos está en `../PROTOCOL.md`; acá va por qué el archivo es uno por tarea.
|
|
@@ -27,6 +29,27 @@ El nombre del archivo es una conveniencia; lo que identifica la tarea es el slug
|
|
|
27
29
|
el archivo no cambia de qué tarea habla, y cerrar dos veces la misma sigue siendo un error que `check`
|
|
28
30
|
rechaza, ahora entre archivos.
|
|
29
31
|
|
|
32
|
+
## Por qué el carril
|
|
33
|
+
|
|
34
|
+
El carril decide qué fases corre una tarea: `express` se saltea Ready, Plan y QA; `full` las corre
|
|
35
|
+
todas. Ese dato vive en la línea del BACKLOG, y la línea **se borra al cerrar** — así que la pregunta
|
|
36
|
+
«¿esta tarea recibió la ceremonia que su superficie pedía?» dejaba de tener dónde contestarse, y quedaba
|
|
37
|
+
en la memoria de quien estuvo en la sesión. `lane:` la devuelve al registro.
|
|
38
|
+
|
|
39
|
+
Se escribe aunque sea `sin clasificar`, que es distinto de no escribirlo: uno dice que la tarea corrió
|
|
40
|
+
sin carril declarado y el otro, que nadie llenó el campo.
|
|
41
|
+
|
|
42
|
+
## Por qué la revisión
|
|
43
|
+
|
|
44
|
+
El carril dice cuánta ceremonia **merecía** la tarea; `review:` dice cuánta **recibió**. Con los dos, la
|
|
45
|
+
pregunta que OPS-006 dejó pendiente —«¿el carril elegido fue el correcto?»— se contesta desde el registro
|
|
46
|
+
en vez de desde la memoria de la sesión.
|
|
47
|
+
|
|
48
|
+
`express` es el único carril que no convoca revisor, así que ahí `n/a — razón` es lo correcto. En
|
|
49
|
+
`directo`, `lite` y `full` una revisión que no corrió es la ADR incumplida, y `check` lo avisa nombrando
|
|
50
|
+
la tarea. Avisa y no falla: es un hecho del pasado que no se arregla editando la entrada, y el único
|
|
51
|
+
camino al verde sería reescribir el registro.
|
|
52
|
+
|
|
30
53
|
## Por qué la fecha
|
|
31
54
|
|
|
32
55
|
Mientras las entradas vivían en un archivo, «la última» era la última del archivo. Con archivos sueltos
|
|
@@ -12,6 +12,7 @@ phase: Build
|
|
|
12
12
|
started: AAAA-MM-DD
|
|
13
13
|
service: ruta
|
|
14
14
|
acceptance: "criterio observable"
|
|
15
|
+
lane: full
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
## Plan aprobado
|
|
@@ -24,6 +25,10 @@ acceptance: "criterio observable"
|
|
|
24
25
|
- (ninguno)
|
|
25
26
|
```
|
|
26
27
|
|
|
28
|
+
`lane:` viaja acá por la misma razón por la que existe en DONE: la línea del BACKLOG se borra al
|
|
29
|
+
cerrar, y una corrida que se reanuda arma la tarea desde este archivo. Sin el campo, el cierre de una
|
|
30
|
+
corrida reanudada escribe `sin clasificar` sobre una tarea que sí tenía carril.
|
|
31
|
+
|
|
27
32
|
Sin archivo, el runner está en IDLE: un clon nuevo no trae ninguno y eso no es un error.
|
|
28
33
|
|
|
29
34
|
## Por qué uno por runner y no uno solo
|