"""Migration script: move state from .a0/ files to patterns.db. Liest alle bestehenden .a0/ und .a0proj/ State-Files (in einem Projekt-Wurzelverzeichnis) und schreibt sie in die neue DB-Struktur (orch_* Tabellen in patterns.db). Anschließend werden die Quelldateien nach .a0/.backup-/ verschoben (NICHT gelöscht), damit man die Migration verifizieren oder rückgängig machen kann. Usage: python3 utils/migrate_a0_to_db.py [--dry-run] [--skip-backup] Beispiel: cd /pfad/zu/mein-projekt python3 /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py . ODER für ein registriertes Projekt: python3 /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py crm-system """ from __future__ import annotations import argparse import json import os import re import shutil import sys from datetime import datetime from typing import Any, Dict, List, Optional, Tuple PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Run this maintenance script from the Agent Zero framework runtime with /a0 available. # Example: cd /a0 && /opt/venv-a0/bin/python /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py from usr.plugins.a0_software_orchestrator.helpers.db_state_store import ( register_project, set_kv, append_worklog, set_status, add_todo, add_next_step, set_block_compact, create_snapshot, add_score, append_briefing, now_iso, ) # --------------------------------------------------------------------------- # Helpers – Datei-Reader # --------------------------------------------------------------------------- def _read_json(path: str) -> Optional[dict]: if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: print(f" WARN: {path} not readable: {e}") return None def _read_text(path: str) -> Optional[str]: if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as f: return f.read() except (OSError, UnicodeDecodeError) as e: print(f" WARN: {path} not readable: {e}") return None def _parse_worklog_md(text: str) -> List[Dict[str, str]]: """Parst worklog.md in Einträge. Format (etabliert): ## 2026-05-10T... | phase=implementation | block=T012 | agent=foo - summary text - finding ... """ entries = [] if not text: return entries # Sehr tolerant: pro Header-Zeile ein Eintrag blocks = re.split(r"\n(?=##\s)", text) for block in blocks: m = re.match( r"^##\s+(\S+)\s*\|\s*phase=([^|]+)\s*\|\s*block=([^|]+)\s*\|\s*agent=(\S+)", block.strip(), ) if not m: # Fallback: nur Datum, Rest default m2 = re.match(r"^##\s+(\S+)", block.strip()) if not m2: continue ts, phase, blk, agent = m2.group(1), None, None, None else: ts, phase, blk, agent = m.groups() # Rest des Blocks ist Summary (alles nach erster Zeile) rest = block.split("\n", 1)[1] if "\n" in block else "" summary = rest.strip()[:500] entries.append({ "created_at": ts, "phase": (phase or "").strip() or None, "work_block": (blk or "").strip() or None, "agent": (agent or "").strip() or None, "summary": summary, }) return entries def _parse_todo_md(text: str) -> List[Dict[str, str]]: """Parst todo.md in einzelne Todos. Format: '- [ ] foo' / '- [x] foo' / '- foo' """ todos = [] if not text: return todos for line in text.splitlines(): m = re.match(r"^\s*-\s*(?:\[(?P[ xX])\])?\s*(?P.+)$", line) if not m: continue done = m.group("done") in ("x", "X") todos.append({ "content": m.group("content").strip(), "status": "done" if done else "open", }) return todos def _parse_next_steps_md(text: str) -> List[str]: """Parst next_steps.md in Step-Liste (alle pending).""" steps = [] if not text: return steps for line in text.splitlines(): m = re.match(r"^\s*-\s*(?:\[\s*\]\s*)?(?P.+)$", line) if not m: continue steps.append(m.group("content").strip()) return steps def _parse_known_errors_md(text: str) -> List[Dict[str, str]]: """Parst known_errors.md in Errors. Format: - [high] TypeError in user.py: ... """ errors = [] if not text: return errors for line in text.splitlines(): m = re.match( r"^\s*-\s*(?:\[(?Phigh|medium|low|critical|info)\])?\s*" r"(?P.+)$", line, re.IGNORECASE, ) if not m: continue errors.append({ "severity": (m.group("sev") or "medium").lower(), "message": m.group("content").strip(), "status": "open", }) return errors def _parse_briefing_md(text: str) -> Dict[str, Any]: """Parst ein Briefing-MD in ein Briefing-Dict (für orch_briefings).""" # Best-effort: nur die Section als ganzes return { "section_md": text, "summary": text.split("\n")[0][:200] if text else "", "decisions": re.findall(r"^- (?:decision|Decision):\s*(.+)$", text, re.M), "artifacts": re.findall(r"^- (?:artifact|Artifact):\s*(.+)$", text, re.M), "recent_turns": re.findall(r"^> (?:User|Agent):\s*(.+)$", text, re.M), "return_condition": "user_request", "handover_reason": "migrated from .a0proj/handover/", } # --------------------------------------------------------------------------- # Migration # --------------------------------------------------------------------------- def migrate_a0_to_db( project_name: str, project_root: str, dry_run: bool = False, skip_backup: bool = False, ) -> Dict[str, Any]: """Migriert .a0/ und .a0proj/ State-Files in die DB. Returns dict mit Counts (was migriert wurde). """ a0_dir = os.path.join(project_root, ".a0") a0proj_dir = os.path.join(project_root, ".a0proj") handover_dir = os.path.join(a0proj_dir, "handover") counts = { "worklog": 0, "todo": 0, "next_steps": 0, "errors": 0, "kv_keys": 0, "scorecard": 0, "briefings": 0, "block_compact": 0, "snapshots": 0, "files_moved": 0, "files_skipped": 0, } if not os.path.isdir(a0_dir) and not os.path.isdir(a0proj_dir): print(f" No .a0/ or .a0proj/ directory found in {project_root}") return counts print(f" Migrating project {project_name!r} from {project_root}") # Projekt registrieren (oder existierendes behalten) – mit Pattern+Blacklist-Plausi. # Bug 1/2 Fix: explizit register_project() statt auto-registrierendem resolve_project(). try: pid = register_project(project_name, git_url="") except ValueError as e: print(f"ERROR: invalid project name {project_name!r}: {e}") print(" Bitte --project= verwenden (Pattern: ^[a-z][a-z0-9-]{1,40}$)") sys.exit(1) print(f" Project id: {pid}") # 1. KV-Dateien: project_state.json, orchestrator_mode.json, tool_capabilities.json, … for json_file in ( "project_state.json", "orchestrator_mode.json", "tool_capabilities.json", "tool_registry.json", ): path = os.path.join(a0_dir, json_file) data = _read_json(path) if not data: counts["files_skipped"] += 1 continue key = json_file.replace(".json", "") if dry_run: print(f" [DRY] would set_kv({key!r}, …)") else: set_kv(pid, key, data) counts["kv_keys"] += 1 # 2. Markdown-Dateien # worklog.md worklog_entries = _parse_worklog_md(_read_text(os.path.join(a0_dir, "worklog.md")) or "") for e in worklog_entries: if dry_run: print(f" [DRY] would append_worklog({e.get('work_block')})") else: append_worklog( pid, phase=e.get("phase"), work_block=e.get("work_block"), agent=e.get("agent"), summary=e.get("summary"), ) counts["worklog"] += 1 # todo.md todos = _parse_todo_md(_read_text(os.path.join(a0_dir, "todo.md")) or "") for t in todos: if dry_run: print(f" [DRY] would add_todo({t['content']!r}, status={t['status']})") else: tid = add_todo(pid, t["content"], priority=5) if t["status"] == "done": from usr.plugins.a0_software_orchestrator.helpers.db_state_store import update_todo_status update_todo_status(tid, "done") counts["todo"] += 1 # current_status.md status_text = _read_text(os.path.join(a0_dir, "current_status.md")) if status_text: from usr.plugins.a0_software_orchestrator.helpers.db_state_store import set_status if dry_run: print(f" [DRY] would set_status(…)") else: set_status(pid, status_text.strip()[:1000]) # next_steps.md next_steps = _parse_next_steps_md(_read_text(os.path.join(a0_dir, "next_steps.md")) or "") for s in next_steps: if dry_run: print(f" [DRY] would add_next_step({s!r})") else: add_next_step(pid, s) counts["next_steps"] += 1 # known_errors.md errors = _parse_known_errors_md(_read_text(os.path.join(a0_dir, "known_errors.md")) or "") for e in errors: if dry_run: print(f" [DRY] would report_error({e['message']!r}, sev={e['severity']})") else: from usr.plugins.a0_software_orchestrator.helpers.db_state_store import report_error report_error(pid, e["message"], severity=e["severity"]) counts["errors"] += 1 # project_scorecard.md scorecard = _read_text(os.path.join(a0_dir, "project_scorecard.md")) if scorecard: for line in scorecard.splitlines(): m = re.match(r"^\|\s*(\S+)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|", line) if m and m.group(1) not in ("category", "id"): cat, sc, mx = m.group(1), int(m.group(2)), int(m.group(3)) if dry_run: print(f" [DRY] would add_score({cat!r}, {sc}/{mx})") else: add_score(pid, cat, sc, mx) counts["scorecard"] += 1 # 3. .a0proj/handover/ Briefing-Files if os.path.isdir(handover_dir): for fn in sorted(os.listdir(handover_dir)): if not fn.endswith(".md"): continue full = os.path.join(handover_dir, fn) text = _read_text(full) if not text: continue # specialist und topic aus Dateiname: _.md base = fn[:-3] specialist = base.split("_")[0] if "_" in base else base topic = "_".join(base.split("_")[1:]) or base parsed = _parse_briefing_md(text) if dry_run: print(f" [DRY] would append_briefing({specialist!r}, {topic!r})") else: append_briefing( pid, specialist=specialist, topic=topic, section_md=parsed["section_md"], summary=parsed["summary"], decisions=parsed["decisions"], artifacts=parsed["artifacts"], recent_turns=parsed["recent_turns"], return_condition=parsed["return_condition"], handover_reason=parsed["handover_reason"], ) counts["briefings"] += 1 # 4. Optional: backup + cleanup if not skip_backup and not dry_run: ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") backup_root = os.path.join(project_root, ".a0", f".backup-{ts}") os.makedirs(backup_root, exist_ok=True) if os.path.isdir(a0_dir): for fn in os.listdir(a0_dir): # Skip the backup dir we just created and any previous backups if fn == os.path.basename(backup_root) or fn.startswith(".backup-"): continue full = os.path.join(a0_dir, fn) if os.path.isfile(full): shutil.move(full, os.path.join(backup_root, fn)) counts["files_moved"] += 1 if os.path.isdir(handover_dir): handover_backup = os.path.join(project_root, ".a0proj", f".backup-{ts}") os.makedirs(handover_backup, exist_ok=True) for fn in os.listdir(handover_dir): full = os.path.join(handover_dir, fn) if os.path.isfile(full): shutil.move(full, os.path.join(handover_backup, fn)) counts["files_moved"] += 1 # leere handover_dir entfernen try: os.rmdir(handover_dir) except OSError: pass return counts # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser( description="Migrate .a0/ and .a0proj/ state files to patterns.db." ) ap.add_argument( "project", help="Projektname (registriert in projects-Registry) ODER absoluter Pfad.", ) ap.add_argument( "--project-root", default="", help="Absoluter Pfad zum Projekt-Root (nur wenn project ein Name ist).", ) ap.add_argument( "--dry-run", action="store_true", help="Zeigt nur an, was migriert würde, schreibt nichts.", ) ap.add_argument( "--skip-backup", action="store_true", help="Quelldateien NICHT nach .backup-/ verschieben.", ) args = ap.parse_args() # Bestimme project_name + project_root # Bug 3 Fix: stiller basename-Fallback entfernt. Wenn --project= # übergeben wird, wird basename genutzt MIT sichtbarem WARN-Hinweis, # damit der User den Projektnamen explizit setzen kann. if not args.project: print("ERROR: --project= ist Pflicht (basename-Fallback entfernt)") sys.exit(1) if os.path.isabs(args.project) and os.path.isdir(args.project): project_root = args.project project_name = os.path.basename(project_root.rstrip("/")) print(f"WARN: --project ist absoluter Pfad; leite project_name={project_name!r} aus basename ab.") print(f" Besser: explizit --project={project_name} --project-root={project_root} setzen.") elif args.project_root: project_root = args.project_root project_name = args.project else: project_name = args.project # Versuche, project_root aus projects-Tabelle zu holen from usr.plugins.a0_software_orchestrator.helpers.db_state_store import _conn with _conn() as c: row = c.execute( "SELECT git_url FROM projects WHERE name = ?", (project_name,), ).fetchone() if row and row["git_url"]: # Standard-Repo-Pfad: /a0/usr/workdir/dev-projects/ project_root = f"/a0/usr/workdir/dev-projects/{project_name}" if not os.path.isdir(project_root): print(f"ERROR: project_root not found at {project_root}") sys.exit(1) else: print(f"ERROR: project {project_name!r} not in DB and no --project-root given") sys.exit(1) print(f"=== Migration: {project_name} ===") print(f" project_root: {project_root}") print(f" dry-run: {args.dry_run}") print(f" skip-backup: {args.skip_backup}") print() counts = migrate_a0_to_db( project_name=project_name, project_root=project_root, dry_run=args.dry_run, skip_backup=args.skip_backup, ) print() print("=== Summary ===") for k, v in counts.items(): if v: print(f" {k}: {v}") print("Done.") if __name__ == "__main__": main()