lcode-cli 0.28.0__tar.gz → 0.29.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/CHANGELOG.md +54 -1
  2. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/automation.md +1 -0
  4. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/quickstart.md +1 -1
  6. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/usage.md +42 -3
  7. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/__init__.py +1 -1
  8. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/agent.py +120 -4
  9. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/api.py +4 -0
  10. lcode_cli-0.29.0/src/lcode/checks.py +160 -0
  11. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/cli.py +6 -0
  12. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/config.py +1 -0
  13. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/context.py +68 -0
  14. lcode_cli-0.29.0/src/lcode/permissions.py +260 -0
  15. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/repl.py +1 -0
  16. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/tools.py +160 -33
  17. lcode_cli-0.29.0/tests/test_checks.py +192 -0
  18. lcode_cli-0.29.0/tests/test_robustness.py +221 -0
  19. lcode_cli-0.28.0/src/lcode/permissions.py +0 -138
  20. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.editorconfig +0 -0
  21. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/CODEOWNERS +0 -0
  22. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  23. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  24. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  25. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  26. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/dependabot.yml +0 -0
  28. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/workflows/ci.yml +0 -0
  29. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/workflows/docs.yml +0 -0
  30. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/workflows/release.yml +0 -0
  31. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.gitignore +0 -0
  32. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.pre-commit-config.yaml +0 -0
  33. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/AGENTS.md +0 -0
  34. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/CODE_OF_CONDUCT.md +0 -0
  35. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/CONTRIBUTING.md +0 -0
  36. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/LICENSE +0 -0
  37. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/README.md +0 -0
  38. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/SECURITY.md +0 -0
  39. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/action.yml +0 -0
  40. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/agents.md +0 -0
  41. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/demo.svg +0 -0
  42. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/extra.css +0 -0
  43. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/logo.svg +0 -0
  44. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/social-preview.png +0 -0
  45. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/bench.md +0 -0
  46. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/changelog.md +0 -0
  47. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/commands.md +0 -0
  48. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/contributing.md +0 -0
  49. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/editors.md +0 -0
  50. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/git.md +0 -0
  51. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/hooks.md +0 -0
  52. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/how-it-works.md +0 -0
  53. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/index.md +0 -0
  54. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/installation.md +0 -0
  55. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/lsp.md +0 -0
  56. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/mcp.md +0 -0
  57. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/memory.md +0 -0
  58. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/models.md +0 -0
  59. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/sandbox.md +0 -0
  60. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/search.md +0 -0
  61. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/servers.md +0 -0
  62. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/troubleshooting.md +0 -0
  63. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/README.md +0 -0
  64. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/api/fix_tests.py +0 -0
  65. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/commands/review.md +0 -0
  66. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/skills/commit-message/SKILL.md +0 -0
  67. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  68. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/install.sh +0 -0
  69. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/mkdocs.yml +0 -0
  70. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/overrides/main.html +0 -0
  71. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/pyproject.toml +0 -0
  72. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/scripts/record_demo.py +0 -0
  73. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/scripts/social-preview.html +0 -0
  74. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/__main__.py +0 -0
  75. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/acp.py +0 -0
  76. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/action.py +0 -0
  77. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/backends.py +0 -0
  78. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/bench.py +0 -0
  79. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/bench_backend.py +0 -0
  80. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/bench_runtime.py +0 -0
  81. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/catalog.py +0 -0
  82. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/checkpoints.py +0 -0
  83. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/clipboard.py +0 -0
  84. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/codesearch.py +0 -0
  85. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/extensions.py +0 -0
  86. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/frontmatter.py +0 -0
  87. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/gitflow.py +0 -0
  88. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/hardware.py +0 -0
  89. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/hooks.py +0 -0
  90. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/jobs.py +0 -0
  91. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/limits.py +0 -0
  92. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/lsp.py +0 -0
  93. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/__init__.py +0 -0
  94. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/auth.py +0 -0
  95. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/catalog.py +0 -0
  96. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/catalog.toml +0 -0
  97. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/client.py +0 -0
  98. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/commands.py +0 -0
  99. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/config.py +0 -0
  100. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/http.py +0 -0
  101. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/manager.py +0 -0
  102. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/protocol.py +0 -0
  103. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/server.py +0 -0
  104. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/stdio.py +0 -0
  105. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/memory.py +0 -0
  106. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/models.toml +0 -0
  107. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/notify.py +0 -0
  108. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/ollama.py +0 -0
  109. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/planning.py +0 -0
  110. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/render.py +0 -0
  111. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/repeats.py +0 -0
  112. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/repomap.py +0 -0
  113. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/residency.py +0 -0
  114. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/sandbox.py +0 -0
  115. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/secretscan.py +0 -0
  116. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/__init__.py +0 -0
  117. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/encord.py +0 -0
  118. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/valohai.py +0 -0
  119. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/xai.py +0 -0
  120. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/sessions.py +0 -0
  121. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/subagents.py +0 -0
  122. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/update.py +0 -0
  123. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/vision.py +0 -0
  124. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/web.py +0 -0
  125. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/conftest.py +0 -0
  126. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/fake_lsp_server.py +0 -0
  127. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/fake_mcp_server.py +0 -0
  128. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_acp.py +0 -0
  129. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_action.py +0 -0
  130. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_agent.py +0 -0
  131. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_api.py +0 -0
  132. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_backends.py +0 -0
  133. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_bench.py +0 -0
  134. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_catalog.py +0 -0
  135. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_checkpoints.py +0 -0
  136. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_cli.py +0 -0
  137. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_clipboard.py +0 -0
  138. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_codesearch.py +0 -0
  139. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_config.py +0 -0
  140. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_context.py +0 -0
  141. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_context_picker.py +0 -0
  142. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_extensions.py +0 -0
  143. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_gitflow.py +0 -0
  144. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_hooks.py +0 -0
  145. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_jobs.py +0 -0
  146. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_lsp.py +0 -0
  147. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_mcp.py +0 -0
  148. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_memory.py +0 -0
  149. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_permissions.py +0 -0
  150. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_plan_mode.py +0 -0
  151. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_repeats.py +0 -0
  152. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_repomap.py +0 -0
  153. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_residency.py +0 -0
  154. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_sandbox.py +0 -0
  155. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_secretscan.py +0 -0
  156. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_servers.py +0 -0
  157. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_sessions.py +0 -0
  158. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_subagents.py +0 -0
  159. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_tools.py +0 -0
  160. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_update.py +0 -0
  161. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_vision.py +0 -0
  162. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_web.py +0 -0
  163. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_xai.py +0 -0
  164. {lcode_cli-0.28.0 → lcode_cli-0.29.0}/uv.lock +0 -0
