dostuff 0.2.3__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 (135) hide show
  1. {dostuff-0.2.3 → dostuff-0.2.4}/PKG-INFO +197 -3
  2. {dostuff-0.2.3 → dostuff-0.2.4}/README.md +196 -2
  3. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/loop.py +66 -9
  4. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/permissions.py +30 -2
  5. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/run_tool.py +100 -10
  6. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/cli.py +6 -3
  7. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/cli_tui.py +71 -9
  8. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/config.py +14 -2
  9. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/headless.py +36 -1
  10. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/constants.py +1 -0
  11. dostuff-0.2.4/dostuff/helpers/hooks/__init__.py +10 -0
  12. dostuff-0.2.4/dostuff/helpers/hooks/dispatch.py +139 -0
  13. dostuff-0.2.4/dostuff/helpers/hooks/events.py +84 -0
  14. dostuff-0.2.4/dostuff/helpers/hooks/load_hooks.py +248 -0
  15. dostuff-0.2.4/dostuff/helpers/hooks/run_hook.py +186 -0
  16. dostuff-0.2.4/dostuff/helpers/hooks/trust.py +157 -0
  17. dostuff-0.2.4/dostuff/helpers/ui/doctor.py +191 -0
  18. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/templates/config.example.yaml +40 -0
  19. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/delegate_to_subagent.py +4 -1
  20. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/settings.py +11 -2
  21. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/PKG-INFO +197 -3
  22. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/SOURCES.txt +7 -0
  23. {dostuff-0.2.3 → dostuff-0.2.4}/pyproject.toml +1 -1
  24. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_cli_commands.py +99 -0
  25. dostuff-0.2.4/tests/test_hooks.py +1601 -0
  26. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_permissions.py +25 -0
  27. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_tui.py +314 -0
  28. dostuff-0.2.3/dostuff/helpers/ui/doctor.py +0 -47
  29. {dostuff-0.2.3 → dostuff-0.2.4}/LICENSE +0 -0
  30. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/__init__.py +0 -0
  31. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/__main__.py +0 -0
  32. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/call_agent.py +0 -0
  33. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/instructions.py +0 -0
  34. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/append_step.py +0 -0
  35. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/consume_stream.py +0 -0
  36. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/context_usage.py +0 -0
  37. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/environment_context.py +0 -0
  38. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/file_mentions.py +0 -0
  39. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/get_model_token_limit.py +0 -0
  40. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/load_identity.py +0 -0
  41. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/load_project_instructions.py +0 -0
  42. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/loop_check_cancelled.py +0 -0
  43. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/loop_emit.py +0 -0
  44. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/manage_context.py +0 -0
  45. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/model_cost.py +0 -0
  46. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/pastes.py +0 -0
  47. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/quick_memory.py +0 -0
  48. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/slash_suggestion.py +0 -0
  49. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/tool_output.py +0 -0
  50. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/load_mcp_config.py +0 -0
  51. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/mcp_oauth.py +0 -0
  52. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/server_config.py +0 -0
  53. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/token_store.py +0 -0
  54. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/memory/export_session.py +0 -0
  55. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/memory/resolve_memory_operation.py +0 -0
  56. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/memory/shell_step.py +0 -0
  57. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/models/add_model_entry.py +0 -0
  58. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/models/list_models.py +0 -0
  59. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/models/opencode.py +0 -0
  60. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/skills/custom_commands.py +0 -0
  61. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/skills/discover_skills.py +0 -0
  62. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/skills/skill_command.py +0 -0
  63. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/diff.py +0 -0
  64. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/generate_tool_schema.py +0 -0
  65. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/project_root.py +0 -0
  66. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/resolve_safe_path.py +0 -0
  67. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/__init__.py +0 -0
  68. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/emit.py +0 -0
  69. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/prompt_history.py +0 -0
  70. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/terminal_signals.py +0 -0
  71. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/checkpoints.py +0 -0
  72. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/exceptions.py +0 -0
  73. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_client.py +0 -0
  74. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_client_registration_store.py +0 -0
  75. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_tool_registry_store.py +0 -0
  76. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/chroma.py +0 -0
  77. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/episodic_memory_store.py +0 -0
  78. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/semantic_memory_store.py +0 -0
  79. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/session_store.py +0 -0
  80. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/stores.py +0 -0
  81. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/types.py +0 -0
  82. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/model.py +0 -0
  83. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/tracing.py +0 -0
  84. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/worktree.py +0 -0
  85. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/memory/__init__.py +0 -0
  86. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/setup_wizard.py +0 -0
  87. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/skills.py +0 -0
  88. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/templates/env.example +0 -0
  89. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/__init__.py +0 -0
  90. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/bash/bash_command.py +0 -0
  91. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/definitions.py +0 -0
  92. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/delete_file.py +0 -0
  93. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/edit_file.py +0 -0
  94. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/list_files.py +0 -0
  95. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/read_file.py +0 -0
  96. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/write_file.py +0 -0
  97. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/get_current_datetime.py +0 -0
  98. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/mcp/call_mcp_tool.py +0 -0
  99. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/mcp/get_mcp_tool_details.py +0 -0
  100. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/mcp/search_mcp_tools.py +0 -0
  101. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/memory/save_episode.py +0 -0
  102. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/memory/save_memory.py +0 -0
  103. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/search/glob.py +0 -0
  104. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/search/grep.py +0 -0
  105. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/search/walk.py +0 -0
  106. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/todo_write.py +0 -0
  107. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/web_fetch.py +0 -0
  108. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/web_search.py +0 -0
  109. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/dependency_links.txt +0 -0
  110. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/entry_points.txt +0 -0
  111. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/requires.txt +0 -0
  112. {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/top_level.txt +0 -0
  113. {dostuff-0.2.3 → dostuff-0.2.4}/setup.cfg +0 -0
  114. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_checkpoints_todos_caching.py +0 -0
  115. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_config_memory_mcp.py +0 -0
  116. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_file_mentions.py +0 -0
  117. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_file_tools.py +0 -0
  118. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_loop.py +0 -0
  119. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_mcp_e2e.py +0 -0
  120. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_mcp_oauth.py +0 -0
  121. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_opencode.py +0 -0
  122. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_optional_memory_and_instructions.py +0 -0
  123. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_pastes.py +0 -0
  124. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_roadmap_021.py +0 -0
  125. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_session_store.py +0 -0
  126. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_settings_tools.py +0 -0
  127. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_setup_wizard.py +0 -0
  128. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_skill_command.py +0 -0
  129. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_slash_suggestion.py +0 -0
  130. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_subagent.py +0 -0
  131. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_terminal_signals.py +0 -0
  132. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_tool_schemas.py +0 -0
  133. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_web_fetch.py +0 -0
  134. {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_web_search.py +0 -0
  135. {dostuff-0.2.3 → 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.3
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:
@@ -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:
@@ -15,7 +15,15 @@ import inspect
15
15
  import random
16
16
  from dataclasses import dataclass
17
17
  from dostuff.agent.permissions import PLAN_MODE_INSTRUCTION, SessionPermissions
18
- from dostuff.agent.run_tool import authorize_batch, authorize_tool, dispatch_tool
18
+ from dostuff.agent.run_tool import (
19
+ CANCELLED_RESULT, authorize_batch, authorize_tool, dispatch_tool, post_tool_use, pre_tool_use,
20
+ )
21
+ from dostuff.helpers.hooks import HookOutcome, run_hooks
22
+ from dostuff.helpers.hooks.dispatch import report_outcome
23
+ from dostuff.helpers.hooks.events import (
24
+ STOP_LIMIT_MESSAGE, STOP_NOTE, session_context_suffix, session_start, stop_event_for,
25
+ )
26
+ from dostuff.helpers.hooks.run_hook import payload_for
19
27
  from dostuff.config import AUTO_COMPACT_THRESHOLD, get_config
20
28
  from dostuff.tools.definitions import TOOL_MAP
21
29
  from typing import Any, Literal
@@ -26,7 +34,6 @@ from dostuff.helpers.agent.tool_output import EMPTY_RESPONSE_NUDGE, format_call_
26
34
 
27
35
  TOOL_TIMEOUT_S = 120.0
28
36
  RETRY_BASE_DELAY_S = 1.0
29
- CANCELLED_RESULT = "Cancelled by user"
30
37
  CANCELLED_RUNNING_RESULT = "Cancelled by user (may still be running)"
31
38
  # Read-only tools that must still run one at a time (a subagent may prompt for approval)
32
39
  _SEQUENTIAL_READ_TOOLS = frozenset({"delegate_to_subagent"})
@@ -85,6 +92,7 @@ class _ToolCall:
85
92
  id: str
86
93
  error: str | None = None # set when the call can't run (bad JSON, unavailable tool, denied)
87
94
  result: str | None = None
95
+ hook: HookOutcome | None = None # its PreToolUse outcome, an input to the permission check
88
96
 
89
97
 
90
98
  def _parse_tool_calls(tool_calls: list[Any], tool_names: list[str] | None) -> list[_ToolCall]:
@@ -154,8 +162,12 @@ async def _sleep_or_cancel(seconds: float, adapter: Any) -> bool:
154
162
  return is_cancelled(adapter)
155
163
 
156
164
 
157
- async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_instruction: str, mcp_client: MCPClient, working_history: list, turn_type: Literal['interactive_loop', 'subagent_loop'], current_session_history=None, steps_history=None, store=None, adapter=None, compaction_notes: str = "", session_prompt_tokens: int = 0, session_completion_tokens: int = 0, session_total_tokens: int = 0, last_input_tokens: int = 0, stream: bool = True, max_iterations: int = MAX_ITERATIONS, tool_names: list[str] | None = None, permissions: SessionPermissions | None = None) -> str:
158
- """Run one agent turn. `working_history` is mutated in place (appends and compaction)."""
165
+ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_instruction: str, mcp_client: MCPClient, working_history: list, turn_type: Literal['interactive_loop', 'subagent_loop'], current_session_history=None, steps_history=None, store=None, adapter=None, compaction_notes: str = "", session_prompt_tokens: int = 0, session_completion_tokens: int = 0, session_total_tokens: int = 0, last_input_tokens: int = 0, stream: bool = True, max_iterations: int = MAX_ITERATIONS, tool_names: list[str] | None = None, permissions: SessionPermissions | None = None, session_hook_context: dict[str, str] | None = None) -> str:
166
+ """Run one agent turn. `working_history` is mutated in place (appends and compaction).
167
+
168
+ `session_hook_context` is the caller's per-session SessionStart context (hooks spec REQ-2.4): added to
169
+ every model call's system instruction, refreshed here on compaction, never written to history.
170
+ """
159
171
  current_session_id.set(session_id)
160
172
  iteration = 0
161
173
  token_limit = get_model_token_limit()
@@ -190,6 +202,10 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
190
202
  except Exception as e:
191
203
  await loop_emit("error", f"Could not save compaction: {e}", adapter=adapter)
192
204
  await loop_emit("system", f"{reason} Context compacted. Notes: {compaction_notes}", adapter=adapter)
205
+ if session_hook_context is not None:
206
+ # Earlier SessionStart context may be stale now; refresh it under its own key (REQ-2.4)
207
+ await session_start("compact", session_hook_context, session_id=session_id, turn_id=turn_id,
208
+ turn_type=turn_type, adapter=adapter)
193
209
  return True
194
210
 
195
211
  async def check_cancelled() -> bool:
@@ -209,6 +225,7 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
209
225
 
210
226
  max_iter = max_iterations
211
227
  nudged = False
228
+ stop_blocks = 0 # Stop-hook continuations this turn, bounded by hooks.stop_block_limit
212
229
  while iteration < max_iter:
213
230
  iteration += 1
214
231
  with tracer.start_as_current_span("iteration") as iter_span:
@@ -232,7 +249,8 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
232
249
  steps_history=working_history,
233
250
  system_instruction=dynamic_system_instruction
234
251
  + (f"\n\n{PLAN_MODE_INSTRUCTION}" if plan_mode else "")
235
- + (f"\n\n[Summary of earlier conversation]: {compaction_notes}" if compaction_notes else ""),
252
+ + (f"\n\n[Summary of earlier conversation]: {compaction_notes}" if compaction_notes else "")
253
+ + session_context_suffix(session_hook_context),
236
254
  tool_names=tool_names,
237
255
  stream=stream,
238
256
  )
