@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 +129 -0
- package/automatization/shared/eval-measured.js +1 -1
- package/automatization/shared/workflow-root.js +15 -4
- package/automatization/workflows/autobuild.js +9 -6
- package/engine/agents/learning-files.js +13 -1
- package/engine/automation/index.js +16 -0
- package/engine/automation/rules.js +99 -5
- package/engine/automation/runners.js +7 -3
- package/engine/cli/catalog.js +8 -0
- package/engine/cli/claims.js +9 -3
- package/engine/cli/io.js +7 -1
- package/engine/cli/ops.js +3 -2
- package/engine/cli/planning.js +21 -193
- package/engine/cli/validate.js +205 -0
- package/engine/planning/contracts.js +54 -0
- package/engine/planning/state.js +14 -4
- package/package.json +1 -1
- package/template/planning/PROTOCOL.md +5 -0
- package/template/planning/rules/README.md +31 -0
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`: `{{
|
|
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
|
-
//
|
|
2
|
-
// expone `process`, así que leerlo de ahí reventaba el archivo entero en su primera línea. Viaja
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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
|
|
340
|
-
+ `
|
|
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
|
|
28
|
-
return text.replace(MARKER, (_, format) => [
|
|
29
|
-
|
|
30
|
-
: `- \`{{OPS_DIR}}${file}\``)),
|
|
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
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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.
|
package/engine/cli/catalog.js
CHANGED
|
@@ -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
|
|
package/engine/cli/claims.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
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')
|
|
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)
|
package/engine/cli/planning.js
CHANGED
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// Los comandos que leen
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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,
|
package/engine/planning/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
@@ -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.
|