@ingeniomaps/cauce 0.98.0 → 0.99.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/agents/roles/system/ai-product-manager/learning/HISTORY.md +1 -0
  3. package/agents/roles/system/analytics-engineer/learning/HISTORY.md +1 -0
  4. package/agents/roles/system/backend-engineer/learning/HISTORY.md +1 -0
  5. package/agents/roles/system/cloud-architect/learning/HISTORY.md +1 -0
  6. package/agents/roles/system/customer-success-manager/learning/HISTORY.md +1 -0
  7. package/agents/roles/system/data-engineer/learning/HISTORY.md +1 -0
  8. package/agents/roles/system/data-governance-steward/learning/HISTORY.md +1 -0
  9. package/agents/roles/system/devops-engineer/learning/HISTORY.md +1 -0
  10. package/agents/roles/system/engineering-manager/learning/HISTORY.md +1 -0
  11. package/agents/roles/system/engineering-manager/learning/sources.yaml +1 -1
  12. package/agents/roles/system/engineering-manager/references/operating-model.md +1 -1
  13. package/agents/roles/system/machine-learning-engineer/learning/HISTORY.md +1 -0
  14. package/agents/roles/system/mlops-engineer/learning/HISTORY.md +1 -0
  15. package/agents/roles/system/mobile-engineer/learning/HISTORY.md +1 -0
  16. package/agents/roles/system/product-manager/learning/HISTORY.md +1 -0
  17. package/agents/roles/system/site-reliability-engineer/learning/HISTORY.md +1 -0
  18. package/agents/roles/system/software-architect/learning/HISTORY.md +1 -0
  19. package/agents/roles/system/ui-designer/learning/HISTORY.md +1 -0
  20. package/automatization/hooks/README.md +6 -2
  21. package/automatization/runners/antigravity/README.md +4 -1
  22. package/automatization/runners/antigravity/hook.js +84 -22
  23. package/automatization/runners/antigravity/manifest.json +1 -0
  24. package/automatization/shared/acceptance.js +26 -0
  25. package/automatization/workflows/agent-promote.js +3 -1
  26. package/automatization/workflows/autobuild.js +69 -26
  27. package/engine/agents/learning-seal.js +20 -1
  28. package/engine/automation/index.js +11 -2
  29. package/engine/automation/registration.js +89 -0
  30. package/engine/cli/args.js +1 -1
  31. package/engine/cli/catalog.js +8 -2
  32. package/engine/cli/ops.js +1 -1
  33. package/engine/cli/validate.js +3 -0
  34. package/engine/cli/wiring.js +1 -1
  35. package/engine/config/validate.js +30 -11
  36. package/engine/core/changelog.js +60 -1
  37. package/engine/core/migrations.js +275 -0
  38. package/engine/core/repos.js +18 -4
  39. package/engine/hooks/approval.js +77 -14
  40. package/engine/hooks/chat.js +85 -27
  41. package/engine/hooks/files.js +9 -107
  42. package/engine/hooks/input.js +67 -11
  43. package/engine/hooks/migrations.js +93 -0
  44. package/engine/hooks/push.js +5 -3
  45. package/engine/hooks/run.js +10 -5
  46. package/engine/hooks/secrets-shell.js +2 -1
  47. package/engine/hooks/self-approval.js +39 -22
  48. package/engine/hooks/verify.js +79 -22
  49. package/engine/planning/acceptance.js +18 -0
  50. package/engine/planning/contracts.js +78 -45
  51. package/engine/schemas/ops-config.schema.json +9 -0
  52. package/package.json +1 -1
  53. package/template/AGENTS.md +11 -2
  54. package/template/planning/PROTOCOL.md +6 -2
