@ingeniomaps/cauce 0.69.0 → 0.71.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 (168) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/README.md +6 -6
  3. package/agents/README.md +38 -0
  4. package/agents/roles/system/accounting-specialist/learning/HISTORY.md +2 -0
  5. package/agents/roles/system/ai-governance-lead/learning/HISTORY.md +6 -1
  6. package/agents/roles/system/ai-governance-lead/learning/sources.yaml +4 -4
  7. package/agents/roles/system/ai-governance-lead/references/operating-model.md +2 -2
  8. package/agents/roles/system/ai-product-manager/learning/HISTORY.md +4 -1
  9. package/agents/roles/system/ai-product-manager/learning/sources.yaml +2 -2
  10. package/agents/roles/system/ai-product-manager/references/operating-model.md +2 -2
  11. package/agents/roles/system/analytics-engineer/learning/HISTORY.md +4 -1
  12. package/agents/roles/system/analytics-engineer/learning/sources.yaml +2 -2
  13. package/agents/roles/system/analytics-engineer/references/operating-model.md +2 -2
  14. package/agents/roles/system/backend-engineer/learning/HISTORY.md +2 -0
  15. package/agents/roles/system/business-strategist/learning/HISTORY.md +2 -0
  16. package/agents/roles/system/business-strategist/learning/sources.yaml +1 -1
  17. package/agents/roles/system/business-strategist/references/operating-model.md +2 -2
  18. package/agents/roles/system/cloud-architect/learning/HISTORY.md +1 -1
  19. package/agents/roles/system/cloud-architect/learning/sources.yaml +2 -2
  20. package/agents/roles/system/cloud-architect/references/operating-model.md +2 -2
  21. package/agents/roles/system/community-manager/learning/HISTORY.md +4 -1
  22. package/agents/roles/system/community-manager/learning/sources.yaml +2 -2
  23. package/agents/roles/system/community-manager/references/operating-model.md +1 -1
  24. package/agents/roles/system/content-specialist/learning/HISTORY.md +2 -0
  25. package/agents/roles/system/customer-success-manager/learning/HISTORY.md +2 -0
  26. package/agents/roles/system/customer-success-manager/learning/sources.yaml +4 -4
  27. package/agents/roles/system/customer-success-manager/references/operating-model.md +3 -3
  28. package/agents/roles/system/customer-support-specialist/learning/HISTORY.md +2 -0
  29. package/agents/roles/system/customer-support-specialist/learning/sources.yaml +2 -2
  30. package/agents/roles/system/customer-support-specialist/references/operating-model.md +2 -2
  31. package/agents/roles/system/data-analyst/learning/HISTORY.md +2 -0
  32. package/agents/roles/system/data-engineer/learning/HISTORY.md +4 -1
  33. package/agents/roles/system/data-engineer/learning/sources.yaml +3 -3
  34. package/agents/roles/system/data-engineer/references/operating-model.md +2 -2
  35. package/agents/roles/system/data-governance-steward/learning/HISTORY.md +2 -0
  36. package/agents/roles/system/data-scientist/learning/HISTORY.md +4 -1
  37. package/agents/roles/system/data-scientist/learning/sources.yaml +3 -3
  38. package/agents/roles/system/data-scientist/references/operating-model.md +2 -2
  39. package/agents/roles/system/database-administrator/learning/HISTORY.md +1 -1
  40. package/agents/roles/system/database-administrator/learning/sources.yaml +2 -2
  41. package/agents/roles/system/database-administrator/references/operating-model.md +2 -2
  42. package/agents/roles/system/developer-relations-engineer/learning/HISTORY.md +4 -1
  43. package/agents/roles/system/devops-engineer/learning/HISTORY.md +2 -0
  44. package/agents/roles/system/engineering-manager/learning/HISTORY.md +2 -0
  45. package/agents/roles/system/engineering-manager/learning/sources.yaml +1 -1
  46. package/agents/roles/system/engineering-manager/references/operating-model.md +2 -2
  47. package/agents/roles/system/financial-controller/learning/HISTORY.md +2 -0
  48. package/agents/roles/system/financial-controller/learning/sources.yaml +2 -2
  49. package/agents/roles/system/financial-controller/references/operating-model.md +1 -1
  50. package/agents/roles/system/finops-engineer/learning/HISTORY.md +2 -0
  51. package/agents/roles/system/fraud-risk-analyst/learning/HISTORY.md +2 -0
  52. package/agents/roles/system/frontend-engineer/learning/HISTORY.md +2 -0
  53. package/agents/roles/system/growth-marketer/learning/HISTORY.md +2 -0
  54. package/agents/roles/system/growth-marketer/learning/sources.yaml +1 -1
  55. package/agents/roles/system/implementation-manager/learning/HISTORY.md +1 -1
  56. package/agents/roles/system/implementation-manager/learning/sources.yaml +4 -4
  57. package/agents/roles/system/implementation-manager/references/operating-model.md +3 -3
  58. package/agents/roles/system/integrations-engineer/learning/HISTORY.md +2 -0
  59. package/agents/roles/system/kyc-aml-specialist/learning/HISTORY.md +3 -2
  60. package/agents/roles/system/kyc-aml-specialist/learning/sources.yaml +1 -1
  61. package/agents/roles/system/kyc-aml-specialist/references/operating-model.md +1 -1
  62. package/agents/roles/system/legal-counsel/learning/HISTORY.md +6 -1
  63. package/agents/roles/system/legal-counsel/learning/sources.yaml +1 -1
  64. package/agents/roles/system/legal-counsel/references/operating-model.md +1 -1
  65. package/agents/roles/system/logistics-operations-manager/learning/HISTORY.md +2 -0
  66. package/agents/roles/system/machine-learning-engineer/learning/HISTORY.md +4 -1
  67. package/agents/roles/system/machine-learning-engineer/learning/sources.yaml +6 -6
  68. package/agents/roles/system/machine-learning-engineer/references/operating-model.md +3 -3
  69. package/agents/roles/system/mlops-engineer/learning/HISTORY.md +4 -1
  70. package/agents/roles/system/mlops-engineer/learning/sources.yaml +2 -2
  71. package/agents/roles/system/mlops-engineer/references/operating-model.md +2 -2
  72. package/agents/roles/system/mobile-engineer/learning/HISTORY.md +2 -0
  73. package/agents/roles/system/mobile-engineer/learning/sources.yaml +1 -1
  74. package/agents/roles/system/partnerships-manager/learning/HISTORY.md +4 -1
  75. package/agents/roles/system/partnerships-manager/learning/sources.yaml +2 -2
  76. package/agents/roles/system/partnerships-manager/references/operating-model.md +2 -2
  77. package/agents/roles/system/people-operations-manager/learning/HISTORY.md +4 -1
  78. package/agents/roles/system/people-operations-manager/learning/sources.yaml +2 -2
  79. package/agents/roles/system/people-operations-manager/references/operating-model.md +2 -2
  80. package/agents/roles/system/privacy-compliance-specialist/learning/HISTORY.md +2 -0
  81. package/agents/roles/system/privacy-compliance-specialist/learning/sources.yaml +1 -1
  82. package/agents/roles/system/privacy-compliance-specialist/references/operating-model.md +1 -1
  83. package/agents/roles/system/procurement-manager/learning/HISTORY.md +1 -1
  84. package/agents/roles/system/procurement-manager/learning/sources.yaml +2 -2
  85. package/agents/roles/system/procurement-manager/references/operating-model.md +2 -2
  86. package/agents/roles/system/product-manager/learning/HISTORY.md +1 -1
  87. package/agents/roles/system/product-marketing-manager/learning/HISTORY.md +2 -0
  88. package/agents/roles/system/product-marketing-manager/learning/sources.yaml +1 -1
  89. package/agents/roles/system/project-manager/learning/HISTORY.md +4 -1
  90. package/agents/roles/system/project-manager/learning/sources.yaml +1 -1
  91. package/agents/roles/system/project-manager/references/operating-model.md +2 -2
  92. package/agents/roles/system/qa-engineer/learning/HISTORY.md +2 -0
  93. package/agents/roles/system/qa-engineer/learning/sources.yaml +9 -11
  94. package/agents/roles/system/qa-engineer/references/operating-model.md +2 -2
  95. package/agents/roles/system/release-manager/learning/HISTORY.md +1 -1
  96. package/agents/roles/system/sales-representative/learning/HISTORY.md +2 -0
  97. package/agents/roles/system/sales-representative/learning/sources.yaml +2 -2
  98. package/agents/roles/system/sales-representative/references/operating-model.md +1 -1
  99. package/agents/roles/system/security-engineer/learning/HISTORY.md +2 -0
  100. package/agents/roles/system/security-engineer/learning/sources.yaml +1 -1
  101. package/agents/roles/system/security-engineer/references/operating-model.md +1 -1
  102. package/agents/roles/system/site-reliability-engineer/learning/HISTORY.md +2 -0
  103. package/agents/roles/system/software-architect/learning/HISTORY.md +2 -0
  104. package/agents/roles/system/software-architect/learning/sources.yaml +2 -2
  105. package/agents/roles/system/software-architect/references/operating-model.md +1 -1
  106. package/agents/roles/system/solutions-engineer/learning/HISTORY.md +4 -1
  107. package/agents/roles/system/solutions-engineer/learning/sources.yaml +4 -4
  108. package/agents/roles/system/solutions-engineer/references/operating-model.md +2 -2
  109. package/agents/roles/system/tech-lead/learning/HISTORY.md +2 -0
  110. package/agents/roles/system/technical-program-manager/learning/HISTORY.md +4 -1
  111. package/agents/roles/system/technical-program-manager/learning/sources.yaml +2 -2
  112. package/agents/roles/system/technical-program-manager/references/operating-model.md +3 -3
  113. package/agents/roles/system/technical-writer/learning/HISTORY.md +4 -1
  114. package/agents/roles/system/treasury-analyst/learning/HISTORY.md +2 -0
  115. package/agents/roles/system/ui-designer/learning/HISTORY.md +2 -0
  116. package/agents/roles/system/ui-designer/learning/sources.yaml +2 -2
  117. package/agents/roles/system/user-researcher/learning/HISTORY.md +1 -1
  118. package/agents/roles/system/user-researcher/learning/sources.yaml +1 -1
  119. package/agents/roles/system/ux-designer/learning/HISTORY.md +2 -0
  120. package/agents/roles/system/ux-designer/learning/sources.yaml +1 -1
  121. package/agents/roles/system/ux-designer/references/operating-model.md +1 -1
  122. package/automatization/AGENTS.md +1 -1
  123. package/automatization/runners/antigravity/rules/cauce.md +1 -1
  124. package/automatization/runners/claude/CLAUDE.md +1 -1
  125. package/automatization/runners/codex/AGENTS.md +1 -1
  126. package/automatization/runners/gemini/GEMINI.md +1 -1
  127. package/automatization/shared/skills/autobuild/SKILL.md +1 -1
  128. package/automatization/workflows/autobuild.js +81 -27
  129. package/engine/agents/learning-seal.js +36 -5
  130. package/engine/agents/learning-sources.js +46 -2
  131. package/engine/agents/learning.js +13 -1
  132. package/engine/cli/archive.js +87 -0
  133. package/engine/cli/args.js +6 -2
  134. package/engine/cli/catalog.js +9 -1
  135. package/engine/cli/claims.js +125 -0
  136. package/engine/cli/ops.js +15 -4
  137. package/engine/cli/planning.js +139 -107
  138. package/engine/cli/worktree.js +89 -0
  139. package/engine/core/ownership.js +13 -5
  140. package/engine/core/repos.js +67 -0
  141. package/engine/hooks/files.js +3 -2
  142. package/engine/planning/adoption.js +1 -1
  143. package/engine/planning/claims.js +153 -0
  144. package/engine/planning/contracts.js +43 -202
  145. package/engine/planning/parser.js +51 -12
  146. package/engine/planning/state.js +39 -6
  147. package/engine/planning/structure.js +220 -0
  148. package/package.json +1 -1
  149. package/template/.gitattributes +19 -0
  150. package/template/AGENTS.md +47 -5
  151. package/template/automatization/AGENTS.md +1 -1
  152. package/template/gitignore +9 -0
  153. package/template/planning/BACKLOG.md +5 -0
  154. package/template/planning/FLOW.md +1 -1
  155. package/template/planning/PROTOCOL.md +34 -8
  156. package/template/planning/README.md +3 -3
  157. package/template/planning/RECURRING.md +1 -1
  158. package/template/planning/adr/system/OPS-001-planificacion-como-fuente-de-verdad.md +3 -2
  159. package/template/planning/business-rules/system/BR-OPS-001-una-sola-tarea-activa.md +7 -4
  160. package/template/planning/business-rules/system/BR-OPS-005-una-tarea-un-runner.md +44 -0
  161. package/template/planning/claims/README.md +70 -0
  162. package/template/planning/delivery/README.md +1 -0
  163. package/template/planning/delivery/multi-repo.md +11 -0
  164. package/template/planning/delivery/teamwork.md +162 -0
  165. package/template/planning/done/README.md +41 -0
  166. package/template/planning/wip/README.md +49 -0
  167. package/template/planning/DONE.md +0 -13
  168. package/template/planning/WIP.md +0 -22
