@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
|
@@ -27,6 +27,7 @@ export const meta = {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
{{INCLUDE:shared/workflow-root.js}}
|
|
30
|
+
{{INCLUDE:shared/inbox.js}}
|
|
30
31
|
const CONFIG = `${ROOT}/ops.config.json`
|
|
31
32
|
const P = `${ROOT}/planning`
|
|
32
33
|
const ORG = `${ROOT}/organization`
|
|
@@ -82,6 +83,10 @@ const CONTEXT = {
|
|
|
82
83
|
// Dónde va el plan de este runner. El nombre sale de su id y el recorrido no lo deriva: lo
|
|
83
84
|
// pregunta, igual que la fecha.
|
|
84
85
|
wipFile: { type: 'string' },
|
|
86
|
+
// Con los nombres que ya hay en el INBOX, Review no vuelve a anotar uno.
|
|
87
|
+
inbox: { ...INBOX_HEADS },
|
|
88
|
+
// Las reglas que rigen el proyecto, con los overrides ya resueltos por el motor (caso 105).
|
|
89
|
+
rules: { type: 'array', items: { type: 'string' } },
|
|
85
90
|
},
|
|
86
91
|
}
|
|
87
92
|
const CLAIM = {
|
|
@@ -132,6 +137,10 @@ const DECISION = {
|
|
|
132
137
|
consulted: { type: 'array', items: { type: 'string' } },
|
|
133
138
|
},
|
|
134
139
|
}
|
|
140
|
+
// Review nombra contra qué reglas revisó (caso 105): recibir las rutas no garantiza abrirlas, y esto es lo único
|
|
141
|
+
// que deja rastro de que se hizo. Critique no lo lleva porque no recibe la lista.
|
|
142
|
+
const REVIEWED = { ...DECISION, required: [...DECISION.required, 'rules'],
|
|
143
|
+
properties: { ...DECISION.properties, rules: { type: 'array', items: { type: 'string' } } } }
|
|
135
144
|
// Un exit code dice que el test corrió, no que pruebe lo que la tarea prometió: un test que asercia de
|
|
136
145
|
// menos —o que ni existe— sale verde igual, y el guard de verify tampoco lo ve porque también mira exit
|
|
137
146
|
// codes. Por eso `uncovered` se contrasta contra la aceptación leyendo el fuente, no la salida (R9).
|
|
@@ -274,6 +283,8 @@ const VERDICT = ' Cerrá con verdict=aprobado si no queda nada por corregir ante
|
|
|
274
283
|
'si algo no se resuelve acá —el diseño no lo cubre, falta una decisión ajena, o la corrección excede el ' +
|
|
275
284
|
'alcance—. Marcá blocking=true sólo en el hallazgo que impide entregar: el resto queda registrado y no ' +
|
|
276
285
|
'manda a tocar código.'
|
|
286
|
+
// Acompaña a todo prompt con schema REVIEWED.
|
|
287
|
+
const RULED = ' En rules nombrá, por su ruta, cada una de las reglas que rigen contra la que revisaste el diff.'
|
|
277
288
|
// Lo que hay que corregir antes de entregar. El resto de los hallazgos no desaparece: se registra.
|
|
278
289
|
const blockers = (verdict) => verdict.concerns.filter((one) => one.blocking).map((one) => one.detail)
|
|
279
290
|
// Atajo para reconocer un gate que corrió pruebas sin preguntarle a nadie. No alcanza solo y no
|
|
@@ -331,12 +342,19 @@ if (!contract.rootOk) {
|
|
|
331
342
|
|
|
332
343
|
const bounds = contract.boundaries || []
|
|
333
344
|
const limits = bounds.length ? ` Límites del proyecto: ${bounds.join('; ')}.` : ''
|
|
345
|
+
// Las reglas que rigen el proyecto (caso 105). Las lista `context`, que se lee después del contrato, así que
|
|
346
|
+
// entran al preámbulo cuando esa lectura vuelve. Viajan las rutas y no el texto: el preámbulo se reenvía a cada
|
|
347
|
+
// subagente que toca código, y el texto de las reglas multiplicaría su tamaño por cada uno.
|
|
348
|
+
let governing = []
|
|
334
349
|
// Alcance de escritura: para subagentes que tocan código o ejecutan gates del producto.
|
|
335
|
-
const SCOPE = `${BASE}\n\nProyecto ${contract.project}. workspaceRoots es el límite completo de escritura
|
|
336
|
-
`producto: ${contract.workspaceRoots.join('; ')}.${limits} Este preámbulo ya trae el contrato;
|
|
337
|
-
|
|
350
|
+
const SCOPE = () => `${BASE}\n\nProyecto ${contract.project}. workspaceRoots es el límite completo de escritura ` +
|
|
351
|
+
`del producto: ${contract.workspaceRoots.join('; ')}.${limits} Este preámbulo ya trae el contrato; ` +
|
|
352
|
+
`no vuelvas a leer ${ROOT}/AGENTS.md, ${ORG}/workspace.md, ${CONFIG} ni ${P}/PROTOCOL.md.` +
|
|
353
|
+
(governing.length ? ` Las reglas que rigen este proyecto son éstas, relativas a ${ROOT}: ${governing.join(', ')}. ` +
|
|
354
|
+
'Leé las que toquen tu fase antes de planificar, construir o revisar; donde una propia contradice a una del ' +
|
|
355
|
+
'sistema, rige la propia.' : '')
|
|
338
356
|
// Formatos de planning: sólo para subagentes que escriben roadmap, BACKLOG, WIP, DONE o gates.
|
|
339
|
-
const LEDGER = `${SCOPE}\n\nContratos de planning, textuales de ${P}/PROTOCOL.md:\n${contract.contracts}`
|
|
357
|
+
const LEDGER = () => `${SCOPE()}\n\nContratos de planning, textuales de ${P}/PROTOCOL.md:\n${contract.contracts}`
|
|
340
358
|
|
|
341
359
|
// Un subagente puede morir —error terminal tras reintentos, o alguien que lo saltea— y entonces el
|
|
342
360
|
// runtime devuelve `null`. Sin comprobarlo, la primera propiedad que se le pide revienta el recorrido
|
|
@@ -352,8 +370,8 @@ const LEDGER = `${SCOPE}\n\nContratos de planning, textuales de ${P}/PROTOCOL.md
|
|
|
352
370
|
// justo la distinción que hace falta. Lo que evita el olvido es el arnés, que rechaza la llamada sin
|
|
353
371
|
// etiqueta en las cuatro suites del recorrido.
|
|
354
372
|
const read = (prompt, options = {}) => agent(`${BASE}\n\n${prompt}`, options)
|
|
355
|
-
const run = (prompt, options = {}) => agent(`${SCOPE}\n\n${prompt}`, options)
|
|
356
|
-
const write = (prompt, options = {}) => agent(`${LEDGER}\n\n${prompt}`, options)
|
|
373
|
+
const run = (prompt, options = {}) => agent(`${SCOPE()}\n\n${prompt}`, options)
|
|
374
|
+
const write = (prompt, options = {}) => agent(`${LEDGER()}\n\n${prompt}`, options)
|
|
357
375
|
|
|
358
376
|
// Las tres paradas que dejan una fila en HUMAN_ACTIONS delegan esa escritura a un agente, y esa fila es
|
|
359
377
|
// el único rastro de la parada: sin ella el recorrido informa un estado que el disco no tiene. Por eso
|
|
@@ -368,10 +386,11 @@ const registerHuman = async (prompt, label) => (await write(prompt, { label })
|
|
|
368
386
|
const readContext = () => read(
|
|
369
387
|
`Corré "node tools/ops.js context ${P} --json" desde ${ROOT} y reportá sólo lo que imprimió. Derivá hasTask ` +
|
|
370
388
|
`de si task es null, wipActive de si wip es null, claimed del campo claimed, today y wipFile de sus ` +
|
|
371
|
-
`campos, y lane ` +
|
|
389
|
+
`campos, rules del campo rules tal cual, y lane ` +
|
|
372
390
|
`de task.tier; copiá slug, ` +
|
|
373
391
|
`hito, service, acceptance, ` +
|
|
374
|
-
`epic y cast de task,
|
|
392
|
+
`epic y cast de task, epicContext de epic.context —vacío si no hay épica— e inbox tal cual. El comando es ` +
|
|
393
|
+
`la fuente de ` +
|
|
375
394
|
`verdad: no abras archivos de planning para completarlo. Poné readOk en true sólo si el comando salió ` +
|
|
376
395
|
`con código 0 y devolvió JSON; si falló, readOk en false y el resto en sus valores vacíos, sin ` +
|
|
377
396
|
`deducir el estado de ninguna otra fuente.`,
|
|
@@ -405,6 +424,7 @@ if (blocker === 'blocked-on-human') {
|
|
|
405
424
|
}
|
|
406
425
|
if (blocker) return stop('context-unavailable', `${P} contestó blocked=${JSON.stringify(planning.blocked)}, `
|
|
407
426
|
+ 'que no es del vocabulario. No se sabe si hay bloqueo, así que no se sigue como si no lo hubiera.')
|
|
427
|
+
governing = planning.rules || []
|
|
408
428
|
|
|
409
429
|
let currentMilestone = planning.wipActive ? planning.hito : ''
|
|
410
430
|
const completed = []
|
|
@@ -565,7 +585,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
565
585
|
const nota = await registerHuman(
|
|
566
586
|
`Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
|
|
567
587
|
+ `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
|
|
568
|
-
+ `distintas y partirla
|
|
588
|
+
+ `distintas y partirla, o dejarla entera con la razón escrita.`, 'plan-human')
|
|
569
589
|
return stop(reason, `${detail}${nota}`)
|
|
570
590
|
}
|
|
571
591
|
|
|
@@ -746,8 +766,8 @@ while (rounds++ < MAX_TASKS) {
|
|
|
746
766
|
let review = await run(
|
|
747
767
|
`${asRole(cast.review)}Revisá el diff real por aceptación, regresiones, seguridad, arquitectura, código ` +
|
|
748
768
|
`generado, migraciones y alcance accidental. Cada cargo revisa su dominio, no el ajeno.${MANIFEST}` +
|
|
749
|
-
`${VERDICT}`,
|
|
750
|
-
{ schema:
|
|
769
|
+
`${VERDICT}${RULED}`,
|
|
770
|
+
{ schema: REVIEWED, label: 'review' },
|
|
751
771
|
)
|
|
752
772
|
if (!review) return stop('agent-unavailable', 'Review no devolvió resultado')
|
|
753
773
|
if (review.verdict === 'bloqueado') {
|
|
@@ -756,24 +776,38 @@ while (rounds++ < MAX_TASKS) {
|
|
|
756
776
|
if (blockers(review).length) {
|
|
757
777
|
await write(`Corregí sólo estos hallazgos con evidencia y actualizá el WIP: ${blockers(review).join('; ')}`,
|
|
758
778
|
{ label: 'review-fix' })
|
|
759
|
-
review = await run(`Volvé a revisar el diff corregido de ${task.id}.${MANIFEST}${VERDICT}`,
|
|
760
|
-
{ schema:
|
|
779
|
+
review = await run(`Volvé a revisar el diff corregido de ${task.id}.${MANIFEST}${VERDICT}${RULED}`,
|
|
780
|
+
{ schema: REVIEWED, label: 'review' })
|
|
761
781
|
if (!review) return stop('agent-unavailable', 'la re-revisión no devolvió resultado')
|
|
762
782
|
if (review.verdict === 'bloqueado' || blockers(review).length) {
|
|
763
783
|
return stop('review-failed', blockers(review).join('; ') || 'sin condiciones nombradas')
|
|
764
784
|
}
|
|
765
785
|
}
|
|
786
|
+
// Con reglas que rigen, aprobar sin nombrar contra cuáles es la misma falla que la de abajo en otro eje.
|
|
787
|
+
if (governing.length && !(review.rules || []).length) {
|
|
788
|
+
return stop('review-unbacked', 'aprobó el diff sin nombrar contra qué reglas revisó')
|
|
789
|
+
}
|
|
790
|
+
if (governing.length) log(`Review contra: ${review.rules.join(', ')}`)
|
|
766
791
|
// Aprobar sin declarar qué se abrió no se arregla mandando a tocar código: falló quien revisó.
|
|
767
792
|
if (!review.consulted.length) return stop('review-unbacked', 'aprobó el diff sin declarar qué inspeccionó')
|
|
768
793
|
reviewFact = `${review.verdict} por ${cast.review}, sobre ${review.consulted.join(', ')}`
|
|
769
794
|
// Lo que no impide entregar no manda a tocar código, y tampoco desaparece: la mejora opinable que se
|
|
770
795
|
// corrige a las apuradas cuesta una vuelta y un riesgo que nadie pidió. Va a Propuestas y no a
|
|
771
|
-
// Lecciones porque lo que la revisión anotó es un cambio del producto
|
|
772
|
-
// sobre cómo trabajamos, y ahí el hallazgo queda
|
|
773
|
-
|
|
774
|
-
|
|
796
|
+
// Lecciones porque lo que la revisión anotó es un cambio del producto —su evidencia es la de la
|
|
797
|
+
// tarea, que queda en `done/`—; Lecciones es sobre cómo trabajamos, y ahí el hallazgo queda
|
|
798
|
+
// esperando una promoción que nadie va a hacer.
|
|
799
|
+
const noted = review.concerns.filter((one) => !one.blocking).map((one) => oneLine(one.detail))
|
|
800
|
+
const kept = noted.slice(0, INBOX_CAP)
|
|
801
|
+
// Lo que pasa del tope no se escribe y tampoco desaparece: queda contado en el hecho de revisión, que
|
|
802
|
+
// viaja a `done/`. Una revisión que anota treinta y seis cosas no está priorizando, y el INBOX no las
|
|
803
|
+
// iba a leer (caso 101).
|
|
804
|
+
if (noted.length > kept.length) {
|
|
805
|
+
reviewFact += ` · ${noted.length - kept.length} anotado(s) sin volcar al INBOX`
|
|
806
|
+
}
|
|
807
|
+
if (kept.length) {
|
|
775
808
|
await write(`Registrá en la sección Propuestas de ${P}/INBOX.md lo que la revisión de ${task.id} dejó ` +
|
|
776
|
-
`anotado sin frenar la entrega, sin promover ninguna
|
|
809
|
+
`anotado sin frenar la entrega, sin promover ninguna. ${inboxAsk(['Propuestas'], planning.inbox)} ` +
|
|
810
|
+
`Lo anotado: ${JSON.stringify(kept)}`, { label: 'review-noted' })
|
|
777
811
|
}
|
|
778
812
|
}
|
|
779
813
|
|
|
@@ -16,6 +16,7 @@ export const meta = {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
{{INCLUDE:shared/workflow-root.js}}
|
|
19
|
+
{{INCLUDE:shared/inbox.js}}
|
|
19
20
|
|
|
20
21
|
// Dónde trabaja el recorrido. Normalmente es la raíz donde se lo invocó; `args.root` existe para
|
|
21
22
|
// correrlo sobre otra instancia —el banco desechable con el que `flow-eval` lo mide—, porque un
|
|
@@ -58,6 +59,8 @@ const MANIFEST = {
|
|
|
58
59
|
// `dependsOn`, que se arregló mirando sólo las claves de las etapas.
|
|
59
60
|
completion: { type: 'array', items: { type: 'string' } },
|
|
60
61
|
conditionalAgents: { type: 'array', items: { type: 'string' } },
|
|
62
|
+
// Con los nombres que ya hay en el INBOX, lo que el recorrido escribe al final no repite uno.
|
|
63
|
+
inbox: INBOX_HEADS,
|
|
61
64
|
owners: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
62
65
|
domain: { type: 'string' }, agent: { type: 'string' },
|
|
63
66
|
} } },
|
|
@@ -161,6 +164,7 @@ const contract = await agent(
|
|
|
161
164
|
`only ran on failure the destination came out of memory.\n` +
|
|
162
165
|
` If command 1 failed, set exists=false, report flows, and stop.\n` +
|
|
163
166
|
`3. "node tools/ops.js agents list --json", which gives each role its resolved path.\n` +
|
|
167
|
+
`4. "node tools/ops.js context planning --json" — copy only its inbox field into inbox, verbatim.\n` +
|
|
164
168
|
`Report exists=true and these manifest fields: name, purpose, outcome, entryAgent, facilitator, ` +
|
|
165
169
|
`guardrails, decisionOwners flattened into owners as domain/agent pairs, and stages with id, phase, ` +
|
|
166
170
|
`agent, produces, dependsOn and exitGate. Drop every other field the command printed — the schema ` +
|
|
@@ -398,17 +402,38 @@ if (contract.outcome === 'report') {
|
|
|
398
402
|
`sólo llevaba lo que la siguiente necesitaba para decidir.\n\n` +
|
|
399
403
|
`Escribí el informe en ${REPORTS} como ` +
|
|
400
404
|
`<AAAA-MM-DD>-<slug>.md: qué pasó, qué se sabe con evidencia, qué se supone, qué se decidió y qué ` +
|
|
401
|
-
`queda abierto. Separá causa de síntoma y no atribuyas responsabilidad a personas.
|
|
402
|
-
`
|
|
403
|
-
`
|
|
405
|
+
`queda abierto. Separá causa de síntoma y no atribuyas responsabilidad a personas. Cada seguimiento ` +
|
|
406
|
+
`va en lo que queda abierto del informe y además en followUps, del más al menos importante, con la ` +
|
|
407
|
+
`sección que le toca por su sujeto: un cambio del producto va a Propuestas, lo aprendido sobre cómo ` +
|
|
408
|
+
`trabajamos va a Lecciones. No escribas en ${INBOX}: eso lo hace el paso siguiente. ` +
|
|
404
409
|
`Toda acción que requiera una persona, en ${HUMAN}.`,
|
|
405
410
|
{ schema: { type: 'object', required: ['file', 'followUps'], properties: {
|
|
406
|
-
file: { type: 'string' },
|
|
411
|
+
file: { type: 'string' }, summary: { type: 'string' },
|
|
412
|
+
followUps: { type: 'array', items: { type: 'object', additionalProperties: false,
|
|
413
|
+
required: ['section', 'entry'], properties: {
|
|
414
|
+
section: { type: 'string', enum: ['Propuestas', 'Lecciones'] }, entry: { type: 'string' },
|
|
415
|
+
} } },
|
|
407
416
|
} }, label: 'report-write' },
|
|
408
417
|
)
|
|
409
418
|
if (!report) return stop('report-unavailable', 'el informe no devolvió resultado')
|
|
410
|
-
|
|
411
|
-
|
|
419
|
+
// El tope lo aplica el recorrido y no quien escribe, y lo que pasa de él ya está en el informe: al
|
|
420
|
+
// INBOX va lo que alguien tiene que decidir, no todo lo que el informe dejó abierto (caso 101).
|
|
421
|
+
const followUps = report.followUps || []
|
|
422
|
+
const listed = followUps.slice(0, INBOX_CAP).map((one) => ({ section: one.section, entry: oneLine(one.entry) }))
|
|
423
|
+
if (listed.length) {
|
|
424
|
+
await agent(
|
|
425
|
+
`${RULES}\n\nRegistrá en ${INBOX} estos seguimientos del informe ${report.file}, cada uno en su ` +
|
|
426
|
+
`sección y sin promover ninguno. ${inboxAsk(['Propuestas', 'Lecciones'], contract.inbox)} ` +
|
|
427
|
+
`Seguimientos: ${JSON.stringify(listed)}`,
|
|
428
|
+
{ label: 'report-inbox' },
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
const unlisted = followUps.length - listed.length
|
|
432
|
+
log(`Informe en ${report.file}. ${listed.length} seguimiento(s) en el INBOX, sin promover` +
|
|
433
|
+
`${unlisted ? `; ${unlisted} más quedan sólo en el informe, por el tope de ${INBOX_CAP} por corrida` : ''}.`)
|
|
434
|
+
return finish({
|
|
435
|
+
flow: FLOW, stages: handoffs.length, report: report.file, followUps: listed.length, unlisted, promoted: false,
|
|
436
|
+
})
|
|
412
437
|
}
|
|
413
438
|
|
|
414
439
|
const epic = await agent(
|
|
@@ -431,7 +456,7 @@ if (!epic) return stop('draft-unavailable', 'la propuesta de épica no devolvió
|
|
|
431
456
|
if (epic.outcome === 'no-hacer') {
|
|
432
457
|
await agent(
|
|
433
458
|
`${RULES}\n\nRegistrá la conclusión en la sección Lecciones de ${INBOX}: por qué esta intención no ` +
|
|
434
|
-
`es viable hoy y qué la haría viable. Motivo: ${epic.reason}`,
|
|
459
|
+
`es viable hoy y qué la haría viable. ${inboxAsk(['Lecciones'], contract.inbox)} Motivo: ${epic.reason}`,
|
|
435
460
|
{ label: 'inbox-lesson' },
|
|
436
461
|
)
|
|
437
462
|
return stop('no-viable', epic.reason)
|
|
@@ -442,7 +467,7 @@ if (epic.outcome === 'investigar') {
|
|
|
442
467
|
await agent(
|
|
443
468
|
`${RULES}\n\nRegistrá en ${HUMAN} qué hay que averiguar antes de poder decidir esta intención y quién ` +
|
|
444
469
|
`puede hacerlo, sin inventar responsables ni fechas, y dejá la conclusión en la sección Ideas de ` +
|
|
445
|
-
`${INBOX} sin promoverla. Qué falta averiguar: ${epic.reason}`,
|
|
470
|
+
`${INBOX} sin promoverla. ${inboxAsk(['Ideas'], contract.inbox)} Qué falta averiguar: ${epic.reason}`,
|
|
446
471
|
{ label: 'investigar' },
|
|
447
472
|
)
|
|
448
473
|
return finish({ flow: FLOW, stages: handoffs.length, investigate: epic.reason, promoted: false })
|
|
@@ -59,6 +59,7 @@ const BASE = `Nunca inventes clientes, métricas, ingresos, plazos ni responsabl
|
|
|
59
59
|
`retrasa el único momento en que la herramienta todavía no sirve para nada.`
|
|
60
60
|
|
|
61
61
|
{{INCLUDE:shared/workflow-finish.js}}
|
|
62
|
+
{{INCLUDE:shared/inbox.js}}
|
|
62
63
|
|
|
63
64
|
const SCAN = {
|
|
64
65
|
type: 'object', additionalProperties: false, required: ['fresh', 'services'],
|
|
@@ -86,6 +87,8 @@ const SCAN = {
|
|
|
86
87
|
} } },
|
|
87
88
|
externals: { type: 'array', items: { type: 'string' } },
|
|
88
89
|
secrets: { type: 'array', items: { type: 'string' } },
|
|
90
|
+
// Los nombres que ya hay en Ideas: con `force` el arranque reescribe una instancia que ya tiene INBOX.
|
|
91
|
+
inboxIdeas: { type: 'array', items: { type: 'string' } },
|
|
89
92
|
},
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -104,13 +107,14 @@ phase('Scan')
|
|
|
104
107
|
// Una sola llamada, y todo lo que hace es correr dos comandos y mirar dos archivos. Lo que sigue depende
|
|
105
108
|
// de lo que devuelva, así que gastar más antes de saberlo es gastar a ciegas.
|
|
106
109
|
const state = await agent(
|
|
107
|
-
`${BASE}\n\nFrom ${ROOT}, run exactly these
|
|
110
|
+
`${BASE}\n\nFrom ${ROOT}, run exactly these three commands and report what they printed. Explore nothing ` +
|
|
108
111
|
`else and open no file other than .env.example at the workspace root.\n` +
|
|
109
112
|
`1. "node tools/ops.js onboard --json": the instance state, the workspace inventory, the opening ` +
|
|
110
113
|
`question and the dimensions still uncovered. Copy fresh, opening, followUps, the "need" of each ` +
|
|
111
114
|
`dimension, and every service with its path, its runtimes, its declared commands keeping the source ` +
|
|
112
115
|
`file each command came from, and the variable names its "env" carries. Add nothing it did not print.\n` +
|
|
113
116
|
`2. "node tools/ops.js check planning".\n` +
|
|
117
|
+
`3. "node tools/ops.js context planning --json": copy its inbox.ideas into inboxIdeas, verbatim.\n` +
|
|
114
118
|
`The inventory already names every credential each service expects: never open a .env file to look for ` +
|
|
115
119
|
`more. Report those names in secrets and the services they point at in externals. A name the inventory ` +
|
|
116
120
|
`carries is declared, and saying otherwise is a claim the repository contradicts.`,
|
|
@@ -180,16 +184,28 @@ const drafted = await agent(
|
|
|
180
184
|
`que no pidas declararlo de nuevo: lo que falta es dónde se carga el valor y quién lo hace, y ningún ` +
|
|
181
185
|
`valor se propone acá. Además, una por cada sistema externo o MCP a conectar, y una por la autoridad ` +
|
|
182
186
|
`del runner, que hoy declara runner.allowPush=false.\n` +
|
|
183
|
-
`
|
|
184
|
-
`
|
|
187
|
+
`Devolvé en files cada archivo que tocaste, en assumptions cada supuesto que dejaste marcado y en ` +
|
|
188
|
+
`openQuestions las preguntas que quedaron abiertas, de la más a la menos importante. No las escribas en ` +
|
|
189
|
+
`${INBOX}: eso lo hace el paso siguiente.`,
|
|
185
190
|
{ schema: WRITTEN, label: 'contexto' },
|
|
186
191
|
)
|
|
187
192
|
if (!drafted) return stop('draft-unavailable', 'los borradores no devolvieron resultado')
|
|
188
193
|
|
|
189
194
|
phase('Epic')
|
|
190
195
|
|
|
196
|
+
// Las preguntas abiertas van al INBOX con tope, y el tope lo aplica el recorrido: por eso las escribe
|
|
197
|
+
// este paso con lo que el anterior devolvió, y no el anterior mientras las redactaba (caso 101). Las que
|
|
198
|
+
// no entran se dicen al cerrar, que es donde la persona que arrancó la instancia las lee.
|
|
199
|
+
const questions = (drafted.openQuestions || []).map(oneLine)
|
|
200
|
+
const inboxed = questions.slice(0, INBOX_CAP)
|
|
201
|
+
const INBOX_ASK = inboxed.length
|
|
202
|
+
? `Registrá además en la sección Ideas de ${INBOX}, sin promover, estas preguntas abiertas: ` +
|
|
203
|
+
`${JSON.stringify(inboxed)}. ${inboxAsk(['Ideas'], { ideas: state.inboxIdeas || [] })}\n\n`
|
|
204
|
+
: ''
|
|
205
|
+
|
|
191
206
|
const epic = await agent(
|
|
192
207
|
`${BASE}\n\n${EVIDENCE}\n\nSupuestos que quedaron escritos: ${JSON.stringify(drafted.assumptions || [])}\n\n` +
|
|
208
|
+
INBOX_ASK +
|
|
193
209
|
`Escribí en ${ROADMAP} la épica epic-001-<slug>.md siguiendo el contrato de ${P}/PROTOCOL.md: ` +
|
|
194
210
|
`frontmatter epic/title/status/service con status open, criterios **CN** observables, "## Contexto ` +
|
|
195
211
|
`relevante" con rutas reales e historias con (→ CN) y (service: ruta), cada una de menos de cuatro ` +
|
|
@@ -223,11 +239,17 @@ const assumptions = (drafted.assumptions || []).length
|
|
|
223
239
|
const humanActions = (drafted.humanActions || []).length
|
|
224
240
|
log(`Contexto escrito con ${assumptions} supuesto(s) por confirmar y ${humanActions} acción(es) humana(s) en ${HUMAN}.`)
|
|
225
241
|
log(`Épica en ${epic.file}, sin promover: revisala, promoví una historia a un hito del BACKLOG y corré /autobuild.`)
|
|
242
|
+
const unlisted = questions.slice(INBOX_CAP)
|
|
243
|
+
if (unlisted.length) {
|
|
244
|
+
log(`${unlisted.length} pregunta(s) abierta(s) más no entraron al INBOX (tope de ${INBOX_CAP}): ` +
|
|
245
|
+
unlisted.join(' · '))
|
|
246
|
+
}
|
|
226
247
|
|
|
227
248
|
return finish({
|
|
228
249
|
services: services.length,
|
|
229
250
|
assumptions,
|
|
230
251
|
humanActions,
|
|
231
252
|
epic: epic.file,
|
|
253
|
+
unlistedQuestions: unlisted,
|
|
232
254
|
promoted: false,
|
|
233
255
|
})
|
|
@@ -7,6 +7,7 @@ const F = require('../core/files')
|
|
|
7
7
|
const catalog = require('../agents/catalog')
|
|
8
8
|
const O = require('../core/ownership')
|
|
9
9
|
const M = require('../core/manifest')
|
|
10
|
+
const RL = require('./rules')
|
|
10
11
|
const {
|
|
11
12
|
RUNNER_NAMES, OPS_DIR, OPS_ROOT, packagedAutomation, runnerManifest, installRoot, opsPrefix,
|
|
12
13
|
runnerPaths, resolveItem, inline, render, runnerConfig, activated,
|
|
@@ -51,7 +52,15 @@ function check(root) {
|
|
|
51
52
|
errors.push(`falta automatization/workflows/${name}: corré "npm install" en la raíz del repo ops`)
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
// Un choque que `upgrade` conservó (caso 110) también queda distinto del paquete, y mandarlo a correr
|
|
56
|
+
// `upgrade` era una vuelta sin salida: lo conservaría otra vez. Se dice qué es y qué hacer.
|
|
57
|
+
const choques = new Set(O.collisions(root))
|
|
54
58
|
for (const { file, edited } of staleHooks(root)) {
|
|
59
|
+
if (choques.has(`automatization/hooks/${file}`)) {
|
|
60
|
+
errors.push(`automatization/hooks/${file}: es tuyo y se llama como uno que trae el paquete, así que el `
|
|
61
|
+
+ "del paquete no está instalado; renombrá el tuyo y corré `cauce upgrade`")
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
55
64
|
errors.push(edited
|
|
56
65
|
? `automatization/hooks/${file}: lo editaste y es del toolkit; agregá un guard propio al lado `
|
|
57
66
|
+ 'o descartá tu cambio con `cauce upgrade --force`'
|
|
@@ -145,6 +154,10 @@ function doctor(root, name, output = console) {
|
|
|
145
154
|
} catch (error) {
|
|
146
155
|
errors.push(`${runner.config.target}: ${error.message}`)
|
|
147
156
|
}
|
|
157
|
+
// Una regla nueva cambia el render: se dice cuál, y calla el genérico, que mandaba a buscar un cambio de Cauce.
|
|
158
|
+
const ruled = RL.drift(root, name)
|
|
159
|
+
warnings.push(...ruled.map((one) => RL.driftLine(name, one)))
|
|
160
|
+
const quiet = (item) => ruled.some((one) => one.target === item.target)
|
|
148
161
|
for (const item of runner.instructions || []) {
|
|
149
162
|
const resolved = { item, ...resolveItem(paths, root, name, item) }
|
|
150
163
|
// El archivo compartido no se compara entero: alrededor del bloque vive el texto de la empresa, así
|
|
@@ -155,7 +168,7 @@ function doctor(root, name, output = console) {
|
|
|
155
168
|
if (!fs.existsSync(resolved.target)) errors.push(`falta ${item.target}`)
|
|
156
169
|
else if (!fs.readFileSync(resolved.target, 'utf8').includes(blockStart(name))) {
|
|
157
170
|
errors.push(`${item.target}: no tiene las instrucciones de Cauce; reinstalá el adaptador`)
|
|
158
|
-
} else if (!blockUpToDate(resolved.target, name, content)) {
|
|
171
|
+
} else if (!quiet(item) && !blockUpToDate(resolved.target, name, content)) {
|
|
159
172
|
warnings.push(`${item.target}: su bloque de Cauce quedó viejo; reinstalá el adaptador`)
|
|
160
173
|
}
|
|
161
174
|
continue
|
|
@@ -168,7 +181,7 @@ function doctor(root, name, output = console) {
|
|
|
168
181
|
else if (!ownFile && !fs.readFileSync(resolved.target, 'utf8').includes('AGENTS.md')) {
|
|
169
182
|
warnings.push(`${item.target}: no referencia AGENTS.md; verifica las reglas globales`)
|
|
170
183
|
}
|
|
171
|
-
if (deliveryState(M.readRunners(root), name, resolved, opsPrefix(root)) === 'desactualizado') {
|
|
184
|
+
if (!quiet(item) && deliveryState(M.readRunners(root), name, resolved, opsPrefix(root)) === 'desactualizado') {
|
|
172
185
|
warnings.push(`${item.target}: Cauce trae una versión más nueva y vos no lo tocaste; reinstalá`)
|
|
173
186
|
}
|
|
174
187
|
}
|
|
@@ -179,7 +192,7 @@ function doctor(root, name, output = console) {
|
|
|
179
192
|
const resolved = { item, ...resolveItem(paths, root, name, item) }
|
|
180
193
|
const status = deliveryState(recorded, name, resolved, opsPrefix(root))
|
|
181
194
|
if (status === 'nuevo') errors.push(`falta ${item.target}`)
|
|
182
|
-
else if (status === 'desactualizado') {
|
|
195
|
+
else if (status === 'desactualizado' && !quiet(item)) {
|
|
183
196
|
warnings.push(`${item.target}: hay una versión más nueva en Cauce; reinstalá el adaptador`)
|
|
184
197
|
} else if (status === 'ajeno') {
|
|
185
198
|
warnings.push(`${item.target}: lo editaste y es del toolkit; `
|
|
@@ -322,7 +335,7 @@ function uninstall(root, name, output = console) {
|
|
|
322
335
|
|
|
323
336
|
if (fs.existsSync(paths.configTarget)) {
|
|
324
337
|
const current = JSON.parse(fs.readFileSync(paths.configTarget, 'utf8'))
|
|
325
|
-
const clean = unmergeConfig(current, runnerConfig(paths, root))
|
|
338
|
+
const clean = unmergeConfig(unmergeConfig(current, runnerConfig(paths, root)), runner.config.retired || {})
|
|
326
339
|
if (clean && Object.keys(clean).length) F.atomicWriteJson(paths.configTarget, clean)
|
|
327
340
|
else { removeFile(paths.configTarget, paths.install); removed += 1 }
|
|
328
341
|
output.log(`✓ ${name}: ${runner.config.target} sin las entradas de Cauce`)
|
|
@@ -389,6 +402,14 @@ function install(root, name, output = console, options = {}) {
|
|
|
389
402
|
? { config: {}, dropped: [] }
|
|
390
403
|
: withoutDeliveredHooks(current, live, previous)
|
|
391
404
|
reportRemoved(name, clean.dropped, live, output)
|
|
405
|
+
// Lo que Cauce entregó en una versión y retiró en otra sin ser un hook, que el merge no saca: las reglas
|
|
406
|
+
// `permissions.deny` que puso el 092 y sacó el 104. Se va exactamente lo que el adaptador declara como
|
|
407
|
+
// retirado; una regla de la empresa que no coincide letra por letra se queda.
|
|
408
|
+
if (runner.config.retired) {
|
|
409
|
+
const before = JSON.stringify(clean.config)
|
|
410
|
+
clean.config = unmergeConfig(clean.config, runner.config.retired) || {}
|
|
411
|
+
if (JSON.stringify(clean.config) !== before) output.log(`− ${name}: quitadas las reglas que Cauce ya no entrega`)
|
|
412
|
+
}
|
|
392
413
|
F.atomicWriteJson(paths.configTarget, mergeConfig(clean.config, incoming))
|
|
393
414
|
// Dónde aterrizó, no sólo qué archivo: en sidecar el destino no es el repo desde el que se corrió
|
|
394
415
|
// el comando, y descubrirlo por sorpresa es la diferencia entre confiar y adivinar.
|
|
@@ -424,7 +445,9 @@ function install(root, name, output = console, options = {}) {
|
|
|
424
445
|
continue
|
|
425
446
|
}
|
|
426
447
|
if (status === 'ajeno' && ownFile) {
|
|
427
|
-
output.log(
|
|
448
|
+
output.log(RL.refresh(resolved.target, render(resolved.source, prefix, resolved.automationRoot, resolved.opsRoot))
|
|
449
|
+
? `✓ ${name}: ${resolved.item.target} conserva tus cambios y recibió las reglas vigentes`
|
|
450
|
+
: `= ${name}: conservado ${resolved.item.target} (tiene cambios tuyos)`)
|
|
428
451
|
} else if (status === 'al día') {
|
|
429
452
|
output.log(`= ${name}: ${resolved.item.target} ya está al día`)
|
|
430
453
|
} else {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Cómo llegan a un runner las reglas que rigen la instancia (casos 099 y 105). Su archivo de instrucciones las
|
|
4
|
+
// nombraba fijas —las cuatro de `system/`—, así que la sesión cargaba la que la empresa había sobrescrito y
|
|
5
|
+
// ninguna de las propias. El adaptador trae un marcador y el motor lo resuelve contra `effectiveRules` al
|
|
6
|
+
// instalar. Y como `upgrade` no reinstala, `check` y `doctor` comparan lo instalado con lo vigente.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs')
|
|
9
|
+
const F = require('../core/files')
|
|
10
|
+
const O = require('../core/ownership')
|
|
11
|
+
|
|
12
|
+
// `imports` para el runner que carga archivos con `@ruta`; `list` para el que sólo lee prosa, donde un `@` no
|
|
13
|
+
// significa nada.
|
|
14
|
+
const MARKER = /\{\{RULES:(imports|list)\}\}/g
|
|
15
|
+
const START = '<!-- cauce:reglas inicio — lo reescribe "automation install" con las reglas vigentes -->'
|
|
16
|
+
const END = '<!-- cauce:reglas fin -->'
|
|
17
|
+
// Se reconoce por el arranque y no por la línea entera: quien quiera el bloque en un archivo propio escribe las
|
|
18
|
+
// dos marcas a mano, y pedirle el texto exacto de la primera es pedirle que acierte un guion largo.
|
|
19
|
+
const START_AT = '<!-- cauce:reglas inicio'
|
|
20
|
+
// Lo que un `CLAUDE.md` o un `GEMINI.md` instalado antes de 0.82.0 trae en el lugar del bloque.
|
|
21
|
+
const LEGACY_IMPORT = /^@\S*planning\/rules\/\S+\.md\s*$/
|
|
22
|
+
|
|
23
|
+
// Sin raíz el marcador queda como está —así lo leen las pruebas que revisan el texto de un adaptador—: un
|
|
24
|
+
// bloque vacío se leería igual que un proyecto sin reglas, y un marcador sin resolver se ve.
|
|
25
|
+
function fill(text, root) {
|
|
26
|
+
if (!root || !text.includes('{{RULES:')) return text
|
|
27
|
+
const rules = O.effectiveRules(root)
|
|
28
|
+
return text.replace(MARKER, (_, format) => [START, ...rules.map((file) => (format === 'imports'
|
|
29
|
+
? `@{{OPS_DIR}}${file}`
|
|
30
|
+
: `- \`{{OPS_DIR}}${file}\``)), END].join('\n'))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blockOf(text) {
|
|
34
|
+
const start = text.indexOf(START_AT)
|
|
35
|
+
if (start === -1) return null
|
|
36
|
+
const end = text.indexOf(END, start)
|
|
37
|
+
if (end === -1) return null
|
|
38
|
+
return { start, end: end + END.length, body: text.slice(start, end + END.length) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Las reglas que nombra un archivo instalado, relativas a la raíz ops: las del bloque o, si es anterior al
|
|
42
|
+
// bloque, sus imports sueltos.
|
|
43
|
+
function listed(text) {
|
|
44
|
+
const found = blockOf(text)
|
|
45
|
+
const lines = found ? found.body.split('\n') : text.split('\n').filter((line) => LEGACY_IMPORT.test(line))
|
|
46
|
+
return lines.map((line) => (line.match(/planning\/rules\/[^\s`]+\.md/) || [])[0]).filter(Boolean)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Un archivo de instrucciones con cambios de la empresa se conserva, y hasta acá eso lo dejaba sin reglas
|
|
50
|
+
// nuevas para siempre. Recibe el bloque donde estaba el suyo, o donde estaban los imports fijos de antes, y el
|
|
51
|
+
// resto queda como lo dejó quien lo editó. Devuelve null si el archivo no tiene dónde recibirlo: uno propio,
|
|
52
|
+
// sin marcas ni imports de Cauce, no se toca.
|
|
53
|
+
function withBlock(text, rendered) {
|
|
54
|
+
const fresh = blockOf(rendered)
|
|
55
|
+
if (!fresh) return null
|
|
56
|
+
const current = blockOf(text)
|
|
57
|
+
if (current) return `${text.slice(0, current.start)}${fresh.body}${text.slice(current.end)}`
|
|
58
|
+
const lines = text.split('\n')
|
|
59
|
+
const first = lines.findIndex((line) => LEGACY_IMPORT.test(line))
|
|
60
|
+
if (first === -1) return null
|
|
61
|
+
const kept = lines.filter((line, index) => index === first || !LEGACY_IMPORT.test(line))
|
|
62
|
+
kept[first] = fresh.body
|
|
63
|
+
return kept.join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Qué archivos de un runner nombran otras reglas que las vigentes: sólo los que su adaptador entrega con el
|
|
67
|
+
// marcador y que están en disco. Se carga tarde porque `runners` usa `fill` para renderizar.
|
|
68
|
+
function drift(root, name) {
|
|
69
|
+
const { runnerManifest, runnerPaths, resolveItem } = require('./runners')
|
|
70
|
+
const runner = runnerManifest(root, name)
|
|
71
|
+
const paths = runnerPaths(root, name, runner)
|
|
72
|
+
const expected = O.effectiveRules(root)
|
|
73
|
+
const found = []
|
|
74
|
+
for (const item of [...(runner.instructions || []), ...(runner.artifacts || [])]) {
|
|
75
|
+
const { source, target } = resolveItem(paths, root, name, item)
|
|
76
|
+
if (!fs.existsSync(target) || !fs.readFileSync(source, 'utf8').includes('{{RULES:')) continue
|
|
77
|
+
const text = fs.readFileSync(target, 'utf8')
|
|
78
|
+
const have = listed(text)
|
|
79
|
+
const missing = expected.filter((file) => !have.includes(file))
|
|
80
|
+
const extra = have.filter((file) => !expected.includes(file))
|
|
81
|
+
if (!blockOf(text) && !have.length) found.push({ target: item.target, bare: true, missing, extra })
|
|
82
|
+
else if (missing.length || extra.length) found.push({ target: item.target, bare: false, missing, extra })
|
|
83
|
+
}
|
|
84
|
+
return found
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Escribe el bloque nuevo dentro de un archivo con cambios propios; dice si hubo algo que escribir.
|
|
88
|
+
function refresh(file, rendered) {
|
|
89
|
+
const current = fs.readFileSync(file, 'utf8')
|
|
90
|
+
const updated = withBlock(current, rendered)
|
|
91
|
+
if (updated === null || updated === current) return false
|
|
92
|
+
F.atomicWrite(file, updated)
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function driftLine(name, one) {
|
|
97
|
+
if (one.bare) {
|
|
98
|
+
return `${one.target} no carga las reglas vigentes; reinstalá el adaptador (make install-${name}), `
|
|
99
|
+
+ `y si ese archivo es tuyo, marcá dónde va el bloque con ${START_AT} --> y ${END}`
|
|
100
|
+
}
|
|
101
|
+
const parts = [
|
|
102
|
+
one.missing.length ? `no carga ${one.missing.join(', ')}` : '',
|
|
103
|
+
one.extra.length ? `carga ${one.extra.join(', ')}, que ya no rige` : '',
|
|
104
|
+
].filter(Boolean)
|
|
105
|
+
return `${one.target} ${parts.join(' y ')}; reinstalá el adaptador (make install-${name})`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Lo mismo para cada runner que esta instancia instaló, que es lo que `check` mira en cada corrida: una regla
|
|
109
|
+
// escrita después de instalar, o traída por un `upgrade`, no llega a ninguna sesión hasta reinstalar.
|
|
110
|
+
function staleLines(root) {
|
|
111
|
+
const { RUNNER_NAMES } = require('./runners')
|
|
112
|
+
const recorded = Object.keys(require('../core/manifest').readRunners(root))
|
|
113
|
+
const lines = []
|
|
114
|
+
for (const name of RUNNER_NAMES) {
|
|
115
|
+
if (!recorded.some((key) => key.startsWith(`${name}/`))) continue
|
|
116
|
+
// Sin el paquete no hay adaptador contra el cual comparar, y eso ya lo dice `automation doctor`.
|
|
117
|
+
try { for (const one of drift(root, name)) lines.push(`${name}: ${driftLine(name, one)}`) } catch { continue }
|
|
118
|
+
}
|
|
119
|
+
return lines
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { fill, refresh, drift, driftLine, staleLines }
|
|
@@ -10,6 +10,7 @@ const path = require('node:path')
|
|
|
10
10
|
const { spawnSync } = require('node:child_process')
|
|
11
11
|
const F = require('../core/files')
|
|
12
12
|
const O = require('../core/ownership')
|
|
13
|
+
const { fill } = require('./rules')
|
|
13
14
|
|
|
14
15
|
const RUNNER_NAMES = ['claude', 'codex', 'gemini', 'antigravity']
|
|
15
16
|
|
|
@@ -126,8 +127,9 @@ function inline(text, automationRoot) {
|
|
|
126
127
|
// archivo se rompe si el proyecto se mueve, así que la lleva sólo el que se queda sin alternativa.
|
|
127
128
|
const OPS_ROOT = '{{OPS_ROOT}}'
|
|
128
129
|
|
|
130
|
+
// `{{RULES:…}}` va antes que `{{OPS_DIR}}` por lo mismo que el include: las rutas que escribe llevan el prefijo.
|
|
129
131
|
function render(file, prefix, automationRoot, opsRoot = '') {
|
|
130
|
-
return inline(fs.readFileSync(file, 'utf8'), automationRoot)
|
|
132
|
+
return fill(inline(fs.readFileSync(file, 'utf8'), automationRoot), opsRoot)
|
|
131
133
|
.split(OPS_ROOT).join(opsRoot)
|
|
132
134
|
.split(OPS_DIR).join(prefix)
|
|
133
135
|
}
|