@ingeniomaps/cauce 0.85.0 → 0.87.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,98 @@ 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.87.0] - 2026-09-14
18
+
19
+ ### Corregido
20
+
21
+ - **El aviso de credenciales sin dueño mira el `.env.example` entero y ya no sus primeras cuarenta
22
+ variables.** `check` te avisa de las credenciales que ningún contrato declara, y el escaneo cortaba en
23
+ cuarenta variables por servicio **antes** de mirar qué era cada una. En un servicio grande eso no
24
+ recortaba una lista: apagaba el análisis, y qué credencial se veía terminaba dependiendo de en qué línea
25
+ del archivo había caído.
26
+
27
+ El riesgo estaba escrito como aceptado y sin medir desde que el tope existe. Medirlo contra una instancia
28
+ real lo convirtió en dos nombres: un servicio con 61 variables tenía tres credenciales pasado el corte y
29
+ **dos que ningún contrato declaraba**, en las líneas 43 y 60. Lo único que salía era el aviso de que había
30
+ cortado.
31
+
32
+ Ahora el tope recorta lo que se **lista** y no lo que se mira: las credenciales se buscan sobre el archivo
33
+ entero, y `scan` sigue mostrando cuarenta y diciendo cuántas más hay. Si tenés un servicio con más de
34
+ cuarenta variables, contá con avisos nuevos en el próximo `check` — son credenciales que ya estaban y que
35
+ nadie estaba mirando.
36
+
37
+ ## [0.86.0] - 2026-09-12
38
+
39
+ ### Corregido
40
+
41
+ - **Tu guard propio ya no se pierde al actualizar una instancia vieja.** Si tu instancia es anterior al
42
+ registro de entregas —no tiene `.cauce/manifest.json`— y tenés un guard tuyo con un nombre que el paquete
43
+ hoy trae, `upgrade` lo reemplazaba sin nombrarlo, salía con 0 y después lo anotaba como entregado por
44
+ Cauce. Los tres pasos juntos hacían la pérdida silenciosa **e** irrecuperable: para cuando la notabas, el
45
+ registro decía que ese archivo siempre había sido nuestro. Y la última línea de la corrida afirmaba lo
46
+ contrario de lo que había pasado — «planning, organization y todo lo propio quedaron intactos».
47
+
48
+ Ahora, sin registro para esa ruta, la duda se resuelve del lado del que se vuelve: el archivo se conserva,
49
+ `upgrade` lo nombra y `upgrade --check` sale con 1 **antes** de tocar nada. Si el que querés es el del
50
+ paquete, `--force` lo reemplaza diciéndolo, como ya hacía.
51
+
52
+ ### Agregado
53
+
54
+ - **`check` te avisa de una fila de `HUMAN_ACTIONS.md` que figura resuelta y que ningún commit registró.**
55
+ Una decisión que nadie dejó escrita es una aprobación autoservida, y la puerta barata no la miraba: el
56
+ recorrido la rechazaba recién en Ready, con Triage, Pick, Claim y Decompose ya pagados. En la corrida que
57
+ lo destapó fueron tres paradas y 1,21 M de tokens, con `check` en verde las tres veces.
58
+
59
+ Avisa, no falla —rechazar enunciados por heurística frenaría trabajo legítimo— y **se calla cuando no hay
60
+ con qué contestar**: sin repositorio, o con el archivo todavía sin commitear, no dice nada.
61
+
62
+ - **`check` muestra lo que autorizaste en el chat y sigue vigente.** Desde 0.83.0, lo que un guard te deja
63
+ pasar queda concedido para el resto de la sesión, así no te vuelve a preguntar lo mismo en cada mensaje.
64
+ Eso está bien, y no se veía en ninguna parte: por una línea olvidada en `planning/.ops-approval` `check` te
65
+ avisaba, y por una concesión que vale toda la sesión no decía nada. Ahora lista las dos. Lo que concediste
66
+ trabajando en otra instancia no se le cuenta a ésta.
67
+
68
+ - **Podés acotar una concesión diciendo hasta cuándo vale, y Cauce te hace caso.** Si al autorizar algo
69
+ escribís «mientras dure la tarea t-014», esa concesión deja de valer en cuanto esa tarea ya no sea la de
70
+ tu WIP, en vez de durar toda la sesión. Ya lo escribías y se perdía: de esa frase sobrevivía la ruta y el
71
+ acote se tiraba.
72
+
73
+ Hace falta la palabra `tarea` o `task` —«mientras dure la tarea t-014», «only for task t-014»—, porque sin
74
+ ella no hay contra qué comparar. Lo que no se reconoce no se pierde: vale lo de antes, la sesión entera.
75
+
76
+ - **Lo que concedés en el chat deja un rastro local en `planning/.grant-log`.** Una línea por concesión, con
77
+ la fecha, el alcance, la vía y la sesión; **no guarda lo que escribiste**. Es el mismo molde que
78
+ `planning/.push-log`: sólo agrega, y sirve para contestar meses después quién autorizó qué.
79
+
80
+ **Si tu instancia ya existía, agregale a mano esta línea a tu `.gitignore`:** `planning/.grant-log`.
81
+ `upgrade` no puede tocar ese archivo porque es tuyo, así que la línea sólo llega a las instancias nuevas;
82
+ sin ella, el rastro te va a aparecer en `git status`. Es lo mismo que pasó con `planning/.push-log`.
83
+
84
+ - **`check` te avisa si git no está ignorando los rastros locales de Cauce.** Son tres —
85
+ `planning/.verify-log`, `planning/.push-log` y `planning/.grant-log`— y no deben viajar: son evidencia de
86
+ una corrida tuya, de tu máquina. La línea que los cubre la trae el `.gitignore` que se escribe **al crear**
87
+ la instancia, y `upgrade` no lo toca porque ese archivo es tuyo y puede tener líneas propias. Así que una
88
+ instancia anterior a cada rastro nuevo se quedaba sin su línea para siempre y el archivo aparecía en
89
+ `git status` listo para commitearse.
90
+
91
+ Ahora `check` lo dice y nombra las rutas, que es lo que hay que pegar. Pregunta si git **los ignora**, no
92
+ si la línea está escrita: si ya los cubrís con una regla propia, no te molesta. Sin repositorio se calla.
93
+
94
+ **Si tu instancia ya existía, pegá estas tres líneas en tu `.gitignore`** — o dejá que `check` te diga
95
+ cuáles te faltan:
96
+
97
+ ```
98
+ planning/.verify-log
99
+ planning/.push-log
100
+ planning/.grant-log
101
+ ```
102
+
103
+ ### Cambiado
104
+
105
+ - **La guía dejó de pedirle al agente que borre una autorización que no escribió él.** `AGENTS.md` decía que
106
+ «borrarla es parte de terminar» sin distinguir la línea que el agente pidió para una operación de la que
107
+ dejaste puesta vos a propósito. Ahora sólo se borra la primera.
108
+
17
109
  ## [0.85.0] - 2026-09-12
