@ingeniomaps/cauce 0.88.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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,135 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.89.0] - 2026-09-14
18
+
19
+ ### Agregado
20
+
21
+ - **`check` te avisa de una condición de aceptación que no se va a poder comprobar, antes de que el
22
+ recorrido la construya.** Verify corre antes que Commit y que Done, así que una condición que pide ver
23
+ el commit, el reclamo, `done/` o la evidencia registrada pide algo que todavía no existe cuando se la
24
+ mira. El recorrido ya lo frenaba —y hace bien—, pero recién en Verify: en la corrida que originó esto
25
+ fueron **1,2 M de tokens y once agentes** para terminar con el trabajo hecho, sin commit y sin poder
26
+ cerrar la tarea.
27
+
28
+ Ahora sale de `check`, cuesta un regex sobre la cola y corre en los cuatro carriles, incluidos los que
29
+ saltean Ready. Avisa y no falla.
30
+
31
+ El aviso dice qué hacer: esa cláusula va en `tests:`, `qa:` o `commit:` de la entrada de DONE, que ya la
32
+ exigen, así que repetirla en la aceptación no agrega garantía sino un bloqueo. **Y si de verdad va ahí,
33
+ se declara y deja de avisarse**: `(fuera de verify: <razón>)` dentro de la propia condición, la misma
34
+ salida explícita que `(sin partir: …)` y que `n/a — razón`. Se juzga condición por condición, así que
35
+ declarar una no exime a las demás.
36
+
37
+ - **Una regla propia puede declarar sobre qué superficie rige, y entonces se nombra sin cargarse.** Todo
38
+ lo que `planning/rules/` contiene viaja en el contexto de arranque de **cada** agente —medido: sólo las
39
+ cuatro reglas que trae Cauce son 38,3 KB por agente—, así que una regla de dos páginas que importa en
40
+ una tarea de cada cien se leía cien veces.
41
+
42
+ Ahora una regla puede llevar `aplica: <superficie>` en su frontmatter. El bloque que escribe
43
+ `automation install` la lista —`- ruta (aplica: pagos)`— en lugar de importarla: pesa una línea en vez
44
+ de su archivo entero. **Sigue rigiendo igual**: `ops context` la devuelve entre las reglas del proyecto
45
+ y el recorrido se la nombra a cada agente que toca código, para que quien trabaje sobre esa superficie
46
+ la lea antes de planificar o construir.
47
+
48
+ **Sin el campo, la regla se carga como siempre, y por eso este cambio no te pide hacer nada.** El
49
+ default es ése a propósito: una regla que no se leyó no existe, así que apartarla del arranque es una
50
+ decisión del proyecto y nunca algo que se deduzca. El valor lo elegís vos —`pagos`,
51
+ `infraestructura`— y lo único que tiene que lograr es que quien lo lea sepa cuándo le toca. Conviene
52
+ para lo que es de un dominio acotado; lo que gobierna cómo se trabaja se paga y se carga.
53
+
54
+ - **`automation install` te dice lo que ese bloque va a pesar, y `check` avisa cuando ya pesa demasiado.**
55
+ Escribir una regla propia encarece todas las corridas futuras de todos los agentes, y hasta acá no lo
56
+ decía nadie: había que sumar los tamaños a mano después de una corrida cara para enterarse.
57
+
58
+ Al instalar, una línea declara cuántos archivos carga el bloque, cuánto pesan y cuáles son los dos más
59
+ grandes —`el bloque de reglas carga 4 archivo(s), 38.3 KB en cada agente (las más grandes: conduct.md,
60
+ process.md)`—. Y `check` lo repite como advertencia cuando el total pasa de **64 KB**, que es el umbral
61
+ elegido para dejar unos 26 KB de reglas propias por encima del piso que trae Cauce: más abajo avisaría
62
+ en toda instancia recién creada y se apagaría por ruido el primer día.
63
+
64
+ **El número va en KB y no en tokens** a propósito. Los bytes los mide el motor y se pueden comprobar;
65
+ la equivalencia en tokens depende del modelo y del tokenizador, y una cifra estimada en una salida que
66
+ se cita para decidir vale menos que una exacta. Como referencia, en el repositorio de Cauce esos 38,3 KB
67
+ rondaron los 10 K tokens medidos una vez, pero eso es una observación y no un factor de conversión.
68
+
69
+ Una regla declarada con `aplica:` no suma en ninguno de los dos números: es justamente lo que se apartó
70
+ del arranque, y contarla haría que declararla no sirviera de nada.
71
+
72
+ ### Corregido
73
+
74
+ - **El ciclo de aprendizaje ya no te pide una firma por una propuesta que no decide nada.** El documento
75
+ se compone en dos tiempos: `learn --proposal` lo arma desde los informes y los veredictos, y
76
+ `/agent-propose` escribe el cambio concreto. El ciclo automático corre el primero y abría el PR ahí
77
+ mismo, con «Cambio propuesto» todavía en el molde — así se gastaron siete firmas el 2026-09-14, y
78
+ ninguna de esas propuestas se pudo aplicar.
79
+
80
+ Ahora el paso que compone dice si el documento quedó sin decidir y el que publica lo lee. **La rama se
81
+ empuja igual**, porque lleva el sello de los informes que el ciclo consumió y perderlo haría entrar el
82
+ mismo material el mes siguiente; lo que se posterga es sólo el PR. La corrida lo deja anotado con el
83
+ nombre de la rama y qué falta para abrirlo.
84
+
85
+ Y `ops learn <cargo> --proposal` lo dice también cuando lo corrés a mano: «sin cambio decidido: falta
86
+ correr agent-propose antes de que esto se pueda firmar». Sin esa línea el archivo se ve terminado y no
87
+ lo está.
88
+
89
+ - **En `mode: sidecar`, `ops context` ya no te esconde tu propio plan.** El id de un runner sale de
90
+ `CAUCE_RUNNER` y, sin ella, del repositorio git desde el que corrés el comando. Cuando la instancia es su
91
+ **propio repositorio** —lo habitual: `<empresa>/` y `<empresa>-ops/` al lado— hay dos raíces, y el mismo
92
+ agente recibe un id distinto según desde cuál invoque. El reclamo queda guardado con uno, el plan se
93
+ escribe bajo ese mismo, y la sesión que pregunta desde el otro recibe `WIP idle` y `TAKEN … vos, desde
94
+ otro runner`: el plan existe, con sus pasos ya tildados, y nadie lo nombra.
95
+
96
+ Ahora `context` lo nombra. Cuando una tarea tomada es tuya y hay un plan escrito bajo el otro id, agrega
97
+ una línea `PLAN` con el archivo y con el `export CAUCE_RUNNER` que lo recupera. Y **`ops claim` imprime
98
+ ese id al tomar la tarea**, que es el momento en que se conoce: hasta ahora sólo lo hacía `ops worktree`,
99
+ y en sidecar no se crea ningún worktree, así que el dato no quedaba escrito en ninguna parte.
100
+
101
+ El id sigue sin ser estable, y eso no cambia: derivarlo de quién sos en vez de dónde corrés le devolvería
102
+ a cada agente de la máquina la tarea del otro, que es peor que no encontrar la propia. Lo que cambia es
103
+ que dejó de ser mudo.
104
+
105
+ - **`automation install` te dice dónde quedaron los recorridos cuando no es donde estás parado.** En
106
+ sidecar el adaptador se instala en la carpeta de la empresa y no en el repo ops desde el que corrés el
107
+ comando. Eso es correcto y no cambió —es donde el runner ve el código—; lo que engañaba era la salida.
108
+ Avisaba «el runner se abre en `<raíz>` — ahí queda su configuración» para un archivo, y a continuación
109
+ nombraba los recorridos en relativo, `.claude/workflows/autobuild.js`, que leído desde el repo ops apunta
110
+ a una carpeta vacía. Cerraba en «adaptador operativo (0 advertencia(s))», y todo era cierto.
111
+
112
+ Lo que cuesta es que un recorrido se invoca **por nombre**, y el nombre lo resuelve la sesión contra la
113
+ carpeta en la que se abrió: una sesión abierta en el repo ops no encuentra ninguno y recibe un «no
114
+ existe» que se lee como instalación fallida. Ahora la salida nombra ese directorio con su raíz puesta y
115
+ desde dónde hay que abrir la sesión para que los vea.
116
+
117
+ - **Los recorridos dejan de depender de la carpeta desde la que se abrió la sesión.** La raíz que traen
118
+ escrita —la que usan para nombrar el planning, la configuración y el CLI en cada comando que le dictan a
119
+ un agente— era **relativa** a la carpeta donde se abre la herramienta. Eso vale mientras el agente esté
120
+ parado ahí, y nadie lo promete: una sesión abierta en el repo ops resolvía `<empresa>-ops/planning`
121
+ contra su propio directorio, el tramo se duplicaba, y el comando contestaba que el planning no existe.
122
+ En una corrida real de `autobuild` costó una vuelta entera de la fase Claim.
123
+
124
+ Ahora esa raíz es **absoluta** y la escribe `automation install`, que es quien la conoce. Ninguna
125
+ consigna depende ya de dónde arranque la sesión.
126
+
127
+ **Lo que tenés que saber**: un archivo que lleva la raíz escrita se rompe si movés el proyecto de
128
+ carpeta. Se repara con `automation install` del runner, que es lo que ya hacía falta cuando el wiring
129
+ quedaba apuntando a otro lado. Y como los recorridos vienen del paquete, esto llega con el `upgrade`:
130
+ después conviene reinstalar el adaptador para que la raíz nueva quede escrita.
131
+
132
+ - **Agregar un archivo al motor ya no deja la cobertura en un callejón.** La puerta de pisos fallaba
133
+ diciendo «corré `npm run coverage:update`», y ese comando no podía completarse **mientras el piso
134
+ faltara**: corría la suite entera bajo `set -e`, la suite incluía la prueba que estaba en rojo por el
135
+ piso que faltaba, y el script moría antes de llegar a la línea que lo registra.
136
+
137
+ Ahora las corridas de medición no deciden por su código de salida —existen para producir el archivo de
138
+ cobertura— y en su lugar se exige que ese archivo traiga contenido, que es lo único que distingue una
139
+ suite que falló de una que no llegó a arrancar. Y al abortar ya no se borran las mediciones que sí se
140
+ completaron, así que se puede retomar desde donde quedó.
141
+
142
+ **Y medir cero dejó de anunciarse como éxito**: `coverage-files.js --update` con un archivo de
143
+ cobertura vacío imprimía «piso registrado» y salía en 0, dejando el registro vacío sin que nada lo
144
+ dijera. Ahora se niega y explica por qué.
145
+
17
146
  ## [0.88.0] - 2026-09-14
