@ingeniomaps/cauce 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,168 @@ 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.84.0] - 2026-09-12
18
+
19
+ ### Corregido
20
+
21
+ - **Una frase en inglés que prohíbe ya no autoriza.** Cauce reconoce si le negás algo antes de dejarlo
22
+ pasar, y en inglés sólo entendía `don't`: «the tool doesn't read the .env» o «we can't read the .env»
23
+ nombraban el archivo, traían un verbo y ninguna negación reconocida, así que **una frase que prohíbe
24
+ terminaba autorizando**. Entran ahora las contraídas con auxiliar —`doesn't`, `isn't`, `can't`,
25
+ `won't`, `wasn't`, `wouldn't`, `shouldn't`…—, con o sin apóstrofo.
26
+
27
+ **Qué cambia para vos**: nada que hacer. Si trabajás en inglés, negar algo ahora se respeta; lo que antes
28
+ pasaba por no entenderse, se frena. Una frase como «I can't tell if the .env is right» deja de autorizar
29
+ y cuesta un «dale», que es el mismo costo que el español ya tenía.
30
+
31
+ - **La entrada de DONE dice contra qué reglas se revisó.** `autobuild` exige que Review nombre las reglas
32
+ contra las que revisó —si no las nombra, la corrida para—, pero esa lista quedaba sólo en el registro de
33
+ la corrida, que no sobrevive. Ahora va también en la línea `review=` de `DONE.md`, que es lo que queda.
34
+
35
+ **Qué cambia para vos**: nada que hacer. Al auditar una entrega vieja vas a poder decir contra qué se
36
+ revisó sin depender del registro de aquella corrida.
37
+
38
+ - **Lo que pedís vos ya no se frena sin ofrecerte ninguna salida.** Siete de los guards que pueden frenar
39
+ nunca consultaban si lo habías pedido: no tenían variable, ni línea que pegar en
40
+ `planning/.ops-approval`, ni «dale». Entre ellos estaban las reglas que cubren trabajo de todos los días
41
+ —`git reset --hard`, `git clean -f`, `git checkout -- .`, `docker compose down`, `docker system prune`—
42
+ y los que frenan escribir una credencial, un archivo generado, un snapshot del sincronizador o el motor
43
+ instalado. Pedirlos con todas las letras no servía de nada: se frenaban igual que si los hubiera
44
+ decidido el agente por su cuenta.
45
+
46
+ Ahora preguntan lo mismo que el resto de los guards: si lo pediste en el chat pasa, y si no, el bloqueo
47
+ te dice qué se frenó y un «dale» lo aprueba. De paso se cierra una asimetría que nadie había decidido:
48
+ **leer** una credencial se podía aprobar desde 0.80.0 y **escribirla** no.
49
+
50
+ **Lo que no cambia, y es a propósito**: reescribir historia publicada —`git push --force`,
51
+ `git commit --amend`—, el borrado de disco, `rm -r` sobre la raíz, el home o el directorio padre, y
52
+ `git add -A`. Ésos no se abren ni pidiéndolos, porque R8 y R23 no admiten excepción configurable.
53
+
54
+ **Qué cambia para vos**: nada que hacer ni que reinstalar. Lo que antes te obligaba a exportar una
55
+ variable para poder trabajar —o directamente no tenía salida— ahora se destraba diciéndolo. El permiso
56
+ es angosto a propósito: vale para el comando entero tal como lo escribiste, así que otra bandera es otro
57
+ comando y se vuelve a preguntar.
58
+
59
+ ## [0.83.0] - 2026-09-11
60
+
61
+ ### Agregado
62
+
63
+ - **Autorizar en el chat ahora se dice como se dice.** Un guard dejaba pasar lo que pedías con un verbo de
64
+ acción —«leé el .env», «usá esa ruta»— y se seguía frenando si lo decías autorizando: «autorizo la
65
+ lectura del .env» no traía ninguna palabra que Cauce reconociera, así que había que repetirlo con otras
66
+ palabras, contestar «dale», o escribir la línea a mano. Entran las formas con que una persona concede, en
67
+ español y en inglés: `autorizo`, `autorizá`, `te autorizo`, `permito`, `apruebo`, `habilito`,
68
+ `authorize`, `allow`, `grant`, `approved`.
69
+
70
+ Lo que **no** cambia es la línea de siempre: nombrar algo no lo autoriza. Los sustantivos que nombran el
71
+ acto —«la lectura del .env», «¿hace falta autorizacion?»— siguen sin autorizar nada, porque aparecen
72
+ igual en una pregunta que no pide nada. Y negar sigue negando, también con las palabras nuevas.
73
+
74
+ **Qué cambia para vos**: si autorizás algo diciendo que lo autorizás, pasa a la primera. Antes eso
75
+ costaba una vuelta, y a veces terminaba en la variable que apaga el guard para toda la sesión.
76
+
77
+ - **El permiso de push ya no se lo puede escribir el agente.** `runner.allowPush` y
78
+ `runner.pushToLiveBranches` deciden qué push se publica y viven en `ops.config.json`, que hasta ahora
79
+ ningún guard miraba: el bloqueo que dice «esto lo decide una persona» se levantaba editando ese mismo
80
+ archivo, con la herramienta de escritura o con un `sed -i`. Lo cierran dos guards nuevos: `ops-config`
81
+ compara lo que se va a escribir contra lo que hay en disco y frena si cambia alguna de esas dos llaves,
82
+ y `ops-config-shell` frena toda escritura por shell sobre el archivo, porque un comando no dice con qué
83
+ va a quedar.
84
+
85
+ **Qué cambia para vos**: el resto de `ops.config.json` —`workspaceRoots`, `writableOutsideRoots`,
86
+ `migrations`, lo que sea— se sigue editando igual que siempre; sólo esas dos llaves pasan a necesitar una
87
+ persona, que las pone editando el archivo ella o pidiéndoselo al agente en el chat nombrando
88
+ `ops.config.json`. Por shell el archivo queda cerrado entero: ese cambio va por la herramienta de
89
+ escritura y con el archivo completo, que es lo único que el guard puede comparar. Si tu runner registra
90
+ los guards de a uno en vez de por grupo, corré `automation install` para que aparezcan
91
+ `guard-ops-config.sh` y `guard-ops-config-shell.sh`.
92
+
93
+ - **Un push aprobado deja constancia en `planning/.push-log`.** Cuando Cauce deja pasar un `git push` por
94
+ una aprobación —la orden en el chat, un «dale», o la línea exacta en `planning/.ops-approval`— anexa una
95
+ línea con la fecha en que se autorizó, el remoto, la rama, por qué vía y la sesión. Por
96
+ `runner.allowPush` no anota nada: esa autorización ya está escrita en `ops.config.json`.
97
+
98
+ El archivo sólo agrega, no guarda el texto de lo que escribiste, y si no se puede escribir no frena el
99
+ push. La fecha se llama `authorizedAt` y no `at` a propósito: el hook corre antes del comando, así que la
100
+ línea dice que el push **se autorizó**, no que se haya publicado.
101
+
102
+ El anillo «Local» de `planning/delivery/teamwork.md` lo nombra junto al resto de lo que no viaja por git.
103
+ Ese archivo lo mantiene Cauce, así que `upgrade` te lo reemplaza: si lo editaste, mirá el diff antes.
104
+
105
+ **Qué cambia para vos**: si necesitás responder quién autorizó un push y cuándo, ahora hay de dónde
106
+ 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
107
+ persona: Cauce no tiene identidad de usuario—. Las instancias nuevas lo traen gitignoreado; si ya tenés
108
+ una, agregá `planning/.push-log` a tu `.gitignore`.
109
+
110
+ - **Lo que un recorrido deja en el INBOX dice ahora de qué vía entró.** Cada línea que `autobuild`, `flow` y
111
+ `onboard` le pasan al agente que escribe viene con su procedencia al final —`(autobuild · t-012 ·
112
+ 2026-09-11)`, `(flow · planning/reports/2026-09-11-alta.md · 2026-09-11)`, `(onboard · 2026-09-11)`—: el
113
+ recorrido, la unidad de la que salió y la fecha. La arma el recorrido y el pedido dice que se copie tal
114
+ cual, en vez de confiar en que el agente se acuerde de escribirla. El formato de una entrada no cambió ni
115
+ el molde tampoco, así que `ops tree`, `check` y `context --json` siguen leyendo exactamente lo mismo.
116
+
117
+ **Qué cambia para vos**: nada que hacer. Al recorrer el INBOX vas a ver el origen en lo que se escriba de
118
+ acá en adelante; lo que ya tenés escrito no lo gana, y una entrada que escribas a mano sigue sin él,
119
+ porque el INBOX es tuyo. Si en algún lado recortás el ancho de esas líneas, contá entre 20 y 60
120
+ caracteres más por entrada: el tope de 240 pasó a medir la línea entera, así que lo que cede es el texto
121
+ del hallazgo y nunca el origen.
122
+
123
+ ### Cambiado
124
+
125
+ - **Lo que autorizás en el chat no alcanza a los gates de un commit.** Lo que un guard deja pasar porque
126
+ vos lo pediste sigue valiendo mientras dure la sesión, con dos excepciones: publicar, y los gates de un
127
+ commit —`governance`, `verify` y `dependencies`—, que preguntan cada vez. Lo que autoriza un commit no
128
+ dice nada del siguiente, porque el índice ya es otro. Por el mismo motivo, nombrar `ops.config.json` una
129
+ vez ya no deja escribir `runner.allowPush` ni `runner.pushToLiveBranches` por el resto de la sesión: ahí
130
+ también se pregunta cada vez.
131
+
132
+ **Qué cambia para vos**: si commiteás varias veces seguidas tocando gobernanza, con un gate en rojo o con
133
+ un manifiesto sin su lockfile, vas a decir «dale» en cada commit en vez de una vez por sesión. Lo que no
134
+ es un gate de commit —leer un `.env`, reescribir una migración, borrar una prueba— sigue valiendo toda la
135
+ sesión.
136
+
137
+ ### Corregido
138
+
139
+ - **Un bloqueo ya no te ofrece pegar una ruta que el shell no expandió.** Cuando el comando nombra la
140
+ credencial a través de una variable —`cat ops/$O/.env.infisical`—, Cauce no puede saber qué archivo es:
141
+ esa variable la expande tu shell, que es otro proceso. Antes el bloqueo ofrecía igual esa línea para
142
+ `planning/.ops-approval`, y pegarla no servía: sólo volvía a valer si el comando se repetía escrito
143
+ igual, y dejaba de valer apenas alguien escribía la ruta de verdad. Con la forma `${O}` era peor, porque
144
+ la línea ofrecida ni siquiera era el archivo que el comando lee. Ahora no la ofrece, dice por qué y qué
145
+ hacer.
146
+
147
+ **Qué cambia para vos**: lo que un bloqueo te ofrece pegar es siempre una línea que después funciona. Si
148
+ armás la ruta con variables, escribila entera en ese comando —o contestá «dale», que destraba igual—.
149
+
150
+ - **Lo que autorizás en el chat ya no deja de valer con el mensaje siguiente.** Hasta 0.82.0 el permiso
151
+ duraba un mensaje: pedías leer el `.env`, el agente lo leía, y apenas escribías cualquier otra cosa
152
+ —«gracias, seguí»— el mismo archivo volvía a frenarse. Ahora lo que un guard deja pasar porque vos lo
153
+ pediste queda anotado y sigue valiendo mientras la sesión siga. Lo revoca decirlo: «no toques el .env» lo
154
+ saca, y lo revocado no vuelve solo.
155
+
156
+ El permiso sigue siendo angosto: vale para el ítem tal como el guard lo nombra, no cruza de sesión y no
157
+ alcanza a un subagente ni a un recorrido de Cauce. Y **no alcanza a lo que vuelve a tener consecuencia
158
+ cada vez**: publicar y los gates de un commit, que preguntan siempre —ver la entrada de abajo—. Volver a
159
+ leer lo que ya se leyó no agrega consecuencia; volver a publicar, o commitear otra vez con otro índice,
160
+ sí.
161
+
162
+ **Qué cambia para vos**: si trabajás con credenciales o con rutas que los guards vigilan, alcanza con
163
+ pedirlo una vez por sesión en lugar de repetir la frase o contestar «dale» a cada rato. Si querés cortar
164
+ un permiso antes de cerrar la sesión, decilo nombrando el archivo.
165
+
166
+ - **El agente no puede escribir en tu aprobación una línea que vos no pediste.** `planning/.ops-approval`
167
+ sólo lo podía escribir el agente si vos nombrabas el archivo en el chat, y ahí se terminaba el control:
168
+ pedías agregar `src/login.js` y podía escribir `push origin main`, que es la línea que autoriza publicar
169
+ en la rama viva. Ahora lo que se escribe se compara línea por línea con lo que pediste: pasan las que
170
+ nombraste, y cualquier otra frena la escritura mostrándote cuál era. Las que ya estaban en el archivo no
171
+ hay que volver a nombrarlas, y quitar líneas no pide permiso. Por shell —un `echo >>`, un `sed -i`— el
172
+ archivo queda cerrado: un comando no dice con qué va a quedar, así que no hay contenido que comparar.
173
+
174
+ **Qué cambia para vos**: si le pedís al agente que te agregue rutas, nombralas en el mismo mensaje
175
+ —«agregá `src/login.js` y `src/altas.js` a `.ops-approval`»—; si frena, te dice exactamente qué línea
176
+ quería escribir y un «dale» aprueba esa y nada más. Vos seguís editando el archivo a mano como siempre,
177
+ 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.
178
+
17
179
  ## [0.82.0] - 2026-09-11
