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:
rikrdo
2026-08-14 21:46:54 +02:00
commit 1d4eebca54
76 changed files with 9430 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# BEGIN ORQUESTRA
__pycache__/
*.pyc
.pytest_cache/
.codegraph/
.atl/
# END ORQUESTRA

View 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
```

View 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");
},
});
}

View 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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

71
harness/agents.matrix.yml Normal file
View 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"

View 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
}

View 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
View 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

View 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.

View 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.

View 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`.

View 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
View 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>`

View 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
```

View 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");
},
});
}

View 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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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 },
};
},
});
}

View 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
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.log

0
project/.gitkeep Normal file
View File

6
project/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
package-lock.json
scripts/tests/fixtures/
design_prompt.md

6
project/.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all"
}

38
project/README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

34
project/eslint.config.mjs Normal file
View 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

File diff suppressed because it is too large Load Diff

30
project/package.json Normal file
View 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"
}
}

View 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();

View 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);
});
});

View File

@@ -0,0 +1,2 @@
import { secret } from '../../beta/domain/secret.js';
export const routes = [secret];

View File

@@ -0,0 +1 @@
export const secret = 'secret';

View File

@@ -0,0 +1,2 @@
import { routes } from '../modules/alpha/api/routes.js';
export const main = [routes];

View File

@@ -0,0 +1 @@
export const routes = 'routes';

View File

@@ -0,0 +1,3 @@
import { alpha } from '../modules/alpha/index.js';
import { beta } from '../modules/beta/index.js';
export const main = [alpha, beta];

View File

@@ -0,0 +1,3 @@
import { alpha } from '../index.js';
import { sharedUtil } from '../../../shared/util.js';
export const routes = [alpha, sharedUtil];

View File

@@ -0,0 +1 @@
export const alpha = 'alpha';

View File

@@ -0,0 +1 @@
export const beta = 'beta';

View File

@@ -0,0 +1 @@
export const sharedUtil = 'shared';

View 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;
}

View 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();
});
});

View 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);
}

View 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' };
});
}

View 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';

View 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();
});
});

View 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 } };
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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.

View 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.

View 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.

View 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
View File

View 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`.

View 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.

View 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.

View 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"
}

View 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"
}

View 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"
}

View 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
View 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
View 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
View 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": []
}