import { execFileSync } from "node:child_process"; import { readFileSync, watchFile, unwatchFile } from "node:fs"; import { join, relative, resolve, sep } from "node:path"; import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent"; const STATUS_FILE = "work/runtime-status.json"; const MATRIX_FILE = "harness/agents.matrix.yml"; const ARTIFACTS_DIR = "work/artifacts"; const WIDGET_KEY = "orquestra-runtime"; const STATUS_KEY = "orquestra-runtime"; const STATUS_GAP = " "; const DEFAULT_EMOJIS: Record = { leader: "🧭", architect: "🏗️", implementer: "🛠️", reviewer: "🔍", security: "🔒", qa: "🧪", documenter: "📝", }; const GATE_FILES: Record = { reviewer: "reviewer.json", security: "security.json", qa: "qa.json", leader: "leader-close.json", }; const AGENT_FILES: Record = { architect: "architect.md", implementer: "implementer.md", reviewer: "reviewer.json", security: "security.json", qa: "qa.json", documenter: "documenter.md", leader: "leader-close.json", }; type RuntimeStatus = { feature_id?: string | null; stage?: string; agent?: string; action?: string; state?: string; next_agent?: string; waiting_for?: string; updated_at?: string; timeline?: Array<{ ts?: string; agent?: string; stage?: string; state?: string; message?: string }>; }; const ROOT_PRODUCT_EXTENSIONS = new Set([".py", ".js", ".ts", ".go", ".rs", ".java", ".php", ".rb"]); // Carpetas permitidas para escritura (Orquestra + proyecto) const ALLOWED_WRITE_DIRS = new Set([ "project", // código del proyecto "tests", // tests del proyecto "work", // artifacts, current.md, runtime-status.json "backlog", // features.json "spec", // specs globales "harness", // configuración Orquestra (solo leader) "scripts", // scripts Orquestra (solo leader) "platforms", // extensiones Pi (solo leader) "docs", // documentación (solo leader) ]); function safeReadJson(root: string, path: string): RuntimeStatus | null { try { return JSON.parse(readFileSync(join(root, path), "utf8")) as RuntimeStatus; } catch { return null; } } function loadRoleEmojis(root: string): Record { try { const text = readFileSync(join(root, MATRIX_FILE), "utf8"); const emojis = { ...DEFAULT_EMOJIS }; let currentRole: string | null = null; for (const line of text.split(/\r?\n/)) { const roleMatch = line.match(/^ ([a-z_]+):\s*$/); if (roleMatch) { currentRole = roleMatch[1]; continue; } const emojiMatch = line.match(/^\s{4}emoji:\s*["']?(.*?)["']?\s*$/); if (emojiMatch && currentRole) emojis[currentRole] = emojiMatch[1]; } return emojis; } catch { return { ...DEFAULT_EMOJIS }; } } function gateState(root: string, featureId?: string | null): Record { const result: Record = {}; if (!featureId) return result; for (const [gate, filename] of Object.entries(GATE_FILES)) { try { if (filename.endsWith(".md")) { readFileSync(join(root, ARTIFACTS_DIR, featureId, filename), "utf8"); result[gate] = "APPROVED"; continue; } const json = JSON.parse(readFileSync(join(root, ARTIFACTS_DIR, featureId, filename), "utf8")) as { verdict?: string }; result[gate] = json.verdict === "APPROVED" ? "APPROVED" : "PRESENT"; } catch { result[gate] = "PENDING"; } } return result; } function gateSummary(root: string, featureId: string | null | undefined, emojis: Record): string { const gates = gateState(root, featureId); if (!featureId) return "Gates: —"; return `Gates: ${["reviewer", "security", "qa", "leader"] .map((gate) => `${emojis[gate] || "•"} ${gates[gate] === "APPROVED" ? "✅" : gates[gate] === "PRESENT" ? "⚠️" : "⏳"}`) .join(STATUS_GAP)}`; } function artifactStatus(root: string, featureId: string | null | undefined, agent: string): string { if (!featureId) return "PENDING"; const filename = AGENT_FILES[agent]; if (!filename) return "PENDING"; try { if (filename.endsWith(".md")) { readFileSync(join(root, ARTIFACTS_DIR, featureId, filename), "utf8"); return "DONE"; } const json = JSON.parse(readFileSync(join(root, ARTIFACTS_DIR, featureId, filename), "utf8")) as { verdict?: string }; return json.verdict === "APPROVED" ? "DONE" : "PRESENT"; } catch { return "PENDING"; } } function agentSummary(root: string, status: RuntimeStatus, emojis: Record): string { const featureId = status.feature_id || null; const currentAgent = status.agent || "leader"; return `Roles: ${["architect", "implementer", "reviewer", "security", "qa", "documenter", "leader"] .map((agent) => { const state = artifactStatus(root, featureId, agent); const mark = state === "DONE" ? "✅" : state === "PRESENT" ? "⚠️" : agent === currentAgent ? "▶️" : "⏳"; return `${emojis[agent] || "•"} ${mark}`; }) .join(STATUS_GAP)}`; } function isInsideRel(relPath: string, dirname: string): boolean { return relPath === dirname || relPath.startsWith(`${dirname}/`) || relPath.startsWith(`${dirname}${sep}`); } function normalizeWritePath(root: string, rawPath: unknown): { relPath?: string; reason?: string } { if (typeof rawPath !== "string" || rawPath.trim() === "") { return { reason: "Orquestra bloqueó una escritura sin path válido." }; } const absPath = resolve(root, rawPath); const relPath = relative(root, absPath); if (relPath === "" || relPath.startsWith("..") || resolve(root, relPath) !== absPath) { return { reason: "Orquestra bloqueó una escritura fuera de la raíz del proyecto." }; } return { relPath: relPath.replace(/\\/g, "/") }; } function isRootProductFile(relPath: string): boolean { if (relPath.includes("/")) return false; const dot = relPath.lastIndexOf("."); return dot > 0 && ROOT_PRODUCT_EXTENSIONS.has(relPath.slice(dot)); } function hasActiveBuildRuntime(status: RuntimeStatus | null): boolean { return Boolean( status?.feature_id && status.stage === "build" && status.agent === "implementer" && status.state === "running", ); } function parseStageCommand(args: string, status: RuntimeStatus | null): { stage?: string; featureId?: string; error?: string } { const parts = args.trim().split(/\s+/).filter(Boolean); const stage = parts[0]; const featureFlag = parts.indexOf("--feature-id"); const featureId = featureFlag >= 0 ? parts[featureFlag + 1] : parts[1] || status?.feature_id || undefined; if (!stage) return { error: "Uso: /orquestra-stage [feature_id]" }; if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage " }; return { stage, featureId }; } function lastUsefulLine(text: string): string { return text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .slice(-1)[0] || "stage terminado"; } function guardWriteOrEdit(event: ToolCallEvent, ctx: ExtensionContext): { block?: boolean; reason?: string } | undefined { if (event.toolName !== "write" && event.toolName !== "edit") return undefined; const { relPath, reason } = normalizeWritePath(ctx.cwd, (event.input as { path?: unknown }).path); if (!relPath) return { block: true, reason }; // Extraer el directorio raíz del path const slashIndex = relPath.indexOf("/"); const rootDir = slashIndex === -1 ? relPath : relPath.slice(0, slashIndex); // Bloquear escrituras fuera de carpetas permitidas if (!ALLOWED_WRITE_DIRS.has(rootDir)) { return { block: true, reason: `Orquestra bloqueó '${relPath}': solo se permite escribir en ${Array.from(ALLOWED_WRITE_DIRS).join(", ")}. No crees carpetas ni archivos en la raíz ni en ubicaciones no autorizadas.`, }; } // Bloquear escrituras directas a backlog/features.json (debe usar close_feature.py) if (relPath === "backlog/features.json") { return { block: true, reason: `Orquestra bloqueó '${relPath}': NUNCA edites backlog/features.json directamente. Usa scripts/close_feature.py para cerrar features. Este script valida gates y actualiza el backlog automáticamente.`, }; } if (isRootProductFile(relPath)) { return { block: true, reason: `Orquestra bloqueó '${relPath}': los archivos de producto/código no van en la raíz. Escribí ese código dentro de project/.`, }; } if (isInsideRel(relPath, "project") || isInsideRel(relPath, "tests")) { const status = safeReadJson(ctx.cwd, STATUS_FILE); if (!hasActiveBuildRuntime(status)) { return { block: true, reason: "Orquestra bloqueó esta escritura: los cambios en project/ o tests/ requieren una feature/ticket activa, stage build, agent implementer y state running. Seleccioná o creá un ticket y actualizá work/runtime-status.json con scripts/agent_status.py antes de escribir.", }; } } return undefined; } function render(root: string, status: RuntimeStatus | null, emojis: Record) { if (!status) { return { widget: ["Orquestra: no se pudo leer work/runtime-status.json"], footer: "Orquestra status unavailable", }; } const feature = status.feature_id || "—"; const stage = status.stage || "—"; const agent = status.agent || "leader"; const action = status.action || "—"; const state = status.state || "—"; const nextAgent = status.next_agent || "—"; const waitingFor = status.waiting_for || "—"; const updatedAt = status.updated_at || "—"; const lastEvent = status.timeline && status.timeline.length > 0 ? status.timeline[status.timeline.length - 1] : null; const lastLine = lastEvent ? `Último: ${lastEvent.ts || "—"} · ${emojis[lastEvent.agent || "leader"] || "•"} ${lastEvent.message || "—"}` : "Último: —"; return { widget: [ `Orquestra ${emojis[agent] || "•"} ${feature} · ${stage} · ${agent} · ${state}`, `Acción: ${action}`, `Siguiente: ${emojis[nextAgent] || "•"} ${nextAgent} · Esperando: ${waitingFor}`, agentSummary(root, status, emojis), gateSummary(root, status.feature_id || null, emojis), lastLine, `Actualizado: ${updatedAt}`, ], footer: `Orquestra ${emojis[agent] || "•"} ${feature} · ${stage} · ${state}`, }; } export default function orquestraStatusExtension(pi: ExtensionAPI) { let currentCtx: ExtensionContext | null = null; let watching = false; let lastRendered = ""; function apply(ctx: ExtensionContext) { const root = ctx.cwd; const emojis = loadRoleEmojis(root); const rendered = render(root, safeReadJson(root, STATUS_FILE), emojis); const signature = JSON.stringify(rendered); if (signature === lastRendered) return; lastRendered = signature; ctx.ui.setWidget(WIDGET_KEY, rendered.widget); ctx.ui.setStatus(STATUS_KEY, rendered.footer); } function refresh() { if (!currentCtx || !currentCtx.hasUI) return; apply(currentCtx); } function startWatching() { if (watching) return; if (!currentCtx) return; watchFile(join(currentCtx.cwd, STATUS_FILE), { interval: 1000, persistent: false }, () => refresh()); watching = true; } function stopWatching() { if (!watching) return; if (currentCtx) unwatchFile(join(currentCtx.cwd, STATUS_FILE)); watching = false; } pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; currentCtx = ctx; apply(ctx); startWatching(); }); pi.on("session_shutdown", async (_event, ctx) => { ctx.ui.setWidget(WIDGET_KEY, undefined); ctx.ui.setStatus(STATUS_KEY, undefined); stopWatching(); currentCtx = null; }); pi.on("tool_call", async (event, ctx) => guardWriteOrEdit(event, ctx)); pi.registerCommand("orquestra-status", { description: "Refresca el widget de runtime status de Orquestra", handler: async (_args, ctx) => { currentCtx = ctx; apply(ctx); ctx.ui.notify("Orquestra status refrescado", "info"); }, }); pi.registerCommand("orquestra-stage", { description: "Ejecuta un stage Orquestra en un proceso Pi fresco: /orquestra-stage [feature_id]", handler: async (args, ctx) => { const parsed = parseStageCommand(args, safeReadJson(ctx.cwd, STATUS_FILE)); if (parsed.error) { ctx.ui.notify(parsed.error, "error"); return; } ctx.ui.notify(`Orquestra ejecutando ${parsed.stage} en Pi fresco…`, "info"); try { const output = execFileSync("python3", ["scripts/run_stage.py", parsed.stage!, "--feature-id", parsed.featureId!], { cwd: ctx.cwd, encoding: "utf8", maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); currentCtx = ctx; apply(ctx); ctx.ui.notify(`Orquestra stage terminado: ${lastUsefulLine(output)}`, "info"); } catch (error) { const failed = error as { stdout?: string; stderr?: string; message?: string }; const detail = lastUsefulLine(`${failed.stdout || ""}\n${failed.stderr || ""}`) || failed.message || "error desconocido"; currentCtx = ctx; apply(ctx); ctx.ui.notify(`Orquestra stage falló: ${detail}`, "error"); } }, }); }