18
147
 
19
148
  ### Corregido
@@ -21,7 +21,7 @@ const unmeasuredNote = (unmeasured) => (unmeasured.length
21
21
  // —trabajaron ahí— y el repositorio las rechaza: una ruta bajo el `/home` de alguien no le sirve a nadie
22
22
  // más y ata el documento a un directorio que en otra máquina no existe.
23
23
  //
24
- // La raíz no siempre la trae `root`: `{{OPS_DIR}}` lo completa `automation install`, y en el repositorio
24
+ // La raíz no siempre la trae `root`: `{{OPS_ROOT}}` lo completa `automation install`, y en el repositorio
25
25
  // del toolkit —que no se instala a sí mismo— queda vacío y `ROOT` vale `.`. Cuando falta, la revela el
26
26
  // propio texto: cualquier ruta del banco la lleva adelante. Con ella se recorta también la que apunta a la
27
27
  // raíz sin nada detrás, que es la que se escapó el 2026-08-31 después de dos arreglos que cubrían el caso
@@ -1,4 +1,15 @@
1
- // El prefijo lo completa `automation install`. No puede venir del entorno: el runtime de workflows no
2
- // expone `process`, así que leerlo de ahí reventaba el archivo entero en su primera línea. Viaja
3
- // escrito, relativo a la carpeta donde se abre la herramienta, que es el cwd de los agentes.
4
- const ROOT = '{{OPS_DIR}}'.replace(/\/+$/, '') || '.'
1
+ // La raíz la completa `automation install`. No puede venir del entorno: el runtime de workflows no
2
+ // expone `process`, así que leerlo de ahí reventaba el archivo entero en su primera línea. Viaja escrita.
3
+ //
4
+ // Y viaja **absoluta**. Lo fue relativa hasta 0.89.0, anclada a la carpeta donde se abre la herramienta
5
+ // «que es el cwd de los agentes» — y esa segunda mitad es la que no se cumple: cada consigna dicta «corré
6
+ // X desde Y» con las dos rutas relativas, así que coinciden sólo si la sesión abrió exactamente donde el
7
+ // instalador supuso. Abierta en la instancia, el tramo se duplica y el comando contesta que el planning
8
+ // no existe (caso 139). Absoluta no hay dónde pararse mal.
9
+ //
10
+ // El costo de escribirla —y por qué se paga acá— lo declara `engine/automation/runners.js` junto al
11
+ // marcador.
12
+ //
13
+ // Sin instalar queda vacía y vale `.`: el toolkit no se consume a sí mismo y sus recorridos se ejercitan
14
+ // desde su propia carpeta.
15
+ const ROOT = '{{OPS_ROOT}}'.replace(/\/+$/, '') || '.'
@@ -252,10 +252,13 @@ const CONTRACT = {
252
252
  required: ['project', 'workspaceRoots', 'maxTaskHours', 'commitPerTask', 'humanCheckpoint', 'contracts',
253
253
  'rootOk'],
254
254
  properties: {
255
- // `ROOT` viaja escrito en el workflow y es relativo al cwd de los agentes: si la sesión abrió en otra
256
- // carpeta, todas las rutas resuelven a `<raíz>/<raíz>/…` y ninguna existe. Nada lo comprobaba, y el
257
- // recorrido gastaba Triage entero sobre archivos ausentes antes de parar más abajo por otra causa,
258
- // nombrando el planning en vez de la raíz de la que ese planning cuelga.
255
+ // `ROOT` viaja escrito en el workflow y lo completa el instalador. Nada comprobaba que esa raíz se
256
+ // pudiera leer, y el recorrido gastaba Triage entero sobre archivos ausentes antes de parar más abajo
257
+ // por otra causa, nombrando el planning en vez de la raíz de la que ese planning cuelga.
258
+ //
259
+ // Lo que rompía era que fuera relativa: abierta la sesión en otra carpeta, todo resolvía a
260
+ // `<raíz>/<raíz>/…` y nada existía. Desde 0.89.0 es absoluta y ese modo de fallo se fue, pero el campo
261
+ // sigue haciendo falta — una raíz absoluta se rompe si alguien mueve el proyecto sin reinstalar.
259
262
  //
260
263
  // Se pregunta acá porque acá ya se leen los cuatro archivos: cuesta un campo y ningún agente más.
261
264
  rootOk: { type: 'boolean' },
@@ -336,8 +339,8 @@ if (!contract) return stop('contract-unavailable', `no se pudo leer ${CONFIG} ni
336
339
  // Falla acá y nombrando la raíz, que es lo que hace falta para arreglarlo: parar más abajo mandaba a
337
340
  // revisar el planning, y el planning está bien — lo que no existe es la carpeta de la que cuelga.
338
341
  if (!contract.rootOk) {
339
- return stop('root-unreadable', `${ROOT} no se pudo leer entero. Es una ruta relativa a la carpeta `
340
- + `donde se abre la herramienta: comprobá desde dónde estás corriendo el recorrido.`)
342
+ return stop('root-unreadable', `${ROOT} no se pudo leer entero. Es la raíz absoluta que escribió `
343
+ + `"automation install": comprobá que exista y, si moviste el proyecto de carpeta, reinstalá el adaptador.`)
341
344
  }
342
345
 
343
346
  const bounds = contract.boundaries || []
@@ -9,6 +9,7 @@ const fs = require('node:fs')
9
9
  const path = require('node:path')
10
10
  const catalog = require('./catalog')
11
11
  const ownership = require('../core/ownership')
12
+ const { section } = require('../planning/parser')
12
13
 
13
14
  const REQUIRED_SECTIONS = [
14
15
  'Hallazgos',
@@ -53,6 +54,17 @@ const undecided = (value) => {
53
54
  return !text || /^(por definir|pendiente)\b/i.test(text) || text === LEGACY_REVISION
54
55
  }
55
56
 
57
+ // Lo mismo preguntado sobre el documento, que es como lo necesita quien acaba de componerlo y todavía no
58
+ // leyó su «Cambio propuesto». Vive acá y no en cada `return` de `prepareProposal` —son cinco y sólo uno
59
+ // compone— porque la señal tiene que valer igual en todos: puesta en uno, vuelve `undefined` en los
60
+ // demás y quien la lea creerá que el documento decide algo.
61
+ function blankProposal(file) {
62
+ try {
63
+ const body = section(fs.readFileSync(file, 'utf8'), /Cambio propuesto/i)
64
+ return undecided(body.split('\n').slice(1).join('\n'))
65
+ } catch { return false }
66
+ }
67
+
56
68
  // Un cargo del sistema vive dentro del paquete: escribir ahí perdería el informe en el próximo
57
69
  // `npm ci`, y además duplicaría en cada empresa una investigación sobre la profesión que se hace
58
70
  // mejor una sola vez. Lo que sí es de esta empresa es su contexto, y ese tiene otro lugar.
@@ -144,7 +156,7 @@ function reportFiles(dir) {
144
156
  }
145
157
 
146
158
  module.exports = {
147
- REQUIRED_SECTIONS, SUMMARY_MAX, PROPOSAL_NAME, REPORT_NAME, undecided, SIGNED, CLOSED,
159
+ REQUIRED_SECTIONS, SUMMARY_MAX, PROPOSAL_NAME, REPORT_NAME, undecided, blankProposal, SIGNED, CLOSED,
148
160
  assertWritableTeam, assertWritable, isoDate, month,
149
161
  proposalOrder, proposalFiles, frontmatterState, proposalState, reportFiles, lastOfPeriod,
150
162
  }
@@ -423,9 +423,11 @@ function install(root, name, output = console, options = {}) {
423
423
  const ourHooks = deliveredHookCommands(live)
424
424
  if (ourHooks.length) deliveredPaths[deliveryKey(name, HOOKS_KEY)] = ourHooks
425
425
  else delete deliveredPaths[deliveryKey(name, HOOKS_KEY)]
426
+ const landed = new Set()
426
427
  for (const resolved of resolvedItems) {
427
428
  const status = state.get(resolved)
428
429
  const ownFile = runner.instructions.includes(resolved.item)
430
+ if (!ownFile) landed.add(path.dirname(resolved.target))
429
431
  if (ownFile && isSharedFile(root, resolved.target)) {
430
432
  const content = render(resolved.source, opsPrefix(root), resolved.automationRoot, resolved.opsRoot)
431
433
  if (blockUpToDate(resolved.target, name, content)) {
@@ -463,11 +465,25 @@ function install(root, name, output = console, options = {}) {
463
465
  deliveredPaths[deliveryKey(name, resolved.item.target)] = M.digest(resolved.target)
464
466
  }
465
467
  }
468
+ // Dónde quedaron, con la raíz puesta. Arriba ya se dice de la configuración, que es un archivo que nadie
469
+ // invoca; éstos se invocan **por nombre**, y el nombre lo resuelve la sesión contra la carpeta en la que
470
+ // se abrió. Nombrados en relativo se leen como si estuvieran acá, así que quien abre la sesión en el repo
471
+ // ops no encuentra ninguno mientras esta misma salida dice que están todos instalados.
472
+ if (paths.install !== root) {
473
+ for (const dir of landed) {
474
+ output.log(` ${name}: ${dir} — los encuentra por nombre una sesión abierta en ${paths.install}`)
475
+ }
476
+ }
466
477
  installRoleSkills(root, runner, output)
467
478
  // Cómo se lo llama acá. El nombre del recorrido es el mismo en todos los runners —`onboard`, `flow`,
468
479
  // `autobuild`—; el prefijo lo pone cada uno según su espacio de nombres, y esa diferencia es la que
469
480
  // hace que alguien no encuentre en Gemini lo que usó en Claude. Decirlo al instalar cuesta una línea
470
481
  // y ahorra buscarlo en una lista tan larga como el catálogo.
482
+ // Lo que ese bloque va a costar, dicho donde se decide. Escribir una regla propia encarece todas las
483
+ // corridas futuras de todos los agentes, y hasta acá había que sumar los tamaños a mano después de una
484
+ // corrida cara para enterarse (caso 141). Se declara siempre, pase o no el umbral: `check` avisa cuando
485
+ // ya pesa, y esto informa mientras todavía se está eligiendo.
486
+ output.log(` ${name}: ${RL.weightLine(root)}`)
471
487
  const invocation = runner.commands && runner.commands.invocation
472
488
  if (invocation && (runner.commands.names || []).length) {
473
489
  const listing = runner.commands.names.map((nombre) => invocation.replace('{name}', nombre))
@@ -6,6 +6,7 @@
6
6
  // instalar. Y como `upgrade` no reinstala, `check` y `doctor` comparan lo instalado con lo vigente.
7
7
 
8
8
  const fs = require('node:fs')
9
+ const path = require('node:path')
9
10
  const F = require('../core/files')
10
11
  const O = require('../core/ownership')
11
12
 
@@ -20,14 +21,100 @@ const START_AT = '<!-- cauce:reglas inicio'
20
21
  // Lo que un `CLAUDE.md` o un `GEMINI.md` instalado antes de 0.82.0 trae en el lugar del bloque.
21
22
  const LEGACY_IMPORT = /^@\S*planning\/rules\/\S+\.md\s*$/
22
23
 
24
+ // Una regla que declara `aplica:` rige igual, y se carga sólo cuando se toca esa superficie. Lo que se
25
+ // ahorra está medido: el contexto de arranque de **cada** agente son ~16 K tokens sólo de lo que Cauce
26
+ // pone, y una regla que importa en una tarea de cada cien se leía cien veces (caso 141).
27
+ //
28
+ // El default es lo que vuelve seguro esto: sin el campo, la regla se carga como siempre. Al revés —pedir
29
+ // que declare `siempre` para seguir cargándose— cada instancia habría perdido sus reglas propias en el
30
+ // próximo `upgrade`, una por una y sin que nada lo dijera.
31
+ //
32
+ // `effectiveRules` no cambia: sigue devolviendo todo lo que rige, que es lo que `ops context` le entrega
33
+ // al recorrido. Acá sólo se decide qué se **carga**, y por eso la partición vive de este lado.
34
+ const SURFACE = /^aplica:[ \t]*(\S.*?)[ \t]*$/m
35
+ function surfaceOf(root, file) {
36
+ try {
37
+ const text = fs.readFileSync(path.join(root, file), 'utf8')
38
+ // Sólo en el frontmatter: un `aplica:` en la prosa de una regla es una frase, no una declaración.
39
+ if (!text.startsWith('---')) return ''
40
+ const end = text.indexOf('\n---', 3)
41
+ return end === -1 ? '' : ((text.slice(0, end).match(SURFACE) || [])[1] || '')
42
+ } catch { return '' }
43
+ }
44
+
45
+ // Las que se cargan y las que sólo se nombran. Las dos listas salen de la misma lectura para que el
46
+ // bloque que se escribe y el aviso que lo audita no puedan discrepar: con dos recorridos, `doctor`
47
+ // reportaría como faltante la que a propósito no se carga, para siempre y sin forma de callarlo.
48
+ function split(root) {
49
+ const loaded = []
50
+ const named = []
51
+ for (const file of O.effectiveRules(root)) {
52
+ const surface = surfaceOf(root, file)
53
+ if (surface) named.push({ file, surface })
54
+ else loaded.push(file)
55
+ }
56
+ return { loaded, named }
57
+ }
58
+
59
+ // Cuánto pesa lo que el bloque carga, en bytes. En bytes y no en tokens a propósito: los bytes los mide
60
+ // el motor y se pueden comprobar, mientras que la equivalencia en tokens depende del modelo y del
61
+ // tokenizador. Un número estimado en una salida que alguien cita para decidir vale menos que uno exacto.
62
+ //
63
+ // Sólo `loaded`: una regla declarada por superficie no viaja en el arranque, y contarla haría que
64
+ // declararla no sirviera de nada. Vive acá —y no en quien lo imprime— porque lo miran dos, `install` al
65
+ // instalar y `check` en cada corrida, y con dos cuentas separadas se contradicen el día que alguien toque
66
+ // una sola.
67
+ function weight(root) {
68
+ const { loaded } = split(root)
69
+ let bytes = 0
70
+ const files = []
71
+ for (const file of loaded) {
72
+ try {
73
+ const size = fs.statSync(path.join(root, file)).size
74
+ bytes += size
75
+ files.push({ file, size })
76
+ } catch { /* la que no está en disco ya la reporta `check` por su lado */ }
77
+ }
78
+ return { count: loaded.length, bytes, files: files.sort((a, b) => b.size - a.size) }
79
+ }
80
+
81
+ const KB = (bytes) => `${(bytes / 1024).toFixed(1)} KB`
82
+
83
+ // A partir de dónde el peso deja de ser el costo de arrancar y pasa a ser una decisión que conviene mirar.
84
+ // El piso que Cauce impone —las cuatro reglas del sistema— son 38,3 KB, así que un umbral por debajo de
85
+ // eso avisaría en toda instancia recién creada y se apagaría por ruido el primer día: eso descartó los
86
+ // 60 KB que el caso 141 proponía. 64 KB deja ~26 KB para lo propio, que son varias reglas de tamaño
87
+ // normal, antes de que el aviso hable.
88
+ const HEAVY = 64 * 1024
89
+
90
+ // La línea que declara el peso, para que la digan igual `install` y `check`. Nombra las dos más grandes
91
+ // porque es lo accionable: saber que el bloque pesa no dice cuál conviene declarar por superficie.
92
+ function weightLine(root) {
93
+ const { count, bytes, files } = weight(root)
94
+ const top = files.slice(0, 2).map((one) => path.basename(one.file)).join(', ')
95
+ return `el bloque de reglas carga ${count} archivo(s), ${KB(bytes)} en cada agente`
96
+ + (top ? ` (las más grandes: ${top})` : '')
97
+ }
98
+
99
+ // Sólo cuando pasó el umbral. Devuelve lista porque es lo que `check` empalma con el resto de avisos.
100
+ function heavyRules(root) {
101
+ return weight(root).bytes > HEAVY ? [weightLine(root)] : []
102
+ }
103
+
23
104
  // Sin raíz el marcador queda como está —así lo leen las pruebas que revisan el texto de un adaptador—: un
24
105
  // bloque vacío se leería igual que un proyecto sin reglas, y un marcador sin resolver se ve.
25
106
  function fill(text, root) {
26
107
  if (!root || !text.includes('{{RULES:')) return text
27
- const rules = O.effectiveRules(root)
28
- return text.replace(MARKER, (_, format) => [START, ...rules.map((file) => (format === 'imports'
29
- ? `@{{OPS_DIR}}${file}`
30
- : `- \`{{OPS_DIR}}${file}\``)), END].join('\n'))
108
+ const { loaded, named } = split(root)
109
+ return text.replace(MARKER, (_, format) => [
110
+ START,
111
+ ...loaded.map((file) => (format === 'imports' ? `@{{OPS_DIR}}${file}` : `- \`{{OPS_DIR}}${file}\``)),
112
+ // Nombrada y no cargada: pesa una línea en vez de su archivo entero, y quien la lea sabe qué la
113
+ // dispara. Va en el mismo bloque a propósito — fuera de él, una sesión que no corre un recorrido no
114
+ // se enteraría de que existe, y una regla que nadie sabe que existe es una que no rige.
115
+ ...named.map((one) => `- \`{{OPS_DIR}}${one.file}\` (aplica: ${one.surface})`),
116
+ END,
117
+ ].join('\n'))
31
118
  }
