commit dffa821c38dfe049482962748ef1458a9e01a5e43ce049cc343ce14337b76949 Author: Tyler King Date: Mon Apr 6 03:24:01 2026 -0400 feat: Guildhouse MCP server + multi-agent governed dev pipeline MCP server connecting Claude Code to the Guildhouse ecosystem. Tools (23): Forgejo: repos, files, branches, PRs, CI status Chronicle: events, epochs, verify, emit GSAP proxy: request_ac, check_operation, get_posture, delegate Capstone: agents, tenants Tasks: list, get, create, update_status, submit_for_review, submit_review Multi-agent topology: Lead Agent — plans work, delegates, reviews proposals Worker Agents — implement in parallel, submit PRs with confidence Reviewer Agent — QA's worker output, submits review confidence Confidence Gate — auto-merge/flag/propose/reject based on combined score Confidence thresholds (configurable): >= 85: auto-merge (no human needed) >= 70: flag-merge (merged, flagged for post-review) >= 50: proposal (Tyler reviews) < 50: reject (worker revises) Reviewer can override: 'reject' always rejects, 'request_changes' caps at propose. Task definitions (TOML) with phased prompts, success criteria, and delegation scopes. Every tool call emits Chronicle MCP_TOOL_CALL (0x3020). Every gate decision emits Chronicle events (0x4010-0x4013). Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0ef1c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.pytest_cache/ +.status.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5e4ee86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# CLAUDE.md — Guildhouse MCP Server + +## What is this? + +MCP server connecting Claude Code to the Guildhouse ecosystem: Forgejo, Capstone, GSAP governance, Chronicle audit, and K8s/Flux. + +## Structure + +- `src/guildhouse_mcp/server.py` — FastMCP server with 25+ tools +- `src/guildhouse_mcp/tools/` — Backend implementations (forgejo, chronicle, capstone, gsap_proxy, tasks) +- `src/guildhouse_mcp/agents/` — Multi-agent orchestration (confidence gate, topology) +- `tasks/` — TOML task definitions with phased prompts + +## Commands + +```bash +pip install -e ".[dev]" +python -m guildhouse_mcp # Run MCP server (stdio) +python -m pytest tests/ # Run tests +``` + +## Key Concepts + +- **Confidence gate**: Combined worker (40%) + reviewer (60%) score decides auto-merge/flag/propose/reject +- **Chronicle recording**: Every tool call emits MCP_TOOL_CALL (0x3020) event +- **GSAP proxy**: Forwards governance tool calls to existing GSAP MCP broker +- **Task definitions**: TOML files with phases, success criteria, delegation scopes + +## Thresholds (configurable via env) + +- `GUILDHOUSE_AUTO_MERGE_THRESHOLD=85` — No human needed +- `GUILDHOUSE_FLAG_MERGE_THRESHOLD=70` — Merged, flagged for post-review +- `GUILDHOUSE_PROPOSAL_THRESHOLD=50` — Tyler reviews +- Below 50 — Rejected, worker revises diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..50c8f2c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "guildhouse-mcp" +version = "0.1.0" +description = "Guildhouse governed development MCP server" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27.0", + "httpx>=0.27.0", + "pydantic>=2.0", + "pydantic-settings>=2.0", +] + +[project.optional-dependencies] +k8s = ["kubernetes>=28.0"] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23"] + +[project.scripts] +guildhouse-mcp = "guildhouse_mcp.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/guildhouse_mcp"] diff --git a/src/guildhouse_mcp/__init__.py b/src/guildhouse_mcp/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/guildhouse_mcp/__main__.py b/src/guildhouse_mcp/__main__.py new file mode 100644 index 0000000..813050f --- /dev/null +++ b/src/guildhouse_mcp/__main__.py @@ -0,0 +1,12 @@ +"""Entry point for guildhouse-mcp server.""" + +from .server import mcp + + +def main(): + """Run the MCP server via stdio (for Claude Code).""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/guildhouse_mcp/agents/__init__.py b/src/guildhouse_mcp/agents/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/guildhouse_mcp/agents/confidence.py b/src/guildhouse_mcp/agents/confidence.py new file mode 100644 index 0000000..47c742c --- /dev/null +++ b/src/guildhouse_mcp/agents/confidence.py @@ -0,0 +1,95 @@ +""" +Confidence-based approval gate. + +Combines worker and reviewer confidence scores to decide: +auto-merge, flag-merge, propose-to-human, or reject. +""" + +from enum import Enum +from dataclasses import dataclass +from ..config import settings + + +class GateDecision(str, Enum): + AUTO_MERGE = "auto_merge" + FLAG_MERGE = "flag_merge" + PROPOSE = "propose_to_human" + REJECT = "reject" + + +@dataclass +class GateResult: + decision: GateDecision + combined_confidence: float + worker_confidence: int + reviewer_confidence: int + reason: str + auto_approved: bool + + +def evaluate_gate( + worker_confidence: int, + reviewer_confidence: int, + reviewer_recommendation: str = "approve", +) -> GateResult: + """ + Evaluate the confidence gate. + + Combined = (worker * 0.4) + (reviewer * 0.6) + Reviewer weighted higher because QA is the verification step. + + If reviewer recommends "reject" -> always reject regardless of score. + If reviewer recommends "request_changes" -> cap at PROPOSE level. + """ + combined = (worker_confidence * 0.4) + (reviewer_confidence * 0.6) + + if reviewer_recommendation == "reject": + return GateResult( + decision=GateDecision.REJECT, + combined_confidence=combined, + worker_confidence=worker_confidence, + reviewer_confidence=reviewer_confidence, + reason=f"Reviewer rejected (combined: {combined:.0f})", + auto_approved=False, + ) + + if reviewer_recommendation == "request_changes": + decision = ( + GateDecision.PROPOSE + if combined >= settings.proposal_threshold + else GateDecision.REJECT + ) + return GateResult( + decision=decision, + combined_confidence=combined, + worker_confidence=worker_confidence, + reviewer_confidence=reviewer_confidence, + reason=f"Reviewer requested changes (combined: {combined:.0f})", + auto_approved=False, + ) + + if combined >= settings.auto_merge_threshold: + decision = GateDecision.AUTO_MERGE + reason = f"High confidence ({combined:.0f} >= {settings.auto_merge_threshold})" + auto = True + elif combined >= settings.flag_merge_threshold: + decision = GateDecision.FLAG_MERGE + reason = f"Moderate confidence ({combined:.0f} >= {settings.flag_merge_threshold}), flagged for post-review" + auto = True + elif combined >= settings.proposal_threshold: + decision = GateDecision.PROPOSE + reason = f"Below auto-merge ({combined:.0f} < {settings.flag_merge_threshold}), creating proposal" + auto = False + else: + decision = GateDecision.REJECT + reason = f"Low confidence ({combined:.0f} < {settings.proposal_threshold})" + auto = False + + return GateResult( + decision=decision, + combined_confidence=combined, + worker_confidence=worker_confidence, + reviewer_confidence=reviewer_confidence, + reason=reason, + auto_approved=auto, + ) diff --git a/src/guildhouse_mcp/config.py b/src/guildhouse_mcp/config.py new file mode 100644 index 0000000..8fcb24d --- /dev/null +++ b/src/guildhouse_mcp/config.py @@ -0,0 +1,37 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Forgejo + forgejo_url: str = "https://git.guildhouse.dev" + forgejo_token: str = "" + + # Capstone (engine API) + capstone_url: str = "http://localhost:8000" + capstone_token: str = "" + + # GSAP Broker + gsap_url: str = "http://localhost:8090" + + # Guildhouse Platform API (Rust) + platform_url: str = "http://localhost:8080" + + # Chronicle identity + chronicle_source_id: str = "did:web:guildhouse.dev/agent/claude-code" + + # Agent identity + agent_did: str = "did:web:guildhouse.dev/agent/lead" + + # Confidence thresholds + auto_merge_threshold: int = 85 + flag_merge_threshold: int = 70 + proposal_threshold: int = 50 + + # K8s (optional) + kubeconfig: str = "" + k8s_context: str = "" + + model_config = {"env_prefix": "GUILDHOUSE_"} + + +settings = Settings() diff --git a/src/guildhouse_mcp/identity.py b/src/guildhouse_mcp/identity.py new file mode 100644 index 0000000..11ecf25 --- /dev/null +++ b/src/guildhouse_mcp/identity.py @@ -0,0 +1,26 @@ +"""Agent identity and delegation management.""" + +from dataclasses import dataclass, field +from .config import Settings + + +@dataclass +class AgentIdentity: + """Tracks this agent's identity and active delegation.""" + + settings: Settings + delegation_id: str = "" + ac_id: str = "" + capabilities: list[str] = field(default_factory=list) + + @property + def did(self) -> str: + return self.settings.agent_did + + @property + def source_id(self) -> str: + return self.settings.chronicle_source_id + + @property + def has_delegation(self) -> bool: + return bool(self.delegation_id) diff --git a/src/guildhouse_mcp/server.py b/src/guildhouse_mcp/server.py new file mode 100644 index 0000000..60d3d0f --- /dev/null +++ b/src/guildhouse_mcp/server.py @@ -0,0 +1,264 @@ +""" +Guildhouse MCP Server — governed development tools. + +Connects Claude Code to Forgejo, Capstone, GSAP, Chronicle, +and K8s via MCP protocol. All actions are governed by GSAP ACs +and recorded in Chronicle. +""" + +import json +from mcp.server.fastmcp import FastMCP +from .config import settings +from .tools import forgejo, chronicle, capstone, gsap_proxy, tasks + +mcp = FastMCP("guildhouse-mcp") + + +# ═══════════════════════════════════════════ +# FORGEJO TOOLS +# ═══════════════════════════════════════════ + +@mcp.tool() +async def forgejo_list_repos(org: str = "", limit: int = 20) -> str: + """List repositories accessible to this agent. Filter by org if specified.""" + result = await forgejo.list_repos(org=org, limit=limit) + await chronicle.emit_tool_call("forgejo_list_repos", {"org": org}) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def forgejo_read_file(repo: str, path: str, ref: str = "main") -> str: + """Read a file from a Forgejo repository.""" + result = await forgejo.read_file(repo=repo, path=path, ref=ref) + await chronicle.emit_tool_call("forgejo_read_file", {"repo": repo, "path": path}) + return result + + +@mcp.tool() +async def forgejo_create_branch(repo: str, branch: str, from_ref: str = "main") -> str: + """Create a new branch in a repository for agent work.""" + result = await forgejo.create_branch(repo=repo, branch=branch, from_ref=from_ref) + await chronicle.emit_tool_call("forgejo_create_branch", {"repo": repo, "branch": branch}) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def forgejo_create_pr( + repo: str, title: str, body: str, + head: str, base: str = "main", confidence: int = 0, +) -> str: + """Create a pull request. Include confidence score (0-100) in metadata.""" + result = await forgejo.create_pr( + repo=repo, title=title, body=body, + head=head, base=base, + labels=["agent-created"], + metadata={"confidence": confidence, "agent": settings.agent_did}, + ) + await chronicle.emit_tool_call("forgejo_create_pr", { + "repo": repo, "title": title, "confidence": confidence, + }) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def forgejo_get_pr(repo: str, pr_number: int) -> str: + """Get pull request details including CI status and review comments.""" + result = await forgejo.get_pr(repo=repo, pr_number=pr_number) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def forgejo_ci_status(repo: str, ref: str = "main") -> str: + """Check CI pipeline status for a branch or commit.""" + result = await forgejo.ci_status(repo=repo, ref=ref) + return json.dumps(result, indent=2) + + +# ═══════════════════════════════════════════ +# CHRONICLE TOOLS +# ═══════════════════════════════════════════ + +@mcp.tool() +async def chronicle_query_events( + source: str = "", event_type: str = "", + principal: str = "", limit: int = 20, +) -> str: + """Query Chronicle events. Filter by source, type, or principal.""" + result = await chronicle.query_events( + source=source, event_type=event_type, + principal=principal, limit=limit, + ) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def chronicle_query_epochs(source: str = "", limit: int = 10) -> str: + """Query sealed Chronicle epochs. Shows merkle roots and chain integrity.""" + result = await chronicle.query_epochs(source=source, limit=limit) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def chronicle_verify_epoch(epoch_id: str) -> str: + """Verify a Chronicle epoch's integrity — recomputes and checks all merkle roots.""" + result = await chronicle.verify_epoch(epoch_id=epoch_id) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def chronicle_emit(event_type: str, event_code: str, payload: str = "{}") -> str: + """Emit a Chronicle event for this agent's action. Payload is JSON string.""" + result = await chronicle.emit_event( + event_type=event_type, + event_code=event_code, + principal=settings.agent_did, + payload=json.loads(payload), + ) + return json.dumps(result, indent=2) + + +# ═══════════════════════════════════════════ +# GSAP GOVERNANCE TOOLS +# ═══════════════════════════════════════════ + +@mcp.tool() +async def gsap_request_ac(scope: str, duration_minutes: int = 60) -> str: + """Request an Authorization Context for a governed operation.""" + result = await gsap_proxy.request_ac(scope=scope, duration=duration_minutes) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def gsap_check_operation(operation: str, target: str = "") -> str: + """Dry-run: check if an operation is allowed at the current DEFCON posture.""" + result = await gsap_proxy.check_operation(operation=operation, target=target) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def gsap_get_posture() -> str: + """Get current DEFCON posture level and any restrictions.""" + result = await gsap_proxy.get_posture() + return json.dumps(result, indent=2) + + +@mcp.tool() +async def gsap_request_delegation( + agent_name: str, scope: str, + capabilities: str = "code:read,code:write", + max_ttl_minutes: int = 120, +) -> str: + """Create a delegation for a sub-agent with scoped capabilities.""" + result = await gsap_proxy.request_delegation( + agent_name=agent_name, + scope=scope, + capabilities=capabilities.split(","), + max_ttl_minutes=max_ttl_minutes, + ) + await chronicle.emit_tool_call("gsap_request_delegation", { + "agent_name": agent_name, "scope": scope, + }) + return json.dumps(result, indent=2) + + +# ═══════════════════════════════════════════ +# TASK MANAGEMENT TOOLS +# ═══════════════════════════════════════════ + +@mcp.tool() +async def task_list() -> str: + """List available task definitions from the tasks/ directory.""" + result = await tasks.list_tasks() + return json.dumps(result, indent=2) + + +@mcp.tool() +async def task_get(task_id: str) -> str: + """Get a task definition including all phases and success criteria.""" + result = await tasks.get_task(task_id) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def task_create( + task_id: str, repo: str, description: str, phases: str, +) -> str: + """Create a new task definition. phases is a JSON array of phase objects.""" + result = await tasks.create_task( + task_id=task_id, repo=repo, + description=description, + phases=json.loads(phases), + ) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def task_update_status( + task_id: str, phase: int, status: str, + confidence: int = 0, notes: str = "", +) -> str: + """Update task phase status. Triggers confidence gate if phase is complete.""" + result = await tasks.update_status( + task_id=task_id, phase=phase, + status=status, confidence=confidence, notes=notes, + ) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def submit_for_review( + task_id: str, phase: int, pr_number: int, + worker_confidence: int, notes: str = "", +) -> str: + """Submit completed work for reviewer agent evaluation.""" + result = await tasks.submit_for_review( + task_id=task_id, phase=phase, + pr_number=pr_number, + worker_confidence=worker_confidence, + notes=notes, + ) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def submit_review( + task_id: str, phase: int, pr_number: int, + reviewer_confidence: int, + findings: str = "[]", + recommendation: str = "approve", +) -> str: + """Submit a review evaluation. Triggers confidence gate for merge decision.""" + result = await tasks.submit_review( + task_id=task_id, phase=phase, + pr_number=pr_number, + reviewer_confidence=reviewer_confidence, + findings=json.loads(findings), + recommendation=recommendation, + ) + return json.dumps(result, indent=2) + + +# ═══════════════════════════════════════════ +# CAPSTONE TOOLS +# ═══════════════════════════════════════════ + +@mcp.tool() +async def capstone_list_agents() -> str: + """List agent profiles from Capstone.""" + result = await capstone.list_agents() + return json.dumps(result, indent=2) + + +@mcp.tool() +async def capstone_run_agent(agent_slug: str, input_text: str) -> str: + """Trigger an agent run in Capstone.""" + result = await capstone.run_agent(slug=agent_slug, input_text=input_text) + await chronicle.emit_tool_call("capstone_run_agent", {"agent": agent_slug}) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def capstone_list_tenants() -> str: + """List tenants from the platform API.""" + result = await capstone.list_tenants() + return json.dumps(result, indent=2) diff --git a/src/guildhouse_mcp/tools/__init__.py b/src/guildhouse_mcp/tools/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/guildhouse_mcp/tools/capstone.py b/src/guildhouse_mcp/tools/capstone.py new file mode 100644 index 0000000..9b4dfcb --- /dev/null +++ b/src/guildhouse_mcp/tools/capstone.py @@ -0,0 +1,44 @@ +"""Capstone API tools — agents, tenants.""" + +import httpx +from ..config import settings + +_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None: + headers = {"Accept": "application/json"} + if settings.capstone_token: + headers["Authorization"] = f"Bearer {settings.capstone_token}" + _client = httpx.AsyncClient( + base_url=settings.capstone_url, + headers=headers, + timeout=30.0, + ) + return _client + + +async def list_agents() -> dict: + client = _get_client() + resp = await client.get("/api/v1/agents/profiles/") + resp.raise_for_status() + return resp.json() + + +async def run_agent(slug: str, input_text: str) -> dict: + client = _get_client() + resp = await client.post( + f"/api/v1/agents/profiles/{slug}/chat/", + json={"message": input_text}, + ) + resp.raise_for_status() + return resp.json() + + +async def list_tenants() -> dict: + client = _get_client() + resp = await client.get("/api/v1/tenancy/tenants/") + resp.raise_for_status() + return resp.json() diff --git a/src/guildhouse_mcp/tools/chronicle.py b/src/guildhouse_mcp/tools/chronicle.py new file mode 100644 index 0000000..863bced --- /dev/null +++ b/src/guildhouse_mcp/tools/chronicle.py @@ -0,0 +1,90 @@ +"""Chronicle query + emit tools via Capstone API.""" + +import httpx +from ..config import settings + +_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None: + headers = {"Accept": "application/json"} + if settings.capstone_token: + headers["Authorization"] = f"Bearer {settings.capstone_token}" + _client = httpx.AsyncClient( + base_url=settings.capstone_url, + headers=headers, + timeout=15.0, + ) + return _client + + +async def query_events( + source: str = "", event_type: str = "", + principal: str = "", limit: int = 20, +) -> dict: + client = _get_client() + params: dict = {} + if source: + params["source"] = source + if event_type: + params["type"] = event_type + if principal: + params["principal"] = principal + + resp = await client.get("/api/v1/chronicle/events/", params=params) + resp.raise_for_status() + return resp.json() + + +async def query_epochs(source: str = "", limit: int = 10) -> dict: + client = _get_client() + params: dict = {} + if source: + params["source"] = source + + resp = await client.get("/api/v1/chronicle/epochs/", params=params) + resp.raise_for_status() + return resp.json() + + +async def verify_epoch(epoch_id: str) -> dict: + client = _get_client() + resp = await client.get(f"/api/v1/chronicle/epochs/{epoch_id}/verify/") + resp.raise_for_status() + return resp.json() + + +async def emit_event( + event_type: str, event_code: str, + principal: str, payload: dict | None = None, +) -> dict: + client = _get_client() + resp = await client.post( + "/api/v1/chronicle/events/", + json={ + "event_type": event_type, + "event_code": event_code, + "principal": principal, + "source_id": settings.chronicle_source_id, + "source_type": "agent", + "payload": payload or {}, + }, + ) + if resp.status_code in (200, 201): + return resp.json() + return {"status": "failed", "code": resp.status_code} + + +async def emit_tool_call(tool_name: str, params: dict) -> None: + """Convenience: emit MCP_TOOL_CALL event for this agent's action.""" + try: + await emit_event( + event_type="MCP_TOOL_CALL", + event_code="0x3020", + principal=settings.agent_did, + payload={"tool": tool_name, "params": params}, + ) + except Exception: + pass # Chronicle emission is best-effort diff --git a/src/guildhouse_mcp/tools/forgejo.py b/src/guildhouse_mcp/tools/forgejo.py new file mode 100644 index 0000000..ad85002 --- /dev/null +++ b/src/guildhouse_mcp/tools/forgejo.py @@ -0,0 +1,143 @@ +"""Forgejo REST API tools.""" + +import json +import httpx +from ..config import settings + +_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient( + base_url=settings.forgejo_url, + headers={ + "Authorization": f"token {settings.forgejo_token}", + "Accept": "application/json", + }, + timeout=30.0, + ) + return _client + + +async def list_repos(org: str = "", limit: int = 20) -> dict: + client = _get_client() + if org: + resp = await client.get(f"/api/v1/orgs/{org}/repos", params={"limit": limit}) + else: + resp = await client.get("/api/v1/repos/search", params={"limit": limit}) + resp.raise_for_status() + data = resp.json() + repos = data if isinstance(data, list) else data.get("data", []) + return { + "repos": [ + { + "full_name": r["full_name"], + "description": r.get("description", ""), + "default_branch": r.get("default_branch", "main"), + "updated_at": r.get("updated_at", ""), + "stars": r.get("stars_count", 0), + "open_issues": r.get("open_issues_count", 0), + } + for r in repos + ], + "count": len(repos), + } + + +async def read_file(repo: str, path: str, ref: str = "main") -> str: + client = _get_client() + resp = await client.get(f"/api/v1/repos/{repo}/raw/{path}", params={"ref": ref}) + resp.raise_for_status() + return resp.text + + +async def create_branch(repo: str, branch: str, from_ref: str = "main") -> dict: + client = _get_client() + resp = await client.post( + f"/api/v1/repos/{repo}/branches", + json={"new_branch_name": branch, "old_branch_name": from_ref}, + ) + resp.raise_for_status() + return {"branch": branch, "repo": repo, "status": "created"} + + +async def create_pr( + repo: str, title: str, body: str, + head: str, base: str = "main", + labels: list[str] | None = None, + metadata: dict | None = None, +) -> dict: + client = _get_client() + + full_body = body + if metadata: + full_body += f"\n\n" + + resp = await client.post( + f"/api/v1/repos/{repo}/pulls", + json={ + "title": title, + "body": full_body, + "head": head, + "base": base, + }, + ) + resp.raise_for_status() + pr = resp.json() + return { + "number": pr["number"], + "url": pr.get("html_url", ""), + "state": pr["state"], + } + + +async def get_pr(repo: str, pr_number: int) -> dict: + client = _get_client() + resp = await client.get(f"/api/v1/repos/{repo}/pulls/{pr_number}") + resp.raise_for_status() + pr = resp.json() + + ci = await ci_status(repo, ref=pr.get("head", {}).get("sha", "")) + + return { + "number": pr["number"], + "title": pr["title"], + "state": pr["state"], + "mergeable": pr.get("mergeable"), + "body": pr.get("body", ""), + "ci": ci, + "created_at": pr.get("created_at"), + "updated_at": pr.get("updated_at"), + } + + +async def ci_status(repo: str, ref: str = "main") -> dict: + client = _get_client() + try: + resp = await client.get(f"/api/v1/repos/{repo}/commits/{ref}/status") + if resp.status_code == 200: + data = resp.json() + return { + "state": data.get("state", "unknown"), + "total": data.get("total_count", 0), + "statuses": [ + {"context": s.get("context"), "state": s.get("status")} + for s in data.get("statuses", []) + ], + } + except Exception: + pass + return {"state": "unknown", "total": 0, "statuses": []} + + +async def merge_pr(repo: str, pr_number: int, method: str = "merge") -> dict: + """Merge a PR. Only called by the confidence gate on auto-merge.""" + client = _get_client() + resp = await client.post( + f"/api/v1/repos/{repo}/pulls/{pr_number}/merge", + json={"Do": method, "merge_message_field": ""}, + ) + resp.raise_for_status() + return {"merged": True, "pr_number": pr_number} diff --git a/src/guildhouse_mcp/tools/gsap_proxy.py b/src/guildhouse_mcp/tools/gsap_proxy.py new file mode 100644 index 0000000..574c716 --- /dev/null +++ b/src/guildhouse_mcp/tools/gsap_proxy.py @@ -0,0 +1,79 @@ +"""Proxy to existing GSAP MCP broker tools.""" + +import httpx +from ..config import settings + +_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient( + base_url=settings.gsap_url, + headers={"Accept": "application/json"}, + timeout=15.0, + ) + return _client + + +async def _call_gsap_tool(tool_name: str, arguments: dict) -> dict: + """Call a GSAP MCP tool via JSON-RPC 2.0.""" + client = _get_client() + resp = await client.post( + "/governance/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + }, + }, + ) + resp.raise_for_status() + result = resp.json() + if "error" in result: + return {"error": result["error"]} + content = result.get("result", {}).get("content", []) + if content and content[0].get("type") == "text": + import json + try: + return json.loads(content[0]["text"]) + except (json.JSONDecodeError, KeyError): + return {"text": content[0].get("text", "")} + return result.get("result", {}) + + +async def request_ac(scope: str, duration: int = 60) -> dict: + return await _call_gsap_tool("request_ac", { + "principal": settings.agent_did, + "accord_template": "dev-operations", + "playbook": scope, + "session_mode": True, + }) + + +async def check_operation(operation: str, target: str = "") -> dict: + return await _call_gsap_tool("check_operation", { + "operation_type": operation, + "target_layer": target or "applications", + }) + + +async def get_posture() -> dict: + return await _call_gsap_tool("get_posture", {}) + + +async def request_delegation( + agent_name: str, scope: str, + capabilities: list[str] | None = None, + max_ttl_minutes: int = 120, +) -> dict: + return await _call_gsap_tool("request_delegation", { + "delegator_ac_id": "", + "agent_type": agent_name, + "capability_ceiling": ",".join(capabilities or ["code:read"]), + "max_ttl_minutes": max_ttl_minutes, + }) diff --git a/src/guildhouse_mcp/tools/tasks.py b/src/guildhouse_mcp/tools/tasks.py new file mode 100644 index 0000000..a886735 --- /dev/null +++ b/src/guildhouse_mcp/tools/tasks.py @@ -0,0 +1,178 @@ +"""Task definition management — stored as TOML files.""" + +import json +import tomllib +from pathlib import Path +from ..config import settings +from ..agents.confidence import evaluate_gate, GateDecision + +TASKS_DIR = Path(__file__).parent.parent.parent.parent / "tasks" + + +async def list_tasks() -> dict: + tasks = [] + if TASKS_DIR.exists(): + for f in sorted(TASKS_DIR.glob("**/*.toml")): + with open(f, "rb") as fh: + data = tomllib.load(fh) + task = data.get("task", {}) + tasks.append({ + "id": task.get("id", f.stem), + "repo": task.get("repo", ""), + "description": task.get("description", ""), + "phases": len(data.get("phases", {})), + "file": str(f.relative_to(TASKS_DIR)), + }) + return {"tasks": tasks, "count": len(tasks)} + + +async def get_task(task_id: str) -> dict: + if not TASKS_DIR.exists(): + return {"error": f"Task '{task_id}' not found"} + for f in TASKS_DIR.glob("**/*.toml"): + with open(f, "rb") as fh: + data = tomllib.load(fh) + if data.get("task", {}).get("id") == task_id: + return data + return {"error": f"Task '{task_id}' not found"} + + +async def create_task( + task_id: str, repo: str, + description: str, phases: list[dict], +) -> dict: + TASKS_DIR.mkdir(parents=True, exist_ok=True) + task_file = TASKS_DIR / f"{task_id}.toml" + + lines = [ + '[task]', + f'id = "{task_id}"', + f'repo = "{repo}"', + f'description = "{description}"', + '', + ] + + for i, phase in enumerate(phases, 1): + lines.append(f'[phases.{i}]') + for key, value in phase.items(): + if isinstance(value, str): + lines.append(f'{key} = "{value}"') + elif isinstance(value, (int, float)): + lines.append(f'{key} = {value}') + lines.append('') + + task_file.write_text('\n'.join(lines)) + return {"task_id": task_id, "file": str(task_file), "phases": len(phases)} + + +async def update_status( + task_id: str, phase: int, + status: str, confidence: int = 0, notes: str = "", +) -> dict: + TASKS_DIR.mkdir(parents=True, exist_ok=True) + status_file = TASKS_DIR / f".{task_id}.status.json" + + if status_file.exists(): + current = json.loads(status_file.read_text()) + else: + current = {"task_id": task_id, "phases": {}} + + current["phases"][str(phase)] = { + "status": status, + "confidence": confidence, + "notes": notes, + } + + status_file.write_text(json.dumps(current, indent=2)) + return current + + +async def submit_for_review( + task_id: str, phase: int, + pr_number: int, worker_confidence: int, notes: str = "", +) -> dict: + return await update_status( + task_id=task_id, phase=phase, + status="awaiting_review", + confidence=worker_confidence, + notes=f"PR #{pr_number}: {notes}", + ) + + +async def submit_review( + task_id: str, phase: int, + pr_number: int, reviewer_confidence: int, + findings: list | None = None, recommendation: str = "approve", +) -> dict: + from . import chronicle, forgejo + + # Get worker confidence from status + status_file = TASKS_DIR / f".{task_id}.status.json" + current = json.loads(status_file.read_text()) if status_file.exists() else {} + phase_data = current.get("phases", {}).get(str(phase), {}) + worker_confidence = phase_data.get("confidence", 50) + + gate = evaluate_gate( + worker_confidence=worker_confidence, + reviewer_confidence=reviewer_confidence, + reviewer_recommendation=recommendation, + ) + + result = { + "gate_decision": gate.decision.value, + "combined_confidence": gate.combined_confidence, + "worker_confidence": gate.worker_confidence, + "reviewer_confidence": gate.reviewer_confidence, + "reason": gate.reason, + "auto_approved": gate.auto_approved, + "findings": findings or [], + } + + if gate.decision in (GateDecision.AUTO_MERGE, GateDecision.FLAG_MERGE): + task = await get_task(task_id) + repo = task.get("task", {}).get("repo", "") + if repo: + merge_result = await forgejo.merge_pr(repo, pr_number) + result["merged"] = True + result["merge_result"] = merge_result + + event_type = "PR_AUTO_MERGED" if gate.decision == GateDecision.AUTO_MERGE else "PR_FLAG_MERGED" + event_code = "0x4010" if gate.decision == GateDecision.AUTO_MERGE else "0x4011" + if gate.decision == GateDecision.FLAG_MERGE: + result["flagged_for_review"] = True + + await chronicle.emit_event( + event_type=event_type, + event_code=event_code, + principal=settings.agent_did, + payload=result, + ) + + elif gate.decision == GateDecision.PROPOSE: + result["proposal_created"] = True + result["message"] = "Below auto-merge threshold. Proposal created for Tyler." + await chronicle.emit_event( + event_type="PR_PROPOSAL_CREATED", + event_code="0x4012", + principal=settings.agent_did, + payload=result, + ) + + elif gate.decision == GateDecision.REJECT: + result["rejected"] = True + result["message"] = "Confidence too low. Work needs revision." + await chronicle.emit_event( + event_type="PR_REJECTED", + event_code="0x4013", + principal=settings.agent_did, + payload=result, + ) + + await update_status( + task_id=task_id, phase=phase, + status=gate.decision.value, + confidence=int(gate.combined_confidence), + notes=gate.reason, + ) + + return result diff --git a/tasks/examples/bascule-tui.toml b/tasks/examples/bascule-tui.toml new file mode 100644 index 0000000..9b7c265 --- /dev/null +++ b/tasks/examples/bascule-tui.toml @@ -0,0 +1,20 @@ +[task] +id = "bascule-dashboard-tui" +repo = "tking/bascule-oss" +description = "Build TUI dashboard using dioxus-ratatui bridge" +branch = "feat/dashboard-tui" +delegation_scope = "code:write,test:run" +chronicle_source = "did:web:guildhouse.dev/agent/worker-a" + +[phases.1] +description = "Implement TUI components using dioxus-ratatui" +prompt = "Build TUI versions of SessionTable, AuthStats, and StatusBar using the dioxus-ratatui bridge crate" +success_criteria = "cargo build -p bascule-dashboard-tui compiles" +estimated_confidence = 75 + +[phases.2] +description = "Wire to management API" +prompt = "Connect TUI components to bascule-server management API on port 9090" +depends_on = 1 +success_criteria = "TUI shows live session data from running bascule-server" +estimated_confidence = 70 diff --git a/tasks/examples/capstone-cleanup.toml b/tasks/examples/capstone-cleanup.toml new file mode 100644 index 0000000..9248b4a --- /dev/null +++ b/tasks/examples/capstone-cleanup.toml @@ -0,0 +1,20 @@ +[task] +id = "capstone-test-cleanup" +repo = "tking/capstone" +description = "Fix pre-existing Keycloak JWT auth test failures" +branch = "fix/auth-tests" +delegation_scope = "code:write,test:run" +chronicle_source = "did:web:guildhouse.dev/agent/worker-b" + +[phases.1] +description = "Diagnose failing auth tests" +prompt = "Run pytest on apps/accounts/tests/ and identify the 4 pre-existing Keycloak JWT failures. Document root cause." +success_criteria = "Root cause documented for each failure" +estimated_confidence = 80 + +[phases.2] +description = "Fix test fixtures" +prompt = "Update test fixtures to match current Keycloak JWT format. Do not change production code." +depends_on = 1 +success_criteria = "All 4 previously-failing tests pass, no regressions" +estimated_confidence = 70