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