ensemble-claude 0.3.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 (46) hide show
  1. ensemble/__init__.py +5 -0
  2. ensemble/ack.py +86 -0
  3. ensemble/cli.py +31 -0
  4. ensemble/commands/__init__.py +1 -0
  5. ensemble/commands/_init_impl.py +208 -0
  6. ensemble/commands/_launch_impl.py +217 -0
  7. ensemble/commands/init.py +35 -0
  8. ensemble/commands/launch.py +32 -0
  9. ensemble/config.py +218 -0
  10. ensemble/dashboard.py +168 -0
  11. ensemble/helpers.py +79 -0
  12. ensemble/lock.py +77 -0
  13. ensemble/logger.py +80 -0
  14. ensemble/notes.py +221 -0
  15. ensemble/queue.py +166 -0
  16. ensemble/templates/__init__.py +75 -0
  17. ensemble/templates/agents/conductor.md +239 -0
  18. ensemble/templates/agents/dispatch.md +351 -0
  19. ensemble/templates/agents/integrator.md +138 -0
  20. ensemble/templates/agents/learner.md +133 -0
  21. ensemble/templates/agents/reviewer.md +84 -0
  22. ensemble/templates/agents/security-reviewer.md +136 -0
  23. ensemble/templates/agents/worker.md +184 -0
  24. ensemble/templates/commands/go-light.md +49 -0
  25. ensemble/templates/commands/go.md +101 -0
  26. ensemble/templates/commands/improve.md +116 -0
  27. ensemble/templates/commands/review.md +74 -0
  28. ensemble/templates/commands/status.md +56 -0
  29. ensemble/templates/scripts/dashboard-update.sh +78 -0
  30. ensemble/templates/scripts/launch.sh +137 -0
  31. ensemble/templates/scripts/pane-setup.sh +111 -0
  32. ensemble/templates/scripts/setup.sh +163 -0
  33. ensemble/templates/scripts/worktree-create.sh +89 -0
  34. ensemble/templates/scripts/worktree-merge.sh +194 -0
  35. ensemble/templates/workflows/default.yaml +78 -0
  36. ensemble/templates/workflows/heavy.yaml +149 -0
  37. ensemble/templates/workflows/simple.yaml +41 -0
  38. ensemble/templates/workflows/worktree.yaml +202 -0
  39. ensemble/utils.py +60 -0
  40. ensemble/workflow.py +127 -0
  41. ensemble/worktree.py +322 -0
  42. ensemble_claude-0.3.0.dist-info/METADATA +144 -0
  43. ensemble_claude-0.3.0.dist-info/RECORD +46 -0
  44. ensemble_claude-0.3.0.dist-info/WHEEL +4 -0
  45. ensemble_claude-0.3.0.dist-info/entry_points.txt +2 -0
  46. ensemble_claude-0.3.0.dist-info/licenses/LICENSE +21 -0
