@ingeniomaps/cauce 0.84.0 → 0.86.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 +92 -0
- package/engine/cli/planning.js +4 -8
- package/engine/core/evidence.js +1 -1
- package/engine/core/ownership.js +7 -3
- package/engine/core/repos.js +29 -1
- package/engine/core/trails.js +52 -0
- package/engine/hooks/approval.js +23 -1
- package/engine/hooks/chat.js +107 -12
- package/engine/hooks/push.js +8 -21
- package/engine/hooks/shell.js +11 -0
- package/engine/hooks/trail.js +34 -0
- package/package.json +2 -3
- package/template/AGENTS.md +2 -1
- package/template/gitignore +4 -0
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.86.0] - 2026-09-12
|
|
18
|
+
|
|
19
|
+
### Corregido
|
|
20
|
+
|
|
21
|
+
- **Tu guard propio ya no se pierde al actualizar una instancia vieja.** Si tu instancia es anterior al
|
|
22
|
+
registro de entregas —no tiene `.cauce/manifest.json`— y tenés un guard tuyo con un nombre que el paquete
|
|
23
|
+
hoy trae, `upgrade` lo reemplazaba sin nombrarlo, salía con 0 y después lo anotaba como entregado por
|
|
24
|
+
Cauce. Los tres pasos juntos hacían la pérdida silenciosa **e** irrecuperable: para cuando la notabas, el
|
|
25
|
+
registro decía que ese archivo siempre había sido nuestro. Y la última línea de la corrida afirmaba lo
|
|
26
|
+
contrario de lo que había pasado — «planning, organization y todo lo propio quedaron intactos».
|
|
27
|
+
|
|
28
|
+
Ahora, sin registro para esa ruta, la duda se resuelve del lado del que se vuelve: el archivo se conserva,
|
|
29
|
+
`upgrade` lo nombra y `upgrade --check` sale con 1 **antes** de tocar nada. Si el que querés es el del
|
|
30
|
+
paquete, `--force` lo reemplaza diciéndolo, como ya hacía.
|
|
31
|
+
|
|
32
|
+
### Agregado
|
|
33
|
+
|
|
34
|
+
- **`check` te avisa de una fila de `HUMAN_ACTIONS.md` que figura resuelta y que ningún commit registró.**
|
|
35
|
+
Una decisión que nadie dejó escrita es una aprobación autoservida, y la puerta barata no la miraba: el
|
|
36
|
+
recorrido la rechazaba recién en Ready, con Triage, Pick, Claim y Decompose ya pagados. En la corrida que
|
|
37
|
+
lo destapó fueron tres paradas y 1,21 M de tokens, con `check` en verde las tres veces.
|
|
38
|
+
|
|
39
|
+
Avisa, no falla —rechazar enunciados por heurística frenaría trabajo legítimo— y **se calla cuando no hay
|
|
40
|
+
con qué contestar**: sin repositorio, o con el archivo todavía sin commitear, no dice nada.
|
|
41
|
+
|
|
42
|
+
- **`check` muestra lo que autorizaste en el chat y sigue vigente.** Desde 0.83.0, lo que un guard te deja
|
|
43
|
+
pasar queda concedido para el resto de la sesión, así no te vuelve a preguntar lo mismo en cada mensaje.
|
|
44
|
+
Eso está bien, y no se veía en ninguna parte: por una línea olvidada en `planning/.ops-approval` `check` te
|
|
45
|
+
avisaba, y por una concesión que vale toda la sesión no decía nada. Ahora lista las dos. Lo que concediste
|
|
46
|
+
trabajando en otra instancia no se le cuenta a ésta.
|
|
47
|
+
|
|
48
|
+
- **Podés acotar una concesión diciendo hasta cuándo vale, y Cauce te hace caso.** Si al autorizar algo
|
|
49
|
+
escribís «mientras dure la tarea t-014», esa concesión deja de valer en cuanto esa tarea ya no sea la de
|
|
50
|
+
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
|
|
51
|
+
acote se tiraba.
|
|
52
|
+
|
|
53
|
+
Hace falta la palabra `tarea` o `task` —«mientras dure la tarea t-014», «only for task t-014»—, porque sin
|
|
54
|
+
ella no hay contra qué comparar. Lo que no se reconoce no se pierde: vale lo de antes, la sesión entera.
|
|
55
|
+
|
|
56
|
+
- **Lo que concedés en el chat deja un rastro local en `planning/.grant-log`.** Una línea por concesión, con
|
|
57
|
+
la fecha, el alcance, la vía y la sesión; **no guarda lo que escribiste**. Es el mismo molde que
|
|
58
|
+
`planning/.push-log`: sólo agrega, y sirve para contestar meses después quién autorizó qué.
|
|
59
|
+
|
|
60
|
+
**Si tu instancia ya existía, agregale a mano esta línea a tu `.gitignore`:** `planning/.grant-log`.
|
|
61
|
+
`upgrade` no puede tocar ese archivo porque es tuyo, así que la línea sólo llega a las instancias nuevas;
|
|
62
|
+
sin ella, el rastro te va a aparecer en `git status`. Es lo mismo que pasó con `planning/.push-log`.
|
|
63
|
+
|
|
64
|
+
- **`check` te avisa si git no está ignorando los rastros locales de Cauce.** Son tres —
|
|
65
|
+
`planning/.verify-log`, `planning/.push-log` y `planning/.grant-log`— y no deben viajar: son evidencia de
|
|
66
|
+
una corrida tuya, de tu máquina. La línea que los cubre la trae el `.gitignore` que se escribe **al crear**
|
|
67
|
+
la instancia, y `upgrade` no lo toca porque ese archivo es tuyo y puede tener líneas propias. Así que una
|
|
68
|
+
instancia anterior a cada rastro nuevo se quedaba sin su línea para siempre y el archivo aparecía en
|
|
69
|
+
`git status` listo para commitearse.
|
|
70
|
+
|
|
71
|
+
Ahora `check` lo dice y nombra las rutas, que es lo que hay que pegar. Pregunta si git **los ignora**, no
|
|
72
|
+
si la línea está escrita: si ya los cubrís con una regla propia, no te molesta. Sin repositorio se calla.
|
|
73
|
+
|
|
74
|
+
**Si tu instancia ya existía, pegá estas tres líneas en tu `.gitignore`** — o dejá que `check` te diga
|
|
75
|
+
cuáles te faltan:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
planning/.verify-log
|
|
79
|
+
planning/.push-log
|
|
80
|
+
planning/.grant-log
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Cambiado
|
|
84
|
+
|
|
85
|
+
- **La guía dejó de pedirle al agente que borre una autorización que no escribió él.** `AGENTS.md` decía que
|
|
86
|
+
«borrarla es parte de terminar» sin distinguir la línea que el agente pidió para una operación de la que
|
|
87
|
+
dejaste puesta vos a propósito. Ahora sólo se borra la primera.
|
|
88
|
+
|
|
89
|
+
## [0.85.0] - 2026-09-12
|
|
90
|
+
|
|
91
|
+
### Corregido
|
|
92
|
+
|
|
93
|
+
- **Gobernanza ya no te interroga cuando sos vos quien pide el trabajo.** El guard que frena un commit que
|
|
94
|
+
toca reglas, ADR, `automatization/`, `engine/` o contratos de cargo te pedía **nombrar cada archivo** en
|
|
95
|
+
tu mensaje, o contestar «dale», o pegar las rutas en `planning/.ops-approval` — y te lo cobraba en cada
|
|
96
|
+
commit. Ahora, con una persona conduciendo el turno, no pregunta nada.
|
|
97
|
+
|
|
98
|
+
Lo que **no** cambia, y es el punto del guard: sigue frenando igual cuando el que commitea es un
|
|
99
|
+
subagente, un recorrido de Cauce o CI. Ahí nadie está conduciendo, que es exactamente para lo que existe.
|
|
100
|
+
|
|
101
|
+
Sus dos vecinos —`verify` y `dependencies`— **siguen preguntando**, y la diferencia no es quién pidió el
|
|
102
|
+
commit: esos frenan porque algo está mal —una verificación que falla, un manifiesto sin su lockfile— y
|
|
103
|
+
callarlos porque hay alguien hablando sería taparte un rojo. `OPS_GOVERNANCE_OVERRIDE` sigue existiendo
|
|
104
|
+
para el caso sin persona.
|
|
105
|
+
|
|
106
|
+
**Qué cambia para vos**: nada que hacer. Si venías aprobando commits de gobernanza uno por uno, o pegando
|
|
107
|
+
rutas a mano para poder trabajar, eso se terminó.
|
|
108
|
+
|
|
17
109
|
## [0.84.0] - 2026-09-12
|
|
18
110
|
|
|
19
111
|
### Corregido
|
package/engine/cli/planning.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
package/engine/core/evidence.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const fs = require('node:fs')
|
|
21
21
|
const path = require('node:path')
|
|
22
22
|
|
|
23
|
-
const 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
|
package/engine/core/ownership.js
CHANGED
|
@@ -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.
|
|
356
|
-
//
|
|
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
|
package/engine/core/repos.js
CHANGED
|
@@ -119,4 +119,32 @@ function coverageWarnings(opsRoot, done) {
|
|
|
119
119
|
return warnings
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
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 }
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
|
|
18
|
+
// Cuáles de esos rastros **git no está ignorando** en esta instancia.
|
|
19
|
+
//
|
|
20
|
+
// La línea que los cubre la trae `template/gitignore`, y ese archivo se escribe al **crear** la
|
|
21
|
+
// instancia: `upgrade` no lo toca, porque es de la empresa y puede llevar líneas propias que un
|
|
22
|
+
// reemplazo se llevaría puestas. Así que una instancia anterior a cada rastro nuevo se queda sin su
|
|
23
|
+
// línea para siempre, y el archivo aparece en `git status` listo para commitearse por descuido.
|
|
24
|
+
//
|
|
25
|
+
// Se pregunta por el **efecto** y no por el texto del molde, y la diferencia importa en los dos
|
|
26
|
+
// sentidos: la empresa puede cubrirlo con una regla propia —y comparar líneas daría un falso positivo—,
|
|
27
|
+
// y en sidecar el `.gitignore` vive en la instancia mientras el repositorio es el workspace de arriba.
|
|
28
|
+
// Medido en las dos topologías: `check-ignore` contesta igual.
|
|
29
|
+
//
|
|
30
|
+
// Sin repositorio no hay a quién preguntarle —`check-ignore` sale 128— y ahí se calla: un aviso sobre
|
|
31
|
+
// lo que git haría en un repositorio que no existe sería inventado. Es la misma degradación que el 086
|
|
32
|
+
// eligió para las migraciones y el 121 para las filas resueltas: antes callar de más que avisar de más.
|
|
33
|
+
function unignored(root) {
|
|
34
|
+
const asked = spawnSync('git', ['check-ignore', '--', ...LOCAL], { cwd: root, encoding: 'utf8' })
|
|
35
|
+
// 0 = ignoró alguno, 1 = ninguno de los que preguntó; cualquier otro código es que no hay repositorio
|
|
36
|
+
// o que git no pudo contestar, y entonces no hay nada que reportar.
|
|
37
|
+
if (asked.status !== 0 && asked.status !== 1) return []
|
|
38
|
+
const covered = new Set(asked.stdout.split('\n').map((one) => one.trim()).filter(Boolean))
|
|
39
|
+
return LOCAL.filter((one) => !covered.has(one))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// El aviso, en la forma que `check` publica el resto: cuenta, nombra y no falla. Lleva las rutas porque
|
|
43
|
+
// son exactamente lo que hay que pegar, que es lo único accionable — el archivo es de la empresa y Cauce
|
|
44
|
+
// no lo edita.
|
|
45
|
+
function warnings(root) {
|
|
46
|
+
const missing = unignored(root)
|
|
47
|
+
if (!missing.length) return []
|
|
48
|
+
return [`${missing.length} rastro(s) local(es) que git no ignora (${missing.join(', ')}); `
|
|
49
|
+
+ 'agregá esas líneas a tu .gitignore o van a entrar al repositorio']
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { VERIFY, PUSH, GRANT, warnings }
|
package/engine/hooks/approval.js
CHANGED
|
@@ -121,4 +121,26 @@ function HOW(variable, lines, input, pasteable = lines) {
|
|
|
121
121
|
return ask + paste + unresolved + off
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
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 }
|
package/engine/hooks/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
249
|
+
function grant(input, saved, entries) {
|
|
189
250
|
const before = saved.granted || []
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
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 =
|
|
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.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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 }
|
package/engine/hooks/push.js
CHANGED
|
@@ -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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
package/engine/hooks/shell.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
|
+
const CHAT = require('./chat')
|
|
16
17
|
const { publish } = require('./push')
|
|
17
18
|
const { selfApprovalShell } = require('./self-approval')
|
|
18
19
|
const EV = require('../core/evidence')
|
|
@@ -346,6 +347,16 @@ function governance(input) {
|
|
|
346
347
|
if (process.env.OPS_GOVERNANCE_OVERRIDE === '1') return
|
|
347
348
|
const command = commandOf(input)
|
|
348
349
|
if (!isCommit(command)) return
|
|
350
|
+
// Con una persona conduciendo el turno, este guard no pregunta nada. Frena por **política** —qué archivos
|
|
351
|
+
// toca un commit— y no por un defecto de hecho, y esa pregunta a quien está dando instrucciones no le
|
|
352
|
+
// corresponde: lo que el guard contiene es al agente decidiendo solo (caso 126). `said` ya distingue las
|
|
353
|
+
// dos cosas —devuelve nada para un subagente, para un recorrido de Cauce y en CI—, así que la exención no
|
|
354
|
+
// alcanza a nada de eso. Es la misma forma que usa `plan-first` en `files.js`.
|
|
355
|
+
//
|
|
356
|
+
// Sus dos vecinos de gate no llevan esta exención y la diferencia no es quién pidió el commit: `verify` y
|
|
357
|
+
// `dependencies` frenan por algo que está mal —una verificación que falla, un manifiesto sin su lockfile—
|
|
358
|
+
// y callarlos porque hay alguien hablando sería tapar un rojo.
|
|
359
|
+
if (CHAT.said(input)) return
|
|
349
360
|
// El contrato de un cargo y lo que lo mide son gobernanza, igual que un ADR o una regla. La firma de
|
|
350
361
|
// «Aprobación humana» sólo estaba protegida por una frase en un prompt; `SKILL.md` y `references/`
|
|
351
362
|
// son lo que la propuesta cambia, y editarlos directo saltea el ciclo entero; y `evaluations/` es el
|
|
@@ -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.
|
|
3
|
+
"version": "0.86.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
|
-
"
|
|
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": {
|
package/template/AGENTS.md
CHANGED
|
@@ -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
|
|
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.
|
package/template/gitignore
CHANGED
|
@@ -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/`.
|