@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 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;
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Shim: delega `plan-first` en run-hook.sh; el registro de engine/hooks/run.js nombra su módulo.
3
+ exec "$(dirname "$0")/run-hook.sh" plan-first
@@ -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: [],
@@ -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
- const git = (...args) => spawnSync('git', ['-C', dir, ...args], { stdio: 'ignore' })
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')
@@ -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
- function copyRuntime(source, target, preserve = false, boundary = target, skip = []) {
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()) Object.assign(preserved, copyRuntime(from, to, preserve, boundary, skip))
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
- if (changed.length && !force) {
297
- for (const file of changed) console.error(`✗ ${file}`)
298
- fail(
299
- `\n${changed.length} archivo(s) que mantiene Cauce fueron editados y se perderían.\n\n` +
300
- `${adviceFor(changed)}\n\nSi el cambio ya no te sirve, repetí con --force para descartarlo.`,
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
- reportUpgrade({ root, from, to, system, changed, retired, added, overrides, pinned, droppedBlocks })
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)
@@ -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({ root, from, to, system, changed, retired, added, overrides, pinned, droppedBlocks }) {
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
- for (const file of changed) console.log(`− descartado tu cambio en ${file}`)
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: llegar acá con algo en `changed` es haber descartado contenido de la
82
- // empresa con --force, que las líneas de arriba enumeran.
83
- if (!changed.length) console.log(' planning, organization y todo lo propio quedaron intactos')
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'))) {
@@ -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 }
@@ -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,
@@ -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 = { secrets, integrationSnapshot, generated, testEvidence, workspaceBoundary, migrations, engineWrites }
236
+ module.exports = {
237
+ secrets, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
238
+ migrations, engineWrites,
239
+ }
@@ -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',
@@ -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, leer una
398
- // etiqueta— falla ahí por no encontrarlo: el guard frenaría un commit correcto por su propia
399
- // mecánica. Comprobado sobre la suite de este repositorio, que pasa de dos fallos a ninguno con estas
400
- // dos variables. Apuntan al repositorio de verdad con el árbol puesto en la copia, así que `git`
401
- // contesta sobre lo que se va a commitear.
402
- const gitDir = run('git', ['-C', dir, 'rev-parse', '--absolute-git-dir'], dir)
403
- const env = gitDir.ok ? { GIT_DIR: gitDir.output.trim(), GIT_WORK_TREE: temp } : {}
404
- return { root: temp, temp, env }
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
- function verifyGates(root, dir, aprobado, env) {
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 rows = withoutComments(read(path.join(dir, 'HUMAN_ACTIONS.md'))).split('\n')
291
- .filter((line) => /^\|/.test(line) && !/^\|\s*:?-+/.test(line))
292
- .map((line) => ({ line, cells: line.split('|').slice(1, -1).map((cell) => cell.trim()) }))
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] || ''
@@ -50,4 +50,14 @@ function currentTask({ milestones, done, wip }, blockers = []) {
50
50
  }
51
51
 
52
52
 
53
- module.exports = { snapshot, pendingHumanActions, currentTask }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -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
- Los tres aceptan `--json`. Leer `BACKLOG.md`, `WIP.md` o `HUMAN_ACTIONS.md` completos sólo cuando haga
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
@@ -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.