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.
Files changed (132) hide show
  1. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/PKG-INFO +16 -7
  2. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/README.md +15 -6
  3. lemming_cli-0.3.0/docs/screenshots/dashboard-desktop.png +0 -0
  4. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/screenshots/dashboard-mobile.png +0 -0
  5. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/pyproject.toml +1 -1
  6. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/tasks.py +35 -1
  7. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/tasks_test.py +34 -1
  8. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/tasks.py +231 -67
  9. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/tasks_test.py +139 -6
  10. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/roadmap.py +162 -0
  11. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/roadmap_test.py +63 -5
  12. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/trial_test.py +2 -1
  13. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/models.py +3 -0
  14. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/models_test.py +3 -0
  15. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/orchestrator.py +17 -4
  16. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/orchestrator_test.py +38 -0
  17. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/persistence_test.py +23 -0
  18. lemming_cli-0.3.0/src/lemming/prompts/hooks/90-roadmap.md +79 -0
  19. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts.py +3 -0
  20. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts_test.py +17 -0
  21. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/__init__.py +6 -0
  22. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/lifecycle.py +2 -0
  23. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/lifecycle_test.py +4 -0
  24. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/operations.py +76 -4
  25. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/operations_test.py +69 -3
  26. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/queries.py +45 -1
  27. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/queries_test.py +22 -0
  28. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/dashboard.test.js +129 -19
  29. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/files.html +4 -1
  30. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/index.html +68 -22
  31. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/index.js +73 -24
  32. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/logs.html +15 -0
  33. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/logs.test.js +29 -0
  34. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/uv.lock +1 -1
  35. lemming_cli-0.2.1/docs/screenshots/dashboard-desktop.png +0 -0
  36. lemming_cli-0.2.1/src/lemming/prompts/hooks/90-roadmap.md +0 -61
  37. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  38. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  39. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.github/workflows/screenshots.yml +0 -0
  40. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.gitignore +0 -0
  41. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.prettierignore +0 -0
  42. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/.prettierrc +0 -0
  43. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/Dockerfile +0 -0
  44. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/LICENSE +0 -0
  45. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/biome.json +0 -0
  46. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docker-compose.yml +0 -0
  47. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/EVALS.md +0 -0
  48. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/HOOKS.md +0 -0
  49. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/screenshots/task-log-desktop.png +0 -0
  50. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/docs/screenshots/task-log-mobile.png +0 -0
  51. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/package-lock.json +0 -0
  52. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/package.json +0 -0
  53. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/playwright.config.js +0 -0
  54. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/__init__.py +0 -0
  55. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/__init__.py +0 -0
  56. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/auth.py +0 -0
  57. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/auth_test.py +0 -0
  58. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/config.py +0 -0
  59. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/config_test.py +0 -0
  60. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/conftest.py +0 -0
  61. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/context.py +0 -0
  62. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/context_test.py +0 -0
  63. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/directories.py +0 -0
  64. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/directories_test.py +0 -0
  65. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/files.py +0 -0
  66. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/files_test.py +0 -0
  67. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/hooks.py +0 -0
  68. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/hooks_test.py +0 -0
  69. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/logging.py +0 -0
  70. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/logging_test.py +0 -0
  71. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/loop.py +0 -0
  72. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/loop_test.py +0 -0
  73. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/main.py +0 -0
  74. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api/main_test.py +0 -0
  75. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/api_test.py +0 -0
  76. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/__init__.py +0 -0
  77. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/config.py +0 -0
  78. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/config_test.py +0 -0
  79. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/goal.py +0 -0
  80. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/goal_test.py +0 -0
  81. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/hooks.py +0 -0
  82. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/hooks_test.py +0 -0
  83. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/main.py +0 -0
  84. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/main_test.py +0 -0
  85. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/operations.py +0 -0
  86. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/operations_test.py +0 -0
  87. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/progress.py +0 -0
  88. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/progress_test.py +0 -0
  89. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/readability_cli.py +0 -0
  90. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli/readability_cli_test.py +0 -0
  91. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/cli_test.py +0 -0
  92. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/conftest.py +0 -0
  93. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/__init__.py +0 -0
  94. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/__main__.py +0 -0
  95. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/cli.py +0 -0
  96. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/cli_test.py +0 -0
  97. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/container.py +0 -0
  98. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/container_test.py +0 -0
  99. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/fixtures.py +0 -0
  100. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/fixtures_test.py +0 -0
  101. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/harness.py +0 -0
  102. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/harness_test.py +0 -0
  103. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/readability.py +0 -0
  104. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/readability_test.py +0 -0
  105. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/scenarios.py +0 -0
  106. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/scenarios_test.py +0 -0
  107. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/suites.py +0 -0
  108. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/evals/trial.py +0 -0
  109. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/hooks.py +0 -0
  110. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/hooks_test.py +0 -0
  111. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/integration_test.py +0 -0
  112. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/main.py +0 -0
  113. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/main_test.py +0 -0
  114. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/paths.py +0 -0
  115. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/paths_test.py +0 -0
  116. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/persistence.py +0 -0
  117. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/hooks/50-readability.md +0 -0
  118. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/hooks/60-testing.md +0 -0
  119. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/hooks/70-ux.md +0 -0
  120. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/prompts/taskrunner.md +0 -0
  121. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/providers.py +0 -0
  122. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/providers_test.py +0 -0
  123. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/runner.py +0 -0
  124. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/runner_test.py +0 -0
  125. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/progress.py +0 -0
  126. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/tasks/progress_test.py +0 -0
  127. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/dashboard.spec.js +0 -0
  128. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/favicon.js +0 -0
  129. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/favicon.spec.js +0 -0
  130. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/files.spec.js +0 -0
  131. {lemming_cli-0.2.1 → lemming_cli-0.3.0}/src/lemming/web/mancha.js +0 -0
  132. {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.2.1
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>]`**: Roadmap overview or deep-dive into a specific task.
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 a task. Supports `--all` and `--completed` for bulk
255
- operations.
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
- - **`logs [<id>]`**: Print a task's execution log to stdout. If no ID is
287
- provided, it defaults to the active or most recent task. Orchestrator hook
288
- output is automatically appended.
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>]`**: Roadmap overview or deep-dive into a specific task.
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 a task. Supports `--all` and `--completed` for bulk
233
- operations.
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
- - **`logs [<id>]`**: Print a task's execution log to stdout. If no ID is
265
- provided, it defaults to the active or most recent task. Orchestrator hook
266
- output is automatically appended.
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lemming-cli"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "An autonomous, iterative task runner for AI coding agents."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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), task_id=task_id
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 test_api_delete_log_cleanup(client, test_tasks):
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("--completed", is_flag=True, help="Delete completed tasks only.")
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, task_id: str | None, delete_all: bool, completed: bool
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} completed task(s) and their logs.")
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(f"Removed task(s) matching {task_id} and their logs")
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
- for t in project_data.tasks:
242
- if not verbose and t.status in (
243
- tasks.TaskStatus.COMPLETED,
244
- tasks.TaskStatus.CANCELLED,
245
- ):
246
- continue
247
-
248
- if t.status == tasks.TaskStatus.COMPLETED:
249
- marker = "[x]"
250
- status_color = "green"
251
- elif t.status == tasks.TaskStatus.IN_PROGRESS:
252
- marker = "[*]"
253
- status_color = "cyan"
254
- elif t.status == tasks.TaskStatus.CANCELLED:
255
- marker = "[-]"
256
- status_color = "red"
257
- else:
258
- marker = "[ ]"
259
- status_color = "yellow"
260
-
261
- click.secho(f"{marker} ", fg=status_color, nl=False)
262
- parent_str = ""
263
- if t.parent:
264
- parent_str = f" [parent:{t.parent}]"
265
-
266
- click.echo(f"({t.id}){parent_str} {t.description}")
267
-
268
- if not verbose:
269
- completed_count = sum(
270
- 1
271
- for t in project_data.tasks
272
- if t.status
273
- in (tasks.TaskStatus.COMPLETED, tasks.TaskStatus.CANCELLED)
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
- target = next(
282
- (t for t in project_data.tasks if t.id.startswith(task_id)), None
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
- if not target:
286
- click.echo(f"Error: Task {task_id} not found.")
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
- click.echo(f"Parent: {target.parent}")
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
- if run_time < 60:
335
- rt_str = f"{run_time:.1f}s"
336
- else:
337
- rt_str = f"{int(run_time // 60)}m {int(run_time % 60)}s"
338
- click.echo(f"Run Time: {rt_str}")
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 completed one.
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
- target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
364
- if not target:
365
- click.echo(f"Error: Task {task_id} not found.")
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 completed task
375
- completed = [
376
- t for t in data.tasks if t.status == tasks.TaskStatus.COMPLETED
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 completed:
379
- target = sorted(completed, key=lambda x: x.completed_at or 0)[
380
- -1
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 completed task found.")
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 = paths.get_log_file(tasks_file, target.id)
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)