lcode-cli 0.28.1__tar.gz → 0.30.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 (165) hide show
  1. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/CHANGELOG.md +39 -1
  2. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/automation.md +1 -0
  4. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/quickstart.md +1 -1
  6. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/usage.md +66 -1
  7. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/pyproject.toml +2 -1
  8. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/__init__.py +1 -1
  9. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/acp.py +1 -1
  10. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/agent.py +89 -2
  11. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/api.py +3 -0
  12. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/bench.py +15 -7
  13. lcode_cli-0.30.0/src/lcode/checks.py +160 -0
  14. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/cli.py +6 -0
  15. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/config.py +1 -0
  16. lcode_cli-0.30.0/src/lcode/matching.py +287 -0
  17. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/repeats.py +2 -2
  18. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/subagents.py +1 -1
  19. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/tools.py +127 -39
  20. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_bench.py +10 -1
  21. lcode_cli-0.30.0/tests/test_checks.py +192 -0
  22. lcode_cli-0.30.0/tests/test_matching.py +167 -0
  23. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_tools.py +6 -6
  24. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.editorconfig +0 -0
  25. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/CODEOWNERS +0 -0
  26. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  29. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  30. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/dependabot.yml +0 -0
  32. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/workflows/ci.yml +0 -0
  33. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/workflows/docs.yml +0 -0
  34. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.github/workflows/release.yml +0 -0
  35. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.gitignore +0 -0
  36. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/.pre-commit-config.yaml +0 -0
  37. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/AGENTS.md +0 -0
  38. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/CODE_OF_CONDUCT.md +0 -0
  39. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/CONTRIBUTING.md +0 -0
  40. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/LICENSE +0 -0
  41. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/README.md +0 -0
  42. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/SECURITY.md +0 -0
  43. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/action.yml +0 -0
  44. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/agents.md +0 -0
  45. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/assets/demo.svg +0 -0
  46. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/assets/extra.css +0 -0
  47. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/assets/logo.svg +0 -0
  48. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/assets/social-preview.png +0 -0
  49. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/bench.md +0 -0
  50. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/changelog.md +0 -0
  51. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/commands.md +0 -0
  52. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/contributing.md +0 -0
  53. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/editors.md +0 -0
  54. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/git.md +0 -0
  55. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/hooks.md +0 -0
  56. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/how-it-works.md +0 -0
  57. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/index.md +0 -0
  58. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/installation.md +0 -0
  59. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/lsp.md +0 -0
  60. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/mcp.md +0 -0
  61. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/memory.md +0 -0
  62. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/models.md +0 -0
  63. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/sandbox.md +0 -0
  64. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/search.md +0 -0
  65. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/servers.md +0 -0
  66. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/docs/troubleshooting.md +0 -0
  67. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/examples/README.md +0 -0
  68. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/examples/api/fix_tests.py +0 -0
  69. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/examples/commands/review.md +0 -0
  70. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/examples/skills/commit-message/SKILL.md +0 -0
  71. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  72. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/install.sh +0 -0
  73. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/mkdocs.yml +0 -0
  74. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/overrides/main.html +0 -0
  75. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/scripts/record_demo.py +0 -0
  76. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/scripts/social-preview.html +0 -0
  77. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/__main__.py +0 -0
  78. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/action.py +0 -0
  79. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/backends.py +0 -0
  80. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/bench_backend.py +0 -0
  81. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/bench_runtime.py +0 -0
  82. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/catalog.py +0 -0
  83. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/checkpoints.py +0 -0
  84. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/clipboard.py +0 -0
  85. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/codesearch.py +0 -0
  86. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/context.py +0 -0
  87. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/extensions.py +0 -0
  88. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/frontmatter.py +0 -0
  89. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/gitflow.py +0 -0
  90. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/hardware.py +0 -0
  91. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/hooks.py +0 -0
  92. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/jobs.py +0 -0
  93. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/limits.py +0 -0
  94. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/lsp.py +0 -0
  95. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/__init__.py +0 -0
  96. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/auth.py +0 -0
  97. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/catalog.py +0 -0
  98. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/catalog.toml +0 -0
  99. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/client.py +0 -0
  100. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/commands.py +0 -0
  101. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/config.py +0 -0
  102. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/http.py +0 -0
  103. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/manager.py +0 -0
  104. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/protocol.py +0 -0
  105. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/server.py +0 -0
  106. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/mcp/stdio.py +0 -0
  107. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/memory.py +0 -0
  108. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/models.toml +0 -0
  109. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/notify.py +0 -0
  110. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/ollama.py +0 -0
  111. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/permissions.py +0 -0
  112. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/planning.py +0 -0
  113. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/render.py +0 -0
  114. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/repl.py +0 -0
  115. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/repomap.py +0 -0
  116. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/residency.py +0 -0
  117. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/sandbox.py +0 -0
  118. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/secretscan.py +0 -0
  119. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/servers/__init__.py +0 -0
  120. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/servers/encord.py +0 -0
  121. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/servers/valohai.py +0 -0
  122. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/servers/xai.py +0 -0
  123. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/sessions.py +0 -0
  124. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/update.py +0 -0
  125. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/vision.py +0 -0
  126. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/src/lcode/web.py +0 -0
  127. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/conftest.py +0 -0
  128. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/fake_lsp_server.py +0 -0
  129. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/fake_mcp_server.py +0 -0
  130. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_acp.py +0 -0
  131. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_action.py +0 -0
  132. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_agent.py +0 -0
  133. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_api.py +0 -0
  134. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_backends.py +0 -0
  135. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_catalog.py +0 -0
  136. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_checkpoints.py +0 -0
  137. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_cli.py +0 -0
  138. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_clipboard.py +0 -0
  139. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_codesearch.py +0 -0
  140. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_config.py +0 -0
  141. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_context.py +0 -0
  142. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_context_picker.py +0 -0
  143. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_extensions.py +0 -0
  144. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_gitflow.py +0 -0
  145. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_hooks.py +0 -0
  146. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_jobs.py +0 -0
  147. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_lsp.py +0 -0
  148. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_mcp.py +0 -0
  149. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_memory.py +0 -0
  150. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_permissions.py +0 -0
  151. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_plan_mode.py +0 -0
  152. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_repeats.py +0 -0
  153. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_repomap.py +0 -0
  154. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_residency.py +0 -0
  155. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_robustness.py +0 -0
  156. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_sandbox.py +0 -0
  157. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_secretscan.py +0 -0
  158. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_servers.py +0 -0
  159. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_sessions.py +0 -0
  160. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_subagents.py +0 -0
  161. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_update.py +0 -0
  162. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_vision.py +0 -0
  163. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_web.py +0 -0
  164. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/tests/test_xai.py +0 -0
  165. {lcode_cli-0.28.1 → lcode_cli-0.30.0}/uv.lock +0 -0