@@ -325,6 +343,25 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
325
343
  }
326
344
  await append_step(model_step, steps_history, working_history, current_session_history, session_id, store, turn_type)
327
345
 
346
+ # Stop / SubagentStop: only here, on a final answer, never on cancel, errors or the
347
+ # iteration cap. A block keeps the turn going, at most stop_block_limit times (REQ-2.3)
348
+ stop_event = stop_event_for(turn_type)
349
+ stop = await run_hooks(stop_event, payload_for(
350
+ stop_event, session_id=session_id, turn_id=turn_id, turn_type=turn_type,
351
+ last_assistant_message=content, stop_hook_active=stop_blocks > 0,
352
+ ), adapter=adapter)
353
+ await report_outcome(stop_event, stop, adapter=adapter)
354
+ if stop.decision == "deny" and not stop.cancelled:
355
+ name = stop.decided_by or stop_event
356
+ if stop_blocks < config.hooks_stop_block_limit:
357
+ stop_blocks += 1
358
+ note = STOP_NOTE.format(name=name, reason=stop.reason or "keep going")
359
+ await append_step({"role": "user", "content": note}, steps_history, working_history, current_session_history, session_id, store, turn_type)
360
+ iter_span.set_status(Status(StatusCode.OK))
361
+ continue
362
+ await loop_emit("system", STOP_LIMIT_MESSAGE.format(
363
+ event=stop_event, name=name, limit=config.hooks_stop_block_limit), adapter=adapter)
364
+
328
365
  turn_span.set_attribute("outcome", "success")
