diff --git a/.github/workflows/vigia.yml b/.github/workflows/vigia.yml new file mode 100644 index 0000000..518c249 --- /dev/null +++ b/.github/workflows/vigia.yml @@ -0,0 +1,29 @@ +# Tests del vigia de vencimientos +# ----------------------------------------- +# A diferencia de los smoke tests de SAIJ, estos no dependen de ninguna API +# externa: arman expedientes sinteticos en una carpeta temporal y comprueban +# que el escaneo separe bien lo que vence, lo que nadie computo y lo que no se +# puede computar por falta de jurisdiccion. +# +# Sin red y sin datos reales, asi que no hay motivo para que sean tolerantes a +# fallos: si fallan, fallan. + +name: Vigia de vencimientos + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Tests del vigia + run: node test/vigia-test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 60ad12a..2e38915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,15 @@ computar plazos o citar procedimiento. Leé la ficha de jurisdicción correspond área en SAIJ (fuente primaria, en vivo). 4. Confirmá jurisdicción/fuero antes de cualquier cómputo de plazos. +## Al empezar la semana + +Corré la skill **`argentina-vigia`**: revisa todos los expedientes de una pasada +y separa lo que vence pronto, lo que nadie computó todavía, y lo que no se puede +computar porque a la ficha le falta la jurisdicción. + +El plazo que se pierde casi nunca es el que estabas mirando. Es la fila que +quedó en `🔲 a computar` en uno de los otros casos. + ## Reglas de operación (no negociables) - **Plazos por jurisdicción:** días hábiles judiciales ≠ hábiles administrativos ≠ @@ -63,7 +72,8 @@ computar plazos o citar procedimiento. Leé la ficha de jurisdicción correspond `saij_buscar_doctrina`, `saij_documento(uuid)`. Cubre Nacional/Federal y legislación Local de Jujuy y Salta. - **Skills** — `abogacia-argentina` (router y ejes normativos por área), - `argentina-plazos`, `argentina-diagnostico`, `argentina-bucles`, `saij-argentina`. + `argentina-plazos`, `argentina-diagnostico`, `argentina-bucles`, `saij-argentina`, + `argentina-vigia`. ## Privacidad diff --git a/README.md b/README.md index 5347457..0e4171d 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ y se computa el plazo aplicable. | Capa | Qué hace | |------|----------| -| **Skills** | `abogacia-argentina` (router generalista por área: laboral, civil, penal, familia, consumidor, societario, administrativo, previsional, tributario, tránsito, protección de datos), `argentina-formatos` (anatomía de cada tipo de escrito: demanda ≠ descargo ≠ recurso ≠ nota), `argentina-plazos`, `argentina-diagnostico`, `argentina-bucles`, `saij-argentina`. Todas propias, MIT. | +| **Skills** | `abogacia-argentina` (router generalista por área: laboral, civil, penal, familia, consumidor, societario, administrativo, previsional, tributario, tránsito, protección de datos), `argentina-formatos` (anatomía de cada tipo de escrito: demanda ≠ descargo ≠ recurso ≠ nota), `argentina-plazos`, `argentina-diagnostico`, `argentina-bucles`, `saij-argentina`. Todas propias, MIT., `argentina-vigia` (repaso de vencimientos de todos los expedientes) | | **MCP SAIJ** | Búsqueda en vivo de jurisprudencia, legislación y doctrina + texto completo por uuid. Código propio, cero dependencias. | | **Multi-jurisdicción** | CABA/Nacional (CPCCN, CPPF), Jujuy y Salta — extensible con una ficha por provincia. Pregunta el fuero al abrir cada caso. | | **Workspace de casos** | Cada caso es una subcarpeta en `casos/` con ficha, escritos, documentación, jurisprudencia y plazos. | diff --git a/componentes/vigia/escanear.mjs b/componentes/vigia/escanear.mjs new file mode 100755 index 0000000..4f20e48 --- /dev/null +++ b/componentes/vigia/escanear.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Escanea `casos/` y reporta el estado de los plazos de cada expediente. + * + * Este script NO computa ningún plazo, y eso es deliberado. El cómputo depende + * de la jurisdicción, del tipo de plazo, de la feria y de las acordadas del + * tribunal, y la regla del proyecto es que eso lo resuelve `argentina-plazos` + * después de confirmar el fuero. Un script que se pusiera a restar días + * hábiles estaría adivinando exactamente lo que la regla prohíbe adivinar. + * + * Lo que sí hace es lo mecánico, que es donde se pierden los plazos de verdad: + * recorrer veinte carpetas, leer veinte tablas, y decir cuáles tienen filas sin + * computar, cuáles vencen pronto y cuáles no se pueden computar todavía porque + * a la ficha le falta la jurisdicción. + * + * Uso: + * node componentes/vigia/escanear.mjs [--casos ] [--json] [--dias ] + * + * --casos dónde están los expedientes (default: ./casos) + * --json salida en JSON, para encadenar + * --dias ventana de "vence pronto" en días corridos (default: 15) + */ + +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const opt = (nombre, porDefecto) => { + const i = args.indexOf(`--${nombre}`); + return i >= 0 && args[i + 1] ? args[i + 1] : porDefecto; +}; +const DIR_CASOS = opt('casos', 'casos'); +const VENTANA = Number(opt('dias', '15')); +const JSON_OUT = args.includes('--json'); + +/** Marcas del proyecto: 🔲 es pendiente, 🔴 es fatal. */ +const PENDIENTE = /🔲/u; +const FATAL = /🔴/u; + +/** Una fila de la tabla de plazos, tal como está escrita. */ +function parsearFilas(md) { + const filas = []; + for (const linea of md.split('\n')) { + const l = linea.trim(); + if (!l.startsWith('|') || !l.endsWith('|')) continue; + const celdas = l.slice(1, -1).split('|').map((c) => c.trim()); + if (celdas.length < 5) continue; + // Cabecera y separador de la tabla markdown. + if (/^-+$/.test(celdas[0].replace(/[\s:]/g, ''))) continue; + if (/^acto\b/i.test(celdas[0])) continue; + // Fila vacía de la plantilla. + if (celdas.every((c) => c === '')) continue; + // La fila de ejemplo que trae la plantilla, entre guiones bajos. + if (/^_.*_$/.test(celdas[0])) continue; + + filas.push({ + acto: celdas[0], + tipo: celdas[1], + inicio: celdas[2], + vencimiento: celdas[3], + estado: celdas[4], + }); + } + return filas; +} + +/** Una fecha suelta escrita como la escribe una persona. */ +function leerFecha(texto) { + if (!texto) return null; + const t = texto.trim(); + let m = t.match(/(\d{4})-(\d{2})-(\d{2})/); + if (m) return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); + m = t.match(/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/); + if (m) return new Date(Date.UTC(+m[3], +m[2] - 1, +m[1])); + return null; +} + +/** La jurisdicción y el fuero declarados en la ficha, o null si faltan. */ +function leerFicha(md) { + const campo = (etiqueta) => { + const re = new RegExp(`\\*\\*${etiqueta}:?\\*\\*\\s*(.*)`, 'i'); + const m = md.match(re); + if (!m) return null; + // Se descarta el paréntesis de ayuda que trae la plantilla sin completar. + const v = m[1].replace(/\(.*?\)/g, '').trim(); + return v === '' ? null : v; + }; + return { + caratula: campo('Carátula'), + expediente: campo('N° de expediente'), + jurisdiccion: campo('Jurisdicción'), + fuero: campo('Fuero'), + estado: campo('Estado actual'), + }; +} + +const hoy = new Date(); +hoy.setUTCHours(0, 0, 0, 0); +const diasHasta = (f) => Math.round((f - hoy) / 86400000); + +async function escanear() { + let entradas; + try { + entradas = await readdir(DIR_CASOS, { withFileTypes: true }); + } catch { + return { error: `no pude leer ${DIR_CASOS}`, casos: [] }; + } + + const casos = []; + for (const e of entradas) { + if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue; + const base = path.join(DIR_CASOS, e.name); + + let ficha = {}; + try { + ficha = leerFicha(await readFile(path.join(base, 'FICHA.md'), 'utf8')); + } catch { + ficha = { falta_ficha: true }; + } + + let filas = []; + let sinArchivo = false; + try { + filas = parsearFilas(await readFile(path.join(base, 'plazos.md'), 'utf8')); + } catch { + sinArchivo = true; + } + + const plazos = filas.map((f) => { + const venc = leerFecha(f.vencimiento); + return { + ...f, + fatal: FATAL.test(f.estado) || FATAL.test(f.acto), + sinComputar: venc === null || PENDIENTE.test(f.estado), + dias: venc ? diasHasta(venc) : null, + }; + }); + + casos.push({ + caso: e.name, + ...ficha, + sinArchivoDePlazos: sinArchivo, + // La regla dura del proyecto: sin jurisdicción no se computa nada. + puedeComputarse: Boolean(ficha.jurisdiccion && ficha.fuero), + plazos, + }); + } + return { casos }; +} + +function informar({ casos, error }) { + if (error) { + console.error(` ${error}`); + process.exitCode = 2; + return; + } + if (casos.length === 0) { + console.log(' No hay expedientes en ' + DIR_CASOS); + return; + } + + const bloqueados = casos.filter((c) => !c.puedeComputarse); + const sinComputar = casos.flatMap((c) => + c.plazos.filter((p) => p.sinComputar).map((p) => ({ ...p, caso: c.caso })), + ); + const proximos = casos + .flatMap((c) => c.plazos.filter((p) => p.dias !== null).map((p) => ({ ...p, caso: c.caso }))) + .filter((p) => p.dias <= VENTANA) + .sort((a, b) => a.dias - b.dias); + + console.log(`\n ${casos.length} expedientes revisados el ${hoy.toISOString().slice(0, 10)}\n`); + + // Lo vencido y lo inminente primero: es lo único que puede costar un derecho hoy. + if (proximos.length) { + console.log(' ── Vencen dentro de ' + VENTANA + ' días ──'); + for (const p of proximos) { + const marca = p.dias < 0 ? '⛔ VENCIDO' : p.fatal ? '🔴 fatal ' : ' '; + const cuando = p.dias < 0 ? `hace ${-p.dias}d` : `en ${p.dias}d`; + console.log(` ${marca} ${cuando.padEnd(9)} ${p.caso} · ${p.acto}`); + } + console.log(''); + } + + // El modo de falla real: nadie lo computó todavía. + if (sinComputar.length) { + console.log(' ── Sin computar ──'); + console.log(' (una fila sin vencimiento no es un plazo holgado: es un plazo que nadie miró)'); + for (const p of sinComputar) { + console.log(` 🔲 ${p.caso} · ${p.acto || '(sin acto)'} · ${p.tipo || 'tipo sin declarar'}`); + } + console.log(''); + } + + // Y lo que ni siquiera se puede computar todavía. + if (bloqueados.length) { + console.log(' ── No se pueden computar: falta jurisdicción o fuero en la FICHA ──'); + for (const c of bloqueados) { + const falta = [ + c.jurisdiccion ? null : 'jurisdicción', + c.fuero ? null : 'fuero', + ].filter(Boolean).join(' y '); + console.log(` ⚠️ ${c.caso} · falta ${falta || 'la ficha entera'}`); + } + console.log(''); + } + + const limpios = casos.length - new Set([ + ...proximos.map((p) => p.caso), + ...sinComputar.map((p) => p.caso), + ...bloqueados.map((c) => c.caso), + ]).size; + console.log(` ${limpios} expediente(s) sin nada pendiente en esta ventana.`); + + if (proximos.some((p) => p.dias < 0) || sinComputar.length || bloqueados.length) { + process.exitCode = 1; + } +} + +const resultado = await escanear(); +if (JSON_OUT) console.log(JSON.stringify(resultado, null, 2)); +else informar(resultado); diff --git a/skills/argentina-vigia/SKILL.md b/skills/argentina-vigia/SKILL.md new file mode 100755 index 0000000..3a98339 --- /dev/null +++ b/skills/argentina-vigia/SKILL.md @@ -0,0 +1,90 @@ +--- +name: argentina-vigia +description: "Revisa todos los expedientes abiertos de una sola pasada y reporta qué vence pronto, qué plazo nadie computó todavía, y qué caso no se puede computar porque a la ficha le falta la jurisdicción o el fuero. Usar al empezar el día o la semana, al volver de la feria, antes de irse de licencia, o cuando el usuario diga «qué tengo pendiente», «cómo vienen los plazos», «revisá los casos» o «qué se me vence»." +license: MIT +--- + +# Vigía de vencimientos · Todos los expedientes de una pasada + +Skill original de Agente Smith (MIT). + +Las otras skills trabajan **un** caso. Esta mira **todos** a la vez, porque el +plazo que se pierde en una Defensoría casi nunca es el que estabas mirando. + +## El modo de falla que esta skill persigue + +No es el plazo que computaste y se te pasó. Ese lo tenés en la cabeza. + +Es la fila que quedó así: + +``` +| contestar demanda | hábiles judiciales | | | 🔲 a computar | +``` + +Alguien abrió el caso, cargó el acto, y no volvió. Con tres expedientes eso se +nota. Con veinte no se nota nunca, y desde afuera se ve idéntico a un caso al +día: una tabla prolija, sin fechas rojas, porque **no hay fechas**. + +Por eso el reporte separa tres cosas distintas y no las mezcla: + +| | qué es | por qué duele | +|---|---|---| +| **vence pronto** | tiene fecha y está cerca | lo que ya sabés | +| **sin computar** | no tiene fecha porque nadie la calculó | lo que no sabés que no sabés | +| **bloqueado** | no se puede calcular: falta jurisdicción o fuero en la `FICHA.md` | el que parece un problema administrativo y es un plazo escondido | + +## Procedimiento + +**1. Escaneá lo mecánico.** + +```bash +node componentes/vigia/escanear.mjs +``` + +Recorre `casos/`, lee cada `FICHA.md` y cada `plazos.md`, y devuelve el estado. +Con `--json` sale estructurado; con `--dias N` cambiás la ventana de "vence +pronto", que por defecto son 15 días corridos. + +**El script no computa ningún plazo, a propósito.** Sólo lee fechas ya escritas +y cuenta días corridos hasta ellas para ordenar la urgencia. Restar días hábiles +sin saber la jurisdicción, la feria y las acordadas del tribunal sería adivinar +justo lo que la regla del proyecto prohíbe adivinar. + +**2. Los bloqueados van primero.** + +Un caso sin jurisdicción o sin fuero en la ficha **no tiene un problema de +formulario: tiene todos sus plazos sin computar y no lo parece.** Antes de +seguir, pedile al defensor esos datos. No los deduzcas del nombre del juzgado ni +del tipo de carátula. + +**3. Recién ahí, computá lo que falta.** + +Para cada fila `🔲`, activá **`argentina-plazos`** con la jurisdicción y el fuero +de la ficha. Esa skill pregunta lo que le falte; dejala preguntar. Escribí el +resultado en la `plazos.md` del caso, con la norma fuente en la columna de tipo. + +**4. Marcá lo que quede sin verificar.** + +Si computaste un vencimiento pero no pudiste confirmar la feria o una acordada +del tribunal en SAIJ, la fila queda `🔲` con la nota de qué falta verificar. Un +plazo computado a medias que figura como cerrado es peor que uno sin computar, +porque deja de aparecer en este reporte. + +## Cuándo correrla + +Al empezar la semana, al volver de la feria, antes de tomarte licencia, y cuando +recibís un expediente de otro defensor. Los cuatro son momentos donde el estado +real de los plazos y el que uno recuerda se separan. + +## Lo que esta skill no hace + +**No manda avisos ni recordatorios.** Reporta cuando la corrés. Un sistema que +avisa solo hace falta y es la evolución natural, pero necesita un lugar donde +correr programado; mientras tanto, esto se corre y se lee. + +**No abre el expediente electrónico.** Lee lo que está escrito en `casos/`. Si +el juzgado notificó algo que nadie cargó, el vigía no lo sabe. Sincronizar con +el sistema del Poder Judicial es otro trabajo. + +**No decide qué es urgente.** Ordena por fecha y marca lo fatal con 🔴 si vos lo +marcaste. La prioridad entre dos plazos del mismo día la pone el defensor. diff --git a/test/vigia-test.mjs b/test/vigia-test.mjs new file mode 100755 index 0000000..66b4bb0 --- /dev/null +++ b/test/vigia-test.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// Tests del vigia de vencimientos +// ------------------------------------------------ +// Arma expedientes sinteticos en una carpeta temporal y comprueba que el +// escaneo separe bien los tres estados que importan: lo que vence pronto, lo +// que nadie computo, y lo que no se puede computar porque falta la +// jurisdiccion en la ficha. +// +// Los expedientes de prueba se crean y se borran en /tmp. Nunca se escribe +// nada dentro de casos/, ni con datos inventados: la regla de la casa es que +// ahi no entra nada que se parezca a un caso. +// +// Uso: +// node test/vigia-test.mjs +// +// Exit codes: +// 0 = todo ok +// 1 = algun test fallo + +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const ejecutar = promisify(execFile); +const AQUI = path.dirname(fileURLToPath(import.meta.url)); +const ESCANER = path.join(AQUI, "..", "componentes", "vigia", "escanear.mjs"); + +let pasaron = 0; +let fallaron = 0; + +function ok(nombre) { + pasaron += 1; + process.stderr.write(`[vigia] ok ${nombre}\n`); +} +function mal(nombre, detalle) { + fallaron += 1; + process.stderr.write(`[vigia] MAL ${nombre}\n ${detalle}\n`); +} +function comprobar(nombre, condicion, detalle) { + condicion ? ok(nombre) : mal(nombre, detalle); +} + +/** Una fecha a N dias de hoy, en el formato que usa la plantilla. */ +function enDias(n) { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} + +async function caso(base, nombre, ficha, plazos) { + const dir = path.join(base, nombre); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "FICHA.md"), ficha, "utf8"); + if (plazos !== null) { + await writeFile(path.join(dir, "plazos.md"), plazos, "utf8"); + } +} + +const CABECERA = + "| Acto / plazo | Tipo | Inicio | Vencimiento | Estado |\n" + + "|---|---|---|---|---|\n"; + +async function main() { + const base = await mkdtemp(path.join(tmpdir(), "vigia-")); + + // Al dia, con un vencimiento fatal cerca. + await caso( + base, + "0001-expediente-de-prueba", + "## Jurisdicción y fuero\n- **Jurisdicción:** CABA-Nacional\n- **Fuero:** previsional\n", + CABECERA + `| expresar agravios | hábiles judiciales | ${enDias(-9)} | ${enDias(6)} | 🔴 computado |\n`, + ); + + // Una fila que nadie computo: el modo de falla que persigue esta skill. + await caso( + base, + "0002-expediente-de-prueba", + "## Jurisdicción y fuero\n- **Jurisdicción:** Salta\n- **Fuero:** civil y comercial\n", + CABECERA + "| contestar demanda | hábiles judiciales | 2026-08-25 | | 🔲 a computar |\n", + ); + + // Ficha con los parentesis de la plantilla sin completar. + await caso( + base, + "0003-expediente-de-prueba", + "## Jurisdicción y fuero\n- **Jurisdicción:** (CABA-Nacional / Jujuy / Salta)\n- **Fuero:** (civil / penal)\n", + CABECERA + "| algo | hábiles judiciales | | | 🔲 a computar |\n", + ); + + // Ya vencido. + await caso( + base, + "0004-expediente-de-prueba", + "## Jurisdicción y fuero\n- **Jurisdicción:** Jujuy\n- **Fuero:** penal\n", + CABECERA + `| interponer recurso | hábiles judiciales | ${enDias(-12)} | ${enDias(-3)} | 🔴 computado |\n`, + ); + + // Sin nada pendiente: vence lejos. + await caso( + base, + "0005-expediente-de-prueba", + "## Jurisdicción y fuero\n- **Jurisdicción:** Salta\n- **Fuero:** familia\n", + CABECERA + `| audiencia | corridos | ${enDias(1)} | ${enDias(120)} | computado |\n`, + ); + + let salida; + try { + const r = await ejecutar("node", [ESCANER, "--casos", base, "--json"]); + salida = JSON.parse(r.stdout); + } catch (e) { + // exit 1 es esperado cuando hay pendientes: la salida sigue siendo valida. + if (e.stdout) salida = JSON.parse(e.stdout); + else { + mal("el escaner corre", String(e).slice(0, 160)); + return; + } + } + + const por = (n) => salida.casos.find((c) => c.caso.startsWith(n)); + + comprobar("encuentra los cinco expedientes", salida.casos.length === 5, + `encontro ${salida.casos.length}`); + + comprobar("lee jurisdicción y fuero de la ficha", + por("0001").jurisdiccion === "CABA-Nacional" && por("0001").fuero === "previsional", + JSON.stringify(por("0001")).slice(0, 120)); + + comprobar("una ficha con los paréntesis sin completar no cuenta como completa", + por("0003").puedeComputarse === false, + "0003 quedo como computable y no deberia"); + + comprobar("una ficha completa sí habilita el cómputo", + por("0002").puedeComputarse === true, + "0002 quedo como no computable"); + + comprobar("marca sin computar la fila sin vencimiento", + por("0002").plazos.some((p) => p.sinComputar), + "no detecto la fila 🔲 de 0002"); + + comprobar("no marca sin computar una fila con fecha", + por("0001").plazos.every((p) => !p.sinComputar), + "marco como pendiente una fila ya computada"); + + comprobar("cuenta los días hasta el vencimiento", + por("0001").plazos[0].dias === 6, + `dias = ${por("0001").plazos[0].dias}, esperaba 6`); + + comprobar("un vencimiento pasado da días negativos", + por("0004").plazos[0].dias === -3, + `dias = ${por("0004").plazos[0].dias}, esperaba -3`); + + comprobar("reconoce el marcador de plazo fatal", + por("0004").plazos[0].fatal === true, + "no vio el 🔴"); + + comprobar("ignora la fila de ejemplo de la plantilla", + por("0005").plazos.length === 1, + `0005 tiene ${por("0005").plazos.length} filas, esperaba 1`); + + // La ventana no debe arrastrar lo que vence dentro de cuatro meses. + const lejano = por("0005").plazos[0]; + comprobar("un vencimiento lejano no entra en la ventana", + lejano.dias > 15, + `dias = ${lejano.dias}`); + + // El escaner nunca computa: una fila sin fecha se queda sin fecha. + comprobar("el escáner NO inventa un vencimiento", + por("0002").plazos[0].dias === null, + "le puso fecha a un plazo que nadie computo"); + + await rm(base, { recursive: true, force: true }); + + process.stderr.write(`\n[vigia] ${pasaron} ok, ${fallaron} mal\n`); + process.exitCode = fallaron > 0 ? 1 : 0; +} + +await main();