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
This commit is contained in:
rikrdo
2026-08-17 07:39:03 +02:00
parent 622e5df382
commit 634525fa21
28 changed files with 2296 additions and 284 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.codegraph/
# Local Pi runtime state
.atl/
.env

View File

@@ -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/<feature_id>/`.
- 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.
- **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 <feature_id>`** 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.

170
HOWTO.md
View File

@@ -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 <repo-instalado>
./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/<feature>/...`)
- 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.

233
README.md
View File

@@ -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/<feature>/implementer.md`
- `work/artifacts/<feature>/reviewer.md`
- `work/artifacts/<feature>/security.md`
- `work/artifacts/<feature>/qa.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 de agente siempre: `done -> <ruta>` o `blocked -> <ruta>`.
Respuesta estándar por stage:
- `done -> <ruta>`
- `blocked -> <ruta>`
### 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

View File

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

276
docs/context-handoff.md Normal file
View File

@@ -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/<feature_id>/`.
2. From the active Pi session, `/orquestra-stage <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 <feature_id>` 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/<feature_id>/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/<feature_id>/architect.md
output:
- work/artifacts/<feature_id>/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/<feature_id>/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/<feature_id>/implementer.md
- work/artifacts/<feature_id>/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/<feature_id>/implementer.md
- work/artifacts/<feature_id>/reviewer.json
- work/artifacts/<feature_id>/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.

View File

@@ -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"

View File

@@ -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"],

View File

@@ -5,11 +5,20 @@ Los agentes **no** devuelven código en chat. Deben escribir a disco y responder
- `done -> <ruta>`
- `blocked -> <ruta>`
## 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/<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

23
harness/model-routing.yml Normal file
View File

@@ -0,0 +1,23 @@
version: 1
# Sequential role model hints for Pi. The parent session changes model before
# each stage when a different model is desired. No parallel agents/subagents.
roles:
leader:
model: default
reasoning: medium
architect:
model: strong
reasoning: medium
implementer:
model: default
reasoning: medium
reviewer:
model: strong
reasoning: low
security:
model: strong
reasoning: medium
qa:
model: default
reasoning: low

View File

@@ -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/<feature_id>/architect.md
- name: build
owner: implementer
input:
- work/current.md
- spec/product.md
- spec/tech.md
- spec/acceptance.md
- work/artifacts/<feature_id>/architect.md
output:
- work/artifacts/<feature_id>/implementer.md
- name: review_gate
owner: reviewer
required: true
input:
- work/current.md
- spec/acceptance.md
- work/artifacts/<feature_id>/implementer.md
output:
- work/artifacts/<feature_id>/reviewer.json
- name: security_gate
owner: security
required: true
input:
- work/current.md
- work/artifacts/<feature_id>/implementer.md
- work/artifacts/<feature_id>/reviewer.json
output:
- work/artifacts/<feature_id>/security.json
- name: qa_gate
owner: qa
required: true
input:
- work/current.md
- spec/acceptance.md
- work/artifacts/<feature_id>/implementer.md
- work/artifacts/<feature_id>/reviewer.json
- work/artifacts/<feature_id>/security.json
output:
- work/artifacts/<feature_id>/qa.json
- name: document
owner: documenter
optional: true
when: docs/API/contracts/user-facing behavior changed
input:
- work/current.md
- work/artifacts/<feature_id>/implementer.md
- work/artifacts/<feature_id>/reviewer.json
- work/artifacts/<feature_id>/security.json
- work/artifacts/<feature_id>/qa.json
output:
- work/artifacts/<feature_id>/documenter.md
- name: close
owner: leader
required: true
input:
- work/current.md
- 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
output:
- work/artifacts/<feature_id>/leader-close.json
- work/history.md
post_actions:
- scripts/close_feature.py <feature_id>
close_requirements:
- reviewer.json.verdict == "APPROVED"

View File