329
366
  turn_span.set_status(Status(StatusCode.OK))
330
367
  iter_span.set_status(Status(StatusCode.OK))
@@ -355,18 +392,34 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
355
392
  await loop_emit("tool_call", _call_label(call), adapter=adapter)
356
393
 
357
394
  cancelled = False
395
+ hook_ids: dict[str, Any] = {"session_id": session_id, "turn_id": turn_id, "turn_type": turn_type, "adapter": adapter}
396
+
397
+ # PreToolUse hooks run for every call, one at a time, before any prompt: a hook deny costs
398
+ # the user no prompt, and a rewrite is what the permission layer sees (hooks spec D2, D6)
399
+ for call in calls:
400
+ if call.error is not None:
401
+ continue
402
+ call.args, call.hook = await pre_tool_use(call.name, call.args, tool_use_id=call.id, **hook_ids)
403
+ if call.hook.cancelled:
404
+ call.error = call.result = CANCELLED_RESULT
405
+ cancelled = True
406
+ break
407
+ if call.hook.decision == "deny":
408
+ call.error = call.hook.reason
358
409
 
359
410
  # Permission checks run first, outside the tool timeout so a slow human doesn't time the
360
411
  # call out: one prompt for all calls that need approval, else one at a time.
361
- pending = [c for c in calls if c.error is None]
362
- batch = await authorize_batch([(c.name, dict(c.args)) for c in pending], adapter=adapter, permissions=permissions)
412
+ pending = [c for c in calls if c.error is None] if not cancelled else []
413
+ batch = await authorize_batch([(c.name, dict(c.args)) for c in pending], adapter=adapter, permissions=permissions, hooks=[c.hook for c in pending])
363
414
  decided = dict(zip(map(id, pending), batch)) if batch is not None else {}
