"""Tool: block_compactor – Block-Compact-Protokoll ausführen (DB + Repo-Sync). Nach jedem abgeschlossenen Block: 1. Snapshot in DB (orch_snapshots) 2. Block-Compact-Marker in DB (orch_block_compact) 3. Worklog-Eintrag in DB (orch_worklog) 4. History-Snapshot in Git-Repo (docs/projects//snapshots/.json) (nur wenn project_repo_root übergeben wird) Wird am Block-Ende aufgerufen, BEVOR dem User der Status gemeldet wird. """ from __future__ import annotations import json import os from typing import Any, Dict, List, Optional from helpers.tool import Tool, Response from usr.plugins.a0_software_orchestrator.helpers.db_state_store import ( get_project_id, now_iso, get_kv, set_kv, append_worklog, set_status, add_next_step, set_block_compact, get_block_compact, create_snapshot, collect_full_state, list_kv_keys, get_status, ) from usr.plugins.a0_software_orchestrator.helpers.compact_protocol import ( MANDATORY_ARTIFACTS, get_block_compact_config, should_block_compact, check_mandatory_artifacts, ) from usr.plugins.a0_software_orchestrator.helpers.context_budget import estimate_ratio from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError def _atomic_write(path: str, content: str) -> None: """Schreibt atomar: erst temp-File, dann rename.""" try: os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: f.write(content) os.replace(tmp, path) except OSError as e: raise PluginDBError(f"Atomic write failed for {path}: {e}") from e def _build_resume_md( project_name: str, block_id: str, block_summary: str, next_block: str, artifacts_state: Dict[str, bool], ) -> str: """Baut den Inhalt für resume.md (History-Snapshot im Repo).""" artifact_lines = "\n".join( f"- [{'x' if ok else ' '}] {k}" for k, ok in artifacts_state.items() ) return ( f"# Resume Marker – {project_name}\n\n" f"- **Last completed block**: `{block_id}`\n" f"- **Completed at**: {now_iso()}\n" f"- **Next block**: {next_block}\n\n" f"## What was done in this block\n\n" f"{block_summary.strip() or '_no summary provided_'}\n\n" f"## Mandatory artifacts state at compact time\n\n" f"{artifact_lines}\n\n" f"## How to resume (read in this order)\n\n" f"1. `db.orch_block_compact[project_id]` – current block marker (DB)\n" f"2. `db.orch_status[project_id]` – one-line status (DB)\n" f"3. `db.orch_next_steps[project_id]` – pending next steps (DB)\n" f"4. `docs/projects/{project_name}/snapshots/.json` – full state (Repo)\n" f"5. `db.orch_worklog[project_id]` – detailed block history (DB)\n\n" f"## Guardrails on resume\n\n" f"- Do NOT re-run subagents for blocks already marked done in worklog.\n" f"- Re-validate artifacts; missing items = block re-open.\n" f"- If `orch_next_steps` is empty, ask the user before continuing.\n" ) def _build_conversation_summary_md( block_id: str, block_summary: str, decisions: Optional[List[str]] = None, open_questions: Optional[List[str]] = None, key_findings: Optional[List[str]] = None, max_lines: int = 200, ) -> str: """Baut den Inhalt für conversation_summary.md (History-Snapshot im Repo).""" decisions = decisions or [] open_questions = open_questions or [] key_findings = key_findings or [] sections: List[str] = [ f"# Conversation Summary – Block `{block_id}`", "", f"_Generated: {now_iso()}_", "", "## Block summary", "", (block_summary.strip() or "_no summary provided_"), "", "## Key decisions", "", ] if decisions: sections.extend(f"- {d}" for d in decisions) else: sections.append("_none recorded_") sections.append("") sections.append("## Key findings") sections.append("") if key_findings: sections.extend(f"- {f}" for f in key_findings) else: sections.append("_none recorded_") sections.append("") sections.append("## Open questions") sections.append("") if open_questions: sections.extend(f"- {q}" for q in open_questions) else: sections.append("_none recorded_") sections.append("") raw = "\n".join(sections) lines = raw.splitlines() if len(lines) <= max_lines: return raw head = lines[: max_lines - 1] return "\n".join(head + [f"... [truncated to {max_lines} lines] ..."]) def _sync_to_repo( project_name: str, project_repo_root: str, block_id: str, block_summary: str, next_block: str, decisions: Optional[List[str]], key_findings: Optional[List[str]], open_questions: Optional[List[str]], artifacts_state: Dict[str, bool], state_snapshot: Dict[str, Any], ) -> Dict[str, str]: """Schreibt History-Snapshots in das Git-Repo. Layout: / ├── docs/projects// │ ├── snapshots/.json – vollständiger State │ ├── worklog.md – append-only (im Block-Eintrag) │ ├── decisions.md – append-only (wenn decisions) │ ├── resume.md – letzter Resume-Marker │ └── conversation_summary.md – letzte komprimierte Zusammenfassung Returns dict mit den geschriebenen Pfaden. """ docs_dir = os.path.join( project_repo_root, "docs", "projects", project_name ) snap_dir = os.path.join(docs_dir, "snapshots") os.makedirs(snap_dir, exist_ok=True) paths: Dict[str, str] = {} # 1. JSON-Snapshot ts_safe = now_iso().replace(":", "-") snap_path = os.path.join(snap_dir, f"{ts_safe}.json") _atomic_write( snap_path, json.dumps( { "block_id": block_id, "next_block": next_block, "block_summary": block_summary, "decisions": decisions or [], "key_findings": key_findings or [], "open_questions": open_questions or [], "artifacts_state": artifacts_state, "state": state_snapshot, "snapshot_at": now_iso(), }, indent=2, ensure_ascii=False, default=str, ), ) paths["snapshot"] = snap_path # 2. worklog.md (append-only) worklog_path = os.path.join(docs_dir, "worklog.md") if not os.path.exists(worklog_path): _atomic_write(worklog_path, f"# Worklog – {project_name}\n\n") with open(worklog_path, "a", encoding="utf-8") as f: f.write( f"\n## Block `{block_id}` @ {now_iso()}\n\n" f"- **next**: {next_block}\n" f"- **summary**: {block_summary.strip() or '_none_'}\n" ) for d in (decisions or []): f.write(f"- **decision**: {d}\n") for f_ in (key_findings or []): f.write(f"- **finding**: {f_}\n") for q in (open_questions or []): f.write(f"- **question**: {q}\n") paths["worklog"] = worklog_path # 3. decisions.md (append-only, eigene Datei für Architektur-Entscheidungen) if decisions: decisions_path = os.path.join(docs_dir, "decisions.md") if not os.path.exists(decisions_path): _atomic_write(decisions_path, f"# Decisions – {project_name}\n\n") with open(decisions_path, "a", encoding="utf-8") as f: f.write(f"\n## Block `{block_id}` @ {now_iso()}\n\n") for d in decisions: f.write(f"- {d}\n") paths["decisions"] = decisions_path # 4. resume.md (letzter Marker, überschreibt) resume_path = os.path.join(docs_dir, "resume.md") _atomic_write( resume_path, _build_resume_md( project_name=project_name, block_id=block_id, block_summary=block_summary, next_block=next_block, artifacts_state=artifacts_state, ), ) paths["resume"] = resume_path # 5. conversation_summary.md (überschreibt, max 200 Zeilen) summary_path = os.path.join(docs_dir, "conversation_summary.md") _atomic_write( summary_path, _build_conversation_summary_md( block_id=block_id, block_summary=block_summary, decisions=decisions, key_findings=key_findings, open_questions=open_questions, ), ) paths["conversation_summary"] = summary_path return paths class BlockCompactor(Tool): """Sichert Block-State am Ende eines abgeschlossenen Aufgabenblocks. Args (kwargs): project_name (str): Projektname (bevorzugt). project_root (str): Legacy – wird zu basename() aufgelöst. project_repo_root (str): Pfad zum Git-Repo (für History-Sync). block_id (str): Eindeutige Block-ID (z.B. "T012", "B-requirements-v1"). block_summary (str): Was wurde gemacht? (Pflicht) next_block (str): Was kommt als nächstes? (default: siehe next_steps) decisions (list[str]): Wichtige Entscheidungen. open_questions (list[str]): Offene Fragen. key_findings (list[str]): Wichtige Erkenntnisse. estimated_context_tokens (int): Für Token-Ratio-Berechnung. max_context_tokens (int): Default 8000. open_subagent_calls (int): Default 0. quality_gate_passed (bool): Default True. user_gave_new_instruction (bool): Default False. force (bool): Default False. Nur Token-Schwelle, nicht Preconditions. """ async def execute( self, project_name: str = "", project_root: str = "", project_repo_root: str = "", block_id: str = "", block_summary: str = "", next_block: str = "", decisions: Optional[List[str]] = None, open_questions: Optional[List[str]] = None, key_findings: Optional[List[str]] = None, estimated_context_tokens: int = 0, max_context_tokens: int = 8000, open_subagent_calls: int = 0, quality_gate_passed: bool = True, user_gave_new_instruction: bool = False, force: bool = False, **kwargs, ): # Bug-Fix: _resolve_name entfernt (basename-Fallback erzeugte # potenziell ungültige/Phantom-Namen). project_name ist jetzt Pflicht. if not project_name or not project_name.strip(): return Response( message="ERROR: project_name is required (basename-fallback removed; " "pass explicit project_name or registered name)", break_loop=False, ) name = project_name.strip() try: pid = get_project_id(name) if pid is None: raise LookupError(f"project '{name}' is not registered") except (ValueError, LookupError) as e: return Response(message=f"ERROR: {e}", break_loop=False) except PluginDBError as e: return Response(message=f"Database error: {e}", break_loop=False) if not block_id: return Response( message="ERROR: block_id is required (e.g. 'T012', 'B-requirements-v1')", break_loop=False, ) if not block_summary: return Response( message="ERROR: block_summary is required", break_loop=False, ) # 1. Token-Ratio ratio = ( estimate_ratio(int(estimated_context_tokens), int(max_context_tokens)) if estimated_context_tokens and max_context_tokens else 0.0 ) # 2. Preconditions prüfen decision = should_block_compact( project_name=name, estimated_ratio=ratio, open_subagent_calls=int(open_subagent_calls), quality_gate_passed=bool(quality_gate_passed), user_gave_new_instruction=bool(user_gave_new_instruction), ) if not decision["allowed"] and not force: return Response( message=( f"BLOCK COMPACT BLOCKED: {decision['reason']}\n" f"Fix preconditions first, then retry. " f"Use force=true to override token threshold only (NOT preconditions)." ), break_loop=False, ) if not decision["allowed"] and force: # Bei force=True: Token-Schwelle darf überfahren werden, # ANDERE Preconditions (quality, subagents, user, artifacts, next_steps) # NICHT. reason = decision.get("reason", "") if not reason.startswith("context ratio"): return Response( message=( f"BLOCK COMPACT BLOCKED: {reason}\n" f"force=true may ONLY override the token-ratio precondition." ), break_loop=False, ) # 3. Pflicht-Artefakte prüfen (Option B / Soft-Check) # WARN statt BLOCK: Tool schreibt trotzdem, User sieht Hinweise. # Hard-Block bleibt nur für: Token-Ratio, Subagent > 0, # Quality-Gate fail (via should_block_compact oben). artifacts = check_mandatory_artifacts(name) missing = [k for k, v in artifacts.items() if not v] warnings: List[str] = [] if missing: warnings.append(f"mandatory artifacts incomplete: {missing}") # 4. next_block: aus DB holen wenn nicht angegeben if not next_block or next_block == "see next_steps.md": from usr.plugins.a0_software_orchestrator.helpers.db_state_store import list_next_steps pending = list_next_steps(pid, status="pending") if pending: next_block = pending[0]["content"] else: next_block = "(none defined)" # 5. Snapshot in DB state_snapshot = collect_full_state(pid) snap_id = create_snapshot( pid, trigger="block_compact", state=state_snapshot ) # 6. Block-Compact-Marker in DB set_block_compact( pid, block_id=block_id, next_block=next_block, block_summary=block_summary, decisions=decisions, key_findings=key_findings, open_questions=open_questions, resume_md=_build_resume_md( name, block_id, block_summary, next_block, artifacts ), conversation_summary_md=_build_conversation_summary_md( block_id, block_summary, decisions, open_questions, key_findings ), context_ratio=ratio, ) # 7. Worklog-Eintrag append_worklog( pid, phase=get_kv(pid, "phase"), work_block=block_id, agent="block_compactor", summary=f"Block compact: {block_summary[:200]}", details=json.dumps({ "next_block": next_block, "decisions": decisions or [], "key_findings": key_findings or [], "open_questions": open_questions or [], "context_ratio": round(ratio, 3), "snapshot_id": snap_id, }, default=str), ) # 8. History-Sync in Git-Repo (optional, wenn project_repo_root gegeben) repo_paths: Dict[str, str] = {} if project_repo_root and os.path.isdir(project_repo_root): try: repo_paths = _sync_to_repo( project_name=name, project_repo_root=project_repo_root, block_id=block_id, block_summary=block_summary, next_block=next_block, decisions=decisions, key_findings=key_findings, open_questions=open_questions, artifacts_state=artifacts, state_snapshot=state_snapshot, ) except Exception as exc: repo_paths = {"_error": repr(exc)} # 9. Status zurückgeben forced = bool(decision.get("forced")) forced_marker = " [FORCED]" if forced else "" repo_summary = "" if repo_paths and "_error" not in repo_paths: repo_summary = ( f"\n- repo snapshot: `{repo_paths.get('snapshot', '?')}`" f"\n- repo worklog: `{repo_paths.get('worklog', '?')}`" f"\n- repo resume: `{repo_paths.get('resume', '?')}`" ) elif "_error" in repo_paths: repo_summary = f"\n- repo sync FAILED: {repo_paths['_error']}" else: repo_summary = "\n- repo sync: SKIPPED (no project_repo_root)" return Response( message=( f"BLOCK COMPACT READY{forced_marker}\n\n" f"- project: `{name}` (pid={pid})\n" f"- block_id: `{block_id}`\n" f"- next_block: {next_block}\n" f"- db snapshot_id: {snap_id}\n" f"- context_ratio: {ratio:.2f}\n" f"- mandatory_artifacts: {sum(artifacts.values())}/{len(artifacts)} " + ("✅" if not missing else f"⚠️ missing: {missing}") + (f"\n- warnings:\n - " + "\n - ".join(warnings) if warnings else "") + f"{repo_summary}\n\n" f"NEXT for the orchestrator:\n" f"1. Confirm all 5 mandatory artifacts present.\n" f"2. Provide user with max 30-line status report.\n" f"3. On resume in a new chat, call `block_resume` with project_name={name!r}.\n" f"4. If this is the 3rd completed block, run `quality_gate` with action='run' and category='release_auditor' to enforce release auditing." ), break_loop=False, )