18
180
 
19
181
  ### 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 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.
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` |
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Shim: delega `ops-config-shell` en run-hook.sh; el registro de engine/hooks/run.js nombra su módulo.
3
+ exec "$(dirname "$0")/run-hook.sh" ops-config-shell
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Shim: delega `ops-config` en run-hook.sh; el registro de engine/hooks/run.js nombra su módulo.
3
+ exec "$(dirname "$0")/run-hook.sh" ops-config
@@ -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
- return first.length > 240 ? `${first.slice(0, 239)}…` : first
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. Por nombre, ${known}: lo que ya esté con uno de esos nombres no se ` +
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
  }
@@ -790,13 +790,20 @@ while (rounds++ < MAX_TASKS) {
790
790
  if (governing.length) log(`Review contra: ${review.rules.join(', ')}`)
791
791
  // Aprobar sin declarar qué se abrió no se arregla mandando a tocar código: falló quien revisó.
792
792
  if (!review.consulted.length) return stop('review-unbacked', 'aprobó el diff sin declarar qué inspeccionó')
793
+ // Contra qué reglas se revisó va también a la entrada de DONE, y no sólo al journal: el journal muere
794
+ // con la corrida y la entrada queda, así que sin esto no había cómo reconstruir contra cuáles se
795
+ // revisó cuando alguien audita la entrega meses después (caso 122).
793
796
  reviewFact = `${review.verdict} por ${cast.review}, sobre ${review.consulted.join(', ')}`
797
+ + ((review.rules || []).length ? ` · reglas: ${review.rules.join(', ')}` : '')
794
798
  // Lo que no impide entregar no manda a tocar código, y tampoco desaparece: la mejora opinable que se
795
799
  // corrige a las apuradas cuesta una vuelta y un riesgo que nadie pidió. Va a Propuestas y no a
796
800
  // Lecciones porque lo que la revisión anotó es un cambio del producto —su evidencia es la de la
797
801
  // tarea, que queda en `done/`—; Lecciones es sobre cómo trabajamos, y ahí el hallazgo queda
798
802
  // esperando una promoción que nadie va a hacer.
799
- const noted = review.concerns.filter((one) => !one.blocking).map((one) => oneLine(one.detail))
803
+ // De qué vía salió cada una: este recorrido, la tarea que la dejó anotada y la fecha del motor. La
804
+ // arma el recorrido, que es el único de los dos que sabe las tres cosas (caso 115).
805
+ const origin = inboxOrigin('autobuild', task.id, planning.today)
806
+ const noted = review.concerns.filter((one) => !one.blocking).map((one) => withOrigin(one.detail, origin))
800
807
  const kept = noted.slice(0, INBOX_CAP)
801
808
  // Lo que pasa del tope no se escribe y tampoco desaparece: queda contado en el hecho de revisión, que
802
809
  // viaja a `done/`. Una revisión que anota treinta y seis cosas no está priorizando, y el INBOX no las
@@ -806,7 +813,8 @@ while (rounds++ < MAX_TASKS) {
806
813
  }
807
814
  if (kept.length) {
808
815
  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. ${inboxAsk(['Propuestas'], planning.inbox)} ` +
816
+ `anotado sin frenar la entrega, sin promover ninguna. ` +
817
+ `${inboxAsk(['Propuestas'], planning.inbox, origin)} ` +
810
818
  `Lo anotado: ${JSON.stringify(kept)}`, { label: 'review-noted' })
811
819
  }
812
820
  }
@@ -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 only its inbox field into inbox, verbatim.\n` +
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 listed = followUps.slice(0, INBOX_CAP).map((one) => ({ section: one.section, entry: oneLine(one.entry) }))
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)} Motivo: ${epic.reason}`,
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)} Qué falta averiguar: ${epic.reason}`,
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, verbatim.\n` +
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(
@@ -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
- let text = ''
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
- function pending(root, files, input) {
49
+ const left = (root, files) => {
47
50
  const approved = new Set(root ? read(root) : [])
48
- return CHAT.unauthorized(input, files.filter((file) => !approved.has(file)))
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,36 @@ 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
- function HOW(variable, lines, input) {
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
- return (chat
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. Si prefiere aprobarlo a mano, que pegue ella tal cual en'
78
- : 'Aprobalo pegando tal cual en')
79
- + ` ${where(input)} estas líneas:\n`
80
- + lines.map((line) => ` ${line}\n`).join('')
81
- + `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
82
- + 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
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
+ // Sin variable no se nombra ninguna. Un guard que no tiene apagado por sesión no debería anunciar uno, y
115
+ // los que estrenan salida angosta con el 117 no lo tienen a propósito: ofrecer el permiso más ancho
116
+ // cuando alcanza el más angosto es lo que hizo que la variable fuera la vía que quedaba a mano (caso 089).
117
+ const off = variable
118
+ ? `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
119
+ + 'es la vía recomendada.'
120
+ : ''
121
+ return ask + paste + unresolved + off
83
122
  }
84
123
 
85
- module.exports = { APPROVAL, read, pending, where, HOW }
124
+ module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW }
@@ -44,7 +44,14 @@ function load(session) {
44
44
 
45
45
  // Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
46
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
47
+ // En inglés la negación se escribe contraída, y de todas se conocía una sola: `don't`. «the tool doesn't
48
+ // read the .env» nombraba el archivo, traía un verbo de la lista y ninguna negación reconocida, así que una
49
+ // frase que **prohíbe** autorizaba (caso 120). Es el error en la dirección peligrosa, al revés que el 109.
50
+ // El auxiliar entra en la alternancia con el apóstrofo opcional, que es lo que `don'?t` ya hacía para uno;
51
+ // `don't` sale de acá —`do` + `n't`—, así que no quedan dos reglas para lo mismo.
52
+ const CONTRACTED = String.raw`(?:do|does|did|is|are|was|were|ca|wo|would|should)n'?t`
53
+ const NEGATION = new RegExp(
54
+ String.raw`(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|${CONTRACTED})(?![\p{L}])`, 'iu')
48
55
  // Un punto corta sólo si cierra la oración: el de `x.js` es parte del nombre, y cortar ahí dejaba el verbo de
