ensemble-claude 0.3.1__py3-none-any.whl → 0.4.1__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 +1 -1
- ensemble/cli.py +2 -0
- ensemble/commands/_issue_impl.py +150 -0
- ensemble/commands/_launch_impl.py +172 -47
- ensemble/commands/issue.py +36 -0
- ensemble/git_utils.py +157 -0
- ensemble/issue_provider.py +97 -0
- ensemble/providers/__init__.py +5 -0
- ensemble/providers/github.py +103 -0
- ensemble/templates/agents/conductor.md +64 -10
- ensemble/templates/agents/dispatch.md +128 -14
- ensemble/templates/agents/worker.md +55 -0
- ensemble/templates/commands/go-issue.md +157 -0
- ensemble/templates/commands/go.md +19 -4
- ensemble/templates/scripts/launch.sh +97 -58
- ensemble/templates/scripts/pane-setup.sh +103 -30
- {ensemble_claude-0.3.1.dist-info → ensemble_claude-0.4.1.dist-info}/METADATA +8 -5
- {ensemble_claude-0.3.1.dist-info → ensemble_claude-0.4.1.dist-info}/RECORD +21 -14
- {ensemble_claude-0.3.1.dist-info → ensemble_claude-0.4.1.dist-info}/WHEEL +0 -0
- {ensemble_claude-0.3.1.dist-info → ensemble_claude-0.4.1.dist-info}/entry_points.txt +0 -0
- {ensemble_claude-0.3.1.dist-info → ensemble_claude-0.4.1.dist-info}/licenses/LICENSE +0 -0
ensemble/__init__.py
CHANGED
ensemble/cli.py
CHANGED
|
@@ -4,6 +4,7 @@ import click
|
|
|
4
4
|
|
|
5
5
|
from ensemble import __version__
|
|
6
6
|
from ensemble.commands.init import init
|
|
7
|
+
from ensemble.commands.issue import issue
|
|
7
8
|
from ensemble.commands.launch import launch
|
|
8
9
|
|
|
9
10
|
|
|
@@ -19,6 +20,7 @@ def cli() -> None:
|
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
cli.add_command(init)
|
|
23
|
+
cli.add_command(issue)
|
|
22
24
|
cli.add_command(launch)
|
|
23
25
|
|
|
24
26
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Implementation of the ensemble issue command."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ensemble.issue_provider import Issue, IssueProvider
|
|
6
|
+
from ensemble.providers.github import GitHubProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_provider(provider_name: str) -> IssueProvider:
|
|
10
|
+
"""Get the issue provider by name.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
provider_name: Name of the provider ('github', 'gitlab').
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
IssueProvider instance.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
click.ClickException: If provider is not available.
|
|
20
|
+
"""
|
|
21
|
+
if provider_name == "github":
|
|
22
|
+
provider = GitHubProvider()
|
|
23
|
+
if not provider.is_available():
|
|
24
|
+
raise click.ClickException(
|
|
25
|
+
"GitHub CLI (gh) is not installed. "
|
|
26
|
+
"Install it from: https://cli.github.com/"
|
|
27
|
+
)
|
|
28
|
+
return provider
|
|
29
|
+
elif provider_name == "gitlab":
|
|
30
|
+
raise click.ClickException(
|
|
31
|
+
"GitLab provider is not yet implemented. Coming soon!"
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
raise click.ClickException(f"Unknown provider: {provider_name}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_issue(
|
|
38
|
+
identifier: str | None,
|
|
39
|
+
state: str,
|
|
40
|
+
provider: str,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Run the issue command implementation.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
identifier: Issue number or URL (None to list issues).
|
|
46
|
+
state: Filter state for listing ('open', 'closed', 'all').
|
|
47
|
+
provider: Provider name ('github', 'gitlab').
|
|
48
|
+
"""
|
|
49
|
+
issue_provider = get_provider(provider)
|
|
50
|
+
|
|
51
|
+
if identifier:
|
|
52
|
+
# View specific issue
|
|
53
|
+
_view_issue(issue_provider, identifier)
|
|
54
|
+
else:
|
|
55
|
+
# List issues
|
|
56
|
+
_list_issues(issue_provider, state)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _view_issue(provider: IssueProvider, identifier: str) -> None:
|
|
60
|
+
"""View a specific issue.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
provider: Issue provider instance.
|
|
64
|
+
identifier: Issue number or URL.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
issue = provider.get_issue(identifier)
|
|
68
|
+
_print_issue_detail(issue)
|
|
69
|
+
except ValueError as e:
|
|
70
|
+
raise click.ClickException(str(e))
|
|
71
|
+
except RuntimeError as e:
|
|
72
|
+
raise click.ClickException(str(e))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _list_issues(provider: IssueProvider, state: str) -> None:
|
|
76
|
+
"""List issues.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
provider: Issue provider instance.
|
|
80
|
+
state: Filter state.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
issues = provider.list_issues(state=state)
|
|
84
|
+
|
|
85
|
+
if not issues:
|
|
86
|
+
click.echo(f"No {state} issues found.")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
click.echo(f"\n{click.style(f'{len(issues)} {state} issue(s):', bold=True)}\n")
|
|
90
|
+
|
|
91
|
+
for issue in issues:
|
|
92
|
+
_print_issue_summary(issue)
|
|
93
|
+
|
|
94
|
+
click.echo("")
|
|
95
|
+
|
|
96
|
+
except RuntimeError as e:
|
|
97
|
+
raise click.ClickException(str(e))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_issue_summary(issue: Issue) -> None:
|
|
101
|
+
"""Print a one-line summary of an issue.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
issue: Issue to print.
|
|
105
|
+
"""
|
|
106
|
+
# Format: #123 [bug, urgent] Issue title
|
|
107
|
+
number = click.style(f"#{issue.number}", fg="cyan", bold=True)
|
|
108
|
+
labels = ""
|
|
109
|
+
if issue.labels:
|
|
110
|
+
label_str = ", ".join(issue.labels[:3]) # Max 3 labels
|
|
111
|
+
if len(issue.labels) > 3:
|
|
112
|
+
label_str += ", ..."
|
|
113
|
+
labels = click.style(f" [{label_str}]", fg="yellow")
|
|
114
|
+
|
|
115
|
+
# Truncate title if too long
|
|
116
|
+
title = issue.title
|
|
117
|
+
if len(title) > 60:
|
|
118
|
+
title = title[:57] + "..."
|
|
119
|
+
|
|
120
|
+
click.echo(f" {number}{labels} {title}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_issue_detail(issue: Issue) -> None:
|
|
124
|
+
"""Print detailed view of an issue.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
issue: Issue to print.
|
|
128
|
+
"""
|
|
129
|
+
click.echo("")
|
|
130
|
+
click.echo(click.style(f"Issue #{issue.number}", fg="cyan", bold=True))
|
|
131
|
+
click.echo(click.style("=" * 60, dim=True))
|
|
132
|
+
click.echo("")
|
|
133
|
+
click.echo(f"{click.style('Title:', bold=True)} {issue.title}")
|
|
134
|
+
click.echo(f"{click.style('State:', bold=True)} {issue.state}")
|
|
135
|
+
|
|
136
|
+
if issue.labels:
|
|
137
|
+
labels = ", ".join(issue.labels)
|
|
138
|
+
click.echo(f"{click.style('Labels:', bold=True)} {labels}")
|
|
139
|
+
|
|
140
|
+
click.echo(f"{click.style('URL:', bold=True)} {issue.url}")
|
|
141
|
+
click.echo("")
|
|
142
|
+
|
|
143
|
+
if issue.body:
|
|
144
|
+
click.echo(click.style("Description:", bold=True))
|
|
145
|
+
click.echo(click.style("-" * 40, dim=True))
|
|
146
|
+
click.echo(issue.body)
|
|
147
|
+
else:
|
|
148
|
+
click.echo(click.style("(No description)", dim=True))
|
|
149
|
+
|
|
150
|
+
click.echo("")
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import shutil
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
import time
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Optional
|
|
9
10
|
|
|
@@ -16,7 +17,7 @@ def run_launch(session: str = "ensemble", attach: bool = True) -> None:
|
|
|
16
17
|
"""Run the launch command implementation.
|
|
17
18
|
|
|
18
19
|
Args:
|
|
19
|
-
session:
|
|
20
|
+
session: Base name for the tmux sessions (will create {session}-conductor and {session}-workers).
|
|
20
21
|
attach: Whether to attach to the session after creation.
|
|
21
22
|
"""
|
|
22
23
|
# Check prerequisites
|
|
@@ -29,12 +30,24 @@ def run_launch(session: str = "ensemble", attach: bool = True) -> None:
|
|
|
29
30
|
click.echo("Install Claude Code from: https://claude.ai/code")
|
|
30
31
|
sys.exit(1)
|
|
31
32
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# Session names
|
|
34
|
+
conductor_session = f"{session}-conductor"
|
|
35
|
+
workers_session = f"{session}-workers"
|
|
36
|
+
|
|
37
|
+
# Check if sessions already exist
|
|
38
|
+
conductor_exists = _session_exists(conductor_session)
|
|
39
|
+
workers_exists = _session_exists(workers_session)
|
|
40
|
+
|
|
41
|
+
if conductor_exists or workers_exists:
|
|
42
|
+
click.echo("Existing Ensemble sessions found:")
|
|
43
|
+
if conductor_exists:
|
|
44
|
+
click.echo(f" - {conductor_session}")
|
|
45
|
+
if workers_exists:
|
|
46
|
+
click.echo(f" - {workers_session}")
|
|
47
|
+
click.echo("")
|
|
48
|
+
click.echo("To attach, open two terminal windows and run:")
|
|
49
|
+
click.echo(f" Terminal 1: tmux attach -t {conductor_session}")
|
|
50
|
+
click.echo(f" Terminal 2: tmux attach -t {workers_session}")
|
|
38
51
|
return
|
|
39
52
|
|
|
40
53
|
project_root = Path.cwd()
|
|
@@ -46,21 +59,58 @@ def run_launch(session: str = "ensemble", attach: bool = True) -> None:
|
|
|
46
59
|
click.echo("Run 'ensemble init' first")
|
|
47
60
|
sys.exit(1)
|
|
48
61
|
|
|
49
|
-
click.echo(f"Launching Ensemble
|
|
62
|
+
click.echo(f"Launching Ensemble sessions '{session}-*'...")
|
|
63
|
+
|
|
64
|
+
# Clean up queue directories
|
|
65
|
+
queue_dir = project_root / "queue"
|
|
66
|
+
if queue_dir.exists():
|
|
67
|
+
for subdir in ["tasks", "processing", "reports", "ack"]:
|
|
68
|
+
subdir_path = queue_dir / subdir
|
|
69
|
+
if subdir_path.exists():
|
|
70
|
+
for f in subdir_path.glob("*.yaml"):
|
|
71
|
+
f.unlink()
|
|
72
|
+
for f in subdir_path.glob("*.ack"):
|
|
73
|
+
f.unlink()
|
|
74
|
+
|
|
75
|
+
# Ensure queue directories exist
|
|
76
|
+
for subdir in ["tasks", "processing", "reports", "ack", "conductor"]:
|
|
77
|
+
(queue_dir / subdir).mkdir(parents=True, exist_ok=True)
|
|
50
78
|
|
|
51
79
|
# Get agent paths
|
|
52
80
|
agents = _resolve_agent_paths(project_root)
|
|
53
81
|
|
|
54
|
-
# Create tmux
|
|
55
|
-
|
|
82
|
+
# Create tmux sessions (2 separate sessions)
|
|
83
|
+
_create_sessions(session, project_root, agents)
|
|
56
84
|
|
|
57
85
|
# Save pane IDs
|
|
58
86
|
_save_pane_ids(session, ensemble_dir)
|
|
59
87
|
|
|
60
|
-
click.echo(click.style("Ensemble
|
|
88
|
+
click.echo(click.style("Ensemble sessions started!", fg="green"))
|
|
89
|
+
click.echo("")
|
|
90
|
+
click.echo("==========================================")
|
|
91
|
+
click.echo(" Two separate tmux sessions created!")
|
|
92
|
+
click.echo("==========================================")
|
|
93
|
+
click.echo("")
|
|
94
|
+
click.echo(f"Session 1: {conductor_session}")
|
|
95
|
+
click.echo(" +------------------+------------------+")
|
|
96
|
+
click.echo(" | Conductor | dashboard |")
|
|
97
|
+
click.echo(" +------------------+------------------+")
|
|
98
|
+
click.echo("")
|
|
99
|
+
click.echo(f"Session 2: {workers_session}")
|
|
100
|
+
click.echo(" +------------------+------------------+")
|
|
101
|
+
click.echo(" | dispatch | worker-area |")
|
|
102
|
+
click.echo(" +------------------+------------------+")
|
|
103
|
+
click.echo("")
|
|
104
|
+
click.echo("To view both simultaneously, open two terminal windows:")
|
|
105
|
+
click.echo(f" Terminal 1: tmux attach -t {conductor_session}")
|
|
106
|
+
click.echo(f" Terminal 2: tmux attach -t {workers_session}")
|
|
107
|
+
click.echo("")
|
|
61
108
|
|
|
62
109
|
if attach:
|
|
63
|
-
|
|
110
|
+
click.echo(f"Attaching to {conductor_session}...")
|
|
111
|
+
click.echo(f"(Open another terminal and run: tmux attach -t {workers_session})")
|
|
112
|
+
click.echo("")
|
|
113
|
+
_attach_session(conductor_session)
|
|
64
114
|
|
|
65
115
|
|
|
66
116
|
def _check_tmux() -> bool:
|
|
@@ -125,93 +175,168 @@ def _resolve_agent_paths(project_root: Path) -> dict[str, Path]:
|
|
|
125
175
|
return agents
|
|
126
176
|
|
|
127
177
|
|
|
128
|
-
def
|
|
129
|
-
"""Create
|
|
130
|
-
|
|
178
|
+
def _create_sessions(session: str, project_root: Path, agents: dict[str, Path]) -> None:
|
|
179
|
+
"""Create two separate tmux sessions for Ensemble.
|
|
180
|
+
|
|
181
|
+
Session 1 ({session}-conductor): Conductor (left 60%) + Dashboard (right 40%)
|
|
182
|
+
Session 2 ({session}-workers): Dispatch (left 60%) + Worker area (right 40%)
|
|
183
|
+
|
|
184
|
+
This allows viewing both sessions simultaneously in separate terminal windows.
|
|
185
|
+
"""
|
|
186
|
+
conductor_session = f"{session}-conductor"
|
|
187
|
+
workers_session = f"{session}-workers"
|
|
188
|
+
|
|
189
|
+
# === Session 1: Conductor ===
|
|
131
190
|
subprocess.run(
|
|
132
191
|
[
|
|
133
192
|
"tmux", "new-session",
|
|
134
193
|
"-d",
|
|
135
|
-
"-s",
|
|
194
|
+
"-s", conductor_session,
|
|
136
195
|
"-c", str(project_root),
|
|
137
196
|
"-n", "main",
|
|
138
197
|
],
|
|
139
198
|
check=True,
|
|
140
199
|
)
|
|
141
200
|
|
|
142
|
-
# Split
|
|
143
|
-
# First split vertically (creates pane 1 on right)
|
|
201
|
+
# Split conductor window: left/right (60/40)
|
|
144
202
|
subprocess.run(
|
|
145
|
-
["tmux", "split-window", "-t", f"{
|
|
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)],
|
|
203
|
+
["tmux", "split-window", "-t", f"{conductor_session}:main", "-h", "-l", "40%", "-c", str(project_root)],
|
|
152
204
|
check=True,
|
|
153
205
|
)
|
|
154
206
|
|
|
155
207
|
# Set pane titles
|
|
156
|
-
subprocess.run(["tmux", "select-pane", "-t", f"{
|
|
157
|
-
subprocess.run(["tmux", "select-pane", "-t", f"{
|
|
158
|
-
subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.2", "-T", "dispatch"], check=True)
|
|
208
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{conductor_session}:main.0", "-T", "conductor"], check=True)
|
|
209
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{conductor_session}:main.1", "-T", "dashboard"], check=True)
|
|
159
210
|
|
|
160
|
-
# Start Claude in Conductor pane (
|
|
211
|
+
# Start Claude in Conductor pane (left)
|
|
161
212
|
conductor_agent = agents.get("conductor")
|
|
162
213
|
if conductor_agent:
|
|
163
|
-
cmd = f"claude --agent {conductor_agent}"
|
|
214
|
+
cmd = f"MAX_THINKING_TOKENS=0 claude --agent {conductor_agent} --model opus --dangerously-skip-permissions"
|
|
164
215
|
else:
|
|
165
|
-
cmd = "claude"
|
|
216
|
+
cmd = "MAX_THINKING_TOKENS=0 claude --model opus --dangerously-skip-permissions"
|
|
217
|
+
subprocess.run(
|
|
218
|
+
["tmux", "send-keys", "-t", f"{conductor_session}:main.0", cmd],
|
|
219
|
+
check=True,
|
|
220
|
+
)
|
|
221
|
+
time.sleep(1)
|
|
166
222
|
subprocess.run(
|
|
167
|
-
["tmux", "send-keys", "-t", f"{
|
|
223
|
+
["tmux", "send-keys", "-t", f"{conductor_session}:main.0", "Enter"],
|
|
168
224
|
check=True,
|
|
169
225
|
)
|
|
170
226
|
|
|
171
|
-
# Start dashboard watch in Dashboard pane (
|
|
227
|
+
# Start dashboard watch in Dashboard pane (right)
|
|
172
228
|
dashboard_path = project_root / ".ensemble" / "status" / "dashboard.md"
|
|
173
229
|
subprocess.run(
|
|
174
|
-
["tmux", "send-keys", "-t", f"{
|
|
230
|
+
["tmux", "send-keys", "-t", f"{conductor_session}:main.1", f"watch -n 5 cat {dashboard_path}"],
|
|
175
231
|
check=True,
|
|
176
232
|
)
|
|
233
|
+
time.sleep(1)
|
|
234
|
+
subprocess.run(
|
|
235
|
+
["tmux", "send-keys", "-t", f"{conductor_session}:main.1", "Enter"],
|
|
236
|
+
check=True,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# フレンドリーファイア防止: Conductorが起動完了するまで待機
|
|
240
|
+
time.sleep(3)
|
|
177
241
|
|
|
178
|
-
#
|
|
242
|
+
# === Session 2: Workers ===
|
|
243
|
+
subprocess.run(
|
|
244
|
+
[
|
|
245
|
+
"tmux", "new-session",
|
|
246
|
+
"-d",
|
|
247
|
+
"-s", workers_session,
|
|
248
|
+
"-c", str(project_root),
|
|
249
|
+
"-n", "main",
|
|
250
|
+
],
|
|
251
|
+
check=True,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Split workers window: left/right (60/40)
|
|
255
|
+
subprocess.run(
|
|
256
|
+
["tmux", "split-window", "-t", f"{workers_session}:main", "-h", "-l", "40%", "-c", str(project_root)],
|
|
257
|
+
check=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Set pane titles
|
|
261
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{workers_session}:main.0", "-T", "dispatch"], check=True)
|
|
262
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{workers_session}:main.1", "-T", "worker-area"], check=True)
|
|
263
|
+
|
|
264
|
+
# Start Claude in Dispatch pane (left)
|
|
179
265
|
dispatch_agent = agents.get("dispatch")
|
|
180
266
|
if dispatch_agent:
|
|
181
|
-
cmd = f"claude --agent {dispatch_agent}"
|
|
267
|
+
cmd = f"claude --agent {dispatch_agent} --model sonnet --dangerously-skip-permissions"
|
|
182
268
|
else:
|
|
183
|
-
cmd = "claude"
|
|
269
|
+
cmd = "claude --model sonnet --dangerously-skip-permissions"
|
|
184
270
|
subprocess.run(
|
|
185
|
-
["tmux", "send-keys", "-t", f"{
|
|
271
|
+
["tmux", "send-keys", "-t", f"{workers_session}:main.0", cmd],
|
|
272
|
+
check=True,
|
|
273
|
+
)
|
|
274
|
+
time.sleep(1)
|
|
275
|
+
subprocess.run(
|
|
276
|
+
["tmux", "send-keys", "-t", f"{workers_session}:main.0", "Enter"],
|
|
186
277
|
check=True,
|
|
187
278
|
)
|
|
188
279
|
|
|
189
|
-
#
|
|
190
|
-
subprocess.run(
|
|
280
|
+
# Show placeholder message in worker area (right)
|
|
281
|
+
subprocess.run(
|
|
282
|
+
["tmux", "send-keys", "-t", f"{workers_session}:main.1", "echo '=== Worker Area ===' && echo 'Workers will be started here.'", "Enter"],
|
|
283
|
+
check=True,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Select dispatch pane in workers session
|
|
287
|
+
subprocess.run(["tmux", "select-pane", "-t", f"{workers_session}:main.0"], check=True)
|
|
191
288
|
|
|
192
289
|
|
|
193
290
|
def _save_pane_ids(session: str, ensemble_dir: Path) -> None:
|
|
194
291
|
"""Save pane IDs to panes.env file."""
|
|
195
|
-
|
|
292
|
+
conductor_session = f"{session}-conductor"
|
|
293
|
+
workers_session = f"{session}-workers"
|
|
294
|
+
|
|
295
|
+
# Get conductor session pane IDs
|
|
296
|
+
result = subprocess.run(
|
|
297
|
+
["tmux", "list-panes", "-t", f"{conductor_session}:main", "-F", "#{pane_index}:#{pane_id}"],
|
|
298
|
+
capture_output=True,
|
|
299
|
+
text=True,
|
|
300
|
+
check=True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
conductor_pane_map = {}
|
|
304
|
+
for line in result.stdout.strip().split("\n"):
|
|
305
|
+
if ":" in line:
|
|
306
|
+
idx, pane_id = line.split(":", 1)
|
|
307
|
+
conductor_pane_map[int(idx)] = pane_id
|
|
308
|
+
|
|
309
|
+
# Get workers session pane IDs
|
|
196
310
|
result = subprocess.run(
|
|
197
|
-
["tmux", "list-panes", "-t", f"{
|
|
311
|
+
["tmux", "list-panes", "-t", f"{workers_session}:main", "-F", "#{pane_index}:#{pane_id}"],
|
|
198
312
|
capture_output=True,
|
|
199
313
|
text=True,
|
|
200
314
|
check=True,
|
|
201
315
|
)
|
|
202
316
|
|
|
203
|
-
|
|
317
|
+
workers_pane_map = {}
|
|
204
318
|
for line in result.stdout.strip().split("\n"):
|
|
205
319
|
if ":" in line:
|
|
206
320
|
idx, pane_id = line.split(":", 1)
|
|
207
|
-
|
|
321
|
+
workers_pane_map[int(idx)] = pane_id
|
|
208
322
|
|
|
209
323
|
# Write panes.env
|
|
210
324
|
panes_env = ensemble_dir / "panes.env"
|
|
211
325
|
with open(panes_env, "w") as f:
|
|
212
326
|
f.write("# Ensemble pane IDs (auto-generated)\n")
|
|
213
|
-
f.write(
|
|
214
|
-
f.write(f"
|
|
215
|
-
f.write(f"
|
|
327
|
+
f.write("# Session names\n")
|
|
328
|
+
f.write(f"CONDUCTOR_SESSION={conductor_session}\n")
|
|
329
|
+
f.write(f"WORKERS_SESSION={workers_session}\n")
|
|
330
|
+
f.write("\n")
|
|
331
|
+
f.write("# Pane IDs (use these with tmux send-keys -t)\n")
|
|
332
|
+
f.write(f"CONDUCTOR_PANE={conductor_pane_map.get(0, '%0')}\n")
|
|
333
|
+
f.write(f"DASHBOARD_PANE={conductor_pane_map.get(1, '%1')}\n")
|
|
334
|
+
f.write(f"DISPATCH_PANE={workers_pane_map.get(0, '%0')}\n")
|
|
335
|
+
f.write(f"WORKER_AREA_PANE={workers_pane_map.get(1, '%1')}\n")
|
|
336
|
+
f.write("\n")
|
|
337
|
+
f.write("# Usage examples:\n")
|
|
338
|
+
f.write("# source .ensemble/panes.env\n")
|
|
339
|
+
f.write("# tmux send-keys -t \"$CONDUCTOR_PANE\" 'message' Enter\n")
|
|
340
|
+
f.write("# tmux send-keys -t \"$DISPATCH_PANE\" 'message' Enter\n")
|
|
216
341
|
|
|
217
342
|
click.echo(f" Saved pane IDs to {panes_env.relative_to(ensemble_dir.parent)}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Issue command for listing and viewing issues."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.command()
|
|
7
|
+
@click.argument("identifier", required=False)
|
|
8
|
+
@click.option(
|
|
9
|
+
"--state",
|
|
10
|
+
type=click.Choice(["open", "closed", "all"]),
|
|
11
|
+
default="open",
|
|
12
|
+
help="Filter issues by state.",
|
|
13
|
+
)
|
|
14
|
+
@click.option(
|
|
15
|
+
"--provider",
|
|
16
|
+
type=click.Choice(["github", "gitlab"]),
|
|
17
|
+
default="github",
|
|
18
|
+
help="Issue provider to use.",
|
|
19
|
+
)
|
|
20
|
+
def issue(identifier: str | None, state: str, provider: str) -> None:
|
|
21
|
+
"""List issues or view a specific issue.
|
|
22
|
+
|
|
23
|
+
Without IDENTIFIER, lists all issues.
|
|
24
|
+
With IDENTIFIER, shows details of that issue (number or URL).
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
|
|
28
|
+
ensemble issue # List open issues
|
|
29
|
+
|
|
30
|
+
ensemble issue 123 # View issue #123
|
|
31
|
+
|
|
32
|
+
ensemble issue --state all # List all issues
|
|
33
|
+
"""
|
|
34
|
+
from ensemble.commands._issue_impl import run_issue
|
|
35
|
+
|
|
36
|
+
run_issue(identifier=identifier, state=state, provider=provider)
|
ensemble/git_utils.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Git utilities for branch and PR management."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_current_branch() -> str:
|
|
8
|
+
"""Get the name of the current git branch.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
Current branch name.
|
|
12
|
+
"""
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
["git", "branch", "--show-current"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
check=True,
|
|
18
|
+
)
|
|
19
|
+
return result.stdout.strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_working_tree_clean() -> bool:
|
|
23
|
+
"""Check if the git working tree is clean.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if there are no uncommitted changes.
|
|
27
|
+
"""
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", "status", "--porcelain"],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
)
|
|
33
|
+
return result.stdout.strip() == ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_main_updated(base_branch: str = "main") -> None:
|
|
37
|
+
"""Checkout the main branch and pull latest changes.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
base_branch: Name of the base branch (default: main).
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
RuntimeError: If checkout or pull fails.
|
|
44
|
+
"""
|
|
45
|
+
# Checkout base branch
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git", "checkout", base_branch],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
raise RuntimeError(f"Failed to checkout {base_branch}: {result.stderr}")
|
|
54
|
+
|
|
55
|
+
# Pull latest
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
["git", "pull", "origin", base_branch],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if result.returncode != 0:
|
|
63
|
+
# Pull might fail if remote doesn't exist, which is OK for local repos
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_issue_branch(issue_number: int, title: str) -> str:
|
|
68
|
+
"""Create a new branch for working on an issue.
|
|
69
|
+
|
|
70
|
+
Format: issue/<number>-<slugified-title>
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
issue_number: Issue number.
|
|
74
|
+
title: Issue title (will be slugified).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Name of the created branch.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
RuntimeError: If branch creation fails.
|
|
81
|
+
"""
|
|
82
|
+
# Slugify title
|
|
83
|
+
slug = title.lower()
|
|
84
|
+
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
|
|
85
|
+
slug = re.sub(r"\s+", "-", slug)
|
|
86
|
+
slug = re.sub(r"-+", "-", slug)
|
|
87
|
+
slug = slug.strip("-")
|
|
88
|
+
|
|
89
|
+
# Truncate if too long
|
|
90
|
+
if len(slug) > 40:
|
|
91
|
+
slug = slug[:40].rsplit("-", 1)[0]
|
|
92
|
+
|
|
93
|
+
branch_name = f"issue/{issue_number}-{slug}"
|
|
94
|
+
|
|
95
|
+
# Try to create new branch
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["git", "checkout", "-b", branch_name],
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if result.returncode != 0:
|
|
103
|
+
# Branch might already exist, try to checkout
|
|
104
|
+
if "already exists" in result.stderr:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
["git", "checkout", branch_name],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
)
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
raise RuntimeError(f"Failed to checkout branch {branch_name}: {result.stderr}")
|
|
112
|
+
else:
|
|
113
|
+
raise RuntimeError(f"Failed to create branch {branch_name}: {result.stderr}")
|
|
114
|
+
|
|
115
|
+
return branch_name
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def create_pull_request(
|
|
119
|
+
title: str,
|
|
120
|
+
body: str,
|
|
121
|
+
issue_number: int | None = None,
|
|
122
|
+
) -> str:
|
|
123
|
+
"""Create a pull request using gh CLI.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
title: PR title.
|
|
127
|
+
body: PR body/description.
|
|
128
|
+
issue_number: Optional issue number to link.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
URL of the created PR.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
RuntimeError: If PR creation fails.
|
|
135
|
+
"""
|
|
136
|
+
# Build body with issue reference if provided
|
|
137
|
+
full_body = body
|
|
138
|
+
if issue_number:
|
|
139
|
+
if f"#{issue_number}" not in body and f"Closes #{issue_number}" not in body:
|
|
140
|
+
full_body = f"{body}\n\nCloses #{issue_number}"
|
|
141
|
+
|
|
142
|
+
cmd = [
|
|
143
|
+
"gh", "pr", "create",
|
|
144
|
+
"--title", title,
|
|
145
|
+
"--body", full_body,
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
cmd,
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if result.returncode != 0:
|
|
155
|
+
raise RuntimeError(f"Failed to create pull request: {result.stderr}")
|
|
156
|
+
|
|
157
|
+
return result.stdout.strip()
|