@ingeniomaps/cauce 0.66.0 → 0.67.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,76 @@ 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.67.0] - 2026-09-07
18
+
19
+ ### Agregado
20
+
21
+ - **Guard `plan-first`: no se cambia el producto sin un plan escrito.** R1 y el paso 7 del protocolo lo
22
+ piden desde siempre y nada lo comprobaba: tocar el archivo primero y redactar después la aceptación
23
+ que lo justifica salía igual de verde y se leía igual en DONE. Ahora una escritura de producto exige
24
+ un WIP activo con al menos un paso numerado.
25
+
26
+ No juzga lo que tu instancia posee —`planning/`, `organization/`, `agents/`, `flows/`,
27
+ `integrations/`, `automatization/`, `tools/`—: el plan se escribe en `planning/`, y exigirlo ahí sería
28
+ un candado con la llave adentro. Y queda **inerte mientras tu planning no declare ninguna tarea**, que
29
+ es una instancia recién creada; `automation check` te lo dice cuando lo está.
30
+
31
+ **Lo que te pide algo**: si el cambio no es trabajo de una tarea, aprobá la ruta en
32
+ `planning/.ops-approval`. La variable `OPS_PLAN_FIRST_OVERRIDE=1` lo apaga para toda la sesión.
33
+
34
+ - **`ops evidence <planning-dir>`: contrasta la evidencia de una entrada de DONE contra lo que no
35
+ escribió su autor.** Los dos lados de `tests: CN → prueba` los escribía la misma mano en el mismo
36
+ acto, así que compararlos medía prosa. Ahora se comprueban dos cosas independientes: si el artefacto
37
+ que el rastro nombra existe en tus raíces de código, y qué gates corrió `verify` al commitear con su
38
+ código de salida. Dice también lo que **no** puede contestar —que la prueba nombrada haya corrido
39
+ depende del runner— y no reemplaza a leer su fuente, que es lo que R9 pide.
40
+
41
+ **Lo que te pide algo**: `verify` deja ese registro en `planning/.verify-log`. Tu `.gitignore` no se
42
+ actualiza con el molde —es de `init`—, así que agregale esa línea o el archivo te va a aparecer sin
43
+ trackear en cada commit.
44
+
45
+ ### Cambiado
46
+
47
+ - **`upgrade` conserva lo editado y actualiza el resto, en vez de abortar.** Antes cortaba si algún
48
+ archivo del molde estaba editado, y el único flag que lo destrababa descartaba todos tus cambios de
49
+ una: quien adoptó Cauce sobre un proceso propio quedaba eligiendo entre no actualizar nunca y perder
50
+ su corpus. Ahora recibís `rules/system/` y `adr/system/` frescos y conservás lo tuyo, y la corrida
51
+ nombra qué congeló — en cada corrida, no sólo la primera.
52
+
53
+ `check` cuenta esos archivos como advertencia para que la deuda no desaparezca entre actualización y
54
+ actualización. Y el consejo nombra los cuatro que no tienen contraparte propia adónde mudarse
55
+ —`PROTOCOL.md`, `METHODOLOGY.md`, `FLOW.md` y el `Makefile`—, en vez de mandarte a mudarlos.
56
+
57
+ **Lo que te pide algo**: `upgrade` ya no devuelve código distinto de cero por una edición local. Si lo
58
+ llamás desde un script que esperaba ese fallo, ese script cambia. `--force` sigue reemplazando todo.
59
+
60
+ ### Corregido
61
+
62
+ - **Un pipe escapado en `HUMAN_ACTIONS.md` corría las columnas de su fila.** En markdown un pipe dentro
63
+ de una celda se escribe `\|` —es la única forma— y el parser lo tomaba como separador. La cara que
64
+ importa era silenciosa: con el pipe detrás de la palabra del vocabulario, `check` pasaba y el runner
65
+ recibía el contenido de `Origen` como si fuera la acción de desbloqueo. El escape ya no parte la
66
+ celda, y se quita al leerla porque esa columna existe para que una persona la lea.
67
+
68
+ **Puede que empieces a ver un error nuevo**, y es correcto: una fila con las columnas corridas podía
69
+ esconder un estado fuera del vocabulario y desbloquear una tarea que nadie resolvió. Leída bien, ese
70
+ estado se ve.
71
+
72
+ - **La cabecera de `HUMAN_ACTIONS.md` sólo se salteaba si decía exactamente `Tarea`.** Cualquier otro
73
+ encabezado —`Tarea Requerida`, `Bloqueo`— caía del lado de los datos y `check` reportaba las etiquetas
74
+ de tus columnas como una acción rota. Ahora la cabecera se reconoce por su forma —es la fila anterior
75
+ a la de separadores— y se saltea la de **cada** tabla del archivo, no sólo la primera.
76
+
77
+ - **Los gates de `verify` corrían con el `GIT_DIR` de tu repositorio.** Un gate que escribe con git
78
+ —una suite que levanta repositorios de prueba y les commitea— escribía entonces en el tuyo: commits
79
+ ajenos en tu rama, archivos trackeados que nadie agregó y un `core.worktree` apuntando a un temporal
80
+ ya borrado, sin que nada lo anunciara. Se disparaba al committear una naturaleza por vez, que es lo
81
+ que R8 pide. Ahora la copia que `verify` materializa es un repositorio propio.
82
+
83
+ **Lo que te pide algo**: esa copia no tiene historia. Un gate que lea una etiqueta o un `git log` no
84
+ la encuentra, y un gate cuyo efecto **es** una escritura de git —taggear, commitear un lockfile
85
+ regenerado— la hace sobre la copia, que se borra. Si tenés un gate así, sacá esa escritura del gate.
86
+
17
87
  ## [0.66.0] - 2026-09-07
