@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +13 -6
  3. package/automatization/AGENTS.md +1 -1
  4. package/automatization/runners/antigravity/rules/cauce.md +1 -1
  5. package/automatization/runners/claude/CLAUDE.md +1 -1
  6. package/automatization/runners/codex/AGENTS.md +1 -1
  7. package/automatization/runners/gemini/GEMINI.md +1 -1
  8. package/automatization/shared/skills/autobuild/SKILL.md +1 -1
  9. package/automatization/workflows/autobuild.js +44 -8
  10. package/engine/cli/archive.js +87 -0
  11. package/engine/cli/args.js +7 -2
  12. package/engine/cli/catalog.js +9 -1
  13. package/engine/cli/claims.js +125 -0
  14. package/engine/cli/ops.js +17 -4
  15. package/engine/cli/planning.js +158 -102
  16. package/engine/cli/worktree.js +89 -0
  17. package/engine/core/ownership.js +16 -5
  18. package/engine/core/repos.js +67 -0
  19. package/engine/hooks/files.js +3 -2
  20. package/engine/planning/adoption.js +1 -1
  21. package/engine/planning/claims.js +153 -0
  22. package/engine/planning/contracts.js +43 -202
  23. package/engine/planning/parser.js +89 -33
  24. package/engine/planning/recurring.js +148 -0
  25. package/engine/planning/state.js +39 -6
  26. package/engine/planning/structure.js +220 -0
  27. package/package.json +1 -1
  28. package/template/.gitattributes +19 -0
  29. package/template/AGENTS.md +54 -5
  30. package/template/Makefile +4 -1
  31. package/template/automatization/AGENTS.md +1 -1
  32. package/template/gitignore +9 -0
  33. package/template/planning/BACKLOG.md +5 -0
  34. package/template/planning/FLOW.md +3 -1
  35. package/template/planning/PROTOCOL.md +25 -8
  36. package/template/planning/README.md +4 -3
  37. package/template/planning/RECURRING.md +77 -0
  38. package/template/planning/adr/system/OPS-001-planificacion-como-fuente-de-verdad.md +3 -2
  39. package/template/planning/business-rules/system/BR-OPS-001-una-sola-tarea-activa.md +7 -4
  40. package/template/planning/business-rules/system/BR-OPS-005-una-tarea-un-runner.md +44 -0
  41. package/template/planning/claims/README.md +70 -0
  42. package/template/planning/delivery/README.md +1 -0
  43. package/template/planning/delivery/multi-repo.md +11 -0
  44. package/template/planning/delivery/teamwork.md +162 -0
  45. package/template/planning/done/README.md +41 -0
  46. package/template/planning/wip/README.md +49 -0
  47. package/template/planning/DONE.md +0 -13
  48. package/template/planning/WIP.md +0 -22
package/engine/cli/ops.js CHANGED
@@ -9,6 +9,9 @@ const { FLAGS, parse } = require('./args')
9
9
  const { fail } = require('./io')
10
10
  const IN = require('./instance')
11
11
  const PL = require('./planning')
12
+ const AR = require('./archive')
13
+ const CLM = require('./claims')
14
+ const WT = require('./worktree')
12
15
  const CAT = require('./catalog')
13
16
  const W = require('./wiring')
14
17
  const BOOT = require('./bootstrap')
