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/models.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Plan data model: Plan -> Phase -> Task.
|
|
2
|
+
|
|
3
|
+
The plan manifest (``plan.json``) is the source of truth for status, model
|
|
4
|
+
assignments, and task dependencies. These pydantic models (de)serialize it and
|
|
5
|
+
enforce structural invariants (unique ids, intra-phase deps, no cycles).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
from .errors import RoundtableError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Status(str, Enum):
|
|
19
|
+
pending = "pending"
|
|
20
|
+
in_progress = "in_progress"
|
|
21
|
+
waiting = "waiting"
|
|
22
|
+
done = "done"
|
|
23
|
+
failed = "failed"
|
|
24
|
+
skipped = "skipped"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentRef(BaseModel):
|
|
28
|
+
"""How to run a role: which configured CLI agent, and which model it uses.
|
|
29
|
+
|
|
30
|
+
``agent`` names an entry in the config ``agents`` map (for ``provider: cli``);
|
|
31
|
+
``model`` is the model token passed to that agent's ``{model}`` placeholder
|
|
32
|
+
(e.g. ``opus-4.8``, ``gemini-3.5-flash``). For ``provider: litellm`` there is
|
|
33
|
+
no separate command, so ``model`` (falling back to ``agent``) is the litellm
|
|
34
|
+
model string.
|
|
35
|
+
|
|
36
|
+
Accepts either an object ``{agent, model}`` or a shorthand string on input:
|
|
37
|
+
``"opencode:mimo-v2.5-pro"`` -> ``agent=opencode, model=mimo-v2.5-pro``, and a
|
|
38
|
+
bare ``"claude"`` -> ``agent=claude`` (no model). Always serializes as an
|
|
39
|
+
object so the manifest is explicit.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(protected_namespaces=())
|
|
43
|
+
|
|
44
|
+
agent: str = ""
|
|
45
|
+
model: str = ""
|
|
46
|
+
|
|
47
|
+
@model_validator(mode="before")
|
|
48
|
+
@classmethod
|
|
49
|
+
def _coerce(cls, v: object) -> object:
|
|
50
|
+
if isinstance(v, str):
|
|
51
|
+
s = v.strip()
|
|
52
|
+
if ":" in s:
|
|
53
|
+
a, m = s.split(":", 1)
|
|
54
|
+
return {"agent": a.strip(), "model": m.strip()}
|
|
55
|
+
return {"agent": s}
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
def __bool__(self) -> bool:
|
|
59
|
+
return bool(self.agent or self.model)
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return f"{self.agent}:{self.model}" if self.model else self.agent
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def slugify(text: str, max_len: int = 40) -> str:
|
|
69
|
+
"""Lowercase, hyphenated, filesystem-safe slug."""
|
|
70
|
+
s = _SLUG_RE.sub("-", text.strip().lower()).strip("-")
|
|
71
|
+
if len(s) > max_len:
|
|
72
|
+
s = s[:max_len].rstrip("-")
|
|
73
|
+
return s or "item"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Task(BaseModel):
|
|
77
|
+
id: str
|
|
78
|
+
title: str
|
|
79
|
+
slug: str = ""
|
|
80
|
+
description: str = ""
|
|
81
|
+
runner: AgentRef = Field(default_factory=AgentRef) # choosable (agent, model) for this task; backfilled if empty
|
|
82
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
83
|
+
status: Status = Status.pending
|
|
84
|
+
result_path: str | None = None
|
|
85
|
+
validate_command: list[str] | None = None # run after execution; non-zero exit = failure
|
|
86
|
+
requires_approval: bool = False # pause and wait for `roundtable resume` before executing
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def _default_slug(self) -> Task:
|
|
90
|
+
if not self.slug:
|
|
91
|
+
self.slug = slugify(self.title)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Phase(BaseModel):
|
|
96
|
+
id: str
|
|
97
|
+
index: int = 0
|
|
98
|
+
title: str
|
|
99
|
+
slug: str = ""
|
|
100
|
+
objective: str = ""
|
|
101
|
+
runner: AgentRef = Field(default_factory=AgentRef) # choosable (agent, model) for this phase orchestrator
|
|
102
|
+
tasks: list[Task] = Field(default_factory=list)
|
|
103
|
+
status: Status = Status.pending
|
|
104
|
+
summary_path: str | None = None
|
|
105
|
+
validate_command: list[str] | None = None # runs after all tasks succeed; non-zero exit fails the phase
|
|
106
|
+
|
|
107
|
+
@model_validator(mode="after")
|
|
108
|
+
def _default_slug(self) -> Phase:
|
|
109
|
+
if not self.slug:
|
|
110
|
+
self.slug = slugify(self.title)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def dir_name(self) -> str:
|
|
115
|
+
return f"phase-{self.index:02d}-{self.slug}"
|
|
116
|
+
|
|
117
|
+
def task_dir_name(self, task: Task, task_index: int) -> str:
|
|
118
|
+
return f"task-{task_index:02d}-{task.slug}"
|
|
119
|
+
|
|
120
|
+
def topological_order(self, external_ids: set[str] | None = None) -> list[Task]:
|
|
121
|
+
"""Tasks ordered so intra-phase dependencies precede dependents.
|
|
122
|
+
|
|
123
|
+
``external_ids`` are task ids defined in *other* phases that this phase's
|
|
124
|
+
tasks may depend on (cross-phase deps). They are treated as already
|
|
125
|
+
satisfied and don't participate in intra-phase ordering. Raises
|
|
126
|
+
RoundtableError on unknown dep ids or intra-phase cycles.
|
|
127
|
+
"""
|
|
128
|
+
external_ids = external_ids or set()
|
|
129
|
+
by_id = {t.id: t for t in self.tasks}
|
|
130
|
+
for t in self.tasks:
|
|
131
|
+
for dep in t.depends_on:
|
|
132
|
+
if dep not in by_id and dep not in external_ids:
|
|
133
|
+
raise RoundtableError(
|
|
134
|
+
f"task {t.id!r} depends on unknown task {dep!r} in phase {self.id!r}"
|
|
135
|
+
)
|
|
136
|
+
ordered: list[Task] = []
|
|
137
|
+
seen: set[str] = set()
|
|
138
|
+
visiting: set[str] = set()
|
|
139
|
+
|
|
140
|
+
def visit(t: Task) -> None:
|
|
141
|
+
if t.id in seen:
|
|
142
|
+
return
|
|
143
|
+
if t.id in visiting:
|
|
144
|
+
raise RoundtableError(f"dependency cycle detected at task {t.id!r}")
|
|
145
|
+
visiting.add(t.id)
|
|
146
|
+
for dep in t.depends_on:
|
|
147
|
+
if dep in by_id: # only intra-phase deps affect ordering
|
|
148
|
+
visit(by_id[dep])
|
|
149
|
+
visiting.discard(t.id)
|
|
150
|
+
seen.add(t.id)
|
|
151
|
+
ordered.append(t)
|
|
152
|
+
|
|
153
|
+
for t in self.tasks:
|
|
154
|
+
visit(t)
|
|
155
|
+
return ordered
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Plan(BaseModel):
|
|
159
|
+
goal: str
|
|
160
|
+
created_at: str = ""
|
|
161
|
+
main_runner: AgentRef = Field(default_factory=AgentRef) # choosable (agent, model) for the main orchestrator
|
|
162
|
+
runners: dict[str, AgentRef] = Field(default_factory=dict) # role defaults snapshot
|
|
163
|
+
phases: list[Phase] = Field(default_factory=list)
|
|
164
|
+
status: Status = Status.pending
|
|
165
|
+
approved: bool = False
|
|
166
|
+
|
|
167
|
+
@model_validator(mode="after")
|
|
168
|
+
def _check_unique_ids(self) -> Plan:
|
|
169
|
+
pids = [p.id for p in self.phases]
|
|
170
|
+
if len(pids) != len(set(pids)):
|
|
171
|
+
raise RoundtableError("duplicate phase ids in plan")
|
|
172
|
+
# Task ids are unique across the WHOLE plan — cross-phase deps reference
|
|
173
|
+
# tasks by id, so ids must be globally resolvable. Phase list order is the
|
|
174
|
+
# execution order; a dep may only reference the same or an earlier phase.
|
|
175
|
+
all_ids: set[str] = set()
|
|
176
|
+
task_phase_pos: dict[str, int] = {}
|
|
177
|
+
for i, p in enumerate(self.phases):
|
|
178
|
+
for t in p.tasks:
|
|
179
|
+
if t.id in all_ids:
|
|
180
|
+
raise RoundtableError(f"duplicate task id {t.id!r} in plan")
|
|
181
|
+
all_ids.add(t.id)
|
|
182
|
+
task_phase_pos[t.id] = i
|
|
183
|
+
for i, p in enumerate(self.phases):
|
|
184
|
+
# Intra-phase cycle check; cross-phase ids are allowed as externals.
|
|
185
|
+
p.topological_order(external_ids=all_ids)
|
|
186
|
+
for t in p.tasks:
|
|
187
|
+
for dep in t.depends_on:
|
|
188
|
+
if dep not in all_ids:
|
|
189
|
+
raise RoundtableError(f"task {t.id!r} depends on unknown task {dep!r}")
|
|
190
|
+
if task_phase_pos[dep] > i:
|
|
191
|
+
raise RoundtableError(
|
|
192
|
+
f"task {t.id!r} depends on {dep!r} in a later phase; "
|
|
193
|
+
"cross-phase dependencies must reference an earlier phase"
|
|
194
|
+
)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def task_by_id(self, task_id: str) -> tuple[Phase, Task] | None:
|
|
198
|
+
for p in self.phases:
|
|
199
|
+
for t in p.tasks:
|
|
200
|
+
if t.id == task_id:
|
|
201
|
+
return p, t
|
|
202
|
+
return None
|
roundtable/prompts.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""System prompts for each agent role.
|
|
2
|
+
|
|
3
|
+
Kept terse and explicit. The planner prompt pins the exact JSON contract the
|
|
4
|
+
``Plan`` model expects; the rest produce Markdown.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
PLANNER_SYSTEM = """\
|
|
10
|
+
You are the PLANNER. Turn the user's goal into a complete, executable plan.
|
|
11
|
+
|
|
12
|
+
Break the work into ordered PHASES, each a meaningful milestone. A phase holds
|
|
13
|
+
one or more TASKS. Tasks may depend on earlier tasks in the SAME phase, or on
|
|
14
|
+
tasks in an EARLIER phase (reference them by id) — never on a later phase.
|
|
15
|
+
|
|
16
|
+
Right-size tasks. One task = one coherent unit of work a single agent finishes
|
|
17
|
+
end to end, including the edits it implies. Do NOT split one logical change
|
|
18
|
+
across tasks — "add the flag", then "implement it", then "test it" for one small
|
|
19
|
+
feature is ONE task, not three. Prefer the fewest tasks that cleanly cover the
|
|
20
|
+
goal; more tasks means more cost and coordination, not more quality.
|
|
21
|
+
|
|
22
|
+
Output ONLY a single JSON object (no prose, no code fences) with this shape:
|
|
23
|
+
{
|
|
24
|
+
"goal": "<one-line restatement>",
|
|
25
|
+
"phases": [
|
|
26
|
+
{
|
|
27
|
+
"id": "p1",
|
|
28
|
+
"title": "<short phase title>",
|
|
29
|
+
"objective": "<what this phase achieves>",
|
|
30
|
+
"runner": {"agent": "<cli>", "model": "<model>"},
|
|
31
|
+
"tasks": [
|
|
32
|
+
{
|
|
33
|
+
"id": "p1-t1",
|
|
34
|
+
"title": "<short task title>",
|
|
35
|
+
"description": "<concrete, actionable definition of the work>",
|
|
36
|
+
"runner": {"agent": "<cli>", "model": "<model>"},
|
|
37
|
+
"depends_on": ["p1-..."],
|
|
38
|
+
"validate_command": ["<cmd>", "<arg>"]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Rules:
|
|
46
|
+
- ids: phases p1,p2,...; tasks <phase>-t1,<phase>-t2,... (task ids unique plan-wide).
|
|
47
|
+
- depends_on may reference task ids in the SAME phase or any EARLIER phase
|
|
48
|
+
(never a later phase); no cycles.
|
|
49
|
+
- Each "runner" picks the CLI + model that runs that phase/task. Choose ONLY
|
|
50
|
+
from the ALLOWED (agent:model) pairs provided: split each "agent:model" into
|
|
51
|
+
{"agent": ..., "model": ...}. When unsure use the given role defaults.
|
|
52
|
+
- VERIFY WITH validate_command, NOT with a task. "validate_command" (optional,
|
|
53
|
+
on a task or a phase) is an argv list run in the project root; a non-zero exit
|
|
54
|
+
fails that task/phase. Use it to check work — tests, build, typecheck, lint.
|
|
55
|
+
NEVER create a task whose job is to "run the tests"/"run pytest"/"verify":
|
|
56
|
+
that is what validate_command is for. A phase-level validate_command is the
|
|
57
|
+
phase completion gate (e.g. the full test suite after its tasks finish).
|
|
58
|
+
- Don't over-phase. For a small change, ONE phase whose implementation task
|
|
59
|
+
carries a test validate_command is enough — do not split "implement" and
|
|
60
|
+
"test" into separate phases.
|
|
61
|
+
- Keep phases in execution order. Be specific and concrete; right-size — do not
|
|
62
|
+
over-split.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
PHASE_DEFINE_SYSTEM = """\
|
|
66
|
+
You are a PHASE ORCHESTRATOR preparing one task for a sub-agent. Write a precise
|
|
67
|
+
work definition the agent can execute without further questions.
|
|
68
|
+
|
|
69
|
+
Output Markdown with sections:
|
|
70
|
+
- **Objective**: What the task must achieve.
|
|
71
|
+
- **Steps**: Numbered, concrete steps.
|
|
72
|
+
- **Inputs/Context**: What the agent should read or reference.
|
|
73
|
+
- **Expected Output Artifacts**: Specific files, sections, or deliverables the
|
|
74
|
+
agent must produce (not just "do the work" — name the artifact).
|
|
75
|
+
- **Acceptance Criteria**: How to verify the work is correct.
|
|
76
|
+
|
|
77
|
+
Be concrete. Do not do the work yourself; define it.
|
|
78
|
+
{project_context}"""
|
|
79
|
+
|
|
80
|
+
TASK_EXEC_SYSTEM = """\
|
|
81
|
+
You are a TASK AGENT executing a work definition inside a project.
|
|
82
|
+
|
|
83
|
+
Your working directory is the project root. You have access to the project's
|
|
84
|
+
files and can read/edit them directly. Use the tools available to you (file
|
|
85
|
+
editing, terminal commands, etc.) to complete the work.
|
|
86
|
+
|
|
87
|
+
Output Markdown with:
|
|
88
|
+
1. A brief summary of what you did.
|
|
89
|
+
2. The concrete work product (code written/modified, content, analysis, etc.).
|
|
90
|
+
3. Any assumptions made and remaining risks.
|
|
91
|
+
|
|
92
|
+
If you cannot complete the work, explain clearly what went wrong and why.
|
|
93
|
+
{project_context}"""
|
|
94
|
+
|
|
95
|
+
PHASE_SUMMARY_SYSTEM = """\
|
|
96
|
+
You are a PHASE ORCHESTRATOR. Summarize the completed phase for the MAIN
|
|
97
|
+
ORCHESTRATOR. Be concise and decision-useful: what was accomplished, key
|
|
98
|
+
outputs, decisions, and anything Main needs for documentation or later phases.
|
|
99
|
+
Do NOT paste full task transcripts; synthesize.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
MAIN_KICKOFF_SYSTEM = """\
|
|
103
|
+
You are the MAIN ORCHESTRATOR. Write a short project overview document to seed
|
|
104
|
+
the docs: the goal, the phase roadmap, and how progress will be tracked.
|
|
105
|
+
Output Markdown.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
MAIN_INTEGRATE_SYSTEM = """\
|
|
109
|
+
You are the MAIN ORCHESTRATOR maintaining the project documentation. Given a
|
|
110
|
+
completed phase summary, write a concise progress entry capturing what changed
|
|
111
|
+
and any decisions worth recording. Output a short Markdown entry only.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
MAIN_FINALIZE_SYSTEM = """\
|
|
115
|
+
You are the MAIN ORCHESTRATOR. All phases are complete. Write a final report:
|
|
116
|
+
goal, what was delivered per phase, notable decisions, and follow-ups.
|
|
117
|
+
Output Markdown.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
MAP_ARCH_SYSTEM = """\
|
|
121
|
+
You are a STAFF ENGINEER mapping an unfamiliar codebase. From the provided
|
|
122
|
+
codebase digest (a pruned file tree plus key file contents), write a clear
|
|
123
|
+
architecture overview that makes the project understandable fast.
|
|
124
|
+
|
|
125
|
+
Output Markdown with sections: Purpose, Tech stack, Module / component map (a
|
|
126
|
+
table of path -> responsibility), Data / control flow, Entry points & how to run,
|
|
127
|
+
Notable conventions, Risks / unknowns. Cite real file paths from the digest. Be
|
|
128
|
+
concrete; do NOT invent files or behavior. Mark anything you are unsure of as an
|
|
129
|
+
explicit assumption rather than stating it as fact.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
MAP_PRD_SYSTEM = """\
|
|
133
|
+
You are a PRODUCT ENGINEER reverse-engineering a PRD (product requirements
|
|
134
|
+
document) from an existing codebase digest and its architecture overview. Infer
|
|
135
|
+
what the product does and why it exists FROM THE CODE — do not invent a roadmap.
|
|
136
|
+
|
|
137
|
+
Output Markdown with sections: Summary, Problem / Goals, Users, Current Features &
|
|
138
|
+
Capabilities (each derived from concrete code, with file paths), Non-functional
|
|
139
|
+
constraints, Out of scope / not yet built, and Open questions & assumptions.
|
|
140
|
+
|
|
141
|
+
This PRD is REVERSE-ENGINEERED and may be wrong: clearly flag every inference in
|
|
142
|
+
the "Open questions & assumptions" section. State up top that a human must review
|
|
143
|
+
and edit this document before it is used to drive planning.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
PHASE_REPLAN_SYSTEM = """\
|
|
147
|
+
You are a PHASE ORCHESTRATOR. A task in your phase has failed. Adapt the
|
|
148
|
+
remaining tasks so the phase can still achieve its objective.
|
|
149
|
+
|
|
150
|
+
Review the failed task and its output, then return a JSON object mapping task IDs
|
|
151
|
+
(from the remaining tasks list) to updated descriptions. Only include tasks that
|
|
152
|
+
genuinely need adjustment given the failure. Return an empty object if no changes
|
|
153
|
+
are needed.
|
|
154
|
+
|
|
155
|
+
Output ONLY a single JSON object (no prose, no code fences):
|
|
156
|
+
{"<task_id>": "<updated description>", ...}
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
PLAN_IMPORT_SYSTEM = """\
|
|
160
|
+
You are importing an EXISTING plan or requirements/PRD document. Convert it into
|
|
161
|
+
the roundtable plan JSON WITHOUT inventing or dropping scope: preserve the author's
|
|
162
|
+
phases, tasks, and steps as faithfully as possible; only structure them and fill
|
|
163
|
+
in ids, dependencies, and model assignments.
|
|
164
|
+
|
|
165
|
+
Output ONLY a single JSON object (no prose, no code fences) with this shape:
|
|
166
|
+
{
|
|
167
|
+
"goal": "<one-line restatement>",
|
|
168
|
+
"phases": [
|
|
169
|
+
{
|
|
170
|
+
"id": "p1", "title": "...", "objective": "...",
|
|
171
|
+
"runner": {"agent": "<cli>", "model": "<model>"},
|
|
172
|
+
"tasks": [
|
|
173
|
+
{
|
|
174
|
+
"id": "p1-t1", "title": "...", "description": "...",
|
|
175
|
+
"runner": {"agent": "<cli>", "model": "<model>"},
|
|
176
|
+
"depends_on": ["p1-..."]
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
Rules: ids p1,p2,... and <phase>-t1,... (unique plan-wide); depends_on may
|
|
184
|
+
reference the same or an earlier phase (never a later one); no cycles; each
|
|
185
|
+
"runner" picks a CLI + model ONLY from the ALLOWED (agent:model)
|
|
186
|
+
pairs (split "agent:model" into {"agent": ..., "model": ...}; default to the
|
|
187
|
+
given role defaults). Keep the author's ordering.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_prompt(template: str, *, project_context: str = "") -> str:
|
|
192
|
+
"""Interpolate prompt template variables.
|
|
193
|
+
|
|
194
|
+
Currently supports ``{project_context}`` — inserted as a clearly labelled
|
|
195
|
+
block when non-empty, omitted entirely when empty.
|
|
196
|
+
"""
|
|
197
|
+
ctx_block = ""
|
|
198
|
+
if project_context:
|
|
199
|
+
ctx_block = (
|
|
200
|
+
"\n\nPROJECT CONTEXT (provided by the user — use this to inform your work):\n"
|
|
201
|
+
+ project_context.strip()
|
|
202
|
+
+ "\n"
|
|
203
|
+
)
|
|
204
|
+
return template.replace("{project_context}", ctx_block)
|
|
205
|
+
|
roundtable/runctl.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Run control shared by the CLI, the MCP server, and the REST API.
|
|
2
|
+
|
|
3
|
+
One place owns the ``run.pid`` protocol:
|
|
4
|
+
|
|
5
|
+
* a live run writes its pid to ``.roundtable/runs/run.pid`` and clears it on exit;
|
|
6
|
+
* starting a run (attached or detached) first checks for a live pid and refuses
|
|
7
|
+
to double-launch;
|
|
8
|
+
* ``stop_run`` SIGTERMs the recorded pid's process group when possible.
|
|
9
|
+
|
|
10
|
+
Detached launches (MCP / REST) spawn ``python -m roundtable.cli run`` in a new
|
|
11
|
+
session so the run outlives the server that started it; its output is appended
|
|
12
|
+
to ``.roundtable/runs/run.out`` for later inspection. Plan generation can likewise
|
|
13
|
+
be launched detached (``start_plan``) since planning may take minutes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .errors import RoundtableError
|
|
26
|
+
from .store import Store
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pid_alive(pid: int) -> bool:
|
|
30
|
+
"""True if ``pid`` is a live process (or one we may not signal)."""
|
|
31
|
+
try:
|
|
32
|
+
os.kill(pid, 0)
|
|
33
|
+
return True
|
|
34
|
+
except ProcessLookupError:
|
|
35
|
+
return False
|
|
36
|
+
except PermissionError:
|
|
37
|
+
return True # exists but owned by someone else — treat as alive
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def current_run_pid(store: Store) -> int | None:
|
|
41
|
+
"""Pid of a live in-progress run, or None. Stale pid files are cleaned up.
|
|
42
|
+
|
|
43
|
+
The calling process itself never counts as "another run" — the run process
|
|
44
|
+
checks the pid file that its (MCP/REST) launcher may have already written.
|
|
45
|
+
"""
|
|
46
|
+
pid = store.read_run_pid()
|
|
47
|
+
if pid is None or pid == os.getpid():
|
|
48
|
+
return None
|
|
49
|
+
if pid_alive(pid):
|
|
50
|
+
return pid
|
|
51
|
+
store.clear_run_pid()
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detached(cmd: list[str], log_path: Path) -> subprocess.Popen:
|
|
56
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with log_path.open("a") as log:
|
|
58
|
+
return subprocess.Popen(
|
|
59
|
+
cmd,
|
|
60
|
+
stdout=log,
|
|
61
|
+
stderr=subprocess.STDOUT,
|
|
62
|
+
stdin=subprocess.DEVNULL,
|
|
63
|
+
start_new_session=True, # detach: survive the launching server
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def start_run(store: Store, *, approve: bool = False) -> tuple[int | None, str]:
|
|
68
|
+
"""Spawn a detached ``roundtable run``; returns ``(pid, message)``.
|
|
69
|
+
|
|
70
|
+
``pid`` is None when the launch was refused (a run is already live).
|
|
71
|
+
"""
|
|
72
|
+
existing = current_run_pid(store)
|
|
73
|
+
if existing is not None:
|
|
74
|
+
return None, (
|
|
75
|
+
f"run already in progress (pid={existing}); "
|
|
76
|
+
"stop it first or wait for it to finish"
|
|
77
|
+
)
|
|
78
|
+
cmd = [
|
|
79
|
+
sys.executable, "-m", "roundtable.cli", "run",
|
|
80
|
+
"--project", str(store.root), "--no-watch", "--no-dashboard",
|
|
81
|
+
]
|
|
82
|
+
if approve:
|
|
83
|
+
cmd.append("--approve")
|
|
84
|
+
proc = _detached(cmd, store.runs_dir / "run.out")
|
|
85
|
+
store.write_run_pid(proc.pid)
|
|
86
|
+
return proc.pid, f"run started (pid={proc.pid})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stop_run(store: Store) -> tuple[bool, str]:
|
|
90
|
+
"""SIGTERM the recorded run pid/process group; returns ``(stopped, message)``."""
|
|
91
|
+
pid = store.read_run_pid()
|
|
92
|
+
if pid is None:
|
|
93
|
+
return False, "no run.pid found — no run appears to be in progress"
|
|
94
|
+
try:
|
|
95
|
+
os.kill(pid, 0)
|
|
96
|
+
except ProcessLookupError:
|
|
97
|
+
store.clear_run_pid()
|
|
98
|
+
return False, f"process {pid} is not running (stale pid file cleaned up)"
|
|
99
|
+
except PermissionError:
|
|
100
|
+
return False, f"cannot signal process {pid} (permission denied)"
|
|
101
|
+
target = f"process {pid}"
|
|
102
|
+
try:
|
|
103
|
+
pgid = os.getpgid(pid)
|
|
104
|
+
if pgid != os.getpgrp():
|
|
105
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
106
|
+
target = f"process group {pgid}"
|
|
107
|
+
else:
|
|
108
|
+
os.kill(pid, signal.SIGTERM)
|
|
109
|
+
except ProcessLookupError:
|
|
110
|
+
store.clear_run_pid()
|
|
111
|
+
return False, f"process {pid} is not running (stale pid file cleaned up)"
|
|
112
|
+
except OSError as e:
|
|
113
|
+
return False, f"could not stop process {pid}: {e}"
|
|
114
|
+
return True, f"sent SIGTERM to {target}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def approve_hitl(store: Store, task_id: str) -> str:
|
|
118
|
+
"""Approve a waiting HITL checkpoint so the paused run continues."""
|
|
119
|
+
checkpoint = store.hitl_path(task_id)
|
|
120
|
+
if not checkpoint.exists():
|
|
121
|
+
raise RoundtableError(
|
|
122
|
+
f"no pending approval checkpoint for task {task_id!r}; "
|
|
123
|
+
f"is the run paused and waiting at that task?"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(checkpoint.read_text())
|
|
127
|
+
except (ValueError, OSError) as e:
|
|
128
|
+
raise RoundtableError(f"could not read checkpoint for task {task_id!r}: {e}") from e
|
|
129
|
+
if data.get("status") != "waiting":
|
|
130
|
+
raise RoundtableError(
|
|
131
|
+
f"task {task_id!r} checkpoint has status {data.get('status')!r}, expected 'waiting'"
|
|
132
|
+
)
|
|
133
|
+
data["status"] = "approved"
|
|
134
|
+
checkpoint.write_text(json.dumps(data))
|
|
135
|
+
return f"task {task_id!r} approved — run will continue"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------- #
|
|
139
|
+
# Detached plan generation (planning can take minutes; the API must not block)
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
def _plan_pid_path(store: Store) -> Path:
|
|
142
|
+
return store.runs_dir / "plan.pid"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _plan_log_path(store: Store) -> Path:
|
|
146
|
+
return store.runs_dir / "plan.log"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def start_plan(
|
|
150
|
+
store: Store,
|
|
151
|
+
*,
|
|
152
|
+
goal: str | None = None,
|
|
153
|
+
prd: str | None = None,
|
|
154
|
+
plan_file: str | None = None,
|
|
155
|
+
model: str | None = None,
|
|
156
|
+
) -> tuple[int | None, str]:
|
|
157
|
+
"""Spawn a detached ``roundtable plan``; returns ``(pid, message)``."""
|
|
158
|
+
pid = plan_pid(store)
|
|
159
|
+
if pid is not None:
|
|
160
|
+
return None, f"plan generation already in progress (pid={pid})"
|
|
161
|
+
if not (goal or prd or plan_file):
|
|
162
|
+
raise RoundtableError("nothing to plan from: pass goal, prd, or plan_file")
|
|
163
|
+
cmd = [sys.executable, "-m", "roundtable.cli", "plan", "--project", str(store.root)]
|
|
164
|
+
if goal:
|
|
165
|
+
cmd += ["--goal", goal]
|
|
166
|
+
if prd:
|
|
167
|
+
cmd += ["--prd", prd]
|
|
168
|
+
if plan_file:
|
|
169
|
+
cmd += ["--plan", plan_file]
|
|
170
|
+
if model:
|
|
171
|
+
cmd += ["--model", model]
|
|
172
|
+
log = _plan_log_path(store)
|
|
173
|
+
try:
|
|
174
|
+
log.unlink() # fresh log per generation
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
proc = _detached(cmd, log)
|
|
178
|
+
store.runs_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
_plan_pid_path(store).write_text(str(proc.pid))
|
|
180
|
+
return proc.pid, f"plan generation started (pid={proc.pid})"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def plan_pid(store: Store) -> int | None:
|
|
184
|
+
"""Pid of a live detached plan generation, or None (stale files cleaned)."""
|
|
185
|
+
p = _plan_pid_path(store)
|
|
186
|
+
try:
|
|
187
|
+
pid = int(p.read_text().strip())
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
return None
|
|
190
|
+
if pid_alive(pid):
|
|
191
|
+
return pid
|
|
192
|
+
try:
|
|
193
|
+
p.unlink()
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def plan_status(store: Store) -> dict:
|
|
200
|
+
"""Status of a detached plan generation: running flag, log tail, plan presence."""
|
|
201
|
+
log = _plan_log_path(store)
|
|
202
|
+
tail = ""
|
|
203
|
+
if log.exists():
|
|
204
|
+
try:
|
|
205
|
+
tail = log.read_text()[-2000:]
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
return {
|
|
209
|
+
"running": plan_pid(store) is not None,
|
|
210
|
+
"has_plan": store.has_plan(),
|
|
211
|
+
"log_tail": tail,
|
|
212
|
+
}
|