chore(harness): isolate pi stage execution

This commit is contained in:
rikrdo
2026-08-15 09:51:25 +02:00
parent 546971280f
commit cf1c69fc8b
9 changed files with 205 additions and 24 deletions

View File

@@ -7,6 +7,7 @@ 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: "🧭",
@@ -100,8 +101,8 @@ function gateSummary(root: string, featureId: string | null | undefined, emojis:
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(" ")}`;
.map((gate) => `${emojis[gate] || "•"} ${gates[gate] === "APPROVED" ? "✅" : gates[gate] === "PRESENT" ? "⚠️" : "⏳"}`)
.join(STATUS_GAP)}`;
}
function artifactStatus(root: string, featureId: string | null | undefined, agent: string): string {
@@ -127,9 +128,9 @@ function agentSummary(root: string, status: RuntimeStatus, emojis: Record<string
.map((agent) => {
const state = artifactStatus(root, featureId, agent);
const mark = state === "DONE" ? "✅" : state === "PRESENT" ? "⚠️" : agent === currentAgent ? "▶️" : "⏳";
return `${emojis[agent] || "•"}${mark}`;
return `${emojis[agent] || "•"} ${mark}`;
})
.join(" ")}`;
.join(STATUS_GAP)}`;
}
function isInsideRel(relPath: string, dirname: string): boolean {
@@ -229,7 +230,6 @@ function render(root: string, status: RuntimeStatus | null, emojis: Record<strin
export default function orquestraStatusExtension(pi: ExtensionAPI) {
let currentCtx: ExtensionContext | null = null;
let watching = false;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let lastRendered = "";
function apply(ctx: ExtensionContext) {
@@ -251,16 +251,13 @@ export default function orquestraStatusExtension(pi: ExtensionAPI) {
function startWatching() {
if (watching) return;
if (!currentCtx) return;
watchFile(join(currentCtx.cwd, STATUS_FILE), { interval: 1000 }, () => refresh());
refreshTimer = setInterval(refresh, 1000);
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));
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
watching = false;
}

View File

@@ -5,6 +5,14 @@ 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`

View File

@@ -15,29 +15,54 @@ 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
@@ -45,12 +70,25 @@ stages:
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

View File

@@ -4,8 +4,9 @@ Orquestra se ejecuta desde Pi como **un solo parent session secuencial**. No ins
## 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` y carga solo extensiones Orquestra.
- 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.
@@ -16,12 +17,19 @@ Cuando Orquestra se instala en un repo de proyecto, el instalador debe copiar:
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, ejecutar cada stage con `python3 scripts/run_stage.py <stage> --feature-id <feature_id>`; usa `pi --no-session --no-context-files` y carga solo Engram + extensiones Orquestra.
## Flujo secuencial
1. Ejecutar `./scripts/verify.sh`.
2. Abrir Pi limpio desde la raíz con `./scripts/pi_orquestra.sh`.
3. Confirmar el widget con `/orquestra-status`.
4. El mismo parent session cambia de rol siguiendo `harness/workflow.stages.yml`.
5. Antes de cada stage, actualizar estado con `python3 scripts/agent_status.py set ...`.
4. Ejecutar cada stage como proceso fresco: `python3 scripts/run_stage.py <stage> --feature-id <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>/`.

View File

@@ -7,6 +7,7 @@ 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: "🧭",
@@ -100,8 +101,8 @@ function gateSummary(root: string, featureId: string | null | undefined, emojis:
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(" ")}`;
.map((gate) => `${emojis[gate] || "•"} ${gates[gate] === "APPROVED" ? "✅" : gates[gate] === "PRESENT" ? "⚠️" : "⏳"}`)
.join(STATUS_GAP)}`;
}
function artifactStatus(root: string, featureId: string | null | undefined, agent: string): string {
@@ -127,9 +128,9 @@ function agentSummary(root: string, status: RuntimeStatus, emojis: Record<string
.map((agent) => {
const state = artifactStatus(root, featureId, agent);
const mark = state === "DONE" ? "✅" : state === "PRESENT" ? "⚠️" : agent === currentAgent ? "▶️" : "⏳";
return `${emojis[agent] || "•"}${mark}`;
return `${emojis[agent] || "•"} ${mark}`;
})
.join(" ")}`;
.join(STATUS_GAP)}`;
}
function isInsideRel(relPath: string, dirname: string): boolean {
@@ -229,7 +230,6 @@ function render(root: string, status: RuntimeStatus | null, emojis: Record<strin
export default function orquestraStatusExtension(pi: ExtensionAPI) {
let currentCtx: ExtensionContext | null = null;
let watching = false;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let lastRendered = "";
function apply(ctx: ExtensionContext) {
@@ -251,16 +251,13 @@ export default function orquestraStatusExtension(pi: ExtensionAPI) {
function startWatching() {
if (watching) return;
if (!currentCtx) return;
watchFile(join(currentCtx.cwd, STATUS_FILE), { interval: 1000 }, () => refresh());
refreshTimer = setInterval(refresh, 1000);
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));
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
watching = false;
}

View File

@@ -125,7 +125,7 @@ done
copy_dir_update "$SOURCE_DIR/harness" "$TARGET_DIR/harness"
copy_dir_update "$SOURCE_DIR/platforms/pi" "$TARGET_DIR/platforms/pi"
for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/install.sh; do
for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/run_stage.py scripts/install.sh; do
copy_file_update "$SOURCE_DIR/$file" "$TARGET_DIR/$file"
done
@@ -152,7 +152,7 @@ if [ -d "$TARGET_DIR/.pi/subagents" ] || [ -f "$TARGET_DIR/.pi/subagents.json" ]
warn "Found .pi/subagents or .pi/subagents.json in target. Orquestra does not delete user Pi config automatically; remove Orquestra-owned stale subagent files manually before running verify.sh."
fi
chmod +x "$TARGET_DIR/scripts/verify.sh" "$TARGET_DIR/scripts/pi_orquestra.sh" "$TARGET_DIR/scripts/install.sh"
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

View File

@@ -11,6 +11,7 @@ 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
@@ -18,4 +19,10 @@ if [ ! -f "$STATUS_EXT/index.ts" ] || [ ! -f "$WEB_FETCH_EXT" ]; then
exit 1
fi
exec pi --no-extensions --no-skills -e "$STATUS_EXT" -e "$WEB_FETCH_EXT" "$@"
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" "$@"

117
scripts/run_stage.py Executable file
View File

@@ -0,0 +1,117 @@
#!/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': []}
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 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)
owner = stage.get('owner', 'leader')
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'}
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.
- 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

@@ -35,6 +35,7 @@ required=(
"scripts/agent_status.py"
"scripts/new_ticket.py"
"scripts/pi_orquestra.sh"
"scripts/run_stage.py"
"platforms/pi/README.md"
"platforms/pi/extensions/orquestra-status/index.ts"
"platforms/pi/extensions/orquestra-web-fetch.ts"
@@ -79,6 +80,14 @@ else
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