@ingeniomaps/cauce 0.90.0 → 0.91.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,57 @@ 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.91.0] - 2026-09-15
18
+
19
+ ### Agregado
20
+
21
+ - **`ops contract <ops-root> [--json]`: el contrato de tu proyecto sin pasarlo por un modelo.** Devuelve lo
22
+ que un recorrido necesita antes de la primera fase —cómo se llama el proyecto, dónde puede escribir, con
23
+ qué se verifica, qué límites rigen y qué formatos exige `planning/`—, derivado de `ops.config.json`,
24
+ `AGENTS.md`, `organization/workspace.md` y `planning/PROTOCOL.md`.
25
+
26
+ Los diez campos salen de parsear, así que el comando no inventa nada y cuesta cero tokens. `autobuild` los
27
+ derivaba con un agente que leía esos cuatro archivos y los transcribía; el comando existe para que deje de
28
+ hacerlo, aunque el recorrido todavía no lo use.
29
+
30
+ **Falla en vez de contestar a medias.** Si falta uno de los cuatro archivos, o si `AGENTS.md` o
31
+ `PROTOCOL.md` perdieron la sección de la que sale el contrato, se niega nombrando cuál y manda a correr
32
+ `ops upgrade`, que es quien los repone: entregar límites vacíos es peor que parar, porque un límite que no
33
+ llega se lee igual que uno que no existe. Que `organization/workspace.md` no declare excepciones **no** es
34
+ un error: es tuyo y puede no tenerlas.
35
+
36
+ ### Corregido
37
+
38
+ - **La protección que evita que un gate te borre el `node_modules` no estaba puesta.** `verify` corre los
39
+ gates sobre una copia del índice con el `node_modules` enlazado a tu proyecto, y le pedía a pnpm que no
40
+ sincronizara dependencias antes de correr el script. Se lo pedía con un nombre que pnpm ignora: sus
41
+ ajustes se leen del entorno con el prefijo `pnpm_config_`, no con el de npm. La petición viajaba y se
42
+ descartaba en silencio desde 0.75.0, que es cuando entró.
43
+
44
+ Con pnpm 11, donde esa comprobación viene encendida, se nota al commitear un cambio de `pnpm-lock.yaml`:
45
+ pnpm decide reinstalar, la reinstalación **empieza borrando** el directorio de módulos —el tuyo, por el
46
+ enlace— y sin TTY aborta con `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. Los gates vuelven en poco más
47
+ de un segundo, así que se leen como una suite en rojo que no lo es.
48
+
49
+ Si te está pasando, alcanza con actualizar. Lo que **no** corresponde, aunque el bloqueo lo sugiera, es
50
+ aprobar las rutas o `OPS_SKIP_VERIFY=1` —las dos commitean sin haber corrido el gate— ni `CI=true`, que
51
+ desarma justamente la confirmación con la que pnpm frena antes de purgar.
52
+
53
+ - **`plan-first` decía que no tenías plan cuando lo que pasaba es que no veía el tuyo.** El guard busca el
54
+ WIP de tu id, y ese id sale del árbol donde corre el proceso mientras no exportes `CAUCE_RUNNER`. Con la
55
+ instancia al lado de dos repositorios, el mismo cambio se frenaba o pasaba según el directorio desde el
56
+ que saliera la llamada — y el bloqueo decía «WIP está en IDLE» con el plan escrito y a la vista, así que
57
+ mandaba a escribir de nuevo algo que ya existía.
58
+
59
+ Ahora distingue las dos causas: si hay un plan bajo otro id, lo nombra con su tarea y te manda a volver a
60
+ ese id —`ops runners <planning>` los lista— o a montar el tuyo con `ops worktree <planning> <tarea>`. Y
61
+ **deja de ofrecerte aprobar la ruta**, porque con un plan a la vista eso escribe por vos que el cambio no
62
+ es trabajo de ninguna tarea, que es falso y queda en el registro.
63
+
64
+ Lo que no cambia: el plan de otro runner sigue sin autorizarte a escribir. Si van a trabajar en paralelo,
65
+ cada agente necesita su árbol —`git worktree`, que es lo que `ops worktree` prepara—, y ahí el problema no
66
+ existe porque no comparten archivos.
67
+
17
68
  ## [0.90.0] - 2026-09-15
18
69
 
19
70
  ### Corregido
@@ -20,6 +20,7 @@ const FLAGS = {
20
20
  check: ['--json'],
21
21
  tree: ['--json', '--no-color'],
22
22
  context: ['--json', '--hito'],
23
+ contract: ['--json'],
23
24
  recurring: ['--json', '--promote'],
24
25
  claim: ['--json'],
25
26
  runners: ['--json'],
@@ -0,0 +1,127 @@
1
+ 'use strict'
2
+
3
+ // El contrato del proyecto, derivado del disco. Es lo que un recorrido necesita saber antes de la primera
4
+ // fase —cómo se llama el proyecto, dónde puede escribir, con qué se verifica, qué límites rigen y qué
5
+ // formatos exige planning— y hasta 0.91.0 lo producía un agente que leía cuatro archivos y los transcribía.
6
+ //
7
+ // Transcribir no es decidir: los diez campos salen de parsear. Seis son valores de `ops.config.json`, uno
8
+ // es copiar una sección de `PROTOCOL.md` y dos son secciones de `AGENTS.md` y `organization/workspace.md`.
9
+ // Pagarle a un modelo por eso cuesta una llamada por corrida y, sobre todo, hace que lo que viaja al
10
+ // preámbulo de cada subagente sea **lo que alguien transcribió** en vez de lo que el archivo dice (caso 154).
11
+ //
12
+ // El porqué de resolverlo con un comando y no con un agente ya está escrito donde se decidió la primera
13
+ // vez: el comentario de `readContext`, en el recorrido. Acá sólo se repite la elección, no su razón.
14
+
15
+ const fs = require('node:fs')
16
+ const path = require('node:path')
17
+ const P = require('../planning/parser')
18
+ const { fail, opsRoot } = require('./io')
19
+
20
+ // Los cuatro que componen el contrato, con la ruta relativa a la raíz de la instancia. El orden es el que
21
+ // usa el mensaje de error: se nombra el primero que falte y no los cuatro, porque arreglar uno suele
22
+ // arreglar la causa de los demás —una raíz mal apuntada los pierde todos a la vez—.
23
+ const SOURCES = ['AGENTS.md', path.join('organization', 'workspace.md'), 'ops.config.json',
24
+ path.join('planning', 'PROTOCOL.md')]
25
+
26
+ // Qué sección sostiene cada campo de texto, y de quién es el archivo que la trae. La diferencia decide qué
27
+ // pasa cuando falta, y no es una preferencia: `AGENTS.md` y `PROTOCOL.md` están en `TEMPLATE_FILES`, así que
28
+ // `upgrade` los reemplaza enteros y su sección no puede faltar en una instancia viva —si falta, algo se
29
+ // rompió y seguir entregaría límites vacíos a cada subagente—. `organization/workspace.md` es del proyecto:
30
+ // que no declare excepciones es un estado legítimo y el molde lo dice.
31
+ const REQUIRED_SECTIONS = [
32
+ { file: 'AGENTS.md', heading: /Autonom/, name: '## Autonomía' },
33
+ { file: path.join('planning', 'PROTOCOL.md'), heading: /Contratos/, name: '## Contratos' },
34
+ ]
35
+
36
+ const readIfAny = (file) => {
37
+ try { return fs.readFileSync(file, 'utf8') } catch { return '' }
38
+ }
39
+
40
+ // Con qué arranca un párrafo que **enuncia** un límite, frente a uno que lo explica. Es vocabulario
41
+ // cerrado, igual que `lane` o `blocked`, y por la misma razón: lo que sigue es una lista de la que un
42
+ // agente tiene que poder obedecer cada entrada, y una heurística abierta admite cualquier cosa.
43
+ const ENUNCIA = /^(?:El runner\b|Debe\b|Nunca\b)/
44
+ // Un límite por entrada y no la sección cruda. `SCOPE()` las une con `; ` en una sola frase del preámbulo
45
+ // que se reenvía a **cada** subagente, así que el tamaño no se paga una vez: volcar ahí los dos mil bytes
46
+ // de la sección entera metía encabezados, conectores —«Eso rige sin que nadie escriba nada.»— y los
47
+ // párrafos que razonan sobre `BR-OPS-002` y `runner.allowPush`, que son prosa para una persona.
48
+ //
49
+ // Se corta por párrafo y por su sujeto, no por posición ni por oración. Partir por oración fue el primer
50
+ // intento y devolvía diecisiete entradas de las que cuatro eran límites; por posición habría funcionado
51
+ // sobre el molde de hoy y se habría roto con el primero que agregue un párrafo.
52
+ //
53
+ // Lo que esto no resuelve, y por eso existe el caso 157: que sea una deducción gramatical y no una marca.
54
+ // Un límite que el proyecto escriba con otra forma no entra, y eso no se ve — la lista sale más corta y se
55
+ // lee igual de completa. Sobre `AGENTS.md` casi no puede pasar porque `upgrade` lo reemplaza entero; sobre
56
+ // `organization/workspace.md`, que lo escribe una persona, pasa siempre que no imite esta gramática.
57
+ function limits(text) {
58
+ return text.split(/\n\s*\n/)
59
+ .map((block) => block.split('\n')
60
+ .map((line) => line.replace(/^[-*]\s+/, '').trim())
61
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('|'))
62
+ .join(' ')
63
+ .trim())
64
+ .filter((block) => ENUNCIA.test(block))
65
+ }
66
+
67
+ function contract(dir, cli) {
68
+ const root = opsRoot(dir)
69
+ const missing = SOURCES.find((name) => !fs.existsSync(path.join(root, name)))
70
+ // Sin los cuatro no hay contrato que derivar, y contestar uno a medias es peor que no contestar: lo que
71
+ // se pierde no se ve en la salida, se ve tres fases después en lo que un subagente creyó que podía tocar.
72
+ if (missing) {
73
+ return fail(`${root} no tiene ${missing}, así que no hay contrato que derivar. Es la raíz que escribió `
74
+ + '`automation install`: comprobá que exista y, si moviste el proyecto de carpeta, reinstalá el adaptador.', 2)
75
+ }
76
+
77
+ let config
78
+ try {
79
+ config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
80
+ } catch (error) {
81
+ return fail(`ops.config.json no se pudo leer como JSON: ${error.message}`, 2)
82
+ }
83
+
84
+ const sections = {}
85
+ for (const { file, heading, name } of REQUIRED_SECTIONS) {
86
+ const found = P.section(readIfAny(path.join(root, file)), heading)
87
+ // Nombrar la sección y el archivo es lo que separa este error de «algo salió mal»: quien lo lee tiene
88
+ // que poder abrir el archivo y ver qué encabezado falta, y el arreglo es restaurarlo con `upgrade`.
89
+ if (!found.trim()) {
90
+ return fail(`${file} no tiene la sección ${name}, y de ahí sale el contrato que reciben los agentes. `
91
+ + 'Ese archivo lo reemplaza `ops upgrade` entero: corrélo para restaurarlo.', 2)
92
+ }
93
+ sections[file] = found
94
+ }
95
+
96
+ const roots = Array.isArray(config.workspaceRoots) ? config.workspaceRoots : []
97
+ const runner = config.runner || {}
98
+ const excepciones = P.section(readIfAny(path.join(root, 'organization', 'workspace.md')),
99
+ /Excepciones de autonom/)
100
+ const report = {
101
+ rootOk: true,
102
+ project: String(config.project || ''),
103
+ // «nombre → ruta», que es la forma con la que el preámbulo las enumera como límite de escritura.
104
+ workspaceRoots: roots.filter((one) => one && one.name && one.path).map((one) => `${one.name} → ${one.path}`),
105
+ // Una entrada por raíz que declare `verify`, y ninguna por las que no: la lista vacía significa que el
106
+ // proyecto no dice con qué se verifica, que es distinto de no haberlo mirado.
107
+ gates: roots.filter((one) => one && one.path && one.verify).map((one) => `${one.path} → ${one.verify}`),
108
+ maxTaskHours: Number(runner.maxTaskHours || 0),
109
+ commitPerTask: Boolean(runner.commitPerTask),
110
+ humanCheckpoint: Boolean(runner.humanCheckpointBetweenMilestones),
111
+ // Textual y sin reformular: es el formato contra el que se escribe roadmap, BACKLOG, WIP y DONE, y un
112
+ // resumen de un formato no sirve para cumplirlo.
113
+ contracts: sections[path.join('planning', 'PROTOCOL.md')].trim(),
114
+ // Los del toolkit primero y los del proyecto después, que es el orden en que se leen: lo segundo amplía
115
+ // o restringe lo primero, y al revés se leería como si el proyecto fijara la base.
116
+ boundaries: [...limits(sections['AGENTS.md']), ...limits(excepciones)],
117
+ }
118
+ if (cli.has('--json')) return console.log(JSON.stringify(report))
119
+ console.log(`${report.project} (${report.workspaceRoots.join('; ') || 'sin raíces declaradas'})`)
120
+ console.log(`gates ${report.gates.join('; ') || 'ninguno declarado'}`)
121
+ console.log(`runner ${report.maxTaskHours} h por tarea · `
122
+ + `commit ${report.commitPerTask ? 'por tarea' : 'libre'} · `
123
+ + `checkpoint ${report.humanCheckpoint ? 'entre hitos' : 'no'}`)
124
+ console.log(`límites ${report.boundaries.length} · contratos ${report.contracts.length} caracteres`)
125
+ }
126
+
127
+ module.exports = { contract }
package/engine/cli/ops.js CHANGED
@@ -9,6 +9,7 @@ const { FLAGS, parse } = require('./args')
9
9
  const { fail } = require('./io')
10
10
  const IN = require('./instance')
11
11
  const PL = require('./planning')
12
+ const CT = require('./contract')
12
13
  const VA = require('./validate')
13
14
  const AR = require('./archive')
14
15
  const CLM = require('./claims')
@@ -149,6 +150,7 @@ function usage() {
149
150
  ops check <planning-dir> [--json]
150
151
  ops tree <planning-dir> [--no-color] [--json]
151
152
  ops context <planning-dir> [--hito <slug>] [--json]
153
+ ops contract <ops-root> [--json]
152
154
  ops recurring <planning-dir> [--promote <qué>] [--json]
153
155
  ops runners <planning-dir> [--json]
154
156
  ops claim <planning-dir> <tarea>
@@ -208,6 +210,7 @@ async function run(cli) {
208
210
  else if (command === 'check') VA.check(arg[1], cli)
209
211
  else if (command === 'tree') PL.tree(arg[1], cli)
210
212
  else if (command === 'context') PL.context(arg[1], cli)
213
+ else if (command === 'contract') CT.contract(arg[1], cli)
211
214
  else if (command === 'recurring') PL.recurring(arg[1], cli)
212
215
  else if (command === 'runners') CLM.runners(arg[1], cli)
213
216
  else if (command === 'claim') CLM.claim(arg[1], arg[2], cli)
@@ -14,7 +14,7 @@ const {
14
14
  const AP = require('./approval')
15
15
  const CHAT = require('./chat')
16
16
  const { selfApproval } = require('./self-approval')
17
- const { readWip } = require('../planning/parser')
17
+ const { readWip, readWips } = require('../planning/parser')
18
18
  const { runner } = require('../planning/claims')
19
19
  const { hasTasks } = require('../planning/state')
20
20
  const { TEMPLATE_PREFIXES } = require('../core/ownership')
@@ -235,14 +235,33 @@ function planFirst(input) {
235
235
  // Que el guard quede inerte lo dice `automation check`, porque una condición invisible es peor que
236
236
  // no tenerla.
237
237
  if (!hasTasks(planning)) return
238
+ // No tener plan y no ver el propio piden cosas opuestas: escribirlo, o volver al id desde el que ya se
239
+ // escribió. `runner()` sale del árbol donde corre el proceso, así que con una instancia al lado de dos
240
+ // repositorios el mismo cambio cae de un lado o del otro según el directorio, y el mensaje mandaba a
241
+ // escribir un plan que estaba a la vista (caso 152).
242
+ //
243
+ // Se lista recién acá, después de las dos salidas de arriba: quien tiene su plan sale por la primera y
244
+ // no paga esta lectura, que es la misma razón por la que `hasTasks` se pregunta donde se pregunta.
245
+ const ajenos = readWips(planning).filter((one) => one.complete + one.pending > 0)
238
246
  const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
239
- const why = `${estado}, así que el plan todavía no está escrito.\n`
240
- + 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
241
- + 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
247
+ const why = ajenos.length
248
+ ? `hay plan escrito, pero bajo otro id: ${ajenos.map((one) => `${one.runner} (${one.task})`).join(', ')}.\n`
249
+ + 'Si ese plan es tuyo, volvé a su id con `export CAUCE_RUNNER=<id>` —`ops runners <planning>` los lista '
250
+ + 'con su tarea y su avance— y repetí el cambio. Si vas a trabajar en paralelo, montá tu propio árbol '
251
+ + 'con `ops worktree <planning> <tarea>`, que te devuelve el id hecho.\n'
252
+ : `${estado}, así que el plan todavía no está escrito.\n`
253
+ + 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con '
254
+ + 'un estado verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
242
255
  for (const raw of filesOf(input)) {
243
256
  if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
244
257
  if (approved(input, raw)) continue
245
- block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)}`)
258
+ // Con un plan a la vista no se ofrece ninguna de las dos salidas: aprobar la ruta escribe «esto no es
259
+ // trabajo de una tarea», que ahí es falso, y anunciar la variable ofrece el permiso más ancho cuando
260
+ // la acción correcta es angosta y concreta —el mismo criterio con que `HOW` decide no nombrarla—.
261
+ const how = ajenos.length
262
+ ? AP.HOW(null, [], input, [])
263
+ : AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)
264
+ block(`${raw} cambia el producto sin plan. ${why}${how}`)
246
265
  }
