@oswaldzsh/devhive 0.1.2 → 0.1.4
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.
- package/control_plane/cli.py +209 -457
- package/control_plane/tui.py +14 -1
- package/package.json +1 -1
package/control_plane/cli.py
CHANGED
|
@@ -3,29 +3,19 @@
|
|
|
3
3
|
dh — DevHive CLI
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
|
-
dh Start interactive REPL
|
|
7
|
-
dh do <description> Submit a task
|
|
8
|
-
dh do -f <file> Submit a task from JSON/YAML spec file
|
|
9
|
-
dh run Start orchestrator daemon
|
|
6
|
+
dh Start interactive REPL
|
|
7
|
+
dh do <description> Submit a task
|
|
10
8
|
dh status Show system status
|
|
11
|
-
dh log <task-id> Show task
|
|
12
|
-
dh review
|
|
13
|
-
dh resolve <id> Resolve
|
|
14
|
-
dh !<shell command> Escape to shell (from interactive mode)
|
|
15
|
-
|
|
16
|
-
Examples:
|
|
17
|
-
dh do "fix the login timeout bug in auth/session.py"
|
|
18
|
-
dh do "add rate limiting middleware to all API endpoints" -p HIGH
|
|
19
|
-
dh do -f task_spec.yaml
|
|
20
|
-
dh status
|
|
21
|
-
dh log task-20260624-143000-abc123
|
|
22
|
-
dh review
|
|
9
|
+
dh log <task-id> Show task timeline
|
|
10
|
+
dh review Review escalations
|
|
11
|
+
dh resolve <id> Resolve escalation
|
|
23
12
|
"""
|
|
24
13
|
|
|
25
14
|
import argparse
|
|
26
15
|
import asyncio
|
|
27
16
|
import json
|
|
28
17
|
import os
|
|
18
|
+
import re
|
|
29
19
|
import shlex
|
|
30
20
|
import subprocess
|
|
31
21
|
import sys
|
|
@@ -33,555 +23,313 @@ from datetime import datetime, timezone
|
|
|
33
23
|
from pathlib import Path
|
|
34
24
|
from typing import Optional
|
|
35
25
|
|
|
36
|
-
# Rich imports
|
|
37
26
|
from rich.console import Console
|
|
38
|
-
from rich.live import Live
|
|
39
27
|
from rich.panel import Panel
|
|
40
28
|
from rich.text import Text
|
|
41
29
|
from rich.table import Table
|
|
42
30
|
from rich import box
|
|
43
31
|
|
|
44
|
-
# DevHive imports
|
|
45
32
|
from protocol.schemas import TaskSpec, Priority
|
|
46
33
|
from orchestrator.engine import Orchestrator
|
|
47
|
-
from
|
|
48
|
-
DevHiveTUI, status_display, task_timeline, escalation_review,
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
VERSION = "0.1.0"
|
|
53
|
-
BANNER = r"""
|
|
54
|
-
╔══════════════════════════════════════════╗
|
|
55
|
-
║ ____ __ __ _ ║
|
|
56
|
-
║ / __ \ ___ __ __/ / / / (_) _____ ║
|
|
57
|
-
║ / / / // _ \\ \ / / /_/ / / / | / / _ \ ║
|
|
58
|
-
║/ /_/ /| __/\ V / __ / / /| |/ / __/ ║
|
|
59
|
-
║\_____/ \___| \_/ /_/ /_/ /_/ |___/\___| ║
|
|
60
|
-
║ ║
|
|
61
|
-
║ Multi-Agent Software Development ║
|
|
62
|
-
╚══════════════════════════════════════════╝
|
|
63
|
-
"""
|
|
34
|
+
from storage.checkpoint import CheckpointStore
|
|
64
35
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
:tasks, :t List all tasks
|
|
73
|
-
:watch <id> Watch a specific task in detail
|
|
74
|
-
:quit, :q Exit DevHive
|
|
75
|
-
|
|
76
|
-
Shortcuts:
|
|
77
|
-
!<cmd> Escape to shell (e.g., !git status)
|
|
78
|
-
Ctrl+C Interrupt current operation
|
|
79
|
-
|
|
80
|
-
From terminal:
|
|
81
|
-
dh do "description" Quick task submission
|
|
82
|
-
dh status Quick status check
|
|
83
|
-
dh review Quick escalation review
|
|
84
|
-
"""
|
|
36
|
+
VERSION = "0.1.4"
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
BANNER = """
|
|
40
|
+
[bold cyan]DevHive[/] — Multi-Agent Software Development
|
|
41
|
+
Version {version} │ :help for commands │ Ctrl+C to quit
|
|
42
|
+
""".format(version=VERSION)
|
|
85
43
|
|
|
86
44
|
|
|
87
45
|
# ── Config ──────────────────────────────────────────────────
|
|
88
46
|
|
|
89
|
-
def load_config(
|
|
90
|
-
"""Load YAML config, resolving env vars."""
|
|
47
|
+
def load_config(path: str = None) -> dict:
|
|
91
48
|
import yaml
|
|
92
|
-
paths = [
|
|
93
|
-
|
|
94
|
-
os.path.join(os.path.dirname(__file__), "..", "config.yaml"),
|
|
95
|
-
os.path.join(os.path.dirname(__file__), "..", "config.local.yaml"),
|
|
96
|
-
os.path.expanduser("~/.config/devhive/config.yaml"),
|
|
97
|
-
]
|
|
49
|
+
paths = [path, "config.yaml", "config.local.yaml",
|
|
50
|
+
os.path.expanduser("~/.config/devhive/config.yaml")]
|
|
98
51
|
for p in paths:
|
|
99
|
-
if p and os.path.
|
|
52
|
+
if p and os.path.isfile(p):
|
|
100
53
|
with open(p) as f:
|
|
101
54
|
raw = f.read()
|
|
102
|
-
|
|
103
|
-
import re
|
|
104
|
-
def _sub(m):
|
|
105
|
-
return os.environ.get(m.group(1), m.group(0))
|
|
106
|
-
raw = re.sub(r'\$\{(\w+)\}', _sub, raw)
|
|
55
|
+
raw = re.sub(r'\$\{(\w+)\}', lambda m: os.environ.get(m.group(1), m.group(0)), raw)
|
|
107
56
|
return yaml.safe_load(raw)
|
|
108
57
|
return {}
|
|
109
58
|
|
|
110
59
|
|
|
111
|
-
# ──
|
|
60
|
+
# ── Task parsing ────────────────────────────────────────────
|
|
112
61
|
|
|
113
|
-
async def
|
|
114
|
-
config: dict = None) -> TaskSpec:
|
|
115
|
-
"""Use the model to parse a natural language description into a TaskSpec."""
|
|
62
|
+
async def parse_task(description: str, priority: str = "MEDIUM", config: dict = None) -> TaskSpec:
|
|
116
63
|
from tools.api_client import APIClient, extract_text_from_response
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
client = APIClient(
|
|
121
|
-
base_url=api_cfg.get("base_url"),
|
|
122
|
-
auth_token=api_cfg.get("auth_token"),
|
|
123
|
-
default_model=api_cfg.get("default_model"),
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
system = """You are a task parser. Convert the user's natural language description
|
|
127
|
-
into a structured JSON task specification. Output ONLY the JSON, no explanation.
|
|
128
|
-
|
|
129
|
-
Format:
|
|
130
|
-
{
|
|
131
|
-
"title": "concise one-line title",
|
|
132
|
-
"description": "expanded description with context and requirements",
|
|
133
|
-
"acceptance_criteria": ["criterion 1", "criterion 2"],
|
|
134
|
-
"scope_constraints": ["constraint 1"],
|
|
135
|
-
"sensitive_modules": [],
|
|
136
|
-
"priority": "MEDIUM"
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
Rules:
|
|
140
|
-
- title: one line, action-oriented (start with a verb)
|
|
141
|
-
- description: include what, why, and any context the user provided
|
|
142
|
-
- acceptance_criteria: specific, testable conditions of done
|
|
143
|
-
- scope_constraints: what NOT to touch
|
|
144
|
-
- sensitive_modules: detect if auth/payment/data/permission is involved
|
|
145
|
-
- priority: use the provided priority if specified
|
|
146
|
-
"""
|
|
64
|
+
cfg = (config or {}).get("api", {})
|
|
65
|
+
client = APIClient(base_url=cfg.get("base_url"), auth_token=cfg.get("auth_token"),
|
|
66
|
+
default_model=cfg.get("default_model"))
|
|
147
67
|
|
|
148
68
|
response = await client.create_message(
|
|
149
|
-
system=
|
|
69
|
+
system="Convert user request to JSON task spec. Output ONLY valid JSON, no explanation.\n"
|
|
70
|
+
'Format: {"title":"...","description":"...","acceptance_criteria":[...],'
|
|
71
|
+
'"scope_constraints":[...],"sensitive_modules":[...],"priority":"MEDIUM"}',
|
|
150
72
|
messages=[{"role": "user", "content": f"Priority: {priority}\n\n{description}"}],
|
|
151
|
-
max_tokens=1024,
|
|
152
|
-
temperature=0.1,
|
|
153
|
-
)
|
|
73
|
+
max_tokens=1024, temperature=0.1)
|
|
154
74
|
|
|
155
75
|
text = extract_text_from_response(response)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
import re
|
|
159
|
-
match = re.search(r'\{.*\}', text, re.DOTALL)
|
|
160
|
-
if match:
|
|
161
|
-
data = json.loads(match.group(0))
|
|
162
|
-
else:
|
|
163
|
-
# Fallback: build simple spec
|
|
164
|
-
data = {
|
|
165
|
-
"title": description[:80],
|
|
166
|
-
"description": description,
|
|
167
|
-
"acceptance_criteria": [],
|
|
168
|
-
"scope_constraints": [],
|
|
169
|
-
"sensitive_modules": [],
|
|
170
|
-
"priority": priority,
|
|
171
|
-
}
|
|
172
|
-
|
|
76
|
+
m = re.search(r'\{.*\}', text, re.DOTALL)
|
|
77
|
+
data = json.loads(m.group(0)) if m else {"title": description[:80], "description": description}
|
|
173
78
|
return TaskSpec(
|
|
174
79
|
title=data.get("title", description[:80]),
|
|
175
80
|
description=data.get("description", description),
|
|
176
81
|
acceptance_criteria=data.get("acceptance_criteria", []),
|
|
177
82
|
scope_constraints=data.get("scope_constraints", []),
|
|
178
83
|
sensitive_modules=data.get("sensitive_modules", []),
|
|
179
|
-
priority=Priority(data.get("priority", priority))
|
|
180
|
-
|
|
84
|
+
priority=Priority(data.get("priority", priority)))
|
|
85
|
+
|
|
181
86
|
|
|
87
|
+
# ── REPL ────────────────────────────────────────────────────
|
|
182
88
|
|
|
183
|
-
|
|
89
|
+
HELP = """
|
|
90
|
+
[bold]:do, :d <desc>[/] Submit task from natural language
|
|
91
|
+
[bold]:status, :st[/] Show pending tasks & escalations
|
|
92
|
+
[bold]:tasks, :t[/] List all tasks
|
|
93
|
+
[bold]:log, :l <id>[/] Show task execution timeline
|
|
94
|
+
[bold]:review, :rv[/] Review and resolve escalations
|
|
95
|
+
[bold]:resolve, :rs <id>[/] Resolve an escalation by ID
|
|
96
|
+
[bold]:watch <id>[/] Zoom into a specific task
|
|
97
|
+
[bold]:help, :h[/] Show this help
|
|
98
|
+
[bold]:quit, :q[/] Exit
|
|
99
|
+
|
|
100
|
+
[bold]!<cmd>[/] Escape to shell
|
|
101
|
+
[dim]Bare text → auto-submit as task[/]
|
|
102
|
+
"""
|
|
184
103
|
|
|
185
|
-
class DevHiveREPL:
|
|
186
|
-
"""Interactive REPL with live dashboard."""
|
|
187
104
|
|
|
105
|
+
class REPL:
|
|
188
106
|
def __init__(self, config: dict):
|
|
189
107
|
self.config = config
|
|
190
|
-
self.
|
|
191
|
-
self.
|
|
192
|
-
self.
|
|
193
|
-
self._running = False
|
|
194
|
-
self._command_task: Optional[asyncio.Task] = None
|
|
108
|
+
self.orch: Optional[Orchestrator] = None
|
|
109
|
+
self.store: Optional[CheckpointStore] = None
|
|
110
|
+
self.selected_task: Optional[str] = None
|
|
195
111
|
|
|
196
112
|
async def start(self):
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
self.
|
|
200
|
-
|
|
201
|
-
self.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# Start orchestrator
|
|
205
|
-
self.orchestrator = Orchestrator(self.config)
|
|
206
|
-
self.tui._orchestrator = self.orchestrator
|
|
207
|
-
|
|
208
|
-
# Start orchestrator in background
|
|
209
|
-
orchestrator_task = asyncio.create_task(
|
|
210
|
-
self.orchestrator.start(agent_counts={"execute": 1})
|
|
211
|
-
)
|
|
212
|
-
await asyncio.sleep(0.5) # Let it initialize
|
|
213
|
-
|
|
214
|
-
self.tui.log_activity("system", "Orchestrator started")
|
|
215
|
-
self._running = True
|
|
216
|
-
|
|
217
|
-
# Start TUI refresh and command input concurrently
|
|
218
|
-
try:
|
|
219
|
-
await self._repl_loop()
|
|
220
|
-
except (KeyboardInterrupt, EOFError):
|
|
221
|
-
pass
|
|
222
|
-
finally:
|
|
223
|
-
self._running = False
|
|
224
|
-
await self.orchestrator.stop()
|
|
225
|
-
orchestrator_task.cancel()
|
|
226
|
-
try:
|
|
227
|
-
await orchestrator_task
|
|
228
|
-
except asyncio.CancelledError:
|
|
229
|
-
pass
|
|
230
|
-
|
|
231
|
-
self.console.print("\n DevHive stopped.", style="dim")
|
|
232
|
-
|
|
233
|
-
async def _repl_loop(self):
|
|
234
|
-
"""Main REPL: live dashboard + command input."""
|
|
235
|
-
from rich.live import Live
|
|
236
|
-
from prompt_toolkit import PromptSession
|
|
237
|
-
from prompt_toolkit.history import FileHistory
|
|
238
|
-
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
239
|
-
|
|
240
|
-
# Try prompt_toolkit for fancy input; fall back to input()
|
|
113
|
+
console.print(BANNER)
|
|
114
|
+
|
|
115
|
+
self.orch = Orchestrator(self.config)
|
|
116
|
+
self.store = self.orch.checkpoint
|
|
117
|
+
asyncio.create_task(self.orch.start(agent_counts={"execute": 1}))
|
|
118
|
+
await asyncio.sleep(0.5)
|
|
119
|
+
|
|
241
120
|
try:
|
|
242
|
-
|
|
243
|
-
history=FileHistory(os.path.expanduser("~/.devhive_history")),
|
|
244
|
-
auto_suggest=AutoSuggestFromHistory(),
|
|
245
|
-
)
|
|
246
|
-
use_prompt_toolkit = True
|
|
247
|
-
except ImportError:
|
|
248
|
-
session = None
|
|
249
|
-
use_prompt_toolkit = False
|
|
250
|
-
|
|
251
|
-
with Live(self.tui.render(), console=self.console,
|
|
252
|
-
refresh_per_second=4, screen=True) as live:
|
|
253
|
-
|
|
254
|
-
# Background task to push TUI updates
|
|
255
|
-
async def _refresh_tui():
|
|
256
|
-
while self._running:
|
|
257
|
-
live.update(self.tui.render())
|
|
258
|
-
await asyncio.sleep(0.25)
|
|
259
|
-
|
|
260
|
-
refresh_task = asyncio.create_task(_refresh_tui())
|
|
261
|
-
|
|
262
|
-
# Command input loop
|
|
263
|
-
while self._running:
|
|
121
|
+
while True:
|
|
264
122
|
try:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
None, lambda: session.prompt("dh> "))
|
|
269
|
-
else:
|
|
270
|
-
cmd = await asyncio.get_event_loop().run_in_executor(
|
|
271
|
-
None, lambda: input("dh> "))
|
|
272
|
-
live.start()
|
|
273
|
-
|
|
274
|
-
if cmd:
|
|
275
|
-
await self._handle_command(cmd.strip())
|
|
276
|
-
except (KeyboardInterrupt, EOFError):
|
|
123
|
+
cmd = await asyncio.get_event_loop().run_in_executor(
|
|
124
|
+
None, lambda: input("dh> "))
|
|
125
|
+
except (EOFError, KeyboardInterrupt):
|
|
277
126
|
break
|
|
278
|
-
|
|
279
|
-
self.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
try:
|
|
283
|
-
await refresh_task
|
|
284
|
-
except asyncio.CancelledError:
|
|
285
|
-
pass
|
|
127
|
+
if cmd:
|
|
128
|
+
await self._dispatch(cmd.strip())
|
|
129
|
+
finally:
|
|
130
|
+
await self.orch.stop()
|
|
286
131
|
|
|
287
|
-
async def
|
|
288
|
-
"""Dispatch a REPL command."""
|
|
132
|
+
async def _dispatch(self, cmd: str):
|
|
289
133
|
# Shell escape
|
|
290
134
|
if cmd.startswith("!"):
|
|
291
|
-
|
|
292
|
-
if shell_cmd:
|
|
293
|
-
self.tui.log_activity("shell", shell_cmd)
|
|
294
|
-
try:
|
|
295
|
-
result = subprocess.run(
|
|
296
|
-
shell_cmd, shell=True, capture_output=True,
|
|
297
|
-
text=True, timeout=30
|
|
298
|
-
)
|
|
299
|
-
output = result.stdout or result.stderr
|
|
300
|
-
for line in output.strip().split("\n")[:20]:
|
|
301
|
-
self.tui.log_activity("shell", line)
|
|
302
|
-
except Exception as e:
|
|
303
|
-
self.tui.log_activity("shell", f"Error: {e}")
|
|
135
|
+
self._shell(cmd[1:].strip())
|
|
304
136
|
return
|
|
305
137
|
|
|
306
|
-
# Colon
|
|
138
|
+
# Colon command or bare text
|
|
307
139
|
if cmd.startswith(":"):
|
|
308
140
|
parts = shlex.split(cmd)
|
|
309
|
-
|
|
310
|
-
args = parts[1:]
|
|
141
|
+
name = parts[0][1:].lower()
|
|
142
|
+
args = parts[1:]
|
|
311
143
|
else:
|
|
312
|
-
|
|
313
|
-
cmd_name = "do"
|
|
144
|
+
name = "do"
|
|
314
145
|
args = [cmd]
|
|
315
146
|
|
|
316
|
-
match
|
|
317
|
-
case "help" | "h":
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
case "
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
case "
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
case "review" | "rv":
|
|
338
|
-
await self._cmd_review()
|
|
339
|
-
|
|
340
|
-
case "resolve" | "rs":
|
|
341
|
-
if args:
|
|
342
|
-
await self._cmd_resolve(args[0])
|
|
343
|
-
|
|
344
|
-
case "watch" | "w":
|
|
345
|
-
if args:
|
|
346
|
-
self.tui._selected_task = args[0]
|
|
347
|
-
self.tui.log_activity("system", f"Watching task {args[0]}")
|
|
348
|
-
|
|
349
|
-
case _:
|
|
350
|
-
# Unknown command → treat as task description
|
|
351
|
-
desc = cmd[1:] if cmd.startswith(":") else cmd
|
|
352
|
-
await self._cmd_submit(desc)
|
|
147
|
+
match name:
|
|
148
|
+
case "help" | "h": console.print(HELP)
|
|
149
|
+
case "quit" | "q" | "exit": raise EOFError
|
|
150
|
+
case "do" | "d": await self._do(" ".join(args))
|
|
151
|
+
case "status" | "st": await self._status()
|
|
152
|
+
case "tasks" | "t": await self._tasks()
|
|
153
|
+
case "log" | "l": await self._log(args[0] if args else None)
|
|
154
|
+
case "review" | "rv": await self._review()
|
|
155
|
+
case "resolve" | "rs": await self._resolve(args[0] if args else "")
|
|
156
|
+
case "watch" | "w": self.selected_task = args[0] if args else self.selected_task
|
|
157
|
+
case _: await self._do(cmd if not cmd.startswith(":") else cmd[1:])
|
|
158
|
+
|
|
159
|
+
def _shell(self, cmd: str):
|
|
160
|
+
if not cmd: return
|
|
161
|
+
try:
|
|
162
|
+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
163
|
+
if r.stdout: console.print(r.stdout.rstrip(), style="dim")
|
|
164
|
+
if r.stderr: console.print(r.stderr.rstrip(), style="red")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
console.print(f"[red]Error: {e}[/]")
|
|
353
167
|
|
|
354
|
-
async def
|
|
355
|
-
"""Submit a new task."""
|
|
168
|
+
async def _do(self, description: str):
|
|
356
169
|
if not description:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
self.tui.log_activity("system", f"Parsing: {description[:60]}...")
|
|
361
|
-
|
|
170
|
+
console.print("[red]Usage: :do <description>[/]"); return
|
|
171
|
+
console.print(f"[dim]Parsing...[/]")
|
|
362
172
|
try:
|
|
363
|
-
spec = await
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
self.console.print(f" ✓ Task created: {task_id}", style="green")
|
|
173
|
+
spec = await parse_task(description, config=self.config)
|
|
174
|
+
tid = await self.orch.submit_task(spec)
|
|
175
|
+
console.print(Panel(
|
|
176
|
+
f"Title: {spec.title}\nPriority: {spec.priority.value}\nID: {tid}",
|
|
177
|
+
title="[green]✓ Task Created[/]", border_style="green"))
|
|
369
178
|
except Exception as e:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
async def
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
179
|
+
console.print(f"[red]Failed: {e}[/]")
|
|
180
|
+
|
|
181
|
+
async def _status(self):
|
|
182
|
+
tasks = self.store.get_pending_tasks()
|
|
183
|
+
escs = self.store.get_open_escalations()
|
|
184
|
+
t = Table(title="Status", box=box.ROUNDED)
|
|
185
|
+
t.add_column("Metric"); t.add_column("Count")
|
|
186
|
+
t.add_row("Pending tasks", str(len(tasks)))
|
|
187
|
+
t.add_row("Open escalations", str(len(escs)))
|
|
188
|
+
console.print(t)
|
|
189
|
+
for e in escs[:5]:
|
|
190
|
+
console.print(f" [red]⚠[/] {e.get('id','?')[:30]} task: {e.get('task_id','?')[-20:]}")
|
|
191
|
+
console.print()
|
|
192
|
+
|
|
193
|
+
async def _tasks(self):
|
|
194
|
+
tasks = self.store.get_pending_tasks()
|
|
195
|
+
if not tasks:
|
|
196
|
+
console.print("[dim]No pending tasks.[/]"); return
|
|
383
197
|
for t in tasks:
|
|
384
198
|
stage = t.get("current_stage", "?")
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
"""Resolve an escalation by ID."""
|
|
414
|
-
self.orchestrator.checkpoint.resolve_escalation(esc_id, "human-operator")
|
|
415
|
-
self.tui.log_activity("system", f"Resolved {esc_id[:20]}...")
|
|
416
|
-
self.console.print(f" ✓ Escalation resolved", style="green")
|
|
199
|
+
console.print(f" • {t['id'][-20:]} [{stage}] {t.get('status','pending')}")
|
|
200
|
+
|
|
201
|
+
async def _log(self, tid: str = None):
|
|
202
|
+
tid = tid or self.selected_task
|
|
203
|
+
if not tid:
|
|
204
|
+
console.print("[red]Usage: :log <task-id>[/]"); return
|
|
205
|
+
history = self.store.get_task_history(tid)
|
|
206
|
+
if not history:
|
|
207
|
+
console.print("[dim]No history found.[/]"); return
|
|
208
|
+
console.print(f"[bold]── {tid} ──[/]")
|
|
209
|
+
for h in history:
|
|
210
|
+
icon = "✓" if h["outcome"] in ("COMPLETED","PASS") else "✗" if h["outcome"]=="FAIL" else "●"
|
|
211
|
+
console.print(f" {icon} [{h['stage']}] {h['outcome']} [dim]{h['created_at']}[/]")
|
|
212
|
+
|
|
213
|
+
async def _review(self):
|
|
214
|
+
escs = self.store.get_open_escalations()
|
|
215
|
+
if not escs:
|
|
216
|
+
console.print("[green]No open escalations![/]"); return
|
|
217
|
+
for i, e in enumerate(escs):
|
|
218
|
+
console.print(f" [{i+1}] {e['id'][:30]}\n task: {e.get('task_id','?')[-30:]}\n {e.get('created_at','?')}")
|
|
219
|
+
try:
|
|
220
|
+
ch = input(" Select [1-N] or Enter to skip: ").strip()
|
|
221
|
+
if ch.isdigit():
|
|
222
|
+
idx = int(ch) - 1
|
|
223
|
+
if 0 <= idx < len(escs):
|
|
224
|
+
self.store.resolve_escalation(escs[idx]["id"], "human")
|
|
225
|
+
console.print(f"[green]✓ Resolved[/]")
|
|
226
|
+
except (EOFError, KeyboardInterrupt): pass
|
|
417
227
|
|
|
228
|
+
async def _resolve(self, eid: str):
|
|
229
|
+
if not eid:
|
|
230
|
+
console.print("[red]Usage: :resolve <escalation-id>[/]"); return
|
|
231
|
+
self.store.resolve_escalation(eid, "human")
|
|
232
|
+
console.print(f"[green]✓ Resolved {eid[:30]}[/]")
|
|
418
233
|
|
|
419
|
-
# ── One-shot Commands ───────────────────────────────────────
|
|
420
234
|
|
|
421
|
-
|
|
422
|
-
"""Quick task submission from the command line."""
|
|
423
|
-
console = Console()
|
|
424
|
-
console.print(BANNER, style="bold cyan")
|
|
235
|
+
# ── One-shot commands ───────────────────────────────────────
|
|
425
236
|
|
|
237
|
+
async def cmd_do(args, config):
|
|
238
|
+
console.print(BANNER)
|
|
426
239
|
if args.file:
|
|
427
240
|
import yaml
|
|
428
241
|
with open(args.file) as f:
|
|
429
242
|
data = json.load(f) if args.file.endswith(".json") else yaml.safe_load(f)
|
|
430
243
|
spec = TaskSpec(**data)
|
|
431
|
-
console.print(f" Loaded spec from {args.file}", style="dim")
|
|
432
244
|
elif args.description:
|
|
433
|
-
|
|
434
|
-
spec = await parse_task_from_nl(
|
|
435
|
-
args.description,
|
|
436
|
-
priority=args.priority or "MEDIUM",
|
|
437
|
-
config=config,
|
|
438
|
-
)
|
|
439
|
-
console.print(f" Title: {spec.title}", style="bold")
|
|
440
|
-
console.print(f" Priority: {spec.priority.value}", style="dim")
|
|
245
|
+
spec = await parse_task(args.description, args.priority or "MEDIUM", config)
|
|
441
246
|
else:
|
|
442
|
-
console.print("
|
|
443
|
-
return
|
|
444
|
-
|
|
247
|
+
console.print("[red]Usage: dh do <description> or dh do -f <file>[/]"); return
|
|
445
248
|
orch = Orchestrator(config)
|
|
446
249
|
await orch.start(agent_counts={"execute": 1})
|
|
447
250
|
await asyncio.sleep(0.3)
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
console.print(f"
|
|
451
|
-
|
|
452
|
-
# Wait briefly for execution to start
|
|
453
|
-
await asyncio.sleep(2)
|
|
454
|
-
|
|
455
|
-
# Show task status
|
|
456
|
-
history = orch.checkpoint.get_task_history(task_id)
|
|
457
|
-
if history:
|
|
458
|
-
task_timeline(history)
|
|
459
|
-
|
|
251
|
+
tid = await orch.submit_task(spec)
|
|
252
|
+
console.print(f"[green]✓ Submitted: {tid}[/]")
|
|
253
|
+
console.print(f" Title: {spec.title}")
|
|
460
254
|
await orch.stop()
|
|
461
255
|
|
|
462
|
-
|
|
463
|
-
async def cmd_status(args, config: dict):
|
|
464
|
-
"""Quick status check."""
|
|
256
|
+
async def cmd_status(args, config):
|
|
465
257
|
orch = Orchestrator(config)
|
|
466
258
|
await orch.start(agent_counts={})
|
|
467
|
-
await asyncio.sleep(0.
|
|
468
|
-
|
|
469
|
-
tasks =
|
|
470
|
-
escs =
|
|
471
|
-
|
|
472
|
-
|
|
259
|
+
await asyncio.sleep(0.2)
|
|
260
|
+
store = orch.checkpoint
|
|
261
|
+
tasks = store.get_pending_tasks()
|
|
262
|
+
escs = store.get_open_escalations()
|
|
263
|
+
console.print(f"Pending tasks: {len(tasks)} | Open escalations: {len(escs)}")
|
|
264
|
+
for e in escs:
|
|
265
|
+
console.print(f" [red]⚠[/] {e['id'][:30]}")
|
|
473
266
|
await orch.stop()
|
|
474
267
|
|
|
475
|
-
|
|
476
|
-
async def cmd_log(args, config: dict):
|
|
477
|
-
"""Show task timeline."""
|
|
268
|
+
async def cmd_log(args, config):
|
|
478
269
|
orch = Orchestrator(config)
|
|
479
270
|
await orch.start(agent_counts={})
|
|
480
|
-
|
|
481
271
|
history = orch.checkpoint.get_task_history(args.task_id)
|
|
482
|
-
|
|
483
|
-
|
|
272
|
+
for h in history:
|
|
273
|
+
console.print(f" [{h['stage']}] {h['outcome']} [dim]{h['created_at']}[/]")
|
|
484
274
|
await orch.stop()
|
|
485
275
|
|
|
486
|
-
|
|
487
|
-
async def cmd_review(args, config: dict):
|
|
488
|
-
"""Interactive escalation review."""
|
|
276
|
+
async def cmd_review(args, config):
|
|
489
277
|
orch = Orchestrator(config)
|
|
490
278
|
await orch.start(agent_counts={})
|
|
491
|
-
|
|
492
279
|
escs = orch.checkpoint.get_open_escalations()
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
280
|
+
if not escs:
|
|
281
|
+
console.print("[green]No escalations[/]")
|
|
282
|
+
else:
|
|
283
|
+
for i, e in enumerate(escs):
|
|
284
|
+
console.print(f" [{i+1}] {e['id'][:30]}")
|
|
285
|
+
try:
|
|
286
|
+
ch = input("Select: ").strip()
|
|
287
|
+
if ch.isdigit():
|
|
288
|
+
e = escs[int(ch)-1]
|
|
289
|
+
orch.checkpoint.resolve_escalation(e["id"], "human")
|
|
290
|
+
console.print("[green]✓ Resolved[/]")
|
|
291
|
+
except (EOFError, KeyboardInterrupt): pass
|
|
499
292
|
await orch.stop()
|
|
500
293
|
|
|
501
|
-
|
|
502
|
-
async def cmd_resolve(args, config: dict):
|
|
503
|
-
"""Resolve an escalation."""
|
|
294
|
+
async def cmd_resolve(args, config):
|
|
504
295
|
orch = Orchestrator(config)
|
|
505
296
|
await orch.start(agent_counts={})
|
|
506
|
-
orch.checkpoint.resolve_escalation(args.escalation_id, "human
|
|
507
|
-
|
|
297
|
+
orch.checkpoint.resolve_escalation(args.escalation_id, "human")
|
|
298
|
+
console.print(f"[green]✓ Resolved[/]")
|
|
508
299
|
await orch.stop()
|
|
509
300
|
|
|
510
|
-
|
|
511
|
-
# ── Daemon ──────────────────────────────────────────────────
|
|
512
|
-
|
|
513
|
-
async def cmd_run(args, config: dict):
|
|
514
|
-
"""Start orchestrator daemon (for long-running use)."""
|
|
515
|
-
console = Console()
|
|
516
|
-
console.print(BANNER, style="bold cyan")
|
|
517
|
-
console.print(" Starting DevHive daemon...", style="dim")
|
|
518
|
-
|
|
301
|
+
async def cmd_run(args, config):
|
|
519
302
|
orch = Orchestrator(config)
|
|
520
|
-
await orch.start(agent_counts=args.agents)
|
|
521
|
-
console.print("
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
try:
|
|
525
|
-
await asyncio.Event().wait()
|
|
526
|
-
except KeyboardInterrupt:
|
|
527
|
-
console.print("\n Shutting down...", style="dim")
|
|
528
|
-
await orch.stop()
|
|
303
|
+
await orch.start(agent_counts={"execute": args.agents or 1})
|
|
304
|
+
console.print("[green]Daemon running. Ctrl+C to stop.[/]")
|
|
305
|
+
try: await asyncio.Event().wait()
|
|
306
|
+
except KeyboardInterrupt: await orch.stop()
|
|
529
307
|
|
|
530
308
|
|
|
531
|
-
# ── Entry
|
|
309
|
+
# ── Entry point ─────────────────────────────────────────────
|
|
532
310
|
|
|
533
311
|
def main():
|
|
534
|
-
parser = argparse.ArgumentParser(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
)
|
|
538
|
-
parser.add_argument("-c", "--config", help="Config file path")
|
|
539
|
-
parser.add_argument("-v", "--version", action="version",
|
|
540
|
-
version=f"DevHive {VERSION}")
|
|
312
|
+
parser = argparse.ArgumentParser(prog="dh", description="DevHive CLI")
|
|
313
|
+
parser.add_argument("-c", "--config")
|
|
314
|
+
parser.add_argument("-v", "--version", action="version", version=f"DevHive {VERSION}")
|
|
541
315
|
sub = parser.add_subparsers(dest="command")
|
|
542
316
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
# dh run
|
|
553
|
-
p_run = sub.add_parser("run", help="Start orchestrator daemon")
|
|
554
|
-
p_run.add_argument("-a", "--agents", type=int, default=1,
|
|
555
|
-
help="Number of execute agents")
|
|
556
|
-
|
|
557
|
-
# dh status
|
|
558
|
-
sub.add_parser("status", help="Show system status")
|
|
559
|
-
|
|
560
|
-
# dh log
|
|
561
|
-
p_log = sub.add_parser("log", help="Show task execution timeline")
|
|
562
|
-
p_log.add_argument("task_id", help="Task ID")
|
|
563
|
-
|
|
564
|
-
# dh review
|
|
565
|
-
sub.add_parser("review", help="Interactive escalation review")
|
|
566
|
-
|
|
567
|
-
# dh resolve
|
|
568
|
-
p_resolve = sub.add_parser("resolve", help="Resolve an escalation")
|
|
569
|
-
p_resolve.add_argument("escalation_id", help="Escalation ID")
|
|
317
|
+
p_do = sub.add_parser("do"); p_do.add_argument("description", nargs="?")
|
|
318
|
+
p_do.add_argument("-f", "--file"); p_do.add_argument("-p", "--priority",
|
|
319
|
+
choices=["CRITICAL","HIGH","MEDIUM","LOW"])
|
|
320
|
+
p_run = sub.add_parser("run"); p_run.add_argument("-a", "--agents", type=int)
|
|
321
|
+
sub.add_parser("status")
|
|
322
|
+
p_log = sub.add_parser("log"); p_log.add_argument("task_id")
|
|
323
|
+
sub.add_parser("review")
|
|
324
|
+
p_res = sub.add_parser("resolve"); p_res.add_argument("escalation_id")
|
|
570
325
|
|
|
571
326
|
args = parser.parse_args()
|
|
572
327
|
config = load_config(args.config)
|
|
573
328
|
|
|
574
|
-
if args.command
|
|
575
|
-
|
|
576
|
-
repl = DevHiveREPL(config)
|
|
577
|
-
try:
|
|
578
|
-
asyncio.run(repl.start())
|
|
579
|
-
except KeyboardInterrupt:
|
|
580
|
-
pass
|
|
329
|
+
if args.command == "run":
|
|
330
|
+
asyncio.run(cmd_run(args, config))
|
|
581
331
|
elif args.command == "do":
|
|
582
332
|
asyncio.run(cmd_do(args, config))
|
|
583
|
-
elif args.command == "run":
|
|
584
|
-
asyncio.run(cmd_run(args, config))
|
|
585
333
|
elif args.command == "status":
|
|
586
334
|
asyncio.run(cmd_status(args, config))
|
|
587
335
|
elif args.command == "log":
|
|
@@ -590,6 +338,10 @@ def main():
|
|
|
590
338
|
asyncio.run(cmd_review(args, config))
|
|
591
339
|
elif args.command == "resolve":
|
|
592
340
|
asyncio.run(cmd_resolve(args, config))
|
|
341
|
+
else:
|
|
342
|
+
repl = REPL(config)
|
|
343
|
+
try: asyncio.run(repl.start())
|
|
344
|
+
except (KeyboardInterrupt, EOFError): pass
|
|
593
345
|
|
|
594
346
|
|
|
595
347
|
if __name__ == "__main__":
|
package/control_plane/tui.py
CHANGED
|
@@ -63,6 +63,7 @@ class DevHiveTUI:
|
|
|
63
63
|
self._tasks: dict = {}
|
|
64
64
|
self._selected_task: Optional[str] = None
|
|
65
65
|
self._running = False
|
|
66
|
+
self._output_lines: list[str] = [] # command output shown in detail pane
|
|
66
67
|
|
|
67
68
|
def log_activity(self, source: str, message: str):
|
|
68
69
|
"""Add an entry to the activity feed."""
|
|
@@ -78,6 +79,10 @@ class DevHiveTUI:
|
|
|
78
79
|
def set_tasks(self, tasks: dict):
|
|
79
80
|
self._tasks = tasks
|
|
80
81
|
|
|
82
|
+
def show(self, *lines: str):
|
|
83
|
+
"""Set output lines to display in the detail panel. Auto-clears after next update."""
|
|
84
|
+
self._output_lines = list(lines)
|
|
85
|
+
|
|
81
86
|
@property
|
|
82
87
|
def uptime(self) -> str:
|
|
83
88
|
seconds = int(time.monotonic() - self._start_time)
|
|
@@ -158,7 +163,15 @@ class DevHiveTUI:
|
|
|
158
163
|
title_align="left")
|
|
159
164
|
|
|
160
165
|
def _detail_panel(self) -> Panel:
|
|
161
|
-
"""Detail view for selected or
|
|
166
|
+
"""Detail view for selected task, output, or command results."""
|
|
167
|
+
|
|
168
|
+
# Output mode: show command output when available
|
|
169
|
+
if self._output_lines:
|
|
170
|
+
text = Text("\n").join(Text(line) for line in self._output_lines)
|
|
171
|
+
panel = Panel(text, title="Output", border_style="bright_cyan",
|
|
172
|
+
title_align="left")
|
|
173
|
+
return panel
|
|
174
|
+
|
|
162
175
|
if self._selected_task and self._selected_task in self._tasks:
|
|
163
176
|
tasks_to_show = {self._selected_task: self._tasks[self._selected_task]}
|
|
164
177
|
elif self._tasks:
|
package/package.json
CHANGED