@ingeniomaps/cauce 0.80.0 → 0.81.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 +49 -0
- package/automatization/hooks/README.md +41 -11
- package/automatization/hooks/guard-chat.sh +5 -0
- package/automatization/hooks/guard-secrets-shell.sh +3 -0
- package/automatization/runners/claude/README.md +5 -4
- package/automatization/runners/claude/manifest.json +26 -1
- package/automatization/runners/claude/settings.json +8 -24
- package/automatization/runners/codex/README.md +4 -0
- package/automatization/runners/codex/hooks.json +7 -0
- package/automatization/runners/gemini/README.md +5 -2
- package/automatization/runners/gemini/settings.json +11 -1
- package/engine/automation/index.js +9 -1
- package/engine/hooks/approval.js +37 -7
- package/engine/hooks/chat.js +122 -0
- package/engine/hooks/files.js +28 -16
- package/engine/hooks/input.js +7 -1
- package/engine/hooks/run.js +23 -3
- package/engine/hooks/secrets-shell.js +62 -0
- package/engine/hooks/self-approval.js +31 -0
- package/engine/hooks/shell.js +30 -26
- package/package.json +1 -1
- package/template/AGENTS.md +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,55 @@ 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.81.0] - 2026-09-11
|
|
18
|
+
|
|
19
|
+
### Cambiado
|
|
20
|
+
|
|
21
|
+
- **Lo que pedís en el chat ya no te frena.** Los guards veían la herramienta y nada de la conversación,
|
|
22
|
+
así que frenaban igual lo que pediste con todas las letras y lo que el agente decidía solo. Ahora, en
|
|
23
|
+
Claude Code, Codex y Gemini, un hook registra tu mensaje y los guards lo leen: si nombraste lo que se
|
|
24
|
+
iba a frenar —«borrá la prueba de altas», «reescribí la migración 004»—, pasa; si no, el agente te dice
|
|
25
|
+
qué se frenó y un «dale» aprueba exactamente eso. `plan-first` deja de pedir un plan cuando el cambio lo
|
|
26
|
+
pediste vos. Lo que el agente hace por su cuenta, dentro de un recorrido o en un subagente, se sigue
|
|
27
|
+
frenando igual.
|
|
28
|
+
|
|
29
|
+
**Qué cambia para vos**: corré `automation install` para que tu runner registre el hook nuevo
|
|
30
|
+
(`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en Gemini); en Codex, confialo con `/hooks`. Tu
|
|
31
|
+
mensaje se guarda en el temporal del sistema, no en el repositorio, y sólo el último de cada sesión.
|
|
32
|
+
Antigravity sigue con el archivo de aprobación.
|
|
33
|
+
|
|
34
|
+
- **Leer una credencial por shell se frena en los cuatro runners, y en Claude ya no te frena a vos.** Un
|
|
35
|
+
guard nuevo, `secrets-shell`, frena el comando que muestra una credencial —`cat .env`, `head`, `grep`,
|
|
36
|
+
`source`, una redirección `<`, un `node -e`— en Claude, Codex, Gemini y Antigravity. Hasta ahora sólo
|
|
37
|
+
Claude lo frenaba, con reglas nativas `permissions.deny` que tampoco dejaban pasar tu pedido; esas reglas
|
|
38
|
+
se retiran, y como con el resto de los guards, si lo pedís en el chat, pasa.
|
|
39
|
+
|
|
40
|
+
**Qué cambia para vos**: corré `automation install`; en Claude quita las reglas que Cauce había puesto y
|
|
41
|
+
conserva las tuyas. Si querés un bloqueo nativo total, escribilo como regla propia.
|
|
42
|
+
|
|
43
|
+
### Corregido
|
|
44
|
+
|
|
45
|
+
- **El bloqueo de `verify` cita la prueba que falló también con jest, vitest, mocha y pytest.** Con esas
|
|
46
|
+
herramientas citaba el archivo, el resumen o —con `pytest -v`— una prueba verde cuyo nombre decía «error».
|
|
47
|
+
Ahora reconoce cómo marca cada una la prueba que falla, comprobado contra la salida real de cada una.
|
|
48
|
+
|
|
49
|
+
**Qué cambia para vos**: el mensaje apunta a la prueba que hay que mirar.
|
|
50
|
+
|
|
51
|
+
- **El agente ya no puede escribirse la aprobación.** `planning/.ops-approval` se escribía con la misma
|
|
52
|
+
herramienta que usa el agente y ningún guard lo miraba, así que una aprobación suya destrababa igual que
|
|
53
|
+
una tuya. Los guards de límites lo frenan ahora —por la herramienta de escritura y por el destino evidente
|
|
54
|
+
de un comando—, salvo que se lo hayas pedido nombrándolo en el chat.
|
|
55
|
+
|
|
56
|
+
**Qué cambia para vos**: nada si el archivo lo editás vos. Si el agente lo escribía por pedido tuyo,
|
|
57
|
+
ahora se lo pedís en el chat.
|
|
58
|
+
|
|
59
|
+
- **En sidecar, el bloqueo dice en qué archivo aprobar.** Mandaba a `planning/.ops-approval`, y desde la
|
|
60
|
+
carpeta del workspace, donde se abre la sesión, ése no es el `planning/` de la instancia: pegar donde
|
|
61
|
+
decía no destrababa nada. Ahora nombra la ruta que el guard lee —`acme-ops/planning/.ops-approval`—; en
|
|
62
|
+
modo embebido sigue diciendo `planning/.ops-approval`.
|
|
63
|
+
|
|
64
|
+
**Qué cambia para vos**: nada que hacer; el mensaje dice dónde.
|
|
65
|
+
|
|
17
66
|
## [0.80.0] - 2026-09-10
|
|
18
67
|
|
|
19
68
|
### Agregado
|
|
@@ -47,17 +47,43 @@ una cadena — permisos del runner, alcance del token, aprobación de un PR.
|
|
|
47
47
|
|
|
48
48
|
### Leer una credencial
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
Dos guards frenan leer un archivo que `secrets` frenaría al escribir —los nombres conocidos y las
|
|
51
|
+
identidades que declara `organization/secrets.json`—: `secrets-read` en las herramientas de lectura y de
|
|
52
|
+
búsqueda del runner (`Read` y `Grep` en Claude, `read_file` y `grep_search` en Gemini) y `secrets-shell` en
|
|
53
|
+
el shell de los cuatro runners, cuando un comando la muestra —`cat`, `head`, `grep`, `sed`, `source`, una
|
|
54
|
+
redirección `<`, un intérprete en línea—. Un comodín que la nombra —`rg -g '.env*'`, `--include='*.env'`—
|
|
55
|
+
cuenta igual. Lo que sólo la nombra —`ls`, `test -f`, `rm`, `cp .env.example .env`— pasa, y lo que la
|
|
56
|
+
persona pidió en el chat también.
|
|
57
|
+
|
|
58
|
+
Lo que no ve ninguno, comprobado en sesiones reales: una búsqueda sobre la carpeta que no nombra el archivo
|
|
59
|
+
—un `rg` de todo el árbol—, un nombre armado en una variable, y en Gemini el propio entorno del runner,
|
|
60
|
+
que carga el `.env` de la carpeta al arrancar (documentado en geminicli.com/docs/reference/configuration):
|
|
61
|
+
un `env` muestra sus valores sin leer ningún archivo.
|
|
62
|
+
|
|
63
|
+
Hasta 0.80.0 Claude traía además reglas nativas `permissions.deny` `Read(...)`. Las aplica Claude mismo, sin
|
|
64
|
+
pasar por ningún hook, así que frenaban también lo que la persona pedía, mientras en los otros runners el
|
|
65
|
+
shell no tenía freno (caso 104). `automation install` las retira de una instalación anterior y conserva las
|
|
66
|
+
que escribió la empresa: quien quiera un bloqueo nativo total lo escribe como regla propia.
|
|
67
|
+
|
|
68
|
+
### Lo que pide la persona
|
|
69
|
+
|
|
70
|
+
Un guard ve la llamada a la herramienta y nada de la conversación, así que frenaba igual lo que la persona
|
|
71
|
+
pidió con todas las letras y lo que el agente decidió solo. `chat` corre sobre el mensaje de la persona —el
|
|
72
|
+
runner lo dispara cuando ella manda algo, nunca por el resultado de una herramienta— y lo deja donde los
|
|
73
|
+
guards lo leen:
|
|
74
|
+
|
|
75
|
+
- lo que la persona **nombró** en su mensaje pasa: «leé el `.env`» autoriza leer el `.env`, y «no toques
|
|
76
|
+
el `.env`» no;
|
|
77
|
+
- lo que se frenó sin que lo nombrara queda anotado, y un «dale» en el mensaje siguiente aprueba
|
|
78
|
+
exactamente eso;
|
|
79
|
+
- `plan-first` no aplica: el plan es del trabajo que va por tareas.
|
|
80
|
+
|
|
81
|
+
No cuenta cuando no hay persona —CI, o un aviso del runner como el de un subagente que terminó—, cuando lo
|
|
82
|
+
que pidió es un recorrido de Cauce (`/autobuild`, `$flow`…), ni en la llamada de un subagente, que Claude
|
|
83
|
+
marca con `agent_id`. En Claude y Codex cada llamada trae el identificador del mensaje que la originó;
|
|
84
|
+
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 agente no puede
|
|
86
|
+
escribirse ninguno de los dos. Como todo lo de esta página, frena la forma habitual y no un script decidido.
|
|
61
87
|
|
|
62
88
|
## Cómo se ejecutan
|
|
63
89
|
|
|
@@ -85,8 +111,12 @@ runner, mientras la lógica se prueba y mantiene una sola vez en `engine/hooks/r
|
|
|
85
111
|
| `pre-shell` | destructive, git-add, dependencies, governance, verify, shell-boundary | `guard-shell.sh` |
|
|
86
112
|
| `pre-files` | secrets, generated, workspace-boundary, engine, migrations, integration-snapshot, test-evidence, plan-first | `guard-files.sh` |
|
|
87
113
|
| `pre-read` | secrets-read | `guard-secrets-read.sh` |
|
|
114
|
+
| `prompt` | chat | `guard-chat.sh` |
|
|
88
115
|
| `stop` | planning-drift | `guard-planning-drift.sh` |
|
|
89
116
|
|
|
117
|
+
`prompt` corre sobre el mensaje de la persona —`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en
|
|
118
|
+
Gemini— y no sobre una herramienta, y su shim sale siempre con 0.
|
|
119
|
+
|
|
90
120
|
Registrar el grupo gasta un proceso por herramienta en lugar de cinco, con el mismo orden y la misma
|
|
91
121
|
semántica: el primer guard que bloquea corta la ejecución. Un runner que necesite granularidad fina puede
|
|
92
122
|
seguir registrando los wrappers individuales.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shim: qué registra está en engine/hooks/run.js → guards['chat']. Sale siempre con 0: el runner lo corre
|
|
3
|
+
# sobre el mensaje de la persona, y un 2 ahí no frena una herramienta sino lo que la persona escribió.
|
|
4
|
+
"$(dirname "$0")/run-hook.sh" chat >/dev/null 2>&1
|
|
5
|
+
exit 0
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# Claude Code
|
|
2
2
|
|
|
3
|
-
Adaptador nativo mediante `PreToolUse` y `Stop`. Instalar con:
|
|
3
|
+
Adaptador nativo mediante `PreToolUse`, `UserPromptSubmit` y `Stop`. Instalar con:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
node tools/ops.js automation install . claude
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
El instalador fusiona la sección `hooks` en `.claude/settings.json` y conserva otras claves.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
El instalador fusiona la sección `hooks` en `.claude/settings.json` y conserva otras claves. Las reglas
|
|
10
|
+
`permissions.deny` `Read(...)` que Cauce sumaba hasta 0.80.0 las retira al reinstalar —las declara
|
|
11
|
+
`config.retired` en `manifest.json`— y deja las que el proyecto haya escrito: leer una credencial lo frenan
|
|
12
|
+
ahora los guards, y lo que la persona pide en el chat pasa (caso 104). Si ya
|
|
12
13
|
existe una versión distinta de un archivo, se detiene sin sobrescribirla para no destruir
|
|
13
14
|
personalizaciones del proyecto. También crea `CLAUDE.md` cuando no existe y conserva uno existente.
|
|
14
15
|
|
|
@@ -4,7 +4,32 @@
|
|
|
4
4
|
"command": "claude",
|
|
5
5
|
"config": {
|
|
6
6
|
"source": "settings.json",
|
|
7
|
-
"target": ".claude/settings.json"
|
|
7
|
+
"target": ".claude/settings.json",
|
|
8
|
+
"retired": {
|
|
9
|
+
"permissions": {
|
|
10
|
+
"deny": [
|
|
11
|
+
"Read(.env)",
|
|
12
|
+
"Read(.env.local)",
|
|
13
|
+
"Read(.env.*.local)",
|
|
14
|
+
"Read(.npmrc)",
|
|
15
|
+
"Read(.netrc)",
|
|
16
|
+
"Read(_netrc)",
|
|
17
|
+
"Read(.pypirc)",
|
|
18
|
+
"Read(.dockercfg)",
|
|
19
|
+
"Read(id_rsa)",
|
|
20
|
+
"Read(id_dsa)",
|
|
21
|
+
"Read(id_ecdsa)",
|
|
22
|
+
"Read(id_ed25519)",
|
|
23
|
+
"Read(credentials)",
|
|
24
|
+
"Read(credentials*.json)",
|
|
25
|
+
"Read(*service-account*.json)",
|
|
26
|
+
"Read(*.pem)",
|
|
27
|
+
"Read(*.key)",
|
|
28
|
+
"Read(accesos.md)",
|
|
29
|
+
"Read(credenciales*)"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
8
33
|
},
|
|
9
34
|
"instructions": [
|
|
10
35
|
{
|
|
@@ -1,27 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"deny": [
|
|
4
|
-
"Read(.env)",
|
|
5
|
-
"Read(.env.local)",
|
|
6
|
-
"Read(.env.*.local)",
|
|
7
|
-
"Read(.npmrc)",
|
|
8
|
-
"Read(.netrc)",
|
|
9
|
-
"Read(_netrc)",
|
|
10
|
-
"Read(.pypirc)",
|
|
11
|
-
"Read(.dockercfg)",
|
|
12
|
-
"Read(id_rsa)",
|
|
13
|
-
"Read(id_dsa)",
|
|
14
|
-
"Read(id_ecdsa)",
|
|
15
|
-
"Read(id_ed25519)",
|
|
16
|
-
"Read(credentials)",
|
|
17
|
-
"Read(credentials*.json)",
|
|
18
|
-
"Read(*service-account*.json)",
|
|
19
|
-
"Read(*.pem)",
|
|
20
|
-
"Read(*.key)",
|
|
21
|
-
"Read(accesos.md)",
|
|
22
|
-
"Read(credenciales*)"
|
|
23
|
-
]
|
|
24
|
-
},
|
|
25
2
|
"hooks": {
|
|
26
3
|
"PreToolUse": [
|
|
27
4
|
{
|
|
@@ -37,12 +14,19 @@
|
|
|
37
14
|
]
|
|
38
15
|
},
|
|
39
16
|
{
|
|
40
|
-
"matcher": "Read",
|
|
17
|
+
"matcher": "Read|Grep",
|
|
41
18
|
"hooks": [
|
|
42
19
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-secrets-read.sh" }
|
|
43
20
|
]
|
|
44
21
|
}
|
|
45
22
|
],
|
|
23
|
+
"UserPromptSubmit": [
|
|
24
|
+
{
|
|
25
|
+
"hooks": [
|
|
26
|
+
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-chat.sh" }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
46
30
|
"Stop": [
|
|
47
31
|
{
|
|
48
32
|
"hooks": [
|
|
@@ -18,6 +18,10 @@ porque cambia el hash. No se usa `--dangerously-bypass-hook-trust`.
|
|
|
18
18
|
Los `matcher` filtran el **nombre de la herramienta**: los comandos de shell llegan como `Bash` y las
|
|
19
19
|
ediciones como `apply_patch`, `Edit` o `Write`. No son los nombres internos del protocolo.
|
|
20
20
|
|
|
21
|
+
`UserPromptSubmit` registra el mensaje de la persona, para que lo que pidió en el chat pase sin aprobarlo
|
|
22
|
+
a mano. Codex le pasa a cada herramienta el `turn_id` del mensaje que la originó, que es lo que ata la
|
|
23
|
+
llamada al pedido.
|
|
24
|
+
|
|
21
25
|
Si actualizás una instalación anterior a este cambio, `.codex/hooks/hooks.json` queda huérfano —Codex
|
|
22
26
|
nunca lo leyó— y se borra a mano.
|
|
23
27
|
|
|
@@ -17,9 +17,12 @@ Antes vivían bajo `/ops:` y eran tres: el arranque y el recorrido de equipo le
|
|
|
17
17
|
así que alguien que venía de otro runner los buscaba en la lista y no estaban. Si actualizás una
|
|
18
18
|
instalación vieja, `.gemini/commands/ops/` queda huérfano y se borra a mano.
|
|
19
19
|
|
|
20
|
-
Gemini CLI tiene hooks nativos y el adaptador los usa: `BeforeTool` y `AfterAgent` en
|
|
20
|
+
Gemini CLI tiene hooks nativos y el adaptador los usa: `BeforeTool`, `BeforeAgent` y `AfterAgent` en
|
|
21
21
|
`.gemini/settings.json`, declarados en `manifest.json`. Sólo corren si la carpeta está marcada como
|
|
22
22
|
confiable —`GEMINI.md` explica qué avisa Gemini cuando no lo está—. `read_file` pasa por
|
|
23
|
-
`guard-secrets-read.sh`, que frena leer una credencial; un `cat` por `run_shell_command`
|
|
23
|
+
`guard-secrets-read.sh`, que frena leer una credencial; un `cat` por `run_shell_command` lo frena
|
|
24
|
+
`secrets-shell`, en el grupo de shell.
|
|
25
|
+
`BeforeAgent` registra el mensaje de la persona, para que lo que pidió en el chat pase sin aprobarlo a
|
|
26
|
+
mano; Gemini no le pasa a cada herramienta de qué mensaje viene, así que ahí vale el último.
|
|
24
27
|
|
|
25
28
|
Comprueba la instalación con `node tools/ops.js automation doctor . gemini`.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
|
-
"matcher": "read_file",
|
|
32
|
+
"matcher": "read_file|grep_search",
|
|
33
33
|
"hooks": [
|
|
34
34
|
{
|
|
35
35
|
"type": "command",
|
|
@@ -38,6 +38,16 @@
|
|
|
38
38
|
]
|
|
39
39
|
}
|
|
40
40
|
],
|
|
41
|
+
"BeforeAgent": [
|
|
42
|
+
{
|
|
43
|
+
"hooks": [
|
|
44
|
+
{
|
|
45
|
+
"type": "command",
|
|
46
|
+
"command": "$GEMINI_PROJECT_DIR/{{OPS_DIR}}automatization/hooks/guard-chat.sh"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
],
|
|
41
51
|
"AfterAgent": [
|
|
42
52
|
{
|
|
43
53
|
"hooks": [
|
|
@@ -322,7 +322,7 @@ function uninstall(root, name, output = console) {
|
|
|
322
322
|
|
|
323
323
|
if (fs.existsSync(paths.configTarget)) {
|
|
324
324
|
const current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
|
|
325
|
-
const clean = unmergeConfig(current, runnerConfig(paths, root))
|
|
325
|
+
const clean = unmergeConfig(unmergeConfig(current, runnerConfig(paths, root)), runner.config.retired || {})
|
|
326
326
|
if (clean && Object.keys(clean).length) F.atomicWriteJson(paths.configTarget, clean)
|
|
327
327
|
else { removeFile(paths.configTarget, paths.install); removed += 1 }
|
|
328
328
|
output.log(`✓ ${name}: ${runner.config.target} sin las entradas de Cauce`)
|
|
@@ -389,6 +389,14 @@ function install(root, name, output = console, options = {}) {
|
|
|
389
389
|
? { config: {}, dropped: [] }
|
|
390
390
|
: withoutDeliveredHooks(current, live, previous)
|
|
391
391
|
reportRemoved(name, clean.dropped, live, output)
|
|
392
|
+
// Lo que Cauce entregó en una versión y retiró en otra sin ser un hook, que el merge no saca: las reglas
|
|
393
|
+
// `permissions.deny` que puso el 092 y sacó el 104. Se va exactamente lo que el adaptador declara como
|
|
394
|
+
// retirado; una regla de la empresa que no coincide letra por letra se queda.
|
|
395
|
+
if (runner.config.retired) {
|
|
396
|
+
const before = JSON.stringify(clean.config)
|
|
397
|
+
clean.config = unmergeConfig(clean.config, runner.config.retired) || {}
|
|
398
|
+
if (JSON.stringify(clean.config) !== before) output.log(`− ${name}: quitadas las reglas que Cauce ya no entrega`)
|
|
399
|
+
}
|
|
392
400
|
F.atomicWriteJson(paths.configTarget, mergeConfig(clean.config, incoming))
|
|
393
401
|
// Dónde aterrizó, no sólo qué archivo: en sidecar el destino no es el repo desde el que se corrió
|
|
394
402
|
// el comando, y descubrirlo por sorpresa es la diferencia entre confiar y adivinar.
|
package/engine/hooks/approval.js
CHANGED
|
@@ -20,9 +20,14 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Queda a la vista porque `check` avisa mientras exista. Sin eso, un archivo olvidado sigue autorizando
|
|
22
22
|
// esas mismas rutas la próxima vez que alguien las stagee, que es la puerta abierta que esto evitaba.
|
|
23
|
+
//
|
|
24
|
+
// El archivo es la vía de cuando no hay chat. Con una persona hablando, lo que ella pidió ya está
|
|
25
|
+
// aprobado —cómo se sabe, en `chat.js`—, y escribir el archivo deja de ser necesario (caso 098).
|
|
23
26
|
|
|
24
27
|
const path = require('node:path')
|
|
25
28
|
const fs = require('node:fs')
|
|
29
|
+
const { opsRoot, cwdOf } = require('./input')
|
|
30
|
+
const CHAT = require('./chat')
|
|
26
31
|
|
|
27
32
|
const APPROVAL = '.ops-approval'
|
|
28
33
|
|
|
@@ -35,10 +40,23 @@ function read(root) {
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
// Qué queda sin aprobar de lo que un guard está por bloquear. Se reporta sólo eso: mandar a revisar lo
|
|
38
|
-
// que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje.
|
|
39
|
-
|
|
43
|
+
// que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje. Cuenta también lo que la
|
|
44
|
+
// persona pidió en el chat.
|
|
45
|
+
function pending(root, files, input) {
|
|
40
46
|
const approved = new Set(root ? read(root) : [])
|
|
41
|
-
return files.filter((file) => !approved.has(file))
|
|
47
|
+
return CHAT.unauthorized(input, files.filter((file) => !approved.has(file)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// El archivo que el guard va a leer, nombrado desde la carpeta en la que está la sesión. En sidecar la
|
|
51
|
+
// sesión se abre en el workspace y la instancia es una subcarpeta, así que `planning/` a secas nombraba
|
|
52
|
+
// otro directorio y pegar ahí no destrababa nada (caso 097).
|
|
53
|
+
function where(input) {
|
|
54
|
+
const root = opsRoot(input)
|
|
55
|
+
if (!root) return `planning/${APPROVAL}`
|
|
56
|
+
const file = path.join(root, 'planning', APPROVAL)
|
|
57
|
+
const session = process.env.CLAUDE_PROJECT_DIR || process.env.GEMINI_PROJECT_DIR || cwdOf(input)
|
|
58
|
+
const relative = path.relative(session, file)
|
|
59
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
// Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
|
|
@@ -46,9 +64,21 @@ function pending(root, files) {
|
|
|
46
64
|
// de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
|
|
47
65
|
// decirla, lo que quedaba a mano era la variable (caso 089). Nombra también la variable: sigue
|
|
48
66
|
// existiendo, y esconderla haría que quien la necesite la descubra sin saber su alcance.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
//
|
|
68
|
+
// Con una persona en el chat, además, deja anotado lo que se frenó —para eso llama a `hold`— y lo dice
|
|
69
|
+
// primero: contestar es más corto que editar un archivo, y es lo que la persona ya está haciendo. El
|
|
70
|
+
// archivo queda como cosa de ella: dicho en imperativo, el agente leía «aprobalo» como una orden para él
|
|
71
|
+
// e intentaba escribírselo en vez de reintentar, medido en una sesión real de Claude Code.
|
|
72
|
+
function HOW(variable, lines, input) {
|
|
73
|
+
const chat = CHAT.hold(input, lines)
|
|
74
|
+
return (chat
|
|
75
|
+
? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
|
|
76
|
+
+ 'pasa. Si prefiere aprobarlo a mano, que pegue ella tal cual en'
|
|
77
|
+
: 'Aprobalo pegando tal cual en')
|
|
78
|
+
+ ` ${where(input)} estas líneas:\n`
|
|
79
|
+
+ lines.map((line) => ` ${line}\n`).join('')
|
|
80
|
+
+ `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
|
|
81
|
+
+ 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
|
|
82
|
+
}
|
|
53
83
|
|
|
54
84
|
module.exports = { APPROVAL, read, pending, HOW }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Lo que la persona dijo en el chat, capturado por el runner y no contado por el modelo. Existe porque un
|
|
4
|
+
// guard sólo ve la llamada a la herramienta: frenaba igual lo que la persona pidió con todas las letras y
|
|
5
|
+
// lo que el agente decidió solo, y la única forma de decir «sí» era un archivo que el agente también
|
|
6
|
+
// podía escribirse (caso 098).
|
|
7
|
+
//
|
|
8
|
+
// El hook de mensaje del runner —`UserPromptSubmit` en Claude y Codex, `BeforeAgent` en Gemini— corre
|
|
9
|
+
// cuando la persona manda algo, y Claude y Codex le pasan a cada llamada el identificador del mensaje que
|
|
10
|
+
// la originó. Lo que llega por otro lado —un README, un ticket, el resultado de una herramienta— nunca
|
|
11
|
+
// pasa por acá, y ésa es toda la diferencia entre una orden y una sugerencia.
|
|
12
|
+
//
|
|
13
|
+
// No es un límite de seguridad, como ningún guard: un agente decidido a escribir este registro lo escribe
|
|
14
|
+
// con un script. Lo que se frena es la forma habitual, y la frenan los guards de límites.
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const os = require('node:os')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
// El temporal y no la instancia: el texto de la persona no tiene por qué terminar en un commit, y una
|
|
21
|
+
// orden dura lo que dura la sesión.
|
|
22
|
+
const DIR = path.join(os.tmpdir(), 'cauce-chat')
|
|
23
|
+
|
|
24
|
+
// Los recorridos de Cauce, donde el que piensa es el agente y los guards contienen como siempre. Salen de
|
|
25
|
+
// los workflows que el paquete entrega, así que uno nuevo queda cubierto sin tocar esto; los de
|
|
26
|
+
// `integrations/` se invocan con ese prefijo. Claude y Gemini los llaman con `/`, Codex con `$`.
|
|
27
|
+
const WORKFLOWS = path.join(__dirname, '..', '..', 'automatization', 'workflows')
|
|
28
|
+
const scripts = (dir, prefix) => fs.readdirSync(dir).filter((one) => one.endsWith('.js'))
|
|
29
|
+
.map((one) => `${prefix}${one.slice(0, -3)}`)
|
|
30
|
+
function flowCommand(text) {
|
|
31
|
+
let names = []
|
|
32
|
+
try { names = [...scripts(WORKFLOWS, ''), ...scripts(path.join(WORKFLOWS, 'integrations'), 'integration-')] }
|
|
33
|
+
catch { return false }
|
|
34
|
+
return names.length > 0 && new RegExp(`^\\s*[/$](?:cauce:)?(?:${names.join('|')})(?![\\w-])`).test(text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Claude lo llama `prompt_id` y Codex `turn_id`; Gemini no manda ninguno y ahí vale el último mensaje.
|
|
38
|
+
const idOf = (input) => String(input.prompt_id || input.turn_id || '')
|
|
39
|
+
const recordPath = (session) => path.join(DIR, `${String(session).replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
|
|
40
|
+
|
|
41
|
+
function load(session) {
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(recordPath(session), 'utf8')) } catch { return null }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
|
|
46
|
+
// antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
|
|
47
|
+
const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
|
|
48
|
+
const CLAUSE = /[.,;:!?\n]/
|
|
49
|
+
|
|
50
|
+
// Cada aparición del nombre en el texto y si va negada. Un nombre tiene que estar entero: `.env` no
|
|
51
|
+
// aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
|
|
52
|
+
function mentions(text, item) {
|
|
53
|
+
const lower = String(text).toLowerCase()
|
|
54
|
+
const found = []
|
|
55
|
+
for (const name of new Set([item, path.basename(item)].map((one) => one.toLowerCase()))) {
|
|
56
|
+
if (name.length < 3) continue
|
|
57
|
+
for (let at = lower.indexOf(name); at !== -1; at = lower.indexOf(name, at + 1)) {
|
|
58
|
+
const before = lower[at - 1]
|
|
59
|
+
const rest = lower.slice(at + name.length)
|
|
60
|
+
if (before && !/[\s'"`(/]/.test(before)) continue
|
|
61
|
+
if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
|
|
62
|
+
found.push(NEGATION.test(lower.slice(0, at).split(CLAUSE).pop()))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { named: found.includes(false), denied: found.includes(true) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
|
|
69
|
+
// entera o su primera palabra, no algo que aparece en medio de otra frase.
|
|
70
|
+
const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|adelante|aprobado|apruebo`
|
|
71
|
+
+ String.raw`|aprob[aá]lo|de acuerdo|yes)(?![\p{L}])`, 'iu')
|
|
72
|
+
|
|
73
|
+
// El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
|
|
74
|
+
// deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
|
|
75
|
+
// que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
|
|
76
|
+
function record(input) {
|
|
77
|
+
try {
|
|
78
|
+
if (!input.session_id) return
|
|
79
|
+
const text = String(input.prompt || '')
|
|
80
|
+
const human = !process.env.CI && !/^\s*</.test(text)
|
|
81
|
+
const previous = load(input.session_id)
|
|
82
|
+
const approved = human && previous && YES.test(text)
|
|
83
|
+
? previous.pending.filter((item) => !mentions(text, item).denied)
|
|
84
|
+
: []
|
|
85
|
+
fs.mkdirSync(DIR, { recursive: true })
|
|
86
|
+
fs.writeFileSync(recordPath(input.session_id),
|
|
87
|
+
JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
|
|
88
|
+
} catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// El mensaje de la persona que originó esta llamada, o nada. Nada cuando no hay persona, cuando lo que
|
|
92
|
+
// pidió es un recorrido de Cauce, cuando la llamada la hace un subagente —trabajo que el agente delegó,
|
|
93
|
+
// y Claude lo marca con `agent_id`— o cuando el registro es de otro mensaje.
|
|
94
|
+
function said(input) {
|
|
95
|
+
if (process.env.CI || input.agent_id || !input.session_id) return null
|
|
96
|
+
const saved = load(input.session_id)
|
|
97
|
+
if (!saved || !saved.human || saved.flow) return null
|
|
98
|
+
const current = idOf(input)
|
|
99
|
+
return current && saved.id && current !== saved.id ? null : saved
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Lo que la persona no autorizó de lo que un guard está por frenar: ni lo nombró en su mensaje ni lo
|
|
103
|
+
// aprobó contestando.
|
|
104
|
+
function unauthorized(input, items) {
|
|
105
|
+
const saved = said(input)
|
|
106
|
+
if (!saved) return items
|
|
107
|
+
return items.filter((item) => !saved.approved.includes(item) && !mentions(saved.text, item).named)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
111
|
+
// Devuelve si hay una persona a quien preguntarle.
|
|
112
|
+
function hold(input, items) {
|
|
113
|
+
const saved = said(input)
|
|
114
|
+
if (!saved) return false
|
|
115
|
+
try {
|
|
116
|
+
saved.pending = [...new Set([...saved.pending, ...items])]
|
|
117
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
|
|
118
|
+
return true
|
|
119
|
+
} catch { return false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { DIR, record, said, unauthorized, hold }
|
package/engine/hooks/files.js
CHANGED
|
@@ -8,24 +8,21 @@ const fs = require('node:fs')
|
|
|
8
8
|
const path = require('node:path')
|
|
9
9
|
const { spawnSync } = require('node:child_process')
|
|
10
10
|
const {
|
|
11
|
-
patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot,
|
|
11
|
+
patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot, opsRoot,
|
|
12
12
|
writableRoots, outsideRoots, DECLARE_IT,
|
|
13
13
|
} = require('./input')
|
|
14
14
|
const AP = require('./approval')
|
|
15
|
+
const CHAT = require('./chat')
|
|
16
|
+
const { selfApproval } = require('./self-approval')
|
|
15
17
|
const { readWip } = require('../planning/parser')
|
|
16
18
|
const { runner } = require('../planning/claims')
|
|
17
19
|
const { hasTasks } = require('../planning/state')
|
|
18
20
|
const { TEMPLATE_PREFIXES } = require('../core/ownership')
|
|
19
21
|
|
|
20
|
-
// La raíz donde vive `planning/`, que es donde se busca la aprobación por operación.
|
|
21
|
-
function opsRoot(input) {
|
|
22
|
-
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
22
|
// Si la ruta que este guard está por bloquear está aprobada, no hay nada que decir. Es la salida
|
|
26
23
|
// angosta: vale para esa ruta y deja de valer en cuanto cambie, a diferencia de la variable, que apaga
|
|
27
24
|
// el guard hasta que cierre la sesión.
|
|
28
|
-
const approved = (input, file) => !AP.pending(opsRoot(input), [file]).length
|
|
25
|
+
const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
|
|
29
26
|
|
|
30
27
|
// Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
|
|
31
28
|
// no contesta. Devuelve el motivo del bloqueo o cadena vacía.
|
|
@@ -88,12 +85,20 @@ function secrets(input) {
|
|
|
88
85
|
|
|
89
86
|
// Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
|
|
90
87
|
// grupo porque los guards de escritura frenarían leer fuera de las raíces o con el WIP vacío.
|
|
88
|
+
// Un comodín también nombra: `rg -g '.env*'`, el `glob` del Grep de Claude o el `include_pattern` del
|
|
89
|
+
// `grep_search` de Gemini recorren la carpeta buscando justo eso, y así leyeron el `.env` dos agentes en
|
|
90
|
+
// sesiones reales (caso 104). Se prueba el nombre con el comodín vacío, la forma más corta que el patrón
|
|
91
|
+
// acepta.
|
|
92
|
+
const patternNames = (token) => (/[*?]/.test(token) ? [token.replace(/[*?]/g, '')] : [token]).filter(Boolean)
|
|
93
|
+
|
|
91
94
|
function secretsRead(input) {
|
|
92
95
|
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
const fields = input.tool_input || {}
|
|
97
|
+
const patterns = [fields.glob, fields.include_pattern].filter((one) => typeof one === 'string')
|
|
98
|
+
for (const file of [...filesOf(input), ...patterns]) {
|
|
99
|
+
if (!patternNames(file).some((name) => credential(input, name)) || approved(input, file)) continue
|
|
95
100
|
block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
|
|
96
|
-
+ `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file])}`)
|
|
101
|
+
+ `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file], input)}`)
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
@@ -147,7 +152,7 @@ function testEvidence(input) {
|
|
|
147
152
|
'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
|
|
148
153
|
'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
|
|
149
154
|
'decisión con dueño.\n'
|
|
150
|
-
const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file])
|
|
155
|
+
const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file], input)
|
|
151
156
|
for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
|
|
152
157
|
const removed = match[1].trim()
|
|
153
158
|
if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
|
|
@@ -208,6 +213,10 @@ function isProduct(root, file) {
|
|
|
208
213
|
// vigila: la tarea ya está nombrada y el plan todavía no existe.
|
|
209
214
|
function planFirst(input) {
|
|
210
215
|
if (process.env.OPS_PLAN_FIRST_OVERRIDE === '1') return
|
|
216
|
+
// El plan lo exige el flujo, que es donde piensa el agente. Cuando la persona pide un cambio directo en
|
|
217
|
+
// el chat la que decidió es ella, y frenarla para que escriba un plan o apruebe una ruta es limitarla en
|
|
218
|
+
// lo que acaba de pedir (caso 098). Un subagente o un recorrido de Cauce no cuentan: `said` los descarta.
|
|
219
|
+
if (CHAT.said(input)) return
|
|
211
220
|
const root = opsRoot(input)
|
|
212
221
|
if (!root) return
|
|
213
222
|
const planning = path.join(root, 'planning')
|
|
@@ -227,16 +236,18 @@ function planFirst(input) {
|
|
|
227
236
|
for (const raw of filesOf(input)) {
|
|
228
237
|
if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
|
|
229
238
|
if (approved(input, raw)) continue
|
|
230
|
-
block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw])}`)
|
|
239
|
+
block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)}`)
|
|
231
240
|
}
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
function workspaceBoundary(input) {
|
|
235
244
|
const allowed = writableRoots(input)
|
|
236
|
-
if (!allowed) return
|
|
237
245
|
for (const raw of filesOf(input)) {
|
|
238
246
|
const file = path.resolve(cwdOf(input), raw)
|
|
239
|
-
|
|
247
|
+
// Lo mismo que en `shell-boundary`: la aprobación de la persona se juzga aunque no haya raíces.
|
|
248
|
+
const own = selfApproval(input, file)
|
|
249
|
+
if (own) block(own)
|
|
250
|
+
if (allowed && outsideRoots(file, allowed)) {
|
|
240
251
|
block(`${file} está fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
241
252
|
}
|
|
242
253
|
}
|
|
@@ -301,7 +312,7 @@ function migrations(input) {
|
|
|
301
312
|
if (!esMigracion.test(normalized)) continue
|
|
302
313
|
if (approved(input, normalized)) continue
|
|
303
314
|
if (destructiveSql.test(contentOf(input))) {
|
|
304
|
-
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized])}`)
|
|
315
|
+
block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input)}`)
|
|
305
316
|
}
|
|
306
317
|
// El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
|
|
307
318
|
// lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
|
|
@@ -311,7 +322,7 @@ function migrations(input) {
|
|
|
311
322
|
const shipped = alreadyShipped(file)
|
|
312
323
|
if (shipped) {
|
|
313
324
|
block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
|
|
314
|
-
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized]))
|
|
325
|
+
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
328
|
}
|
|
@@ -342,6 +353,7 @@ function engineWrites(input) {
|
|
|
342
353
|
}
|
|
343
354
|
|
|
344
355
|
module.exports = {
|
|
356
|
+
credential, patternNames,
|
|
345
357
|
secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
|
|
346
358
|
migrations, engineWrites,
|
|
347
359
|
}
|
package/engine/hooks/input.js
CHANGED
|
@@ -262,9 +262,15 @@ function outsideRoots(file, allowed) {
|
|
|
262
262
|
const DECLARE_IT = 'Si el proyecto necesita escribir ahí, declaralo en writableOutsideRoots de '
|
|
263
263
|
+ 'ops.config.json; cambiar de herramienta no lo autoriza.'
|
|
264
264
|
|
|
265
|
+
// La raíz donde vive `planning/`, que es donde se busca la aprobación. La resuelven igual los guards de
|
|
266
|
+
// archivos, los de shell y la aprobación misma, así que se resuelve en un solo lugar.
|
|
267
|
+
function opsRoot(input) {
|
|
268
|
+
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
269
|
+
}
|
|
270
|
+
|
|
265
271
|
module.exports = {
|
|
266
272
|
readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
267
273
|
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit, pushAllowed,
|
|
268
|
-
findOpsRoot,
|
|
274
|
+
findOpsRoot, opsRoot,
|
|
269
275
|
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
270
276
|
}
|
package/engine/hooks/run.js
CHANGED
|
@@ -12,6 +12,8 @@ const path = require('node:path')
|
|
|
12
12
|
const { readInput, cwdOf, block, findOpsRoot } = require('./input')
|
|
13
13
|
const shell = require('./shell')
|
|
14
14
|
const files = require('./files')
|
|
15
|
+
const chat = require('./chat')
|
|
16
|
+
const { secretsShell } = require('./secrets-shell')
|
|
15
17
|
|
|
16
18
|
function planningDrift(input) {
|
|
17
19
|
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
@@ -39,6 +41,7 @@ const guards = {
|
|
|
39
41
|
governance: shell.governance,
|
|
40
42
|
verify: shell.verify,
|
|
41
43
|
'shell-boundary': shell.shellBoundary,
|
|
44
|
+
'secrets-shell': secretsShell,
|
|
42
45
|
secrets: files.secrets,
|
|
43
46
|
generated: files.generated,
|
|
44
47
|
'workspace-boundary': files.workspaceBoundary,
|
|
@@ -48,15 +51,18 @@ const guards = {
|
|
|
48
51
|
'test-evidence': files.testEvidence,
|
|
49
52
|
'plan-first': files.planFirst,
|
|
50
53
|
'secrets-read': files.secretsRead,
|
|
54
|
+
chat: chat.record,
|
|
51
55
|
'planning-drift': planningDrift,
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// Grupos por evento: un runner corre el grupo entero en un solo proceso en lugar de un guard por hook.
|
|
55
59
|
const hookGroups = {
|
|
56
|
-
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'
|
|
60
|
+
'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary',
|
|
61
|
+
'secrets-shell'],
|
|
57
62
|
'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
|
|
58
63
|
'integration-snapshot', 'test-evidence', 'plan-first'],
|
|
59
64
|
'pre-read': ['secrets-read'],
|
|
65
|
+
prompt: ['chat'],
|
|
60
66
|
stop: ['planning-drift'],
|
|
61
67
|
}
|
|
62
68
|
|
|
@@ -89,7 +95,8 @@ const hookMetadata = [
|
|
|
89
95
|
{
|
|
90
96
|
name: 'shell-boundary',
|
|
91
97
|
event: 'PreToolUse · shell',
|
|
92
|
-
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas
|
|
98
|
+
purpose: 'Frena el destino evidente de un comando que escribe fuera de las raíces declaradas, o en la '
|
|
99
|
+
+ 'aprobación de la persona.',
|
|
93
100
|
},
|
|
94
101
|
{
|
|
95
102
|
name: 'secrets',
|
|
@@ -101,11 +108,18 @@ const hookMetadata = [
|
|
|
101
108
|
event: 'PreToolUse · read',
|
|
102
109
|
purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
|
|
103
110
|
},
|
|
111
|
+
{
|
|
112
|
+
name: 'secrets-shell',
|
|
113
|
+
event: 'PreToolUse · shell',
|
|
114
|
+
purpose: 'Bloquea leer por shell una credencial conocida o declarada; lo que la persona pidió en el chat '
|
|
115
|
+
+ 'pasa.',
|
|
116
|
+
},
|
|
104
117
|
{ name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
|
|
105
118
|
{
|
|
106
119
|
name: 'workspace-boundary',
|
|
107
120
|
event: 'PreToolUse · files',
|
|
108
|
-
purpose: 'Limita escrituras a las raíces declaradas en ops.config.json
|
|
121
|
+
purpose: 'Limita escrituras a las raíces declaradas en ops.config.json, y no deja al agente escribirse '
|
|
122
|
+
+ 'la aprobación de la persona.',
|
|
109
123
|
},
|
|
110
124
|
{
|
|
111
125
|
name: 'engine',
|
|
@@ -133,6 +147,12 @@ const hookMetadata = [
|
|
|
133
147
|
event: 'PreToolUse · files',
|
|
134
148
|
purpose: 'Exige WIP activo con plan escrito antes de cambiar el producto.',
|
|
135
149
|
},
|
|
150
|
+
{
|
|
151
|
+
name: 'chat',
|
|
152
|
+
event: 'UserPromptSubmit / BeforeAgent',
|
|
153
|
+
purpose: 'Registra el mensaje de la persona: lo que nombró o aprobó en el chat pasa sin archivo. Nunca '
|
|
154
|
+
+ 'bloquea.',
|
|
155
|
+
},
|
|
136
156
|
{
|
|
137
157
|
name: 'planning-drift',
|
|
138
158
|
event: 'Stop / SessionEnd',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Leer una credencial por shell. `secrets-read` mira la herramienta de lectura del runner, y un `cat .env`
|
|
4
|
+
// no pasa por ella: en Claude lo tapaba una regla nativa `permissions.deny`, que además frenaba lo que la
|
|
5
|
+
// persona pedía, y en los otros runners no lo tapaba nada (caso 104). Esto lo mira donde pasan todos los
|
|
6
|
+
// comandos, con la misma salida que el resto: lo que la persona pidió en el chat pasa.
|
|
7
|
+
//
|
|
8
|
+
// Como todo lo que lee el texto de un comando, frena la forma habitual y no un script decidido: un nombre
|
|
9
|
+
// armado en una variable o un `grep -r` sobre la carpeta sin nombrar el archivo pasan.
|
|
10
|
+
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { commandOf, cwdOf, block, isCommit, unquoted, opsRoot } = require('./input')
|
|
13
|
+
const { credential, patternNames } = require('./files')
|
|
14
|
+
const AP = require('./approval')
|
|
15
|
+
|
|
16
|
+
// Lo que muestra el contenido de un archivo. Queda afuera lo que sólo lo nombra —`ls`, `test -f`, `rm`,
|
|
17
|
+
// `git add`—, que no deja nada en la sesión, y `cp` y `mv`, cuyo último argumento es un destino: `cp
|
|
18
|
+
// .env.example .env` es preparar el entorno, no leerlo. `nl` entró porque un agente lo usó en una sesión
|
|
19
|
+
// real para leer el `.env` después de que la lista no lo tuviera.
|
|
20
|
+
const READERS = new Set(['cat', 'tac', 'nl', 'head', 'tail', 'less', 'more', 'bat', 'sed', 'awk', 'grep', 'egrep',
|
|
21
|
+
'fgrep', 'rg', 'strings', 'xxd', 'od', 'hexdump', 'base64', 'cut', 'sort', 'uniq', 'rev', 'paste', 'fold', 'pr',
|
|
22
|
+
'dd', 'jq', 'yq', 'diff', 'cmp', 'source', '.', 'node', 'python', 'python3', 'ruby', 'perl', 'php', 'deno', 'bun'])
|
|
23
|
+
const PREFIXES = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nohup', 'nice', 'xargs'])
|
|
24
|
+
|
|
25
|
+
// El verbo de un tramo, saltando lo que va delante sin serlo: asignaciones, prefijos y sus banderas.
|
|
26
|
+
function verbOf(words) {
|
|
27
|
+
let prefixed = false
|
|
28
|
+
while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || PREFIXES.has(words[0])
|
|
29
|
+
|| (prefixed && words[0].startsWith('-')))) {
|
|
30
|
+
prefixed = prefixed || PREFIXES.has(words[0])
|
|
31
|
+
words.shift()
|
|
32
|
+
}
|
|
33
|
+
return words[0] || ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Las palabras de cada tramo que lee: el que empieza con un lector, o el que redirige un archivo a la
|
|
37
|
+
// entrada. Lo entrecomillado se mira, porque el código de un `node -e` nombra el archivo ahí adentro.
|
|
38
|
+
function readTokens(command) {
|
|
39
|
+
const found = []
|
|
40
|
+
for (const segment of command.split(/[;&|\n]+|\$\(|`/)) {
|
|
41
|
+
const words = segment.trim().replace(/^[({]+\s*/, '').split(/\s+/).filter(Boolean)
|
|
42
|
+
const reads = READERS.has(path.basename(verbOf(words))) || /<(?![<(])/.test(segment)
|
|
43
|
+
if (reads) found.push(...(segment.match(/[^\s'"`\\;|&<>(){}=,]+/g) || []))
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(found)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function secretsShell(input) {
|
|
49
|
+
if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
|
|
50
|
+
const raw = commandOf(input)
|
|
51
|
+
// El mensaje de un commit es dato, como en `destructive`: nombrar el archivo ahí no lo lee.
|
|
52
|
+
const command = isCommit(raw) ? unquoted(raw) : raw
|
|
53
|
+
const cwd = cwdOf(input)
|
|
54
|
+
const files = readTokens(command).filter((token) => patternNames(token).some((name) => credential(input, name)))
|
|
55
|
+
.map((token) => path.resolve(cwd, token))
|
|
56
|
+
const left = AP.pending(opsRoot(input), [...new Set(files)], input)
|
|
57
|
+
if (!left.length) return
|
|
58
|
+
block(`el comando lee ${left.join(', ')}, que es una credencial: leerla la deja en el contexto de la sesión. `
|
|
59
|
+
+ `Si hace falta un valor, pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { secretsShell }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El canal por el que una persona dice «sí» no lo puede escribir el agente: si pudiera, cada bloqueo que
|
|
4
|
+
// ofrece aprobarse se aprobaría solo. Pasaba —ningún guard miraba `planning/.ops-approval`— y en la prueba
|
|
5
|
+
// en vivo del 092 el agente, frenado, intentó escribírsela (caso 098).
|
|
6
|
+
//
|
|
7
|
+
// Lo preguntan los dos guards de límites —el que mira un `Write` y el que mira el destino de un comando—,
|
|
8
|
+
// que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
|
|
9
|
+
// hook ve, o se lo pide al agente nombrándolo en el chat. El registro del chat no lo escribe nunca una
|
|
10
|
+
// herramienta.
|
|
11
|
+
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
const { opsRoot } = require('./input')
|
|
14
|
+
const AP = require('./approval')
|
|
15
|
+
const CHAT = require('./chat')
|
|
16
|
+
|
|
17
|
+
// Por qué el agente no puede escribir en `file`, o vacío.
|
|
18
|
+
function selfApproval(input, file) {
|
|
19
|
+
if (file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)) {
|
|
20
|
+
return `${file} es el registro de lo que la persona dijo en el chat: lo escribe el runner, nunca una `
|
|
21
|
+
+ 'herramienta.'
|
|
22
|
+
}
|
|
23
|
+
const root = opsRoot(input)
|
|
24
|
+
if (!root || file !== path.join(root, 'planning', AP.APPROVAL)) return ''
|
|
25
|
+
if (!CHAT.unauthorized(input, [file]).length) return ''
|
|
26
|
+
return `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la persona quiere `
|
|
27
|
+
+ 'autorizar algo, que lo diga en el chat —nombrándolo, o contestando «dale» al bloqueo— o que edite el '
|
|
28
|
+
+ 'archivo ella.'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { selfApproval }
|
package/engine/hooks/shell.js
CHANGED
|
@@ -10,9 +10,10 @@ const path = require('node:path')
|
|
|
10
10
|
const { spawnSync } = require('node:child_process')
|
|
11
11
|
const {
|
|
12
12
|
commandOf, cwdOf, block, isCommit, stagedForCommit, pushAllowed,
|
|
13
|
-
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
13
|
+
writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
|
+
const { selfApproval } = require('./self-approval')
|
|
16
17
|
const EV = require('../core/evidence')
|
|
17
18
|
|
|
18
19
|
// Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
|
|
@@ -48,12 +49,6 @@ const MISMO = String.raw`[^;&|\n]`
|
|
|
48
49
|
// `bash -c "git push origin main"` y `eval "git reset --hard"` siguen cayendo, comprobado. Queda afuera
|
|
49
50
|
// la sustitución dentro del propio mensaje —`git commit -m "$(...)"` corre y ya no se ve—, que es
|
|
50
51
|
// evasión y no la forma habitual.
|
|
51
|
-
// La raíz donde vive `planning/`, que es donde se busca la aprobación. Los cuatro guards que la
|
|
52
|
-
// consultan la resuelven igual, así que se resuelve una vez.
|
|
53
|
-
function opsRoot(input) {
|
|
54
|
-
return findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
52
|
function destructive(input) {
|
|
58
53
|
const raw = commandOf(input)
|
|
59
54
|
// Las opciones globales de `git` se sacan acá y no en cada regla: toda regla de abajo que mire un
|
|
@@ -181,7 +176,7 @@ function dependencies(input) {
|
|
|
181
176
|
// `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
|
|
182
177
|
// conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
|
|
183
178
|
const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
|
|
184
|
-
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
|
|
179
|
+
names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
|
|
185
180
|
// Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
|
|
186
181
|
// disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
|
|
187
182
|
// en el próximo commit— y ahí la comprobación dejaba de dispararse justo cuando más hacía falta. Es
|
|
@@ -204,12 +199,12 @@ function dependencies(input) {
|
|
|
204
199
|
const manifests = sinAprobar(parent, state.manifests)
|
|
205
200
|
if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
|
|
206
201
|
block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
|
|
207
|
-
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests))
|
|
202
|
+
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
|
|
208
203
|
}
|
|
209
204
|
const lockfiles = sinAprobar(parent, state.locks)
|
|
210
205
|
if (state.locks.length && !state.manifests.length && lockfiles.length) {
|
|
211
206
|
block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.\n`
|
|
212
|
-
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles))
|
|
207
|
+
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles, input))
|
|
213
208
|
}
|
|
214
209
|
}
|
|
215
210
|
}
|
|
@@ -306,17 +301,21 @@ function writesWithBase(command, cwd) {
|
|
|
306
301
|
|
|
307
302
|
function shellBoundary(input) {
|
|
308
303
|
const allowed = writableRoots(input)
|
|
309
|
-
if (!allowed) return
|
|
310
304
|
for (const { raw, base } of writesWithBase(commandOf(input), cwdOf(input))) {
|
|
311
305
|
// Una ruta absoluta no depende del `cd`, así que un destino que no se sabe no la vuelve injuzgable.
|
|
312
306
|
// Al revés sí: sin saber desde dónde se resuelve, una relativa no se puede verificar, y un guard que
|
|
313
307
|
// no puede verificar no autoriza —el criterio que fijó el 031 para el índice—.
|
|
314
308
|
if (!path.isAbsolute(raw) && base === null) {
|
|
309
|
+
if (!allowed) continue
|
|
315
310
|
block(`el comando hace \`cd\` a un destino que no se puede resolver acá, así que no hay contra qué `
|
|
316
311
|
+ `resolver ${raw}. Escribí la ruta absoluta, o hacé el \`cd\` en un comando aparte.`)
|
|
317
312
|
}
|
|
318
|
-
const file = path.resolve(base, raw)
|
|
319
|
-
|
|
313
|
+
const file = path.resolve(base || '/', raw)
|
|
314
|
+
// El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
|
|
315
|
+
// declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
|
|
316
|
+
const own = selfApproval(input, file)
|
|
317
|
+
if (own) block(own)
|
|
318
|
+
if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
|
|
320
319
|
if (outsideRoots(file, allowed)) {
|
|
321
320
|
block(`el comando escribe en ${file}, fuera de las raíces declaradas en ops.config.json. ${DECLARE_IT}`)
|
|
322
321
|
}
|
|
@@ -347,9 +346,9 @@ function governance(input) {
|
|
|
347
346
|
// La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
|
|
348
347
|
// reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
|
|
349
348
|
// entre una llave por operación y una puerta que quedó abierta.
|
|
350
|
-
const pendientes = AP.pending(opsRoot(input), governed)
|
|
349
|
+
const pendientes = AP.pending(opsRoot(input), governed, input)
|
|
351
350
|
if (!pendientes.length) return
|
|
352
|
-
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes)}`)
|
|
351
|
+
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
|
|
353
352
|
}
|
|
354
353
|
|
|
355
354
|
function run(program, args, cwd, extra = {}) {
|
|
@@ -488,20 +487,20 @@ function verify(input) {
|
|
|
488
487
|
// Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
|
|
489
488
|
// es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
|
|
490
489
|
// y no de una regla, que es lo que la vuelve una operación y no un permiso.
|
|
491
|
-
const sinAprobar = AP.pending(opsRoot(input), staged)
|
|
490
|
+
const sinAprobar = AP.pending(opsRoot(input), staged, input)
|
|
492
491
|
const aprobado = !sinAprobar.length
|
|
493
492
|
if (changedOpenApi && !hasApiGenerated && !aprobado) {
|
|
494
493
|
block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
|
|
495
|
-
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar)}`)
|
|
494
|
+
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
|
|
496
495
|
}
|
|
497
496
|
if (changedSqlSource && !hasSqlGenerated && !aprobado) {
|
|
498
497
|
block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
|
|
499
|
-
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
|
|
498
|
+
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
500
499
|
}
|
|
501
500
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
502
501
|
const { root, temp, env } = commitTree(dir)
|
|
503
502
|
try {
|
|
504
|
-
verifyGates(root, dir, sinAprobar, env,
|
|
503
|
+
verifyGates(root, dir, sinAprobar, env, input)
|
|
505
504
|
} finally {
|
|
506
505
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
507
506
|
}
|
|
@@ -522,11 +521,15 @@ function verify(input) {
|
|
|
522
521
|
// Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
|
|
523
522
|
// que hace falta para diagnosticar es la primera línea de error, no el volcado.
|
|
524
523
|
const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
|
|
525
|
-
// Cómo marca un reporte de pruebas cada resultado
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
524
|
+
// Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
|
|
525
|
+
// 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
|
|
526
|
+
// quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
|
|
527
|
+
// `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
|
|
528
|
+
// `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
|
|
529
|
+
// pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
|
|
530
|
+
// no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
|
|
531
|
+
const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
|
|
532
|
+
const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
|
|
530
533
|
const MAX_LINE = 160
|
|
531
534
|
function fallo(gate, result) {
|
|
532
535
|
// La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
|
|
@@ -555,7 +558,8 @@ function comoSeLee(failures) {
|
|
|
555
558
|
+ 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
|
|
556
559
|
}
|
|
557
560
|
|
|
558
|
-
function verifyGates(root, dir, sinAprobar, env,
|
|
561
|
+
function verifyGates(root, dir, sinAprobar, env, input) {
|
|
562
|
+
const ops = opsRoot(input)
|
|
559
563
|
const failures = []
|
|
560
564
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
561
565
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
@@ -595,7 +599,7 @@ function verifyGates(root, dir, sinAprobar, env, ops) {
|
|
|
595
599
|
const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
|
|
596
600
|
+ 'directorio pasa, es que en disco tenés algo que no está staged.'
|
|
597
601
|
block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
|
|
598
|
-
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
|
|
602
|
+
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
599
603
|
}
|
|
600
604
|
|
|
601
605
|
module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -103,9 +103,19 @@ Un guard existente **no se edita**: `upgrade` detecta el cambio y se detiene ant
|
|
|
103
103
|
Algunos bloqueos tienen salida, y conviene saber cuál antes de necesitarla — el momento en que un guard
|
|
104
104
|
te frena es el peor para elegir bien.
|
|
105
105
|
|
|
106
|
-
**
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
**Si lo pediste vos en el chat, no hace falta nada.** Los guards contienen al agente cuando decide solo o
|
|
107
|
+
cuando trabaja dentro de un recorrido; lo que vos pedís directo no se frena. Nombrá lo que querés que
|
|
108
|
+
toque —«borrá la prueba de altas», «reescribí la migración 004»— y pasa sin preguntarte de nuevo. Si tu
|
|
109
|
+
pedido no lo nombraba y algo se frena, el agente te dice qué y por qué: contestá «dale» y pasa exactamente
|
|
110
|
+
eso. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
|
|
111
|
+
por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
|
|
112
|
+
Antigravity, y cuando nadie está en el chat —CI, un recorrido, un subagente—, queda el archivo de abajo.
|
|
113
|
+
|
|
114
|
+
**Sin chat, la salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que
|
|
115
|
+
autorizás, una por línea, con `#` para lo que no sea una ruta. En sidecar es el `planning/` de la
|
|
116
|
+
instancia y no una carpeta al lado de tus proyectos; el bloqueo dice la ruta exacta. Es un solo archivo
|
|
117
|
+
para todos los guards, porque lo que escribís son rutas y quién las mira lo decide qué guard esté
|
|
118
|
+
juzgando esa ruta. Lo escribís vos: si el agente intenta escribírselo, un guard lo frena.
|
|
109
119
|
|
|
110
120
|
| lo que te frena | qué ruta aprobás |
|
|
111
121
|
|---|---|
|