diff --git a/brain-engine/server.py b/brain-engine/server.py index 69e4799..cfade94 100644 --- a/brain-engine/server.py +++ b/brain-engine/server.py @@ -121,7 +121,7 @@ FEATURE_TIER: dict[str, str] = { 'bsi': 'free', } -TIER_RANK = {'free': 0, 'pro': 1, 'owner': 2, 'full': 2} # 'full' = alias owner +TIER_RANK = {'free': 0, 'featured': 1, 'pro': 2, 'owner': 3, 'full': 3} # chaîne: free → featured → pro → full # ── Tier cache ────────────────────────────────────────────────────────────────── @@ -332,14 +332,15 @@ def _load_catalog(agents_dir: Path) -> dict: # Tier access hierarchy: owner sees all, pro sees pro+free, free sees only free -_CATALOG_TIER_RANK: dict[str, int] = {'free': 0, 'pro': 1, 'owner': 2} +_CATALOG_TIER_RANK: dict[str, int] = {'free': 0, 'featured': 1, 'pro': 2, 'owner': 3} # Map token tier → max catalog tier accessible _TOKEN_TIER_TO_CATALOG: dict[str, str] = { - 'free': 'free', - 'mcp': 'pro', - 'pro': 'pro', - 'owner': 'owner', + 'free': 'free', + 'featured': 'featured', + 'mcp': 'pro', + 'pro': 'pro', + 'owner': 'owner', } diff --git a/scripts/bsi-claim.sh b/scripts/bsi-claim.sh new file mode 100755 index 0000000..00e2ee7 --- /dev/null +++ b/scripts/bsi-claim.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# bsi-claim.sh — Open/close claims dans brain.db (source unique — ADR-042) +# +# Usage : +# bsi-claim.sh open [--scope X] [--type X] [--zone X] [--mode X] [--story "X"] +# bsi-claim.sh close [--result X] +# bsi-claim.sh close-stale → ferme tous les claims open > TTL (4h par défaut) +# bsi-claim.sh exists → exit 0 si open, exit 1 sinon +# bsi-claim.sh init → crée brain.db + table claims si absent +# +# Garantie tier free : python3 + sqlite3 stdlib — zéro dépendance externe. +# Auto-init : si brain.db ou table claims absente → créée automatiquement. +# +# Exit codes : +# 0 = succès +# 1 = argument manquant / erreur usage +# 2 = erreur Python / DB + +set -euo pipefail + +BRAIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DB_PATH="$BRAIN_ROOT/brain.db" +CMD="${1:-help}" +shift || true + +python3 - "$DB_PATH" "$CMD" "$@" <<'PYEOF' +import sqlite3 +import sys +from datetime import datetime, timezone + +db_path = sys.argv[1] +cmd = sys.argv[2] if len(sys.argv) > 2 else "help" +args = sys.argv[3:] + +CLAIMS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS claims ( + sess_id TEXT PRIMARY KEY, + type TEXT, + scope TEXT, + status TEXT DEFAULT 'open', + opened_at TEXT, + closed_at TEXT, + handoff_level TEXT, + story_angle TEXT, + health_score REAL, + context_at_close REAL, + cold_start_kpi_pass INTEGER, + ttl_hours REAL DEFAULT 4.0, + expires_at TEXT, + instance TEXT, + parent_sess TEXT, + satellite_type TEXT, + satellite_level TEXT, + theme_branch TEXT, + zone TEXT, + mode TEXT, + workflow TEXT, + workflow_step INTEGER, + result_status TEXT, + result_json TEXT +) +""" + +def get_db(): + """Connect and ensure table exists (auto-init for fresh forks).""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute(CLAIMS_SCHEMA) + conn.commit() + return conn + +def parse_opts(args): + """Parse --key value pairs from args.""" + opts = {} + i = 0 + while i < len(args): + if args[i].startswith("--") and i + 1 < len(args): + opts[args[i][2:]] = args[i + 1] + i += 2 + else: + i += 1 + return opts + +def cmd_open(): + if not args: + print("❌ Usage: bsi-claim.sh open [--scope X] [--type X] ...", file=sys.stderr) + sys.exit(1) + + sess_id = args[0] + opts = parse_opts(args[1:]) + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M") + + conn = get_db() + + # Vérifier si déjà open + existing = conn.execute( + "SELECT status FROM claims WHERE sess_id = ?", (sess_id,) + ).fetchone() + if existing and existing["status"] == "open": + print(f"⚠️ Claim déjà ouvert : {sess_id}") + conn.close() + sys.exit(0) + + new_scope = opts.get("scope", "brain/") + + # Scope overlap detection — BSI mutex + open_claims = conn.execute( + "SELECT sess_id, scope, zone FROM claims WHERE status = 'open'" + ).fetchall() + + for oc in open_claims: + oc_scope = oc["scope"] or "" + # Overlap = un scope est préfixe de l'autre, ou identique + if (new_scope.startswith(oc_scope) or oc_scope.startswith(new_scope) + or new_scope == oc_scope): + oc_zone = oc["zone"] or "project" + + # Zone kernel = hard block + if oc_zone == "kernel" or opts.get("zone") == "kernel": + print(f"🔴 SCOPE CONFLICT — zone kernel verrouillée") + print(f" Existant : {oc['sess_id']} → scope: {oc_scope} (zone: {oc_zone})") + print(f" Demandé : {sess_id} → scope: {new_scope}") + print(f" → Fermer le claim existant d'abord : bsi-claim.sh close {oc['sess_id']}") + conn.close() + sys.exit(1) + + # Zone project = soft warning (parallélisme autorisé avec avertissement) + print(f"⚠️ SCOPE OVERLAP détecté") + print(f" Existant : {oc['sess_id']} → scope: {oc_scope}") + print(f" Demandé : {sess_id} → scope: {new_scope}") + print(f" → Parallélisme autorisé — attention aux conflits d'écriture") + + conn.execute(""" + INSERT OR REPLACE INTO claims + (sess_id, type, scope, status, opened_at, zone, mode, story_angle, + handoff_level, instance, ttl_hours, expires_at) + VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, 4.0, datetime(?, '+4 hours')) + """, ( + sess_id, + opts.get("type", "navigate"), + new_scope, + now, + opts.get("zone", "project"), + opts.get("mode"), + opts.get("story"), + opts.get("handoff", "0"), + opts.get("instance"), + now, + )) + conn.commit() + conn.close() + print(f"✅ Claim ouvert : {sess_id}") + +def cmd_close(): + if not args: + print("❌ Usage: bsi-claim.sh close [--result X]", file=sys.stderr) + sys.exit(1) + + sess_id = args[0] + opts = parse_opts(args[1:]) + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M") + + conn = get_db() + cur = conn.execute( + "UPDATE claims SET status = 'closed', closed_at = ?, result_status = ? WHERE sess_id = ? AND status = 'open'", + (now, opts.get("result", "success"), sess_id) + ) + conn.commit() + + if cur.rowcount == 0: + print(f"⚠️ Claim non trouvé ou déjà fermé : {sess_id}") + else: + print(f"✅ Claim fermé : {sess_id}") + conn.close() + +def cmd_close_stale(): + conn = get_db() + cur = conn.execute(""" + UPDATE claims + SET status = 'closed', + closed_at = datetime('now'), + result_status = 'stale-auto-closed' + WHERE status = 'open' + AND julianday('now') > julianday(opened_at, '+' || COALESCE(ttl_hours, 4) || ' hours') + """) + conn.commit() + n = cur.rowcount + if n > 0: + print(f"✅ {n} claim(s) stale fermé(s)") + else: + print("ℹ️ Aucun claim stale") + conn.close() + +def cmd_exists(): + if not args: + print("❌ Usage: bsi-claim.sh exists ", file=sys.stderr) + sys.exit(1) + + conn = get_db() + row = conn.execute( + "SELECT status FROM claims WHERE sess_id = ? AND status = 'open'", (args[0],) + ).fetchone() + conn.close() + sys.exit(0 if row else 1) + +def cmd_init(): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM claims").fetchone()[0] + conn.close() + print(f"✅ brain.db prêt — table claims ({n} entrées)") + +def cmd_help(): + print("Usage: bsi-claim.sh ") + print(" open [--scope X] [--type X] [--zone X] [--mode X] [--story 'X']") + print(" close [--result X]") + print(" close-stale — ferme les claims open > TTL") + print(" exists — exit 0 si open, exit 1 sinon") + print(" init — crée brain.db + table si absent") + +commands = { + "open": cmd_open, + "close": cmd_close, + "close-stale": cmd_close_stale, + "exists": cmd_exists, + "init": cmd_init, + "help": cmd_help, +} + +fn = commands.get(cmd, cmd_help) +fn() +PYEOF diff --git a/scripts/feature-gate-check.sh b/scripts/feature-gate-check.sh new file mode 100755 index 0000000..6241783 --- /dev/null +++ b/scripts/feature-gate-check.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# feature-gate-check.sh — Vérifie si une feature ou un tier est activé +# Returns 0 (enabled) / 1 (disabled) +# +# Usage : +# bash scripts/feature-gate-check.sh bact.enrichment +# bash scripts/feature-gate-check.sh pro # tier level check +# if bash scripts/feature-gate-check.sh diagram.actions; then ... + +set -uo pipefail + +BRAIN_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +COMPOSE_FILE="$BRAIN_ROOT/brain-compose.local.yml" + +# --- Lire le tier actif depuis brain-compose.local.yml --- +_get_tier() { + [ -f "$COMPOSE_FILE" ] || { echo "free"; return; } + local tier="free" + if command -v python3 &>/dev/null && python3 -c "import yaml" &>/dev/null 2>&1; then + tier=$(BRAIN_COMPOSE="$COMPOSE_FILE" python3 - <<'PYEOF' 2>/dev/null +import yaml, os, sys +path = os.environ.get('BRAIN_COMPOSE', '') +try: + with open(path) as f: + data = yaml.safe_load(f) + instances = data.get('instances', {}) + for name, inst in instances.items(): + if inst.get('active'): + print(inst.get('feature_set', {}).get('tier', 'free')) + sys.exit(0) +except Exception: + pass +print('free') +PYEOF +) + else + # Fallback grep — fonctionne sur brain-compose.local.yml standard + tier=$(grep "^\s*tier:" "$COMPOSE_FILE" | head -1 | awk '{print $NF}' | tr -d "'\"") + fi + echo "${tier:-free}" +} + +# --- Niveau numérique du tier --- +_tier_level() { + case "$1" in + free) echo 0 ;; + featured) echo 1 ;; + pro) echo 2 ;; + full) echo 3 ;; + *) echo 0 ;; + esac +} + +# --- Tier minimum requis par feature --- +_feature_min_tier() { + case "$1" in + # tier: free — toujours enabled + kernel.boot|kernel.agents|workflow.manual|diagram.readonly) + echo "free" ;; + # tier: featured — coaching + distillation RAG + coach.full|distillation.rag|progression.tracking) + echo "featured" ;; + # tier: pro — outils metier + bact.enrichment|workflow.orchestrated|diagram.interactive|supervisor.project) + echo "pro" ;; + # tier: full — kernel + supervision + bact.rag|diagram.actions|kernel.write) + echo "full" ;; + # feature inconnue → false (défaut sécurisé) + *) + echo "unknown" ;; + esac +} + +# --- Main --- +FEATURE="${1:-}" +if [ -z "$FEATURE" ]; then + echo "Usage: feature-gate-check.sh " >&2 + exit 2 +fi + +CURRENT_TIER=$(_get_tier) +CURRENT_LEVEL=$(_tier_level "$CURRENT_TIER") + +# Cas 1 : argument est un tier name (free/featured/pro/full) +case "$FEATURE" in + free|featured|pro|full) + REQUIRED_LEVEL=$(_tier_level "$FEATURE") + [ "$CURRENT_LEVEL" -ge "$REQUIRED_LEVEL" ] && exit 0 || exit 1 + ;; +esac + +# Cas 2 : argument est un feature name +MIN_TIER=$(_feature_min_tier "$FEATURE") +if [ "$MIN_TIER" = "unknown" ]; then + exit 1 +fi +REQUIRED_LEVEL=$(_tier_level "$MIN_TIER") +[ "$CURRENT_LEVEL" -ge "$REQUIRED_LEVEL" ] && exit 0 || exit 1