49
56
  // «agregá src/x.js a .ops-approval» en otra frase que la del archivo.
50
57
  const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
@@ -53,6 +60,12 @@ const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
53
60
  // archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
54
61
  // español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
55
62
  // quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
63
+ //
64
+ // Autorizar también es pedir, y era lo que faltaba: «autorizo la lectura del .env» es la forma más
65
+ // explícita de decir que sí y se frenaba igual, porque ninguna de sus palabras estaba (caso 118). Entran
66
+ // las formas con que una persona **concede** —primera persona e imperativo— y no los sustantivos que
67
+ // nombran el acto: `lectura`, `permiso` y `autorizacion` aparecen igual en una pregunta que no autoriza
68
+ // nada, y dejarlos afuera es lo que el 109 decidió cuando dijo que nombrar no alcanza.
56
69
  const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
57
70
  + 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
58
71
  + 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
@@ -60,9 +73,11 @@ const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena ed
60
73
  + 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
61
74
  + 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
62
75
  + 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
76
+ + 'autorizo autoriza autorizar autorizado permito permiti permite permitir apruebo habilito '
63
77
  + 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
64
78
  + 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
65
- + 'rename move restore skip').split(' '))
79
+ + 'rename move restore skip '
80
+ + 'authorize authorized allow allowed permit permitted grant granted approved').split(' '))
66
81
  const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
