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

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