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:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
# BEGIN ORQUESTRA
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.codegraph/
|
||||||
|
.atl/
|
||||||
|
# END ORQUESTRA
|
||||||
42
.pi/extensions/orquestra-status/README.md
Normal file
42
.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
.pi/extensions/orquestra-status/index.ts
Normal file
291
.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
.pi/extensions/orquestra-web-fetch.ts
Normal file
56
.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 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
22
AGENTS.md
Normal file
22
AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# AGENTS.md — Entrada de Orquestra
|
||||||
|
|
||||||
|
## Arranque obligatorio
|
||||||
|
1. Ejecutar `./scripts/verify.sh`.
|
||||||
|
2. Leer `work/current.md`.
|
||||||
|
3. Leer `backlog/features.json` y seleccionar **una** feature `pending`.
|
||||||
|
4. Seguir `harness/workflow.stages.yml` y `harness/agents.matrix.yml`.
|
||||||
|
5. Actualizar estado visible con `python3 scripts/agent_status.py set ...` antes de cada stage.
|
||||||
|
|
||||||
|
## Reglas duras
|
||||||
|
- Orquestra se ejecuta secuencialmente en Pi: no subagentes.
|
||||||
|
- Para Pi limpio, arrancar con `./scripts/pi_orquestra.sh`, no con `pi` directo.
|
||||||
|
- Una sola feature en `in_progress`.
|
||||||
|
- Ningún rol pasa código por chat: todo va a `work/artifacts/<feature_id>/`.
|
||||||
|
- `implementer` nunca marca `done`.
|
||||||
|
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
|
||||||
|
- Si `verify.sh` falla, no se cierra la feature.
|
||||||
|
|
||||||
|
## Reentrada
|
||||||
|
- Releer `work/current.md`, `work/runtime-status.json` y artefactos de la feature activa.
|
||||||
|
- Ejecutar `./scripts/verify.sh`.
|
||||||
|
- Continuar desde el próximo stage pendiente.
|
||||||
21
CHECKPOINTS.md
Normal file
21
CHECKPOINTS.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# CHECKPOINTS
|
||||||
|
|
||||||
|
## C1 — Estructura
|
||||||
|
- [ ] Existe `harness/`, `spec/`, `backlog/`, `work/`, `scripts/`, `platforms/`.
|
||||||
|
|
||||||
|
## C2 — Estado
|
||||||
|
- [ ] Máximo una feature en `in_progress`.
|
||||||
|
- [ ] Estados válidos en backlog.
|
||||||
|
|
||||||
|
## C3 — Gates
|
||||||
|
- [ ] Toda feature `done` tiene `reviewer.json` aprobado.
|
||||||
|
- [ ] Toda feature `done` tiene `security.json` aprobado.
|
||||||
|
- [ ] Toda feature `done` tiene `qa.json` aprobado.
|
||||||
|
- [ ] Toda feature `done` tiene `leader-close.json` válido.
|
||||||
|
|
||||||
|
## C4 — Verificación
|
||||||
|
- [ ] `./scripts/verify.sh` termina en OK.
|
||||||
|
|
||||||
|
## C5 — Trazabilidad
|
||||||
|
- [ ] `work/history.md` actualizado en cierre.
|
||||||
|
- [ ] Artefactos por feature en `work/artifacts/<feature_id>/`.
|
||||||
93
HOWTO.md
Normal file
93
HOWTO.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# HOWTO — usar Orquestra
|
||||||
|
|
||||||
|
## 1) Requisitos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
command -v pi
|
||||||
|
command -v python3
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `pi` no existe, no instales Orquestra todavía.
|
||||||
|
|
||||||
|
## 2) Instalar en un proyecto
|
||||||
|
|
||||||
|
Desde el repo fuente de Orquestra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh /path/to/project-repo
|
||||||
|
```
|
||||||
|
|
||||||
|
Para actualizar, ejecutá el mismo comando otra vez sobre el repo destino.
|
||||||
|
|
||||||
|
La instalación es safe-update:
|
||||||
|
- crear archivos faltantes
|
||||||
|
- conservar o mergear archivos existentes
|
||||||
|
- crear `project/` si falta y no pisar su contenido
|
||||||
|
- no pisar progreso en `project/`, `work/`, `backlog/`, `spec/` ni `work/artifacts/`
|
||||||
|
|
||||||
|
## 3) Verificar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe comprobar:
|
||||||
|
- estructura mínima
|
||||||
|
- `project/` existente
|
||||||
|
- ausencia de archivos de producto/código en la raíz (`*.py`, `*.js`, `*.ts`, `*.go`, `*.rs`, `*.java`, `*.php`, `*.rb`); usá `project/`
|
||||||
|
- Pi instalado
|
||||||
|
- extensión project-local permitida
|
||||||
|
- sin `.pi/subagents/` ni `.pi/subagents.json`
|
||||||
|
- extensiones Orquestra requeridas
|
||||||
|
- backlog válido
|
||||||
|
- runtime status válido
|
||||||
|
|
||||||
|
## 4) Ejecutar desde Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-instalado>
|
||||||
|
./scripts/pi_orquestra.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ese launcher usa `pi --no-extensions` y carga solo `orquestra-status` + `orquestra-web-fetch`, así Pi arranca limpio con las extensiones del proyecto.
|
||||||
|
|
||||||
|
Después de abrir o recargar Pi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/orquestra-status
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) Flujo secuencial
|
||||||
|
|
||||||
|
1. `leader` selecciona una feature pending.
|
||||||
|
2. `architect` diseña si hace falta.
|
||||||
|
3. `implementer` implementa y escribe `implementer.md`.
|
||||||
|
4. `reviewer` escribe `reviewer.json`.
|
||||||
|
5. `security` escribe `security.json`.
|
||||||
|
6. `qa` escribe `qa.json`.
|
||||||
|
7. `documenter` escribe `documenter.md` solo si cambiaron docs/API/contratos/comportamiento user-facing.
|
||||||
|
8. `leader` cierra con `leader-close.json` y `work/history.md`.
|
||||||
|
|
||||||
|
Un stage empieza solo cuando el anterior terminó con artefacto en disco; `document` es opcional/condicional y no bloquea el cierre por defecto.
|
||||||
|
|
||||||
|
## 6) Dónde va el código de producto
|
||||||
|
|
||||||
|
El código del producto vive en `project/`. No escribas archivos de producto/código en la raíz del repo.
|
||||||
|
|
||||||
|
En Pi, las herramientas `write` y `edit` solo pueden modificar `project/` o `tests/` cuando `work/runtime-status.json` tiene una feature activa con `stage=build`, `agent=implementer` y `state=running`. Prepará el stage con:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_status.py set --feature-id F-001 --stage build --agent implementer --state running --action "Implementing"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7) Estado visible
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_status.py show
|
||||||
|
python3 scripts/agent_status.py set --feature-id F-001 --stage build --agent implementer --state running --action "Implementing"
|
||||||
|
python3 scripts/agent_status.py reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regla corta
|
||||||
|
|
||||||
|
Pi instalado → Orquestra instalado sin pisar progreso → producto dentro de `project/` → `verify.sh` verde → `./scripts/pi_orquestra.sh` desde raíz → stages secuenciales con evidencia en disco.
|
||||||
161
README.md
Normal file
161
README.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Orquestra — harness secuencial para Pi
|
||||||
|
|
||||||
|
Orquestra es un harness in-house para instalar en cualquier repo de proyecto y ejecutarlo desde Pi con control de estado, evidencias y gates.
|
||||||
|
|
||||||
|
No instala subagentes. El flujo es secuencial: termina un rol/stage, se escribe su artefacto, recién ahí empieza el siguiente.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- `pi` instalado y disponible en `PATH` antes de ejecutar Orquestra.
|
||||||
|
- `python3` disponible para scripts del harness.
|
||||||
|
- Ejecutar Pi desde la raíz del proyecto.
|
||||||
|
- Arrancar con `./scripts/pi_orquestra.sh` para usar `pi --no-extensions` y cargar solo extensiones Orquestra.
|
||||||
|
- Extensiones Pi project-local declaradas: `orquestra-status` y `orquestra-web-fetch.ts`.
|
||||||
|
|
||||||
|
> Nota honesta: abrir `pi` directo puede cargar extensiones globales. Para Pi limpio, usá `./scripts/pi_orquestra.sh`.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Permitir trabajo asistido por agentes sin perder control:
|
||||||
|
- una feature a la vez
|
||||||
|
- estado persistente en disco
|
||||||
|
- evidencia auditable, nunca solo chat
|
||||||
|
- separación de roles
|
||||||
|
- gates obligatorios de revisión, seguridad y QA
|
||||||
|
- documentación opcional cuando cambian docs/API/contratos/comportamiento user-facing
|
||||||
|
- código de producto dentro de `project/` (nunca archivos de código en la raíz)
|
||||||
|
- cierre solo con `./scripts/verify.sh` en verde
|
||||||
|
|
||||||
|
## Roles secuenciales
|
||||||
|
|
||||||
|
1. `leader` — selecciona feature, orquesta, cierra.
|
||||||
|
2. `architect` — diseño/contratos cuando haga falta.
|
||||||
|
3. `implementer` — cambia código y tests, no aprueba.
|
||||||
|
4. `reviewer` — gate técnico.
|
||||||
|
5. `security` — gate de seguridad.
|
||||||
|
6. `qa` — gate funcional/aceptación.
|
||||||
|
7. `documenter` — documentación opcional cuando aplique.
|
||||||
|
|
||||||
|
Los modelos por rol se definen en `harness/model-routing.yml`; el cambio de modelo ocurre antes de cada stage, nunca en paralelo.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
1. `intake` → `leader`
|
||||||
|
2. `design` → `architect` opcional
|
||||||
|
3. `build` → `implementer`
|
||||||
|
4. `review_gate` → `reviewer`
|
||||||
|
5. `security_gate` → `security`
|
||||||
|
6. `qa_gate` → `qa`
|
||||||
|
7. `document` → `documenter` opcional/condicional
|
||||||
|
8. `close` → `leader`
|
||||||
|
|
||||||
|
No hay `done` si falta cualquier gate obligatorio; `documenter.md` no es requisito de cierre salvo que el cambio necesite documentación.
|
||||||
|
|
||||||
|
## Evidencia obligatoria
|
||||||
|
|
||||||
|
Cada stage escribe en disco:
|
||||||
|
- `work/artifacts/<feature>/implementer.md`
|
||||||
|
- `work/artifacts/<feature>/reviewer.json`
|
||||||
|
- `work/artifacts/<feature>/security.json`
|
||||||
|
- `work/artifacts/<feature>/qa.json`
|
||||||
|
- `work/artifacts/<feature>/documenter.md` (opcional/condicional)
|
||||||
|
- `work/artifacts/<feature>/leader-close.json`
|
||||||
|
|
||||||
|
Respuesta estándar por stage:
|
||||||
|
- `done -> <ruta>`
|
||||||
|
- `blocked -> <ruta>`
|
||||||
|
|
||||||
|
## Estructura mínima
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── AGENTS.md
|
||||||
|
├── README.md
|
||||||
|
├── harness/
|
||||||
|
│ ├── agents.matrix.yml
|
||||||
|
│ ├── workflow.stages.yml
|
||||||
|
│ ├── model-routing.yml
|
||||||
|
│ ├── policies/
|
||||||
|
│ └── contracts/
|
||||||
|
├── platforms/pi/
|
||||||
|
│ └── extensions/
|
||||||
|
│ ├── orquestra-status/
|
||||||
|
│ └── orquestra-web-fetch.ts
|
||||||
|
├── spec/
|
||||||
|
├── project/
|
||||||
|
├── backlog/features.json
|
||||||
|
├── work/
|
||||||
|
│ ├── current.md
|
||||||
|
│ ├── history.md
|
||||||
|
│ ├── runtime-status.json
|
||||||
|
│ └── artifacts/
|
||||||
|
└── scripts/
|
||||||
|
├── verify.sh
|
||||||
|
├── agent_status.py
|
||||||
|
├── new_ticket.py
|
||||||
|
└── pi_orquestra.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalación y actualización segura
|
||||||
|
|
||||||
|
Desde el repo fuente de Orquestra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh /path/to/project-repo
|
||||||
|
```
|
||||||
|
|
||||||
|
Para actualizar, volvé a ejecutar el mismo comando sobre el repo destino.
|
||||||
|
|
||||||
|
Regla base:
|
||||||
|
- crear si falta
|
||||||
|
- conservar o mergear si existe
|
||||||
|
- nunca pisar `work/`, `backlog/`, `spec/` ni artefactos ya producidos
|
||||||
|
|
||||||
|
Archivos del harness actualizables: `AGENTS.md`, `README.md`, `HOWTO.md`, `CHECKPOINTS.md`, `harness/`, `scripts/`, `platforms/pi/`.
|
||||||
|
Datos del proyecto: `project/`, `work/`, `backlog/`, `spec/`.
|
||||||
|
|
||||||
|
El instalador crea `project/` si falta y no pisa su contenido. Los archivos de producto/código (`*.py`, `*.js`, `*.ts`, `*.go`, `*.rs`, `*.java`, `*.php`, `*.rb`) son inválidos en la raíz del repo; `./scripts/verify.sh` falla si los encuentra. Durante una sesión Pi, escribir en `project/` o `tests/` requiere una feature activa con `stage=build`, `agent=implementer` y `state=running` en `work/runtime-status.json`.
|
||||||
|
|
||||||
|
## Pi
|
||||||
|
|
||||||
|
Las extensiones Orquestra esperadas son:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.pi/extensions/orquestra-status/index.ts
|
||||||
|
.pi/extensions/orquestra-web-fetch.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Comando manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/pi_orquestra.sh
|
||||||
|
# dentro de Pi:
|
||||||
|
/orquestra-status
|
||||||
|
```
|
||||||
|
|
||||||
|
Fuente de verdad:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_status.py show
|
||||||
|
python3 scripts/agent_status.py set ...
|
||||||
|
python3 scripts/agent_status.py reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Comprueba:
|
||||||
|
- estructura mínima
|
||||||
|
- `pi` instalado
|
||||||
|
- launcher limpio `scripts/pi_orquestra.sh`
|
||||||
|
- extensiones requeridas: `orquestra-status` y `orquestra-web-fetch`
|
||||||
|
- ausencia de subagentes project-local
|
||||||
|
- precondiciones de stage en `agent_status.py`
|
||||||
|
- extensiones project-local no declaradas
|
||||||
|
- backlog y gates
|
||||||
|
- `work/runtime-status.json`
|
||||||
|
- `project/` existente y sin archivos de producto/código en la raíz
|
||||||
|
- suite del proyecto si existe
|
||||||
1143
backlog/features.json
Normal file
1143
backlog/features.json
Normal file
File diff suppressed because it is too large
Load Diff
71
harness/agents.matrix.yml
Normal file
71
harness/agents.matrix.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
roles:
|
||||||
|
leader:
|
||||||
|
can_edit: ["work/", "backlog/", "spec/", "harness/"]
|
||||||
|
cannot_edit: ["src/", "tests/"]
|
||||||
|
responsibilities:
|
||||||
|
- plan
|
||||||
|
- orchestrate
|
||||||
|
- enforce_gates
|
||||||
|
- close_feature
|
||||||
|
|
||||||
|
architect:
|
||||||
|
can_edit: ["spec/", "harness/contracts/", "docs/"]
|
||||||
|
cannot_edit: ["src/", "tests/", "backlog/features.json:status"]
|
||||||
|
responsibilities:
|
||||||
|
- design
|
||||||
|
- update_contracts
|
||||||
|
|
||||||
|
implementer:
|
||||||
|
can_edit: ["src/", "tests/", "work/artifacts/"]
|
||||||
|
cannot_edit:
|
||||||
|
- "backlog/features.json:done"
|
||||||
|
- "work/history.md"
|
||||||
|
- "work/artifacts/*/reviewer.json"
|
||||||
|
- "work/artifacts/*/security.json"
|
||||||
|
- "work/artifacts/*/qa.json"
|
||||||
|
- "work/artifacts/*/leader-close.json"
|
||||||
|
responsibilities:
|
||||||
|
- implement_feature
|
||||||
|
- write_tests
|
||||||
|
- produce_implementer_evidence
|
||||||
|
|
||||||
|
reviewer:
|
||||||
|
can_edit: ["work/artifacts/"]
|
||||||
|
cannot_edit: ["src/", "tests/", "backlog/"]
|
||||||
|
responsibilities:
|
||||||
|
- technical_review
|
||||||
|
- emit_reviewer_verdict
|
||||||
|
|
||||||
|
security:
|
||||||
|
can_edit: ["work/artifacts/"]
|
||||||
|
cannot_edit: ["src/", "tests/", "backlog/"]
|
||||||
|
responsibilities:
|
||||||
|
- sast
|
||||||
|
- dependency_review
|
||||||
|
- secret_scan
|
||||||
|
- emit_security_verdict
|
||||||
|
|
||||||
|
qa:
|
||||||
|
can_edit: ["work/artifacts/"]
|
||||||
|
cannot_edit: ["src/", "tests/", "backlog/"]
|
||||||
|
responsibilities:
|
||||||
|
- acceptance_traceability
|
||||||
|
- integration_e2e_checks
|
||||||
|
- regression_checks
|
||||||
|
- emit_qa_verdict
|
||||||
|
|
||||||
|
documenter:
|
||||||
|
can_edit: ["work/artifacts/", "docs/", "README.md", "HOWTO.md", "platforms/pi/README.md", "harness/contracts/"]
|
||||||
|
cannot_edit: ["src/", "tests/", "backlog/features.json:status"]
|
||||||
|
responsibilities:
|
||||||
|
- document_changes_when_needed
|
||||||
|
- update_docs_api_contracts_user_facing_notes
|
||||||
|
- produce_documenter_evidence
|
||||||
|
|
||||||
|
anti_cheat:
|
||||||
|
- "Implementer cannot promote feature to done"
|
||||||
|
- "Done requires reviewer/security/qa approved artifacts"
|
||||||
|
- "Leader close requires verify.sh success"
|
||||||
|
- "Evidence must be on disk; chat-only claims are invalid"
|
||||||
19
harness/contracts/evidence.schema.json
Normal file
19
harness/contracts/evidence.schema.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://orquestra.local/evidence.schema.json",
|
||||||
|
"title": "Gate Evidence",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["feature_id", "agent", "verdict", "summary", "evidence", "timestamp"],
|
||||||
|
"properties": {
|
||||||
|
"feature_id": { "type": "string", "minLength": 1 },
|
||||||
|
"agent": { "type": "string", "enum": ["reviewer", "security", "qa", "leader"] },
|
||||||
|
"verdict": { "type": "string", "enum": ["APPROVED", "CHANGES_REQUESTED"] },
|
||||||
|
"summary": { "type": "string", "minLength": 1 },
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"timestamp": { "type": "string", "format": "date-time" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
22
harness/contracts/handoff.md
Normal file
22
harness/contracts/handoff.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Handoff Contract
|
||||||
|
|
||||||
|
## Regla anti teléfono-descompuesto
|
||||||
|
Los agentes **no** devuelven código en chat. Deben escribir a disco y responder solo:
|
||||||
|
- `done -> <ruta>`
|
||||||
|
- `blocked -> <ruta>`
|
||||||
|
|
||||||
|
## Rutas estándar por feature
|
||||||
|
- `work/artifacts/<feature_id>/implementer.md`
|
||||||
|
- `work/artifacts/<feature_id>/reviewer.json`
|
||||||
|
- `work/artifacts/<feature_id>/security.json`
|
||||||
|
- `work/artifacts/<feature_id>/qa.json`
|
||||||
|
- `work/artifacts/<feature_id>/documenter.md` (opcional; requerido solo si cambiaron docs/API/contratos/comportamiento user-facing)
|
||||||
|
- `work/artifacts/<feature_id>/leader-close.json`
|
||||||
|
|
||||||
|
## Campos mínimos de artefactos JSON de gate
|
||||||
|
- `feature_id` (string)
|
||||||
|
- `agent` (reviewer|security|qa|leader)
|
||||||
|
- `verdict` (APPROVED|CHANGES_REQUESTED)
|
||||||
|
- `summary` (string)
|
||||||
|
- `evidence` (array de comandos/salidas/rutas)
|
||||||
|
- `timestamp` (ISO 8601)
|
||||||
23
harness/model-routing.yml
Normal file
23
harness/model-routing.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
# Sequential role model hints for Pi. The parent session changes model before
|
||||||
|
# each stage when a different model is desired. No parallel agents/subagents.
|
||||||
|
roles:
|
||||||
|
leader:
|
||||||
|
model: default
|
||||||
|
reasoning: medium
|
||||||
|
architect:
|
||||||
|
model: strong
|
||||||
|
reasoning: medium
|
||||||
|
implementer:
|
||||||
|
model: default
|
||||||
|
reasoning: medium
|
||||||
|
reviewer:
|
||||||
|
model: strong
|
||||||
|
reasoning: low
|
||||||
|
security:
|
||||||
|
model: strong
|
||||||
|
reasoning: medium
|
||||||
|
qa:
|
||||||
|
model: default
|
||||||
|
reasoning: low
|
||||||
16
harness/policies/governance.md
Normal file
16
harness/policies/governance.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Governance Policy
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
- Una sola feature activa (`in_progress`) a la vez.
|
||||||
|
- El pipeline es secuencial con gates obligatorios.
|
||||||
|
- `done` solo por `leader` y solo con evidencia completa.
|
||||||
|
|
||||||
|
## Separación de funciones
|
||||||
|
- Implementer construye.
|
||||||
|
- Reviewer/Security/QA validan.
|
||||||
|
- Leader decide cierre.
|
||||||
|
|
||||||
|
## Prohibiciones
|
||||||
|
- Saltar gates.
|
||||||
|
- Aprobar con evidencia incompleta.
|
||||||
|
- Cerrar con `verify.sh` en rojo.
|
||||||
12
harness/policies/quality.md
Normal file
12
harness/policies/quality.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Quality Policy
|
||||||
|
|
||||||
|
## Gate QA (obligatorio)
|
||||||
|
Cada feature debe tener `work/artifacts/<feature_id>/qa.json` con:
|
||||||
|
- `verdict`: APPROVED | CHANGES_REQUESTED
|
||||||
|
- trazabilidad criterio de aceptación -> test
|
||||||
|
- resultado de pruebas integración/E2E relevantes
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
- No hay `done` sin cobertura mínima de acceptance.
|
||||||
|
- No hay `done` con regresiones abiertas.
|
||||||
|
- Claims sin evidencia ejecutable no son válidos.
|
||||||
12
harness/policies/security.md
Normal file
12
harness/policies/security.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Gate de seguridad (obligatorio)
|
||||||
|
Cada feature debe tener `work/artifacts/<feature_id>/security.json` con:
|
||||||
|
- `verdict`: APPROVED | CHANGES_REQUESTED
|
||||||
|
- `checks`: secretos, dependencias, SAST básico, validación de inputs
|
||||||
|
- `findings`: lista de hallazgos con severidad
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
- Si hay hallazgos críticos/altos sin mitigación: `CHANGES_REQUESTED`.
|
||||||
|
- No se permite exponer credenciales ni secretos en repo/chat.
|
||||||
|
- Dependencias nuevas requieren justificación en `spec/tech.md`.
|
||||||
62
harness/workflow.stages.yml
Normal file
62
harness/workflow.stages.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
feature_states:
|
||||||
|
allowed: [pending, in_progress, blocked, done]
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- name: intake
|
||||||
|
owner: leader
|
||||||
|
input:
|
||||||
|
- backlog/features.json
|
||||||
|
- work/current.md
|
||||||
|
output:
|
||||||
|
- work/current.md
|
||||||
|
|
||||||
|
- name: design
|
||||||
|
owner: architect
|
||||||
|
optional: true
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/architect.md
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
owner: implementer
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/implementer.md
|
||||||
|
|
||||||
|
- name: review_gate
|
||||||
|
owner: reviewer
|
||||||
|
required: true
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/reviewer.json
|
||||||
|
|
||||||
|
- name: security_gate
|
||||||
|
owner: security
|
||||||
|
required: true
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/security.json
|
||||||
|
|
||||||
|
- name: qa_gate
|
||||||
|
owner: qa
|
||||||
|
required: true
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/qa.json
|
||||||
|
|
||||||
|
- name: document
|
||||||
|
owner: documenter
|
||||||
|
optional: true
|
||||||
|
when: docs/API/contracts/user-facing behavior changed
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/documenter.md
|
||||||
|
|
||||||
|
- name: close
|
||||||
|
owner: leader
|
||||||
|
required: true
|
||||||
|
output:
|
||||||
|
- work/artifacts/<feature_id>/leader-close.json
|
||||||
|
- work/history.md
|
||||||
|
|
||||||
|
close_requirements:
|
||||||
|
- reviewer.json.verdict == "APPROVED"
|
||||||
|
- security.json.verdict == "APPROVED"
|
||||||
|
- qa.json.verdict == "APPROVED"
|
||||||
|
- scripts/verify.sh exit_code == 0
|
||||||
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.
|
||||||
4
project/.gitignore
vendored
Normal file
4
project/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
0
project/.gitkeep
Normal file
0
project/.gitkeep
Normal file
6
project/.prettierignore
Normal file
6
project/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
package-lock.json
|
||||||
|
scripts/tests/fixtures/
|
||||||
|
design_prompt.md
|
||||||
6
project/.prettierrc.json
Normal file
6
project/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
38
project/README.md
Normal file
38
project/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# MercadoDeVida backend — modular monolith skeleton
|
||||||
|
|
||||||
|
TypeScript + Fastify modular monolith. Simple code, clear modules, small changes, no magic.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js >= 22
|
||||||
|
- npm
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # install dependencies
|
||||||
|
npm run build # compile to dist/
|
||||||
|
npm start # run compiled server (PORT, HOST env vars)
|
||||||
|
npm test # vitest unit/integration tests
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run lint # eslint + prettier check
|
||||||
|
npm run lint:boundaries # module boundary check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── app/ # composition root (only place that wires modules)
|
||||||
|
├── infrastructure/ # http server entrypoint (later: db, redis, providers)
|
||||||
|
├── modules/ # business modules, one folder each
|
||||||
|
│ └── health/ # exemplar module: public API only via index.ts
|
||||||
|
└── shared/ # cross-cutting helpers (error envelope)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module rules
|
||||||
|
|
||||||
|
- A module exposes its public API only through its `index.ts`.
|
||||||
|
- Files inside a module may import: own subtree, `src/shared`, Node builtins, npm packages.
|
||||||
|
- Code outside modules (app/infrastructure) may import a module only via its `index.ts`.
|
||||||
|
- `npm run lint:boundaries` enforces these rules.
|
||||||
1315
project/design_prompt.md
Normal file
1315
project/design_prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
34
project/eslint.config.mjs
Normal file
34
project/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'scripts/tests/fixtures/**'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'no-console': ['error', { allow: ['error'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.mjs'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-console': ['error', { allow: ['error', 'log'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
3732
project/package-lock.json
generated
Normal file
3732
project/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
project/package.json
Normal file
30
project/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "mercadodevida-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "MercadoDeVida vNext backend - modular monolith skeleton",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/infrastructure/http/server.js",
|
||||||
|
"lint": "eslint . && prettier --check .",
|
||||||
|
"lint:boundaries": "node scripts/check-module-boundaries.mjs src",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-prettier": "^10.0.0",
|
||||||
|
"prettier": "^3.4.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.18.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
project/scripts/check-module-boundaries.mjs
Normal file
112
project/scripts/check-module-boundaries.mjs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Module boundary checker.
|
||||||
|
*
|
||||||
|
* Rules (see specs/F-001-scaffold/DESIGN.md):
|
||||||
|
* R1: Files inside <root>/modules/<mod>/ may only import their own module
|
||||||
|
* subtree, <root>/shared/, Node builtins, or npm packages.
|
||||||
|
* R2: Files outside modules may import a module only through its index.ts.
|
||||||
|
* Deep imports into <root>/modules/<mod>/... are violations.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/check-module-boundaries.mjs <srcRoot>
|
||||||
|
* Exit codes: 0 = clean, 1 = violations found, 2 = usage error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
function isBareSpecifier(specifier) {
|
||||||
|
return !specifier.startsWith('.') && !specifier.startsWith('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, files = []) {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = path.join(dir, entry);
|
||||||
|
const stat = statSync(full);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (entry === 'node_modules' || entry === 'dist' || entry === 'fixtures') continue;
|
||||||
|
walk(full, files);
|
||||||
|
} else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) {
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSpecifiers(source) {
|
||||||
|
const specifiers = [];
|
||||||
|
const fromRegex = /(?:import|export)\s+[^'"]*?from\s*['"]([^'"]+)['"]/g;
|
||||||
|
const sideEffectRegex = /^\s*import\s*['"]([^'"]+)['"]/gm;
|
||||||
|
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = fromRegex.exec(source)) !== null) specifiers.push(match[1]);
|
||||||
|
while ((match = sideEffectRegex.exec(source)) !== null) specifiers.push(match[1]);
|
||||||
|
while ((match = dynamicRegex.exec(source)) !== null) specifiers.push(match[1]);
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip a .js/.ts extension so we can compare logical paths. */
|
||||||
|
function stripExtension(p) {
|
||||||
|
return p.replace(/\.(js|ts|mjs|cjs)$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFile(file, rootAbs, violations) {
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
const relFile = path.relative(rootAbs, file);
|
||||||
|
const fileDir = path.dirname(file);
|
||||||
|
const relFileParts = relFile.split(path.sep);
|
||||||
|
const sourceInModule =
|
||||||
|
relFileParts[0] === 'modules' && relFileParts.length >= 2 ? relFileParts[1] : null;
|
||||||
|
|
||||||
|
for (const specifier of extractSpecifiers(source)) {
|
||||||
|
if (isBareSpecifier(specifier)) continue; // npm package or node builtin
|
||||||
|
|
||||||
|
const targetAbs = stripExtension(path.resolve(fileDir, specifier));
|
||||||
|
const relTarget = path.relative(rootAbs, targetAbs);
|
||||||
|
const targetParts = relTarget.split(path.sep);
|
||||||
|
const targetInModule =
|
||||||
|
targetParts[0] === 'modules' && targetParts.length >= 2 ? targetParts[1] : null;
|
||||||
|
|
||||||
|
if (sourceInModule !== null) {
|
||||||
|
// R1: stay inside own module or go to shared
|
||||||
|
const ownModule = relTarget.startsWith(path.join('modules', sourceInModule) + path.sep);
|
||||||
|
const toShared = targetParts[0] === 'shared';
|
||||||
|
if (!ownModule && !toShared) {
|
||||||
|
violations.push(
|
||||||
|
`R1 violation: ${relFile} imports "${specifier}" (escapes module "${sourceInModule}")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (targetInModule !== null) {
|
||||||
|
// R2: outside code may only use a module's public index
|
||||||
|
const isIndex = targetParts.length === 3 && targetParts[2] === 'index';
|
||||||
|
if (!isIndex) {
|
||||||
|
violations.push(
|
||||||
|
`R2 violation: ${relFile} imports "${specifier}" (deep import into module "${targetInModule}", use its index)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const rootArg = process.argv[2];
|
||||||
|
if (!rootArg) {
|
||||||
|
console.error('Usage: node scripts/check-module-boundaries.mjs <srcRoot>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const rootAbs = path.resolve(rootArg);
|
||||||
|
const files = walk(rootAbs);
|
||||||
|
const violations = [];
|
||||||
|
for (const file of files) {
|
||||||
|
checkFile(file, rootAbs, violations);
|
||||||
|
}
|
||||||
|
if (violations.length > 0) {
|
||||||
|
for (const violation of violations) console.error(violation);
|
||||||
|
console.error(`Boundary check FAILED: ${violations.length} violation(s)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Boundary check OK: ${files.length} file(s) checked`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
49
project/scripts/tests/boundary-checker.test.ts
Normal file
49
project/scripts/tests/boundary-checker.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const scriptPath = path.resolve(here, '..', 'check-module-boundaries.mjs');
|
||||||
|
const fixturesDir = path.resolve(here, 'fixtures');
|
||||||
|
|
||||||
|
function runChecker(rootDir: string): Promise<{ code: number; output: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(
|
||||||
|
process.execPath,
|
||||||
|
[scriptPath, rootDir],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
const code = error !== null && 'code' in error ? (error.code as number) : 0;
|
||||||
|
resolve({ code, output: `${stdout}\n${stderr}` });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('check-module-boundaries', () => {
|
||||||
|
it('passes on a clean tree', async () => {
|
||||||
|
const { code, output } = await runChecker(path.join(fixturesDir, 'ok'));
|
||||||
|
expect(output).toContain('Boundary check OK');
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when a module imports another module internal file (R1)', async () => {
|
||||||
|
const { code, output } = await runChecker(path.join(fixturesDir, 'cross-module-internal'));
|
||||||
|
expect(output).toContain('R1 violation');
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when outside code deep-imports a module internal file (R2)', async () => {
|
||||||
|
const { code, output } = await runChecker(path.join(fixturesDir, 'deep-from-app'));
|
||||||
|
expect(output).toContain('R2 violation');
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows outside code to import a module index (R2 ok)', async () => {
|
||||||
|
const { code, output } = await runChecker(path.join(fixturesDir, 'ok'));
|
||||||
|
expect(output).toContain('Boundary check OK');
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
2
project/scripts/tests/fixtures/cross-module-internal/modules/alpha/api/routes.ts
vendored
Normal file
2
project/scripts/tests/fixtures/cross-module-internal/modules/alpha/api/routes.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { secret } from '../../beta/domain/secret.js';
|
||||||
|
export const routes = [secret];
|
||||||
1
project/scripts/tests/fixtures/cross-module-internal/modules/beta/domain/secret.ts
vendored
Normal file
1
project/scripts/tests/fixtures/cross-module-internal/modules/beta/domain/secret.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const secret = 'secret';
|
||||||
2
project/scripts/tests/fixtures/deep-from-app/app/main.ts
vendored
Normal file
2
project/scripts/tests/fixtures/deep-from-app/app/main.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { routes } from '../modules/alpha/api/routes.js';
|
||||||
|
export const main = [routes];
|
||||||
1
project/scripts/tests/fixtures/deep-from-app/modules/alpha/api/routes.ts
vendored
Normal file
1
project/scripts/tests/fixtures/deep-from-app/modules/alpha/api/routes.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const routes = 'routes';
|
||||||
3
project/scripts/tests/fixtures/ok/app/main.ts
vendored
Normal file
3
project/scripts/tests/fixtures/ok/app/main.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { alpha } from '../modules/alpha/index.js';
|
||||||
|
import { beta } from '../modules/beta/index.js';
|
||||||
|
export const main = [alpha, beta];
|
||||||
3
project/scripts/tests/fixtures/ok/modules/alpha/api/routes.ts
vendored
Normal file
3
project/scripts/tests/fixtures/ok/modules/alpha/api/routes.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { alpha } from '../index.js';
|
||||||
|
import { sharedUtil } from '../../../shared/util.js';
|
||||||
|
export const routes = [alpha, sharedUtil];
|
||||||
1
project/scripts/tests/fixtures/ok/modules/alpha/index.ts
vendored
Normal file
1
project/scripts/tests/fixtures/ok/modules/alpha/index.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const alpha = 'alpha';
|
||||||
1
project/scripts/tests/fixtures/ok/modules/beta/index.ts
vendored
Normal file
1
project/scripts/tests/fixtures/ok/modules/beta/index.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const beta = 'beta';
|
||||||
1
project/scripts/tests/fixtures/ok/shared/util.ts
vendored
Normal file
1
project/scripts/tests/fixtures/ok/shared/util.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const sharedUtil = 'shared';
|
||||||
31
project/src/app/build-app.ts
Normal file
31
project/src/app/build-app.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||||
|
import { errorEnvelope } from '../shared/errors.js';
|
||||||
|
|
||||||
|
function notFoundHandler(_request: FastifyRequest, reply: FastifyReply): void {
|
||||||
|
void reply.code(404).send(errorEnvelope(404, 'Not Found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorHandler(error: FastifyError, _request: FastifyRequest, reply: FastifyReply): void {
|
||||||
|
const statusCode =
|
||||||
|
error.statusCode !== undefined && error.statusCode >= 400 ? error.statusCode : 500;
|
||||||
|
const message = statusCode >= 500 ? 'Internal Server Error' : error.message;
|
||||||
|
void reply.code(statusCode).send(errorEnvelope(statusCode, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition root. The only place allowed to wire modules together.
|
||||||
|
*/
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
app.setNotFoundHandler(notFoundHandler);
|
||||||
|
|
||||||
|
await app.register(async (instance) => {
|
||||||
|
await registerHealthRoutes(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
25
project/src/app/tests/build-app.test.ts
Normal file
25
project/src/app/tests/build-app.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildApp } from '../build-app.js';
|
||||||
|
|
||||||
|
describe('composition root', () => {
|
||||||
|
it('exposes GET /health through the wired app', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ status: 'ok' });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 JSON envelope for unknown routes without leaking internals', async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/does-not-exist' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
const body = response.json() as { error: { statusCode: number; message: string } };
|
||||||
|
expect(body.error.statusCode).toBe(404);
|
||||||
|
expect(body.error.message).toBe('Not Found');
|
||||||
|
expect(response.body).not.toContain('stack');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
project/src/infrastructure/http/server.ts
Normal file
12
project/src/infrastructure/http/server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { buildApp } from '../../app/build-app.js';
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
const host = process.env.HOST ?? '0.0.0.0';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await buildApp();
|
||||||
|
await app.listen({ port, host });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start HTTP server', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
11
project/src/modules/health/api/health.routes.ts
Normal file
11
project/src/modules/health/api/health.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status: 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/health', async (): Promise<HealthResponse> => {
|
||||||
|
return { status: 'ok' };
|
||||||
|
});
|
||||||
|
}
|
||||||
5
project/src/modules/health/index.ts
Normal file
5
project/src/modules/health/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Public API of the health module. Everything a module exposes to the
|
||||||
|
* outside world goes through this file.
|
||||||
|
*/
|
||||||
|
export { registerHealthRoutes } from './api/health.routes.js';
|
||||||
18
project/src/modules/health/tests/health.test.ts
Normal file
18
project/src/modules/health/tests/health.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { registerHealthRoutes } from '../index.js';
|
||||||
|
|
||||||
|
describe('health module API', () => {
|
||||||
|
it('GET /health returns 200 and status ok', async () => {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(async (instance) => {
|
||||||
|
await registerHealthRoutes(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ status: 'ok' });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
project/src/shared/errors.ts
Normal file
15
project/src/shared/errors.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Shared error envelope. Single shape for every API error.
|
||||||
|
* Never leak stack traces or internal details to the client.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ErrorEnvelope {
|
||||||
|
error: {
|
||||||
|
statusCode: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorEnvelope(statusCode: number, message: string): ErrorEnvelope {
|
||||||
|
return { error: { statusCode, message } };
|
||||||
|
}
|
||||||
12
project/tsconfig.build.json
Normal file
12
project/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
18
project/tsconfig.json
Normal file
18
project/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "scripts/tests/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "scripts/tests/fixtures"]
|
||||||
|
}
|
||||||
9
project/vitest.config.ts
Normal file
9
project/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts', 'scripts/tests/**/*.test.ts'],
|
||||||
|
exclude: ['node_modules/**', 'dist/**', 'scripts/tests/fixtures/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
371
scripts/agent_status.py
Executable file
371
scripts/agent_status.py
Executable file
@@ -0,0 +1,371 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
STATUS_PATH = ROOT / 'work' / 'runtime-status.json'
|
||||||
|
MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml'
|
||||||
|
WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml'
|
||||||
|
ARTIFACTS_DIR = ROOT / 'work' / 'artifacts'
|
||||||
|
VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'}
|
||||||
|
|
||||||
|
DEFAULT_EMOJIS = {
|
||||||
|
'leader': '🧭',
|
||||||
|
'triager': '🧩',
|
||||||
|
'architect': '🏗️',
|
||||||
|
'implementer': '🛠️',
|
||||||
|
'reviewer': '🔍',
|
||||||
|
'security': '🔒',
|
||||||
|
'qa': '🧪',
|
||||||
|
'documenter': '📝',
|
||||||
|
}
|
||||||
|
|
||||||
|
GATE_FILES = {
|
||||||
|
'reviewer': 'reviewer.json',
|
||||||
|
'security': 'security.json',
|
||||||
|
'qa': 'qa.json',
|
||||||
|
'leader': 'leader-close.json',
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENT_FILES = {
|
||||||
|
'architect': 'architect.md',
|
||||||
|
'implementer': 'implementer.md',
|
||||||
|
'reviewer': 'reviewer.json',
|
||||||
|
'security': 'security.json',
|
||||||
|
'qa': 'qa.json',
|
||||||
|
'documenter': 'documenter.md',
|
||||||
|
'leader': 'leader-close.json',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso():
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, default=None):
|
||||||
|
if not path.exists():
|
||||||
|
return default
|
||||||
|
return json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, payload):
|
||||||
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def load_role_emojis():
|
||||||
|
emojis = dict(DEFAULT_EMOJIS)
|
||||||
|
if not MATRIX_PATH.exists():
|
||||||
|
return emojis
|
||||||
|
current_role = None
|
||||||
|
for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines():
|
||||||
|
match_role = re.match(r'^ ([a-z_]+):\s*$', line)
|
||||||
|
if match_role:
|
||||||
|
current_role = match_role.group(1)
|
||||||
|
continue
|
||||||
|
match_emoji = re.match(r'^\s{4}emoji:\s*["\']?(.*?)["\']?\s*$', line)
|
||||||
|
if match_emoji and current_role:
|
||||||
|
emojis[current_role] = match_emoji.group(1)
|
||||||
|
return emojis
|
||||||
|
|
||||||
|
|
||||||
|
def load_roles():
|
||||||
|
roles = []
|
||||||
|
if not MATRIX_PATH.exists():
|
||||||
|
return roles
|
||||||
|
for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines():
|
||||||
|
match_role = re.match(r'^ ([a-z_]+):\s*$', line)
|
||||||
|
if match_role:
|
||||||
|
roles.append(match_role.group(1))
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
def load_stage_names():
|
||||||
|
stages = []
|
||||||
|
if not WORKFLOW_PATH.exists():
|
||||||
|
return stages
|
||||||
|
for line in WORKFLOW_PATH.read_text(encoding='utf-8').splitlines():
|
||||||
|
match_stage = re.match(r'^ - name:\s*([a-z_]+)\s*$', line)
|
||||||
|
if match_stage:
|
||||||
|
stages.append(match_stage.group(1))
|
||||||
|
return stages
|
||||||
|
|
||||||
|
|
||||||
|
def default_status():
|
||||||
|
return {
|
||||||
|
'feature_id': None,
|
||||||
|
'stage': 'idle',
|
||||||
|
'agent': 'leader',
|
||||||
|
'action': 'Sin ejecución activa',
|
||||||
|
'state': 'waiting',
|
||||||
|
'next_agent': 'leader',
|
||||||
|
'waiting_for': 'Seleccionar una feature pending y actualizar este estado',
|
||||||
|
'updated_at': now_iso(),
|
||||||
|
'timeline': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_status():
|
||||||
|
status = load_json(STATUS_PATH, default_status())
|
||||||
|
base = default_status()
|
||||||
|
for key, value in base.items():
|
||||||
|
status.setdefault(key, value)
|
||||||
|
if not isinstance(status.get('timeline'), list):
|
||||||
|
status['timeline'] = []
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def gate_status(feature_id):
|
||||||
|
gates = {}
|
||||||
|
if not feature_id:
|
||||||
|
return gates
|
||||||
|
feature_dir = ARTIFACTS_DIR / feature_id
|
||||||
|
for gate, filename in GATE_FILES.items():
|
||||||
|
path = feature_dir / filename
|
||||||
|
if not path.exists():
|
||||||
|
gates[gate] = 'pending'
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present'
|
||||||
|
except Exception:
|
||||||
|
gates[gate] = 'invalid'
|
||||||
|
return gates
|
||||||
|
|
||||||
|
|
||||||
|
def render_gate(gate, state, emojis):
|
||||||
|
icon = {
|
||||||
|
'approved': '✅',
|
||||||
|
'pending': '⏳',
|
||||||
|
'present': '⚠️',
|
||||||
|
'invalid': '❌',
|
||||||
|
}.get(state, '•')
|
||||||
|
label = {
|
||||||
|
'leader': 'close',
|
||||||
|
}.get(gate, gate)
|
||||||
|
return f"{icon} {emojis.get(gate, '•')} {label}: {state.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_status(feature_id, agent):
|
||||||
|
if not feature_id:
|
||||||
|
return 'pending'
|
||||||
|
filename = AGENT_FILES.get(agent)
|
||||||
|
if not filename:
|
||||||
|
return 'pending'
|
||||||
|
path = ARTIFACTS_DIR / feature_id / filename
|
||||||
|
if not path.exists():
|
||||||
|
return 'pending'
|
||||||
|
if filename.endswith('.md'):
|
||||||
|
return 'done'
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
return 'done' if payload.get('verdict') == 'APPROVED' else 'present'
|
||||||
|
except Exception:
|
||||||
|
return 'invalid'
|
||||||
|
|
||||||
|
|
||||||
|
def render_artifact(agent, state, current_agent, emojis):
|
||||||
|
icon = {
|
||||||
|
'done': '✅',
|
||||||
|
'pending': '▶️' if agent == current_agent else '⏳',
|
||||||
|
'present': '⚠️',
|
||||||
|
'invalid': '❌',
|
||||||
|
}.get(state, '•')
|
||||||
|
return f"{icon} {emojis.get(agent, '•')} {agent}: {state.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
STAGE_OWNERS = {
|
||||||
|
'idle': 'leader',
|
||||||
|
'intake': 'leader',
|
||||||
|
'design': 'architect',
|
||||||
|
'build': 'implementer',
|
||||||
|
'review_gate': 'reviewer',
|
||||||
|
'security_gate': 'security',
|
||||||
|
'qa_gate': 'qa',
|
||||||
|
'document': 'documenter',
|
||||||
|
'close': 'leader',
|
||||||
|
}
|
||||||
|
|
||||||
|
STAGE_PREREQUISITES = {
|
||||||
|
'review_gate': [('implementer.md', None)],
|
||||||
|
'security_gate': [('implementer.md', None), ('reviewer.json', 'APPROVED')],
|
||||||
|
'qa_gate': [('implementer.md', None), ('reviewer.json', 'APPROVED'), ('security.json', 'APPROVED')],
|
||||||
|
'document': [('implementer.md', None), ('reviewer.json', 'APPROVED'), ('security.json', 'APPROVED'), ('qa.json', 'APPROVED')],
|
||||||
|
'close': [('implementer.md', None), ('reviewer.json', 'APPROVED'), ('security.json', 'APPROVED'), ('qa.json', 'APPROVED')],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_ok(feature_id, filename, verdict=None):
|
||||||
|
path = ARTIFACTS_DIR / feature_id / filename
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
if verdict is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding='utf-8')).get('verdict') == verdict
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_stage_transition(status, args):
|
||||||
|
stage = args.stage or status.get('stage') or 'idle'
|
||||||
|
agent = args.agent or status.get('agent') or STAGE_OWNERS.get(stage)
|
||||||
|
feature_id = args.feature_id if args.feature_id is not None else status.get('feature_id')
|
||||||
|
|
||||||
|
expected_owner = STAGE_OWNERS.get(stage)
|
||||||
|
if expected_owner and agent != expected_owner:
|
||||||
|
raise SystemExit(f"Stage {stage} debe ejecutarlo {expected_owner}, no {agent}")
|
||||||
|
|
||||||
|
if stage != 'idle' and not feature_id:
|
||||||
|
raise SystemExit(f"Stage {stage} requiere feature_id")
|
||||||
|
|
||||||
|
if feature_id:
|
||||||
|
for filename, verdict in STAGE_PREREQUISITES.get(stage, []):
|
||||||
|
if not artifact_ok(feature_id, filename, verdict):
|
||||||
|
suffix = f" con verdict {verdict}" if verdict else ""
|
||||||
|
raise SystemExit(f"No se puede entrar en {stage}: falta {filename}{suffix} para {feature_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime_args(args):
|
||||||
|
roles = set(load_roles()) or set(DEFAULT_EMOJIS)
|
||||||
|
stages = set(load_stage_names()) | {'idle'}
|
||||||
|
|
||||||
|
if args.agent is not None and args.agent not in roles:
|
||||||
|
raise SystemExit(f"Invalid agent: {args.agent}. Allowed: {', '.join(sorted(roles))}")
|
||||||
|
if args.next_agent is not None and args.next_agent not in roles:
|
||||||
|
raise SystemExit(f"Invalid next-agent: {args.next_agent}. Allowed: {', '.join(sorted(roles))}")
|
||||||
|
if args.stage is not None and args.stage not in stages:
|
||||||
|
raise SystemExit(f"Invalid stage: {args.stage}. Allowed: {', '.join(sorted(stages))}")
|
||||||
|
if args.state is not None and args.state not in VALID_RUNTIME_STATES:
|
||||||
|
raise SystemExit(f"Invalid state: {args.state}. Allowed: {', '.join(sorted(VALID_RUNTIME_STATES))}")
|
||||||
|
|
||||||
|
|
||||||
|
def show_status():
|
||||||
|
status = load_status()
|
||||||
|
emojis = load_role_emojis()
|
||||||
|
feature_id = status.get('feature_id') or '—'
|
||||||
|
current_agent = status.get('agent', 'leader')
|
||||||
|
next_agent = status.get('next_agent') or '—'
|
||||||
|
gates = gate_status(status.get('feature_id'))
|
||||||
|
|
||||||
|
print('╔══════════════════════════════════════════════════════════════╗')
|
||||||
|
print('║ Orquestra · Runtime Status ║')
|
||||||
|
print('╚══════════════════════════════════════════════════════════════╝')
|
||||||
|
print(f"Feature activa : {feature_id}")
|
||||||
|
print(f"Stage actual : {status.get('stage', '—')}")
|
||||||
|
print(f"Agente actual : {emojis.get(current_agent, '•')} {current_agent}")
|
||||||
|
print(f"Acción : {status.get('action', '—')}")
|
||||||
|
print(f"Estado : {status.get('state', '—')}")
|
||||||
|
print(f"Siguiente : {emojis.get(next_agent, '•')} {next_agent}")
|
||||||
|
print(f"Esperando : {status.get('waiting_for', '—')}")
|
||||||
|
print(f"Actualizado : {status.get('updated_at', '—')}")
|
||||||
|
print()
|
||||||
|
print('Gates')
|
||||||
|
if gates:
|
||||||
|
for gate in ['reviewer', 'security', 'qa', 'leader']:
|
||||||
|
print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}")
|
||||||
|
else:
|
||||||
|
print(' — Sin feature activa —')
|
||||||
|
print()
|
||||||
|
print('Artifacts')
|
||||||
|
if status.get('feature_id'):
|
||||||
|
for agent in ['architect', 'implementer', 'reviewer', 'security', 'qa', 'documenter', 'leader']:
|
||||||
|
state = artifact_status(status.get('feature_id'), agent)
|
||||||
|
print(f" {render_artifact(agent, state, current_agent, emojis)}")
|
||||||
|
else:
|
||||||
|
print(' — Sin feature activa —')
|
||||||
|
print()
|
||||||
|
print('Timeline')
|
||||||
|
timeline = status.get('timeline', [])[-8:]
|
||||||
|
if not timeline:
|
||||||
|
print(' — Sin eventos —')
|
||||||
|
return
|
||||||
|
for item in timeline:
|
||||||
|
agent = item.get('agent', 'leader')
|
||||||
|
emoji = emojis.get(agent, '•')
|
||||||
|
ts = item.get('ts', '—')
|
||||||
|
stage = item.get('stage', '—')
|
||||||
|
state = item.get('state', '—')
|
||||||
|
message = item.get('message', '')
|
||||||
|
print(f" - {ts} · {emoji} {agent} · {stage} · {state} · {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(args):
|
||||||
|
validate_runtime_args(args)
|
||||||
|
status = load_status()
|
||||||
|
validate_stage_transition(status, args)
|
||||||
|
if args.feature_id is not None:
|
||||||
|
status['feature_id'] = args.feature_id or None
|
||||||
|
if args.stage is not None:
|
||||||
|
status['stage'] = args.stage
|
||||||
|
if args.agent is not None:
|
||||||
|
status['agent'] = args.agent
|
||||||
|
if args.action is not None:
|
||||||
|
status['action'] = args.action
|
||||||
|
if args.state is not None:
|
||||||
|
status['state'] = args.state
|
||||||
|
if args.next_agent is not None:
|
||||||
|
status['next_agent'] = args.next_agent
|
||||||
|
if args.waiting_for is not None:
|
||||||
|
status['waiting_for'] = args.waiting_for
|
||||||
|
|
||||||
|
status['updated_at'] = now_iso()
|
||||||
|
event_message = args.note or status.get('action') or 'Estado actualizado'
|
||||||
|
status['timeline'].append({
|
||||||
|
'ts': status['updated_at'],
|
||||||
|
'agent': status.get('agent', 'leader'),
|
||||||
|
'stage': status.get('stage', '—'),
|
||||||
|
'state': status.get('state', '—'),
|
||||||
|
'message': event_message,
|
||||||
|
})
|
||||||
|
status['timeline'] = status['timeline'][-20:]
|
||||||
|
save_json(STATUS_PATH, status)
|
||||||
|
show_status()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_status(_args):
|
||||||
|
status = default_status()
|
||||||
|
status['updated_at'] = now_iso()
|
||||||
|
save_json(STATUS_PATH, status)
|
||||||
|
show_status()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(description='Renderiza y actualiza el estado visible de Orquestra.')
|
||||||
|
sub = parser.add_subparsers(dest='command', required=True)
|
||||||
|
|
||||||
|
sub.add_parser('show', help='Muestra el panel visible de estado')
|
||||||
|
|
||||||
|
set_parser = sub.add_parser('set', help='Actualiza el estado runtime y añade evento a timeline')
|
||||||
|
set_parser.add_argument('--feature-id')
|
||||||
|
set_parser.add_argument('--stage')
|
||||||
|
set_parser.add_argument('--agent')
|
||||||
|
set_parser.add_argument('--action')
|
||||||
|
set_parser.add_argument('--state')
|
||||||
|
set_parser.add_argument('--next-agent')
|
||||||
|
set_parser.add_argument('--waiting-for')
|
||||||
|
set_parser.add_argument('--note')
|
||||||
|
|
||||||
|
sub.add_parser('reset', help='Resetea el estado runtime a idle')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == 'show':
|
||||||
|
show_status()
|
||||||
|
elif args.command == 'set':
|
||||||
|
set_status(args)
|
||||||
|
elif args.command == 'reset':
|
||||||
|
reset_status(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
165
scripts/install.sh
Executable file
165
scripts/install.sh
Executable file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
banner() {
|
||||||
|
cat <<'EOF'
|
||||||
|
___ ____ ___ _ _ _____ ____ _____ ____ _
|
||||||
|
/ _ \| _ \ / _ \| | | | ____/ ___|_ _| _ \ / \
|
||||||
|
| | | | |_) | | | | | | | _| \___ \ | | | |_) | / _ \
|
||||||
|
| |_| | _ <| |_| | |_| | |___ ___) || | | _ < / ___ \
|
||||||
|
\___/|_| \_\\__\_\\___/|_____|____/ |_| |_| \_\/_/ \_\
|
||||||
|
|
||||||
|
sequential orchestration runtime
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: ./scripts/install.sh /path/to/project-repo
|
||||||
|
|
||||||
|
Install or update Orquestra from this source checkout into a target project repo.
|
||||||
|
Project-owned progress files are created only when missing and are never overwritten.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '[FAIL] %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
printf '[WARN] %s\n' "$1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
printf '[OK] %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_file_update() {
|
||||||
|
local src=$1
|
||||||
|
local dst=$2
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
cp "$src" "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_file_if_missing() {
|
||||||
|
local src=$1
|
||||||
|
local dst=$2
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
if [ ! -e "$dst" ]; then
|
||||||
|
cp "$src" "$dst"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_dir_update() {
|
||||||
|
local src=$1
|
||||||
|
local dst=$2
|
||||||
|
mkdir -p "$dst"
|
||||||
|
cp -R "$src"/. "$dst"/
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_dir_if_missing_contents() {
|
||||||
|
local src=$1
|
||||||
|
local dst=$2
|
||||||
|
local dir file rel
|
||||||
|
mkdir -p "$dst"
|
||||||
|
if [ -d "$src" ]; then
|
||||||
|
find "$src" -type d | while IFS= read -r dir; do
|
||||||
|
rel=${dir#"$src"}
|
||||||
|
mkdir -p "$dst$rel"
|
||||||
|
done
|
||||||
|
find "$src" -type f | while IFS= read -r file; do
|
||||||
|
rel=${file#"$src"/}
|
||||||
|
copy_file_if_missing "$file" "$dst/$rel"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
append_gitignore_block() {
|
||||||
|
local gitignore=$1
|
||||||
|
touch "$gitignore"
|
||||||
|
if ! grep -q '^# BEGIN ORQUESTRA$' "$gitignore"; then
|
||||||
|
cat >>"$gitignore" <<'EOF'
|
||||||
|
|
||||||
|
# BEGIN ORQUESTRA
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.codegraph/
|
||||||
|
.atl/
|
||||||
|
# END ORQUESTRA
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
banner
|
||||||
|
|
||||||
|
command -v pi >/dev/null 2>&1 || fail "pi is required in PATH before installing Orquestra"
|
||||||
|
command -v python3 >/dev/null 2>&1 || fail "python3 is required in PATH before installing Orquestra"
|
||||||
|
|
||||||
|
SOURCE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||||
|
TARGET_ARG=$1
|
||||||
|
TARGET_DIR=$(python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$TARGET_ARG")
|
||||||
|
|
||||||
|
if [ "$TARGET_DIR" = "$SOURCE_DIR" ]; then
|
||||||
|
fail "target repo must be different from the Orquestra source repo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
|
||||||
|
if [ ! -d "$TARGET_DIR/.git" ]; then
|
||||||
|
git -C "$TARGET_DIR" init >/dev/null
|
||||||
|
info "Initialized git repo in $TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Harness-owned files: update on every run.
|
||||||
|
for file in AGENTS.md README.md HOWTO.md CHECKPOINTS.md; do
|
||||||
|
copy_file_update "$SOURCE_DIR/$file" "$TARGET_DIR/$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
copy_dir_update "$SOURCE_DIR/harness" "$TARGET_DIR/harness"
|
||||||
|
copy_dir_update "$SOURCE_DIR/platforms/pi" "$TARGET_DIR/platforms/pi"
|
||||||
|
|
||||||
|
for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/install.sh; do
|
||||||
|
copy_file_update "$SOURCE_DIR/$file" "$TARGET_DIR/$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
append_gitignore_block "$TARGET_DIR/.gitignore"
|
||||||
|
|
||||||
|
# Project-owned/state files: create only when missing.
|
||||||
|
copy_dir_if_missing_contents "$SOURCE_DIR/project" "$TARGET_DIR/project"
|
||||||
|
copy_file_if_missing "$SOURCE_DIR/backlog/features.json" "$TARGET_DIR/backlog/features.json"
|
||||||
|
copy_dir_if_missing_contents "$SOURCE_DIR/spec" "$TARGET_DIR/spec"
|
||||||
|
copy_file_if_missing "$SOURCE_DIR/work/current.md" "$TARGET_DIR/work/current.md"
|
||||||
|
copy_file_if_missing "$SOURCE_DIR/work/history.md" "$TARGET_DIR/work/history.md"
|
||||||
|
copy_file_if_missing "$SOURCE_DIR/work/runtime-status.json" "$TARGET_DIR/work/runtime-status.json"
|
||||||
|
mkdir -p "$TARGET_DIR/work/artifacts"
|
||||||
|
if [ -f "$SOURCE_DIR/work/artifacts/.gitkeep" ] && [ ! -e "$TARGET_DIR/work/artifacts/.gitkeep" ]; then
|
||||||
|
cp "$SOURCE_DIR/work/artifacts/.gitkeep" "$TARGET_DIR/work/artifacts/.gitkeep"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pi project-local runtime extensions.
|
||||||
|
mkdir -p "$TARGET_DIR/.pi/extensions"
|
||||||
|
copy_dir_update "$SOURCE_DIR/platforms/pi/extensions/orquestra-status" "$TARGET_DIR/.pi/extensions/orquestra-status"
|
||||||
|
copy_file_update "$SOURCE_DIR/platforms/pi/extensions/orquestra-web-fetch.ts" "$TARGET_DIR/.pi/extensions/orquestra-web-fetch.ts"
|
||||||
|
|
||||||
|
if [ -d "$TARGET_DIR/.pi/subagents" ] || [ -f "$TARGET_DIR/.pi/subagents.json" ]; then
|
||||||
|
warn "Found .pi/subagents or .pi/subagents.json in target. Orquestra does not delete user Pi config automatically; remove Orquestra-owned stale subagent files manually before running verify.sh."
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$TARGET_DIR/scripts/verify.sh" "$TARGET_DIR/scripts/pi_orquestra.sh" "$TARGET_DIR/scripts/install.sh"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Orquestra installed/updated safely in: $TARGET_DIR
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
cd "$TARGET_DIR"
|
||||||
|
./scripts/verify.sh
|
||||||
|
./scripts/pi_orquestra.sh
|
||||||
|
EOF
|
||||||
102
scripts/new_ticket.py
Executable file
102
scripts/new_ticket.py
Executable file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BACKLOG = ROOT / 'backlog' / 'features.json'
|
||||||
|
TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore')
|
||||||
|
LEVEL_CHOICES = ('low', 'med', 'high')
|
||||||
|
|
||||||
|
|
||||||
|
def ask(prompt, default=''):
|
||||||
|
value = input(f"{prompt}{' [' + default + ']' if default else ''}: ").strip()
|
||||||
|
return value if value else default
|
||||||
|
|
||||||
|
|
||||||
|
def ask_choice(prompt, choices, default):
|
||||||
|
while True:
|
||||||
|
value = ask(prompt, default).lower()
|
||||||
|
if value in choices:
|
||||||
|
return value
|
||||||
|
print(f"Invalid value. Use one of: {', '.join(choices)}")
|
||||||
|
|
||||||
|
|
||||||
|
def ask_list(prompt, default_csv=''):
|
||||||
|
raw = ask(prompt, default_csv)
|
||||||
|
return [item.strip() for item in raw.split(',') if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def next_id(features):
|
||||||
|
nums = []
|
||||||
|
for feature in features:
|
||||||
|
fid = str(feature.get('id', ''))
|
||||||
|
if fid.startswith('F-') and fid[2:].isdigit():
|
||||||
|
nums.append(int(fid[2:]))
|
||||||
|
return f"F-{(max(nums) + 1) if nums else 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = json.loads(BACKLOG.read_text(encoding='utf-8'))
|
||||||
|
features = data.get('features', [])
|
||||||
|
|
||||||
|
print('Create ticket (English caveman style).')
|
||||||
|
ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature')
|
||||||
|
title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO')
|
||||||
|
problem = ask('Problem (short EN)', 'Need change')
|
||||||
|
goal = ask('Goal (short EN)', 'Make flow better')
|
||||||
|
scope_in = ask_list('Scope IN (comma list EN)', 'Core flow')
|
||||||
|
scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign')
|
||||||
|
risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low')
|
||||||
|
priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med')
|
||||||
|
|
||||||
|
print('Acceptance bullets (EN caveman). Empty line to end.')
|
||||||
|
acceptance = []
|
||||||
|
while True:
|
||||||
|
line = input('- ').strip()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
acceptance.append(line)
|
||||||
|
|
||||||
|
if not acceptance:
|
||||||
|
acceptance = [
|
||||||
|
'Flow works end to end',
|
||||||
|
'No break old behavior',
|
||||||
|
'verify.sh is green',
|
||||||
|
]
|
||||||
|
|
||||||
|
fid = next_id(features)
|
||||||
|
desc = (
|
||||||
|
f"Problem: {problem}. "
|
||||||
|
f"Goal: {goal}. "
|
||||||
|
f"Scope IN: {', '.join(scope_in) or 'none'}. "
|
||||||
|
f"Scope OUT: {', '.join(scope_out) or 'none'}. "
|
||||||
|
f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}."
|
||||||
|
)
|
||||||
|
|
||||||
|
features.append({
|
||||||
|
'id': fid,
|
||||||
|
'type': ticket_type,
|
||||||
|
'title': title,
|
||||||
|
'problem': problem,
|
||||||
|
'goal': goal,
|
||||||
|
'scope_in': scope_in,
|
||||||
|
'scope_out': scope_out,
|
||||||
|
'priority': priority,
|
||||||
|
'risk': risk,
|
||||||
|
'description': desc,
|
||||||
|
'acceptance': acceptance,
|
||||||
|
'status': 'pending',
|
||||||
|
'created_at': str(date.today()),
|
||||||
|
'gates': {'review': False, 'security': False, 'qa': False},
|
||||||
|
})
|
||||||
|
|
||||||
|
data['features'] = features
|
||||||
|
rules = data.setdefault('rules', {})
|
||||||
|
rules.setdefault('valid_types', list(TYPE_CHOICES))
|
||||||
|
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
|
print(f'Created {fid}: {title}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
21
scripts/pi_orquestra.sh
Executable file
21
scripts/pi_orquestra.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if ! command -v pi >/dev/null 2>&1; then
|
||||||
|
echo "[FAIL] pi is required before running Orquestra" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATUS_EXT=".pi/extensions/orquestra-status"
|
||||||
|
WEB_FETCH_EXT=".pi/extensions/orquestra-web-fetch.ts"
|
||||||
|
|
||||||
|
if [ ! -f "$STATUS_EXT/index.ts" ] || [ ! -f "$WEB_FETCH_EXT" ]; then
|
||||||
|
echo "[FAIL] Orquestra Pi extensions are not installed in this project." >&2
|
||||||
|
echo " Run: /path/to/orquestra/scripts/install.sh $ROOT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec pi --no-extensions --no-skills -e "$STATUS_EXT" -e "$WEB_FETCH_EXT" "$@"
|
||||||
215
scripts/verify.sh
Executable file
215
scripts/verify.sh
Executable file
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
ok() { printf "${GREEN}[OK]${NC} %s\n" "$1"; }
|
||||||
|
warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1"; }
|
||||||
|
fail() { printf "${RED}[FAIL]${NC} %s\n" "$1"; }
|
||||||
|
|
||||||
|
EXIT_CODE=0
|
||||||
|
|
||||||
|
echo "── 1) Verificando estructura base ─────────────────────"
|
||||||
|
required=(
|
||||||
|
"AGENTS.md"
|
||||||
|
"CHECKPOINTS.md"
|
||||||
|
"harness/agents.matrix.yml"
|
||||||
|
"harness/workflow.stages.yml"
|
||||||
|
"harness/policies/governance.md"
|
||||||
|
"harness/policies/security.md"
|
||||||
|
"harness/policies/quality.md"
|
||||||
|
"harness/contracts/handoff.md"
|
||||||
|
"harness/contracts/evidence.schema.json"
|
||||||
|
"harness/model-routing.yml"
|
||||||
|
"spec/product.md"
|
||||||
|
"spec/tech.md"
|
||||||
|
"spec/acceptance.md"
|
||||||
|
"backlog/features.json"
|
||||||
|
"work/current.md"
|
||||||
|
"work/history.md"
|
||||||
|
"work/runtime-status.json"
|
||||||
|
"scripts/install.sh"
|
||||||
|
"scripts/agent_status.py"
|
||||||
|
"scripts/new_ticket.py"
|
||||||
|
"scripts/pi_orquestra.sh"
|
||||||
|
"platforms/pi/README.md"
|
||||||
|
"platforms/pi/extensions/orquestra-status/index.ts"
|
||||||
|
"platforms/pi/extensions/orquestra-web-fetch.ts"
|
||||||
|
)
|
||||||
|
|
||||||
|
for f in "${required[@]}"; do
|
||||||
|
if [ -f "$f" ]; then
|
||||||
|
ok "Existe $f"
|
||||||
|
else
|
||||||
|
fail "Falta $f"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
required_dirs=("project")
|
||||||
|
for d in "${required_dirs[@]}"; do
|
||||||
|
if [ -d "$d" ]; then
|
||||||
|
ok "Existe directorio $d"
|
||||||
|
else
|
||||||
|
fail "Falta directorio $d"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
root_product_files=$(find . -mindepth 1 -maxdepth 1 -type f \( \
|
||||||
|
-name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.go' -o -name '*.rs' -o \
|
||||||
|
-name '*.java' -o -name '*.php' -o -name '*.rb' \
|
||||||
|
\) -print 2>/dev/null)
|
||||||
|
if [ -n "$root_product_files" ]; then
|
||||||
|
fail "Archivos de producto/código en la raíz no permitidos; usar project/: $root_product_files"
|
||||||
|
EXIT_CODE=1
|
||||||
|
else
|
||||||
|
ok "Sin archivos de producto/código en la raíz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── 2) Validando requisitos Pi ─────────────────────────"
|
||||||
|
if command -v pi >/dev/null 2>&1; then
|
||||||
|
ok "Pi instalado: $(command -v pi)"
|
||||||
|
else
|
||||||
|
fail "Pi es obligatorio antes de instalar Orquestra"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d ".pi/subagents" ] || [ -f ".pi/subagents.json" ]; then
|
||||||
|
fail "Orquestra es secuencial: no debe instalar .pi/subagents ni .pi/subagents.json"
|
||||||
|
EXIT_CODE=1
|
||||||
|
else
|
||||||
|
ok "Sin subagentes project-local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d ".pi/extensions" ]; then
|
||||||
|
extra_extensions=$(find .pi/extensions -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name orquestra-status \
|
||||||
|
! -name orquestra-web-fetch.ts \
|
||||||
|
-print 2>/dev/null)
|
||||||
|
if [ -n "$extra_extensions" ]; then
|
||||||
|
fail "Extensiones project-local no declaradas: $extra_extensions"
|
||||||
|
EXIT_CODE=1
|
||||||
|
else
|
||||||
|
ok "Extensiones project-local declaradas"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Sin .pi/extensions; la instalación debe copiar extensiones Orquestra en proyectos"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── 3) Validando backlog + runtime ─────────────────────"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = pathlib.Path('.')
|
||||||
|
path = root / 'backlog' / 'features.json'
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] backlog/features.json inválido: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"]))
|
||||||
|
features = data.get('features', [])
|
||||||
|
if not isinstance(features, list):
|
||||||
|
print('[FAIL] features debe ser una lista')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
in_progress = [f for f in features if f.get('status') == 'in_progress']
|
||||||
|
if len(in_progress) > 1:
|
||||||
|
print(f"[FAIL] Hay {len(in_progress)} features in_progress (máximo 1)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
for f in features:
|
||||||
|
fid = str(f.get('id', '')).strip()
|
||||||
|
status = f.get('status')
|
||||||
|
if status not in valid:
|
||||||
|
print(f"[FAIL] Estado inválido en feature {fid}: {status}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if status == 'done':
|
||||||
|
d = root / 'work' / 'artifacts' / fid
|
||||||
|
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json']
|
||||||
|
missing = [name for name in req if not (d / name).is_file()]
|
||||||
|
if missing:
|
||||||
|
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
'reviewer.json': 'reviewer',
|
||||||
|
'security.json': 'security',
|
||||||
|
'qa.json': 'qa',
|
||||||
|
'leader-close.json': 'leader',
|
||||||
|
}
|
||||||
|
for filename, agent in expected.items():
|
||||||
|
try:
|
||||||
|
obj = json.loads((d / filename).read_text(encoding='utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] {fid}/{filename} inválido: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if obj.get('agent') != agent:
|
||||||
|
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
|
||||||
|
sys.exit(1)
|
||||||
|
if obj.get('verdict') != 'APPROVED':
|
||||||
|
print(f"[FAIL] {fid}/{filename} no está APPROVED")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
runtime_path = root / 'work' / 'runtime-status.json'
|
||||||
|
try:
|
||||||
|
runtime = json.loads(runtime_path.read_text(encoding='utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] work/runtime-status.json inválido: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
required_runtime = ['feature_id', 'stage', 'agent', 'action', 'state', 'next_agent', 'waiting_for', 'updated_at', 'timeline']
|
||||||
|
missing_runtime = [key for key in required_runtime if key not in runtime]
|
||||||
|
if missing_runtime:
|
||||||
|
print(f"[FAIL] work/runtime-status.json incompleto: {', '.join(missing_runtime)}")
|
||||||
|
sys.exit(1)
|
||||||
|
if not isinstance(runtime.get('timeline'), list):
|
||||||
|
print('[FAIL] work/runtime-status.json timeline debe ser una lista')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"[OK] backlog válido ({len(features)} features)")
|
||||||
|
print('[OK] runtime-status válido')
|
||||||
|
PY
|
||||||
|
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── 4) Verificación de tests/build (opcional auto-detect) ─"
|
||||||
|
if [ -f "Makefile" ] && grep -qE '^test:' Makefile; then
|
||||||
|
if make test; then ok "make test OK"; else fail "make test falló"; EXIT_CODE=1; fi
|
||||||
|
elif [ -f "package.json" ]; then
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
if npm test --silent --if-present; then ok "npm test OK"; else fail "npm test falló"; EXIT_CODE=1; fi
|
||||||
|
else
|
||||||
|
warn "package.json detectado pero npm no está disponible"
|
||||||
|
fi
|
||||||
|
elif [ -d "tests" ]; then
|
||||||
|
if command -v pytest >/dev/null 2>&1; then
|
||||||
|
if pytest -q; then ok "pytest OK"; else fail "pytest falló"; EXIT_CODE=1; fi
|
||||||
|
else
|
||||||
|
if python3 -m unittest discover -s tests -v; then ok "unittest OK"; else fail "unittest falló"; EXIT_CODE=1; fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Sin suite de producto detectada; no aplica al harness base"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "── 5) Resumen ─────────────────────────────────────────"
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
ok "Orquestra verificado. Puedes trabajar."
|
||||||
|
else
|
||||||
|
fail "Orquestra NO verificado. Corrige antes de continuar."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
9
spec/acceptance.md
Normal file
9
spec/acceptance.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
Define criterios verificables por feature.
|
||||||
|
|
||||||
|
Formato recomendado:
|
||||||
|
- Feature ID:
|
||||||
|
- Escenario:
|
||||||
|
- Given / When / Then:
|
||||||
|
- Evidencia esperada (test/comando):
|
||||||
15
spec/product.md
Normal file
15
spec/product.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Product Spec
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
Describe el problema de negocio.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Define el resultado esperado del producto.
|
||||||
|
|
||||||
|
## Usuarios
|
||||||
|
- Usuario principal:
|
||||||
|
- Usuario secundario:
|
||||||
|
|
||||||
|
## Alcance v1
|
||||||
|
- In scope:
|
||||||
|
- Out of scope:
|
||||||
52
spec/roadmap.md
Normal file
52
spec/roadmap.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# MercadoDeVida vNext — Roadmap
|
||||||
|
|
||||||
|
Source of truth for intent: project/design_prompt.md
|
||||||
|
Source of truth for execution: backlog/features.json (one feature at a time, gates before done)
|
||||||
|
|
||||||
|
Stack decisions (fixed, boring):
|
||||||
|
|
||||||
|
- Backend: TypeScript + Node.js + Fastify (modular monolith)
|
||||||
|
- Frontend: Next.js + React + TypeScript + Tailwind (SSG/ISR for SEO pages)
|
||||||
|
- Database: PostgreSQL (source of truth) + Redis (cache only)
|
||||||
|
- Product code lives in project/
|
||||||
|
|
||||||
|
## Waves
|
||||||
|
|
||||||
|
### Wave 0 — Foundation (F-001 … F-004)
|
||||||
|
|
||||||
|
Skeleton, migrations, request context, config/flags. Nothing business yet.
|
||||||
|
Exit: build/lint/typecheck/test green, dev DB up with one command.
|
||||||
|
|
||||||
|
### Wave 1 — Identity & Users (F-005 … F-006)
|
||||||
|
|
||||||
|
Accounts, sessions, RBAC. Exit: customer and admin identities work.
|
||||||
|
|
||||||
|
### Wave 2 — Catalog (F-007 … F-012)
|
||||||
|
|
||||||
|
Categories → products → brands → variants/rich data → images → search.
|
||||||
|
Exit: full catalog model behind public module APIs, search replaceable.
|
||||||
|
|
||||||
|
### Wave 3 — Storefront & SEO (F-013 … F-015)
|
||||||
|
|
||||||
|
Next.js shell → catalog pages (SSG/ISR, stable URLs) → structured data, sitemap, redirects.
|
||||||
|
Exit: SEO-critical public surface is live and crawlable.
|
||||||
|
|
||||||
|
### Wave 4 — Commerce core (F-016 … F-023)
|
||||||
|
|
||||||
|
Inventory → pricing → cart → promotions → shipping → orders → checkout → payments.
|
||||||
|
This is the money path. Order matters; do not skip dependencies.
|
||||||
|
Exit: customer pays, order becomes PAID, stock is correct under concurrency.
|
||||||
|
|
||||||
|
### Wave 5 — Post-purchase & cross-cutting (F-024 … F-030)
|
||||||
|
|
||||||
|
Notifications, reviews, CMS, caching, security hardening, observability, E2E.
|
||||||
|
Exit: production-grade cross-cutting concerns with the money path protected end to end.
|
||||||
|
|
||||||
|
## Execution rules (from design_prompt.md)
|
||||||
|
|
||||||
|
1. NO FEATURE WITHOUT SPEC: each ticket produces specs/<feature>/SPEC.md, DESIGN.md, TASKS.md, TESTS.md during the design stage.
|
||||||
|
2. Smallest possible change; no unrelated refactors.
|
||||||
|
3. Every change reports expected blast radius before coding.
|
||||||
|
4. Modules communicate only through public interfaces or domain events.
|
||||||
|
5. Never trust frontend for price, stock, discount, payment status or user identity.
|
||||||
|
6. ./scripts/verify.sh green is a precondition for closing any ticket.
|
||||||
30
spec/tech.md
Normal file
30
spec/tech.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Technical Spec — MercadoDeVida vNext
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Lenguaje: TypeScript (strict) — backend y frontend
|
||||||
|
- Framework backend: Fastify 5 (monolito modular; elegido por tipado, simplicidad y ausencia de magia)
|
||||||
|
- Framework frontend: Next.js + React + Tailwind (llega en F-013; SSG/ISR para SEO)
|
||||||
|
- Runtime: Node.js >= 22
|
||||||
|
- Base de datos: PostgreSQL (fuente de verdad) + Redis (solo caché) — desde F-002
|
||||||
|
|
||||||
|
## Dependencias (justificación obligatoria por política de seguridad)
|
||||||
|
|
||||||
|
| Dependencia | Versión | Justificación |
|
||||||
|
|---|---|---|
|
||||||
|
| fastify | ^5.2 | Servidor HTTP tipado, maintained, sin magia; única dep de runtime en F-001 |
|
||||||
|
| typescript | ^5.7 | Tipado estricto (dev) |
|
||||||
|
| vitest | ^3 | Tests unit/integración TS-native (dev) |
|
||||||
|
| eslint + @eslint/js + typescript-eslint + eslint-config-prettier | ^9 / ^8 / ^10 | Linting estándar (dev) |
|
||||||
|
| prettier | ^3 | Formato consistente (dev) |
|
||||||
|
|
||||||
|
Regla: toda dependencia nueva debe agregarse a esta tabla con justificación en el ticket que la introduce.
|
||||||
|
|
||||||
|
## Restricciones
|
||||||
|
- Seguridad: nunca confiar en frontend para precio/stock/descuento/estado de pago; envelopes de error sin stack traces; sin secretos en repo
|
||||||
|
- Rendimiento: caché solo donde sea medible (F-027)
|
||||||
|
- Compatibilidad: URLs públicas estables /productos/<slug>, /categoria/<slug>, /marca/<slug>
|
||||||
|
|
||||||
|
## Observabilidad
|
||||||
|
- Logging: estructurado con request_id (F-003)
|
||||||
|
- Métricas: Prometheus con métricas de negocio (F-029)
|
||||||
|
- Alertas: fuera de alcance inicial
|
||||||
78
specs/F-001-scaffold/DESIGN.md
Normal file
78
specs/F-001-scaffold/DESIGN.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# DESIGN — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
## Affected modules
|
||||||
|
- New: `project/` application skeleton (no business modules yet, only exemplar `health` module).
|
||||||
|
|
||||||
|
## Modules touched
|
||||||
|
- `src/modules/health` (exemplar module proving the layout and boundary rule)
|
||||||
|
- `src/shared` (error envelope helper)
|
||||||
|
- `src/infrastructure/http` (Fastify server bootstrap)
|
||||||
|
- `src/app` (composition root)
|
||||||
|
|
||||||
|
## Modules NOT touched
|
||||||
|
- Everything else. No business modules exist yet. No harness files outside `work/artifacts/` and `specs/`.
|
||||||
|
|
||||||
|
## New interfaces
|
||||||
|
- `health` module public API: `registerHealthRoutes(app: FastifyInstance): Promise<void>` exported only from `src/modules/health/index.ts`.
|
||||||
|
- Boundary checker script: `node scripts/check-module-boundaries.mjs src` → exit 0 ok / exit 1 violation.
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
- Adds `GET /health` → `200 {"status":"ok"}`.
|
||||||
|
|
||||||
|
## Database changes
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## External integrations
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Cache changes
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
- Fastify default JSON error handler replaced with an envelope that never leaks stack traces.
|
||||||
|
- No dependencies beyond Fastify + toolchain.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
project/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── eslint.config.mjs
|
||||||
|
├── vitest.config.ts
|
||||||
|
├── .gitignore
|
||||||
|
├── scripts/
|
||||||
|
│ └── check-module-boundaries.mjs
|
||||||
|
└── src/
|
||||||
|
├── app/
|
||||||
|
│ └── build-app.ts # composition root: wires modules
|
||||||
|
├── infrastructure/
|
||||||
|
│ └── http/
|
||||||
|
│ └── server.ts # listen entrypoint
|
||||||
|
├── modules/
|
||||||
|
│ └── health/
|
||||||
|
│ ├── index.ts # public API
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── health.routes.ts
|
||||||
|
│ └── tests/
|
||||||
|
│ └── health.test.ts
|
||||||
|
└── shared/
|
||||||
|
└── errors.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundary rules enforced by the checker
|
||||||
|
1. Files inside `src/modules/<mod>/` may only import: own subtree (relative), `src/shared/...` (relative or alias-free path), Node builtins, and npm packages.
|
||||||
|
2. Any relative import escaping `src/modules/<mod>/` toward another module or toward `src/app`/`src/infrastructure` is a violation.
|
||||||
|
3. Files outside modules (`src/app`, `src/infrastructure`) may import a module only via its `index.ts` (direct deep import = violation).
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
- TypeScript strict, Fastify 5, Vitest, ESLint (flat config) + Prettier, tsc build to `dist/`.
|
||||||
|
|
||||||
|
## Migration strategy
|
||||||
|
- None (greenfield).
|
||||||
|
|
||||||
|
## Rollback strategy
|
||||||
|
- Delete `project/` content added by this ticket; no other system depends on it yet.
|
||||||
55
specs/F-001-scaffold/SPEC.md
Normal file
55
specs/F-001-scaffold/SPEC.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# SPEC — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
No codebase exists. MercadoDeVida vNext needs a boring, typed, modular home before any business feature.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
TypeScript modular monolith skeleton with strict module boundaries and a green toolchain.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- No business logic
|
||||||
|
- No database access
|
||||||
|
- No frontend
|
||||||
|
|
||||||
|
## User story
|
||||||
|
As a developer (human or AI), I can run install/build/lint/typecheck/test from `project/` and get a running HTTP app with a health endpoint, so every later feature starts from a known green base.
|
||||||
|
|
||||||
|
## Functional requirements
|
||||||
|
1. `project/` contains a Node + TypeScript app with Fastify.
|
||||||
|
2. Layout: `src/modules/`, `src/shared/`, `src/infrastructure/`, `src/app/`.
|
||||||
|
3. Every module folder exposes its public API through `index.ts`.
|
||||||
|
4. A boundary checker fails when a module imports another module's internal files.
|
||||||
|
5. `GET /health` returns 200 `{"status":"ok"}`.
|
||||||
|
|
||||||
|
## Business rules
|
||||||
|
- Modules communicate only through public interfaces (`index.ts`).
|
||||||
|
- Composition happens only in `src/app` (composition root).
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
- `GET /health`: none.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
- `GET /health`: `200 application/json {"status":"ok"}`.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
- Invalid route → 404 JSON envelope.
|
||||||
|
- Server start failure (port busy) → non-zero exit with clear error.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. `npm install`, `npm run build`, `npm run lint`, `npm run typecheck`, `npm test` all green in `project/`.
|
||||||
|
2. `GET /health` returns HTTP 200 with `{"status":"ok"}`.
|
||||||
|
3. `src/modules`, `src/shared`, `src/infrastructure`, `src/app` exist.
|
||||||
|
4. A module importing another module's internal file fails the boundary check (demonstrated by test).
|
||||||
|
5. `./scripts/verify.sh` green at repo root.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- None (first ticket).
|
||||||
|
|
||||||
|
## Security implications
|
||||||
|
- No secrets, no auth surface. Error responses must not leak stack traces.
|
||||||
|
|
||||||
|
## SEO implications
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Performance implications
|
||||||
|
- None measurable yet; baseline latency observable via test only.
|
||||||
13
specs/F-001-scaffold/TASKS.md
Normal file
13
specs/F-001-scaffold/TASKS.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# TASKS — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
- TASK-001 Init `project/` package: package.json (type module), tsconfig strict, .gitignore
|
||||||
|
- TASK-002 Add Fastify + shared error envelope + `build-app.ts` composition root
|
||||||
|
- TASK-003 Add `health` exemplar module (index.ts public API, routes, tests)
|
||||||
|
- TASK-004 Add HTTP server entrypoint (`src/infrastructure/http/server.ts`)
|
||||||
|
- TASK-005 Add boundary checker script + fixtures proving it fails on violations
|
||||||
|
- TASK-006 Add toolchain: ESLint flat config (incl. no deep cross-module imports), Prettier, Vitest
|
||||||
|
- TASK-007 Wire npm scripts: build, lint, lint:boundaries, typecheck, test, start
|
||||||
|
- TASK-008 Write project README with run commands
|
||||||
|
- TASK-009 Run full verification and write implementer evidence
|
||||||
|
|
||||||
|
Each task is independently understandable and leaves the tree consistent.
|
||||||
25
specs/F-001-scaffold/TESTS.md
Normal file
25
specs/F-001-scaffold/TESTS.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# TESTS — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
## Unit / integration (Vitest)
|
||||||
|
- `src/modules/health/tests/health.test.ts`: module-scoped; registers the module public API on a bare Fastify instance; `GET /health` → 200 and `{"status":"ok"}`.
|
||||||
|
- `src/app/tests/build-app.test.ts`: composition root; health wired through `buildApp()`; unknown route → 404 JSON envelope without stack leak.
|
||||||
|
- `scripts/tests/boundary-checker.test.ts`: run `check-module-boundaries.mjs` against fixture trees:
|
||||||
|
- fixture `ok` (imports inside own module + shared) → exit 0
|
||||||
|
- fixture `cross-module-internal` (imports another module's `domain/`) → exit 1
|
||||||
|
- fixture `deep-from-app` (app imports `modules/x/api/...` directly) → exit 1
|
||||||
|
|
||||||
|
## Toolchain checks (manual/CI commands)
|
||||||
|
- `npm run build` exits 0
|
||||||
|
- `npm run typecheck` exits 0
|
||||||
|
- `npm run lint` exits 0
|
||||||
|
- `npm run lint:boundaries` exits 0 on clean tree
|
||||||
|
- `npm test` exits 0
|
||||||
|
|
||||||
|
## Acceptance traceability
|
||||||
|
| Criterion | Evidence |
|
||||||
|
|---|---|
|
||||||
|
| install/build/lint/typecheck/test green | command outputs in implementer.md |
|
||||||
|
| GET /health 200 ok | health.test.ts + build-app.test.ts |
|
||||||
|
| src/{modules,shared,infrastructure,app} exist | tree listing in implementer.md |
|
||||||
|
| boundary violation fails lint | boundary-checker.test.ts fixtures |
|
||||||
|
| verify.sh green | qa.json evidence |
|
||||||
0
work/artifacts/.gitkeep
Normal file
0
work/artifacts/.gitkeep
Normal file
31
work/artifacts/F-001/architect.md
Normal file
31
work/artifacts/F-001/architect.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Architect — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
done -> work/artifacts/F-001/architect.md
|
||||||
|
|
||||||
|
## Decision summary
|
||||||
|
- Backend skeleton: Node 22 + TypeScript strict + Fastify 5, ESM, tsc build. No framework magic, no aliases (relative imports only) so runtime resolution stays trivial.
|
||||||
|
- Composition root: `src/app/build-app.ts` is the only place that wires modules onto the HTTP server.
|
||||||
|
- Boundary enforcement: explicit script `scripts/check-module-boundaries.mjs` wired into `lint:boundaries`, tested with fixtures. ESLint additionally blocks deep `@module` style imports is NOT needed because we use no aliases; the script is the single source of truth.
|
||||||
|
- Spec-first artifacts at `specs/F-001-scaffold/` (SPEC, DESIGN, TASKS, TESTS).
|
||||||
|
|
||||||
|
## Expected blast radius
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXPECTED BLAST RADIUS
|
||||||
|
|
||||||
|
Modules modified: none (greenfield; new project/ skeleton only)
|
||||||
|
Modules indirectly affected: none
|
||||||
|
Database changes: none
|
||||||
|
API changes: adds GET /health
|
||||||
|
Events added/changed: none
|
||||||
|
Risk level: low
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack justification (boring choices)
|
||||||
|
- Fastify: maintained, typed, small, no magic.
|
||||||
|
- Vitest: TS-native, same mental model as the test pyramid required by design_prompt.
|
||||||
|
- ESLint flat config + Prettier: standard.
|
||||||
|
- No ORM, no Redis client, no config framework yet — each arrives with the ticket that needs it.
|
||||||
|
|
||||||
|
## Handoff to implementer
|
||||||
|
Follow `specs/F-001-scaffold/TASKS.md` in order. Definition of done for build stage: all five commands green and evidence written to `work/artifacts/F-001/implementer.md`.
|
||||||
22
work/artifacts/F-001/documenter.md
Normal file
22
work/artifacts/F-001/documenter.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Documenter — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
done -> work/artifacts/F-001/documenter.md
|
||||||
|
|
||||||
|
## Why documentation applies
|
||||||
|
F-001 introduces new developer-facing contracts: npm scripts, module boundary rules, and project layout.
|
||||||
|
|
||||||
|
## Documents reviewed/produced
|
||||||
|
- `project/README.md` (created at build stage, verified accurate):
|
||||||
|
- requirements (Node >= 22, npm)
|
||||||
|
- all 6 npm scripts match package.json exactly: build, start, lint, lint:boundaries, typecheck, test
|
||||||
|
- layout diagram matches actual tree
|
||||||
|
- module rules match scripts/check-module-boundaries.mjs behavior (R1/R2)
|
||||||
|
- `specs/F-001-scaffold/` SPEC/DESIGN/TASKS/TESTS kept consistent with final implementation (TESTS.md updated at build stage).
|
||||||
|
- `spec/tech.md` completed with stack and dependency justification table (security policy requirement).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `grep` cross-check: README command list == package.json scripts keys (build, start, lint, lint:boundaries, typecheck, test) -> match.
|
||||||
|
- No API docs needed yet beyond /health (documented in implementer.md and SPEC.md).
|
||||||
|
|
||||||
|
## Pending docs (not this ticket)
|
||||||
|
- API reference will start being meaningful from F-005 onward.
|
||||||
55
work/artifacts/F-001/implementer.md
Normal file
55
work/artifacts/F-001/implementer.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Implementer — F-001 Scaffold modular monolith skeleton
|
||||||
|
|
||||||
|
done -> work/artifacts/F-001/implementer.md
|
||||||
|
|
||||||
|
## Files created (all under project/)
|
||||||
|
- package.json, tsconfig.json, tsconfig.build.json, eslint.config.mjs, vitest.config.ts
|
||||||
|
- .prettierrc.json, .prettierignore, .gitignore, README.md
|
||||||
|
- scripts/check-module-boundaries.mjs
|
||||||
|
- scripts/tests/boundary-checker.test.ts
|
||||||
|
- scripts/tests/fixtures/{ok,cross-module-internal,deep-from-app}/ (boundary test fixtures)
|
||||||
|
- src/app/build-app.ts (composition root)
|
||||||
|
- src/app/tests/build-app.test.ts
|
||||||
|
- src/infrastructure/http/server.ts (listen entrypoint)
|
||||||
|
- src/modules/health/index.ts, src/modules/health/api/health.routes.ts, src/modules/health/tests/health.test.ts
|
||||||
|
- src/shared/errors.ts
|
||||||
|
|
||||||
|
## Files modified
|
||||||
|
- specs/F-001-scaffold/TESTS.md (test locations updated to final layout)
|
||||||
|
|
||||||
|
## Database migrations
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
- Adds GET /health -> 200 {"status":"ok"}
|
||||||
|
- Adds 404 JSON envelope: {"error":{"statusCode":404,"message":"Not Found"}}
|
||||||
|
- Error handler never leaks stack traces (500 -> generic message).
|
||||||
|
|
||||||
|
## Tests added
|
||||||
|
- health module API test (module-scoped, no cross-module imports)
|
||||||
|
- composition root tests (health wired, 404 envelope without stack leak)
|
||||||
|
- boundary checker tests: ok tree, R1 violation fixture, R2 violation fixture, index import allowed
|
||||||
|
|
||||||
|
## Tests passed (evidence)
|
||||||
|
```
|
||||||
|
npm run lint -> OK (eslint + prettier)
|
||||||
|
npm run lint:boundaries -> Boundary check OK: 7 file(s) checked
|
||||||
|
npm run typecheck -> exit 0
|
||||||
|
npm run build -> exit 0 (dist generated)
|
||||||
|
npm test -> Test Files 3 passed (3), Tests 7 passed (7)
|
||||||
|
smoke: PORT=3999 node dist/infrastructure/http/server.js
|
||||||
|
curl /health -> HTTP 200 {"status":"ok"}
|
||||||
|
curl /nope -> {"error":{"statusCode":404,"message":"Not Found"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
- Boundary checker covers static import/export specifiers only (no require(), no runtime reflection). Acceptable for TS ESM codebase.
|
||||||
|
- No request_id / structured logging yet (F-003 scope).
|
||||||
|
- No CI pipeline defined (repo-level decision, out of F-001 scope).
|
||||||
|
|
||||||
|
## Follow-up work
|
||||||
|
- F-002 database foundation, F-003 request context, F-004 config/flags (per backlog dependencies).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The boundary checker caught a real violation during build (health test importing src/app). Fixed by moving composition tests to src/app/tests and keeping module tests module-scoped. This is the rule working as designed.
|
||||||
|
- design_prompt.md added to .prettierignore: it is a project input document, not product code.
|
||||||
29
work/artifacts/F-001/leader-close.json
Normal file
29
work/artifacts/F-001/leader-close.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-001",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-001 closed. Scaffold modular monolith implemented in project/, all gates APPROVED, verify.sh exit 0.",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": "APPROVED (reviewer.json)",
|
||||||
|
"security": "APPROVED (security.json)",
|
||||||
|
"qa": "APPROVED (qa.json)",
|
||||||
|
"verify_sh": "exit 0"
|
||||||
|
},
|
||||||
|
"deliverables": [
|
||||||
|
"project/: TypeScript + Fastify modular monolith skeleton (7 source files + toolchain)",
|
||||||
|
"boundary checker with tested fixtures enforcing module rules",
|
||||||
|
"specs/F-001-scaffold/ SPEC, DESIGN, TASKS, TESTS",
|
||||||
|
"spec/tech.md with dependency justification"
|
||||||
|
],
|
||||||
|
"next_feature_hint": "F-002 (database foundation) - only depends on F-001",
|
||||||
|
"evidence": [
|
||||||
|
"work/artifacts/F-001/architect.md",
|
||||||
|
"work/artifacts/F-001/implementer.md",
|
||||||
|
"work/artifacts/F-001/reviewer.json",
|
||||||
|
"work/artifacts/F-001/security.json",
|
||||||
|
"work/artifacts/F-001/qa.json",
|
||||||
|
"work/artifacts/F-001/documenter.md",
|
||||||
|
"./scripts/verify.sh exit 0 at close"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-14T19:52:00Z"
|
||||||
|
}
|
||||||
42
work/artifacts/F-001/qa.json
Normal file
42
work/artifacts/F-001/qa.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-001",
|
||||||
|
"agent": "qa",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "All 5 acceptance criteria verified with fresh executions against the final tree. No regressions (greenfield baseline).",
|
||||||
|
"traceability": [
|
||||||
|
{
|
||||||
|
"criterion": "AC1: npm install, build, lint, typecheck, test all green in project/",
|
||||||
|
"test": "fresh run of npm run build && typecheck && lint && lint:boundaries && test",
|
||||||
|
"result": "PASS (all exit 0; 3 test files / 7 tests passed)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "AC2: GET /health returns 200 {\"status\":\"ok\"}",
|
||||||
|
"test": "vitest inject tests (health.test.ts, build-app.test.ts) + live smoke: PORT=3998 node dist/infrastructure/http/server.js; curl /health",
|
||||||
|
"result": "PASS (HTTP 200 {\"status\":\"ok\"} live)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "AC3: src/modules, src/shared, src/infrastructure, src/app exist",
|
||||||
|
"test": "ls -d over project/src",
|
||||||
|
"result": "PASS (all four directories present)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "AC4: cross-module internal import fails boundary check",
|
||||||
|
"test": "node scripts/check-module-boundaries.mjs scripts/tests/fixtures/cross-module-internal + boundary-checker.test.ts fixtures",
|
||||||
|
"result": "PASS (exit 1, R1 violation reported)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"criterion": "AC5: ./scripts/verify.sh green at repo root",
|
||||||
|
"test": "./scripts/verify.sh",
|
||||||
|
"result": "PASS (exit 0, 'Orquestra verificado')"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"regressions": "n/a - greenfield ticket, no prior behavior to regress",
|
||||||
|
"evidence": [
|
||||||
|
"project/: npm run build/typecheck/lint/lint:boundaries/test -> all exit 0",
|
||||||
|
"npm test -> Test Files 3 passed (3), Tests 7 passed (7)",
|
||||||
|
"live smoke on PORT=3998 -> 200 {\"status\":\"ok\"}",
|
||||||
|
"boundary checker on violation fixture -> exit 1 with R1 violation message",
|
||||||
|
"./scripts/verify.sh -> exit 0"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-14T19:50:00Z"
|
||||||
|
}
|
||||||
32
work/artifacts/F-001/reviewer.json
Normal file
32
work/artifacts/F-001/reviewer.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-001",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Scaffold matches specs/F-001-scaffold DESIGN.md. Layout, composition root, boundary rules, error envelope and toolchain all verified by re-running commands against the tree. No blockers.",
|
||||||
|
"checks": {
|
||||||
|
"layout_matches_design": "PASS: src/{app,infrastructure,modules,shared} present; health exemplar exposes public API only via index.ts",
|
||||||
|
"boundary_rules": "PASS: R1 (module escape) and R2 (deep import from outside) enforced by scripts/check-module-boundaries.mjs, demonstrated by fixture tests (7 file(s) checked clean)",
|
||||||
|
"composition_root": "PASS: only src/app/build-app.ts wires modules; server.ts is a thin entrypoint",
|
||||||
|
"error_handling": "PASS: single error envelope, 404 handler, 500 message generic (no stack leak)",
|
||||||
|
"toolchain_green": "PASS: lint, lint:boundaries, typecheck, build, test all exit 0; 3 test files / 7 tests passed"
|
||||||
|
},
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"severity": "info",
|
||||||
|
"note": "Boundary checker is regex-based over static import/export specifiers; documented limitation acceptable for TS ESM, revisit only if dynamic imports appear."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "info",
|
||||||
|
"note": "Implementer evidence shows the checker caught a real violation during build (test escaping module) and fixed it architecturally - rule is effective, not decorative."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"npm run lint -> exit 0 (eslint + prettier)",
|
||||||
|
"npm run lint:boundaries -> Boundary check OK: 7 file(s) checked",
|
||||||
|
"npm run typecheck -> exit 0",
|
||||||
|
"npm test -> Test Files 3 passed (3), Tests 7 passed (7)",
|
||||||
|
"files reviewed: project/src/**, project/scripts/**, project/*.json, project/eslint.config.mjs",
|
||||||
|
"specs compared: specs/F-001-scaffold/SPEC.md, DESIGN.md, TASKS.md, TESTS.md"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-14T19:46:00Z"
|
||||||
|
}
|
||||||
27
work/artifacts/F-001/security.json
Normal file
27
work/artifacts/F-001/security.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-001",
|
||||||
|
"agent": "security",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Security gate passed. Zero audit vulnerabilities, no hardcoded secrets, no dangerous patterns in src/, error envelope prevents stack leakage, new dependencies justified in spec/tech.md per policy.",
|
||||||
|
"checks": {
|
||||||
|
"secrets": "PASS: grep for api_key/secret/password/token patterns in project code found none",
|
||||||
|
"dependencies": "PASS: npm audit --omit=dev -> 0 vulnerabilities; npm audit (full) -> 0 vulnerabilities; dependency table with justification added to spec/tech.md",
|
||||||
|
"sast_basic": "PASS: no eval, no new Function, no child_process in src/ (execFile used only in test harness with fixed, non-user-controlled arguments)",
|
||||||
|
"input_validation": "PASS (n/a scope): no user input surfaces yet beyond GET /health; Fastify default JSON body limits apply; error handler returns generic message for 5xx",
|
||||||
|
"repo_hygiene": "PASS: project/.gitignore excludes node_modules, dist, coverage, logs"
|
||||||
|
},
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"severity": "info",
|
||||||
|
"note": "No auth, rate limiting or CSRF surface exists yet; tracked in F-005/F-028. Nothing to mitigate in F-001."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"npm audit --omit=dev -> found 0 vulnerabilities",
|
||||||
|
"npm audit -> found 0 vulnerabilities",
|
||||||
|
"grep secret scan over project/src, project/scripts, configs -> no hardcoded secrets",
|
||||||
|
"grep eval|new Function|child_process over project/src -> none",
|
||||||
|
"dependency justification added to spec/tech.md (fastify, typescript, vitest, eslint stack, prettier)"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-14T19:48:00Z"
|
||||||
|
}
|
||||||
14
work/current.md
Normal file
14
work/current.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Sesión actual
|
||||||
|
|
||||||
|
- Feature en curso: _ninguna_ (F-001 cerrada DONE el 2026-08-14)
|
||||||
|
- Inicio: —
|
||||||
|
- Orquestador: —
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
- Próxima feature según dependencias: F-002 (Database foundation) o F-003 (HTTP foundation) o F-004 (config/flags) — todas dependen solo de F-001.
|
||||||
|
|
||||||
|
## Bitácora
|
||||||
|
- 2026-08-14: F-001 completada con todos los gates APPROVED y verify.sh verde.
|
||||||
|
|
||||||
|
## Próximo paso
|
||||||
|
- intake de la siguiente feature (sugerida: F-002).
|
||||||
9
work/history.md
Normal file
9
work/history.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Historial (append-only)
|
||||||
|
|
||||||
|
> Añadir entradas al final. No reescribir historial previo.
|
||||||
|
|
||||||
|
## 2026-08-14 — F-001 Scaffold modular monolith skeleton — DONE
|
||||||
|
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0
|
||||||
|
- Entregable: skeleton TypeScript + Fastify en project/ con boundary checker testeado; specs/F-001-scaffold completos; spec/tech.md con justificación de dependencias
|
||||||
|
- Artefactos: work/artifacts/F-001/ (architect.md, implementer.md, reviewer.json, security.json, qa.json, documenter.md, leader-close.json)
|
||||||
|
- Nota: el boundary checker detectó una violación real durante build (test escapando del módulo) y se corrigió moviendo los tests de composición a src/app
|
||||||
11
work/runtime-status.json
Normal file
11
work/runtime-status.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"feature_id": null,
|
||||||
|
"stage": "idle",
|
||||||
|
"agent": "leader",
|
||||||
|
"action": "Sin ejecución activa",
|
||||||
|
"state": "waiting",
|
||||||
|
"next_agent": "leader",
|
||||||
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
|
"updated_at": "2026-08-14T19:46:48Z",
|
||||||
|
"timeline": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user