@@ -142,11 +145,16 @@ function usage() {
142
145
  ops onboard [ops-root] [--json]
143
146
  ops check <planning-dir> [--json]
144
147
  ops tree <planning-dir> [--no-color] [--json]
145
- ops context <planning-dir> [--json]
148
+ ops context <planning-dir> [--hito <slug>] [--json]
149
+ ops recurring <planning-dir> [--promote <qué>] [--json]
150
+ ops runners <planning-dir> [--json]
151
+ ops claim <planning-dir> <tarea>
152
+ ops release <planning-dir> <tarea>
153
+ ops worktree <planning-dir> <tarea> [--json]
146
154
  ops evidence <planning-dir> [--task <slug>] [--json]
147
155
  ops upgrade <ops-root> [--check] [--force]
148
156
  ops destroy <ops-root> [--force]
149
- ops archive <planning-dir> <NNN|human-actions>
157
+ ops archive <planning-dir> human-actions
150
158
  ops adopt <planning-dir>
151
159
  ops integration list <ops-root>
152
160
  ops integration enable <ops-root> <provider>
@@ -196,12 +204,17 @@ async function run(cli) {
196
204
  else if (command === 'check') PL.check(arg[1], cli)
197
205
  else if (command === 'tree') PL.tree(arg[1], cli)
198
206
  else if (command === 'context') PL.context(arg[1], cli)
207
+ else if (command === 'recurring') PL.recurring(arg[1], cli)
208
+ else if (command === 'runners') CLM.runners(arg[1], cli)
209
+ else if (command === 'claim') CLM.claim(arg[1], arg[2], cli)
210
+ else if (command === 'release') CLM.release(arg[1], arg[2])
211
+ else if (command === 'worktree') WT.worktree(arg[1], arg[2], cli)
199
212
  else if (command === 'evidence') PL.evidence(arg[1], cli)
200
213
  else if (command === 'upgrade') IN.upgrade(arg[1], cli)
201
214
  else if (command === 'destroy') IN.destroy(arg[1], cli)
202
215
  else if (command === 'agents') CAT.agents(arg[1], arg[2], arg[3], cli)
203
- else if (command === 'archive') PL.archive(arg[1], arg[2])
204
- else if (command === 'adopt') PL.adopt(arg[1])
216
+ else if (command === 'archive') AR.archive(arg[1], arg[2])
217
+ else if (command === 'adopt') AR.adopt(arg[1])
205
218
  else if (command === 'integration') {
206
219
  await W.integration(arg[1], arg[2], arg[3], arg[4], cli)
207
220
  }
@@ -8,7 +8,11 @@ const path = require('node:path')
8
8
  const P = require('../planning/parser')
9
9
  const B = require('../planning/business-rules')
10
10
  const PC = require('../planning/contracts')
11
+ const SR = require('../planning/structure')
11
12
  const SZ = require('../planning/sizing')
13
+ const RC = require('../planning/recurring')
14
+ const CL = require('../planning/claims')
15
+ const R = require('../core/repos')
12
16
  const ST = require('../planning/state')
13
17
  const AD = require('../planning/adoption')
14
18
  const AP = require('../hooks/approval')
@@ -19,7 +23,6 @@ const OB = require('../core/onboarding')
19
23
  const C = require('../config/validate')
20
24
  const CP = require('../config/paths')
21
25
  const AG = require('../agents/catalog')
22
- const F = require('../core/files')
23
26
  const { fail } = require('./io')
24
27
 
25
28
  // Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
@@ -37,7 +40,9 @@ function evidence(dir, cli) {
37
40
  const opsDir = path.join(root, '..')
38
41
  const entries = P.readDone(root).entries
39
42
  const slug = cli.value('--task')
40
- const entry = slug ? entries.find((one) => one.slug === slug) : entries[entries.length - 1]
43
+ // Sin `--task`, la más reciente, y la decide `fecha:` — por qué ese campo existe lo dice el contrato.
44
+ const reciente = [...entries].sort((a, b) => (a.fecha || '').localeCompare(b.fecha || '')).pop()
45
+ const entry = slug ? entries.find((one) => one.slug === slug) : reciente
41
46
  if (!entry) return fail(slug ? `DONE no tiene la entrada ${slug}` : 'DONE no tiene ninguna entrada', 2)
42
47
 
43
48
  let config = {}
@@ -66,11 +71,29 @@ function evidence(dir, cli) {
66
71
  + 'que la prueba nombrada haya corrido: eso depende del runner, y varios no la nombran al pasar.')
67
72
  }
68
73
 
74
+ // La fecha de hoy, en un solo lugar: los comandos que la usan tienen que estar mirando el mismo día, y
75
+ // el módulo que calcula vencimientos la recibe en vez de preguntarla.
76
+ const TODAY = () => new Date().toISOString().slice(0, 10)
77
+
69
78
  function check(dir, cli) {
70
79
  const root = path.resolve(dir || '.')
71
80
  const errors = []
72
81
  const warnings = []
73
- const required = ['BACKLOG.md', 'WIP.md', 'DONE.md', 'INBOX.md', 'HUMAN_ACTIONS.md', 'PROTOCOL.md']
82
+ // El plan no está: `wip/` es local y gitignoreado, así que un clon nuevo no lo trae y eso no es un
83
+ // error. Ausente se lee como IDLE, que es lo que significa.
84
+ const required = ['BACKLOG.md', 'INBOX.md', 'HUMAN_ACTIONS.md', 'PROTOCOL.md']
85
+ // `DONE.md` se retiró: la evidencia vive en un archivo por tarea. Un `DONE.md` que quede en disco
86
+ // ya no lo lee nadie, y eso no se nota — las épicas dejan de poder cerrar y sus historias figuran
87
+ // sin evidencia, que es lo mismo que se vería si nunca se hubieran hecho.
88
+ // `WIP.md` se retiró por lo mismo que `DONE.md`: era uno solo y lo escribían todos los que corren sobre
89
+ // una instancia sidecar. Uno que quede en disco ya no lo lee nadie, y su plan a medias se pierde sin
90
+ // que nada lo diga.
91
+ if (fs.existsSync(path.join(root, 'WIP.md'))) {
92
+ errors.push('WIP.md ya no se lee: el plan de cada runner vive en `wip/<runner>.md`; movelo y borralo')
93
+ }
94
+ if (fs.existsSync(path.join(root, 'DONE.md'))) {
95
+ errors.push('DONE.md ya no se lee: pasá cada entrada a su propio `done/<slug>.md` y borralo')
96
+ }
74
97
  for (const file of required) if (!fs.existsSync(path.join(root, file))) errors.push(`falta ${file}`)
75
98
 
76
99
  const configPath = path.join(root, '..', 'ops.config.json')
@@ -108,23 +131,42 @@ function check(dir, cli) {
108
131
  const milestones = P.readBacklog(root)
109
132
  const done = P.readDone(root)
110
133
  errors.push(...B.validate(path.join(root, 'business-rules')))
111
- errors.push(...PC.validateRoadmapStructure(root))
112
- errors.push(...PC.validateBacklogStructure(root))
113
- errors.push(...PC.validateRules(root))
114
- errors.push(...PC.validateAdr(root))
134
+ errors.push(...SR.validateRoadmapStructure(root))
135
+ errors.push(...SR.validateBacklogStructure(root))
136
+ errors.push(...SR.validateRules(root))
137
+ errors.push(...SR.validateAdr(root))
115
138
  const backlog = milestones.flatMap((milestone) => milestone.tasks)
116
139
  const backlogSlugs = new Set(backlog.map((task) => task.slug))
117
140
  const epicNums = new Set()
118
141
  const storySlugs = new Set()
119
142
 
120
143
  const roles = new Set(AG.list(path.resolve(root, '..')).map((role) => role.slug))
121
- const wip = P.readWip(root)
144
+ const wips = P.readWips(root)
122
145
  const adopted = AD.read(root)
123
146
  errors.push(...SZ.oversizedUnits({ epics, milestones }))
124
147
  errors.push(...PC.validateState({
125
- epics, milestones, done, wip, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
148
+ epics, milestones, done, wips, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
126
149
  }))
127
150
  warnings.push(...AD.report({ done, epics, adopted }))
151
+ // Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
152
+ // no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
153
+ // en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
154
+ // Un reclamo que nombra una tarea que no existe bloquea la cola sin que nada lo explique, y uno viejo
155
+ // la bloquea para siempre. Lo primero es error; lo segundo avisa, porque abandonar no es un defecto.
156
+ const claims = CL.read(root)
157
+ errors.push(...CL.validate({ claims, milestones, done }))
158
+ // Si la rama de cada tarea tomada se movió, que es lo único barato que distingue una tarea larga de
159
+ // una abandonada. Sin repositorio resoluble el mapa queda vacío y el aviso vuelve a mirar sólo la
160
+ // fecha, que es lo que había antes: degrada, no rompe.
161
+ const activity = new Map()
162
+ for (const claim of claims.filter((one) => !done.set.has(one.slug))) {
163
+ const at = R.lastCommit(R.repoOf(path.join(root, '..'), claim.service), CL.branchOf(claim.slug))
164
+ if (at) activity.set(claim.slug, at)
165
+ }
166
+ warnings.push(...CL.warnings({ claims, done, today: TODAY(), activity }))
167
+ const recurring = RC.read(root)
168
+ errors.push(...RC.validate(recurring))
169
+ warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
128
170
  warnings.push(...AD.sealWarnings(root))
129
171
  // Una aprobación vale para el conjunto que nombra, así que olvidada sigue autorizando
130
172
  // esas mismas rutas la próxima vez que alguien las stagee. No caduca sola: lo que la cierra es que se
@@ -157,14 +199,14 @@ function check(dir, cli) {
157
199
  errors.push(...integration.errors)
158
200
  warnings.push(...integration.warnings)
159
201
 
160
- warnings.push(...PC.competingSections(root))
202
+ warnings.push(...SR.competingSections(root))
161
203
  // Sobrescribir una entrada de system/ es legítimo y esperado; lo que no puede pasar es que
162
204
  // ocurra en silencio, porque esa entrada deja de recibir las mejoras del toolkit.
163
205
  for (const override of O.overrides(path.resolve(root, '..'))) {
164
206
  // Y con qué se queda el proyecto: un override sano redefine lo que reemplaza, y el que deja IDs
165
207
  // afuera los retira sin decirlo. Nombrarlos es lo único que separa una decisión de un descuido.
166
208
  const retired = override.collection === 'planning/rules'
167
- ? PC.retiredByOverride(root, override.project)
209
+ ? SR.retiredByOverride(root, override.project)
168
210
  : []
169
211
  warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} `
170
212
  + `(override explícito)${retired.length ? `; deja de regir ${retired.join(', ')}` : ''}`)
@@ -182,7 +224,7 @@ function check(dir, cli) {
182
224
  epics: epics.length,
183
225
  queued: backlog.length,
184
226
  done: done.entries.length,
185
- wip: wip ? wip.task : null,
227
+ wips: wips.map((one) => one.task),
186
228
  errors,
187
229
  warnings,
188
230
  }))
@@ -200,7 +242,7 @@ function check(dir, cli) {
200
242
  }
201
243
 
202
244
  // Estado observable de planning sin mutar nada; base común de `tree` y de sus salidas.
203
- function treeJson({ epics, milestones, done, wip, inbox, queued }) {
245
+ function treeJson({ epics, milestones, done, wips, inbox, queued, claims }) {
204
246
  const state = (slug) => done.set.has(slug) ? 'done' : queued.has(slug) ? 'queued' : 'pending'
205
247
  console.log(JSON.stringify({
206
248
  roadmap: epics.map((epic) => ({
@@ -213,8 +255,11 @@ function treeJson({ epics, milestones, done, wip, inbox, queued }) {
213
255
  slug: milestone.slug,
214
256
  tasks: milestone.tasks.map((task) => ({ slug: task.slug, tier: task.tier || '' })),
215
257
  })),
216
- wip: wip ? { task: wip.task, phase: wip.phase, complete: wip.complete, pending: wip.pending } : null,
258
+ wip: wips.map((one) => ({
259
+ task: one.task, runner: one.runner, phase: one.phase, complete: one.complete, pending: one.pending,
260
+ })),
217
261
  inbox: { deuda: inbox.deuda, ideas: inbox.ideas, propuestas: inbox.propuestas, lecciones: inbox.lecciones },
262
+ claims: claims.map((one) => ({ slug: one.slug, owner: one.owner, started: one.started })),
218
263
  done: done.entries.length,
219
264
  }))
220
265
  }
@@ -223,7 +268,7 @@ function tree(dir, cli) {
223
268
  const root = path.resolve(dir || '.')
224
269
  const state = ST.snapshot(root)
225
270
  if (cli.has('--json')) return treeJson(state)
226
- const { epics, milestones, done, wip, inbox, queued } = state
271
+ const { epics, milestones, done, wips, inbox, queued, claims } = state
227
272
  const color = process.stdout.isTTY && !cli.has('--no-color')
228
273
  const paint = (code, text) => color ? `\x1b[${code}m${text}\x1b[0m` : text
229
274
  console.log(`\n${paint('1', 'CAUCE')}\n`)
@@ -243,8 +288,8 @@ function tree(dir, cli) {
243
288
  console.log(` ${milestone.heading}`)
244
289
  for (const task of milestone.tasks) console.log(` ☐ ${task.slug}${task.tier ? ` [${task.tier}]` : ''}`)
245
290
  }
246
- const wipText = wip
247
- ? `▶ ${wip.task} · ${wip.phase} · ${wip.complete}✓/${wip.pending}○`
291
+ const wipText = wips.length
292
+ ? wips.map((one) => `▶ ${one.task} · ${one.phase} · ${one.complete}✓/${one.pending}○ (${one.runner})`).join(' ')
248
293
  : 'idle'
249
294
  console.log(`\n${paint('1', 'WIP')} ${wipText}`)
250
295
  console.log(
@@ -253,6 +298,9 @@ function tree(dir, cli) {
253
298
  // Sin esto, doce viñetas sin nombre se veían como un inbox vacío y nadie se enteraba.
254
299
  (inbox.skipped ? ` (${inbox.skipped} sin contar: falta el nombre en **negrita**)` : ''),
255
300
  )
301
+ if (claims.length) {
302
+ console.log(`${paint('1', 'CLAIM')} ${claims.map((one) => `${one.slug} · ${one.owner}`).join(' ')}`)
303
+ }
256
304
  console.log(`${paint('1', 'DONE')} ${done.entries.length} tareas\n`)
257
305
  }
258
306
 
@@ -260,9 +308,32 @@ function tree(dir, cli) {
260
308
  function context(dir, cli) {
261
309
  const root = path.resolve(dir || '.')
262
310
  const state = ST.snapshot(root)
311
+ // Acotar la cola a un hito es como un equipo se reparte trabajo sin coordinarse: dos personas en hitos
312
+ // distintos casi nunca dependen entre sí ni tocan los mismos archivos. Lo que se acota es qué se
313
+ // ofrece, no qué se sabe: `done` sigue siendo global, así que una dependencia que vive en otro hito se
314
+ // juzga igual de bien.
315
+ const from = CL.runner()
316
+ const mio = state.wips.find((one) => one.runner === P.wipName(from)) || null
317
+ const hito = cli.value('--hito')
318
+ // Un filtro elige dónde buscar trabajo **nuevo**; no puede esconder el que ya tenés. Sin esto, pedir
319
+ // otro hito mientras sostenías una tarea ofrecía una segunda que `claim` después se niega a dar: el
320
+ // comando que dice qué hacer y el que lo autoriza contestaban distinto, y sólo se veía al reclamar.
321
+ const propio = state.claims.find((one) => one.runner === from && !state.done.set.has(one.slug))
322
+ let hitoOmitido = ''
323
+ if (hito) {
324
+ const existe = state.milestones.some((one) => one.slug === hito)
325
+ // Un hito mal escrito devolvería «sin tarea disponible», que es indistinguible de un hito terminado.
326
+ if (!existe) {
327
+ const hay = state.milestones.map((one) => one.slug).join(', ') || '(ninguno)'
328
+ return fail(`el hito ${hito} no existe. Hay: ${hay}`, 2)
329
+ }
330
+ if (propio) hitoOmitido = `${hito} no se aplica: ya tenés ${propio.slug} tomada`
331
+ else state.milestones = state.milestones.filter((one) => one.slug === hito)
332
+ }
263
333
  const gate = path.join(root, 'AWAITING_REVIEW.md')
264
334
  const humanActions = ST.pendingHumanActions(root)
265
- const { task, skipped } = ST.currentTask(state, humanActions)
335
+ const me = CL.owner(root)
336
+ const { task, skipped, claimed, taken, waiting } = ST.currentTask(state, humanActions, from)
266
337
  const epic = task ? state.epics.find((candidate) => candidate.num === task.epic) : null
267
338
  const criteria = epic ? epic.criteria.filter((criterion) => task.criteria.includes(criterion.id)) : []
268
339
  const report = {
@@ -280,10 +351,27 @@ function context(dir, cli) {
280
351
  // un criterio de cumplir su letra. Viaja acá porque el ejecutor tiene prohibido ir a buscarlo:
281
352
  // `autobuild` le dice que lea cuatro archivos una sola vez y nada más, y el roadmap no es ninguno.
282
353
  epic: epic ? { num: epic.num, title: epic.title, status: epic.status, context: epic.context } : null,
283
- wip: state.wip ? { phase: state.wip.phase, complete: state.wip.complete, pending: state.wip.pending } : null,
354
+ // El WIP que este runner tiene, no el de la instancia: es lo único que le corresponde continuar.
355
+ wip: mio ? { phase: mio.phase, complete: mio.complete, pending: mio.pending } : null,
356
+ // Dónde va su plan. Lo dice el motor porque el nombre sale del id del runner, y quien escribe el
357
+ // plan —un workflow— no tiene por qué saber cómo se deriva.
358
+ wipFile: `wip/${P.wipName(from)}.md`,
284
359
  queued: state.milestones.reduce((total, milestone) => total + milestone.tasks.length, 0),
285
360
  blockedTasks: skipped,
286
361
  humanActions,
362
+ owner: me,
363
+ // El día de hoy, para quien no tiene reloj. Un workflow no puede llamar a `new Date` —una puerta se
364
+ // lo impide, porque su salida dejaría de ser reproducible— y necesita la fecha para cerrar una tarea.
365
+ // Sale de acá y no del modelo: es un dato mecánico, y pedírselo a un agente es invitarlo a inventarlo.
366
+ today: TODAY(),
367
+ claimed,
368
+ taken,
369
+ waiting,
370
+ // Sólo las vencidas: la fila que todavía no vence no tiene nada que decirle a quien va a tomar una
371
+ // tarea, y una recurrencia que hablara siempre sería ruido en el único comando que se corre en cada
372
+ // vuelta. Que aparezca es la señal.
373
+ recurring: RC.status({ ...RC.read(root), done: state.done, today: TODAY() })
374
+ .filter((one) => one.overdue),
287
375
  }
288
376
  if (cli.has('--json')) return console.log(JSON.stringify(report))
289
377
 
@@ -299,9 +387,28 @@ function context(dir, cli) {
299
387
  // que una instancia recién arrancada —`onboard` deja filas pendientes y ninguna tarea todavía—
300
388
  // respondía «sin tarea disponible» y se tragaba las siete cosas que una persona tenía que desbloquear.
301
389
  // Es el comando que existe para decir qué toca ahora, contestando «nada» cuando lo que toca es eso.
390
+ // Tu propio nombre en una tarea «ajena» es la señal de que sos vos desde otro runner, y sin decirlo se
391
+ // lee como que alguien te ganó la tarea.
392
+ const dueño = (one) => (one.owner === report.owner ? `${one.owner} — vos, desde otro runner` : one.owner)
393
+ const espera = () => {
394
+ for (const one of report.waiting) {
395
+ console.log(`WAIT ${one.slug}: espera a ${one.dep}${one.owner ? ` (${one.owner})` : ''}`)
396
+ }
397
+ }
398
+ const due = () => {
399
+ for (const one of report.recurring) {
400
+ const when = one.overdueDays === 0 ? 'vence hoy' : `vencida hace ${one.overdueDays} día(s)`
401
+ console.log(`DUE ${one.id}: ${when}`)
402
+ }
403
+ }
302
404
  if (!report.task) {
303
405
  console.log('TASK (sin tarea disponible)')
406
+ // Mismo motivo que `blocked` arriba, con otra causa: acá la cola no la traba una persona, la tiene
407
+ // el equipo, y lo que corresponde es hablar con quien la tiene.
408
+ for (const one of report.taken) console.log(`TAKEN ${one.slug} (${dueño(one)})`)
409
+ espera()
304
410
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
411
+ due()
305
412
  return
306
413
  }
307
414
  console.log(`TASK ${report.task.slug}${report.task.tier ? ` [${report.task.tier}]` : ''}` +
@@ -322,95 +429,44 @@ function context(dir, cli) {
322
429
  for (const criterion of criteria) console.log(`${criterion.id.padEnd(6)} ${criterion.text}`)
323
430
  const wip = report.wip ? `${report.wip.phase} · ${report.wip.complete}✓/${report.wip.pending}○` : 'idle'
324
431
  console.log(`WIP ${wip}`)
432
+ if (hitoOmitido) console.log(`HITO ${hitoOmitido}`)
433
+ console.log(report.claimed
434
+ ? `CLAIM tuya desde el reclamo (${report.owner})`
435
+ : `CLAIM libre — tomala con \`ops claim <planning> ${report.task.slug}\``)
436
+ for (const one of report.taken) console.log(`TAKEN ${one.slug} (${dueño(one)})`)
437
+ espera()
325
438
  if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
326
439
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
440
+ due()
327
441
  }
328
442
 
329
- // El historial de acciones humanas se acumula en un solo archivo y no por épica: una fila no pertenece
330
- // a ninguna, y esperar el cierre de una épica dejaría sin archivar las de un planning que todavía no
331
- // cerró ninguna —que es justo cuando el archivo se vuelve ilegible—.
332
- // Adoptar es declarar de una vez qué historia llegó con el proyecto. Se genera con lo que hoy no cumple
333
- // y no se vuelve a correr: un baseline que se regenera perdona de nuevo lo que alguien ya se tomó el
334
- // trabajo de arreglar, y uno que crece a mano deja de ser una lista de perdones para ser una amnistía.
335
- // Achicarlo sí es a mano, borrando el renglón que `check` señala.
336
- function adopt(dir) {
337
- const root = path.resolve(dir || '.')
338
- const target = path.join(root, AD.BASELINE)
339
- if (fs.existsSync(target)) {
340
- // Un baseline que ya trae huella no se toca: regenerarlo es exactamente lo que la huella impide.
341
- // Uno sin huella lo generó una versión anterior, y sellarlo no es regenerar nada — se calcula sobre
342
- // lo que ya está—, así que es la única salida de un aviso que si no no tendría ninguna.
343
- const existing = fs.readFileSync(target, 'utf8')
344
- if (!AD.sealWarnings(root).some((one) => /sin huella/.test(one))) {
345
- fail(`${AD.BASELINE} ya existe: se genera una vez. Para retirar un renglón, ponele \`#~\` `
346
- + 'delante; `check` marca los que ya cumplen.')
347
- }
348
- const slugs = AD.declared(existing)
349
- F.atomicWrite(target, existing.replace(/\n?$/, `\n# huella: ${slugs.length} entradas · `
350
- + `sha256:${AD.digest(slugs)}\n`))
351
- return console.log(`✓ ${AD.BASELINE} sellado con ${slugs.length} entrada(s); la lista no cambió`)
352
- }
353
- const epics = P.readEpics(root)
354
- const pending = P.readDone(root).entries.filter((entry) => PC.doneEntryErrors(entry, epics).length)
355
- if (!pending.length) {
356
- return console.log('= no hay nada que exentar: todas las entradas de DONE cumplen el contrato')
357
- }
358
- const today = new Date().toISOString().slice(0, 10)
359
- const slugs = pending.map((entry) => entry.slug)
360
- F.atomicWrite(target, `# Entradas anteriores a la adopción de Cauce (${today}). No se agregan nuevas:\n`
361
- + '# desde esa fecha rige el contrato completo, y `check` avisa cuando una de éstas pasa a\n'
362
- + '# cumplirlo para que le pongas `#~` delante y quede retirada.\n'
363
- + `# huella: ${slugs.length} entradas · sha256:${AD.digest(slugs)}\n`
364
- + `${slugs.join('\n')}\n`)
365
- console.log(`✓ ${pending.length} entrada(s) exentas en ${AD.BASELINE}`)
366
- return console.log(' revisá la lista: lo que sí cumple el contrato no tiene por qué estar ahí')
367
- }
368
-
369
- function archiveHumanActions(root) {
370
- const source = path.join(root, 'HUMAN_ACTIONS.md')
371
- const rows = P.readHumanActions(root).filter((row) => row.resolved)
372
- if (!rows.length) return console.log('= no hay filas resueltas')
373
- const target = path.join(root, 'done', 'human-actions.md')
374
- const header = '| Tarea | Estado | Origen | Acción concreta y condición de desbloqueo |\n|---|---|---|---|'
375
- const previous = P.read(target).trimEnd()
376
- const head = previous || `---\nstatus: archived\n---\n\n# Acciones humanas resueltas\n\n${header}`
377
- fs.mkdirSync(path.dirname(target), { recursive: true })
378
- F.atomicWrite(target, `${head}\n${rows.map((row) => row.raw).join('\n')}\n`)
379
- const drop = new Set(rows.map((row) => row.raw))
380
- const kept = P.read(source).split('\n').filter((line) => !drop.has(line))
381
- F.atomicWrite(source, `${kept.join('\n').trimEnd()}\n`)
382
- return console.log(`✓ ${rows.length} fila(s) archivadas`)
383
- }
384
-
385
- function archive(dir, rawNum) {
443
+ // Qué trabajo recurrente vence, y la línea con la que se promueve. Emite esa línea y no la escribe:
444
+ // `BACKLOG.md` es la cola de lo aprobado y la escribe una persona — ningún comando del motor la toca,
445
+ // ni siquiera `integration promote`, que aterriza en el roadmap. Pegarla es el acto de promoción.
446
+ function recurring(dir, cli) {
386
447
  const root = path.resolve(dir || '.')
387
- if (String(rawNum || '') === 'human-actions') return archiveHumanActions(root)
388
- const num = String(rawNum || '').padStart(3, '0')
389
- if (!/^\d{3}$/.test(num)) fail('La épica debe ser NNN, o human-actions.', 2)
390
- const epic = P.readEpics(root).find((candidate) => candidate.num === num)
391
- if (!epic) fail(`No existe epic-${num}.`, 2)
392
- if (epic.status !== 'closed') fail(`epic-${num} no está cerrada (status: ${epic.status}).`)
393
- const target = path.join(root, 'done', `epic-${num}.md`)
394
- const source = path.join(root, 'DONE.md')
395
- const content = P.read(source)
396
- const slugs = new Set(epic.stories.map((story) => story.slug))
397
- const entries = P.readDone(root).entries.filter((entry) => entry.source === 'DONE.md' && slugs.has(entry.slug))
398
- if (!entries.length) {
399
- if (fs.existsSync(target)) return console.log(`= epic-${num} ya estaba archivada`)
400
- fail(`No hay entradas de epic-${num} en DONE.md.`)
448
+ const file = RC.read(root)
449
+ if (!file.exists) return console.log(`= este planning no declara trabajo recurrente (${RC.FILE})`)
450
+ const state = RC.status({ ...file, done: P.readDone(root), today: TODAY() })
451
+ const promote = cli.value('--promote')
452
+ if (promote) {
453
+ const one = state.find((candidate) => candidate.id === promote)
454
+ if (!one) return fail(`${RC.FILE} no declara ${promote}`, 2)
455
+ // La línea sale sola por stdout para que se pueda pegar o redirigir sin recortar nada; el destino,
456
+ // que es lo único que falta decidir, va por stderr.
457
+ console.error(`Pegala en el hito que corresponda de BACKLOG.md:`)
458
+ return console.log(RC.taskLine(one, TODAY().slice(0, 7)))
401
459
  }
402
- let updated = content
403
- for (const entry of entries) updated = updated.replace(entry.raw, '').replace(/\n{3,}/g, '\n\n')
404
- fs.mkdirSync(path.dirname(target), { recursive: true })
405
- if (!fs.existsSync(target)) {
406
- F.atomicWrite(
407
- target,
408
- `---\nepic: ${num}\nstatus: archived\n---\n\n# DONE — ${epic.title}\n\n` +
409
- `${entries.map((entry) => entry.raw).join('\n\n')}\n`,
410
- )
460
+ if (cli.has('--json')) return console.log(JSON.stringify(state))
461
+ if (!state.length) return console.log(`= ${RC.FILE} no declara ninguna fila legible`)
462
+ for (const one of state) {
463
+ const when = one.overdue
464
+ ? (one.overdueDays === 0 ? 'vence hoy' : `vencida hace ${one.overdueDays} día(s)`)
465
+ : `vence ${one.due}`
466
+ const last = one.last ? `última ${one.last}` : 'nunca corrió'
467
+ const held = one.postponed ? `, postergada ${one.postponed}` : ''
468
+ console.log(`${one.overdue ? 'DUE' : 'OK '} ${one.id.padEnd(16)} ${when} (${last}${held})`)
411
469
  }
412
- F.atomicWrite(source, `${updated.trimEnd()}\n`)
413
- console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
414
470
  }
415
471
 
416
- module.exports = { check, evidence, tree, context, archive, adopt }
472
+ module.exports = { check, evidence, tree, context, recurring }
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+
3
+ // Dónde trabaja un agente. Prepara un árbol de trabajo por tarea con `git worktree`, que es git de base:
4
+ // no hace falta instalar nada, y sobre todo **no clona el repositorio**. Un worktree comparte el mismo
5
+ // `.git`, el mismo historial y los mismos objetos; lo único que materializa es un segundo directorio de
6
+ // archivos, fijado a su rama.
7
+ //
8
+ // Esa es la propiedad que importa con varios agentes: cada uno queda en su rama y **nadie hace checkout
9
+ // nunca**, que es lo que pisaría el trabajo del otro dentro de un único directorio compartido.
10
+
11
+ const fs = require('node:fs')
12
+ const path = require('node:path')
13
+ const { spawnSync } = require('node:child_process')
14
+ const ST = require('../planning/state')
15
+ const CL = require('../planning/claims')
16
+ const R = require('../core/repos')
17
+ const O = require('../core/ownership')
18
+ const { fail } = require('./io')
19
+
20
+ const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
21
+
22
+ // El árbol que ya existe para esa rama, si existe. `--porcelain` lista bloques de `worktree <ruta>` y
23
+ // `branch refs/heads/<nombre>`, y se lee así para no depender del formato humano, que cambia.
24
+ function existing(repo, branch) {
25
+ const listed = git(repo, 'worktree', 'list', '--porcelain')
26
+ if (listed.status !== 0) return ''
27
+ let current = ''
28
+ for (const line of listed.stdout.split('\n')) {
29
+ if (line.startsWith('worktree ')) current = line.slice(9).trim()
30
+ if (line.trim() === `branch refs/heads/${branch}`) return current
31
+ }
32
+ return ''
33
+ }
34
+
35
+ function worktree(dir, slug, cli) {
36
+ const root = path.resolve(dir || '.')
37
+ if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', 2)
38
+ const state = ST.snapshot(root)
39
+ const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
40
+ if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, 2)
41
+
42
+ // No reserva —eso es `claim`— pero se niega a montar sobre lo de otro: preparar un árbol para una
43
+ // tarea ajena es trabajo que se va a tirar, y el aviso cuesta menos que descubrirlo después.
44
+ const taken = state.claims.find((one) => one.slug === slug)
45
+ if (taken && taken.runner !== CL.runner()) {
46
+ return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`)
47
+ }
48
+
49
+ const candidatos = R.reposFor(path.join(root, '..'), task.service)
50
+ if (candidatos.length > 1) {
51
+ return fail(`${task.service || '.'} existe en más de un repositorio (${candidatos.join(', ')}), así que `
52
+ + 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', 2)
53
+ }
54
+ const repo = candidatos[0]
55
+ if (!repo) {
56
+ return fail(`no encontré el repositorio de ${task.service || '(sin service)'}: revisá workspaceRoots `
57
+ + 'en ops.config.json y que la ruta del servicio exista.', 2)
58
+ }
59
+
60
+ const branch = CL.branchOf(slug)
61
+ const already = existing(repo, branch)
62
+ const target = already || path.join(path.dirname(repo), `${path.basename(repo)}-${slug}`)
63
+ if (!already) {
64
+ if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`)
65
+ const hasBranch = git(repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`).status === 0
66
+ const args = hasBranch ? ['worktree', 'add', target, branch] : ['worktree', 'add', '-b', branch, target]
67
+ const added = git(repo, ...args)
68
+ if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`)
69
+ }
70
+
71
+ if (cli.has('--json')) {
72
+ return console.log(JSON.stringify({ path: target, branch, repo, runner: target, reused: Boolean(already) }))
73
+ }
74
+ console.log(`${already ? '=' : '✓'} ${target} (${branch})`)
75
+ // En `embedded` la instancia vive dentro del repo, así que cada árbol se lleva su propia copia de
76
+ // `planning/` — o ninguna, si todavía no se commiteó—. Para un agente solo eso funciona; para varios
77
+ // deja de haber coordinación, porque los reclamos de uno no los ve el otro hasta mergear. Se avisa y no
78
+ // se frena: usar un árbol por rama sin equipo es legítimo.
79
+ if (O.mode(path.join(root, '..')) === 'embedded') {
80
+ console.log(' ⚠ la instancia vive dentro del repo, así que este árbol lleva su propia copia de '
81
+ + 'planning/: los reclamos no se ven entre árboles hasta mergear. Para varios agentes, mode: sidecar.')
82
+ }
83
+ // El id del runner y la ruta son la misma cosa a propósito: el árbol es lo que distingue a un agente
84
+ // de otro en una máquina, así que darlo hecho evita el modo de fallo que deja a los dos con el mismo.
85
+ console.log(` export CAUCE_RUNNER=${target}`)
86
+ console.log(` node tools/ops.js claim ${path.relative(process.cwd(), root) || '.'} ${slug}`)
87
+ }
88
+
89
+ module.exports = { worktree }
@@ -20,6 +20,9 @@ const SYSTEM_FILES = [
20
20
  'planning/business-rules/000-template.md',
21
21
  'planning/business-rules/README.md',
22
22
  'planning/rules/README.md',
23
+ 'planning/claims/README.md',
24
+ 'planning/done/README.md',
25
+ 'planning/wip/README.md',
23
26
  'planning/roadmap/README.md',
24
27
  'planning/roadmap/epic-000-template.md',
25
28
  // La guía de entrega no tiene una línea de la empresa: describe el camino que Cauce recomienda, y lo
@@ -32,6 +35,7 @@ const SYSTEM_FILES = [
32
35
  'planning/delivery/environments.md',
33
36
  'planning/delivery/flags.md',
34
37
  'planning/delivery/multi-repo.md',
38
+ 'planning/delivery/teamwork.md',
35
39
  'organization/roles/README.md',
36
40
  'flows/000-template.md',
37
41
  'flows/README.md',
@@ -75,9 +79,12 @@ const TEMPLATE_PREFIXES = [
75
79
  'tools/',
76
80
  ]
77
81
 
78
- // Archivos que la instancia recibe en su raíz y que el paquete tiene por duplicado: el propio del
79
- // toolkit y el de la plantilla. Gana el de la plantilla, que es el que le habla a la instancia.
80
- const TEMPLATE_FILES = new Set(['AGENTS.md', 'Makefile'])
82
+ // Archivos que la instancia recibe en su raíz y cuyo original vive en `template/`. Dos de ellos el
83
+ // paquete los tiene por duplicado —el propio del toolkit y el de la plantilla— y gana el de la
84
+ // plantilla, que es el que le habla a la instancia; `.gitattributes` existe sólo del lado del molde y
85
+ // entra por la misma puerta, porque sin esto `sourceOf` lo busca en la raíz del paquete y `upgrade` lo
86
+ // saltea en silencio.
87
+ const TEMPLATE_FILES = new Set(['AGENTS.md', 'Makefile', '.gitattributes'])
81
88
 
82
89
  function sourceOf(relative) {
83
90
  if (TEMPLATE_FILES.has(relative)) return path.join('template', relative)
@@ -186,6 +193,9 @@ function overrides(root) {
186
193
  // decidir acá. Una entrada pasa de `upgrade` a `init` cuando ninguna versión soportada puede no
187
194
  // tenerlo; que se quede de más no rompe nada, porque el archivo ya está y se conserva.
188
195
  const TEMPLATE_OWN = {
196
+ // 0.70.0. Le dice a git que los dos archivos que sólo crecen se concatenan en vez de
197
+ // conflictuar, así que hace falta en la instancia que ya existe y no sólo en la nueva.
198
+ '.gitattributes': 'upgrade',
189
199
  'README.md': 'init',
190
200
  'gitignore': 'init',
191
201
  'integrations/config.json': 'init',
@@ -202,10 +212,11 @@ const TEMPLATE_OWN = {
202
212
  // una instancia que actualiza queda leyendo una instrucción que apunta a un archivo que no tiene.
203
213
  'organization/workspace.md': 'upgrade',
204
214
  'planning/BACKLOG.md': 'init',
205
- 'planning/DONE.md': 'init',
206
215
  'planning/HUMAN_ACTIONS.md': 'init',
207
216
  'planning/INBOX.md': 'init',
208
- 'planning/WIP.md': 'init',
217
+ // 0.69.0. El contrato nace con esta versión, así que ninguna instancia anterior lo tiene: por `init`
218
+ // no llegaría nunca a la que ya existe, que es justo la que iba a usarlo.
219
+ 'planning/RECURRING.md': 'upgrade',
209
220
  'planning/delivery/project.md': 'init',
210
221
  'planning/done/.gitkeep': 'init',
211
222
  'planning/reports/README.md': 'init',