Files
rune/shared/custodian.py
Nick 6378c22fb5
Auto-label / auto-label (pull_request) Successful in 3s
CI Pipeline / lint (pull_request) Successful in 21s
CI Pipeline / canary (pull_request) Successful in 3s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / required-checks (pull_request) Successful in 1s
feat(gitea): complete governance and recovery tiers
2026-08-24 14:37:58 +01:00

751 lines
29 KiB
Python

#!/usr/bin/env python3
"""custodian.py — The Custodian agent for Gitea-based self-improvement.
The Custodian lives in Gitea, manages issues, executes tasks, and
creates PRs. It uses local models for remedial work and reports
back to Rune (TUI) for judgment calls.
Priority queue:
1. Rune's growth delta (primary) — memory, governance, cognition, autonomy
2. Nick's life improvement (secondary) — finances, organization, revolutionary tools
3. Infrastructure (tertiary) — Gitea, pipeline, automation
Everything is reversible. Nick reviews daily.
Usage:
custodian.py status # Check Custodian status
custodian.py create-issue <title> # Create a Gitea issue
custodian.py list-issues # List open issues
custodian.py work <issue-number> # Work on an issue
custodian.py report # Generate daily report
"""
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from typing import Dict, List, Optional
from gitea_auth import load_gitea_token
SCRIPTS_DIR = os.path.expanduser("~/.config/opencode/scripts")
SHARED_DIR = os.path.expanduser("~/.config/opencode/shared")
CUSTODIAN_STATE_FILE = os.path.expanduser("~/.config/opencode/memory/custodian_state.json")
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.thingscouldgetdicey.online")
GITEA_TOKEN = load_gitea_token()
REPO = "conductor/rune"
TUI_DIR = "/mnt/workdrive/rune"
sys.path.insert(0, SCRIPTS_DIR)
sys.path.insert(0, SHARED_DIR)
# Umbrella tags for issue categorization
UMBRELLA_TAGS = {
"memory": "Memory systems — store, recall, consolidation, shards, tiers",
"governance": "Quality control — drift, promotion, audit, curator",
"identity": "Persona, voice, constitution, anchors",
"growth": "Learning — deltas, lessons, skills, self-improvement",
"perception": "Input processing — routing, capability broker, witness",
"infrastructure": "Plumbing — microkernel, hooks, events, config",
"life": "Nick's life — finances, organization, revolutionary tools",
"custodian": "Custodian self-improvement",
}
class Custodian:
"""The Custodian agent for Gitea-based self-improvement."""
def __init__(self):
self.state = self._load_state()
def _load_state(self) -> dict:
"""Load Custodian state from disk."""
if os.path.exists(CUSTODIAN_STATE_FILE):
try:
with open(CUSTODIAN_STATE_FILE) as f:
return json.load(f)
except json.JSONDecodeError:
return self._default_state()
return self._default_state()
def _default_state(self) -> dict:
return {
"last_activity": None,
"issues_created": 0,
"prs_created": 0,
"issues_completed": 0,
"active_issues": [],
}
def _save_state(self):
"""Save Custodian state to disk."""
os.makedirs(os.path.dirname(CUSTODIAN_STATE_FILE), exist_ok=True)
with open(CUSTODIAN_STATE_FILE, "w") as f:
json.dump(self.state, f, indent=2)
def _gitea_api(self, endpoint: str, method: str = "GET", data: dict = None) -> dict:
"""Make a Gitea API call."""
import urllib.request
url = f"{GITEA_URL}/api/v1/repos/{REPO}/{endpoint}"
headers = {
"Authorization": f"token {GITEA_TOKEN}",
"Content-Type": "application/json",
}
req = urllib.request.Request(url, headers=headers, method=method)
if data:
req.data = json.dumps(data).encode()
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except Exception as e:
return {"error": str(e)[:200]}
def create_issue(self, title: str, body: str, labels: List[str] = None) -> dict:
"""Create a Gitea issue with plain-language description.
Labels should be umbrella names (memory, governance, etc).
They get converted to IDs automatically.
"""
# Convert label names to IDs
label_ids = []
if labels:
all_labels = self._gitea_api("labels")
if isinstance(all_labels, list):
label_map = {l["name"]: l["id"] for l in all_labels}
label_ids = [label_map[l] for l in labels if l in label_map]
data = {
"title": title,
"body": body,
"labels": label_ids,
}
result = self._gitea_api("issues", method="POST", data=data)
if "error" not in result:
self.state["issues_created"] += 1
self.state["active_issues"].append(result.get("number"))
self._save_state()
return result
def list_issues(self, state: str = "open") -> List[dict]:
"""List issues in the repo."""
result = self._gitea_api(f"issues?state={state}")
if isinstance(result, list):
return result
return result.get("data", [])
def create_issue_comment(self, issue_number: int, body: str) -> dict:
"""Add a comment to an issue."""
return self._gitea_api(f"issues/{issue_number}/comments", method="POST", data={"body": body})
def get_issue_comments(self, issue_number: int) -> List[dict]:
"""Get comments on an issue."""
result = self._gitea_api(f"issues/{issue_number}/comments")
if isinstance(result, list):
return result
return result.get("data", [])
def create_pr(self, title: str, body: str, head: str, base: str = "master") -> dict:
"""Create a pull request."""
data = {
"title": title,
"body": body,
"head": head,
"base": base,
}
result = self._gitea_api("pulls", method="POST", data=data)
if "error" not in result:
self.state["prs_created"] += 1
self._save_state()
return result
def generate_daily_report(self) -> dict:
"""Generate a daily report of Custodian activity."""
issues = self.list_issues("open")
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"open_issues": len(issues),
"issues_created_today": self.state["issues_created"],
"prs_created_today": self.state["prs_created"],
"issues_completed_today": self.state["issues_completed"],
"issues": [],
}
for issue in issues[:10]:
report["issues"].append({
"number": issue.get("number"),
"title": issue.get("title"),
"labels": [l.get("name") for l in issue.get("labels", [])],
"state": issue.get("state"),
})
return report
def get_status(self) -> dict:
"""Get Custodian status."""
return {
"last_activity": self.state.get("last_activity"),
"issues_created": self.state.get("issues_created", 0),
"prs_created": self.state.get("prs_created", 0),
"issues_completed": self.state.get("issues_completed", 0),
"active_issues": len(self.state.get("active_issues", [])),
}
def work_on_issue(self, issue_number: int) -> dict:
"""Work on an issue by spawning a worktree and using local models.
Steps:
1. Get issue details from Gitea
2. Create a worktree for the work
3. Analyze the issue and propose a solution
4. Implement the solution
5. Run canaries to verify
6. Create a PR with the changes
Returns dict with work results.
"""
import subprocess
result = {
"issue_number": issue_number,
"status": "started",
"steps": [],
}
# Step 1: Get issue details
issue = self._gitea_api(f"issues/{issue_number}")
if "error" in issue:
result["status"] = "failed"
result["error"] = issue["error"]
return result
title = issue.get("title", "")
body = issue.get("body", "")
labels = [l.get("name", "") for l in issue.get("labels", [])]
result["steps"].append({"step": "get_issue", "title": title, "labels": labels})
# Step 2: Create worktree
worktree_branch = f"custodian/issue-{issue_number}"
try:
proc = subprocess.run(
["git", "-C", TUI_DIR, "worktree", "add", "-b", worktree_branch, f"/tmp/rune-worktree-{issue_number}"],
capture_output=True, text=True, timeout=30
)
if proc.returncode == 0:
result["steps"].append({"step": "create_worktree", "branch": worktree_branch, "status": "ok"})
else:
# Branch might already exist
proc2 = subprocess.run(
["git", "-C", TUI_DIR, "worktree", "add", f"/tmp/rune-worktree-{issue_number}", worktree_branch],
capture_output=True, text=True, timeout=30
)
if proc2.returncode == 0:
result["steps"].append({"step": "create_worktree", "branch": worktree_branch, "status": "ok"})
else:
result["steps"].append({"step": "create_worktree", "status": "failed", "error": proc2.stderr[:200]})
except Exception as e:
result["steps"].append({"step": "create_worktree", "status": "failed", "error": str(e)[:200]})
# Step 3: Analyze issue (use local model)
analysis = self._analyze_issue(title, body, labels)
result["steps"].append({"step": "analyze", "analysis": analysis})
# Step 4: Implement changes in worktree
worktree_path = f"/tmp/rune-worktree-{issue_number}"
impl_result = self._implement_in_worktree(worktree_path, title, body, labels, analysis)
result["steps"].append({"step": "implement", **impl_result})
# Step 5: Run canaries
try:
canary_result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "canary_full_system.py")],
capture_output=True, text=True, timeout=120
)
canary_passed = canary_result.returncode == 0
result["steps"].append({"step": "canary", "passed": canary_passed})
except Exception as e:
result["steps"].append({"step": "canary", "status": "error", "error": str(e)[:200]})
# Step 6: Create PR
if impl_result.get("status") == "ok" and canary_passed:
pr_result = self._create_pr_from_worktree(issue_number, title, worktree_branch)
result["steps"].append({"step": "create_pr", **pr_result})
else:
result["steps"].append({"step": "create_pr", "status": "skipped", "reason": "implementation or canary failed"})
# Step 7: Generate documentation
is_feature = "bug" not in " ".join(labels).lower() and "fix" not in title.lower()
doc_result = self.generate_documentation(
issue_number, title, labels,
impl_result.get("changes", []),
is_feature=is_feature
)
result["steps"].append({"step": "documentation", "docs": len(doc_result.get("docs", []))})
# Step 8: Cleanup worktree
try:
subprocess.run(
["git", "-C", TUI_DIR, "worktree", "remove", worktree_path, "--force"],
capture_output=True, timeout=30
)
result["steps"].append({"step": "cleanup_worktree", "status": "ok"})
except Exception:
result["steps"].append({"step": "cleanup_worktree", "status": "skipped"})
result["status"] = "complete" if impl_result.get("status") == "ok" else "partial"
return result
def _analyze_issue(self, title: str, body: str, labels: list) -> dict:
"""Analyze an issue and propose a solution using local model."""
# Use local model to analyze the issue
try:
import urllib.request
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
prompt = f"""Analyze this issue and propose a concrete solution:
Title: {title}
Body: {body[:500]}
Labels: {', '.join(labels)}
What needs to be done? What files need to change? What's the implementation plan?"""
data = json.dumps({
"model": "qwen2.5:7b",
"prompt": prompt,
"stream": False,
}).encode()
req = urllib.request.Request(
f"{ollama_url}/api/generate",
data=data,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read())
return {
"model": "qwen2.5:7b",
"analysis": result.get("response", "")[:500],
"status": "ok",
}
except Exception as e:
return {"status": "error", "error": str(e)[:200]}
def _implement_in_worktree(self, worktree_path: str, title: str, body: str,
labels: list, analysis: dict) -> dict:
"""Implement changes in a worktree using local model.
Steps:
1. Use local model to generate implementation plan
2. Apply changes to files in the worktree
3. Commit changes
"""
result = {"status": "ok", "changes": []}
# Generate implementation plan using local model
plan = self._generate_implementation_plan(title, body, labels, analysis)
if plan.get("status") != "ok":
result["status"] = "failed"
result["error"] = plan.get("error", "plan generation failed")
return result
result["plan"] = plan.get("plan", "")[:200]
# Apply changes to files in the worktree
changes = plan.get("changes", [])
for change in changes:
file_path = change.get("file", "")
action = change.get("action", "modify")
content = change.get("content", "")
if not file_path or not content:
continue
full_path = os.path.join(worktree_path, file_path)
try:
if action == "create":
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
result["changes"].append({"file": file_path, "action": "created"})
elif action == "modify":
if os.path.exists(full_path):
with open(full_path, "r") as f:
existing = f.read()
# Apply the change (simple replacement for now)
with open(full_path, "w") as f:
f.write(content)
result["changes"].append({"file": file_path, "action": "modified"})
else:
result["changes"].append({"file": file_path, "action": "skipped", "reason": "file not found"})
except Exception as e:
result["changes"].append({"file": file_path, "action": "error", "error": str(e)[:100]})
# Commit changes
if result["changes"]:
try:
subprocess.run(
["git", "-C", worktree_path, "add", "-A"],
capture_output=True, timeout=30
)
subprocess.run(
["git", "-C", worktree_path, "commit", "-m", f"custodian: {title}"],
capture_output=True, timeout=30
)
result["committed"] = True
except Exception as e:
result["committed"] = False
result["commit_error"] = str(e)[:100]
return result
def _generate_implementation_plan(self, title: str, body: str, labels: list,
analysis: dict) -> dict:
"""Generate an implementation plan using local model."""
try:
import urllib.request
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
prompt = f"""You are implementing a code change for this issue:
Title: {title}
Description: {body[:300]}
Labels: {', '.join(labels)}
Analysis: {analysis.get('analysis', '')[:200]}
Generate a JSON implementation plan with:
- plan: brief description of what to do
- changes: list of {{file, action, content}} objects
Output ONLY JSON:
{{"plan":"...", "changes":[{{"file":"path/to/file.py", "action":"create|modify", "content":"..."}}]}}"""
data = json.dumps({
"model": "qwen2.5:7b",
"prompt": prompt,
"stream": False,
}).encode()
req = urllib.request.Request(
f"{ollama_url}/api/generate",
data=data,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
result = json.loads(resp.read())
response = result.get("response", "")
# Try to parse JSON from response
try:
plan = json.loads(response)
return {"status": "ok", "plan": plan.get("plan", ""), "changes": plan.get("changes", [])}
except json.JSONDecodeError:
return {"status": "ok", "plan": response[:500], "changes": []}
except Exception as e:
return {"status": "error", "error": str(e)[:200]}
def _create_pr_from_worktree(self, issue_number: int, title: str, branch: str) -> dict:
"""Create a PR from the worktree branch."""
try:
# Push the branch (force if needed to update existing)
proc = subprocess.run(
["git", "-C", TUI_DIR, "push", "gitea", branch, "--force"],
capture_output=True, text=True, timeout=60
)
if proc.returncode != 0:
return {"status": "failed", "error": f"push failed: {proc.stderr[:200]}"}
# Create PR via API
pr_data = {
"title": f"[Custodian] {title}",
"body": f"Automated PR from Custodian for issue #{issue_number}.\n\nThis PR was generated by the Custodian agent working on Gitea issue #{issue_number}.",
"head": branch,
"base": "master",
}
result = self._gitea_api("pulls", method="POST", data=pr_data)
if "error" not in result:
return {
"status": "ok",
"pr_number": result.get("number"),
"pr_url": result.get("html_url"),
}
else:
# PR might already exist — try to find it
existing = self._gitea_api(f"pulls?head={branch}&state=open")
if isinstance(existing, list) and existing:
return {
"status": "ok",
"pr_number": existing[0].get("number"),
"pr_url": existing[0].get("html_url"),
"note": "PR already existed",
}
return {"status": "failed", "error": result.get("error", "unknown")}
except Exception as e:
return {"status": "failed", "error": str(e)[:200]}
def generate_documentation(self, issue_number: int, title: str, labels: list,
changes: list, is_feature: bool = True) -> dict:
"""Generate extensive documentation for a completed feature.
For features: wiki page, function docs, annotations, linked references.
For bug fixes: minimal docs — what was broken, what was fixed.
Returns dict with generated documentation.
"""
result = {"status": "ok", "docs": []}
if not is_feature:
# Bug fix — minimal docs
result["docs"].append({
"type": "fix_note",
"content": f"Fix: {title}\n\nChanges: {', '.join(c.get('file', '') for c in changes[:5])}",
})
return result
# Feature — extensive documentation
docs_dir = os.path.join(TUI_DIR, "docs", "wiki")
os.makedirs(docs_dir, exist_ok=True)
# 1. Generate wiki page
wiki_content = self._generate_wiki_page(title, labels, changes)
wiki_path = os.path.join(docs_dir, f"issue-{issue_number}.md")
try:
with open(wiki_path, "w") as f:
f.write(wiki_content)
result["docs"].append({"type": "wiki", "path": wiki_path})
except Exception as e:
result["docs"].append({"type": "wiki", "status": "failed", "error": str(e)[:100]})
# 2. Generate function documentation for each changed file
for change in changes:
file_path = change.get("file", "")
if not file_path.endswith(".py"):
continue
func_docs = self._generate_function_docs(file_path, title)
if func_docs:
doc_path = os.path.join(docs_dir, f"{os.path.basename(file_path).replace('.py', '')}-docs.md")
try:
with open(doc_path, "w") as f:
f.write(func_docs)
result["docs"].append({"type": "function_docs", "path": doc_path})
except Exception as e:
result["docs"].append({"type": "function_docs", "status": "failed", "error": str(e)[:100]})
# 3. Generate index page linking all docs
index_content = self._generate_doc_index(issue_number, title, result["docs"])
index_path = os.path.join(docs_dir, f"issue-{issue_number}-index.md")
try:
with open(index_path, "w") as f:
f.write(index_content)
result["docs"].append({"type": "index", "path": index_path})
except Exception as e:
result["docs"].append({"type": "index", "status": "failed", "error": str(e)[:100]})
return result
def _generate_wiki_page(self, title: str, labels: list, changes: list) -> str:
"""Generate a wiki page for a feature."""
files_changed = [c.get("file", "") for c in changes if c.get("file")]
return f"""# {title}
## Overview
This feature was implemented by the Custodian agent as part of the governed adaptation system.
## Labels
{', '.join(labels)}
## Files Changed
{chr(10).join(f'- `{f}`' for f in files_changed)}
## What This Does
[Description of the feature and its purpose]
## How It Works
[Technical explanation of the implementation]
## Usage
[How to use this feature]
## Related Issues
[Links to related Gitea issues]
## Documentation
[Links to function docs, wiki pages, and annotations]
---
_Auto-generated by Custodian on {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_
"""
def _generate_function_docs(self, file_path: str, context: str) -> str:
"""Generate function documentation for a Python file."""
full_path = os.path.join(TUI_DIR, "shared", os.path.basename(file_path))
if not os.path.exists(full_path):
full_path = os.path.join(TUI_DIR, "src", "rune", os.path.basename(file_path))
if not os.path.exists(full_path):
return ""
try:
with open(full_path) as f:
content = f.read()
# Extract function definitions
import re
functions = re.findall(r'def (\w+)\((.*?)\)(?:\s*->.*?)?:(?:\s*"""(.*?)""")?', content, re.DOTALL)
if not functions:
return ""
doc = f"# Function Documentation: {os.path.basename(file_path)}\n\n"
doc += f"Context: {context}\n\n"
for name, args, docstring in functions:
if name.startswith("_"):
continue # Skip private functions
doc += f"## `{name}({args[:80]})`\n\n"
if docstring:
doc += f"{docstring[:300]}\n\n"
else:
doc += "_No documentation._\n\n"
return doc
except Exception:
return ""
def _generate_doc_index(self, issue_number: int, title: str, docs: list) -> str:
"""Generate an index page linking all documentation."""
index = f"# Documentation Index: {title}\n\n"
index += f"Issue: #{issue_number}\n"
index += f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}\n\n"
index += "## Documents\n\n"
for doc in docs:
doc_type = doc.get("type", "unknown")
doc_path = doc.get("path", "")
if doc_path:
index += f"- [{doc_type}]({os.path.basename(doc_path)})\n"
else:
index += f"- {doc_type}: {doc.get('status', 'ok')}\n"
return index
def format_issue_plain(title: str, what: str, why: str, how: str,
umbrella: str, priority: str) -> str:
"""Format an issue in plain language for Nick."""
return f"""## What this does
{what}
## Why it matters
{why}
## How it works
{how}
## Related to
{UMBRELLA_TAGS.get(umbrella, umbrella)}
## Priority
{priority}
---
_Technical details in collapsible section below._
<details>
<summary>Technical details</summary>
_Title: {title}
_Umbrella: {umbrella}
_Priority: {priority}
</details>
"""
# ═══════════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════════
def main():
import argparse
parser = argparse.ArgumentParser(description="Custodian agent for Gitea")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("status", help="Check Custodian status")
p_create = sub.add_parser("create-issue", help="Create a Gitea issue")
p_create.add_argument("title")
p_create.add_argument("--body", default="")
p_create.add_argument("--labels", nargs="*", default=[])
sub.add_parser("list-issues", help="List open issues")
p_work = sub.add_parser("work", help="Work on an issue")
p_work.add_argument("issue_number", type=int)
sub.add_parser("report", help="Generate daily report")
args = parser.parse_args()
if not args.cmd:
parser.print_help()
sys.exit(1)
custodian = Custodian()
if args.cmd == "status":
status = custodian.get_status()
print("=== Custodian Status ===")
print(f" Last activity: {status['last_activity'] or 'never'}")
print(f" Issues created: {status['issues_created']}")
print(f" PRs created: {status['prs_created']}")
print(f" Issues completed: {status['issues_completed']}")
print(f" Active issues: {status['active_issues']}")
elif args.cmd == "create-issue":
result = custodian.create_issue(args.title, args.body, args.labels)
if "error" not in result:
print(f"Created issue #{result.get('number')}: {result.get('html_url')}")
else:
print(f"Error: {result['error']}")
elif args.cmd == "list-issues":
issues = custodian.list_issues()
print(f"Open issues ({len(issues)}):")
for issue in issues:
labels = [l.get("name") for l in issue.get("labels", [])]
print(f" #{issue['number']}: {issue['title']} [{', '.join(labels)}]")
elif args.cmd == "report":
report = custodian.generate_daily_report()
print("=== Daily Custodian Report ===")
print(f" Open issues: {report['open_issues']}")
print(f" Created today: {report['issues_created_today']}")
print(f" PRs today: {report['prs_created_today']}")
print(f" Completed today: {report['issues_completed_today']}")
if report["issues"]:
print(f"\n Issues:")
for issue in report["issues"]:
print(f" #{issue['number']}: {issue['title']} [{', '.join(issue['labels'])}]")
elif args.cmd == "work":
result = custodian.work_on_issue(args.issue_number)
print(f"=== Working on Issue #{result['issue_number']} ===")
print(f"Status: {result['status']}")
for step in result.get("steps", []):
step_name = step.get("step", "unknown")
step_status = step.get("status", "ok")
if step_status == "ok":
print(f" ✓ {step_name}")
elif step_status == "failed":
print(f" ✗ {step_name}: {step.get('error', 'unknown')}")
elif step_status == "not_implemented":
print(f" ⊘ {step_name}: {step.get('reason', 'not implemented')}")
else:
print(f" ? {step_name}: {step_status}")
# TODO: Execute work in worktree using local model
print(" (Work execution not yet implemented — needs worktree + local model integration)")
if __name__ == "__main__":
main()