@@ -6,6 +6,57 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.29.0] - 2026-10-13
10
+
11
+ ### Added
12
+
13
+ - lcode runs the project's checks before calling work done. After a request that changed files, it
14
+ runs the tests (pytest, unittest, npm/pnpm/yarn/bun test, cargo test, go test or make test, found
15
+ from the project, or the command in `.lcode/settings.toml` or the `checks` setting). When they
16
+ fail, the model gets the output and keeps working, twice at most, and then says what still fails.
17
+ Checks the model already ran after its last change aren't repeated. `lcode -p --output json`
18
+ reports them in `checks`, and `lcode bench --checks off` compares. See
19
+ [Checks before done](https://nasser1941.github.io/lcode/usage/#checks-before-done).
20
+
21
+ ### Fixed
22
+
23
+ - A tool call that Ollama couldn't read because its XML was out of order (`expected element type
24
+ <function> but have <parameter>`) ended the request with an error. Like other malformed calls,
25
+ it's now sent back to the model to make again.
26
+
27
+ ## [0.28.1] - 2026-10-13
28
+
29
+ ### Fixed
30
+
31
+ Found by testing lcode's tools on real files and commands (#118):
32
+
33
+ - Some commands that change things ran without asking, because they were taken for read-only:
34
+ `sort -o`, `uniq in out`, `tree -o`, `git branch -D` and other branch changes, `git remote
35
+ add/remove`, `git log/diff --output`, `rg --pre` (which runs a program), `find -fls`, `date -s`,
36
+ and `nvidia-smi` settings. Arguments are now checked. `2>&1` and `>/dev/null` no longer make a
37
+ read-only command ask.
38
+ - "Always allow" for a command also covered chains that started with it: allowing `npm test` let
39
+ `npm test && rm -rf build` run without asking. Now a chain needs its own "always".
40
+ - A JSON tool call that the model only showed as an example in its answer was run. Text is now
41
+ taken as a call only when the whole message is one.
42
+ - A change you made to a file while lcode asked to edit it was overwritten. lcode now notices and
43
+ doesn't apply the edit, and the model redoes it on the current content.
44
+ - `replace_all: "false"`, and other values sent as text, were taken as true. They now get the
45
+ type the tool declares.
46
+ - The shell timeout wasn't enforced while a command kept printing, so a watcher started without
47
+ `background` hung the request. Long output is no longer held in memory either: 400 MB of output
48
+ used to cost 2.5 GB, and now it goes to a file, keeping its start, end and error lines.
49
+ - One tool result could overflow the context window: `read_file` returned up to ~40K tokens,
50
+ however small the window, and compaction only looked at the previous request. A result now takes
51
+ at most a quarter of the window (`read_file` says where to read on), and lcode makes room based on
52
+ the size of the next request.
53
+ - Sessions were saved only when a request ended, so a crash lost the whole request. They're now
54
+ saved after every step, and atomically, so a crash while writing can't corrupt them.
55
+ - Editing a file with Windows line endings turned the whole file to Unix line endings, and editing a
56
+ file that isn't UTF-8 (Windows-1252 or Latin-1) replaced every accented letter in it with `�`,
57
+ not only in the edited line. Files now keep their encoding, line endings and byte order mark. A
58
+ character the encoding can't hold is refused instead of written wrong.
59
+
9
60
  ## [0.28.0] - 2026-10-13
10
61
 
11
62
  ### Added
@@ -599,7 +650,9 @@ First public release.
599
650
  - `AGENTS.md` project instructions and `/init` to generate them.
600
651
  - One-line installer for Linux and macOS.
601
652
 
602
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.0...HEAD
653
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.29.0...HEAD
654
+ [0.29.0]: https://github.com/nasser1941/lcode/compare/v0.28.1...v0.29.0
655
+ [0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
603
656
  [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
604
657
  [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
605
658
  [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -55,6 +55,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped becau
55
55
  | `tool_calls` | Every tool call, with its arguments and output (the first 4,000 characters). `error` is true when the call failed or wasn't allowed |
56
56
  | `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
57
57
  | `usage` | Model requests and tokens; `prompt_tokens` includes tokens served from the prompt cache |
58
+ | `checks` | The [checks](usage.md#checks-before-done) that ran after the change: `command`, `exit_code` (`null` when they weren't allowed to run) and `by` (`lcode`, or `model` when the model ran them itself) |
58
59
  | `error` | What went wrong, when `status` is `error` |
59
60
  | `session_id` | Continue the conversation later with `lcode --resume <id>` |
60
61
 
@@ -43,6 +43,7 @@ lcode config path # print the file location
43
43
  | `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
44
44
  | `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
45
45
  | `notify_after` | `30` | Seconds a request runs before it notifies |
46
+ | `checks` | `auto` | Run the project's [checks](usage.md#checks-before-done) after a request changed files: `auto` (found from the project), `off`, or a command |
46
47
  | `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
47
48
  | `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
48
49
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.28.0 — local coding agent │
15
+ │ lcode v0.29.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -113,6 +113,42 @@ reasoning is on.
113
113
  lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
114
114
  it was read, so the model always edits the current version.
115
115
 
116
+ ## Checks before done
117
+
118
+ Small models like to call work done after a syntax check. So after a request that changed files, lcode
119
+ runs the project's checks itself before the answer is final:
120
+
121
+ ```text
122
+ ◆ Checking the work: python -m unittest (from test files)
123
+ ⚠ Checks fail (python -m unittest, exit code 1): the model gets the output to fix it.
124
+ ◆ Checking the work: python -m unittest (from test files)
125
+ ◆ Checks pass: python -m unittest
126
+ ```
127
+
128
+ - **When they fail,** the model gets the output and keeps working, twice at most. After that its answer
129
+ says what still fails instead of claiming success. A failure that the change didn't cause is
130
+ reported, not chased, and the checks run again only if the model changes something more.
131
+ - **When nothing could run** (no tests collected, or the test runner isn't installed), lcode says so
132
+ and doesn't send it to the model.
133
+ - **When the model already ran them** after its last change and they passed, they aren't run again.
134
+ - **Permission:** they run like any other command, so in `ask` mode the first run asks (++a++ allows
135
+ them for the session).
136
+ - **Programs** using `lcode -p --output json` see the runs in the result's `checks`.
137
+
138
+ **Which checks.** lcode looks at the project: pytest when the configuration mentions it (`uv run
139
+ pytest -q` with a `uv.lock`), otherwise `python -m unittest` for `test_*.py` files. Then
140
+ `npm test` (or pnpm, yarn, bun) when `package.json` has a test script, `cargo test`, `go test ./...`
141
+ and `make test`. To choose the command yourself, set it for the repository in
142
+ `.lcode/settings.toml`. That file is used once you trust the repository, like its hooks:
143
+
144
+ ```toml
145
+ [checks]
146
+ command = "uv run pytest -q && uv run ruff check ."
147
+ ```
148
+
149
+ Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
150
+ turns checks off.
151
+
116
152
  ## Background commands
117
153
 
118
154
  Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
@@ -224,11 +260,14 @@ image support off with `lcode config set vision_model off`.
224
260
  `--yolo` start in one.
225
261
 
226
262
  Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
227
- `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar, as long as they don't redirect
228
- output, chain commands or run subshells. A `cd <folder> &&` in front is fine.
263
+ `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar. Their arguments count too:
264
+ `sort -o file`, `git branch -D`, `git log --output=file`, `rg --pre` or `find -delete` change
265
+ things, so they ask. Pipes between read-only commands are fine, and so are `cd <folder> &&` in front
266
+ and `2>&1` or `>/dev/null`; anything else (`&&`, `;`, redirecting to a file, `$(…)`) asks.
229
267
 
230
268
  When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
231
- or ++n++. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
269
+ or ++n++. "Always" covers the same program, or for a chain the same programs in the same order:
270
+ allowing `npm test` doesn't allow `npm test && rm -rf build`. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
232
271
  deny lists, per user or per repository; deny always wins, even in `yolo`. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
233
272
 
234
273
  !!! warning "yolo mode"
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.28.0"
6
+ __version__ = "0.29.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -6,6 +6,7 @@ import base64
6
6
  import contextlib
7
7
  import datetime as dt
8
8
  import json
9
+ import os
9
10
  import platform
10
11
  import re
11
12
  import shutil
@@ -26,6 +27,7 @@ from rich.text import Text
26
27
 
27
28
  from lcode import (
28
29
  catalog,
30
+ checks,
29
31
  codesearch,
30
32
  extensions,
31
33
  limits,
@@ -80,7 +82,15 @@ EVENT_OUTPUT = 4000 # characters of a tool's output in an event
80
82
  REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
81
83
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
82
84
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
83
- MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
85
+ MALFORMED_CALL = (
86
+ "error parsing tool call",
87
+ # Go's XML decoder, for models that write calls as XML (Qwen3.6): a cut-off or misordered call
88
+ "XML syntax error",
89
+ "expected element type",
90
+ "closed by </",
91
+ )
92
+ RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
93
+ MIN_RESULT_CHARS = 8000
84
94
  READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
85
95
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
86
96
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
@@ -111,7 +121,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
111
121
  - A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
112
122
  - To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
113
123
  - For what you can't see, like a GUI, a web page or a layout, check it without a screen where you can: `QT_QPA_PLATFORM=offscreen` for Qt, a headless browser, or a script that prints sizes and positions or renders to an image{look}. Measure instead of assuming.
114
- - Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.
124
+ - Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.{checks}
115
125
 
116
126
  # Communication
117
127
  - Be concise and direct; no filler. Use GitHub-flavored markdown.
@@ -265,6 +275,7 @@ class Settings:
265
275
  secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
266
276
  compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
267
277
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
278
+ checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
268
279
 
269
280
 
270
281
  class Agent:
@@ -320,7 +331,11 @@ class Agent:
320
331
  self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
321
332
  self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
322
333
  self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
334
+ self.autosave = False # save the session after every step (sessions people resume; not the bench's)
335
+ self._check: tuple[Path, checks.Check | None] | None = None
336
+ self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
323
337
  self.sent_chars = 0 # characters of the conversation the model had already read
338
+ self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
324
339
  self.turn_status = "" # how the last request ended: success, max_steps, loop
325
340
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
326
341
  self.response = None # the streaming response, so another thread can abort it
@@ -358,6 +373,7 @@ class Agent:
358
373
  code=self.code_prompt(),
359
374
  map=self.map_prompt(),
360
375
  look=" you look at with view_image" if self.vision_model() else "",
376
+ checks=self.checks_prompt(),
361
377
  )
362
378
 
363
379
  def repo_map(self) -> repomap.RepoMap | None:
@@ -576,6 +592,7 @@ class Agent:
576
592
  self.tools.read_mtimes.clear()
577
593
  self.reflected = len(self.messages)
578
594
  self.ctx_used = len(self.messages[0]["content"]) // 3
595
+ self.counted_chars = None
579
596
 
580
597
  def session_file(self) -> Path:
581
598
  return sessions.sessions_dir() / f"{self.session_id}.json"
@@ -597,7 +614,10 @@ class Agent:
597
614
  "messages": self.messages,
598
615
  "checkpoints": self.checkpoints.to_json(),
599
616
  }
600
- f.write_text(json.dumps(data))
617
+ # Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
618
+ partial = f.with_name(f".{f.name}.partial")
619
+ partial.write_text(json.dumps(data))
620
+ os.replace(partial, f)
601
621
 
602
622
  def new_session(self) -> None:
603
623
  self.reset()
@@ -634,6 +654,7 @@ class Agent:
634
654
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
635
655
  self.reflected = len(self.messages) # checked for notes to remember when that session ended
636
656
  self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
657
+ self.counted_chars = None
637
658
  return note
638
659
 
639
660
  def load_latest(self) -> bool:
@@ -797,11 +818,69 @@ class Agent:
797
818
  self.usage["output_tokens"] += final.get("eval_count", 0)
798
819
  self.usage["output_ns"] += final.get("eval_duration", 0)
799
820
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
800
- self.sent_chars = conversation_chars(self.messages)
821
+ self.sent_chars = self.counted_chars = conversation_chars(self.messages)
801
822
  seconds = final.get("eval_duration", 0) / 1e9
802
823
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
803
824
  return message
804
825
 
826
+ def checks_prompt(self) -> str:
827
+ check = self.project_check()
828
+ if check is None:
829
+ return ""
830
+ return (
831
+ f"\n- When you've changed files, lcode runs the project's checks (`{check.command}`) before your answer "
832
+ "is final, and gives you the failures to fix. Run them yourself while you work, too."
833
+ )
834
+
835
+ def project_check(self) -> checks.Check | None:
836
+ """The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
837
+ if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
838
+ return None
839
+ root = project_root_or_cwd(self.cwd)
840
+ if self._check is None or self._check[0] != root or self._check[1] is None: # tests may appear meanwhile
841
+ self._check = (root, checks.find(root, self.settings.checks, self.settings.trust_project))
842
+ return self._check[1]
843
+
844
+ def model_ran_checks(self, command: str, result: str) -> bool:
845
+ """Whether a command the model ran was the project's checks, and they passed."""
846
+ check = self.project_check()
847
+ if check is None or not checks.covers(check, command) or checks.exit_code(result) != 0:
848
+ return False
849
+ self.check_results.append({"command": check.command, "exit_code": 0, "by": "model"})
850
+ return True
851
+
852
+ def run_check(self, check: checks.Check, round_: int) -> str | None:
853
+ """Run the checks after a change. Returns what to tell the model when they fail, else None."""
854
+ self.say(f"Checking the work: {check.command} (from {check.source})")
855
+ result = self.tools.run("bash", {"command": check.command, "timeout": checks.TIMEOUT})
856
+ code = checks.exit_code(result)
857
+ self.check_results.append({"command": check.command, "exit_code": code, "by": "lcode"})
858
+ if code is None:
859
+ self.say(f"The checks didn't run: {result.splitlines()[0][:200]}", warning=True)
860
+ return None
861
+ if code == 0:
862
+ self.say(f"Checks pass: {check.command}")
863
+ return None
864
+ if checks.no_tests(check, result):
865
+ self.say(f"The checks found no tests to run ({check.command}).")
866
+ return None
867
+ if checks.missing(check, result):
868
+ self.say(
869
+ f"The checks couldn't run ({check.command}): {result.splitlines()[0][:160]}. Install what they need, "
870
+ 'or set the command: lcode config set checks "…" (or checks off).',
871
+ warning=True,
872
+ )
873
+ return None
874
+ if round_ >= checks.MAX_ROUNDS:
875
+ self.say(
876
+ f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
877
+ )
878
+ else:
879
+ self.say(
880
+ f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
881
+ )
882
+ return checks.failure_note(check, result, round_)
883
+
805
884
  def before_request(self, progress: Progress) -> residency.LoadWatch | None:
806
885
  """Set the status line for what comes before the first token: loading the model if it isn't in memory
807
886
  (watched until it is), then reading the prompt. Returns the watch while the model loads."""
@@ -938,11 +1017,16 @@ class Agent:
938
1017
  note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
939
1018
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
940
1019
  self.repeats.reset()
1020
+ self.check_results = []
1021
+ changed = verified = False # files changed in this request; the checks passed since
1022
+ check_rounds = 0
941
1023
  malformed = 0
942
1024
  looping = "" # the call the model kept repeating, when lcode stopped the request for it
943
1025
  for step in range(self.max_steps):
944
1026
  if self.cancel is not None and self.cancel.is_set():
945
1027
  raise KeyboardInterrupt
1028
+ if step and self.autosave:
1029
+ self.save() # a crash or a closed terminal loses at most this step
946
1030
  self.maybe_compact()
947
1031
  try:
948
1032
  reply = self.assistant_step()
@@ -971,6 +1055,8 @@ class Agent:
971
1055
  reason = str(e).rsplit("err=", 1)[-1].strip()
972
1056
  elif "XML syntax error" in str(e):
973
1057
  reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
1058
+ elif "expected element type" in str(e) or "closed by </" in str(e):
1059
+ reason = str(e).split("Ollama error:", 1)[-1].strip() or "invalid XML"
974
1060
  else:
975
1061
  reason = "invalid JSON"
976
1062
  self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
@@ -984,6 +1070,15 @@ class Agent:
984
1070
  )
985
1071
  continue
986
1072
  if not calls:
1073
+ check = self.project_check() if changed and not verified else None
1074
+ if check is not None and check_rounds < checks.MAX_ROUNDS:
1075
+ check_rounds += 1
1076
+ feedback = self.run_check(check, check_rounds)
1077
+ if feedback:
1078
+ self.messages.append({"role": "user", "content": feedback})
1079
+ changed = False # run them again only if the model changes something more
1080
+ continue
1081
+ verified = True
987
1082
  break
988
1083
  done: dict[int, str] = {} # results of subagents that ran together
989
1084
  together = [
@@ -1027,6 +1122,9 @@ class Agent:
1027
1122
  self.console.print(Text(f" {result[:300]}", style="red"))
1028
1123
  elif name in ("read_file", "grep", "glob", "list_dir"):
1029
1124
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
1125
+ budget = self.result_budget()
1126
+ if len(result) > budget and name not in ("agent", "skill"):
1127
+ result = context_tools.shorten(result, budget, f"{name} result")
1030
1128
  verdict = self.repeats.check(name, args, result)
1031
1129
  if verdict.note:
1032
1130
  result += verdict.note
@@ -1036,6 +1134,10 @@ class Agent:
1036
1134
  )
1037
1135
  if verdict.stop:
1038
1136
  looping = self.describe_call(name, args)
1137
+ if name in ("edit_file", "write_file") and not result.startswith(REFUSED):
1138
+ changed, verified = True, False
1139
+ elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
1140
+ verified = True # the model ran the checks itself, after its last change
1039
1141
  tool_msg = {"role": "tool", "tool_name": name, "content": result}
1040
1142
  if call.get("id"):
1041
1143
  tool_msg["tool_call_id"] = call["id"]
@@ -1161,8 +1263,21 @@ class Agent:
1161
1263
  limit = min(limit, self.settings.compact_at)
1162
1264
  return limit
1163
1265
 
1266
+ def next_request_tokens(self) -> int:
1267
+ """The size of the next request: what the model last counted, plus what was added since (tool output,
1268
+ attachments). Deciding on the last count alone let one large tool result overflow the window."""
1269
+ if self.counted_chars is None:
1270
+ return self.ctx_used
1271
+ return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
1272
+
1273
+ def result_budget(self) -> int:
1274
+ """Characters one tool result may take: a share of the window, so one large result can't fill it."""
1275
+ return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
1276
+
1164
1277
  def maybe_compact(self) -> None:
1165
1278
  limit = self.compact_limit()
1279
+ if self.next_request_tokens() > limit:
1280
+ self.ctx_used, self.counted_chars = self.next_request_tokens(), None
1166
1281
  if self.ctx_used <= limit:
1167
1282
  return
1168
1283
  enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
@@ -1231,6 +1346,7 @@ class Agent:
1231
1346
  )
