@ingeniomaps/cauce 0.98.0 → 0.99.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,103 @@ 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.99.0] - 2026-09-24
18
+
19
+ ### Agregado
20
+
21
+ - **`migrations.paths` en `ops.config.json`**: las carpetas donde viven tus migraciones, por ejemplo
22
+ `["migrations", "alembic/versions"]`. Si la declarás, reemplaza el default (`migrations`, `migration`,
23
+ `migrate`), que no alcanzaba a Alembic. `check` avisa la extensión o la carpeta declarada que no alcanza
24
+ a ningún archivo: una cobertura que declaraste y no cubre nada.
25
+
26
+ ### Cambiado
27
+
28
+ - **`check` falla si una entrada de DONE tiene `tests:` todo `n/a` y su commit toca algo que no sea un
29
+ documento (`.md`, `.txt`, `.adoc`) o una imagen (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`).** Es lo que vuelve creíble el `n/a` de arriba: lo demás se ejecuta y se prueba. Rige
30
+ para lo cerrado desde el 2026-09-24, así que tu historia anterior no se pone en rojo al actualizar.
31
+
32
+ ### Corregido
33
+
34
+ - **Un aviso de una tarea de fondo ya no le borra a la persona lo que dijo.** Si un guard frenaba algo en el
35
+ turno de un aviso de un subagente, eso no quedaba esperando tu confirmación, y tu «dale» siguiente no
36
+ aprobaba nada. Ahora lo frenado espera tu respuesta aunque el aviso llegue antes que ella, y tu negativa
37
+ —«no toques el `.env`»— sigue valiendo después de un aviso sin depender del texto que el aviso traiga.
38
+
39
+ - **Una negación sobre otra cosa ya no te hace perder un bloqueo.** «dale, fijate si esto no es un
40
+ defecto» dejaba lo frenado sin anotar, y tu «confirmo» siguiente no aprobaba nada. Ahora la respuesta se
41
+ lee en la primera parte del mensaje, y lo que negás nombrándolo —«dale, pero no el .env»— sigue sin
42
+ pasar, igual que un push si en cualquier parte decís que no se publique —«no hagas push todavía»—. Cuando algo no queda esperando tu confirmación, el bloqueo lo dice y te indica que lo pidas
43
+ nombrándolo, en vez de ofrecerte un «dale» que no iba a servir.
44
+
45
+ - **Si lo que se frenó lo hacía un subagente, tu confirmación le llega.** Dentro de trabajo delegado —Build en
46
+ cada `autobuild`— el bloqueo no ofrecía el chat: decías que sí y el reintento volvía a frenar con el mismo
47
+ mensaje, y la única salida era pegar la línea en `planning/.ops-approval`. Ahora el subagente devuelve el
48
+ bloqueo para que te lo pregunten, y lo que confirmes pasa aunque lo reintente otro subagente. Sólo eso: un
49
+ pedido tuyo que nombraba algo sigue valiendo para el agente con el que hablás, no para un subagente.
50
+
51
+ - **Sin nadie en el chat, el bloqueo ya no le ordena al agente que se escriba la aprobación.** Decía «Aprobalo
52
+ pegando tal cual en…», y el agente lo leía como una orden que su contrato le prohíbe. Ahora dice que las
53
+ líneas las pega una persona, y al agente qué hacer mientras tanto.
54
+
55
+ - **El gate de commit reconoce el código de sqlc donde lo pongas.** Sólo aceptaba el generado bajo una
56
+ carpeta `sqlc/` o `generated/`, así que un `out` como `internal/platform/pgdb/` pedía aprobación en cada
57
+ commit que tocaba una consulta aunque hubieras regenerado. Ahora reconoce `*.sql.go` en cualquier carpeta.
58
+ Y ve una consulta en cualquier carpeta `queries/` —`api/db/queries/` en un monorepo—, que antes se
59
+ commiteaba sin su generado sin que nada avisara; eso sólo si el repositorio tiene `sqlc.yaml`, `sqlc.yml`
60
+ o `sqlc.json`. Si frena, dice qué buscó. Límite: con `output_files_suffix` el generado no se reconoce.
61
+
62
+ - **Un guard invocado a mano ya no se queda colgado.** Lanzado fuera del runner —a mano, desde un script o
63
+ en segundo plano— con stdin abierto y sin datos, esperaba hasta que el otro extremo cerrara: horas, sin
64
+ llegar a correr. Ahora, si en 2 s no llega nada, bloquea y explica cómo invocarlo: con el JSON del hook
65
+ por stdin, o sin entrada con `</dev/null`. Lo que manda el runner se lee entero, así que una escritura
66
+ grande no se corta, y un JSON completo se juzga aunque quien lo mandó no cierre stdin.
67
+
68
+ - **Una tarea cuyo entregable es un documento o una decisión escrita ya puede cerrarse en `autobuild`.**
69
+ Paraba en `verify-hollow` pidiendo pruebas imposibles. Ahora Verify declara esos criterios sin superficie
70
+ ejecutable, llegan a Done como `tests: n/a — <razón>`, y QA comprueba que el documento exista y cubra lo
71
+ que la aceptación enumera. Con causas mezcladas, el rebote pide sólo las pruebas que de verdad faltan, y
72
+ Done recibe de Verify qué prueba cubre cada criterio en vez de componerlo de memoria.
73
+
74
+ - **Una condición marcada `(fuera de verify: <razón>)` ya no llega a Verify ni a QA.** Era la salida que
75
+ `check` recomienda, y la corrida paraba igual; ahora viaja a Done para quedar cumplida en `tests:`, `qa:`
76
+ o `commit:`.
77
+
78
+ - **El guard de migraciones ya no frena la reversión honesta.** Buscaba en todo el archivo, así que el
79
+ `DROP TABLE` del `-- +goose Down` frenaba cada tabla nueva y una migración sin `Down` pasaba. Ahora, en
80
+ goose y dbmate, juzga sólo el bloque que aplica; un `*.down.sql` es reversión entera; corregir el `Down`
81
+ con una edición también pasa; y el mensaje dice en qué bloque está lo que frena.
82
+
83
+ - **Si declarás `migrations.extensions`, el guard ve también el borrado escrito con la API del ORM**, no
84
+ sólo el SQL crudo: `op.drop_table`/`drop_column` (Alembic), `drop_table`, `remove_column(s)` y
85
+ `drop_join_table` (Rails), `dropTable`/`dropColumn(s)` (TypeORM), `dropTable(IfExists)`/`dropColumn`
86
+ (Knex) y `DeleteModel`/`RemoveField` (Django). La reversión —`downgrade()`, `down`— no se juzga.
87
+
88
+ - **El puente de Antigravity ya no deja pasar lo que no puede leer.** Ante un JSON ilegible respondía
89
+ `allow`: un `git push --force` al que le faltaba una llave pasaba. Y con stdin abierto se colgaba. Ahora
90
+ lee con el mismo lector que los guards: lo ilegible se niega en `pre-shell` y `pre-files`, si en 2 s no
91
+ llega nada niega y dice cómo invocarlo a mano, y en `stop` deja cerrar con el motivo a la vista. Sin
92
+ entrada (`</dev/null`) sigue permitiendo, como antes.
93
+
94
+ - **El puente de Antigravity niega una llamada que no sabe describir.** Leía cada campo por un nombre fijo,
95
+ así que una llamada con otra forma —un campo renombrado en una actualización del runner— llegaba vacía a
96
+ los guards y pasaba: todos apagados sin avisar. Ahora un `pre-shell` sin comando o un `pre-files` sin
97
+ archivo se niegan, y el motivo nombra los campos que sí llegaron.
98
+
99
+ - **El gate de commit reconoce una especificación OpenAPI por lo que declara, no por la carpeta.** Cualquier
100
+ `.yaml` bajo `api/`, `openapi/` o `spec/` pedía regenerar el cliente: una config de sqlc, un compose o un
101
+ fixture ahí frenaban cada commit. Ahora cuenta el que declara `openapi:` o `swagger:`, o un fragmento de una
102
+ carpeta que tiene uno.
103
+
104
+ - **En Codex, el guard de migraciones juzga cada archivo del parche por separado.** Un `apply_patch` llegaba
105
+ como un solo sobre: los marcadores de goose y dbmate venían con el `+` del parche y no partían, así que la
106
+ reversión honesta volvía a frenar, y un `DROP TABLE` citado en otro archivo del mismo parche frenaba la
107
+ migración. Ahora cada archivo se juzga por su sección, y en una modificación sólo lo agregado.
108
+
109
+ - **`automation doctor` de Antigravity mira la copia que `agy` ejecuta de verdad.** `agy` corre la que registrás
110
+ con `agy plugin install`, una por usuario, y `doctor` miraba la del workspace: decía «operativo» mientras `agy`
111
+ ejecutaba el plugin de otro proyecto o uno roto, y frenaba cada comando. Ahora compara las dos, lanza la
112
+ registrada como la lanza `agy`, y si difiere te dice qué comando corre para registrar esta instalación.
113
+
17
114
  ## [0.98.0] - 2026-09-17
18
115
 
19
116
  ### Cambiado
@@ -12,7 +12,8 @@ Los hooks convierten invariantes comprobables en gates mecánicos. La base recom
12
12
  - cierre de sesión con planning o integraciones inválidas;
13
13
  - modificación del protocolo durante una tarea de producto.
14
14
  - escrituras fuera de las raíces declaradas del workspace;
15
- - reescritura de migraciones y SQL destructivo;
15
+ - reescritura de migraciones y borrado destructivo —SQL o la API del ORM— en la parte que aplica, no en su
16
+ reversión; qué carpetas y extensiones son migraciones lo declara `migrations` en `ops.config.json`;
16
17
  - publicación, instalaciones globales y drift entre manifests y lockfiles.
17
18
 
18
19
  La lógica portable vive en `engine/hooks/run.js`; los `guard-*.sh` son entradas ejecutables comunes. Cada
@@ -84,7 +85,10 @@ guards lo leen:
84
85
 
85
86
  No cuenta cuando no hay persona —CI, o un aviso del runner como el de un subagente que terminó—, cuando lo
86
87
  que pidió es un recorrido de Cauce (`/autobuild`, `$flow`…), ni en la llamada de un subagente, que Claude
87
- marca con `agent_id`. En Claude y Codex cada llamada trae el identificador del mensaje que la originó;
88
+ marca con `agent_id`: a un subagente no le llega lo que ella pidió ni lo que se le concedió antes. Sí le
89
+ llega su confirmación a un bloqueo, que nombra exactamente lo frenado: el bloqueo de un subagente le pide
90
+ devolverlo a quien lo lanzó para que se lo pregunte, y lo que ella confirme pasa aunque lo reintente otro
91
+ subagente. En Claude y Codex cada llamada trae el identificador del mensaje que la originó;
88
92
  Gemini no lo manda, y ahí vale el último mensaje. El registro vive en el temporal del sistema, uno por
89
93
  sesión, y los guards de límites lo cuidan junto con `planning/.ops-approval`: el registro no lo escribe
90
94
  nunca una herramienta, y en la aprobación sólo entran las líneas que la persona nombró en ese mismo
@@ -19,7 +19,10 @@ usuario y no por workspace. De ahí salen tres consecuencias que conviene tener
19
19
  - Registrar desde otro proyecto reemplaza el plugin del anterior.
20
20
 
21
21
  Volvé a registrar cada vez que `automation install` cambie el wiring o el proyecto se mueva de lugar;
22
- `agy plugin validate .agents/plugins/cauce` comprueba la copia del repo antes de registrarla.
22
+ `agy plugin validate .agents/plugins/cauce` comprueba la copia del repo antes de registrarla. Si te
23
+ olvidás, `automation doctor` lo dice: compara la copia registrada con la del workspace y la lanza como
24
+ la lanza `agy`, desde su carpeta, así que una copia de otro proyecto o de otra versión es un error con el
25
+ comando que lo corrige. Al instalar es una advertencia, porque registrar es justo el paso que sigue.
23
26
 
24
27
  El plugin aporta hooks `PreToolUse` y `Stop`, reglas Cauce y los cinco recorridos —`/cauce:onboard`,
25
28
  `/cauce:flow`, `/cauce:autobuild`, `/cauce:integration-sync` y `/cauce:integration-promote`— más el
@@ -4,13 +4,6 @@
4
4
  const fs = require('node:fs')
5
5
  const path = require('node:path')
6
6
 
7
- function readInput() {
8
- try {
9
- const raw = fs.readFileSync(0, 'utf8')
10
- return raw.trim() ? JSON.parse(raw) : {}
11
- } catch { return {} }
12
- }
13
-
14
7
  // Dónde quedó la raíz ops respecto de la carpeta que Antigravity abre. Lo completa
15
8
  // `automation install`, que es el único momento en que se sabe: en modo sidecar la raíz ops es un
16
9
  // hermano de los repos de producto, y ninguna búsqueda hacia arriba la encuentra. Sin esto el bridge
@@ -78,18 +71,39 @@ function findRoot(input, markers = MARKERS) {
78
71
  throw new Error('No se encontró una raíz Cauce desde el workspace de Antigravity.')
79
72
  }
80
73
 
81
- function runtimeAt(root) {
74
+ function engineAt(root, file) {
82
75
  const candidates = [
83
- path.join(root, 'node_modules', '@ingeniomaps', 'cauce', 'engine', 'hooks', 'run.js'),
84
- path.join(root, 'engine', 'hooks', 'run.js'),
85
- path.join(root, '..', 'node_modules', '@ingeniomaps', 'cauce', 'engine', 'hooks', 'run.js'),
76
+ path.join(root, 'node_modules', '@ingeniomaps', 'cauce', 'engine', 'hooks', file),
77
+ path.join(root, 'engine', 'hooks', file),
78
+ path.join(root, '..', 'node_modules', '@ingeniomaps', 'cauce', 'engine', 'hooks', file),
86
79
  ]
87
80
  // Copia de `packagePath`; su porqué vive allá. Son tres los que la repiten y el motor los nombra.
88
- const runtime = candidates.find(fs.existsSync)
81
+ return candidates.find(fs.existsSync) || ''
82
+ }
83
+
84
+ function runtimeAt(root) {
85
+ const runtime = engineAt(root, 'run.js')
89
86
  if (!runtime) throw new Error('No se encontró el runtime engine/hooks/run.js.')
90
87
  return require(runtime)
91
88
  }
92
89
 
90
+ // La entrada se lee con el lector del motor y no con uno propio: el puente tenía su copia, y la copia se
91
+ // colgaba con stdin abierto y convertía un JSON ilegible en `allow` (caso 198). Lo que no se puede usar para
92
+ // encontrarlo es la entrada misma, que todavía no se leyó: se busca como `findRoot` sin entrada —la raíz que
93
+ // `install` dejó escrita y, si no resuelve, desde donde corre el puente—. Sólo la declarada dejaba sin
94
+ // lector, negando cada llamada, a un proyecto movido que los guards sí encontraban. Sin ninguna, se niega.
95
+ function inputReader(markers = MARKERS) {
96
+ let root = ''
97
+ try { root = findRoot({}, markers) } catch { /* sin raíz no hay motor: lo dice el error de abajo */ }
98
+ const reader = root && engineAt(root, 'input.js')
99
+ if (!reader) {
100
+ throw new Error('No se encontró engine/hooks/input.js, con el que se lee la entrada, ni en la raíz que '
101
+ + `automation install declaró (${declaredRoot(markers) || 'ninguna'}) ni desde ${process.cwd()}. `
102
+ + 'Reinstalá el runner.')
103
+ }
104
+ return require(reader)
105
+ }
106
+
93
107
  // La carpeta que el runner abrió, deducida de la raíz: en sidecar la raíz ops es su hija, y en modo
94
108
  // embebido son la misma.
95
109
  function workspaceOf(root, markers) {
@@ -134,6 +148,47 @@ function respond(value) {
134
148
  process.stdout.write(`${JSON.stringify(value)}\n`)
135
149
  }
136
150
 
151
+ function refusal(event, message, blocking) {
152
+ const reason = `Cauce: ${message}`
153
+ if (event !== 'stop') return { decision: 'deny', reason }
154
+ // Un guard que bloquea marca su error con `blocked` (engine/hooks/run.js); cualquier otro es que el
155
+ // puente no llegó a juzgar nada. En `stop` los dos devolvían `continue`, y eso ata al agente: la
156
+ // raíz que no resuelve no se arregla sola, así que cada intento de cerrar repite el mismo error.
157
+ // El bloqueo sigue dando `continue` —es el mecanismo funcionando—; la falla deja cerrar y avisa.
158
+ return blocking ? { decision: 'continue', reason } : { decision: 'stop', reason }
159
+ }
160
+
161
+ // Lo que cada evento tiene que traer para que sus guards juzguen algo. `normalize` lee campos por nombre y
162
+ // convierte en `''` el que no encuentra, así que una llamada con otra forma —un campo renombrado en una
163
+ // actualización de Antigravity— llegaba vacía a los guards, ninguno frenaba y el puente respondía `allow`:
164
+ // todos los guards apagados sin rastro (caso 200). Cerrado por defecto: sin lo que el evento juzga, se niega
165
+ // y se dice qué llegó, que es lo que hace falta para enseñarle la forma nueva a `normalize`.
166
+ //
167
+ // Una entrada vacía no es una llamada con otra forma: no describe ninguna, y es como se invoca a mano, con
168
+ // `OPS_HOOK_COMMAND` u `OPS_HOOK_FILE` (caso 198).
169
+ const DESCRIBED_BY = {
170
+ 'pre-shell': { field: 'command', names: 'CommandLine' },
171
+ 'pre-files': { field: 'file_path', names: 'TargetFile ni AbsolutePath' },
172
+ }
173
+
174
+ // Un archivo sin su contenido tampoco se puede juzgar: los guards que miran qué se escribe —secretos,
175
+ // migraciones— verían un texto vacío. Basta que el campo esté; vacío es un archivo vacío.
176
+ const CONTENT_FIELDS = ['CodeContent', 'ReplacementContent', 'ReplacementChunks']
177
+
178
+ function undescribed(event, input, normalized) {
179
+ const need = DESCRIBED_BY[event]
180
+ if (!need || !Object.keys(input).length) return ''
181
+ const fields = (input.toolCall && input.toolCall.args) || {}
182
+ const args = Object.keys(fields)
183
+ const missing = !normalized.tool_input[need.field] ? need.names
184
+ : event === 'pre-files' && !CONTENT_FIELDS.some((field) => field in fields)
185
+ ? 'CodeContent, ReplacementContent ni ReplacementChunks' : ''
186
+ if (!missing) return ''
187
+ const received = args.length ? `toolCall.args trae ${args.join(', ')}` : `llegó ${Object.keys(input).join(', ')}`
188
+ return `la llamada no trae ${missing}, así que no hay nada que juzgar y no se autoriza (${received}). Si `
189
+ + 'Antigravity cambió el formato de sus llamadas, el puente tiene que aprenderlo en normalize().'
190
+ }
191
+
137
192
  function evaluate(event, input) {
138
193
  try {
139
194
  const root = findRoot(input)
@@ -141,23 +196,30 @@ function evaluate(event, input) {
141
196
  const hooks = runtimeAt(root)
142
197
  const normalized = normalize(input, root)
143
198
  if (!hooks.hookGroups[event]) throw new Error(`Evento Antigravity desconocido: ${event || '(vacío)'}`)
199
+ const missing = undescribed(event, input, normalized)
200
+ if (missing) throw new Error(missing)
144
201
  hooks.executeAll([event], normalized)
145
202
  return event === 'stop' ? { decision: 'stop' } : { decision: 'allow' }
146
203
  } catch (error) {
147
- const reason = `Cauce: ${error.message}`
148
- if (event !== 'stop') return { decision: 'deny', reason }
149
- // Un guard que bloquea marca su error con `blocked` (engine/hooks/run.js); cualquier otro es que el
150
- // puente no llegó a juzgar nada. En `stop` los dos devolvían `continue`, y eso ata al agente: la
151
- // raíz que no resuelve no se arregla sola, así que cada intento de cerrar repite el mismo error.
152
- // El bloqueo sigue dando `continue` —es el mecanismo funcionando—; la falla deja cerrar y avisa.
153
- return error.blocked ? { decision: 'continue', reason } : { decision: 'stop', reason }
204
+ return refusal(event, error.message, error.blocked)
154
205
  }
155
206
  }
156
207
 
157
- function main() {
158
- respond(evaluate(process.argv[2], readInput()))
208
+ async function main(event = process.argv[2]) {
209
+ let input
210
+ try {
211
+ const engine = inputReader()
212
+ const usage = 'pasale el JSON de Antigravity —printf \'%s\' '
213
+ + `'{"toolCall":{"args":{"CommandLine":"…"}}}' | node hook.js ${event || '<evento>'}—`
214
+ input = await engine.readInput(process.stdin, engine.FIRST_BYTE_MS, usage)
215
+ } catch (error) {
216
+ // El lector del motor marca `blocked` lo que no pudo leer, porque para un guard eso es bloquear. Acá
217
+ // es el puente sin nada que juzgar, y en `stop` eso deja cerrar: reintentar no arregla la entrada.
218
+ return respond(refusal(event, error.message, false))
219
+ }
220
+ respond(evaluate(event, input))
159
221
  }
160
222
 
161
223
  if (require.main === module) main()
162
224
 
163
- module.exports = { evaluate, findRoot, normalize }
225
+ module.exports = { evaluate, findRoot, normalize, inputReader }
@@ -52,6 +52,7 @@
52
52
  "roleSkills": ".agents/plugins/cauce/skills",
53
53
  "activation": {
54
54
  "hint": "agy plugin install .agents/plugins/cauce",
55
+ "registered": "~/.gemini/config/plugins/cauce",
55
56
  "verify": [
56
57
  "plugin",
57
58
  "list"
@@ -0,0 +1,26 @@
1
+ // Cómo se lee una aceptación, compartido por las dos puntas que la juzgan: `check`, que avisa sobre la
2
+ // cola, y `autobuild`, que la manda a Verify. Vive acá y no en el motor porque el recorrido no puede hacer
3
+ // `require` —se renderiza con `{{INCLUDE:}}`— y el motor sí puede leer este archivo: lo carga
4
+ // `engine/planning/acceptance.js`. Escrito dos veces, una copia dejaba de reconocer lo que la otra pedía
5
+ // escribir, que es exactamente el caso 195: `check` ofrecía una marca que el recorrido no conocía.
6
+
7
+ // Una condición por tramo separado con `;`, el grano con el que Verify contrasta —su `uncovered` enumera
8
+ // criterios— y con el que `check` avisa.
9
+ const acceptanceConditions = (acceptance) => String(acceptance || '').split(';')
10
+ .map((one) => one.trim()).filter(Boolean)
11
+
12
+ // La salida explícita, con la forma que el repositorio ya usa dos veces: `(sin partir: …)` para el umbral
13
+ // de R17 y `n/a — razón` para `tests:` y `commit:`. Acá vale lo mismo que allá —«como lleva su razón
14
+ // escrita se lee en el propio artefacto sin que nadie la cruce»— y por eso no se intenta adivinar si la
15
+ // prosa excluye a Verify. Adivinarlo es lo que no se puede: la única aceptación real que nombra el commit
16
+ // lo hace justamente para decir que no es condición de Verify, y cualquier lista de frases que la
17
+ // reconociera enseñaría a escribir esa frase exacta para silenciar el aviso.
18
+ const OUT_OF_VERIFY = /\(fuera de verify:\s*[^)]+\)/i
19
+
20
+ // Lo que no se ejecuta, y por eso lo único que un criterio `no-surface` puede haber producido (caso 189).
21
+ // Es la lista a favor y no la de lo ejecutable a propósito (R27): un `.sql` de migración, un workflow en
22
+ // YAML, un `Dockerfile`, un `Makefile` o un `.json` de configuración se ejecutan sin parecer código, y una
23
+ // lista de lo ejecutable dejaría afuera lo que venga después. Ampliarla es un cambio con su razón al lado.
24
+ // Las imágenes entraron porque un ADR suele traer su diagrama, y sin ellas ese commit contaba como código.
25
+ // Lo que no tiene extensión sigue contando como ejecutable: `Makefile` y `Dockerfile` no la tienen.
26
+ const NON_EXECUTABLE = ['.md', '.txt', '.adoc', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']
@@ -28,6 +28,7 @@ export const meta = {
28
28
 
29
29
  {{INCLUDE:shared/workflow-root.js}}
30
30
  {{INCLUDE:shared/inbox.js}}
31
+ {{INCLUDE:shared/acceptance.js}}
31
32
  const CONFIG = `${ROOT}/ops.config.json`
32
33
  const P = `${ROOT}/planning`
33
34
  const ORG = `${ROOT}/organization`
@@ -190,19 +191,28 @@ const REVIEWED = { ...DECISION, required: [...DECISION.required, 'rules'],
190
191
  // menos —o que ni existe— sale verde igual, y el guard de verify tampoco lo ve porque también mira exit
191
192
  // codes. Por eso `uncovered` se contrasta contra la aceptación leyendo el fuente, no la salida (R9).
192
193
  const VERIFY = {
193
- type: 'object', additionalProperties: false, required: ['passed', 'commands', 'details', 'uncovered'],
194
+ type: 'object', additionalProperties: false, required: ['passed', 'commands', 'details', 'uncovered', 'covered'],
194
195
  properties: {
195
196
  passed: { type: 'boolean' }, details: { type: 'string' },
196
- // Dos causas que se leen igual en el resultado y piden cosas opuestas: a una le falta trabajo que
197
- // el propio recorrido puede hacer, a la otra le falta una decisión que no es suya. Sin separarlas,
198
- // la corrida frena por las dos y una persona termina resolviendo lo que se resolvía solo.
197
+ // Tres causas que se leen igual en el resultado y piden cosas distintas: a una le falta trabajo que
198
+ // el propio recorrido puede hacer, a otra le falta una decisión que no es suya, y la tercera no tiene
199
+ // prueba posible porque su entregable no se ejecuta —un ADR, una política—. Sin separarlas, la corrida
200
+ // frena por las tres, una persona resuelve lo que se resolvía solo y la tarea de decisión no cierra
201
+ // nunca (caso 189). `reason` es lo que Done escribe en `tests: n/a — <razón>`.
199
202
  uncovered: { type: 'array', items: { type: 'object', additionalProperties: false,
200
203
  required: ['criterion', 'cause'],
201
204
  properties: {
202
205
  criterion: { type: 'string' },
203
- cause: { type: 'string', enum: ['missing-test', 'ambiguous'] },
206
+ cause: { type: 'string', enum: ['missing-test', 'ambiguous', 'no-surface'] },
207
+ reason: { type: 'string' },
204
208
  },
205
209
  } },
210
+ // El mapeo que Verify arma al contrastar y del que sale `tests: CN → prueba`. Sin viajar, Done lo
211
+ // componía de memoria (hallazgo del 189).
212
+ covered: { type: 'array', items: { type: 'object', additionalProperties: false,
213
+ required: ['criterion', 'test'],
214
+ properties: { criterion: { type: 'string' }, test: { type: 'string' } },
215
+ } },
206
216
  commands: { type: 'array', items: { type: 'object', required: ['cmd', 'exitCode'], properties: {
207
217
  cmd: { type: 'string' }, exitCode: { type: 'integer' }, note: { type: 'string' },
208
218
  ranTests: { type: 'boolean' },
@@ -582,13 +592,13 @@ while (rounds++ < MAX_TASKS) {
582
592
  // por eso perder la carrera no es un error: se relee y se sigue con la que quedó libre.
583
593
  if (!planning.claimed && !planning.wipActive) {
584
594
  phase('Claim')
585
- const reserva = await write(
595
+ const claim = await write(
586
596
  `Corré "node tools/ops.js claim ${P} ${task.id}" desde ${ROOT}. No escribas ningún archivo vos: lo ` +
587
597
  `escribe el comando. claimed=true sólo con exit 0; si falla porque la tomó otro, claimed=false y ` +
588
598
  `copiá el mensaje en details.`,
589
599
  { schema: CLAIM, label: `claim:${task.id}` },
590
600
  )
591
- if (!reserva || !reserva.claimed) {
601
+ if (!claim || !claim.claimed) {
592
602
  planning = await readContext()
593
603
  if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
594
604
  // Perder la carrera es legítimo y se ve en que la cola pasa a ofrecer **otra** tarea: quien la
@@ -607,7 +617,7 @@ while (rounds++ < MAX_TASKS) {
607
617
  if (planning.hasTask && planning.slug === task.id && !planning.claimed) {
608
618
  return stop('claim-stuck', `${task.id} sigue siendo la próxima tarea y no se pudo reclamar. `
609
619
  + `context la ofrece y claim la rechaza, así que repetir no cambia nada. `
610
- + `El reclamo contestó: ${(reserva && reserva.details) || '(sin detalle)'}`)
620
+ + `El reclamo contestó: ${(claim && claim.details) || '(sin detalle)'}`)
611
621
  }
612
622
  // Con qué sigue, que es lo que cambia respecto de lo que esperaba quien autorizó la corrida: se
613
623
  // pidió un hito y se va a construir otra tarea de ese hito. Sin decirlo, el cambio sólo aparece al
@@ -721,12 +731,12 @@ while (rounds++ < MAX_TASKS) {
721
731
 
722
732
  const planRejected = async (reason, unit, found) => {
723
733
  const detail = found.join('; ') || 'sin condiciones nombradas'
724
- const nota = await registerHuman(
734
+ const note = await registerHuman(
725
735
  `Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
726
736
  + `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
727
737
  + `distintas y partirla, o dejarla entera con la razón escrita.`, 'plan-human', unit.id)
728
738
  await releaseBlocked()
729
- return stop(reason, `${detail}${nota}`)
739
+ return stop(reason, `${detail}${note}`)
730
740
  }
731
741
 
732
742
  if (!planning.wipActive) {
@@ -741,11 +751,11 @@ while (rounds++ < MAX_TASKS) {
741
751
  )
742
752
  if (!ready) return stop('agent-unavailable', 'Ready no devolvió resultado')
743
753
  if (!ready.ready) {
744
- const nota = await registerHuman(
754
+ const note = await registerHuman(
745
755
  `Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
746
756
  'ready-human', task.id)