18
110
 
19
111
  ### Corregido
@@ -20,6 +20,7 @@ const AP = require('../hooks/approval')
20
20
  const I = require('../integrations/registry')
21
21
  const O = require('../core/ownership')
22
22
  const EV = require('../core/evidence')
23
+ const TR = require('../core/trails')
23
24
  const OB = require('../core/onboarding')
24
25
  const C = require('../config/validate')
25
26
  const CP = require('../config/paths')
@@ -175,14 +176,9 @@ function check(dir, cli) {
175
176
  warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
176
177
  warnings.push(...IB.warnings(root, done, config))
177
178
  warnings.push(...AD.sealWarnings(root))
178
- // Una aprobación vale para el conjunto que nombra, así que olvidada sigue autorizando
179
- // esas mismas rutas la próxima vez que alguien las stagee. No caduca sola: lo que la cierra es que se
180
- // vea en cada corrida y alguien la borre.
181
- const aprobadas = AP.read(path.resolve(root, '..'))
182
- if (aprobadas.length) {
183
- warnings.push(`planning/${AP.APPROVAL}: ${aprobadas.length} ruta(s) aprobadas y sin borrar; `
184
- + 'el archivo sigue autorizándolas')
185
- }
179
+ warnings.push(...R.unrecordedHumanActions(path.resolve(root, '..'), P.readHumanActions(root)))
180
+ warnings.push(...AP.warnings(path.resolve(root, '..')))
181
+ warnings.push(...TR.warnings(path.resolve(root, '..')))
186
182
 
187
183
  // Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
188
184
  // avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
@@ -143,7 +143,13 @@ function scan(target, cli) {
143
143
  for (const service of result.services.slice(0, MAX_LISTED)) {
144
144
  // El proyecto que vive en la raíz se nombra por su carpeta: `.` a secas no dice de cuál se habla.
145
145
  const label = service.path === '.' ? `. (${path.basename(service.root || result.root)})` : service.path
146
- const expects = service.env ? `\n espera ${service.env.names.join(', ')} (${service.env.file})` : ''
146
+ // La lista llega entera —quien busca credenciales la necesita así, ver `core/scan.js`— y el recorte
147
+ // de pantalla se aplica acá, junto al de servicios de arriba y por la misma razón.
148
+ const shown = service.env ? service.env.names.slice(0, service.env.names.length - service.env.truncated) : []
149
+ const expects = service.env
150
+ ? `\n espera ${shown.join(', ')}${service.env.truncated ? ` y ${service.env.truncated} más` : ''} `
151
+ + `(${service.env.file})`
152
+ : ''
147
153
  console.log(
148
154
  `${label} [${(service.runtimes || []).join(', ')}]${SC.commandsLine(service.commands)}${expects}`,
149
155
  )
@@ -20,7 +20,7 @@
20
20
  const fs = require('node:fs')
21
21
  const path = require('node:path')
22
22
 
23
- const LOG = path.join('planning', '.verify-log')
23
+ const LOG = require('./trails').VERIFY
24
24
  // Rodante: interesa el trabajo en curso, no la historia. Sin tope, el archivo crece con cada commit y
25
25
  // nadie lo mira; con tope, lo que queda es lo que todavía se puede cruzar contra una entrada abierta.
26
26
  const MAX_RUNS = 20
@@ -127,7 +127,9 @@ function orphanCredentials(root) {
127
127
  for (const name of env.names || []) {
128
128
  if (sensitiveKey(name) && !named(contracts, name)) orphans.push({ name, service: service.path })
129
129
  }
130
- if (env.truncated) cut.push(`${service.path} (${env.truncated} de ${env.names.length + env.truncated})`)
130
+ // `names` llega entero desde el 134 —el tope recorta lo que se lista, no lo que se mira—, así que el
131
+ // total es su largo a secas: sumarle `truncated` contaría dos veces lo mismo.
132
+ if (env.truncated) cut.push(`${service.path} (${env.truncated} de ${env.names.length})`)
131
133
  }
132
134
  const warnings = []
133
135
  if (orphans.length) {
@@ -141,11 +143,12 @@ function orphanCredentials(root) {
141
143
  + 'organization/workspace.md o en una fila de planning/HUMAN_ACTIONS.md; el criterio es el nombre, así '
142
144
  + 'que una credencial con nombre de configuración no aparece acá')
143
145
  }
144
- // El escaneo corta cada ejemplo en un tope, y lo que quedó afuera no se miró: con el filtro, puede ser
145
- // justo la credencial.
146
+ // El tope recorta lo que se **lista**, no lo que se mira: las credenciales de arriba salen del ejemplo
147
+ // entero. Esto se sigue diciendo porque un corte que no se anuncia hace pasar lo listado por todo lo que
148
+ // hay —y hasta el 134 además cegaba el análisis, que es de donde viene la redacción vieja—.
146
149
  if (cut.length) {
147
- warnings.push(`sin revisar por credenciales sin dueño, pasado el tope de variables por servicio: `
148
- + `${cut.join(', ')} — lo que quedó afuera puede incluir una credencial que nadie carga`)
150
+ warnings.push(`pasado el tope de variables por servicio: ${cut.join(', ')} — el ejemplo se lista `
151
+ + 'recortado; las credenciales se buscan igual sobre el archivo entero')
149
152
  }
150
153
  return warnings
151
154
  }
@@ -352,14 +352,18 @@ function deliveredFiles(root, relative) {
352
352
 
353
353
  // Lo que el paquete empieza a traer con un nombre que la instancia ya usaba para algo suyo: un guard propio
354
354
  // que se llama como uno nuevo del toolkit (caso 110). Sin huella en el registro no cuenta como edición, así
355
- // que copiar encima lo borraba sin decirlo. Sólo si el registro ya conoce la ruta: en una instancia
356
- // anterior al registro, «sin huella» también es «lo entregó una versión vieja».
355
+ // que copiar encima lo borraba sin decirlo.
356
+ //
357
+ // Sin registro para esa ruta la duda no se puede resolver —«sin huella» es tanto «es mío» como «lo entregó
358
+ // una versión vieja»—, así que se elige el lado del que se vuelve: se conserva y se avisa. Mirar sólo las
359
+ // instancias que ya tenían registro dejaba afuera justo a la que más perdía, la anterior al mecanismo, donde
360
+ // el primer `upgrade` pisaba el guard propio sin nombrarlo y después lo registraba como entregado por Cauce
361
+ // (caso 125). Equivocarse ahora cuesta un aviso de más; antes costaba un archivo que no vuelve.
357
362
  function collisions(root) {
358
363
  const manifest = require('./manifest')
359
364
  const recorded = manifest.read(root)
360
365
  const found = []
361
366
  for (const relative of RUNTIME_PATHS) {
362
- if (!Object.keys(recorded).some((key) => key.startsWith(`${relative}/`))) continue
363
367
  for (const file of shippedFiles(relative)) {
364
368
  const local = path.join(root, relative, file)
365
369
  if (recorded[`${relative}/${file}`] || !fs.existsSync(local)) continue
@@ -119,4 +119,32 @@ function coverageWarnings(opsRoot, done) {
119
119
  return warnings
120
120
  }
121
121
 
122
- module.exports = { reposFor, repoOf, lastCommit, coverageWarnings }
122
+ // La fila de `HUMAN_ACTIONS.md` que figura resuelta sin que ningún commit la haya tocado. Ready la rechaza
123
+ // —una decisión que nadie dejó escrita es una aprobación autoservida— y `check` no la miraba, así que el
124
+ // defecto se descubría en la fase 4 de un recorrido: 1,21 M de tokens en tres paradas, con la puerta en
125
+ // verde las tres veces (caso 121).
126
+ //
127
+ // Se pregunta con el pickaxe sobre la línea entera y no por la palabra `resuelta`: lo que hay que
128
+ // establecer es que **esa** fila, con ese estado, existió alguna vez en un commit. Una que pasó a resuelta
129
+ // sólo en el árbol de trabajo no aparece en ninguno.
130
+ //
131
+ // Sin repositorio, o con el archivo todavía sin commitear, no dice nada: no hay historia contra la cual
132
+ // preguntar y el aviso sería inventado. Degrada como el 086 con las migraciones — antes callar de más que
133
+ // avisar de más, porque un aviso que salta siempre se termina apagando.
134
+ function unrecordedHumanActions(opsRoot, rows) {
135
+ const file = path.join(opsRoot, 'planning', 'HUMAN_ACTIONS.md')
136
+ const top = git(path.dirname(file), 'rev-parse', '--show-toplevel')
137
+ if (top.status !== 0) return []
138
+ const repo = top.stdout.trim()
139
+ const relative = path.relative(repo, file)
140
+ const history = git(repo, 'log', '--format=%h', '--', relative)
141
+ if (history.status !== 0 || !history.stdout.trim()) return []
142
+ return rows.filter((row) => row.resolved)
143
+ .filter((row) => {
144
+ const found = git(repo, 'log', '--format=%h', `-S${row.raw}`, '--', relative)
145
+ return found.status === 0 && !found.stdout.trim()
146
+ })
147
+ .map((row) => `HUMAN_ACTIONS.md: ${row.task} figura resuelta y ningún commit la registró`)
148
+ }
149
+
150
+ module.exports = { reposFor, repoOf, lastCommit, coverageWarnings, unrecordedHumanActions }
@@ -107,7 +107,12 @@ function manifestsOf(dir) {
107
107
  // diciendo «la credencial del proveedor» en vez de nombrarla.
108
108
  const ENV_EXAMPLES = ['.env.example', '.env.sample', '.env.template', '.env.dist']
109
109
 
110
- // Un ejemplo con cientos de variables es un archivo generado, no un contrato: se corta y se dice.
110
+ // Un ejemplo con cientos de variables es un archivo generado, no un contrato: se corta lo que **se
111
+ // lista** y se dice cuánto. Lo que no se corta es lo que se **mira**: hasta el 134 este número recortaba
112
+ // las dos cosas, así que una credencial en la posición 41 no existía para quien busca credenciales sin
113
+ // dueño —medido en una instancia real: dos, sobre las 61 variables de un servicio, y el único aviso era
114
+ // que se había cortado—. Quién recorta para imprimir está en `cli/wiring.js`, junto al otro recorte de
115
+ // ese mismo comando.
111
116
  const ENV_MAX = 40
112
117
 
113
118
  function expectedEnv(dir) {
@@ -122,7 +127,7 @@ function expectedEnv(dir) {
122
127
  .map((line) => line.replace(/^export\s+/, '').split('=')[0].trim())
123
128
  .filter((key) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
124
129
  if (!names.length) return null
125
- return { file: name, names: names.slice(0, ENV_MAX), truncated: Math.max(0, names.length - ENV_MAX) }
130
+ return { file: name, names, truncated: Math.max(0, names.length - ENV_MAX) }
126
131
  }
127
132
  return null
128
133
  }
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ // Los rastros que Cauce escribe **dentro** de la instancia y que no viajan: son evidencia de una corrida
4
+ // local, de la máquina donde ocurrió, y committearlos sería historia que nadie lee y un conflicto por
5
+ // commit. Cada uno nace con su razón escrita en `template/gitignore`, al lado de su línea.
6
+ //
7
+ // Viven declarados acá y no en el módulo que escribe cada uno porque son tres —`verify`, `push` y el
8
+ // chat— y hacían falta en un cuarto lugar: el aviso de abajo. Escribir la lista ahí habría dejado una
9
+ // cuarta copia de la misma ruta, que es la que se pudre cuando alguien renombra un archivo (caso 128).
10
+ const VERIFY = 'planning/.verify-log'
11
+ const PUSH = 'planning/.push-log'
12
+ const GRANT = 'planning/.grant-log'
13
+
14
+ const LOCAL = [VERIFY, PUSH, GRANT]
15
+
16
+ const { spawnSync } = require('node:child_process')
17
+ const { mode } = require('./ownership')
18
+
19
+ // A quién le toca esta pregunta. Los rastros los escribe Cauce **dentro de una instancia**, así que en una
20
+ // raíz que no lo es no existen ni van a existir, y avisar ahí es hablar de algo imposible: el molde de este
21
+ // repositorio lo recibía una vez por corrida, con `check template/planning` (caso 133).
22
+ //
23
+ // Se mira el modo declarado y no el nombre de la carpeta, porque las tres formas de no ser una instancia
24
+ // son la misma pregunta: `toolkit` —acá se fabrica Cauce—, `{{MODE}}` sin renderizar —el molde, que es la
25
+ // raíz que dispara el falso positivo— y la ausencia de configuración. Un modo ilegible o fuera del enum
26
+ // también cae del lado silencioso, y eso no esconde nada: `check` ya los denuncia como error y sale 1.
27
+ const INSTANCE = new Set(['embedded', 'sidecar'])
28
+ const forInstance = (root) => {
29
+ try { return INSTANCE.has(mode(root)) } catch { return false }
30
+ }
31
+
32
+ // Cuáles de esos rastros **git no está ignorando** en esta instancia.
33
+ //
34
+ // La línea que los cubre la trae `template/gitignore`, y ese archivo se escribe al **crear** la
35
+ // instancia: `upgrade` no lo toca, porque es de la empresa y puede llevar líneas propias que un
36
+ // reemplazo se llevaría puestas. Así que una instancia anterior a cada rastro nuevo se queda sin su
37
+ // línea para siempre, y el archivo aparece en `git status` listo para commitearse por descuido.
38
+ //
39
+ // Se pregunta por el **efecto** y no por el texto del molde, y la diferencia importa en los dos
40
+ // sentidos: la empresa puede cubrirlo con una regla propia —y comparar líneas daría un falso positivo—,
41
+ // y en sidecar el `.gitignore` vive en la instancia mientras el repositorio es el workspace de arriba.
42
+ // Medido en las dos topologías: `check-ignore` contesta igual.
43
+ //
44
+ // Sin repositorio no hay a quién preguntarle —`check-ignore` sale 128— y ahí se calla: un aviso sobre
45
+ // lo que git haría en un repositorio que no existe sería inventado. Es la misma degradación que el 086
46
+ // eligió para las migraciones y el 121 para las filas resueltas: antes callar de más que avisar de más.
47
+ function unignored(root) {
48
+ const asked = spawnSync('git', ['check-ignore', '--', ...LOCAL], { cwd: root, encoding: 'utf8' })
49
+ // 0 = ignoró alguno, 1 = ninguno de los que preguntó; cualquier otro código es que no hay repositorio
50
+ // o que git no pudo contestar, y entonces no hay nada que reportar.
51
+ if (asked.status !== 0 && asked.status !== 1) return []
52
+ const covered = new Set(asked.stdout.split('\n').map((one) => one.trim()).filter(Boolean))
53
+ return LOCAL.filter((one) => !covered.has(one))
54
+ }
55
+
56
+ // El aviso, en la forma que `check` publica el resto: cuenta, nombra y no falla. Lleva las rutas porque
57
+ // son exactamente lo que hay que pegar, que es lo único accionable — el archivo es de la empresa y Cauce
58
+ // no lo edita.
59
+ function warnings(root) {
60
+ if (!forInstance(root)) return []
61
+ const missing = unignored(root)
62
+ if (!missing.length) return []
63
+ return [`${missing.length} rastro(s) local(es) que git no ignora (${missing.join(', ')}); `
64
+ + 'agregá esas líneas a tu .gitignore o van a entrar al repositorio']
65
+ }
66
+
67
+ module.exports = { VERIFY, PUSH, GRANT, warnings }
@@ -121,4 +121,26 @@ function HOW(variable, lines, input, pasteable = lines) {
121
121
  return ask + paste + unresolved + off
122
122
  }
123
123
 
124
- module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW }
124
+ // Las dos exenciones que sobreviven a un bloqueo, para que `check` las muestre juntas: la lista que una
125
+ // persona escribió a mano, y lo que la sesión fue concediendo sola a medida que los guards dejaban pasar.
126
+ // Ninguna de las dos caduca por su cuenta, así que lo único que las cierra es verlas en cada corrida.
127
+ //
128
+ // La segunda no se veía en ninguna parte hasta 0.86.0. El 116 la trajo para que la persona no tuviera que
129
+ // repetir la autorización en cada mensaje —y eso está bien—, pero quedó del lado que nadie audita: vive en
130
+ // el temporal del sistema, mientras que por una sola línea del archivo `check` sí avisaba. Una exención que
131
+ // no se ve es un límite que ya no existe (caso 117).
132
+ function warnings(root) {
133
+ const out = []
134
+ const approved = read(root)
135
+ if (approved.length) {
136
+ out.push(`planning/${APPROVAL}: ${approved.length} ruta(s) aprobadas y sin borrar; `
137
+ + 'el archivo sigue autorizándolas')
138
+ }
139
+ const granted = CHAT.grantedIn(root)
140
+ if (granted.length) {
141
+ out.push(`${granted.length} ruta(s) concedidas en el chat de esta sesión: ${granted.join(', ')}`)
142
+ }
143
+ return out
144
+ }
145
+
146
+ module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, warnings }
@@ -16,6 +16,14 @@
16
16
  const fs = require('node:fs')
17
17
  const os = require('node:os')
18
18
  const path = require('node:path')
19
+ const { opsRoot } = require('./input')
20
+ const { readWip } = require('../planning/parser')
21
+ const { runner } = require('../planning/claims')
22
+ const TRAIL = require('./trail')
23
+
24
+ // Dónde queda anotado lo que se concedió. Cómo se escribe un rastro, en `trail.js`; por qué éste no
25
+ // viaja y quién más lo declara, en `core/trails.js`.
26
+ const LOG = require('../core/trails').GRANT
19
27
 
20
28
  // El temporal y no la instancia: el texto de la persona no tiene por qué terminar en un commit, y una
21
29
  // orden dura lo que dura la sesión.
@@ -80,6 +88,16 @@ const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena ed
80
88
  + 'authorize authorized allow allowed permit permitted grant granted approved').split(' '))
81
89
  const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
82
90
 
91
+ // El alcance que la persona ya escribe al conceder: «escribí X mientras dure la tarea t-014». Medido, de
92
+ // esa frase sobrevivía la ruta y el acote se tiraba, así que la concesión valía la sesión entera aunque
93
+ // alguien hubiera dicho hasta cuándo (caso 127).
94
+ //
95
+ // Exige la palabra `tarea` o `task` a propósito. Sin ella —«mientras dure esto», «for now»— no hay contra
96
+ // qué comparar, y adivinar un alcance que nadie nombró concede de menos por una lectura propia. La lista
97
+ // es corta por lo mismo que la de verbos: lo que no se reconoce no se pierde, vale lo de antes.
98
+ const SCOPE = new RegExp(String.raw`(?:mientras dure|mientras siga|durante|s[oó]lo para|solo para|only for)`
99
+ + String.raw`\s+(?:la\s+|the\s+)?(?:tarea|task)\s+([\p{L}\p{N}][\p{L}\p{N}._-]*)`, 'iu')
100
+
83
101
  function asks(clause) {
84
102
  const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
85
103
  return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
@@ -98,10 +116,22 @@ function mentions(text, item) {
98
116
  if (before && !/[\s'"`(/]/.test(before)) continue
99
117
  if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
100
118
  const clause = lower.slice(0, at).split(CLAUSE).pop()
101
- found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
119
+ const frase = `${clause} ${rest.split(CLAUSE)[0]}`
120
+ found.push({
121
+ denied: NEGATION.test(clause),
122
+ asked: asks(frase),
123
+ // El alcance se lee de la misma frase que decide si la ruta fue pedida, y no del mensaje entero:
124
+ // un «mientras dure la tarea t-014» que hable de otra cosa, en otra oración, no acota a ésta.
125
+ scope: (frase.match(SCOPE) || [])[1] || '',
126
+ })
102
127
  }
103
128
  }
104
- return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
129
+ const pedidas = found.filter((one) => one.asked && !one.denied)
130
+ return {
131
+ named: pedidas.length > 0,
132
+ denied: found.some((one) => one.denied),
133
+ scope: (pedidas.find((one) => one.scope) || {}).scope || '',
134
+ }
105
135
  }
106
136
 
107
137
  // Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
@@ -149,9 +179,16 @@ function record(input) {
149
179
  ? previous.pending.filter((item) => !mentions(text, item).denied)
150
180
  : []
151
181
  const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
182
+ // El acote viaja con lo concedido: lo que se negó pierde las dos cosas a la vez, y nada queda con un
183
+ // alcance que ya no acota a nadie.
184
+ const scopes = {}
185
+ for (const one of granted) if (previous.scopes && previous.scopes[one]) scopes[one] = previous.scopes[one]
152
186
  fs.mkdirSync(DIR, { recursive: true })
187
+ // Sobre qué instancia se está hablando, que es lo que después deja filtrar lo concedido: por qué hace
188
+ // falta, en `grantedIn`.
153
189
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
154
- { id: idOf(input), text, human, flow: flowCommand(text), approved, granted, pending: [] }))
190
+ { id: idOf(input), text, human, flow: flowCommand(text), root: opsRoot(input), approved, granted,
191
+ scopes, pending: [] }))
155
192
  } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
156
193
  }