32
119
 
33
120
  function blockOf(text) {
@@ -69,6 +156,13 @@ function drift(root, name) {
69
156
  const { runnerManifest, runnerPaths, resolveItem } = require('./runners')
70
157
  const runner = runnerManifest(root, name)
71
158
  const paths = runnerPaths(root, name, runner)
159
+ // Contra todo lo que rige, y no contra la partición: una regla declarada por superficie igual aparece
160
+ // en el bloque —nombrada en vez de importada—, y `listed` extrae la ruta de las dos formas. Así que el
161
+ // audit la ve y no la extraña, sin que este lado tenga que saber que la partición existe.
162
+ //
163
+ // Se intentó filtrar acá por simetría con `fill` y no cambiaba nada: la mutación que lo revertía dejaba
164
+ // las pruebas en verde. Queda dicho porque la simetría es tentadora y el código que no altera ninguna
165
+ // conducta se lee como si sostuviera algo.
72
166
  const expected = O.effectiveRules(root)
73
167
  const found = []
74
168
  for (const item of [...(runner.instructions || []), ...(runner.artifacts || [])]) {
@@ -119,4 +213,4 @@ function staleLines(root) {
119
213
  return lines
120
214
  }
121
215
 
122
- module.exports = { fill, refresh, drift, driftLine, staleLines }
216
+ module.exports = { fill, refresh, drift, driftLine, staleLines, split, weightLine, heavyRules }
@@ -122,9 +122,13 @@ function inline(text, automationRoot) {
122
122
  })
123
123
  }
124
124
 
125
- // `{{OPS_ROOT}}` es la raíz absoluta. La necesita quien no puede deducirla de dónde lo ejecutaron
126
- // —el puente de Antigravity—, y por eso no reemplaza a `{{OPS_DIR}}`: una ruta absoluta escrita en un
127
- // archivo se rompe si el proyecto se mueve, así que la lleva sólo el que se queda sin alternativa.
125
+ // `{{OPS_ROOT}}` es la raíz absoluta, y la lleva quien no puede deducirla de dónde lo ejecutaron: el
126
+ // puente de Antigravity, los hooks de Codex y —desde 0.89.0— los recorridos, que dictan comandos a un
127
+ // agente cuyo directorio nadie promete (caso 139).
128
+ //
129
+ // No reemplaza a `{{OPS_DIR}}` en general, y el costo dice por qué: un archivo que lleva la raíz escrita
130
+ // se rompe si el proyecto se mueve de carpeta, y se repara reinstalando el adaptador. Lo paga el que no
131
+ // tiene alternativa; lo que se resuelve contra la carpeta donde se abre la herramienta sigue con el otro.
128
132
  const OPS_ROOT = '{{OPS_ROOT}}'
129
133
 
130
134
  // `{{RULES:…}}` va antes que `{{OPS_DIR}}` por lo mismo que el include: las rutas que escribe llevan el prefijo.
@@ -7,6 +7,7 @@ const fs = require('node:fs')
7
7
  const path = require('node:path')
8
8
  const { spawnSync } = require('node:child_process')
9
9
  const L = require('../agents/learning')
10
+ const LF = require('../agents/learning-files')
10
11
  const AG = require('../agents/catalog')
11
12
  const EV = require('../agents/evaluations')
12
13
  const T = require('../flows/registry')
@@ -288,6 +289,13 @@ function learn(agent, cli) {
288
289
  ? ` ${result.reports} corrida(s) consolidada(s), ${result.findings} hallazgo(s)`
289
290
  : ` ${result.reports} informe(s) semanal(es) incluidos`)
290
291
  }
