diff --git a/okf-hub/.gitignore b/okf-hub/.gitignore new file mode 100644 index 0000000..88c55a6 --- /dev/null +++ b/okf-hub/.gitignore @@ -0,0 +1,4 @@ +# Artefactos gerados (regeneráveis pelos scripts) — não versionar +okf-normalize-report.md +okf-rename-index.log +hub-okf-graph.dot diff --git a/okf-hub/install-hooks.sh b/okf-hub/install-hooks.sh new file mode 100755 index 0000000..c42343d --- /dev/null +++ b/okf-hub/install-hooks.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# install-hooks.sh — Instala os git hooks OKF no Hub vault +# Uso: bash scripts/install-hooks.sh [--uninstall] +# +# Criado: 28-06-2026 + +VAULT="/media/ealmeida/Dados/Hub" +SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" +GIT_DIR=$(git -C "$VAULT" rev-parse --git-dir 2>/dev/null) + +if [[ -z "$GIT_DIR" ]]; then + echo "ERRO: $VAULT não é um repositório git" + exit 1 +fi + +# Resolver path absoluto do .git +if [[ "$GIT_DIR" == ".git" ]]; then + GIT_ABSOLUTE="$VAULT/.git" +else + GIT_ABSOLUTE="$GIT_DIR" +fi + +HOOKS_DIR="$GIT_ABSOLUTE/hooks" +HOOK_FILE="$HOOKS_DIR/pre-commit" +HOOK_SOURCE="$SCRIPTS_DIR/okf-validate.sh" + +# ─── Desinstalar ────────────────────────────────────────────────────────────── + +if [[ "${1:-}" == "--uninstall" ]]; then + if [[ -L "$HOOK_FILE" ]]; then + rm "$HOOK_FILE" + echo "[OK] Hook removido: $HOOK_FILE" + elif [[ -f "$HOOK_FILE" ]]; then + echo "[AVISO] $HOOK_FILE não é um symlink — remover manualmente se necessário" + else + echo "[INFO] Nenhum hook instalado" + fi + exit 0 +fi + +# ─── Instalar ───────────────────────────────────────────────────────────────── + +echo "=== Instalar OKF pre-commit hook ===" +echo "Vault: $VAULT" +echo "Git dir: $GIT_ABSOLUTE" +echo "Hook: $HOOK_FILE" +echo "Source: $HOOK_SOURCE" +echo "" + +# Verificar que o script de validação existe +if [[ ! -f "$HOOK_SOURCE" ]]; then + echo "ERRO: Script não encontrado — $HOOK_SOURCE" + exit 1 +fi + +# Tornar executável +chmod +x "$HOOK_SOURCE" + +# Criar diretório hooks se não existir +mkdir -p "$HOOKS_DIR" + +# Backup do hook existente (se não for nosso symlink) +if [[ -f "$HOOK_FILE" ]] && [[ ! -L "$HOOK_FILE" ]]; then + BACKUP="$HOOK_FILE.backup.$(date +%Y%m%d)" + mv "$HOOK_FILE" "$BACKUP" + echo "[INFO] Hook existente guardado em: $BACKUP" +fi + +# Remover symlink antigo se existir +[[ -L "$HOOK_FILE" ]] && rm "$HOOK_FILE" + +# Criar symlink +ln -s "$HOOK_SOURCE" "$HOOK_FILE" +chmod +x "$HOOK_FILE" + +echo "[OK] Hook instalado: $HOOK_FILE → $HOOK_SOURCE" +echo "" +echo "Testar: git -C $VAULT commit --dry-run -m 'test'" +echo "Validar tudo: bash $HOOK_SOURCE --all" +echo "Desinstalar: bash $SCRIPTS_DIR/install-hooks.sh --uninstall" diff --git a/okf-hub/okf-convert-wikilinks.py b/okf-hub/okf-convert-wikilinks.py new file mode 100644 index 0000000..4fb0464 --- /dev/null +++ b/okf-hub/okf-convert-wikilinks.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +okf-convert-wikilinks.py — Fase 3: Converte [[wikilinks]] → [texto](path.md) nos index.md +OKF §5: links bundle-relative para navegação entre conceitos + +Âmbito: apenas ficheiros index.md (navegação) +Corpo de documentos (PROC, QR, etc.) mantém wikilinks — OKF tolera e Obsidian renderiza ambos. + +Uso: + python3 okf-convert-wikilinks.py [--dry-run] [--dir /path/to/Hub] + +Criado: 28-06-2026 +""" + +import os +import re +import sys +from pathlib import Path + +HUB_DEFAULT = "/media/ealmeida/Dados/Hub" + +EXCLUDE_DIRS = {".stversions", "node_modules", ".git", ".obsidian", ".trash"} + +# Padrão wikilink: [[NomeFicheiro]] ou [[NomeFicheiro|Alias]] +WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]') + + +def build_file_index(hub: Path) -> dict: + """Constrói índice nome→path para resolução de wikilinks.""" + index = {} # stem → Path relativo ao hub + for root, dirs, files in os.walk(hub): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")] + for fname in files: + if fname.endswith(".md"): + fp = Path(root) / fname + stem = fp.stem.lower() + rel = fp.relative_to(hub) + # Guardar o primeiro match (mais provável no vault activo) + if stem not in index: + index[stem] = rel + # Também indexar o nome completo sem extensão + full_name = fname.lower() + if full_name not in index: + index[full_name] = rel + return index + + +def resolve_wikilink(target: str, current_file: Path, file_index: dict, hub: Path) -> str: + """Resolve [[target]] para um caminho relativo ao ficheiro actual.""" + # Limpar o target (remover ^anchor, #heading, etc.) + target_clean = re.split(r'[#^]', target)[0].strip() + target_lower = target_clean.lower() + target_with_ext = target_lower + ".md" if not target_lower.endswith(".md") else target_lower + + # Tentar resolver + resolved = file_index.get(target_with_ext) or file_index.get(target_lower) + + if resolved: + # Calcular path relativo a partir do directório do ficheiro actual + try: + rel_path = os.path.relpath(hub / resolved, current_file.parent) + return rel_path.replace("\\", "/") + except Exception: + return str(resolved) + return None + + +def convert_wikilinks_in_file(filepath: Path, file_index: dict, hub: Path, dry_run: bool) -> dict: + """Converte wikilinks no ficheiro. Retorna estatísticas.""" + result = {"file": str(filepath.relative_to(hub)), "converted": 0, "unresolved": [], "action": "skip"} + + try: + content = filepath.read_text(encoding="utf-8") + except Exception as e: + result["action"] = "error" + result["error"] = str(e) + return result + + if "[[" not in content: + result["action"] = "no_wikilinks" + return result + + def replace_wikilink(m): + target = m.group(1) + alias = m.group(2) + display = alias if alias else target + + resolved_path = resolve_wikilink(target, filepath, file_index, hub) + if resolved_path: + result["converted"] += 1 + return f"[{display}]({resolved_path})" + else: + # Manter como wikilink se não resolvível + result["unresolved"].append(target) + return m.group(0) + + new_content = WIKILINK_RE.sub(replace_wikilink, content) + + if new_content != content: + result["action"] = "converted" + if not dry_run: + filepath.write_text(new_content, encoding="utf-8") + else: + result["action"] = "no_changes" + + return result + + +def main(): + dry_run = "--dry-run" in sys.argv + hub = Path(HUB_DEFAULT) + for arg in sys.argv[1:]: + if arg.startswith("--dir="): + hub = Path(arg[6:]) + + if not hub.exists(): + print(f"ERRO: Hub não encontrado em {hub}", file=sys.stderr) + sys.exit(1) + + print(f"{'[DRY-RUN] ' if dry_run else ''}A construir índice de ficheiros…") + file_index = build_file_index(hub) + print(f" {len(file_index)} ficheiros indexados") + + print(f"A converter wikilinks nos index.md…") + total_converted = 0 + total_unresolved = [] + files_changed = 0 + + for root, dirs, files in os.walk(hub): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")] + for fname in files: + if fname != "index.md": + continue + filepath = Path(root) / fname + result = convert_wikilinks_in_file(filepath, file_index, hub, dry_run) + + if result["action"] == "converted": + files_changed += 1 + total_converted += result["converted"] + total_unresolved.extend(result["unresolved"]) + print(f" [OK] {result['file']}: {result['converted']} convertidos" + + (f", {len(result['unresolved'])} não resolvidos" if result["unresolved"] else "")) + elif result["action"] == "error": + print(f" [ERRO] {result['file']}: {result.get('error')}") + + print(f"\n=== Resultado ===") + print(f"Ficheiros alterados: {files_changed}") + print(f"Wikilinks convertidos: {total_converted}") + if total_unresolved: + print(f"Não resolvidos ({len(total_unresolved)}): {', '.join(set(total_unresolved))[:200]}") + + +if __name__ == "__main__": + main() diff --git a/okf-hub/okf-gen-graph.sh b/okf-hub/okf-gen-graph.sh new file mode 100755 index 0000000..69f7649 --- /dev/null +++ b/okf-hub/okf-gen-graph.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# okf-gen-graph.sh — Gera grafo OKF do Hub para integração com Wayland/visualização +# +# Uso: +# bash scripts/okf-gen-graph.sh → gera hub-okf-graph.dot +# bash scripts/okf-gen-graph.sh --svg → gera também hub-okf-graph.svg (requer graphviz) +# bash scripts/okf-gen-graph.sh --info → mostra inventário do bundle +# +# Requer: okf CLI (cargo install --git https://github.com/W4G1/okf) +# +# Criado: 28-06-2026 + +set -euo pipefail + +VAULT="/media/ealmeida/Dados/Hub" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DOT="$SCRIPT_DIR/hub-okf-graph.dot" + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[AVISO]${NC} $*"; } + +if ! command -v okf &>/dev/null; then + echo -e "${RED}[ERRO]${NC} okf CLI não encontrado." + echo " Instalar: cargo install --git https://github.com/W4G1/okf" + echo " Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + exit 1 +fi + +# Inventário do bundle +if [[ "${1:-}" == "--info" ]]; then + info "=== OKF Bundle Inventory ===" + okf info "$VAULT" 2>/dev/null + exit 0 +fi + +# Gerar grafo DOT +info "A gerar grafo OKF do Hub..." +okf graph "$VAULT" --dot 2>/dev/null > "$OUTPUT_DOT" +NODE_COUNT=$(grep -c "^ " "$OUTPUT_DOT" 2>/dev/null || echo "?") +ok "Grafo gerado: $OUTPUT_DOT ($NODE_COUNT nós/arestas)" + +# Gerar SVG se graphviz disponível e --svg pedido +if [[ "${1:-}" == "--svg" ]]; then + OUTPUT_SVG="${OUTPUT_DOT%.dot}.svg" + if command -v dot &>/dev/null; then + info "A gerar SVG via graphviz..." + dot -Tsvg "$OUTPUT_DOT" -o "$OUTPUT_SVG" 2>/dev/null + ok "SVG gerado: $OUTPUT_SVG" + info "Abrir com: xdg-open $OUTPUT_SVG" + else + warn "graphviz não instalado — só o DOT foi gerado" + warn "Instalar: sudo apt install graphviz" + warn "Ou visualizar online: https://dreampuf.github.io/GraphvizOnline/" + fi +fi + +info "Para Wayland F6: usar $OUTPUT_DOT como input de importação da estrutura Hub" diff --git a/okf-hub/okf-gen-logs.sh b/okf-hub/okf-gen-logs.sh new file mode 100755 index 0000000..74e22b7 --- /dev/null +++ b/okf-hub/okf-gen-logs.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# okf-gen-logs.sh — Fase 4: Gera log.md por directório top-level +# OKF §7: log.md com histórico de alterações por data ISO, newest first +# Criado: 28-06-2026 + +HUB="/media/ealmeida/Dados/Hub" +DAYS=90 # Últimos N dias de histórico +DRY_RUN=false + +if [[ "$1" == "--dry-run" ]]; then + DRY_RUN=true + echo "[DRY-RUN] Nenhum ficheiro será criado." +fi + +# Directórios top-level a processar (excluir .stversions, node_modules, tmp) +TOP_DIRS=( + "00-Inbox" + "03-Propostas" + "04-Stack" + "05-Projectos" + "06-Operacoes" + "07-Clientes" + "90-Templates" + "99-Arquivo" +) + +SINCE=$(date -d "-${DAYS} days" +%Y-%m-%d) + +generate_log() { + local dir="$1" + local dir_path="$HUB/$dir" + local log_path="$dir_path/log.md" + + if [[ ! -d "$dir_path" ]]; then + echo "[SKIP] $dir não existe" + return + fi + + echo "A gerar log.md para $dir (últimos ${DAYS} dias desde ${SINCE})…" + + # Obter commits que tocaram nesta pasta + local git_log + git_log=$(git -C "$HUB" log \ + --since="$SINCE" \ + --format="%cd|%s" \ + --date=format:"%Y-%m-%d" \ + -- "$dir/" 2>/dev/null) + + if [[ -z "$git_log" ]]; then + echo " [INFO] Sem commits no período para $dir" + git_log="" + fi + + # Agrupar por data e gerar markdown + local log_content + log_content="# Log de Actualizações — $dir + +$(echo "$git_log" | awk -F'|' ' +{ + date=$1; msg=$2 + if (date != prev_date) { + if (prev_date != "") print "" + print "## " date + prev_date=date + } + # Classificar entrada + if (msg ~ /^(feat|add|create|novo|cria)/) prefix="**Creation**" + else if (msg ~ /^(fix|corr|resolv)/) prefix="**Fix**" + else if (msg ~ /^(archive|arquiv)/) prefix="**Archive**" + else if (msg ~ /^(delete|remov|apag)/) prefix="**Deletion**" + else prefix="**Update**" + print "* " prefix ": " msg +}' 2>/dev/null || echo "_(sem histórico git no período)_") +" + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY] $log_path" + echo " Primeiras linhas: $(echo "$log_content" | head -5)" + else + echo "$log_content" > "$log_path" + echo " [OK] $log_path" + fi +} + +echo "=== okf-gen-logs.sh — $(date -I) ===" +echo "Hub: $HUB" +echo "" + +for dir in "${TOP_DIRS[@]}"; do + generate_log "$dir" +done + +echo "" +echo "=== Concluído ===" +echo "log.md gerado em ${#TOP_DIRS[@]} directórios." diff --git a/okf-hub/okf-normalize.py b/okf-hub/okf-normalize.py new file mode 100644 index 0000000..a7d8850 --- /dev/null +++ b/okf-hub/okf-normalize.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +okf-normalize.py — Fase 1: Normaliza frontmatter OKF em todos os .md do Hub +Adiciona/completa: type, title, description, timestamp +OKF SPEC §4.1: type é o único campo obrigatório + +Uso: + python3 okf-normalize.py [--dry-run] [--dir /path/to/Hub] + +Criado: 28-06-2026 +""" + +import os +import re +import sys +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +HUB_DEFAULT = "/media/ealmeida/Dados/Hub" + +# Directórios excluídos do scan +EXCLUDE_DIRS = { + ".stversions", + "node_modules", + ".git", + ".obsidian", + ".trash", + "99-Arquivo", +} + +# Ficheiros reservados OKF — sem frontmatter obrigatório +OKF_RESERVED = {"index.md", "log.md"} + +# Taxonomia Hub → OKF type +def infer_type(filepath: Path) -> str: + name = filepath.name + parts = str(filepath).lower() + + if name.startswith("PROC-") or name.startswith("proc-"): + return "Playbook" + if name.startswith("QR-") or name.startswith("qr-"): + return "Reference" + if name.lower() in ("index.md", "index.md"): + return "Index" + if name.endswith("-SPEC.md") or name == "SPEC.md": + return "Specification" + if name.startswith("STATUS"): + return "Status" + if name.upper().startswith("CHANGELOG"): + return "Changelog" + if name.upper().startswith("README"): + return "Reference" + if "proposta" in parts or "orcamento" in parts or "budget" in parts: + return "Proposal" + if "90-templates" in parts or "/template" in parts: + return "Template" + if "07-clientes" in parts: + return "Client Profile" + return "Document" + + +def get_git_timestamp(filepath: Path, hub: Path) -> str: + """Obter timestamp da última modificação via git log.""" + try: + rel = filepath.relative_to(hub) + result = subprocess.run( + ["git", "log", "-1", "--format=%cI", "--", str(rel)], + cwd=str(hub), + capture_output=True, + text=True, + timeout=5, + ) + ts = result.stdout.strip() + if ts: + return ts + except Exception: + pass + # fallback: mtime do ficheiro + mtime = filepath.stat().st_mtime + return datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() + + +def parse_frontmatter(content: str): + """Retorna (frontmatter_str, body_str, has_fm) ou (None, content, False).""" + if content.startswith("---\n"): + end = content.find("\n---\n", 4) + if end != -1: + fm = content[4:end] + body = content[end + 5:] + return fm, body, True + return None, content, False + + +def first_useful_sentence(body: str) -> str: + """Extrai primeira frase útil do body para description.""" + # Remover headings, listas, blocos de código + lines = body.split("\n") + for line in lines: + line = line.strip() + if not line: + continue + if line.startswith("#"): + continue + if line.startswith("```"): + continue + if line.startswith("|"): + continue + if line.startswith("-") or line.startswith("*"): + # Lista: usar conteúdo sem bullet + line = re.sub(r"^[-*]\s+", "", line) + # Limpar markdown inline + line = re.sub(r"\*\*(.+?)\*\*", r"\1", line) + line = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", line) + line = line.strip() + if len(line) > 10: + # Truncar em 120 chars + return line[:120].rstrip(".") + ("…" if len(line) > 120 else "") + return "" + + +def normalize_file(filepath: Path, hub: Path, dry_run: bool) -> dict: + """Normaliza um ficheiro. Retorna dict com acção tomada.""" + result = {"file": str(filepath.relative_to(hub)), "action": "skip", "changes": []} + + try: + content = filepath.read_text(encoding="utf-8") + except Exception as e: + result["action"] = "error" + result["error"] = str(e) + return result + + fm_str, body, has_fm = parse_frontmatter(content) + + if not has_fm: + # Injetar frontmatter mínimo + inferred_type = infer_type(filepath) + title = filepath.stem.replace("-", " ").replace("_", " ").title() + description = first_useful_sentence(body) + timestamp = get_git_timestamp(filepath, hub) + + new_fm_lines = [f"type: {inferred_type}", f"title: {title}"] + if description: + new_fm_lines.append(f"description: >-\n {description}") + new_fm_lines.append(f"timestamp: {timestamp}") + + new_content = "---\n" + "\n".join(new_fm_lines) + "\n---\n" + content + result["action"] = "add_frontmatter" + result["changes"] = new_fm_lines + else: + # Ficheiro já tem frontmatter — completar campos em falta + fm_lines = fm_str.split("\n") + changes = [] + + has_type = any(line.startswith("type:") for line in fm_lines) + has_title = any(line.startswith("title:") for line in fm_lines) + has_description = any(line.startswith("description:") for line in fm_lines) + has_timestamp = any( + line.startswith("timestamp:") or line.startswith("date:") + for line in fm_lines + ) + + if not has_type: + inferred_type = infer_type(filepath) + fm_lines.insert(0, f"type: {inferred_type}") + changes.append(f"+ type: {inferred_type}") + + if not has_title: + title = filepath.stem.replace("-", " ").replace("_", " ").title() + # Inserir após type + type_idx = next( + (i for i, l in enumerate(fm_lines) if l.startswith("type:")), 0 + ) + fm_lines.insert(type_idx + 1, f"title: {title}") + changes.append(f"+ title: {title}") + + if not has_description: + desc = first_useful_sentence(body) + if desc: + desc_entry = f"description: >-\n {desc}" + title_idx = next( + (i for i, l in enumerate(fm_lines) if l.startswith("title:")), 1 + ) + fm_lines.insert(title_idx + 1, desc_entry) + changes.append(f"+ description: {desc[:60]}…") + + if not has_timestamp: + ts = get_git_timestamp(filepath, hub) + fm_lines.append(f"timestamp: {ts}") + changes.append(f"+ timestamp: {ts}") + + if not changes: + result["action"] = "already_ok" + return result + + new_fm = "\n".join(fm_lines) + new_content = "---\n" + new_fm + "\n---\n" + body + result["action"] = "update_frontmatter" + result["changes"] = changes + + if not dry_run: + try: + filepath.write_text(new_content, encoding="utf-8") + except Exception as e: + result["action"] = "error" + result["error"] = str(e) + + return result + + +def scan_hub(hub: Path, dry_run: bool): + """Scan recursivo do vault Hub.""" + stats = {"add": 0, "update": 0, "ok": 0, "skip": 0, "error": 0} + report_lines = [ + f"# okf-normalize — {'DRY-RUN' if dry_run else 'EXECUÇÃO'} — {datetime.now().isoformat()[:16]}", + f"Hub: {hub}", + "", + ] + + for root, dirs, files in os.walk(hub): + root_path = Path(root) + + # Excluir directórios + dirs[:] = [ + d for d in dirs + if d not in EXCLUDE_DIRS and not d.startswith(".") + ] + + for fname in files: + if not fname.endswith(".md"): + continue + if fname.lower() in OKF_RESERVED: + continue + + filepath = root_path / fname + result = normalize_file(filepath, hub, dry_run) + + action = result["action"] + if action == "add_frontmatter": + stats["add"] += 1 + report_lines.append(f"[ADD] {result['file']}") + for c in result["changes"]: + report_lines.append(f" {c}") + elif action == "update_frontmatter": + stats["update"] += 1 + report_lines.append(f"[UPD] {result['file']}") + for c in result["changes"]: + report_lines.append(f" {c}") + elif action == "already_ok": + stats["ok"] += 1 + elif action == "error": + stats["error"] += 1 + report_lines.append(f"[ERR] {result['file']}: {result.get('error')}") + else: + stats["skip"] += 1 + + report_lines += [ + "", + "## Resultado", + f"- Frontmatter adicionado: {stats['add']}", + f"- Frontmatter actualizado: {stats['update']}", + f"- Já conformes: {stats['ok']}", + f"- Erros: {stats['error']}", + f"- Ignorados: {stats['skip']}", + ] + return stats, "\n".join(report_lines) + + +def main(): + dry_run = "--dry-run" in sys.argv + hub = Path(HUB_DEFAULT) + for arg in sys.argv[1:]: + if arg.startswith("--dir="): + hub = Path(arg[6:]) + + if not hub.exists(): + print(f"ERRO: Hub não encontrado em {hub}", file=sys.stderr) + sys.exit(1) + + print(f"{'[DRY-RUN] ' if dry_run else ''}A normalizar OKF em {hub}…") + stats, report = scan_hub(hub, dry_run) + + report_path = hub / "04-Stack/02.04-Sistemas/MemoriaCentral/scripts/okf-normalize-report.md" + report_path.write_text(report, encoding="utf-8") + + print(report_path.read_text(encoding="utf-8").split("## Resultado")[1].strip()) + print(f"\nRelatório completo: {report_path}") + + +if __name__ == "__main__": + main() diff --git a/okf-hub/okf-rename-index.sh b/okf-hub/okf-rename-index.sh new file mode 100755 index 0000000..9484afd --- /dev/null +++ b/okf-hub/okf-rename-index.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# okf-rename-index.sh — Fase 2: Renomeia INDEX.md → index.md no vault Hub +# OKF §6: index.md é ficheiro reservado (lowercase) +# Criado: 28-06-2026 + +HUB="/media/ealmeida/Dados/Hub" +DRY_RUN=false +LOG_FILE="$(dirname "$0")/okf-rename-index.log" + +# Modo dry-run com --dry-run +if [[ "$1" == "--dry-run" ]]; then + DRY_RUN=true + echo "[DRY-RUN] Nenhum ficheiro será alterado." +fi + +echo "=== okf-rename-index.sh — $(date -I) ===" | tee "$LOG_FILE" +echo "Hub: $HUB" | tee -a "$LOG_FILE" +echo "" | tee -a "$LOG_FILE" + +COUNT=0 +ERRORS=0 + +# Encontrar todos os INDEX.md excluindo .stversions e node_modules +while IFS= read -r -d '' INDEX_FILE; do + DIR=$(dirname "$INDEX_FILE") + TARGET="$DIR/index.md" + + # Verificar se já existe index.md (colisão) + if [[ -f "$TARGET" ]]; then + echo "[SKIP] Colisão: $TARGET já existe — manter INDEX.md" | tee -a "$LOG_FILE" + ((ERRORS++)) + continue + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo "[DRY] $INDEX_FILE → $TARGET" | tee -a "$LOG_FILE" + else + # Usar git mv para preservar histórico + if git -C "$HUB" mv "${INDEX_FILE#$HUB/}" "${TARGET#$HUB/}" 2>>"$LOG_FILE"; then + echo "[OK] $INDEX_FILE → $TARGET" | tee -a "$LOG_FILE" + else + echo "[ERRO] Falha: $INDEX_FILE" | tee -a "$LOG_FILE" + ((ERRORS++)) + continue + fi + fi + ((COUNT++)) + +done < <(find "$HUB" -name "INDEX.md" \ + -not -path "*/.stversions/*" \ + -not -path "*/node_modules/*" \ + -not -path "*/99-Arquivo/*" \ + -print0) + +# Incluir 99-Arquivo separadamente (sem git mv — só rename simples) +while IFS= read -r -d '' INDEX_FILE; do + DIR=$(dirname "$INDEX_FILE") + TARGET="$DIR/index.md" + + if [[ -f "$TARGET" ]]; then + echo "[SKIP] Colisão: $TARGET já existe" | tee -a "$LOG_FILE" + ((ERRORS++)) + continue + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo "[DRY-ARQUIVO] $INDEX_FILE → $TARGET" | tee -a "$LOG_FILE" + else + if mv "$INDEX_FILE" "$TARGET" 2>>"$LOG_FILE"; then + echo "[OK-ARQUIVO] $INDEX_FILE → $TARGET" | tee -a "$LOG_FILE" + else + echo "[ERRO-ARQUIVO] $INDEX_FILE" | tee -a "$LOG_FILE" + ((ERRORS++)) + continue + fi + fi + ((COUNT++)) + +done < <(find "$HUB/99-Arquivo" -name "INDEX.md" \ + -not -path "*/.stversions/*" \ + -print0) + +echo "" | tee -a "$LOG_FILE" +echo "=== Resultado ===" | tee -a "$LOG_FILE" +echo "Renomeados: $COUNT" | tee -a "$LOG_FILE" +echo "Erros/Colisoes: $ERRORS" | tee -a "$LOG_FILE" +echo "" | tee -a "$LOG_FILE" + +if [[ "$DRY_RUN" == "false" && $COUNT -gt 0 ]]; then + echo "=== Actualizar referencias internas ===" | tee -a "$LOG_FILE" + # Substituir [INDEX.md] e (INDEX.md) por index.md nas referencias + grep -rl "INDEX\.md" "$HUB" \ + --include="*.md" \ + --exclude-dir=".stversions" \ + --exclude-dir="node_modules" | while read -r FILE; do + sed -i 's/\bINDEX\.md\b/index.md/g' "$FILE" + echo "[REF] $FILE" >> "$LOG_FILE" + done + echo "Referencias actualizadas — ver log para detalhes." | tee -a "$LOG_FILE" +fi + +echo "" | tee -a "$LOG_FILE" +echo "Log: $LOG_FILE" diff --git a/okf-hub/okf-validate.sh b/okf-hub/okf-validate.sh new file mode 100755 index 0000000..b9582eb --- /dev/null +++ b/okf-hub/okf-validate.sh @@ -0,0 +1,352 @@ +#!/bin/bash +# okf-validate.sh — Validação OKF pre-commit para o Hub Obsidian +# +# Instalar: bash scripts/install-hooks.sh +# Executar manualmente: bash scripts/okf-validate.sh [--all] [--warn-only] +# +# Comportamento: +# Sem args → valida apenas ficheiros staged (para pre-commit) +# --all → valida todos os ficheiros activos do vault +# --warn-only → não bloqueia o commit (só avisos) +# +# Criado: 28-06-2026 + +set -euo pipefail + +VAULT="/media/ealmeida/Dados/Hub" +ERRORS=0 +WARNINGS=0 +WARN_ONLY=false +ALL_FILES=false + +# Parsing de argumentos +for arg in "$@"; do + case "$arg" in + --warn-only) WARN_ONLY=true ;; + --all) ALL_FILES=true ;; + esac +done + +# Cores para output +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +err() { echo -e "${RED}[ERRO]${NC} $*" >&2; ERRORS=$((ERRORS+1)); } +warn() { echo -e "${YELLOW}[AVISO]${NC} $*" >&2; WARNINGS=$((WARNINGS+1)); } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +info() { echo -e "${BLUE}[INFO]${NC} $*"; } + +# ─── Determinar ficheiros a validar ─────────────────────────────────────────── + +get_files() { + if [[ "$ALL_FILES" == "true" ]]; then + # Todos os .md activos (excluir arquivo, stversions, gitignore) + find "$VAULT" -name "*.md" \ + -not -path "*/99-Arquivo/*" \ + -not -path "*/.stversions/*" \ + -not -path "*/node_modules/*" \ + -not -path "*/.git/*" \ + -not -path "*/.obsidian/*" \ + -not -path "*/.ijfw/*" \ + -not -path "*/\.trash/*" \ + 2>/dev/null + else + # Só ficheiros staged (modo pre-commit) + git -C "$VAULT" diff --cached --name-only --diff-filter=ACM 2>/dev/null \ + | grep "\.md$" \ + | while IFS= read -r f; do echo "$VAULT/$f"; done + fi +} + +# ─── Regras de exclusão ─────────────────────────────────────────────────────── + +should_skip() { + local file="$1" + local basename + basename=$(basename "$file") + local filepath_lower + filepath_lower=$(echo "$file" | tr '[:upper:]' '[:lower:]') + + # OKF reserved — sem frontmatter obrigatório + [[ "$basename" == "index.md" ]] && return 0 + [[ "$basename" == "log.md" ]] && return 0 + + # Ficheiros de sistema/config — não são documentos OKF + [[ "$basename" == "CLAUDE.md" ]] && return 0 + [[ "$basename" == "AGENTS.md" ]] && return 0 + [[ "$basename" == "README.md" ]] && return 0 + [[ "$basename" == "README.txt" ]] && return 0 + [[ "$basename" == ".desk-project" ]] && return 0 + + # Paths internos de ferramentas e config + [[ "$filepath_lower" == *"/.wayland/"* ]] && return 0 + [[ "$filepath_lower" == *"/.hermes/"* ]] && return 0 + [[ "$filepath_lower" == *"/ijfw/"* ]] && return 0 + [[ "$filepath_lower" == *"/.github/"* ]] && return 0 + [[ "$filepath_lower" == *"/\.obsidian/"* ]] && return 0 + + # Relatórios de scripts (gerados automaticamente) + [[ "$basename" == "okf-normalize-report.md" ]] && return 0 + [[ "$basename" == "project-journal.md" ]] && return 0 + + return 1 +} + +# ─── Verificações ───────────────────────────────────────────────────────────── + +check_type_field() { + local file="$1" + if ! grep -q "^type:" "$file" 2>/dev/null; then + err "Sem 'type:' — $file" + return + fi + # Verificar valor válido + local type_val + type_val=$(grep "^type:" "$file" | head -1 | sed 's/^type: *//' | tr -d '"'"'" | xargs) + # Tipos OKF canónicos + local canonical_types=( + "Document" "Index" "Playbook" "Reference" "Specification" + "Status" "Template" "Changelog" "Proposal" "Client Profile" + "Concept" "Decision" "Guide" "Record" "Runbook" + ) + # Tipos legacy (pré-OKF) — aceites sem aviso para não bloquear trabalho normal + local legacy_types=( + "note" "Note" "concept" "manual" "Manual" "procedimento" + "procedure" "reference" "spec" "taskforce" "api" "departamento" + "report" "plan" "deep-research" "research-report" "research" + "documentation" "componente-ia" "proposta" "pesquisa" + "deep-research-prompt" "audit" "relatorio-ic" "runbook" + "guide" "record" "decision" "Worklist" "meeting-notes" + "journal" "review" "analysis" "summary" "overview" + # Hub-specific legacy types descobertos via okf info/validate + "schema" "proc" "servico" "redirect" "diagnostic" + "deep-research-sources" "source-list" "documentacao" + "agente" "analise" "archive-marker" "arquitectura" "arquivo" + "checklist" "checkpoint" "checkup" "checkup-consolidado" + "componente-infra" "content" "continuacao" "conversa-exportada" + "daily" "decisao" + "auditoria" "design-spec" "diagnostico" "diario" "doc" + "documentacao-tecnica" "estrategia" "evaluation" "evidencia" + "final-report" "fontes-curadas" "framework" "inbox" "insight" + "mapeamento" "metricas" "notes" "planning" "plano" + "plano-execucao" "procedimento-infra" "process-map" "product-spec" + "projecto" "prompt" "prompts-pesquisa" "reconnaissance" + "registo-historico" "relatorio-pesquisa" "reuniao" "revisao" + "roadmap" "session-handoff" "sintese" "sistema" "spec-design" + "spec-umbrella" "status" "tarefa" "triangulacao" "troubleshooting" + "visao" "worklog" + ) + local found=false + for vt in "${canonical_types[@]}" "${legacy_types[@]}"; do + [[ "$type_val" == "$vt" ]] && found=true && break + done + if [[ "$found" == "false" ]]; then + warn "type desconhecido '$type_val' — usar: Document, Playbook, Reference, Specification, Status — $file" + fi +} + +check_description_field() { + local file="$1" + if ! grep -q "^description:" "$file" 2>/dev/null; then + # Só aviso — não bloqueia + warn "Sem 'description:' — $file" + fi +} + +check_timestamp_field() { + local file="$1" + # Aceitar 'timestamp:' OU 'date:' (muitos ficheiros antigos têm 'date:') + if ! grep -qE "^(timestamp|date):" "$file" 2>/dev/null; then + warn "Sem 'timestamp:' — $file" + fi +} + +check_uppercase_index() { + local file="$1" + local basename + basename=$(basename "$file") + # Bloquear criação de INDEX.md maiúsculo (deprecated desde 28-06-2026) + if [[ "$basename" == "INDEX.md" ]]; then + err "INDEX.md uppercase está deprecated desde 28-06-2026 — usar 'index.md' — $file" + fi +} + +check_index_wikilinks() { + local file="$1" + local basename + basename=$(basename "$file") + if [[ "$basename" == "index.md" ]]; then + if grep -q "\[\[" "$file" 2>/dev/null; then + local count + count=$(grep -c "\[\[" "$file" 2>/dev/null || echo 0) + warn "index.md com $count wikilinks — converter para [texto](path.md) — $file" + fi + fi +} + +check_qr_line_limit() { + local file="$1" + local basename + basename=$(basename "$file") + if [[ "$basename" == QR-*.md ]]; then + local lines + lines=$(wc -l < "$file" 2>/dev/null || echo 0) + if [[ "$lines" -gt 350 ]]; then + # Erro só para QR verdadeiramente gigantes (>350) — indica decomposição urgente + err "QR-*.md excede 350 linhas ($lines) — dividir imediatamente — $file" + elif [[ "$lines" -gt 200 ]]; then + # Aviso para QR entre 200-350 — dívida técnica, não bloqueia + warn "QR-*.md excede 200 linhas ($lines) — dividir quando possível — $file" + fi + fi +} + +check_index_size() { + local file="$1" + local basename + basename=$(basename "$file") + if [[ "$basename" == "index.md" ]]; then + local lines + lines=$(wc -l < "$file" 2>/dev/null || echo 0) + if [[ "$lines" -gt 100 ]]; then + warn "index.md muito longo ($lines linhas, max recomendado: 80) — $file" + fi + fi +} + +check_no_content_in_index() { + local file="$1" + local basename + basename=$(basename "$file") + if [[ "$basename" != "index.md" ]]; then return; fi + + # Contar linhas de conteúdo substantivo (não links, não headings, não vazias, não frontmatter) + local subst_lines + subst_lines=$(awk ' + /^---$/ { in_fm = !in_fm; next } + in_fm { next } + /^\s*$/ { next } + /^#/ { next } + /^\[/ { next } + /^\|/ { next } + /^>/ { next } + /^```/ { next } + { count++ } + END { print count+0 } + ' "$file" 2>/dev/null || echo 0) + + if [[ "$subst_lines" -gt 5 ]]; then + warn "index.md tem $subst_lines linhas de conteúdo substantivo — index.md deve conter só links — $file" + fi +} + +check_sync_conflicts() { + local file="$1" + if [[ "$file" == *".sync-conflict-"* ]]; then + warn "Ficheiro sync-conflict a ser commitado — resolver antes — $file" + fi +} + +# ─── Verificação de links quebrados via okf CLI ─────────────────────────────── + +check_broken_links_okf() { + if ! command -v okf &>/dev/null; then + return 0 + fi + if [[ "$ALL_FILES" != "true" ]]; then + return 0 # só correr em modo --all (vault completo) + fi + info "OKF CLI: a verificar links quebrados no bundle..." + local okf_out + okf_out=$(okf validate "$VAULT" 2>&1) || true + # Filtrar: excluir erros de directórios ocultos (dot-paths: .ijfw, .stversions, .github, .wayland) + # que o okf não sabe ignorar — são erros de parsing, não broken links + local broken_lines + broken_lines=$(echo "$okf_out" \ + | grep -iE "broken|not found|missing link" \ + | grep -v "Invalid concept id segment" \ + | grep -v "/\." \ + 2>/dev/null || true) + if [[ -n "$broken_lines" ]]; then + local count + count=$(echo "$broken_lines" | wc -l | tr -d ' ') + warn "OKF CLI: $count links quebrados detectados" + echo "$broken_lines" | head -30 >&2 + fi +} + +# ─── Loop principal ──────────────────────────────────────────────────────────── + +echo "" +info "=== OKF Validation $(date '+%Y-%m-%d %H:%M') ===" +if [[ "$ALL_FILES" == "true" ]]; then + info "Modo: COMPLETO (todos os ficheiros activos)" +else + info "Modo: STAGED (ficheiros em staging)" +fi +echo "" + +FILE_COUNT=0 +mapfile -t files < <(get_files) + +for file in "${files[@]}"; do + [[ -z "$file" ]] && continue + [[ ! -f "$file" ]] && continue + + if should_skip "$file"; then + continue + fi + + FILE_COUNT=$((FILE_COUNT+1)) + + check_uppercase_index "$file" + check_sync_conflicts "$file" + check_type_field "$file" + check_description_field "$file" + check_timestamp_field "$file" + check_index_wikilinks "$file" + check_index_size "$file" + check_no_content_in_index "$file" + check_qr_line_limit "$file" +done + +# Verificação de links quebrados (só em modo --all) +check_broken_links_okf + +# ─── Sumário ────────────────────────────────────────────────────────────────── + +echo "" +echo "─────────────────────────────────────────" +info "Ficheiros validados: $FILE_COUNT" + +if [[ $WARNINGS -gt 0 ]]; then + echo -e "${YELLOW}Avisos: $WARNINGS${NC}" +fi + +if [[ $ERRORS -gt 0 ]]; then + echo -e "${RED}Erros: $ERRORS${NC}" + echo "" + if [[ "$WARN_ONLY" == "true" ]]; then + warn "Modo --warn-only: commit não bloqueado apesar de $ERRORS erros" + exit 0 + else + err "Commit bloqueado — corrigir erros OKF antes de commitar" + echo " Dica: bash scripts/okf-validate.sh --warn-only (para forçar)" + echo " Dica: bash scripts/okf-normalize.py (para auto-corrigir frontmatter)" + exit 1 + fi +else + ok "OKF Validation PASSED ($FILE_COUNT ficheiros, $WARNINGS avisos)" +fi + +# ─── OKF Bundle Inventory (modo --all) ─────────────────────────────────────── +if command -v okf &>/dev/null && [[ "$ALL_FILES" == "true" ]]; then + echo "" + info "=== OKF Bundle Inventory ===" + okf info "$VAULT" 2>/dev/null || true +fi + +exit ${ERRORS:-0}