From 60226260d7f5ec4b3b6774ad12719c851d65e1fa Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Wed, 3 Jun 2026 09:45:40 +0200 Subject: [PATCH 1/4] feat: language detection, per-project settings, configurable MD files - Language detection: 17+ language signatures, monorepo detection, Makefile/Justfile fallback, 2 new tests - Configurable MD files: TINYHARNESS_MD_FILES env var, project_md_files in settings, additional files via .tinyharness/config.json, 3 new tests - Per-project settings: .tinyharness/config.json discovery + merge, MergedSettings, /project-settings cmd with init subcommand, source annotations Docs: docs/README.md, docs/language-detection.md, docs/per-project-settings.md, docs/project-instructions.md --- .gitignore | 3 +- README.md | 406 +++++++++++++++++++++++++----- TINYHARNESS.md | 152 +++++------ docs/README.md | 38 +++ docs/language-detection.md | 100 ++++++++ docs/per-project-settings.md | 170 +++++++++++++ docs/project-instructions.md | 133 ++++++++++ src/commands/init.rs | 2 + src/commands/mod.rs | 10 + src/commands/project_settings.rs | 294 ++++++++++++++++++++++ tinyharness-lib/src/config/mod.rs | 264 +++++++++++++++++++ tinyharness-lib/src/context.rs | 385 ++++++++++++++++++++++++---- tinyharness-lib/src/lib.rs | 5 +- 13 files changed, 1765 insertions(+), 197 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/language-detection.md create mode 100644 docs/per-project-settings.md create mode 100644 docs/project-instructions.md create mode 100644 src/commands/project_settings.rs diff --git a/.gitignore b/.gitignore index 98757ce..22c17cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target -/.tinyharness \ No newline at end of file +/.tinyharness +/todo \ No newline at end of file diff --git a/README.md b/README.md index 5124b28..2af4c94 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,26 @@ # TinyHarness -Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, llama.cpp, vLLM), built-in tool calling, and agent skills. +Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, llama.cpp, vLLM), built-in tool calling, agent skills, and customizable system prompts. ![TinyHarness screenshot](screenshots/image.png) ## Features -- **Pluggable Providers**: Ollama, llama.cpp, and vLLM — plus any OpenAI-compatible API. Swap backends without changing application code. -- **Tool System**: Modular tools (`ls`, `read`, `write`, `edit`, `grep`, `glob`, `run`, `web_search`, `web_fetch`, `auto_compact`, `invoke_skill`, `switch_mode`, `question`) that the AI calls to interact with your filesystem, run commands, or search the web. -- **Agent Modes**: Four modes — `casual` (no tools), `planning` (read-only), `agent` (full access), and `research` (web-focused) — to control what the AI can do. -- **Skills**: Pluggable skill modules that inject specialized instructions into the conversation for specific tasks (e.g. PDF processing, image analysis). -- **Context Management**: Token estimation, context-window load warnings at 70%/90%, and cascading conversation compaction via `/compact`. -- **Session Persistence**: JSONL-based sessions with UUIDs, saved in `~/.local/share/tinyharness/sessions/`. -- **Async Streaming**: Built on `tokio` for efficient streaming communication with LLM APIs. -- **Interactive CLI**: Color-coded terminal interface with `/` slash commands for session management, configuration, and tool control. +- **Pluggable Providers**: Ollama, llama.cpp, vLLM, and any OpenAI-compatible API. Swap backends without changing application code. Ollama supports retries with backoff, configurable timeouts, and reasoning/think levels. +- **Tool System**: 15 modular tools (`ls`, `read`, `write`, `edit`, `grep`, `glob`, `run`, `web_search`, `web_fetch`, `auto_compact`, `invoke_skill`, `switch_mode`, `question`, `screenshot`, plus the built-in `read` image loader for multimodal models). +- **Agent Modes**: Four modes — `casual` (web-only), `planning` (read-only + signals), `agent` (full access), and `research` (web-focused) — to control what the AI can do. Modes are backed by customizable `.md` prompt files. +- **Skills**: Pluggable SKILL.md modules discovered from `~/.config/tinyharness/skills/` and `.tinyharness/skills/`. Invokable by the AI via `invoke_skill` or by the user via `/use `. Supports YAML frontmatter with name, description, compatibility, licensing, and model-invocation controls. +- **Context Management**: Token estimation with per-model context window sizes (8K–256K), load warnings at 70%/90% thresholds, and cascading conversation compaction via `/compact`. +- **Session Persistence**: JSONL-based sessions with UUIDs, saved in `~/.local/share/tinyharness/sessions/`. Supports session listing, switching by prefix, renaming, deletion, and auto-save every 5 messages. +- **Async Streaming**: Built on `tokio` for efficient streaming with all providers. Ctrl+C interrupts generation gracefully. +- **Interactive CLI**: Color-coded terminal interface with 22+ slash commands for session management, configuration, file pinning, image attachment, audit logging, and tool control. +- **Customizable Prompts**: System prompts are seeded from hardcoded defaults on first launch to `~/.config/tinyharness/prompts/` and can be freely edited. +- **Command Safety**: Smart auto-accept for safe shell commands with prefix matching, deny lists, redirection stripping, and audit logging. +- **Thinking Display**: Optionally render the model's reasoning chain inline during streaming (toggle via `/showthink`). +- **Image Attachments**: Multimodal support — attach images with `/image` and the `read` tool automatically loads image files for visual models. +- **Project Instructions**: Auto-discovers `TINYHARNESS.md`, `.tinyharness.md`, `AGENTS.md`, or `CLAUDE.md` walking up from the current directory. Configurable via `TINYHARNESS_MD_FILES` env var or settings. Additional project-specific files (e.g. `RULES.md`, `.cursorrules`) can be loaded from `.tinyharness/config.json`. +- **Per-Project Settings**: Layer `.tinyharness/config.json` over global settings. Override safe/denied commands, auto-accept behavior, context limits, preferred mode, and more per project. View merged settings with `/project-settings`. +- **Smart Language Detection**: Auto-detects 17+ languages and build tools (Rust, Zig, Deno, Bun, Swift, Ruby, Elixir, Haskell, Kotlin, .NET, Dart/Flutter, Nix, Node.js, Python, Go, Java, C/C++). Detects monorepos (e.g. "Rust + Node.js"). ## Getting Started @@ -57,7 +64,7 @@ cargo install --path . ```bash tinyharness ``` -Connects to `http://127.0.0.1:11434`. +Connects to `http://127.0.0.1:11434`. Supports configurable timeout, retries, and think/reasoning level. **llama.cpp**: ```bash @@ -71,7 +78,7 @@ tinyharness --vllm ``` Connects to `http://127.0.0.1:8000` by default. -A health check runs on startup to verify the provider is reachable. +A health check runs on startup to verify the provider is reachable. If the saved model is unavailable, the first available model is auto-selected with a warning. **Custom URL** (works with any provider): ```bash @@ -80,6 +87,25 @@ tinyharness --ollama --url http://192.168.1.50:11434 tinyharness --vllm --url http://gpu-server:8000 ``` +**Continue last session**: +```bash +tinyharness --continue +``` +Resumes the most recent session in the current working directory. + +**Non-interactive prompt**: +```bash +tinyharness -p "What does this project do?" +tinyharness --prompt "Explain the architecture" +``` +Sends an initial prompt and then drops into the interactive loop for follow-up turns. + +**Interactive setup**: +```bash +tinyharness --config +``` +Runs a guided setup: pick a provider, enter a URL, save to settings. Exits when done. + ### CLI Arguments | Flag | Description | @@ -87,36 +113,104 @@ tinyharness --vllm --url http://gpu-server:8000 | `-o`, `--ollama` | Use the Ollama provider (default) | | `-l`, `--llama-cpp` | Use the llama.cpp provider | | `-v`, `--vllm` | Use the vLLM provider | -| `-u`, `--url` | Custom base URL for the provider | +| `-u`, `--url ` | Custom base URL for the provider | +| `-c`, `--continue` | Continue the most recent session in the current directory | +| `--config` | Run interactive provider setup, then exit | +| `-p`, `--prompt ` | Start with this message, then drop into interactive mode | ## Agent Modes | Mode | Tools Available | Purpose | |------|----------------|---------| -| **casual** | None | Pure chat, no filesystem access | -| **planning** | `ls`, `read`, `grep`, `glob`, `web_search`, `switch_mode`, `question` | Analyze & plan, then escalate to agent | -| **agent** | All tools | Full development access — code, commands, web | -| **research** | `web_search`, `web_fetch`, `ls`, `read`, `grep`, `glob`, `switch_mode`, `question` | Web research, then escalate for execution | - -Switch modes with `/mode ` or let the AI request escalation via `switch_mode`. +| **casual** | `web_search`, `web_fetch` | Pure chat with web access, no filesystem access | +| **planning** | All read-only tools + signal tools | Analyze & plan, then escalate to agent | +| **agent** | All 15 tools | Full development access — code, commands, web | +| **research** | All read-only tools + signal tools | Web research, then escalate for execution | + +Switch modes with `/mode `, use shortcut aliases (`/plan`, `/agent`, `/research`, `/casual`), or let the AI request escalation via `switch_mode`. + +## Tools + +| Tool | Category | Description | +|------|----------|-------------| +| `ls` | ReadOnly | List directory contents | +| `read` | ReadOnly | Read file content (also loads images for multimodal models) | +| `grep` | ReadOnly | Search with regex across files | +| `glob` | ReadOnly | Find files by glob pattern | +| `web_search` | ReadOnly | Search the web (requires Ollama API key) | +| `web_fetch` | ReadOnly | Fetch a web page by URL | +| `write` | Destructive | Write content to a file | +| `edit` | Destructive | Edit a file by find-and-replace | +| `run` | Destructive | Execute shell commands with timeout & working directory | +| `switch_mode` | Signal | Request a mode switch | +| `question` | Signal | Ask the user a question with options | +| `auto_compact` | Signal | Request conversation compaction | +| `invoke_skill` | Signal | Activate a skill by name | +| `screenshot` | Signal | Request a screenshot from the user | ## Slash Commands +### Session Management +| Command | Description | +|---------|-------------| +| `/sessions` | List all saved sessions (most recent first) | +| `/session ` | Switch to an existing session by ID prefix | +| `/session delete ` | Delete a session with confirmation | +| `/rename ` | Rename the current session | + +### Mode & Model +| Command | Description | +|---------|-------------| +| `/mode [casual\|planning\|agent\|research]` | Show or switch agent mode | +| `/plan`, `/agent`, `/research`, `/casual` | Quick mode switch aliases | +| `/model [name]` | List available models or switch to one | + +### Context & Files +| Command | Description | +| ---------------------- | ----------------------------------------------------------- | +| `/add ` | Pin a file into context | +| `/drop ` | Remove a pinned file from context | +| `/files` | List all pinned files | +| `/dropall` | Remove all pinned files | +| `/refresh` | Re-read pinned files from disk | +| `/context` | Show auto-detected project context | +| `/init` | Generate or update `TINYHARNESS.md` | +| `/project-settings [init]` | Show or initialize per-project settings | + +### Conversation +| Command | Description | +|---------|-------------| +| `/compact [focus]` | Summarize conversation history (with cascading for long sessions) | +| `/image [\|clear\|drop ]` | Attach an image to the next message | + +### Skills +| Command | Description | +|---------|-------------| +| `/skills` | List all available skills | +| `/skill ` | Show a skill's details and content | +| `/use ` | Activate a skill, injecting its instructions | +| `/unload ` | Deactivate a previously loaded skill | + +### Configuration +| Command | Description | +|---------|-------------| +| `/settings [all]` | Show current configuration | +| `/command [list\|add\|rm\|deny\|undeny\|reset\|resetdeny]` | Manage auto-accepted and denied commands | +| `/apikey [key\|clear]` | Set, show, or clear the Ollama API key (needed for `web_search`) | +| `/contextlimit [tokens]` | Show or set the context warning threshold | +| `/autoaccept [on\|off]` | Toggle auto-accept for safe read-only commands | +| `/showthink [on\|off]` | Toggle display of the model's thinking/reasoning chain | +| `/timeout ` | Set Ollama request timeout (default: 5s) | +| `/retries ` | Set Ollama max retries (default: 3) | +| `/think [off\|low\|medium\|high]` | Set Ollama reasoning/think level | +| `/audit [last\|session\|clear]` | View command execution audit log | + +### General | Command | Description | |---------|-------------| | `/help` | Show available commands | -| `/mode [casual\|planning\|agent\|research]` | Switch agent mode | -| `/compact [focus]` | Summarize older messages (cascading for long sessions) | -| `/session [list\|new\|switch\|rename]` | Manage conversation sessions | -| `/settings [summary\|all]` | Show configuration (`all` for full safe-command list) | -| `/command [list\|add\|rm\|deny\|undeny\|reset]` | Manage auto-accepted and denied commands | -| `/model [name]` | List or switch models | -| `/apikey [key]` | Set/show/clear Ollama API key (needed for `web_search`) | -| `/add `, `/drop `, `/files`, `/dropall` | Pin/unpin files into context | -| `/context` | Show auto-detected project context | -| `/init` | Generate or update `TINYHARNESS.md` | | `/clear` | Clear terminal screen | -| `/exit` | Quit | +| `/exit` or `/quit` | Exit TinyHarness | ### Command Management @@ -132,64 +226,156 @@ The `/command` system controls which shell commands are auto-accepted: /command resetdeny # Clear the deny list ``` -Safe commands are shown 3 per row with markers: `·` for defaults, `+` for user-added. -Cross-list warnings alert you if a command appears on both lists. +Safe commands include `cd`, `ls`, `grep`, `cat`, `git status`, `git diff`, `git log`, `cargo tree`, and ~40 more. Shell redirections (`2>&1`, `2>/dev/null`) are stripped before matching. The deny list takes priority — if a command matches both lists, it is denied. The `run` tool can never be auto-accepted even in auto-accept mode. ### Session Compaction -`/compact` summarizes older messages to free context space. Sessions over 60% of the context window use cascading compaction (chunking + merging) to handle very long conversations. +`/compact` summarizes older messages to free context space. Sessions with over 200 intermediate messages use cascading multi-stage compaction (chunking → per-stage summaries → merged final summary). ``` /compact focus on build errors and fixes -Cascading compaction: 580 intermediate messages → 12 stages (50 messages/stage) - Stage 1/12: Compacting messages 1–50... - ... - Merging 12 summaries into final summary... +Cascading compaction: 580 intermediate messages → 3 stages (200 messages/stage) + Stage 1/3: Compacting messages 1–200... + Stage 2/3: Compacting messages 201–400... + Stage 3/3: Compacting messages 401–580... + Merging 3 summaries into final summary... Compacted: 600 messages → 6 messages ``` -On session load, TinyHarness warns if the conversation exceeds 70% or 90% of the context window. +On session load, TinyHarness warns if the conversation exceeds 70% or 90% of the context window, using the last known provider token count from session metadata. ## Skills -Skills are pluggable instruction modules that give the AI specialized knowledge for specific tasks. Invoke a skill with the `invoke_skill` tool, and its instructions are injected into the conversation. +Skills are pluggable instruction modules that give the AI specialized knowledge. Each skill lives in a directory with a `SKILL.md` file. + +**Discovery paths:** +- `~/.config/tinyharness/skills//SKILL.md` (personal — per-user) +- `.tinyharness/skills//SKILL.md` (project-local — per-repo) + +Project skills take precedence over personal skills with the same name. + +**SKILL.md format (YAML frontmatter):** +```markdown +--- +name: rust-dev +description: Rust development best practices and code review guidelines +argument-hint: Rust file or module to review +compatibility: rust +disable-model-invocation: false +license: MIT +metadata: + version: "1.0" +user-invocable: true +--- + +# Rust Development Skill + +Always run `cargo fmt` and `cargo clippy` before suggesting changes... +``` + +All frontmatter fields are optional with sensible defaults. Skills over 10,000 characters are truncated (70% head / 30% tail). Use the `read` tool to view full content. -Skills are discovered from: -- `~/.config/tinyharness/skills/` (user skills) -- `skills/` in the current project directory (project skills) +**Invoking skills:** +- AI: calls `invoke_skill` with the skill name (unless `disable-model-invocation: true`) +- User: `/use ` or `/skill use ` +- Deactivate: `/unload ` + +Both personal and project-local skill directories are scanned at startup and accessible via `/skills`. ## Project Structure -TinyHarness is a Cargo workspace with two crates: +TinyHarness is a Cargo workspace with three crates: -### Library crate (`tinyharness-lib/`) +### Core library (`tinyharness-lib/`) Frontend-agnostic — no terminal I/O, no ANSI codes, no rustyline. ``` tinyharness-lib/src/ -├── lib.rs Re-exports all public types -├── provider/ Provider trait + implementations (ollama, llama_cpp, vllm, openai_compat) -├── config/mod.rs Settings persistence (provider, model, mode, API key, denied commands) -├── mode.rs AgentMode enum with system prompts -├── context.rs WorkspaceContext — auto-detected project metadata + TINYHARNESS.md loading -├── session.rs JSONL session persistence with UUIDs -├── token.rs Token estimation and context window calculations -├── skill.rs Skill discovery and registry -└── tools/ Tool implementations (ls, read, write, edit, grep, glob, run, web_search, etc.) +├── lib.rs Re-exports all public types +├── provider/ Provider trait + implementations +│ ├── mod.rs Provider trait, Message types, ToolDefinition +│ ├── ollama.rs OllamaProvider — raw SSE streaming, retries, Gemini signatures +│ ├── llama_cpp.rs LlamaCppProvider — OpenAI-compatible +│ ├── vllm.rs VllmProvider — OpenAI-compatible +│ └── openai_compat.rs Shared HTTP/SSE logic for OpenAI-compatible backends +├── config/mod.rs Settings persistence (provider, model, mode, API key, safe/denied commands, think type) +├── mode.rs AgentMode enum (casual/planning/agent/research) with customizable .md prompts +├── context.rs WorkspaceContext — auto-detected project metadata + instruction file discovery +├── session.rs JSONL session persistence with UUIDs, auto-save, atomic writes +├── token.rs Token estimation, context window sizes (8K–256K), usage warnings +├── skill.rs Skill discovery, registry, frontmatter parsing, indexing +├── image.rs Image attachment handling (base64 encoding, dimension detection) +├── prompts/ Hardcoded default system prompts (header.md, casual.md, planning.md, agent.md, research.md) +└── tools/ 15 tool implementations + ├── mod.rs ToolManager with mode-based filtering, signal event parsing + ├── tool.rs Tool struct, ToolCategory enum, schema builders, extract_args! macro + ├── ls.rs Directory listing + ├── read.rs File reading with image detection for multimodal models + ├── write.rs File writing + ├── edit.rs Find-and-replace file editing + ├── grep.rs Regex search across files + ├── glob.rs File glob pattern matching + ├── run.rs Shell command execution with timeout + ├── web_search.rs Web search + web fetch (Ollama cloud API) + ├── switch_mode.rs Mode switch signal + ├── question.rs User question signal + ├── auto_compact.rs Compaction signal + ├── invoke_skill.rs Skill activation signal + └── screenshot.rs Screenshot request signal +``` + +### UI library (`tinyharness-ui/`) + +Terminal UI abstractions — reusable output formatting, diff display, confirmation prompts. + +``` +tinyharness-ui/src/ +├── lib.rs Module declarations +├── output.rs Structured output writer (stdout/stderr abstraction) +├── style.rs ANSI color constants (BOLD, CYAN, RED, BG_TOOL, SPINNER_FRAMES, etc.) +└── ui/ + ├── mod.rs Module declarations + ├── confirm.rs Tool call confirmation prompts (Yes/No/Auto-accept) + ├── diff.rs Unified diff display + ├── input.rs CommandHelper for rustyline tab-completion + └── wrap.rs Word-wrapped output with ANSI-aware line filling ``` ### Binary crate (`src/`) -Terminal-UI layer — CLI parsing, interactive prompts, ANSI output. +CLI application — argument parsing, agent loop, slash commands, tool dispatch, safety checking. ``` src/ -├── main.rs Entry point, CLI parsing, provider creation -├── agent.rs Main interaction loop, tool dispatch, confirmation UI, context warnings -├── style.rs ANSI color constants -├── commands/ Slash command handlers (help, mode, compact, session, settings, etc.) -└── ui/ Terminal UI helpers (confirmation prompts, input, diffs) +├── main.rs Entry point, CLI parsing (clap), provider creation, session init +├── agent/ +│ ├── mod.rs Main interaction loop, streaming response display, spinner, thinking chain +│ ├── tools.rs Tool call dispatch, confirmation, generic execution, signal handlers +│ ├── safety.rs Shell command safety checker (prefix + deny list + redirection stripping) +│ ├── setup.rs Interactive provider setup (--config), URL prompting +│ ├── display.rs Context status formatting, args summaries, listing result summaries +│ └── input.rs Multi-line input reading with continuation prompt +└── commands/ + ├── mod.rs CommandRegistry, CommandDispatcher, build_registry() — 22+ commands + ├── registry.rs CommandContext, CommandResult, AsyncCommand trait, async_command! macro + ├── apikey.rs /apikey — Ollama API key management + ├── audit.rs /audit — command execution audit log + ├── clear.rs /clear — terminal clear + ├── command.rs /command — safe/denied command management + ├── compact.rs /compact — cascading conversation summarization + ├── config_settings.rs /contextlimit, /autoaccept, /showthink, /timeout, /retries, /think + ├── context.rs /context — workspace context display + ├── exit.rs /exit — graceful shutdown + ├── files.rs /add, /drop, /files, /dropall, /refresh — file pinning + ├── help.rs /help — command listing + ├── image.rs /image — image attachment management + ├── init.rs /init — TINYHARNESS.md generation + ├── mode.rs /mode — mode switching + ├── models.rs /model — model listing and selection + ├── sessions.rs /sessions, /session — session listing, switching, deletion + ├── settings.rs /settings — configuration display + └── skill.rs /skills, /skill, /use, /unload — skill management ``` ## Development @@ -200,6 +386,9 @@ src/ cargo build # Debug build cargo build --release # Release build cargo test --workspace # Run all tests +cargo test -p tinyharness-lib # Library tests only +cargo test -p TinyHarness # Binary tests only +cargo test # Run a specific test cargo clippy --workspace -- -D warnings # Lint cargo fmt --all -- --check # Format check cargo fmt --all # Auto-format @@ -212,7 +401,7 @@ After making changes, run: 1. `cargo fmt --all` — ensure formatting is clean 2. `cargo clippy --workspace -- -D warnings` — no clippy warnings 3. `cargo test --workspace` — all tests pass -4. `cargo build` — clean release build succeeds +4. `cargo build` — clean debug build succeeds ## AI Usage & Security @@ -222,7 +411,7 @@ TinyHarness grants LLMs the ability to interact with your filesystem through too - **Non-determinism**: LLMs may hallucinate or produce incorrect tool arguments. Always review proposed actions. - **Accountability**: You assume full responsibility for all operations performed by the AI. Ensure you have backups. -The `run` tool can never be auto-accepted — even with auto-accept mode — unlike `write` and `edit`. +The `run` tool can never be auto-accepted — even with auto-accept mode (`a`) — unlike `write` and `edit`. Safe commands (e.g., `ls`, `git status`) can be auto-accepted when `/autoaccept` is on. ## Project Instructions (TINYHARNESS.md) @@ -230,7 +419,9 @@ TinyHarness automatically discovers project instruction files, similar to `CLAUD ### Discovery -Searches from the current directory up to the filesystem root (first match wins): +Searches from the current directory up to the filesystem root (first match wins). The discovery order is configurable via settings or an environment variable. + +**Default priority order:** | Priority | File | Notes | |---|---|---| @@ -239,7 +430,30 @@ Searches from the current directory up to the filesystem root (first match wins) | 3 | `AGENTS.md` | Industry standard (60K+ repos) | | 4 | `CLAUDE.md` | Claude Code compatibility | -Files over 20,000 characters are truncated (70% head / 20% tail with a marker). +**Customizing the discovery list:** + +```bash +# Env var override (highest priority): +export TINYHARNESS_MD_FILES="CLAUDE.md,TEAM_RULES.md" +tinyharness +``` + +Or in `~/.config/tinyharness/settings.json`: +```json +{ + "project_md_files": ["CLAUDE.md", ".cursorrules", "TEAM_RULES.md"] +} +``` + +**Additional per-project files** can be loaded via `.tinyharness/config.json`: +```json +{ + "project_md_files": ["RULES.md", "DEPLOYMENT.md"] +} +``` +These are appended after the main instruction file in the AI's context. + +Files over 20,000 characters are truncated (70% head / 30% tail with a marker). ### Generating with `/init` @@ -264,3 +478,71 @@ A good instruction file should contain what you'd tell a new teammate: - **Verification steps** — what to run after making changes Keep it concise (under 200 lines). For detailed reference, the AI can use `read` on specific files. + +## Per-Project Settings + +TinyHarness supports per-project configuration via `.tinyharness/config.json`, discovered by walking up from the current working directory (same algorithm as instruction file discovery). Settings are layered: + +``` +~/.config/tinyharness/settings.json (global) + → .tinyharness/config.json (project override) + → CLI flags (highest priority) +``` + +### Supported fields + +```json +{ + "safe_command_prefixes": ["python -m pytest", "npm run lint"], + "denied_command_prefixes": ["git push --force"], + "auto_accept_safe_commands": false, + "context_limit": 32768, + "project_md_files": ["RULES.md", ".cursorrules"], + "preferred_mode": "agent" +} +``` + +- **`safe_command_prefixes`**: Extends (not replaces) the global safe list +- **`denied_command_prefixes`**: Replaces the global deny list entirely +- **`auto_accept_safe_commands`**: Overrides global toggle +- **`context_limit`**: Overrides context warning threshold +- **`project_md_files`**: Additional instruction files loaded after the main one +- **`preferred_mode`**: Default agent mode for this project + +### Managing project settings + +``` +/project-settings # Show merged settings with source annotations +/project-settings init # Generate a commented .tinyharness/config.json template +``` + +Sources are displayed as `(project)`, `(global)`, or `(default)`, making it clear where each value originates. + +## Language Detection + +TinyHarness auto-detects your project's language and build tool from marker files in the workspace root. Detection supports 17+ languages and can identify multiple languages in monorepos (e.g. "Rust + Node.js"). + +### Supported languages + +| Language | Detection files | +|----------|----------------| +| Rust | `Cargo.toml` | +| Zig | `build.zig`, `build.zig.zon` | +| Deno | `deno.json`, `deno.jsonc` | +| Bun | `bun.lockb`, `bun.lock` | +| Swift | `Package.swift` | +| Ruby | `Gemfile` | +| Elixir | `mix.exs` | +| Haskell | `stack.yaml`, `*.cabal` | +| Kotlin | `build.gradle.kts`, `settings.gradle.kts` | +| .NET | `*.csproj`, `*.sln` | +| Dart/Flutter | `pubspec.yaml` | +| Nix | `flake.nix`, `default.nix` | +| Node.js | `package.json` | +| Python | `pyproject.toml`, `setup.py`, `setup.cfg`, `requirements.txt` | +| Go | `go.mod` | +| Java (Gradle) | `build.gradle` | +| Java (Maven) | `pom.xml` | +| C/C++ (CMake) | `CMakeLists.txt` | + +Unknown project types fall back to `Makefile` or `Justfile` hints when available. Detected commands are injected into the system prompt so the AI knows how to build and test your project without being told. diff --git a/TINYHARNESS.md b/TINYHARNESS.md index b011d35..88ff827 100644 --- a/TINYHARNESS.md +++ b/TINYHARNESS.md @@ -8,111 +8,93 @@ Lightweight AI assistant framework in Rust with pluggable LLM providers (Ollama, - Test: `cargo test --workspace` - Lint: `cargo clippy --workspace -- -D warnings` - Format check: `cargo fmt --all -- --check` -- Formating: `cargo fmt --all` +- Formatting: `cargo fmt --all` - Install: `make install` (builds release + copies to `~/.local/bin`) - Run: `cargo run` (Ollama default) or `cargo run -- --llama-cpp` / `--vllm` ## Workspace Structure -The project uses a Cargo workspace with two crates: - -- **`tinyharness-lib`** — Core library crate (frontend-agnostic, no terminal I/O) -- **`tinyharness`** — Binary CLI crate (depends on `tinyharness-lib`) - -### Library crate (`tinyharness-lib/`) - -``` -tinyharness-lib/src/ -├── lib.rs Re-exports all public types -├── provider/ Provider trait + implementations (ollama, llama_cpp, vllm, openai_compat) -├── config/mod.rs Settings persistence (provider, model, mode, API key, denied commands) -├── mode.rs AgentMode enum (casual/planning/agent/research) with system prompts -├── context.rs WorkspaceContext — auto-detected project metadata + TINYHARNESS.md loading -├── session.rs JSONL session persistence with UUIDs -├── token.rs Token estimation and context window calculations -└── tools/ Tool implementations (ls, read, write, edit, grep, run, glob, web_search, etc.) -``` - -### Binary crate (`src/`) - -``` -src/ -├── main.rs Entry point, CLI parsing, provider creation -├── agent.rs Main interaction loop, tool call dispatch, confirmation UI, context load warning -├── style.rs ANSI color constants -├── commands/ Slash command handlers -│ ├── mod.rs CommandDispatcher — parse and dispatch /commands -│ ├── command.rs /command — manage safe/denied commands -│ ├── compact.rs /compact — cascading summarization for long sessions -│ ├── settings.rs /settings — configuration display (supports `all` variant) -│ └── ... Other commands (session, model, apikey, etc.) -└── ui/ Terminal UI helpers (confirmation prompts, input, diffs) -``` +Three crates in a Cargo workspace: + +- **`tinyharness-lib`** — Core library: providers, tools, sessions, context, skills, tokens. No terminal I/O. +- **`tinyharness-ui`** — UI library: ANSI output, confirmation prompts, diff display, command input. +- **`TinyHarness`** — Binary CLI: agent loop, slash commands, tool dispatch, setup. + +### Key `tinyharness-lib` modules + +- `provider/` — Provider trait, `OllamaProvider` (raw SSE, Gemini signatures), `LlamaCppProvider`/`VllmProvider` (shared OpenAI-compat internals) +- `tools/` — 15 tools (ls, read, write, edit, grep, glob, run, web_search, web_fetch, switch_mode, question, auto_compact, invoke_skill, screenshot), registration in `register_defaults()`, mode-based filtering +- `session.rs` — JSONL persistence, auto-save every 5 messages +- `context.rs` — Workspace metadata + instruction file discovery (TINYHARNESS.md → .tinyharness.md → AGENTS.md → CLAUDE.md) +- `skill.rs` — Skill discovery from `~/.config/tinyharness/skills/` and `.tinyharness/skills/` +- `mode.rs` — Agent modes with `.md` system prompts +- `config/mod.rs` — SettingsStore, ProviderKind, OllamaThinkType + +### Binary crate structure + +- `src/agent/` — Agent loop, tool execution, safety checks, display, multi-line input, provider setup +- `src/commands/` — 22+ slash commands (mode, model, sessions, compact, init, context, files, image, skill, settings, help, etc.), `CommandRegistry` and `async_command!` macro ## Code Conventions - Rust edition 2024 -- Core logic lives in `tinyharness-lib` — no terminal I/O, no ANSI codes, no rustyline -- CLI-specific code (terminal output, interactive prompts) stays in the binary crate -- Binary crate imports from `tinyharness_lib` for provider, config, tools, session, etc. -- Tools registered in `tinyharness-lib/src/tools/mod.rs` via `ToolManager::register_defaults()` -- Tool definitions live in `tinyharness-lib/src/tools/.rs` — each exposes a `*_tool_entry()` function returning a `Tool` -- Providers implement the `Provider` trait in `tinyharness-lib/src/provider/mod.rs` -- Settings persisted as JSON in `~/.config/tinyharness/settings.json` -- Sessions stored as JSONL in `~/.local/share/tinyharness/sessions/` +- Core logic (`tinyharness-lib`) must not use terminal I/O, ANSI codes, or rustyline - Use `serde` + `schemars` for serialization and tool schema generation -- Minimize dependencies — prefer `std` and lightweight crates over heavy ones; avoid adding new deps when the same can be achieved with what's already in the workspace -- Prefer manual `Pin>` over `async-trait` to keep the dependency tree small -- Error handling: `Result` for user-facing errors, `Result>` for internal +- Prefer `Pin>` over `async-trait` to keep dependency tree small +- Error handling: `Result` for user-facing, `Result>` for internal +- Minimize dependencies; avoid adding new crates when existing ones suffice +- `#[macro_export]` macros (`extract_args!`) live at `tinyharness_lib` root, not inside `tools` +- Tool categories: `ReadOnly` (auto-executed), `Destructive` (requires confirmation), `Signal` (handled specially by agent loop) ## Architecture -Key flow: `main.rs` → `create_provider()` → `run_agent_loop()` (in `agent.rs`) → streams responses from provider → dispatches tool calls → confirms with user for sensitive tools (write/edit/run/switch_mode) → appends results. +1. `main.rs` → parse CLI, create provider, health check, auto-select model, collect workspace context, initialize prompts, register tools, load/create session, build command registry, enter `run_agent_loop()` +2. Agent loop: read input (or `--prompt`), dispatch slash commands, send messages to provider, stream response, handle tool calls +3. Signal tools (`switch_mode`, `question`, `auto_compact`, `invoke_skill`) bypass generic tool execution and are handled inline +4. Destructive tools prompt for confirmation (except `run` which cannot be auto-accepted); ReadOnly tools run immediately +5. Tool results are batched into a single `Role::Tool` message, appended to conversation +6. Auto-save session every 5 messages; flush on mode switch, session switch, exit ## Agent Modes -| Mode | Tools | Purpose | -|------|-------|---------| -| casual | None | Pure chat, no filesystem access | -| planning | read-only (ls, read, grep, glob, web_search) + switch_mode, question | Analyze & plan, then escalate to agent | -| agent | All tools | Full development access | -| research | read-only + web_search, web_fetch + switch_mode, question | Web research, then escalate | +| Mode | Tools | Purpose | +|----------|-------|---------| +| casual | web_search, web_fetch | Chat with web access | +| planning | ReadOnly + Signal tools | Analyze, plan, escalate to agent | +| agent | All 15 tools | Full development access | +| research | Same as planning (research-focused prompt) | Web research, then escalate | ## Testing -- Framework: built-in `#[test]` + `cargo test --workspace` -- Use `tempfile` crate in dev-dependencies for test isolation — tool tests must not write to real filesystem +- `cargo test --workspace` runs all tests +- `tinyharness-lib` has good coverage; `tinyharness-ui` and binary crate have limited coverage (see `todo/01-testing-gaps.md`) +- Use `tempfile` for test isolation; tool tests must not touch the real filesystem - Run specific test: `cargo test ` -- Library tests: `cargo test -p tinyharness-lib` -- Binary tests: `cargo test -p TinyHarness` - -## Important Rules - -- Never modify `src/style.rs` ANSI codes without checking terminal compatibility -- `switch_mode` and `question` tools are handled specially in `agent.rs` — they bypass the generic tool execution path -- Confirmation for `run` tool cannot be auto-accepted even with 'a' (auto-accept) — only write/edit can -- System prompt is refreshed after mode switches, file pinning (/add, /drop), and /refresh -- Session auto-saves every 5 messages -- When adding new modules to `tinyharness-lib`, update `lib.rs` re-exports -- Command safety: `is_safe_command()` in `src/agent.rs` checks prefixes and deny list; shell redirections (`2>&1`, `>/dev/null`) are stripped before matching -- Context management: `/compact` uses cascading for sessions >60% of context window; load-time warnings at 70%/90% thresholds - -## Known Gotchas - -- All providers now run a health check on startup; Ollama is included -- If the saved model is unavailable, auto-select picks the first available model with a warning -- `rustyline` history stored in `~/.local/share/tinyharness/history.txt` -- Web search requires an Ollama API key set via `/apikey` -- `#[macro_export]` macros (`define_tool!`, `extract_args!`, `register_tools!`) are exported at the crate root of `tinyharness_lib`, not in the `tools` module -- Shell commands with redirections (`2>&1`, `>/dev/null`) are auto-accepted if the base command is safe — redirections are stripped before prefix matching -- Cascading compaction may produce less coherent summaries than single-pass (trade-off for handling very long sessions) -- Context load warnings are estimates based on token counting; actual usage may vary by model -- Ctrl+C interrupts the current LLM generation; a second Ctrl+C exits the process immediately +- Run per crate: `cargo test -p tinyharness-lib`, `cargo test -p TinyHarness`, `cargo test -p tinyharness-ui` + +## Important Rules & Gotchas + +- **Provider startup**: All providers run a health check (Ollama calls `list_local_models`). If saved model is unavailable, auto-select picks the first available with a warning. +- **Ollama specifics**: Own raw SSE parser (not ollama-rs streaming) to handle native and OpenAI-compatible formats; captures Gemini `thought_signature` from tool responses and re-injects them; fixes serialization quirks (lowercases tool type, injects `name` in tool results). +- **System prompts**: Assembled from `header.md` + `.md` for Agent/Planning/Research; Casual is self-contained. Prompts are refreshed on mode switch, file pinning changes, skill activation, and `/refresh`. +- **Command safety** (`src/agent/safety.rs`): Prefix matching with word boundaries, deny list priority, strips redirections before matching; rejects `;`, `&`, `|`, `$()`, backticks, newlines. Redirections like `2>&1` are auto-accepted if base command is safe. +- **Confirmation**: `run` tool cannot be auto-accepted even with 'a' (auto-accept mode); only `write` and `edit` can. +- **Compaction**: `/compact` uses single-pass for ≤200 intermediate messages, cascading (chunk+merge) for larger sessions. +- **Context warnings**: Load warnings at 70%/90% thresholds based on last known token count (estimation). +- **Session files**: JSONL (metadata line first, then message lines); malformed lines silently skipped on load; stored in `~/.local/share/tinyharness/sessions/`. +- **Web tools**: `web_search` and `web_fetch` use `https://ollama.com/api/web_search` and require an Ollama API key set via `/apikey`. +- **Ctrl+C**: Interrupts current LLM generation; second Ctrl+C exits immediately. +- **Configuration**: Set via `--config` (interactive setup), stored as JSON in `~/.config/tinyharness/settings.json`. Persistent prompts are seeded from embedded defaults into `~/.config/tinyharness/prompts/`. +- **Image attachments**: Base64 data URIs, used by multimodal models; set via `/image`. +- **`async_command!` macro**: Registers commands that need `provider.lock().await`. +- **`CommandResult` variants**: `SwitchSession`, `RenameSession`, `Init`, `SkillUse`, `SkillUnload` carry data back to the agent loop. +- **`CommandContext`** holds shared mutable state: provider, mode, file context, session ID, skill registry, active skills, pending images, thinking toggle, compaction token usage. +- **`extract_args!` macro** exported at `tinyharness_lib` root, not in `tools`. ## Verification Steps -After making changes, run: -1. `cargo fmt --all` — ensure formatting is clean -2. `cargo clippy --workspace -- -D warnings` — no clippy warnings -3. `cargo test --workspace` — all tests pass -4. `cargo build` — clean release build succeeds \ No newline at end of file +After making changes, run in order: +1. `cargo fmt --all` +2. `cargo clippy --workspace -- -D warnings` +3. `cargo test --workspace` +4. `cargo build` \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f367a13 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# TinyHarness Documentation + +## User Guides + +- [Per-Project Settings](per-project-settings.md) — `.tinyharness/config.json` reference +- [Language Detection](language-detection.md) — how project types are auto-detected +- [Project Instructions](project-instructions.md) — configuring `TINYHARNESS.md` discovery + +## Files and Directories + +TinyHarness stores data in standard XDG paths: + +``` +~/.config/tinyharness/ +├── settings.json Global settings +├── prompts/ Customizable system prompt .md files +│ ├── header.md Shared header (agent, planning, research modes) +│ ├── casual.md Casual mode prompt +│ ├── planning.md Planning mode prompt +│ ├── agent.md Agent mode prompt +│ └── research.md Research mode prompt +└── skills/ Personal skills + └── / + └── SKILL.md + +~/.local/share/tinyharness/ +├── sessions/ JSONL session files +│ └── .jsonl +├── history.txt Command history (rustyline) +└── backups/ File backups (when /undo is implemented) + └── / + +/.tinyharness/ +├── config.json Per-project settings +└── skills/ Project-local skills + └── / + └── SKILL.md +``` diff --git a/docs/language-detection.md b/docs/language-detection.md new file mode 100644 index 0000000..f1ee2a9 --- /dev/null +++ b/docs/language-detection.md @@ -0,0 +1,100 @@ +# Language Detection + +TinyHarness auto-detects your project's language and build tool when it starts. This information is injected into the system prompt so the AI knows how to build, test, and work with your project without being told. + +## How It Works + +On startup, `WorkspaceContext::collect()` scans the project root for marker files. Each language has one or more signature files — if any match, that language is detected. + +### Detection Algorithm + +1. Scan for primary markers (Cargo.toml, package.json, go.mod, etc.) +2. Glob-scan for secondary markers (*.csproj, *.sln, *.cabal) +3. If nothing matches, check for Makefile or Justfile as fallback hints +4. Multiple matches → monorepo detection (e.g. "Rust + Node.js") + +## Supported Languages + +| Language | Marker Files | Build Command | Test Command | +|----------|-------------|---------------|--------------| +| Rust | `Cargo.toml` | `cargo build` | `cargo test` | +| Zig | `build.zig`, `build.zig.zon` | `zig build` | `zig build test` | +| Deno | `deno.json`, `deno.jsonc` | `deno task build` | `deno test` | +| Bun | `bun.lockb`, `bun.lock` | `bun run build` | `bun test` | +| Swift | `Package.swift` | `swift build` | `swift test` | +| Ruby | `Gemfile` | `bundle install` | `bundle exec rspec` | +| Elixir | `mix.exs` | `mix compile` | `mix test` | +| Haskell | `stack.yaml`, `*.cabal` | `stack build` | `stack test` | +| Kotlin | `build.gradle.kts`, `settings.gradle.kts` | `gradle build` | `gradle test` | +| .NET | `*.csproj`, `*.sln` | `dotnet build` | `dotnet test` | +| Dart/Flutter | `pubspec.yaml` | `dart run build` | `dart test` | +| Nix | `flake.nix`, `default.nix` | `nix build` | `nix flake check` | +| Node.js | `package.json` | `npm run build` | `npm test` | +| Python | `pyproject.toml`, `setup.py`, `setup.cfg`, `requirements.txt` | `pip install -e .` | `pytest` | +| Go | `go.mod` | `go build ./...` | `go test ./...` | +| Java (Gradle) | `build.gradle` | `gradle build` | `gradle test` | +| Java (Maven) | `pom.xml` | `mvn compile` | `mvn test` | +| C/C++ (CMake) | `CMakeLists.txt` | `cmake --build build` | `ctest` | + +## Fallback Detection + +When no language marker is found, TinyHarness checks for: + +| File | Detection | Build | Test | +|------|-----------|-------|------| +| `Makefile` | "Unknown (has Makefile)" | `make` | `make test` | +| `Justfile` | "Unknown (has Justfile)" | `just build` | `just test` | + +## Monorepo Detection + +When multiple language markers are found in the same directory, TinyHarness joins them: + +``` +Cargo.toml + package.json → "Rust + Node.js" +``` + +The build and test commands come from the first detected language. + +## Viewing Detected Context + +Use `/context` to see what TinyHarness detected: + +``` +Workspace Context: + Project: TinyHarness (Rust) + Root: /home/user/projects/TinyHarness + Git repo: yes + Build: cargo build + Test: cargo test + +Structure: + src/ (agent/, commands/) + tinyharness-lib/ (src/, Cargo.toml, prompts/) + ... +``` + +The detected type also appears in the system prompt: + +``` +You are operating inside a Rust project called "TinyHarness". +``` + +## What the AI Sees + +The detection results are injected into the system prompt at startup: + +``` +You are operating inside a Rust project called "TinyHarness". +Workspace root: /home/user/projects/TinyHarness +This is a git repository. + +Project structure: + src/ + tinyharness-lib/ + ... + +Build command: cargo build +Test command: cargo test +``` + +This means the AI knows how to build and test your project from the first message — no need to explain it. diff --git a/docs/per-project-settings.md b/docs/per-project-settings.md new file mode 100644 index 0000000..80f603e --- /dev/null +++ b/docs/per-project-settings.md @@ -0,0 +1,170 @@ +# Per-Project Settings + +TinyHarness supports per-project configuration via `.tinyharness/config.json`. This lets you customize behavior for a specific project without affecting your global setup. + +## How It Works + +Settings are layered in this priority order: + +``` +CLI flags (highest) + → .tinyharness/config.json (project) + → ~/.config/tinyharness/settings.json (global) + → hardcoded defaults (lowest) +``` + +The `.tinyharness/config.json` file is discovered by walking up from the current working directory — the same algorithm used to find `TINYHARNESS.md`. The first match wins (closest to CWD). + +## Creating a Project Config + +Run the scaffolding command: + +``` +/project-settings init +``` + +This generates `.tinyharness/config.json` in your project root with all available fields commented out: + +```json +{ + "auto_accept_safe_commands": true +} +``` + +Uncomment and change fields as needed. Run `/project-settings` to see the effective merged settings. + +## Available Fields + +### `safe_command_prefixes` + +Extends the global safe command list. Useful for adding project-specific commands that should be auto-accepted. + +```json +{ + "safe_command_prefixes": ["pytest", "npm run lint", "just build"] +} +``` + +This is additive — your project commands are merged with the global safe list, not replacing it. + +### `denied_command_prefixes` + +Always blocks these commands from auto-accept, even if they'd match a safe prefix. This replaces the global deny list entirely for this project. + +```json +{ + "denied_command_prefixes": ["git push --force", "rm -rf"] +} +``` + +### `auto_accept_safe_commands` + +Toggle whether safe commands are auto-accepted without confirmation. + +```json +{ + "auto_accept_safe_commands": false +} +``` + +Set to `false` on sensitive projects to require manual approval for every command. + +### `context_limit` + +Override the context window warning threshold (in tokens). The default is model-dependent. Use this if your project requires a specific context size. + +```json +{ + "context_limit": 32768 +} +``` + +Set to `null` to use the model default. + +### `project_md_files` + +Additional instruction files to load into the AI's context. These are loaded after the main instruction file (TINYHARNESS.md or equivalent). + +```json +{ + "project_md_files": ["RULES.md", "DEPLOYMENT.md", ".cursorrules"] +} +``` + +Each file must exist in the project root. Files are truncated at 20,000 characters. + +### `preferred_mode` + +Set the default agent mode when starting a session in this project. + +```json +{ + "preferred_mode": "agent" +} +``` + +Valid values: `casual`, `planning`, `agent`, `research`. + +## Viewing Merged Settings + +``` +/project-settings +``` + +Shows all effective settings with source annotations: + +``` +╭─ Project Settings (.tinyharness/config.json) ─╮ +│ Mode: agent (project) +│ Auto-Accept: enabled (default) +│ Ctx Limit: 32768 tokens (project) +│ Safe Cmds: 48 configured (default) +│ Denied: 3 denied (project) +│ Extra MD: RULES.md, DEPLOYMENT.md (project) +╰─────────────────────────────────────────────────╯ + +Legend: (project) = from .tinyharness/config.json + (global) = from ~/.config/tinyharness/settings.json + (default) = hardcoded default +``` + +## Common Use Cases + +### Strict project with manual approvals + +```json +{ + "auto_accept_safe_commands": false, + "denied_command_prefixes": ["rm", "mv", "chmod", "chown", "sudo"] +} +``` + +### Python project with custom test commands + +```json +{ + "safe_command_prefixes": ["python -m pytest", "pip install -e '.[dev]'", "ruff check"], + "preferred_mode": "agent" +} +``` + +### Monorepo with extra instruction files + +```json +{ + "project_md_files": ["RULES.md", "frontend-GUIDELINES.md", "backend-CONVENTIONS.md"] +} +``` + +## Files on Disk + +``` +my-project/ +├── .tinyharness/ +│ ├── config.json # Per-project settings (this file) +│ └── skills/ # Project-local skills +│ └── my-skill/ +│ └── SKILL.md +├── TINYHARNESS.md # Main project instructions +└── src/ +``` diff --git a/docs/project-instructions.md b/docs/project-instructions.md new file mode 100644 index 0000000..89243be --- /dev/null +++ b/docs/project-instructions.md @@ -0,0 +1,133 @@ +# Configurable Project Instructions + +TinyHarness loads project instruction files (like `TINYHARNESS.md`) to give the AI persistent context about your project. The discovery process is fully configurable. + +## Default Behavior + +By default, TinyHarness searches for these files in priority order, walking up from the current directory to the filesystem root: + +| Priority | File | Purpose | +|----------|------|---------| +| 1 | `TINYHARNESS.md` | TinyHarness-native instruction file | +| 2 | `.tinyharness.md` | Hidden variant | +| 3 | `AGENTS.md` | Industry standard, compatible with other AI tools | +| 4 | `CLAUDE.md` | Claude Code compatibility | + +The first file found wins. If `TINYHARNESS.md` exists in your project root, it's used and the search stops — `AGENTS.md` and `CLAUDE.md` are ignored. + +## Customizing the File List + +You can change which files are searched and in what order. + +### Via Environment Variable + +```bash +# Replace the default list entirely: +export TINYHARNESS_MD_FILES="CLAUDE.md,TEAM_RULES.md" +tinyharness +``` + +The env var takes highest priority. When set, it completely replaces the default list. + +### Via Global Settings + +In `~/.config/tinyharness/settings.json`: + +```json +{ + "project_md_files": ["CLAUDE.md", ".cursorrules", "TEAM_RULES.md"] +} +``` + +This takes effect when the env var is not set. + +### Priority Chain + +``` +TINYHARNESS_MD_FILES env var (highest) + → settings.json project_md_files + → hardcoded defaults (lowest) +``` + +## Additional Per-Project Files + +Beyond the main instruction file, you can load additional files into the AI's context via `.tinyharness/config.json`: + +```json +{ + "project_md_files": ["RULES.md", "DEPLOYMENT.md"] +} +``` + +These are loaded after the main instruction file and appear as separate sections in the system prompt: + +``` +--- +# Project Instructions (from TINYHARNESS.md) +...main instructions... + +--- +# Additional Instructions (from RULES.md) +...extra rules... + +--- +# Additional Instructions (from DEPLOYMENT.md) +...deployment notes... +``` + +Each file is truncated at 20,000 characters (70% head / 30% tail). + +## Generating Instruction Files + +Use `/init` to have the AI analyze your project and generate a `TINYHARNESS.md`: + +``` +[agent]> /init + Generating project instruction file... + ✦ Created /path/to/TINYHARNESS.md (45 lines) +``` + +If one already exists, `/init` updates it — preserving accurate sections, removing outdated ones, and adding what's missing. + +## Use Cases + +### Team with shared rules + +Multiple team members use TinyHarness. The team has `TEAM_RULES.md` with shared conventions. + +```bash +# Everyone sets: +export TINYHARNESS_MD_FILES="TEAM_RULES.md,TINYHARNESS.md,AGENTS.md" +``` + +Now `TEAM_RULES.md` is always loaded first. + +### Claude Code compatibility + +You already have a `CLAUDE.md` and want TinyHarness to use it as the primary file: + +```bash +export TINYHARNESS_MD_FILES="CLAUDE.md" +``` + +### Suppress AGENTS.md + +Your project has a generic `AGENTS.md` for other tools, but you don't want TinyHarness to pick it up: + +```json +{ + "project_md_files": ["TINYHARNESS.md", ".tinyharness.md"] +} +``` + +By omitting `AGENTS.md` from the list, it's never discovered. + +### Multiple instruction files by concern + +```json +{ + "project_md_files": ["CODING-STYLE.md", "ARCHITECTURE.md", "DEPLOY.md"] +} +``` + +Each concern in its own file, all loaded into context. diff --git a/src/commands/init.rs b/src/commands/init.rs index 545ed34..b355a9d 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -389,6 +389,7 @@ mod tests { build_command: "cargo build".to_string(), test_command: "cargo test".to_string(), project_md: None, + additional_project_mds: Vec::new(), }; let prompt = build_init_prompt(&ctx, None); @@ -413,6 +414,7 @@ mod tests { build_command: "cargo build".to_string(), test_command: "cargo test".to_string(), project_md: None, + additional_project_mds: Vec::new(), }; let existing = "# Old Rules\nUse cargo."; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index bebdcf5..eed06ef 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod image; pub mod init; pub mod mode; pub mod models; +pub mod project_settings; pub mod registry; pub mod sessions; pub mod settings; @@ -134,6 +135,15 @@ pub fn build_registry() -> CommandRegistry { |arg, ctx, _msg| crate::commands::config_settings::execute_autoaccept(&mut ctx.output, arg), ); + // ── Per-project settings ────────────────────────────────────────────── + + reg.register_sync_with_usage( + "/project-settings", + "Show or initialize per-project settings (.tinyharness/config.json)", + "/project-settings [init]", + |arg, ctx, _msg| crate::commands::project_settings::execute(&mut ctx.output, arg), + ); + reg.register_sync_with_usage( "/showthink", "Show or toggle display of the model's thinking/reasoning chain during responses", diff --git a/src/commands/project_settings.rs b/src/commands/project_settings.rs new file mode 100644 index 0000000..f5c4987 --- /dev/null +++ b/src/commands/project_settings.rs @@ -0,0 +1,294 @@ +//! `/project-settings` — view and init per-project settings. +//! +//! Per-project settings live in `.tinyharness/config.json` (discovered by walking +//! up from CWD) and override/merge with the global `~/.config/tinyharness/settings.json`. + +use std::io::Write; + +use tinyharness_lib::config::{ + SettingSource, discover_project_settings, load_merged_settings, load_settings, +}; +use tinyharness_ui::output::Output; +use tinyharness_ui::style::*; + +use crate::commands::registry::CommandResult; + +pub fn execute(out: &mut Output, arg: Option<&str>) -> Result { + match arg { + Some("init") => execute_init(out), + _ => execute_show(out), + } + Ok(CommandResult::Ok) +} + +/// Show merged effective settings with source annotations. +fn execute_show(out: &mut Output) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let project_config = discover_project_settings(&cwd); + + let has_project = match &project_config { + Some(Ok(_)) => true, + _ => false, + }; + + let (_, _, merged) = load_merged_settings(); + + let _ = writeln!(out); + if has_project { + let _ = writeln!( + out, + "{BOLD}╭─ Project Settings (.tinyharness/config.json) ─╮{RESET}", + ); + } else { + let _ = writeln!( + out, + "{BOLD}╭─ Project Settings (no .tinyharness/config.json) ─╮{RESET}", + ); + let _ = writeln!( + out, + "{BOLD}│{RESET} {GRAY}Use {BOLD}/project-settings init{RESET}{GRAY} to create one.{RESET}", + ); + } + + // ── Preferred mode ── + let (mode_str, mode_src) = format_setting( + &merged.preferred_mode.to_string(), + merged.preferred_mode_source, + Some("default: casual"), + ); + let _ = writeln!(out, "{BOLD}│{RESET} Mode: {mode_str} {mode_src}"); + + // ── Auto-accept ── + let aa_val = if merged.auto_accept_safe_commands { + "enabled" + } else { + "disabled" + }; + let (aa_str, aa_src) = format_setting(aa_val, merged.auto_accept_source, None); + let _ = writeln!(out, "{BOLD}│{RESET} Auto-Accept: {aa_str} {aa_src}"); + + // ── Context limit ── + let ctx_val = merged + .context_limit + .map(|n| format!("{} tokens", n)) + .unwrap_or_else(|| "auto".to_string()); + let (ctx_str, ctx_src) = format_setting(&ctx_val, merged.context_limit_source, None); + let _ = writeln!(out, "{BOLD}│{RESET} Ctx Limit: {ctx_str} {ctx_src}"); + + // ── Safe commands ── + let safe_count = merged.safe_commands.len(); + let (safe_str, safe_src) = format_setting( + &format!("{} configured", safe_count), + merged.safe_commands_source, + None, + ); + let _ = writeln!(out, "{BOLD}│{RESET} Safe Cmds: {safe_str} {safe_src}"); + + // ── Denied commands ── + if !merged.denied_commands.is_empty() { + let denied_count = merged.denied_commands.len(); + let (denied_str, denied_src) = format_setting( + &format!("{} denied", denied_count), + merged.denied_commands_source, + None, + ); + let _ = writeln!(out, "{BOLD}│{RESET} Denied: {denied_str} {denied_src}"); + } + + // ── Additional MD files ── + if !merged.project_md_files.is_empty() { + let files_str = merged.project_md_files.join(", "); + let (md_str, md_src) = format_setting(&files_str, merged.project_md_files_source, None); + let _ = writeln!( + out, + "{BOLD}│{RESET} Extra MD: {BLUE}{md_str}{RESET} {md_src}" + ); + } + + let _ = writeln!( + out, + "{BOLD}╰─────────────────────────────────────────────────╯{RESET}", + ); + + // ── Legend ── + let _ = writeln!( + out, + "\n{GRAY}Legend: {GREEN}(project){GRAY} = from .tinyharness/config.json, {CYAN}(global){GRAY} = from ~/.config/tinyharness/settings.json, {ORANGE}(default){GRAY} = hardcoded default{RESET}", + ); + + // ── Show project config path if found ── + if let Some(Ok(_)) = &project_config { + let mut dir = cwd; + loop { + let candidate = dir.join(".tinyharness").join("config.json"); + if candidate.is_file() { + let _ = writeln!( + out, + "\n{GRAY}Config file: {BLUE}{}{RESET}", + candidate.display() + ); + break; + } + if let Some(parent) = dir.parent() { + if parent == dir { + break; + } + dir = parent.to_path_buf(); + } else { + break; + } + } + } + + let _ = writeln!(out); +} + +/// Generate a `.tinyharness/config.json` template from current settings. +fn execute_init(out: &mut Output) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let dir = cwd.join(".tinyharness"); + + // Check if config already exists + let config_path = dir.join("config.json"); + if config_path.exists() { + let _ = writeln!( + out, + "{ORANGE}⚠ .tinyharness/config.json already exists at {BLUE}{}{RESET}", + config_path.display() + ); + let _ = writeln!( + out, + "{GRAY} Delete it first if you want to regenerate: rm {}{}{RESET}", + BLUE, + config_path.display(), + ); + return; + } + + // Create the .tinyharness directory + if let Err(e) = std::fs::create_dir_all(&dir) { + let _ = writeln!(out, "{RED}Failed to create directory: {e}{RESET}"); + return; + } + + // Generate template from current settings + let _settings = load_settings(); + + // Write the template (with empty project_md_files as an example) + let json = r#"{ + // Safe command prefixes: these extend the global safe list. + // Add project-specific commands that should be auto-accepted. + // "safe_command_prefixes": ["python -m pytest", "npm run lint"], + + // Denied command prefixes: always block these, even if they'd match a safe prefix. + // "denied_command_prefixes": ["git push --force"], + + // Whether to auto-accept safe read-only commands. + "auto_accept_safe_commands": true, + + // Context limit in tokens. Set to null for model default. + // "context_limit": null, + + // Additional project MD files to include in the AI's context. + // These are loaded AFTER TINYHARNESS.md or AGENTS.md. + // "project_md_files": ["RULES.md", ".cursorrules"], + + // Preferred agent mode for this project. + // Valid modes: casual, planning, agent, research + // "preferred_mode": "agent" +} +"#; + + // Strip comments for valid JSON output + let stripped = strip_json_comments(json); + + if let Err(e) = std::fs::write(&config_path, &stripped) { + let _ = writeln!( + out, + "{RED}Failed to write config file: {e}{RESET}" + ); + return; + } + + let _ = writeln!( + out, + "\n{GREEN}✔ Created {BLUE}{}{GREEN}{RESET}", + config_path.display() + ); + let _ = writeln!( + out, + "{GRAY}Edit this file to customize per-project settings.{RESET}", + ); + let _ = writeln!( + out, + "{GRAY}Uncomment and change fields as needed. Run {BOLD}/project-settings{RESET}{GRAY} to view merged settings.{RESET}", + ); + let _ = writeln!(out); +} + +/// Naive JSON comment stripper: removes `// ...` line comments and `/* ... */` blocks. +fn strip_json_comments(json: &str) -> String { + let mut result = String::new(); + let mut chars = json.chars().peekable(); + + while let Some(&ch) = chars.peek() { + if ch == '/' { + chars.next(); // consume first / + match chars.peek() { + Some(&'/') => { + // Line comment: skip until end of line + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\n' { + // Keep the newline + result.push('\n'); + break; + } + } + } + Some(&'*') => { + // Block comment: skip until */ + chars.next(); + loop { + match chars.next() { + Some('*') => { + if let Some(&'/') = chars.peek() { + chars.next(); + break; + } + } + None => break, + _ => {} + } + } + } + _ => { + result.push('/'); + } + } + } else { + result.push(ch); + chars.next(); + } + } + + result +} + +/// Format a setting value with a source annotation tag. +fn format_setting(value: &str, source: SettingSource, extra: Option<&str>) -> (String, String) { + let src_tag = match source { + SettingSource::Project => format!("{GREEN}(project){RESET}"), + SettingSource::Global => format!("{CYAN}(global){RESET}"), + SettingSource::Default => format!("{ORANGE}(default){RESET}"), + }; + + let display = if let Some(extra_info) = extra { + format!("{BLUE}{value}{RESET} {GRAY}{extra_info}{RESET}") + } else { + format!("{BLUE}{value}{RESET}") + }; + + (display, src_tag) +} diff --git a/tinyharness-lib/src/config/mod.rs b/tinyharness-lib/src/config/mod.rs index 788fdb0..d62b190 100644 --- a/tinyharness-lib/src/config/mod.rs +++ b/tinyharness-lib/src/config/mod.rs @@ -5,6 +5,228 @@ use serde::{Deserialize, Serialize}; use crate::mode::AgentMode; +// ── Project Settings ──────────────────────────────────────────────────────── + +/// Per-project override settings discovered from `.tinyharness/config.json`. +/// +/// All fields are `Option` — only present fields override the global setting. +/// Discovery walks up from CWD, same algorithm as `discover_project_md`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProjectSettings { + /// Override safe command prefixes (extends, doesn't replace) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub safe_command_prefixes: Option>, + /// Override denied command prefixes + #[serde(default, skip_serializing_if = "Option::is_none")] + pub denied_command_prefixes: Option>, + /// Override auto_accept_safe_commands + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_accept_safe_commands: Option, + /// Override context_limit + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_limit: Option, + /// Additional project-specific MD file names to include in context + /// (e.g. ["RULES.md", ".cursorrules"]). These are loaded AFTER the main + /// project instruction file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_md_files: Option>, + /// Override the preferred mode for this project + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_mode: Option, +} + +/// Discover and load `.tinyharness/config.json` by walking up from CWD. +/// +/// Returns `None` if no config file is found. Returns `Some(Err(...))` if +/// a file is found but cannot be parsed. +pub fn discover_project_settings(start_dir: &std::path::Path) -> Option> { + let mut dir = start_dir.to_path_buf(); + + loop { + let candidate = dir.join(".tinyharness").join("config.json"); + if candidate.is_file() { + let content = match std::fs::read_to_string(&candidate) { + Ok(c) => c, + Err(e) => return Some(Err(SettingsError::Io(e))), + }; + let parsed = serde_json::from_str(&content).map_err(SettingsError::Parse); + return Some(parsed); + } + + // Walk up one directory + if let Some(parent) = dir.parent() { + if parent == dir { + break; + } + dir = parent.to_path_buf(); + } else { + break; + } + } + + None +} + +/// Source annotation for merged settings values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingSource { + /// Value came from the global `~/.config/tinyharness/settings.json` + Global, + /// Value came from `.tinyharness/config.json` + Project, + /// Value is the hardcoded default (no config found) + Default, +} + +impl std::fmt::Display for SettingSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SettingSource::Global => f.write_str("global"), + SettingSource::Project => f.write_str("project"), + SettingSource::Default => f.write_str("default"), + } + } +} + +/// Effective merged settings — the result of layering global + project settings. +/// +/// This is a read-only view. Each field has a source annotation for display. +#[derive(Debug, Clone)] +pub struct MergedSettings { + /// Merged safe command prefixes (project extends global) + pub safe_commands: Vec, + pub safe_commands_source: SettingSource, + /// Merged denied command prefixes (project overrides global) + pub denied_commands: Vec, + pub denied_commands_source: SettingSource, + pub auto_accept_safe_commands: bool, + pub auto_accept_source: SettingSource, + pub context_limit: Option, + pub context_limit_source: SettingSource, + pub project_md_files: Vec, + pub project_md_files_source: SettingSource, + pub preferred_mode: AgentMode, + pub preferred_mode_source: SettingSource, +} + +/// Load and merge global + project settings. +/// +/// Layering: project overrides global where specified, otherwise falls back +/// to global. For safe commands, project *extends* the global list rather +/// than replacing it. +pub fn load_merged_settings() -> (Settings, Option, MergedSettings) { + let global = load_settings(); + + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let project = match discover_project_settings(&cwd) { + Some(Ok(ps)) => Some(ps), + Some(Err(e)) => { + tracing::warn!("Failed to parse .tinyharness/config.json: {e}. Ignoring project settings."); + None + } + None => None, + }; + + let merged = merge_settings(&global, project.as_ref()); + (global, project, merged) +} + +/// Merge global settings with optional project overrides. +fn merge_settings(global: &Settings, project: Option<&ProjectSettings>) -> MergedSettings { + let global_safe = global.get_safe_commands(); + let global_denied = global.get_denied_commands(); + + match project { + None => MergedSettings { + safe_commands: global_safe, + safe_commands_source: SettingSource::Default, + denied_commands: global_denied, + denied_commands_source: SettingSource::Default, + auto_accept_safe_commands: global.auto_accept_safe_commands, + auto_accept_source: SettingSource::Default, + context_limit: global.context_limit, + context_limit_source: SettingSource::Default, + project_md_files: Vec::new(), + project_md_files_source: SettingSource::Default, + preferred_mode: global.preferred_mode, + preferred_mode_source: SettingSource::Default, + }, + Some(p) => { + // Safe commands: project extends global + let safe_commands = if let Some(ref proj_safe) = p.safe_command_prefixes { + let mut combined = global_safe.clone(); + for cmd in proj_safe { + if !combined.contains(cmd) { + combined.push(cmd.clone()); + } + } + combined + } else { + global_safe + }; + let safe_source = if p.safe_command_prefixes.is_some() { + SettingSource::Project + } else { + SettingSource::Default + }; + + // Denied commands: project replaces global + let (denied_commands, denied_source) = + if let Some(ref proj_denied) = p.denied_command_prefixes { + (proj_denied.clone(), SettingSource::Project) + } else { + (global_denied, SettingSource::Default) + }; + + let (auto_accept, auto_source) = p.auto_accept_safe_commands + .map(|v| (v, SettingSource::Project)) + .unwrap_or((global.auto_accept_safe_commands, SettingSource::Default)); + + let (context_limit, ctx_source) = p.context_limit + .map(|v| (Some(v), SettingSource::Project)) + .unwrap_or((global.context_limit, SettingSource::Default)); + + let (project_md_files, md_source) = p.project_md_files + .as_ref() + .map(|files| (files.clone(), SettingSource::Project)) + .unwrap_or((Vec::new(), SettingSource::Default)); + + let (preferred_mode, mode_source) = p.preferred_mode + .map(|m| (m, SettingSource::Project)) + .unwrap_or((global.preferred_mode, SettingSource::Default)); + + MergedSettings { + safe_commands, + safe_commands_source: safe_source, + denied_commands, + denied_commands_source: denied_source, + auto_accept_safe_commands: auto_accept, + auto_accept_source: auto_source, + context_limit, + context_limit_source: ctx_source, + project_md_files, + project_md_files_source: md_source, + preferred_mode, + preferred_mode_source: mode_source, + } + } + } +} + +/// Generate a starter `.tinyharness/config.json` file from current settings +/// overrides that make sense for a project (safe commands, denied commands, +/// auto-accept, context limit). +pub fn generate_project_config_template(settings: &Settings) -> ProjectSettings { + ProjectSettings { + safe_command_prefixes: settings.safe_command_prefixes.clone(), + denied_command_prefixes: settings.denied_command_prefixes.clone(), + auto_accept_safe_commands: Some(settings.auto_accept_safe_commands), + context_limit: settings.context_limit, + project_md_files: None, // user must fill this in + preferred_mode: Some(settings.preferred_mode), + } +} + /// Identifies which provider backend was used last. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum ProviderKind { @@ -113,6 +335,12 @@ pub struct Settings { /// List of command prefixes that are always denied auto-accept, even if they /// match a safe prefix. Takes priority over the safe list. (default: empty) pub denied_command_prefixes: Option>, + /// Override the project instruction file discovery list. + /// When set, replaces the hardcoded default list (TINYHARNESS.md, AGENTS.md, etc.). + /// Use `TINYHARNESS_MD_FILES` env var for the highest priority override. + /// (default: None → use hardcoded defaults) + #[serde(default)] + pub project_md_files: Option>, } impl Default for Settings { @@ -131,6 +359,7 @@ impl Default for Settings { auto_accept_safe_commands: true, safe_command_prefixes: None, denied_command_prefixes: None, + project_md_files: None, } } } @@ -341,6 +570,41 @@ pub fn save_settings(settings: &Settings) { // ── Prompt file management ────────────────────────────────────────────────── +/// Resolve the effective list of project instruction file names to discover. +/// +/// Priority (highest first): +/// 1. `TINYHARNESS_MD_FILES` env var (comma-separated) +/// 2. `settings.project_md_files` from global settings +/// 3. Hardcoded default: TINYHARNESS.md, .tinyharness.md, AGENTS.md, CLAUDE.md +pub fn resolve_project_md_files(settings: Option<&Settings>) -> Vec { + // 1. Env var takes highest priority + if let Ok(env) = std::env::var("TINYHARNESS_MD_FILES") { + let files: Vec = env + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !files.is_empty() { + return files; + } + } + + // 2. Settings override + if let Some(s) = settings { + if let Some(ref configured) = s.project_md_files { + if !configured.is_empty() { + return configured.clone(); + } + } + } + + // 3. Hardcoded defaults + crate::context::DEFAULT_PROJECT_MD_FILE_NAMES + .iter() + .map(|s| s.to_string()) + .collect() +} + /// Returns the directory where per-mode prompt `.md` files are stored. /// /// Default: `~/.config/tinyharness/prompts/` diff --git a/tinyharness-lib/src/context.rs b/tinyharness-lib/src/context.rs index 0226d88..2d3d91b 100644 --- a/tinyharness-lib/src/context.rs +++ b/tinyharness-lib/src/context.rs @@ -13,13 +13,16 @@ const PROJECT_MD_HEAD_RATIO: f64 = 0.70; /// File names to search for, in priority order (first match wins). /// Mirrors the priority system used by Hermes Agent: /// .hermes.md → AGENTS.md → CLAUDE.md → .cursorrules -pub const PROJECT_MD_FILE_NAMES: &[&str] = &[ +pub const DEFAULT_PROJECT_MD_FILE_NAMES: &[&str] = &[ "TINYHARNESS.md", ".tinyharness.md", "AGENTS.md", "CLAUDE.md", ]; +/// Compatibility alias — kept for existing internal references. +pub const PROJECT_MD_FILE_NAMES: &[&str] = DEFAULT_PROJECT_MD_FILE_NAMES; + /// Collected metadata about the workspace/repository the agent is operating in. #[derive(Debug, Clone)] pub struct WorkspaceContext { @@ -40,6 +43,10 @@ pub struct WorkspaceContext { /// Contents of the discovered project instruction file (TINYHARNESS.md, AGENTS.md, etc.). /// `None` if no file was found. pub project_md: Option<(String, String)>, // (filename, content) + /// Additional project MD files loaded from `.tinyharness/config.json`'s + /// `project_md_files` field (e.g. RULES.md, .cursorrules). + /// Each entry is (filename, content). Empty if none configured. + pub additional_project_mds: Vec<(String, String)>, } impl WorkspaceContext { @@ -47,11 +54,24 @@ impl WorkspaceContext { pub fn collect() -> Self { let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let project_type = detect_project_type(&root); - let project_name = detect_project_name(&root, project_type); + let project_name = detect_project_name(&root, &project_type); let structure = list_top_level(&root); let is_git_repo = root.join(".git").is_dir(); - let (build_command, test_command) = detect_commands(project_type); - let project_md = discover_project_md(&root); + let (build_command, test_command) = detect_commands(&project_type, &root); + + // Resolve the project MD discovery list (env var > settings > defaults) + let settings = crate::config::load_settings(); + let md_files = crate::config::resolve_project_md_files(Some(&settings)); + let project_md = discover_project_md(&root, &md_files); + + // Load additional MD files from per-project .tinyharness/config.json + let additional_project_mds = if let Some(Ok(proj)) = + crate::config::discover_project_settings(&root) + { + load_additional_md_files(&root, proj.project_md_files.as_deref()) + } else { + Vec::new() + }; WorkspaceContext { root, @@ -62,6 +82,7 @@ impl WorkspaceContext { build_command: build_command.to_string(), test_command: test_command.to_string(), project_md, + additional_project_mds, } } @@ -96,27 +117,198 @@ impl WorkspaceContext { lines.push(content.clone()); } + // Append additional project MD files + for (filename, content) in &self.additional_project_mds { + lines.push(format!("\n---\n# Additional Instructions (from {filename})\n")); + lines.push(content.clone()); + } + lines.join("\n") } } -fn detect_project_type(root: &Path) -> &'static str { - if root.join("Cargo.toml").exists() { - "Rust" - } else if root.join("package.json").exists() { - "Node.js" - } else if root.join("setup.py").exists() || root.join("pyproject.toml").exists() { - "Python" - } else if root.join("go.mod").exists() { - "Go" - } else if root.join("pom.xml").exists() || root.join("build.gradle").exists() { - "Java" - } else if root.join("CMakeLists.txt").exists() { - "C/C++ (CMake)" - } else if root.join("Makefile").exists() { - "C/C++ (Make)" +/// Load additional project instruction files specified in per-project config. +fn load_additional_md_files(root: &Path, files: Option<&[String]>) -> Vec<(String, String)> { + let Some(files) = files else { return Vec::new() }; + files + .iter() + .filter_map(|name| { + let path = root.join(name); + if path.is_file() + && let Ok(content) = fs::read_to_string(&path) + { + let truncated = truncate_content(&content, name); + Some((name.clone(), truncated)) + } else { + None + } + }) + .collect() +} + +/// Detection entry: (language label, marker file(s), build command, test command). +/// Ordered by priority — first match wins per detection pass. +struct LanguageSignature { + label: &'static str, + markers: &'static [&'static str], + build_cmd: &'static str, + test_cmd: &'static str, +} + +const LANGUAGE_SIGNATURES: &[LanguageSignature] = &[ + LanguageSignature { + label: "Rust", + markers: &["Cargo.toml"], + build_cmd: "cargo build", + test_cmd: "cargo test", + }, + LanguageSignature { + label: "Zig", + markers: &["build.zig", "build.zig.zon"], + build_cmd: "zig build", + test_cmd: "zig build test", + }, + LanguageSignature { + label: "Deno", + markers: &["deno.json", "deno.jsonc"], + build_cmd: "deno task build", + test_cmd: "deno test", + }, + LanguageSignature { + label: "Bun", + markers: &["bun.lockb", "bun.lock"], + build_cmd: "bun run build", + test_cmd: "bun test", + }, + LanguageSignature { + label: "Swift", + markers: &["Package.swift"], + build_cmd: "swift build", + test_cmd: "swift test", + }, + LanguageSignature { + label: "Ruby", + markers: &["Gemfile"], + build_cmd: "bundle install", + test_cmd: "bundle exec rspec", + }, + LanguageSignature { + label: "Elixir", + markers: &["mix.exs"], + build_cmd: "mix compile", + test_cmd: "mix test", + }, + LanguageSignature { + label: "Haskell", + markers: &["stack.yaml"], + build_cmd: "stack build", + test_cmd: "stack test", + }, + LanguageSignature { + label: "Kotlin", + markers: &["build.gradle.kts", "settings.gradle.kts"], + build_cmd: "gradle build", + test_cmd: "gradle test", + }, + LanguageSignature { + label: "Dart/Flutter", + markers: &["pubspec.yaml"], + build_cmd: "dart run build", + test_cmd: "dart test", + }, + LanguageSignature { + label: "Nix", + markers: &["flake.nix", "default.nix"], + build_cmd: "nix build", + test_cmd: "nix flake check", + }, + LanguageSignature { + label: "Node.js", + markers: &["package.json"], + build_cmd: "npm run build", + test_cmd: "npm test", + }, + LanguageSignature { + label: "Python", + markers: &["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"], + build_cmd: "pip install -e .", + test_cmd: "pytest", + }, + LanguageSignature { + label: "Go", + markers: &["go.mod"], + build_cmd: "go build ./...", + test_cmd: "go test ./...", + }, + LanguageSignature { + label: "Java (Gradle)", + markers: &["build.gradle"], + build_cmd: "gradle build", + test_cmd: "gradle test", + }, + LanguageSignature { + label: "Java (Maven)", + markers: &["pom.xml"], + build_cmd: "mvn compile", + test_cmd: "mvn test", + }, + LanguageSignature { + label: "C/C++ (CMake)", + markers: &["CMakeLists.txt"], + build_cmd: "cmake --build build", + test_cmd: "ctest", + }, +]; + +/// Glob patterns for additional marker files that are checked after the +/// ordered signatures. These detect languages whose markers overlap with +/// other signatures (e.g. `.csproj` for .NET). +const SECONDARY_MARKERS: &[(&str, &str)] = &[ + (".NET", "*.csproj"), + (".NET", "*.sln"), + ("Haskell (Cabal)", "*.cabal"), +]; + +fn detect_project_type(root: &Path) -> String { + let mut found: Vec<&'static str> = Vec::new(); + + for sig in LANGUAGE_SIGNATURES { + for marker in sig.markers { + if root.join(marker).exists() { + if !found.contains(&sig.label) { + found.push(sig.label); + } + break; // one marker match is enough + } + } + } + + // Secondary check: glob for patterns like *.csproj + for (label, pattern) in SECONDARY_MARKERS { + if found.contains(label) { + continue; + } + if let Ok(entries) = glob::glob(&root.join(pattern).to_string_lossy()) { + if entries.flatten().next().is_some() { + found.push(label); + } + } + } + + if found.is_empty() { + // Fallback: check for Makefile / Justfile as task-runner hints + if root.join("Makefile").exists() { + "Unknown (has Makefile)".to_string() + } else if root.join("Justfile").exists() { + "Unknown (has Justfile)".to_string() + } else { + "Unknown".to_string() + } + } else if found.len() == 1 { + found[0].to_string() } else { - "Unknown" + // Monorepo: join all detected types + found.join(" + ") } } @@ -144,7 +336,10 @@ fn detect_project_name(root: &Path, project_type: &str) -> String { .unwrap_or_else(|| "Unknown".to_string()) }; - match project_type { + // Handle "Rust + Node.js" style monorepo labels + let primary = project_type.split_once(" + ").map(|(a, _)| a).unwrap_or(project_type); + + match primary { "Rust" => { if let Ok(content) = fs::read_to_string(root.join("Cargo.toml")) { for line in content.lines() { @@ -155,7 +350,7 @@ fn detect_project_name(root: &Path, project_type: &str) -> String { } fallback() } - "Node.js" => { + "Node.js" | "Bun" | "Deno" => { if let Ok(content) = fs::read_to_string(root.join("package.json")) && let Ok(json) = serde_json::from_str::(&content) && let Some(name) = json.get("name").and_then(|n| n.as_str()) @@ -164,6 +359,30 @@ fn detect_project_name(root: &Path, project_type: &str) -> String { } fallback() } + "Python" => { + if let Ok(content) = fs::read_to_string(root.join("pyproject.toml")) { + for line in content.lines() { + if let Some(name) = extract_quoted_field(line, "name") { + return name.to_string(); + } + } + } + fallback() + } + "Go" => { + // Read module name from go.mod + if let Ok(content) = fs::read_to_string(root.join("go.mod")) { + for line in content.lines() { + if let Some(rest) = line.strip_prefix("module ") { + let name = rest.trim(); + if !name.is_empty() { + return name.to_string(); + } + } + } + } + fallback() + } _ => fallback(), } } @@ -216,27 +435,41 @@ fn list_top_level(root: &Path) -> Vec { entries } -fn detect_commands(project_type: &str) -> (&'static str, &'static str) { - match project_type { - "Rust" => ("cargo build", "cargo test"), - "Node.js" => ("npm run build", "npm test"), - "Python" => ("pip install -e .", "pytest"), - "Go" => ("go build ./...", "go test ./..."), - _ => ("", ""), +fn detect_commands(project_type: &str, root: &Path) -> (&'static str, &'static str) { + // Handle monorepo labels: use the first detected type's commands + let primary = project_type.split_once(" + ").map(|(a, _)| a).unwrap_or(project_type); + + for sig in LANGUAGE_SIGNATURES { + if sig.label == primary { + return (sig.build_cmd, sig.test_cmd); + } + } + + // Fallback: check for Makefile / Justfile as task-runner hints + if root.join("Makefile").exists() { + ("make", "make test") + } else if root.join("Justfile").exists() { + ("just build", "just test") + } else { + ("", "") } } /// Search for a project instruction file in the current directory and parent -/// directories up to the git root (or filesystem root). Returns the first -/// match found, following the priority order defined in `PROJECT_MD_FILE_NAMES`. +/// directories up to the filesystem root. Returns the first +/// match found, following the priority order defined in `file_names`. /// /// This mirrors how CLAUDE.md and HERMES.md discover context files: /// walk up from CWD, check each directory for any of the known filenames. -fn discover_project_md(start_dir: &Path) -> Option<(String, String)> { +fn discover_project_md(start_dir: &Path, file_names: &[String]) -> Option<(String, String)> { + if file_names.is_empty() { + return None; + } + let mut dir = start_dir.to_path_buf(); loop { - for &filename in PROJECT_MD_FILE_NAMES { + for filename in file_names { let candidate = dir.join(filename); if candidate.is_file() && let Ok(content) = fs::read_to_string(&candidate) @@ -248,16 +481,10 @@ fn discover_project_md(start_dir: &Path) -> Option<(String, String)> { // Walk up one directory if let Some(parent) = dir.parent() { - // Stop at filesystem root if parent == dir { break; } dir = parent.to_path_buf(); - - // Stop at git root boundary: if we just checked inside a .git - // directory's parent, we've reached the repo root. - // We continue walking up because CLAUDE.md walks up to root, - // but we stop at the filesystem root. } else { break; } @@ -296,6 +523,16 @@ mod tests { use super::*; use std::fs; + /// Helper: the default discovery list as owned Strings for tests. + fn default_md_names() -> Vec { + DEFAULT_PROJECT_MD_FILE_NAMES.iter().map(|s| s.to_string()).collect() + } + + /// Helper: custom discovery list for override tests. + fn custom_md_names(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + /// Test that TINYHARNESS.md is discovered from the current directory. #[test] fn test_discover_project_md_tinyharness_md() { @@ -304,7 +541,7 @@ mod tests { fs::write(dir_path.join("TINYHARNESS.md"), "# Project\n\nUse Rust.").unwrap(); - let result = discover_project_md(dir_path); + let result = discover_project_md(dir_path, &default_md_names()); assert!(result.is_some()); let (filename, content) = result.unwrap(); assert_eq!(filename, "TINYHARNESS.md"); @@ -322,7 +559,7 @@ mod tests { fs::write(dir_path.join("AGENTS.md"), "# From AGENTS.md").unwrap(); fs::write(dir_path.join("CLAUDE.md"), "# From CLAUDE.md").unwrap(); - let result = discover_project_md(dir_path); + let result = discover_project_md(dir_path, &default_md_names()); assert!(result.is_some()); let (filename, content) = result.unwrap(); assert_eq!(filename, "TINYHARNESS.md"); @@ -337,7 +574,7 @@ mod tests { fs::write(dir_path.join("AGENTS.md"), "# From AGENTS.md").unwrap(); - let result = discover_project_md(dir_path); + let result = discover_project_md(dir_path, &default_md_names()); assert!(result.is_some()); let (filename, content) = result.unwrap(); assert_eq!(filename, "AGENTS.md"); @@ -352,7 +589,7 @@ mod tests { fs::write(dir_path.join("CLAUDE.md"), "# From CLAUDE.md").unwrap(); - let result = discover_project_md(dir_path); + let result = discover_project_md(dir_path, &default_md_names()); assert!(result.is_some()); let (filename, content) = result.unwrap(); assert_eq!(filename, "CLAUDE.md"); @@ -363,7 +600,7 @@ mod tests { #[test] fn test_discover_project_md_none() { let dir = tempfile::tempdir().unwrap(); - let result = discover_project_md(dir.path()); + let result = discover_project_md(dir.path(), &default_md_names()); assert!(result.is_none()); } @@ -379,7 +616,7 @@ mod tests { let subdir = dir_path.join("src").join("tools"); fs::create_dir_all(&subdir).unwrap(); - let result = discover_project_md(&subdir); + let result = discover_project_md(&subdir, &default_md_names()); assert!(result.is_some()); let (filename, content) = result.unwrap(); assert_eq!(filename, "TINYHARNESS.md"); @@ -394,13 +631,43 @@ mod tests { fs::write(dir_path.join(".tinyharness.md"), "# Hidden variant").unwrap(); - let result = discover_project_md(dir_path); + let result = discover_project_md(dir_path, &default_md_names()); assert!(result.is_some()); let (filename, content) = result.unwrap(); assert_eq!(filename, ".tinyharness.md"); assert!(content.contains("Hidden variant")); } + /// Test that custom file names work (env var / settings override). + #[test] + fn test_discover_project_md_custom_names() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path(); + + fs::write(dir_path.join("TEAM_RULES.md"), "# Team Rules").unwrap(); + fs::write(dir_path.join("CLAUDE.md"), "# Claude Rules").unwrap(); + + // Custom order: TEAM_RULES.md first + let names = custom_md_names(&["TEAM_RULES.md", "CLAUDE.md"]); + let result = discover_project_md(dir_path, &names); + assert!(result.is_some()); + let (filename, content) = result.unwrap(); + assert_eq!(filename, "TEAM_RULES.md"); + assert!(content.contains("# Team Rules")); + } + + /// Test that when custom list is empty, nothing is found. + #[test] + fn test_discover_project_md_empty_list() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path(); + + fs::write(dir_path.join("TINYHARNESS.md"), "# Project").unwrap(); + + let result = discover_project_md(dir_path, &[]); + assert!(result.is_none()); + } + /// Test truncation of oversized content. #[test] fn test_truncate_content_under_limit() { @@ -443,6 +710,7 @@ mod tests { "TINYHARNESS.md".to_string(), "# My Rules\nAlways use Rust.".to_string(), )), + additional_project_mds: Vec::new(), }; let formatted = ctx.format(); @@ -463,12 +731,35 @@ mod tests { build_command: "cargo build".to_string(), test_command: "cargo test".to_string(), project_md: None, + additional_project_mds: Vec::new(), }; let formatted = ctx.format(); assert!(!formatted.contains("Project Instructions")); } + /// Test that format() includes additional project MD files. + #[test] + fn test_format_includes_additional_mds() { + let ctx = WorkspaceContext { + root: PathBuf::from("/tmp/test"), + project_type: "Rust".to_string(), + project_name: "test-project".to_string(), + structure: vec!["src/ (main.rs)".to_string()], + is_git_repo: false, + build_command: "cargo build".to_string(), + test_command: "cargo test".to_string(), + project_md: None, + additional_project_mds: vec![ + ("RULES.md".to_string(), "# Custom Rules".to_string()), + ], + }; + + let formatted = ctx.format(); + assert!(formatted.contains("# Additional Instructions (from RULES.md)")); + assert!(formatted.contains("# Custom Rules")); + } + /// Test priority between .tinyharness.md and AGENTS.md. #[test] fn test_discover_project_md_hidden_over_agents() { @@ -478,7 +769,7 @@ mod tests { fs::write(dir_path.join(".tinyharness.md"), "# Hidden").unwrap(); fs::write(dir_path.join("AGENTS.md"), "# Agents").unwrap(); - let result = discover_project_md(dir_path); + let result = discover_project_md(dir_path, &default_md_names()); assert!(result.is_some()); let (filename, _) = result.unwrap(); assert_eq!(filename, ".tinyharness.md"); diff --git a/tinyharness-lib/src/lib.rs b/tinyharness-lib/src/lib.rs index ab6ec89..338dba0 100644 --- a/tinyharness-lib/src/lib.rs +++ b/tinyharness-lib/src/lib.rs @@ -10,8 +10,9 @@ pub mod tools; // Re-export key types at crate root for convenience pub use config::{ - ProviderKind, Settings, SettingsError, SettingsStore, ensure_prompts_initialized, - load_settings, prompts_dir, save_settings, + MergedSettings, ProjectSettings, ProviderKind, SettingSource, Settings, SettingsError, + SettingsStore, discover_project_settings, ensure_prompts_initialized, generate_project_config_template, + load_merged_settings, load_settings, prompts_dir, resolve_project_md_files, save_settings, }; pub use context::WorkspaceContext; pub use image::ImageAttachment; From 6e294bab44780fc01989ffd3c7daeb14a4980e95 Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Wed, 3 Jun 2026 10:44:26 +0200 Subject: [PATCH 2/4] docs: comprehensive project documentation (6 new docs) - skills.md: SKILL.md format, frontmatter reference, discovery, invocation, truncation rules, examples, best practices - tools-reference.md: all 15 tools with params, categories, auto-exec rules, mode filtering, schema format, custom tools - configuration.md: all 13 settings fields, per-project config, system prompts, XDG paths, env vars, CLI flags - safety.md: command safety model, safe/deny lists, metacharacter rejection, chain splitting, audit log, security best practices - modes.md: mode overview, tool filtering, prompt architecture, escalation pattern, customization, use cases - contributing.md: workspace structure, dev workflow, code conventions, CI, PR process, common tasks Updated docs/README.md with full index and question-based nav. --- docs/README.md | 35 +++++ docs/configuration.md | 262 ++++++++++++++++++++++++++++++++++++++ docs/contributing.md | 276 ++++++++++++++++++++++++++++++++++++++++ docs/modes.md | 269 +++++++++++++++++++++++++++++++++++++++ docs/safety.md | 262 ++++++++++++++++++++++++++++++++++++++ docs/skills.md | 233 +++++++++++++++++++++++++++++++++ docs/tools-reference.md | 237 ++++++++++++++++++++++++++++++++++ 7 files changed, 1574 insertions(+) create mode 100644 docs/configuration.md create mode 100644 docs/contributing.md create mode 100644 docs/modes.md create mode 100644 docs/safety.md create mode 100644 docs/skills.md create mode 100644 docs/tools-reference.md diff --git a/docs/README.md b/docs/README.md index f367a13..ddedfcb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,10 +2,45 @@ ## User Guides +- [Skills Guide](skills.md) — creating and using SKILL.md skill modules +- [Tools Reference](tools-reference.md) — tool categories, parameters, and behavior +- [Configuration Guide](configuration.md) — all settings, XDG paths, prompt customization +- [Safety & Security](safety.md) — command safety model, best practices +- [Agent Modes](modes.md) — mode behavior, prompt architecture, escalation - [Per-Project Settings](per-project-settings.md) — `.tinyharness/config.json` reference - [Language Detection](language-detection.md) — how project types are auto-detected - [Project Instructions](project-instructions.md) — configuring `TINYHARNESS.md` discovery +## Developer Docs + +- [Contributing](contributing.md) — project setup, code conventions, PR process + +## Quick References + +### By Role + +| Role | Start Here | +|------|------------| +| New user | [Configuration Guide](configuration.md) → [Agent Modes](modes.md) | +| Setting up a project | [Project Instructions](project-instructions.md) → [Per-Project Settings](per-project-settings.md) | +| Writing skills | [Skills Guide](skills.md) | +| Security-conscious user | [Safety & Security](safety.md) | +| Contributor | [Contributing](contributing.md) | + +### By Question + +| Question | Doc | +|----------|-----| +| "How do I add project-specific commands?" | [Per-Project Settings](per-project-settings.md) | +| "How do I customize prompts?" | [Configuration Guide](configuration.md#system-prompts) | +| "What's the difference between planning and agent?" | [Agent Modes](modes.md) | +| "Can the AI auto-execute any command?" | [Safety & Security](safety.md#safe-commands) | +| "How do I write a skill?" | [Skills Guide](skills.md#skill-file-format) | +| "What tools are available?" | [Tools Reference](tools-reference.md) | +| "How do I override TINYHARNESS.md discovery?" | [Project Instructions](project-instructions.md#customizing-the-file-list) | +| "What languages are auto-detected?" | [Language Detection](language-detection.md) | +| "How do I contribute?" | [Contributing](contributing.md) | + ## Files and Directories TinyHarness stores data in standard XDG paths: diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a1ce564 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,262 @@ +# Configuration Guide + +TinyHarness stores all persistent settings in `~/.config/tinyharness/`. This guide covers every configurable option. + +## Settings File + +**Location**: `~/.config/tinyharness/settings.json` + +Settings are loaded at startup. On first launch, defaults are used until the user runs `--config` (interactive setup) or manually edits the file. + +### Setting vs. Saving + +- **Global settings** are managed via slash commands (`/command`, `/model`, `/apikey`, etc.) +- **Project settings** are managed via `/project-settings` and `.tinyharness/config.json` +- **Manual editing** the JSON file works too — reload with `/refresh` + +### Atomic Writes + +Settings are saved atomically: written to a `.tmp` file, then renamed. This prevents corruption if the process crashes during a write. + +--- + +## All Settings Fields + +```json +{ + "last_provider": "ollama", + "last_provider_url": "http://127.0.0.1:11434", + "last_model": "qwen2.5-coder:14b", + "preferred_mode": "agent", + "ollama_api_key": null, + "ollama_timeout_secs": 5, + "ollama_max_retries": 3, + "ollama_think_type": "medium", + "show_thinking": false, + "context_limit": null, + "auto_accept_safe_commands": true, + "safe_command_prefixes": null, + "denied_command_prefixes": null, + "project_md_files": null +} +``` + +### Provider Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `last_provider` | string | `"ollama"` | Last used provider: `"ollama"`, `"llamacpp"`, or `"vllm"` | +| `last_provider_url` | string\|null | `null` | Custom base URL for the provider. Set by `--url` flag or `--config` interactive setup. If `null`, uses the provider's default URL | +| `last_model` | string\|null | `null` | Last used model name. Set by `/model `. If `null`, the provider auto-selects the first available model | + +**Provider default URLs** (used when `last_provider_url` is `null`): +- Ollama: `http://127.0.0.1:11434` +- llama.cpp: `http://127.0.0.1:8080` +- vLLM: `http://127.0.0.1:8000` + +### Mode Setting + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `preferred_mode` | string | `"casual"` | Default mode on startup: `"casual"`, `"planning"`, `"agent"`, or `"research"` | + +Can be overridden per-project via `.tinyharness/config.json` → `preferred_mode`. + +### Ollama-Specific Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `ollama_api_key` | string\|null | `null` | API key for Ollama cloud features (`web_search`, `web_fetch`). Set via `/apikey `. Leave `null` for local-only use | +| `ollama_timeout_secs` | u64 | `5` | HTTP request timeout in seconds. Increase for slow models or large payloads. Set via `/timeout ` | +| `ollama_max_retries` | u32 | `3` | Maximum retries on transient errors (network failures, 5xx responses). Set via `/retries ` | +| `ollama_think_type` | string | `"medium"` | Reasoning level for models that support it (qwen2.5 variants): `"off"`, `"low"`, `"medium"`, `"high"`. Set via `/think ` | + +### Display Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `show_thinking` | bool | `false` | Whether to render the model's reasoning chain inline during streaming. Toggle with `/showthink` | + +### Context Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `context_limit` | u32\|null | `null` | Custom context warning threshold in tokens. If `null`, uses the model's default (8K–256K depending on model). Warnings fire at 70% and 90%. Set via `/contextlimit ` | + +### Command Safety Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `auto_accept_safe_commands` | bool | `true` | Whether safe commands auto-execute without confirmation. Toggle with `/autoaccept` | +| `safe_command_prefixes` | vec\|null | `null` | Custom safe command prefixes. If `null`, uses the hardcoded default list (43 commands). Set via `/command add/rm/reset` | +| `denied_command_prefixes` | vec\|null | `null` | Always-denied prefixes. Takes priority over safe list. Set via `/command deny/undeny/resetdeny` | + +The deny list takes priority. If a command matches both a safe prefix and a denied prefix, it is denied. This lets you block specific dangerous commands (e.g. deny `git push` but keep `git status` safe). + +### Project Instruction File Settings + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `project_md_files` | vec\|null | `null` | Custom discovery order for project instruction files. If `null`, uses the hardcoded order: `TINYHARNESS.md` → `.tinyharness.md` → `AGENTS.md` → `CLAUDE.md` | + +Override priority: +1. `TINYHARNESS_MD_FILES` env var (highest) +2. `project_md_files` in settings +3. Hardcoded defaults (lowest) + +--- + +## Per-Project Settings + +**Location**: `.tinyharness/config.json` (discovered walking up from CWD) + +Overrides global settings for a specific project. See [Per-Project Settings](per-project-settings.md) for full details. + +### Supported Fields + +```json +{ + "safe_command_prefixes": ["python -m pytest", "npm run lint"], + "denied_command_prefixes": ["git push --force"], + "auto_accept_safe_commands": false, + "context_limit": 32768, + "project_md_files": ["RULES.md", ".cursorrules"], + "preferred_mode": "agent" +} +``` + +### Layering + +``` +~/.config/tinyharness/settings.json (global) + → .tinyharness/config.json (project override) + → CLI flags (highest priority) +``` + +- `safe_command_prefixes`: **Extends** (not replaces) the global list +- `denied_command_prefixes`: **Replaces** the global list +- All other fields: **Override** if present, fall back to global if absent + +### Viewing Merged Settings + +``` +/project-settings +``` + +Shows every setting with its source annotation: + +``` +safe_command_prefixes (project): + python -m pytest + ... +auto_accept_safe_commands (project): false +context_limit (project): 32768 +last_provider (global): ollama +ollama_timeout_secs (default): 5 +``` + +### Creating a Project Config + +``` +/project-settings init +``` + +Generates a `.tinyharness/config.json` with commented defaults from your current global settings. + +--- + +## System Prompts + +**Location**: `~/.config/tinyharness/prompts/` + +On first launch, TinyHarness seeds this directory with default `.md` prompt files. Existing files are **never overwritten** — you can safely customize them. + +``` +~/.config/tinyharness/prompts/ +├── header.md Shared header for agent/planning/research +├── casual.md Self-contained casual mode prompt +├── planning.md Planning mode (ReadOnly + Signal tools) +├── agent.md Agent mode (all 15 tools) +└── research.md Research mode (ReadOnly + Signal tools) +``` + +### Prompt Assembly + +For Agent, Planning, and Research modes: +``` +header.md + blank line + .md +``` + +For Casual mode: +``` +casual.md (self-contained) +``` + +Prompts are rebuilt (re-read from disk) on: +- Mode switch +- Skill activation/unload +- File pinning changes (`/add`, `/drop`) +- `/refresh` command + +### Customizing Prompts + +1. Edit the `.md` files in `~/.config/tinyharness/prompts/` +2. Run `/refresh` or switch modes to apply + +To restore a default, delete the file and restart TinyHarness — it will re-seed on next launch. + +--- + +## XDG Paths Reference + +``` +~/.config/tinyharness/ +├── settings.json Global settings +├── prompts/ Customizable system prompt .md files +│ ├── header.md +│ ├── casual.md +│ ├── planning.md +│ ├── agent.md +│ └── research.md +└── skills/ Personal skills + └── / + └── SKILL.md + +~/.local/share/tinyharness/ +├── sessions/ JSONL session files +│ └── .jsonl +├── history.txt Command history (rustyline) +└── backups/ File backups (when /undo is implemented) + └── / + +/.tinyharness/ +├── config.json Per-project settings +└── skills/ Project-local skills + └── / + └── SKILL.md +``` + +--- + +## CLI Flags + +All CLI flags override settings: + +| Flag | Setting Override | +|------|-----------------| +| `-o`, `--ollama` | `last_provider = "ollama"` | +| `-l`, `--llama-cpp` | `last_provider = "llamacpp"` | +| `-v`, `--vllm` | `last_provider = "vllm"` | +| `-u`, `--url ` | `last_provider_url = ` | +| `-c`, `--continue` | Loads most recent session (doesn't modify settings) | +| `--config` | Runs interactive setup, saves, exits | +| `-p`, `--prompt ` | Sends initial prompt then enters interactive mode | + +--- + +## Environment Variables + +| Variable | Effect | +|----------|--------| +| `TINYHARNESS_MD_FILES` | Comma-separated list of instruction file names, overrides `project_md_files` in settings | +| `HOME` | Used to resolve `~/.config/tinyharness/` and `~/.local/share/tinyharness/` | diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..c7c997b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,276 @@ +# Contributing to TinyHarness + +TinyHarness is a Rust workspace with three crates and a focus on minimal dependencies. This guide covers setup, conventions, and the PR workflow. + +## Project Setup + +### Prerequisites + +- Rust latest stable (edition 2024) +- An LLM backend for testing (Ollama recommended, but not required for library tests) + +### Getting the Code + +```bash +git clone https://github.com/yourusername/TinyHarness.git +cd TinyHarness +``` + +### First Build + +```bash +cargo build --workspace +cargo test --workspace +``` + +This compiles all three crates and runs the test suite (~81 tests in `tinyharness-lib` and the binary crate). + +--- + +## Workspace Structure + +``` +TinyHarness/ Binary crate — CLI, agent loop, slash commands +├── src/ +│ ├── main.rs Entry point, CLI parsing, provider creation +│ ├── agent/ Agent loop, tool execution, safety, display, input +│ └── commands/ 22+ slash command modules + registry +│ +tinyharness-lib/ Core library — no terminal I/O, no ANSI, no rustyline +├── src/ +│ ├── lib.rs Re-exports all public types +│ ├── provider/ Provider trait + Ollama/llama.cpp/vLLM impls +│ ├── tools/ 15 tools + ToolManager with mode filtering +│ ├── config/mod.rs Settings, project settings, prompt management +│ ├── context.rs Workspace detection, instruction file discovery +│ ├── session.rs JSONL persistence, auto-save, atomic writes +│ ├── token.rs Token estimation, context windows, warnings +│ ├── skill.rs Skill discovery, registry, frontmatter parsing +│ ├── image.rs Image attachment handling +│ ├── mode.rs AgentMode enum, prompt assembly +│ └── prompts/ Hardcoded default system prompts (.md files) +│ +tinyharness-ui/ UI library — terminal output abstractions +├── src/ +│ ├── lib.rs Module declarations +│ ├── output.rs Structured output writer +│ ├── style.rs ANSI color constants, spinner frames +│ └── ui/ confirm.rs, diff.rs, input.rs, wrap.rs +│ +docs/ User-facing documentation +└── todo/ Enhancement tracking (local only, not committed) +``` + +### Crate Rules + +- **`tinyharness-lib`**: Must not use terminal I/O, ANSI escape codes, or `rustyline`. Uses `tracing` for logging. +- **`tinyharness-ui`**: Terminal UI abstractions — ANSI colors, confirmation prompts, diff display, word wrapping. +- **`src/` (binary)**: Wires everything together. Handles I/O, user interaction, and the agent loop. + +--- + +## Development Workflow + +### Building + +```bash +cargo build # Debug build (all crates) +cargo build --release # Release build +cargo build -p tinyharness-lib # Build only the library +``` + +### Testing + +```bash +cargo test --workspace # All tests +cargo test -p tinyharness-lib # Library tests only +cargo test -p TinyHarness # Binary crate tests only +cargo test -p tinyharness-ui # UI crate tests only +cargo test # Specific test (searches all crates) +``` + +### Linting & Formatting + +```bash +cargo clippy --workspace -- -D warnings # Lint all crates (warnings = errors) +cargo fmt --all # Auto-format +cargo fmt --all -- --check # Check formatting without changing +``` + +### Verification Checklist + +Before submitting a PR, run these in order: + +1. `cargo fmt --all` — ensure formatting is clean +2. `cargo clippy --workspace -- -D warnings` — no clippy warnings +3. `cargo test --workspace` — all tests pass +4. `cargo build` — clean debug build succeeds + +--- + +## Code Conventions + +### Rust Edition + +All crates use Rust **edition 2024**. Check `Cargo.toml` files if you're unsure. + +### Error Handling + +- **User-facing errors**: `Result` — the binary crate displays these directly +- **Internal errors**: `Result>` — for library code where error types vary +- **I/O errors**: Propagate with `?` or wrap in domain-specific error enums + +### Async Patterns + +Prefer `Pin>` over `async-trait`: + +```rust +// ✅ Do this +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; +handler: Box) -> BoxFuture<'static, String> + Send + Sync>, + +// ❌ Not this +#[async_trait] +pub trait AsyncHandler { + async fn handle(&self, args: HashMap) -> String; +} +``` + +This avoids pulling in the `async-trait` crate and keeps the dependency tree small. + +### Serialization + +Use `serde` + `schemars` for serialization and JSON Schema generation: +- `serde::Serialize` / `serde::Deserialize` for data types +- `schemars::JsonSchema` (or manual `Schema` construction) for tool parameter schemas + +### Dependency Policy + +- **Minimize dependencies** — avoid adding new crates when existing ones suffice +- **Feature-gate optional functionality** — e.g., a hypothetical `server` feature for HTTP API mode +- **Audit existing usage** — `chrono` usage could be replaced with `std::time` (see `todo/16-dependency-slimming.md`) + +### Macros + +`#[macro_export]` macros (`extract_args!`) live at the `tinyharness_lib` crate root, not inside a module. They're re-exported via `pub use`. + +### Tests + +- Use `tempfile` for test isolation — tool tests must not touch the real filesystem +- Test modules go inline: `#[cfg(test)] mod tests { ... }` +- Library crate has good test coverage; binary and UI crates need more (see `todo/01-testing-gaps.md`) + +### Tool Categories + +When adding a new tool, assign it to one of three categories: +- `ReadOnly` — auto-executed, no side effects +- `Destructive` — requires confirmation +- `Signal` — handled specially by the agent loop + +See [Tools Reference](tools-reference.md) for the full list and behavior. + +--- + +## CI Pipeline + +GitHub Actions runs on every push and PR to `master`: + +| Job | Command | Purpose | +|-----|---------|---------| +| Format | `cargo fmt --all -- --check` | Ensures consistent formatting | +| Clippy | `cargo clippy --workspace -- -D warnings` | Catches common mistakes | +| Test | `cargo test --workspace` | Runs full test suite | +| Build | `cargo build --workspace` | Confirms compilation | + +Uses `dtolnay/rust-toolchain@stable` for Rust and `Swatinem/rust-cache@v2` for caching. + +--- + +## Pull Request Process + +1. **Create a feature branch**: `feat/short-description` or `fix/short-description` +2. **Make changes**: Follow code conventions above +3. **Run verification checklist**: fmt → clippy → test → build +4. **Update docs**: If adding/changing user-facing features, update relevant docs in `docs/` +5. **Update todos**: If your PR completes a tracked enhancement, update the status in `todo/-*.md` and `todo/todo.md` +6. **Write a clear PR description**: What, why, and any breaking changes +7. **PR to `master`**: CI runs automatically + +### Commit Style + +``` +feat: language detection for 17+ languages + +Adds detection for Zig, Deno, Bun, Swift, Ruby, Elixir, +Haskell, Kotlin, .NET, Dart/Flutter, Nix. Monorepo detection +joins multiple types with "+". Falls back to Makefile/Justfile. + +- context.rs: expanded detect_project_type with 17+ signatures +- monorepo detection joins types (e.g. "Rust + Node.js") +- Makefile/Justfile fallback for unknown types +``` + +Keep the summary line under 72 characters. Use imperative mood ("Add" not "Added"). + +--- + +## Common Tasks + +### Adding a New Slash Command + +1. Create `src/commands/.rs` +2. Implement the handler function +3. Register in `src/commands/mod.rs` → `build_registry()` +4. If async (needs provider access), use the `async_command!` macro +5. Add help text to `/help` output + +Example: +```rust +use crate::commands::registry::CommandResult; + +pub fn handle_my_command(ctx: &mut CommandContext, _args: &[&str]) -> CommandResult { + // Command logic here + CommandResult::Ok +} +``` + +### Adding a New Tool + +1. Create `tinyharness-lib/src/tools/.rs` +2. Implement using `make_tool()` and `build_string_params_schema()` +3. Add `pub mod ;` to `tinyharness-lib/src/tools/mod.rs` +4. Register in `ToolManager::register_defaults()` +5. Assign a `ToolCategory` (ReadOnly, Destructive, or Signal) +6. If Destructive, wire into confirmation flow (`src/agent/tools.rs`) +7. If Signal, add to `parse_signal_event()` and agent loop handling + +### Adding a New Provider + +1. Create `tinyharness-lib/src/provider/.rs` +2. Implement the `Provider` trait +3. Add to `ProviderKind` enum in `config/mod.rs` +4. Add CLI flag in `main.rs` +5. Add provider creation in `src/agent/setup.rs` + +### Modifying Settings + +1. Add the field to `Settings` struct in `tinyharness-lib/src/config/mod.rs` +2. Add a default value in `Default::default()` (or derive `#[serde(default)]`) +3. Consider per-project override support in `ProjectSettings` +4. Add a slash command to modify it (optional, for user-facing settings) + +--- + +## Where to Get Help + +- **Code questions**: Look at existing patterns — most modules follow consistent idioms +- **Architecture**: Read `TINYHARNESS.md` (the project's own instructions) and the module overview above +- **Planned work**: Check `todo/todo.md` and `todo/-*.md` for tracked enhancements +- **Tool docs**: See [Tools Reference](tools-reference.md) for tool schemas and behavior +- **Configuration**: See [Configuration Guide](configuration.md) for settings and paths + +--- + +## License + +MIT — see `LICENSE` at the repository root. All contributions are under the same license. diff --git a/docs/modes.md b/docs/modes.md new file mode 100644 index 0000000..5ee6380 --- /dev/null +++ b/docs/modes.md @@ -0,0 +1,269 @@ +# Agent Modes + +TinyHarness has four agent modes that control which tools are available and how the AI behaves. Modes provide a graduated trust model — start with restricted access and escalate when you need more power. + +## Mode Overview + +| Mode | Tools | Purpose | Best For | +|------|-------|---------|----------| +| **casual** | `web_search`, `web_fetch` | Chat with web access | General questions, research without touching your code | +| **planning** | All ReadOnly + all Signal | Analyze, plan, escalate | Codebase exploration, architecture review, planning changes | +| **agent** | All 15 tools | Full development access | Writing code, running commands, full development workflow | +| **research** | All ReadOnly + all Signal | Web research, escalate | Research-heavy tasks with codebase awareness | + +## Tool Availability by Mode + +### Casual +``` +Available: web_search, web_fetch +Not available: ls, read, write, edit, grep, glob, run, + switch_mode, question, auto_compact, invoke_skill, screenshot +``` +The most restricted mode. No filesystem access at all. The AI can search the web and fetch pages, but cannot read or modify files. + +### Planning +``` +Available: ls, read, grep, glob, web_search, web_fetch, + switch_mode, question, auto_compact, invoke_skill, screenshot +Not available: write, edit, run +``` +Can explore code but not change it. Ideal for architecture discussions, code reviews, and planning before switching to agent mode. + +### Agent +``` +Available: ls, read, write, edit, grep, glob, run, + web_search, web_fetch, switch_mode, question, + auto_compact, invoke_skill, screenshot +Not available: (none) +``` +Full access. All tools are available. This is the default for development work. + +### Research +``` +Available: ls, read, grep, glob, web_search, web_fetch, + switch_mode, question, auto_compact, invoke_skill, screenshot +Not available: write, edit, run +``` +Same toolset as planning, but with a research-focused system prompt. The prompt emphasizes gathering information, cross-referencing sources, and presenting findings. + +--- + +## Mode Switching + +### By the User + +``` +/mode agent # Show current mode + list all +/mode planning # Switch to planning +/plan # Shortcut alias +/agent # Shortcut alias +/research # Shortcut alias +/casual # Shortcut alias +``` + +Modes also accept aliases: +- `planning` or `plan` +- `agent` or `dev` +- `research` or `researching` +- `casual` (no alias) + +### By the AI (Signal Tool) + +The AI can request a mode switch by calling `switch_mode`: + +```json +{"name": "switch_mode", "arguments": {"mode": "agent"}} +``` + +This is handled as a signal — the agent loop catches it, switches the mode, rebuilds the system prompt, and continues the conversation in the new mode. The AI typically uses this pattern: + +1. Start in `planning` mode +2. Explore the codebase, understand the problem +3. Call `switch_mode` to `agent` when ready to implement +4. Make changes +5. Optionally switch back to `planning` to review + +### What Happens on Mode Switch + +1. System prompt is rebuilt from disk (header.md + new mode's .md file) +2. Tool definitions are regenerated for the new mode +3. Active skills are re-injected +4. Project context and file pins are refreshed +5. Previous conversation history is preserved +6. Session is saved (flush) + +--- + +## System Prompt Architecture + +Prompts are assembled from multiple components in a specific order: + +``` +1. header.md (shared — agent/planning/research) +2. blank line +3. .md (mode-specific instructions) +4. Project context (language, root, git status, structure) +5. Project instructions (TINYHARNESS.md + additional files) +6. File pins (pinned file contents) +7. Active skills (skill content) +8. Available tools + skill index +``` + +### Header (`header.md`) + +Used by agent, planning, and research modes. Contains: +- Role definition ("development AI with tools") +- Operating context (project name, language, workspace root) +- File pinning instructions +- Conversation behavior guidelines + +### Mode Files + +#### `casual.md` (self-contained, no header) +``` +You are a helpful AI assistant named TinyHarness. You run in casual mode +with access to web search and web fetch only... +``` + +#### `planning.md` +``` +You are in PLANNING mode. Analyze, research, plan — do NOT make changes. +Use ReadOnly tools to explore, Signal tools to escalate... +``` + +#### `agent.md` +``` +You are in AGENT mode. Full development access — write code, run commands, +make changes. You have access to all tools... +``` + +#### `research.md` +``` +You are in RESEARCH mode. Gather information from the web and codebase. +Cross-reference sources, present findings clearly... +``` + +### Customizing Prompts + +See [Configuration Guide](configuration.md#system-prompts). Key points: +- Edit files in `~/.config/tinyharness/prompts/` +- Existing files are never overwritten +- Use `/refresh` to reload + +--- + +## Mode Use Cases + +### Recommended Workflow: Escalation Ladder + +``` +casual → planning → agent + ↓ +(review question) + ↓ +planning (review changes) +``` + +1. **Casual**: Ask general questions, research concepts +2. **Planning**: Explore the codebase, understand the architecture, plan changes +3. **Agent**: Implement changes, run tests, iterate +4. **Planning**: Review the diff, verify correctness + +### When to Stay in Planning + +- Onboarding to a new codebase +- Code reviews +- Architecture discussions +- Debugging with read-only access +- Writing documentation (if using `write` via agent escalation) + +### When to Use Casual + +- General programming questions ("How does Rust's borrow checker work?") +- Research before touching code ("What's the best library for X?") +- Quick web searches during development + +### When to Use Research + +- Gathering external API documentation +- Comparing libraries/frameworks +- Finding solutions to error messages from multiple sources +- Pre-implementation research with codebase awareness + +--- + +## Mode Configuration + +### Default Mode on Startup + +Set via settings: +```json +{ + "preferred_mode": "agent" +} +``` + +Or per-project: +```json +{ + "preferred_mode": "planning" +} +``` + +### Forcing a Mode via CLI + +There's no `--mode` CLI flag. The saved `preferred_mode` is used on startup. Switch immediately after with `/mode `. + +--- + +## Mode Internals + +### `AgentMode` Enum + +Located in `tinyharness-lib/src/mode.rs`: + +```rust +pub enum AgentMode { + Casual, + Planning, + Agent, + Research, +} +``` + +### Tool Filtering + +Located in `ToolManager::tools_for_mode()` (`tinyharness-lib/src/tools/mod.rs`): + +```rust +fn tools_for_mode(&self, mode: AgentMode) -> Vec { + match mode { + AgentMode::Agent => self.get_all_tool_definitions(), + AgentMode::Casual => /* web_search + web_fetch only */, + AgentMode::Planning => /* ReadOnly + Signal */, + AgentMode::Research => /* ReadOnly + Signal */, + } +} +``` + +### Prompt Loading + +Located in `AgentMode::load_system_prompt()` (`tinyharness-lib/src/mode.rs`): + +- Files are read from `~/.config/tinyharness/prompts/` +- Empty or missing files fall back to hardcoded defaults (`include_str!`) +- Casual mode uses only its own file +- Other modes prepend the shared header + +--- + +## Tips + +1. **Start sessions in planning mode**: Set `preferred_mode: "planning"` in project config so the AI explores before changing anything. +2. **Use per-project mode**: Different projects have different trust levels: + ```json + { "preferred_mode": "agent" } // your own project + { "preferred_mode": "planning" } // a codebase you're exploring + ``` +3. **The AI knows to escalate**: The planning and research prompts instruct the AI to call `switch_mode` when it needs write access. You don't need to switch manually. +4. **Tool filtering is automatic**: The tool list in the system prompt changes on mode switch. The AI literally cannot call `write` in planning mode. diff --git a/docs/safety.md b/docs/safety.md new file mode 100644 index 0000000..3f834e4 --- /dev/null +++ b/docs/safety.md @@ -0,0 +1,262 @@ +# Safety & Security + +TinyHarness grants an LLM the ability to execute shell commands, write files, and modify your project. This document explains how the safety system works and how to configure it for your threat model. + +## Command Safety Model + +The `run` tool executes arbitrary shell commands. Every command goes through a safety checker (`is_safe_command`) before auto-accept is allowed. + +### What Gets Checked + +1. **Deny list** — checked first, takes highest priority +2. **Shell metacharacter rejection** — blocks command injection patterns +3. **Safe descriptor redirections** — strips harmless patterns before further checks +4. **Chain splitting** — `&&` and `||` chains are recursively checked +5. **Safe prefix matching** — word-boundary-aware prefix comparison + +### Safety Check Flow + +``` +Input: "cd /project && cargo test 2>&1" + ↓ +1. Check deny list: not denied ✓ +2. Check newlines: none ✓ +3. Check semicolons: none ✓ +4. Strip safe redirections: "cd /project && cargo test " +5. Check lone `&` (background): none ✓ +6. Split on && / ||: ["cd /project", "cargo test "] +7. For each part, check pipes/$()/backticks: none ✓ +8. For each part, check >/<: none ✓ +9. For each part, prefix-match safe list: + - "cd" matches ✓ + - "cargo test" matches ✓ +10. Result: SAFE ✓ +``` + +--- + +## Safe Commands + +The default safe list contains 43 prefixes covering common read-only CLI utilities: + +| Category | Commands | +|----------|----------| +| **Navigation** | `cd`, `ls`, `pwd`, `tree`, `find` | +| **File inspection** | `cat`, `head`, `tail`, `wc`, `file`, `stat` | +| **Search** | `grep`, `glob` | +| **System info** | `du`, `df`, `free`, `uptime`, `whoami`, `hostname`, `uname`, `date`, `cal` | +| **Process info** | `ps` | +| **Environment** | `env`, `printenv`, `which`, `whereis`, `type` | +| **Output** | `echo`, `printf` | +| **Git (read-only)** | `git status`, `git diff`, `git log`, `git show`, `git branch`, `git remote`, `git tag`, `git describe`, `git rev-parse` | +| **Cargo (read-only)** | `cargo tree`, `cargo metadata`, `cargo doc`, `rustc --version`, `cargo --version` | + +### What's NOT on the Safe List + +Anything that creates, modifies, or executes: + +- `git commit`, `git push`, `git add`, `git checkout` +- `cargo build`, `cargo test`, `cargo run` +- `make`, `cmake`, `ninja` +- `npm`, `pip`, `curl`, `wget` +- `rm`, `mv`, `cp`, `chmod`, `chown` + +These always require explicit confirmation. + +### Customizing the Safe List + +``` +/command add "python -m pytest" # Add a custom safe prefix +/command rm "git tag" # Remove a prefix (make it require confirmation) +/command reset # Restore defaults +``` + +Project-specific additions (in `.tinyharness/config.json`): +```json +{ + "safe_command_prefixes": ["python -m pytest", "npm run lint"] +} +``` + +--- + +## Deny List + +Commands that should **always** require confirmation, even if they match a safe prefix. + +```json +{ + "denied_command_prefixes": ["git push", "git push --force", "rm"] +} +``` + +The deny list takes **priority over the safe list**. If a command matches both, it's blocked: + +``` +/command deny "git push" +# Now "git push" always requires confirmation +# But "git status" is still auto-accepted +``` + +### Block All Cargo Commands + +``` +/command deny "cargo" +# Blocks: cargo build, cargo test, cargo tree, cargo metadata, ... +``` + +This overrides any matching safe prefixes like `cargo tree`. + +### Useful Deny Patterns + +| Pattern | Blocks | +|---------|--------| +| `git push` | `git push`, `git push origin main`, `git push --force` | +| `cargo` | All cargo subcommands including read-only ones | +| `rm` | `rm`, `rm -rf`, `rmdir` | +| `curl` | `curl`, `wget` | +| `git` | All git subcommands (if you want full manual control) | + +--- + +## Shell Metacharacter Rejection + +The following patterns are **always rejected** and cannot be made safe: + +| Pattern | Reason | +|---------|--------| +| `\n` (newline) | Could hide a second command on a new line | +| `;` | Command separator: `ls; rm -rf /` | +| Single `&` | Background operator: `sleep 1 & rm -rf /` | +| `\|` (pipe) | Could pipe to a dangerous command | +| `$()` | Command substitution: `echo $(rm -rf /)` | +| `` ` `` (backticks) | Alternative command substitution | +| `>` (redirection) | File writing: `echo hi > /etc/hosts` | +| `<` (redirection) | File input: `cat < /etc/shadow` | + +### Safe Exceptions + +Safe descriptor redirections are stripped **before** metacharacter checks: + +| Pattern | What it does | Why it's safe | +|---------|-------------|---------------| +| `2>&1` | Redirect stderr to stdout | No file access, just merges output streams | +| `2>/dev/null` | Discard stderr | Only writes to `/dev/null` (bit bucket) | +| `1>&2` | Redirect stdout to stderr | No file access | + +This enables auto-accepted commands like `cargo test 2>&1` (if `cargo test` is on the safe list). + +### Chain Splitting + +`&&` and `||` are handled by recursively checking each part: + +``` +"cd /path && ls && git status" → safe (all 3 parts are safe) +"cd /path && rm -rf /" → NOT safe (rm is not safe) +"ls || cargo build" → NOT safe (cargo build is not safe) +``` + +Mixed chains work: +``` +"cd /path && ls || pwd" → safe +"cd /path && ls || rm -rf /" → NOT safe +``` + +--- + +## Confirmation Behavior + +### Destructive Tools + +`write`, `edit`, and `run` always show a confirmation prompt: + +``` + Write to /path/to/file.rs (4194 bytes) + +Confirm? (Y)es / (N)o / (A)uto-accept future +``` + +### Auto-Accept Mode + +Toggle with `/autoaccept on` or `/autoaccept off`. + +When on (`auto_accept_safe_commands: true`): +- **ReadOnly** tools auto-execute (always, regardless of this setting) +- **Destructive** `write` and `edit` get a prompt — but pressing `A` during confirmation enables auto-accept for the rest of the session +- **Destructive** `run` is NEVER auto-accepted, even with `A` + +When off: +- All destructive tools prompt for confirmation +- No auto-accept toggle is offered + +### `run` Tool Special Rule + +The `run` tool can **never** be auto-accepted, even with `/autoaccept on` and pressing `A`. This is a hard rule — if commands are safe, they pass the safety checker and auto-execute. If they're not, you must confirm them individually. + +--- + +## Security Best Practices + +### For Users + +1. **Sandbox**: Run TinyHarness inside a Docker container or VM for untrusted models +2. **Review before confirming**: Always read the proposed command before pressing `Y` +3. **Use the deny list**: Block commands you never want auto-executed: `/command deny "git push --force"` +4. **Per-project settings**: Set `auto_accept_safe_commands: false` for sensitive projects +5. **Limit tools by mode**: Use `planning` mode for analysis, switch to `agent` only when you need destructive operations +6. **Check the audit log**: `/audit last` shows recently executed commands; `/audit session` shows all in this session + +### For Project Maintainers + +1. **Commit a `.tinyharness/config.json`** with appropriate deny patterns: + ```json + { + "denied_command_prefixes": ["git push --force", "rm -rf"], + "auto_accept_safe_commands": false + } + ``` +2. **Use project-local skills** with `disable-model-invocation: true` for sensitive procedures +3. **Keep `TINYHARNESS.md` up to date** — the better the AI understands your project, the less likely it is to propose dangerous commands + +### LLM Limitations + +- LLMs can hallucinate or misunderstand context +- They may propose commands that are technically safe but semantically dangerous (e.g. `git reset --hard` without understanding the consequences) +- **You are responsible** for all operations performed by the AI. Review everything before confirming. + +--- + +## Audit Log + +Track executed commands with `/audit`: + +| Command | Output | +|---------|--------| +| `/audit last` | Most recent command execution | +| `/audit session` | All commands executed in this session | +| `/audit clear` | Clear the audit log for this session | + +Each entry includes: +- Command text +- Tool arguments +- Execution timestamp +- Exit code +- Duration + +--- + +## Safety Checker Internals + +Located in `src/agent/safety.rs`. Key functions: + +- `is_safe_command(command, safe_list, deny_list)` — main checker +- `strip_safe_descriptor_redirections(command)` — pre-processing step + +### Edge Cases Handled + +- **Word boundaries**: `cdx` doesn't match the `cd` prefix. Must be `cd `, `cd=`, or end-of-string after the prefix. +- **Whitespace**: Leading/trailing whitespace is trimmed. +- **Empty commands**: Empty strings are safe. +- **Mixed safe/unsafe chains**: Every part of a `&&`/`||` chain must be safe. +- **Deny priority**: Deny list beats safe list, always. +- **Redirection stripping order**: Descriptor redirections are stripped before `>` and `<` checks, preventing false positives from `2>&1`. diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..cae2702 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,233 @@ +# Skills Guide + +Skills are pluggable instruction modules that give the AI specialized knowledge about tools, workflows, or conventions. They're discovered from markdown files and can be activated by the user or (optionally) by the AI itself. + +## How Skills Work + +1. **Discovery** — On startup, TinyHarness scans `~/.config/tinyharness/skills/` and `.tinyharness/skills/` for directories containing `SKILL.md` files. +2. **Injection** — When activated (by user or AI), the skill's content is injected into the system prompt, giving the AI specialized context. +3. **Deactivation** — Skills stay active until explicitly unloaded with `/unload `. + +## Skill File Format + +Each skill lives in a directory named after the skill, containing a `SKILL.md` file: + +``` +~/.config/tinyharness/skills/ +└── rust-dev/ + └── SKILL.md + +.tinyharness/skills/ +└── python-lint/ + └── SKILL.md +``` + +### SKILL.md Structure + +```markdown +--- +name: rust-dev +description: Rust development best practices and code review guidelines +argument-hint: Rust file or module to review +compatibility: rust +disable-model-invocation: false +license: MIT +metadata: + version: "1.0" + author: team-name +user-invocable: true +--- + +# Rust Development Skill + +Always run `cargo fmt` and `cargo clippy -- -D warnings` before +suggesting changes. Prefer `cargo test -- --nocapture` for debugging. + +## Code Style + +- Use Rust edition 2024 +- Prefer `impl Trait` over generics for single-use cases +- Document public APIs with doc comments +``` + +### Frontmatter Reference + +All fields are optional. Sensible defaults apply when fields are omitted. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | string | Directory name | Unique identifier for the skill. Used with `/use ` | +| `description` | string | `""` | Short description shown in `/skills` listings | +| `argument-hint` | string | — | Hint about what argument to pass (e.g. "file path to review") | +| `compatibility` | string | — | Compatibility tag (e.g. "rust", "python", "any") | +| `disable-model-invocation` | boolean | `false` | If `true`, the AI cannot auto-invoke this skill — only manual `/use` works | +| `license` | string | — | SPDX license identifier (e.g. "MIT", "Apache-2.0") | +| `metadata` | map | — | Arbitrary key-value pairs (version, author, etc.) | +| `user-invocable` | boolean | `true` | Whether users can invoke via `/use ` | + +### Frontmatter Notes + +- The `metadata` block uses indented sub-properties (two spaces): + ```yaml + metadata: + version: "1.0" + author: team + ``` +- Boolean fields accept `true` or anything else (treated as `false`) +- Unknown keys are silently ignored +- Quoting values is optional for simple strings + +## Skill Discovery + +### Personal Skills (`~/.config/tinyharness/skills/`) + +Available to all projects for the current user. Good for personal workflows and preferences. + +```bash +# Create a personal skill +mkdir -p ~/.config/tinyharness/skills/my-review-workflow +vim ~/.config/tinyharness/skills/my-review-workflow/SKILL.md +``` + +### Project Skills (`.tinyharness/skills/`) + +Project-specific skills, committed to the repository. Good for team conventions and project-specific tooling. + +```bash +# Create a project-local skill +mkdir -p .tinyharness/skills/backend-conventions +vim .tinyharness/skills/backend-conventions/SKILL.md +``` + +**Precedence**: Project skills override personal skills with the same name. If both `~/.config/tinyharness/skills/review/SKILL.md` and `.tinyharness/skills/review/SKILL.md` exist, the project version wins. + +## Invoking Skills + +### By the User + +| Command | Effect | +|---------|--------| +| `/skills` | List all available skills | +| `/skill ` | Show a skill's details and content | +| `/use ` | Activate a skill, injecting its instructions | +| `/unload ` | Deactivate a previously loaded skill | + +### By the AI + +The AI can call `invoke_skill` with the skill name. This is allowed unless `disable-model-invocation: true` is set in the skill's frontmatter. + +Example in the system prompt (auto-generated from the skill registry): +``` +- **review**: Code review and analysis skill (arg: file path or diff to review) [any] _(manual invocation only)_ +``` + +### Active Skills + +Multiple skills can be active simultaneously. Each skill's content is injected into the system prompt with a header: + +``` +## Active Skill: rust-dev + +Rust development best practices and code review guidelines + +--- +Skill instructions: +... +``` + +Skills are listed in the available tools section as well, so the AI knows what's available even before invocation. + +## Content Truncation + +Skills longer than 10,000 characters are truncated to prevent overwhelming the AI's context window: + +- 70% of the content from the **head** is kept +- 30% from the **tail** is kept +- A truncation notice is inserted in between + +``` +[...truncated skill 'my-skill': showing first 7000 + last 3000 chars. Use the read tool to view the full file.] +``` + +Keep skills concise. Use the `read` tool to load detailed reference material when needed. + +## Examples + +### Minimal Skill + +```markdown +# TypeScript Conventions + +Always use `const` over `let`. Prefer arrow functions. +``` + +No frontmatter needed. The skill name defaults to the directory name. + +### Model-Restricted Skill + +```markdown +--- +name: secret-ops +description: Internal deployment procedures +disable-model-invocation: true +user-invocable: false +--- + +# Secret Operations + +These procedures are for human operators only. Never share with automated tools. +``` + +This skill can never be invoked by the AI (`disable-model-invocation: true`) and can't even be activated by the user with `/use` (`user-invocable: false`). It exists purely as documentation accessible via `/skill secret-ops`. + +### Project-Specific Skill (Team Shared) + +```markdown +--- +name: backend-standards +description: Backend code review and development standards +compatibility: rust +metadata: + version: "2.1" + last-reviewed: "2026-01-15" +--- + +# Backend Standards + +## PR Checklist +1. `cargo fmt --all -- --check` passes +2. `cargo clippy --workspace -- -D warnings` clean +3. All tests pass: `cargo test --workspace` +4. New public APIs have doc comments +5. No new dependencies without team discussion + +## Architecture +- Keep `tinyharness-lib` free of terminal I/O +- Prefer `Pin>` over `async-trait` +- Use `Result` for user-facing errors +``` + +Place this in `.tinyharness/skills/backend-standards/SKILL.md`, commit to the repo, and the whole team gets it. + +## System Prompt Integration + +When skills are active, the system prompt is rebuilt from scratch: + +1. Shared header (`header.md`) +2. Mode-specific prompt (`agent.md`, `planning.md`, etc.) +3. Project context (language, build/test commands) +4. Project instructions (TINYHARNESS.md and additional files) +5. **Active skill content** ← injected here +6. Available tools and skill index + +Prompts are refreshed on mode switch, skill activation/unload, file pinning changes, and `/refresh`. + +## Best Practices + +1. **Keep skills focused**: One concern per skill. "Database conventions" and "Deployment procedures" should be separate. +2. **Use metadata**: Track version and last-review dates for team skills. +3. **Set compatibility tags**: Helps the AI decide when to auto-invoke a skill. +4. **Disable model invocation for sensitive content**: Use `disable-model-invocation: true` if the skill contains procedures that need human judgment. +5. **Truncate large skills intentionally**: Write the most important 7,000 characters first. Put reference tables, examples, and edge cases in the tail. +6. **Project skills for teams**: Commit `.tinyharness/skills/` to the repository for shared conventions. +7. **Personal skills for preferences**: Keep personal workflow preferences in `~/.config/tinyharness/skills/`. diff --git a/docs/tools-reference.md b/docs/tools-reference.md new file mode 100644 index 0000000..9afb204 --- /dev/null +++ b/docs/tools-reference.md @@ -0,0 +1,237 @@ +# Tools Reference + +TinyHarness provides 15 tools across three categories. Each tool has a JSON Schema that the AI uses to construct valid calls. + +## Tool Categories + +| Category | Behavior | Tools | +|----------|----------|-------| +| **ReadOnly** | Execute immediately, no confirmation needed | `ls`, `read`, `grep`, `glob`, `web_search`, `web_fetch` | +| **Destructive** | Require user confirmation before execution | `write`, `edit`, `run` | +| **Signal** | Handled specially by the agent loop (not generic execution) | `switch_mode`, `question`, `auto_compact`, `invoke_skill`, `screenshot` | + +### Auto-Execution Rules + +- **ReadOnly tools** run immediately in all modes +- **Destructive tools** prompt for confirmation (Yes/No/Auto-accept for all) + - `run` can **never** be auto-accepted in auto-accept mode (only `write`/`edit` can) + - Safe commands within `run` may still auto-accept if `/autoaccept` is on and the command passes safety checks +- **Signal tools** are intercepted by the agent loop before reaching generic tool execution + +### Mode Filtering + +| Mode | Available Tools | +|------|----------------| +| **casual** | `web_search`, `web_fetch` | +| **planning** | All ReadOnly + all Signal tools (no destructive) | +| **agent** | All 15 tools | +| **research** | All ReadOnly + all Signal tools (same as planning, different prompt) | + +--- + +## File System Tools + +### `ls` — List Directory + +**Category**: ReadOnly | **Auto-executes**: Yes + +Lists the contents of a single directory. Returns newline-separated file and directory names. Does not recurse — use `glob` for recursive searches. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `path` | Yes | The directory path to list | + +### `read` — Read File + +**Category**: ReadOnly | **Auto-executes**: Yes + +Reads file content with optional line ranges. For image files (png, jpg, webp, gif, bmp), returns a description and the image data is automatically loaded for the model to view. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `path` | Yes | The absolute path to the file | +| `from` | No | Starting line number (0-based, inclusive) | +| `to` | No | Number of lines to read (only valid when `from` is set) | + +### `write` — Write File + +**Category**: Destructive | **Auto-executes**: Only in auto-accept mode + +Writes content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories automatically. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `path` | Yes | The absolute path to write | +| `content` | Yes | The text content to write | + +Confirmation prompt shows the path and content preview. Use for new files or complete rewrites. For targeted edits, prefer `edit`. + +### `edit` — Edit File + +**Category**: Destructive | **Auto-executes**: Only in auto-accept mode + +Edits a file by finding an exact string and replacing it with new text. The `old_str` must appear exactly once in the file. Use for targeted changes. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `path` | Yes | The absolute path to the file | +| `old_str` | Yes | The exact string to find (must appear exactly once) | +| `new_str` | Yes | The replacement string | + +If `old_str` appears multiple times or not at all, the edit fails with an error. + +### `grep` — Search with Regex + +**Category**: ReadOnly | **Auto-executes**: Yes + +Searches for a regex pattern across files. Returns matching lines with file paths and line numbers. Skips hidden directories, `node_modules`, `target`, and binary files. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `pattern` | Yes | The regex pattern to search for | +| `path` | No | The directory to search (defaults to project root) | +| `include` | No | Filter by file extension (e.g. `.rs` for Rust) | + +### `glob` — Find Files by Pattern + +**Category**: ReadOnly | **Auto-executes**: Yes + +Finds files by glob pattern. Returns sorted results. Use this instead of `find` or recursive `ls`. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `pattern` | Yes | The glob pattern (e.g. `**/*.rs`, `**/Cargo.toml`) | +| `max_results` | No | Maximum results to return (default: 100) | + +### `run` — Execute Shell Command + +**Category**: Destructive | **Auto-executes**: **Never** + +Executes a shell command and returns its output (stdout, stderr, exit code, duration). Output is truncated at 5,000 chars for stdout and 2,000 for stderr. Default timeout is 30 seconds. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `command` | Yes | The shell command to execute | +| `timeout` | No | Timeout in milliseconds (default: 30000) | +| `cwd` | No | Working directory (default: project root) | + +Commands are checked against the safe/denied prefix lists. Shell metacharacters (`;`, `&`, `|`, `$()`, backticks, newlines) are rejected. Safe descriptor redirections (`2>&1`, `2>/dev/null`) are stripped before matching. + +--- + +## Web Tools + +### `web_search` — Search the Web + +**Category**: ReadOnly | **Auto-executes**: Yes + +Searches the web using Ollama's cloud API. Requires an Ollama API key set via `/apikey`. Returns titles, URLs, and content snippets. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `query` | Yes | The search query string | +| `max_results` | No | Maximum results (default: 5, max: 10) | + +### `web_fetch` — Fetch Web Page + +**Category**: ReadOnly | **Auto-executes**: Yes + +Fetches a specific web page by URL. Returns the page title, main content, and links found on the page. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `url` | Yes | The URL to fetch | + +--- + +## Signal Tools + +Signal tools are **not executed generically**. The agent loop intercepts them and handles them inline. + +### `switch_mode` — Switch Agent Mode + +Requests a mode switch. Modes control which tools are available. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `mode` | Yes | Target mode: `casual`, `planning`, `agent`, or `research` | + +The agent loop immediately refreshes the system prompt and toolset. Conversation history is preserved. + +### `question` — Ask the User + +Asks the user a question with predefined answer options. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `question` | Yes | The question to ask | +| `answers` | Yes | Array of answer options (at least one) | + +The agent loop presents a numbered list. User selects by number or text. The answer becomes the tool result. + +### `auto_compact` — Compact Conversation + +Requests conversation compaction to free context space. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `focus` | No | Topics, decisions, or details to preserve in the summary | + +For up to 200 intermediate messages: single-pass compaction. For larger sessions: cascading multi-stage (chunk, then per-stage summaries, then merged final summary). + +### `invoke_skill` — Activate a Skill + +Activates a skill by name, injecting its instructions into the system prompt. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `skill_name` | Yes | The exact skill name from the available skills list | + +The skill stays active until unloaded with `/unload `. Skills with `disable-model-invocation: true` are marked as "manual invocation only". + +### `screenshot` — Request Screenshot + +Requests the user to take a screenshot and attach it. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `description` | Yes | What you want the user to capture | + +The agent loop prompts the user to attach an image with `/image` and reply. User can skip if unable. + +--- + +## Tool Schema Format + +Tools use JSON Schema to describe their parameters. The schema is sent to the provider, enabling the model to construct valid tool calls. + +All parameters are passed as strings to keep parsing simple. For non-string types (booleans, numbers, arrays), the string is coerced by the tool handler. + +### Tool Call Format + +The model emits tool calls in XML block format with `tool_calls` and `invoke` elements. Multiple tools can be called in a single block. Results are batched into a single response message. + +--- + +## Adding a Custom Tool + +While the binary crate registers tools via `ToolManager::register_defaults()`, the `tinyharness-lib` API allows registering additional tools programmatically: + +```rust +use tinyharness_lib::tools::tool::{make_tool, build_string_params_schema, ToolCategory, require_arg}; + +let tool = make_tool( + "echo", + "Echo a message back", + ToolCategory::ReadOnly, + build_string_params_schema(&[("text", "The text to echo")], &[]), + |args| Box::pin(async move { + let text = require_arg(&args, "text").unwrap(); + format!("You said: {}", text) + }), +); + +manager.register_tool(tool); +``` + +Custom tools are uncommon — most users extend functionality via skills rather than writing Rust code. From 996d1e4bd7b63db7151a6e7e834859552307618c Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 4 Jun 2026 12:33:46 +0200 Subject: [PATCH 3/4] chore: add CONTRIBUTING.md at repo root GitHub picks up CONTRIBUTING.md for the 'Contributing' banner. Full contributor guide in docs/contributing.md. --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f384c68 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +TinyHarness is a Rust workspace with three crates. See the full guide at [docs/contributing.md](docs/contributing.md). + +## Quick Start + +```bash +git clone https://github.com/yourusername/TinyHarness.git +cd TinyHarness +cargo build --workspace +cargo test --workspace +``` + +## Verification Checklist + +Before submitting a PR: + +```bash +cargo fmt --all +cargo clippy --workspace -- -D warnings +cargo test --workspace +cargo build +``` + +## Docs + +User-facing docs are in `docs/`. Developer docs (this file) are in `docs/contributing.md`. Enhancement tracking is in `todo/` (local only, not committed). + +## License + +MIT — see [LICENSE](LICENSE). From 877d8f24001013d45969902fbb3e18bc110c350f Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 4 Jun 2026 12:57:56 +0200 Subject: [PATCH 4/4] fix: bump version to 0.1.2, resolve clippy lints and formatting --- Cargo.lock | 6 ++-- Cargo.toml | 6 ++-- src/commands/project_settings.rs | 10 ++---- tinyharness-lib/Cargo.toml | 2 +- tinyharness-lib/src/config/mod.rs | 31 ++++++++++------- tinyharness-lib/src/context.rs | 55 ++++++++++++++++++++----------- tinyharness-lib/src/lib.rs | 5 +-- tinyharness-ui/Cargo.toml | 4 +-- 8 files changed, 68 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d35cbd3..0b3d0d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "TinyHarness" -version = "0.1.1" +version = "0.1.2" dependencies = [ "chrono", "clap", @@ -2005,7 +2005,7 @@ dependencies = [ [[package]] name = "tinyharness-lib" -version = "0.1.1" +version = "0.1.2" dependencies = [ "glob", "ollama-rs", @@ -2023,7 +2023,7 @@ dependencies = [ [[package]] name = "tinyharness-ui" -version = "0.1.1" +version = "0.1.2" dependencies = [ "regex", "rustyline", diff --git a/Cargo.toml b/Cargo.toml index 38f92ae..58babd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "tinyharness-lib", "tinyharness-ui"] [package] name = "TinyHarness" -version = "0.1.1" +version = "0.1.2" license = "MIT" description = "tinyharness - ai coding harness" edition = "2024" @@ -13,8 +13,8 @@ name = "tinyharness" path = "src/main.rs" [dependencies] -tinyharness-lib = { version = "0.1.1", path = "tinyharness-lib" } -tinyharness-ui = { version = "0.1.1", path = "tinyharness-ui" } +tinyharness-lib = { version = "0.1.2", path = "tinyharness-lib" } +tinyharness-ui = { version = "0.1.2", path = "tinyharness-ui" } clap = { version = "4.6.1", features = ["derive"] } tokio = { version = "1.52.1", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/commands/project_settings.rs b/src/commands/project_settings.rs index f5c4987..feb3cca 100644 --- a/src/commands/project_settings.rs +++ b/src/commands/project_settings.rs @@ -26,10 +26,7 @@ fn execute_show(out: &mut Output) { let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let project_config = discover_project_settings(&cwd); - let has_project = match &project_config { - Some(Ok(_)) => true, - _ => false, - }; + let has_project = matches!(&project_config, Some(Ok(_))); let (_, _, merged) = load_merged_settings(); @@ -203,10 +200,7 @@ fn execute_init(out: &mut Output) { let stripped = strip_json_comments(json); if let Err(e) = std::fs::write(&config_path, &stripped) { - let _ = writeln!( - out, - "{RED}Failed to write config file: {e}{RESET}" - ); + let _ = writeln!(out, "{RED}Failed to write config file: {e}{RESET}"); return; } diff --git a/tinyharness-lib/Cargo.toml b/tinyharness-lib/Cargo.toml index 84ea635..336c5df 100644 --- a/tinyharness-lib/Cargo.toml +++ b/tinyharness-lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinyharness-lib" -version = "0.1.1" +version = "0.1.2" license = "MIT" description = "core liblary for tinyharness" edition = "2024" diff --git a/tinyharness-lib/src/config/mod.rs b/tinyharness-lib/src/config/mod.rs index d62b190..15feb57 100644 --- a/tinyharness-lib/src/config/mod.rs +++ b/tinyharness-lib/src/config/mod.rs @@ -39,7 +39,9 @@ pub struct ProjectSettings { /// /// Returns `None` if no config file is found. Returns `Some(Err(...))` if /// a file is found but cannot be parsed. -pub fn discover_project_settings(start_dir: &std::path::Path) -> Option> { +pub fn discover_project_settings( + start_dir: &std::path::Path, +) -> Option> { let mut dir = start_dir.to_path_buf(); loop { @@ -121,7 +123,9 @@ pub fn load_merged_settings() -> (Settings, Option, MergedSetti let project = match discover_project_settings(&cwd) { Some(Ok(ps)) => Some(ps), Some(Err(e)) => { - tracing::warn!("Failed to parse .tinyharness/config.json: {e}. Ignoring project settings."); + tracing::warn!( + "Failed to parse .tinyharness/config.json: {e}. Ignoring project settings." + ); None } None => None, @@ -178,20 +182,24 @@ fn merge_settings(global: &Settings, project: Option<&ProjectSettings>) -> Merge (global_denied, SettingSource::Default) }; - let (auto_accept, auto_source) = p.auto_accept_safe_commands + let (auto_accept, auto_source) = p + .auto_accept_safe_commands .map(|v| (v, SettingSource::Project)) .unwrap_or((global.auto_accept_safe_commands, SettingSource::Default)); - let (context_limit, ctx_source) = p.context_limit + let (context_limit, ctx_source) = p + .context_limit .map(|v| (Some(v), SettingSource::Project)) .unwrap_or((global.context_limit, SettingSource::Default)); - let (project_md_files, md_source) = p.project_md_files + let (project_md_files, md_source) = p + .project_md_files .as_ref() .map(|files| (files.clone(), SettingSource::Project)) .unwrap_or((Vec::new(), SettingSource::Default)); - let (preferred_mode, mode_source) = p.preferred_mode + let (preferred_mode, mode_source) = p + .preferred_mode .map(|m| (m, SettingSource::Project)) .unwrap_or((global.preferred_mode, SettingSource::Default)); @@ -590,12 +598,11 @@ pub fn resolve_project_md_files(settings: Option<&Settings>) -> Vec { } // 2. Settings override - if let Some(s) = settings { - if let Some(ref configured) = s.project_md_files { - if !configured.is_empty() { - return configured.clone(); - } - } + if let Some(s) = settings + && let Some(ref configured) = s.project_md_files + && !configured.is_empty() + { + return configured.clone(); } // 3. Hardcoded defaults diff --git a/tinyharness-lib/src/context.rs b/tinyharness-lib/src/context.rs index 2d3d91b..b3cea64 100644 --- a/tinyharness-lib/src/context.rs +++ b/tinyharness-lib/src/context.rs @@ -65,13 +65,12 @@ impl WorkspaceContext { let project_md = discover_project_md(&root, &md_files); // Load additional MD files from per-project .tinyharness/config.json - let additional_project_mds = if let Some(Ok(proj)) = - crate::config::discover_project_settings(&root) - { - load_additional_md_files(&root, proj.project_md_files.as_deref()) - } else { - Vec::new() - }; + let additional_project_mds = + if let Some(Ok(proj)) = crate::config::discover_project_settings(&root) { + load_additional_md_files(&root, proj.project_md_files.as_deref()) + } else { + Vec::new() + }; WorkspaceContext { root, @@ -119,7 +118,9 @@ impl WorkspaceContext { // Append additional project MD files for (filename, content) in &self.additional_project_mds { - lines.push(format!("\n---\n# Additional Instructions (from {filename})\n")); + lines.push(format!( + "\n---\n# Additional Instructions (from {filename})\n" + )); lines.push(content.clone()); } @@ -129,7 +130,9 @@ impl WorkspaceContext { /// Load additional project instruction files specified in per-project config. fn load_additional_md_files(root: &Path, files: Option<&[String]>) -> Vec<(String, String)> { - let Some(files) = files else { return Vec::new() }; + let Some(files) = files else { + return Vec::new(); + }; files .iter() .filter_map(|name| { @@ -230,7 +233,12 @@ const LANGUAGE_SIGNATURES: &[LanguageSignature] = &[ }, LanguageSignature { label: "Python", - markers: &["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"], + markers: &[ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + ], build_cmd: "pip install -e .", test_cmd: "pytest", }, @@ -288,10 +296,10 @@ fn detect_project_type(root: &Path) -> String { if found.contains(label) { continue; } - if let Ok(entries) = glob::glob(&root.join(pattern).to_string_lossy()) { - if entries.flatten().next().is_some() { - found.push(label); - } + if let Ok(entries) = glob::glob(&root.join(pattern).to_string_lossy()) + && entries.flatten().next().is_some() + { + found.push(label); } } @@ -337,7 +345,10 @@ fn detect_project_name(root: &Path, project_type: &str) -> String { }; // Handle "Rust + Node.js" style monorepo labels - let primary = project_type.split_once(" + ").map(|(a, _)| a).unwrap_or(project_type); + let primary = project_type + .split_once(" + ") + .map(|(a, _)| a) + .unwrap_or(project_type); match primary { "Rust" => { @@ -437,7 +448,10 @@ fn list_top_level(root: &Path) -> Vec { fn detect_commands(project_type: &str, root: &Path) -> (&'static str, &'static str) { // Handle monorepo labels: use the first detected type's commands - let primary = project_type.split_once(" + ").map(|(a, _)| a).unwrap_or(project_type); + let primary = project_type + .split_once(" + ") + .map(|(a, _)| a) + .unwrap_or(project_type); for sig in LANGUAGE_SIGNATURES { if sig.label == primary { @@ -525,7 +539,10 @@ mod tests { /// Helper: the default discovery list as owned Strings for tests. fn default_md_names() -> Vec { - DEFAULT_PROJECT_MD_FILE_NAMES.iter().map(|s| s.to_string()).collect() + DEFAULT_PROJECT_MD_FILE_NAMES + .iter() + .map(|s| s.to_string()) + .collect() } /// Helper: custom discovery list for override tests. @@ -750,9 +767,7 @@ mod tests { build_command: "cargo build".to_string(), test_command: "cargo test".to_string(), project_md: None, - additional_project_mds: vec![ - ("RULES.md".to_string(), "# Custom Rules".to_string()), - ], + additional_project_mds: vec![("RULES.md".to_string(), "# Custom Rules".to_string())], }; let formatted = ctx.format(); diff --git a/tinyharness-lib/src/lib.rs b/tinyharness-lib/src/lib.rs index 338dba0..b8f714d 100644 --- a/tinyharness-lib/src/lib.rs +++ b/tinyharness-lib/src/lib.rs @@ -11,8 +11,9 @@ pub mod tools; // Re-export key types at crate root for convenience pub use config::{ MergedSettings, ProjectSettings, ProviderKind, SettingSource, Settings, SettingsError, - SettingsStore, discover_project_settings, ensure_prompts_initialized, generate_project_config_template, - load_merged_settings, load_settings, prompts_dir, resolve_project_md_files, save_settings, + SettingsStore, discover_project_settings, ensure_prompts_initialized, + generate_project_config_template, load_merged_settings, load_settings, prompts_dir, + resolve_project_md_files, save_settings, }; pub use context::WorkspaceContext; pub use image::ImageAttachment; diff --git a/tinyharness-ui/Cargo.toml b/tinyharness-ui/Cargo.toml index 81192c8..3fec297 100644 --- a/tinyharness-ui/Cargo.toml +++ b/tinyharness-ui/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "tinyharness-ui" -version = "0.1.1" +version = "0.1.2" license = "MIT" description = "ui liblary for tinyharness" edition = "2024" [dependencies] -tinyharness-lib = { version = "0.1.1", path = "../tinyharness-lib" } +tinyharness-lib = { version = "0.1.2", path = "../tinyharness-lib" } rustyline = { version = "18.0.0", features = ["derive"] } serde_json = "1.0.149" regex = "1.11.1"