@@ -0,0 +1,275 @@
1
+ 'use strict'
2
+
3
+ // Qué es una migración para un proyecto, qué parte de ella aplica y qué cuenta como destruir. Lo preguntan
4
+ // el guard `migrations`, que juzga cada escritura, y `check`, que mira si lo declarado alcanza a algún
5
+ // archivo; la respuesta tiene que ser la misma en los dos, y por eso vive una sola vez acá.
6
+
7
+ const path = require('node:path')
8
+ const { spawnSync } = require('node:child_process')
9
+
10
+ // Sin esto el guard sólo veía `.sql`, así que en TypeORM, Prisma, Django, Rails o Alembic no miraba nada:
11
+ // ni frenaba el SQL destructivo, ni protegía una migración existente de ser reescrita. Y no lo decía —
12
+ // aparecía cableado y en verde—. Medido en una instancia real: 64 migraciones `.sql` cubiertas y **409
13
+ // TypeORM `.ts` invisibles** (caso 077).
14
+ //
15
+ // No se amplía el default a `.ts`/`.py`/`.rb` por su cuenta: eso reintroduciría el falso positivo del
16
+ // caso 039 —un archivo de lenguaje que menciona `DROP TABLE` en un comentario o en un string— por otra
17
+ // puerta. Declararlo es opt-in porque el que sabe si sus migraciones son de lenguaje es el proyecto, y
18
+ // porque así el costo lo elige quien lo paga.
19
+ const DEFAULT_EXTENSIONS = ['sql']
20
+
21
+ // Las carpetas se declaran por lo mismo que las extensiones: ninguna lista del motor alcanza a Alembic,
22
+ // cuya carpeta es el argumento de `alembic init` y puede moverse con `version_locations` (caso 196). El
23
+ // default son los tres nombres que el motor usaba antes, así que quien no declara nada no ve diferencia.
24
+ const DEFAULT_PATHS = ['migrations', 'migration', 'migrate']
25
+
26
+ // Una carpeta relativa, de segmentos que no empiezan con punto: así no hay `..`, `.` ni ruta absoluta, y
27
+ // lo que entra en la expresión regular no trae metacaracteres salvo el punto, que se escapa al usarla.
28
+ const PATH_SHAPE = /^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$/
29
+ const EXTENSION_SHAPE = /^[a-z0-9]+$/
30
+
31
+ // Lo inválido no se descarta en silencio: descartarlo dejaría al proyecto creyendo que declaró una
32
+ // cobertura que no tiene. Lo valida `validateOpsConfig`, y acá se ignora lo que no pasa ese filtro porque
33
+ // el guard no es el lugar donde se enseña a escribir la configuración.
34
+ function declared(config, key, shape, fallback) {
35
+ const value = ((config && config.migrations) || {})[key]
36
+ const usable = (Array.isArray(value) ? value : []).filter((one) => typeof one === 'string' && shape.test(one))
37
+ return usable.length ? usable : fallback
38
+ }
39
+
40
+ function pattern(config) {
41
+ const extensions = declared(config, 'extensions', EXTENSION_SHAPE, DEFAULT_EXTENSIONS)
42
+ const folders = declared(config, 'paths', PATH_SHAPE, DEFAULT_PATHS).map((one) => one.replace(/\./g, '\\.'))
43
+ return new RegExp(`(?:^|/)(?:${folders.join('|')})/.*\\.(?:${extensions.join('|')})$`, 'i')
44
+ }
45
+
46
+ // Dónde empieza la reversión, por formato. Cada marcador es el que su herramienta reconoce y no uno
47
+ // parecido, porque partir donde la herramienta no parte deja sin juzgar algo que sí corre:
48
+ //
49
+ // - goose (`internal/sqlparser/parser.go` de pressly/goose, `main`): la línea empieza con `--` sin
50
+ // espacio antes, y la anotación se compara con `strings.EqualFold`, así que `--+goose down` vale.
51
+ // - dbmate (`pkg/dbmate/migration.go` de amacneil/dbmate, `main`): `(?m)^--\s*migrate:down(\s*$|\s+\S+)`,
52
+ // sensible a mayúsculas.
53
+ //
54
+ // El `Up` también se reconoce porque devuelve al lado que aplica: lo que venga después de él se juzga.
55
+ const SQL_MARKERS = [
56
+ { up: /^--\s*\+goose\s*up\s*$/i, down: /^--\s*\+goose\s*down\s*$/i, label: '-- +goose Down' },
57
+ { up: /^--\s*migrate:up(?:\s*$|\s+\S+)/, down: /^--\s*migrate:down(?:\s*$|\s+\S+)/, label: '-- migrate:down' },
58
+ ]
59
+
60
+ // El método de reversión de cada herramienta de lenguaje: `downgrade()` de Alembic, `down` de Rails
61
+ // (también el `self.down` de las versiones viejas), `down()` de TypeORM y `exports.down` o `export function
62
+ // down` de Knex. Se lo reconoce al principio de la línea, así que una llamada a `this.down(...)` dentro de
63
+ // `up` no cuenta.
64
+ const LANGUAGE_HEADERS = [
65
+ { header: /^(\s*)(?:async\s+)?def\s+downgrade\s*\(/, label: 'downgrade()' },
66
+ { header: /^(\s*)def\s+(?:self\.)?down\b/, label: 'down' },
67
+ { header: /^(\s*)(?:(?:public|protected|private)\s+)?(?:async\s+)?down\s*\(/, label: 'down()' },
68
+ { header: /^(\s*)(?:module\.)?exports\.down\s*=/, label: 'exports.down' },
69
+ { header: /^(\s*)export\s+(?:async\s+)?function\s+down\s*\(|^(\s*)export\s+const\s+down\s*=/, label: 'down()' },
70
+ ]
71
+
72
+ // Las herramientas golang-migrate y sqlx guardan la reversión en otro archivo, `{version}_{title}.down.{extension}`
73
+ // (`MIGRATIONS.md` de golang-migrate/migrate), y ése es reversión entera.
74
+ const DOWN_FILE = /\.down\.[^./]+$/i
75
+
76
+ const indent = (line) => line.match(/^\s*/)[0].length
77
+
78
+ // Qué líneas aplican: una lista de booleanos, uno por línea, y el marcador que parte. `null` si no hay nada
79
+ // que partir, para que quien pregunta juzgue el texto entero: sin marcador reconocido se degrada al
80
+ // comportamiento de antes en vez de fallar abierto. Los marcadores son comentarios y no aplican.
81
+ function sqlMask(lines) {
82
+ const markers = SQL_MARKERS.filter((one) => lines.some((line) => one.up.test(line) || one.down.test(line)))
83
+ if (!markers.length) return null
84
+ let applies = true
85
+ const mask = lines.map((line) => {
86
+ if (markers.some((one) => one.up.test(line))) {
87
+ applies = true
88
+ return false
89
+ }
90
+ if (markers.some((one) => one.down.test(line))) {
91
+ applies = false
92
+ return false
93
+ }
94
+ return applies
95
+ })
96
+ return { mask, label: markers[0].label }
97
+ }
98
+
99
+ // En una migración de lenguaje el bloque termina donde la indentación vuelve a la del encabezado: la línea
100
+ // que lo cierra —`}`, `end`, `};`— queda del lado que aplica, y da igual porque no destruye nada. Es la
101
+ // forma en que las herramientas generan sus migraciones; lo que se aparta de ella acorta el bloque en vez de
102
+ // alargarlo, así que un archivo con formato raro se juzga de más y no de menos. El borde que queda abierto
103
+ // es escribir `up` en la misma línea que el encabezado de `down`, y eso no lo genera ninguna.
104
+ function languageMask(lines) {
105
+ let label = ''
106
+ let depth = null
107
+ const mask = lines.map((line) => {
108
+ if (depth !== null) {
109
+ if (!line.trim() || indent(line) > depth) return false
110
+ depth = null
111
+ }
112
+ const found = LANGUAGE_HEADERS.find((one) => one.header.test(line))
113
+ if (!found) return true
114
+ label = found.label
115
+ depth = indent(line)
116
+ return false
117
+ })
118
+ return label ? { mask, label } : null
119
+ }
120
+
121
+ function applyMask(file, lines) {
122
+ if (DOWN_FILE.test(file)) return { mask: lines.map(() => false), label: path.basename(file) }
123
+ return /\.sql$/i.test(file) ? sqlMask(lines) : languageMask(lines)
124
+ }
125
+
126
+ // Lo que hay que juzgar de una escritura: el texto de las líneas que aplican y el marcador que partió, o
127
+ // el texto entero sin marcador si no había nada que partir.
128
+ //
129
+ // En un `Edit` el guard no ve el archivo, sólo el fragmento. Se reconstruye el archivo como va a quedar
130
+ // —`old` reemplazado en `disk`— y se juzga la parte del fragmento que cae del lado que aplica, así que un
131
+ // fragmento que mueve un marcador se lee en su lugar y no suelto. Sin `disk` o sin `old` adentro se juzga
132
+ // el fragmento entero, como antes.
133
+ function judged(file, text, edit = {}) {
134
+ const { disk, old, all } = edit
135
+ let result = String(text)
136
+ let spans = [[0, result.length]]
137
+ if (old && typeof disk === 'string' && disk.includes(old)) {
138
+ const at = disk.indexOf(old)
139
+ const pieces = all ? disk.split(old) : [disk.slice(0, at), disk.slice(at + old.length)]
140
+ result = pieces[0]
141
+ spans = []
142
+ for (const piece of pieces.slice(1)) {
143
+ spans.push([result.length, result.length + String(text).length])
144
+ result += String(text) + piece
145
+ }
146
+ } else if (old) return { text: String(text), label: '' }
147
+ const lines = result.split('\n')
148
+ const split = applyMask(file, lines.map((line) => line.replace(/\r$/, '')))
149
+ if (!split && spans.length === 1 && spans[0][0] === 0 && spans[0][1] === result.length) {
150
+ return { text: result, label: '' }
151
+ }
152
+ const kept = []
153
+ let at = 0
154
+ lines.forEach((line, index) => {
155
+ const [from, to] = [at, at + line.length]
156
+ at = to + 1
157
+ if (split && !split.mask[index]) return
158
+ for (const [start, end] of spans) {
159
+ if (Math.max(start, from) <= Math.min(end, to)) {
160
+ kept.push(result.slice(Math.max(start, from), Math.min(end, to)))
161
+ }
162
+ }
163
+ })
164
+ return { text: kept.join('\n'), label: split ? split.label : '' }
165
+ }
166
+
167
+ // Las secciones de un `apply_patch`, por archivo: la línea `*** Add|Update|Delete File: <ruta>` y lo que
168
+ // sigue hasta la cabecera del próximo archivo o `*** End Patch`. Cada línea conserva su prefijo —`+`
169
+ // agregada, `-` quitada, ` ` contexto— porque de él depende qué se juzga. `@@`, `*** Move to:` y
170
+ // `*** End of File` son del formato y no contenido, y no cortan la sección: cortarla ahí dejaba sin juzgar
171
+ // lo que venía después, y pasaba lo que el guard viejo, que juzgaba el sobre entero, frenaba.
172
+ function patchSections(patch) {
173
+ const sections = new Map()
174
+ let current = null
175
+ for (const line of String(patch).split('\n')) {
176
+ const header = line.match(/^\*\*\* (Add|Update|Delete) File:\s*(.+)$/)
177
+ if (header) {
178
+ current = { kind: header[1].toLowerCase(), lines: [] }
179
+ sections.set(header[2].trim(), current)
180
+ } else if (/^\*\*\* End Patch\s*$/.test(line)) current = null
181
+ else if (current && !line.startsWith('@@') && !line.startsWith('*** ')) {
182
+ current.lines.push({ op: line[0] || ' ', text: line.slice(1) })
183
+ }
184
+ }
185
+ return sections
186
+ }
187
+
188
+ // Lo que hay que juzgar de un archivo del parche, con el mismo contrato que `judged` (caso 199). Un archivo
189
+ // nuevo es su contenido entero sin el prefijo, y se parte como un `Write`. Una modificación es el lado que va a
190
+ // quedar —contexto y agregado—, partido con lo que el hunk trae, y de él se juzga sólo lo agregado: lo quitado
191
+ // no destruye nada y el contexto ya estaba. Si el hunk no trae ningún marcador, no se sabe de qué lado cae y se
192
+ // juzga todo lo agregado, como antes.
193
+ function judgedPatch(file, section) {
194
+ if (section.kind === 'delete') return { text: '', label: '' }
195
+ if (section.kind === 'add') return judged(file, section.lines.map((line) => line.text).join('\n'))
196
+ const after = section.lines.filter((line) => line.op !== '-')
197
+ const split = applyMask(file, after.map((line) => line.text.replace(/\r$/, '')))
198
+ const kept = after.filter((line, index) => line.op === '+' && (!split || split.mask[index]))
199
+ return { text: kept.map((line) => line.text).join('\n'), label: split ? split.label : '' }
200
+ }
201
+
202
+ // Lo que destruye escrito en SQL. Cada rama cierra su propio límite. Cuando el `\b` estaba al final del
203
+ // grupo se aplicaba a las tres, y la de `delete` termina a propósito en `;`: después de un punto y coma no
204
+ // hay límite de palabra, así que `DELETE FROM pedidos;` —la forma que tiene en cualquier migración— pasaba
205
+ // y sólo frenaba la variante sin punto y coma. `drop column` y `drop constraint` faltaban: pierden datos y
206
+ // garantías igual que `drop table`.
207
+ const DESTRUCTIVE_SQL = new RegExp(
208
+ String.raw`\bdrop\s+(?:table|database|schema|column|constraint)\b` +
209
+ String.raw`|\btruncate\b` +
210
+ String.raw`|\bdelete\s+from\s+\S+\s*(?:;|$)`,
211
+ 'i',
212
+ )
213
+
214
+ // Lo que destruye escrito con la API de la herramienta (caso 193). Son los nombres que la documentación
215
+ // pública de cada una define, comprobados el 2026-09-23 y no más: Alembic (`ops.html`), Rails
216
+ // (`active_record_migrations.md`), TypeORM (`migrations/09-api.md`), Knex (`schema-builder.md`) y Django
217
+ // (`ref/migration-operations`). La lista envejece nombrando: lo que no está sigue pasando, igual que antes.
218
+ // Sensible a mayúsculas porque la API lo es, y así `DeleteModel` no se confunde con prosa.
219
+ const DESTRUCTIVE_API = new RegExp(
220
+ String.raw`\b(?:drop_table|drop_column|remove_columns?|drop_join_table` +
221
+ String.raw`|dropTable|dropTableIfExists|dropColumns?|DeleteModel|RemoveField)\b`,
222
+ )
223
+
224
+ // Qué destruye en `text`, o vacío: el fragmento que lo delata y de qué clase es, para que el mensaje pueda
225
+ // nombrar la sentencia y no sólo el archivo.
226
+ function destructive(text) {
227
+ const sql = String(text).match(DESTRUCTIVE_SQL)
228
+ if (sql) return { kind: 'SQL destructivo', what: sql[0].replace(/\s+/g, ' ').toUpperCase() }
229
+ const api = String(text).match(DESTRUCTIVE_API)
230
+ return api ? { kind: 'un borrado destructivo', what: api[0] } : null
231
+ }
232
+
233
+ // Lo declarado en `migrations` que no alcanza a ningún archivo del producto: una extensión o una carpeta
234
+ // que el guard promete mirar y no mira nada. Es lo que el 077 y el 196 tienen en común —cobertura
235
+ // declarada que no cubre, en silencio—, y va en `check` y no en el guard porque recorre el repositorio y el
236
+ // guard corre en cada escritura (R26).
237
+ //
238
+ // Sólo mira lo que el proyecto declaró: sin declarar, el default no promete nada que alguien haya elegido.
239
+ // Lee `git ls-files` de cada repositorio de las raíces, que es barato y deja afuera lo generado; sin
240
+ // repositorio no dice nada, como el resto de los avisos que preguntan a git.
241
+ function coverageWarnings(repos, config) {
242
+ const migrations = (config && config.migrations) || {}
243
+ const askExtensions = Array.isArray(migrations.extensions)
244
+ const askPaths = Array.isArray(migrations.paths)
245
+ if (!askExtensions && !askPaths) return []
246
+ const files = repos.flatMap((repo) => {
247
+ const listed = spawnSync('git', ['ls-files', '-z'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
248
+ return listed.status === 0 ? listed.stdout.split('\0').filter(Boolean) : []
249
+ })
250
+ if (!files.length) return []
251
+ const matcher = pattern(config)
252
+ const covered = files.filter((file) => matcher.test(file))
253
+ const warnings = []
254
+ if (askExtensions) {
255
+ for (const one of declared(config, 'extensions', EXTENSION_SHAPE, [])) {
256
+ if (covered.some((file) => file.toLowerCase().endsWith(`.${one}`))) continue
257
+ warnings.push(`ops.config.json: migrations.extensions "${one}" no alcanza a ningún archivo bajo `
258
+ + `${declared(config, 'paths', PATH_SHAPE, DEFAULT_PATHS).join(', ')}: el guard no mira ninguna `
259
+ + 'migración con esa extensión. Si viven en otra carpeta, declarala en migrations.paths')
260
+ }
261
+ }
262
+ if (askPaths) {
263
+ for (const one of declared(config, 'paths', PATH_SHAPE, [])) {
264
+ const only = pattern({ migrations: { ...migrations, paths: [one] } })
265
+ if (files.some((file) => only.test(file))) continue
266
+ warnings.push(`ops.config.json: migrations.paths "${one}" no alcanza a ningún archivo con las extensiones `
267
+ + `${declared(config, 'extensions', EXTENSION_SHAPE, DEFAULT_EXTENSIONS).join(', ')}`)
268
+ }
269
+ }
270
+ return warnings
271
+ }
272
+
273
+ module.exports = {
274
+ PATH_SHAPE, EXTENSION_SHAPE, pattern, judged, judgedPatch, patchSections, destructive, coverageWarnings,
275
+ }
@@ -34,7 +34,7 @@ function reposFor(opsRoot, service) {
34
34
  .filter(Boolean)
35
35
  // Dos raíces del mismo repositorio son un solo repositorio: lo ambiguo es a cuál pertenece el
36
36
  // servicio, no cuántas rutas lo contienen.
37
- .filter((repo, index, todos) => todos.indexOf(repo) === index)
37
+ .filter((repo, index, all) => all.indexOf(repo) === index)
38
38
  }
39
39
 
40
40
  // El repositorio del servicio cuando no hay duda. Sin ninguno o con varios devuelve vacío, y quien
@@ -84,7 +84,7 @@ function unrecordedCommits(repo, since, recorded) {
84
84
  // historial reescrito o un `commit --date` la cuenta daba cero sobre un repositorio lleno.
85
85
  const log = git(repo, 'log', '--no-merges', '--date=short', '--format=%h %ad %s')
86
86
  if (log.status !== 0) return []
87
- const conocidos = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
87
+ const known = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
88
88
  // El hash y la fecha se leen partiendo por espacios y no por columna: `%h` mide 7 por default y git lo
89
89
  // sube solo cuando el repositorio crece —en éste mide 8—, así que una posición fija leía un espacio en
90
90
  // vez de la fecha y ningún commit pasaba el filtro. El aviso quedaba mudo sin decirlo, que es la peor
@@ -92,7 +92,7 @@ function unrecordedCommits(repo, since, recorded) {
92
92
  return log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
93
93
  .map((line) => ({ line, sha: line.split(/\s+/)[0], date: line.split(/\s+/)[1] }))
94
94
  .filter((one) => one.date >= since)
95
- .filter((one) => !conocidos.has(one.sha.slice(0, 7)))
95
+ .filter((one) => !known.has(one.sha.slice(0, 7)))
96
96
  .map((one) => one.line)
97
97
  }
98
98
 
@@ -153,4 +153,18 @@ function unrecordedHumanActions(opsRoot, rows) {
153
153
  .map((row) => `HUMAN_ACTIONS.md: ${row.task} figura resuelta y ningún commit la registró`)
154
154
  }
155
155
 
156
- module.exports = { reposFor, repoOf, lastCommit, coverageWarnings, unrecordedHumanActions }
156
+ // Qué archivos tocó un commit, buscado en todos los repositorios declarados: una entrada de DONE nombra
157
+ // el sha y no el repositorio. Devuelve null si ninguno lo conoce, y quien pregunta decide qué significa.
158
+ // `--root` es para que el primer commit de un repositorio también liste lo suyo.
159
+ function commitFiles(opsRoot) {
160
+ const repos = reposFor(opsRoot, '.')
161
+ return (sha) => {
162
+ for (const repo of repos) {
163
+ const shown = git(repo, 'diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sha)
164
+ if (shown.status === 0) return shown.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
165
+ }
166
+ return null
167
+ }
168
+ }
169
+
170
+ module.exports = { reposFor, repoOf, lastCommit, coverageWarnings, unrecordedHumanActions, commitFiles }
@@ -15,8 +15,8 @@
15
15
  // **Se coteja, no se consume.** Borrar el archivo al leerlo daría el mismo alcance y traería dos cosas
16
16
  // que no queremos: hoy ningún guard escribe en el repositorio, y `governance` corre antes que `verify`,
17
17
  // así que un commit frenado por otra razón se habría llevado puesta la aprobación y habría que
18
- // rehacerla. Cotejando, la aprobación vale para el conjunto que nombra y para ningún otro: en cuanto
19
- // cambia lo que está en el índice deja de servir, que es «por operación» sin fecha ni contador.
18
+ // rehacerla. Cotejando, la aprobación vale para las rutas que nombra y para ninguna otra: lo que se sume
19
+ // al índice vuelve a frenar, que es «por operación» sin fecha ni contador.
20
20
  //
21
21
  // Queda a la vista porque `check` avisa mientras exista. Sin eso, un archivo olvidado sigue autorizando
22
22
  // esas mismas rutas la próxima vez que alguien las stagee, que es la puerta abierta que esto evitaba.
@@ -79,6 +79,17 @@ function where(input) {
79
79
  return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
80
80
  }
81
81
 
82
+ // Lo que el último mensaje de la persona negaba no quedó esperando su confirmación, así que un «dale» no
83
+ // lo aprobaría: ofrecerlo sería mandarla a contestar para volver a frenar (caso 188). Lo que sí lo pasa es
84
+ // pedirlo nombrándolo, que es una orden y no una confirmación.
85
+ function REFUSED(items, input = {}) {
86
+ return `Lo último que dijo la persona niega o frena ${items.join(', ')}, así que no quedó esperando su `
87
+ + 'confirmación y un sí no lo aprobaría: no reintentes. '
88
+ + (input.agent_id
89
+ ? 'Devolvele el bloqueo a quien te lanzó. '
90
+ : 'Si lo quiere, que lo pida en el chat nombrándolo. ')
91
+ }
92
+
82
93
  // Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
83
94
  // las líneas exactas porque cada guard coteja la ruta en la forma que tiene a mano —absoluta la que llega
84
95
  // de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
@@ -93,8 +104,10 @@ function where(input) {
93
104
  // `pasteable` es lo que se puede aprobar por archivo, y por defecto es todo: un guard lo angosta cuando lo
94
105
  // que tiene a mano no sirve para pegar —por qué, en `secrets-shell.js` (caso 118)—. Lo frenado se anota
95
106
  // igual, así que el «dale» sigue cubriendo todo.
96
- function HOW(variable, lines, input, pasteable = lines) {
97
- const chat = CHAT.hold(input, lines)
107
+ function HOW(variable, lines, input, pasteable = lines, { durable = false } = {}) {
108
+ const held = CHAT.hold(input, lines)
109
+ const dropped = held ? held.dropped : []
110
+ const chat = held && dropped.length < lines.length
98
111
  const stuck = lines.filter((one) => !pasteable.includes(one))
99
112
  // El alcance del «dale» va con la oferta y no después. La rama del pegado ya decía el suyo —«valen para
100
113
  // ese conjunto»— y la del chat no decía ninguno, siendo la que se ofrece primero. Las dos mitades que
@@ -103,18 +116,35 @@ function HOW(variable, lines, input, pasteable = lines) {
103
116
  // siguientes** es la dirección permisiva, la que nadie nota porque lo que no ocurre es un bloqueo.
104
117
  // `check` la muestra al final de la corrida —es lo que trajo el 117—, y al concederla no la decía nadie
105
118
  // (caso 170).
106
- const ask = chat
107
- ? 'Decile a la persona qué se frenó y por qué, y pedile que lo confirme con sus palabras: un «dale» '
119
+ // Un subagente no puede esperar la respuesta —su turno termina antes—, así que lo que le toca es
120
+ // devolver el bloqueo: preguntar lo hace quien lo lanzó, y el reintento pasa aunque vuelva a ser delegado
121
+ // (caso 186).
122
+ const lead = input.agent_id
123
+ ? 'Sos un subagente y no podés esperar la respuesta: devolvele a quien te lanzó qué se frenó y por qué, '
124
+ + 'para que se lo pregunte a la persona. Si ella lo confirma, el mismo cambio pasa aunque lo reintente '
125
+ + 'un subagente. '
126
+ : 'Decile a la persona qué se frenó y por qué, y pedile que lo confirme con sus palabras: un «dale» '
108
127
  + 'alcanza, pero no hace falta esa palabra. Si lo que contesta es un sí, reintentá el mismo cambio y '
109
128
  + 'pasa; si duda, pregunta o dice que no, no reintentes. Juzgarlo te toca a vos: el guard sólo frena la '
110
- + 'respuesta que niega, frena o pregunta. Esa confirmación cubre lo que se frenó y nada más —algo nuevo '
111
- + 'vuelve a frenar— y sigue valiendo en los mensajes siguientes hasta que ella lo niegue. '
129
+ + 'respuesta que niega, frena o pregunta. '
130
+ const ask = chat
131
+ ? lead + 'Esa confirmación cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo '
132
+ + 'en los mensajes siguientes hasta que ella lo niegue. '
112
133
  : ''
134
+ const refused = dropped.length ? REFUSED(dropped, input) : ''
135
+ const keep = chat && durable ? KEEP(input, pasteable.filter((one) => !dropped.includes(one))) : ''
136
+ // Sin chat la salida es la misma, y también se dice como cosa de ella: el imperativo que el párrafo de
137
+ // arriba sacó de la rama con chat seguía acá, y es lo único que ve quien no tiene a nadie en el chat
138
+ // (caso 194). A quien corre el guard le toca otra cosa, y se le dice cuál. Vale también cuando hay persona
139
+ // pero no se le ofrece contestar, porque su último mensaje negaba todo lo frenado.
113
140
  const paste = pasteable.length
114
- ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
141
+ ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella' : 'Esto lo aprueba una persona: que pegue ella')
142
+ + ' tal cual en'
115
143
  + ` ${where(input)} estas líneas:\n`
116
144
  + pasteable.map((line) => ` ${line}\n`).join('')
117
- + 'Valen para ese conjunto y dejan de valer en cuanto cambie. '
145
+ + 'Cada línea vale hasta que alguien la borre, y lo que no esté ahí vuelve a frenar. '
146
+ + (chat ? '' : 'Vos no lo escribas —un guard lo frena—: decí qué se frenó y dónde, y reintentá cuando '
147
+ + 'esté; si sos un subagente, devolvele el bloqueo a quien te lanzó. ')
118
148
  : ''
119
149
  const unresolved = stuck.length
120
150
  ? `Por archivo no hay línea que pegar para ${stuck.join(', ')}: la ruta llegó con una expansión del shell `
@@ -128,7 +158,28 @@ function HOW(variable, lines, input, pasteable = lines) {
128
158
  ? `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
129
159
  + 'es la vía recomendada.'
130
160
  : ''
131
- return ask + paste + unresolved + off
161
+ return ask + keep + refused + paste + unresolved + off
162
+ }
163
+
164
+ // La lectura que un proyecto necesita siempre —el token con que su regla manda autenticar— se preguntaba en
165
+ // cada sesión, porque el «dale» dura la sesión y la línea del archivo la tenía que pegar la persona a mano:
166
+ // «pon esa línea» o «acepto que leas» no le alcanzaban al agente para escribírsela (caso 202).
167
+ //
168
+ // La pregunta va entera al agente, y no se le pide a la persona ninguna frase: cuál de las dos cosas quiso la
169
+ // juzga él, igual que el «dale» (caso 184). Lo que hace el guard es dejar anotados, además de lo frenado, el
170
+ // archivo de aprobación, así que la misma confirmación que habilita leer habilita escribir **esas líneas y
171
+ // ninguna otra** —cuáles, lo compara `self-approval`—. Sólo lo ofrece un guard de lectura: una lectura
172
+ // frena siempre la misma ruta, y ahí una línea permanente es lo que la persona está pidiendo; para un
173
+ // commit, lo frenado cambia con el índice y dejarlo escrito no le ahorra nada a nadie.
174
+ function KEEP(input, lines) {
175
+ const root = opsRoot(input)
176
+ if (!root || !lines.length) return ''
177
+ const held = CHAT.hold(input, [path.join(root, 'planning', APPROVAL)])
178
+ if (!held || held.dropped.length) return ''
179
+ return 'Preguntale también si quiere dejarlo aprobado para siempre, para no volver a preguntarlo en otra '
180
+ + `sesión. Si esa es su intención, agregá a ${where(input)} las líneas de abajo, con un comentario arriba que `
181
+ + 'diga quién lo aprobó, cuándo y para qué: la misma confirmación te deja escribirlas. Si sólo quiso que '
182
+ + 'leas ahora, no las escribas y vale para esta sesión. '
132
183
  }
133
184
 
134
185
  // Las dos exenciones que sobreviven a un bloqueo, para que `check` las muestre juntas: la lista que una
@@ -139,11 +190,23 @@ function HOW(variable, lines, input, pasteable = lines) {
139
190
  // repetir la autorización en cada mensaje —y eso está bien—, pero quedó del lado que nadie audita: vive en
140
191
  // el temporal del sistema, mientras que por una sola línea del archivo `check` sí avisaba. Una exención que
141
192
  // no se ve es un límite que ya no existe (caso 117).
193
+ //
194
+ // Una credencial aprobada se nombra aparte, y no como algo «sin borrar». Está ahí a propósito —la dejó
195
+ // escrita la persona para no volver a autorizar la misma lectura en cada sesión (caso 202)—, y contada
196
+ // junto a lo que quedó de un commit se leía como un olvido que había que limpiar. Sigue saliendo en cada
197
+ // corrida, igual que `writableOutsideRoots`: una exención deliberada también se ve.
142
198
  function warnings(root) {
143
199
  const out = []
200
+ const { credential } = require('./files')
144
201
  const approved = read(root)
145
- if (approved.length) {
146
- out.push(`planning/${APPROVAL}: ${approved.length} ruta(s) aprobadas y sin borrar; `
202
+ const secret = approved.filter((line) => credential({ cwd: root }, line))
203
+ const rest = approved.filter((line) => !secret.includes(line))
204
+ if (secret.length) {
205
+ out.push(`planning/${APPROVAL}: ${secret.length} credencial(es) aprobadas hasta que se borre su línea: `
206
+ + secret.join(', '))
207
+ }
208
+ if (rest.length) {
209
+ out.push(`planning/${APPROVAL}: ${rest.length} ruta(s) aprobadas y sin borrar; `
147
210
  + 'el archivo sigue autorizándolas')
148
211
  }
149
212
  const granted = CHAT.grantedIn(root)
@@ -153,4 +216,4 @@ function warnings(root) {
153
216
  return out
154
217
  }
155
218
 
156
- module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, warnings }
219
+ module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, REFUSED, warnings }