sibyl-cli 0.2.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.
@@ -0,0 +1,161 @@
1
+ """オーケストレーションログを集約するサービス."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Mapping
9
+
10
+ import yaml
11
+
12
+
13
+ class LogManager:
14
+ """各サイクルのメタデータを YAML/JSONL として永続化する."""
15
+
16
+ def __init__(self, logs_dir: Path) -> None:
17
+ self.logs_dir = Path(logs_dir)
18
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
19
+ self.cycles_dir = self.logs_dir / "cycles"
20
+ self.cycles_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ def record_cycle(
23
+ self,
24
+ *,
25
+ instruction: str,
26
+ layout: Mapping[str, Any],
27
+ fork_map: Mapping[str, str],
28
+ completion: Mapping[str, Any],
29
+ result: Any,
30
+ ) -> Dict[str, Path]:
31
+ timestamp = datetime.utcnow().strftime("%y-%m-%d-%H%M%S")
32
+ payload = {
33
+ "instruction": instruction,
34
+ "layout": dict(layout),
35
+ "fork_map": dict(fork_map),
36
+ "completion": dict(completion),
37
+ "result": {
38
+ "selected_session": getattr(result, "selected_session", None),
39
+ "sessions_summary": getattr(result, "sessions_summary", None),
40
+ },
41
+ }
42
+ outcome = getattr(result, "merge_outcome", None)
43
+ if outcome is not None:
44
+ payload["result"]["merge_outcome"] = {
45
+ "strategy": getattr(outcome, "strategy", None).value if getattr(outcome, "strategy", None) else None,
46
+ "status": getattr(outcome, "status", None),
47
+ "branch": getattr(outcome, "branch", None),
48
+ "error": getattr(outcome, "error", None),
49
+ "reason": getattr(outcome, "reason", None),
50
+ }
51
+ path = self.cycles_dir / f"{timestamp}.yaml"
52
+ path.write_text(
53
+ yaml.safe_dump(payload, sort_keys=False),
54
+ encoding="utf-8",
55
+ )
56
+ jsonl_path = self.cycles_dir / f"{timestamp}.jsonl"
57
+ events = self._build_cycle_events(
58
+ instruction=instruction,
59
+ layout=layout,
60
+ fork_map=fork_map,
61
+ completion=completion,
62
+ result=result,
63
+ )
64
+ with jsonl_path.open("w", encoding="utf-8") as fh:
65
+ for event in events:
66
+ fh.write(json.dumps(event, ensure_ascii=False) + "\n")
67
+ return {"yaml": path, "jsonl": jsonl_path}
68
+
69
+ def _build_cycle_events(
70
+ self,
71
+ *,
72
+ instruction: str,
73
+ layout: Mapping[str, Any],
74
+ fork_map: Mapping[str, str],
75
+ completion: Mapping[str, Any],
76
+ result: Any,
77
+ ) -> List[Dict[str, Any]]:
78
+ timestamp = datetime.utcnow().isoformat(timespec="seconds")
79
+ events: List[Dict[str, Any]] = []
80
+
81
+ events.append(
82
+ {
83
+ "type": "instruction",
84
+ "timestamp": timestamp,
85
+ "instruction": instruction,
86
+ "layout": dict(layout),
87
+ }
88
+ )
89
+
90
+ if fork_map:
91
+ events.append(
92
+ {
93
+ "type": "fork",
94
+ "timestamp": timestamp,
95
+ "fork_map": dict(fork_map),
96
+ }
97
+ )
98
+
99
+ if completion:
100
+ events.append(
101
+ {
102
+ "type": "completion",
103
+ "timestamp": timestamp,
104
+ "completion": dict(completion),
105
+ }
106
+ )
107
+
108
+ scoreboard = getattr(result, "sessions_summary", None) or {}
109
+ if scoreboard:
110
+ events.append(
111
+ {
112
+ "type": "scoreboard",
113
+ "timestamp": timestamp,
114
+ "scoreboard": scoreboard,
115
+ }
116
+ )
117
+
118
+ selected_session = getattr(result, "selected_session", None)
119
+ selected_key = None
120
+ for key, data in scoreboard.items():
121
+ if data.get("selected"):
122
+ selected_key = key
123
+ break
124
+ events.append(
125
+ {
126
+ "type": "selection",
127
+ "timestamp": timestamp,
128
+ "selected_session": selected_session,
129
+ "selected_key": selected_key,
130
+ }
131
+ )
132
+
133
+ outcome = getattr(result, "merge_outcome", None)
134
+ if outcome is not None:
135
+ events.append(
136
+ {
137
+ "type": "merge_outcome",
138
+ "timestamp": timestamp,
139
+ "strategy": getattr(outcome, "strategy", None).value if getattr(outcome, "strategy", None) else None,
140
+ "status": getattr(outcome, "status", None),
141
+ "branch": getattr(outcome, "branch", None),
142
+ "error": getattr(outcome, "error", None),
143
+ "reason": getattr(outcome, "reason", None),
144
+ }
145
+ )
146
+
147
+ artifact = getattr(result, "artifact", None)
148
+ if artifact is not None:
149
+ events.append(
150
+ {
151
+ "type": "artifact",
152
+ "timestamp": timestamp,
153
+ "main_session_id": getattr(artifact, "main_session_id", None),
154
+ "worker_sessions": getattr(artifact, "worker_sessions", {}),
155
+ "boss_session_id": getattr(artifact, "boss_session_id", None),
156
+ "worker_paths": {k: str(v) for k, v in getattr(artifact, "worker_paths", {}).items()},
157
+ "boss_path": str(getattr(artifact, "boss_path", "")) if getattr(artifact, "boss_path", None) else None,
158
+ }
159
+ )
160
+
161
+ return events
@@ -0,0 +1,245 @@
1
+ """tmux レイアウトとキーストロークを制御するサービス."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, Mapping, Optional, Sequence, TYPE_CHECKING, List
9
+
10
+ import libtmux
11
+
12
+ if TYPE_CHECKING: # pragma: no cover - 型チェック専用
13
+ from .codex_monitor import CodexMonitor
14
+
15
+
16
+ class TmuxLayoutManager:
17
+ """Parallel Codex 用の tmux セッションを構成・制御する."""
18
+
19
+ def __init__(
20
+ self,
21
+ session_name: str,
22
+ worker_count: int,
23
+ monitor: "CodexMonitor",
24
+ *,
25
+ root_path: Path,
26
+ startup_delay: float = 0.0,
27
+ backtrack_delay: float = 0.05,
28
+ reuse_existing_session: bool = False,
29
+ session_namespace: Optional[str] = None,
30
+ ) -> None:
31
+ self.session_name = session_name
32
+ self.worker_count = worker_count
33
+ self.monitor = monitor
34
+ self.root_path = Path(root_path)
35
+ self.boss_path = self.root_path
36
+ self.startup_delay = startup_delay
37
+ self.backtrack_delay = backtrack_delay
38
+ self.reuse_existing_session = reuse_existing_session
39
+ self.session_namespace = session_namespace
40
+ self._server = libtmux.Server()
41
+
42
+ def set_boss_path(self, path: Path) -> None:
43
+ self.boss_path = Path(path)
44
+
45
+ def set_reuse_existing_session(self, reuse: bool) -> None:
46
+ self.reuse_existing_session = reuse
47
+
48
+ def ensure_layout(self, *, session_name: str, worker_count: int) -> dict[str, Any]:
49
+ if session_name != self.session_name or worker_count != self.worker_count:
50
+ raise ValueError("session_name/worker_count mismatch with manager configuration")
51
+
52
+ session = self._get_or_create_session(fresh=not self.reuse_existing_session)
53
+ window = getattr(session, "attached_window", None) or session.windows[0]
54
+
55
+ target_pane_count = self.worker_count + 2 # main + boss + workers
56
+ while len(window.panes) < target_pane_count:
57
+ self._split_largest_pane(window)
58
+ window.select_layout("tiled")
59
+ window = getattr(session, "attached_window", None) or session.windows[0]
60
+ window.select_layout("tiled")
61
+
62
+ panes = window.panes
63
+ layout = {
64
+ "main": panes[0].pane_id,
65
+ "boss": panes[1].pane_id,
66
+ "workers": [pane.pane_id for pane in panes[2 : 2 + self.worker_count]],
67
+ }
68
+ self._apply_role_labels(session, layout)
69
+ return layout
70
+
71
+ def launch_main_session(self, *, pane_id: str) -> None:
72
+ codex = self._codex_command("codex")
73
+ command = f"cd {shlex.quote(str(self.root_path))} && {codex}"
74
+ self._send_command(pane_id, command)
75
+ self._maybe_wait()
76
+
77
+ def resume_session(self, *, pane_id: str, workdir: Path, session_id: str) -> None:
78
+ codex = self._codex_command(f"codex resume {shlex.quote(str(session_id))}")
79
+ command = f"cd {shlex.quote(str(workdir))} && {codex}"
80
+ self._send_command(pane_id, command)
81
+ self._maybe_wait()
82
+
83
+ def fork_boss(self, *, pane_id: str, base_session_id: str, boss_path: Path) -> None:
84
+ self.interrupt_pane(pane_id=pane_id)
85
+ command = f"cd {shlex.quote(str(boss_path))} && {self._codex_command(f'codex resume {shlex.quote(str(base_session_id))}')}"
86
+ self._send_command(pane_id, command)
87
+ self._maybe_wait()
88
+ pane = self._get_pane(pane_id)
89
+ pane.send_keys("C-[", enter=False)
90
+ time.sleep(self.backtrack_delay)
91
+ pane.send_keys("C-[", enter=False)
92
+ time.sleep(self.backtrack_delay)
93
+ pane.send_keys("", enter=True)
94
+ time.sleep(self.backtrack_delay)
95
+ self._maybe_wait()
96
+
97
+ def fork_workers(
98
+ self,
99
+ *,
100
+ workers: Iterable[str],
101
+ base_session_id: str,
102
+ pane_paths: Mapping[str, Path],
103
+ ) -> List[str]:
104
+ if not base_session_id:
105
+ raise RuntimeError("base_session_id が空です。メインセッションのIDが取得できていません。")
106
+ worker_list = list(workers)
107
+ for pane_id in worker_list:
108
+ try:
109
+ worker_path = Path(pane_paths[pane_id])
110
+ except KeyError as exc:
111
+ raise RuntimeError(f"pane {pane_id!r} に対応するワークツリーパスがありません") from exc
112
+ self.interrupt_pane(pane_id=pane_id)
113
+ command = f"cd {shlex.quote(str(worker_path))} && {self._codex_command(f'codex resume {shlex.quote(str(base_session_id))}')}"
114
+ self._send_command(pane_id, command)
115
+ self._maybe_wait()
116
+ if worker_list:
117
+ self._broadcast_keys(worker_list, "C-[", enter=False)
118
+ self._broadcast_keys(worker_list, "C-[", enter=False)
119
+ for pane_id in worker_list:
120
+ pane = self._get_pane(pane_id)
121
+ pane.send_keys("", enter=True)
122
+ self._maybe_wait()
123
+ return worker_list
124
+
125
+ def send_instruction_to_pane(self, *, pane_id: str, instruction: str) -> None:
126
+ self._send_text(pane_id, instruction)
127
+
128
+ def prepare_for_instruction(self, *, pane_id: str) -> None:
129
+ pane = self._get_pane(pane_id)
130
+ pane.send_keys("C-c", enter=False)
131
+ if self.backtrack_delay > 0:
132
+ time.sleep(self.backtrack_delay)
133
+
134
+ def promote_to_main(self, *, session_id: str, pane_id: str) -> None:
135
+ command = self._codex_command(f"codex resume {shlex.quote(str(session_id))}")
136
+ if self.backtrack_delay > 0:
137
+ time.sleep(self.backtrack_delay)
138
+ self._send_command(pane_id, command)
139
+
140
+ def interrupt_pane(self, *, pane_id: str) -> None:
141
+ pane = self._get_pane(pane_id)
142
+ pane.send_keys("C-c", enter=False)
143
+ if self.backtrack_delay > 0:
144
+ time.sleep(self.backtrack_delay)
145
+ pane.send_keys("C-c", enter=False)
146
+ if self.backtrack_delay > 0:
147
+ time.sleep(self.backtrack_delay)
148
+
149
+ def _apply_role_labels(self, session, layout: Mapping[str, Any]) -> None:
150
+ try:
151
+ self._set_pane_title(session, layout.get("main"), "MAIN")
152
+ self._set_pane_title(session, layout.get("boss"), "BOSS")
153
+ for index, pane_id in enumerate(layout.get("workers", []), start=1):
154
+ self._set_pane_title(session, pane_id, f"WORKER-{index}")
155
+ except Exception: # pragma: no cover - tmux互換対策
156
+ pass
157
+
158
+ def _set_pane_title(self, session, pane_id: Optional[str], title: str) -> None:
159
+ if not pane_id:
160
+ return
161
+ try:
162
+ session.cmd("select-pane", "-t", pane_id, "-T", title)
163
+ except Exception: # pragma: no cover - 古いtmux向け
164
+ pass
165
+
166
+ def _split_largest_pane(self, window) -> None:
167
+ panes = list(window.panes)
168
+ if not panes:
169
+ window.split_window(attach=False)
170
+ return
171
+ largest = max(panes, key=lambda pane: int(pane.height) * int(pane.width))
172
+ window.select_pane(largest.pane_id)
173
+ window.split_window(attach=False)
174
+
175
+ def _get_or_create_session(self, fresh: bool = False):
176
+ session = self._server.find_where({"session_name": self.session_name})
177
+ if session is not None and not fresh:
178
+ self._configure_session(session)
179
+ return session
180
+
181
+ kill_existing = fresh and session is not None
182
+ session = self._server.new_session(
183
+ session_name=self.session_name,
184
+ attach=False,
185
+ kill_session=kill_existing,
186
+ )
187
+ self._configure_session(session)
188
+ return session
189
+
190
+ def _get_pane(self, pane_id: str):
191
+ pane = self._find_pane(pane_id)
192
+ if pane is not None:
193
+ return pane
194
+ self._server = libtmux.Server()
195
+ pane = self._find_pane(pane_id)
196
+ if pane is not None:
197
+ return pane
198
+ raise RuntimeError(f"Pane {pane_id!r} not found in tmux session {self.session_name}")
199
+
200
+ def _find_pane(self, pane_id: str):
201
+ for session in getattr(self._server, "sessions", []):
202
+ for window in getattr(session, "windows", []):
203
+ for pane in getattr(window, "panes", []):
204
+ if getattr(pane, "pane_id", None) == pane_id:
205
+ return pane
206
+ return None
207
+
208
+ def _send_command(self, pane_id: str, command: str) -> None:
209
+ pane = self._get_pane(pane_id)
210
+ pane.send_keys(command, enter=True)
211
+
212
+ def _maybe_wait(self) -> None:
213
+ if self.startup_delay > 0:
214
+ time.sleep(self.startup_delay)
215
+
216
+ def _send_text(self, pane_id: str, text: str) -> None:
217
+ pane = self._get_pane(pane_id)
218
+ payload = text.replace("\r\n", "\n")
219
+ pane.send_keys(f"\x1b[200~{payload}\x1b[201~", enter=True)
220
+
221
+ def _codex_command(self, command: str) -> str:
222
+ return command
223
+
224
+ def _broadcast_keys(self, pane_ids: Sequence[str], key: str, *, enter: bool) -> None:
225
+ for pane_id in pane_ids:
226
+ pane = self._get_pane(pane_id)
227
+ pane.send_keys(key, enter=enter)
228
+ if self.backtrack_delay > 0:
229
+ time.sleep(self.backtrack_delay)
230
+
231
+ def _configure_session(self, session) -> None:
232
+ commands = [
233
+ ("set-option", "-g", "mouse", "on"),
234
+ ("set-option", "-g", "pane-border-style", "fg=green"),
235
+ ("set-option", "-g", "pane-active-border-style", "fg=orange"),
236
+ ("set-option", "-g", "pane-border-status", "top"),
237
+ ("set-option", "-g", "pane-border-format", "#{pane_title}"),
238
+ ("set-option", "-g", "display-panes-colour", "green"),
239
+ ("set-option", "-g", "display-panes-active-colour", "orange"),
240
+ ]
241
+ for args in commands:
242
+ try:
243
+ session.cmd(*args)
244
+ except Exception: # pragma: no cover - 一部オプション非対応のtmux向けフォールバック
245
+ continue
@@ -0,0 +1,119 @@
1
+ """Git worktree を管理するサービス."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Dict, Optional
8
+
9
+ import git
10
+
11
+
12
+ class WorktreeManager:
13
+ """ワーカー/ボス用の git worktree を生成・整備する."""
14
+
15
+ def __init__(
16
+ self,
17
+ root: Path,
18
+ worker_count: int,
19
+ session_namespace: Optional[str] = None,
20
+ storage_root: Optional[Path] = None,
21
+ ) -> None:
22
+ self.root = Path(root)
23
+ self.worker_count = worker_count
24
+ self.session_namespace = session_namespace
25
+ self._repo = git.Repo(self.root)
26
+ self._ensure_repo_initialized()
27
+ self._storage_root = Path(storage_root) if storage_root is not None else self.root
28
+ self._session_root = self._resolve_session_root()
29
+ self.worktrees_dir = self._session_root / "worktrees"
30
+ self.boss_path = self.worktrees_dir / "boss"
31
+ self._worker_branch_template, self._boss_branch = self._resolve_branch_templates()
32
+ self._initialized = False
33
+ self._worker_paths: Dict[str, Path] = {}
34
+
35
+ def prepare(self) -> Dict[str, Path]:
36
+ self.worktrees_dir.mkdir(parents=True, exist_ok=True)
37
+ mapping: Dict[str, Path] = {}
38
+ for index in range(1, self.worker_count + 1):
39
+ worker_name = f"worker-{index}"
40
+ worktree_path = self.worktrees_dir / worker_name
41
+ branch_name = self.worker_branch(worker_name)
42
+ if not self._initialized or worker_name not in self._worker_paths:
43
+ self._recreate_worktree(worktree_path, branch_name)
44
+ else:
45
+ self._reset_worktree(worktree_path)
46
+ mapping[worker_name] = worktree_path
47
+ if self._initialized:
48
+ existing = set(self._worker_paths)
49
+ target = set(mapping)
50
+ for obsolete in existing - target:
51
+ self._remove_worktree(self.worktrees_dir / obsolete)
52
+
53
+ if not self._initialized:
54
+ self._recreate_worktree(self.boss_path, self.boss_branch)
55
+ else:
56
+ self._reset_worktree(self.boss_path)
57
+ self._worker_paths = mapping
58
+ self._initialized = True
59
+ return mapping
60
+
61
+ def worker_branch(self, worker_name: str) -> str:
62
+ return self._worker_branch_template.format(name=worker_name)
63
+
64
+ @property
65
+ def boss_branch(self) -> str:
66
+ return self._boss_branch
67
+
68
+ def _ensure_repo_initialized(self) -> None:
69
+ try:
70
+ _ = self._repo.head.commit # type: ignore[attr-defined]
71
+ except ValueError as exc:
72
+ raise RuntimeError(
73
+ "Git repository has no commits. Create an initial commit before running parallel-dev."
74
+ ) from exc
75
+
76
+ def _recreate_worktree(self, path: Path, branch_name: str) -> None:
77
+ if path.exists():
78
+ try:
79
+ self._repo.git.worktree("remove", "--force", str(path))
80
+ except git.GitCommandError:
81
+ shutil.rmtree(path, ignore_errors=True)
82
+ if path.exists():
83
+ shutil.rmtree(path, ignore_errors=True)
84
+ self._repo.git.worktree(
85
+ "add",
86
+ "-B",
87
+ branch_name,
88
+ str(path),
89
+ "HEAD",
90
+ )
91
+
92
+ def _reset_worktree(self, path: Path) -> None:
93
+ if not path.exists():
94
+ return
95
+ repo = git.Repo(path)
96
+ repo.git.reset("--hard", "HEAD")
97
+ repo.git.clean("-fdx")
98
+
99
+ def _remove_worktree(self, path: Path) -> None:
100
+ if not path.exists():
101
+ return
102
+ try:
103
+ self._repo.git.worktree("remove", "--force", str(path))
104
+ except git.GitCommandError:
105
+ shutil.rmtree(path, ignore_errors=True)
106
+ if path.exists():
107
+ shutil.rmtree(path, ignore_errors=True)
108
+
109
+ def _resolve_session_root(self) -> Path:
110
+ base = self._storage_root / ".parallel-dev"
111
+ if self.session_namespace:
112
+ return base / "sessions" / self.session_namespace
113
+ return base
114
+
115
+ def _resolve_branch_templates(self) -> tuple[str, str]:
116
+ if self.session_namespace:
117
+ prefix = f"parallel-dev/{self.session_namespace}"
118
+ return f"{prefix}/{{name}}", f"{prefix}/boss"
119
+ return "parallel-dev/{name}", "parallel-dev/boss"
@@ -0,0 +1,20 @@
1
+ """Persistence-related store helpers."""
2
+
3
+ from .session_manifest import ManifestStore, PaneRecord, SessionManifest, SessionReference
4
+ from .settings_store import (
5
+ SettingsStore,
6
+ default_config_dir,
7
+ resolve_settings_path,
8
+ resolve_worktree_root,
9
+ )
10
+
11
+ __all__ = [
12
+ "ManifestStore",
13
+ "PaneRecord",
14
+ "SessionManifest",
15
+ "SessionReference",
16
+ "SettingsStore",
17
+ "default_config_dir",
18
+ "resolve_settings_path",
19
+ "resolve_worktree_root",
20
+ ]