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.
- {dostuff-0.2.3 → dostuff-0.2.4}/PKG-INFO +197 -3
- {dostuff-0.2.3 → dostuff-0.2.4}/README.md +196 -2
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/loop.py +66 -9
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/permissions.py +30 -2
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/run_tool.py +100 -10
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/cli.py +6 -3
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/cli_tui.py +71 -9
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/config.py +14 -2
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/headless.py +36 -1
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/constants.py +1 -0
- dostuff-0.2.4/dostuff/helpers/hooks/__init__.py +10 -0
- dostuff-0.2.4/dostuff/helpers/hooks/dispatch.py +139 -0
- dostuff-0.2.4/dostuff/helpers/hooks/events.py +84 -0
- dostuff-0.2.4/dostuff/helpers/hooks/load_hooks.py +248 -0
- dostuff-0.2.4/dostuff/helpers/hooks/run_hook.py +186 -0
- dostuff-0.2.4/dostuff/helpers/hooks/trust.py +157 -0
- dostuff-0.2.4/dostuff/helpers/ui/doctor.py +191 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/templates/config.example.yaml +40 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/delegate_to_subagent.py +4 -1
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/settings.py +11 -2
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/PKG-INFO +197 -3
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/SOURCES.txt +7 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/pyproject.toml +1 -1
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_cli_commands.py +99 -0
- dostuff-0.2.4/tests/test_hooks.py +1601 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_permissions.py +25 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_tui.py +314 -0
- dostuff-0.2.3/dostuff/helpers/ui/doctor.py +0 -47
- {dostuff-0.2.3 → dostuff-0.2.4}/LICENSE +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/__init__.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/__main__.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/call_agent.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/agent/instructions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/append_step.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/consume_stream.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/context_usage.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/environment_context.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/file_mentions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/get_model_token_limit.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/load_identity.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/load_project_instructions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/loop_check_cancelled.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/loop_emit.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/manage_context.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/model_cost.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/pastes.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/quick_memory.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/slash_suggestion.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/agent/tool_output.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/load_mcp_config.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/mcp_oauth.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/server_config.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/mcp/token_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/memory/export_session.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/memory/resolve_memory_operation.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/memory/shell_step.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/models/add_model_entry.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/models/list_models.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/models/opencode.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/skills/custom_commands.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/skills/discover_skills.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/skills/skill_command.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/diff.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/generate_tool_schema.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/project_root.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/tools/resolve_safe_path.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/__init__.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/emit.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/prompt_history.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/helpers/ui/terminal_signals.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/checkpoints.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/exceptions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_client.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_client_registration_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/mcp/mcp_tool_registry_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/chroma.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/episodic_memory_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/semantic_memory_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/session_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/stores.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/memory/types.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/model.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/tracing.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/lib/worktree.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/memory/__init__.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/setup_wizard.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/skills.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/templates/env.example +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/__init__.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/bash/bash_command.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/definitions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/delete_file.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/edit_file.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/list_files.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/read_file.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/files/write_file.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/get_current_datetime.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/mcp/call_mcp_tool.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/mcp/get_mcp_tool_details.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/mcp/search_mcp_tools.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/memory/save_episode.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/memory/save_memory.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/search/glob.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/search/grep.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/search/walk.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/todo_write.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/web_fetch.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff/tools/web_search.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/dependency_links.txt +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/entry_points.txt +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/requires.txt +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/dostuff.egg-info/top_level.txt +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/setup.cfg +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_checkpoints_todos_caching.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_config_memory_mcp.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_file_mentions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_file_tools.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_loop.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_mcp_e2e.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_mcp_oauth.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_opencode.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_optional_memory_and_instructions.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_pastes.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_roadmap_021.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_session_store.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_settings_tools.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_setup_wizard.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_skill_command.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_slash_suggestion.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_subagent.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_terminal_signals.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_tool_schemas.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_web_fetch.py +0 -0
- {dostuff-0.2.3 → dostuff-0.2.4}/tests/test_web_search.py +0 -0
- {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
|
+
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
|
|
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,
|