dostuff 0.2.2__tar.gz → 0.2.4__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 (137) hide show
  1. {dostuff-0.2.2 → dostuff-0.2.4}/PKG-INFO +200 -4
  2. {dostuff-0.2.2 → dostuff-0.2.4}/README.md +199 -3
  3. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/agent/loop.py +66 -9
  4. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/agent/permissions.py +30 -2
  5. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/agent/run_tool.py +100 -10
  6. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/cli.py +6 -3
  7. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/cli_tui.py +71 -9
  8. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/config.py +14 -2
  9. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/headless.py +36 -1
  10. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/constants.py +1 -0
  11. dostuff-0.2.4/dostuff/helpers/agent/get_model_token_limit.py +54 -0
  12. dostuff-0.2.4/dostuff/helpers/hooks/__init__.py +10 -0
  13. dostuff-0.2.4/dostuff/helpers/hooks/dispatch.py +139 -0
  14. dostuff-0.2.4/dostuff/helpers/hooks/events.py +84 -0
  15. dostuff-0.2.4/dostuff/helpers/hooks/load_hooks.py +248 -0
  16. dostuff-0.2.4/dostuff/helpers/hooks/run_hook.py +186 -0
  17. dostuff-0.2.4/dostuff/helpers/hooks/trust.py +157 -0
  18. dostuff-0.2.4/dostuff/helpers/models/opencode.py +200 -0
  19. dostuff-0.2.4/dostuff/helpers/ui/doctor.py +191 -0
  20. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/templates/config.example.yaml +41 -1
  21. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/delegate_to_subagent.py +4 -1
  22. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/settings.py +11 -2
  23. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff.egg-info/PKG-INFO +200 -4
  24. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff.egg-info/SOURCES.txt +7 -0
  25. {dostuff-0.2.2 → dostuff-0.2.4}/pyproject.toml +1 -1
  26. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_cli_commands.py +99 -0
  27. dostuff-0.2.4/tests/test_hooks.py +1601 -0
  28. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_opencode.py +43 -0
  29. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_permissions.py +25 -0
  30. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_tui.py +314 -0
  31. dostuff-0.2.2/dostuff/helpers/agent/get_model_token_limit.py +0 -29
  32. dostuff-0.2.2/dostuff/helpers/models/opencode.py +0 -76
  33. dostuff-0.2.2/dostuff/helpers/ui/doctor.py +0 -47
  34. {dostuff-0.2.2 → dostuff-0.2.4}/LICENSE +0 -0
  35. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/__init__.py +0 -0
  36. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/__main__.py +0 -0
  37. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/agent/call_agent.py +0 -0
  38. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/agent/instructions.py +0 -0
  39. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/append_step.py +0 -0
  40. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/consume_stream.py +0 -0
  41. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/context_usage.py +0 -0
  42. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/environment_context.py +0 -0
  43. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/file_mentions.py +0 -0
  44. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/load_identity.py +0 -0
  45. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/load_project_instructions.py +0 -0
  46. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/loop_check_cancelled.py +0 -0
  47. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/loop_emit.py +0 -0
  48. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/manage_context.py +0 -0
  49. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/model_cost.py +0 -0
  50. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/pastes.py +0 -0
  51. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/quick_memory.py +0 -0
  52. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/slash_suggestion.py +0 -0
  53. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/agent/tool_output.py +0 -0
  54. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/mcp/load_mcp_config.py +0 -0
  55. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/mcp/mcp_oauth.py +0 -0
  56. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/mcp/server_config.py +0 -0
  57. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/mcp/token_store.py +0 -0
  58. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/memory/export_session.py +0 -0
  59. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/memory/resolve_memory_operation.py +0 -0
  60. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/memory/shell_step.py +0 -0
  61. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/models/add_model_entry.py +0 -0
  62. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/models/list_models.py +0 -0
  63. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/skills/custom_commands.py +0 -0
  64. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/skills/discover_skills.py +0 -0
  65. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/skills/skill_command.py +0 -0
  66. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/tools/diff.py +0 -0
  67. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/tools/generate_tool_schema.py +0 -0
  68. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/tools/project_root.py +0 -0
  69. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/tools/resolve_safe_path.py +0 -0
  70. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/ui/__init__.py +0 -0
  71. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/ui/emit.py +0 -0
  72. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/ui/prompt_history.py +0 -0
  73. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/helpers/ui/terminal_signals.py +0 -0
  74. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/checkpoints.py +0 -0
  75. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/exceptions.py +0 -0
  76. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_client.py +0 -0
  77. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_client_registration_store.py +0 -0
  78. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_tool_registry_store.py +0 -0
  79. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/memory/chroma.py +0 -0
  80. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/memory/episodic_memory_store.py +0 -0
  81. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/memory/semantic_memory_store.py +0 -0
  82. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/memory/session_store.py +0 -0
  83. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/memory/stores.py +0 -0
  84. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/memory/types.py +0 -0
  85. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/model.py +0 -0
  86. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/tracing.py +0 -0
  87. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/lib/worktree.py +0 -0
  88. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/memory/__init__.py +0 -0
  89. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/setup_wizard.py +0 -0
  90. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/skills.py +0 -0
  91. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/templates/env.example +0 -0
  92. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/__init__.py +0 -0
  93. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/bash/bash_command.py +0 -0
  94. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/definitions.py +0 -0
  95. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/files/delete_file.py +0 -0
  96. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/files/edit_file.py +0 -0
  97. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/files/list_files.py +0 -0
  98. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/files/read_file.py +0 -0
  99. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/files/write_file.py +0 -0
  100. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/get_current_datetime.py +0 -0
  101. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/mcp/call_mcp_tool.py +0 -0
  102. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/mcp/get_mcp_tool_details.py +0 -0
  103. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/mcp/search_mcp_tools.py +0 -0
  104. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/memory/save_episode.py +0 -0
  105. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/memory/save_memory.py +0 -0
  106. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/search/glob.py +0 -0
  107. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/search/grep.py +0 -0
  108. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/search/walk.py +0 -0
  109. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/todo_write.py +0 -0
  110. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/web_fetch.py +0 -0
  111. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff/tools/web_search.py +0 -0
  112. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff.egg-info/dependency_links.txt +0 -0
  113. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff.egg-info/entry_points.txt +0 -0
  114. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff.egg-info/requires.txt +0 -0
  115. {dostuff-0.2.2 → dostuff-0.2.4}/dostuff.egg-info/top_level.txt +0 -0
  116. {dostuff-0.2.2 → dostuff-0.2.4}/setup.cfg +0 -0
  117. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_checkpoints_todos_caching.py +0 -0
  118. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_config_memory_mcp.py +0 -0
  119. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_file_mentions.py +0 -0
  120. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_file_tools.py +0 -0
  121. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_loop.py +0 -0
  122. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_mcp_e2e.py +0 -0
  123. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_mcp_oauth.py +0 -0
  124. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_optional_memory_and_instructions.py +0 -0
  125. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_pastes.py +0 -0
  126. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_roadmap_021.py +0 -0
  127. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_session_store.py +0 -0
  128. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_settings_tools.py +0 -0
  129. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_setup_wizard.py +0 -0
  130. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_skill_command.py +0 -0
  131. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_slash_suggestion.py +0 -0
  132. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_subagent.py +0 -0
  133. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_terminal_signals.py +0 -0
  134. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_tool_schemas.py +0 -0
  135. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_web_fetch.py +0 -0
  136. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_web_search.py +0 -0
  137. {dostuff-0.2.2 → dostuff-0.2.4}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dostuff
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: DoStuff - a terminal coding agent for any LLM: TUI and headless mode, permissions, MCP, skills, web tools, sessions and git worktrees
5
5
  Author-email: Varun Kumawat <varunkumawatleap2@gmail.com>
