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
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.