@@ -6,6 +6,42 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.30.0] - 2026-10-13
10
+
11
+ ### Added
12
+
13
+ - Edits that the model copied imperfectly land where they belong instead of failing: line numbers
14
+ copied from `read_file`, stray escapes, a block at the wrong indentation or spaces for tabs (the new
15
+ text is re-indented), dropped blank lines, and very close matches. A close match changes only the
16
+ lines the model meant to change, so an earlier edit its copy doesn't show survives. Ambiguous or
17
+ weak matches are refused, and the model is shown the closest lines with their numbers. See
18
+ [Imperfect edits](https://nasser1941.github.io/lcode/usage/#imperfect-edits).
19
+ - `apply_patch`: the model can apply a unified diff to several files at once. Hunks are found by
20
+ their text, so wrong `@@` line numbers don't matter, and a file changes only if every hunk fits.
21
+ - Common tool slips get a useful answer instead of an error: a `grep` pattern that isn't a valid regex
22
+ (`def add(`) is searched as plain text, a path that doesn't exist comes back with the closest
23
+ existing ones (`Did you mean: src/pkg/math_utils.py?`), `read_file` on a folder lists it, and
24
+ `list_dir` on a file says to read it.
25
+ - `lcode bench --json` breaks failed tool calls down by tool (`errors_by_tool`).
26
+
27
+ ## [0.29.0] - 2026-10-13
28
+
29
+ ### Added
30
+
31
+ - lcode runs the project's checks before calling work done. After a request that changed files, it
32
+ runs the tests (pytest, unittest, npm/pnpm/yarn/bun test, cargo test, go test or make test, found
33
+ from the project, or the command in `.lcode/settings.toml` or the `checks` setting). When they
34
+ fail, the model gets the output and keeps working, twice at most, and then says what still fails.
35
+ Checks the model already ran after its last change aren't repeated. `lcode -p --output json`
36
+ reports them in `checks`, and `lcode bench --checks off` compares. See
37
+ [Checks before done](https://nasser1941.github.io/lcode/usage/#checks-before-done).
38
+
39
+ ### Fixed
40
+
41
+ - A tool call that Ollama couldn't read because its XML was out of order (`expected element type
42
+ <function> but have <parameter>`) ended the request with an error. Like other malformed calls,
43
+ it's now sent back to the model to make again.
44
+
9
45
  ## [0.28.1] - 2026-10-13
10
46
 
11
47
  ### Fixed
@@ -632,7 +668,9 @@ First public release.
632
668
  - `AGENTS.md` project instructions and `/init` to generate them.
633
669
  - One-line installer for Linux and macOS.
634
670
 
635
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.1...HEAD
671
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.30.0...HEAD
672
+ [0.30.0]: https://github.com/nasser1941/lcode/compare/v0.29.0...v0.30.0
673
+ [0.29.0]: https://github.com/nasser1941/lcode/compare/v0.28.1...v0.29.0
636
674
  [0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
637
675
  [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
638
676
  [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.28.1
3
+ Version: 0.30.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.1 — local coding agent │
15
+ │ lcode v0.30.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -92,7 +92,8 @@ reasoning is on.
92
92
  |---|---|
93
93
  | `read_file` | Read a file with line numbers (paged for large files) |
94
94
  | `write_file` | Create or overwrite a file (shows a preview or diff) |
95
- | `edit_file` | Replace an exact snippet in a file (shows a diff); tolerates trailing-whitespace differences |
95
+ | `edit_file` | Replace a snippet in a file (shows a diff); [forgiving](#imperfect-edits) when the model copies it imperfectly |
96
+ | `apply_patch` | Apply a unified diff (as `git diff` writes it) to one or more files; hunks are found by their text |
96
97
  | `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
97
98
  | `glob` | Find files by pattern, newest first |
98
99
  | `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
@@ -113,6 +114,70 @@ reasoning is on.
113
114
  lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
114
115
  it was read, so the model always edits the current version.
115
116
 
117
+ ### Imperfect edits
118
+
119
+ Small models copy text imperfectly, and an edit that doesn't match exactly used to fail, often again
120
+ and again. When the text isn't in the file exactly, lcode tries, in order:
121
+
122
+ - **Copying slips:** removing the line numbers `read_file` shows, and undoing stray escapes such
123
+ as `\"`.
124
+ - **Looser line matches:** comparing lines without trailing spaces, then without indentation (a block
125
+ at the wrong level, or spaces where the file has tabs). The new text is re-indented to fit.
126
+ - **Blank lines:** ignoring blank lines the model dropped or added.
127
+ - **A very close match:** at least 90% alike, and clearly better than any other place. Only the lines
128
+ the model meant to change are changed, so an earlier edit that the model's copy doesn't show is
129
+ kept. If a changed line differs in the file by more than a typo, the edit is refused.
130
+
131
+ The result says how it matched (`Edited config.py (1 replacement(s), ignoring indentation)`), and the
132
+ diff shows the change as usual. A match that's ambiguous or too weak is never applied. Instead, the
133
+ model gets the closest lines with their numbers, so it can copy them exactly. `apply_patch` places
134
+ each hunk the same way, so wrong `@@` line numbers don't matter, and it changes a file only if every
135
+ hunk fits.
136
+
137
+ Other common slips get a useful answer instead of a bare error:
138
+
139
+ - **A pattern that isn't a valid regex** (`grep "def add("`) is searched as plain text, and the result
140
+ says so.
141
+ - **A path that doesn't exist** comes back with the closest existing ones: the same file name
142
+ elsewhere in the project, or a near name in the folder (`Did you mean: src/pkg/math_utils.py?`).
143
+ - **`read_file` on a folder** lists it, and **`list_dir` on a file** says to read it.
144
+
145
+ ## Checks before done
146
+
147
+ Small models like to call work done after a syntax check. So after a request that changed files, lcode
148
+ runs the project's checks itself before the answer is final:
149
+
150
+ ```text
151
+ ◆ Checking the work: python -m unittest (from test files)
152
+ ⚠ Checks fail (python -m unittest, exit code 1): the model gets the output to fix it.
153
+ ◆ Checking the work: python -m unittest (from test files)
154
+ ◆ Checks pass: python -m unittest
155
+ ```
156
+
157
+ - **When they fail,** the model gets the output and keeps working, twice at most. After that its answer
158
+ says what still fails instead of claiming success. A failure that the change didn't cause is
159
+ reported, not chased, and the checks run again only if the model changes something more.
160
+ - **When nothing could run** (no tests collected, or the test runner isn't installed), lcode says so
161
+ and doesn't send it to the model.
162
+ - **When the model already ran them** after its last change and they passed, they aren't run again.
163
+ - **Permission:** they run like any other command, so in `ask` mode the first run asks (++a++ allows
164
+ them for the session).
165
+ - **Programs** using `lcode -p --output json` see the runs in the result's `checks`.
166
+
167
+ **Which checks.** lcode looks at the project: pytest when the configuration mentions it (`uv run
168
+ pytest -q` with a `uv.lock`), otherwise `python -m unittest` for `test_*.py` files. Then
169
+ `npm test` (or pnpm, yarn, bun) when `package.json` has a test script, `cargo test`, `go test ./...`
170
+ and `make test`. To choose the command yourself, set it for the repository in
171
+ `.lcode/settings.toml`. That file is used once you trust the repository, like its hooks:
172
+
173
+ ```toml
174
+ [checks]
175
+ command = "uv run pytest -q && uv run ruff check ."
176
+ ```
177
+
178
+ Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
179
+ turns checks off.
180
+
116
181
  ## Background commands
117
182
 
118
183
  Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
@@ -82,7 +82,8 @@ ignore = [
82
82
  "src/lcode/context.py" = ["E501"]
83
83
  "src/lcode/lsp.py" = ["E501"]
84
84
  "src/lcode/repomap.py" = ["E501"]
85
- "src/lcode/bench_runtime.py" = ["E501"] # the task's files and hidden test
85
+ "src/lcode/bench_runtime.py" = ["E501"]
86
+ "tests/test_matching.py" = ["E501"] # file contents as test data # the task's files and hidden test
86
87
  "src/lcode/codesearch.py" = ["E501"]
87
88
  "src/lcode/bench_backend.py" = ["E501"]
88
89
  "src/lcode/backends.py" = ["E501"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.28.1"
6
+ __version__ = "0.30.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -44,7 +44,7 @@ MODES = [
44
44
  ]
45
45
  KINDS = {
46
46
  "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
47
- "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
47
+ "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", "apply_patch": "edit",
48
48
  "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
49
  "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
50
50
  } # fmt: skip
@@ -27,6 +27,7 @@ from rich.text import Text
27
27
 
28
28
  from lcode import (
29
29
  catalog,
30
+ checks,
30
31
  codesearch,
31
32
  extensions,
32
33
  limits,
@@ -81,7 +82,13 @@ EVENT_OUTPUT = 4000 # characters of a tool's output in an event
81
82
  REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
82
83
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
83
84
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
84
- 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
+ )
85
92
  RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
86
93
  MIN_RESULT_CHARS = 8000
87
94
  READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
@@ -114,7 +121,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
114
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.
115
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.
116
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.
117
- - 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}
118
125
 
119
126
  # Communication
120
127
  - Be concise and direct; no filler. Use GitHub-flavored markdown.
@@ -268,6 +275,7 @@ class Settings:
268
275
  secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
269
276
  compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
270
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
271
279
 
272
280
 
273
281
  class Agent:
@@ -324,6 +332,8 @@ class Agent:
324
332
  self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
325
333
  self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
326
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
327
337
  self.sent_chars = 0 # characters of the conversation the model had already read
328
338
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
329
339
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -363,6 +373,7 @@ class Agent:
363
373
  code=self.code_prompt(),
364
374
  map=self.map_prompt(),
365
375
  look=" you look at with view_image" if self.vision_model() else "",
376
+ checks=self.checks_prompt(),
366
377
  )
367
378
 
368
379
  def repo_map(self) -> repomap.RepoMap | None:
@@ -812,6 +823,64 @@ class Agent:
812
823
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
813
824
  return message
814
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
+
815
884
  def before_request(self, progress: Progress) -> residency.LoadWatch | None:
816
885
  """Set the status line for what comes before the first token: loading the model if it isn't in memory
817
886
  (watched until it is), then reading the prompt. Returns the watch while the model loads."""
@@ -948,6 +1017,9 @@ class Agent:
948
1017
  note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
949
1018
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
950
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
951
1023
  malformed = 0
952
1024
  looping = "" # the call the model kept repeating, when lcode stopped the request for it
953
1025
  for step in range(self.max_steps):
@@ -983,6 +1055,8 @@ class Agent:
983
1055
  reason = str(e).rsplit("err=", 1)[-1].strip()
984
1056
  elif "XML syntax error" in str(e):
985
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"
986
1060
  else:
987
1061
  reason = "invalid JSON"
988
1062
  self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
@@ -996,6 +1070,15 @@ class Agent:
996
1070
  )
997
1071
  continue
998
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
999
1082
  break
1000
1083
  done: dict[int, str] = {} # results of subagents that ran together
1001
1084
  together = [
@@ -1051,6 +1134,10 @@ class Agent:
1051
1134
  )
1052
1135
  if verdict.stop:
1053
1136
  looping = self.describe_call(name, args)
1137
+ if name in ("edit_file", "write_file", "apply_patch") 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
1054
1141
  tool_msg = {"role": "tool", "tool_name": name, "content": result}
1055
1142
  if call.get("id"):
1056
1143
  tool_msg["tool_call_id"] = call["id"]
@@ -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,6 +197,7 @@ 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)
@@ -303,6 +305,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
303
305
  tool_calls=list(calls.values()),
