refactor: make ARNES external-repo based with ticket publish flow

This commit is contained in:
rikrdo
2026-05-18 00:26:32 +02:00
parent 3ff9b70e4c
commit b396b6d3c9
101 changed files with 810 additions and 6140 deletions

View File

@@ -8,7 +8,9 @@ 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': '🧭',
@@ -26,6 +28,7 @@ GATE_FILES = {
'security': 'security.json',
'qa': 'qa.json',
'documenter': 'documenter.md',
'publish': 'publish.json',
'leader': 'leader-close.json',
}
@@ -60,6 +63,28 @@ def load_role_emojis():
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,
@@ -99,7 +124,8 @@ def gate_status(feature_id):
continue
try:
payload = json.loads(path.read_text(encoding='utf-8'))
gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present'
wanted = 'PUBLISHED' if gate == 'publish' else 'APPROVED'
gates[gate] = 'approved' if payload.get('verdict') == wanted else 'present'
except Exception:
gates[gate] = 'invalid'
return gates
@@ -115,10 +141,25 @@ def render_gate(gate, state, emojis):
label = {
'leader': 'close',
'documenter': 'docs',
'publish': 'publish',
}.get(gate, gate)
return f"{icon} {emojis.get(gate, '')} {label}: {state.upper()}"
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()
@@ -141,7 +182,7 @@ def show_status():
print()
print('Gates')
if gates:
for gate in ['reviewer', 'security', 'qa', 'documenter', 'leader']:
for gate in ['reviewer', 'security', 'qa', 'documenter', 'publish', 'leader']:
print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}")
else:
print(' — Sin feature activa —')
@@ -162,6 +203,7 @@ def show_status():
def set_status(args):
validate_runtime_args(args)
status = load_status()
if args.feature_id is not None:
status['feature_id'] = args.feature_id or None

66
scripts/install_into_repo.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET_INPUT="${1:-}"
if [ -z "$TARGET_INPUT" ]; then
echo "Usage: ./scripts/install_into_repo.sh /path/to/target-repo"
exit 1
fi
mkdir -p "$TARGET_INPUT"
TARGET_ROOT="$(cd "$TARGET_INPUT" && pwd)"
if [ "$TARGET_ROOT" = "$SRC_ROOT" ]; then
echo "Refusing to install ARNES into its own source repository. Use a different project repo."
exit 1
fi
if ! git -C "$TARGET_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "No git repo detected at target. Initializing git repository..."
git -C "$TARGET_ROOT" init >/dev/null
fi
copy_item() {
local item="$1"
if [ -d "$SRC_ROOT/$item" ]; then
mkdir -p "$TARGET_ROOT/$item"
cp -R "$SRC_ROOT/$item"/. "$TARGET_ROOT/$item"/
else
cp "$SRC_ROOT/$item" "$TARGET_ROOT/$item"
fi
}
ITEMS=(
AGENTS.md
AGENTS.local.md.example
CHECKPOINTS.md
HOWTO.md
HOWTO-FEATURE.md
README.md
TEMPLATE.md
Makefile
requirements.txt
backlog
defaults
docs
features
harness
platforms
project
scripts
spec
starter-pack
work
)
for item in "${ITEMS[@]}"; do
copy_item "$item"
done
echo "Installed ARNES core into: $TARGET_ROOT"
echo "Next steps:"
echo " cd $TARGET_ROOT"
echo " ./scripts/start.sh"
echo " ./scripts/verify.sh"

View File

@@ -5,6 +5,8 @@ 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=''):
@@ -12,10 +14,23 @@ def ask(prompt, default=''):
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 f in features:
fid = str(f.get('id', ''))
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}"
@@ -26,14 +41,14 @@ def main():
features = data.get('features', [])
print('Create ticket (English caveman style).')
ttype = ask('Type (feature/fix/bug/chore)', 'feature')
title = ask('Title (short EN)', f'{ttype.capitalize()} TODO')
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('Scope IN (comma list EN)', 'Core flow')
scope_out = ask('Scope OUT (comma list EN)', 'No redesign')
risk = ask('Risk (low/med/high)', 'low')
priority = ask('Priority (low/med/high)', 'med')
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 = []
@@ -47,29 +62,38 @@ def main():
acceptance = [
'Flow works end to end',
'No break old behavior',
'verify.sh is green'
'verify.sh is green',
]
fid = next_id(features)
desc = (
f"Problem: {problem}. "
f"Goal: {goal}. "
f"Scope IN: {scope_in}. "
f"Scope OUT: {scope_out}. "
f"Type: {ttype}. Priority: {priority}. Risk: {risk}."
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}
'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}')

