lemming-cli 0.1.4__tar.gz → 0.2.1__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.1.4 → lemming_cli-0.2.1}/PKG-INFO +15 -7
  2. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/README.md +13 -5
  3. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docs/HOOKS.md +19 -4
  4. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/package-lock.json +4 -4
  5. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/package.json +1 -1
  6. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/pyproject.toml +2 -2
  7. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/hooks_test.py +3 -0
  8. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/tasks.py +11 -8
  9. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/goal.py +1 -7
  10. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/main_test.py +8 -0
  11. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/operations.py +11 -15
  12. lemming_cli-0.2.1/src/lemming/cli/operations_test.py +93 -0
  13. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/progress.py +1 -8
  14. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/tasks.py +67 -86
  15. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/tasks_test.py +65 -0
  16. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/readability.py +134 -2
  17. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/readability_test.py +80 -1
  18. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/hooks_test.py +5 -0
  19. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/integration_test.py +6 -6
  20. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/models.py +5 -0
  21. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/models_test.py +1 -0
  22. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/orchestrator.py +59 -6
  23. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/orchestrator_test.py +37 -0
  24. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/persistence.py +45 -4
  25. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/persistence_test.py +24 -0
  26. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/prompts/hooks/50-readability.md +20 -7
  27. lemming_cli-0.2.1/src/lemming/prompts/hooks/70-ux.md +55 -0
  28. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/prompts_test.py +18 -0
  29. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/runner.py +36 -4
  30. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/runner_test.py +53 -0
  31. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/__init__.py +11 -0
  32. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/lifecycle.py +45 -29
  33. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/lifecycle_test.py +21 -0
  34. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/operations.py +51 -27
  35. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/operations_test.py +94 -1
  36. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/queries.py +17 -0
  37. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/dashboard.spec.js +62 -0
  38. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/dashboard.test.js +160 -0
  39. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/index.html +38 -0
  40. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/index.js +66 -0
  41. lemming_cli-0.2.1/src/lemming/web/mancha.js +58 -0
  42. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/screenshots.spec.js +0 -1
  43. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/uv.lock +5 -5
  44. lemming_cli-0.1.4/src/lemming/cli/operations_test.py +0 -44
  45. lemming_cli-0.1.4/src/lemming/web/mancha.js +0 -58
  46. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  47. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/.github/workflows/publish.yml +0 -0
  48. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/.github/workflows/screenshots.yml +0 -0
  49. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/.gitignore +0 -0
  50. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/.prettierignore +0 -0
  51. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/.prettierrc +0 -0
  52. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/Dockerfile +0 -0
  53. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/LICENSE +0 -0
  54. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/biome.json +0 -0
  55. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docker-compose.yml +0 -0
  56. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docs/EVALS.md +0 -0
  57. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docs/screenshots/dashboard-desktop.png +0 -0
  58. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docs/screenshots/dashboard-mobile.png +0 -0
  59. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docs/screenshots/task-log-desktop.png +0 -0
  60. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/docs/screenshots/task-log-mobile.png +0 -0
  61. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/playwright.config.js +0 -0
  62. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/__init__.py +0 -0
  63. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/__init__.py +0 -0
  64. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/auth.py +0 -0
  65. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/auth_test.py +0 -0
  66. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/config.py +0 -0
  67. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/config_test.py +0 -0
  68. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/conftest.py +0 -0
  69. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/context.py +0 -0
  70. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/context_test.py +0 -0
  71. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/directories.py +0 -0
  72. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/directories_test.py +0 -0
  73. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/files.py +0 -0
  74. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/files_test.py +0 -0
  75. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/hooks.py +0 -0
  76. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/logging.py +0 -0
  77. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/logging_test.py +0 -0
  78. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/loop.py +0 -0
  79. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/loop_test.py +0 -0
  80. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/main.py +0 -0
  81. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/main_test.py +0 -0
  82. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api/tasks_test.py +0 -0
  83. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/api_test.py +0 -0
  84. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/__init__.py +0 -0
  85. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/config.py +0 -0
  86. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/config_test.py +0 -0
  87. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/goal_test.py +0 -0
  88. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/hooks.py +0 -0
  89. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/hooks_test.py +0 -0
  90. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/main.py +0 -0
  91. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/progress_test.py +0 -0
  92. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/readability_cli.py +0 -0
  93. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli/readability_cli_test.py +0 -0
  94. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/cli_test.py +0 -0
  95. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/conftest.py +0 -0
  96. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/__init__.py +0 -0
  97. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/__main__.py +0 -0
  98. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/cli.py +0 -0
  99. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/cli_test.py +0 -0
  100. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/container.py +0 -0
  101. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/container_test.py +0 -0
  102. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/fixtures.py +0 -0
  103. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/fixtures_test.py +0 -0
  104. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/harness.py +0 -0
  105. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/harness_test.py +0 -0
  106. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/roadmap.py +0 -0
  107. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/roadmap_test.py +0 -0
  108. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/scenarios.py +0 -0
  109. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/scenarios_test.py +0 -0
  110. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/suites.py +0 -0
  111. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/trial.py +0 -0
  112. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/evals/trial_test.py +0 -0
  113. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/hooks.py +0 -0
  114. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/main.py +0 -0
  115. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/main_test.py +0 -0
  116. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/paths.py +0 -0
  117. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/paths_test.py +0 -0
  118. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/prompts/hooks/60-testing.md +0 -0
  119. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/prompts/hooks/90-roadmap.md +0 -0
  120. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/prompts/taskrunner.md +0 -0
  121. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/prompts.py +0 -0
  122. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/providers.py +0 -0
  123. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/providers_test.py +0 -0
  124. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/progress.py +0 -0
  125. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/progress_test.py +0 -0
  126. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/tasks/queries_test.py +0 -0
  127. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/favicon.js +0 -0
  128. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/favicon.spec.js +0 -0
  129. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/files.html +0 -0
  130. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/files.spec.js +0 -0
  131. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/logs.html +0 -0
  132. {lemming_cli-0.1.4 → lemming_cli-0.2.1}/src/lemming/web/logs.test.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lemming-cli
