@ingeniomaps/cauce 0.66.0 → 0.68.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 +100 -0
- package/automatization/hooks/README.md +1 -0
- package/automatization/hooks/guard-plan-first.sh +3 -0
- package/engine/cli/args.js +2 -0
- package/engine/cli/catalog.js +8 -1
- package/engine/cli/instance.js +44 -10
- package/engine/cli/ops.js +2 -0
- package/engine/cli/planning.js +58 -1
- package/engine/cli/upgrade-report.js +29 -6
- package/engine/cli/wiring.js +8 -0
- package/engine/core/evidence.js +107 -0
- package/engine/core/ownership.js +10 -0
- package/engine/hooks/files.js +55 -1
- package/engine/hooks/run.js +7 -1
- package/engine/hooks/shell.js +29 -10
- package/engine/planning/parser.js +27 -3
- package/engine/planning/state.js +11 -1
- package/package.json +1 -1
- package/template/AGENTS.md +9 -2
- package/template/gitignore +4 -0
- package/template/planning/rules/system/commits.md +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,106 @@ 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.68.0] - 2026-09-07
|
|
18
|
+
|
|
19
|
+
### Cambiado
|
|
20
|
+
|
|
21
|
+
- **`upgrade` deja de borrar una ruta retirada cuyo contenido no puede probar suyo.** Retirar una ruta
|
|
22
|
+
nunca volvió al toolkit dueño de lo que hay adentro, y hasta acá se borraba el directorio entero: quien
|
|
23
|
+
tenía sus propios workflows en `automatization/workflows` los perdía sin confirmación y sin vuelta
|
|
24
|
+
atrás. De las seis rutas retiradas, cuatro viven bajo `system/` o son un archivo con nombre del
|
|
25
|
+
toolkit y se siguen retirando igual; las dos que un proyecto también usa para lo suyo
|
|
26
|
+
—`automatization/runners` y `automatization/workflows`— se conservan.
|
|
27
|
+
|
|
28
|
+
**Lo que te pide algo**: esas dos rutas quedan en disco con lo que tengas adentro, la corrida te dice
|
|
29
|
+
cuántos archivos son, y `check` las cuenta en cada corrida hasta que las muevas o las borres. Si lo
|
|
30
|
+
que tenés ahí son restos viejos del toolkit y querés limpiarlos, `--force` las retira.
|
|
31
|
+
|
|
32
|
+
- **La regla R9 dice cómo se prueba una quita.** Una prueba que comprueba que aparece lo nuevo no
|
|
33
|
+
comprueba que desapareció lo viejo, y lo que se quita tiene dependientes que no se anuncian —el
|
|
34
|
+
mensaje que afirmaba la invariante, la condición que la deducía—. Sale de una regresión de 0.67.0 que
|
|
35
|
+
entró exactamente por ahí.
|
|
36
|
+
|
|
37
|
+
### Corregido
|
|
38
|
+
|
|
39
|
+
- **El informe de `upgrade` afirmaba descartes que no ocurrieron.** 0.67.0 pasó a conservar los archivos
|
|
40
|
+
editados y la salida siguió enumerándolos como `− descartado tu cambio en …`, uno por uno, después de
|
|
41
|
+
la línea que anuncia que terminó bien. En una instancia real fueron diecinueve renglones falsos, y
|
|
42
|
+
entre ellos iba la única línea destructiva verdadera de esa corrida, indistinguible. Ahora el informe
|
|
43
|
+
recibe qué pasó —descartado, conservado, pendiente— en vez de deducirlo de una condición que dejó de
|
|
44
|
+
valer, y la línea «planning, organization y todo lo propio quedaron intactos» sale justo cuando es
|
|
45
|
+
cierto, que es cuando se conservó algo.
|
|
46
|
+
|
|
47
|
+
## [0.67.0] - 2026-09-07
|
|
48
|
+
|
|
49
|
+
### Agregado
|
|
50
|
+
|
|
51
|
+
- **Guard `plan-first`: no se cambia el producto sin un plan escrito.** R1 y el paso 7 del protocolo lo
|
|
52
|
+
piden desde siempre y nada lo comprobaba: tocar el archivo primero y redactar después la aceptación
|
|
53
|
+
que lo justifica salía igual de verde y se leía igual en DONE. Ahora una escritura de producto exige
|
|
54
|
+
un WIP activo con al menos un paso numerado.
|
|
55
|
+
|
|
56
|
+
No juzga lo que tu instancia posee —`planning/`, `organization/`, `agents/`, `flows/`,
|
|
57
|
+
`integrations/`, `automatization/`, `tools/`—: el plan se escribe en `planning/`, y exigirlo ahí sería
|
|
58
|
+
un candado con la llave adentro. Y queda **inerte mientras tu planning no declare ninguna tarea**, que
|
|
59
|
+
es una instancia recién creada; `automation check` te lo dice cuando lo está.
|
|
60
|
+
|
|
61
|
+
**Lo que te pide algo**: si el cambio no es trabajo de una tarea, aprobá la ruta en
|
|
62
|
+
`planning/.ops-approval`. La variable `OPS_PLAN_FIRST_OVERRIDE=1` lo apaga para toda la sesión.
|
|
63
|
+
|
|
64
|
+
- **`ops evidence <planning-dir>`: contrasta la evidencia de una entrada de DONE contra lo que no
|
|
65
|
+
escribió su autor.** Los dos lados de `tests: CN → prueba` los escribía la misma mano en el mismo
|
|
66
|
+
acto, así que compararlos medía prosa. Ahora se comprueban dos cosas independientes: si el artefacto
|
|
67
|
+
que el rastro nombra existe en tus raíces de código, y qué gates corrió `verify` al commitear con su
|
|
68
|
+
código de salida. Dice también lo que **no** puede contestar —que la prueba nombrada haya corrido
|
|
69
|
+
depende del runner— y no reemplaza a leer su fuente, que es lo que R9 pide.
|
|
70
|
+
|
|
71
|
+
**Lo que te pide algo**: `verify` deja ese registro en `planning/.verify-log`. Tu `.gitignore` no se
|
|
72
|
+
actualiza con el molde —es de `init`—, así que agregale esa línea o el archivo te va a aparecer sin
|
|
73
|
+
trackear en cada commit.
|
|
74
|
+
|
|
75
|
+
### Cambiado
|
|
76
|
+
|
|
77
|
+
- **`upgrade` conserva lo editado y actualiza el resto, en vez de abortar.** Antes cortaba si algún
|
|
78
|
+
archivo del molde estaba editado, y el único flag que lo destrababa descartaba todos tus cambios de
|
|
79
|
+
una: quien adoptó Cauce sobre un proceso propio quedaba eligiendo entre no actualizar nunca y perder
|
|
80
|
+
su corpus. Ahora recibís `rules/system/` y `adr/system/` frescos y conservás lo tuyo, y la corrida
|
|
81
|
+
nombra qué congeló — en cada corrida, no sólo la primera.
|
|
82
|
+
|
|
83
|
+
`check` cuenta esos archivos como advertencia para que la deuda no desaparezca entre actualización y
|
|
84
|
+
actualización. Y el consejo nombra los cuatro que no tienen contraparte propia adónde mudarse
|
|
85
|
+
—`PROTOCOL.md`, `METHODOLOGY.md`, `FLOW.md` y el `Makefile`—, en vez de mandarte a mudarlos.
|
|
86
|
+
|
|
87
|
+
**Lo que te pide algo**: `upgrade` ya no devuelve código distinto de cero por una edición local. Si lo
|
|
88
|
+
llamás desde un script que esperaba ese fallo, ese script cambia. `--force` sigue reemplazando todo.
|
|
89
|
+
|
|
90
|
+
### Corregido
|
|
91
|
+
|
|
92
|
+
- **Un pipe escapado en `HUMAN_ACTIONS.md` corría las columnas de su fila.** En markdown un pipe dentro
|
|
93
|
+
de una celda se escribe `\|` —es la única forma— y el parser lo tomaba como separador. La cara que
|
|
94
|
+
importa era silenciosa: con el pipe detrás de la palabra del vocabulario, `check` pasaba y el runner
|
|
95
|
+
recibía el contenido de `Origen` como si fuera la acción de desbloqueo. El escape ya no parte la
|
|
96
|
+
celda, y se quita al leerla porque esa columna existe para que una persona la lea.
|
|
97
|
+
|
|
98
|
+
**Puede que empieces a ver un error nuevo**, y es correcto: una fila con las columnas corridas podía
|
|
99
|
+
esconder un estado fuera del vocabulario y desbloquear una tarea que nadie resolvió. Leída bien, ese
|
|
100
|
+
estado se ve.
|
|
101
|
+
|
|
102
|
+
- **La cabecera de `HUMAN_ACTIONS.md` sólo se salteaba si decía exactamente `Tarea`.** Cualquier otro
|
|
103
|
+
encabezado —`Tarea Requerida`, `Bloqueo`— caía del lado de los datos y `check` reportaba las etiquetas
|
|
104
|
+
de tus columnas como una acción rota. Ahora la cabecera se reconoce por su forma —es la fila anterior
|
|
105
|
+
a la de separadores— y se saltea la de **cada** tabla del archivo, no sólo la primera.
|
|
106
|
+
|
|
107
|
+
- **Los gates de `verify` corrían con el `GIT_DIR` de tu repositorio.** Un gate que escribe con git
|
|
108
|
+
—una suite que levanta repositorios de prueba y les commitea— escribía entonces en el tuyo: commits
|
|
109
|
+
ajenos en tu rama, archivos trackeados que nadie agregó y un `core.worktree` apuntando a un temporal
|
|
110
|
+
ya borrado, sin que nada lo anunciara. Se disparaba al committear una naturaleza por vez, que es lo
|
|
111
|
+
que R8 pide. Ahora la copia que `verify` materializa es un repositorio propio.
|
|
112
|
+
|
|
113
|
+
**Lo que te pide algo**: esa copia no tiene historia. Un gate que lea una etiqueta o un `git log` no
|
|
114
|
+
la encuentra, y un gate cuyo efecto **es** una escritura de git —taggear, commitear un lockfile
|
|
115
|
+
regenerado— la hace sobre la copia, que se borra. Si tenés un gate así, sacá esa escritura del gate.
|
|
116
|
+
|
|
17
117
|
## [0.66.0] - 2026-09-07
|
|
18
118
|
|
|
19
119
|
### Corregido
|
|
@@ -7,6 +7,7 @@ Los hooks convierten invariantes comprobables en gates mecánicos. La base recom
|
|
|
7
7
|
- edición manual de código generado y drift respecto a OpenAPI/SQL;
|
|
8
8
|
- commits sin Verify aplicable;
|
|
9
9
|
- apagado o borrado de la prueba que juzga el cambio;
|
|
10
|
+
- cambio de producto sin un WIP activo que traiga el plan;
|
|
10
11
|
- cierre de sesión con planning o integraciones inválidas;
|
|
11
12
|
- modificación del protocolo durante una tarea de producto.
|
|
12
13
|
- escrituras fuera de las raíces declaradas del workspace;
|
package/engine/cli/args.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Banderas que consumen el argumento siguiente: su valor no es un posicional.
|
|
8
8
|
const VALUED_FLAGS = new Set([
|
|
9
9
|
'--name', '--mode', '--fixture', '--period', '--record', '--runner', '--integration',
|
|
10
|
+
'--task',
|
|
10
11
|
])
|
|
11
12
|
|
|
12
13
|
// Qué acepta cada comando, y a la vez qué comandos existen. Una bandera desconocida se rechaza en vez
|
|
@@ -19,6 +20,7 @@ const FLAGS = {
|
|
|
19
20
|
check: ['--json'],
|
|
20
21
|
tree: ['--json', '--no-color'],
|
|
21
22
|
context: ['--json'],
|
|
23
|
+
evidence: ['--json', '--task'],
|
|
22
24
|
upgrade: ['--check', '--force'],
|
|
23
25
|
destroy: ['--force'],
|
|
24
26
|
archive: [],
|
package/engine/cli/catalog.js
CHANGED
|
@@ -129,7 +129,14 @@ function evaluationBench(root, agent, caso, force, kind) {
|
|
|
129
129
|
// uno contestó un resumen y escribió el contrato entero en su `INBOX.md`, y el juez —que sólo leía
|
|
130
130
|
// la respuesta— lo dio por ausente. Con git, `status` y `diff` muestran qué produjo, separado del
|
|
131
131
|
// andamiaje. Se ignora `node_modules`: es un symlink al toolkit, no obra del cargo.
|
|
132
|
-
|
|
132
|
+
// `-C` dice dónde mirar y `GIT_DIR` gana igual —comprobado: con `GIT_DIR` puesto,
|
|
133
|
+
// `git -C otro rev-parse --absolute-git-dir` contesta el de la variable—, así que sin limpiarla el
|
|
134
|
+
// banco commitea en el repositorio que la haya exportado. Es lo que hizo el caso 045 antes de
|
|
135
|
+
// arreglarse en `hooks/shell.js`: el banco de una evaluación dejó sus commits en la rama del usuario.
|
|
136
|
+
const env = { ...process.env }
|
|
137
|
+
delete env.GIT_DIR
|
|
138
|
+
delete env.GIT_WORK_TREE
|
|
139
|
+
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { stdio: 'ignore', env })
|
|
133
140
|
fs.appendFileSync(path.join(dir, '.gitignore'), '\nnode_modules/\n')
|
|
134
141
|
git('init', '-q')
|
|
135
142
|
git('config', 'user.email', 'banco@cauce.local')
|
package/engine/cli/instance.js
CHANGED
|
@@ -65,7 +65,10 @@ function copyTemplate(source, target, replacements, force, skip = [], quiet = fa
|
|
|
65
65
|
|
|
66
66
|
// Devuelve lo conservado igual que `copyTemplate`, y por la misma razón: acá el runtime no lleva
|
|
67
67
|
// reemplazos, así que lo que habríamos escrito es el archivo del paquete tal cual.
|
|
68
|
-
|
|
68
|
+
// `preserve` conserva todo lo que ya exista —es lo que `init` necesita— y `conservar` conserva sólo lo
|
|
69
|
+
// que responda que sí, que es lo que `upgrade` necesita para saltear una edición local sin congelar el
|
|
70
|
+
// resto del directorio. Son dos preguntas distintas y por eso no se unifican en una.
|
|
71
|
+
function copyRuntime(source, target, preserve = false, boundary = target, skip = [], conservar = () => false) {
|
|
69
72
|
F.assertNoSymlinkPath(boundary, target)
|
|
70
73
|
fs.mkdirSync(target, { recursive: true })
|
|
71
74
|
const preserved = {}
|
|
@@ -73,7 +76,8 @@ function copyRuntime(source, target, preserve = false, boundary = target, skip =
|
|
|
73
76
|
if (skip.includes(entry.name)) continue
|
|
74
77
|
const from = path.join(source, entry.name)
|
|
75
78
|
const to = path.join(target, entry.name)
|
|
76
|
-
if (entry.isDirectory()
|
|
79
|
+
if (!entry.isDirectory() && conservar(to)) continue
|
|
80
|
+
if (entry.isDirectory()) Object.assign(preserved, copyRuntime(from, to, preserve, boundary, skip, conservar))
|
|
77
81
|
else if (preserve && fs.existsSync(to)) {
|
|
78
82
|
console.log(`= conservado ${to}`)
|
|
79
83
|
preserved[to] = M.digest(from)
|
|
@@ -293,12 +297,19 @@ function upgrade(dir, cli) {
|
|
|
293
297
|
)
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
// Lo que el 001 protege es que una edición local no se pierda, y abortar la corrida entera era una
|
|
301
|
+
// forma cara de conseguirlo: dejaba a quien adoptó Cauce sobre un proceso propio eligiendo entre no
|
|
302
|
+
// actualizar nunca y descartar su corpus. Se conserva archivo por archivo y se actualiza el resto,
|
|
303
|
+
// que es donde viven las reglas que los agentes leen.
|
|
304
|
+
//
|
|
305
|
+
// El aviso sale en **cada** corrida y no sólo la primera: una instancia con medio molde congelado y
|
|
306
|
+
// sin enterarse es el otro modo de fallo, y es silencioso.
|
|
307
|
+
const conservados = force ? new Set() : new Set(changed)
|
|
308
|
+
if (conservados.size) {
|
|
309
|
+
for (const file of conservados) console.log(`= conservado ${file} (editado localmente)`)
|
|
310
|
+
console.log(`\n${conservados.size} archivo(s) del molde quedan congelados por tu edición.`)
|
|
311
|
+
console.log(`${adviceFor([...conservados])}\n`)
|
|
312
|
+
console.log('Para tomar la versión nueva y descartar la tuya, repetí con --force.\n')
|
|
302
313
|
}
|
|
303
314
|
|
|
304
315
|
// Lo que una versión agrega y es del proyecto: se crea si falta y nunca se pisa. `systemPaths` no lo
|
|
@@ -339,13 +350,15 @@ function upgrade(dir, cli) {
|
|
|
339
350
|
}
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
const conservar = (file) => conservados.has(path.relative(root, file).replace(/\\/g, '/'))
|
|
342
354
|
for (const relative of [...system, ...O.RUNTIME_PATHS]) {
|
|
343
355
|
const origin = path.join(PROJECT_ROOT, O.sourceOf(relative))
|
|
344
356
|
if (!fs.existsSync(origin)) continue
|
|
345
357
|
const target = path.join(root, relative)
|
|
358
|
+
if (conservados.has(relative)) continue
|
|
346
359
|
// Sobrescribe lo que trae el paquete y deja intacto lo demás: un guard propio de la empresa,
|
|
347
360
|
// o un adaptador de runner que el toolkit no conoce, sobreviven a la actualización.
|
|
348
|
-
if (fs.statSync(origin).isDirectory()) copyRuntime(origin, target, false, root)
|
|
361
|
+
if (fs.statSync(origin).isDirectory()) copyRuntime(origin, target, false, root, [], conservar)
|
|
349
362
|
else {
|
|
350
363
|
F.assertNoSymlinkPath(root, target)
|
|
351
364
|
F.atomicWrite(target, fs.readFileSync(origin, 'utf8'))
|
|
@@ -357,10 +370,19 @@ function upgrade(dir, cli) {
|
|
|
357
370
|
|
|
358
371
|
// Retirar lo que el toolkit ya no distribuye, después de haber actualizado lo que sí.
|
|
359
372
|
const retired = []
|
|
373
|
+
const pendientes = []
|
|
360
374
|
for (const relative of O.RETIRED) {
|
|
361
375
|
const target = path.join(root, relative)
|
|
362
376
|
if (!fs.existsSync(target)) continue
|
|
363
377
|
F.assertNoSymlinkPath(root, target)
|
|
378
|
+
// Retirar una ruta no vuelve al toolkit dueño de lo que hay adentro. En las dos que un proyecto
|
|
379
|
+
// también usa para lo suyo, borrar el directorio entero se llevaba puesto contenido que nadie
|
|
380
|
+
// había entregado —un `autobuild.js` propio, los workflows de una empresa— sin confirmación y sin
|
|
381
|
+
// vuelta atrás. Se conserva y se nombra; `--force` es la salida, igual que para una edición local.
|
|
382
|
+
const contenido = O.RETIRED_COMPARTIDO.includes(relative) && fs.statSync(target).isDirectory()
|
|
383
|
+
? O.treeFiles(target)
|
|
384
|
+
: []
|
|
385
|
+
if (contenido.length && !force) { pendientes.push({ relative, files: contenido }); continue }
|
|
364
386
|
fs.rmSync(target, { recursive: true, force: true })
|
|
365
387
|
retired.push(relative)
|
|
366
388
|
}
|
|
@@ -368,11 +390,17 @@ function upgrade(dir, cli) {
|
|
|
368
390
|
// Dejar registrado lo que se entregó, para poder distinguir después una edición local de una
|
|
369
391
|
// mejora del toolkit.
|
|
370
392
|
let record = M.read(root)
|
|
393
|
+
// Lo que el toolkit entregó la última vez, antes de re-registrar. Un archivo conservado tiene que
|
|
394
|
+
// conservar **ese** digest: registrar el de disco lo volvería idéntico a lo entregado, dejaría de
|
|
395
|
+
// detectarse como editado y la corrida siguiente lo pisaría sin decir nada. Es el 001 de vuelta por
|
|
396
|
+
// la puerta de atrás, y no se ve mirando el archivo — se ve dos upgrades después.
|
|
397
|
+
const entregado = { ...record }
|
|
371
398
|
for (const relative of O.trackedPaths()) {
|
|
372
399
|
const dir = path.join(root, relative)
|
|
373
400
|
if (fs.existsSync(dir)) record = M.record(root, relative, O.treeFiles(dir), record)
|
|
374
401
|
}
|
|
375
402
|
record = M.recordPaths(root, O.SYSTEM_FILES, record)
|
|
403
|
+
for (const file of conservados) if (entregado[file]) record[file] = entregado[file]
|
|
376
404
|
// El registro de forks se poda igual que el de archivos: un cargo devuelto al catálogo deja su
|
|
377
405
|
// entrada, y una entrada sin copia sólo puede producir avisos sobre algo que no está.
|
|
378
406
|
const kept = Object.fromEntries(Object.entries(M.readForks(root)).filter(
|
|
@@ -385,7 +413,13 @@ function upgrade(dir, cli) {
|
|
|
385
413
|
// Acá es donde las tres versiones se saben a la vez, así que es donde pueden quedar diciendo lo mismo.
|
|
386
414
|
const pinned = pinEngine(root, to)
|
|
387
415
|
|
|
388
|
-
|
|
416
|
+
// El informe recibe qué pasó y no una condición de la que deducirlo: `changed` es «lo que estaba
|
|
417
|
+
// editado», y sin `--force` eso se conserva. Deducirlo hizo que 0.67.0 afirmara diecinueve descartes
|
|
418
|
+
// que no ocurrieron (caso 048).
|
|
419
|
+
reportUpgrade({
|
|
420
|
+
root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
|
|
421
|
+
descartados: force ? changed : [], conservados: [...conservados], pendientes,
|
|
422
|
+
})
|
|
389
423
|
}
|
|
390
424
|
|
|
391
425
|
module.exports = { copyTemplate, scaffold, providerNames, upgrade, destroy, PROJECT_ROOT }
|
package/engine/cli/ops.js
CHANGED
|
@@ -143,6 +143,7 @@ function usage() {
|
|
|
143
143
|
ops check <planning-dir> [--json]
|
|
144
144
|
ops tree <planning-dir> [--no-color] [--json]
|
|
145
145
|
ops context <planning-dir> [--json]
|
|
146
|
+
ops evidence <planning-dir> [--task <slug>] [--json]
|
|
146
147
|
ops upgrade <ops-root> [--check] [--force]
|
|
147
148
|
ops destroy <ops-root> [--force]
|
|
148
149
|
ops archive <planning-dir> <NNN|human-actions>
|
|
@@ -195,6 +196,7 @@ async function run(cli) {
|
|
|
195
196
|
else if (command === 'check') PL.check(arg[1], cli)
|
|
196
197
|
else if (command === 'tree') PL.tree(arg[1], cli)
|
|
197
198
|
else if (command === 'context') PL.context(arg[1], cli)
|
|
199
|
+
else if (command === 'evidence') PL.evidence(arg[1], cli)
|
|
198
200
|
else if (command === 'upgrade') IN.upgrade(arg[1], cli)
|
|
199
201
|
else if (command === 'destroy') IN.destroy(arg[1], cli)
|
|
200
202
|
else if (command === 'agents') CAT.agents(arg[1], arg[2], arg[3], cli)
|
package/engine/cli/planning.js
CHANGED
|
@@ -14,6 +14,7 @@ const AD = require('../planning/adoption')
|
|
|
14
14
|
const AP = require('../hooks/approval')
|
|
15
15
|
const I = require('../integrations/registry')
|
|
16
16
|
const O = require('../core/ownership')
|
|
17
|
+
const EV = require('../core/evidence')
|
|
17
18
|
const OB = require('../core/onboarding')
|
|
18
19
|
const C = require('../config/validate')
|
|
19
20
|
const CP = require('../config/paths')
|
|
@@ -27,6 +28,44 @@ const { fail } = require('./io')
|
|
|
27
28
|
//
|
|
28
29
|
// Va como advertencia y no como error: la empresa es dueña de esos archivos y puede reestructurarlos a
|
|
29
30
|
// propósito. Lo que no puede pasar es que una dimensión desaparezca sin que se vea.
|
|
31
|
+
// El contraste de la evidencia de una entrada de DONE contra lo que no lo escribió su autor. No es una
|
|
32
|
+
// puerta y por eso no vive en `check`: `check` juzga todo DONE, y el registro de gates es rodante —una
|
|
33
|
+
// entrada de hace tres meses no tiene con qué cruzarse—. Acá se pregunta por una entrada, que es como
|
|
34
|
+
// se cierra una tarea: se escribe la evidencia y se la mira contra el árbol y contra lo que corrió.
|
|
35
|
+
function evidence(dir, cli) {
|
|
36
|
+
const root = path.resolve(dir || '.')
|
|
37
|
+
const opsDir = path.join(root, '..')
|
|
38
|
+
const entries = P.readDone(root).entries
|
|
39
|
+
const slug = cli.value('--task')
|
|
40
|
+
const entry = slug ? entries.find((one) => one.slug === slug) : entries[entries.length - 1]
|
|
41
|
+
if (!entry) return fail(slug ? `DONE no tiene la entrada ${slug}` : 'DONE no tiene ninguna entrada', 2)
|
|
42
|
+
|
|
43
|
+
let config = {}
|
|
44
|
+
try { config = JSON.parse(fs.readFileSync(path.join(opsDir, 'ops.config.json'), 'utf8')) } catch { /* sin raíces */ }
|
|
45
|
+
const roots = (Array.isArray(config.workspaceRoots) ? config.workspaceRoots : [])
|
|
46
|
+
.filter((workspace) => workspace && workspace.path)
|
|
47
|
+
.map((workspace) => path.resolve(opsDir, workspace.path))
|
|
48
|
+
.filter((one) => fs.existsSync(one))
|
|
49
|
+
const traces = EV.contrast(entry.tests, roots)
|
|
50
|
+
const runs = EV.runs(opsDir)
|
|
51
|
+
const report = { task: entry.slug, epic: entry.epic, traces, runs }
|
|
52
|
+
if (cli.has('--json')) return console.log(JSON.stringify(report))
|
|
53
|
+
|
|
54
|
+
console.log(`TAREA ${entry.slug}${entry.epic ? ` (epic: ${entry.epic})` : ''}`)
|
|
55
|
+
if (!traces.length) console.log('TESTS (la entrada no rastrea ningún criterio)')
|
|
56
|
+
for (const trace of traces) {
|
|
57
|
+
const nota = trace.verdict === 'inbuscable'
|
|
58
|
+
? (roots.length ? 'describe la prueba en vez de nombrarla' : 'el proyecto no declara raíces de código')
|
|
59
|
+
: ''
|
|
60
|
+
console.log(` ${trace.criterion} → ${trace.artifact} [${trace.verdict}]${nota ? ` — ${nota}` : ''}`)
|
|
61
|
+
}
|
|
62
|
+
if (!runs.length) console.log('GATES (sin corridas registradas; `verify` todavía no corrió acá)')
|
|
63
|
+
for (const run of runs) console.log(`GATES ${run.at} ${run.gate} (exit ${run.status})`)
|
|
64
|
+
// Un contraste que no dice qué no puede ver se lee como si lo hubiera visto todo.
|
|
65
|
+
console.log('Este contraste dice si el artefacto existe y qué gates corrieron al commitear. No dice '
|
|
66
|
+
+ 'que la prueba nombrada haya corrido: eso depende del runner, y varios no la nombran al pasar.')
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
function check(dir, cli) {
|
|
31
70
|
const root = path.resolve(dir || '.')
|
|
32
71
|
const errors = []
|
|
@@ -96,6 +135,24 @@ function check(dir, cli) {
|
|
|
96
135
|
+ 'el archivo sigue autorizándolas')
|
|
97
136
|
}
|
|
98
137
|
|
|
138
|
+
// Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
|
|
139
|
+
// avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
|
|
140
|
+
// en cada corrida y no sólo el día que alguien actualiza.
|
|
141
|
+
const congelados = O.localChanges(path.resolve(root, '..'))
|
|
142
|
+
if (congelados.length) {
|
|
143
|
+
warnings.push(`${congelados.length} archivo(s) del molde congelados por edición local; `
|
|
144
|
+
+ '`upgrade` los conserva y no les trae mejoras')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Y lo que `upgrade` no retiró porque no pudo demostrar que fuera suyo: queda ahí, sin colgar de
|
|
148
|
+
// ningún mecanismo, hasta que alguien lo mueva o lo borre. Se cuenta por lo mismo que los congelados
|
|
149
|
+
// — un resto que no se ve se vuelve permanente.
|
|
150
|
+
const restos = O.RETIRED_COMPARTIDO.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
|
|
151
|
+
if (restos.length) {
|
|
152
|
+
warnings.push(`${restos.length} ruta(s) retiradas siguen en disco con contenido tuyo `
|
|
153
|
+
+ `(${restos.join(', ')}); Cauce ya no las distribuye ni las toca`)
|
|
154
|
+
}
|
|
155
|
+
|
|
99
156
|
const integration = I.validate(path.resolve(root, '..'))
|
|
100
157
|
errors.push(...integration.errors)
|
|
101
158
|
warnings.push(...integration.warnings)
|
|
@@ -356,4 +413,4 @@ function archive(dir, rawNum) {
|
|
|
356
413
|
console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
|
|
357
414
|
}
|
|
358
415
|
|
|
359
|
-
module.exports = { check, tree, context, archive, adopt }
|
|
416
|
+
module.exports = { check, evidence, tree, context, archive, adopt }
|
|
@@ -44,6 +44,18 @@ function adviceFor(changed) {
|
|
|
44
44
|
+ 'es suyo —una ADR propia, una regla propia, o `planning/delivery/project.md` para la entrega—.',
|
|
45
45
|
)
|
|
46
46
|
}
|
|
47
|
+
// El consejo de arriba manda mudar lo propio a donde sí es del proyecto, y para estos cuatro no hay
|
|
48
|
+
// adónde: no existe un PROTOCOL de la empresa que le gane al del toolkit como sí lo hay en `rules/`.
|
|
49
|
+
// Sin decirlo, quien adoptó Cauce sobre su propio proceso lee un consejo que no puede seguir.
|
|
50
|
+
const SIN_CONTRAPARTE = ['planning/PROTOCOL.md', 'planning/METHODOLOGY.md', 'planning/FLOW.md', 'Makefile']
|
|
51
|
+
const propios = docs.filter((file) => SIN_CONTRAPARTE.includes(file))
|
|
52
|
+
if (propios.length) {
|
|
53
|
+
advice.push(
|
|
54
|
+
`${propios.join(', ')} no tienen contraparte propia adónde mudarse: son del toolkit y no hay\n`
|
|
55
|
+
+ 'una versión del proyecto que le gane. Quedan congelados con tu versión y el resto se actualiza\n'
|
|
56
|
+
+ 'igual. Adoptar el del toolkit es trabajo propio —comparar los dos procesos y decidir—, no un flag.',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
47
59
|
// `AGENTS.md` se lo gana aparte porque hasta ahora el README mandaba completarlo, así que el consejo
|
|
48
60
|
// genérico de arriba —«no llevan una línea de la empresa»— le miente justo a quien le hizo caso.
|
|
49
61
|
if (docs.includes('AGENTS.md')) {
|
|
@@ -60,12 +72,23 @@ function adviceFor(changed) {
|
|
|
60
72
|
// propias, agentes editados— queda intacto por construcción, no por comparación.
|
|
61
73
|
// Qué le pasó a la instancia en esta actualización, contado a quien la corrió. Recibe el resultado
|
|
62
74
|
// entero en vez de recalcular nada: lo que se informa es exactamente lo que ocurrió.
|
|
63
|
-
function reportUpgrade({
|
|
75
|
+
function reportUpgrade({
|
|
76
|
+
root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
|
|
77
|
+
descartados, conservados, pendientes,
|
|
78
|
+
}) {
|
|
64
79
|
console.log(`✓ Cauce ${from || '(previa)'} → ${to}`)
|
|
65
80
|
// Descartar con --force es legítimo; hacerlo sin dejar rastro no. Queda en la salida del comando,
|
|
66
|
-
// que es la evidencia que el protocolo pide para cualquier cambio.
|
|
67
|
-
|
|
81
|
+
// que es la evidencia que el protocolo pide para cualquier cambio. Y lo que se conservó no se vuelve
|
|
82
|
+
// a enumerar acá: ya salió con su consejo antes de escribir nada, y repetirlo con el glifo del
|
|
83
|
+
// descarte es lo que volvía ilegible el bloque.
|
|
84
|
+
for (const file of descartados) console.log(`− descartado tu cambio en ${file}`)
|
|
85
|
+
if (conservados.length) console.log(`= ${conservados.length} archivo(s) conservados por tu edición`)
|
|
68
86
|
for (const relative of retired) console.log(`− retirado ${relative}: Cauce ya no lo distribuye`)
|
|
87
|
+
// Sin glifo de acción porque no hubo ninguna: la ruta sigue ahí y el contenido también.
|
|
88
|
+
for (const { relative, files } of pendientes) {
|
|
89
|
+
console.log(` ${relative}: ${files.length} archivo(s) que Cauce no entregó; la ruta se retiró y `
|
|
90
|
+
+ 'el contenido queda. Movelo adonde lo quieras y borrala, o repetí con --force.')
|
|
91
|
+
}
|
|
69
92
|
// Nombrarlos importa tanto como crearlos: existen para que los completes, y uno que aparece sin
|
|
70
93
|
// aviso no lo completa nadie.
|
|
71
94
|
for (const relative of added) console.log(`+ ${relative}: lo agrega esta versión, completalo`)
|
|
@@ -78,9 +101,9 @@ function reportUpgrade({ root, from, to, system, changed, retired, added, overri
|
|
|
78
101
|
for (const override of overrides) {
|
|
79
102
|
console.log(`= conservado ${override.collection}/${override.project}: sobrescribe ${override.system}`)
|
|
80
103
|
}
|
|
81
|
-
// Sólo cuando es cierto
|
|
82
|
-
//
|
|
83
|
-
if (!
|
|
104
|
+
// Sólo cuando es cierto, y ahora lo decide lo que pasó y no una condición: se dice justo cuando no
|
|
105
|
+
// se descartó nada, que incluye la corrida que conservó veinte archivos.
|
|
106
|
+
if (!descartados.length) console.log(' planning, organization y todo lo propio quedaron intactos')
|
|
84
107
|
// No se borra: sin la dependencia declarada, quitarle `.ops/` la dejaría sin motor. Se avisa y
|
|
85
108
|
// decide una persona.
|
|
86
109
|
if (fs.existsSync(path.join(root, '.ops', 'engine'))) {
|
package/engine/cli/wiring.js
CHANGED
|
@@ -12,6 +12,7 @@ const A = require('../automation')
|
|
|
12
12
|
const SC = require('../core/scan')
|
|
13
13
|
const OB = require('../core/onboarding')
|
|
14
14
|
const IN = require('./instance')
|
|
15
|
+
const ST = require('../planning/state')
|
|
15
16
|
const { fail, opsRoot } = require('./io')
|
|
16
17
|
|
|
17
18
|
// Cuántos servicios se listan en pantalla antes de recortar. El resto sigue en `--json`, que es lo que
|
|
@@ -234,6 +235,13 @@ function automation(action, rootArg, runnerName, cli) {
|
|
|
234
235
|
console.log(
|
|
235
236
|
`✓ automatización válida: ${A.GUARD_NAMES.length} guards, ${A.RUNNER_NAMES.length} adaptadores`,
|
|
236
237
|
)
|
|
238
|
+
// De los guards instalados hay uno que no siempre corre, y un guard que a veces no corre tiene que
|
|
239
|
+
// decir cuándo. `plan-first` queda inerte mientras el planning no declare ninguna tarea; sin esta
|
|
240
|
+
// línea la condición sería invisible y el conteo de arriba prometería una cobertura que no está.
|
|
241
|
+
const planning = path.join(root, 'planning')
|
|
242
|
+
if (fs.existsSync(planning) && !ST.hasTasks(planning)) {
|
|
243
|
+
console.log(' plan-first: inerte, el planning todavía no declara tareas')
|
|
244
|
+
}
|
|
237
245
|
return
|
|
238
246
|
}
|
|
239
247
|
if (action === 'doctor') {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El contraste entre lo que una entrada de DONE dice haber probado y lo que se puede comprobar sin
|
|
4
|
+
// creerle. Existe porque los dos lados de `tests: CN → prueba` los escribe el mismo autor en el mismo
|
|
5
|
+
// acto: comparar eso mide consistencia de prosa, no que la prueba exista.
|
|
6
|
+
//
|
|
7
|
+
// Son dos preguntas con alcances distintos y por eso se responden por separado:
|
|
8
|
+
//
|
|
9
|
+
// - **El artefacto existe**: se busca el nombre en las raíces declaradas. Vale para cualquier stack y
|
|
10
|
+
// es lo que atrapa la prueba inventada o renombrada, que es la forma de la evidencia falsa.
|
|
11
|
+
// - **El gate corrió**: sale del registro que escribe `verify`, que es la única vez que el toolkit
|
|
12
|
+
// ejecuta algo y ve su código de salida.
|
|
13
|
+
//
|
|
14
|
+
// Lo que NO se puede responder acá, y decirlo es parte del contraste: que la prueba nombrada haya
|
|
15
|
+
// corrido. Depende del runner, y no de una forma que se pueda normalizar: verificado corriendo una
|
|
16
|
+
// prueba que pasa en go1.26.3, donde `go test ./...` imprime `ok <paquete>` y nunca su nombre, y en
|
|
17
|
+
// node v24.18.0, donde `node --test` imprime `✔ <nombre>`. Una comprobación construida sobre la
|
|
18
|
+
// salida diría «no aparece» sobre los stacks del primer tipo, donde sí corrió.
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs')
|
|
21
|
+
const path = require('node:path')
|
|
22
|
+
|
|
23
|
+
const LOG = path.join('planning', '.verify-log')
|
|
24
|
+
// Rodante: interesa el trabajo en curso, no la historia. Sin tope, el archivo crece con cada commit y
|
|
25
|
+
// nadie lo mira; con tope, lo que queda es lo que todavía se puede cruzar contra una entrada abierta.
|
|
26
|
+
const MAX_RUNS = 20
|
|
27
|
+
|
|
28
|
+
function logPath(root) {
|
|
29
|
+
return path.join(root, LOG)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Una línea por gate corrido. Nunca lanza: es un efecto de borde de un guard, y un registro que no se
|
|
33
|
+
// puede escribir no puede impedir el commit que estaba juzgando.
|
|
34
|
+
function record(root, gate, status) {
|
|
35
|
+
if (!root) return
|
|
36
|
+
try {
|
|
37
|
+
const file = logPath(root)
|
|
38
|
+
const previous = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []
|
|
39
|
+
const entry = JSON.stringify({ at: new Date().toISOString(), gate, status })
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
41
|
+
fs.writeFileSync(file, `${[...previous, entry].slice(-MAX_RUNS).join('\n')}\n`)
|
|
42
|
+
} catch { /* el registro es evidencia, no una puerta */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runs(root) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.readFileSync(logPath(root), 'utf8').split('\n').filter(Boolean)
|
|
48
|
+
.map((line) => { try { return JSON.parse(line) } catch { return null } }).filter(Boolean)
|
|
49
|
+
} catch { return [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Los rastros de una línea `tests:`, ya partidos en criterio y artefacto. `n/a — razón` no rastrea
|
|
53
|
+
// ninguno a propósito y sale de acá vacío, igual que en `contracts`.
|
|
54
|
+
function traces(tests) {
|
|
55
|
+
return String(tests || '').split(/\s*;\s*/).filter(Boolean)
|
|
56
|
+
.map((item) => item.match(/^(A|C\d+)\s*(?:→|->)\s*(.+)$/i))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((match) => ({ criterion: match[1].toUpperCase(), artifact: match[2].trim() }))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Qué se puede ir a buscar. Un artefacto con espacios describe la prueba en vez de nombrarla —el molde
|
|
62
|
+
// admite «nombre de prueba o comando»—, y buscar una frase en el código devuelve siempre que no. Se
|
|
63
|
+
// declara inbuscable en vez de darlo por ausente: un contraste que confunde «no lo encontré» con «no
|
|
64
|
+
// existe» enseña a no leerlo.
|
|
65
|
+
function searchable(artifact) {
|
|
66
|
+
return /^[^\s]{4,}$/.test(artifact) && /[A-Za-z]/.test(artifact)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sourceFiles(dir, found = [], depth = 0) {
|
|
70
|
+
if (depth > 8 || found.length > 5000) return found
|
|
71
|
+
let entries = []
|
|
72
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return found }
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) continue
|
|
75
|
+
const full = path.join(dir, entry.name)
|
|
76
|
+
if (entry.isDirectory()) sourceFiles(full, found, depth + 1)
|
|
77
|
+
else found.push(full)
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Si el nombre aparece en el árbol: como parte de una ruta de archivo o dentro del fuente de alguno.
|
|
83
|
+
// Se mira el contenido y no sólo los nombres porque lo que un rastro nombra suele ser la prueba
|
|
84
|
+
// —`TestAddSuma`— y no el archivo que la contiene.
|
|
85
|
+
function findsArtifact(roots, artifact) {
|
|
86
|
+
for (const root of roots) {
|
|
87
|
+
for (const file of sourceFiles(root)) {
|
|
88
|
+
if (file.replace(/\\/g, '/').includes(artifact)) return true
|
|
89
|
+
let text = ''
|
|
90
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { continue }
|
|
91
|
+
if (text.includes(artifact)) return true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// El veredicto por rastro: `encontrado`, `ausente` o `inbuscable`. Sin raíces declaradas no se afirma
|
|
98
|
+
// nada — no hay dónde mirar, y decir «ausente» ahí sería inventar el hallazgo.
|
|
99
|
+
function contrast(tests, roots) {
|
|
100
|
+
return traces(tests).map((trace) => {
|
|
101
|
+
if (!searchable(trace.artifact)) return { ...trace, verdict: 'inbuscable' }
|
|
102
|
+
if (!roots.length) return { ...trace, verdict: 'inbuscable' }
|
|
103
|
+
return { ...trace, verdict: findsArtifact(roots, trace.artifact) ? 'encontrado' : 'ausente' }
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { MAX_RUNS, record, runs, traces, contrast }
|
package/engine/core/ownership.js
CHANGED
|
@@ -231,6 +231,14 @@ const RETIRED = [
|
|
|
231
231
|
|
|
232
232
|
// Aprendizaje que quedó dentro de una ruta retirada. Es lo único ahí que no se puede reponer, así
|
|
233
233
|
// que se detecta antes de borrar nada: perderlo en silencio sería peor que dejar el directorio.
|
|
234
|
+
// De las rutas retiradas, las que un proyecto también usa para lo suyo. El resto de la lista vive bajo
|
|
235
|
+
// `system/` o es un archivo con nombre propio del toolkit, y ahí nadie más escribe; éstas dos se llaman
|
|
236
|
+
// como el concepto general, así que quien adoptó Cauce cuando se distribuían tiene los suyos justo ahí.
|
|
237
|
+
//
|
|
238
|
+
// El manifiesto no las puede desempatar: `trackedPaths()` nunca las registró, así que para el toolkit
|
|
239
|
+
// todo su contenido es igual de ajeno. Sin poder demostrar que lo entregó, no lo borra.
|
|
240
|
+
const RETIRED_COMPARTIDO = ['automatization/runners', 'automatization/workflows']
|
|
241
|
+
|
|
234
242
|
function retiredWithLearning(root) {
|
|
235
243
|
const found = []
|
|
236
244
|
for (const relative of RETIRED) {
|
|
@@ -294,7 +302,9 @@ function localChanges(root) {
|
|
|
294
302
|
|
|
295
303
|
module.exports = {
|
|
296
304
|
RETIRED,
|
|
305
|
+
RETIRED_COMPARTIDO,
|
|
297
306
|
TEMPLATE_OWN,
|
|
307
|
+
TEMPLATE_PREFIXES,
|
|
298
308
|
addedPaths,
|
|
299
309
|
trackedPaths,
|
|
300
310
|
packageDir,
|
package/engine/hooks/files.js
CHANGED
|
@@ -11,6 +11,9 @@ const {
|
|
|
11
11
|
writableRoots, outsideRoots, DECLARE_IT,
|
|
12
12
|
} = require('./input')
|
|
13
13
|
const AP = require('./approval')
|
|
14
|
+
const { readWip } = require('../planning/parser')
|
|
15
|
+
const { hasTasks } = require('../planning/state')
|
|
16
|
+
const { TEMPLATE_PREFIXES } = require('../core/ownership')
|
|
14
17
|
|
|
15
18
|
// La raíz donde vive `planning/`, que es donde se busca la aprobación por operación.
|
|
16
19
|
function opsRoot(input) {
|
|
@@ -108,6 +111,54 @@ function testEvidence(input) {
|
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
// Lo que la instancia recibe del molde, más los cargos que forkeó. `plan-first` no lo juzga, y no es una
|
|
115
|
+
// concesión: el plan se escribe en `planning/`, así que exigirlo ahí sería un candado cuya llave está
|
|
116
|
+
// adentro. Los recorridos que no pasan por la máquina de tareas escriben en las otras raíces —`onboard`
|
|
117
|
+
// en `organization/`, una evaluación en `agents/`, el sincronizador en `integrations/`— y tampoco tienen
|
|
118
|
+
// un WIP que mostrar. Sale de `ownership` para que una raíz nueva del molde quede exenta sola; `agents/`
|
|
119
|
+
// se suma acá porque no viene del molde, la escribe `fork` en la instancia.
|
|
120
|
+
const OPS_OWNED = [...TEMPLATE_PREFIXES, 'agents/']
|
|
121
|
+
|
|
122
|
+
function opsOwned(root, file) {
|
|
123
|
+
const relative = path.relative(root, file).replace(/\\/g, '/')
|
|
124
|
+
if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return false
|
|
125
|
+
return OPS_OWNED.some((prefix) => relative.startsWith(prefix))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// R1 y el paso 7 del protocolo piden el plan antes del primer cambio, y hasta acá nadie lo comprobaba:
|
|
129
|
+
// tocar el archivo primero y redactar después la aceptación que lo justifica sale igual de verde que
|
|
130
|
+
// hacerlo al revés, y se lee igual en DONE. Lo que se exige es lo mínimo que separa un plan de una
|
|
131
|
+
// intención —WIP activo con al menos un paso escrito—, no que el paso sea bueno; eso lo mira Critique.
|
|
132
|
+
//
|
|
133
|
+
// El conteo sale del mismo parser que `check` y `context`, así que lo que el guard llama plan es lo que
|
|
134
|
+
// el resto del motor llama plan. Un WIP con frontmatter y sin pasos es el estado intermedio que esto
|
|
135
|
+
// vigila: la tarea ya está nombrada y el plan todavía no existe.
|
|
136
|
+
function planFirst(input) {
|
|
137
|
+
if (process.env.OPS_PLAN_FIRST_OVERRIDE === '1') return
|
|
138
|
+
const root = opsRoot(input)
|
|
139
|
+
if (!root) return
|
|
140
|
+
const planning = path.join(root, 'planning')
|
|
141
|
+
const wip = readWip(planning)
|
|
142
|
+
if (wip && wip.complete + wip.pending > 0) return
|
|
143
|
+
// Una instancia recién creada no tiene de dónde sacar una tarea: `onboard` deja el roadmap vacío y
|
|
144
|
+
// dice que alguien lo llene. Exigir el plan ahí es un candado delante de la puerta, y la salida que
|
|
145
|
+
// enseña es apagar el guard en el entorno, que lo deja sin morder para siempre. Se pregunta recién
|
|
146
|
+
// acá: en una instancia con trabajo el camino común sale por el WIP de arriba y no paga esta lectura.
|
|
147
|
+
// Que el guard quede inerte lo dice `automation check`, porque una condición invisible es peor que
|
|
148
|
+
// no tenerla.
|
|
149
|
+
if (!hasTasks(planning)) return
|
|
150
|
+
const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
|
|
151
|
+
const why = `${estado}, así que el plan todavía no está escrito.\n`
|
|
152
|
+
+ 'Escribí en planning/WIP.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
|
|
153
|
+
+ 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
|
|
154
|
+
+ AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
|
|
155
|
+
for (const raw of filesOf(input)) {
|
|
156
|
+
if (opsOwned(root, path.resolve(cwdOf(input), raw))) continue
|
|
157
|
+
if (approved(input, raw)) continue
|
|
158
|
+
block(`${raw} cambia el producto sin plan. ${why}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
111
162
|
function workspaceBoundary(input) {
|
|
112
163
|
const allowed = writableRoots(input)
|
|
113
164
|
if (!allowed) return
|
|
@@ -182,4 +233,7 @@ function engineWrites(input) {
|
|
|
182
233
|
}
|
|
183
234
|
}
|
|
184
235
|
|
|
185
|
-
module.exports = {
|
|
236
|
+
module.exports = {
|
|
237
|
+
secrets, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
238
|
+
migrations, engineWrites,
|
|
239
|
+
}
|
package/engine/hooks/run.js
CHANGED
|
@@ -46,6 +46,7 @@ const guards = {
|
|
|
46
46
|
migrations: files.migrations,
|
|
47
47
|
'integration-snapshot': files.integrationSnapshot,
|
|
48
48
|
'test-evidence': files.testEvidence,
|
|
49
|
+
'plan-first': files.planFirst,
|
|
49
50
|
'planning-drift': planningDrift,
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -53,7 +54,7 @@ const guards = {
|
|
|
53
54
|
const hookGroups = {
|
|
54
55
|
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
|
|
55
56
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
56
|
-
'integration-snapshot', 'test-evidence'],
|
|
57
|
+
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
57
58
|
stop: ['planning-drift'],
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -119,6 +120,11 @@ const hookMetadata = [
|
|
|
119
120
|
event: 'PreToolUse · files',
|
|
120
121
|
purpose: 'Impide apagar o borrar la prueba que juzga el cambio.',
|
|
121
122
|
},
|
|
123
|
+
{
|
|
124
|
+
name: 'plan-first',
|
|
125
|
+
event: 'PreToolUse · files',
|
|
126
|
+
purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
|
|
127
|
+
},
|
|
122
128
|
{
|
|
123
129
|
name: 'planning-drift',
|
|
124
130
|
event: 'Stop / SessionEnd',
|
package/engine/hooks/shell.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
writableRoots, outsideRoots, DECLARE_IT, unquoted, findOpsRoot, withoutGitGlobals,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
|
+
const EV = require('../core/evidence')
|
|
16
17
|
|
|
17
18
|
// Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
|
|
18
19
|
// decidían por su cuenta admitiendo sólo un espacio, el principio o el fin, y en un shell una palabra
|
|
@@ -394,14 +395,24 @@ function commitTree(dir) {
|
|
|
394
395
|
fs.mkdirSync(path.dirname(link), { recursive: true })
|
|
395
396
|
fs.symlinkSync(path.join(dir, name), link, 'junction')
|
|
396
397
|
}
|
|
397
|
-
// Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
398
|
+
// Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
|
|
399
|
+
// por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
|
|
400
|
+
// un repositorio propio, con su índice cargado desde lo que se acaba de materializar, así que `git`
|
|
401
|
+
// contesta sobre lo que el commit va a grabar.
|
|
402
|
+
//
|
|
403
|
+
// Antes esto se resolvía exportando `GIT_DIR` del repositorio de verdad, y ahí el gate que **escribe**
|
|
404
|
+
// con git escribía en él: la suite de un proyecto levanta repositorios de prueba y les commitea, y
|
|
405
|
+
// esos commits caían en la rama del usuario junto con un `core.worktree` apuntando a un temporal ya
|
|
406
|
+
// borrado. Nada lo anunciaba (caso 045).
|
|
407
|
+
//
|
|
408
|
+
// Lo que se pierde a cambio, y son dos cosas. La copia no tiene historia, así que un gate que lea una
|
|
409
|
+
// etiqueta o un `git log` no la encuentra: falla y se ve. Y un gate cuyo efecto ES una escritura de
|
|
410
|
+
// git —taggear, commitear un lockfile regenerado— la hace sobre la copia, que se borra: ese efecto se
|
|
411
|
+
// pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
|
|
412
|
+
// quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
|
|
413
|
+
const started = run('git', ['init', '--quiet'], temp)
|
|
414
|
+
if (started.ok) run('git', ['add', '--all'], temp)
|
|
415
|
+
return { root: temp, temp, env: {} }
|
|
405
416
|
}
|
|
406
417
|
|
|
407
418
|
function verify(input) {
|
|
@@ -429,7 +440,7 @@ function verify(input) {
|
|
|
429
440
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
430
441
|
const { root, temp, env } = commitTree(dir)
|
|
431
442
|
try {
|
|
432
|
-
verifyGates(root, dir, aprobado, env)
|
|
443
|
+
verifyGates(root, dir, aprobado, env, opsRoot(input))
|
|
433
444
|
} finally {
|
|
434
445
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
435
446
|
}
|
|
@@ -438,7 +449,11 @@ function verify(input) {
|
|
|
438
449
|
// Corre lo que el stack declare y bloquea si algo sale en rojo. `root` es dónde corre —el índice
|
|
439
450
|
// materializado o el árbol, que ahí son lo mismo— y `dir` es el repositorio, que es el nombre que le
|
|
440
451
|
// dice algo a quien lee el mensaje.
|
|
441
|
-
|
|
452
|
+
//
|
|
453
|
+
// Cada gate deja su rastro en `ops`; para qué sirve ese registro lo dice `core/evidence.js`. Lo que se
|
|
454
|
+
// decide acá es que el rojo se anota igual que el verde: un gate que falló y se commiteó con
|
|
455
|
+
// aprobación es exactamente lo que alguien va a querer ver después.
|
|
456
|
+
function verifyGates(root, dir, aprobado, env, ops) {
|
|
442
457
|
const failures = []
|
|
443
458
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
444
459
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
@@ -448,16 +463,19 @@ function verifyGates(root, dir, aprobado, env) {
|
|
|
448
463
|
for (const script of ['test', 'lint', 'typecheck', 'build']) {
|
|
449
464
|
if (!pkg.scripts || !pkg.scripts[script]) continue
|
|
450
465
|
const result = run(pm, ['run', script], root, env)
|
|
466
|
+
EV.record(ops, script, result.status)
|
|
451
467
|
if (!result.ok) failures.push(`${script} (exit ${result.status})`)
|
|
452
468
|
}
|
|
453
469
|
} else if (fs.existsSync(path.join(root, 'go.mod'))) {
|
|
454
470
|
const makefile = path.join(root, 'Makefile')
|
|
455
471
|
if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
456
472
|
const result = run('make', ['ci'], root, env)
|
|
473
|
+
EV.record(ops, 'make ci', result.status)
|
|
457
474
|
if (!result.ok) failures.push(`make ci (exit ${result.status})`)
|
|
458
475
|
} else {
|
|
459
476
|
for (const args of [['test', './...'], ['build', './...']]) {
|
|
460
477
|
const result = run('go', args, root, env)
|
|
478
|
+
EV.record(ops, `go ${args[0]}`, result.status)
|
|
461
479
|
if (!result.ok) failures.push(`go ${args[0]} (exit ${result.status})`)
|
|
462
480
|
}
|
|
463
481
|
}
|
|
@@ -465,6 +483,7 @@ function verifyGates(root, dir, aprobado, env) {
|
|
|
465
483
|
const makefile = path.join(root, 'Makefile')
|
|
466
484
|
if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
467
485
|
const result = run('make', ['test'], root, env)
|
|
486
|
+
EV.record(ops, 'make test', result.status)
|
|
468
487
|
if (!result.ok) failures.push(`make test (exit ${result.status})`)
|
|
469
488
|
}
|
|
470
489
|
}
|
|
@@ -286,10 +286,34 @@ const HUMAN_ACTION_STATES = ['pendiente', 'resuelta']
|
|
|
286
286
|
// («resuelta 2026-08-17») siga valiendo sin que una palabra suelta dentro de un texto largo resuelva
|
|
287
287
|
// una fila que sigue abierta. `valid` distingue la fila mal escrita de la fila pendiente: las dos
|
|
288
288
|
// bloquean, pero sólo una es un error que hay que reportar.
|
|
289
|
+
// En markdown un pipe dentro de una celda se escribe `\|` —es la única forma que hay— así que partir
|
|
290
|
+
// por todo `|` abre esa celda en dos y corre las columnas de la fila. El daño peor es silencioso: con el
|
|
291
|
+
// pipe detrás de la palabra del vocabulario, el estado sigue leyéndose bien, `check` pasa y lo que se
|
|
292
|
+
// entrega como acción de desbloqueo es el contenido de `Origen`.
|
|
293
|
+
//
|
|
294
|
+
// Lo que no cubre: una celda que termine en una barra invertida literal. En markdown eso se escribe
|
|
295
|
+
// `\\` y acá se leería como escape del separador. Es un borde que nadie escribe y taparlo pedía un
|
|
296
|
+
// parser de verdad; queda dicho en vez de supuesto.
|
|
297
|
+
const SEPARADOR = /(?<!\\)\|/
|
|
298
|
+
const SEPARADORES = /^\|\s*:?-+/
|
|
299
|
+
|
|
300
|
+
// El escape se quita al normalizar. Esta columna existe para que una persona lea qué tiene que hacer, y
|
|
301
|
+
// `\|` no es parte de lo que quiso decir: es cómo markdown escribe un pipe. La fila archivada no se ve
|
|
302
|
+
// afectada —`archive` reescribe `raw`, la línea original, no las celdas—, así que quitarlo no pierde nada.
|
|
303
|
+
const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
|
|
304
|
+
|
|
289
305
|
function readHumanActions(dir) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
const lineas = withoutComments(read(path.join(dir, 'HUMAN_ACTIONS.md'))).split('\n')
|
|
307
|
+
// En markdown la cabecera es la fila anterior a la de separadores, diga lo que diga su primera celda.
|
|
308
|
+
// Se marcan todas y no la primera: un archivo con una tabla por sección tiene una cabecera por tabla,
|
|
309
|
+
// y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
|
|
310
|
+
// fila de datos nunca está inmediatamente antes de los guiones.
|
|
311
|
+
const cabeceras = new Set(lineas.map((line, i) => (SEPARADORES.test(line) ? i - 1 : -1)))
|
|
312
|
+
const rows = lineas
|
|
313
|
+
.filter((line, i) => /^\|/.test(line) && !SEPARADORES.test(line) && !cabeceras.has(i))
|
|
314
|
+
.map((line) => ({ line, cells: line.split(SEPARADOR).slice(1, -1).map(celda) }))
|
|
315
|
+
// El literal queda como resguardo de la tabla escrita sin su fila de separadores: markdown no la
|
|
316
|
+
// renderiza como tabla, y este parser lee sus filas igual.
|
|
293
317
|
return rows.filter(({ cells }) => cells.length >= 4 && !/^tarea$/i.test(cells[0]))
|
|
294
318
|
.map(({ line, cells }) => {
|
|
295
319
|
const state = (cells[1].match(new RegExp(`^(${HUMAN_ACTION_STATES.join('|')})\\b`, 'i')) || [])[1] || ''
|
package/engine/planning/state.js
CHANGED
|
@@ -50,4 +50,14 @@ function currentTask({ milestones, done, wip }, blockers = []) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// Si el planning declara trabajo, en cualquiera de sus dos estados. Lo preguntan dos: el guard
|
|
54
|
+
// `plan-first`, para no exigir un plan donde todavía no hay de dónde sacar una tarea, y
|
|
55
|
+
// `automation check`, para poder decir que ese guard está inerte. Vive acá y no en el guard porque con
|
|
56
|
+
// dos copias una se pudre y el reporte anuncia una condición distinta de la que el guard aplica.
|
|
57
|
+
function hasTasks(root) {
|
|
58
|
+
return P.readBacklog(root).some((milestone) => milestone.tasks.length > 0)
|
|
59
|
+
|| P.readDone(root).entries.length > 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
module.exports = { snapshot, pendingHumanActions, currentTask, hasTasks }
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -139,6 +139,7 @@ y eso lo deja apagado **hasta que cierres la sesión**, no para un comando.
|
|
|
139
139
|
| `OPS_MIGRATIONS_OVERRIDE=1` | el de migraciones |
|
|
140
140
|
| `OPS_TEST_EVIDENCE_OVERRIDE=1` | el de evidencia de pruebas |
|
|
141
141
|
| `OPS_DEPENDENCIES_OVERRIDE=1` | el de dependencias, incluido publicar e instalar global |
|
|
142
|
+
| `OPS_PLAN_FIRST_OVERRIDE=1` | el que exige plan antes de cambiar el producto |
|
|
142
143
|
| `OPS_SKIP_VERIFY=1` | el que corre los gates |
|
|
143
144
|
|
|
144
145
|
Por eso la aprobación es la vía recomendada y esto es lo que queda cuando no alcanza: prendela para lo
|
|
@@ -153,8 +154,14 @@ muta nada.
|
|
|
153
154
|
aceptación y sus criterios. Es la entrada correcta para empezar a trabajar.
|
|
154
155
|
- `node tools/ops.js tree planning` — panorama de roadmap, backlog, WIP, inbox y done.
|
|
155
156
|
- `node tools/ops.js check planning` — validación de contratos y trazabilidad.
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
- `node tools/ops.js evidence planning [--task <slug>]` — contrasta la evidencia de una entrada de DONE
|
|
158
|
+
contra lo que no escribió su autor: si el artefacto que `tests:` nombra existe en las raíces de
|
|
159
|
+
código, y qué gates corrió `verify` al commitear, con su código de salida. Al cerrar una tarea, es la
|
|
160
|
+
única parte de esa evidencia que no sale de la misma mano que la afirma. No dice que la prueba
|
|
161
|
+
nombrada haya corrido —eso depende del runner, y varios no la nombran al pasar— ni reemplaza a leer
|
|
162
|
+
su fuente, que es lo que R9 pide.
|
|
163
|
+
|
|
164
|
+
Los cuatro aceptan `--json`. Leer `BACKLOG.md`, `WIP.md` o `HUMAN_ACTIONS.md` completos sólo cuando haga
|
|
158
165
|
falta editarlos o cuando el CLI no responda la pregunta.
|
|
159
166
|
|
|
160
167
|
## Autonomía
|
package/template/gitignore
CHANGED
|
@@ -5,5 +5,9 @@ node_modules/
|
|
|
5
5
|
.env.*
|
|
6
6
|
!.env.example
|
|
7
7
|
|
|
8
|
+
# El registro de gates que escribe `verify`: es evidencia de una corrida local, rodante y de la
|
|
9
|
+
# máquina. Committearlo sería historia que nadie lee y un conflicto por commit.
|
|
10
|
+
planning/.verify-log
|
|
11
|
+
|
|
8
12
|
*.tgz
|
|
9
13
|
.DS_Store
|
|
@@ -31,6 +31,18 @@ y la cobertura no lo va a decir: mide qué líneas se ejecutan, no qué defectos
|
|
|
31
31
|
La precondición del caso también cuenta. Si el estado en que arranca no puede ocurrir por el camino de
|
|
32
32
|
producción, lo que prueba tampoco: queda verde para siempre sobre algo que nadie va a vivir.
|
|
33
33
|
|
|
34
|
+
**Y quitar un comportamiento se prueba al revés que agregarlo.** Una prueba que comprueba que aparece lo
|
|
35
|
+
nuevo no comprueba que desapareció lo viejo: los dos pueden convivir, y ahí el verde dice que la mitad
|
|
36
|
+
del cambio ocurrió. La aserción que hace falta es de ausencia —que la salida vieja ya no esté, que la
|
|
37
|
+
rama vieja ya no corra—, y es la que no se escribe sola porque nadie la extraña.
|
|
38
|
+
|
|
39
|
+
Lo que se quita, además, tiene dependientes, y no se anuncian. Una invariante que deja de valer se lleva
|
|
40
|
+
puesto a quien la daba por cierta: el mensaje que la afirmaba, la condición que la deducía, el comentario
|
|
41
|
+
que la explicaba. Suelen vivir en otro archivo, que es donde una premisa vieja se pudre sin que nada
|
|
42
|
+
falle. Antes de entregar una quita se busca quién dependía de ella —qué la afirmaba, qué la deducía— y
|
|
43
|
+
cada uno se corrige o se declara. Recorrer la enumeración de la tarea no encuentra esto: la enumeración
|
|
44
|
+
dice qué había que hacer, no qué se apoyaba en lo que había.
|
|
45
|
+
|
|
34
46
|
## R10 — Publicación humana por defecto
|
|
35
47
|
|
|
36
48
|
Push, PR, merge, tags, deploy y rollback requieren la autorización configurada para el proyecto.
|