From cf1c69fc8ba3051fa48430458a43ec687f90f118 Mon Sep 17 00:00:00 2001 From: rikrdo Date: Sat, 15 Aug 2026 09:51:25 +0200 Subject: [PATCH] chore(harness): isolate pi stage execution --- .pi/extensions/orquestra-status/index.ts | 15 +-- harness/contracts/handoff.md | 8 ++ harness/workflow.stages.yml | 38 ++++++ platforms/pi/README.md | 14 ++- .../pi/extensions/orquestra-status/index.ts | 15 +-- scripts/install.sh | 4 +- scripts/pi_orquestra.sh | 9 +- scripts/run_stage.py | 117 ++++++++++++++++++ scripts/verify.sh | 9 ++ 9 files changed, 205 insertions(+), 24 deletions(-) create mode 100755 scripts/run_stage.py diff --git a/.pi/extensions/orquestra-status/index.ts b/.pi/extensions/orquestra-status/index.ts index 30efef9..78f164c 100644 --- a/.pi/extensions/orquestra-status/index.ts +++ b/.pi/extensions/orquestra-status/index.ts @@ -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 = { 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 { 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 | 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; } diff --git a/harness/contracts/handoff.md b/harness/contracts/handoff.md index 1fe2f47..9c199b6 100644 --- a/harness/contracts/handoff.md +++ b/harness/contracts/handoff.md @@ -5,6 +5,14 @@ Los agentes **no** devuelven código en chat. Deben escribir a disco y responder - `done -> ` - `blocked -> ` +## Contexto mínimo por stage +Cada stage recibe solo: +1. Los archivos listados en `harness/workflow.stages.yml` como `input`. +2. El artefacto del stage anterior cuando el workflow lo declare. +3. La memoria Engram que el leader seleccione explícitamente para ese stage. + +No se considera válido pasar todo el chat anterior como contexto operativo. Si un stage necesita más información, debe pedir una ruta o artefacto concreto y bloquear hasta tenerlo. + ## Rutas estándar por feature - `work/artifacts//implementer.md` - `work/artifacts//reviewer.json` diff --git a/harness/workflow.stages.yml b/harness/workflow.stages.yml index 221cd0e..24ab58f 100644 --- a/harness/workflow.stages.yml +++ b/harness/workflow.stages.yml @@ -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//architect.md - name: build owner: implementer + input: + - work/current.md + - spec/product.md + - spec/tech.md + - spec/acceptance.md + - work/artifacts//architect.md output: - work/artifacts//implementer.md - name: review_gate owner: reviewer required: true + input: + - work/current.md + - spec/acceptance.md + - work/artifacts//implementer.md output: - work/artifacts//reviewer.json - name: security_gate owner: security required: true + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json output: - work/artifacts//security.json - name: qa_gate owner: qa required: true + input: + - work/current.md + - spec/acceptance.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json output: - work/artifacts//qa.json @@ -45,12 +70,25 @@ stages: owner: documenter optional: true when: docs/API/contracts/user-facing behavior changed + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json + - work/artifacts//qa.json output: - work/artifacts//documenter.md - name: close owner: leader required: true + input: + - work/current.md + - work/artifacts//implementer.md + - work/artifacts//reviewer.json + - work/artifacts//security.json + - work/artifacts//qa.json + - work/artifacts//documenter.md output: - work/artifacts//leader-close.json - work/history.md diff --git a/platforms/pi/README.md b/platforms/pi/README.md index 8e0c97a..d753cc4 100644 --- a/platforms/pi/README.md +++ b/platforms/pi/README.md @@ -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 --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 --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//`. diff --git a/platforms/pi/extensions/orquestra-status/index.ts b/platforms/pi/extensions/orquestra-status/index.ts index 30efef9..78f164c 100644 --- a/platforms/pi/extensions/orquestra-status/index.ts +++ b/platforms/pi/extensions/orquestra-status/index.ts @@ -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 = { 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 { 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 | 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; } diff --git a/scripts/install.sh b/scripts/install.sh index 21802af..a312a9c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 <&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" "$@" diff --git a/scripts/run_stage.py b/scripts/run_stage.py new file mode 100755 index 0000000..ac7227d --- /dev/null +++ b/scripts/run_stage.py @@ -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) 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 -> . +- 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 -> or blocked -> . +""" + + +def main(): + parser = argparse.ArgumentParser(description='Run one Orquestra stage in a fresh Pi process with minimal declared context.') + parser.add_argument('stage') + parser.add_argument('--feature-id', required=True) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + stages = parse_stages() + if args.stage not in stages: + print(f"[FAIL] Unknown stage: {args.stage}", file=sys.stderr) + print(f"Allowed: {', '.join(stages)}", file=sys.stderr) + return 2 + + cmd = [ + 'pi', '--no-session', '--no-context-files', '--no-extensions', '--no-skills', + '-e', str(ENGRAM_EXT), '-e', str(STATUS_EXT), '-e', str(WEB_FETCH_EXT), + '-p', prompt_for(stages[args.stage], args.feature_id), + ] + if args.dry_run: + print(' '.join(cmd[:-1])) + print('\n--- prompt ---') + print(cmd[-1]) + return 0 + + missing = [] + if not ENGRAM_EXT.is_file(): + missing.append(str(ENGRAM_EXT)) + if not (STATUS_EXT / 'index.ts').is_file(): + missing.append(str(STATUS_EXT / 'index.ts')) + if not WEB_FETCH_EXT.is_file(): + missing.append(str(WEB_FETCH_EXT)) + if missing: + print('[FAIL] Missing Pi runtime extension(s):', file=sys.stderr) + for path in missing: + print(f' - {path}', file=sys.stderr) + print('Run scripts/install.sh for this project and install gentle-engram.', file=sys.stderr) + return 1 + return subprocess.run(cmd, cwd=ROOT, env=os.environ.copy()).returncode + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/verify.sh b/scripts/verify.sh index a6507a8..6ee4599 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -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