747
757
  await releaseBlocked()
748
- return stop('not-ready', `${ready.reason}${nota}`)
758
+ return stop('not-ready', `${ready.reason}${note}`)
749
759
  }
750
760
  if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
751
761
  }
@@ -1045,11 +1055,11 @@ while (rounds++ < MAX_TASKS) {
1045
1055
  if (filed.length) {
1046
1056
  // La nota que devuelve viaja al hecho: sin ella la entrega afirma una fila que el disco no tiene,
1047
1057
  // que es el caso 087 entrando por otra puerta.
1048
- const nota = await registerHuman(`Registrá en ${HUMAN} una fila por cada decisión que la revisión de `
1058
+ const note = await registerHuman(`Registrá en ${HUMAN} una fila por cada decisión que la revisión de `
1049
1059
  + `${task.id} dejó abierta, con qué la cierra y quién puede tomarla. La primera columna nunca es `
1050
1060
  + `${task.id} —el porqué es el mismo que en Build—: va la épica, el hito o el recorrido al que `
1051
1061
  + `alcanza. No inventes responsables ni fechas: ${JSON.stringify(filed)}`, 'review-human')
1052
- decidedNote = `${nota}`
1062
+ decidedNote = `${note}`
1053
1063
  }
1054
1064
  reviewFact = `${review.verdict} por ${cast.review}, sobre ${review.consulted.join(', ')}`
1055
1065
  + (filed.length ? ` · ${filed.length} decisión(es) registrada(s)${decidedNote}` : '')
@@ -1083,11 +1093,25 @@ while (rounds++ < MAX_TASKS) {
1083
1093
  }
1084
1094
 
1085
1095
  phase('Verify')
1096
+ // Lo declarado `(fuera de verify: …)` se separa acá y no se le explica a Verify: la aceptación es texto
1097
+ // conocido antes de preguntar, y dejar que el modelo reconozca la marca en la respuesta es apostar a que
1098
+ // enumere los criterios con el mismo corte. `check` ofrecía la marca y el recorrido la mandaba igual,
1099
+ // así que la condición terminaba en `uncovered` y la corrida en `verify-hollow` (caso 195).
1100
+ const conditions = acceptanceConditions(task.acceptance)
1101
+ const outOfVerify = conditions.filter((one) => OUT_OF_VERIFY.test(one))
1102
+ const checkable = outOfVerify.length
1103
+ ? conditions.filter((one) => !OUT_OF_VERIFY.test(one)).join('; ')
1104
+ || 'ninguna: todas se declararon fuera de verify'
1105
+ : task.acceptance
1086
1106
  const VERIFY_ASK = `${asRole(cast.verify)}Abrí el fuente de los tests que la tarea agregó o cambió y ` +
1087
1107
  `contrastá cada criterio ` +
1088
1108
  `de aceptación contra sus aserciones: en uncovered va el criterio que ningún test codifica, con su causa ` +
1089
1109
  `—missing-test si el test falta o no asercia la propiedad, ambiguous si el criterio no dice qué habría ` +
1090
- `que aserciar—. Un test que pasa sin aserciarla no la cubre. Después corré los gates reales de ${task.service}. ` +
1110
+ `que aserciar, no-surface si se cumple en un artefacto que no se ejecuta, como un documento o una ` +
1111
+ `decisión escrita, y con reason diciendo cuál—. no-surface vale sólo si la tarea no tocó ningún archivo ` +
1112
+ `que no termine en ${NON_EXECUTABLE.join(', ')}; con cualquier otro en el diff es missing-test. En ` +
1113
+ `covered va cada criterio que un test sí codifica, con el nombre de ese test. ` +
1114
+ `Un test que pasa sin aserciarla no la cubre. Después corré los gates reales de ${task.service}. ` +
1091
1115
  // Descubrir la puerta es trabajo de modelo repetido en cada tarea sobre una respuesta que no cambia,
1092
1116
  // y encima adivinable: el proyecto la declara en `verify` y ahí deja de adivinarse. Cuando no la
1093
1117
  // declara se vuelve a descubrir, que es lo que pasaba siempre.
@@ -1100,7 +1124,7 @@ while (rounds++ < MAX_TASKS) {
1100
1124
  `Leé los exit codes de verdad. ` +
1101
1125
  `passed=true exige comandos corridos y ninguna regresión causada por la tarea. Marcá ranTests en el ` +
1102
1126
  `comando que haya corrido las pruebas, sea cual sea su nombre. ` +
1103
- `Aceptación: ${task.acceptance}.`
1127
+ `Aceptación: ${checkable}.`
1104
1128
  let verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
1105
1129
  if (!verified) return stop('agent-unavailable', 'Verify no devolvió resultado')
1106
1130
  // Un criterio que nadie sabe cómo aserciar no es trabajo que falta sino una definición que falta, y
@@ -1108,14 +1132,18 @@ while (rounds++ < MAX_TASKS) {
1108
1132
  // hacer parar a una persona por eso le cobra una interrupción por algo que se resolvía solo.
1109
1133
  const ambiguous = verified.uncovered.find((entry) => entry.cause === 'ambiguous')
1110
1134
  if (ambiguous) {
1111
- const nota = await registerHuman(
1135
+ const note = await registerHuman(
1112
1136
  `Registrá ${task.id} en ${HUMAN}: el criterio "${ambiguous.criterion}" no dice qué habría ` +
1113
1137
  `que aserciar, y hace falta la decisión que lo fija.`, 'verify-human', task.id)
1114
- return stop('acceptance-ambiguous', `${ambiguous.criterion}${nota}`)
1138
+ return stop('acceptance-ambiguous', `${ambiguous.criterion}${note}`)
1115
1139
  }
1116
- if (verified.uncovered.length) {
1140
+ // Lo que no tiene superficie no frena ni rebota: viaja a Done, que lo escribe como `tests: n/a`. Se filtra
1141
+ // por exclusión y no por `missing-test` para que una causa que no se conozca siga frenando (R27). Que
1142
+ // el modelo no lo use para cerrar sin pruebas lo sostiene `check`, que mira qué tocó el commit.
1143
+ const lacking = () => verified.uncovered.filter((entry) => entry.cause !== 'no-surface')
1144
+ if (lacking().length) {
1117
1145
  await run(`${asRole(cast.build)}Escribí sólo las pruebas que faltan en ${task.id}, con el mismo rojo ` +
1118
- `previo, y no toques el código de producción: ${verified.uncovered.map((e) => e.criterion).join('; ')}`,
1146
+ `previo, y no toques el código de producción: ${lacking().map((e) => e.criterion).join('; ')}`,
1119
1147
  { label: 'missing-tests' })
1120
1148
  verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
1121
1149
  if (!verified) return stop('agent-unavailable', 'la segunda pasada de Verify no devolvió resultado')
@@ -1127,21 +1155,29 @@ while (rounds++ < MAX_TASKS) {
1127
1155
  if (build.redFirst.length && !ranTests) {
1128
1156
  return stop('verify-untested', `${task.id} escribió pruebas y ningún gate corrió una`)
1129
1157
  }
1130
- if (verified.uncovered.length) {
1131
- return stop('verify-hollow', `sin test que lo codifique: ${verified.uncovered.map((e) => e.criterion).join('; ')}`)
1158
+ if (lacking().length) {
1159
+ return stop('verify-hollow', `sin test que lo codifique: ${lacking().map((e) => e.criterion).join('; ')}`)
1132
1160
  }
1161
+ const noSurface = verified.uncovered.filter((entry) => entry.cause === 'no-surface')
1162
+ const covered = verified.covered || []
1163
+ // Toda la tarea sin superficie: no hay comportamiento que ejercitar, y saltear QA en silencio dejaría sin
1164
+ // mirar lo único que se puede mirar, que el documento esté y diga lo que la aceptación enumera.
1165
+ const onlyDocument = noSurface.length > 0 && !covered.length
1133
1166
 
1134
1167
  // QA ejercita comportamiento, y lo mecánico no lo cambia: el valor literal que la aceptación nombra
1135
1168
  // ya lo comprobó Verify contra el test, y en `directo` además lo mira el revisor que nombra el cast.
1136
1169
  let qa = { passed: true, evidence: 'carril mecánico: la aceptación queda comprobada en Verify' }
1137
1170
  if (!mechanical) {
1138
1171
  phase('QA')
1139
- qa = await run(
1140
- `${asRole(cast.qa)}${lite
1172
+ qa = await run(onlyDocument
1173
+ ? `${asRole(cast.qa)}${task.id} no tiene superficie ejecutable: comprobá que el documento existe y cubre ` +
1174
+ `cada elemento que la aceptación enumera, y en evidence decí cuáles encontraste y dónde. ` +
1175
+ `Aceptación: ${checkable}.`
1176
+ : `${asRole(cast.qa)}${lite
1141
1177
  ? 'Hacé la comprobación de aceptación real más barata'
1142
1178
  : 'Ejercitá el comportamiento real que ve quien lo usa'} para ` +
1143
1179
  `${task.id}. Las pruebas unitarias solas no son QA. Levantá el mínimo runtime necesario y bajalo ` +
1144
- `después. Aceptación: ${task.acceptance}.`,
1180
+ `después. Aceptación: ${checkable}.`,
1145
1181
  { schema: QA, label: 'qa' },
1146
1182
  )
1147
1183
  if (!qa) return stop('agent-unavailable', 'QA no devolvió resultado')
@@ -1169,7 +1205,14 @@ while (rounds++ < MAX_TASKS) {
1169
1205
  `"node tools/ops.js release ${P} ${task.id}". En decisions no nombres una fase ni un cargo ` +
1170
1206
  `que no figure en estos hechos. Hechos: lane=${planning.lane || 'sin clasificar'}; ` +
1171
1207
  `review=${reviewFact}; fases=${ran.join(' → ')}; build=${build.summary}; ` +
1172
- `verify=${JSON.stringify(verified.commands)}; qa=${qa.evidence}; commit=${commit.hash || commit.reason}.`,
1208
+ `verify=${JSON.stringify(verified.commands)}; cubiertos=${JSON.stringify(covered)}; ` +
1209
+ (noSurface.length ? `sin-superficie=${JSON.stringify(noSurface.map(({ criterion, reason }) => ({
1210
+ criterion, reason: reason || 'no se ejecuta' })))}; ` : '') +
1211
+ (outOfVerify.length ? `fuera-de-verify=${JSON.stringify(outOfVerify)}; ` : '') +
1212
+ `qa=${qa.evidence}; commit=${commit.hash || commit.reason}. En tests rastreá cada criterio con la ` +
1213
+ `prueba que cubiertos le asigna` +
1214
+ (noSurface.length ? ', y los de sin-superficie con tests: n/a — <razón>' : '') +
1215
+ (outOfVerify.length ? '; cada condición de fuera-de-verify queda cumplida en tests, qa o commit' : '') + '.',
1173
1216
  { label: 'done' },
1174
1217
  )
1175
1218
  completed.push(task.id)
@@ -25,6 +25,7 @@ const {
25
25
  // Qué le falta a la superficie de automatización, que no comparte ayudantes con los tres verbos que
26
26
  // escriben. Se reexporta para que sus consumidores sigan pidiéndoselo a este módulo.
27
27
  const { check } = require('./check')
28
+ const { registrationProblems } = require('./registration')
28
29
 
29
30
  // Ejecuta el puente del runner tal como él lo invoca, y desde otra carpeta. Instalado no es lo mismo que
30
31
  // operativo: un bridge que el runner no puede lanzar —porque su ruta es relativa y el cwd es otro, o
@@ -48,7 +49,12 @@ function probeBridge(paths, runner) {
48
49
  const launchDirs = [paths.install, path.dirname(script)]
49
50
  for (const event of hookEvents) {
50
51
  for (const cwd of launchDirs) {
51
- const payload = JSON.stringify({ toolCall: { args: { CommandLine: 'ls', Cwd: paths.install } } })
52
+ // Una llamada inocua para el evento que la juzga, y ninguna para los demás: un `pre-files` con un
53
+ // comando no describe ningún archivo, y el puente niega lo que no sabe describir (caso 200). La
54
+ // entrada vacía es la forma legítima de no describir nada, y ejercita igual el arranque y la raíz.
55
+ const payload = JSON.stringify(event === 'pre-shell'
56
+ ? { toolCall: { args: { CommandLine: 'ls', Cwd: paths.install } } }
57
+ : {})
52
58
  const result = spawnSync(process.execPath, [script, event], { cwd, input: payload, encoding: 'utf8' })
53
59
  let response = {}
54
60
  try { response = JSON.parse((result.stdout || '').trim()) } catch { response = {} }
@@ -68,7 +74,9 @@ function probeBridge(paths, runner) {
68
74
  return problems
69
75
  }
70
76
 
71
- function doctor(root, name, output = console) {
77
+ // `afterInstall` es la llamada con la que cierra `install`: ahí una copia registrada vieja es el paso que sigue
78
+ // —registrar lo que se acaba de instalar—, no una instalación rota, así que se avisa en vez de fallar.
79
+ function doctor(root, name, output = console, { afterInstall = false } = {}) {
72
80
  const runner = runnerManifest(root, name)
73
81
  const paths = runnerPaths(root, name, runner)
74
82
  const errors = []
@@ -151,6 +159,7 @@ function doctor(root, name, output = console) {
151
159
  }
152
160
  // Un puente que no responde niega cada llamada del runner: es error, no advertencia.
153
161
  for (const problem of probeBridge(paths, runner)) errors.push(problem)
162
+ for (const problem of registrationProblems(paths, runner)) (afterInstall ? warnings : errors).push(problem)
154
163
 
155
164
  const executable = spawnSync(
156
165
  'sh',