133
scripts/publish_ticket.py Executable file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
import argparse
import json
import subprocess
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BACKLOG = ROOT / 'backlog' / 'features.json'
ARTIFACTS = ROOT / 'work' / 'artifacts'
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
def run_git(*args, check=True):
result = subprocess.run(
['git', *args],
cwd=ROOT,
text=True,
capture_output=True,
)
if check and result.returncode != 0:
msg = result.stderr.strip() or result.stdout.strip() or f'git command failed: {args}'
raise SystemExit(msg)
return result
def ensure_git_repo():
result = run_git('rev-parse', '--is-inside-work-tree', check=False)
if result.returncode != 0 or result.stdout.strip() != 'true':
raise SystemExit('This project is not inside a git repository. Run git init or clone a repo first.')
def load_backlog():
return json.loads(BACKLOG.read_text(encoding='utf-8'))
def find_feature(feature_id):
data = load_backlog()
for feature in data.get('features', []):
if str(feature.get('id')) == feature_id:
return feature
raise SystemExit(f'Feature not found in backlog: {feature_id}')
def current_branch():
branch = run_git('symbolic-ref', '--quiet', '--short', 'HEAD', check=False).stdout.strip()
if not branch:
branch = run_git('rev-parse', '--abbrev-ref', 'HEAD', check=False).stdout.strip()
if not branch or branch == 'HEAD':
raise SystemExit('Detached HEAD is not supported for publish. Checkout a branch first.')
return branch
def ensure_remote(remote):
remotes = [line.strip() for line in run_git('remote').stdout.splitlines() if line.strip()]
if remote not in remotes:
raise SystemExit(f'Remote not found: {remote}. Add it first with git remote add {remote} <url>.')
def status_porcelain():
return run_git('status', '--porcelain').stdout.strip()
def default_commit_message(feature):
feature_id = feature['id']
ticket_type = feature.get('type')
title = str(feature.get('title', '')).strip()
if ticket_type:
return f'{feature_id} {ticket_type}: {title}'
return f'{feature_id}: {title}'
def write_publish_artifact(feature_id, payload):
feature_dir = ARTIFACTS / feature_id
feature_dir.mkdir(parents=True, exist_ok=True)
path = feature_dir / 'publish.json'
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
return path
def ensure_git_identity():
name = run_git('config', 'user.name', check=False).stdout.strip()
email = run_git('config', 'user.email', check=False).stdout.strip()
if not name or not email:
raise SystemExit('Missing git identity. Configure git user.name and user.email before publish.')
def main():
parser = argparse.ArgumentParser(description='Commit and push one ticket, then write publish.json evidence.')
parser.add_argument('--feature-id', required=True)
parser.add_argument('--remote', default='origin')
parser.add_argument('--branch', default='')
parser.add_argument('--commit-message', default='')
args = parser.parse_args()
ensure_git_repo()
ensure_git_identity()
feature = find_feature(args.feature_id)
remote = args.remote.strip() or 'origin'
branch = args.branch.strip() or current_branch()
ensure_remote(remote)
if not status_porcelain():
raise SystemExit('No git changes to publish. Nothing to commit.')
commit_message = args.commit_message.strip() or default_commit_message(feature)
payload = {
'agent': 'leader',
'verdict': 'PUBLISHED',
'feature_id': args.feature_id,
'branch': branch,
'remote': remote,
'message': commit_message,
'pushed': True,
'published_at': now_iso(),
'note': 'This artifact is committed inside the publish commit for this ticket.'
}
artifact_path = write_publish_artifact(args.feature_id, payload)
run_git('add', '-A')
if not status_porcelain():
raise SystemExit('No staged git changes after git add -A. Nothing to commit.')
run_git('commit', '-m', commit_message)
run_git('push', remote, branch)
print(f'done -> {artifact_path}')
if __name__ == '__main__':
main()

