lcode-cli 0.8.0__tar.gz → 0.9.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 (100) hide show
  1. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/CHANGELOG.md +20 -1
  2. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/PKG-INFO +4 -2
  3. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/README.md +3 -1
  4. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/configuration.md +1 -1
  5. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/how-it-works.md +1 -0
  6. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/quickstart.md +1 -1
  7. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/usage.md +46 -3
  8. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/pyproject.toml +1 -0
  9. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/__init__.py +1 -1
  10. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/agent.py +12 -2
  11. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/cli.py +4 -1
  12. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/config.py +2 -2
  13. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/commands.py +2 -2
  14. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/memory.py +1 -1
  15. lcode_cli-0.9.0/src/lcode/planning.py +138 -0
  16. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/repl.py +18 -2
  17. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/tools.py +31 -1
  18. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_mcp.py +15 -0
  19. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_memory.py +1 -0
  20. lcode_cli-0.9.0/tests/test_plan_mode.py +161 -0
  21. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.editorconfig +0 -0
  22. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/CODEOWNERS +0 -0
  23. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  27. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/dependabot.yml +0 -0
  29. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/workflows/ci.yml +0 -0
  30. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/workflows/docs.yml +0 -0
  31. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/workflows/release.yml +0 -0
  32. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.gitignore +0 -0
  33. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.pre-commit-config.yaml +0 -0
  34. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/AGENTS.md +0 -0
  35. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/CODE_OF_CONDUCT.md +0 -0
  36. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/CONTRIBUTING.md +0 -0
  37. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/LICENSE +0 -0
  38. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/SECURITY.md +0 -0
  39. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/agents.md +0 -0
  40. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/demo.svg +0 -0
  41. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/extra.css +0 -0
  42. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/logo.svg +0 -0
  43. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/social-preview.png +0 -0
  44. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/bench.md +0 -0
  45. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/changelog.md +0 -0
  46. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/contributing.md +0 -0
  47. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/index.md +0 -0
  48. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/installation.md +0 -0
  49. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/mcp.md +0 -0
  50. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/memory.md +0 -0
  51. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/models.md +0 -0
  52. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/sandbox.md +0 -0
  53. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/troubleshooting.md +0 -0
  54. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/install.sh +0 -0
  55. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/mkdocs.yml +0 -0
  56. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/overrides/main.html +0 -0
  57. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/scripts/record_demo.py +0 -0
  58. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/scripts/social-preview.html +0 -0
  59. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/__main__.py +0 -0
  60. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/bench.py +0 -0
  61. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/catalog.py +0 -0
  62. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/checkpoints.py +0 -0
  63. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/hardware.py +0 -0
  64. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/limits.py +0 -0
  65. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/__init__.py +0 -0
  66. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/auth.py +0 -0
  67. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/catalog.py +0 -0
  68. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/catalog.toml +0 -0
  69. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/client.py +0 -0
  70. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/config.py +0 -0
  71. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/http.py +0 -0
  72. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/manager.py +0 -0
  73. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/protocol.py +0 -0
  74. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/stdio.py +0 -0
  75. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/models.toml +0 -0
  76. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/ollama.py +0 -0
  77. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/permissions.py +0 -0
  78. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/render.py +0 -0
  79. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/sandbox.py +0 -0
  80. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/sessions.py +0 -0
  81. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/subagents.py +0 -0
  82. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/vision.py +0 -0
  83. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/web.py +0 -0
  84. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/conftest.py +0 -0
  85. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/fake_mcp_server.py +0 -0
  86. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_agent.py +0 -0
  87. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_bench.py +0 -0
  88. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_catalog.py +0 -0
  89. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_checkpoints.py +0 -0
  90. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_cli.py +0 -0
  91. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_config.py +0 -0
  92. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_context_picker.py +0 -0
  93. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_permissions.py +0 -0
  94. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_sandbox.py +0 -0
  95. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_sessions.py +0 -0
  96. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_subagents.py +0 -0
  97. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_tools.py +0 -0
  98. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_vision.py +0 -0
  99. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_web.py +0 -0
  100. {lcode_cli-0.8.0 → lcode_cli-0.9.0}/uv.lock +0 -0
@@ -6,6 +6,24 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-10-05
10
+
11
+ ### Added
12
+
13
+ - Plan mode: a `plan` permission mode (Shift+Tab from `ask`, `/mode plan`, `lcode --plan`, or
14
+ `/plan <request>`) in which the model can read, search, run read-only commands, use the web and
15
+ read-only subagents, but can't change anything. It presents a plan with the new `present_plan`
16
+ tool, and you approve it (in `ask` or `auto-edit` mode), edit it, save it to `.lcode/plans/`, or
17
+ send it back with feedback. An approved plan fills the todo list and is kept through compaction;
18
+ `/plan` shows it. See [Plan mode](https://nasser1941.github.io/lcode/usage/#plan-mode).
19
+
20
+ ### Fixed
21
+
22
+ - `lcode mcp add` shows the default value in its questions again, such as `AWS region
23
+ [us-east-1]`; the terminal formatting swallowed it.
24
+ - The end-of-session "Worth remembering?" list shows each note's type (`[feedback]`, `[project]`, …),
25
+ which the same problem hid.
26
+
9
27
  ## [0.8.0] - 2026-10-05
10
28
 
11
29
  ### Added
@@ -246,7 +264,8 @@ First public release.
246
264
  - `AGENTS.md` project instructions and `/init` to generate them.
247
265
  - One-line installer for Linux and macOS.
248
266
 
249
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.8.0...HEAD
267
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.9.0...HEAD
268
+ [0.9.0]: https://github.com/nasser1941/lcode/compare/v0.8.0...v0.9.0
250
269
  [0.8.0]: https://github.com/nasser1941/lcode/compare/v0.7.0...v0.8.0
251
270
  [0.7.0]: https://github.com/nasser1941/lcode/compare/v0.6.1...v0.7.0
252
271
  [0.6.1]: https://github.com/nasser1941/lcode/compare/v0.6.0...v0.6.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.8.0
3
+ Version: 0.9.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/
@@ -80,6 +80,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
80
80
  your approval. `auto-edit` and `yolo` modes when you want speed.
81
81
  - **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
82
82
  Podman) that only sees your project, without network access unless you allow it.
