@ingeniomaps/cauce 0.98.0 → 0.99.1
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 +113 -0
- package/agents/roles/system/ai-product-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/analytics-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/backend-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/cloud-architect/learning/HISTORY.md +1 -0
- package/agents/roles/system/customer-success-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/data-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/data-governance-steward/learning/HISTORY.md +1 -0
- package/agents/roles/system/devops-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/engineering-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/engineering-manager/learning/sources.yaml +1 -1
- package/agents/roles/system/engineering-manager/references/operating-model.md +1 -1
- package/agents/roles/system/machine-learning-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/mlops-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/mobile-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/product-manager/learning/HISTORY.md +1 -0
- package/agents/roles/system/site-reliability-engineer/learning/HISTORY.md +1 -0
- package/agents/roles/system/software-architect/learning/HISTORY.md +1 -0
- package/agents/roles/system/ui-designer/learning/HISTORY.md +1 -0
- package/automatization/hooks/README.md +6 -2
- package/automatization/runners/antigravity/README.md +4 -1
- package/automatization/runners/antigravity/hook.js +84 -22
- package/automatization/runners/antigravity/manifest.json +1 -0
- package/automatization/shared/acceptance.js +26 -0
- package/automatization/workflows/agent-promote.js +3 -1
- package/automatization/workflows/autobuild.js +69 -26
- package/engine/agents/learning-seal.js +20 -1
- package/engine/automation/index.js +11 -2
- package/engine/automation/registration.js +89 -0
- package/engine/cli/args.js +1 -1
- package/engine/cli/catalog.js +8 -2
- package/engine/cli/ops.js +1 -1
- package/engine/cli/validate.js +3 -0
- package/engine/cli/wiring.js +1 -1
- package/engine/config/validate.js +30 -11
- package/engine/core/changelog.js +60 -1
- package/engine/core/migrations.js +275 -0
- package/engine/core/repos.js +18 -4
- package/engine/hooks/approval.js +77 -14
- package/engine/hooks/chat.js +85 -27
- package/engine/hooks/files.js +9 -107
- package/engine/hooks/input.js +67 -11
- package/engine/hooks/migrations.js +93 -0
- package/engine/hooks/push.js +5 -3
- package/engine/hooks/run.js +10 -5
- package/engine/hooks/secrets-shell.js +2 -1
- package/engine/hooks/self-approval.js +39 -22
- package/engine/hooks/verify.js +79 -22
- package/engine/planning/acceptance.js +18 -0
- package/engine/planning/contracts.js +78 -45
- package/engine/schemas/ops-config.schema.json +9 -0
- package/package.json +1 -1
- package/template/AGENTS.md +11 -2
- package/template/planning/PROTOCOL.md +6 -2
|
@@ -28,6 +28,7 @@ export const meta = {
|
|
|
28
28
|
|
|
29
29
|
{{INCLUDE:shared/workflow-root.js}}
|
|
30
30
|
{{INCLUDE:shared/inbox.js}}
|
|
31
|
+
{{INCLUDE:shared/acceptance.js}}
|
|
31
32
|
const CONFIG = `${ROOT}/ops.config.json`
|
|
32
33
|
const P = `${ROOT}/planning`
|
|
33
34
|
const ORG = `${ROOT}/organization`
|
|
@@ -190,19 +191,28 @@ const REVIEWED = { ...DECISION, required: [...DECISION.required, 'rules'],
|
|
|
190
191
|
// menos —o que ni existe— sale verde igual, y el guard de verify tampoco lo ve porque también mira exit
|
|
191
192
|
// codes. Por eso `uncovered` se contrasta contra la aceptación leyendo el fuente, no la salida (R9).
|
|
192
193
|
const VERIFY = {
|
|
193
|
-
type: 'object', additionalProperties: false, required: ['passed', 'commands', 'details', 'uncovered'],
|
|
194
|
+
type: 'object', additionalProperties: false, required: ['passed', 'commands', 'details', 'uncovered', 'covered'],
|
|
194
195
|
properties: {
|
|
195
196
|
passed: { type: 'boolean' }, details: { type: 'string' },
|
|
196
|
-
//
|
|
197
|
-
// el propio recorrido puede hacer, a
|
|
198
|
-
//
|
|
197
|
+
// Tres causas que se leen igual en el resultado y piden cosas distintas: a una le falta trabajo que
|
|
198
|
+
// el propio recorrido puede hacer, a otra le falta una decisión que no es suya, y la tercera no tiene
|
|
199
|
+
// prueba posible porque su entregable no se ejecuta —un ADR, una política—. Sin separarlas, la corrida
|
|
200
|
+
// frena por las tres, una persona resuelve lo que se resolvía solo y la tarea de decisión no cierra
|
|
201
|
+
// nunca (caso 189). `reason` es lo que Done escribe en `tests: n/a — <razón>`.
|
|
199
202
|
uncovered: { type: 'array', items: { type: 'object', additionalProperties: false,
|
|
200
203
|
required: ['criterion', 'cause'],
|
|
201
204
|
properties: {
|
|
202
205
|
criterion: { type: 'string' },
|
|
203
|
-
cause: { type: 'string', enum: ['missing-test', 'ambiguous'] },
|
|
206
|
+
cause: { type: 'string', enum: ['missing-test', 'ambiguous', 'no-surface'] },
|
|
207
|
+
reason: { type: 'string' },
|
|
204
208
|
},
|
|
205
209
|
} },
|
|
210
|
+
// El mapeo que Verify arma al contrastar y del que sale `tests: CN → prueba`. Sin viajar, Done lo
|
|
211
|
+
// componía de memoria (hallazgo del 189).
|
|
212
|
+
covered: { type: 'array', items: { type: 'object', additionalProperties: false,
|
|
213
|
+
required: ['criterion', 'test'],
|
|
214
|
+
properties: { criterion: { type: 'string' }, test: { type: 'string' } },
|
|
215
|
+
} },
|
|
206
216
|
commands: { type: 'array', items: { type: 'object', required: ['cmd', 'exitCode'], properties: {
|
|
207
217
|
cmd: { type: 'string' }, exitCode: { type: 'integer' }, note: { type: 'string' },
|
|
208
218
|
ranTests: { type: 'boolean' },
|
|
@@ -582,13 +592,13 @@ while (rounds++ < MAX_TASKS) {
|
|
|
582
592
|
// por eso perder la carrera no es un error: se relee y se sigue con la que quedó libre.
|
|
583
593
|
if (!planning.claimed && !planning.wipActive) {
|
|
584
594
|
phase('Claim')
|
|
585
|
-
const
|
|
595
|
+
const claim = await write(
|
|
586
596
|
`Corré "node tools/ops.js claim ${P} ${task.id}" desde ${ROOT}. No escribas ningún archivo vos: lo ` +
|
|
587
597
|
`escribe el comando. claimed=true sólo con exit 0; si falla porque la tomó otro, claimed=false y ` +
|
|
588
598
|
`copiá el mensaje en details.`,
|
|
589
599
|
{ schema: CLAIM, label: `claim:${task.id}` },
|
|
590
600
|
)
|
|
591
|
-
if (!
|
|
601
|
+
if (!claim || !claim.claimed) {
|
|
592
602
|
planning = await readContext()
|
|
593
603
|
if (!planning) return stop('context-unavailable', `no se pudo releer el estado de ${P}`)
|
|
594
604
|
// Perder la carrera es legítimo y se ve en que la cola pasa a ofrecer **otra** tarea: quien la
|
|
@@ -607,7 +617,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
607
617
|
if (planning.hasTask && planning.slug === task.id && !planning.claimed) {
|
|
608
618
|
return stop('claim-stuck', `${task.id} sigue siendo la próxima tarea y no se pudo reclamar. `
|
|
609
619
|
+ `context la ofrece y claim la rechaza, así que repetir no cambia nada. `
|
|
610
|
-
+ `El reclamo contestó: ${(
|
|
620
|
+
+ `El reclamo contestó: ${(claim && claim.details) || '(sin detalle)'}`)
|
|
611
621
|
}
|
|
612
622
|
// Con qué sigue, que es lo que cambia respecto de lo que esperaba quien autorizó la corrida: se
|
|
613
623
|
// pidió un hito y se va a construir otra tarea de ese hito. Sin decirlo, el cambio sólo aparece al
|
|
@@ -721,12 +731,12 @@ while (rounds++ < MAX_TASKS) {
|
|
|
721
731
|
|
|
722
732
|
const planRejected = async (reason, unit, found) => {
|
|
723
733
|
const detail = found.join('; ') || 'sin condiciones nombradas'
|
|
724
|
-
const
|
|
734
|
+
const note = await registerHuman(
|
|
725
735
|
`Registrá ${unit.id} en ${HUMAN}: nadie pudo escribir un plan que sobreviva a la crítica. `
|
|
726
736
|
+ `Motivo: ${detail}. La acción humana es revisar si la unidad son dos resultados con vidas `
|
|
727
737
|
+ `distintas y partirla, o dejarla entera con la razón escrita.`, 'plan-human', unit.id)
|
|
728
738
|
await releaseBlocked()
|
|
729
|
-
return stop(reason, `${detail}${
|
|
739
|
+
return stop(reason, `${detail}${note}`)
|
|
730
740
|
}
|
|
731
741
|
|
|
732
742
|
if (!planning.wipActive) {
|
|
@@ -741,11 +751,11 @@ while (rounds++ < MAX_TASKS) {
|
|
|
741
751
|
)
|
|
742
752
|
if (!ready) return stop('agent-unavailable', 'Ready no devolvió resultado')
|
|
743
753
|
if (!ready.ready) {
|
|
744
|
-
const
|
|
754
|
+
const note = await registerHuman(
|
|
745
755
|
`Registrá ${task.id} en ${HUMAN} con el motivo y una acción humana exacta: ${ready.reason}.`,
|
|
746
756
|
'ready-human', task.id)
|
|
747
757
|
await releaseBlocked()
|
|
748
|
-
return stop('not-ready', `${ready.reason}${
|
|
758
|
+
return stop('not-ready', `${ready.reason}${note}`)
|
|
749
759
|
}
|
|
750
760
|
if (ready.refinedAcceptance) task.acceptance = ready.refinedAcceptance
|
|
751
761
|
}
|
|
@@ -1045,11 +1055,11 @@ while (rounds++ < MAX_TASKS) {
|
|
|
1045
1055
|
if (filed.length) {
|
|
1046
1056
|
// La nota que devuelve viaja al hecho: sin ella la entrega afirma una fila que el disco no tiene,
|
|
1047
1057
|
// que es el caso 087 entrando por otra puerta.
|
|
1048
|
-
const
|
|
1058
|
+
const note = await registerHuman(`Registrá en ${HUMAN} una fila por cada decisión que la revisión de `
|
|
1049
1059
|
+ `${task.id} dejó abierta, con qué la cierra y quién puede tomarla. La primera columna nunca es `
|
|
1050
1060
|
+ `${task.id} —el porqué es el mismo que en Build—: va la épica, el hito o el recorrido al que `
|
|
1051
1061
|
+ `alcanza. No inventes responsables ni fechas: ${JSON.stringify(filed)}`, 'review-human')
|
|
1052
|
-
decidedNote = `${
|
|
1062
|
+
decidedNote = `${note}`
|
|
1053
1063
|
}
|
|
1054
1064
|
reviewFact = `${review.verdict} por ${cast.review}, sobre ${review.consulted.join(', ')}`
|
|
1055
1065
|
+ (filed.length ? ` · ${filed.length} decisión(es) registrada(s)${decidedNote}` : '')
|
|
@@ -1083,11 +1093,25 @@ while (rounds++ < MAX_TASKS) {
|
|
|
1083
1093
|
}
|
|
1084
1094
|
|
|
1085
1095
|
phase('Verify')
|
|
1096
|
+
// Lo declarado `(fuera de verify: …)` se separa acá y no se le explica a Verify: la aceptación es texto
|
|
1097
|
+
// conocido antes de preguntar, y dejar que el modelo reconozca la marca en la respuesta es apostar a que
|
|
1098
|
+
// enumere los criterios con el mismo corte. `check` ofrecía la marca y el recorrido la mandaba igual,
|
|
1099
|
+
// así que la condición terminaba en `uncovered` y la corrida en `verify-hollow` (caso 195).
|
|
1100
|
+
const conditions = acceptanceConditions(task.acceptance)
|
|
1101
|
+
const outOfVerify = conditions.filter((one) => OUT_OF_VERIFY.test(one))
|
|
1102
|
+
const checkable = outOfVerify.length
|
|
1103
|
+
? conditions.filter((one) => !OUT_OF_VERIFY.test(one)).join('; ')
|
|
1104
|
+
|| 'ninguna: todas se declararon fuera de verify'
|
|
1105
|
+
: task.acceptance
|
|
1086
1106
|
const VERIFY_ASK = `${asRole(cast.verify)}Abrí el fuente de los tests que la tarea agregó o cambió y ` +
|
|
1087
1107
|
`contrastá cada criterio ` +
|
|
1088
1108
|
`de aceptación contra sus aserciones: en uncovered va el criterio que ningún test codifica, con su causa ` +
|
|
1089
1109
|
`—missing-test si el test falta o no asercia la propiedad, ambiguous si el criterio no dice qué habría ` +
|
|
1090
|
-
`que aserciar
|
|
1110
|
+
`que aserciar, no-surface si se cumple en un artefacto que no se ejecuta, como un documento o una ` +
|
|
1111
|
+
`decisión escrita, y con reason diciendo cuál—. no-surface vale sólo si la tarea no tocó ningún archivo ` +
|
|
1112
|
+
`que no termine en ${NON_EXECUTABLE.join(', ')}; con cualquier otro en el diff es missing-test. En ` +
|
|
1113
|
+
`covered va cada criterio que un test sí codifica, con el nombre de ese test. ` +
|
|
1114
|
+
`Un test que pasa sin aserciarla no la cubre. Después corré los gates reales de ${task.service}. ` +
|
|
1091
1115
|
// Descubrir la puerta es trabajo de modelo repetido en cada tarea sobre una respuesta que no cambia,
|
|
1092
1116
|
// y encima adivinable: el proyecto la declara en `verify` y ahí deja de adivinarse. Cuando no la
|
|
1093
1117
|
// declara se vuelve a descubrir, que es lo que pasaba siempre.
|
|
@@ -1100,7 +1124,7 @@ while (rounds++ < MAX_TASKS) {
|
|
|
1100
1124
|
`Leé los exit codes de verdad. ` +
|
|
1101
1125
|
`passed=true exige comandos corridos y ninguna regresión causada por la tarea. Marcá ranTests en el ` +
|
|
1102
1126
|
`comando que haya corrido las pruebas, sea cual sea su nombre. ` +
|
|
1103
|
-
`Aceptación: ${
|
|
1127
|
+
`Aceptación: ${checkable}.`
|
|
1104
1128
|
let verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
|
|
1105
1129
|
if (!verified) return stop('agent-unavailable', 'Verify no devolvió resultado')
|
|
1106
1130
|
// Un criterio que nadie sabe cómo aserciar no es trabajo que falta sino una definición que falta, y
|
|
@@ -1108,14 +1132,18 @@ while (rounds++ < MAX_TASKS) {
|
|
|
1108
1132
|
// hacer parar a una persona por eso le cobra una interrupción por algo que se resolvía solo.
|
|
1109
1133
|
const ambiguous = verified.uncovered.find((entry) => entry.cause === 'ambiguous')
|
|
1110
1134
|
if (ambiguous) {
|
|
1111
|
-
const
|
|
1135
|
+
const note = await registerHuman(
|
|
1112
1136
|
`Registrá ${task.id} en ${HUMAN}: el criterio "${ambiguous.criterion}" no dice qué habría ` +
|
|
1113
1137
|
`que aserciar, y hace falta la decisión que lo fija.`, 'verify-human', task.id)
|
|
1114
|
-
return stop('acceptance-ambiguous', `${ambiguous.criterion}${
|
|
1138
|
+
return stop('acceptance-ambiguous', `${ambiguous.criterion}${note}`)
|
|
1115
1139
|
}
|
|
1116
|
-
|
|
1140
|
+
// Lo que no tiene superficie no frena ni rebota: viaja a Done, que lo escribe como `tests: n/a`. Se filtra
|
|
1141
|
+
// por exclusión y no por `missing-test` para que una causa que no se conozca siga frenando (R27). Que
|
|
1142
|
+
// el modelo no lo use para cerrar sin pruebas lo sostiene `check`, que mira qué tocó el commit.
|
|
1143
|
+
const lacking = () => verified.uncovered.filter((entry) => entry.cause !== 'no-surface')
|
|
1144
|
+
if (lacking().length) {
|
|
1117
1145
|
await run(`${asRole(cast.build)}Escribí sólo las pruebas que faltan en ${task.id}, con el mismo rojo ` +
|
|
1118
|
-
`previo, y no toques el código de producción: ${
|
|
1146
|
+
`previo, y no toques el código de producción: ${lacking().map((e) => e.criterion).join('; ')}`,
|
|
1119
1147
|
{ label: 'missing-tests' })
|
|
1120
1148
|
verified = await run(VERIFY_ASK, { schema: VERIFY, label: 'verify' })
|
|
1121
1149
|
if (!verified) return stop('agent-unavailable', 'la segunda pasada de Verify no devolvió resultado')
|
|
@@ -1127,21 +1155,29 @@ while (rounds++ < MAX_TASKS) {
|
|
|
1127
1155
|
if (build.redFirst.length && !ranTests) {
|
|
1128
1156
|
return stop('verify-untested', `${task.id} escribió pruebas y ningún gate corrió una`)
|
|
1129
1157
|
}
|
|
1130
|
-
if (
|
|
1131
|
-
return stop('verify-hollow', `sin test que lo codifique: ${
|
|
1158
|
+
if (lacking().length) {
|
|
1159
|
+
return stop('verify-hollow', `sin test que lo codifique: ${lacking().map((e) => e.criterion).join('; ')}`)
|
|
1132
1160
|
}
|
|
1161
|
+
const noSurface = verified.uncovered.filter((entry) => entry.cause === 'no-surface')
|
|
1162
|
+
const covered = verified.covered || []
|
|
1163
|
+
// Toda la tarea sin superficie: no hay comportamiento que ejercitar, y saltear QA en silencio dejaría sin
|
|
1164
|
+
// mirar lo único que se puede mirar, que el documento esté y diga lo que la aceptación enumera.
|
|
1165
|
+
const onlyDocument = noSurface.length > 0 && !covered.length
|
|
1133
1166
|
|
|
1134
1167
|
// QA ejercita comportamiento, y lo mecánico no lo cambia: el valor literal que la aceptación nombra
|
|
1135
1168
|
// ya lo comprobó Verify contra el test, y en `directo` además lo mira el revisor que nombra el cast.
|
|
1136
1169
|
let qa = { passed: true, evidence: 'carril mecánico: la aceptación queda comprobada en Verify' }
|
|
1137
1170
|
if (!mechanical) {
|
|
1138
1171
|
phase('QA')
|
|
1139
|
-
qa = await run(
|
|
1140
|
-
`${asRole(cast.qa)}${
|
|
1172
|
+
qa = await run(onlyDocument
|
|
1173
|
+
? `${asRole(cast.qa)}${task.id} no tiene superficie ejecutable: comprobá que el documento existe y cubre ` +
|
|
1174
|
+
`cada elemento que la aceptación enumera, y en evidence decí cuáles encontraste y dónde. ` +
|
|
1175
|
+
`Aceptación: ${checkable}.`
|
|
1176
|
+
: `${asRole(cast.qa)}${lite
|
|
1141
1177
|
? 'Hacé la comprobación de aceptación real más barata'
|
|
1142
1178
|
: 'Ejercitá el comportamiento real que ve quien lo usa'} para ` +
|
|
1143
1179
|
`${task.id}. Las pruebas unitarias solas no son QA. Levantá el mínimo runtime necesario y bajalo ` +
|
|
1144
|
-
`después. Aceptación: ${
|
|
1180
|
+
`después. Aceptación: ${checkable}.`,
|
|
1145
1181
|
{ schema: QA, label: 'qa' },
|
|
1146
1182
|
)
|
|
1147
1183
|
if (!qa) return stop('agent-unavailable', 'QA no devolvió resultado')
|
|
@@ -1169,7 +1205,14 @@ while (rounds++ < MAX_TASKS) {
|
|
|
1169
1205
|
`"node tools/ops.js release ${P} ${task.id}". En decisions no nombres una fase ni un cargo ` +
|
|
1170
1206
|
`que no figure en estos hechos. Hechos: lane=${planning.lane || 'sin clasificar'}; ` +
|
|
1171
1207
|
`review=${reviewFact}; fases=${ran.join(' → ')}; build=${build.summary}; ` +
|
|
1172
|
-
`verify=${JSON.stringify(verified.commands)};
|
|
1208
|
+
`verify=${JSON.stringify(verified.commands)}; cubiertos=${JSON.stringify(covered)}; ` +
|
|
1209
|
+
(noSurface.length ? `sin-superficie=${JSON.stringify(noSurface.map(({ criterion, reason }) => ({
|
|
1210
|
+
criterion, reason: reason || 'no se ejecuta' })))}; ` : '') +
|
|
1211
|
+
(outOfVerify.length ? `fuera-de-verify=${JSON.stringify(outOfVerify)}; ` : '') +
|
|
1212
|
+
`qa=${qa.evidence}; commit=${commit.hash || commit.reason}. En tests rastreá cada criterio con la ` +
|
|
1213
|
+
`prueba que cubiertos le asigna` +
|
|
1214
|
+
(noSurface.length ? ', y los de sin-superficie con tests: n/a — <razón>' : '') +
|
|
1215
|
+
(outOfVerify.length ? '; cada condición de fuera-de-verify queda cumplida en tests, qa o commit' : '') + '.',
|
|
1173
1216
|
{ label: 'done' },
|
|
1174
1217
|
)
|
|
1175
1218
|
completed.push(task.id)
|
|
@@ -14,6 +14,8 @@ const {
|
|
|
14
14
|
isoDate, proposalFiles, proposalState, assertWritable, lastOfPeriod, undecided, SIGNED,
|
|
15
15
|
} = require('./learning-files')
|
|
16
16
|
const { section } = require('../planning/parser')
|
|
17
|
+
const ownership = require('../core/ownership')
|
|
18
|
+
const changelog = require('../core/changelog')
|
|
17
19
|
// La misma identidad con la que se reclama una tarea: quién es la persona, no qué runner corre.
|
|
18
20
|
const { owner } = require('../planning/claims')
|
|
19
21
|
|
|
@@ -28,7 +30,7 @@ const UNSEALED = /^-[ \t]*Estado:[ \t]*(?:pendiente|aprobada)[ \t]*$/mi
|
|
|
28
30
|
// aprobada con responsable — y «aprobada y aplicada» cumple eso—, así que volver a correrlo sobre una
|
|
29
31
|
// propuesta ya aplicada la aplicaba de nuevo. Como los cambios son aditivos por diseño, el resultado
|
|
30
32
|
// no es un error visible sino un contrato con cada viñeta y cada fuente duplicadas.
|
|
31
|
-
function seal(root, agent, period = '', kind = 'agent') {
|
|
33
|
+
function seal(root, agent, period = '', kind = 'agent', { note = true } = {}) {
|
|
32
34
|
const file = proposalFile(root, agent, period, kind)
|
|
33
35
|
const dir = path.dirname(file)
|
|
34
36
|
const text = fs.readFileSync(file, 'utf8')
|
|
@@ -66,9 +68,26 @@ function seal(root, agent, period = '', kind = 'agent') {
|
|
|
66
68
|
// así que el registro que la plantilla promete no existía nunca. Se escribe acá porque es determinista:
|
|
67
69
|
// la fecha, el documento, quién aprobó y qué dice que cambia, todo sale de lo que se acaba de sellar.
|
|
68
70
|
if (kind === 'flow') appendHistory(path.dirname(path.dirname(dir)), file, responsible.trim(), change)
|
|
71
|
+
// Sólo en el toolkit: ahí el cargo es del catálogo que se publica, y su cambio tiene que viajar en una
|
|
72
|
+
// versión. En una empresa el cargo es suyo y no hay CHANGELOG de Cauce que escribir. `note: false` es
|
|
73
|
+
// el mes revisado sin cambios, que se sella igual y no le cambia nada a quien actualiza.
|
|
74
|
+
if (kind === 'agent' && note && ownership.mode(root) === 'toolkit') {
|
|
75
|
+
const noted = changelog.noteAgentChange(root, {
|
|
76
|
+
agent, proposal: path.basename(file), summary: summarize(change), today: isoDate(new Date()),
|
|
77
|
+
})
|
|
78
|
+
return { file, already: false, noted }
|
|
79
|
+
}
|
|
69
80
|
return { file, already: false }
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// La primera idea de «Cambio propuesto», en una línea: el documento entero está en el cargo, y el
|
|
84
|
+
// CHANGELOG lo lee quien actualiza antes de decidir, no quien aplica.
|
|
85
|
+
function summarize(change, limit = 240) {
|
|
86
|
+
const first = change.split(/\n\s*\n/)[0].replace(/^\s*(?:\d+\.|[-*])\s+/, '').replace(/\s+/g, ' ').trim()
|
|
87
|
+
if (first.length <= limit) return first.replace(/[.:;]$/, '')
|
|
88
|
+
return `${first.slice(0, first.lastIndexOf(' ', limit)).replace(/[.,:;]$/, '')}…`
|
|
89
|
+
}
|
|
90
|
+
|
|
72
91
|
// Archivar es el tercer destino de una propuesta, y hasta ahora no existía: aplicarla, dejarla
|
|
73
92
|
// esperando, o **mirarla y decidir que no cambia nada**. Ese tercero se venía haciendo mergeando el PR
|
|
74
93
|
// sin firmar, que deja el documento en `proposed` para siempre — indistinguible de una que espera
|
|
@@ -25,6 +25,7 @@ const {
|
|
|
25
25
|
// Qué le falta a la superficie de automatización, que no comparte ayudantes con los tres verbos que
|
|
26
26
|
// escriben. Se reexporta para que sus consumidores sigan pidiéndoselo a este módulo.
|
|
27
27
|
const { check } = require('./check')
|
|
28
|
+
const { registrationProblems } = require('./registration')
|
|
28
29
|
|
|
29
30
|
// Ejecuta el puente del runner tal como él lo invoca, y desde otra carpeta. Instalado no es lo mismo que
|
|
30
31
|
// operativo: un bridge que el runner no puede lanzar —porque su ruta es relativa y el cwd es otro, o
|
|
@@ -48,7 +49,12 @@ function probeBridge(paths, runner) {
|
|
|
48
49
|
const launchDirs = [paths.install, path.dirname(script)]
|
|
49
50
|
for (const event of hookEvents) {
|
|
50
51
|
for (const cwd of launchDirs) {
|
|
51
|
-
|
|
52
|
+
// Una llamada inocua para el evento que la juzga, y ninguna para los demás: un `pre-files` con un
|
|
53
|
+
// comando no describe ningún archivo, y el puente niega lo que no sabe describir (caso 200). La
|
|
54
|
+
// entrada vacía es la forma legítima de no describir nada, y ejercita igual el arranque y la raíz.
|
|
55
|
+
const payload = JSON.stringify(event === 'pre-shell'
|
|
56
|
+
? { toolCall: { args: { CommandLine: 'ls', Cwd: paths.install } } }
|
|
57
|
+
: {})
|
|
52
58
|
const result = spawnSync(process.execPath, [script, event], { cwd, input: payload, encoding: 'utf8' })
|
|
53
59
|
let response = {}
|
|
54
60
|
try { response = JSON.parse((result.stdout || '').trim()) } catch { response = {} }
|
|
@@ -68,7 +74,9 @@ function probeBridge(paths, runner) {
|
|
|
68
74
|
return problems
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
// `afterInstall` es la llamada con la que cierra `install`: ahí una copia registrada vieja es el paso que sigue
|
|
78
|
+
// —registrar lo que se acaba de instalar—, no una instalación rota, así que se avisa en vez de fallar.
|
|
79
|
+
function doctor(root, name, output = console, { afterInstall = false } = {}) {
|
|
72
80
|
const runner = runnerManifest(root, name)
|
|
73
81
|
const paths = runnerPaths(root, name, runner)
|
|
74
82
|
const errors = []
|
|
@@ -151,6 +159,7 @@ function doctor(root, name, output = console) {
|
|
|
151
159
|
}
|
|
152
160
|
// Un puente que no responde niega cada llamada del runner: es error, no advertencia.
|
|
153
161
|
for (const problem of probeBridge(paths, runner)) errors.push(problem)
|
|
162
|
+
for (const problem of registrationProblems(paths, runner)) (afterInstall ? warnings : errors).push(problem)
|
|
154
163
|
|
|
155
164
|
const executable = spawnSync(
|
|
156
165
|
'sh',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// La copia que el runner ejecuta, cuando no es la del workspace. `agy` corre la que registra
|
|
4
|
+
// `agy plugin install`, una por usuario y no por proyecto, así que el plugin del workspace puede estar
|
|
5
|
+
// perfecto mientras el runner ejecuta el de otro proyecto, de otra versión o roto. `doctor` sondeaba sólo
|
|
6
|
+
// el del workspace y decía «operativo» con cada llamada del runner fallando (caso 201).
|
|
7
|
+
//
|
|
8
|
+
// El runner declara dónde vive esa copia —`activation.registered` en su manifiesto—, y sin esa
|
|
9
|
+
// declaración esto no dice nada: no hay copia aparte que mirar.
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs')
|
|
12
|
+
const os = require('node:os')
|
|
13
|
+
const path = require('node:path')
|
|
14
|
+
const { spawnSync } = require('node:child_process')
|
|
15
|
+
|
|
16
|
+
function registeredCopy(runner) {
|
|
17
|
+
const declared = runner.activation && runner.activation.registered
|
|
18
|
+
return declared ? declared.replace(/^~(?=\/|$)/, os.homedir()) : ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Los archivos del plugin, relativos a su carpeta: los que el adaptador entrega bajo ella, más su
|
|
22
|
+
// configuración de hooks.
|
|
23
|
+
function pluginFiles(paths, runner, pluginDir) {
|
|
24
|
+
const targets = [...(runner.artifacts || []).map((item) => path.resolve(paths.install, item.target)),
|
|
25
|
+
paths.configTarget]
|
|
26
|
+
return [...new Set(targets)]
|
|
27
|
+
.filter((file) => file.startsWith(`${pluginDir}${path.sep}`))
|
|
28
|
+
.map((file) => path.relative(pluginDir, file))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Lanza cada comando de la configuración registrada como lo lanza el runner: el texto literal, desde la
|
|
32
|
+
// carpeta del plugin. Así se ve el wiring que no resuelve, que ejecutando el puente directo no aparece.
|
|
33
|
+
// Con tope: la copia registrada puede ser vieja o estar rota, y una que no contesta colgaba a `doctor`. Diez
|
|
34
|
+
// segundos sobran para una copia sana —arranca y contesta en menos de uno— y con una que no contesta ya
|
|
35
|
+
// alcanza para el diagnóstico, así que el sondeo corta ahí en vez de esperar el tope una vez por evento.
|
|
36
|
+
const LAUNCH_LIMIT_MS = 10000
|
|
37
|
+
|
|
38
|
+
function launch(dir, event, command) {
|
|
39
|
+
const payload = event === 'pre-shell' ? { toolCall: { args: { CommandLine: 'ls' } } } : {}
|
|
40
|
+
const input = JSON.stringify(payload)
|
|
41
|
+
const result = spawnSync('sh', ['-c', command],
|
|
42
|
+
{ cwd: dir, input, encoding: 'utf8', timeout: LAUNCH_LIMIT_MS, killSignal: 'SIGKILL' })
|
|
43
|
+
if (result.error && result.error.code === 'ETIMEDOUT') return `no respondió en ${LAUNCH_LIMIT_MS / 1000} s`
|
|
44
|
+
let response = {}
|
|
45
|
+
try { response = JSON.parse((result.stdout || '').trim()) } catch { response = {} }
|
|
46
|
+
const healthy = event === 'stop' ? 'stop' : 'allow'
|
|
47
|
+
if (response.decision === healthy && !response.reason) return ''
|
|
48
|
+
const stderr = (result.stderr || '').split('\n').find((line) => /Error|error/.test(line)) || ''
|
|
49
|
+
return response.reason || (response.decision ? `respondió ${response.decision}` : stderr.trim() || 'sin respuesta')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Qué impide que la copia registrada sea esta instalación funcionando. Vacío si el runner no declara una
|
|
53
|
+
// copia aparte o si todavía no hay ninguna registrada —eso lo dice `activated`—.
|
|
54
|
+
function registrationProblems(paths, runner) {
|
|
55
|
+
const dir = registeredCopy(runner)
|
|
56
|
+
if (!dir || !fs.existsSync(dir)) return []
|
|
57
|
+
const bridge = (runner.artifacts || []).find((item) => item.target.endsWith('hook.js'))
|
|
58
|
+
if (!bridge) return []
|
|
59
|
+
const pluginDir = path.dirname(path.resolve(paths.install, bridge.target))
|
|
60
|
+
const differ = pluginFiles(paths, runner, pluginDir).filter((file) => {
|
|
61
|
+
const ours = path.join(pluginDir, file)
|
|
62
|
+
const theirs = path.join(dir, file)
|
|
63
|
+
if (!fs.existsSync(ours)) return false
|
|
64
|
+
return !fs.existsSync(theirs) || !fs.readFileSync(ours).equals(fs.readFileSync(theirs))
|
|
65
|
+
})
|
|
66
|
+
const problems = []
|
|
67
|
+
if (differ.length) {
|
|
68
|
+
const shown = differ.length > 5 ? [...differ.slice(0, 5), `y ${differ.length - 5} más`] : differ
|
|
69
|
+
problems.push(`${runner.command} ejecuta ${dir}, que no es esta instalación: difiere(n) ${shown.join(', ')}. `
|
|
70
|
+
+ `Corré desde ${paths.install}: ${runner.activation.hint}`)
|
|
71
|
+
}
|
|
72
|
+
let config = {}
|
|
73
|
+
try { config = JSON.parse(fs.readFileSync(path.join(dir, path.basename(paths.configTarget)), 'utf8')) } catch {
|
|
74
|
+
return problems
|
|
75
|
+
}
|
|
76
|
+
const commands = [...new Set(JSON.stringify(config).match(/"command":"([^"]*hook\.js [a-z-]+)"/g) || [])]
|
|
77
|
+
.map((entry) => entry.slice(11, -1))
|
|
78
|
+
for (const command of commands) {
|
|
79
|
+
const event = command.split(' ').pop()
|
|
80
|
+
const failure = launch(dir, event, command)
|
|
81
|
+
if (failure) {
|
|
82
|
+
problems.push(`la copia registrada no responde a ${event} lanzada como la lanza ${runner.command}: ${failure}`)
|
|
83
|
+
}
|
|
84
|
+
if (/^no respondió/.test(failure)) break
|
|
85
|
+
}
|
|
86
|
+
return problems
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { registrationProblems }
|
package/engine/cli/args.js
CHANGED
|
@@ -36,7 +36,7 @@ const FLAGS = {
|
|
|
36
36
|
integration: ['--fixture'],
|
|
37
37
|
secrets: [],
|
|
38
38
|
automation: ['--force'],
|
|
39
|
-
learn: ['--flow', '--proposal', '--applied', '--archived', '--period', '--reason'],
|
|
39
|
+
learn: ['--flow', '--proposal', '--applied', '--unchanged', '--archived', '--period', '--reason'],
|
|
40
40
|
evaluate: ['--cases', '--json', '--bench', '--force', '--record', '--flow'],
|
|
41
41
|
flow: ['--json'],
|
|
42
42
|
}
|
package/engine/cli/catalog.js
CHANGED
|
@@ -104,11 +104,17 @@ function learn(agent, cli) {
|
|
|
104
104
|
: `✓ ${relative} queda archivada: ${porque}`)
|
|
105
105
|
}
|
|
106
106
|
if (cli.has('--applied')) {
|
|
107
|
-
const result = L.seal(opsRoot(), agent, cli.value('--period'), kind)
|
|
107
|
+
const result = L.seal(opsRoot(), agent, cli.value('--period'), kind, { note: !cli.has('--unchanged') })
|
|
108
108
|
const relative = path.relative(opsRoot(), result.file)
|
|
109
|
-
|
|
109
|
+
console.log(result.already
|
|
110
110
|
? `= ${relative} ya estaba aplicada`
|
|
111
111
|
: `✓ ${relative} queda aplicada: no se vuelve a aplicar`)
|
|
112
|
+
if (result.noted) {
|
|
113
|
+
console.log(result.noted.already
|
|
114
|
+
? `= CHANGELOG.md ya nombraba este cambio en ${result.noted.version}`
|
|
115
|
+
: `✓ CHANGELOG.md: el cambio sale en ${result.noted.version}`)
|
|
116
|
+
}
|
|
117
|
+
return
|
|
112
118
|
}
|
|
113
119
|
// Un recorrido no tiene informe de investigación: su propuesta se compone de los veredictos de sus
|
|
114
120
|
// propias corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre su
|
package/engine/cli/ops.js
CHANGED
|
@@ -180,7 +180,7 @@ function usage() {
|
|
|
180
180
|
ops automation doctor <ops-root> claude|codex|gemini|antigravity
|
|
181
181
|
ops automation install <ops-root> claude|codex|gemini|antigravity
|
|
182
182
|
ops automation uninstall <ops-root> claude|codex|gemini|antigravity
|
|
183
|
-
ops learn <agent|flow> [--flow] [--proposal [--period <AAAA-MM>]] [--applied [--period <AAAA-MM>]]
|
|
183
|
+
ops learn <agent|flow> [--flow] [--proposal [--period <AAAA-MM>]] [--applied [--period <AAAA-MM>] [--unchanged]]
|
|
184
184
|
ops learn <agent|flow> [--flow] --archived [--period <AAAA-MM>] [--reason <motivo>]
|
|
185
185
|
ops evaluate <agent|flow> [--flow] [--cases [--json]] [--bench [caso]] [--record [AAAA-MM-DD]]
|
|
186
186
|
ops agents list [ops-root] [--own|--system] [--json]
|
package/engine/cli/validate.js
CHANGED
|
@@ -26,6 +26,7 @@ const O = require('../core/ownership')
|
|
|
26
26
|
const TR = require('../core/trails')
|
|
27
27
|
const OB = require('../core/onboarding')
|
|
28
28
|
const C = require('../config/validate')
|
|
29
|
+
const MG = require('../core/migrations')
|
|
29
30
|
const CP = require('../config/paths')
|
|
30
31
|
const AG = require('../agents/catalog')
|
|
31
32
|
const RL = require('../automation/rules')
|
|
@@ -62,6 +63,7 @@ function check(dir, cli) {
|
|
|
62
63
|
if (!raw.includes('{{')) {
|
|
63
64
|
errors.push(...C.validateOpsConfig(config))
|
|
64
65
|
warnings.push(...C.configWarnings(config))
|
|
66
|
+
warnings.push(...MG.coverageWarnings(R.reposFor(path.dirname(configPath), '.'), config))
|
|
65
67
|
if (Array.isArray(config.workspaceRoots)) {
|
|
66
68
|
for (const workspace of config.workspaceRoots) {
|
|
67
69
|
if (workspace && workspace.name && workspace.path
|
|
@@ -108,6 +110,7 @@ function check(dir, cli) {
|
|
|
108
110
|
// corre en los cuatro carriles, incluidos los que saltean Ready (caso 140).
|
|
109
111
|
warnings.push(...PC.unverifiableAcceptance(milestones))
|
|
110
112
|
warnings.push(...R.coverageWarnings(path.resolve(root, '..'), done))
|
|
113
|
+
errors.push(...PC.surfaceWithoutTests(done.entries, R.commitFiles(path.resolve(root, '..')), new Set(adopted)))
|
|
111
114
|
// Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
|
|
112
115
|
// no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
|
|
113
116
|
// en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
|
package/engine/cli/wiring.js
CHANGED
|
@@ -283,7 +283,7 @@ function automation(action, rootArg, runnerName, cli) {
|
|
|
283
283
|
if (!runner.capabilities.nativeHooks) {
|
|
284
284
|
console.log(` ${runnerName} no expone hooks nativos; aplica guards como prechecks.`)
|
|
285
285
|
}
|
|
286
|
-
const result = A.doctor(root, runnerName)
|
|
286
|
+
const result = A.doctor(root, runnerName, console, { afterInstall: true })
|
|
287
287
|
if (result.errors.length) fail(`${runnerName}: instalación incompleta`, REFUSED)
|
|
288
288
|
console.log(`✓ ${runnerName}: adaptador operativo (${result.warnings.length} advertencia(s))`)
|
|
289
289
|
return
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { PATH_SHAPE, EXTENSION_SHAPE } = require('../core/migrations')
|
|
4
|
+
|
|
3
5
|
const MODES = ['embedded', 'sidecar', 'toolkit']
|
|
4
6
|
|
|
5
7
|
// Campos que existieron y se retiraron. Se nombran en vez de caer en «propiedad desconocida» porque
|
|
@@ -69,18 +71,35 @@ function validateMigrations(migrations, errors) {
|
|
|
69
71
|
return
|
|
70
72
|
}
|
|
71
73
|
for (const key of Object.keys(migrations)) {
|
|
72
|
-
if (
|
|
73
|
-
}
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
if (!['extensions', 'paths'].includes(key)) errors.push(`ops.config.json: migrations.${key} no está permitido`)
|
|
75
|
+
}
|
|
76
|
+
if ('extensions' in migrations) {
|
|
77
|
+
const declared = migrations.extensions
|
|
78
|
+
if (!Array.isArray(declared) || !declared.length) {
|
|
79
|
+
errors.push('ops.config.json: migrations.extensions debe listar al menos una extensión, o no estar')
|
|
80
|
+
} else {
|
|
81
|
+
for (const one of declared) {
|
|
82
|
+
if (typeof one !== 'string' || !EXTENSION_SHAPE.test(one)) {
|
|
83
|
+
errors.push(`ops.config.json: migrations.extensions "${one}" debe ser la extensión sin el punto `
|
|
84
|
+
+ 'y en minúscula, como "sql" o "ts"')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
79
88
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
// Las carpetas, por lo mismo que las extensiones y con una razón más: el guard las busca dentro de la
|
|
90
|
+
// ruta que recibe, que puede ser relativa, así que una absoluta o una que sale con `..` no nombraría
|
|
91
|
+
// nada que pudiera encontrar (caso 196).
|
|
92
|
+
if ('paths' in migrations) {
|
|
93
|
+
const paths = migrations.paths
|
|
94
|
+
if (!Array.isArray(paths) || !paths.length) {
|
|
95
|
+
errors.push('ops.config.json: migrations.paths debe listar al menos una carpeta, o no estar')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
for (const one of paths) {
|
|
99
|
+
if (typeof one !== 'string' || !PATH_SHAPE.test(one)) {
|
|
100
|
+
errors.push(`ops.config.json: migrations.paths ${JSON.stringify(one)} debe ser una carpeta relativa, `
|
|
101
|
+
+ 'sin barra al principio ni al final y sin «.» ni «..», como "migrations" o "alembic/versions"')
|
|
102
|
+
}
|
|
84
103
|
}
|
|
85
104
|
}
|
|
86
105
|
}
|
package/engine/core/changelog.js
CHANGED
|
@@ -47,4 +47,63 @@ function read(packageRoot) {
|
|
|
47
47
|
try { return fs.readFileSync(path.join(packageRoot, 'CHANGELOG.md'), 'utf8') } catch { return '' }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// La línea que `agent-promote` deja al aplicar un cargo, en la versión que todavía no salió. Sin ella el
|
|
51
|
+
// cambio quedaba en `main` sin versión que lo llevara: `release-pr.yml` sólo abre el PR cuando el
|
|
52
|
+
// CHANGELOG declara un número nuevo, y escribirlo era un paso que nadie tenía asignado.
|
|
53
|
+
//
|
|
54
|
+
// Abierta es la entrada más nueva si va por delante de `package.json`; si no, ya salió y se abre la
|
|
55
|
+
// minor siguiente. Minor porque cambia lo que recibe quien hace `upgrade`, que es la regla de este
|
|
56
|
+
// archivo. Varios cargos del mismo mes caen así en una sola versión.
|
|
57
|
+
function noteAgentChange(root, { agent, proposal, summary, today }) {
|
|
58
|
+
const file = path.join(root, 'CHANGELOG.md')
|
|
59
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n')
|
|
60
|
+
const released = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
|
|
61
|
+
const first = lines.findIndex((line) => /^##\s*\[\d/.test(line))
|
|
62
|
+
if (first < 0) throw new Error('CHANGELOG.md no tiene ninguna entrada con versión.')
|
|
63
|
+
const bullet = wrap(`- **\`${agent}\`**: ${summary} (propuesta \`${proposal}\`).`)
|
|
64
|
+
const top = lines[first].match(HEADING)[1]
|
|
65
|
+
if (compare(top, released) <= 0) {
|
|
66
|
+
const [major, minor] = released.split('.').map(Number)
|
|
67
|
+
const version = `${major}.${minor + 1}.0`
|
|
68
|
+
lines.splice(first, 0, `## [${version}] - ${today}`, '', '### Cargos', '', ...bullet, '')
|
|
69
|
+
fs.writeFileSync(file, lines.join('\n'))
|
|
70
|
+
return { version, already: false }
|
|
71
|
+
}
|
|
72
|
+
let end = lines.findIndex((line, index) => index > first && HEADING.test(line))
|
|
73
|
+
if (end < 0) end = lines.length
|
|
74
|
+
// Una propuesta ya anotada no se anota dos veces: `seal` puede volver a correr sobre un cuerpo sin sellar.
|
|
75
|
+
// Con los espacios normalizados porque la viñeta se envuelve, y el corte puede caer entre las dos mitades.
|
|
76
|
+
const entry = lines.slice(first, end).join(' ').replace(/\s+/g, ' ')
|
|
77
|
+
if (entry.includes(`- **\`${agent}\`**`) && entry.includes(`(propuesta \`${proposal}\`)`)) {
|
|
78
|
+
return { version: top, already: true }
|
|
79
|
+
}
|
|
80
|
+
const section = lines.findIndex((line, index) => index > first && index < end && /^###\s+Cargos\s*$/.test(line))
|
|
81
|
+
let at = end
|
|
82
|
+
if (section >= 0) {
|
|
83
|
+
at = lines.findIndex((line, index) => index > section && index < end && /^#{2,3}\s/.test(line))
|
|
84
|
+
if (at < 0) at = end
|
|
85
|
+
}
|
|
86
|
+
while (at > first + 1 && lines[at - 1].trim() === '') at--
|
|
87
|
+
lines.splice(at, 0, ...(section >= 0 ? bullet : ['', '### Cargos', '', ...bullet]))
|
|
88
|
+
fs.writeFileSync(file, lines.join('\n'))
|
|
89
|
+
return { version: top, already: false }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Al ancho del resto del archivo, con la sangría de continuación que ya usan sus viñetas.
|
|
93
|
+
function wrap(text, width = 110) {
|
|
94
|
+
const out = []
|
|
95
|
+
let line = ''
|
|
96
|
+
for (const word of text.split(/\s+/)) {
|
|
97
|
+
const next = line ? `${line} ${word}` : word
|
|
98
|
+
if (next.length > width && line) {
|
|
99
|
+
out.push(line)
|
|
100
|
+
line = ` ${word}`
|
|
101
|
+
} else {
|
|
102
|
+
line = next
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (line) out.push(line)
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { between, compare, entries, read, noteAgentChange }
|