@ingeniomaps/cauce 0.81.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 +128 -0
- package/automatization/hooks/README.md +2 -2
- 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 +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 +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 +3 -2
- package/engine/hooks/chat.js +59 -10
- package/engine/hooks/input.js +1 -11
- package/engine/hooks/push.js +147 -0
- package/engine/hooks/shell.js +7 -5
- 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 +10 -4
- package/template/planning/INBOX.md +2 -1
- package/template/planning/RECURRING.md +6 -5
- package/template/planning/rules/system/commits.md +3 -1
package/engine/core/ownership.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
const fs = require('node:fs')
|
|
9
9
|
const path = require('node:path')
|
|
10
10
|
|
|
11
|
+
// El paquete que corre: contra él se decide qué del runtime es una entrega de Cauce (casos 100 y 110).
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
|
|
13
|
+
|
|
11
14
|
// Archivos de los que el toolkit es único autor. Un proyecto que necesite cambiarlos no los
|
|
12
15
|
// edita: agrega una regla propia junto a las de `system/`, que sí sobrevive al upgrade.
|
|
13
16
|
const SYSTEM_FILES = [
|
|
@@ -176,6 +179,27 @@ function overrides(root) {
|
|
|
176
179
|
return found
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
// Las reglas que rigen una instancia, relativas a su raíz: cada `planning/rules/*.md` del proyecto y cada una de
|
|
183
|
+
// `system/` que el proyecto no sobrescribió. `overrides()` ya sabía cuál reemplaza a cuál y sólo servía para
|
|
184
|
+
// avisar; esto es lo que se entrega a quien trabaja (casos 099 y 105). Sólo el primer nivel, igual que `check`.
|
|
185
|
+
function effectiveRules(root) {
|
|
186
|
+
const dir = path.join(root, 'planning', 'rules')
|
|
187
|
+
const markdown = (sub) => {
|
|
188
|
+
try {
|
|
189
|
+
return fs.readdirSync(path.join(dir, sub), { withFileTypes: true })
|
|
190
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md'
|
|
191
|
+
&& !entry.name.startsWith('.'))
|
|
192
|
+
.map((entry) => entry.name).sort()
|
|
193
|
+
} catch { return [] }
|
|
194
|
+
}
|
|
195
|
+
const replaced = new Set(overrides(root).filter((one) => one.collection === 'planning/rules')
|
|
196
|
+
.map((one) => one.system))
|
|
197
|
+
return [
|
|
198
|
+
...markdown('system').filter((name) => !replaced.has(name)).map((name) => `planning/rules/system/${name}`),
|
|
199
|
+
...markdown('').map((name) => `planning/rules/${name}`),
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
// Rutas que el toolkit dejó de materializar. Sin esto una instancia arrastra para siempre lo que
|
|
180
204
|
// alguna versión suya copió: `upgrade` agrega y reemplaza, pero nunca quitaba nada.
|
|
181
205
|
// Cada archivo propio del molde, con cómo llega a una instancia que **ya existe**. `upgrade` sólo
|
|
@@ -302,7 +326,7 @@ function localChanges(root) {
|
|
|
302
326
|
for (const target of trackedPaths()) {
|
|
303
327
|
const dir = path.join(root, target)
|
|
304
328
|
if (!fs.existsSync(dir)) continue
|
|
305
|
-
for (const file of manifest.edited(root, target,
|
|
329
|
+
for (const file of manifest.edited(root, target, deliveredFiles(root, target))) changed.push(`${target}/${file}`)
|
|
306
330
|
}
|
|
307
331
|
// Los archivos sueltos del sistema entran por la misma puerta. Quedaban afuera, así que `upgrade`
|
|
308
332
|
// los reemplazaba en silencio: un cargo escribió el índice de ADR que el propio README le pedía
|
|
@@ -311,8 +335,47 @@ function localChanges(root) {
|
|
|
311
335
|
return changed
|
|
312
336
|
}
|
|
313
337
|
|
|
338
|
+
// Lo que el paquete que corre trae en una ruta del runtime. El resto de esa carpeta es de la empresa —un
|
|
339
|
+
// guard propio—, y no es una entrega: registrado, pasaba a «editado» en cuanto la empresa lo tocaba, y
|
|
340
|
+
// `upgrade --check` salía con 1 por algo que nunca fue de Cauce (caso 100).
|
|
341
|
+
function shippedFiles(relative) {
|
|
342
|
+
return new Set(treeFiles(path.join(PACKAGE_ROOT, sourceOf(relative))))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Lo que de una ruta rastreada cuenta como entregado: todo, salvo en el runtime, donde sólo lo que el
|
|
346
|
+
// paquete trae. Lo usan el registro de `init` y de `upgrade` y la detección de ediciones, que tienen que
|
|
347
|
+
// contar lo mismo.
|
|
348
|
+
function deliveredFiles(root, relative) {
|
|
349
|
+
const files = treeFiles(path.join(root, relative))
|
|
350
|
+
return RUNTIME_PATHS.includes(relative) ? files.filter((file) => shippedFiles(relative).has(file)) : files
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Lo que el paquete empieza a traer con un nombre que la instancia ya usaba para algo suyo: un guard propio
|
|
354
|
+
// que se llama como uno nuevo del toolkit (caso 110). Sin huella en el registro no cuenta como edición, así
|
|
355
|
+
// que copiar encima lo borraba sin decirlo. Sólo si el registro ya conoce la ruta: en una instancia
|
|
356
|
+
// anterior al registro, «sin huella» también es «lo entregó una versión vieja».
|
|
357
|
+
function collisions(root) {
|
|
358
|
+
const manifest = require('./manifest')
|
|
359
|
+
const recorded = manifest.read(root)
|
|
360
|
+
const found = []
|
|
361
|
+
for (const relative of RUNTIME_PATHS) {
|
|
362
|
+
if (!Object.keys(recorded).some((key) => key.startsWith(`${relative}/`))) continue
|
|
363
|
+
for (const file of shippedFiles(relative)) {
|
|
364
|
+
const local = path.join(root, relative, file)
|
|
365
|
+
if (recorded[`${relative}/${file}`] || !fs.existsSync(local)) continue
|
|
366
|
+
const shipped = path.join(PACKAGE_ROOT, sourceOf(relative), file)
|
|
367
|
+
if (manifest.digest(local) !== manifest.digest(shipped)) found.push(`${relative}/${file}`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return found
|
|
371
|
+
}
|
|
372
|
+
|
|
314
373
|
module.exports = {
|
|
315
374
|
RETIRED,
|
|
375
|
+
collisions,
|
|
376
|
+
deliveredFiles,
|
|
377
|
+
effectiveRules,
|
|
378
|
+
shippedFiles,
|
|
316
379
|
RETIRED_COMPARTIDO,
|
|
317
380
|
TEMPLATE_OWN,
|
|
318
381
|
TEMPLATE_PREFIXES,
|
package/engine/core/scan.js
CHANGED
|
@@ -171,17 +171,28 @@ function scan(root, skip = '') {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Dónde puede mirar una instancia: exactamente las raíces que declara,
|
|
174
|
+
// Dónde puede mirar una instancia: exactamente las raíces que declara, con el nombre que les puso. Sale
|
|
175
175
|
// de `ops.config.json` en vez de suponerse —el sidecar declara `..`, el embebido `.`— para que acotar las
|
|
176
176
|
// raíces acote también el escaneo, y para que nadie termine recorriendo la carpeta de al lado.
|
|
177
|
-
|
|
177
|
+
//
|
|
178
|
+
// El nombre cae a la carpeta cuando la raíz no lo declara: `check` lo exige, pero `scan` y `onboard`
|
|
179
|
+
// corren igual sobre una configuración que nunca pasó por ahí, y ahí el prefijo saldría `undefined`.
|
|
180
|
+
function declaredRoots(root) {
|
|
181
|
+
const fallback = [{ name: path.basename(root), dir: root }]
|
|
178
182
|
try {
|
|
179
183
|
const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
|
|
180
|
-
const declared = (config.workspaceRoots || []).map((entry) =>
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
const declared = (config.workspaceRoots || []).map((entry) => {
|
|
185
|
+
const dir = path.resolve(root, entry.path || '.')
|
|
186
|
+
return { name: String(entry.name || '').trim() || path.basename(dir), dir }
|
|
187
|
+
})
|
|
188
|
+
return declared.length ? declared : fallback
|
|
189
|
+
} catch { return fallback }
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
// Sólo las rutas. Es lo que mira quien acota una escritura, y `onboard --json` las emite tal cual en
|
|
193
|
+
// `roots`: darles forma de objeto habría cambiado ese contrato para quien no necesita el nombre.
|
|
194
|
+
const workspaceRoots = (root) => declaredRoots(root).map((one) => one.dir)
|
|
195
|
+
|
|
185
196
|
// Qué hay en las raíces declaradas, antes de que nadie razone sobre ello. La raíz ops se saltea: no es
|
|
186
197
|
// un servicio del proyecto, y su `package.json` sólo declara el motor.
|
|
187
198
|
// Los candidatos de una raíz, con el proyecto que vive en ella misma primero: un monolito declara sus
|
|
@@ -197,12 +208,15 @@ function candidates(workspace, skip = '') {
|
|
|
197
208
|
// Con varias raíces, cada repositorio es la raíz de su propio escaneo y su candidato principal se llama
|
|
198
209
|
// `.`: tres servicios con el mismo nombre y nada que los distinga. El prefijo los vuelve nombrables, que
|
|
199
210
|
// es la única forma de que una credencial pueda atribuirse a un servicio en vez de quedar suelta.
|
|
211
|
+
//
|
|
212
|
+
// El prefijo es el `name` declarado y no la carpeta, que es lo que dejaba a `gouduet/keycloak` y
|
|
213
|
+
// `hypixo/keycloak` llamándose las dos `keycloak` (caso 113). Que no se repita lo exige el validador.
|
|
200
214
|
function inventory(root) {
|
|
201
|
-
const roots =
|
|
202
|
-
if (roots.length === 1) return candidates(roots[0], root)
|
|
203
|
-
return roots.flatMap((
|
|
215
|
+
const roots = declaredRoots(root)
|
|
216
|
+
if (roots.length === 1) return candidates(roots[0].dir, root)
|
|
217
|
+
return roots.flatMap(({ name, dir }) => candidates(dir, root).map((service) => ({
|
|
204
218
|
...service,
|
|
205
|
-
path: service.path === '.' ?
|
|
219
|
+
path: service.path === '.' ? name : `${name}/${service.path}`,
|
|
206
220
|
})))
|
|
207
221
|
}
|
|
208
222
|
|
package/engine/hooks/approval.js
CHANGED
|
@@ -32,7 +32,8 @@ const CHAT = require('./chat')
|
|
|
32
32
|
const APPROVAL = '.ops-approval'
|
|
33
33
|
|
|
34
34
|
// Una ruta por línea, `#` para lo demás. El archivo ausente y el vacío son lo mismo: no hay nada
|
|
35
|
-
// aprobado, que es el estado normal.
|
|
35
|
+
// aprobado, que es el estado normal. Un push se aprueba igual, con la línea `push <remoto> <rama>`
|
|
36
|
+
// tal cual y sin patrones: `feat/*` convertiría una aprobación puntual en un permiso (caso 103).
|
|
36
37
|
function read(root) {
|
|
37
38
|
let text = ''
|
|
38
39
|
try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
|
|
@@ -81,4 +82,4 @@ function HOW(variable, lines, input) {
|
|
|
81
82
|
+ 'y apaga el guard para toda la sesión, que es por lo que no es la vía recomendada.'
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
module.exports = { APPROVAL, read, pending, HOW }
|
|
85
|
+
module.exports = { APPROVAL, read, pending, where, HOW }
|
package/engine/hooks/chat.js
CHANGED
|
@@ -45,10 +45,33 @@ function load(session) {
|
|
|
45
45
|
// Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
|
|
46
46
|
// antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
|
|
47
47
|
const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
|
|
48
|
-
|
|
48
|
+
// Un punto corta sólo si cierra la oración: el de `x.js` es parte del nombre, y cortar ahí dejaba el verbo de
|
|
49
|
+
// «agregá src/x.js a .ops-approval» en otra frase que la del archivo.
|
|
50
|
+
const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
|
|
49
51
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
+
// Y no negarlo tampoco alcanza: «¿para qué sirven las credentials?» o «el .env tiene algo raro» nombran el
|
|
53
|
+
// archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
|
|
54
|
+
// español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
|
|
55
|
+
// quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
|
|
56
|
+
const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
|
|
57
|
+
+ 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
|
|
58
|
+
+ 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
|
|
59
|
+
+ 'actualizar modifica modificar revisa revisar mira mirar fijate chequea verifica verificar instala instalar '
|
|
60
|
+
+ 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
|
|
61
|
+
+ 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
|
|
62
|
+
+ 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
|
|
63
|
+
+ 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
|
|
64
|
+
+ 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
|
|
65
|
+
+ 'rename move restore skip').split(' '))
|
|
66
|
+
const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
|
|
67
|
+
|
|
68
|
+
function asks(clause) {
|
|
69
|
+
const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
|
|
70
|
+
return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cada aparición del nombre en el texto: si va negada y si su frase pide algo. Un nombre tiene que estar
|
|
74
|
+
// entero: `.env` no aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
|
|
52
75
|
function mentions(text, item) {
|
|
53
76
|
const lower = String(text).toLowerCase()
|
|
54
77
|
const found = []
|
|
@@ -59,10 +82,34 @@ function mentions(text, item) {
|
|
|
59
82
|
const rest = lower.slice(at + name.length)
|
|
60
83
|
if (before && !/[\s'"`(/]/.test(before)) continue
|
|
61
84
|
if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
|
|
62
|
-
|
|
85
|
+
const clause = lower.slice(0, at).split(CLAUSE).pop()
|
|
86
|
+
found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
|
|
63
87
|
}
|
|
64
88
|
}
|
|
65
|
-
return { named: found.
|
|
89
|
+
return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
|
|
93
|
+
// `push origin feat/login` eso es `login`, y «arreglá el login y no subas nada» publicaba (caso 103). Acá
|
|
94
|
+
// el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
|
|
95
|
+
// —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
|
|
96
|
+
// antes del último de los dos.
|
|
97
|
+
const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
|
|
98
|
+
function ordersPush(text, item) {
|
|
99
|
+
const [verb, remote, branch] = item.split(' ')
|
|
100
|
+
if (verb !== 'push' || !remote || !branch) return false
|
|
101
|
+
return String(text).split(CLAUSE).some((clause) => {
|
|
102
|
+
// La comilla simple se saca de los bordes y no se corta en ella: partida, «don't» dejaba de ser una
|
|
103
|
+
// negación.
|
|
104
|
+
const words = clause.split(/[\s"`()]+/)
|
|
105
|
+
.map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
|
|
106
|
+
const last = Math.max(words.indexOf(remote), words.indexOf(branch))
|
|
107
|
+
if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
|
|
108
|
+
const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
109
|
+
.match(/[a-z]+/g) || []
|
|
110
|
+
return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
|
|
111
|
+
&& !NEGATION.test(words.slice(0, last + 1).join(' '))
|
|
112
|
+
})
|
|
66
113
|
}
|
|
67
114
|
|
|
68
115
|
// Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
|
|
@@ -99,12 +146,14 @@ function said(input) {
|
|
|
99
146
|
return current && saved.id && current !== saved.id ? null : saved
|
|
100
147
|
}
|
|
101
148
|
|
|
102
|
-
// Lo que la persona no autorizó de lo que un guard está por frenar: ni lo
|
|
103
|
-
// aprobó contestando.
|
|
104
|
-
|
|
149
|
+
// Lo que la persona no autorizó de lo que un guard está por frenar: ni lo pidió en su mensaje ni lo
|
|
150
|
+
// aprobó contestando. Qué cuenta como pedirlo depende de qué se frena: un archivo se nombra, un push se
|
|
151
|
+
// ordena con su remoto y su rama.
|
|
152
|
+
const named = (text, item) => mentions(text, item).named
|
|
153
|
+
function unauthorized(input, items, asked = named) {
|
|
105
154
|
const saved = said(input)
|
|
106
155
|
if (!saved) return items
|
|
107
|
-
return items.filter((item) => !saved.approved.includes(item) && !
|
|
156
|
+
return items.filter((item) => !saved.approved.includes(item) && !asked(saved.text, item))
|
|
108
157
|
}
|
|
109
158
|
|
|
110
159
|
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
@@ -119,4 +168,4 @@ function hold(input, items) {
|
|
|
119
168
|
} catch { return false }
|
|
120
169
|
}
|
|
121
170
|
|
|
122
|
-
module.exports = { DIR, record, said, unauthorized, hold }
|
|
171
|
+
module.exports = { DIR, record, said, unauthorized, hold, ordersPush }
|
package/engine/hooks/input.js
CHANGED
|
@@ -183,9 +183,6 @@ function stagedFiles(dir) {
|
|
|
183
183
|
return result.stdout.trim().split('\n').filter(Boolean)
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
|
|
187
|
-
// sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
|
|
188
|
-
// guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
|
|
189
186
|
// El índice que un hook de pre-ejecución lee es el de **antes** del comando, y el comando puede ser
|
|
190
187
|
// justamente el que lo llene. Ahí los tres guards que juzgan mirando el índice no fallan: leen bien,
|
|
191
188
|
// encuentran cero archivos y concluyen que no hay nada que revisar.
|
|
@@ -212,13 +209,6 @@ function stagedForCommit(command, cwd) {
|
|
|
212
209
|
return { dir, staged: stagedFiles(dir) }
|
|
213
210
|
}
|
|
214
211
|
|
|
215
|
-
function pushAllowed(input) {
|
|
216
|
-
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
217
|
-
if (!root) return false
|
|
218
|
-
const runner = configOf(root).runner
|
|
219
|
-
return Boolean(runner && runner.allowPush === true)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
212
|
function findOpsRoot(start) {
|
|
223
213
|
let current = path.resolve(start)
|
|
224
214
|
while (true) {
|
|
@@ -270,7 +260,7 @@ function opsRoot(input) {
|
|
|
270
260
|
|
|
271
261
|
module.exports = {
|
|
272
262
|
readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
273
|
-
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
|
|
263
|
+
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
|
|
274
264
|
findOpsRoot, opsRoot,
|
|
275
265
|
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
276
266
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Si un `git push` se publica, y con qué autorización. R10 pide «la autorización configurada para el
|
|
4
|
+
// proyecto», y hasta 0.81.0 era un solo interruptor: `runner.allowPush` dejaba pasar cualquier push —a la
|
|
5
|
+
// rama viva, y de un subagente igual que de la persona— y, apagado, frenaba también el que la persona
|
|
6
|
+
// acababa de pedir con todas las letras (casos 103 y 108).
|
|
7
|
+
//
|
|
8
|
+
// Lo que garantiza, en orden:
|
|
9
|
+
//
|
|
10
|
+
// - Un subagente no publica, con ningún permiso: es trabajo que el agente delegó, dos pasos más lejos de
|
|
11
|
+
// la persona que la sesión. El push sale de la sesión principal.
|
|
12
|
+
// - Una rama viva —`main`, `master` y la rama por defecto de cada remoto— no la alcanza ni `allowPush` ni
|
|
13
|
+
// una orden del chat si el proyecto no la nombró en `runner.pushToLiveBranches`. La línea exacta en
|
|
14
|
+
// `.ops-approval` sí la alcanza: la escribe una persona a mano y los guards de límites no dejan que el
|
|
15
|
+
// agente se la escriba.
|
|
16
|
+
// - A una rama de trabajo, o a una viva ya nombrada, la alcanzan `allowPush`, la línea exacta en
|
|
17
|
+
// `.ops-approval`, un mensaje de la persona que ordena ese push con su remoto y su rama, o un «dale» al
|
|
18
|
+
// push que quedó frenado. Qué cuenta como orden lo decide `chat.js`.
|
|
19
|
+
//
|
|
20
|
+
// El `--force` no llega hasta acá: lo frena `destructive` antes, sin override (R8).
|
|
21
|
+
|
|
22
|
+
const { spawnSync } = require('node:child_process')
|
|
23
|
+
const { block, cwdOf, gitDirectory, opsRoot, configOf } = require('./input')
|
|
24
|
+
const AP = require('./approval')
|
|
25
|
+
const CHAT = require('./chat')
|
|
26
|
+
|
|
27
|
+
// Lo que va entre `git push` y el fin del comando. El salto de línea corta igual que `;`, por lo que
|
|
28
|
+
// `destructive` explica en MISMO.
|
|
29
|
+
const PUSH = /\bgit\s+push\b([^;&|\n]*)/g
|
|
30
|
+
// Las banderas que llevan su valor en la palabra siguiente: sin saltearlo, el valor se leía como remoto.
|
|
31
|
+
const VALUED = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
|
|
32
|
+
// Publican todas las ramas, así que entre ellas la viva: no hay una rama que nombrar ni que aprobar.
|
|
33
|
+
const EVERY = new Set(['--all', '--branches', '--mirror'])
|
|
34
|
+
|
|
35
|
+
// Lectura local e inocua del repositorio: ninguna de estas consultas habla con el remoto. Si falla, no
|
|
36
|
+
// hay dato, y quien pregunta decide sin él.
|
|
37
|
+
function git(dir, args) {
|
|
38
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' })
|
|
39
|
+
return result.status === 0 ? result.stdout.trim() : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// La rama por defecto de un remoto es la que git anota en `refs/remotes/<remoto>/HEAD`: la escribe el
|
|
43
|
+
// `clone` y la cambia `git remote set-head`, así que el proyecto la declara sin un campo nuevo. `main` y
|
|
44
|
+
// `master` cuentan siempre, porque un remoto agregado a mano no la anota.
|
|
45
|
+
function liveBranches(dir) {
|
|
46
|
+
const live = new Set(['main', 'master'])
|
|
47
|
+
const refs = git(dir, ['for-each-ref', '--format=%(refname)%09%(symref)', 'refs/remotes/'])
|
|
48
|
+
for (const line of refs.split('\n')) {
|
|
49
|
+
const [name, target] = line.split('\t')
|
|
50
|
+
if (!target || !name.endsWith('/HEAD')) continue
|
|
51
|
+
live.add(target.slice(name.length - 'HEAD'.length))
|
|
52
|
+
}
|
|
53
|
+
return live
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A dónde publicaría un `git push` sin rama: lo que git resuelve como `@{push}`, que ya aplica
|
|
57
|
+
// push.default y el remoto de publicación que declare la rama. Sin upstream no resuelve, y git mismo
|
|
58
|
+
// tampoco sabría a dónde ir.
|
|
59
|
+
function pushTarget(dir, current) {
|
|
60
|
+
if (!current) return null
|
|
61
|
+
const remote = git(dir, ['for-each-ref', '--format=%(push:remotename)', `refs/heads/${current}`])
|
|
62
|
+
const full = git(dir, ['rev-parse', '--symbolic-full-name', '@{push}'])
|
|
63
|
+
const prefix = `refs/remotes/${remote}/`
|
|
64
|
+
return remote && full.startsWith(prefix) ? { remote, branch: full.slice(prefix.length) } : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Los destinos de un push: `{ item, remote, branch }` con el ítem que se aprueba, `push <remoto> <rama>`.
|
|
68
|
+
// El que no se puede resolver lleva por ítem el comando mismo, que no nombra ninguna rama: lo aprueba un
|
|
69
|
+
// «dale» a ese bloqueo y nada en `.ops-approval`.
|
|
70
|
+
function destinationsOf(args, dir) {
|
|
71
|
+
const words = args.trim().split(/\s+/).filter(Boolean).map((word) => word.replace(/^['"]|['"]$/g, ''))
|
|
72
|
+
const positional = []
|
|
73
|
+
const found = []
|
|
74
|
+
for (let at = 0; at < words.length; at += 1) {
|
|
75
|
+
if (!words[at].startsWith('-')) positional.push(words[at])
|
|
76
|
+
else if (EVERY.has(words[at])) found.push({ every: true })
|
|
77
|
+
else if (VALUED.has(words[at])) at += 1
|
|
78
|
+
else if (words[at] === '--tags') found.push({ tags: true })
|
|
79
|
+
}
|
|
80
|
+
const current = git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
81
|
+
const target = pushTarget(dir, current)
|
|
82
|
+
const [remote, ...refspecs] = positional
|
|
83
|
+
const unknown = { item: 'git push', branch: current }
|
|
84
|
+
const one = (to, branch) => (branch ? { item: `push ${to} ${branch}`, remote: to, branch } : unknown)
|
|
85
|
+
const named = refspecs.map((spec) => {
|
|
86
|
+
const destination = spec.replace(/^\+/, '').split(':').pop().replace(/^refs\/heads\//, '')
|
|
87
|
+
return one(remote, destination === 'HEAD' ? current : destination)
|
|
88
|
+
})
|
|
89
|
+
const rest = found.map((flag) => (flag.every ? { every: true }
|
|
90
|
+
: one(remote || (target && target.remote) || 'origin', '--tags')))
|
|
91
|
+
if (named.length || found.length) return [...named, ...rest]
|
|
92
|
+
if (remote) return [one(remote, target && target.remote === remote ? target.branch : current)]
|
|
93
|
+
return [target ? one(target.remote, target.branch) : unknown]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SUBAGENT = "'git push' desde un subagente no se publica, con ningún permiso: publicar lo decide una "
|
|
97
|
+
+ 'persona (R10), y un subagente es trabajo que el agente delegó. Devolvé el resultado a la sesión '
|
|
98
|
+
+ 'principal, que es la que publica.'
|
|
99
|
+
|
|
100
|
+
function liveMessage(live, input) {
|
|
101
|
+
const lines = live.filter((to) => to.remote).map((to) => to.item)
|
|
102
|
+
const names = [...new Set(live.map((to) => (to.every ? 'todas las ramas' : to.branch)))].join(', ')
|
|
103
|
+
// Le habla al agente y deja el permiso como cosa de la persona, por lo mismo que `AP.HOW`: medido en
|
|
104
|
+
// una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
|
|
105
|
+
return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
|
|
106
|
+
+ 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
|
|
107
|
+
+ 'se frenó y esperá: un «dale» no lo destraba. Lo da ella, nombrando la rama en '
|
|
108
|
+
+ 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
|
|
109
|
+
+ (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
110
|
+
+ lines.map((line) => ` ${line}\n`).join('') : '.')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function workMessage(items, input) {
|
|
114
|
+
const lines = items.filter((item) => item.startsWith('push '))
|
|
115
|
+
const chat = CHAT.hold(input, items)
|
|
116
|
+
const how = chat
|
|
117
|
+
? 'Decile a la persona qué se frenó y esperá: si contesta «dale», reintentá el mismo push y pasa. '
|
|
118
|
+
+ 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
|
|
119
|
+
: 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
|
|
120
|
+
const paste = lines.length
|
|
121
|
+
? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
122
|
+
+ lines.map((line) => ` ${line}\n`).join('')
|
|
123
|
+
: ' Sin remoto y rama que se puedan leer del comando no hay línea que aprobar: nombralos.\n'
|
|
124
|
+
return `'git push' publica cambios y requiere una acción humana. ${how}${paste}`
|
|
125
|
+
+ 'El permiso permanente es runner.allowPush en ops.config.json, y lo decide una persona.'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function publish(input, command) {
|
|
129
|
+
const pushes = [...command.matchAll(PUSH)]
|
|
130
|
+
if (!pushes.length) return
|
|
131
|
+
if (input.agent_id) block(SUBAGENT)
|
|
132
|
+
const dir = gitDirectory(command, cwdOf(input))
|
|
133
|
+
const root = opsRoot(input)
|
|
134
|
+
const runner = (root && configOf(root).runner) || {}
|
|
135
|
+
const listed = new Set(Array.isArray(runner.pushToLiveBranches) ? runner.pushToLiveBranches : [])
|
|
136
|
+
const filed = new Set(root ? AP.read(root) : [])
|
|
137
|
+
const live = liveBranches(dir)
|
|
138
|
+
const left = pushes.flatMap((match) => destinationsOf(match[1], dir))
|
|
139
|
+
.filter((to) => !(to.item && to.item.startsWith('push ') && filed.has(to.item)))
|
|
140
|
+
const unlisted = left.filter((to) => to.every || (live.has(to.branch) && !listed.has(to.branch)))
|
|
141
|
+
if (unlisted.length) block(liveMessage(unlisted, input))
|
|
142
|
+
if (runner.allowPush === true) return
|
|
143
|
+
const unordered = CHAT.unauthorized(input, [...new Set(left.map((to) => to.item))], CHAT.ordersPush)
|
|
144
|
+
if (unordered.length) block(workMessage(unordered, input))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { publish }
|
package/engine/hooks/shell.js
CHANGED
|
@@ -9,10 +9,11 @@ const os = require('node:os')
|
|
|
9
9
|
const path = require('node:path')
|
|
10
10
|
const { spawnSync } = require('node:child_process')
|
|
11
11
|
const {
|
|
12
|
-
commandOf, cwdOf, block, isCommit, stagedForCommit,
|
|
12
|
+
commandOf, cwdOf, block, isCommit, stagedForCommit,
|
|
13
13
|
writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
|
|
14
14
|
} = require('./input')
|
|
15
15
|
const AP = require('./approval')
|
|
16
|
+
const { publish } = require('./push')
|
|
16
17
|
const { selfApproval } = require('./self-approval')
|
|
17
18
|
const EV = require('../core/evidence')
|
|
18
19
|
|
|
@@ -64,14 +65,15 @@ function destructive(input) {
|
|
|
64
65
|
// matchea igual las dos formas, así que `allowPush` habilitaba el force-push sin que nadie lo decidiera
|
|
65
66
|
// y el párrafo de autonomía de `AGENTS.md` tenía que confesarlo. R8 prohíbe `force` sin excepción
|
|
66
67
|
// configurable, así que esta rama va antes del permiso y no lo consulta.
|
|
67
|
-
|
|
68
|
+
//
|
|
69
|
+
// El `+` delante de una rama es el mismo force escrito en el refspec —`git push origin +main`—, y
|
|
70
|
+
// pasaba como un push normal: la regla miraba sólo las banderas (caso 103).
|
|
71
|
+
if (new RegExp(String.raw`\bgit\s+push\b${MISMO}*\s(?:(?:-f|--force(?:-with-lease|-if-includes)?)\b|\+\S)`)
|
|
68
72
|
.test(command)) {
|
|
69
73
|
block("'git push --force' reescribe historia ya publicada. R8 lo prohíbe y runner.allowPush no lo "
|
|
70
74
|
+ 'habilita: publicá con un push normal, o registrá una acción humana.')
|
|
71
75
|
}
|
|
72
|
-
|
|
73
|
-
block("'git push' publica cambios y requiere una acción humana. Se habilita con runner.allowPush.")
|
|
74
|
-
}
|
|
76
|
+
publish(input, command)
|
|
75
77
|
const rules = [
|
|
76
78
|
[/\bgit\s+reset\s+--hard\b/, "'git reset --hard' destruye cambios locales."],
|
|
77
79
|
// R8 lo prohíbe sin excepción configurable y ningún guard lo miraba: `grep -rn amend engine/hooks/`
|
|
@@ -64,11 +64,22 @@ function adapter(root, name, entry = {}) {
|
|
|
64
64
|
return impl
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Qué nombre tiene forma de secreto. Es una sola regla para los tres que la necesitan: la configuración
|
|
68
|
+
// de una integración, la declaración de secretos y el aviso de credenciales sin dueño de «ops check».
|
|
69
|
+
// La clave cuenta sólo como palabra propia —API_KEY sí, projectKey no—, porque el campo de Jira con la
|
|
70
|
+
// clave del proyecto está en instancias reales y rechazarlo les rompería la validación (caso 102, donde
|
|
71
|
+
// está la medición).
|
|
72
|
+
const SENSITIVE = /(password|secret|token|authorization|cookie|dsn|credentials?|(?:^|_)key)$/i
|
|
73
|
+
|
|
74
|
+
function sensitiveKey(key) {
|
|
75
|
+
return SENSITIVE.test(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
function sensitivePath(value, trail = '') {
|
|
68
79
|
if (!value || typeof value !== 'object') return ''
|
|
69
80
|
for (const [key, child] of Object.entries(value)) {
|
|
70
81
|
const next = trail ? `${trail}.${key}` : key
|
|
71
|
-
if (
|
|
82
|
+
if (sensitiveKey(key)) return next
|
|
72
83
|
const nested = sensitivePath(child, next)
|
|
73
84
|
if (nested) return nested
|
|
74
85
|
}
|
|
@@ -442,6 +453,7 @@ module.exports = {
|
|
|
442
453
|
providerConfig,
|
|
443
454
|
reconcile,
|
|
444
455
|
safeSegment,
|
|
456
|
+
sensitiveKey,
|
|
445
457
|
sensitivePath,
|
|
446
458
|
sync,
|
|
447
459
|
validate,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Lo que `check` dice sobre el INBOX. Son advertencias y nunca errores: el INBOX es de la persona, y un
|
|
4
|
+
// `check` rojo por lo que tiene adentro la frenaría a ella por lo que escribió un recorrido (caso 101).
|
|
5
|
+
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const P = require('./parser')
|
|
8
|
+
|
|
9
|
+
const FILE = 'INBOX.md'
|
|
10
|
+
// Un archivo que todavía se recorre de una sentada; el molde vacío ocupa menos de treinta. La instancia
|
|
11
|
+
// lo cambia en `inbox.warnLines`.
|
|
12
|
+
const WARN_LINES = 300
|
|
13
|
+
|
|
14
|
+
function warnings(root, done, config) {
|
|
15
|
+
const text = P.read(path.join(root, FILE))
|
|
16
|
+
if (!text.trim()) return []
|
|
17
|
+
const found = []
|
|
18
|
+
const declared = config && config.inbox && config.inbox.warnLines
|
|
19
|
+
const limit = Number.isInteger(declared) && declared > 0 ? declared : WARN_LINES
|
|
20
|
+
const count = text.replace(/\n$/, '').split('\n').length
|
|
21
|
+
if (count > limit) {
|
|
22
|
+
found.push(`${FILE}: ${count} líneas, más que el umbral de ${limit} (inbox.warnLines en ops.config.json); `
|
|
23
|
+
+ 'recorrelo y borrá lo que ya se decidió')
|
|
24
|
+
}
|
|
25
|
+
// Una entrada que se llama como una tarea cerrada probablemente se promovió y nadie la borró. Es un
|
|
26
|
+
// indicio y no una prueba —el molde no obliga a que la tarea conserve el nombre del ítem—, y por eso
|
|
27
|
+
// avisa en vez de fallar (caso 106).
|
|
28
|
+
for (const name of Object.values(P.inboxHeads(root)).flat()) {
|
|
29
|
+
if (done.set.has(name)) {
|
|
30
|
+
found.push(`${FILE}: **${name}** se llama como done/${name}.md; si ya se promovió, borrala`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return found
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { warnings }
|
|
@@ -405,18 +405,31 @@ function readWips(dir) {
|
|
|
405
405
|
// La plantilla no traía ningún ejemplo, así que quien escribía viñetas planas veía cero ítems sobre un
|
|
406
406
|
// archivo con doce y nada se lo decía. `skipped` es lo que vuelve visible esa diferencia.
|
|
407
407
|
function readInbox(dir) {
|
|
408
|
-
const
|
|
408
|
+
const { heads, skipped } = inboxSections(dir)
|
|
409
|
+
return { ...Object.fromEntries(Object.entries(heads).map(([key, names]) => [key, names.length])), skipped }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Los nombres en negrita de cada sección, que es lo que un recorrido necesita para no volver a escribir
|
|
413
|
+
// lo que ya está: el nombre y no la entrada, porque pasar el archivo entero a cada tarea cuesta lo que
|
|
414
|
+
// el INBOX pesa (caso 101).
|
|
415
|
+
function inboxHeads(dir) {
|
|
416
|
+
return inboxSections(dir).heads
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function inboxSections(dir) {
|
|
420
|
+
const heads = { deuda: [], ideas: [], propuestas: [], lecciones: [] }
|
|
421
|
+
let skipped = 0
|
|
409
422
|
for (const part of read(path.join(dir, 'INBOX.md')).split(/^##\s+/m)) {
|
|
410
423
|
const title = part.split('\n')[0]
|
|
411
424
|
const bullets = (part.match(/^[-*]\s+(?:\[[ xX]\]\s+)?/gm) || []).length
|
|
412
|
-
const
|
|
413
|
-
if (/Deuda|Ideas|Visi[oó]n|Propuestas|Lecciones/i.test(title))
|
|
414
|
-
if (/Deuda/i.test(title))
|
|
415
|
-
if (/Ideas|Visi[oó]n/i.test(title))
|
|
416
|
-
if (/Propuestas/i.test(title))
|
|
417
|
-
if (/Lecciones/i.test(title))
|
|
425
|
+
const names = [...part.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?\*\*([^*\n]*)/gm)].map((hit) => hit[1].trim())
|
|
426
|
+
if (/Deuda|Ideas|Visi[oó]n|Propuestas|Lecciones/i.test(title)) skipped += bullets - names.length
|
|
427
|
+
if (/Deuda/i.test(title)) heads.deuda = names
|
|
428
|
+
if (/Ideas|Visi[oó]n/i.test(title)) heads.ideas = names
|
|
429
|
+
if (/Propuestas/i.test(title)) heads.propuestas = names
|
|
430
|
+
if (/Lecciones/i.test(title)) heads.lecciones = names
|
|
418
431
|
}
|
|
419
|
-
return
|
|
432
|
+
return { heads, skipped }
|
|
420
433
|
}
|
|
421
434
|
|
|
422
435
|
module.exports = {
|
|
@@ -424,5 +437,5 @@ module.exports = {
|
|
|
424
437
|
TASK_LINE, TASK_LINE_ANY_LANE,
|
|
425
438
|
read, section, withoutComments, frontmatter, readEpics, readBacklog, readDone, readWip, readWips, wipName,
|
|
426
439
|
acceptanceConditions, tableRows, taskFromLine,
|
|
427
|
-
readInbox, readHumanActions,
|
|
440
|
+
readInbox, inboxHeads, readHumanActions,
|
|
428
441
|
}
|
|
@@ -19,6 +19,10 @@ const CADENCES = { mensual: 1, trimestral: 3, semestral: 6, anual: 12 }
|
|
|
19
19
|
|
|
20
20
|
const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
21
21
|
const DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
22
|
+
// La fila que el molde trae activa escribe su `Desde` como marcador, porque la fecha depende del día en
|
|
23
|
+
// que se crea la instancia y la reemplazan `init` y `upgrade`. Sin resolver sólo existe en el molde
|
|
24
|
+
// mismo —el que `npm run check` valida en el toolkit—, y ahí no vence: `status` la descarta por fecha.
|
|
25
|
+
const PLACEHOLDER = /^\{\{[A-Z_]+\}\}$/
|
|
22
26
|
// `- **qué** AAAA-MM-DD — razón`. El nombre en negrita adelante es la misma convención del INBOX, y por
|
|
23
27
|
// el mismo motivo: es con lo que se cita la fila desde otro lado.
|
|
24
28
|
const POSTPONEMENT = /^-\s+\*\*([^*]+)\*\*\s+(\S+)\s+[—-]\s+(.+)$/
|
|
@@ -77,7 +81,7 @@ function validate({ exists, rows, postponements }) {
|
|
|
77
81
|
if (!CADENCES[row.cadence]) {
|
|
78
82
|
errors.push(`${at}: cadencia "${row.cadence}" fuera de ${Object.keys(CADENCES).join(' | ')}`)
|
|
79
83
|
}
|
|
80
|
-
if (!DATE.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
|
|
84
|
+
if (!DATE.test(row.since) && !PLACEHOLDER.test(row.since)) errors.push(`${at}: Desde debe ser AAAA-MM-DD`)
|
|
81
85
|
// Se juzga la línea armada y no la celda suelta: lo que se promueve es esa línea, y quien la va a
|
|
82
86
|
// leer es el mismo lector de BACKLOG. Una celda que pasa acá y una línea que BACKLOG rechaza es el
|
|
83
87
|
// error que aparece un mes después, con la tarea ya pegada.
|
|
@@ -145,4 +149,11 @@ function warnings(state) {
|
|
|
145
149
|
return lines
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
|
|
152
|
+
// Los marcadores de fecha del molde, resueltos para una instancia que nace hoy. `Desde` es la primera
|
|
153
|
+
// fecha de vencimiento y no el día en que se declara la fila, así que va un período después: con la
|
|
154
|
+
// fecha de hoy la fila nacía «vence hoy» (caso 106). Trimestral es la cadencia de la fila del molde.
|
|
155
|
+
function sinceValues(today) {
|
|
156
|
+
return { '{{INBOX_SINCE}}': addMonths(today, CADENCES.trimestral) }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { FILE, read, validate, status, warnings, taskLine, sinceValues }
|