292
+ // Lo lee quien automatiza el ciclo para no pedir una firma por un documento que no decide nada, y
293
+ // también quien lo corre a mano: sin esta línea el archivo se ve terminado y no lo está. Se pregunta
294
+ // sobre el documento y no sobre lo que devolvió el motor, porque `prepareProposal` sale por cinco
295
+ // lugares y sólo uno compone: puesto en ése, el dato falta en los otros cuatro.
296
+ if (LF.blankProposal(result.file)) {
297
+ console.log(' sin cambio decidido: falta correr agent-propose antes de que esto se pueda firmar')
298
+ }
291
299
  } catch (error) { fail(error.message, 2) }
292
300
  }
293
301
 
@@ -76,9 +76,15 @@ function claim(dir, slug, cli) {
76
76
  + `${CL.DIR}/${slug}.md a mano: soltar lo de otro es una decisión, no un comando.`)
77
77
  }
78
78
  console.log(`✓ ${slug} tomada por ${me}`)
79
- // Un reclamo sin empujar no protege de nada: el otro runner lee lo que hay en su copia. Decirlo acá
80
- // es lo único que separa «tomé la tarea» de «creí que la había tomado».
81
- if (!cli.has('--json')) console.log(` commiteá y empujá ${CL.DIR}/${slug}.md para que el equipo lo vea`)
79
+ if (!cli.has('--json')) {
80
+ // Un reclamo sin empujar no protege de nada: el otro runner lee lo que hay en su copia. Decirlo acá
81
+ // es lo único que separa «tomé la tarea» de «creí que la había tomado».
82
+ console.log(` commiteá y empujá ${CL.DIR}/${slug}.md para que el equipo lo vea`)
83
+ // Y con qué id volver. Este dato lo imprimía `ops worktree` al crear el árbol; en sidecar no se crea
84
+ // ninguno, así que ese comando no corre nunca y el id no quedaba escrito en el único momento en que
85
+ // alguien lo tiene delante: ahora, que se acaba de guardar en el reclamo.
86
+ console.log(` export CAUCE_RUNNER=${from}`)
87
+ }
82
88
  }
