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
This commit is contained in:
36
platforms/pi/README.md
Normal file
36
platforms/pi/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Adaptador Pi
|
||||
|
||||
Orquestra se ejecuta desde Pi como **un solo parent session secuencial**. No instala subagentes.
|
||||
|
||||
## Requisitos obligatorios
|
||||
- `pi` debe existir en `PATH` antes de instalar Orquestra.
|
||||
- El proyecto instalado debe abrirse desde su raíz.
|
||||
- Arrancar con `./scripts/pi_orquestra.sh`, que ejecuta `pi --no-extensions` y carga solo extensiones Orquestra.
|
||||
- Extensiones project-local declaradas: `.pi/extensions/orquestra-status/` y `.pi/extensions/orquestra-web-fetch.ts`.
|
||||
- El código de producto vive en `project/`; archivos de código en la raíz son inválidos.
|
||||
|
||||
## Instalación esperada
|
||||
Cuando Orquestra se instala en un repo de proyecto, el instalador debe copiar:
|
||||
- `platforms/pi/extensions/orquestra-status/` -> `.pi/extensions/orquestra-status/`
|
||||
- `platforms/pi/extensions/orquestra-web-fetch.ts` -> `.pi/extensions/orquestra-web-fetch.ts`
|
||||
|
||||
No debe crear `.pi/subagents/` ni `.pi/subagents.json`.
|
||||
|
||||
## Flujo secuencial
|
||||
1. Ejecutar `./scripts/verify.sh`.
|
||||
2. Abrir Pi limpio desde la raíz con `./scripts/pi_orquestra.sh`.
|
||||
3. Confirmar el widget con `/orquestra-status`.
|
||||
4. El mismo parent session cambia de rol siguiendo `harness/workflow.stages.yml`.
|
||||
5. Antes de cada stage, actualizar estado con `python3 scripts/agent_status.py set ...`.
|
||||
6. `agent_status.py` rechaza saltos de stage sin artefactos previos obligatorios.
|
||||
7. Durante `build`, escribir producto en `project/` y tests en `tests/`; requiere `feature_id`, `stage=build`, `agent=implementer` y `state=running` en `work/runtime-status.json`.
|
||||
8. Al terminar cada stage, escribir el artefacto esperado en `work/artifacts/<feature_id>/`.
|
||||
9. Ejecutar `document`/`documenter.md` solo si cambiaron docs/API/contratos/comportamiento user-facing; no es gate obligatorio de cierre.
|
||||
10. Recién después empieza el siguiente rol/stage.
|
||||
|
||||
## Modelos por rol
|
||||
Si querés modelos distintos por etapa, se eligen secuencialmente antes de cada stage según `harness/model-routing.yml`. No hay ejecución paralela.
|
||||
|
||||
## Respuesta estándar por etapa
|
||||
- `done -> <ruta>`
|
||||
- `blocked -> <ruta>`
|
||||
42
platforms/pi/extensions/orquestra-status/README.md
Normal file
42
platforms/pi/extensions/orquestra-status/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Orquestra Status Extension
|
||||
|
||||
Pi extension project-local que renderiza `work/runtime-status.json` y aplica el guard mínimo de escritura Orquestra.
|
||||
|
||||
## Muestra
|
||||
- feature activa
|
||||
- stage actual
|
||||
- rol actual
|
||||
- acción/estado
|
||||
- siguiente rol
|
||||
- checklist de artefactos por rol
|
||||
- estado de gates
|
||||
- último evento de timeline
|
||||
|
||||
## Ruta instalada
|
||||
- `.pi/extensions/orquestra-status/index.ts`
|
||||
|
||||
Se lanza junto con `.pi/extensions/orquestra-web-fetch.ts` desde `scripts/pi_orquestra.sh`.
|
||||
|
||||
## Uso
|
||||
1. Abrí `pi` en la raíz del proyecto instalado.
|
||||
2. Si Pi ya estaba abierto, ejecutá `/reload`.
|
||||
3. El widget aparece arriba del editor y el footer muestra estado compacto.
|
||||
4. El widget refresca cada segundo.
|
||||
5. Refresco manual: `/orquestra-status`.
|
||||
|
||||
## Guard de escritura
|
||||
|
||||
La extensión intercepta herramientas Pi built-in `write` y `edit`:
|
||||
- bloquea archivos de producto/código en la raíz (`*.py`, `*.js`, `*.ts`, `*.go`, `*.rs`, `*.java`, `*.php`, `*.rb`); usá `project/`.
|
||||
- bloquea escrituras en `project/` o `tests/` salvo que `work/runtime-status.json` tenga `feature_id`, `stage=build`, `agent=implementer` y `state=running`.
|
||||
- bloquea paths que escapan la raíz del repo con `..`.
|
||||
|
||||
## Fuente de verdad
|
||||
- `work/runtime-status.json`
|
||||
|
||||
Actualizar con:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_status.py set ...
|
||||
python3 scripts/agent_status.py reset
|
||||
```
|
||||
291
platforms/pi/extensions/orquestra-status/index.ts
Normal file
291
platforms/pi/extensions/orquestra-status/index.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
56
platforms/pi/extensions/orquestra-web-fetch.ts
Normal file
56
platforms/pi/extensions/orquestra-web-fetch.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
function textFromHtml(html: string): { title: string; content: string } {
|
||||
const title = html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || "No title";
|
||||
const content = html
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return { title, content };
|
||||
}
|
||||
|
||||
async function fetchText(url: string, signal?: AbortSignal) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
const activeSignal = signal ?? controller.signal;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: activeSignal,
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 Orquestra/1.0",
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return textFromHtml(await response.text());
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export default function orquestraWebFetch(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "orquestra_web_fetch",
|
||||
label: "Orquestra Web Fetch",
|
||||
description: "Fetch a web page and return extracted text using a simple non-browser fetch.",
|
||||
parameters: Type.Object({
|
||||
url: Type.String({ description: "URL to fetch" }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, description: "Max characters to return" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, signal) {
|
||||
const { title, content } = await fetchText(params.url, signal);
|
||||
const limit = params.limit ?? 8000;
|
||||
return {
|
||||
content: [{ type: "text", text: `Title: ${title}\n\nContent:\n${content.slice(0, limit)}` }],
|
||||
details: { title, url: params.url, characters: content.length },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
11
platforms/pi/leader.prompt.md
Normal file
11
platforms/pi/leader.prompt.md
Normal file
@@ -0,0 +1,11 @@
|
||||
Actúa como `leader` secuencial de Orquestra.
|
||||
|
||||
Reglas obligatorias:
|
||||
- No implementes código de producto.
|
||||
- No uses subagentes.
|
||||
- Antes de cada stage, ejecutá `python3 scripts/agent_status.py set ...` con el rol/stage correcto.
|
||||
- Si `agent_status.py` rechaza el cambio de stage, STOP: falta evidencia previa.
|
||||
- Orquestá stages según `harness/workflow.stages.yml`.
|
||||
- Usá `document`/`documenter` solo si cambiaron docs/API/contratos/comportamiento user-facing.
|
||||
- Aplicá anti-trampa de `harness/agents.matrix.yml`.
|
||||
- No marques `done` sin reviewer/security/qa APPROVED y `./scripts/verify.sh` en verde; `documenter.md` no es requisito de cierre por defecto.
|
||||
Reference in New Issue
Block a user