157
194
 
@@ -170,11 +207,35 @@ function said(input) {
170
207
  // había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
171
208
  // se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
172
209
  const named = (text, item) => mentions(text, item).named
210
+
211
+ // Si el acote que la persona puso sigue en pie. Sin alcance no hay nada que comprobar y vale lo de
212
+ // siempre —la sesión—, que es lo que decidió el 116; con alcance, vale mientras esa tarea sea la del WIP,
213
+ // y `readWip` devuelve nada con el WIP en IDLE, así que «se cerró» se lee sin mecanismo nuevo.
214
+ //
215
+ // El orden importa y es el mismo que toma `files.js` con el plan: primero lo barato. Una concesión sin
216
+ // alcance —el caso común— no paga ninguna lectura del planning.
217
+ //
218
+ // Sin WIP legible el alcance venció, y son el mismo caso tres cosas que parecen distintas: el WIP en
219
+ // IDLE, el archivo que no está y el `planning` que no se puede leer. `readWip` las devuelve todas como
220
+ // nada —su lectura traga el error—, así que acá no hay ninguna rama de excepción que atender, y vencer es
221
+ // la dirección de la que se vuelve: quien lo necesite lo vuelve a pedir.
222
+ //
223
+ // Sin instancia resoluble no hay WIP contra el cual comparar, y eso no es lo mismo: el alcance se respeta,
224
+ // porque revocar ahí sería castigar a quien trabaja fuera de una instancia por algo que no dijo.
225
+ function scopeAlive(saved, item) {
226
+ const scope = (saved.scopes || {})[item]
227
+ if (!scope) return true
228
+ if (!saved.root) return true
229
+ const wip = readWip(path.join(saved.root, 'planning'), runner())
230
+ return Boolean(wip && String(wip.task).toLowerCase() === String(scope).toLowerCase())
231
+ }
232
+
173
233
  function why(saved, item, asked, inherit) {
174
234
  if (asked(saved.text, item)) return 'orden'
175
235
  if (saved.approved.includes(item)) return 'dale'
176
236
  if (!inherit) return ''
177
- return (saved.granted || []).includes(item) ? 'concedido' : ''
237
+ if (!(saved.granted || []).includes(item)) return ''
238
+ return scopeAlive(saved, item) ? 'concedido' : ''
178
239
  }
179
240
 
180
241
  // Lo que un guard dejó pasar queda anotado, que es la contracara de `hold`: hasta 0.82.0 sólo se anotaba
@@ -185,14 +246,22 @@ function why(saved, item, asked, inherit) {
185
246
  // Se anota el ítem **como el guard lo nombró** —la ruta en la forma que ese guard tiene a mano— y no el
186
247
  // archivo que hay detrás: es el mismo alcance que tiene una línea de `.ops-approval`, angosto de más
187
248
  // antes que de menos.
188
- function grant(input, saved, items) {
249
+ function grant(input, saved, entries) {
189
250
  const before = saved.granted || []
190
- const granted = [...new Set([...before, ...items])]
191
- if (granted.length === before.length) return
251
+ const nuevas = entries.filter((one) => !before.includes(one.item))
252
+ if (!nuevas.length) return
253
+ const grantedAt = new Date().toISOString()
192
254
  try {
193
- saved.granted = granted
255
+ saved.granted = [...before, ...nuevas.map((one) => one.item)]
256
+ saved.scopes = { ...(saved.scopes || {}) }
257
+ for (const one of nuevas) if (one.scope) saved.scopes[one.item] = one.scope
194
258
  fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
195
259
  } catch { /* sin anotarlo, se vuelve a pedir */ }
260
+ // Y queda el rastro, que es lo que el registro de la sesión no puede dar: muere con ella, y lo que una
261
+ // auditoría pregunta es quién concedió qué y con qué alcance, meses después (caso 127).
262
+ TRAIL.append(saved.root, LOG, nuevas.map((one) => ({
263
+ grantedAt, item: one.item, scope: one.scope || null, via: one.via, session: input.session_id || null,
264
+ })))
196
265
  }
197
266
 
198
267
  // Con qué autorización pasa cada uno de los que pasan. Lo pregunta quien necesita el porqué y no sólo el
@@ -213,9 +282,15 @@ function authorized(input, items, { asked = named, inherit = true } = {}) {
213
282
  function unauthorized(input, items) {
214
283
  const saved = said(input)
215
284
  if (!saved) return items
216
- const passed = items.filter((item) => why(saved, item, named, true))
217
- grant(input, saved, passed)
218
- return items.filter((item) => !passed.includes(item))
285
+ const passed = items.map((item) => ({ item, via: why(saved, item, named, true) })).filter((one) => one.via)
286
+ // El alcance sale del mensaje cuando es éste el que lo concede, y del registro cuando se hereda: una
287
+ // orden vieja no se reinterpreta contra un texto que no la nombraba.
288
+ grant(input, saved, passed.map((one) => ({
289
+ ...one,
290
+ scope: one.via === 'orden' ? mentions(saved.text, one.item).scope : (saved.scopes || {})[one.item] || '',
291
+ })))
292
+ const cleared = new Set(passed.map((one) => one.item))
293
+ return items.filter((item) => !cleared.has(item))
219
294
  }
220
295
 
221
296
  // Lo mismo sin conceder y sin heredar: lo que no está en el mensaje en curso queda pendiente aunque la
@@ -237,4 +312,24 @@ function hold(input, items) {
237
312
  } catch { return false }
238
313
  }
239
314
 
240
- module.exports = { DIR, record, said, authorized, unauthorized, unauthorizedNow, hold, ordersPush }
315
+ // Lo que quedó concedido en esta máquina para una instancia, para que `check` pueda mostrarlo. Se filtra
316
+ // por la raíz que anotó el mensaje: el directorio es uno solo por máquina, así que sin filtrar una
317
+ // instancia reportaría las exenciones de la de al lado, que es peor que no reportar ninguna.
318
+ //
319
+ // Un registro anterior a que la raíz se anotara no trae el campo y queda afuera, igual que uno escrito
320
+ // fuera de toda instancia —ahí `opsRoot` devuelve vacío—: decir «concedido» sin saber dónde es exactamente
321
+ // lo que este filtro existe para evitar (caso 117).
322
+ function grantedIn(root) {
323
+ let names = []
324
+ try { names = fs.readdirSync(DIR) } catch { return [] }
325
+ const found = new Set()
326
+ for (const name of names) {
327
+ let saved = null
328
+ try { saved = JSON.parse(fs.readFileSync(path.join(DIR, name), 'utf8')) } catch { continue }
329
+ if (!saved || !saved.root || saved.root !== root) continue
330
+ for (const one of saved.granted || []) found.add(one)
331
+ }
332
+ return [...found].sort()
333
+ }
334
+
335
+ module.exports = { DIR, record, said, authorized, unauthorized, unauthorizedNow, hold, ordersPush, grantedIn }
@@ -19,12 +19,11 @@
19
19
  //
20
20
  // El `--force` no llega hasta acá: lo frena `destructive` antes, sin override (R8).
21
21
 
22
- const fs = require('node:fs')
23
- const path = require('node:path')
24
22
  const { spawnSync } = require('node:child_process')
25
23
  const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
26
24
  const AP = require('./approval')
27
25
  const CHAT = require('./chat')
26
+ const TRAIL = require('./trail')
28
27
 
29
28
  // Lo que va entre `git push` y el fin del comando. El salto de línea corta igual que `;`, por lo que
30
29
  // `destructive` explica en MISMO.
@@ -130,28 +129,16 @@ function workMessage(items, input) {
130
129
  // El rastro de las aprobaciones que publicaron: una línea por push que pasó porque alguien lo autorizó.
131
130
  // Una aprobación se consume sin dejar nada —un «dale» publica y al mensaje siguiente ya no queda quién lo
132
131
  // autorizó— y un push no vuelve atrás, así que «quién, a qué rama y cuándo» no tenía de dónde salir
133
- // (caso 112).
132
+ // (caso 112). Qué garantiza el archivo —que sólo agrega, que no frena si falla— está en `trail.js`.
134
133
  //
135
- // Sólo agrega, a diferencia del registro de gates, que es rodante: lo que una auditoría pregunta es
136
- // justamente la entrada vieja.
137
- //
138
- // No guarda el texto de la persona. La vía y la sesión alcanzan para reconstruir qué pasó, y el texto se
139
- // queda en el temporal, que es donde el 098 decidió dejarlo.
140
- //
141
- // Y anota la autorización, no el resultado: este hook corre antes del comando, así que un push que después
142
- // falla queda registrado igual. Por eso la fecha se llama `authorizedAt` y no `at`: leer la línea como
143
- // «esto se publicó» afirmaría algo que el hook no puede saber.
144
- const LOG = path.join('planning', '.push-log')
134
+ // Acá se decide una sola cosa, y es del push: anota la autorización, no el resultado. Este hook corre
135
+ // antes del comando, así que un push que después falla queda registrado igual. Por eso la fecha se llama
136
+ // `authorizedAt` y no `at`: leer la línea como «esto se publicó» afirmaría algo que el hook no puede saber.
137
+ const LOG = require('../core/trails').PUSH
145
138
 
146
139
  function trail(root, session, entries) {
147
- if (!root) return
148
- try {
149
- const authorizedAt = new Date().toISOString()
150
- const file = path.join(root, LOG)
151
- fs.mkdirSync(path.dirname(file), { recursive: true })
152
- fs.appendFileSync(file, `${entries
153
- .map((one) => JSON.stringify({ authorizedAt, ...one, session: session || null })).join('\n')}\n`)
154
- } catch { /* el push ya estaba autorizado: no lo frena un registro que no se pudo escribir */ }
140
+ const authorizedAt = new Date().toISOString()
141
+ TRAIL.append(root, LOG, entries.map((one) => ({ authorizedAt, ...one, session: session || null })))
155
142
  }
156
143
 
157
144
  // Un destino en la forma en que se anota. Sin remoto ni rama resolubles va `null`, que es exactamente lo
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ // El rastro local de una autorización: una línea JSON por cosa que pasó porque alguien la autorizó.
4
+ //
5
+ // **Sólo agrega**, a diferencia de un registro rodante como el de gates: lo que una auditoría pregunta es
6
+ // justamente la entrada vieja.
7
+ //
8
+ // **No frena nada si no se puede escribir.** Lo que se anota ya fue autorizado antes de llegar acá, así
9
+ // que un registro que falla no puede convertirse en un bloqueo: sería negar por no haber podido contar.
10
+ //
11
+ // **El texto de la persona no entra**, y eso lo decide quien arma las entradas: la vía y la sesión
12
+ // alcanzan para reconstruir qué pasó, y el texto se queda en el temporal, que es donde el 098 lo dejó.
13
+ //
14
+ // Vive en su propio módulo y no dentro del guard que lo usa porque lo escriben dos —el push que se
15
+ // autorizó (caso 112) y la concesión del chat (caso 127)— y `chat.js` **no puede importar a `push.js`**:
16
+ // `push.js` ya lo importa a él, así que el require sería circular. Escrito dos veces, una de las dos
17
+ // copias se pudre sin que nada falle.
18
+ //
19
+ // Las entradas llegan armadas y no se tocan acá: cada rastro nombra sus campos como corresponde a lo que
20
+ // anota —`authorizedAt` no es `grantedAt`— y el orden de las claves es parte de lo que sus pruebas fijan.
21
+
22
+ const fs = require('node:fs')
23
+ const path = require('node:path')
24
+
25
+ function append(root, relative, entries) {
26
+ if (!root || !entries.length) return
27
+ try {
28
+ const file = path.join(root, relative)
29
+ fs.mkdirSync(path.dirname(file), { recursive: true })
30
+ fs.appendFileSync(file, `${entries.map((one) => JSON.stringify(one)).join('\n')}\n`)
31
+ } catch { /* lo anotado ya estaba autorizado: no lo frena un registro que no se pudo escribir */ }
32
+ }
33
+
34
+ module.exports = { append }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.85.0",
3
+ "version": "0.87.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -34,8 +34,7 @@
34
34
  "coverage": "bash test/tools/coverage.sh",
35
35
  "coverage:update": "bash test/tools/coverage.sh --update",
36
36
  "dead-code": "node test/tools/dead-code.js",
37
- "dead-code:engine": "node test/tools/dead-code.js --engine",
38
- "ci": "npm run check && npm run automation:check && npm run integration:check && npm run dead-code:engine && npm run coverage",
37
+ "ci": "npm run check && npm run automation:check && npm run integration:check && npm run dead-code && npm run coverage",
39
38
  "prepublishOnly": "npm run ci"
40
39
  },
41
40
  "engines": {
@@ -136,7 +136,8 @@ caduca, deja de coincidir. En la última fila es más visible —aprobás el ín
136
136
  una cosa más la invalida—, y es a propósito: commitear en rojo se autoriza para un commit concreto.
137
137
 
138
138
  No se borra sola, así que un commit frenado por otra cosa no te obliga a rehacerla. `check` te avisa
139
- mientras exista, y borrarla es parte de terminar.
139
+ mientras exista, y borrar **la línea que pediste para una operación ya terminada** es parte de terminar.
140
+ La que dejaste puesta vos no se toca: el agente no borra una autorización que no escribió él.
140
141
 
141
142
  **Publicar un paquete o instalar algo global no se aprueba así**, porque ahí no hay ninguna ruta sobre
142
143
  la cual decidir. Esa sigue siendo una acción humana y su única llave es la variable de abajo.
@@ -14,6 +14,10 @@ planning/.verify-log
14
14
  # la sesión que publicó.
15
15
  planning/.push-log
16
16
 
17
+ # El rastro de lo que se concedió en el chat, por lo mismo que el de arriba: una concesión se consume sin
18
+ # dejar nada, y quién la dio y con qué alcance se pregunta en la máquina donde ocurrió la conversación.
19
+ planning/.grant-log
20
+
17
21
  # El plan de cada runner. Es de la máquina que lo corre —existe para recuperar una ejecución
18
22
  # interrumpida, y nadie más puede retomarla— y cambia en cada paso, así que compartirlo es un conflicto
19
23
  # por commit a cambio de nada. Lo que el equipo sí necesita saber vive en `planning/claims/`.