67
82
 
68
83
  function asks(clause) {
@@ -120,6 +135,10 @@ const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|
120
135
  // El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
121
136
  // deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
122
137
  // que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
138
+ //
139
+ // Lo concedido es lo único que cruza de un mensaje al siguiente, y se hereda aunque este mensaje no lo
140
+ // haya escrito una persona: un aviso del runner en el medio no le quita a nadie lo que ya autorizó. La
141
+ // negación se aplica venga de donde venga, porque revocar es la dirección segura.
123
142
  function record(input) {
124
143
  try {
125
144
  if (!input.session_id) return
@@ -129,9 +148,10 @@ function record(input) {
129
148
  const approved = human && previous && YES.test(text)
130
149
  ? previous.pending.filter((item) => !mentions(text, item).denied)
131
150
  : []
151
+ const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
132
152
  fs.mkdirSync(DIR, { recursive: true })
133
- fs.writeFileSync(recordPath(input.session_id),
134
- JSON.stringify({ id: idOf(input), text, human, flow: flowCommand(text), approved, pending: [] }))
153
+ fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
154
+ { id: idOf(input), text, human, flow: flowCommand(text), approved, granted, pending: [] }))
135
155
  } catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
136
156
  }
137
157
 
@@ -146,14 +166,63 @@ function said(input) {
146
166
  return current && saved.id && current !== saved.id ? null : saved
147
167
  }
