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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/paths_test.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
import stat
|
|
5
|
+
import subprocess
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from lemming import paths
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_get_lemming_home_default():
|
|
12
|
+
expected = pathlib.Path.home() / ".local" / "lemming"
|
|
13
|
+
if "LEMMING_HOME" in os.environ:
|
|
14
|
+
del os.environ["LEMMING_HOME"]
|
|
15
|
+
assert paths.get_lemming_home() == expected
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_get_lemming_home_override(tmp_path):
|
|
19
|
+
os.environ["LEMMING_HOME"] = str(tmp_path)
|
|
20
|
+
assert paths.get_lemming_home() == tmp_path
|
|
21
|
+
del os.environ["LEMMING_HOME"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_get_project_dir(tmp_path):
|
|
25
|
+
tasks_file = tmp_path / "my_tasks.yml"
|
|
26
|
+
tasks_file.touch()
|
|
27
|
+
|
|
28
|
+
project_dir = paths.get_project_dir(tasks_file)
|
|
29
|
+
assert str(paths.get_lemming_home()) in str(project_dir)
|
|
30
|
+
|
|
31
|
+
# Check that it's consistent
|
|
32
|
+
assert paths.get_project_dir(tasks_file) == project_dir
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_get_default_tasks_file_local(tmp_path):
|
|
36
|
+
orig_cwd = os.getcwd()
|
|
37
|
+
os.chdir(tmp_path)
|
|
38
|
+
try:
|
|
39
|
+
local_tasks = tmp_path / "tasks.yml"
|
|
40
|
+
local_tasks.touch()
|
|
41
|
+
assert paths.get_default_tasks_file() == tmp_path / "tasks.yml"
|
|
42
|
+
finally:
|
|
43
|
+
os.chdir(orig_cwd)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_get_log_file(tmp_path):
|
|
47
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
48
|
+
log_file = paths.get_log_file(tasks_file, "task123")
|
|
49
|
+
assert log_file.name == "task123-runner.log"
|
|
50
|
+
assert log_file.parent.exists()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_in_git_repo_and_is_ignored(tmp_path):
|
|
54
|
+
orig_cwd = os.getcwd()
|
|
55
|
+
os.chdir(tmp_path)
|
|
56
|
+
try:
|
|
57
|
+
paths.in_git_repo.cache_clear()
|
|
58
|
+
|
|
59
|
+
# Not a git repo yet
|
|
60
|
+
assert paths.in_git_repo() is False
|
|
61
|
+
assert paths.is_ignored(tmp_path / "foo.txt") is False
|
|
62
|
+
|
|
63
|
+
# Init git repo
|
|
64
|
+
subprocess.run(["git", "init", "-q"], check=True)
|
|
65
|
+
subprocess.run(
|
|
66
|
+
["git", "config", "user.email", "test@example.com"], check=True
|
|
67
|
+
)
|
|
68
|
+
subprocess.run(["git", "config", "user.name", "test"], check=True)
|
|
69
|
+
|
|
70
|
+
# Clear cache again
|
|
71
|
+
paths.in_git_repo.cache_clear()
|
|
72
|
+
|
|
73
|
+
assert paths.in_git_repo() is True
|
|
74
|
+
|
|
75
|
+
# Create a gitignore
|
|
76
|
+
(tmp_path / ".gitignore").write_text("ignored.txt\n", encoding="utf-8")
|
|
77
|
+
(tmp_path / "ignored.txt").touch()
|
|
78
|
+
(tmp_path / "not_ignored.txt").touch()
|
|
79
|
+
|
|
80
|
+
assert paths.is_ignored(tmp_path / "ignored.txt") is True
|
|
81
|
+
assert paths.is_ignored(tmp_path / "not_ignored.txt") is False
|
|
82
|
+
|
|
83
|
+
finally:
|
|
84
|
+
os.chdir(orig_cwd)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestParseDotenv:
|
|
88
|
+
def test_basic_key_value(self, tmp_path):
|
|
89
|
+
env_file = tmp_path / ".env"
|
|
90
|
+
env_file.write_text("FOO=bar\nBAZ=qux\n")
|
|
91
|
+
assert paths._parse_dotenv(env_file) == {"FOO": "bar", "BAZ": "qux"}
|
|
92
|
+
|
|
93
|
+
def test_comments_and_blank_lines(self, tmp_path):
|
|
94
|
+
env_file = tmp_path / ".env"
|
|
95
|
+
env_file.write_text("# comment\n\nFOO=bar\n # another\n")
|
|
96
|
+
assert paths._parse_dotenv(env_file) == {"FOO": "bar"}
|
|
97
|
+
|
|
98
|
+
def test_quoted_values(self, tmp_path):
|
|
99
|
+
env_file = tmp_path / ".env"
|
|
100
|
+
env_file.write_text("SINGLE='hello world'\nDOUBLE=\"hello world\"\n")
|
|
101
|
+
result = paths._parse_dotenv(env_file)
|
|
102
|
+
assert result == {"SINGLE": "hello world", "DOUBLE": "hello world"}
|
|
103
|
+
|
|
104
|
+
def test_export_prefix(self, tmp_path):
|
|
105
|
+
env_file = tmp_path / ".env"
|
|
106
|
+
env_file.write_text("export FOO=bar\n")
|
|
107
|
+
assert paths._parse_dotenv(env_file) == {"FOO": "bar"}
|
|
108
|
+
|
|
109
|
+
def test_value_with_equals(self, tmp_path):
|
|
110
|
+
env_file = tmp_path / ".env"
|
|
111
|
+
env_file.write_text("URL=https://example.com?a=1&b=2\n")
|
|
112
|
+
assert paths._parse_dotenv(env_file) == {
|
|
113
|
+
"URL": "https://example.com?a=1&b=2"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def test_missing_file(self, tmp_path):
|
|
117
|
+
assert paths._parse_dotenv(tmp_path / "nope") == {}
|
|
118
|
+
|
|
119
|
+
def test_malformed_line_skipped(self, tmp_path):
|
|
120
|
+
env_file = tmp_path / ".env"
|
|
121
|
+
env_file.write_text("GOOD=yes\nBADLINE\nALSO_GOOD=yes\n")
|
|
122
|
+
result = paths._parse_dotenv(env_file)
|
|
123
|
+
assert result == {"GOOD": "yes", "ALSO_GOOD": "yes"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestLoadDotenv:
|
|
127
|
+
def test_global_env_loaded(self, tmp_path):
|
|
128
|
+
global_env = tmp_path / ".env"
|
|
129
|
+
global_env.write_text("LEMMING_TEST_GLOBAL=from_global\n")
|
|
130
|
+
with mock.patch.object(
|
|
131
|
+
paths, "get_lemming_home", return_value=tmp_path
|
|
132
|
+
):
|
|
133
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
134
|
+
os.environ["PATH"] = "/usr/bin:/bin"
|
|
135
|
+
paths.load_dotenv()
|
|
136
|
+
assert os.environ["LEMMING_TEST_GLOBAL"] == "from_global"
|
|
137
|
+
|
|
138
|
+
def test_project_env_overrides_global(self, tmp_path):
|
|
139
|
+
home_dir = tmp_path / "home"
|
|
140
|
+
home_dir.mkdir()
|
|
141
|
+
proj_dir = tmp_path / "project"
|
|
142
|
+
proj_dir.mkdir()
|
|
143
|
+
|
|
144
|
+
(home_dir / ".env").write_text("MY_VAR=global\nONLY_GLOBAL=yes\n")
|
|
145
|
+
(proj_dir / ".env").write_text("MY_VAR=project\n")
|
|
146
|
+
|
|
147
|
+
with mock.patch.object(
|
|
148
|
+
paths, "get_lemming_home", return_value=home_dir
|
|
149
|
+
):
|
|
150
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
151
|
+
os.environ["PATH"] = "/usr/bin:/bin"
|
|
152
|
+
paths.load_dotenv(project_dir=proj_dir)
|
|
153
|
+
assert os.environ["MY_VAR"] == "project"
|
|
154
|
+
assert os.environ["ONLY_GLOBAL"] == "yes"
|
|
155
|
+
|
|
156
|
+
def test_real_env_wins(self, tmp_path):
|
|
157
|
+
env_file = tmp_path / ".env"
|
|
158
|
+
env_file.write_text("EXISTING=from_file\n")
|
|
159
|
+
with mock.patch.object(
|
|
160
|
+
paths, "get_lemming_home", return_value=tmp_path
|
|
161
|
+
):
|
|
162
|
+
with mock.patch.dict(
|
|
163
|
+
os.environ, {"EXISTING": "from_shell"}, clear=True
|
|
164
|
+
):
|
|
165
|
+
os.environ["PATH"] = "/usr/bin:/bin"
|
|
166
|
+
paths.load_dotenv()
|
|
167
|
+
assert os.environ["EXISTING"] == "from_shell"
|
|
168
|
+
|
|
169
|
+
def test_no_env_files_is_noop(self, tmp_path):
|
|
170
|
+
with mock.patch.object(
|
|
171
|
+
paths, "get_lemming_home", return_value=tmp_path
|
|
172
|
+
):
|
|
173
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
174
|
+
os.environ["PATH"] = "/usr/bin:/bin"
|
|
175
|
+
paths.load_dotenv() # should not raise
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TestCheckPermissions:
|
|
179
|
+
def test_warns_on_world_readable(self, tmp_path, caplog):
|
|
180
|
+
env_file = tmp_path / ".env"
|
|
181
|
+
env_file.write_text("X=1\n")
|
|
182
|
+
env_file.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IROTH)
|
|
183
|
+
|
|
184
|
+
with caplog.at_level(logging.WARNING):
|
|
185
|
+
paths._check_permissions(env_file)
|
|
186
|
+
assert "permissive" in caplog.text
|
lemming/persistence.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""YAML persistence and file locking for the roadmap state."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import fcntl
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from . import models, paths
|
|
11
|
+
|
|
12
|
+
STALE_THRESHOLD = 30 # seconds
|
|
13
|
+
LOOP_LOCK_FILENAME = ".lemming_loop.lock"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextlib.contextmanager
|
|
17
|
+
def lock_tasks(tasks_file: pathlib.Path):
|
|
18
|
+
"""Context manager for cross-platform file locking.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
tasks_file: Path to the tasks YAML file.
|
|
22
|
+
"""
|
|
23
|
+
tasks_file.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
# Ensure the file exists before we can lock it
|
|
25
|
+
if not tasks_file.exists():
|
|
26
|
+
tasks_file.write_text("{}", encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
lock_path = tasks_file.with_suffix(".lock")
|
|
29
|
+
with open(lock_path, "w") as lock_file:
|
|
30
|
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
|
31
|
+
try:
|
|
32
|
+
yield
|
|
33
|
+
finally:
|
|
34
|
+
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextlib.contextmanager
|
|
38
|
+
def read_lock_tasks(tasks_file: pathlib.Path):
|
|
39
|
+
"""Context manager that acquires a shared (read) lock on the tasks file.
|
|
40
|
+
|
|
41
|
+
Use this around load_tasks() in read-only paths (e.g. API polling) to
|
|
42
|
+
prevent reading a partially-written file. Do NOT nest inside lock_tasks()
|
|
43
|
+
as that will deadlock on Linux.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
tasks_file: Path to the tasks YAML file.
|
|
47
|
+
"""
|
|
48
|
+
tasks_file.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
if not tasks_file.exists():
|
|
50
|
+
tasks_file.write_text("{}", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
lock_path = tasks_file.with_suffix(".lock")
|
|
53
|
+
with open(lock_path, "w") as lock_file:
|
|
54
|
+
fcntl.flock(lock_file, fcntl.LOCK_SH)
|
|
55
|
+
try:
|
|
56
|
+
yield
|
|
57
|
+
finally:
|
|
58
|
+
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_tasks(tasks_file: pathlib.Path) -> models.Roadmap:
|
|
62
|
+
"""Loads tasks from a YAML file.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
tasks_file: Path to the tasks YAML file.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A Roadmap containing the context and list of tasks.
|
|
69
|
+
"""
|
|
70
|
+
if not tasks_file.exists():
|
|
71
|
+
return models.Roadmap(
|
|
72
|
+
context="# Project Context\n\nAdd your guidelines here.",
|
|
73
|
+
tasks=[],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with open(tasks_file, "r", encoding="utf-8") as f:
|
|
78
|
+
data = yaml.safe_load(f)
|
|
79
|
+
except (yaml.scanner.ScannerError, yaml.parser.ParserError) as e:
|
|
80
|
+
# If the file is corrupted, return an empty roadmap.
|
|
81
|
+
# This allows the user to continue, but may cause loss of some data.
|
|
82
|
+
# In a real-world scenario, we might want to back up the corrupted file.
|
|
83
|
+
print(f"Warning: Failed to load corrupted tasks file: {e}")
|
|
84
|
+
return models.Roadmap(
|
|
85
|
+
context="# Project Context (Corrupted File Recovery)\n\n"
|
|
86
|
+
"The tasks file was corrupted and could not be fully loaded.\n"
|
|
87
|
+
"Check the server logs or the original file for details.",
|
|
88
|
+
tasks=[],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not data:
|
|
92
|
+
data = {}
|
|
93
|
+
# TODO(compat): Remove once all clients write "progress" instead of
|
|
94
|
+
# "outcomes".
|
|
95
|
+
for task_data in data.get("tasks", []):
|
|
96
|
+
if "outcomes" in task_data:
|
|
97
|
+
task_data["progress"] = task_data.pop("outcomes")
|
|
98
|
+
|
|
99
|
+
# TODO(compat): Remove alongside the config migration below.
|
|
100
|
+
if task_data.get("runner") == "gemini":
|
|
101
|
+
task_data.pop("runner")
|
|
102
|
+
|
|
103
|
+
# TODO(compat): Remove once no persisted configs reference the deprecated
|
|
104
|
+
# gemini runner. Dropping the key lets runtime detection pick the default.
|
|
105
|
+
config_data = data.get("config")
|
|
106
|
+
if isinstance(config_data, dict) and config_data.get("runner") == "gemini":
|
|
107
|
+
config_data.pop("runner")
|
|
108
|
+
|
|
109
|
+
return models.Roadmap.model_validate(data)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _BlockStyleDumper(yaml.SafeDumper):
|
|
113
|
+
"""YAML dumper that forces multiline strings to use block style (|)."""
|
|
114
|
+
|
|
115
|
+
def represent_scalar(self, tag, value, style=None):
|
|
116
|
+
if tag == "tag:yaml.org,2002:str" and "\n" in value:
|
|
117
|
+
style = "|"
|
|
118
|
+
return super().represent_scalar(tag, value, style)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_tasks(tasks_file: pathlib.Path, data: models.Roadmap) -> None:
|
|
122
|
+
"""Saves the roadmap data to a YAML file.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
tasks_file: Path to the tasks YAML file.
|
|
126
|
+
data: The Roadmap to save.
|
|
127
|
+
"""
|
|
128
|
+
tasks_file.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
# Use a temporary file for atomic write
|
|
131
|
+
temp_file = tasks_file.with_suffix(".tmp")
|
|
132
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
133
|
+
# Exclude runtime-computed fields from the YAML file.
|
|
134
|
+
exclude = {
|
|
135
|
+
"tasks": {
|
|
136
|
+
"__all__": {
|
|
137
|
+
"index",
|
|
138
|
+
"has_runner_log",
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
yaml.dump(
|
|
143
|
+
data.model_dump(exclude_none=True, mode="json", exclude=exclude),
|
|
144
|
+
f,
|
|
145
|
+
Dumper=_BlockStyleDumper,
|
|
146
|
+
default_flow_style=False,
|
|
147
|
+
sort_keys=False,
|
|
148
|
+
width=1000,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Atomically replace the old file with the new one
|
|
152
|
+
os.replace(temp_file, tasks_file)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _get_loop_lock_path(tasks_file: pathlib.Path) -> pathlib.Path:
|
|
156
|
+
project_dir = paths.get_project_dir(tasks_file)
|
|
157
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
return project_dir / LOOP_LOCK_FILENAME
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def acquire_loop_lock(tasks_file: pathlib.Path) -> None:
|
|
162
|
+
"""Write a loop lock file with the current PID."""
|
|
163
|
+
_get_loop_lock_path(tasks_file).write_text(str(os.getpid()))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def release_loop_lock(tasks_file: pathlib.Path) -> None:
|
|
167
|
+
"""Remove the loop lock file."""
|
|
168
|
+
_get_loop_lock_path(tasks_file).unlink(missing_ok=True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_loop_pid(tasks_file: pathlib.Path) -> int | None:
|
|
172
|
+
"""Returns the PID of the running orchestrator loop, if any."""
|
|
173
|
+
lock_path = _get_loop_lock_path(tasks_file)
|
|
174
|
+
if not lock_path.exists():
|
|
175
|
+
return None
|
|
176
|
+
try:
|
|
177
|
+
return int(lock_path.read_text().strip())
|
|
178
|
+
except (ValueError, OSError):
|
|
179
|
+
return None
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from lemming import models, persistence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_lock_tasks(tmp_path):
|
|
9
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
10
|
+
with persistence.lock_tasks(tasks_file):
|
|
11
|
+
assert tasks_file.exists()
|
|
12
|
+
assert tasks_file.read_text() == "{}"
|
|
13
|
+
|
|
14
|
+
lock_path = tasks_file.with_suffix(".lock")
|
|
15
|
+
assert lock_path.exists()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_load_save_tasks(tmp_path):
|
|
19
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
20
|
+
data = models.Roadmap(
|
|
21
|
+
context="test",
|
|
22
|
+
tasks=[
|
|
23
|
+
models.Task(
|
|
24
|
+
id="1",
|
|
25
|
+
description="task 1",
|
|
26
|
+
status=models.TaskStatus.PENDING,
|
|
27
|
+
attempts=0,
|
|
28
|
+
)
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
persistence.save_tasks(tasks_file, data)
|
|
32
|
+
|
|
33
|
+
loaded = persistence.load_tasks(tasks_file)
|
|
34
|
+
assert loaded.context == "test"
|
|
35
|
+
assert len(loaded.tasks) == 1
|
|
36
|
+
assert loaded.tasks[0].id == "1"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_load_tasks_migrates_deprecated_gemini_runner(tmp_path):
|
|
40
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
41
|
+
tasks_file.write_text(
|
|
42
|
+
yaml.dump({"config": {"runner": "gemini", "retries": 5}})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
loaded = persistence.load_tasks(tasks_file)
|
|
46
|
+
# The deprecated runner is dropped so runtime detection supplies the
|
|
47
|
+
# default.
|
|
48
|
+
assert loaded.config.runner in models.KNOWN_RUNNERS
|
|
49
|
+
# Other user-set config values are preserved.
|
|
50
|
+
assert loaded.config.retries == 5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_load_tasks_migrates_deprecated_gemini_task_runner(tmp_path):
|
|
54
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
55
|
+
tasks_file.write_text(
|
|
56
|
+
yaml.dump(
|
|
57
|
+
{
|
|
58
|
+
"tasks": [
|
|
59
|
+
{"id": "1", "description": "a", "runner": "gemini"},
|
|
60
|
+
{"id": "2", "description": "b", "runner": "aider"},
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
loaded = persistence.load_tasks(tasks_file)
|
|
67
|
+
# The deprecated per-task runner is dropped so the config runner is used.
|
|
68
|
+
assert loaded.tasks[0].runner is None
|
|
69
|
+
assert loaded.tasks[1].runner == "aider"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_load_tasks_preserves_explicit_runner(tmp_path):
|
|
73
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
74
|
+
tasks_file.write_text(yaml.dump({"config": {"runner": "aider"}}))
|
|
75
|
+
|
|
76
|
+
loaded = persistence.load_tasks(tasks_file)
|
|
77
|
+
assert loaded.config.runner == "aider"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_loop_lock_management(tmp_path):
|
|
81
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
82
|
+
|
|
83
|
+
# Initially, no loop is running
|
|
84
|
+
assert persistence.get_loop_pid(tasks_file) is None
|
|
85
|
+
|
|
86
|
+
# Create a lock file
|
|
87
|
+
persistence.acquire_loop_lock(tasks_file)
|
|
88
|
+
pid = persistence.get_loop_pid(tasks_file)
|
|
89
|
+
assert pid == os.getpid()
|
|
90
|
+
|
|
91
|
+
# Release the lock
|
|
92
|
+
persistence.release_loop_lock(tasks_file)
|
|
93
|
+
assert persistence.get_loop_pid(tasks_file) is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_get_loop_pid_corrupted_lock_file(tmp_path):
|
|
97
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
98
|
+
lock_path = persistence._get_loop_lock_path(tasks_file)
|
|
99
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
# Write "corrupted" content
|
|
102
|
+
lock_path.write_text("not-a-pid")
|
|
103
|
+
assert persistence.get_loop_pid(tasks_file) is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_save_tasks_excludes_computed_fields(tmp_path):
|
|
107
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
108
|
+
task = models.Task(
|
|
109
|
+
id="123",
|
|
110
|
+
description="Test Task",
|
|
111
|
+
index=5,
|
|
112
|
+
has_runner_log=True,
|
|
113
|
+
)
|
|
114
|
+
roadmap = models.Roadmap(tasks=[task])
|
|
115
|
+
|
|
116
|
+
persistence.save_tasks(tasks_file, roadmap)
|
|
117
|
+
|
|
118
|
+
# Read the raw YAML to verify exclusion
|
|
119
|
+
with open(tasks_file, "r", encoding="utf-8") as f:
|
|
120
|
+
raw_data = yaml.safe_load(f)
|
|
121
|
+
|
|
122
|
+
task_data = raw_data["tasks"][0]
|
|
123
|
+
assert "id" in task_data
|
|
124
|
+
assert "description" in task_data
|
|
125
|
+
# Computed fields should be excluded
|
|
126
|
+
assert "index" not in task_data
|
|
127
|
+
assert "has_runner_log" not in task_data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_save_tasks_uses_block_style_for_multiline_strings(tmp_path):
|
|
131
|
+
tasks_file = tmp_path / "tasks.yml"
|
|
132
|
+
multiline_description = "Line 1\nLine 2\nLine 3"
|
|
133
|
+
task = models.Task(
|
|
134
|
+
id="123",
|
|
135
|
+
description=multiline_description,
|
|
136
|
+
)
|
|
137
|
+
roadmap = models.Roadmap(tasks=[task])
|
|
138
|
+
|
|
139
|
+
persistence.save_tasks(tasks_file, roadmap)
|
|
140
|
+
|
|
141
|
+
# Read the raw content to check for '|'
|
|
142
|
+
content = tasks_file.read_text(encoding="utf-8")
|
|
143
|
+
assert "description: |" in content
|
|
144
|
+
assert "Line 1" in content
|
|
145
|
+
assert "Line 2" in content
|
|
146
|
+
assert "Line 3" in content
|
|
147
|
+
|
|
148
|
+
# Verify that it still loads correctly
|
|
149
|
+
loaded = persistence.load_tasks(tasks_file)
|
|
150
|
+
assert loaded.tasks[0].description == multiline_description
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Readability Hook
|
|
2
|
+
|
|
3
|
+
You are a senior code reviewer. Your goal is to ensure code quality and
|
|
4
|
+
adherence to the Google Style Guide with minimal overhead.
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
{{roadmap}}
|
|
9
|
+
|
|
10
|
+
## Finished Task
|
|
11
|
+
|
|
12
|
+
{{finished_task}}
|
|
13
|
+
|
|
14
|
+
## Directives
|
|
15
|
+
|
|
16
|
+
1. **Automate**: Immediately run `lemming readability check <path> --fix` for
|
|
17
|
+
every file modified or created in the last task. This handles standard
|
|
18
|
+
formatting (ruff, biome, prettier) and type checking (pyrefly).
|
|
19
|
+
2. **Report**: Record any meaningful findings as progress using
|
|
20
|
+
`lemming progress {{finished_task_id}} '<finding>'`.
|
|
21
|
+
3. **No Orchestration**: Do NOT add new tasks to the roadmap. If you identify
|
|
22
|
+
significant issues that require follow-up work, record them as progress so
|
|
23
|
+
the roadmap hook can decide whether to add a new task.
|
|
24
|
+
4. **No Manual Refactoring**: Do NOT perform manual code changes. Stick
|
|
25
|
+
EXCLUSIVELY to automated fixes via `lemming readability check`.
|
|
26
|
+
5. **Fast Exit**: If the code is clean after automated checks, exit
|
|
27
|
+
immediately.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Fix formatting/linting
|
|
33
|
+
lemming readability check <path> --fix
|
|
34
|
+
# Consult style guides (only if strictly necessary)
|
|
35
|
+
lemming readability guide <language>
|
|
36
|
+
# Record progress
|
|
37
|
+
lemming --tasks-file {{tasks_file_path}} progress {{finished_task_id}} '<finding>'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Limit your review ONLY to the files changed in the last task. Avoid
|
|
41
|
+
"just-in-case" analysis. Your goal is formatting consistency, not feature
|
|
42
|
+
completeness or architectural review.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Roadmap Hook
|
|
2
|
+
|
|
3
|
+
You are a roadmap orchestrator. Your goal is to keep the project on track and
|
|
4
|
+
the plan up-to-date with minimal friction.
|
|
5
|
+
|
|
6
|
+
## Roadmap
|
|
7
|
+
|
|
8
|
+
{{roadmap}}
|
|
9
|
+
|
|
10
|
+
## Finished Task
|
|
11
|
+
|
|
12
|
+
{{finished_task}}
|
|
13
|
+
|
|
14
|
+
## Directives
|
|
15
|
+
|
|
16
|
+
1. **Diagnose**: Review the execution logs and progress of the finished task to
|
|
17
|
+
understand its impact on the roadmap. Check if the task was FULLY completed,
|
|
18
|
+
including any necessary cleanup, teardowns, or documentation.
|
|
19
|
+
2. **Repair**: If a task has failed, you MUST intervene. Simply resetting a
|
|
20
|
+
task without changing the approach will lead to the same failure. You MUST
|
|
21
|
+
either:
|
|
22
|
+
- Rewrite its description with a fundamentally different approach and then
|
|
23
|
+
reset its attempts.
|
|
24
|
+
- Delete it and insert smaller, more manageable prerequisite tasks to
|
|
25
|
+
unblock the goal.
|
|
26
|
+
- If it failed due to timeout, split it into smaller sub-tasks.
|
|
27
|
+
- If it failed due to rate limits (429), you might still want to refine the
|
|
28
|
+
description to be more efficient, or just reset it if you think it was a
|
|
29
|
+
transient issue, but be aware that if it reached the max attempts, you
|
|
30
|
+
MUST change something or the project will abort.
|
|
31
|
+
3. **Refine**: If any pending tasks are now redundant, overly broad, or based
|
|
32
|
+
on invalidated assumptions, edit or delete them immediately.
|
|
33
|
+
4. **Extend**: If the project goal is not yet fully achieved and all tasks are
|
|
34
|
+
finished, add concrete, self-contained tasks to close the gap.
|
|
35
|
+
5. **Follow-up**: If you identify missing work from the previous task (like
|
|
36
|
+
forgotten teardowns, missing tests reported by the testing hook, or
|
|
37
|
+
formatting issues reported by the readability hook), add new tasks to
|
|
38
|
+
address them.
|
|
39
|
+
6. **No Code Changes**: Your only role is to modify the roadmap via the
|
|
40
|
+
`lemming` CLI. Do NOT touch source files.
|
|
41
|
+
7. **Fast Exit**: If the roadmap is accurate and well-structured, AND there are
|
|
42
|
+
no failed tasks that have reached their maximum attempts, you may exit
|
|
43
|
+
immediately without running any commands. However, if a task is marked as
|
|
44
|
+
FAILED and has reached its maximum attempts, a Fast Exit will result in the
|
|
45
|
+
entire project ABORTING. In that case, you MUST repair it.
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Add new tasks
|
|
51
|
+
lemming --tasks-file {{tasks_file_path}} add '<description>' [--index N]
|
|
52
|
+
# Edit existing tasks
|
|
53
|
+
lemming --tasks-file {{tasks_file_path}} edit <id> --description '<desc>'
|
|
54
|
+
# Reset/Delete/Status
|
|
55
|
+
lemming --tasks-file {{tasks_file_path}} reset <id>
|
|
56
|
+
lemming --tasks-file {{tasks_file_path}} delete <id>
|
|
57
|
+
lemming --tasks-file {{tasks_file_path}} progress <id> '<finding>'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Avoid "be thorough" mindset — favor speed and clarity. Only act if the roadmap
|
|
61
|
+
is factually outdated or inefficient.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Testing Hook
|
|
2
|
+
|
|
3
|
+
You are a senior developer verifying the most recent task for testing and
|
|
4
|
+
reliability. Your goal is to ensure functionality remains robust with minimal
|
|
5
|
+
overhead.
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
{{roadmap}}
|
|
10
|
+
|
|
11
|
+
## Finished Task
|
|
12
|
+
|
|
13
|
+
{{finished_task}}
|
|
14
|
+
|
|
15
|
+
## Directives
|
|
16
|
+
|
|
17
|
+
1. **Verify**: Every non-trivial code change must have corresponding unit
|
|
18
|
+
tests. If new or modified logic lacks testing, you must act.
|
|
19
|
+
2. **Validate**: Run the relevant test suite for the modified components to
|
|
20
|
+
ensure all tests pass.
|
|
21
|
+
3. **Repair/Fix**: For minor testing gaps or simple bug fixes in tests, you may
|
|
22
|
+
fix them only if it's strictly necessary for the test suite to pass the
|
|
23
|
+
current changes.
|
|
24
|
+
4. **No Orchestration**: Do NOT add new tasks to the roadmap. If you identify
|
|
25
|
+
significant testing gaps or architectural issues that require follow-up
|
|
26
|
+
work, record them as progress so the roadmap hook can decide whether to add
|
|
27
|
+
a new task.
|
|
28
|
+
5. **No Manual Refactoring**: Do NOT perform complex, manual code changes or
|
|
29
|
+
broad refactors. Stick strictly to verification and targeted test fixes.
|
|
30
|
+
6. **Fast Exit**: If tests are passings and coverage is sufficient for the
|
|
31
|
+
change, exit immediately.
|
|
32
|
+
7. **Consolidate Tests**: Maintain a 1:1 mapping between code files and test
|
|
33
|
+
files in the same directory (excluding integration tests). Avoid one-off
|
|
34
|
+
test files.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Record progress
|
|
40
|
+
lemming --tasks-file {{tasks_file_path}} progress {{finished_task_id}} '<finding>'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Limit your review ONLY to the code changed in the last task. Your goal is
|
|
44
|
+
verification, not a general security or performance audit.
|