Initial commit: a0_software_orchestrator v1.0

- Auto-Registration-Bug behoben (register_project/get_project_id/resolve_project Trennung)
- 25 Tests gruen (Pytest)
- block_compactor-Tool refactored (Option B: Soft-Check statt Hard-Block)
- 4 Restbaustellen gefixt
- DB-Schema: plugin_settings-Tabelle hinzugefuegt
- 3 Schattenprojekte aus DB geloescht
- Plan v3 + Refactor-Plan + Worklog dokumentiert
This commit is contained in:
Software Orchestrator
2026-06-16 22:13:06 +00:00
commit 5769c1cd22
236 changed files with 17825 additions and 0 deletions
+457
View File
@@ -0,0 +1,457 @@
"""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-<ts>/ verschoben
(NICHT gelöscht), damit man die Migration verifizieren oder rückgängig
machen kann.
Usage:
python3 utils/migrate_a0_to_db.py <project_root> [--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 <project_root>
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<done>[ xX])\])?\s*(?P<content>.+)$", 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<content>.+)$", 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*(?:\[(?P<sev>high|medium|low|critical|info)\])?\s*"
r"(?P<content>.+)$",
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=<gültiger Name> 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: <specialist>_<topic>.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-<ts>/ verschieben.",
)
args = ap.parse_args()
# Bestimme project_name + project_root
# Bug 3 Fix: stiller basename-Fallback entfernt. Wenn --project=<abs path>
# übergeben wird, wird basename genutzt MIT sichtbarem WARN-Hinweis,
# damit der User den Projektnamen explizit setzen kann.
if not args.project:
print("ERROR: --project=<name> 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/<name>
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()