Files
brain-template/scripts/migrate-claims-to-db.py

178 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""
migrate-claims-to-db.py — Migration one-shot : claims/*.yml → brain.db
ADR-036 : BSI hors git — les claims deviennent la source de vérité dans brain.db.
Usage :
python3 scripts/migrate-claims-to-db.py → migrer tout
python3 scripts/migrate-claims-to-db.py --dry-run → preview sans écriture
python3 scripts/migrate-claims-to-db.py --archive → migrer + archiver les .yml
Idempotent : INSERT OR IGNORE sur sess_id PRIMARY KEY.
"""
import os
import re
import sys
import sqlite3
import shutil
from pathlib import Path
from datetime import datetime, timedelta
BRAIN_ROOT = Path(__file__).parent.parent
CLAIMS_DIR = BRAIN_ROOT / 'claims'
DB_PATH = BRAIN_ROOT / 'brain.db'
ARCHIVE_DIR = BRAIN_ROOT / 'archive' / 'claims-git-era'
# Kernel scopes — synchronisé avec KERNEL.md
KERNEL_SCOPES = ['agents/', 'profil/', 'scripts/', 'KERNEL.md',
'brain-constitution.md', 'brain-compose.yml']
PERSONAL_SCOPES = ['profil/capital', 'profil/objectifs', 'progression/', 'MYSECRETS']
def extract(content, *patterns, default=''):
"""Extract first matching pattern from content."""
for p in patterns:
m = re.search(p, content, re.MULTILINE)
if m:
return m.group(1).strip().strip('"\'')
return default
def infer_zone(scope):
"""Infer zone from scope — ADR-014."""
for ks in KERNEL_SCOPES:
if ks in scope:
return 'kernel'
for ps in PERSONAL_SCOPES:
if ps in scope:
return 'personal'
return 'project'
def parse_claim(filepath):
"""Parse a claim YAML file into a dict."""
with open(filepath, 'r') as f:
content = f.read()
sess_id = extract(content, r'^sess_id:\s*(.+)', r'^name:\s*(sess-.+)')
if not sess_id:
return None
scope = extract(content, r'^scope:\s*(.+)')
status = extract(content, r'^status:\s*(.+)', default='closed')
opened_at = extract(content, r'^opened_at:\s*(.+)', r'^opened:\s*(.+)')
type_ = extract(content, r'^type:\s*(.+)', default='work')
handoff = extract(content, r'^handoff_level:\s*(.+)')
story = extract(content, r'^story_angle:\s*(.+)')
parent = extract(content, r'^parent_satellite:\s*(.+)')
sat_type = extract(content, r'^satellite_type:\s*(.+)')
sat_level = extract(content, r'^satellite_level:\s*(.+)')
theme_branch = extract(content, r'^theme_branch:\s*(.+)')
zone = extract(content, r'^zone:\s*(.+)') or infer_zone(scope)
mode = extract(content, r'^mode:\s*(.+)')
# Check if TTL expired → mark stale
if status == 'open' and opened_at:
try:
opened_dt = datetime.fromisoformat(opened_at.replace('Z', '+00:00'))
if datetime.now(opened_dt.tzinfo or None) - opened_dt.replace(tzinfo=None) > timedelta(hours=4):
status = 'stale'
except (ValueError, TypeError):
pass
return {
'sess_id': sess_id,
'type': type_,
'scope': scope,
'status': status,
'opened_at': opened_at,
'handoff_level': handoff or None,
'story_angle': story or None,
'parent_sess': parent or None,
'satellite_type': sat_type or None,
'satellite_level': sat_level or None,
'theme_branch': theme_branch or None,
'zone': zone,
'mode': mode or None,
'ttl_hours': 4,
}
def main():
dry_run = '--dry-run' in sys.argv
archive = '--archive' in sys.argv
if not CLAIMS_DIR.exists():
print(f"❌ claims/ introuvable : {CLAIMS_DIR}")
sys.exit(1)
yml_files = sorted(CLAIMS_DIR.glob('sess-*.yml'))
print(f"📦 {len(yml_files)} fichiers claims trouvés")
if dry_run:
print(" (mode dry-run — aucune écriture)")
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode=WAL")
migrated = 0
skipped = 0
stale_marked = 0
errors = 0
for yml in yml_files:
claim = parse_claim(yml)
if not claim:
print(f" ⚠️ SKIP {yml.name} — pas de sess_id")
skipped += 1
continue
if claim['status'] == 'stale':
stale_marked += 1
if dry_run:
print(f"{claim['sess_id']} | {claim['status']} | {claim['scope'][:40]}")
migrated += 1
continue
try:
conn.execute("""
INSERT OR IGNORE INTO claims
(sess_id, type, scope, status, opened_at, handoff_level,
story_angle, parent_sess, satellite_type, satellite_level,
theme_branch, zone, mode, ttl_hours)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
claim['sess_id'], claim['type'], claim['scope'],
claim['status'], claim['opened_at'], claim['handoff_level'],
claim['story_angle'], claim['parent_sess'],
claim['satellite_type'], claim['satellite_level'],
claim['theme_branch'], claim['zone'], claim['mode'],
claim['ttl_hours'],
))
migrated += 1
except Exception as e:
print(f" ❌ ERROR {yml.name} : {e}")
errors += 1
conn.commit()
conn.close()
print(f"\n✅ Migration terminée :")
print(f" Migrés : {migrated}")
print(f" Skippés : {skipped}")
print(f" Stale : {stale_marked} (open > 4h → marqués stale)")
print(f" Erreurs : {errors}")
if archive and not dry_run:
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
for yml in yml_files:
shutil.move(str(yml), str(ARCHIVE_DIR / yml.name))
print(f"\n📁 {len(yml_files)} fichiers archivés → {ARCHIVE_DIR}")
print(" → Ajouter 'claims/' à .gitignore pour finaliser")
if __name__ == '__main__':
main()