turnloop 0.1.5__tar.gz → 0.1.7__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 (161) hide show
  1. turnloop-0.1.7/.turnloop/settings.local.json +3 -0
  2. {turnloop-0.1.5 → turnloop-0.1.7}/PKG-INFO +155 -18
  3. {turnloop-0.1.5 → turnloop-0.1.7}/README.md +154 -17
  4. {turnloop-0.1.5 → turnloop-0.1.7}/pyproject.toml +1 -1
  5. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_config.py +83 -30
  6. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_hooks_mcp_commands.py +537 -431
  7. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_loop.py +73 -0
  8. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_permissions.py +48 -0
  9. turnloop-0.1.7/tests/test_skills_install.py +334 -0
  10. turnloop-0.1.7/tests/test_system_prompt_layout.py +85 -0
  11. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_tui.py +643 -397
  12. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/__init__.py +1 -1
  13. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/agent/headless.py +17 -9
  14. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/agent/system_prompt.py +31 -0
  15. turnloop-0.1.7/turnloop/cli.py +502 -0
  16. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/commands/dispatch.py +486 -448
  17. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/commands/loader.py +39 -2
  18. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/config.py +507 -444
  19. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/configio.py +16 -1
  20. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/sessions/store.py +286 -271
  21. turnloop-0.1.7/turnloop/skills_install.py +284 -0
  22. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/runner.py +63 -1
  23. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/app.py +550 -432
  24. turnloop-0.1.7/turnloop/tui/session_screen.py +114 -0
  25. turnloop-0.1.5/turnloop/cli.py +0 -273
  26. {turnloop-0.1.5 → turnloop-0.1.7}/.github/workflows/ci.yml +0 -0
  27. {turnloop-0.1.5 → turnloop-0.1.7}/.gitignore +0 -0
  28. {turnloop-0.1.5 → turnloop-0.1.7}/.turnloop/.gitignore +0 -0
  29. {turnloop-0.1.5 → turnloop-0.1.7}/tests/conftest.py +0 -0
  30. {turnloop-0.1.5 → turnloop-0.1.7}/tests/fixtures/anthropic_tool_call.sse +0 -0
  31. {turnloop-0.1.5 → turnloop-0.1.7}/tests/fixtures/glm_tool_call.sse +0 -0
  32. {turnloop-0.1.5 → turnloop-0.1.7}/tests/fixtures/mcp_echo_server.py +0 -0
  33. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_bash.py +0 -0
  34. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_compaction.py +0 -0
  35. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_config_screen.py +0 -0
  36. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_configio.py +0 -0
  37. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_experiments.py +0 -0
  38. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_providers.py +0 -0
  39. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_tools_files.py +0 -0
  40. {turnloop-0.1.5 → turnloop-0.1.7}/tests/test_websearch.py +0 -0
  41. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/__main__.py +0 -0
  42. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/agent/__init__.py +0 -0
  43. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/agent/factory.py +0 -0
  44. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/agent/loop.py +0 -0
  45. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/agent/subagent.py +0 -0
  46. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/commands/__init__.py +0 -0
  47. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/context/__init__.py +0 -0
  48. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/context/budget.py +0 -0
  49. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/context/compaction.py +0 -0
  50. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/context/memory.py +0 -0
  51. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/core/__init__.py +0 -0
  52. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/core/events.py +0 -0
  53. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/core/ids.py +0 -0
  54. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/core/messages.py +0 -0
  55. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/core/tokens.py +0 -0
  56. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/diagnostics.py +0 -0
  57. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/errors.py +0 -0
  58. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/__init__.py +0 -0
  59. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  60. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  61. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/context-glm.yaml +0 -0
  62. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/context.yaml +0 -0
  63. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  64. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/groq-free.yaml +0 -0
  65. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  66. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  67. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/loop.yaml +0 -0
  68. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  69. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/reliability.yaml +0 -0
  70. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  71. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/configs/smoke.yaml +0 -0
  72. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/graders.py +0 -0
  73. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/report.py +0 -0
  74. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/runner.py +0 -0
  75. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/default.yaml +0 -0
  76. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  77. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  78. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  79. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  80. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  81. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  82. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  83. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  84. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  85. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  86. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  87. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  88. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  89. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  90. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  91. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  92. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  93. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  94. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  95. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  96. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  97. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  98. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  99. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  100. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  101. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  102. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  103. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  104. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  105. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  106. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  107. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  108. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  109. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  110. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  111. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  112. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  113. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  114. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  115. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  116. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  117. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/hooks/__init__.py +0 -0
  118. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/hooks/runner.py +0 -0
  119. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/mcp/__init__.py +0 -0
  120. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/mcp/adapter.py +0 -0
  121. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/mcp/client.py +0 -0
  122. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/permissions/__init__.py +0 -0
  123. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/permissions/engine.py +0 -0
  124. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/permissions/rules.py +0 -0
  125. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/__init__.py +0 -0
  126. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/anthropic.py +0 -0
  127. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/base.py +0 -0
  128. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/gemini.py +0 -0
  129. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/mock.py +0 -0
  130. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/openai_compat.py +0 -0
  131. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/pricing.py +0 -0
  132. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/registry.py +0 -0
  133. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/providers/sse.py +0 -0
  134. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/sessions/__init__.py +0 -0
  135. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/sessions/models.py +0 -0
  136. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/__init__.py +0 -0
  137. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/ask.py +0 -0
  138. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/base.py +0 -0
  139. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/bash.py +0 -0
  140. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/builtin.py +0 -0
  141. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/edit.py +0 -0
  142. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/glob.py +0 -0
  143. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/grep.py +0 -0
  144. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/read.py +0 -0
  145. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/shell.py +0 -0
  146. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/skill.py +0 -0
  147. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/task.py +0 -0
  148. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/textio.py +0 -0
  149. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/todo.py +0 -0
  150. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/webfetch.py +0 -0
  151. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/websearch.py +0 -0
  152. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tools/write.py +0 -0
  153. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/__init__.py +0 -0
  154. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/app.tcss +0 -0
  155. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/bridge.py +0 -0
  156. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/config_screen.py +0 -0
  157. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/mcp_screen.py +0 -0
  158. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/widgets/__init__.py +0 -0
  159. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/widgets/permission.py +0 -0
  160. {turnloop-0.1.5 → turnloop-0.1.7}/turnloop/tui/widgets/status.py +0 -0
  161. {turnloop-0.1.5 → turnloop-0.1.7}/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.7
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, 338 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.7
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,28 @@ 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 # 338 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` | 25 | 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_skills_install.py` | 19 | frontmatter validation, GitHub resolution, import subsets, drive-root refusal |
1041
+ | `test_loop.py` | 23 | streaming, truncation, iteration cap |
947
1042
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
948
- | `test_bash.py` | 14 | shell selection, process-tree kill |
949
1043
  | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
