@ingeniomaps/cauce 0.68.0 → 0.70.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 +159 -0
- package/README.md +13 -6
- package/automatization/AGENTS.md +1 -1
- package/automatization/runners/antigravity/rules/cauce.md +1 -1
- package/automatization/runners/claude/CLAUDE.md +1 -1
- package/automatization/runners/codex/AGENTS.md +1 -1
- package/automatization/runners/gemini/GEMINI.md +1 -1
- package/automatization/shared/skills/autobuild/SKILL.md +1 -1
- package/automatization/workflows/autobuild.js +44 -8
- package/engine/cli/archive.js +87 -0
- package/engine/cli/args.js +7 -2
- package/engine/cli/catalog.js +9 -1
- package/engine/cli/claims.js +125 -0
- package/engine/cli/ops.js +17 -4
- package/engine/cli/planning.js +158 -102
- package/engine/cli/worktree.js +89 -0
- package/engine/core/ownership.js +16 -5
- package/engine/core/repos.js +67 -0
- package/engine/hooks/files.js +3 -2
- package/engine/planning/adoption.js +1 -1
- package/engine/planning/claims.js +153 -0
- package/engine/planning/contracts.js +43 -202
- package/engine/planning/parser.js +89 -33
- package/engine/planning/recurring.js +148 -0
- package/engine/planning/state.js +39 -6
- package/engine/planning/structure.js +220 -0
- package/package.json +1 -1
- package/template/.gitattributes +19 -0
- package/template/AGENTS.md +54 -5
- package/template/Makefile +4 -1
- package/template/automatization/AGENTS.md +1 -1
- package/template/gitignore +9 -0
- package/template/planning/BACKLOG.md +5 -0
- package/template/planning/FLOW.md +3 -1
- package/template/planning/PROTOCOL.md +25 -8
- package/template/planning/README.md +4 -3
- package/template/planning/RECURRING.md +77 -0
- package/template/planning/adr/system/OPS-001-planificacion-como-fuente-de-verdad.md +3 -2
- package/template/planning/business-rules/system/BR-OPS-001-una-sola-tarea-activa.md +7 -4
- package/template/planning/business-rules/system/BR-OPS-005-una-tarea-un-runner.md +44 -0
- package/template/planning/claims/README.md +70 -0
- package/template/planning/delivery/README.md +1 -0
- package/template/planning/delivery/multi-repo.md +11 -0
- package/template/planning/delivery/teamwork.md +162 -0
- package/template/planning/done/README.md +41 -0
- package/template/planning/wip/README.md +49 -0
- package/template/planning/DONE.md +0 -13
- package/template/planning/WIP.md +0 -22
|
@@ -193,6 +193,31 @@ 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
|
+
// De qué otras tareas depende. El orden del BACKLOG alcanzaba mientras hubiera un runner: con dos,
|
|
213
|
+
// el segundo toma la que sigue mientras el primero construye la de la que depende, y el orden deja
|
|
214
|
+
// de decir nada.
|
|
215
|
+
depends: ((rest.match(/\(depende:\s*([^)]+)\)/i) || [])[1] || '')
|
|
216
|
+
.split(',').map((one) => one.trim()).filter(Boolean),
|
|
217
|
+
noSplit: noSplitReason(rest),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
function readBacklog(dir) {
|
|
197
222
|
const text = withoutComments(read(path.join(dir, 'BACKLOG.md')))
|
|
198
223
|
const milestones = []
|
|
@@ -208,34 +233,28 @@ function readBacklog(dir) {
|
|
|
208
233
|
continue
|
|
209
234
|
}
|
|
210
235
|
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
|
-
})
|
|
236
|
+
const task = current ? taskFromLine(line) : null
|
|
237
|
+
if (task) current.tasks.push(task)
|
|
224
238
|
}
|
|
225
239
|
return milestones
|
|
226
240
|
}
|
|
227
241
|
|
|
242
|
+
// Lo que hay en `done/` y no es una entrada: una tabla de acciones humanas resueltas y la prosa que
|
|
243
|
+
// explica el formato. Se nombran acá porque el lector recorre el directorio entero — una tarea cerrada
|
|
244
|
+
// es un archivo suyo, y cualquier nombre sirve mientras el slug de adentro sea el que manda.
|
|
245
|
+
const NOT_ENTRIES = new Set(['human-actions.md', 'README.md'])
|
|
246
|
+
|
|
228
247
|
function doneFiles(dir) {
|
|
229
|
-
const files = [path.join(dir, 'DONE.md')]
|
|
230
|
-
const archive = path.join(dir, 'done')
|
|
231
248
|
try {
|
|
232
|
-
|
|
233
|
-
.
|
|
234
|
-
|
|
235
|
-
return
|
|
249
|
+
return fs.readdirSync(path.join(dir, 'done'))
|
|
250
|
+
.filter((file) => file.endsWith('.md') && !NOT_ENTRIES.has(file)).sort()
|
|
251
|
+
.map((file) => path.join(dir, 'done', file))
|
|
252
|
+
} catch { return [] }
|
|
236
253
|
}
|
|
237
254
|
|
|
238
|
-
|
|
255
|
+
// `fecha` entra al vocabulario porque un campo que no esté acá no corta al anterior: sin nombrarlo, el
|
|
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'
|
|
239
258
|
|
|
240
259
|
// Un campo vale hasta el próximo campo, una línea en blanco o el fin de la entrada. Mismo corte que ya
|
|
241
260
|
// se arregló para los criterios y las historias, con el mismo síntoma: el valor es prosa y se envuelve a
|
|
@@ -266,7 +285,8 @@ function readDone(dir) {
|
|
|
266
285
|
entries.push({
|
|
267
286
|
slug: match[1].trim(),
|
|
268
287
|
epic: ((match[2].match(/\(epic:\s*(\d{3})\)/) || [])[1] || ''),
|
|
269
|
-
acceptance: field('acept'),
|
|
288
|
+
acceptance: field('acept'), fecha: field('fecha'),
|
|
289
|
+
done: field('done'), qa: field('qa'), tests: field('tests'),
|
|
270
290
|
decisions: field('decisions'), commit: field('commit'),
|
|
271
291
|
source: path.relative(dir, file), raw: match[0].trimEnd(),
|
|
272
292
|
})
|
|
@@ -302,16 +322,24 @@ const SEPARADORES = /^\|\s*:?-+/
|
|
|
302
322
|
// afectada —`archive` reescribe `raw`, la línea original, no las celdas—, así que quitarlo no pierde nada.
|
|
303
323
|
const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
|
|
304
324
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
325
|
+
// Las filas de datos de las tablas de un texto, con las celdas ya normalizadas. Vive acá y no en cada
|
|
326
|
+
// lector porque el escape del pipe tiene que leerse igual en los dos archivos que traen tabla: escrito
|
|
327
|
+
// dos veces, una de las dos copias se pudre sin que nada falle.
|
|
328
|
+
//
|
|
329
|
+
// En markdown la cabecera es la fila anterior a la de separadores, diga lo que diga su primera celda.
|
|
330
|
+
// Se marcan todas y no la primera: un archivo con una tabla por sección tiene una cabecera por tabla,
|
|
331
|
+
// y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
|
|
332
|
+
// fila de datos nunca está inmediatamente antes de los guiones.
|
|
333
|
+
function tableRows(text) {
|
|
334
|
+
const lineas = text.split('\n')
|
|
311
335
|
const cabeceras = new Set(lineas.map((line, i) => (SEPARADORES.test(line) ? i - 1 : -1)))
|
|
312
|
-
|
|
336
|
+
return lineas
|
|
313
337
|
.filter((line, i) => /^\|/.test(line) && !SEPARADORES.test(line) && !cabeceras.has(i))
|
|
314
338
|
.map((line) => ({ line, cells: line.split(SEPARADOR).slice(1, -1).map(celda) }))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function readHumanActions(dir) {
|
|
342
|
+
const rows = tableRows(withoutComments(read(path.join(dir, 'HUMAN_ACTIONS.md'))))
|
|
315
343
|
// El literal queda como resguardo de la tabla escrita sin su fila de separadores: markdown no la
|
|
316
344
|
// renderiza como tabla, y este parser lee sus filas igual.
|
|
317
345
|
return rows.filter(({ cells }) => cells.length >= 4 && !/^tarea$/i.test(cells[0]))
|
|
@@ -324,19 +352,47 @@ function readHumanActions(dir) {
|
|
|
324
352
|
})
|
|
325
353
|
}
|
|
326
354
|
|
|
327
|
-
|
|
328
|
-
|
|
355
|
+
// El plan de un runner vive en su propio archivo, y el nombre sale de su id. Con una instancia sidecar
|
|
356
|
+
// hay un solo `planning/` por máquina, así que un archivo compartido lo escriben todos los agentes que
|
|
357
|
+
// corren ahí: el segundo pisaba el plan del primero y `context` le entregaba su tarea.
|
|
358
|
+
//
|
|
359
|
+
// El id es una ruta, así que se aplana para poder ser un nombre. Queda largo y legible a propósito —
|
|
360
|
+
// el directorio no viaja por git y sirve para mirar de quién es cada plan cuando algo quedó a medias.
|
|
361
|
+
function wipName(runner) {
|
|
362
|
+
return String(runner || '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase()
|
|
363
|
+
|| 'sin-runner'
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseWip(text, runner) {
|
|
329
367
|
if (/^status:\s*IDLE/m.test(text)) return null
|
|
330
368
|
const field = frontmatter(text)
|
|
331
369
|
const task = field('task')
|
|
332
370
|
if (!task) return null
|
|
333
371
|
return {
|
|
334
|
-
task, phase: field('phase') || '?', service: field('service'),
|
|
372
|
+
task, runner, phase: field('phase') || '?', service: field('service'),
|
|
335
373
|
complete: (text.match(/^\d+\.\s+\[[xX]\]/gm) || []).length,
|
|
336
374
|
pending: (text.match(/^\d+\.\s+\[\s\]/gm) || []).length,
|
|
337
375
|
}
|
|
338
376
|
}
|
|
339
377
|
|
|
378
|
+
// El de un runner: el único que le corresponde continuar. El `runner` que devuelve es el nombre
|
|
379
|
+
// aplanado, que es la identidad que hay en disco — devolver el id crudo dejaba a quien compara dos
|
|
380
|
+
// lecturas comparando formas distintas de lo mismo, y no coincidían nunca.
|
|
381
|
+
function readWip(dir, runner) {
|
|
382
|
+
const name = wipName(runner)
|
|
383
|
+
return parseWip(read(path.join(dir, 'wip', `${name}.md`)), name)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Todos los que hay. Lo pregunta `check`, que juzga si cada plan apunta a una tarea que existe, y `tree`,
|
|
387
|
+
// que muestra qué está en vuelo: las dos son preguntas sobre la instancia y no sobre quien pregunta.
|
|
388
|
+
function readWips(dir) {
|
|
389
|
+
let names = []
|
|
390
|
+
try { names = fs.readdirSync(path.join(dir, 'wip')) } catch { return [] }
|
|
391
|
+
return names.filter((name) => name.endsWith('.md')).sort()
|
|
392
|
+
.map((name) => parseWip(read(path.join(dir, 'wip', name)), name.replace(/\.md$/, '')))
|
|
393
|
+
.filter(Boolean)
|
|
394
|
+
}
|
|
395
|
+
|
|
340
396
|
// Un ítem se cuenta cuando empieza con su nombre en negrita, y el nombre existe para poder citarlo
|
|
341
397
|
// —`HUMAN_ACTIONS.md` y las propuestas mensuales se refieren a un ítem por ese slug—. La convención se
|
|
342
398
|
// conserva; lo que no se conserva es el silencio.
|
|
@@ -361,7 +417,7 @@ function readInbox(dir) {
|
|
|
361
417
|
module.exports = {
|
|
362
418
|
EPIC_STATES, HUMAN_ACTION_STATES, LANES, MILESTONE_HEADING, STOP_REASONS,
|
|
363
419
|
TASK_LINE, TASK_LINE_ANY_LANE,
|
|
364
|
-
read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip,
|
|
365
|
-
acceptanceConditions,
|
|
420
|
+
read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip, readWips, wipName,
|
|
421
|
+
acceptanceConditions, tableRows, taskFromLine,
|
|
366
422
|
readInbox, readHumanActions,
|
|
367
423
|
}
|
|
@@ -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/engine/planning/state.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// que fija el protocolo. Vivía en el CLI, donde nadie podía ejercitarlo sin lanzar un proceso.
|
|
6
6
|
|
|
7
7
|
const P = require('./parser')
|
|
8
|
+
const C = require('./claims')
|
|
8
9
|
|
|
9
10
|
function snapshot(root) {
|
|
10
11
|
const milestones = P.readBacklog(root)
|
|
@@ -12,8 +13,9 @@ function snapshot(root) {
|
|
|
12
13
|
epics: P.readEpics(root),
|
|
13
14
|
milestones,
|
|
14
15
|
done: P.readDone(root),
|
|
15
|
-
|
|
16
|
+
wips: P.readWips(root),
|
|
16
17
|
inbox: P.readInbox(root),
|
|
18
|
+
claims: C.read(root),
|
|
17
19
|
queued: new Set(milestones.flatMap((m) => m.tasks.map((t) => t.slug))),
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -31,21 +33,52 @@ function pendingHumanActions(root) {
|
|
|
31
33
|
// WIP activo primero —es el mutex y manda incluso si tiene una acción humana abierta—, si no la
|
|
32
34
|
// primera tarea no terminada y no bloqueada recorriendo los hitos en su orden: agotado el primero,
|
|
33
35
|
// sigue por el que viene.
|
|
34
|
-
|
|
36
|
+
//
|
|
37
|
+
// Con equipo se agrega un escalón entre los dos: lo que ya reclamé va antes que lo libre, porque es lo
|
|
38
|
+
// que dije que iba a hacer. Y lo que reclamó otro no se ofrece — sin eso, dos runners preguntando a la
|
|
39
|
+
// vez reciben la misma tarea y ninguno se entera, que es la colisión que no se ve.
|
|
40
|
+
//
|
|
41
|
+
// Quién soy acá es el runner y no la persona: dos agentes en la misma máquina comparten la identidad de
|
|
42
|
+
// git, así que comparar por owner le devolvería a cada uno la tarea del otro como propia.
|
|
43
|
+
function currentTask({ milestones, done, wips = [], claims = [] }, blockers = [], runner = '') {
|
|
44
|
+
// El plan de otro runner no es asunto de éste: leerlo como propio era lo que le entregaba a un agente
|
|
45
|
+
// la tarea que otro estaba construyendo, con el WIP ajeno adentro y diciéndole que estaba libre.
|
|
46
|
+
const wip = wips.find((one) => one.runner === P.wipName(runner)) || null
|
|
35
47
|
const queue = milestones.flatMap((milestone) => milestone.tasks.map((task) => ({ ...task, hito: milestone.slug })))
|
|
48
|
+
const mine = new Set(claims.filter((one) => runner && one.runner === runner).map((one) => one.slug))
|
|
49
|
+
const others = new Map(claims.filter((one) => !mine.has(one.slug)).map((one) => [one.slug, one.owner]))
|
|
36
50
|
if (wip) {
|
|
37
51
|
const active = queue.find((task) => task.slug === wip.task)
|
|
38
52
|
|| {
|
|
39
53
|
slug: wip.task, hito: '', tier: '', cast: { build: '', review: [] },
|
|
40
54
|
service: wip.service, acceptance: '', epic: '', criteria: [],
|
|
41
55
|
}
|
|
42
|
-
return { task: active, skipped: [] }
|
|
56
|
+
return { task: active, claimed: mine.has(wip.task), skipped: [], taken: [], waiting: [] }
|
|
43
57
|
}
|
|
44
58
|
const blocked = new Set(blockers.map((action) => action.task))
|
|
45
|
-
const pending = queue.filter((task) => !done.set.has(task.slug))
|
|
59
|
+
const pending = queue.filter((task) => !done.set.has(task.slug) && !blocked.has(task.slug))
|
|
60
|
+
// Una tarea está lista cuando todo lo que declaró depender ya está en DONE. Mientras no lo esté no se
|
|
61
|
+
// le ofrece a nadie, y en particular no a otro runner: lo que sigue a una tarea en vuelo es trabajo
|
|
62
|
+
// del que la tiene, y dárselo a otro produce dos ramas que se van a pisar al integrar.
|
|
63
|
+
const ready = (task) => (task.depends || []).every((dep) => done.set.has(dep))
|
|
64
|
+
const claimed = pending.find((task) => mine.has(task.slug))
|
|
46
65
|
return {
|
|
47
|
-
task: pending.find((task) => !
|
|
48
|
-
|
|
66
|
+
task: claimed || pending.find((task) => !others.has(task.slug) && ready(task)) || null,
|
|
67
|
+
// Que la tarea devuelta ya sea mía o esté libre cambia lo que corresponde hacer con ella, y desde
|
|
68
|
+
// afuera las dos se ven igual.
|
|
69
|
+
claimed: Boolean(claimed),
|
|
70
|
+
skipped: queue.filter((task) => !done.set.has(task.slug) && blocked.has(task.slug))
|
|
71
|
+
.map((task) => task.slug),
|
|
72
|
+
taken: pending.filter((task) => others.has(task.slug))
|
|
73
|
+
.map((task) => ({ slug: task.slug, owner: others.get(task.slug) })),
|
|
74
|
+
// Lo que espera a otra tarea, con cuál y quién la tiene: la tercera causa por la que una cola puede
|
|
75
|
+
// no ofrecer nada, y `context` las distingue por la misma razón que distingue las otras dos.
|
|
76
|
+
// El filtro garantiza que hay dependencias y que al menos una no está cerrada —sin eso la tarea
|
|
77
|
+
// estaría lista y no acá—, así que buscarla no puede fallar y no lleva defensa.
|
|
78
|
+
waiting: pending.filter((task) => !others.has(task.slug) && !ready(task)).map((task) => {
|
|
79
|
+
const dep = task.depends.find((one) => !done.set.has(one))
|
|
80
|
+
return { slug: task.slug, dep, owner: others.get(dep) || (mine.has(dep) ? 'vos' : '') }
|
|
81
|
+
}),
|
|
49
82
|
}
|
|
50
83
|
}
|
|
51
84
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Lo que se juzga leyendo el disco: cómo están armados los directorios de planning —roadmap, BACKLOG,
|
|
4
|
+
// reglas y ADR— antes de que alguien componga un estado con ellos.
|
|
5
|
+
//
|
|
6
|
+
// Vive aparte de `contracts.js` porque es la otra mitad de una costura: aquéllas reciben el estado ya
|
|
7
|
+
// leído y se prueban sin tocar disco, éstas abren archivos. Se separaron cuando el archivo cruzó las 500
|
|
8
|
+
// líneas, y lo que decidió el corte fue eso y no el número.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const P = require('./parser')
|
|
13
|
+
|
|
14
|
+
const EPIC_AUXILIARY_FILES = new Set(['notes.md', 'plan.md', 'research.md', 'spec.md'])
|
|
15
|
+
|
|
16
|
+
function validateRoadmapStructure(dir) {
|
|
17
|
+
const roadmap = path.join(dir, 'roadmap')
|
|
18
|
+
let entries = []
|
|
19
|
+
try { entries = fs.readdirSync(roadmap, { withFileTypes: true }) } catch { return ['falta roadmap/'] }
|
|
20
|
+
const errors = []
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
// Un archivo que se llama como una épica y no cumple el patrón no lo lee nadie: ni `check`, ni
|
|
23
|
+
// `tree`, ni el runner que busca trabajo. Ignorarlo en silencio es peor que rechazarlo, porque el
|
|
24
|
+
// planning se reporta válido mientras la épica que alguien escribió no existe para el sistema.
|
|
25
|
+
if (/^epic-/.test(entry.name) && !/^epic-\d{3}-/.test(entry.name)) {
|
|
26
|
+
errors.push(
|
|
27
|
+
`roadmap/${entry.name}: nadie lo lee. Una épica se nombra epic-NNN-<slug>.md, `
|
|
28
|
+
+ 'o un directorio epic-NNN-<slug>/ con spec.md adentro.',
|
|
29
|
+
)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (!entry.isDirectory() || !/^epic-\d{3}-/.test(entry.name)) continue
|
|
33
|
+
const epicDir = path.join(roadmap, entry.name)
|
|
34
|
+
if (!fs.existsSync(path.join(epicDir, 'spec.md'))) {
|
|
35
|
+
errors.push(`roadmap/${entry.name}: falta spec.md`)
|
|
36
|
+
}
|
|
37
|
+
for (const child of fs.readdirSync(epicDir, { withFileTypes: true })) {
|
|
38
|
+
if (!child.isFile() || !EPIC_AUXILIARY_FILES.has(child.name)) {
|
|
39
|
+
errors.push(`roadmap/${entry.name}/${child.name}: archivo auxiliar no permitido`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return errors
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// El BACKLOG es la única cola, y su lector descarta en silencio lo que no cumple el contrato: una
|
|
47
|
+
// viñeta mal escrita no está en cola, no aparece en `tree` y no la toma nadie, sin que nada falle.
|
|
48
|
+
// Se juzga sólo lo que vive bajo un hito —el encabezado del archivo es prosa— y sólo las viñetas,
|
|
49
|
+
// para no confundir con un error el texto que acompaña a una tarea.
|
|
50
|
+
function validateBacklogStructure(dir) {
|
|
51
|
+
const text = P.withoutComments(P.read(path.join(dir, 'BACKLOG.md')))
|
|
52
|
+
const errors = []
|
|
53
|
+
let milestone = ''
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
const heading = line.match(P.MILESTONE_HEADING)
|
|
56
|
+
if (heading) { milestone = heading[1]; continue }
|
|
57
|
+
if (/^##\s+Hito\b/.test(line)) {
|
|
58
|
+
errors.push(`BACKLOG "${line.trim()}": encabezado inválido; se escribe ## Hito <slug> — <Título>, `
|
|
59
|
+
+ 'y sin él las tareas que vienen abajo quedan huérfanas')
|
|
60
|
+
milestone = ''
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (/^##\s+/.test(line)) { milestone = ''; continue }
|
|
64
|
+
if (!milestone || !/^\s*[-*]\s+\S/.test(line) || P.TASK_LINE.test(line)) continue
|
|
65
|
+
const lane = line.match(P.TASK_LINE_ANY_LANE)
|
|
66
|
+
if (lane) {
|
|
67
|
+
errors.push(`BACKLOG ${lane[1].trim()}: lane "${lane[2]}" no existe; usá ${P.LANES.join(' | ')}, `
|
|
68
|
+
+ 'o dejá la tarea sin clasificar')
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const at = `BACKLOG hito ${milestone}: no la lee nadie`
|
|
72
|
+
if (/^-\s+\[[xX]\]/.test(line)) {
|
|
73
|
+
errors.push(`${at} — ${line.trim().slice(0, 60)}. Una tarea terminada se mueve a DONE.md, no se tilda acá.`)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
errors.push(`${at} — ${line.trim().slice(0, 60)}. Una tarea se escribe `
|
|
77
|
+
+ '`- [ ] **slug** [lane] — descripción`, con `(→ CN) (epic: NNN)` o `_Aceptación:_` después del guión.')
|
|
78
|
+
}
|
|
79
|
+
return errors
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// El número de una regla es su identificador, y lo cita todo el sistema: cargos, workflows, plantillas
|
|
83
|
+
// y entradas de DONE. El override se declara escribiendo un archivo con el mismo nombre que el del
|
|
84
|
+
// sistema —ahí redefinir sus números es el punto—; en cualquier otro archivo, reusar un `R` crea una
|
|
85
|
+
// segunda definición que nadie declaró y que ninguna herramienta veía. Las propias se numeran `P`.
|
|
86
|
+
function ruleIds(file) {
|
|
87
|
+
return [...P.read(file).matchAll(/^##\s+([A-Z]\d+)\s+[—-]/gm)].map((match) => match[1])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Qué IDs deja de regir un override por nombre: los que definía el archivo del sistema y el propio no
|
|
91
|
+
// redefine. Reemplazar el archivo entero es la función del override y está documentada; lo que no se
|
|
92
|
+
// veía es la consecuencia, porque la advertencia nombraba el par de archivos y no la diferencia. El
|
|
93
|
+
// caso caro es una regla que el motor sigue exigiendo —R17 lo hace—: queda exigida y sin estar escrita
|
|
94
|
+
// en ningún lado, y quien la vea fallar la va a buscar en `rules/`, donde ya no está.
|
|
95
|
+
// Dos secciones de una épica que compiten por el mismo rol. El parser prefiere la exacta, así que
|
|
96
|
+
// resuelve —y en silencio: quien escribió las dos no se entera de que una se ignora entera. La
|
|
97
|
+
// promoción dejó de generarlas cuando lo importado empezó a bajar un nivel; a mano se siguen pudiendo
|
|
98
|
+
// escribir, y ahí el aviso es lo único que lo dice.
|
|
99
|
+
function competingSections(dir) {
|
|
100
|
+
const roadmap = path.join(dir, 'roadmap')
|
|
101
|
+
const avisos = []
|
|
102
|
+
let files = []
|
|
103
|
+
try { files = fs.readdirSync(roadmap).filter((file) => /^epic-\d{3}-/.test(file)) } catch { return [] }
|
|
104
|
+
for (const file of files.sort()) {
|
|
105
|
+
const text = P.read(path.join(roadmap, file))
|
|
106
|
+
const titles = [...text.matchAll(/^##\s+(.+)$/gm)].map((hit) => hit[1].trim())
|
|
107
|
+
for (const role of [/Criterios/i, /Historias/i]) {
|
|
108
|
+
const casan = titles.filter((title) => role.test(title))
|
|
109
|
+
if (casan.length < 2) continue
|
|
110
|
+
const exact = new RegExp(`^${role.source}$`, role.flags)
|
|
111
|
+
const gana = casan.find((title) => exact.test(title)) || casan[0]
|
|
112
|
+
const ignoradas = casan.filter((title) => title !== gana)
|
|
113
|
+
avisos.push(`roadmap/${file}: "## ${gana}" convive con "## ${ignoradas.join('", "## ')}"; `
|
|
114
|
+
+ 'sólo se lee la primera y el resto se ignora entero')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return avisos
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function retiredByOverride(dir, name) {
|
|
121
|
+
const rules = path.join(dir, 'rules')
|
|
122
|
+
const system = path.join(rules, 'system', name)
|
|
123
|
+
if (!fs.existsSync(system)) return []
|
|
124
|
+
const redefined = ruleIds(path.join(rules, name))
|
|
125
|
+
return ruleIds(system).filter((id) => !redefined.includes(id))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateRules(dir) {
|
|
129
|
+
const rules = path.join(dir, 'rules')
|
|
130
|
+
const owner = new Map()
|
|
131
|
+
const errors = []
|
|
132
|
+
const files = (sub) => {
|
|
133
|
+
try {
|
|
134
|
+
return fs.readdirSync(path.join(rules, sub), { withFileTypes: true })
|
|
135
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md')
|
|
136
|
+
.map((entry) => entry.name).sort()
|
|
137
|
+
} catch { return [] }
|
|
138
|
+
}
|
|
139
|
+
const system = new Set(files('system'))
|
|
140
|
+
for (const name of system) {
|
|
141
|
+
for (const id of ruleIds(path.join(rules, 'system', name))) {
|
|
142
|
+
if (owner.has(id)) errors.push(`rules/system/${name}: ${id} ya lo define ${owner.get(id)}`)
|
|
143
|
+
else owner.set(id, `rules/system/${name}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const name of files('')) {
|
|
147
|
+
// El override se declara por nombre: redefinir los números del archivo que reemplaza es su función.
|
|
148
|
+
if (system.has(name)) continue
|
|
149
|
+
for (const id of ruleIds(path.join(rules, name))) {
|
|
150
|
+
const definedBy = owner.get(id)
|
|
151
|
+
if (!definedBy) { owner.set(id, `rules/${name}`); continue }
|
|
152
|
+
errors.push(definedBy.startsWith('rules/system/')
|
|
153
|
+
? `rules/${name}: ${id} ya lo define ${definedBy}; una regla propia se numera P1..Pn, `
|
|
154
|
+
+ 'o vive en un archivo con el mismo nombre para declarar el override'
|
|
155
|
+
: `rules/${name}: ${id} ya lo define ${definedBy}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return errors
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Una decisión que no dice si rige no decide nada, y el molde traía el menú entero en la línea de estado:
|
|
162
|
+
// casi una de cada cinco decisiones escritas con este modelo se publicó con el menú intacto. Presentar
|
|
163
|
+
// las opciones no obliga a elegir; esto sí. Las secciones son las cuatro que se escriben siempre —las
|
|
164
|
+
// alternativas quedan en el molde sin exigirse, porque pedirlas rechazaría a casi todas las que existen—.
|
|
165
|
+
const ADR_STATES = ['Propuesto', 'Aceptado', 'Obsoleto']
|
|
166
|
+
const ADR_SUPERSEDED = /^Reemplazada por \[[^\]]+\]\([^)]+\)(?: \(\d{4}-\d{2}-\d{2}\))?$/
|
|
167
|
+
const ADR_SECTIONS = ['Contexto', 'Decisión', 'Consecuencias', 'Estado de implementación']
|
|
168
|
+
|
|
169
|
+
function validateAdrFile(at, text) {
|
|
170
|
+
const errors = []
|
|
171
|
+
const declared = ((text.match(/^\*\*Estado:\*\*\s*(.+?)\s*$/m) || [])[1] || '').trim()
|
|
172
|
+
if (!declared) errors.push(`${at}: falta **Estado:**`)
|
|
173
|
+
else if (declared.includes('|')) errors.push(`${at}: el estado sigue siendo el menú de la plantilla; elegí uno`)
|
|
174
|
+
else if (!ADR_STATES.includes(declared) && !ADR_SUPERSEDED.test(declared)) {
|
|
175
|
+
errors.push(`${at}: estado "${declared}" fuera de ${ADR_STATES.join(' | ')} `
|
|
176
|
+
+ '| Reemplazada por [NNN](NNN-slug.md)')
|
|
177
|
+
}
|
|
178
|
+
for (const section of ADR_SECTIONS) {
|
|
179
|
+
if (!new RegExp(`^##\\s+${section}\\s*$`, 'm').test(text)) errors.push(`${at}: falta ## ${section}`)
|
|
180
|
+
}
|
|
181
|
+
return errors
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// El nombre lleva el id porque de ahí sale la identidad con que se detecta un override, y porque una
|
|
185
|
+
// decisión se cita por número. Sin él, el archivo existe y no lo alcanza ninguna referencia.
|
|
186
|
+
function validateAdr(dir) {
|
|
187
|
+
const adr = path.join(dir, 'adr')
|
|
188
|
+
const errors = []
|
|
189
|
+
const numbers = new Map()
|
|
190
|
+
const scan = (sub, pattern) => {
|
|
191
|
+
let entries = []
|
|
192
|
+
try { entries = fs.readdirSync(path.join(adr, sub), { withFileTypes: true }) } catch { return }
|
|
193
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
194
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
195
|
+
if (entry.name === 'README.md' || entry.name === '000-template.md') continue
|
|
196
|
+
const at = `adr/${sub ? `${sub}/` : ''}${entry.name}`
|
|
197
|
+
const id = entry.name.match(pattern)
|
|
198
|
+
if (!id) {
|
|
199
|
+
errors.push(`${at}: nadie lo lee como decisión. Una ADR se nombra NNN-<slug>.md, `
|
|
200
|
+
+ 'y la del sistema <ID>-NNN-<slug>.md en system/.')
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (numbers.has(id[1])) errors.push(`${at}: ${id[1]} ya lo usa ${numbers.get(id[1])}`)
|
|
204
|
+
else numbers.set(id[1], at)
|
|
205
|
+
errors.push(...validateAdrFile(at, P.read(path.join(adr, sub, entry.name))))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
scan('', /^(\d{3})-[a-z0-9-]+\.md$/)
|
|
209
|
+
scan('system', /^([A-Z][A-Z0-9]*-\d{3})-[a-z0-9-]+\.md$/)
|
|
210
|
+
return errors
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
validateRoadmapStructure,
|
|
215
|
+
validateBacklogStructure,
|
|
216
|
+
competingSections,
|
|
217
|
+
retiredByOverride,
|
|
218
|
+
validateRules,
|
|
219
|
+
validateAdr,
|
|
220
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Cómo mergea git lo que el equipo escribe a la vez.
|
|
2
|
+
#
|
|
3
|
+
# `union` concatena los dos lados en vez de marcar conflicto. Vale para un archivo que sólo crece por el
|
|
4
|
+
# final, que es lo que hacen estos dos cuando dos personas registran un bloqueo el mismo día: las dos
|
|
5
|
+
# filas son buenas y van las dos, así que el conflicto no significa nada y hay que resolverlo igual.
|
|
6
|
+
#
|
|
7
|
+
# La evidencia de una tarea cerrada no está acá porque no la necesita: vive en su propio `done/<slug>.md`
|
|
8
|
+
# y nadie escribe el archivo de nadie.
|
|
9
|
+
#
|
|
10
|
+
# El borde: `ops archive human-actions` **quita** filas de la tabla activa al mover las resueltas al
|
|
11
|
+
# histórico, y eso no es crecer por el final. Archivar mientras otro registra un bloqueo puede devolver a
|
|
12
|
+
# la vida una fila resuelta. Es un acto raro y deliberado: se hace con el árbol limpio.
|
|
13
|
+
planning/HUMAN_ACTIONS.md merge=union
|
|
14
|
+
planning/done/human-actions.md merge=union
|
|
15
|
+
|
|
16
|
+
# INBOX, BACKLOG y RECURRING quedan afuera a propósito. En los tres, quitar una línea es una operación
|
|
17
|
+
# normal y frecuente —promover una idea, sacar una tarea de la cola, retirar una recurrencia— y union
|
|
18
|
+
# la desharía sin que nada lo dijera. Ahí el conflicto es la respuesta correcta: significa que dos
|
|
19
|
+
# personas decidieron sobre lo mismo, y eso se lee.
|