148
168
 
149
- // Lo que la persona no autorizó de lo que un guard está por frenar: ni lo pidió en su mensaje ni lo
150
- // aprobó contestando. Qué cuenta como pedirlo depende de qué se frena: un archivo se nombra, un push se
151
- // ordena con su remoto y su rama.
169
+ // Con qué autorización pasa un ítem, o vacío si no pasa: lo pidió este mensaje, un «dale» aprobó lo que
170
+ // había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
171
+ // se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
152
172
  const named = (text, item) => mentions(text, item).named
153
- function unauthorized(input, items, asked = named) {
173
+ function why(saved, item, asked, inherit) {
174
+ if (asked(saved.text, item)) return 'orden'
175
+ if (saved.approved.includes(item)) return 'dale'
176
+ if (!inherit) return ''
177
+ return (saved.granted || []).includes(item) ? 'concedido' : ''
178
+ }
179
+
180
+ // Lo que un guard dejó pasar queda anotado, que es la contracara de `hold`: hasta 0.82.0 sólo se anotaba
181
+ // lo frenado, así que una autorización usada moría con el mensaje y lo mismo se frenaba una y otra vez.
182
+ // Ahí la salida barata era apagar el guard para toda la sesión con una variable, o sea el permiso más
183
+ // ancho de los dos (caso 116).
184
+ //
185
+ // Se anota el ítem **como el guard lo nombró** —la ruta en la forma que ese guard tiene a mano— y no el
186
+ // archivo que hay detrás: es el mismo alcance que tiene una línea de `.ops-approval`, angosto de más
187
+ // antes que de menos.
188
+ function grant(input, saved, items) {
189
+ const before = saved.granted || []
190
+ const granted = [...new Set([...before, ...items])]
191
+ if (granted.length === before.length) return
192
+ try {
193
+ saved.granted = granted
194
+ fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
195
+ } catch { /* sin anotarlo, se vuelve a pedir */ }
196
+ }
197
+
198
+ // Con qué autorización pasa cada uno de los que pasan. Lo pregunta quien necesita el porqué y no sólo el
199
+ // qué —el rastro de un push lo anota (caso 112)—, y no concede nada: preguntar no cambia qué va a valer
200
+ // en el mensaje siguiente.
201
+ //
202
+ // Conceder y heredar son dos cosas distintas y hasta 0.83.0 se movían juntas: conceder es escribir en el
203
+ // registro, heredar es leer lo que escribió un mensaje anterior. Quien decide algo que vuelve a tener
204
+ // consecuencia cada vez que ocurre apaga lo segundo con `inherit: false`, y ahí vale lo que la persona pidió
205
+ // en el mensaje en curso o aprobó con un «dale» (caso 119).
206
+ function authorized(input, items, { asked = named, inherit = true } = {}) {
207
+ const saved = said(input)
208
+ if (!saved) return []
209
+ return items.map((item) => ({ item, via: why(saved, item, asked, inherit) })).filter((one) => one.via)
210
+ }
211
+
212
+ // Lo que la persona no autorizó de lo que un guard está por frenar; lo que sí, queda concedido.
213
+ function unauthorized(input, items) {
154
214
  const saved = said(input)
155
215
  if (!saved) return items
156
- return items.filter((item) => !saved.approved.includes(item) && !asked(saved.text, item))
216
+ const passed = items.filter((item) => why(saved, item, named, true))
217
+ grant(input, saved, passed)
218
+ return items.filter((item) => !passed.includes(item))
219
+ }
220
+
221
+ // Lo mismo sin conceder y sin heredar: lo que no está en el mensaje en curso queda pendiente aunque la
222
+ // sesión lo haya dejado pasar antes.
223
+ function unauthorizedNow(input, items) {
224
+ const cleared = new Set(authorized(input, items, { inherit: false }).map((one) => one.item))
225
+ return items.filter((item) => !cleared.has(item))
157
226
  }
158
227
 
159
228
  // Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
@@ -168,4 +237,4 @@ function hold(input, items) {
168
237
  } catch { return false }
169
238
  }
