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/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)
|