@ingeniomaps/cauce 0.94.0 → 0.95.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 +196 -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 +2 -2
- package/engine/automation/config.js +9 -9
- package/engine/automation/index.js +19 -8
- package/engine/cli/archive.js +1 -4
- package/engine/cli/args.js +16 -2
- package/engine/cli/bench.js +3 -3
- package/engine/cli/catalog.js +3 -3
- package/engine/cli/claims.js +21 -23
- package/engine/cli/contract.js +24 -7
- package/engine/cli/instance.js +19 -19
- package/engine/cli/ops.js +7 -3
- package/engine/cli/planning.js +4 -4
- package/engine/cli/upgrade-report.js +10 -10
- package/engine/cli/validate.js +7 -7
- package/engine/core/onboarding.js +4 -1
- package/engine/core/ownership.js +2 -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/claims.js
CHANGED
|
@@ -9,9 +9,7 @@ const path = require('node:path')
|
|
|
9
9
|
const CL = require('../planning/claims')
|
|
10
10
|
const R = require('../core/repos')
|
|
11
11
|
const ST = require('../planning/state')
|
|
12
|
-
const { fail, planningRoot } = require('./io')
|
|
13
|
-
|
|
14
|
-
const TODAY = () => new Date().toISOString().slice(0, 10)
|
|
12
|
+
const { fail, planningRoot, TODAY } = require('./io')
|
|
15
13
|
|
|
16
14
|
function claim(dir, slug, cli) {
|
|
17
15
|
const root = planningRoot(dir)
|
|
@@ -22,8 +20,8 @@ function claim(dir, slug, cli) {
|
|
|
22
20
|
|
|
23
21
|
// No se reserva lo que todavía no se puede empezar: una tarea tomada con su dependencia en vuelo
|
|
24
22
|
// bloquea la cola sin que nadie pueda avanzarla, y el runner que la tomó se queda sin poder tomar otra.
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
23
|
+
const blocker = task.depends.find((dep) => !state.done.set.has(dep))
|
|
24
|
+
if (blocker) return fail(`${slug} depende de ${blocker}, que todavía no está en DONE.`)
|
|
27
25
|
|
|
28
26
|
const me = CL.owner(root)
|
|
29
27
|
const from = CL.runner()
|
|
@@ -45,7 +43,7 @@ function claim(dir, slug, cli) {
|
|
|
45
43
|
|
|
46
44
|
const target = CL.file(root, slug)
|
|
47
45
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
48
|
-
const
|
|
46
|
+
const body = CL.content({ task: slug, owner: me, runner: from, started: TODAY(), service: task.service })
|
|
49
47
|
try {
|
|
50
48
|
// Reservar **es** crear el archivo, así que el único juez de quién la tiene es el archivo. `wx` falla
|
|
51
49
|
// si ya está, y de ahí sale la respuesta entera: propia, ajena o perdida en la carrera.
|
|
@@ -54,25 +52,25 @@ function claim(dir, slug, cli) {
|
|
|
54
52
|
// de diferencia ganarían los dos sin que ninguno se entere. Y una comprobación previa tampoco
|
|
55
53
|
// alcanzaría —entre mirar y escribir queda la misma ventana—, así que sería un segundo juez que
|
|
56
54
|
// adelanta un veredicto que este bloque tiene que volver a dar igual.
|
|
57
|
-
fs.writeFileSync(target,
|
|
55
|
+
fs.writeFileSync(target, body, { flag: 'wx' })
|
|
58
56
|
} catch (error) {
|
|
59
57
|
if (error.code !== 'EEXIST') throw error
|
|
60
|
-
const
|
|
58
|
+
const holder = CL.read(root).find((one) => one.slug === slug)
|
|
61
59
|
// Existía al crear y ya no está: alguien la soltó entre las dos operaciones. Es una ventana de
|
|
62
60
|
// microsegundos y aun así tiene respuesta, porque la alternativa es reventar con un TypeError.
|
|
63
|
-
if (!
|
|
64
|
-
if (
|
|
61
|
+
if (!holder) return fail(`${slug} cambió de manos mientras la pedías; volvé a intentarlo.`)
|
|
62
|
+
if (holder.runner === from) return console.log(`= ${slug} ya era tuya desde ${holder.started}`)
|
|
65
63
|
// Mismo dueño y otro runner son dos situaciones que se ven idénticas desde acá —vos retomando la
|
|
66
64
|
// sesión de ayer, o un segundo agente tuyo— y ninguna se puede distinguir mirando el archivo.
|
|
67
65
|
// Retomarla sola le sacaría la tarea al otro agente; crear un runner nuevo dejaría dos trabajando lo
|
|
68
66
|
// mismo. Las dos rompen trabajo, así que decide una persona y acá sólo se dice cuál es cuál.
|
|
69
|
-
if (
|
|
70
|
-
return fail(`${slug} la tenés vos, tomada el ${
|
|
67
|
+
if (holder.owner === me) {
|
|
68
|
+
return fail(`${slug} la tenés vos, tomada el ${holder.started} desde el runner ${holder.runner}. `
|
|
71
69
|
+ 'Preguntá si se retoma esa sesión —y entonces corré con ese id— o si es otro agente en '
|
|
72
70
|
+ `paralelo, que toma otra tarea. \`ops runners ${path.relative(process.cwd(), root) || '.'}\` `
|
|
73
71
|
+ 'lista lo que hay abierto.')
|
|
74
72
|
}
|
|
75
|
-
return fail(`${slug} la tomó ${
|
|
73
|
+
return fail(`${slug} la tomó ${holder.owner} el ${holder.started}. Si se abandonó, borrá `
|
|
76
74
|
+ `${CL.DIR}/${slug}.md a mano: soltar lo de otro es una decisión, no un comando.`)
|
|
77
75
|
}
|
|
78
76
|
console.log(`✓ ${slug} tomada por ${me}`)
|
|
@@ -111,20 +109,20 @@ function release(dir, slug) {
|
|
|
111
109
|
function runners(dir, cli) {
|
|
112
110
|
const root = planningRoot(dir)
|
|
113
111
|
const done = ST.snapshot(root).done
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
112
|
+
const open = CL.read(root).filter((one) => !done.set.has(one.slug))
|
|
113
|
+
const today = TODAY()
|
|
114
|
+
const rows = open.map((one) => {
|
|
117
115
|
const commit = R.lastCommit(R.repoOf(path.join(root, '..'), one.service), CL.branchOf(one.slug))
|
|
118
116
|
return { runner: one.runner, task: one.slug, owner: one.owner, started: one.started, lastCommit: commit }
|
|
119
117
|
})
|
|
120
|
-
if (cli.has('--json')) return console.log(JSON.stringify(
|
|
121
|
-
if (!
|
|
122
|
-
const
|
|
123
|
-
for (const
|
|
124
|
-
const
|
|
125
|
-
console.log(`${
|
|
118
|
+
if (cli.has('--json')) return console.log(JSON.stringify(rows))
|
|
119
|
+
if (!rows.length) return console.log('= ningún runner tiene trabajo abierto: arrancá con un id propio')
|
|
120
|
+
const width = Math.max(...rows.map((one) => one.runner.length))
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const progress = row.lastCommit ? `último commit ${row.lastCommit}` : 'sin commits en su rama'
|
|
123
|
+
console.log(`${row.runner.padEnd(width)} ${row.task} (${row.owner}, desde ${row.started}; ${progress})`)
|
|
126
124
|
}
|
|
127
|
-
console.log(`\n${
|
|
125
|
+
console.log(`\n${rows.length} runner(s) con trabajo abierto al ${today}. `
|
|
128
126
|
+ 'Retomá uno usando su id, o arrancá con uno propio.')
|
|
129
127
|
}
|
|
130
128
|
|
package/engine/cli/contract.js
CHANGED
|
@@ -83,9 +83,18 @@ const MARKED = /^###\s+Límites\s*$/m
|
|
|
83
83
|
// llevaba adentro los cuatro párrafos que la persona había agregado al final de la sección. Descontar el
|
|
84
84
|
// bloque entero —que es lo que parecía el arreglo— apagaba justo lo que el aviso existe para encontrar.
|
|
85
85
|
const BULLET = /^\s*[-*]\s+/
|
|
86
|
+
// Una viñeta es la viñeta entera, no su primera línea. Un límite de verdad no entra en el ancho del
|
|
87
|
+
// archivo, así que se escribe en dos, y recorriendo línea por línea lo que viajaba al preámbulo de cada
|
|
88
|
+
// subagente era la mitad — cortada justo donde suele estar lo que el límite decide (caso 168).
|
|
89
|
+
//
|
|
90
|
+
// `own` son las líneas **sin plegar**, y existe para `warnings`: descuenta por línea contra lo que el
|
|
91
|
+
// archivo tiene escrito (caso 159), y una viñeta ya plegada no coincide con ninguna de esas líneas, así
|
|
92
|
+
// que su continuación volvía a contarse como un párrafo perdido. Son dos preguntas distintas sobre el
|
|
93
|
+
// mismo bloque: qué dice cada límite, y qué líneas ya están cubiertas.
|
|
86
94
|
function marked(raw) {
|
|
87
95
|
const bullets = []
|
|
88
96
|
const intros = []
|
|
97
|
+
const own = []
|
|
89
98
|
let rest = raw
|
|
90
99
|
for (let start = rest.search(MARKED); start >= 0; start = rest.search(MARKED)) {
|
|
91
100
|
const after = rest.slice(start).split('\n').slice(1)
|
|
@@ -93,13 +102,21 @@ function marked(raw) {
|
|
|
93
102
|
const block = end < 0 ? after : after.slice(0, end)
|
|
94
103
|
const first = block.findIndex((line) => BULLET.test(line))
|
|
95
104
|
if (first > 0) intros.push(...block.slice(0, first).map((line) => line.trim()).filter(Boolean))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
// Una línea en blanco cierra la viñeta abierta: lo que venga después es otra cosa y pegarlo ahí
|
|
106
|
+
// uniría dos límites en uno.
|
|
107
|
+
let open = -1
|
|
108
|
+
for (const line of first < 0 ? [] : block.slice(first)) {
|
|
109
|
+
const text = line.replace(BULLET, '').trim()
|
|
110
|
+
if (!text) { open = -1; continue }
|
|
111
|
+
// A `own` va lo que pertenece a una viñeta y nada más. Empujar toda línea no vacía haría que la
|
|
112
|
+
// prosa suelta que quedó bajo el encabezado contara como declarada y `warnings` dejara de nombrarla:
|
|
113
|
+
// el plegado apagaría el aviso en vez de arreglar el límite.
|
|
114
|
+
if (BULLET.test(line)) { bullets.push(text); open = bullets.length - 1; own.push(text) }
|
|
115
|
+
else if (open >= 0) { bullets[open] += ` ${text}`; own.push(text) }
|
|
116
|
+
}
|
|
100
117
|
rest = (end < 0 ? '' : after.slice(end).join('\n'))
|
|
101
118
|
}
|
|
102
|
-
return { bullets, intros }
|
|
119
|
+
return { bullets, intros, own }
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
const declared = (raw) => marked(raw).bullets
|
|
@@ -147,8 +164,8 @@ function warnings(root) {
|
|
|
147
164
|
// Se descuenta por línea y no por párrafo, que es donde estaba el defecto: `paragraphs` saca el `- ` y
|
|
148
165
|
// une las viñetas seguidas en un párrafo solo, así que una lista declarada no era igual a ninguna
|
|
149
166
|
// entrada de `declared()` y se contaba entera como un límite perdido (caso 159).
|
|
150
|
-
const {
|
|
151
|
-
const suyo = new Set([...
|
|
167
|
+
const { own, intros } = marked(mine)
|
|
168
|
+
const suyo = new Set([...own, ...intros])
|
|
152
169
|
const outside = P.withoutComments(mine).split('\n')
|
|
153
170
|
.filter((line) => !suyo.has(line.replace(BULLET, '').trim()))
|
|
154
171
|
.join('\n')
|
package/engine/cli/instance.js
CHANGED
|
@@ -15,7 +15,7 @@ const P = require('../planning/parser')
|
|
|
15
15
|
const ST = require('../planning/state')
|
|
16
16
|
const RC = require('../planning/recurring')
|
|
17
17
|
const A = require('../automation')
|
|
18
|
-
const { fail } = require('./io')
|
|
18
|
+
const { fail, TODAY } = require('./io')
|
|
19
19
|
const { declareEngine, pinEngine, undeclareEngine } = require('./dependency')
|
|
20
20
|
const { adviceFor, previewUpgrade, reportUpgrade } = require('./upgrade-report')
|
|
21
21
|
|
|
@@ -98,7 +98,7 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
|
|
|
98
98
|
'{{PROJECT_NAME}}': name,
|
|
99
99
|
'{{MODE}}': mode,
|
|
100
100
|
'{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
|
|
101
|
-
...RC.sinceValues(
|
|
101
|
+
...RC.sinceValues(TODAY()),
|
|
102
102
|
}, force, providerNames(), quiet)
|
|
103
103
|
// No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
|
|
104
104
|
// el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando lo que no aplica dejaba
|
|
@@ -309,17 +309,17 @@ function upgrade(dir, cli) {
|
|
|
309
309
|
//
|
|
310
310
|
// El aviso sale en **cada** corrida y no sólo la primera: una instancia con medio molde congelado y
|
|
311
311
|
// sin enterarse es el otro modo de fallo, y es silencioso.
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
for (const file of
|
|
315
|
-
console.log(`\n${
|
|
316
|
-
console.log(`${adviceFor([...
|
|
312
|
+
const keptFiles = force ? new Set() : new Set(changed)
|
|
313
|
+
if (keptFiles.size) {
|
|
314
|
+
for (const file of keptFiles) console.log(`= conservado ${file} (editado localmente)`)
|
|
315
|
+
console.log(`\n${keptFiles.size} archivo(s) del molde quedan congelados por tu edición.`)
|
|
316
|
+
console.log(`${adviceFor([...keptFiles])}\n`)
|
|
317
317
|
console.log('Para tomar la versión nueva y descartar la tuya, repetí con --force.\n')
|
|
318
318
|
}
|
|
319
319
|
// Un archivo propio que se llama como uno que el paquete empieza a traer (caso 110): se conserva y se
|
|
320
320
|
// dice, igual que una edición local, y `--force` lo reemplaza diciéndolo.
|
|
321
|
-
const
|
|
322
|
-
for (const file of
|
|
321
|
+
const collisions = new Set(force ? [] : colliding)
|
|
322
|
+
for (const file of collisions) {
|
|
323
323
|
console.log(`= conservado ${file}: ya existía y Cauce no lo entregó, así que el del paquete no se `
|
|
324
324
|
+ 'instaló. Renombrá el tuyo y repetí, o repetí con --force para reemplazarlo.')
|
|
325
325
|
}
|
|
@@ -345,7 +345,7 @@ function upgrade(dir, cli) {
|
|
|
345
345
|
'{{PROJECT_NAME}}': config.project || path.basename(root),
|
|
346
346
|
'{{MODE}}': O.mode(root),
|
|
347
347
|
'{{WORKSPACE_PATH}}': O.mode(root) === 'embedded' ? '.' : '..',
|
|
348
|
-
...RC.sinceValues(
|
|
348
|
+
...RC.sinceValues(TODAY()),
|
|
349
349
|
})) content = content.replaceAll(key, value)
|
|
350
350
|
F.atomicWrite(target, content)
|
|
351
351
|
added.push(relative)
|
|
@@ -368,13 +368,13 @@ function upgrade(dir, cli) {
|
|
|
368
368
|
|
|
369
369
|
const conservar = (file) => {
|
|
370
370
|
const relative = path.relative(root, file).replace(/\\/g, '/')
|
|
371
|
-
return
|
|
371
|
+
return keptFiles.has(relative) || collisions.has(relative)
|
|
372
372
|
}
|
|
373
373
|
for (const relative of [...system, ...O.RUNTIME_PATHS]) {
|
|
374
374
|
const origin = path.join(PROJECT_ROOT, O.sourceOf(relative))
|
|
375
375
|
if (!fs.existsSync(origin)) continue
|
|
376
376
|
const target = path.join(root, relative)
|
|
377
|
-
if (
|
|
377
|
+
if (keptFiles.has(relative)) continue
|
|
378
378
|
// Sobrescribe lo que trae el paquete y deja intacto lo demás: un guard propio de la empresa,
|
|
379
379
|
// o un adaptador de runner que el toolkit no conoce, sobreviven a la actualización.
|
|
380
380
|
if (fs.statSync(origin).isDirectory()) copyRuntime(origin, target, false, root, [], conservar)
|
|
@@ -389,7 +389,7 @@ function upgrade(dir, cli) {
|
|
|
389
389
|
|
|
390
390
|
// Retirar lo que el toolkit ya no distribuye, después de haber actualizado lo que sí.
|
|
391
391
|
const retired = []
|
|
392
|
-
const
|
|
392
|
+
const pending = []
|
|
393
393
|
for (const relative of O.RETIRED) {
|
|
394
394
|
const target = path.join(root, relative)
|
|
395
395
|
if (!fs.existsSync(target)) continue
|
|
@@ -398,10 +398,10 @@ function upgrade(dir, cli) {
|
|
|
398
398
|
// también usa para lo suyo, borrar el directorio entero se llevaba puesto contenido que nadie
|
|
399
399
|
// había entregado —un `autobuild.js` propio, los workflows de una empresa— sin confirmación y sin
|
|
400
400
|
// vuelta atrás. Se conserva y se nombra; `--force` es la salida, igual que para una edición local.
|
|
401
|
-
const
|
|
401
|
+
const leftover = O.RETIRED_SHARED.includes(relative) && fs.statSync(target).isDirectory()
|
|
402
402
|
? O.treeFiles(target)
|
|
403
403
|
: []
|
|
404
|
-
if (
|
|
404
|
+
if (leftover.length && !force) { pending.push({ relative, files: leftover }); continue }
|
|
405
405
|
fs.rmSync(target, { recursive: true, force: true })
|
|
406
406
|
retired.push(relative)
|
|
407
407
|
}
|
|
@@ -413,7 +413,7 @@ function upgrade(dir, cli) {
|
|
|
413
413
|
// conservar **ese** digest: registrar el de disco lo volvería idéntico a lo entregado, dejaría de
|
|
414
414
|
// detectarse como editado y la corrida siguiente lo pisaría sin decir nada. Es el 001 de vuelta por
|
|
415
415
|
// la puerta de atrás, y no se ve mirando el archivo — se ve dos upgrades después.
|
|
416
|
-
const
|
|
416
|
+
const delivered = { ...record }
|
|
417
417
|
for (const relative of O.trackedPaths()) {
|
|
418
418
|
if (fs.existsSync(path.join(root, relative))) {
|
|
419
419
|
record = M.record(root, relative, O.deliveredFiles(root, relative), record)
|
|
@@ -428,8 +428,8 @@ function upgrade(dir, cli) {
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
record = M.recordPaths(root, O.SYSTEM_FILES, record)
|
|
431
|
-
for (const file of
|
|
432
|
-
for (const file of
|
|
431
|
+
for (const file of keptFiles) if (delivered[file]) record[file] = delivered[file]
|
|
432
|
+
for (const file of collisions) delete record[file]
|
|
433
433
|
// El registro de forks se poda igual que el de archivos: un cargo devuelto al catálogo deja su
|
|
434
434
|
// entrada, y una entrada sin copia sólo puede producir avisos sobre algo que no está.
|
|
435
435
|
const kept = Object.fromEntries(Object.entries(M.readForks(root)).filter(
|
|
@@ -447,7 +447,7 @@ function upgrade(dir, cli) {
|
|
|
447
447
|
// que no ocurrieron (caso 048).
|
|
448
448
|
reportUpgrade({
|
|
449
449
|
root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
|
|
450
|
-
|
|
450
|
+
discarded: force ? changed : [], keptFiles: [...keptFiles], pending,
|
|
451
451
|
})
|
|
452
452
|
}
|
|
453
453
|
|
package/engine/cli/ops.js
CHANGED
|
@@ -195,11 +195,15 @@ const NO_FLAGS = { has: () => false, value: (_flag, fallback = '') => fallback }
|
|
|
195
195
|
|
|
196
196
|
async function run(cli) {
|
|
197
197
|
const [command] = cli.positional
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
// `--help` y `-h` nunca llegan acá: `parse` los reconoce como banderas, así que el posicional queda
|
|
199
|
+
// vacío y lo atiende el `!command` de al lado. Lo que sí es un posicional es `help` a secas.
|
|
200
|
+
if (!command || command === 'help') return usage()
|
|
201
|
+
// `Object.hasOwn` y no `FLAGS[command]`: `constructor` heredado de `Object.prototype` pasaba por
|
|
202
|
+
// comando válido y el CLI salía con 0 sin hacer nada.
|
|
203
|
+
if (!Object.hasOwn(FLAGS, command)) { usage(); fail(`Comando desconocido: ${command}`, 2) }
|
|
200
204
|
// `--help` valía sólo como primer argumento: `check --help` corría `check` contra el directorio
|
|
201
205
|
// actual en vez de explicarse.
|
|
202
|
-
if (cli.has('--help')
|
|
206
|
+
if (cli.has('--help')) return usage()
|
|
203
207
|
const unknown = cli.unknown(command)
|
|
204
208
|
if (unknown.length) {
|
|
205
209
|
const accepts = FLAGS[command].length ? `Acepta: ${FLAGS[command].join(', ')}.` : 'No acepta banderas.'
|
package/engine/cli/planning.js
CHANGED
|
@@ -141,7 +141,7 @@ function context(dir, cli) {
|
|
|
141
141
|
// Un filtro elige dónde buscar trabajo **nuevo**; no puede esconder el que ya tenés. Sin esto, pedir
|
|
142
142
|
// otro hito mientras sostenías una tarea ofrecía una segunda que `claim` después se niega a dar: el
|
|
143
143
|
// comando que dice qué hacer y el que lo autoriza contestaban distinto, y sólo se veía al reclamar.
|
|
144
|
-
const
|
|
144
|
+
const own = state.claims.find((one) => one.runner === from && !state.done.set.has(one.slug))
|
|
145
145
|
let hitoOmitido = ''
|
|
146
146
|
if (hito) {
|
|
147
147
|
const existe = state.milestones.some((one) => one.slug === hito)
|
|
@@ -150,7 +150,7 @@ function context(dir, cli) {
|
|
|
150
150
|
const hay = state.milestones.map((one) => one.slug).join(', ') || '(ninguno)'
|
|
151
151
|
return fail(`el hito ${hito} no existe. Hay: ${hay}`, 2)
|
|
152
152
|
}
|
|
153
|
-
if (
|
|
153
|
+
if (own) hitoOmitido = `${hito} no se aplica: ya tenés ${own.slug} tomada`
|
|
154
154
|
else state.milestones = state.milestones.filter((one) => one.slug === hito)
|
|
155
155
|
}
|
|
156
156
|
const gate = path.join(root, 'AWAITING_REVIEW.md')
|
|
@@ -222,13 +222,13 @@ function context(dir, cli) {
|
|
|
222
222
|
// Es el comando que existe para decir qué toca ahora, contestando «nada» cuando lo que toca es eso.
|
|
223
223
|
// Tu propio nombre en una tarea «ajena» es la señal de que sos vos desde otro runner, y sin decirlo se
|
|
224
224
|
// lee como que alguien te ganó la tarea.
|
|
225
|
-
const
|
|
225
|
+
const ownerLabel = (one) => (one.owner === report.owner ? `${one.owner} — vos, desde otro runner` : one.owner)
|
|
226
226
|
// Que el reclamo sea tuyo desde otro id ya se decía; que además haya un **plan escrito** bajo ese id, no.
|
|
227
227
|
// Esa es la mitad que cuesta la sesión: los pasos ya hechos están en un archivo que nadie nombra, y el id
|
|
228
228
|
// que lo recupera es justo el que esta sesión no supo deducir. Los dos datos están en el reclamo.
|
|
229
229
|
const tomadas = () => {
|
|
230
230
|
for (const one of report.taken) {
|
|
231
|
-
console.log(`TAKEN ${one.slug} (${
|
|
231
|
+
console.log(`TAKEN ${one.slug} (${ownerLabel(one)})`)
|
|
232
232
|
if (one.owner === report.owner && one.wip) {
|
|
233
233
|
console.log(`PLAN ${one.slug}: su plan está en wip/${one.wip} — retomalo con `
|
|
234
234
|
+ `\`export CAUCE_RUNNER=${one.runner}\``)
|
|
@@ -26,7 +26,7 @@ function adviceFor(changed) {
|
|
|
26
26
|
const advice = []
|
|
27
27
|
if (ruleFiles.length) {
|
|
28
28
|
advice.push(
|
|
29
|
-
'Las
|
|
29
|
+
'Las reglas y decisiones bajo system/ son del toolkit. Para cambiar una, escribí la tuya al\n'
|
|
30
30
|
+ 'lado con el mismo ID: el proyecto manda y `check` lo reporta como override explícito.',
|
|
31
31
|
)
|
|
32
32
|
}
|
|
@@ -47,11 +47,11 @@ function adviceFor(changed) {
|
|
|
47
47
|
// El consejo de arriba manda mudar lo propio a donde sí es del proyecto, y para estos cuatro no hay
|
|
48
48
|
// adónde: no existe un PROTOCOL de la empresa que le gane al del toolkit como sí lo hay en `rules/`.
|
|
49
49
|
// Sin decirlo, quien adoptó Cauce sobre su propio proceso lee un consejo que no puede seguir.
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
50
|
+
const NO_COUNTERPART = ['planning/PROTOCOL.md', 'planning/METHODOLOGY.md', 'planning/FLOW.md', 'Makefile']
|
|
51
|
+
const ownDocs = docs.filter((file) => NO_COUNTERPART.includes(file))
|
|
52
|
+
if (ownDocs.length) {
|
|
53
53
|
advice.push(
|
|
54
|
-
`${
|
|
54
|
+
`${ownDocs.join(', ')} no tienen contraparte propia adónde mudarse: son del toolkit y no hay\n`
|
|
55
55
|
+ 'una versión del proyecto que le gane. Quedan congelados con tu versión y el resto se actualiza\n'
|
|
56
56
|
+ 'igual. Adoptar el del toolkit es trabajo propio —comparar los dos procesos y decidir—, no un flag.',
|
|
57
57
|
)
|
|
@@ -74,18 +74,18 @@ function adviceFor(changed) {
|
|
|
74
74
|
// entero en vez de recalcular nada: lo que se informa es exactamente lo que ocurrió.
|
|
75
75
|
function reportUpgrade({
|
|
76
76
|
root, from, to, system, retired, added, overrides, pinned, droppedBlocks,
|
|
77
|
-
|
|
77
|
+
discarded, keptFiles, pending,
|
|
78
78
|
}) {
|
|
79
79
|
console.log(`✓ Cauce ${from || '(previa)'} → ${to}`)
|
|
80
80
|
// Descartar con --force es legítimo; hacerlo sin dejar rastro no. Queda en la salida del comando,
|
|
81
81
|
// que es la evidencia que el protocolo pide para cualquier cambio. Y lo que se conservó no se vuelve
|
|
82
82
|
// a enumerar acá: ya salió con su consejo antes de escribir nada, y repetirlo con el glifo del
|
|
83
83
|
// descarte es lo que volvía ilegible el bloque.
|
|
84
|
-
for (const file of
|
|
85
|
-
if (
|
|
84
|
+
for (const file of discarded) console.log(`− descartado tu cambio en ${file}`)
|
|
85
|
+
if (keptFiles.length) console.log(`= ${keptFiles.length} archivo(s) conservados por tu edición`)
|
|
86
86
|
for (const relative of retired) console.log(`− retirado ${relative}: Cauce ya no lo distribuye`)
|
|
87
87
|
// Sin glifo de acción porque no hubo ninguna: la ruta sigue ahí y el contenido también.
|
|
88
|
-
for (const { relative, files } of
|
|
88
|
+
for (const { relative, files } of pending) {
|
|
89
89
|
console.log(` ${relative}: ${files.length} archivo(s) que Cauce no entregó; la ruta se retiró y `
|
|
90
90
|
+ 'el contenido queda. Movelo adonde lo quieras y borrala, o repetí con --force.')
|
|
91
91
|
}
|
|
@@ -103,7 +103,7 @@ function reportUpgrade({
|
|
|
103
103
|
}
|
|
104
104
|
// Sólo cuando es cierto, y ahora lo decide lo que pasó y no una condición: se dice justo cuando no
|
|
105
105
|
// se descartó nada, que incluye la corrida que conservó veinte archivos.
|
|
106
|
-
if (!
|
|
106
|
+
if (!discarded.length) console.log(' planning, organization y todo lo propio quedaron intactos')
|
|
107
107
|
// No se borra: sin la dependencia declarada, quitarle `.ops/` la dejaría sin motor. Se avisa y
|
|
108
108
|
// decide una persona.
|
|
109
109
|
if (fs.existsSync(path.join(root, '.ops', 'engine'))) {
|
package/engine/cli/validate.js
CHANGED
|
@@ -137,19 +137,19 @@ function check(dir, cli) {
|
|
|
137
137
|
// Lo que `upgrade` conserva por estar editado deja de recibir mejoras, y eso es una deuda que no
|
|
138
138
|
// avisa sola: la instancia queda con medio molde viejo y todo se ve normal. Sale acá para que se vea
|
|
139
139
|
// en cada corrida y no sólo el día que alguien actualiza.
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
warnings.push(`${
|
|
140
|
+
const frozen = O.localChanges(path.resolve(root, '..'))
|
|
141
|
+
if (frozen.length) {
|
|
142
|
+
warnings.push(`${frozen.length} archivo(s) del molde congelados por edición local; `
|
|
143
143
|
+ '`upgrade` los conserva y no les trae mejoras')
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Y lo que `upgrade` no retiró porque no pudo demostrar que fuera suyo: queda ahí, sin colgar de
|
|
147
147
|
// ningún mecanismo, hasta que alguien lo mueva o lo borre. Se cuenta por lo mismo que los congelados
|
|
148
148
|
// — un resto que no se ve se vuelve permanente.
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
warnings.push(`${
|
|
152
|
-
+ `(${
|
|
149
|
+
const leftovers = O.RETIRED_SHARED.filter((relative) => fs.existsSync(path.join(root, '..', relative)))
|
|
150
|
+
if (leftovers.length) {
|
|
151
|
+
warnings.push(`${leftovers.length} ruta(s) retiradas siguen en disco con contenido tuyo `
|
|
152
|
+
+ `(${leftovers.join(', ')}); Cauce ya no las distribuye ni las toca`)
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
const integration = I.validate(path.resolve(root, '..'))
|
|
@@ -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
|
@@ -299,7 +299,7 @@ const RETIRED = [
|
|
|
299
299
|
//
|
|
300
300
|
// El manifiesto no las puede desempatar: `trackedPaths()` nunca las registró, así que para el toolkit
|
|
301
301
|
// todo su contenido es igual de ajeno. Sin poder demostrar que lo entregó, no lo borra.
|
|
302
|
-
const
|
|
302
|
+
const RETIRED_SHARED = ['automatization/runners', 'automatization/workflows']
|
|
303
303
|
|
|
304
304
|
function retiredWithLearning(root) {
|
|
305
305
|
const found = []
|
|
@@ -407,7 +407,7 @@ module.exports = {
|
|
|
407
407
|
deliveredFiles,
|
|
408
408
|
effectiveRules,
|
|
409
409
|
shippedFiles,
|
|
410
|
-
|
|
410
|
+
RETIRED_SHARED,
|
|
411
411
|
TEMPLATE_OWN,
|
|
412
412
|
TEMPLATE_PREFIXES,
|
|
413
413
|
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
|