fix: tier featured dans feature-gate + TIER_RANK + scripts manquants
This commit is contained in:
@@ -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
231
scripts/bsi-claim.sh
Executable 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
99
scripts/feature-gate-check.sh
Executable 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
|
||||||
Reference in New Issue
Block a user