3
- Version: 0.1.4
3
+ Version: 0.2.1
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
@@ -15,7 +15,7 @@ Requires-Dist: fastapi>=0.124.4
15
15
  Requires-Dist: markdownify>=0.14.1
16
16
  Requires-Dist: pydantic>=2.12.5
17
17
  Requires-Dist: pyyaml>=6.0.3
18
- Requires-Dist: readability-cli>=0.4.0
18
+ Requires-Dist: readability-cli>=0.5.0
19
19
  Requires-Dist: requests>=2.32.3
20
20
  Requires-Dist: uvicorn>=0.33.0
21
21
  Description-Content-Type: text/markdown
@@ -186,11 +186,17 @@ Lemming comes with several built-in hooks to help manage your project:
186
186
  analyzes the results of the finished task and decides if the remaining roadmap
187
187
  needs to be adjusted (e.g., adding a missing prerequisite, skipping obsolete
188
188
  tasks, or breaking down a broad task).
189
- - **`readability`**: A code quality hook that reviews changes for adherence to
190
- the Google Style Guide and general readability using the
189
+ - **`readability`**: A code quality and simplification hook that challenges
190
+ unnecessary complexity and duplicate implementations, then reviews changes
191
+ for adherence to the Google Style Guide and general readability using the
191
192
  [readability](https://github.com/owahltinez/readability) tool (exposed as
192
193
  `lemming readability`). It can record findings as task progress or suggest
193
194
  follow-up refactoring tasks.
195
+ - **`testing`**: Verifies that changed behavior has focused test coverage and
196
+ that the relevant tests pass.
197
+ - **`ux`**: Reviews at most one critical user journey affected by a
198
+ user-visible change. It reports only concrete, reproducible continuity gaps
199
+ and exits immediately for non-user-facing tasks.
194
200
 
195
201
  ### Custom and Global Hooks
196
202
 
@@ -307,10 +313,12 @@ Lemming uses **fuzzy matching** to automatically inject the correct "YOLO"
307
313
  - **Antigravity (`agy`)**: Adds `--dangerously-skip-permissions`
308
314
  - **Aider**: Adds `--yes --quiet`
309
315
  - **Claude**: Adds `--dangerously-skip-permissions`
310
- - **Codex**: Adds `--yolo`
316
+ - **Codex**: Runs non-interactively via `codex exec`, adds `--json`, and, in
317
+ YOLO mode, adds `--dangerously-bypass-approvals-and-sandbox`
311
318
 
