371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
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<string, string> = {
|
|
leader: "🧭",
|
|
architect: "🏗️",
|
|
implementer: "🛠️",
|
|
reviewer: "🔍",
|
|
security: "🔒",
|
|
qa: "🧪",
|
|
documenter: "📝",
|
|
};
|
|
|
|
const GATE_FILES: Record<string, string> = {
|
|
reviewer: "reviewer.json",
|
|
security: "security.json",
|
|
qa: "qa.json",
|
|
leader: "leader-close.json",
|
|
};
|
|
|
|
const AGENT_FILES: Record<string, string> = {
|
|
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<string, string> {
|
|
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<string, string> {
|
|
const result: Record<string, string> = {};
|
|
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, string>): 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, string>): 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 <stage> [feature_id]" };
|
|
if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage <stage> <feature_id>" };
|
|
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<string, string>) {
|
|
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 <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");
|
|
}
|
|
},
|
|
});
|
|
}
|