roundtable-cli 0.4.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.
- roundtable/__init__.py +10 -0
- roundtable/agents.py +218 -0
- roundtable/cli.py +722 -0
- roundtable/config.py +265 -0
- roundtable/dashboard.py +533 -0
- roundtable/discovery.py +71 -0
- roundtable/engine.py +714 -0
- roundtable/errors.py +20 -0
- roundtable/insights.py +210 -0
- roundtable/llm.py +1048 -0
- roundtable/mcp.py +248 -0
- roundtable/modelpick.py +309 -0
- roundtable/models.py +202 -0
- roundtable/prompts.py +205 -0
- roundtable/runctl.py +212 -0
- roundtable/scan.py +187 -0
- roundtable/store.py +339 -0
- roundtable_cli-0.4.0.dist-info/METADATA +570 -0
- roundtable_cli-0.4.0.dist-info/RECORD +22 -0
- roundtable_cli-0.4.0.dist-info/WHEEL +4 -0
- roundtable_cli-0.4.0.dist-info/entry_points.txt +4 -0
- roundtable_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
roundtable/cli.py
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
roundtable init [dir] scaffold .roundtable/ + default config;
|
|
4
|
+
lists installed CLIs and their models
|
|
5
|
+
roundtable agents list configured agents + their models
|
|
6
|
+
roundtable map scan an existing project into docs + a PRD
|
|
7
|
+
roundtable plan --goal "..." plan from a goal
|
|
8
|
+
roundtable plan --prd FILE plan from a PRD / requirements file
|
|
9
|
+
roundtable plan --plan FILE ingest an existing plan (JSON or doc)
|
|
10
|
+
roundtable approve approve the plan (the human gate)
|
|
11
|
+
roundtable run execute all phases autonomously
|
|
12
|
+
(live web dashboard + inline progress)
|
|
13
|
+
roundtable status show phase/task progress
|
|
14
|
+
roundtable dashboard live web dashboard (stdlib http.server)
|
|
15
|
+
roundtable watch live terminal dashboard
|
|
16
|
+
roundtable mcp start the MCP server (stdio)
|
|
17
|
+
|
|
18
|
+
Designed to run *inside an existing project*: all roundtable artifacts live under
|
|
19
|
+
``.roundtable/`` and agents run with the project directory as their working dir, so
|
|
20
|
+
they read and edit the real files. After ``plan``, review ``.roundtable/plan/PLAN.md``;
|
|
21
|
+
``run`` refuses until ``approve``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import asyncio
|
|
28
|
+
import datetime as _dt
|
|
29
|
+
import os
|
|
30
|
+
import signal
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from . import runctl
|
|
38
|
+
from .agents import Analyst, Planner
|
|
39
|
+
from .config import Config, load_config, write_default_config
|
|
40
|
+
from .dashboard import make_server
|
|
41
|
+
from .discovery import AgentStatus, discover
|
|
42
|
+
from .engine import Engine, normalize_runner_refs
|
|
43
|
+
from .errors import RoundtableError
|
|
44
|
+
from .insights import build_state, render_text
|
|
45
|
+
from . import modelpick
|
|
46
|
+
from .llm import CLIProvider, LiteLLMProvider, LLMProvider, PiProvider, ScriptedProvider
|
|
47
|
+
from .models import AgentRef, Plan, Status
|
|
48
|
+
from .scan import build_digest
|
|
49
|
+
from .store import Store
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_provider(config: Config, cwd: Path | str | None = None) -> LLMProvider:
|
|
53
|
+
if config.provider == "pi":
|
|
54
|
+
return PiProvider(
|
|
55
|
+
config.pi, cwd=cwd,
|
|
56
|
+
timeout=config.defaults.timeout, max_retries=config.defaults.max_retries,
|
|
57
|
+
)
|
|
58
|
+
if config.provider == "cli":
|
|
59
|
+
return CLIProvider(
|
|
60
|
+
config.agents, cwd=cwd,
|
|
61
|
+
timeout=config.defaults.timeout, max_retries=config.defaults.max_retries,
|
|
62
|
+
)
|
|
63
|
+
if config.provider == "scripted":
|
|
64
|
+
return ScriptedProvider()
|
|
65
|
+
if config.provider == "litellm":
|
|
66
|
+
return LiteLLMProvider(max_retries=config.defaults.max_retries)
|
|
67
|
+
raise RoundtableError(
|
|
68
|
+
f"unknown provider {config.provider!r} (use 'pi', 'cli', 'litellm', or 'scripted')"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _role_models(config: Config) -> dict[str, AgentRef]:
|
|
73
|
+
return {
|
|
74
|
+
"planner": config.models.planner,
|
|
75
|
+
"main": config.models.main,
|
|
76
|
+
"phase": config.models.phase,
|
|
77
|
+
"task": config.models.task,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _first_line(text: str, default: str = "") -> str:
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
s = line.strip().lstrip("# ").strip()
|
|
84
|
+
if s:
|
|
85
|
+
return s[:120]
|
|
86
|
+
return default
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def make_plan(
|
|
90
|
+
store: Store,
|
|
91
|
+
config: Config,
|
|
92
|
+
*,
|
|
93
|
+
goal: str | None = None,
|
|
94
|
+
prd_path: str | None = None,
|
|
95
|
+
plan_path: str | None = None,
|
|
96
|
+
planner_model: str | None = None,
|
|
97
|
+
) -> Plan:
|
|
98
|
+
roles = _role_models(config)
|
|
99
|
+
allowed = sorted({str(r) for r in roles.values()})
|
|
100
|
+
provider = build_provider(config, store.root)
|
|
101
|
+
planner_ref = AgentRef.model_validate(planner_model) if planner_model else roles["planner"]
|
|
102
|
+
planner = Planner(provider, planner_ref, config.defaults.temperature)
|
|
103
|
+
|
|
104
|
+
source_text = ""
|
|
105
|
+
if plan_path:
|
|
106
|
+
source_text = Path(plan_path).read_text()
|
|
107
|
+
try:
|
|
108
|
+
plan = Plan.model_validate_json(source_text) # already in our schema -> no LLM
|
|
109
|
+
except Exception:
|
|
110
|
+
plan = await planner.structure_plan(source_text, allowed, roles) # convert via LLM
|
|
111
|
+
elif prd_path or goal:
|
|
112
|
+
prd_text = Path(prd_path).read_text() if prd_path else ""
|
|
113
|
+
source_text = prd_text
|
|
114
|
+
combined = "\n\n".join(x for x in [goal or "", prd_text] if x).strip()
|
|
115
|
+
plan = await planner.create_plan(combined, allowed, roles)
|
|
116
|
+
else:
|
|
117
|
+
raise RoundtableError("nothing to plan from: pass --goal, --prd, or --plan")
|
|
118
|
+
|
|
119
|
+
# Backfill metadata + role defaults; normalize indices; re-validate the graph.
|
|
120
|
+
if goal:
|
|
121
|
+
plan.goal = goal.strip()
|
|
122
|
+
if not plan.goal:
|
|
123
|
+
plan.goal = _first_line(source_text, "(imported plan)")
|
|
124
|
+
plan.created_at = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
125
|
+
plan.main_runner = plan.main_runner or roles["main"]
|
|
126
|
+
plan.runners = roles
|
|
127
|
+
for i, phase in enumerate(plan.phases, start=1):
|
|
128
|
+
phase.index = i
|
|
129
|
+
phase.runner = phase.runner or roles["phase"]
|
|
130
|
+
for task in phase.tasks:
|
|
131
|
+
task.runner = task.runner or roles["task"]
|
|
132
|
+
normalize_runner_refs(plan, config)
|
|
133
|
+
plan = Plan.model_validate(plan.model_dump()) # re-run validators after edits
|
|
134
|
+
|
|
135
|
+
archive_dir = store.archive_current_run()
|
|
136
|
+
if archive_dir:
|
|
137
|
+
print(f"archived previous run → {archive_dir.relative_to(store.root)}")
|
|
138
|
+
|
|
139
|
+
store.scaffold()
|
|
140
|
+
store.write_brief(plan.goal if not source_text else source_text)
|
|
141
|
+
store.save_plan(plan)
|
|
142
|
+
store.write_plan_md(plan)
|
|
143
|
+
return plan
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def map_project(
|
|
147
|
+
store: Store,
|
|
148
|
+
config: Config,
|
|
149
|
+
*,
|
|
150
|
+
target: Path,
|
|
151
|
+
analyst_model: str | None = None,
|
|
152
|
+
max_files: int = 400,
|
|
153
|
+
max_bytes: int = 60000,
|
|
154
|
+
) -> tuple[str, str]:
|
|
155
|
+
"""Scan ``target`` and write ARCHITECTURE.md + PRD.md to ``.roundtable/docs/``."""
|
|
156
|
+
provider = build_provider(config, target) # cwd = scanned project
|
|
157
|
+
ref = AgentRef.model_validate(analyst_model) if analyst_model else config.models.main
|
|
158
|
+
store.scaffold()
|
|
159
|
+
digest = build_digest(target, max_files=max_files, max_total_bytes=max_bytes)
|
|
160
|
+
store.record_event(
|
|
161
|
+
"map_started", message=f"mapping {target}", target=str(target), digest_bytes=len(digest)
|
|
162
|
+
)
|
|
163
|
+
analyst = Analyst(provider, ref, config.defaults.temperature)
|
|
164
|
+
arch = await analyst.architecture(digest)
|
|
165
|
+
store.write_doc("ARCHITECTURE.md", arch)
|
|
166
|
+
prd = await analyst.prd(digest, arch)
|
|
167
|
+
store.write_doc("PRD.md", prd)
|
|
168
|
+
store.record_event("map_done", message="map complete", docs=["ARCHITECTURE.md", "PRD.md"])
|
|
169
|
+
return arch, prd
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
# Commands
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
def _print_agents(statuses: list[AgentStatus], *, limit: int | None = None) -> None:
|
|
176
|
+
if not statuses:
|
|
177
|
+
print(" (no agents configured)")
|
|
178
|
+
return
|
|
179
|
+
for st in sorted(statuses, key=lambda s: (not s.installed, s.name)):
|
|
180
|
+
if not st.installed:
|
|
181
|
+
print(f" [ ] {st.name} ({st.binary}) — not on PATH")
|
|
182
|
+
elif st.models:
|
|
183
|
+
shown = st.models if limit is None else st.models[:limit]
|
|
184
|
+
print(f" [x] {st.name} ({st.binary}) — {len(st.models)} model(s):")
|
|
185
|
+
for m in shown:
|
|
186
|
+
print(f" {m}")
|
|
187
|
+
if limit is not None and len(st.models) > limit:
|
|
188
|
+
print(f" … (+{len(st.models) - limit} more — run `roundtable agents`)")
|
|
189
|
+
else:
|
|
190
|
+
print(f" [x] {st.name} ({st.binary}) — models: n/a ({st.note})")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
194
|
+
root = Path(args.dir).resolve()
|
|
195
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
store = Store(root)
|
|
197
|
+
store.scaffold()
|
|
198
|
+
cfg_path = root / "roundtable.config.yaml"
|
|
199
|
+
# Roundtable works best with a pi-family agent. Prefer upstream `pi`; fall back
|
|
200
|
+
# to oh-my-pi (`omp`) if only that is installed.
|
|
201
|
+
pi_flavor = "pi" if shutil.which("pi") else ("omp" if shutil.which("omp") else None)
|
|
202
|
+
if cfg_path.exists() and not args.force:
|
|
203
|
+
print(f"config already exists: {cfg_path} (use --force to overwrite)")
|
|
204
|
+
else:
|
|
205
|
+
backend = "pi" if pi_flavor else "cli"
|
|
206
|
+
write_default_config(root, backend=backend, flavor=pi_flavor or "pi")
|
|
207
|
+
print(f"wrote {cfg_path} (provider: {backend}" + (f", flavor: {pi_flavor})" if pi_flavor else ")"))
|
|
208
|
+
if pi_flavor:
|
|
209
|
+
tool = "omp" if pi_flavor == "omp" else "pi"
|
|
210
|
+
print(f"\ndetected `{tool}` on PATH → scaffolded the recommended pi backend (flavor: {pi_flavor}).")
|
|
211
|
+
print(f"connect an LLM to {tool} (pick one per provider you use):")
|
|
212
|
+
print(" • set a key: export ANTHROPIC_API_KEY=… (or OPENAI_API_KEY / GEMINI_API_KEY / …)")
|
|
213
|
+
print(f" • or use {tool}'s own login (e.g. `pi-ai login <provider>` for pi)")
|
|
214
|
+
hint = "`pi --list-models`" if tool == "pi" else "`omp --help`"
|
|
215
|
+
print(f"see assignable models ({hint}), then edit `models:` in the config.")
|
|
216
|
+
else:
|
|
217
|
+
print("\nno pi-family agent (`pi` or `omp`) on PATH. Two ways to run roundtable:")
|
|
218
|
+
print(" 1) Recommended — install pi (or oh-my-pi) and connect it to your LLMs, then re-run `roundtable init`:")
|
|
219
|
+
print(" npm install -g @earendil-works/pi-coding-agent # upstream pi")
|
|
220
|
+
print(" npm install -g @oh-my-pi/pi-coding-agent # oh-my-pi (omp): LSP/DAP/subagents")
|
|
221
|
+
print(" # then: set ANTHROPIC_API_KEY / OPENAI_API_KEY / … (or the tool's login)")
|
|
222
|
+
print(" 2) Use what you already have — the scaffolded `provider: cli` config drives the")
|
|
223
|
+
print(" terminal CLIs you've installed (claude, codex, gemini, …); or set `provider: litellm`")
|
|
224
|
+
print(" for direct API calls. Edit roundtable.config.yaml to pick each role's agent/model.")
|
|
225
|
+
print(f"\ninitialized roundtable in {root} (artifacts under {store.base})")
|
|
226
|
+
|
|
227
|
+
config = load_config(root)
|
|
228
|
+
if not args.no_models and config.provider in ("pi", "cli"):
|
|
229
|
+
if sys.stdin.isatty():
|
|
230
|
+
try:
|
|
231
|
+
ans = input("\nselect a model for each role now? [Y/n] ").strip().lower()
|
|
232
|
+
except (EOFError, KeyboardInterrupt):
|
|
233
|
+
ans = "n"
|
|
234
|
+
if ans in ("", "y", "yes"):
|
|
235
|
+
groups = modelpick.list_model_groups(config, timeout=args.models_timeout)
|
|
236
|
+
current = modelpick.current_refs(config)
|
|
237
|
+
picks = modelpick.pick_models(groups, current)
|
|
238
|
+
if picks:
|
|
239
|
+
modelpick.update_config_models(cfg_path, {**current, **picks})
|
|
240
|
+
print("updated `models:` in the config.")
|
|
241
|
+
else:
|
|
242
|
+
print("kept the scaffolded models — change any time with `roundtable models`.")
|
|
243
|
+
elif config.provider == "cli":
|
|
244
|
+
print("\navailable agents & models (probing installed CLIs — --no-models to skip):")
|
|
245
|
+
statuses = asyncio.run(discover(config.agents, timeout=args.models_timeout))
|
|
246
|
+
_print_agents(statuses, limit=8)
|
|
247
|
+
print("\nassign an agent:model to each role under `models:` (or run `roundtable models`).")
|
|
248
|
+
|
|
249
|
+
print("next: edit roundtable.config.yaml (or `roundtable models`), then "
|
|
250
|
+
"`roundtable plan --goal \"...\"` (or --prd FILE / --plan FILE)")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_agents(args: argparse.Namespace) -> int:
|
|
255
|
+
root = Path(args.project).resolve()
|
|
256
|
+
config = load_config(root)
|
|
257
|
+
if config.provider != "cli":
|
|
258
|
+
print(f"provider is {config.provider!r}; agent discovery applies to `provider: cli`.")
|
|
259
|
+
return 0
|
|
260
|
+
statuses = asyncio.run(discover(config.agents, timeout=args.timeout))
|
|
261
|
+
if args.json:
|
|
262
|
+
from dataclasses import asdict
|
|
263
|
+
import json
|
|
264
|
+
print(json.dumps([asdict(s) for s in statuses], indent=2))
|
|
265
|
+
else:
|
|
266
|
+
_print_agents(statuses, limit=None)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _print_model_groups(groups: list[modelpick.ModelGroup]) -> None:
|
|
271
|
+
if not groups:
|
|
272
|
+
print(" (no model listing for this provider)")
|
|
273
|
+
return
|
|
274
|
+
for g in groups:
|
|
275
|
+
if not g.choices:
|
|
276
|
+
print(f" {g.name}: {g.note or 'no models'}")
|
|
277
|
+
continue
|
|
278
|
+
print(f" {g.name} ({len(g.choices)} model(s)):")
|
|
279
|
+
for c in g.choices:
|
|
280
|
+
print(f" {c.label}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_models(args: argparse.Namespace) -> int:
|
|
284
|
+
root = Path(args.project).resolve()
|
|
285
|
+
config = load_config(root)
|
|
286
|
+
if config.provider not in ("pi", "cli"):
|
|
287
|
+
print(f"provider is {config.provider!r}; `roundtable models` applies to 'pi' or 'cli'.")
|
|
288
|
+
return 0
|
|
289
|
+
groups = modelpick.list_model_groups(config, timeout=args.timeout)
|
|
290
|
+
if args.json:
|
|
291
|
+
import json
|
|
292
|
+
payload = [
|
|
293
|
+
{"provider": g.name, "installed": g.installed, "note": g.note,
|
|
294
|
+
"models": [{"label": c.label, "ref": c.ref.model_dump()} for c in g.choices]}
|
|
295
|
+
for g in groups
|
|
296
|
+
]
|
|
297
|
+
print(json.dumps(payload, indent=2))
|
|
298
|
+
return 0
|
|
299
|
+
if args.list:
|
|
300
|
+
_print_model_groups(groups)
|
|
301
|
+
return 0
|
|
302
|
+
if not sys.stdin.isatty():
|
|
303
|
+
print("not a TTY — use `roundtable models --list` or `--json`, or run in an interactive terminal.")
|
|
304
|
+
return 1
|
|
305
|
+
current = modelpick.current_refs(config)
|
|
306
|
+
picks = modelpick.pick_models(groups, current)
|
|
307
|
+
if not picks:
|
|
308
|
+
print("no changes made.")
|
|
309
|
+
return 0
|
|
310
|
+
merged = {**current, **picks}
|
|
311
|
+
modelpick.update_config_models(root / "roundtable.config.yaml", merged)
|
|
312
|
+
print(f"\nupdated `models:` in {root / 'roundtable.config.yaml'}:")
|
|
313
|
+
for r in modelpick.ROLES:
|
|
314
|
+
print(f" {r}: {modelpick.fmt_ref(merged.get(r))}")
|
|
315
|
+
if args.verify:
|
|
316
|
+
modelpick.verify_refs(config, picks)
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cmd_map(args: argparse.Namespace) -> int:
|
|
321
|
+
root = Path(args.project).resolve()
|
|
322
|
+
target = Path(args.target).resolve() if args.target else root
|
|
323
|
+
config = load_config(root)
|
|
324
|
+
store = Store(root)
|
|
325
|
+
asyncio.run(map_project(
|
|
326
|
+
store, config, target=target, analyst_model=args.model,
|
|
327
|
+
max_files=args.max_files, max_bytes=args.max_bytes,
|
|
328
|
+
))
|
|
329
|
+
prd_path = store.docs_dir / "PRD.md"
|
|
330
|
+
print(f"mapped {target}")
|
|
331
|
+
print(f"wrote {store.docs_dir / 'ARCHITECTURE.md'} and {prd_path}")
|
|
332
|
+
print(f"review/edit {prd_path}, then: "
|
|
333
|
+
f"roundtable plan --prd {prd_path} --project {args.project}")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
338
|
+
root = Path(args.project).resolve()
|
|
339
|
+
config = load_config(root)
|
|
340
|
+
store = Store(root)
|
|
341
|
+
plan = asyncio.run(make_plan(
|
|
342
|
+
store, config, goal=args.goal, prd_path=args.prd, plan_path=args.plan,
|
|
343
|
+
planner_model=args.model,
|
|
344
|
+
))
|
|
345
|
+
print(f"planned {len(plan.phases)} phase(s) for: {plan.goal}")
|
|
346
|
+
for p in plan.phases:
|
|
347
|
+
print(f" [{p.id}] {p.title} ({len(p.tasks)} task(s), runner={p.runner})")
|
|
348
|
+
print(f"\nwrote {store.manifest_path}")
|
|
349
|
+
print(f"review {store.plan_dir / 'PLAN.md'}, then: roundtable approve --project {args.project}")
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def cmd_approve(args: argparse.Namespace) -> int:
|
|
354
|
+
root = Path(args.project).resolve()
|
|
355
|
+
store = Store(root)
|
|
356
|
+
plan = store.load_plan()
|
|
357
|
+
plan.approved = True
|
|
358
|
+
store.save_plan(plan)
|
|
359
|
+
print("plan approved. run it with: roundtable run --project", args.project)
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
364
|
+
root = Path(args.project).resolve()
|
|
365
|
+
config = load_config(root)
|
|
366
|
+
store = Store(root)
|
|
367
|
+
|
|
368
|
+
existing = runctl.current_run_pid(store)
|
|
369
|
+
if existing is not None:
|
|
370
|
+
print(
|
|
371
|
+
f"error: a run is already in progress (pid={existing}); "
|
|
372
|
+
"wait for it or cancel it with `roundtable stop`",
|
|
373
|
+
file=sys.stderr,
|
|
374
|
+
)
|
|
375
|
+
return 2
|
|
376
|
+
|
|
377
|
+
if args.approve:
|
|
378
|
+
# Best-effort auto-approve; a missing/invalid plan is surfaced by the engine.
|
|
379
|
+
try:
|
|
380
|
+
plan = store.load_plan()
|
|
381
|
+
if not plan.approved:
|
|
382
|
+
plan.approved = True
|
|
383
|
+
store.save_plan(plan)
|
|
384
|
+
print("plan auto-approved via --approve flag.")
|
|
385
|
+
except (FileNotFoundError, RoundtableError):
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
provider = build_provider(config, root)
|
|
389
|
+
engine = Engine(store, config, provider)
|
|
390
|
+
store.write_run_pid(os.getpid()) # this process IS the run; guards double-launch
|
|
391
|
+
try:
|
|
392
|
+
return asyncio.run(_run_with_progress(engine, store, args))
|
|
393
|
+
except asyncio.CancelledError:
|
|
394
|
+
print("\nterminated — re-run `roundtable run` to resume", file=sys.stderr)
|
|
395
|
+
return 143
|
|
396
|
+
except KeyboardInterrupt:
|
|
397
|
+
print("\ninterrupted — re-run `roundtable run` to resume", file=sys.stderr)
|
|
398
|
+
return 130
|
|
399
|
+
finally:
|
|
400
|
+
store.clear_run_pid()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def _run_with_progress(engine: Engine, store: Store, args: argparse.Namespace) -> int:
|
|
404
|
+
"""Drive the engine while showing live progress: a web dashboard link up front
|
|
405
|
+
and the terminal ``watch`` view rendered inline (TTY only)."""
|
|
406
|
+
live = sys.stdout.isatty() and not args.no_watch
|
|
407
|
+
|
|
408
|
+
# Serve the web dashboard in a background thread so the link is live as the run
|
|
409
|
+
# goes. Port 0 picks a free port, side-stepping any port collision.
|
|
410
|
+
httpd = url = None
|
|
411
|
+
if not args.no_dashboard:
|
|
412
|
+
try:
|
|
413
|
+
httpd, url = make_server(store, host=args.host, port=args.port)
|
|
414
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
415
|
+
if args.open:
|
|
416
|
+
import webbrowser
|
|
417
|
+
webbrowser.open(url)
|
|
418
|
+
except RoundtableError as e:
|
|
419
|
+
print(f"(web dashboard unavailable: {e})", file=sys.stderr)
|
|
420
|
+
|
|
421
|
+
def banner() -> str:
|
|
422
|
+
web = f"dashboard: {url}" if url else "dashboard: off (--no-dashboard)"
|
|
423
|
+
return f"{web} · Ctrl-C to stop (resumable)"
|
|
424
|
+
|
|
425
|
+
print(banner()) # show the link immediately, before the first render
|
|
426
|
+
|
|
427
|
+
task = asyncio.create_task(engine.run())
|
|
428
|
+
loop = asyncio.get_running_loop()
|
|
429
|
+
terminated = False
|
|
430
|
+
|
|
431
|
+
def _on_sigterm() -> None:
|
|
432
|
+
nonlocal terminated
|
|
433
|
+
terminated = True
|
|
434
|
+
task.cancel()
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
loop.add_signal_handler(signal.SIGTERM, _on_sigterm)
|
|
438
|
+
has_sigterm_handler = True
|
|
439
|
+
except (NotImplementedError, RuntimeError):
|
|
440
|
+
has_sigterm_handler = False
|
|
441
|
+
try:
|
|
442
|
+
if live:
|
|
443
|
+
while not task.done():
|
|
444
|
+
try:
|
|
445
|
+
frame = render_text(build_state(store))
|
|
446
|
+
except Exception: # transient read during an engine write
|
|
447
|
+
frame = "loading…"
|
|
448
|
+
sys.stdout.write("\x1b[2J\x1b[H" + banner() + "\n\n" + frame + "\n")
|
|
449
|
+
sys.stdout.flush()
|
|
450
|
+
await asyncio.sleep(max(0.1, args.interval))
|
|
451
|
+
plan = await task # awaits completion; re-raises any engine error
|
|
452
|
+
except asyncio.CancelledError:
|
|
453
|
+
# Ctrl-C: asyncio.run cancels this coroutine (KeyboardInterrupt itself
|
|
454
|
+
# surfaces in cmd_run, not here). Forward the cancellation to the engine
|
|
455
|
+
# so it can mark the manifest resumable before we go.
|
|
456
|
+
task.cancel()
|
|
457
|
+
try:
|
|
458
|
+
await task
|
|
459
|
+
except BaseException:
|
|
460
|
+
pass
|
|
461
|
+
if terminated:
|
|
462
|
+
print("\nterminated — re-run `roundtable run` to resume", file=sys.stderr)
|
|
463
|
+
return 143
|
|
464
|
+
raise
|
|
465
|
+
finally:
|
|
466
|
+
if has_sigterm_handler:
|
|
467
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
468
|
+
if httpd:
|
|
469
|
+
httpd.shutdown()
|
|
470
|
+
httpd.server_close()
|
|
471
|
+
|
|
472
|
+
if live:
|
|
473
|
+
try:
|
|
474
|
+
sys.stdout.write("\x1b[2J\x1b[H" + render_text(build_state(store)) + "\n")
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
print(f"\nrun complete: {len(plan.phases)} phase(s), status={plan.status.value}")
|
|
478
|
+
print(f"docs: {store.docs_dir}")
|
|
479
|
+
return 0
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
483
|
+
root = Path(args.project).resolve()
|
|
484
|
+
store = Store(root)
|
|
485
|
+
|
|
486
|
+
if args.json:
|
|
487
|
+
import json as _json
|
|
488
|
+
state = build_state(store)
|
|
489
|
+
print(_json.dumps(state, indent=2))
|
|
490
|
+
return 0
|
|
491
|
+
|
|
492
|
+
plan = store.load_plan()
|
|
493
|
+
mark = {
|
|
494
|
+
Status.done: "[x]", Status.in_progress: "[~]", Status.pending: "[ ]",
|
|
495
|
+
Status.failed: "[!]", Status.skipped: "[-]", Status.waiting: "[?]",
|
|
496
|
+
}
|
|
497
|
+
print(f"goal: {plan.goal}")
|
|
498
|
+
print(f"role runners: {({k: str(v) for k, v in plan.runners.items()})}")
|
|
499
|
+
print(f"approved: {plan.approved} | status: {plan.status.value}\n")
|
|
500
|
+
for p in plan.phases:
|
|
501
|
+
print(f"{mark[p.status]} Phase {p.index}: {p.title} [{p.id}]")
|
|
502
|
+
for t in p.tasks:
|
|
503
|
+
dep = f" <- {','.join(t.depends_on)}" if t.depends_on else ""
|
|
504
|
+
print(f" {mark[t.status]} {t.title} [{t.id}] ({t.runner}){dep}")
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
509
|
+
store = Store(Path(args.project).resolve())
|
|
510
|
+
httpd, url = make_server(store, host=args.host, port=args.port)
|
|
511
|
+
print(f"dashboard live at {url} (Ctrl-C to stop)")
|
|
512
|
+
print("watching .roundtable/ — run `roundtable run` in another terminal to see it update")
|
|
513
|
+
if args.open:
|
|
514
|
+
import webbrowser
|
|
515
|
+
webbrowser.open(url)
|
|
516
|
+
try:
|
|
517
|
+
httpd.serve_forever()
|
|
518
|
+
except KeyboardInterrupt:
|
|
519
|
+
print("\nstopping dashboard")
|
|
520
|
+
finally:
|
|
521
|
+
httpd.shutdown()
|
|
522
|
+
httpd.server_close()
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
527
|
+
"""Dashboard + REST control API with a machine-readable first line.
|
|
528
|
+
|
|
529
|
+
The desktop app spawns `roundtable serve --project X --port 0` and parses the
|
|
530
|
+
first stdout line (JSON) for the URL; humans get a friendly line after it.
|
|
531
|
+
"""
|
|
532
|
+
import json as _json
|
|
533
|
+
store = Store(Path(args.project).resolve())
|
|
534
|
+
httpd, url = make_server(store, host=args.host, port=args.port)
|
|
535
|
+
print(_json.dumps({"event": "serving", "url": url, "project": str(store.root)}), flush=True)
|
|
536
|
+
print(f"roundtable API + dashboard live at {url} (Ctrl-C to stop)", flush=True)
|
|
537
|
+
try:
|
|
538
|
+
httpd.serve_forever()
|
|
539
|
+
except KeyboardInterrupt:
|
|
540
|
+
pass
|
|
541
|
+
finally:
|
|
542
|
+
httpd.shutdown()
|
|
543
|
+
httpd.server_close()
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def cmd_resume(args: argparse.Namespace) -> int:
|
|
548
|
+
store = Store(Path(args.project).resolve())
|
|
549
|
+
print(runctl.approve_hitl(store, args.task))
|
|
550
|
+
return 0
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def cmd_stop(args: argparse.Namespace) -> int:
|
|
554
|
+
store = Store(Path(args.project).resolve())
|
|
555
|
+
stopped, msg = runctl.stop_run(store)
|
|
556
|
+
print(msg)
|
|
557
|
+
return 0 if stopped else 1
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
561
|
+
try:
|
|
562
|
+
from .mcp import run_server
|
|
563
|
+
except ImportError:
|
|
564
|
+
print("error: 'mcp' package not installed — run: pip install 'roundtable-cli[mcp]'",
|
|
565
|
+
file=sys.stderr)
|
|
566
|
+
return 2
|
|
567
|
+
run_server()
|
|
568
|
+
return 0
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
572
|
+
store = Store(Path(args.project).resolve())
|
|
573
|
+
if not store.has_plan():
|
|
574
|
+
raise RoundtableError("no plan to watch; run `roundtable plan` first")
|
|
575
|
+
try:
|
|
576
|
+
while True:
|
|
577
|
+
state = build_state(store)
|
|
578
|
+
sys.stdout.write("\x1b[2J\x1b[H") # clear screen + home
|
|
579
|
+
sys.stdout.write(render_text(state) + "\n")
|
|
580
|
+
sys.stdout.flush()
|
|
581
|
+
if state.get("status") == "done" and not args.follow:
|
|
582
|
+
break
|
|
583
|
+
time.sleep(args.interval)
|
|
584
|
+
except KeyboardInterrupt:
|
|
585
|
+
pass
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
590
|
+
p = argparse.ArgumentParser(prog="roundtable", description="Multi-LLM planning & orchestration tool")
|
|
591
|
+
p.add_argument("-v", "--verbose", action="store_true", help="enable verbose/debug logging")
|
|
592
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
593
|
+
|
|
594
|
+
sp = sub.add_parser("init", help="scaffold .roundtable/ + default config")
|
|
595
|
+
sp.add_argument("dir", nargs="?", default=".")
|
|
596
|
+
sp.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
597
|
+
sp.add_argument("--no-models", action="store_true",
|
|
598
|
+
help="skip probing installed CLIs for their model lists")
|
|
599
|
+
sp.add_argument("--models-timeout", type=float, default=15.0,
|
|
600
|
+
help="seconds to wait per CLI when listing models (default 15)")
|
|
601
|
+
sp.set_defaults(func=cmd_init)
|
|
602
|
+
|
|
603
|
+
sp = sub.add_parser("agents", help="list configured agents, which are installed, and their models")
|
|
604
|
+
sp.add_argument("--project", default=".")
|
|
605
|
+
sp.add_argument("--timeout", type=float, default=20.0,
|
|
606
|
+
help="seconds to wait per CLI when listing models (default 20)")
|
|
607
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
608
|
+
sp.set_defaults(func=cmd_agents)
|
|
609
|
+
|
|
610
|
+
sp = sub.add_parser("models", help="list connected models and pick one per role (pi/omp or cli)")
|
|
611
|
+
sp.add_argument("--project", default=".")
|
|
612
|
+
sp.add_argument("--list", action="store_true", help="just print available models, don't pick")
|
|
613
|
+
sp.add_argument("--json", action="store_true", help="machine-readable model list")
|
|
614
|
+
sp.add_argument("--verify", action="store_true",
|
|
615
|
+
help="after picking, send one tiny call to each chosen model to confirm it works")
|
|
616
|
+
sp.add_argument("--timeout", type=float, default=20.0,
|
|
617
|
+
help="seconds for the model-list query (default 20)")
|
|
618
|
+
sp.set_defaults(func=cmd_models)
|
|
619
|
+
|
|
620
|
+
sp = sub.add_parser("map", help="scan an existing project into outline docs + a PRD to confirm")
|
|
621
|
+
sp.add_argument("--project", default=".", help="roundtable root (where .roundtable/ lives)")
|
|
622
|
+
sp.add_argument("--target", default=None,
|
|
623
|
+
help="codebase to scan (default: the project root)")
|
|
624
|
+
sp.add_argument("--model", default=None,
|
|
625
|
+
help="override the analyst agent/model (default: the 'main' role)")
|
|
626
|
+
sp.add_argument("--max-files", type=int, default=400,
|
|
627
|
+
help="max files to include in the scan digest (default 400)")
|
|
628
|
+
sp.add_argument("--max-bytes", type=int, default=60000,
|
|
629
|
+
help="max total bytes of file contents in the digest (default 60000)")
|
|
630
|
+
sp.set_defaults(func=cmd_map)
|
|
631
|
+
|
|
632
|
+
sp = sub.add_parser("plan", help="generate or import a plan")
|
|
633
|
+
sp.add_argument("--goal", default=None, help="goal text")
|
|
634
|
+
sp.add_argument("--prd", default=None, help="path to a PRD / requirements file")
|
|
635
|
+
sp.add_argument("--plan", default=None, help="path to an existing plan (JSON schema or free-form)")
|
|
636
|
+
sp.add_argument("--project", default=".")
|
|
637
|
+
sp.add_argument("--model", default=None, help="override the planner model/agent")
|
|
638
|
+
sp.set_defaults(func=cmd_plan)
|
|
639
|
+
|
|
640
|
+
sp = sub.add_parser("approve", help="approve the plan (human gate)")
|
|
641
|
+
sp.add_argument("--project", default=".")
|
|
642
|
+
sp.set_defaults(func=cmd_approve)
|
|
643
|
+
|
|
644
|
+
sp = sub.add_parser("run", help="execute the approved plan (live progress + web dashboard)")
|
|
645
|
+
sp.add_argument("--project", default=".")
|
|
646
|
+
sp.add_argument("--approve", action="store_true", help="auto-approve the plan before running")
|
|
647
|
+
sp.add_argument("--no-dashboard", action="store_true",
|
|
648
|
+
help="don't serve the live web dashboard during the run")
|
|
649
|
+
sp.add_argument("--no-watch", action="store_true",
|
|
650
|
+
help="don't render the live terminal view (just run)")
|
|
651
|
+
sp.add_argument("--interval", type=float, default=1.0,
|
|
652
|
+
help="live terminal refresh seconds (default 1)")
|
|
653
|
+
sp.add_argument("--host", default="127.0.0.1", help="dashboard bind address (localhost only)")
|
|
654
|
+
sp.add_argument("--port", type=int, default=0,
|
|
655
|
+
help="dashboard port (0 = pick a free port)")
|
|
656
|
+
sp.add_argument("--open", action="store_true", help="open the dashboard in a browser")
|
|
657
|
+
sp.set_defaults(func=cmd_run)
|
|
658
|
+
|
|
659
|
+
sp = sub.add_parser("status", help="show phase/task progress")
|
|
660
|
+
sp.add_argument("--project", default=".")
|
|
661
|
+
sp.add_argument("--json", action="store_true", help="output status as JSON")
|
|
662
|
+
sp.set_defaults(func=cmd_status)
|
|
663
|
+
|
|
664
|
+
sp = sub.add_parser("dashboard", help="serve a live web dashboard of the run")
|
|
665
|
+
sp.add_argument("--project", default=".")
|
|
666
|
+
sp.add_argument("--host", default="127.0.0.1", help="bind address (localhost only)")
|
|
667
|
+
sp.add_argument("--port", type=int, default=8787)
|
|
668
|
+
sp.add_argument("--open", action="store_true", help="open the dashboard in a browser")
|
|
669
|
+
sp.set_defaults(func=cmd_dashboard)
|
|
670
|
+
|
|
671
|
+
sp = sub.add_parser(
|
|
672
|
+
"serve",
|
|
673
|
+
help="serve the dashboard + REST control API (machine-readable URL on stdout)",
|
|
674
|
+
)
|
|
675
|
+
sp.add_argument("--project", default=".")
|
|
676
|
+
sp.add_argument("--host", default="127.0.0.1", help="bind address (localhost only)")
|
|
677
|
+
sp.add_argument("--port", type=int, default=0, help="port (0 = pick a free port)")
|
|
678
|
+
sp.set_defaults(func=cmd_serve)
|
|
679
|
+
|
|
680
|
+
sp = sub.add_parser("watch", help="live terminal dashboard of the run")
|
|
681
|
+
sp.add_argument("--project", default=".")
|
|
682
|
+
sp.add_argument("--interval", type=float, default=1.0, help="refresh seconds (default 1)")
|
|
683
|
+
sp.add_argument("--follow", action="store_true", help="keep watching after the run completes")
|
|
684
|
+
sp.set_defaults(func=cmd_watch)
|
|
685
|
+
|
|
686
|
+
sp = sub.add_parser(
|
|
687
|
+
"resume",
|
|
688
|
+
help="approve a paused HITL task and let the run continue",
|
|
689
|
+
)
|
|
690
|
+
sp.add_argument("--task", required=True, help="task ID to approve (e.g. p1-t2)")
|
|
691
|
+
sp.add_argument("--project", default=".")
|
|
692
|
+
sp.set_defaults(func=cmd_resume)
|
|
693
|
+
|
|
694
|
+
sp = sub.add_parser("stop", help="stop the in-progress run/process group")
|
|
695
|
+
sp.add_argument("--project", default=".")
|
|
696
|
+
sp.set_defaults(func=cmd_stop)
|
|
697
|
+
|
|
698
|
+
sp = sub.add_parser("mcp", help="start the roundtable MCP server over stdio (requires mcp extra)")
|
|
699
|
+
sp.set_defaults(func=cmd_mcp)
|
|
700
|
+
return p
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def main(argv: list[str] | None = None) -> int:
|
|
704
|
+
import logging
|
|
705
|
+
args = build_parser().parse_args(argv)
|
|
706
|
+
level = logging.DEBUG if getattr(args, 'verbose', False) else logging.WARNING
|
|
707
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
708
|
+
try:
|
|
709
|
+
return args.func(args)
|
|
710
|
+
except RoundtableError as e:
|
|
711
|
+
print(f"error: {e}", file=sys.stderr)
|
|
712
|
+
return 2
|
|
713
|
+
except FileNotFoundError as e:
|
|
714
|
+
print(f"error: {e}", file=sys.stderr)
|
|
715
|
+
return 2
|
|
716
|
+
except KeyboardInterrupt:
|
|
717
|
+
print("\ninterrupted", file=sys.stderr)
|
|
718
|
+
return 130
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
if __name__ == "__main__":
|
|
722
|
+
raise SystemExit(main())
|