From 634525fa2131e9adec7487db175b4435e40c9de1 Mon Sep 17 00:00:00 2001 From: rikrdo Date: Mon, 17 Aug 2026 07:39:03 +0200 Subject: [PATCH] feat: Orquestra - sequential orchestration runtime - Context isolation: fresh Pi process per stage (run_stage.py) - Gate enforcement: blocks close without approved gates - Auto commit/push on feature close (close_feature.py) - Write restrictions: only allowed directories (ALLOWED_WRITE_DIRS) - Pi extension: orquestra-status with /orquestra-stage command - Documentation: context-handoff.md, updated README - Scripts: agent_status.py, verify.sh, install.sh updated --- .gitignore | 8 + AGENTS.md | 22 +- HOWTO.md | 170 +++----- README.md | 233 +++++------ backlog/features.json | 16 +- docs/context-handoff.md | 276 +++++++++++++ harness/agents.matrix.yml | 8 + harness/contracts/evidence.schema.json | 2 +- harness/contracts/handoff.md | 9 + harness/model-routing.yml | 23 ++ harness/workflow.stages.yml | 47 +++ platforms/opencode/README.md | 13 - platforms/opencode/leader.prompt.md | 4 - platforms/pi/README.md | 48 ++- .../pi/extensions/orquestra-status/README.md | 42 ++ .../pi/extensions/orquestra-status/index.ts | 362 +++++++++++++++++ .../pi/extensions/orquestra-web-fetch.ts | 56 +++ platforms/pi/leader.prompt.md | 16 +- project/.gitkeep | 0 scripts/agent_status.py | 382 ++++++++++++++++++ scripts/close_feature.py | 119 ++++++ scripts/commit_feature.sh | 58 +++ scripts/install.sh | 166 ++++++++ scripts/new_ticket.py | 102 +++++ scripts/pi_orquestra.sh | 28 ++ scripts/run_stage.py | 130 ++++++ scripts/verify.sh | 102 ++++- work/runtime-status.json | 138 +++++++ 28 files changed, 2296 insertions(+), 284 deletions(-) create mode 100644 .gitignore create mode 100644 docs/context-handoff.md create mode 100644 harness/model-routing.yml delete mode 100644 platforms/opencode/README.md delete mode 100644 platforms/opencode/leader.prompt.md create mode 100644 platforms/pi/extensions/orquestra-status/README.md create mode 100644 platforms/pi/extensions/orquestra-status/index.ts create mode 100644 platforms/pi/extensions/orquestra-web-fetch.ts create mode 100644 project/.gitkeep create mode 100755 scripts/agent_status.py create mode 100755 scripts/close_feature.py create mode 100755 scripts/commit_feature.sh create mode 100755 scripts/install.sh create mode 100755 scripts/new_ticket.py create mode 100755 scripts/pi_orquestra.sh create mode 100755 scripts/run_stage.py create mode 100644 work/runtime-status.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d237b7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.codegraph/ + +# Local Pi runtime state +.atl/ +.env diff --git a/AGENTS.md b/AGENTS.md index a0ed3c8..2a3deb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,25 @@ -# AGENTS.md — Entry point del framework +# AGENTS.md — Entrada de Orquestra ## Arranque obligatorio -1. Leer `work/current.md`. -2. Leer `backlog/features.json` y seleccionar **una** feature `pending`. -3. Ejecutar `./scripts/verify.sh`. +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 agente pasa código por chat: todo va a `work/artifacts//`. +- Ningún rol pasa código por chat: todo va a `work/artifacts//`. - `implementer` nunca marca `done`. - `done` requiere gates aprobados: `reviewer`, `security`, `qa`. - Si `verify.sh` falla, no se cierra la feature. +- **Solo se puede escribir en carpetas permitidas**: `project/`, `tests/`, `work/`, `backlog/`, `spec/`, `harness/`, `scripts/`, `platforms/`, `docs/`. Cualquier otra escritura será bloqueada por la extensión `orquestra-status`. +- **Al cerrar una feature, el leader ejecuta `scripts/close_feature.py `** que valida gates, actualiza el backlog y hace commit/push automáticamente. +- **NUNCA editar `backlog/features.json` directamente**. Usar siempre `scripts/close_feature.py` para cerrar features. -## Reentrada (context loss) -- Releer `work/current.md` y artefactos de la feature activa. +## Reentrada +- Releer `work/current.md`, `work/runtime-status.json` y artefactos de la feature activa. - Ejecutar `./scripts/verify.sh`. -- Continuar desde “Próximo paso”. +- Continuar desde el próximo stage pendiente. diff --git a/HOWTO.md b/HOWTO.md index 97114b0..ce48659 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -1,145 +1,93 @@ -# HOWTO — Cómo usar ARNES Framework +# HOWTO — usar Orquestra -Guía rápida para arrancar proyectos nuevos usando este framework. - ---- - -## Fórmula base (siempre igual) - -1. **Crear repo nuevo** -2. **Copiar ARNES Framework dentro del repo** -3. **Configurar spec + backlog** -4. **Ejecutar verificación** -5. **Empezar implementación por features (una a la vez)** - ---- - -## 1) Crear repo +## 1) Requisitos ```bash -mkdir mi-proyecto -cd mi-proyecto -git init +command -v pi +command -v python3 ``` ---- +Si `pi` no existe, no instales Orquestra todavía. -## 2) Copiar framework +## 2) Instalar en un proyecto -Desde tu copia local de ARNES: +Desde el repo fuente de Orquestra: ```bash -cp -R /ruta/a/arnes/* . -cp -R /ruta/a/arnes/.[!.]* . 2>/dev/null || true +./scripts/install.sh /path/to/project-repo ``` -> Si usas plantilla remota, clónala y copia su contenido al repo nuevo. +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) Personalizar proyecto - -Edita mínimo: - -- `README.md` (contexto del proyecto) -- `spec/product.md` (qué construir) -- `spec/tech.md` (stack y límites técnicos) -- `spec/acceptance.md` (criterios de aceptación) -- `backlog/features.json` (features iniciales en `pending`) -- `harness/agents.matrix.yml` (roles/permisos) -- `harness/workflow.stages.yml` (flujo y gates) - ---- - -## 4) Elegir plataforma (pi.dev u opencode) - -Usa el adaptador correspondiente: - -- `platforms/pi/` -- `platforms/opencode/` - -El núcleo del framework no cambia; solo cambian prompts/hooks/permisos de plataforma. - ---- - -## 5) Inicializar estado de trabajo - -Verifica que existan y estén limpios: - -- `work/current.md` -- `work/history.md` -- `work/artifacts/` - -Pon solo **1 feature activa** (`in_progress`) como máximo. - ---- - -## 6) Ejecutar verificación inicial +## 3) Verificar ```bash ./scripts/verify.sh ``` -Si falla, **no empezar implementación** hasta dejar todo en verde. +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 -## 7) Ciclo operativo por feature +```bash +cd +./scripts/pi_orquestra.sh +``` -Orden obligatorio: +Ese launcher usa `pi --no-extensions` y carga solo `orquestra-status` + `orquestra-web-fetch`, así Pi arranca limpio con las extensiones del proyecto. -1. `leader` orquesta -2. `architect` define/ajusta diseño -3. `implementer` implementa + tests -4. `reviewer` gate técnico -5. `security` gate seguridad -6. `qa` gate funcional -7. `leader` cierra si todo está aprobado +Después de abrir o recargar Pi: -Reglas clave: -- una feature a la vez -- evidencia en disco (`work/artifacts//...`) -- nadie marca `done` si falta un gate +```text +/orquestra-status +``` ---- +## 5) Flujo secuencial -## 8) Cierre de feature +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`. -Antes de pasar a `done`: +Un stage empieza solo cuando el anterior terminó con artefacto en disco; `document` es opcional/condicional y no bloquea el cierre por defecto. -- `verify.sh` en verde -- review aprobado -- security aprobado -- qa aprobado -- resumen en `work/history.md` +## 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. -## 9) Manejo de pérdida de contexto (memoria) +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: -Si una sesión se corta: +```bash +python3 scripts/agent_status.py set --feature-id F-001 --stage build --agent implementer --state running --action "Implementing" +``` -1. leer `work/current.md` -2. revisar `backlog/features.json` -3. abrir artefactos de la feature activa -4. ejecutar `./scripts/verify.sh` -5. continuar desde “Próximo paso” +## 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 +``` -## 10) Checklist rápido de arranque +## Regla corta -- [ ] Repo creado -- [ ] Framework copiado -- [ ] Specs escritas -- [ ] Backlog definido -- [ ] Matriz de agentes configurada -- [ ] Workflow de stages configurado -- [ ] Verificación inicial OK -- [ ] Primera feature en `pending` - ---- - -## Comando mental (resumen) - -**Crear repo → copiar framework → definir spec/backlog → verificar → ejecutar pipeline de 6 agentes con gates obligatorios.** +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. diff --git a/README.md b/README.md index b530135..0e84a46 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,161 @@ -# ARNES Framework (agnóstico) — Diseño v0.1 +# Orquestra — harness secuencial para Pi -Framework para construir aplicaciones con agentes autónomos, con control estricto de calidad, seguridad y trazabilidad. -Compatible por diseño con **pi.dev** y **opencode** mediante adaptadores. +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 que agentes implementen features de forma autónoma **sin perder control**: +Permitir trabajo asistido por agentes sin perder control: - una feature a la vez -- evidencia en disco (no en chat) +- estado persistente en disco +- evidencia auditable, nunca solo chat +- separación de roles - gates obligatorios de revisión, seguridad y QA -- cierre solo con validación completa +- 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 -## Principios +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. -1. **Vendor-neutral**: núcleo independiente de herramienta. -2. **Estado persistente**: todo vive en archivos versionables. -3. **No confianza ciega**: “funciona” debe demostrarse con evidencia ejecutable. -4. **Separación de roles**: quien implementa no aprueba. -5. **Anti-trampa por diseño**: no se puede marcar `done` saltando gates. +Los modelos por rol se definen en `harness/model-routing.yml`; el cambio de modelo ocurre antes de cada stage, nunca en paralelo. ---- +## Pipeline -## Matriz de agentes (6) +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` -1. **leader** - - Orquesta etapas y handoffs. - - No implementa código de producto. +No hay `done` si falta cualquier gate obligatorio; `documenter.md` no es requisito de cierre salvo que el cambio necesite documentación. -2. **architect** - - Define/ajusta diseño técnico y contratos. - - Puede editar documentación y diseño. +## Evidencia obligatoria -3. **implementer** - - Implementa una sola feature + tests. - - No puede aprobar ni cerrar. - -4. **reviewer** - - Revisión técnica vs arquitectura/convenios. - - No edita código, solo aprueba/rechaza. - -5. **security** - - Gate de seguridad: secretos, dependencias, SAST básico, hardening checks. - - No edita código. - -6. **qa** - - Gate de calidad funcional: aceptación, integración/E2E, regresión. - - No edita código. - ---- - -## Flujo de trabajo (pipeline) - -1. `intake` (leader) -2. `design` (architect) -3. `build` (implementer) -4. `review_gate` (reviewer) ✅ -5. `security_gate` (security) ✅ -6. `qa_gate` (qa) ✅ -7. `close` (leader) - -**Regla:** no hay `done` si cualquier gate falla. - ---- - -## Anti-trampa (control estricto) - -### Reglas de autorización -- Solo `leader` puede mover `in_progress -> done`. -- `implementer` no puede editar archivos de estado final de cierre. -- `reviewer/security/qa` no pueden editar código de producto. - -### Evidencia obligatoria por etapa -Cada agente escribe artefactos en disco: +Cada stage escribe en disco: - `work/artifacts//implementer.md` -- `work/artifacts//reviewer.md` -- `work/artifacts//security.md` -- `work/artifacts//qa.md` +- `work/artifacts//reviewer.json` +- `work/artifacts//security.json` +- `work/artifacts//qa.json` +- `work/artifacts//documenter.md` (opcional/condicional) +- `work/artifacts//leader-close.json` -Respuesta de agente siempre: `done -> ` o `blocked -> `. +Respuesta estándar por stage: +- `done -> ` +- `blocked -> ` -### Gates ejecutados fuera del agente -- Verificación disparada por harness/scripts (no por “declaración” del agente). -- Si gate falla, estado vuelve a `blocked` o permanece `in_progress`. - -### Trazabilidad y auditoría -- `work/history.md` append-only. -- Checklist de cierre firmado por etapa (aprobado/rechazado + evidencia). -- Cualquier missing evidence = cierre denegado. - ---- - -## Estructura propuesta +## Estructura mínima ```text . +├── AGENTS.md ├── README.md ├── harness/ │ ├── agents.matrix.yml │ ├── workflow.stages.yml +│ ├── model-routing.yml │ ├── policies/ -│ │ ├── security.md -│ │ ├── quality.md -│ │ └── governance.md │ └── contracts/ -│ ├── handoff.md -│ └── evidence.schema.json +├── platforms/pi/ +│ └── extensions/ +│ ├── orquestra-status/ +│ └── orquestra-web-fetch.ts ├── spec/ -│ ├── product.md -│ ├── tech.md -│ └── acceptance.md -├── backlog/ -│ └── features.json +├── project/ +├── backlog/features.json ├── work/ │ ├── current.md │ ├── history.md +│ ├── runtime-status.json │ └── artifacts/ └── scripts/ - └── verify.sh + ├── verify.sh + ├── agent_status.py + ├── new_ticket.py + └── pi_orquestra.sh ``` ---- +## Instalación y actualización segura -## Manejo de pérdidas de memoria (context loss) +Desde el repo fuente de Orquestra: -Sí: el framework está diseñado para eso. +```bash +./scripts/install.sh /path/to/project-repo +``` -Mecanismos: -1. **Estado en disco** (`work/current.md`, `backlog/features.json`). -2. **Bitácora append-only** (`work/history.md`). -3. **Handoffs explícitos** por archivo, no por chat. -4. **Protocolo de reentrada** al iniciar sesión: - - leer `work/current.md` - - leer feature activa - - ejecutar verificación base - - continuar desde “próximo paso” +Para actualizar, volvé a ejecutar el mismo comando sobre el repo destino. -Si se pierde contexto del modelo, el sistema se puede reconstruir desde archivos. +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/`. -## Adaptadores de plataforma +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`. -- `platforms/pi/`: prompts, hooks, permisos, comandos compatibles con pi.dev. -- `platforms/opencode/`: prompts, hooks, permisos, comandos compatibles con opencode. +## Pi -El núcleo no cambia; solo el adaptador. +Las extensiones Orquestra esperadas son: ---- +```text +.pi/extensions/orquestra-status/index.ts +.pi/extensions/orquestra-web-fetch.ts +``` -## Criterios de éxito del framework +Comando manual: -- No se puede cerrar una feature sin 3 gates en verde (review/security/qa). -- Evidencia completa y auditable por feature. -- Reentrada robusta tras reinicio o pérdida de contexto. -- Portabilidad entre pi.dev y opencode sin rediseñar el núcleo. +```bash +./scripts/pi_orquestra.sh +# dentro de Pi: +/orquestra-status +``` ---- +Fuente de verdad: -## Próximos pasos sugeridos +```bash +python3 scripts/agent_status.py show +python3 scripts/agent_status.py set ... +python3 scripts/agent_status.py reset +``` -1. Definir `agents.matrix.yml` completo (permisos exactos por rutas). -2. Definir `workflow.stages.yml` con transiciones válidas. -3. Diseñar `features.json` con estados y criterios de aceptación. -4. Especificar `scripts/verify.sh` (lint/test/security/qa gates). -5. Crear adaptadores `platforms/pi` y `platforms/opencode`. +## 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 diff --git a/backlog/features.json b/backlog/features.json index 656d6b5..cca79ed 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -1,6 +1,6 @@ { - "project": "nuevo-proyecto", - "description": "Backlog inicial del proyecto", + "project": "orquestra-harness", + "description": "Backlog del harness Orquestra instalable", "rules": { "one_feature_at_a_time": true, "require_review_gate": true, @@ -11,12 +11,14 @@ "features": [ { "id": "F-001", - "title": "Definir estructura inicial", - "description": "Bootstrap del proyecto con estructura base.", + "title": "Definir contrato de instalación y actualización segura", + "description": "Establecer el contrato mínimo para instalar Orquestra en cualquier proyecto compatible y actualizarlo sin pisar progreso existente.", "acceptance": [ - "Estructura base creada", - "Tests o checks iniciales definidos", - "Artefactos de gate configurados" + "Identidad del harness definida", + "Estructura mínima requerida para ejecutar Orquestra documentada", + "Frontera entre archivos del harness y datos del proyecto documentada", + "Regla de actualización segura definida: crear si falta, conservar o mergear si existe", + "Artefactos de gate alineados con el esquema JSON" ], "status": "pending" } diff --git a/docs/context-handoff.md b/docs/context-handoff.md new file mode 100644 index 0000000..dc071c9 --- /dev/null +++ b/docs/context-handoff.md @@ -0,0 +1,276 @@ +# Context Handoff in Orquestra + +Orquestra keeps agents focused by making context explicit. Agents do not pass a whole chat history to the next agent. They pass durable artifacts on disk, and each new stage reads only the files declared for that stage. + +## Quick path + +1. The current agent writes its result to `work/artifacts//`. +2. From the active Pi session, `/orquestra-stage [feature_id]` calls `scripts/run_stage.py`. +3. `run_stage.py` starts a fresh Pi process. +4. The new agent receives only the `input` paths declared in `harness/workflow.stages.yml`. +5. The new agent writes its own output artifact. +6. `scripts/agent_status.py` and `scripts/verify.sh` enforce stage order and gates. +7. When the `close` stage completes, `scripts/close_feature.py ` runs automatically to validate gates, update the backlog, commit and push if a remote repo is configured. + +## Mental model + +```text +Agent A + reads its stage inputs + writes one durable artifact + │ + ▼ +work/artifacts/F-001/agent-a-output + │ + ▼ +run_stage.py starts a fresh Pi process + │ + ▼ +Agent B + reads only declared inputs, including Agent A's artifact if relevant + writes its own artifact +``` + +The agents “talk” through files, not through memory-heavy chat context. + +## Why fresh processes matter + +A single long Pi session naturally accumulates chat, tool output, false starts, and old assumptions. That causes two problems: + +| Problem | Orquestra answer | +|---|---| +| The next agent gets distracted by old context | Start the next stage with `--no-session --no-context-files` | +| Token usage grows until compaction | Each stage starts with a small prompt and declared files | +| Claims become hard to verify | Only artifacts on disk count as handoff evidence | +| Agents can skip gates in chat | `agent_status.py` validates stage owner and prerequisites | + +## What a stage receives + +Each stage receives three kinds of context: + +1. **Harness rules** — always read first. + - `AGENTS.md` + - `harness/agents.matrix.yml` + - `harness/workflow.stages.yml` + - `harness/contracts/handoff.md` + +2. **Declared stage inputs** — from `harness/workflow.stages.yml`. + +3. **Selected Engram memory** — only when the agent performs a narrow search for a concrete need. + +The full previous chat is not valid input. + +## Example: design to build + +`architect` receives product/spec context: + +```yaml +- name: design + owner: architect + input: + - work/current.md + - spec/product.md + - spec/tech.md + - spec/acceptance.md + output: + - work/artifacts//architect.md +``` + +It writes: + +```text +work/artifacts/F-001/architect.md +``` + +Then `implementer` starts fresh from the active Pi session: + +```text +/orquestra-stage build F-001 +``` + +Equivalent shell form: + +```bash +python3 scripts/run_stage.py build --feature-id F-001 +``` + +The generated prompt gives it only: + +```yaml +- name: build + owner: implementer + input: + - work/current.md + - spec/product.md + - spec/tech.md + - spec/acceptance.md + - work/artifacts//architect.md + output: + - work/artifacts//implementer.md +``` + +So the implementer knows the design result, but not the architect's full chat. + +## Example: build to review + +The implementer writes evidence: + +```text +work/artifacts/F-001/implementer.md +``` + +A minimal implementer artifact should answer: + +```markdown +# Implementer Evidence + +## Changed files +- project/app.py +- tests/test_app.py + +## What changed +- Added ticket creation validation. + +## Checks +- `python3 -m unittest discover -s tests -v` passed. + +## Notes for reviewer +- Main behavior lives in `project/app.py:create_ticket`. +``` + +Then reviewer starts fresh: + +```text +/orquestra-stage review_gate F-001 +``` + +Reviewer input is only: + +```yaml +input: + - work/current.md + - spec/acceptance.md + - work/artifacts//implementer.md +``` + +Reviewer does not need the architect's whole reasoning unless the workflow explicitly declares it. If reviewer needs more, it blocks and asks for a specific artifact/path. + +## Example: security and QA reuse only relevant context + +Security does not need all acceptance prose plus every design note. It needs the implementation evidence and review result: + +```yaml +- name: security_gate + owner: security + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json +``` + +QA needs acceptance criteria and approved gate context: + +```yaml +- name: qa_gate + owner: qa + input: + - work/current.md + - spec/acceptance.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json +``` + +That is the core rule: give each agent the smallest context that lets it do its job. + +## Anti-cheating controls + +```text +run_stage.py + ├─ starts fresh Pi process + ├─ disables previous session/context files + └─ injects only declared input/output paths + +agent_status.py + ├─ validates stage owner + ├─ requires feature_id for real stages + ├─ rejects gated stages when prerequisite artifacts are missing + └─ keeps visible runtime status + +orquestra-status extension + ├─ blocks writes outside allowed directories: project/, tests/, work/, backlog/, spec/, harness/, scripts/, platforms/, docs/ + ├─ blocks product/test writes outside implementer build stage + └─ blocks product code in repository root + +verify.sh + ├─ validates harness structure + ├─ validates backlog/runtime JSON + ├─ validates Engram availability + └─ rejects done features without approved gate artifacts +``` + +Chat-only claims do not pass any gate. The agent must write evidence to disk. + +## What Engram is for + +Engram is durable memory for reusable facts, not a replacement for stage artifacts. + +Each stage must save to Engram only when it produced durable knowledge. + +Use Engram for: + +- stable project decisions, +- bug fixes, +- non-obvious discoveries, +- reusable conventions or patterns, +- configuration changes, +- repeated gotchas, +- environment facts, +- lessons learned across features. + +Do not use Engram for: + +- replacing `implementer.md`, +- hiding gate evidence, +- routine progress, +- command output dumps, +- artifact summaries, +- passing huge summaries to every stage, +- bypassing declared workflow inputs. + +A good Engram lookup is narrow: + +```text +Search: "orquestra project root product files rule" +Use result only if it affects this stage. +``` + +A bad lookup is broad: + +```text +Search: "everything about this project" +``` + +## Updating an installed project safely + +From the Orquestra source checkout: + +```bash +/path/to/orquestra/scripts/install.sh /path/to/project +cd /path/to/project +./scripts/verify.sh +``` + +The installer updates harness-owned files and preserves project-owned progress files when they already exist: + +```text +backlog/features.json +spec/* +work/current.md +work/history.md +work/runtime-status.json +project/* +work/artifacts/* +``` + +That means an existing project can adopt fresh-stage handoff without losing its current feature state. diff --git a/harness/agents.matrix.yml b/harness/agents.matrix.yml index 98aca2b..f11e55f 100644 --- a/harness/agents.matrix.yml +++ b/harness/agents.matrix.yml @@ -56,6 +56,14 @@ roles: - 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" diff --git a/harness/contracts/evidence.schema.json b/harness/contracts/evidence.schema.json index e5f9946..8e8fcc4 100644 --- a/harness/contracts/evidence.schema.json +++ b/harness/contracts/evidence.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://arnes.local/evidence.schema.json", + "$id": "https://orquestra.local/evidence.schema.json", "title": "Gate Evidence", "type": "object", "required": ["feature_id", "agent", "verdict", "summary", "evidence", "timestamp"], diff --git a/harness/contracts/handoff.md b/harness/contracts/handoff.md index d61110b..9c199b6 100644 --- a/harness/contracts/handoff.md +++ b/harness/contracts/handoff.md @@ -5,11 +5,20 @@ Los agentes **no** devuelven código en chat. Deben escribir a disco y responder - `done -> ` - `blocked -> ` +## Contexto mínimo por stage +Cada stage recibe solo: +1. Los archivos listados en `harness/workflow.stages.yml` como `input`. +2. El artefacto del stage anterior cuando el workflow lo declare. +3. La memoria Engram que el leader seleccione explícitamente para ese stage. + +No se considera válido pasar todo el chat anterior como contexto operativo. Si un stage necesita más información, debe pedir una ruta o artefacto concreto y bloquear hasta tenerlo. + ## Rutas estándar por feature - `work/artifacts//implementer.md` - `work/artifacts//reviewer.json` - `work/artifacts//security.json` - `work/artifacts//qa.json` +- `work/artifacts//documenter.md` (opcional; requerido solo si cambiaron docs/API/contratos/comportamiento user-facing) - `work/artifacts//leader-close.json` ## Campos mínimos de artefactos JSON de gate diff --git a/harness/model-routing.yml b/harness/model-routing.yml new file mode 100644 index 0000000..eb8dec5 --- /dev/null +++ b/harness/model-routing.yml @@ -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 diff --git a/harness/workflow.stages.yml b/harness/workflow.stages.yml index 1b569fc..923b0af 100644 --- a/harness/workflow.stages.yml +++ b/harness/workflow.stages.yml @@ -15,38 +15,85 @@ stages: - name: design owner: architect optional: true + input: + - work/current.md + - spec/product.md + - spec/tech.md + - spec/acceptance.md output: - work/artifacts//architect.md - name: build owner: implementer + input: + - work/current.md + - spec/product.md + - spec/tech.md + - spec/acceptance.md + - work/artifacts//architect.md output: - work/artifacts//implementer.md - name: review_gate owner: reviewer required: true + input: + - work/current.md + - spec/acceptance.md + - work/artifacts//implementer.md output: - work/artifacts//reviewer.json - name: security_gate owner: security required: true + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json output: - work/artifacts//security.json - name: qa_gate owner: qa required: true + input: + - work/current.md + - spec/acceptance.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json output: - work/artifacts//qa.json + - name: document + owner: documenter + optional: true + when: docs/API/contracts/user-facing behavior changed + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json + - work/artifacts//qa.json + output: + - work/artifacts//documenter.md + - name: close owner: leader required: true + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json + - work/artifacts//qa.json + - work/artifacts//documenter.md output: - work/artifacts//leader-close.json - work/history.md + post_actions: + - scripts/close_feature.py close_requirements: - reviewer.json.verdict == "APPROVED" diff --git a/platforms/opencode/README.md b/platforms/opencode/README.md deleted file mode 100644 index 7b5bccc..0000000 --- a/platforms/opencode/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Adaptador opencode - -Usa este adaptador para ejecutar ARNES en opencode. - -## Recomendado -- Definir perfiles/roles con la matriz `harness/agents.matrix.yml`. -- Separar permisos por rutas para evitar bypass de gates. -- Forzar handoff por archivos (`work/artifacts//`). -- Ejecutar `./scripts/verify.sh` en pre-close. - -## Respuesta estándar de subagentes -- `done -> ` -- `blocked -> ` diff --git a/platforms/opencode/leader.prompt.md b/platforms/opencode/leader.prompt.md deleted file mode 100644 index 2f2b9e3..0000000 --- a/platforms/opencode/leader.prompt.md +++ /dev/null @@ -1,4 +0,0 @@ -Rol: leader. -No escribas código de producto. -Coordina intake -> design -> build -> review_gate -> security_gate -> qa_gate -> close. -Si falta evidencia en disco o falla verify.sh, no cerrar feature. diff --git a/platforms/pi/README.md b/platforms/pi/README.md index ed63efb..1afffcc 100644 --- a/platforms/pi/README.md +++ b/platforms/pi/README.md @@ -1,13 +1,45 @@ -# Adaptador pi.dev +# Adaptador Pi -Usa este adaptador para ejecutar ARNES en pi.dev. +Orquestra se ejecuta desde Pi como **un solo parent session secuencial**. No instala subagentes. -## Recomendado -- Cargar `AGENTS.md` como entrada principal. -- Enforzar roles según `harness/agents.matrix.yml`. -- Exigir que subagentes escriban artefactos en `work/artifacts//`. -- Ejecutar `./scripts/verify.sh` al inicio y antes de cierre. +## Requisitos obligatorios +- `pi` debe existir en `PATH` antes de instalar Orquestra. +- `gentle-engram` debe estar instalado: Orquestra usa Engram como memoria durable externa; no escribe memoria propia. +- El proyecto instalado debe abrirse desde su raíz. +- Arrancar con `./scripts/pi_orquestra.sh`, que ejecuta `pi --no-extensions`, carga Engram explícitamente 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. -## Respuesta estándar de subagentes +## 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`. + +## Técnica de memoria +- Engram es la única memoria persistente del harness. +- `verify.sh` falla si `~/.pi/agent/npm/node_modules/gentle-engram/index.ts` no existe. +- `pi_orquestra.sh` carga Engram con `-e` aunque Pi arranque con `--no-extensions`; así se evita cargar extensiones globales no declaradas sin perder memoria. +- Cada rol trabaja desde los `input` declarados en `harness/workflow.stages.yml`; el chat completo no es un handoff válido. +- Para aislamiento real desde la sesión Pi, ejecutar cada stage con `/orquestra-stage [feature_id]`; internamente usa `run_stage.py`, `pi --no-session --no-context-files` y carga solo Engram + extensiones Orquestra. +- Detalle completo del handoff: `docs/context-handoff.md`. + +## 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. Ejecutar cada stage como proceso fresco desde Pi: `/orquestra-stage [feature_id]`. +5. `run_stage.py` genera un prompt mínimo con las rutas `input`/`output` del stage y no hereda la sesión anterior. +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//`. +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 -> ` - `blocked -> ` diff --git a/platforms/pi/extensions/orquestra-status/README.md b/platforms/pi/extensions/orquestra-status/README.md new file mode 100644 index 0000000..5fceac6 --- /dev/null +++ b/platforms/pi/extensions/orquestra-status/README.md @@ -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 +``` diff --git a/platforms/pi/extensions/orquestra-status/index.ts b/platforms/pi/extensions/orquestra-status/index.ts new file mode 100644 index 0000000..88111e3 --- /dev/null +++ b/platforms/pi/extensions/orquestra-status/index.ts @@ -0,0 +1,362 @@ +import { execFileSync } from "node:child_process"; +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 STATUS_GAP = " "; + +const DEFAULT_EMOJIS: Record = { + leader: "🧭", + architect: "🏗️", + implementer: "🛠️", + reviewer: "🔍", + security: "🔒", + qa: "🧪", + documenter: "📝", +}; + +const GATE_FILES: Record = { + reviewer: "reviewer.json", + security: "security.json", + qa: "qa.json", + leader: "leader-close.json", +}; + +const AGENT_FILES: Record = { + 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"]); + +// Carpetas permitidas para escritura (Orquestra + proyecto) +const ALLOWED_WRITE_DIRS = new Set([ + "project", // código del proyecto + "tests", // tests del proyecto + "work", // artifacts, current.md, runtime-status.json + "backlog", // features.json + "spec", // specs globales + "harness", // configuración Orquestra (solo leader) + "scripts", // scripts Orquestra (solo leader) + "platforms", // extensiones Pi (solo leader) + "docs", // documentación (solo leader) +]); + +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 { + 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 { + const result: Record = {}; + 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 { + 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(STATUS_GAP)}`; +} + +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 { + 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(STATUS_GAP)}`; +} + +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 parseStageCommand(args: string, status: RuntimeStatus | null): { stage?: string; featureId?: string; error?: string } { + const parts = args.trim().split(/\s+/).filter(Boolean); + const stage = parts[0]; + const featureFlag = parts.indexOf("--feature-id"); + const featureId = featureFlag >= 0 ? parts[featureFlag + 1] : parts[1] || status?.feature_id || undefined; + if (!stage) return { error: "Uso: /orquestra-stage [feature_id]" }; + if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage " }; + return { stage, featureId }; +} + +function lastUsefulLine(text: string): string { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-1)[0] || "stage terminado"; +} + +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 }; + + // Extraer el directorio raíz del path + const slashIndex = relPath.indexOf("/"); + const rootDir = slashIndex === -1 ? relPath : relPath.slice(0, slashIndex); + + // Bloquear escrituras fuera de carpetas permitidas + if (!ALLOWED_WRITE_DIRS.has(rootDir)) { + return { + block: true, + reason: `Orquestra bloqueó '${relPath}': solo se permite escribir en ${Array.from(ALLOWED_WRITE_DIRS).join(", ")}. No crees carpetas ni archivos en la raíz ni en ubicaciones no autorizadas.`, + }; + } + + 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) { + 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 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, persistent: false }, () => refresh()); + watching = true; + } + + function stopWatching() { + if (!watching) return; + if (currentCtx) unwatchFile(join(currentCtx.cwd, STATUS_FILE)); + 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"); + }, + }); + + pi.registerCommand("orquestra-stage", { + description: "Ejecuta un stage Orquestra en un proceso Pi fresco: /orquestra-stage [feature_id]", + handler: async (args, ctx) => { + const parsed = parseStageCommand(args, safeReadJson(ctx.cwd, STATUS_FILE)); + if (parsed.error) { + ctx.ui.notify(parsed.error, "error"); + return; + } + + ctx.ui.notify(`Orquestra ejecutando ${parsed.stage} en Pi fresco…`, "info"); + try { + const output = execFileSync("python3", ["scripts/run_stage.py", parsed.stage!, "--feature-id", parsed.featureId!], { + cwd: ctx.cwd, + encoding: "utf8", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + currentCtx = ctx; + apply(ctx); + ctx.ui.notify(`Orquestra stage terminado: ${lastUsefulLine(output)}`, "info"); + } catch (error) { + const failed = error as { stdout?: string; stderr?: string; message?: string }; + const detail = lastUsefulLine(`${failed.stdout || ""}\n${failed.stderr || ""}`) || failed.message || "error desconocido"; + currentCtx = ctx; + apply(ctx); + ctx.ui.notify(`Orquestra stage falló: ${detail}`, "error"); + } + }, + }); +} diff --git a/platforms/pi/extensions/orquestra-web-fetch.ts b/platforms/pi/extensions/orquestra-web-fetch.ts new file mode 100644 index 0000000..595a78d --- /dev/null +++ b/platforms/pi/extensions/orquestra-web-fetch.ts @@ -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>/i)?.[1]?.trim() || "No title"; + const content = html + .replace(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/]*>[\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 }, + }; + }, + }); +} diff --git a/platforms/pi/leader.prompt.md b/platforms/pi/leader.prompt.md index 79167e4..6dcb47c 100644 --- a/platforms/pi/leader.prompt.md +++ b/platforms/pi/leader.prompt.md @@ -1,5 +1,11 @@ -Actúa como `leader`. -No implementes código de producto. -Orquesta stages según `harness/workflow.stages.yml`. -Aplica anti-trampa de `harness/agents.matrix.yml`. -No marques `done` sin gates APPROVED y `./scripts/verify.sh` en verde. +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. diff --git a/project/.gitkeep b/project/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/agent_status.py b/scripts/agent_status.py new file mode 100755 index 0000000..0416309 --- /dev/null +++ b/scripts/agent_status.py @@ -0,0 +1,382 @@ +#!/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') + state = args.state + + 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}") + + # Block marking close stage as done without all required gates + if stage == 'close' and state == 'done' and feature_id: + missing_gates = [] + for gate_agent in ['reviewer', 'security', 'qa']: + gate_file = GATE_FILES[gate_agent] + if not artifact_ok(feature_id, gate_file, 'APPROVED'): + missing_gates.append(f"{gate_file} (APPROVED)") + if missing_gates: + raise SystemExit(f"No se puede cerrar {feature_id}: faltan gates aprobados: {', '.join(missing_gates)}") + + +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()) diff --git a/scripts/close_feature.py b/scripts/close_feature.py new file mode 100755 index 0000000..462ec5a --- /dev/null +++ b/scripts/close_feature.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Close a feature: validates all gates are approved, updates backlog, and runs commit. +Usage: python3 scripts/close_feature.py +""" +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BACKLOG_PATH = ROOT / 'backlog' / 'features.json' +ARTIFACTS_DIR = ROOT / 'work' / 'artifacts' +COMMIT_SCRIPT = ROOT / 'scripts' / 'commit_feature.sh' + + +def now_iso(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + +def check_gate(feature_id, gate_file, expected_verdict='APPROVED'): + """Check if a gate artifact exists and has the expected verdict.""" + path = ARTIFACTS_DIR / feature_id / gate_file + if not path.exists(): + return False, f"Missing {gate_file}" + try: + data = json.loads(path.read_text(encoding='utf-8')) + if data.get('verdict') != expected_verdict: + return False, f"{gate_file} verdict is {data.get('verdict')}, expected {expected_verdict}" + return True, None + except Exception as e: + return False, f"{gate_file} invalid: {e}" + + +def close_feature(feature_id): + """Close a feature after validating all gates.""" + print(f"Closing feature {feature_id}...") + + # Check all required gates + gates = [ + ('reviewer.json', 'APPROVED'), + ('security.json', 'APPROVED'), + ('qa.json', 'APPROVED'), + ('leader-close.json', None), # Just needs to exist + ] + + errors = [] + for gate_file, verdict in gates: + if verdict: + ok, err = check_gate(feature_id, gate_file, verdict) + else: + path = ARTIFACTS_DIR / feature_id / gate_file + ok = path.exists() + err = f"Missing {gate_file}" if not ok else None + + if not ok: + errors.append(err) + print(f" ❌ {err}") + else: + print(f" ✅ {gate_file}") + + if errors: + print(f"\n[FAIL] Cannot close {feature_id}: {len(errors)} gate(s) missing or invalid") + return 1 + + # Update backlog + if not BACKLOG_PATH.exists(): + print(f"[FAIL] Backlog not found: {BACKLOG_PATH}") + return 1 + + try: + backlog = json.loads(BACKLOG_PATH.read_text(encoding='utf-8')) + except Exception as e: + print(f"[FAIL] Invalid backlog: {e}") + return 1 + + feature_found = False + for feature in backlog.get('features', []): + if feature.get('id') == feature_id: + feature['status'] = 'done' + feature['completed_at'] = now_iso() + feature_found = True + print(f"\n[OK] Updated backlog: {feature_id} -> done") + break + + if not feature_found: + print(f"[FAIL] Feature {feature_id} not found in backlog") + return 1 + + # Save backlog + try: + BACKLOG_PATH.write_text(json.dumps(backlog, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + except Exception as e: + print(f"[FAIL] Cannot save backlog: {e}") + return 1 + + # Run commit script + if COMMIT_SCRIPT.exists(): + print(f"\nRunning commit script...") + result = subprocess.run([str(COMMIT_SCRIPT), feature_id], cwd=ROOT) + if result.returncode != 0: + print(f"[WARN] Commit script returned {result.returncode}") + + print(f"\n✅ Feature {feature_id} closed successfully") + return 0 + + +def main(): + if len(sys.argv) != 2: + print("Usage: python3 scripts/close_feature.py ") + return 1 + + feature_id = sys.argv[1] + return close_feature(feature_id) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/commit_feature.sh b/scripts/commit_feature.sh new file mode 100755 index 0000000..8ef6477 --- /dev/null +++ b/scripts/commit_feature.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Commit and optionally push a completed feature +# Usage: ./scripts/commit_feature.sh + +set -euo pipefail + +FEATURE_ID="${1:-}" +if [ -z "$FEATURE_ID" ]; then + echo "[FAIL] Usage: $0 " >&2 + exit 1 +fi + +# Check if git repo +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[SKIP] Not a git repository, skipping commit" + exit 0 +fi + +# Stage all changes (excluding node_modules, dist, .next, etc.) +git add -A + +# Check if there are changes to commit +if git diff --cached --quiet; then + echo "[SKIP] No changes to commit for $FEATURE_ID" + exit 0 +fi + +# Create commit message +COMMIT_MSG="feat($FEATURE_ID): completed feature" + +# Check if there's a description in the feature +if [ -f "work/current.md" ]; then + # Extract first line of description if available + DESC=$(grep -m1 "^## Description" work/current.md -A1 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//' || true) + if [ -n "$DESC" ]; then + COMMIT_MSG="$COMMIT_MSG + +$DESC" + fi +fi + +# Commit +git commit -m "$COMMIT_MSG" + +echo "[OK] Committed $FEATURE_ID" + +# Check if remote exists and push +if git remote get-url origin >/dev/null 2>&1; then + BRANCH=$(git rev-parse --abbrev-ref HEAD) + echo "[INFO] Pushing to origin/$BRANCH..." + if git push origin "$BRANCH"; then + echo "[OK] Pushed to origin/$BRANCH" + else + echo "[WARN] Push failed (you can push manually later)" + fi +else + echo "[SKIP] No remote 'origin' configured, skipping push" +fi diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..9fe725c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,166 @@ +#!/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" +copy_dir_update "$SOURCE_DIR/docs" "$TARGET_DIR/docs" + +for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/run_stage.py scripts/commit_feature.sh scripts/close_feature.py 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/run_stage.py" "$TARGET_DIR/scripts/install.sh" + +cat </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" +ENGRAM_EXT="$HOME/.pi/agent/npm/node_modules/gentle-engram/index.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 + +if [ ! -f "$ENGRAM_EXT" ]; then + echo "[FAIL] gentle-engram is required for Orquestra memory." >&2 + echo " Install gentle-engram, then run this script again." >&2 + exit 1 +fi + +exec pi --no-extensions --no-skills -e "$ENGRAM_EXT" -e "$STATUS_EXT" -e "$WEB_FETCH_EXT" "$@" diff --git a/scripts/run_stage.py b/scripts/run_stage.py new file mode 100755 index 0000000..e78de76 --- /dev/null +++ b/scripts/run_stage.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / 'harness' / 'workflow.stages.yml' +STATUS_EXT = ROOT / '.pi' / 'extensions' / 'orquestra-status' +WEB_FETCH_EXT = ROOT / '.pi' / 'extensions' / 'orquestra-web-fetch.ts' +ENGRAM_EXT = Path.home() / '.pi' / 'agent' / 'npm' / 'node_modules' / 'gentle-engram' / 'index.ts' + + +def parse_stages(): + stages = {} + current = None + section = None + for raw in WORKFLOW.read_text(encoding='utf-8').splitlines(): + line = raw.rstrip() + if line.startswith(' - name:'): + current = {'input': [], 'output': [], 'post_actions': []} + current['name'] = line.split(':', 1)[1].strip() + stages[current['name']] = current + section = None + continue + if current is None: + continue + if line.startswith(' owner:'): + current['owner'] = line.split(':', 1)[1].strip() + elif line.startswith(' input:'): + section = 'input' + elif line.startswith(' output:'): + section = 'output' + elif line.startswith(' post_actions:'): + section = 'post_actions' + elif section and line.startswith(' - '): + current[section].append(line.split('- ', 1)[1].strip()) + elif line.startswith(' '): + section = None + return stages + + +def expand(paths, feature_id): + return [p.replace('', feature_id) for p in paths] + + +def prompt_for(stage, feature_id): + inputs = expand(stage.get('input', []), feature_id) + outputs = expand(stage.get('output', []), feature_id) + post_actions = expand(stage.get('post_actions', []), feature_id) + owner = stage.get('owner', 'leader') + + post_actions_text = "" + if post_actions: + post_actions_text = f""" +Post-actions (execute after completing the stage): +{chr(10).join(f'- {a}' for a in post_actions)} +""" + + return f"""You are the Orquestra stage agent '{owner}' for feature '{feature_id}'. + +Fresh-process rule: do not rely on previous chat/session context. Use only the files listed here, explicit Engram memories you choose after a narrow search, and repository evidence you read yourself. + +First read these harness rules: +- AGENTS.md +- harness/agents.matrix.yml +- harness/workflow.stages.yml +- harness/contracts/handoff.md + +Stage input paths: +{chr(10).join(f'- {p}' for p in inputs) or '- none'} + +Stage output paths: +{chr(10).join(f'- {p}' for p in outputs) or '- none'} +{post_actions_text} +Rules: +- Before work, run: python3 scripts/agent_status.py set --feature-id {feature_id} --stage {stage['name']} --agent {owner} --state running --action "Running {stage['name']}" +- Read only the stage inputs that exist. If a required input is missing, write a blocked artifact when possible and answer blocked -> . +- Do not carry or summarize previous chat. Previous stage context is on disk in the declared input artifacts. +- Write evidence to the declared output path(s). Do not return code in chat. + - NEVER edit backlog/features.json directly. Use scripts/close_feature.py to close features. +- Before finishing, save to Engram only if this stage produced durable knowledge: a decision, bugfix, non-obvious discovery, reusable convention, or configuration change. Do not save routine progress, command output, or artifact summaries; those belong in the output artifact. +- Finish by updating runtime status to done or blocked. +- Final response must be exactly: done -> or blocked -> . +""" + + +def main(): + parser = argparse.ArgumentParser(description='Run one Orquestra stage in a fresh Pi process with minimal declared context.') + parser.add_argument('stage') + parser.add_argument('--feature-id', required=True) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + stages = parse_stages() + if args.stage not in stages: + print(f"[FAIL] Unknown stage: {args.stage}", file=sys.stderr) + print(f"Allowed: {', '.join(stages)}", file=sys.stderr) + return 2 + + cmd = [ + 'pi', '--no-session', '--no-context-files', '--no-extensions', '--no-skills', + '-e', str(ENGRAM_EXT), '-e', str(STATUS_EXT), '-e', str(WEB_FETCH_EXT), + '-p', prompt_for(stages[args.stage], args.feature_id), + ] + if args.dry_run: + print(' '.join(cmd[:-1])) + print('\n--- prompt ---') + print(cmd[-1]) + return 0 + + missing = [] + if not ENGRAM_EXT.is_file(): + missing.append(str(ENGRAM_EXT)) + if not (STATUS_EXT / 'index.ts').is_file(): + missing.append(str(STATUS_EXT / 'index.ts')) + if not WEB_FETCH_EXT.is_file(): + missing.append(str(WEB_FETCH_EXT)) + if missing: + print('[FAIL] Missing Pi runtime extension(s):', file=sys.stderr) + for path in missing: + print(f' - {path}', file=sys.stderr) + print('Run scripts/install.sh for this project and install gentle-engram.', file=sys.stderr) + return 1 + return subprocess.run(cmd, cwd=ROOT, env=os.environ.copy()).returncode + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/verify.sh b/scripts/verify.sh index ea4b2cc..b654a0c 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -23,12 +23,25 @@ required=( "harness/policies/quality.md" "harness/contracts/handoff.md" "harness/contracts/evidence.schema.json" + "harness/model-routing.yml" + "docs/context-handoff.md" "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" + "scripts/run_stage.py" + "scripts/commit_feature.sh" + "scripts/close_feature.py" + "platforms/pi/README.md" + "platforms/pi/extensions/orquestra-status/index.ts" + "platforms/pi/extensions/orquestra-web-fetch.ts" ) for f in "${required[@]}"; do @@ -40,8 +53,68 @@ for f in "${required[@]}"; do 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 backlog + gates ───────────────────────" +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 + +ENGRAM_EXT="$HOME/.pi/agent/npm/node_modules/gentle-engram/index.ts" +if [ -f "$ENGRAM_EXT" ]; then + ok "Engram instalado: $ENGRAM_EXT" +else + fail "Engram es obligatorio para la memoria Orquestra: falta $ENGRAM_EXT" + 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 @@ -102,12 +175,29 @@ for f in features: 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 "── 3) Verificación de tests/build (opcional auto-detect) ─" +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 @@ -123,15 +213,15 @@ elif [ -d "tests" ]; then if python3 -m unittest discover -s tests -v; then ok "unittest OK"; else fail "unittest falló"; EXIT_CODE=1; fi fi else - warn "No se detectó suite automática (tests/ | Makefile test | package.json test)" + ok "Sin suite de producto detectada; no aplica al harness base" fi echo "" -echo "── 4) Resumen ─────────────────────────────────────────" +echo "── 5) Resumen ─────────────────────────────────────────" if [ $EXIT_CODE -eq 0 ]; then - ok "Harness verificado. Puedes trabajar." + ok "Orquestra verificado. Puedes trabajar." else - fail "Harness NO verificado. Corrige antes de continuar." + fail "Orquestra NO verificado. Corrige antes de continuar." fi exit $EXIT_CODE diff --git a/work/runtime-status.json b/work/runtime-status.json new file mode 100644 index 0000000..a2695ee --- /dev/null +++ b/work/runtime-status.json @@ -0,0 +1,138 @@ +{ + "feature_id": "F-001", + "stage": "intake", + "agent": "leader", + "action": "Gate validation reforzado: agent_status.py bloquea close sin gates + close_feature.py valida antes de actualizar backlog", + "state": "done", + "next_agent": "leader", + "waiting_for": "Probar en producción", + "updated_at": "2026-08-17T05:25:08Z", + "timeline": [ + { + "ts": "2026-08-15T07:05:52Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "status extension hangs" + }, + { + "ts": "2026-08-15T07:08:02Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "verify.sh OK" + }, + { + "ts": "2026-08-15T07:12:07Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "emoji overlap + context handoff" + }, + { + "ts": "2026-08-15T07:13:29Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "verify.sh OK" + }, + { + "ts": "2026-08-15T07:16:37Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "fresh Pi process per stage" + }, + { + "ts": "2026-08-15T07:18:21Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "py_compile + dry-run + verify.sh OK" + }, + { + "ts": "2026-08-15T13:20:09Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "context sharing doc" + }, + { + "ts": "2026-08-15T13:21:11Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "verify.sh + run_stage dry-run OK" + }, + { + "ts": "2026-08-15T15:56:29Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "parent session launcher" + }, + { + "ts": "2026-08-15T15:58:00Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "verify.sh OK; dry-run confirms fresh Pi flags" + }, + { + "ts": "2026-08-15T16:08:27Z", + "agent": "leader", + "stage": "intake", + "state": "running", + "message": "selective Engram save + install target" + }, + { + "ts": "2026-08-15T16:09:07Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "source verify OK; target verify OK; target dry-run OK" + }, + { + "ts": "2026-08-16T22:06:15Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "ALLOWED_WRITE_DIRS + AGENTS.md + docs actualizados" + }, + { + "ts": "2026-08-17T05:19:30Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "scripts/commit_feature.sh + workflow post_actions + docs actualizados" + }, + { + "ts": "2026-08-17T05:24:32Z", + "agent": "leader", + "stage": "close", + "state": "running", + "message": "Starting close" + }, + { + "ts": "2026-08-17T05:24:32Z", + "agent": "leader", + "stage": "close", + "state": "done", + "message": "Test close with all gates" + }, + { + "ts": "2026-08-17T05:24:51Z", + "agent": "leader", + "stage": "close", + "state": "running", + "message": "Starting close" + }, + { + "ts": "2026-08-17T05:25:08Z", + "agent": "leader", + "stage": "intake", + "state": "done", + "message": "Scripts: close_feature.py, agent_status.py actualizado, run_stage.py con instrucción de no editar backlog" + } + ] +}