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:
371
scripts/agent_status.py
Executable file
371
scripts/agent_status.py
Executable 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
165
scripts/install.sh
Executable 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
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()
|
||||
21
scripts/pi_orquestra.sh
Executable file
21
scripts/pi_orquestra.sh
Executable 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
215
scripts/verify.sh
Executable 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
|
||||
Reference in New Issue
Block a user