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 ADDED
@@ -0,0 +1,3 @@
1
+ """Conclave: a managed agent team. One Dean, many Cardinals, a few Devils, and you (the Pope)."""
2
+
3
+ __version__ = "0.1.0"
conclave/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from conclave.cli import main
2
+
3
+ raise SystemExit(main())
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())