@ingeniomaps/cauce 0.87.0 → 0.89.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.
@@ -0,0 +1,205 @@
1
+ 'use strict'
2
+
3
+ // La puerta de un planning: qué está mal y qué conviene mirar, en un solo comando que falla.
4
+ //
5
+ // Vive aparte de los que leen el mismo estado porque hace lo contrario que ellos. `tree`, `context` y
6
+ // `recurring` contestan una pregunta y salen en cero; éste junta errores y advertencias de dieciocho
7
+ // módulos —estructura, contratos, reclamos, integraciones, ownership, onboarding— y decide si la
8
+ // instancia puede seguir. De ahí que casi todo lo que el CLI de planning importa entre por acá y no por
9
+ // allá: lo que valida necesita conocer a todos, y lo que informa sólo necesita el estado ya compuesto.
10
+
11
+ const fs = require('node:fs')
12
+ const path = require('node:path')
13
+ const P = require('../planning/parser')
14
+ const B = require('../planning/business-rules')
15
+ const PC = require('../planning/contracts')
16
+ const SR = require('../planning/structure')
17
+ const SZ = require('../planning/sizing')
18
+ const RC = require('../planning/recurring')
19
+ const IB = require('../planning/inbox')
20
+ const CL = require('../planning/claims')
21
+ const R = require('../core/repos')
22
+ const AD = require('../planning/adoption')
23
+ const AP = require('../hooks/approval')
24
+ const I = require('../integrations/registry')
25
+ const O = require('../core/ownership')
26
+ const TR = require('../core/trails')
27
+ const OB = require('../core/onboarding')
28
+ const C = require('../config/validate')
29
+ const CP = require('../config/paths')
30
+ const AG = require('../agents/catalog')
31
+ const RL = require('../automation/rules')
32
+ const { fail, planningRoot, TODAY } = require('./io')
33
+
34
+ function check(dir, cli) {
35
+ const root = planningRoot(dir)
36
+ const errors = []
37
+ const warnings = []
38
+ // El plan no está: `wip/` es local y gitignoreado, así que un clon nuevo no lo trae y eso no es un
39
+ // error. Ausente se lee como IDLE, que es lo que significa.
40
+ const required = ['BACKLOG.md', 'INBOX.md', 'HUMAN_ACTIONS.md', 'PROTOCOL.md']
41
+ // `DONE.md` se retiró: la evidencia vive en un archivo por tarea. Un `DONE.md` que quede en disco
42
+ // ya no lo lee nadie, y eso no se nota — las épicas dejan de poder cerrar y sus historias figuran
43
+ // sin evidencia, que es lo mismo que se vería si nunca se hubieran hecho.
44
+ // `WIP.md` se retiró por lo mismo que `DONE.md`: era uno solo y lo escribían todos los que corren sobre
45
+ // una instancia sidecar. Uno que quede en disco ya no lo lee nadie, y su plan a medias se pierde sin
46
+ // que nada lo diga.
47
+ if (fs.existsSync(path.join(root, 'WIP.md'))) {
48
+ errors.push('WIP.md ya no se lee: el plan de cada runner vive en `wip/<runner>.md`; movelo y borralo')
49
+ }
50
+ if (fs.existsSync(path.join(root, 'DONE.md'))) {
51
+ errors.push('DONE.md ya no se lee: pasá cada entrada a su propio `done/<slug>.md` y borralo')
52
+ }
53
+ for (const file of required) if (!fs.existsSync(path.join(root, file))) errors.push(`falta ${file}`)
54
+
55
+ const configPath = path.join(root, '..', 'ops.config.json')
56
+ let config = null
57
+ if (fs.existsSync(configPath)) {
58
+ try {
59
+ const raw = fs.readFileSync(configPath, 'utf8')
60
+ config = JSON.parse(raw)
61
+ if (!raw.includes('{{')) {
62
+ errors.push(...C.validateOpsConfig(config))
63
+ warnings.push(...C.configWarnings(config))
64
+ if (Array.isArray(config.workspaceRoots)) {
65
+ for (const workspace of config.workspaceRoots) {
66
+ if (workspace && workspace.name && workspace.path
67
+ && !fs.existsSync(path.resolve(path.dirname(configPath), workspace.path))) {
68
+ errors.push(`ops.config.json: no existe la raíz ${workspace.name} (${workspace.path})`)
69
+ }
70
+ }
71
+ }
72
+ // Es la única parte de la configuración que le levanta el límite a un guard, y quien la escribió
73
+ // no es quien la lee dentro de seis meses: va como advertencia permanente, igual que un override.
74
+ // Y se muestra resuelta porque resuelta es como la compara el guard — un `~` escrito solo exenta
75
+ // la casa entera, y escrito no se nota.
76
+ for (const exempt of CP.writableOutsideRoots(path.dirname(configPath), config)) {
77
+ warnings.push(`ops.config.json: ${exempt.declared} está exenta del límite `
78
+ + `de raíces (${exempt.path})`)
79
+ }
80
+ }
81
+ } catch (error) {
82
+ errors.push(`ops.config.json: JSON inválido (${error.message})`)
83
+ }
84
+ } else {
85
+ warnings.push(`no existe ${path.relative(root, configPath)}`)
86
+ }
87
+
88
+ const epics = P.readEpics(root)
89
+ const milestones = P.readBacklog(root)
90
+ const done = P.readDone(root)
91
+ errors.push(...B.validate(path.join(root, 'business-rules')))
92
+ errors.push(...SR.validateRoadmapStructure(root))
93
+ errors.push(...SR.validateBacklogStructure(root))
94
+ errors.push(...SR.validateRules(root))
95
+ errors.push(...SR.validateAdr(root))
96
+ const backlog = milestones.flatMap((milestone) => milestone.tasks)
97
+ const roles = new Set(AG.list(path.resolve(root, '..')).map((role) => role.slug))
98
+ const wips = P.readWips(root)
99
+ const adopted = AD.read(root)
100
+ errors.push(...SZ.oversizedUnits({ epics, milestones }))
101
+ errors.push(...PC.validateState({
102
+ epics, milestones, done, wips, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
103
+ }))
104
+ warnings.push(...AD.report({ done, epics, adopted }))
105
+ warnings.push(...PC.doneCeremonyWarnings(done, new Set(adopted)))
106
+ // Antes de que el recorrido pague Build para descubrirlo en Verify. Cuesta un regex sobre la cola y
107
+ // corre en los cuatro carriles, incluidos los que saltean Ready (caso 140).
108
+ warnings.push(...PC.unverifiableAcceptance(milestones))
109
+ warnings.push(...R.coverageWarnings(path.resolve(root, '..'), done))
110
+ // Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
111
+ // no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
112
+ // en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
113
+ // Un reclamo que nombra una tarea que no existe bloquea la cola sin que nada lo explique, y uno viejo
114
+ // la bloquea para siempre. Lo primero es error; lo segundo avisa, porque abandonar no es un defecto.
115
+ const claims = CL.read(root)
116
+ errors.push(...CL.validate({ claims, milestones, done }))
117
+ // Si la rama de cada tarea tomada se movió, que es lo único barato que distingue una tarea larga de
118
+ // una abandonada. Sin repositorio resoluble el mapa queda vacío y el aviso vuelve a mirar sólo la
119
+ // fecha, que es lo que había antes: degrada, no rompe.
120
+ const activity = new Map()
121
+ for (const claim of claims.filter((one) => !done.set.has(one.slug))) {
122
+ const at = R.lastCommit(R.repoOf(path.join(root, '..'), claim.service), CL.branchOf(claim.slug))
123
+ if (at) activity.set(claim.slug, at)
124
+ }
125
+ warnings.push(...CL.warnings({ claims, done, today: TODAY(), activity }))
126
+ const recurring = RC.read(root)
127
+ errors.push(...RC.validate(recurring))
128
+ warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
129
+ warnings.push(...IB.warnings(root, done, config))
130
+ warnings.push(...AD.sealWarnings(root))
131
+ warnings.push(...R.unrecordedHumanActions(path.resolve(root, '..'), P.readHumanActions(root)))
132
+ warnings.push(...AP.warnings(path.resolve(root, '..')))
133
+ warnings.push(...TR.warnings(path.resolve(root, '..')))
134
+
135
+ // Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
136
+ // avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
137
+ // en cada corrida y no sólo el día que alguien actualiza.
138
+ const congelados = O.localChanges(path.resolve(root, '..'))
139
+ if (congelados.length) {
140
+ warnings.push(`${congelados.length} archivo(s) del molde congelados por edición local; `
141
+ + '`upgrade` los conserva y no les trae mejoras')
142
+ }
143
+
144
+ // Y lo que `upgrade` no retiró porque no pudo demostrar que fuera suyo: queda ahí, sin colgar de
145
+ // ningún mecanismo, hasta que alguien lo mueva o lo borre. Se cuenta por lo mismo que los congelados
146
+ // — un resto que no se ve se vuelve permanente.
147
+ const restos = O.RETIRED_COMPARTIDO.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
148
+ if (restos.length) {
149
+ warnings.push(`${restos.length} ruta(s) retiradas siguen en disco con contenido tuyo `
150
+ + `(${restos.join(', ')}); Cauce ya no las distribuye ni las toca`)
151
+ }
152
+
153
+ const integration = I.validate(path.resolve(root, '..'))
154
+ errors.push(...integration.errors)
155
+ warnings.push(...integration.warnings)
156
+
157
+ warnings.push(...SR.competingSections(root))
158
+ // Sobrescribir una entrada de system/ es legítimo y esperado; lo que no puede pasar es que
159
+ // ocurra en silencio, porque esa entrada deja de recibir las mejoras del toolkit.
160
+ for (const override of O.overrides(path.resolve(root, '..'))) {
161
+ // Y con qué se queda el proyecto: un override sano redefine lo que reemplaza, y el que deja IDs
162
+ // afuera los retira sin decirlo. Nombrarlos es lo único que separa una decisión de un descuido.
163
+ const retired = override.collection === 'planning/rules'
164
+ ? SR.retiredByOverride(root, override.project)
165
+ : []
166
+ warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} `
167
+ + `(override explícito)${retired.length ? `; deja de regir ${retired.join(', ')}` : ''}`)
168
+ }
169
+ // Y lo que instaló cada runner, contra esas mismas reglas (caso 099).
170
+ warnings.push(...RL.staleLines(path.resolve(root, '..')))
171
+ // Y cuánto pesa lo que ese bloque carga, cuando ya se pasó del umbral. Es la contracara de la línea que
172
+ // `install` imprime al elegir: una instancia suma reglas de a una, cada una razonable, y el total no lo
173
+ // mira nadie hasta que una corrida sale cara.
174
+ warnings.push(...RL.heavyRules(path.resolve(root, '..')))
175
+ // Misma regla para los cargos, que es donde más caro sale: un fork se hace una vez y se olvida.
176
+ const FK = require('../agents/fork')
177
+ for (const entry of FK.drift(path.resolve(root, '..'))) warnings.push(FK.driftLine(entry))
178
+
179
+ warnings.push(...OB.missingSections(path.resolve(root, '..')))
180
+ warnings.push(...OB.orphanCredentials(path.resolve(root, '..')))
181
+
182
+ if (cli.has('--json')) {
183
+ console.log(JSON.stringify({
184
+ ok: !errors.length,
185
+ epics: epics.length,
186
+ queued: backlog.length,
187
+ done: done.entries.length,
188
+ wips: wips.map((one) => one.task),
189
+ errors,
190
+ warnings,
191
+ }))
192
+ if (errors.length) process.exit(1)
193
+ return
194
+ }
195
+
196
+ for (const warning of warnings) console.warn(`⚠ ${warning}`)
197
+ for (const error of errors) console.error(`✗ ${error}`)
198
+ if (errors.length) fail(`\n${errors.length} error(es), ${warnings.length} advertencia(s)`)
199
+ console.log(
200
+ `✓ planning válido: ${epics.length} épica(s), ${backlog.length} tarea(s) en cola, ` +
201
+ `${done.entries.length} terminada(s)`,
202
+ )
203
+ }
204
+
205
+ module.exports = { check }
@@ -155,6 +155,59 @@ function doneCeremonyWarnings(done, adopted = new Set()) {
155
155
  return warnings
156
156
  }
157
157
 
158
+ // Lo que sólo existe después de Verify: el registro de la tarea, no su producto. `done/<slug>.md` lo
159
+ // escribe Done, el commit lo escribe Commit y el reclamo se libera al cerrar, así que una aceptación que
160
+ // pida verlos pide algo que en Verify todavía no puede estar.
161
+ const POST_VERIFY = [
162
+ [/\bplanning\/done\b|\bdone\/|\bentrada de DONE\b/i, 'planning/done/'],
163
+ [/\bla evidencia\b|\bevidencia registrada\b/i, 'la evidencia'],
164
+ [/\bel commit\b|\bcommitead[oa]\b/i, 'el commit'],
165
+ [/\bel reclamo\b|\bclaims\//i, 'el reclamo'],
166
+ ]
167
+
168
+ // La salida explícita, con la forma que el repositorio ya usa dos veces: `(sin partir: …)` para el umbral
169
+ // de R17 y `n/a — razón` para `tests:` y `commit:`. Acá vale lo mismo que allá —«como lleva su razón
170
+ // escrita se lee en el propio artefacto sin que nadie la cruce»— y por eso no se intenta adivinar si la
171
+ // prosa excluye a Verify. Adivinarlo es lo que no se puede: la única aceptación real que nombra el commit
172
+ // lo hace justamente para decir que no es condición de Verify, y cualquier lista de frases que la
173
+ // reconociera enseñaría a escribir esa frase exacta para silenciar el aviso.
174
+ const OUT_OF_VERIFY = /\(fuera de verify:\s*[^)]+\)/i
175
+
176
+ // Una condición de aceptación que nombra el registro en vez del producto no se puede cumplir nunca: se
177
+ // comprueba en Verify, que corre antes que Commit y que Done. El recorrido lo detecta —y hace bien—, pero
178
+ // recién ahí: en la corrida que originó esto fueron 1,2 M de tokens y once agentes para terminar con el
179
+ // trabajo hecho, sin commit y sin poder cerrar (caso 140).
180
+ //
181
+ // Avisa y no falla, por lo mismo que el aviso de HUMAN_ACTIONS resueltas sin commit —que nació del 121,
182
+ // el mismo daño y el mismo tamaño—: el patrón es de texto y una aceptación legítima puede mencionar la
183
+ // palabra sin depender de ella. Un aviso que salta siempre se termina apagando.
184
+ //
185
+ // Se juzga condición por condición y no la aceptación entera, que es el grano con el que Verify contrasta
186
+ // —su `uncovered` enumera criterios—: las aceptaciones reales traen varias y marcar el párrafo completo
187
+ // señala a las que están bien por vecindad. Y la marca se busca en la condición, no en la tarea, para que
188
+ // excluir una no exima a las demás.
189
+ //
190
+ // Lo que corresponde casi siempre no es borrar la cláusula sino moverla: `tests:`, `qa:` y `commit:` de
191
+ // DONE ya exigen ese registro (PROTOCOL), así que repetirlo en la aceptación no agrega garantía — agrega
192
+ // un bloqueo. Cuando sí corresponde dejarla, se declara y pasa en silencio.
193
+ function unverifiableAcceptance(milestones = []) {
194
+ const warnings = []
195
+ for (const milestone of milestones) {
196
+ for (const task of milestone.tasks || []) {
197
+ for (const condition of String(task.acceptance || '').split(';').map((one) => one.trim())) {
198
+ if (!condition || OUT_OF_VERIFY.test(condition)) continue
199
+ const nombra = POST_VERIFY.filter(([pattern]) => pattern.test(condition))
200
+ if (!nombra.length) continue
201
+ warnings.push(`BACKLOG ${task.slug}: una condición nombra ${nombra.map(([, what]) => what).join(', ')}`
202
+ + ', que existe después de Verify, así que no se puede comprobar cuando se la comprueba. Eso va '
203
+ + 'en tests:, qa: o commit: de su entrada de DONE, que ya lo exigen; si de verdad va acá, '
204
+ + 'declaralo con "(fuera de verify: <razón>)"')
205
+ }
206
+ }
207
+ }
208
+ return warnings
209
+ }
210
+
158
211
  function duplicates(values) {
159
212
  return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
160
213
  }
@@ -362,6 +415,7 @@ module.exports = {
362
415
  validateState,
363
416
  doneEntryErrors,
364
417
  doneCeremonyWarnings,
418
+ unverifiableAcceptance,
365
419
  validCommitTrace,
366
420
  validDecisionTrace,
367
421
  validTestTrace,
@@ -46,7 +46,9 @@ function currentTask({ milestones, done, wips = [], claims = [] }, blockers = []
46
46
  const wip = wips.find((one) => one.runner === P.wipName(runner)) || null
47
47
  const queue = milestones.flatMap((milestone) => milestone.tasks.map((task) => ({ ...task, hito: milestone.slug })))
48
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]))
49
+ // El reclamo entero y no sólo su dueño: quién lo tomó alcanza para decir que la tarea está ocupada, y
50
+ // no para decir cómo volver a ella. Eso lo dice el id desde el que se tomó, que vive en el mismo archivo.
51
+ const others = new Map(claims.filter((one) => !mine.has(one.slug)).map((one) => [one.slug, one]))
50
52
  if (wip) {
51
53
  const active = queue.find((task) => task.slug === wip.task)
52
54
  || {
@@ -69,15 +71,23 @@ function currentTask({ milestones, done, wips = [], claims = [] }, blockers = []
69
71
  claimed: Boolean(claimed),
70
72
  skipped: queue.filter((task) => !done.set.has(task.slug) && blocked.has(task.slug))
71
73
  .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
+ // Un reclamo hecho desde otro id no siempre es de otro agente: puede ser el mismo, con el id resuelto
75
+ // distinto porque `runner()` lo deduce del árbol donde corre el proceso y en sidecar hay dos árboles
76
+ // plausibles. Ahí el plan existe, está escrito y queda invisible para quien lo escribió. Por eso viajan
77
+ // los dos: el id crudo, que es lo que se exporta para volver, y el plan que ese id dejó, si lo dejó.
78
+ taken: pending.filter((task) => others.has(task.slug)).map((task) => {
79
+ const claim = others.get(task.slug)
80
+ const plan = wips.find((one) => one.task === task.slug && one.runner === P.wipName(claim.runner))
81
+ return { slug: task.slug, owner: claim.owner, runner: claim.runner, wip: plan ? `${plan.runner}.md` : '' }
82
+ }),
74
83
  // Lo que espera a otra tarea, con cuál y quién la tiene: la tercera causa por la que una cola puede
75
84
  // no ofrecer nada, y `context` las distingue por la misma razón que distingue las otras dos.
76
85
  // El filtro garantiza que hay dependencias y que al menos una no está cerrada —sin eso la tarea
77
86
  // estaría lista y no acá—, así que buscarla no puede fallar y no lleva defensa.
78
87
  waiting: pending.filter((task) => !others.has(task.slug) && !ready(task)).map((task) => {
79
88
  const dep = task.depends.find((one) => !done.set.has(one))
80
- return { slug: task.slug, dep, owner: others.get(dep) || (mine.has(dep) ? 'vos' : '') }
89
+ const otro = others.get(dep)
90
+ return { slug: task.slug, dep, owner: otro ? otro.owner : (mine.has(dep) ? 'vos' : '') }
81
91
  }),
82
92
  }
83
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.87.0",
3
+ "version": "0.89.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -12,6 +12,11 @@ invariantes.
12
12
  puede heredar aceptación usando `(→ CN) (epic: NNN)` y declarar `(depende: slug, otro)`. Lane y cast son
13
13
  opcionales: sin ellos la tarea está sin clasificar, que es un estado y no un error. Una tarea con
14
14
  dependencias no se ofrece ni se toma hasta que todas estén en DONE.
15
+ Una condición se comprueba en Verify, que corre antes que Commit y que Done: la que nombre el commit, el
16
+ reclamo, `done/` o la evidencia registrada pide algo que todavía no existe cuando se la mira, y su lugar
17
+ son los campos `tests:`, `qa:` y `commit:` de DONE, que ya lo exigen. `check` lo avisa sobre la cola. Si
18
+ aun así corresponde dejarla ahí, se declara en la propia condición con `(fuera de verify: <razón>)` —la
19
+ misma salida explícita que `(sin partir: …)` y que `n/a — razón`— y deja de avisarse.
15
20
  - DONE: un archivo por tarea cerrada, `done/<slug>.md`, con su entrada `[x]` y los campos `acept:`,
16
21
  `fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:`, `commit:` y `lane:`. `lane:` repite el carril con el
17
22
  que la tarea corrió —`express`, `directo`, `lite`, `full`— o `sin clasificar` si su línea no lo
@@ -26,3 +26,34 @@ lo hace—, y ahí queda exigida sin estar escrita en ningún lado. Por eso `che
26
26
  una regla nueva: si lo era, va acá al lado como `P1..Pn` y no se lleva nada puesto.
27
27
 
28
28
  Las convenciones específicas de lenguaje viven junto al servicio que usa ese lenguaje.
29
+
30
+ ## Una regla que sólo rige sobre una superficie
31
+
32
+ Toda regla de este directorio se carga en el contexto de arranque de **cada** agente, y eso cuesta: sólo
33
+ lo que trae Cauce son ~16 K tokens por agente, medido. Una regla propia de dos páginas que importa en una
34
+ tarea de cada cien se lee cien veces.
35
+
36
+ Una regla puede declarar a qué superficie pertenece, y entonces se **nombra** sin cargarse:
37
+
38
+ ```markdown
39
+ ---
40
+ aplica: pagos
41
+ ---
42
+
43
+ # Pagos
44
+
45
+ ## P3 — Conciliar antes de cerrar
46
+ ```
47
+
48
+ Qué cambia: el bloque que `automation install` escribe la lista —`- ruta (aplica: pagos)`— en vez de
49
+ importarla, así que pesa una línea y no su archivo entero. Sigue rigiendo igual: `ops context` la devuelve
50
+ entre las reglas del proyecto, el recorrido se la nombra a cada agente que toca código, y quien trabaje
51
+ sobre esa superficie la lee antes de planificar o construir.
52
+
53
+ **Sin el campo, la regla se carga siempre.** Es el default a propósito: una regla que no se leyó no existe,
54
+ así que apartarla del arranque es una decisión del proyecto y nunca algo que se deduzca. Por lo mismo el
55
+ valor es libre y lo elige quien escribe la regla —`pagos`, `infraestructura`, `el front`—: lo que tiene que
56
+ hacer es que quien lo lea sepa cuándo le toca.
57
+
58
+ Conviene para lo que es de un dominio acotado —un proveedor, un stack, una integración— y no para lo que
59
+ gobierna cómo se trabaja: esas se pagan y se cargan.