364
415
  for call in calls:
416
+ if cancelled:
417
+ break
365
418
  if call.error is None:
366
419
  if id(call) in decided:
367
420
  call.error = decided[id(call)]
368
421
  else:
369
- call.error = await authorize_tool(call.name, dict(call.args), adapter=adapter, permissions=permissions)
422
+ call.error = await authorize_tool(call.name, dict(call.args), adapter=adapter, permissions=permissions, hook=call.hook)
370
423
  if call.error is not None:
371
424
  call.result = call.error
372
425
  await loop_emit("tool_result", f"{call.name}: {call.result}", adapter=adapter)
@@ -394,6 +447,10 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
394
447
  call.result = f"Error: {exc}"
395
448
  else:
396
449
  call.result = str(task.result())
450
+ if not was_cancelled:
451
+ # PostToolUse runs here, after the dispatch task and outside TOOL_TIMEOUT_S, so the
452
+ # parallel read-only batch stays intact and its context reaches the tool step (D5)
453
+ call.result, _ = await post_tool_use(call.name, call.args, call.result, tool_use_id=call.id, **hook_ids)
397
454
  await loop_emit("tool_result", f"{call.name}: {call.result}", adapter=adapter)
398
455
  return was_cancelled
399
456
 
@@ -411,7 +468,7 @@ async def loop(session_id: str, turn_id: str, user_text: str, dynamic_system_ins
411
468
  # Keep the assistant step and every result (completed or not) so history stays valid
412
469
  await append_step(assistant_tool_step, steps_history, working_history, current_session_history, session_id, store, turn_type)
413
470
  for call in calls:
414
- result_text = call.result if call.result is not None else CANCELLED_RESULT
471
+ result_text = call.result if call.result is not None else call.error or CANCELLED_RESULT
415
472
  result_step = {
416
473
  "role": "tool",
417
474
  "name": call.name,