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:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user