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
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""A0 Software Orchestrator tool package.
No eager imports here. Agent Zero discovers plugin tools by tool name and loads
``tools/<tool_name>.py`` from enabled plugin paths. Keeping this package lazy
prevents one optional tool import from breaking unrelated tool discovery.
"""
from __future__ import annotations
__all__ = [
"ArtifactGuard",
"BlockCompactor",
"BlockResume",
"CapabilityCheck",
"ContextCompactor",
"HandoverToSpecialist",
"NextStep",
"OrchestratorSelfCheck",
"OrchestratorState",
"PlanModeGuard",
"ProjectRegistry",
"QualityGate",
"ReadBriefing",
"RepoManifest",
"ResumeChecker",
"ScorecardUpdate",
"ToolRegistry",
]
+70
View File
@@ -0,0 +1,70 @@
"""Tool: check required project artifacts (hybrid DB + repo)."""
from __future__ import annotations
import json
import os
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.artifact_rules import check_required_artifacts
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
class ArtifactGuard(Tool):
"""Prüft alle Pflicht-Artefakte (5 DB + 10 Repo) für ein Projekt.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst, bzw. als
Wurzel für Repo-File-Checks verwendet.
format (str): "summary" (default) oder "json".
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
format: str = "summary",
**kwargs,
):
name = _resolve_name(project_name, project_root)
# Falls nur project_name gegeben, project_root auf cwd setzen
if not project_root:
project_root = os.getcwd()
result = check_required_artifacts(
project_name=name, project_root=project_root
)
n_present = len(result["present"])
n_missing = len(result["missing"])
n_total = result["total"]
if format == "json":
return Response(
message=json.dumps(result, indent=2, ensure_ascii=False),
break_loop=False,
)
lines = [
f"Required artifacts for project {name!r}: "
f"{n_present}/{n_total} present, {n_missing} missing.",
]
if result["missing"]:
lines.append("")
lines.append("Missing DB artifacts:")
for a in result.get("db", {}):
if not result["db"][a]:
lines.append(f" - {a}")
lines.append("")
lines.append("Missing repo artifacts:")
for a in result.get("repo", {}):
if not result["repo"][a]:
lines.append(f" - {a}")
return Response(message="\n".join(lines), break_loop=False)
+469
View File
@@ -0,0 +1,469 @@
"""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,
)
+125
View File
@@ -0,0 +1,125 @@
"""Tool: block_resume Recovery-Übersicht am Chat-Start (DB-backed)."""
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.compact_protocol import get_resume_payload
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
get_project_id,
get_status, list_next_steps, list_todos, list_errors, list_worklog,
get_kv, get_block_compact,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
def _build_status_line(artifacts_state: Dict[str, bool]) -> str:
parts: List[str] = []
for key, ok in artifacts_state.items():
marker = "" if ok else ""
parts.append(f"{key}={marker}")
return ", ".join(parts) if parts else "(none)"
class BlockResume(Tool):
"""Lädt die kompakte 10-Zeilen-Resume-Übersicht aus der DB.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
include_full_resume (bool): Wenn True, zusätzlich resume_md ausgeben.
include_conversation_summary (bool): Wenn True, zusätzlich conversation_summary_md.
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
include_full_resume: bool = False,
include_conversation_summary: bool = False,
**kwargs,
):
name = _resolve_name(project_name, project_root)
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)
bc = get_block_compact(pid)
status = get_status(pid)
pending_steps = list_next_steps(pid, status="pending")
open_todos = list_todos(pid, status="open")
open_errors = list_errors(pid, status="open")
recent_worklog = list_worklog(pid, limit=5)
phase = get_kv(pid, "phase", default="<unknown>")
# 10-Zeilen-Übersicht
lines: List[str] = [f"🔁 RESUME: {name} (pid={pid}) — phase={phase}"]
if bc:
lines.append(
f" last block: {bc.get('last_block_id')} @ {bc.get('last_compact_at')}"
)
lines.append(f" next block: {bc.get('next_block') or '(none)'}")
else:
lines.append(" no block_compact marker yet")
lines.append(
f" status: {status or '(none)'}"
)
lines.append(
f" pending next_steps: {len(pending_steps)}, "
f"open todos: {len(open_todos)}, open errors: {len(open_errors)}"
)
if bc:
decisions = bc.get("decisions") or []
key_findings = bc.get("key_findings") or []
open_questions = bc.get("open_questions") or []
lines.append(
f" last block decisions: {len(decisions)}, "
f"key_findings: {len(key_findings)}, "
f"open_questions: {len(open_questions)}"
)
# Optional: full resume + conversation summary
if include_full_resume and bc and bc.get("resume_md"):
lines.append("")
lines.append("=== Full resume.md ===")
lines.append(bc["resume_md"])
if include_conversation_summary and bc and bc.get("conversation_summary_md"):
lines.append("")
lines.append("=== Conversation summary ===")
lines.append(bc["conversation_summary_md"])
# Recent worklog (top 5)
if recent_worklog:
lines.append("")
lines.append("=== Recent worklog (top 5) ===")
for w in recent_worklog:
ts = w.get("created_at", "?")
blk = w.get("work_block") or "-"
agent = w.get("agent") or "-"
summary = (w.get("summary") or "")[:100]
lines.append(f" - [{ts}] {blk} ({agent}): {summary}")
# Hinweise für den Orchestrator
lines.append("")
lines.append("Hints:")
lines.append(" - Don't re-run subagents for already-done blocks (see worklog).")
lines.append(" - If pending_steps is empty, ask the user before continuing.")
lines.append(" - If open_errors > 0, address them first.")
return Response(message="\n".join(lines), break_loop=False)
+18
View File
@@ -0,0 +1,18 @@
"""Tool: check external tool availability."""
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.tool_capabilities import generate_capability_report
import yaml
import os
class CapabilityCheck(Tool):
async def execute(self, plugin_dir: str = "", **kwargs):
if not plugin_dir:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
config_path = os.path.join(plugin_dir, "default_config.yaml")
try:
with open(config_path, "r") as f:
config = yaml.safe_load(f)
except (FileNotFoundError, yaml.YAMLError) as e:
config = {}
report = generate_capability_report(config)
return Response(message=str(report), break_loop=False)
+19
View File
@@ -0,0 +1,19 @@
"""Tool: enforce raw-output-to-file policy and compact large outputs."""
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.context_budget import estimate_tokens, estimate_ratio, should_warn, should_hard_compact, compact_text
from usr.plugins.a0_software_orchestrator.helpers.safety_rules import redact_secrets
import os
class ContextCompactor(Tool):
async def execute(self, text: str = "", max_lines: int = 80, max_tokens: int = 4096, **kwargs):
if not text:
return Response(message="No text provided", break_loop=False)
text = redact_secrets(text)
tokens = estimate_tokens(text)
ratio = estimate_ratio(tokens, max_tokens)
if should_hard_compact(ratio):
compacted = compact_text(text, max_lines)
return Response(message=f"COMPACTED ({tokens} tokens, ratio {ratio:.2f}):\n{compacted}", break_loop=False)
elif should_warn(ratio):
return Response(message=f"WARNING: {tokens} tokens (ratio {ratio:.2f}). Consider compacting.", break_loop=False)
return Response(message=f"OK: {tokens} tokens (ratio {ratio:.2f})", break_loop=False)
+197
View File
@@ -0,0 +1,197 @@
"""Tool: handover_to_specialist prepare a stage-1 handover package and append a briefing.
Phase 1: read-only spike. The tool:
1. validates the requested specialist profile against the allow-list
2. builds a stage-1 handover package (summary + recent turns + decisions + artifacts)
3. appends a new section to `orch_briefings` in the DB (NEU war vorher
eine .md-Datei unter .a0proj/handover/)
4. returns the package metadata to the orchestrator
The tool does NOT call call_subordinate. The actual subagent invocation
is the responsibility of the orchestrator; the generated briefing can be
retrieved with read_briefing.
See /usr/workdir/handover-spike.md sections 4 and 5 for the design.
"""
from __future__ import annotations
import json
import os
from typing import Any
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.handover_protocol import (
DEFAULT_MAX_CONTEXT_TOKENS,
DEFAULT_RECENT_TURNS,
build_handover_package,
is_known_specialist,
list_known_specialists,
persona_marker,
)
from usr.plugins.a0_software_orchestrator.helpers.briefing_file import write_initial_briefing
class HandoverToSpecialist(Tool):
"""Prepare a handover package and append a briefing (DB)."""
name = "handover_to_specialist"
description = (
"Stage-1 handover: build a token-bounded context package for a "
"specialist subagent and persist a per-specialist briefing row in "
"patterns.db (table: orch_briefings). Read-only spike does not "
"call the subagent yet; the orchestrator is expected to follow up "
"with call_subordinate()."
)
async def execute(
self,
specialist_profile: str = "",
topic: str = "",
summary: str = "",
recent_turns: Any = None,
decisions: Any = None,
artifacts: Any = None,
return_condition: str = "user_request",
handover_reason: str = "",
project_name: str = "",
project_root: str = "",
max_context_tokens: int = DEFAULT_MAX_CONTEXT_TOKENS,
include_recent_turns: int = DEFAULT_RECENT_TURNS,
**kwargs: Any,
) -> Response:
# --- Input normalisation -----------------------------------------
recent_turns = _coerce_str_list(recent_turns, default=[])
decisions = _coerce_str_list(decisions, default=[])
artifacts = _coerce_str_list(artifacts, default=[])
if include_recent_turns and include_recent_turns > 0:
recent_turns = recent_turns[-include_recent_turns:]
# Known specialists as nicely formatted list (BUG-FIX: was chr(39).join
# which produced a 1-char string between items, not quotes)
known = ", ".join(repr(s) for s in list_known_specialists())
# --- Validation --------------------------------------------------
if not specialist_profile or not specialist_profile.strip():
return Response(
message=(
f"ERROR: specialist_profile is required. "
f"Known specialists: {known}"
),
break_loop=False,
)
if not is_known_specialist(specialist_profile):
return Response(
message=(
f"ERROR: unknown specialist profile {specialist_profile!r}. "
f"Known specialists: {known}"
),
break_loop=False,
)
if not topic or not topic.strip():
return Response(
message="ERROR: topic is required and must be a non-empty string.",
break_loop=False,
)
# Resolve project name
if not project_name and project_root:
project_name = os.path.basename(project_root.rstrip("/"))
if not project_name:
project_name = os.path.basename(os.getcwd())
if project_root and not os.path.isabs(project_root):
return Response(
message=f"ERROR: project_root must be absolute, got: {project_root!r}",
break_loop=False,
)
if max_context_tokens <= 0:
return Response(
message="ERROR: max_context_tokens must be positive.",
break_loop=False,
)
# --- Build the package ------------------------------------------
try:
package = build_handover_package(
specialist=specialist_profile,
topic=topic,
summary=summary,
recent_turns=recent_turns,
decisions=decisions,
artifacts=artifacts,
return_condition=return_condition,
handover_reason=handover_reason,
project_root=project_root or os.getcwd(),
max_context_tokens=max_context_tokens,
)
except ValueError as exc:
return Response(message=f"ERROR: {exc}", break_loop=False)
# --- Persist the briefing (DB) ---------------------------------
try:
briefing_meta = write_initial_briefing(
project_name=project_name,
project_root=project_root,
specialist=specialist_profile,
topic=topic,
summary=summary,
decisions=decisions,
artifacts=artifacts,
recent_turns=recent_turns,
return_condition=return_condition,
handover_reason=handover_reason,
)
except (ValueError, OSError) as exc:
return Response(
message=f"ERROR: failed to append briefing: {exc}",
break_loop=False,
)
# --- Compose the response payload -------------------------------
payload = {
"status": "ok",
"persona_marker": persona_marker(specialist_profile),
"package": package,
"briefing": briefing_meta,
"next_step": (
"Orchestrator: review the package, then call "
"call_subordinate(profile=specialist_profile, message=...) "
"to actually hand off. The subagent is expected to "
"call the registered read_briefing tool with briefing_id=... and obey the return_condition."
),
}
text = json.dumps(payload, indent=2, ensure_ascii=False)
if package.get("oversize_warning"):
text += (
f"\n\nWARNING: estimated {package['estimated_tokens']} tokens "
f"exceed max_context_tokens={max_context_tokens}. Consider "
"shortening the summary or trimming recent_turns."
)
return Response(message=text, break_loop=False)
def _coerce_str_list(value: Any, default: list) -> list:
"""Best-effort coercion of a tool argument into a list[str]."""
if value is None:
return list(default)
if isinstance(value, list):
return [str(x) for x in value]
if isinstance(value, str):
s = value.strip()
if not s:
return list(default)
if s.startswith("[") and s.endswith("]"):
try:
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(x) for x in parsed]
except json.JSONDecodeError:
pass
return [s]
raise ValueError(
f"Cannot coerce value of type {type(value).__name__} into list[str]"
)
+103
View File
@@ -0,0 +1,103 @@
"""Tool: manage next steps for a project (DB-backed, replaces .a0/next_steps.md)."""
from __future__ import annotations
import os
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
get_project_id,
add_next_step,
complete_next_step,
list_next_steps,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
"""Ermittelt den Projektnamen: explizit → aus project_root → aus cwd."""
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
class NextStep(Tool):
"""Add, list, or complete next steps for a project.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
next_step (str): Inhalt des neuen Steps (bei action=add).
step_id (int): ID des Steps (bei action=complete).
action (str): "add" (default), "list", "complete".
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
next_step: str = "",
step_id: int = 0,
action: str = "",
**kwargs,
):
if not action:
return Response(message="ERROR: 'action' required. Supported: add, list, complete", break_loop=False)
name = _resolve_name(project_name, project_root)
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 action == "add":
if not next_step and kwargs.get("content"):
next_step = str(kwargs.get("content"))
if not next_step or not next_step.strip():
return Response(
message="ERROR: 'next_step' content required for action=add",
break_loop=False,
)
new_id = add_next_step(pid, next_step.strip())
return Response(
message=f"Next step added (id={new_id}, project={name}): {next_step.strip()}",
break_loop=False,
)
if action == "list":
steps = list_next_steps(pid, status="pending")
if not steps:
return Response(
message=f"No pending next steps for project '{name}'.",
break_loop=False,
)
lines = [f"Pending next steps for '{name}':"]
for s in steps:
lines.append(f" - [id={s['id']}] {s['content']}")
return Response(message="\n".join(lines), break_loop=False)
if action == "complete":
if not step_id:
return Response(
message="ERROR: 'step_id' required for action=complete",
break_loop=False,
)
ok = complete_next_step(int(step_id))
if ok:
return Response(
message=f"Next step id={step_id} marked as done.",
break_loop=False,
)
return Response(
message=f"No next step with id={step_id} found.",
break_loop=False,
)
return Response(
message=f"Unknown action {action!r}. Supported: add, list, complete",
break_loop=False,
)
+139
View File
@@ -0,0 +1,139 @@
"""Tool: Orchestrator self-check for Agent Zero runtime/plugin contract."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from helpers.tool import Tool, Response
PLUGIN_NAME = "a0_software_orchestrator"
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
A0_ROOT = Path("/a0")
AGENT_PROFILE_NAMES = [
"a0_software_orchestrator",
"codebase_explorer",
"deploy_agent",
"implementation_engineer",
"quality_reviewer",
"release_auditor",
"requirements_analyst",
"runtime_devops_engineer",
"security_data_engineer",
"solution_architect",
"test_debug_engineer",
]
TOOL_NAMES = [
"artifact_guard",
"block_compactor",
"block_resume",
"capability_check",
"context_compactor",
"handover_to_specialist",
"next_step",
"orchestrator_state",
"plan_mode_guard",
"project_registry",
"quality_gate",
"read_briefing",
"repo_manifest",
"resume_checker",
"scorecard_update",
"tool_registry",
"orchestrator_self_check",
]
def _rel(path: Path) -> str:
try:
return str(path.relative_to(A0_ROOT))
except Exception:
try:
return str(path.relative_to(PLUGIN_ROOT))
except Exception:
return str(path)
def _find_scoped_toggles() -> list[dict]:
"""Find scoped plugin toggles without reading unrelated file contents."""
roots = [A0_ROOT / "usr" / "agents", A0_ROOT / "usr" / "projects"]
out: list[dict] = []
for root in roots:
if not root.exists():
continue
try:
for base in root.rglob(PLUGIN_NAME):
if not base.is_dir():
continue
t0 = base / ".toggle-0"
t1 = base / ".toggle-1"
if t0.exists() or t1.exists():
out.append({
"path": _rel(base),
"toggle_0": t0.exists(),
"toggle_1": t1.exists(),
})
except Exception as exc:
out.append({"path": _rel(root), "error": f"{type(exc).__name__}: {exc}"})
return out
class OrchestratorSelfCheck(Tool):
"""Run deterministic plugin contract checks inside Agent Zero's tool runtime."""
async def execute(self, action: str = "summary", **kwargs):
from helpers.tool import Tool as FrameworkTool
import importlib.util
results = []
def add(name: str, ok: bool, detail: str = ""):
results.append({"check": name, "ok": bool(ok), "detail": detail})
add("plugin_root_exists", PLUGIN_ROOT.exists(), str(PLUGIN_ROOT))
add("plugin_yaml_exists", (PLUGIN_ROOT / "plugin.yaml").is_file(), "plugin.yaml")
add("toggle_on_root", (PLUGIN_ROOT / ".toggle-1").exists() and not (PLUGIN_ROOT / ".toggle-0").exists(), ".toggle-1 present and .toggle-0 absent")
add("tools_dir_exists", (PLUGIN_ROOT / "tools").is_dir(), "tools/")
add("prompts_dir_exists", (PLUGIN_ROOT / "prompts").is_dir(), "prompts/")
add("helpers_dir_exists", (PLUGIN_ROOT / "helpers").is_dir(), "helpers/")
add("plugin_agents_dir_exists", (PLUGIN_ROOT / "agents").is_dir(), "agents/")
for profile_name in AGENT_PROFILE_NAMES:
profile_dir = PLUGIN_ROOT / "agents" / profile_name
add(f"plugin_agent_profile:{profile_name}", (profile_dir / "agent.yaml").is_file(), str((profile_dir / "agent.yaml").relative_to(PLUGIN_ROOT)) if (profile_dir / "agent.yaml").exists() else "missing")
add("a0_root_in_sys_path", str(A0_ROOT) in [str(Path(x)) for x in sys.path if x], f"sys.path contains /a0={str(A0_ROOT) in [str(Path(x)) for x in sys.path if x]}")
add("framework_runtime_hint", "/opt/venv-a0" in os.environ.get("VIRTUAL_ENV", "") or os.path.exists("/opt/venv-a0"), f"VIRTUAL_ENV={os.environ.get('VIRTUAL_ENV','')}")
scoped_toggles = _find_scoped_toggles()
scoped_disabled = [x for x in scoped_toggles if x.get("toggle_0")]
add("scoped_toggle0_absent", not scoped_disabled, json.dumps(scoped_disabled[:20], ensure_ascii=False))
add("scoped_toggle_scan", True, json.dumps(scoped_toggles[:40], ensure_ascii=False))
for tool_name in TOOL_NAMES:
path = PLUGIN_ROOT / "tools" / f"{tool_name}.py"
prompt = PLUGIN_ROOT / "prompts" / f"agent.system.tool.{tool_name}.md"
add(f"tool_file:{tool_name}", path.is_file(), str(path.relative_to(PLUGIN_ROOT)) if path.exists() else "missing")
add(f"tool_prompt:{tool_name}", prompt.is_file(), str(prompt.relative_to(PLUGIN_ROOT)) if prompt.exists() else "missing")
if path.is_file():
try:
spec = importlib.util.spec_from_file_location(f"_a0_orch_check_{tool_name}", str(path))
if spec and spec.loader:
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
classes = [obj for obj in mod.__dict__.values() if isinstance(obj, type) and obj is not FrameworkTool and issubclass(obj, FrameworkTool)]
add(f"tool_subclass:{tool_name}", bool(classes), ", ".join(c.__name__ for c in classes) or "no Tool subclass")
else:
add(f"tool_import:{tool_name}", False, "could not build import spec")
except Exception as exc:
add(f"tool_import:{tool_name}", False, f"{type(exc).__name__}: {exc}")
failed = [r for r in results if not r["ok"]]
payload = {
"ok": not failed,
"failed_count": len(failed),
"note": "This runs only after Agent Zero has already found this tool. If this tool is not found, inspect plugin activation/scope/cache outside the tool.",
"checks": results if action == "full" else failed[:80],
}
return Response(message=json.dumps(payload, indent=2, ensure_ascii=False), break_loop=False)
+142
View File
@@ -0,0 +1,142 @@
"""Tool: read/update entries inside orch_kv (DB-backed, replaces .a0/*.json)."""
from __future__ import annotations
import json
import os
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
get_project_id,
get_kv, set_kv, delete_kv, list_kv_keys,
set_status as db_set_status, get_status as db_get_status,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
class OrchestratorState(Tool):
"""Read or update KV-entries for a project in `orch_kv` (patterns.db).
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
action (str): "read", "update", "delete", "list_keys" (default "read").
key (str): Schlüssel-Name (z.B. "phase", "orchestrator_mode",
"task_graph", "tool_capabilities").
Bei "read" ohne key: gibt alle Keys+Values als Dict zurück.
value (str|dict): JSON-string oder dict für action=update.
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
action: str = "read",
key: str = "",
value: str = "",
**kwargs,
):
name = _resolve_name(project_name, project_root)
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 action == "set_phase":
action = "update"
key = "phase"
value = kwargs.get("phase", value)
if action == "get_phase":
action = "read"
key = "phase"
if action == "set_status":
content = kwargs.get("status", value)
if not content:
return Response(message="ERROR: 'status' content required for action=set_status", break_loop=False)
db_set_status(pid, str(content))
return Response(message=f"Updated status for project '{name}'.", break_loop=False)
if action == "get_status":
content = db_get_status(pid)
return Response(message=content or f"No status set for project '{name}'.", break_loop=False)
if action == "list_keys":
keys = list_kv_keys(pid)
return Response(
message=f"Keys for project '{name}': {', '.join(keys) if keys else '(none)'}",
break_loop=False,
)
if action == "read":
if not key:
# Alle Keys als Dict zurückgeben
keys = list_kv_keys(pid)
data = {k: get_kv(pid, k) for k in keys}
return Response(
message=json.dumps(data, indent=2, ensure_ascii=False, default=str),
break_loop=False,
)
v = get_kv(pid, key, default=None)
if v is None:
return Response(
message=f"Key '{key}' not set for project '{name}'.",
break_loop=False,
)
return Response(
message=json.dumps(v, indent=2, ensure_ascii=False, default=str),
break_loop=False,
)
if action == "update":
if not key:
return Response(
message="ERROR: 'key' required for action=update",
break_loop=False,
)
# value kann dict (direkt) oder str (JSON-encoded) sein
if isinstance(value, str):
try:
parsed = json.loads(value) if value.strip() else None
except json.JSONDecodeError:
# Nicht-JSON-String → als reiner String speichern
parsed = value
else:
parsed = value
set_kv(pid, key, parsed)
return Response(
message=f"Updated key '{key}' for project '{name}'.",
break_loop=False,
)
if action == "delete":
if not key:
return Response(
message="ERROR: 'key' required for action=delete",
break_loop=False,
)
ok = delete_kv(pid, key)
return Response(
message=(
f"Deleted key '{key}' for project '{name}'." if ok
else f"Key '{key}' did not exist for project '{name}'."
),
break_loop=False,
)
return Response(
message=f"Unknown action {action!r}. Supported: read, update, delete, list_keys, set_phase, get_phase, set_status, get_status",
break_loop=False,
)
+163
View File
@@ -0,0 +1,163 @@
"""Tool: enforce Plan Mode by checking current mode (DB-backed, global)."""
from __future__ import annotations
import json
from typing import Any, Dict
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
_db_error_handler, _db,
)
_DEFAULT_MODE = {
"mode": "planning_only",
"allowed_actions": [],
"blocked_actions": [
"code",
"deploy",
"destroy",
"push_to_main",
],
}
_MODE_POLICIES = {
"planning_only": {"allowed_actions": [], "blocked_actions": ["code", "deploy", "destroy", "push_to_main"]},
"implementation_allowed": {"allowed_actions": ["code", "test", "commit"], "blocked_actions": ["deploy", "destroy", "push_to_main"]},
"runtime_verification_allowed": {"allowed_actions": ["code", "test", "commit", "start_server"], "blocked_actions": ["deploy", "destroy"]},
"deployment_preparation_allowed": {"allowed_actions": ["code", "test", "commit", "deploy_staging"], "blocked_actions": ["deploy_production", "destroy"]},
"release_handoff_allowed": {"allowed_actions": ["deploy_production"], "blocked_actions": ["destroy"]},
"maintenance_allowed": {"allowed_actions": ["code", "test", "commit", "deploy"], "blocked_actions": ["destroy"]},
}
# Plan-Mode ist plugin-weit (Bug 7 Fix). Der Key lebt in plugin_settings,
# einer Tabelle ohne project_id/FK saubere Architektur statt
# project_id=0-Workaround in orch_kv (Migration 5).
_PLUGIN_MODE_KEY = "orchestrator_mode"
def _normalize_mode(mode_data: Any) -> Dict[str, Any]:
"""Stellt sicher, dass das gelesene Mode-Dict die erwarteten Keys hat."""
if not isinstance(mode_data, dict):
return dict(_DEFAULT_MODE)
out = dict(_DEFAULT_MODE)
out.update({
"mode": mode_data.get("mode", _DEFAULT_MODE["mode"]),
"allowed_actions": list(mode_data.get("allowed_actions") or []),
"blocked_actions": list(mode_data.get("blocked_actions") or []),
})
return out
@_db_error_handler
def _get_plugin_mode() -> Dict[str, Any]:
"""Liest den globalen Plan-Mode aus plugin_settings.
Returns _DEFAULT_MODE wenn kein Eintrag existiert.
"""
db = _db()
row = db.conn.execute(
"SELECT value_json FROM plugin_settings WHERE key = ?",
(_PLUGIN_MODE_KEY,),
).fetchone()
if not row:
return dict(_DEFAULT_MODE)
try:
return _normalize_mode(json.loads(row[0]))
except (json.JSONDecodeError, TypeError):
return dict(_DEFAULT_MODE)
@_db_error_handler
def _set_plugin_mode(mode_data: Dict[str, Any]) -> None:
"""Schreibt den globalen Plan-Mode in plugin_settings."""
db = _db()
db.conn.execute(
"INSERT INTO plugin_settings (key, value_json, updated_at) "
"VALUES (?, ?, datetime('now')) "
"ON CONFLICT(key) DO UPDATE SET "
" value_json = excluded.value_json, updated_at = datetime('now')",
(_PLUGIN_MODE_KEY, json.dumps(mode_data)),
)
db.conn.commit()
class PlanModeGuard(Tool):
"""Prüft den aktuellen Plan-Mode (plugin-weit) und entscheidet, ob eine
Aktion erlaubt ist.
KEIN resolve_project()-Aufruf mehr: Plan-Mode ist global, nicht pro Projekt.
Args (kwargs):
requested_action (str): Action-Name zum Testen (z.B. "code", "deploy").
new_mode (str): Optional neuen Mode setzen (für Mode-Transitions).
"""
async def execute(
self,
requested_action: str = "",
new_mode: str = "",
**kwargs,
):
# Mode-Transition (z.B. planning_only → implementation_allowed)
if new_mode:
if new_mode not in _MODE_POLICIES:
return Response(
message=json.dumps({
"verdict": "ERROR",
"error": f"invalid mode: {new_mode}",
"allowed_modes": sorted(_MODE_POLICIES),
}, indent=2),
break_loop=False,
)
mode_data = {
"mode": new_mode,
"allowed_actions": list(_MODE_POLICIES[new_mode]["allowed_actions"]),
"blocked_actions": list(_MODE_POLICIES[new_mode]["blocked_actions"]),
}
_set_plugin_mode(mode_data)
return Response(
message=json.dumps({
"verdict": "ALLOWED",
"mode": new_mode,
"allowed_actions": mode_data["allowed_actions"],
"blocked_actions": mode_data["blocked_actions"],
"transitioned": True,
}, indent=2),
break_loop=False,
)
# Ansonsten: aktuellen Mode lesen und Aktion prüfen
mode_data = _get_plugin_mode()
current_mode = mode_data["mode"]
allowed = mode_data["allowed_actions"]
blocked = mode_data["blocked_actions"]
if requested_action and requested_action in blocked:
return Response(
message=json.dumps({
"verdict": "BLOCKED",
"mode": current_mode,
"action": requested_action,
"reason": f"action '{requested_action}' is not allowed in mode '{current_mode}'",
}, indent=2),
break_loop=False,
)
if not requested_action or requested_action in allowed:
return Response(
message=json.dumps({
"verdict": "ALLOWED",
"mode": current_mode,
"action": requested_action,
}, indent=2),
break_loop=False,
)
return Response(
message=json.dumps({
"verdict": "UNKNOWN",
"mode": current_mode,
"action": requested_action,
"hint": f"action not in allowed={allowed} or blocked={blocked}",
}, indent=2),
break_loop=False,
)
+139
View File
@@ -0,0 +1,139 @@
"""Tool: Project Registry Projekt-Registrierung, Status-Tracking und Historien-Snapshots."""
import json
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
list_projects,
get_project_detail,
register_project,
update_project_state,
create_project_snapshot,
list_project_snapshots,
remove_project,
)
class ProjectRegistry(Tool):
"""Tool for managing software project entries in the plugin's project database."""
async def execute(
self,
action: str = "list",
name: str = "",
git_url: str = "",
description: str = "",
tech_stack: str = "",
status: str = "",
phase: str = "",
plan_mode: str = "",
total_tasks: int = 0,
completed_tasks: int = 0,
open_errors: int = 0,
trigger: str = "",
commit_hash: str = "",
**kwargs
):
try:
if action == "list":
projects = list_projects()
return Response(
message=json.dumps(projects, indent=2, ensure_ascii=False),
break_loop=False
)
elif action == "detail":
if not name:
return Response(message="Missing 'name' for detail action", break_loop=False)
detail = get_project_detail(name)
if not detail:
return Response(message=f"Project '{name}' not found.", break_loop=False)
return Response(
message=json.dumps(detail, indent=2, ensure_ascii=False),
break_loop=False
)
elif action == "register":
if not name:
return Response(message="Missing 'name' for register action", break_loop=False)
if not git_url:
return Response(message="Missing 'git_url' for register action", break_loop=False)
project_id = register_project(
name=name,
git_url=git_url,
description=description,
tech_stack=tech_stack or "{}",
status=status or "active",
phase=phase or "intake"
)
return Response(
message=f"Project '{name}' registered successfully (id={project_id}, git_url={git_url}).",
break_loop=False
)
elif action == "update":
if not name:
return Response(message="Missing 'name' for update action", break_loop=False)
fields = {}
for field in ["status", "phase", "plan_mode"]:
val = kwargs.get(field, "")
if val:
fields[field] = val
for field in ["total_tasks", "completed_tasks", "open_errors"]:
val = kwargs.get(field)
if val is not None:
fields[field] = int(val)
if "last_active_at" in kwargs:
fields["last_active_at"] = kwargs["last_active_at"]
if "completed_at" in kwargs:
fields["completed_at"] = kwargs["completed_at"]
if not fields:
return Response(message="No fields to update.", break_loop=False)
update_project_state(name, **fields)
return Response(
message=f"Project '{name}' updated.",
break_loop=False
)
elif action == "snapshot":
if not name:
return Response(message="Missing 'name' for snapshot action", break_loop=False)
snapshot_id = create_project_snapshot(
name=name,
trigger=trigger or "manual",
commit_hash=commit_hash or ""
)
return Response(
message=f"Snapshot saved for project '{name}' (trigger: {trigger or 'manual'}).",
break_loop=False
)
elif action == "history":
if not name:
return Response(message="Missing 'name' for history action", break_loop=False)
snapshots = list_project_snapshots(name)
return Response(
message=json.dumps(snapshots, indent=2, ensure_ascii=False),
break_loop=False
)
elif action == "remove":
if not name:
return Response(message="Missing 'name' for remove action", break_loop=False)
remove_project(name)
return Response(
message=f"Project '{name}' removed from registry (snapshots also deleted).",
break_loop=False
)
else:
supported = ["list", "detail", "register", "update", "snapshot", "history", "remove"]
return Response(
message=f"Unknown action '{action}'. Supported: {', '.join(supported)}",
break_loop=False
)
except PluginDBError as e:
return Response(message=f"Database error: {e}", break_loop=False)
except ValueError as e:
return Response(message=f"Error: {e}", break_loop=False)
+469
View File
@@ -0,0 +1,469 @@
"""Tool: phase gate validation (real implementation, DB-backed).
Replaces the stub. Persists every run to orch_quality_runs in patterns.db.
Args (kwargs):
action (str): 'run' | 'show_last' | 'list_runs' | 'show_config'
Default: 'run'
project_name (str): project name (preferred, e.g. 'rentman-clone')
project_root (str): alternative — path to project root, name derived from basename
gate_level (str): 'implementation' | 'quality-review' | 'deployment' | 'release'
Default: 'quality-review'
triggered_by (str): 'manual' | 'pre-commit' | 'pre-deploy' | etc.
Default: 'manual'
dry_run (bool): if True, list checks that would run without executing
Default: False
Returns:
JSON string with decision, checks, blockers, next_action.
Persists to orch_quality_runs (one row per run).
For action=show_last: returns last run's full record.
For action=list_runs: returns summary of recent runs.
For action=show_config: returns orch_quality_config row.
"""
from __future__ import annotations
import json
import os
import subprocess
import shlex
import time
from typing import Any, Dict, List, Optional
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
DB_PATH,
get_project_id,
get_kv,
set_kv,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
from usr.plugins.a0_software_orchestrator.helpers.safety_rules import redact_secrets
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
# Registry of L1-L3 checks. Each entry: (name, level, command_template, expect_exit, parser)
# command_template uses {project_root} placeholder. timeout in seconds.
PYTHON_FASTAPI_CHECKS = {
"L1": [
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
],
"L2": [
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
],
"L3": [
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
],
}
PYTHON_FASTAPI_VUE_CHECKS = {
"L1": [
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
],
"L2": [
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
],
"L3": [
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
],
}
PYTHON_FASTAPI_REACT_CHECKS = {
"L1": [
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
{"name": "L1_tsc_noemit", "cmd": "cd {project_root}/frontend && ./node_modules/.bin/tsc --noEmit 2>&1", "expect": 0},
],
"L2": [
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
],
"L3": [
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
],
}
def _detect_tech_stack(project_root: str) -> str:
"""Auto-detect tech stack from project structure."""
has_pyproject = os.path.isfile(os.path.join(project_root, "backend", "pyproject.toml")) or os.path.isfile(os.path.join(project_root, "pyproject.toml"))
has_reqs = os.path.isfile(os.path.join(project_root, "backend", "requirements.txt")) or os.path.isfile(os.path.join(project_root, "requirements.txt"))
has_python_app = os.path.isdir(os.path.join(project_root, "backend", "app")) or os.path.isdir(os.path.join(project_root, "app"))
has_react = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and os.path.isfile(os.path.join(project_root, "frontend", "tsconfig.json"))
has_vue = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and (
os.path.isfile(os.path.join(project_root, "frontend", "vite.config.js")) or os.path.isfile(os.path.join(project_root, "frontend", "vite.config.ts"))
)
if has_reqs and has_python_app and has_react:
return "python_fastapi+node_react"
if has_reqs and has_python_app and has_vue:
return "python_fastapi+vue"
if (has_reqs or has_pyproject) and has_python_app:
return "python_fastapi"
return "unknown"
def _get_checks_for_stack(tech_stack: str, gate_level: str) -> List[Dict[str, Any]]:
"""Return list of check definitions for the given stack + gate level."""
if tech_stack == "python_fastapi+node_react":
registry = PYTHON_FASTAPI_REACT_CHECKS
elif tech_stack == "python_fastapi+vue":
registry = PYTHON_FASTAPI_VUE_CHECKS
elif tech_stack == "python_fastapi":
registry = PYTHON_FASTAPI_CHECKS
else:
return []
# Map gate_level → which L1-L3 levels are required
if gate_level in ("implementation", "quality-review"):
levels = ["L1", "L2"]
elif gate_level == "deployment":
levels = ["L1", "L2", "L3"]
elif gate_level == "release":
levels = ["L1", "L2", "L3"]
else:
levels = ["L1"]
checks: List[Dict[str, Any]] = []
for lvl in levels:
for c in registry.get(lvl, []):
checks.append({"level": lvl, **c})
return checks
def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) -> Dict[str, Any]:
"""Run a single check, return result dict."""
name = check["name"]
safe_project_root = shlex.quote(os.path.abspath(project_root))
cmd = check["cmd"].format(project_root=safe_project_root)
expect = check.get("expect", 0)
start = time.time()
try:
proc = subprocess.run(
["bash", "-lc", cmd],
shell=False,
capture_output=True,
text=True,
timeout=timeout,
)
duration_ms = int((time.time() - start) * 1000)
stdout = redact_secrets(proc.stdout[-500:] if proc.stdout else "")
stderr = redact_secrets(proc.stderr[-500:] if proc.stderr else "")
detail = redact_secrets((stdout + stderr).strip()[:300]) or f"exit={proc.returncode}"
if proc.returncode == expect:
result = "pass"
elif proc.returncode == 5: # ruff format: "would reformat" = 0 fixed
result = "fail"
else:
result = "fail"
return {
"name": name,
"level": check["level"],
"result": result,
"duration_ms": duration_ms,
"detail": detail,
"exit_code": proc.returncode,
}
except subprocess.TimeoutExpired:
return {
"name": name,
"level": check["level"],
"result": "fail",
"duration_ms": timeout * 1000,
"detail": f"timeout after {timeout}s",
"exit_code": -1,
}
except FileNotFoundError as e:
return {
"name": name,
"level": check["level"],
"result": "skip",
"duration_ms": 0,
"detail": f"command not found: {e}",
"exit_code": -1,
}
except Exception as e: # pragma: no cover
return {
"name": name,
"level": check["level"],
"result": "fail",
"duration_ms": 0,
"detail": f"exception: {e}",
"exit_code": -1,
}
def _persist_run(
project_id: int,
gate_level: str,
decision: str,
checks: List[Dict[str, Any]],
blockers: List[str],
next_action: str,
duration_ms: int,
triggered_by: str,
) -> int:
"""Insert a run into orch_quality_runs, return the new id."""
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
cur = conn.execute(
"""
INSERT INTO orch_quality_runs (project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
project_id,
gate_level,
decision,
json.dumps(checks),
json.dumps(blockers),
next_action,
duration_ms,
triggered_by,
),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def _suggest_next_action(failed_checks: List[Dict[str, Any]]) -> str:
"""Generate a human-readable next-action suggestion from failed checks."""
actions: List[str] = []
for c in failed_checks:
name = c["name"]
if name == "L1_ruff_check":
actions.append("Run 'ruff check --fix' to auto-fix")
elif name == "L1_ruff_format":
actions.append("Run 'ruff format' to fix formatting")
elif name == "L1_mypy_strict":
actions.append("Add type annotations manually")
elif name == "L1_tsc_noemit":
actions.append("Fix TypeScript errors (unused imports, type annotations)")
elif name == "L2_vite_build":
actions.append("Fix build errors (usually TS or import issues)")
elif name == "L3_pytest":
actions.append("Fix failing tests")
else:
actions.append(f"Investigate {name}")
return "; ".join(actions) if actions else "No actions needed"
def _fetch_last_run(project_id: int) -> Optional[Dict[str, Any]]:
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
row = conn.execute(
"SELECT id, project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT 1",
(project_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"project_id": row[1],
"gate_level": row[2],
"decision": row[3],
"checks": json.loads(row[4]) if row[4] else [],
"blockers": json.loads(row[5]) if row[5] else [],
"next_action": row[6],
"duration_ms": row[7],
"triggered_by": row[8],
"created_at": row[9],
}
finally:
conn.close()
def _list_runs(project_id: int, limit: int = 10) -> List[Dict[str, Any]]:
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
rows = conn.execute(
"SELECT id, gate_level, decision, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT ?",
(project_id, limit),
).fetchall()
return [
{
"id": r[0],
"gate_level": r[1],
"decision": r[2],
"duration_ms": r[3],
"triggered_by": r[4],
"created_at": r[5],
}
for r in rows
]
finally:
conn.close()
def _show_config(project_id: int) -> Optional[Dict[str, Any]]:
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
row = conn.execute(
"SELECT project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, custom_checks_json, updated_at FROM orch_quality_config WHERE project_id = ?",
(project_id,),
).fetchone()
if not row:
return None
return {
"project_id": row[0],
"tech_stack": row[1],
"coverage_min_pct": row[2],
"strict_types": bool(row[3]),
"require_security_scan": bool(row[4]),
"require_openapi_check": bool(row[5]),
"custom_checks_json": row[6],
"updated_at": row[7],
}
finally:
conn.close()
def _ensure_config(project_id: int, tech_stack: str) -> None:
"""Create or update orch_quality_config for a project."""
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
conn.execute(
"""
INSERT INTO orch_quality_config (project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, updated_at)
VALUES (?, ?, 70, 1, 1, 0, datetime('now'))
ON CONFLICT(project_id) DO UPDATE SET
tech_stack = excluded.tech_stack,
updated_at = datetime('now')
""",
(project_id, tech_stack),
)
conn.commit()
finally:
conn.close()
class QualityGate(Tool):
"""Phase gate validation (real implementation, DB-backed).
Actions:
run: execute checks, persist run, return decision
show_last: return most recent run for project
list_runs: return summary of recent runs (default 10)
show_config: return orch_quality_config for project
Auto-detects tech_stack from project_root if no config exists yet.
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
action: str = "run",
gate_level: str = "quality-review",
triggered_by: str = "manual",
dry_run: bool = False,
**kwargs,
) -> Response:
name = _resolve_name(project_name, project_root)
if not name:
return Response(message=json.dumps({"error": "no project_name or project_root given"}), break_loop=False)
# resolve project_id (no auto-create; LookupError on unknown name)
try:
project_id = get_project_id(name)
if project_id is None:
raise LookupError(f"project '{name}' is not registered")
except (ValueError, LookupError) as e:
return Response(message=json.dumps({"error": str(e)}), break_loop=False)
except PluginDBError as e:
return Response(message=json.dumps({"error": f"Database error: {e}"}), break_loop=False)
# Determine project_root: prefer explicit, else default to /a0/usr/workdir/<name>-check
if not project_root:
project_root = f"/a0/usr/workdir/{name}-check"
if not os.path.isdir(project_root):
return Response(message=json.dumps({"error": f"project_root {project_root!r} does not exist; clone the repo first"}), break_loop=False)
if action == "show_last":
run = _fetch_last_run(project_id)
return Response(message=json.dumps(run or {"info": "no runs yet"}, indent=2), break_loop=False)
if action == "list_runs":
runs = _list_runs(project_id)
return Response(message=json.dumps(runs, indent=2), break_loop=False)
if action == "show_config":
cfg = _show_config(project_id)
return Response(message=json.dumps(cfg or {"info": "no config yet"}, indent=2), break_loop=False)
# action == 'run' (default)
tech_stack = _detect_tech_stack(project_root)
if tech_stack == "unknown":
return Response(message=json.dumps({"error": f"could not detect tech_stack in {project_root}", "project_root": project_root}), break_loop=False)
_ensure_config(project_id, tech_stack)
checks_def = _get_checks_for_stack(tech_stack, gate_level)
if not checks_def:
return Response(message=json.dumps({"error": f"no checks defined for tech_stack={tech_stack} gate_level={gate_level}"}), break_loop=False)
if dry_run:
return Response(message=json.dumps({
"project": name,
"project_id": project_id,
"tech_stack": tech_stack,
"gate_level": gate_level,
"would_run": [c["name"] for c in checks_def],
}, indent=2), break_loop=False)
start = time.time()
results: List[Dict[str, Any]] = []
for c in checks_def:
results.append(_run_check(c, project_root))
duration_ms = int((time.time() - start) * 1000)
failed = [r for r in results if r["result"] == "fail"]
skipped = [r for r in results if r["result"] == "skip"]
decision = "fail" if failed else ("warn" if skipped else "pass")
blockers = [f"{r['name']}: {r['detail']}" for r in failed]
next_action = _suggest_next_action(failed)
run_id = _persist_run(
project_id=project_id,
gate_level=gate_level,
decision=decision,
checks=results,
blockers=blockers,
next_action=next_action,
duration_ms=duration_ms,
triggered_by=triggered_by,
)
summary = {
"run_id": run_id,
"project": name,
"project_id": project_id,
"tech_stack": tech_stack,
"gate_level": gate_level,
"decision": decision,
"checks": results,
"blockers": blockers,
"next_action": next_action,
"duration_ms": duration_ms,
"triggered_by": triggered_by,
}
return Response(message=json.dumps(summary, indent=2), break_loop=False)
+95
View File
@@ -0,0 +1,95 @@
"""Tool: read_briefing read or list DB-backed handover briefings."""
from __future__ import annotations
import json
import os
from typing import Optional
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.briefing_file import (
read_briefing as read_briefing_text,
list_briefings_for,
)
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import get_briefing
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
class ReadBriefing(Tool):
"""Read or list handover briefings created by handover_to_specialist.
Args:
action: "read" (default), "metadata", or "list".
briefing_id: DB id returned by handover_to_specialist.
project_name/project_root: used for action=list.
specialist: optional filter for action=list.
max_bytes: max bytes of markdown returned by action=read.
format: "text" or "json".
"""
async def execute(
self,
action: str = "read",
briefing_id: int = 0,
project_name: str = "",
project_root: str = "",
specialist: Optional[str] = None,
max_bytes: int = 32000,
format: str = "text",
**kwargs,
):
try:
if action == "list":
name = _resolve_name(project_name, project_root)
rows = list_briefings_for(
project_name=name,
project_root=project_root,
specialist=specialist,
limit=int(kwargs.get("limit", 50)),
)
return Response(
message=json.dumps(rows, indent=2, ensure_ascii=False, default=str),
break_loop=False,
)
if not briefing_id:
return Response(
message="ERROR: 'briefing_id' is required for action=read/metadata.",
break_loop=False,
)
if action == "metadata":
rec = get_briefing(int(briefing_id))
if not rec:
return Response(message=f"Briefing id={briefing_id} not found.", break_loop=False)
rec = dict(rec)
rec.pop("section_md", None)
return Response(
message=json.dumps(rec, indent=2, ensure_ascii=False, default=str),
break_loop=False,
)
if action == "read":
text = read_briefing_text(int(briefing_id), max_bytes=max(1, int(max_bytes)))
if not text:
return Response(message=f"Briefing id={briefing_id} not found.", break_loop=False)
if format == "json":
return Response(
message=json.dumps({"briefing_id": briefing_id, "section_md": text}, indent=2, ensure_ascii=False),
break_loop=False,
)
return Response(message=text, break_loop=False)
return Response(
message=f"Unknown action {action!r}. Supported: read, metadata, list",
break_loop=False,
)
except (ValueError, PluginDBError) as e:
return Response(message=f"ERROR: {e}", break_loop=False)
+158
View File
@@ -0,0 +1,158 @@
"""Tool: create/read/update/validate project manifests (DB-backed, replaces .a0/manifests/*.json)."""
from __future__ import annotations
import json
import os
from typing import Any, Dict
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import get_project_id, get_kv, set_kv, list_kv_keys
from usr.plugins.a0_software_orchestrator.helpers.manifest_schema import PROJECT_MANIFEST_SCHEMA, DEPLOYMENT_MANIFEST_SCHEMA
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
def _schema_for(manifest_type: str) -> Dict[str, Any]:
if manifest_type == "project":
return PROJECT_MANIFEST_SCHEMA
if manifest_type == "deployment":
return DEPLOYMENT_MANIFEST_SCHEMA
return {}
def _validate(data: Dict[str, Any], schema: Dict[str, Any]) -> list[str]:
"""Lightweight required-keys validator (subset of JSON-Schema)."""
errors = []
if not isinstance(data, dict):
return [f"manifest must be a JSON object, got {type(data).__name__}"]
required = schema.get("required", [])
for key in required:
if key not in data:
errors.append(f"missing required field: {key!r}")
return errors
class RepoManifest(Tool):
"""Create, read, update, list or validate project/deployment/quality manifests.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
manifest_type (str): "project" (default), "deployment", "quality".
action (str): "create" (default), "read", "update", "list", "validate".
data (str|dict): JSON-string oder dict (für create/update).
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
manifest_type: str = "project",
action: str = "create",
data: str = "",
**kwargs,
):
name = _resolve_name(project_name, project_root)
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)
key = f"manifest_{manifest_type}"
schema = _schema_for(manifest_type)
if action == "list":
keys = [k for k in list_kv_keys(pid) if k.startswith("manifest_")]
if not keys:
return Response(
message=f"No manifests for project {name!r}.",
break_loop=False,
)
return Response(
message=f"Manifests for {name!r}: {', '.join(keys)}",
break_loop=False,
)
if action == "read":
manifest = get_kv(pid, key, default=None)
if manifest is None:
return Response(
message=f"No {manifest_type!r} manifest for project {name!r}.",
break_loop=False,
)
return Response(
message=json.dumps(manifest, indent=2, ensure_ascii=False),
break_loop=False,
)
if action in ("create", "update"):
# Parse data
if not data:
return Response(
message="ERROR: 'data' required for create/update (JSON string or dict)",
break_loop=False,
)
if isinstance(data, str):
try:
parsed = json.loads(data)
except json.JSONDecodeError as e:
return Response(
message=f"ERROR: 'data' is not valid JSON: {e}",
break_loop=False,
)
else:
parsed = data
# Validate against schema (if known)
if schema:
errors = _validate(parsed, schema)
if errors:
return Response(
message=f"ERROR: manifest validation failed: {errors}",
break_loop=False,
)
set_kv(pid, key, parsed)
return Response(
message=f"{manifest_type!r} manifest saved for project {name!r} (key={key!r}).",
break_loop=False,
)
if action == "validate":
manifest = get_kv(pid, key, default=None)
if manifest is None:
return Response(
message=f"No {manifest_type!r} manifest to validate for project {name!r}.",
break_loop=False,
)
if not schema:
return Response(
message=f"No schema defined for {manifest_type!r}; cannot validate.",
break_loop=False,
)
errors = _validate(manifest, schema)
if errors:
return Response(
message=f"Validation FAILED: {errors}",
break_loop=False,
)
return Response(
message=f"Validation PASSED for {manifest_type!r} manifest of project {name!r}.",
break_loop=False,
)
return Response(
message=f"Unknown action {action!r}. Supported: create, read, update, list, validate",
break_loop=False,
)
+73
View File
@@ -0,0 +1,73 @@
"""Tool: reconstruct current state after interruption (DB-backed)."""
from __future__ import annotations
import os
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
get_project_id,
get_kv, get_status, list_next_steps, list_todos, list_errors,
get_block_compact,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
class ResumeChecker(Tool):
"""Liest den aktuellen State aus der DB und liefert eine kompakte
Recovery-Übersicht für Session-Resume nach Crash/Timeout.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
**kwargs,
):
name = _resolve_name(project_name, project_root)
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)
status = get_status(pid)
next_steps = list_next_steps(pid, status="pending")
open_todos = list_todos(pid, status="open")
open_errors = list_errors(pid, status="open")
bc = get_block_compact(pid)
phase = get_kv(pid, "phase", default="<unknown>")
if not status and not next_steps and not bc:
return Response(
message=f"No state found for recovery on project {name!r}.",
break_loop=False,
)
lines = [f"🔁 RESUME for project {name!r} (pid={pid})"]
lines.append(f" - Phase: {phase}")
lines.append(f" - Status: {status or '(none)'}")
lines.append(f" - Pending next steps: {len(next_steps)}")
lines.append(f" - Open todos: {len(open_todos)}")
lines.append(f" - Open errors: {len(open_errors)}")
if bc:
lines.append(
f" - Last block: {bc.get('last_block_id')} "
f"@ {bc.get('last_compact_at')}"
)
lines.append(f" - Next block: {bc.get('next_block')}")
return Response(message="\n".join(lines), break_loop=False)
+88
View File
@@ -0,0 +1,88 @@
"""Tool: append a scorecard entry (DB-backed, replaces .a0/project_scorecard.md)."""
from __future__ import annotations
import os
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import get_project_id, add_score, list_scores
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
class ScorecardUpdate(Tool):
"""Append or list scorecard entries for a project.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
action (str): "add" (default), "list".
category (str): Kategorie (z.B. "tests", "lint", "docs").
score (int): Punkte (0-100).
max_score (int): Maximal mögliche Punkte (default 100).
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
action: str = "add",
category: str = "",
score: int = 0,
max_score: int = 100,
**kwargs,
):
name = _resolve_name(project_name, project_root)
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 action == "list":
entries = list_scores(pid)
if not entries:
return Response(
message=f"No scorecard entries for project {name!r}.",
break_loop=False,
)
lines = [f"Scorecard for {name!r}:"]
lines.append("| id | category | score | max | created |")
lines.append("|---|---|---|---|---|")
for e in entries:
lines.append(
f"| {e['id']} | {e['category']} | {e['score']} | "
f"{e['max_score']} | {e['created_at']} |"
)
return Response(message="\n".join(lines), break_loop=False)
if action == "add":
if not category:
return Response(
message="ERROR: 'category' required for action=add",
break_loop=False,
)
if score < 0 or max_score <= 0 or score > max_score:
return Response(
message=f"ERROR: invalid score {score}/{max_score} (must be 0 ≤ score ≤ max_score, max_score > 0)",
break_loop=False,
)
new_id = add_score(pid, category, score, max_score)
return Response(
message=f"Score added (id={new_id}): {category} = {score}/{max_score} for {name!r}.",
break_loop=False,
)
return Response(
message=f"Unknown action {action!r}. Supported: add, list",
break_loop=False,
)
+113
View File
@@ -0,0 +1,113 @@
"""Tool: record/inspect tool health (DB-backed, replaces .a0/tool_registry.json)."""
from __future__ import annotations
import json
import os
from datetime import datetime
from typing import Any, Dict
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import get_project_id, set_kv, get_kv
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
def _now() -> str:
return datetime.utcnow().isoformat() + "Z"
class ToolRegistry(Tool):
"""Register, list, or deregister tools in `orch_kv["tool_registry"]`.
Args (kwargs):
project_name (str): Projektname (bevorzugt).
project_root (str): Legacy wird zu basename() aufgelöst.
action (str): "register" (default), "list", "deregister".
tool_name (str): Name des Tools (für register/deregister).
status (str): Status "ok", "degraded", "failed" (für register).
details (str): Optionale Details (für register).
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
action: str = "list",
tool_name: str = "",
status: str = "ok",
details: str = "",
**kwargs,
):
name = _resolve_name(project_name, project_root)
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)
# Wir nutzen orch_kv mit key="tool_registry" als JSON-Blob
reg: Dict[str, Any] = get_kv(pid, "tool_registry", default={"tools": {}})
if "tools" not in reg:
reg = {"tools": {}}
if action == "register":
if not tool_name:
return Response(
message="ERROR: 'tool_name' required for action=register",
break_loop=False,
)
reg["tools"][tool_name] = {
"status": status,
"last_checked": _now(),
}
if details:
reg["tools"][tool_name]["details"] = details
set_kv(pid, "tool_registry", reg)
return Response(
message=f"Registered {tool_name!r} as {status!r} for project {name!r}.",
break_loop=False,
)
if action == "deregister":
if not tool_name:
return Response(
message="ERROR: 'tool_name' required for action=deregister",
break_loop=False,
)
if tool_name in reg["tools"]:
del reg["tools"][tool_name]
set_kv(pid, "tool_registry", reg)
return Response(
message=f"Deregistered {tool_name!r} for project {name!r}.",
break_loop=False,
)
return Response(
message=f"Tool {tool_name!r} not registered for project {name!r}.",
break_loop=False,
)
if action == "list":
if not reg["tools"]:
return Response(
message=f"No tools registered for project {name!r}.",
break_loop=False,
)
return Response(
message=json.dumps(reg, indent=2, ensure_ascii=False),
break_loop=False,
)
return Response(
message=f"Unknown action {action!r}. Supported: register, deregister, list",
break_loop=False,
)