feat(F-001): scaffold modular monolith skeleton with boundary checker

- TypeScript + Fastify skeleton under project/ (src/modules, shared, infrastructure, app)
- scripts/check-module-boundaries.mjs enforcing module public-API rules (tested with fixtures)
- GET /health endpoint, error envelope without stack leakage
- specs/F-001-scaffold (SPEC/DESIGN/TASKS/TESTS), spec/tech.md dependency justification
- 30-ticket MercadoDeVida roadmap in backlog/features.json, spec/roadmap.md
- All gates approved: reviewer, security, qa; verify.sh green
This commit is contained in:
rikrdo
2026-08-14 21:46:54 +02:00
commit 1d4eebca54
76 changed files with 9430 additions and 0 deletions

371
scripts/agent_status.py Executable file
View File

@@ -0,0 +1,371 @@
#!/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')
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}")
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())

165
scripts/install.sh Executable file
View File

@@ -0,0 +1,165 @@
#!/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"
for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh 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/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()

21
scripts/pi_orquestra.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/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"
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
exec pi --no-extensions --no-skills -e "$STATUS_EXT" -e "$WEB_FETCH_EXT" "$@"

215
scripts/verify.sh Executable file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env bash
set -u
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
ok() { printf "${GREEN}[OK]${NC} %s\n" "$1"; }
warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1"; }
fail() { printf "${RED}[FAIL]${NC} %s\n" "$1"; }
EXIT_CODE=0
echo "── 1) Verificando estructura base ─────────────────────"
required=(
"AGENTS.md"
"CHECKPOINTS.md"
"harness/agents.matrix.yml"
"harness/workflow.stages.yml"
"harness/policies/governance.md"
"harness/policies/security.md"
"harness/policies/quality.md"
"harness/contracts/handoff.md"
"harness/contracts/evidence.schema.json"
"harness/model-routing.yml"
"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"
"platforms/pi/README.md"
"platforms/pi/extensions/orquestra-status/index.ts"
"platforms/pi/extensions/orquestra-web-fetch.ts"
)
for f in "${required[@]}"; do
if [ -f "$f" ]; then
ok "Existe $f"
else
fail "Falta $f"
EXIT_CODE=1
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 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
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
import sys
root = pathlib.Path('.')
path = root / 'backlog' / 'features.json'
try:
data = json.loads(path.read_text(encoding='utf-8'))
except Exception as e:
print(f"[FAIL] backlog/features.json inválido: {e}")
sys.exit(1)
valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"]))
features = data.get('features', [])
if not isinstance(features, list):
print('[FAIL] features debe ser una lista')
sys.exit(1)
in_progress = [f for f in features if f.get('status') == 'in_progress']
if len(in_progress) > 1:
print(f"[FAIL] Hay {len(in_progress)} features in_progress (máximo 1)")
sys.exit(1)
for f in features:
fid = str(f.get('id', '')).strip()
status = f.get('status')
if status not in valid:
print(f"[FAIL] Estado inválido en feature {fid}: {status}")
sys.exit(1)
if status == 'done':
d = root / 'work' / 'artifacts' / fid
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json']
missing = [name for name in req if not (d / name).is_file()]
if missing:
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
sys.exit(1)
expected = {
'reviewer.json': 'reviewer',
'security.json': 'security',
'qa.json': 'qa',
'leader-close.json': 'leader',
}
for filename, agent in expected.items():
try:
obj = json.loads((d / filename).read_text(encoding='utf-8'))
except Exception as e:
print(f"[FAIL] {fid}/{filename} inválido: {e}")
sys.exit(1)
if obj.get('agent') != agent:
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
sys.exit(1)
if obj.get('verdict') != 'APPROVED':
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 "── 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
if command -v npm >/dev/null 2>&1; then
if npm test --silent --if-present; then ok "npm test OK"; else fail "npm test falló"; EXIT_CODE=1; fi
else
warn "package.json detectado pero npm no está disponible"
fi
elif [ -d "tests" ]; then
if command -v pytest >/dev/null 2>&1; then
if pytest -q; then ok "pytest OK"; else fail "pytest falló"; EXIT_CODE=1; fi
else
if python3 -m unittest discover -s tests -v; then ok "unittest OK"; else fail "unittest falló"; EXIT_CODE=1; fi
fi
else
ok "Sin suite de producto detectada; no aplica al harness base"
fi
echo ""
echo "── 5) Resumen ─────────────────────────────────────────"
if [ $EXIT_CODE -eq 0 ]; then
ok "Orquestra verificado. Puedes trabajar."
else
fail "Orquestra NO verificado. Corrige antes de continuar."
fi
exit $EXIT_CODE