170
239
 
171
- module.exports = { DIR, record, said, unauthorized, hold, ordersPush }
240
+ module.exports = { DIR, record, said, authorized, unauthorized, unauthorizedNow, hold, ordersPush }
@@ -76,10 +76,14 @@ function credential(input, raw) {
76
76
  : ''
77
77
  }
78
78
 
79
+ // Escribir una credencial se autoriza igual que leerla. `secrets-read` consulta la aprobación desde 0.80.0
80
+ // y éste frenaba sin ofrecer nada, así que la misma persona podía aprobar leer su `.env` y no podía aprobar
81
+ // escribirlo. La asimetría no la decidió nadie (caso 117).
79
82
  function secrets(input) {
80
83
  for (const file of filesOf(input)) {
81
84
  const reason = credential(input, file)
82
- if (reason) block(`${file} ${reason}`)
85
+ if (!reason || approved(input, file)) continue
86
+ block(`${file} ${reason}\n${AP.HOW(null, [file], input)}`)
83
87
  }
84
88
  }
85
89
 
@@ -105,9 +109,10 @@ function secretsRead(input) {
105
109
  function integrationSnapshot(input) {
106
110
  for (const raw of filesOf(input)) {
107
111
  const file = raw.replace(/\\/g, '/')
108
- if (/(?:^|\/)integrations\/[^/]+\/staging\/(?:.+\/remote\.json|sync-state\.json)$/.test(file)) {
109
- block(`${file} pertenece al sincronizador. Cura draft.md; no edites snapshots a mano.`)
110
- }
112
+ if (!/(?:^|\/)integrations\/[^/]+\/staging\/(?:.+\/remote\.json|sync-state\.json)$/.test(file)) continue
113
+ if (approved(input, raw)) continue
114
+ block(`${file} pertenece al sincronizador. Cura draft.md; no edites snapshots a mano.\n`
115
+ + AP.HOW(null, [raw], input))
111
116
  }
112
117
  }
113
118
 
@@ -115,9 +120,10 @@ function generated(input) {
115
120
  for (const raw of filesOf(input)) {
116
121
  const file = raw.replace(/\\/g, '/')
117
122
  const base = path.basename(file)
118
- if (/(?:^|[._-])generated\.[^.]+$/i.test(base) || /(?:^|[._-])gen\.(?:go|ts|js|py)$/i.test(base)) {
119
- block(`${file} parece código generado. Modifica su fuente y ejecuta el generador; no lo edites a mano.`)
120
- }
123
+ if (!/(?:^|[._-])generated\.[^.]+$/i.test(base) && !/(?:^|[._-])gen\.(?:go|ts|js|py)$/i.test(base)) continue
124
+ if (approved(input, raw)) continue
125
+ block(`${file} parece código generado. Modifica su fuente y ejecuta el generador; no lo edites a mano.\n`
126
+ + AP.HOW(null, [raw], input))
121
127
  }
122
128
  }
123
129
 
@@ -342,6 +348,7 @@ function engineWrites(input) {
342
348
  for (const raw of filesOf(input)) {
343
349
  const file = path.resolve(cwdOf(input), raw)
344
350
  if (file !== pkg && !file.startsWith(`${pkg}${path.sep}`)) continue
351
+ if (approved(input, raw)) continue
345
352
  block(`${raw} pertenece al motor de Cauce, que llega por npm.\n` +
346
353
  'Un cambio acá lo borra el próximo install y mientras tanto corrés un motor que no coincide ' +
347
354
  'con la versión que declarás. Para traer una versión nueva son dos pasos —el motor y después ' +
@@ -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 }
@@ -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 left = pushes.flatMap((match) => destinationsOf(match[1], dir))
139
- .filter((to) => !(to.item && to.item.startsWith('push ') && filed.has(to.item)))
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 unordered = CHAT.unauthorized(input, [...new Set(left.map((to) => to.item))], CHAT.ordersPush)
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 }
@@ -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
- + `Si hace falta un valor, pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input)}`)
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
- // 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 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
- 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.'
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 }
@@ -14,7 +14,7 @@ const {
14
14
  } = require('./input')
