@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.
- package/CHANGELOG.md +113 -0
- package/agents/roles/system/ai-product-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/analytics-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/backend-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/cloud-architect/learning/HISTORY.md +1 -0
- package/agents/roles/system/customer-success-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/data-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/data-governance-steward/learning/HISTORY.md +1 -0
- package/agents/roles/system/devops-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/engineering-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/engineering-manager/learning/sources.yaml +1 -1
- package/agents/roles/system/engineering-manager/references/operating-model.md +1 -1
- package/agents/roles/system/machine-learning-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/mlops-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/mobile-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/product-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/site-reliability-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/software-architect/learning/HISTORY.md +1 -0
- package/agents/roles/system/ui-designer/learning/HISTORY.md +1 -0
- package/automatization/hooks/README.md +6 -2
- package/automatization/runners/antigravity/README.md +4 -1
- package/automatization/runners/antigravity/hook.js +84 -22
- package/automatization/runners/antigravity/manifest.json +1 -0
- package/automatization/shared/acceptance.js +26 -0
- package/automatization/workflows/agent-promote.js +3 -1
- package/automatization/workflows/autobuild.js +69 -26
- package/engine/agents/learning-seal.js +20 -1
- package/engine/automation/index.js +11 -2
- package/engine/automation/registration.js +89 -0
- package/engine/cli/args.js +1 -1
- package/engine/cli/catalog.js +8 -2
- package/engine/cli/ops.js +1 -1
- package/engine/cli/validate.js +3 -0
- package/engine/cli/wiring.js +1 -1
- package/engine/config/validate.js +30 -11
- package/engine/core/changelog.js +60 -1
- package/engine/core/migrations.js +275 -0
- package/engine/core/repos.js +18 -4
- package/engine/hooks/approval.js +77 -14
- package/engine/hooks/chat.js +85 -27
- package/engine/hooks/files.js +9 -107
- package/engine/hooks/input.js +67 -11
- package/engine/hooks/migrations.js +93 -0
- package/engine/hooks/push.js +5 -3
- package/engine/hooks/run.js +10 -5
- package/engine/hooks/secrets-shell.js +2 -1
- package/engine/hooks/self-approval.js +39 -22
- package/engine/hooks/verify.js +79 -22
- package/engine/planning/acceptance.js +18 -0
- package/engine/planning/contracts.js +78 -45
- package/engine/schemas/ops-config.schema.json +9 -0
- package/package.json +1 -1
- package/template/AGENTS.md +11 -2
- 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
|
+
}
|
package/engine/core/repos.js
CHANGED
|
@@ -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,
|
|
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
|
|
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) => !
|
|
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
|
-
|
|
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 }
|
package/engine/hooks/approval.js
CHANGED
|
@@ -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
|
|
19
|
-
//
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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.
|
|
111
|
-
|
|
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
|
|
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
|
-
+ '
|
|
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
|
-
|
|
146
|
-
|
|
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 }
|