83
89
 
84
90
  function release(dir, slug) {
package/engine/cli/io.js CHANGED
@@ -10,6 +10,12 @@ function fail(message, code = 1) {
10
10
  process.exit(code)
11
11
  }
12
12
 
13
+ // La fecha de hoy, en un solo lugar: los comandos que la usan tienen que estar mirando el mismo día, y
14
+ // el módulo que calcula vencimientos la recibe en vez de preguntarla. Vive acá desde que la puerta de
15
+ // planning se separó de los comandos que la leen — quedaba en el archivo que se partió, y dejar una copia
16
+ // a cada lado habría roto en silencio lo único que esta función promete.
17
+ const TODAY = () => new Date().toISOString().slice(0, 10)
18
+
13
19
  // La raíz ops de un comando que no la recibe. El shim `tools/ops.js` la exporta porque sabe dónde
14
20
  // vive: sin eso, invocarlo desde otra carpeta —lo normal en sidecar— la resolvía contra el cwd.
15
21
  function opsRoot(dir) {
@@ -44,4 +50,4 @@ function planningRoot(dir) {
44
50
  return root
45
51
  }
46
52
 
47
- module.exports = { fail, opsRoot, planningRoot }
53
+ module.exports = { fail, opsRoot, planningRoot, TODAY }
package/engine/cli/ops.js CHANGED
@@ -9,6 +9,7 @@ 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 VA = require('./validate')
12
13
  const AR = require('./archive')
13
14
  const CLM = require('./claims')
14
15
  const WT = require('./worktree')
@@ -124,7 +125,7 @@ async function init(target, cli) {
124
125
  })
125
126
  } catch (error) { fail(error.message, 2) }