ensemble/config.py ADDED
@@ -0,0 +1,218 @@
1
+ """Ensemble configuration management.
2
+
3
+ Handles global (~/.config/ensemble/) and local (.ensemble/) configuration.
4
+ """
5
+
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import yaml
11
+
12
+ from ensemble.templates import get_template_path
13
+
14
+ # Default configuration values
15
+ DEFAULT_CONFIG = {
16
+ "version": "0.3.0",
17
+ "session": {
18
+ "name": "ensemble",
19
+ "attach": True,
20
+ },
21
+ "agents": {
22
+ "conductor": "conductor.md",
23
+ "dispatch": "dispatch.md",
24
+ "worker": "worker.md",
25
+ "reviewer": "reviewer.md",
26
+ "security_reviewer": "security-reviewer.md",
27
+ "integrator": "integrator.md",
28
+ "learner": "learner.md",
29
+ },
30
+ "workflow": {
31
+ "default": "default.yaml",
32
+ "simple": "simple.yaml",
33
+ "heavy": "heavy.yaml",
34
+ "worktree": "worktree.yaml",
35
+ },
36
+ "limits": {
37
+ "max_parallel_workers": 4,
38
+ "max_iterations": 15,
39
+ },
40
+ }
41
+
42
+
43
+ def get_global_config_dir() -> Path:
44
+ """Get the global configuration directory path."""
45
+ return Path.home() / ".config" / "ensemble"
46
+
47
+
48
+ def get_local_config_dir() -> Path:
49
+ """Get the local configuration directory path (current project)."""
50
+ return Path.cwd() / ".ensemble"
51
+
52
+
53
+ def ensure_global_config() -> Path:
54
+ """Ensure global configuration directory exists with defaults.
55
+
56
+ Creates ~/.config/ensemble/ with default templates if it doesn't exist.
57
+
58
+ Returns:
59
+ Path to the global config directory
60
+ """
61
+ global_dir = get_global_config_dir()
62
+
63
+ if not global_dir.exists():
64
+ global_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ # Copy default config
67
+ _write_default_config(global_dir / "config.yaml")
68
+
69
+ # Copy agent templates
70
+ _copy_default_templates(global_dir)
71
+
72
+ return global_dir
73
+
74
+
75
+ def _write_default_config(config_path: Path) -> None:
76
+ """Write default configuration to file."""
77
+ with open(config_path, "w") as f:
78
+ yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False, sort_keys=False)
79
+
80
+
81
+ def _copy_default_templates(global_dir: Path) -> None:
82
+ """Copy default templates to global config directory."""
83
+ # Copy agents
84
+ agents_dir = global_dir / "agents"
85
+ agents_dir.mkdir(exist_ok=True)
86
+
87
+ template_agents = get_template_path("agents")
88
+ if template_agents.exists():
89
+ for agent_file in template_agents.glob("*.md"):
90
+ shutil.copy(agent_file, agents_dir / agent_file.name)
91
+
92
+ # Copy workflows
93
+ workflows_dir = global_dir / "workflows"
94
+ workflows_dir.mkdir(exist_ok=True)
95
+
96
+ template_workflows = get_template_path("workflows")
97
+ if template_workflows.exists():
98
+ for workflow_file in template_workflows.glob("*.yaml"):
99
+ shutil.copy(workflow_file, workflows_dir / workflow_file.name)
100
+
101
+
102
+ def load_config() -> dict[str, Any]:
103
+ """Load merged configuration (global + local).
104
+
105
+ Priority (highest first):
106
+ 1. Local project config (.ensemble/config.yaml)
107
+ 2. Global config (~/.config/ensemble/config.yaml)
108
+ 3. Default values
109
+
110
+ Returns:
111
+ Merged configuration dictionary
112
+ """
113
+ config = DEFAULT_CONFIG.copy()
114
+
115
+ # Load global config
116
+ global_config_file = get_global_config_dir() / "config.yaml"
117
+ if global_config_file.exists():
118
+ with open(global_config_file) as f:
119
+ global_config = yaml.safe_load(f) or {}
120
+ config = _deep_merge(config, global_config)
121
+
122
+ # Load local config (overrides global)
123
+ local_config_file = get_local_config_dir() / "config.yaml"
124
+ if local_config_file.exists():
125
+ with open(local_config_file) as f:
126
+ local_config = yaml.safe_load(f) or {}
127
+ config = _deep_merge(config, local_config)
128
+
129
+ return config
130
+
131
+
132
+ def _deep_merge(base: dict, override: dict) -> dict:
133
+ """Deep merge two dictionaries.
134
+
135
+ Args:
136
+ base: Base dictionary
137
+ override: Dictionary with overriding values
138
+
139
+ Returns:
140
+ Merged dictionary
141
+ """
142
+ result = base.copy()
143
+
144
+ for key, value in override.items():
145
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
146
+ result[key] = _deep_merge(result[key], value)
147
+ else:
148
+ result[key] = value
149
+
150
+ return result
151
+
152
+
153
+ def resolve_agent_path(agent_name: str) -> Optional[Path]:
154
+ """Resolve the path to an agent definition file.
155
+
156
+ Priority (highest first):
157
+ 1. Local project: ./.claude/agents/{agent}.md
158
+ 2. Global config: ~/.config/ensemble/agents/{agent}.md
159
+ 3. Package template: ensemble/templates/agents/{agent}.md
160
+
161
+ Args:
162
+ agent_name: Name of the agent (e.g., "conductor", "dispatch")
163
+
164
+ Returns:
165
+ Path to the agent file, or None if not found
166
+ """
167
+ filename = f"{agent_name}.md"
168
+
169
+ # Check local
170
+ local_path = Path.cwd() / ".claude" / "agents" / filename
171
+ if local_path.exists():
172
+ return local_path
173
+
174
+ # Check global
175
+ global_path = get_global_config_dir() / "agents" / filename
176
+ if global_path.exists():
177
+ return global_path
178
+
179
+ # Check package template
180
+ template_path = get_template_path("agents") / filename
181
+ if template_path.exists():
182
+ return template_path
183
+
184
+ return None
185
+
186
+
187
+ def resolve_workflow_path(workflow_name: str) -> Optional[Path]:
188
+ """Resolve the path to a workflow definition file.
189
+
190
+ Priority (highest first):
191
+ 1. Local project: ./.ensemble/workflows/{workflow}.yaml
192
+ 2. Global config: ~/.config/ensemble/workflows/{workflow}.yaml
193
+ 3. Package template: ensemble/templates/workflows/{workflow}.yaml
194
+
195
+ Args:
196
+ workflow_name: Name of the workflow (e.g., "default", "simple")
197
+
198
+ Returns:
199
+ Path to the workflow file, or None if not found
200
+ """
201
+ filename = f"{workflow_name}.yaml"
202
+
203
+ # Check local
204
+ local_path = Path.cwd() / ".ensemble" / "workflows" / filename
205
+ if local_path.exists():
206
+ return local_path
207
+
208
+ # Check global
209
+ global_path = get_global_config_dir() / "workflows" / filename
210
+ if global_path.exists():
211
+ return global_path
212
+
213
+ # Check package template
214
+ template_path = get_template_path("workflows") / filename
215
+ if template_path.exists():
216
+ return template_path
217
+
218
+ return None
ensemble/dashboard.py ADDED
@@ -0,0 +1,168 @@
1
+ """
2
+ ダッシュボード更新モジュール
3
+
4
+ status/dashboard.md を更新してリアルタイムの進捗を表示する。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ensemble.lock import atomic_write
14
+
15
+
16
+ class DashboardUpdater:
17
+ """
18
+ ダッシュボード更新クラス
19
+
20
+ Markdownファイルを更新してタスクの進捗状況を表示する。
21
+ """
22
+
23
+ def __init__(self, status_dir: Path | None = None) -> None:
24
+ """
25
+ ダッシュボードアップデータを初期化する
26
+
27
+ Args:
28
+ status_dir: ステータスディレクトリ(デフォルト: status/)
29
+ """
30
+ self.status_dir = status_dir if status_dir else Path("status")
31
+ self.status_dir.mkdir(parents=True, exist_ok=True)
32
+ self.dashboard_path = self.status_dir / "dashboard.md"
33
+
34
+ # 内部状態
35
+ self._phase = "idle"
36
+ self._current_task = ""
37
+ self._completed = 0
38
+ self._total = 0
39
+ self._agents: dict[str, dict[str, str]] = {}
40
+ self._logs: list[str] = []
41
+
42
+ # 初期化時にダッシュボードを作成
43
+ self._write_dashboard()
44
+
45
+ def update_status(
46
+ self,
47
+ phase: str,
48
+ current_task: str,
49
+ agents: dict[str, str] | None = None,
50
+ ) -> None:
51
+ """
52
+ ステータスを更新する
53
+
54
+ Args:
55
+ phase: 現在のフェーズ
56
+ current_task: 現在のタスク
57
+ agents: エージェントステータス {"name": "status"}
58
+ """
59
+ self._phase = phase
60
+ self._current_task = current_task
61
+ if agents:
62
+ for name, status in agents.items():
63
+ self._agents[name] = {"status": status, "task": ""}
64
+ self._write_dashboard()
65
+
66
+ def add_log_entry(self, message: str) -> None:
67
+ """
68
+ ログエントリを追加する
69
+
70
+ Args:
71
+ message: ログメッセージ
72
+ """
73
+ timestamp = datetime.now().strftime("%H:%M:%S")
74
+ self._logs.append(f"[{timestamp}] {message}")
75
+ # 最新10件のみ保持
76
+ self._logs = self._logs[-10:]
77
+ self._write_dashboard()
78
+
79
+ def set_phase(self, phase: str) -> None:
80
+ """
81
+ フェーズを設定する
82
+
83
+ Args:
84
+ phase: フェーズ名
85
+ """
86
+ self._phase = phase
87
+ self._write_dashboard()
88
+
89
+ def set_progress(self, completed: int, total: int) -> None:
90
+ """
91
+ 進捗を設定する
92
+
93
+ Args:
94
+ completed: 完了タスク数
95
+ total: 総タスク数
96
+ """
97
+ self._completed = completed
98
+ self._total = total
99
+ self._write_dashboard()
100
+
101
+ def set_agent_status(
102
+ self, name: str, status: str, task: str = ""
103
+ ) -> None:
104
+ """
105
+ エージェントのステータスを設定する
106
+
107
+ Args:
108
+ name: エージェント名
109
+ status: ステータス
110
+ task: 実行中のタスク
111
+ """
112
+ self._agents[name] = {"status": status, "task": task}
113
+ self._write_dashboard()
114
+
115
+ def clear(self) -> None:
116
+ """
117
+ ダッシュボードをリセットする
118
+ """
119
+ self._phase = "idle"
120
+ self._current_task = ""
121
+ self._completed = 0
122
+ self._total = 0
123
+ self._agents = {}
124
+ self._logs = []
125
+ self._write_dashboard()
126
+
127
+ def _write_dashboard(self) -> None:
128
+ """ダッシュボードファイルを書き込む"""
129
+ now = datetime.now()
130
+
131
+ # エージェントテーブル
132
+ agent_rows = ""
133
+ for name, info in self._agents.items():
134
+ agent_rows += f"| {name} | {info['status']} | {info.get('task', '')} |\n"
135
+ if not agent_rows:
136
+ agent_rows = "| - | - | - |\n"
137
+
138
+ # ログセクション
139
+ log_section = "\n".join(self._logs) if self._logs else "(no logs)"
140
+
141
+ # 進捗表示
142
+ if self._total > 0:
143
+ progress = f"{self._completed}/{self._total}"
144
+ else:
145
+ progress = "-"
146
+
147
+ content = f"""# Ensemble Dashboard
148
+
149
+ **Last Updated**: {now.strftime("%Y-%m-%d %H:%M:%S")}
150
+
151
+ ## Status
152
+
153
+ | Phase | Current Task | Progress |
154
+ |-------|--------------|----------|
155
+ | {self._phase} | {self._current_task} | {progress} |
156
+
157
+ ## Agents
158
+
159
+ | Agent | Status | Task |
160
+ |-------|--------|------|
161
+ {agent_rows}
162
+ ## Recent Logs
163
+
164
+ ```
165
+ {log_section}
166
+ ```
167
+ """
168
+ atomic_write(str(self.dashboard_path), content)
ensemble/helpers.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ src/ensemble/helpers.py - ヘルパー関数
3
+
4
+ Ensemble固有のヘルパー関数を提供する。
5
+ """
6
+
7
+ import re
8
+ from pathlib import Path
9
+
10
+
11
+ def sanitize_task_id(task_id: str) -> str:
12
+ """
13
+ タスクIDをサニタイズする
14
+
15
+ Args:
16
+ task_id: サニタイズするタスクID
17
+
18
+ Returns:
19
+ サニタイズされたタスクID
20
+ """
21
+ # 英数字、ハイフン、アンダースコアのみ許可
22
+ return re.sub(r"[^a-zA-Z0-9\-_]", "_", task_id)
23
+
24
+
25
+ def get_queue_path(queue_type: str, base_dir: str = ".") -> Path:
26
+ """
27
+ キューディレクトリのパスを取得する
28
+
29
+ Args:
30
+ queue_type: キューの種類(tasks, reports, ack)
31
+ base_dir: ベースディレクトリ
32
+
33
+ Returns:
34
+ キューディレクトリのパス
35
+ """
36
+ valid_types = {"tasks", "reports", "ack", "conductor"}
37
+ if queue_type not in valid_types:
38
+ raise ValueError(f"Invalid queue type: {queue_type}")
39
+ return Path(base_dir) / "queue" / queue_type
40
+
41
+
42
+ def parse_worker_id(pane_name: str) -> int | None:
43
+ """
44
+ ペイン名からワーカーIDを抽出する
45
+
46
+ Args:
47
+ pane_name: ペイン名(例: "worker-1", "worker-2")
48
+
49
+ Returns:
50
+ ワーカーID。抽出できない場合はNone
51
+ """
52
+ match = re.match(r"worker-(\d+)", pane_name)
53
+ if match:
54
+ return int(match.group(1))
55
+ return None
56
+
57
+
58
+ def format_duration(seconds: float) -> str:
59
+ """
60
+ 秒数を人間が読みやすい形式に変換する
61
+
62
+ Args:
63
+ seconds: 秒数
64
+
65
+ Returns:
66
+ "1h 23m 45s" 形式の文字列
67
+ """
68
+ hours, remainder = divmod(int(seconds), 3600)
69
+ minutes, secs = divmod(remainder, 60)
70
+
71
+ parts = []
72
+ if hours > 0:
73
+ parts.append(f"{hours}h")
74
+ if minutes > 0:
75
+ parts.append(f"{minutes}m")
76
+ if secs > 0 or not parts:
77
+ parts.append(f"{secs}s")
78
+
79
+ return " ".join(parts)
ensemble/lock.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ アトミックロック機構
3
+
4
+ ファイルベースのアトミック操作を提供する。
5
+ mvコマンド(os.rename)のアトミック性を利用。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+
13
+
14
+ def atomic_write(filepath: str, content: str) -> bool:
15
+ """
16
+ アトミックな書き込みを行う
17
+
18
+ tmp作成 → mv(アトミック)で安全に書き込む。
19
+ 同一ファイルシステム上でのos.renameはアトミック。
20
+
21
+ Args:
22
+ filepath: 書き込み先ファイルパス
23
+ content: 書き込む内容
24
+
25
+ Returns:
26
+ 成功時True、失敗時False
27
+ """
28
+ dir_path = os.path.dirname(filepath)
29
+
30
+ # 親ディレクトリが存在しない場合は失敗
31
+ if dir_path and not os.path.exists(dir_path):
32
+ return False
33
+
34
+ try:
35
+ # tmpファイルを同じディレクトリに作成(同一ファイルシステム保証)
36
+ fd, tmp_path = tempfile.mkstemp(dir=dir_path if dir_path else ".")
37
+ try:
38
+ os.write(fd, content.encode("utf-8"))
39
+ os.close(fd)
40
+ os.rename(tmp_path, filepath) # アトミック
41
+ return True
42
+ except Exception:
43
+ # 失敗時はtmpファイルを削除
44
+ try:
45
+ os.unlink(tmp_path)
46
+ except OSError:
47
+ pass
48
+ return False
49
+ except Exception:
50
+ return False
51
+
52
+
53
+ def atomic_claim(filepath: str, processing_dir: str) -> str | None:
54
+ """
55
+ アトミックなタスク取得を行う
56
+
57
+ mv(アトミック)で処理中ディレクトリへ移動することで、
58
+ 複数プロセス間での排他制御を実現。
59
+
60
+ Args:
61
+ filepath: 取得対象のファイルパス
62
+ processing_dir: 処理中ファイルの移動先ディレクトリ
63
+
64
+ Returns:
65
+ 成功時は移動先パス、失敗時(別プロセスが先に取得)はNone
66
+ """
67
+ filename = os.path.basename(filepath)
68
+ dest = os.path.join(processing_dir, filename)
69
+
70
+ try:
71
+ os.rename(filepath, dest) # アトミック
72
+ return dest
73
+ except FileNotFoundError:
74
+ # 別プロセスが先に取得した場合
75
+ return None
76
+ except Exception:
77
+ return None
ensemble/logger.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Ensembleログ出力モジュール
3
+
4
+ コンソール: テキスト形式(人間が読みやすい)
5
+ ファイル: JSON形式(機械可読、分析容易)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ class EnsembleLogger:
17
+ """
18
+ Ensemble用ログ出力クラス
19
+
20
+ コンソール: テキスト形式(人間が読みやすい)
21
+ ファイル: JSON形式(機械可読、分析容易)
22
+ """
23
+
24
+ def __init__(self, name: str = "ensemble", log_dir: Path | None = None) -> None:
25
+ """
26
+ ロガーを初期化する
27
+
28
+ Args:
29
+ name: ロガー名
30
+ log_dir: ログ出力ディレクトリ(デフォルト: logs/)
31
+ """
32
+ self.name = name
33
+ self.log_dir = log_dir if log_dir else Path("logs")
34
+ self.log_dir.mkdir(exist_ok=True)
35
+
36
+ def _get_log_file(self) -> Path:
37
+ """今日のログファイルパスを取得"""
38
+ today = datetime.now().strftime("%Y%m%d")
39
+ return self.log_dir / f"ensemble-{today}.log"
40
+
41
+ def log(self, level: str, message: str, **kwargs: Any) -> None:
42
+ """
43
+ 構造化ログを出力する
44
+
45
+ Args:
46
+ level: ログレベル (DEBUG, INFO, WARNING, ERROR)
47
+ message: ログメッセージ
48
+ **kwargs: 追加の構造化データ
49
+ """
50
+ now = datetime.now()
51
+
52
+ # コンソール: テキスト形式
53
+ time_str = now.strftime("%H:%M:%S")
54
+ print(f"{time_str} [{level}] {message}")
55
+
56
+ # ファイル: JSON形式
57
+ log_entry = {
58
+ "timestamp": now.isoformat(),
59
+ "level": level,
60
+ "message": message,
61
+ **kwargs,
62
+ }
63
+ with open(self._get_log_file(), "a") as f:
64
+ f.write(json.dumps(log_entry) + "\n")
65
+
66
+ def debug(self, message: str, **kwargs: Any) -> None:
67
+ """DEBUGレベルでログ出力"""
68
+ self.log("DEBUG", message, **kwargs)
69
+
70
+ def info(self, message: str, **kwargs: Any) -> None:
71
+ """INFOレベルでログ出力"""
72
+ self.log("INFO", message, **kwargs)
73
+
74
+ def warning(self, message: str, **kwargs: Any) -> None:
75
+ """WARNINGレベルでログ出力"""
76
+ self.log("WARNING", message, **kwargs)
77
+
78
+ def error(self, message: str, **kwargs: Any) -> None:
79
+ """ERRORレベルでログ出力"""
80
+ self.log("ERROR", message, **kwargs)