304
306
  files_changed=files,
305
307
  usage=usage,
308
+ checks=list(agent.check_results),
306
309
  seconds=round(time.monotonic() - started, 2),
307
310
  error=error,
308
311
  )
@@ -789,6 +789,7 @@ class TaskResult:
789
789
  steps: int = 0 # model responses
790
790
  tool_errors: int = 0 # failed tool calls, including malformed ones
791
791
  error: str | None = None # why the run stopped early: a timeout or an Ollama error
792
+ errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
792
793
 
793
794
 
794
795
  @dataclass
@@ -861,14 +862,20 @@ class Elapsed:
861
862
  return Text(f"{self.label} {time.monotonic() - self.start:.0f}s", style="dim")
862
863
 
863
864
 
865
+ def errors_by_tool(messages: list[dict]) -> dict[str, int]:
866
+ """Failed tool calls by tool, and "malformed" for calls the model wrote unreadably."""
867
+ counts: dict[str, int] = {}
868
+ for m in messages:
869
+ if m.get("role") == "tool" and m.get("content", "").startswith("Error:"):
870
+ name = m.get("tool_name") or "?"
871
+ counts[name] = counts.get(name, 0) + 1
872
+ elif m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call"):
873
+ counts["malformed"] = counts.get("malformed", 0) + 1
874
+ return counts
875
+
876
+
864
877
  def count_tool_errors(messages: list[dict]) -> int:
865
- failed = sum(1 for m in messages if m.get("role") == "tool" and m.get("content", "").startswith("Error:"))
866
- malformed = sum(
867
- 1
868
- for m in messages
869
- if m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call")
870
- )
871
- return failed + malformed
878
+ return sum(errors_by_tool(messages).values())
872
879
 
873
880
 
874
881
  def run_task(
@@ -933,6 +940,7 @@ def run_task(
933
940
  detail=error or check.detail,
934
941
  steps=agent.usage["requests"] - before.get("requests", 0),
935
942
  tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
943
+ errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
936
944
  error=error,
937
945
  )
938
946
 
@@ -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%)"),