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
+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
}