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:
382
scripts/agent_status.py
Executable file
382
scripts/agent_status.py
Executable 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
119
scripts/close_feature.py
Executable 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
58
scripts/commit_feature.sh
Executable 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
166
scripts/install.sh
Executable 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
102
scripts/new_ticket.py
Executable 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
28
scripts/pi_orquestra.sh
Executable 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
130
scripts/run_stage.py
Executable 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user