15
15
  const AP = require('./approval')
16
16
  const { publish } = require('./push')
17
- const { selfApproval } = require('./self-approval')
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
@@ -75,7 +75,7 @@ function destructive(input) {
75
75
  }
76
76
  publish(input, command)
77
77
  const rules = [
78
- [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
78
+ [/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales.", true],
79
79
  // R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
80
80
  // no devolvía una línea. Se bloquea por política y no por daño —un `--amend` sobre algo que nadie vio
81
81
  // no rompe nada—, así que el mensaje manda a lo que sí corresponde: otro commit.
@@ -83,7 +83,7 @@ function destructive(input) {
83
83
  new RegExp(String.raw`\bgit\s+commit\b${MISMO}*\s--amend\b`),
84
84
  "'git commit --amend' reescribe un commit ya creado. R8 pide uno nuevo en su lugar.",
85
85
  ],
86
- [/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento."],
86
+ [/\bgit\s+clean\s+-[^\s]*f/, "'git clean -f' borra archivos sin seguimiento.", true],
87
87
  // `git checkout -- .` destruye lo mismo que `reset --hard` y sin recuperación, pero se escribe como
88
88
  // una limpieza. Se bloquea sólo la forma ancha —`.`, `*`, `:/`, o sin ruta—: revertir un archivo
89
89
  // nombrado es trabajo corriente y no se toca.
@@ -98,14 +98,17 @@ function destructive(input) {
98
98
  + String.raw`(?:--(?=\s*(?:${COMANDO}))|(?:--\s+)?(?:\.|\*|:\/)(?=\s*(?:${COMANDO})))`),
99
99
  "'git checkout -- .' revierte todo lo no commiteado del directorio, no sólo lo que estás mirando. "
100
100
  + 'Nombrá el archivo, o commiteá lo que quieras conservar antes.',
101
+ true,
101
102
  ],
102
103
  [
103
104
  /\bdocker(?:\s+\w+)*\s+(?:volume\s+(?:rm|prune)|system\s+prune|network\s+prune)\b/,
104
105
  'La limpieza global de Docker puede borrar datos compartidos.',
106
+ true,
105
107
  ],
106
108
  [
107
109
  /\bdocker(?:\s+compose|-compose)\s+(?:\S+\s+)*(?:down|stop|kill|rm)\b/,
108
110
  'Detener un stack Compose puede interrumpir servicios compartidos.',
111
+ true,
109
112
  ],
110
113
  [
111
114
  new RegExp(ANTES + String.raw`(?:mkfs\S*|shred)\s`
@@ -117,7 +120,22 @@ function destructive(input) {
117
120
  "'rm -r' sobre /, home o el directorio padre es catastrófico.",
118
121
  ],
119
122
  ]
120
- for (const [pattern, message] of rules) if (pattern.test(command)) block(message)
123
+ // El tercer elemento dice si la regla tiene salida. Las que no la tienen son las que una persona tampoco
124
+ // debería poder abrir pidiéndolo: `--amend` y el force-push de arriba, que R8 prohíbe sin excepción
125
+ // configurable; el borrado de disco; y `rm -r` sobre raíz, home o el padre, que es la clase que gobierna
126
+ // R23. El resto no tenía salida por omisión y no por decisión: son anteriores al canal de chat de 0.81.0
127
+ // —por qué ese canal existe y qué distingue, en `chat.js`— y nadie volvió a pasarles por encima (117).
128
+ //
129
+ // El ítem que se aprueba es el comando entero tal como llegó, no el pedazo que matcheó: `git clean -f`
130
+ // no aparece entero dentro de «corré git clean -fd», así que aprobar el fragmento no destrabaría lo que
131
+ // la persona escribió. Entero es además lo que ella pegaría en el archivo, y lo más angosto: cualquier
132
+ // otra bandera es otro comando y vuelve a preguntarse.
133
+ for (const [pattern, message, open] of rules) {
134
+ if (!pattern.test(command)) continue
135
+ const item = String(raw).trim()
136
+ if (open && !AP.pending(opsRoot(input), [item], input).length) continue
137
+ block(open ? `${message}\n${AP.HOW(null, [item], input)}` : message)
138
+ }
121
139
  }
122
140
 
123
141
  function gitAdd(input) {
@@ -177,7 +195,7 @@ function dependencies(input) {
177
195
  // Lo que se juzga acá es el archivo staged, así que la aprobación por ruta lo expresa: autorizar
178
196
  // `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
179
197
  // 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.pending(opsRoot(input),
198
+ const sinAprobar = (parent, names) => AP.pendingNow(opsRoot(input),
181
199
  names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)), input)
182
200
  // 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
201
  // disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
@@ -315,7 +333,7 @@ function shellBoundary(input) {
315
333
  const file = path.resolve(base || '/', raw)
316
334
  // El canal por el que la persona aprueba no es un destino más: se juzga aunque no haya raíces
317
335
  // declaradas y aunque caiga en el temporal, que el resto de este guard deja pasar (caso 098).
318
- const own = selfApproval(input, file)
336
+ const own = selfApprovalShell(input, file)
319
337
  if (own) block(own)
320
338
  if (!allowed || NEUTRAL.some((pattern) => pattern.test(file))) continue
321
339
  if (outsideRoots(file, allowed)) {
@@ -348,7 +366,7 @@ function governance(input) {
348
366
  // La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
349
367
  // reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
350
368
  // entre una llave por operación y una puerta que quedó abierta.
351
- const pendientes = AP.pending(opsRoot(input), governed, input)
369
+ const pendientes = AP.pendingNow(opsRoot(input), governed, input)
352
370
  if (!pendientes.length) return
353
371
  block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes, input)}`)
354
372
  }
@@ -489,7 +507,7 @@ function verify(input) {
489
507
  // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
490
508
  // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
491
509
  // y no de una regla, que es lo que la vuelve una operación y no un permiso.
492
- const sinAprobar = AP.pending(opsRoot(input), staged, input)
510
+ const sinAprobar = AP.pendingNow(opsRoot(input), staged, input)
493
511
  const aprobado = !sinAprobar.length
494
512
  if (changedOpenApi && !hasApiGenerated && !aprobado) {
495
513
  block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
@@ -604,4 +622,4 @@ function verifyGates(root, dir, sinAprobar, env, input) {
604
622
  + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
605
623
  }
606
624
 
607
- module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
625
+ module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run, writesWithBase }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.82.0",
3
+ "version": "0.84.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -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. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
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
 
@@ -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.