fix: tier featured dans feature-gate + TIER_RANK + scripts manquants

This commit is contained in:
2026-03-20 20:38:26 +01:00
parent 8244a07881
commit 2b69c3769a
3 changed files with 337 additions and 6 deletions

View File

@@ -121,7 +121,7 @@ FEATURE_TIER: dict[str, str] = {
'bsi': 'free', '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 ────────────────────────────────────────────────────────────────── # ── 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 # 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 # Map token tier → max catalog tier accessible
_TOKEN_TIER_TO_CATALOG: dict[str, str] = { _TOKEN_TIER_TO_CATALOG: dict[str, str] = {
'free': 'free', 'free': 'free',
'mcp': 'pro', 'featured': 'featured',
'pro': 'pro', 'mcp': 'pro',
'owner': 'owner', 'pro': 'pro',
'owner': 'owner',
} }

231
scripts/bsi-claim.sh Executable file
View File

@@ -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 <sess_id> [--scope X] [--type X] [--zone X] [--mode X] [--story "X"]
# bsi-claim.sh close <sess_id> [--result X]
# bsi-claim.sh close-stale → ferme tous les claims open > TTL (4h par défaut)
# bsi-claim.sh exists <sess_id> → 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 <sess_id> [--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 <sess_id> [--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 <sess_id>", 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 <open|close|close-stale|exists|init>")
print(" open <sess_id> [--scope X] [--type X] [--zone X] [--mode X] [--story 'X']")
print(" close <sess_id> [--result X]")
print(" close-stale — ferme les claims open > TTL")
print(" exists <sess_id> — 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

99
scripts/feature-gate-check.sh Executable file
View File

@@ -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 <feature|tier>" >&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