@@ -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/<feature_id>/`).
- Ejecutar `./scripts/verify.sh` en pre-close.
## Respuesta estándar de subagentes
- `done -> <ruta>`
- `blocked -> <ruta>`

View File

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

View File

@@ -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/<feature_id>/`.
- 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 <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 <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/<feature_id>/`.
9. Ejecutar `document`/`documenter.md` solo si cambiaron docs/API/contratos/comportamiento user-facing; no es gate obligatorio de cierre.
10. Recién después empieza el siguiente rol/stage.
## Modelos por rol
Si querés modelos distintos por etapa, se eligen secuencialmente antes de cada stage según `harness/model-routing.yml`. No hay ejecución paralela.
## Respuesta estándar por etapa
- `done -> <ruta>`
- `blocked -> <ruta>`

View File

@@ -0,0 +1,42 @@
# Orquestra Status Extension
Pi extension project-local que renderiza `work/runtime-status.json` y aplica el guard mínimo de escritura Orquestra.
## Muestra
- feature activa
- stage actual
- rol actual
- acción/estado
- siguiente rol
- checklist de artefactos por rol
- estado de gates
- último evento de timeline
## Ruta instalada
- `.pi/extensions/orquestra-status/index.ts`
Se lanza junto con `.pi/extensions/orquestra-web-fetch.ts` desde `scripts/pi_orquestra.sh`.
## Uso
1. Abrí `pi` en la raíz del proyecto instalado.
2. Si Pi ya estaba abierto, ejecutá `/reload`.
3. El widget aparece arriba del editor y el footer muestra estado compacto.
4. El widget refresca cada segundo.
5. Refresco manual: `/orquestra-status`.
## Guard de escritura
La extensión intercepta herramientas Pi built-in `write` y `edit`:
- bloquea archivos de producto/código en la raíz (`*.py`, `*.js`, `*.ts`, `*.go`, `*.rs`, `*.java`, `*.php`, `*.rb`); usá `project/`.
- bloquea escrituras en `project/` o `tests/` salvo que `work/runtime-status.json` tenga `feature_id`, `stage=build`, `agent=implementer` y `state=running`.
- bloquea paths que escapan la raíz del repo con `..`.
## Fuente de verdad
- `work/runtime-status.json`
Actualizar con:
```bash
python3 scripts/agent_status.py set ...
python3 scripts/agent_status.py reset
```

View File

@@ -0,0 +1,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<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"]);
// 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<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(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, 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(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 <stage> [feature_id]" };
if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage <stage> <feature_id>" };
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<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 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 <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");
}
},
});
}

View File

@@ -0,0 +1,56 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
function textFromHtml(html: string): { title: string; content: string } {
const title = html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || "No title";
const content = html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/\s+/g, " ")
.trim();
return { title, content };
}
async function fetchText(url: string, signal?: AbortSignal) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
const activeSignal = signal ?? controller.signal;
try {
const response = await fetch(url, {
signal: activeSignal,
headers: {
"User-Agent": "Mozilla/5.0 Orquestra/1.0",
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return textFromHtml(await response.text());
} finally {
clearTimeout(timeout);
}
}
export default function orquestraWebFetch(pi: ExtensionAPI) {
pi.registerTool({
name: "orquestra_web_fetch",
label: "Orquestra Web Fetch",
description: "Fetch a web page and return extracted text using a simple non-browser fetch.",
parameters: Type.Object({
url: Type.String({ description: "URL to fetch" }),
limit: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, description: "Max characters to return" })),
}),
async execute(_toolCallId, params, signal) {
const { title, content } = await fetchText(params.url, signal);
const limit = params.limit ?? 8000;
return {
content: [{ type: "text", text: `Title: ${title}\n\nContent:\n${content.slice(0, limit)}` }],
details: { title, url: params.url, characters: content.length },
};
},
});
}

View File

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

0
project/.gitkeep Normal file
View File

382
scripts/agent_status.py Executable file
View File

@@ -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())

119
scripts/close_feature.py Executable file
View File

@@ -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 <feature_id>
"""
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 <feature_id>")
return 1
feature_id = sys.argv[1]
return close_feature(feature_id)
if __name__ == '__main__':
sys.exit(main())

58
scripts/commit_feature.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Commit and optionally push a completed feature
# Usage: ./scripts/commit_feature.sh <feature_id>
set -euo pipefail
FEATURE_ID="${1:-}"
if [ -z "$FEATURE_ID" ]; then
echo "[FAIL] Usage: $0 <feature_id>" >&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

166
scripts/install.sh Executable file
View File

@@ -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 <<EOF
Orquestra installed/updated safely in: $TARGET_DIR
Next steps:
cd "$TARGET_DIR"
./scripts/verify.sh
./scripts/pi_orquestra.sh
EOF

102
scripts/new_ticket.py Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
import json
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BACKLOG = ROOT / 'backlog' / 'features.json'
TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore')
LEVEL_CHOICES = ('low', 'med', 'high')
def ask(prompt, default=''):
value = input(f"{prompt}{' [' + default + ']' if default else ''}: ").strip()
return value if value else default
def ask_choice(prompt, choices, default):
while True:
value = ask(prompt, default).lower()
if value in choices:
return value
print(f"Invalid value. Use one of: {', '.join(choices)}")
def ask_list(prompt, default_csv=''):
raw = ask(prompt, default_csv)
return [item.strip() for item in raw.split(',') if item.strip()]
def next_id(features):
nums = []
for feature in features:
fid = str(feature.get('id', ''))
if fid.startswith('F-') and fid[2:].isdigit():
nums.append(int(fid[2:]))
return f"F-{(max(nums) + 1) if nums else 1:03d}"
def main():
data = json.loads(BACKLOG.read_text(encoding='utf-8'))
features = data.get('features', [])
print('Create ticket (English caveman style).')
ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature')
title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO')
problem = ask('Problem (short EN)', 'Need change')
goal = ask('Goal (short EN)', 'Make flow better')
scope_in = ask_list('Scope IN (comma list EN)', 'Core flow')
scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign')
risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low')
priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med')
print('Acceptance bullets (EN caveman). Empty line to end.')
acceptance = []
while True:
line = input('- ').strip()
if not line:
break
acceptance.append(line)
if not acceptance:
acceptance = [
'Flow works end to end',
'No break old behavior',
'verify.sh is green',
]
fid = next_id(features)
desc = (
f"Problem: {problem}. "
f"Goal: {goal}. "
f"Scope IN: {', '.join(scope_in) or 'none'}. "
f"Scope OUT: {', '.join(scope_out) or 'none'}. "
f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}."
)
features.append({
'id': fid,
'type': ticket_type,
'title': title,
'problem': problem,
'goal': goal,
'scope_in': scope_in,
'scope_out': scope_out,
'priority': priority,
'risk': risk,
'description': desc,
'acceptance': acceptance,
'status': 'pending',
'created_at': str(date.today()),
'gates': {'review': False, 'security': False, 'qa': False},
})
data['features'] = features
rules = data.setdefault('rules', {})
rules.setdefault('valid_types', list(TYPE_CHOICES))
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
print(f'Created {fid}: {title}')
if __name__ == '__main__':
main()

28
scripts/pi_orquestra.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/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"
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" "$@"

130
scripts/run_stage.py Executable file
View File

@@ -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>', 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 -> <path>.
- 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 -> <path> or blocked -> <path>.
"""
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())

View File

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

138
work/runtime-status.json Normal file
View File

@@ -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"
}
]
}