Files
mercadodevida/platforms/pi/extensions/orquestra-status/index.ts
rikrdo 1d4eebca54 feat(F-001): scaffold modular monolith skeleton with boundary checker
- TypeScript + Fastify skeleton under project/ (src/modules, shared, infrastructure, app)
- scripts/check-module-boundaries.mjs enforcing module public-API rules (tested with fixtures)
- GET /health endpoint, error envelope without stack leakage
- specs/F-001-scaffold (SPEC/DESIGN/TASKS/TESTS), spec/tech.md dependency justification
- 30-ticket MercadoDeVida roadmap in backlog/features.json, spec/roadmap.md
- All gates approved: reviewer, security, qa; verify.sh green
2026-08-14 21:46:54 +02:00

292 lines
9.5 KiB
TypeScript

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 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"]);
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(" ")}`;
}
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(" ")}`;
}
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 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 };
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 refreshTimer: ReturnType<typeof setInterval> | null = null;
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 }, () => refresh());
refreshTimer = setInterval(refresh, 1000);
watching = true;
}
function stopWatching() {
if (!watching) return;
if (currentCtx) unwatchFile(join(currentCtx.cwd, STATUS_FILE));
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
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");
},
});
}