Files

470 lines
18 KiB
Python
Raw Permalink Normal View History

"""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/<name>/snapshots/<ts>.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/<latest>.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:
<project_repo_root>/
├── docs/projects/<project_name>/
│ ├── snapshots/<ts>.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,
)