@@ -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,13 +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
217
  // 0.69.0. El contrato nace con esta versión, así que ninguna instancia anterior lo tiene: por `init`
209
218
  // no llegaría nunca a la que ya existe, que es justo la que iba a usarlo.
210
219
  'planning/RECURRING.md': 'upgrade',
211
- 'planning/WIP.md': 'init',
212
220
  'planning/delivery/project.md': 'init',
213
221
  'planning/done/.gitkeep': 'init',
214
222
  'planning/reports/README.md': 'init',
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ // En qué repositorio vive un servicio, y cuándo se movió por última vez una rama. Vive acá porque lo
4
+ // preguntan dos cosas que no se conocen entre sí —preparar un árbol de trabajo y juzgar si un reclamo
5
+ // sigue vivo— y la resolución tiene que ser la misma en las dos: escrita dos veces, una copia envejece
6
+ // y las dos respuestas dejan de coincidir sin que nada falle.
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+ const { spawnSync } = require('node:child_process')
11
+
12
+ const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
13
+
14
+ // Los repositorios cuyo árbol contiene el servicio, resuelto como lo resuelve `check` para juzgar si
15
+ // existe. Devuelve la raíz git de cada uno, que no siempre es la raíz declarada: `workspaceRoots` puede
16
+ // apuntar a un subdirectorio.
17
+ //
18
+ // Devuelve una lista y no el primero porque con varias raíces la respuesta puede ser ambigua: un
19
+ // `service: .` existe en todas, y un `src` puede existir en dos. Elegir el primero da una respuesta
20
+ // plausible y equivocada —un árbol de trabajo en el repositorio que no era— sin que nada lo diga.
21
+ function reposFor(opsRoot, service) {
22
+ let config = {}
23
+ try {
24
+ config = JSON.parse(fs.readFileSync(path.join(opsRoot, 'ops.config.json'), 'utf8'))
25
+ } catch { return [] }
26
+ return (Array.isArray(config.workspaceRoots) ? config.workspaceRoots : [])
27
+ .filter((one) => one && one.path)
28
+ .map((one) => path.resolve(opsRoot, one.path))
29
+ .filter((root) => fs.existsSync(path.join(root, service || '.')))
30
+ .map((root) => {
31
+ const top = git(root, 'rev-parse', '--show-toplevel')
32
+ return top.status === 0 ? top.stdout.trim() : ''
33
+ })
34
+ .filter(Boolean)
35
+ // Dos raíces del mismo repositorio son un solo repositorio: lo ambiguo es a cuál pertenece el
36
+ // servicio, no cuántas rutas lo contienen.
37
+ .filter((repo, index, todos) => todos.indexOf(repo) === index)
38
+ }
39
+
40
+ // El repositorio del servicio cuando no hay duda. Sin ninguno o con varios devuelve vacío, y quien
41
+ // pregunta decide qué decir: para `check` es la degradación ya declarada —mirar sólo la fecha—, y para
42
+ // `worktree` es un error que tiene que nombrar los candidatos.
43
+ function repoOf(opsRoot, service) {
44
+ const repos = reposFor(opsRoot, service)
45
+ return repos.length === 1 ? repos[0] : ''
46
+ }
47
+
48
+ // La fecha del último commit **propio** de una rama, en AAAA-MM-DD, o vacío si no tiene ninguno. Vacío no
49
+ // es un error: una tarea recién tomada todavía no tiene rama, la rama recién creada no tiene commits, y
50
+ // un proyecto puede nombrar sus ramas de otra forma. Quien pregunta decide qué hacer con la ausencia.
51
+ //
52
+ // Lo que hay que excluir es el tronco. Una rama nueva hereda su historia entera, así que preguntar por su
53
+ // último commit a secas devuelve el del tronco y **toda rama parece haber avanzado el día que se creó** —
54
+ // que es justo lo contrario de lo que esta función existe para medir. El tronco es la rama en la que está
55
+ // el árbol principal: los worktrees se crean desde ahí y ahí se queda.
56
+ function lastCommit(repo, branch) {
57
+ if (!repo) return ''
58
+ const actual = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')
59
+ const tronco = actual.status === 0 ? actual.stdout.trim() : ''
60
+ const args = tronco && tronco !== branch
61
+ ? ['log', '-1', '--format=%cs', branch, '--not', tronco, '--']
62
+ : ['log', '-1', '--format=%cs', branch, '--']
63
+ const shown = git(repo, ...args)
64
+ return shown.status === 0 ? shown.stdout.trim() : ''
65
+ }
66
+
67
+ module.exports = { reposFor, repoOf, lastCommit }
@@ -12,6 +12,7 @@ const {
12
12
  } = require('./input')
