@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
|
@@ -67,7 +67,8 @@ function secretsShell(input) {
|
|
|
67
67
|
if (!left.length) return
|
|
68
68
|
block(`el comando lee ${left.join(', ')}, que es una credencial: leerla la deja en el contexto de la sesión. `
|
|
69
69
|
+ 'Si hace falta un valor, pedíselo a una persona.\n'
|
|
70
|
-
+ AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input, left.filter((one) => !UNRESOLVED.test(one))
|
|
70
|
+
+ AP.HOW('OPS_SECRETS_READ_OVERRIDE', left, input, left.filter((one) => !UNRESOLVED.test(one)),
|
|
71
|
+
{ durable: true }))
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
module.exports = { secretsShell }
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Lo preguntan los dos guards de límites —el que mira un `Write` y el que mira el destino de un comando—,
|
|
8
8
|
// que ya son los que deciden dónde puede caer una escritura. La persona edita el archivo a mano, que ningún
|
|
9
|
-
// hook ve, o se lo pide al agente
|
|
10
|
-
// herramienta.
|
|
9
|
+
// hook ve, o se lo pide al agente en el chat: nombrándolo, o confirmando el bloqueo que le mostró qué iba a
|
|
10
|
+
// escribir. El registro del chat no lo escribe nunca una herramienta.
|
|
11
11
|
//
|
|
12
12
|
// **Nombrar el archivo autoriza el archivo, no lo que se escribe adentro**, y hasta 0.83.0 eso era todo lo
|
|
13
13
|
// que se comprobaba: la persona pedía agregar una ruta inocua, el agente escribía `push origin main`, y esa
|
|
@@ -34,6 +34,12 @@ const UNASKED = (file, missing) => `${file} es la aprobación de una persona y e
|
|
|
34
34
|
+ 'Escribí sólo lo que ella nombró en su mensaje. Si hacen falta las otras, decile cuáles y por qué, y '
|
|
35
35
|
+ 'pedile que lo confirme con sus palabras: si lo que contesta es un sí, reintentá la misma escritura y pasa.'
|
|
36
36
|
|
|
37
|
+
// Lo mismo que `UNASKED`, para cuando además el archivo no se nombró: ninguna de las líneas está pedida.
|
|
38
|
+
const UNNAMED = (file, lines) => `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. `
|
|
39
|
+
+ 'Esta escritura agrega:\n' + lines.map((line) => ` ${line}\n`).join('')
|
|
40
|
+
+ 'Decile qué querés dejar aprobado y por qué, y pedile que lo confirme con sus palabras: si lo que contesta '
|
|
41
|
+
+ 'es un sí, reintentá la misma escritura y pasa. Si no, que lo edite ella.'
|
|
42
|
+
|
|
37
43
|
// **Por shell se frena toda escritura.** Es la misma decisión que toma `ops-config`, y por qué un comando
|
|
38
44
|
// no se puede comparar está escrito allá. Lo que la trae hasta acá es que el contenido es lo único que
|
|
39
45
|
// separa la línea que la persona pidió de la que el agente se escribe solo.
|
|
@@ -49,15 +55,31 @@ function approvalRoot(input, file) {
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
// Lo que las dos vías deciden igual: el registro del chat no se escribe nunca, y la aprobación sólo si la
|
|
52
|
-
// persona la nombró en su mensaje.
|
|
58
|
+
// persona la nombró en su mensaje o confirmó el bloqueo que la frenaba.
|
|
53
59
|
//
|
|
54
60
|
// Se pregunta sin conceder: una concesión que sobreviviera al mensaje convertiría «agregá src/x.js a
|
|
55
61
|
// .ops-approval» en permiso para escribirle después cualquier otra línea, que es aprobarse solo por la
|
|
56
62
|
// puerta de al lado.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const named = (input, file) => CHAT.authorized(input, [file]).length > 0
|
|
64
|
+
const chatRecord = (file) => file === CHAT.DIR || file.startsWith(`${CHAT.DIR}${path.sep}`)
|
|
65
|
+
|
|
66
|
+
// Lo que esta escritura agrega al archivo. **Lo que ya estaba en disco no se vuelve a nombrar**: esta
|
|
67
|
+
// escritura no lo agrega, y exigirlo obligaría a repetir el archivo entero para sumar una línea. Quitar
|
|
68
|
+
// tampoco se pregunta: una aprobación más corta autoriza menos.
|
|
69
|
+
function added(input, root) {
|
|
70
|
+
const filed = new Set(AP.read(root))
|
|
71
|
+
return AP.lines(contentOf(input)).filter((line) => !filed.has(line))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// El archivo sin nombrar frenaba sin anotar nada, así que la salida que el propio mensaje ofrecía
|
|
75
|
+
// —confirmar el bloqueo— no aprobaba nada: el «dale» siguiente volvía a frenar igual, y la persona sólo
|
|
76
|
+
// podía destrabarlo con la frase exacta que nombra el archivo con un verbo (caso 202). Se anota el archivo
|
|
77
|
+
// con las líneas que trae, que es lo que la confirmación va a aprobar y lo que el mensaje le muestra.
|
|
78
|
+
function unnamed(input, root, file) {
|
|
79
|
+
const lines = added(input, root)
|
|
80
|
+
const held = lines.length ? CHAT.hold(input, [file, ...lines]) : false
|
|
81
|
+
if (!held || held.dropped.length) return SELF(file)
|
|
82
|
+
return UNNAMED(file, lines)
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
// Una línea de push no se pregunta como una ruta: `mentions` compara también el basename, así que para
|
|
@@ -67,10 +89,6 @@ const isPush = (line) => /^push\s+\S+\s+\S+$/.test(line)
|
|
|
67
89
|
|
|
68
90
|
// Por qué no se puede escribir este contenido, o vacío.
|
|
69
91
|
//
|
|
70
|
-
// **Lo que ya estaba en disco no se vuelve a nombrar**: esta escritura no lo agrega, y exigirlo obligaría a
|
|
71
|
-
// repetir el archivo entero para sumar una línea. Quitar tampoco se pregunta: una aprobación más corta
|
|
72
|
-
// autoriza menos.
|
|
73
|
-
//
|
|
74
92
|
// **Un fragmento sí se juzga, al revés que en `ops.config.json`.** Allá el archivo entrante se compara
|
|
75
93
|
// entero porque una llave cambia de sentido según lo que la rodea, y un `Edit` manda un pedazo; acá cada
|
|
76
94
|
// línea vale por sí sola, así que el pedazo que llega es exactamente lo que se agrega. Lo que no se puede
|
|
@@ -80,12 +98,11 @@ const isPush = (line) => /^push\s+\S+\s+\S+$/.test(line)
|
|
|
80
98
|
// Y se pregunta sin heredar lo que la sesión concedió antes: una línea acá la leen todos los guards y llega
|
|
81
99
|
// hasta la rama viva, así que tiene que ser la que la persona dijo en el mensaje en curso.
|
|
82
100
|
function unasked(input, root, file) {
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const paths = CHAT.authorized(input, added.filter((line) => !isPush(line)), { inherit: false })
|
|
101
|
+
const lines = added(input, root)
|
|
102
|
+
const pushes = CHAT.authorized(input, lines.filter(isPush), { asked: CHAT.ordersPush, inherit: false })
|
|
103
|
+
const paths = CHAT.authorized(input, lines.filter((line) => !isPush(line)), { inherit: false })
|
|
87
104
|
const cleared = new Set([...pushes, ...paths].map((one) => one.item))
|
|
88
|
-
const missing =
|
|
105
|
+
const missing = lines.filter((line) => !cleared.has(line))
|
|
89
106
|
if (!missing.length) return ''
|
|
90
107
|
// El archivo se anota junto con las líneas: sin él, el «dale» aprobaría las líneas y el guard volvería a
|
|
91
108
|
// frenar por el archivo, que en ese mensaje ya nadie nombra.
|
|
@@ -95,17 +112,17 @@ function unasked(input, root, file) {
|
|
|
95
112
|
|
|
96
113
|
// Por qué el agente no puede escribir en `file` con la herramienta de edición, o vacío.
|
|
97
114
|
function selfApproval(input, file) {
|
|
98
|
-
|
|
99
|
-
if (stop) return stop
|
|
115
|
+
if (chatRecord(file)) return CHAT_RECORD(file)
|
|
100
116
|
const root = approvalRoot(input, file)
|
|
101
|
-
|
|
117
|
+
if (!root) return ''
|
|
118
|
+
return named(input, file) ? unasked(input, root, file) : unnamed(input, root, file)
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
// Lo mismo para el destino de un comando, o vacío.
|
|
105
122
|
function selfApprovalShell(input, file) {
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
return
|
|
123
|
+
if (chatRecord(file)) return CHAT_RECORD(file)
|
|
124
|
+
if (!approvalRoot(input, file)) return ''
|
|
125
|
+
return named(input, file) ? BY_COMMAND(file) : SELF(file)
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
module.exports = { selfApproval, selfApprovalShell }
|
package/engine/hooks/verify.js
CHANGED
|
@@ -144,33 +144,90 @@ function commitTree(dir, input) {
|
|
|
144
144
|
return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// Dónde viven la consulta de sqlc y su generado lo declara cada proyecto en su config (`queries:` y
|
|
148
|
+
// `gen.go.out`), y acá no se lee: no hay parser de YAML y no se agrega uno para un guard. Lo que se usa
|
|
149
|
+
// en su lugar son dos hechos que no dependen del layout.
|
|
150
|
+
//
|
|
151
|
+
// La consulta se busca en `queries/` a cualquier profundidad —un monorepo la tiene en `api/db/queries/`
|
|
152
|
+
// (caso 192)—, y sólo cuenta si el repositorio tiene un `sqlc.yaml`, `sqlc.yml` o `sqlc.json`, los tres
|
|
153
|
+
// nombres que sqlc busca: sin esa condición, desanclar el patrón frenaría consultas de un reporte o
|
|
154
|
+
// fixtures que nadie genera, y cada freno falso se resuelve aprobándolo. Un `queries:` con otro nombre de
|
|
155
|
+
// carpeta sigue sin verse.
|
|
156
|
+
//
|
|
157
|
+
// El generado se reconoce por el nombre del archivo, no por su carpeta: sqlc escribe `<consulta>.sql.go`
|
|
158
|
+
// donde diga `out`, y esperar una carpeta `sqlc/` frenaba al que la llamó de otro modo (caso 187). La
|
|
159
|
+
// carpeta queda como respaldo. `.ts` o `.py` no: esos generadores son plugins y su nombre no se comprobó.
|
|
160
|
+
const SQL_SOURCE = /(?:^|\/)queries\/.*\.sql$/i
|
|
161
|
+
const SQL_GENERATED = /\.sql\.go$|(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i
|
|
162
|
+
function usesSqlc(dir) {
|
|
163
|
+
// `top` porque `dir` puede ser un subdirectorio y el índice se pregunta entero; `glob` para que `**/`
|
|
164
|
+
// alcance también la raíz. Se lee el índice y no el disco: una config sin trackear no es del proyecto.
|
|
165
|
+
// Si git no contesta se asume que sí: un guard que no pudo mirar no afloja.
|
|
166
|
+
const listed = run('git', ['-C', dir, 'ls-files', '--',
|
|
167
|
+
...['yaml', 'yml', 'json'].map((ext) => `:(top,glob)**/sqlc.${ext}`)], dir)
|
|
168
|
+
return !listed.ok || Boolean(listed.output.trim())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Una especificación OpenAPI se reconoce por lo que declara y no por la carpeta: `api/` guarda también la
|
|
172
|
+
// config de sqlc, un compose o fixtures, y cada uno pedía regenerar un cliente que no existe (caso 197). La
|
|
173
|
+
// carpeta queda como filtro barato antes de leer nada. Un fragmento de una especificación partida con `$ref`
|
|
174
|
+
// no declara nada, así que cuenta si su carpeta de primer nivel tiene en el índice una raíz que sí lo haga.
|
|
175
|
+
// Se lee el índice y no el disco, que es lo que el commit graba. Si git no contesta, dispara: un guard que
|
|
176
|
+
// no pudo mirar no afloja.
|
|
177
|
+
const OPENAPI_CANDIDATE = /^(?:(?:openapi|api|spec)\/(?:.*\/)?[^/]+|openapi|swagger)\.ya?ml$/i
|
|
178
|
+
const OPENAPI_ROOT = /^(?:openapi|swagger)\s*:/m
|
|
179
|
+
|
|
180
|
+
function declaresOpenApi(dir, file) {
|
|
181
|
+
// Un archivo borrado figura como cambio y ya no está en el índice: lo que era se lee en `HEAD`.
|
|
182
|
+
for (const revision of ['', 'HEAD']) {
|
|
183
|
+
const shown = run('git', ['-C', dir, 'show', `${revision}:${file}`], dir)
|
|
184
|
+
if (shown.ok) return OPENAPI_ROOT.test(shown.output.slice(0, 4096))
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function changedOpenApiSpec(dir, staged) {
|
|
190
|
+
const candidates = staged.filter((file) => OPENAPI_CANDIDATE.test(file))
|
|
191
|
+
if (candidates.some((file) => declaresOpenApi(dir, file))) return true
|
|
192
|
+
// Una sola búsqueda en el índice por todas las carpetas, y no un `git show` por archivo: un `spec/` con
|
|
193
|
+
// cientos de fixtures costaba cientos de procesos por commit. `git grep` sale con 1 si no encuentra nada, y
|
|
194
|
+
// con cualquier otro código no pudo mirar, que dispara igual.
|
|
195
|
+
const folders = [...new Set(candidates.filter((file) => file.includes('/')).map((file) => file.split('/')[0]))]
|
|
196
|
+
if (!folders.length) return false
|
|
197
|
+
const found = run('git', ['-C', dir, 'grep', '--cached', '-l', '-E', '^(openapi|swagger)[[:space:]]*:', '--',
|
|
198
|
+
...folders.flatMap((folder) => [`:(top,glob)${folder}/**/*.yaml`, `:(top,glob)${folder}/**/*.yml`])], dir)
|
|
199
|
+
if (found.ok) return Boolean(found.output.trim())
|
|
200
|
+
return found.status !== 1
|
|
201
|
+
}
|
|
202
|
+
|
|
147
203
|
function verify(input) {
|
|
148
204
|
if (process.env.OPS_SKIP_VERIFY === '1') return
|
|
149
205
|
const command = commandOf(input)
|
|
150
206
|
if (!isCommit(command)) return
|
|
151
207
|
const { dir, staged } = stagedForCommit(command, cwdOf(input))
|
|
152
|
-
const changedOpenApi =
|
|
153
|
-
|
|
154
|
-
const changedSqlSource = staged.some((file) => /^(?:db\/queries|queries)\/.*\.sql$/i.test(file))
|
|
208
|
+
const changedOpenApi = changedOpenApiSpec(dir, staged)
|
|
209
|
+
const changedSqlSource = staged.some((file) => SQL_SOURCE.test(file)) && usesSqlc(dir)
|
|
155
210
|
const hasApiGenerated = staged.some((file) => /(?:^|\/)[^/]*(?:generated|\.gen)\.(?:go|ts|js|py)$/i.test(file))
|
|
156
|
-
const hasSqlGenerated = staged.some((file) =>
|
|
211
|
+
const hasSqlGenerated = staged.some((file) => SQL_GENERATED.test(file))
|
|
157
212
|
// Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
|
|
158
213
|
// es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
|
|
159
214
|
// y no de una regla, que es lo que la vuelve una operación y no un permiso.
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
if (changedOpenApi && !hasApiGenerated && !
|
|
215
|
+
const unapproved = AP.pendingNow(opsRoot(input), staged, input)
|
|
216
|
+
const approved = !unapproved.length
|
|
217
|
+
if (changedOpenApi && !hasApiGenerated && !approved) {
|
|
163
218
|
block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
|
|
164
|
-
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY',
|
|
219
|
+
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', unapproved, input)}`)
|
|
165
220
|
}
|
|
166
|
-
if (changedSqlSource && !hasSqlGenerated && !
|
|
167
|
-
block('Cambió una consulta SQL fuente sin artefactos regenerados
|
|
168
|
-
+
|
|
221
|
+
if (changedSqlSource && !hasSqlGenerated && !approved) {
|
|
222
|
+
block('Cambió una consulta SQL fuente sin artefactos regenerados: busqué en el índice un `*.sql.go`, '
|
|
223
|
+
+ 'o algo bajo una carpeta `sqlc/` o `generated/`, y no hay ninguno. Si corriste `sqlc generate`, '
|
|
224
|
+
+ 'stageá lo que escribió; si su `output_files_suffix` le cambia el nombre, esto no lo reconoce.\n'
|
|
225
|
+
+ AP.HOW('OPS_SKIP_VERIFY', unapproved, input))
|
|
169
226
|
}
|
|
170
227
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
171
228
|
const { root, temp, env } = commitTree(dir, input)
|
|
172
229
|
try {
|
|
173
|
-
verifyGates(root, dir,
|
|
230
|
+
verifyGates(root, dir, unapproved, env, input)
|
|
174
231
|
} finally {
|
|
175
232
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
176
233
|
}
|
|
@@ -201,7 +258,7 @@ const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
|
|
|
201
258
|
const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
|
|
202
259
|
const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
|
|
203
260
|
const MAX_LINE = 160
|
|
204
|
-
function
|
|
261
|
+
function failure(gate, result) {
|
|
205
262
|
// La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
|
|
206
263
|
// que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
|
|
207
264
|
// coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
|
|
@@ -228,7 +285,7 @@ function howItReads(failures) {
|
|
|
228
285
|
+ 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
|
|
229
286
|
}
|
|
230
287
|
|
|
231
|
-
function verifyGates(root, dir,
|
|
288
|
+
function verifyGates(root, dir, unapproved, env, input) {
|
|
232
289
|
const ops = opsRoot(input)
|
|
233
290
|
const failures = []
|
|
234
291
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
@@ -240,19 +297,19 @@ function verifyGates(root, dir, sinAprobar, env, input) {
|
|
|
240
297
|
if (!pkg.scripts || !pkg.scripts[script]) continue
|
|
241
298
|
const result = run(pm, ['run', script], root, env)
|
|
242
299
|
EV.record(ops, script, result.status, result.ms)
|
|
243
|
-
if (!result.ok) failures.push(
|
|
300
|
+
if (!result.ok) failures.push(failure(script, result))
|
|
244
301
|
}
|
|
245
302
|
} else if (fs.existsSync(path.join(root, 'go.mod'))) {
|
|
246
303
|
const makefile = path.join(root, 'Makefile')
|
|
247
304
|
if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
248
305
|
const result = run('make', ['ci'], root, env)
|
|
249
306
|
EV.record(ops, 'make ci', result.status, result.ms)
|
|
250
|
-
if (!result.ok) failures.push(
|
|
307
|
+
if (!result.ok) failures.push(failure('make ci', result))
|
|
251
308
|
} else {
|
|
252
309
|
for (const args of [['test', './...'], ['build', './...']]) {
|
|
253
310
|
const result = run('go', args, root, env)
|
|
254
311
|
EV.record(ops, `go ${args[0]}`, result.status, result.ms)
|
|
255
|
-
if (!result.ok) failures.push(
|
|
312
|
+
if (!result.ok) failures.push(failure(`go ${args[0]}`, result))
|
|
256
313
|
}
|
|
257
314
|
}
|
|
258
315
|
} else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
|
|
@@ -260,16 +317,16 @@ function verifyGates(root, dir, sinAprobar, env, input) {
|
|
|
260
317
|
if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
261
318
|
const result = run('make', ['test'], root, env)
|
|
262
319
|
EV.record(ops, 'make test', result.status, result.ms)
|
|
263
|
-
if (!result.ok) failures.push(
|
|
320
|
+
if (!result.ok) failures.push(failure('make test', result))
|
|
264
321
|
}
|
|
265
322
|
}
|
|
266
|
-
if (!failures.length || !
|
|
323
|
+
if (!failures.length || !unapproved.length) return
|
|
267
324
|
// Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
|
|
268
325
|
// comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
|
|
269
|
-
const
|
|
326
|
+
const where = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
|
|
270
327
|
+ 'directorio pasa, es que en disco tenés algo que no está staged.'
|
|
271
|
-
block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${
|
|
272
|
-
+ AP.HOW('OPS_SKIP_VERIFY',
|
|
328
|
+
block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${where}\n`
|
|
329
|
+
+ AP.HOW('OPS_SKIP_VERIFY', unapproved, input))
|
|
273
330
|
}
|
|
274
331
|
|
|
275
332
|
module.exports = { verify }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El lado del motor de `automatization/shared/acceptance.js`: lo evalúa en vez de copiarlo. El archivo
|
|
4
|
+
// declara constantes sueltas porque así lo incluye un recorrido, y un fragmento así no se puede
|
|
5
|
+
// `require`; evaluado aparte da los mismos valores que recibe el recorrido renderizado.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const vm = require('node:vm')
|
|
10
|
+
|
|
11
|
+
const SOURCE = path.join(__dirname, '..', '..', 'automatization', 'shared', 'acceptance.js')
|
|
12
|
+
|
|
13
|
+
const { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE } = vm.runInNewContext(
|
|
14
|
+
`${fs.readFileSync(SOURCE, 'utf8')}\n;({ acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE })`,
|
|
15
|
+
{}, { filename: SOURCE },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
module.exports = { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE }
|
|
@@ -4,6 +4,7 @@ const fs = require('node:fs')
|
|
|
4
4
|
const path = require('node:path')
|
|
5
5
|
const P = require('./parser')
|
|
6
6
|
const { PLACEHOLDERS } = require('../core/onboarding')
|
|
7
|
+
const { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE } = require('./acceptance')
|
|
7
8
|
|
|
8
9
|
const TEST_TRACE = /^(?:n\/a\s*[—-]\s*.+|(?:A|C\d+)\s*(?:→|->)\s*\S.+)$/i
|
|
9
10
|
const DECISION_TRACE = /\[(?:fuente|supuesto):\s*[^\]]+\]/i
|
|
@@ -40,12 +41,13 @@ function validDecisionTrace(value) {
|
|
|
40
41
|
// sha. La salida explícita existe porque hay tareas que no producen commit —abrir una fila en
|
|
41
42
|
// HUMAN_ACTIONS, registrar un informe—, y forzarlas a llenar el campo produce un sha inventado, que es
|
|
42
43
|
// peor que la ausencia: parece evidencia.
|
|
44
|
+
// Se corta en cada `|`, y en un `;` sólo cuando detrás viene un sha: el `;` aparece también dentro del
|
|
45
|
+
// paréntesis final —`(api@main; sin footer Task:)`— y cortar ahí convertiría una nota en un commit que
|
|
46
|
+
// falta. Cada tramo responde por sí mismo; validar sólo el primero dejaba pasar la mitad sin artefacto.
|
|
47
|
+
const commitParts = (value) => String(value || '').trim().split(/\s*(?:\||;(?=\s*[0-9a-f]{7,40}\s))\s*/)
|
|
48
|
+
|
|
43
49
|
function validCommitTrace(value) {
|
|
44
|
-
|
|
45
|
-
// paréntesis final —`(api@main; sin footer Task:)`— y cortar ahí convertiría una nota en un commit que
|
|
46
|
-
// falta. Cada tramo responde por sí mismo; validar sólo el primero dejaba pasar la mitad sin artefacto.
|
|
47
|
-
return String(value || '').trim().split(/\s*(?:\||;(?=\s*[0-9a-f]{7,40}\s))\s*/)
|
|
48
|
-
.every((part) => COMMIT_TRACE.test(part.trim()))
|
|
50
|
+
return commitParts(value).every((part) => COMMIT_TRACE.test(part.trim()))
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// Los criterios que la evidencia realmente rastrea. `n/a — razón` no rastrea ninguno a propósito: es
|
|
@@ -118,8 +120,8 @@ function doneEntryErrors(entry, epics = []) {
|
|
|
118
120
|
// Un carril declara **cuánta ceremonia** merecía la tarea; `n/a` en `review:` dice que la revisión no
|
|
119
121
|
// corrió. `express` es el único que no convoca revisor, así que en los otros tres esa combinación es la
|
|
120
122
|
// ADR incumplida, escrita en el propio registro.
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
+
const REVIEWED_LANES = ['directo', 'lite', 'full']
|
|
124
|
+
const NO_REVIEW = /^n\/a\b/i
|
|
123
125
|
|
|
124
126
|
// Lo que el registro puede decir sobre la ceremonia, y lo que todavía no. Los dos campos avisan en vez de
|
|
125
127
|
// fallar por lo que dice `doneEntryErrors`, y cuentan en vez de listar porque al principio son todas: lo
|
|
@@ -134,22 +136,22 @@ const SIN_REVISION = /^n\/a\b/i
|
|
|
134
136
|
// completo, pero eso lo sabe el recorrido y no la entrada. Avisar sobre lo que hay que deducir es lo que
|
|
135
137
|
// llena de ruido un aviso que después nadie mira.
|
|
136
138
|
function doneCeremonyWarnings(done, adopted = new Set()) {
|
|
137
|
-
const
|
|
139
|
+
const own = done.entries.filter((entry) => !adopted.has(entry.slug))
|
|
138
140
|
const warnings = []
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
warnings.push(`planning/done: ${
|
|
141
|
+
const withoutLane = own.filter((entry) => !entry.lane)
|
|
142
|
+
if (withoutLane.length) {
|
|
143
|
+
warnings.push(`planning/done: ${withoutLane.length} entrada(s) sin lane:, así que no se puede comprobar `
|
|
142
144
|
+ 'sobre el registro que la ceremonia que recibieron fue la que su superficie pedía (OPS-006)')
|
|
143
145
|
}
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
warnings.push(`planning/done: ${
|
|
146
|
+
const withoutReview = own.filter((entry) => !entry.review)
|
|
147
|
+
if (withoutReview.length) {
|
|
148
|
+
warnings.push(`planning/done: ${withoutReview.length} entrada(s) sin review:, que es la dimensión con la `
|
|
147
149
|
+ 'que OPS-006 dice que se mide si el carril elegido fue el correcto')
|
|
148
150
|
}
|
|
149
|
-
const
|
|
150
|
-
&& entry.review &&
|
|
151
|
-
if (
|
|
152
|
-
warnings.push(`planning/done: ${
|
|
151
|
+
const skipped = own.filter((entry) => REVIEWED_LANES.includes(entry.lane)
|
|
152
|
+
&& entry.review && NO_REVIEW.test(entry.review))
|
|
153
|
+
if (skipped.length) {
|
|
154
|
+
warnings.push(`planning/done: ${skipped.map((entry) => entry.slug).join(', ')} declara(n) un carril `
|
|
153
155
|
+ 'que convoca revisor y una revisión que no corrió: el carril reduce ceremonia, nunca evidencia')
|
|
154
156
|
}
|
|
155
157
|
return warnings
|
|
@@ -165,14 +167,6 @@ const POST_VERIFY = [
|
|
|
165
167
|
[/\bel reclamo\b|\bclaims\//i, 'el reclamo'],
|
|
166
168
|
]
|
|
167
169
|
|
|
168
|
-
// La salida explícita, con la forma que el repositorio ya usa dos veces: `(sin partir: …)` para el umbral
|
|
169
|
-
// de R17 y `n/a — razón` para `tests:` y `commit:`. Acá vale lo mismo que allá —«como lleva su razón
|
|
170
|
-
// escrita se lee en el propio artefacto sin que nadie la cruce»— y por eso no se intenta adivinar si la
|
|
171
|
-
// prosa excluye a Verify. Adivinarlo es lo que no se puede: la única aceptación real que nombra el commit
|
|
172
|
-
// lo hace justamente para decir que no es condición de Verify, y cualquier lista de frases que la
|
|
173
|
-
// reconociera enseñaría a escribir esa frase exacta para silenciar el aviso.
|
|
174
|
-
const OUT_OF_VERIFY = /\(fuera de verify:\s*[^)]+\)/i
|
|
175
|
-
|
|
176
170
|
// Una condición de aceptación que nombra el registro en vez del producto no se puede cumplir nunca: se
|
|
177
171
|
// comprueba en Verify, que corre antes que Commit y que Done. El recorrido lo detecta —y hace bien—, pero
|
|
178
172
|
// recién ahí: en la corrida que originó esto fueron 1,2 M de tokens y once agentes para terminar con el
|
|
@@ -194,11 +188,11 @@ function unverifiableAcceptance(milestones = []) {
|
|
|
194
188
|
const warnings = []
|
|
195
189
|
for (const milestone of milestones) {
|
|
196
190
|
for (const task of milestone.tasks || []) {
|
|
197
|
-
for (const condition of
|
|
198
|
-
if (
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
201
|
-
warnings.push(`BACKLOG ${task.slug}: una condición nombra ${
|
|
191
|
+
for (const condition of acceptanceConditions(task.acceptance)) {
|
|
192
|
+
if (OUT_OF_VERIFY.test(condition)) continue
|
|
193
|
+
const names = POST_VERIFY.filter(([pattern]) => pattern.test(condition))
|
|
194
|
+
if (!names.length) continue
|
|
195
|
+
warnings.push(`BACKLOG ${task.slug}: una condición nombra ${names.map(([, what]) => what).join(', ')}`
|
|
202
196
|
+ ', que existe después de Verify, así que no se puede comprobar cuando se la comprueba. Eso va '
|
|
203
197
|
+ 'en tests:, qa: o commit: de su entrada de DONE, que ya lo exigen; si de verdad va acá, '
|
|
204
198
|
+ 'declaralo con "(fuera de verify: <razón>)"')
|
|
@@ -208,6 +202,44 @@ function unverifiableAcceptance(milestones = []) {
|
|
|
208
202
|
return warnings
|
|
209
203
|
}
|
|
210
204
|
|
|
205
|
+
// `tests: n/a` dice que la tarea no produjo nada que se ejecute, y quien lo escribe es el mismo que quiere
|
|
206
|
+
// cerrar: desde el 189 el recorrido lo emite cuando Verify declara `no-surface`, que es la palabra de un
|
|
207
|
+
// modelo. Lo que la vuelve creíble es mirar qué tocó el commit, y eso no lo decide nadie. Se juzga el n/a
|
|
208
|
+
// entero y no el mixto: en una tarea que rastrea algún criterio con su prueba, el código del commit es el
|
|
209
|
+
// de esos criterios y no dice nada del que se declaró sin superficie.
|
|
210
|
+
//
|
|
211
|
+
// `filesOf` devuelve los archivos de un sha, o null si ningún repositorio lo conoce. Ahí calla, igual que
|
|
212
|
+
// el resto de lo que pregunta a git desde `check`: un sha que no se encuentra no es superficie, y avisarlo
|
|
213
|
+
// sería inventar el hallazgo. Lo adoptado queda afuera por lo mismo que en el resto de DONE: se escribió
|
|
214
|
+
// antes de que el contrato lo pidiera.
|
|
215
|
+
//
|
|
216
|
+
// Y rige desde que existe. Una entrada cerrada antes con `tests: n/a` sobre código no violó nada que se le
|
|
217
|
+
// hubiera pedido, y frenarla ahora pondría `check` en rojo con el `upgrade` —y con él al guard que lo corre
|
|
218
|
+
// antes de cada commit— por algo que nadie puede corregir sin reescribir evidencia. Es la misma razón por
|
|
219
|
+
// la que se exime lo adoptado, aplicada a lo que ya estaba cerrado cuando la regla llegó.
|
|
220
|
+
const NOT_APPLICABLE = /^n\/a\s*[—-]/i
|
|
221
|
+
const SURFACE_SINCE = '2026-09-24'
|
|
222
|
+
|
|
223
|
+
function surfaceWithoutTests(entries, filesOf, adopted = new Set()) {
|
|
224
|
+
const errors = []
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
const traces = splitTraces(entry.tests)
|
|
227
|
+
if (adopted.has(entry.slug) || !traces.length || !traces.every((one) => NOT_APPLICABLE.test(one))) continue
|
|
228
|
+
if (!(String(entry.fecha || '') >= SURFACE_SINCE)) continue
|
|
229
|
+
for (const sha of commitParts(entry.commit).map((part) => (part.match(/^([0-9a-f]{7,40})\s/) || [])[1])) {
|
|
230
|
+
if (!sha) continue
|
|
231
|
+
const surface = (filesOf(sha) || [])
|
|
232
|
+
.filter((file) => !NON_EXECUTABLE.includes(path.extname(file).toLowerCase()))
|
|
233
|
+
if (!surface.length) continue
|
|
234
|
+
const shown = surface.length > 5 ? [...surface.slice(0, 5), `y ${surface.length - 5} más`] : surface
|
|
235
|
+
errors.push(`${entry.source} ${entry.slug}: tests: n/a dice que no hay superficie ejecutable y el commit `
|
|
236
|
+
+ `${sha} toca ${shown.join(', ')}; sólo ${NON_EXECUTABLE.join(', ')} cuentan como no ejecutables, `
|
|
237
|
+
+ 'así que esos criterios se rastrean con su prueba')
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return errors
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
function duplicates(values) {
|
|
212
244
|
return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
|
|
213
245
|
}
|
|
@@ -260,22 +292,22 @@ function dependencyErrors(milestones, done) {
|
|
|
260
292
|
// Recorrido en profundidad con el camino a cuestas: al reencontrar un slug que sigue en el camino,
|
|
261
293
|
// ese camino **es** el ciclo, y nombrarlo entero es lo que lo hace reparable — decir sólo que hay uno
|
|
262
294
|
// deja el trabajo de encontrarlo del lado de quien lee.
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
const
|
|
269
|
-
errors.push(`BACKLOG: ciclo de dependencias ${
|
|
295
|
+
const state = new Map()
|
|
296
|
+
const visit = (slug, trail) => {
|
|
297
|
+
if (state.get(slug) === 'listo') return
|
|
298
|
+
const from = trail.indexOf(slug)
|
|
299
|
+
if (from >= 0) {
|
|
300
|
+
const cycle = [...trail.slice(from), slug]
|
|
301
|
+
errors.push(`BACKLOG: ciclo de dependencias ${cycle.join(' → ')}`)
|
|
270
302
|
return
|
|
271
303
|
}
|
|
272
304
|
for (const dep of queued.get(slug) || []) {
|
|
273
305
|
// La que se depende a sí misma ya tiene su error, más claro que un ciclo de un solo paso.
|
|
274
|
-
if (dep !== slug && queued.has(dep))
|
|
306
|
+
if (dep !== slug && queued.has(dep)) visit(dep, [...trail, slug])
|
|
275
307
|
}
|
|
276
|
-
|
|
308
|
+
state.set(slug, 'listo')
|
|
277
309
|
}
|
|
278
|
-
for (const slug of queued.keys())
|
|
310
|
+
for (const slug of queued.keys()) visit(slug, [])
|
|
279
311
|
return [...new Set(errors)]
|
|
280
312
|
}
|
|
281
313
|
|
|
@@ -397,10 +429,10 @@ function validateState({
|
|
|
397
429
|
// ofreciendo. `**slug: de qué se trata**` es la que sale natural, porque esta tabla la lee una
|
|
398
430
|
// persona. Sin esto la ausencia no deja rastro, que es la forma de R15 aplicada a un mecanismo.
|
|
399
431
|
if (backlogSlugs.has(row.task)) continue
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${
|
|
403
|
-
+ `motor bloquea por la primera columna exacta. Dejá "${
|
|
432
|
+
const near = [...backlogSlugs].find((slug) => new RegExp(`\\b${slug}\\b`).test(row.task))
|
|
433
|
+
if (near) {
|
|
434
|
+
errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${near} y no bloquea nada, porque el `
|
|
435
|
+
+ `motor bloquea por la primera columna exacta. Dejá "${near}" sola ahí y contá el resto en la `
|
|
404
436
|
+ 'acción, o nombrá la épica o el recorrido si lo que se frena no es esa tarea')
|
|
405
437
|
}
|
|
406
438
|
}
|
|
@@ -435,6 +467,7 @@ module.exports = {
|
|
|
435
467
|
doneEntryErrors,
|
|
436
468
|
doneCeremonyWarnings,
|
|
437
469
|
unverifiableAcceptance,
|
|
470
|
+
surfaceWithoutTests,
|
|
438
471
|
validCommitTrace,
|
|
439
472
|
validDecisionTrace,
|
|
440
473
|
validTestTrace,
|
|
@@ -118,6 +118,15 @@
|
|
|
118
118
|
"pattern": "^[a-z0-9]+$"
|
|
119
119
|
},
|
|
120
120
|
"minItems": 1
|
|
121
|
+
},
|
|
122
|
+
"paths": {
|
|
123
|
+
"type": "array",
|
|
124
|
+
"description": "Carpetas donde viven las migraciones, relativas y sin «.» ni «..»: el guard las reconoce en cualquier lugar de la ruta, así que \"alembic/versions\" también alcanza a \"api/alembic/versions\". Declararlas reemplaza el default [\"migrations\", \"migration\", \"migrate\"], y `check` avisa la que no alcanza a ningún archivo.",
|
|
125
|
+
"items": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$"
|
|
128
|
+
},
|
|
129
|
+
"minItems": 1
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
},
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -114,13 +114,22 @@ preguntando, no pasa. **Con los gates de un commit se pregunta cada vez**, como
|
|
|
114
114
|
stack y los lockfiles no heredan lo que autorizaste en un mensaje anterior, porque cada commit es otra
|
|
115
115
|
operación. Y `plan-first` no te pide un plan cuando el cambio lo pediste vos: el plan es para el trabajo que va
|
|
116
116
|
por tareas. Funciona en Claude Code, Codex y Gemini, que le avisan a Cauce cuando mandás un mensaje; en
|
|
117
|
-
Antigravity, y cuando nadie está en el chat —CI, un recorrido
|
|
117
|
+
Antigravity, y cuando nadie está en el chat —CI, un recorrido—, queda el archivo de abajo. Si lo frenado
|
|
118
|
+
lo hacía un subagente, tu confirmación le llega igual; un pedido tuyo que lo nombraba, no: ése vale sólo
|
|
119
|
+
para el agente con el que hablás.
|
|
118
120
|
|
|
119
121
|
**Sin chat, la salida de todos ellos es la misma**: escribir en `planning/.ops-approval` las rutas que
|
|
120
122
|
autorizás, una por línea, con `#` para lo que no sea una ruta. En sidecar es el `planning/` de la
|
|
121
123
|
instancia y no una carpeta al lado de tus proyectos; el bloqueo dice la ruta exacta. Es un solo archivo
|
|
122
124
|
para todos los guards, porque lo que escribís son rutas y quién las mira lo decide qué guard esté
|
|
123
|
-
juzgando esa ruta. Lo escribís vos
|
|
125
|
+
juzgando esa ruta. Lo escribís vos, o el agente con tu confirmación: si intenta escribírselo solo, un
|
|
126
|
+
guard lo frena y te muestra qué iba a agregar.
|
|
127
|
+
|
|
128
|
+
**Una lectura que necesitás siempre se aprueba una vez.** El «dale» dura la sesión. Cuando el agente se
|
|
129
|
+
frena leyendo una credencial —el token con el que una regla tuya manda autenticar—, te pregunta además si
|
|
130
|
+
querés dejarla aprobada para siempre; si esa es tu intención, escribe la ruta en ese archivo, con quién lo
|
|
131
|
+
aprobó y para qué, y ninguna sesión vuelve a preguntarlo. `check` la sigue nombrando mientras exista, como
|
|
132
|
+
toda exención; se revoca borrando la línea.
|
|
124
133
|
|
|
125
134
|
| lo que te frena | qué ruta aprobás |
|
|
126
135
|
|---|---|
|
|
@@ -16,7 +16,8 @@ invariantes.
|
|
|
16
16
|
reclamo, `done/` o la evidencia registrada pide algo que todavía no existe cuando se la mira, y su lugar
|
|
17
17
|
son los campos `tests:`, `qa:` y `commit:` de DONE, que ya lo exigen. `check` lo avisa sobre la cola. Si
|
|
18
18
|
aun así corresponde dejarla ahí, se declara en la propia condición con `(fuera de verify: <razón>)` —la
|
|
19
|
-
misma salida explícita que `(sin partir: …)` y que `n/a — razón`— y deja de avisarse.
|
|
19
|
+
misma salida explícita que `(sin partir: …)` y que `n/a — razón`— y deja de avisarse. El recorrido la
|
|
20
|
+
saca de lo que manda a Verify y a QA, y la lleva a Done para que quede cumplida en esos campos.
|
|
20
21
|
- DONE: un archivo por tarea cerrada, `done/<slug>.md`, con su entrada `[x]` y los campos `acept:`,
|
|
21
22
|
`fecha:` en AAAA-MM-DD, `done:`, `qa:`, `tests:`, `commit:` y `lane:`. `lane:` repite el carril con el
|
|
22
23
|
que la tarea corrió —`express`, `directo`, `lite`, `full`— o `sin clasificar` si su línea no lo
|
|
@@ -28,7 +29,10 @@ invariantes.
|
|
|
28
29
|
que convoca revisor con una revisión que no corrió es la ADR incumplida, escrita en el propio registro. La fecha es la del cierre, y es lo que
|
|
29
30
|
ordena una evidencia que ya no depende de su posición dentro de un archivo. `tests:` enlaza cada criterio
|
|
30
31
|
mediante `CN → prueba`; usa `A → prueba` cuando no hay épica o `n/a — razón` si no existe una
|
|
31
|
-
superficie ejecutable. `
|
|
32
|
+
superficie ejecutable. Un `tests:` que sea todo `n/a` es un error de `check` si su `commit:` toca algún
|
|
33
|
+
archivo que no sea un documento (`.md`, `.txt`, `.adoc`) o una imagen (`.png`, `.jpg`, `.jpeg`, `.gif`,
|
|
34
|
+
`.svg`, `.webp`): lo demás se ejecuta y se prueba, también lo que no tiene extensión, como un `Makefile`.
|
|
35
|
+
Rige para lo cerrado desde el 2026-09-24; lo anterior no se juzga con ella. `decisions:` es opcional y, si aparece, cita `[fuente: ...]` o
|
|
32
36
|
`[supuesto: ...]`. `commit:` apunta a `<sha> <asunto>`, o a `n/a — razón` cuando la tarea no
|
|
33
37
|
produce commit.
|
|
34
38
|
- Acción humana: fila `| tarea | estado | origen | acción y condición de desbloqueo |`, con el estado
|