workstreams-cli 0.5.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.
workstreams/manager.py ADDED
@@ -0,0 +1,573 @@
1
+ """Core Workstreams Manager - init, start, dispatch, sync, PR, merge, monitor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from datetime import datetime, UTC, timedelta
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .config import load_config, save_config
14
+ from .models import WorkstreamConfig, WorkstreamsConfig
15
+ from .event_log import get_event_log, EventLog
16
+ from .notifier import Notifier
17
+ from .subagent_client import subagent_report, subagent_done, subagent_started
18
+ from .multiplexer import get_multiplexer
19
+
20
+
21
+ class WorkstreamsManager:
22
+ """Main manager for workstreams."""
23
+
24
+ def __init__(self, config: WorkstreamsConfig):
25
+ self.config = config
26
+ self.base_path = Path(config.base_path).resolve()
27
+ self.config_file = self.base_path / ".workstreams.yaml"
28
+ self.event_log = get_event_log(config.project)
29
+ self.notifier = Notifier(config.project)
30
+ self._mux = None
31
+
32
+ # -- multiplexer ---------------------------------------------------------
33
+
34
+ def _get_multiplexer(self):
35
+ if self._mux is None:
36
+ try:
37
+ self._mux = get_multiplexer(self.config.multiplexer, self.config)
38
+ except ValueError:
39
+ self._mux = None
40
+ return self._mux
41
+
42
+ # -- init ----------------------------------------------------------------
43
+
44
+ def init_project(
45
+ self,
46
+ force: bool = False,
47
+ num_workstreams: int = 4,
48
+ agent: Optional[str] = None,
49
+ ) -> bool:
50
+ """Initialize workstreams for a project.
51
+
52
+ Creates git worktrees (or branches) + config file. Does NOT
53
+ create a multiplexer session - call start() for that.
54
+ """
55
+ if self.config_file.exists() and not force:
56
+ print(
57
+ f"Configuration already exists at {self.config_file}. "
58
+ "Use --force to overwrite."
59
+ )
60
+ return False
61
+
62
+ if not self.config.workstreams:
63
+ for i in range(1, num_workstreams + 1):
64
+ ws = WorkstreamConfig(
65
+ id=i,
66
+ name=f"ws{i}",
67
+ path=f"worktrees/ws{i}",
68
+ branch=f"ws/{i}",
69
+ command="",
70
+ env={},
71
+ )
72
+ self.config.workstreams.append(ws)
73
+
74
+ if agent:
75
+ self.config.agent = agent
76
+
77
+ for ws in self.config.workstreams:
78
+ self._create_workstream(ws)
79
+
80
+ save_config(self.config, self.base_path)
81
+ print(
82
+ f"Initialized {len(self.config.workstreams)} workstream(s) for "
83
+ f"project '{self.config.project}'"
84
+ )
85
+ print(f" Config: {self.config_file}")
86
+ print(" Next: workstreams start [--cmd 'claude']")
87
+ return True
88
+
89
+ def _create_workstream(self, ws: WorkstreamConfig) -> None:
90
+ ws_path = self.base_path / ws.path
91
+ ws_path.parent.mkdir(parents=True, exist_ok=True)
92
+
93
+ if self.config.mode == "worktree":
94
+ try:
95
+ # Fast path: branch already exists
96
+ result = subprocess.run(
97
+ ["git", "worktree", "add", str(ws_path), ws.branch],
98
+ check=True,
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ _ = result
103
+ created = True
104
+ except (subprocess.CalledProcessError, subprocess.SubprocessError):
105
+ created = False
106
+ if not created:
107
+ # Branch may not exist yet - create it from base branch
108
+ base = self.config.base_branch
109
+ probe = subprocess.run(["git", "rev-parse", "--verify", base], capture_output=True, text=True)
110
+ if probe.returncode != 0:
111
+ base = "HEAD"
112
+ print(f" (base branch '{self.config.base_branch}' not found; using HEAD)")
113
+ r2 = subprocess.run(
114
+ ["git", "branch", ws.branch, base],
115
+ check=False,
116
+ capture_output=True,
117
+ text=True,
118
+ )
119
+ if r2.returncode != 0 and "already" not in (r2.stderr or "").lower():
120
+ print(f" warning: could not create branch {ws.branch}: {(r2.stderr or '').strip()}")
121
+ r3 = subprocess.run(
122
+ ["git", "worktree", "add", str(ws_path), ws.branch],
123
+ check=False,
124
+ capture_output=True,
125
+ text=True,
126
+ )
127
+ if r3.returncode != 0:
128
+ # Clean partial state and fail with a clear message
129
+ if ws_path.exists() and ws_path.is_dir():
130
+ shutil.rmtree(ws_path, ignore_errors=True)
131
+ print(f" error: git worktree add failed for {ws.branch}: {(r3.stderr or '').strip()}")
132
+ print(f" Run 'git worktree prune' and retry, or use --no-worktrees.")
133
+ return
134
+ else:
135
+ ws_path.mkdir(parents=True, exist_ok=True)
136
+
137
+ (ws_path / "logs").mkdir(exist_ok=True)
138
+
139
+ # -- start / attach -------------------------------------------------------
140
+
141
+ def start(self, workstream_id: Optional[int] = None, command: Optional[str] = None) -> None:
142
+ """Start workstreams in the configured multiplexer."""
143
+ workstreams = self._filter_ws(workstream_id)
144
+ mux = self._get_multiplexer()
145
+ if mux is None:
146
+ print(f"Unsupported multiplexer: {self.config.multiplexer}")
147
+ return
148
+ mux.start([ws.to_dict() for ws in workstreams], command)
149
+
150
+ def attach(self, multiplexer: Optional[str] = None, session: Optional[str] = None) -> None:
151
+ mux_name = multiplexer or self.config.multiplexer
152
+ sess = session or f"workstreams-{self.config.project}"
153
+ mux = get_multiplexer(mux_name, self.config)
154
+ mux.attach(sess)
155
+
156
+ # -- status / monitor ------------------------------------------------------
157
+
158
+ def status(self, workstream_id: Optional[int] = None, verbose: bool = False, live: bool = False, as_json: bool = False) -> None:
159
+ if live:
160
+ self.monitor(refresh=2, once=False)
161
+ return
162
+ statuses = []
163
+ for ws in self._filter_ws(workstream_id):
164
+ from .dashboard import LiveDashboard
165
+ dash = LiveDashboard(self, auto_refresh=False)
166
+ statuses.append(dash._get_workstream_status(ws))
167
+
168
+ if as_json:
169
+ print(json.dumps([s.to_dict() for s in statuses], indent=2, default=str))
170
+ return
171
+
172
+ for s in statuses:
173
+ print(f"\n=== Workstream {s.id}: {s.name} ===")
174
+ print(f" Path: {s.path}")
175
+ print(f" Branch: {s.branch}")
176
+ print(f" Git: {s.git_status}")
177
+ print(f" Last: {s.last_commit or s.last_activity or '-'}")
178
+ print(f" Pane: {s.pane or '-'}")
179
+ if s.pid:
180
+ print(f" PID: {s.pid}")
181
+ if s.alerts:
182
+ print(" Alerts:")
183
+ for a in s.alerts:
184
+ print(f" - {a}")
185
+
186
+ def monitor(self, refresh: int = 2, once: bool = False) -> None:
187
+ from .dashboard import LiveDashboard
188
+ dash = LiveDashboard(self, auto_refresh=not once)
189
+ dash.refresh_rate = float(refresh)
190
+ if once:
191
+ print(dash.render_once())
192
+ else:
193
+ dash.run()
194
+
195
+ # -- dispatch (the core workstream concept) --------------------------------
196
+
197
+ def dispatch(
198
+ self,
199
+ workstream_id: int,
200
+ subagent: str,
201
+ issue: int,
202
+ prompt: Optional[str] = None,
203
+ wait: bool = False,
204
+ ) -> int:
205
+ """Dispatch a subagent to a workstream pane.
206
+
207
+ Logs a `started` event, appends to the workstream log, notifies
208
+ other terminals, and (if the multiplexer session is running)
209
+ sends the prompt to the pane via tmux send-keys.
210
+
211
+ Returns exit code: 0 ok, 5 workstream not found, 4 mux unavailable.
212
+ """
213
+ ws = self.config.workstream(workstream_id)
214
+ if not ws:
215
+ print(f"Workstream {workstream_id} not found")
216
+ return 5
217
+
218
+ prompt = prompt or f"Work on issue #{issue} as {subagent}"
219
+
220
+ subagent_started(self.config.project, workstream_id, subagent, issue, prompt)
221
+
222
+ self._append_workstream_log(ws, f"DISPATCH [{subagent}] issue #{issue}: {prompt}")
223
+
224
+ self.notifier.send(
225
+ f"Workstream {ws.name}",
226
+ f"{subagent} started on issue #{issue}",
227
+ )
228
+ print(f"Dispatched {subagent} -> workstream {ws.name} (issue #{issue})")
229
+
230
+ mux = self._get_multiplexer()
231
+ if mux is not None:
232
+ try:
233
+ mux.send_command(workstream_id, prompt)
234
+ except Exception as e:
235
+ print(f" (warning) could not send to pane: {e}", file=sys.stderr)
236
+
237
+ if wait:
238
+ # Block until the subagent reports 'done' or 'completed'
239
+ return self._wait_for_done(workstream_id, subagent)
240
+
241
+ return 0
242
+
243
+ def _wait_for_done(self, workstream_id: int, subagent: str) -> int:
244
+ """Poll the event log until this subagent reports done/completed/failed."""
245
+ print(f"Waiting for {subagent} on workstream {workstream_id} to finish...")
246
+ import time as _time
247
+ deadline = _time.time() + 4 * 3600 # 4h safety
248
+ last_seen = 0
249
+ while _time.time() < deadline:
250
+ events = self.event_log.get_events(workstream_id=workstream_id)
251
+ tail = [e for e in events if e.subagent == subagent]
252
+ if len(tail) > last_seen:
253
+ last_seen = len(tail)
254
+ for e in tail:
255
+ if e.event_type in ("completed", "failed", "done"):
256
+ label = {"completed": "completed", "failed": "FAILED", "done": "done"}[e.event_type]
257
+ print(f" {subagent} {label}: {e.message}")
258
+ return 0 if e.event_type != "failed" else 1
259
+ _time.sleep(3)
260
+ print(" (timeout) no terminal event received", file=sys.stderr)
261
+ return 2
262
+
263
+ # -- work (new: run agent command in pane + wait) ---------------------------
264
+
265
+ def work(
266
+ self,
267
+ workstream_id: int,
268
+ agent_cmd: str,
269
+ task: str,
270
+ subagent: str = "agent",
271
+ issue: int = 0,
272
+ wait: bool = False,
273
+ ) -> int:
274
+ """Run an agent command in a workstream pane.
275
+
276
+ This is the agent-agnostic entry point: the `agent_cmd` is anything
277
+ like `claude`, `codex`, `opencode run`, `qwen`, `cline`, ...
278
+ We build: `<agent_cmd> <task>` (plus optional flags via env) and
279
+ send it to the pane.
280
+ """
281
+ ws = self.config.workstream(workstream_id)
282
+ if not ws:
283
+ print(f"Workstream {workstream_id} not found")
284
+ return 5
285
+
286
+ command = f"{agent_cmd} {task}"
287
+ self._append_workstream_log(ws, f"WORK [{subagent}] {command}")
288
+
289
+ if wait:
290
+ subagent_started(self.config.project, workstream_id, subagent, issue, command)
291
+
292
+ mux = self._get_multiplexer()
293
+ if mux is not None:
294
+ try:
295
+ mux.send_command(workstream_id, command)
296
+ print(f"Sent to workstream {ws.name}: {command}")
297
+ except Exception as e:
298
+ print(f"(warning) pane send failed: {e}", file=sys.stderr)
299
+ else:
300
+ print(f"(no multiplexer session - ran command in shell instead)")
301
+ result = subprocess.run(command, shell=True, cwd=self.base_path / ws.path)
302
+ rc = result.returncode
303
+
304
+ if wait:
305
+ return self._wait_for_done(workstream_id, subagent)
306
+ return 0
307
+
308
+ # -- assign ----------------------------------------------------------------
309
+
310
+ def assign(self, workstream_id: int, issue_numbers: List[int]) -> None:
311
+ ws = self.config.workstream(workstream_id)
312
+ if not ws:
313
+ print(f"Workstream {workstream_id} not found")
314
+ return
315
+ self._append_workstream_log(ws, f"ASSIGN issues {issue_numbers}")
316
+ print(f"Assigned issues {issue_numbers} to workstream {ws.name}")
317
+
318
+ # -- sync -------------------------------------------------------------------
319
+
320
+ def sync(self, workstream_id: Optional[int] = None, rebase: bool = False) -> int:
321
+ rc = 0
322
+ for ws in self._filter_ws(workstream_id):
323
+ ws_path = self.base_path / ws.path
324
+ if not ws_path.exists():
325
+ print(f" {ws.name}: path missing, skipping")
326
+ rc = 3
327
+ continue
328
+ print(f"Syncing {ws.name}...")
329
+ for cmd in (
330
+ ["git", "fetch", "origin"],
331
+ ["git", "rebase", f"origin/{self.config.base_branch}"] if rebase
332
+ else ["git", "merge", f"origin/{self.config.base_branch}"],
333
+ ):
334
+ result = subprocess.run(cmd, cwd=ws_path, capture_output=True, text=True)
335
+ if result.returncode != 0:
336
+ print(f" failed: {result.stderr.strip() or result.stdout.strip()}")
337
+ rc = 3
338
+ break
339
+ else:
340
+ print(f" Synced {ws.name}")
341
+ return rc
342
+
343
+ # -- PR -------------------------------------------------------------------
344
+
345
+ def pr(
346
+ self,
347
+ workstream_id: int,
348
+ title: str,
349
+ body: str = "",
350
+ base: Optional[str] = None,
351
+ draft: bool = False,
352
+ ) -> int:
353
+ ws = self.config.workstream(workstream_id)
354
+ if not ws:
355
+ print(f"Workstream {workstream_id} not found")
356
+ return 5
357
+ base = base or self.config.base_branch
358
+
359
+ ws_path = self.base_path / ws.path
360
+ result = subprocess.run(
361
+ ["git", "push", "origin", ws.branch],
362
+ cwd=ws_path,
363
+ capture_output=True,
364
+ text=True,
365
+ )
366
+ if result.returncode != 0:
367
+ print(f"Push failed: {result.stderr.strip()}")
368
+ return 3
369
+
370
+ cmd = [
371
+ "gh", "pr", "create",
372
+ "--title", title,
373
+ "--body", body,
374
+ "--base", base,
375
+ "--head", ws.branch,
376
+ ]
377
+ if draft:
378
+ cmd.append("--draft")
379
+ result = subprocess.run(cmd, cwd=ws_path, capture_output=True, text=True)
380
+ if result.returncode == 0:
381
+ print(f"Created PR: {result.stdout.strip()}")
382
+ else:
383
+ print(f"Failed to create PR: {result.stderr.strip()}")
384
+ return 0 if result.returncode == 0 else 3
385
+
386
+ # -- merge ------------------------------------------------------------------
387
+
388
+ def merge(
389
+ self,
390
+ workstream_id: int,
391
+ method: str = "squash",
392
+ delete_branch: bool = False,
393
+ auto: bool = False,
394
+ ) -> int:
395
+ ws = self.config.workstream(workstream_id)
396
+ if not ws:
397
+ print(f"Workstream {workstream_id} not found")
398
+ return 5
399
+
400
+ cmd = ["gh", "pr", "merge", ws.branch, "--method", method]
401
+ if delete_branch:
402
+ cmd.append("--delete-branch")
403
+ if auto:
404
+ cmd.append("--auto")
405
+ result = subprocess.run(cmd, capture_output=True, text=True)
406
+ if result.returncode == 0:
407
+ print(f"Merged {ws.name} ({method})")
408
+ if delete_branch:
409
+ ws_path = self.base_path / ws.path
410
+ subprocess.run(["git", "worktree", "remove", str(ws_path)], check=False, capture_output=True)
411
+ if ws_path.exists():
412
+ shutil.rmtree(ws_path, ignore_errors=True)
413
+ else:
414
+ print(f"Failed to merge: {result.stderr.strip()}")
415
+ return 0 if result.returncode == 0 else 3
416
+
417
+ # -- run -------------------------------------------------------------------
418
+
419
+ def run(self, workstream_id: int, command: str) -> bool:
420
+ ws = self.config.workstream(workstream_id)
421
+ if not ws:
422
+ print(f"Workstream {workstream_id} not found")
423
+ return False
424
+ ws_path = self.base_path / ws.path
425
+ result = subprocess.run(command, shell=True, cwd=ws_path, capture_output=True, text=True)
426
+ if result.stdout:
427
+ print(result.stdout, end="")
428
+ if result.stderr:
429
+ print(result.stderr, end="", file=sys.stderr)
430
+ self._append_workstream_log(ws, f"RUN: {command} -> exit {result.returncode}")
431
+ return result.returncode == 0
432
+
433
+ # -- logs ------------------------------------------------------------------
434
+
435
+ def logs(self, workstream_id: int, follow: bool = False, lines: int = 100) -> None:
436
+ ws = self.config.workstream(workstream_id)
437
+ if not ws:
438
+ print(f"Workstream {workstream_id} not found")
439
+ return
440
+ ws_path = self.base_path / ws.path
441
+ log_file = ws_path / "logs" / "worker.log"
442
+ if not log_file.exists():
443
+ print(f"No log file at {log_file}")
444
+ return
445
+ # Use Python for cross-platform (tail -f equivalent on Windows)
446
+ if follow:
447
+ import time as _time
448
+ try:
449
+ with open(log_file, "r", encoding="utf-8", errors="replace") as f:
450
+ # Seek to end, then poll
451
+ f.seek(0, 2)
452
+ while True:
453
+ line = f.readline()
454
+ if line:
455
+ print(line, end="")
456
+ else:
457
+ _time.sleep(0.5)
458
+ except KeyboardInterrupt:
459
+ pass
460
+ else:
461
+ with open(log_file, "r", encoding="utf-8", errors="replace") as f:
462
+ all_lines = f.readlines()
463
+ for ln in all_lines[-lines:]:
464
+ print(ln, end="")
465
+
466
+ def tail_logs(self, workstream_id: Optional[int] = None, follow: bool = True) -> None:
467
+ ws_list = self._filter_ws(workstream_id)
468
+ if len(ws_list) == 1:
469
+ self.logs(ws_list[0].id, follow=follow)
470
+ return
471
+ print("Tailing logs from all workstreams (Ctrl+C to stop):")
472
+ for ws in ws_list:
473
+ print(f"\n--- {ws.name} ({self.base_path / ws.path}) ---")
474
+ self.logs(ws.id, follow=False, lines=20)
475
+
476
+ # -- events / notify -------------------------------------------------------
477
+
478
+ def get_events(
479
+ self,
480
+ workstream_id: Optional[int] = None,
481
+ since_minutes: int = 10,
482
+ event_type: Optional[str] = None,
483
+ subagent: Optional[str] = None,
484
+ limit: Optional[int] = None,
485
+ ) -> List[Dict[str, Any]]:
486
+ since = datetime.now(UTC) - timedelta(minutes=since_minutes)
487
+ events = self.event_log.get_events(
488
+ workstream_id=workstream_id,
489
+ since=since,
490
+ event_type=event_type,
491
+ subagent=subagent,
492
+ limit=limit,
493
+ )
494
+ return [e.to_dict() for e in events]
495
+
496
+ def notify(self, title: str, message: str, urgency: str = "normal") -> None:
497
+ self.notifier.send(title, message, urgency)
498
+
499
+ # -- workstream management ---------------------------------------------------
500
+
501
+ def add_workstream(self, name: str, branch: str, path: Optional[str] = None, command: str = "") -> WorkstreamConfig:
502
+ existing = len(self.config.workstreams)
503
+ new_id = max((ws.id for ws in self.config.workstreams), default=0) + 1
504
+ ws = WorkstreamConfig(
505
+ id=new_id,
506
+ name=name,
507
+ path=path or f"worktrees/{name}",
508
+ branch=branch,
509
+ command=command,
510
+ env={},
511
+ )
512
+ self.config.workstreams.append(ws)
513
+ self._create_workstream(ws)
514
+ save_config(self.config, self.base_path)
515
+ print(f"Added workstream {new_id} ({name}) -> {ws.path} on {branch}")
516
+ _ = existing
517
+ return ws
518
+
519
+ def remove_workstream(self, workstream_id: int, force: bool = False) -> int:
520
+ ws = self.config.workstream(workstream_id)
521
+ if not ws:
522
+ print(f"Workstream {workstream_id} not found")
523
+ return 5
524
+ ws_path = self.base_path / ws.path
525
+ if ws_path.exists() and ws_path.is_dir():
526
+ if not force:
527
+ print(f"Refusing to remove {ws_path} without --force (workstream may have uncommitted work)")
528
+ return 1
529
+ # Remove worktree (best effort)
530
+ subprocess.run(["git", "worktree", "remove", str(ws_path)], check=False, capture_output=True)
531
+ shutil.rmtree(ws_path, ignore_errors=True)
532
+ self.config.workstreams = [x for x in self.config.workstreams if x.id != workstream_id]
533
+ save_config(self.config, self.base_path)
534
+ print(f"Removed workstream {workstream_id} ({ws.name})")
535
+ return 0
536
+
537
+ def cleanup(self, workstream_id: Optional[int] = None, force: bool = False, all_done: bool = False) -> None:
538
+ """Remove worktrees for completed (or all) workstreams."""
539
+ targets = self._filter_ws(workstream_id) if not all_done else self._filter_ws(workstream_id)
540
+ if all_done:
541
+ targets = self._filter_ws(None)
542
+ for ws in targets:
543
+ ws_path = self.base_path / ws.path
544
+ if not ws_path.exists():
545
+ continue
546
+ if not force and not workstream_id and not all_done:
547
+ print(f"Skipping {ws.name} (use --force or --all)")
548
+ continue
549
+ subprocess.run(["git", "worktree", "remove", str(ws_path)], check=False, capture_output=True)
550
+ shutil.rmtree(ws_path, ignore_errors=True)
551
+ print(f"Cleaned up {ws.name}")
552
+
553
+ # -- helpers -----------------------------------------------------------------
554
+
555
+ def _filter_ws(self, workstream_id: Optional[int]) -> List[WorkstreamConfig]:
556
+ if workstream_id is None:
557
+ return list(self.config.workstreams)
558
+ return [ws for ws in self.config.workstreams if ws.id == workstream_id]
559
+
560
+ def _append_workstream_log(self, ws: WorkstreamConfig, line: str) -> None:
561
+ ws_path = self.base_path / ws.path
562
+ log_file = ws_path / "logs" / "worker.log"
563
+ log_file.parent.mkdir(parents=True, exist_ok=True)
564
+ ts = datetime.now(UTC).isoformat()
565
+ try:
566
+ with open(log_file, "a", encoding="utf-8") as f:
567
+ f.write(f"[{ts}] {line}\n")
568
+ except OSError:
569
+ pass
570
+
571
+ @staticmethod
572
+ def now() -> str:
573
+ return datetime.now(UTC).isoformat()
workstreams/models.py ADDED
@@ -0,0 +1,121 @@
1
+ """Data models for workstreams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field, asdict
6
+ from datetime import datetime, UTC
7
+ from typing import Any, Dict, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class WorkstreamConfig:
12
+ """Configuration for a single workstream."""
13
+
14
+ id: int
15
+ name: str
16
+ path: str
17
+ branch: str
18
+ command: str
19
+ env: Dict[str, str] = field(default_factory=dict)
20
+ multiplexer: str = "tmux"
21
+ layout: str = "even-horizontal"
22
+ shared_deps: List[str] = field(default_factory=list)
23
+ mode: str = "worktree" # worktree|branch
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return asdict(self)
27
+
28
+ @classmethod
29
+ def from_dict(cls, data: Dict[str, Any]) -> "WorkstreamConfig":
30
+ known = {
31
+ "id", "name", "path", "branch", "command", "env",
32
+ "multiplexer", "layout", "shared_deps", "mode",
33
+ }
34
+ return cls(**{k: v for k, v in data.items() if k in known})
35
+
36
+
37
+ @dataclass
38
+ class WorkstreamsConfig:
39
+ """Project-level configuration."""
40
+
41
+ project: str
42
+ multiplexer: str = "tmux"
43
+ layout: str = "even-horizontal"
44
+ base_branch: str = "main"
45
+ mode: str = "worktree" # worktree|branch
46
+ workstreams: List[WorkstreamConfig] = field(default_factory=list)
47
+ shared_deps: List[str] = field(default_factory=list)
48
+ base_path: str = "."
49
+ agent: str = "auto" # auto|claude|codex|opencode|qwen|generic
50
+
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ d = asdict(self)
53
+ return d
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: Dict[str, Any]) -> "WorkstreamsConfig":
57
+ known = {
58
+ "project", "multiplexer", "layout", "base_branch", "mode",
59
+ "shared_deps", "base_path", "agent",
60
+ }
61
+ workstreams = [
62
+ WorkstreamConfig.from_dict(ws) for ws in data.get("workstreams", [])
63
+ ]
64
+ base = {k: v for k, v in data.items() if k in known and k != "workstreams"}
65
+ base["workstreams"] = workstreams
66
+ return cls(**base)
67
+
68
+ def workstream(self, ws_id: int) -> Optional[WorkstreamConfig]:
69
+ for ws in self.workstreams:
70
+ if ws.id == ws_id:
71
+ return ws
72
+ return None
73
+
74
+
75
+ @dataclass
76
+ class WorkstreamStatus:
77
+ """Real-time status of a workstream."""
78
+
79
+ id: int
80
+ name: str
81
+ branch: str
82
+ path: str
83
+ git_status: str = "unknown"
84
+ last_commit: str = ""
85
+ last_activity: str = ""
86
+ pid: Optional[int] = None
87
+ command: str = ""
88
+ pane: Optional[str] = None
89
+ log_tail: List[str] = field(default_factory=list)
90
+ alerts: List[str] = field(default_factory=list)
91
+
92
+ def to_dict(self) -> Dict[str, Any]:
93
+ return asdict(self)
94
+
95
+
96
+ @dataclass
97
+ class SubagentEvent:
98
+ """Event from a subagent (or any external process)."""
99
+
100
+ workstream_id: int
101
+ subagent: str
102
+ issue: int
103
+ event_type: str # started, progress, completed, failed, error, done
104
+ message: str
105
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
106
+ data: Dict[str, Any] = field(default_factory=dict)
107
+
108
+ def to_dict(self) -> Dict[str, Any]:
109
+ return asdict(self)
110
+
111
+ @classmethod
112
+ def from_dict(cls, data: Dict[str, Any]) -> "SubagentEvent":
113
+ return cls(
114
+ workstream_id=data.get("workstream_id", 0),
115
+ subagent=data.get("subagent", "unknown"),
116
+ issue=data.get("issue", 0),
117
+ event_type=data.get("event_type", "progress"),
118
+ message=data.get("message", ""),
119
+ timestamp=data.get("timestamp", datetime.now(UTC).isoformat()),
120
+ data=data.get("data", {}) or {},
121
+ )