@ingeniomaps/cauce 0.82.0 → 0.83.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 +120 -0
- package/automatization/hooks/README.md +8 -4
- package/automatization/hooks/guard-ops-config-shell.sh +3 -0
- package/automatization/hooks/guard-ops-config.sh +3 -0
- package/automatization/shared/inbox.js +23 -4
- package/automatization/workflows/autobuild.js +6 -2
- package/automatization/workflows/flow.js +17 -5
- package/automatization/workflows/onboard.js +10 -3
- package/engine/hooks/approval.js +48 -14
- package/engine/hooks/chat.js +71 -9
- package/engine/hooks/ops-config.js +110 -0
- package/engine/hooks/push.js +50 -3
- package/engine/hooks/run.js +17 -2
- package/engine/hooks/secrets-shell.js +13 -2
- package/engine/hooks/self-approval.js +93 -13
- package/engine/hooks/shell.js +6 -6
- package/package.json +1 -1
- package/template/AGENTS.md +3 -1
- package/template/gitignore +5 -0
- package/template/planning/delivery/teamwork.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,126 @@ 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.83.0] - 2026-09-11
|
|
18
|
+
|
|
19
|
+
### Agregado
|
|
20
|
+
|
|
21
|
+
- **Autorizar en el chat ahora se dice como se dice.** Un guard dejaba pasar lo que pedías con un verbo de
|
|
22
|
+
acción —«leé el .env», «usá esa ruta»— y se seguía frenando si lo decías autorizando: «autorizo la
|
|
23
|
+
lectura del .env» no traía ninguna palabra que Cauce reconociera, así que había que repetirlo con otras
|
|
24
|
+
palabras, contestar «dale», o escribir la línea a mano. Entran las formas con que una persona concede, en
|
|
25
|
+
español y en inglés: `autorizo`, `autorizá`, `te autorizo`, `permito`, `apruebo`, `habilito`,
|
|
26
|
+
`authorize`, `allow`, `grant`, `approved`.
|
|
27
|
+
|
|
28
|
+
Lo que **no** cambia es la línea de siempre: nombrar algo no lo autoriza. Los sustantivos que nombran el
|
|
29
|
+
acto —«la lectura del .env», «¿hace falta autorizacion?»— siguen sin autorizar nada, porque aparecen
|
|
30
|
+
igual en una pregunta que no pide nada. Y negar sigue negando, también con las palabras nuevas.
|
|
31
|
+
|
|
32
|
+
**Qué cambia para vos**: si autorizás algo diciendo que lo autorizás, pasa a la primera. Antes eso
|
|
33
|
+
costaba una vuelta, y a veces terminaba en la variable que apaga el guard para toda la sesión.
|
|
34
|
+
|
|
35
|
+
- **El permiso de push ya no se lo puede escribir el agente.** `runner.allowPush` y
|
|
36
|
+
`runner.pushToLiveBranches` deciden qué push se publica y viven en `ops.config.json`, que hasta ahora
|
|
37
|
+
ningún guard miraba: el bloqueo que dice «esto lo decide una persona» se levantaba editando ese mismo
|
|
38
|
+
archivo, con la herramienta de escritura o con un `sed -i`. Lo cierran dos guards nuevos: `ops-config`
|
|
39
|
+
compara lo que se va a escribir contra lo que hay en disco y frena si cambia alguna de esas dos llaves,
|
|
40
|
+
y `ops-config-shell` frena toda escritura por shell sobre el archivo, porque un comando no dice con qué
|
|
41
|
+
va a quedar.
|
|
42
|
+
|
|
43
|
+
**Qué cambia para vos**: el resto de `ops.config.json` —`workspaceRoots`, `writableOutsideRoots`,
|
|
44
|
+
`migrations`, lo que sea— se sigue editando igual que siempre; sólo esas dos llaves pasan a necesitar una
|
|
45
|
+
persona, que las pone editando el archivo ella o pidiéndoselo al agente en el chat nombrando
|
|
46
|
+
`ops.config.json`. Por shell el archivo queda cerrado entero: ese cambio va por la herramienta de
|
|
47
|
+
escritura y con el archivo completo, que es lo único que el guard puede comparar. Si tu runner registra
|
|
48
|
+
los guards de a uno en vez de por grupo, corré `automation install` para que aparezcan
|
|
49
|
+
`guard-ops-config.sh` y `guard-ops-config-shell.sh`.
|
|
50
|
+
|
|
51
|
+
- **Un push aprobado deja constancia en `planning/.push-log`.** Cuando Cauce deja pasar un `git push` por
|
|
52
|
+
una aprobación —la orden en el chat, un «dale», o la línea exacta en `planning/.ops-approval`— anexa una
|
|
53
|
+
línea con la fecha en que se autorizó, el remoto, la rama, por qué vía y la sesión. Por
|
|
54
|
+
`runner.allowPush` no anota nada: esa autorización ya está escrita en `ops.config.json`.
|
|
55
|
+
|
|
56
|
+
El archivo sólo agrega, no guarda el texto de lo que escribiste, y si no se puede escribir no frena el
|
|
57
|
+
push. La fecha se llama `authorizedAt` y no `at` a propósito: el hook corre antes del comando, así que la
|
|
58
|
+
línea dice que el push **se autorizó**, no que se haya publicado.
|
|
59
|
+
|
|
60
|
+
El anillo «Local» de `planning/delivery/teamwork.md` lo nombra junto al resto de lo que no viaja por git.
|
|
61
|
+
Ese archivo lo mantiene Cauce, así que `upgrade` te lo reemplaza: si lo editaste, mirá el diff antes.
|
|
62
|
+
|
|
63
|
+
**Qué cambia para vos**: si necesitás responder quién autorizó un push y cuándo, ahora hay de dónde
|
|
64
|
+
sacarlo, en la máquina donde corrió la sesión —el rastro dice la sesión y la vía, no el nombre de la
|
|
65
|
+
persona: Cauce no tiene identidad de usuario—. Las instancias nuevas lo traen gitignoreado; si ya tenés
|
|
66
|
+
una, agregá `planning/.push-log` a tu `.gitignore`.
|
|
67
|
+
|
|
68
|
+
- **Lo que un recorrido deja en el INBOX dice ahora de qué vía entró.** Cada línea que `autobuild`, `flow` y
|
|
69
|
+
`onboard` le pasan al agente que escribe viene con su procedencia al final —`(autobuild · t-012 ·
|
|
70
|
+
2026-09-11)`, `(flow · planning/reports/2026-09-11-alta.md · 2026-09-11)`, `(onboard · 2026-09-11)`—: el
|
|
71
|
+
recorrido, la unidad de la que salió y la fecha. La arma el recorrido y el pedido dice que se copie tal
|
|
72
|
+
cual, en vez de confiar en que el agente se acuerde de escribirla. El formato de una entrada no cambió ni
|
|
73
|
+
el molde tampoco, así que `ops tree`, `check` y `context --json` siguen leyendo exactamente lo mismo.
|
|
74
|
+
|
|
75
|
+
**Qué cambia para vos**: nada que hacer. Al recorrer el INBOX vas a ver el origen en lo que se escriba de
|
|
76
|
+
acá en adelante; lo que ya tenés escrito no lo gana, y una entrada que escribas a mano sigue sin él,
|
|
77
|
+
porque el INBOX es tuyo. Si en algún lado recortás el ancho de esas líneas, contá entre 20 y 60
|
|
78
|
+
caracteres más por entrada: el tope de 240 pasó a medir la línea entera, así que lo que cede es el texto
|
|
79
|
+
del hallazgo y nunca el origen.
|
|
80
|
+
|
|
81
|
+
### Cambiado
|
|
82
|
+
|
|
83
|
+
- **Lo que autorizás en el chat no alcanza a los gates de un commit.** Lo que un guard deja pasar porque
|
|
84
|
+
vos lo pediste sigue valiendo mientras dure la sesión, con dos excepciones: publicar, y los gates de un
|
|
85
|
+
commit —`governance`, `verify` y `dependencies`—, que preguntan cada vez. Lo que autoriza un commit no
|
|
86
|
+
dice nada del siguiente, porque el índice ya es otro. Por el mismo motivo, nombrar `ops.config.json` una
|
|
87
|
+
vez ya no deja escribir `runner.allowPush` ni `runner.pushToLiveBranches` por el resto de la sesión: ahí
|
|
88
|
+
también se pregunta cada vez.
|
|
89
|
+
|
|
90
|
+
**Qué cambia para vos**: si commiteás varias veces seguidas tocando gobernanza, con un gate en rojo o con
|
|
91
|
+
un manifiesto sin su lockfile, vas a decir «dale» en cada commit en vez de una vez por sesión. Lo que no
|
|
92
|
+
es un gate de commit —leer un `.env`, reescribir una migración, borrar una prueba— sigue valiendo toda la
|
|
93
|
+
sesión.
|
|
94
|
+
|
|
95
|
+
### Corregido
|
|
96
|
+
|
|
97
|
+
- **Un bloqueo ya no te ofrece pegar una ruta que el shell no expandió.** Cuando el comando nombra la
|
|
98
|
+
credencial a través de una variable —`cat ops/$O/.env.infisical`—, Cauce no puede saber qué archivo es:
|
|
99
|
+
esa variable la expande tu shell, que es otro proceso. Antes el bloqueo ofrecía igual esa línea para
|
|
100
|
+
`planning/.ops-approval`, y pegarla no servía: sólo volvía a valer si el comando se repetía escrito
|
|
101
|
+
igual, y dejaba de valer apenas alguien escribía la ruta de verdad. Con la forma `${O}` era peor, porque
|
|
102
|
+
la línea ofrecida ni siquiera era el archivo que el comando lee. Ahora no la ofrece, dice por qué y qué
|
|
103
|
+
hacer.
|
|
104
|
+
|
|
105
|
+
**Qué cambia para vos**: lo que un bloqueo te ofrece pegar es siempre una línea que después funciona. Si
|
|
106
|
+
armás la ruta con variables, escribila entera en ese comando —o contestá «dale», que destraba igual—.
|
|
107
|
+
|
|
108
|
+
- **Lo que autorizás en el chat ya no deja de valer con el mensaje siguiente.** Hasta 0.82.0 el permiso
|
|
109
|
+
duraba un mensaje: pedías leer el `.env`, el agente lo leía, y apenas escribías cualquier otra cosa
|
|
110
|
+
—«gracias, seguí»— el mismo archivo volvía a frenarse. Ahora lo que un guard deja pasar porque vos lo
|
|
111
|
+
pediste queda anotado y sigue valiendo mientras la sesión siga. Lo revoca decirlo: «no toques el .env» lo
|
|
112
|
+
saca, y lo revocado no vuelve solo.
|
|
113
|
+
|
|
114
|
+
El permiso sigue siendo angosto: vale para el ítem tal como el guard lo nombra, no cruza de sesión y no
|
|
115
|
+
alcanza a un subagente ni a un recorrido de Cauce. Y **no alcanza a lo que vuelve a tener consecuencia
|
|
116
|
+
cada vez**: publicar y los gates de un commit, que preguntan siempre —ver la entrada de abajo—. Volver a
|
|
117
|
+
leer lo que ya se leyó no agrega consecuencia; volver a publicar, o commitear otra vez con otro índice,
|
|
118
|
+
sí.
|
|
119
|
+
|
|
120
|
+
**Qué cambia para vos**: si trabajás con credenciales o con rutas que los guards vigilan, alcanza con
|
|
121
|
+
pedirlo una vez por sesión en lugar de repetir la frase o contestar «dale» a cada rato. Si querés cortar
|
|
122
|
+
un permiso antes de cerrar la sesión, decilo nombrando el archivo.
|
|
123
|
+
|
|
124
|
+
- **El agente no puede escribir en tu aprobación una línea que vos no pediste.** `planning/.ops-approval`
|
|
125
|
+
sólo lo podía escribir el agente si vos nombrabas el archivo en el chat, y ahí se terminaba el control:
|
|
126
|
+
pedías agregar `src/login.js` y podía escribir `push origin main`, que es la línea que autoriza publicar
|
|
127
|
+
en la rama viva. Ahora lo que se escribe se compara línea por línea con lo que pediste: pasan las que
|
|
128
|
+
nombraste, y cualquier otra frena la escritura mostrándote cuál era. Las que ya estaban en el archivo no
|
|
129
|
+
hay que volver a nombrarlas, y quitar líneas no pide permiso. Por shell —un `echo >>`, un `sed -i`— el
|
|
130
|
+
archivo queda cerrado: un comando no dice con qué va a quedar, así que no hay contenido que comparar.
|
|
131
|
+
|
|
132
|
+
**Qué cambia para vos**: si le pedís al agente que te agregue rutas, nombralas en el mismo mensaje
|
|
133
|
+
—«agregá `src/login.js` y `src/altas.js` a `.ops-approval`»—; si frena, te dice exactamente qué línea
|
|
134
|
+
quería escribir y un «dale» aprueba esa y nada más. Vos seguís editando el archivo a mano como siempre,
|
|
135
|
+
que ningún guard mira. Esto venía desde 0.82.0, así que si actualizás desde ahí, cerrás de paso esa vía.
|
|
136
|
+
|
|
17
137
|
## [0.82.0] - 2026-09-11
|
|
18
138
|
|
|
19
139
|
### Cambiado
|
|
@@ -76,14 +76,18 @@ guards lo leen:
|
|
|
76
76
|
`.env`» no, y tampoco una pregunta o un comentario que sólo lo nombra —«¿qué tiene el `.env`?»—;
|
|
77
77
|
- lo que se frenó sin que lo nombrara queda anotado, y un «dale» en el mensaje siguiente aprueba
|
|
78
78
|
exactamente eso;
|
|
79
|
+
- lo que un guard dejó pasar porque ella lo pidió sigue valiendo mientras dure la sesión, **salvo en los
|
|
80
|
+
gates de un commit** —`governance`, `verify` y `dependencies`—, que preguntan cada vez: ahí vale lo que
|
|
81
|
+
pidió el mensaje en curso o un «dale», igual que para publicar;
|
|
79
82
|
- `plan-first` no aplica: el plan es del trabajo que va por tareas.
|
|
80
83
|
|
|
81
84
|
No cuenta cuando no hay persona —CI, o un aviso del runner como el de un subagente que terminó—, cuando lo
|
|
82
85
|
que pidió es un recorrido de Cauce (`/autobuild`, `$flow`…), ni en la llamada de un subagente, que Claude
|
|
83
86
|
marca con `agent_id`. En Claude y Codex cada llamada trae el identificador del mensaje que la originó;
|
|
84
87
|
Gemini no lo manda, y ahí vale el último mensaje. El registro vive en el temporal del sistema, uno por
|
|
85
|
-
sesión, y los guards de límites lo cuidan junto con `planning/.ops-approval`: el
|
|
86
|
-
|
|
88
|
+
sesión, y los guards de límites lo cuidan junto con `planning/.ops-approval`: el registro no lo escribe
|
|
89
|
+
nunca una herramienta, y en la aprobación sólo entran las líneas que la persona nombró en ese mismo
|
|
90
|
+
mensaje —por shell, ninguna: un comando no dice con qué va a quedar el archivo—. Como todo lo de esta página, frena la forma habitual y no un script decidido.
|
|
87
91
|
|
|
88
92
|
## Cómo se ejecutan
|
|
89
93
|
|
|
@@ -108,8 +112,8 @@ runner, mientras la lógica se prueba y mantiene una sola vez en `engine/hooks/r
|
|
|
108
112
|
|
|
109
113
|
| Grupo | Guards | Wrapper |
|
|
110
114
|
|---|---|---|
|
|
111
|
-
| `pre-shell` | destructive, git-add, dependencies, governance, verify, shell-boundary | `guard-shell.sh` |
|
|
112
|
-
| `pre-files` | secrets, generated, workspace-boundary, engine, migrations, integration-snapshot, test-evidence, plan-first | `guard-files.sh` |
|
|
115
|
+
| `pre-shell` | destructive, git-add, dependencies, governance, verify, shell-boundary, secrets-shell, ops-config-shell | `guard-shell.sh` |
|
|
116
|
+
| `pre-files` | secrets, generated, workspace-boundary, engine, migrations, integration-snapshot, test-evidence, plan-first, ops-config | `guard-files.sh` |
|
|
113
117
|
| `pre-read` | secrets-read | `guard-secrets-read.sh` |
|
|
114
118
|
| `prompt` | chat | `guard-chat.sh` |
|
|
115
119
|
| `stop` | planning-drift | `guard-planning-drift.sh` |
|
|
@@ -9,20 +9,39 @@ const INBOX_ENTRY = '- **slug-del-item** — Qué es, y qué se decide o se resu
|
|
|
9
9
|
const INBOX_HEADS = { type: 'object', additionalProperties: false, properties: Object.fromEntries(
|
|
10
10
|
['deuda', 'ideas', 'propuestas', 'lecciones'].map((key) => [key, { type: 'array', items: { type: 'string' } }]),
|
|
11
11
|
) }
|
|
12
|
+
// Lo que entra en una entrada, procedencia incluida. El tope es del conjunto y no del detalle: así un
|
|
13
|
+
// sufijo largo recorta lo que se cuenta y nunca al revés. Se recorta el detalle porque es lo único
|
|
14
|
+
// recuperable —sigue entero en el informe o en `done/`—, y la procedencia no se reconstruye después.
|
|
15
|
+
const INBOX_LINE = 240
|
|
12
16
|
// Una entrada es una línea. Un hallazgo de largo libre se recorta antes de llegar a quien lo escribe,
|
|
13
17
|
// porque lo que llega entero es lo que termina copiado entero.
|
|
14
|
-
const oneLine = (text) => {
|
|
18
|
+
const oneLine = (text, reserved = 0) => {
|
|
15
19
|
const first = String(text || '').split('\n')[0].trim()
|
|
16
|
-
|
|
20
|
+
const cap = INBOX_LINE - reserved
|
|
21
|
+
return first.length > cap ? `${first.slice(0, cap - 1)}…` : first
|
|
17
22
|
}
|
|
23
|
+
// De qué vía salió una entrada: el recorrido, la unidad de la que salió —una tarea, un informe, un
|
|
24
|
+
// equipo— y la fecha. La arma el recorrido, que es el que las sabe, en vez de pedírselas al agente que
|
|
25
|
+
// escribe: una convención que depende de que alguien se acuerde no deja rastro cuando no se cumple, y
|
|
26
|
+
// una entrada sin remitente se lee igual que una que nunca lo tuvo (caso 115).
|
|
27
|
+
//
|
|
28
|
+
// Las partes vacías se caen: `onboard` no tiene ni tarea ni informe, y la fecha la da el motor —acá no
|
|
29
|
+
// hay reloj—, así que falta si el comando que la trae no contestó. Va entre paréntesis, al final y en
|
|
30
|
+
// minúscula, porque compite con lo que la entrada dice; y nombra al recorrido y nunca a un cargo, para
|
|
31
|
+
// que no se lea como una firma con la que decidir sin leer la entrada.
|
|
32
|
+
const inboxOrigin = (...parts) => `(${parts.filter(Boolean).join(' · ')})`
|
|
33
|
+
// La entrada ya armada, para que quien escribe la copie en vez de redactarla.
|
|
34
|
+
const withOrigin = (detail, origin) => `${oneLine(detail, origin.length + 1)} ${origin}`
|
|
18
35
|
// La forma y los nombres que ya hay en las secciones donde se va a escribir. Van los nombres y no el
|
|
19
36
|
// archivo: con ellos alcanza para no repetir una entrada, y el archivo entero pesaría lo que el INBOX.
|
|
20
|
-
function inboxAsk(sections, heads) {
|
|
37
|
+
function inboxAsk(sections, heads, origin) {
|
|
21
38
|
const known = sections.map((name) => {
|
|
22
39
|
const names = (heads || {})[name.toLowerCase()] || []
|
|
23
40
|
return names.length ? `en ${name} ya están ${names.join(', ')}` : `${name} no tiene entradas`
|
|
24
41
|
}).join('; ')
|
|
25
42
|
return `Cada entrada con la forma del molde —${INBOX_ENTRY}—: un nombre y una línea, y la evidencia se ` +
|
|
26
|
-
`cita donde ya vive, no se copia.
|
|
43
|
+
`cita donde ya vive, no se copia. Cada línea que te paso termina con su procedencia —${origin}—: va al ` +
|
|
44
|
+
`final de la entrada tal cual, sin reescribirla, resumirla ni completarla. ` +
|
|
45
|
+
`Por nombre, ${known}: lo que ya esté con uno de esos nombres no se ` +
|
|
27
46
|
`vuelve a escribir.`
|
|
28
47
|
}
|
|
@@ -796,7 +796,10 @@ while (rounds++ < MAX_TASKS) {
|
|
|
796
796
|
// Lecciones porque lo que la revisión anotó es un cambio del producto —su evidencia es la de la
|
|
797
797
|
// tarea, que queda en `done/`—; Lecciones es sobre cómo trabajamos, y ahí el hallazgo queda
|
|
798
798
|
// esperando una promoción que nadie va a hacer.
|
|
799
|
-
|
|
799
|
+
// De qué vía salió cada una: este recorrido, la tarea que la dejó anotada y la fecha del motor. La
|
|
800
|
+
// arma el recorrido, que es el único de los dos que sabe las tres cosas (caso 115).
|
|
801
|
+
const origin = inboxOrigin('autobuild', task.id, planning.today)
|
|
802
|
+
const noted = review.concerns.filter((one) => !one.blocking).map((one) => withOrigin(one.detail, origin))
|
|
800
803
|
const kept = noted.slice(0, INBOX_CAP)
|
|
801
804
|
// Lo que pasa del tope no se escribe y tampoco desaparece: queda contado en el hecho de revisión, que
|
|
802
805
|
// viaja a `done/`. Una revisión que anota treinta y seis cosas no está priorizando, y el INBOX no las
|
|
@@ -806,7 +809,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
806
809
|
}
|
|
807
810
|
if (kept.length) {
|
|
808
811
|
await write(`Registrá en la sección Propuestas de ${P}/INBOX.md lo que la revisión de ${task.id} dejó ` +
|
|
809
|
-
`anotado sin frenar la entrega, sin promover ninguna.
|
|
812
|
+
`anotado sin frenar la entrega, sin promover ninguna. ` +
|
|
813
|
+
`${inboxAsk(['Propuestas'], planning.inbox, origin)} ` +
|
|
810
814
|
`Lo anotado: ${JSON.stringify(kept)}`, { label: 'review-noted' })
|
|
811
815
|
}
|
|
812
816
|
}
|
|
@@ -61,6 +61,8 @@ const MANIFEST = {
|
|
|
61
61
|
conditionalAgents: { type: 'array', items: { type: 'string' } },
|
|
62
62
|
// Con los nombres que ya hay en el INBOX, lo que el recorrido escribe al final no repite uno.
|
|
63
63
|
inbox: INBOX_HEADS,
|
|
64
|
+
// La fecha, del mismo comando que trae los nombres (caso 115).
|
|
65
|
+
today: { type: 'string' },
|
|
64
66
|
owners: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
65
67
|
domain: { type: 'string' }, agent: { type: 'string' },
|
|
66
68
|
} } },
|
|
@@ -164,7 +166,8 @@ const contract = await agent(
|
|
|
164
166
|
`only ran on failure the destination came out of memory.\n` +
|
|
165
167
|
` If command 1 failed, set exists=false, report flows, and stop.\n` +
|
|
166
168
|
`3. "node tools/ops.js agents list --json", which gives each role its resolved path.\n` +
|
|
167
|
-
`4. "node tools/ops.js context planning --json" — copy
|
|
169
|
+
`4. "node tools/ops.js context planning --json" — copy its inbox field into inbox and its today field ` +
|
|
170
|
+
`into today, both verbatim, and nothing else it printed.\n` +
|
|
168
171
|
`Report exists=true and these manifest fields: name, purpose, outcome, entryAgent, facilitator, ` +
|
|
169
172
|
`guardrails, decisionOwners flattened into owners as domain/agent pairs, and stages with id, phase, ` +
|
|
170
173
|
`agent, produces, dependsOn and exitGate. Drop every other field the command printed — the schema ` +
|
|
@@ -201,6 +204,11 @@ const RULES = `${BASE}\n\nRecorrido ${contract.name}: ${contract.purpose}\n` +
|
|
|
201
204
|
+ `destino, sale de esa lista; si ninguno sirve, decilo con su razón en vez de inventar uno.\n` : ''}` +
|
|
202
205
|
`Contexto de la empresa en ${WORKDIR}/organization/. Intención a evaluar: ${GOAL}`
|
|
203
206
|
|
|
207
|
+
// De qué vía sale lo que este recorrido escriba en el INBOX: el recorrido, el equipo que lo corrió y la
|
|
208
|
+
// fecha que dio el motor. En modo informe la unidad es el informe y esa rama arma la suya, porque es
|
|
209
|
+
// donde queda la evidencia de lo que se anotó.
|
|
210
|
+
const ORIGIN = inboxOrigin('flow', FLOW, contract.today)
|
|
211
|
+
|
|
204
212
|
phase('Stages')
|
|
205
213
|
|
|
206
214
|
const handoffs = []
|
|
@@ -419,11 +427,13 @@ if (contract.outcome === 'report') {
|
|
|
419
427
|
// El tope lo aplica el recorrido y no quien escribe, y lo que pasa de él ya está en el informe: al
|
|
420
428
|
// INBOX va lo que alguien tiene que decidir, no todo lo que el informe dejó abierto (caso 101).
|
|
421
429
|
const followUps = report.followUps || []
|
|
422
|
-
const
|
|
430
|
+
const reportOrigin = inboxOrigin('flow', report.file, contract.today)
|
|
431
|
+
const listed = followUps.slice(0, INBOX_CAP)
|
|
432
|
+
.map((one) => ({ section: one.section, entry: withOrigin(one.entry, reportOrigin) }))
|
|
423
433
|
if (listed.length) {
|
|
424
434
|
await agent(
|
|
425
435
|
`${RULES}\n\nRegistrá en ${INBOX} estos seguimientos del informe ${report.file}, cada uno en su ` +
|
|
426
|
-
`sección y sin promover ninguno. ${inboxAsk(['Propuestas', 'Lecciones'], contract.inbox)} ` +
|
|
436
|
+
`sección y sin promover ninguno. ${inboxAsk(['Propuestas', 'Lecciones'], contract.inbox, reportOrigin)} ` +
|
|
427
437
|
`Seguimientos: ${JSON.stringify(listed)}`,
|
|
428
438
|
{ label: 'report-inbox' },
|
|
429
439
|
)
|
|
@@ -456,7 +466,8 @@ if (!epic) return stop('draft-unavailable', 'la propuesta de épica no devolvió
|
|
|
456
466
|
if (epic.outcome === 'no-hacer') {
|
|
457
467
|
await agent(
|
|
458
468
|
`${RULES}\n\nRegistrá la conclusión en la sección Lecciones de ${INBOX}: por qué esta intención no ` +
|
|
459
|
-
`es viable hoy y qué la haría viable. ${inboxAsk(['Lecciones'], contract.inbox)}
|
|
469
|
+
`es viable hoy y qué la haría viable. ${inboxAsk(['Lecciones'], contract.inbox, ORIGIN)} ` +
|
|
470
|
+
`Motivo: ${withOrigin(epic.reason, ORIGIN)}`,
|
|
460
471
|
{ label: 'inbox-lesson' },
|
|
461
472
|
)
|
|
462
473
|
return stop('no-viable', epic.reason)
|
|
@@ -467,7 +478,8 @@ if (epic.outcome === 'investigar') {
|
|
|
467
478
|
await agent(
|
|
468
479
|
`${RULES}\n\nRegistrá en ${HUMAN} qué hay que averiguar antes de poder decidir esta intención y quién ` +
|
|
469
480
|
`puede hacerlo, sin inventar responsables ni fechas, y dejá la conclusión en la sección Ideas de ` +
|
|
470
|
-
`${INBOX} sin promoverla. ${inboxAsk(['Ideas'], contract.inbox)}
|
|
481
|
+
`${INBOX} sin promoverla. ${inboxAsk(['Ideas'], contract.inbox, ORIGIN)} ` +
|
|
482
|
+
`Qué falta averiguar: ${withOrigin(epic.reason, ORIGIN)}`,
|
|
471
483
|
{ label: 'investigar' },
|
|
472
484
|
)
|
|
473
485
|
return finish({ flow: FLOW, stages: handoffs.length, investigate: epic.reason, promoted: false })
|
|
@@ -89,6 +89,8 @@ const SCAN = {
|
|
|
89
89
|
secrets: { type: 'array', items: { type: 'string' } },
|
|
90
90
|
// Los nombres que ya hay en Ideas: con `force` el arranque reescribe una instancia que ya tiene INBOX.
|
|
91
91
|
inboxIdeas: { type: 'array', items: { type: 'string' } },
|
|
92
|
+
// La fecha que lleva la procedencia de lo que se escriba en Ideas (caso 115).
|
|
93
|
+
today: { type: 'string' },
|
|
92
94
|
},
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -114,7 +116,8 @@ const state = await agent(
|
|
|
114
116
|
`dimension, and every service with its path, its runtimes, its declared commands keeping the source ` +
|
|
115
117
|
`file each command came from, and the variable names its "env" carries. Add nothing it did not print.\n` +
|
|
116
118
|
`2. "node tools/ops.js check planning".\n` +
|
|
117
|
-
`3. "node tools/ops.js context planning --json": copy its inbox.ideas into inboxIdeas
|
|
119
|
+
`3. "node tools/ops.js context planning --json": copy its inbox.ideas into inboxIdeas and its today ` +
|
|
120
|
+
`into today, both verbatim.\n` +
|
|
118
121
|
`The inventory already names every credential each service expects: never open a .env file to look for ` +
|
|
119
122
|
`more. Report those names in secrets and the services they point at in externals. A name the inventory ` +
|
|
120
123
|
`carries is declared, and saying otherwise is a claim the repository contradicts.`,
|
|
@@ -196,11 +199,15 @@ phase('Epic')
|
|
|
196
199
|
// Las preguntas abiertas van al INBOX con tope, y el tope lo aplica el recorrido: por eso las escribe
|
|
197
200
|
// este paso con lo que el anterior devolvió, y no el anterior mientras las redactaba (caso 101). Las que
|
|
198
201
|
// no entran se dicen al cerrar, que es donde la persona que arrancó la instancia las lee.
|
|
202
|
+
// De qué vía salieron: este recorrido y la fecha del motor. Un arranque no tiene ni tarea ni informe que
|
|
203
|
+
// nombrar, así que su procedencia son esas dos partes y nada más (caso 115). El log de las que no entran
|
|
204
|
+
// se queda sin ella: quien lo lee está mirando la corrida que las produjo.
|
|
205
|
+
const ORIGIN = inboxOrigin('onboard', state.today)
|
|
199
206
|
const questions = (drafted.openQuestions || []).map(oneLine)
|
|
200
|
-
const inboxed = questions.slice(0, INBOX_CAP)
|
|
207
|
+
const inboxed = questions.slice(0, INBOX_CAP).map((one) => withOrigin(one, ORIGIN))
|
|
201
208
|
const INBOX_ASK = inboxed.length
|
|
202
209
|
? `Registrá además en la sección Ideas de ${INBOX}, sin promover, estas preguntas abiertas: ` +
|
|
203
|
-
`${JSON.stringify(inboxed)}. ${inboxAsk(['Ideas'], { ideas: state.inboxIdeas || [] })}\n\n`
|
|
210
|
+
`${JSON.stringify(inboxed)}. ${inboxAsk(['Ideas'], { ideas: state.inboxIdeas || [] }, ORIGIN)}\n\n`
|
|
204
211
|
: ''
|
|
205
212
|
|
|
206
213
|
const epic = await agent(
|
package/engine/hooks/approval.js
CHANGED
|
@@ -34,18 +34,37 @@ const APPROVAL = '.ops-approval'
|
|
|
34
34
|
// Una ruta por línea, `#` para lo demás. El archivo ausente y el vacío son lo mismo: no hay nada
|
|
35
35
|
// aprobado, que es el estado normal. Un push se aprueba igual, con la línea `push <remoto> <rama>`
|
|
36
36
|
// tal cual y sin patrones: `feat/*` convertiría una aprobación puntual en un permiso (caso 103).
|
|
37
|
+
//
|
|
38
|
+
// El texto se lee aparte del archivo porque `self-approval` compara lo que se está por escribir contra lo
|
|
39
|
+
// que ya está en disco: con dos parsers, una línea podría contar de un lado y no del otro (caso 119).
|
|
40
|
+
const lines = (text) => text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
|
|
41
|
+
|
|
37
42
|
function read(root) {
|
|
38
|
-
|
|
39
|
-
try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
|
|
40
|
-
return text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
|
|
43
|
+
try { return lines(fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8')) } catch { return [] }
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
// Qué queda sin aprobar de lo que un guard está por bloquear. Se reporta sólo eso: mandar a revisar lo
|
|
44
47
|
// que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje. Cuenta también lo que la
|
|
45
48
|
// persona pidió en el chat.
|
|
46
|
-
|
|
49
|
+
const left = (root, files) => {
|
|
47
50
|
const approved = new Set(root ? read(root) : [])
|
|
48
|
-
return
|
|
51
|
+
return files.filter((file) => !approved.has(file))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pending(root, files, input) {
|
|
55
|
+
return CHAT.unauthorized(input, left(root, files))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Lo mismo para los gates de commit —`governance`, `verify` y `dependencies`—, que preguntan cada vez: no
|
|
59
|
+
// conceden nada y no heredan lo que la sesión venía concediendo.
|
|
60
|
+
//
|
|
61
|
+
// Commitear está del lado de publicar y no del de leer, y por qué esa diferencia decide quién hereda está
|
|
62
|
+
// en `push.js`. Lo propio de un commit es que el objeto del permiso se mueve solo: lo que autoriza uno no
|
|
63
|
+
// dice nada del siguiente, porque el índice ya es otro. Nadie lo había decidido para los gates —heredaban
|
|
64
|
+
// por venir todos de `pending`—, y se midió: con otro mensaje en curso, un commit de gobernanza pasaba
|
|
65
|
+
// (caso 119).
|
|
66
|
+
function pendingNow(root, files, input) {
|
|
67
|
+
return CHAT.unauthorizedNow(input, left(root, files))
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
// El archivo que el guard va a leer, nombrado desde la carpeta en la que está la sesión. En sidecar la
|
|
@@ -70,16 +89,31 @@ function where(input) {
|
|
|
70
89
|
// primero: contestar es más corto que editar un archivo, y es lo que la persona ya está haciendo. El
|
|
71
90
|
// archivo queda como cosa de ella: dicho en imperativo, el agente leía «aprobalo» como una orden para él
|
|
72
91
|
// e intentaba escribírselo en vez de reintentar, medido en una sesión real de Claude Code.
|
|
73
|
-
|
|
92
|
+
//
|
|
93
|
+
// `pasteable` es lo que se puede aprobar por archivo, y por defecto es todo: un guard lo angosta cuando lo
|
|
94
|
+
// que tiene a mano no sirve para pegar —por qué, en `secrets-shell.js` (caso 118)—. Lo frenado se anota
|
|
95
|
+
// igual, así que el «dale» sigue cubriendo todo.
|
|
96
|
+
function HOW(variable, lines, input, pasteable = lines) {
|
|
74
97
|
const chat = CHAT.hold(input, lines)
|
|
75
|
-
|
|
98
|
+
const stuck = lines.filter((one) => !pasteable.includes(one))
|
|
99
|
+
const ask = chat
|
|
76
100
|
? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
|
|
77
|
-
+ 'pasa.
|
|
78
|
-
: '
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
+ 'pasa. '
|
|
102
|
+
: ''
|
|
103
|
+
const paste = pasteable.length
|
|
104
|
+
? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
|
|
105
|
+
+ ` ${where(input)} estas líneas:\n`
|
|
106
|
+
+ pasteable.map((line) => ` ${line}\n`).join('')
|
|
107
|
+
+ 'Valen para ese conjunto y dejan de valer en cuanto cambie. '
|
|
108
|
+
: ''
|
|
109
|
+
const unresolved = stuck.length
|
|
110
|
+
? `Por archivo no hay línea que pegar para ${stuck.join(', ')}: la ruta llegó con una expansión del shell `
|
|
111
|
+
+ 'sin resolver, y la aprobación compara texto, así que esa línea sólo valdría para un comando escrito '
|
|
112
|
+
+ 'igual. Volvé a correrlo con la ruta escrita y el bloqueo va a decir qué pegar. '
|
|
113
|
+
: ''
|
|
114
|
+
return ask + paste + unresolved
|
|
115
|
+
+ `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
|
|
116
|
+
+ 'es la vía recomendada.'
|
|
83
117
|
}
|
|
84
118
|
|
|
85
|
-
module.exports = { APPROVAL, read, pending, where, HOW }
|
|
119
|
+
module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW }
|
package/engine/hooks/chat.js
CHANGED
|
@@ -53,6 +53,12 @@ const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
|
|
|
53
53
|
// archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
|
|
54
54
|
// español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
|
|
55
55
|
// quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
|
|
56
|
+
//
|
|
57
|
+
// Autorizar también es pedir, y era lo que faltaba: «autorizo la lectura del .env» es la forma más
|
|
58
|
+
// explícita de decir que sí y se frenaba igual, porque ninguna de sus palabras estaba (caso 118). Entran
|
|
59
|
+
// las formas con que una persona **concede** —primera persona e imperativo— y no los sustantivos que
|
|
60
|
+
// nombran el acto: `lectura`, `permiso` y `autorizacion` aparecen igual en una pregunta que no autoriza
|
|
61
|
+
// nada, y dejarlos afuera es lo que el 109 decidió cuando dijo que nombrar no alcanza.
|
|
56
62
|
const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
|
|
57
63
|
+ 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
|
|
58
64
|
+ 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
|
|
@@ -60,9 +66,11 @@ const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena ed
|
|
|
60
66
|
+ 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
|
|
61
67
|
+ 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
|
|
62
68
|
+ 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
|
|
69
|
+
+ 'autorizo autoriza autorizar autorizado permito permiti permite permitir apruebo habilito '
|
|
63
70
|
+ 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
|
|
64
71
|
+ 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
|
|
65
|
-
+ 'rename move restore skip
|
|
72
|
+
+ 'rename move restore skip '
|
|
73
|
+
+ 'authorize authorized allow allowed permit permitted grant granted approved').split(' '))
|
|
66
74
|
const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
|
|
67
75
|
|
|
68
76
|
function asks(clause) {
|
|
@@ -120,6 +128,10 @@ const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|
|
|
|
120
128
|
// El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
|
|
121
129
|
// deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
|
|
122
130
|
// que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
|
|
131
|
+
//
|
|
132
|
+
// Lo concedido es lo único que cruza de un mensaje al siguiente, y se hereda aunque este mensaje no lo
|
|
133
|
+
// haya escrito una persona: un aviso del runner en el medio no le quita a nadie lo que ya autorizó. La
|
|
134
|
+
// negación se aplica venga de donde venga, porque revocar es la dirección segura.
|
|
123
135
|
function record(input) {
|
|
124
136
|
try {
|
|
125
137
|
if (!input.session_id) return
|
|
@@ -129,9 +141,10 @@ function record(input) {
|
|
|
129
141
|
const approved = human && previous && YES.test(text)
|
|
130
142
|
? previous.pending.filter((item) => !mentions(text, item).denied)
|
|
131
143
|
: []
|
|
144
|
+
const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
|
|
132
145
|
fs.mkdirSync(DIR, { recursive: true })
|
|
133
|
-
fs.writeFileSync(recordPath(input.session_id),
|
|
134
|
-
|
|
146
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
|
|
147
|
+
{ id: idOf(input), text, human, flow: flowCommand(text), approved, granted, pending: [] }))
|
|
135
148
|
} catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
|
|
136
149
|
}
|
|
137
150
|
|
|
@@ -146,14 +159,63 @@ function said(input) {
|
|
|
146
159
|
return current && saved.id && current !== saved.id ? null : saved
|
|
147
160
|
}
|
|
148
161
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// ordena con su remoto y su rama.
|
|
162
|
+
// Con qué autorización pasa un ítem, o vacío si no pasa: lo pidió este mensaje, un «dale» aprobó lo que
|
|
163
|
+
// había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
|
|
164
|
+
// se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
|
|
152
165
|
const named = (text, item) => mentions(text, item).named
|
|
153
|
-
function
|
|
166
|
+
function why(saved, item, asked, inherit) {
|
|
167
|
+
if (asked(saved.text, item)) return 'orden'
|
|
168
|
+
if (saved.approved.includes(item)) return 'dale'
|
|
169
|
+
if (!inherit) return ''
|
|
170
|
+
return (saved.granted || []).includes(item) ? 'concedido' : ''
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Lo que un guard dejó pasar queda anotado, que es la contracara de `hold`: hasta 0.82.0 sólo se anotaba
|
|
174
|
+
// lo frenado, así que una autorización usada moría con el mensaje y lo mismo se frenaba una y otra vez.
|
|
175
|
+
// Ahí la salida barata era apagar el guard para toda la sesión con una variable, o sea el permiso más
|
|
176
|
+
// ancho de los dos (caso 116).
|
|
177
|
+
//
|
|
178
|
+
// Se anota el ítem **como el guard lo nombró** —la ruta en la forma que ese guard tiene a mano— y no el
|
|
179
|
+
// archivo que hay detrás: es el mismo alcance que tiene una línea de `.ops-approval`, angosto de más
|
|
180
|
+
// antes que de menos.
|
|
181
|
+
function grant(input, saved, items) {
|
|
182
|
+
const before = saved.granted || []
|
|
183
|
+
const granted = [...new Set([...before, ...items])]
|
|
184
|
+
if (granted.length === before.length) return
|
|
185
|
+
try {
|
|
186
|
+
saved.granted = granted
|
|
187
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
|
|
188
|
+
} catch { /* sin anotarlo, se vuelve a pedir */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Con qué autorización pasa cada uno de los que pasan. Lo pregunta quien necesita el porqué y no sólo el
|
|
192
|
+
// qué —el rastro de un push lo anota (caso 112)—, y no concede nada: preguntar no cambia qué va a valer
|
|
193
|
+
// en el mensaje siguiente.
|
|
194
|
+
//
|
|
195
|
+
// Conceder y heredar son dos cosas distintas y hasta 0.83.0 se movían juntas: conceder es escribir en el
|
|
196
|
+
// registro, heredar es leer lo que escribió un mensaje anterior. Quien decide algo que vuelve a tener
|
|
197
|
+
// consecuencia cada vez que ocurre apaga lo segundo con `inherit: false`, y ahí vale lo que la persona pidió
|
|
198
|
+
// en el mensaje en curso o aprobó con un «dale» (caso 119).
|
|
199
|
+
function authorized(input, items, { asked = named, inherit = true } = {}) {
|
|
200
|
+
const saved = said(input)
|
|
201
|
+
if (!saved) return []
|
|
202
|
+
return items.map((item) => ({ item, via: why(saved, item, asked, inherit) })).filter((one) => one.via)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Lo que la persona no autorizó de lo que un guard está por frenar; lo que sí, queda concedido.
|
|
206
|
+
function unauthorized(input, items) {
|
|
154
207
|
const saved = said(input)
|
|
155
208
|
if (!saved) return items
|
|
156
|
-
|
|
209
|
+
const passed = items.filter((item) => why(saved, item, named, true))
|
|
210
|
+
grant(input, saved, passed)
|
|
211
|
+
return items.filter((item) => !passed.includes(item))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Lo mismo sin conceder y sin heredar: lo que no está en el mensaje en curso queda pendiente aunque la
|
|
215
|
+
// sesión lo haya dejado pasar antes.
|
|
216
|
+
function unauthorizedNow(input, items) {
|
|
217
|
+
const cleared = new Set(authorized(input, items, { inherit: false }).map((one) => one.item))
|
|
218
|
+
return items.filter((item) => !cleared.has(item))
|
|
157
219
|
}
|
|
158
220
|
|
|
159
221
|
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
@@ -168,4 +230,4 @@ function hold(input, items) {
|
|
|
168
230
|
} catch { return false }
|
|
169
231
|
}
|
|
170
232
|
|
|
171
|
-
module.exports = { DIR, record, said, unauthorized, hold, ordersPush }
|
|
233
|
+
module.exports = { DIR, record, said, authorized, unauthorized, unauthorizedNow, hold, ordersPush }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Las dos llaves que deciden si un `git push` se publica —`runner.allowPush` y `runner.pushToLiveBranches`—
|
|
4
|
+
// viven en `ops.config.json`, y hasta 0.82.0 ningún guard miraba ese archivo: el bloqueo que dice «esto lo
|
|
5
|
+
// decide una persona» se levantaba escribiendo el archivo que lo levanta, por `Write` o por `sed -i`, y el
|
|
6
|
+
// mensaje del bloqueo nombra el campo exacto que hay que agregar (caso 114).
|
|
7
|
+
//
|
|
8
|
+
// **Protege esos dos campos y no el archivo.** Protegerlo entero repondría el candado que el 090 sacó: el
|
|
9
|
+
// límite de raíces manda a declarar la ruta en `writableOutsideRoots`, de este mismo archivo, así que
|
|
10
|
+
// frenar esa edición dejaría al agente sin la salida que el otro guard le acaba de indicar. Todo lo demás
|
|
11
|
+
// —raíces, migraciones, lo que sea— sigue pasando como trabajo normal.
|
|
12
|
+
//
|
|
13
|
+
// Son dos guards y un solo archivo, como `secrets` y `secrets-shell`: corren en eventos distintos y cada
|
|
14
|
+
// grupo cubre a cada guard una vez, mientras que lo que deciden —cuál es el archivo, qué llave cambió y
|
|
15
|
+
// cómo se dice— es uno solo y copiarlo dejaría dos mitades que dejan de coincidir.
|
|
16
|
+
|
|
17
|
+
const path = require('node:path')
|
|
18
|
+
const { block, commandOf, configOf, contentOf, cwdOf, filesOf, opsRoot } = require('./input')
|
|
19
|
+
const CHAT = require('./chat')
|
|
20
|
+
const { writesWithBase } = require('./shell')
|
|
21
|
+
|
|
22
|
+
const INSTANCE_CONFIG = 'ops.config.json'
|
|
23
|
+
const PROTECTED = ['allowPush', 'pushToLiveBranches']
|
|
24
|
+
const NAMES = PROTECTED.map((name) => `runner.${name}`).join(' y ')
|
|
25
|
+
|
|
26
|
+
// El valor de cada llave en la forma en que se comparan. Ausente no es vacío: sacar `pushToLiveBranches`
|
|
27
|
+
// cambia qué ramas alcanza el permiso y tiene que distinguirse de dejarlo en una lista sin ramas. Lo hace
|
|
28
|
+
// `JSON.stringify`, que de un campo ausente no devuelve nada y de ninguno presente devuelve la cadena vacía.
|
|
29
|
+
function keysOf(config) {
|
|
30
|
+
const runner = (config && typeof config.runner === 'object' && config.runner) || {}
|
|
31
|
+
return PROTECTED.map((name) => JSON.stringify(runner[name])).join(' ')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// El mensaje le habla a la persona y deja el permiso como cosa suya; el porqué de esa redacción está en
|
|
35
|
+
// `HOW`, en `approval.js`.
|
|
36
|
+
const HANDS_OFF = 'Lo decide una persona (R10): decile qué se frenó y esperá. Lo pone ella editando el '
|
|
37
|
+
+ `archivo, o pidiéndotelo en el chat nombrando ${INSTANCE_CONFIG}.`
|
|
38
|
+
|
|
39
|
+
function changeMessage(file) {
|
|
40
|
+
return `${file} lleva las dos llaves que deciden qué push se publica, ${NAMES}, y este cambio las toca. `
|
|
41
|
+
+ `${HANDS_OFF}\nEl resto del archivo no lo frena este guard: si venías a declarar una raíz o a tocar `
|
|
42
|
+
+ 'cualquier otro campo, mandá el mismo cambio sin mover esas dos llaves.'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// **Un contenido que no se puede leer como JSON se frena.** Un `Edit` manda el fragmento que reemplaza y no
|
|
46
|
+
// el archivo, así que no hay con qué comparar: dejarlo pasar apagaría este guard con la herramienta más
|
|
47
|
+
// común de todas, y frenarlo deja una salida más cara pero escrita —mandar el archivo entero, que sí se
|
|
48
|
+
// compara—. Es el mismo criterio con el que el motor decide cuando no puede leer el índice de git.
|
|
49
|
+
function unreadableMessage(file) {
|
|
50
|
+
return `${file} no llega como un JSON completo que se pueda leer, así que no hay cómo saber si el cambio `
|
|
51
|
+
+ `toca ${NAMES}, las dos llaves que deciden qué push se publica. Un guard que no puede verificar no `
|
|
52
|
+
+ 'autoriza: mandá el archivo entero en una sola escritura y el guard compara lo que cambia.'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// **Por shell se frena toda escritura, y no sólo la de las dos llaves.** La mitad de archivos compara dos
|
|
56
|
+
// contenidos porque tiene los dos: el entrante viene en la llamada y el otro está en disco. Un comando no
|
|
57
|
+
// trae ninguno —`sed -i 's/false/true/'` dice qué reemplaza, no con qué va a quedar el archivo—, y
|
|
58
|
+
// averiguarlo sería ejecutarlo, que es justo lo que este guard corre antes de que ocurra. Sin contenido que
|
|
59
|
+
// comparar quedan dos conductas posibles y ninguna intermedia, y se elige la que cierra la vía que el 114
|
|
60
|
+
// midió.
|
|
61
|
+
function commandMessage(file) {
|
|
62
|
+
return `el comando escribe en ${file}, donde viven las dos llaves que deciden qué push se publica, `
|
|
63
|
+
+ `${NAMES}. Un comando no dice con qué va a quedar el archivo, así que acá se frena toda escritura y `
|
|
64
|
+
+ `no sólo la de esas dos llaves. ${HANDS_OFF}\nSi el cambio es de cualquier otro campo, escribí el `
|
|
65
|
+
+ 'archivo entero con la herramienta de edición, que el guard sí puede comparar.'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Lo que la persona pidió nombrando el archivo pasa: ahí quien decide es ella, que es lo que este guard
|
|
69
|
+
// cuida. Se pregunta cada vez —sin conceder y sin heredar—, igual que `self-approval`: las dos llaves que
|
|
70
|
+
// viven acá son el permiso de push, y nombrar el archivo una vez no lo deja abierto toda la sesión. El
|
|
71
|
+
// comentario decía «lo mismo» mientras el código concedía (caso 119).
|
|
72
|
+
const asked = (input, file) => !CHAT.unauthorizedNow(input, [file]).length
|
|
73
|
+
|
|
74
|
+
function judgeWrite(input, root, file) {
|
|
75
|
+
if (!filesOf(input).some((raw) => path.resolve(cwdOf(input), raw) === file)) return
|
|
76
|
+
if (asked(input, file)) return
|
|
77
|
+
let incoming = null
|
|
78
|
+
try { incoming = JSON.parse(contentOf(input)) } catch { block(unreadableMessage(file)) }
|
|
79
|
+
if (keysOf(configOf(root)) !== keysOf(incoming)) block(changeMessage(file))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function judgeCommand(input, file) {
|
|
83
|
+
for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
|
|
84
|
+
// Una ruta relativa sin base contra la que resolverla no se puede juzgar, y esa pregunta es de
|
|
85
|
+
// `shell-boundary`, que ya la contesta para todo destino.
|
|
86
|
+
if (!path.isAbsolute(raw) && base === null) continue
|
|
87
|
+
if (path.resolve(base || '/', raw) !== file) continue
|
|
88
|
+
if (asked(input, file)) return
|
|
89
|
+
block(commandMessage(file))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// La configuración que decide, o nada. Sin raíz ops no hay ninguna: `push.js` lee el `runner` de esta
|
|
94
|
+
// misma raíz, así que donde no la hay tampoco hay permiso que escribirse.
|
|
95
|
+
function target(input) {
|
|
96
|
+
const root = opsRoot(input)
|
|
97
|
+
return root ? { root, file: path.join(root, INSTANCE_CONFIG) } : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function opsConfig(input) {
|
|
101
|
+
const found = target(input)
|
|
102
|
+
if (found) judgeWrite(input, found.root, found.file)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function opsConfigShell(input) {
|
|
106
|
+
const found = target(input)
|
|
107
|
+
if (found) judgeCommand(input, found.file)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { opsConfig, opsConfigShell }
|
package/engine/hooks/push.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
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')
|
|
22
24
|
const { spawnSync } = require('node:child_process')
|
|
23
25
|
const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
|
|
24
26
|
const AP = require('./approval')
|
|
@@ -125,6 +127,37 @@ function workMessage(items, input) {
|
|
|
125
127
|
+ 'El permiso permanente es runner.allowPush en ops.config.json, y lo decide una persona.'
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// El rastro de las aprobaciones que publicaron: una línea por push que pasó porque alguien lo autorizó.
|
|
131
|
+
// Una aprobación se consume sin dejar nada —un «dale» publica y al mensaje siguiente ya no queda quién lo
|
|
132
|
+
// 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).
|
|
134
|
+
//
|
|
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')
|
|
145
|
+
|
|
146
|
+
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 */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Un destino en la forma en que se anota. Sin remoto ni rama resolubles va `null`, que es exactamente lo
|
|
158
|
+
// que el comando dejó ver.
|
|
159
|
+
const entryOf = (to, via) => ({ remote: to.remote || null, branch: to.branch || null, via })
|
|
160
|
+
|
|
128
161
|
function publish(input, command) {
|
|
129
162
|
const pushes = [...command.matchAll(PUSH)]
|
|
130
163
|
if (!pushes.length) return
|
|
@@ -135,13 +168,27 @@ function publish(input, command) {
|
|
|
135
168
|
const listed = new Set(Array.isArray(runner.pushToLiveBranches) ? runner.pushToLiveBranches : [])
|
|
136
169
|
const filed = new Set(root ? AP.read(root) : [])
|
|
137
170
|
const live = liveBranches(dir)
|
|
138
|
-
const
|
|
139
|
-
|
|
171
|
+
const destinations = pushes.flatMap((match) => destinationsOf(match[1], dir))
|
|
172
|
+
const byFile = destinations.filter((to) => to.item && to.item.startsWith('push ') && filed.has(to.item))
|
|
173
|
+
const left = destinations.filter((to) => !byFile.includes(to))
|
|
140
174
|
const unlisted = left.filter((to) => to.every || (live.has(to.branch) && !listed.has(to.branch)))
|
|
141
175
|
if (unlisted.length) block(liveMessage(unlisted, input))
|
|
176
|
+
// Con la llave puesta no hizo falta ninguna aprobación, así que no hay ninguna que anotar: la
|
|
177
|
+
// autorización ya está escrita en `ops.config.json`, y repetirla en cada push sería registrar el archivo
|
|
178
|
+
// del proyecto contra sí mismo.
|
|
142
179
|
if (runner.allowPush === true) return
|
|
143
|
-
const
|
|
180
|
+
const items = [...new Set(left.map((to) => to.item))]
|
|
181
|
+
// Se pregunta sin conceder, a diferencia de lo que pasa por `AP.pending`: volver a leer lo que ya se
|
|
182
|
+
// leyó no agrega consecuencia y volver a publicar sí, así que la autorización de un push vale para esa
|
|
183
|
+
// operación y no para el mensaje siguiente (R10).
|
|
184
|
+
const cleared = CHAT.authorized(input, items, { asked: CHAT.ordersPush })
|
|
185
|
+
const unordered = items.filter((item) => !cleared.some((one) => one.item === item))
|
|
144
186
|
if (unordered.length) block(workMessage(unordered, input))
|
|
187
|
+
const destination = new Map(destinations.map((to) => [to.item, to]))
|
|
188
|
+
trail(root, input.session_id, [
|
|
189
|
+
...new Map(byFile.map((to) => [to.item, entryOf(to, AP.APPROVAL)])).values(),
|
|
190
|
+
...cleared.map((one) => entryOf(destination.get(one.item), one.via)),
|
|
191
|
+
])
|
|
145
192
|
}
|
|
146
193
|
|
|
147
194
|
module.exports = { publish }
|
package/engine/hooks/run.js
CHANGED
|
@@ -14,6 +14,7 @@ const shell = require('./shell')
|
|
|
14
14
|
const files = require('./files')
|
|
15
15
|
const chat = require('./chat')
|
|
16
16
|
const { secretsShell } = require('./secrets-shell')
|
|
17
|
+
const { opsConfig, opsConfigShell } = require('./ops-config')
|
|
17
18
|
|
|
18
19
|
function planningDrift(input) {
|
|
19
20
|
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
@@ -42,6 +43,8 @@ const guards = {
|
|
|
42
43
|
verify: shell.verify,
|
|
43
44
|
'shell-boundary': shell.shellBoundary,
|
|
44
45
|
'secrets-shell': secretsShell,
|
|
46
|
+
'ops-config-shell': opsConfigShell,
|
|
47
|
+
'ops-config': opsConfig,
|
|
45
48
|
secrets: files.secrets,
|
|
46
49
|
generated: files.generated,
|
|
47
50
|
'workspace-boundary': files.workspaceBoundary,
|
|
@@ -58,9 +61,9 @@ const guards = {
|
|
|
58
61
|
// Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
|
|
59
62
|
const hookGroups = {
|
|
60
63
|
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
|
|
61
|
-
'secrets-shell'],
|
|
64
|
+
'secrets-shell', 'ops-config-shell'],
|
|
62
65
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
63
|
-
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
66
|
+
'integration-snapshot', 'test-evidence', 'plan-first', 'ops-config'],
|
|
64
67
|
'pre-read': ['secrets-read'],
|
|
65
68
|
prompt: ['chat'],
|
|
66
69
|
stop: ['planning-drift'],
|
|
@@ -114,6 +117,18 @@ const hookMetadata = [
|
|
|
114
117
|
purpose: 'Bloquea leer por shell una credencial conocida o declarada; lo que la persona pidió en el chat '
|
|
115
118
|
+ 'pasa.',
|
|
116
119
|
},
|
|
120
|
+
{
|
|
121
|
+
name: 'ops-config',
|
|
122
|
+
event: 'PreToolUse · files',
|
|
123
|
+
purpose: 'No deja al agente escribirse el permiso de push: frena la escritura que cambia '
|
|
124
|
+
+ 'runner.allowPush o runner.pushToLiveBranches en ops.config.json. El resto del archivo pasa.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'ops-config-shell',
|
|
128
|
+
event: 'PreToolUse · shell',
|
|
129
|
+
purpose: 'Frena toda escritura por shell sobre ops.config.json: un comando no dice con qué va a '
|
|
130
|
+
+ 'quedar el archivo, así que no hay contenido que comparar.',
|
|
131
|
+
},
|
|
117
132
|
{ name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
|
|
118
133
|
{
|
|
119
134
|
name: 'workspace-boundary',
|
|
@@ -33,11 +33,21 @@ function verbOf(words) {
|
|
|
33
33
|
return words[0] || ''
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// `${VAR}` y `$VAR` son la misma expansión, y sólo la primera lleva llaves: la separación de palabras corta
|
|
37
|
+
// ahí, así que de `ops/${O}/.env.infisical` quedaba suelto `/.env.infisical` —una ruta absoluta que el
|
|
38
|
+
// comando no lee y que el bloqueo ofrecía aprobar—. Sin llaves, la expansión se queda pegada a su ruta y el
|
|
39
|
+
// guard la ve como lo que es: algo que no resolvió (caso 118).
|
|
40
|
+
const unbraced = (command) => String(command).replace(/\$\{(\w+)\}/g, '$$$1')
|
|
41
|
+
|
|
42
|
+
// Lo que el shell iba a expandir y el guard no: una variable, un `$(…)` o un backtick. Lo que sale de ahí
|
|
43
|
+
// no nombra ningún archivo, así que no se puede aprobar por archivo.
|
|
44
|
+
const UNRESOLVED = /[$`]/
|
|
45
|
+
|
|
36
46
|
// Las palabras de cada tramo que lee: el que empieza con un lector, o el que redirige un archivo a la
|
|
37
47
|
// entrada. Lo entrecomillado se mira, porque el código de un `node -e` nombra el archivo ahí adentro.
|
|
38
48
|
function readTokens(command) {
|
|
39
49
|
const found = []
|
|
40
|
-
for (const segment of command.split(/[;&|\n]+|\$\(|`/)) {
|
|
50
|
+
for (const segment of unbraced(command).split(/[;&|\n]+|\$\(|`/)) {
|
|
41
51
|
const words = segment.trim().replace(/^[({]+\s*/, '').split(/\s+/).filter(Boolean)
|
|
42
52
|
const reads = READERS.has(path.basename(verbOf(words))) || /<(?![<(])/.test(segment)
|
|
43
53
|
if (reads) found.push(...(segment.match(/[^\s'"`\\;|&<>(){}=,]+/g) || []))
|
|
@@ -56,7 +66,8 @@ function secretsShell(input) {
|
|
|
56
66
|
const left = AP.pending(opsRoot(input), [...new Set(files)], input)
|
|
57
67
|
if (!left.length) return
|
|
58
68
|
block(`el comando lee ${left.join(', ')}, que es una credencial: leerla la deja en el contexto de la sesión. `
|
|
59
|
-
+
|
|
69
|
+
+ 'Si hace falta un valor, pedíselo a una persona.\n'
|
|
70
|
+
+ AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input, left.filter((one) => !UNRESOLVED.test(one))))
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
module.exports = { secretsShell }
|
|
@@ -8,24 +8,104 @@
|
|
|
8
8
|
// que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
|
|
9
9
|
// hook ve, o se lo pide al agente nombrándolo en el chat. El registro del chat no lo escribe nunca una
|
|
10
10
|
// herramienta.
|
|
11
|
+
//
|
|
12
|
+
// **Nombrar el archivo autoriza el archivo, no lo que se escribe adentro**, y hasta 0.83.0 eso era todo lo
|
|
13
|
+
// que se comprobaba: la persona pedía agregar una ruta inocua, el agente escribía `push origin main`, y esa
|
|
14
|
+
// línea después publicaba en la rama viva —el alcance que el 108 le dio a propósito, apoyado en que sólo
|
|
15
|
+
// una persona podía escribirla—. Medido punta a punta (caso 119). Por eso lo que llega se compara línea por
|
|
16
|
+
// línea contra lo que la persona pidió, y son dos entradas porque cada vía tiene con qué distinto.
|
|
11
17
|
|
|
12
18
|
const path = require('node:path')
|
|
13
|
-
const { opsRoot } = require('./input')
|
|
19
|
+
const { contentOf, opsRoot } = require('./input')
|
|
14
20
|
const AP = require('./approval')
|
|
15
21
|
const CHAT = require('./chat')
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const CHAT_RECORD = (file) => `${file} es el registro de lo que la persona dijo en el chat: lo escribe el `
|
|
24
|
+
+ 'runner, nunca una herramienta.'
|
|
25
|
+
|
|
26
|
+
const SELF = (file) => `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la `
|
|
27
|
+
+ 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o '
|
|
28
|
+
+ 'que edite el archivo ella.'
|
|
29
|
+
|
|
30
|
+
// Las líneas van en el mensaje porque son lo que el «dale» va a aprobar: una confirmación que no muestra
|
|
31
|
+
// qué autoriza es la que firma lo que nadie leyó.
|
|
32
|
+
const UNASKED = (file, missing) => `${file} es la aprobación de una persona y estas líneas no las pidió:\n`
|
|
33
|
+
+ missing.map((line) => ` ${line}\n`).join('')
|
|
34
|
+
+ 'Escribí sólo lo que ella nombró en su mensaje. Si hacen falta las otras, decile cuáles y por qué, y '
|
|
35
|
+
+ 'esperá: si contesta «dale», reintentá la misma escritura y pasa.'
|
|
36
|
+
|
|
37
|
+
// **Por shell se frena toda escritura.** Es la misma decisión que toma `ops-config`, y por qué un comando
|
|
38
|
+
// no se puede comparar está escrito allá. Lo que la trae hasta acá es que el contenido es lo único que
|
|
39
|
+
// separa la línea que la persona pidió de la que el agente se escribe solo.
|
|
40
|
+
const BY_COMMAND = (file) => `el comando escribe en ${file}, la aprobación de una persona, y no dice con `
|
|
41
|
+
+ 'qué va a quedar el archivo: sin eso no hay cómo saber si lo que se agrega es lo que ella pidió. '
|
|
42
|
+
+ 'Mandalo con la herramienta de edición, que trae el contenido y se compara línea por línea, o que lo '
|
|
43
|
+
+ 'pegue ella.'
|
|
44
|
+
|
|
45
|
+
// La raíz de la instancia si `file` es su aprobación, o `null`.
|
|
46
|
+
function approvalRoot(input, file) {
|
|
23
47
|
const root = opsRoot(input)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
return root && file === path.join(root, 'planning', AP.APPROVAL) ? root : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Lo que las dos vías deciden igual: el registro del chat no se escribe nunca, y la aprobación sólo si la
|
|
52
|
+
// persona la nombró en su mensaje.
|
|
53
|
+
//
|
|
54
|
+
// Se pregunta sin conceder: una concesión que sobreviviera al mensaje convertiría «agregá src/x.js a
|
|
55
|
+
// .ops-approval» en permiso para escribirle después cualquier otra línea, que es aprobarse solo por la
|
|
56
|
+
// puerta de al lado.
|
|
57
|
+
function common(input, file) {
|
|
58
|
+
if (file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)) return CHAT_RECORD(file)
|
|
59
|
+
if (!approvalRoot(input, file)) return ''
|
|
60
|
+
return CHAT.authorized(input, [file]).length ? '' : SELF(file)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Una línea de push no se pregunta como una ruta: `mentions` compara también el basename, así que para
|
|
64
|
+
// `push origin feat/login` alcanzaría con que el mensaje pidiera algo del login (caso 103). Es la misma
|
|
65
|
+
// partición que hace `push.js` cuando pregunta por un destino.
|
|
66
|
+
const isPush = (line) => /^push\s+\S+\s+\S+$/.test(line)
|
|
67
|
+
|
|
68
|
+
// Por qué no se puede escribir este contenido, o vacío.
|
|
69
|
+
//
|
|
70
|
+
// **Lo que ya estaba en disco no se vuelve a nombrar**: esta escritura no lo agrega, y exigirlo obligaría a
|
|
71
|
+
// repetir el archivo entero para sumar una línea. Quitar tampoco se pregunta: una aprobación más corta
|
|
72
|
+
// autoriza menos.
|
|
73
|
+
//
|
|
74
|
+
// **Un fragmento sí se juzga, al revés que en `ops.config.json`.** Allá el archivo entrante se compara
|
|
75
|
+
// entero porque una llave cambia de sentido según lo que la rodea, y un `Edit` manda un pedazo; acá cada
|
|
76
|
+
// línea vale por sí sola, así que el pedazo que llega es exactamente lo que se agrega. Lo que no se puede
|
|
77
|
+
// leer como líneas —un parche con sus encabezados— no coincide con nada pedido y se frena, que es el lado
|
|
78
|
+
// correcto para equivocarse.
|
|
79
|
+
//
|
|
80
|
+
// Y se pregunta sin heredar lo que la sesión concedió antes: una línea acá la leen todos los guards y llega
|
|
81
|
+
// hasta la rama viva, así que tiene que ser la que la persona dijo en el mensaje en curso.
|
|
82
|
+
function unasked(input, root, file) {
|
|
83
|
+
const filed = new Set(AP.read(root))
|
|
84
|
+
const added = AP.lines(contentOf(input)).filter((line) => !filed.has(line))
|
|
85
|
+
const pushes = CHAT.authorized(input, added.filter(isPush), { asked: CHAT.ordersPush, inherit: false })
|
|
86
|
+
const paths = CHAT.authorized(input, added.filter((line) => !isPush(line)), { inherit: false })
|
|
87
|
+
const cleared = new Set([...pushes, ...paths].map((one) => one.item))
|
|
88
|
+
const missing = added.filter((line) => !cleared.has(line))
|
|
89
|
+
if (!missing.length) return ''
|
|
90
|
+
// El archivo se anota junto con las líneas: sin él, el «dale» aprobaría las líneas y el guard volvería a
|
|
91
|
+
// frenar por el archivo, que en ese mensaje ya nadie nombra.
|
|
92
|
+
CHAT.hold(input, [file, ...missing])
|
|
93
|
+
return UNASKED(file, missing)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Por qué el agente no puede escribir en `file` con la herramienta de edición, o vacío.
|
|
97
|
+
function selfApproval(input, file) {
|
|
98
|
+
const stop = common(input, file)
|
|
99
|
+
if (stop) return stop
|
|
100
|
+
const root = approvalRoot(input, file)
|
|
101
|
+
return root ? unasked(input, root, file) : ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Lo mismo para el destino de un comando, o vacío.
|
|
105
|
+
function selfApprovalShell(input, file) {
|
|
106
|
+
const stop = common(input, file)
|
|
107
|
+
if (stop) return stop
|
|
108
|
+
return approvalRoot(input, file) ? BY_COMMAND(file) : ''
|
|
29
109
|
}
|
|
30
110
|
|
|
31
|
-
module.exports = { selfApproval }
|
|
111
|
+
module.exports = { selfApproval, selfApprovalShell }
|
package/engine/hooks/shell.js
CHANGED
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
16
|
const { publish } = require('./push')
|
|
17
|
-
const {
|
|
17
|
+
const { selfApprovalShell } = require('./self-approval')
|
|
18
18
|
const EV = require('../core/evidence')
|
|
19
19
|
|
|
20
20
|
// Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
|
|
@@ -177,7 +177,7 @@ function dependencies(input) {
|
|
|
177
177
|
// Lo que se juzga acá es el archivo staged, así que la aprobación por ruta lo expresa: autorizar
|
|
178
178
|
// `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
|
|
179
179
|
// conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
|
|
180
|
-
const sinAprobar = (parent, names) => AP.
|
|
180
|
+
const sinAprobar = (parent, names) => AP.pendingNow(opsRoot(input),
|
|
181
181
|
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
|
|
182
182
|
// Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
|
|
183
183
|
// disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
|
|
@@ -315,7 +315,7 @@ function shellBoundary(input) {
|
|
|
315
315
|
const file = path.resolve(base || '/', raw)
|
|
316
316
|
// El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
|
|
317
317
|
// declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
|
|
318
|
-
const own =
|
|
318
|
+
const own = selfApprovalShell(input, file)
|
|
319
319
|
if (own) block(own)
|
|
320
320
|
if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
|
|
321
321
|
if (outsideRoots(file, allowed)) {
|
|
@@ -348,7 +348,7 @@ function governance(input) {
|
|
|
348
348
|
// La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
|
|
349
349
|
// reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
|
|
350
350
|
// entre una llave por operación y una puerta que quedó abierta.
|
|
351
|
-
const pendientes = AP.
|
|
351
|
+
const pendientes = AP.pendingNow(opsRoot(input), governed, input)
|
|
352
352
|
if (!pendientes.length) return
|
|
353
353
|
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
|
|
354
354
|
}
|
|
@@ -489,7 +489,7 @@ function verify(input) {
|
|
|
489
489
|
// Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
|
|
490
490
|
// es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
|
|
491
491
|
// y no de una regla, que es lo que la vuelve una operación y no un permiso.
|
|
492
|
-
const sinAprobar = AP.
|
|
492
|
+
const sinAprobar = AP.pendingNow(opsRoot(input), staged, input)
|
|
493
493
|
const aprobado = !sinAprobar.length
|
|
494
494
|
if (changedOpenApi && !hasApiGenerated && !aprobado) {
|
|
495
495
|
block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
|
|
@@ -604,4 +604,4 @@ function verifyGates(root, dir, sinAprobar, env, input) {
|
|
|
604
604
|
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
-
module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
|
|
607
|
+
module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run, writesWithBase }
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -109,7 +109,9 @@ te frena es el peor para elegir bien.
|
|
|
109
109
|
cuando trabaja dentro de un recorrido; lo que vos pedís directo no se frena. Nombrá lo que querés que
|
|
110
110
|
toque —«borrá la prueba de altas», «reescribí la migración 004»— y pasa sin preguntarte de nuevo. Si tu
|
|
111
111
|
pedido no lo nombraba y algo se frena, el agente te dice qué y por qué: contestá «dale» y pasa exactamente
|
|
112
|
-
eso.
|
|
112
|
+
eso. **Con los gates de un commit se pregunta cada vez**, como con publicar: gobernanza, los gates del
|
|
113
|
+
stack y los lockfiles no heredan lo que autorizaste en un mensaje anterior, porque cada commit es otra
|
|
114
|
+
operación. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
|
|
113
115
|
por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
|
|
114
116
|
Antigravity, y cuando nadie está en el chat —CI, un recorrido, un subagente—, queda el archivo de abajo.
|
|
115
117
|
|
package/template/gitignore
CHANGED
|
@@ -9,6 +9,11 @@ node_modules/
|
|
|
9
9
|
# máquina. Committearlo sería historia que nadie lee y un conflicto por commit.
|
|
10
10
|
planning/.verify-log
|
|
11
11
|
|
|
12
|
+
# El rastro de los push que pasaron por una aprobación. Local por lo mismo que el de arriba, y encima
|
|
13
|
+
# sólo crece: lo que contesta —quién autorizó qué rama y cuándo— se pregunta en la máquina donde corrió
|
|
14
|
+
# la sesión que publicó.
|
|
15
|
+
planning/.push-log
|
|
16
|
+
|
|
12
17
|
# El plan de cada runner. Es de la máquina que lo corre —existe para recuperar una ejecución
|
|
13
18
|
# interrumpida, y nadie más puede retomarla— y cambia en cada paso, así que compartirlo es un conflicto
|
|
14
19
|
# por commit a cambio de nada. Lo que el equipo sí necesita saber vive en `planning/claims/`.
|
|
@@ -11,7 +11,7 @@ choques entre dos personas —o entre dos agentes— salen de tratarlas igual.
|
|
|
11
11
|
|---|---|---|---|
|
|
12
12
|
| **Compartido** | `roadmap/`, `BACKLOG.md`, `INBOX.md`, `HUMAN_ACTIONS.md`, `done/`, reglas y ADR | cualquiera, en actos humanos | baja |
|
|
13
13
|
| **Coordinación** | `claims/`, un archivo por tarea tomada | uno por tarea | dos veces por tarea |
|
|
14
|
-
| **Local** | `wip/<runner>.md`, `.verify-log`, el árbol de trabajo | vos | continua |
|
|
14
|
+
| **Local** | `wip/<runner>.md`, `.verify-log`, `.push-log`, el árbol de trabajo | vos | continua |
|
|
15
15
|
|
|
16
16
|
La regla que los separa: **un archivo con más de un escritor tiene que cambiar poco; uno que cambia mucho
|
|
17
17
|
tiene que tener un solo escritor.** Cuando uno viola las dos a la vez, el equipo se pisa en cada commit.
|