View File

@@ -1,36 +0,0 @@
#!/bin/bash
# Script para arrancar el servidor ARNES UI API
set -e
cd "$(dirname "$0")"
# Configuración
PORT=${1:-8000}
HOST="0.0.0.0"
# Colores
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} ARNES API - Starting...${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e " URL: ${YELLOW}http://localhost:${PORT}/ui/login.html${NC}"
echo -e " Host: ${YELLOW}${HOST}:${PORT}${NC}"
echo ""
echo -e " Credenciales de prueba:"
echo -e " Email: ${YELLOW}alice@example.com${NC}"
echo -e " Password: ${YELLOW}SecurePass123!${NC}"
echo ""
# Instalar dependencias si falta
if ! python3 -c "import fastapi" 2>/dev/null; then
echo -e "${YELLOW}Instalando dependencias...${NC}"
pip3 install -q fastapi uvicorn pydantic PyJWT bcrypt httpx
fi
# Arrancar servidor
exec python3 -m uvicorn src.main:app --host "$HOST" --port "$PORT" --reload

View File

@@ -16,12 +16,16 @@ ask() {
}
echo "=== ARNES start wizard ==="
echo "Mode: use this template in a new repo or copy core ARNES into an existing repo."
echo "Mode: clone arnes-fork, put your app folder inside, run this wizard."
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "No git repo detected. Initializing local git repository..."
git init >/dev/null
fi
PROJECT_NAME="$(ask 'Project name' 'my-project')"
PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')"
APP_DIR="$(ask 'App directory (relative)' 'app')"
APP_DIR="$(ask 'App directory (relative)' 'project')"
STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')"
if [ "$STACK_CHOICE" = "2" ]; then
@@ -40,12 +44,22 @@ MODEL_MODE="$(ask 'Model mode (lean/balanced/power)' 'lean')"
ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')"
mkdir -p "$APP_DIR"
[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" <<EOF
# Project code
This directory holds the real project code.
Configured by ARNES start wizard.
EOF
if [ "$BACKEND" = "python/flask" ]; then
mkdir -p "$APP_DIR/templates" "$APP_DIR/static/js" "$APP_DIR/static/css" "$APP_DIR/static/images"
fi
if [ "$CSSFW" = "skeleton" ]; then
mkdir -p "$APP_DIR/static/css" "$APP_DIR/static/images"
cp -n defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css" || true
cp -n defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css" || true
cp -n defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png" || true
[ -f "$APP_DIR/static/css/normalize.css" ] || cp defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css"
[ -f "$APP_DIR/static/css/skeleton.css" ] || cp defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css"
[ -f "$APP_DIR/static/images/favicon.png" ] || cp defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png"
fi
cat > harness/project.config.json <<JSON
@@ -82,7 +96,7 @@ APP_DIR=$(python3 - <<'PY'
import json
from pathlib import Path
cfg=json.loads(Path('harness/project.config.json').read_text())
print(cfg.get('app_dir','app'))
print(cfg.get('app_dir','project'))
PY
)
TEST_CMD=$(python3 - <<'PY'
@@ -126,25 +140,34 @@ import json
from pathlib import Path
from datetime import date
b=Path('backlog/features.json')
data=json.loads(b.read_text(encoding='utf-8'))
data['project']='$PROJECT_NAME'
data['description']='$PROJECT_DESC'
features=data.get('features',[])
b = Path('backlog/features.json')
data = json.loads(b.read_text(encoding='utf-8'))
data['project'] = '$PROJECT_NAME'
data['description'] = '$PROJECT_DESC'
rules = data.setdefault('rules', {})
rules.setdefault('valid_types', ['feature', 'fix', 'bug', 'chore'])
features = data.get('features', [])
if '$ADD_BOOTSTRAP'.lower().startswith('y') and not features:
features.append({
'id':'F-001',
'title':'Bootstrap ARNES on project',
'description':'Setup ARNES pipeline and run first complete feature cycle.',
'acceptance':['verify.sh is green','runtime status works','first feature closes with gates'],
'status':'pending',
'created_at':str(date.today()),
'gates':{'review':False,'security':False,'qa':False}
'id': 'F-001',
'type': 'chore',
'title': 'Bootstrap ARNES on project',
'problem': 'Need base workflow and control',
'goal': 'Make ARNES ready on this repo',
'scope_in': ['Harness setup', 'Runtime status', 'First verify cycle'],
'scope_out': ['Business feature work', 'Product redesign'],
'priority': 'med',
'risk': 'low',
'description': 'Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.',
'acceptance': ['verify.sh is green', 'runtime status works', 'first feature closes with gates'],
'status': 'pending',
'created_at': str(date.today()),
'gates': {'review': False, 'security': False, 'qa': False}
})
data['features']=features
b.write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
data['features'] = features
b.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
PY
cat > work/current.md <<EOF
@@ -169,5 +192,6 @@ echo "Done. Project configured."
echo "- Config: harness/project.config.json"
echo "- Local checks: scripts/verify.local.sh"
echo "- Ticket tool: python3 scripts/new_ticket.py"
echo "- Publish tool: python3 scripts/publish_ticket.py --feature-id F-001"
echo "- Verify: ./scripts/verify.sh"
echo "- Runtime: python3 scripts/agent_status.py show"

View File

@@ -1,93 +0,0 @@
"""Test script for the API."""
import sys
import time
import subprocess
import requests
from threading import Thread
SERVER_URL = "http://127.0.0.1:8000"
def start_server():
"""Start the uvicorn server."""
subprocess.run([
"python3", "-m", "uvicorn",
"src.main:app",
"--host", "127.0.0.1",
"--port", "8000"
])
def wait_for_server(timeout=10):
"""Wait for server to be ready."""
start = time.time()
while time.time() - start < timeout:
try:
response = requests.get(f"{SERVER_URL}/health", timeout=1)
if response.status_code == 200:
return True
except:
pass
time.sleep(0.5)
return False
def test_health():
"""Test health endpoint."""
response = requests.get(f"{SERVER_URL}/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
print("✅ Health check passed")
def test_login():
"""Test login endpoint."""
response = requests.post(
f"{SERVER_URL}/api/v1/auth/login",
json={"email": "alice@example.com", "password": "SecurePass123!"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] == True
assert "access_token" in data["data"]
print("✅ Login endpoint passed")
return data["data"]["access_token"]
def test_login_invalid():
"""Test login with invalid credentials."""
response = requests.post(
f"{SERVER_URL}/api/v1/auth/login",
json={"email": "alice@example.com", "password": "WrongPassword!"}
)
assert response.status_code == 401
print("✅ Invalid login returns 401")
def test_profile():
"""Test profile endpoint."""
response = requests.get(f"{SERVER_URL}/api/v1/profile/me")
assert response.status_code == 200
print("✅ Profile endpoint passed")
def run_tests():
"""Run all tests."""
print("🔧 Starting server...")
server_thread = Thread(target=start_server, daemon=True)
server_thread.start()
print("⏳ Waiting for server...")
if not wait_for_server():
print("❌ Server failed to start")
return False
print("✅ Server is ready!\n")
try:
test_health()
test_login()
test_login_invalid()
test_profile()
print("\n🎉 All tests passed!")
return True
except Exception as e:
print(f"\n❌ Test failed: {e}")
return False
if __name__ == "__main__":
success = run_tests()
sys.exit(0 if success else 1)

View File

@@ -18,6 +18,11 @@ echo "── 1) Verificando estructura base ────────────
required=(
"AGENTS.md"
"CHECKPOINTS.md"
"README.md"
"HOWTO.md"
"TEMPLATE.md"
"docs/repository-layout.md"
"docs/scripts-reference.md"
"harness/agents.matrix.yml"
"harness/workflow.stages.yml"
"harness/policies/governance.md"
@@ -31,14 +36,24 @@ required=(
"spec/product.md"
"spec/tech.md"
"spec/acceptance.md"
"spec/bdd/README.md"
"spec/bdd/features/README.md"
"spec/sdd/README.md"
"spec/sdd/components/README.md"
"spec/sdd/decisions/README.md"
"features/README.md"
"project/README.md"
"backlog/features.json"
"work/current.md"
"work/history.md"
"work/runtime-status.json"
"scripts/agent_status.py"
"scripts/new_ticket.py"
"scripts/publish_ticket.py"
"scripts/install_into_repo.sh"
"scripts/start.sh"
"platforms/pi/README.md"
"platforms/opencode/README.md"
)
for f in "${required[@]}"; do
@@ -50,6 +65,20 @@ for f in "${required[@]}"; do
fi
done
echo ""
echo "── 1.5) Validando git repo ───────────────────────────"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
ok "Git repo detectado"
if git remote | grep -q .; then
ok "Git remote configurado"
else
warn "Sin git remote configurado (publish requerirá remote)"
fi
else
fail "Este proyecto debe vivir dentro de un git repo"
EXIT_CODE=1
fi
echo ""
echo "── 2) Validando backlog + gates ───────────────────────"
python3 - <<'PY'
@@ -59,6 +88,7 @@ import sys
root = pathlib.Path('.')
path = root / 'backlog' / 'features.json'
level_choices = {'low', 'med', 'high'}
try:
data = json.loads(path.read_text(encoding='utf-8'))
@@ -66,7 +96,9 @@ 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"]))
rules = data.get('rules', {})
valid_status = set(rules.get('valid_status', ["pending", "in_progress", "blocked", "done"]))
valid_types = set(rules.get('valid_types', ["feature", "fix", "bug", "chore"]))
features = data.get('features', [])
if not isinstance(features, list):
print('[FAIL] features debe ser una lista')
@@ -85,25 +117,65 @@ if len(in_progress) > 1:
for f in features:
fid = str(f.get('id', '')).strip()
status = f.get('status')
if status not in valid:
title = str(f.get('title', '')).strip()
acceptance = f.get('acceptance')
gates = f.get('gates', {})
if not fid:
print('[FAIL] Hay una feature sin id')
sys.exit(1)
if not title:
print(f"[FAIL] Feature {fid} sin title")
sys.exit(1)
if status not in valid_status:
print(f"[FAIL] Estado inválido en feature {fid}: {status}")
sys.exit(1)
if not isinstance(acceptance, list) or not acceptance or any(not str(item).strip() for item in acceptance):
print(f"[FAIL] Feature {fid} debe tener acceptance como lista no vacía")
sys.exit(1)
ticket_type = f.get('type')
if ticket_type is not None and ticket_type not in valid_types:
print(f"[FAIL] Feature {fid} tiene type inválido: {ticket_type}")
sys.exit(1)
for field in ('priority', 'risk'):
value = f.get(field)
if value is not None and value not in level_choices:
print(f"[FAIL] Feature {fid} tiene {field} inválido: {value}")
sys.exit(1)
for field in ('scope_in', 'scope_out'):
value = f.get(field)
if value is not None:
if not isinstance(value, list) or any(not str(item).strip() for item in value):
print(f"[FAIL] Feature {fid} tiene {field} inválido")
sys.exit(1)
if gates:
for gate_name in ('review', 'security', 'qa'):
gate_value = gates.get(gate_name)
if not isinstance(gate_value, bool):
print(f"[FAIL] Feature {fid} tiene gates.{gate_name} inválido")
sys.exit(1)
if status == 'done':
d = root / 'work' / 'artifacts' / fid
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md']
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md', 'publish.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',
'reviewer.json': ('reviewer', 'APPROVED'),
'security.json': ('security', 'APPROVED'),
'qa.json': ('qa', 'APPROVED'),
'leader-close.json': ('leader', 'APPROVED'),
'publish.json': ('leader', 'PUBLISHED'),
}
for filename, agent in expected.items():
for filename, rule in expected.items():
agent, verdict = rule
try:
obj = json.loads((d / filename).read_text(encoding='utf-8'))
except Exception as e:
@@ -113,8 +185,11 @@ for f in features:
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")
if obj.get('verdict') != verdict:
print(f"[FAIL] {fid}/{filename} no está {verdict}")
sys.exit(1)
if filename == 'publish.json' and obj.get('pushed') is not True:
print(f"[FAIL] {fid}/{filename} debe tener pushed=true")
sys.exit(1)
print(f"[OK] backlog válido ({len(features)} features)")