1044
+ | `test_bash.py` | 14 | shell selection, process-tree kill |
950
1045
  | `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
951
1046
  | `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 |
1047
+ | `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
953
1048
 
954
1049
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
955
1050
  `live` — **network is off at the transport layer**, so a forgotten real call fails
@@ -996,6 +1091,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
996
1091
  in the system prompt and load their body on demand, which on a 65k window is the
997
1092
  difference between having skills and not.
998
1093
 
1094
+ The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
1095
+ — 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1096
+ the model cannot configure the tool it is running inside: asked to add an MCP server
1097
+ it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
1098
+ `examples/` — both real outputs from live runs. With it, both go to the right place
1099
+ first try. It includes the literal YAML frontmatter a skill needs, since a skill with
1100
+ markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
1101
+ now also reports anything found on disk but rejected, with the reason and path — see
1102
+ [Bugs worth reading about](#bugs-worth-reading-about).
1103
+
999
1104
  Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
1000
1105
  engine like any other Bash call. A markdown file in a repository is not trusted input
1001
1106
  just because it is on disk.
@@ -1003,8 +1108,9 @@ just because it is on disk.
1003
1108
  ### Editing configuration
1004
1109
 
1005
1110
  `tl config --edit` edits the provider table, permission mode, tool verbosity, iteration
1006
- cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers. The
1007
- same screens open inside a running session as `/config` and `/mcp add`.
1111
+ cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers.
1112
+ `tl skills add/list/remove` manages skills. The config and MCP screens open inside a
1113
+ running session as `/config` and `/mcp add`.
1008
1114
 
1009
1115
  **Adding a provider** is a form for `kind`, `model`, `base_url`, and `api_key_env`, with
1010
1116
  a live ✓/✗ showing whether that environment variable is currently set. Capabilities are
@@ -1081,6 +1187,37 @@ unavailable" and never blocks the loop. Connections are lazy and every call is b
1081
1187
  None of that helps if you install a server that does exactly what it says. Read what you
1082
1188
  add.
1083
1189
 
1190
+ ### Skills are a trust decision too
1191
+
1192
+ `tl skills add <owner/repo>` resolves a GitHub repo shorthand, a repo URL, or a direct
1193
+ raw URL to a `SKILL.md`, and installs it to `.turnloop/skills/<name>/` (or
1194
+ `~/.turnloop/skills/` with `--user`). The same reasoning as the MCP section above
1195
+ applies without modification: a skill's body is not metadata, it is text that gets
1196
+ loaded straight into the model's context the moment the model decides the skill
1197
+ applies (`commands/loader.py`). A hostile `SKILL.md` can instruct the model exactly
1198
+ like a hostile MCP tool description can. `add` prints the source URL and the
1199
+ description and asks for confirmation before writing anything; `--yes` skips waiting
1200
+ for that confirmation but never skips printing it, and never skips printing what got
1201
+ installed and from where.
1202
+
1203
+ Before writing, `add` parses the frontmatter with the same `parse_frontmatter` the
1204
+ loader uses and requires a non-empty `description` — the exact rule `load_skills`
1205
+ enforces when deciding what to advertise (see "A skill that failed to parse vanished
1206
+ with no error" below). A `SKILL.md` that would silently vanish once installed is
1207
+ refused before it is ever written, rather than discovered later as "the skill isn't
1208
+ there."
1209
+
1210
+ **Importing from Claude Code.** If `~/.claude/skills/*/SKILL.md` exist and turnloop
1211
+ has never asked about them, one line appears once, in the TUI, pointing at
1212
+ `tl skills import` — not a prompt that blocks startup, and not a silent copy.
1213
+ Importing computes the token cost of each candidate with `rough_tokens` before you
1214
+ choose (34 skills advertise for about 2,961 tokens of permanent system-prompt
1215
+ overhead — most of a session's `CONFIG_LAYOUT` segment on its own), lets you import
1216
+ any subset, and copies the chosen files into turnloop's own skills directory rather
1217
+ than reading `~/.claude/skills` at runtime — turnloop stays uncoupled from another
1218
+ tool's directory layout. Whether you say yes or no, the question is recorded in the
1219
+ user-level settings file so it is never asked again.
1220
+
1084
1221
  ---
1085
1222
 
1086
1223
  ## Limitations
@@ -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, 338 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.7
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,28 @@ 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 # 338 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` | 25 | 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_skills_install.py` | 19 | frontmatter validation, GitHub resolution, import subsets, drive-root refusal |
1012
+ | `test_loop.py` | 23 | streaming, truncation, iteration cap |
918
1013
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
919
- | `test_bash.py` | 14 | shell selection, process-tree kill |
920
1014
  | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
1015
+ | `test_bash.py` | 14 | shell selection, process-tree kill |
921
1016
  | `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
922
1017
  | `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 |
1018
+ | `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
924
1019
 
925
1020
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
926
1021
  `live` — **network is off at the transport layer**, so a forgotten real call fails
@@ -967,6 +1062,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
967
1062
  in the system prompt and load their body on demand, which on a 65k window is the
968
1063
  difference between having skills and not.
969
1064
 
1065
+ The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
1066
+ — 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
1067
+ the model cannot configure the tool it is running inside: asked to add an MCP server
1068
+ it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
1069
+ `examples/` — both real outputs from live runs. With it, both go to the right place
1070
+ first try. It includes the literal YAML frontmatter a skill needs, since a skill with
1071
+ markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
1072
+ now also reports anything found on disk but rejected, with the reason and path — see
1073
+ [Bugs worth reading about](#bugs-worth-reading-about).
1074
+
970
1075
  Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
971
1076
  engine like any other Bash call. A markdown file in a repository is not trusted input
972
1077
  just because it is on disk.
@@ -974,8 +1079,9 @@ just because it is on disk.
974
1079
  ### Editing configuration
975
1080
 
976
1081
  `tl config --edit` edits the provider table, permission mode, tool verbosity, iteration
977
- cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers. The
978
- same screens open inside a running session as `/config` and `/mcp add`.
1082
+ cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers.
1083
+ `tl skills add/list/remove` manages skills. The config and MCP screens open inside a
1084
+ running session as `/config` and `/mcp add`.
979
1085
 
980
1086
  **Adding a provider** is a form for `kind`, `model`, `base_url`, and `api_key_env`, with
981
1087
  a live ✓/✗ showing whether that environment variable is currently set. Capabilities are
@@ -1052,6 +1158,37 @@ unavailable" and never blocks the loop. Connections are lazy and every call is b
1052
1158
  None of that helps if you install a server that does exactly what it says. Read what you
1053
1159
  add.
1054
1160
 
1161
+ ### Skills are a trust decision too
1162
+
1163
+ `tl skills add <owner/repo>` resolves a GitHub repo shorthand, a repo URL, or a direct
1164
+ raw URL to a `SKILL.md`, and installs it to `.turnloop/skills/<name>/` (or
1165
+ `~/.turnloop/skills/` with `--user`). The same reasoning as the MCP section above
1166
+ applies without modification: a skill's body is not metadata, it is text that gets
1167
+ loaded straight into the model's context the moment the model decides the skill
1168
+ applies (`commands/loader.py`). A hostile `SKILL.md` can instruct the model exactly
1169
+ like a hostile MCP tool description can. `add` prints the source URL and the
1170
+ description and asks for confirmation before writing anything; `--yes` skips waiting
1171
+ for that confirmation but never skips printing it, and never skips printing what got
1172
+ installed and from where.
1173
+
1174
+ Before writing, `add` parses the frontmatter with the same `parse_frontmatter` the
1175
+ loader uses and requires a non-empty `description` — the exact rule `load_skills`
1176
+ enforces when deciding what to advertise (see "A skill that failed to parse vanished
1177
+ with no error" below). A `SKILL.md` that would silently vanish once installed is
1178
+ refused before it is ever written, rather than discovered later as "the skill isn't
1179
+ there."
1180
+
1181
+ **Importing from Claude Code.** If `~/.claude/skills/*/SKILL.md` exist and turnloop
1182
+ has never asked about them, one line appears once, in the TUI, pointing at
1183
+ `tl skills import` — not a prompt that blocks startup, and not a silent copy.
1184
+ Importing computes the token cost of each candidate with `rough_tokens` before you
1185
+ choose (34 skills advertise for about 2,961 tokens of permanent system-prompt
1186
+ overhead — most of a session's `CONFIG_LAYOUT` segment on its own), lets you import
1187
+ any subset, and copies the chosen files into turnloop's own skills directory rather
1188
+ than reading `~/.claude/skills` at runtime — turnloop stays uncoupled from another
1189
+ tool's directory layout. Whether you say yes or no, the question is recorded in the
1190
+ user-level settings file so it is never asked again.
1191
+
1055
1192
  ---
1056
1193
 
1057
1194
  ## Limitations
@@ -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.7"
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"