lemming-cli 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/PKG-INFO +16 -7
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/README.md +15 -6
- lemming_cli-0.3.0/docs/screenshots/dashboard-desktop.png +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/screenshots/dashboard-mobile.png +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/pyproject.toml +1 -1
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/tasks.py +35 -1
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/tasks_test.py +34 -1
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/tasks.py +231 -67
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/tasks_test.py +139 -6
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/roadmap.py +162 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/roadmap_test.py +63 -5
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/trial_test.py +2 -1
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/models.py +3 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/models_test.py +3 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/orchestrator.py +17 -4
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/orchestrator_test.py +38 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/persistence_test.py +23 -0
- lemming_cli-0.3.0/src/lemming/prompts/hooks/90-roadmap.md +79 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts.py +3 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts_test.py +17 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/__init__.py +6 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/lifecycle.py +2 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/lifecycle_test.py +4 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/operations.py +76 -4
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/operations_test.py +69 -3
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/queries.py +45 -1
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/queries_test.py +22 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/dashboard.test.js +129 -19
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/files.html +4 -1
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/index.html +68 -22
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/index.js +73 -24
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/logs.html +15 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/logs.test.js +29 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/uv.lock +1 -1
- lemming_cli-0.2.1/docs/screenshots/dashboard-desktop.png +0 -0
- lemming_cli-0.2.1/src/lemming/prompts/hooks/90-roadmap.md +0 -61
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.github/workflows/screenshots.yml +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.gitignore +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.prettierignore +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.prettierrc +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/Dockerfile +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/LICENSE +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/biome.json +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docker-compose.yml +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/EVALS.md +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/HOOKS.md +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/screenshots/task-log-desktop.png +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/screenshots/task-log-mobile.png +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/package-lock.json +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/package.json +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/playwright.config.js +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/__init__.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/__init__.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/auth.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/auth_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/config.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/config_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/conftest.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/context.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/context_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/directories.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/directories_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/files.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/files_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/hooks.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/hooks_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/logging.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/logging_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/loop.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/loop_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/main.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/main_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/__init__.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/config.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/config_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/goal.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/goal_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/hooks.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/hooks_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/main.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/main_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/operations.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/operations_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/progress.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/progress_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/readability_cli.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/readability_cli_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/conftest.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/__init__.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/__main__.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/cli.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/cli_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/container.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/container_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/fixtures.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/fixtures_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/harness.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/harness_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/readability.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/readability_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/scenarios.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/scenarios_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/suites.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/trial.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/hooks.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/hooks_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/integration_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/main.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/main_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/paths.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/paths_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/persistence.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/hooks/50-readability.md +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/hooks/60-testing.md +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/hooks/70-ux.md +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/taskrunner.md +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/providers.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/providers_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/runner.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/runner_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/progress.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/progress_test.py +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/dashboard.spec.js +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/favicon.js +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/favicon.spec.js +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/files.spec.js +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/mancha.js +0 -0
- {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/screenshots.spec.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lemming-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: An autonomous, iterative task runner for AI coding agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/owahltinez/lemming
|
|
6
6
|
Project-URL: Repository, https://github.com/owahltinez/lemming
|
|
@@ -246,13 +246,20 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
246
246
|
|
|
247
247
|
### Roadmap Management
|
|
248
248
|
|
|
249
|
-
- **`status [<id>]`**:
|
|
249
|
+
- **`status [<id>]`**: Queue/history overview or deep-dive into a specific
|
|
250
|
+
task, including supersession lineage and runner/orchestrator-hook execution
|
|
251
|
+
times. Superseded and failed history stays visible in the default overview;
|
|
252
|
+
`--verbose` also shows routine completed/cancelled history.
|
|
250
253
|
- **`goal [<text>]`**: Set or view the long-term goal shared by all tasks.
|
|
251
254
|
Supports `-f/--file`.
|
|
252
255
|
- **`add <desc>`**: Append a new task. Supports `--index` and `--runner`.
|
|
253
256
|
- **`edit <id>`**: Modify a task's description, runner, or position.
|
|
254
|
-
- **`delete <id>`**: Remove
|
|
255
|
-
|
|
257
|
+
- **`delete <id>`**: Remove an unstarted task while retaining its runner log.
|
|
258
|
+
Tasks with execution history require `--force`; autonomous restructuring
|
|
259
|
+
should use `supersede`. Supports `--all` and `--completed` for bulk cleanup,
|
|
260
|
+
including logs.
|
|
261
|
+
- **`supersede <id> --reason <text>`**: Retire a replaced or split task without
|
|
262
|
+
losing its progress, timings, log, or links to replacement tasks.
|
|
256
263
|
- **`progress`**: Manage progress entries and findings for specific tasks.
|
|
257
264
|
- `list <id>`: List all progress for a task.
|
|
258
265
|
- `add <id> <finding>`: Record a new technical detail.
|
|
@@ -283,9 +290,11 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
283
290
|
- **`fail <id>`**: Mark a task as a terminal failure (will not be retried).
|
|
284
291
|
- **`cancel <id>`**: Stop an in-progress task (kills the runner process).
|
|
285
292
|
- **`reset <id>`**: Clear attempts and progress to start a task fresh.
|
|
286
|
-
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
- Superseded tasks remain visible as non-failing history; replacement tasks
|
|
294
|
+
link back through their parent task ID.
|
|
295
|
+
- **`logs [<id>]`**: Print a task's execution log to stdout, including retained
|
|
296
|
+
logs for removed tasks. If no ID is provided, it defaults to the active or
|
|
297
|
+
most recent task. Orchestrator hook output is automatically appended.
|
|
289
298
|
|
|
290
299
|
### Execution
|
|
291
300
|
|
|
@@ -224,13 +224,20 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
224
224
|
|
|
225
225
|
### Roadmap Management
|
|
226
226
|
|
|
227
|
-
- **`status [<id>]`**:
|
|
227
|
+
- **`status [<id>]`**: Queue/history overview or deep-dive into a specific
|
|
228
|
+
task, including supersession lineage and runner/orchestrator-hook execution
|
|
229
|
+
times. Superseded and failed history stays visible in the default overview;
|
|
230
|
+
`--verbose` also shows routine completed/cancelled history.
|
|
228
231
|
- **`goal [<text>]`**: Set or view the long-term goal shared by all tasks.
|
|
229
232
|
Supports `-f/--file`.
|
|
230
233
|
- **`add <desc>`**: Append a new task. Supports `--index` and `--runner`.
|
|
231
234
|
- **`edit <id>`**: Modify a task's description, runner, or position.
|
|
232
|
-
- **`delete <id>`**: Remove
|
|
233
|
-
|
|
235
|
+
- **`delete <id>`**: Remove an unstarted task while retaining its runner log.
|
|
236
|
+
Tasks with execution history require `--force`; autonomous restructuring
|
|
237
|
+
should use `supersede`. Supports `--all` and `--completed` for bulk cleanup,
|
|
238
|
+
including logs.
|
|
239
|
+
- **`supersede <id> --reason <text>`**: Retire a replaced or split task without
|
|
240
|
+
losing its progress, timings, log, or links to replacement tasks.
|
|
234
241
|
- **`progress`**: Manage progress entries and findings for specific tasks.
|
|
235
242
|
- `list <id>`: List all progress for a task.
|
|
236
243
|
- `add <id> <finding>`: Record a new technical detail.
|
|
@@ -261,9 +268,11 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
261
268
|
- **`fail <id>`**: Mark a task as a terminal failure (will not be retried).
|
|
262
269
|
- **`cancel <id>`**: Stop an in-progress task (kills the runner process).
|
|
263
270
|
- **`reset <id>`**: Clear attempts and progress to start a task fresh.
|
|
264
|
-
-
|
|
265
|
-
|
|
266
|
-
|
|
271
|
+
- Superseded tasks remain visible as non-failing history; replacement tasks
|
|
272
|
+
link back through their parent task ID.
|
|
273
|
+
- **`logs [<id>]`**: Print a task's execution log to stdout, including retained
|
|
274
|
+
logs for removed tasks. If no ID is provided, it defaults to the active or
|
|
275
|
+
most recent task. Orchestrator hook output is automatically appended.
|
|
267
276
|
|
|
268
277
|
### Execution
|
|
269
278
|
|
|
Binary file
|
|
Binary file
|
|
@@ -76,6 +76,11 @@ def update_task(
|
|
|
76
76
|
"""Update a task's description, runner, position, status, or parent."""
|
|
77
77
|
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
78
78
|
status = update.get("status")
|
|
79
|
+
if status == tasks.TaskStatus.SUPERSEDED:
|
|
80
|
+
raise fastapi.HTTPException(
|
|
81
|
+
400,
|
|
82
|
+
"Use the supersede endpoint so a reason is recorded.",
|
|
83
|
+
)
|
|
79
84
|
|
|
80
85
|
# Validation: require progress if completing or failing from the UI,
|
|
81
86
|
# but not if we are just marking a finished task as pending (uncomplete).
|
|
@@ -91,6 +96,7 @@ def update_task(
|
|
|
91
96
|
tasks.TaskStatus.COMPLETED,
|
|
92
97
|
tasks.TaskStatus.FAILED,
|
|
93
98
|
tasks.TaskStatus.CANCELLED,
|
|
99
|
+
tasks.TaskStatus.SUPERSEDED,
|
|
94
100
|
):
|
|
95
101
|
require_progress = True
|
|
96
102
|
|
|
@@ -130,11 +136,39 @@ def delete_task(
|
|
|
130
136
|
):
|
|
131
137
|
"""Delete a single task by ID."""
|
|
132
138
|
tasks.delete_tasks(
|
|
133
|
-
context.resolve_tasks_file(request.app.state, project),
|
|
139
|
+
context.resolve_tasks_file(request.app.state, project),
|
|
140
|
+
task_id=task_id,
|
|
141
|
+
force=True,
|
|
134
142
|
)
|
|
135
143
|
return {"status": "ok"}
|
|
136
144
|
|
|
137
145
|
|
|
146
|
+
class SupersedeTaskRequest(pydantic.BaseModel):
|
|
147
|
+
"""Request body for superseding a task."""
|
|
148
|
+
|
|
149
|
+
reason: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.post("/api/tasks/{task_id}/supersede")
|
|
153
|
+
def supersede_task(
|
|
154
|
+
request: fastapi.Request,
|
|
155
|
+
task_id: str,
|
|
156
|
+
body: SupersedeTaskRequest,
|
|
157
|
+
project: str | None = None,
|
|
158
|
+
):
|
|
159
|
+
"""Supersede a task while retaining its history."""
|
|
160
|
+
try:
|
|
161
|
+
return tasks.supersede_task(
|
|
162
|
+
context.resolve_tasks_file(request.app.state, project),
|
|
163
|
+
task_id,
|
|
164
|
+
body.reason,
|
|
165
|
+
)
|
|
166
|
+
except ValueError as e:
|
|
167
|
+
if "not found" in str(e):
|
|
168
|
+
raise fastapi.HTTPException(404, str(e))
|
|
169
|
+
raise fastapi.HTTPException(400, str(e))
|
|
170
|
+
|
|
171
|
+
|
|
138
172
|
@router.post("/api/tasks/{task_id}/cancel")
|
|
139
173
|
def cancel_task_endpoint(
|
|
140
174
|
request: fastapi.Request, task_id: str, project: str | None = None
|
|
@@ -174,6 +174,25 @@ def test_reopen_cancelled_task_via_api(client, test_tasks):
|
|
|
174
174
|
assert response.json()["attempts"] == 0
|
|
175
175
|
|
|
176
176
|
|
|
177
|
+
def test_supersede_and_reopen_task_via_api(client, test_tasks):
|
|
178
|
+
response = client.post(
|
|
179
|
+
"/api/tasks/task2/supersede",
|
|
180
|
+
json={"reason": "split after timeout"},
|
|
181
|
+
)
|
|
182
|
+
assert response.status_code == 200
|
|
183
|
+
assert response.json()["status"] == tasks.TaskStatus.SUPERSEDED
|
|
184
|
+
assert response.json()["superseded_reason"] == "split after timeout"
|
|
185
|
+
|
|
186
|
+
response = client.post(
|
|
187
|
+
"/api/tasks/task2/update",
|
|
188
|
+
json={"status": tasks.TaskStatus.PENDING},
|
|
189
|
+
)
|
|
190
|
+
assert response.status_code == 200
|
|
191
|
+
assert response.json()["status"] == tasks.TaskStatus.PENDING
|
|
192
|
+
assert response.json()["superseded_reason"] is None
|
|
193
|
+
assert response.json()["superseded_at"] is None
|
|
194
|
+
|
|
195
|
+
|
|
177
196
|
def test_has_log_population(client, test_tasks):
|
|
178
197
|
# Initially no logs
|
|
179
198
|
response = client.get("/api/data")
|
|
@@ -225,7 +244,7 @@ def test_api_log(client, test_tasks):
|
|
|
225
244
|
assert response.json() == {"log": ""}
|
|
226
245
|
|
|
227
246
|
|
|
228
|
-
def
|
|
247
|
+
def test_api_delete_retains_log(client, test_tasks):
|
|
229
248
|
test_tasks_file = test_tasks
|
|
230
249
|
# 1. Add a task
|
|
231
250
|
data = tasks.load_tasks(test_tasks_file)
|
|
@@ -248,6 +267,20 @@ def test_api_delete_log_cleanup(client, test_tasks):
|
|
|
248
267
|
|
|
249
268
|
# 3. Delete via API
|
|
250
269
|
response = client.post(f"/api/tasks/{task_id}/delete")
|
|
270
|
+
assert response.status_code == 200
|
|
271
|
+
assert log_file.read_text() == "API delete log"
|
|
272
|
+
|
|
273
|
+
response = client.get(f"/api/tasks/{task_id}/log")
|
|
274
|
+
assert response.status_code == 200
|
|
275
|
+
assert response.json() == {"log": "API delete log"}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_api_delete_completed_cleans_up_log(client, test_tasks):
|
|
279
|
+
log_file = paths.get_log_file(test_tasks, "task1")
|
|
280
|
+
log_file.write_text("completed task log")
|
|
281
|
+
|
|
282
|
+
response = client.post("/api/tasks/delete-completed")
|
|
283
|
+
|
|
251
284
|
assert response.status_code == 200
|
|
252
285
|
assert not log_file.exists()
|
|
253
286
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""CLI commands for managing tasks in the roadmap queue."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import time
|
|
4
5
|
import typing
|
|
5
6
|
|
|
@@ -9,6 +10,58 @@ from .. import paths, tasks
|
|
|
9
10
|
from .main import cli
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def _format_elapsed_time(seconds: float) -> str:
|
|
14
|
+
"""Format elapsed seconds for CLI status output."""
|
|
15
|
+
if seconds < 60:
|
|
16
|
+
return f"{seconds:.1f}s"
|
|
17
|
+
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _execution_time_rows(
|
|
21
|
+
execution_times: dict[str, float] | None,
|
|
22
|
+
) -> list[tuple[str, str]]:
|
|
23
|
+
"""Return display labels and durations with the runner first."""
|
|
24
|
+
if not execution_times:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
components = [
|
|
28
|
+
(component, duration)
|
|
29
|
+
for component, duration in execution_times.items()
|
|
30
|
+
if math.isfinite(duration) and duration > 0
|
|
31
|
+
]
|
|
32
|
+
components.sort(key=lambda item: item[0] != "runner")
|
|
33
|
+
return [
|
|
34
|
+
(
|
|
35
|
+
component.removeprefix("hook:"),
|
|
36
|
+
_format_elapsed_time(duration),
|
|
37
|
+
)
|
|
38
|
+
for component, duration in components
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _echo_task_summary(task: tasks.Task, all_tasks: list[tasks.Task]) -> None:
|
|
43
|
+
"""Print one task row plus compact supersession lineage."""
|
|
44
|
+
marker_by_status = {
|
|
45
|
+
tasks.TaskStatus.COMPLETED: ("[x]", "green"),
|
|
46
|
+
tasks.TaskStatus.IN_PROGRESS: ("[*]", "cyan"),
|
|
47
|
+
tasks.TaskStatus.CANCELLED: ("[-]", "red"),
|
|
48
|
+
tasks.TaskStatus.SUPERSEDED: ("[~]", "magenta"),
|
|
49
|
+
tasks.TaskStatus.FAILED: ("[!]", "red"),
|
|
50
|
+
tasks.TaskStatus.PENDING: ("[ ]", "yellow"),
|
|
51
|
+
}
|
|
52
|
+
marker, status_color = marker_by_status[task.status]
|
|
53
|
+
click.secho(f"{marker} ", fg=status_color, nl=False)
|
|
54
|
+
parent_str = f" [parent:{task.parent}]" if task.parent else ""
|
|
55
|
+
click.echo(f"({task.id}){parent_str} {task.description}")
|
|
56
|
+
|
|
57
|
+
if task.status == tasks.TaskStatus.SUPERSEDED:
|
|
58
|
+
if task.superseded_reason:
|
|
59
|
+
click.echo(f" Reason: {task.superseded_reason}")
|
|
60
|
+
replacements = [item.id for item in all_tasks if item.parent == task.id]
|
|
61
|
+
if replacements:
|
|
62
|
+
click.echo(f" Replaced by: {', '.join(replacements)}")
|
|
63
|
+
|
|
64
|
+
|
|
12
65
|
@cli.command(short_help="[description] Add a new task to the queue")
|
|
13
66
|
@click.argument("description", required=False)
|
|
14
67
|
@click.option(
|
|
@@ -165,10 +218,23 @@ def edit(
|
|
|
165
218
|
is_flag=True,
|
|
166
219
|
help="Delete all tasks and clear the goal.",
|
|
167
220
|
)
|
|
168
|
-
@click.option(
|
|
221
|
+
@click.option(
|
|
222
|
+
"--completed",
|
|
223
|
+
is_flag=True,
|
|
224
|
+
help="Delete terminal task history and its logs.",
|
|
225
|
+
)
|
|
226
|
+
@click.option(
|
|
227
|
+
"--force",
|
|
228
|
+
is_flag=True,
|
|
229
|
+
help="Explicitly remove a task that has execution history.",
|
|
230
|
+
)
|
|
169
231
|
@click.pass_context
|
|
170
232
|
def delete_task(
|
|
171
|
-
ctx: click.Context,
|
|
233
|
+
ctx: click.Context,
|
|
234
|
+
task_id: str | None,
|
|
235
|
+
delete_all: bool,
|
|
236
|
+
completed: bool,
|
|
237
|
+
force: bool,
|
|
172
238
|
):
|
|
173
239
|
"""Deletes one or more tasks from the roadmap."""
|
|
174
240
|
tasks_file = ctx.obj["TASKS_FILE"]
|
|
@@ -180,6 +246,9 @@ def delete_task(
|
|
|
180
246
|
if task_id and (delete_all or completed):
|
|
181
247
|
click.echo("Error: Cannot specify a task ID with --all or --completed.")
|
|
182
248
|
ctx.exit(1)
|
|
249
|
+
if force and not task_id:
|
|
250
|
+
click.echo("Error: --force requires a task ID.")
|
|
251
|
+
ctx.exit(1)
|
|
183
252
|
if not task_id and not delete_all and not completed:
|
|
184
253
|
click.echo("Error: Provide a task ID, or use --all or --completed.")
|
|
185
254
|
ctx.exit(1)
|
|
@@ -190,6 +259,7 @@ def delete_task(
|
|
|
190
259
|
task_id=task_id,
|
|
191
260
|
all_tasks=delete_all,
|
|
192
261
|
completed_only=completed,
|
|
262
|
+
force=force,
|
|
193
263
|
)
|
|
194
264
|
except ValueError as e:
|
|
195
265
|
click.echo(f"Error: {e}")
|
|
@@ -200,14 +270,35 @@ def delete_task(
|
|
|
200
270
|
"Deleted all tasks, progress, and logs, and cleared the goal."
|
|
201
271
|
)
|
|
202
272
|
elif completed:
|
|
203
|
-
click.echo(f"Deleted {removed}
|
|
273
|
+
click.echo(f"Deleted {removed} history task(s) and their logs.")
|
|
204
274
|
elif task_id:
|
|
205
275
|
if removed > 0:
|
|
206
|
-
click.echo(
|
|
276
|
+
click.echo(
|
|
277
|
+
f"Removed task matching {task_id}; runner log retained "
|
|
278
|
+
"if present."
|
|
279
|
+
)
|
|
207
280
|
else:
|
|
208
281
|
click.echo(f"Error: Task {task_id} not found.")
|
|
209
282
|
|
|
210
283
|
|
|
284
|
+
@cli.command(short_help="<taskid> Replace a task while keeping its history")
|
|
285
|
+
@click.argument("task_id")
|
|
286
|
+
@click.option(
|
|
287
|
+
"--reason",
|
|
288
|
+
required=True,
|
|
289
|
+
help="Why the task was replaced (for example, split after timeout).",
|
|
290
|
+
)
|
|
291
|
+
@click.pass_context
|
|
292
|
+
def supersede(ctx: click.Context, task_id: str, reason: str):
|
|
293
|
+
"""Marks a task as superseded without deleting its execution record."""
|
|
294
|
+
try:
|
|
295
|
+
target = tasks.supersede_task(ctx.obj["TASKS_FILE"], task_id, reason)
|
|
296
|
+
click.echo(f"Task {target.id} marked as superseded: {reason.strip()}")
|
|
297
|
+
except ValueError as e:
|
|
298
|
+
click.echo(f"Error: {e}")
|
|
299
|
+
ctx.exit(1)
|
|
300
|
+
|
|
301
|
+
|
|
211
302
|
@cli.command(short_help="<taskid> Show the goal and task details")
|
|
212
303
|
@click.argument("task_id", required=False)
|
|
213
304
|
@click.pass_context
|
|
@@ -231,59 +322,81 @@ def status(ctx: click.Context, task_id: str | None):
|
|
|
231
322
|
if verbose:
|
|
232
323
|
click.secho("=== Long-Term Goal ===", fg="cyan", bold=True)
|
|
233
324
|
click.echo(project_data.goal or "No goal set.")
|
|
234
|
-
click.secho("\n=== Tasks ===", fg="cyan", bold=True)
|
|
235
325
|
|
|
236
326
|
if not project_data.tasks:
|
|
237
327
|
if verbose:
|
|
238
328
|
click.echo("No tasks found.")
|
|
239
329
|
return
|
|
240
330
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
331
|
+
terminal_statuses = {
|
|
332
|
+
tasks.TaskStatus.COMPLETED,
|
|
333
|
+
tasks.TaskStatus.FAILED,
|
|
334
|
+
tasks.TaskStatus.CANCELLED,
|
|
335
|
+
tasks.TaskStatus.SUPERSEDED,
|
|
336
|
+
}
|
|
337
|
+
queue = [
|
|
338
|
+
task
|
|
339
|
+
for task in project_data.tasks
|
|
340
|
+
if task.status not in terminal_statuses
|
|
341
|
+
]
|
|
342
|
+
history = [
|
|
343
|
+
task
|
|
344
|
+
for task in project_data.tasks
|
|
345
|
+
if task.status in terminal_statuses
|
|
346
|
+
]
|
|
347
|
+
visible_history = (
|
|
348
|
+
history
|
|
349
|
+
if verbose
|
|
350
|
+
else [
|
|
351
|
+
task
|
|
352
|
+
for task in history
|
|
353
|
+
if task.status
|
|
354
|
+
in (tasks.TaskStatus.FAILED, tasks.TaskStatus.SUPERSEDED)
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
queue_heading = "\n=== Queue ===" if verbose else "Queue:"
|
|
359
|
+
click.secho(queue_heading, fg="cyan", bold=True)
|
|
360
|
+
if queue:
|
|
361
|
+
for task in queue:
|
|
362
|
+
_echo_task_summary(task, project_data.tasks)
|
|
363
|
+
else:
|
|
364
|
+
click.echo("No active tasks.")
|
|
365
|
+
|
|
366
|
+
if visible_history:
|
|
367
|
+
history_heading = "\n=== History ===" if verbose else "\nHistory:"
|
|
368
|
+
click.secho(history_heading, fg="magenta", bold=True)
|
|
369
|
+
for task in visible_history:
|
|
370
|
+
_echo_task_summary(task, project_data.tasks)
|
|
371
|
+
|
|
372
|
+
hidden_count = len(history) - len(visible_history)
|
|
373
|
+
if hidden_count:
|
|
374
|
+
click.echo(
|
|
375
|
+
f"({hidden_count} completed/cancelled history task(s) hidden; "
|
|
376
|
+
"use --verbose to show)"
|
|
274
377
|
)
|
|
275
|
-
if completed_count > 0:
|
|
276
|
-
click.echo(
|
|
277
|
-
f"({completed_count} completed/cancelled tasks hidden)"
|
|
278
|
-
)
|
|
279
378
|
return
|
|
280
379
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
380
|
+
try:
|
|
381
|
+
target = tasks.resolve_task(project_data.tasks, task_id)
|
|
382
|
+
except tasks.TaskNotFoundError:
|
|
383
|
+
try:
|
|
384
|
+
retained_log = tasks.resolve_log_file(tasks_file, task_id)
|
|
385
|
+
except tasks.TaskNotFoundError:
|
|
386
|
+
click.echo(f"Error: Task {task_id} not found.")
|
|
387
|
+
return
|
|
388
|
+
except tasks.AmbiguousTaskIdError as e:
|
|
389
|
+
click.echo(f"Error: {e}")
|
|
390
|
+
return
|
|
284
391
|
|
|
285
|
-
|
|
286
|
-
click.echo(
|
|
392
|
+
retained_id = retained_log.name.removesuffix("-runner.log")
|
|
393
|
+
click.echo(
|
|
394
|
+
f"Task {retained_id} was removed; runner log retained at "
|
|
395
|
+
f"{retained_log}"
|
|
396
|
+
)
|
|
397
|
+
return
|
|
398
|
+
except tasks.AmbiguousTaskIdError as e:
|
|
399
|
+
click.echo(f"Error: {e}")
|
|
287
400
|
return
|
|
288
401
|
|
|
289
402
|
if project_data.loop_running:
|
|
@@ -304,7 +417,14 @@ def status(ctx: click.Context, task_id: str | None):
|
|
|
304
417
|
click.echo(f"Status: {status_str}")
|
|
305
418
|
click.echo(f"Description: {target.description}")
|
|
306
419
|
if target.parent:
|
|
307
|
-
|
|
420
|
+
parent = next(
|
|
421
|
+
(task for task in project_data.tasks if task.id == target.parent),
|
|
422
|
+
None,
|
|
423
|
+
)
|
|
424
|
+
parent_context = (
|
|
425
|
+
f" [{parent.status}] {parent.description}" if parent else ""
|
|
426
|
+
)
|
|
427
|
+
click.echo(f"Parent: {target.parent}{parent_context}")
|
|
308
428
|
if target.runner:
|
|
309
429
|
click.echo(f"Custom Runner: {target.runner}")
|
|
310
430
|
click.echo(f"Attempts: {target.attempts}")
|
|
@@ -326,16 +446,37 @@ def status(ctx: click.Context, task_id: str | None):
|
|
|
326
446
|
"%Y-%m-%d %H:%M:%S %Z", time.localtime(target.completed_at)
|
|
327
447
|
)
|
|
328
448
|
click.echo(f"Completed At: {comp_time}")
|
|
449
|
+
if target.superseded_at:
|
|
450
|
+
superseded_time = time.strftime(
|
|
451
|
+
"%Y-%m-%d %H:%M:%S %Z",
|
|
452
|
+
time.localtime(target.superseded_at),
|
|
453
|
+
)
|
|
454
|
+
click.echo(f"Superseded At: {superseded_time}")
|
|
455
|
+
if target.superseded_reason:
|
|
456
|
+
click.echo(f"Reason: {target.superseded_reason}")
|
|
457
|
+
|
|
458
|
+
replacements = [
|
|
459
|
+
task for task in project_data.tasks if task.parent == target.id
|
|
460
|
+
]
|
|
461
|
+
if replacements:
|
|
462
|
+
click.echo("Replaced By:")
|
|
463
|
+
for replacement in replacements:
|
|
464
|
+
click.echo(
|
|
465
|
+
f" {replacement.id} [{replacement.status}] "
|
|
466
|
+
f"{replacement.description}"
|
|
467
|
+
)
|
|
329
468
|
run_time = target.run_time
|
|
330
469
|
if target.status == tasks.TaskStatus.IN_PROGRESS and target.last_started_at:
|
|
331
470
|
run_time += time.time() - target.last_started_at
|
|
332
471
|
|
|
333
472
|
if run_time > 0:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
473
|
+
click.echo(f"Run Time: {_format_elapsed_time(run_time)}")
|
|
474
|
+
|
|
475
|
+
execution_rows = _execution_time_rows(target.execution_times)
|
|
476
|
+
if execution_rows:
|
|
477
|
+
label_width = max(len(label) for label, _ in execution_rows)
|
|
478
|
+
for label, duration in execution_rows:
|
|
479
|
+
click.echo(f" {label:<{label_width}} {duration}")
|
|
339
480
|
|
|
340
481
|
if target.progress:
|
|
341
482
|
click.secho("\n--- Progress ---", fg="magenta", bold=True)
|
|
@@ -350,7 +491,7 @@ def logs(ctx: click.Context, task_id: str | None):
|
|
|
350
491
|
"""Prints the execution log for a task to stdout.
|
|
351
492
|
|
|
352
493
|
If no task_id is provided, it defaults to the currently running task or
|
|
353
|
-
the most recently
|
|
494
|
+
the most recently finished one.
|
|
354
495
|
|
|
355
496
|
Note: Orchestrator hooks are appended to the main 'runner' log.
|
|
356
497
|
"""
|
|
@@ -359,10 +500,18 @@ def logs(ctx: click.Context, task_id: str | None):
|
|
|
359
500
|
data = tasks.load_tasks(tasks_file)
|
|
360
501
|
|
|
361
502
|
target = None
|
|
503
|
+
log_file = None
|
|
362
504
|
if task_id:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
505
|
+
try:
|
|
506
|
+
target = tasks.resolve_task(data.tasks, task_id)
|
|
507
|
+
except tasks.TaskNotFoundError:
|
|
508
|
+
try:
|
|
509
|
+
log_file = tasks.resolve_log_file(tasks_file, task_id)
|
|
510
|
+
except (tasks.TaskNotFoundError, tasks.AmbiguousTaskIdError) as e:
|
|
511
|
+
click.echo(f"Error: {e}")
|
|
512
|
+
ctx.exit(1)
|
|
513
|
+
except tasks.AmbiguousTaskIdError as e:
|
|
514
|
+
click.echo(f"Error: {e}")
|
|
366
515
|
ctx.exit(1)
|
|
367
516
|
else:
|
|
368
517
|
# Try to find an active task
|
|
@@ -371,20 +520,35 @@ def logs(ctx: click.Context, task_id: str | None):
|
|
|
371
520
|
None,
|
|
372
521
|
)
|
|
373
522
|
if not target:
|
|
374
|
-
# Fall back to the most recently
|
|
375
|
-
|
|
376
|
-
|
|
523
|
+
# Fall back to the most recently finished task.
|
|
524
|
+
finished = [
|
|
525
|
+
task
|
|
526
|
+
for task in data.tasks
|
|
527
|
+
if task.status
|
|
528
|
+
in (
|
|
529
|
+
tasks.TaskStatus.COMPLETED,
|
|
530
|
+
tasks.TaskStatus.FAILED,
|
|
531
|
+
tasks.TaskStatus.CANCELLED,
|
|
532
|
+
tasks.TaskStatus.SUPERSEDED,
|
|
533
|
+
)
|
|
377
534
|
]
|
|
378
|
-
if
|
|
379
|
-
target =
|
|
380
|
-
|
|
381
|
-
|
|
535
|
+
if finished:
|
|
536
|
+
target = max(
|
|
537
|
+
finished,
|
|
538
|
+
key=lambda task: (
|
|
539
|
+
task.completed_at
|
|
540
|
+
or task.superseded_at
|
|
541
|
+
or task.created_at
|
|
542
|
+
or 0
|
|
543
|
+
),
|
|
544
|
+
)
|
|
382
545
|
|
|
383
|
-
if not target:
|
|
384
|
-
click.echo("Error: No active or recently
|
|
546
|
+
if not target and log_file is None:
|
|
547
|
+
click.echo("Error: No active or recently finished task found.")
|
|
385
548
|
ctx.exit(1)
|
|
386
549
|
|
|
387
|
-
log_file
|
|
550
|
+
if log_file is None:
|
|
551
|
+
log_file = paths.get_log_file(tasks_file, target.id)
|
|
388
552
|
if not log_file.exists():
|
|
389
553
|
click.echo(f"No log for task {target.id}.")
|
|
390
554
|
ctx.exit(1)
|