126
127
 
127
- if (result.installed) PL.check(path.join(root, 'planning'), NO_FLAGS)
128
+ if (result.installed) VA.check(path.join(root, 'planning'), NO_FLAGS)
128
129
 
129
130
  // Una instancia recién instalada funciona y no sabe nada de este proyecto: `organization/` es el molde
130
131
  // y el roadmap está vacío. Llenarlo exige leer el repositorio y decidir qué es cada cosa, que es justo
@@ -204,7 +205,7 @@ async function run(cli) {
204
205
  if (command === 'init') await init(arg[1], cli)
205
206
  else if (command === 'scan') W.scan(arg[1], cli)
206
207
  else if (command === 'onboard') W.onboard(arg[1], cli)
207
- else if (command === 'check') PL.check(arg[1], cli)
208
+ else if (command === 'check') VA.check(arg[1], cli)
208
209
  else if (command === 'tree') PL.tree(arg[1], cli)
209
210
  else if (command === 'context') PL.context(arg[1], cli)
210
211
  else if (command === 'recurring') PL.recurring(arg[1], cli)
@@ -1,32 +1,20 @@
1
1
  'use strict'
2
2
 
3
- // Los comandos que leen y validan un planning: qué está mal, qué hay, qué toca ahora y qué se archiva.
4
- // Los cuatro trabajan sobre el mismo estado, que `planning/state.js` compone una vez.
3
+ // Los comandos que leen un planning y contestan sobre él: qué hay, qué toca ahora, qué vence y con qué
4
+ // se cerró una tarea. Todos trabajan sobre el mismo estado, que `planning/state.js` compone una vez.
5
+ //
6
+ // La puerta no está acá: vive en `validate.js` porque decide en vez de informar, y con ella se fueron
7
+ // quince de los veintiún módulos que este archivo importaba.
5
8
 
6
9
  const fs = require('node:fs')
7
10
  const path = require('node:path')
8
11
  const P = require('../planning/parser')
9
- const B = require('../planning/business-rules')
10
- const PC = require('../planning/contracts')
11
- const SR = require('../planning/structure')
12
- const SZ = require('../planning/sizing')
13
12
  const RC = require('../planning/recurring')
14
- const IB = require('../planning/inbox')
15
13
  const CL = require('../planning/claims')
16
- const R = require('../core/repos')
17
14
  const ST = require('../planning/state')
18
- const AD = require('../planning/adoption')
19
- const AP = require('../hooks/approval')
20
- const I = require('../integrations/registry')
21
15
  const O = require('../core/ownership')
22
16
  const EV = require('../core/evidence')
23
- const TR = require('../core/trails')
24
- const OB = require('../core/onboarding')
25
- const C = require('../config/validate')
26
- const CP = require('../config/paths')
27
- const AG = require('../agents/catalog')
28
- const RL = require('../automation/rules')
29
- const { fail, planningRoot } = require('./io')
17
+ const { fail, planningRoot, TODAY } = require('./io')
30
18
 
31
19
  // Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
32
20
  // esos archivos tiende a quedarse con el contenido y perder la estructura: el resultado se lee entero y
@@ -74,178 +62,6 @@ function evidence(dir, cli) {
74
62
  + 'que la prueba nombrada haya corrido: eso depende del runner, y varios no la nombran al pasar.')
75
63
  }
