@ingeniomaps/cauce 0.94.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +238 -0
- package/automatization/workflows/autobuild.js +28 -1
- package/engine/agents/evaluations.js +20 -5
- package/engine/agents/fork.js +21 -8
- package/engine/agents/learning-sources.js +7 -7
- package/engine/agents/learning.js +15 -6
- package/engine/automation/check.js +8 -2
- package/engine/automation/config.js +9 -9
- package/engine/automation/index.js +19 -8
- package/engine/cli/archive.js +3 -6
- package/engine/cli/args.js +16 -2
- package/engine/cli/bench.js +9 -9
- package/engine/cli/catalog.js +36 -17
- package/engine/cli/claims.js +30 -32
- package/engine/cli/contract.js +29 -11
- package/engine/cli/dependency.js +2 -2
- package/engine/cli/instance.js +25 -24
- package/engine/cli/io.js +26 -4
- package/engine/cli/ops.js +24 -15
- package/engine/cli/planning.js +8 -8
- package/engine/cli/upgrade-report.js +10 -10
- package/engine/cli/validate.js +9 -9
- package/engine/cli/wiring.js +16 -16
- package/engine/cli/worktree.js +8 -8
- package/engine/core/onboarding.js +4 -1
- package/engine/core/ownership.js +9 -2
- package/engine/core/repos.js +18 -12
- package/engine/hooks/approval.js +9 -1
- package/engine/hooks/chat.js +27 -5
- package/engine/hooks/files.js +6 -6
- package/engine/hooks/input.js +5 -2
- package/engine/hooks/run.js +3 -3
- package/engine/hooks/shell.js +20 -6
- package/engine/hooks/verify.js +7 -7
- package/engine/integrations/proposals.js +5 -1
- package/engine/integrations/registry.js +7 -0
- package/engine/integrations/state.js +6 -3
- package/engine/planning/adoption.js +2 -2
- package/engine/planning/claims.js +8 -8
- package/engine/planning/parser.js +8 -8
- package/engine/planning/state.js +2 -2
- package/engine/planning/structure.js +7 -7
- package/package.json +1 -1
package/engine/cli/worktree.js
CHANGED
|
@@ -15,7 +15,7 @@ const ST = require('../planning/state')
|
|
|
15
15
|
const CL = require('../planning/claims')
|
|
16
16
|
const R = require('../core/repos')
|
|
17
17
|
const O = require('../core/ownership')
|
|
18
|
-
const { fail, planningRoot } = require('./io')
|
|
18
|
+
const { fail, planningRoot, USAGE, REFUSED } = require('./io')
|
|
19
19
|
|
|
20
20
|
const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
21
21
|
|
|
@@ -34,38 +34,38 @@ function existing(repo, branch) {
|
|
|
34
34
|
|
|
35
35
|
function worktree(dir, slug, cli) {
|
|
36
36
|
const root = planningRoot(dir)
|
|
37
|
-
if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`',
|
|
37
|
+
if (!slug) return fail('Falta el slug. `ops worktree <planning-dir> <tarea>`', USAGE)
|
|
38
38
|
const state = ST.snapshot(root)
|
|
39
39
|
const task = state.milestones.flatMap((milestone) => milestone.tasks).find((one) => one.slug === slug)
|
|
40
|
-
if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`,
|
|
40
|
+
if (!task) return fail(`${slug} no está en BACKLOG: sólo se prepara trabajo ya promovido.`, USAGE)
|
|
41
41
|
|
|
42
42
|
// No reserva —eso es `claim`— pero se niega a montar sobre lo de otro: preparar un árbol para una
|
|
43
43
|
// tarea ajena es trabajo que se va a tirar, y el aviso cuesta menos que descubrirlo después.
|
|
44
44
|
const taken = state.claims.find((one) => one.slug === slug)
|
|
45
45
|
if (taken && taken.runner !== CL.runner()) {
|
|
46
|
-
return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie
|
|
46
|
+
return fail(`${slug} la tomó ${taken.owner}; preparar un árbol para su tarea no ayuda a nadie.`, REFUSED)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const candidatos = R.reposFor(path.join(root, '..'), task.service)
|
|
50
50
|
if (candidatos.length > 1) {
|
|
51
51
|
return fail(`${task.service || '.'} existe en más de un repositorio (${candidatos.join(', ')}), así que `
|
|
52
|
-
+ 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.',
|
|
52
|
+
+ 'no puedo saber cuál. Escribí un `service:` que sólo exista en uno.', USAGE)
|
|
53
53
|
}
|
|
54
54
|
const repo = candidatos[0]
|
|
55
55
|
if (!repo) {
|
|
56
56
|
return fail(`no encontré el repositorio de ${task.service || '(sin service)'}: revisá workspaceRoots `
|
|
57
|
-
+ 'en ops.config.json y que la ruta del servicio exista.',
|
|
57
|
+
+ 'en ops.config.json y que la ruta del servicio exista.', USAGE)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const branch = CL.branchOf(slug)
|
|
61
61
|
const already = existing(repo, branch)
|
|
62
62
|
const target = already || path.join(path.dirname(repo), `${path.basename(repo)}-${slug}`)
|
|
63
63
|
if (!already) {
|
|
64
|
-
if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama
|
|
64
|
+
if (fs.existsSync(target)) return fail(`${target} ya existe y no es un árbol de esta rama.`, REFUSED)
|
|
65
65
|
const hasBranch = git(repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`).status === 0
|
|
66
66
|
const args = hasBranch ? ['worktree', 'add', target, branch] : ['worktree', 'add', '-b', branch, target]
|
|
67
67
|
const added = git(repo, ...args)
|
|
68
|
-
if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}
|
|
68
|
+
if (added.status !== 0) return fail(`git worktree add falló: ${(added.stderr || '').trim()}`, REFUSED)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
if (cli.has('--json')) {
|
|
@@ -119,7 +119,10 @@ function orphanCredentials(root) {
|
|
|
119
119
|
path.join('planning', 'HUMAN_ACTIONS.md')]
|
|
120
120
|
.map((file) => { try { return fs.readFileSync(path.join(root, file), 'utf8') } catch { return '' } })
|
|
121
121
|
.join('\n')
|
|
122
|
-
|
|
122
|
+
// Acá había una salida temprana por `contracts` vacío que no corría nunca —pegar tres cadenas con
|
|
123
|
+
// `\n` deja `"\n\n"` aunque las tres fallen—, y no se la repone: una credencial que ningún documento
|
|
124
|
+
// nombra está huérfana igual, y apagar el aviso porque el documento que debía nombrarla está vacío lo
|
|
125
|
+
// calla justo en la instancia que más lo necesita.
|
|
123
126
|
const orphans = []
|
|
124
127
|
const cut = []
|
|
125
128
|
for (const service of inventory(root)) {
|
package/engine/core/ownership.js
CHANGED
|
@@ -164,9 +164,16 @@ function engineAt(root, relative = '') {
|
|
|
164
164
|
// de copiarse. Se reconocen por contener `system/`, que es el espacio del toolkit y no algo que un
|
|
165
165
|
// proyecto deba crear. Una sola implementación para las dos, o divergen.
|
|
166
166
|
function packageDir(root, name) {
|
|
167
|
+
// La raíz declarada va también acá, y no sólo en `packagePath`. Las dos contestan dónde está el paquete
|
|
168
|
+
// sobre la misma instancia y desde el 158 contestaban distinto: en el layout documentado —`npm install`
|
|
169
|
+
// en la carpeta de la empresa y la instancia adentro— el motor se resolvía y el catálogo no, así que
|
|
170
|
+
// `check` pasaba en verde y `agents list` devolvía una lista vacía con exit 0, indistinguible de «esta
|
|
171
|
+
// instancia no tiene cargos». `evaluate` era peor: culpaba a un `SKILL.md` que falta (caso 171).
|
|
172
|
+
const above = declaredRoot(root)
|
|
167
173
|
const candidates = [
|
|
168
174
|
path.join(root, 'node_modules', '@ingeniomaps', 'cauce', name),
|
|
169
175
|
path.join(root, name),
|
|
176
|
+
...(above ? [path.join(above, 'node_modules', '@ingeniomaps', 'cauce', name)] : []),
|
|
170
177
|
]
|
|
171
178
|
return candidates.find((dir) => fs.existsSync(path.join(dir, 'system'))
|
|
172
179
|
|| fs.existsSync(path.join(dir, 'roles', 'system'))) || ''
|
|
@@ -299,7 +306,7 @@ const RETIRED = [
|
|
|
299
306
|
//
|
|
300
307
|
// El manifiesto no las puede desempatar: `trackedPaths()` nunca las registró, así que para el toolkit
|
|
301
308
|
// todo su contenido es igual de ajeno. Sin poder demostrar que lo entregó, no lo borra.
|
|
302
|
-
const
|
|
309
|
+
const RETIRED_SHARED = ['automatization/runners', 'automatization/workflows']
|
|
303
310
|
|
|
304
311
|
function retiredWithLearning(root) {
|
|
305
312
|
const found = []
|
|
@@ -407,7 +414,7 @@ module.exports = {
|
|
|
407
414
|
deliveredFiles,
|
|
408
415
|
effectiveRules,
|
|
409
416
|
shippedFiles,
|
|
410
|
-
|
|
417
|
+
RETIRED_SHARED,
|
|
411
418
|
TEMPLATE_OWN,
|
|
412
419
|
TEMPLATE_PREFIXES,
|
|
413
420
|
addedPaths,
|
package/engine/core/repos.js
CHANGED
|
@@ -55,10 +55,10 @@ function repoOf(opsRoot, service) {
|
|
|
55
55
|
// el árbol principal: los worktrees se crean desde ahí y ahí se queda.
|
|
56
56
|
function lastCommit(repo, branch) {
|
|
57
57
|
if (!repo) return ''
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const args =
|
|
61
|
-
? ['log', '-1', '--format=%cs', branch, '--not',
|
|
58
|
+
const head = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')
|
|
59
|
+
const trunk = head.status === 0 ? head.stdout.trim() : ''
|
|
60
|
+
const args = trunk && trunk !== branch
|
|
61
|
+
? ['log', '-1', '--format=%cs', branch, '--not', trunk, '--']
|
|
62
62
|
: ['log', '-1', '--format=%cs', branch, '--']
|
|
63
63
|
const shown = git(repo, ...args)
|
|
64
64
|
return shown.status === 0 ? shown.stdout.trim() : ''
|
|
@@ -85,9 +85,15 @@ function unrecordedCommits(repo, since, recorded) {
|
|
|
85
85
|
const log = git(repo, 'log', '--no-merges', '--date=short', '--format=%h %ad %s')
|
|
86
86
|
if (log.status !== 0) return []
|
|
87
87
|
const conocidos = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
|
|
88
|
+
// El hash y la fecha se leen partiendo por espacios y no por columna: `%h` mide 7 por default y git lo
|
|
89
|
+
// sube solo cuando el repositorio crece —en éste mide 8—, así que una posición fija leía un espacio en
|
|
90
|
+
// vez de la fecha y ningún commit pasaba el filtro. El aviso quedaba mudo sin decirlo, que es la peor
|
|
91
|
+
// forma de que una puerta falle (caso 169).
|
|
88
92
|
return log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
|
|
89
|
-
.
|
|
90
|
-
.filter((
|
|
93
|
+
.map((line) => ({ line, sha: line.split(/\s+/)[0], date: line.split(/\s+/)[1] }))
|
|
94
|
+
.filter((one) => one.date >= since)
|
|
95
|
+
.filter((one) => !conocidos.has(one.sha.slice(0, 7)))
|
|
96
|
+
.map((one) => one.line)
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
// Cuánto del trabajo que entró a los repositorios quedó registrado, desde la última tarea cerrada. Avisa
|
|
@@ -102,18 +108,18 @@ function unrecordedCommits(repo, since, recorded) {
|
|
|
102
108
|
// Y no dice cuántos *deberían* tener entrada, porque eso no se sabe desde acá: lo dice el desglose, y en
|
|
103
109
|
// la instancia medida 120 de 173 eran `feat` o `fix` (caso 082).
|
|
104
110
|
function coverageWarnings(opsRoot, done) {
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
if (!
|
|
111
|
+
const dates = done.entries.map((entry) => entry.fecha).filter(Boolean).sort()
|
|
112
|
+
const since = dates[dates.length - 1]
|
|
113
|
+
if (!since) return []
|
|
108
114
|
const recorded = new Set()
|
|
109
115
|
for (const entry of done.entries) {
|
|
110
116
|
for (const sha of String(entry.commit || '').matchAll(/\b[0-9a-f]{7,40}\b/g)) recorded.add(sha[0])
|
|
111
117
|
}
|
|
112
118
|
const warnings = []
|
|
113
119
|
for (const repo of reposFor(opsRoot, '.')) {
|
|
114
|
-
const
|
|
115
|
-
if (!
|
|
116
|
-
warnings.push(`${path.basename(repo)}: ${
|
|
120
|
+
const unrecorded = unrecordedCommits(repo, since, recorded)
|
|
121
|
+
if (!unrecorded.length) continue
|
|
122
|
+
warnings.push(`${path.basename(repo)}: ${unrecorded.length} commit(s) desde ${since} que ninguna `
|
|
117
123
|
+ 'entrada de DONE nombra, así que ese trabajo no está en planning/ (OPS-001)')
|
|
118
124
|
}
|
|
119
125
|
return warnings
|
package/engine/hooks/approval.js
CHANGED
|
@@ -96,9 +96,17 @@ function where(input) {
|
|
|
96
96
|
function HOW(variable, lines, input, pasteable = lines) {
|
|
97
97
|
const chat = CHAT.hold(input, lines)
|
|
98
98
|
const stuck = lines.filter((one) => !pasteable.includes(one))
|
|
99
|
+
// El alcance del «dale» va con la oferta y no después. La rama del pegado ya decía el suyo —«valen para
|
|
100
|
+
// ese conjunto»— y la del chat no decía ninguno, siendo la que se ofrece primero. Las dos mitades que
|
|
101
|
+
// faltaban no son la misma: que **cubre lo frenado y nada más** explica por qué un archivo nuevo vuelve a
|
|
102
|
+
// frenar, que desde afuera se lee como que el guard se olvidó; y que **sigue valiendo en los mensajes
|
|
103
|
+
// siguientes** es la dirección permisiva, la que nadie nota porque lo que no ocurre es un bloqueo.
|
|
104
|
+
// `check` la muestra al final de la corrida —es lo que trajo el 117—, y al concederla no la decía nadie
|
|
105
|
+
// (caso 170).
|
|
99
106
|
const ask = chat
|
|
100
107
|
? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
|
|
101
|
-
+ 'pasa. '
|
|
108
|
+
+ 'pasa. Ese «dale» cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo en '
|
|
109
|
+
+ 'los mensajes siguientes hasta que ella lo niegue. '
|
|
102
110
|
: ''
|
|
103
111
|
const paste = pasteable.length
|
|
104
112
|
? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
|
package/engine/hooks/chat.js
CHANGED
|
@@ -116,13 +116,13 @@ function mentions(text, item) {
|
|
|
116
116
|
if (before && !/[\s'"`(/]/.test(before)) continue
|
|
117
117
|
if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
|
|
118
118
|
const clause = lower.slice(0, at).split(CLAUSE).pop()
|
|
119
|
-
const
|
|
119
|
+
const phrase = `${clause} ${rest.split(CLAUSE)[0]}`
|
|
120
120
|
found.push({
|
|
121
121
|
denied: NEGATION.test(clause),
|
|
122
|
-
asked: asks(
|
|
122
|
+
asked: asks(phrase),
|
|
123
123
|
// El alcance se lee de la misma frase que decide si la ruta fue pedida, y no del mensaje entero:
|
|
124
124
|
// un «mientras dure la tarea t-014» que hable de otra cosa, en otra oración, no acota a ésta.
|
|
125
|
-
scope: (
|
|
125
|
+
scope: (phrase.match(SCOPE) || [])[1] || '',
|
|
126
126
|
})
|
|
127
127
|
}
|
|
128
128
|
}
|
|
@@ -183,11 +183,19 @@ function record(input) {
|
|
|
183
183
|
// alcance que ya no acota a nadie.
|
|
184
184
|
const scopes = {}
|
|
185
185
|
for (const one of granted) if (previous.scopes && previous.scopes[one]) scopes[one] = previous.scopes[one]
|
|
186
|
+
// Que haya alguien a quien preguntarle es de la sesión y no de este mensaje. Una notificación de una
|
|
187
|
+
// tarea de fondo entra por este mismo hook —es un `UserPromptSubmit` con el texto
|
|
188
|
+
// `<task-notification>…`, medido sobre los registros reales— así que se anota con `human: false`, y
|
|
189
|
+
// sin esto borraba del registro a la persona que está mirando el bloqueo en ese instante. Se arrastra
|
|
190
|
+
// igual que `granted` y por la misma razón: un aviso del runner en el medio no se lleva puesto lo que
|
|
191
|
+
// ya era cierto. Lo vuelve a decidir el mensaje humano siguiente (caso 166).
|
|
192
|
+
const flow = flowCommand(text)
|
|
193
|
+
const askable = human ? !flow : Boolean(previous && previous.askable)
|
|
186
194
|
fs.mkdirSync(DIR, { recursive: true })
|
|
187
195
|
// Sobre qué instancia se está hablando, que es lo que después deja filtrar lo concedido: por qué hace
|
|
188
196
|
// falta, en `grantedIn`.
|
|
189
197
|
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
|
|
190
|
-
{ id: idOf(input), text, human, flow
|
|
198
|
+
{ id: idOf(input), text, human, askable, flow, root: opsRoot(input), approved, granted,
|
|
191
199
|
scopes, pending: [] }))
|
|
192
200
|
} catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
|
|
193
201
|
}
|
|
@@ -203,6 +211,20 @@ function said(input) {
|
|
|
203
211
|
return current && saved.id && current !== saved.id ? null : saved
|
|
204
212
|
}
|
|
205
213
|
|
|
214
|
+
// Hay una persona en esta sesión a quien preguntarle, que no es lo mismo que «este mensaje lo escribió
|
|
215
|
+
// ella». `said` contesta la segunda porque de ella depende **conceder**; ésta contesta la primera, que es
|
|
216
|
+
// todo lo que hace falta para decidir si el bloqueo ofrece contestar «dale» o manda a pegar líneas en un
|
|
217
|
+
// archivo. Preguntar no es conceder, y fundir las dos preguntas le cobraba un copiar y pegar a la persona
|
|
218
|
+
// justo cuando estaba mirando (caso 166).
|
|
219
|
+
//
|
|
220
|
+
// Un registro escrito antes de que `askable` existiera no lo trae y queda afuera: la sesión pierde la
|
|
221
|
+
// salida por chat hasta el mensaje siguiente, que la vuelve a escribir. Es la dirección barata del error.
|
|
222
|
+
function present(input) {
|
|
223
|
+
if (process.env.CI || input.agent_id || !input.session_id) return null
|
|
224
|
+
const saved = load(input.session_id)
|
|
225
|
+
return saved && saved.askable ? saved : null
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
// Con qué autorización pasa un ítem, o vacío si no pasa: lo pidió este mensaje, un «dale» aprobó lo que
|
|
207
229
|
// había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
|
|
208
230
|
// se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
|
|
@@ -303,7 +325,7 @@ function unauthorizedNow(input, items) {
|
|
|
303
325
|
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
304
326
|
// Devuelve si hay una persona a quien preguntarle.
|
|
305
327
|
function hold(input, items) {
|
|
306
|
-
const saved =
|
|
328
|
+
const saved = present(input)
|
|
307
329
|
if (!saved) return false
|
|
308
330
|
try {
|
|
309
331
|
saved.pending = [...new Set([...saved.pending, ...items])]
|
package/engine/hooks/files.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('node:fs')
|
|
|
8
8
|
const path = require('node:path')
|
|
9
9
|
const { spawnSync } = require('node:child_process')
|
|
10
10
|
const {
|
|
11
|
-
patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
11
|
+
patchOf, filesOf, contentOf, cwdOf, block, configOf, opsRoot,
|
|
12
12
|
writableRoots, outsideRoots, DECLARE_IT,
|
|
13
13
|
} = require('./input')
|
|
14
14
|
const AP = require('./approval')
|
|
@@ -242,10 +242,10 @@ function planFirst(input) {
|
|
|
242
242
|
//
|
|
243
243
|
// Se lista recién acá, después de las dos salidas de arriba: quien tiene su plan sale por la primera y
|
|
244
244
|
// no paga esta lectura, que es la misma razón por la que `hasTasks` se pregunta donde se pregunta.
|
|
245
|
-
const
|
|
245
|
+
const foreign = readWips(planning).filter((one) => one.complete + one.pending > 0)
|
|
246
246
|
const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
|
|
247
|
-
const why =
|
|
248
|
-
? `hay plan escrito, pero bajo otro id: ${
|
|
247
|
+
const why = foreign.length
|
|
248
|
+
? `hay plan escrito, pero bajo otro id: ${foreign.map((one) => `${one.runner} (${one.task})`).join(', ')}.\n`
|
|
249
249
|
+ 'Si ese plan es tuyo, volvé a su id con `export CAUCE_RUNNER=<id>` —`ops runners <planning>` los lista '
|
|
250
250
|
+ 'con su tarea y su avance— y repetí el cambio. Si vas a trabajar en paralelo, montá tu propio árbol '
|
|
251
251
|
+ 'con `ops worktree <planning> <tarea>`, que te devuelve el id hecho.\n'
|
|
@@ -258,7 +258,7 @@ function planFirst(input) {
|
|
|
258
258
|
// Con un plan a la vista no se ofrece ninguna de las dos salidas: aprobar la ruta escribe «esto no es
|
|
259
259
|
// trabajo de una tarea», que ahí es falso, y anunciar la variable ofrece el permiso más ancho cuando
|
|
260
260
|
// la acción correcta es angosta y concreta —el mismo criterio con que `HOW` decide no nombrarla—.
|
|
261
|
-
const how =
|
|
261
|
+
const how = foreign.length
|
|
262
262
|
? AP.HOW(null, [], input, [])
|
|
263
263
|
: AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw], input)
|
|
264
264
|
block(`${raw} cambia el producto sin plan. ${why}${how}`)
|
|
@@ -359,7 +359,7 @@ function migrations(input) {
|
|
|
359
359
|
// empresa corre un motor que no coincide con la versión que declara —la clase de diferencia que
|
|
360
360
|
// aparece como un bug irreproducible—. En modo `toolkit` no aplica: ahí el motor es el producto.
|
|
361
361
|
function engineWrites(input) {
|
|
362
|
-
const root =
|
|
362
|
+
const root = opsRoot(input)
|
|
363
363
|
if (!root) return
|
|
364
364
|
const config = configOf(root)
|
|
365
365
|
if (config.mode === 'toolkit') return
|
package/engine/hooks/input.js
CHANGED
|
@@ -157,7 +157,10 @@ function gitDirectory(command, cwd) {
|
|
|
157
157
|
// de correr: gobernanza, dependencias y generados. Cualquier variable delante alcanza, y la ironía es
|
|
158
158
|
// que el prefijo que el procedimiento manda escribir para un commit de gobernanza es
|
|
159
159
|
// `OPS_GOVERNANCE_OVERRIDE=1`: escrito ahí, el guard no lee el override, directamente no se ejecuta.
|
|
160
|
-
|
|
160
|
+
// El ancla admite el salto de línea y el `(` por lo mismo que admite `;`: los tres abren un comando. El
|
|
161
|
+
// salto es el que más caro salía —dos líneas en un solo Bash es como se escriben dos pasos— y el léxico
|
|
162
|
+
// de `shell.js` ya lo declaraba separador; acá faltaba (caso 165).
|
|
163
|
+
const PREFIX = String.raw`(?:^|[;&|(\n]\s*)(?:(?:env|sudo)\s+)*`
|
|
161
164
|
+ String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+)*`
|
|
162
165
|
const COMMIT = new RegExp(PREFIX + String.raw`git\s+commit(?:\s|$)`)
|
|
163
166
|
|
|
@@ -230,7 +233,7 @@ function findOpsRoot(start) {
|
|
|
230
233
|
// Sin raíz legible no hay lista, y quien pregunta se abstiene: el guard que no sabe dónde está no
|
|
231
234
|
// inventa un límite.
|
|
232
235
|
function writableRoots(input) {
|
|
233
|
-
const root =
|
|
236
|
+
const root = opsRoot(input)
|
|
234
237
|
if (!root) return null
|
|
235
238
|
const config = configOf(root)
|
|
236
239
|
return [
|
package/engine/hooks/run.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
const fs = require('node:fs')
|
|
10
10
|
const os = require('node:os')
|
|
11
11
|
const path = require('node:path')
|
|
12
|
-
const { readInput,
|
|
12
|
+
const { readInput, block, opsRoot } = require('./input')
|
|
13
13
|
const shell = require('./shell')
|
|
14
14
|
const { verify } = require('./verify')
|
|
15
15
|
const files = require('./files')
|
|
@@ -18,7 +18,7 @@ const { secretsShell } = require('./secrets-shell')
|
|
|
18
18
|
const { opsConfig, opsConfigShell } = require('./ops-config')
|
|
19
19
|
|
|
20
20
|
function planningDrift(input) {
|
|
21
|
-
const root =
|
|
21
|
+
const root = opsRoot(input)
|
|
22
22
|
if (!root) return
|
|
23
23
|
const local = path.join(root, 'tools', 'ops.js')
|
|
24
24
|
const source = path.join(root, 'engine', 'cli', 'ops.js')
|
|
@@ -202,5 +202,5 @@ if (require.main === module) {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
module.exports = {
|
|
205
|
-
execute, executeAll,
|
|
205
|
+
execute, executeAll, guards, hookGroups, hookMetadata,
|
|
206
206
|
}
|
package/engine/hooks/shell.js
CHANGED
|
@@ -120,7 +120,13 @@ function destructive(input) {
|
|
|
120
120
|
'Operación destructiva sobre disco o dispositivo.',
|
|
121
121
|
],
|
|
122
122
|
[
|
|
123
|
-
|
|
123
|
+
// El destino se reconoce como lo escribe una persona y no sólo desnudo: entre comillas, con llaves,
|
|
124
|
+
// detrás de `--` y con barra final. Las comillas importan más que las otras tres — `rm -rf "$HOME"`
|
|
125
|
+
// es la forma *correcta* de escribirlo en bash, así que sin esto el hueco premiaba al que cita bien
|
|
126
|
+
// sus variables. Y `destructive` no desentrecomilla fuera de un commit, a propósito, así que la
|
|
127
|
+
// comilla tiene que entrar en el patrón (caso 167).
|
|
128
|
+
new RegExp(String.raw`\brm\s+(?:-[^\s]*r[^\s]*\s+)+(?:--\s+)?['"]?`
|
|
129
|
+
+ String.raw`(?:\/\*?|~\/?|\$\{?HOME\}?\/?|\.\.)(?=${PALABRA})`),
|
|
124
130
|
"'rm -r' sobre /, home o el directorio padre es catastrófico.",
|
|
125
131
|
],
|
|
126
132
|
]
|
|
@@ -252,7 +258,12 @@ function dependencies(input) {
|
|
|
252
258
|
// era la causa —sólo hacía que la lectura frenara en un token que sobrevive—: sin él la lista cruzaba
|
|
253
259
|
// igual y el último token era la marca de lo entrecomillado, que el filtro final descarta. O sea que
|
|
254
260
|
// pasaba de casualidad, y aserciar que pasa no fijaba nada.
|
|
255
|
-
|
|
261
|
+
// Y el `|` después de `>` es el override de `noclobber`, no una tubería: `>| destino` escribe igual que
|
|
262
|
+
// `> destino` aunque el shell esté configurado para no pisar. Sin admitirlo acá el guard no veía ese
|
|
263
|
+
// destino —el `|` está fuera de la clase que captura la ruta, así que la coincidencia moría—, y con él
|
|
264
|
+
// se perdían los tres guards que salen de `writesWithBase` a la vez. Lo que un agente se escribía por
|
|
265
|
+
// ese hueco era su propia `.ops-approval` (caso 164).
|
|
266
|
+
const REDIRECT = /(?:^|[\s(])&?\d*>>?\|?\s*(?![&(])([^\s;|&<>()]+)/g
|
|
256
267
|
const EVERY_ARG = /(?:^|[\s;|&(])(tee|truncate)\s+([^;|&<>()\n]+)/g
|
|
257
268
|
const LAST_ARG = /(?:^|[\s;|&(])(cp|mv|install|rsync)\s+([^;|&<>()\n]+)/g
|
|
258
269
|
const SED = /(?:^|[\s;|&(])sed\s+([^;|&<>()\n]+)/g
|
|
@@ -315,7 +326,10 @@ function cdTarget(argument, base) {
|
|
|
315
326
|
function writesWithBase(command, cwd) {
|
|
316
327
|
const found = []
|
|
317
328
|
let base = cwd
|
|
318
|
-
|
|
329
|
+
// El `|` que sigue a un `>` no parte nada: es el override de `noclobber`, no una tubería. Partir ahí
|
|
330
|
+
// separaba la redirección de su destino —`echo x >| ruta` quedaba como `echo x >` y ` ruta`— y el
|
|
331
|
+
// destino no lo veía nadie, que es por donde se colaba escribir la propia `.ops-approval` (caso 164).
|
|
332
|
+
for (const segment of unquoted(command).split(/[;&\n]+|(?<!>)\|+/)) {
|
|
319
333
|
const cd = segment.match(/^\s*cd(?:\s+(\S+))?\s*$/)
|
|
320
334
|
if (cd) { base = base === null ? null : cdTarget(cd[1], base); continue }
|
|
321
335
|
for (const raw of writeTargets(segment)) found.push({ raw, base })
|
|
@@ -380,9 +394,9 @@ function governance(input) {
|
|
|
380
394
|
// La aprobación vale para lo que nombra y para nada más: lo que quede sin cubrir es lo que se
|
|
381
395
|
// reporta. Así una aprobación vieja no autoriza el archivo que se sumó después, que es la diferencia
|
|
382
396
|
// entre una llave por operación y una puerta que quedó abierta.
|
|
383
|
-
const
|
|
384
|
-
if (!
|
|
385
|
-
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE',
|
|
397
|
+
const pending = AP.pendingNow(opsRoot(input), governed, input)
|
|
398
|
+
if (!pending.length) return
|
|
399
|
+
block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pending, input)}`)
|
|
386
400
|
}
|
|
387
401
|
|
|
388
402
|
function run(program, args, cwd, extra = {}) {
|
package/engine/hooks/verify.js
CHANGED
|
@@ -216,15 +216,15 @@ function fallo(gate, result) {
|
|
|
216
216
|
// Un gate que vuelve en menos de esto no corrió una suite. No se afirma que **no** haya corrido —un
|
|
217
217
|
// lint puede fallar rápido y de verdad— y por eso lo que se agrega es el número, no un veredicto: los
|
|
218
218
|
// tres gates del caso 068 volvieron a un segundo uno de otro contra los trece de la corrida real.
|
|
219
|
-
const
|
|
220
|
-
function
|
|
221
|
-
const
|
|
219
|
+
const TOO_FAST = 2000
|
|
220
|
+
function howItReads(failures) {
|
|
221
|
+
const summary = failures
|
|
222
222
|
.map((one) => `${one.gate} (exit ${one.status}, ${(one.ms / 1000).toFixed(1)} s)`
|
|
223
223
|
+ `${one.line ? `: ${one.line}` : ''}`)
|
|
224
224
|
.join('; ')
|
|
225
|
-
if (!failures.every((one) => one.ms <
|
|
226
|
-
const
|
|
227
|
-
return `${
|
|
225
|
+
if (!failures.every((one) => one.ms < TOO_FAST)) return summary
|
|
226
|
+
const lead = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
|
|
227
|
+
return `${summary}\n${lead} en menos de ${TOO_FAST / 1000} s: eso no alcanza para correr `
|
|
228
228
|
+ 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
|
|
229
229
|
}
|
|
230
230
|
|
|
@@ -268,7 +268,7 @@ function verifyGates(root, dir, sinAprobar, env, input) {
|
|
|
268
268
|
// comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
|
|
269
269
|
const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
|
|
270
270
|
+ 'directorio pasa, es que en disco tenés algo que no está staged.'
|
|
271
|
-
block(`Verify falló en ${path.basename(dir)}: ${
|
|
271
|
+
block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${donde}\n`
|
|
272
272
|
+ AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
|
|
273
273
|
}
|
|
274
274
|
|
|
@@ -40,7 +40,11 @@ function validate(root, provider, workspaces = []) {
|
|
|
40
40
|
let maxHours = 4
|
|
41
41
|
try {
|
|
42
42
|
const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
|
|
43
|
-
maxHours
|
|
43
|
+
// `|| maxHours` y no a secas: un `runner` sin `maxTaskHours` no lanza, así que el `catch` no lo ve
|
|
44
|
+
// y el tope quedaba en `undefined`. `estimate > undefined` es falso siempre, o sea que el límite
|
|
45
|
+
// desaparecía entero y en silencio — el modo de fallo que menos se nota, porque lo que deja de
|
|
46
|
+
// pasar es un rechazo.
|
|
47
|
+
maxHours = config.runner.maxTaskHours || maxHours
|
|
44
48
|
} catch { /* reported by the main validator */ }
|
|
45
49
|
for (const proposal of read(root, provider)) {
|
|
46
50
|
const at = `integrations/${provider}/proposed/${proposal.file}`
|
|
@@ -197,6 +197,13 @@ function validate(root, onlyProvider = '') {
|
|
|
197
197
|
if (fields.promotionKind === 'story' && (!/^\d{3}$/.test(fields.promotionEpic) || !fields.promotionCriteria)) {
|
|
198
198
|
errors.push(`${at}: story exige promotionEpic NNN y promotionCriteria`)
|
|
199
199
|
}
|
|
200
|
+
// Para una épica el campo es opcional —sin él el número lo elige el motor—, y por eso nadie lo
|
|
201
|
+
// miraba: el único caso sin validar era justo el que arma el nombre del archivo con lo que diga.
|
|
202
|
+
// `promotionEpic: 7` producía `epic-7-…`, que el roadmap no reconoce como épica, y un `..` sacaba
|
|
203
|
+
// la épica de `roadmap/` entero con `promote` devolviendo 0 y anunciando que promovió.
|
|
204
|
+
if (fields.promotionKind === 'epic' && fields.promotionEpic && !/^\d{3}$/.test(fields.promotionEpic)) {
|
|
205
|
+
errors.push(`${at}: promotionEpic debe ser NNN`)
|
|
206
|
+
}
|
|
200
207
|
if (!sections.Aceptación || /^(por definir|n\/a|pendiente)\.?$/i.test(sections.Aceptación)) {
|
|
201
208
|
errors.push(`${at}: ready exige aceptación concreta`)
|
|
202
209
|
}
|
|
@@ -107,9 +107,12 @@ function derive(snapshot, draft) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function renderDraft(item, config, state = 'pending') {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
// `|| []` porque el item lo arma el adaptador y el README declara el contrato por sus tres funciones,
|
|
111
|
+
// sin decir qué campos trae un item. Uno propio —que es lo que ese README invita a escribir— puede no
|
|
112
|
+
// traer `components`, y con `serviceFrom: "component"`, que es lo que trae el molde, eso era un
|
|
113
|
+
// TypeError sin nada que lo atribuya. Sin campo la respuesta es la que el borrador ya sabe dar.
|
|
114
|
+
const components = item.components || []
|
|
115
|
+
const service = config.serviceFrom === 'component' && components.length === 1 ? components[0] : ''
|
|
113
116
|
const acceptance = item.acceptance || 'Por definir.'
|
|
114
117
|
const issues = []
|
|
115
118
|
if (!item.acceptance) issues.push('La incidencia no contiene aceptación concreta.')
|
|
@@ -74,9 +74,9 @@ function sealWarnings(dir) {
|
|
|
74
74
|
if (extra > 0) {
|
|
75
75
|
// Cuáles sobran no se puede decir sin el conjunto original, y la cuenta sí: se nombran los últimos,
|
|
76
76
|
// que es donde se agrega a mano. Decir «alguna de éstas» es más honesto que señalar la equivocada.
|
|
77
|
-
const
|
|
77
|
+
const lastOnes = slugs.slice(-extra).join(', ')
|
|
78
78
|
return [`${BASELINE}: la lista creció. La huella sella ${seal[1]} entrada(s) y hay ${slugs.length}; `
|
|
79
|
-
+ `sobra(n) ${extra}, probablemente ${
|
|
79
|
+
+ `sobra(n) ${extra}, probablemente ${lastOnes}. Una entrada agregada a mano queda exenta para `
|
|
80
80
|
+ 'siempre y nada más lo dice.']
|
|
81
81
|
}
|
|
82
82
|
return [`${BASELINE}: la huella no coincide con la lista. Sella ${seal[1]} entrada(s) y hay `
|
|
@@ -109,7 +109,7 @@ function validate({ claims, milestones, done }) {
|
|
|
109
109
|
return errors
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
const
|
|
112
|
+
const daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`))
|
|
113
113
|
/ 86400000)
|
|
114
114
|
|
|
115
115
|
// `activity` mapea el slug de una tarea a la fecha del último commit de su rama. Llega de afuera porque
|
|
@@ -125,14 +125,14 @@ function warnings({ claims, done, today, activity = new Map() }) {
|
|
|
125
125
|
}
|
|
126
126
|
if (!DATE.test(claim.started)) continue
|
|
127
127
|
const commit = activity.get(claim.slug) || ''
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
const
|
|
132
|
-
? `último commit hace ${
|
|
128
|
+
const lastSeen = commit && commit > claim.started ? commit : claim.started
|
|
129
|
+
const idleDays = daysBetween(lastSeen, today)
|
|
130
|
+
if (idleDays > STALE_DAYS) {
|
|
131
|
+
const signal = commit
|
|
132
|
+
? `último commit hace ${daysBetween(commit, today)} días`
|
|
133
133
|
: 'la rama de la tarea no tiene commits'
|
|
134
|
-
lines.push(`${claim.at}: ${claim.slug} sin avanzar hace ${
|
|
135
|
-
+ `(${claim.owner}, tomada hace ${
|
|
134
|
+
lines.push(`${claim.at}: ${claim.slug} sin avanzar hace ${idleDays} días `
|
|
135
|
+
+ `(${claim.owner}, tomada hace ${daysBetween(claim.started, today)}; ${signal}); mirá si sigue viva`)
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
// Dos tareas del mismo servicio pueden tocar los mismos archivos, y eso no se puede saber antes de
|
|
@@ -315,13 +315,13 @@ const HUMAN_ACTION_STATES = ['pendiente', 'resuelta']
|
|
|
315
315
|
// Lo que no cubre: una celda que termine en una barra invertida literal. En markdown eso se escribe
|
|
316
316
|
// `\\` y acá se leería como escape del separador. Es un borde que nadie escribe y taparlo pedía un
|
|
317
317
|
// parser de verdad; queda dicho en vez de supuesto.
|
|
318
|
-
const
|
|
319
|
-
const
|
|
318
|
+
const CELL_SPLIT = /(?<!\\)\|/
|
|
319
|
+
const SEPARATORS = /^\|\s*:?-+/
|
|
320
320
|
|
|
321
321
|
// El escape se quita al normalizar. Esta columna existe para que una persona lea qué tiene que hacer, y
|
|
322
322
|
// `\|` no es parte de lo que quiso decir: es cómo markdown escribe un pipe. La fila archivada no se ve
|
|
323
323
|
// afectada —`archive` reescribe `raw`, la línea original, no las celdas—, así que quitarlo no pierde nada.
|
|
324
|
-
const
|
|
324
|
+
const cellOf = (cell) => cell.trim().replace(/\\\|/g, '|')
|
|
325
325
|
|
|
326
326
|
// Las filas de datos de las tablas de un texto, con las celdas ya normalizadas. Vive acá y no en cada
|
|
327
327
|
// lector porque el escape del pipe tiene que leerse igual en los dos archivos que traen tabla: escrito
|
|
@@ -332,11 +332,11 @@ const celda = (cell) => cell.trim().replace(/\\\|/g, '|')
|
|
|
332
332
|
// y con `findIndex` la segunda y la tercera vuelven a leerse como datos. Nada más se mueve, porque una
|
|
333
333
|
// fila de datos nunca está inmediatamente antes de los guiones.
|
|
334
334
|
function tableRows(text) {
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
return
|
|
338
|
-
.filter((line, i) => /^\|/.test(line) && !
|
|
339
|
-
.map((line) => ({ line, cells: line.split(
|
|
335
|
+
const lines = text.split('\n')
|
|
336
|
+
const headers = new Set(lines.map((line, i) => (SEPARATORS.test(line) ? i - 1 : -1)))
|
|
337
|
+
return lines
|
|
338
|
+
.filter((line, i) => /^\|/.test(line) && !SEPARATORS.test(line) && !headers.has(i))
|
|
339
|
+
.map((line) => ({ line, cells: line.split(CELL_SPLIT).slice(1, -1).map(cellOf) }))
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
function readHumanActions(dir) {
|
package/engine/planning/state.js
CHANGED
|
@@ -86,8 +86,8 @@ function currentTask({ milestones, done, wips = [], claims = [] }, blockers = []
|
|
|
86
86
|
// estaría lista y no acá—, así que buscarla no puede fallar y no lleva defensa.
|
|
87
87
|
waiting: pending.filter((task) => !others.has(task.slug) && !ready(task)).map((task) => {
|
|
88
88
|
const dep = task.depends.find((one) => !done.set.has(one))
|
|
89
|
-
const
|
|
90
|
-
return { slug: task.slug, dep, owner:
|
|
89
|
+
const other = others.get(dep)
|
|
90
|
+
return { slug: task.slug, dep, owner: other ? other.owner : (mine.has(dep) ? 'vos' : '') }
|
|
91
91
|
}),
|
|
92
92
|
}
|
|
93
93
|
}
|
|
@@ -98,23 +98,23 @@ function ruleIds(file) {
|
|
|
98
98
|
// escribir, y ahí el aviso es lo único que lo dice.
|
|
99
99
|
function competingSections(dir) {
|
|
100
100
|
const roadmap = path.join(dir, 'roadmap')
|
|
101
|
-
const
|
|
101
|
+
const warnings = []
|
|
102
102
|
let files = []
|
|
103
103
|
try { files = fs.readdirSync(roadmap).filter((file) => /^epic-\d{3}-/.test(file)) } catch { return [] }
|
|
104
104
|
for (const file of files.sort()) {
|
|
105
105
|
const text = P.read(path.join(roadmap, file))
|
|
106
106
|
const titles = [...text.matchAll(/^##\s+(.+)$/gm)].map((hit) => hit[1].trim())
|
|
107
107
|
for (const role of [/Criterios/i, /Historias/i]) {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
108
|
+
const matching = titles.filter((title) => role.test(title))
|
|
109
|
+
if (matching.length < 2) continue
|
|
110
110
|
const exact = new RegExp(`^${role.source}$`, role.flags)
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
111
|
+
const winner = matching.find((title) => exact.test(title)) || matching[0]
|
|
112
|
+
const ignored = matching.filter((title) => title !== winner)
|
|
113
|
+
warnings.push(`roadmap/${file}: "## ${winner}" convive con "## ${ignored.join('", "## ')}"; `
|
|
114
114
|
+ 'sólo se lee la primera y el resto se ignora entero')
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
-
return
|
|
117
|
+
return warnings
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function retiredByOverride(dir, name) {
|