312
- You can disable this behavior with `--no-defaults`, or use a **template** to
313
- fully control the command layout:
319
+ You can disable default flag injection with `--no-defaults` (`codex exec`
320
+ remains the Codex execution interface), or use a **template** to fully control
321
+ the command layout:
314
322
 
315
323
  ```bash
316
324
  lemming run --runner "my-tool --input={{prompt}} --json"
@@ -164,11 +164,17 @@ Lemming comes with several built-in hooks to help manage your project:
164
164
  analyzes the results of the finished task and decides if the remaining roadmap
165
165
  needs to be adjusted (e.g., adding a missing prerequisite, skipping obsolete
166
166
  tasks, or breaking down a broad task).
167
- - **`readability`**: A code quality hook that reviews changes for adherence to
168
- the Google Style Guide and general readability using the
167
+ - **`readability`**: A code quality and simplification hook that challenges
168
+ unnecessary complexity and duplicate implementations, then reviews changes
169
+ for adherence to the Google Style Guide and general readability using the
169
170
  [readability](https://github.com/owahltinez/readability) tool (exposed as
170
171
  `lemming readability`). It can record findings as task progress or suggest
171
172
  follow-up refactoring tasks.
173
+ - **`testing`**: Verifies that changed behavior has focused test coverage and
174
+ that the relevant tests pass.
175
+ - **`ux`**: Reviews at most one critical user journey affected by a
176
+ user-visible change. It reports only concrete, reproducible continuity gaps
177
+ and exits immediately for non-user-facing tasks.
172
178
 
173
179
  ### Custom and Global Hooks
174
180
 
@@ -285,10 +291,12 @@ Lemming uses **fuzzy matching** to automatically inject the correct "YOLO"
285
291
  - **Antigravity (`agy`)**: Adds `--dangerously-skip-permissions`
286
292
  - **Aider**: Adds `--yes --quiet`
287
293
  - **Claude**: Adds `--dangerously-skip-permissions`
288
- - **Codex**: Adds `--yolo`
294
+ - **Codex**: Runs non-interactively via `codex exec`, adds `--json`, and, in
295
+ YOLO mode, adds `--dangerously-bypass-approvals-and-sandbox`
289
296
 
290
- You can disable this behavior with `--no-defaults`, or use a **template** to
291
- fully control the command layout:
297
+ You can disable default flag injection with `--no-defaults` (`codex exec`
298
+ remains the Codex execution interface), or use a **template** to fully control
299
+ the command layout:
292
300
 
293
301
  ```bash
294
302
  lemming run --runner "my-tool --input={{prompt}} --json"
@@ -51,15 +51,30 @@ a broad task).
51
51
 
52
52
  ### `readability`
53
53
 
54
- A code quality hook that reviews changes for adherence to the Google Style Guide
55
- and general readability using the bundled `lemming readability` tool. It
56
- provides feedback via task progress or suggests follow-up refactoring tasks.
54
+ A code quality and simplification hook that challenges unnecessary complexity,
55
+ indirection, and duplicated implementations before reviewing changes for
56
+ adherence to the Google Style Guide and general readability using the bundled
57
+ `lemming readability` tool. It provides feedback via task progress or suggests
58
+ follow-up refactoring tasks.
59
+
60
+ ### `testing`
61
+
62
+ Checks that changed behavior has focused test coverage and runs the relevant
63
+ tests. It records larger testing gaps as task progress for the roadmap hook.
64
+
65
+ ### `ux`
66
+
67
+ A lightweight journey-continuity review for user-visible changes. It considers
68
+ at most one affected critical journey, reports only concrete and reproducible
69
+ gaps, and exits immediately when the task did not change user-facing behavior.
70
+ It runs after `testing` and before `roadmap`.
57
71
 
58
72
  ### Editing Built-in Hook Prompts
59
73
 
60
74
  The built-in hook prompts are load-bearing: a wording change can regress
61
75
  orchestration behavior without any unit test failing. When you edit a prompt
62
- under `src/lemming/prompts/hooks/`, run its eval suite before shipping:
76
+ under `src/lemming/prompts/hooks/` that has an eval suite, run it before
77
+ shipping:
63
78
 
64
79
  ```bash
65
80
  uv run python -m lemming.evals run --suite roadmap
@@ -12,7 +12,7 @@
12
12
  "@biomejs/biome": "1.9.4",
13
13
  "@playwright/test": "^1.60.0",
14
14
  "jsdom": "latest",
15
- "mancha": "^0.22.4",
15
+ "mancha": "^0.22.7",
16
16
  "playwright": "^1.60.0"
17
17
  }
18
18
  },
@@ -1019,9 +1019,9 @@
1019
1019
  }
1020
1020
  },
1021
1021
  "node_modules/mancha": {
1022
- "version": "0.22.4",
1023
- "resolved": "https://registry.npmjs.org/mancha/-/mancha-0.22.4.tgz",
1024
- "integrity": "sha512-bNurE3cVS3ZoOow3hhkJiJcxRkXCZrWh4NaVVlV1KYFe3iY3wrcAqk/HJKMHw0kglcQjUc2iAtJbemI2oJKIUg==",
1022
+ "version": "0.22.7",
1023
+ "resolved": "https://registry.npmjs.org/mancha/-/mancha-0.22.7.tgz",
1024
+ "integrity": "sha512-ZClxBTYOKSD9f4KqW8lCMYVEUPhxzIRgihw5g20QrD4kzZ9JcQ383LXXkPMVTaCAjLlqYaF3brUMYACwWgQZgA==",
1025
1025
  "dev": true,
1026
1026
  "license": "MIT",
1027
1027
  "dependencies": {
@@ -16,7 +16,7 @@
16
16
  "@biomejs/biome": "1.9.4",
17
17
  "@playwright/test": "^1.60.0",
18
18
  "jsdom": "latest",
19
- "mancha": "^0.22.4",
19
+ "mancha": "^0.22.7",
20
20
  "playwright": "^1.60.0"
21
21
  }
22
22
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lemming-cli"
7
- version = "0.1.4"
7
+ version = "0.2.1"
8
8
  description = "An autonomous, iterative task runner for AI coding agents."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "markdownify>=0.14.1",
21
21
  "pydantic>=2.12.5",
22
22
  "pyyaml>=6.0.3",
23
- "readability-cli>=0.4.0",
23
+ "readability-cli>=0.5.0",
24
24
  "requests>=2.32.3",
25
25
  "uvicorn>=0.33.0",
26
26
  ]
@@ -15,6 +15,9 @@ def test_list_hooks(client, test_tasks, monkeypatch, tmp_path):
15
15
  assert hooks["roadmap"]["masked"] is False
16
16
  assert hooks["readability"]["source"] == "built-in"
17
17
  assert hooks["readability"]["runs_on_failure"] is False
18
+ assert hooks["ux"]["priority"] == 70
19
+ assert hooks["ux"]["source"] == "built-in"
20
+ assert hooks["ux"]["runs_on_failure"] is False
18
21
 
19
22
  # Hooks are sorted by ascending priority
20
23
  priorities = [h["priority"] for h in payload]
@@ -34,14 +34,17 @@ def add_task(
34
34
  ):
35
35
  """Add a new task and start the orchestrator loop if needed."""
36
36
  tasks_file = context.resolve_tasks_file(request.app.state, project)
37
- new_task = tasks.add_task(
38
- tasks_file,
39
- task.description,
40
- task.runner,
41
- index=task.index,
42
- parent=task.parent,
43
- parent_tasks_file=task.parent_tasks_file,
44
- )
37
+ try:
38
+ new_task = tasks.add_task(
39
+ tasks_file,
40
+ task.description,
41
+ task.runner,
42
+ index=task.index,
43
+ parent=task.parent,
44
+ parent_tasks_file=task.parent_tasks_file,
45
+ )
46
+ except ValueError as e:
47
+ raise fastapi.HTTPException(400, str(e))
45
48
  loop.start_loop_if_needed(
46
49
  request.app.state,
47
50
  tasks_file,
@@ -18,13 +18,7 @@ from .main import cli
18
18
  )
19
19
  @click.pass_context
20
20
  def goal(ctx: click.Context, goal_text: str | None, file: pathlib.Path | None):
21
- """Sets or displays the long-term goal shared across all tasks.
22
-
23
- Args:
24
- ctx: The click context holding shared CLI state.
25
- goal_text: The goal string to set (optional).
26
- file: A file path to read the goal from (optional).
27
- """
21
+ """Sets or displays the long-term goal shared across all tasks."""
28
22
  tasks_file = ctx.obj["TASKS_FILE"]
29
23
 
30
24
  if file:
@@ -16,6 +16,14 @@ class TestCLIMain(unittest.TestCase):
16
16
  "Lemming: An autonomous, iterative task runner", result.output
17
17
  )
18
18
 
19
+ def test_command_help_hides_internal_docstrings(self):
20
+ for command_name in cli.commands:
21
+ with self.subTest(command=command_name):
22
+ result = self.cli_runner.invoke(cli, [command_name, "--help"])
23
+ self.assertEqual(result.exit_code, 0)
24
+ self.assertNotIn("Args:", result.output)
25
+ self.assertNotIn("ctx: The click context", result.output)
26
+
19
27
 
20
28
  if __name__ == "__main__":
21
29
  unittest.main()
@@ -21,9 +21,7 @@ from .main import cli
21
21
  @click.option(
22
22
  "--retry-delay",
23
23
  default=10,
24
- help=(
25
- "Seconds to wait before retrying a failed task (to handle rate limits)."
26
- ),
24
+ help="Seconds to wait before retrying an incomplete task.",
27
25
  )
28
26
  @click.option(
29
27
  "--yolo/--no-yolo",
@@ -50,16 +48,7 @@ def run(
50
48
  no_defaults: bool,
51
49
  runner_args: tuple,
52
50
  ) -> None:
53
- """Starts the orchestrator loop to autonomously execute pending tasks.
54
-
55
- Args:
56
- ctx: The click context.
57
- retry_delay: Delay between retries.
58
- yolo: If True, skip runner confirmations.
59
- env: Environment variables to inject.
60
- no_defaults: Skip default flag injection.
61
- runner_args: Raw arguments passed directly to the runner.
62
- """
51
+ """Starts the orchestrator loop to autonomously execute pending tasks."""
63
52
  tasks_file = ctx.obj["TASKS_FILE"]
64
53
  verbose = ctx.obj["VERBOSE"]
65
54
 
@@ -78,9 +67,14 @@ def run(
78
67
  if env_overrides:
79
68
  os.environ.update(env_overrides)
80
69
 
81
- tasks.acquire_loop_lock(tasks_file)
70
+ completed = False
71
+ try:
72
+ tasks.acquire_loop_lock(tasks_file)
73
+ except tasks.LoopAlreadyRunningError as e:
74
+ click.echo(f"Error: {e}")
75
+ ctx.exit(1)
82
76
  try:
83
- run_loop(
77
+ completed = run_loop(
84
78
  tasks_file,
85
79
  verbose,
86
80
  retry_delay,
@@ -91,6 +85,8 @@ def run(
91
85
  )
92
86
  finally:
93
87
  tasks.release_loop_lock(tasks_file)
88
+ if not completed:
89
+ ctx.exit(1)
94
90
 
95
91
 
96
92
  @cli.command(short_help="Launch the web interface")
@@ -0,0 +1,93 @@
1
+ import pathlib
2
+ import shutil
3
+ import tempfile
4
+ import time
5
+ import unittest
6
+ from unittest import mock
7
+
8
+ import click.testing
9
+
10
+ from lemming import cli, tasks
11
+
12
+
13
+ class TestCLIOperations(unittest.TestCase):
14
+ def setUp(self):
15
+ self.cli_runner = click.testing.CliRunner()
16
+ self.test_dir = tempfile.mkdtemp()
17
+ self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
18
+ self.base_args = [
19
+ "--verbose",
20
+ "--tasks-file",
21
+ str(self.test_tasks_file),
22
+ ]
23
+
24
+ # Scaffold a valid file
25
+ data = tasks.Roadmap(
26
+ goal="Initial goal",
27
+ tasks=[],
28
+ )
29
+ tasks.save_tasks(self.test_tasks_file, data)
30
+
31
+ def tearDown(self):
32
+ shutil.rmtree(self.test_dir)
33
+
34
+ def test_run_help(self):
35
+ result = self.cli_runner.invoke(cli.cli, ["run", "--help"])
36
+ self.assertEqual(result.exit_code, 0)
37
+ self.assertIn("Starts the orchestrator loop", result.output)
38
+
39
+ def test_run_empty_queue_reports_completion(self):
40
+ result = self.cli_runner.invoke(cli.cli, self.base_args + ["run"])
41
+
42
+ self.assertEqual(result.exit_code, 0)
43
+ self.assertIn("All tasks completed!", result.output)
44
+
45
+ def test_run_blocked_queue_exits_nonzero(self):
46
+ tasks.save_tasks(
47
+ self.test_tasks_file,
48
+ tasks.Roadmap(
49
+ tasks=[
50
+ tasks.Task(
51
+ id="active123",
52
+ description="Active task",
53
+ status=tasks.TaskStatus.IN_PROGRESS,
54
+ pid=1234,
55
+ last_heartbeat=time.time(),
56
+ ),
57
+ tasks.Task(
58
+ id="pending456",
59
+ description="Pending task",
60
+ status=tasks.TaskStatus.PENDING,
61
+ ),
62
+ ]
63
+ ),
64
+ )
65
+
66
+ with mock.patch(
67
+ "lemming.tasks.lifecycle.is_pid_alive", return_value=True
68
+ ):
69
+ result = self.cli_runner.invoke(cli.cli, self.base_args + ["run"])
70
+
71
+ self.assertEqual(result.exit_code, 1)
72
+ self.assertIn("Queue blocked by active task active123", result.output)
73
+ self.assertIn("1 pending task remains", result.output)
74
+ self.assertNotIn("All tasks completed!", result.output)
75
+
76
+ def test_run_rejects_live_loop_owner(self):
77
+ tasks.acquire_loop_lock(self.test_tasks_file)
78
+ try:
79
+ result = self.cli_runner.invoke(cli.cli, self.base_args + ["run"])
80
+ finally:
81
+ tasks.release_loop_lock(self.test_tasks_file)
82
+
83
+ self.assertEqual(result.exit_code, 1)
84
+ self.assertIn("Another loop is already running", result.output)
85
+
86
+ def test_serve_help(self):
87
+ result = self.cli_runner.invoke(cli.cli, ["serve", "--help"])
88
+ self.assertEqual(result.exit_code, 0)
89
+ self.assertIn("Launches the local web dashboard", result.output)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ unittest.main()
@@ -24,14 +24,7 @@ def progress(
24
24
  text: typing.Optional[str],
25
25
  file: typing.Optional[typing.TextIO],
26
26
  ):
27
- """Records a progress entry or finding for a specific task.
28
-
29
- Args:
30
- ctx: The click context holding shared CLI state.
31
- task_id: The ID of the task.
32
- text: The progress to record (optional if --file is used).
33
- file: An optional file to read the progress from.
34
- """
27
+ """Records a progress entry or finding for a specific task."""
35
28
  tasks_file = ctx.obj["TASKS_FILE"]
36
29
 
37
30
  if file:
@@ -20,7 +20,7 @@ from .main import cli
20
20
  @click.option(
21
21
  "--index",
22
22
  default=-1,
23
- help="Index to insert the task at (defaults to -1, the end).",
23
+ help="Zero-based displayed queue position (-1 appends).",
24
24
  )
25
25
  @click.option(
26
26
  "--runner",
@@ -47,18 +47,7 @@ def add(
47
47
  parent: str | None,
48
48
  parent_tasks_file: str | None,
49
49
  ):
50
- """Adds a new task to the roadmap queue.
51
-
52
- Args:
53
- ctx: The click context.
54
- description: A text description of the task to perform (optional if
55
- --file is used).
56
- file: An optional file to read the description from.
57
- index: The position in the roadmap to insert the task.
58
- runner_name: An optional custom runner to use for this specific task.
59
- parent: Optional parent task ID.
60
- parent_tasks_file: Optional parent tasks file path.
61
- """
50
+ """Adds a new task to the roadmap queue."""
62
51
  tasks_file = ctx.obj["TASKS_FILE"]
63
52
  verbose = ctx.obj["VERBOSE"]
64
53
 
@@ -74,14 +63,18 @@ def add(
74
63
  click.echo("Error: Must provide either description or --file.")
75
64
  ctx.exit(1)
76
65
 
77
- new_task = tasks.add_task(
78
- tasks_file,
79
- description,
80
- runner_name,
81
- index=index,
82
- parent=parent,
83
- parent_tasks_file=parent_tasks_file,
84
- )
66
+ try:
67
+ new_task = tasks.add_task(
68
+ tasks_file,
69
+ description,
70
+ runner_name,
71
+ index=index,
72
+ parent=parent,
73
+ parent_tasks_file=parent_tasks_file,
74
+ )
75
+ except ValueError as e:
76
+ click.echo(f"Error: {e}")
77
+ ctx.exit(1)
85
78
  task_id = new_task.id
86
79
 
87
80
  if verbose:
@@ -100,7 +93,11 @@ def add(
100
93
  help="Read new description from a file (or - for stdin).",
101
94
  )
102
95
  @click.option("--runner", "runner_name", help="New custom runner for the task.")
103
- @click.option("--index", type=int, help="New index in the task queue.")
96
+ @click.option(
97
+ "--index",
98
+ type=int,
99
+ help="New zero-based position in the displayed task queue.",
100
+ )
104
101
  @click.option(
105
102
  "--parent",
106
103
  help="New parent task ID (use empty string to remove).",
@@ -120,18 +117,7 @@ def edit(
120
117
  parent: str | None,
121
118
  parent_tasks_file: str | None,
122
119
  ):
123
- """Edits an existing task's description, runner, position, or parent.
124
-
125
- Args:
126
- ctx: The click context.
127
- task_id: The ID of the task to update.
128
- description: The new description (optional).
129
- file: An optional file to read the new description from.
130
- runner_name: The new preferred runner (optional).
131
- index: The new position in the roadmap (optional).
132
- parent: The new parent task ID (optional).
133
- parent_tasks_file: The new parent tasks file path (optional).
134
- """
120
+ """Edits an existing task's description, runner, position, or parent."""
135
121
  if file:
136
122
  if description:
137
123
  click.echo("Error: Cannot provide both description and --file.")
@@ -184,14 +170,7 @@ def edit(
184
170
  def delete_task(
185
171
  ctx: click.Context, task_id: str | None, delete_all: bool, completed: bool
186
172
  ):
187
- """Deletes one or more tasks from the roadmap.
188
-
189
- Args:
190
- ctx: The click context.
191
- task_id: The ID of the specific task to delete.
192
- delete_all: If set, clears the entire roadmap and long-term goal.
193
- completed: If set, deletes all tasks marked as 'completed'.
194
- """
173
+ """Deletes one or more tasks from the roadmap."""
195
174
  tasks_file = ctx.obj["TASKS_FILE"]
196
175
 
197
176
  # Validate argument combinations
@@ -205,12 +184,16 @@ def delete_task(
205
184
  click.echo("Error: Provide a task ID, or use --all or --completed.")
206
185
  ctx.exit(1)
207
186
 
208
- removed = tasks.delete_tasks(
209
- tasks_file,
210
- task_id=task_id,
211
- all_tasks=delete_all,
212
- completed_only=completed,
213
- )
187
+ try:
188
+ removed = tasks.delete_tasks(
189
+ tasks_file,
190
+ task_id=task_id,
191
+ all_tasks=delete_all,
192
+ completed_only=completed,
193
+ )
194
+ except ValueError as e:
195
+ click.echo(f"Error: {e}")
196
+ ctx.exit(1)
214
197
 
215
198
  if delete_all:
216
199
  click.echo(
@@ -229,12 +212,7 @@ def delete_task(
229
212
  @click.argument("task_id", required=False)
230
213
  @click.pass_context
231
214
  def status(ctx: click.Context, task_id: str | None):
232
- """Displays the roadmap status or details for a specific task.
233
-
234
- Args:
235
- ctx: The click context.
236
- task_id: Optional ID of the task to inspect in detail.
237
- """
215
+ """Displays the roadmap status or details for a specific task."""
238
216
  tasks_file = ctx.obj["TASKS_FILE"]
239
217
  verbose = ctx.obj["VERBOSE"]
240
218
  project_data = tasks.get_project_data(tasks_file)
@@ -422,24 +400,47 @@ def logs(ctx: click.Context, task_id: str | None):
422
400
 
423
401
  @cli.command(short_help="<taskid> Mark a task as completed")
424
402
  @click.argument("task_id")
403
+ @click.option(
404
+ "--force",
405
+ is_flag=True,
406
+ help="Finalize an in-progress task immediately, without running hooks.",
407
+ )
425
408
  @click.pass_context
426
- def complete(ctx: click.Context, task_id: str):
427
- """Marks a task as completed (requires at least one progress entry).
428
-
429
- Args:
430
- ctx: The click context.
431
- task_id: The ID of the task to mark as completed.
432
- """
409
+ def complete(ctx: click.Context, task_id: str, force: bool):
410
+ """Marks a task as completed (requires at least one progress entry)."""
433
411
  tasks_file = ctx.obj["TASKS_FILE"]
434
412
 
435
413
  try:
414
+ data = tasks.load_tasks(tasks_file)
415
+ current_task = tasks.resolve_task(data.tasks, task_id)
416
+ task_is_active = tasks.is_task_active(current_task, time.time())
417
+ if (
418
+ current_task.status == tasks.TaskStatus.IN_PROGRESS
419
+ and current_task.progress
420
+ and not force
421
+ and not task_is_active
422
+ and not tasks.is_loop_running(tasks_file)
423
+ ):
424
+ raise ValueError(
425
+ f"Task {current_task.id} is in progress, but no active runner "
426
+ "or loop can finalize it. Use --force to complete it without "
427
+ "running hooks, or reset it to retry."
428
+ )
429
+
436
430
  target_task = tasks.update_task(
437
431
  tasks_file,
438
432
  task_id,
439
433
  status=tasks.TaskStatus.COMPLETED,
440
434
  require_progress=True,
435
+ force=force,
441
436
  )
442
- click.echo(f"Task {target_task.id} marked as completed.")
437
+ if target_task.requested_status == tasks.TaskStatus.COMPLETED:
438
+ click.echo(
439
+ f"Task {target_task.id} completion requested; "
440
+ "finalization hooks are pending."
441
+ )
442
+ else:
443
+ click.echo(f"Task {target_task.id} marked as completed.")
443
444
  except ValueError as e:
444
445
  click.echo(f"Error: {e}")
445
446
  ctx.exit(1)
@@ -449,12 +450,7 @@ def complete(ctx: click.Context, task_id: str):
449
450
  @click.argument("task_id")
450
451
  @click.pass_context
451
452
  def uncomplete(ctx: click.Context, task_id: str):
452
- """Unmarks a completed task, moving it back to 'pending' status.
453
-
454
- Args:
455
- ctx: The click context.
456
- task_id: The ID of the task to uncomplete.
457
- """
453
+ """Unmarks a completed task, moving it back to 'pending' status."""
458
454
  tasks_file = ctx.obj["TASKS_FILE"]
459
455
  try:
460
456
  target_task = tasks.update_task(
@@ -470,12 +466,7 @@ def uncomplete(ctx: click.Context, task_id: str):
470
466
  @click.argument("task_id")
471
467
  @click.pass_context
472
468
  def fail(ctx: click.Context, task_id: str):
473
- """Marks a task as failed (requires at least one recorded progress entry).
474
-
475
- Args:
476
- ctx: The click context.
477
- task_id: The ID of the task to mark as failed.
478
- """
469
+ """Marks a task as failed (requires recorded progress)."""
479
470
  tasks_file = ctx.obj["TASKS_FILE"]
480
471
  try:
481
472
  target_task = tasks.update_task(
@@ -494,12 +485,7 @@ def fail(ctx: click.Context, task_id: str):
494
485
  @click.argument("task_id")
495
486
  @click.pass_context
496
487
  def cancel(ctx: click.Context, task_id: str):
497
- """Kills the runner for an in-progress task and marks it as cancelled.
498
-
499
- Args:
500
- ctx: The click context.
501
- task_id: The ID of the task to cancel.
502
- """
488
+ """Kills the runner and cancels an in-progress task."""
503
489
  tasks_file = ctx.obj["TASKS_FILE"]
504
490
  if tasks.cancel_task(tasks_file, task_id):
505
491
  click.echo(f"Task {task_id} cancelled.")
@@ -512,12 +498,7 @@ def cancel(ctx: click.Context, task_id: str):
512
498
  @click.argument("task_id")
513
499
  @click.pass_context
514
500
  def reset(ctx: click.Context, task_id: str):
515
- """Clears all history (attempts, progress, and logs) for a specific task.
516
-
517
- Args:
518
- ctx: The click context.
519
- task_id: The ID of the task to reset.
520
- """
501
+ """Clears a task's attempts, progress, and logs."""
521
502
  tasks_file = ctx.obj["TASKS_FILE"]
522
503
  try:
523
504
  target_task = tasks.reset_task(tasks_file, task_id)