conclave-team 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- conclave/__init__.py +3 -0
- conclave/__main__.py +3 -0
- conclave/cli.py +535 -0
- conclave/config.py +201 -0
- conclave/devils.py +65 -0
- conclave/loop.py +169 -0
- conclave/notify.py +42 -0
- conclave/providers.py +84 -0
- conclave/runners.py +98 -0
- conclave/templates/CONCLAVE.md +71 -0
- conclave/templates/COORD.md +17 -0
- conclave/templates/DEAN.md +74 -0
- conclave/templates/__init__.py +0 -0
- conclave/templates/devil-system.md +14 -0
- conclave/templates/lane.md +22 -0
- conclave/workspace.py +207 -0
- conclave_team-0.1.0.dist-info/METADATA +258 -0
- conclave_team-0.1.0.dist-info/RECORD +21 -0
- conclave_team-0.1.0.dist-info/WHEEL +4 -0
- conclave_team-0.1.0.dist-info/entry_points.txt +2 -0
- conclave_team-0.1.0.dist-info/licenses/LICENSE +21 -0
conclave/__init__.py
ADDED
conclave/__main__.py
ADDED
conclave/cli.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""conclave: run a managed agent team from the terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from conclave import __version__, devils, loop, notify, runners
|
|
15
|
+
from conclave.config import CONFIG_NAME, ConfigError, Config, find_root, load
|
|
16
|
+
from conclave.providers import ProviderError, chat
|
|
17
|
+
from conclave.workspace import (board_append, board_section, git, has_remote, is_git_repo, scaffold, unpushed,
|
|
18
|
+
utcnow, worktree)
|
|
19
|
+
|
|
20
|
+
C = {"b": "\033[1m", "d": "\033[2m", "r": "\033[31m", "g": "\033[32m", "y": "\033[33m", "x": "\033[0m"}
|
|
21
|
+
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
|
|
22
|
+
C = {k: "" for k in C}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def say(msg: str = "") -> None:
|
|
26
|
+
for k, v in C.items(): # color tokens only; never str.format() text that may hold JSON braces
|
|
27
|
+
msg = msg.replace("{" + k + "}", v)
|
|
28
|
+
print(msg)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------- init
|
|
32
|
+
|
|
33
|
+
KNOWN_KEYS = [ # env var -> (provider name, kind, base_url, a sensible Devil model)
|
|
34
|
+
("OPENROUTER_API_KEY", "openrouter", "openai", "https://openrouter.ai/api/v1", "deepseek/deepseek-r1"),
|
|
35
|
+
("ANTHROPIC_API_KEY", "anthropic", "anthropic", None, "claude-opus-5-5"),
|
|
36
|
+
("OPENAI_API_KEY", "openai", "openai", os.environ.get("OPENAI_BASE_URL"), os.environ.get("OPENAI_MODEL", "gpt-5")),
|
|
37
|
+
("GEMINI_API_KEY", "gemini", "openai", "https://generativelanguage.googleapis.com/v1beta/openai", "gemini-3.1-pro-preview"),
|
|
38
|
+
("DEEPSEEK_API_KEY", "deepseek", "openai", "https://api.deepseek.com", "deepseek-reasoner"),
|
|
39
|
+
("GROQ_API_KEY", "groq", "openai", "https://api.groq.com/openai/v1", "moonshotai/kimi-k2-instruct"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ask(q: str, default: str = "", yes: bool = False) -> str:
|
|
44
|
+
if yes:
|
|
45
|
+
return default
|
|
46
|
+
d = f" [{default}]" if default else ""
|
|
47
|
+
try:
|
|
48
|
+
a = input(f"{q}{d}: ").strip()
|
|
49
|
+
except EOFError:
|
|
50
|
+
a = ""
|
|
51
|
+
return a or default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def q(s: str) -> str:
|
|
55
|
+
return json.dumps(s, ensure_ascii=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_init(a: argparse.Namespace) -> int:
|
|
59
|
+
root = Path.cwd()
|
|
60
|
+
if (root / CONFIG_NAME).exists() and not a.force:
|
|
61
|
+
say(f"{{y}}{CONFIG_NAME} already exists.{{x}} Edit it, or re-run with --force.")
|
|
62
|
+
return 1
|
|
63
|
+
if not is_git_repo(root):
|
|
64
|
+
say("{y}Not a git repo.{x} Cardinals work on their own branches, so Conclave needs one.")
|
|
65
|
+
if a.yes or ask("Run `git init` here? (y/n)", "y").lower().startswith("y"):
|
|
66
|
+
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=root, check=True)
|
|
67
|
+
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "Start a Conclave"], cwd=root, check=True)
|
|
68
|
+
else:
|
|
69
|
+
return 1
|
|
70
|
+
if not git(root, "rev-parse", "--verify", "--quiet", "HEAD", check=False):
|
|
71
|
+
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "Start a Conclave"], cwd=root, check=True)
|
|
72
|
+
base = git(root, "branch", "--show-current", check=False) or "main"
|
|
73
|
+
|
|
74
|
+
y = a.yes
|
|
75
|
+
say("\n{b}Start a Conclave.{x} You are the Pope: set the goal, the finish line and the budget.\n")
|
|
76
|
+
name = ask("Name", a.name or root.name.lower().replace(" ", "-"), y)
|
|
77
|
+
goal = ask("Goal", a.goal or "Make <something> measurably better", y)
|
|
78
|
+
done = ask("Done when", a.done_when or "<a number you can measure>", y)
|
|
79
|
+
budget = ask("Budget (money, hours, scarce runs)", a.budget or "2 days, $20 of API calls", y)
|
|
80
|
+
|
|
81
|
+
installed = [r for r in runners.BINARIES if shutil.which(runners.BINARIES[r])]
|
|
82
|
+
keys = [k for k in KNOWN_KEYS if os.environ.get(k[0])]
|
|
83
|
+
say(f"\n{{d}}Agent CLIs found: {', '.join(installed) or 'none'}. "
|
|
84
|
+
f"API keys found: {', '.join(k[0] for k in keys) or 'none'}.{{x}}")
|
|
85
|
+
default_runner = installed[0] if installed else "claude"
|
|
86
|
+
|
|
87
|
+
n = int(ask("How many Cardinals (workers)", str(a.cardinals or 2), y) or 2)
|
|
88
|
+
cards = []
|
|
89
|
+
for i in range(n):
|
|
90
|
+
cname = ask(f" Cardinal {i + 1} name", f"cardinal-{i + 1}", y)
|
|
91
|
+
lane = ask(f" {cname}'s lane (one line; the Dean refines it)", "", y)
|
|
92
|
+
runner = ask(f" {cname} runs on ({'/'.join(runners.AGENT_RUNNERS)})", default_runner, y)
|
|
93
|
+
model = ask(f" {cname} model (blank = the CLI's default)", "", y)
|
|
94
|
+
cards.append((cname, lane, runner, model))
|
|
95
|
+
|
|
96
|
+
devil_rows = []
|
|
97
|
+
for env, pname, kind, url, model in keys[:2]:
|
|
98
|
+
devil_rows.append((f"{pname}-devil", "api", pname, model))
|
|
99
|
+
for r in ("codex", "gemini"):
|
|
100
|
+
if r in installed and len(devil_rows) < 3 and r != default_runner:
|
|
101
|
+
devil_rows.append((f"{r}-devil", r, None, None))
|
|
102
|
+
|
|
103
|
+
lines = [
|
|
104
|
+
"# Conclave: a managed agent team. Docs: https://github.com/tech-cosmos/conclave",
|
|
105
|
+
"# Roles: you are the Pope; the Dean orchestrates; Cardinals work; Devils argue back.",
|
|
106
|
+
"",
|
|
107
|
+
"[conclave]",
|
|
108
|
+
f"name = {q(name)}",
|
|
109
|
+
f"goal = {q(goal)}",
|
|
110
|
+
f"done_when = {q(done)}",
|
|
111
|
+
f"budget = {q(budget)}",
|
|
112
|
+
f"base_branch = {q(base)}",
|
|
113
|
+
"rules = [",
|
|
114
|
+
' "Test before building: every idea gets a cheap measurement and a pass mark first.",',
|
|
115
|
+
' "Real improvements only: no gaming the metric, no editing the benchmark.",',
|
|
116
|
+
"]",
|
|
117
|
+
"",
|
|
118
|
+
"[pope]",
|
|
119
|
+
'notify = "desktop" # none | desktop | ntfy:<topic> | command:<shell cmd using {message}>',
|
|
120
|
+
"",
|
|
121
|
+
"# ---- Providers: where models live. kind = openai (any OpenAI-compatible URL) or anthropic.",
|
|
122
|
+
]
|
|
123
|
+
for env, pname, kind, url, _ in keys or [KNOWN_KEYS[0]]:
|
|
124
|
+
lines += [f"[providers.{pname}]", f"kind = {q(kind)}", f"api_key_env = {q(env)}"]
|
|
125
|
+
if url:
|
|
126
|
+
lines.append(f"base_url = {q(url)}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
lines += [
|
|
129
|
+
"# [providers.local] # e.g. Ollama / LM Studio / vLLM",
|
|
130
|
+
'# kind = "openai"',
|
|
131
|
+
'# base_url = "http://localhost:11434/v1"',
|
|
132
|
+
"",
|
|
133
|
+
"# ---- The Dean: the orchestrator you talk to (`conclave dean` opens it).",
|
|
134
|
+
"[dean]",
|
|
135
|
+
f"runner = {q(default_runner)}",
|
|
136
|
+
"",
|
|
137
|
+
"# ---- Cardinals: one lane each, own branch (conclave/<name>), headless sessions in a loop.",
|
|
138
|
+
"# runner: claude | codex | gemini | opencode | aider | custom. provider = \"...\" points it at another API.",
|
|
139
|
+
]
|
|
140
|
+
for cname, lane, runner, model in cards:
|
|
141
|
+
lines += ["[[cardinals]]", f"name = {q(cname)}", f"lane = {q(lane)}", f"runner = {q(runner)}"]
|
|
142
|
+
if model:
|
|
143
|
+
lines.append(f"model = {q(model)}")
|
|
144
|
+
lines.append("")
|
|
145
|
+
lines += ["# ---- Devils: different models, called for fresh ideas when stuck (`conclave devils \"...\"`)."]
|
|
146
|
+
for dname, runner, prov, model in devil_rows:
|
|
147
|
+
lines += ["[[devils]]", f"name = {q(dname)}", f"runner = {q(runner)}"]
|
|
148
|
+
if prov:
|
|
149
|
+
lines.append(f"provider = {q(prov)}")
|
|
150
|
+
if model:
|
|
151
|
+
lines.append(f"model = {q(model)}")
|
|
152
|
+
lines.append("")
|
|
153
|
+
if not devil_rows:
|
|
154
|
+
lines += ["# [[devils]]", '# name = "skeptic"', '# runner = "api"', '# provider = "openrouter"',
|
|
155
|
+
'# model = "deepseek/deepseek-r1"', ""]
|
|
156
|
+
lines += [
|
|
157
|
+
"[compute]",
|
|
158
|
+
'backend = "local" # or "remote" with exec_prefix = ["ssh", "gpu-box"] and remote_dir = "~/repo"',
|
|
159
|
+
'locks = [] # shared scarce things, e.g. ["gpu"]; use: conclave lock gpu -- python bench.py',
|
|
160
|
+
"",
|
|
161
|
+
"[loop]",
|
|
162
|
+
"session_timeout_min = 90",
|
|
163
|
+
"pause_between_s = 30",
|
|
164
|
+
"backoff_s = 900 # after a session that dies fast (auth, usage limit, outage)",
|
|
165
|
+
"max_sessions = 0 # 0 = until paused",
|
|
166
|
+
"push = true # push each Cardinal's branch after every session (if the repo has a remote)",
|
|
167
|
+
]
|
|
168
|
+
(root / CONFIG_NAME).write_text("\n".join(lines) + "\n")
|
|
169
|
+
cfg = load(root)
|
|
170
|
+
scaffold(cfg)
|
|
171
|
+
say(f"\n{{g}}Wrote {CONFIG_NAME} and .conclave/{{x}} (CONCLAVE.md, DEAN.md, COORD.md, lanes/).")
|
|
172
|
+
say("Next: {b}conclave doctor{x}, then let the Dean refine the lanes and run {b}conclave start{x}.")
|
|
173
|
+
say("Or open the Dean: {b}conclave dean{x}")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------- doctor
|
|
178
|
+
|
|
179
|
+
def cmd_doctor(cfg: Config, a: argparse.Namespace) -> int:
|
|
180
|
+
bad = 0
|
|
181
|
+
|
|
182
|
+
def line(ok: bool, what: str, hint: str = "") -> None:
|
|
183
|
+
nonlocal bad
|
|
184
|
+
bad += not ok
|
|
185
|
+
say(f" {'{g}ok{x}' if ok else '{r}!!{x}'} {what}" + (f" {{d}}{hint}{{x}}" if hint and not ok else ""))
|
|
186
|
+
|
|
187
|
+
say(f"{{b}}Conclave {cfg.name}{{x}} ({cfg.root})")
|
|
188
|
+
line(is_git_repo(cfg.root), "git repo")
|
|
189
|
+
line(bool(git(cfg.root, "rev-parse", "--verify", "--quiet", cfg.base_branch, check=False)),
|
|
190
|
+
f"base branch {cfg.base_branch}", "set conclave.base_branch")
|
|
191
|
+
line(has_remote(cfg.root) or not cfg.loop.push, "git remote (for pushing Cardinal branches)",
|
|
192
|
+
"add one, or set loop.push = false")
|
|
193
|
+
line(bool(cfg.goal and "<" not in cfg.goal), "goal is set", "edit [conclave] goal")
|
|
194
|
+
line(bool(cfg.cardinals), "at least one Cardinal")
|
|
195
|
+
for m in [cfg.dean, *cfg.cardinals, *cfg.devils]:
|
|
196
|
+
if m.runner == "api":
|
|
197
|
+
line(True, f"{m.role} {m.name}: api call")
|
|
198
|
+
else:
|
|
199
|
+
line(runners.is_installed(m), f"{m.role} {m.name}: runner {m.runner}",
|
|
200
|
+
f"install `{runners.binary_for(m)}` or pick another runner")
|
|
201
|
+
if m.role == "cardinal" and m.runner == "api":
|
|
202
|
+
line(False, f"cardinal {m.name}: runner api has no tools", "use opencode/aider/claude/codex")
|
|
203
|
+
p = cfg.provider_for(m)
|
|
204
|
+
if p and p.api_key_env:
|
|
205
|
+
line(bool(p.api_key), f"{m.role} {m.name}: ${p.api_key_env} for provider {p.name}", "export it")
|
|
206
|
+
if a.ping:
|
|
207
|
+
say("{b}Pinging providers{x} (one tiny call each)")
|
|
208
|
+
seen = set()
|
|
209
|
+
for m in cfg.devils:
|
|
210
|
+
if m.runner != "api" or (m.provider, m.model) in seen:
|
|
211
|
+
continue
|
|
212
|
+
seen.add((m.provider, m.model))
|
|
213
|
+
try:
|
|
214
|
+
r = chat(cfg.provider_for(m), m.model or "", "Reply with one word.", "Say ok.", max_tokens=20, timeout=60)
|
|
215
|
+
line(True, f"{m.provider} / {m.model}: {r[:40]!r}")
|
|
216
|
+
except ProviderError as e:
|
|
217
|
+
line(False, f"{m.provider} / {m.model}", str(e)[:200])
|
|
218
|
+
say(f"\n{'{g}All good.{x}' if not bad else f'{{r}}{bad} problem(s).{{x}}'}")
|
|
219
|
+
return 1 if bad else 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------- start / pause / status
|
|
223
|
+
|
|
224
|
+
def _names(cfg: Config, names: list[str]) -> list[str]:
|
|
225
|
+
for n in names:
|
|
226
|
+
cfg.cardinal(n)
|
|
227
|
+
return names or [c.name for c in cfg.cardinals]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_start(cfg: Config, a: argparse.Namespace) -> int:
|
|
231
|
+
scaffold(cfg)
|
|
232
|
+
names = _names(cfg, a.names)
|
|
233
|
+
if not a.names:
|
|
234
|
+
(cfg.workdir / "STOP").unlink(missing_ok=True)
|
|
235
|
+
for n in names:
|
|
236
|
+
(cfg.workdir / f"STOP-{n}").unlink(missing_ok=True)
|
|
237
|
+
m = cfg.cardinal(n)
|
|
238
|
+
if m.runner == "api":
|
|
239
|
+
say(f" {{r}}{n}: runner api cannot be a Cardinal (no tools){{x}}")
|
|
240
|
+
continue
|
|
241
|
+
if not runners.is_installed(m):
|
|
242
|
+
say(f" {{r}}{n}: `{runners.binary_for(m)}` not found{{x}}")
|
|
243
|
+
continue
|
|
244
|
+
pid = loop.alive(cfg, n)
|
|
245
|
+
if pid:
|
|
246
|
+
say(f" {n}: already running (pid {pid})")
|
|
247
|
+
continue
|
|
248
|
+
from conclave.workspace import ensure_worktree
|
|
249
|
+
ensure_worktree(cfg, m)
|
|
250
|
+
pid = loop.spawn(cfg, n)
|
|
251
|
+
say(f" {{g}}{n}{{x}}: started (pid {pid}) in {worktree(cfg, m).relative_to(cfg.root)}")
|
|
252
|
+
board_append(cfg, "Results", f"{utcnow()} dean: started {', '.join(names)}")
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def cmd_pause(cfg: Config, a: argparse.Namespace) -> int:
|
|
257
|
+
names = _names(cfg, a.names)
|
|
258
|
+
if not a.names:
|
|
259
|
+
(cfg.workdir / "STOP").touch()
|
|
260
|
+
for n in names:
|
|
261
|
+
(cfg.workdir / f"STOP-{n}").touch()
|
|
262
|
+
if a.now and loop.kill(cfg, n):
|
|
263
|
+
say(f" {n}: killed")
|
|
264
|
+
else:
|
|
265
|
+
say(f" {n}: will stop after its current session")
|
|
266
|
+
_check_pushed(cfg, names)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _check_pushed(cfg: Config, names: list[str]) -> None:
|
|
271
|
+
for n in names:
|
|
272
|
+
u = unpushed(cfg, cfg.cardinal(n))
|
|
273
|
+
if u:
|
|
274
|
+
say(f" {{y}}{n}: {u}{{x}} (push before deleting any machine)")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def status_data(cfg: Config) -> dict:
|
|
278
|
+
out = {"name": cfg.name, "goal": cfg.goal, "stopped": (cfg.workdir / "STOP").exists(), "cardinals": []}
|
|
279
|
+
for c in cfg.cardinals:
|
|
280
|
+
wt = worktree(cfg, c)
|
|
281
|
+
st = loop.read_state(cfg, c.name)
|
|
282
|
+
last = git(wt, "log", "-1", "--format=%cr: %s", check=False) if wt.exists() else ""
|
|
283
|
+
state_file = wt / f"STATE-{c.name}.md"
|
|
284
|
+
out["cardinals"].append({
|
|
285
|
+
"name": c.name, "runner": c.runner, "model": c.model, "pid": loop.alive(cfg, c.name),
|
|
286
|
+
"paused": (cfg.workdir / f"STOP-{c.name}").exists(), **st, "last_commit": last,
|
|
287
|
+
"state_head": state_file.read_text().splitlines()[:3] if state_file.exists() else [],
|
|
288
|
+
})
|
|
289
|
+
out["claims"] = board_section(cfg, "Claims")
|
|
290
|
+
out["requests"] = board_section(cfg, "Requests")
|
|
291
|
+
out["pings"] = board_section(cfg, "Pings")[-3:]
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_status(cfg: Config, a: argparse.Namespace) -> int:
|
|
296
|
+
d = status_data(cfg)
|
|
297
|
+
if a.json:
|
|
298
|
+
print(json.dumps(d, indent=2))
|
|
299
|
+
return 0
|
|
300
|
+
say(f"{{b}}Conclave {d['name']}{{x}}: {d['goal']}")
|
|
301
|
+
for c in d["cardinals"]:
|
|
302
|
+
if c["pid"]:
|
|
303
|
+
s = "{g}working{x}" if c.get("running") else "{g}between sessions{x}"
|
|
304
|
+
else:
|
|
305
|
+
s = ("{y}paused{x}" if c["paused"] or d["stopped"] else
|
|
306
|
+
"{d}loop ended{x}" if c.get("sessions") else "{d}not started{x}")
|
|
307
|
+
model = f" / {c['model']}" if c["model"] else ""
|
|
308
|
+
extra = f" session {c.get('sessions', 0)}"
|
|
309
|
+
if "last_exit" in c:
|
|
310
|
+
extra += f", last exit {c['last_exit']} after {c['last_duration_s']}s"
|
|
311
|
+
say(f" {{b}}{c['name']}{{x}} ({c['runner']}{model}): {s}{extra}")
|
|
312
|
+
if c["last_commit"]:
|
|
313
|
+
say(f" {{d}}last commit {c['last_commit'][:110]}{{x}}")
|
|
314
|
+
for k in ("claims", "requests", "pings"):
|
|
315
|
+
if d[k]:
|
|
316
|
+
say(f"{{b}}{k.title()}{{x}}")
|
|
317
|
+
for ln in d[k][-8:]:
|
|
318
|
+
say(f" {ln[:140]}")
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def cmd_end(cfg: Config, a: argparse.Namespace) -> int:
|
|
323
|
+
(cfg.workdir / "STOP").touch()
|
|
324
|
+
say("Stop flag set: every Cardinal stops after its current session.")
|
|
325
|
+
if a.now:
|
|
326
|
+
for c in cfg.cardinals:
|
|
327
|
+
if loop.kill(cfg, c.name):
|
|
328
|
+
say(f" {c.name}: killed")
|
|
329
|
+
_check_pushed(cfg, [c.name for c in cfg.cardinals])
|
|
330
|
+
out = write_report(cfg, Path(a.out) if a.out else None)
|
|
331
|
+
say(f"Report: {{b}}{out}{{x}}")
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------- report
|
|
336
|
+
|
|
337
|
+
def write_report(cfg: Config, dest: Path | None) -> Path:
|
|
338
|
+
lines = [
|
|
339
|
+
"---", f"title: Conclave {cfg.name}", f"created: {utcnow()[:10]}", "tags: [conclave, agent-team]", "---", "",
|
|
340
|
+
f"# Conclave: {cfg.name}", "", f"> **Goal:** {cfg.goal} ", f"> **Done when:** {cfg.done_when} ",
|
|
341
|
+
f"> **Budget:** {cfg.budget}", "", "## Cardinals", "",
|
|
342
|
+
]
|
|
343
|
+
for c in cfg.cardinals:
|
|
344
|
+
wt = worktree(cfg, c)
|
|
345
|
+
st = loop.read_state(cfg, c.name)
|
|
346
|
+
lines += [f"### {c.name} ({c.runner}{' / ' + c.model if c.model else ''})", "",
|
|
347
|
+
f"Lane: {c.lane or '(see lane file)'} ", f"Branch: `conclave/{c.name}`, {st.get('sessions', 0)} sessions",
|
|
348
|
+
""]
|
|
349
|
+
sf = wt / f"STATE-{c.name}.md"
|
|
350
|
+
if sf.exists():
|
|
351
|
+
lines += ["**Final state:**", "", sf.read_text().strip(), ""]
|
|
352
|
+
if wt.exists():
|
|
353
|
+
log = git(wt, "log", f"{cfg.base_branch}..HEAD", "--format=- %h %s", check=False)
|
|
354
|
+
if log:
|
|
355
|
+
lines += ["**Commits:**", "", log, ""]
|
|
356
|
+
for sec in ("Pope's decisions", "Results", "Handoffs"):
|
|
357
|
+
items = board_section(cfg, sec)
|
|
358
|
+
if items:
|
|
359
|
+
lines += [f"## {sec}", "", *items, ""]
|
|
360
|
+
rounds = sorted((cfg.workdir / "devils").glob("*.md"))
|
|
361
|
+
if rounds:
|
|
362
|
+
lines += ["## Devils rounds", "", *[f"- {r.name}" for r in rounds], ""]
|
|
363
|
+
text = "\n".join(lines)
|
|
364
|
+
out = cfg.workdir / "REPORT.md"
|
|
365
|
+
out.write_text(text)
|
|
366
|
+
if dest:
|
|
367
|
+
dest = dest.expanduser()
|
|
368
|
+
target = dest / f"Conclave - {cfg.name}.md" if dest.is_dir() else dest
|
|
369
|
+
target.write_text(text)
|
|
370
|
+
return target
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def cmd_report(cfg: Config, a: argparse.Namespace) -> int:
|
|
375
|
+
say(str(write_report(cfg, Path(a.out) if a.out else None)))
|
|
376
|
+
return 0
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------- devils / ping / lock / logs / dean
|
|
380
|
+
|
|
381
|
+
def cmd_devils(cfg: Config, a: argparse.Namespace) -> int:
|
|
382
|
+
brief = Path(a.file).read_text() if a.file else " ".join(a.brief)
|
|
383
|
+
if not brief.strip():
|
|
384
|
+
say("{r}Give the Devils a brief:{x} conclave devils \"we're stuck at X; tried A, B; numbers ...\"")
|
|
385
|
+
return 1
|
|
386
|
+
say(f"Calling {len(a.only or cfg.devils)} Devil(s)...")
|
|
387
|
+
out = devils.round_(cfg, brief, a.only)
|
|
388
|
+
say(f"{{g}}Done:{{x}} {out}")
|
|
389
|
+
if a.print:
|
|
390
|
+
print(out.read_text())
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def cmd_ping(cfg: Config, a: argparse.Namespace) -> int:
|
|
395
|
+
sender = a.sender or os.environ.get("CONCLAVE_NAME", "dean")
|
|
396
|
+
say(notify.ping(cfg, " ".join(a.message), sender))
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def cmd_board(cfg: Config, a: argparse.Namespace) -> int:
|
|
401
|
+
sender = os.environ.get("CONCLAVE_NAME", "dean")
|
|
402
|
+
board_append(cfg, a.section, f"{utcnow()} {sender}: {' '.join(a.text)}")
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def cmd_lock(cfg: Config, a: argparse.Namespace) -> int:
|
|
407
|
+
cmd = a.cmd[1:] if a.cmd and a.cmd[0] == "--" else a.cmd
|
|
408
|
+
if not cmd:
|
|
409
|
+
say("usage: conclave lock <name> -- <command...>")
|
|
410
|
+
return 2
|
|
411
|
+
return loop.with_lock(cfg, a.name, cmd)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def cmd_logs(cfg: Config, a: argparse.Namespace) -> int:
|
|
415
|
+
logs = sorted((cfg.workdir / "logs").glob(f"{a.name}-*.log"), key=lambda p: p.stat().st_mtime)
|
|
416
|
+
if not logs:
|
|
417
|
+
say(f"no session logs for {a.name} yet")
|
|
418
|
+
return 1
|
|
419
|
+
text = logs[-1].read_text(errors="replace").splitlines()
|
|
420
|
+
say(f"{{d}}{logs[-1]}{{x}}")
|
|
421
|
+
print("\n".join(text[-a.n:]))
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def cmd_loop(cfg: Config, a: argparse.Namespace) -> int:
|
|
426
|
+
return loop.loop(cfg, a.name, once=a.once)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def cmd_dean(cfg: Config, a: argparse.Namespace) -> int:
|
|
430
|
+
scaffold(cfg)
|
|
431
|
+
prompt = (f"You are the Dean of the Conclave \"{cfg.name}\". Read {cfg.workdir}/DEAN.md and follow it. "
|
|
432
|
+
"Start by checking `conclave status` and asking the Pope (me) anything the checklist is missing.")
|
|
433
|
+
r = cfg.dean.runner
|
|
434
|
+
argv = {"claude": ["claude"], "codex": ["codex"], "gemini": ["gemini", "-i"], "opencode": ["opencode", "--prompt"]}.get(r)
|
|
435
|
+
if not argv:
|
|
436
|
+
say(f"Open your agent and paste:\n\n{prompt}")
|
|
437
|
+
return 0
|
|
438
|
+
if cfg.dean.model:
|
|
439
|
+
argv += ["--model" if r != "codex" else "-m", cfg.dean.model]
|
|
440
|
+
from conclave.providers import provider_env
|
|
441
|
+
os.environ.update(provider_env(cfg.provider_for(cfg.dean)))
|
|
442
|
+
os.chdir(cfg.root)
|
|
443
|
+
os.execvp(argv[0], [*argv, prompt])
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------- remote forwarding
|
|
447
|
+
|
|
448
|
+
LOCAL_ONLY = {"init", "dean"}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def forward_remote(cfg: Config, argv: list[str]) -> int:
|
|
452
|
+
inner = f"cd {cfg.compute.remote_dir} && CONCLAVE_LOCAL=1 conclave {shlex.join(argv)}"
|
|
453
|
+
return subprocess.run([*cfg.compute.exec_prefix, "bash", "-lc", shlex.quote(inner)]).returncode
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------- main
|
|
457
|
+
|
|
458
|
+
def parser() -> argparse.ArgumentParser:
|
|
459
|
+
p = argparse.ArgumentParser(prog="conclave", description="Run a managed agent team: Dean, Cardinals, Devils.")
|
|
460
|
+
p.add_argument("--version", action="version", version=f"conclave {__version__}")
|
|
461
|
+
s = p.add_subparsers(dest="command", required=True)
|
|
462
|
+
|
|
463
|
+
i = s.add_parser("init", help="set up conclave.toml and .conclave/ in this repo (wizard)")
|
|
464
|
+
i.add_argument("--yes", "-y", action="store_true", help="accept defaults, no questions")
|
|
465
|
+
i.add_argument("--force", action="store_true")
|
|
466
|
+
for f in ("name", "goal", "done-when", "budget"):
|
|
467
|
+
i.add_argument(f"--{f}")
|
|
468
|
+
i.add_argument("--cardinals", type=int)
|
|
469
|
+
|
|
470
|
+
d = s.add_parser("doctor", help="check runners, keys and git")
|
|
471
|
+
d.add_argument("--ping", action="store_true", help="make one tiny call to each Devil's provider")
|
|
472
|
+
|
|
473
|
+
for name, hlp in (("start", "start (or resume) Cardinal loops"), ("resume", "same as start")):
|
|
474
|
+
x = s.add_parser(name, help=hlp)
|
|
475
|
+
x.add_argument("names", nargs="*")
|
|
476
|
+
x = s.add_parser("pause", help="stop Cardinals after their current session")
|
|
477
|
+
x.add_argument("names", nargs="*")
|
|
478
|
+
x.add_argument("--now", action="store_true", help="kill the running session too")
|
|
479
|
+
x = s.add_parser("status", help="who is doing what")
|
|
480
|
+
x.add_argument("--json", action="store_true")
|
|
481
|
+
x = s.add_parser("end", help="stop everything, check pushes, write the report")
|
|
482
|
+
x.add_argument("--now", action="store_true")
|
|
483
|
+
x.add_argument("--out", help="also write the report here (a file, or a folder such as your Obsidian vault)")
|
|
484
|
+
x = s.add_parser("report", help="write .conclave/REPORT.md")
|
|
485
|
+
x.add_argument("--out")
|
|
486
|
+
x = s.add_parser("devils", help="call the Devils for fresh ideas")
|
|
487
|
+
x.add_argument("brief", nargs="*")
|
|
488
|
+
x.add_argument("--file", "-f")
|
|
489
|
+
x.add_argument("--only", action="append", metavar="NAME", help="only this Devil (repeat for more)")
|
|
490
|
+
x.add_argument("--print", action="store_true")
|
|
491
|
+
x = s.add_parser("ping", help="notify the Pope (wins, decisions, done)")
|
|
492
|
+
x.add_argument("message", nargs="+")
|
|
493
|
+
x.add_argument("--from", dest="sender")
|
|
494
|
+
x = s.add_parser("board", help="append a line to a COORD.md section")
|
|
495
|
+
x.add_argument("section", choices=["Claims", "Results", "Handoffs", "Requests", "Pope's decisions"])
|
|
496
|
+
x.add_argument("text", nargs="+")
|
|
497
|
+
x = s.add_parser("lock", help="run a command holding a shared lock, e.g. conclave lock gpu -- python bench.py")
|
|
498
|
+
x.add_argument("name")
|
|
499
|
+
x.add_argument("cmd", nargs=argparse.REMAINDER)
|
|
500
|
+
x = s.add_parser("logs", help="tail a Cardinal's latest session log")
|
|
501
|
+
x.add_argument("name")
|
|
502
|
+
x.add_argument("-n", type=int, default=40)
|
|
503
|
+
x = s.add_parser("loop", help=argparse.SUPPRESS)
|
|
504
|
+
x.add_argument("name")
|
|
505
|
+
x.add_argument("--once", action="store_true")
|
|
506
|
+
s.add_parser("dean", help="open the Dean (your orchestrator agent) with its playbook")
|
|
507
|
+
return p
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
HANDLERS = {
|
|
511
|
+
"doctor": cmd_doctor, "start": cmd_start, "resume": cmd_start, "pause": cmd_pause, "status": cmd_status,
|
|
512
|
+
"end": cmd_end, "report": cmd_report, "devils": cmd_devils, "ping": cmd_ping, "board": cmd_board,
|
|
513
|
+
"lock": cmd_lock, "logs": cmd_logs, "loop": cmd_loop, "dean": cmd_dean,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def main(argv: list[str] | None = None) -> int:
|
|
518
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
519
|
+
a = parser().parse_args(argv)
|
|
520
|
+
try:
|
|
521
|
+
if a.command == "init":
|
|
522
|
+
return cmd_init(a)
|
|
523
|
+
cfg = load(find_root())
|
|
524
|
+
if cfg.compute.backend == "remote" and a.command not in LOCAL_ONLY and not os.environ.get("CONCLAVE_LOCAL"):
|
|
525
|
+
return forward_remote(cfg, argv)
|
|
526
|
+
return HANDLERS[a.command](cfg, a) or 0
|
|
527
|
+
except ConfigError as e:
|
|
528
|
+
say(f"{{r}}config:{{x}} {e}")
|
|
529
|
+
return 2
|
|
530
|
+
except KeyboardInterrupt:
|
|
531
|
+
return 130
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
if __name__ == "__main__":
|
|
535
|
+
raise SystemExit(main())
|