steerdev 0.4.27__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.
- steerdev-0.4.27.dist-info/METADATA +224 -0
- steerdev-0.4.27.dist-info/RECORD +57 -0
- steerdev-0.4.27.dist-info/WHEEL +4 -0
- steerdev-0.4.27.dist-info/entry_points.txt +2 -0
- steerdev_agent/__init__.py +10 -0
- steerdev_agent/api/__init__.py +32 -0
- steerdev_agent/api/activity.py +278 -0
- steerdev_agent/api/agents.py +145 -0
- steerdev_agent/api/client.py +158 -0
- steerdev_agent/api/commands.py +399 -0
- steerdev_agent/api/configs.py +238 -0
- steerdev_agent/api/context.py +306 -0
- steerdev_agent/api/events.py +294 -0
- steerdev_agent/api/hooks.py +178 -0
- steerdev_agent/api/implementation_plan.py +408 -0
- steerdev_agent/api/messages.py +231 -0
- steerdev_agent/api/prd.py +281 -0
- steerdev_agent/api/runs.py +526 -0
- steerdev_agent/api/sessions.py +403 -0
- steerdev_agent/api/specs.py +321 -0
- steerdev_agent/api/tasks.py +659 -0
- steerdev_agent/api/workflow_runs.py +351 -0
- steerdev_agent/api/workflows.py +191 -0
- steerdev_agent/cli.py +2254 -0
- steerdev_agent/config/__init__.py +19 -0
- steerdev_agent/config/models.py +236 -0
- steerdev_agent/config/platform.py +272 -0
- steerdev_agent/config/settings.py +62 -0
- steerdev_agent/daemon.py +675 -0
- steerdev_agent/executor/__init__.py +64 -0
- steerdev_agent/executor/base.py +121 -0
- steerdev_agent/executor/claude.py +328 -0
- steerdev_agent/executor/stream.py +163 -0
- steerdev_agent/git/__init__.py +1 -0
- steerdev_agent/handlers/__init__.py +5 -0
- steerdev_agent/handlers/prd.py +533 -0
- steerdev_agent/integration.py +334 -0
- steerdev_agent/prompt/__init__.py +10 -0
- steerdev_agent/prompt/builder.py +263 -0
- steerdev_agent/prompt/templates.py +422 -0
- steerdev_agent/py.typed +0 -0
- steerdev_agent/runner.py +829 -0
- steerdev_agent/setup/__init__.py +5 -0
- steerdev_agent/setup/claude_setup.py +560 -0
- steerdev_agent/setup/templates/claude_md_section.md +140 -0
- steerdev_agent/setup/templates/settings.json +69 -0
- steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
- steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
- steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
- steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
- steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
- steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
- steerdev_agent/setup/templates/steerdev.yaml +51 -0
- steerdev_agent/version.py +149 -0
- steerdev_agent/workflow/__init__.py +10 -0
- steerdev_agent/workflow/executor.py +494 -0
- steerdev_agent/workflow/memory.py +185 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""Task management API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from steerdev_agent.api.client import SteerDevClient, get_project_id
|
|
11
|
+
from steerdev_agent.api.implementation_plan import (
|
|
12
|
+
display_implementation_plan,
|
|
13
|
+
extract_task_description,
|
|
14
|
+
parse_implementation_plan,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
# Linear-native status_type values
|
|
20
|
+
VALID_STATUSES = [
|
|
21
|
+
"backlog",
|
|
22
|
+
"unstarted",
|
|
23
|
+
"started",
|
|
24
|
+
"completed",
|
|
25
|
+
"canceled",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# ===== Status Transition Validation =====
|
|
29
|
+
# Uses Linear-native status_type values directly:
|
|
30
|
+
# backlog -> unstarted (ready) -> started (in progress) -> completed
|
|
31
|
+
|
|
32
|
+
VALID_STATUS_TRANSITIONS: dict[str, list[str]] = {
|
|
33
|
+
"backlog": ["unstarted", "canceled"],
|
|
34
|
+
"unstarted": ["started", "backlog", "canceled"],
|
|
35
|
+
"started": ["completed", "canceled"],
|
|
36
|
+
"completed": [], # Terminal state
|
|
37
|
+
"canceled": [], # Terminal state
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_status_transition(current: str, new: str) -> tuple[bool, str | None]:
|
|
42
|
+
"""Validate if a status transition is allowed.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
current: Current task status.
|
|
46
|
+
new: Desired new status.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Tuple of (is_valid, error_message).
|
|
50
|
+
error_message is None if transition is valid.
|
|
51
|
+
"""
|
|
52
|
+
if current == new:
|
|
53
|
+
return True, None
|
|
54
|
+
|
|
55
|
+
allowed = VALID_STATUS_TRANSITIONS.get(current, [])
|
|
56
|
+
if new not in allowed:
|
|
57
|
+
allowed_str = ", ".join(allowed) if allowed else "none (terminal state)"
|
|
58
|
+
return False, (
|
|
59
|
+
f"Cannot transition from '{current}' to '{new}'. Allowed transitions: {allowed_str}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return True, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TasksClient(SteerDevClient):
|
|
66
|
+
"""Client for task management operations."""
|
|
67
|
+
|
|
68
|
+
def get_next_task(self, project_id: str | None = None) -> dict[str, Any] | None:
|
|
69
|
+
"""Fetch the next task to work on (single-task mode, no waves).
|
|
70
|
+
|
|
71
|
+
Returns the highest priority task in order:
|
|
72
|
+
1. In Progress tasks (to continue work)
|
|
73
|
+
2. Todo tasks (to start new work)
|
|
74
|
+
3. Backlog tasks (if nothing else available)
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
project_id: Filter by project ID. Falls back to STEERDEV_PROJECT_ID env var.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Task data dict or None if no tasks available.
|
|
81
|
+
"""
|
|
82
|
+
effective_project_id = project_id or get_project_id()
|
|
83
|
+
|
|
84
|
+
params: dict[str, str] = {"waves": "false"}
|
|
85
|
+
if effective_project_id:
|
|
86
|
+
params["project_id"] = effective_project_id
|
|
87
|
+
|
|
88
|
+
console.print(f"Fetching next task from {self.api_base}/tasks/next")
|
|
89
|
+
response = self.get("/tasks/next", params=params if params else None)
|
|
90
|
+
|
|
91
|
+
if response.status_code == 404:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
if response.status_code != 200:
|
|
95
|
+
console.print(f"[red]API Error: {response.status_code} - {response.text}[/red]")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return response.json()
|
|
99
|
+
|
|
100
|
+
def get_next_wave(self, project_id: str | None = None) -> dict[str, Any] | None:
|
|
101
|
+
"""Fetch the next wave-aware task with full wave context.
|
|
102
|
+
|
|
103
|
+
Returns the full wave context including:
|
|
104
|
+
- Current wave info (number, description, total waves)
|
|
105
|
+
- All tasks in the current wave with statuses
|
|
106
|
+
- Completed waves summary
|
|
107
|
+
- The specific next task to work on
|
|
108
|
+
|
|
109
|
+
If no wave tasks exist, returns None (caller should fall back to get_next_task).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
project_id: Filter by project ID. Falls back to STEERDEV_PROJECT_ID env var.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Wave response dict or None if no wave tasks available.
|
|
116
|
+
"""
|
|
117
|
+
effective_project_id = project_id or get_project_id()
|
|
118
|
+
|
|
119
|
+
params: dict[str, str] = {"waves": "true"}
|
|
120
|
+
if effective_project_id:
|
|
121
|
+
params["project_id"] = effective_project_id
|
|
122
|
+
|
|
123
|
+
console.print(f"Fetching next wave task from {self.api_base}/tasks/next")
|
|
124
|
+
response = self.get("/tasks/next", params=params)
|
|
125
|
+
|
|
126
|
+
if response.status_code == 404:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if response.status_code != 200:
|
|
130
|
+
console.print(f"[red]API Error: {response.status_code} - {response.text}[/red]")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
data = response.json()
|
|
134
|
+
# Check if response is a wave response (has "wave" key) vs single task
|
|
135
|
+
if "wave" in data and "context" in data:
|
|
136
|
+
return data
|
|
137
|
+
# API returned a single task (no wave data) — return None so caller falls back
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def list_tasks(
|
|
141
|
+
self,
|
|
142
|
+
status: str | None = None,
|
|
143
|
+
project_id: str | None = None,
|
|
144
|
+
limit: int = 20,
|
|
145
|
+
) -> list[dict[str, Any]]:
|
|
146
|
+
"""List tasks with optional filters.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
status: Filter by status.
|
|
150
|
+
project_id: Filter by project ID. Falls back to STEERDEV_PROJECT_ID env var.
|
|
151
|
+
limit: Maximum number of tasks to return.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of task dicts.
|
|
155
|
+
"""
|
|
156
|
+
effective_project_id = project_id or get_project_id()
|
|
157
|
+
|
|
158
|
+
params: dict[str, str | int] = {"limit": limit}
|
|
159
|
+
if status:
|
|
160
|
+
params["status"] = status
|
|
161
|
+
if effective_project_id:
|
|
162
|
+
params["project_id"] = effective_project_id
|
|
163
|
+
|
|
164
|
+
console.print(f"Fetching tasks from {self.api_base}/tasks")
|
|
165
|
+
response = self.get("/tasks", params=params)
|
|
166
|
+
|
|
167
|
+
if response.status_code != 200:
|
|
168
|
+
console.print(f"[red]API Error: {response.status_code} - {response.text}[/red]")
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
data = response.json()
|
|
172
|
+
return data.get("tasks", []) if isinstance(data, dict) else data
|
|
173
|
+
|
|
174
|
+
def get_task(self, task_id: str) -> dict[str, Any] | None:
|
|
175
|
+
"""Get a specific task by ID.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
task_id: Task ID (UUID) to fetch.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Task data dict or None if not found.
|
|
182
|
+
"""
|
|
183
|
+
console.print(f"Fetching task {task_id} from {self.api_base}/tasks/{task_id}")
|
|
184
|
+
response = self.get(f"/tasks/{task_id}")
|
|
185
|
+
|
|
186
|
+
if response.status_code == 404:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
if response.status_code != 200:
|
|
190
|
+
console.print(f"[red]API Error: {response.status_code} - {response.text}[/red]")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
return response.json()
|
|
194
|
+
|
|
195
|
+
def update_task(
|
|
196
|
+
self,
|
|
197
|
+
task_id: str,
|
|
198
|
+
status: str | None = None,
|
|
199
|
+
title: str | None = None,
|
|
200
|
+
prompt: str | None = None,
|
|
201
|
+
priority: int | None = None,
|
|
202
|
+
result_summary: str | None = None,
|
|
203
|
+
error_message: str | None = None,
|
|
204
|
+
comment: str | None = None,
|
|
205
|
+
) -> bool:
|
|
206
|
+
"""Update a task's fields.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
task_id: Task ID to update.
|
|
210
|
+
status: New status.
|
|
211
|
+
title: New title.
|
|
212
|
+
prompt: New prompt.
|
|
213
|
+
priority: New priority (1=urgent, 2=high, 3=medium, 4=low, 0=none).
|
|
214
|
+
result_summary: Result summary for completed tasks.
|
|
215
|
+
error_message: Error message for failed tasks.
|
|
216
|
+
comment: Comment to add.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if update succeeded.
|
|
220
|
+
"""
|
|
221
|
+
# If status is being updated, validate the transition first
|
|
222
|
+
if status:
|
|
223
|
+
current_task = self.get_task(task_id)
|
|
224
|
+
if current_task:
|
|
225
|
+
current_status = current_task.get("status", "")
|
|
226
|
+
is_valid, error = validate_status_transition(current_status, status)
|
|
227
|
+
if not is_valid:
|
|
228
|
+
logger.error(f"Invalid status transition: {error}")
|
|
229
|
+
console.print(f"[red]Error: {error}[/red]")
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
# Build payload
|
|
233
|
+
payload: dict[str, str | int] = {}
|
|
234
|
+
if status:
|
|
235
|
+
payload["status"] = status
|
|
236
|
+
if title:
|
|
237
|
+
payload["title"] = title
|
|
238
|
+
if prompt:
|
|
239
|
+
payload["prompt"] = prompt
|
|
240
|
+
if priority is not None:
|
|
241
|
+
payload["priority"] = priority
|
|
242
|
+
if result_summary:
|
|
243
|
+
payload["result_summary"] = result_summary
|
|
244
|
+
if error_message:
|
|
245
|
+
payload["error_message"] = error_message
|
|
246
|
+
if comment:
|
|
247
|
+
payload["comment"] = comment
|
|
248
|
+
|
|
249
|
+
if not payload:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
console.print(f"Updating task {task_id} at {self.api_base}/tasks/{task_id}")
|
|
253
|
+
response = self.patch(f"/tasks/{task_id}", json=payload)
|
|
254
|
+
|
|
255
|
+
if response.status_code == 404:
|
|
256
|
+
console.print(f"[red]Error: Task '{task_id}' not found[/red]")
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
if response.status_code == 400:
|
|
260
|
+
# Status transition validation error from API
|
|
261
|
+
console.print(f"[red]Validation Error: {response.text}[/red]")
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
if response.status_code not in (200, 204):
|
|
265
|
+
console.print(f"[red]API Error: {response.status_code} - {response.text}[/red]")
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def create_task(
|
|
271
|
+
self,
|
|
272
|
+
title: str,
|
|
273
|
+
prompt: str,
|
|
274
|
+
project_id: str | None = None,
|
|
275
|
+
priority: int = 3,
|
|
276
|
+
working_directory: str | None = None,
|
|
277
|
+
spec_id: str | None = None,
|
|
278
|
+
cycle_id: str | None = None,
|
|
279
|
+
) -> dict[str, Any] | None:
|
|
280
|
+
"""Create a new task.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
title: Task title.
|
|
284
|
+
prompt: Task prompt/description.
|
|
285
|
+
project_id: Project ID. Falls back to STEERDEV_PROJECT_ID env var.
|
|
286
|
+
priority: Task priority (1=urgent, 2=high, 3=medium, 4=low, 0=none).
|
|
287
|
+
working_directory: Working directory for the task.
|
|
288
|
+
spec_id: Specification ID to link this task to.
|
|
289
|
+
cycle_id: Cycle ID to link this task to.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Created task data dict or None on failure.
|
|
293
|
+
"""
|
|
294
|
+
effective_project_id = project_id or get_project_id()
|
|
295
|
+
if not effective_project_id:
|
|
296
|
+
console.print(
|
|
297
|
+
"[red]Error: project_id is required. "
|
|
298
|
+
"Use --project-id or set STEERDEV_PROJECT_ID environment variable[/red]"
|
|
299
|
+
)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
payload: dict[str, str | int | None] = {
|
|
303
|
+
"project_id": effective_project_id,
|
|
304
|
+
"title": title,
|
|
305
|
+
"prompt": prompt,
|
|
306
|
+
"priority": priority,
|
|
307
|
+
"source": "api",
|
|
308
|
+
}
|
|
309
|
+
if working_directory:
|
|
310
|
+
payload["working_directory"] = working_directory
|
|
311
|
+
if spec_id:
|
|
312
|
+
payload["spec_id"] = spec_id
|
|
313
|
+
if cycle_id:
|
|
314
|
+
payload["cycle_id"] = cycle_id
|
|
315
|
+
|
|
316
|
+
console.print(f"Creating task at {self.api_base}/tasks")
|
|
317
|
+
response = self.post("/tasks", json=payload)
|
|
318
|
+
|
|
319
|
+
if response.status_code not in (200, 201):
|
|
320
|
+
console.print(f"[red]API Error: {response.status_code} - {response.text}[/red]")
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
return response.json()
|
|
324
|
+
|
|
325
|
+
# ===== Workflow Helper Methods =====
|
|
326
|
+
# These methods make the workflow explicit and easy to use.
|
|
327
|
+
|
|
328
|
+
def get_backlog_tasks(self, project_id: str | None = None) -> list[dict[str, Any]]:
|
|
329
|
+
"""Get tasks in the backlog.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
project_id: Filter by project ID. Falls back to STEERDEV_PROJECT_ID env var.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of backlog tasks.
|
|
336
|
+
"""
|
|
337
|
+
return self.list_tasks(status="backlog", project_id=project_id)
|
|
338
|
+
|
|
339
|
+
def get_unstarted_tasks(self, project_id: str | None = None) -> list[dict[str, Any]]:
|
|
340
|
+
"""Get tasks that are ready for implementation (unstarted).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
project_id: Filter by project ID. Falls back to STEERDEV_PROJECT_ID env var.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
List of unstarted tasks ready to be implemented.
|
|
347
|
+
"""
|
|
348
|
+
return self.list_tasks(status="unstarted", project_id=project_id)
|
|
349
|
+
|
|
350
|
+
def mark_ready(self, task_id: str) -> bool:
|
|
351
|
+
"""Mark a task as ready for implementation.
|
|
352
|
+
|
|
353
|
+
Transitions task from 'backlog' to 'unstarted'.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
task_id: Task ID to update.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
True if transition succeeded.
|
|
360
|
+
"""
|
|
361
|
+
return self.update_task(task_id, status="unstarted")
|
|
362
|
+
|
|
363
|
+
def start_task(self, task_id: str) -> bool:
|
|
364
|
+
"""Start working on a task.
|
|
365
|
+
|
|
366
|
+
Transitions task from 'unstarted' to 'started'.
|
|
367
|
+
This will fail if the task is not in 'unstarted' status.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
task_id: Task ID to update.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
True if transition succeeded.
|
|
374
|
+
"""
|
|
375
|
+
task = self.get_task(task_id)
|
|
376
|
+
if not task:
|
|
377
|
+
console.print(f"[red]Error: Task '{task_id}' not found[/red]")
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
if task.get("status") != "unstarted":
|
|
381
|
+
logger.error(
|
|
382
|
+
f"Cannot start task: task {task_id} is not ready "
|
|
383
|
+
f"(current status: {task.get('status')})"
|
|
384
|
+
)
|
|
385
|
+
console.print(
|
|
386
|
+
f"[red]Error: Cannot start task. "
|
|
387
|
+
f"Task must be in 'unstarted' status, but is '{task.get('status')}'.[/red]"
|
|
388
|
+
)
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
return self.update_task(task_id, status="started")
|
|
392
|
+
|
|
393
|
+
def complete_task(self, task_id: str, result_summary: str | None = None) -> bool:
|
|
394
|
+
"""Mark a task as completed.
|
|
395
|
+
|
|
396
|
+
Transitions task from 'started' to 'completed'.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
task_id: Task ID to update.
|
|
400
|
+
result_summary: Optional summary of what was accomplished.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
True if transition succeeded.
|
|
404
|
+
"""
|
|
405
|
+
return self.update_task(task_id, status="completed", result_summary=result_summary)
|
|
406
|
+
|
|
407
|
+
def cancel_task(self, task_id: str, error_message: str) -> bool:
|
|
408
|
+
"""Mark a task as canceled.
|
|
409
|
+
|
|
410
|
+
Transitions task from 'started' to 'canceled'.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
task_id: Task ID to update.
|
|
414
|
+
error_message: Description of what went wrong.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
True if transition succeeded.
|
|
418
|
+
"""
|
|
419
|
+
return self.update_task(task_id, status="canceled", error_message=error_message)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def get_priority_display(priority: int | None) -> str:
|
|
423
|
+
"""Get human-readable priority display.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
priority: Priority number (1=urgent, 2=high, 3=medium, 4=low, 0=none).
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Formatted priority string like "Urgent (1)" or "N/A".
|
|
430
|
+
"""
|
|
431
|
+
priority_names = {
|
|
432
|
+
1: "Urgent",
|
|
433
|
+
2: "High",
|
|
434
|
+
3: "Medium",
|
|
435
|
+
4: "Low",
|
|
436
|
+
0: "None",
|
|
437
|
+
}
|
|
438
|
+
if priority is None:
|
|
439
|
+
return "N/A"
|
|
440
|
+
name = priority_names.get(priority, "Unknown")
|
|
441
|
+
return f"{name} ({priority})"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def display_task(task: dict[str, Any], title: str = "Task") -> None:
|
|
445
|
+
"""Display a task in a formatted panel.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
task: Task data dict.
|
|
449
|
+
title: Panel title.
|
|
450
|
+
"""
|
|
451
|
+
priority_display = get_priority_display(task.get("priority"))
|
|
452
|
+
linear_id = task.get("linear_identifier", "N/A")
|
|
453
|
+
task_info = (
|
|
454
|
+
f"[bold cyan]Linear ID:[/bold cyan] {linear_id}\n"
|
|
455
|
+
f"[bold cyan]ID:[/bold cyan] {task.get('id', 'N/A')}\n"
|
|
456
|
+
f"[bold cyan]Title:[/bold cyan] {task.get('title', 'N/A')}\n"
|
|
457
|
+
f"[bold cyan]Status:[/bold cyan] {task.get('status', 'N/A')}\n"
|
|
458
|
+
f"[bold cyan]Priority:[/bold cyan] {priority_display}\n"
|
|
459
|
+
f"[bold cyan]Project ID:[/bold cyan] {task.get('project_id', 'N/A')}"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Parse implementation plan from prompt
|
|
463
|
+
prompt = task.get("prompt", "")
|
|
464
|
+
impl_plan = parse_implementation_plan(prompt)
|
|
465
|
+
task_description = extract_task_description(prompt) if impl_plan else prompt
|
|
466
|
+
|
|
467
|
+
if task_description:
|
|
468
|
+
task_info += f"\n\n[bold cyan]Description:[/bold cyan]\n{task_description}"
|
|
469
|
+
|
|
470
|
+
if task.get("working_directory"):
|
|
471
|
+
task_info += f"\n\n[bold cyan]Working Directory:[/bold cyan] {task['working_directory']}"
|
|
472
|
+
|
|
473
|
+
if task.get("spec_id"):
|
|
474
|
+
task_info += f"\n[bold cyan]Spec ID:[/bold cyan] {task.get('spec_id')}"
|
|
475
|
+
|
|
476
|
+
if task.get("cycle_id"):
|
|
477
|
+
task_info += f"\n[bold cyan]Cycle ID:[/bold cyan] {task.get('cycle_id')}"
|
|
478
|
+
|
|
479
|
+
console.print(Panel(task_info, title=title, border_style="green"))
|
|
480
|
+
|
|
481
|
+
# Display implementation plan if present
|
|
482
|
+
if impl_plan and not impl_plan.is_empty():
|
|
483
|
+
display_implementation_plan(impl_plan)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def display_task_list(tasks: list[dict[str, Any]], full_ids: bool = True) -> None:
|
|
487
|
+
"""Display a list of tasks in a formatted table.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
tasks: List of task data dicts.
|
|
491
|
+
full_ids: If True, show full UUIDs. If False, truncate to 8 chars.
|
|
492
|
+
"""
|
|
493
|
+
if not tasks:
|
|
494
|
+
console.print("[yellow]No tasks found[/yellow]")
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
table = Table(title="Tasks")
|
|
498
|
+
table.add_column("Linear ID", style="cyan", no_wrap=True)
|
|
499
|
+
table.add_column("Title", style="white")
|
|
500
|
+
table.add_column("Status", style="magenta")
|
|
501
|
+
table.add_column("Priority", style="yellow")
|
|
502
|
+
table.add_column("ID", style="dim")
|
|
503
|
+
|
|
504
|
+
for task in tasks:
|
|
505
|
+
# Linear-native status_type styling
|
|
506
|
+
status_style = {
|
|
507
|
+
"backlog": "dim",
|
|
508
|
+
"unstarted": "cyan",
|
|
509
|
+
"started": "yellow",
|
|
510
|
+
"completed": "green",
|
|
511
|
+
"canceled": "dim",
|
|
512
|
+
}.get(task.get("status", ""), "white")
|
|
513
|
+
|
|
514
|
+
task_id = str(task.get("id", "N/A"))
|
|
515
|
+
linear_id = task.get("linear_identifier", "N/A")
|
|
516
|
+
|
|
517
|
+
if not full_ids:
|
|
518
|
+
task_id = task_id[:8] + "..." if len(task_id) > 8 else task_id
|
|
519
|
+
|
|
520
|
+
table.add_row(
|
|
521
|
+
linear_id,
|
|
522
|
+
task.get("title", "N/A")[:50],
|
|
523
|
+
f"[{status_style}]{task.get('status', 'N/A')}[/{status_style}]",
|
|
524
|
+
get_priority_display(task.get("priority")),
|
|
525
|
+
task_id,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
console.print(table)
|
|
529
|
+
console.print(f"\n[dim]Total: {len(tasks)} tasks[/dim]")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def display_wave_context(wave_response: dict[str, Any]) -> None:
|
|
533
|
+
"""Display wave context in a formatted panel.
|
|
534
|
+
|
|
535
|
+
Shows the current wave overview, task statuses, completed waves,
|
|
536
|
+
and the next task to work on.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
wave_response: Wave response dict from the API.
|
|
540
|
+
"""
|
|
541
|
+
wave = wave_response.get("wave", {})
|
|
542
|
+
context = wave_response.get("context", {})
|
|
543
|
+
tasks = wave_response.get("tasks", [])
|
|
544
|
+
next_task = context.get("next_task", {})
|
|
545
|
+
completed_waves = context.get("completed_waves", [])
|
|
546
|
+
|
|
547
|
+
wave_number = wave.get("wave_number", "?")
|
|
548
|
+
total_waves = wave.get("total_waves", "?")
|
|
549
|
+
wave_desc = wave.get("description", "")
|
|
550
|
+
|
|
551
|
+
# Count task statuses in current wave
|
|
552
|
+
status_counts: dict[str, int] = {}
|
|
553
|
+
for t in tasks:
|
|
554
|
+
s = t.get("status", "unknown")
|
|
555
|
+
status_counts[s] = status_counts.get(s, 0) + 1
|
|
556
|
+
|
|
557
|
+
status_parts = []
|
|
558
|
+
for s in ["completed", "started", "unstarted", "backlog", "canceled"]:
|
|
559
|
+
if s in status_counts:
|
|
560
|
+
status_parts.append(f"{status_counts[s]} {s}")
|
|
561
|
+
status_summary = ", ".join(status_parts) if status_parts else "no tasks"
|
|
562
|
+
|
|
563
|
+
# Wave header
|
|
564
|
+
header = f"[bold blue]Wave {wave_number} of {total_waves}[/bold blue]"
|
|
565
|
+
if wave_desc:
|
|
566
|
+
header += f" — {wave_desc}"
|
|
567
|
+
header += f"\n[dim]{len(tasks)} tasks: {status_summary}[/dim]"
|
|
568
|
+
|
|
569
|
+
# Completed waves
|
|
570
|
+
if completed_waves:
|
|
571
|
+
header += "\n\n[green]Completed waves:[/green]"
|
|
572
|
+
for cw in completed_waves:
|
|
573
|
+
header += f"\n [green]✓[/green] Wave {cw.get('wave_number', '?')}: {cw.get('description', '')}"
|
|
574
|
+
|
|
575
|
+
console.print(Panel(header, title="Wave Context", border_style="blue"))
|
|
576
|
+
|
|
577
|
+
# Task table for current wave
|
|
578
|
+
table = Table(title=f"Wave {wave_number} Tasks")
|
|
579
|
+
table.add_column("", style="bold", width=3)
|
|
580
|
+
table.add_column("Linear ID", style="cyan", no_wrap=True)
|
|
581
|
+
table.add_column("Title", style="white")
|
|
582
|
+
table.add_column("Status", style="magenta")
|
|
583
|
+
table.add_column("Priority", style="yellow")
|
|
584
|
+
|
|
585
|
+
next_task_id = next_task.get("id", "")
|
|
586
|
+
for t in tasks:
|
|
587
|
+
is_next = t.get("id") == next_task_id
|
|
588
|
+
marker = ">>>" if is_next else ""
|
|
589
|
+
|
|
590
|
+
status = t.get("status", "unknown")
|
|
591
|
+
status_style = {
|
|
592
|
+
"backlog": "dim",
|
|
593
|
+
"unstarted": "cyan",
|
|
594
|
+
"started": "yellow",
|
|
595
|
+
"completed": "green",
|
|
596
|
+
"canceled": "dim",
|
|
597
|
+
}.get(status, "white")
|
|
598
|
+
|
|
599
|
+
table.add_row(
|
|
600
|
+
f"[bold yellow]{marker}[/bold yellow]" if is_next else "",
|
|
601
|
+
t.get("linear_identifier", "N/A"),
|
|
602
|
+
t.get("title", "N/A")[:50],
|
|
603
|
+
f"[{status_style}]{status}[/{status_style}]",
|
|
604
|
+
get_priority_display(t.get("priority")),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
console.print(table)
|
|
608
|
+
|
|
609
|
+
# Next task detail
|
|
610
|
+
if next_task:
|
|
611
|
+
display_task(next_task, title="Next Task")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def display_update_success(
|
|
615
|
+
task_id: str,
|
|
616
|
+
status: str | None = None,
|
|
617
|
+
title: str | None = None,
|
|
618
|
+
prompt: str | None = None,
|
|
619
|
+
priority: int | None = None,
|
|
620
|
+
result_summary: str | None = None,
|
|
621
|
+
error_message: str | None = None,
|
|
622
|
+
comment: str | None = None,
|
|
623
|
+
) -> None:
|
|
624
|
+
"""Display update success message.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
task_id: Updated task ID.
|
|
628
|
+
status: Updated status if any.
|
|
629
|
+
title: Updated title if any.
|
|
630
|
+
prompt: Updated prompt if any.
|
|
631
|
+
priority: Updated priority if any.
|
|
632
|
+
result_summary: Added result summary if any.
|
|
633
|
+
error_message: Added error message if any.
|
|
634
|
+
comment: Added comment if any.
|
|
635
|
+
"""
|
|
636
|
+
updates = []
|
|
637
|
+
if status:
|
|
638
|
+
updates.append(f"Status -> {status}")
|
|
639
|
+
if title:
|
|
640
|
+
updates.append("Title updated")
|
|
641
|
+
if prompt:
|
|
642
|
+
updates.append("Prompt updated")
|
|
643
|
+
if priority is not None:
|
|
644
|
+
updates.append(f"Priority -> {priority}")
|
|
645
|
+
if result_summary:
|
|
646
|
+
updates.append("Result summary added")
|
|
647
|
+
if error_message:
|
|
648
|
+
updates.append("Error message added")
|
|
649
|
+
if comment:
|
|
650
|
+
updates.append("Comment added")
|
|
651
|
+
|
|
652
|
+
console.print(
|
|
653
|
+
Panel(
|
|
654
|
+
f"[bold green]Task {task_id} updated[/bold green]\n\n"
|
|
655
|
+
+ "\n".join(f"* {u}" for u in updates),
|
|
656
|
+
title="Success",
|
|
657
|
+
border_style="green",
|
|
658
|
+
)
|
|
659
|
+
)
|