83
+ - **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
84
+ you approve, edit or send it back before anything changes.
83
85
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
84
86
  request's edits, new files and shell-command changes, without touching your git history.
85
87
  - **Subagents.** The model hands broad searches, planning and self-contained changes to subagents
@@ -195,7 +197,7 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
195
197
 
196
198
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
197
199
 
198
- - [Plan mode](https://github.com/nasser1941/lcode/issues/41) and [custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
200
+ - [Custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
199
201
  - [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
200
202
  - Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
201
203
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
@@ -46,6 +46,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
46
46
  your approval. `auto-edit` and `yolo` modes when you want speed.
47
47
  - **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
48
48
  Podman) that only sees your project, without network access unless you allow it.
49
+ - **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
50
+ you approve, edit or send it back before anything changes.
49
51
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
50
52
  request's edits, new files and shell-command changes, without touching your git history.
51
53
  - **Subagents.** The model hands broad searches, planning and self-contained changes to subagents
@@ -161,7 +163,7 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
161
163
 
162
164
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
163
165
 
164
- - [Plan mode](https://github.com/nasser1941/lcode/issues/41) and [custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
166
+ - [Custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
165
167
  - [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
166
168
  - Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
167
169
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
@@ -20,7 +20,7 @@ lcode config path # print the file location
20
20
  | `num_batch` | 512 | Prompt batch size. Larger reads prompts faster but needs more GPU memory |
21
21
  | `keep_alive` | `30m` | How long Ollama keeps the model in memory after the last request |
22
22
  | `ollama_host` | `http://localhost:11434` | Ollama server URL |
23
- | `permission_mode` | `ask` | `ask`, `auto-edit` or `yolo` |
23
+ | `permission_mode` | `ask` | `ask`, `plan`, `auto-edit` or `yolo` |
24
24
  | `think` | `true` | Let the model reason before answering |
25
25
  | `web` | `on` | Web search and page fetching: `on`, `ask` (before each search/website) or `off` |
26
26
  | `search_backend` | `auto` | `auto`, `ollama`, `brave`, `tavily` or `searxng`; `auto` uses the first one configured |
@@ -89,6 +89,7 @@ the Ollama server you configure, it only contacts the web when the model searche
89
89
  | `bench.py` | `lcode bench`: the benchmark tasks, their checks and the reports |
90
90
  | `vision.py` | Looking at images with a model that can see |
91
91
  | `subagents.py` | Subagents: agent types, running them (also in parallel), worktrees for workers |
92
+ | `planning.py` | Plan mode: what it allows, presenting a plan and approving it |
92
93
  | `memory.py` | Notes that carry over between sessions, and the end-of-session check |
93
94
  | `sandbox.py` | The optional container for shell commands |
94
95
  | `checkpoints.py` | Snapshots before the model changes files, for `/undo` and `/rewind` |
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.8.0 — local coding agent │
15
+ │ lcode v0.9.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -20,6 +20,7 @@ lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases
20
20
  | `-r, --repo DIR` | Work in another directory |
21
21
  | `-c, --continue` | Continue the most recently used session in this directory |
22
22
  | `--resume [SESSION]` | Resume a saved session: pick from a list, or give its number, name or id |
23
+ | `--plan` | Start in [plan mode](#plan-mode): agree on a plan before anything changes |
23
24
  | `--auto-edit` | Apply file edits without asking (commands still ask) |
24
25
  | `--yolo` | Never ask for permission |
25
26
  | `--no-think` | Turn off the model's reasoning: faster, less accurate |
@@ -38,7 +39,7 @@ lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases
38
39
  | ++esc++ ++enter++ or a trailing `\` | New line |
39
40
  | ++ctrl+c++ | Interrupt the model or a running command |
40
41
  | ++ctrl+d++ | Quit |
41
- | ++shift+tab++ | Cycle permission mode: ask → auto-edit → yolo |
42
+ | ++shift+tab++ | Cycle permission mode: ask → plan → auto-edit → yolo |
42
43
  | ++tab++ | Complete slash commands and `@` paths |
43
44
  | `@path` | Attach a file (or a directory listing) to your message |
44
45
  | `@name` | Ask for a [subagent](agents.md) of that type, e.g. `@plan` |
@@ -63,12 +64,13 @@ reasoning is on.
63
64
  | `/memory [show\|edit\|delete N]` | The [notes](memory.md) lcode remembers |
64
65
  | `/sandbox [network on\|off]` | The [sandbox](sandbox.md) for shell commands |
65
66
  | `/mcp [tools\|login\|restart NAME]` | [MCP servers](mcp.md), their status and tools |
67
+ | `/plan [request]` | Work out a [plan](#plan-mode) before changing anything, or show the approved plan |
66
68
  | `/compact [focus]` | Summarize the conversation to free context |
67
69
  | `/context [size]` | Show how full the context window is and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
68
70
  | `/model [name]`, `/models` | Switch model, list models |
69
71
  | `/think [on\|off]` | Toggle reasoning |
70
72
  | `/verbose` | Toggle showing the reasoning text |
71
- | `/mode [ask\|auto-edit\|yolo]` | Set the permission mode |
73
+ | `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
72
74
  | `/cd DIR` | Change the working directory |
73
75
  | `/todos` | Show the model's task list |
74
76
  | `/exit` | Quit |
@@ -90,6 +92,7 @@ reasoning is on.
90
92
  | `view_image` | Look at an image: a model that can see describes it ([Images](#images)) |
91
93
  | `agent` | Hand a task to a [subagent](agents.md) with its own context; only its report comes back |
92
94
  | `memory` | Save, update or delete [notes](memory.md) that later sessions load |
95
+ | `present_plan` | In [plan mode](#plan-mode): show the plan and ask for approval |
93
96
 
94
97
  lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
95
98
  it was read, so the model always edits the current version.
@@ -127,12 +130,16 @@ image support off with `lcode config set vision_model off`.
127
130
  | Mode | File edits | Shell commands |
128
131
  |---|---|---|
129
132
  | `ask` (default) | ask | ask, except read-only commands |
133
+ | `plan` | refused | only read-only commands ([Plan mode](#plan-mode)) |
130
134
  | `auto-edit` | automatic | ask, except read-only commands |
131
135
  | `yolo` | automatic | automatic |
132
136
 
137
+ ++shift+tab++ cycles through them in this order, `/mode` sets one, and `--plan`, `--auto-edit` and
138
+ `--yolo` start in one.
139
+
133
140
  Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
134
141
  `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar, as long as they don't redirect
135
- output, chain commands or run subshells.
142
+ output, chain commands or run subshells. A `cd <folder> &&` in front is fine.
136
143
 
137
144
  When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
138
145
  or ++n++. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
@@ -146,6 +153,42 @@ or ++n++. Text after ++n++ goes to the model as instructions: `n run the tests w
146
153
  With the [sandbox](sandbox.md) on, `auto-edit` mode runs shell commands without asking too: they can
147
154
  only reach the project, and `/undo` can take their changes back.
148
155
 
156
+ ## Plan mode
157
+
158
+ Local models are slower than cloud models and make more mistakes, so on a bigger change it pays to
159
+ agree on the approach before the model starts editing. In **plan mode** the model can read, search,
160
+ run read-only commands, use the web and [explore and plan subagents](agents.md), but it can't
161
+ change anything. It ends by presenting a plan: numbered steps, the files it will change, the risks
162
+ and how it will verify the work.
163
+
164
+ ```text
165
+ ❯ /plan add retries to the webhook handlers
166
+ …
167
+ ╭─ Plan: Retries for webhook handlers ───────────────────────────────────────────╮
168
+ │ 1. Wrap the HTTP call in payments/webhooks.py:88 in a retry loop (3 tries, …) │
169
+ │ 2. Add tests for a timeout and a 500 in tests/test_webhooks.py │
170
+ │ Risks: duplicate deliveries if the endpoint isn't idempotent … │
171
+ │ Verify: pytest tests/test_webhooks.py │
172
+ ╰────────────────────────────────────────────────────────────────────────────────╯
173
+ [y] run it, asking before changes · [a] run it, edits without asking (auto-edit)
174
+ [e] edit the plan · [s] save it to .lcode/plans/ · [n] keep planning (+ what to change)
175
+ >
176
+ ```
177
+
178
+ | Answer | |
179
+ |---|---|
180
+ | ++y++ or ++enter++ | Approve: lcode switches to `ask` mode and the model starts on the plan right away |
181
+ | ++a++ | Approve and switch to `auto-edit`, so file edits don't ask |
182
+ | ++e++ | Edit the plan in your editor first; the model follows your version |
183
+ | ++s++ | Save it as `.lcode/plans/<title>.md`, for example to share it or commit it |
184
+ | ++n++ + text | Send it back: `n keep the old API working` |
185
+
186
+ After approval, the todo list is filled from the plan's numbered steps, and the plan is kept word
187
+ for word when the conversation is compacted. `/plan` shows the approved plan.
188
+
189
+ Start planning with `/plan <request>` (switches to plan mode and sends the request), with
190
+ ++shift+tab++ (one step from `ask`), or with `lcode --plan`.
191
+
149
192
  ## Undo and checkpoints
150
193
 
151
194
  Before the model first changes anything in a request (an edit, a new file, or a shell command that
@@ -71,6 +71,7 @@ ignore = [
71
71
  "src/lcode/agent.py" = ["E501"] # the prompts are prose; wrapping them would change what the model sees
72
72
  "src/lcode/memory.py" = ["E501"]
73
73
  "src/lcode/subagents.py" = ["E501"]
74
+ "src/lcode/planning.py" = ["E501"]
74
75
 
75
76
  [tool.pytest.ini_options]
76
77
  testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -21,7 +21,7 @@ from rich.markup import escape
21
21
  from rich.panel import Panel
22
22
  from rich.text import Text
23
23
 
24
- from lcode import catalog, limits, sessions, subagents, vision, web
24
+ from lcode import catalog, limits, planning, sessions, subagents, vision, web
25
25
  from lcode import memory as memory_notes
26
26
  from lcode.checkpoints import Checkpoints
27
27
  from lcode.config import format_tokens
@@ -32,6 +32,7 @@ from lcode.render import MarkdownStreamer
32
32
  from lcode.sandbox import Sandbox, SandboxError, project_root
33
33
  from lcode.tools import (
34
34
  MEMORY_SCHEMA,
35
+ PRESENT_PLAN_SCHEMA,
35
36
  SCHEMAS,
36
37
  VIEW_IMAGE_SCHEMA,
37
38
  WEB_FETCH_SCHEMA,
@@ -198,6 +199,7 @@ class Agent:
198
199
  self._memory: memory_notes.Memory | None = None
199
200
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
200
201
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
202
+ self.plan = "" # the plan the user approved; kept through compaction
201
203
  # Set on subagents (see lcode.subagents):
202
204
  self.allowed_tools: set[str] | None = None # None: every tool
203
205
  self.read_only = False # only read-only shell commands
@@ -259,6 +261,9 @@ class Agent:
259
261
  assert self._agent_types is not None
260
262
  return self._agent_types[2]
261
263
 
264
+ def planning(self) -> bool:
265
+ return self.perms.mode == "plan"
266
+
262
267
  def tool_names(self) -> set[str]:
263
268
  return {s["function"]["name"] for s in self.tool_schemas()}
264
269
 
@@ -283,6 +288,8 @@ class Agent:
283
288
  schemas.append(MEMORY_SCHEMA)
284
289
  if self.settings.subagents:
285
290
  schemas.append(subagents.schema(self.agent_types(), self.settings.max_parallel_agents))
291
+ if self.planning():
292
+ schemas.append(PRESENT_PLAN_SCHEMA)
286
293
  if self.mcp:
287
294
  schemas += self.mcp.schemas(self.settings.context)
288
295
  if self.allowed_tools is not None:
@@ -638,7 +645,8 @@ class Agent:
638
645
 
639
646
  def _run_turn(self, user_text: str, max_steps: int = MAX_STEPS_PER_TURN) -> None:
640
647
  self.prepare_mcp()
641
- self.messages.append({"role": "user", "content": self.expand_mentions(user_text)})
648
+ note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
649
+ self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
642
650
  malformed = 0
643
651
  for _ in range(max_steps):
644
652
  if self.cancel is not None and self.cancel.is_set():
@@ -777,6 +785,8 @@ class Agent:
777
785
  {"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
778
786
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
779
787
  ]
788
+ if self.plan: # the approved plan stays word for word
789
+ self.messages[1]["content"] += f"\n\n[The plan the user approved; keep following it]\n\n{self.plan}"
780
790
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
781
791
  self.reflected = len(self.messages)
782
792
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
@@ -519,7 +519,9 @@ def cmd_chat(args) -> None:
519
519
  context, note = choose_context(ollama, model, spec, requested_ctx, hw)
520
520
  if note:
521
521
  console.print(f"[yellow]Context {format_tokens(context)}: {note}[/]")
522
- mode = "yolo" if args.yolo else ("auto-edit" if args.auto_edit else cfg["permission_mode"])
522
+ mode = (
523
+ "yolo" if args.yolo else ("auto-edit" if args.auto_edit else ("plan" if args.plan else cfg["permission_mode"]))
524
+ )
523
525
  num_batch = cfg["num_batch"]
524
526
  if num_batch is None and spec and spec.num_batch:
525
527
  if model == spec.local_name:
@@ -589,6 +591,7 @@ def build_parser() -> argparse.ArgumentParser:
589
591
  metavar="SESSION",
590
592
  help="resume a saved session: pick from a list, or give its number, name or id",
591
593
  )
594
+ parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
592
595
  parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
593
596
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
594
597
  parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
@@ -21,7 +21,7 @@ else:
21
21
  STATE_DIR = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "lcode"
22
22
 
23
23
  DEFAULT_MODEL = "qwen3.6-35b"
24
- PERMISSION_MODES = ("ask", "auto-edit", "yolo")
24
+ PERMISSION_MODES = ("ask", "plan", "auto-edit", "yolo") # Shift+Tab cycles in this order
25
25
  WEB_MODES = ("on", "ask", "off")
26
26
  SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
27
27
  MCP_TOOL_MODES = ("auto", "direct", "search")
@@ -35,7 +35,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
35
35
  "num_batch": (None, int, "prompt batch size; larger reads prompts faster but needs more VRAM"),
36
36
  "keep_alive": ("30m", str, "how long Ollama keeps the model loaded after the last request"),
37
37
  "ollama_host": ("http://localhost:11434", str, "Ollama server URL"),
38
- "permission_mode": ("ask", str, "ask | auto-edit | yolo"),
38
+ "permission_mode": ("ask", str, "ask | plan | auto-edit | yolo"),
39
39
  "think": (True, bool, "let the model reason before answering (slower, better)"),
40
40
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
41
41
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
@@ -216,9 +216,9 @@ def _ask(spec: catalog.Input, console: Console) -> str | None:
216
216
  source = " ".join(spec.from_command)
217
217
  if Confirm.ask(f" Use the token from `{source}`?", default=True, console=console):
218
218
  return suggestion
219
- default = f" [{spec.default}]" if spec.default else ""
219
+ default = f" \\[{escape(spec.default)}]" if spec.default else "" # escaped, or rich takes it for markup
220
220
  while True:
221
- value = console.input(f" {spec.prompt}{default}: ", password=spec.secret).strip()
221
+ value = console.input(f" {escape(spec.prompt)}{default}: ", password=spec.secret).strip()
222
222
  value = value or spec.default or ""
223
223
  if value or spec.optional:
224
224
  return value or None
@@ -507,7 +507,7 @@ def reflect(agent: Agent) -> None:
507
507
  where = "every repository" if note.scope == "user" else "this repository"
508
508
  update = memory.find(note.name, note.scope)
509
509
  rows.append(
510
- f"[cyan]{i}[/] [{note.type}] {escape(note.description)} [dim]({where}"
510
+ f"[cyan]{i}[/] \\[{note.type}] {escape(note.description)} [dim]({where}"
511
511
  f"{', updates ' + escape(update.name) if update else ''})[/]"
512
512
  + (f"\n [dim]{escape(note.details)}[/]" if note.details else "")
513
513
  )
@@ -0,0 +1,138 @@
1
+ """Plan mode: agree on an approach before anything changes.
2
+
3
+ In the `plan` permission mode the model can read, search, run read-only commands, use the web and
4
+ read-only subagents, but can't change anything. It ends by presenting a plan with the
5
+ present_plan tool. The user approves it (lcode switches to `ask` or `auto-edit`, fills the todo
6
+ list from the plan's steps and keeps the plan through compaction), edits it, saves it to
7
+ `.lcode/plans/`, or sends it back with feedback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from rich.markdown import Markdown
22
+ from rich.markup import escape
23
+ from rich.panel import Panel
24
+
25
+ from lcode.checkpoints import work_tree_for
26
+ from lcode.memory import slugify
27
+
28
+ if TYPE_CHECKING:
29
+ from lcode.agent import Agent
30
+
31
+ # Tools that can't change anything (bash is limited to read-only commands, agent to read-only types).
32
+ PLAN_MODE_TOOLS = frozenset(
33
+ {
34
+ "read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write",
35
+ "agent", "memory", "present_plan", "mcp_find_tools",
36
+ }
37
+ ) # fmt: skip
38
+
39
+ NOTE = """
40
+
41
+ [lcode] Plan mode is on: you can read, search, run read-only commands, use the web and read-only subagents, but you can't change anything yet. Work out how to do this, then call present_plan with: numbered steps, the files you'll change and how, the risks, and how you'll verify the result. Ask the user only about decisions that are genuinely theirs."""
42
+
43
+ BLOCKED = (
44
+ "Plan mode is on, so nothing can be changed yet. Finish working out the plan and present it with "
45
+ "present_plan; once the user approves it, you can make the changes."
46
+ )
47
+
48
+ CHOICES = (
49
+ " [bold]\\[y][/] run it, asking before changes · [bold]\\[a][/] run it, edits without asking (auto-edit)\n"
50
+ " [bold]\\[e][/] edit the plan · [bold]\\[s][/] save it to .lcode/plans/ · [bold]\\[n][/] keep planning (+ what to change)"
51
+ )
52
+
53
+
54
+ def steps(plan: str, limit: int = 20) -> list[str]:
55
+ """The plan's numbered steps (top level only), for the todo list."""
56
+ found = []
57
+ for line in plan.splitlines():
58
+ m = re.match(r"^ {0,3}\d+[.)]\s+(.+)", line)
59
+ if m:
60
+ # Plain text for the todo list: no bold, code or emphasis marks (underscores in names stay).
61
+ text = m.group(1).replace("**", "").replace("__", "").replace("`", "")
62
+ text = re.sub(r"(?<!\w)[*_]([^*_]+)[*_](?!\w)", r"\1", text).strip().rstrip(":").strip()
63
+ found.append(text if len(text) <= 120 else text[:119].rstrip() + "…")
64
+ return found[:limit]
65
+
66
+
67
+ def edit_text(text: str) -> str:
68
+ """Let the user edit `text` in their editor; returns the result."""
69
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("nano" if shutil.which("nano") else "vi")
70
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
71
+ f.write(text)
72
+ path = Path(f.name)
73
+ try:
74
+ subprocess.call([*shlex.split(editor), str(path)])
75
+ return path.read_text()
76
+ finally:
77
+ path.unlink(missing_ok=True)
78
+
79
+
80
+ def save(agent: Agent, title: str, plan: str) -> Path:
81
+ folder = work_tree_for(agent.cwd) / ".lcode" / "plans"
82
+ folder.mkdir(parents=True, exist_ok=True)
83
+ path = folder / f"{slugify(title) or 'plan'}.md"
84
+ path.write_text(f"# {title}\n\n{plan.strip()}\n")
85
+ return path
86
+
87
+
88
+ def present(agent: Agent, title: str, plan: str) -> str:
89
+ """Show the plan and let the user decide. Returns the tool result for the model."""
90
+ c = agent.console
91
+ title = " ".join(title.split()) or "Plan"
92
+ plan = plan.strip()
93
+ if not plan:
94
+ return "Error: the plan is empty. Write the steps, the files, the risks and how you'll verify the work."
95
+ edited = False
96
+ while True:
97
+ c.print(Panel(Markdown(plan), title=f"Plan: {escape(title)}", title_align="left", border_style="cyan"))
98
+ if not agent.interactive:
99
+ return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
100
+ c.print(CHOICES)
101
+ try:
102
+ answer = input(" > ").strip()
103
+ except EOFError:
104
+ return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
105
+ word = answer.lower()
106
+ if word in ("", "y", "yes"):
107
+ return approve(agent, title, plan, "ask", edited)
108
+ if word in ("a", "auto", "auto-edit"):
109
+ return approve(agent, title, plan, "auto-edit", edited)
110
+ if word in ("e", "edit"):
111
+ new = edit_text(plan).strip()
112
+ if new and new != plan:
113
+ plan, edited = new, True
114
+ continue
115
+ if word in ("s", "save"):
116
+ path = save(agent, title, plan)
117
+ c.print(f"[green]Saved to {escape(agent.tools.rel(path))}[/]")
118
+ continue
119
+ feedback = re.sub(r"^(no|n)\b[\s,:-]*", "", answer, flags=re.I).strip()
120
+ if feedback:
121
+ return f"The user wants changes to the plan: {feedback}\nStay in plan mode: revise the plan and present it again."
122
+ return "The user didn't approve the plan. Ask them what they'd like to change."
123
+
124
+
125
+ def approve(agent: Agent, title: str, plan: str, mode: str, edited: bool) -> str:
126
+ agent.perms.mode = mode
127
+ agent.plan = f"# {title}\n\n{plan}"
128
+ todo = steps(plan)
129
+ if todo:
130
+ agent.tools.todos = [{"content": step, "status": "pending"} for step in todo]
131
+ agent.tools.show_todos()
132
+ how = "file edits no longer ask (auto-edit)" if mode == "auto-edit" else "changes ask for approval as usual"
133
+ agent.console.print(f"[green]Plan approved.[/] Mode: {mode} — {how}.")
134
+ result = f"The user approved the plan, and plan mode is off ({mode} mode: {how}). Carry it out now, step by step"
135
+ result += ", keeping the todo list up to date." if todo else "."
136
+ if edited:
137
+ result += f"\nThe user edited the plan first. This is the approved version:\n\n{plan}"
138
+ return result
@@ -18,6 +18,7 @@ from prompt_toolkit.formatted_text import HTML
18
18
  from prompt_toolkit.history import FileHistory
19
19
  from prompt_toolkit.key_binding import KeyBindings
20
20
  from rich.console import Group
21
+ from rich.markdown import Markdown
21
22
  from rich.markup import escape
22
23
  from rich.panel import Panel
23
24
  from rich.rule import Rule
@@ -55,7 +56,8 @@ COMMANDS = {
55
56
  "/models": "List models and how they fit this machine",
56
57
  "/think": "Toggle model reasoning on/off",
57
58
  "/verbose": "Toggle showing the model's reasoning text",
58
- "/mode": "Permission mode: ask | auto-edit | yolo (Shift+Tab cycles)",
59
+ "/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
60
+ "/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
59
61
  "/cd": "Change the working directory",
60
62
  "/todos": "Show the current todo list",
61
63
  "/exit": "Quit",
@@ -118,7 +120,7 @@ def build_session(agent: Agent) -> PromptSession:
118
120
  def toolbar():
119
121
  s = agent.settings
120
122
  pct = 100 * agent.ctx_used / s.context
121
- color = {"ask": "ansigreen", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
123
+ color = {"ask": "ansigreen", "plan": "ansicyan", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
122
124
  return HTML(
123
125
  f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
124
126
  f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
@@ -772,6 +774,20 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
772
774
  elif cmd == "/verbose":
773
775
  s.show_thinking = not s.show_thinking
774
776
  c.print(f"Showing reasoning text: {'on' if s.show_thinking else 'off'}.")
777
+ elif cmd == "/plan":
778
+ if not arg:
779
+ if agent.plan:
780
+ c.print(Panel(Markdown(agent.plan), title="Approved plan", title_align="left", border_style="cyan"))
781
+ else:
782
+ c.print(
783
+ "No approved plan in this session. [bold]/plan <request>[/] works out a plan first, without "
784
+ "changing anything (or Shift+Tab to plan mode)."
785
+ )
786
+ return True
787
+ agent.perms.mode = "plan"
788
+ c.print("[cyan]Plan mode:[/] the model can look around but not change anything until you approve its plan.")
789
+ c.print(Rule(style="dim"))
790
+ run_safely(agent, arg)
775
791
  elif cmd == "/mode":
776
792
  if arg in PERMISSION_MODES:
777
793
  agent.perms.mode = arg
@@ -24,6 +24,7 @@ from rich.text import Text
24
24
 
25
25
  from lcode import web
26
26
  from lcode.permissions import bash_key, is_read_only
27
+ from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
27
28
  from lcode.sandbox import SandboxError
28
29
  from lcode.vision import is_image
29
30
 
@@ -198,8 +199,22 @@ MEMORY_SCHEMA = _fn(
198
199
  },
199
200
  ["action"],
200
201
  )
202
+ PRESENT_PLAN_SCHEMA = _fn(
203
+ "present_plan",
204
+ "Plan mode: show the user your plan and ask for approval. Once approved, plan mode ends and you carry it out.",
205
+ {
206
+ "title": {"type": "string", "description": "A short name for the plan, e.g. 'Add retries to webhooks'"},
207
+ "plan": {
208
+ "type": "string",
209
+ "description": "Markdown: numbered steps, the files you'll change and how, the risks, and how you'll "
210
+ "verify the result",
211
+ },
212
+ },
213
+ ["title", "plan"],
214
+ )
201
215
  TOOL_NAMES = {
202
- s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA]
216
+ s["function"]["name"]
217
+ for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA, PRESENT_PLAN_SCHEMA]
203
218
  }
204
219
 
205
220
 
@@ -337,6 +352,10 @@ class Toolbox:
337
352
  allowed = self.agent.allowed_tools
338
353
  if allowed is not None and name not in allowed:
339
354
  return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
355
+ if self.agent.planning() and name not in PLAN_MODE_TOOLS:
356
+ return f"Error: {BLOCKED}"
357
+ if name == "present_plan" and not self.agent.planning():
358
+ return "Error: plan mode is off, so there's nothing to present: carry on with the work."
340
359
  if self.agent.mcp and self.agent.mcp.owns(name):
341
360
  return self._mcp(name, args)
342
361
  fn = getattr(self, f"t_{name}", None)
@@ -376,8 +395,17 @@ class Toolbox:
376
395
  raise ToolError("subagents are turned off")
377
396
  from lcode import subagents
378
397
 
398
+ kind = self.agent.agent_types().get(type)
399
+ if self.agent.planning() and kind and not kind.read_only:
400
+ raise ToolError(f"plan mode is on: only read-only agents can run now ({BLOCKED})")
379
401
  return subagents.run_one(self.agent, {"type": type, "task": task, "description": description})
380
402
 
403
+ # -- plan mode
404
+ def t_present_plan(self, title: str, plan: str) -> str:
405
+ from lcode.planning import present
406
+
407
+ return present(self.agent, title, plan)
408
+
381
409
  # -- memory
382
410
  def t_memory(
383
411
  self, action: str, name: str = "", type: str = "", description: str = "", details: str = "", scope: str = ""
@@ -605,6 +633,8 @@ class Toolbox:
605
633
  # -- shell
606
634
  def t_bash(self, command: str, timeout: int = 180) -> str:
607
635
  sandbox = self.agent.sandbox
636
+ if self.agent.planning() and not is_read_only(command):
637
+ raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
608
638
  if self.agent.read_only and not is_read_only(command):
609
639
  raise ToolError(
610
640
  "you can only run read-only commands (such as ls, cat, grep, find, git log, git diff), one at a "
@@ -556,3 +556,18 @@ def test_gpu_tools_run_to_completion(make_agent, tmp_path, monkeypatch):
556
556
  agent.run_turn("make an image")
557
557
  assert sent == [{"a": 1, "b": 2, "wait": True}]
558
558
  agent.mcp.close()
559
+
560
+
561
+ def test_input_prompts_show_their_default(monkeypatch):
562
+ import io
563
+
564
+ from rich.console import Console
565
+
566
+ from lcode.mcp.catalog import Input
567
+ from lcode.mcp.commands import _ask
568
+
569
+ console = Console(file=io.StringIO(), force_terminal=False)
570
+ monkeypatch.delenv("AWS_REGION", raising=False)
571
+ monkeypatch.setattr("builtins.input", lambda *args: "")
572
+ assert _ask(Input("AWS_REGION", "AWS region", default="us-east-1"), console) == "us-east-1"
573
+ assert "AWS region [us-east-1]: " in console.file.getvalue() # not swallowed as rich markup
@@ -246,6 +246,7 @@ def test_reflection_with_memory_ask_saves_only_what_the_user_picks(make_agent, m
246
246
  answers(monkeypatch, "2")
247
247
  memory_notes.reflect(agent)
248
248
  assert [n.name for n in agent.memory().notes()] == ["vpn"]
249
+ assert "1 [feedback] Use pnpm, not npm (this repository)" in output(agent)
249
250
 
250
251
 
251
252
  def test_reflection_with_memory_ask_saves_nothing_without_approval(make_agent, monkeypatch):
@@ -0,0 +1,161 @@
1
+ import io
2
+
3
+ from rich.console import Console
4
+
5
+ from conftest import output, reply
6
+ from lcode import config, planning
7
+ from lcode.hardware import Hardware
8
+ from lcode.permissions import Permissions
9
+ from lcode.repl import handle_command
10
+
11
+ HW = Hardware("linux", "x", 31, "GPU", 12)
12
+ PLAN = (
13
+ "1. Add `mul` to **src/pkg/math.py**\n2. Test it\n - with negative numbers\n\n"
14
+ "Risks: none.\nVerify: run the tests."
15
+ )
16
+
17
+
18
+ def tool(name, **arguments):
19
+ return {"function": {"name": name, "arguments": arguments}}
20
+
21
+
22
+ def answers(monkeypatch, *replies):
23
+ queue = list(replies)
24
+ monkeypatch.setattr("builtins.input", lambda prompt="": queue.pop(0))
25
+
26
+
27
+ def present(title="Add mul", plan=PLAN):
28
+ return tool("present_plan", title=title, plan=plan)
29
+
30
+
31
+ def test_plan_mode_blocks_every_change(make_agent, repo):
32
+ agent = make_agent(
33
+ [
34
+ reply(
35
+ tool_calls=[
36
+ tool("read_file", path="README.md"),
37
+ tool("bash", command="cd src && ls"),
38
+ tool("edit_file", path="README.md", old_string="Demo", new_string="X"),
39
+ tool("write_file", path="new.txt", content="x"),
40
+ tool("bash", command="touch made"),
41
+ tool("agent", type="worker", task="change things"),
42
+ ]
43
+ ),
44
+ reply("Here's what I found."),
45
+ ],
46
+ mode="plan",
47
+ subagents=True,
48
+ )
49
+ agent.run_turn("add mul")
50
+ assert agent.messages[1]["content"].endswith(planning.NOTE)
51
+ assert "present_plan" in {t["function"]["name"] for t in agent.ollama.payloads[0]["tools"]}
52
+ results = [m["content"] for m in agent.messages if m["role"] == "tool"]
53
+ assert "# Demo" in results[0] and "pkg" in results[1]
54
+ assert all("Plan mode is on" in r for r in results[2:]), results[2:]
55
+ assert (repo / "README.md").read_text() == "# Demo\n"
56
+ assert not (repo / "new.txt").exists() and not (repo / "made").exists()
57
+
58
+
59
+ def test_approving_a_plan_switches_mode_and_fills_the_todo_list(make_agent, repo, monkeypatch):
60
+ agent = make_agent(
61
+ [
62
+ reply(tool_calls=[present()]),
63
+ reply(tool_calls=[tool("write_file", path="new.txt", content="x\n")]),
64
+ reply("Done."),
65
+ ],
66
+ mode="plan",
67
+ )
68
+ answers(monkeypatch, "a")
69
+ agent.run_turn("add mul")
70
+ assert agent.perms.mode == "auto-edit"
71
+ assert "[y] run it, asking before changes · [a] run it, edits without asking" in output(agent)
72
+ assert agent.plan.startswith("# Add mul\n\n1. Add `mul`")
73
+ assert agent.tools.todos == [
74
+ {"content": "Add mul to src/pkg/math.py", "status": "pending"},
75
+ {"content": "Test it", "status": "pending"},
76
+ ]
77
+ assert agent.messages[3]["content"].startswith("The user approved the plan, and plan mode is off (auto-edit mode")
78
+ assert (repo / "new.txt").read_text() == "x\n" # carried out in the same request
79
+ assert "present_plan" not in {t["function"]["name"] for t in agent.ollama.payloads[1]["tools"]}
80
+
81
+
82
+ def test_y_approves_in_ask_mode(make_agent, monkeypatch):
83
+ agent = make_agent([reply(tool_calls=[present()]), reply("Starting.")], mode="plan")
84
+ answers(monkeypatch, "")
85
+ agent.run_turn("add mul")
86
+ assert agent.perms.mode == "ask"
87
+ assert "(ask mode: changes ask for approval as usual)" in agent.messages[3]["content"]
88
+
89
+
90
+ def test_a_plan_sent_back_with_feedback_stays_in_plan_mode(make_agent, monkeypatch):
91
+ agent = make_agent([reply(tool_calls=[present()]), reply("I'll revise it.")], mode="plan")
92
+ answers(monkeypatch, "add tests for overflow too") # starts with "a", but isn't an approval
93
+ agent.run_turn("add mul")
94
+ assert agent.perms.mode == "plan" and agent.plan == ""
95
+ assert agent.messages[3]["content"].startswith("The user wants changes to the plan: add tests for overflow too")
96
+
97
+
98
+ def test_edit_and_save_a_plan_before_approving(make_agent, repo, monkeypatch):
99
+ agent = make_agent([reply(tool_calls=[present()]), reply("Starting.")], mode="plan")
100
+ monkeypatch.setattr(planning, "edit_text", lambda text: text.replace("Test it", "Test it twice"))
101
+ answers(monkeypatch, "e", "s", "y")
102
+ agent.run_turn("add mul")
103
+ saved = repo / ".lcode" / "plans" / "add-mul.md"
104
+ assert "Test it twice" in saved.read_text()
105
+ result = agent.messages[3]["content"]
106
+ assert "The user edited the plan first" in result and "2. Test it twice" in result
107
+ assert agent.tools.todos[1]["content"] == "Test it twice"
108
+
109
+
110
+ def test_without_a_terminal_the_plan_waits_for_review(make_agent):
111
+ agent = make_agent([reply(tool_calls=[present()]), reply("Waiting.")], mode="plan")
112
+ agent.interactive = False
113
+ agent.run_turn("add mul")
114
+ assert agent.perms.mode == "plan"
115
+ assert "review it later" in agent.messages[3]["content"]
116
+
117
+
118
+ def test_present_plan_outside_plan_mode_is_refused(make_agent):
119
+ agent = make_agent([reply(tool_calls=[present()]), reply("ok")], mode="ask")
120
+ agent.run_turn("go")
121
+ assert agent.messages[3]["content"].startswith("Error: plan mode is off")
122
+ assert "present_plan" not in {t["function"]["name"] for t in agent.ollama.payloads[0]["tools"]}
123
+
124
+
125
+ def test_the_approved_plan_survives_compaction(make_agent):
126
+ agent = make_agent([reply("ok"), reply("Summary.")])
127
+ agent.run_turn("hi")
128
+ agent.plan = "# Add mul\n\n1. Add it"
129
+ agent.compact()
130
+ assert agent.messages[1]["content"].endswith(
131
+ "[The plan the user approved; keep following it]\n\n# Add mul\n\n1. Add it"
132
+ )
133
+
134
+
135
+ def test_plan_command(make_agent, monkeypatch):
136
+ agent = make_agent([reply(tool_calls=[present()]), reply("Starting.")], mode="ask")
137
+ handle_command(agent, "/plan", HW)
138
+ assert "No approved plan" in output(agent)
139
+ answers(monkeypatch, "y")
140
+ handle_command(agent, "/plan add mul", HW)
141
+ assert agent.messages[1]["content"].startswith("add mul") and planning.NOTE in agent.messages[1]["content"]
142
+ assert agent.perms.mode == "ask"
143
+ handle_command(agent, "/plan", HW)
144
+ assert "Approved plan" in output(agent)
145
+
146
+
147
+ def test_shift_tab_reaches_plan_mode_first():
148
+ perms = Permissions(Console(file=io.StringIO()), "ask")
149
+ seen = []
150
+ for _ in range(4):
151
+ perms.cycle()
152
+ seen.append(perms.mode)
153
+ assert seen == ["plan", "auto-edit", "yolo", "ask"]
154
+ assert config.coerce("permission_mode", "plan") == "plan"
155
+
156
+
157
+ def test_steps_come_from_the_numbered_lines():
158
+ assert planning.steps(PLAN) == ["Add mul to src/pkg/math.py", "Test it"]
159
+ real = "1. **`calc.py`** — add a `div(a, b)` function after `mul`:\n2. **`test_calc.py`** — add *two* tests:"
160
+ assert planning.steps(real) == ["calc.py — add a div(a, b) function after mul", "test_calc.py — add two tests"]
161
+ assert planning.steps("No numbers here") == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes