lemming-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,69 @@
1
+ # Task Runner
2
+
3
+ You are an autonomous AI coding agent managed by the 'Lemming' orchestrator.
4
+
5
+ ## The Project Roadmap
6
+
7
+ {{roadmap}}{{progress}}
8
+
9
+ ## Your Assignment
10
+
11
+ Your CURRENT, EXCLUSIVE task is: **{{description}}**
12
+
13
+ ## Critical Directives
14
+
15
+ 1. **Execute:** Write the code to fulfill the current task. Run any necessary
16
+ tests. **Your operations should be idempotent** — your process may be killed
17
+ at any time (e.g. due to a timeout) and the task retried from scratch. Design
18
+ your work so that re-running it on a partially modified workspace produces
19
+ the correct result: check whether changes already exist before applying them,
20
+ use create-or-update patterns, and avoid operations that fail if run twice.
21
+ 2. **DO NOT edit `{{tasks_file_name}}` directly.** You must use the Lemming CLI
22
+ API.
23
+ 3. **Task Management:** You may manipulate the task list to add or insert new
24
+ tasks at any position in the queue. However, you should generally only do
25
+ this if it is explicitly requested by the user or clearly necessary to
26
+ complete your current assignment. Use
27
+ `lemming --tasks-file {{tasks_file_path}} --help` for the full list of
28
+ available commands. Assume the Lemming queue is in a running state and will
29
+ pick up new tasks automatically.
30
+ - **Logs:** If this is a retry or you need context from a previous task's
31
+ execution, you can read the full runner log with
32
+ `lemming --tasks-file {{tasks_file_path}} logs [<id>]`. If no ID is
33
+ provided, it shows the log for the currently active task.
34
+
35
+ - **Context Isolation:** Be aware that newly scheduled tasks will start with
36
+ a fresh, empty conversation history. Their only context is the global
37
+ roadmap, previously recorded progress from completed tasks, and the state
38
+ of the file system.
39
+ - **Self-Contained Descriptions:** Because of this isolation, you MUST write
40
+ extremely thorough, self-contained descriptions for any new tasks you
41
+ schedule. Reference specific task IDs, file paths, and exact symbols so the
42
+ new task knows exactly what to do.
43
+ - **Complex Handoffs:** If you need to pass extensive context (like detailed
44
+ architectural plans, specific error traces, or large code snippets) to a
45
+ downstream task, do not rely on the brief progress messages alone. Instead,
46
+ create a dedicated file in the workspace to hold this context, and
47
+ explicitly mention its path in the new task's description. Use this
48
+ technique judiciously to avoid creating a mess of orphaned files in the
49
+ project.
50
+
51
+ 4. **Progress:** Your first action should be to record a brief progress entry
52
+ describing how you intend to approach this task — e.g. which files you will
53
+ modify, what strategy you will use. This lets the operator verify your
54
+ direction early. Then continue recording progress as you work: each time you
55
+ complete a meaningful step, discover something relevant, or hit a problem,
56
+ record it immediately. If your process is killed, recorded progress carries
57
+ over to the next attempt. At least one progress entry is required before
58
+ completing or failing a task:
59
+ `lemming --tasks-file {{tasks_file_path}} progress {{task_id}} '<what you did or found>'`
60
+ 5. **Success:** When you have completely finished and verified the task, and
61
+ recorded ALL relevant progress (at least one is required), run:
62
+ `lemming --tasks-file {{tasks_file_path}} complete {{task_id}}`
63
+ 6. **Failure/Blocker:** If you hit a technical roadblock, cannot fix a bug, or
64
+ are unable to complete the task, after recording ALL relevant progress (at
65
+ least one is required), run:
66
+ `lemming --tasks-file {{tasks_file_path}} fail {{task_id}}`
67
+
68
+ 7. Stop and exit after running either the complete or fail command.
69
+ {{time_limit_section}}
lemming/prompts.py ADDED
@@ -0,0 +1,354 @@
1
+ """Prompt template loading and rendering for runners and orchestrator hooks."""
2
+
3
+ import logging
4
+ import pathlib
5
+
6
+ from . import paths, runner, tasks
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def ensure_hooks_symlinked():
12
+ """Ensures built-in hooks are symlinked in the global hooks directory.
13
+
14
+ If a hook already exists (as a file or symlink), it is not overwritten.
15
+ This allows users to override them by replacing the symlink with their
16
+ own file.
17
+ """
18
+ global_hooks_dir = paths.get_global_hooks_dir()
19
+ global_hooks_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ base_path = pathlib.Path(__file__).parent / "prompts" / "hooks"
22
+ if not base_path.exists():
23
+ return
24
+
25
+ for f in base_path.glob("*.md"):
26
+ target = global_hooks_dir / f.name
27
+ # We only create the symlink if NOTHING exists there yet.
28
+ # This respects manual deletion as a way to "disable" the global
29
+ # symlink, but the hook will still be available as a built-in
30
+ # fallback unless disabled in the project's tasks.yml.
31
+ if not target.exists() and not target.is_symlink():
32
+ try:
33
+ target.symlink_to(f.absolute())
34
+ except (OSError, PermissionError) as e:
35
+ logger.error(
36
+ "Failed to create symlink for hook %s: %s", target, e
37
+ )
38
+ # If we hit permission issues, we want to know, not just
39
+ # skip silently
40
+ if isinstance(e, PermissionError):
41
+ raise e
42
+
43
+
44
+ def load_prompt(name: str, tasks_file: pathlib.Path | None = None) -> str:
45
+ """Loads a prompt template from the project, global, or built-in hooks.
46
+
47
+ Args:
48
+ name: Name of the prompt template (without .md extension).
49
+ tasks_file: Optional path to the tasks file to look for local hooks.
50
+
51
+ Returns:
52
+ The content of the prompt template.
53
+
54
+ Raises:
55
+ FileNotFoundError: If the prompt template does not exist.
56
+ """
57
+ # 1. Look for local hooks in the project directory
58
+ if tasks_file:
59
+ working_dir = paths.get_working_dir(tasks_file)
60
+ local_hook_path = working_dir / ".lemming" / "hooks" / f"{name}.md"
61
+ if local_hook_path.exists():
62
+ return local_hook_path.read_text(encoding="utf-8")
63
+
64
+ # 2. Look in global hooks directory (~/.local/lemming/hooks)
65
+ global_hooks_dir = paths.get_global_hooks_dir()
66
+ global_hook_path = global_hooks_dir / f"{name}.md"
67
+ if global_hook_path.exists():
68
+ content = global_hook_path.read_text(encoding="utf-8")
69
+ if content.strip():
70
+ return content
71
+
72
+ # 3. Look in built-in prompts directory (fallback)
73
+ base_path = pathlib.Path(__file__).parent / "prompts"
74
+
75
+ # Try exact name first (e.g. taskrunner)
76
+ path = base_path / f"{name}.md"
77
+ if path.exists():
78
+ return path.read_text(encoding="utf-8")
79
+
80
+ # Try hooks subdirectory
81
+ path = base_path / "hooks" / f"{name}.md"
82
+ if path.exists():
83
+ return path.read_text(encoding="utf-8")
84
+
85
+ raise FileNotFoundError(f"Prompt template {name} not found")
86
+
87
+
88
+ def list_hooks(tasks_file: pathlib.Path | None = None) -> list[str]:
89
+ """Lists available orchestrator hooks.
90
+
91
+ Args:
92
+ tasks_file: Optional path to the tasks file to look for local hooks.
93
+
94
+ Returns:
95
+ A list of hook names.
96
+ """
97
+ hooks = set()
98
+
99
+ # 1. Look for local hooks in the project directory
100
+ if tasks_file:
101
+ working_dir = paths.get_working_dir(tasks_file)
102
+ local_hooks_dir = working_dir / ".lemming" / "hooks"
103
+ if local_hooks_dir.exists():
104
+ for f in local_hooks_dir.glob("*.md"):
105
+ hooks.add(f.stem)
106
+
107
+ # 2. Look in global hooks directory
108
+ global_hooks_dir = paths.get_global_hooks_dir()
109
+ if global_hooks_dir.exists():
110
+ for f in global_hooks_dir.glob("*.md"):
111
+ hooks.add(f.stem)
112
+
113
+ # 3. Look in built-in prompts directory (always included)
114
+ base_path = pathlib.Path(__file__).parent / "prompts" / "hooks"
115
+ if base_path.exists():
116
+ for f in base_path.glob("*.md"):
117
+ hooks.add(f.stem)
118
+
119
+ # Ensure 'roadmap' is always last
120
+ hooks_list = sorted(list(hooks))
121
+ if "roadmap" in hooks_list:
122
+ hooks_list.remove("roadmap")
123
+ hooks_list.append("roadmap")
124
+
125
+ return hooks_list
126
+
127
+
128
+ def _format_roadmap(
129
+ data: tasks.Roadmap, current_task_id: str | None = None
130
+ ) -> str:
131
+ """Formats the roadmap for inclusion in prompts.
132
+
133
+ Args:
134
+ data: The current Roadmap.
135
+ current_task_id: ID of the task currently being executed, if any.
136
+
137
+ Returns:
138
+ A formatted roadmap string.
139
+ """
140
+ roadmap_str = (
141
+ f"## Project Context\n{data.context or 'No context provided.'}\n\n"
142
+ )
143
+ roadmap_str += "## Roadmap\n"
144
+
145
+ completed_tasks = [
146
+ t
147
+ for t in data.tasks
148
+ if (t.requested_status or t.status) == tasks.TaskStatus.COMPLETED
149
+ ]
150
+
151
+ for i, t in enumerate(data.tasks):
152
+ effective_status = t.requested_status or t.status
153
+ if effective_status == tasks.TaskStatus.COMPLETED:
154
+ marker = "[COMPLETED]"
155
+ elif effective_status == tasks.TaskStatus.FAILED:
156
+ marker = f"[FAILED - {t.attempts}/{data.config.retries} attempt(s)]"
157
+ elif (
158
+ t.status == tasks.TaskStatus.IN_PROGRESS or t.id == current_task_id
159
+ ):
160
+ marker = "[IN PROGRESS]"
161
+ elif t.attempts > 0:
162
+ marker = (
163
+ f"[PENDING - {t.attempts}/{data.config.retries}"
164
+ " attempt(s) so far]"
165
+ )
166
+ else:
167
+ marker = "[PENDING]"
168
+
169
+ # Bold the current task to make it stand out
170
+ if t.id == current_task_id:
171
+ roadmap_str += f"- **{marker} ({t.id}) {t.description}**\n"
172
+ else:
173
+ roadmap_str += f"- {marker} ({t.id}) {t.description}\n"
174
+
175
+ if t.progress:
176
+ # For completed tasks, only show progress for the last 5 to
177
+ # keep the prompt concise
178
+ if effective_status == tasks.TaskStatus.COMPLETED:
179
+ completed_index = completed_tasks.index(t)
180
+ if len(completed_tasks) - completed_index <= 5:
181
+ for o in t.progress:
182
+ roadmap_str += f" - {o}\n"
183
+ else:
184
+ for o in t.progress:
185
+ roadmap_str += f" - {o}\n"
186
+
187
+ return roadmap_str
188
+
189
+
190
+ def prepare_hook_prompt(
191
+ hook_name: str,
192
+ data: tasks.Roadmap,
193
+ finished_task: tasks.Task,
194
+ tasks_file: pathlib.Path,
195
+ ) -> str:
196
+ """Prepares the prompt for a specific orchestrator hook.
197
+
198
+ Args:
199
+ hook_name: Name of the orchestrator hook (e.g. "roadmap").
200
+ data: The current Roadmap.
201
+ finished_task: The Task that just finished executing.
202
+ tasks_file: Path to the tasks YAML file.
203
+
204
+ Returns:
205
+ The fully rendered hook prompt string.
206
+ """
207
+ roadmap_str = _format_roadmap(data, current_task_id=finished_task.id)
208
+
209
+ # Use requested_status when available — it reflects the actual outcome
210
+ # (e.g. FAILED) while status may still be IN_PROGRESS during hook execution.
211
+ result_status = finished_task.requested_status or finished_task.status
212
+
213
+ finished_str = f"Task ID: {finished_task.id}\n"
214
+ finished_str += f"Description: {finished_task.description}\n"
215
+ finished_str += f"Result: {result_status}\n"
216
+ finished_str += (
217
+ f"Attempts: {finished_task.attempts}/{data.config.retries}\n"
218
+ )
219
+
220
+ if (
221
+ finished_task.attempts >= data.config.retries
222
+ and result_status == tasks.TaskStatus.FAILED
223
+ ):
224
+ finished_str += "\n!!! WARNING: FINAL ATTEMPT FAILED !!!\n"
225
+ finished_str += (
226
+ f"This task has reached the maximum of"
227
+ f" {data.config.retries} attempts.\n"
228
+ )
229
+ finished_str += (
230
+ "Unless you intervene NOW (by resetting it with a new approach,\n"
231
+ )
232
+ finished_str += (
233
+ "editing it, or replacing it), the entire orchestrator loop will\n"
234
+ )
235
+ finished_str += "ABORT and the project will fail.\n"
236
+
237
+ if finished_task.progress:
238
+ finished_str += "Progress recorded during this attempt:\n"
239
+ for o in finished_task.progress:
240
+ finished_str += f"- {o}\n"
241
+
242
+ # Include the last 100 lines of the runner log for the finished task.
243
+ # We filter out 'Command:' lines because they contain the full previous
244
+ # prompt and cause exponential escaping growth when prompts are re-quoted.
245
+ log_file = paths.get_log_file(tasks_file, finished_task.id)
246
+ if log_file.exists():
247
+ try:
248
+ with open(log_file, "r", encoding="utf-8") as f:
249
+ lines = f.readlines()
250
+ filtered = [
251
+ line for line in lines if not line.startswith("Command: ")
252
+ ]
253
+ last_lines = [line.rstrip() for line in filtered[-100:]]
254
+ finished_str += (
255
+ "\nExecution log of THIS task (last 100 lines):\n"
256
+ )
257
+ finished_str += "```\n"
258
+ finished_str += "\n".join(last_lines)
259
+ finished_str += "\n```\n"
260
+ except Exception as e:
261
+ finished_str += f"\n(Could not read log file: {e})\n"
262
+
263
+ tasks_file_str = runner._pretty_quote(str(tasks_file))
264
+ prompt_template = load_prompt(hook_name, tasks_file)
265
+
266
+ return (
267
+ prompt_template.replace("{{roadmap}}", roadmap_str)
268
+ .replace("{{finished_task}}", finished_str)
269
+ .replace("{{finished_task_id}}", finished_task.id)
270
+ .replace("{{tasks_file_name}}", tasks_file.name)
271
+ .replace("{{tasks_file_path}}", tasks_file_str)
272
+ )
273
+
274
+
275
+ def prepare_prompt(
276
+ data: tasks.Roadmap,
277
+ task: tasks.Task,
278
+ tasks_file: pathlib.Path,
279
+ time_limit: int = 0,
280
+ ) -> str:
281
+ """Prepares the runner prompt based on the current roadmap state.
282
+
283
+ Args:
284
+ data: The current Roadmap.
285
+ task: The Task being executed.
286
+ tasks_file: Path to the tasks YAML file.
287
+ time_limit: Maximum execution time in minutes. 0 means no limit.
288
+
289
+ Returns:
290
+ The fully rendered prompt string.
291
+ """
292
+ roadmap_str = _format_roadmap(data, current_task_id=task.id)
293
+
294
+ # Add parent task context if it's from another project
295
+ if task.parent and task.parent_tasks_file:
296
+ try:
297
+ parent_tasks_path = pathlib.Path(task.parent_tasks_file)
298
+ if parent_tasks_path.exists():
299
+ parent_roadmap = tasks.load_tasks(parent_tasks_path)
300
+ parent_task = next(
301
+ (t for t in parent_roadmap.tasks if t.id == task.parent),
302
+ None,
303
+ )
304
+ if parent_task:
305
+ roadmap_str += (
306
+ "\n## Parent Task Context (From root project)\n"
307
+ )
308
+ roadmap_str += f"- [ ] {parent_task.description}\n"
309
+ if parent_task.progress:
310
+ for progress_item in parent_task.progress:
311
+ roadmap_str += f" - {progress_item}\n"
312
+ except Exception:
313
+ pass
314
+
315
+ progress_str = ""
316
+ if task.progress:
317
+ progress_str = "### Progress from Previous Attempts on THIS Task\n"
318
+ for progress_item in task.progress:
319
+ progress_str += f"- {progress_item}\n"
320
+ progress_str += "\n"
321
+
322
+ time_limit_section = ""
323
+ if time_limit > 0:
324
+ time_limit_section = (
325
+ f"\n## Time Limit\n\n"
326
+ f"You have a hard time limit of **{time_limit} minutes**."
327
+ f" If you exceed it, your\n"
328
+ f"process will be killed and any unrecorded progress will be"
329
+ f" lost.\n\n"
330
+ f"- **Record progress early and often.** Don't wait until the"
331
+ f" end. If you\n"
332
+ f" are killed, your recorded progress will be passed to the"
333
+ f" next attempt.\n"
334
+ f"- **If the work is too large** for {time_limit} minutes,"
335
+ f" break it into smaller\n"
336
+ f" sub-tasks using `lemming` and complete what you can.\n"
337
+ f"- **Leverage background tasks and subagents** if your runner"
338
+ f" supports\n"
339
+ f" them. Long-running operations (builds, test suites, large"
340
+ f" refactors)\n"
341
+ f" are good candidates for parallel execution."
342
+ )
343
+
344
+ tasks_file_str = runner._pretty_quote(str(tasks_file))
345
+ prompt_template = load_prompt("taskrunner", tasks_file)
346
+ return (
347
+ prompt_template.replace("{{roadmap}}", roadmap_str)
348
+ .replace("{{progress}}", progress_str)
349
+ .replace("{{description}}", task.description)
350
+ .replace("{{tasks_file_name}}", tasks_file.name)
351
+ .replace("{{tasks_file_path}}", tasks_file_str)
352
+ .replace("{{task_id}}", task.id)
353
+ .replace("{{time_limit_section}}", time_limit_section)
354
+ )