probiotic 0.1.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.
- probiotic/__init__.py +6 -0
- probiotic/cli.py +390 -0
- probiotic/dna/__init__.py +4 -0
- probiotic/dna/binary_finder.py +146 -0
- probiotic/dna/config.py +113 -0
- probiotic/dna/hive_client.py +119 -0
- probiotic/dna/journal.py +177 -0
- probiotic/dna/lock.py +54 -0
- probiotic/dna/prompts.py +167 -0
- probiotic/dna/runtime.py +266 -0
- probiotic/dna/scheduler.py +162 -0
- probiotic/dna/tickets.py +72 -0
- probiotic/dna/tools/__init__.py +2 -0
- probiotic/dna/tools/usage.py +215 -0
- probiotic/templates/__init__.py +2 -0
- probiotic/templates/_base/SOUL.md.j2 +7 -0
- probiotic/templates/_base/agent.md.j2 +25 -0
- probiotic/templates/_base/cell.toml.default +13 -0
- probiotic/templates/commit/SOUL.md +4 -0
- probiotic/templates/commit/agent.md.j2 +22 -0
- probiotic/templates/commit/cell.toml.default +13 -0
- probiotic/templates/documentation/SOUL.md +4 -0
- probiotic/templates/documentation/agent.md.j2 +23 -0
- probiotic/templates/documentation/cell.toml.default +13 -0
- probiotic/templates/janitor/SOUL.md +7 -0
- probiotic/templates/janitor/agent.md.j2 +35 -0
- probiotic/templates/janitor/cell.toml.default +13 -0
- probiotic/templates/manager/SOUL.md +8 -0
- probiotic/templates/manager/agent.md.j2 +35 -0
- probiotic/templates/manager/cell.toml.default +13 -0
- probiotic/templates/progress_tracker/SOUL.md +4 -0
- probiotic/templates/progress_tracker/agent.md.j2 +29 -0
- probiotic/templates/progress_tracker/cell.toml.default +13 -0
- probiotic/templates/researcher/SOUL.md +4 -0
- probiotic/templates/researcher/agent.md.j2 +42 -0
- probiotic/templates/researcher/cell.toml.default +13 -0
- probiotic-0.1.0.dist-info/METADATA +78 -0
- probiotic-0.1.0.dist-info/RECORD +40 -0
- probiotic-0.1.0.dist-info/WHEEL +4 -0
- probiotic-0.1.0.dist-info/entry_points.txt +2 -0
probiotic/__init__.py
ADDED
probiotic/cli.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .dna.config import (
|
|
13
|
+
DEFAULT_RUN_ORDER,
|
|
14
|
+
default_cell_config_text,
|
|
15
|
+
default_instance_config_text,
|
|
16
|
+
nested_get,
|
|
17
|
+
read_toml,
|
|
18
|
+
write_if_missing,
|
|
19
|
+
)
|
|
20
|
+
from .dna.hive_client import HiveConfig, check_updates
|
|
21
|
+
from .dna.prompts import TemplateContext, render_text
|
|
22
|
+
from .dna.runtime import RuntimePaths, run_cell
|
|
23
|
+
from .dna.scheduler import ordered_cells, run_nightly
|
|
24
|
+
from .dna.tools.usage import cmd_usage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ROOT_FILES = {
|
|
28
|
+
"todos.md": "# Todos\n\n## Open\n\n## Finished\n",
|
|
29
|
+
"proposals.md": "# Proposals queue\n\n## Pending\n\n*(none)*\n",
|
|
30
|
+
"progressSummary.md": "# Progress summary\n\nNo runs yet.\n",
|
|
31
|
+
"Changelog.md": "# Changelog\n\n## [Unreleased]\n",
|
|
32
|
+
"TICKET_FORMAT.md": "# Ticket format\n\nOne request per Markdown file in a receiver's `tickets/` directory.\n",
|
|
33
|
+
".gitignore": "cells/*/__pycache__/\ncells/*/*.lock\ncells/*/*.log\nnightly.log\n",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
LEGACY_CELL_MAP = {
|
|
37
|
+
"manager": ("manager", "manager.md", "claude"),
|
|
38
|
+
"researcher": ("researcher", "researcher.md", "codex"),
|
|
39
|
+
"documentation": ("documentation", "documentation.md", "codex"),
|
|
40
|
+
"Janitor": ("janitor", "janitor.md", "codex"),
|
|
41
|
+
"commit": ("commit", "commit.md", "codex"),
|
|
42
|
+
"progressTracker": ("progress_tracker", "tracker.md", "codex"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _templates_root():
|
|
47
|
+
return resources.files("probiotic.templates")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def available_templates() -> list[str]:
|
|
51
|
+
root = _templates_root()
|
|
52
|
+
return sorted(
|
|
53
|
+
child.name
|
|
54
|
+
for child in root.iterdir()
|
|
55
|
+
if child.is_dir() and child.name != "__pycache__" and not child.name.startswith(".")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _template_file(template_name: str, filename: str):
|
|
60
|
+
return _templates_root().joinpath(template_name, filename)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_template_text(template_name: str, filename: str) -> str | None:
|
|
64
|
+
resource = _template_file(template_name, filename)
|
|
65
|
+
if resource.is_file():
|
|
66
|
+
return resource.read_text(encoding="utf-8")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _extra_render(text: str, values: dict[str, str]) -> str:
|
|
71
|
+
rendered = text
|
|
72
|
+
for key, value in values.items():
|
|
73
|
+
rendered = rendered.replace("{{" + key + "}}", value)
|
|
74
|
+
return rendered
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _template_hash(template_name: str) -> str:
|
|
78
|
+
digest = hashlib.sha256()
|
|
79
|
+
for filename in ("agent.md.j2", "SOUL.md", "SOUL.md.j2", "cell.toml.default"):
|
|
80
|
+
text = _read_template_text(template_name, filename)
|
|
81
|
+
if text:
|
|
82
|
+
digest.update(filename.encode("utf-8"))
|
|
83
|
+
digest.update(text.encode("utf-8"))
|
|
84
|
+
return "sha256:" + digest.hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _schedule_order(name: str) -> int:
|
|
88
|
+
try:
|
|
89
|
+
return DEFAULT_RUN_ORDER.index(name) + 1
|
|
90
|
+
except ValueError:
|
|
91
|
+
return 100
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def spawn_cell(
|
|
95
|
+
name: str,
|
|
96
|
+
*,
|
|
97
|
+
project_root: Path,
|
|
98
|
+
symbiont_dir: Path | str = "symbiont",
|
|
99
|
+
template: str | None = None,
|
|
100
|
+
binary: str | None = None,
|
|
101
|
+
dry_run: bool = False,
|
|
102
|
+
) -> Path:
|
|
103
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
104
|
+
template_name = template or name
|
|
105
|
+
if template_name not in available_templates():
|
|
106
|
+
template_name = "_base"
|
|
107
|
+
cell_dir = paths.cells_root / name
|
|
108
|
+
if cell_dir.exists() and not dry_run:
|
|
109
|
+
raise FileExistsError(f"Cell already exists: {cell_dir}")
|
|
110
|
+
|
|
111
|
+
instance_config = read_toml(paths.symbiont_root / "config.toml")
|
|
112
|
+
default_binary = str(nested_get(instance_config, ("symbiont", "default_binary"), "codex"))
|
|
113
|
+
cell_binary = binary or default_binary
|
|
114
|
+
template_hash = _template_hash(template_name)
|
|
115
|
+
context = TemplateContext.create(project_root, paths.symbiont_root, cell_dir, name)
|
|
116
|
+
extra = {
|
|
117
|
+
"CELL_NAME": name,
|
|
118
|
+
"BINARY": cell_binary,
|
|
119
|
+
"SCHEDULE_ORDER": str(_schedule_order(name)),
|
|
120
|
+
"TEMPLATE_NAME": template_name,
|
|
121
|
+
"TEMPLATE_HASH": template_hash,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if dry_run:
|
|
125
|
+
return cell_dir
|
|
126
|
+
|
|
127
|
+
cell_dir.mkdir(parents=True, exist_ok=False)
|
|
128
|
+
(cell_dir / "journal").mkdir()
|
|
129
|
+
(cell_dir / "tickets").mkdir()
|
|
130
|
+
(cell_dir / "proposals").mkdir()
|
|
131
|
+
(cell_dir / "run.log").touch()
|
|
132
|
+
(cell_dir / "memory.md").write_text("# Memory\n\n", encoding="utf-8")
|
|
133
|
+
|
|
134
|
+
prompt_text = _read_template_text(template_name, "agent.md.j2") or _read_template_text("_base", "agent.md.j2")
|
|
135
|
+
if prompt_text is None:
|
|
136
|
+
raise FileNotFoundError(f"Template {template_name} does not include agent.md.j2")
|
|
137
|
+
rendered_prompt = _extra_render(prompt_text, extra)
|
|
138
|
+
(cell_dir / "agent.md").write_text(rendered_prompt, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
soul_text = _read_template_text(template_name, "SOUL.md") or _read_template_text(template_name, "SOUL.md.j2")
|
|
141
|
+
if soul_text is None:
|
|
142
|
+
soul_text = _read_template_text("_base", "SOUL.md.j2")
|
|
143
|
+
if soul_text is None:
|
|
144
|
+
raise FileNotFoundError("Base template is missing SOUL.md.j2")
|
|
145
|
+
(cell_dir / "SOUL.md").write_text(_extra_render(soul_text, extra), encoding="utf-8")
|
|
146
|
+
|
|
147
|
+
cell_toml = _read_template_text(template_name, "cell.toml.default")
|
|
148
|
+
if cell_toml is None:
|
|
149
|
+
cell_toml = default_cell_config_text(
|
|
150
|
+
name,
|
|
151
|
+
binary=cell_binary,
|
|
152
|
+
schedule_order=_schedule_order(name),
|
|
153
|
+
template=template_name,
|
|
154
|
+
template_hash=template_hash,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
cell_toml = _extra_render(cell_toml, extra)
|
|
158
|
+
(cell_dir / "cell.toml").write_text(cell_toml, encoding="utf-8")
|
|
159
|
+
return cell_dir
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def init_symbiont(
|
|
163
|
+
*,
|
|
164
|
+
project_root: Path,
|
|
165
|
+
symbiont_dir: Path | str = "symbiont",
|
|
166
|
+
default_binary: str = "codex",
|
|
167
|
+
dry_run: bool = False,
|
|
168
|
+
) -> Path:
|
|
169
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
170
|
+
if dry_run:
|
|
171
|
+
return paths.symbiont_root
|
|
172
|
+
|
|
173
|
+
paths.symbiont_root.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
paths.cells_root.mkdir(exist_ok=True)
|
|
175
|
+
write_if_missing(paths.symbiont_root / "config.toml", default_instance_config_text(project_root, default_binary=default_binary))
|
|
176
|
+
for filename, content in ROOT_FILES.items():
|
|
177
|
+
write_if_missing(paths.symbiont_root / filename, content)
|
|
178
|
+
manager_dir = paths.cells_root / "manager"
|
|
179
|
+
if not manager_dir.exists():
|
|
180
|
+
spawn_cell("manager", project_root=project_root, symbiont_dir=paths.symbiont_root, template="manager", binary=default_binary)
|
|
181
|
+
return paths.symbiont_root
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def migrate_legacy_symbiont(
|
|
185
|
+
*,
|
|
186
|
+
project_root: Path,
|
|
187
|
+
symbiont_dir: Path | str = "symbiont",
|
|
188
|
+
dry_run: bool = False,
|
|
189
|
+
) -> list[str]:
|
|
190
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
191
|
+
actions: list[str] = []
|
|
192
|
+
if not paths.symbiont_root.exists():
|
|
193
|
+
raise FileNotFoundError(f"Symbiont directory not found: {paths.symbiont_root}")
|
|
194
|
+
if not dry_run:
|
|
195
|
+
paths.cells_root.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
write_if_missing(paths.symbiont_root / "config.toml", default_instance_config_text(project_root))
|
|
197
|
+
|
|
198
|
+
for legacy_name, (cell_name, prompt_name, binary) in LEGACY_CELL_MAP.items():
|
|
199
|
+
source = paths.symbiont_root / legacy_name
|
|
200
|
+
if not source.exists():
|
|
201
|
+
continue
|
|
202
|
+
destination = paths.cells_root / cell_name
|
|
203
|
+
if destination.exists():
|
|
204
|
+
actions.append(f"skip {legacy_name}: {destination} already exists")
|
|
205
|
+
continue
|
|
206
|
+
actions.append(f"copy {legacy_name}/ -> cells/{cell_name}/")
|
|
207
|
+
if dry_run:
|
|
208
|
+
continue
|
|
209
|
+
shutil.copytree(source, destination)
|
|
210
|
+
legacy_prompt = destination / prompt_name
|
|
211
|
+
agent_prompt = destination / "agent.md"
|
|
212
|
+
if legacy_prompt.exists() and not agent_prompt.exists():
|
|
213
|
+
shutil.copy2(legacy_prompt, agent_prompt)
|
|
214
|
+
(destination / "journal").mkdir(exist_ok=True)
|
|
215
|
+
(destination / "tickets").mkdir(exist_ok=True)
|
|
216
|
+
(destination / "run.log").touch(exist_ok=True)
|
|
217
|
+
if not (destination / "memory.md").exists():
|
|
218
|
+
(destination / "memory.md").write_text("# Memory\n\n", encoding="utf-8")
|
|
219
|
+
write_if_missing(
|
|
220
|
+
destination / "cell.toml",
|
|
221
|
+
default_cell_config_text(
|
|
222
|
+
cell_name,
|
|
223
|
+
binary=binary,
|
|
224
|
+
schedule_order=_schedule_order(cell_name),
|
|
225
|
+
template=cell_name,
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
if not actions:
|
|
229
|
+
actions.append("no legacy cells found")
|
|
230
|
+
return actions
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _replace_toml_value(text: str, section: str, key: str, value: str) -> str:
|
|
234
|
+
pattern = rf"(\[{re.escape(section)}\][\s\S]*?)(?=\n\[|\Z)"
|
|
235
|
+
match = re.search(pattern, text)
|
|
236
|
+
line = f'{key} = "{value}"'
|
|
237
|
+
if key == "enabled":
|
|
238
|
+
line = f"{key} = {value.lower()}"
|
|
239
|
+
if not match:
|
|
240
|
+
return text.rstrip() + f"\n\n[{section}]\n{line}\n"
|
|
241
|
+
block = match.group(1)
|
|
242
|
+
key_pattern = rf"^{re.escape(key)}\s*=.*$"
|
|
243
|
+
if re.search(key_pattern, block, flags=re.MULTILINE):
|
|
244
|
+
block = re.sub(key_pattern, line, block, flags=re.MULTILINE)
|
|
245
|
+
else:
|
|
246
|
+
block = block.rstrip() + "\n" + line
|
|
247
|
+
return text[: match.start(1)] + block + text[match.end(1) :]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def update_hive_config(symbiont_root: Path, *, enabled: bool, url: str = "", api_key: str = "") -> None:
|
|
251
|
+
config_path = symbiont_root / "config.toml"
|
|
252
|
+
text = config_path.read_text(encoding="utf-8")
|
|
253
|
+
text = _replace_toml_value(text, "symbiont.hive", "enabled", "true" if enabled else "false")
|
|
254
|
+
if url:
|
|
255
|
+
text = _replace_toml_value(text, "symbiont.hive", "url", url.rstrip("/"))
|
|
256
|
+
if api_key:
|
|
257
|
+
text = _replace_toml_value(text, "symbiont.hive", "api_key", api_key)
|
|
258
|
+
config_path.write_text(text, encoding="utf-8")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def print_status(project_root: Path, symbiont_dir: Path | str) -> int:
|
|
262
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
263
|
+
instance_config = read_toml(paths.symbiont_root / "config.toml")
|
|
264
|
+
print(f"Symbiont: {paths.symbiont_root}")
|
|
265
|
+
print(f"Repo: {nested_get(instance_config, ('symbiont', 'repo_name'), project_root.name)}")
|
|
266
|
+
for cell_name in ordered_cells(paths, instance_config):
|
|
267
|
+
cell_dir = paths.cells_root / cell_name
|
|
268
|
+
if not cell_dir.exists():
|
|
269
|
+
cell_dir = paths.symbiont_root / cell_name
|
|
270
|
+
run_log = cell_dir / "run.log"
|
|
271
|
+
last = run_log.read_text(encoding="utf-8").splitlines()[-1] if run_log.exists() and run_log.stat().st_size else "never"
|
|
272
|
+
print(f"- {cell_name}: {last}")
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
277
|
+
parser = argparse.ArgumentParser(prog="probiotic", description="Autonomous symbiont framework for code repositories")
|
|
278
|
+
parser.add_argument("--symbiont-dir", default="symbiont", help="Override symbiont directory")
|
|
279
|
+
parser.add_argument("--dry-run", action="store_true", help="Show what would happen without executing")
|
|
280
|
+
parser.add_argument("--verbose", action="store_true", help="Verbose logging")
|
|
281
|
+
parser.add_argument("--version", action="version", version=f"probiotic {__version__}")
|
|
282
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
283
|
+
|
|
284
|
+
init_parser = sub.add_parser("init", help="Initialize a symbiont in the current repository")
|
|
285
|
+
init_parser.add_argument("--default-binary", default="codex", choices=["codex", "claude", "custom"])
|
|
286
|
+
init_parser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS)
|
|
287
|
+
|
|
288
|
+
spawn_parser = sub.add_parser("spawn", help="Create a new cell from a template")
|
|
289
|
+
spawn_parser.add_argument("name")
|
|
290
|
+
spawn_parser.add_argument("--template")
|
|
291
|
+
spawn_parser.add_argument("--binary", choices=["codex", "claude", "custom"])
|
|
292
|
+
spawn_parser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS)
|
|
293
|
+
|
|
294
|
+
run_parser = sub.add_parser("run", help="Run a specific cell")
|
|
295
|
+
run_parser.add_argument("cell")
|
|
296
|
+
run_parser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS)
|
|
297
|
+
|
|
298
|
+
nightly_parser = sub.add_parser("run-nightly", help="Run all scheduled cells in order")
|
|
299
|
+
nightly_parser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS)
|
|
300
|
+
nightly_parser.add_argument("--skip-updates", action="store_true", help="Skip pulling hive improvements before running cells")
|
|
301
|
+
sub.add_parser("status", help="Show cell status")
|
|
302
|
+
sub.add_parser("list-templates", help="Show available templates")
|
|
303
|
+
migrate_parser = sub.add_parser("migrate-legacy", help="Copy old top-level symbiont agents into cells/")
|
|
304
|
+
migrate_parser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS)
|
|
305
|
+
|
|
306
|
+
connect = sub.add_parser("connect-hive", help="Connect this symbiont to a hive server")
|
|
307
|
+
connect.add_argument("url")
|
|
308
|
+
connect.add_argument("--api-key", default="")
|
|
309
|
+
|
|
310
|
+
sub.add_parser("disconnect-hive", help="Disconnect from hive")
|
|
311
|
+
sub.add_parser("update-check", help="Check hive for available updates")
|
|
312
|
+
|
|
313
|
+
usage = sub.add_parser("usage", help="Print token usage summary")
|
|
314
|
+
usage.add_argument("--json", action="store_true")
|
|
315
|
+
return parser
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main(argv: list[str] | None = None) -> int:
|
|
319
|
+
parser = build_parser()
|
|
320
|
+
args = parser.parse_args(argv)
|
|
321
|
+
project_root = Path.cwd()
|
|
322
|
+
symbiont_dir = args.symbiont_dir
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
if args.command == "init":
|
|
326
|
+
path = init_symbiont(project_root=project_root, symbiont_dir=symbiont_dir, default_binary=args.default_binary, dry_run=args.dry_run)
|
|
327
|
+
print(f"Symbiont initialized at {path}")
|
|
328
|
+
print("Next: edit symbiont/todos.md, then run `probiotic run manager`.")
|
|
329
|
+
return 0
|
|
330
|
+
if args.command == "spawn":
|
|
331
|
+
path = spawn_cell(args.name, project_root=project_root, symbiont_dir=symbiont_dir, template=args.template, binary=args.binary, dry_run=args.dry_run)
|
|
332
|
+
print(f"Cell `{args.name}` created at {path}")
|
|
333
|
+
return 0
|
|
334
|
+
if args.command == "run":
|
|
335
|
+
result = run_cell(args.cell, project_root=project_root, symbiont_dir=symbiont_dir, dry_run=args.dry_run, verbose=args.verbose)
|
|
336
|
+
print(result.summary)
|
|
337
|
+
return result.returncode
|
|
338
|
+
if args.command == "run-nightly":
|
|
339
|
+
results = run_nightly(
|
|
340
|
+
project_root=project_root,
|
|
341
|
+
symbiont_dir=symbiont_dir,
|
|
342
|
+
dry_run=args.dry_run,
|
|
343
|
+
verbose=args.verbose,
|
|
344
|
+
skip_updates=args.skip_updates,
|
|
345
|
+
)
|
|
346
|
+
for result in results:
|
|
347
|
+
print(f"{result.cell_name}: {result.summary}")
|
|
348
|
+
return 0 if all(result.ok for result in results) else 1
|
|
349
|
+
if args.command == "status":
|
|
350
|
+
return print_status(project_root, symbiont_dir)
|
|
351
|
+
if args.command == "list-templates":
|
|
352
|
+
for name in available_templates():
|
|
353
|
+
if name != "_base":
|
|
354
|
+
print(name)
|
|
355
|
+
return 0
|
|
356
|
+
if args.command == "migrate-legacy":
|
|
357
|
+
actions = migrate_legacy_symbiont(project_root=project_root, symbiont_dir=symbiont_dir, dry_run=args.dry_run)
|
|
358
|
+
for action in actions:
|
|
359
|
+
print(action)
|
|
360
|
+
return 0
|
|
361
|
+
if args.command == "connect-hive":
|
|
362
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
363
|
+
update_hive_config(paths.symbiont_root, enabled=True, url=args.url, api_key=args.api_key)
|
|
364
|
+
print(f"Connected hive: {args.url.rstrip('/')}")
|
|
365
|
+
return 0
|
|
366
|
+
if args.command == "disconnect-hive":
|
|
367
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
368
|
+
update_hive_config(paths.symbiont_root, enabled=False)
|
|
369
|
+
print("Hive disconnected")
|
|
370
|
+
return 0
|
|
371
|
+
if args.command == "update-check":
|
|
372
|
+
paths = RuntimePaths.create(project_root, symbiont_dir)
|
|
373
|
+
config = read_toml(paths.symbiont_root / "config.toml")
|
|
374
|
+
hive = HiveConfig.from_instance_config(config)
|
|
375
|
+
instance_id = str(nested_get(config, ("symbiont", "instance_id"), "local"))
|
|
376
|
+
updates = check_updates(hive, instance_id=instance_id)
|
|
377
|
+
print(f"{len(updates.get('improvements', []))} improvements, {len(updates.get('new_templates', []))} new templates")
|
|
378
|
+
return 0
|
|
379
|
+
if args.command == "usage":
|
|
380
|
+
return cmd_usage(as_json=args.json)
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
print(f"probiotic: {exc}", file=sys.stderr)
|
|
383
|
+
return 1
|
|
384
|
+
|
|
385
|
+
parser.print_help()
|
|
386
|
+
return 1
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
sys.exit(main())
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _env_override(name: str) -> Path | None:
|
|
10
|
+
override = os.environ.get(name)
|
|
11
|
+
if not override:
|
|
12
|
+
return None
|
|
13
|
+
candidate = Path(override).expanduser()
|
|
14
|
+
if candidate.is_file():
|
|
15
|
+
return candidate
|
|
16
|
+
raise FileNotFoundError(f"{name} points to a missing file: {candidate}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_codex_binary() -> Path:
|
|
20
|
+
override = _env_override("CODEX_BIN")
|
|
21
|
+
if override:
|
|
22
|
+
return override
|
|
23
|
+
|
|
24
|
+
path_hit = shutil.which("codex")
|
|
25
|
+
if path_hit:
|
|
26
|
+
return Path(path_hit)
|
|
27
|
+
|
|
28
|
+
candidates: list[Path] = []
|
|
29
|
+
for root in [
|
|
30
|
+
Path.home() / ".vscode-server" / "extensions",
|
|
31
|
+
Path.home() / ".vscode" / "extensions",
|
|
32
|
+
]:
|
|
33
|
+
if root.is_dir():
|
|
34
|
+
candidates.extend(root.glob("openai.chatgpt-*/bin/*/codex"))
|
|
35
|
+
|
|
36
|
+
if candidates:
|
|
37
|
+
return max(candidates, key=lambda path: path.stat().st_mtime_ns)
|
|
38
|
+
raise FileNotFoundError("Could not find a Codex binary in PATH or VS Code extensions")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_claude_binary() -> Path:
|
|
42
|
+
override = _env_override("CLAUDE_BIN")
|
|
43
|
+
if override:
|
|
44
|
+
return override
|
|
45
|
+
|
|
46
|
+
path_hit = shutil.which("claude")
|
|
47
|
+
if path_hit:
|
|
48
|
+
return Path(path_hit)
|
|
49
|
+
|
|
50
|
+
for candidate in [
|
|
51
|
+
Path.home() / ".local" / "bin" / "claude",
|
|
52
|
+
Path("/usr/local/bin/claude"),
|
|
53
|
+
Path("/usr/bin/claude"),
|
|
54
|
+
]:
|
|
55
|
+
if candidate.is_file():
|
|
56
|
+
return candidate
|
|
57
|
+
|
|
58
|
+
nvm_dir = Path.home() / ".nvm" / "versions" / "node"
|
|
59
|
+
if nvm_dir.is_dir():
|
|
60
|
+
hits = sorted(nvm_dir.glob("*/bin/claude"), reverse=True)
|
|
61
|
+
if hits:
|
|
62
|
+
return hits[0]
|
|
63
|
+
|
|
64
|
+
raise FileNotFoundError("Could not find claude binary in PATH or common locations")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_binary(backend: str, *, binary_command: str | None = None) -> Path:
|
|
68
|
+
if backend == "codex":
|
|
69
|
+
return find_codex_binary()
|
|
70
|
+
if backend == "claude":
|
|
71
|
+
return find_claude_binary()
|
|
72
|
+
if backend == "custom":
|
|
73
|
+
if not binary_command:
|
|
74
|
+
raise ValueError("custom backend requires cell.binary_command")
|
|
75
|
+
command = shlex.split(binary_command)
|
|
76
|
+
if not command:
|
|
77
|
+
raise ValueError("custom backend has an empty binary_command")
|
|
78
|
+
candidate = Path(command[0]).expanduser()
|
|
79
|
+
if candidate.is_file():
|
|
80
|
+
return candidate
|
|
81
|
+
path_hit = shutil.which(command[0])
|
|
82
|
+
if path_hit:
|
|
83
|
+
return Path(path_hit)
|
|
84
|
+
raise FileNotFoundError(f"Could not find custom binary: {command[0]}")
|
|
85
|
+
raise ValueError(f"Unsupported binary backend: {backend}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_env(binary: Path, backend: str) -> dict[str, str]:
|
|
89
|
+
env = os.environ.copy()
|
|
90
|
+
default_path = [
|
|
91
|
+
str(binary.parent),
|
|
92
|
+
str(Path.home() / ".local" / "bin"),
|
|
93
|
+
"/usr/local/sbin",
|
|
94
|
+
"/usr/local/bin",
|
|
95
|
+
"/usr/sbin",
|
|
96
|
+
"/usr/bin",
|
|
97
|
+
"/sbin",
|
|
98
|
+
"/bin",
|
|
99
|
+
]
|
|
100
|
+
existing_path = env.get("PATH", "")
|
|
101
|
+
if existing_path:
|
|
102
|
+
default_path.append(existing_path)
|
|
103
|
+
|
|
104
|
+
env["HOME"] = str(Path.home())
|
|
105
|
+
if backend == "codex":
|
|
106
|
+
env["CODEX_HOME"] = str(Path.home() / ".codex")
|
|
107
|
+
env["PATH"] = ":".join(dict.fromkeys(part for part in default_path if part))
|
|
108
|
+
env.setdefault("TERM", "dumb")
|
|
109
|
+
return env
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_command(backend: str, binary: Path, workdir: Path, *, prompt: str, binary_command: str | None = None) -> tuple[list[str], str | None]:
|
|
113
|
+
if backend == "codex":
|
|
114
|
+
return (
|
|
115
|
+
[
|
|
116
|
+
str(binary),
|
|
117
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
118
|
+
"exec",
|
|
119
|
+
"--skip-git-repo-check",
|
|
120
|
+
"--cd",
|
|
121
|
+
str(workdir),
|
|
122
|
+
"-",
|
|
123
|
+
],
|
|
124
|
+
prompt,
|
|
125
|
+
)
|
|
126
|
+
if backend == "claude":
|
|
127
|
+
return (
|
|
128
|
+
[
|
|
129
|
+
str(binary),
|
|
130
|
+
"-p",
|
|
131
|
+
prompt,
|
|
132
|
+
"--dangerously-skip-permissions",
|
|
133
|
+
"--output-format",
|
|
134
|
+
"json",
|
|
135
|
+
],
|
|
136
|
+
None,
|
|
137
|
+
)
|
|
138
|
+
if backend == "custom":
|
|
139
|
+
if not binary_command:
|
|
140
|
+
raise ValueError("custom backend requires cell.binary_command")
|
|
141
|
+
command = shlex.split(binary_command)
|
|
142
|
+
if Path(command[0]).name == binary.name:
|
|
143
|
+
command[0] = str(binary)
|
|
144
|
+
return command, prompt
|
|
145
|
+
raise ValueError(f"Unsupported binary backend: {backend}")
|
|
146
|
+
|
probiotic/dna/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import tomllib
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_RUN_ORDER = [
|
|
11
|
+
"manager",
|
|
12
|
+
"researcher",
|
|
13
|
+
"documentation",
|
|
14
|
+
"janitor",
|
|
15
|
+
"commit",
|
|
16
|
+
"progress_tracker",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_toml(path: Path) -> dict[str, Any]:
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return {}
|
|
23
|
+
with path.open("rb") as fh:
|
|
24
|
+
return tomllib.load(fh)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def nested_get(data: dict[str, Any], keys: tuple[str, ...], default: Any = None) -> Any:
|
|
28
|
+
cursor: Any = data
|
|
29
|
+
for key in keys:
|
|
30
|
+
if not isinstance(cursor, dict) or key not in cursor:
|
|
31
|
+
return default
|
|
32
|
+
cursor = cursor[key]
|
|
33
|
+
return cursor
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_project_type(project_root: Path) -> str:
|
|
37
|
+
markers = [
|
|
38
|
+
("pyproject.toml", "python"),
|
|
39
|
+
("package.json", "node"),
|
|
40
|
+
("go.mod", "go"),
|
|
41
|
+
("Cargo.toml", "rust"),
|
|
42
|
+
]
|
|
43
|
+
for filename, project_type in markers:
|
|
44
|
+
if (project_root / filename).exists():
|
|
45
|
+
return project_type
|
|
46
|
+
return "generic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def default_instance_config_text(
|
|
50
|
+
project_root: Path,
|
|
51
|
+
*,
|
|
52
|
+
default_binary: str = "codex",
|
|
53
|
+
timezone: str = "America/Vancouver",
|
|
54
|
+
) -> str:
|
|
55
|
+
repo_name = project_root.resolve().name or socket.gethostname()
|
|
56
|
+
run_order = ", ".join(f'"{name}"' for name in DEFAULT_RUN_ORDER)
|
|
57
|
+
return f"""[symbiont]
|
|
58
|
+
instance_id = "{uuid.uuid4()}"
|
|
59
|
+
repo_name = "{repo_name}"
|
|
60
|
+
project_type = "{detect_project_type(project_root)}"
|
|
61
|
+
timezone = "{timezone}"
|
|
62
|
+
default_binary = "{default_binary}"
|
|
63
|
+
|
|
64
|
+
[symbiont.budget]
|
|
65
|
+
enabled = true
|
|
66
|
+
signal_source = "local"
|
|
67
|
+
max_5h_pct = 50
|
|
68
|
+
human_reserve_pct = 50
|
|
69
|
+
|
|
70
|
+
[symbiont.hive]
|
|
71
|
+
enabled = false
|
|
72
|
+
url = ""
|
|
73
|
+
api_key = ""
|
|
74
|
+
share_prompts = true
|
|
75
|
+
share_approval_rates = true
|
|
76
|
+
share_journals = false
|
|
77
|
+
|
|
78
|
+
[symbiont.schedule]
|
|
79
|
+
run_order = [{run_order}]
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_cell_config_text(
|
|
84
|
+
name: str,
|
|
85
|
+
*,
|
|
86
|
+
binary: str = "codex",
|
|
87
|
+
schedule_order: int = 100,
|
|
88
|
+
template: str = "_base",
|
|
89
|
+
template_hash: str = "",
|
|
90
|
+
budget_soft: float = 0.50,
|
|
91
|
+
) -> str:
|
|
92
|
+
return f"""[cell]
|
|
93
|
+
name = "{name}"
|
|
94
|
+
binary = "{binary}"
|
|
95
|
+
enabled = true
|
|
96
|
+
schedule_order = {schedule_order}
|
|
97
|
+
budget_soft = {budget_soft:.2f}
|
|
98
|
+
template = "{template}"
|
|
99
|
+
spawned_from_template_hash = "{template_hash}"
|
|
100
|
+
allow_self_edit = true
|
|
101
|
+
backup_before_edit = true
|
|
102
|
+
hive_report = true
|
|
103
|
+
hive_accept_improvements = true
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def write_if_missing(path: Path, content: str) -> bool:
|
|
108
|
+
if path.exists():
|
|
109
|
+
return False
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
path.write_text(content, encoding="utf-8")
|
|
112
|
+
return True
|
|
113
|
+
|