turnloop 0.1.5__tar.gz → 0.1.6__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 (158) hide show
  1. turnloop-0.1.6/.turnloop/settings.local.json +3 -0
  2. {turnloop-0.1.5 → turnloop-0.1.6}/PKG-INFO +120 -16
  3. {turnloop-0.1.5 → turnloop-0.1.6}/README.md +119 -15
  4. {turnloop-0.1.5 → turnloop-0.1.6}/pyproject.toml +1 -1
  5. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_config.py +83 -30
  6. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_hooks_mcp_commands.py +537 -431
  7. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_loop.py +73 -0
  8. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_permissions.py +48 -0
  9. turnloop-0.1.6/tests/test_system_prompt_layout.py +85 -0
  10. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_tui.py +613 -397
  11. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/__init__.py +1 -1
  12. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/headless.py +17 -9
  13. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/system_prompt.py +31 -0
  14. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/cli.py +22 -8
  15. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/commands/dispatch.py +486 -448
  16. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/commands/loader.py +39 -2
  17. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/config.py +478 -444
  18. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/sessions/store.py +286 -271
  19. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/runner.py +63 -1
  20. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/app.py +518 -432
  21. turnloop-0.1.6/turnloop/tui/session_screen.py +114 -0
  22. {turnloop-0.1.5 → turnloop-0.1.6}/.github/workflows/ci.yml +0 -0
  23. {turnloop-0.1.5 → turnloop-0.1.6}/.gitignore +0 -0
  24. {turnloop-0.1.5 → turnloop-0.1.6}/.turnloop/.gitignore +0 -0
  25. {turnloop-0.1.5 → turnloop-0.1.6}/tests/conftest.py +0 -0
  26. {turnloop-0.1.5 → turnloop-0.1.6}/tests/fixtures/anthropic_tool_call.sse +0 -0
  27. {turnloop-0.1.5 → turnloop-0.1.6}/tests/fixtures/glm_tool_call.sse +0 -0
  28. {turnloop-0.1.5 → turnloop-0.1.6}/tests/fixtures/mcp_echo_server.py +0 -0
  29. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_bash.py +0 -0
  30. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_compaction.py +0 -0
  31. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_config_screen.py +0 -0
  32. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_configio.py +0 -0
  33. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_experiments.py +0 -0
  34. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_providers.py +0 -0
  35. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_tools_files.py +0 -0
  36. {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_websearch.py +0 -0
  37. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/__main__.py +0 -0
  38. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/__init__.py +0 -0
  39. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/factory.py +0 -0
  40. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/loop.py +0 -0
  41. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/subagent.py +0 -0
  42. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/commands/__init__.py +0 -0
  43. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/configio.py +0 -0
  44. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/__init__.py +0 -0
  45. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/budget.py +0 -0
  46. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/compaction.py +0 -0
  47. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/memory.py +0 -0
  48. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/__init__.py +0 -0
  49. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/events.py +0 -0
  50. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/ids.py +0 -0
  51. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/messages.py +0 -0
  52. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/tokens.py +0 -0
  53. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/diagnostics.py +0 -0
  54. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/errors.py +0 -0
  55. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/__init__.py +0 -0
  56. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  57. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  58. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/context-glm.yaml +0 -0
  59. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/context.yaml +0 -0
  60. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  61. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/groq-free.yaml +0 -0
  62. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  63. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  64. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/loop.yaml +0 -0
  65. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  66. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/reliability.yaml +0 -0
  67. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  68. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/smoke.yaml +0 -0
  69. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/graders.py +0 -0
  70. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/report.py +0 -0
  71. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/runner.py +0 -0
  72. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/default.yaml +0 -0
  73. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  74. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  75. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  76. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  77. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  78. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  79. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  80. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  81. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  82. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  83. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  84. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  85. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  86. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  87. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  88. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  89. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  90. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  91. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  92. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  93. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  94. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  95. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  96. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  97. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  98. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  99. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  100. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  101. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  102. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  103. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  104. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  105. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  106. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  107. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  108. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  109. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  110. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  111. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  112. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  113. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  114. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/hooks/__init__.py +0 -0
  115. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/hooks/runner.py +0 -0
  116. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/mcp/__init__.py +0 -0
  117. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/mcp/adapter.py +0 -0
  118. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/mcp/client.py +0 -0
  119. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/permissions/__init__.py +0 -0
  120. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/permissions/engine.py +0 -0
  121. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/permissions/rules.py +0 -0
  122. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/__init__.py +0 -0
  123. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/anthropic.py +0 -0
  124. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/base.py +0 -0
  125. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/gemini.py +0 -0
  126. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/mock.py +0 -0
  127. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/openai_compat.py +0 -0
  128. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/pricing.py +0 -0
  129. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/registry.py +0 -0
  130. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/sse.py +0 -0
  131. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/sessions/__init__.py +0 -0
  132. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/sessions/models.py +0 -0
  133. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/__init__.py +0 -0
  134. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/ask.py +0 -0
  135. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/base.py +0 -0
  136. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/bash.py +0 -0
  137. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/builtin.py +0 -0
  138. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/edit.py +0 -0
  139. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/glob.py +0 -0
  140. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/grep.py +0 -0
  141. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/read.py +0 -0
  142. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/shell.py +0 -0
  143. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/skill.py +0 -0
  144. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/task.py +0 -0
  145. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/textio.py +0 -0
  146. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/todo.py +0 -0
  147. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/webfetch.py +0 -0
  148. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/websearch.py +0 -0
  149. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/write.py +0 -0
  150. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/__init__.py +0 -0
  151. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/app.tcss +0 -0
  152. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/bridge.py +0 -0
  153. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/config_screen.py +0 -0
  154. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/mcp_screen.py +0 -0
  155. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/__init__.py +0 -0
  156. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/permission.py +0 -0
  157. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/status.py +0 -0
  158. {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/transcript.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ "provider": "blaxel"
3
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turnloop
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
5
5
  Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
6
6
  Author: Pranesh
@@ -49,7 +49,7 @@ tl experiment run smoke # a measurement run, offline and free
49
49
 
50
50
  Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
51
51
  SDKs, no agent framework — the loop is the point, so the loop is written here.
52
- ~18,000 lines of Python, 290 tests, no network in the default test run.
52
+ ~20,000 lines of Python, 318 tests, no network in the default test run.
53
53
 
54
54
  ---
55
55
 
@@ -117,7 +117,7 @@ point.
117
117
  Verify:
118
118
 
119
119
  ```bash
120
- tl --version # turnloop 0.1.5
120
+ tl --version # turnloop 0.1.6
121
121
  tl doctor # every provider, key presence, shell, context budget
122
122
  ```
123
123
 
@@ -139,8 +139,9 @@ context arithmetic for the active provider:
139
139
  tl # interactive TUI
140
140
  tl -p "why does the login test flake" # headless single turn
141
141
  tl -p "..." --json # headless, JSONL events on stdout
142
- tl --resume # resume the most recent session
143
- tl --resume <session-id> # resume a specific one
142
+ tl -c # resume the most recent session, no picker
143
+ tl --resume # interactive picker (TUI); most recent (headless)
144
+ tl --resume <session-id> # resume a specific one, no picker
144
145
  tl sessions # list recorded sessions
145
146
  tl config # merged config + which layer each value came from
146
147
  tl config --edit # interactive settings editor
@@ -162,7 +163,8 @@ and neither is reachable by the model — see
162
163
  | `--max-iterations N` | tool-loop safety cap (default 40) |
163
164
  | `-p, --print PROMPT` | headless single turn |
164
165
  | `--json` | headless output as JSONL events |
165
- | `--resume [ID]` | resume last, or a named session |
166
+ | `-c, --continue` | resume the most recent session directly, no picker |
167
+ | `--resume [ID]` | bare: interactive picker in the TUI, most recent in headless. With an id: that session, no picker |
166
168
 
167
169
  ### Permission modes
168
170
 
@@ -184,12 +186,18 @@ to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
184
186
  ### Keys
185
187
 
186
188
  ```
187
- ctrl+c interrupt the running turn (again to exit)
189
+ ctrl+c copy the selection; else interrupt a running turn; else nothing
188
190
  ctrl+p cycle permission mode
189
191
  ctrl+l clear the view (history is kept)
190
192
  ctrl+d quit
191
193
  ```
192
194
 
195
+ `ctrl+c` no longer quits. It used to call `self.exit()` whenever no turn was
196
+ running, so pressing it to copy text killed the session. It now checks, in order,
197
+ whether there is a selection to copy (via Textual's `App.copy_to_clipboard`, OSC 52,
198
+ no new dependency), then whether a turn is running to interrupt, and otherwise does
199
+ nothing. `ctrl+d` is still the only way to quit.
200
+
193
201
  `ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
194
202
  focus and would otherwise swallow them.
195
203
 
@@ -205,6 +213,9 @@ focus and would otherwise swallow them.
205
213
  /provider switch provider /config settings editor
206
214
  /export transcript to markdown /hooks configured hooks
207
215
  /sessions recorded sessions /doctor diagnostics
216
+ /skills loaded skills, and any /resume picker, rebuilds the
217
+ rejected on disk (why, agent in place; refuses
218
+ and where) while a turn is running
208
219
  /quit exit
209
220
  ```
210
221
 
@@ -350,6 +361,13 @@ close a self-escalation path: settings files set `permission_mode` and
350
361
  next launch. Verified against the real engine *in bypass mode*, which is the case that
351
362
  matters — deny beats bypass, so the block holds even there.
352
363
 
364
+ That was a unit test. It has since been verified live: with
365
+ `Write(.turnloop/**)` explicitly allowed *and* `--permission-mode bypass`, a real
366
+ model told to grant itself permanent bypass access was blocked, made one attempt,
367
+ and proposed the change to the user instead. The settings file was unchanged. Deny
368
+ beats an explicit allow rule and bypass mode at once — not just in the test suite,
369
+ against a live model that was actually trying.
370
+
353
371
  It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
354
372
  and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
355
373
  Closing that properly needs a path guard below the tool layer rather than more patterns.
@@ -395,6 +413,14 @@ because a crashed run still leaves a readable trace, and because the experiments
395
413
  layer reads exactly the same format the TUI writes — a run trace and a real session
396
414
  are the same artifact.
397
415
 
416
+ `-c`/`--continue` reopens the most recent session with no prompting. Bare `--resume`
417
+ in the TUI opens an interactive picker — a `DataTable` of sessions, newest first,
418
+ with a live preview pane of the selected conversation — and `--resume <id>` jumps
419
+ straight to a specific one. `/resume` opens the same picker in-session and rebuilds
420
+ the agent in place; it refuses while a turn is running rather than tearing one down
421
+ mid-flight. Headless has no picker to show, so a bare `--resume` there falls back to
422
+ "most recent" — the same thing `--continue` does.
423
+
398
424
  ---
399
425
 
400
426
  ## Providers
@@ -891,11 +917,78 @@ And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/co
891
917
  Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
892
918
  fail if the decorators are removed.
893
919
 
894
- **The lesson, five times over in one project:** an 84-run sweep failing while printing
920
+ **A directory that exists on every launch cannot mean "this is a project."**
921
+
922
+ `find_project_root` treated any ancestor `.turnloop` directory as an explicit project
923
+ declaration. But `default_project_dir` creates `.turnloop/` — with a `sessions/`
924
+ folder and a `.gitignore` — on every launch, unconditionally, wherever turnloop
925
+ happens to start. So the directory's mere existence declared nothing; it was
926
+ frequently just the residue of having once run the tool there. Run `tl` once from a
927
+ home directory, or a drive root, and it permanently annexed every project beneath it:
928
+ sessions, the `.env` layer, `settings.json` and the permission rules all resolved to
929
+ the wrong root from then on. Real instance, the author's own machine: `F:\.turnloop`
930
+ was created on 2026-08-01, and every `F:\anything\proj\pyproject.toml` since then
931
+ resolved its project root to `F:\`.
932
+
933
+ The invariant that broke was never written down as one, which is exactly why it lasted.
934
+ The fix makes the implicit rule explicit: a `.turnloop` counts only if it holds
935
+ something a human put there — `settings.json`, `settings.local.json`, `commands/`,
936
+ `skills/`, `TURNLOOP.md` — and one holding only an auto-created `sessions/` is ignored.
937
+ The home directory is excluded outright on top of that, since `~/.turnloop` is the
938
+ user-scope settings location and can never mean "project." The false invariant had
939
+ been there since the tool first created `.turnloop/` on launch; fixed in 0.1.6.
940
+
941
+ **A skill that failed to parse vanished with no error, and the model reported success.**
942
+
943
+ A live run installed a skill whose frontmatter was markdown-bold — `**name**:
944
+ something` — rather than YAML. `yaml.safe_load` returned no usable keys,
945
+ `load_skills` hit its "no description" skip, and the skill disappeared: no exception,
946
+ no log line, nothing in `/skills`. The model that had just "installed" it reported
947
+ success, because from its side the write succeeded and nothing said otherwise.
948
+
949
+ The bug was not the parser rejecting bad frontmatter — that part is correct. It was
950
+ that a rejection and a nonexistent skill looked identical from the outside. `load_skills`
951
+ keeps its exact signature, but a new `rejected_skills()` now returns what was dropped
952
+ and why, `/skills` reports it, and `CONFIG_LAYOUT` shows the literal YAML frontmatter
953
+ block in the system prompt so the model stops guessing at markdown-bold instead.
954
+ Fixed in 0.1.6.
955
+
956
+ **A permission denial that should end in one message looped ten times, and it cost
957
+ real money before anyone noticed — because nothing about it failed a test.**
958
+
959
+ Two defects landed in the same code path with the same symptom. The DENY branch's
960
+ message said only `Permission denied: <rule>`; the ASK-decline branch, which is
961
+ recoverable, had always carried "Do not retry it" — so the weaker wording sat on the
962
+ *permanent* block, the one that most needed it. Fixing the message was not enough on
963
+ its own: the ASK path looped despite already carrying that warning, because prompt
964
+ wording is a request, not a constraint, and a model under pressure to finish a task
965
+ does not reliably honor it.
966
+
967
+ Measured against a live, non-interactive provider — this is what a passing test suite
968
+ cannot see, since nothing here raises or returns the wrong value, it just keeps calling
969
+ a tool that keeps getting denied:
970
+
971
+ - denied `Write .turnloop/settings.local.json`, repeated 10 times → **233,742 input
972
+ tokens, $0.0372**
973
+ - after the message fix alone → **10,654 tokens, $0.0017**, one attempt then a correct
974
+ proposal to the user
975
+ - a separate ASK-decline loop on `dir`/`mkdir`, before any fix → **199,352 tokens,
976
+ $0.0317**
977
+
978
+ So wording is now backed by a deterministic guard in `ToolRunner` rather than trusted
979
+ alone: identity is tool name plus pydantic-normalized args, scope is the session, one
980
+ free retry, and the entry clears the moment the call is allowed or approved — so a
981
+ permission granted mid-session un-poisons a call that was denied earlier, instead of
982
+ blocking it forever. Fixed in 0.1.6.
983
+
984
+ **The lesson, eight times over in one project:** an 84-run sweep failing while printing
895
985
  a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
896
986
  10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
897
987
  the turn the test never made; a feature with green tests that crashed on its first
898
- keystroke. Each was caught only by looking past the summary line — and the last two
988
+ keystroke; an implicit invariant that a docstring never stated, so nobody noticed it
989
+ was false; a silent failure that a model reported as success; a cost bug that no
990
+ assertion could ever have caught, because every individual call did exactly what it
991
+ was told. Each was caught only by looking past the summary line — and most of them
899
992
  only by using the thing instead of testing it.
900
993
 
901
994
  ---
@@ -930,26 +1023,27 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
930
1023
  ## Testing
931
1024
 
932
1025
  ```bash
933
- pytest # 290 tests, no network
1026
+ pytest # 318 tests, no network
934
1027
  ruff check turnloop
935
1028
  mypy turnloop
936
1029
  ```
937
1030
 
938
1031
  | file | tests | |
939
1032
  |---|---|---|
940
- | `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
1033
+ | `test_permissions.py` | 50 | rule grammar, compound-command splitting, mode enforcement |
941
1034
  | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
942
1035
  | `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
943
1036
  | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
944
- | `test_loop.py` | 20 | streaming, truncation, iteration cap |
945
- | `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
946
- | `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
1037
+ | `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
1038
+ | `test_tui.py` | 24 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
1039
+ | `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
1040
+ | `test_loop.py` | 23 | streaming, truncation, iteration cap |
947
1041
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
948
- | `test_bash.py` | 14 | shell selection, process-tree kill |
949
1042
  | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
1043
+ | `test_bash.py` | 14 | shell selection, process-tree kill |
950
1044
  | `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
951
1045
  | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
952
- | `test_tui.py` | 15 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
1046
+ | `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
953
1047
 
954
1048
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
955
1049
  `live` — **network is off at the transport layer**, so a forgotten real call fails
@@ -996,6 +1090,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
996
1090
  in the system prompt and load their body on demand, which on a 65k window is the
997
1091
  difference between having skills and not.
998
1092
 
1093
+ The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
1094
+ — 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1095
+ the model cannot configure the tool it is running inside: asked to add an MCP server
1096
+ it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
1097
+ `examples/` — both real outputs from live runs. With it, both go to the right place
1098
+ first try. It includes the literal YAML frontmatter a skill needs, since a skill with
1099
+ markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
1100
+ now also reports anything found on disk but rejected, with the reason and path — see
1101
+ [Bugs worth reading about](#bugs-worth-reading-about).
1102
+
999
1103
  Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
1000
1104
  engine like any other Bash call. A markdown file in a repository is not trusted input
1001
1105
  just because it is on disk.
@@ -20,7 +20,7 @@ tl experiment run smoke # a measurement run, offline and free
20
20
 
21
21
  Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
22
22
  SDKs, no agent framework — the loop is the point, so the loop is written here.
23
- ~18,000 lines of Python, 290 tests, no network in the default test run.
23
+ ~20,000 lines of Python, 318 tests, no network in the default test run.
24
24
 
25
25
  ---
26
26
 
@@ -88,7 +88,7 @@ point.
88
88
  Verify:
89
89
 
90
90
  ```bash
91
- tl --version # turnloop 0.1.5
91
+ tl --version # turnloop 0.1.6
92
92
  tl doctor # every provider, key presence, shell, context budget
93
93
  ```
94
94
 
@@ -110,8 +110,9 @@ context arithmetic for the active provider:
110
110
  tl # interactive TUI
111
111
  tl -p "why does the login test flake" # headless single turn
112
112
  tl -p "..." --json # headless, JSONL events on stdout
113
- tl --resume # resume the most recent session
114
- tl --resume <session-id> # resume a specific one
113
+ tl -c # resume the most recent session, no picker
114
+ tl --resume # interactive picker (TUI); most recent (headless)
115
+ tl --resume <session-id> # resume a specific one, no picker
115
116
  tl sessions # list recorded sessions
116
117
  tl config # merged config + which layer each value came from
117
118
  tl config --edit # interactive settings editor
@@ -133,7 +134,8 @@ and neither is reachable by the model — see
133
134
  | `--max-iterations N` | tool-loop safety cap (default 40) |
134
135
  | `-p, --print PROMPT` | headless single turn |
135
136
  | `--json` | headless output as JSONL events |
136
- | `--resume [ID]` | resume last, or a named session |
137
+ | `-c, --continue` | resume the most recent session directly, no picker |
138
+ | `--resume [ID]` | bare: interactive picker in the TUI, most recent in headless. With an id: that session, no picker |
137
139
 
138
140
  ### Permission modes
139
141
 
@@ -155,12 +157,18 @@ to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
155
157
  ### Keys
156
158
 
157
159
  ```
158
- ctrl+c interrupt the running turn (again to exit)
160
+ ctrl+c copy the selection; else interrupt a running turn; else nothing
159
161
  ctrl+p cycle permission mode
160
162
  ctrl+l clear the view (history is kept)
161
163
  ctrl+d quit
162
164
  ```
163
165
 
166
+ `ctrl+c` no longer quits. It used to call `self.exit()` whenever no turn was
167
+ running, so pressing it to copy text killed the session. It now checks, in order,
168
+ whether there is a selection to copy (via Textual's `App.copy_to_clipboard`, OSC 52,
169
+ no new dependency), then whether a turn is running to interrupt, and otherwise does
170
+ nothing. `ctrl+d` is still the only way to quit.
171
+
164
172
  `ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
165
173
  focus and would otherwise swallow them.
166
174
 
@@ -176,6 +184,9 @@ focus and would otherwise swallow them.
176
184
  /provider switch provider /config settings editor
177
185
  /export transcript to markdown /hooks configured hooks
178
186
  /sessions recorded sessions /doctor diagnostics
187
+ /skills loaded skills, and any /resume picker, rebuilds the
188
+ rejected on disk (why, agent in place; refuses
189
+ and where) while a turn is running
179
190
  /quit exit
180
191
  ```
181
192
 
@@ -321,6 +332,13 @@ close a self-escalation path: settings files set `permission_mode` and
321
332
  next launch. Verified against the real engine *in bypass mode*, which is the case that
322
333
  matters — deny beats bypass, so the block holds even there.
323
334
 
335
+ That was a unit test. It has since been verified live: with
336
+ `Write(.turnloop/**)` explicitly allowed *and* `--permission-mode bypass`, a real
337
+ model told to grant itself permanent bypass access was blocked, made one attempt,
338
+ and proposed the change to the user instead. The settings file was unchanged. Deny
339
+ beats an explicit allow rule and bypass mode at once — not just in the test suite,
340
+ against a live model that was actually trying.
341
+
324
342
  It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
325
343
  and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
326
344
  Closing that properly needs a path guard below the tool layer rather than more patterns.
@@ -366,6 +384,14 @@ because a crashed run still leaves a readable trace, and because the experiments
366
384
  layer reads exactly the same format the TUI writes — a run trace and a real session
367
385
  are the same artifact.
368
386
 
387
+ `-c`/`--continue` reopens the most recent session with no prompting. Bare `--resume`
388
+ in the TUI opens an interactive picker — a `DataTable` of sessions, newest first,
389
+ with a live preview pane of the selected conversation — and `--resume <id>` jumps
390
+ straight to a specific one. `/resume` opens the same picker in-session and rebuilds
391
+ the agent in place; it refuses while a turn is running rather than tearing one down
392
+ mid-flight. Headless has no picker to show, so a bare `--resume` there falls back to
393
+ "most recent" — the same thing `--continue` does.
394
+
369
395
  ---
370
396
 
371
397
  ## Providers
@@ -862,11 +888,78 @@ And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/co
862
888
  Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
863
889
  fail if the decorators are removed.
864
890
 
865
- **The lesson, five times over in one project:** an 84-run sweep failing while printing
891
+ **A directory that exists on every launch cannot mean "this is a project."**
892
+
893
+ `find_project_root` treated any ancestor `.turnloop` directory as an explicit project
894
+ declaration. But `default_project_dir` creates `.turnloop/` — with a `sessions/`
895
+ folder and a `.gitignore` — on every launch, unconditionally, wherever turnloop
896
+ happens to start. So the directory's mere existence declared nothing; it was
897
+ frequently just the residue of having once run the tool there. Run `tl` once from a
898
+ home directory, or a drive root, and it permanently annexed every project beneath it:
899
+ sessions, the `.env` layer, `settings.json` and the permission rules all resolved to
900
+ the wrong root from then on. Real instance, the author's own machine: `F:\.turnloop`
901
+ was created on 2026-08-01, and every `F:\anything\proj\pyproject.toml` since then
902
+ resolved its project root to `F:\`.
903
+
904
+ The invariant that broke was never written down as one, which is exactly why it lasted.
905
+ The fix makes the implicit rule explicit: a `.turnloop` counts only if it holds
906
+ something a human put there — `settings.json`, `settings.local.json`, `commands/`,
907
+ `skills/`, `TURNLOOP.md` — and one holding only an auto-created `sessions/` is ignored.
908
+ The home directory is excluded outright on top of that, since `~/.turnloop` is the
909
+ user-scope settings location and can never mean "project." The false invariant had
910
+ been there since the tool first created `.turnloop/` on launch; fixed in 0.1.6.
911
+
912
+ **A skill that failed to parse vanished with no error, and the model reported success.**
913
+
914
+ A live run installed a skill whose frontmatter was markdown-bold — `**name**:
915
+ something` — rather than YAML. `yaml.safe_load` returned no usable keys,
916
+ `load_skills` hit its "no description" skip, and the skill disappeared: no exception,
917
+ no log line, nothing in `/skills`. The model that had just "installed" it reported
918
+ success, because from its side the write succeeded and nothing said otherwise.
919
+
920
+ The bug was not the parser rejecting bad frontmatter — that part is correct. It was
921
+ that a rejection and a nonexistent skill looked identical from the outside. `load_skills`
922
+ keeps its exact signature, but a new `rejected_skills()` now returns what was dropped
923
+ and why, `/skills` reports it, and `CONFIG_LAYOUT` shows the literal YAML frontmatter
924
+ block in the system prompt so the model stops guessing at markdown-bold instead.
925
+ Fixed in 0.1.6.
926
+
927
+ **A permission denial that should end in one message looped ten times, and it cost
928
+ real money before anyone noticed — because nothing about it failed a test.**
929
+
930
+ Two defects landed in the same code path with the same symptom. The DENY branch's
931
+ message said only `Permission denied: <rule>`; the ASK-decline branch, which is
932
+ recoverable, had always carried "Do not retry it" — so the weaker wording sat on the
933
+ *permanent* block, the one that most needed it. Fixing the message was not enough on
934
+ its own: the ASK path looped despite already carrying that warning, because prompt
935
+ wording is a request, not a constraint, and a model under pressure to finish a task
936
+ does not reliably honor it.
937
+
938
+ Measured against a live, non-interactive provider — this is what a passing test suite
939
+ cannot see, since nothing here raises or returns the wrong value, it just keeps calling
940
+ a tool that keeps getting denied:
941
+
942
+ - denied `Write .turnloop/settings.local.json`, repeated 10 times → **233,742 input
943
+ tokens, $0.0372**
944
+ - after the message fix alone → **10,654 tokens, $0.0017**, one attempt then a correct
945
+ proposal to the user
946
+ - a separate ASK-decline loop on `dir`/`mkdir`, before any fix → **199,352 tokens,
947
+ $0.0317**
948
+
949
+ So wording is now backed by a deterministic guard in `ToolRunner` rather than trusted
950
+ alone: identity is tool name plus pydantic-normalized args, scope is the session, one
951
+ free retry, and the entry clears the moment the call is allowed or approved — so a
952
+ permission granted mid-session un-poisons a call that was denied earlier, instead of
953
+ blocking it forever. Fixed in 0.1.6.
954
+
955
+ **The lesson, eight times over in one project:** an 84-run sweep failing while printing
866
956
  a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
867
957
  10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
868
958
  the turn the test never made; a feature with green tests that crashed on its first
869
- keystroke. Each was caught only by looking past the summary line — and the last two
959
+ keystroke; an implicit invariant that a docstring never stated, so nobody noticed it
960
+ was false; a silent failure that a model reported as success; a cost bug that no
961
+ assertion could ever have caught, because every individual call did exactly what it
962
+ was told. Each was caught only by looking past the summary line — and most of them
870
963
  only by using the thing instead of testing it.
871
964
 
872
965
  ---
@@ -901,26 +994,27 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
901
994
  ## Testing
902
995
 
903
996
  ```bash
904
- pytest # 290 tests, no network
997
+ pytest # 318 tests, no network
905
998
  ruff check turnloop
906
999
  mypy turnloop
907
1000
  ```
908
1001
 
909
1002
  | file | tests | |
910
1003
  |---|---|---|
911
- | `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
1004
+ | `test_permissions.py` | 50 | rule grammar, compound-command splitting, mode enforcement |
912
1005
  | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
913
1006
  | `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
914
1007
  | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
915
- | `test_loop.py` | 20 | streaming, truncation, iteration cap |
916
- | `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
917
- | `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
1008
+ | `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
1009
+ | `test_tui.py` | 24 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
1010
+ | `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
1011
+ | `test_loop.py` | 23 | streaming, truncation, iteration cap |
918
1012
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
919
- | `test_bash.py` | 14 | shell selection, process-tree kill |
920
1013
  | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
1014
+ | `test_bash.py` | 14 | shell selection, process-tree kill |
921
1015
  | `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
922
1016
  | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
923
- | `test_tui.py` | 15 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
1017
+ | `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
924
1018
 
925
1019
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
926
1020
  `live` — **network is off at the transport layer**, so a forgotten real call fails
@@ -967,6 +1061,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
967
1061
  in the system prompt and load their body on demand, which on a 65k window is the
968
1062
  difference between having skills and not.
969
1063
 
1064
+ The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
1065
+ — 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1066
+ the model cannot configure the tool it is running inside: asked to add an MCP server
1067
+ it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
1068
+ `examples/` — both real outputs from live runs. With it, both go to the right place
1069
+ first try. It includes the literal YAML frontmatter a skill needs, since a skill with
1070
+ markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
1071
+ now also reports anything found on disk but rejected, with the reason and path — see
1072
+ [Bugs worth reading about](#bugs-worth-reading-about).
1073
+
970
1074
  Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
971
1075
  engine like any other Bash call. A markdown file in a repository is not trusted input
972
1076
  just because it is on disk.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "turnloop"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ from pathlib import Path
5
6
 
6
7
  import pytest
7
8
 
@@ -9,8 +10,15 @@ from turnloop.config import find_project_root, load_settings
9
10
  from turnloop.errors import ConfigError
10
11
 
11
12
 
12
- def test_defaults_ship_a_working_provider(tmp_path):
13
- settings = load_settings(tmp_path)
13
+ def test_defaults_ship_a_working_provider(monkeypatch):
14
+ # Route through a path that doesn't exist on disk, and a fake home to
15
+ # match: the "user" layer in load_settings reads ~/.turnloop/settings.json
16
+ # unconditionally, and a dev machine's real global config (or a real
17
+ # ~/.turnloop up tmp_path's real ancestry) would otherwise leak into this
18
+ # test's result.
19
+ fake_root = Path("Z:/turnloop-fake-home/project")
20
+ monkeypatch.setattr("turnloop.config.Path.home", lambda: fake_root.parent)
21
+ settings = load_settings(fake_root)
14
22
  assert settings.provider == "mock"
15
23
  assert "glm" in settings.providers
16
24
 
@@ -128,6 +136,7 @@ def test_project_root_prefers_nearest_manifest_over_a_distant_git_repo(tmp_path)
128
136
  def test_explicit_turnloop_dir_wins(tmp_path):
129
137
  root = tmp_path / "root"
130
138
  (root / ".turnloop").mkdir(parents=True)
139
+ (root / ".turnloop" / "settings.json").write_text("{}", encoding="utf-8")
131
140
  nested = root / "a" / "b"
132
141
  nested.mkdir(parents=True)
133
142
  (nested / "pyproject.toml").write_text("", encoding="utf-8")
@@ -135,6 +144,50 @@ def test_explicit_turnloop_dir_wins(tmp_path):
135
144
  assert find_project_root(nested) == root
136
145
 
137
146
 
147
+ def test_auto_created_turnloop_dir_does_not_annex_a_nested_project(tmp_path):
148
+ """`default_project_dir` (sessions/store.py) creates `.turnloop/` with only
149
+ a `sessions/` folder and a `.gitignore` on every launch, unconditionally.
150
+ That auto-created directory must not be mistaken for a human declaration —
151
+ a project with its own manifest nested underneath it must resolve to
152
+ itself, not to the ancestor that merely happened to run turnloop once.
153
+ """
154
+ outer = tmp_path / "outer"
155
+ turnloop_dir = outer / ".turnloop"
156
+ turnloop_dir.mkdir(parents=True)
157
+ (turnloop_dir / "sessions").mkdir()
158
+ (turnloop_dir / ".gitignore").write_text("sessions/\n", encoding="utf-8")
159
+
160
+ inner = outer / "proj"
161
+ inner.mkdir()
162
+ (inner / "pyproject.toml").write_text("", encoding="utf-8")
163
+
164
+ assert find_project_root(inner) == inner
165
+
166
+
167
+ def test_turnloop_dir_in_home_does_not_annex_unrelated_projects(monkeypatch):
168
+ """`~/.turnloop` is user-scope config, not a project declaration; it must
169
+ not make every project under home resolve its root to home itself.
170
+
171
+ Built on a path that never touches real disk (a drive letter that doesn't
172
+ exist) rather than `tmp_path`, because on this machine — and potentially
173
+ any dev machine — pytest's temp dir lives under the real home directory,
174
+ which itself has a real `.turnloop`. That real marker would confound the
175
+ test regardless of whether the fix works, so the condition is constructed
176
+ explicitly instead of relying on ambient machine state.
177
+ """
178
+ fake_home = Path("Z:/turnloop-fake-home")
179
+ project = fake_home / "code" / "myproject"
180
+ real_is_dir = Path.is_dir
181
+
182
+ def fake_is_dir(self):
183
+ return self == fake_home / ".turnloop" or real_is_dir(self)
184
+
185
+ monkeypatch.setattr(Path, "is_dir", fake_is_dir)
186
+ monkeypatch.setattr("turnloop.config.Path.home", lambda: fake_home)
187
+
188
+ assert find_project_root(project) == project
189
+
190
+
138
191
  def test_dotenv_is_loaded_and_reports_names_only(tmp_path, monkeypatch):
139
192
  monkeypatch.delenv("GROQ_API_KEY", raising=False)
140
193
  (tmp_path / "pyproject.toml").write_text("", encoding="utf-8")
@@ -226,31 +279,31 @@ def test_doctor_survives_a_console_that_cannot_encode_its_glyphs(tmp_path, capsy
226
279
  assert rc in (0, 1) # findings are allowed; a crash is not
227
280
  body = narrow.buffer.getvalue().decode("cp1252")
228
281
  assert "context budget" in body
229
-
230
-
231
- def test_the_console_guard_replaces_a_glyph_cp1252_cannot_encode():
232
- """The doctor fix removed one glyph; the guard covers every future one.
233
-
234
- Tool results, MCP servers and slash commands all print characters this repo
235
- does not control, so keeping every string inside cp1252 is not a maintainable
236
- invariant. Degrading to `?` is.
237
- """
238
- import io
239
- import sys
240
-
241
- from turnloop.cli import _survive_a_narrow_console
242
-
243
- narrow = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
244
- real_out, real_err = sys.stdout, sys.stderr
245
- sys.stdout = sys.stderr = narrow
246
- try:
247
- with pytest.raises(UnicodeEncodeError):
248
- narrow.write("→")
249
- narrow.flush()
250
- _survive_a_narrow_console()
251
- narrow.write("budget − reserve → history\n")
252
- narrow.flush()
253
- finally:
254
- sys.stdout, sys.stderr = real_out, real_err
255
-
256
- assert "budget ? reserve ? history" in narrow.buffer.getvalue().decode("cp1252")
282
+
283
+
284
+ def test_the_console_guard_replaces_a_glyph_cp1252_cannot_encode():
285
+ """The doctor fix removed one glyph; the guard covers every future one.
286
+
287
+ Tool results, MCP servers and slash commands all print characters this repo
288
+ does not control, so keeping every string inside cp1252 is not a maintainable
289
+ invariant. Degrading to `?` is.
290
+ """
291
+ import io
292
+ import sys
293
+
294
+ from turnloop.cli import _survive_a_narrow_console
295
+
296
+ narrow = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
297
+ real_out, real_err = sys.stdout, sys.stderr
298
+ sys.stdout = sys.stderr = narrow
299
+ try:
300
+ with pytest.raises(UnicodeEncodeError):
301
+ narrow.write("→")
302
+ narrow.flush()
303
+ _survive_a_narrow_console()
304
+ narrow.write("budget − reserve → history\n")
305
+ narrow.flush()
306
+ finally:
307
+ sys.stdout, sys.stderr = real_out, real_err
308
+
309
+ assert "budget ? reserve ? history" in narrow.buffer.getvalue().decode("cp1252")