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,1398 @@
1
+ """Controller and orchestration helpers for the Sibyl CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from concurrent.futures import Future
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Set, Mapping, Awaitable, Union
13
+ import platform
14
+ import subprocess
15
+ import shlex
16
+ import shutil
17
+ from subprocess import PIPE
18
+
19
+ import git
20
+
21
+ from .commands import CommandOption, CommandSpecEntry, CommandSuggestion, build_command_specs
22
+ from .events import ControllerEventType
23
+ from .flow import WorkerFlowHelper
24
+ from .pause import PauseHelper
25
+ from .history import HistoryManager
26
+ from ..orchestrator import (
27
+ BossMode,
28
+ CandidateInfo,
29
+ CycleLayout,
30
+ OrchestrationResult,
31
+ Orchestrator,
32
+ SelectionDecision,
33
+ WorkerDecision,
34
+ MergeMode,
35
+ MergeOutcome,
36
+ )
37
+ from ..services import CodexMonitor, LogManager, TmuxLayoutManager, WorktreeManager
38
+ from ..stores import (
39
+ ManifestStore,
40
+ PaneRecord,
41
+ SessionManifest,
42
+ SessionReference,
43
+ SettingsStore,
44
+ default_config_dir,
45
+ resolve_settings_path,
46
+ resolve_worktree_root,
47
+ )
48
+ from .workflow_runner import WorkflowRunner
49
+
50
+ def _ensure_logs_directory(identifier: str) -> Path:
51
+ """Create and return the logs directory for the given identifier."""
52
+ base_dir = default_config_dir() / "logs"
53
+ base_dir.mkdir(parents=True, exist_ok=True)
54
+ target = base_dir / identifier
55
+ target.mkdir(parents=True, exist_ok=True)
56
+ return target
57
+
58
+
59
+ class SessionMode(str, Enum):
60
+ PARALLEL = "parallel"
61
+ MAIN = "main"
62
+
63
+
64
+ class FlowMode(str, Enum):
65
+ MANUAL = "manual"
66
+ AUTO_REVIEW = "auto_review"
67
+ AUTO_SELECT = "auto_select"
68
+ FULL_AUTO = "full_auto"
69
+
70
+
71
+ FLOW_MODE_LABELS = {
72
+ FlowMode.MANUAL: "Manual",
73
+ FlowMode.AUTO_REVIEW: "Auto Review",
74
+ FlowMode.AUTO_SELECT: "Auto Select",
75
+ FlowMode.FULL_AUTO: "Full Auto",
76
+ }
77
+ MERGE_STRATEGY_LABELS = {
78
+ MergeMode.MANUAL: "Manual",
79
+ MergeMode.AUTO: "Auto",
80
+ }
81
+
82
+
83
+ class TmuxAttachManager:
84
+ """Launch an external terminal to attach to the tmux session."""
85
+
86
+ def attach(self, session_name: str, workdir: Optional[Path] = None) -> subprocess.CompletedProcess:
87
+ system = platform.system().lower()
88
+ command_string = self._build_command_string(session_name, workdir)
89
+
90
+ if "darwin" in system:
91
+ escaped_command = self._escape_for_applescript(command_string)
92
+ apple_script = (
93
+ 'tell application "Terminal"\n'
94
+ f' do script "{escaped_command}"\n'
95
+ " activate\n"
96
+ "end tell"
97
+ )
98
+ command = ["osascript", "-e", apple_script]
99
+ try:
100
+ return subprocess.run(command, check=False)
101
+ except FileNotFoundError:
102
+ # Fall through to generic fallback below.
103
+ pass
104
+ elif "linux" in system:
105
+ command = ["gnome-terminal", "--", "bash", "-lc", command_string]
106
+ try:
107
+ return subprocess.run(command, check=False)
108
+ except FileNotFoundError:
109
+ # gnome-terminal not available; fall back to shell attach.
110
+ pass
111
+
112
+ fallback_command: List[str]
113
+ if shutil.which("bash"):
114
+ fallback_command = ["bash", "-lc", command_string]
115
+ else:
116
+ fallback_command = ["tmux", "attach", "-t", session_name]
117
+
118
+ try:
119
+ return subprocess.run(fallback_command, check=False)
120
+ except FileNotFoundError:
121
+ return subprocess.CompletedProcess(fallback_command, returncode=127)
122
+
123
+ def is_attached(self, session_name: str) -> bool:
124
+ try:
125
+ result = subprocess.run(
126
+ [
127
+ "tmux",
128
+ "display-message",
129
+ "-t",
130
+ session_name,
131
+ "-p",
132
+ "#{session_attached}",
133
+ ],
134
+ check=False,
135
+ stdout=PIPE,
136
+ stderr=PIPE,
137
+ text=True,
138
+ )
139
+ except FileNotFoundError:
140
+ return False
141
+
142
+ if result.returncode != 0:
143
+ return False
144
+ output = (result.stdout or "").strip().lower()
145
+ return output in {"1", "true"}
146
+
147
+ def session_exists(self, session_name: str) -> bool:
148
+ try:
149
+ result = subprocess.run(
150
+ [
151
+ "tmux",
152
+ "has-session",
153
+ "-t",
154
+ session_name,
155
+ ],
156
+ check=False,
157
+ stdout=PIPE,
158
+ stderr=PIPE,
159
+ text=True,
160
+ )
161
+ except FileNotFoundError:
162
+ return False
163
+ return result.returncode == 0
164
+
165
+ def _build_command_string(self, session_name: str, workdir: Optional[Path]) -> str:
166
+ return f"tmux attach -t {shlex.quote(session_name)}"
167
+
168
+ @staticmethod
169
+ def _escape_for_applescript(command: str) -> str:
170
+ return command.replace("\\", "\\\\").replace('"', '\\"')
171
+
172
+
173
+ @dataclass
174
+ class SessionConfig:
175
+ session_id: str
176
+ tmux_session: str
177
+ worker_count: int
178
+ mode: SessionMode
179
+ logs_root: Path
180
+ boss_mode: BossMode = BossMode.SCORE
181
+ flow_mode: FlowMode = FlowMode.MANUAL
182
+ reuse_existing_session: bool = False
183
+ merge_mode: MergeMode = MergeMode.MANUAL
184
+
185
+ @dataclass
186
+ class SelectionContext:
187
+ future: Future
188
+ candidates: List[CandidateInfo]
189
+ scoreboard: Dict[str, Dict[str, object]]
190
+
191
+
192
+ class CLIController:
193
+ """Core orchestration controller decoupled from Textual UI."""
194
+
195
+ def __init__(
196
+ self,
197
+ *,
198
+ event_handler: Callable[[str, Dict[str, object]], None],
199
+ orchestrator_builder: Callable[..., Orchestrator] = None,
200
+ manifest_store: Optional[ManifestStore] = None,
201
+ worktree_root: Optional[Path] = None,
202
+ settings_path: Optional[Path] = None,
203
+ ) -> None:
204
+ self._event_handler = event_handler
205
+ self._builder = orchestrator_builder or self._default_builder
206
+ self._manifest_store = manifest_store or ManifestStore()
207
+ self._worktree_root = Path(worktree_root or Path.cwd())
208
+ self._config = self._create_initial_config()
209
+ self._last_scoreboard: Dict[str, Dict[str, object]] = {}
210
+ self._last_instruction: Optional[str] = None
211
+ self._running: bool = False
212
+ self._selection_context: Optional[SelectionContext] = None
213
+ self._resume_options: List[SessionReference] = []
214
+ self._last_selected_session: Optional[str] = None
215
+ self._active_main_session_id: Optional[str] = None
216
+ self._paused: bool = False
217
+ self._history = HistoryManager()
218
+ self._cycle_counter: int = 0
219
+ self._current_cycle_id: Optional[int] = None
220
+ self._cancelled_cycles: Set[int] = set()
221
+ self._last_tmux_manager: Optional[TmuxLayoutManager] = None
222
+ self._active_orchestrator: Optional[Orchestrator] = None
223
+ self._queued_instruction: Optional[str] = None
224
+ self._continue_future: Optional[Future] = None
225
+ self._continuation_input_future: Optional[Future] = None
226
+ self._awaiting_continuation_input: bool = False
227
+ self._attach_manager = TmuxAttachManager()
228
+ explicit_settings_path = Path(settings_path).expanduser() if settings_path else None
229
+ resolved_settings_path = resolve_settings_path(explicit_settings_path)
230
+ self._settings_store = SettingsStore(resolved_settings_path)
231
+ self._worktree_storage_root = resolve_worktree_root(
232
+ self._settings_store.worktree_root,
233
+ self._worktree_root,
234
+ )
235
+ self._attach_mode: str = self._settings_store.attach
236
+ saved_boss_mode = self._settings_store.boss
237
+ try:
238
+ self._config.boss_mode = BossMode(saved_boss_mode)
239
+ except ValueError:
240
+ self._config.boss_mode = BossMode.SCORE
241
+ saved_flow_mode = self._settings_store.flow
242
+ try:
243
+ self._flow_mode = FlowMode(saved_flow_mode)
244
+ except ValueError:
245
+ self._flow_mode = FlowMode.FULL_AUTO
246
+ self._config.flow_mode = self._flow_mode
247
+ saved_merge_mode = self._settings_store.merge
248
+ try:
249
+ self._merge_mode = MergeMode(saved_merge_mode)
250
+ except ValueError:
251
+ self._merge_mode = MergeMode.AUTO
252
+ self._config.merge_mode = self._merge_mode
253
+ self._auto_commit_enabled: bool = self._settings_store.commit == "auto"
254
+ try:
255
+ self._config.worker_count = max(1, int(self._settings_store.parallel or "3"))
256
+ except ValueError:
257
+ self._config.worker_count = 3
258
+ try:
259
+ self._config.mode = SessionMode(self._settings_store.mode)
260
+ except ValueError:
261
+ self._config.mode = SessionMode.PARALLEL
262
+ self._session_namespace: str = self._config.session_id
263
+ self._last_started_main_session_id: Optional[str] = None
264
+ self._pre_cycle_selected_session: Optional[str] = None
265
+ self._pre_cycle_selected_session_set: bool = False
266
+ self._command_specs: Dict[str, CommandSpecEntry] = build_command_specs(
267
+ self,
268
+ flow_mode_cls=FlowMode,
269
+ boss_mode_cls=BossMode,
270
+ merge_mode_cls=MergeMode,
271
+ )
272
+ self._worker_flow = WorkerFlowHelper(self, FlowMode)
273
+ self._pause_helper = PauseHelper(self)
274
+ self._log_hook = self._handle_orchestrator_log
275
+ self._workflow = WorkflowRunner(self)
276
+
277
+ async def handle_input(self, user_input: str) -> None:
278
+ raw_text = user_input.rstrip("\n")
279
+
280
+ if self._awaiting_continuation_input:
281
+ stripped = raw_text.strip()
282
+ if not stripped:
283
+ self._emit(ControllerEventType.LOG, {"text": "追加指示が空です。何をするか具体的に入力してください。"})
284
+ return
285
+ if stripped.startswith("/"):
286
+ self._emit(ControllerEventType.LOG, {"text": "追加指示入力中です。コマンドではなくテキストで指示を入力してください。"})
287
+ return
288
+ self._submit_continuation_instruction(raw_text)
289
+ return
290
+
291
+ text = raw_text.strip()
292
+ if not text:
293
+ return
294
+ if text.startswith("/"):
295
+ self._record_history(text)
296
+ await self._execute_text_command(text)
297
+ return
298
+ if self._paused:
299
+ self._record_history(text)
300
+ await self._dispatch_paused_instruction(text)
301
+ return
302
+ if self._running:
303
+ if self._current_cycle_id and self._current_cycle_id in self._cancelled_cycles:
304
+ self._queued_instruction = text
305
+ self._emit(ControllerEventType.LOG, {"text": "キャンセル処理中です。完了後にこの指示を実行します。"})
306
+ return
307
+ if self._running:
308
+ self._emit(ControllerEventType.LOG, {"text": "別の指示を処理中です。完了を待ってから再度実行してください。"})
309
+ return
310
+ self._record_history(text)
311
+ await self._run_instruction(text)
312
+
313
+ async def _execute_text_command(self, command_text: str) -> None:
314
+ parts = command_text.split(maxsplit=1)
315
+ if not parts:
316
+ return
317
+ name = parts[0].lower()
318
+ if name == "/quit":
319
+ name = "/exit"
320
+ if name == "/marge":
321
+ name = "/merge"
322
+ option = parts[1].strip() if len(parts) > 1 else None
323
+ if option == "":
324
+ option = None
325
+ await self.execute_command(name, option)
326
+
327
+ def get_command_suggestions(self, prefix: str) -> List[CommandSuggestion]:
328
+ prefix = (prefix or "/").lower()
329
+ if not prefix.startswith("/"):
330
+ prefix = "/" + prefix
331
+ suggestions: List[CommandSuggestion] = []
332
+ for name in sorted(self._command_specs.keys()):
333
+ if name.startswith(prefix):
334
+ spec = self._command_specs[name]
335
+ suggestions.append(CommandSuggestion(name=name, description=spec.description))
336
+ if not suggestions and prefix == "/":
337
+ for name in sorted(self._command_specs.keys()):
338
+ spec = self._command_specs[name]
339
+ suggestions.append(CommandSuggestion(name=name, description=spec.description))
340
+ return suggestions
341
+
342
+ def get_command_options(self, name: str) -> List[CommandOption]:
343
+ spec = self._command_specs.get(name)
344
+ if not spec:
345
+ return []
346
+ options: List[CommandOption] = []
347
+ if spec.options_provider:
348
+ options = spec.options_provider()
349
+ elif spec.options:
350
+ options = list(spec.options)
351
+ return options
352
+
353
+ async def execute_command(self, name: str, option: Optional[object] = None) -> None:
354
+ spec = self._command_specs.get(name)
355
+ if spec is None:
356
+ self._emit(ControllerEventType.LOG, {"text": f"未知のコマンドです: {name}"})
357
+ return
358
+ await spec.handler(option)
359
+
360
+ async def _cmd_exit(self, option: Optional[object]) -> None:
361
+ self._emit(ControllerEventType.QUIT, {})
362
+
363
+ async def _cmd_help(self, option: Optional[object]) -> None:
364
+ lines = ["利用可能なコマンド:"]
365
+ for name in sorted(self._command_specs.keys()):
366
+ spec = self._command_specs[name]
367
+ lines.append(f" {name:10s} : {spec.description}")
368
+ self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
369
+
370
+ async def _cmd_status(self, option: Optional[object]) -> None:
371
+ self._emit_status("待機中")
372
+
373
+ async def _cmd_scoreboard(self, option: Optional[object]) -> None:
374
+ self._emit(ControllerEventType.SCOREBOARD, {"scoreboard": self._last_scoreboard})
375
+
376
+ async def _cmd_done(self, option: Optional[object]) -> None:
377
+ if self._continue_future and not self._continue_future.done():
378
+ self._continue_future.set_result("done")
379
+ self._emit(ControllerEventType.LOG, {"text": "/done を受け付けました。採点フェーズへ移行します。"})
380
+ return
381
+ if self._active_orchestrator:
382
+ count = self._active_orchestrator.force_complete_workers()
383
+ if count:
384
+ self._emit(ControllerEventType.LOG, {"text": f"/done を検知として扱い、{count} ワーカーを完了済みに設定しました。"})
385
+ else:
386
+ self._emit(ControllerEventType.LOG, {"text": "完了扱いにするワーカーセッションが見つかりませんでした。"})
387
+ else:
388
+ self._emit(ControllerEventType.LOG, {"text": "現在進行中のワーカークセッションがないため /done を適用できません。"})
389
+
390
+ async def _cmd_continue(self, option: Optional[object]) -> None:
391
+ if self._continue_future and not self._continue_future.done():
392
+ self._continue_future.set_result("continue")
393
+ self._emit(ControllerEventType.LOG, {"text": "/continue を受け付けました。追加指示を入力してください。"})
394
+ else:
395
+ self._emit(ControllerEventType.LOG, {"text": "/continue は現在利用できません。"})
396
+
397
+ def _await_worker_command(self) -> str:
398
+ future = Future()
399
+ self._continue_future = future
400
+ self._emit(
401
+ ControllerEventType.LOG,
402
+ {
403
+ "text": (
404
+ "ワーカーの処理が完了しました。追加で作業させるには /continue を、"
405
+ "評価へ進むには /done を入力してください。"
406
+ )
407
+ },
408
+ )
409
+ try:
410
+ decision = future.result()
411
+ finally:
412
+ self._continue_future = None
413
+ return str(decision)
414
+
415
+ def _await_continuation_instruction(self) -> str:
416
+ future = Future()
417
+ self._continuation_input_future = future
418
+ self._awaiting_continuation_input = True
419
+ self._emit(
420
+ ControllerEventType.LOG,
421
+ {"text": "追加指示を入力してください。完了したらワーカーのフラグ更新を待ちます。"},
422
+ )
423
+ try:
424
+ instruction = future.result()
425
+ finally:
426
+ self._continuation_input_future = None
427
+ self._awaiting_continuation_input = False
428
+ return str(instruction).strip()
429
+
430
+ def _submit_continuation_instruction(self, instruction: str) -> None:
431
+ future = self._continuation_input_future
432
+ self._continuation_input_future = None
433
+ self._awaiting_continuation_input = False
434
+ if future and not future.done():
435
+ future.set_result(instruction.strip())
436
+ self._emit(ControllerEventType.LOG, {"text": "追加指示を受け付けました。ワーカーの完了を待ちます。"})
437
+
438
+ async def _cmd_boss(self, option: Optional[object]) -> None:
439
+ if option is None:
440
+ mode = self._config.boss_mode.value
441
+ self._emit(
442
+ ControllerEventType.LOG,
443
+ {
444
+ "text": (
445
+ "現在の Boss モードは {mode} です。"
446
+ " (skip=採点スキップ, score=採点のみ, rewrite=再実装)"
447
+ ).format(mode=mode)
448
+ },
449
+ )
450
+ return
451
+ value = str(option).strip()
452
+ mapping = {
453
+ "skip": BossMode.SKIP,
454
+ "score": BossMode.SCORE,
455
+ "rewrite": BossMode.REWRITE,
456
+ }
457
+ new_mode = mapping.get(value.lower())
458
+ if new_mode is None:
459
+ # Treat unknown arguments as regular instructions for compatibility
460
+ await self.handle_input(f"{value}")
461
+ return
462
+ if new_mode == self._config.boss_mode:
463
+ self._emit(ControllerEventType.LOG, {"text": f"Boss モードは既に {new_mode.value} です。"})
464
+ return
465
+ self._config.boss_mode = new_mode
466
+ self._settings_store.boss = new_mode.value
467
+ self._emit(ControllerEventType.LOG, {"text": f"Boss モードを {new_mode.value} に設定しました。"})
468
+
469
+ async def _cmd_flow(self, option: Optional[object]) -> None:
470
+ if option is None:
471
+ lines = [
472
+ f"現在のフローモード: {self._flow_mode_display()}",
473
+ "利用可能なモード:",
474
+ ]
475
+ for mode in FlowMode:
476
+ lines.append(f" {mode.value:12s} : {self._flow_mode_display(mode)}")
477
+ lines.append("使い方: /flow [manual|auto_review|auto_select|full_auto]")
478
+ self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
479
+ return
480
+
481
+ token = str(option).strip().lower().replace("-", "_")
482
+ mapping = {mode.value: mode for mode in FlowMode}
483
+ new_mode = mapping.get(token)
484
+ if new_mode is None:
485
+ self._emit(
486
+ ControllerEventType.LOG,
487
+ {"text": "使い方: /flow [manual|auto_review|auto_select|full_auto]"},
488
+ )
489
+ return
490
+ if new_mode == getattr(self, "_flow_mode", FlowMode.MANUAL):
491
+ self._emit(ControllerEventType.LOG, {"text": f"フローモードは既に {self._flow_mode_display(new_mode)} です。"})
492
+ return
493
+
494
+ self._flow_mode = new_mode
495
+ self._config.flow_mode = new_mode
496
+ self._settings_store.flow = new_mode.value
497
+ self._emit(ControllerEventType.LOG, {"text": f"フローモードを {self._flow_mode_display(new_mode)} に設定しました。"})
498
+ self._emit_status("待機中")
499
+
500
+ async def _cmd_merge(self, option: Optional[object]) -> None:
501
+ if option is None:
502
+ lines = [
503
+ f"現在のマージ戦略: {self._merge_strategy_display()}",
504
+ "利用可能な戦略:",
505
+ ]
506
+ for strategy in MergeMode:
507
+ lines.append(f" {strategy.value:16s} : {self._merge_strategy_display(strategy)}")
508
+ lines.append("使い方: /merge [manual|auto]")
509
+ self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
510
+ return
511
+
512
+ token = str(option).strip().lower().replace("-", "_")
513
+ mapping = {strategy.value: strategy for strategy in MergeMode}
514
+ new_strategy = mapping.get(token)
515
+ if new_strategy is None:
516
+ self._emit(ControllerEventType.LOG, {"text": "使い方: /merge [manual|auto]"})
517
+ return
518
+ if new_strategy == getattr(self, "_merge_mode", MergeMode.MANUAL):
519
+ self._emit(ControllerEventType.LOG, {"text": f"マージ戦略は既に {self._merge_strategy_display(new_strategy)} です。"})
520
+ return
521
+
522
+ self._merge_mode = new_strategy
523
+ self._config.merge_mode = new_strategy
524
+ self._settings_store.merge = new_strategy.value
525
+ self._emit(ControllerEventType.LOG, {"text": f"マージ戦略を {self._merge_strategy_display(new_strategy)} に設定しました。"})
526
+ self._emit_status("待機中")
527
+
528
+ async def _cmd_attach(self, option: Optional[object]) -> None:
529
+ mode = str(option).lower() if option is not None else None
530
+ if mode in {"auto", "manual"}:
531
+ self._attach_mode = mode
532
+ self._emit(ControllerEventType.LOG, {"text": f"/attach モードを {mode} に設定しました。"})
533
+ self._settings_store.attach = mode
534
+ return
535
+ if mode == "now" or option is None:
536
+ await self._handle_attach_command(force=True)
537
+ return
538
+ self._emit(ControllerEventType.LOG, {"text": "使い方: /attach [auto|manual|now]"})
539
+
540
+ async def _cmd_parallel(self, option: Optional[object]) -> None:
541
+ if option is None:
542
+ self._emit(ControllerEventType.LOG, {"text": "使い方: /parallel <ワーカー数>"})
543
+ return
544
+ try:
545
+ value = int(str(option))
546
+ except ValueError:
547
+ self._emit(ControllerEventType.LOG, {"text": "ワーカー数は数字で指定してください。"})
548
+ return
549
+ if value < 1:
550
+ self._emit(ControllerEventType.LOG, {"text": "ワーカー数は1以上で指定してください。"})
551
+ return
552
+ self._config.worker_count = value
553
+ self._settings_store.parallel = str(value)
554
+ self._emit_status("設定を更新しました。")
555
+
556
+ async def _cmd_mode(self, option: Optional[object]) -> None:
557
+ mode = str(option).lower() if option is not None else None
558
+ if mode not in {"main", "parallel"}:
559
+ self._emit(ControllerEventType.LOG, {"text": "使い方: /mode main | /mode parallel"})
560
+ return
561
+ self._config.mode = SessionMode(mode)
562
+ self._settings_store.mode = mode
563
+ self._emit_status("設定を更新しました。")
564
+
565
+ async def _cmd_resume(self, option: Optional[object]) -> None:
566
+ if option is None:
567
+ self._list_sessions()
568
+ return
569
+ index: Optional[int] = None
570
+ if isinstance(option, int):
571
+ index = option
572
+ else:
573
+ try:
574
+ index = int(str(option))
575
+ except ValueError:
576
+ index = self._find_resume_index_by_session(str(option))
577
+ if index is None:
578
+ self._emit(ControllerEventType.LOG, {"text": "指定されたセッションが見つかりません。"})
579
+ return
580
+ self._load_session(index)
581
+
582
+ async def _cmd_log(self, option: Optional[object]) -> None:
583
+ if option is None:
584
+ self._emit(
585
+ ControllerEventType.LOG,
586
+ {
587
+ "text": "使い方: /log copy | /log save <path>\n"
588
+ " copy : 現在のログをクリップボードへコピー\n"
589
+ " save : 指定パスへログを書き出す"
590
+ },
591
+ )
592
+ return
593
+ action: str
594
+ argument: Optional[str] = None
595
+ if isinstance(option, str):
596
+ sub_parts = option.split(maxsplit=1)
597
+ action = sub_parts[0].lower()
598
+ if len(sub_parts) > 1:
599
+ argument = sub_parts[1].strip()
600
+ else:
601
+ action = str(option).lower()
602
+ if action == "copy":
603
+ self._emit(ControllerEventType.LOG_COPY, {})
604
+ return
605
+ if action == "save":
606
+ if not argument:
607
+ self._emit(ControllerEventType.LOG, {"text": "保存先パスを指定してください。例: /log save logs/output.log"})
608
+ return
609
+ self._emit(ControllerEventType.LOG_SAVE, {"path": argument})
610
+ return
611
+ self._emit(ControllerEventType.LOG, {"text": "使い方: /log copy | /log save <path>"})
612
+
613
+ async def _cmd_commit(self, option: Optional[object]) -> None:
614
+ mode = "manual"
615
+ if option is not None:
616
+ mode = str(option).lower()
617
+ if mode == "manual":
618
+ self._auto_commit_enabled = False
619
+ self._settings_store.commit = "manual"
620
+ self._perform_commit(auto=False, quiet_when_no_change=False)
621
+ return
622
+ if mode == "auto":
623
+ if self._auto_commit_enabled:
624
+ self._auto_commit_enabled = False
625
+ self._settings_store.commit = "manual"
626
+ self._emit(ControllerEventType.LOG, {"text": "自動コミットを無効にしました。"})
627
+ else:
628
+ self._auto_commit_enabled = True
629
+ self._settings_store.commit = "auto"
630
+ self._emit(ControllerEventType.LOG, {"text": "自動コミットを有効にしました。"})
631
+ self._perform_commit(auto=True, quiet_when_no_change=True)
632
+ return
633
+ self._emit(ControllerEventType.LOG, {"text": "/commit は manual または auto を指定してください。"})
634
+
635
+ def _find_resume_index_by_session(self, token: str) -> Optional[int]:
636
+ if not self._resume_options:
637
+ self._resume_options = self._manifest_store.list_sessions()
638
+ for idx, ref in enumerate(self._resume_options, start=1):
639
+ if ref.session_id == token or ref.session_id.startswith(token):
640
+ return idx
641
+ return None
642
+
643
+ def broadcast_escape(self) -> None:
644
+ session_name = self._config.tmux_session
645
+ pane_ids = self._tmux_list_panes()
646
+ if pane_ids is None:
647
+ return
648
+ if not pane_ids:
649
+ self._emit(ControllerEventType.LOG, {"text": f"tmuxセッション {session_name} にペインが見つかりませんでした。"})
650
+ return
651
+
652
+ for pane_id in pane_ids:
653
+ subprocess.run(
654
+ ["tmux", "send-keys", "-t", pane_id, "Escape"],
655
+ check=False,
656
+ )
657
+ self._emit(ControllerEventType.LOG, {"text": f"tmuxセッション {session_name} の {len(pane_ids)} 個のペインへEscapeを送信しました。"})
658
+
659
+ def handle_escape(self) -> None:
660
+ self._pause_helper.handle_escape()
661
+ def _tmux_list_panes(self) -> Optional[List[str]]:
662
+ session_name = self._config.tmux_session
663
+ try:
664
+ result = subprocess.run(
665
+ ["tmux", "list-panes", "-t", session_name, "-F", "#{pane_id}"],
666
+ check=False,
667
+ stdout=PIPE,
668
+ stderr=PIPE,
669
+ text=True,
670
+ )
671
+ except FileNotFoundError:
672
+ self._emit(ControllerEventType.LOG, {"text": "tmux コマンドが見つかりません。tmuxがインストールされているか確認してください。"})
673
+ return None
674
+ if result.returncode != 0:
675
+ message = (result.stderr or result.stdout or "").strip()
676
+ if message:
677
+ self._emit(ControllerEventType.LOG, {"text": f"tmux list-panes に失敗しました: {message}"})
678
+ return None
679
+ return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
680
+
681
+ async def _dispatch_paused_instruction(self, instruction: str) -> None:
682
+ await self._pause_helper.dispatch_paused_instruction(instruction)
683
+
684
+ def _record_cycle_snapshot(self, result: OrchestrationResult, cycle_id: int) -> None:
685
+ snapshot = {
686
+ "cycle_id": cycle_id,
687
+ "selected_session": result.selected_session,
688
+ "scoreboard": dict(result.sessions_summary),
689
+ "instruction": self._last_instruction,
690
+ }
691
+ self._history.record_cycle_snapshot(result, cycle_id, self._last_instruction)
692
+
693
+ def _handle_worker_decision(
694
+ self,
695
+ fork_map: Mapping[str, str],
696
+ completion_info: Mapping[str, Any],
697
+ layout: CycleLayout,
698
+ ) -> WorkerDecision:
699
+ return self._worker_flow.handle_worker_decision(fork_map, completion_info, layout)
700
+
701
+ def _record_history(self, text: str) -> None:
702
+ self._history.record_input(text)
703
+
704
+ def _perform_commit(self, *, auto: bool, quiet_when_no_change: bool) -> bool:
705
+ try:
706
+ repo = git.Repo(self._worktree_root)
707
+ except git.exc.InvalidGitRepositoryError:
708
+ self._emit(ControllerEventType.LOG, {"text": "Gitリポジトリが存在しません。`git init` を実行してください。"})
709
+ return False
710
+
711
+ if not repo.is_dirty(untracked_files=True):
712
+ if not quiet_when_no_change:
713
+ self._emit(ControllerEventType.LOG, {"text": "コミット対象の変更がありません。"})
714
+ return False
715
+
716
+ try:
717
+ repo.git.add(A=True)
718
+ except Exception as exc: # noqa: BLE001
719
+ self._emit(ControllerEventType.LOG, {"text": f"git add に失敗しました: {exc}"})
720
+ return False
721
+
722
+ prefix = "sibyl-auto-save" if auto else "sibyl-manual-save"
723
+ message = f"{prefix} {datetime.utcnow().isoformat(timespec='seconds')}Z"
724
+ try:
725
+ repo.index.commit(message)
726
+ except Exception as exc: # noqa: BLE001
727
+ self._emit(ControllerEventType.LOG, {"text": f"コミットに失敗しました: {exc}"})
728
+ return False
729
+
730
+ self._emit(ControllerEventType.LOG, {"text": f"変更をコミットしました: {message}"})
731
+ return True
732
+
733
+ def _maybe_auto_commit(self) -> None:
734
+ if not getattr(self, "_auto_commit_enabled", False):
735
+ return
736
+ self._perform_commit(auto=True, quiet_when_no_change=True)
737
+
738
+ def _request_selection(self, candidates: List[CandidateInfo], scoreboard: Optional[Dict[str, Dict[str, object]]] = None) -> Future:
739
+ future: Future = Future()
740
+ context = SelectionContext(
741
+ future=future,
742
+ candidates=candidates,
743
+ scoreboard=scoreboard or {},
744
+ )
745
+ self._selection_context = context
746
+ formatted = [f"{idx + 1}. {candidate.label}" for idx, candidate in enumerate(candidates)]
747
+ self._emit(
748
+ "selection_request",
749
+ {
750
+ "candidates": formatted,
751
+ "scoreboard": scoreboard or {},
752
+ },
753
+ )
754
+ return future
755
+
756
+ def _select_candidates(
757
+ self,
758
+ candidates: List[CandidateInfo],
759
+ scoreboard: Optional[Dict[str, Dict[str, object]]] = None,
760
+ ) -> SelectionDecision:
761
+ candidates, scoreboard = self._prune_boss_candidates(candidates, scoreboard)
762
+
763
+ mode = getattr(self, "_flow_mode", FlowMode.MANUAL)
764
+ if mode in {FlowMode.MANUAL, FlowMode.AUTO_REVIEW}:
765
+ future = self._request_selection(candidates, scoreboard)
766
+ return future.result()
767
+
768
+ decision = self._auto_select_decision(candidates, scoreboard or {})
769
+ if decision is None:
770
+ future = self._request_selection(candidates, scoreboard)
771
+ return future.result()
772
+
773
+ candidate_map = {candidate.key: candidate for candidate in candidates}
774
+ selected_candidate = candidate_map.get(decision.selected_key)
775
+ label = selected_candidate.label if selected_candidate else decision.selected_key
776
+ self._emit(
777
+ ControllerEventType.LOG,
778
+ {
779
+ "text": f"[flow {mode.value}] {label} を自動選択しました。",
780
+ },
781
+ )
782
+ return decision
783
+
784
+ def _auto_select_decision(
785
+ self,
786
+ candidates: List[CandidateInfo],
787
+ scoreboard: Dict[str, Dict[str, object]],
788
+ ) -> Optional[SelectionDecision]:
789
+ boss_mode = self._config.boss_mode
790
+ if boss_mode == BossMode.SKIP:
791
+ return None
792
+
793
+ candidate_map = {candidate.key: candidate for candidate in candidates}
794
+
795
+ def score_from_entry(entry: Dict[str, object]) -> Optional[float]:
796
+ score = entry.get("score")
797
+ if score is None:
798
+ return None
799
+ try:
800
+ return float(score)
801
+ except (TypeError, ValueError):
802
+ return None
803
+
804
+ selected_key: Optional[str]
805
+ if boss_mode == BossMode.REWRITE and "boss" in candidate_map:
806
+ selected_key = "boss"
807
+ else:
808
+ selected_key = None
809
+ best_score: Optional[float] = None
810
+ for candidate in candidates:
811
+ entry = scoreboard.get(candidate.key, {})
812
+ score_value = score_from_entry(entry)
813
+ if score_value is None:
814
+ continue
815
+ if best_score is None or score_value > best_score:
816
+ best_score = score_value
817
+ selected_key = candidate.key
818
+ if selected_key is None:
819
+ return None
820
+
821
+ scores: Dict[str, float] = {}
822
+ comments: Dict[str, str] = {}
823
+ for candidate in candidates:
824
+ entry = scoreboard.get(candidate.key, {})
825
+ score_value = score_from_entry(entry)
826
+ scores[candidate.key] = score_value if score_value is not None else 0.0
827
+ comment_value = entry.get("comment")
828
+ if isinstance(comment_value, str) and comment_value:
829
+ comments[candidate.key] = comment_value
830
+
831
+ return SelectionDecision(selected_key=selected_key, scores=scores, comments=comments)
832
+
833
+ def _prune_boss_candidates(
834
+ self,
835
+ candidates: List[CandidateInfo],
836
+ scoreboard: Optional[Dict[str, Dict[str, object]]],
837
+ ) -> tuple[List[CandidateInfo], Optional[Dict[str, Dict[str, object]]]]:
838
+ if self._config.boss_mode == BossMode.REWRITE:
839
+ return candidates, scoreboard
840
+ filtered = [candidate for candidate in candidates if candidate.key != "boss"]
841
+ if not scoreboard or "boss" not in scoreboard:
842
+ return filtered, scoreboard
843
+ trimmed = {key: value for key, value in scoreboard.items() if key != "boss"}
844
+ return filtered, trimmed
845
+
846
+ def history_previous(self) -> Optional[str]:
847
+ return self._history.history_previous()
848
+
849
+ def history_next(self) -> Optional[str]:
850
+ return self._history.history_next()
851
+
852
+ def history_reset(self) -> None:
853
+ self._history.reset_cursor()
854
+
855
+ @property
856
+ def _cycle_history(self) -> List[Dict[str, object]]:
857
+ return self._history._cycle_history
858
+
859
+ @_cycle_history.setter
860
+ def _cycle_history(self, value: List[Dict[str, object]]) -> None:
861
+ self._history.set_cycle_history(value)
862
+
863
+ def _perform_revert(self, silent: bool = False) -> None:
864
+ tmux_manager = self._last_tmux_manager
865
+ pane_ids = self._tmux_list_panes() or []
866
+ main_pane = pane_ids[0] if pane_ids else None
867
+
868
+ snapshot = self._history.last_snapshot()
869
+ if snapshot is None:
870
+ session_id: Optional[str]
871
+ if self._pre_cycle_selected_session_set and self._pre_cycle_selected_session:
872
+ session_id = self._pre_cycle_selected_session
873
+ elif self._active_main_session_id:
874
+ session_id = self._active_main_session_id
875
+ else:
876
+ session_id = self._last_started_main_session_id
877
+ self._last_selected_session = session_id
878
+ self._active_main_session_id = session_id
879
+ self._last_scoreboard = {}
880
+ self._last_instruction = None
881
+ self._paused = False
882
+ if tmux_manager and main_pane:
883
+ if session_id:
884
+ tmux_manager.promote_to_main(session_id=session_id, pane_id=main_pane)
885
+ else:
886
+ tmux_manager.launch_main_session(pane_id=main_pane)
887
+ summary = session_id or "(未選択)"
888
+ if not silent:
889
+ self._emit(ControllerEventType.LOG, {"text": f"前回のセッションを再開しました。次の指示はセッション {summary} から再開します。"})
890
+ self._emit_status("待機中")
891
+ self._emit_pause_state()
892
+ self._pre_cycle_selected_session = None
893
+ self._pre_cycle_selected_session_set = False
894
+ return
895
+
896
+ snapshot = self._history.last_snapshot() or {}
897
+ session_id = snapshot.get("selected_session") or self._active_main_session_id or self._last_started_main_session_id
898
+
899
+ self._last_selected_session = session_id
900
+ self._active_main_session_id = session_id
901
+ self._last_scoreboard = snapshot.get("scoreboard", {})
902
+ self._last_instruction = snapshot.get("instruction")
903
+ if self._last_scoreboard:
904
+ self._emit(ControllerEventType.SCOREBOARD, {"scoreboard": self._last_scoreboard})
905
+
906
+ self._paused = False
907
+ if tmux_manager and main_pane:
908
+ if session_id:
909
+ tmux_manager.promote_to_main(session_id=session_id, pane_id=main_pane)
910
+ else:
911
+ tmux_manager.launch_main_session(pane_id=main_pane)
912
+
913
+ summary = session_id or "(未選択)"
914
+ if not silent:
915
+ self._emit(ControllerEventType.LOG, {"text": f"サイクルを巻き戻しました。次の指示はセッション {summary} から再開します。"})
916
+ self._emit_status("待機中")
917
+ self._emit_pause_state()
918
+ self._pre_cycle_selected_session = None
919
+ self._pre_cycle_selected_session_set = False
920
+
921
+ def _emit_pause_state(self) -> None:
922
+ self._emit(ControllerEventType.PAUSE_STATE, {"paused": self._paused})
923
+
924
+ def _on_main_session_started(self, session_id: str) -> None:
925
+ self._active_main_session_id = session_id
926
+ self._last_started_main_session_id = session_id
927
+ if self._last_selected_session is None:
928
+ self._last_selected_session = session_id
929
+ self._config.reuse_existing_session = True
930
+
931
+ async def _run_instruction(self, instruction: str) -> None:
932
+ await self._workflow.run(instruction)
933
+
934
+ def _resolve_selection(self, index: int) -> None:
935
+ if not self._selection_context:
936
+ self._emit(ControllerEventType.LOG, {"text": "現在選択待ちではありません。"})
937
+ return
938
+ context = self._selection_context
939
+ if index < 1 or index > len(context.candidates):
940
+ self._emit(ControllerEventType.LOG, {"text": "無効な番号です。"})
941
+ return
942
+ candidate = context.candidates[index - 1]
943
+ scores = {
944
+ cand.key: (1.0 if cand.key == candidate.key else 0.0) for cand in context.candidates
945
+ }
946
+ decision = SelectionDecision(selected_key=candidate.key, scores=scores)
947
+ context.future.set_result(decision)
948
+ self._emit(ControllerEventType.LOG, {"text": f"{candidate.label} を選択しました。"})
949
+ self._selection_context = None
950
+ self._emit(ControllerEventType.SELECTION_FINISHED, {})
951
+
952
+ async def _handle_attach_command(self, *, force: bool = False) -> None:
953
+ session_name = self._config.tmux_session
954
+ wait_for_session = not force and self._attach_mode == "auto"
955
+ if wait_for_session:
956
+ self._emit(ControllerEventType.LOG, {"text": f"[auto] tmuxセッション {session_name} の起動を待機中..."})
957
+ session_ready = await self._wait_for_session(session_name)
958
+ if not session_ready:
959
+ self._emit(
960
+ ControllerEventType.LOG,
961
+ {"text": f"[auto] tmuxセッション {session_name} が見つかりませんでした。少し待ってから再度試してください。"},
962
+ )
963
+ return
964
+ else:
965
+ if not self._attach_manager.session_exists(session_name):
966
+ self._emit(
967
+ ControllerEventType.LOG,
968
+ {
969
+ "text": (
970
+ f"tmuxセッション {session_name} がまだ存在しません。"
971
+ " 指示を送信してセッションを初期化した後に再度実行してください。"
972
+ )
973
+ },
974
+ )
975
+ return
976
+
977
+ perform_detection = not force and self._attach_mode == "auto"
978
+ if perform_detection:
979
+ if self._attach_manager.is_attached(session_name):
980
+ self._emit(
981
+ ControllerEventType.LOG,
982
+ {"text": f"[auto] tmuxセッション {session_name} は既に接続済みのため、自動アタッチをスキップしました。"},
983
+ )
984
+ return
985
+ result = self._attach_manager.attach(session_name, workdir=self._worktree_root)
986
+ if result.returncode == 0:
987
+ prefix = "[auto] " if perform_detection else ""
988
+ self._emit(ControllerEventType.LOG, {"text": f"{prefix}tmuxセッション {session_name} に接続しました。"})
989
+ else:
990
+ self._emit(ControllerEventType.LOG, {"text": "tmuxへの接続に失敗しました。tmuxが利用可能か確認してください。"})
991
+
992
+ def _build_resume_options(self) -> List[CommandOption]:
993
+ references = self._manifest_store.list_sessions()
994
+ self._resume_options = references
995
+ options: List[CommandOption] = []
996
+ for idx, ref in enumerate(references, start=1):
997
+ summary = ref.latest_instruction or ""
998
+ label = f"{idx}. {ref.created_at} | tmux={ref.tmux_session}"
999
+ if summary:
1000
+ label += f" | last: {summary[:40]}"
1001
+ options.append(CommandOption(label, idx))
1002
+ return options
1003
+
1004
+ def _list_sessions(self) -> None:
1005
+ references = self._manifest_store.list_sessions()
1006
+ self._resume_options = references
1007
+ if not references:
1008
+ self._emit(ControllerEventType.LOG, {"text": "保存済みセッションが見つかりません。"})
1009
+ return
1010
+ lines = [
1011
+ "=== 保存されたセッション ===",
1012
+ ]
1013
+ for idx, ref in enumerate(references, start=1):
1014
+ summary = ref.latest_instruction or ""
1015
+ lines.append(
1016
+ f"{idx}. {ref.created_at} | tmux={ref.tmux_session} | workers={ref.worker_count} | mode={ref.mode}"
1017
+ )
1018
+ if summary:
1019
+ lines.append(f" last instruction: {summary[:80]}")
1020
+ self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
1021
+ self._emit(ControllerEventType.LOG, {"text": "再開するには /resume からセッションを選択してください。"})
1022
+
1023
+ def _load_session(self, index: int) -> None:
1024
+ if not self._resume_options:
1025
+ self._emit(ControllerEventType.LOG, {"text": "先に /resume で一覧を表示してください。"})
1026
+ return
1027
+ if index < 1 or index > len(self._resume_options):
1028
+ self._emit(ControllerEventType.LOG, {"text": "無効な番号です。"})
1029
+ return
1030
+ reference = self._resume_options[index - 1]
1031
+ try:
1032
+ manifest = self._manifest_store.load_manifest(reference.session_id)
1033
+ except FileNotFoundError:
1034
+ self._emit(ControllerEventType.LOG, {"text": "セッションファイルが見つかりませんでした。"})
1035
+ return
1036
+ self._apply_manifest(manifest)
1037
+ self._emit(ControllerEventType.LOG, {"text": f"セッション {manifest.session_id} を読み込みました。"})
1038
+ if manifest.scoreboard:
1039
+ self._emit(ControllerEventType.SCOREBOARD, {"scoreboard": manifest.scoreboard})
1040
+ self._show_conversation_log(manifest.conversation_log)
1041
+
1042
+ def _apply_manifest(self, manifest: SessionManifest) -> None:
1043
+ self._config.session_id = manifest.session_id
1044
+ self._config.tmux_session = manifest.tmux_session
1045
+ self._config.worker_count = manifest.worker_count
1046
+ self._config.mode = SessionMode(manifest.mode)
1047
+ if manifest.logs_dir:
1048
+ self._config.logs_root = Path(manifest.logs_dir).parent
1049
+ else:
1050
+ self._config.logs_root = _ensure_logs_directory(manifest.session_id)
1051
+ self._config.reuse_existing_session = True
1052
+ self._last_scoreboard = manifest.scoreboard or {}
1053
+ self._last_instruction = manifest.latest_instruction
1054
+ self._last_selected_session = manifest.selected_session_id
1055
+ self._session_namespace = manifest.session_id
1056
+ self._emit_status("再開準備完了")
1057
+ self._ensure_tmux_session(manifest)
1058
+
1059
+ def _ensure_tmux_session(self, manifest: SessionManifest) -> None:
1060
+ try:
1061
+ import libtmux
1062
+ except ImportError:
1063
+ self._emit(ControllerEventType.LOG, {"text": "libtmux が見つかりません。tmux セッションは手動で復元してください。"})
1064
+ return
1065
+
1066
+ server = libtmux.Server() # type: ignore[attr-defined]
1067
+ existing = server.find_where({"session_name": manifest.tmux_session})
1068
+ if existing is not None:
1069
+ return
1070
+
1071
+ worker_count = len(manifest.workers)
1072
+ orchestrator = self._builder(
1073
+ worker_count=worker_count,
1074
+ log_dir=Path(manifest.logs_dir) if manifest.logs_dir else None,
1075
+ session_name=manifest.tmux_session,
1076
+ reuse_existing_session=False,
1077
+ session_namespace=manifest.session_id,
1078
+ boss_mode=self._config.boss_mode,
1079
+ project_root=self._worktree_root,
1080
+ worktree_storage_root=self._worktree_storage_root,
1081
+ )
1082
+ tmux_manager = orchestrator._tmux # type: ignore[attr-defined]
1083
+ orchestrator._worktree.prepare() # type: ignore[attr-defined]
1084
+ boss_path = Path(manifest.boss.worktree) if manifest.boss and manifest.boss.worktree else self._worktree_root
1085
+ tmux_manager.set_boss_path(boss_path)
1086
+ tmux_manager.set_reuse_existing_session(True)
1087
+ layout = tmux_manager.ensure_layout(session_name=manifest.tmux_session, worker_count=worker_count)
1088
+ tmux_manager.resume_session(
1089
+ pane_id=layout.main_pane,
1090
+ workdir=self._worktree_root,
1091
+ session_id=manifest.main.session_id,
1092
+ )
1093
+ for idx, worker_name in enumerate(layout.worker_names):
1094
+ record = manifest.workers.get(worker_name)
1095
+ if not record or not record.worktree:
1096
+ continue
1097
+ pane_id = layout.worker_panes[idx]
1098
+ tmux_manager.resume_session(
1099
+ pane_id=pane_id,
1100
+ workdir=Path(record.worktree),
1101
+ session_id=record.session_id,
1102
+ )
1103
+ if manifest.boss and manifest.boss.worktree:
1104
+ tmux_manager.resume_session(
1105
+ pane_id=layout.boss_pane,
1106
+ workdir=Path(manifest.boss.worktree),
1107
+ session_id=manifest.boss.session_id,
1108
+ )
1109
+
1110
+ def _show_conversation_log(self, log_path: Optional[str]) -> None:
1111
+ if not log_path:
1112
+ self._emit(ControllerEventType.LOG, {"text": "会話ログはありません。"})
1113
+ return
1114
+ path = Path(log_path)
1115
+ if not path.exists():
1116
+ self._emit(ControllerEventType.LOG, {"text": "会話ログが見つかりませんでした。"})
1117
+ return
1118
+ lines: List[str] = ["--- Conversation Log ---"]
1119
+ try:
1120
+ if path.suffix == ".jsonl":
1121
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
1122
+ if not raw_line.strip():
1123
+ continue
1124
+ data = json.loads(raw_line)
1125
+ event_type = data.get("type")
1126
+ if event_type == "instruction":
1127
+ lines.append(f"[instruction] {data.get('instruction', '')}")
1128
+ elif event_type == "fork":
1129
+ workers = ", ".join(data.get("fork_map", {}).keys())
1130
+ lines.append(f"[fork] workers={workers}")
1131
+ elif event_type == "completion":
1132
+ done = [k for k, v in (data.get("completion") or {}).items() if v.get("done")]
1133
+ lines.append(f"[completion] done={done}")
1134
+ elif event_type == "scoreboard":
1135
+ lines.append("[scoreboard]")
1136
+ for key, info in (data.get("scoreboard") or {}).items():
1137
+ score = info.get("score")
1138
+ selected = " [selected]" if info.get("selected") else ""
1139
+ comment = info.get("comment", "")
1140
+ lines.append(f" {key}: {score} {selected} {comment}")
1141
+ elif event_type == "selection":
1142
+ lines.append(
1143
+ f"[selection] session={data.get('selected_session')} key={data.get('selected_key')}"
1144
+ )
1145
+ elif event_type == "artifact":
1146
+ workers = list((data.get("worker_sessions") or {}).keys())
1147
+ lines.append(f"[artifact] main={data.get('main_session_id')} workers={workers}")
1148
+ else:
1149
+ lines.append(raw_line)
1150
+ else:
1151
+ lines.extend(path.read_text(encoding="utf-8").splitlines())
1152
+ except json.JSONDecodeError:
1153
+ lines.extend(path.read_text(encoding="utf-8").splitlines())
1154
+ lines.append("--- End Conversation Log ---")
1155
+ self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
1156
+
1157
+ def _emit_status(self, message: str) -> None:
1158
+ self._emit(ControllerEventType.STATUS, {"message": message})
1159
+
1160
+ def _emit(self, event_type: Union[str, ControllerEventType], payload: Dict[str, object]) -> None:
1161
+ if isinstance(event_type, ControllerEventType):
1162
+ key = event_type.value
1163
+ else:
1164
+ key = event_type
1165
+ self._event_handler(key, payload)
1166
+
1167
+ def _flow_mode_display(self, mode: Optional[FlowMode] = None) -> str:
1168
+ current = mode or getattr(self, "_flow_mode", FlowMode.MANUAL)
1169
+ if isinstance(current, FlowMode):
1170
+ return FLOW_MODE_LABELS.get(current, current.value)
1171
+ return str(current)
1172
+
1173
+ def _merge_strategy_display(self, strategy: Optional[MergeMode] = None) -> str:
1174
+ current = strategy or getattr(self, "_merge_mode", MergeMode.MANUAL)
1175
+ if isinstance(current, MergeMode):
1176
+ return MERGE_STRATEGY_LABELS.get(current, current.value)
1177
+ return str(current)
1178
+
1179
+ def _create_initial_config(self) -> SessionConfig:
1180
+ session_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + "-" + datetime.utcnow().strftime("%f")[:6]
1181
+ tmux_session = f"parallel-dev-{session_id}"
1182
+ logs_root = _ensure_logs_directory(session_id)
1183
+ return SessionConfig(
1184
+ session_id=session_id,
1185
+ tmux_session=tmux_session,
1186
+ worker_count=3,
1187
+ mode=SessionMode.PARALLEL,
1188
+ logs_root=logs_root,
1189
+ boss_mode=BossMode.SCORE,
1190
+ flow_mode=FlowMode.FULL_AUTO,
1191
+ merge_mode=MergeMode.AUTO,
1192
+ )
1193
+
1194
+ def _create_cycle_logs_dir(self) -> Path:
1195
+ timestamp = datetime.utcnow().strftime("%y-%m-%d-%H%M%S")
1196
+ logs_dir = self._config.logs_root / timestamp
1197
+ logs_dir.mkdir(parents=True, exist_ok=True)
1198
+ return logs_dir
1199
+
1200
+ async def _wait_for_session(self, session_name: str, attempts: int = 20, delay: float = 0.25) -> bool:
1201
+ for _ in range(attempts):
1202
+ if self._attach_manager.session_exists(session_name):
1203
+ return True
1204
+ await asyncio.sleep(delay)
1205
+ return False
1206
+
1207
+ def _build_manifest(self, result: OrchestrationResult, logs_dir: Path) -> SessionManifest:
1208
+ assert result.artifact is not None
1209
+ artifact = result.artifact
1210
+ main_record = PaneRecord(
1211
+ role="main",
1212
+ name=None,
1213
+ session_id=artifact.main_session_id,
1214
+ worktree=str(self._worktree_root),
1215
+ )
1216
+ workers = {
1217
+ name: PaneRecord(
1218
+ role="worker",
1219
+ name=name,
1220
+ session_id=session_id,
1221
+ worktree=str(artifact.worker_paths.get(name)) if artifact.worker_paths.get(name) else None,
1222
+ )
1223
+ for name, session_id in artifact.worker_sessions.items()
1224
+ }
1225
+ boss_record = (
1226
+ PaneRecord(
1227
+ role="boss",
1228
+ name="boss",
1229
+ session_id=artifact.boss_session_id,
1230
+ worktree=str(artifact.boss_path) if artifact.boss_path else None,
1231
+ )
1232
+ if artifact.boss_session_id
1233
+ else None
1234
+ )
1235
+ conversation_path = None
1236
+ if artifact.log_paths:
1237
+ conversation_path = str(artifact.log_paths.get("jsonl") or artifact.log_paths.get("yaml"))
1238
+ else:
1239
+ conversation_path = str(logs_dir / "instruction.log")
1240
+ return SessionManifest(
1241
+ session_id=self._config.session_id,
1242
+ created_at=datetime.utcnow().isoformat(timespec="seconds"),
1243
+ tmux_session=self._config.tmux_session,
1244
+ worker_count=len(workers),
1245
+ mode=self._config.mode.value,
1246
+ logs_dir=str(logs_dir),
1247
+ latest_instruction=self._last_instruction,
1248
+ scoreboard=self._last_scoreboard,
1249
+ conversation_log=conversation_path,
1250
+ selected_session_id=artifact.selected_session_id,
1251
+ main=main_record,
1252
+ boss=boss_record,
1253
+ workers=workers,
1254
+ )
1255
+
1256
+ def _handle_merge_outcome(self, outcome: Optional[MergeOutcome]) -> None:
1257
+ if outcome is None:
1258
+ return
1259
+ branch = outcome.branch or "選択されたブランチ"
1260
+ status = outcome.status
1261
+ error = outcome.error
1262
+ reason_key = outcome.reason
1263
+ reason_labels = {
1264
+ "agent_auto": "エージェントが統合を担当",
1265
+ }
1266
+ if status == "delegate" and reason_key == "manual_user":
1267
+ self._emit(
1268
+ ControllerEventType.LOG,
1269
+ {
1270
+ "text": (
1271
+ f"[merge] manualモード: {branch} の統合作業はユーザに委譲されています。"
1272
+ "必要な作業を終えたら /done で次に進んでください。"
1273
+ )
1274
+ },
1275
+ )
1276
+ self._emit_status("統合作業待ち")
1277
+ elif status == "delegate" and reason_key == "agent_auto":
1278
+ self._emit(
1279
+ ControllerEventType.LOG,
1280
+ {
1281
+ "text": (
1282
+ f"[merge] Autoモード: {branch} の統合作業はエージェントが完了し、"
1283
+ "ホストは結果を同期済みです。問題があればログを確認してください。"
1284
+ )
1285
+ },
1286
+ )
1287
+ self._emit_status("統合作業完了")
1288
+ elif status == "delegate":
1289
+ label = reason_labels.get(reason_key, reason_key or "手動統合に切り替え")
1290
+ detail = f" 詳細: {error}" if error else ""
1291
+ self._emit(
1292
+ ControllerEventType.LOG,
1293
+ {"text": f"[merge] 自動マージをスキップし、エージェントに委譲します ({label}).{detail}"},
1294
+ )
1295
+ self._emit_status("統合作業待ち")
1296
+ elif status == "failed":
1297
+ self._emit(
1298
+ ControllerEventType.LOG,
1299
+ {"text": f"[merge] {branch} の自動マージに失敗しました: {error or '理由不明'}. /merge コマンドで戦略を切り替えてください。"},
1300
+ )
1301
+ self._emit_status("マージ失敗")
1302
+
1303
+ def _handle_orchestrator_log(self, message: str) -> None:
1304
+ status = None
1305
+ token = "::status::"
1306
+ if token in message:
1307
+ message, status = message.split(token, 1)
1308
+ message = message.rstrip()
1309
+ status = status.strip()
1310
+ self._emit(ControllerEventType.LOG, {"text": message})
1311
+ if status:
1312
+ self._emit_status(status)
1313
+
1314
+ @staticmethod
1315
+ def _default_builder(
1316
+ *,
1317
+ worker_count: int,
1318
+ log_dir: Optional[Path],
1319
+ session_name: Optional[str] = None,
1320
+ reuse_existing_session: bool = False,
1321
+ session_namespace: Optional[str] = None,
1322
+ boss_mode: BossMode = BossMode.SCORE,
1323
+ project_root: Optional[Path] = None,
1324
+ worktree_storage_root: Optional[Path] = None,
1325
+ ) -> Orchestrator:
1326
+ raise RuntimeError("Orchestrator builder is not configured.")
1327
+
1328
+
1329
+ def build_orchestrator(
1330
+ *,
1331
+ worker_count: int,
1332
+ log_dir: Optional[Path],
1333
+ session_name: Optional[str] = None,
1334
+ reuse_existing_session: bool = False,
1335
+ session_namespace: Optional[str] = None,
1336
+ boss_mode: BossMode = BossMode.SCORE,
1337
+ project_root: Optional[Path] = None,
1338
+ worktree_storage_root: Optional[Path] = None,
1339
+ log_hook: Optional[Callable[[str], None]] = None,
1340
+ merge_mode: MergeMode = MergeMode.MANUAL,
1341
+ ) -> Orchestrator:
1342
+ session_name = session_name or "parallel-dev"
1343
+ timestamp = datetime.utcnow().strftime("%y-%m-%d-%H%M%S")
1344
+ if log_dir:
1345
+ base_logs_dir = Path(log_dir)
1346
+ base_logs_dir.mkdir(parents=True, exist_ok=True)
1347
+ else:
1348
+ base_logs_dir = _ensure_logs_directory(timestamp)
1349
+ map_session_id = session_namespace or session_name or "parallel-dev"
1350
+ session_map_dir = default_config_dir() / "session_maps"
1351
+ session_map_dir.mkdir(parents=True, exist_ok=True)
1352
+ session_map_path = session_map_dir / f"{map_session_id}.yaml"
1353
+
1354
+ project_root_path = Path(project_root).expanduser() if project_root else Path.cwd()
1355
+ storage_root_path = (
1356
+ Path(worktree_storage_root).expanduser()
1357
+ if worktree_storage_root
1358
+ else project_root_path
1359
+ )
1360
+ session_root = storage_root_path / ".parallel-dev"
1361
+ if session_namespace:
1362
+ session_root = session_root / "sessions" / session_namespace
1363
+ session_root.mkdir(parents=True, exist_ok=True)
1364
+
1365
+ monitor = CodexMonitor(
1366
+ logs_dir=base_logs_dir,
1367
+ session_map_path=session_map_path,
1368
+ session_namespace=session_namespace,
1369
+ )
1370
+ tmux_manager = TmuxLayoutManager(
1371
+ session_name=session_name,
1372
+ worker_count=worker_count,
1373
+ monitor=monitor,
1374
+ root_path=project_root_path,
1375
+ startup_delay=0.0,
1376
+ backtrack_delay=0.0,
1377
+ reuse_existing_session=reuse_existing_session,
1378
+ session_namespace=session_namespace,
1379
+ )
1380
+ worktree_manager = WorktreeManager(
1381
+ root=project_root_path,
1382
+ worker_count=worker_count,
1383
+ session_namespace=session_namespace,
1384
+ storage_root=storage_root_path,
1385
+ )
1386
+ log_manager = LogManager(logs_dir=base_logs_dir)
1387
+
1388
+ return Orchestrator(
1389
+ tmux_manager=tmux_manager,
1390
+ worktree_manager=worktree_manager,
1391
+ monitor=monitor,
1392
+ log_manager=log_manager,
1393
+ worker_count=worker_count,
1394
+ session_name=session_name,
1395
+ boss_mode=boss_mode,
1396
+ log_hook=log_hook,
1397
+ merge_mode=merge_mode,
1398
+ )