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.
Files changed (40) hide show
  1. probiotic/__init__.py +6 -0
  2. probiotic/cli.py +390 -0
  3. probiotic/dna/__init__.py +4 -0
  4. probiotic/dna/binary_finder.py +146 -0
  5. probiotic/dna/config.py +113 -0
  6. probiotic/dna/hive_client.py +119 -0
  7. probiotic/dna/journal.py +177 -0
  8. probiotic/dna/lock.py +54 -0
  9. probiotic/dna/prompts.py +167 -0
  10. probiotic/dna/runtime.py +266 -0
  11. probiotic/dna/scheduler.py +162 -0
  12. probiotic/dna/tickets.py +72 -0
  13. probiotic/dna/tools/__init__.py +2 -0
  14. probiotic/dna/tools/usage.py +215 -0
  15. probiotic/templates/__init__.py +2 -0
  16. probiotic/templates/_base/SOUL.md.j2 +7 -0
  17. probiotic/templates/_base/agent.md.j2 +25 -0
  18. probiotic/templates/_base/cell.toml.default +13 -0
  19. probiotic/templates/commit/SOUL.md +4 -0
  20. probiotic/templates/commit/agent.md.j2 +22 -0
  21. probiotic/templates/commit/cell.toml.default +13 -0
  22. probiotic/templates/documentation/SOUL.md +4 -0
  23. probiotic/templates/documentation/agent.md.j2 +23 -0
  24. probiotic/templates/documentation/cell.toml.default +13 -0
  25. probiotic/templates/janitor/SOUL.md +7 -0
  26. probiotic/templates/janitor/agent.md.j2 +35 -0
  27. probiotic/templates/janitor/cell.toml.default +13 -0
  28. probiotic/templates/manager/SOUL.md +8 -0
  29. probiotic/templates/manager/agent.md.j2 +35 -0
  30. probiotic/templates/manager/cell.toml.default +13 -0
  31. probiotic/templates/progress_tracker/SOUL.md +4 -0
  32. probiotic/templates/progress_tracker/agent.md.j2 +29 -0
  33. probiotic/templates/progress_tracker/cell.toml.default +13 -0
  34. probiotic/templates/researcher/SOUL.md +4 -0
  35. probiotic/templates/researcher/agent.md.j2 +42 -0
  36. probiotic/templates/researcher/cell.toml.default +13 -0
  37. probiotic-0.1.0.dist-info/METADATA +78 -0
  38. probiotic-0.1.0.dist-info/RECORD +40 -0
  39. probiotic-0.1.0.dist-info/WHEEL +4 -0
  40. probiotic-0.1.0.dist-info/entry_points.txt +2 -0
probiotic/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Probiotic: autonomous symbiont runtime for code repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
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,4 @@
1
+ """DNA layer: reusable runtime machinery for Probiotic cells."""
2
+
3
+ from __future__ import annotations
4
+
@@ -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
+
@@ -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
+