@ingeniomaps/cauce 0.80.0 → 0.82.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 +177 -0
- package/automatization/hooks/README.md +41 -11
- package/automatization/hooks/guard-chat.sh +5 -0
- package/automatization/hooks/guard-secrets-shell.sh +3 -0
- package/automatization/runners/antigravity/rules/cauce.md +7 -2
- package/automatization/runners/claude/CLAUDE.md +1 -4
- package/automatization/runners/claude/README.md +5 -4
- package/automatization/runners/claude/manifest.json +26 -1
- package/automatization/runners/claude/settings.json +8 -24
- package/automatization/runners/codex/AGENTS.md +7 -3
- package/automatization/runners/codex/README.md +4 -0
- package/automatization/runners/codex/hooks.json +7 -0
- package/automatization/runners/gemini/GEMINI.md +1 -4
- package/automatization/runners/gemini/README.md +5 -2
- package/automatization/runners/gemini/settings.json +11 -1
- package/automatization/shared/inbox.js +28 -0
- package/automatization/workflows/agent-eval.js +1 -1
- package/automatization/workflows/autobuild.js +52 -18
- package/automatization/workflows/flow.js +33 -8
- package/automatization/workflows/onboard.js +25 -3
- package/engine/automation/index.js +28 -5
- package/engine/automation/rules.js +122 -0
- package/engine/automation/runners.js +3 -1
- package/engine/cli/instance.js +35 -6
- package/engine/cli/planning.js +16 -2
- package/engine/config/validate.js +53 -3
- package/engine/core/onboarding.js +37 -7
- package/engine/core/ownership.js +64 -1
- package/engine/core/scan.js +23 -9
- package/engine/hooks/approval.js +40 -9
- package/engine/hooks/chat.js +171 -0
- package/engine/hooks/files.js +28 -16
- package/engine/hooks/input.js +8 -12
- package/engine/hooks/push.js +147 -0
- package/engine/hooks/run.js +23 -3
- package/engine/hooks/secrets-shell.js +62 -0
- package/engine/hooks/self-approval.js +31 -0
- package/engine/hooks/shell.js +37 -31
- package/engine/integrations/registry.js +13 -1
- package/engine/planning/inbox.js +36 -0
- package/engine/planning/parser.js +22 -9
- package/engine/planning/recurring.js +13 -2
- package/engine/schemas/ops-config.schema.json +21 -0
- package/package.json +1 -1
- package/template/AGENTS.md +23 -7
- package/template/planning/INBOX.md +2 -1
- package/template/planning/RECURRING.md +6 -5
- package/template/planning/rules/system/commits.md +3 -1
package/engine/cli/instance.js
CHANGED
|
@@ -13,6 +13,7 @@ const M = require('../core/manifest')
|
|
|
13
13
|
const OB = require('../core/onboarding')
|
|
14
14
|
const P = require('../planning/parser')
|
|
15
15
|
const ST = require('../planning/state')
|
|
16
|
+
const RC = require('../planning/recurring')
|
|
16
17
|
const A = require('../automation')
|
|
17
18
|
const { fail } = require('./io')
|
|
18
19
|
const { declareEngine, pinEngine, undeclareEngine } = require('./dependency')
|
|
@@ -97,6 +98,7 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
|
|
|
97
98
|
'{{PROJECT_NAME}}': name,
|
|
98
99
|
'{{MODE}}': mode,
|
|
99
100
|
'{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
|
|
101
|
+
...RC.sinceValues(new Date().toISOString().slice(0, 10)),
|
|
100
102
|
}, force, providerNames(), quiet)
|
|
101
103
|
// No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
|
|
102
104
|
// el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando lo que no aplica dejaba
|
|
@@ -113,8 +115,9 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
|
|
|
113
115
|
declareEngine(path.join(root, 'package.json'), version)
|
|
114
116
|
let deliveredPaths = {}
|
|
115
117
|
for (const relative of O.trackedPaths()) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
if (fs.existsSync(path.join(root, relative))) {
|
|
119
|
+
deliveredPaths = M.record(root, relative, O.deliveredFiles(root, relative), deliveredPaths)
|
|
120
|
+
}
|
|
118
121
|
}
|
|
119
122
|
deliveredPaths = M.recordPaths(root, O.SYSTEM_FILES, deliveredPaths)
|
|
120
123
|
// Adoptar Cauce en un repositorio con contenido es `init --force`, y lo que se conserva ahí lo
|
|
@@ -277,12 +280,14 @@ function upgrade(dir, cli) {
|
|
|
277
280
|
const to = require(path.join(PROJECT_ROOT, 'package.json')).version
|
|
278
281
|
const system = O.systemPaths(root)
|
|
279
282
|
const changed = O.localChanges(root)
|
|
283
|
+
const colliding = O.collisions(root)
|
|
280
284
|
const overrides = O.overrides(root)
|
|
281
285
|
|
|
282
286
|
// El código de salida lo aplica acá y no adentro: `--check` mira y cuenta, y quien decide qué hacer
|
|
283
287
|
// con lo que vio es el comando. Escondido en la función que informa, el corte del flujo no se ve.
|
|
284
288
|
if (dry) {
|
|
285
|
-
const
|
|
289
|
+
for (const file of colliding) console.log(` choca con uno tuyo: ${file}`)
|
|
290
|
+
const code = previewUpgrade({ from, to, changed }) || (colliding.length ? 1 : 0)
|
|
286
291
|
if (code) process.exit(code)
|
|
287
292
|
return
|
|
288
293
|
}
|
|
@@ -311,6 +316,16 @@ function upgrade(dir, cli) {
|
|
|
311
316
|
console.log(`${adviceFor([...conservados])}\n`)
|
|
312
317
|
console.log('Para tomar la versión nueva y descartar la tuya, repetí con --force.\n')
|
|
313
318
|
}
|
|
319
|
+
// Un archivo propio que se llama como uno que el paquete empieza a traer (caso 110): se conserva y se
|
|
320
|
+
// dice, igual que una edición local, y `--force` lo reemplaza diciéndolo.
|
|
321
|
+
const choques = new Set(force ? [] : colliding)
|
|
322
|
+
for (const file of choques) {
|
|
323
|
+
console.log(`= conservado ${file}: ya existía y Cauce no lo entregó, así que el del paquete no se `
|
|
324
|
+
+ 'instaló. Renombrá el tuyo y repetí, o repetí con --force para reemplazarlo.')
|
|
325
|
+
}
|
|
326
|
+
if (force) {
|
|
327
|
+
for (const file of colliding) console.log(`− reemplazado ${file}, que era tuyo y se llamaba como uno del paquete`)
|
|
328
|
+
}
|
|
314
329
|
|
|
315
330
|
// Lo que una versión agrega y es del proyecto: se crea si falta y nunca se pisa. `systemPaths` no lo
|
|
316
331
|
// incluye a propósito —lo reemplazaría en cada actualización, que es lo que un archivo del proyecto
|
|
@@ -330,6 +345,7 @@ function upgrade(dir, cli) {
|
|
|
330
345
|
'{{PROJECT_NAME}}': config.project || path.basename(root),
|
|
331
346
|
'{{MODE}}': O.mode(root),
|
|
332
347
|
'{{WORKSPACE_PATH}}': O.mode(root) === 'embedded' ? '.' : '..',
|
|
348
|
+
...RC.sinceValues(new Date().toISOString().slice(0, 10)),
|
|
333
349
|
})) content = content.replaceAll(key, value)
|
|
334
350
|
F.atomicWrite(target, content)
|
|
335
351
|
added.push(relative)
|
|
@@ -350,7 +366,10 @@ function upgrade(dir, cli) {
|
|
|
350
366
|
}
|
|
351
367
|
}
|
|
352
368
|
|
|
353
|
-
const conservar = (file) =>
|
|
369
|
+
const conservar = (file) => {
|
|
370
|
+
const relative = path.relative(root, file).replace(/\\/g, '/')
|
|
371
|
+
return conservados.has(relative) || choques.has(relative)
|
|
372
|
+
}
|
|
354
373
|
for (const relative of [...system, ...O.RUNTIME_PATHS]) {
|
|
355
374
|
const origin = path.join(PROJECT_ROOT, O.sourceOf(relative))
|
|
356
375
|
if (!fs.existsSync(origin)) continue
|
|
@@ -396,11 +415,21 @@ function upgrade(dir, cli) {
|
|
|
396
415
|
// la puerta de atrás, y no se ve mirando el archivo — se ve dos upgrades después.
|
|
397
416
|
const entregado = { ...record }
|
|
398
417
|
for (const relative of O.trackedPaths()) {
|
|
399
|
-
|
|
400
|
-
|
|
418
|
+
if (fs.existsSync(path.join(root, relative))) {
|
|
419
|
+
record = M.record(root, relative, O.deliveredFiles(root, relative), record)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Migración: una versión anterior pudo haber registrado un guard propio —qué cuenta, en `deliveredFiles`—.
|
|
423
|
+
// Se olvida acá. Y un choque que se conservó no se registra, para que la corrida siguiente lo vuelva a ver.
|
|
424
|
+
for (const relative of O.RUNTIME_PATHS) {
|
|
425
|
+
const shipped = O.shippedFiles(relative)
|
|
426
|
+
for (const key of Object.keys(record)) {
|
|
427
|
+
if (key.startsWith(`${relative}/`) && !shipped.has(key.slice(relative.length + 1))) delete record[key]
|
|
428
|
+
}
|
|
401
429
|
}
|
|
402
430
|
record = M.recordPaths(root, O.SYSTEM_FILES, record)
|
|
403
431
|
for (const file of conservados) if (entregado[file]) record[file] = entregado[file]
|
|
432
|
+
for (const file of choques) delete record[file]
|
|
404
433
|
// El registro de forks se poda igual que el de archivos: un cargo devuelto al catálogo deja su
|
|
405
434
|
// entrada, y una entrada sin copia sólo puede producir avisos sobre algo que no está.
|
|
406
435
|
const kept = Object.fromEntries(Object.entries(M.readForks(root)).filter(
|
package/engine/cli/planning.js
CHANGED
|
@@ -11,6 +11,7 @@ const PC = require('../planning/contracts')
|
|
|
11
11
|
const SR = require('../planning/structure')
|
|
12
12
|
const SZ = require('../planning/sizing')
|
|
13
13
|
const RC = require('../planning/recurring')
|
|
14
|
+
const IB = require('../planning/inbox')
|
|
14
15
|
const CL = require('../planning/claims')
|
|
15
16
|
const R = require('../core/repos')
|
|
16
17
|
const ST = require('../planning/state')
|
|
@@ -23,6 +24,7 @@ const OB = require('../core/onboarding')
|
|
|
23
24
|
const C = require('../config/validate')
|
|
24
25
|
const CP = require('../config/paths')
|
|
25
26
|
const AG = require('../agents/catalog')
|
|
27
|
+
const RL = require('../automation/rules')
|
|
26
28
|
const { fail, planningRoot } = require('./io')
|
|
27
29
|
|
|
28
30
|
// Qué dimensiones enumera el molde de `organization/` y cuáles dejaron de estar. Un agente que reescribe
|
|
@@ -97,12 +99,14 @@ function check(dir, cli) {
|
|
|
97
99
|
for (const file of required) if (!fs.existsSync(path.join(root, file))) errors.push(`falta ${file}`)
|
|
98
100
|
|
|
99
101
|
const configPath = path.join(root, '..', 'ops.config.json')
|
|
102
|
+
let config = null
|
|
100
103
|
if (fs.existsSync(configPath)) {
|
|
101
104
|
try {
|
|
102
105
|
const raw = fs.readFileSync(configPath, 'utf8')
|
|
103
|
-
|
|
106
|
+
config = JSON.parse(raw)
|
|
104
107
|
if (!raw.includes('{{')) {
|
|
105
108
|
errors.push(...C.validateOpsConfig(config))
|
|
109
|
+
warnings.push(...C.configWarnings(config))
|
|
106
110
|
if (Array.isArray(config.workspaceRoots)) {
|
|
107
111
|
for (const workspace of config.workspaceRoots) {
|
|
108
112
|
if (workspace && workspace.name && workspace.path
|
|
@@ -169,6 +173,7 @@ function check(dir, cli) {
|
|
|
169
173
|
const recurring = RC.read(root)
|
|
170
174
|
errors.push(...RC.validate(recurring))
|
|
171
175
|
warnings.push(...RC.warnings(RC.status({ ...recurring, done, today: TODAY() })))
|
|
176
|
+
warnings.push(...IB.warnings(root, done, config))
|
|
172
177
|
warnings.push(...AD.sealWarnings(root))
|
|
173
178
|
// Una aprobación vale para el conjunto que nombra, así que olvidada sigue autorizando
|
|
174
179
|
// esas mismas rutas la próxima vez que alguien las stagee. No caduca sola: lo que la cierra es que se
|
|
@@ -213,6 +218,8 @@ function check(dir, cli) {
|
|
|
213
218
|
warnings.push(`${override.collection}/${override.project} sobrescribe ${override.system} `
|
|
214
219
|
+ `(override explícito)${retired.length ? `; deja de regir ${retired.join(', ')}` : ''}`)
|
|
215
220
|
}
|
|
221
|
+
// Y lo que instaló cada runner, contra esas mismas reglas (caso 099).
|
|
222
|
+
warnings.push(...RL.staleLines(path.resolve(root, '..')))
|
|
216
223
|
// Misma regla para los cargos, que es donde más caro sale: un fork se hace una vez y se olvida.
|
|
217
224
|
const FK = require('../agents/fork')
|
|
218
225
|
for (const entry of FK.drift(path.resolve(root, '..'))) warnings.push(FK.driftLine(entry))
|
|
@@ -380,8 +387,14 @@ function context(dir, cli) {
|
|
|
380
387
|
// mismo criterio. Por qué no la encola una máquina está en la fase Pick de `autobuild`.
|
|
381
388
|
nextEpic: (!task && [...state.epics].filter((one) => one.status === 'open')
|
|
382
389
|
.sort((a, b) => String(a.num).localeCompare(String(b.num)))[0]) || null,
|
|
390
|
+
// Sólo en el JSON: lo leen los recorridos que escriben en el INBOX, para no repetir un nombre.
|
|
391
|
+
inbox: P.inboxHeads(root),
|
|
392
|
+
// Las reglas que rigen, con los overrides resueltos. Van acá porque `autobuild` ya lee este comando y
|
|
393
|
+
// tiene prohibido abrir otros archivos para completar su contrato (caso 105).
|
|
394
|
+
rules: O.effectiveRules(path.resolve(root, '..')),
|
|
383
395
|
}
|
|
384
396
|
if (cli.has('--json')) return console.log(JSON.stringify(report))
|
|
397
|
+
const reglas = () => console.log(`RULES ${report.rules.join(', ') || '(ninguna)'}`)
|
|
385
398
|
|
|
386
399
|
if (report.blocked === 'awaiting-review') {
|
|
387
400
|
const first = P.read(gate).split('\n').find((line) => line.trim() && !line.startsWith('#')) || ''
|
|
@@ -419,7 +432,7 @@ function context(dir, cli) {
|
|
|
419
432
|
espera()
|
|
420
433
|
for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
|
|
421
434
|
due()
|
|
422
|
-
return
|
|
435
|
+
return reglas()
|
|
423
436
|
}
|
|
424
437
|
console.log(`TASK ${report.task.slug}${report.task.tier ? ` [${report.task.tier}]` : ''}` +
|
|
425
438
|
`${report.task.service ? ` service: ${report.task.service}` : ''}` +
|
|
@@ -448,6 +461,7 @@ function context(dir, cli) {
|
|
|
448
461
|
if (report.blockedTasks.length) console.log(`SKIP ${report.blockedTasks.join(', ')} (acción humana abierta)`)
|
|
449
462
|
for (const action of report.humanActions) console.log(`HUMAN ${action.task}: ${action.action}`)
|
|
450
463
|
due()
|
|
464
|
+
reglas()
|
|
451
465
|
}
|
|
452
466
|
|
|
453
467
|
// Qué trabajo recurrente vence, y la línea con la que se promueve. Emite esa línea y no la escribe:
|
|
@@ -20,7 +20,7 @@ function validateOpsConfig(config) {
|
|
|
20
20
|
// `cauceVersion` la escribe el toolkit, no la persona: registra de qué versión salió la instancia.
|
|
21
21
|
const allowed = new Set([
|
|
22
22
|
'$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'writableOutsideRoots', 'runner',
|
|
23
|
-
'migrations',
|
|
23
|
+
'migrations', 'inbox',
|
|
24
24
|
])
|
|
25
25
|
for (const key of Object.keys(config)) {
|
|
26
26
|
if (RETIRED[key]) errors.push(`ops.config.json: ${key} ya no se usa: ${RETIRED[key]}`)
|
|
@@ -34,9 +34,26 @@ function validateOpsConfig(config) {
|
|
|
34
34
|
validateWritable(config.writableOutsideRoots, errors)
|
|
35
35
|
validateRunner(config.runner, errors)
|
|
36
36
|
validateMigrations(config.migrations, errors)
|
|
37
|
+
validateInbox(config.inbox, errors)
|
|
37
38
|
return errors
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// El umbral del aviso de tamaño del INBOX. Sólo un entero positivo: un cero o un texto se leerían como
|
|
42
|
+
// «avisá siempre» o «nunca», y ninguno de los dos es algo que alguien escriba a propósito.
|
|
43
|
+
function validateInbox(inbox, errors) {
|
|
44
|
+
if (inbox === undefined) return
|
|
45
|
+
if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox)) {
|
|
46
|
+
errors.push('ops.config.json: inbox debe ser un objeto')
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
for (const key of Object.keys(inbox)) {
|
|
50
|
+
if (key !== 'warnLines') errors.push(`ops.config.json: inbox.${key} no está permitido`)
|
|
51
|
+
}
|
|
52
|
+
if ('warnLines' in inbox && !(Number.isInteger(inbox.warnLines) && inbox.warnLines > 0)) {
|
|
53
|
+
errors.push('ops.config.json: inbox.warnLines debe ser un entero mayor que cero, o no estar')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
// Qué cuenta como migración para el guard. Sin declararlo, sólo `.sql` — y ése es el default que hace
|
|
41
58
|
// falta decir, porque un proyecto TypeORM, Prisma, Django o Rails tiene el guard cableado y en verde sin
|
|
42
59
|
// que mire una sola migración (caso 077).
|
|
@@ -68,11 +85,36 @@ function validateMigrations(migrations, errors) {
|
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
// Lo que la configuración tiene de dudoso y no de inválido. Va aparte de `validateOpsConfig` porque lo que
|
|
89
|
+
// aquélla devuelve son errores y la leen también los guards: una advertencia ahí sería un rechazo.
|
|
90
|
+
//
|
|
91
|
+
// Hoy sólo el nombre repetido: dos raíces con el mismo `name` dejan dos servicios indistinguibles en
|
|
92
|
+
// `check`, `onboard` y `scan`, y una credencial que no se puede atribuir a ninguno (caso 113). Avisa y no
|
|
93
|
+
// falla porque nadie eligió su `name` pensando que fuera único, así que rechazarlo le rompería la puerta a
|
|
94
|
+
// una instancia que hoy funciona, y lo que está en juego es un nombre ambiguo, no algo que se pierda.
|
|
95
|
+
function configWarnings(config) {
|
|
96
|
+
const roots = Array.isArray(config && config.workspaceRoots) ? config.workspaceRoots : []
|
|
97
|
+
const found = []
|
|
98
|
+
const named = new Map()
|
|
99
|
+
for (const workspace of roots) {
|
|
100
|
+
const name = workspace && typeof workspace.name === 'string' ? workspace.name.trim() : ''
|
|
101
|
+
if (!name) continue
|
|
102
|
+
if (named.has(name)) {
|
|
103
|
+
found.push(`ops.config.json: ${name} nombra dos raíces (${named.get(name)} y ${workspace.path}): `
|
|
104
|
+
+ 'sus servicios salen con el mismo nombre. Renombrá una')
|
|
105
|
+
} else named.set(name, workspace.path)
|
|
106
|
+
}
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
109
|
+
|
|
71
110
|
function validateWorkspaces(workspaces, errors) {
|
|
72
111
|
if (!Array.isArray(workspaces) || !workspaces.length) {
|
|
73
112
|
errors.push('ops.config.json: workspaceRoots debe contener al menos una raíz')
|
|
74
113
|
return
|
|
75
114
|
}
|
|
115
|
+
// El nombre repetido no se rechaza acá: lo avisa `check`, que es quien puede hacerlo sin romperle la
|
|
116
|
+
// puerta a una instancia que hoy funciona (caso 113). Este validador lo leen también los guards, y lo
|
|
117
|
+
// que devuelve son errores: meterlo acá era decidir que la configuración es inválida.
|
|
76
118
|
for (const [index, workspace] of workspaces.entries()) {
|
|
77
119
|
if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) {
|
|
78
120
|
errors.push(`ops.config.json: workspaceRoots[${index}] debe ser un objeto`)
|
|
@@ -119,10 +161,18 @@ function validateRunner(runner, errors) {
|
|
|
119
161
|
return
|
|
120
162
|
}
|
|
121
163
|
const booleans = ['humanCheckpointBetweenMilestones', 'commitPerTask', 'allowPush']
|
|
122
|
-
const allowed = new Set(['maxTaskHours', ...booleans])
|
|
164
|
+
const allowed = new Set(['maxTaskHours', 'pushToLiveBranches', ...booleans])
|
|
123
165
|
for (const key of Object.keys(runner)) {
|
|
124
166
|
if (!allowed.has(key)) errors.push(`ops.config.json: runner.${key} no está permitido`)
|
|
125
167
|
}
|
|
168
|
+
// Un patrón haría de un permiso por rama un permiso por familia, que es lo que el campo vino a evitar
|
|
169
|
+
// (caso 108): el guard compara el nombre tal cual, y `release/*` no publicaría en ninguna.
|
|
170
|
+
const live = runner.pushToLiveBranches
|
|
171
|
+
if (live !== undefined && (!Array.isArray(live)
|
|
172
|
+
|| live.some((branch) => typeof branch !== 'string' || !/^[^\s*?[]+$/.test(branch)))) {
|
|
173
|
+
errors.push('ops.config.json: runner.pushToLiveBranches debe ser una lista de nombres de rama exactos, '
|
|
174
|
+
+ 'sin espacios ni patrones')
|
|
175
|
+
}
|
|
126
176
|
if (typeof runner.maxTaskHours !== 'number' || runner.maxTaskHours <= 0) {
|
|
127
177
|
errors.push('ops.config.json: runner.maxTaskHours debe ser mayor que cero')
|
|
128
178
|
}
|
|
@@ -133,4 +183,4 @@ function validateRunner(runner, errors) {
|
|
|
133
183
|
}
|
|
134
184
|
}
|
|
135
185
|
|
|
136
|
-
module.exports = { validateOpsConfig }
|
|
186
|
+
module.exports = { configWarnings, validateOpsConfig }
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const fs = require('node:fs')
|
|
10
10
|
const path = require('node:path')
|
|
11
11
|
const { inventory } = require('./scan')
|
|
12
|
+
const { sensitiveKey } = require('../integrations/registry')
|
|
12
13
|
|
|
13
14
|
// La raíz del paquete: el molde contra el que se compara lo que una instancia escribió.
|
|
14
15
|
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
|
|
@@ -91,11 +92,23 @@ function missingSections(root) {
|
|
|
91
92
|
return warnings
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// Una aparición cuenta sólo si lo que la rodea no puede ser parte de otro nombre de variable: buscada
|
|
96
|
+
// como subcadena, API_SECRET_ROTATION daba por cargada a API_SECRET (caso 111). El escaneo sólo deja
|
|
97
|
+
// pasar identificadores, así que el nombre entra a la expresión sin nada que escapar.
|
|
98
|
+
function named(text, name) {
|
|
99
|
+
return new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(text)
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
// Credenciales que el proyecto declara y que no aparecen en ningún contrato. El arranque tiene que
|
|
95
103
|
// dejar una fila por cada una —quién la carga y dónde— y en la práctica cubre las que se hablaron en la
|
|
96
104
|
// conversación: las que sólo estaban en el inventario se pierden, y con ellas el servicio externo que
|
|
97
105
|
// hay detrás. Una variable sin dueño no rompe nada hoy; rompe el día que alguien tiene que desplegar.
|
|
98
106
|
//
|
|
107
|
+
// Credencial es lo que tiene nombre de secreto según `sensitiveKey`, la misma regla con la que la
|
|
108
|
+
// declaración de secretos y la configuración de una integración rechazan un valor. Contando cualquier
|
|
109
|
+
// variable, el aviso listaba ciento ocho nombres de build y la única credencial quedaba en «y 1 más»
|
|
110
|
+
// (caso 102). El precio es el secreto con nombre de configuración, y el aviso lo dice.
|
|
111
|
+
//
|
|
99
112
|
// Sólo cuando la instancia ya tiene contexto escrito: antes del arranque no hay dónde estuvieran.
|
|
100
113
|
function orphanCredentials(root) {
|
|
101
114
|
if (guide(root).fresh) return []
|
|
@@ -108,16 +121,33 @@ function orphanCredentials(root) {
|
|
|
108
121
|
.join('\n')
|
|
109
122
|
if (!contracts) return []
|
|
110
123
|
const orphans = []
|
|
124
|
+
const cut = []
|
|
111
125
|
for (const service of inventory(root)) {
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
const env = service.env || {}
|
|
127
|
+
for (const name of env.names || []) {
|
|
128
|
+
if (sensitiveKey(name) && !named(contracts, name)) orphans.push({ name, service: service.path })
|
|
114
129
|
}
|
|
130
|
+
if (env.truncated) cut.push(`${service.path} (${env.truncated} de ${env.names.length + env.truncated})`)
|
|
115
131
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
const warnings = []
|
|
133
|
+
if (orphans.length) {
|
|
134
|
+
const listed = orphans.map((one) => `${one.name} (${one.service})`)
|
|
135
|
+
const summary = listed.length > 4
|
|
136
|
+
? `${listed.slice(0, 4).join(', ')} y ${listed.length - 4} más`
|
|
137
|
+
: listed.join(', ')
|
|
138
|
+
const services = [...new Set(orphans.map((one) => one.service))].join(', ')
|
|
139
|
+
warnings.push(`credenciales por nombre sin dueño (${orphans.length}, en ${services}): ${summary} — no `
|
|
140
|
+
+ 'aparecen en el mapa ni en HUMAN_ACTIONS: nadie las carga. El dueño se escribe en '
|
|
141
|
+
+ 'organization/workspace.md o en una fila de planning/HUMAN_ACTIONS.md; el criterio es el nombre, así '
|
|
142
|
+
+ 'que una credencial con nombre de configuración no aparece acá')
|
|
143
|
+
}
|
|
144
|
+
// El escaneo corta cada ejemplo en un tope, y lo que quedó afuera no se miró: con el filtro, puede ser
|
|
145
|
+
// justo la credencial.
|
|
146
|
+
if (cut.length) {
|
|
147
|
+
warnings.push(`sin revisar por credenciales sin dueño, pasado el tope de variables por servicio: `
|
|
148
|
+
+ `${cut.join(', ')} — lo que quedó afuera puede incluir una credencial que nadie carga`)
|
|
149
|
+
}
|
|
150
|
+
return warnings
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
module.exports = {
|
package/engine/core/ownership.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
const fs = require('node:fs')
|
|
9
9
|
const path = require('node:path')
|
|
10
10
|
|
|
11
|
+
// El paquete que corre: contra él se decide qué del runtime es una entrega de Cauce (casos 100 y 110).
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
|
|
13
|
+
|
|
11
14
|
// Archivos de los que el toolkit es único autor. Un proyecto que necesite cambiarlos no los
|
|
12
15
|
// edita: agrega una regla propia junto a las de `system/`, que sí sobrevive al upgrade.
|
|
13
16
|
const SYSTEM_FILES = [
|
|
@@ -176,6 +179,27 @@ function overrides(root) {
|
|
|
176
179
|
return found
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
// Las reglas que rigen una instancia, relativas a su raíz: cada `planning/rules/*.md` del proyecto y cada una de
|
|
183
|
+
// `system/` que el proyecto no sobrescribió. `overrides()` ya sabía cuál reemplaza a cuál y sólo servía para
|
|
184
|
+
// avisar; esto es lo que se entrega a quien trabaja (casos 099 y 105). Sólo el primer nivel, igual que `check`.
|
|
185
|
+
function effectiveRules(root) {
|
|
186
|
+
const dir = path.join(root, 'planning', 'rules')
|
|
187
|
+
const markdown = (sub) => {
|
|
188
|
+
try {
|
|
189
|
+
return fs.readdirSync(path.join(dir, sub), { withFileTypes: true })
|
|
190
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md'
|
|
191
|
+
&& !entry.name.startsWith('.'))
|
|
192
|
+
.map((entry) => entry.name).sort()
|
|
193
|
+
} catch { return [] }
|
|
194
|
+
}
|
|
195
|
+
const replaced = new Set(overrides(root).filter((one) => one.collection === 'planning/rules')
|
|
196
|
+
.map((one) => one.system))
|
|
197
|
+
return [
|
|
198
|
+
...markdown('system').filter((name) => !replaced.has(name)).map((name) => `planning/rules/system/${name}`),
|
|
199
|
+
...markdown('').map((name) => `planning/rules/${name}`),
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
// Rutas que el toolkit dejó de materializar. Sin esto una instancia arrastra para siempre lo que
|
|
180
204
|
// alguna versión suya copió: `upgrade` agrega y reemplaza, pero nunca quitaba nada.
|
|
181
205
|
// Cada archivo propio del molde, con cómo llega a una instancia que **ya existe**. `upgrade` sólo
|
|
@@ -302,7 +326,7 @@ function localChanges(root) {
|
|
|
302
326
|
for (const target of trackedPaths()) {
|
|
303
327
|
const dir = path.join(root, target)
|
|
304
328
|
if (!fs.existsSync(dir)) continue
|
|
305
|
-
for (const file of manifest.edited(root, target,
|
|
329
|
+
for (const file of manifest.edited(root, target, deliveredFiles(root, target))) changed.push(`${target}/${file}`)
|
|
306
330
|
}
|
|
307
331
|
// Los archivos sueltos del sistema entran por la misma puerta. Quedaban afuera, así que `upgrade`
|
|
308
332
|
// los reemplazaba en silencio: un cargo escribió el índice de ADR que el propio README le pedía
|
|
@@ -311,8 +335,47 @@ function localChanges(root) {
|
|
|
311
335
|
return changed
|
|
312
336
|
}
|
|
313
337
|
|
|
338
|
+
// Lo que el paquete que corre trae en una ruta del runtime. El resto de esa carpeta es de la empresa —un
|
|
339
|
+
// guard propio—, y no es una entrega: registrado, pasaba a «editado» en cuanto la empresa lo tocaba, y
|
|
340
|
+
// `upgrade --check` salía con 1 por algo que nunca fue de Cauce (caso 100).
|
|
341
|
+
function shippedFiles(relative) {
|
|
342
|
+
return new Set(treeFiles(path.join(PACKAGE_ROOT, sourceOf(relative))))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Lo que de una ruta rastreada cuenta como entregado: todo, salvo en el runtime, donde sólo lo que el
|
|
346
|
+
// paquete trae. Lo usan el registro de `init` y de `upgrade` y la detección de ediciones, que tienen que
|
|
347
|
+
// contar lo mismo.
|
|
348
|
+
function deliveredFiles(root, relative) {
|
|
349
|
+
const files = treeFiles(path.join(root, relative))
|
|
350
|
+
return RUNTIME_PATHS.includes(relative) ? files.filter((file) => shippedFiles(relative).has(file)) : files
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Lo que el paquete empieza a traer con un nombre que la instancia ya usaba para algo suyo: un guard propio
|
|
354
|
+
// que se llama como uno nuevo del toolkit (caso 110). Sin huella en el registro no cuenta como edición, así
|
|
355
|
+
// que copiar encima lo borraba sin decirlo. Sólo si el registro ya conoce la ruta: en una instancia
|
|
356
|
+
// anterior al registro, «sin huella» también es «lo entregó una versión vieja».
|
|
357
|
+
function collisions(root) {
|
|
358
|
+
const manifest = require('./manifest')
|
|
359
|
+
const recorded = manifest.read(root)
|
|
360
|
+
const found = []
|
|
361
|
+
for (const relative of RUNTIME_PATHS) {
|
|
362
|
+
if (!Object.keys(recorded).some((key) => key.startsWith(`${relative}/`))) continue
|
|
363
|
+
for (const file of shippedFiles(relative)) {
|
|
364
|
+
const local = path.join(root, relative, file)
|
|
365
|
+
if (recorded[`${relative}/${file}`] || !fs.existsSync(local)) continue
|
|
366
|
+
const shipped = path.join(PACKAGE_ROOT, sourceOf(relative), file)
|
|
367
|
+
if (manifest.digest(local) !== manifest.digest(shipped)) found.push(`${relative}/${file}`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return found
|
|
371
|
+
}
|
|
372
|
+
|
|
314
373
|
module.exports = {
|
|
315
374
|
RETIRED,
|
|
375
|
+
collisions,
|
|
376
|
+
deliveredFiles,
|
|
377
|
+
effectiveRules,
|
|
378
|
+
shippedFiles,
|
|
316
379
|
RETIRED_COMPARTIDO,
|
|
317
380
|
TEMPLATE_OWN,
|
|
318
381
|
TEMPLATE_PREFIXES,
|
package/engine/core/scan.js
CHANGED
|
@@ -171,17 +171,28 @@ function scan(root, skip = '') {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Dónde puede mirar una instancia: exactamente las raíces que declara,
|
|
174
|
+
// Dónde puede mirar una instancia: exactamente las raíces que declara, con el nombre que les puso. Sale
|
|
175
175
|
// de `ops.config.json` en vez de suponerse —el sidecar declara `..`, el embebido `.`— para que acotar las
|
|
176
176
|
// raíces acote también el escaneo, y para que nadie termine recorriendo la carpeta de al lado.
|
|
177
|
-
|
|
177
|
+
//
|
|
178
|
+
// El nombre cae a la carpeta cuando la raíz no lo declara: `check` lo exige, pero `scan` y `onboard`
|
|
179
|
+
// corren igual sobre una configuración que nunca pasó por ahí, y ahí el prefijo saldría `undefined`.
|
|
180
|
+
function declaredRoots(root) {
|
|
181
|
+
const fallback = [{ name: path.basename(root), dir: root }]
|
|
178
182
|
try {
|
|
179
183
|
const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
|
|
180
|
-
const declared = (config.workspaceRoots || []).map((entry) =>
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
const declared = (config.workspaceRoots || []).map((entry) => {
|
|
185
|
+
const dir = path.resolve(root, entry.path || '.')
|
|
186
|
+
return { name: String(entry.name || '').trim() || path.basename(dir), dir }
|
|
187
|
+
})
|
|
188
|
+
return declared.length ? declared : fallback
|
|
189
|
+
} catch { return fallback }
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
// Sólo las rutas. Es lo que mira quien acota una escritura, y `onboard --json` las emite tal cual en
|
|
193
|
+
// `roots`: darles forma de objeto habría cambiado ese contrato para quien no necesita el nombre.
|
|
194
|
+
const workspaceRoots = (root) => declaredRoots(root).map((one) => one.dir)
|
|
195
|
+
|
|
185
196
|
// Qué hay en las raíces declaradas, antes de que nadie razone sobre ello. La raíz ops se saltea: no es
|
|
186
197
|
// un servicio del proyecto, y su `package.json` sólo declara el motor.
|
|
187
198
|
// Los candidatos de una raíz, con el proyecto que vive en ella misma primero: un monolito declara sus
|
|
@@ -197,12 +208,15 @@ function candidates(workspace, skip = '') {
|
|
|
197
208
|
// Con varias raíces, cada repositorio es la raíz de su propio escaneo y su candidato principal se llama
|
|
198
209
|
// `.`: tres servicios con el mismo nombre y nada que los distinga. El prefijo los vuelve nombrables, que
|
|
199
210
|
// es la única forma de que una credencial pueda atribuirse a un servicio en vez de quedar suelta.
|
|
211
|
+
//
|
|
212
|
+
// El prefijo es el `name` declarado y no la carpeta, que es lo que dejaba a `gouduet/keycloak` y
|
|
213
|
+
// `hypixo/keycloak` llamándose las dos `keycloak` (caso 113). Que no se repita lo exige el validador.
|
|
200
214
|
function inventory(root) {
|
|
201
|
-
const roots =
|
|
202
|
-
if (roots.length === 1) return candidates(roots[0], root)
|
|
203
|
-
return roots.flatMap((
|
|
215
|
+
const roots = declaredRoots(root)
|
|
216
|
+
if (roots.length === 1) return candidates(roots[0].dir, root)
|
|
217
|
+
return roots.flatMap(({ name, dir }) => candidates(dir, root).map((service) => ({
|
|
204
218
|
...service,
|
|
205
|
-
path: service.path === '.' ?
|
|
219
|
+
path: service.path === '.' ? name : `${name}/${service.path}`,
|
|
206
220
|
})))
|
|
207
221
|
}
|
|
208
222
|
|
package/engine/hooks/approval.js
CHANGED
|
@@ -20,14 +20,20 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Queda a la vista porque `check` avisa mientras exista. Sin eso, un archivo olvidado sigue autorizando
|
|
22
22
|
// esas mismas rutas la próxima vez que alguien las stagee, que es la puerta abierta que esto evitaba.
|
|
23
|
+
//
|
|
24
|
+
// El archivo es la vía de cuando no hay chat. Con una persona hablando, lo que ella pidió ya está
|
|
25
|
+
// aprobado —cómo se sabe, en `chat.js`—, y escribir el archivo deja de ser necesario (caso 098).
|
|
23
26
|
|
|
24
27
|
const path = require('node:path')
|
|
25
28
|
const fs = require('node:fs')
|
|
29
|
+
const { opsRoot, cwdOf } = require('./input')
|
|
30
|
+
const CHAT = require('./chat')
|
|
26
31
|
|
|
27
32
|
const APPROVAL = '.ops-approval'
|
|
28
33
|
|
|
29
34
|
// Una ruta por línea, `#` para lo demás. El archivo ausente y el vacío son lo mismo: no hay nada
|
|
30
|
-
// aprobado, que es el estado normal.
|
|
35
|
+
// aprobado, que es el estado normal. Un push se aprueba igual, con la línea `push <remoto> <rama>`
|
|
36
|
+
// tal cual y sin patrones: `feat/*` convertiría una aprobación puntual en un permiso (caso 103).
|
|
31
37
|
function read(root) {
|
|
32
38
|
let text = ''
|
|
33
39
|
try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
|
|
@@ -35,10 +41,23 @@ function read(root) {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
// Qué queda sin aprobar de lo que un guard está por bloquear. Se reporta sólo eso: mandar a revisar lo
|
|
38
|
-
// que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje.
|
|
39
|
-
|
|
44
|
+
// que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje. Cuenta también lo que la
|
|
45
|
+
// persona pidió en el chat.
|
|
46
|
+
function pending(root, files, input) {
|
|
40
47
|
const approved = new Set(root ? read(root) : [])
|
|
41
|
-
return files.filter((file) => !approved.has(file))
|
|
48
|
+
return CHAT.unauthorized(input, files.filter((file) => !approved.has(file)))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// El archivo que el guard va a leer, nombrado desde la carpeta en la que está la sesión. En sidecar la
|
|
52
|
+
// sesión se abre en el workspace y la instancia es una subcarpeta, así que `planning/` a secas nombraba
|
|
53
|
+
// otro directorio y pegar ahí no destrababa nada (caso 097).
|
|
54
|
+
function where(input) {
|
|
55
|
+
const root = opsRoot(input)
|
|
56
|
+
if (!root) return `planning/${APPROVAL}`
|
|
57
|
+
const file = path.join(root, 'planning', APPROVAL)
|
|
58
|
+
const session = process.env.CLAUDE_PROJECT_DIR || process.env.GEMINI_PROJECT_DIR || cwdOf(input)
|
|
59
|
+
const relative = path.relative(session, file)
|
|
60
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
// Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
|
|
@@ -46,9 +65,21 @@ function pending(root, files) {
|
|
|
46
65
|
// de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
|
|
47
66
|
// decirla, lo que quedaba a mano era la variable (caso 089). Nombra también la variable: sigue
|
|
48
67
|
// existiendo, y esconderla haría que quien la necesite la descubra sin saber su alcance.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
//
|
|
69
|
+
// Con una persona en el chat, además, deja anotado lo que se frenó —para eso llama a `hold`— y lo dice
|
|
70
|
+
// primero: contestar es más corto que editar un archivo, y es lo que la persona ya está haciendo. El
|
|
71
|
+
// archivo queda como cosa de ella: dicho en imperativo, el agente leía «aprobalo» como una orden para él
|
|
72
|
+
// e intentaba escribírselo en vez de reintentar, medido en una sesión real de Claude Code.
|
|
73
|
+
function HOW(variable, lines, input) {
|
|
74
|
+
const chat = CHAT.hold(input, lines)
|
|
75
|
+
return (chat
|
|
76
|
+
? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
|
|
77
|
+
+ 'pasa. Si prefiere aprobarlo a mano, que pegue ella tal cual en'
|
|
78
|
+
: 'Aprobalo pegando tal cual en')
|
|
79
|
+
+ ` ${where(input)} estas líneas:\n`
|
|
80
|
+
+ lines.map((line) => ` ${line}\n`).join('')
|
|
81
|
+
+ `Valen para ese conjunto y dejan de valer en cuanto cambie. La variable ${variable}=1 sigue existiendo `
|
|
82
|
+
+ 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
|
|
83
|
+
}
|
|
53
84
|
|
|
54
|
-
module.exports = { APPROVAL, read, pending, HOW }
|
|
85
|
+
module.exports = { APPROVAL, read, pending, where, HOW }
|