1232
1347
  self.skills_loaded.clear()
1233
1348
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1349
+ self.counted_chars = None
1234
1350
  self.summarized_size = self.ctx_used
1235
1351
  self.sent_chars = 0
1236
1352
  self.reflected = len(self.messages)
@@ -76,6 +76,7 @@ class Result:
76
76
  tool_calls: list[dict] = field(default_factory=list) # {"id", "name", "arguments", "error", "output"}
77
77
  files_changed: list[dict] = field(default_factory=list) # {"status": "A" | "M" | "D", "path"}
78
78
  usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
79
+ checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
79
80
  seconds: float = 0.0
80
81
  error: str = ""
81
82
 
@@ -196,10 +197,12 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
196
197
  notify_after=cfg["notify_after"],
197
198
  secret_check=cfg["secret_check"],
198
199
  repeat_limit=cfg["repeat_limit"],
200
+ checks=cfg["checks"],
199
201
  compact_at=cfg["compact_at"],
200
202
  )
201
203
  agent = Agent(ollama, settings, cwd, console=console)
202
204
  agent.hardware = hw
205
+ agent.autosave = True
203
206
  agent.interactive = options.interactive
204
207
  if options.max_steps:
205
208
  agent.max_steps = options.max_steps
@@ -302,6 +305,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
302
305
  tool_calls=list(calls.values()),