18
88
 
19
89
  ### 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'))
@@ -368,11 +381,17 @@ function upgrade(dir, cli) {
368
381
  // Dejar registrado lo que se entregó, para poder distinguir después una edición local de una
369
382
  // mejora del toolkit.
370
383
  let record = M.read(root)
384
+ // Lo que el toolkit entregó la última vez, antes de re-registrar. Un archivo conservado tiene que
385
+ // conservar **ese** digest: registrar el de disco lo volvería idéntico a lo entregado, dejaría de
386
+ // detectarse como editado y la corrida siguiente lo pisaría sin decir nada. Es el 001 de vuelta por
387
+ // la puerta de atrás, y no se ve mirando el archivo — se ve dos upgrades después.
388
+ const entregado = { ...record }
371
389
  for (const relative of O.trackedPaths()) {
372
390
  const dir = path.join(root, relative)
373
391
  if (fs.existsSync(dir)) record = M.record(root, relative, O.treeFiles(dir), record)
374
392
  }
375
393
  record = M.recordPaths(root, O.SYSTEM_FILES, record)
394
+ for (const file of conservados) if (entregado[file]) record[file] = entregado[file]
376
395
  // El registro de forks se poda igual que el de archivos: un cargo devuelto al catálogo deja su
377
396
  // entrada, y una entrada sin copia sólo puede producir avisos sobre algo que no está.
378
397
  const kept = Object.fromEntries(Object.entries(M.readForks(root)).filter(
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,15 @@ 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
+
99
147
  const integration = I.validate(path.resolve(root, '..'))
100
148
  errors.push(...integration.errors)
101
149
  warnings.push(...integration.warnings)
@@ -356,4 +404,4 @@ function archive(dir, rawNum) {
356
404
  console.log(`✓ epic-${num}: ${entries.length} entrada(s) archivadas`)
357
405
  }
358
406
 
359
- module.exports = { check, tree, context, archive, adopt }
407
+ 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')) {
@@ -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 }
@@ -295,6 +295,7 @@ function localChanges(root) {
295
295
  module.exports = {
296
296
  RETIRED,
297
297
  TEMPLATE_OWN,
298
+ TEMPLATE_PREFIXES,
298
299
  addedPaths,
299
300
  trackedPaths,
300
301
  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.67.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