6
6
  License-Expression: MIT
@@ -84,6 +84,7 @@ DoStuff is a terminal AI agent that runs anywhere `pip` runs. It gives you a ful
84
84
  - **Project instructions** — `AGENTS.md` / `CLAUDE.md` from `~/.dostuff/`, the repo root and every folder down to the current one, combined
85
85
  - **Coding tools** — `read_file` (line numbers, offset/limit), `edit_file` (exact replacement), `write_file`, `grep`, `glob`, `list_files`, `delete_file`, `bash_command`, `web_fetch` (read a URL as text), `web_search` (free, no API key)
86
86
  - **Permission layer** — mutating tool calls ask for approval (allow once / always this session / deny with a note); read-only shell commands run without asking; modes `ask` / `allowlist` / `auto`; `/undo` reverts the agent's file changes
87
+ - **Hooks** — shell commands on `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` and `SubagentStop` that can block a tool call or prompt, feed a formatter's output back to the model, or keep the turn going; Claude Code's hook I/O contract, and project hooks run only once you trust them. See [Hooks](#hooks)
87
88
  - **File sandbox** — file tools stay inside the project directory and never read `.env*` files or `~/.dostuff/`
88
89
  - **Skills loader** — drop a `SKILL.md` in the right folder, restart, done; run one with `/<skill-name> [args]`
89
90
  - **Subagents** — `delegate_to_subagent` runs a bounded task with a restricted tool set; its approval prompts and ESC go through the same TUI
@@ -203,7 +204,7 @@ In the TUI:
203
204
  | `dostuff sessions rm <id>... [-y]` | Delete sessions and their `/undo` checkpoints |
204
205
  | `dostuff sessions prune [--older-than 30d] [--dry-run] [-y]` | Delete empty sessions, and sessions not used for the given time (`45m`, `12h`, `30d`, `2w`) |
205
206
  | `dostuff export [<id>] [-o file\|-]` | Save a session (default: latest here) as Markdown: prompts, answers, tool calls and results |
206
- | `dostuff -p "<prompt>"` | Headless: run one prompt, print the answer to stdout, exit. Progress goes to stderr. Piped stdin is appended to the prompt (`-p -` reads the prompt from stdin). `--session <id>` continues a session, `--output-format json` (or `--json`) prints `{result, session_id, usage, ...}`, `--output-format stream-json` prints one JSON event per line (`init`, `assistant`, `tool_call`, `tool_result`, `usage`, `error`, then `result`), `-q` hides progress, `--permission-mode ask\|allowlist\|auto\|plan`. `--max-turns N` stops after N model calls (exit code 1). `--allowed-tools` (comma-separated or repeated) runs matching calls without asking in any mode, in the `permissions.allow` syntax (`"npm test"`, `web_fetch:docs.python.org`) or a tool name for every call of it (`write_file`, `bash_command`). Without a terminal, other calls that need approval are denied |
207
+ | `dostuff -p "<prompt>"` | Headless: run one prompt, print the answer to stdout, exit. Progress goes to stderr. Piped stdin is appended to the prompt (`-p -` reads the prompt from stdin). `--session <id>` continues a session, `--output-format json` (or `--json`) prints `{result, session_id, usage, ...}`, `--output-format stream-json` prints one JSON event per line (`init`, `assistant`, `tool_call`, `tool_result`, `usage`, `error`, then `result`), `-q` hides progress, `--permission-mode ask\|allowlist\|auto\|plan`. `--max-turns N` stops after N model calls (exit code 1). `--allowed-tools` (comma-separated or repeated) runs matching calls without asking in any mode, in the `permissions.allow` syntax (`"npm test"`, `web_fetch:docs.python.org`) or a tool name for every call of it (`write_file`, `bash_command`). Without a terminal, other calls that need approval are denied. `--trust-project-hooks` runs the project's [hooks](#project-hooks-and-trust) without a saved trust answer |
207
208
  | `dostuff mcp list` | Show MCP servers and the file each comes from |
208
209
  | `dostuff mcp add <name> [-e K=V] -- <command> [args...]` | Add a stdio server; `--url <url> [-H "Name: value"]` for HTTP; `--project` writes `./.dostuff/mcp_config.json` |
209
210
  | `dostuff mcp remove <name> [--project]` | Remove a server |
@@ -243,7 +244,8 @@ In the TUI:
243
244
  | `/status` | Session, directory, model, permission mode, tokens, context use, MCP servers |
244
245
  | `/context` | What fills the context window: system prompt, tools, MCP tools, project instructions, compaction summary and messages, with tokens, % and a bar |
245
246
  | `/memory [project\|global]` | Edit `AGENTS.md` (this project's or `~/.dostuff/AGENTS.md`) in the same editor as `/config`; `Ctrl+S` saves and the agent follows it from the next message |
246
- | `/doctor` | The `dostuff doctor` checks (config, API key, MCP config, memory), with a fix for each problem |
247
+ | `/doctor` | The `dostuff doctor` checks (config, API key, MCP config, memory, hooks), with a fix for each problem |
248
+ | `/hooks [r]` | List the configured hooks per event: file, matcher, timeout, `fail_closed`, and whether each runs (`user` / `trusted` / `untrusted` / `disabled` / `broken`). `/hooks r` re-reads the config files. See [Hooks](#hooks) |
247
249
  | `/<skill> [args]` | Run a skill: every discovered skill is a command (listed in the `/` menu and `/help`). See [Skills](#skills) |
248
250
  | `/<command> [args]` | Run a custom command from `.dostuff/commands/` or `~/.dostuff/commands/`. See [Custom commands](#custom-commands) |
249
251
  | `/rename <title>` | Name this session (shown by `dostuff sessions`) |
@@ -285,6 +287,11 @@ In the TUI:
285
287
  | `read_settings` | read | never (MCP `env` / header values are shown as `<redacted>`) |
286
288
  | `edit_config`, `set_mcp_server`, `remove_mcp_server` | settings | **always, in every mode, with no "always" option** (shows the diff or the server's command) |
287
289
 
290
+ File tools writing under a `.dostuff` directory, and `bash_command` calls whose command or `cwd` names `.dostuff`, are
291
+ settings changes too, so they always ask. That way auto mode can't quietly add a hook to `~/.dostuff/config.yaml`. Paths
292
+ into subagent worktrees (`.dostuff/worktrees/…`) are exempt. The shell check matches the text `.dostuff`, so a name the
293
+ command builds indirectly is not caught.
294
+
288
295
  `permissions.mode` in `config.yaml`:
289
296
 
290
297
  - `ask` (default) — as in the table above.
@@ -320,6 +327,193 @@ API keys stay in `.env` files, which the agent can't read or change. Subagents d
320
327
 
321
328
  ---
322
329
 
330
+ ## Hooks
331
+
332
+ Hooks are shell commands DoStuff runs at fixed points of a session: block a tool call, feed a formatter's output back
333
+ to the model, keep the turn going until the tests pass. They are configured under `hooks:` in `config.yaml` and use
334
+ Claude Code's I/O contract (stdin JSON, exit codes, JSON output), so an existing Claude Code hook script works once its
335
+ matcher and tool argument names are renamed (see [the table below](#coming-from-claude-code)).
336
+
337
+ > [!WARNING]
338
+ > Hooks run with your privileges, **unsandboxed**, and are not permission-checked: they are your config, not model
339
+ > output. Read a project's hooks before you trust them.
340
+
341
+ | Event | When | Matcher is matched against | What a hook can do |
342
+ |---|---|---|---|
343
+ | `SessionStart` | Before the first turn of a session, and after a compaction | the source: `startup`, `resume`, `clear`, `compact` | Add context for the rest of the session |
344
+ | `UserPromptSubmit` | After you send a prompt, before it is stored or sent | — | Reject the prompt, or add context for this turn |
345
+ | `PreToolUse` | Before each tool call, before any approval prompt | the tool name | `allow`, `deny` or `ask`; rewrite the arguments |
346
+ | `PostToolUse` | After a tool call ran | the tool name | Append text to the result the model sees |
347
+ | `Stop` | When the agent is about to give its final answer | — | Block, so the turn continues with your reason |
348
+ | `SubagentStop` | Same, for a `delegate_to_subagent` run | — | Same |
349
+
350
+ ### Config
351
+
352
+ `matcher` is a Python regex that must match the whole name (`edit_file` does not match `edit_file_v2`); empty or `*`
353
+ matches everything. `if` (tool events only) is searched in a `bash_command`'s `command`. Inline flags such as `(?i)`
354
+ go first in a pattern. `timeout` (default 30 s, 1–300) and `fail_closed` can be set on a group or on one hook. This is
355
+ the example from the spec, and the two scripts it runs are below:
356
+
357
+ ```yaml
358
+ hooks:
359
+ project_trust: ask # ask | allow | deny (D4) — only read from ~/.dostuff/config.yaml
360
+ stop_block_limit: 3 # Stop blocks honoured per turn before the loop is allowed to end (1–10)
361
+ SessionStart:
362
+ - matcher: "startup|resume|clear" # regex, full match against the source; empty or "*" = all
363
+ hooks:
364
+ - type: command
365
+ command: "git status --short"
366
+ PreToolUse:
367
+ - matcher: "bash_command" # regex, full match against the tool name
368
+ if: "(?i)^\\s*git\\s+push" # optional regex, searched in tool_input.command (shell tool only)
369
+ fail_closed: true
370
+ timeout: 10
371
+ hooks:
372
+ - type: command
373
+ name: block-main-push
374
+ command: "python .dostuff/hooks/block_main_push.py"
375
+ PostToolUse:
376
+ - matcher: "write_file|edit_file"
377
+ hooks:
378
+ # the script reads tool_input.path from the stdin JSON and runs the formatter on it;
379
+ # its stdout is fed back to the model (D5)
380
+ - type: command
381
+ name: format
382
+ command: "python .dostuff/hooks/format_file.py"
383
+ Stop:
384
+ - hooks:
385
+ - { type: command, name: tests, command: "python .dostuff/hooks/run_tests.py" }
386
+ ```
387
+
388
+ - **`fail_closed: true`** (`PreToolUse` and `UserPromptSubmit` only) is for guardrails: if the hook crashes or times
389
+ out, the call is denied (or the prompt rejected) instead of going ahead. A `fail_closed` hook with a config mistake,
390
+ such as a bad regex, is kept and denies everything it matches; any other invalid hook is dropped with a warning.
391
+ - **`stop_block_limit`** (1–10) bounds how many times `Stop` hooks can continue one turn; after that the turn ends with
392
+ a note.
393
+ - User hooks come first, then the project's are added after them; a project file can't remove or reorder yours.
394
+
395
+ ```python
396
+ # .dostuff/hooks/block_main_push.py: PreToolUse on bash_command; the group's `if` already matched `git push`
397
+ import json
398
+ import subprocess
399
+ import sys
400
+
401
+ payload = json.load(sys.stdin)
402
+ words = payload["tool_input"].get("command", "").replace(":", " ").split()
403
+ branch = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True,
404
+ cwd=payload["cwd"]).stdout.strip()
405
+ if "main" in words or branch == "main":
406
+ print("Don't push to main: push a feature branch and open a pull request.", file=sys.stderr)
407
+ sys.exit(2) # exit 2 = deny; stderr is the reason the model sees
408
+ ```
409
+
410
+ ```python
411
+ # .dostuff/hooks/format_file.py: PostToolUse on write_file|edit_file; stdout is appended to the tool result
412
+ import json
413
+ import subprocess
414
+ import sys
415
+
416
+ payload = json.load(sys.stdin)
417
+ path = payload["tool_input"].get("path", "")
418
+ if path.endswith(".py"):
419
+ result = subprocess.run(["ruff", "format", path], capture_output=True, text=True, cwd=payload["cwd"])
420
+ print((result.stdout + result.stderr).strip())
421
+ ```
422
+
423
+ ```python
424
+ # .dostuff/hooks/run_tests.py: Stop; exit 2 sends the failures back and the turn continues
425
+ import json
426
+ import subprocess
427
+ import sys
428
+
429
+ payload = json.load(sys.stdin)
430
+ result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x"], capture_output=True, text=True,
431
+ cwd=payload["cwd"])
432
+ if result.returncode not in (0, 5): # 5: no tests collected
433
+ print("Tests fail; fix them before finishing:\n" + result.stdout[-3000:], file=sys.stderr)
434
+ sys.exit(2)
435
+ ```
436
+
437
+ ### Input and output
438
+
439
+ Each hook runs through the same shell as `bash_command`, in the project directory (a worktree subagent's own
440
+ worktree), with no terminal. stdin is one JSON object:
441
+
442
+ ```json
443
+ {"hook_event_name": "PreToolUse", "session_id": "…", "turn_id": "…", "cwd": "…", "turn_type": "interactive_loop",
444
+ "tool_name": "bash_command", "tool_input": {"command": "git push origin main"}, "tool_use_id": "…"}
445
+ ```
446
+
447
+ | Event | Extra keys |
448
+ |---|---|
449
+ | `PreToolUse`, `PostToolUse` | `tool_name`, `tool_input`, `tool_use_id`; `PostToolUse` also `tool_response` (the result, already truncated) |
450
+ | `UserPromptSubmit` | `prompt` |
451
+ | `SessionStart` | `source` |
452
+ | `Stop`, `SubagentStop` | `last_assistant_message`, `stop_hook_active` (true once a Stop hook already continued this turn) |
453
+
454
+ The environment adds `DOSTUFF_HOOK_EVENT`, `DOSTUFF_SESSION_ID`, `DOSTUFF_TURN_ID`, `DOSTUFF_PROJECT_ROOT` and
455
+ `DOSTUFF_TURN_TYPE`. Output is capped at `loop.max_tool_output_chars`.
456
+
457
+ | Exit code | Meaning |
458
+ |---|---|
459
+ | `0` | Success. Plain stdout is context (`SessionStart`, `UserPromptSubmit`, `PostToolUse`); JSON on stdout can carry a decision (below) |
460
+ | `2` | Block. stderr (or stdout) is the reason: `PreToolUse` denies the call, `PostToolUse` appends it to the result, `UserPromptSubmit` rejects the prompt, `Stop` continues the turn |
461
+ | other | The hook failed: an error line in the transcript, and the run goes on (unless `fail_closed`) |
462
+
463
+ JSON on stdout (exit 0), in either Claude Code spelling:
464
+
465
+ ```json
466
+ {"decision": "block", "reason": "…"}
467
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow|deny|ask",
468
+ "permissionDecisionReason": "…", "updatedInput": {"command": "…"},
469
+ "additionalContext": "…"}}
470
+ ```
471
+
472
+ When several hooks match, they run one after another, each on the original input: the strictest decision wins
473
+ (`deny` > `ask` > `allow`), contexts are joined in config order, and the last `updatedInput` wins. Plan mode still
474
+ applies: a hook `allow` never runs a call plan mode refused, and settings changes still ask. Rewritten arguments are
475
+ checked again, so a rewrite can't skip the prompt the new call needs. Each hook's effect shows in the transcript as a
476
+ line like `⚓ PreToolUse bash_command → deny (reason)` (a `hook` event in `--output-format stream-json`). ESC kills a
477
+ running hook.
478
+
479
+ ### Project hooks and trust
480
+
481
+ Hooks in `./.dostuff/config.yaml` come with the repo, so they are code from whoever wrote it. They run only once you
482
+ trust them, set by `hooks.project_trust` in **`~/.dostuff/config.yaml`** (a project file can't set it):
483
+
484
+ - `ask` (default): before the first project hook would run, the TUI lists every project hook's command and file and
485
+ asks. A yes is saved in `~/.dostuff/trusted_hooks.json` for that project, until the hooks block or any file in
486
+ `.dostuff/hooks/` changes; then it asks again. A no skips them for the session.
487
+ - `allow` runs them without asking; `deny` never runs them.
488
+ - Headless (`dostuff -p`) can't ask: it honours a saved yes, runs them with `--trust-project-hooks`, and otherwise
489
+ skips them with a warning. If a skipped hook is `fail_closed`, it refuses to start (exit code 2) instead.
490
+
491
+ Keep hook scripts in `.dostuff/hooks/`: trust covers the files there, and the model can't edit anything under
492
+ `.dostuff/` without a settings prompt. A script elsewhere in the repo can be changed after you trusted it.
493
+
494
+ `/hooks` lists every hook per event with its file, matcher, timeout, `fail_closed` and whether it runs (`user`,
495
+ `trusted`, `untrusted`, `disabled` or `broken`); `/hooks r` re-reads the config files. `dostuff doctor` reports
496
+ invalid hooks, commands not found on `PATH` and project scripts outside `.dostuff/hooks/`.
497
+
498
+ ### Coming from Claude Code
499
+
500
+ The stdin envelope, exit codes, JSON output, event names and the `matcher` + `hooks: [{type: command, command}]`
501
+ nesting are the same. Tool names and some argument names differ, so rename the matcher and any `tool_input` keys:
502
+
503
+ | Claude Code | DoStuff `tool_name` | `tool_input` keys that differ |
504
+ |---|---|---|
505
+ | `Bash` | `bash_command` | none (`command`, plus `cwd`, `timeout`) |
506
+ | `Write` | `write_file` | `file_path` → `path` |
507
+ | `Edit` | `edit_file` | `file_path` → `path`, `old_string`/`new_string` → `old_str`/`new_str` |
508
+ | `Read` | `read_file` | `file_path` → `path` |
509
+ | `Grep` / `Glob` | `grep` / `glob` | per tool |
510
+ | `mcp__<server>__<tool>` | `call_mcp_tool` | server and tool are inside `tool_input` |
511
+
512
+ Not supported yet: `http`, `prompt` and `agent` hook types, and the `Notification`, `PreCompact`, `SessionEnd` and
513
+ `SubagentStart` events.
514
+
515
+ ---
516
+
323
517
  ## Configuration
324
518
 
325
519
  Config is layered, with later sources overriding earlier ones:
@@ -352,7 +546,7 @@ models:
352
546
  loop:
353
547
  max_iterations: 100
354
548
  subagent_max_iterations: 25
355
- # context_window: 128000 # override when litellm doesn't know the model
549
+ # context_window: 128000 # override when dostuff/litellm don't know the model
356
550
  max_tool_output_chars: 30000 # longer tool results keep head + tail
357
551
  max_retries: 6 # retries on rate limits, timeouts, 5xx (0 = none)
358
552
  retry_max_delay: 60 # cap on each wait, seconds; Retry-After is honored
@@ -416,6 +610,8 @@ or `/model` inside the TUI. Model lists come from litellm's catalog (chat models
416
610
 
417
611
  OpenCode models (`opencode/<id>` for Zen, `opencode-go/<id>` for a Go subscription) use the ids from the [OpenCode docs](https://opencode.ai/docs/zen/). litellm has no OpenCode provider, so DoStuff sends each model to the gateway endpoint for its family: Claude models (and a few Qwen / MiniMax models) to the Anthropic Messages endpoint, GPT / Grok / Muse models to the Responses endpoint, Gemini models to the Gemini endpoint, and all other models to chat completions. One console key in `OPENCODE_API_KEY` works for Zen and Go. OpenCode only allows its free models from its own app, so use paid models or Go.
418
612
 
613
+ The gateways don't publish a model's context window in `/v1/models`, so `helpers/models/opencode.py` carries one per model, taken from [models.dev](https://models.dev)'s `opencode` and `opencode-go` entries (the same id can differ per gateway: `opencode/minimax-m3` is 512k, `opencode-go/minimax-m3` is 1M). `/context`, the status bar and auto-compaction size themselves from it; set `loop.context_window` to override.
614
+
419
615
  ---
420
616
 
421
617
  ## MCP servers
@@ -36,6 +36,7 @@ DoStuff is a terminal AI agent that runs anywhere `pip` runs. It gives you a ful
36
36
  - **Project instructions** — `AGENTS.md` / `CLAUDE.md` from `~/.dostuff/`, the repo root and every folder down to the current one, combined
37
37
  - **Coding tools** — `read_file` (line numbers, offset/limit), `edit_file` (exact replacement), `write_file`, `grep`, `glob`, `list_files`, `delete_file`, `bash_command`, `web_fetch` (read a URL as text), `web_search` (free, no API key)
38
38
  - **Permission layer** — mutating tool calls ask for approval (allow once / always this session / deny with a note); read-only shell commands run without asking; modes `ask` / `allowlist` / `auto`; `/undo` reverts the agent's file changes
39
+ - **Hooks** — shell commands on `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` and `SubagentStop` that can block a tool call or prompt, feed a formatter's output back to the model, or keep the turn going; Claude Code's hook I/O contract, and project hooks run only once you trust them. See [Hooks](#hooks)
39
40
  - **File sandbox** — file tools stay inside the project directory and never read `.env*` files or `~/.dostuff/`
40
41
  - **Skills loader** — drop a `SKILL.md` in the right folder, restart, done; run one with `/<skill-name> [args]`
41
42
  - **Subagents** — `delegate_to_subagent` runs a bounded task with a restricted tool set; its approval prompts and ESC go through the same TUI
@@ -155,7 +156,7 @@ In the TUI:
155
156
  | `dostuff sessions rm <id>... [-y]` | Delete sessions and their `/undo` checkpoints |
156
157
  | `dostuff sessions prune [--older-than 30d] [--dry-run] [-y]` | Delete empty sessions, and sessions not used for the given time (`45m`, `12h`, `30d`, `2w`) |
157
158
  | `dostuff export [<id>] [-o file\|-]` | Save a session (default: latest here) as Markdown: prompts, answers, tool calls and results |
158
- | `dostuff -p "<prompt>"` | Headless: run one prompt, print the answer to stdout, exit. Progress goes to stderr. Piped stdin is appended to the prompt (`-p -` reads the prompt from stdin). `--session <id>` continues a session, `--output-format json` (or `--json`) prints `{result, session_id, usage, ...}`, `--output-format stream-json` prints one JSON event per line (`init`, `assistant`, `tool_call`, `tool_result`, `usage`, `error`, then `result`), `-q` hides progress, `--permission-mode ask\|allowlist\|auto\|plan`. `--max-turns N` stops after N model calls (exit code 1). `--allowed-tools` (comma-separated or repeated) runs matching calls without asking in any mode, in the `permissions.allow` syntax (`"npm test"`, `web_fetch:docs.python.org`) or a tool name for every call of it (`write_file`, `bash_command`). Without a terminal, other calls that need approval are denied |
159
+ | `dostuff -p "<prompt>"` | Headless: run one prompt, print the answer to stdout, exit. Progress goes to stderr. Piped stdin is appended to the prompt (`-p -` reads the prompt from stdin). `--session <id>` continues a session, `--output-format json` (or `--json`) prints `{result, session_id, usage, ...}`, `--output-format stream-json` prints one JSON event per line (`init`, `assistant`, `tool_call`, `tool_result`, `usage`, `error`, then `result`), `-q` hides progress, `--permission-mode ask\|allowlist\|auto\|plan`. `--max-turns N` stops after N model calls (exit code 1). `--allowed-tools` (comma-separated or repeated) runs matching calls without asking in any mode, in the `permissions.allow` syntax (`"npm test"`, `web_fetch:docs.python.org`) or a tool name for every call of it (`write_file`, `bash_command`). Without a terminal, other calls that need approval are denied. `--trust-project-hooks` runs the project's [hooks](#project-hooks-and-trust) without a saved trust answer |
159
160
  | `dostuff mcp list` | Show MCP servers and the file each comes from |
160
161
  | `dostuff mcp add <name> [-e K=V] -- <command> [args...]` | Add a stdio server; `--url <url> [-H "Name: value"]` for HTTP; `--project` writes `./.dostuff/mcp_config.json` |
161
162
  | `dostuff mcp remove <name> [--project]` | Remove a server |
@@ -195,7 +196,8 @@ In the TUI:
195
196
  | `/status` | Session, directory, model, permission mode, tokens, context use, MCP servers |
196
197
  | `/context` | What fills the context window: system prompt, tools, MCP tools, project instructions, compaction summary and messages, with tokens, % and a bar |
197
198
  | `/memory [project\|global]` | Edit `AGENTS.md` (this project's or `~/.dostuff/AGENTS.md`) in the same editor as `/config`; `Ctrl+S` saves and the agent follows it from the next message |
198
- | `/doctor` | The `dostuff doctor` checks (config, API key, MCP config, memory), with a fix for each problem |
199
+ | `/doctor` | The `dostuff doctor` checks (config, API key, MCP config, memory, hooks), with a fix for each problem |
200
+ | `/hooks [r]` | List the configured hooks per event: file, matcher, timeout, `fail_closed`, and whether each runs (`user` / `trusted` / `untrusted` / `disabled` / `broken`). `/hooks r` re-reads the config files. See [Hooks](#hooks) |
199
201
  | `/<skill> [args]` | Run a skill: every discovered skill is a command (listed in the `/` menu and `/help`). See [Skills](#skills) |
200
202
  | `/<command> [args]` | Run a custom command from `.dostuff/commands/` or `~/.dostuff/commands/`. See [Custom commands](#custom-commands) |
201
203
  | `/rename <title>` | Name this session (shown by `dostuff sessions`) |
@@ -237,6 +239,11 @@ In the TUI:
237
239
  | `read_settings` | read | never (MCP `env` / header values are shown as `<redacted>`) |
238
240
  | `edit_config`, `set_mcp_server`, `remove_mcp_server` | settings | **always, in every mode, with no "always" option** (shows the diff or the server's command) |
239
241
 
242
+ File tools writing under a `.dostuff` directory, and `bash_command` calls whose command or `cwd` names `.dostuff`, are
243
+ settings changes too, so they always ask. That way auto mode can't quietly add a hook to `~/.dostuff/config.yaml`. Paths
244
+ into subagent worktrees (`.dostuff/worktrees/…`) are exempt. The shell check matches the text `.dostuff`, so a name the
245
+ command builds indirectly is not caught.
246
+
240
247
  `permissions.mode` in `config.yaml`:
241
248
 
242
249
  - `ask` (default) — as in the table above.
@@ -272,6 +279,193 @@ API keys stay in `.env` files, which the agent can't read or change. Subagents d
272
279
 
273
280
  ---
274
281
 
282
+ ## Hooks
283
+
284
+ Hooks are shell commands DoStuff runs at fixed points of a session: block a tool call, feed a formatter's output back
285
+ to the model, keep the turn going until the tests pass. They are configured under `hooks:` in `config.yaml` and use
286
+ Claude Code's I/O contract (stdin JSON, exit codes, JSON output), so an existing Claude Code hook script works once its
287
+ matcher and tool argument names are renamed (see [the table below](#coming-from-claude-code)).
288
+
289
+ > [!WARNING]
290
+ > Hooks run with your privileges, **unsandboxed**, and are not permission-checked: they are your config, not model
291
+ > output. Read a project's hooks before you trust them.
292
+
293
+ | Event | When | Matcher is matched against | What a hook can do |
294
+ |---|---|---|---|
295
+ | `SessionStart` | Before the first turn of a session, and after a compaction | the source: `startup`, `resume`, `clear`, `compact` | Add context for the rest of the session |
296
+ | `UserPromptSubmit` | After you send a prompt, before it is stored or sent | — | Reject the prompt, or add context for this turn |
297
+ | `PreToolUse` | Before each tool call, before any approval prompt | the tool name | `allow`, `deny` or `ask`; rewrite the arguments |
298
+ | `PostToolUse` | After a tool call ran | the tool name | Append text to the result the model sees |
299
+ | `Stop` | When the agent is about to give its final answer | — | Block, so the turn continues with your reason |
300
+ | `SubagentStop` | Same, for a `delegate_to_subagent` run | — | Same |
301
+
302
+ ### Config
303
+
304
+ `matcher` is a Python regex that must match the whole name (`edit_file` does not match `edit_file_v2`); empty or `*`
305
+ matches everything. `if` (tool events only) is searched in a `bash_command`'s `command`. Inline flags such as `(?i)`
306
+ go first in a pattern. `timeout` (default 30 s, 1–300) and `fail_closed` can be set on a group or on one hook. This is
307
+ the example from the spec, and the two scripts it runs are below:
308
+
309
+ ```yaml
310
+ hooks:
311
+ project_trust: ask # ask | allow | deny (D4) — only read from ~/.dostuff/config.yaml
312
+ stop_block_limit: 3 # Stop blocks honoured per turn before the loop is allowed to end (1–10)
313
+ SessionStart:
314
+ - matcher: "startup|resume|clear" # regex, full match against the source; empty or "*" = all
315
+ hooks:
316
+ - type: command
317
+ command: "git status --short"
318
+ PreToolUse:
319
+ - matcher: "bash_command" # regex, full match against the tool name
320
+ if: "(?i)^\\s*git\\s+push" # optional regex, searched in tool_input.command (shell tool only)
321
+ fail_closed: true
322
+ timeout: 10
323
+ hooks:
324
+ - type: command
325
+ name: block-main-push
326
+ command: "python .dostuff/hooks/block_main_push.py"
327
+ PostToolUse:
328
+ - matcher: "write_file|edit_file"
329
+ hooks:
330
+ # the script reads tool_input.path from the stdin JSON and runs the formatter on it;
331
+ # its stdout is fed back to the model (D5)
332
+ - type: command
333
+ name: format
334
+ command: "python .dostuff/hooks/format_file.py"
335
+ Stop:
336
+ - hooks:
337
+ - { type: command, name: tests, command: "python .dostuff/hooks/run_tests.py" }
338
+ ```
339
+
340
+ - **`fail_closed: true`** (`PreToolUse` and `UserPromptSubmit` only) is for guardrails: if the hook crashes or times
341
+ out, the call is denied (or the prompt rejected) instead of going ahead. A `fail_closed` hook with a config mistake,
342
+ such as a bad regex, is kept and denies everything it matches; any other invalid hook is dropped with a warning.
343
+ - **`stop_block_limit`** (1–10) bounds how many times `Stop` hooks can continue one turn; after that the turn ends with
344
+ a note.
345
+ - User hooks come first, then the project's are added after them; a project file can't remove or reorder yours.
346
+
347
+ ```python
348
+ # .dostuff/hooks/block_main_push.py: PreToolUse on bash_command; the group's `if` already matched `git push`
349
+ import json
350
+ import subprocess
351
+ import sys
352
+
353
+ payload = json.load(sys.stdin)
354
+ words = payload["tool_input"].get("command", "").replace(":", " ").split()
355
+ branch = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True,
356
+ cwd=payload["cwd"]).stdout.strip()
357
+ if "main" in words or branch == "main":
358
+ print("Don't push to main: push a feature branch and open a pull request.", file=sys.stderr)
359
+ sys.exit(2) # exit 2 = deny; stderr is the reason the model sees
360
+ ```
361
+
362
+ ```python
363
+ # .dostuff/hooks/format_file.py: PostToolUse on write_file|edit_file; stdout is appended to the tool result
364
+ import json
365
+ import subprocess
366
+ import sys
367
+
368
+ payload = json.load(sys.stdin)
369
+ path = payload["tool_input"].get("path", "")
370
+ if path.endswith(".py"):
371
+ result = subprocess.run(["ruff", "format", path], capture_output=True, text=True, cwd=payload["cwd"])
372
+ print((result.stdout + result.stderr).strip())
373
+ ```
374
+
375
+ ```python
376
+ # .dostuff/hooks/run_tests.py: Stop; exit 2 sends the failures back and the turn continues
377
+ import json
378
+ import subprocess
379
+ import sys
380
+
381
+ payload = json.load(sys.stdin)
382
+ result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x"], capture_output=True, text=True,
383
+ cwd=payload["cwd"])
384
+ if result.returncode not in (0, 5): # 5: no tests collected
385
+ print("Tests fail; fix them before finishing:\n" + result.stdout[-3000:], file=sys.stderr)
386
+ sys.exit(2)
387
+ ```
388
+
389
+ ### Input and output
390
+
391
+ Each hook runs through the same shell as `bash_command`, in the project directory (a worktree subagent's own
392
+ worktree), with no terminal. stdin is one JSON object:
393
+
394
+ ```json
395
+ {"hook_event_name": "PreToolUse", "session_id": "…", "turn_id": "…", "cwd": "…", "turn_type": "interactive_loop",
396
+ "tool_name": "bash_command", "tool_input": {"command": "git push origin main"}, "tool_use_id": "…"}
397
+ ```
398
+
399
+ | Event | Extra keys |
400
+ |---|---|
401
+ | `PreToolUse`, `PostToolUse` | `tool_name`, `tool_input`, `tool_use_id`; `PostToolUse` also `tool_response` (the result, already truncated) |
402
+ | `UserPromptSubmit` | `prompt` |
403
+ | `SessionStart` | `source` |
404
+ | `Stop`, `SubagentStop` | `last_assistant_message`, `stop_hook_active` (true once a Stop hook already continued this turn) |
405
+
406
+ The environment adds `DOSTUFF_HOOK_EVENT`, `DOSTUFF_SESSION_ID`, `DOSTUFF_TURN_ID`, `DOSTUFF_PROJECT_ROOT` and
407
+ `DOSTUFF_TURN_TYPE`. Output is capped at `loop.max_tool_output_chars`.
408
+
409
+ | Exit code | Meaning |
410
+ |---|---|
411
+ | `0` | Success. Plain stdout is context (`SessionStart`, `UserPromptSubmit`, `PostToolUse`); JSON on stdout can carry a decision (below) |
412
+ | `2` | Block. stderr (or stdout) is the reason: `PreToolUse` denies the call, `PostToolUse` appends it to the result, `UserPromptSubmit` rejects the prompt, `Stop` continues the turn |
413
+ | other | The hook failed: an error line in the transcript, and the run goes on (unless `fail_closed`) |
414
+
415
+ JSON on stdout (exit 0), in either Claude Code spelling:
416
+
417
+ ```json
418
+ {"decision": "block", "reason": "…"}
419
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow|deny|ask",
420
+ "permissionDecisionReason": "…", "updatedInput": {"command": "…"},
421
+ "additionalContext": "…"}}
422
+ ```
423
+
424
+ When several hooks match, they run one after another, each on the original input: the strictest decision wins
425
+ (`deny` > `ask` > `allow`), contexts are joined in config order, and the last `updatedInput` wins. Plan mode still
426
+ applies: a hook `allow` never runs a call plan mode refused, and settings changes still ask. Rewritten arguments are
427
+ checked again, so a rewrite can't skip the prompt the new call needs. Each hook's effect shows in the transcript as a
428
+ line like `⚓ PreToolUse bash_command → deny (reason)` (a `hook` event in `--output-format stream-json`). ESC kills a
429
+ running hook.
430
+
431
+ ### Project hooks and trust
432
+
433
+ Hooks in `./.dostuff/config.yaml` come with the repo, so they are code from whoever wrote it. They run only once you
434
+ trust them, set by `hooks.project_trust` in **`~/.dostuff/config.yaml`** (a project file can't set it):
435
+
436
+ - `ask` (default): before the first project hook would run, the TUI lists every project hook's command and file and
437
+ asks. A yes is saved in `~/.dostuff/trusted_hooks.json` for that project, until the hooks block or any file in
438
+ `.dostuff/hooks/` changes; then it asks again. A no skips them for the session.
439
+ - `allow` runs them without asking; `deny` never runs them.
440
+ - Headless (`dostuff -p`) can't ask: it honours a saved yes, runs them with `--trust-project-hooks`, and otherwise
441
+ skips them with a warning. If a skipped hook is `fail_closed`, it refuses to start (exit code 2) instead.
442
+
443
+ Keep hook scripts in `.dostuff/hooks/`: trust covers the files there, and the model can't edit anything under
444
+ `.dostuff/` without a settings prompt. A script elsewhere in the repo can be changed after you trusted it.
445
+
446
+ `/hooks` lists every hook per event with its file, matcher, timeout, `fail_closed` and whether it runs (`user`,
447
+ `trusted`, `untrusted`, `disabled` or `broken`); `/hooks r` re-reads the config files. `dostuff doctor` reports
448
+ invalid hooks, commands not found on `PATH` and project scripts outside `.dostuff/hooks/`.
449
+
450
+ ### Coming from Claude Code
451
+
452
+ The stdin envelope, exit codes, JSON output, event names and the `matcher` + `hooks: [{type: command, command}]`
453
+ nesting are the same. Tool names and some argument names differ, so rename the matcher and any `tool_input` keys:
454
+
455
+ | Claude Code | DoStuff `tool_name` | `tool_input` keys that differ |
456
+ |---|---|---|
457
+ | `Bash` | `bash_command` | none (`command`, plus `cwd`, `timeout`) |
458
+ | `Write` | `write_file` | `file_path` → `path` |
459
+ | `Edit` | `edit_file` | `file_path` → `path`, `old_string`/`new_string` → `old_str`/`new_str` |
460
+ | `Read` | `read_file` | `file_path` → `path` |
461
+ | `Grep` / `Glob` | `grep` / `glob` | per tool |
462
+ | `mcp__<server>__<tool>` | `call_mcp_tool` | server and tool are inside `tool_input` |
463
+
464
+ Not supported yet: `http`, `prompt` and `agent` hook types, and the `Notification`, `PreCompact`, `SessionEnd` and
465
+ `SubagentStart` events.
466
+
467
+ ---
468
+
275
469
  ## Configuration
276
470
 
277
471
  Config is layered, with later sources overriding earlier ones:
@@ -304,7 +498,7 @@ models:
304
498
  loop:
305
499
  max_iterations: 100
306
500
  subagent_max_iterations: 25
307
- # context_window: 128000 # override when litellm doesn't know the model
501
+ # context_window: 128000 # override when dostuff/litellm don't know the model
308
502
  max_tool_output_chars: 30000 # longer tool results keep head + tail
309
503
  max_retries: 6 # retries on rate limits, timeouts, 5xx (0 = none)
310
504
  retry_max_delay: 60 # cap on each wait, seconds; Retry-After is honored
@@ -368,6 +562,8 @@ or `/model` inside the TUI. Model lists come from litellm's catalog (chat models
368
562
 
369
563
  OpenCode models (`opencode/<id>` for Zen, `opencode-go/<id>` for a Go subscription) use the ids from the [OpenCode docs](https://opencode.ai/docs/zen/). litellm has no OpenCode provider, so DoStuff sends each model to the gateway endpoint for its family: Claude models (and a few Qwen / MiniMax models) to the Anthropic Messages endpoint, GPT / Grok / Muse models to the Responses endpoint, Gemini models to the Gemini endpoint, and all other models to chat completions. One console key in `OPENCODE_API_KEY` works for Zen and Go. OpenCode only allows its free models from its own app, so use paid models or Go.
370
564
 
565
+ The gateways don't publish a model's context window in `/v1/models`, so `helpers/models/opencode.py` carries one per model, taken from [models.dev](https://models.dev)'s `opencode` and `opencode-go` entries (the same id can differ per gateway: `opencode/minimax-m3` is 512k, `opencode-go/minimax-m3` is 1M). `/context`, the status bar and auto-compaction size themselves from it; set `loop.context_window` to override.
566
+
371
567
  ---
372
568
 
373
569
  ## MCP servers