bootstrap: iniciar bitácora operativa

This commit is contained in:
2026-08-13 00:43:15 -06:00
commit 1ecfb022b1
85 changed files with 7967 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
+66
View File
@@ -0,0 +1,66 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Instalar Zola fijado
uses: taiki-e/install-action@v2
with:
tool: zola@0.23.3
fallback: none
- name: Preparar tema fijado
run: ./scripts/bootstrap-theme.sh
- name: Formato Go
run: test -z "$(gofmt -l cmd internal)"
- name: Pruebas Go
run: go test ./...
- name: Detector de carreras Go
run: go test -race ./...
- name: Análisis estático Go
run: go vet ./...
- name: Compilar CLI
run: make build
- name: Flujo integral de la CLI
run: ./scripts/e2e-local.sh
- name: Verificar firmas SSH de commits del pull request
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: ./scripts/verify-signatures-ci.sh
- name: Regenerar y comprobar proyección
run: |
./bin/clusterlog --root . sync
./bin/clusterlog --root . sync --check
- name: Validar contenido y plantillas
run: ./bin/clusterlog --root . validate --require-zola
- name: Construir sitio
run: zola build
+5
View File
@@ -0,0 +1,5 @@
/public/
/bin/
/themes/zola.386/
.DS_Store
*.tmp
+97
View File
@@ -0,0 +1,97 @@
# Instrucciones para agentes de programación
## Propósito
Use `clusterlog` para registrar trabajo operativo de forma atribuible, validada y reproducible. No edite directamente archivos derivados ni simule una identidad.
## Reglas obligatorias
1. Lea `clusterlog --json schema <tipo>` antes de construir una entrada nueva.
2. No incluya secretos, tokens, contraseñas, llaves privadas ni datos personales innecesarios.
3. Registre sólo hechos observados. Distinga claramente entre cambio ejecutado, validación ejecutada y recomendación pendiente.
4. Ejecute primero con `--dry-run` cuando el contenido provenga de una sesión larga o incluya comandos.
5. Use siempre `--json` y compruebe el código de salida.
6. Para una escritura final, use `--commit` únicamente cuando el usuario haya autorizado la operación y su mecanismo de firma esté disponible.
7. Nunca exporte, lea ni copie la llave privada. Pase una ruta autorizada o permita que Git/ssh-agent la maneje.
8. No edite `data/generated/site_state.json`; ejecute `clusterlog sync`.
9. No agregue manualmente eventos a un arreglo central. Cada evento debe ocupar su archivo independiente y ser creado por la CLI.
10. Después de escribir, ejecute `clusterlog validate` y devuelva al usuario ID, ruta, advertencias y SHA del commit.
## Flujo para una memoria de sesión
```bash
clusterlog --json schema memory > /tmp/memory-schema-envelope.json
cat > /tmp/memory.json <<'JSON'
{
"title": "...",
"description": "...",
"tags": [],
"systems": [],
"risk": "low",
"review_required": true,
"context": "...",
"changes": [],
"commands": [],
"validation": [],
"rollback": "...",
"agent": "<nombre-del-agente>"
}
JSON
clusterlog --root . --json --dry-run --ssh-key "$CLUSTERLOG_SSH_KEY" \
memory create --from-json /tmp/memory.json
clusterlog --root . --json --commit --ssh-key "$CLUSTERLOG_SSH_KEY" \
memory create --from-json /tmp/memory.json
clusterlog --root . --json validate
clusterlog --root . --json verify commit HEAD
```
## Calidad de una memoria
Una memoria útil responde:
- ¿Cuál era el contexto o síntoma?
- ¿Qué se cambió exactamente?
- ¿Qué comandos son relevantes, con valores sensibles redactados?
- ¿Qué resultado se observó?
- ¿Cómo se validó?
- ¿Qué riesgo quedó abierto?
- ¿Cómo se revierte?
- ¿Qué manual o documento debe actualizarse?
No convierta salidas extensas de terminal en el cuerpo. Resuma y enlace evidencia aprobada.
## Comportamiento ante errores
- `identity_not_found`: deténgase; no elija otra identidad por aproximación.
- `possible_secret`: redacte y repita el dry-run; no fuerce la escritura.
- `validation_failed`: presente los códigos y rutas concretas.
- `git_commit_failed`: no afirme que la operación quedó auditada; los archivos pueden existir sin commit y deben revisarse.
- `task_has_running_timers`: identifique a los actores; no cierre o borre eventos.
## Cambios al código del proyecto
Antes de entregar:
```bash
gofmt -w cmd internal
go test ./...
go vet ./...
make build
./bin/clusterlog --root . sync
./bin/clusterlog --root . sync --check
./bin/clusterlog --root . validate --skip-zola
```
Cuando Zola esté disponible:
```bash
./scripts/bootstrap-theme.sh
./bin/clusterlog --root . validate --require-zola
zola build
```
Mantenga compatibilidad del JSON. Cualquier cambio de contrato debe actualizar `schema.go`, `CLI.md`, ejemplos y pruebas.
+12
View File
@@ -0,0 +1,12 @@
# Changelog
## 0.1.0 — 2026-08-12
- Estructura Zola con Documentación, Manuales, Memorias, Proyectos, Tareas y Revisión.
- CLI `clusterlog` orientada a humanos y agentes.
- Identidad por huella SSH y commits firmados opcionales.
- Revisión por hash y conjunto capturado de administradores.
- Eventos independientes para revisiones y tiempo de tareas.
- Proyección regenerable para el sitio.
- Validación, escaneo heurístico de secretos, pruebas y CI.
- Estrategia de compatibilidad fijada para `zola.386`.
+38
View File
@@ -0,0 +1,38 @@
# Claude Code: operación de la bitácora
Sigue primero `AGENTS.md`. Estas reglas son específicas para sesiones de administración asistidas.
## Al iniciar
- Confirma la raíz con `clusterlog --root . --json identity` usando la llave proporcionada por el entorno.
- Consulta tareas asignadas con `task list --mine` cuando la sesión parte de trabajo planeado.
- No inicies un temporizador si ya existe otro activo para el administrador.
## Durante la sesión
- Mantén notas temporales fuera del repositorio hasta redactarlas y eliminar secretos.
- Separa observación, hipótesis, acción y validación.
- No registres como ejecutado un comando sólo porque fue sugerido.
- Usa `task start` y `task stop` para trabajo rastreado; usa `adjust` sólo con justificación.
## Al cerrar
- Prepara una memoria cuando hubo un cambio, corrección, diagnóstico significativo o aprendizaje reutilizable.
- Incluye un rollback accionable para riesgo `high` o `critical`.
- Actualiza o crea un manual cuando el procedimiento deba repetirse.
- Ejecuta `--dry-run`, luego la escritura con `--commit` si está autorizada.
- Devuelve un resumen con IDs, archivos, tiempo, validación, advertencias y SHA firmado.
## Formato de respuesta al usuario
```text
Resultado: <éxito o error verificado>
Tarea: <TSK-ID y tiempo, cuando aplique>
Memoria: <MEM-ID>
Archivos: <rutas>
Validación: <comandos realmente ejecutados>
Commit: <SHA o “no creado”>
Pendientes: <riesgos o acciones concretas>
```
No ocultes un fallo de firma o validación detrás de una descripción optimista.
+368
View File
@@ -0,0 +1,368 @@
# Referencia de `clusterlog`
## 1. Forma general
```text
clusterlog [opciones globales] <comando> [opciones del comando]
```
Las opciones globales deben colocarse **antes** del comando.
| Opción | Efecto |
|---|---|
| `--root DIR` | raíz del repositorio; si se omite, la CLI busca el proyecto desde el directorio actual |
| `--json` | salida estructurada y estable para agentes |
| `--ssh-key PATH` | llave pública o ruta base usada para resolver identidad y firma |
| `--commit` | crea un commit Git SSH firmado con sólo los archivos de la operación |
| `--dry-run` | valida y calcula el resultado sin escribir |
También puede definirse `CLUSTERLOG_SSH_KEY`.
## 2. Contrato JSON
Éxito:
```json
{
"ok": true,
"command": "memory.create",
"data": {},
"warnings": []
}
```
Error:
```json
{
"ok": false,
"error": {
"kind": "identity_not_found",
"message": "ninguna llave SSH local coincide con un administrador activo",
"details": {}
}
}
```
Un agente debe usar el código del proceso como decisión primaria y `error.kind` para tratamiento específico. No debe inferir éxito por texto parcial.
## 3. Códigos de salida
| Código | Significado |
|---:|---|
| `0` | éxito |
| `2` | uso, flags o entrada inválida |
| `3` | identidad no encontrada o no autorizada |
| `4` | validación, firma o contenido inválido |
| `5` | conflicto de estado |
| `6` | operación Git fallida |
| `10` | error interno no clasificado |
## 4. Identidad
```bash
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub identity
```
La respuesta incluye el administrador, la huella, la ruta pública y la ruta que Git intentará usar para firmar. No prueba posesión hasta que exista una firma válida.
## 5. Administradores
### Agregar
```bash
clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub admin add \
--id ana \
--name "Ana Torres" \
--email ana@example.org \
--key ~/.ssh/id_ed25519.pub \
--roles owner,admin
```
Reglas:
- El primer administrador obtiene `owner` aunque no se incluya explícitamente.
- Después del primero, sólo un `owner` puede agregar administradores.
- No se aceptan IDs, correos o huellas duplicados.
- `data/admins.json` y `config/allowed_signers` se actualizan juntos.
### Listar
```bash
clusterlog --root . --json admin list
clusterlog --root . --json admin list --active
```
## 6. Memorias
### Consultar esquema
```bash
clusterlog --json schema memory
```
### Crear por JSON
```bash
cat examples/memory.json | clusterlog \
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
memory create --from-json -
```
### Crear por flags
```bash
clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub memory create \
--title "Rotar certificado del API" \
--description "Renovación programada y comprobación de clientes" \
--tags tls,certificados \
--systems kube-apiserver \
--risk medium \
--ticket CHG-1042 \
--duration 45m \
--agent claude-code \
--body-file memoria.md
```
Opciones específicas:
- `--title`
- `--description`
- `--tags`
- `--systems`
- `--risk low|medium|high|critical`
- `--ticket`
- `--review` (por defecto `true`)
- `--review-policy all-active|all-active-except-author`
- `--duration` en minutos, `90m` o `2h`
- `--agent`
- `--body-file ARCHIVO|-`
- `--from-json ARCHIVO|-`
Cuando se combinan JSON y flags, los flags que se proporcionen explícitamente tienen precedencia.
## 7. Revisión
### Listar pendientes
```bash
clusterlog --root . --json review list
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
```
`--mine` requiere identidad y devuelve sólo memorias cuyo conjunto pendiente contiene al administrador actual.
### Marcar la versión actual
```bash
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
review mark MEM-20260812-... --note "Revisado; sin objeciones"
```
El evento guarda el SHA-256 del contenido actual. Repetir el comando sobre el mismo hash es idempotente; editar la memoria hace que el evento anterior deje de contar.
## 8. Tareas
### Crear
```bash
cat examples/task.json | clusterlog \
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task create --from-json -
```
Flags disponibles:
- `--title`, `--description`
- `--project PRJ-...`
- `--priority low|medium|high|urgent`
- `--assignees ana,luis`
- `--estimate 120m`
- `--due YYYY-MM-DD`
- `--tags`, `--systems`
- `--objective`
- `--acceptance` separado por comas
- `--notes`
- `--body-file ARCHIVO|-`
- `--from-json ARCHIVO|-`
### Iniciar y detener tiempo
```bash
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task start TSK-... --note "Diagnóstico inicial"
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task stop TSK-... --note "Prueba completada"
```
Un administrador sólo puede mantener un temporizador activo. Los eventos se ordenan por fecha y tipo para producir un total reproducible.
### Ajustar tiempo
```bash
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task adjust TSK-... --minutes 20 --note "Trabajo previo no registrado"
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task adjust TSK-... --minutes -10 --note "Corregir doble conteo"
```
El total efectivo nunca puede quedar negativo.
### Cambiar estado
```bash
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task status TSK-... --status blocked --note "Esperando refacción"
```
Estados admitidos aquí: `backlog`, `todo`, `in_progress`, `blocked`, `cancelled`. Para finalizar use `task done`.
### Completar
```bash
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
task done TSK-... --note "Criterios de aceptación verificados"
```
Si el administrador actual mantiene el temporizador, la CLI lo detiene. Rechaza completar si otra persona sigue registrando tiempo en la misma tarea.
### Listar
```bash
clusterlog --root . --json task list
clusterlog --root . --json task list --status blocked
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub task list --mine
clusterlog --root . --json task list --project PRJ-...
```
## 9. Proyectos
### Crear
```bash
cat examples/project.json | clusterlog \
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
project create --from-json -
```
Flags:
- `--title`, `--description`, `--objective`
- `--owners`
- `--target-date YYYY-MM-DD`
- `--status planned|active|blocked|done|cancelled|archived`
- `--tags`, `--systems`
- `--scope`, `--out-of-scope`
- `--milestones`, `--success`
- `--body-file ARCHIVO|-`
- `--from-json ARCHIVO|-`
### Listar
```bash
clusterlog --root . --json project list
clusterlog --root . --json project list --status active
```
## 10. Documentación y manuales
```bash
cat examples/manual.json | clusterlog \
--root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
content create --from-json -
```
También acepta:
```bash
clusterlog --root . --commit --ssh-key ~/.ssh/id_ed25519.pub content create \
--section manuales \
--title "Reemplazar nodo de cómputo" \
--description "Procedimiento seguro y validaciones" \
--tags runbook,hardware \
--systems compute \
--body-file reemplazo.md \
--weight 20
```
`section` sólo puede ser `documentacion` o `manuales`.
## 11. Proyección
```bash
clusterlog --root . sync
clusterlog --root . sync --check
```
`sync` lee Markdown, administradores y eventos; genera `data/generated/site_state.json`. `--check` no escribe y falla cuando el archivo derivado no coincide con la fuente.
Los agentes no deben editar `data/generated/site_state.json` a mano.
## 12. Validación
```bash
clusterlog --root . --json validate
clusterlog --root . --json validate --skip-zola
clusterlog --root . --json validate --require-zola
```
Se comprueban, entre otros:
- registro de administradores y `allowed_signers`;
- IDs, autores, revisores, owners y assignees;
- estados, fechas y referencias de proyectos;
- eventos huérfanos o inconsistentes;
- temporizadores concurrentes y totales negativos;
- secretos probables;
- vigencia de la proyección;
- plantillas y enlaces con `zola check`, cuando está disponible.
## 13. Firmas
```bash
clusterlog --root . verify commit HEAD
clusterlog --root . verify commit abc1234
```
La verificación usa:
```text
config/allowed_signers
namespace: git
```
En CI conviene verificar todos los commits nuevos, no sólo `HEAD`.
## 14. Esquemas para agentes
```bash
clusterlog --json schema
clusterlog --json schema memory
clusterlog --json schema task
clusterlog --json schema project
clusterlog --json schema content
```
Los esquemas siguen JSON Schema 2020-12 y rechazan propiedades adicionales. Esta restricción permite detectar errores ortográficos en nombres de campos.
## 15. Patrón recomendado para agentes
```bash
set -euo pipefail
SCHEMA="$(clusterlog --json schema memory)"
# Construir input.json con base en data del sobre.
clusterlog --root . --json --dry-run --ssh-key "$KEY" \
memory create --from-json input.json
RESULT="$(clusterlog --root . --json --commit --ssh-key "$KEY" \
memory create --from-json input.json)"
printf '%s\n' "$RESULT"
clusterlog --root . --json verify commit HEAD
```
El agente debe detenerse ante cualquier código distinto de cero y presentar el error al usuario sin ocultarlo.
+484
View File
@@ -0,0 +1,484 @@
# Hoja de proyecto: Bitácora Operativa del Clúster
**Versión:** 0.1.0
**Fecha base:** 12 de agosto de 2026
**Nombre de trabajo:** Bitácora Operativa del Clúster
**CLI:** `clusterlog`
**Tipo de producto:** sitio interno estático + CLI de escritura + repositorio Git auditable
## 1. Resumen ejecutivo
El equipo de soporte necesita un lugar único para comprender **cómo está construido el clúster, cómo se opera, qué cambió, qué falta por hacer y qué debe revisar el resto del equipo**. La propuesta convierte un repositorio Git en la fuente de verdad y publica una proyección navegable mediante Zola, con la apariencia del tema `zola.386`.
La escritura no ocurre en el navegador. Administradores y agentes de programación usan una CLI no interactiva que:
- identifica al actor por la huella de su llave pública SSH;
- valida el contenido contra contratos predecibles;
- crea Markdown y eventos JSON pequeños;
- actualiza la proyección que consume Zola;
- y, cuando se solicita, deja todo en un commit Git firmado por SSH.
La página **Revisión** será la agenda inicial de las reuniones semanales. Muestra las memorias aún no revisadas por todos los administradores requeridos y deja de mostrarlas en cuanto la versión actual obtiene todas las confirmaciones.
## 2. Problema que resuelve
Hoy, el conocimiento operativo suele quedar fragmentado entre terminales, chats, tickets, wikis, memoria personal y commits técnicos. Esto produce cinco fallas:
1. No hay una narrativa cronológica de las intervenciones.
2. Los manuales y la arquitectura se desactualizan sin que exista una señal clara.
3. El equipo no sabe qué cambios ya fueron comprendidos por todos.
4. Las tareas y proyectos pierden contexto técnico y tiempo real invertido.
5. Los agentes de IA pueden ayudar a documentar, pero no disponen de un contrato seguro, determinista y atribuible.
## 3. Visión del producto
> Después de cada sesión de administración, el equipo debe poder reconstruir qué se intentó, qué se cambió, cómo se validó, cómo se revierte, quién lo ejecutó y quién ya lo revisó, sin depender de la memoria individual.
## 4. Objetivos
### Objetivos del MVP
- Centralizar documentación técnica, runbooks, memorias, proyectos y tareas.
- Hacer que registrar una memoria tome menos fricción que escribir una nota libre en otro sistema.
- Proporcionar a Claude Code y agentes similares un contrato JSON estable y no interactivo.
- Atribuir las escrituras a una identidad registrada por llave SSH.
- Hacer auditable la aprobación mediante commits SSH firmados.
- Construir una cola semanal de revisión reproducible y sin estado oculto en el navegador.
- Medir tiempo real de tareas sin introducir una base de datos.
- Mantener la solución portable: archivos legibles, Git y un generador estático.
### Objetivos posteriores al MVP
- Integración con CI corporativa, SSO, tickets y notificaciones.
- Importación de inventario o CMDB.
- Reportes de SLO, incidentes, cambios por sistema y deuda operativa.
- API remota controlada, sólo cuando el trabajo sin conexión y el modelo Git resulten insuficientes.
### Fuera de alcance del MVP
- Edición web multiusuario.
- Chat interno, comentarios en tiempo real o presencia.
- Almacenamiento de secretos, credenciales o volcados sensibles.
- Sustitución de monitoreo, ticketing, CMDB o gestión de incidentes.
- Identidad basada únicamente en el comentario textual de una llave.
- Firmar en nombre del usuario sin acceso autorizado a su llave privada o agente SSH.
## 5. Usuarios y responsabilidades
### Administrador del clúster
Crea memorias, documentación, manuales, tareas y proyectos; registra tiempo; revisa cambios ajenos; conserva su llave privada bajo su control.
### Owner de la bitácora
Da de alta administradores, mantiene la política de revisión, protege la rama principal y supervisa la validez de firmas.
### Agente de IA
Prepara contenido estructurado en nombre del administrador que inició la sesión. Usa la CLI y su salida JSON; no modifica archivos derivados a mano; no inventa resultados; no expone secretos.
### Lector técnico
Consulta el sitio, busca por texto, sistema o etiqueta y usa manuales y memorias como contexto operativo.
## 6. Arquitectura de información
| Sección | Propósito | Unidad principal | Cadencia esperada |
|---|---|---|---|
| Documentación | Explicar estado y diseño vigentes | documento técnico | cuando cambia la arquitectura |
| Manuales | Describir procedimientos repetibles | runbook/manual | cuando cambia un procedimiento |
| Memorias | Narrar una intervención concreta | entrada cronológica | después de cada sesión o cambio |
| Proyectos | Coordinar un resultado de varias tareas | proyecto | semanal/mensual |
| Tareas | Registrar trabajo accionable y tiempo | tarea | diaria |
| Revisión | Alinear al equipo sobre cambios recientes | proyección de pendientes | semanal |
### Taxonomías comunes
- `tags`: tema transversal, por ejemplo `firmware`, `backup`, `seguridad`.
- `systems`: componente real afectado, por ejemplo `etcd`, `spine-01`, `ceph`.
- `categories`: sección funcional.
### Convención de identificadores
- Documentación: `DOC-...`
- Manuales: `RUN-...`
- Memorias: `MEM-...`
- Proyectos: `PRJ-...`
- Tareas: `TSK-...`
- Eventos: identificadores únicos propios, almacenados en archivos independientes.
## 7. Modelo de datos
### Memoria operativa
Campos mínimos o recomendados:
- ID, título, resumen y fecha.
- autor registrado y huella SSH empleada;
- agente que ayudó a redactar, cuando aplique;
- sistemas, etiquetas, ticket y nivel de riesgo;
- contexto, cambios, comandos redactados, validaciones y reversión;
- inicio, fin y duración;
- requisito y política de revisión;
- conjunto capturado de revisores;
- hash SHA-256 de la versión actual, calculado por la CLI.
### Evento de revisión
Cada confirmación vive en `data/review-events/<evento>.json` e incluye:
- memoria;
- administrador y huella;
- fecha;
- hash exacto del contenido revisado;
- nota opcional.
No existe un booleano mutable `reviewed=true`. La revisión se deriva de eventos válidos para el hash actual.
### Tarea
La definición Markdown contiene título, objetivo, prioridad, responsables, proyecto, estimación y fecha. Los cambios de ejecución viven como eventos independientes:
- `start`;
- `stop`;
- `adjust`;
- `status`.
El tiempo real y el estado efectivo se proyectan al sincronizar.
### Proyecto
Incluye objetivo medible, responsables, estado, fecha objetivo, alcance, fuera de alcance, hitos y criterios de éxito.
## 8. Semántica de revisión
La revisión debe responder: **“¿qué administradores confirmaron haber comprendido esta versión exacta?”**
Reglas:
1. Al crear una memoria se captura el conjunto de revisores aplicable en ese momento.
2. La política puede requerir a todos los activos o a todos excepto el autor.
3. Cada evento registra el hash SHA-256 actual de la memoria.
4. Sólo cuentan eventos cuyo hash coincide con la versión actual.
5. Una edición posterior cambia el hash y vuelve a abrir la memoria automáticamente.
6. Dar de alta a un administrador en el futuro no reabre memorias históricas, porque cada memoria conserva su conjunto capturado.
7. Una memoria sale de `/revision/` cuando no falta ningún revisor.
8. La firma del commit, no la mera presencia de la huella en JSON, aporta la prueba criptográfica de autoría.
## 9. Identidad y confianza
### Resolución de identidad
La CLI busca una llave local en este orden general:
1. `--ssh-key`;
2. `CLUSTERLOG_SSH_KEY`;
3. `git config user.signingkey` cuando es una ruta;
4. llaves públicas comunes dentro de `~/.ssh`.
Calcula la huella OpenSSH SHA-256 de la llave pública y la compara con `data/admins.json`.
### Límite importante
Conocer una llave pública sólo identifica un registro; no demuestra posesión de la privada. Por ello, el flujo de producción debe exigir:
- `--commit` en operaciones de escritura;
- commits SSH firmados;
- `config/allowed_signers` generado desde administradores activos;
- verificación en CI;
- protección de rama y revisión de cambios en archivos de identidad.
## 10. Requisitos funcionales
### RF-01. Navegación central
El sitio debe exponer las seis secciones principales y búsqueda global.
**Aceptación:** desde cualquier página se llega a cada sección en un paso.
### RF-02. Creación de memorias por CLI
La CLI debe aceptar flags humanos o JSON por archivo/`stdin`.
**Aceptación:** `memory create --from-json -` produce una página válida, un ID y una proyección actualizada.
### RF-03. Atribución
Toda escritura debe guardar el ID del administrador y la huella resuelta.
**Aceptación:** una llave no registrada falla con código de identidad y no escribe archivos.
### RF-04. Revisión por todos
La página de revisión debe mostrar faltantes y desaparecer la memoria al completar el conjunto requerido.
**Aceptación:** con dos revisores, la entrada sigue visible tras una confirmación y desaparece tras la segunda.
### RF-05. Invalidación por edición
Una revisión anterior no debe contar si cambia la memoria.
**Aceptación:** modificar el cuerpo y ejecutar `sync` vuelve a mostrar la entrada.
### RF-06. Tareas y tiempo
Debe ser posible crear, iniciar, detener, ajustar y completar una tarea.
**Aceptación:** el tiempo real es la suma reproducible de eventos y no puede ser negativo.
### RF-07. Proyectos
Debe ser posible crear y listar proyectos con objetivo y estado.
**Aceptación:** las tareas pueden enlazar un proyecto existente y la validación rechaza referencias inexistentes.
### RF-08. Operación por agentes
Debe existir salida JSON estable, esquemas JSON y `--dry-run`.
**Aceptación:** un agente puede descubrir el esquema, validar y escribir sin prompts ni parsing de texto humano.
### RF-09. Validación
La CLI debe revisar estructura, referencias, eventos, firmas autorizables, secretos probables, proyección y Zola.
**Aceptación:** `validate --require-zola` falla ante cualquier error bloqueante.
### RF-10. Auditoría Git
Una operación con `--commit` debe añadir sólo los archivos que creó o modificó y producir un commit SSH firmado.
**Aceptación:** `verify commit <sha>` confirma la firma contra `config/allowed_signers`.
## 11. Requisitos no funcionales
- **Portabilidad:** Markdown, JSON y Git deben seguir siendo legibles sin la CLI.
- **Determinismo:** la misma fuente genera la misma proyección, salvo la marca informativa `generated_at` controlada por la sincronización.
- **Concurrencia Git:** cada revisión y evento de tarea ocupa un archivo distinto para minimizar conflictos.
- **Seguridad:** no almacenar secretos; sitio no indexable y desplegado en red privada.
- **Accesibilidad:** navegación semántica, uso por teclado y contenido legible sin JavaScript salvo la búsqueda.
- **Mantenibilidad:** plantillas locales separadas del tema visual fijado.
- **Observabilidad:** errores con `kind`, detalles y código de salida estable.
- **Recuperación:** toda proyección puede eliminarse y regenerarse desde la fuente.
## 12. Diseño de la CLI
Forma general:
```text
clusterlog [--root DIR] [--json] [--ssh-key PATH] [--commit] [--dry-run] COMANDO
```
Principios:
- flags globales antes del comando;
- sin prompts interactivos;
- operaciones de creación atómicas;
- `stdin` con `--from-json -` o `--body-file -`;
- respuesta JSON envuelta en `{ok, command, data, warnings}`;
- errores JSON con `kind`, `message`, `details` y código de proceso;
- no sobrescribir eventos existentes;
- comandos idempotentes cuando existe un estado equivalente, por ejemplo revisar dos veces el mismo hash.
La referencia exhaustiva está en `CLI.md`.
## 13. Arquitectura técnica
```text
┌───────────────────────────────┐
│ Humano / Claude Code / agente │
└───────────────┬───────────────┘
│ JSON o flags
v
┌───────────────────────────────┐
│ clusterlog (Go) │
│ identidad · validación · Git │
└───────┬──────────────┬────────┘
│ │
│ Markdown │ eventos JSON inmutables
v v
┌───────────────────────────────────────────────┐
│ Repositorio Git │
│ content/ · data/ · config/allowed_signers │
└───────────────────┬───────────────────────────┘
│ sync
v
┌───────────────────────────────────────────────┐
│ data/generated/site_state.json │
│ cola · métricas · tareas · proyectos · memoria│
└───────────────────┬───────────────────────────┘
│ load_data
v
┌───────────────────────────────────────────────┐
│ Zola + recursos visuales de zola.386 │
└───────────────────────────────────────────────┘
```
### Decisión sobre estado compartido
No se usa `localStorage`, una cookie ni un formulario estático para las revisiones. Esos mecanismos no representan un consenso compartido ni son auditables. El estado se conserva en Git y Zola sólo lo presenta.
### Decisión sobre el tema
Se fijan Sass y archivos estáticos de `zola.386` a una revisión concreta. Las plantillas son locales, porque el tema original precede a cambios mayores de Zola/Tera y el proyecto necesita estructuras específicas para revisión, tareas y proyectos.
## 14. Alcance del MVP entregado
### Incluido
- estructura Zola y navegación;
- tema visual preparado mediante script;
- CLI Go funcional;
- contratos JSON y ejemplos;
- registro de administradores;
- memoria/revisión por hash;
- tareas y eventos de tiempo;
- proyectos;
- proyección para el sitio;
- validación y escaneo de secretos probables;
- commits y verificación SSH;
- pruebas Go;
- CI para Zola 0.23.3;
- documentación de seguridad y operación.
### Pendiente para producción
- reemplazar dominio de ejemplo;
- decidir hosting interno y autenticación;
- configurar protección de rama y verificación obligatoria de firmas;
- poblar administradores reales;
- revisar taxonomía de sistemas;
- probar la canalización en el repositorio corporativo;
- definir retención y clasificación de información;
- integrar respaldos del repositorio.
## 15. Fases recomendadas
### Fase 0 — Alineación y seguridad
- Aprobar nombres, secciones y política de revisión.
- Definir qué información está prohibida.
- Seleccionar repositorio y hosting interno.
- Registrar owners iniciales.
**Salida:** decisiones aprobadas y threat model aceptado.
### Fase 1 — Piloto técnico
- Desplegar el prototipo.
- Incorporar de dos a cuatro administradores.
- Importar cinco manuales y diez memorias recientes.
- Ejecutar dos reuniones semanales usando `/revision/`.
**Salida:** retroalimentación sobre fricción, campos y cola.
### Fase 2 — Adopción operativa
- Añadir plantilla a las instrucciones de Claude Code.
- Exigir `validate` y firmas en pull requests.
- Migrar proyectos y tareas activas.
- Establecer responsables de calidad documental.
**Salida:** bitácora como flujo normal, no como actividad extraordinaria.
### Fase 3 — Integraciones
- Enlaces automáticos a tickets.
- Notificaciones de revisión.
- Métricas y reportes de mantenimiento.
- Evaluar una API sólo con casos concretos que Git no resuelva.
## 16. Riesgos y mitigaciones
| Riesgo | Consecuencia | Mitigación |
|---|---|---|
| El agente incluye un secreto | exposición en Git y sitio | contrato explícito, escáner heurístico, revisión humana y herramientas corporativas de secret scanning |
| Se suplanta una huella en un JSON | falsa atribución aparente | exigir commit SSH firmado y verificarlo en CI |
| Dos personas editan el mismo estado | conflictos de merge | un archivo por evento y Markdown por entidad |
| Una memoria cambia después de revisarse | consenso obsoleto | hash de contenido en cada revisión |
| Un nuevo admin reabre todo el histórico | cola inmanejable | captura de revisores por memoria |
| El tema deja de compilar | bloqueo de publicación | revisión fijada, plantillas locales y build en CI |
| El sitio se expone a Internet | fuga de conocimiento operativo | VPN/SSO, `robots.txt`, `noindex`, mínimo privilegio |
| La documentación se vuelve burocrática | baja adopción | JSON preparado por agente, campos mínimos y plantillas breves |
| Git crece con muchos eventos | mantenimiento | eventos pequeños, política de archivo posterior y métricas reales antes de rediseñar |
## 17. Métricas de éxito del piloto
- Porcentaje de cambios relevantes con memoria dentro de 24 horas.
- Tiempo mediano para registrar una memoria.
- Porcentaje de memorias revisadas antes de la siguiente reunión.
- Antigüedad máxima de un pendiente de revisión.
- Número de manuales actualizados a partir de hallazgos en memorias.
- Diferencia entre tiempo estimado y real por categoría de tareas.
- Errores de validación o secretos bloqueados antes de llegar a la rama principal.
Las métricas deben usarse para mejorar el sistema, no para evaluar individualmente el desempeño sin contexto.
## 18. Criterios de aceptación del MVP
El MVP se considera aceptado cuando:
1. La CLI compila y todas las pruebas automatizadas pasan.
2. Zola construye el sitio con el tema preparado y las plantillas locales.
3. Un owner puede registrar a un segundo administrador.
4. Una memoria creada por JSON aparece en Memorias y Revisión.
5. Cada administrador puede revisarla desde su propia llave.
6. La memoria desaparece al completar el conjunto de revisores.
7. Editar la memoria vuelve a abrir su revisión.
8. Una tarea registra tiempo y puede completarse sin producir minutos negativos.
9. Una tarea puede enlazarse a un proyecto existente.
10. La validación detecta referencias inválidas, temporizadores inconsistentes y secretos probables.
11. Los commits creados con `--commit` se verifican contra `allowed_signers`.
12. La rama principal exige CI y firmas según la política del repositorio.
13. El sitio está accesible sólo en el entorno interno acordado.
## 19. Definición de terminado para cada cambio
Un cambio está terminado cuando:
- tiene pruebas o una justificación explícita de por qué no aplican;
- no introduce secretos ni datos personales innecesarios;
- actualiza esquema, documentación y ejemplo cuando cambia un contrato;
- ejecuta `go test ./...`, `go vet ./...`, `clusterlog sync --check` y `clusterlog validate`;
- construye con Zola en CI;
- conserva compatibilidad de salida JSON o incrementa versión con migración;
- queda en un commit firmado y revisable.
## 20. Backlog inmediato priorizado
### P0 — antes del piloto
- Configurar dominio interno, hosting y acceso.
- Ejecutar CI en el repositorio real.
- Registrar owners y política de firmas.
- Revisar patrones del escáner de secretos con ejemplos del entorno.
- Crear los primeros manuales críticos: acceso, respaldo, restauración y escalamiento.
### P1 — durante el piloto
- Añadir edición controlada de tareas y proyectos mediante eventos o comandos específicos.
- Añadir desactivación/rotación de llaves de administradores.
- Añadir verificación de firma para un rango de commits en CI.
- Generar una vista “mis pendientes”.
- Incorporar relaciones entre memorias y documentación que debe actualizarse.
### P2 — después de validar adopción
- Notificaciones de revisión pendientes.
- Importación de tickets.
- Reportes de actividad por sistema y riesgo.
- Archivado por periodo y política de retención.
- API o servicio de escritura sólo si los flujos Git resultan insuficientes.
## 21. Preguntas de gobierno que el equipo debe cerrar
- ¿El autor debe revisar su propia memoria o se excluye por defecto?
- ¿Qué riesgo exige revisión síncrona antes del cambio y cuál permite revisión posterior?
- ¿Cuánto tiempo puede permanecer una memoria pendiente?
- ¿Quién puede registrar, desactivar o rotar llaves?
- ¿Qué datos de infraestructura están permitidos en el sitio?
- ¿Qué sistema conserva el ticket canónico y cuál sólo enlaza?
- ¿Cuándo una memoria obliga a actualizar un manual o documento de arquitectura?
- ¿Qué estados de proyecto y tarea necesita realmente el equipo?
Estas decisiones son configuración y proceso; no requieren convertir el MVP en una aplicación con base de datos.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Proyecto Bitácora Operativa del Clúster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
SHELL := /usr/bin/env bash
GO ?= go
ZOLA ?= zola
BINARY ?= bin/clusterlog
.PHONY: all build test vet e2e theme sync sync-check validate validate-local site serve smoke ci clean
all: build
build:
mkdir -p $(dir $(BINARY))
$(GO) build -trimpath -o $(BINARY) ./cmd/clusterlog
test:
$(GO) test ./...
vet:
$(GO) vet ./...
e2e: build
./scripts/e2e-local.sh
theme:
./scripts/bootstrap-theme.sh
sync: build
./$(BINARY) --root . sync
sync-check: build
./$(BINARY) --root . sync --check
validate: build
./$(BINARY) --root . validate --require-zola
validate-local: build
./$(BINARY) --root . validate --skip-zola
site: theme sync validate
$(ZOLA) build
serve: theme sync
$(ZOLA) serve --interface 127.0.0.1
smoke: build
./scripts/smoke.sh
ci: theme test vet e2e build sync sync-check validate
$(ZOLA) build
clean:
rm -rf bin public themes/zola.386
+183
View File
@@ -0,0 +1,183 @@
# Bitácora Operativa del Clúster
Sitio interno y CLI auditable para conservar en un solo lugar la documentación, los manuales, las memorias de cambio, los proyectos, las tareas y la agenda de revisión semanal del equipo que administra un clúster.
La solución separa responsabilidades:
```text
administrador o agente IA
|
v
clusterlog CLI ---- identifica por huella SSH
| ---- valida contratos y secretos probables
| ---- escribe Markdown/eventos JSON
| ---- opcionalmente crea commit SSH firmado
v
repositorio Git ---- fuente de verdad y auditoría
|
v
clusterlog sync ---- genera data/generated/site_state.json
|
v
Zola ---- lectura, navegación, taxonomías y búsqueda
```
## Qué incluye el MVP
- Secciones de **Documentación**, **Manuales**, **Memorias**, **Proyectos**, **Tareas** y **Revisión**.
- Memorias operativas con autor, huella SSH, riesgo, sistemas, etiquetas, ticket, duración, validación y reversión.
- Cola de revisión por administrador. Una memoria desaparece cuando todos los revisores capturados para esa entrada la aprobaron.
- Invalidación automática de revisiones cuando cambia el contenido de una memoria.
- Tareas con estimación, fecha objetivo, responsables, estado y tiempo real mediante eventos `start`, `stop` y `adjust`.
- Proyectos con objetivo, responsables, alcance, hitos, criterio de éxito y estado.
- CLI no interactiva, salida JSON, entrada JSON por archivo o `stdin`, modo `--dry-run` y códigos de salida estables.
- Identidad por huella de llave pública SSH y commits Git firmados por SSH con `--commit`.
- Detección heurística de secretos antes de escribir contenido.
- Estado derivado y reproducible para que Zola siga siendo un sitio estático.
## Requisitos
- Go 1.23 o posterior.
- Git con soporte de firmas SSH.
- OpenSSH/`ssh-keygen` para firmar y verificar commits.
- Zola 0.23.3 para construir el sitio.
- Acceso al repositorio del tema durante `make theme`, o una copia local indicada con `ZOLA386_SOURCE`.
## Inicio rápido
```bash
make theme
make build
./bin/clusterlog --root . sync
./bin/clusterlog --root . validate --skip-zola
zola serve
```
El sitio queda disponible en la dirección que muestre Zola. Antes de publicarlo, cambie `base_url` en `zola.toml` y manténgalo detrás de VPN, red privada o autenticación corporativa.
## Registrar al primer administrador
La llave pública identifica el registro del administrador. Para una auditoría fuerte, las operaciones posteriores deben usar `--commit` y la rama protegida debe rechazar commits sin firma válida.
```bash
./bin/clusterlog \
--root . \
--ssh-key ~/.ssh/id_ed25519.pub \
admin add \
--id ana \
--name "Ana Torres" \
--email ana@example.org \
--key ~/.ssh/id_ed25519.pub \
--roles owner,admin
```
Configure Git para firmar el primer commit completo:
```bash
git init
git config user.name "Ana Torres"
git config user.email ana@example.org
git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519
git config commit.gpgsign true
git add .
git commit -S -m "bootstrap: iniciar bitácora operativa"
```
A partir de ahí:
```bash
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit identity
./bin/clusterlog --root . --ssh-key ~/.ssh/id_ed25519.pub --commit memory create --from-json examples/memory.json
```
`identity` no modifica el repositorio; `--commit` sólo tiene efecto en comandos que escriben archivos.
## Flujo para Claude Code u otro agente
1. El agente recopila y redacta los hechos de la sesión, sin incluir credenciales ni valores sensibles.
2. Consulta el contrato:
```bash
./bin/clusterlog --json schema memory
```
3. Prevalida sin escribir:
```bash
cat session-memory.json | ./bin/clusterlog \
--root . --json --dry-run \
--ssh-key ~/.ssh/id_ed25519.pub \
memory create --from-json -
```
4. Escribe y firma una unidad atómica:
```bash
cat session-memory.json | ./bin/clusterlog \
--root . --json --commit \
--ssh-key ~/.ssh/id_ed25519.pub \
memory create --from-json -
```
5. El agente devuelve al humano el `entry_id`, la ruta y el SHA del commit; nunca afirma que la firma fue válida sin comprobar la salida.
Las instrucciones completas para agentes están en [`AGENTS.md`](AGENTS.md) y [`CLAUDE.md`](CLAUDE.md).
## Flujo de revisión semanal
```bash
# Ver sólo las memorias que le faltan al administrador actual
./bin/clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
# Marcar la versión actual como revisada y crear un commit firmado
./bin/clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
review mark MEM-... --note "Revisado en reunión semanal"
# Confirmar la firma del commit actual
./bin/clusterlog --root . verify commit HEAD
```
La página `/revision/` contiene la cola pendiente y, como apoyo a la reunión, las tareas abiertas y los proyectos planeados, activos o bloqueados.
## Comandos de calidad
```bash
make test # pruebas unitarias e integración de la CLI
make vet # análisis estático de Go
make e2e # flujo completo en una copia temporal
make smoke # contratos, sincronización y validación local
make validate-local # sin requerir Zola
make site # tema + estado + validación completa + sitio
make ci # equivalente local de la canalización
```
## Estructura
```text
cmd/clusterlog/ punto de entrada de la CLI
internal/clusterlog/ modelo, comandos, validación y proyección
content/ Markdown fuente por sección
data/admins.json registro de administradores y política
data/review-events/ un archivo por revisión
data/task-events/ un archivo por evento de tiempo/estado
data/generated/ proyección regenerable para Zola
templates/ plantillas locales compatibles con Zola actual
static/ ajustes visuales y búsqueda
scripts/ preparación del tema y smoke tests
docs/ decisiones arquitectónicas y operación
```
## Documentación clave
- [`HOJA_DE_PROYECTO.md`](HOJA_DE_PROYECTO.md): alcance, requisitos, fases y criterios de aceptación.
- [`CLI.md`](CLI.md): contrato de comandos, JSON y códigos de salida.
- [`SECURITY.md`](SECURITY.md): modelo de confianza y despliegue recomendado.
- [`THEME_COMPATIBILITY.md`](THEME_COMPATIBILITY.md): estrategia para `zola.386`.
- [`docs/WEEKLY_REVIEW.md`](docs/WEEKLY_REVIEW.md): guion de la reunión semanal.
- [`VALIDATION_REPORT.md`](VALIDATION_REPORT.md): pruebas ejecutadas, límites y controles pendientes.
- [`RELEASE_NOTES.md`](RELEASE_NOTES.md): alcance de la entrega 0.1.0.
## Estado del prototipo
La CLI compila con Go 1.23 y sus pruebas cubren creación por JSON, resolución de identidad, revisión por hash, proyección de tareas y eventos independientes. La construcción de Zola está configurada en la CI incluida. Cuando el registro ya contiene administradores activos, la CI también verifica cada commit del pull request contra `config/allowed_signers`; el scaffold vacío omite esa comprobación durante el bootstrap inicial.
+49
View File
@@ -0,0 +1,49 @@
# Notas de entrega — 0.1.0
**Fecha:** 2026-08-12
## Entrega
Esta versión convierte la idea inicial en un MVP ejecutable compuesto por:
- un sitio estático Zola de consulta;
- la CLI `clusterlog` como única interfaz de escritura;
- Markdown y eventos JSON anexables como datos fuente;
- Git y firmas SSH como capa de auditoría;
- una proyección determinista para la cola de revisión, tareas y proyectos.
## Capacidades principales
- Secciones de documentación, manuales, memorias, proyectos, tareas y revisión semanal.
- Identidad de administradores mediante huella de llave pública SSH.
- Registro generado de firmantes autorizados para Git.
- Memorias con contexto, cambios, validación, riesgo y reversión.
- Revisión por todos los administradores capturados al crear la memoria.
- Invalidación automática de revisiones tras cualquier cambio de contenido.
- Proyectos con objetivo, alcance, hitos y criterio de éxito.
- Tareas con estado, prioridad, responsables, estimación, vencimiento y tiempo efectivo.
- Eventos independientes para reducir conflictos de Git.
- Contrato JSON, entrada por `stdin`, `--dry-run`, salida estable y códigos de error para agentes de IA.
- Validación de contenido, referencias, proyección y secretos probables.
- Búsqueda local sin servicios externos.
- CI con pruebas, detector de carreras, validación Zola, construcción y control de firmas.
## Decisiones relevantes
- El navegador no modifica estado en el MVP; toda escritura pasa por la CLI y por Git.
- La huella pública atribuye una identidad registrada, pero sólo una firma Git verificada demuestra posesión autorizada de la llave.
- Cada revisión está ligada al SHA-256 de la versión exacta de la memoria.
- La lista de revisores se captura al crear la memoria para que altas futuras no reabran historia antigua.
- El tema `zola.386` aporta estilos y recursos, mientras las plantillas se mantienen localmente para aislar cambios de compatibilidad de Zola.
## Inicio recomendado
```bash
make theme
make build
./bin/clusterlog --root . sync
./bin/clusterlog --root . validate --skip-zola
zola serve
```
Antes de uso operativo, complete los pendientes de producción descritos en `HOJA_DE_PROYECTO.md`, `SECURITY.md` y `VALIDATION_REPORT.md`.
+126
View File
@@ -0,0 +1,126 @@
# Seguridad y modelo de confianza
## Alcance
Este repositorio contiene conocimiento operativo potencialmente sensible. Debe tratarse como un sistema interno aun cuando el resultado sea un sitio estático.
## Garantías que sí busca el diseño
- Atribución de contenido a un administrador registrado por huella de llave pública SSH.
- Prueba criptográfica de autoría cuando la operación queda en un commit SSH firmado y autorizado.
- Historial de cambios, revisiones y tiempo dentro de Git.
- Revisión ligada al hash exacto de una memoria.
- Separación entre fuente y proyección derivada.
- Validación de referencias, estados y secuencias de eventos.
- Bloqueo heurístico de patrones frecuentes de secretos.
## Lo que una huella pública no demuestra
Un archivo JSON puede copiar una huella conocida. La huella por sí sola sirve para resolver una identidad, pero **no demuestra posesión de la llave privada**. La confianza fuerte exige verificar la firma del commit que introdujo ese archivo.
Por ello, el despliegue de producción debe:
1. exigir commits firmados en la rama protegida;
2. ejecutar `clusterlog verify commit` o una verificación equivalente en CI;
3. limitar quién puede modificar `data/admins.json` y `config/allowed_signers`;
4. requerir revisión de owner para altas, bajas y rotaciones de llaves;
5. conservar logs del proveedor Git.
## Llaves
- Sólo se guardan llaves **públicas**.
- Nunca copie una llave privada al repositorio ni a la entrada JSON de un agente.
- `--ssh-key` puede apuntar a la `.pub` para identidad; para `--commit`, la CLI intenta localizar la contraparte privada cuando está disponible.
- En estaciones administradas, prefiera `ssh-agent` o un dispositivo de hardware según la política local.
- Rote una llave comprometida de inmediato y revoque su acceso en el proveedor Git.
El MVP no incluye todavía un comando de rotación/desactivación. Hágalo mediante un cambio revisado por owners en `data/admins.json`, regenere `config/allowed_signers` con la CLI o una migración controlada y documente el incidente.
## Secretos
El escáner integrado es una defensa adicional, no una garantía. Antes de guardar una memoria:
- elimine tokens, contraseñas, cookies, llaves privadas y cadenas de conexión;
- redacte valores en comandos y salidas;
- no copie volcados completos de configuración;
- use identificadores de un gestor de secretos, nunca el valor;
- ejecute el secret scanner corporativo en pre-commit y CI.
Una vez que un secreto entra a Git, borrarlo del archivo actual no basta: debe revocarse y, si la política lo exige, reescribirse el historial.
## Sitio interno
El proyecto incluye `robots.txt` y `noindex`, pero estos controles **no son autenticación**. Despliegue detrás de al menos uno de los siguientes:
- VPN o red de administración;
- proxy con SSO y MFA;
- hosting privado con control de acceso;
- segmentación y listas de acceso.
No publique el directorio `public/` en un bucket o servicio accesible anónimamente.
## Contenido prohibido o restringido
No almacene:
- credenciales o material de autenticación;
- datos personales no necesarios;
- claves privadas, kubeconfigs con tokens o archivos de credenciales cloud;
- dumps de bases de datos;
- inventarios que revelen información clasificada más allá del acceso autorizado;
- instrucciones destructivas sin contexto, validación y reversión.
Defina una clasificación interna y márquela en la documentación si el entorno lo requiere.
## Agentes de IA
Un agente debe operar con los permisos mínimos del usuario y cumplir estas reglas:
- nunca afirmar que ejecutó o validó algo que no observó;
- no inventar comandos, salidas, tickets, horas ni revisores;
- presentar un `--dry-run` antes de escrituras sensibles;
- usar `--commit` sólo con autorización y acceso legítimo a la firma del usuario;
- no intentar exportar la llave privada;
- redactar secretos antes de llamar la CLI;
- detenerse ante errores de identidad, validación o firma;
- devolver el ID y SHA del commit como evidencia.
## Protección recomendada de rama
- Pull request obligatorio.
- CI obligatoria: tests, vet, sync check, validate, Zola build y secret scanning.
- Firmas verificadas.
- Revisión de CODEOWNERS para `data/admins.json`, `config/allowed_signers`, `.github/` y `SECURITY.md`.
- Prohibir force-push y borrado de rama principal.
- Respaldo del repositorio y prueba periódica de restauración.
Ejemplo de `CODEOWNERS` a adaptar:
```text
/data/admins.json @equipo/owners-bitacora
/config/allowed_signers @equipo/owners-bitacora
/.github/ @equipo/owners-bitacora
/SECURITY.md @equipo/seguridad @equipo/owners-bitacora
```
## Revisión de commits en CI
`scripts/verify-signatures-ci.sh` recorre el rango del pull request y ejecuta la verificación de cada commit contra `config/allowed_signers`. El scaffold sin administradores activos omite la regla para permitir el bootstrap; desde el primer alta, un commit sin firma SSH autorizada hace fallar la comprobación.
La protección de rama debe marcar la CI como obligatoria. Para conservar las firmas originales, prefiera una estrategia de merge que no sustituya todos los commits por un commit nuevo sin una identidad autorizada, o configure un bot de merge con una llave explícitamente aprobada. Los cambios de gobierno siguen necesitando revisión de owners además de una firma válida.
## Respuesta a incidentes
Ante contenido sensible o firma cuestionable:
1. retire temporalmente el sitio o restrinja acceso;
2. preserve evidencia y determine el alcance;
3. revoque credenciales expuestas;
4. suspenda la llave o usuario afectado;
5. corrija la rama mediante el procedimiento aprobado;
6. documente el incidente en el sistema canónico, enlazando una memoria redactada cuando sea seguro;
7. actualice manuales y controles preventivos.
## Reporte de vulnerabilidades
No abra un issue público con detalles sensibles. Use el canal privado de seguridad de la organización y proporcione versión, impacto, pasos de reproducción y una prueba sin secretos reales.
+1
View File
@@ -0,0 +1 @@
76b832471eead59133fbfffe63dd5e5ef1ec708a2e4c4b9b864d665350616ad5 bin/clusterlog
+80
View File
@@ -0,0 +1,80 @@
# Compatibilidad con `zola.386`
## Objetivo
Conservar la estética retro y responsiva de `lopes/zola.386` sin acoplar la lógica del producto a plantillas históricas del tema.
## Revisión fijada
El script `scripts/bootstrap-theme.sh` usa por defecto:
```text
d9c9726435d1347341f2171f9aa751291f1db93d
```
Puede cambiarse de forma explícita:
```bash
ZOLA386_REF=<commit> ./scripts/bootstrap-theme.sh
```
Para una copia local u offline:
```bash
ZOLA386_SOURCE=/ruta/a/zola.386 ./scripts/bootstrap-theme.sh
```
## Qué se importa
- `sass/`
- `static/`
- `theme.toml`
- licencia y README upstream, cuando existen
## Qué no se importa
Las plantillas upstream no se copian. El proyecto mantiene plantillas locales en `templates/` para:
- usar el contrato actual de Zola/Tera;
- cargar `data/generated/site_state.json`;
- presentar memorias, proyectos, tareas y revisión;
- controlar navegación, búsqueda y accesibilidad;
- evitar que una actualización visual cambie la semántica de datos.
Zola da precedencia a las plantillas locales sobre las del tema, por lo que se conserva el tema como fuente visual y no como capa de aplicación.
## Actualización del tema
1. Seleccione un commit, no una rama flotante.
2. Ejecute el script con `ZOLA386_REF`.
3. Revise visualmente todas las secciones.
4. Ejecute:
```bash
go test ./...
go vet ./...
make sync
clusterlog --root . validate --require-zola
zola build
```
5. Compare HTML, búsqueda y responsive.
6. Registre la nueva revisión en un commit firmado.
## Búsqueda en español
El proyecto genera `search_index.es.json` con `elasticlunr_json`, pero no carga el motor Elasticlunr en el navegador. `static/js/ops-search.js` lee los documentos del índice y hace una coincidencia local normalizada, sin depender de stemmers externos ni de una CDN. Para el tamaño esperado del MVP, esto favorece portabilidad y funcionamiento offline.
## Zola fijado
La CI usa Zola `0.23.3`. Actualizar Zola requiere una pull request separada que incluya:
- revisión de notas de migración;
- `zola check`;
- construcción completa;
- prueba de búsqueda;
- prueba visual de portada, secciones, páginas, taxonomías y revisión.
## Licencias
La licencia del tema se conserva dentro de `themes/zola.386/LICENSE` cuando el script lo prepara. La licencia del proyecto no sustituye ni elimina las obligaciones del componente upstream.
+104
View File
@@ -0,0 +1,104 @@
# Informe de validación del MVP
**Producto:** Bitácora Operativa del Clúster
**Versión:** 0.1.0
**Fecha de corte:** 2026-08-12, zona `America/Mexico_City`
**Plataforma local:** Linux `x86_64`, Go 1.23.2, Git 2.47.3, Node.js 22.16.0
## Resultado ejecutivo
El prototipo de la CLI quedó compilado y todas las comprobaciones ejecutables en el entorno local terminaron correctamente. El flujo integral validó el ciclo administrador → memoria → revisión por dos personas → invalidación por edición → nueva revisión → proyecto → tarea → tiempo → cierre.
La construcción real del sitio con Zola y una firma SSH válida no pudieron ejecutarse en este entorno porque no estaban instalados `zola` ni `ssh-keygen`. Ambos controles están definidos en la canalización de GitHub Actions y deben ejecutarse antes de promover el repositorio a piloto.
## Comprobaciones ejecutadas
| Comprobación | Comando | Resultado |
|---|---|---|
| Formato Go | `test -z "$(gofmt -l cmd internal)"` | Correcto |
| Pruebas Go | `go test ./...` | Correcto |
| Detector de carreras | `go test -race ./...` | Correcto |
| Cobertura | `go test -cover ./...` | 32.9 % en `internal/clusterlog` |
| Análisis estático | `go vet ./...` | Correcto |
| Compilación | `make build` | Correcto |
| Flujo integral | `make e2e` | Correcto |
| Contratos rápidos | `make smoke` | Correcto |
| JavaScript | `node --check static/js/ops-search.js` | Correcto |
| Scripts shell | `bash -n scripts/*.sh` | Correcto |
| Proyección | `clusterlog sync` | Sin diferencias |
| Reproducibilidad | `clusterlog sync --check` | Correcto |
| Validación local | `clusterlog validate --skip-zola` | Válido, sin errores ni advertencias |
| Firma no autorizada | prueba negativa de `verify-signatures-ci.sh` | Rechazada correctamente |
| Bootstrap sin administradores | `verify-signatures-ci.sh` | Omisión controlada correcta |
## Escenario E2E validado
1. Registra el primer administrador como `owner` y un segundo administrador.
2. Crea una memoria con política `all-active`.
3. Comprueba que ambos administradores aparecen como revisores requeridos.
4. Registra cada revisión en un archivo de evento independiente.
5. Comprueba que la memoria desaparece de la cola al completar las revisiones.
6. Modifica la memoria, recalcula su hash y comprueba que las revisiones anteriores dejan de aplicar.
7. Registra nuevamente las dos revisiones.
8. Crea un proyecto activo y una tarea vinculada.
9. Registra inicio de tiempo, ajuste manual y cierre de la tarea.
10. Comprueba la proyección final, la independencia de eventos y la regeneración determinista.
## Estado inicial de demostración
La proyección incluida contiene:
- `0` administradores activos;
- `0` memorias pendientes de revisión;
- `1` tarea abierta de demostración;
- `1` proyecto planeado de demostración.
La memoria de ejemplo declara `review_required = false` para no contaminar la primera reunión real.
## Binario entregado
- Plataforma: Linux `amd64`.
- Tipo: ejecutable ELF de 64 bits, enlazado estáticamente.
- Ruta: `bin/clusterlog`.
- SHA-256 al cierre de esta validación: se publica en `SHA256SUMS`.
## Comprobaciones delegadas a CI
La canalización `.github/workflows/ci.yml` fija Zola 0.23.3 y ejecuta:
- descarga del tema fijado a una revisión concreta;
- validación con `--require-zola`;
- construcción completa con `zola build`;
- verificación de todos los commits de un pull request contra `config/allowed_signers` cuando ya existen administradores activos.
Estas comprobaciones están configuradas, pero este informe no afirma que GitHub Actions ya haya ejecutado una corrida. Debe conservarse como requisito de aceptación del primer pull request.
## Comprobaciones pendientes antes del piloto
1. Ejecutar la CI en el repositorio remoto y conservar una corrida verde.
2. Crear y verificar al menos un commit firmado con una llave SSH real autorizada.
3. Revisar el sitio generado en escritorio y móvil detrás del mecanismo de acceso interno elegido.
4. Probar el flujo con al menos dos administradores reales y una memoria no demostrativa.
5. Subir la cobertura de lógica crítica, especialmente errores de Git, validación de contenido y colisiones de archivos.
6. Ejecutar revisión de amenazas y acordar protección de rama, propietarios de código y retención.
## Repetición local
```bash
make build
make test
make vet
make e2e
make smoke
node --check static/js/ops-search.js
for f in scripts/*.sh; do bash -n "$f"; done
./bin/clusterlog --root . --json sync --check
./bin/clusterlog --root . --json validate --skip-zola
```
En un equipo con Zola, OpenSSH y acceso al tema:
```bash
make theme
make site
```
+1
View File
@@ -0,0 +1 @@
0.1.0
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"flag"
"fmt"
"os"
"example.org/bitacora-cluster/internal/clusterlog"
)
func main() {
fs := flag.NewFlagSet("clusterlog", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
root := fs.String("root", "", "raíz del proyecto")
jsonMode := fs.Bool("json", false, "salida JSON estable")
sshKey := fs.String("ssh-key", "", "llave pública SSH o ruta base")
commit := fs.Bool("commit", false, "crea un commit Git SSH firmado")
dryRun := fs.Bool("dry-run", false, "valida sin escribir")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Uso: clusterlog [opciones globales] <comando> [opciones]")
fmt.Fprintln(os.Stderr, "Ejecute clusterlog help para ver los comandos.")
fs.PrintDefaults()
}
if err := fs.Parse(os.Args[1:]); err != nil {
os.Exit(clusterlog.PrintError(os.Stderr, *jsonMode, clusterlog.NewError(clusterlog.ExitUsage, "invalid_global_flags", err.Error(), nil)))
}
app := clusterlog.New(clusterlog.Options{
Root: *root, JSON: *jsonMode, SSHKey: *sshKey, Commit: *commit, DryRun: *dryRun,
Stdout: os.Stdout, Stderr: os.Stderr, Stdin: os.Stdin, Executable: "clusterlog",
})
if err := app.Run(fs.Args()); err != nil {
os.Exit(clusterlog.PrintError(os.Stderr, *jsonMode, err))
}
}
+3
View File
@@ -0,0 +1,3 @@
# Archivo generado por clusterlog. Contiene únicamente llaves públicas.
# principal namespaces="git" tipo base64
fenandos@hotmail.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICKqDgPynfafNBlem/TW9hJZ0Ejp5UFrnUk72hcpJl1v
+14
View File
@@ -0,0 +1,14 @@
+++
title = "Bitácora Operativa del Clúster"
description = "Punto único para entender, operar y revisar los cambios técnicos del clúster."
sort_by = "date"
template = "index.html"
+++
La bitácora reúne conocimiento estable y actividad operativa en un mismo sitio, sin mezclar sus ciclos de vida:
- **Documentación** explica cómo está construido el clúster.
- **Manuales** indican cómo ejecutar procedimientos repetibles.
- **Memorias** registran qué se cambió, quién lo hizo y cómo se validó.
- **Proyectos** agrupan objetivos, alcance e hitos.
- **Tareas** muestran trabajo pendiente, responsables y tiempo real.
+9
View File
@@ -0,0 +1,9 @@
+++
title = "Documentación"
description = "Arquitectura, decisiones, inventarios, políticas y explicaciones técnicas duraderas."
sort_by = "weight"
template = "section.html"
page_template = "page.html"
+++
Aquí vive el conocimiento descriptivo del clúster: el **qué**, el **por qué** y las relaciones entre componentes.
@@ -0,0 +1,33 @@
+++
title = "Arquitectura de la bitácora operativa"
description = "Separación entre contenido legible, eventos de estado, identidad SSH y publicación estática."
date = 2026-08-12T00:00:00Z
authors = ["sistema"]
slug = "arquitectura-bitacora"
template = "page.html"
weight = 10
[taxonomies]
categories = ["documentacion"]
tags = ["arquitectura", "git", "zola"]
systems = ["bitacora"]
[extra]
id = "DOC-DEMO-001"
kind = "documentacion"
author_id = "sistema"
author_fingerprint = ""
+++
## Capas
1. **Contenido fuente:** páginas Markdown con front matter TOML.
2. **Eventos:** revisiones y temporizadores en archivos JSON anexables.
3. **Identidad:** registro de administradores y huellas de llaves públicas SSH.
4. **Auditoría:** commits Git, preferentemente firmados con SSH.
5. **Proyección:** `clusterlog sync` calcula el estado actual para las vistas de Zola.
6. **Publicación:** Zola genera HTML estático, búsqueda, taxonomías y feeds.
## Regla de diseño
El sitio web es una proyección de lectura. Las mutaciones se realizan con la CLI, se validan y después se integran mediante Git.
+9
View File
@@ -0,0 +1,9 @@
+++
title = "Manuales"
description = "Runbooks y procedimientos operativos verificables, con prerrequisitos, pasos y reversión."
sort_by = "weight"
template = "section.html"
page_template = "page.html"
+++
Los manuales deben ser ejecutables por una persona distinta de quien los escribió y declarar claramente sus condiciones de seguridad.
+45
View File
@@ -0,0 +1,45 @@
+++
title = "Registrar al primer administrador"
description = "Inicialización segura del registro de identidad de clusterlog."
date = 2026-08-12T00:00:00Z
authors = ["sistema"]
slug = "registrar-primer-administrador"
template = "page.html"
weight = 10
[taxonomies]
categories = ["manuales"]
tags = ["identidad", "ssh", "inicio"]
systems = ["bitacora"]
[extra]
id = "RUN-DEMO-001"
kind = "manuales"
author_id = "sistema"
author_fingerprint = ""
+++
## Prerrequisitos
- Una llave SSH personal con su archivo público disponible.
- Git inicializado en la raíz del proyecto.
- El binario `clusterlog` compilado.
## Procedimiento
```bash
git init
make build
./bin/clusterlog --ssh-key ~/.ssh/id_ed25519 --commit admin add \
--id ana --name "Ana Operadora" --email ana@example.org
```
El primer administrador recibe automáticamente el rol `owner`. Después, sólo un `owner` activo puede registrar a otra persona.
## Verificación
```bash
./bin/clusterlog --ssh-key ~/.ssh/id_ed25519.pub identity
./bin/clusterlog validate --skip-zola
git log --show-signature -1
```
+10
View File
@@ -0,0 +1,10 @@
+++
title = "Memorias"
description = "Historia cronológica y auditable de intervenciones, cambios, incidentes y correcciones."
sort_by = "date"
template = "memories.html"
page_template = "memory.html"
generate_feeds = true
+++
Cada entrada describe una sesión de trabajo o una unidad de cambio. Las entradas sujetas a revisión aparecen también en **Revisión semanal**.
+47
View File
@@ -0,0 +1,47 @@
+++
title = "Inicializar la estructura de la bitácora"
description = "Ejemplo de memoria no operativa que explica el formato esperado."
date = 2026-08-12T00:00:00Z
authors = ["sistema"]
slug = "ejemplo-inicial"
template = "memory.html"
[taxonomies]
categories = ["memorias"]
tags = ["ejemplo", "inicio"]
systems = ["bitacora"]
[extra]
id = "MEM-DEMO-001"
kind = "memory"
author_id = "sistema"
author_fingerprint = ""
risk = "low"
ticket = ""
review_required = false
review_policy = "all-active"
started_at = "2026-08-12T00:00:00Z"
finished_at = "2026-08-12T00:15:00Z"
duration_minutes = 15
agent = "scaffold"
+++
## Contexto
Se creó una entrada demostrativa para documentar el contrato de las memorias sin añadirla a la cola de revisión.
## Cambios realizados
- Se definió el front matter mínimo.
- Se añadieron taxonomías de etiquetas y sistemas.
- Se declaró explícitamente que el ejemplo no requiere revisión.
## Validación
- La página es parseable por la CLI.
- El identificador comienza con `MEM-`.
- No contiene información sensible.
## Riesgo y reversión
Eliminar esta página cuando el equipo haya creado su primera memoria real.
+9
View File
@@ -0,0 +1,9 @@
+++
title = "Proyectos"
description = "Objetivos compartidos, alcance, responsables, hitos y criterios de éxito."
sort_by = "date"
template = "projects.html"
page_template = "project.html"
+++
Los proyectos dan contexto a las tareas y permiten explicar por qué existe cada línea de trabajo.
+44
View File
@@ -0,0 +1,44 @@
+++
title = "Adopción de la bitácora operativa"
description = "Proyecto demostrativo para desplegar el flujo de documentación y revisión."
date = 2026-08-12T00:00:00Z
authors = ["sistema"]
slug = "adopcion-bitacora"
template = "project.html"
[taxonomies]
categories = ["proyectos"]
tags = ["ejemplo", "operaciones"]
systems = ["bitacora"]
[extra]
id = "PRJ-DEMO-001"
kind = "project"
status = "planned"
owners = []
target_date = "2026-09-30"
created_by = "sistema"
author_fingerprint = ""
+++
## Objetivo
Conseguir que todas las intervenciones relevantes del equipo queden documentadas, revisadas y localizables en un único repositorio.
## Alcance
- Registrar administradores.
- Crear memorias desde sesiones asistidas por IA.
- Celebrar una revisión semanal basada en la cola pendiente.
- Adoptar proyectos y tareas compartidas.
## Hitos
- Primer administrador registrado.
- Primera memoria real revisada por todo el equipo.
- Primer mes con cobertura operativa completa.
## Criterios de éxito
- Toda memoria de cambio relevante se publica el mismo día.
- Ningún pendiente de revisión supera dos reuniones semanales.
+8
View File
@@ -0,0 +1,8 @@
+++
title = "Revisión semanal"
description = "Memorias que aún requieren confirmación de uno o más administradores."
template = "review.html"
in_search_index = false
+++
Esta página es la agenda operativa de la reunión semanal. Una memoria desaparece cuando todos los revisores requeridos han registrado su revisión para el hash actual del contenido.
+9
View File
@@ -0,0 +1,9 @@
+++
title = "Tareas"
description = "Trabajo visible para el equipo, con prioridad, estimación, fecha objetivo y tiempo acumulado."
sort_by = "date"
template = "tasks.html"
page_template = "task.html"
+++
El estado actual y el tiempo real se calculan a partir de eventos anexables; la definición original de la tarea permanece legible en Markdown.
+36
View File
@@ -0,0 +1,36 @@
+++
title = "Registrar al equipo inicial"
description = "Tarea demostrativa vinculada al proyecto de adopción."
date = 2026-08-12T00:00:00Z
authors = ["sistema"]
slug = "registrar-equipo-inicial"
template = "task.html"
[taxonomies]
categories = ["tareas"]
tags = ["ejemplo", "inicio"]
systems = ["bitacora"]
[extra]
id = "TSK-DEMO-001"
kind = "task"
status = "todo"
priority = "high"
project_id = "PRJ-DEMO-001"
assignees = []
estimate_minutes = 30
due = "2026-08-31"
created_by = "sistema"
author_fingerprint = ""
+++
## Objetivo
Registrar las llaves públicas de las personas que administran el clúster y comprobar la resolución de identidad.
## Criterios de aceptación
- Existe al menos un `owner` activo.
- Cada administrador tiene una huella SSH única.
- `config/allowed_signers` contiene todas las llaves activas.
- `clusterlog validate --skip-zola` termina sin errores.
+26
View File
@@ -0,0 +1,26 @@
{
"version": 1,
"review_policy": {
"default": "all-active",
"include_author": true
},
"admins": [
{
"id": "gen",
"name": "Fernando Rivas",
"email": "fenandos@hotmail.com",
"active": true,
"roles": [
"admin",
"owner"
],
"ssh_keys": [
{
"fingerprint": "SHA256:JhN1Tt4jYqWoJIznDJ6QkMAiOR6HFHr3xLz9aBJqmPg",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICKqDgPynfafNBlem/TW9hJZ0Ejp5UFrnUk72hcpJl1v fenan@asu",
"added_at": "2026-08-13T06:42:03.795557478Z"
}
]
}
]
}
+69
View File
@@ -0,0 +1,69 @@
{
"version": 2,
"generated_at": "2026-08-13T06:42:03Z",
"source_sha256": "64a404e7ccb669ab6712d92141f5e2446eb3256723075a42bd7037380f285050",
"stats": {
"active_admins": 1,
"pending_reviews": 0,
"open_tasks": 1,
"active_projects": 1
},
"review_queue": [],
"recent_memories": [
{
"entry_id": "MEM-DEMO-001",
"title": "Inicializar la estructura de la bitácora",
"description": "Ejemplo de memoria no operativa que explica el formato esperado.",
"date": "2026-08-12T00:00:00Z",
"author_id": "sistema",
"author_fingerprint": "",
"risk": "low",
"review_required": false,
"review_policy": "all-active",
"reviewer_snapshot": false,
"reviewers": [],
"tags": [
"ejemplo",
"inicio"
],
"systems": [
"bitacora"
],
"slug": "ejemplo-inicial",
"permalink": "/memorias/ejemplo-inicial/",
"source_path": "content/memorias/ejemplo-inicial.md",
"content_sha256": "46b9387e70d9d9522bb59e1db42a21b4c523f22d50e0d33917a4f2d72ecc7c42"
}
],
"tasks": [
{
"task_id": "TSK-DEMO-001",
"title": "Registrar al equipo inicial",
"description": "Tarea demostrativa vinculada al proyecto de adopción.",
"status": "todo",
"priority": "high",
"project_id": "PRJ-DEMO-001",
"assignees": [],
"estimate_minutes": 30,
"actual_minutes": 0,
"due": "2026-08-31",
"running_by": [],
"slug": "registrar-equipo-inicial",
"permalink": "/tareas/registrar-equipo-inicial/",
"source_path": "content/tareas/tarea-ejemplo.md"
}
],
"projects": [
{
"project_id": "PRJ-DEMO-001",
"title": "Adopción de la bitácora operativa",
"description": "Proyecto demostrativo para desplegar el flujo de documentación y revisión.",
"status": "planned",
"owners": [],
"target_date": "2026-09-30",
"slug": "adopcion-bitacora",
"permalink": "/proyectos/adopcion-bitacora/",
"source_path": "content/proyectos/proyecto-ejemplo.md"
}
]
}
View File
View File
+84
View File
@@ -0,0 +1,84 @@
# Reunión semanal de revisión operativa
## Objetivo
Asegurar que el conocimiento de los cambios técnicos se distribuya, que los riesgos pendientes tengan dueño y que manuales, tareas y proyectos reflejen la realidad.
## Preparación individual
Antes de la reunión, cada administrador ejecuta:
```bash
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub review list --mine
clusterlog --root . --json --ssh-key ~/.ssh/id_ed25519.pub task list --mine
```
Debe leer las memorias, anotar preguntas y revisar evidencias. Marcar “revisada” significa comprender el cambio y sus implicaciones; no necesariamente aprobar retrospectivamente una decisión.
## Agenda sugerida
### 1. Riesgo alto o crítico
Abrir primero memorias `critical` y `high`:
- resultado observado;
- validaciones;
- rollback;
- riesgo residual;
- seguimiento requerido.
### 2. Resto de memorias pendientes
Para cada entrada:
- confirmar sistemas afectados;
- aclarar términos y comandos;
- detectar conocimiento reusable;
- decidir si debe actualizarse un manual o documento;
- crear una tarea cuando exista una acción concreta.
Cada administrador registra su propia confirmación:
```bash
clusterlog --root . --json --commit --ssh-key ~/.ssh/id_ed25519.pub \
review mark MEM-... --note "Revisado en reunión semanal"
```
No debe existir una sola persona marcando por todo el equipo.
### 3. Tareas abiertas
Revisar especialmente:
- bloqueadas;
- vencidas o próximas a vencer;
- con tiempo real muy superior a la estimación;
- con temporizador activo desde una sesión anterior;
- sin responsable.
### 4. Proyectos
Para proyectos planeados, activos o bloqueados:
- progreso contra objetivo;
- siguiente hito;
- dependencia o riesgo;
- tareas faltantes;
- fecha objetivo.
### 5. Cierre
- La cola de memorias debe quedar vacía o con una causa y fecha de seguimiento.
- Toda acción debe convertirse en tarea con responsable.
- Toda mejora repetible debe actualizar un manual.
- Todo cambio de arquitectura debe actualizar Documentación.
## Evidencia de cierre
```bash
clusterlog --root . sync --check
clusterlog --root . validate
clusterlog --root . verify commit HEAD
```
El secretario de la reunión puede enlazar los IDs discutidos en la minuta corporativa, sin duplicar contenido sensible.
+41
View File
@@ -0,0 +1,41 @@
# ADR-0001: Git como fuente y Zola como proyección
- **Estado:** aceptada
- **Fecha:** 2026-08-12
## Contexto
El sitio debe mostrar estado compartido —revisiones, tiempo de tareas y estados— aunque Zola genere archivos estáticos. Guardar ese estado en el navegador no sería compartido ni auditable. Introducir una base de datos en el MVP aumentaría operación, permisos, respaldo y superficie de ataque.
## Decisión
La fuente de verdad será:
- Markdown para entidades legibles;
- un archivo JSON inmutable por evento;
- Git para historia y concurrencia;
- `clusterlog sync` para producir `data/generated/site_state.json`;
- Zola para consumir la proyección en modo sólo lectura.
La proyección es desechable y nunca se edita a mano.
## Consecuencias positivas
- El repositorio funciona sin un servicio permanente.
- Toda la historia es inspeccionable con herramientas estándar.
- Los conflictos se reducen al separar eventos.
- La publicación puede ser atómica y cacheable.
- Es posible reconstruir el sitio desde cero.
## Consecuencias negativas
- No hay edición web en tiempo real.
- Cada escritura necesita sincronización Git.
- Con volúmenes muy altos de eventos habrá que medir rendimiento y quizá archivar.
- La coherencia depende de CI, validación y disciplina de ramas.
## Alternativas descartadas
- `localStorage`: no compartido, fácil de perder y no auditable.
- Archivo JSON central mutable: conflictos frecuentes entre administradores.
- Base de datos desde el MVP: complejidad desproporcionada para el flujo y volumen iniciales.
+35
View File
@@ -0,0 +1,35 @@
# ADR-0002: Identidad por llave SSH y prueba mediante commit firmado
- **Estado:** aceptada
- **Fecha:** 2026-08-12
## Contexto
Los administradores ya usan llaves SSH y los agentes operan en su sesión local. Se busca evitar un sistema adicional de usuarios y contraseñas, pero una llave pública almacenada no basta para probar que una operación la realizó quien posee la privada.
## Decisión
- El registro `data/admins.json` asocia una o más huellas públicas con cada administrador.
- La CLI resuelve la identidad a partir de una llave local.
- El contenido conserva `admin_id` y huella para trazabilidad.
- Las escrituras de producción usan `--commit` para crear un commit Git firmado por SSH.
- `config/allowed_signers` se genera desde administradores activos.
- CI y protección de rama verifican firmas y autorización.
## Consecuencias positivas
- Reutiliza la práctica operativa existente.
- Evita secretos de aplicación dentro del repositorio.
- La evidencia se integra con el historial Git.
- Los agentes pueden usar el mismo contrato sin conocer la llave privada.
## Consecuencias negativas
- La configuración inicial de Git/OpenSSH requiere cuidado.
- Rotación y revocación deben gobernarse explícitamente.
- Una operación sin `--commit` queda atribuida sólo de manera declarativa.
- El proveedor Git debe aplicar políticas de firma y rama.
## Regla de interpretación
La huella es identidad declarada; una firma válida y autorizada es evidencia criptográfica. Nunca deben tratarse como equivalentes.
+31
View File
@@ -0,0 +1,31 @@
# ADR-0003: Revisiones por hash y conjunto capturado
- **Estado:** aceptada
- **Fecha:** 2026-08-12
## Contexto
Un booleano `revisada` no indica quién revisó ni qué versión vio. Además, añadir un administrador meses después podría reabrir todo el histórico si la política se recalculara siempre contra el equipo actual.
## Decisión
Al crear una memoria:
- se captura el conjunto de revisores requerido;
- se calcula un hash SHA-256 sobre la versión de contenido;
- cada revisión registra administrador, huella, fecha y hash;
- sólo cuentan revisiones cuyo hash coincide con el actual;
- la cola se completa cuando todos los IDs capturados tienen un evento válido.
## Consecuencias positivas
- Editar una memoria invalida automáticamente el consenso anterior.
- La cola histórica no cambia al incorporar administradores futuros.
- Es posible explicar exactamente por qué una entrada está pendiente.
- Los eventos permanecen inmutables y auditables.
## Consecuencias negativas
- Corregir incluso un detalle menor puede reabrir la revisión.
- El equipo debe decidir cuándo una corrección editorial merece conservar o repetir consenso; el MVP prioriza seguridad y vuelve a abrir siempre.
- Bajas de personal requieren una política de excepción explícita, todavía no automatizada.
+9
View File
@@ -0,0 +1,9 @@
{
"section": "manuales",
"title": "Reemplazo seguro de un nodo de cómputo",
"description": "Runbook para drenar, reemplazar y reincorporar un nodo",
"tags": ["runbook", "hardware"],
"systems": ["compute", "kubernetes"],
"weight": 20,
"body_markdown": "## Prerrequisitos\n\n- Ventana aprobada.\n- Capacidad suficiente en los nodos restantes.\n- Acceso fuera de banda probado.\n\n## Procedimiento\n\n1. Confirmar salud del clúster.\n2. Drenar el nodo según la política de cargas.\n3. Verificar reubicación.\n4. Reemplazar el equipo.\n5. Reincorporar y validar.\n\n## Reversión\n\nRehabilitar el nodo anterior únicamente cuando sea seguro y documentar cualquier desviación.\n"
}
+26
View File
@@ -0,0 +1,26 @@
{
"title": "Actualizar firmware del switch spine-01",
"description": "Cambio controlado y validación de enlaces del spine principal",
"tags": ["red", "firmware"],
"systems": ["spine-01"],
"risk": "high",
"ticket": "CHG-1042",
"review_required": true,
"review_policy": "all-active-except-author",
"context": "La versión instalada contiene una corrección aprobada para pérdida intermitente de telemetría.",
"changes": [
"Respaldé la configuración vigente.",
"Instalé la imagen aprobada y reinicié durante la ventana de mantenimiento."
],
"commands": [
"show version",
"show interfaces status"
],
"validation": [
"Todos los uplinks quedaron en estado up.",
"No aparecieron errores nuevos en los registros durante 20 minutos."
],
"rollback": "Arrancar la imagen anterior y restaurar la configuración respaldada.",
"duration_minutes": 75,
"agent": "claude-code"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Estandarizar observabilidad del clúster",
"description": "Unificar métricas, registros, alertas y tableros operativos",
"objective": "Lograr cobertura observable y criterios de alerta consistentes para todos los componentes críticos.",
"owners": [],
"target_date": "2026-12-15",
"status": "planned",
"tags": ["observabilidad"],
"systems": ["prometheus", "loki", "grafana"],
"scope": [
"Inventario de alertas",
"Dashboards base",
"Retención y capacidad"
],
"out_of_scope": [
"Sustituir el sistema corporativo de incidentes"
],
"milestones": [
"Completar inventario",
"Publicar estándares",
"Migrar componentes críticos"
],
"success_criteria": [
"100% de nodos reporta métricas base.",
"Todas las alertas críticas tienen owner y runbook."
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"title": "Probar restauración de etcd",
"description": "Ejercicio controlado de recuperación del plano de control",
"project_id": "",
"priority": "high",
"assignees": [],
"estimate_minutes": 120,
"due": "2026-09-15",
"tags": ["backup", "dr"],
"systems": ["etcd"],
"objective": "Validar el RTO y corregir el runbook de restauración.",
"acceptance_criteria": [
"La restauración termina sin pérdida fuera del punto respaldado.",
"El RTO medido es menor a 60 minutos.",
"El manual refleja todos los pasos observados."
],
"notes": "Ejecutar en un entorno aislado con snapshots aprobados."
}
+3
View File
@@ -0,0 +1,3 @@
module example.org/bitacora-cluster
go 1.23
@@ -0,0 +1,91 @@
package clusterlog
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func runJSONCommand(t *testing.T, root, keyPath, stdin string, now time.Time, args ...string) ResultEnvelope {
t.Helper()
var stdout bytes.Buffer
var stderr bytes.Buffer
app := New(Options{
Root: root, JSON: true, SSHKey: keyPath, Stdout: &stdout, Stderr: &stderr,
Stdin: strings.NewReader(stdin), Now: func() time.Time { return now }, Executable: "clusterlog",
})
if err := app.Run(args); err != nil {
t.Fatalf("command %v failed: %v\nstderr: %s", args, err, stderr.String())
}
var envelope ResultEnvelope
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid command JSON for %v: %v\n%s", args, err, stdout.String())
}
if !envelope.OK {
t.Fatalf("command returned non-ok envelope: %#v", envelope)
}
return envelope
}
func envelopeDataMap(t *testing.T, envelope ResultEnvelope) map[string]any {
t.Helper()
data, ok := envelope.Data.(map[string]any)
if !ok {
t.Fatalf("unexpected data type: %T", envelope.Data)
}
return data
}
func TestCLIAdminMemoryReviewFlow(t *testing.T) {
root := testProjectRoot(t)
keyPath := filepath.Join(root, "ana.pub")
if err := os.WriteFile(keyPath, []byte(testOpenSSHPublicKey(9, "ana@test")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 12, 14, 0, 0, 123, time.UTC)
runJSONCommand(t, root, keyPath, "", now,
"admin", "add", "--id", "ana", "--name", "Ana Operadora", "--email", "ana@example.test")
memoryJSON := `{
"title": "Reiniciar controlador de almacenamiento",
"description": "Corrección controlada de un controlador degradado",
"risk": "high",
"tags": ["storage", "mantenimiento"],
"systems": ["ceph"],
"changes": ["Se drenó el nodo", "Se reinició el controlador"],
"validation": ["El clúster volvió a HEALTH_OK"],
"rollback": "Reintegrar el controlador anterior",
"agent": "claude-code"
}`
envelope := runJSONCommand(t, root, keyPath, memoryJSON, now.Add(time.Minute),
"memory", "create", "--from-json", "-")
data := envelopeDataMap(t, envelope)
entryID, ok := data["entry_id"].(string)
if !ok || entryID == "" {
t.Fatalf("missing entry id: %#v", data)
}
state, err := New(Options{}).BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.ReviewQueue) != 1 || !contains(state.ReviewQueue[0].MissingReviewers, "ana") {
t.Fatalf("unexpected review queue: %#v", state.ReviewQueue)
}
runJSONCommand(t, root, keyPath, "", now.Add(2*time.Minute), "review", "mark", entryID, "--note", "Validado")
state, err = New(Options{}).BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.ReviewQueue) != 0 {
t.Fatalf("reviewed item remained pending: %#v", state.ReviewQueue)
}
files, err := reviewEventFiles(root)
if err != nil || len(files) != 1 {
t.Fatalf("expected one independent review event file, got %v, err=%v", files, err)
}
}
+427
View File
@@ -0,0 +1,427 @@
package clusterlog
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func (a *App) Run(args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "missing_command", usageText(a.Options.Executable), nil)
}
command := args[0]
if command == "help" || command == "--help" || command == "-h" {
return a.printResult("help", usageText(a.Options.Executable))
}
if command == "version" {
return a.printResult("version", map[string]any{"name": "clusterlog", "version": Version})
}
if command == "schema" {
return a.runSchema(args[1:])
}
root, err := a.ResolveRoot()
if err != nil {
return err
}
switch command {
case "identity":
return a.runIdentity(root, args[1:])
case "admin":
return a.runAdmin(root, args[1:])
case "memory":
return a.runMemory(root, args[1:])
case "review":
return a.runReview(root, args[1:])
case "task":
return a.runTask(root, args[1:])
case "project":
return a.runProject(root, args[1:])
case "content":
return a.runContent(root, args[1:])
case "sync":
return a.runSync(root, args[1:])
case "validate":
return a.runValidate(root, args[1:])
case "verify":
return a.runVerify(root, args[1:])
default:
return NewError(ExitUsage, "unknown_command", "comando desconocido: "+command, usageText(a.Options.Executable))
}
}
func usageText(executable string) string {
return fmt.Sprintf(`%s [opciones globales] <comando>
Opciones globales (antes del comando):
--root DIR raíz del proyecto
--json salida estable para agentes
--ssh-key PATH llave pública SSH o ruta base
--commit crea un commit Git SSH firmado con sólo los archivos modificados
--dry-run valida y muestra el resultado sin escribir
Comandos:
identity
admin add | admin list
memory create
review list | review mark ID
task create | start ID | stop ID | adjust ID | status ID | done ID | list
project create | list
content create
sync [--check]
validate
verify commit [SHA]
schema [memory|task|project|content]
`, executable)
}
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(os.Stderr)
return fs
}
func wasSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func (a *App) runIdentity(root string, args []string) error {
if len(args) != 0 {
return NewError(ExitUsage, "unexpected_arguments", "identity no acepta argumentos", args)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
return a.printResult("identity", identity)
}
func (a *App) runAdmin(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", nil)
}
if args[0] == "list" {
fs := newFlagSet("admin list")
activeOnly := fs.Bool("active", false, "muestra sólo administradores activos")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
registry, err := loadAdmins(root)
if err != nil {
return err
}
admins := []Admin{}
for _, admin := range registry.Admins {
if !*activeOnly || admin.Active {
admins = append(admins, admin)
}
}
return a.printResult("admin.list", map[string]any{
"count": len(admins), "admins": admins, "review_policy": registry.ReviewPolicy,
})
}
if args[0] != "add" {
return NewError(ExitUsage, "admin_usage", "uso: admin add ... | admin list [--active]", args[0])
}
fs := newFlagSet("admin add")
id := fs.String("id", "", "identificador estable")
name := fs.String("name", "", "nombre")
email := fs.String("email", "", "correo")
keyPath := fs.String("key", "", "llave pública")
rolesCSV := fs.String("roles", "admin", "roles separados por coma")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
adminID := slugify(*id)
if adminID == "" || strings.TrimSpace(*name) == "" || !strings.Contains(*email, "@") {
return NewError(ExitUsage, "invalid_admin", "id, name y un email válido son obligatorios", nil)
}
path := firstNonEmpty(*keyPath, a.Options.SSHKey, os.Getenv("CLUSTERLOG_SSH_KEY"))
if path == "" {
return NewError(ExitUsage, "missing_ssh_key", "indique --key o --ssh-key para registrar la llave pública", nil)
}
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
if err != nil {
return NewError(ExitIdentity, "invalid_ssh_key", err.Error(), path)
}
registry, err := loadAdmins(root)
if err != nil {
return err
}
var actor Identity
if len(registry.Admins) > 0 {
actor, err = a.ResolveIdentity(root)
if err != nil {
return err
}
if !hasRole(actor.Admin, "owner") {
return NewError(ExitIdentity, "not_authorized", "sólo un administrador con rol owner puede agregar administradores", actor.Admin.ID)
}
}
for _, admin := range registry.Admins {
if strings.EqualFold(admin.ID, adminID) || strings.EqualFold(admin.Email, *email) {
return NewError(ExitConflict, "admin_exists", "ya existe un administrador con ese id o correo", admin.ID)
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return NewError(ExitConflict, "key_exists", "la llave SSH ya pertenece a otro administrador", admin.ID)
}
}
}
roles := splitCSV(*rolesCSV)
if len(registry.Admins) == 0 && !contains(roles, "owner") {
roles = append(roles, "owner")
}
roles = uniqueSorted(roles)
newAdmin := Admin{
ID: adminID, Name: strings.TrimSpace(*name), Email: strings.TrimSpace(*email),
Active: true, Roles: roles,
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: canonical, AddedAt: a.Options.Now().UTC().Format(time.RFC3339Nano)}},
}
registry.Admins = append(registry.Admins, newAdmin)
sort.Slice(registry.Admins, func(i, j int) bool { return registry.Admins[i].ID < registry.Admins[j].ID })
if a.Options.DryRun {
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "dry_run": true})
}
if err := saveAdmins(root, registry); err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
commitIdentity := actor
if len(registry.Admins) == 1 {
commitIdentity = Identity{Admin: newAdmin, Fingerprint: fingerprint, PublicKey: canonical, PublicPath: publicPath, SigningKey: signingPath}
}
paths := []string{filepath.Join(root, "data", "admins.json"), filepath.Join(root, "config", "allowed_signers")}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, commitIdentity, "admin: add "+newAdmin.ID, paths)
if err != nil {
return err
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "proteja data/admins.json y exija commits SSH firmados antes de usarlo en producción")
}
return a.printResult("admin.add", map[string]any{"admin": newAdmin, "state": state.Stats, "commit": sha}, warnings...)
}
func (a *App) runMemory(root string, args []string) error {
if len(args) == 0 || args[0] != "create" {
return NewError(ExitUsage, "memory_usage", "uso: memory create --title ... [--from-json ARCHIVO|-]", nil)
}
fs := newFlagSet("memory create")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
risk := fs.String("risk", "", "low|medium|high|critical")
ticket := fs.String("ticket", "", "ticket relacionado")
review := fs.Bool("review", true, "requiere revisión")
reviewPolicy := fs.String("review-policy", "", "all-active|all-active-except-author")
bodyFile := fs.String("body-file", "", "Markdown o -")
fromJSON := fs.String("from-json", "", "JSON o -")
duration := fs.String("duration", "", "minutos, 90m, 2h")
agent := fs.String("agent", "", "agente que preparó el texto")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input MemoryInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "risk") {
input.Risk = *risk
}
if wasSet(fs, "ticket") {
input.Ticket = *ticket
}
if wasSet(fs, "review") {
input.ReviewRequired = review
}
if wasSet(fs, "review-policy") {
input.ReviewPolicy = *reviewPolicy
}
if wasSet(fs, "agent") {
input.Agent = *agent
}
if *duration != "" {
minutes, err := parseDurationMinutes(*duration)
if err != nil {
return NewError(ExitUsage, "invalid_duration", err.Error(), nil)
}
input.DurationMinutes = minutes
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createMemory(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("memory.create", result, warnings...)
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
createdPath := filepath.FromSlash(result["path"].(string))
paths := []string{createdPath}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("memory(%s): %s", result["entry_id"], input.Title), paths)
if err != nil {
return err
}
result["commit"] = sha
result["pending_reviews"] = state.Stats.PendingReviews
return a.printResult("memory.create", result, warnings...)
}
func (a *App) runReview(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID [--note TEXTO]", nil)
}
switch args[0] {
case "list":
fs := newFlagSet("review list")
mine := fs.Bool("mine", false, "sólo pendientes del administrador actual")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, err := a.BuildState(root)
if err != nil {
return err
}
queue := state.ReviewQueue
if *mine {
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
filtered := []ReviewQueueItem{}
for _, item := range queue {
if contains(item.MissingReviewers, identity.Admin.ID) {
filtered = append(filtered, item)
}
}
queue = filtered
}
return a.printResult("review.list", map[string]any{"count": len(queue), "items": queue})
case "mark":
if len(args) < 2 {
return NewError(ExitUsage, "missing_entry_id", "indique el ID de la memoria", nil)
}
entryID := args[1]
fs := newFlagSet("review mark")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[2:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
memory, _, err := findMemory(root, entryID)
if err != nil {
return err
}
if !memory.ReviewRequired {
return NewError(ExitValidation, "review_not_required", "la memoria no requiere revisión", memory.EntryID)
}
registry, err := loadAdmins(root)
if err != nil {
return err
}
if memory.ReviewerSnapshot {
if !contains(memory.Reviewers, identity.Admin.ID) {
return NewError(ExitValidation, "review_not_required_for_admin", "el administrador no forma parte del conjunto de revisores capturado para esta memoria", map[string]any{
"admin_id": identity.Admin.ID, "reviewers": memory.Reviewers,
})
}
} else {
policy := firstNonEmpty(memory.ReviewPolicy, registry.ReviewPolicy.Default)
if (policy == "all-active-except-author" || (policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)) && identity.Admin.ID == memory.AuthorID {
return NewError(ExitValidation, "review_not_required_for_author", "la política excluye al autor de la revisión", identity.Admin.ID)
}
}
log, err := loadReviewLog(root)
if err != nil {
return err
}
for _, event := range log.Events {
if normalizeID(event.EntryID) == memory.EntryID && event.AdminID == identity.Admin.ID && event.ContentSHA256 == memory.ContentSHA256 {
state, err := a.BuildState(root)
if err != nil {
return err
}
return a.printResult("review.mark", map[string]any{"entry_id": memory.EntryID, "admin_id": identity.Admin.ID, "already_reviewed": true, "pending_reviews": state.Stats.PendingReviews})
}
}
eventID, err := generateID("REV", a.Options.Now())
if err != nil {
return err
}
event := ReviewEvent{ID: eventID, EntryID: memory.EntryID, AdminID: identity.Admin.ID, Fingerprint: identity.Fingerprint, ReviewedAt: a.Options.Now().UTC().Format(time.RFC3339Nano), ContentSHA256: memory.ContentSHA256, Note: strings.TrimSpace(*note)}
if a.Options.DryRun {
return a.printResult("review.mark", map[string]any{"event": event, "dry_run": true})
}
eventPath, err := writeReviewEvent(root, event)
if err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := []string{eventPath}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("review(%s): %s", memory.EntryID, identity.Admin.ID), paths)
if err != nil {
return err
}
return a.printResult("review.mark", map[string]any{"event": event, "pending_reviews": state.Stats.PendingReviews, "commit": sha})
default:
return NewError(ExitUsage, "review_usage", "uso: review list | review mark ID", args[0])
}
}
+227
View File
@@ -0,0 +1,227 @@
package clusterlog
import (
"fmt"
"path/filepath"
)
func (a *App) runProject(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "project_usage", "uso: project create | list", nil)
}
switch args[0] {
case "create":
fs := newFlagSet("project create")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
objective := fs.String("objective", "", "objetivo")
owners := fs.String("owners", "", "administradores separados por coma")
targetDate := fs.String("target-date", "", "fecha YYYY-MM-DD")
status := fs.String("status", "", "planned|active|blocked|done|cancelled")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
scope := fs.String("scope", "", "elementos de alcance separados por coma")
outOfScope := fs.String("out-of-scope", "", "elementos fuera de alcance separados por coma")
milestones := fs.String("milestones", "", "hitos separados por coma")
success := fs.String("success", "", "criterios de éxito separados por coma")
bodyFile := fs.String("body-file", "", "Markdown o -")
fromJSON := fs.String("from-json", "", "JSON o -")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input ProjectInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "objective") {
input.Objective = *objective
}
if wasSet(fs, "owners") {
input.Owners = splitCSV(*owners)
}
if wasSet(fs, "target-date") {
input.TargetDate = *targetDate
}
if wasSet(fs, "status") {
input.Status = *status
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "scope") {
input.Scope = splitCSV(*scope)
}
if wasSet(fs, "out-of-scope") {
input.OutOfScope = splitCSV(*outOfScope)
}
if wasSet(fs, "milestones") {
input.Milestones = splitCSV(*milestones)
}
if wasSet(fs, "success") {
input.Success = splitCSV(*success)
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createProject(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("project.create", result, warnings...)
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := []string{filepath.FromSlash(result["path"].(string))}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("project(%s): %s", result["project_id"], input.Title), paths)
if err != nil {
return err
}
result["commit"] = sha
result["active_projects"] = state.Stats.ActiveProjects
return a.printResult("project.create", result, warnings...)
case "list":
fs := newFlagSet("project list")
status := fs.String("status", "", "filtrar por estado")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, err := a.BuildState(root)
if err != nil {
return err
}
items := []ProjectState{}
for _, item := range state.Projects {
if *status == "" || item.Status == *status {
items = append(items, item)
}
}
return a.printResult("project.list", map[string]any{"count": len(items), "items": items})
default:
return NewError(ExitUsage, "project_usage", "uso: project create | list", args[0])
}
}
func (a *App) runContent(root string, args []string) error {
if len(args) == 0 || args[0] != "create" {
return NewError(ExitUsage, "content_usage", "uso: content create --section documentacion|manuales --title ... --body-file ...", nil)
}
fs := newFlagSet("content create")
section := fs.String("section", "", "documentacion|manuales")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
bodyFile := fs.String("body-file", "", "Markdown o -")
weight := fs.Int("weight", 0, "orden opcional")
fromJSON := fs.String("from-json", "", "JSON o -")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input ContentInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "section") {
input.Section = *section
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "weight") {
input.Weight = *weight
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createContent(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("content.create", result, warnings...)
}
createdPath := filepath.FromSlash(result["path"].(string))
sha, err := a.commitPaths(root, identity, fmt.Sprintf("%s(%s): %s", input.Section, result["content_id"], input.Title), []string{createdPath})
if err != nil {
return err
}
result["commit"] = sha
return a.printResult("content.create", result, warnings...)
}
func (a *App) runSync(root string, args []string) error {
fs := newFlagSet("sync")
check := fs.Bool("check", false, "no escribe; falla si el estado generado está desactualizado")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, changed, path, err := a.Sync(root, *check)
if err != nil {
return err
}
return a.printResult("sync", map[string]any{
"path": filepath.ToSlash(path), "changed": changed, "check": *check,
"source_sha256": state.SourceSHA256, "stats": state.Stats,
})
}
func (a *App) runVerify(root string, args []string) error {
if len(args) == 0 || args[0] != "commit" {
return NewError(ExitUsage, "verify_usage", "uso: verify commit [SHA]", nil)
}
sha := "HEAD"
if len(args) > 1 {
sha = args[1]
}
if len(args) > 2 {
return NewError(ExitUsage, "unexpected_arguments", "uso: verify commit [SHA]", args[2:])
}
if err := verifyCommit(root, sha); err != nil {
return err
}
return a.printResult("verify.commit", map[string]any{"commit": sha, "valid": true})
}
+527
View File
@@ -0,0 +1,527 @@
package clusterlog
import (
"fmt"
"path/filepath"
"strings"
"time"
)
func (a *App) runTask(root string, args []string) error {
if len(args) == 0 {
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", nil)
}
switch args[0] {
case "create":
return a.runTaskCreate(root, args[1:])
case "start":
return a.runTaskStart(root, args[1:])
case "stop":
return a.runTaskStop(root, args[1:])
case "adjust":
return a.runTaskAdjust(root, args[1:])
case "status":
return a.runTaskStatus(root, args[1:])
case "done":
return a.runTaskDone(root, args[1:])
case "list":
return a.runTaskList(root, args[1:])
default:
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", args[0])
}
}
func (a *App) runTaskCreate(root string, args []string) error {
fs := newFlagSet("task create")
title := fs.String("title", "", "título")
description := fs.String("description", "", "resumen")
projectID := fs.String("project", "", "ID de proyecto")
priority := fs.String("priority", "", "low|medium|high|urgent")
assignees := fs.String("assignees", "", "administradores separados por coma")
estimate := fs.String("estimate", "", "minutos, 90m, 2h")
due := fs.String("due", "", "fecha YYYY-MM-DD")
tags := fs.String("tags", "", "tags separados por coma")
systems := fs.String("systems", "", "sistemas separados por coma")
objective := fs.String("objective", "", "objetivo")
acceptance := fs.String("acceptance", "", "criterios separados por coma")
notes := fs.String("notes", "", "notas")
bodyFile := fs.String("body-file", "", "Markdown o -")
fromJSON := fs.String("from-json", "", "JSON o -")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
var input TaskInput
if *fromJSON != "" {
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
return err
}
}
if wasSet(fs, "title") {
input.Title = *title
}
if wasSet(fs, "description") {
input.Description = *description
}
if wasSet(fs, "project") {
input.ProjectID = *projectID
}
if wasSet(fs, "priority") {
input.Priority = *priority
}
if wasSet(fs, "assignees") {
input.Assignees = splitCSV(*assignees)
}
if wasSet(fs, "due") {
input.Due = *due
}
if wasSet(fs, "tags") {
input.Tags = splitCSV(*tags)
}
if wasSet(fs, "systems") {
input.Systems = splitCSV(*systems)
}
if wasSet(fs, "objective") {
input.Objective = *objective
}
if wasSet(fs, "acceptance") {
input.Acceptance = splitCSV(*acceptance)
}
if wasSet(fs, "notes") {
input.Notes = *notes
}
if *estimate != "" {
minutes, err := parseDurationMinutes(*estimate)
if err != nil {
return NewError(ExitUsage, "invalid_estimate", err.Error(), nil)
}
input.EstimateMinutes = minutes
}
if *bodyFile != "" {
data, err := readAll(*bodyFile, a.Options.Stdin)
if err != nil {
return err
}
input.BodyMarkdown = string(data)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
result, warnings, err := a.createTask(root, identity, input)
if err != nil {
return err
}
if a.Options.DryRun {
return a.printResult("task.create", result, warnings...)
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
createdPath := filepath.FromSlash(result["path"].(string))
paths := []string{createdPath}
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", result["task_id"], input.Title), paths)
if err != nil {
return err
}
result["commit"] = sha
result["open_tasks"] = state.Stats.OpenTasks
return a.printResult("task.create", result, warnings...)
}
func (a *App) runTaskStart(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task start ID [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task start")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
state, err := a.BuildState(root)
if err != nil {
return err
}
currentStatus := task.Status
for _, current := range state.Tasks {
if current.TaskID == task.TaskID {
currentStatus = current.Status
break
}
}
if statusIsClosed(currentStatus) {
return NewError(ExitConflict, "task_closed", "no se puede iniciar una tarea cerrada", map[string]any{
"task_id": task.TaskID, "status": currentStatus,
})
}
log, err := loadTaskLog(root)
if err != nil {
return err
}
if active, ok := taskActiveStart(log, identity.Admin.ID); ok {
if normalizeID(active.TaskID) == task.TaskID {
return a.printResult("task.start", map[string]any{
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
"already_running": true, "started_at": active.At,
})
}
return NewError(ExitConflict, "timer_already_running", "el administrador ya tiene otra tarea activa", map[string]any{
"active_task_id": active.TaskID,
"started_at": active.At,
"hint": "ejecute task stop para la tarea activa",
})
}
eventID, err := generateID("EVT", a.Options.Now())
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "start", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: a.Options.Now().UTC().Format(time.RFC3339Nano), Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.start", map[string]any{"event": event, "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "start")
}
func (a *App) runTaskStop(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task stop ID [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task stop")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
log, err := loadTaskLog(root)
if err != nil {
return err
}
started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID)
if !running {
return NewError(ExitConflict, "timer_not_running", "no hay un temporizador activo para esta tarea y administrador", map[string]any{
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
})
}
startedAt, err := time.Parse(time.RFC3339, started.At)
if err != nil {
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
}
now := a.Options.Now().UTC()
if now.Before(startedAt) {
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
}
elapsed := now.Sub(startedAt)
minutes := int((elapsed + time.Minute - 1) / time.Minute)
if minutes < 1 {
minutes = 1
}
eventID, err := generateID("EVT", now)
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes, Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.stop", map[string]any{"event": event, "elapsed": formatMinutes(minutes), "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "stop")
}
func (a *App) runTaskAdjust(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task adjust ID --minutes N [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task adjust")
minutes := fs.Int("minutes", 0, "minutos positivos o negativos")
note := fs.String("note", "", "justificación del ajuste")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
if !wasSet(fs, "minutes") || *minutes == 0 {
return NewError(ExitUsage, "invalid_adjustment", "--minutes debe ser distinto de cero", nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
state, err := a.BuildState(root)
if err != nil {
return err
}
for _, current := range state.Tasks {
if current.TaskID == task.TaskID && current.ActualMinutes+*minutes < 0 {
return NewError(ExitValidation, "negative_actual_time", "el ajuste dejaría el tiempo real por debajo de cero", map[string]any{
"task_id": task.TaskID, "actual_minutes": current.ActualMinutes, "adjustment": *minutes,
})
}
}
now := a.Options.Now().UTC()
eventID, err := generateID("EVT", now)
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "adjust", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: *minutes, Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.adjust", map[string]any{"event": event, "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "adjust")
}
func (a *App) runTaskStatus(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task status ID --status backlog|todo|in_progress|blocked|cancelled [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task status")
status := fs.String("status", "", "backlog|todo|in_progress|blocked|cancelled")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
newStatus := strings.ToLower(strings.TrimSpace(*status))
allowed := map[string]bool{"backlog": true, "todo": true, "in_progress": true, "blocked": true, "cancelled": true}
if !allowed[newStatus] {
return NewError(ExitUsage, "invalid_task_status", "use backlog, todo, in_progress, blocked o cancelled; para completar use task done", newStatus)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
state, err := a.BuildState(root)
if err != nil {
return err
}
for _, current := range state.Tasks {
if current.TaskID != task.TaskID {
continue
}
if current.Status == newStatus {
return a.printResult("task.status", map[string]any{"task_id": task.TaskID, "status": newStatus, "already_set": true})
}
if statusIsClosed(newStatus) && len(current.RunningBy) > 0 {
return NewError(ExitConflict, "task_has_running_timers", "detenga todos los temporizadores antes de cerrar la tarea", map[string]any{
"task_id": task.TaskID, "running_by": current.RunningBy,
})
}
}
now := a.Options.Now().UTC()
eventID, err := generateID("EVT", now)
if err != nil {
return err
}
event := TaskEvent{
ID: eventID, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: newStatus, Note: strings.TrimSpace(*note),
}
if a.Options.DryRun {
return a.printResult("task.status", map[string]any{"event": event, "dry_run": true})
}
return a.persistTaskEvent(root, identity, event, "status")
}
func (a *App) runTaskDone(root string, args []string) error {
if len(args) < 1 {
return NewError(ExitUsage, "missing_task_id", "uso: task done ID [--note TEXTO]", nil)
}
taskID := normalizeID(args[0])
fs := newFlagSet("task done")
note := fs.String("note", "", "nota breve")
if err := fs.Parse(args[1:]); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
task, _, err := findTask(root, taskID)
if err != nil {
return err
}
stateBefore, err := a.BuildState(root)
if err != nil {
return err
}
for _, current := range stateBefore.Tasks {
if current.TaskID != task.TaskID {
continue
}
if current.Status == "done" {
return a.printResult("task.done", map[string]any{"task_id": task.TaskID, "already_done": true})
}
otherRunners := []string{}
for _, adminID := range current.RunningBy {
if adminID != identity.Admin.ID {
otherRunners = append(otherRunners, adminID)
}
}
if len(otherRunners) > 0 {
return NewError(ExitConflict, "task_has_other_running_timers", "otros administradores aún registran tiempo en esta tarea", map[string]any{
"task_id": task.TaskID, "running_by": otherRunners,
})
}
}
log, err := loadTaskLog(root)
if err != nil {
return err
}
now := a.Options.Now().UTC()
newEvents := []TaskEvent{}
if started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID); running {
startedAt, parseErr := time.Parse(time.RFC3339, started.At)
if parseErr != nil {
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
}
if now.Before(startedAt) {
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
}
minutes := int((now.Sub(startedAt) + time.Minute - 1) / time.Minute)
if minutes < 1 {
minutes = 1
}
id, idErr := generateID("EVT", now)
if idErr != nil {
return idErr
}
newEvents = append(newEvents, TaskEvent{
ID: id, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes,
Note: "temporizador cerrado al completar la tarea",
})
}
id, err := generateID("EVT", now)
if err != nil {
return err
}
statusEvent := TaskEvent{
ID: id, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: "done", Note: strings.TrimSpace(*note),
}
newEvents = append(newEvents, statusEvent)
if a.Options.DryRun {
return a.printResult("task.done", map[string]any{"events": newEvents, "dry_run": true})
}
eventPaths, err := writeTaskEvents(root, newEvents)
if err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := append([]string(nil), eventPaths...)
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): done", task.TaskID), paths)
if err != nil {
return err
}
var final TaskState
for _, item := range state.Tasks {
if item.TaskID == task.TaskID {
final = item
break
}
}
return a.printResult("task.done", map[string]any{"task": final, "events": newEvents, "commit": sha})
}
func (a *App) runTaskList(root string, args []string) error {
fs := newFlagSet("task list")
status := fs.String("status", "", "filtrar por estado")
mine := fs.Bool("mine", false, "sólo tareas asignadas o ejecutadas por el administrador actual")
projectID := fs.String("project", "", "filtrar por proyecto")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
state, err := a.BuildState(root)
if err != nil {
return err
}
var adminID string
if *mine {
identity, err := a.ResolveIdentity(root)
if err != nil {
return err
}
adminID = identity.Admin.ID
}
items := []TaskState{}
for _, item := range state.Tasks {
if *status != "" && item.Status != strings.ToLower(*status) {
continue
}
if *projectID != "" && item.ProjectID != normalizeID(*projectID) {
continue
}
if *mine && !contains(item.Assignees, adminID) && !contains(item.RunningBy, adminID) {
continue
}
items = append(items, item)
}
return a.printResult("task.list", map[string]any{"count": len(items), "items": items})
}
func (a *App) persistTaskEvent(root string, identity Identity, event TaskEvent, action string) error {
eventPaths, err := writeTaskEvents(root, []TaskEvent{event})
if err != nil {
return err
}
state, changed, statePath, err := a.Sync(root, false)
if err != nil {
return err
}
paths := append([]string(nil), eventPaths...)
if changed {
paths = append(paths, statePath)
}
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", event.TaskID, action), paths)
if err != nil {
return err
}
var task TaskState
for _, item := range state.Tasks {
if item.TaskID == normalizeID(event.TaskID) {
task = item
break
}
}
return a.printResult("task."+action, map[string]any{"event": event, "task": task, "commit": sha})
}
+434
View File
@@ -0,0 +1,434 @@
package clusterlog
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type MemoryInput struct {
Title string `json:"title"`
Description string `json:"description"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Risk string `json:"risk"`
Ticket string `json:"ticket,omitempty"`
ReviewRequired *bool `json:"review_required,omitempty"`
ReviewPolicy string `json:"review_policy,omitempty"`
BodyMarkdown string `json:"body_markdown,omitempty"`
Context string `json:"context,omitempty"`
Changes []string `json:"changes,omitempty"`
Commands []string `json:"commands,omitempty"`
Validation []string `json:"validation,omitempty"`
Rollback string `json:"rollback,omitempty"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
DurationMinutes int `json:"duration_minutes,omitempty"`
Agent string `json:"agent,omitempty"`
}
type TaskInput struct {
Title string `json:"title"`
Description string `json:"description"`
ProjectID string `json:"project_id,omitempty"`
Priority string `json:"priority,omitempty"`
Assignees []string `json:"assignees,omitempty"`
EstimateMinutes int `json:"estimate_minutes,omitempty"`
Due string `json:"due,omitempty"`
Tags []string `json:"tags,omitempty"`
Systems []string `json:"systems,omitempty"`
BodyMarkdown string `json:"body_markdown,omitempty"`
Objective string `json:"objective,omitempty"`
Acceptance []string `json:"acceptance_criteria,omitempty"`
Notes string `json:"notes,omitempty"`
}
type ProjectInput struct {
Title string `json:"title"`
Description string `json:"description"`
Objective string `json:"objective"`
Owners []string `json:"owners,omitempty"`
TargetDate string `json:"target_date,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags,omitempty"`
Systems []string `json:"systems,omitempty"`
BodyMarkdown string `json:"body_markdown,omitempty"`
Scope []string `json:"scope,omitempty"`
OutOfScope []string `json:"out_of_scope,omitempty"`
Milestones []string `json:"milestones,omitempty"`
Success []string `json:"success_criteria,omitempty"`
}
type ContentInput struct {
Section string `json:"section"`
Title string `json:"title"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Systems []string `json:"systems,omitempty"`
BodyMarkdown string `json:"body_markdown"`
Weight int `json:"weight,omitempty"`
}
func decodeJSONInput(path string, stdinReader io.Reader, dst any) error {
data, err := readAll(path, stdinReader)
if err != nil {
return err
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return NewError(ExitUsage, "invalid_json", "el documento de entrada no es JSON válido o contiene campos desconocidos", err.Error())
}
return nil
}
func renderMemoryBody(input MemoryInput) string {
if strings.TrimSpace(input.BodyMarkdown) != "" {
return normalizeMarkdownBody(input.BodyMarkdown)
}
var b strings.Builder
b.WriteString("## Contexto\n\n")
b.WriteString(firstNonEmpty(input.Context, "_No especificado._"))
b.WriteString("\n\n## Cambios realizados\n\n")
b.WriteString(bullets(input.Changes))
b.WriteString("\n## Comandos relevantes (secretos redactados)\n\n")
b.WriteString(codeBlock(input.Commands))
b.WriteString("\n## Validación\n\n")
b.WriteString(bullets(input.Validation))
b.WriteString("\n## Riesgo y reversión\n\n")
if input.Rollback == "" {
b.WriteString("_No se documentó un procedimiento de reversión._\n")
} else {
b.WriteString(input.Rollback + "\n")
}
if input.Ticket != "" {
b.WriteString("\n## Referencias\n\n- Ticket: `" + input.Ticket + "`\n")
}
return normalizeMarkdownBody(b.String())
}
func renderTaskBody(input TaskInput) string {
if strings.TrimSpace(input.BodyMarkdown) != "" {
return normalizeMarkdownBody(input.BodyMarkdown)
}
var b strings.Builder
b.WriteString("## Objetivo\n\n")
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
b.WriteString("\n\n## Criterios de aceptación\n\n")
b.WriteString(bullets(input.Acceptance))
if input.Notes != "" {
b.WriteString("\n## Notas\n\n" + input.Notes + "\n")
}
return normalizeMarkdownBody(b.String())
}
func renderProjectBody(input ProjectInput) string {
if strings.TrimSpace(input.BodyMarkdown) != "" {
return normalizeMarkdownBody(input.BodyMarkdown)
}
var b strings.Builder
b.WriteString("## Objetivo\n\n")
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
b.WriteString("\n\n## Alcance\n\n")
b.WriteString(bullets(input.Scope))
b.WriteString("\n## Fuera de alcance\n\n")
b.WriteString(bullets(input.OutOfScope))
b.WriteString("\n## Hitos\n\n")
b.WriteString(bullets(input.Milestones))
b.WriteString("\n## Criterios de éxito\n\n")
b.WriteString(bullets(input.Success))
return normalizeMarkdownBody(b.String())
}
func (a *App) createMemory(root string, identity Identity, input MemoryInput) (map[string]any, []string, error) {
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Title)
input.Risk = strings.ToLower(firstNonEmpty(input.Risk, "low"))
validRisk := map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
if input.Title == "" {
return nil, nil, NewError(ExitUsage, "missing_title", "--title o title en JSON es obligatorio", nil)
}
if !validRisk[input.Risk] {
return nil, nil, NewError(ExitUsage, "invalid_risk", "risk debe ser low, medium, high o critical", input.Risk)
}
reviewRequired := true
if input.ReviewRequired != nil {
reviewRequired = *input.ReviewRequired
}
registry, err := loadAdmins(root)
if err != nil {
return nil, nil, err
}
input.ReviewPolicy = firstNonEmpty(input.ReviewPolicy, registry.ReviewPolicy.Default, "all-active")
if input.ReviewPolicy != "all-active" && input.ReviewPolicy != "all-active-except-author" {
return nil, nil, NewError(ExitUsage, "invalid_review_policy", "review_policy debe ser all-active o all-active-except-author", input.ReviewPolicy)
}
reviewers := []string{}
if reviewRequired {
reviewers = requiredReviewers(registry, input.ReviewPolicy, identity.Admin.ID)
}
now := a.Options.Now().UTC()
id, err := generateID("MEM", now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
body := renderMemoryBody(input)
front := strings.Builder{}
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"memory.html\"\n\n")
front.WriteString("[taxonomies]\n")
front.WriteString("categories = [\"memorias\"]\n")
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
front.WriteString("kind = \"memory\"\n")
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
fmt.Fprintf(&front, "risk = %s\n", tomlString(input.Risk))
fmt.Fprintf(&front, "ticket = %s\n", tomlString(input.Ticket))
fmt.Fprintf(&front, "review_required = %t\n", reviewRequired)
fmt.Fprintf(&front, "review_policy = %s\n", tomlString(input.ReviewPolicy))
front.WriteString("reviewer_snapshot = true\n")
fmt.Fprintf(&front, "reviewers = %s\n", tomlStringArray(reviewers))
fmt.Fprintf(&front, "started_at = %s\n", tomlString(input.StartedAt))
fmt.Fprintf(&front, "finished_at = %s\n", tomlString(input.FinishedAt))
fmt.Fprintf(&front, "duration_minutes = %d\n", input.DurationMinutes)
fmt.Fprintf(&front, "agent = %s\n", tomlString(input.Agent))
front.WriteString("+++\n\n")
content := front.String() + body
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "la memoria parece contener secretos; redacte antes de registrarla", findings)
}
filename := now.Format("2006-01-02") + "-" + slug + ".md"
path := filepath.Join(root, "content", "memorias", filename)
if _, err := os.Stat(path); err == nil {
return nil, nil, NewError(ExitConflict, "file_exists", "ya existe el archivo de memoria", path)
}
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "la atribución local usa la huella SSH, pero la prueba criptográfica exige un commit firmado; repita con --commit o firme el commit manualmente")
}
return map[string]any{
"entry_id": id, "path": filepath.ToSlash(path), "slug": slug,
"review_required": reviewRequired, "reviewers": reviewers, "content_sha256": bytesSHA256([]byte(content)),
"dry_run": a.Options.DryRun,
}, warnings, nil
}
func (a *App) createTask(root string, identity Identity, input TaskInput) (map[string]any, []string, error) {
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Title)
input.Priority = strings.ToLower(firstNonEmpty(input.Priority, "medium"))
if input.Title == "" {
return nil, nil, NewError(ExitUsage, "missing_title", "el título de la tarea es obligatorio", nil)
}
if !map[string]bool{"low": true, "medium": true, "high": true, "urgent": true}[input.Priority] {
return nil, nil, NewError(ExitUsage, "invalid_priority", "priority debe ser low, medium, high o urgent", input.Priority)
}
if err := validateDate(input.Due); err != nil {
return nil, nil, NewError(ExitUsage, "invalid_due", err.Error(), nil)
}
if err := ensureProjectExists(root, input.ProjectID); err != nil {
return nil, nil, err
}
registry, err := loadAdmins(root)
if err != nil {
return nil, nil, err
}
for _, assignee := range input.Assignees {
admin, ok := adminByID(registry, assignee)
if !ok || !admin.Active {
return nil, nil, NewError(ExitValidation, "invalid_assignee", "la tarea contiene un responsable inexistente o inactivo", assignee)
}
}
now := a.Options.Now().UTC()
id, err := generateID("TSK", now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
body := renderTaskBody(input)
var front strings.Builder
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"task.html\"\n\n")
front.WriteString("[taxonomies]\n")
front.WriteString("categories = [\"tareas\"]\n")
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
front.WriteString("kind = \"task\"\nstatus = \"todo\"\n")
fmt.Fprintf(&front, "priority = %s\n", tomlString(input.Priority))
fmt.Fprintf(&front, "project_id = %s\n", tomlString(normalizeID(input.ProjectID)))
fmt.Fprintf(&front, "assignees = %s\n", tomlStringArray(uniqueSorted(input.Assignees)))
fmt.Fprintf(&front, "estimate_minutes = %d\n", input.EstimateMinutes)
fmt.Fprintf(&front, "due = %s\n", tomlString(input.Due))
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
front.WriteString("+++\n\n")
content := front.String() + body
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "la tarea parece contener secretos; redacte antes de registrarla", findings)
}
path := filepath.Join(root, "content", "tareas", now.Format("2006-01-02")+"-"+slug+".md")
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
}
return map[string]any{"task_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
}
func (a *App) createProject(root string, identity Identity, input ProjectInput) (map[string]any, []string, error) {
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Objective, input.Title)
input.Status = strings.ToLower(firstNonEmpty(input.Status, "planned"))
if input.Title == "" || strings.TrimSpace(input.Objective) == "" {
return nil, nil, NewError(ExitUsage, "missing_project_fields", "title y objective son obligatorios", nil)
}
if !map[string]bool{"planned": true, "active": true, "blocked": true, "done": true, "cancelled": true, "archived": true}[input.Status] {
return nil, nil, NewError(ExitUsage, "invalid_project_status", "status debe ser planned, active, blocked, done, cancelled o archived", input.Status)
}
if err := validateDate(input.TargetDate); err != nil {
return nil, nil, NewError(ExitUsage, "invalid_target_date", err.Error(), nil)
}
registry, err := loadAdmins(root)
if err != nil {
return nil, nil, err
}
for _, owner := range input.Owners {
admin, ok := adminByID(registry, owner)
if !ok || !admin.Active {
return nil, nil, NewError(ExitValidation, "invalid_owner", "el proyecto contiene un responsable inexistente o inactivo", owner)
}
}
if len(input.Owners) == 0 {
input.Owners = []string{identity.Admin.ID}
}
now := a.Options.Now().UTC()
id, err := generateID("PRJ", now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
body := renderProjectBody(input)
var front strings.Builder
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"project.html\"\n\n")
front.WriteString("[taxonomies]\n")
front.WriteString("categories = [\"proyectos\"]\n")
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
front.WriteString("kind = \"project\"\n")
fmt.Fprintf(&front, "status = %s\n", tomlString(input.Status))
fmt.Fprintf(&front, "owners = %s\n", tomlStringArray(uniqueSorted(input.Owners)))
fmt.Fprintf(&front, "target_date = %s\n", tomlString(input.TargetDate))
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
front.WriteString("+++\n\n")
content := front.String() + body
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "el proyecto parece contener secretos; redacte antes de registrarlo", findings)
}
path := filepath.Join(root, "content", "proyectos", now.Format("2006-01-02")+"-"+slug+".md")
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
}
return map[string]any{"project_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
}
func (a *App) createContent(root string, identity Identity, input ContentInput) (map[string]any, []string, error) {
input.Section = strings.ToLower(strings.TrimSpace(input.Section))
if input.Section != "documentacion" && input.Section != "manuales" {
return nil, nil, NewError(ExitUsage, "invalid_section", "section debe ser documentacion o manuales", input.Section)
}
input.Title = strings.TrimSpace(input.Title)
input.Description = firstNonEmpty(input.Description, input.Title)
if input.Title == "" || strings.TrimSpace(input.BodyMarkdown) == "" {
return nil, nil, NewError(ExitUsage, "missing_content_fields", "title y body_markdown son obligatorios", nil)
}
now := a.Options.Now().UTC()
idPrefix := "DOC"
if input.Section == "manuales" {
idPrefix = "RUN"
}
id, err := generateID(idPrefix, now)
if err != nil {
return nil, nil, err
}
slug := slugify(id + "-" + input.Title)
var front strings.Builder
front.WriteString("+++\n")
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
front.WriteString("template = \"page.html\"\n")
if input.Weight != 0 {
fmt.Fprintf(&front, "weight = %d\n", input.Weight)
}
front.WriteString("\n[taxonomies]\n")
fmt.Fprintf(&front, "categories = %s\n", tomlStringArray([]string{input.Section}))
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
front.WriteString("[extra]\n")
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
fmt.Fprintf(&front, "kind = %s\n", tomlString(input.Section))
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
front.WriteString("+++\n\n")
content := front.String() + normalizeMarkdownBody(input.BodyMarkdown)
if findings := scanSecrets(content); len(findings) > 0 {
return nil, nil, NewError(ExitValidation, "possible_secret", "el contenido parece incluir secretos; redacte antes de registrarlo", findings)
}
path := filepath.Join(root, "content", input.Section, now.Format("2006-01-02")+"-"+slug+".md")
if !a.Options.DryRun {
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
return nil, nil, err
}
}
warnings := []string{}
if !a.Options.Commit {
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
}
return map[string]any{"content_id": id, "section": input.Section, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
}
+114
View File
@@ -0,0 +1,114 @@
package clusterlog
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const (
reviewEventsDirName = "review-events"
taskEventsDirName = "task-events"
)
func listJSONFiles(dir string) ([]string, error) {
files := []string{}
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return files, nil
}
return nil, err
}
for _, entry := range entries {
if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
continue
}
files = append(files, filepath.Join(dir, entry.Name()))
}
sort.Strings(files)
return files, nil
}
func reviewEventFiles(root string) ([]string, error) {
return listJSONFiles(filepath.Join(root, "data", reviewEventsDirName))
}
func taskEventFiles(root string) ([]string, error) {
return listJSONFiles(filepath.Join(root, "data", taskEventsDirName))
}
func eventFilePath(root, dirName, id string) string {
return filepath.Join(root, "data", dirName, strings.ToLower(id)+".json")
}
func writeEventFile(path string, value any) error {
if _, err := os.Stat(path); err == nil {
return NewError(ExitConflict, "event_exists", "ya existe un evento con el mismo identificador", filepath.ToSlash(path))
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return writeJSONAtomic(path, value)
}
func writeReviewEvent(root string, event ReviewEvent) (string, error) {
path := eventFilePath(root, reviewEventsDirName, event.ID)
return path, writeEventFile(path, event)
}
func writeTaskEvents(root string, events []TaskEvent) ([]string, error) {
paths := make([]string, 0, len(events))
for _, event := range events {
path := eventFilePath(root, taskEventsDirName, event.ID)
if err := writeEventFile(path, event); err != nil {
return paths, err
}
paths = append(paths, path)
}
return paths, nil
}
func parseEventTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}
}
return parsed
}
func sortReviewEvents(events []ReviewEvent) {
sort.SliceStable(events, func(i, j int) bool {
ti := parseEventTime(events[i].ReviewedAt)
tj := parseEventTime(events[j].ReviewedAt)
if ti.Equal(tj) {
return events[i].ID < events[j].ID
}
return ti.Before(tj)
})
}
func sortTaskEvents(events []TaskEvent) {
typeOrder := map[string]int{"start": 0, "stop": 1, "adjust": 2, "status": 3}
sort.SliceStable(events, func(i, j int) bool {
ti := parseEventTime(events[i].At)
tj := parseEventTime(events[j].At)
if ti.Equal(tj) {
oi, iOK := typeOrder[events[i].Type]
oj, jOK := typeOrder[events[j].Type]
if !iOK {
oi = 9
}
if !jOK {
oj = 9
}
if oi == oj {
return events[i].ID < events[j].ID
}
return oi < oj
}
return ti.Before(tj)
})
}
+88
View File
@@ -0,0 +1,88 @@
package clusterlog
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
func gitOutput(root string, args ...string) (string, error) {
full := append([]string{"-C", root}, args...)
cmd := exec.Command("git", full...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
}
return strings.TrimSpace(string(output)), nil
}
func isGitRepo(root string) bool {
output, err := gitOutput(root, "rev-parse", "--is-inside-work-tree")
return err == nil && output == "true"
}
func (a *App) commitPaths(root string, identity Identity, message string, paths []string) (string, error) {
if !a.Options.Commit {
return "", nil
}
if !isGitRepo(root) {
return "", NewError(ExitGit, "not_git_repo", "--commit requiere que el proyecto sea un repositorio Git", root)
}
rel, err := relativePaths(root, paths)
if err != nil {
return "", err
}
addArgs := append([]string{"add", "--"}, rel...)
if _, err := gitOutput(root, addArgs...); err != nil {
return "", NewError(ExitGit, "git_add_failed", err.Error(), rel)
}
args := []string{
"-c", "user.name=" + identity.Admin.Name,
"-c", "user.email=" + identity.Admin.Email,
"-c", "gpg.format=ssh",
"-c", "user.signingkey=" + identity.SigningKey,
"commit", "-S", "-m", message, "--",
}
args = append(args, rel...)
if _, err := gitOutput(root, args...); err != nil {
return "", NewError(ExitGit, "git_commit_failed",
"no se pudo crear el commit SSH firmado", map[string]any{
"cause": err.Error(),
"signing_key": identity.SigningKey,
"hint": "compruebe OpenSSH/ssh-keygen y que la llave privada esté disponible o cargada en ssh-agent",
})
}
sha, err := gitOutput(root, "rev-parse", "HEAD")
if err != nil {
return "", err
}
return sha, nil
}
func verifyCommit(root, sha string) error {
if sha == "" {
sha = "HEAD"
}
allowed := filepath.Join(root, "config", "allowed_signers")
_, err := gitOutput(root,
"-c", "gpg.format=ssh",
"-c", "gpg.ssh.allowedSignersFile="+allowed,
"verify-commit", sha,
)
if err != nil {
return NewError(ExitValidation, "invalid_commit_signature", "el commit no tiene una firma SSH válida y autorizada", map[string]any{
"commit": sha,
"cause": err.Error(),
})
}
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package clusterlog
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
func loadAdmins(root string) (AdminRegistry, error) {
var registry AdminRegistry
if err := loadJSON(filepath.Join(root, "data", "admins.json"), &registry); err != nil {
return registry, err
}
if registry.Version == 0 {
registry.Version = 1
}
if registry.ReviewPolicy.Default == "" {
registry.ReviewPolicy.Default = "all-active"
registry.ReviewPolicy.IncludeAuthor = true
}
return registry, nil
}
func saveAdmins(root string, registry AdminRegistry) error {
if err := writeJSONAtomic(filepath.Join(root, "data", "admins.json"), registry); err != nil {
return err
}
return writeAllowedSigners(root, registry)
}
func writeAllowedSigners(root string, registry AdminRegistry) error {
lines := []string{
"# Archivo generado por clusterlog. Contiene únicamente llaves públicas.",
"# principal namespaces=\"git\" tipo base64",
}
seen := map[string]bool{}
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
fields := strings.Fields(key.PublicKey)
if len(fields) < 2 {
continue
}
line := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
if !seen[line] {
lines = append(lines, line)
seen[line] = true
}
}
}
sort.Strings(lines[2:])
return writeFileAtomic(filepath.Join(root, "config", "allowed_signers"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
}
func parsePublicKeyLine(line string) (canonical, fingerprint string, err error) {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 {
return "", "", fmt.Errorf("la llave pública debe tener formato OpenSSH: tipo base64 [comentario]")
}
decoded, err := base64.StdEncoding.DecodeString(fields[1])
if err != nil {
return "", "", fmt.Errorf("base64 inválido en llave pública: %w", err)
}
sum := sha256.Sum256(decoded)
fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
canonical = fields[0] + " " + fields[1]
if len(fields) > 2 {
canonical += " " + strings.Join(fields[2:], " ")
}
return canonical, fingerprint, nil
}
func readPublicKey(path string) (canonical, fingerprint, publicPath, signingPath string, err error) {
path = strings.TrimSpace(path)
if path == "" {
return "", "", "", "", fmt.Errorf("ruta de llave vacía")
}
expanded, err := expandPath(path)
if err != nil {
return "", "", "", "", err
}
original := expanded
// user.signingkey suele apuntar a la llave privada; para identificar al
// administrador siempre leemos la contraparte pública cuando existe.
if !strings.HasSuffix(expanded, ".pub") {
if _, pubErr := os.Stat(expanded + ".pub"); pubErr == nil {
expanded += ".pub"
}
}
info, statErr := os.Stat(expanded)
if statErr != nil {
return "", "", "", "", statErr
}
if info.IsDir() {
return "", "", "", "", fmt.Errorf("%s es un directorio", expanded)
}
data, err := os.ReadFile(expanded)
if err != nil {
return "", "", "", "", err
}
canonical, fingerprint, err = parsePublicKeyLine(string(data))
if err != nil {
return "", "", "", "", err
}
publicPath = expanded
signingPath = expanded
if original != expanded {
signingPath = original
} else if strings.HasSuffix(expanded, ".pub") {
privateCandidate := strings.TrimSuffix(expanded, ".pub")
if _, err := os.Stat(privateCandidate); err == nil {
signingPath = privateCandidate
}
}
return canonical, fingerprint, publicPath, signingPath, nil
}
func expandPath(path string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if path == "~" {
path = home
} else {
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return filepath.Abs(path)
}
func candidateKeyPaths(explicit string) []string {
candidates := []string{}
if explicit != "" {
candidates = append(candidates, explicit)
}
if env := os.Getenv("CLUSTERLOG_SSH_KEY"); env != "" {
candidates = append(candidates, env)
}
if output, err := exec.Command("git", "config", "--get", "user.signingkey").Output(); err == nil {
value := strings.TrimSpace(string(output))
if strings.HasPrefix(value, "key::") {
// Una llave inline se identifica, pero no sirve como ruta para firmar.
} else if value != "" {
candidates = append(candidates, value)
}
}
if home, err := os.UserHomeDir(); err == nil {
for _, name := range []string{"id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"} {
candidates = append(candidates, filepath.Join(home, ".ssh", name))
}
}
seen := map[string]bool{}
out := []string{}
for _, candidate := range candidates {
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
out = append(out, candidate)
}
return out
}
func (a *App) ResolveIdentity(root string) (Identity, error) {
registry, err := loadAdmins(root)
if err != nil {
return Identity{}, err
}
attempted := []string{}
for _, path := range candidateKeyPaths(a.Options.SSHKey) {
canonical, fingerprint, publicPath, signingPath, err := readPublicKey(path)
if err != nil {
attempted = append(attempted, fmt.Sprintf("%s: %v", path, err))
continue
}
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return Identity{
Admin: admin, Fingerprint: fingerprint, PublicKey: canonical,
PublicPath: publicPath, SigningKey: signingPath,
}, nil
}
}
}
attempted = append(attempted, fmt.Sprintf("%s: %s no está registrada", path, fingerprint))
}
return Identity{}, NewError(ExitIdentity, "identity_not_found",
"ninguna llave SSH local coincide con un administrador activo", map[string]any{
"attempted": attempted,
"hint": "use --ssh-key ~/.ssh/id_ed25519.pub o CLUSTERLOG_SSH_KEY",
})
}
func adminByID(registry AdminRegistry, id string) (Admin, bool) {
id = normalizeID(id)
for _, admin := range registry.Admins {
if normalizeID(admin.ID) == id {
return admin, true
}
}
return Admin{}, false
}
func activeAdminIDs(registry AdminRegistry) []string {
ids := []string{}
for _, admin := range registry.Admins {
if admin.Active {
ids = append(ids, admin.ID)
}
}
sort.Strings(ids)
return ids
}
+209
View File
@@ -0,0 +1,209 @@
package clusterlog
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func parseMarkdown(path string) (PageMeta, error) {
data, err := os.ReadFile(path)
if err != nil {
return PageMeta{}, err
}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
if !strings.HasPrefix(text, "+++\n") {
return PageMeta{}, fmt.Errorf("%s no tiene front matter TOML delimitado por +++", path)
}
rest := strings.TrimPrefix(text, "+++\n")
idx := strings.Index(rest, "\n+++\n")
if idx < 0 {
if strings.HasSuffix(rest, "\n+++") {
idx = len(rest) - len("\n+++")
} else {
return PageMeta{}, fmt.Errorf("%s tiene front matter sin cierre +++", path)
}
}
rawFront := rest[:idx]
bodyStart := idx + len("\n+++\n")
body := ""
if bodyStart <= len(rest) {
body = rest[bodyStart:]
}
meta := PageMeta{
Path: path,
Body: body,
Root: map[string]any{},
Taxonomies: map[string][]string{},
Extra: map[string]any{},
RawFront: rawFront,
ContentHash: bytesSHA256(data),
}
section := "root"
scanner := bufio.NewScanner(strings.NewReader(rawFront))
scanner.Buffer(make([]byte, 1024), 1024*1024)
lineNo := 0
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(stripTOMLComment(scanner.Text()))
if line == "" {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))
continue
}
key, raw, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
raw = strings.TrimSpace(raw)
value, err := parseTOMLValue(raw)
if err != nil {
return PageMeta{}, fmt.Errorf("%s:%d: %w", path, lineNo, err)
}
switch section {
case "root":
meta.Root[key] = value
case "taxonomies":
values, ok := value.([]string)
if ok {
meta.Taxonomies[key] = values
}
case "extra":
meta.Extra[key] = value
default:
// El parser intencionalmente ignora secciones no usadas por clusterlog.
}
}
if err := scanner.Err(); err != nil {
return PageMeta{}, err
}
return meta, nil
}
func stripTOMLComment(line string) string {
inString := false
escaped := false
for i, r := range line {
if escaped {
escaped = false
continue
}
if r == '\\' && inString {
escaped = true
continue
}
if r == '"' {
inString = !inString
continue
}
if r == '#' && !inString {
return line[:i]
}
}
return line
}
func parseTOMLValue(raw string) (any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if strings.HasPrefix(raw, "\"") {
value, err := strconv.Unquote(raw)
if err != nil {
return nil, fmt.Errorf("cadena TOML inválida: %w", err)
}
return value, nil
}
if raw == "true" {
return true, nil
}
if raw == "false" {
return false, nil
}
if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
return parseTOMLStringArray(raw)
}
if n, err := strconv.Atoi(raw); err == nil {
return n, nil
}
// Fechas TOML y otros escalares simples se conservan como texto.
return raw, nil
}
func parseTOMLStringArray(raw string) ([]string, error) {
content := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]"))
if content == "" {
return []string{}, nil
}
var values []string
var current strings.Builder
inString := false
escaped := false
flush := func() error {
item := strings.TrimSpace(current.String())
current.Reset()
if item == "" {
return nil
}
value, err := strconv.Unquote(item)
if err != nil {
return fmt.Errorf("arreglo TOML inválido: %w", err)
}
values = append(values, value)
return nil
}
for _, r := range content {
if escaped {
current.WriteRune(r)
escaped = false
continue
}
if r == '\\' && inString {
current.WriteRune(r)
escaped = true
continue
}
if r == '"' {
inString = !inString
current.WriteRune(r)
continue
}
if r == ',' && !inString {
if err := flush(); err != nil {
return nil, err
}
continue
}
current.WriteRune(r)
}
if inString {
return nil, fmt.Errorf("arreglo TOML con cadena sin cerrar")
}
if err := flush(); err != nil {
return nil, err
}
return values, nil
}
func pageSlug(meta PageMeta) string {
if value := meta.String("slug"); value != "" {
return value
}
return slugify(strings.TrimSuffix(filepathBase(meta.Path), ".md"))
}
func filepathBase(path string) string {
path = strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(path, "/"); idx >= 0 {
return path[idx+1:]
}
return path
}
+57
View File
@@ -0,0 +1,57 @@
package clusterlog
import (
"path/filepath"
"testing"
)
func TestParseMarkdownFrontMatter(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "page.md")
mustWriteTestFile(t, path, `+++
title = "Prueba # literal"
description = "Página de prueba"
date = 2026-08-12T08:30:00Z
authors = ["ana", "bob"]
slug = "pagina-prueba"
[taxonomies]
tags = ["red", "cambio"]
systems = ["spine-01"]
[extra]
id = "MEM-TEST-001"
review_required = true
duration_minutes = 45
reviewers = ["ana", "bob"] # comentario
+++
## Cuerpo
Contenido.
`)
meta, err := parseMarkdown(path)
if err != nil {
t.Fatal(err)
}
if meta.String("title") != "Prueba # literal" {
t.Fatalf("unexpected title: %q", meta.String("title"))
}
if meta.ExtraString("id") != "MEM-TEST-001" || !meta.ExtraBool("review_required") || meta.ExtraInt("duration_minutes") != 45 {
t.Fatalf("unexpected extra metadata: %#v", meta.Extra)
}
if got := meta.Taxonomies["tags"]; len(got) != 2 || got[0] != "red" || got[1] != "cambio" {
t.Fatalf("unexpected tags: %#v", got)
}
if meta.ContentHash == "" || meta.Body == "" {
t.Fatal("expected content hash and body")
}
}
func TestParseMarkdownRejectsUnclosedFrontMatter(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.md")
mustWriteTestFile(t, path, "+++\ntitle = \"sin cierre\"\n")
if _, err := parseMarkdown(path); err == nil {
t.Fatal("expected invalid front matter to fail")
}
}
+164
View File
@@ -0,0 +1,164 @@
package clusterlog
import (
"encoding/json"
"strings"
)
type inputSchema struct {
Schema string `json:"$schema"`
ID string `json:"$id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Required []string `json:"required,omitempty"`
Properties map[string]map[string]any `json:"properties"`
Additional bool `json:"additionalProperties"`
Examples []any `json:"examples,omitempty"`
}
func property(kind, description string) map[string]any {
return map[string]any{"type": kind, "description": description}
}
func stringArray(description string) map[string]any {
return map[string]any{
"type": "array", "description": description,
"items": map[string]any{"type": "string"}, "uniqueItems": true,
}
}
func enumProperty(description string, values ...string) map[string]any {
items := make([]any, len(values))
for i, value := range values {
items[i] = value
}
return map[string]any{"type": "string", "description": description, "enum": items}
}
func schemaFor(name string) (inputSchema, bool) {
base := inputSchema{
Schema: "https://json-schema.org/draft/2020-12/schema",
ID: "https://clusterlog.local/schemas/" + name + ".json",
Type: "object", Additional: false,
}
switch name {
case "memory":
base.Title = "Entrada de memoria operativa"
base.Description = "Contrato de entrada para clusterlog memory create --from-json. Nunca incluya secretos sin redactar."
base.Required = []string{"title"}
base.Properties = map[string]map[string]any{
"title": property("string", "Título breve y accionable"),
"description": property("string", "Resumen para listados"),
"tags": stringArray("Etiquetas temáticas"),
"systems": stringArray("Sistemas o componentes afectados"),
"risk": enumProperty("Nivel de riesgo", "low", "medium", "high", "critical"),
"ticket": property("string", "Ticket, incidente o cambio relacionado"),
"review_required": property("boolean", "Incluye la entrada en la cola de revisión"),
"review_policy": enumProperty("Quién debe revisar", "all-active", "all-active-except-author"),
"body_markdown": property("string", "Cuerpo Markdown completo; sustituye las secciones estructuradas"),
"context": property("string", "Contexto de la intervención"),
"changes": stringArray("Cambios realizados"),
"commands": stringArray("Comandos relevantes con secretos redactados"),
"validation": stringArray("Comprobaciones ejecutadas"),
"rollback": property("string", "Procedimiento de reversión"),
"started_at": property("string", "Inicio RFC3339"),
"finished_at": property("string", "Fin RFC3339"),
"duration_minutes": property("integer", "Duración total en minutos"),
"agent": property("string", "Agente que preparó el borrador, por ejemplo claude-code"),
}
base.Examples = []any{MemoryInput{
Title: "Actualizar firmware del switch spine-01", Description: "Cambio controlado y validación de enlaces",
Tags: []string{"red", "firmware"}, Systems: []string{"spine-01"}, Risk: "high", Ticket: "CHG-1042",
Changes: []string{"Respaldé la configuración", "Instalé la versión aprobada"},
Commands: []string{"show version", "show interfaces status"},
Validation: []string{"Todos los uplinks quedaron up", "Sin errores nuevos en logs"},
Rollback: "Arrancar la imagen anterior y restaurar la configuración respaldada.", Agent: "claude-code",
}}
case "task":
base.Title = "Tarea compartida"
base.Description = "Contrato de entrada para clusterlog task create --from-json."
base.Required = []string{"title"}
base.Properties = map[string]map[string]any{
"title": property("string", "Título de la tarea"),
"description": property("string", "Resumen"),
"project_id": property("string", "Proyecto relacionado, si existe"),
"priority": enumProperty("Prioridad", "low", "medium", "high", "urgent"),
"assignees": stringArray("IDs de administradores responsables"),
"estimate_minutes": property("integer", "Estimación en minutos"),
"due": property("string", "Fecha límite YYYY-MM-DD"),
"tags": stringArray("Etiquetas"),
"systems": stringArray("Sistemas afectados"),
"body_markdown": property("string", "Cuerpo Markdown completo"),
"objective": property("string", "Resultado esperado"),
"acceptance_criteria": stringArray("Criterios verificables"),
"notes": property("string", "Notas de ejecución"),
}
base.Examples = []any{TaskInput{Title: "Probar restauración de etcd", Priority: "high", EstimateMinutes: 120, Due: "2026-09-15", Objective: "Validar el RTO del plano de control", Acceptance: []string{"Restauración documentada", "RTO menor a 60 minutos"}}}
case "project":
base.Title = "Proyecto de operación"
base.Description = "Contrato de entrada para clusterlog project create --from-json."
base.Required = []string{"title", "objective"}
base.Properties = map[string]map[string]any{
"title": property("string", "Nombre del proyecto"),
"description": property("string", "Resumen ejecutivo"),
"objective": property("string", "Objetivo medible"),
"owners": stringArray("IDs de administradores responsables"),
"target_date": property("string", "Fecha objetivo YYYY-MM-DD"),
"status": enumProperty("Estado", "planned", "active", "blocked", "done", "cancelled", "archived"),
"tags": stringArray("Etiquetas"),
"systems": stringArray("Sistemas incluidos"),
"body_markdown": property("string", "Cuerpo Markdown completo"),
"scope": stringArray("Elementos dentro del alcance"),
"out_of_scope": stringArray("Elementos fuera del alcance"),
"milestones": stringArray("Hitos"),
"success_criteria": stringArray("Criterios de éxito"),
}
base.Examples = []any{ProjectInput{Title: "Estandarizar observabilidad", Objective: "Unificar métricas, logs y alertas del clúster", Status: "planned", Scope: []string{"Inventario de alertas", "Dashboards base"}, Success: []string{"100% de nodos reportando métricas"}}}
case "content":
base.Title = "Documento o manual"
base.Description = "Contrato de entrada para clusterlog content create --from-json."
base.Required = []string{"section", "title", "body_markdown"}
base.Properties = map[string]map[string]any{
"section": enumProperty("Sección principal", "documentacion", "manuales"),
"title": property("string", "Título"),
"description": property("string", "Resumen"),
"tags": stringArray("Etiquetas"),
"systems": stringArray("Sistemas relacionados"),
"body_markdown": property("string", "Contenido Markdown"),
"weight": property("integer", "Orden opcional dentro de la sección"),
}
base.Examples = []any{ContentInput{Section: "manuales", Title: "Reemplazo seguro de un nodo de cómputo", Tags: []string{"runbook"}, BodyMarkdown: "## Prerrequisitos\n\n...\n"}}
default:
return inputSchema{}, false
}
return base, true
}
func (a *App) runSchema(args []string) error {
if len(args) == 0 {
return a.printResult("schema", map[string]any{
"available": []string{"memory", "task", "project", "content"},
"usage": a.Options.Executable + " --json schema memory",
"stdin_example": "cat input.json | " + a.Options.Executable + " --json --commit memory create --from-json -",
})
}
if len(args) != 1 {
return NewError(ExitUsage, "schema_usage", "uso: schema [memory|task|project|content]", args)
}
name := strings.ToLower(args[0])
schema, ok := schemaFor(name)
if !ok {
return NewError(ExitUsage, "unknown_schema", "esquema desconocido", name)
}
// Convertir a un mapa preserva el documento JSON Schema como dato del sobre de salida.
data, err := json.Marshal(schema)
if err != nil {
return err
}
var document map[string]any
if err := json.Unmarshal(data, &document); err != nil {
return err
}
return a.printResult("schema."+name, document)
}
+539
View File
@@ -0,0 +1,539 @@
package clusterlog
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const siteStateVersion = 2
func loadReviewLog(root string) (ReviewLog, error) {
log := ReviewLog{Version: 1, Events: []ReviewEvent{}}
files, err := reviewEventFiles(root)
if err != nil {
return log, err
}
for _, path := range files {
var event ReviewEvent
if err := loadJSON(path, &event); err != nil {
return log, err
}
log.Events = append(log.Events, event)
}
sortReviewEvents(log.Events)
return log, nil
}
func loadTaskLog(root string) (TaskLog, error) {
log := TaskLog{Version: 1, Events: []TaskEvent{}}
files, err := taskEventFiles(root)
if err != nil {
return log, err
}
for _, path := range files {
var event TaskEvent
if err := loadJSON(path, &event); err != nil {
return log, err
}
log.Events = append(log.Events, event)
}
sortTaskEvents(log.Events)
return log, nil
}
func requiredReviewers(registry AdminRegistry, policy, authorID string) []string {
policy = firstNonEmpty(policy, registry.ReviewPolicy.Default)
required := activeAdminIDs(registry)
excludeAuthor := policy == "all-active-except-author" ||
(policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)
if !excludeAuthor || authorID == "" {
return required
}
filtered := make([]string, 0, len(required))
for _, id := range required {
if id != authorID {
filtered = append(filtered, id)
}
}
return filtered
}
func (a *App) BuildState(root string) (SiteState, error) {
registry, err := loadAdmins(root)
if err != nil {
return SiteState{}, err
}
reviews, err := loadReviewLog(root)
if err != nil {
return SiteState{}, err
}
tasksLog, err := loadTaskLog(root)
if err != nil {
return SiteState{}, err
}
memoryFiles, err := listMarkdownFiles(root, "memorias")
if err != nil {
return SiteState{}, err
}
taskFiles, err := listMarkdownFiles(root, "tareas")
if err != nil {
return SiteState{}, err
}
projectFiles, err := listMarkdownFiles(root, "proyectos")
if err != nil {
return SiteState{}, err
}
reviewFiles, err := reviewEventFiles(root)
if err != nil {
return SiteState{}, err
}
taskEventPaths, err := taskEventFiles(root)
if err != nil {
return SiteState{}, err
}
sourcePaths := []string{filepath.Join(root, "data", "admins.json")}
sourcePaths = append(sourcePaths, reviewFiles...)
sourcePaths = append(sourcePaths, taskEventPaths...)
sourcePaths = append(sourcePaths, memoryFiles...)
sourcePaths = append(sourcePaths, taskFiles...)
sourcePaths = append(sourcePaths, projectFiles...)
digest, err := sourceDigest(sourcePaths)
if err != nil {
return SiteState{}, err
}
memories := make([]MemoryRecord, 0, len(memoryFiles))
for _, path := range memoryFiles {
meta, err := parseMarkdown(path)
if err != nil {
return SiteState{}, err
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
continue
}
rel, _ := filepath.Rel(root, path)
record := MemoryRecord{
EntryID: id,
Title: meta.String("title"),
Description: meta.String("description"),
Date: meta.String("date"),
AuthorID: meta.ExtraString("author_id"),
AuthorFingerprint: meta.ExtraString("author_fingerprint"),
Risk: firstNonEmpty(meta.ExtraString("risk"), "low"),
Ticket: meta.ExtraString("ticket"),
ReviewRequired: meta.ExtraBool("review_required"),
ReviewPolicy: meta.ExtraString("review_policy"),
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"),
Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
Tags: uniqueSorted(meta.Taxonomies["tags"]),
Systems: uniqueSorted(meta.Taxonomies["systems"]),
Slug: pageSlug(meta),
SourcePath: filepath.ToSlash(rel),
ContentSHA256: meta.ContentHash,
}
record.Permalink = "/memorias/" + record.Slug + "/"
if record.ReviewPolicy == "" {
record.ReviewPolicy = registry.ReviewPolicy.Default
}
memories = append(memories, record)
}
sort.Slice(memories, func(i, j int) bool {
if memories[i].Date == memories[j].Date {
return memories[i].EntryID > memories[j].EntryID
}
return memories[i].Date > memories[j].Date
})
activeIDs := activeAdminIDs(registry)
queue := []ReviewQueueItem{}
for _, memory := range memories {
if !memory.ReviewRequired {
continue
}
var required []string
if memory.ReviewerSnapshot {
required = append([]string(nil), memory.Reviewers...)
} else {
required = requiredReviewers(registry, memory.ReviewPolicy, memory.AuthorID)
}
reviewedSet := map[string]bool{}
for _, event := range reviews.Events {
if normalizeID(event.EntryID) != memory.EntryID || event.ContentSHA256 != memory.ContentSHA256 {
continue
}
if contains(required, event.AdminID) {
reviewedSet[event.AdminID] = true
}
}
reviewed := []string{}
missing := []string{}
for _, id := range required {
if reviewedSet[id] {
reviewed = append(reviewed, id)
} else {
missing = append(missing, id)
}
}
item := ReviewQueueItem{
EntryID: memory.EntryID, Title: memory.Title, Description: memory.Description,
Date: memory.Date, AuthorID: memory.AuthorID, Risk: memory.Risk,
Tags: memory.Tags, Systems: memory.Systems, Permalink: memory.Permalink,
ContentSHA256: memory.ContentSHA256, ReviewedBy: reviewed,
MissingReviewers: missing, RequiredCount: len(required), ReviewedCount: len(reviewed),
}
if !memory.ReviewerSnapshot && len(activeIDs) == 0 {
item.ConfigurationError = "No hay administradores activos; registre al menos uno antes de cerrar revisiones."
}
if item.ConfigurationError != "" || len(missing) > 0 {
queue = append(queue, item)
}
}
taskStates := make([]TaskState, 0, len(taskFiles))
taskIndex := map[string]int{}
for _, path := range taskFiles {
meta, err := parseMarkdown(path)
if err != nil {
return SiteState{}, err
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
continue
}
rel, _ := filepath.Rel(root, path)
state := TaskState{
TaskID: id,
RunningBy: []string{},
Title: meta.String("title"),
Description: meta.String("description"),
Status: firstNonEmpty(meta.ExtraString("status"), "todo"),
Priority: firstNonEmpty(meta.ExtraString("priority"), "medium"),
ProjectID: normalizeID(meta.ExtraString("project_id")),
Assignees: uniqueSorted(meta.ExtraStrings("assignees")),
EstimateMinutes: meta.ExtraInt("estimate_minutes"),
Due: meta.ExtraString("due"),
Slug: pageSlug(meta),
SourcePath: filepath.ToSlash(rel),
}
state.Permalink = "/tareas/" + state.Slug + "/"
taskIndex[id] = len(taskStates)
taskStates = append(taskStates, state)
}
running := map[string]map[string]bool{}
for _, event := range tasksLog.Events {
id := normalizeID(event.TaskID)
idx, ok := taskIndex[id]
if !ok {
continue
}
state := &taskStates[idx]
actor := event.AdminID
switch event.Type {
case "start":
if running[id] == nil {
running[id] = map[string]bool{}
}
running[id][actor] = true
if state.Status == "todo" || state.Status == "backlog" {
state.Status = "in_progress"
}
case "stop":
state.ActualMinutes += event.Minutes
if running[id] != nil {
delete(running[id], actor)
}
case "adjust":
state.ActualMinutes += event.Minutes
case "status":
if event.Status != "" {
state.Status = event.Status
}
}
}
for id, actors := range running {
idx, ok := taskIndex[id]
if !ok {
continue
}
for actor, isRunning := range actors {
if isRunning {
taskStates[idx].RunningBy = append(taskStates[idx].RunningBy, actor)
}
}
sort.Strings(taskStates[idx].RunningBy)
}
sort.Slice(taskStates, func(i, j int) bool {
order := map[string]int{"in_progress": 0, "blocked": 1, "todo": 2, "backlog": 3, "done": 4, "cancelled": 5}
oi, ok := order[taskStates[i].Status]
if !ok {
oi = 9
}
oj, ok := order[taskStates[j].Status]
if !ok {
oj = 9
}
if oi == oj {
if taskStates[i].Due == taskStates[j].Due {
return taskStates[i].TaskID < taskStates[j].TaskID
}
if taskStates[i].Due == "" {
return false
}
if taskStates[j].Due == "" {
return true
}
return taskStates[i].Due < taskStates[j].Due
}
return oi < oj
})
projects := make([]ProjectState, 0, len(projectFiles))
for _, path := range projectFiles {
meta, err := parseMarkdown(path)
if err != nil {
return SiteState{}, err
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
continue
}
rel, _ := filepath.Rel(root, path)
project := ProjectState{
ProjectID: id,
Title: meta.String("title"),
Description: meta.String("description"),
Status: firstNonEmpty(meta.ExtraString("status"), "planned"),
Owners: uniqueSorted(meta.ExtraStrings("owners")),
TargetDate: meta.ExtraString("target_date"),
Slug: pageSlug(meta),
SourcePath: filepath.ToSlash(rel),
}
project.Permalink = "/proyectos/" + project.Slug + "/"
projects = append(projects, project)
}
sort.Slice(projects, func(i, j int) bool {
return projects[i].ProjectID < projects[j].ProjectID
})
openTasks := 0
for _, task := range taskStates {
if task.Status != "done" && task.Status != "cancelled" {
openTasks++
}
}
activeProjects := 0
for _, project := range projects {
switch project.Status {
case "done", "completed", "cancelled", "archived":
default:
activeProjects++
}
}
recent := memories
if len(recent) > 30 {
recent = recent[:30]
}
return SiteState{
Version: siteStateVersion,
SourceSHA256: digest,
Stats: SiteStats{
ActiveAdmins: len(activeIDs), PendingReviews: len(queue),
OpenTasks: openTasks, ActiveProjects: activeProjects,
},
ReviewQueue: queue,
RecentMemories: recent,
Tasks: taskStates,
Projects: projects,
}, nil
}
func (a *App) Sync(root string, check bool) (SiteState, bool, string, error) {
state, err := a.BuildState(root)
if err != nil {
return SiteState{}, false, "", err
}
path := filepath.Join(root, "data", "generated", "site_state.json")
var old SiteState
oldErr := loadJSON(path, &old)
upToDate := oldErr == nil && old.Version == state.Version && old.SourceSHA256 == state.SourceSHA256
if check {
if !upToDate {
return state, false, path, NewError(ExitValidation, "generated_state_stale",
"data/generated/site_state.json no corresponde a las fuentes actuales", map[string]any{
"expected_source_sha256": state.SourceSHA256,
"actual_source_sha256": old.SourceSHA256,
"hint": "ejecute clusterlog sync",
})
}
return old, false, path, nil
}
if upToDate {
return old, false, path, nil
}
state.GeneratedAt = a.Options.Now().UTC().Format("2006-01-02T15:04:05Z")
if a.Options.DryRun {
return state, true, path, nil
}
if err := writeJSONAtomic(path, state); err != nil {
return SiteState{}, false, path, err
}
return state, true, path, nil
}
func findMemory(root, id string) (MemoryRecord, PageMeta, error) {
id = normalizeID(id)
files, err := listMarkdownFiles(root, "memorias")
if err != nil {
return MemoryRecord{}, PageMeta{}, err
}
for _, path := range files {
meta, err := parseMarkdown(path)
if err != nil {
return MemoryRecord{}, PageMeta{}, err
}
if normalizeID(meta.ExtraString("id")) == id {
rel, _ := filepath.Rel(root, path)
record := MemoryRecord{
EntryID: id,
Title: meta.String("title"), Description: meta.String("description"),
Date: meta.String("date"), AuthorID: meta.ExtraString("author_id"),
AuthorFingerprint: meta.ExtraString("author_fingerprint"), Risk: meta.ExtraString("risk"),
ReviewRequired: meta.ExtraBool("review_required"), ReviewPolicy: meta.ExtraString("review_policy"),
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"), Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
Tags: meta.Taxonomies["tags"], Systems: meta.Taxonomies["systems"],
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel), ContentSHA256: meta.ContentHash,
}
record.Permalink = "/memorias/" + record.Slug + "/"
return record, meta, nil
}
}
return MemoryRecord{}, PageMeta{}, NewError(ExitUsage, "memory_not_found", "no existe la memoria indicada", id)
}
func findTask(root, id string) (TaskState, PageMeta, error) {
id = normalizeID(id)
files, err := listMarkdownFiles(root, "tareas")
if err != nil {
return TaskState{}, PageMeta{}, err
}
for _, path := range files {
meta, err := parseMarkdown(path)
if err != nil {
return TaskState{}, PageMeta{}, err
}
if normalizeID(meta.ExtraString("id")) == id {
rel, _ := filepath.Rel(root, path)
state := TaskState{
TaskID: id, Title: meta.String("title"), Description: meta.String("description"),
Status: firstNonEmpty(meta.ExtraString("status"), "todo"), Priority: meta.ExtraString("priority"),
ProjectID: normalizeID(meta.ExtraString("project_id")), Assignees: meta.ExtraStrings("assignees"),
EstimateMinutes: meta.ExtraInt("estimate_minutes"), Due: meta.ExtraString("due"),
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel),
}
state.Permalink = "/tareas/" + state.Slug + "/"
return state, meta, nil
}
}
return TaskState{}, PageMeta{}, NewError(ExitUsage, "task_not_found", "no existe la tarea indicada", id)
}
func taskActiveStart(log TaskLog, adminID string) (TaskEvent, bool) {
active := map[string]TaskEvent{}
for _, event := range log.Events {
if event.AdminID != adminID {
continue
}
id := normalizeID(event.TaskID)
switch event.Type {
case "start":
active[id] = event
case "stop":
delete(active, id)
}
}
if len(active) == 0 {
return TaskEvent{}, false
}
keys := make([]string, 0, len(active))
for id := range active {
keys = append(keys, id)
}
sort.Strings(keys)
return active[keys[0]], true
}
func taskIsRunning(log TaskLog, taskID, adminID string) (TaskEvent, bool) {
taskID = normalizeID(taskID)
var current TaskEvent
running := false
for _, event := range log.Events {
if normalizeID(event.TaskID) != taskID || event.AdminID != adminID {
continue
}
switch event.Type {
case "start":
current = event
running = true
case "stop":
running = false
}
}
return current, running
}
func ensureProjectExists(root, id string) error {
if strings.TrimSpace(id) == "" {
return nil
}
id = normalizeID(id)
files, err := listMarkdownFiles(root, "proyectos")
if err != nil {
return err
}
for _, path := range files {
meta, err := parseMarkdown(path)
if err != nil {
return err
}
if normalizeID(meta.ExtraString("id")) == id {
return nil
}
}
return NewError(ExitValidation, "project_not_found", "la tarea referencia un proyecto inexistente", id)
}
func generatedStateExists(root string) bool {
_, err := os.Stat(filepath.Join(root, "data", "generated", "site_state.json"))
return err == nil
}
func statusIsClosed(status string) bool {
switch strings.ToLower(status) {
case "done", "completed", "cancelled", "archived":
return true
default:
return false
}
}
func pageIDDuplicates(records []string) []string {
counts := map[string]int{}
for _, id := range records {
counts[id]++
}
out := []string{}
for id, count := range counts {
if id != "" && count > 1 {
out = append(out, fmt.Sprintf("%s (%d)", id, count))
}
}
sort.Strings(out)
return out
}
+193
View File
@@ -0,0 +1,193 @@
package clusterlog
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestReviewQueueUsesContentHashAndReviewerSnapshot(t *testing.T) {
root := testProjectRoot(t)
ana := testAdmin(t, "ana", 1, "owner", "admin")
bob := testAdmin(t, "bob", 2, "admin")
registry := AdminRegistry{
Version: 1,
ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true},
Admins: []Admin{ana, bob},
}
if err := saveAdmins(root, registry); err != nil {
t.Fatal(err)
}
memoryPath := filepath.Join(root, "content", "memorias", "memory.md")
mustWriteTestFile(t, memoryPath, `+++
title = "Cambio de red"
description = "Ajuste controlado"
date = 2026-08-12T10:00:00Z
authors = ["ana"]
slug = "cambio-red"
[taxonomies]
tags = ["red"]
systems = ["spine-01"]
[extra]
id = "MEM-TEST-001"
author_id = "ana"
author_fingerprint = "`+ana.SSHKeys[0].Fingerprint+`"
risk = "high"
review_required = true
review_policy = "all-active"
reviewer_snapshot = true
reviewers = ["ana", "bob"]
+++
## Cambio
Versión uno.
`)
app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) }})
state, err := app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.ReviewQueue) != 1 || len(state.ReviewQueue[0].MissingReviewers) != 2 {
t.Fatalf("unexpected initial queue: %#v", state.ReviewQueue)
}
memory, _, err := findMemory(root, "MEM-TEST-001")
if err != nil {
t.Fatal(err)
}
event := ReviewEvent{
ID: "REV-TEST-001", EntryID: memory.EntryID, AdminID: "ana",
Fingerprint: ana.SSHKeys[0].Fingerprint, ReviewedAt: "2026-08-12T12:01:00Z",
ContentSHA256: memory.ContentSHA256,
}
if _, err := writeReviewEvent(root, event); err != nil {
t.Fatal(err)
}
state, err = app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if got := state.ReviewQueue[0].MissingReviewers; len(got) != 1 || got[0] != "bob" {
t.Fatalf("unexpected missing reviewers after review: %#v", got)
}
// Adding a new administrator must not reopen old entries because the
// required reviewer set is captured when the memory is created.
carol := testAdmin(t, "carol", 3, "admin")
registry.Admins = append(registry.Admins, carol)
if err := saveAdmins(root, registry); err != nil {
t.Fatal(err)
}
state, err = app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if state.ReviewQueue[0].RequiredCount != 2 || contains(state.ReviewQueue[0].MissingReviewers, "carol") {
t.Fatalf("snapshot unexpectedly changed: %#v", state.ReviewQueue[0])
}
// Any edit changes the hash, invalidating the old review automatically.
data, err := os.ReadFile(memoryPath)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(memoryPath, append(data, []byte("\nCorrección posterior.\n")...), 0o644); err != nil {
t.Fatal(err)
}
state, err = app.BuildState(root)
if err != nil {
t.Fatal(err)
}
if got := state.ReviewQueue[0].MissingReviewers; len(got) != 2 || !contains(got, "ana") || !contains(got, "bob") {
t.Fatalf("old reviews were not invalidated: %#v", got)
}
}
func TestTaskProjectionFromIndependentEventFiles(t *testing.T) {
root := testProjectRoot(t)
ana := testAdmin(t, "ana", 1, "owner", "admin")
if err := saveAdmins(root, AdminRegistry{Version: 1, ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true}, Admins: []Admin{ana}}); err != nil {
t.Fatal(err)
}
mustWriteTestFile(t, filepath.Join(root, "content", "tareas", "task.md"), `+++
title = "Probar respaldo"
description = "Prueba de tarea"
date = 2026-08-12T10:00:00Z
authors = ["ana"]
slug = "probar-respaldo"
[taxonomies]
tags = ["backup"]
systems = ["etcd"]
[extra]
id = "TSK-TEST-001"
status = "todo"
priority = "high"
project_id = ""
assignees = ["ana"]
estimate_minutes = 60
due = "2026-08-20"
+++
## Objetivo
Validar el respaldo.
`)
events := []TaskEvent{
{ID: "EVT-3", TaskID: "TSK-TEST-001", Type: "status", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:30:00Z", Status: "blocked"},
{ID: "EVT-1", TaskID: "TSK-TEST-001", Type: "start", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:00:00Z"},
{ID: "EVT-2", TaskID: "TSK-TEST-001", Type: "stop", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:25:00Z", Minutes: 25},
{ID: "EVT-4", TaskID: "TSK-TEST-001", Type: "adjust", AdminID: "ana", Fingerprint: ana.SSHKeys[0].Fingerprint, At: "2026-08-12T10:31:00Z", Minutes: 5},
}
// Intentionally write out of chronological order: loading must sort by At.
if _, err := writeTaskEvents(root, events); err != nil {
t.Fatal(err)
}
state, err := New(Options{}).BuildState(root)
if err != nil {
t.Fatal(err)
}
if len(state.Tasks) != 1 {
t.Fatalf("unexpected tasks: %#v", state.Tasks)
}
got := state.Tasks[0]
if got.ActualMinutes != 30 || got.Status != "blocked" || len(got.RunningBy) != 0 || got.RunningBy == nil {
t.Fatalf("unexpected task projection: %#v", got)
}
}
func TestSyncRegeneratesWhenProjectionVersionChanges(t *testing.T) {
root := testProjectRoot(t)
app := New(Options{Root: root, Now: func() time.Time { return time.Date(2026, 8, 12, 15, 0, 0, 0, time.UTC) }})
state, changed, path, err := app.Sync(root, false)
if err != nil {
t.Fatal(err)
}
if !changed || state.Version != siteStateVersion {
t.Fatalf("unexpected initial sync: changed=%v state=%#v", changed, state)
}
var stale SiteState
if err := loadJSON(path, &stale); err != nil {
t.Fatal(err)
}
stale.Version = siteStateVersion - 1
if err := writeJSONAtomic(path, stale); err != nil {
t.Fatal(err)
}
if _, _, _, err := app.Sync(root, true); err == nil {
t.Fatal("sync --check accepted a stale projection version")
}
state, changed, _, err = app.Sync(root, false)
if err != nil {
t.Fatal(err)
}
if !changed || state.Version != siteStateVersion {
t.Fatalf("projection was not regenerated: changed=%v state=%#v", changed, state)
}
}
+361
View File
@@ -0,0 +1,361 @@
package clusterlog
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
func loadJSON(path string, dst any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(data, dst); err != nil {
return fmt.Errorf("JSON inválido en %s: %w", path, err)
}
return nil
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return writeFileAtomic(path, data, 0o644)
}
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".clusterlog-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func readAll(path string, stdin io.Reader) ([]byte, error) {
if path == "" {
return nil, nil
}
if path == "-" {
return io.ReadAll(stdin)
}
return os.ReadFile(path)
}
func fileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
func bytesSHA256(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func generateID(prefix string, now time.Time) (string, error) {
random := make([]byte, 3)
if _, err := rand.Read(random); err != nil {
return "", err
}
return fmt.Sprintf("%s-%s-%s", strings.ToUpper(prefix), now.UTC().Format("20060102"), strings.ToUpper(hex.EncodeToString(random))), nil
}
func normalizeID(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func slugify(value string) string {
replacements := map[rune]rune{
'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'Á': 'a', 'À': 'a', 'Ä': 'a', 'Â': 'a',
'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'É': 'e', 'È': 'e', 'Ë': 'e', 'Ê': 'e',
'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'Í': 'i', 'Ì': 'i', 'Ï': 'i', 'Î': 'i',
'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'Ó': 'o', 'Ò': 'o', 'Ö': 'o', 'Ô': 'o',
'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'Ú': 'u', 'Ù': 'u', 'Ü': 'u', 'Û': 'u',
'ñ': 'n', 'Ñ': 'n', 'ç': 'c', 'Ç': 'c',
}
var b strings.Builder
lastDash := false
for _, r := range value {
if replacement, ok := replacements[r]; ok {
r = replacement
}
r = unicode.ToLower(r)
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func splitCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
seen := map[string]bool{}
out := []string{}
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func parseDurationMinutes(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, nil
}
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
return 0, fmt.Errorf("la duración no puede ser negativa")
}
return n, nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("duración inválida %q; use minutos, 90m, 2h o 1h30m", value)
}
if duration < 0 {
return 0, fmt.Errorf("la duración no puede ser negativa")
}
return int(duration.Round(time.Minute) / time.Minute), nil
}
func formatMinutes(minutes int) string {
if minutes <= 0 {
return "0m"
}
hours := minutes / 60
rest := minutes % 60
if hours == 0 {
return fmt.Sprintf("%dm", rest)
}
if rest == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, rest)
}
func listMarkdownFiles(root, section string) ([]string, error) {
base := filepath.Join(root, "content", section)
entries := []string{}
err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" || filepath.Base(path) == "_index.md" {
return nil
}
entries = append(entries, path)
return nil
})
if os.IsNotExist(err) {
return nil, nil
}
sort.Strings(entries)
return entries, err
}
func sourceDigest(paths []string) (string, error) {
sort.Strings(paths)
h := sha256.New()
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
_, _ = h.Write([]byte(filepath.ToSlash(path)))
_, _ = h.Write([]byte{0})
_, _ = h.Write(data)
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func uniqueSorted(values []string) []string {
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
seen[value] = true
}
}
out := make([]string, 0, len(seen))
for value := range seen {
out = append(out, value)
}
sort.Strings(out)
return out
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func hasRole(admin Admin, role string) bool {
return contains(admin.Roles, role)
}
func relativePaths(root string, paths []string) ([]string, error) {
out := make([]string, 0, len(paths))
for _, path := range paths {
rel, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
out = append(out, filepath.ToSlash(rel))
}
return out, nil
}
func validateDate(value string) error {
if value == "" {
return nil
}
if _, err := time.Parse("2006-01-02", value); err != nil {
return fmt.Errorf("fecha inválida %q; use YYYY-MM-DD", value)
}
return nil
}
var secretPatterns = []struct {
Name string
Pattern *regexp.Regexp
}{
{"llave privada OpenSSH", regexp.MustCompile(`(?i)BEGIN[ ]+OPENSSH[ ]+PRIVATE[ ]+KEY`)},
{"llave privada PEM", regexp.MustCompile(`(?i)BEGIN[ ]+(RSA|EC|DSA)?[ ]*PRIVATE[ ]+KEY`)},
{"token GitHub", regexp.MustCompile(`\b(ghp|github_pat)_[A-Za-z0-9_]{20,}\b`)},
{"access key AWS", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
{"token Slack", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`)},
{"contraseña explícita", regexp.MustCompile(`(?im)\b(password|passwd|contrase(?:ñ|n)a)\s*[:=]\s*[^<\s][^\n]{3,}`)},
}
func scanSecrets(text string) []string {
findings := []string{}
for _, item := range secretPatterns {
if item.Pattern.MatchString(text) {
findings = append(findings, item.Name)
}
}
return uniqueSorted(findings)
}
func tomlString(value string) string { return strconv.Quote(value) }
func tomlStringArray(values []string) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, tomlString(value))
}
return "[" + strings.Join(parts, ", ") + "]"
}
func bullets(values []string) string {
if len(values) == 0 {
return "_No especificado._\n"
}
var b strings.Builder
for _, value := range values {
fmt.Fprintf(&b, "- %s\n", value)
}
return b.String()
}
func codeBlock(values []string) string {
if len(values) == 0 {
return "_No se registraron comandos._\n"
}
return "```console\n" + strings.Join(values, "\n") + "\n```\n"
}
func normalizeMarkdownBody(body string) string {
body = strings.ReplaceAll(body, "\r\n", "\n")
return strings.TrimSpace(body) + "\n"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func readLines(r io.Reader) ([]string, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 1024), 1024*1024)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
func canonicalJSON(value any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(value); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+58
View File
@@ -0,0 +1,58 @@
package clusterlog
import "testing"
func TestSlugifySpanishText(t *testing.T) {
got := slugify(" Actualización del Clúster: Niño / ÁRBOL ")
want := "actualizacion-del-cluster-nino-arbol"
if got != want {
t.Fatalf("slugify() = %q, want %q", got, want)
}
}
func TestParseDurationMinutes(t *testing.T) {
tests := map[string]int{
"90": 90,
"90m": 90,
"2h": 120,
"1h30m": 90,
}
for input, want := range tests {
got, err := parseDurationMinutes(input)
if err != nil {
t.Fatalf("parseDurationMinutes(%q): %v", input, err)
}
if got != want {
t.Errorf("parseDurationMinutes(%q) = %d, want %d", input, got, want)
}
}
if _, err := parseDurationMinutes("-5m"); err == nil {
t.Fatal("expected negative duration to fail")
}
}
func TestScanSecrets(t *testing.T) {
text := "password = supersecreto\n-----BEGIN OPENSSH PRIVATE KEY-----"
findings := scanSecrets(text)
if !contains(findings, "contraseña explícita") || !contains(findings, "llave privada OpenSSH") {
t.Fatalf("unexpected findings: %#v", findings)
}
if findings := scanSecrets("kubectl get nodes\nshow version"); len(findings) != 0 {
t.Fatalf("false positive findings: %#v", findings)
}
}
func TestPublicKeyFingerprintIsStable(t *testing.T) {
key := testOpenSSHPublicKey(7, "ana@test")
canonical1, fingerprint1, err := parsePublicKeyLine(key)
if err != nil {
t.Fatal(err)
}
canonical2, fingerprint2, err := parsePublicKeyLine(" " + key + " ")
if err != nil {
t.Fatal(err)
}
if canonical1 != canonical2 || fingerprint1 != fingerprint2 {
t.Fatalf("key parsing is not stable: %q/%q vs %q/%q", canonical1, fingerprint1, canonical2, fingerprint2)
}
}
+78
View File
@@ -0,0 +1,78 @@
package clusterlog
import (
"bytes"
"encoding/base64"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func testProjectRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
mustWriteTestFile(t, filepath.Join(root, "zola.toml"), "title = \"test\"\n")
for _, section := range []string{"documentacion", "manuales", "memorias", "proyectos", "tareas", "revision"} {
if err := os.MkdirAll(filepath.Join(root, "content", section), 0o755); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{
filepath.Join(root, "data", reviewEventsDirName),
filepath.Join(root, "data", taskEventsDirName),
filepath.Join(root, "config"),
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
registry := AdminRegistry{
Version: 1,
ReviewPolicy: ReviewPolicy{Default: "all-active", IncludeAuthor: true},
Admins: []Admin{},
}
if err := saveAdmins(root, registry); err != nil {
t.Fatal(err)
}
return root
}
func mustWriteTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
func testOpenSSHPublicKey(seed byte, comment string) string {
var blob bytes.Buffer
writeSSHString := func(value []byte) {
_ = binary.Write(&blob, binary.BigEndian, uint32(len(value)))
_, _ = blob.Write(value)
}
writeSSHString([]byte("ssh-ed25519"))
key := bytes.Repeat([]byte{seed}, 32)
writeSSHString(key)
encoded := base64.StdEncoding.EncodeToString(blob.Bytes())
if comment == "" {
return "ssh-ed25519 " + encoded
}
return "ssh-ed25519 " + encoded + " " + comment
}
func testAdmin(t *testing.T, id string, seed byte, roles ...string) Admin {
t.Helper()
publicKey := testOpenSSHPublicKey(seed, id+"@test")
_, fingerprint, err := parsePublicKeyLine(publicKey)
if err != nil {
t.Fatal(err)
}
return Admin{
ID: id, Name: id, Email: id + "@example.test", Active: true, Roles: roles,
SSHKeys: []SSHKey{{Fingerprint: fingerprint, PublicKey: publicKey, AddedAt: "2026-08-12T00:00:00Z"}},
}
}
+377
View File
@@ -0,0 +1,377 @@
package clusterlog
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
const Version = "0.1.0"
const (
ExitOK = 0
ExitUsage = 2
ExitIdentity = 3
ExitValidation = 4
ExitConflict = 5
ExitGit = 6
ExitInternal = 10
)
type CLIError struct {
Code int
Kind string
Message string
Details any
}
func (e *CLIError) Error() string { return e.Message }
func NewError(code int, kind, message string, details any) *CLIError {
return &CLIError{Code: code, Kind: kind, Message: message, Details: details}
}
type Options struct {
Root string
JSON bool
SSHKey string
Commit bool
DryRun bool
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
Now func() time.Time
Executable string
}
type App struct {
Options Options
}
func New(options Options) *App {
if options.Stdout == nil {
options.Stdout = os.Stdout
}
if options.Stderr == nil {
options.Stderr = os.Stderr
}
if options.Stdin == nil {
options.Stdin = os.Stdin
}
if options.Now == nil {
options.Now = time.Now
}
if options.Executable == "" {
options.Executable = "clusterlog"
}
return &App{Options: options}
}
type ReviewPolicy struct {
Default string `json:"default"`
IncludeAuthor bool `json:"include_author"`
}
type SSHKey struct {
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
AddedAt string `json:"added_at,omitempty"`
}
type Admin struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Active bool `json:"active"`
Roles []string `json:"roles,omitempty"`
SSHKeys []SSHKey `json:"ssh_keys"`
}
type AdminRegistry struct {
Version int `json:"version"`
ReviewPolicy ReviewPolicy `json:"review_policy"`
Admins []Admin `json:"admins"`
}
type Identity struct {
Admin Admin `json:"admin"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
PublicPath string `json:"public_key_path"`
SigningKey string `json:"signing_key_path"`
}
type ReviewEvent struct {
ID string `json:"id"`
EntryID string `json:"entry_id"`
AdminID string `json:"admin_id"`
Fingerprint string `json:"fingerprint"`
ReviewedAt string `json:"reviewed_at"`
ContentSHA256 string `json:"content_sha256"`
Note string `json:"note,omitempty"`
}
type ReviewLog struct {
Version int `json:"version"`
Events []ReviewEvent `json:"events"`
}
type TaskEvent struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Type string `json:"type"`
AdminID string `json:"admin_id"`
Fingerprint string `json:"fingerprint"`
At string `json:"at"`
Status string `json:"status,omitempty"`
Minutes int `json:"minutes,omitempty"`
Note string `json:"note,omitempty"`
}
type TaskLog struct {
Version int `json:"version"`
Events []TaskEvent `json:"events"`
}
type PageMeta struct {
Path string
Body string
Root map[string]any
Taxonomies map[string][]string
Extra map[string]any
RawFront string
ContentHash string
}
func (p PageMeta) String(key string) string {
if v, ok := p.Root[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func (p PageMeta) ExtraString(key string) string {
if v, ok := p.Extra[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func (p PageMeta) ExtraBool(key string) bool {
v, ok := p.Extra[key]
if !ok {
return false
}
b, _ := v.(bool)
return b
}
func (p PageMeta) ExtraInt(key string) int {
v, ok := p.Extra[key]
if !ok {
return 0
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func (p PageMeta) ExtraStrings(key string) []string {
v, ok := p.Extra[key]
if !ok {
return nil
}
switch values := v.(type) {
case []string:
return values
case []any:
out := make([]string, 0, len(values))
for _, item := range values {
out = append(out, fmt.Sprint(item))
}
return out
default:
return nil
}
}
type MemoryRecord struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Description string `json:"description"`
Date string `json:"date"`
AuthorID string `json:"author_id"`
AuthorFingerprint string `json:"author_fingerprint"`
Risk string `json:"risk"`
Ticket string `json:"ticket,omitempty"`
ReviewRequired bool `json:"review_required"`
ReviewPolicy string `json:"review_policy"`
ReviewerSnapshot bool `json:"reviewer_snapshot"`
Reviewers []string `json:"reviewers"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
ContentSHA256 string `json:"content_sha256"`
}
type ReviewQueueItem struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Description string `json:"description"`
Date string `json:"date"`
AuthorID string `json:"author_id"`
Risk string `json:"risk"`
Tags []string `json:"tags"`
Systems []string `json:"systems"`
Permalink string `json:"permalink"`
ContentSHA256 string `json:"content_sha256"`
ReviewedBy []string `json:"reviewed_by"`
MissingReviewers []string `json:"missing_reviewers"`
RequiredCount int `json:"required_count"`
ReviewedCount int `json:"reviewed_count"`
ConfigurationError string `json:"configuration_error,omitempty"`
}
type TaskState struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
ProjectID string `json:"project_id,omitempty"`
Assignees []string `json:"assignees"`
EstimateMinutes int `json:"estimate_minutes"`
ActualMinutes int `json:"actual_minutes"`
Due string `json:"due,omitempty"`
RunningBy []string `json:"running_by"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
}
type ProjectState struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Owners []string `json:"owners"`
TargetDate string `json:"target_date,omitempty"`
Slug string `json:"slug"`
Permalink string `json:"permalink"`
SourcePath string `json:"source_path"`
}
type SiteStats struct {
ActiveAdmins int `json:"active_admins"`
PendingReviews int `json:"pending_reviews"`
OpenTasks int `json:"open_tasks"`
ActiveProjects int `json:"active_projects"`
}
type SiteState struct {
Version int `json:"version"`
GeneratedAt string `json:"generated_at"`
SourceSHA256 string `json:"source_sha256"`
Stats SiteStats `json:"stats"`
ReviewQueue []ReviewQueueItem `json:"review_queue"`
RecentMemories []MemoryRecord `json:"recent_memories"`
Tasks []TaskState `json:"tasks"`
Projects []ProjectState `json:"projects"`
}
type ResultEnvelope struct {
OK bool `json:"ok"`
Command string `json:"command,omitempty"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
func (a *App) printResult(command string, data any, warnings ...string) error {
if a.Options.JSON {
enc := json.NewEncoder(a.Options.Stdout)
enc.SetIndent("", " ")
return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings})
}
switch v := data.(type) {
case string:
fmt.Fprintln(a.Options.Stdout, v)
default:
bytes, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Fprintln(a.Options.Stdout, string(bytes))
}
for _, warning := range warnings {
fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning)
}
return nil
}
func PrintError(w io.Writer, jsonMode bool, err error) int {
var cliErr *CLIError
if !errors.As(err, &cliErr) {
cliErr = NewError(ExitInternal, "internal", err.Error(), nil)
}
if jsonMode {
payload := ResultEnvelope{OK: false, Error: map[string]any{
"kind": cliErr.Kind,
"message": cliErr.Message,
"details": cliErr.Details,
"exit_code": cliErr.Code,
}}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
} else {
fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message)
if cliErr.Details != nil {
bytes, _ := json.MarshalIndent(cliErr.Details, "", " ")
fmt.Fprintln(w, string(bytes))
}
}
return cliErr.Code
}
func (a *App) ResolveRoot() (string, error) {
if a.Options.Root != "" {
root, err := filepath.Abs(a.Options.Root)
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil {
return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root)
}
return root, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
current := cwd
for {
if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd)
}
+495
View File
@@ -0,0 +1,495 @@
package clusterlog
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type ValidationIssue struct {
Level string `json:"level"`
Code string `json:"code"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
type ValidationReport struct {
Valid bool `json:"valid"`
Errors []ValidationIssue `json:"errors"`
Warnings []ValidationIssue `json:"warnings"`
Checks map[string]int `json:"checks"`
ZolaChecked bool `json:"zola_checked"`
}
type pageRecord struct {
Section string
Meta PageMeta
ID string
}
func (r *ValidationReport) add(level, code, path, message string, details any) {
issue := ValidationIssue{Level: level, Code: code, Path: filepath.ToSlash(path), Message: message, Details: details}
if level == "warning" {
r.Warnings = append(r.Warnings, issue)
} else {
r.Errors = append(r.Errors, issue)
}
}
func valueStrings(value any) []string {
switch v := value.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, fmt.Sprint(item))
}
return out
case string:
if v != "" {
return []string{v}
}
}
return nil
}
func fingerprintBelongs(registry AdminRegistry, adminID, fingerprint string) bool {
admin, ok := adminByID(registry, adminID)
if !ok {
return false
}
for _, key := range admin.SSHKeys {
if key.Fingerprint == fingerprint {
return true
}
}
return false
}
func validTaskStatus(status string) bool {
switch status {
case "backlog", "todo", "in_progress", "blocked", "done", "cancelled":
return true
default:
return false
}
}
func validProjectStatus(status string) bool {
switch status {
case "planned", "active", "blocked", "done", "completed", "cancelled", "archived":
return true
default:
return false
}
}
func (a *App) runValidate(root string, args []string) error {
fs := newFlagSet("validate")
skipZola := fs.Bool("skip-zola", false, "omite zola check")
requireZola := fs.Bool("require-zola", false, "falla si Zola no está instalado")
if err := fs.Parse(args); err != nil {
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
}
report := ValidationReport{Checks: map[string]int{}, Errors: []ValidationIssue{}, Warnings: []ValidationIssue{}}
registry, err := loadAdmins(root)
if err != nil {
report.add("error", "admins_invalid", filepath.Join(root, "data", "admins.json"), err.Error(), nil)
} else {
a.validateAdmins(root, registry, &report)
}
pages := a.validatePages(root, registry, &report)
a.validateEvents(root, registry, pages, &report)
if len(report.Errors) == 0 {
if _, _, _, err := a.Sync(root, true); err != nil {
report.add("error", "generated_state_stale", filepath.Join(root, "data", "generated", "site_state.json"), err.Error(), nil)
} else {
report.Checks["generated_state"] = 1
}
}
if !*skipZola {
zolaPath, lookupErr := exec.LookPath("zola")
if lookupErr != nil {
if *requireZola {
report.add("error", "zola_missing", "", "Zola no está instalado o no está en PATH", nil)
} else {
report.add("warning", "zola_missing", "", "Zola no está instalado; se omitió la validación de plantillas y enlaces", nil)
}
} else {
report.ZolaChecked = true
cmd := exec.Command(zolaPath, "check")
cmd.Dir = root
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
report.add("error", "zola_check_failed", filepath.Join(root, "zola.toml"), "zola check falló", strings.TrimSpace(output.String()))
} else {
report.Checks["zola"] = 1
}
}
}
report.Valid = len(report.Errors) == 0
if !report.Valid {
return NewError(ExitValidation, "validation_failed", "la validación del proyecto encontró errores", report)
}
warnings := make([]string, 0, len(report.Warnings))
for _, issue := range report.Warnings {
warnings = append(warnings, issue.Message)
}
return a.printResult("validate", report, warnings...)
}
func (a *App) validateAdmins(root string, registry AdminRegistry, report *ValidationReport) {
if registry.ReviewPolicy.Default != "all-active" && registry.ReviewPolicy.Default != "all-active-except-author" {
report.add("error", "invalid_default_review_policy", filepath.Join(root, "data", "admins.json"), "review_policy.default no es válido", registry.ReviewPolicy.Default)
}
ids := map[string]string{}
emails := map[string]string{}
fingerprints := map[string]string{}
ownerCount := 0
for _, admin := range registry.Admins {
report.Checks["admins"]++
idKey := normalizeID(admin.ID)
if admin.ID == "" || slugify(admin.ID) != admin.ID {
report.add("error", "invalid_admin_id", filepath.Join(root, "data", "admins.json"), "el ID del administrador debe ser un slug estable en minúsculas", admin.ID)
}
if prior, ok := ids[idKey]; ok {
report.add("error", "duplicate_admin_id", filepath.Join(root, "data", "admins.json"), "ID de administrador duplicado", []string{prior, admin.ID})
}
ids[idKey] = admin.ID
emailKey := strings.ToLower(strings.TrimSpace(admin.Email))
if emailKey == "" || !strings.Contains(emailKey, "@") {
report.add("error", "invalid_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador inválido", admin.ID)
}
if prior, ok := emails[emailKey]; ok {
report.add("error", "duplicate_admin_email", filepath.Join(root, "data", "admins.json"), "correo de administrador duplicado", []string{prior, admin.ID})
}
emails[emailKey] = admin.ID
if hasRole(admin, "owner") && admin.Active {
ownerCount++
}
if admin.Active && len(admin.SSHKeys) == 0 {
report.add("error", "active_admin_without_key", filepath.Join(root, "data", "admins.json"), "un administrador activo debe tener al menos una llave SSH", admin.ID)
}
for _, key := range admin.SSHKeys {
report.Checks["ssh_keys"]++
_, computed, err := parsePublicKeyLine(key.PublicKey)
if err != nil {
report.add("error", "invalid_public_key", filepath.Join(root, "data", "admins.json"), err.Error(), admin.ID)
continue
}
if key.Fingerprint != computed {
report.add("error", "fingerprint_mismatch", filepath.Join(root, "data", "admins.json"), "la huella almacenada no corresponde a la llave pública", map[string]any{"admin_id": admin.ID, "stored": key.Fingerprint, "computed": computed})
}
if prior, ok := fingerprints[key.Fingerprint]; ok {
report.add("error", "duplicate_ssh_key", filepath.Join(root, "data", "admins.json"), "una llave SSH está asignada a más de un administrador", []string{prior, admin.ID})
}
fingerprints[key.Fingerprint] = admin.ID
}
}
if len(registry.Admins) > 0 && ownerCount == 0 {
report.add("error", "missing_owner", filepath.Join(root, "data", "admins.json"), "debe existir al menos un administrador activo con rol owner", nil)
}
allowedPath := filepath.Join(root, "config", "allowed_signers")
allowedData, err := os.ReadFile(allowedPath)
if err != nil {
report.add("error", "allowed_signers_missing", allowedPath, "no se pudo leer el archivo de firmantes autorizados", err.Error())
return
}
allowed := string(allowedData)
for _, admin := range registry.Admins {
if !admin.Active {
continue
}
for _, key := range admin.SSHKeys {
fields := strings.Fields(key.PublicKey)
if len(fields) < 2 {
continue
}
expected := fmt.Sprintf("%s namespaces=\"git\" %s %s", admin.Email, fields[0], fields[1])
if !strings.Contains(allowed, expected) {
report.add("error", "allowed_signer_missing", allowedPath, "falta una llave activa en allowed_signers", map[string]any{"admin_id": admin.ID, "fingerprint": key.Fingerprint})
}
}
}
}
func (a *App) validatePages(root string, registry AdminRegistry, report *ValidationReport) []pageRecord {
sections := []string{"documentacion", "manuales", "memorias", "proyectos", "tareas"}
pages := []pageRecord{}
ids := []string{}
for _, section := range sections {
files, err := listMarkdownFiles(root, section)
if err != nil {
report.add("error", "content_walk_failed", filepath.Join(root, "content", section), err.Error(), nil)
continue
}
for _, path := range files {
report.Checks["content_pages"]++
data, readErr := os.ReadFile(path)
if readErr != nil {
report.add("error", "content_read_failed", path, readErr.Error(), nil)
continue
}
if findings := scanSecrets(string(data)); len(findings) > 0 {
report.add("error", "possible_secret", path, "el contenido parece incluir secretos", findings)
}
meta, parseErr := parseMarkdown(path)
if parseErr != nil {
report.add("error", "front_matter_invalid", path, parseErr.Error(), nil)
continue
}
id := normalizeID(meta.ExtraString("id"))
if id == "" {
report.add("error", "missing_page_id", path, "la página no tiene extra.id", nil)
}
ids = append(ids, id)
pages = append(pages, pageRecord{Section: section, Meta: meta, ID: id})
if strings.TrimSpace(meta.String("title")) == "" {
report.add("error", "missing_title", path, "la página no tiene título", nil)
}
authors := valueStrings(meta.Root["authors"])
for _, author := range authors {
if _, ok := adminByID(registry, author); !ok && author != "sistema" {
report.add("error", "unknown_author", path, "la página referencia un autor no registrado", author)
}
}
}
}
for _, duplicate := range pageIDDuplicates(ids) {
report.add("error", "duplicate_page_id", filepath.Join(root, "content"), "hay IDs de contenido duplicados", duplicate)
}
projectIDs := map[string]bool{}
for _, page := range pages {
if page.Section == "proyectos" {
projectIDs[page.ID] = true
}
}
for _, page := range pages {
path := page.Meta.Path
switch page.Section {
case "memorias":
if !strings.HasPrefix(page.ID, "MEM-") {
report.add("error", "invalid_memory_id", path, "el ID de memoria debe iniciar con MEM-", page.ID)
}
authorID := page.Meta.ExtraString("author_id")
if _, ok := adminByID(registry, authorID); !ok && authorID != "sistema" {
report.add("error", "unknown_memory_author", path, "author_id no corresponde a un administrador", authorID)
}
fingerprint := page.Meta.ExtraString("author_fingerprint")
if authorID != "sistema" && !fingerprintBelongs(registry, authorID, fingerprint) {
report.add("error", "memory_fingerprint_mismatch", path, "author_fingerprint no pertenece al autor", map[string]any{"author_id": authorID, "fingerprint": fingerprint})
}
policy := page.Meta.ExtraString("review_policy")
if policy != "" && policy != "all-active" && policy != "all-active-except-author" {
report.add("error", "invalid_review_policy", path, "review_policy no es válido", policy)
}
if page.Meta.ExtraBool("reviewer_snapshot") {
seenReviewers := map[string]bool{}
for _, reviewer := range page.Meta.ExtraStrings("reviewers") {
if seenReviewers[reviewer] {
report.add("error", "duplicate_reviewer", path, "un revisor aparece más de una vez", reviewer)
}
seenReviewers[reviewer] = true
if _, ok := adminByID(registry, reviewer); !ok {
report.add("error", "unknown_reviewer", path, "el conjunto capturado referencia un administrador inexistente", reviewer)
}
}
}
case "tareas":
if !strings.HasPrefix(page.ID, "TSK-") {
report.add("error", "invalid_task_id", path, "el ID de tarea debe iniciar con TSK-", page.ID)
}
priority := page.Meta.ExtraString("priority")
if priority != "low" && priority != "medium" && priority != "high" && priority != "urgent" {
report.add("error", "invalid_task_priority", path, "priority no es válida", priority)
}
status := firstNonEmpty(page.Meta.ExtraString("status"), "todo")
if !validTaskStatus(status) {
report.add("error", "invalid_task_status", path, "status no es válido", status)
}
if due := page.Meta.ExtraString("due"); validateDate(due) != nil {
report.add("error", "invalid_due", path, "due debe usar YYYY-MM-DD", due)
}
projectID := normalizeID(page.Meta.ExtraString("project_id"))
if projectID != "" && !projectIDs[projectID] {
report.add("error", "unknown_project", path, "project_id no existe", projectID)
}
for _, assignee := range page.Meta.ExtraStrings("assignees") {
admin, ok := adminByID(registry, assignee)
if !ok || !admin.Active {
report.add("error", "invalid_assignee", path, "assignee no existe o está inactivo", assignee)
}
}
case "proyectos":
if !strings.HasPrefix(page.ID, "PRJ-") {
report.add("error", "invalid_project_id", path, "el ID de proyecto debe iniciar con PRJ-", page.ID)
}
status := firstNonEmpty(page.Meta.ExtraString("status"), "planned")
if !validProjectStatus(status) {
report.add("error", "invalid_project_status", path, "status no es válido", status)
}
if target := page.Meta.ExtraString("target_date"); validateDate(target) != nil {
report.add("error", "invalid_target_date", path, "target_date debe usar YYYY-MM-DD", target)
}
for _, owner := range page.Meta.ExtraStrings("owners") {
admin, ok := adminByID(registry, owner)
if !ok || !admin.Active {
report.add("error", "invalid_owner", path, "owner no existe o está inactivo", owner)
}
}
case "documentacion":
if !strings.HasPrefix(page.ID, "DOC-") {
report.add("error", "invalid_document_id", path, "el ID de documentación debe iniciar con DOC-", page.ID)
}
case "manuales":
if !strings.HasPrefix(page.ID, "RUN-") {
report.add("error", "invalid_manual_id", path, "el ID de manual debe iniciar con RUN-", page.ID)
}
}
}
return pages
}
func (a *App) validateEvents(root string, registry AdminRegistry, pages []pageRecord, report *ValidationReport) {
memories := map[string]pageRecord{}
tasks := map[string]pageRecord{}
for _, page := range pages {
if page.Section == "memorias" {
memories[page.ID] = page
}
if page.Section == "tareas" {
tasks[page.ID] = page
}
}
reviews, err := loadReviewLog(root)
if err != nil {
report.add("error", "review_log_invalid", filepath.Join(root, "data", reviewEventsDirName), err.Error(), nil)
} else {
seen := map[string]bool{}
for _, event := range reviews.Events {
report.Checks["review_events"]++
if seen[event.ID] {
report.add("error", "duplicate_review_event", filepath.Join(root, "data", reviewEventsDirName), "ID de evento de revisión duplicado", event.ID)
}
seen[event.ID] = true
if _, ok := memories[normalizeID(event.EntryID)]; !ok {
report.add("warning", "orphan_review_event", filepath.Join(root, "data", reviewEventsDirName), "el evento apunta a una memoria que ya no está en el árbol actual", event.EntryID)
}
if _, ok := adminByID(registry, event.AdminID); !ok {
report.add("error", "unknown_review_admin", filepath.Join(root, "data", reviewEventsDirName), "admin_id de revisión no existe", event.AdminID)
}
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
report.add("error", "review_fingerprint_mismatch", filepath.Join(root, "data", reviewEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
}
if _, parseErr := time.Parse(time.RFC3339, event.ReviewedAt); parseErr != nil {
report.add("error", "invalid_review_time", filepath.Join(root, "data", reviewEventsDirName), "reviewed_at no es RFC3339", event.ID)
}
if len(event.ContentSHA256) != 64 {
report.add("error", "invalid_review_hash", filepath.Join(root, "data", reviewEventsDirName), "content_sha256 no tiene longitud SHA-256", event.ID)
}
}
}
log, err := loadTaskLog(root)
if err != nil {
report.add("error", "task_log_invalid", filepath.Join(root, "data", taskEventsDirName), err.Error(), nil)
return
}
seen := map[string]bool{}
activeTimer := map[string]TaskEvent{}
totalMinutes := map[string]int{}
for _, event := range log.Events {
report.Checks["task_events"]++
if seen[event.ID] {
report.add("error", "duplicate_task_event", filepath.Join(root, "data", taskEventsDirName), "ID de evento de tarea duplicado", event.ID)
}
seen[event.ID] = true
if _, ok := tasks[normalizeID(event.TaskID)]; !ok {
report.add("error", "unknown_task_event_target", filepath.Join(root, "data", taskEventsDirName), "el evento apunta a una tarea inexistente", event.TaskID)
}
if _, ok := adminByID(registry, event.AdminID); !ok {
report.add("error", "unknown_task_admin", filepath.Join(root, "data", taskEventsDirName), "admin_id de evento no existe", event.AdminID)
}
if !fingerprintBelongs(registry, event.AdminID, event.Fingerprint) {
report.add("error", "task_fingerprint_mismatch", filepath.Join(root, "data", taskEventsDirName), "la huella del evento no pertenece al administrador", event.ID)
}
if _, parseErr := time.Parse(time.RFC3339, event.At); parseErr != nil {
report.add("error", "invalid_task_event_time", filepath.Join(root, "data", taskEventsDirName), "at no es RFC3339", event.ID)
}
switch event.Type {
case "start":
if active, ok := activeTimer[event.AdminID]; ok {
code := "duplicate_task_start"
message := "el administrador inició dos veces la misma tarea sin detenerla"
if normalizeID(active.TaskID) != normalizeID(event.TaskID) {
code = "concurrent_task_timers"
message = "el administrador tiene más de un temporizador activo"
}
report.add("error", code, filepath.Join(root, "data", taskEventsDirName), message, map[string]any{
"admin_id": event.AdminID, "active_event": active.ID, "new_event": event.ID,
})
}
activeTimer[event.AdminID] = event
case "stop":
active, ok := activeTimer[event.AdminID]
if !ok || normalizeID(active.TaskID) != normalizeID(event.TaskID) {
report.add("error", "task_stop_without_start", filepath.Join(root, "data", taskEventsDirName), "el evento stop no corresponde a un temporizador activo", event.ID)
} else {
delete(activeTimer, event.AdminID)
}
if event.Minutes <= 0 {
report.add("error", "invalid_stop_minutes", filepath.Join(root, "data", taskEventsDirName), "un evento stop debe registrar minutos positivos", event.ID)
}
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
case "adjust":
totalMinutes[normalizeID(event.TaskID)] += event.Minutes
if event.Minutes == 0 {
report.add("error", "invalid_adjust_minutes", filepath.Join(root, "data", taskEventsDirName), "un ajuste no puede ser cero", event.ID)
}
case "status":
if !validTaskStatus(event.Status) {
report.add("error", "invalid_status_event", filepath.Join(root, "data", taskEventsDirName), "status no es válido", event.Status)
}
default:
report.add("error", "invalid_task_event_type", filepath.Join(root, "data", taskEventsDirName), "tipo de evento desconocido", event.Type)
}
}
for taskID, minutes := range totalMinutes {
if minutes < 0 {
report.add("error", "negative_task_time", filepath.Join(root, "data", taskEventsDirName), "los eventos dejan el tiempo acumulado por debajo de cero", map[string]any{
"task_id": taskID, "actual_minutes": minutes,
})
}
}
// La lectura estable de errores facilita comparaciones en CI y por agentes.
sort.Slice(report.Errors, func(i, j int) bool {
if report.Errors[i].Path == report.Errors[j].Path {
return report.Errors[i].Code < report.Errors[j].Code
}
return report.Errors[i].Path < report.Errors[j].Path
})
sort.Slice(report.Warnings, func(i, j int) bool {
if report.Warnings[i].Path == report.Warnings[j].Path {
return report.Warnings[i].Code < report.Warnings[j].Code
}
return report.Warnings[i].Path < report.Warnings[j].Path
})
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REF="${ZOLA386_REF:-d9c9726435d1347341f2171f9aa751291f1db93d}"
DEST="$ROOT/themes/zola.386"
SOURCE="${ZOLA386_SOURCE:-}"
TMP=""
cleanup() {
if [[ -n "$TMP" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
}
trap cleanup EXIT
if [[ -z "$SOURCE" ]]; then
TMP="$(mktemp -d)"
SOURCE="$TMP/source"
git init -q "$SOURCE"
git -C "$SOURCE" remote add origin https://github.com/lopes/zola.386.git
git -C "$SOURCE" fetch -q --depth 1 origin "$REF"
git -C "$SOURCE" checkout -q --detach FETCH_HEAD
else
SOURCE="$(cd "$SOURCE" && pwd)"
fi
for required in sass static theme.toml; do
if [[ ! -e "$SOURCE/$required" ]]; then
echo "Falta $required en el tema fuente: $SOURCE" >&2
exit 1
fi
done
rm -rf "$DEST"
mkdir -p "$DEST"
mkdir -p "$DEST/templates"
cp -R "$SOURCE/sass" "$DEST/sass"
cp -R "$SOURCE/static" "$DEST/static"
cp "$SOURCE/theme.toml" "$DEST/theme.toml"
[[ -f "$SOURCE/LICENSE" ]] && cp "$SOURCE/LICENSE" "$DEST/LICENSE"
[[ -f "$SOURCE/README.md" ]] && cp "$SOURCE/README.md" "$DEST/UPSTREAM_README.md"
printf '%s\n' "$REF" > "$DEST/.upstream-revision"
cat <<MSG
Tema zola.386 preparado en themes/zola.386
Revisión fijada: $REF
Se conservaron Sass y archivos estáticos; las plantillas locales modernas tienen precedencia.
MSG
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLI="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
for command in jq mktemp cp find; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "Falta el comando requerido para la prueba E2E: $command" >&2
exit 1
fi
done
if [[ ! -x "$CLI" ]]; then
echo "No existe un binario ejecutable en $CLI; ejecute make build." >&2
exit 1
fi
TMP="$(mktemp -d)"
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT
cp -a "$ROOT/." "$TMP/"
CLI="$TMP/bin/clusterlog"
for section in memorias tareas proyectos; do
find "$TMP/content/$section" -maxdepth 1 -type f -name '*.md' ! -name '_index.md' -delete
done
find "$TMP/data/review-events" -type f ! -name '.gitkeep' -delete
find "$TMP/data/task-events" -type f ! -name '.gitkeep' -delete
cat > "$TMP/data/admins.json" <<'JSON'
{
"version": 1,
"review_policy": {"default": "all-active", "include_author": true},
"admins": []
}
JSON
: > "$TMP/config/allowed_signers"
# Material público ficticio: sólo ejercita resolución de huella, nunca firma.
cat > "$TMP/ana.pub" <<'KEY'
ssh-ed25519 ZmFrZS1rZXktbWF0ZXJpYWwtYW5hLTAwMDAwMDAwMDAwMDAwMA== ana@test
KEY
cat > "$TMP/bob.pub" <<'KEY'
ssh-ed25519 ZmFrZS1rZXktbWF0ZXJpYWwtYm9iLTAwMDAwMDAwMDAwMDAwMA== bob@test
KEY
run_json() {
local output
output=$("$@")
jq -e '.ok == true' <<<"$output" >/dev/null
printf '%s' "$output"
}
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
admin add --id ana --name 'Ana Admin' --email ana@example.org \
--key "$TMP/ana.pub" --roles admin >/dev/null
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
admin add --id bob --name 'Bob Admin' --email bob@example.org \
--key "$TMP/bob.pub" --roles admin >/dev/null
cat > "$TMP/memory.json" <<'JSON'
{
"title": "Actualizar kubelet de nodos de prueba",
"description": "Cambio controlado en dos nodos",
"tags": ["kubernetes", "mantenimiento"],
"systems": ["worker-test-01", "worker-test-02"],
"risk": "medium",
"review_required": true,
"review_policy": "all-active",
"context": "Validar la actualización antes de la ventana productiva.",
"changes": ["Drenar nodos", "Actualizar paquete", "Reintegrar nodos"],
"validation": ["Nodos Ready", "Pods reprogramados"],
"rollback": "Reinstalar la versión anterior."
}
JSON
memory=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
memory create --from-json "$TMP/memory.json")
mem_id=$(jq -er '.data.entry_id' <<<"$memory")
mem_path=$(jq -er '.data.path' <<<"$memory")
queue=$(run_json "$CLI" --root "$TMP" --json review list)
[[ $(jq -er '.data.count' <<<"$queue") -eq 1 ]]
[[ $(jq -er '.data.items[0].required_count' <<<"$queue") -eq 2 ]]
[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 0 ]]
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
review mark "$mem_id" --note 'Revisado por Ana' >/dev/null
queue=$(run_json "$CLI" --root "$TMP" --json review list)
[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 1 ]]
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
review mark "$mem_id" --note 'Revisado por Bob' >/dev/null
queue=$(run_json "$CLI" --root "$TMP" --json review list)
[[ $(jq -er '.data.count' <<<"$queue") -eq 0 ]]
# Cambiar el contenido invalida las confirmaciones de la versión anterior.
printf '\n## Seguimiento\n\nSe agregó una observación posterior.\n' >> "$mem_path"
run_json "$CLI" --root "$TMP" --json sync >/dev/null
queue=$(run_json "$CLI" --root "$TMP" --json review list)
[[ $(jq -er '.data.count' <<<"$queue") -eq 1 ]]
[[ $(jq -er '.data.items[0].reviewed_count' <<<"$queue") -eq 0 ]]
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" review mark "$mem_id" >/dev/null
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" review mark "$mem_id" >/dev/null
queue=$(run_json "$CLI" --root "$TMP" --json review list)
[[ $(jq -er '.data.count' <<<"$queue") -eq 0 ]]
cat > "$TMP/project.json" <<'JSON'
{
"title": "Renovar plataforma de monitoreo",
"description": "Actualizar y normalizar la observabilidad",
"objective": "Reducir puntos ciegos operativos.",
"owners": ["ana"],
"target_date": "2099-12-01",
"status": "active",
"tags": ["observabilidad"],
"systems": ["prometheus"],
"scope": ["Métricas y alertas"],
"out_of_scope": ["Mesa de ayuda"],
"milestones": ["Inventario", "Migración"],
"success_criteria": ["Cobertura completa de nodos"]
}
JSON
project=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/ana.pub" \
project create --from-json "$TMP/project.json")
project_id=$(jq -er '.data.project_id' <<<"$project")
cat > "$TMP/task.json" <<JSON
{
"title": "Migrar alertas de nodos",
"description": "Mover reglas al repositorio estándar",
"project_id": "$project_id",
"priority": "high",
"assignees": ["bob"],
"estimate_minutes": 60,
"due": "2099-11-01",
"tags": ["alertas"],
"systems": ["prometheus"],
"objective": "Centralizar reglas.",
"acceptance_criteria": ["Reglas validadas"],
"notes": "Sin cambios productivos."
}
JSON
task=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
task create --from-json "$TMP/task.json")
task_id=$(jq -er '.data.task_id' <<<"$task")
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
task start "$task_id" --note 'Inicio' >/dev/null
run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
task adjust "$task_id" --minutes 15 --note 'Trabajo previo' >/dev/null
done=$(run_json "$CLI" --root "$TMP" --json --ssh-key "$TMP/bob.pub" \
task done "$task_id" --note 'Terminado')
[[ $(jq -er '.data.task.status' <<<"$done") == done ]]
[[ $(jq -er '.data.task.actual_minutes' <<<"$done") -ge 15 ]]
[[ $(jq -er '.data.task.running_by | length' <<<"$done") -eq 0 ]]
tasks=$(run_json "$CLI" --root "$TMP" --json task list --project "$project_id")
[[ $(jq -er '.data.count' <<<"$tasks") -eq 1 ]]
[[ $(jq -er '.data.items[0].status' <<<"$tasks") == done ]]
projects=$(run_json "$CLI" --root "$TMP" --json project list --status active)
[[ $(jq -er '.data.count' <<<"$projects") -eq 1 ]]
run_json "$CLI" --root "$TMP" --json sync --check >/dev/null
validation=$(run_json "$CLI" --root "$TMP" --json validate --skip-zola)
[[ $(jq -er '.data.valid' <<<"$validation") == true ]]
[[ $(find "$TMP/data/review-events" -type f -name '*.json' | wc -l) -eq 4 ]]
[[ $(find "$TMP/data/task-events" -type f -name '*.json' | wc -l) -ge 4 ]]
printf 'E2E OK: memoria %s, proyecto %s, tarea %s; revisión por hash y eventos independientes verificados.\n' \
"$mem_id" "$project_id" "$task_id"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
"$BIN" --root "$ROOT" --json sync >/dev/null
"$BIN" --root "$ROOT" --json sync --check >/dev/null
"$BIN" --root "$ROOT" --json validate --skip-zola >/dev/null
"$BIN" --json schema memory >/dev/null
"$BIN" --json schema task >/dev/null
"$BIN" --json schema project >/dev/null
"$BIN" --json schema content >/dev/null
echo "Smoke test correcto."
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLI="${CLUSTERLOG_BIN:-$ROOT/bin/clusterlog}"
BASE_SHA="${BASE_SHA:-}"
HEAD_SHA="${HEAD_SHA:-HEAD}"
for command in git jq; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "Falta el comando requerido para verificar firmas: $command" >&2
exit 1
fi
done
if [[ ! -x "$CLI" ]]; then
echo "No existe un binario ejecutable en $CLI; ejecute make build." >&2
exit 1
fi
active_admins=$(jq '[.admins[] | select(.active == true)] | length' "$ROOT/data/admins.json")
if [[ "$active_admins" -eq 0 ]]; then
echo "Firmas: bootstrap sin administradores activos; comprobación omitida."
exit 0
fi
if [[ -z "$BASE_SHA" ]]; then
echo "BASE_SHA es obligatorio cuando existen administradores activos." >&2
exit 1
fi
if ! git -C "$ROOT" cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
echo "El commit base no está disponible: $BASE_SHA" >&2
exit 1
fi
if ! git -C "$ROOT" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then
echo "El commit de cabeza no está disponible: $HEAD_SHA" >&2
exit 1
fi
mapfile -t commits < <(git -C "$ROOT" rev-list --reverse "$BASE_SHA..$HEAD_SHA")
if [[ ${#commits[@]} -eq 0 ]]; then
echo "Firmas: no hay commits nuevos en el rango."
exit 0
fi
for commit in "${commits[@]}"; do
"$CLI" --root "$ROOT" verify commit "$commit" >/dev/null
echo "Firma SSH autorizada: $commit"
done
+131
View File
@@ -0,0 +1,131 @@
:root {
--ops-bg: #08131f;
--ops-panel: #102437;
--ops-panel-2: #17324a;
--ops-text: #eef6f7;
--ops-muted: #a9bec9;
--ops-accent: #5df2c2;
--ops-warning: #ffd166;
--ops-danger: #ff6b6b;
--ops-border: #3c6277;
--ops-shadow: 6px 6px 0 #02070b;
}
html { scroll-behavior: smooth; }
body { background: var(--ops-bg); color: var(--ops-text); }
a { color: var(--ops-accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }
code, kbd, pre { font-family: "Lucida Console", Monaco, monospace; }
code { overflow-wrap: anywhere; }
.container { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
.ops-header { position: sticky; top: 0; z-index: 20; background: rgba(8, 19, 31, .97); border-bottom: 3px double var(--ops-border); }
.ops-header__inner { min-height: 72px; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding-block: .6rem; }
.ops-brand { color: var(--ops-text); font-size: 1.15rem; font-weight: 800; text-decoration: none; text-transform: uppercase; letter-spacing: .04em; }
.ops-prompt { color: var(--ops-accent); font-family: monospace; font-size: .8rem; }
.ops-nav { margin-left: auto; display: flex; flex-wrap: wrap; gap: .25rem .75rem; align-items: center; }
.ops-nav a { color: var(--ops-text); text-decoration: none; padding: .35rem .1rem; border-bottom: 2px solid transparent; }
.ops-nav a:hover, .ops-nav a:focus { color: var(--ops-accent); border-color: var(--ops-accent); }
.ops-nav__review { color: var(--ops-warning) !important; }
.ops-nav__review span { display: inline-grid; place-items: center; min-width: 1.5rem; min-height: 1.5rem; border: 1px solid currentColor; }
.ops-search { padding-top: 1rem; }
.ops-search > label { display: block; color: var(--ops-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; margin-bottom: .3rem; }
.ops-search__row { display: flex; align-items: center; background: #02070b; border: 2px solid var(--ops-border); }
.ops-search input { flex: 1; min-width: 0; background: transparent; color: var(--ops-text); border: 0; padding: .7rem .8rem; font: inherit; outline: none; }
.ops-search input:focus { box-shadow: inset 0 0 0 2px var(--ops-accent); }
.ops-search kbd { margin-right: .55rem; color: var(--ops-muted); }
.ops-search-results { position: relative; z-index: 10; background: var(--ops-panel); border: 2px solid var(--ops-border); border-top: 0; box-shadow: var(--ops-shadow); max-height: 65vh; overflow: auto; }
.ops-search-result { display: block; color: var(--ops-text); text-decoration: none; padding: .7rem .9rem; border-bottom: 1px solid var(--ops-border); }
.ops-search-result:hover, .ops-search-result:focus { background: var(--ops-panel-2); }
.ops-search-result strong { display: block; color: var(--ops-accent); }
.ops-search-result small { color: var(--ops-muted); }
.ops-main { padding-block: 2.5rem 4rem; min-height: 70vh; }
.ops-hero { border: 4px double var(--ops-border); background: linear-gradient(135deg, var(--ops-panel), #0a1a29); padding: clamp(1.4rem, 4vw, 3.5rem); box-shadow: var(--ops-shadow); }
.ops-hero h1, .ops-page-header h1 { margin: .1em 0 .35em; line-height: 1.04; text-wrap: balance; }
.ops-lead { color: var(--ops-muted); font-size: 1.08rem; max-width: 72ch; }
.ops-eyebrow { margin: 0; color: var(--ops-accent); font-family: monospace; font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; }
.ops-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: .85rem; margin: 1.5rem 0 2.5rem; }
.ops-stat { display: flex; flex-direction: column; gap: .1rem; padding: 1rem; background: var(--ops-panel); border: 2px solid var(--ops-border); color: var(--ops-text); text-decoration: none; }
.ops-stat strong { color: var(--ops-accent); font-size: 1.9rem; }
.ops-stat span { color: var(--ops-muted); }
.ops-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.ops-grid--sections { margin-bottom: 3rem; }
.ops-panel { min-height: 165px; padding: 1.15rem; background: var(--ops-panel); border: 2px solid var(--ops-border); box-shadow: 4px 4px 0 #02070b; color: var(--ops-text); text-decoration: none; transition: transform .12s ease; }
.ops-panel:hover { transform: translate(-2px, -2px); border-color: var(--ops-accent); }
.ops-panel h2 { color: var(--ops-text); margin: .55rem 0 .35rem; }
.ops-panel p { color: var(--ops-muted); }
.ops-panel__code { color: var(--ops-accent); font-family: monospace; border: 1px solid currentColor; padding: .15rem .35rem; }
.ops-panel--attention { border-color: var(--ops-warning); }
.ops-panel--attention .ops-panel__code { color: var(--ops-warning); }
.ops-section-heading { display: flex; justify-content: space-between; align-items: end; gap: 1rem; margin: 2rem 0 1rem; border-bottom: 2px solid var(--ops-border); }
.ops-section-heading h2 { margin-top: .1rem; }
.ops-page-header { padding-bottom: 1.5rem; margin-bottom: 1.5rem; border-bottom: 4px double var(--ops-border); }
.ops-page-header > p:not(.ops-eyebrow) { color: var(--ops-muted); max-width: 78ch; }
.ops-meta { display: flex; flex-wrap: wrap; gap: .5rem 1rem; color: var(--ops-muted); font-size: .9rem; }
.ops-tags { display: flex; flex-wrap: wrap; gap: .4rem .65rem; margin-top: .7rem; color: var(--ops-accent); font-family: monospace; font-size: .86rem; }
.ops-tags a, .ops-tags span { color: inherit; }
.ops-list { display: grid; gap: 1rem; }
.ops-list-item { padding: 1rem 1.1rem; background: var(--ops-panel); border-left: 5px solid var(--ops-border); }
.ops-list-item:hover { border-left-color: var(--ops-accent); }
.ops-list-item h2, .ops-list-item h3 { margin: .45rem 0 .3rem; }
.ops-list-item p { color: var(--ops-muted); margin: .25rem 0; }
.ops-list-item__meta { display: flex; flex-wrap: wrap; gap: .4rem .75rem; align-items: center; color: var(--ops-muted); font-size: .82rem; }
.ops-list-item__meta code { color: var(--ops-accent); }
.ops-list-item--review { border-left-color: var(--ops-warning); }
.badge { display: inline-block; font-family: monospace; font-size: .72rem; padding: .14rem .35rem; border: 1px solid currentColor; text-transform: uppercase; }
.badge--low { color: var(--ops-accent); }
.badge--medium { color: #80d8ff; }
.badge--high { color: var(--ops-warning); }
.badge--critical { color: var(--ops-danger); }
.badge--status { color: var(--ops-warning); }
.ops-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; align-items: start; }
.ops-card { background: var(--ops-panel); border: 2px solid var(--ops-border); padding: 1rem; box-shadow: 4px 4px 0 #02070b; }
.ops-card__top { display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
.ops-card h2 { font-size: 1.15rem; margin: .8rem 0 .35rem; }
.ops-card p { color: var(--ops-muted); }
.ops-card dl { display: grid; grid-template-columns: auto 1fr; gap: .25rem .7rem; font-size: .88rem; }
.ops-card dt { color: var(--ops-muted); }
.ops-card dd { margin: 0; text-align: right; }
.ops-running { color: var(--ops-accent) !important; font-family: monospace; }
.ops-article { max-width: 900px; }
.ops-prose { line-height: 1.72; }
.ops-prose h2 { margin-top: 2em; border-bottom: 1px solid var(--ops-border); padding-bottom: .2em; }
.ops-prose h3 { margin-top: 1.6em; }
.ops-prose blockquote { border-left: 4px solid var(--ops-accent); margin-left: 0; padding: .6rem 1rem; background: var(--ops-panel); }
pre { overflow: auto; background: #02070b; color: var(--ops-text); padding: .9rem; border: 1px solid var(--ops-border); }
.ops-callout { margin: 1rem 0 1.5rem; padding: 1rem; background: #1b2b25; border: 2px solid var(--ops-accent); }
.ops-source { display: grid; gap: .3rem; margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid var(--ops-border); color: var(--ops-muted); }
.ops-review-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.ops-review-grid > div { display: grid; gap: .25rem; padding: .7rem; background: #091723; }
.reviewer { color: var(--ops-muted); }
.reviewer--done { color: var(--ops-accent); }
.reviewer--missing { color: var(--ops-warning); }
.ops-error { color: var(--ops-danger) !important; font-weight: 700; }
.ops-empty { padding: 2rem; border: 2px dashed var(--ops-border); color: var(--ops-muted); text-align: center; }
.ops-empty--success { border-color: var(--ops-accent); color: var(--ops-accent); }
.ops-footer { display: flex; justify-content: space-between; gap: 1rem; border-top: 3px double var(--ops-border); padding-block: 1.5rem 2.5rem; color: var(--ops-muted); font-size: .85rem; }
@media (max-width: 920px) {
.ops-stats, .ops-grid, .ops-board { grid-template-columns: repeat(2, 1fr); }
.ops-nav { width: 100%; margin-left: 0; }
}
@media (max-width: 620px) {
.ops-stats, .ops-grid, .ops-board, .ops-review-grid { grid-template-columns: 1fr; }
.ops-header { position: static; }
.ops-footer { flex-direction: column; }
.ops-panel { min-height: 0; }
}
.ops-meeting-section {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 2px dashed currentColor;
}
+142
View File
@@ -0,0 +1,142 @@
(() => {
"use strict";
const input = document.getElementById("ops-search-input");
const results = document.getElementById("ops-search-results");
if (!input || !results) return;
const indexURL = input.dataset.indexUrl;
let documentsPromise = null;
const normalize = (value) => String(value ?? "")
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLocaleLowerCase("es")
.replace(/\s+/g, " ")
.trim();
const loadDocuments = () => {
if (documentsPromise) return documentsPromise;
documentsPromise = fetch(indexURL, { credentials: "same-origin" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((index) => {
const docs = index?.documentStore?.docs ?? {};
return Object.entries(docs).map(([ref, doc]) => ({ ref, ...doc }));
});
return documentsPromise;
};
const scoreDocument = (doc, tokens, wholeQuery) => {
const title = normalize(doc.title);
const description = normalize(doc.description);
const content = normalize(doc.content ?? doc.body);
const path = normalize(doc.path ?? doc.ref);
const fullText = `${title} ${description} ${path} ${content}`;
if (!tokens.every((token) => fullText.includes(token))) return -1;
let score = 0;
if (title === wholeQuery) score += 100;
if (title.startsWith(wholeQuery)) score += 40;
if (title.includes(wholeQuery)) score += 25;
if (description.includes(wholeQuery)) score += 12;
for (const token of tokens) {
if (title.includes(token)) score += 12;
if (description.includes(token)) score += 6;
if (path.includes(token)) score += 4;
if (content.includes(token)) score += 2;
}
return score;
};
const clearResults = () => {
results.hidden = true;
results.replaceChildren();
};
const appendMessage = (title, detail) => {
const item = document.createElement("div");
item.className = "ops-search-result";
const strong = document.createElement("strong");
strong.textContent = title;
const small = document.createElement("small");
small.textContent = detail;
item.append(strong, small);
results.replaceChildren(item);
results.hidden = false;
};
const renderMatches = (matches) => {
const fragment = document.createDocumentFragment();
for (const { doc } of matches) {
const link = document.createElement("a");
link.className = "ops-search-result";
link.href = doc.permalink ?? doc.ref ?? doc.path ?? "/";
const title = document.createElement("strong");
title.textContent = doc.title || link.href;
const detail = document.createElement("small");
detail.textContent = doc.description || doc.path || "";
link.append(title, detail);
fragment.append(link);
}
results.replaceChildren(fragment);
results.hidden = false;
};
let requestSerial = 0;
const render = async (rawQuery) => {
const serial = ++requestSerial;
const query = normalize(rawQuery);
const tokens = [...new Set(query.split(" ").filter((token) => token.length > 1))];
if (query.length < 2 || tokens.length === 0) {
clearResults();
return;
}
try {
const documents = await loadDocuments();
if (serial !== requestSerial) return;
const matches = documents
.map((doc) => ({ doc, score: scoreDocument(doc, tokens, query) }))
.filter((item) => item.score >= 0)
.sort((a, b) => b.score - a.score || String(a.doc.title).localeCompare(String(b.doc.title), "es"))
.slice(0, 12);
if (matches.length === 0) {
appendMessage("Sin resultados", "Pruebe otro término o una etiqueta más corta.");
return;
}
renderMatches(matches);
} catch (error) {
console.warn("No se pudo cargar el índice de búsqueda", error);
appendMessage("Búsqueda no disponible", "Compruebe que el sitio se construyó con el índice habilitado.");
}
};
let debounceTimer = null;
input.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => render(input.value), 100);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
input.value = "";
clearResults();
input.blur();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "/" && document.activeElement !== input && !event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
input.focus();
}
});
document.addEventListener("click", (event) => {
if (!results.contains(event.target) && event.target !== input) results.hidden = true;
});
})();
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}404 · {{ config.title }}{% endblock title %}
{% block content %}<section class="ops-hero"><p class="ops-eyebrow">ERROR 404</p><h1>Ruta no encontrada</h1><p>La referencia no existe en la proyección actual de la bitácora.</p><p><a href="{{ get_url(path='@/_index.md') }}">Volver al inicio</a></p></section>{% endblock content %}
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% block description %}{{ config.description }}{% endblock description %}">
<meta name="robots" content="noindex,nofollow,noarchive">
<title>{% block title %}{{ config.title }}{% endblock title %}</title>
<link rel="stylesheet" href="{{ get_url(path='site.css') }}">
<link rel="stylesheet" href="{{ get_url(path='css/ops.css') }}">
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ get_url(path='atom.xml') }}">
</head>
<body>
{% set ops = load_data(path="data/generated/site_state.json", format="json") %}
<header class="ops-header">
<div class="container ops-header__inner">
<a class="ops-brand" href="{{ get_url(path='@/_index.md') }}">{{ config.extra.product_name }}</a>
<span class="ops-prompt" aria-hidden="true">C:\CLUSTER&gt;</span>
<nav class="ops-nav" aria-label="Navegación principal">
<a href="{{ get_url(path='@/documentacion/_index.md') }}">Documentación</a>
<a href="{{ get_url(path='@/manuales/_index.md') }}">Manuales</a>
<a href="{{ get_url(path='@/memorias/_index.md') }}">Memorias</a>
<a href="{{ get_url(path='@/proyectos/_index.md') }}">Proyectos</a>
<a href="{{ get_url(path='@/tareas/_index.md') }}">Tareas</a>
<a class="ops-nav__review" href="{{ get_url(path='@/revision/_index.md') }}">Revisión <span>{{ ops.stats.pending_reviews }}</span></a>
</nav>
</div>
</header>
<div class="container ops-search" role="search">
<label for="ops-search-input">Buscar en la bitácora</label>
<div class="ops-search__row">
<input id="ops-search-input" type="search" autocomplete="off" data-index-url="{{ get_url(path='search_index.es.json') }}" placeholder="título, sistema, comando, incidente…">
<kbd>/</kbd>
</div>
<div id="ops-search-results" class="ops-search-results" hidden></div>
</div>
<main class="container ops-main">
{% block content %}{% endblock content %}
</main>
<footer class="container ops-footer">
<span>{{ config.extra.footer_text }}</span>
<span>CLI: <code>{{ config.extra.product_short_name }}</code></span>
</footer>
<script defer src="{{ get_url(path='js/ops-search.js') }}"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<section class="ops-hero">
<p class="ops-eyebrow">OPERACIONES / CONOCIMIENTO / AUDITORÍA</p>
<h1>{{ section.title }}</h1>
<div class="ops-lead">{{ section.content | safe }}</div>
</section>
<section class="ops-stats" aria-label="Estado actual">
<a class="ops-stat" href="{{ get_url(path='@/revision/_index.md') }}"><strong>{{ ops.stats.pending_reviews }}</strong><span>revisiones pendientes</span></a>
<a class="ops-stat" href="{{ get_url(path='@/tareas/_index.md') }}"><strong>{{ ops.stats.open_tasks }}</strong><span>tareas abiertas</span></a>
<a class="ops-stat" href="{{ get_url(path='@/proyectos/_index.md') }}"><strong>{{ ops.stats.active_projects }}</strong><span>proyectos activos</span></a>
<div class="ops-stat"><strong>{{ ops.stats.active_admins }}</strong><span>administradores activos</span></div>
</section>
<section class="ops-grid ops-grid--sections">
<a class="ops-panel" href="{{ get_url(path='@/documentacion/_index.md') }}"><span class="ops-panel__code">DOC</span><h2>Documentación</h2><p>Arquitectura y decisiones duraderas.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/manuales/_index.md') }}"><span class="ops-panel__code">RUN</span><h2>Manuales</h2><p>Procedimientos repetibles y reversibles.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/memorias/_index.md') }}"><span class="ops-panel__code">MEM</span><h2>Memorias</h2><p>Historia de operaciones y cambios.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/proyectos/_index.md') }}"><span class="ops-panel__code">PRJ</span><h2>Proyectos</h2><p>Objetivos, alcance e hitos.</p></a>
<a class="ops-panel" href="{{ get_url(path='@/tareas/_index.md') }}"><span class="ops-panel__code">TSK</span><h2>Tareas</h2><p>Trabajo visible y tiempo registrado.</p></a>
<a class="ops-panel ops-panel--attention" href="{{ get_url(path='@/revision/_index.md') }}"><span class="ops-panel__code">REV</span><h2>Revisión semanal</h2><p>La agenda común del equipo.</p></a>
</section>
<section class="ops-section-heading">
<div><p class="ops-eyebrow">ACTIVIDAD</p><h2>Memorias recientes</h2></div>
<a href="{{ get_url(path='@/memorias/_index.md') }}">Ver todas →</a>
</section>
<div class="ops-list">
{% for item in ops.recent_memories %}
{% if loop.index <= 6 %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ item.entry_id }}</code><span class="badge badge--{{ item.risk }}">{{ item.risk }}</span><span>{{ item.date }}</span></div>
<h3><a href="{{ item.permalink }}">{{ item.title }}</a></h3>
<p>{{ item.description }}</p>
<div class="ops-tags">{% for tag in item.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
</article>
{% endif %}
{% endfor %}
{% if ops.recent_memories | length == 0 %}<p class="ops-empty">Todavía no hay memorias operativas.</p>{% endif %}
</div>
{% endblock content %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header">
<p class="ops-eyebrow">HISTORIA TÉCNICA</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p>
<div class="ops-prose">{{ section.content | safe }}</div>
</header>
<div class="ops-list">
{% for page in section.pages %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span class="badge badge--{{ page.extra.risk }}">{{ page.extra.risk }}</span><span>{{ page.date }}</span><span>por {{ page.extra.author_id }}</span></div>
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
<p>{{ page.description }}</p>
<div class="ops-tags">{% for system in page.taxonomies.systems %}<span>@{{ system }}</span>{% endfor %}{% for tag in page.taxonomies.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
</article>
{% endfor %}
</div>
{% endblock content %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header">
<p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / MEMORIA OPERATIVA</p>
<h1>{{ page.title }}</h1><p>{{ page.description }}</p>
<div class="ops-meta">
<span>Autor: <strong>{{ page.extra.author_id }}</strong></span>
<span>Fecha: {{ page.date }}</span>
<span>Riesgo: <strong class="badge badge--{{ page.extra.risk }}">{{ page.extra.risk }}</strong></span>
{% if page.extra.duration_minutes > 0 %}<span>Duración: {{ page.extra.duration_minutes }} min</span>{% endif %}
{% if page.extra.ticket %}<span>Referencia: {{ page.extra.ticket }}</span>{% endif %}
</div>
<div class="ops-tags">{% for system in page.taxonomies.systems %}<span>@{{ system }}</span>{% endfor %}{% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', name=tag) }}">#{{ tag }}</a>{% endfor %}</div>
</header>
{% if page.extra.review_required %}
<aside class="ops-callout"><strong>Revisión requerida.</strong> El estado se calcula contra el hash actual. Consulte <a href="{{ get_url(path='@/revision/_index.md') }}">Revisión semanal</a>.</aside>
{% endif %}
<div class="ops-prose">{{ page.content | safe }}</div>
<footer class="ops-source"><span>Huella SSH del autor:</span><code>{{ page.extra.author_fingerprint }}</code></footer>
</article>
{% endblock content %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header">
<p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / {{ page.extra.kind }}</p>
<h1>{{ page.title }}</h1>
<p>{{ page.description }}</p>
<div class="ops-meta"><span>Autor: {{ page.extra.author_id }}</span><span>Fecha: {{ page.date }}</span></div>
<div class="ops-tags">{% for tag in page.taxonomies.tags %}<a href="{{ get_taxonomy_url(kind='tags', name=tag) }}">#{{ tag }}</a>{% endfor %}</div>
</header>
<div class="ops-prose">{{ page.content | safe }}</div>
</article>
{% endblock content %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header"><p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / PROYECTO</p><h1>{{ page.title }}</h1><p>{{ page.description }}</p>
<div class="ops-meta"><span>Estado: {{ page.extra.status }}</span>{% if page.extra.target_date %}<span>Fecha objetivo: {{ page.extra.target_date }}</span>{% endif %}<span>Responsables: {% for owner in page.extra.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if page.extra.owners | length == 0 %}Sin asignar{% endif %}</span></div></header>
<div class="ops-prose">{{ page.content | safe }}</div>
</article>
{% endblock content %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">LÍNEAS DE TRABAJO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p><div class="ops-prose">{{ section.content | safe }}</div></header>
<div class="ops-board">
{% for project in ops.projects %}
<article class="ops-card">
<div class="ops-card__top"><code>{{ project.project_id }}</code><span class="badge badge--status">{{ project.status }}</span></div>
<h2><a href="{{ project.permalink }}">{{ project.title }}</a></h2><p>{{ project.description }}</p>
<dl>{% if project.target_date %}<dt>Objetivo</dt><dd>{{ project.target_date }}</dd>{% endif %}<dt>Responsables</dt><dd>{% for owner in project.owners %}{{ owner }}{% if not loop.last %}, {% endif %}{% endfor %}{% if project.owners | length == 0 %}Sin asignar{% endif %}</dd></dl>
</article>
{% endfor %}
{% if ops.projects | length == 0 %}<p class="ops-empty">No hay proyectos.</p>{% endif %}
</div>
{% endblock content %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header">
<p class="ops-eyebrow">AGENDA DEL EQUIPO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p>
<div class="ops-prose">{{ section.content | safe }}</div>
</header>
<div class="ops-callout">
<strong>Flujo de la reunión:</strong> abrir la memoria, discutirla y registrar cada confirmación con
<code>clusterlog --commit review mark &lt;ID&gt;</code>. Una edición posterior cambia el hash y vuelve a abrir la revisión.
</div>
<section aria-labelledby="review-queue-title">
<h2 id="review-queue-title">Memorias pendientes</h2>
<div class="ops-list">
{% for item in ops.review_queue %}
<article class="ops-list-item ops-list-item--review">
<div class="ops-list-item__meta"><code>{{ item.entry_id }}</code><span class="badge badge--{{ item.risk }}">{{ item.risk }}</span><span>{{ item.reviewed_count }}/{{ item.required_count }} revisiones</span></div>
<h3><a href="{{ item.permalink }}">{{ item.title }}</a></h3>
<p>{{ item.description }}</p>
{% if item.configuration_error %}<p class="ops-error">{{ item.configuration_error }}</p>{% endif %}
<div class="ops-review-grid">
<div><strong>Revisaron</strong>{% for admin in item.reviewed_by %}<span class="reviewer reviewer--done">✓ {{ admin }}</span>{% endfor %}{% if item.reviewed_by | length == 0 %}<span class="reviewer">Nadie aún</span>{% endif %}</div>
<div><strong>Faltan</strong>{% for admin in item.missing_reviewers %}<span class="reviewer reviewer--missing">○ {{ admin }}</span>{% endfor %}{% if item.missing_reviewers | length == 0 %}<span class="reviewer reviewer--done">Completa</span>{% endif %}</div>
</div>
<pre><code>clusterlog --json --commit review mark {{ item.entry_id }}</code></pre>
</article>
{% endfor %}
{% if ops.review_queue | length == 0 %}<div class="ops-empty ops-empty--success"><strong>Cola limpia.</strong><br>No hay memorias pendientes para la versión actual de su contenido.</div>{% endif %}
</div>
</section>
<section class="ops-meeting-section" aria-labelledby="meeting-tasks-title">
<h2 id="meeting-tasks-title">Tareas abiertas para revisar</h2>
<div class="ops-list">
{% for task in ops.tasks %}
{% if task.status != "done" and task.status != "cancelled" %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ task.task_id }}</code><span class="badge">{{ task.status }}</span><span>{{ task.actual_minutes }}/{{ task.estimate_minutes }} min</span></div>
<h3><a href="{{ task.permalink }}">{{ task.title }}</a></h3>
<p>{{ task.description }}</p>
{% if task.running_by | length > 0 %}<p><strong>En ejecución:</strong> {{ task.running_by | join(sep=", ") }}</p>{% endif %}
</article>
{% endif %}
{% endfor %}
</div>
</section>
<section class="ops-meeting-section" aria-labelledby="meeting-projects-title">
<h2 id="meeting-projects-title">Proyectos activos o bloqueados</h2>
<div class="ops-list">
{% for project in ops.projects %}
{% if project.status == "active" or project.status == "blocked" or project.status == "planned" %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ project.project_id }}</code><span class="badge">{{ project.status }}</span>{% if project.target_date %}<span>Meta {{ project.target_date }}</span>{% endif %}</div>
<h3><a href="{{ project.permalink }}">{{ project.title }}</a></h3>
<p>{{ project.description }}</p>
</article>
{% endif %}
{% endfor %}
</div>
</section>
{% endblock content %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header">
<p class="ops-eyebrow">BIBLIOTECA TÉCNICA</p>
<h1>{{ section.title }}</h1>
<p>{{ section.description }}</p>
<div class="ops-prose">{{ section.content | safe }}</div>
</header>
<div class="ops-list">
{% for page in section.pages %}
<article class="ops-list-item">
<div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span>{{ page.date }}</span></div>
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
<p>{{ page.description }}</p>
<div class="ops-tags">{% for tag in page.taxonomies.tags %}<span>#{{ tag }}</span>{% endfor %}</div>
</article>
{% endfor %}
{% if section.pages | length == 0 %}<p class="ops-empty">No hay páginas en esta sección.</p>{% endif %}
</div>
{% endblock content %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ page.description }}{% endblock description %}
{% block content %}
<article class="ops-article">
<header class="ops-page-header"><p class="ops-eyebrow"><code>{{ page.extra.id }}</code> / TAREA</p><h1>{{ page.title }}</h1><p>{{ page.description }}</p>
<div class="ops-meta"><span>Estado base: {{ page.extra.status }}</span><span>Prioridad: {{ page.extra.priority }}</span><span>Estimación: {{ page.extra.estimate_minutes }} min</span>{% if page.extra.due %}<span>Fecha: {{ page.extra.due }}</span>{% endif %}{% if page.extra.project_id %}<span>Proyecto: {{ page.extra.project_id }}</span>{% endif %}</div></header>
<aside class="ops-callout">El estado efectivo y el tiempo acumulado se muestran en el <a href="{{ get_url(path='@/tareas/_index.md') }}">tablero de tareas</a>; se calculan desde el registro de eventos.</aside>
<div class="ops-prose">{{ page.content | safe }}</div>
<pre><code>clusterlog --commit task start {{ page.extra.id }}
clusterlog --commit task stop {{ page.extra.id }}
clusterlog --commit task done {{ page.extra.id }}</code></pre>
</article>
{% endblock content %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} · {{ config.title }}{% endblock title %}
{% block description %}{{ section.description }}{% endblock description %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">TRABAJO COMPARTIDO</p><h1>{{ section.title }}</h1><p>{{ section.description }}</p><div class="ops-prose">{{ section.content | safe }}</div></header>
<div class="ops-board">
{% for task in ops.tasks %}
<article class="ops-card">
<div class="ops-card__top"><code>{{ task.task_id }}</code><span class="badge badge--status">{{ task.status }}</span></div>
<h2><a href="{{ task.permalink }}">{{ task.title }}</a></h2><p>{{ task.description }}</p>
<dl><dt>Prioridad</dt><dd>{{ task.priority }}</dd><dt>Estimado</dt><dd>{{ task.estimate_minutes }} min</dd><dt>Real</dt><dd>{{ task.actual_minutes }} min</dd>{% if task.due %}<dt>Fecha</dt><dd>{{ task.due }}</dd>{% endif %}{% if task.project_id %}<dt>Proyecto</dt><dd>{{ task.project_id }}</dd>{% endif %}</dl>
{% if task.running_by | length > 0 %}<p class="ops-running">▶ En curso por {% for admin in task.running_by %}{{ admin }}{% if not loop.last %}, {% endif %}{% endfor %}</p>{% endif %}
<div class="ops-tags">{% for admin in task.assignees %}<span>+{{ admin }}</span>{% endfor %}</div>
</article>
{% endfor %}
{% if ops.tasks | length == 0 %}<p class="ops-empty">No hay tareas.</p>{% endif %}
</div>
{% endblock content %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}{{ taxonomy.name }} · {{ config.title }}{% endblock title %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">ÍNDICE</p><h1>{{ taxonomy.name }}</h1></header>
<div class="ops-grid">
{% for term in terms %}<a class="ops-panel" href="{{ term.permalink }}"><h2>{{ term.name }}</h2><p>{{ term.page_count }} páginas</p></a>{% endfor %}
</div>
{% endblock content %}
+6
View File
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ term.name }} · {{ config.title }}{% endblock title %}
{% block content %}
<header class="ops-page-header"><p class="ops-eyebrow">{{ taxonomy.name }}</p><h1>{{ term.name }}</h1><p>{{ term.page_count }} páginas</p></header>
<div class="ops-list">{% for page in term.pages %}<article class="ops-list-item"><div class="ops-list-item__meta"><code>{{ page.extra.id }}</code><span>{{ page.date }}</span></div><h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2><p>{{ page.description }}</p></article>{% endfor %}</div>
{% endblock content %}
View File
+53
View File
@@ -0,0 +1,53 @@
base_url = "https://bitacora-cluster.example.invalid"
title = "Bitácora Operativa del Clúster"
description = "Documentación, manuales, memorias de cambio, proyectos y tareas del equipo de soporte."
default_language = "es"
theme = "zola.386"
output_dir = "public"
compile_sass = true
minify_html = true
generate_feeds = true
feed_filenames = ["atom.xml", "rss.xml"]
build_search_index = true
generate_sitemap = true
generate_robots_txt = false
taxonomies = [
{ name = "categories" },
{ name = "tags", feed = true },
{ name = "systems" },
]
[markdown]
external_links_target_blank = true
external_links_no_referrer = true
smart_punctuation = true
github_alerts = true
insert_anchor_links = "right"
[markdown.highlighting]
error_on_missing_language = false
style = "inline"
theme = "nord"
[search]
include_title = true
include_description = true
include_date = true
include_path = true
include_content = true
truncate_content_length = 12000
index_format = "elasticlunr_json"
[slugify]
paths = "on"
taxonomies = "on"
anchors = "on"
paths_keep_dates = false
[extra]
product_name = "Bitácora Operativa"
product_short_name = "clusterlog"
repository_label = "Repositorio interno"
review_meeting_label = "Revisión semanal"
footer_text = "Fuente auditable: Markdown + eventos JSON + Git"