303
306
  files_changed=files,
304
307
  usage=usage,
308
+ checks=list(agent.check_results),
305
309
  seconds=round(time.monotonic() - started, 2),
306
310
  error=error,
307
311
  )
@@ -0,0 +1,160 @@
1
+ """Run the project's checks before calling work done.
2
+
3
+ Small models call work done after a syntax check, and telling them to verify isn't enough. So after a request
4
+ that changed files, lcode runs the project's check command itself (its tests, or a linter) before the answer
5
+ counts as final. When it fails, the output goes back to the model, which keeps working, at most `MAX_ROUNDS`
6
+ times; then the answer says plainly that the checks still fail.
7
+
8
+ The command is, in order: the repository's `.lcode/settings.toml` (`[checks] command = "…"`, used once the
9
+ repository is trusted, like its hooks), the user's `checks` setting, or what the project looks like: pytest,
10
+ unittest, npm test, cargo test, go test, make test. `checks = "off"` turns it off.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import shutil
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ MAX_ROUNDS = 2 # times the model gets failing checks back before lcode stops asking
22
+ TIMEOUT = 600 # seconds a check may run
23
+ OUTPUT_TAIL = 6000 # characters of a failing check's output the model gets back
24
+ EXIT = re.compile(r"\[exit code: (-?\d+)\]\s*$")
25
+
26
+
27
+ @dataclass
28
+ class Check:
29
+ command: str
30
+ source: str # where it comes from, for the user: "pyproject.toml", "your configuration"…
31
+
32
+
33
+ def find(root: Path, setting: str, trusted: bool) -> Check | None:
34
+ """The project's check command, or None when there's none or checks are off."""
35
+ setting = (setting or "auto").strip()
36
+ if setting == "off":
37
+ return None
38
+ if trusted:
39
+ from lcode.hooks import HookError, read_toml
40
+
41
+ try:
42
+ data = read_toml(root / ".lcode" / "settings.toml")
43
+ except HookError:
44
+ data = {}
45
+ command = str((data.get("checks") or {}).get("command") or "").strip()
46
+ if command:
47
+ return Check(command, ".lcode/settings.toml")
48
+ if setting != "auto":
49
+ return Check(setting, "your configuration (checks)")
50
+ return detect(root)
51
+
52
+
53
+ def python() -> str:
54
+ return "python" if shutil.which("python") else "python3"
55
+
56
+
57
+ def text(path: Path) -> str:
58
+ try:
59
+ return path.read_text(errors="replace") if path.is_file() else ""
60
+ except OSError:
61
+ return ""
62
+
63
+
64
+ def python_tests(root: Path) -> bool:
65
+ """Whether the project has Python test files (a few levels deep, outside environments and caches)."""
66
+ from lcode.tools import IGNORE_DIRS
67
+
68
+ def walk(folder: Path, depth: int) -> bool:
69
+ try:
70
+ entries = list(folder.iterdir())
71
+ except OSError:
72
+ return False
73
+ if any(e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name) for e in entries):
74
+ return True
75
+ return depth > 0 and any(
76
+ walk(e, depth - 1)
77
+ for e in entries
78
+ if e.is_dir() and e.name not in IGNORE_DIRS and not e.name.startswith(".")
79
+ )
80
+
81
+ return walk(root, 3)
82
+
83
+
84
+ def detect(root: Path) -> Check | None:
85
+ """A check command from what the project looks like. Python checks need test files: pytest configured in a
86
+ project without tests "fails" with nothing to run."""
87
+ if python_tests(root):
88
+ configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini")
89
+ if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")):
90
+ runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{python()} -m pytest -q"
91
+ return Check(runner, "pytest in the project's configuration")
92
+ if list(root.glob("test_*.py")) or list(root.glob("*_test.py")):
93
+ return Check(f"{python()} -m unittest", "test files")
94
+ if (root / "tests").is_dir():
95
+ return Check(f"{python()} -m unittest discover -s tests -t .", "tests/")
96
+ package = text(root / "package.json")
97
+ if package:
98
+ try:
99
+ test = (json.loads(package).get("scripts") or {}).get("test") or ""
100
+ except ValueError:
101
+ test = ""
102
+ if test and "no test specified" not in test:
103
+ for lock, tool in (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun")):
104
+ if (root / lock).is_file():
105
+ return Check(f"{tool} test", "package.json")
106
+ return Check("npm test", "package.json")
107
+ if (root / "Cargo.toml").is_file():
108
+ return Check("cargo test", "Cargo.toml")
109
+ if (root / "go.mod").is_file():
110
+ return Check("go test ./...", "go.mod")
111
+ if re.search(r"^test:", text(root / "Makefile"), re.M):
112
+ return Check("make test", "Makefile")
113
+ return None
114
+
115
+
116
+ NO_TESTS = 5 # pytest's and unittest's exit code when no test ran: not a failure
117
+
118
+
119
+ def no_tests(check: Check, result: str) -> bool:
120
+ return exit_code(result) == NO_TESTS and bool(re.search(r"pytest|unittest", check.command))
121
+
122
+
123
+ def exit_code(result: str) -> int | None:
124
+ """The exit code at the end of a bash tool result (None: it didn't run, e.g. not allowed)."""
125
+ found = EXIT.search(result)
126
+ return int(found.group(1)) if found else None
127
+
128
+
129
+ def covers(check: Check, command: str) -> bool:
130
+ """Whether a command the model ran is this check (`cd x && uv run pytest -q 2>&1 | tail` counts)."""
131
+ return " ".join(check.command.split()) in " ".join(command.split())
132
+
133
+
134
+ def failure_note(check: Check, result: str, round_: int) -> str:
135
+ output = result if len(result) <= OUTPUT_TAIL else "…" + result[-OUTPUT_TAIL:]
136
+ last = round_ >= MAX_ROUNDS
137
+ return (
138
+ f"[lcode] Your changes are done, but the project's checks fail: `{check.command}` "
139
+ f"(exit code {exit_code(result)}).\n\n{output}\n\n"
140
+ + (
141
+ "This was the last check lcode runs for this request. Don't change anything more: tell the user what "
142
+ "still fails and why."
143
+ if last
144
+ else "Fix what your changes broke, then finish. If a failure isn't caused by your changes (it failed "
145
+ "before), don't chase it: say so in your answer."
146
+ )
147
+ )
148
+
149
+
150
+ # The check command itself is missing (not the code under test): nothing the model can fix.
151
+ MISSING = re.compile(r"No module named '?pytest'?\s*$|command not found|^\S+: not found$", re.I | re.M)
152
+
153
+
154
+ def missing(check: Check, result: str) -> bool:
155
+ """Whether the check couldn't run at all: its program isn't installed."""
156
+ code = exit_code(result)
157
+ if code == 127:
158
+ return True
159
+ head = result.splitlines()[:5] # a missing program says so first; test failures look different
160
+ return code not in (0, None) and any(MISSING.search(line) for line in head)
@@ -580,6 +580,7 @@ def cmd_bench(args) -> None:
580
580
  checkpoints=False,
581
581
  prune=not args.no_prune,
582
582
  compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
583
+ checks=cfg["checks"] if args.checks is None else args.checks,
583
584
  repo_map=args.repo_map,
584
585
  embed_model=cfg["embed_model"] if args.code_search else "off",
585
586
  )
@@ -774,6 +775,11 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
774
775
  p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
775
776
  p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
776
777
  p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
778
+ p.add_argument(
779
+ "--checks",
780
+ metavar="auto|off|COMMAND",
781
+ help="run each task's checks before it counts as done (default: the checks setting)",
782
+ )
777
783
  p.add_argument(
778
784
  "--compact-at",
779
785
  type=compact_tokens,
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
59
59
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
60
60
  "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
+ "checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
62
63
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
63
64
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
64
65
  "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),