13
13
  const AP = require('./approval')
14
14
  const { readWip } = require('../planning/parser')
15
+ const { runner } = require('../planning/claims')
15
16
  const { hasTasks } = require('../planning/state')
16
17
  const { TEMPLATE_PREFIXES } = require('../core/ownership')
17
18
 
@@ -138,7 +139,7 @@ function planFirst(input) {
138
139
  const root = opsRoot(input)
139
140
  if (!root) return
140
141
  const planning = path.join(root, 'planning')
141
- const wip = readWip(planning)
142
+ const wip = readWip(planning, runner())
142
143
  if (wip && wip.complete + wip.pending > 0) return
143
144
  // Una instancia recién creada no tiene de dónde sacar una tarea: `onboard` deja el roadmap vacío y
144
145
  // dice que alguien lo llene. Exigir el plan ahí es un candado delante de la puerta, y la salida que
@@ -149,7 +150,7 @@ function planFirst(input) {
149
150
  if (!hasTasks(planning)) return
150
151
  const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
151
152
  const why = `${estado}, así que el plan todavía no está escrito.\n`
152
- + 'Escribí en planning/WIP.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
153
+ + 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
153
154
  + 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
154
155
  + AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
155
156
  for (const raw of filesOf(input)) {
@@ -96,7 +96,7 @@ function report({ done, epics = [], adopted = [] }) {
96
96
  for (const slug of slugs) {
97
97
  const entry = done.entries.find((candidate) => candidate.slug === slug)
98
98
  if (!entry) {
99
- warnings.push(`${BASELINE}: ${slug} no está en DONE.md; sacalo de la lista`)
99
+ warnings.push(`${BASELINE}: ${slug} no está en done/; sacalo de la lista`)
100
100
  } else if (!PC.doneEntryErrors(entry, epics).length) {
101
101
  warnings.push(`${BASELINE}: ${slug} ya cumple el contrato; retiralo poniéndole \`#~\` delante`)
102
102
  }
@@ -0,0 +1,153 @@
1
+ 'use strict'
2
+
3
+ // Quién tomó qué. Un archivo por tarea reclamada: crearlo es reclamar, borrarlo es soltar.
4
+ //
5
+ // El nombre del archivo es el slug de la tarea, y de ahí sale la única propiedad que importa: dos
6
+ // personas en tareas distintas no tocan el mismo archivo nunca, y dos que reclaman la misma chocan en
7
+ // git — que es exactamente donde el choque significa algo y donde conviene verlo.
8
+ //
9
+ // El plan de la ejecución no vive acá. Eso es `wip/<runner>.md`, que es local y cambia en cada paso; acá va lo
10
+ // poco que el resto del equipo necesita saber, que cambia dos veces por tarea. Son dos responsabilidades
11
+ // distintas y por eso son dos archivos: el reclamo evita que dos runners tomen la misma tarea, y el WIP
12
+ // evita que un runner lleve dos.
13
+
14
+ const fs = require('node:fs')
15
+ const { spawnSync } = require('node:child_process')
16
+ const path = require('node:path')
17
+ const P = require('./parser')
18
+
19
+ const DIR = 'claims'
20
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
21
+
22
+ // Tres días **sin ninguna señal de avance**, que no es lo mismo que tres días desde que se tomó. El
23
+ // tiempo transcurrido solo no distingue una tarea larga de una abandonada, y equivocarse en esa
24
+ // distinción es caro en los dos sentidos: apurar a alguien que está trabajando, o dejar bloqueada para
25
+ // siempre la tarea de quien se fue.
26
+ //
27
+ // Lo que sí distingue es si la rama de la tarea se movió. Con esa señal, tres días sin un solo commit no
28
+ // es una tarea larga: es una que se detuvo, y el aviso manda a mirar y no a soltar.
29
+ const STALE_DAYS = 3
30
+
31
+ // Quién soy. Sale de la identidad de git porque ya está configurada, es por máquina y es la que va a
32
+ // terminar en el commit igual: pedir una segunda identidad sólo para esto sería un dato más que puede
33
+ // quedar desincronizado. `CAUCE_OWNER` la pisa donde no la haya —un contenedor de CI, por ejemplo—.
34
+ //
35
+ // Sin identidad no se puede reclamar, y eso es correcto: un reclamo anónimo no le dice a nadie a quién
36
+ // preguntarle.
37
+ function owner(root) {
38
+ if (process.env.CAUCE_OWNER) return process.env.CAUCE_OWNER.trim()
39
+ const result = spawnSync('git', ['config', 'user.email'], { cwd: root, encoding: 'utf8' })
40
+ return result.status === 0 ? (result.stdout || '').trim() : ''
41
+ }
42
+
43
+ // Qué runner soy, que no es lo mismo que quién soy. En una máquina con varios agentes la identidad de
44
+ // git es la misma para todos —`git config user.email` no distingue una sesión de otra—, así que si lo
45
+ // que decide «esto es mío» fuera el owner, el segundo agente tomaría por propia la tarea del primero y
46
+ // los dos construirían lo mismo. La unidad es el árbol de trabajo: uno por agente.
47
+ //
48
+ // `CAUCE_RUNNER` es la vía explícita y la que deja `ops worktree`. Sin ella se deduce del árbol donde
49
+ // corre el proceso, que acierta cuando el agente invoca desde el suyo y falla —devolviendo el mismo id
50
+ // para todos— cuando invoca desde una instancia sidecar compartida. Esa falla no queda en silencio:
51
+ // `claim` se niega a darle una segunda tarea a un runner que ya tiene una, y ese es el mensaje que
52
+ // manda a poner la variable.
53
+ function runner() {
54
+ if (process.env.CAUCE_RUNNER) return process.env.CAUCE_RUNNER.trim()
55
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
56
+ return result.status === 0 ? (result.stdout || '').trim() : process.cwd()
57
+ }
58
+
59
+ // La rama donde vive el trabajo de una tarea. La escriben `worktree` al crearla y `check` al buscar si
60
+ // se movió, y son la misma o el segundo mira una rama que nadie usa.
61
+ const branchOf = (slug) => `task/${slug}`
62
+
63
+ function file(root, slug) {
64
+ return path.join(root, DIR, `${slug}.md`)
65
+ }
66
+
67
+ function read(root) {
68
+ let names = []
69
+ try { names = fs.readdirSync(path.join(root, DIR)) } catch { return [] }
70
+ return names.filter((name) => name.endsWith('.md') && name !== 'README.md').sort()
71
+ .map((name) => {
72
+ const field = P.frontmatter(P.read(path.join(root, DIR, name)))
73
+ return {
74
+ slug: name.replace(/\.md$/, ''),
75
+ task: field('task'),
76
+ owner: field('owner'),
77
+ runner: field('runner'),
78
+ started: field('started'),
79
+ service: field('service'),
80
+ at: `${DIR}/${name}`,
81
+ }
82
+ })
83
+ }
84
+
85
+ // El cuerpo de un reclamo. Es corto a propósito: todo lo que crezca acá vuelve a viajar por git en cada
86
+ // cambio, que es de lo que este archivo vino a separarse.
87
+ function content({ task, owner, runner: from, started, service }) {
88
+ return `---\ntask: ${task}\nowner: ${owner}\nrunner: ${from}\nstarted: ${started}\n`
89
+ + `service: ${service || ''}\n---\n\nTomada. El plan vive en el \`wip/\` de quien la tomó.\n`
90
+ }
91
+
92
+ function validate({ claims, milestones, done }) {
93
+ const errors = []
94
+ const queued = new Set(milestones.flatMap((milestone) => milestone.tasks).map((task) => task.slug))
95
+ for (const claim of claims) {
96
+ const at = `${claim.at}`
97
+ // El nombre del archivo es lo que hace única la reserva, así que un frontmatter que dice otra cosa
98
+ // reclama una tarea y bloquea otra. Es la única forma en que este contrato puede mentir.
99
+ if (claim.task !== claim.slug) {
100
+ errors.push(`${at}: declara task "${claim.task}" y el archivo reserva ${claim.slug}`)
101
+ }
102
+ if (!claim.owner) errors.push(`${at}: falta owner`)
103
+ if (!claim.runner) errors.push(`${at}: falta runner`)
104
+ if (!DATE.test(claim.started)) errors.push(`${at}: started debe ser AAAA-MM-DD`)
105
+ if (!queued.has(claim.slug) && !done.set.has(claim.slug)) {
106
+ errors.push(`${at}: ${claim.slug} no existe en BACKLOG ni DONE`)
107
+ }
108
+ }
109
+ return errors
110
+ }
111
+
112
+ const days = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`))
113
+ / 86400000)
114
+
115
+ // `activity` mapea el slug de una tarea a la fecha del último commit de su rama. Llega de afuera porque
116
+ // resolverlo exige git y la configuración del proyecto, y este módulo se prueba sin ninguna de las dos.
117
+ // Vacío es un estado legítimo —una tarea recién tomada no tiene rama— y entonces la única señal que
118
+ // queda es cuándo se tomó.
119
+ function warnings({ claims, done, today, activity = new Map() }) {
120
+ const lines = []
121
+ for (const claim of claims) {
122
+ if (done.set.has(claim.slug)) {
123
+ lines.push(`${claim.at}: ${claim.slug} ya está en DONE; soltala con \`ops release\``)
124
+ continue
125
+ }
126
+ if (!DATE.test(claim.started)) continue
127
+ const commit = activity.get(claim.slug) || ''
128
+ const ultima = commit && commit > claim.started ? commit : claim.started
129
+ const quieta = days(ultima, today)
130
+ if (quieta > STALE_DAYS) {
131
+ const senal = commit
132
+ ? `último commit hace ${days(commit, today)} días`
133
+ : 'la rama de la tarea no tiene commits'
134
+ lines.push(`${claim.at}: ${claim.slug} sin avanzar hace ${quieta} días `
135
+ + `(${claim.owner}, tomada hace ${days(claim.started, today)}; ${senal}); mirá si sigue viva`)
136
+ }
137
+ }
138
+ // Dos tareas del mismo servicio pueden tocar los mismos archivos, y eso no se puede saber antes de
139
+ // hacerlas. Lo único que se puede es decirlo a tiempo de que hablen, así que avisa y nunca frena:
140
+ // frenar serializaría a un equipo entero sobre un servicio, que es peor que la colisión que evita.
141
+ const byService = new Map()
142
+ for (const claim of claims.filter((one) => one.service && !done.set.has(one.slug))) {
143
+ byService.set(claim.service, [...(byService.get(claim.service) || []), claim])
144
+ }
145
+ for (const [service, group] of byService) {
146
+ if (group.length < 2) continue
147
+ lines.push(`${group.length} tareas tomadas sobre ${service} `
148
+ + `(${group.map((one) => `${one.slug} · ${one.owner}`).join('; ')}); pueden tocar los mismos archivos`)
149
+ }
150
+ return lines
151
+ }
152
+
153
+ module.exports = { DIR, STALE_DAYS, owner, runner, branchOf, file, read, content, validate, warnings }