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) <noreply@anthropic.com>
This commit is contained in:
Tyler King 2026-04-06 03:24:01 -04:00
commit dffa821c38
18 changed files with 1076 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.eggs/
*.egg
.pytest_cache/
.status.json

34
CLAUDE.md Normal file
View file

@ -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

25
pyproject.toml Normal file
View file

@ -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"]

View file

View file

@ -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()

View file

View file

@ -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,
)

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

View file

@ -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()

View file

@ -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

View file

@ -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<!-- agent-metadata: {json.dumps(metadata)} -->"
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}

View file

@ -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,
})

View file

@ -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

View file

@ -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

View file

@ -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