@ingeniomaps/cauce 0.65.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 +87 -0
- package/automatization/hooks/README.md +1 -0
- package/automatization/hooks/guard-plan-first.sh +3 -0
- package/engine/cli/args.js +2 -0
- package/engine/cli/catalog.js +8 -1
- package/engine/cli/instance.js +28 -9
- package/engine/cli/ops.js +2 -0
- package/engine/cli/planning.js +49 -1
- package/engine/cli/upgrade-report.js +12 -0
- package/engine/cli/wiring.js +8 -0
- package/engine/core/evidence.js +107 -0
- package/engine/core/ownership.js +1 -0
- package/engine/hooks/files.js +55 -1
- package/engine/hooks/run.js +7 -1
- package/engine/hooks/shell.js +67 -12
- package/engine/planning/parser.js +27 -3
- package/engine/planning/state.js +11 -1
- package/package.json +1 -1
- package/template/AGENTS.md +9 -2
- package/template/gitignore +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,93 @@ 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
|
+
|
|
87
|
+
## [0.66.0] - 2026-09-07
|
|
88
|
+
|
|
89
|
+
### Corregido
|
|
90
|
+
|
|
91
|
+
- **`shell-boundary` ignoraba el `cd` del propio comando.** Resolvía las rutas relativas contra el
|
|
92
|
+
directorio que le entrega tu runner, no contra el que el comando elige antes de escribir, así que
|
|
93
|
+
juzgaba una ruta que nadie iba a escribir. Fallaba para los dos lados: `cd <fuera de tus raíces> &&
|
|
94
|
+
echo x > nota.md` pasaba sin decir nada y el archivo se escribía afuera; y con el runner abierto en
|
|
95
|
+
otro directorio, un `cd` a un lugar legítimo se frenaba nombrando una ruta que no estaba en el
|
|
96
|
+
comando.
|
|
97
|
+
|
|
98
|
+
Ahora cada escritura se juzga contra el `cd` que la precede. **Lo que te pide algo**: un `cd` cuyo
|
|
99
|
+
destino no se puede saber acá —`cd $TRABAJO`, `cd -`— deja sin juzgar a toda ruta relativa que venga
|
|
100
|
+
después, y eso se bloquea pidiendo la ruta absoluta o el `cd` en un comando aparte. Es deliberado y
|
|
101
|
+
es el criterio que ya regía para el índice: un guard que no puede verificar no autoriza. Una ruta
|
|
102
|
+
absoluta no depende del `cd` y se sigue juzgando igual.
|
|
103
|
+
|
|
17
104
|
## [0.65.0] - 2026-09-07
|
|
18
105
|
|
|
19
106
|
### Cambiado
|
|
@@ -7,6 +7,7 @@ Los hooks convierten invariantes comprobables en gates mecánicos. La base recom
|
|
|
7
7
|
- edición manual de código generado y drift respecto a OpenAPI/SQL;
|
|
8
8
|
- commits sin Verify aplicable;
|
|
9
9
|
- apagado o borrado de la prueba que juzga el cambio;
|
|
10
|
+
- cambio de producto sin un WIP activo que traiga el plan;
|
|
10
11
|
- cierre de sesión con planning o integraciones inválidas;
|
|
11
12
|
- modificación del protocolo durante una tarea de producto.
|
|
12
13
|
- escrituras fuera de las raíces declaradas del workspace;
|
package/engine/cli/args.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Banderas que consumen el argumento siguiente: su valor no es un posicional.
|
|
8
8
|
const VALUED_FLAGS = new Set([
|
|
9
9
|
'--name', '--mode', '--fixture', '--period', '--record', '--runner', '--integration',
|
|
10
|
+
'--task',
|
|
10
11
|
])
|
|
11
12
|
|
|
12
13
|
// Qué acepta cada comando, y a la vez qué comandos existen. Una bandera desconocida se rechaza en vez
|
|
@@ -19,6 +20,7 @@ const FLAGS = {
|
|
|
19
20
|
check: ['--json'],
|
|
20
21
|
tree: ['--json', '--no-color'],
|
|
21
22
|
context: ['--json'],
|
|
23
|
+
evidence: ['--json', '--task'],
|
|
22
24
|
upgrade: ['--check', '--force'],
|
|
23
25
|
destroy: ['--force'],
|
|
24
26
|
archive: [],
|
package/engine/cli/catalog.js
CHANGED
|
@@ -129,7 +129,14 @@ function evaluationBench(root, agent, caso, force, kind) {
|
|
|
129
129
|
// uno contestó un resumen y escribió el contrato entero en su `INBOX.md`, y el juez —que sólo leía
|
|
130
130
|
// la respuesta— lo dio por ausente. Con git, `status` y `diff` muestran qué produjo, separado del
|
|
131
131
|
// andamiaje. Se ignora `node_modules`: es un symlink al toolkit, no obra del cargo.
|
|
132
|
-
|
|
132
|
+
// `-C` dice dónde mirar y `GIT_DIR` gana igual —comprobado: con `GIT_DIR` puesto,
|
|
133
|
+
// `git -C otro rev-parse --absolute-git-dir` contesta el de la variable—, así que sin limpiarla el
|
|
134
|
+
// banco commitea en el repositorio que la haya exportado. Es lo que hizo el caso 045 antes de
|
|
135
|
+
// arreglarse en `hooks/shell.js`: el banco de una evaluación dejó sus commits en la rama del usuario.
|
|
136
|
+
const env = { ...process.env }
|
|
137
|
+
delete env.GIT_DIR
|
|
138
|
+
delete env.GIT_WORK_TREE
|
|
139
|
+
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { stdio: 'ignore', env })
|
|
133
140
|
fs.appendFileSync(path.join(dir, '.gitignore'), '\nnode_modules/\n')
|
|
134
141
|
git('init', '-q')
|
|
135
142
|
git('config', 'user.email', 'banco@cauce.local')
|
package/engine/cli/instance.js
CHANGED
|
@@ -65,7 +65,10 @@ function copyTemplate(source, target, replacements, force, skip = [], quiet = fa
|
|
|
65
65
|
|
|
66
66
|
// Devuelve lo conservado igual que `copyTemplate`, y por la misma razón: acá el runtime no lleva
|
|
67
67
|
// reemplazos, así que lo que habríamos escrito es el archivo del paquete tal cual.
|
|
68
|
-
|
|
68
|
+
// `preserve` conserva todo lo que ya exista —es lo que `init` necesita— y `conservar` conserva sólo lo
|
|
69
|
+
// que responda que sí, que es lo que `upgrade` necesita para saltear una edición local sin congelar el
|
|
70
|
+
// resto del directorio. Son dos preguntas distintas y por eso no se unifican en una.
|
|
71
|
+
function copyRuntime(source, target, preserve = false, boundary = target, skip = [], conservar = () => false) {
|
|
69
72
|
F.assertNoSymlinkPath(boundary, target)
|
|
70
73
|
fs.mkdirSync(target, { recursive: true })
|
|
71
74
|
const preserved = {}
|
|
@@ -73,7 +76,8 @@ function copyRuntime(source, target, preserve = false, boundary = target, skip =
|
|
|
73
76
|
if (skip.includes(entry.name)) continue
|
|
74
77
|
const from = path.join(source, entry.name)
|
|
75
78
|
const to = path.join(target, entry.name)
|
|
76
|
-
if (entry.isDirectory()
|
|
79
|
+
if (!entry.isDirectory() && conservar(to)) continue
|
|
80
|
+
if (entry.isDirectory()) Object.assign(preserved, copyRuntime(from, to, preserve, boundary, skip, conservar))
|
|
77
81
|
else if (preserve && fs.existsSync(to)) {
|
|
78
82
|
console.log(`= conservado ${to}`)
|
|
79
83
|
preserved[to] = M.digest(from)
|
|
@@ -293,12 +297,19 @@ function upgrade(dir, cli) {
|
|
|
293
297
|
)
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
// Lo que el 001 protege es que una edición local no se pierda, y abortar la corrida entera era una
|
|
301
|
+
// forma cara de conseguirlo: dejaba a quien adoptó Cauce sobre un proceso propio eligiendo entre no
|
|
302
|
+
// actualizar nunca y descartar su corpus. Se conserva archivo por archivo y se actualiza el resto,
|
|
303
|
+
// que es donde viven las reglas que los agentes leen.
|
|
304
|
+
//
|
|
305
|
+
// El aviso sale en **cada** corrida y no sólo la primera: una instancia con medio molde congelado y
|
|
306
|
+
// sin enterarse es el otro modo de fallo, y es silencioso.
|
|
307
|
+
const conservados = force ? new Set() : new Set(changed)
|
|
308
|
+
if (conservados.size) {
|
|
309
|
+
for (const file of conservados) console.log(`= conservado ${file} (editado localmente)`)
|
|
310
|
+
console.log(`\n${conservados.size} archivo(s) del molde quedan congelados por tu edición.`)
|
|
311
|
+
console.log(`${adviceFor([...conservados])}\n`)
|
|
312
|
+
console.log('Para tomar la versión nueva y descartar la tuya, repetí con --force.\n')
|
|
302
313
|
}
|
|
303
314
|
|
|
304
315
|
// Lo que una versión agrega y es del proyecto: se crea si falta y nunca se pisa. `systemPaths` no lo
|
|
@@ -339,13 +350,15 @@ function upgrade(dir, cli) {
|
|
|
339
350
|
}
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
const conservar = (file) => conservados.has(path.relative(root, file).replace(/\\/g, '/'))
|
|
342
354
|
for (const relative of [...system, ...O.RUNTIME_PATHS]) {
|
|
343
355
|
const origin = path.join(PROJECT_ROOT, O.sourceOf(relative))
|
|
344
356
|
if (!fs.existsSync(origin)) continue
|
|
345
357
|
const target = path.join(root, relative)
|
|
358
|
+
if (conservados.has(relative)) continue
|
|
346
359
|
// Sobrescribe lo que trae el paquete y deja intacto lo demás: un guard propio de la empresa,
|
|
347
360
|
// o un adaptador de runner que el toolkit no conoce, sobreviven a la actualización.
|
|
348
|
-
if (fs.statSync(origin).isDirectory()) copyRuntime(origin, target, false, root)
|
|
361
|
+
if (fs.statSync(origin).isDirectory()) copyRuntime(origin, target, false, root, [], conservar)
|
|
349
362
|
else {
|
|
350
363
|
F.assertNoSymlinkPath(root, target)
|
|
351
364
|
F.atomicWrite(target, fs.readFileSync(origin, 'utf8'))
|
|
@@ -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)
|
package/engine/cli/planning.js
CHANGED
|
@@ -14,6 +14,7 @@ const AD = require('../planning/adoption')
|
|
|
14
14
|
const AP = require('../hooks/approval')
|
|
15
15
|
const I = require('../integrations/registry')
|
|
16
16
|
const O = require('../core/ownership')
|
|
17
|
+
const EV = require('../core/evidence')
|
|
17
18
|
const OB = require('../core/onboarding')
|
|
18
19
|
const C = require('../config/validate')
|
|
19
20
|
const CP = require('../config/paths')
|
|
@@ -27,6 +28,44 @@ const { fail } = require('./io')
|
|
|
27
28
|
//
|
|
28
29
|
// Va como advertencia y no como error: la empresa es dueña de esos archivos y puede reestructurarlos a
|
|
29
30
|
// propósito. Lo que no puede pasar es que una dimensión desaparezca sin que se vea.
|
|
31
|
+
// El contraste de la evidencia de una entrada de DONE contra lo que no lo escribió su autor. No es una
|
|
32
|
+
// puerta y por eso no vive en `check`: `check` juzga todo DONE, y el registro de gates es rodante —una
|
|
33
|
+
// entrada de hace tres meses no tiene con qué cruzarse—. Acá se pregunta por una entrada, que es como
|
|
34
|
+
// se cierra una tarea: se escribe la evidencia y se la mira contra el árbol y contra lo que corrió.
|
|
35
|
+
function evidence(dir, cli) {
|
|
36
|
+
const root = path.resolve(dir || '.')
|
|
37
|
+
const opsDir = path.join(root, '..')
|
|
38
|
+
const entries = P.readDone(root).entries
|
|
39
|
+
const slug = cli.value('--task')
|
|
40
|
+
const entry = slug ? entries.find((one) => one.slug === slug) : entries[entries.length - 1]
|
|
41
|
+
if (!entry) return fail(slug ? `DONE no tiene la entrada ${slug}` : 'DONE no tiene ninguna entrada', 2)
|
|
42
|
+
|
|
43
|
+
let config = {}
|
|
44
|
+
try { config = JSON.parse(fs.readFileSync(path.join(opsDir, 'ops.config.json'), 'utf8')) } catch { /* sin raíces */ }
|
|
45
|
+
const roots = (Array.isArray(config.workspaceRoots) ? config.workspaceRoots : [])
|
|
46
|
+
.filter((workspace) => workspace && workspace.path)
|
|
47
|
+
.map((workspace) => path.resolve(opsDir, workspace.path))
|
|
48
|
+
.filter((one) => fs.existsSync(one))
|
|
49
|
+
const traces = EV.contrast(entry.tests, roots)
|
|
50
|
+
const runs = EV.runs(opsDir)
|
|
51
|
+
const report = { task: entry.slug, epic: entry.epic, traces, runs }
|
|
52
|
+
if (cli.has('--json')) return console.log(JSON.stringify(report))
|
|
53
|
+
|
|
54
|
+
console.log(`TAREA ${entry.slug}${entry.epic ? ` (epic: ${entry.epic})` : ''}`)
|
|
55
|
+
if (!traces.length) console.log('TESTS (la entrada no rastrea ningún criterio)')
|
|
56
|
+
for (const trace of traces) {
|
|
57
|
+
const nota = trace.verdict === 'inbuscable'
|
|
58
|
+
? (roots.length ? 'describe la prueba en vez de nombrarla' : 'el proyecto no declara raíces de código')
|
|
59
|
+
: ''
|
|
60
|
+
console.log(` ${trace.criterion} → ${trace.artifact} [${trace.verdict}]${nota ? ` — ${nota}` : ''}`)
|
|
61
|
+
}
|
|
62
|
+
if (!runs.length) console.log('GATES (sin corridas registradas; `verify` todavía no corrió acá)')
|
|
63
|
+
for (const run of runs) console.log(`GATES ${run.at} ${run.gate} (exit ${run.status})`)
|
|
64
|
+
// Un contraste que no dice qué no puede ver se lee como si lo hubiera visto todo.
|
|
65
|
+
console.log('Este contraste dice si el artefacto existe y qué gates corrieron al commitear. No dice '
|
|
66
|
+
+ 'que la prueba nombrada haya corrido: eso depende del runner, y varios no la nombran al pasar.')
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
function check(dir, cli) {
|
|
31
70
|
const root = path.resolve(dir || '.')
|
|
32
71
|
const errors = []
|
|
@@ -96,6 +135,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')) {
|
package/engine/cli/wiring.js
CHANGED
|
@@ -12,6 +12,7 @@ const A = require('../automation')
|
|
|
12
12
|
const SC = require('../core/scan')
|
|
13
13
|
const OB = require('../core/onboarding')
|
|
14
14
|
const IN = require('./instance')
|
|
15
|
+
const ST = require('../planning/state')
|
|
15
16
|
const { fail, opsRoot } = require('./io')
|
|
16
17
|
|
|
17
18
|
// Cuántos servicios se listan en pantalla antes de recortar. El resto sigue en `--json`, que es lo que
|
|
@@ -234,6 +235,13 @@ function automation(action, rootArg, runnerName, cli) {
|
|
|
234
235
|
console.log(
|
|
235
236
|
`✓ automatización válida: ${A.GUARD_NAMES.length} guards, ${A.RUNNER_NAMES.length} adaptadores`,
|
|
236
237
|
)
|
|
238
|
+
// De los guards instalados hay uno que no siempre corre, y un guard que a veces no corre tiene que
|
|
239
|
+
// decir cuándo. `plan-first` queda inerte mientras el planning no declare ninguna tarea; sin esta
|
|
240
|
+
// línea la condición sería invisible y el conteo de arriba prometería una cobertura que no está.
|
|
241
|
+
const planning = path.join(root, 'planning')
|
|
242
|
+
if (fs.existsSync(planning) && !ST.hasTasks(planning)) {
|
|
243
|
+
console.log(' plan-first: inerte, el planning todavía no declara tareas')
|
|
244
|
+
}
|
|
237
245
|
return
|
|
238
246
|
}
|
|
239
247
|
if (action === 'doctor') {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El contraste entre lo que una entrada de DONE dice haber probado y lo que se puede comprobar sin
|
|
4
|
+
// creerle. Existe porque los dos lados de `tests: CN → prueba` los escribe el mismo autor en el mismo
|
|
5
|
+
// acto: comparar eso mide consistencia de prosa, no que la prueba exista.
|
|
6
|
+
//
|
|
7
|
+
// Son dos preguntas con alcances distintos y por eso se responden por separado:
|
|
8
|
+
//
|
|
9
|
+
// - **El artefacto existe**: se busca el nombre en las raíces declaradas. Vale para cualquier stack y
|
|
10
|
+
// es lo que atrapa la prueba inventada o renombrada, que es la forma de la evidencia falsa.
|
|
11
|
+
// - **El gate corrió**: sale del registro que escribe `verify`, que es la única vez que el toolkit
|
|
12
|
+
// ejecuta algo y ve su código de salida.
|
|
13
|
+
//
|
|
14
|
+
// Lo que NO se puede responder acá, y decirlo es parte del contraste: que la prueba nombrada haya
|
|
15
|
+
// corrido. Depende del runner, y no de una forma que se pueda normalizar: verificado corriendo una
|
|
16
|
+
// prueba que pasa en go1.26.3, donde `go test ./...` imprime `ok <paquete>` y nunca su nombre, y en
|
|
17
|
+
// node v24.18.0, donde `node --test` imprime `✔ <nombre>`. Una comprobación construida sobre la
|
|
18
|
+
// salida diría «no aparece» sobre los stacks del primer tipo, donde sí corrió.
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs')
|
|
21
|
+
const path = require('node:path')
|
|
22
|
+
|
|
23
|
+
const LOG = path.join('planning', '.verify-log')
|
|
24
|
+
// Rodante: interesa el trabajo en curso, no la historia. Sin tope, el archivo crece con cada commit y
|
|
25
|
+
// nadie lo mira; con tope, lo que queda es lo que todavía se puede cruzar contra una entrada abierta.
|
|
26
|
+
const MAX_RUNS = 20
|
|
27
|
+
|
|
28
|
+
function logPath(root) {
|
|
29
|
+
return path.join(root, LOG)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Una línea por gate corrido. Nunca lanza: es un efecto de borde de un guard, y un registro que no se
|
|
33
|
+
// puede escribir no puede impedir el commit que estaba juzgando.
|
|
34
|
+
function record(root, gate, status) {
|
|
35
|
+
if (!root) return
|
|
36
|
+
try {
|
|
37
|
+
const file = logPath(root)
|
|
38
|
+
const previous = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []
|
|
39
|
+
const entry = JSON.stringify({ at: new Date().toISOString(), gate, status })
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
41
|
+
fs.writeFileSync(file, `${[...previous, entry].slice(-MAX_RUNS).join('\n')}\n`)
|
|
42
|
+
} catch { /* el registro es evidencia, no una puerta */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runs(root) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.readFileSync(logPath(root), 'utf8').split('\n').filter(Boolean)
|
|
48
|
+
.map((line) => { try { return JSON.parse(line) } catch { return null } }).filter(Boolean)
|
|
49
|
+
} catch { return [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Los rastros de una línea `tests:`, ya partidos en criterio y artefacto. `n/a — razón` no rastrea
|
|
53
|
+
// ninguno a propósito y sale de acá vacío, igual que en `contracts`.
|
|
54
|
+
function traces(tests) {
|
|
55
|
+
return String(tests || '').split(/\s*;\s*/).filter(Boolean)
|
|
56
|
+
.map((item) => item.match(/^(A|C\d+)\s*(?:→|->)\s*(.+)$/i))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((match) => ({ criterion: match[1].toUpperCase(), artifact: match[2].trim() }))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Qué se puede ir a buscar. Un artefacto con espacios describe la prueba en vez de nombrarla —el molde
|
|
62
|
+
// admite «nombre de prueba o comando»—, y buscar una frase en el código devuelve siempre que no. Se
|
|
63
|
+
// declara inbuscable en vez de darlo por ausente: un contraste que confunde «no lo encontré» con «no
|
|
64
|
+
// existe» enseña a no leerlo.
|
|
65
|
+
function searchable(artifact) {
|
|
66
|
+
return /^[^\s]{4,}$/.test(artifact) && /[A-Za-z]/.test(artifact)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sourceFiles(dir, found = [], depth = 0) {
|
|
70
|
+
if (depth > 8 || found.length > 5000) return found
|
|
71
|
+
let entries = []
|
|
72
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return found }
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) continue
|
|
75
|
+
const full = path.join(dir, entry.name)
|
|
76
|
+
if (entry.isDirectory()) sourceFiles(full, found, depth + 1)
|
|
77
|
+
else found.push(full)
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Si el nombre aparece en el árbol: como parte de una ruta de archivo o dentro del fuente de alguno.
|
|
83
|
+
// Se mira el contenido y no sólo los nombres porque lo que un rastro nombra suele ser la prueba
|
|
84
|
+
// —`TestAddSuma`— y no el archivo que la contiene.
|
|
85
|
+
function findsArtifact(roots, artifact) {
|
|
86
|
+
for (const root of roots) {
|
|
87
|
+
for (const file of sourceFiles(root)) {
|
|
88
|
+
if (file.replace(/\\/g, '/').includes(artifact)) return true
|
|
89
|
+
let text = ''
|
|
90
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { continue }
|
|
91
|
+
if (text.includes(artifact)) return true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// El veredicto por rastro: `encontrado`, `ausente` o `inbuscable`. Sin raíces declaradas no se afirma
|
|
98
|
+
// nada — no hay dónde mirar, y decir «ausente» ahí sería inventar el hallazgo.
|
|
99
|
+
function contrast(tests, roots) {
|
|
100
|
+
return traces(tests).map((trace) => {
|
|
101
|
+
if (!searchable(trace.artifact)) return { ...trace, verdict: 'inbuscable' }
|
|
102
|
+
if (!roots.length) return { ...trace, verdict: 'inbuscable' }
|
|
103
|
+
return { ...trace, verdict: findsArtifact(roots, trace.artifact) ? 'encontrado' : 'ausente' }
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { MAX_RUNS, record, runs, traces, contrast }
|
package/engine/core/ownership.js
CHANGED
package/engine/hooks/files.js
CHANGED
|
@@ -11,6 +11,9 @@ const {
|
|
|
11
11
|
writableRoots, outsideRoots, DECLARE_IT,
|
|
12
12
|
} = require('./input')
|
|
13
13
|
const AP = require('./approval')
|
|
14
|
+
const { readWip } = require('../planning/parser')
|
|
15
|
+
const { hasTasks } = require('../planning/state')
|
|
16
|
+
const { TEMPLATE_PREFIXES } = require('../core/ownership')
|
|
14
17
|
|
|
15
18
|
// La raíz donde vive `planning/`, que es donde se busca la aprobación por operación.
|
|
16
19
|
function opsRoot(input) {
|
|
@@ -108,6 +111,54 @@ function testEvidence(input) {
|
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
// Lo que la instancia recibe del molde, más los cargos que forkeó. `plan-first` no lo juzga, y no es una
|
|
115
|
+
// concesión: el plan se escribe en `planning/`, así que exigirlo ahí sería un candado cuya llave está
|
|
116
|
+
// adentro. Los recorridos que no pasan por la máquina de tareas escriben en las otras raíces —`onboard`
|
|
117
|
+
// en `organization/`, una evaluación en `agents/`, el sincronizador en `integrations/`— y tampoco tienen
|
|
118
|
+
// un WIP que mostrar. Sale de `ownership` para que una raíz nueva del molde quede exenta sola; `agents/`
|
|
119
|
+
// se suma acá porque no viene del molde, la escribe `fork` en la instancia.
|
|
120
|
+
const OPS_OWNED = [...TEMPLATE_PREFIXES, 'agents/']
|
|
121
|
+
|
|
122
|
+
function opsOwned(root, file) {
|
|
123
|
+
const relative = path.relative(root, file).replace(/\\/g, '/')
|
|
124
|
+
if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return false
|
|
125
|
+
return OPS_OWNED.some((prefix) => relative.startsWith(prefix))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// R1 y el paso 7 del protocolo piden el plan antes del primer cambio, y hasta acá nadie lo comprobaba:
|
|
129
|
+
// tocar el archivo primero y redactar después la aceptación que lo justifica sale igual de verde que
|
|
130
|
+
// hacerlo al revés, y se lee igual en DONE. Lo que se exige es lo mínimo que separa un plan de una
|
|
131
|
+
// intención —WIP activo con al menos un paso escrito—, no que el paso sea bueno; eso lo mira Critique.
|
|
132
|
+
//
|
|
133
|
+
// El conteo sale del mismo parser que `check` y `context`, así que lo que el guard llama plan es lo que
|
|
134
|
+
// el resto del motor llama plan. Un WIP con frontmatter y sin pasos es el estado intermedio que esto
|
|
135
|
+
// vigila: la tarea ya está nombrada y el plan todavía no existe.
|
|
136
|
+
function planFirst(input) {
|
|
137
|
+
if (process.env.OPS_PLAN_FIRST_OVERRIDE === '1') return
|
|
138
|
+
const root = opsRoot(input)
|
|
139
|
+
if (!root) return
|
|
140
|
+
const planning = path.join(root, 'planning')
|
|
141
|
+
const wip = readWip(planning)
|
|
142
|
+
if (wip && wip.complete + wip.pending > 0) return
|
|
143
|
+
// Una instancia recién creada no tiene de dónde sacar una tarea: `onboard` deja el roadmap vacío y
|
|
144
|
+
// dice que alguien lo llene. Exigir el plan ahí es un candado delante de la puerta, y la salida que
|
|
145
|
+
// enseña es apagar el guard en el entorno, que lo deja sin morder para siempre. Se pregunta recién
|
|
146
|
+
// acá: en una instancia con trabajo el camino común sale por el WIP de arriba y no paga esta lectura.
|
|
147
|
+
// Que el guard quede inerte lo dice `automation check`, porque una condición invisible es peor que
|
|
148
|
+
// no tenerla.
|
|
149
|
+
if (!hasTasks(planning)) return
|
|
150
|
+
const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
|
|
151
|
+
const why = `${estado}, así que el plan todavía no está escrito.\n`
|
|
152
|
+
+ 'Escribí en planning/WIP.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
|
|
153
|
+
+ 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
|
|
154
|
+
+ AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
|
|
155
|
+
for (const raw of filesOf(input)) {
|
|
156
|
+
if (opsOwned(root, path.resolve(cwdOf(input), raw))) continue
|
|
157
|
+
if (approved(input, raw)) continue
|
|
158
|
+
block(`${raw} cambia el producto sin plan. ${why}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
111
162
|
function workspaceBoundary(input) {
|
|
112
163
|
const allowed = writableRoots(input)
|
|
113
164
|
if (!allowed) return
|
|
@@ -182,4 +233,7 @@ function engineWrites(input) {
|
|
|
182
233
|
}
|
|
183
234
|
}
|
|
184
235
|
|
|
185
|
-
module.exports = {
|
|
236
|
+
module.exports = {
|
|
237
|
+
secrets, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
238
|
+
migrations, engineWrites,
|
|
239
|
+
}
|
package/engine/hooks/run.js
CHANGED
|
@@ -46,6 +46,7 @@ const guards = {
|
|
|
46
46
|
migrations: files.migrations,
|
|
47
47
|
'integration-snapshot': files.integrationSnapshot,
|
|
48
48
|
'test-evidence': files.testEvidence,
|
|
49
|
+
'plan-first': files.planFirst,
|
|
49
50
|
'planning-drift': planningDrift,
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -53,7 +54,7 @@ const guards = {
|
|
|
53
54
|
const hookGroups = {
|
|
54
55
|
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
|
|
55
56
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
56
|
-
'integration-snapshot', 'test-evidence'],
|
|
57
|
+
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
57
58
|
stop: ['planning-drift'],
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -119,6 +120,11 @@ const hookMetadata = [
|
|
|
119
120
|
event: 'PreToolUse · files',
|
|
120
121
|
purpose: 'Impide apagar o borrar la prueba que juzga el cambio.',
|
|
121
122
|
},
|
|
123
|
+
{
|
|
124
|
+
name: 'plan-first',
|
|
125
|
+
event: 'PreToolUse · files',
|
|
126
|
+
purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
|
|
127
|
+
},
|
|
122
128
|
{
|
|
123
129
|
name: 'planning-drift',
|
|
124
130
|
event: 'Stop / SessionEnd',
|
package/engine/hooks/shell.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
writableRoots, outsideRoots, DECLARE_IT, unquoted, findOpsRoot, withoutGitGlobals,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
|
+
const EV = require('../core/evidence')
|
|
16
17
|
|
|
17
18
|
// Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
|
|
18
19
|
// decidían por su cuenta admitiendo sólo un espacio, el principio o el fin, y en un shell una palabra
|
|
@@ -264,11 +265,47 @@ function writeTargets(command) {
|
|
|
264
265
|
// el guard frena `> /dev/null 2>&1`, y lo primero que hace quien lo sufre es apagarlo entero.
|
|
265
266
|
const NEUTRAL = [/^\/dev\/(?:null|stdout|stderr|tty|fd\/)/, new RegExp(`^${os.tmpdir()}(?:/|$)`)]
|
|
266
267
|
|
|
268
|
+
// A dónde deja parado un `cd`. `null` significa que no se sabe, que no es lo mismo que la raíz: un
|
|
269
|
+
// destino con variable o un `cd -` dependen de un estado que este proceso no tiene.
|
|
270
|
+
function cdTarget(argument, base) {
|
|
271
|
+
if (argument === undefined) return os.homedir()
|
|
272
|
+
if (argument === '-' || /[$`\u0000]/.test(argument)) return null
|
|
273
|
+
if (/^~(?=$|\/)/.test(argument)) return argument.replace(/^~/, os.homedir())
|
|
274
|
+
return path.resolve(base, argument)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Las escrituras de un comando, cada una con el directorio contra el que hay que resolverla. El `cd`
|
|
278
|
+
// del propio comando cambia eso para todo lo que viene después y es lo primero que el shell ejecuta;
|
|
279
|
+
// sin mirarlo, el guard juzgaba una ruta que nadie iba a escribir, y fallaba en los dos sentidos.
|
|
280
|
+
//
|
|
281
|
+
// Se recorre por tramos y se lleva la cuenta, en vez de mirar sólo el primero como hace `gitDirectory`.
|
|
282
|
+
// Ahí alcanza porque un comando elige un repositorio; acá cada escritura puede caer bajo un `cd`
|
|
283
|
+
// distinto, y juzgar la primera contra el último sería cambiar un error de lugar en vez de arreglarlo.
|
|
284
|
+
//
|
|
285
|
+
// Los tramos salen del texto ya sin comillas, así que un `;` adentro de una cadena no parte nada.
|
|
286
|
+
function writesWithBase(command, cwd) {
|
|
287
|
+
const found = []
|
|
288
|
+
let base = cwd
|
|
289
|
+
for (const segment of unquoted(command).split(/[;&|\n]+/)) {
|
|
290
|
+
const cd = segment.match(/^\s*cd(?:\s+(\S+))?\s*$/)
|
|
291
|
+
if (cd) { base = base === null ? null : cdTarget(cd[1], base); continue }
|
|
292
|
+
for (const raw of writeTargets(segment)) found.push({ raw, base })
|
|
293
|
+
}
|
|
294
|
+
return found
|
|
295
|
+
}
|
|
296
|
+
|
|
267
297
|
function shellBoundary(input) {
|
|
268
298
|
const allowed = writableRoots(input)
|
|
269
299
|
if (!allowed) return
|
|
270
|
-
for (const raw of
|
|
271
|
-
|
|
300
|
+
for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
|
|
301
|
+
// Una ruta absoluta no depende del `cd`, así que un destino que no se sabe no la vuelve injuzgable.
|
|
302
|
+
// Al revés sí: sin saber desde dónde se resuelve, una relativa no se puede verificar, y un guard que
|
|
303
|
+
// no puede verificar no autoriza —el criterio que fijó el 031 para el índice—.
|
|
304
|
+
if (!path.isAbsolute(raw) && base === null) {
|
|
305
|
+
block(`el comando hace \`cd\` a un destino que no se puede resolver acá, así que no hay contra qué `
|
|
306
|
+
+ `resolver ${raw}. Escribí la ruta absoluta, o hacé el \`cd\` en un comando aparte.`)
|
|
307
|
+
}
|
|
308
|
+
const file = path.resolve(base, raw)
|
|
272
309
|
if (NEUTRAL.some((pattern) => pattern.test(file))) continue
|
|
273
310
|
if (outsideRoots(file, allowed)) {
|
|
274
311
|
block(`el comando escribe en ${file}, fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
@@ -358,14 +395,24 @@ function commitTree(dir) {
|
|
|
358
395
|
fs.mkdirSync(path.dirname(link), { recursive: true })
|
|
359
396
|
fs.symlinkSync(path.join(dir, name), link, 'junction')
|
|
360
397
|
}
|
|
361
|
-
// Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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: {} }
|
|
369
416
|
}
|
|
370
417
|
|
|
371
418
|
function verify(input) {
|
|
@@ -393,7 +440,7 @@ function verify(input) {
|
|
|
393
440
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
394
441
|
const { root, temp, env } = commitTree(dir)
|
|
395
442
|
try {
|
|
396
|
-
verifyGates(root, dir, aprobado, env)
|
|
443
|
+
verifyGates(root, dir, aprobado, env, opsRoot(input))
|
|
397
444
|
} finally {
|
|
398
445
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
399
446
|
}
|
|
@@ -402,7 +449,11 @@ function verify(input) {
|
|
|
402
449
|
// Corre lo que el stack declare y bloquea si algo sale en rojo. `root` es dónde corre —el índice
|
|
403
450
|
// materializado o el árbol, que ahí son lo mismo— y `dir` es el repositorio, que es el nombre que le
|
|
404
451
|
// dice algo a quien lee el mensaje.
|
|
405
|
-
|
|
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) {
|
|
406
457
|
const failures = []
|
|
407
458
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
408
459
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
@@ -412,16 +463,19 @@ function verifyGates(root, dir, aprobado, env) {
|
|
|
412
463
|
for (const script of ['test', 'lint', 'typecheck', 'build']) {
|
|
413
464
|
if (!pkg.scripts || !pkg.scripts[script]) continue
|
|
414
465
|
const result = run(pm, ['run', script], root, env)
|
|
466
|
+
EV.record(ops, script, result.status)
|
|
415
467
|
if (!result.ok) failures.push(`${script} (exit ${result.status})`)
|
|
416
468
|
}
|
|
417
469
|
} else if (fs.existsSync(path.join(root, 'go.mod'))) {
|
|
418
470
|
const makefile = path.join(root, 'Makefile')
|
|
419
471
|
if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
420
472
|
const result = run('make', ['ci'], root, env)
|
|
473
|
+
EV.record(ops, 'make ci', result.status)
|
|
421
474
|
if (!result.ok) failures.push(`make ci (exit ${result.status})`)
|
|
422
475
|
} else {
|
|
423
476
|
for (const args of [['test', './...'], ['build', './...']]) {
|
|
424
477
|
const result = run('go', args, root, env)
|
|
478
|
+
EV.record(ops, `go ${args[0]}`, result.status)
|
|
425
479
|
if (!result.ok) failures.push(`go ${args[0]} (exit ${result.status})`)
|
|
426
480
|
}
|
|
427
481
|
}
|
|
@@ -429,6 +483,7 @@ function verifyGates(root, dir, aprobado, env) {
|
|
|
429
483
|
const makefile = path.join(root, 'Makefile')
|
|
430
484
|
if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
431
485
|
const result = run('make', ['test'], root, env)
|
|
486
|
+
EV.record(ops, 'make test', result.status)
|
|
432
487
|
if (!result.ok) failures.push(`make test (exit ${result.status})`)
|
|
433
488
|
}
|
|
434
489
|
}
|
|
@@ -286,10 +286,34 @@ const HUMAN_ACTION_STATES = ['pendiente', 'resuelta']
|
|
|
286
286
|
// («resuelta 2026-08-17») siga valiendo sin que una palabra suelta dentro de un texto largo resuelva
|
|
287
287
|
// una fila que sigue abierta. `valid` distingue la fila mal escrita de la fila pendiente: las dos
|
|
288
288
|
// bloquean, pero sólo una es un error que hay que reportar.
|
|
289
|
+
// En markdown un pipe dentro de una celda se escribe `\|` —es la única forma que hay— así que partir
|
|
290
|
+
// por todo `|` abre esa celda en dos y corre las columnas de la fila. El daño peor es silencioso: con el
|
|
291
|
+
// pipe detrás de la palabra del vocabulario, el estado sigue leyéndose bien, `check` pasa y lo que se
|
|
292
|
+
// entrega como acción de desbloqueo es el contenido de `Origen`.
|
|
293
|
+
//
|
|
294
|
+
// Lo que no cubre: una celda que termine en una barra invertida literal. En markdown eso se escribe
|
|
295
|
+
// `\\` y acá se leería como escape del separador. Es un borde que nadie escribe y taparlo pedía un
|
|
296
|
+
// parser de verdad; queda dicho en vez de supuesto.
|
|
297
|
+
const SEPARADOR = /(?<!\\)\|/
|
|
298
|
+
const SEPARADORES = /^\|\s*:?-+/
|
|
299
|
+
|
|
300
|
+
// El escape se quita al normalizar. Esta columna existe para que una persona lea qué tiene que hacer, y
|
|
301
|
+
// `\|` no es parte de lo que quiso decir: es cómo markdown escribe un pipe. La fila archivada no se ve
|
|
302
|
+
// afectada —`archive` reescribe `raw`, la línea original, no las celdas—, así que quitarlo no pierde nada.
|
|
303
|
+
const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
|
|
304
|
+
|
|
289
305
|
function readHumanActions(dir) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
const lineas = withoutComments(read(path.join(dir, 'HUMAN_ACTIONS.md'))).split('\n')
|
|
307
|
+
// En markdown la cabecera es la fila anterior a la de separadores, diga lo que diga su primera celda.
|
|
308
|
+
// Se marcan todas y no la primera: un archivo con una tabla por sección tiene una cabecera por tabla,
|
|
309
|
+
// y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
|
|
310
|
+
// fila de datos nunca está inmediatamente antes de los guiones.
|
|
311
|
+
const cabeceras = new Set(lineas.map((line, i) => (SEPARADORES.test(line) ? i - 1 : -1)))
|
|
312
|
+
const rows = lineas
|
|
313
|
+
.filter((line, i) => /^\|/.test(line) && !SEPARADORES.test(line) && !cabeceras.has(i))
|
|
314
|
+
.map((line) => ({ line, cells: line.split(SEPARADOR).slice(1, -1).map(celda) }))
|
|
315
|
+
// El literal queda como resguardo de la tabla escrita sin su fila de separadores: markdown no la
|
|
316
|
+
// renderiza como tabla, y este parser lee sus filas igual.
|
|
293
317
|
return rows.filter(({ cells }) => cells.length >= 4 && !/^tarea$/i.test(cells[0]))
|
|
294
318
|
.map(({ line, cells }) => {
|
|
295
319
|
const state = (cells[1].match(new RegExp(`^(${HUMAN_ACTION_STATES.join('|')})\\b`, 'i')) || [])[1] || ''
|
package/engine/planning/state.js
CHANGED
|
@@ -50,4 +50,14 @@ function currentTask({ milestones, done, wip }, blockers = []) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// Si el planning declara trabajo, en cualquiera de sus dos estados. Lo preguntan dos: el guard
|
|
54
|
+
// `plan-first`, para no exigir un plan donde todavía no hay de dónde sacar una tarea, y
|
|
55
|
+
// `automation check`, para poder decir que ese guard está inerte. Vive acá y no en el guard porque con
|
|
56
|
+
// dos copias una se pudre y el reporte anuncia una condición distinta de la que el guard aplica.
|
|
57
|
+
function hasTasks(root) {
|
|
58
|
+
return P.readBacklog(root).some((milestone) => milestone.tasks.length > 0)
|
|
59
|
+
|| P.readDone(root).entries.length > 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
module.exports = { snapshot, pendingHumanActions, currentTask, hasTasks }
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -139,6 +139,7 @@ y eso lo deja apagado **hasta que cierres la sesión**, no para un comando.
|
|
|
139
139
|
| `OPS_MIGRATIONS_OVERRIDE=1` | el de migraciones |
|
|
140
140
|
| `OPS_TEST_EVIDENCE_OVERRIDE=1` | el de evidencia de pruebas |
|
|
141
141
|
| `OPS_DEPENDENCIES_OVERRIDE=1` | el de dependencias, incluido publicar e instalar global |
|
|
142
|
+
| `OPS_PLAN_FIRST_OVERRIDE=1` | el que exige plan antes de cambiar el producto |
|
|
142
143
|
| `OPS_SKIP_VERIFY=1` | el que corre los gates |
|
|
143
144
|
|
|
144
145
|
Por eso la aprobación es la vía recomendada y esto es lo que queda cuando no alcanza: prendela para lo
|
|
@@ -153,8 +154,14 @@ muta nada.
|
|
|
153
154
|
aceptación y sus criterios. Es la entrada correcta para empezar a trabajar.
|
|
154
155
|
- `node tools/ops.js tree planning` — panorama de roadmap, backlog, WIP, inbox y done.
|
|
155
156
|
- `node tools/ops.js check planning` — validación de contratos y trazabilidad.
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
- `node tools/ops.js evidence planning [--task <slug>]` — contrasta la evidencia de una entrada de DONE
|
|
158
|
+
contra lo que no escribió su autor: si el artefacto que `tests:` nombra existe en las raíces de
|
|
159
|
+
código, y qué gates corrió `verify` al commitear, con su código de salida. Al cerrar una tarea, es la
|
|
160
|
+
única parte de esa evidencia que no sale de la misma mano que la afirma. No dice que la prueba
|
|
161
|
+
nombrada haya corrido —eso depende del runner, y varios no la nombran al pasar— ni reemplaza a leer
|
|
162
|
+
su fuente, que es lo que R9 pide.
|
|
163
|
+
|
|
164
|
+
Los cuatro aceptan `--json`. Leer `BACKLOG.md`, `WIP.md` o `HUMAN_ACTIONS.md` completos sólo cuando haga
|
|
158
165
|
falta editarlos o cuando el CLI no responda la pregunta.
|
|
159
166
|
|
|
160
167
|
## Autonomía
|
package/template/gitignore
CHANGED
|
@@ -5,5 +5,9 @@ node_modules/
|
|
|
5
5
|
.env.*
|
|
6
6
|
!.env.example
|
|
7
7
|
|
|
8
|
+
# El registro de gates que escribe `verify`: es evidencia de una corrida local, rodante y de la
|
|
9
|
+
# máquina. Committearlo sería historia que nadie lee y un conflicto por commit.
|
|
10
|
+
planning/.verify-log
|
|
11
|
+
|
|
8
12
|
*.tgz
|
|
9
13
|
.DS_Store
|