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/scan.py ADDED
@@ -0,0 +1,187 @@
1
+ """Codebase scanning: turn an existing project into a compact text "digest".
2
+
3
+ The digest is the provider-agnostic input to the ``map`` command's analyst: a
4
+ pruned file tree plus the truncated contents of the files that explain a project
5
+ fastest (README, package manifests, entry points). It deliberately stays within a
6
+ byte budget so it fits in one LLM call, and is stdlib-only (no extra deps).
7
+
8
+ With ``provider: cli`` the analyst CLI agent additionally has live file access
9
+ (it runs with ``cwd`` set to the scanned project), so the digest is a reliable
10
+ floor, not a ceiling.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ # Directories we never descend into: VCS, caches, deps, build output, IDE noise.
18
+ IGNORE_DIRS = frozenset({
19
+ ".git", ".hg", ".svn", ".roundtable",
20
+ "node_modules", ".venv", "venv", "env", "__pycache__",
21
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".cache",
22
+ "dist", "build", "target", ".next", "out", "coverage", ".nyc_output",
23
+ ".idea", ".vscode", "vendor", ".gradle", ".dart_tool",
24
+ })
25
+
26
+ # Files we list in the tree but never read (binary/asset/lock noise).
27
+ SKIP_READ_EXTS = frozenset({
28
+ # images / media
29
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp",
30
+ ".mp4", ".mov", ".webm", ".mp3", ".wav", ".ogg",
31
+ # fonts
32
+ ".ttf", ".otf", ".woff", ".woff2", ".eot",
33
+ # archives / binaries
34
+ ".zip", ".tar", ".gz", ".tgz", ".bz2", ".7z", ".rar",
35
+ ".pdf", ".so", ".dylib", ".dll", ".o", ".a", ".class", ".pyc",
36
+ ".bin", ".exe", ".wasm", ".db", ".sqlite",
37
+ })
38
+ SKIP_READ_NAMES = frozenset({
39
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock",
40
+ "uv.lock", "Cargo.lock", "Gemfile.lock", "composer.lock", "go.sum",
41
+ })
42
+
43
+ # Manifest -> stack label, also the manifests we always inline.
44
+ _STACK_MANIFESTS: list[tuple[str, str]] = [
45
+ ("pyproject.toml", "Python"),
46
+ ("setup.py", "Python"),
47
+ ("requirements.txt", "Python"),
48
+ ("package.json", "Node"),
49
+ ("Cargo.toml", "Rust"),
50
+ ("go.mod", "Go"),
51
+ ("pom.xml", "JVM"),
52
+ ("build.gradle", "JVM"),
53
+ ("build.gradle.kts", "JVM"),
54
+ ("Gemfile", "Ruby"),
55
+ ("composer.json", "PHP"),
56
+ ("pubspec.yaml", "Dart"),
57
+ ]
58
+
59
+ # Entry-point filenames worth inlining when present anywhere shallow.
60
+ _ENTRYPOINT_NAMES = frozenset({
61
+ "main.py", "__main__.py", "app.py", "cli.py", "manage.py",
62
+ "index.js", "index.ts", "main.js", "main.ts", "main.go", "main.rs",
63
+ })
64
+
65
+
66
+ def detect_stack(root: Path) -> list[str]:
67
+ """Best-effort language/stack labels from manifest files at the root."""
68
+ found: list[str] = []
69
+ for name, label in _STACK_MANIFESTS:
70
+ if (root / name).exists() and label not in found:
71
+ found.append(label)
72
+ return found
73
+
74
+
75
+ def _should_read(path: Path) -> bool:
76
+ return path.suffix.lower() not in SKIP_READ_EXTS and path.name not in SKIP_READ_NAMES
77
+
78
+
79
+ def _iter_files(root: Path, *, max_files: int) -> list[Path]:
80
+ """Deterministically walk ``root``, pruning ignored dirs; cap at ``max_files``."""
81
+ out: list[Path] = []
82
+ stack: list[Path] = [root]
83
+ while stack:
84
+ d = stack.pop()
85
+ try:
86
+ entries = sorted(d.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
87
+ except (PermissionError, OSError):
88
+ continue
89
+ dirs: list[Path] = []
90
+ for e in entries:
91
+ if e.is_symlink():
92
+ continue
93
+ if e.is_dir():
94
+ if e.name not in IGNORE_DIRS:
95
+ dirs.append(e)
96
+ elif e.is_file():
97
+ out.append(e)
98
+ if len(out) >= max_files:
99
+ return out
100
+ # Push dirs reversed so the sorted order is preserved on pop (DFS).
101
+ stack.extend(reversed(dirs))
102
+ return out
103
+
104
+
105
+ def _key_files(root: Path, files: list[Path]) -> list[Path]:
106
+ """Order the files most worth inlining first: README, manifests, entrypoints."""
107
+ manifest_names = {name for name, _ in _STACK_MANIFESTS}
108
+ readmes, manifests, entrypoints = [], [], []
109
+ for f in files:
110
+ if not _should_read(f):
111
+ continue
112
+ name = f.name
113
+ if name.lower().startswith("readme"):
114
+ readmes.append(f)
115
+ elif name in manifest_names:
116
+ manifests.append(f)
117
+ elif name in _ENTRYPOINT_NAMES:
118
+ entrypoints.append(f)
119
+ # README first, then manifests, then a handful of entrypoints.
120
+ return readmes + manifests + entrypoints
121
+
122
+
123
+ def _read_text(path: Path, limit: int) -> str:
124
+ try:
125
+ data = path.read_text(encoding="utf-8", errors="replace")
126
+ except (OSError, ValueError):
127
+ return ""
128
+ if len(data) > limit:
129
+ return data[:limit] + "\n...[truncated]..."
130
+ return data
131
+
132
+
133
+ def build_digest(
134
+ root: Path,
135
+ *,
136
+ max_files: int = 400,
137
+ max_bytes_per_file: int = 8000,
138
+ max_total_bytes: int = 60000,
139
+ ) -> str:
140
+ """Build a compact Markdown digest of ``root`` for the analyst.
141
+
142
+ Sections: a stack line, a pruned file tree (capped at ``max_files``), and the
143
+ inlined contents of key files (README/manifests/entrypoints), each truncated
144
+ to ``max_bytes_per_file`` and the whole inline section bounded by
145
+ ``max_total_bytes``.
146
+ """
147
+ root = Path(root).resolve()
148
+ files = _iter_files(root, max_files=max_files)
149
+ stack = detect_stack(root) or ["unknown"]
150
+
151
+ lines: list[str] = [
152
+ f"# Codebase digest: {root.name}",
153
+ "",
154
+ f"## Stack: {', '.join(stack)}",
155
+ f"_{len(files)} files scanned (cap {max_files})._",
156
+ "",
157
+ "## File tree",
158
+ "",
159
+ ]
160
+ for f in files:
161
+ try:
162
+ rel = f.relative_to(root).as_posix()
163
+ size = f.stat().st_size
164
+ except (OSError, ValueError):
165
+ continue
166
+ lines.append(f"- {rel} ({size}B)")
167
+ if len(files) >= max_files:
168
+ lines.append(f"- …(truncated at {max_files} files)")
169
+
170
+ lines += ["", "## Key files", ""]
171
+ budget = max_total_bytes
172
+ inlined = 0
173
+ for f in _key_files(root, files):
174
+ if budget <= 0:
175
+ lines.append("_…(content budget exhausted; remaining key files omitted)_")
176
+ break
177
+ text = _read_text(f, min(max_bytes_per_file, budget))
178
+ if not text.strip():
179
+ continue
180
+ rel = f.relative_to(root).as_posix()
181
+ lines += [f"### {rel}", "", "```", text, "```", ""]
182
+ budget -= len(text)
183
+ inlined += 1
184
+ if inlined == 0:
185
+ lines.append("_(no README, manifest, or entry point found to inline)_")
186
+
187
+ return "\n".join(lines)
roundtable/store.py ADDED
@@ -0,0 +1,339 @@
1
+ """Filesystem persistence and project layout.
2
+
3
+ Layout::
4
+
5
+ <root>/
6
+ roundtable.config.yaml
7
+ plan/{BRIEF.md, PLAN.md, plan.json}
8
+ phases/phase-NN-<slug>/
9
+ PHASE.md
10
+ phase-summary.md # Phase Orchestrator -> Main report
11
+ tasks/task-NN-<slug>/
12
+ TASK.md # the task's work definition
13
+ result.md # the agent's completed work
14
+ output/ # artifacts produced by the agent
15
+ docs/ # maintained by the Main Orchestrator
16
+ runs/run.log # append-only event log
17
+ archive/<YYYY-MM-DDTHH-MM-SS>/
18
+ plan/ phases/ runs/ docs/ # snapshot of the previous run
19
+
20
+ The ``plan.json`` manifest is the source of truth; the ``*.md`` files are
21
+ human-readable renderings/definitions.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import datetime as _dt
27
+ import json
28
+ import shutil
29
+ import threading
30
+ from pathlib import Path
31
+
32
+ from .models import Phase, Plan, Task
33
+
34
+ PLAN_DIR = "plan"
35
+ PHASES_DIR = "phases"
36
+ DOCS_DIR = "docs"
37
+ RUNS_DIR = "runs"
38
+ HITL_DIR = "hitl"
39
+ MANIFEST = "plan.json"
40
+
41
+
42
+ def _atomic_write(path: Path, text: str) -> None:
43
+ """Write text to path atomically: write a sibling .tmp then rename."""
44
+ tmp = path.with_suffix(path.suffix + ".tmp")
45
+ tmp.write_text(text, encoding="utf-8")
46
+ tmp.replace(path)
47
+
48
+
49
+ def _utcnow() -> str:
50
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
51
+
52
+
53
+ class Store:
54
+ """All disk reads/writes for one roundtable project.
55
+
56
+ ``root`` is the target project directory (where agents do their work).
57
+ All roundtable artifacts live under ``root/workdir`` (default ``.roundtable``) so
58
+ they never clutter an existing repo. Only ``roundtable.config.yaml`` sits at the
59
+ project root.
60
+ """
61
+
62
+ def __init__(self, root: Path | str, workdir: str = ".roundtable"):
63
+ self.root = Path(root).resolve()
64
+ self.base = self.root / workdir
65
+ self._log_lock = threading.Lock() # serialize run.log appends across threads
66
+
67
+ # ---- paths -----------------------------------------------------------
68
+ @property
69
+ def plan_dir(self) -> Path:
70
+ return self.base / PLAN_DIR
71
+
72
+ @property
73
+ def phases_dir(self) -> Path:
74
+ return self.base / PHASES_DIR
75
+
76
+ @property
77
+ def docs_dir(self) -> Path:
78
+ return self.base / DOCS_DIR
79
+
80
+ @property
81
+ def runs_dir(self) -> Path:
82
+ return self.base / RUNS_DIR
83
+
84
+ @property
85
+ def hitl_dir(self) -> Path:
86
+ return self.base / HITL_DIR
87
+
88
+ def hitl_path(self, task_id: str) -> Path:
89
+ return self.hitl_dir / f"{task_id}.json"
90
+
91
+ @property
92
+ def run_pid_path(self) -> Path:
93
+ return self.runs_dir / "run.pid"
94
+
95
+ def write_run_pid(self, pid: int) -> None:
96
+ self.runs_dir.mkdir(parents=True, exist_ok=True)
97
+ _atomic_write(self.run_pid_path, str(pid))
98
+
99
+ def read_run_pid(self) -> int | None:
100
+ try:
101
+ return int(self.run_pid_path.read_text().strip())
102
+ except (OSError, ValueError):
103
+ return None
104
+
105
+ def clear_run_pid(self) -> None:
106
+ try:
107
+ self.run_pid_path.unlink()
108
+ except OSError:
109
+ pass
110
+
111
+ def list_waiting_checkpoints(self) -> list[dict]:
112
+ """Return all HITL checkpoints with status 'waiting'."""
113
+ results: list[dict] = []
114
+ if not self.hitl_dir.exists():
115
+ return results
116
+ for p in self.hitl_dir.glob("*.json"):
117
+ try:
118
+ data = json.loads(p.read_text())
119
+ if data.get("status") == "waiting":
120
+ results.append(data)
121
+ except (json.JSONDecodeError, OSError):
122
+ continue
123
+ return results
124
+
125
+ @property
126
+ def manifest_path(self) -> Path:
127
+ return self.plan_dir / MANIFEST
128
+
129
+ def phase_dir(self, phase: Phase) -> Path:
130
+ return self.phases_dir / phase.dir_name
131
+
132
+ def _task_index(self, phase: Phase, task: Task) -> int:
133
+ for i, t in enumerate(phase.tasks, start=1):
134
+ if t.id == task.id:
135
+ return i
136
+ raise KeyError(f"task {task.id!r} not in phase {phase.id!r}")
137
+
138
+ def task_dir(self, phase: Phase, task: Task) -> Path:
139
+ idx = self._task_index(phase, task)
140
+ return self.phase_dir(phase) / "tasks" / phase.task_dir_name(task, idx)
141
+
142
+ def task_output_dir(self, phase: Phase, task: Task) -> Path:
143
+ return self.task_dir(phase, task) / "output"
144
+
145
+ # ---- scaffolding -----------------------------------------------------
146
+ def scaffold(self) -> None:
147
+ for d in (self.plan_dir, self.phases_dir, self.docs_dir, self.runs_dir):
148
+ d.mkdir(parents=True, exist_ok=True)
149
+
150
+ def scaffold_plan_tree(self, plan: Plan) -> None:
151
+ """Create every phase/task directory for an approved plan."""
152
+ self.scaffold()
153
+ for phase in plan.phases:
154
+ self.phase_dir(phase).mkdir(parents=True, exist_ok=True)
155
+ for task in phase.tasks:
156
+ self.task_output_dir(phase, task).mkdir(parents=True, exist_ok=True)
157
+
158
+ def archive_current_run(self) -> Path | None:
159
+ """Move the current run's artifacts to .roundtable/archive/<created_at>/.
160
+
161
+ Moves plan/, phases/, runs/ entirely and the run-specific docs
162
+ (OVERVIEW.md, PROGRESS.md, FINAL.md). Project-level docs
163
+ (ARCHITECTURE.md, PRD.md) stay in place.
164
+
165
+ Returns the archive directory path, or None if there was no plan to archive.
166
+ """
167
+ if not self.has_plan():
168
+ return None
169
+
170
+ try:
171
+ ts = Plan.model_validate_json(self.manifest_path.read_text()).created_at
172
+ # Normalise to a filesystem-safe name: 2025-06-30T14-22-00
173
+ ts = ts[:19].replace(":", "-")
174
+ except Exception:
175
+ ts = _utcnow()[:19].replace(":", "-")
176
+
177
+ archive_dir = self.base / "archive" / ts
178
+ # Avoid clobbering an existing archive slot (e.g. two plans in one second)
179
+ suffix, attempt = "", 1
180
+ while (archive_dir.parent / (ts + suffix)).exists():
181
+ attempt += 1
182
+ suffix = f"-{attempt}"
183
+ archive_dir = archive_dir.parent / (ts + suffix)
184
+ archive_dir.mkdir(parents=True, exist_ok=True)
185
+
186
+ for name in ("plan", "phases", "runs"):
187
+ src = self.base / name
188
+ if src.exists():
189
+ shutil.move(str(src), str(archive_dir / name))
190
+
191
+ run_docs = ("OVERVIEW.md", "PROGRESS.md", "FINAL.md")
192
+ for doc_name in run_docs:
193
+ src = self.docs_dir / doc_name
194
+ if src.exists():
195
+ (archive_dir / "docs").mkdir(exist_ok=True)
196
+ shutil.move(str(src), str(archive_dir / "docs" / doc_name))
197
+
198
+ return archive_dir
199
+
200
+ # ---- manifest --------------------------------------------------------
201
+ def save_plan(self, plan: Plan) -> None:
202
+ self.plan_dir.mkdir(parents=True, exist_ok=True)
203
+ _atomic_write(self.manifest_path, plan.model_dump_json(indent=2))
204
+
205
+ def load_plan(self) -> Plan:
206
+ if not self.manifest_path.exists():
207
+ raise FileNotFoundError(
208
+ f"no plan manifest at {self.manifest_path}; run `roundtable plan` first"
209
+ )
210
+ return Plan.model_validate_json(self.manifest_path.read_text())
211
+
212
+ def has_plan(self) -> bool:
213
+ return self.manifest_path.exists()
214
+
215
+ # ---- documents -------------------------------------------------------
216
+ def write_brief(self, goal: str) -> Path:
217
+ self.plan_dir.mkdir(parents=True, exist_ok=True)
218
+ p = self.plan_dir / "BRIEF.md"
219
+ _atomic_write(p, f"# Brief\n\n{goal.strip()}\n")
220
+ return p
221
+
222
+ def write_plan_md(self, plan: Plan) -> Path:
223
+ self.plan_dir.mkdir(parents=True, exist_ok=True)
224
+ p = self.plan_dir / "PLAN.md"
225
+ _atomic_write(p, render_plan_md(plan))
226
+ return p
227
+
228
+ def write_phase_md(self, phase: Phase, content: str) -> Path:
229
+ d = self.phase_dir(phase)
230
+ d.mkdir(parents=True, exist_ok=True)
231
+ p = d / "PHASE.md"
232
+ _atomic_write(p, content)
233
+ return p
234
+
235
+ def write_task_def(self, phase: Phase, task: Task, content: str) -> Path:
236
+ d = self.task_dir(phase, task)
237
+ d.mkdir(parents=True, exist_ok=True)
238
+ p = d / "TASK.md"
239
+ _atomic_write(p, content)
240
+ return p
241
+
242
+ def write_result(self, phase: Phase, task: Task, content: str) -> Path:
243
+ d = self.task_dir(phase, task)
244
+ d.mkdir(parents=True, exist_ok=True)
245
+ p = d / "result.md"
246
+ _atomic_write(p, content)
247
+ return p
248
+
249
+ def write_artifact(self, phase: Phase, task: Task, name: str, content: str) -> Path:
250
+ d = self.task_output_dir(phase, task)
251
+ d.mkdir(parents=True, exist_ok=True)
252
+ p = d / name
253
+ p.parent.mkdir(parents=True, exist_ok=True)
254
+ _atomic_write(p, content)
255
+ return p
256
+
257
+ def write_phase_summary(self, phase: Phase, content: str) -> Path:
258
+ d = self.phase_dir(phase)
259
+ d.mkdir(parents=True, exist_ok=True)
260
+ p = d / "phase-summary.md"
261
+ _atomic_write(p, content)
262
+ return p
263
+
264
+ def write_doc(self, name: str, content: str) -> Path:
265
+ self.docs_dir.mkdir(parents=True, exist_ok=True)
266
+ p = self.docs_dir / name
267
+ p.parent.mkdir(parents=True, exist_ok=True)
268
+ _atomic_write(p, content)
269
+ return p
270
+
271
+ def read_doc(self, name: str) -> str:
272
+ p = self.docs_dir / name
273
+ return p.read_text() if p.exists() else ""
274
+
275
+ # ---- event log -------------------------------------------------------
276
+ def record_event(self, event_type: str, message: str = "", **fields: object) -> None:
277
+ """Append one structured JSONL event to ``runs/run.log``.
278
+
279
+ ``type`` + ``fields`` (e.g. ``task_id``, ``agent``, ``model``) let the
280
+ dashboard reconstruct live activity and timings; ``msg`` stays
281
+ human-readable for tailing the log.
282
+
283
+ Thread safety: guarded by ``self._log_lock``. Most callers run in the
284
+ asyncio event loop (single-threaded, cooperative), but streamed
285
+ ``task_output`` events arrive from the PTY drain worker thread, so the
286
+ lock is needed to keep appends from interleaving.
287
+ """
288
+ self.runs_dir.mkdir(parents=True, exist_ok=True)
289
+ rec: dict[str, object] = {"ts": _utcnow(), "type": event_type}
290
+ if message:
291
+ rec["msg"] = message
292
+ rec.update(fields)
293
+ line = json.dumps(rec) + "\n"
294
+ with self._log_lock, (self.runs_dir / "run.log").open("a") as f:
295
+ f.write(line)
296
+
297
+ def read_events(self) -> list[dict]:
298
+ """All recorded events in order; malformed/partial lines are skipped."""
299
+ p = self.runs_dir / "run.log"
300
+ if not p.exists():
301
+ return []
302
+ events: list[dict] = []
303
+ for line in p.read_text().splitlines():
304
+ line = line.strip()
305
+ if not line:
306
+ continue
307
+ try:
308
+ events.append(json.loads(line))
309
+ except json.JSONDecodeError:
310
+ continue # a concurrent writer's partial last line
311
+ return events
312
+
313
+
314
+ def render_plan_md(plan: Plan) -> str:
315
+ lines = [
316
+ "# Plan",
317
+ "",
318
+ f"**Goal:** {plan.goal}",
319
+ "",
320
+ f"- Created: {plan.created_at or 'n/a'}",
321
+ f"- Main orchestrator: `{plan.main_runner or 'n/a'}`",
322
+ f"- Approved: {plan.approved}",
323
+ "",
324
+ "## Phases",
325
+ "",
326
+ ]
327
+ for phase in plan.phases:
328
+ lines.append(f"### Phase {phase.index}: {phase.title} `[{phase.id}]`")
329
+ if phase.objective:
330
+ lines.append(f"\n{phase.objective}\n")
331
+ lines.append(f"- Phase orchestrator: `{phase.runner or 'n/a'}`")
332
+ lines.append("")
333
+ for i, task in enumerate(phase.tasks, start=1):
334
+ dep = f" (depends on: {', '.join(task.depends_on)})" if task.depends_on else ""
335
+ lines.append(f"{i}. **{task.title}** `[{task.id}]` — `{task.runner or 'n/a'}`{dep}")
336
+ if task.description:
337
+ lines.append(f" - {task.description}")
338
+ lines.append("")
339
+ return "\n".join(lines)