@ingeniomaps/cauce 0.81.0 → 0.83.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 +248 -0
- package/automatization/hooks/README.md +10 -6
- package/automatization/hooks/guard-ops-config-shell.sh +3 -0
- package/automatization/hooks/guard-ops-config.sh +3 -0
- package/automatization/runners/antigravity/rules/cauce.md +7 -2
- package/automatization/runners/claude/CLAUDE.md +1 -4
- package/automatization/runners/codex/AGENTS.md +7 -3
- package/automatization/runners/gemini/GEMINI.md +1 -4
- package/automatization/shared/inbox.js +47 -0
- package/automatization/workflows/agent-eval.js +1 -1
- package/automatization/workflows/autobuild.js +56 -18
- package/automatization/workflows/flow.js +45 -8
- package/automatization/workflows/onboard.js +32 -3
- package/engine/automation/index.js +19 -4
- 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 +50 -15
- package/engine/hooks/chat.js +122 -11
- package/engine/hooks/input.js +1 -11
- package/engine/hooks/ops-config.js +110 -0
- package/engine/hooks/push.js +194 -0
- package/engine/hooks/run.js +17 -2
- package/engine/hooks/secrets-shell.js +13 -2
- package/engine/hooks/self-approval.js +93 -13
- package/engine/hooks/shell.js +13 -11
- 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 +13 -5
- package/template/gitignore +5 -0
- package/template/planning/INBOX.md +2 -1
- package/template/planning/RECURRING.md +6 -5
- package/template/planning/delivery/teamwork.md +1 -1
- package/template/planning/rules/system/commits.md +3 -1
|
@@ -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,10 @@ 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,
|
|
64
|
+
// La fecha, del mismo comando que trae los nombres (caso 115).
|
|
65
|
+
today: { type: 'string' },
|
|
61
66
|
owners: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
62
67
|
domain: { type: 'string' }, agent: { type: 'string' },
|
|
63
68
|
} } },
|
|
@@ -161,6 +166,8 @@ const contract = await agent(
|
|
|
161
166
|
`only ran on failure the destination came out of memory.\n` +
|
|
162
167
|
` If command 1 failed, set exists=false, report flows, and stop.\n` +
|
|
163
168
|
`3. "node tools/ops.js agents list --json", which gives each role its resolved path.\n` +
|
|
169
|
+
`4. "node tools/ops.js context planning --json" — copy its inbox field into inbox and its today field ` +
|
|
170
|
+
`into today, both verbatim, and nothing else it printed.\n` +
|
|
164
171
|
`Report exists=true and these manifest fields: name, purpose, outcome, entryAgent, facilitator, ` +
|
|
165
172
|
`guardrails, decisionOwners flattened into owners as domain/agent pairs, and stages with id, phase, ` +
|
|
166
173
|
`agent, produces, dependsOn and exitGate. Drop every other field the command printed — the schema ` +
|
|
@@ -197,6 +204,11 @@ const RULES = `${BASE}\n\nRecorrido ${contract.name}: ${contract.purpose}\n` +
|
|
|
197
204
|
+ `destino, sale de esa lista; si ninguno sirve, decilo con su razón en vez de inventar uno.\n` : ''}` +
|
|
198
205
|
`Contexto de la empresa en ${WORKDIR}/organization/. Intención a evaluar: ${GOAL}`
|
|
199
206
|
|
|
207
|
+
// De qué vía sale lo que este recorrido escriba en el INBOX: el recorrido, el equipo que lo corrió y la
|
|
208
|
+
// fecha que dio el motor. En modo informe la unidad es el informe y esa rama arma la suya, porque es
|
|
209
|
+
// donde queda la evidencia de lo que se anotó.
|
|
210
|
+
const ORIGIN = inboxOrigin('flow', FLOW, contract.today)
|
|
211
|
+
|
|
200
212
|
phase('Stages')
|
|
201
213
|
|
|
202
214
|
const handoffs = []
|
|
@@ -398,17 +410,40 @@ if (contract.outcome === 'report') {
|
|
|
398
410
|
`sólo llevaba lo que la siguiente necesitaba para decidir.\n\n` +
|
|
399
411
|
`Escribí el informe en ${REPORTS} como ` +
|
|
400
412
|
`<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
|
-
`
|
|
413
|
+
`queda abierto. Separá causa de síntoma y no atribuyas responsabilidad a personas. Cada seguimiento ` +
|
|
414
|
+
`va en lo que queda abierto del informe y además en followUps, del más al menos importante, con la ` +
|
|
415
|
+
`sección que le toca por su sujeto: un cambio del producto va a Propuestas, lo aprendido sobre cómo ` +
|
|
416
|
+
`trabajamos va a Lecciones. No escribas en ${INBOX}: eso lo hace el paso siguiente. ` +
|
|
404
417
|
`Toda acción que requiera una persona, en ${HUMAN}.`,
|
|
405
418
|
{ schema: { type: 'object', required: ['file', 'followUps'], properties: {
|
|
406
|
-
file: { type: 'string' },
|
|
419
|
+
file: { type: 'string' }, summary: { type: 'string' },
|
|
420
|
+
followUps: { type: 'array', items: { type: 'object', additionalProperties: false,
|
|
421
|
+
required: ['section', 'entry'], properties: {
|
|
422
|
+
section: { type: 'string', enum: ['Propuestas', 'Lecciones'] }, entry: { type: 'string' },
|
|
423
|
+
} } },
|
|
407
424
|
} }, label: 'report-write' },
|
|
408
425
|
)
|
|
409
426
|
if (!report) return stop('report-unavailable', 'el informe no devolvió resultado')
|
|
410
|
-
|
|
411
|
-
|
|
427
|
+
// El tope lo aplica el recorrido y no quien escribe, y lo que pasa de él ya está en el informe: al
|
|
428
|
+
// INBOX va lo que alguien tiene que decidir, no todo lo que el informe dejó abierto (caso 101).
|
|
429
|
+
const followUps = report.followUps || []
|
|
430
|
+
const reportOrigin = inboxOrigin('flow', report.file, contract.today)
|
|
431
|
+
const listed = followUps.slice(0, INBOX_CAP)
|
|
432
|
+
.map((one) => ({ section: one.section, entry: withOrigin(one.entry, reportOrigin) }))
|
|
433
|
+
if (listed.length) {
|
|
434
|
+
await agent(
|
|
435
|
+
`${RULES}\n\nRegistrá en ${INBOX} estos seguimientos del informe ${report.file}, cada uno en su ` +
|
|
436
|
+
`sección y sin promover ninguno. ${inboxAsk(['Propuestas', 'Lecciones'], contract.inbox, reportOrigin)} ` +
|
|
437
|
+
`Seguimientos: ${JSON.stringify(listed)}`,
|
|
438
|
+
{ label: 'report-inbox' },
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
const unlisted = followUps.length - listed.length
|
|
442
|
+
log(`Informe en ${report.file}. ${listed.length} seguimiento(s) en el INBOX, sin promover` +
|
|
443
|
+
`${unlisted ? `; ${unlisted} más quedan sólo en el informe, por el tope de ${INBOX_CAP} por corrida` : ''}.`)
|
|
444
|
+
return finish({
|
|
445
|
+
flow: FLOW, stages: handoffs.length, report: report.file, followUps: listed.length, unlisted, promoted: false,
|
|
446
|
+
})
|
|
412
447
|
}
|
|
413
448
|
|
|
414
449
|
const epic = await agent(
|
|
@@ -431,7 +466,8 @@ if (!epic) return stop('draft-unavailable', 'la propuesta de épica no devolvió
|
|
|
431
466
|
if (epic.outcome === 'no-hacer') {
|
|
432
467
|
await agent(
|
|
433
468
|
`${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.
|
|
469
|
+
`es viable hoy y qué la haría viable. ${inboxAsk(['Lecciones'], contract.inbox, ORIGIN)} ` +
|
|
470
|
+
`Motivo: ${withOrigin(epic.reason, ORIGIN)}`,
|
|
435
471
|
{ label: 'inbox-lesson' },
|
|
436
472
|
)
|
|
437
473
|
return stop('no-viable', epic.reason)
|
|
@@ -442,7 +478,8 @@ if (epic.outcome === 'investigar') {
|
|
|
442
478
|
await agent(
|
|
443
479
|
`${RULES}\n\nRegistrá en ${HUMAN} qué hay que averiguar antes de poder decidir esta intención y quién ` +
|
|
444
480
|
`puede hacerlo, sin inventar responsables ni fechas, y dejá la conclusión en la sección Ideas de ` +
|
|
445
|
-
`${INBOX} sin promoverla.
|
|
481
|
+
`${INBOX} sin promoverla. ${inboxAsk(['Ideas'], contract.inbox, ORIGIN)} ` +
|
|
482
|
+
`Qué falta averiguar: ${withOrigin(epic.reason, ORIGIN)}`,
|
|
446
483
|
{ label: 'investigar' },
|
|
447
484
|
)
|
|
448
485
|
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,10 @@ 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' } },
|
|
92
|
+
// La fecha que lleva la procedencia de lo que se escriba en Ideas (caso 115).
|
|
93
|
+
today: { type: 'string' },
|
|
89
94
|
},
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -104,13 +109,15 @@ phase('Scan')
|
|
|
104
109
|
// Una sola llamada, y todo lo que hace es correr dos comandos y mirar dos archivos. Lo que sigue depende
|
|
105
110
|
// de lo que devuelva, así que gastar más antes de saberlo es gastar a ciegas.
|
|
106
111
|
const state = await agent(
|
|
107
|
-
`${BASE}\n\nFrom ${ROOT}, run exactly these
|
|
112
|
+
`${BASE}\n\nFrom ${ROOT}, run exactly these three commands and report what they printed. Explore nothing ` +
|
|
108
113
|
`else and open no file other than .env.example at the workspace root.\n` +
|
|
109
114
|
`1. "node tools/ops.js onboard --json": the instance state, the workspace inventory, the opening ` +
|
|
110
115
|
`question and the dimensions still uncovered. Copy fresh, opening, followUps, the "need" of each ` +
|
|
111
116
|
`dimension, and every service with its path, its runtimes, its declared commands keeping the source ` +
|
|
112
117
|
`file each command came from, and the variable names its "env" carries. Add nothing it did not print.\n` +
|
|
113
118
|
`2. "node tools/ops.js check planning".\n` +
|
|
119
|
+
`3. "node tools/ops.js context planning --json": copy its inbox.ideas into inboxIdeas and its today ` +
|
|
120
|
+
`into today, both verbatim.\n` +
|
|
114
121
|
`The inventory already names every credential each service expects: never open a .env file to look for ` +
|
|
115
122
|
`more. Report those names in secrets and the services they point at in externals. A name the inventory ` +
|
|
116
123
|
`carries is declared, and saying otherwise is a claim the repository contradicts.`,
|
|
@@ -180,16 +187,32 @@ const drafted = await agent(
|
|
|
180
187
|
`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
188
|
`valor se propone acá. Además, una por cada sistema externo o MCP a conectar, y una por la autoridad ` +
|
|
182
189
|
`del runner, que hoy declara runner.allowPush=false.\n` +
|
|
183
|
-
`
|
|
184
|
-
`
|
|
190
|
+
`Devolvé en files cada archivo que tocaste, en assumptions cada supuesto que dejaste marcado y en ` +
|
|
191
|
+
`openQuestions las preguntas que quedaron abiertas, de la más a la menos importante. No las escribas en ` +
|
|
192
|
+
`${INBOX}: eso lo hace el paso siguiente.`,
|
|
185
193
|
{ schema: WRITTEN, label: 'contexto' },
|
|
186
194
|
)
|
|
187
195
|
if (!drafted) return stop('draft-unavailable', 'los borradores no devolvieron resultado')
|
|
188
196
|
|
|
189
197
|
phase('Epic')
|
|
190
198
|
|
|
199
|
+
// Las preguntas abiertas van al INBOX con tope, y el tope lo aplica el recorrido: por eso las escribe
|
|
200
|
+
// este paso con lo que el anterior devolvió, y no el anterior mientras las redactaba (caso 101). Las que
|
|
201
|
+
// no entran se dicen al cerrar, que es donde la persona que arrancó la instancia las lee.
|
|
202
|
+
// De qué vía salieron: este recorrido y la fecha del motor. Un arranque no tiene ni tarea ni informe que
|
|
203
|
+
// nombrar, así que su procedencia son esas dos partes y nada más (caso 115). El log de las que no entran
|
|
204
|
+
// se queda sin ella: quien lo lee está mirando la corrida que las produjo.
|
|
205
|
+
const ORIGIN = inboxOrigin('onboard', state.today)
|
|
206
|
+
const questions = (drafted.openQuestions || []).map(oneLine)
|
|
207
|
+
const inboxed = questions.slice(0, INBOX_CAP).map((one) => withOrigin(one, ORIGIN))
|
|
208
|
+
const INBOX_ASK = inboxed.length
|
|
209
|
+
? `Registrá además en la sección Ideas de ${INBOX}, sin promover, estas preguntas abiertas: ` +
|
|
210
|
+
`${JSON.stringify(inboxed)}. ${inboxAsk(['Ideas'], { ideas: state.inboxIdeas || [] }, ORIGIN)}\n\n`
|
|
211
|
+
: ''
|
|
212
|
+
|
|
191
213
|
const epic = await agent(
|
|
192
214
|
`${BASE}\n\n${EVIDENCE}\n\nSupuestos que quedaron escritos: ${JSON.stringify(drafted.assumptions || [])}\n\n` +
|
|
215
|
+
INBOX_ASK +
|
|
193
216
|
`Escribí en ${ROADMAP} la épica epic-001-<slug>.md siguiendo el contrato de ${P}/PROTOCOL.md: ` +
|
|
194
217
|
`frontmatter epic/title/status/service con status open, criterios **CN** observables, "## Contexto ` +
|
|
195
218
|
`relevante" con rutas reales e historias con (→ CN) y (service: ruta), cada una de menos de cuatro ` +
|
|
@@ -223,11 +246,17 @@ const assumptions = (drafted.assumptions || []).length
|
|
|
223
246
|
const humanActions = (drafted.humanActions || []).length
|
|
224
247
|
log(`Contexto escrito con ${assumptions} supuesto(s) por confirmar y ${humanActions} acción(es) humana(s) en ${HUMAN}.`)
|
|
225
248
|
log(`Épica en ${epic.file}, sin promover: revisala, promoví una historia a un hito del BACKLOG y corré /autobuild.`)
|
|
249
|
+
const unlisted = questions.slice(INBOX_CAP)
|
|
250
|
+
if (unlisted.length) {
|
|
251
|
+
log(`${unlisted.length} pregunta(s) abierta(s) más no entraron al INBOX (tope de ${INBOX_CAP}): ` +
|
|
252
|
+
unlisted.join(' · '))
|
|
253
|
+
}
|
|
226
254
|
|
|
227
255
|
return finish({
|
|
228
256
|
services: services.length,
|
|
229
257
|
assumptions,
|
|
230
258
|
humanActions,
|
|
231
259
|
epic: epic.file,
|
|
260
|
+
unlistedQuestions: unlisted,
|
|
232
261
|
promoted: false,
|
|
233
262
|
})
|
|
@@ -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; `
|
|
@@ -432,7 +445,9 @@ function install(root, name, output = console, options = {}) {
|
|
|
432
445
|
continue
|
|
433
446
|
}
|
|
434
447
|
if (status === 'ajeno' && ownFile) {
|
|
435
|
-
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)`)
|
|
436
451
|
} else if (status === 'al día') {
|
|
437
452
|
output.log(`= ${name}: ${resolved.item.target} ya está al día`)
|
|
438
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
|
}
|
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:
|