247
266
  }
248
267
 
@@ -488,11 +488,17 @@ function commitTree(dir) {
488
488
  // el lockfile de la copia y reinstala, lo que **empieza borrando** el `node_modules` del proyecto.
489
489
  //
490
490
  // Lo que se apaga es esa comprobación previa, que es el motivo por el que quiere tocar nada.
491
- // `verify-deps-before-run` la gobierna y tiene tres valores: `install` reinstala solo —el que trae
492
- // pnpm 11 y el que hace el daño—, `error` se niega y frena el gate cuando el lockfile de la copia
493
- // difiere de lo instalado, que es justo lo que pasa al commitear un cambio de lockfile por partes, y
494
- // `false` corre el script sin mirar. La copia no tiene que sincronizar nada: tiene que medir el
495
- // código.
491
+ // `verify-deps-before-run` la gobierna y tiene cinco valores —`install`, `warn`, `prompt`, `error` y
492
+ // `false`—. El que hace el daño es `install`, que reinstala solo y es el default desde pnpm 11. `error`
493
+ // tampoco serviría: frena el gate cuando el lockfile de la copia difiere de lo instalado, que es justo
494
+ // lo que pasa al commitear un cambio de lockfile por partes. La copia no tiene que sincronizar nada:
495
+ // tiene que medir el código.
496
+ //
497
+ // El prefijo es `pnpm_config_` y no `npm_config_`, y esa sola palabra es la diferencia entre apagar la
498
+ // comprobación y no apagar nada: pnpm lee sus ajustes del entorno con su propio prefijo, así que con el
499
+ // de npm la variable llega igual y se ignora en silencio. Acá estuvo `npm_config_` desde el arreglo del
500
+ // 070 y no surtió efecto nunca (caso 151), con lo cual la protección que ese arreglo creyó poner no
501
+ // estuvo puesta. Medido sobre pnpm 10.30.2 y 11.20.0, iguales las dos.
496
502
  //
497
503
  // Acá estuvo `CI: 'true'` y fue una regresión (caso 070). Resolvía el síntoma del 068 —pnpm dejaba de
498
504
  // preguntar antes de purgar— desarmando la confirmación en vez de quitarle el motivo, y esa
@@ -502,7 +508,7 @@ function commitTree(dir) {
502
508
  //
503
509
  // La regla que queda: no se desarma la confirmación de una herramienta, se le quita el motivo de
504
510
  // preguntar. Una confirmación que estorba casi siempre está cuidando algo.
505
- return { root: temp, temp, env: { npm_config_verify_deps_before_run: 'false' } }
511
+ return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
506
512
  }
507
513
 
508
514
  function verify(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",