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/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
+ }