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.
- ensemble/__init__.py +5 -0
- ensemble/ack.py +86 -0
- ensemble/cli.py +31 -0
- ensemble/commands/__init__.py +1 -0
- ensemble/commands/_init_impl.py +208 -0
- ensemble/commands/_launch_impl.py +217 -0
- ensemble/commands/init.py +35 -0
- ensemble/commands/launch.py +32 -0
- ensemble/config.py +218 -0
- ensemble/dashboard.py +168 -0
- ensemble/helpers.py +79 -0
- ensemble/lock.py +77 -0
- ensemble/logger.py +80 -0
- ensemble/notes.py +221 -0
- ensemble/queue.py +166 -0
- ensemble/templates/__init__.py +75 -0
- ensemble/templates/agents/conductor.md +239 -0
- ensemble/templates/agents/dispatch.md +351 -0
- ensemble/templates/agents/integrator.md +138 -0
- ensemble/templates/agents/learner.md +133 -0
- ensemble/templates/agents/reviewer.md +84 -0
- ensemble/templates/agents/security-reviewer.md +136 -0
- ensemble/templates/agents/worker.md +184 -0
- ensemble/templates/commands/go-light.md +49 -0
- ensemble/templates/commands/go.md +101 -0
- ensemble/templates/commands/improve.md +116 -0
- ensemble/templates/commands/review.md +74 -0
- ensemble/templates/commands/status.md +56 -0
- ensemble/templates/scripts/dashboard-update.sh +78 -0
- ensemble/templates/scripts/launch.sh +137 -0
- ensemble/templates/scripts/pane-setup.sh +111 -0
- ensemble/templates/scripts/setup.sh +163 -0
- ensemble/templates/scripts/worktree-create.sh +89 -0
- ensemble/templates/scripts/worktree-merge.sh +194 -0
- ensemble/templates/workflows/default.yaml +78 -0
- ensemble/templates/workflows/heavy.yaml +149 -0
- ensemble/templates/workflows/simple.yaml +41 -0
- ensemble/templates/workflows/worktree.yaml +202 -0
- ensemble/utils.py +60 -0
- ensemble/workflow.py +127 -0
- ensemble/worktree.py +322 -0
- ensemble_claude-0.3.0.dist-info/METADATA +144 -0
- ensemble_claude-0.3.0.dist-info/RECORD +46 -0
- ensemble_claude-0.3.0.dist-info/WHEEL +4 -0
- ensemble_claude-0.3.0.dist-info/entry_points.txt +2 -0
- ensemble_claude-0.3.0.dist-info/licenses/LICENSE +21 -0
ensemble/__init__.py
ADDED
ensemble/ack.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACK(受領確認)機構
|
|
3
|
+
|
|
4
|
+
タスク配信の確認をファイルベースで行う。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ensemble.lock import atomic_write
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AckManager:
|
|
17
|
+
"""
|
|
18
|
+
ACK管理クラス
|
|
19
|
+
|
|
20
|
+
タスク配信後、エージェントからの受領確認を管理する。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, ack_dir: Path | None = None) -> None:
|
|
24
|
+
"""
|
|
25
|
+
ACKマネージャを初期化する
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
ack_dir: ACKファイル保存ディレクトリ(デフォルト: queue/ack/)
|
|
29
|
+
"""
|
|
30
|
+
self.ack_dir = ack_dir if ack_dir else Path("queue/ack")
|
|
31
|
+
self.ack_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
def send(self, task_id: str, agent: str) -> None:
|
|
34
|
+
"""
|
|
35
|
+
ACKを送信する
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
task_id: タスクID
|
|
39
|
+
agent: ACKを送信したエージェント名
|
|
40
|
+
"""
|
|
41
|
+
ack_file = self.ack_dir / f"{task_id}.ack"
|
|
42
|
+
content = f"{agent}\n{datetime.now().isoformat()}\n"
|
|
43
|
+
atomic_write(str(ack_file), content)
|
|
44
|
+
|
|
45
|
+
def wait(self, task_id: str, timeout: float = 30.0, interval: float = 0.1) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
ACKを待機する
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
task_id: タスクID
|
|
51
|
+
timeout: タイムアウト秒数
|
|
52
|
+
interval: ポーリング間隔
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
ACK受信時True、タイムアウト時False
|
|
56
|
+
"""
|
|
57
|
+
ack_file = self.ack_dir / f"{task_id}.ack"
|
|
58
|
+
elapsed = 0.0
|
|
59
|
+
|
|
60
|
+
while elapsed < timeout:
|
|
61
|
+
if ack_file.exists():
|
|
62
|
+
return True
|
|
63
|
+
time.sleep(interval)
|
|
64
|
+
elapsed += interval
|
|
65
|
+
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def check(self, task_id: str) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
ACKが存在するか確認する
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
task_id: タスクID
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
ACK存在時True
|
|
77
|
+
"""
|
|
78
|
+
ack_file = self.ack_dir / f"{task_id}.ack"
|
|
79
|
+
return ack_file.exists()
|
|
80
|
+
|
|
81
|
+
def cleanup(self) -> None:
|
|
82
|
+
"""
|
|
83
|
+
全てのACKファイルを削除する
|
|
84
|
+
"""
|
|
85
|
+
for ack_file in self.ack_dir.glob("*.ack"):
|
|
86
|
+
ack_file.unlink()
|
ensemble/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Ensemble CLI - AI Orchestration Tool for Claude Code."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ensemble import __version__
|
|
6
|
+
from ensemble.commands.init import init
|
|
7
|
+
from ensemble.commands.launch import launch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(version=__version__, prog_name="ensemble")
|
|
12
|
+
def cli() -> None:
|
|
13
|
+
"""Ensemble - AI Orchestration Tool for Claude Code.
|
|
14
|
+
|
|
15
|
+
Ensemble provides multi-agent orchestration for complex development tasks,
|
|
16
|
+
enabling parallel execution, automatic code review, and self-improvement.
|
|
17
|
+
"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
cli.add_command(init)
|
|
22
|
+
cli.add_command(launch)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Entry point for the CLI."""
|
|
27
|
+
cli()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ensemble CLI commands."""
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Implementation of the ensemble init command."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ensemble.templates import get_template_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_init(full: bool = False, force: bool = False) -> None:
|
|
13
|
+
"""Run the init command implementation.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
full: If True, copy all agent definitions locally.
|
|
17
|
+
force: If True, overwrite existing files.
|
|
18
|
+
"""
|
|
19
|
+
project_root = Path.cwd()
|
|
20
|
+
ensemble_dir = project_root / ".ensemble"
|
|
21
|
+
|
|
22
|
+
click.echo(f"Initializing Ensemble in {project_root}")
|
|
23
|
+
|
|
24
|
+
# Create directory structure
|
|
25
|
+
_create_directories(ensemble_dir)
|
|
26
|
+
|
|
27
|
+
# Create CLAUDE.md or append Ensemble section
|
|
28
|
+
_setup_claude_md(project_root, force)
|
|
29
|
+
|
|
30
|
+
# Update .gitignore
|
|
31
|
+
_update_gitignore(project_root)
|
|
32
|
+
|
|
33
|
+
# Create initial dashboard
|
|
34
|
+
_create_dashboard(ensemble_dir)
|
|
35
|
+
|
|
36
|
+
# Create panes.env placeholder
|
|
37
|
+
_create_panes_env(ensemble_dir)
|
|
38
|
+
|
|
39
|
+
# If --full, copy agent definitions
|
|
40
|
+
if full:
|
|
41
|
+
_copy_agent_definitions(project_root, force)
|
|
42
|
+
|
|
43
|
+
click.echo(click.style("Ensemble initialized successfully!", fg="green"))
|
|
44
|
+
click.echo("\nNext steps:")
|
|
45
|
+
click.echo(" 1. Run 'ensemble launch' to start the tmux session")
|
|
46
|
+
click.echo(" 2. Use '/go <task>' to begin orchestration")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _create_directories(ensemble_dir: Path) -> None:
|
|
50
|
+
"""Create the .ensemble directory structure."""
|
|
51
|
+
dirs = [
|
|
52
|
+
ensemble_dir,
|
|
53
|
+
ensemble_dir / "queue" / "conductor",
|
|
54
|
+
ensemble_dir / "queue" / "tasks",
|
|
55
|
+
ensemble_dir / "queue" / "reports",
|
|
56
|
+
ensemble_dir / "queue" / "ack",
|
|
57
|
+
ensemble_dir / "status",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for dir_path in dirs:
|
|
61
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
click.echo(f" Created {dir_path.relative_to(ensemble_dir.parent)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _setup_claude_md(project_root: Path, force: bool) -> None:
|
|
66
|
+
"""Create or update CLAUDE.md with Ensemble section."""
|
|
67
|
+
claude_md = project_root / "CLAUDE.md"
|
|
68
|
+
ensemble_section = '''
|
|
69
|
+
## Ensemble AI Orchestration
|
|
70
|
+
|
|
71
|
+
This project uses Ensemble for AI-powered development orchestration.
|
|
72
|
+
|
|
73
|
+
### Quick Start
|
|
74
|
+
- `/go <task>` - Start a task with automatic planning and execution
|
|
75
|
+
- `/go-light <task>` - Lightweight execution for simple changes
|
|
76
|
+
- `/status` - View current progress
|
|
77
|
+
|
|
78
|
+
### Communication Protocol
|
|
79
|
+
- Agent communication via file-based queue (.ensemble/queue/)
|
|
80
|
+
- Dashboard updates in .ensemble/status/dashboard.md
|
|
81
|
+
|
|
82
|
+
For more information, see the [Ensemble documentation](https://github.com/ChikaKakazu/ensemble).
|
|
83
|
+
'''
|
|
84
|
+
|
|
85
|
+
if claude_md.exists():
|
|
86
|
+
content = claude_md.read_text()
|
|
87
|
+
if "## Ensemble AI Orchestration" in content:
|
|
88
|
+
if force:
|
|
89
|
+
# Remove existing section and re-add
|
|
90
|
+
lines = content.split("\n")
|
|
91
|
+
new_lines = []
|
|
92
|
+
skip = False
|
|
93
|
+
for line in lines:
|
|
94
|
+
if line.startswith("## Ensemble AI Orchestration"):
|
|
95
|
+
skip = True
|
|
96
|
+
continue
|
|
97
|
+
if skip and line.startswith("## "):
|
|
98
|
+
skip = False
|
|
99
|
+
if not skip:
|
|
100
|
+
new_lines.append(line)
|
|
101
|
+
content = "\n".join(new_lines)
|
|
102
|
+
else:
|
|
103
|
+
click.echo(" CLAUDE.md already contains Ensemble section (use --force to overwrite)")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Append Ensemble section
|
|
107
|
+
with open(claude_md, "a") as f:
|
|
108
|
+
f.write(ensemble_section)
|
|
109
|
+
click.echo(" Updated CLAUDE.md with Ensemble section")
|
|
110
|
+
else:
|
|
111
|
+
# Create new CLAUDE.md
|
|
112
|
+
with open(claude_md, "w") as f:
|
|
113
|
+
f.write(f"# {project_root.name}\n")
|
|
114
|
+
f.write(ensemble_section)
|
|
115
|
+
click.echo(" Created CLAUDE.md")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _update_gitignore(project_root: Path) -> None:
|
|
119
|
+
"""Update .gitignore with Ensemble exclusions."""
|
|
120
|
+
gitignore = project_root / ".gitignore"
|
|
121
|
+
ensemble_ignores = """
|
|
122
|
+
# Ensemble
|
|
123
|
+
.ensemble/queue/
|
|
124
|
+
.ensemble/panes.env
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
if gitignore.exists():
|
|
128
|
+
content = gitignore.read_text()
|
|
129
|
+
if "# Ensemble" in content:
|
|
130
|
+
click.echo(" .gitignore already contains Ensemble section")
|
|
131
|
+
return
|
|
132
|
+
with open(gitignore, "a") as f:
|
|
133
|
+
f.write(ensemble_ignores)
|
|
134
|
+
click.echo(" Updated .gitignore")
|
|
135
|
+
else:
|
|
136
|
+
with open(gitignore, "w") as f:
|
|
137
|
+
f.write(ensemble_ignores.strip() + "\n")
|
|
138
|
+
click.echo(" Created .gitignore")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _create_dashboard(ensemble_dir: Path) -> None:
|
|
142
|
+
"""Create initial dashboard.md."""
|
|
143
|
+
dashboard = ensemble_dir / "status" / "dashboard.md"
|
|
144
|
+
content = """# Ensemble Dashboard
|
|
145
|
+
|
|
146
|
+
## Current Task
|
|
147
|
+
None
|
|
148
|
+
|
|
149
|
+
## Execution Status
|
|
150
|
+
| Pane/Worktree | Status | Agent | Progress |
|
|
151
|
+
|---|---|---|---|
|
|
152
|
+
| - | idle | - | - |
|
|
153
|
+
|
|
154
|
+
## Recent Completed Tasks
|
|
155
|
+
| Task | Result | Completed |
|
|
156
|
+
|------|--------|-----------|
|
|
157
|
+
| - | - | - |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
*Last updated: -
|
|
161
|
+
"""
|
|
162
|
+
dashboard.write_text(content)
|
|
163
|
+
click.echo(" Created status/dashboard.md")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _create_panes_env(ensemble_dir: Path) -> None:
|
|
167
|
+
"""Create panes.env placeholder."""
|
|
168
|
+
panes_env = ensemble_dir / "panes.env"
|
|
169
|
+
content = """# Ensemble pane IDs (auto-generated by launch)
|
|
170
|
+
# This file will be populated when 'ensemble launch' is run
|
|
171
|
+
"""
|
|
172
|
+
panes_env.write_text(content)
|
|
173
|
+
click.echo(" Created panes.env placeholder")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _copy_agent_definitions(project_root: Path, force: bool) -> None:
|
|
177
|
+
"""Copy agent definitions to local .claude/agents/."""
|
|
178
|
+
click.echo("\nCopying agent definitions for local customization...")
|
|
179
|
+
|
|
180
|
+
claude_agents_dir = project_root / ".claude" / "agents"
|
|
181
|
+
claude_agents_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
template_agents = get_template_path("agents")
|
|
184
|
+
if not template_agents.exists():
|
|
185
|
+
click.echo(click.style(" Warning: Agent templates not found", fg="yellow"))
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
for agent_file in template_agents.glob("*.md"):
|
|
189
|
+
dest = claude_agents_dir / agent_file.name
|
|
190
|
+
if dest.exists() and not force:
|
|
191
|
+
click.echo(f" Skipped {agent_file.name} (exists, use --force to overwrite)")
|
|
192
|
+
continue
|
|
193
|
+
shutil.copy(agent_file, dest)
|
|
194
|
+
click.echo(f" Copied {agent_file.name}")
|
|
195
|
+
|
|
196
|
+
# Also copy commands
|
|
197
|
+
claude_commands_dir = project_root / ".claude" / "commands"
|
|
198
|
+
claude_commands_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
|
|
200
|
+
template_commands = get_template_path("commands")
|
|
201
|
+
if template_commands.exists():
|
|
202
|
+
for cmd_file in template_commands.glob("*.md"):
|
|
203
|
+
dest = claude_commands_dir / cmd_file.name
|
|
204
|
+
if dest.exists() and not force:
|
|
205
|
+
click.echo(f" Skipped {cmd_file.name} (exists)")
|
|
206
|
+
continue
|
|
207
|
+
shutil.copy(cmd_file, dest)
|
|
208
|
+
click.echo(f" Copied {cmd_file.name}")
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Implementation of the ensemble launch command."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ensemble.templates import get_template_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_launch(session: str = "ensemble", attach: bool = True) -> None:
|
|
16
|
+
"""Run the launch command implementation.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
session: Name of the tmux session.
|
|
20
|
+
attach: Whether to attach to the session after creation.
|
|
21
|
+
"""
|
|
22
|
+
# Check prerequisites
|
|
23
|
+
if not _check_tmux():
|
|
24
|
+
click.echo(click.style("Error: tmux is not installed or not in PATH", fg="red"))
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
if not _check_claude():
|
|
28
|
+
click.echo(click.style("Error: claude CLI is not installed or not in PATH", fg="red"))
|
|
29
|
+
click.echo("Install Claude Code from: https://claude.ai/code")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
# Check if session already exists
|
|
33
|
+
if _session_exists(session):
|
|
34
|
+
click.echo(f"Session '{session}' already exists.")
|
|
35
|
+
if attach:
|
|
36
|
+
click.echo(f"Attaching to existing session...")
|
|
37
|
+
_attach_session(session)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
project_root = Path.cwd()
|
|
41
|
+
ensemble_dir = project_root / ".ensemble"
|
|
42
|
+
|
|
43
|
+
# Verify project is initialized
|
|
44
|
+
if not ensemble_dir.exists():
|
|
45
|
+
click.echo(click.style("Error: Project not initialized for Ensemble", fg="red"))
|
|
46
|
+
click.echo("Run 'ensemble init' first")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
click.echo(f"Launching Ensemble session '{session}'...")
|
|
50
|
+
|
|
51
|
+
# Get agent paths
|
|
52
|
+
agents = _resolve_agent_paths(project_root)
|
|
53
|
+
|
|
54
|
+
# Create tmux session with layout
|
|
55
|
+
_create_session(session, project_root, agents)
|
|
56
|
+
|
|
57
|
+
# Save pane IDs
|
|
58
|
+
_save_pane_ids(session, ensemble_dir)
|
|
59
|
+
|
|
60
|
+
click.echo(click.style("Ensemble session started!", fg="green"))
|
|
61
|
+
|
|
62
|
+
if attach:
|
|
63
|
+
_attach_session(session)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_tmux() -> bool:
|
|
67
|
+
"""Check if tmux is available."""
|
|
68
|
+
return shutil.which("tmux") is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_claude() -> bool:
|
|
72
|
+
"""Check if claude CLI is available."""
|
|
73
|
+
return shutil.which("claude") is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _session_exists(session: str) -> bool:
|
|
77
|
+
"""Check if a tmux session exists."""
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
["tmux", "has-session", "-t", session],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
)
|
|
82
|
+
return result.returncode == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _attach_session(session: str) -> None:
|
|
86
|
+
"""Attach to an existing tmux session."""
|
|
87
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", session])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_agent_paths(project_root: Path) -> dict[str, Path]:
|
|
91
|
+
"""Resolve agent definition paths with priority.
|
|
92
|
+
|
|
93
|
+
Priority order:
|
|
94
|
+
1. Local project: ./.claude/agents/
|
|
95
|
+
2. Global config: ~/.config/ensemble/agents/
|
|
96
|
+
3. Package templates
|
|
97
|
+
"""
|
|
98
|
+
agents = {}
|
|
99
|
+
agent_names = ["conductor", "dispatch", "worker", "reviewer", "security-reviewer", "integrator", "learner"]
|
|
100
|
+
|
|
101
|
+
local_agents = project_root / ".claude" / "agents"
|
|
102
|
+
global_agents = Path.home() / ".config" / "ensemble" / "agents"
|
|
103
|
+
template_agents = get_template_path("agents")
|
|
104
|
+
|
|
105
|
+
for name in agent_names:
|
|
106
|
+
filename = f"{name}.md"
|
|
107
|
+
|
|
108
|
+
# Check local first
|
|
109
|
+
local_path = local_agents / filename
|
|
110
|
+
if local_path.exists():
|
|
111
|
+
agents[name] = local_path
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Check global
|
|
115
|
+
global_path = global_agents / filename
|
|
116
|
+
if global_path.exists():
|
|
117
|
+
agents[name] = global_path
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Fall back to template
|
|
121
|
+
template_path = template_agents / filename
|
|
122
|
+
if template_path.exists():
|
|
123
|
+
agents[name] = template_path
|
|
124
|
+
|
|
125
|
+
return agents
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _create_session(session: str, project_root: Path, agents: dict[str, Path]) -> None:
|
|
129
|
+
"""Create the tmux session with Ensemble layout."""
|
|
130
|
+
# Create new session (detached)
|
|
131
|
+
subprocess.run(
|
|
132
|
+
[
|
|
133
|
+
"tmux", "new-session",
|
|
134
|
+
"-d",
|
|
135
|
+
"-s", session,
|
|
136
|
+
"-c", str(project_root),
|
|
137
|
+
"-n", "main",
|
|
138
|
+
],
|
|
139
|
+
check=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Split into 3 panes: Conductor (left), Dashboard (top-right), Dispatch (bottom-right)
|
|
143
|
+
# First split vertically (creates pane 1 on right)
|
|
144
|
+
subprocess.run(
|
|
145
|
+
["tmux", "split-window", "-t", f"{session}:main", "-h", "-c", str(project_root)],
|
|
146
|
+
check=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Split the right pane horizontally (creates pane 2 at bottom-right)
|
|
150
|
+
subprocess.run(
|
|
151
|
+
["tmux", "split-window", "-t", f"{session}:main.1", "-v", "-c", str(project_root)],
|
|
152
|
+
check=True,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Set pane titles
|
|
156
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.0", "-T", "conductor"], check=True)
|
|
157
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.1", "-T", "dashboard"], check=True)
|
|
158
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.2", "-T", "dispatch"], check=True)
|
|
159
|
+
|
|
160
|
+
# Start Claude in Conductor pane (pane 0)
|
|
161
|
+
conductor_agent = agents.get("conductor")
|
|
162
|
+
if conductor_agent:
|
|
163
|
+
cmd = f"claude --agent {conductor_agent}"
|
|
164
|
+
else:
|
|
165
|
+
cmd = "claude"
|
|
166
|
+
subprocess.run(
|
|
167
|
+
["tmux", "send-keys", "-t", f"{session}:main.0", cmd, "Enter"],
|
|
168
|
+
check=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Start dashboard watch in Dashboard pane (pane 1)
|
|
172
|
+
dashboard_path = project_root / ".ensemble" / "status" / "dashboard.md"
|
|
173
|
+
subprocess.run(
|
|
174
|
+
["tmux", "send-keys", "-t", f"{session}:main.1", f"watch -n 2 cat {dashboard_path}", "Enter"],
|
|
175
|
+
check=True,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Start Claude in Dispatch pane (pane 2)
|
|
179
|
+
dispatch_agent = agents.get("dispatch")
|
|
180
|
+
if dispatch_agent:
|
|
181
|
+
cmd = f"claude --agent {dispatch_agent}"
|
|
182
|
+
else:
|
|
183
|
+
cmd = "claude"
|
|
184
|
+
subprocess.run(
|
|
185
|
+
["tmux", "send-keys", "-t", f"{session}:main.2", cmd, "Enter"],
|
|
186
|
+
check=True,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Select Conductor pane
|
|
190
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.0"], check=True)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _save_pane_ids(session: str, ensemble_dir: Path) -> None:
|
|
194
|
+
"""Save pane IDs to panes.env file."""
|
|
195
|
+
# Get pane IDs
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
["tmux", "list-panes", "-t", f"{session}:main", "-F", "#{pane_index}:#{pane_id}"],
|
|
198
|
+
capture_output=True,
|
|
199
|
+
text=True,
|
|
200
|
+
check=True,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
pane_map = {}
|
|
204
|
+
for line in result.stdout.strip().split("\n"):
|
|
205
|
+
if ":" in line:
|
|
206
|
+
idx, pane_id = line.split(":", 1)
|
|
207
|
+
pane_map[int(idx)] = pane_id
|
|
208
|
+
|
|
209
|
+
# Write panes.env
|
|
210
|
+
panes_env = ensemble_dir / "panes.env"
|
|
211
|
+
with open(panes_env, "w") as f:
|
|
212
|
+
f.write("# Ensemble pane IDs (auto-generated)\n")
|
|
213
|
+
f.write(f"CONDUCTOR_PANE={pane_map.get(0, '%0')}\n")
|
|
214
|
+
f.write(f"DASHBOARD_PANE={pane_map.get(1, '%1')}\n")
|
|
215
|
+
f.write(f"DISPATCH_PANE={pane_map.get(2, '%2')}\n")
|
|
216
|
+
|
|
217
|
+
click.echo(f" Saved pane IDs to {panes_env.relative_to(ensemble_dir.parent)}")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Ensemble init command - Initialize a project for Ensemble."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.option(
|
|
10
|
+
"--full",
|
|
11
|
+
is_flag=True,
|
|
12
|
+
help="Copy all agent definitions to local project for customization.",
|
|
13
|
+
)
|
|
14
|
+
@click.option(
|
|
15
|
+
"--force",
|
|
16
|
+
is_flag=True,
|
|
17
|
+
help="Overwrite existing configuration files.",
|
|
18
|
+
)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def init(ctx: click.Context, full: bool, force: bool) -> None:
|
|
21
|
+
"""Initialize the current project for Ensemble.
|
|
22
|
+
|
|
23
|
+
This command sets up the necessary directory structure and configuration
|
|
24
|
+
files for using Ensemble in your project.
|
|
25
|
+
|
|
26
|
+
Creates:
|
|
27
|
+
- .ensemble/ directory with queue/ and status/ subdirectories
|
|
28
|
+
- Adds Ensemble section to CLAUDE.md (creates if not exists)
|
|
29
|
+
- Updates .gitignore to exclude queue files
|
|
30
|
+
|
|
31
|
+
Use --full to copy all agent definitions locally for customization.
|
|
32
|
+
"""
|
|
33
|
+
from ensemble.commands._init_impl import run_init
|
|
34
|
+
|
|
35
|
+
run_init(full=full, force=force)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Ensemble launch command - Start the Ensemble tmux session."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.command()
|
|
7
|
+
@click.option(
|
|
8
|
+
"--session",
|
|
9
|
+
"-s",
|
|
10
|
+
default="ensemble",
|
|
11
|
+
help="Name of the tmux session to create.",
|
|
12
|
+
)
|
|
13
|
+
@click.option(
|
|
14
|
+
"--attach/--no-attach",
|
|
15
|
+
default=True,
|
|
16
|
+
help="Attach to the session after creation.",
|
|
17
|
+
)
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def launch(ctx: click.Context, session: str, attach: bool) -> None:
|
|
20
|
+
"""Launch the Ensemble tmux session.
|
|
21
|
+
|
|
22
|
+
This command creates a tmux session with the Conductor, Dispatch,
|
|
23
|
+
and Dashboard panes configured and ready for orchestration.
|
|
24
|
+
|
|
25
|
+
The session will use configuration from (in priority order):
|
|
26
|
+
1. ./.ensemble/agents/*.md (local project customization)
|
|
27
|
+
2. ~/.config/ensemble/agents/*.md (global customization)
|
|
28
|
+
3. Package bundled templates (defaults)
|
|
29
|
+
"""
|
|
30
|
+
from ensemble.commands._launch_impl import run_launch
|
|
31
|
+
|
|
32
|
+
run_launch(session=session, attach=attach)
|