76
64
 
77
- // La fecha de hoy, en un solo lugar: los comandos que la usan tienen que estar mirando el mismo día, y
78
- // el módulo que calcula vencimientos la recibe en vez de preguntarla.
79
- const TODAY = () => new Date().toISOString().slice(0, 10)
80
-
81
- function check(dir, cli) {
82
- const root = planningRoot(dir)
83
- const errors = []
84
- const warnings = []
85
- // El plan no está: `wip/` es local y gitignoreado, así que un clon nuevo no lo trae y eso no es un
86
- // error. Ausente se lee como IDLE, que es lo que significa.
87
- const required = ['BACKLOG.md', 'INBOX.md', 'HUMAN_ACTIONS.md', 'PROTOCOL.md']
88
- // `DONE.md` se retiró: la evidencia vive en un archivo por tarea. Un `DONE.md` que quede en disco
89
- // ya no lo lee nadie, y eso no se nota — las épicas dejan de poder cerrar y sus historias figuran
90
- // sin evidencia, que es lo mismo que se vería si nunca se hubieran hecho.
91
- // `WIP.md` se retiró por lo mismo que `DONE.md`: era uno solo y lo escribían todos los que corren sobre
92
- // una instancia sidecar. Uno que quede en disco ya no lo lee nadie, y su plan a medias se pierde sin
93
- // que nada lo diga.
94
- if (fs.existsSync(path.join(root, 'WIP.md'))) {
95
- errors.push('WIP.md ya no se lee: el plan de cada runner vive en `wip/<runner>.md`; movelo y borralo')
96
- }
97
- if (fs.existsSync(path.join(root, 'DONE.md'))) {
98
- errors.push('DONE.md ya no se lee: pasá cada entrada a su propio `done/<slug>.md` y borralo')
99
- }
100
- for (const file of required) if (!fs.existsSync(path.join(root, file))) errors.push(`falta ${file}`)
101
-
102
- const configPath = path.join(root, '..', 'ops.config.json')
103
- let config = null
104
- if (fs.existsSync(configPath)) {
105
- try {
106
- const raw = fs.readFileSync(configPath, 'utf8')
107
- config = JSON.parse(raw)
108
- if (!raw.includes('{{')) {
109
- errors.push(...C.validateOpsConfig(config))
110
- warnings.push(...C.configWarnings(config))
111
- if (Array.isArray(config.workspaceRoots)) {
112
- for (const workspace of config.workspaceRoots) {
113
- if (workspace && workspace.name && workspace.path
114
- && !fs.existsSync(path.resolve(path.dirname(configPath), workspace.path))) {
115
- errors.push(`ops.config.json: no existe la raíz ${workspace.name} (${workspace.path})`)
116
- }
117
- }
118
- }
119
- // Es la única parte de la configuración que le levanta el límite a un guard, y quien la escribió
120
- // no es quien la lee dentro de seis meses: va como advertencia permanente, igual que un override.
121
- // Y se muestra resuelta porque resuelta es como la compara el guard — un `~` escrito solo exenta
122
- // la casa entera, y escrito no se nota.
123
- for (const exempt of CP.writableOutsideRoots(path.dirname(configPath), config)) {
124
- warnings.push(`ops.config.json: ${exempt.declared} está exenta del límite `
125
- + `de raíces (${exempt.path})`)
126
- }
127
- }
128
- } catch (error) {
129
- errors.push(`ops.config.json: JSON inválido (${error.message})`)
130
- }
131
- } else {
132
- warnings.push(`no existe ${path.relative(root, configPath)}`)
133
- }
134
-
135
- const epics = P.readEpics(root)
136
- const milestones = P.readBacklog(root)
137
- const done = P.readDone(root)
138
- errors.push(...B.validate(path.join(root, 'business-rules')))
139
- errors.push(...SR.validateRoadmapStructure(root))
140
- errors.push(...SR.validateBacklogStructure(root))
141
- errors.push(...SR.validateRules(root))
142
- errors.push(...SR.validateAdr(root))
143
- const backlog = milestones.flatMap((milestone) => milestone.tasks)
144
- const backlogSlugs = new Set(backlog.map((task) => task.slug))
145
- const epicNums = new Set()
146
- const storySlugs = new Set()
147
-
148
- const roles = new Set(AG.list(path.resolve(root, '..')).map((role) => role.slug))
149
- const wips = P.readWips(root)
150
- const adopted = AD.read(root)
151
- errors.push(...SZ.oversizedUnits({ epics, milestones }))
152
- errors.push(...PC.validateState({
153
- epics, milestones, done, wips, roles, humanActions: P.readHumanActions(root), adopted: new Set(adopted),
154
- }))
155
- warnings.push(...AD.report({ done, epics, adopted }))
156
- warnings.push(...PC.doneCeremonyWarnings(done, new Set(adopted)))
157
- warnings.push(...R.coverageWarnings(path.resolve(root, '..'), done))
158
- // Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
159
- // no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
160
- // en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
161
- // Un reclamo que nombra una tarea que no existe bloquea la cola sin que nada lo explique, y uno viejo
162
- // la bloquea para siempre. Lo primero es error; lo segundo avisa, porque abandonar no es un defecto.
163
- const claims = CL.read(root)
164
- errors.push(...CL.validate({ claims, milestones, done }))
165
- // Si la rama de cada tarea tomada se movió, que es lo único barato que distingue una tarea larga de
166
- // una abandonada. Sin repositorio resoluble el mapa queda vacío y el aviso vuelve a mirar sólo la
167
- // fecha, que es lo que había antes: degrada, no rompe.
168
- const activity = new Map()
169
- for (const claim of claims.filter((one) => !done.set.has(one.slug))) {
170
- const at = R.lastCommit(R.repoOf(path.join(root, '..'), claim.service), CL.branchOf(claim.slug))
171
- if (at) activity.set(claim.slug, at)
172
- }
173
- warnings.push(...CL.warnings({ claims, done, today: TODAY(), activity }))
174
- const recurring = RC.read(root)
175
- errors.push(...RC.validate(recurring))
176
- warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
177
- warnings.push(...IB.warnings(root, done, config))
178
- warnings.push(...AD.sealWarnings(root))
179
- warnings.push(...R.unrecordedHumanActions(path.resolve(root, '..'), P.readHumanActions(root)))
180
- warnings.push(...AP.warnings(path.resolve(root, '..')))
181
- warnings.push(...TR.warnings(path.resolve(root, '..')))
182
-
183
- // Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
184
- // avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
185
- // en cada corrida y no sólo el día que alguien actualiza.
186
- const congelados = O.localChanges(path.resolve(root, '..'))
187
- if (congelados.length) {
188
- warnings.push(`${congelados.length} archivo(s) del molde congelados por edición local; `
189
- + '`upgrade` los conserva y no les trae mejoras')
190
- }
191
-
192
- // Y lo que `upgrade` no retiró porque no pudo demostrar que fuera suyo: queda ahí, sin colgar de
193
- // ningún mecanismo, hasta que alguien lo mueva o lo borre. Se cuenta por lo mismo que los congelados
194
- // — un resto que no se ve se vuelve permanente.
195
- const restos = O.RETIRED_COMPARTIDO.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
196
- if (restos.length) {
197
- warnings.push(`${restos.length} ruta(s) retiradas siguen en disco con contenido tuyo `
198
- + `(${restos.join(', ')}); Cauce ya no las distribuye ni las toca`)
199
- }
200
-
201
- const integration = I.validate(path.resolve(root, '..'))
202
- errors.push(...integration.errors)
203
- warnings.push(...integration.warnings)
204
-
205
- warnings.push(...SR.competingSections(root))
206
- // Sobrescribir una entrada de system/ es legítimo y esperado; lo que no puede pasar es que
207
- // ocurra en silencio, porque esa entrada deja de recibir las mejoras del toolkit.
208
- for (const override of O.overrides(path.resolve(root, '..'))) {
209
- // Y con qué se queda el proyecto: un override sano redefine lo que reemplaza, y el que deja IDs
210
- // afuera los retira sin decirlo. Nombrarlos es lo único que separa una decisión de un descuido.
211
- const retired = override.collection === 'planning/rules'
212
- ? SR.retiredByOverride(root, override.project)
213
- : []
214
- warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} `
215
- + `(override explícito)${retired.length ? `; deja de regir ${retired.join(', ')}` : ''}`)
216
- }
217
- // Y lo que instaló cada runner, contra esas mismas reglas (caso 099).
218
- warnings.push(...RL.staleLines(path.resolve(root, '..')))
219
- // Misma regla para los cargos, que es donde más caro sale: un fork se hace una vez y se olvida.
220
- const FK = require('../agents/fork')
221
- for (const entry of FK.drift(path.resolve(root, '..'))) warnings.push(FK.driftLine(entry))
222
-
223
- warnings.push(...OB.missingSections(path.resolve(root, '..')))
224
- warnings.push(...OB.orphanCredentials(path.resolve(root, '..')))
225
-
226
- if (cli.has('--json')) {
227
- console.log(JSON.stringify({
228
- ok: !errors.length,
229
- epics: epics.length,
230
- queued: backlog.length,
231
- done: done.entries.length,
232
- wips: wips.map((one) => one.task),
233
- errors,
234
- warnings,
235
- }))
236
- if (errors.length) process.exit(1)
237
- return
238
- }
239
-
240
- for (const warning of warnings) console.warn(`⚠ ${warning}`)
241
- for (const error of errors) console.error(`✗ ${error}`)
242
- if (errors.length) fail(`\n${errors.length} error(es), ${warnings.length} advertencia(s)`)
243
- console.log(
244
- `✓ planning válido: ${epics.length} épica(s), ${backlog.length} tarea(s) en cola, ` +
245
- `${done.entries.length} terminada(s)`,
246
- )
247
- }
248
-
249
65
  // Estado observable de planning sin mutar nada; base común de `tree` y de sus salidas.
250
66
  function treeJson({ epics, milestones, done, wips, inbox, queued, claims }) {
251
67
  const state = (slug) => done.set.has(slug) ? 'done' : queued.has(slug) ? 'queued' : 'pending'
@@ -407,6 +223,18 @@ function context(dir, cli) {
407
223
  // Tu propio nombre en una tarea «ajena» es la señal de que sos vos desde otro runner, y sin decirlo se
408
224
  // lee como que alguien te ganó la tarea.
409
225
  const dueño = (one) => (one.owner === report.owner ? `${one.owner} — vos, desde otro runner` : one.owner)
226
+ // Que el reclamo sea tuyo desde otro id ya se decía; que además haya un **plan escrito** bajo ese id, no.
227
+ // Esa es la mitad que cuesta la sesión: los pasos ya hechos están en un archivo que nadie nombra, y el id
228
+ // que lo recupera es justo el que esta sesión no supo deducir. Los dos datos están en el reclamo.
229
+ const tomadas = () => {
230
+ for (const one of report.taken) {
231
+ console.log(`TAKEN ${one.slug} (${dueño(one)})`)
232
+ if (one.owner === report.owner && one.wip) {
233
+ console.log(`PLAN ${one.slug}: su plan está en wip/${one.wip} — retomalo con `
234
+ + `\`export CAUCE_RUNNER=${one.runner}\``)
235
+ }
236
+ }
237
+ }
410
238
  const espera = () => {
411
239
  for (const one of report.waiting) {
412
240
  console.log(`WAIT ${one.slug}: espera a ${one.dep}${one.owner ? ` (${one.owner})` : ''}`)
@@ -424,7 +252,7 @@ function context(dir, cli) {
424
252
  if (next) console.log(`EPIC ${next.num}: ${next.title} — sin promover`)
425
253
  // Mismo motivo que `blocked` arriba, con otra causa: acá la cola no la traba una persona, la tiene
426
254
  // el equipo, y lo que corresponde es hablar con quien la tiene.
427
- for (const one of report.taken) console.log(`TAKEN ${one.slug} (${dueño(one)})`)
255
+ tomadas()
428
256
  espera()
429
257
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
430
258
  due()
@@ -452,7 +280,7 @@ function context(dir, cli) {
452
280
  console.log(report.claimed
453
281
  ? `CLAIM tuya desde el reclamo (${report.owner})`
454
282
  : `CLAIM libre — tomala con \`ops claim <planning> ${report.task.slug}\``)
455
- for (const one of report.taken) console.log(`TAKEN ${one.slug} (${dueño(one)})`)
283
+ tomadas()
456
284
  espera()
457
285
  if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
458
286
  for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
@@ -489,4 +317,4 @@ function recurring(dir, cli) {
489
317
  }
490
318
  }
491
319
 
492
- module.exports = { check, evidence, tree, context, recurring }
320
+ module.exports = { evidence, tree, context, recurring }
@@ -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.88.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.