diff --git a/AGENTS.MD b/AGENTS.MD index d86ef455..c953942f 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -315,6 +315,5 @@ That's it. Run `zero` from the repo root and the agent has the team's full instr - [README](README.md) — install, quickstart, command reference. - [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec. -- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface. - [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract. - [docs/INSTALL.md](docs/INSTALL.md) — install from source or release. diff --git a/CHANGELOG.md b/CHANGELOG.md index e755be62..93ae2c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,5 +33,4 @@ tagged. Until then, source builds report the version `dev`. - The inert `/input-style` slash command (it had no backend). ### Fixed -- README/`go.mod` Go-version mismatch and other stale docs claims surfaced by the product/UX audit - (`docs/audit/2026-06-21-product-ux-audit.md`). +- README/`go.mod` Go-version mismatch and other stale public-release docs claims. diff --git a/README.md b/README.md index 06bac1a0..671c9b8c 100644 --- a/README.md +++ b/README.md @@ -1,364 +1,289 @@ -
+

+ Zero +

-``` -███████╗ ███████╗ ██████╗ ██████╗ -╚══███╔╝ ██╔════╝ ██╔══██╗ ██╔═══██╗ - ███╔╝ █████╗ ██████╔╝ ██║ ██║ - ███╔╝ ██╔══╝ ██╔══██╗ ██║ ██║ -███████╗ ███████╗ ██║ ██║ ╚██████╔╝ -╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ +

A terminal coding agent you own.

+ +

+ license + Go 1.25+ + 25+ providers +

+ +Zero is an AI coding agent for your local terminal. It can inspect a repository, +edit files, run commands, use browser/terminal helpers, and keep durable local +sessions while you choose the model and the permission level. + +```bash +zero +zero exec "fix the failing test in ./pkg" +zero exec --output-format stream-json < turns.jsonl ``` -### The terminal coding agent you fully own. +## Why Zero -**Any model. Any provider. Your rules.** +- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter, + DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any + OpenAI-/Anthropic-compatible endpoint. +- **Stay in control.** File writes, shell commands, network access, and + out-of-workspace writes go through Zero's permission and sandbox policy. +- **Works in the terminal.** The TUI has model/provider pickers, image input, + slash commands, live plan/tool rendering, scrollback, themes, and resume/fork + support. +- **Works without the TUI.** `zero exec` is scriptable, supports text/JSON/ + stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit + codes for CI. +- **Keeps context local.** Sessions are stored on disk, searchable, resumable, + and never uploaded as telemetry by Zero. +- **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and + specialist subagents from the same CLI. + +## Install + +### npm -![go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white) -![providers](https://img.shields.io/badge/providers-25%2B-34E2EA) -![tests](https://img.shields.io/badge/test%20files-200%2B-43D17A) -![status](https://img.shields.io/badge/status-active%20development-E8B84B) -[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +```bash +npm install -g @gitlawb/zero +zero +``` -
+The npm package installs a small wrapper plus the matching Zero binary for your +platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and +arm64. ---- +### Install scripts -Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home. +Linux/macOS: ```bash -zero # interactive TUI -zero exec "fix the failing test in ./pkg" # headless one-shot -zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI +curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash ``` -> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**. +Windows PowerShell: -## Why Zero +```powershell +irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex +``` -- 🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`. -- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything. -- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI. -- 🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process. -- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions. -- 📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`). -- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations. -- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule. -- 🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in. -- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk. -- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server). +### From source -## Quick start +Source builds require Go 1.25+. ```bash -# run from source (requires Go 1.25+) +git clone https://github.com/Gitlawb/zero.git +cd zero go run ./cmd/zero ``` -Or, once the first release is published, install the prebuilt binary via npm — the wrapper -downloads the right binary for your platform: +Release installers and the npm wrapper require published GitHub Release assets. +If you are testing before the first public release, build from source: ```bash -npm install -g @gitlawb/zero -zero +go build -o zero ./cmd/zero ``` -> **Pre-built binaries are coming soon.** The npm package and the `scripts/install.sh` -> (Linux/macOS) / `scripts/install.ps1` (Windows) installers all download GitHub Release -> assets, which require the first release to be published. Until then, build from source with -> `go run ./cmd/zero` (or `go build -o zero ./cmd/zero`). +More install details: [docs/INSTALL.md](docs/INSTALL.md). + +## First Run -First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively: +Start the TUI: ```bash -export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, ... -zero setup # guided first-run provider setup -zero doctor # verify config, keys, and connectivity +zero ``` -Local models need no key at all: +The setup wizard helps you pick a provider and model. You can also configure +providers from the command line: ```bash -# Ollama or LM Studio running locally? Zero finds them. +zero setup zero providers list +zero models list +zero doctor ``` -## The TUI +For API providers, set the matching environment variable before setup or enter +the key in the wizard: -Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cycles permission modes, **Ctrl+C** exits. +```bash +export OPENAI_API_KEY=sk-... +export ANTHROPIC_API_KEY=... +export GEMINI_API_KEY=... +``` -| | | -|---|---| -| `/model` `/provider` | switch model or provider mid-session (searchable picker) | -| `/spec` `/plan` | spec-mode drafting and live plan view | -| `/image` | attach images for vision models | -| `/resume` `/rewind` | time-travel across sessions | -| `/compact` `/context` | manage the context window | -| `/permissions` `/tools` | inspect what the agent can touch | -| `/add-dir` | grant an extra write directory for the session, or list current write roots | -| `/theme` `/style` | make it yours | -| `/doctor` `/config` | health and config without leaving the chat | +For local models, run Ollama or LM Studio and then use `zero setup` or +`zero providers detect`. + +## Daily Use -Turn-completion notifications (terminal bell / OSC-9) ping you when the agent finishes or needs input — go make coffee. +### Interactive TUI -## Accessibility & appearance +```bash +zero +``` -Zero ships sensible defaults and a few environment/command controls. Meaning never depends on color -alone — diffs carry `+`/`−` signs, permission outcomes use `✓`/`✗` glyphs plus a text `PERMISSION` -badge, and permission modes show text labels — so the UI stays readable when color is stripped. +Useful controls: -| Control | What it does | +| Control | Action | |---|---| -| `NO_COLOR` (any non-empty value) | Disables color, per [no-color.org](https://no-color.org). Zero honors **any** non-empty value (`NO_COLOR=1`, `yes`, `true`, …); bold/underline still render. | -| `ZERO_THEME=auto\|dark\|light` | Selects the palette at startup. `auto` (default) detects the terminal background. | -| `--theme auto\|dark\|light` | Same as `ZERO_THEME`, as a launch flag (takes precedence over the env var). | -| `/theme [auto\|dark\|light]` | Switches the palette live inside the TUI. | -| `ZERO_NO_FADE=1` | Reduce-motion opt-out: disables the streaming-text fade animation. Fade also auto-disables over SSH/tmux and on low-color/no-TTY terminals. | +| `Enter` | send the prompt | +| `/` | open slash-command suggestions | +| `Shift+Tab` | cycle permission mode | +| `Ctrl+B` | show/hide the sidebar | +| `Ctrl+C` | cancel or exit | -## Headless `exec` +Common slash commands: -```bash -# one-shot -zero exec "explain internal/agent/loop.go and suggest one improvement" +| Command | Purpose | +|---|---| +| `/model`, `/provider` | switch the active model/provider | +| `/spec`, `/plan` | draft and review a plan before building | +| `/image` | attach an image for vision-capable models | +| `/resume`, `/rewind` | continue or roll back local sessions | +| `/compact`, `/context` | manage context usage | +| `/permissions`, `/tools` | inspect available tools and policy | +| `/add-dir` | allow an extra write directory for this session | +| `/theme`, `/doctor`, `/config` | adjust appearance and inspect setup | -# pick a model and mode preset per task -zero exec --model claude-sonnet-4.5 --mode deep "refactor the session store" +### Headless `exec` -# spec-first: draft → review → approve → build +```bash +zero exec "explain internal/agent/loop.go" +zero exec --model claude-sonnet-4.5 "refactor the config loader" zero exec --use-spec "add rate limiting to the API client" +zero exec --worktree "try the migration in an isolated worktree" +zero exec --resume +zero exec --fork "try the other approach" +``` -# run in an isolated git worktree, escalate model only if needed -zero exec -w --allow-escalation "migrate the config loader to v2" +Programmatic use: -# multi-turn programmatic I/O over stdio +```bash zero exec --input-format stream-json --output-format stream-json < turns.jsonl - -# resume or fork any previous session -zero exec --resume # latest -zero exec --fork "now try the other approach" ``` -Key flags: `-m/--model` · `--mode ` · `--image` · `--use-spec` · `--auto ` · `--enabled-tools/--disabled-tools` · `-w/--worktree` · `--add-dir ` (repeatable) · `--resume/--fork` · `--allow-escalation` · `--notify` · `-o `. - -stdout carries **only** program output; logs go to stderr. Full contract in [`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md). - -## Commands +The stream-JSON contract is documented in +[docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md). -``` -zero interactive TUI -zero exec one-shot / scripted agent runs -zero setup guided first-run provider setup -zero models model registry (capabilities, context, cost) -zero providers provider profiles + 25-provider catalog -zero doctor config, key, and connectivity health checks -zero context workspace context-budget report -zero repo-map deterministic repository map for agent context -zero repo-info local (network-free) repository characterizer -zero search | find full-text search over local session history -zero sessions session lineage inspection -zero spec review & approve saved spec-mode drafts -zero specialist manage subagent profiles -zero skills markdown instruction skills -zero plugins plugin manifests -zero hooks lifecycle hook configuration -zero mcp MCP client settings -zero serve --mcp expose Zero's tools over MCP stdio -zero sandbox sandbox policy & persistent grants -zero worktrees isolated git worktrees for agent runs -zero verify detect & run local verification checks -zero changes inspect & commit local git changes -zero usage token usage and estimated cost -zero cron scheduled agent jobs (file-backed, dep-free) -zero update check for newer releases -``` +## Safety Model -## Providers +Zero is designed to make side effects visible. -Bring your own key — or no key at all for local runtimes. +- Workspace reads are allowed by default. +- File writes are limited to the workspace unless you grant another directory. +- Shell commands, network access, destructive commands, and elevated actions are + permission-gated. +- `--add-dir ` and `/add-dir ` grant additional write roots without + giving the agent the whole filesystem. +- Unsafe/autonomous modes are explicit opt-ins. +- Secrets are redacted from tool output and logs where Zero controls the surface. -| Tier | Providers | -|---|---| -| **Frontier APIs** | OpenAI · Anthropic · Google Gemini | -| **Fast cloud inference** | Groq · OpenRouter · Together AI · DeepSeek · Mistral · xAI · NVIDIA NIM | -| **Local — no key, no cloud** | Ollama · LM Studio | -| **More clouds** | Ollama Cloud · DashScope (Qwen) · Moonshot (Kimi) · MiniMax · Z.ai · Venice · GitHub Models · and more | -| **Enterprise (catalog)** | Amazon Bedrock · Vertex AI *(adapters in progress)* | -| **Anything else** | any OpenAI-compatible or Anthropic-compatible endpoint | - -The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves. - -**Slow or stalling streams.** Every provider aborts a stream that goes completely silent (no data and no keep-alive) for `ZERO_STREAM_IDLE_TIMEOUT` (default **5 minutes**) so a hung connection can't block the agent forever. SSE keep-alives reset the timer, so a heartbeating-but-slow backend is never killed. If a slow cloud or reasoning model still trips it, raise the limit — `ZERO_STREAM_IDLE_TIMEOUT=10m` (accepts a Go duration or bare seconds; `0`/`off` disables the watchdog). - -## Tools - -| Tool | Purpose | Side effect | -|---|---|---| -| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read | -| `web_fetch` | fetch docs & references | network | -| `update_plan` · `ask_user` | plan & clarify | none | -| `write_file` · `edit_file` · `apply_patch` | create & modify | write (gated) | -| `exec_command` · `write_stdin` | run shell commands, keep long-running processes alive, poll/send input | shell (gated on start/input) | -| `bash` | run one-shot legacy shell commands | shell (gated) | -| `Task` · `TaskOutput` · `TaskStop` | delegate to specialist subagents | per-tool gating | -| `GenerateSpecialist` | create new subagent manifests | write (gated) | -| `skill` | load markdown instruction skills | read | -| `tool_search` | lazily load deferred tools (large MCP sets stay cheap) | none | -| `escalate_model` | request a stronger model mid-run | gated by `--allow-escalation` | - -Every mutating tool routes through the permission policy **before** any side effect. - -### Web search & scraping (free, no API key) - -`web_fetch` is built in and always available: it pulls a single public remote URL -into clean markdown locally, with no third party. For localhost/private dev-server -URLs, use shell commands such as `curl` through `exec_command` so sandbox network -permission applies. For local dev servers, start the server in the foreground with -`exec_command` and keep the returned `session_id`; use `write_stdin` to poll or -interrupt it. Use `/ps` to list running background terminals and `/stop` or -`/stop ` to close them. Interactive terminal-style commands can set -`tty: true` on `exec_command` so stdin works like a terminal session. - -For **search**, JS-rendered scraping, -whole-site crawls, PDF→markdown, and structured extraction, Zero ships the -[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so -web search and scraping work out of the box with **no setup and no API key** (1,000 -free credits/month). It is a normal MCP server, so you stay in control: +Example: ```bash -zero mcp tools list # see the firecrawl_* tools it exposes -zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor +zero --add-dir ../docs-site +zero exec --add-dir ../shared "update both repos" ``` -- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you - prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on - `web_fetch`, or self-host below. -- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your - own instance and override the default URL — Zero only *calls* it over the network, - so the AGPL never reaches into Zero's own code: - ```jsonc - // config.json - { "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } } - ``` -- **Bring your own key (higher limits):** add an auth header over the default: - ```bash - zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \ - --header "Authorization: Bearer fc-your-key" - ``` - -An MCP server that can't be reached at startup (e.g. Firecrawl on an offline -machine) is **skipped with a warning, not fatal** — Zero still launches and the -rest of its tools, including `web_fetch`, keep working. - -### Extra write directories (`--add-dir`) - -Zero confines writes to the workspace by default. To let the agent write somewhere -else, pass the repeatable `--add-dir` flag — it works for both the interactive TUI -and `zero exec`: +Sandbox behavior can be inspected with: ```bash -zero --add-dir ~/Desktop/scratch # launch the TUI with an extra write root -zero exec --add-dir ../sibling-repo "update both repos" +zero sandbox policy +zero sandbox grants list ``` -In the TUI, `/add-dir ` grants a directory mid-session (session-only), and a -bare `/add-dir` lists the current write roots. To persist extra roots across -sessions, set `sandbox.additionalWriteRoots` in the **global** user config -(`~/.config/zero/config.json`); the key is deliberately ignored in project config -so a checked-out repo can't widen its own sandbox. Flag and config sources merge -as a union. - -Granted roots must already exist (the filesystem root is rejected), symlinks are -resolved when the grant is made, and the same per-root symlink-traversal checks -that protect the workspace apply to each extra root. Relative paths in tool calls -still resolve against the workspace only, and network and destructive-shell policy -are unchanged. A write denied outside all roots returns an error that suggests -`/add-dir`. - -Two extra hardening/diagnostic flags are available in the `sandbox` config block, -both **off by default** and safe to leave unset: - -- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a - best-effort seccomp filter that denies `AF_UNIX` socket creation in the - sandboxed command. Linux packages also continue to ship `zero-seccomp` as a - compatibility wrapper for existing direct uses; new sandbox plans do not depend - on that external wrapper. -- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt - denials and appends them to a command's stderr as a `` block - so blocked operations are visible. A no-op on OS versions that do not deliver - seatbelt denials to the queryable log. - -## Architecture +## Web And Local Control -``` - TUI (Bubble Tea) headless exec MCP server cron runner - └──────────────────────┬──────────────────────┘ - surface-agnostic agent core - (loop · typed event stream · tool registry) - ┌──────────┬──────────┬───────────┬───────────┬────────────┬──────────┐ - providers tools sessions specialist repo intel permissions - + catalog registry + search + background + workspace + sandbox - + registry + rewind tasks index + redaction -``` +Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs, +and MCP support for additional tools. -- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron. -- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable. -- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded. -- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it. +For local dev servers, use shell commands such as `curl` through `exec_command` +so the normal sandbox and permission policy applies. Long-running commands stay +attached to a background terminal session and can be listed or stopped from the +TUI. -## Project layout +The npm package also includes browser and terminal helper packages used by local +browser/terminal tools. Source builds can use the same helpers when they are on +`PATH` or configured in Zero's local-control settings. +## Common Commands + +```text +zero interactive TUI +zero exec one-shot or scripted agent run +zero setup first-run provider setup +zero auth OAuth/login helpers for supported providers +zero models model registry and capabilities +zero providers provider profiles and detection +zero doctor setup, key, and connectivity checks +zero context context-budget report +zero repo-map deterministic repository map +zero repo-info local repository summary +zero search | find search local session history +zero sessions inspect, resume, fork, and rewind sessions +zero spec manage spec-mode drafts +zero specialist manage specialist subagents +zero skills manage markdown instruction skills +zero plugins manage plugins +zero hooks manage lifecycle hooks +zero mcp manage MCP servers and tools +zero serve --mcp expose Zero tools over MCP stdio +zero sandbox inspect sandbox policy and grants +zero worktrees prepare isolated git worktrees +zero verify detect and run local verification checks +zero changes inspect and commit local git changes +zero usage token usage and estimated cost +zero cron scheduled agent jobs +zero update check for newer releases ``` -cmd/ - zero/ production CLI entrypoint - zero-release/ release builder + smoke tests - zero-perf-bench/ performance benchmarks - zero-pr-review/ deterministic PR review helper -internal/ - agent/ zeroruntime/ agent loop & runtime orchestration - cli/ command surface (exec, doctor, cron, ...) - tui/ Bubble Tea terminal interface - providers/ providercatalog/ providermodelcatalog/ - modelregistry/ capabilities, context windows, cost - tools/ read/write/edit/bash/grep/glob/patch/... - specialist/ background/ subagents + out-of-process tasks - sessions/ search/ append-only store, full-text search - repomap/ repoinfo/ workspaceindex/ contextreport/ - specmode/ cron/ skills/ plugins/ hooks/ mcp/ - sandbox/ redaction/ secrets/ safety surfaces - doctor/ providerhealth/ verify/ selfverify/ -docs/ PRD, protocols, install/update/perf -scripts/ installers -``` + +## Appearance And Accessibility + +| Control | Effect | +|---|---| +| `NO_COLOR=` | disables color output | +| `ZERO_THEME=auto|dark|light` | selects the startup theme | +| `--theme auto|dark|light` | selects the TUI theme from the CLI | +| `/theme auto|dark|light` | switches theme inside the TUI | +| `ZERO_NO_FADE=1` | disables streaming fade animation | + +Meaning does not rely on color alone; diffs, permissions, and statuses also use +text or glyph markers. ## Development ```bash -go test ./... # full test suite (200+ test files) -go run ./cmd/zero-release build # compile the release binary -go run ./cmd/zero-release smoke # smoke-test it -go run ./cmd/zero-perf-bench # perf benchmarks (docs/PERFORMANCE.md) +go test ./... +go run ./cmd/zero-release build +go run ./cmd/zero-release smoke +go run ./cmd/zero-perf-bench +``` + +Cross-compile examples: -# cross-compile +```bash go run ./cmd/zero-release build --goos linux --goarch amd64 go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe ``` ## Documentation -- [Product Requirements (PRD)](docs/PRD.md) — vision, full feature spec, roadmap -- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract -- [Specialists](docs/SPECIALISTS.md) — subagent manifests, Task tools, background state -- [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md) · [Performance](docs/PERFORMANCE.md) +- [Install](docs/INSTALL.md) +- [Update flow](docs/UPDATE.md) +- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) +- [Specialists](docs/SPECIALISTS.md) +- [GitHub Action](docs/GITHUB_ACTION.md) +- [Benchmarks](docs/BENCHMARK.md) +- [Performance](docs/PERFORMANCE.md) +- [Agent evals](docs/AGENT_EVALS.md) ## Contributing -Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go test ./...` and the relevant build or smoke command before opening a PR. +Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the +relevant tests, and open a focused pull request. -## License - -Zero is released under the [MIT License](LICENSE) — © 2026 Gitlawb. +Security reports should follow [SECURITY.md](SECURITY.md). ---- +## License -
-Zero — one terminal · every model -
+Zero is released under the [MIT License](LICENSE). diff --git a/docs/INSTALL.md b/docs/INSTALL.md index bfdd6b97..db908015 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,32 +1,36 @@ -# Zero Install Scripts +# Installing Zero -> **Status: pre-release.** No GitHub release has been published yet, so the install scripts below -> (which download release assets from `Gitlawb/zero`'s Releases) will fail with a 404 until the first -> release exists. Until then, build from source: `go run ./cmd/zero` or `go build -o zero ./cmd/zero` -> (requires Go 1.25+). The instructions below describe how the scripts work once releases are published. +Zero is distributed as: -Zero release archives are published as: +- an npm package, `@gitlawb/zero` +- release archives on GitHub Releases +- source builds with Go 1.25+ -- `zero-v-linux-.tar.gz` -- `zero-v-macos-.tar.gz` -- `zero-v-windows-.zip` +The npm package and install scripts download a platform-specific release archive. +They require a published GitHub Release for the requested version. -Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary. - -Maintainers can verify the release directory before upload: +## npm ```bash -go run ./cmd/zero-release package -go run ./cmd/zero-release verify +npm install -g @gitlawb/zero +zero ``` -`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are: +The package supports Linux, macOS, and Windows on x64 and arm64. It installs the +`zero` command and downloads the matching release binary during `postinstall`. -```text - -``` +Requirements: -## Linux And macOS +- Node.js 18+ +- network access to npm and GitHub Releases + +## Linux And macOS Script + +Install the latest release: + +```bash +curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash +``` From a checkout: @@ -38,18 +42,14 @@ Install a specific version: ```bash ZERO_VERSION=0.1.0 scripts/install.sh +scripts/install.sh --version 0.1.0 ``` -Install to a custom directory: +Install somewhere else: ```bash ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh -``` - -Install from a fork or internal repository: - -```bash -scripts/install.sh --repo owner/repo +scripts/install.sh --install-dir "$HOME/bin" ``` Defaults: @@ -58,9 +58,15 @@ Defaults: - Version: latest GitHub release - Install path: `~/.local/bin/zero` -Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`. +Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`. + +## Windows PowerShell Script -## Windows +Install the latest release: + +```powershell +irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex +``` From a checkout: @@ -74,30 +80,61 @@ Install a specific version: powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0 ``` -Install to a custom directory: +Install somewhere else: ```powershell powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin" ``` -Install from a fork or internal repository: - -```powershell -powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo -``` - Defaults: - Repository: `Gitlawb/zero` - Version: latest GitHub release - Install path: `%LOCALAPPDATA%\zero\bin\zero.exe` +## From Source + +```bash +git clone https://github.com/Gitlawb/zero.git +cd zero +go run ./cmd/zero +``` + +Build a local binary: + +```bash +go build -o zero ./cmd/zero +``` + +Source builds require Go 1.25+. + +## Release Archive Format + +Release archives are named: + +- `zero-v-linux-.tar.gz` +- `zero-v-macos-.tar.gz` +- `zero-v-windows-.zip` + +Supported targets: + +- `linux-x64` +- `linux-arm64` +- `macos-x64` +- `macos-arm64` +- `windows-x64` +- `windows-arm64` + +Each archive must have a matching `.sha256` file. The install scripts download +both files, verify the checksum, and then copy the binary into the install +directory. + ## Updating -Check whether a newer release exists: +Check for a newer release: ```bash zero update --check ``` -For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release. +Then reinstall with npm or rerun the install script for the version you want. diff --git a/docs/M1_HEADLESS_EXEC_PRD.md b/docs/M1_HEADLESS_EXEC_PRD.md deleted file mode 100644 index 4dfbb7d6..00000000 --- a/docs/M1_HEADLESS_EXEC_PRD.md +++ /dev/null @@ -1,87 +0,0 @@ -# M1 Headless Exec PRD - -Status: Draft -Owner: Vasanth -Milestone: M1 -Scope: `zero exec` CLI surface - -## Goal - -Ship the first production-shaped headless command for Zero: - -```sh -zero exec "inspect this repo and summarize the next fix" -``` - -This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap. - -## Non-Goals - -- No daemon mode. -- No JSON-RPC input protocol. -- No MCP, plugin, marketplace, or subagent support. -- No session resume/fork/search. -- No new provider adapters in this slice. -- No architecture rewrite of the agent loop. - -## User Stories - -1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout. -2. As a script author, I can choose `text` or JSONL output. -3. As a reviewer, I can see tool activity without corrupting text stdout. -4. As a model tester, I can override the configured model for one run. -5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag. - -## CLI Contract - -```sh -zero exec [prompt...] [options] -``` - -Options for M1: - -- `-f, --file `: read the prompt from a file. -- `-m, --model `: override the configured model for this run. -- `-C, --cwd `: run from another working directory. -- `-o, --output-format `: choose stdout format. -- `--skip-permissions-unsafe`: grant prompt-gated tools for this run. - -The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner. - -## Output Contract - -Text mode: - -- stdout contains assistant text only. -- stderr contains tool rows, warnings, and errors. - -JSON mode: - -- stdout is newline-delimited JSON events. -- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`. - -## Exit Codes - -- `0`: success -- `1`: unexpected crash -- `2`: usage error -- `3`: provider/config/runtime error -- `4`: reserved for future tool failure policy -- `5`: reserved for future permission-denied policy - -## Permission Behavior - -Default headless mode advertises only tools marked `permission: "allow"`. - -`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards. - -## Acceptance Criteria - -- `go test ./...` passes. -- `go run ./cmd/zero-release build` passes. -- `go run ./cmd/zero-release smoke` passes. -- `zero exec --help` documents the M1 flags. -- Usage errors do not initialize providers. -- Provider failures return exit code `3`. -- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`. -- Unsafe mode still uses the registry execution path. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 83062729..11b33063 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -1,6 +1,6 @@ # Zero Performance Benchmarks -The M2 performance harness tracks three release-facing signals: +The performance harness tracks three release-facing signals: - Cold start: process startup time for `zero --version`. - Binary first output: time from spawning the built `zero --version` command to diff --git a/docs/PRD.md b/docs/PRD.md deleted file mode 100644 index fdada3a3..00000000 --- a/docs/PRD.md +++ /dev/null @@ -1,602 +0,0 @@ -# Zero — Product Requirements Document (v1.0) - -**Status:** DRAFT v1.0 -**Target:** Compete with Claude Code + Cursor + other multi-provider terminal agents -**Timeline:** 6-12 months -**Stack:** Go native core + npm distribution wrapper - ---- - -## 1. Vision - -**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.** - -It combines the best of: -- **Claude Code** — terminal-native agentic coding -- **Cursor** — IDE integration + smart editing -- **Multi-provider terminal agents** — provider-agnostic + headless - -**One Go-native agent core. Three surfaces. Zero lock-in.** - ---- - -## 2. Core Surfaces - -### 2.1 Terminal (TUI) — Primary -- Interactive coding agent in terminal -- Streaming, append-style (like Claude Code) -- Bubble Tea + Lip Gloss-based -- Full keyboard control -- Mouse support - -### 2.2 Editor (VS Code Extension) -- Same agent core, different UI -- Inline suggestions + chat panel -- File editing with diff view -- Diagnostics integration -- LSP-aware - -### 2.3 Headless (CLI/CI) -- `zero exec "prompt"` for scripts -- `zero serve` for daemon mode -- JSON-RPC over stdio/WebSocket -- MCP server support - ---- - -## 3. Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ ZERO PLATFORM │ -├─────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ TUI │ │ VSCode │ │ Headless │ │ -│ │ Surface │ │ Surface │ │ Surface │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ -│ │ │ │ │ -│ └─────────────┼─────────────┘ │ -│ │ │ -│ ┌──────▼──────┐ │ -│ │ Agent Core │ │ -│ │ (ReAct) │ │ -│ └──────┬──────┘ │ -│ │ │ -│ ┌──────┬──────┬────┴────┬──────┬──────┐ │ -│ │Tools │Perms │Sessions │Provi │Memory│ │ -│ └──────┴──────┴─────────┴──────┴──────┘ │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -**Layering Rules:** -- Agent Core never imports a surface -- Surfaces are thin shells (I/O + rendering) -- All state persists to local files -- No cloud dependency - ---- - -## 4. Functional Requirements (24 Features) - -### F1 — Agent Core (P0) -- ReAct loop: think → tool → result → think -- Stream text + tool calls + thinking -- Parallel tool execution where safe -- Bound turns (max 100) -- Clean interrupt (Esc/Ctrl-C) -- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end` - -### F2 — Tools (P0) - -**Core (8):** -- `read_file` — read with line ranges, images -- `write_file` — create/overwrite -- `edit_file` — exact-string edit (uniqueness guard) -- `bash` — run shell command -- `grep` — ripgrep content search -- `glob` — file pattern matching -- `apply_patch` — multi-hunk patches -- `update_plan` — todo list - -**Advanced (4):** -- `web_fetch` / `web_search` — web access -- `task` — spawn subagent -- `structured_output` — JSON-Schema validated -- `notebook_edit` — Jupyter notebooks - -**Tool Requirements:** -- Go typed parameter schemas -> JSON Schema -- Side-effect class: `read | write | shell | network | out_of_workspace` -- Structured results: `{ status, output, truncated?, meta? }` -- Atomic execution -- Permission gating - -### F3 — Providers & Model Registry (P0) -- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.) -- Anthropic (Claude) -- Google Gemini -- Azure OpenAI -- AWS Bedrock -- xAI (Grok) - -**Model Registry:** - -```go -type ModelEntry struct { - ID string - Name string - Provider string - APIProviders []string - ContextLimits ContextLimits - ReasoningEffort ReasoningEffort - Capabilities ModelCapabilities - Cost ModelCost - Tier string - MatchPatterns []string -} -``` - -- Per-task model selection (`-m`) -- Provider rotation/failover -- Cost tracking -- "Spec model" for planning -- Fallback model on failure - -### F4 — Terminal UI (P0) -- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer -- **Streaming:** append-only transcript model with live tail updates -- **Markdown:** full CommonMark + syntax highlighting -- **Tool rows:** collapsible with one-line summaries -- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt -- **Robustness:** resize, wide-char, ANSI, non-TTY fallback -- **Splash:** ZERO wordmark -- **Themes:** light/dark + custom - -### F5 — Sessions (P0) -- Persist to `~/.local/share/zero/sessions//` -- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/` -- `--resume [id]` (default: latest) -- `--fork ` (copy with lineage) -- Search: `zero search ` over messages/tools/results -- Atomic writes (write-temp-then-rename) -- Schema versioning -- Export to Markdown/JSON - -### F6 — Configuration (P0) -- Layered: defaults → user → project → env → CLI -- `~/.config/zero/config.json` (user) -- `./.zero/config.json` (project) -- Runtime overlay (`--settings `) -- Feature flags for staged rollout -- Validation on load - -### F7 — MCP (Model Context Protocol) (P0) -- Connect to stdio/HTTP/SSE servers -- Surface MCP tools into registry -- `zero mcp add/remove/list` -- Per-server and per-tool permissions -- Persistent grants with revoke/clear -- MCP server mode (zero as MCP server) - -### F8 — Headless / Programmatic (P0) -- `zero exec [prompt]` — one-shot -- `zero serve` — daemon mode -- `--input-format stream-json` — line-delimited events -- `--input-format stream-jsonrpc` — JSON-RPC protocol -- `-o text|json` output formats -- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget` - -### F9 — Permissions & Autonomy (P0) -- **Side-effect classes:** read, write, shell, network, out_of_workspace -- **Autonomy levels:** low (ask), medium (smart), high (auto) -- `--skip-permissions-unsafe` (loud warning) -- TUI: inline prompts (allow/deny/always) -- Headless: default-deny unless granted -- Persistent grants with list/revoke/clear -- Audit log in `events.jsonl` - -**Permission Matrix:** - -| Tool Class | low | medium | high | -|------------|-----|--------|------| -| Read | allow | allow | allow | -| Workspace write | prompt | allow | allow | -| Shell | prompt | prompt | allow | -| Network | prompt | allow | allow | -| Out-of-workspace | deny | prompt | prompt | - -### F10 — Observability (P0) -- Structured logging (stdout/stderr split) -- Secret redaction (API keys, tokens, passwords) -- No vendor telemetry (local only) -- `zero doctor` — health check command -- Crash reports (opt-in, local) -- Performance metrics (TTFT, tokens/sec) - -### F11 — Subagents (P1) -- `task` tool spawns scoped subagent -- Built-in agents: code-review, security-review, refactor, test-gen -- Recursion depth limit -- Parent session linkage -- Shared/isolated budgets - -### F12 — Plugins (P1) -- User/project-scoped plugins -- Commands, tools, prompts, hooks -- Plugin manifest: `plugin.json` -- `zero plugin install ` -- No marketplace in v1 (manual install) - -### F13 — Distribution (P0) -- **Single binary** via Go cross-compilation -- npm package as a thin installer/launcher for the platform binary -- Cross-platform: Linux, macOS, Windows -- `zero update [--check]` self-update -- Checksum verification -- Rollback on failure - -### F14 — VS Code Extension (P1) -- Same agent core via stream protocol -- Inline chat panel -- Diff view in editor -- Diagnostics integration -- LSP awareness -- File watching - -### F15 — Memory & Context (P1) -- `AGENTS.md` hierarchical memory -- `@import` for modular rules -- Auto-generated from `init` command -- Project conventions learning -- Skills system (reusable instruction packs) -- Context budgeting (token tracking) -- Auto-compaction when full - -### F16 — Hooks System (P1) -- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd` -- User-defined shell commands -- JSON output for conditional flow -- `.zero/hooks/` directory - -### F17 — Slash Commands (P0) - -**Built-in (21):** -- Session: `/clear`, `/compact`, `/context`, `/add-dir`, `/rewind`, `/resume`, `/exit` -- Model: `/model`, `/effort`, `/style`, `/permissions` -- Project: `/init`, `/memory`, `/skills` -- Extensibility: `/mcp`, `/hooks`, `/agents` -- Meta: `/help`, `/config`, `/doctor`, `/feedback` - -**Custom:** `./.zero/commands/*.md` - -### F18 — Git Integration (P1) -- Worktree isolation per task -- Auto-commit with messages -- Branch management -- PR creation -- Diff review -- Conflict resolution - -### F19 — Testing & Verification (P1) -- `run_tests` tool -- Self-verification loop -- Test runner integration -- Coverage reporting -- Diagnostics (typecheck, lint) -- LSP integration - -### F20 — Web Access (P1) -- `web_fetch` — fetch URL content -- `web_search` — search engine -- Provider/permission gated -- Rate limiting -- Caching - -### F21 — Sandbox (P2) -- Linux: bubblewrap -- macOS: sandbox-exec -- Windows: AppContainer -- Filesystem policies (workspace write confinement + user-granted extra roots via `--add-dir`) -- Network policies -- Unix socket controls - -### F22 — Multi-modal (P2) -- Image input (screenshots, diagrams) -- PDF parsing -- Voice input (optional) -- File attachments -- Paste support - -### F23 — Telemetry (P2) -- Local-only metrics -- Token usage tracking -- Cost reports -- Performance profiling -- Crash reports (opt-in) -- NO vendor telemetry - -### F24 — Advanced Features (P2) -- Code review automation -- Auto-refactor -- Dependency updates -- Security scanning -- Documentation generation - ---- - -## 5. Data Models - -### 5.1 Headless Event Stream - -```json -{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." } -{ "type": "text", "delta": "..." } -{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" } -{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" } -{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false } -{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 } -{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] } -{ "type": "turn_end", "stopReason": "end_turn" } -{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true } -{ "type": "run_end", "status": "success", "exitCode": 0 } -``` - -### 5.2 Session On Disk - -``` -~/.local/share/zero/sessions// - meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost } - events.jsonl # append-only AgentEvent stream - messages.jsonl # provider-facing messages - checkpoints/ # snapshots for restore/fork/compact -``` - -### 5.3 Tool Contract - -```go -type Tool interface { - Name() string - Description() string - Parameters() JSONSchema - SideEffect() SideEffect - Execute(ctx ToolContext, args map[string]any) ToolResult -} - -type ToolResult struct { - Status string - Output string - Truncated bool - Meta map[string]any -} -``` - -### 5.4 Config Schema - -```json -{ - "model": "claude-sonnet-4-6", - "providers": { - "anthropic": { "apiKey": "env:AN..._KEY" }, - "openai": { "apiKey": "env:OP..._KEY" } - }, - "permissions": { - "autonomy": "medium", - "grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }] - }, - "ui": { "theme": "dark", "syntaxHighlighting": true } -} -``` - ---- - -## 6. Non-Functional Requirements - -### Performance -- TTFT < 500ms after provider's first byte -- No full-transcript repaint per token -- Cold start < 300ms (warm cache) -- Tool execution < 100ms overhead -- Session resume < 1s - -### Reliability -- Crash-free TUI under resize, wide-char, non-TTY -- Clean interrupt (no half-written state) -- Atomic session writes -- Recovery from malformed JSONL -- Provider failover - -### Security -- No mutation without permission decision (hard invariant) -- API keys never logged -- Secret redaction everywhere -- Headless stdout free of logs -- Workspace boundary by default -- Sandboxing for untrusted code - -### Portability -- Linux, macOS, Windows -- Any OpenAI-compatible endpoint -- x86_64, ARM64 -- Single binary distribution - -### Testability -- Mock provider for unit tests -- Fake MCP server/client -- Fake terminal/PTY -- Sandbox simulator -- Deterministic event replay - ---- - -## 7. Milestones - -### M0 — Foundation (Weeks 1-2) -- Go module setup -- Agent core skeleton -- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan) -- OpenAI-compatible provider -- Basic Bubble Tea TUI -- Permission gate (read vs mutate) -- Session persistence - -**Exit:** `zero` runs, can read/edit files, basic chat works. - -### M1 — Multi-Provider (Weeks 3-4) -- Anthropic provider -- Gemini provider -- Model registry -- Per-task model selection -- Provider rotation -- Cost tracking -- Headless `exec` mode - -**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless. - -### M2 — Polish (Weeks 5-8) -- Advanced TUI features -- Full markdown + syntax highlighting -- Tool result truncation/pagination -- Auto-compaction -- Context budgeting -- 20 slash commands -- Doctor command -- Search command - -**Exit:** Production-ready TUI, smooth streaming, all commands work. - -### M3 — Distribution (Weeks 9-10) -- Single binary build -- Self-update -- Cross-platform CI -- VS Code extension MVP -- Documentation site - -**Exit:** `zero` ships as one binary, works on Win/Mac/Linux. - -### M4 — Extensibility (Weeks 11-14) -- MCP client + permissions -- Hooks system -- Skills system -- Memory (AGENTS.md) -- Plugin loading -- Subagents (built-in: review, security) - -**Exit:** Plugins work, MCP servers connect, memory persists. - -### M5 — Advanced (Weeks 15-20) -- Web fetch/search -- Git worktrees -- Self-verification -- Diagnostics (LSP, typecheck) -- Testing integration -- Code review automation - -**Exit:** Zero can review code, run tests, manage git. - -### M6 — Sandbox & Security (Weeks 21-24) -- Linux sandbox (bubblewrap) -- macOS sandbox -- Windows AppContainer -- Network policies -- Filesystem policies - -**Exit:** Untrusted code runs safely in sandbox. - -### Beyond v1.0 -- Multi-modal (images, PDFs) -- Voice input -- Cloud sync (optional) -- Team features -- Marketplace (if demand) - ---- - -## 8. Risks & Mitigations - -| # | Risk | Mitigation | -|---|------|------------| -| R1 | Scope creep | Stick to milestones, defer features | -| R2 | Provider API changes | Abstract via interface, test with mocks | -| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns | -| R4 | Performance | Benchmark early, optimize streaming | -| R5 | Security vulnerabilities | Permission gate from day 1, security review | -| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 | -| R7 | Breaking changes | Schema versioning, migration tools | - ---- - -## 9. Success Metrics - -### v1.0 (6 months) -- 5+ providers supported -- 12+ tools -- 100% test coverage on core -- < 300ms cold start -- Single binary < 50MB -- Works on Win/Mac/Linux -- 1000+ GitHub stars -- 50+ contributors - -### v2.0 (12 months) -- VS Code extension published -- MCP server mode -- Plugin ecosystem (20+ plugins) -- 10K+ active users -- Compete with Claude Code on benchmarks - ---- - -## 10. Out of Scope (v1.0) - -- Cloud sync / hosted backend -- Relay / remote SSH execution -- Multi-agent mission mode -- Plugin marketplace -- Auto-generated wikis -- Git authorship notes -- Vendor telemetry -- Voice input -- Mobile support - ---- - -## 11. Open Decisions - -1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem) -2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple) -3. **Package structure:** Single vs monorepo — **Decide: Single** until M4 -4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability) -5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small) - ---- - -## 12. Team & Timeline - -**Team (3 people):** -- You (lead + TUI + tools) -- Backend dev (providers + core) -- Designer (VS Code extension + docs) - -**Timeline:** -- M0-M1: 4 weeks (foundation) -- M2-M3: 6 weeks (polish + distribution) -- M4-M5: 8 weeks (extensibility) -- M6: 4 weeks (sandbox) -- **Total v1.0: 6 months** - ---- - -## 13. Why This Will Work - -1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns -2. **Multi-provider** — Unique advantage vs Claude Code/Cursor -3. **Open source** — Community contributions -4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics -5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native -6. **Clear milestones** — Ship early, iterate - ---- - -**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.** diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..2dabbfb6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,23 @@ +# Zero Documentation + +This directory contains public documentation for using, extending, and releasing +Zero. + +## User Docs + +- [Install](INSTALL.md) +- [Update flow](UPDATE.md) +- [OAuth logins and subscription-backed providers](oauth-subscriptions.md) + +## Automation And Integrations + +- [Stream-JSON protocol](STREAM_JSON_PROTOCOL.md) +- [GitHub Action](GITHUB_ACTION.md) +- [Specialists](SPECIALISTS.md) + +## Maintainer Docs + +- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md) +- [Benchmarks](BENCHMARK.md) +- [Performance benchmarks](PERFORMANCE.md) +- [Offline agent evals](AGENT_EVALS.md) diff --git a/docs/UPDATE.md b/docs/UPDATE.md index f1beceb4..9447d643 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -1,38 +1,44 @@ -# Zero Update Flow +# Update Flow -`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version. +`zero update --check` checks the latest GitHub release and compares it with the +local CLI version. ```bash zero update --check zero update --check --json zero update --check --repo Gitlawb/zero -zero update --check --endpoint https://api.github.com/repos/Gitlawb/zero/releases/latest -zero update --check --timeout 3s zero update --check --target windows-x64 ``` -For M2 this command is intentionally check-only: +The command is intentionally check-only: - It does not replace the running binary. -- It exits with code `0` when the check succeeds, even when an update is available. +- It exits with code `0` when the check succeeds, even when an update is + available. - It exits with code `1` when the release check cannot be completed. -- `--json` prints the same result in a machine-readable format for scripts and CI. -- `--repo ` checks a different GitHub repository when no endpoint is provided. -- `--endpoint ` checks a specific release API URL or repository slug. -- `--timeout ` overrides the default release check timeout. -- `--target ` validates release metadata for another supported target from the current machine. -- Release checks time out after 5 seconds by default. -- It validates that the latest release includes the expected archive and matching `.sha256` asset for the selected platform target. +- `--json` prints the same result in a machine-readable format for scripts and + CI. -Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current platform. +Useful flags: -The release endpoint resolves in this order: +| Flag | Purpose | +|---|---| +| `--repo ` | Check another GitHub repository. | +| `--endpoint ` | Check a specific release API URL or repository slug. | +| `--timeout ` | Override the default release check timeout. | +| `--target ` | Validate release metadata for another supported target. | -- `--endpoint` from the CLI, or `Options.Endpoint` when calling `update.Check` from code. -- `ZERO_UPDATE_RELEASE_URL` from the environment. -- `--repo` from the CLI, or `Options.Repository` when calling `update.Check` from code. -- `https://api.github.com/repos/Gitlawb/zero/releases/latest`. +Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, +`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current +platform. -`--endpoint`, `Options.Endpoint`, and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug. +Endpoint resolution order: -Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer. +1. `--endpoint` +2. `ZERO_UPDATE_RELEASE_URL` +3. `--repo` +4. `https://api.github.com/repos/Gitlawb/zero/releases/latest` + +Installer scripts download the matching release asset for the local platform and +verify its `.sha256` file. If Zero is already installed, run `zero update --check` +before reinstalling. diff --git a/docs/WORK_SPLIT_PRD.md b/docs/WORK_SPLIT_PRD.md deleted file mode 100644 index c867ca78..00000000 --- a/docs/WORK_SPLIT_PRD.md +++ /dev/null @@ -1,286 +0,0 @@ -# Zero WorkSplit PRD v3: Go-Native Product Ownership Plan - -Status: Draft v3.0 -Date: 2026-06-06 -Timeline: Active sprint plan toward v0.1, then v1.0 hardening - -Team: - -- Vasanth -- Gnanam -- Anandan - -## 1. Product Goal - -Zero is a terminal-first AI coding agent built in Go. - -The goal is not just to remove the old TypeScript runtime. The goal is to make Zero feel like a mature daily-driver CLI agent: - -- fast startup -- strong command UX -- safe autonomy -- clear permission handling -- useful local tools -- reliable provider/runtime behavior -- installable, updateable, and testable on real developer machines - -This document replaces the older migration-era work split. The Go runtime is now the main product baseline, so future work should focus on product depth rather than repeating the old M0 migration plan. - -## 2. Current Baseline On Main - -The current main branch is Go-native for the runtime and CLI. - -Implemented baseline: - -- `cmd/zero` is the primary CLI entrypoint. -- `cmd/zero-pr-review` exists for PR automation workflows. -- Core runtime packages live under `internal/`. -- Current CLI surface includes `exec`, `config`, `models`, `providers`, `doctor`, `search`, `sessions`, `plugins`, `hooks`, `mcp`, `sandbox`, `update`, `worktrees`, `verify`, `changes`, `serve`, `help`, and `version`. -- Core areas exist for agent flow, provider/model registry, tools, TUI, sessions, search, sandbox, self-verification, stream-json, usage, redaction, hooks, MCP, plugins, update, worktrees, and git/change handling. -- TypeScript is no longer part of the repository baseline. The npm package keeps a small Node wrapper that delegates to the Go binary. - -Still needed before Zero feels production-ready: - -- Permission and sandbox UX must become clearer and harder to misuse. -- TUI command flows need polish beyond basic rendering. -- Platform behavior, especially Windows sandbox/update/install paths, needs stronger product ownership. -- Review automation should produce useful, repeatable output for maintainers. -- Release trust needs checksums, install smoke tests, and update verification. - -## 3. Ownership Rules - -- Every feature has one DRI. -- UI, runtime, and platform work should be split by contract, not by vague file ownership. -- Every PR must expose user-runnable behavior or a concrete contract consumed by another owner. -- Docs, CI, and website work can support a product PR, but they should not be the whole lane for any owner. -- Anandan must own platform product work, not only docs/site/CI. -- Shared contracts should be agreed before large UI or integration work lands. -- Keep PRs large enough to move the product forward, but still reviewable. - -## 4. Team Roles - -| Owner | Primary Role | Owns | Should Not Own Alone | -| --- | --- | --- | --- | -| Vasanth | Product UX and TUI owner | Bubble Tea TUI, startup screen, command palette, slash command UX, tool rendering, permission prompts, sandbox block rendering, verification/test/git output UX, themes, daily CLI feel | Provider internals, release pipeline, platform adapters | -| Gnanam | Runtime core owner | Provider/model registry, provider factory, agent/runtime protocols, stream-json, sessions/search backends, config/doctor/usage/redaction, MCP/hooks/plugins backend, sandbox policy/grants, permission event contract | TUI composition, release packaging, platform-specific install trust | -| Anandan | Platform product owner | Install/update/release trust, binary/package smoke tests, platform sandbox adapters, Windows behavior, PR/review automation CLI, CI as product verification, performance and release checks | TUI rendering, provider semantics, model registry ownership | - -## 5. Anandan Platform Product Rule - -Anandan should not be assigned only docs/site/CI forever. His lane must produce platform behavior that users and maintainers can run. - -Accepted Anandan work: - -- `zero sandbox policy` improvements and platform-specific sandbox behavior. -- Windows sandbox adapter behavior and clear fallback reporting. -- `zero update` verification, release metadata validation, and install smoke tests. -- Package and binary checksums. -- PR automation CLI workflows that reviewers can run locally or in CI. -- CI jobs that prove real Zero commands work after installation. - -Not accepted as standalone Anandan work: - -- Docs-only updates. -- Website-only updates. -- Future VS Code notes with no current CLI behavior. -- CI matrix changes that do not prove a user-facing command or release flow. -- Release notes or checklists without executable validation. - -Docs and CI are still useful, but they must attach to real platform product behavior. - -## 6. Shared Contracts - -| Contract | DRI | Consumers | Purpose | -| --- | --- | --- | --- | -| Provider stream events | Gnanam | Vasanth, Anandan | Lets TUI and automation render model output, tool calls, and errors consistently. | -| Permission and sandbox events | Gnanam | Vasanth, Anandan | Lets TUI ask for decisions and platform code enforce them without bypasses. | -| Session event schema | Gnanam | Vasanth, Anandan | Lets TUI, resume, search, and automation share one session model. | -| Command metadata registry | Vasanth | Gnanam, Anandan | Lets slash commands, help, shell completions, and docs use one command source. | -| Platform/release contract | Anandan | Vasanth, Gnanam | Defines install, update, checksum, smoke, and platform capability results. | -| Stream-json protocol | Gnanam | Anandan | Lets CI and PR automation consume Zero output without scraping text UI. | - -## 7. Active Workstreams - -### A. Safe Autonomy - -Top priority. Zero must be able to run tools confidently while showing users what is happening and asking when risk increases. - -Planned PRs: - -- Vasanth: `feat/tui-sandbox-permissions` - - permission prompt UX - - allow, deny, and always-allow flows - - sandbox block rendering - - `/permissions` or equivalent permission state UI -- Gnanam: `feat/runtime-permission-events` - - structured permission request events - - sandbox grant contract - - stream-json support - - tests for denial, approval, and block cases -- Anandan: `feat/windows-sandbox-platform` - - platform sandbox capability checks - - stronger Windows behavior or explicit safe fallback - - `zero sandbox policy --json` output useful enough for CI and support - - platform tests where possible - -Done when: - -- High-autonomy tool attempts produce a clear prompt, decision, and result in TUI and headless modes. -- Denied commands do not run. -- Sandbox blocks are visible and actionable. -- Platform fallback is explicit, tested, and not silently unsafe. - -### B. Command UX And Daily CLI - -The CLI should feel like a product, not a collection of backend commands. - -Planned PRs: - -- Vasanth: `feat/tui-command-polish` - - `/model`, `/provider`, `/context`, `/doctor`, `/verify`, `/resume`, and `/compact` become clear interactive flows - - command results render consistently - - startup screen stays minimal until the user asks for panels -- Gnanam: `feat/runtime-command-contracts` - - stable command data APIs - - provider/model/session/config result structs - - error types suitable for TUI rendering -- Anandan: `feat/install-update-user-flow` - - `zero update` and install flows are user-runnable - - package smoke command verifies installed binary behavior - - CI proves the packaged command starts and reports version/help - -Done when: - -- A new user can run Zero, inspect config/provider/model state, and understand the next action without reading source. - -### C. Verification And Review Automation - -Zero should help maintainers verify changes and review PRs with repeatable local commands. - -Planned PRs: - -- Vasanth: `feat/tui-verification-results` - - render verify/test/git/review results in TUI - - show pass/fail/blocked states clearly -- Gnanam: `feat/runtime-self-verify-contract` - - structured verification results - - stable test/change event model - - redacted logs for model and automation use -- Anandan: `feat/pr-review-cli-workflow` - - make `cmd/zero-pr-review` or `zero review` useful for changed files, validations, and summary output - - support local and CI invocation - - avoid brittle text scraping where stream-json can be used - -Done when: - -- A maintainer can run one local command before review and get useful changed-file, validation, and risk output. - -### D. Release Trust And Distribution - -This is Anandan's main product lane after platform sandbox work. - -Planned PRs: - -- Anandan: `feat/release-trust` - - release metadata validation - - checksums - - install smoke tests - - update verification - - clear error output when release artifacts are invalid -- Vasanth: - - consume release/update results in CLI UX where needed -- Gnanam: - - ensure config, errors, and logs are structured and redacted - -Done when: - -- Users can install, run, verify, and update Zero with confidence. -- Maintainers can prove release artifacts work before publishing. - -### E. Extensibility UX - -This comes after safe autonomy and daily CLI polish. - -Planned PRs: - -- Vasanth: - - `/mcp`, `/hooks`, `/plugins`, and related UI flows -- Gnanam: - - harden backend contracts and lifecycle behavior -- Anandan: - - automation and docs only after executable behavior exists - -Done when: - -- Extensibility feels discoverable and safe from the terminal. - -## 8. Immediate Next PRs - -These are the next reviewable slices. Do not use the old migration-era next steps. - -1. Vasanth: `feat/tui-sandbox-permissions` -2. Gnanam: `feat/runtime-permission-events` -3. Anandan: `feat/windows-sandbox-platform` -4. Anandan: `feat/update-release-verification` -5. Vasanth: `feat/tui-command-polish` -6. Gnanam: `feat/session-rewind-compaction-contract` - -Recommended first parallel set: - -- Vasanth starts the TUI permission prompt and block display. -- Gnanam defines the permission event/grant contract. -- Anandan owns Windows/platform sandbox reporting and tests. - -## 9. Command Ownership Matrix - -| Command Area | Vasanth | Gnanam | Anandan | -| --- | --- | --- | --- | -| `exec` | TUI and interactive UX | Runtime execution contract | Package smoke and platform validation | -| `sandbox` | Prompt and block UX | Policy, grants, and events | Platform adapters and Windows behavior | -| `update` | Display and user messaging | Config and redacted errors | Primary owner of update verification | -| `verify` | Result rendering | Verification backend contract | CI and release integration | -| `changes` | TUI rendering | Change detection backend | PR automation integration | -| `mcp`, `hooks`, `plugins`, `serve` | Discoverability and UI | Backend lifecycle and contracts | Automation/docs after behavior exists | -| `sessions`, `search` | Resume/search UX | Storage, query, and event model | Stream-json and automation consumers | - -## 10. Definition Of Done - -For every code PR: - -- Add or update tests for the changed behavior. -- Run `go test ./...` when Go files changed. -- Run the relevant build command when CLI entrypoints changed. -- Run package smoke checks when install/update/npm wrapper files changed. -- Include manual CLI output or screenshots only when they prove a user-facing flow. - -For docs-only PRs: - -- State clearly that no runtime behavior changed. -- Do not use docs-only PRs as a substitute for platform product work. - -For Anandan's PRs specifically: - -- Must include one of: - - user-runnable command behavior - - install/update/release artifact verification - - platform adapter behavior - - CI that executes real installed Zero commands -- Docs/CI can support the PR, but cannot be the entire value. - -## 11. Review Rotation - -- Vasanth PRs: - - Gnanam reviews runtime contracts. - - Anandan reviews platform/release impact when touched. -- Gnanam PRs: - - Vasanth reviews user-facing contract impact. - - Anandan reviews platform/protocol impact when touched. -- Anandan PRs: - - Vasanth reviews user-facing CLI behavior. - - Gnanam reviews runtime contract and safety impact. - -## 12. Retired Historical Plan - -The old M0-M6 TypeScript-to-Go migration schedule is now historical. - -Do not assign new work from the old migration next-steps section. The Go migration foundation has already landed on main. Future work should move Zero from "ported" to "mature terminal coding agent." diff --git a/docs/assets/zero-logo.png b/docs/assets/zero-logo.png new file mode 100644 index 00000000..60fadb56 Binary files /dev/null and b/docs/assets/zero-logo.png differ diff --git a/docs/audit/2026-06-10-deep-audit-status.md b/docs/audit/2026-06-10-deep-audit-status.md deleted file mode 100644 index cac278da..00000000 --- a/docs/audit/2026-06-10-deep-audit-status.md +++ /dev/null @@ -1,208 +0,0 @@ -# Deep-audit status ledger (re-verified against HEAD on branch fix/audit-batch, 2026-06-13) - -Total verified: 175 of 175 extracted findings - -| Status | Count | -|---|---| -| fixed | 97 | -| open | 66 | -| partial | 12 | -| obsolete | 0 | - - - - -> **Authoritative re-verification (2026-06-13, branch `fix/audit-batch` off current `main`).** -> All 87 rows the 06-11 snapshot marked `open`/`partial` were re-checked against the ACTUAL -> current code by independent agents (the 06-11 ledger was verified on `fix/audit-critical-high`, -> which has since diverged). Every `already_fixed` verdict was adversarially re-checked by a -> second agent trying to prove the defect was still present — none were refuted. -> **Result: 78 genuinely pending** (66 still_open + 12 partial); **9 rows were already fixed on -> `main`** and are reclassified to `fixed` here. Full per-finding table: -> [`2026-06-13-reverification.md`](2026-06-13-reverification.md). -> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification -> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247). - -| Sev | Status | Location | Evidence | -|---|---|---|---| -| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic | -| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green | -| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning | -| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows | -| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 | -| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go | -| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session | -| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go | -| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) | -| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) | -| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) | -| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats | -| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs | -| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail | -| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats | -| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush | -| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C | -| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) | -| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green | -| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go | -| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go | -| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go | -| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go | -| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model | -| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go | -| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go | -| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go | -| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go | -| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go | -| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go | -| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go | -| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface | -| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 | -| low | fixed | internal/mcp/server.go:129 | server.go:129-132 adds `case "ping": return server.writeResult(message.ID, map[string]any{})` (introduced in commit 6fb0231, present at HEAD) | -| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success | -| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass | -| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) | -| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect | -| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) | -| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) | -| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) | -| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) | -| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) | -| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go | -| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) | -| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) | -| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text | -| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) | -| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) | -| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) | -| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) | -| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines | -| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case | -| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs | -| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false | -| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 | -| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains | -| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) | -| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes | -| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe | -| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary | -| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges | -| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read | -| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path | -| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys | -| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference | -| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep | -| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner | -| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition | -| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) | -| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties | -| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it | -| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path | -| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 | -| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid | -| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it | -| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches | -| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) | -| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site | -| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write | -| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go | -| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) | -| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running | -| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path | -| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) | -| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) | -| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) | -| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text | -| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 | -| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) | -| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go | -| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) | -| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree | -| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) | -| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now | -| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 | -| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) | -| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style | -| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) | -| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected | -| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 | -| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) | -| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text | -| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) | -| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error | -| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) | -| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 | -| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass | -| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) | -| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD | -| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 | -| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting | -| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths | -| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) | -| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer | -| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse | -| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default | -| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 | -| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) | -| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting | -| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window | -| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker | -| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) | -| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit | -| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible | -| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass | -| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano | -| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: ` to os.Stderr before returning 1 | -| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go | -| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) | -| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure | -| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit | -| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever | -| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 | -| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits | -| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) | -| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) | -| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests | -| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) | -| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains | -| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used | -| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr | -| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced | -| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call | -| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe | -| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read | -| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) | -| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) | -| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 * * *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps | -| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning | -| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) | -| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 | -| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses | -| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does | -| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' | -| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini | -| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter | -| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe | -| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package | -| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) | -| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil | -| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate | -| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded | -| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped | -| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence | -| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI | -| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event | -| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") | -| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) | -| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) | -| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them | -| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows | -| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) | -| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic | -| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost | diff --git a/docs/audit/2026-06-10-deep-audit.md b/docs/audit/2026-06-10-deep-audit.md deleted file mode 100644 index a6158026..00000000 --- a/docs/audit/2026-06-10-deep-audit.md +++ /dev/null @@ -1,2435 +0,0 @@ -# Zero — Deep Code Audit Report - -Date: 2026-06-10 · Scope: every module in the repository (16 audit areas, 175 findings) · Method: one specialist auditor per module group plus cross-module wiring, hygiene, and TUI-architecture deep dives; findings adversarially verified where capacity allowed (the verification fleet was interrupted by API session limits partway, so findings below are marked **verified** only where a verifier completed; all others are auditor-reported with quoted code evidence). - -**175 unique findings** — 2 critical · 30 high · 71 medium · 72 low - -## Fixed in this pass (2026-06-10) - -The TUI was re-architected around a settled-row flush frontier (history now lives in native terminal scrollback) and the following audit findings were fixed and verified by the full test suite: - -**TUI (internal/tui)** -- History unreachable: settled transcript rows now print once to native scrollback via `tea.Println` (ordered, ack-serialized); `View()` renders only the live tail. Scroll-up, text selection, and copy of full history now work. Title bar prints once at startup at the real terminal width. -- Edited code reviewable in history: flushed cards use a deep 400-line body cap (live region keeps 16), so full diffs land in scrollback. -- Clickable edited places: OSC 8 `file://` hyperlinks (percent-encoded) on diff paths, file-tool targets, and grep locations; `ansiSequenceEnd` now parses OSC, and truncation re-closes open hyperlinks. -- Streamed interim text preserved as non-final assistant rows (tool call, error, and cancel paths) instead of vanishing. -- Visible "Run cancelled." marker (plus partial answer) on Esc/Ctrl+C. -- Transcript dedup key is run-scoped + per-run ordinal suffix for repeated provider tool-call ids (Gemini) — live and on rehydration. -- Ctrl+C after an Esc-cancel and `/exit` mid-run no longer orphan checkpoint blobs (deferred quit until flush; `/spec` gated while exiting). -- Cancelled-run late flush goes to the session recorded at cancel time (no cross-session contamination); `/rewind` blocked while a flush is outstanding; cancelled-run usage recorded. -- `/resume` and `/rewind` flush rehydrated history to scrollback in one batch; ask_user questions AND answers persist and rehydrate. -- Composer: input width tracks the terminal (no more blind typing), shell-style ↑/↓ input history recall. -- `wrapPlainText` preserves indentation (code blocks survive); `looksLikeDiff` no longer hijacks output containing `---` separators; `shortenPath` home-boundary fix; rune-safe truncation of titles/outputs; O(n²) transcript append removed; completed runs release their CancelFunc; ask-user card echoes the typed answer; permission card labels `[esc] cancel run`; dead footer/actions/theme tokens removed. - -**Cross-module** -- `zero search`: lowered-text byte offsets mapped back to the original (panic + invalid-UTF-8 context fixed); unknown `--session-id` errors instead of silently returning 0 hits; corrupt sessions skipped, not fatal. -- `zero usage report`: corrupt sessions skipped; works outside a git repository (net-LOC degrades to 0). -- Providers: HTTP 529 retried; SSE comment keep-alives reset the idle watchdog. -- Model registry: gpt-4.1 max output 16,384 → 32,768; claude-haiku-3.5 vision flag removed. -- `zero update --check`: works on dev/source builds (treats non-semver version as 0.0.0). -- MCP: server answers `ping`; `zero mcp list --json` structurally redacts env/header values. -- Sandbox: new `sandbox.network: allow|deny` config knob (the engine's NetworkDeny default was previously unreachable from any config surface). -- `zero doctor`: reports apiKey presence (`set`/`not set`) instead of an unconditional `[REDACTED]`. -- Config writer: atomic temp+rename write. notify: C1 control bytes stripped from OSC-9. imageinput: real open/read errors surfaced. -- Rune-safe truncation in exec session titles, stream-json output, status lines, cron excerpts, session replay previews, git subjects/output, and the agent system prompt. -- Hygiene: repo gofmt'ed; CI now gates on gofmt + go vet; dead glamour dependency dropped via `go mod tidy`. - -Everything else below remains open and is reported for follow-up. - -## Severity index - -| Sev | Area | Location | Finding | -|---|---|---|---| -| critical | mcp-serve | `internal/mcp/protocol.go:69` | Unbounded Content-Length allocation lets a peer crash the whole process | -| critical | cli-commands | `internal/search/search.go:346` | zero search panics (slice out of range) and emits misaligned/invalid-UTF-8 context due to ToLower byte-offset mismatch | -| high | wiring-parity | `internal/tui/transcript.go:137` | Transcript dedupe key omits runID — repeated provider tool-call IDs silently drop later tool rows | -| high | wiring-parity | `internal/tui/model.go:853` | /exit during an in-flight run quits immediately, orphaning checkpoint blobs and dropping the run's session events | -| high | mcp-serve | `internal/mcp/protocol.go:42` | MCP stdio transport uses LSP Content-Length framing instead of MCP newline-delimited JSON | -| high | mcp-serve | `internal/streamjson/streamjson.go:310` | streamjson secret patterns mangle ordinary text in every stream-json output event | -| high | leaf-packages | `internal/providerhealth/providerhealth.go:285` | Health probe SSRF blocklist and credentials defeated by redirects and DNS rebinding | -| high | tests-hygiene | `internal/tui/model.go:599` | Transcript view ignores terminal height — rows beyond one screen are silently dropped (no viewport/scrollback), and tests only lock in width behavior | -| high | sessions-usage | `internal/sessions/store.go:547` | Torn append (crash mid-write) permanently bricks a session: ReadEvents hard-fails on any malformed JSONL line | -| high | sessions-usage | `internal/tui/model.go:512` | Cancelled run's deferred event flush is appended to whatever session is active at flush time (cross-session contamination) | -| high | sessions-usage | `internal/tui/session_controls.go:176` | /rewind is allowed while a cancelled run is still flushing: prune deletes its checkpoint blobs, then the late flush re-appends pre-rewind events after the rewind marker | -| high | tui-scroll-arch | `internal/tui/model.go:584` | Entire chat history lives inside View(); inline renderer clips it at terminal height, so scrollback/selection of history is impossible | -| high | tui-scroll-arch | `internal/tui/transcript.go:137` | Transcript dedupe key ignores run/turn, so Gemini's repeating synthetic tool-call IDs drop every tool card after the first turn | -| high | tui-scroll-arch | `internal/tui/rendering.go:525` | cardBodyMaxLines=16 permanently hides long diffs and tool output with no expansion or scroll path | -| high | tui-interactions | `internal/tui/transcript.go:135` | Transcript dedup key ignores runID, silently dropping tool rows for providers with per-turn synthesized ToolCallIDs | -| high | tui-interactions | `internal/tui/model.go:512` | Cancelled-run event flush appends to whichever session is active at flush time, contaminating other sessions and breaking their /rewind | -| high | ops-background | `internal/cli/cron_run.go:173` | Cron store has no inter-process locking: paused/edited jobs are silently clobbered by the scheduler's stale read-modify-write, and concurrent schedulers double-fire | -| high | tui-core | `internal/tui/run.go:26` | Inline renderer silently discards transcript taller than the terminal — history is unrecoverable | -| high | tui-core | `internal/tui/transcript.go:139` | Transcript dedup key ignores runID: repeated provider tool-call IDs (Gemini gemini_tool_N) silently drop later tool cards | -| high | tui-core | `internal/tui/model.go:311` | Ctrl+C after an Esc-cancel quits immediately and drops the pending checkpoint flush | -| high | tui-core | `internal/tui/model.go:853` | /exit quits immediately during an in-flight run or pending flush, orphaning checkpoint session events | -| high | tools-sandbox | `internal/tools/bash.go:104` | bash tool timeout does not kill the command when a child inherits the output pipes | -| high | tools-sandbox | `internal/sandbox/runner.go:262` | macOS sandbox-exec profile blocks /tmp and /dev/null, breaking most wrapped shell commands | -| high | tools-sandbox | `internal/tools/apply_patch.go:161` | apply_patch misparses unified-diff body lines starting with '--- '/'+++ ' as file paths | -| high | tools-sandbox | `internal/tools/grep.go:224` | grep glob filter is applied to root-relative paths, silently dropping matches under a subdirectory search | -| high | runtime-providers | `internal/providers/openai/types.go:3` | Reasoning effort is modeled, validated, and surfaced everywhere but never serialized into any provider request | -| high | specialist-verify | `internal/specialist/exec.go:166` | Specialist sessions launched without a description are unresumable (AgentName never recorded / recorded as garbage) | -| high | agent-loop | `internal/agent/loop.go:233` | Truncated/filtered responses (FinishReason) are produced by every provider but never consumed by the agent loop | -| high | cli-commands | `internal/cli/cron.go:112` | cron add silently ignores explicit cron expression when --recipe is also given | -| high | cli-commands | `internal/cli/usage.go:121` | zero usage hard-fails outside a git repository (entire token report aborted by the net-LOC helper) | -| high | config-plugins | `internal/hooks/hooks.go:372` | Hooks subsystem is loaded and listed but never executed (no event dispatch, no edit command) | -| high | config-plugins | `internal/cli/extensions.go:186` | `zero mcp list --json` leaks MCP server env/header secret values instead of using the redacting MCPServerSnapshot | -| medium | wiring-parity | `internal/cli/exec_sessions.go:114` | exec session recorder swallows persistence errors — first failed AppendEvent silently disables all session recording | -| medium | wiring-parity | `internal/tui/session.go:190` | ask_user events are persisted but dropped on /resume rehydration — questionnaire history lost | -| medium | wiring-parity | `internal/tui/session_controls.go:117` | /style stores a preference nothing consumes; /theme, /input-style and /compact are backend-less stubs listed in /help and autocomplete | -| medium | wiring-parity | `internal/tui/session_controls.go:42` | /effort (TUI) and --reasoning-effort (exec) never reach the provider request — effort is validated, stored, and ignored | -| medium | wiring-parity | `internal/tui/model.go:1067` | Cancelling a run (Esc/Ctrl+C) leaves no visible marker in the live transcript despite code comments claiming one is written | -| medium | wiring-parity | `internal/tui/transcript.go:113` | appendTranscriptRow copies the whole transcript and re-scans it for dedupe on every append — O(n²) in the streaming hot path | -| medium | cli-core | `internal/cli/app.go:149` | `zero --skip-permissions-unsafe` silently discards all trailing arguments | -| medium | cli-core | `internal/cli/exec.go:500` | Ctrl+C with `-o json` ends the JSON event stream with no error/done terminal event | -| medium | cli-core | `internal/cli/exec.go:182` | `zero exec --list-tools -o json` ignores the requested JSON format and prints plain text | -| medium | cli-core | `internal/tui/run.go:41` | TUI program errors are swallowed: exit code 1 with zero diagnostics on the default chat path | -| medium | mcp-serve | `internal/mcp/client.go:302` | Client readLoop misroutes server-to-client requests as responses (id collision) and never answers ping | -| medium | mcp-serve | `internal/mcp/client.go:246` | Stdio request write blocks indefinitely while holding client.mu and ignores ctx cancellation | -| medium | mcp-serve | `internal/mcp/client.go:98` | Child stderr captured into an unbounded bytes.Buffer for the entire server lifetime | -| medium | mcp-serve | `internal/mcp/network_client.go:621` | Streamable-HTTP SSE response decoding takes the first event instead of the matching response | -| medium | mcp-serve | `internal/mcp/network_client.go:637` | 1 MiB SSE scanner token cap permanently kills the SSE session on large messages | -| medium | leaf-packages | `internal/secrets/scanner.go:81` | secrets.Redact leaves a private key un-redacted when an inner pattern matches inside it | -| medium | leaf-packages | `internal/search/search.go:345` | Search match offsets computed on ToLower'd text are applied to the original text | -| medium | leaf-packages | `internal/search/search.go:108` | One corrupt session aborts the entire /search and `zero search` surface | -| medium | leaf-packages | `internal/redaction/redaction.go:362` | redactURLPasswords recompiles its regexp on every RedactString call (hot path) | -| medium | leaf-packages | `internal/zerogit/zerogit.go:212` | zerogit parseStatus keeps git's C-quoted paths and combined rename strings verbatim | -| medium | leaf-packages | `internal/worktrees/worktrees.go:247` | worktrees defaultRunGit mixes stderr into parsed stdout and never fills CommandResult.Stderr | -| medium | tests-hygiene | `.github/workflows/ci.yml:31` | 10 files fail gofmt and CI has no gofmt/go vet gate to catch them | -| medium | tests-hygiene | `internal/tui/transcript.go:303` | truncateTUIOutput byte-slices mid-rune, emitting invalid UTF-8 into transcript rows; the only covering test is ASCII-only | -| medium | sessions-usage | `internal/sessions/store.go:632` | No fsync before rename/append: metadata.json (rewritten on every event) can be empty after a crash, silently hiding the session | -| medium | sessions-usage | `internal/tui/session_controls.go:235` | TUI '/rewind latest' keeps the dangling tool_call event of the mutation it just undid | -| medium | sessions-usage | `internal/sessions/store.go:387` | Fork duplicates provider_usage events and rewrites their timestamps, double-counting and re-dating usage in `zero usage report` | -| medium | sessions-usage | `internal/cli/usage.go:46` | One corrupt session aborts the entire `zero usage report` | -| medium | sessions-usage | `internal/usage/report.go:17` | Per-event 'model' field persisted under --allow-escalation is never read by the usage report, mispricing escalated runs | -| medium | sessions-usage | `internal/cli/exec_sessions.go:115` | execSessionRecorder latches the first persist failure, silently drops all later events, and the error is never surfaced | -| medium | sessions-usage | `internal/tui/transcript.go:113` | appendTranscriptRow is O(n) copy + O(n) dedupe scan per row, making transcript growth and session rehydration O(n^2) | -| medium | tui-scroll-arch | `internal/tui/model.go:569` | Assistant text streamed before tool calls is discarded — vanishes from the transcript and the session log | -| medium | tui-scroll-arch | `internal/tui/model.go:1081` | Esc/Ctrl+C cancellation leaves no visible marker in the live transcript | -| medium | tui-scroll-arch | `internal/tui/model.go:227` | Composer never sets textinput.Width, so input longer than the terminal is truncated with the cursor hidden — blind typing | -| medium | tui-scroll-arch | `internal/tui/transcript.go:117` | Full-transcript re-render on every message plus O(n²) append path | -| medium | tui-scroll-arch | `internal/tui/session_controls.go:117` | /style sets responseStyle that nothing ever reads — the command silently has no effect | -| medium | tui-scroll-arch | `internal/tui/view.go:394` | looksLikeDiff false-positives on any output containing a line starting with '---', breaking bash/generic cards | -| medium | tui-scroll-arch | `internal/tui/rendering.go:261` | wrapPlainText collapses all internal whitespace, destroying code indentation in assistant answers and streamed text | -| medium | tui-scroll-arch | `internal/tui/model.go:456` | Resize is unhandled beyond storing width/height: shrink garbles the whole surface, and the first frame renders at a hardcoded 96 cols | -| medium | tui-interactions | `internal/tui/session_controls.go:176` | /rewind allowed while a cancelled run's flush is outstanding — prunes the cancelled run's checkpoint blobs and re-appends rewound-away events | -| medium | tui-interactions | `internal/tui/session.go:190` | ask_user exchanges vanish on rehydration and user answers are never persisted | -| medium | tui-interactions | `internal/tui/model.go:853` | /exit during an in-flight run quits without cancelling or flushing, orphaning checkpoints; /spec can start a run while exiting | -| medium | tui-interactions | `internal/tui/transcript.go:113` | appendTranscriptRow is O(N) copy + O(N) dedup scan per row — O(N²) on /resume rehydration and long runs | -| medium | ops-background | `internal/cron/schedule.go:171` | Cron Next double-fires wall-clock schedules on DST fall-back (repeated hour) | -| medium | ops-background | `internal/cli/cron.go:112` | `zero cron add --recipe R` silently discards the user's cron expression in favor of the recipe's | -| medium | ops-background | `internal/background/process_posix.go:50` | POSIX background-task kill terminates only the leader PID; the specialist's subprocess tree leaks on SIGKILL escalation (Windows kills the whole tree) | -| medium | ops-background | `internal/background/manager.go:462` | Starting a second zero process clobbers a live sibling's running background-task metadata to error/PID=0 on disk | -| medium | tui-core | `internal/tui/spec_mode.go:22` | /spec can start a new run while the app is exiting, which the deferred tea.Quit then kills mid-flight | -| medium | tui-core | `internal/tui/model.go:1081` | Esc-cancelling a run leaves zero feedback in the live transcript (the 'Run cancelled.' marker only goes to the session log) | -| medium | tui-core | `internal/tui/transcript.go:117` | appendTranscriptRow copies the whole transcript and rescans it for dedup on every append — O(n²) | -| medium | tui-core | `internal/tui/model.go:610` | Full transcript re-rendered from scratch every frame (every spinner tick and stream delta), including per-line regex parsing | -| medium | tui-core | `internal/tui/session.go:93` | UTF-8 strings byte-sliced at fixed limits: session titles and tool-result text can be cut mid-rune | -| medium | tui-core | `internal/tui/model.go:227` | Composer textinput has no Width set: typing beyond the terminal width is clipped invisibly instead of scrolling | -| medium | tui-core | `internal/tui/session_controls.go:117` | /style sets m.responseStyle but nothing ever reads it — the command is a silent no-op | -| medium | tui-core | `internal/tui/rendering.go:261` | wrapPlainText collapses all whitespace via strings.Fields — assistant answers lose code indentation and alignment | -| medium | tools-sandbox | `internal/tools/registry.go:224` | escalate_model tool is implemented and registry-aware but never included in any Core* tool set | -| medium | tools-sandbox | `internal/sandbox/safe_command.go:138` | Interactive-command detector flags pager/REPL names that appear inside quoted arguments | -| medium | runtime-providers | `internal/providers/providerio/providerio.go:184` | SSE comment keep-alives never reset the 90s idle watchdog, so heartbeating upstreams are aborted as idle | -| medium | runtime-providers | `internal/modelregistry/catalog.go:27` | Registry caps gpt-4.1 output at 16,384 tokens; the API max is 32,768 (the file itself uses 32,768 for mini/nano) | -| medium | runtime-providers | `internal/providers/gemini/types.go:88` | Gemini adapter never reads cachedContentTokenCount, so CachedInputTokens is always 0 and the catalog's Gemini cached pricing is unreachable | -| medium | runtime-providers | `internal/providers/openai/tool_state.go:95` | OpenAI adapter drops fully-formed tool calls that lack an id instead of synthesizing one; zeroruntime's empty-ID collector support is unreachable | -| medium | specialist-verify | `internal/specialist/exec.go:150` | Specialist children always run with --auto high (PermissionModeUnsafe): one Task approval silently grants unprompted shell/write access | -| medium | specialist-verify | `internal/specialist/accounting.go:72` | Race: duplicate specialist stop/usage accounting events — check-then-append dedupe is not atomic and runs concurrently from onExit and TaskOutput | -| medium | specialist-verify | `internal/specialist/streamer.go:43` | Foreground specialist run fails entirely when any child stream-json line exceeds 1 MiB (untruncated tool_call args) | -| medium | agent-loop | `internal/agent/loop.go:468` | "Always allow" permission decision is converted into a denial when grant persistence fails (always, when Options.Sandbox is nil) | -| medium | agent-loop | `internal/agent/compaction.go:58` | estimateTokens ignores image attachments (and the compaction trigger ignores tool definitions), so the context budget undercounts | -| medium | agent-loop | `internal/agent/compaction.go:376` | Compaction summarizer provider calls bypass OnUsage — their token spend is invisible to usage accounting | -| medium | cli-commands | `internal/update/update.go:135` | zero update --check always fails on source/dev builds: "invalid semantic version: dev" | -| medium | cli-commands | `internal/cli/cron_run.go:115` | cron run start-up reconcile silently cancels --run-now jobs | -| medium | cli-commands | `internal/sessions/lineage.go:140` | zero sessions tree is O(nodes x sessions) disk reads (full store re-list per tree node) | -| medium | config-plugins | `internal/config/resolver.go:95` | ResolvedConfig.MCP is computed but never read; provider-command MCP servers are silently dropped | -| medium | config-plugins | `internal/hooks/hooks.go:503` | AuditStore.append is O(n^2): it re-reads and re-parses the entire audit JSONL on every append | -| low | wiring-parity | `internal/tui/options.go:28` | tui.Options.UsageTracker, ReasoningEffort, and ResponseStyle are read by the TUI but never set by any production caller | -| low | wiring-parity | `internal/tui/transcript.go:303` | Byte-indexed truncation of UTF-8 strings can split multi-byte runes (tool output, session titles) | -| low | wiring-parity | `go.mod:10` | Dead direct dependencies: glamour (and x/ansi, termenv) declared in go.mod but imported nowhere | -| low | wiring-parity | `internal/tui/rendering.go:62` | Dead production code in the TUI: footer/help helpers, unused transcript actions, unused theme tokens, dead resumeText branch, and the never-wired OnContext callback | -| low | wiring-parity | `internal/tui/model.go:525` | Completed runs never invoke their context cancel function — CancelFunc leak per prompt | -| low | cli-core | `internal/cli/exec.go:353` | Exec path bypasses injected session store and clock (deps seam silently ignored) | -| low | cli-core | `internal/cli/exec_sessions.go:88` | Session title truncation slices UTF-8 mid-rune | -| low | cli-core | `internal/cli/exec_writer.go:412` | Stream-json and stderr tool-output truncation split runes mid-sequence | -| low | cli-core | `internal/zerocommands/contracts.go:166` | Providers list/current reports "api key: not set" for profiles authenticated via --auth-header-value | -| low | mcp-serve | `internal/cli/exec_writer.go:412` | Stream-json/status truncation slices strings mid-rune, emitting invalid UTF-8 | -| low | mcp-serve | `internal/streamjson/streamjson.go:30` | streamjson.EventRestore is declared but never emitted | -| low | mcp-serve | `internal/mcp/server.go:129` | MCP server answers "ping" with method-not-found instead of an empty result | -| low | leaf-packages | `internal/secrets/scanner.go:35` | Secret scanner misses modern OpenAI key formats (sk-proj-, sk-svcacct-, keys with -/_) | -| low | leaf-packages | `internal/redaction/redaction.go:222` | RedactValue reports shared (non-circular) pointers/maps as "[Circular]", dropping data | -| low | leaf-packages | `internal/redaction/redaction.go:174` | RedactError stack-trace support is dead code (interface matches nothing) | -| low | leaf-packages | `internal/zerogit/zerogit.go:206` | Commit subject length/truncation is byte-based: rejects valid non-ASCII subjects and can emit invalid UTF-8 | -| low | leaf-packages | `internal/zerogit/zerogit.go:417` | zerogit resolveRunners silently drops env for caller-supplied runners — temp-index isolation lost | -| low | leaf-packages | `internal/imageinput/imageinput.go:58` | imageinput reports every open/read failure as "image file not found" | -| low | tests-hygiene | `internal/tui/session.go:93` | Session titles byte-truncated at 80 can split a UTF-8 rune and persist invalid UTF-8 into session metadata (TUI, spec mode, and exec) | -| low | tests-hygiene | `internal/cli/exec_writer.go:412` | stream-json and status-line output truncation byte-slices mid-rune, corrupting tool output in the machine-readable protocol | -| low | tests-hygiene | `internal/cli/cron.go:275` | cron prompt/error truncation helpers byte-slice mid-rune | -| low | tests-hygiene | `internal/tui/commands_test.go:46` | Dead test helper: commandTestStringSliceContains is defined but never called | -| low | tests-hygiene | `internal/tui/session_test.go:215` | Dead test state: runtimeMessages slice is appended from the agent goroutine but never read | -| low | sessions-usage | `internal/sessions/replay.go:237` | Naive byte-slice truncation can split multi-byte UTF-8 runes in prompts and titles | -| low | sessions-usage | `internal/sessions/store.go:555` | Second-granularity RFC3339 timestamps make Latest()/list ordering pick the wrong session on same-second ties | -| low | tui-scroll-arch | `internal/tui/rendering.go:68` | Dead footer/help rendering code and dead message fields | -| low | tui-scroll-arch | `internal/tui/transcript.go:303` | Byte-index truncation can split UTF-8 runes in session titles and tool-result row text | -| low | tui-scroll-arch | `internal/tui/model.go:853` | /exit during the Ctrl+C checkpoint-flush wait quits immediately, orphaning the checkpoints the deferred quit exists to protect | -| low | tui-interactions | `internal/tui/command_center.go:44` | Dead code: unreachable resumeText branch, never-rendered footer helpers, and stale comments referencing a footer that no longer exists | -| low | tui-interactions | `internal/tui/session.go:93` | Byte-index truncation can split multibyte runes in session titles and tool-result text | -| low | tui-interactions | `internal/tui/session_controls.go:165` | /compact request state is write-only — nothing consumes it | -| low | tui-interactions | `internal/tui/model.go:837` | After Ctrl+C with a hung run the UI is silently inert: no exiting indicator, prompts refused without feedback | -| low | tui-interactions | `internal/tui/rendering.go:482` | Permission card advertises an unlabeled [esc] action whose actual effect is cancelling the entire run | -| low | ops-background | `internal/cli/update.go:37` | `zero update --check` always fails on non-release builds: version "dev" is rejected before the network call | -| low | ops-background | `internal/tui/command_center.go:17` | TUI /doctor omits config paths, so config.files and config.validation always warn in the TUI while the CLI surface passes them | -| low | ops-background | `internal/cli/cron.go:275` | Byte-indexed truncation in promptExcerpt/cronTruncate splits multi-byte UTF-8 runes | -| low | ops-background | `internal/notify/notify.go:134` | notify.sanitizeMessage passes C1 control characters (U+0080–U+009F) into the OSC-9 escape it is meant to protect | -| low | ops-background | `internal/background/process_posix.go:38` | terminateProcess grace-period polling probes a possibly-reaped PID and can SIGKILL a recycled PID | -| low | tui-core | `internal/tui/model.go:512` | Cancelled runs' usage events are flushed to the session log but never recorded in the usage tracker | -| low | tui-core | `internal/tui/rendering.go:490` | renderFocusedAskUserPrompt receives the live input value but never renders it | -| low | tui-core | `internal/tui/rendering.go:62` | Dead production code: legacy footer/help builders and four theme styles are never used outside tests | -| low | tui-core | `internal/tui/view.go:227` | shortenPath matches the home directory as a bare string prefix, mangling sibling paths | -| low | tools-sandbox | `internal/tools/registry.go:108` | OnSandboxDecision callback option is set up to fire but is never wired by any caller (dead code) | -| low | tools-sandbox | `internal/sandbox/risk.go:53` | Destructive/network/installer classification recompiles N regexes over the full command on every shell call | -| low | tools-sandbox | `internal/sandbox/grants.go:146` | Grant store prefix/substring safety relies on exact-key map but Lookup trims while writer key may not match normalized name | -| low | tools-sandbox | `internal/tools/read_file.go:89` | read_file rune-width line numbering can misalign and read_file returns Truncated but no truncation marker in output | -| low | runtime-providers | `internal/modelregistry/catalog.go:36` | claude-haiku-3.5 is flagged vision-capable, but claude-3-5-haiku-20241022 does not support image input | -| low | runtime-providers | `internal/providers/factory.go:118` | isImplicitOpenAI is provably unreachable at its only call site (dead condition in profile resolution) | -| low | runtime-providers | `internal/providercatalog/catalog.go:46` | Descriptor.Public is declared but never written or read anywhere | -| low | runtime-providers | `internal/providers/providerio/retry.go:107` | HTTP 529 (Anthropic overloaded) is classified as a rate-limit error but excluded from the retry policy that retries 429/503 | -| low | specialist-verify | `internal/verify/verify.go:215` | verify.RunLoop / LoopOptions / LoopReport are dead code — production retry loop is reimplemented in selfverify | -| low | specialist-verify | `internal/specialist/output_tool.go:244` | formatTaskOutput is dead code | -| low | specialist-verify | `internal/specialist/exec.go:346` | SetPID failure path deletes the prompt file out from under a running child and records no terminal accounting/status | -| low | specialist-verify | `internal/tui/spec_mode.go:301` | Spec/session titles are truncated by byte index, splitting multi-byte UTF-8 runes | -| low | agent-loop | `internal/agent/loop.go:188` | Mid-stream context cancellation is returned as a flattened errors.New string; the ctx.Err() identity check is unreachable for that path | -| low | agent-loop | `internal/agent/types.go:183` | OnContext / MeasureContext context-utilization pipeline has no consumer on any surface | -| low | agent-loop | `internal/agent/loop.go:1072` | Duplicate items-schema assignment in propertyToRuntimeMap | -| low | agent-loop | `internal/agent/loop.go:473` | Dead stores to requestEvent after always-allow, and unreachable fallbackPermissionEvent | -| low | agent-loop | `internal/agent/system_prompt.go:89` | Project-guidelines truncation can split a multibyte UTF-8 rune in the system prompt | -| low | agent-loop | `internal/agent/loop.go:183` | Reactive mid-stream retry never forwards the retried assistant text to OnText, so streaming surfaces show the aborted partial text instead | -| low | agent-loop | `internal/zeroruntime/helpers.go:104` | Streamed text accumulation is O(n^2): collected.Text += delta copies the whole buffer on every text event | -| low | cli-commands | `internal/cli/cron_run.go:150` | cron run records lose the failure reason: exec errors go to the discarded stdout stream | -| low | cli-commands | `internal/cli/cron.go:275` | promptExcerpt/cronTruncate byte-slice strings mid-rune, emitting invalid UTF-8 | -| low | cli-commands | `internal/cli/exec_sessions.go:55` | exec resume/fork bypasses the injected session store (deps.newSessionStore never used by exec) | -| low | cli-commands | `internal/search/search.go:309` | search --session-id with a nonexistent session silently succeeds with 0 results | -| low | cli-commands | `internal/doctor/doctor.go:134` | doctor always prints apiKey: [REDACTED] whether or not a key is configured | -| low | config-plugins | `internal/zerocommands/sandbox_snapshots.go:104` | zerocommands snapshot library is largely dead: sandbox plan/decision/policy + hook/plugin/MCP snapshots have no production consumer | -| low | config-plugins | `internal/config/contracts.go:12` | internal/config/contracts.go (ContractGap API) is unused dead code | -| low | config-plugins | `internal/skills/skills.go:91` | skills.Duplicates is never surfaced and skills.Get is unused; duplicate-name collisions are silently dropped | -| low | config-plugins | `internal/plugins/plugins.go:893` | Plugin and standalone hook validators disagree on the allowed hook event set | -| low | config-plugins | `internal/config/resolver.go:136` | config maxTurns <= 0 is silently ignored rather than validated | -| low | config-plugins | `internal/config/writer.go:53` | config writer performs a non-atomic in-place write of the user config | - -## Findings by area - -### Cross-module wiring parity - -Cross-module wiring audit of the zero CLI/TUI. (1) tui.Options: all fields set in internal/cli/app.go runInteractiveTUI EXCEPT UsageTracker, ReasoningEffort, and ResponseStyle, which are read by newModel but set by no production caller (read-but-never-set; RuntimeMessageSink is legitimately wired inside tui.Run). (2) Slash commands: /theme and /input-style verified as stubs rendering "does not have a backend setting yet"; /compact only increments a counter ("Backend: state: pending integration"); /style stores a preference nothing consumes; /effort is stored and passed to agent.Options but never reaches the provider request (exec.go documents the same gap for --reasoning-effort). Help text/autocomplete are generated from the same commandDefinitions table so those surfaces are internally consistent. (3) Session events: ask_user records are persisted as role-"ask_user" EventMessage but dropped on /resume rehydration (no "content" key), and EventSessionRewind/EventCompaction have no rehydration arms (EventCompaction has no writers at all — agent-loop compaction is in-memory only). (4) agent.Options callbacks: TUI and exec wire equivalent OnText/OnToolCall/OnToolResult/OnPermission/OnUsage; exec intentionally omits OnAskUser/OnPermissionRequest; OnContext is wired by neither (dead callback); exec's session recorder swallows AppendEvent errors (set-but-never-read err field). (5) zeroline removal is clean: the only remaining references are in docs/design/ZERO_LIME_TUI_REBUILD_PROMPT.md (the rebuild instructions themselves); no help text, scripts, .github, README, or Go code references survive — though stale TUI comments still reference the removed "footer". (6) go.mod: glamour is confirmed dead (direct dep, zero imports); charmbracelet/x/ansi and muesli/termenv are also direct-but-unimported (tidy would demote them). Highest-impact concrete bugs found along the traces: the transcript dedupe key omits runID so Gemini's per-turn-reset synthetic tool IDs cause later tool cards to be silently dropped; /exit mid-run quits without the Ctrl+C checkpoint-flush machinery, orphaning checkpoint blobs and losing the run's session events; cancelled runs show no visible "Run cancelled." marker despite comments claiming one; appendTranscriptRow is O(n²) (full copy + full dedupe scan per streamed row); several byte-indexed truncations can split UTF-8 runes. - -#### [high/bug] Transcript dedupe key omits runID — repeated provider tool-call IDs silently drop later tool rows -`internal/tui/transcript.go:137` - -appendTranscriptRow dedupes rows via transcriptRowKey, which for rowToolCall/rowToolResult is keyed on kind+id only, with no run scoping. The render-side context (rowContext) was explicitly fixed to key on runID+id because providers synthesize ToolCallIDs that repeat (rendering.go:124-127: "some providers synthesize ToolCallIDs that repeat across turns (e.g. Gemini's gemini_tool_N)"), and the Gemini provider does exactly that: internal/providers/gemini/provider.go:147 creates a fresh streamState per request, so syntheticToolIndex resets to 1 every turn and emits gemini_tool_1, gemini_tool_2, ... again. Result: on Gemini (or any provider without native IDs), the SECOND turn's/run's tool-call and tool-result rows arriving via agentRowMsg are classified as duplicates of the first turn's rows and never appended — the user sees no card for those tool executions. Rehydrated rows (runID 0) collide with live rows the same way after /resume. - -``` -transcript.go:136-140: `case rowToolCall, rowToolResult:\n\tif row.id != \"\" {\n\t\treturn fmt.Sprintf(\"%d:%s\", row.kind, row.id)\n\t}` — no runID, although transcriptRow carries one (transcript.go:34 `runID int`). gemini/provider.go:269-271: `if id == \"\" { id = fmt.Sprintf(\"gemini_tool_%d\", syntheticIndex) }` with `state := streamState{}` per request (line 147). rendering.go:135 already scopes rcKey: `return strconv.Itoa(runID) + \":\" + id`. -``` - -**Suggested fix:** Include the run scope in the dedupe key, mirroring rcKey: `return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id)` (and similarly add runID to the rowPermission/rowAskUser key arms). Within-run dedupe between live agentRowMsg rows and the final agentResponseMsg re-append still works because both carry the same runID. - -#### [high/bug] /exit during an in-flight run quits immediately, orphaning checkpoint blobs and dropping the run's session events -`internal/tui/model.go:853` - -The Ctrl+C handler (model.go:294-314) goes to great lengths to defer tea.Quit until a cancelled run's final agentResponseMsg has been flushed, because the run's tool_call/tool_result/EventSessionCheckpoint events are only persisted at run end while SnapshotForCheckpoint has ALREADY written the blobs to disk — quitting early "would drop that message, orphaning the checkpoints and breaking /rewind" (the code's own words). But the /exit (alias /quit) slash command is reachable while a run is pending (handleSubmit only blocks commandPrompt when m.pending) and returns tea.Quit immediately without calling cancelRun() or waiting on flushRunIDs. Typing /exit mid-run therefore loses every session event the run accumulated (tool calls, results, permission events, usage) and leaves the already-written checkpoint blobs unreferenced on disk — exactly the loss the flushRunIDs machinery exists to prevent. - -``` -model.go:853-855: `case commandExit:\n\tm.exiting = true\n\treturn m, tea.Quit` — versus the Ctrl+C path at model.go:305-314 which sets pendingFlush, calls m.cancelRun(), and returns `m, nil` until the flush lands (model.go:519-521 fires the deferred quit). handleSubmit's pending gate at model.go:837 covers only `command.kind == commandPrompt`. -``` - -**Suggested fix:** In the commandExit case, mirror the Ctrl+C path: if m.pending && m.activeRunID != 0, call m.cancelRun(), set m.exiting = true, and return (m, nil) so the agentResponseMsg flush handler fires the deferred tea.Quit; only quit immediately when no run is in flight. - -#### [medium/wiring] exec session recorder swallows persistence errors — first failed AppendEvent silently disables all session recording -`internal/cli/exec_sessions.go:114` - -execSessionRecorder.append latches the first AppendEvent error into recorder.err and then skips every subsequent append, but no caller ever reads recorder.err: runExec (internal/cli/exec.go:405) and runExecSpecDraft (exec_spec.go:87) construct the recorder and never check it, and nothing is written to stderr or the stream-json channel. A transient disk/lock failure early in a --resume/stream-json run therefore silently stops all session persistence — the run still reports its sessionID as resumable, but the history (messages, tool calls, checkpoints' referencing events) is missing, and the user is never told. This is a set-but-never-read field producing silent history loss. - -``` -exec_sessions.go:114-121: `func (recorder *execSessionRecorder) append(...) {\n\tif recorder.err != nil || recorder.prepared.Store == nil || ... { return }\n\t_, recorder.err = recorder.prepared.Store.AppendEvent(...)\n}` — grep for `recorder.err`/`sessionRecorder.err` shows no other reads anywhere in internal/cli. -``` - -**Suggested fix:** Surface the failure: on the first error, emit writer.warning("session recording failed: "+err.Error()) (stderr for text mode, warning event for stream-json), and/or check sessionRecorder.err once after agent.Run returns and report it before exiting. - -#### [medium/wiring] ask_user events are persisted but dropped on /resume rehydration — questionnaire history lost -`internal/tui/session.go:190` - -The TUI persists each ask_user questionnaire as an EventMessage whose payload has role "ask_user", a toolCallId, header, and a questions array (model.go:1197-1200 via askUserSessionPayload). On resume, transcriptRowsFromSessionEvents handles EventMessage by reading payload "content" and skipping the event when it is empty — the ask_user payload has no content key, so every persisted ask_user record is silently dropped from the rehydrated transcript. The dedupe code even anticipates rehydrated ask_user rows (transcript.go:146-148: "Prefer row.id ... it survives rehydration even when row.askUser is nil, so a reloaded ask_user row still dedupes correctly"), but the rehydration path never constructs them. The same payload also yields an empty/garbled line in FormatExecPrompt context, but the user-visible loss is the missing transcript row after /resume. - -``` -session.go:190-193: `case sessions.EventMessage:\n\tcontent := payloadString(payload, \"content\")\n\tif content == \"\" {\n\t\tcontinue\n\t}` — while transcript.go:202-206 writes `payload := map[string]any{\"role\": \"ask_user\", \"toolCallId\": ..., \"questions\": ...}` with no "content" key. -``` - -**Suggested fix:** In transcriptRowsFromSessionEvents, before the content check, branch on `payloadString(payload, "role") == "ask_user"` and rebuild a rowAskUser transcriptRow (id from toolCallId, text from header/len(questions), detail from the questions array), mirroring askUserTranscriptRow. - -#### [medium/ux] /style stores a preference nothing consumes; /theme, /input-style and /compact are backend-less stubs listed in /help and autocomplete -`internal/tui/session_controls.go:117` - -Four registered slash commands have no effect on agent behavior. (1) /style validates and stores m.responseStyle and reports "Style preference is stored for this TUI session", but the only readers are the /style and /context display strings — runAgentWithOptions never threads it into agent.Options (which has no style field), so the response style provably changes nothing about the model's output. (2) /theme and /input-style (commands.go:201-214) resolve to shellOnlyCommandText: "This control is available in the TUI but does not have a backend setting yet." — verified stubs (the old zeroline skin switcher was removed; these survived). (3) /compact with no argument only increments m.compactRequests and prints "Backend: state: pending integration" (session_controls.go:157-167, 244-268); it never triggers the real compaction that exists in internal/agent/compaction.go. All four appear in /help and the autocomplete palette as if functional. - -``` -session_controls.go:117-122: `m.responseStyle = args ... \"Style preference is stored for this TUI session.\"`; grep shows m.responseStyle read only at command_views.go:155 and session_controls.go:120/132 (display). rendering.go:46-56: shellOnlyCommandText returns "...does not have a backend setting yet." wired from model.go:966-977. session_controls.go:165-166: `m.compactRequests++` then compactText prints `{Title: \"Backend\", Lines: []string{\"state: pending integration\"}}`. -``` - -**Suggested fix:** Wire /style into the run path (e.g. append a style directive to agent.Options.SystemPrompt in runAgentWithOptions) or label it a display-only preference; either implement /theme//input-style//compact backends or remove them from commandDefinitions so help/autocomplete stop advertising no-ops. - -#### [medium/wiring] /effort (TUI) and --reasoning-effort (exec) never reach the provider request — effort is validated, stored, and ignored -`internal/tui/session_controls.go:42` - -The TUI /effort command validates the value against the model's supported efforts and tells the user "Reasoning effort preference is stored for this TUI session"; runAgentWithOptions passes it into agent.Options.ReasoningEffort. But the agent loop forwards ReasoningEffort only into tools.RunOptions (for child/specialist runs) and never into the completion request — the zeroruntime request schema has no effort field, as the exec code itself documents: "NOTE: the effective effort is not yet forwarded to the provider request" (exec.go:848-851). So both surfaces accept, validate, and display an effort setting that has zero effect on the actual model reasoning of the current run. The /mode command's effort component is equally inert. - -``` -loop.go:489 and loop.go:644 are the only consumers: `ReasoningEffort: options.ReasoningEffort,` inside tools.RunOptions. exec.go:848-851: "NOTE: the effective effort is not yet forwarded to the provider request — the zeroruntime.CompletionRequest / provider wire schemas carry no effort field." session_controls.go:43-48 tells the user the preference is stored for the session. -``` - -**Suggested fix:** Add a ReasoningEffort field to zeroruntime.CompletionRequest and map it per provider (OpenAI reasoning_effort, Anthropic thinking budget, Gemini thinkingConfig), or change the /effort//mode/exec advisory text to state the value currently only affects spawned child runs. - -#### [medium/ux] Cancelling a run (Esc/Ctrl+C) leaves no visible marker in the live transcript despite code comments claiming one is written -`internal/tui/model.go:1067` - -cancelRun records a "Run cancelled." EventError into the SESSION store only; it never appends a transcript row or system note. The streamingText interim block is cleared, the spinner stops, and the cancelled goroutine's trailing rows/error are deliberately skipped in the flush path (model.go:498-523) — so on screen the partial answer simply vanishes with no indication the run was interrupted. Multiple comments assert the opposite ("cancelRun ... writes the 'Run cancelled.' marker", "the cancel path already wrote the 'Run cancelled.' marker"), but the marker only becomes visible after a later /resume rehydrates the session's EventError. As a side effect of the same code, the persisted log records the cancellation error BEFORE the cancelled run's flushed tool/checkpoint events, so resumed transcripts show "Run cancelled." above the tool activity it cancelled. - -``` -model.go:1081-1087: `if m.pending && m.activeSession.SessionID != \"\" { if next, err := (*m).appendSessionEvent(sessions.EventError, map[string]any{\"message\": \"Run cancelled.\"}); ... }` — no reduceTranscript/appendTranscriptRow call anywhere in cancelRun or the KeyEsc handler (model.go:315-340). Comment at model.go:295-296: "cancelRun records the in-flight run into flushRunIDs and writes the 'Run cancelled.' marker, exactly like the Esc path." -``` - -**Suggested fix:** In cancelRun, also append a visible row: `m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Run cancelled."})` (guarded by m.pending), and optionally defer the session EventError append into the flush handler so the persisted ordering matches execution. - -#### [medium/perf] appendTranscriptRow copies the whole transcript and re-scans it for dedupe on every append — O(n²) in the streaming hot path -`internal/tui/transcript.go:113` - -Every appended row (each agentRowMsg for tool calls/results, each permission event, plus the full msg.rows re-append at run end in the agentResponseMsg handler at model.go:543-545) (a) allocates and copies the entire transcript slice and (b) runs hasTranscriptRow, which computes transcriptRowKey for every existing row. Both are O(n) per append, so a long tool-heavy session degrades quadratically in CPU and allocation: at a few thousand rows, every streamed tool result costs thousands of key computations plus a full slice copy, inside the Bubble Tea Update loop where it directly delays rendering and input handling. - -``` -transcript.go:113-133: `func appendTranscriptRow(rows []transcriptRow, row transcriptRow) []transcriptRow {\n\tif hasTranscriptRow(rows, row) { return rows }\n\tnext := append([]transcriptRow{}, rows...)\n\tnext = append(next, row)\n\treturn next\n}` and `for _, existing := range rows { if transcriptRowKey(existing) == key { return true } }`; called per-message from model.go:572 (agentRowMsg) and in loops at model.go:534-545. -``` - -**Suggested fix:** Keep a `seen map[string]struct{}` of row keys on the model (updated alongside the transcript) so dedupe is O(1), and append in place (`append(rows, row)`) — the model is already passed by value through Update, so the defensive full copy is unnecessary; if copy-on-write is desired, rely on append's amortized growth instead of copying every time. - -#### [low/wiring] tui.Options.UsageTracker, ReasoningEffort, and ResponseStyle are read by the TUI but never set by any production caller -`internal/tui/options.go:28` - -tui.Options is constructed in exactly one production location (internal/cli/app.go:366-386, runInteractiveTUI), which sets Cwd/ProviderName/ModelName/ProviderProfile/Provider/NewProvider/Registry/SessionStore/SandboxStore/AgentOptions/PermissionMode/Notify but never UsageTracker, ReasoningEffort, or ResponseStyle. newModel reads all three (model.go:214, 258, 259), so in every real launch the TUI silently falls back to a fresh in-memory usage tracker, effort "auto", and style "balanced". The fields are exercised only by tests; there is no config or flag path that can ever reach them, so a user cannot pre-seed effort/style for the interactive shell. RuntimeMessageSink is also unset by the CLI but is legitimately wired inside tui.Run (run.go:15-24). - -``` -options.go:28-33 declares `UsageTracker *usage.Tracker`, `ReasoningEffort modelregistry.ReasoningEffort`, `ResponseStyle string`; app.go:366-386 is the only non-test `tui.Options{` literal (grep) and omits all three; model.go:214-217 falls back: `usageTracker := options.UsageTracker; if usageTracker == nil { usageTracker = usage.NewTracker(...) }`. -``` - -**Suggested fix:** Either wire them (e.g. pass a config-resolved default effort/style and a shared tracker from runInteractiveTUI) or delete the three Options fields and have newModel construct the defaults directly, so the API does not advertise configuration that can never arrive. - -#### [low/bug] Byte-indexed truncation of UTF-8 strings can split multi-byte runes (tool output, session titles) -`internal/tui/transcript.go:303` - -Several truncations slice strings at byte offsets: truncateTUIOutput cuts tool output at byte 240 (`output[:limit]`) for every tool-result row text; tuiSessionTitle cuts the session title at byte 80 (session.go:92-94); specImplementationTitle does the same (spec_mode.go:301-303); the CLI's createSessionTitle mirrors it (internal/cli/exec_sessions.go:88-91); sessions.summarizePayload cuts at byte 500 (exec_session.go:193-195). Any CJK/emoji/accented content at the boundary is split mid-rune, producing an invalid UTF-8 tail (\xef\xbf\xbd replacement glyphs in the transcript, malformed JSON-adjacent text in persisted titles/prompts). The codebase already has correct rune/width-aware helpers (truncateRunes, splitAtWidth) used elsewhere. - -``` -transcript.go:300-303: `if limit <= 0 || len(output) <= limit { return output }\nreturn output[:limit] + \" [truncated]\"`; session.go:92-94: `if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] }`. -``` - -**Suggested fix:** Use the existing rune-safe helper (truncateRunes) or `string([]rune(s)[:n])` at these sites; for the 500/240-byte payload previews, back up to a rune boundary with utf8.RuneStart before slicing. - -#### [low/dead-code] Dead direct dependencies: glamour (and x/ansi, termenv) declared in go.mod but imported nowhere -`go.mod:10` - -github.com/charmbracelet/glamour v1.0.0 is a direct requirement but no Go file in the repo imports it (grep over all *.go returns zero hits) — it drags the chroma/goldmark/bluemonday/gorilla-css tree into go.sum and the module graph for nothing. github.com/charmbracelet/x/ansi and github.com/muesli/termenv are likewise listed in the direct require block with zero imports (they are real transitive deps of lipgloss/bubbletea and would be re-marked `// indirect` by go mod tidy). The suspicion in the audit brief is confirmed: glamour is dead. - -``` -go.mod:8-15 direct block includes `github.com/charmbracelet/glamour v1.0.0`, `github.com/charmbracelet/x/ansi v0.11.6`, `github.com/muesli/termenv v0.16.0`; `grep -rl "charmbracelet/glamour\|charmbracelet/x/ansi\|muesli/termenv" --include=*.go .` returns no files; `go build ./...` succeeds without them being imported. -``` - -**Suggested fix:** Run `go mod tidy`: glamour (and its indirect tree) is removed entirely; x/ansi and termenv move to the indirect block. - -#### [low/dead-code] Dead production code in the TUI: footer/help helpers, unused transcript actions, unused theme tokens, dead resumeText branch, and the never-wired OnContext callback -`internal/tui/rendering.go:62` - -A cluster of production code is reachable only from tests: (1) the entire footer system — defaultCommandFooterText, commandFooterText, footerText, formatCommandFooterText, runState (rendering.go:23-118) — the Lime View renders statusLine instead, and stale comments still reference the removed footer (commands.go:238 'the footer advertises "! bash"', autocomplete.go:57 'the footer advertises "/ commands"'); (2) formatCommandHelpLines/listCommandNames (commands.go:281-305) — test-only; (3) transcript actions actionAppendToolCall/actionAppendToolResult (transcript.go:53-54, 82-99) — no production reducer call; (4) theme tokens selRow, statusOk, statusErr, line2, and the panel2 field (theme.go:67-79) — zero non-test references; (5) resumeText's args!="" branch (command_center.go:44-54) is unreachable — handleResumeCommand only calls resumeText(""); (6) agent.Options.OnContext / MeasureContext (agent/types.go:183, loop.go:101-102) is set by no production caller (neither TUI nor exec), so per-turn context breakdowns are computed for nobody. - -``` -grep: `footerText|commandFooterText|formatCommandHelpLines|listCommandNames|defaultCommandFooterText` match only rendering.go/commands.go definitions and *_test.go files; `zeroTheme.selRow|statusOk|statusErr|line2|panel2` → 0 non-test hits; `resumeText(` called only at session.go:104 with ""; `OnContext` set nowhere outside internal/agent and tests. -``` - -**Suggested fix:** Delete the dead helpers, actions, theme fields, and the unreachable resumeText branch; either wire OnContext into the TUI status line (it was designed for exactly that) or remove the callback; update the two stale footer comments. - -#### [low/bug] Completed runs never invoke their context cancel function — CancelFunc leak per prompt -`internal/tui/model.go:525` - -handleSubmit creates a child context per run (`runCtx, cancel := context.WithCancel(m.ctx)`, model.go:1056) and stores cancel in m.runCancel. On the cancel paths cancelRun() invokes it, but on NORMAL completion the agentResponseMsg handler just nils the field without calling it: every successfully completed turn leaks its CancelFunc, leaving the child context registered on m.ctx for the life of the process (go vet's lostcancel pattern). In a long interactive session this accumulates one leaked context per prompt. - -``` -model.go:525-527: `m.pending = false\n\tm.runCancel = nil\n\tm.activeRunID = 0` — m.runCancel is dropped without being invoked; contrast cancelRun (model.go:1068-1070) which calls `m.runCancel()` first. -``` - -**Suggested fix:** In the active-run agentResponseMsg branch, call `if m.runCancel != nil { m.runCancel() }` before setting it to nil (cancelling an already-finished run's context is a harmless no-op). - - -### CLI core wiring - -Audited the zero CLI entry wiring (cmd/zero/main.go, internal/cli/app.go, the exec path: exec.go/exec_parse.go/exec_tools.go/exec_sessions.go/exec_writer.go/exec_spec.go/mcp_tools.go/shutdown.go, provider_setup.go, observability.go, command_center.go) plus the TUI option-consumption side (internal/tui/options.go, run.go, model.go) and the agent-loop deferral gates that the CLI mirrors. Verified clean: every appDeps field set in defaultAppDeps is consumed somewhere (getwd/stdin/userConfigPath/resolve*/newProvider/probeProviderHealth/newSessionStore/loadPlugins/loadHooks/skillsDir/newMCPStore/newSandboxStore/selectSandboxBackend/registerMCPTools/prepareWorktree/detectVerifyPlan/runVerify/runSelfVerify/inspectChanges/commitChanges/runTUI/runEditor/checkUpdate/now), fillAppDeps covers all 24 fields; runInteractiveTUI populates Cwd/ProviderName/ModelName/ProviderProfile/Provider/NewProvider/Registry/SessionStore/SandboxStore/AgentOptions/PermissionMode/Notify, and the unset tui.Options fields are safe by construction (UsageTracker is defaulted in newModel, RuntimeMessageSink is wrapped by tui.Run into program.Send, ResponseStyle defaults to \"balanced\", ReasoningEffort has no config source to drop; Model/Cwd/ContextWindow are filled per-run by the TUI at model.go:1123-1133); version injection is correctly wired via internal/release BuildLdflags to internal/cli.version; the tool_search registration gate in app.go/exec.go matches agent partitionTools (including the disabled-tools threshold-zeroing and the allowlist exemption at loop.go:935-941); notify focus wiring (FocusMsg/BlurMsg/SetFocused) is complete; exit codes 0/1/2/3/130 are consistently produced in text mode and stderr/stdout discipline holds for text and stream-json. Found 8 concrete defects: (1) `zero --skip-permissions-unsafe` silently drops all trailing args (app.go:149); (2) Ctrl+C under `-o json` ends the stdout JSON stream with no error/done terminal event, unlike every other terminal path (exec.go:500, also exec_spec.go:164); (3) `exec --list-tools -o json` ignores the format and prints plain text (exec.go:182); (4) tui.Run swallows program.Run's error, exiting 1 with no diagnostic on the default chat path (tui/run.go:41); (5) the exec path bypasses the injected session store and clock (PrepareExec without Store at exec.go:353, sessions.NewStore at exec_sessions.go:55, time.Now at exec.go:374); (6) session-title truncation slices UTF-8 mid-rune (exec_sessions.go:88); (7) stream-json/stderr output truncation slices runes mid-sequence (exec_writer.go:403/412); (8) providers list shows \"api key: not set\" for AuthHeaderValue-credentialed profiles that providers check accepts (zerocommands/contracts.go:166). No data races found on the audited paths (agent callbacks run synchronously inside agent.Run; notify.Notifier is mutex-guarded; tui.Run's program capture has a happens-before chain through goroutine creation). - -#### [medium/ux] `zero --skip-permissions-unsafe` silently discards all trailing arguments -`internal/cli/app.go:149` - -The top-level dispatch switches on args[0] only, and the `--skip-permissions-unsafe` branch returns immediately into the interactive TUI without ever looking at args[1:]. A user who combines the flag with anything else — e.g. `zero --skip-permissions-unsafe -p "task"` or `zero --skip-permissions-unsafe exec "task"` — gets an interactive unsafe-mode TUI and their prompt/subcommand is silently dropped, with no 'unexpected argument' diagnostic. Every other surface that accepts this flag (exec) parses it positionally anywhere, so the asymmetry is surprising; the unknown-command path proves the CLI normally diagnoses bad invocations (exit 2). - -``` -case "--skip-permissions-unsafe": - // Launch the interactive TUI directly in unsafe mode. ... - return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe) // args[1:] never examined -``` - -**Suggested fix:** In that case branch, reject extra args: `if len(args) > 1 { fmt.Fprintf(stderr, "unexpected argument %q after --skip-permissions-unsafe\n", args[1]); return 2 }` before calling runInteractiveTUI. - -#### [medium/ux] Ctrl+C with `-o json` ends the JSON event stream with no error/done terminal event -`internal/cli/exec.go:500` - -The interrupted branch of runExec special-cases only execOutputStreamJSON. For `-o json`, the protocol on stdout has already emitted {"type":"run_start"} plus text/tool/usage objects, and every other terminal path closes the stream with a terminal object — writer.final emits {"type":"done","exit_code":0} and writeExecProviderError emits {"type":"error"} + {"type":"done","exit_code":3}. On SIGINT, however, the json stream just stops; "Interrupted." goes to stderr and the process exits 130. A machine consumer of `-o json` cannot distinguish an interrupt from a crash/truncated pipe. The identical gap exists in the spec-draft path at internal/cli/exec_spec.go:164-175. - -``` -if errors.Is(err, context.Canceled) || runCtx.Err() != nil { - sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"}) - if options.outputFormat == execOutputStreamJSON { - writer.errorEvent("interrupted", "run cancelled by signal", false) - writer.runEnd("interrupted", exitInterrupted) - ... - } else { - fmt.Fprintln(stderr, "Interrupted.") - } - return exitInterrupted -``` - -**Suggested fix:** Add an execOutputJSON branch mirroring writeExecProviderError: writer.errorEvent("interrupted", "run cancelled by signal", false) followed by writer.writeJSON(map[string]any{"type": "done", "exit_code": exitInterrupted}); apply the same in runExecSpecDraft. - -#### [medium/ux] `zero exec --list-tools -o json` ignores the requested JSON format and prints plain text -`internal/cli/exec.go:182` - -The --list-tools branch honors stream-json (it wraps the list in run_start/final/run_end events) but for `-o json` it falls through to writeExecToolList, which prints the human-readable "Tools visible to model:" table to stdout. A consumer that asked for json gets unparseable text with exit 0, even though every other exec path (writer.runStart/final/toolResult, writeExecProviderError) emits JSON objects for this format. Secondary nit in the same branch: the stream-json path passes execRunMetadata{} so its run_start event carries empty provider/model/api_model fields even though resolveExecRunMetadata is computed later in the function. - -``` -if options.listTools { - if options.outputFormat == execOutputStreamJSON { - return writeExecStreamJSONFinal(stdout, workspaceRoot, execRunMetadata{}, permissionMode, formatExecToolList(registry, options, permissionMode), exitSuccess) - } - if err := writeExecToolList(stdout, registry, options, permissionMode); err != nil { - return exitCrash - } - return exitSuccess -} -``` - -**Suggested fix:** Add an execOutputJSON branch that emits the tool list as a JSON object (e.g. writeJSONLine(stdout, map[string]any{"type":"tools","tools": visibleExecTools(...)}) followed by {"type":"done","exit_code":0}). - -#### [medium/ux] TUI program errors are swallowed: exit code 1 with zero diagnostics on the default chat path -`internal/tui/run.go:41` - -tui.Run — the function wired as deps.runTUI and the terminal step of the default `zero` invocation — discards the error returned by program.Run(). If Bubble Tea fails (cannot open the TTY, terminal init failure, renderer error), the process exits 1 having printed nothing to stderr, leaving the user with no clue why the app vanished. Contrast with runInteractiveTUI, which prefixes every other failure with "[zero] ..." on stderr before returning a nonzero code. - -``` -if _, err := program.Run(); err != nil { - return 1 -} -return 0 -``` - -**Suggested fix:** Print the error before returning: `if _, err := program.Run(); err != nil { fmt.Fprintf(os.Stderr, "[zero] tui error: %v\n", err); return 1 }` (or thread the stderr writer through tui.Options). - -#### [low/wiring] Exec path bypasses injected session store and clock (deps seam silently ignored) -`internal/cli/exec.go:353` - -appDeps.newSessionStore is the dependency-injection seam used by search (observability.go:117), sessions, spec, usage, the spec-draft exec path (exec_spec.go:51), and the interactive TUI — but the primary exec run never uses it: sessions.PrepareExec is called without populating its Store field (sessions.PrepareExecOptions.Store exists and PrepareExec falls back to NewStore(StoreOptions{}) when nil, internal/sessions/exec_session.go:57-60), and preflightExecSession constructs `sessions.NewStore(sessions.StoreOptions{})` directly (exec_sessions.go:55). Any caller/test that injects a custom store gets exec sessions written to the real default store while the rest of the CLI honors the injection. The same seam-bypass applies to the clock: exec.go:374 uses `time.Now()` and exec_writer.go:416 uses the local `timeNow()` for run IDs even though deps.now is injected and exec_spec.go:65 correctly uses run.deps.now(). - -``` -preparedSession, err = sessions.PrepareExec(sessions.PrepareExecOptions{ - SessionID: options.initSessionID, - ... // no Store: field — PrepareExec falls back to NewStore(StoreOptions{}) -}) -// exec_sessions.go:55: store := sessions.NewStore(sessions.StoreOptions{}) -// exec.go:374: runID, err := streamjson.CreateRunID(time.Now()) -``` - -**Suggested fix:** Pass `Store: deps.newSessionStore()` in PrepareExecOptions, thread deps into preflightExecSession, and use deps.now() for CreateRunID. - -#### [low/bug] Session title truncation slices UTF-8 mid-rune -`internal/cli/exec_sessions.go:88` - -createSessionTitle truncates the user's prompt at byte offset 80. For any non-ASCII prompt whose 80th byte lands inside a multi-byte UTF-8 sequence (CJK, accented text, emoji), the persisted session title ends with a broken rune; json.Marshal then stores U+FFFD replacement characters in the session metadata, which surfaces as mojibake in `zero sessions`, `/resume`, and stream-json session payloads. The title feeds execSessionTitle for every exec run that creates a session. - -``` -title := strings.Join(strings.Fields(prompt), " ") -if len(title) > 80 { - title = title[:80] -} -``` - -**Suggested fix:** Truncate on a rune boundary, e.g. `if r := []rune(title); len(r) > 80 { title = string(r[:80]) }`, or back up with utf8.RuneStart before slicing. - -#### [low/bug] Stream-json and stderr tool-output truncation split runes mid-sequence -`internal/cli/exec_writer.go:412` - -truncateForStreamJSONOutput cuts tool output at a fixed byte offset (10 KiB) and truncateForStatus at byte 200; both can bisect a multi-byte UTF-8 rune. For stream-json, the broken trailing bytes are marshalled by streamjson.FormatEvent and become U+FFFD in the protocol output consumed by machine clients; for text mode the stderr "[result] ..." line ends in raw invalid bytes. Tool output is routinely non-ASCII (file contents, grep results), so the boundary case is hit in practice on any sufficiently large multilingual output. - -``` -func truncateForStreamJSONOutput(value string) (string, bool) { - if len(value) <= streamJSONToolResultOutputLimit { - return value, false - } - return value[:streamJSONToolResultOutputLimit] + "\n[truncated]", true -} -// truncateForStatus: return compact[:200] + "..." -``` - -**Suggested fix:** Before slicing, walk back to a rune boundary: `cut := limit; for cut > 0 && !utf8.RuneStart(value[cut]) { cut-- }; return value[:cut] + ...` in both helpers. - -#### [low/ux] Providers list/current reports "api key: not set" for profiles authenticated via --auth-header-value -`internal/zerocommands/contracts.go:166` - -The provider snapshot computes APIKeySet solely from profile.APIKey, but the CLI's own readiness check (provider_setup.go:407-409 providerProfileHasCredential) treats a non-empty AuthHeaderValue as an equivalent credential, and `zero providers add --auth-header-value` is a documented way to store the credential. The result is contradictory output: `zero providers check` passes for such a profile while `zero providers list` / `zero config` (command_center.go:328 prints "api key: %s") show "api key: not set", telling the user their working provider is misconfigured. - -``` -APIKeySet: strings.TrimSpace(profile.APIKey) != "", -// vs provider_setup.go:407: return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" -``` - -**Suggested fix:** Set APIKeySet with the same predicate as providerProfileHasCredential: `strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""` (or rename the rendered label to "credential"). - - -### MCP & stream-json - -Audited internal/mcp (client.go, protocol.go, server.go, network_client.go, registry.go, config.go, schema.go), internal/streamjson, and the cli serve/exec wiring (serve.go, mcp_tools.go, exec.go, exec_writer.go, app.go, extensions.go). 11 concrete defects. Most impactful: (1) the stdio transport implements LSP Content-Length framing instead of MCP's newline-delimited JSON, so both the stdio client and `zero serve --mcp` are incompatible with every standard MCP implementation — a configured real server hangs initialize for 30s then aborts the whole run; (2) the frame reader allocates make([]byte, N) from a peer-controlled Content-Length up to MaxInt64, and the resulting makeslice panic in an unrecovered goroutine crashes the entire process (empirically verified); (3) streamjson's home-grown redaction pattern `sk-[A-Za-z0-9._-]+` mangles ordinary words in every stream-json output event ("task-list" -> "ta[REDACTED]", verified), even though internal/redaction already has correctly bounded patterns. Additional protocol bugs: client readLoop misroutes server->client requests (ping/sampling) as responses on id collision and never answers ping; streamable-HTTP SSE decoding grabs the first event instead of the id-matched response; a 1 MiB SSE scanner cap permanently kills SSE sessions on large messages. Resource/deadlock issues: stdio request write blocks under client.mu with no ctx cancellation (write-side stdio deadlock until Close), and child stderr accumulates unbounded in memory for the server's lifetime. Hygiene: mid-rune byte truncation in stream-json tool output, dead EventRestore schema constant, and the serve-side ping method-not-found response. The pending-map id-correlation/dispatch machinery itself (failAll/removePending/Close ordering, SSE pending channel lifecycle) checked out race-free, and MCP runtime Close() is correctly deferred on all cli paths (exec.go:175, app.go:334, extensions.go:231). - -#### [critical/security] Unbounded Content-Length allocation lets a peer crash the whole process -`internal/mcp/protocol.go:69` - -messageReader.read() parses the peer-supplied Content-Length with strconv.Atoi (accepts up to MaxInt64 on 64-bit) and only rejects values <= 0, then does `make([]byte, contentLength)` with no upper bound. A single frame `Content-Length: 9223372036854775807\r\n\r\n` causes a `makeslice: len out of range` runtime panic (verified with go run: Atoi succeeds, make panics); smaller huge values (e.g. tens of GB) cause OOM. The panic fires inside Client.readLoop's goroutine (client.go:297) or mcp.Serve's read goroutine (server.go:52), neither of which recovers, so a malicious or buggy MCP server — or any MCP host talking to `zero serve --mcp` — crashes the entire zero process mid-session, losing in-flight TUI/exec state. - -``` -`parsed, err := strconv.Atoi(strings.TrimSpace(value)); if err != nil || parsed <= 0 { return rpcMessage{}, fmt.Errorf("invalid MCP content length %q", value) }` (protocol.go:58-61) followed by `body := make([]byte, contentLength)` (protocol.go:69). Verified: `make([]byte, 9223372036854775807)` panics with "runtime error: makeslice: len out of range". -``` - -**Suggested fix:** Add a maximum message size constant (e.g. const maxMessageBytes = 32 << 20) and return an error when contentLength exceeds it before allocating: `if contentLength > maxMessageBytes { return rpcMessage{}, fmt.Errorf("MCP message of %d bytes exceeds limit", contentLength) }`. - -#### [high/bug] MCP stdio transport uses LSP Content-Length framing instead of MCP newline-delimited JSON -`internal/mcp/protocol.go:42` - -The MCP spec (including the "2024-11-05" protocolVersion this code advertises in client.go:133 and server.go:15) defines the stdio transport as newline-delimited JSON-RPC messages. protocol.go instead implements LSP-style framing: read() loops over header lines until it finds "content-length", and write() emits a Content-Length header block. Consequence on the client side: any standard stdio MCP server (e.g. npx @modelcontextprotocol/server-*) writes bare JSON lines, which read() consumes forever as 'header' lines (strings.Cut on ':' never matches content-length), so initialize() hangs for the full 30s initializeTimeout and then registerMCPToolsForWorkspace aborts the whole exec/TUI run with mcp_error (exec.go:171-175). On the server side, `zero serve --mcp` (README: "expose Zero read-only tools over MCP stdio") emits Content-Length frames that no standard MCP host (Claude Desktop/Code, Cursor, MCP Inspector) can parse, and cannot parse their newline JSON, so the serve feature is unusable with real hosts. The unit tests pass only because both ends use the same private messageReader/messageWriter. - -``` -read(): `line, err := reader.reader.ReadString('\n') ... if strings.EqualFold(strings.TrimSpace(name), "content-length") { ... } ... if contentLength <= 0 { return rpcMessage{}, fmt.Errorf("missing MCP content length") }` and write(): `fmt.Fprintf(writer.writer, "Content-Length: %d\r\n\r\n", len(body))` (protocol.go:45-66, 88). Client advertises `"protocolVersion": "2024-11-05"` (client.go:133); the MCP spec for that version mandates newline-delimited messages over stdio. -``` - -**Suggested fix:** Replace the framing in messageReader/messageWriter with newline-delimited JSON: write() should json.Marshal the message and append a single '\n'; read() should read one line (bufio with a generous limit), skip blank lines, and json.Unmarshal it. This fixes both Connect (stdio client) and Serve in one place. - -#### [high/ux] streamjson secret patterns mangle ordinary text in every stream-json output event -`internal/streamjson/streamjson.go:310` - -FormatEvent runs redactString over every string field of every output event (text deltas, final answers, tool_result output). The pattern `sk-[A-Za-z0-9._-]+` has no left word-boundary and no minimum length, so it matches inside ordinary words: verified that "update the task-list now" becomes "update the ta[REDACTED] now" and "a risk-based approach" becomes "a ri[REDACTED] approach". Likewise `(?i)(bearer\s+)[A-Za-z0-9._-]+` rewrites prose like "Bearer tokens are..." to "Bearer [REDACTED] are...". For a coding agent whose answers routinely contain words like task-list, risk-based, desk-, flask-, every stream-json consumer (editor extensions, automation) receives corrupted text. Notably the repo already has a correct implementation: internal/redaction/redaction.go uses bounded patterns like `\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`, and tool outputs are already scrubbed once at the registry boundary (tools/registry.go scrubResultSecrets) before streamjson re-mangles them. - -``` -`var secretPatterns = []*regexp.Regexp{ regexp.MustCompile(`sk-[A-Za-z0-9._-]+`), regexp.MustCompile(`(?i)(api[_-]?key["'=:\s]+)[^"',\s)]+`), regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._-]+`), }` (streamjson.go:309-313), applied to all strings via redactValue in FormatEvent (streamjson.go:154). Verified output: `task-list -> "update the ta[REDACTED] now"`. -``` - -**Suggested fix:** Replace streamjson's ad-hoc secretPatterns with the bounded patterns from internal/redaction (e.g. `\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`, bearer requiring a 12+ char token), or call redaction.RedactString instead of maintaining a divergent copy. - -#### [medium/bug] Client readLoop misroutes server-to-client requests as responses (id collision) and never answers ping -`internal/mcp/client.go:302` - -readLoop dispatches every message that carries an id to the pending-request map, without checking whether the message is itself a request (Method set). JSON-RPC server-to-client requests (MCP "ping", "roots/list", "sampling/createMessage") use the server's own id space, which typically starts at 0/1 — the same range as client.nextID (starts at 1). When a server request's id collides with a pending client call, the caller's request resolves with the request frame (Error nil, Result empty), so e.g. tools/call silently 'succeeds' with an empty result, and the real response arriving later finds no pending entry and is dropped. Independently, the client never replies to ping requests, so servers that health-check the connection will time out and disconnect. - -``` -`if message.ID == nil { continue }\n id, ok := rpcMessageID(message.ID) ... responses := client.pending[id] ... responses <- dispatchResult{message: message}` (client.go:302-316) — no `message.Method` check anywhere in readLoop, while rpcMessage carries a Method field (protocol.go:15). -``` - -**Suggested fix:** In readLoop, before id dispatch: `if message.Method != "" { if message.Method == "ping" && message.ID != nil { _ = client.writer.write(rpcMessage{ID: message.ID, Result: json.RawMessage("{}")}) }; continue }` (the write must go through the same mutex used by request()). - -#### [medium/race] Stdio request write blocks indefinitely while holding client.mu and ignores ctx cancellation -`internal/mcp/client.go:246` - -Client.request acquires client.mu, then calls client.writer.write(), a blocking pipe write to the child's stdin, and only releases the mutex afterwards. ctx is consulted at entry (line 213) and in the post-write select (line 258), but not during the write. If the MCP server stops draining stdin (e.g. it is busy executing the previous tool while the client sends a request whose JSON body exceeds the ~64 KiB OS pipe buffer — easy with large tool arguments), the write blocks forever: the in-flight CallTool cannot be cancelled by its context (TUI Esc / exec timeout), and every other request() caller queues on client.mu. The only escape is Client.Close() at process shutdown, which closes stdin. The hang_test suite covers read-side hangs but not this write-side stdio deadlock. - -``` -`client.mu.Lock()\n id := client.nextID ... if err := client.writer.write(rpcMessage{ID: id, Method: method, Params: rawParams}); err != nil { ... }\n client.mu.Unlock()` (client.go:228-255) — writer.write → bufio Flush → blocking os.Pipe write with no ctx selection. -``` - -**Suggested fix:** Perform the write in a goroutine and select on ctx: send the write result over a channel and, on ctx.Done(), return ctx.Err() after removePending(id) (let the orphaned write finish or fail when stdin closes). Alternatively register a context.AfterFunc(ctx, func(){ stdin.Close() }) style escape for the in-flight write. - -#### [medium/perf] Child stderr captured into an unbounded bytes.Buffer for the entire server lifetime -`internal/mcp/client.go:98` - -connectStdio sets cmd.Stderr to a bytes.Buffer whose contents are only ever read in the initialize-failure error path. After a successful handshake the buffer stays attached for the whole life of the MCP server process. MCP stdio servers conventionally log to stderr (the spec explicitly designates stderr for logging), so a chatty server in a long-lived TUI session grows this buffer without bound — an unbounded memory leak proportional to everything the server ever logs. - -``` -`var stderr bytes.Buffer\n cmd.Stderr = &stderr` (client.go:98-99); the only read is `message := strings.TrimSpace(stderr.String())` inside the initialize error branch (client.go:114). Nothing truncates or detaches the buffer after a successful Connect. -``` - -**Suggested fix:** Replace the bytes.Buffer with a small capped ring writer (e.g. keep the last 8 KiB) used only for the handshake diagnostic, or pipe stderr to io.Discard after initialize succeeds. - -#### [medium/bug] Streamable-HTTP SSE response decoding takes the first event instead of the matching response -`internal/mcp/network_client.go:621` - -When an HTTP MCP server answers a POST with Content-Type text/event-stream, decodeSSERPCMessage returns the first non-empty "message" event and stops scanning. The streamable-HTTP spec explicitly allows the server to send JSON-RPC notifications (logging, progress) and requests on that stream before the final response. If the first event is such a notification, networkClient.request receives a frame whose ID is nil/different, fails the `rpcIDMatches(message.ID, id)` check at line 210, and the call errors with "response id mismatch" while the real response further down the stream is discarded. Any conforming server that emits notifications/message or progress during a long tools/call breaks every call. - -``` -`if err := decoder.Decode(&decoded); err != nil { ... }\n found = true\n return false` (network_client.go:617-622) — scanning stops on the first decodable message event with no Method/ID filtering; caller then enforces `if !rpcIDMatches(message.ID, id) { return fmt.Errorf("MCP %s response id mismatch...") }` (network_client.go:210-211). -``` - -**Suggested fix:** Pass the expected id into decodeSSERPCMessage and keep scanning: skip events where `decoded.Method != ""` or `!rpcIDMatches(decoded.ID, id)`, returning only the frame that is actually the response. - -#### [medium/bug] 1 MiB SSE scanner token cap permanently kills the SSE session on large messages -`internal/mcp/network_client.go:637` - -scanSSEEvents uses bufio.Scanner with a hard 1 MiB max token size. A single `data:` line longer than 1 MiB (a tools/call result with a big file or payload — the stdio path has no comparable limit, and exec truncates only at the output stage) makes scanner.Scan() fail with bufio.ErrTooLong. For remoteSSEClient this error propagates out of readStream into failPending, which sets streamErr permanently; there is no reconnect, so every subsequent request on that server fails with "token too long" until restart. For the streamable-HTTP POST path it fails that call. - -``` -`scanner := bufio.NewScanner(reader)\n scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)` (network_client.go:636-637); on error: `if err != nil { ... client.failPending(err); return }` (network_client.go:495-501) and `client.streamErr = err` in failPending (network_client.go:584) with no recovery path. -``` - -**Suggested fix:** Read SSE lines with a bufio.Reader loop (ReadString('\n') accumulates arbitrarily long lines) or raise the scanner limit to the transport message cap; at minimum treat ErrTooLong as a per-message error rather than a terminal stream error. - -#### [low/bug] Stream-json/status truncation slices strings mid-rune, emitting invalid UTF-8 -`internal/cli/exec_writer.go:412` - -truncateForStreamJSONOutput cuts tool output at a fixed byte offset (10240) and truncateForStatus at byte 200. Both can split a multi-byte UTF-8 rune (e.g. CJK output from grep/read_file), leaving a dangling lead byte. json.Marshal then substitutes U+FFFD, so stream-json consumers see a spurious replacement character appended to truncated tool_result output; the plain-text status path writes raw invalid bytes to stderr. - -``` -`return value[:streamJSONToolResultOutputLimit] + "\n[truncated]", true` (exec_writer.go:412) and `return compact[:200] + "..."` (exec_writer.go:403) — pure byte slicing with no rune-boundary handling. -``` - -**Suggested fix:** Back up to a rune boundary before appending the marker: `cut := streamJSONToolResultOutputLimit; for cut > 0 && !utf8.RuneStart(value[cut]) { cut-- }; return value[:cut] + ...` (same for the 200-byte status truncation). - -#### [low/dead-code] streamjson.EventRestore is declared but never emitted -`internal/streamjson/streamjson.go:30` - -The output event type `EventRestore = "restore"` (paired with CheckpointInfo.FilesRestored/FilesDeleted/Skipped fields that exist to describe a restore) is never produced anywhere: a repo-wide grep finds no writer constructing an Event with Type EventRestore — exec_writer.go emits checkpoint events only. A stream-json consumer implementing the schema will wait for restore events that can never arrive, and the constant plus the restore-specific CheckpointInfo fields are dead weight in the protocol surface. - -``` -`EventRestore EventType = "restore"` (streamjson.go:30); `grep -rn EventRestore internal` returns only this declaration. exec_writer.go has checkpoint() but no restore writer; docs/STREAM_JSON_PROTOCOL.md documents neither. -``` - -**Suggested fix:** Either emit a restore event from the checkpoint-restore path that already records sessions restore data, or delete EventRestore (and the restore-only CheckpointInfo fields) from the public schema. - -#### [low/bug] MCP server answers "ping" with method-not-found instead of an empty result -`internal/mcp/server.go:129` - -toolServer.handle routes every method other than initialize/tools/list/tools/call to the default branch, which returns a -32601 error frame. The MCP spec's ping utility requires the receiver to "respond promptly" with an empty result; hosts that health-check their servers with ping (Claude clients, MCP Inspector) receive an error response and may classify the zero server as unhealthy and tear down the connection. - -``` -`default:\n return server.writeError(message.ID, jsonRPCMethodNotFound, "method not found")` (server.go:129-130) — no "ping" case exists in the switch (server.go:109-131). -``` - -**Suggested fix:** Add `case "ping": return server.writeResult(message.ID, map[string]any{})` to the method switch. - - -### Leaf packages (search, secrets, git, ...) - -Audited internal/providerhealth, repoinfo, search, secrets, redaction, imageinput, contextreport, worktrees, zerogit, and perfbench by reading every source file and verifying suspicions with scratch tests run inside the module (all removed afterwards; package test suites pass). 13 concrete defects found, 4 confirmed empirically. Highest impact: the provider health probe's SSRF blocklist is bypassable via redirects and DNS rebinding, and Go's redirect handling re-sends x-api-key/custom auth headers to cross-host redirect targets (high, security). The secrets scanner's Redact provably leaves a private-key block almost entirely un-redacted when an inner AKIA/AIza-shaped run matches first inside its base64 body, and it misses modern sk-proj OpenAI keys entirely (both currently mitigated end-to-end only by the second redaction layer at the tool-registry boundary). Search has a proven byte-offset drift bug (ToLower length changes) that returns wrong Match offsets and context snippets, plus a global-failure mode where one corrupt session JSONL breaks all searching. zerogit stores git's C-quoted escaped paths verbatim (proven with a non-ASCII filename) and uses byte-based 72-char subject validation/truncation. worktrees' default runner merges stderr into the parsed stdout and never populates its Stderr field. Redaction has a real hot-path perf issue (regexp compiled per RedactString call at the tool-result boundary), a proven shared-pointer-as-[Circular] data-dropping bug, and a dead StackTrace interface. imageinput conflates all open/read errors with \"file not found\". repoinfo, contextreport, and perfbench came out clean — repoinfo in particular handles quotePath, gitlinks, and root-commit age correctly. - -#### [high/security] Health probe SSRF blocklist and credentials defeated by redirects and DNS rebinding -`internal/providerhealth/providerhealth.go:285` - -connectivityCheck carefully validates the endpoint host against a private/special-use IP blocklist (validateEndpoint resolves the host and checks every address), but the actual request is sent with http.DefaultClient: (1) the transport performs its own, second DNS resolution at dial time, so a rebinding DNS server can pass validation with a public IP and then dial 127.0.0.1/169.254.169.254 (classic TOCTOU; validated IPs are never pinned); (2) the default client follows up to 10 redirects with no per-hop re-validation, so any 3xx from the configured baseURL escapes the blocklist entirely; and (3) Go's redirect logic only strips Authorization/Cookie/WWW-Authenticate on cross-host redirects — the probe's x-api-key (Anthropic kinds), x-goog-api-key (Google), and arbitrary profile.CustomHeaders set by applyAuth are re-sent verbatim to whatever host the redirect names, leaking the API key to an attacker-controlled or internal endpoint. Production callers (internal/cli/observability.go:56, internal/cli/provider_setup.go:99) pass no HTTPClient and no Resolver, so this is the live configuration. - -``` -client := options.HTTPClient -if client == nil { - client = http.DefaultClient -} -response, err := client.Do(request) // providerhealth.go:281-285 -... -addrs, err := resolver.LookupNetIP(ctx, "ip", host) // providerhealth.go:387 — resolved once for validation, never pinned for the dial -... -applyAuth(request, profile, kind) // providerhealth.go:339 — sets x-api-key / custom headers that Go re-sends on cross-host redirects -``` - -**Suggested fix:** When options.HTTPClient is nil, build a client with CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } (a health probe never needs to follow redirects; treat 3xx as a provider error), and a Transport whose DialContext dials only the addresses returned by validateEndpoint (have validateEndpoint return the vetted []netip.Addr and connect to those, re-checking blockedAddrReason at dial time). - -#### [medium/security] secrets.Redact leaves a private key un-redacted when an inner pattern matches inside it -`internal/secrets/scanner.go:81` - -Redact replaces findings in sorted (type, match) order via strings.ReplaceAll using the match text captured from the ORIGINAL input. When one finding is a substring of another (realistic: an AKIA[0-9A-Z]{16} or AIza... run inside a PEM/OpenSSH base64 body — base64's alphabet contains A-Z and 0-9), the inner type sorts before "private_key_block" ("aws_access_key_id" < "private_key_block"), its replacement rewrites the text inside the block, and the subsequent ReplaceAll for the full private_key_block no longer finds its match — the entire key material is left in the output while the findings list claims the key was redacted. Verified empirically: Redact on a PEM containing "AKIAQEFAASCBKYWGGYZ1" returned the full BEGIN/END block with key body intact, with only the 20 inner chars replaced. The only production caller (internal/tools/bash.go formatBashOutput) is followed by the registry-boundary redaction.RedactString whose privateKeyPattern still catches the block, so the model context is currently protected — but the scanner's own contract ("Redact replaces every detected secret") is violated, and the user-facing "redacted N likely secret(s)" accounting is wrong. - -``` -for _, f := range findings { - redacted = strings.ReplaceAll(redacted, f.Match, "[REDACTED:"+f.Type+"]") -} -// observed: "-----BEGIN PRIVATE KEY-----\nMIIEv[REDACTED:aws_access_key_id]2ab\nQUJDREVG\n-----END PRIVATE KEY-----" -``` - -**Suggested fix:** Before replacing, sort findings by len(f.Match) descending (ties by type/match for determinism) so containing matches are replaced first; or drop findings whose match is a substring of another finding's match. - -#### [medium/bug] Search match offsets computed on ToLower'd text are applied to the original text -`internal/search/search.go:345` - -findMatch lowercases the indexed text (`normalizedText := strings.ToLower(text)`) and returns byte offsets into that lowered string, but Sessions then slices the ORIGINAL entry.Text with those offsets (buildContext at search.go:128/367) and exports them as Match.Start/End in the JSON result. strings.ToLower changes byte length for several characters (U+212A KELVIN SIGN 3→1 byte, U+0130 2→3 bytes, U+212B, etc.), so all offsets after such a character drift. Verified empirically: text containing three Kelvin signs before the query word produced Match{25,35} whose original-text slice is " here SECR" instead of "SECRETWORD", and a shifted context snippet. buildContext can also cut multi-byte runes at the window edges, emitting invalid UTF-8 into the JSON output. - -``` -normalizedText := strings.ToLower(text) -if index := strings.Index(normalizedText, query); index >= 0 { - return Match{Start: index, End: index + len(query)}, true -} -... Context: buildContext(entry.Text, match.Start, match.End, contextChars) // offsets belong to the lowered text -``` - -**Suggested fix:** Search and slice the same string: compute lowered := strings.ToLower(entry.Text) once, run findMatch and buildContext both against lowered (documenting that Match offsets refer to the normalized text); additionally clamp buildContext boundaries to rune starts (back up while utf8.RuneStart is false). - -#### [medium/ux] One corrupt session aborts the entire /search and `zero search` surface -`internal/search/search.go:108` - -Sessions iterates every stored session and hard-fails on the first LoadIndex error (`return Result{}, err`). RebuildIndex calls store.ReadEvents, which returns an error for ANY malformed line in a session's events JSONL (internal/sessions/store.go:546-547) — e.g. a torn final line from a crash mid-append. From then on every search query, for any session, returns only that error in the TUI (`Search\nerror: ...`) and the CLI, until the user manually deletes the broken session directory. A search across N independent sessions should degrade per-session, not globally. - -``` -index, err := LoadIndex(store, session, LoadOptions{Reindex: options.Reindex, Now: now}) -if err != nil { - return Result{}, err -} -``` - -**Suggested fix:** On LoadIndex error, skip the session (continue), optionally counting skipped sessions in Result (e.g. a SkippedSessions int field) so the formatter can mention "N sessions unreadable" instead of failing the whole query. - -#### [medium/perf] redactURLPasswords recompiles its regexp on every RedactString call (hot path) -`internal/redaction/redaction.go:362` - -Every secret pattern in this package is a package-level compiled var except the URL matcher inside redactURLPasswords, which calls regexp.MustCompile on each invocation. RedactString is on the hottest text paths in the program: it runs on every tool result Output, Display.Summary and each Meta value at the registry boundary (internal/tools/registry.go:87/169-184), on loop-intercepted outputs (internal/agent/loop.go:564), on session replay strings (internal/sessions/replay.go:235), on every string visited by RedactValue (used by search indexing for every event payload string), and per-line in TUI command output. Regex compilation allocates and parses each time for zero benefit. - -``` -func redactURLPasswords(value string, replacement string) string { - return regexp.MustCompile(`\b(?:https?|wss?|ftp)://[^\s]+`).ReplaceAllStringFunc(value, func(candidate string) string { -``` - -**Suggested fix:** Hoist to a package-level var alongside the others: `var urlPattern = regexp.MustCompile(`\b(?:https?|wss?|ftp)://[^\s]+`)` and use it in redactURLPasswords. - -#### [medium/bug] zerogit parseStatus keeps git's C-quoted paths and combined rename strings verbatim -`internal/zerogit/zerogit.go:212` - -Inspect runs `git status --short --untracked-files=all` without -z or -c core.quotePath=false, so git C-quotes any path containing non-ASCII or special characters (verified: a file named `ä ümlaut.txt` is emitted as `"\303\244 \303\274mlaut.txt"` including the literal quotes and octal escapes). parseStatus stores that string as FileChange.Path, so `zero changes inspect --json` reports escaped garbage instead of the real path, and GenerateMessage produces commit subjects like `Update "\303\244 ..."`. Rename records (`RM old -> new`) are likewise stored as the single string `old -> new`. Notably internal/repoinfo/repoinfo.go:84-86 explicitly uses -z for exactly this reason, so the project already knows about the hazard. - -``` -status, err := gitRawOutput(ctx, runGit, root, "status", "--short", "--untracked-files=all") // line 118 -... -path := strings.TrimSpace(line[3:]) // line 222 — raw, possibly C-quoted, possibly "old -> new" -``` - -**Suggested fix:** Use `git status --porcelain=v1 -z --untracked-files=all` (NUL-separated, unquoted; rename records carry the two paths as separate NUL-terminated fields) and parse records instead of lines. - -#### [medium/wiring] worktrees defaultRunGit mixes stderr into parsed stdout and never fills CommandResult.Stderr -`internal/worktrees/worktrees.go:247` - -defaultRunGit uses cmd.CombinedOutput() and stores the merged stream in CommandResult.Stdout, leaving the Stderr field permanently empty. Two consequences: (1) gitOutput's error path `firstNonEmpty(result.Stderr, result.Stdout)` reads a field that is never populated by the default runner (works only by accident because stdout holds the merge); (2) on success, any stderr chatter git emits with exit 0 (warnings, advice, `git worktree add`'s "Preparing worktree …" progress) is concatenated into the stdout that Prepare parses as the repo root / branch / git-common-dir, corrupting paths used to build the worktree location and the same-repo identity check. The sibling package internal/zerogit/zerogit.go:388-407 does this correctly with separate buffers. - -``` -output, err := command.CombinedOutput() -... -return CommandResult{Stdout: string(output), ExitCode: exitCode}, err // Stderr never set, stderr merged into Stdout -``` - -**Suggested fix:** Mirror zerogit.defaultRunGitEnv: attach separate bytes.Buffers to command.Stdout and command.Stderr and return both fields. - -#### [low/security] Secret scanner misses modern OpenAI key formats (sk-proj-, sk-svcacct-, keys with -/_) -`internal/secrets/scanner.go:35` - -The openai_key pattern `sk-[A-Za-z0-9]{20,}` only matches the legacy all-alphanumeric format. Current OpenAI project/service-account keys (sk-proj-..., sk-svcacct-...) contain hyphens after a short alnum run, so the pattern never reaches 20 chars and Scan returns nothing — verified: Redact("token: sk-proj-abcDEF1234...") returned zero findings and the key unchanged. The sibling package internal/redaction/redaction.go:71 already knows about this shape (`\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`) and the registry boundary catches it, so the model context is still scrubbed; the defect is that this scanner's typed findings and the bash tool's "redacted N likely secret(s)" notice silently skip these keys. - -``` -{"openai_key", regexp.MustCompile(`sk-[A-Za-z0-9]{20,}`)}, // does not match sk-proj-…; findings=[] in empirical test -``` - -**Suggested fix:** Align with the redaction package's pattern, e.g. `sk-(?:proj-|svcacct-)?[A-Za-z0-9._-]{20,}` (anchored with \b on both sides to keep precision). - -#### [low/bug] RedactValue reports shared (non-circular) pointers/maps as "[Circular]", dropping data -`internal/redaction/redaction.go:222` - -The `seen` set used for cycle detection is global to the whole traversal and entries are never removed when a branch completes, so it implements "visited" rather than "on the current path". Any value graph in which two sibling fields reference the SAME pointer or map (a DAG, not a cycle) has every reference after the first replaced by "[Circular]". Verified empirically: struct{A,B *thing}{shared, shared} redacts to map[A:map[Name:ok] B:[Circular]]. Callers feed arbitrary payloads through this for JSON output (internal/cli/sessions.go, skills.go, extensions.go, internal/doctor), so legitimate data is silently dropped from redacted reports. - -``` -ptr := value.Pointer() -if _, ok := context.seen[ptr]; ok { - return CircularReference -} -context.seen[ptr] = struct{}{} -return redactReflect(value.Elem(), context, depth+1) // seen never unwound -``` - -**Suggested fix:** Treat seen as a path set: after the recursive call returns, delete(context.seen, ptr) (same for the Map case) so only true ancestor cycles report [Circular]. - -#### [low/dead-code] RedactError stack-trace support is dead code (interface matches nothing) -`internal/redaction/redaction.go:174` - -RedactError probes the error chain for `interface{ StackTrace() fmt.Stringer }`. No type in this repository implements StackTrace at all (grep finds only this file), and the de-facto ecosystem convention (github.com/pkg/errors) declares `StackTrace() errors.StackTrace`, which does not satisfy a fmt.Stringer return type, so errors.As can never succeed. RedactedError.Stack is therefore always empty and the field plus the branch are unreachable. - -``` -var stackTracer interface{ StackTrace() fmt.Stringer } -if errors.As(err, &stackTracer) { - redacted.Stack = RedactString(stackTracer.StackTrace().String(), options) -} -``` - -**Suggested fix:** Delete the branch and the Stack field, or change the probe to an interface actually implemented by the project's error types. - -#### [low/bug] Commit subject length/truncation is byte-based: rejects valid non-ASCII subjects and can emit invalid UTF-8 -`internal/zerogit/zerogit.go:206` - -ValidateMessage enforces `len(firstLine) > 72` in BYTES while the error says "72 characters": a 30-character CJK subject (~90 bytes) is rejected even though it is well under 72 characters. truncateSubject slices `value[:69]` at a fixed byte offset, which can cut a multi-byte rune in half and produce an invalid-UTF-8 commit subject when GenerateMessage builds "Update " from a long non-ASCII filename. - -``` -if len(firstLine) > 72 { - return fmt.Errorf("commit message subject must be 72 characters or fewer") -} -... -func truncateSubject(value string) string { - if len(value) <= 72 { return value } - return strings.TrimSpace(value[:69]) + "..." -} -``` - -**Suggested fix:** Count and slice runes: `runes := []rune(firstLine); if len(runes) > 72 {...}` and `string(runes[:69]) + "..."` in truncateSubject. - -#### [low/wiring] zerogit resolveRunners silently drops env for caller-supplied runners — temp-index isolation lost -`internal/zerogit/zerogit.go:417` - -When a caller provides RunGit but not RunGitEnv, resolveRunners synthesizes an EnvRunner that discards the env slice entirely. stagedSnapshotDiff depends on env carrying GIT_INDEX_FILE to point `git add -A` at a throwaway index; with the env dropped, the `add -A` in this nominally read-only Inspect would mutate the caller's REAL index (staging every change and untracked file). Today's production wiring (internal/cli/app.go:125) passes neither runner so the defaults are used and the hazard is latent, but the option pair is exported and the silent env drop is a booby trap for any future caller injecting a logging/sandboxing RunGit. - -``` -} else if runGitEnv == nil { - runGitEnv = func(ctx context.Context, dir string, _ []string, args ...string) (CommandResult, error) { - return runGit(ctx, dir, args...) // env parameter ignored - } -} -``` - -**Suggested fix:** Make the fallback fail loudly instead of dropping state: return an error from Inspect when RunGit is set without RunGitEnv and env-dependent commands are needed, or have the wrapper reject non-empty env (`if len(env) > 0 { return CommandResult{}, errors.New("RunGitEnv required") }`). - -#### [low/ux] imageinput reports every open/read failure as "image file not found" -`internal/imageinput/imageinput.go:58` - -LoadFile maps three distinct failures — os.Open error (line 58), io.ReadAll error (line 64), and the earlier os.Stat error (line 38) — to the identical message "image file not found: ". A permission-denied file, an I/O error, or a file deleted mid-read all tell the user the file doesn't exist, sending them to debug the wrong thing (the file is visibly present). The size/type validation itself is sound (Stat pre-check, LimitReader bound, sniff + allow-list). - -``` -file, err := os.Open(resolved) -if err != nil { - return zeroruntime.ImageBlock{}, fmt.Errorf("image file not found: %s", path) -} -... -data, err := io.ReadAll(io.LimitReader(file, MaxImageBytes+1)) -if err != nil { - return zeroruntime.ImageBlock{}, fmt.Errorf("image file not found: %s", path) -} -``` - -**Suggested fix:** Differentiate: keep "not found" only for errors.Is(err, os.ErrNotExist) and otherwise wrap the real error, e.g. fmt.Errorf("read image %s: %w", path, err). - - -### Tests & build hygiene - -Test/build hygiene audit of zero at /Users/kratos/Downloads/zero-main 2. Verified clean: `go vet ./...` passes, `go build ./...` passes, `go test ./...` passes (571 tests across internal/tui + internal/cli alone), `go test -race ./...` passes, only one skip fires in practice (Windows-only TestDefaultBaseDirFallsBackForWindowsUserProfile) and conditional skips (symlinks, git, model-catalog upgrade targets) all execute on this platform; no TODO/FIXME/HACK markers in code; no build-tag-orphaned tests (only the correct //go:build !windows on process_posix_test.go); workflows and scripts reference real targets — ci.yml/pr-auto-review.yml/release-artifacts.yml invoke `go run ./cmd/zero-release build|smoke|package|verify` and `go run ./cmd/zero-perf-bench --output … --ci`, all of which exist with those exact subcommands/flags, and scripts/install.sh + install.ps1 are exercised by internal/installtest. Defects found: (1) gofmt -l flags 10 files and no workflow runs gofmt/go vet (or -race), so drift lands on main unchecked; (2) the TUI transcript view ignores m.height entirely — bubbletea v1.3.10's standard renderer drops top lines beyond terminal height (standard_renderer.go:186-187), so older rows become invisible with no viewport/scrollback, and the suite only asserts width fitting (TestViewNeverExceedsTerminalWidth), locking in the height-blind render model; (3) a family of byte-slicing truncation helpers can split UTF-8 runes and emit/persist invalid UTF-8 — internal/tui/transcript.go:303 truncateTUIOutput (transcript rows), internal/tui/session.go:93 + spec_mode.go:302 + internal/cli/exec_sessions.go:88 (session titles), internal/cli/exec_writer.go:403/412 (stream-json protocol output and stderr status), internal/cli/cron.go:275 + cron_run.go:183 (cron list/run records) — every covering test (model_test.go:1078, exec_protocol_test.go:518-543) is ASCII-only and structurally cannot catch the rune split, while a correct rune-safe helper (truncateRunes, view.go:402) already exists in-tree; (4) minor dead test code: unused helper commandTestStringSliceContains (commands_test.go:46) and the never-read runtimeMessages slice appended from the agent goroutine in session_test.go:215/226. No golden tests were found that assert affirmatively wrong width math; the width-tier table tests match the implementation's 58/80/100 boundaries. - -#### [high/ux] Transcript view ignores terminal height — rows beyond one screen are silently dropped (no viewport/scrollback), and tests only lock in width behavior -`internal/tui/model.go:599` - -transcriptView() renders the ENTIRE transcript into one frame every render, and m.height (updated from WindowSizeMsg at model.go:458) is consumed only by the empty-state splash (startup.go:50) — the chat surface never clamps, scrolls, or pages by height. The program runs in Bubble Tea inline mode (run.go builds the program without an alt-screen or viewport). Bubble Tea v1.3.10's standard renderer truncates any frame taller than the terminal by dropping the TOP lines: standard_renderer.go:186-187 `if r.height > 0 && len(newLines) > r.height { newLines = newLines[len(newLines)-r.height:] }`. Once a conversation exceeds one screen, the title bar and all older rows become permanently invisible and unreachable — there is no scrollback because the dropped lines are repainted in place, never committed to the terminal's history. The test suite locks in this render model: width_tiers_test.go TestViewNeverExceedsTerminalWidth checks every frame line against `width` at 7 widths but has no height analogue, and every other view test (model_test.go, session_test.go) asserts substring presence in the full View() string, which passes regardless of whether the user could ever see the content. - -``` -model.go:599-624: `func (m model) transcriptView() string { … for index, row := range m.transcript { … builder.WriteString(m.renderRow(row, width, rc)) … } }` — no reference to m.height. grep shows m.height read only at startup.go:50 (`height := normalizedStartupHeight(m.height)`). bubbletea@v1.3.10/standard_renderer.go:186-187 trims top lines beyond r.height. -``` - -**Suggested fix:** Render transcript history through a bubbles/viewport sized to m.height minus the chrome rows (title bar, composer, status), or print completed rows with tea.Println so they persist in native terminal scrollback and keep only the live region in View(). Add a height-dimension test mirroring TestViewNeverExceedsTerminalWidth. - -#### [medium/wiring] 10 files fail gofmt and CI has no gofmt/go vet gate to catch them -`.github/workflows/ci.yml:31` - -gofmt -l currently flags 10 files (internal/sandbox/safe_command_test.go, internal/tui/autocomplete_test.go, internal/tui/commands.go, internal/tui/image_attach_test.go, internal/tui/model.go, internal/tui/rendering.go, internal/zerocommands/backend_snapshots.go, internal/zerocommands/backend_snapshots_test.go, internal/zerocommands/sandbox_snapshots.go, internal/zerogit/zerogit_test.go). The diffs are real misformatting (e.g. struct-field alignment in internal/tui/commands.go drifted after fields were removed). None of the three workflows (ci.yml, pr-auto-review.yml, release-artifacts.yml) run gofmt or go vet — their steps are only `go test ./...`, `go run ./cmd/zero-release build|smoke|package|verify`, and the perf bench — so formatting drift lands on main unchecked and will keep accumulating. CI also never runs the race detector despite the agent loop and TUI tests being goroutine-heavy (go test -race ./... passes today, so adding it is free). - -``` -`gofmt -l .` → internal/sandbox/safe_command_test.go … internal/zerogit/zerogit_test.go (10 files). ci.yml smoke job steps: "- name: Test\n run: go test ./...\n- name: Build binary\n run: go run ./cmd/zero-release build\n- name: Smoke binary\n run: go run ./cmd/zero-release smoke" — no vet/fmt step in any workflow. -``` - -**Suggested fix:** Run `gofmt -w` on the 10 listed files, then add a hygiene step to the ci.yml smoke job: `go vet ./...` and `test -z "$(gofmt -l .)"` (and preferably switch the Test step to `go test -race ./...` on at least one OS). - -#### [medium/bug] truncateTUIOutput byte-slices mid-rune, emitting invalid UTF-8 into transcript rows; the only covering test is ASCII-only -`internal/tui/transcript.go:303` - -truncateTUIOutput compares and slices by BYTE length: `if limit <= 0 || len(output) <= limit { … } return output[:limit] + " [truncated]"`. It is applied to raw tool output (tuiToolOutputLimit=240 bytes) at model.go:1410 (toolResultRowText, live runs) and session.go:233 (rehydrated sessions). Any tool result containing multi-byte UTF-8 (CJK source files, emoji, box-drawing output from bash) whose 240th byte falls inside a rune is cut mid-sequence, producing an invalid UTF-8 string that renders as a replacement-glyph artifact in the transcript and is persisted into the row text. The package already has a correct rune-safe helper (truncateRunes, view.go:402) that this code path doesn't use. The only test exercising the limit, model_test.go:1078 TestToolResultRowTruncatesLongOutput, feeds `strings.Repeat("x", tuiToolOutputLimit+20)` — pure ASCII — so it validates the byte-based contract while being structurally unable to catch the rune split. - -``` -transcript.go:297-304: `func truncateTUIOutput(output string, limit int) string { … if limit <= 0 || len(output) <= limit { return output } return output[:limit] + " [truncated]" }`. model_test.go:1078: `text := toolResultRowText(agent.ToolResult{Name: "read_file", Output: strings.Repeat("x", tuiToolOutputLimit+20)})`. -``` - -**Suggested fix:** Cut on a rune boundary, e.g. `for limit > 0 && !utf8.RuneStart(output[limit]) { limit-- }` before slicing, or reuse truncateRunes(output, limit). Extend the test with a multi-byte fixture and assert utf8.ValidString on the result. - -#### [low/bug] Session titles byte-truncated at 80 can split a UTF-8 rune and persist invalid UTF-8 into session metadata (TUI, spec mode, and exec) -`internal/tui/session.go:93` - -tuiSessionTitle truncates the user's prompt with `title = title[:tuiSessionTitleLimit]` (byte slice at 80). A prompt whose 80th byte falls inside a multi-byte rune yields invalid UTF-8 that is stored as the session Title and later marshaled to JSON (encoding/json silently replaces the broken byte with U+FFFD), so /resume lists and exec --resume show a mojibake title. The identical pattern exists at internal/tui/spec_mode.go:301-302 (`if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] }` in specImplementationTitle) and internal/cli/exec_sessions.go:87-88 (`if len(title) > 80 { title = title[:80] }` in createSessionTitle). No test feeds non-ASCII prompts to any of the three. - -``` -session.go:90-94: `func tuiSessionTitle(prompt string) string { title := strings.Join(strings.Fields(prompt), " "); if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] } … }`; spec_mode.go:301-302 and exec_sessions.go:87-88 repeat the byte-slice. -``` - -**Suggested fix:** Extract one rune-safe title helper (truncate via []rune or utf8.RuneStart backoff) and use it in all three sites: internal/tui/session.go:93, internal/tui/spec_mode.go:302, internal/cli/exec_sessions.go:88. - -#### [low/bug] stream-json and status-line output truncation byte-slices mid-rune, corrupting tool output in the machine-readable protocol -`internal/cli/exec_writer.go:412` - -truncateForStreamJSONOutput cuts tool-result output at exactly streamJSONToolResultOutputLimit (10*1024) BYTES: `return value[:streamJSONToolResultOutputLimit] + "\n[truncated]"`. If the 10KiB boundary lands inside a multi-byte rune, the string handed to the JSON encoder contains invalid UTF-8 and encoding/json substitutes U+FFFD, so stream-json consumers receive a corrupted trailing character in `output`. truncateForStatus (line 400-406) has the same byte-slice at 200 for the human stderr `[result]` line. The protocol test (exec_protocol_test.go:518 `Output: strings.Repeat("x", streamJSONToolResultOutputLimit+100)` asserted at line 543) is ASCII-only, so the suite pins the byte-limit contract without ever exercising the rune boundary. - -``` -exec_writer.go:408-413: `func truncateForStreamJSONOutput(value string) (string, bool) { if len(value) <= streamJSONToolResultOutputLimit { return value, false } return value[:streamJSONToolResultOutputLimit] + "\n[truncated]", true }`; exec_writer.go:400-406 truncateForStatus `return compact[:200] + "..."`. -``` - -**Suggested fix:** Back the cut index up to a rune start before slicing (`for limit > 0 && !utf8.RuneStart(value[limit]) { limit-- }`) in both helpers; add a multi-byte case to TestRunExecStreamJSON tool-result truncation asserting utf8.ValidString(output). - -#### [low/bug] cron prompt/error truncation helpers byte-slice mid-rune -`internal/cli/cron.go:275` - -promptExcerpt truncates the job prompt for `zero cron list` with `return p[:47] + "…"` — a byte slice that splits multi-byte runes (cron prompts are arbitrary user text; the built-in recipes are ASCII but user prompts need not be). cronTruncate in internal/cli/cron_run.go:179-184 does the same on captured stderr (`return s[:max] + "…"`, called with max=500 at cron_run.go:150) — child-process stderr regularly contains multi-byte characters (e.g. curly quotes in Go tool errors). Both produce invalid UTF-8 in CLI output and in the persisted run record. - -``` -cron.go:272-278: `func promptExcerpt(p string) string { p = strings.TrimSpace(strings.ReplaceAll(p, "\n", " ")); if len(p) > 48 { return p[:47] + "…" } return p }`; cron_run.go:179-184: `func cronTruncate(s string, max int) string { if len(s) <= max { return s } return s[:max] + "…" }`. -``` - -**Suggested fix:** Truncate on rune boundaries in both helpers (shared utf8.RuneStart backoff or []rune slice), mirroring internal/tui/view.go truncateRunes. - -#### [low/dead-code] Dead test helper: commandTestStringSliceContains is defined but never called -`internal/tui/commands_test.go:46` - -commandTestStringSliceContains in internal/tui/commands_test.go is never referenced anywhere in the package (the live tests use the separate stringSliceContains helper from model_test.go:1250). Unused functions compile silently in Go, so this is pure dead weight that suggests an assertion that was planned and never written or was refactored away. - -``` -commands_test.go:46-53: `func commandTestStringSliceContains(values []string, want string) bool { … }` — `grep -rn commandTestStringSliceContains internal/` returns only the definition line. -``` - -**Suggested fix:** Delete the function (or, if the duplicate was intentional, replace its one sibling stringSliceContains and use a single shared helper). - -#### [low/dead-code] Dead test state: runtimeMessages slice is appended from the agent goroutine but never read -`internal/tui/session_test.go:215` - -TestPromptSubmitPersistsPermissionSessionEvents declares `runtimeMessages := []tea.Msg{}` and appends to it inside the RuntimeMessageSink callback (line 226), which runs on the agent goroutine spawned at line 246-248, but the slice is never read by any assertion — the test drives itself entirely off runtimeMessageCh. It is dead state that also models a risky pattern: an unsynchronized slice mutated from a non-test goroutine; if a future edit reads it from the test goroutine it becomes a data race the -race runs would then have to catch. Every other test in the file (via newPermissionTestModel, line 486-488) correctly uses only the channel. - -``` -session_test.go:215 `runtimeMessages := []tea.Msg{}` … :225-228 `RuntimeMessageSink: func(msg tea.Msg) { runtimeMessages = append(runtimeMessages, msg); runtimeMessageCh <- msg },` — no other reference to the slice in the test. -``` - -**Suggested fix:** Remove the runtimeMessages slice and the append, leaving `RuntimeMessageSink: func(msg tea.Msg) { runtimeMessageCh <- msg }` to match the helper used by the other permission tests. - - -### Sessions & usage - -Audited internal/sessions (store, checkpoint, rewind/replay, lineage, exec_session, file locks) and internal/usage (tracker, report), plus their wiring into the TUI run loop (internal/tui/model.go, session.go, session_controls.go) and CLI (exec.go, exec_sessions.go, sessions.go, usage.go). The persistence layer's locking design is sound (per-session in-process mutex + flock, *Locked variants, content-addressed blobs with integrity-checked reads, symlink-resolving workspace confinement on both capture and restore), and the usage tracker is only touched from the Bubble Tea goroutine, so no tracker data race exists as wired. The concrete defects cluster in three areas. (1) Durability: no fsync anywhere in the store — a torn final line in events.jsonl permanently bricks a session because ReadEvents hard-fails on any malformed line (breaking resume, fork, rewind, AND the global usage report via collectUsageData's fail-fast loop), and metadata.json is rewritten per event via unsynced tmp+rename, so a crash can zero it and the session silently vanishes from listing. (2) The TUI's cancelled-run flush protocol: after Esc, m.pending clears while the run goroutine still holds its batched session events, so /resume re-targets the flush into the WRONG session, /rewind is permitted and prunes the cancelled run's not-yet-referenced checkpoint blobs then receives the stale events appended after the rewind marker, and new prompts can interleave with the dying run. (3) Accounting: Fork duplicates provider_usage events with rewritten timestamps (double-counted, wrongly-dated usage report), the escalation 'model' field persisted on usage events is never consumed by BuildReport, and exec's session recorder latches its first error silently and unreported. Secondary findings: '/rewind latest' off-by-one leaves a dangling tool_call for the undone mutation in the log/replayed context, O(n^2) transcript append on rehydration, rune-splitting byte truncations feeding prompts/titles, and second-granularity timestamps making resume-latest tie-break to the older session. - -#### [high/bug] Torn append (crash mid-write) permanently bricks a session: ReadEvents hard-fails on any malformed JSONL line -`internal/sessions/store.go:547` - -appendEventLocked writes events with a plain os.OpenFile(O_APPEND) + Write and no fsync, so a crash or power loss mid-append leaves a partial trailing line in events.jsonl. ReadEvents then refuses to return ANY events: it aborts on the first undecodable line instead of tolerating a torn tail. Because every consumer goes through ReadEvents — PrepareExec resume, TUI /resume, Fork, ApplyRewind (via sortedCheckpointsAfter and truncateEventsLocked), pruneOrphanBlobs, and even `zero usage report` via collectUsageData — one torn line makes the session unresumable, unforkable, unrewindable, and (with no --session filter) breaks the global usage report, with no recovery path in the tool. - -``` -store.go:546-548: `if err := json.Unmarshal(line, &event); err != nil { return nil, fmt.Errorf("invalid json in zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err) }` — combined with the unsynced append at store.go:508-517 (`os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)` ... `file.Write(append(data, '\n'))` ... `file.Close()`). -``` - -**Suggested fix:** In ReadEvents, tolerate a malformed FINAL line (truncated tail from a crash): if json.Unmarshal fails on the last non-empty line, return the successfully parsed prefix (optionally with a warning) instead of an error; only hard-fail on corruption in the middle of the file. Optionally add a `zero sessions repair` path that truncates the file at the last valid newline. - -#### [high/race] Cancelled run's deferred event flush is appended to whatever session is active at flush time (cross-session contamination) -`internal/tui/model.go:512` - -When a run is cancelled (Esc), its goroutine keeps running and later delivers its batched session events in a final agentResponseMsg, which the Update loop persists via m.appendSessionEvents. But appendSessionEvent writes to m.activeSession.SessionID — the session active NOW, not the session the run belonged to. After Esc, m.pending is false, so the user can immediately /resume another session (the commandResume gate at model.go:914 only checks m.pending) or even start a new prompt in the same session (commandPrompt gate at model.go:837 checks `m.pending || m.exiting` only). When the flush lands, the cancelled run's tool_call/tool_result/checkpoint/usage events are permanently written into the wrong session's events.jsonl (and into m.sessionEvents, so they are replayed as context to the agent), or interleaved after the next turn's user message, scrambling the recorded order. - -``` -model.go:506-512: `if _, flushing := m.flushRunIDs[msg.runID]; flushing { ... m, flushRows = m.appendSessionEvents(flushableSessionEvents(msg.sessionEvents))` — and session.go:46: `event, err := m.sessionStore.AppendEvent(m.activeSession.SessionID, ...)`. agentResponseMsg (model.go:118-130) carries no session id; handleResumeCommand (session.go:116) swaps m.activeSession while flushRunIDs is non-empty. -``` - -**Suggested fix:** Capture the owning session id in the run closure (it is already in options.SessionID at model.go:1123) and carry it on agentResponseMsg; in the flush path call sessionStore.AppendEvent with that pinned id directly instead of going through m.appendSessionEvent/m.activeSession. Also skip mutating m.sessionEvents for flushes that do not belong to the active session. - -#### [high/race] /rewind is allowed while a cancelled run is still flushing: prune deletes its checkpoint blobs, then the late flush re-appends pre-rewind events after the rewind marker -`internal/tui/session_controls.go:176` - -handleRewindCommand only guards on m.pending, which cancelRun clears immediately — but the cancelled agent goroutine is still alive: it may still be executing a tool (mutating workspace files concurrently with the restore), and its EventSessionCheckpoint payloads are still un-persisted in its in-memory batch. ApplyRewind then (a) restores files while the dying run may still write them, (b) pruneOrphanBlobsLocked deletes the cancelled run's snapshot blobs because no persisted event references them yet (referencedBlobs reads only events.jsonl), and (c) when the flush finally lands, flushableSessionEvents re-appends the cancelled run's tool_call/checkpoint/usage events AFTER the EventSessionRewind marker — re-polluting the truncated log with events the rewind just dropped, whose checkpoint blob references now dangle (readBlob fails and future rewinds silently report those paths as Skipped). The same un-protected window exists cross-process: a `zero sessions rewind` from the CLI during any in-flight TUI run prunes the TUI's not-yet-referenced blobs, since the TUI batches checkpoint events until end-of-run despite SnapshotForCheckpoint's contract demanding prompt recording. - -``` -session_controls.go:176-178: `if m.pending { return m, "Rewind\ncannot rewind while a run is in progress." }` — no check of m.flushRunIDs, which cancelRun populates (model.go:1075-1080) while the goroutine keeps running. checkpoint.go:283-305 pruneOrphanBlobsLocked removes any blob not referenced by a persisted event; the cancelled run's checkpoint events are only in msg.sessionEvents until the flush at model.go:512. -``` - -**Suggested fix:** In handleRewindCommand (and ideally the commandPrompt/commandResume gates), also refuse while `len(m.flushRunIDs) > 0` ("waiting for cancelled run to finish"). Longer term, persist each checkpoint event immediately via CaptureToolCheckpoint (which holds the session lock across blob write + event append) instead of batching it for end-of-run. - -#### [medium/bug] No fsync before rename/append: metadata.json (rewritten on every event) can be empty after a crash, silently hiding the session -`internal/sessions/store.go:632` - -writeMetadata uses os.WriteFile(tmp) + os.Rename with no file.Sync() before the rename and no directory fsync. On crash/power loss, filesystems with delayed allocation (ext4, others) can commit the rename before the data, leaving a zero-length or partial metadata.json. Since metadata is rewritten on EVERY appendEventLocked (store.go:522), the window recurs constantly during a run. A corrupted metadata.json makes readMetadata fail, Get returns an error, and List silently skips the directory (store.go:325-328) — the session vanishes from `zero sessions list`, /resume, and resume-latest with intact events.jsonl on disk. The same unsynced tmp+rename pattern is in writeBlob (checkpoint.go:188-195), copyBlobs, truncateEventsLocked (replay/rewind.go:220-227), and writeFileAtomic (rewind.go:286-299), and the events append itself is never synced. - -``` -store.go:631-639: `tmp := fmt.Sprintf("%s.tmp-%d", path, store.idCounter.Add(1)); if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { ... } if err := os.Rename(tmp, path); err != nil {` — no Sync anywhere in the package (grep for `.Sync()` in internal/sessions returns nothing). -``` - -**Suggested fix:** Open the temp file explicitly, write, call file.Sync() before Close, then Rename (and fsync the parent directory for full durability). Apply the same to writeBlob/truncateEventsLocked, and call file.Sync() in appendEventLocked before Close. Additionally, make List/Get treat a zero-length metadata.json as recoverable (e.g. surface the session id with a 'damaged' marker) instead of silently skipping it. - -#### [medium/bug] TUI '/rewind latest' keeps the dangling tool_call event of the mutation it just undid -`internal/tui/session_controls.go:235` - -Both recording paths append the EventToolCall first and the EventSessionCheckpoint immediately after it (TUI: model.go:1223 then 1245; exec: exec.go:443 then 449), so checkpoint.Sequence = toolCall.Sequence + 1. resolveRewindTarget('latest') returns lastCheckpoint - 1 as the keep-through sequence, which is exactly the tool_call's sequence — so ApplyRewind correctly restores the file's before-state but the truncated log still ENDS with the tool_call event for the mutation that was undone, with no result. The rehydrated transcript shows a perpetually unresolved tool call, and FormatExecPrompt / sessionPrompt replays that tool_call as context, telling the model the write happened even though the file was rolled back. - -``` -session_controls.go:235: `return lastCheckpoint - 1, nil // undo the most recent checkpoint` — while the TUI batch order at model.go:1223-1250 appends EventToolCall and then the checkpoint payload, making the checkpoint's paired tool_call sit exactly at sequence lastCheckpoint-1. -``` - -**Suggested fix:** Return the sequence BEFORE the checkpoint's paired tool_call: locate the EventToolCall immediately preceding the latest checkpoint and return its sequence minus 1 (or simply lastCheckpoint - 2 given the adjacent recording order, with a fallback when the preceding event is not a tool_call). - -#### [medium/bug] Fork duplicates provider_usage events and rewrites their timestamps, double-counting and re-dating usage in `zero usage report` -`internal/sessions/store.go:387` - -Fork copies every parent event into the fork via AppendEvent, and appendEventLocked stamps each copy with CreatedAt = now (store.go:495-501). collectUsageData (internal/cli/usage.go:35-53) flattens events from ALL sessions, so after a fork every one of the parent's provider_usage events is counted twice in BuildReport's requests/tokens/cost totals — and the duplicated copies are bucketed under the fork's creation date instead of when the usage actually happened, corrupting the per-day table. Forking an old session makes 'today' show requests/cost that occurred weeks ago, twice. - -``` -store.go:387-390: `for _, event := range events { if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {` — appendEventLocked sets `CreatedAt: timestamp` (store.go:495-501) where timestamp is store.now(). usage.go:50: `set.events = append(set.events, sessionEvents...)` with no fork dedup; report.go counts every EventUsage. -``` - -**Suggested fix:** In BuildReport (or collectUsageData), skip the inherited prefix of fork sessions: for Metadata.SessionKind == fork, ignore events with Sequence <= the number of copied events (Fork already records copiedEventCount/forkedFromSequence in metadata and the EventSessionFork payload). Alternatively, preserve the original CreatedAt when Fork copies events and dedupe by original event identity. - -#### [medium/ux] One corrupt session aborts the entire `zero usage report` -`internal/cli/usage.go:46` - -collectUsageData iterates every session and returns the FIRST ReadEvents error for the whole command, so a single damaged events.jsonl anywhere under the sessions root (e.g. from the torn-append crash case) makes `zero usage report` exit with a crash code, even though the other sessions' data is fully readable. List() already tolerates per-session corruption by skipping; the usage traversal does not. - -``` -usage.go:46-49: `sessionEvents, err := store.ReadEvents(meta.SessionID); if err != nil { return usageEventSet{}, err }` inside the for-loop over all sessions. -``` - -**Suggested fix:** Skip-and-warn per session: on ReadEvents error, log a warning to stderr (session id + error) and continue aggregating the remaining sessions instead of returning the error. - -#### [medium/wiring] Per-event 'model' field persisted under --allow-escalation is never read by the usage report, mispricing escalated runs -`internal/usage/report.go:17` - -exec writes a `model` key into each provider_usage payload when escalation is enabled (internal/cli/exec.go:488-490), precisely because the model can change mid-run. But usageEventPayload only decodes promptTokens/completionTokens/totalTokens, and BuildReport prices every event with the session's Metadata.ModelID — so an event produced by the escalated (more expensive) model is costed at the base model's rate. The data needed for correct pricing is persisted and then ignored; the doc comment on usageEventPayload ('model id ... not persisted') is stale. - -``` -report.go:17-21: `type usageEventPayload struct { PromptTokens int ...; CompletionTokens int ...; TotalTokens int ... }` (no model field), report.go:102: `modelID := modelBySession[event.SessionID]` — vs exec.go:488-490: `if options.allowEscalation { payload["model"] = currentModel }`. -``` - -**Suggested fix:** Add `Model string `json:"model"`` to usageEventPayload and prefer it over modelBySession[event.SessionID] when non-empty in BuildReport's cost reconstruction. - -#### [medium/wiring] execSessionRecorder latches the first persist failure, silently drops all later events, and the error is never surfaced -`internal/cli/exec_sessions.go:115` - -recorder.append sets recorder.err on the first AppendEvent failure and then short-circuits every subsequent append — so after one transient failure (disk full, permissions, lock error) the rest of the run's user/assistant messages, tool calls, results and usage events are silently not recorded. No caller ever reads recorder.err (grep shows it is only referenced inside exec_sessions.go), so `zero exec` exits success while the persisted session is silently truncated; a later --resume replays incomplete context and `usage report` undercounts. - -``` -exec_sessions.go:114-121: `func (recorder *execSessionRecorder) append(...) { if recorder.err != nil || ... { return } _, recorder.err = recorder.prepared.Store.AppendEvent(...) }` — exec.go checks writer.err repeatedly (exec.go:495 etc.) but never sessionRecorder.err. -``` - -**Suggested fix:** At run end in runExec, if sessionRecorder.err != nil, emit a warning to stderr (or a stream-json warning event) that session recording failed at event N. Consider not latching: attempt each append independently so one transient failure doesn't drop the remainder of the log. - -#### [medium/perf] appendTranscriptRow is O(n) copy + O(n) dedupe scan per row, making transcript growth and session rehydration O(n^2) -`internal/tui/transcript.go:113` - -Every appended transcript row first scans the entire transcript for a duplicate key (hasTranscriptRow) and then copies the whole slice (`append([]transcriptRow{}, rows...)`). This runs on the hot path for every streamed agentRowMsg during a run, for the end-of-run replay of msg.rows, and in a tight loop when /resume or /rewind rehydrates a session (`for _, row := range transcriptRowsFromSessionEvents(events) { rows = appendTranscriptRow(rows, row) }` at session.go:127 and session_controls.go:206). Resuming a session with thousands of events does ~n^2/2 row copies plus n^2/2 key comparisons, with visible latency on large sessions. - -``` -transcript.go:113-120: `func appendTranscriptRow(rows []transcriptRow, row transcriptRow) []transcriptRow { if hasTranscriptRow(rows, row) { return rows } next := append([]transcriptRow{}, rows...); next = append(next, row); return next }` — hasTranscriptRow (122-133) linearly scans all rows per call. -``` - -**Suggested fix:** Maintain a `seenRowKeys map[string]struct{}` alongside the transcript on the model (rebuilt on clear/rehydrate) for O(1) dedupe, and append in place (`append(rows, row)`) — the defensive full copy is unnecessary when callers always reassign the result; if value-semantics isolation is required, copy only on the rare structural operations, not per append. - -#### [low/bug] Naive byte-slice truncation can split multi-byte UTF-8 runes in prompts and titles -`internal/sessions/replay.go:237` - -Several truncations slice byte strings at fixed offsets with no rune-boundary check: payloadPreview (`value[:240]`), buildCompactionPrompt (`prompt[:maxChars-len(...)]`, replay.go:174-176), summarizePayload (`text[:500]`, exec_session.go:194-196), tuiSessionTitle (`title[:80]`, internal/tui/session.go:93), and createSessionTitle (`title[:80]`, internal/cli/exec_sessions.go:89). Any non-ASCII payload/prompt (CJK text, emoji, accented filenames) can be cut mid-rune, producing invalid UTF-8 that lands in the compaction summary prompt sent to the model, the resume context block, and the persisted session title in metadata.json (where json.Marshal substitutes U+FFFD replacement characters). - -``` -replay.go:236-238: `if len(value) > 240 { return value[:240] + "..." }`; exec_session.go:194-196: `if len(text) > 500 { return text[:500] }`; internal/tui/session.go:92-94: `if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] }`. -``` - -**Suggested fix:** Truncate on a rune boundary: e.g. `for limit > 0 && !utf8.RuneStart(s[limit]) { limit-- }` before slicing, or iterate with utf8.DecodeRuneInString / use a shared truncateRunes helper at all five sites. - -#### [low/bug] Second-granularity RFC3339 timestamps make Latest()/list ordering pick the wrong session on same-second ties -`internal/sessions/store.go:555` - -store.timestamp() formats with time.RFC3339 (whole seconds). List sorts by UpdatedAt descending with SessionID ASCENDING as the tie-break, and Latest() takes sessions[0]. Two sessions updated within the same second (common when child/spec sessions are created back-to-back, or in fast scripted runs) tie on UpdatedAt, and the ascending-id tie-break returns the EARLIER-created session (createID embeds an increasing timestamp/nano so later sessions sort higher) — `zero exec --resume-latest` and the TUI's `/resume latest` can resume the older of two sessions created in the same second. - -``` -store.go:554-556: `func (store *Store) timestamp() string { return store.now().UTC().Format(time.RFC3339) }` and store.go:331-336: `if sessions[left].UpdatedAt == sessions[right].UpdatedAt { return sessions[left].SessionID < sessions[right].SessionID } return sessions[left].UpdatedAt > sessions[right].UpdatedAt`. -``` - -**Suggested fix:** Format timestamps with time.RFC3339Nano (lexicographic ordering still holds for UTC), or invert the tie-break to `SessionID >` so the later-created id wins on equal UpdatedAt. - - -### TUI scroll/history architecture deep-dive - -AUDIT SCOPE: internal/tui of the "zero" Bubble Tea CLI (bubbletea v1.3.10, bubbles v1.0.0, no alt-screen). 14 verified findings: the scrollback architecture defect (high), Gemini tool-id transcript dedupe collisions (high), 16-line card truncation hiding diffs (high), discarded mid-run assistant text, invisible cancellation, blind composer typing, O(n^2)/per-token full re-render perf, /style dead wiring, diff-detection false positives, indentation-destroying wrapping, resize/first-frame corruption, dead footer code, rune-splitting truncation, and /exit bypassing the checkpoint flush. - -WHY HISTORY IS UNREACHABLE TODAY (proven mechanics): run.go builds the Program with only WithContext/WithInput/WithOutput — the standard INLINE renderer, no alt screen, no viewport, and the repo contains zero tea.Println calls. model.View() returns transcriptView(), which renders the ENTIRE transcript plus composer/status as one string every frame. bubbletea v1.3.10 standard_renderer.go:183-188 then drops every line above the last `height` lines ("we can't navigate the cursor into the terminal's scrollback buffer: newLines = newLines[len(newLines)-r.height:]"). Consequences: (a) transcript content above one screen is never written to the terminal AT ALL — there is nothing to scroll up to; (b) the visible region is repainted in place via CursorUp(linesRendered-1), so text selection of even visible history is overwritten by the next frame (per-token agentTextMsg renders + 12fps spinner ticks during runs); (c) on shrink-resize, previously painted lines physically re-wrap, the renderer's logical line count desyncs from physical rows, and stale fragments are pushed into native scrollback above a garbled frame — with the whole UI in one full-height managed region, the entire chat corrupts; the first frame is additionally painted at chatWidth(0)=96 before the initial WindowSizeMsg (tea.go renders the initial view before registering handleResize), wrapping immediately on narrower terminals; (d) long diffs are doubly invisible: cardBodyMaxLines=16 truncates every card body to 16 lines + "… N more lines", and with no scrollback/viewport/expand command those lines are unreachable forever. - -TARGET ARCHITECTURE — SETTLED-ROW FLUSH FRONTIER (bubbletea v1.3.x, inline, no alt-screen): -Core idea: completed transcript rows are emitted to native terminal scrollback via tea.Println EXACTLY ONCE; View() holds only the live tail. tea.Println enqueues queuedMessageLines, which the standard renderer prints ABOVE the managed region on the next flush (FIFO, then the terminal owns them: native scrollback, selection, copy all work). Mouse capture stays off (run.go's comment is correct). - -1) SETTLED-NESS. Keep []transcriptRow as truth; add `flushed int` (frontier: rows[ 0 && len(newLines) > r.height { newLines = newLines[len(newLines)-r.height:] }`. grep confirms zero occurrences of tea.Println/WithAltScreen/viewport in internal/. -``` - -**Suggested fix:** Adopt a settled-row flush frontier (full spec in summary): keep only the live tail (running tool cards, undecided prompts, streaming text, composer, status) in View(), and emit each completed transcript row exactly once into native scrollback via tea.Println at the moment it settles. - -#### [high/bug] Transcript dedupe key ignores run/turn, so Gemini's repeating synthetic tool-call IDs drop every tool card after the first turn -`internal/tui/transcript.go:137` - -appendTranscriptRow dedupes via transcriptRowKey, which for tool calls/results is `kind:id` and for permission rows `kind:ToolCallID:Action` — with no run or sequence component. The Gemini provider synthesizes IDs `gemini_tool_N` from a per-request counter (`state := streamState{}` is fresh in every StreamCompletion, provider.go:147, 270), so turn 2 of the same agent run re-emits `gemini_tool_1`. hasTranscriptRow then finds turn 1's row with the same key and DROPS turn 2's tool-call row, its result row, and its permission prompt/decision rows. The rendering layer was fixed for exactly this (rcKey includes runID, with a comment naming Gemini's gemini_tool_N), but the append layer was not — the rows never reach the transcript to be rendered. Effect: in any multi-turn Gemini run (and in any session resumed then continued), only the first tool call/result per synthetic ID ever appears; later tools execute invisibly with no card and no permission record on screen. - -``` -transcript.go:136-139 `case rowToolCall, rowToolResult: if row.id != "" { return fmt.Sprintf("%d:%s", row.kind, row.id) }` and 113-120 `func appendTranscriptRow(... ) { if hasTranscriptRow(rows, row) { return rows } ... }`; providers/gemini/provider.go:268-271 `if id == "" { id = fmt.Sprintf("gemini_tool_%d", syntheticIndex) }` with `state := streamState{}` per request (line 147); rendering.go:124-126 comment: "some providers synthesize ToolCallIDs that repeat across turns (e.g. Gemini's gemini_tool_N)". -``` - -**Suggested fix:** Make synthetic IDs unique per request in the Gemini provider (e.g. fmt.Sprintf("gemini_tool_%d_%d", requestNonce, syntheticIndex) — the ID only needs to be stable within one stream), and/or scope transcript dedupe to (runID, id) over the rows of the same run only, since dedupe exists solely to reconcile the live agentRowMsg stream with the agentResponseMsg replay of the same run. - -#### [high/ux] cardBodyMaxLines=16 permanently hides long diffs and tool output with no expansion or scroll path -`internal/tui/rendering.go:525` - -Every tool-result card body (diffs, bash output, grep, read) is capped at 16 lines by capCardLines, collapsing the rest into a '… N more lines' trailer. A 400-line `git diff` or apply_patch result shows 16 lines. Because the architecture has no scrollback (finding 1), no viewport, and no expand keybinding/command, those hidden lines are unreachable for the lifetime of the session — the user cannot review the very edits they are being asked to approve. This breaks the core review loop of a coding agent: the only way to see what changed is to leave the TUI and run git diff manually. - -``` -rendering.go:524-525 `// cardBodyMaxLines caps every card body; hidden lines collapse into a "… N more lines" trailer. const cardBodyMaxLines = 16` and 679-686 capCardLines `lines = lines[:cardBodyMaxLines]; return append(lines, ...Render(fmt.Sprintf("… %d more lines", hidden)))`. No expand command exists (commandDefinitions has none) and no scroll mechanism exists. -``` - -**Suggested fix:** Once settled rows flush to native scrollback (finding 1), flush diff cards at full length (cap only the LIVE running-card preview); short of the full architecture, raise the cap for diff-kind bodies and add an expand toggle (e.g. /expand or a key on the most recent card). - -#### [medium/bug] Assistant text streamed before tool calls is discarded — vanishes from the transcript and the session log -`internal/tui/model.go:569` - -Mid-run assistant prose (the text a model emits alongside/before its tool calls, e.g. 'Let me check the failing test first') is shown live via streamingText, then erased when the tool-call row arrives: the agentRowMsg handler sets m.streamingText = "" for rowToolCall and never appends the segment as a transcript row. The agent loop returns only the last turn's text as FinalAnswer (loop.go:233 `result.FinalAnswer = collected.Text`), and the TUI's session recording also persists only that final answer (model.go:1356-1362). So every earlier text segment disappears from the screen the instant a tool runs, and is absent from /resume rehydration too. The user watches explanatory text appear and then get destroyed. - -``` -model.go:567-571: `// a tool call ends the current streamed text segment if msg.row.kind == rowToolCall { m.streamingText = "" }` — no rowAssistant is appended for the cleared segment; agent/loop.go:197-201 appends collected.Text only to provider messages, and loop.go:233 sets FinalAnswer from the final turn only; model.go:1349-1362 records only result.FinalAnswer as the assistant EventMessage. -``` - -**Suggested fix:** In the agentRowMsg handler (or in OnToolCall inside runAgentWithOptions), when a tool call arrives and streamingText is non-blank, append it as a non-final rowAssistant (and a corresponding pendingSessionEvent assistant message) before clearing streamingText. - -#### [medium/ux] Esc/Ctrl+C cancellation leaves no visible marker in the live transcript -`internal/tui/model.go:1081` - -cancelRun appends the 'Run cancelled.' marker ONLY to the persisted session event log (appendSessionEvent with sessions.EventError); nothing is appended to m.transcript. The comments at model.go:296 and :503 claim cancelRun 'writes the "Run cancelled." marker', but on screen the streaming text and spinner simply vanish (streamingText is cleared, pending goes false) with zero indication the run was interrupted, why output stopped, or that Esc worked. The marker only becomes visible later if the user happens to /resume the session (rehydration maps the EventError to a rowError). Tests assert only the session event (session_test.go:751). - -``` -model.go:1081-1087: `if m.pending && m.activeSession.SessionID != "" { if next, err := (*m).appendSessionEvent(sessions.EventError, map[string]any{"message": "Run cancelled."}); err == nil { *m = next } }` — no reduceTranscript/appendTranscriptRow call anywhere in cancelRun; the Esc handler (model.go:337-339) and Ctrl+C handler add nothing either. -``` - -**Suggested fix:** In cancelRun, also append a visible row: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Run cancelled."}) (guarded on m.pending so idle Esc stays silent). - -#### [medium/ux] Composer never sets textinput.Width, so input longer than the terminal is truncated with the cursor hidden — blind typing -`internal/tui/model.go:227` - -textinput.New() is configured (prompt, styles, placeholder) but Width is never assigned anywhere in the package. In bubbles v1.0.0, horizontal scrolling only engages when Width > 0 (`if m.Width <= 0 || uniseg.StringWidth(string(m.value)) <= m.Width` keeps offset at 0 and View renders the full value). composerLine then hard-truncates the rendered line to the terminal width with fitStyledLine, cutting off the tail — which is exactly where the cursor and the characters being typed are. Any prompt longer than ~(width-prompt-hint) cells is typed blind: no cursor, no echo of new characters, and no way to see or edit the end of the input. - -``` -model.go:227-233 configures the input with no Width; grep confirms `input.Width` / `.Width =` never appears in internal/tui. composerLine (model.go:702-706): `line := input.View(); ... return joinHeaderLine(fitStyledLine(line, width-lipgloss.Width(hint)-2), hint, width)` — fitStyledLine (startup.go:200-208) truncates with '…'. bubbles@v1.0.0/textinput/textinput.go:331 shows scrolling requires Width > 0. -``` - -**Suggested fix:** On WindowSizeMsg (and at init), set m.input.Width = chatWidth(m.width) - lipgloss.Width(prompt) - reservedHintWidth so the textinput scrolls horizontally and keeps the cursor visible. - -#### [medium/perf] Full-transcript re-render on every message plus O(n²) append path -`internal/tui/transcript.go:117` - -Two compounding hot paths. (1) Bubble Tea calls View() after every message; View walks and re-styles the ENTIRE transcript (buildRowContext allocates 5 maps and scans all rows, then renderRow runs word-wrapping, regexes, and lipgloss styling per line) — and messages arrive per streamed token (agentTextMsg per delta) and per spinner tick (~12 fps via spinner.MiniDot) for the whole duration of a run. In a long session the per-token render cost is O(total transcript content). (2) appendTranscriptRow copies the whole slice on every append (`next := append([]transcriptRow{}, rows...)`) and hasTranscriptRow recomputes keys over all rows per append, making row appends O(n) copy + O(n) scan, i.e. O(n²) per session even though rows arrive one at a time. - -``` -transcript.go:113-120: `func appendTranscriptRow(rows []transcriptRow, row transcriptRow) []transcriptRow { if hasTranscriptRow(rows, row) { return rows } next := append([]transcriptRow{}, rows...); next = append(next, row); return next }`; hasTranscriptRow (122-133) loops all rows; model.go:441-446 (one Update per text delta), 447-455 (spinner tick while pending), model.go:610 `rc := buildRowContext(m.transcript)` per render over all rows. -``` - -**Suggested fix:** The flush-frontier architecture removes the render cost structurally (View only renders the unflushed tail). Independently: append in place (the model is already passed by value through Update, so the defensive full copy is unnecessary — append to the existing slice), and maintain the dedupe key set as a map on the model instead of rescanning all rows. - -#### [medium/wiring] /style sets responseStyle that nothing ever reads — the command silently has no effect -`internal/tui/session_controls.go:117` - -/style validates the value, stores m.responseStyle, and reports 'Style preference is stored for this TUI session.' But responseStyle is never threaded into agent.Options, the system prompt, or any request: runAgentWithOptions (model.go:1102-1133) sets Registry/PermissionMode/SystemPrompt/SessionID/Model/ReasoningEffort/Cwd/Images/ContextWindow and nothing style-related; a repo-wide grep for ResponseStyle outside internal/tui returns nothing — agent.Options has no such field. The only consumers are display surfaces (/context, /style). Unlike /theme and /input-style (which explicitly answer 'does not have a backend setting yet'), /style claims success, so users selecting 'concise'/'explanatory' get zero behavioral change with no warning. - -``` -session_controls.go:109-123: `m.responseStyle = args; return m, strings.Join([]string{"Style", "active style: " + m.responseStyle, "Style preference is stored for this TUI session."}, "\n")`. grep -rn ResponseStyle internal/ (excluding tui, tests) yields no hits; runAgentWithOptions never references m.responseStyle. -``` - -**Suggested fix:** Either thread it into the run (append a style directive to options.SystemPrompt in runAgentWithOptions based on m.responseStyle) or route /style through shellOnlyCommandText like /theme so the no-op is honest. - -#### [medium/bug] looksLikeDiff false-positives on any output containing a line starting with '---', breaking bash/generic cards -`internal/tui/view.go:394` - -toolCardBody dispatches to diffCardBody whenever looksLikeDiff(detail) is true, checked BEFORE the bash branch (rendering.go:663-668). looksLikeDiff returns true if ANY line has prefix '@@', '+++', or '---'. Output of a bash command that merely contains a '---' separator (YAML document markers, Markdown front matter, test-framework section dividers, `ls` of files named '---…') is therefore rendered as a unified diff card: the bash command line and the exit-status footer are lost (diffCardBody parses neither), the raw 'stdout:'/'stderr:'/'exit_code: 0' protocol lines render as diff metadata in the body, and an empty path renders in the card head. Lines beginning '-' are misclassified as deletions with bogus gutter numbers. - -``` -view.go:389-400: `func looksLikeDiff(text string) bool { ... for _, line := range strings.Split(text, "\n") { if strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") { return true } } ... }`; rendering.go:663-670: `switch { case looksLikeDiff(detail): return diffCardBody(detail, width) ... case name == "bash": return bashCardBody(hint, detail, width)`. -``` - -**Suggested fix:** Tighten the heuristic: require a hunk header AND a file header pair, e.g. at least one line matching `^@@ -\d` plus one of `^\+\+\+ `/`^--- ` (with trailing space), or for bash results strip the stdout:/stderr:/exit_code: envelope first and only diff-render the stdout section when it matches. - -#### [medium/bug] wrapPlainText collapses all internal whitespace, destroying code indentation in assistant answers and streamed text -`internal/tui/rendering.go:261` - -wrapPlainText re-tokenizes every line with strings.Fields and rejoins with single spaces, so leading indentation and aligned columns are destroyed. renderAssistantRow (final answers), the interim streaming block, renderUserRow, and wrapDetailBlock all route through it. For a coding agent whose answers routinely include indented code snippets, every final answer's code is flattened (' return nil' renders as 'return nil'), making multi-level Python/Go snippets unreadable and copy-broken. Tabs are likewise collapsed. - -``` -rendering.go:255-281: `for _, paragraph := range strings.Split(...) { ... line := ""; for _, word := range strings.Fields(paragraph) { ... line += " " + word ... } }` — strings.Fields drops all leading/internal whitespace; renderAssistantRow (line 324) and interimBlock (model.go:678) call wrapPlainText on raw model text. -``` - -**Suggested fix:** Preserve leading whitespace per source line: capture the indent prefix before Fields (e.g. indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]), wrap only the remainder to measure-len(indent), and re-prefix each wrapped line (continuations included) with the indent. - -#### [medium/ux] Resize is unhandled beyond storing width/height: shrink garbles the whole surface, and the first frame renders at a hardcoded 96 cols -`internal/tui/model.go:456` - -On tea.WindowSizeMsg the model only stores m.width/m.height; the next frame reflows at the new width, but everything already painted was emitted at the old width. When the terminal narrows, those physical lines re-wrap, the standard renderer's CursorUp(linesRendered-1) repositioning (computed from logical line counts) lands on the wrong row, and stale wrapped fragments are pushed into scrollback above a duplicated/garbled frame — and since this app's entire UI is one full-height managed region, the whole chat corrupts on every shrink. Separately, Bubble Tea paints the first frame BEFORE the resize handler is registered (tea.go:700 'Render the initial view' precedes p.handleResize()), so the first frame uses m.width==0 → chatWidth(0)=defaultStartupWidth=96: on any terminal narrower than 96 the 96-cell rules and header physically wrap, desyncing the renderer from frame one. - -``` -model.go:456-459: `case tea.WindowSizeMsg: m.width = msg.Width; m.height = msg.Height; return m, nil`; startup.go:282-290: `func chatWidth(width int) int { if width <= 0 { return defaultStartupWidth } ... }` (defaultStartupWidth=96); bubbletea@v1.3.10/tea.go:699-710 renders the initial view before `p.handlers.add(p.handleResize())`; standard_renderer.go:176-177 `buf.WriteString(ansi.CursorUp(r.linesRendered - 1))` assumes no physical re-wrap. -``` - -**Suggested fix:** Render a minimal first frame until the first WindowSizeMsg arrives (e.g. return "" or a single status line while m.width==0) so nothing is emitted at the guessed 96-col width; the shrink corruption is bounded by the flush-frontier architecture (only the small live tail is repainted) and can be further mitigated by issuing tea.ClearScreen on width decrease. - -#### [low/dead-code] Dead footer/help rendering code and dead message fields -`internal/tui/rendering.go:68` - -footerText(), commandFooterText(), formatCommandFooterText() with its pending 'Esc cancel'/'Esc clear' switch, and defaultCommandFooterText (rendering.go:62-118) are never called from production code — the real bottom readout is statusLine() in view.go, which shows none of the advertised command list. formatCommandHelpLines (commands.go:290-292) and indentText (view.go:380-387) are likewise test-only. bashResultMsg.command (command_bash.go:20-22) is populated by runBashEscape but never read by the bashResultMsg handler (model.go:574-576 uses only msg.output). renderFocusedAskUserPrompt's `input string` parameter (rendering.go:490) is never used in the function body. These keep tests green (model_test.go asserts 'Esc cancel') while validating UI text no user ever sees. - -``` -grep shows footerText/commandFooterText/formatCommandHelpLines/indentText referenced only from *_test.go; model.go:574-576 `case bashResultMsg: m.transcript = reduceTranscript(..., text: msg.output)` ignores msg.command; rendering.go:490 `func renderFocusedAskUserPrompt(prompt pendingAskUserPrompt, input string, width int)` never references input. -``` - -**Suggested fix:** Delete footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText/formatCommandHelpLines/indentText (and their tests) or wire the footer into statusLine; drop bashResultMsg.command and the unused input parameter. - -#### [low/bug] Byte-index truncation can split UTF-8 runes in session titles and tool-result row text -`internal/tui/transcript.go:303` - -truncateTUIOutput slices by byte index (`output[:limit]`), and tuiSessionTitle / specImplementationTitle do the same (`title[:tuiSessionTitleLimit]`, session.go:93, spec_mode.go:302). A prompt or output whose 240th/80th byte falls inside a multi-byte rune (CJK, emoji, accented text) produces an invalid UTF-8 fragment. The session title case matters most: it is persisted via sessionStore.Create and later rendered in /resume cards and JSON-marshalled (invalid bytes become U+FFFD replacement characters in the stored metadata). The rest of the package carefully uses rune-safe truncation (truncateRunes, splitAtWidth), making these two the odd ones out. - -``` -transcript.go:297-304: `if limit <= 0 || len(output) <= limit { return output } return output[:limit] + " [truncated]"`; session.go:91-94: `if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] }`; spec_mode.go:301-303 identical pattern. -``` - -**Suggested fix:** Use the existing truncateRunes helper (view.go:402) in truncateTUIOutput, tuiSessionTitle, and specImplementationTitle. - -#### [low/bug] /exit during the Ctrl+C checkpoint-flush wait quits immediately, orphaning the checkpoints the deferred quit exists to protect -`internal/tui/model.go:853` - -After Ctrl+C cancels an in-flight run, the model deliberately defers tea.Quit until the cancelled run's agentResponseMsg flushes its EventSessionCheckpoint events (flushRunIDs machinery, extensively documented at model.go:67-75 and 295-314), and handleSubmit blocks NEW PROMPTS while exiting (`if command.kind == commandPrompt && (m.pending || m.exiting)`). But commandExit is not guarded: typing /exit (or /quit) while m.exiting is waiting on the flush returns tea.Quit immediately, dropping the pending agentResponseMsg and orphaning the checkpoint blobs already written to disk — exactly the /rewind breakage the flush mechanism prevents. Same hole applies to /exit while a run is merely pending (no Ctrl+C at all): it quits without cancelling or flushing. - -``` -model.go:836-839 guards only commandPrompt: `if command.kind == commandPrompt && (m.pending || m.exiting) { return m, nil }`; model.go:853-855: `case commandExit: m.exiting = true; return m, tea.Quit` — no pending/flushRunIDs check, unlike the Ctrl+C path at 305-314. -``` - -**Suggested fix:** In the commandExit case, mirror the Ctrl+C path: call m.cancelRun() if pending, set m.exiting = true, and return (m, nil) instead of tea.Quit whenever len(m.flushRunIDs) > 0, letting the existing agentResponseMsg handler fire the deferred quit. - - -### TUI interactions (commands, pickers, sessions) - -Audited the zero TUI interaction surfaces (commands.go, command_center.go, command_views.go, command_bash.go, command_output.go, autocomplete.go, picker.go, session.go, session_controls.go, spec_mode.go, plan_command.go, image_attach.go, model_catalog.go) plus the model.go/transcript.go/rendering.go/view.go wiring they depend on, and traced rehydration into internal/sessions (rewind.go, checkpoint.go, exec_session.go) and the Gemini provider. 11 concrete findings. Two high: (1) the transcript dedup key (kind:id, no runID/ordinal) collides with Gemini's per-turn-reset synthesized ToolCallIDs, silently dropping later turns' tool cards live and on /resume rehydration (where all rows carry runID=0) — the rcKey comment acknowledges the exact hazard the dedup layer ignores; (2) a cancelled run's deferred event flush appends to m.activeSession at flush time, so /resume or /spec in the window writes the old run's tool/checkpoint events into the wrong session, with checkpoint payloads referencing blobs stored under the original session — corrupting the new session's log and breaking its /rewind. Mediums: /rewind isn't gated on outstanding flushRunIDs so ApplyRewind prunes the cancelled run's not-yet-referenced checkpoint blobs (the documented orphan-vulnerable window) and rewound-away events reappear after the marker; ask_user requests persist without a content key and are dropped on rehydration while answers are never persisted at all; /exit (/quit) mid-run quits without the cancel/flush protection Ctrl+C implements (and /spec ignores m.exiting); appendTranscriptRow's full-slice copy + linear dedup scan is O(N²) on resume/rehydration. Lows: unreachable resumeText branch plus a never-rendered footer subsystem and stale '! bash'/'/ commands' footer comments (the shell escape and @file picker are undocumented in /help); byte-index truncation splitting multibyte runes in session titles and tool output; /compact's write-only request counter; silent inert UI during the post-Ctrl+C flush wait; and the permission card's unlabeled [esc] chip that actually cancels the whole run. Verified clean: slash-command registration parity (all 23 kinds defined are handled in handleSubmit; autocomplete and /help share commandDefinitions), picker modality (keys swallowed, busy-gated, Esc precedence correct), permission event JSON tags round-trip exactly (toolCallId/name/action match payloadString keys), bash-escape gating behind PermissionModeUnsafe referencing a real flag, the image attach/submit-time vision re-check flow, imageinput.LoadFile bounds, and the sessions-store rewind/prune locking itself. - -#### [high/bug] Transcript dedup key ignores runID, silently dropping tool rows for providers with per-turn synthesized ToolCallIDs -`internal/tui/transcript.go:135` - -appendTranscriptRow dedupes rows via transcriptRowKey, which for tool call/result rows is only `kind:id` — runID is excluded. Gemini synthesizes ToolCallIDs as gemini_tool_N where N restarts at 1 for every stream (internal/providers/gemini/provider.go:270, `state.syntheticToolIndex` lives on a per-request streamState), so every agent-loop turn reuses gemini_tool_1, gemini_tool_2, … . Consequence in a single live run: turn 1 appends rows keyed 3:gemini_tool_1 / 4:gemini_tool_1; turn 2's tool call and result with the same id hit hasTranscriptRow and are silently dropped — only the first turn's tool cards ever render. The same collision hits rehydration (/resume): all rehydrated rows carry runID=0, so a second run's duplicate-id events are dropped while building the transcript in transcriptRowsFromSessionEvents/handleResumeCommand, and after a resume, NEW live runs (runID 1, 2, …) collide with nothing — but a second resume-then-run in the same TUI process collides live rows against rehydrated runID-0 rows is avoided only by luck of key format; within-run repeats remain broken regardless. Note the code itself documents the hazard: rendering.go:127-134 says rowContext maps are keyed by rcKey(runID,id) precisely because "some providers synthesize ToolCallIDs that repeat across turns (e.g. Gemini's gemini_tool_N)" — but the dedup layer that decides whether a row exists at all never got the same treatment, and even rcKey cannot distinguish two turns of the same run or two rehydrated runs (both runID=0). - -``` -transcript.go:137-140: `case rowToolCall, rowToolResult:\n if row.id != \"\" {\n return fmt.Sprintf(\"%d:%s\", row.kind, row.id)\n }` consumed by transcript.go:113-119 `func appendTranscriptRow(...) { if hasTranscriptRow(rows, row) { return rows } ... }`; gemini/provider.go:268-271: `id := functionCall.ID\nif id == \"\" {\n id = fmt.Sprintf(\"gemini_tool_%d\", syntheticIndex)\n}` with syntheticToolIndex on per-stream state; rendering.go:131-134 comment: "some providers synthesize ToolCallIDs that repeat across turns (e.g. Gemini's gemini_tool_N), so a bare id could attribute a decision or a result to a different run's call." -``` - -**Suggested fix:** Give each row a unique identity instead of content-derived dedup: stamp rows with a per-run monotonically increasing ordinal in runAgentWithOptions, include runID+ordinal in transcriptRowKey (and key rcKey on the same tuple, assigning synthetic run boundaries — e.g. event sequence — to rehydrated rows instead of runID=0). Minimal alternative: in the agentResponseMsg handler, track how many of this run's rows were already delivered via agentRowMsg and append only the remainder, removing the need for content dedup entirely. - -#### [high/race] Cancelled-run event flush appends to whichever session is active at flush time, contaminating other sessions and breaking their /rewind -`internal/tui/model.go:512` - -When a run is cancelled (Esc/Ctrl+C), its goroutine keeps running and later returns agentResponseMsg; the handler flushes the accumulated session events via m.appendSessionEvents, and appendSessionEvent (session.go:46) writes to m.sessionStore.AppendEvent(m.activeSession.SessionID, …) — the session active NOW, not the session the run belonged to. After cancelling (pending=false) the user can immediately run /resume (sets m.activeSession to another session) or /spec (creates a brand-new draft session) while the cancelled goroutine is still unwinding (it can block in the provider HTTP call for seconds). When the flush lands, the old run's tool_call/tool_result/EventSessionCheckpoint events are appended to the WRONG session's events.jsonl. Worse, the checkpoint payloads reference content-addressed blobs stored under the ORIGINAL session's directory (checkpoint.go blobPath(sessionID, hash)), so a later /rewind in the contaminated session calls readBlob with hashes that don't exist there and reports those files as skipped/not recoverable, while the original session's blobs become unreferenced and get deleted by its next pruneOrphanBlobs. - -``` -model.go:506-513: `if _, flushing := m.flushRunIDs[msg.runID]; flushing { delete(m.flushRunIDs, msg.runID); ... m, flushRows = m.appendSessionEvents(flushableSessionEvents(msg.sessionEvents))`; session.go:46-49: `event, err := m.sessionStore.AppendEvent(m.activeSession.SessionID, sessions.AppendEventInput{...})` — m.activeSession is re-read at flush time; /resume reassigns it at session.go:116 `m.activeSession = *session` and /spec at spec_mode.go:93 `m.activeSession = session`, neither gated on outstanding flushRunIDs. -``` - -**Suggested fix:** Capture the originating session id in the goroutine (the closure already holds the model copy: `sessionID := m.activeSession.SessionID`), carry it on agentResponseMsg, and have the flush path call sessionStore.AppendEvent with that id directly instead of going through m.activeSession. Optionally also block /resume and /spec while len(m.flushRunIDs) > 0. - -#### [medium/race] /rewind allowed while a cancelled run's flush is outstanding — prunes the cancelled run's checkpoint blobs and re-appends rewound-away events -`internal/tui/session_controls.go:176` - -handleRewindCommand refuses to rewind only while m.pending is true. After Esc-cancelling a run, pending is false but the run id sits in m.flushRunIDs and its EventSessionCheckpoint events (whose blobs were already written by SnapshotForCheckpoint) have NOT been appended yet — exactly the orphan-vulnerable window the sessions package documents (checkpoint.go:89-97: blobs "are ORPHAN-VULNERABLE — a concurrent pruneOrphanBlobs/ApplyRewind can delete them — until the caller appends an EventSessionCheckpoint"). If the user runs /rewind in that window, ApplyRewind's pruneOrphanBlobsLocked (rewind.go:261) deletes the cancelled run's not-yet-referenced blobs. When the cancelled goroutine finally returns, the flush appends its tool/checkpoint events AFTER the rewind marker: the event log regains events the user just rewound away (they rehydrate on the next /resume), and the flushed checkpoint events reference deleted blobs, so any later /rewind reports those files "skipped (not recoverable)" — the undo capability for the cancelled run's mutations is permanently lost. - -``` -session_controls.go:176-178: `if m.pending {\n return m, \"Rewind\\ncannot rewind while a run is in progress.\"\n}` — no check of m.flushRunIDs; rewind.go:261: `_, _ = store.pruneOrphanBlobsLocked(sessionID)` inside ApplyRewind; model.go:75 documents flushRunIDs exists precisely so "the checkpoint blobs already written to disk would [not] be orphaned (breaking /rewind)". -``` - -**Suggested fix:** In handleRewindCommand, also refuse while len(m.flushRunIDs) > 0 (e.g. "a cancelled run is still flushing; retry in a moment"), mirroring the m.pending guard. - -#### [medium/wiring] ask_user exchanges vanish on rehydration and user answers are never persisted -`internal/tui/session.go:190` - -The agent goroutine persists an ask_user request as an EventMessage whose payload (askUserSessionPayload, transcript.go:190-211) has role "ask_user", toolCallId, and questions — but no "content" key. transcriptRowsFromSessionEvents' EventMessage branch reads only payloadString(payload, "content") and `continue`s when it is empty, so every persisted ask_user event is silently dropped on /resume: the rehydrated transcript shows no trace of the questionnaire. The dedup comment in transcript.go:148-150 ("Prefer row.id … it survives rehydration … so a reloaded ask_user row still dedupes correctly") describes rehydration behavior that does not exist — no code path ever constructs a rowAskUser from a persisted event. Additionally, the user's ANSWERS are never recorded as a session event at all (OnAskUser only appends the request, model.go:1197-1200), so a resumed session's context (sessions.FormatExecPrompt over ContextEvents) loses what the user answered — the agent sees the questions but not the answers. - -``` -session.go:190-193: `content := payloadString(payload, \"content\")\nif content == \"\" {\n continue\n}`; transcript.go:202-205 builds the payload with `\"role\": \"ask_user\", \"toolCallId\": …, \"questions\": …` and no content; model.go:1197-1200 appends only `pendingSessionEvent{Type: sessions.EventMessage, Payload: askUserSessionPayload(request)}` — nothing is appended after `answers := <-answerCh`. -``` - -**Suggested fix:** In transcriptRowsFromSessionEvents, branch on role=="ask_user" before the content check and rebuild a rowAskUser (id from toolCallId, text/detail from header+questions). In OnAskUser, append a second EventMessage carrying the collected answers after the answer channel delivers. - -#### [medium/bug] /exit during an in-flight run quits without cancelling or flushing, orphaning checkpoints; /spec can start a run while exiting -`internal/tui/model.go:853` - -The Ctrl+C handler goes to great lengths (model.go:294-314, flushRunIDs, deferred tea.Quit) to keep a cancelled run's checkpoint session events from being lost. But the /exit (alias /quit) command path returns tea.Quit immediately without calling cancelRun or waiting for any flush: a run in flight is abandoned, its goroutine is killed with the program, and the EventSessionCheckpoint events batched in that goroutine are never appended — the blobs already written by SnapshotForCheckpoint stay orphaned and /rewind cannot reference the cancelled run's mutations: exactly the loss the Ctrl+C comments say flushRunIDs exists to prevent. Separately, the exiting guard only covers commandPrompt (`command.kind == commandPrompt && (m.pending || m.exiting)`), so during the Ctrl+C deferred-quit window the user can run /spec, which checks only m.pending (spec_mode.go:22) and starts a NEW agent run that the deferred tea.Quit then aborts mid-flight — orphaning that run's checkpoints too. - -``` -model.go:853-855: `case commandExit:\n m.exiting = true\n return m, tea.Quit` — no cancelRun, no flush wait; contrast model.go:296-305 comment: "quitting now would drop that message, orphaning the checkpoints and breaking /rewind"; model.go:837: `if command.kind == commandPrompt && (m.pending || m.exiting)` — only prompts are gated; spec_mode.go:22: `if m.pending {` — m.exiting not checked. -``` - -**Suggested fix:** Make commandExit mirror the Ctrl+C path: call m.cancelRun(), set m.exiting, and return tea.Quit only when len(m.flushRunIDs)==0, otherwise defer to the flush-drain branch. Add `|| m.exiting` to handleSpecCommand's run gate. - -#### [medium/perf] appendTranscriptRow is O(N) copy + O(N) dedup scan per row — O(N²) on /resume rehydration and long runs -`internal/tui/transcript.go:113` - -Every appendTranscriptRow call allocates a brand-new slice and copies the entire existing transcript (`append([]transcriptRow{}, rows...)`), and hasTranscriptRow linearly scans all rows computing transcriptRowKey for each. handleResumeCommand and handleRewindCommand append every rehydrated event row through this path, so resuming a session with N events costs O(N²) copies and O(N²) key computations (a few thousand events → millions of struct copies of ~150-byte rows, gigabytes of memcpy). The same cost applies per agentRowMsg and per row in the agentResponseMsg batch on long-running sessions, since each row append re-copies the whole transcript. - -``` -transcript.go:113-120: `func appendTranscriptRow(rows []transcriptRow, row transcriptRow) []transcriptRow {\n if hasTranscriptRow(rows, row) { return rows }\n next := append([]transcriptRow{}, rows...)\n next = append(next, row)\n return next\n}`; hasTranscriptRow (122-133) scans every existing row; called in a loop at session.go:127-129 (`for _, row := range transcriptRowsFromSessionEvents(events) { rows = appendTranscriptRow(rows, row) }`) and model.go:543-545. -``` - -**Suggested fix:** Append in place (`return append(rows, row)` — every caller already owns the slice it passes in from the Update goroutine) and maintain a map[string]struct{} of seen row keys on the model (rebuilt on /clear, /resume, /rewind) for O(1) dedup. - -#### [low/dead-code] Dead code: unreachable resumeText branch, never-rendered footer helpers, and stale comments referencing a footer that no longer exists -`internal/tui/command_center.go:44` - -resumeText's `args != ""` branch (returns "requested session: …" guidance) is unreachable: the only caller is handleResumeCommand which calls m.resumeText("") exclusively (session.go:104) and handles non-empty args itself. Beyond that, an entire footer subsystem is never rendered by any view: footerText, commandFooterText, formatCommandFooterText, and defaultCommandFooterText (rendering.go:62-118), plus listCommandNames (commands.go:281), formatCommandHelpLines (commands.go:290), and indentText (view.go:380) have no non-test callers (verified by grep across internal/, excluding _test.go). The comments at commands.go:238 ("the footer advertises '! bash'") and autocomplete.go:56-57 ("the footer advertises '/ commands'") describe UI that doesn't exist — and consequently the "!" shell escape and "@file" picker have zero in-product discoverability: /help (formatGroupedCommandHelp) lists neither. - -``` -command_center.go:44: `if args != "" { return renderCommandOutput(commandOutput{ Title: \"Sessions\", ... \"requested session: \" + args ...` with the only call site session.go:104 `return m, m.resumeText(\"\")`; grep over internal/ excluding tests shows footerText/commandFooterText/defaultCommandFooterText/listCommandNames/formatCommandHelpLines/indentText only at their definitions; transcriptView (model.go:599-667) renders titleBar/composerLine/statusLine — no footer call. -``` - -**Suggested fix:** Delete the unreachable resumeText branch and the unused footer/help helpers (or wire footerText into the view if it was meant to render); update the stale comments; add "! " and "@file" lines to helpText so the features are discoverable. - -#### [low/bug] Byte-index truncation can split multibyte runes in session titles and tool-result text -`internal/tui/session.go:93` - -tuiSessionTitle truncates with `title[:tuiSessionTitleLimit]` (byte index 80), specImplementationTitle does the same (spec_mode.go:301-303), and truncateTUIOutput slices `output[:limit]` at byte 240 (transcript.go:297-304). A prompt or spec title containing multibyte UTF-8 (CJK, emoji, accented text) near the boundary gets cut mid-rune, producing invalid UTF-8 that is persisted into session metadata JSON (json.Marshal mangles it to U+FFFD) and shown in /resume cards and transcript row text. The codebase already has a correct rune-safe truncateRunes helper (view.go:402) used elsewhere, so these three are inconsistent stragglers. - -``` -session.go:92-94: `if len(title) > tuiSessionTitleLimit {\n title = title[:tuiSessionTitleLimit]\n}`; spec_mode.go:301-303: `if len(title) > tuiSessionTitleLimit {\n title = title[:tuiSessionTitleLimit]\n}`; transcript.go:300-303: `if limit <= 0 || len(output) <= limit { return output }\nreturn output[:limit] + \" [truncated]\"`. -``` - -**Suggested fix:** Use truncateRunes(title, tuiSessionTitleLimit) in both title helpers and rune-based slicing in truncateTUIOutput (`string([]rune(output)[:limit])` or reuse truncateRunes). - -#### [low/wiring] /compact request state is write-only — nothing consumes it -`internal/tui/session_controls.go:165` - -handleCompactCommand increments m.compactRequests, which is read back only by compactText/compactionStatus to display "requested, not yet compacted" and "Backend state: pending integration". No code path ever consumes the request: it never triggers transcript compaction, never writes a sessions EventCompaction (the event type exists in internal/sessions/store.go:37), and never interacts with the agent loop's compaction (which is wired separately via Options.ContextWindow in runAgentWithOptions). The command is registered, autocompletable, and described as "Show or request transcript compaction state", but the "request" half is a counter feeding its own status text — a field set but never meaningfully read. - -``` -session_controls.go:160-166: `if args != \"\" { return m, \"Compact\\nusage: /compact [status]\" }\nm.compactRequests++\nreturn m, m.compactText(true)`; the only readers are compactText (line 257: `\"requested: \" + boolText(m.compactRequests > 0)`) and compactionStatus (line 270-275); grep shows no other consumer of compactRequests. -``` - -**Suggested fix:** Either wire the request to an actual action (append a sessions.EventCompaction and/or trigger an agent-loop summarization of m.sessionEvents), or change the command description/output to make explicit that compaction is not yet implemented rather than implying a tracked request will be honored. - -#### [low/ux] After Ctrl+C with a hung run the UI is silently inert: no exiting indicator, prompts refused without feedback -`internal/tui/model.go:837` - -Ctrl+C during a run sets m.exiting and defers tea.Quit until the cancelled goroutine returns — which can take many seconds if the provider call doesn't honor cancellation promptly. In that window: pending is false so the spinner and "running…" placeholder disappear, nothing in the view indicates the app is shutting down or waiting on a flush, and handleSubmit silently swallows any prompt the user types (`command.kind == commandPrompt && (m.pending || m.exiting)` returns with no transcript notice). The app looks alive (composer accepts text) but does nothing on Enter and then quits at an arbitrary later moment, which reads as a hang followed by a crash. - -``` -model.go:836-839: `// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) ...\nif command.kind == commandPrompt && (m.pending || m.exiting) {\n return m, nil\n}` — no user-visible message; model.go:311-313 returns `m, nil` after Ctrl+C with pending flush, and transcriptView/statusLine render no exiting state (no reference to m.exiting or m.flushRunIDs anywhere in view code). -``` - -**Suggested fix:** When m.exiting with len(m.flushRunIDs) > 0, render a status segment like "exiting — flushing cancelled run…" (statusLine or interim block), and append a system note ("shutting down; prompt ignored") instead of silently returning when a prompt is submitted while exiting. - -#### [low/ux] Permission card advertises an unlabeled [esc] action whose actual effect is cancelling the entire run -`internal/tui/rendering.go:482` - -renderFocusedPermissionPrompt's action row ends with a bare `[esc]` chip with no verb, sitting next to "[a] allow once", "[y] always", "[d] deny". handlePermissionKey handles only a/d/y; Esc falls through to the global KeyEsc branch, which clears the composer and calls cancelRun — killing the whole run, not just declining this one tool call. The spec-review card labels its chip "[esc] cancel" and Esc there cancels only the review, so the same unlabeled chip on the permission card invites the (wrong) assumption that Esc is a soft dismiss/deny; instead the user loses the rest of the turn. - -``` -rendering.go:478-482: `actions := zeroTheme.badge.Render(\" [a] allow once \") + ... + fill(zeroTheme.faint).Render(\"[esc]\")` — no label; model.go:316-340 KeyEsc: with pendingPermission non-nil none of the earlier modal branches match, so it reaches `if m.pending { m.cancelRun() }`, cancelling the run (handlePermissionKey at model.go:720-731 handles only \"a\",\"d\",\"y\"). -``` - -**Suggested fix:** Label the chip with its real effect ("[esc] cancel run"), or make Esc while pendingPermission resolve the prompt as a deny (m.resolvePermission(permissionDecisionDeny)) and reserve run-cancel for a second Esc. - - -### Cron, background, ops - -Audited internal/cron, background, observability, notify, update, release, doctor, npmwrapper, and installtest (all source read in full; consumers in internal/cli, internal/tui, and internal/specialist traced; two cron findings reproduced with throwaway tests). The cron next-run math is solid for spring-forward DST gaps and century leap-year gaps, but provably double-fires wall-clock schedules during the DST fall-back repeated hour. The cron store has zero inter-process locking: the scheduler's minutes-long read-modify-write clobbers concurrent pause/edit operations and two schedulers (forever-mode plus the documented system-cron --once usage) double-fire jobs. `cron add` has an ordering bug where --recipe silently overrides the user's positional cron expression despite a guard that proves the opposite intent. In background process lifecycle, POSIX kills only the leader PID (Windows tree-kills via taskkill /T) so SIGKILL escalation leaks the specialist's subprocess tree, a second zero process clobbers a live sibling's running-task metadata to error/PID=0 at Manager load, and the post-SIGTERM grace polling can SIGKILL a recycled PID. Smaller items: `zero update --check` is dead on non-ldflags builds (version \"dev\" rejected pre-network), TUI /doctor omits config paths so its config checks always warn while the CLI passes them, byte-indexed truncation splits UTF-8 runes in cron list/error output, and notify's escape sanitizer misses C1 controls. Notify focus tracking (WithReportFocus, SetFocused at launch, Focus/Blur handling), doctor redaction of apiKey details, observability crash recovery wiring, release checksum/packaging path guards, and the npm wrapper/install scripts checked out clean. - -#### [high/race] Cron store has no inter-process locking: paused/edited jobs are silently clobbered by the scheduler's stale read-modify-write, and concurrent schedulers double-fire -`internal/cli/cron_run.go:173` - -There is no file locking anywhere in internal/cron or the cron CLI (grep for flock/lockfile returns nothing). fireJob captures a Job struct from store.List(), synchronously runs the entire `zero exec` (which can take minutes), then writes the WHOLE stale struct back with store.Update(job). Any concurrent edit made during the run is lost: `zero cron pause ` issued from another terminal while the daemon is mid-fire is overwritten back to Status=active when the fire completes, so the paused job silently keeps firing (continuing to spend model tokens). Equally, the documented usage of running `zero cron run --once` under system cron (comment at line 58-61) alongside a forever-mode daemon means two schedulers List() the same due job and both fire it — duplicate agent runs — because nothing serializes NextRunAt advancement. store.writeJob also uses a fixed temp name metadata.json.tmp, so two concurrent writers race on the same temp file. - -``` -cron_run.go fireJob: `code := exec(args, &outBuf, &errBuf)` ... (minutes later) ... `if err := store.Update(job); err != nil {` — `job` is the pre-exec snapshot including Status/Cwd/Model. store.go writeJob: `tmp := filepath.Join(dir, "metadata.json.tmp")` (fixed name, no lock). -``` - -**Suggested fix:** Add an advisory file lock around job mutation: acquire /.lock (O_CREATE|flock LOCK_EX on POSIX, LockFileEx on Windows) in fireJob and in cronSetStatus/cronResume; inside the lock re-Get the job, verify Status==active and NextRunAt unchanged before firing/updating, and merge only the fields fireJob owns (FireCount, NextRunAt, Status-pause-on-invalid) into the freshly read record. Also make writeJob use os.CreateTemp for the temp file. - -#### [medium/bug] Cron Next double-fires wall-clock schedules on DST fall-back (repeated hour) -`internal/cron/schedule.go:171` - -Schedule.Next matches on local wall-clock fields while advancing in absolute time, so on a DST fall-back day every wall-clock instant in the repeated hour occurs twice and Next returns the second occurrence after the first fires. A daily job '30 1 * * *' in America/New_York fires at 01:30 EDT, then fireJob computes Next(fired) which walks minute-by-minute through 01:59 EDT into 01:00 EST (same wall hour repeats) and returns 01:30 EST — one absolute hour later — so the daemon runs the job twice that day. Empirically verified: Next(2026-11-01 00:00 NY) = 01:30 -0400 EDT, then Next of that = 01:30 -0500 EST (1h apart, same wall clock). Vixie cron explicitly suppresses this duplicate; the package doc only claims robustness for spring-forward gaps. - -``` -case !has(s.minute, t.Minute()): - t = t.Add(time.Minute) // crosses 01:59 EDT -> 01:00 EST, hour 1 still matches, returns 01:30 EST -Verified output: "first fire: 2026-11-01 01:30:00 -0400 EDT ... second fire: 2026-11-01 01:30:00 -0500 EST ... DOUBLE FIRE on fall-back day confirmed" -``` - -**Suggested fix:** After finding a candidate t, detect the repeated-hour case: if the same wall-clock fields already matched earlier in absolute time within the run's advance (i.e., t.Add(-time.Hour) has identical wall clock and is after `after`), skip to the next distinct wall-clock match. Simplest concrete guard in fireJob/Next: compute n := s.Next(after); if n and s.Next(n) share the identical wall-clock representation (n.Format differs only by zone offset) treat the duplicated occurrence as already satisfied and advance past it — or document/accept first-occurrence-only by checking `n.Sub(after) < time.Hour && sameWallClock(n, after)`. - -#### [medium/bug] `zero cron add --recipe R` silently discards the user's cron expression in favor of the recipe's -`internal/cli/cron.go:112` - -In cronAdd, the recipe block runs BEFORE the positional argument is assigned to expr. At the recipe block expr is always "" (there is no --expr flag; expr only ever comes from positional[0] at line 123), so the guard `if expr == "" { expr = r.Expr }` — whose existence proves the intent that an explicit expression should win — always takes the recipe's expression. Then `if len(positional) == 1 && expr == ""` is false, so the user-supplied schedule is silently ignored with no error. Empirically verified: `zero cron add "0 12 * * *" --recipe git-recap` stores Expr="*/30 * * * *" (the recipe default), not the requested daily-noon schedule, so the job runs every 30 minutes instead of once a day. The help text (`zero cron add [--prompt P | --recipe R]`) explicitly invites this combination. - -``` -if recipe != "" { - ... - if expr == "" { - expr = r.Expr - } -... -} -... -if len(positional) == 1 && expr == "" { - expr = positional[0] -} -Verified: stored expr = "*/30 * * * *" when user passed "0 12 * * *". -``` - -**Suggested fix:** Move the positional assignment before the recipe block: `if len(positional) == 1 { expr = positional[0] }` first, then apply the recipe defaults only into still-empty fields. (Or reject the combination explicitly with a usage error.) - -#### [medium/bug] POSIX background-task kill terminates only the leader PID; the specialist's subprocess tree leaks on SIGKILL escalation (Windows kills the whole tree) -`internal/background/process_posix.go:50` - -A background specialist is a `zero exec --auto high` child that itself spawns tool subprocesses (bash commands, builds, dev servers). launchBackgroundProcess (internal/specialist/exec.go:638) starts it without Setpgid, and terminateProcess signals only the single pid: SIGTERM lets the child clean up via its signal context, but the escalation path — which exists precisely for a child that ignores or cannot process SIGTERM within the 3s grace — issues `process.Kill()` (SIGKILL) on the leader only. SIGKILL cannot be trapped, so the child's context-cancellation cleanup never runs and all its in-flight tool subprocesses are reparented and keep running (e.g. a `sleep`/server started by the specialist survives TaskStop). The Windows implementation deliberately uses `taskkill /T /F` (tree kill), and the codebase already knows the POSIX pattern — internal/config/process_posix.go:11 sets `Setpgid: true` and kills `-pid` — but the background package omits it, so TaskStop/KillRunning behavior diverges between platforms. - -``` -process_posix.go: `if err := process.Kill(); err != nil && !processGoneError(err)` — single-pid SIGKILL. process_windows.go: `exec.Command("taskkill", "/T", "/F", "/PID", ...)`. specialist/exec.go launchBackgroundProcess: `command := osexec.Command(binaryPath, args...)` with no SysProcAttr. Contrast config/process_posix.go: `cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}` + `syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)`. -``` - -**Suggested fix:** In launchBackgroundProcess set `command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}` (behind a !windows helper), and in terminateProcess signal the group: try `syscall.Kill(-pid, syscall.SIGTERM)` first and escalate with `syscall.Kill(-pid, syscall.SIGKILL)`, falling back to the single pid if the group signal fails with ESRCH/EPERM. - -#### [medium/race] Starting a second zero process clobbers a live sibling's running background-task metadata to error/PID=0 on disk -`internal/background/manager.go:462` - -NewManagerWithOptions -> loadTasks -> normalizeLoadedTask unconditionally rewrites any persisted task whose Status is running to Status=error, PID=0, ExitCode=-1 and PERSISTS that change (`if changed { manager.persistTaskLocked(task) }` at line 419). This is intended for restart-after-crash, but it fires whenever ANY second zero process constructs a Manager on the shared default root (Runtime.Manager() lazily calls background.NewManager("") on the first Task/TaskOutput/TaskStop tool use — registry.go wires all three through runtime.Manager). So while session A has a live background specialist running, session B's first use of a Task tool marks A's task error/PID=0 on disk: B's TaskOutput then reports the live task as failed, B's TaskStop can never stop it (PID is zeroed), and the on-disk record is wrong until A's own Wait-goroutine MarkExited happens to rewrite it from A's in-memory copy. - -``` -if task.Status == StatusRunning { - task.Status = StatusError - task.PID = 0 - task.ExitCode = -1 - ... - changed = true - manager.warnf("marked reloaded running background task %s as error; original process ownership was lost", task.ID) -} -...loadTasks: if changed { if err := manager.persistTaskLocked(task); err != nil {...} } -``` - -**Suggested fix:** Before declaring ownership lost, probe liveness: if task.PID > 0 and the process answers signal 0 (and optionally a recorded owner-pid/start-time matches), keep the task as running (read-only view) instead of rewriting it; only persist the error downgrade when the PID is demonstrably gone, or record the owning zero PID in the metadata and only let that owner (or a load that finds the owner dead) downgrade running tasks. - -#### [low/ux] `zero update --check` always fails on non-release builds: version "dev" is rejected before the network call -`internal/cli/update.go:37` - -runUpdate passes the package-level `version` variable (default "dev", only overridden by release ldflags -X internal/cli.version) as CurrentVersion, and update.Check normalizes CurrentVersion FIRST: normalizeVersionTag("dev") fails the `^v?(\d+)\.(\d+)\.(\d+)` pattern, so Check returns `invalid semantic version: dev` before fetching anything. Every `go install`/`go run` user gets "Could not check for updates: invalid semantic version: dev" and can never use the command, even though the latest-release information is independent of the local version. - -``` -update.go Check: `currentVersion, err := normalizeVersionTag(strings.TrimSpace(firstNonEmpty(options.CurrentVersion, "0.0.0")))` with `var version = "dev"` (app.go:33) and `CurrentVersion: version` (update.go cli line 37); versionPattern = `^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-+].*)?$` does not match "dev". -``` - -**Suggested fix:** In Check (or runUpdate), treat an unparseable CurrentVersion as "0.0.0": e.g. `if _, err := normalizeVersionTag(options.CurrentVersion); err != nil { options.CurrentVersion = "0.0.0" }` so the check still reports the latest release (optionally noting the local version is a dev build). - -#### [low/wiring] TUI /doctor omits config paths, so config.files and config.validation always warn in the TUI while the CLI surface passes them -`internal/tui/command_center.go:17` - -The CLI `zero doctor` resolves UserConfigPath/ProjectConfigPath via config.DefaultResolveOptions and passes them into doctor.Run (internal/cli/observability.go:43-46, 64-72), enabling the config.files and config.validation checks. The TUI's /doctor calls doctor.Run with only Now/Runtime/Provider, so even when the TUI was launched from a workspace whose config files exist (and were used to resolve m.providerProfile), the report always shows '[warn] config.files - No explicit Zero config files were inspected' and '[warn] config.validation - No Zero config files were available to validate'. Same command name, materially weaker diagnostics in one surface; a user with a malformed project config sees pass-with-warn in the TUI where the CLI reports the actual line/col failure. - -``` -report := doctor.Run(doctor.Options{ - Now: m.now, - Runtime: "go", - Provider: m.providerProfile, -}) // no UserConfig/ProjectConfig/Connectivity, vs cli/observability.go which fills UserConfig/ProjectConfig from config.DefaultResolveOptions(workspaceRoot) -``` - -**Suggested fix:** Thread the resolved config paths into the TUI (e.g. add UserConfigPath/ProjectConfigPath to tui.Options, populated in runInteractiveTUI from config.DefaultResolveOptions) and pass them to doctor.Run in doctorText(). - -#### [low/bug] Byte-indexed truncation in promptExcerpt/cronTruncate splits multi-byte UTF-8 runes -`internal/cli/cron.go:275` - -promptExcerpt truncates with `p[:47]` after a byte-length check (`len(p) > 48`), and cronTruncate (cron_run.go:183) does `s[:500]`. Both index bytes, not runes, so a prompt or stderr tail containing non-ASCII text (e.g. Japanese or emoji in a job prompt) can be cut mid-rune: `zero cron list` then prints an invalid-UTF-8 mojibake byte before the ellipsis, and the RunRecord.Error written to runs.jsonl gets the invalid bytes coerced to U+FFFD by json.Marshal. - -``` -func promptExcerpt(p string) string { - p = strings.TrimSpace(strings.ReplaceAll(p, "\n", " ")) - if len(p) > 48 { - return p[:47] + "…" - } -... -func cronTruncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:500 /* s[:max] */] + "…" -``` - -**Suggested fix:** Truncate on rune boundaries: convert to []rune (or walk with utf8.DecodeRuneInString) before slicing, e.g. `r := []rune(p); if len(r) > 48 { return string(r[:47]) + "…" }`, and the same for cronTruncate. - -#### [low/security] notify.sanitizeMessage passes C1 control characters (U+0080–U+009F) into the OSC-9 escape it is meant to protect -`internal/notify/notify.go:134` - -sanitizeMessage's stated contract is to drop control characters 'so the message can't break the escape or inject terminal control', but its filter (`r == 0x1b || r == 0x07 || r < 0x20 || r == 0x7f`) only covers C0+DEL. C1 controls U+0080–U+009F pass through: U+009C (ST) terminates the OSC string early in terminals that honor C1 in UTF-8, and U+009B (CSI) can start an injected control sequence. Today every call site passes the constant DefaultMessage strings, so there is no tainted path yet — but Notify(event, message) is the package's public API and the sanitizer exists precisely to make arbitrary messages safe, so the gap is a latent injection vector for any future caller that includes prompt/tool content in the notification body. - -``` -for _, r := range s { - if r == 0x1b || r == 0x07 || r < 0x20 || r == 0x7f { - continue - } - b.WriteRune(r) // U+0080–U+009F (incl. ST/CSI) are written through -``` - -**Suggested fix:** Extend the filter to C1: `if r == 0x1b || r == 0x07 || r < 0x20 || (r >= 0x7f && r <= 0x9f) { continue }`. - -#### [low/race] terminateProcess grace-period polling probes a possibly-reaped PID and can SIGKILL a recycled PID -`internal/background/process_posix.go:38` - -After SIGTERM, the launcher's Wait goroutine (specialist/exec.go launchBackgroundProcess) reaps the child as soon as it exits, freeing the PID, while terminateProcess keeps polling `processAlive` (signal 0 via a fresh os.FindProcess handle, which on Unix carries no done-state) for up to 3 seconds and then sends SIGKILL to whatever now owns that PID. The manager carefully guards against stale PIDs BEFORE signalling (markKilledIfStillRunning: 'the pid may be stale, so do NOT signal it'), but the post-SIGTERM window has no such guard: if the OS recycles the PID inside the grace window, signal-0 reports alive and the escalation SIGKILLs an unrelated process. Low probability (PID reuse within 3s) but a real ordering that the codebase's own pre-kill comments acknowledge as a hazard. - -``` -deadline := time.Now().Add(terminationGracePeriod) -for time.Now().Before(deadline) { - if !processAlive(process) { return nil } - ... -} -if err := process.Kill(); err != nil && !processGoneError(err) { ... } // pid may have been reaped by command.Wait() and recycled -``` - -**Suggested fix:** Have the kill path coordinate with the reaper instead of probing the raw PID: signal through the original *os.Process held by the Wait goroutine (its handle returns ErrProcessDone after reaping), or combine with the Setpgid fix and signal the process group, or re-check manager.Get(taskID).Status != StatusKilled-exited before escalating to SIGKILL. - - -### TUI core (model, rendering, transcript) - -Audited internal/tui core (model.go, run.go, view.go, transcript.go, rendering.go, theme.go, options.go, startup.go) plus the wiring files they depend on (session.go, session_controls.go, spec_mode.go, commands.go, command_*.go, autocomplete.go, picker.go), reading every file in full and verifying against bubbletea v1.3.10 and the Gemini provider sources. 16 concrete findings. Confirmed the known inline-rendering defect: View() emits the whole transcript with no alt screen/viewport/tea.Println, and the standard renderer truncates frames taller than the terminal, so history is permanently lost (high). Three further high-severity correctness bugs: the transcript dedup key omits runID so Gemini's synthesized repeating tool-call IDs silently drop later tool cards live and on /resume (contradicting the run-scoped rcKey the renderer uses for exactly this reason); Ctrl+C pressed after an Esc-cancel quits before the cancelled run's checkpoint session events flush (the precise data loss flushRunIDs exists to prevent); and /exit quits unconditionally mid-run with the same checkpoint-orphaning effect. Medium findings: /spec bypasses the m.exiting run gate; Esc cancellation writes the 'Run cancelled.' marker only to the session log, leaving zero live-transcript feedback; O(n^2) transcript appends (full-slice copy + linear dedup scan per row); full-transcript re-render with per-line regex work on every spinner tick/stream delta; UTF-8 byte-slicing in session titles, spec titles, and tool-output truncation; composer textinput never gets a Width so long input is clipped invisibly instead of scrolling; /style is validated, stored, and displayed but never read by any run path; and wrapPlainText's strings.Fields collapses all indentation in assistant answers (code blocks flattened). Low findings: cancelled runs' usage never reaches the tracker, the ask-user card ignores the live answer it is passed, a cluster of test-only dead footer/help/theme/bash-msg code, and shortenPath's boundary-less home-prefix match. Cancellation plumbing (runID gating, decision-channel unblocking via ctx.Done, spinner tag dedup, sink wiring in run.go) otherwise checked out with no deadlocks or data races found. - -#### [high/ux] Inline renderer silently discards transcript taller than the terminal — history is unrecoverable -`internal/tui/run.go:26` - -The program runs inline (no tea.WithAltScreen), View() returns the ENTIRE transcript every frame (model.go transcriptView renders all rows), and nothing ever uses tea.Println or a viewport. Bubble Tea v1.3.10's standard renderer truncates any frame taller than the terminal: standard_renderer.go:186-187 'if r.height > 0 && len(newLines) > r.height { newLines = newLines[len(newLines)-r.height:] }'. The dropped top lines are never written to the terminal, so they never reach scrollback. Once a conversation exceeds the window height, the title bar and all earlier turns become permanently invisible with no scroll mechanism (mouse capture is deliberately disabled, there are no scroll keybindings, and /resume rebuilds the same too-tall frame). - -``` -run.go:26-30 builds programOpts with only tea.WithContext/WithInput/WithOutput (no alt screen, no Println); model.go:599-624 transcriptView loops `for index, row := range m.transcript { ... builder.WriteString(m.renderRow(row, width, rc)) }` and View() at model.go:584 returns it whole. grep confirms zero uses of tea.Println/WithAltScreen/viewport in non-test code. -``` - -**Suggested fix:** Either flush finalized rows to terminal scrollback via tea.Println (rendering only the live tail + composer in View), or wrap the transcript in a bubbles viewport sized from WindowSizeMsg with scroll keys. tea.Println is the minimal change for an inline UI: print each row once when it becomes final, keep only pending UI in View. - -#### [high/bug] Transcript dedup key ignores runID: repeated provider tool-call IDs (Gemini gemini_tool_N) silently drop later tool cards -`internal/tui/transcript.go:139` - -transcriptRowKey dedupes tool call/result rows on kind+id only, but the codebase itself documents (rendering.go:124-127) that some providers synthesize ToolCallIDs that repeat across turns, and internal/providers/gemini/provider.go:270 proves it: `id = fmt.Sprintf("gemini_tool_%d", syntheticIndex)` with the index restarting per response. rowContext was given run-scoped keys (rcKey(runID,id)) precisely for this, yet appendTranscriptRow→hasTranscriptRow uses the unscoped key, so the SECOND turn's `gemini_tool_0` call row (and its result row) is treated as a duplicate and never appended — within a single multi-turn run, across runs in one TUI session, and on /resume rehydration (session.go:127-129 also appends through appendTranscriptRow with all rows at runID 0). Users see only the first tool card; later identical-ID calls vanish from the transcript while still executing. - -``` -transcript.go:137-139: `case rowToolCall, rowToolResult: if row.id != "" { return fmt.Sprintf("%d:%s", row.kind, row.id) }` — no runID. rendering.go:124-127: "some providers synthesize ToolCallIDs that repeat across turns (e.g. Gemini's gemini_tool_N), so a bare id could attribute a decision or a result to a different run's call." gemini/provider.go:268-271: `if id == "" { id = fmt.Sprintf("gemini_tool_%d", syntheticIndex) }`. -``` - -**Suggested fix:** Include row.runID in the dedup key: `fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id)` (same for rowPermission/rowAskUser keys). The live re-delivery dedup (agentRowMsg rows vs the final agentResponseMsg.rows) still works because both carry the same runID. For rehydration, append rows from transcriptRowsFromSessionEvents directly (the event log has no duplicates) or salt each rehydrated row's runID with the event sequence. - -#### [high/bug] Ctrl+C after an Esc-cancel quits immediately and drops the pending checkpoint flush -`internal/tui/model.go:311` - -The whole flushRunIDs mechanism exists so a cancelled run's final agentResponseMsg is drained and its accumulated session events — including EventSessionCheckpoint payloads whose blobs are already on disk — get persisted (model.go:65-74). But the Ctrl+C handler only defers the quit when a run is CURRENTLY pending: `pendingFlush` is computed from m.pending before cancelRun. If the user pressed Esc to cancel a run (cancelRun sets pending=false and adds the id to flushRunIDs) and then presses Ctrl+C while the cancelled goroutine is still finishing, pendingFlush is false and tea.Quit fires immediately. The in-flight agentResponseMsg is never processed, so the run's tool calls/results, usage, and checkpoint-reference events are never appended — orphaning the checkpoint blobs and breaking /rewind for that run, the exact loss the code's own comments say this machinery prevents. The session_test.go coverage only exercises Ctrl+C while pending, not Esc-then-Ctrl+C. - -``` -model.go:305-314: `pendingFlush := false; if m.pending && m.activeRunID != 0 { pendingFlush = true }; m.cancelRun(); m.exiting = true; if pendingFlush && len(m.flushRunIDs) > 0 { return m, nil }; return m, tea.Quit` — an Esc-cancelled run leaves m.pending false but len(m.flushRunIDs) > 0, and the condition requires both. -``` - -**Suggested fix:** Drop the pendingFlush precondition: after cancelRun, defer the quit whenever any flush is outstanding — `if len(m.flushRunIDs) > 0 { return m, nil }`. The existing agentResponseMsg flush branch already fires tea.Quit when m.exiting && len(m.flushRunIDs) == 0. - -#### [high/bug] /exit quits immediately during an in-flight run or pending flush, orphaning checkpoint session events -`internal/tui/model.go:853` - -handleSubmit gates only commandPrompt on m.pending/m.exiting; slash commands run any time. `case commandExit` returns tea.Quit unconditionally — it neither cancels an active run nor waits for flushRunIDs to drain. Typing /exit (or /quit) while a run is in flight kills the program before the agent goroutine's final agentResponseMsg can be processed, so none of the run's session events (tool calls/results, usage, EventSessionCheckpoint references) are persisted, while the checkpoint blobs SnapshotForCheckpoint already wrote to disk stay orphaned and /rewind breaks for that run — the same data-loss class the Ctrl+C handler carefully defends against three lines up. The same holds if /exit is typed while a previously Esc-cancelled run is still flushing. - -``` -model.go:853-855: `case commandExit: m.exiting = true; return m, tea.Quit` with no cancelRun() call and no flushRunIDs check, versus the Ctrl+C handler at model.go:294-314 which cancels and defers the quit until the flush lands. -``` - -**Suggested fix:** Mirror the Ctrl+C path: `case commandExit: m.cancelRun(); m.exiting = true; if len(m.flushRunIDs) > 0 { return m, nil }; return m, tea.Quit`. - -#### [medium/bug] /spec can start a new run while the app is exiting, which the deferred tea.Quit then kills mid-flight -`internal/tui/spec_mode.go:22` - -handleSubmit blocks new prompt runs during the Ctrl+C deferred-quit window: model.go:836-839 gates `command.kind == commandPrompt && (m.pending || m.exiting)` with a comment explaining a run started now would be aborted by the deferred tea.Quit and orphan its checkpoint blobs. But /spec routes through handleSpecCommand, which checks only m.pending — after Ctrl+C cancels a run (pending=false, exiting=true, flush outstanding) the user can type `/spec task`, a new agent run starts (m.pending=true, new runID), and when the old run's flush drains, the agentResponseMsg handler fires the deferred tea.Quit (model.go:519-521) killing the new spec run mid-flight with none of its session events persisted — exactly the loss the prompt gate prevents. - -``` -spec_mode.go:22-25 checks only `if m.pending { ... return }` (no m.exiting), then lines 67-77 start a run via runAgentWithOptions; model.go:836-839 gates the equivalent prompt path on `(m.pending || m.exiting)`; model.go:519-521 fires `tea.Quit` as soon as flushRunIDs drains regardless of the new active run. -``` - -**Suggested fix:** Gate spec drafting on exiting too: `if m.pending || m.exiting { ... }` in handleSpecCommand (and the same guard in approveSpecReview before it starts the implementation run). - -#### [medium/ux] Esc-cancelling a run leaves zero feedback in the live transcript (the 'Run cancelled.' marker only goes to the session log) -`internal/tui/model.go:1081` - -cancelRun appends the 'Run cancelled.' message as a sessions.EventError to the persisted store only — no transcript row is ever appended. When the user hits Esc mid-run, the spinner/interim block disappears, the partial streamed answer is wiped (streamingText reset at model.go:1095), and the visible transcript shows nothing at all: no error row, no done line, no indication the run was interrupted. The comments at model.go:296 and model.go:503 claim the cancel path 'writes the "Run cancelled." marker', but that only holds for the persisted log — a /resume of the same session later DOES show the rowError (transcriptRowsFromSessionEvents maps EventError to a row), so the live surface and the rehydrated surface disagree. - -``` -model.go:1081-1087: `if m.pending && m.activeSession.SessionID != "" { if next, err := (*m).appendSessionEvent(sessions.EventError, map[string]any{"message": "Run cancelled."}); err == nil { *m = next } }` — there is no appendTranscriptRow/reduceTranscript call anywhere in cancelRun. -``` - -**Suggested fix:** In cancelRun, also append a visible row: `m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "Run cancelled."})` (guarded by m.pending so plain Esc-clear doesn't emit it). - -#### [medium/perf] appendTranscriptRow copies the whole transcript and rescans it for dedup on every append — O(n²) -`internal/tui/transcript.go:117` - -Every single row append — each agentRowMsg during a run, each row in the agentResponseMsg batch, each /resume rehydration row — allocates a brand-new slice and copies all existing rows (`append([]transcriptRow{}, rows...)`), and hasTranscriptRow linearly rescans the transcript computing an fmt.Sprintf key per existing row. Both are O(n) per append, so a long session is O(n²) in both time and allocations, executed on the Update goroutine where it directly delays input handling and rendering. The defensive copy buys nothing: model.Update already reassigns m.transcript and the model is passed by value, so in-place append semantics are safe. - -``` -transcript.go:113-120: `func appendTranscriptRow(rows []transcriptRow, row transcriptRow) []transcriptRow { if hasTranscriptRow(rows, row) { return rows }; next := append([]transcriptRow{}, rows...); next = append(next, row); return next }` and transcript.go:122-133 hasTranscriptRow looping `for _, existing := range rows { if transcriptRowKey(existing) == key ... }`. -``` - -**Suggested fix:** Use plain `append(rows, row)` (Go slice append amortizes), and replace the linear dedup scan with a `map[string]struct{}` of seen keys maintained on the model (or only dedup the agentResponseMsg batch against rows of the same runID). - -#### [medium/perf] Full transcript re-rendered from scratch every frame (every spinner tick and stream delta), including per-line regex parsing -`internal/tui/model.go:610` - -transcriptView rebuilds rowContext (a full transcript scan) and re-renders EVERY row through renderRow on every View() call. While a run is pending, the spinner self-schedules ticks (~10/s) and every agentTextMsg delta also triggers a frame, so each frame redoes: buildRowContext over all rows, word-wrapping with per-word lipgloss.Width calls, and per-line regexp matching in diffCardBody/readCardBody/grepCardBody for every historical tool card — plus interimBlock re-wraps the entire accumulated streamingText per frame (O(answer²) over a stream). None of the rendered output changes for finalized rows; only the spinner glyph and interim block do. On long transcripts this burns CPU continuously during runs and adds latency to every keystroke. - -``` -model.go:610-624: `rc := buildRowContext(m.transcript); for index, row := range m.transcript { ... builder.WriteString(m.renderRow(row, width, rc)) }` executed in View() (model.go:584); rendering.go:139-183 buildRowContext scans all rows; rendering.go:701/810/908 run hunkHeaderPattern/readNumberedLinePattern/grepMatchPattern per body line; model.go:673-686 interimBlock wraps full streamingText each frame. -``` - -**Suggested fix:** Cache each row's rendered string keyed by (row identity, width, resolved/auto state) and invalidate only on width change or when its state flips; alternatively pre-render rows once when appended and store the string on transcriptRow. For the interim block, cache wrapped lines and only re-wrap the trailing partial line on new deltas. - -#### [medium/bug] UTF-8 strings byte-sliced at fixed limits: session titles and tool-result text can be cut mid-rune -`internal/tui/session.go:93` - -Three call sites truncate by byte index on strings that are routinely multi-byte UTF-8: tuiSessionTitle slices the user's prompt at byte 80 (`title[:tuiSessionTitleLimit]`) and persists the result as the session title — a CJK/emoji prompt longer than 80 bytes is stored with a dangling partial rune that renders as a replacement character in /resume session cards and anywhere else the title is shown; specImplementationTitle (spec_mode.go:301-303) does the same to spec titles; and truncateTUIOutput (transcript.go:303) slices tool output at byte 240 (`output[:limit] + " [truncated]"`), producing invalid UTF-8 in transcriptRow.text and in the rehydrated rows built at session.go:233. The file already has the correct helper (truncateRunes, view.go:402) used elsewhere. - -``` -session.go:91-94: `title := strings.Join(strings.Fields(prompt), " "); if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] }`; transcript.go:300-303: `if limit <= 0 || len(output) <= limit { return output }; return output[:limit] + " [truncated]"`; spec_mode.go:301-303: `if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] }`. -``` - -**Suggested fix:** Use the existing rune-safe helper at all three sites: `title = truncateRunes(title, tuiSessionTitleLimit)` and `return truncateRunes(output, limit) + " [truncated]"` (or guard the byte cut with utf8.RuneStart back-off). - -#### [medium/ux] Composer textinput has no Width set: typing beyond the terminal width is clipped invisibly instead of scrolling -`internal/tui/model.go:227` - -textinput.New() is used with the default Width of 0 and no code ever sets m.input.Width (grep over internal/tui finds no assignment), including the WindowSizeMsg handler. In bubbles v1, Width==0 disables the input's horizontal-scrolling viewport and View() renders the entire value. composerLine then hard-truncates that line to the terminal width with fitStyledLine, so once the typed prompt is longer than the visible width the cursor and all subsequent characters are cut off at the right edge — the user keeps typing with no visual feedback at all, and pasted long prompts cannot be reviewed or edited beyond the first screenful. - -``` -model.go:227-233 configures prompt/styles/placeholder but never Width; model.go:702-706: `line := input.View(); if hint == "" { return fitStyledLine(line, width) }; return joinHeaderLine(fitStyledLine(line, width-lipgloss.Width(hint)-2), hint, width)` — ANSI truncation, not scrolling. -``` - -**Suggested fix:** On tea.WindowSizeMsg (model.go:456-459) set `m.input.Width = chatWidth(msg.Width) - lipgloss.Width(m.input.Prompt) - reservedHintWidth` so the textinput scrolls horizontally to keep the cursor visible. - -#### [medium/wiring] /style sets m.responseStyle but nothing ever reads it — the command is a silent no-op -`internal/tui/session_controls.go:117` - -Options.ResponseStyle is accepted, validated through defaultedResponseStyle, stored on the model, and mutable via /style with the confirmation 'Style preference is stored for this TUI session' — but a repo-wide grep shows the only consumers are the /style and /context display strings. runAgentWithOptions never threads it into agent.Options (no system-prompt suffix, no field), and the CLI entry (internal/cli/app.go:366) doesn't set it either. Selecting 'concise' or 'explanatory' therefore changes nothing about any response, while the command UX (a validated list of four styles with errors for unknown values) strongly implies it does. - -``` -session_controls.go:117: `m.responseStyle = args` followed by 'Style preference is stored for this TUI session.'; grep -rn ResponseStyle over internal/ and cmd/ matches only internal/tui display/validation sites (options.go:33, model.go:259, session_controls.go, command_views.go:155) and nothing in internal/agent — agent/system_prompt.go has no style hook. -``` - -**Suggested fix:** Thread it into the run: in runAgentWithOptions append a style directive to options.SystemPrompt (e.g. map responseStyle → instruction text) — or, until that exists, have /style report it as not yet wired (like /theme's shellOnlyCommandText) instead of claiming the preference is active. - -#### [medium/ux] wrapPlainText collapses all whitespace via strings.Fields — assistant answers lose code indentation and alignment -`internal/tui/rendering.go:261` - -renderUserRow, renderAssistantRow (the turn's final answer), interimBlock (live stream), and wrapDetailBlock all wrap through wrapPlainText, which splits each line with strings.Fields. Fields drops leading whitespace and collapses every internal run of spaces/tabs to a single space, so any code block, indented list, or column-aligned output in a final answer is flattened: a 4-space-indented Go snippet renders fully left-justified and `a b` becomes `a b`. For a coding agent whose answers routinely contain code, this visibly corrupts the primary output (newlines survive, but indentation — semantically meaningful in Python/YAML examples — is destroyed). - -``` -rendering.go:255-261: `for _, paragraph := range strings.Split(...) { ... for _, word := range strings.Fields(paragraph) {` — Fields(" if x {") yields ["if","x","{"], discarding the indent; callers renderAssistantRow (rendering.go:324) and interimBlock (model.go:679) feed final/streaming answers through it. -``` - -**Suggested fix:** Preserve leading whitespace per line (measure the indent prefix, wrap the remainder, re-prefix continuation lines) or only soft-wrap lines that exceed the measure and pass through shorter lines verbatim: `if lipgloss.Width(paragraph) <= measure { out = append(out, paragraph); continue }`. - -#### [low/bug] Cancelled runs' usage events are flushed to the session log but never recorded in the usage tracker -`internal/tui/model.go:512` - -The flush branch for a cancelled run persists its session events (including EventUsage payloads) but never calls recordUsageEvent with msg.usageEvents, unlike the active-run branch (model.go:531-537). Tokens and cost consumed by an Esc-cancelled run — often the most expensive kind, cancelled mid-tool-loop — are therefore missing from the status-line `tok · $` readout and /context usage summary for the rest of the TUI session, undercounting real spend. - -``` -model.go:506-522 handles `flushing` runs with only `m.appendSessionEvents(flushableSessionEvents(msg.sessionEvents))`; msg.usageEvents is ignored on this path, while the matching-runID path at model.go:531-537 loops `for _, event := range msg.usageEvents { m, usageRows = m.recordUsageEvent(msg.usageModelID, event) ... }`. -``` - -**Suggested fix:** In the flush branch, also iterate msg.usageEvents through m.recordUsageEvent(msg.usageModelID, event) before deleting the runID from flushRunIDs. - -#### [low/wiring] renderFocusedAskUserPrompt receives the live input value but never renders it -`internal/tui/rendering.go:490` - -The ask-user questionnaire card is called with the composer's current value — `renderFocusedAskUserPrompt(*m.pendingAskUser, m.input.Value(), width)` at model.go:633 — but the `input string` parameter is never referenced in the function body: the card shows the heading, question counter, question text, options, and a key hint, and nothing else. The typed answer is only visible in the composer at the very bottom of the frame, while the card itself says 'type an answer, Enter to submit', so the answer being composed is displaced from the prompt asking for it. The parameter is pure dead wiring as written. - -``` -rendering.go:490-519: `func renderFocusedAskUserPrompt(prompt pendingAskUserPrompt, input string, width int) string { ... }` — the body builds `lines` from prompt.request fields and a static hint only; `input` does not appear after the signature. -``` - -**Suggested fix:** Render the in-progress answer inside the card (e.g. `lines = append(lines, fill(zeroTheme.ink).Render("❯ "+input))` above the hint), or drop the parameter and its call-site argument. - -#### [low/dead-code] Dead production code: legacy footer/help builders and four theme styles are never used outside tests -`internal/tui/rendering.go:62` - -A cluster of pre-Lime UI leftovers survives only because tests still call it: defaultCommandFooterText (rendering.go:62), commandFooterText (64), m.footerText (68) and formatCommandFooterText's 'Esc clear/Esc cancel/Ctrl+C quit' footer (77-118), plus formatCommandHelpLines (commands.go:290) and listCommandNames (commands.go:281) — grep shows no non-test callers for any of them, so no footer of this form is ever rendered by the Lime surface. Likewise theme.go declares and initializes styles that no renderer references: selRow (theme.go:159; renderers use onSel() instead), statusOk/statusErr (164-165), and line2 (126). Also bashResultMsg.command (command_bash.go:21) is populated by runBashEscape but never read by the Update handler (model.go:574-576 uses only msg.output). This dead code misleadingly documents key behavior ('Esc clear Ctrl+C quit') that no longer matches the rendered UI. - -``` -grep -rn over internal/tui excluding _test.go: footerText/commandFooterText/formatCommandHelpLines/listCommandNames appear only at their definitions; zeroTheme.selRow/statusOk/statusErr/line2 appear only in theme.go; model.go:574-576 `case bashResultMsg: m.transcript = reduceTranscript(..., text: msg.output)` ignores msg.command. -``` - -**Suggested fix:** Delete footerText, commandFooterText, formatCommandFooterText, defaultCommandFooterText, formatCommandHelpLines, listCommandNames and their tests; remove selRow, statusOk, statusErr, line2 from tuiTheme; drop the unused command field from bashResultMsg (or render it for correlation). - -#### [low/bug] shortenPath matches the home directory as a bare string prefix, mangling sibling paths -`internal/tui/view.go:227` - -The title-bar path shortener replaces the home-directory prefix without requiring a path-separator boundary: any cwd that merely begins with the home string is rewritten. With home=/Users/kratos, a workspace at /Users/kratosbackup/proj renders as '~backup/proj' in the title bar — a wrong path that looks like it lives under the user's home. - -``` -view.go:226-229: `if home, err := os.UserHomeDir(); err == nil && home != "" { if strings.HasPrefix(path, home) { return "~" + path[len(home):] } }` — no check that the next byte is os.PathSeparator or that path == home. -``` - -**Suggested fix:** Require the boundary: `if path == home { return "~" }; if strings.HasPrefix(path, home+string(os.PathSeparator)) { return "~" + path[len(home):] }`. - - -### Tools & sandbox - -Audited internal/tools and internal/sandbox by reading every tool implementation and the sandbox engine/risk/runner/grants code, and verified the high-impact behaviors with runnable test snippets. The most serious confirmed defects: (1) the bash tool's timeout/kill semantics rely on exec.CommandContext, which I proved does NOT kill the command past the deadline when a child process inherits the stdout/stderr pipes — a `sleep 5 &`-style background job keeps the whole call blocked far past timeout_ms (no process-group kill, no WaitDelay). (2) The macOS sandbox-exec profile denies all /tmp and /dev/null writes (proven), so virtually every wrapped shell command that touches a tempfile or /dev/null fails under the native darwin backend. (3) apply_patch path validation misparses unified-diff body lines beginning with "--- "/"+++ ", so deleted/added content lines get treated as file paths — producing bogus MutationTargets/checkpoint entries and spurious confinement errors. (4) The grep tool's glob filter matches against the workspace-root-relative path while users naturally pass root-relative globs like "*.go", so grep silently returns "No matches" when a subdirectory path is searched. Plus several medium/low wiring, perf, and UX issues (OnSandboxDecision callback is dead code, escalate_model not in any Core* set, O(n*m) destructive-regex scanning, interactive-command false positives inside quoted args, etc.). - -#### [high/bug] bash tool timeout does not kill the command when a child inherits the output pipes -`internal/tools/bash.go:104` - -The bash tool relies solely on exec.CommandContext(ctx,...) for timeout enforcement: when the deadline fires, Go sends SIGKILL to the direct child (/bin/sh) only. It does NOT put the command in its own process group, sets no cmd.WaitDelay, and never kills the process group. If the shell command backgrounds a process or spawns children that inherit the stdout/stderr *bytes.Buffer pipes, command.Run() blocks on the pipe read until those grandchildren exit — long past timeout_ms. I reproduced this: a 1s context timeout against `/bin/sh -c 'sleep 5 & echo started'` returned only after 5.018s with err=nil (the timeout was completely ineffective). The agent loop and TUI therefore hang for the full child lifetime regardless of the user-specified timeout. - -``` -commandCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond) ... err = command.Run() — and buildBashCommand does `command := exec.CommandContext(ctx, spec.Name, spec.Args...)` with no SysProcAttr{Setpgid:true} and no command.WaitDelay. Proven: elapsed=5.018s err= for a 1s timeout. -``` - -**Suggested fix:** Set command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} (the repo already does this in internal/config/process_posix.go) and on timeout kill the whole process group (syscall.Kill(-pid, SIGKILL)); additionally set command.WaitDelay (Go 1.20+) to a small grace period so Run() returns promptly even when descendants hold the pipes. Apply in both buildBashCommand branches and in sandbox/runner.go's CommandContext. - -#### [high/bug] macOS sandbox-exec profile blocks /tmp and /dev/null, breaking most wrapped shell commands -`internal/sandbox/runner.go:262` - -sandboxExecProfile emits `(allow file-write* (subpath ""))` when EnforceWorkspace is true, with `(deny default)`. This denies writes to /dev/null, /dev/stdout, $TMPDIR, and /tmp — paths that virtually every real shell command (and the Go toolchain, git, compilers, `cmd > /dev/null`) needs. I verified on this darwin host: under exactly that profile, `echo hi > /dev/null` fails with `/dev/null: Operation not permitted` and `mktemp` fails with `Operation not permitted`. So when the native darwin sandbox-exec backend is selected (the default on macOS), wrapped bash commands break pervasively, not just destructive ones. - -``` -writeRule = `(allow file-write* (subpath "` + sandboxProfileString(workspaceRoot) + `"))` with header `(deny default)`; no allowance for /dev/null, (literal "/dev"), or the system temp dir. Repro: `sandbox-exec -p '...subpath "/tmp/zero-audit-ws"...' /bin/sh -c 'echo hi > /dev/null'` -> Operation not permitted; `mktemp` -> Operation not permitted. -``` - -**Suggested fix:** Add the standard device/temp write allowances to the profile, e.g. `(allow file-write-data (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/dtracehelper"))`, `(allow file-write* (subpath "/private/tmp") (subpath "/private/var/folders"))` and TMPDIR, plus `(allow file-write* (regex #"^/dev/tty"))`, alongside the workspace subpath. - -#### [high/bug] apply_patch misparses unified-diff body lines starting with '--- '/'+++ ' as file paths -`internal/tools/apply_patch.go:161` - -patchPathsFromLine treats ANY line beginning with `--- ` or `+++ ` as a diff file header and extracts a 'path' from field[1]. But these prefixes also occur inside hunk bodies: a removed line whose content is `-- drop this comment` appears as `--- drop this comment`, and an added line `++ note` appears as `+++ note`. This corrupts both confinement validation (validatePatchPaths) and MutationTargets (changedFilesFromPatch). I verified: a patch with body line `--- drop this comment` yields changedFiles `[f.sql drop]` (the bogus 'drop' target), and a body line `--- /etc/passwd was here` makes validatePatchPaths reject a perfectly valid patch with `patch path "/etc/passwd" must stay inside the workspace`. The same wrong path set flows into MutationTargets, so /rewind checkpoints the wrong files (or fails to checkpoint the real one). - -``` -if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { fields := strings.Fields(line); if len(fields) >= 2 { return []string{stripPatchPrefix(fields[1])} } } — with no requirement that the line sit in a file-header position. Repro: changedFilesFromPatch(".", patch) = [f.sql drop]; validatePatchPaths(...) on body `--- /etc/passwd was here` => error. -``` - -**Suggested fix:** Only interpret `---`/`+++` lines as headers when they form a real header pair (a `+++ ` line immediately following a `--- ` line, or gated by a preceding `@@`/`diff --git`/index marker). Track hunk state while scanning so lines inside @@ hunks are never parsed as paths; prefer the `diff --git` names when present. - -#### [high/bug] grep glob filter is applied to root-relative paths, silently dropping matches under a subdirectory search -`internal/tools/grep.go:224` - -When grep is given a `path` subdirectory plus a `glob`, the glob is matched against the path RELATIVE TO THE WORKSPACE ROOT (confineGrepFile returns Rel(resolvedRoot, file)), not relative to the searched directory. A natural call like {path:"internal", glob:"*.go"} therefore tests the matcher `^[^/]*\.go$` against `internal/main.go`, which fails because of the `internal/` prefix, so grep reports 'No matches' even though matching files exist. I verified: grep with path=internal, glob=*.go over a file internal/main.go containing the pattern returns status=ok, output "No matches found." Users must instead write glob "**/*.go" or "internal/*.go", which is non-obvious and inconsistent with the glob tool (which matches relative to its cwd). - -``` -files, err := grepFiles(resolvedRoot, target, globMatcher) and inside grepFiles: relative, _, ok := confineGrepFile(resolvedRoot, path) ... if globMatcher == nil || globMatcher.MatchString(relative). Repro: pattern=needle path=internal glob=*.go over internal/main.go -> "No matches found." -``` - -**Suggested fix:** Match the glob against the path relative to the searched `target` directory (filepath.Rel(target, path)) rather than relative to the workspace root, mirroring the glob tool's cwd-relative semantics. Keep the root-relative form only for the output label/confinement. - -#### [medium/wiring] escalate_model tool is implemented and registry-aware but never included in any Core* tool set -`internal/tools/registry.go:224` - -escalate_model (escalate_model.go) is a complete tool whose Meta["escalate_to_model"] is explicitly consumed by the agent loop (loop.go:523 lifts it into RequestedModel for a mid-run provider switch). But none of CoreReadOnlyTools/CoreWriteTools/CoreShellTools/CoreNetworkTools/CoreTools register it, so via the standard Core* construction path the model can never call it and the loop's escalation handling is dead. The consumer side (RequestedModel wiring) exists but the producer is never registered in the core surface, a producer/consumer wiring mismatch. - -``` -CoreTools() composes only CoreReadOnlyTools+CoreWriteTools+CoreShellTools+CoreNetworkTools; none reference NewEscalateModelTool. Meanwhile loop.go:523 sets `RequestedModel: result.Meta["escalate_to_model"]` and escalate_model.go is the only producer of that key. -``` - -**Suggested fix:** If escalation is a supported feature, register NewEscalateModelTool() in the appropriate Core* set (or document where it is registered, e.g. exec/specialist wiring) so the loop's RequestedModel path is reachable; otherwise remove the dead RequestedModel handling. - -#### [medium/bug] Interactive-command detector flags pager/REPL names that appear inside quoted arguments -`internal/sandbox/safe_command.go:138` - -DetectInteractiveCommand splits on `|` (and other operators) BEFORE stripping quotes, and the single-word program scan (the second loop) does not anchor on quoting. A command like `git commit -m "tidy | less noise"` is split on the literal pipe inside the quoted message, producing a segment `less noise` whose first program is `less`, so the whole command is wrongly blocked as an interactive pager. I verified: DetectInteractiveCommand(`git commit -m "tidy | less noise"`, "darwin") returns Interactive=true, Command="less". The multi-word interactiveSegments path was deliberately hardened against this (commandBody anchoring), but the single-word program path and the operator split are not quote-aware, so legitimate commit messages, echo strings, and sed scripts containing these tokens are refused. - -``` -splitShellSegments uses strings.NewReplacer("|","\n", ...) on the raw normalized command with no quote handling, then the second loop does `first := firstProgram(fields); program, ok := interactivePrograms[first]`. Repro: `git commit -m "tidy | less noise"` -> interactive=true command="less". -``` - -**Suggested fix:** Tokenize with quote awareness (or strip/ignore quoted spans) before splitting on shell operators, so pipes/semicolons inside single- or double-quoted strings are not treated as segment boundaries and program names inside quoted arguments are not scanned as executables. - -#### [low/dead-code] OnSandboxDecision callback option is set up to fire but is never wired by any caller (dead code) -`internal/tools/registry.go:108` - -RunOptions.OnSandboxDecision is invoked (in a recover-guarded goroutine) on every sandbox evaluation, but a repo-wide search shows no caller ever sets it: the only references are the field declaration and the call site in registry.go. The agent loop builds RunOptions at loop.go:481 and askUserFallbackResult without ever populating OnSandboxDecision, and there is no other RunOptions literal that sets it. The goroutine, panic-recovery, and the whole async-notify path are therefore unreachable plumbing — a maintenance hazard and a (small) per-call allocation/branch that never does anything. - -``` -grep across the repo: the only OnSandboxDecision occurrences are registry.go:19 (field), :108 (`if options.OnSandboxDecision != nil`), and :116 (the call). No RunOptions{...} literal in internal/agent, internal/tui, internal/cli, internal/specialist sets it. -``` - -**Suggested fix:** Either wire OnSandboxDecision from the agent loop / CLI to feed sandbox decisions to observers, or delete the field and its goroutine call site since SandboxDecision is already returned synchronously on Result and consumed by the loop. - -#### [low/perf] Destructive/network/installer classification recompiles N regexes over the full command on every shell call -`internal/sandbox/risk.go:53` - -matchesDestructive runs the large destructiveCommandPattern plus a loop over destructiveExtraPatterns (5 more regexes) against the entire command string, and Classify additionally runs networkCommandPattern and pipedInstallerPattern. While the regexes are package-level (compiled once), they are all evaluated unconditionally on every bash Classify call, and several are heavy alternations with backtracking-prone constructs like `(-[A-Za-z]*r[A-Za-z]*f|...)` and `(-\S+\s+)*`. For very long commands (e.g. a multi-kilobyte here-doc or generated script passed as a single `command` arg) this is an O(pattern_count * len) hot path on the latency-critical permission gate. Not catastrophic, but it scans the whole untrimmed command repeatedly. - -``` -func matchesDestructive: `if destructiveCommandPattern.MatchString(command)` then `for _, pattern := range destructiveExtraPatterns { if pattern.MatchString(command) ... }`; Classify also calls networkCommandPattern.MatchString(command) and pipedInstallerPattern.MatchString(command) on the same full string. The `(-\S+\s+)*0?777` style sub-patterns are quadratic-prone. -``` - -**Suggested fix:** Cap the scanned length (e.g. classify only the first few KB plus the command head), short-circuit on a cheap prefix check (only run the rm/chmod/chown alternations when the command contains those tokens), and combine the extra patterns into one alternation to reduce passes. - -#### [low/bug] Grant store prefix/substring safety relies on exact-key map but Lookup trims while writer key may not match normalized name -`internal/sandbox/grants.go:146` - -Grant() stores under grant.ToolName (already TrimSpace'd via createGrant), but Lookup() reads state.Grants[strings.TrimSpace(toolName)] while readState() validates/normalizes keys by the raw map key (not trimmed). If a grants file is hand-edited or produced by another writer with a key that has surrounding whitespace, normalizeStoredGrant compares grant.ToolName!=name using the raw key and will error the whole file load, while a request for the trimmed name silently won't match. The matching is exact-key (good — no prefix pitfall), but the trim asymmetry between write path (trims) and the stored map key (not re-keyed after trim) means a tool granted as " bash " can never be looked up as "bash" and vice versa without a file-load error. Low severity because the normal in-process Grant path trims consistently, but cross-writer/edited files behave inconsistently. - -``` -Grant: `state.Grants[grant.ToolName] = grant` (grant.ToolName trimmed). Lookup: `grant, ok := state.Grants[strings.TrimSpace(toolName)]`. readState iterates `for name, grant := range state.Grants` and ValidateToolName(name)/normalizeStoredGrant(name,grant) key off the un-retrimmed map key. -``` - -**Suggested fix:** Re-key entries on the trimmed/normalized tool name during readState (delete the old key, insert the trimmed one) so the stored map key always equals the canonical name Lookup uses, eliminating the write/read trim asymmetry. - -#### [low/ux] read_file rune-width line numbering can misalign and read_file returns Truncated but no truncation marker in output -`internal/tools/read_file.go:89` - -When max_lines truncates the selection, read_file sets Result.Truncated=true but the Output gives no in-band indication that lines were dropped — the header reports `lines start-last of total` which looks like a normal bounded read, so a model consuming only Output (truncation flag is metadata) cannot tell the read was capped and may assume it saw everything up to last. The grep/glob tools have the same Truncated-flag-only behavior but at least grep's content stops at head_limit visibly; read_file's header actively implies completeness for the shown range. This can cause the agent to act on a partial file believing it is complete. - -``` -selected = selected[:maxLines]; truncated = true ... header := fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total) — the only signal that more lines were withheld within the requested range is Result.Truncated, never surfaced in Output. -``` - -**Suggested fix:** When truncated, append an explicit trailing marker to Output (e.g. `… (truncated at max_lines=N; M more lines in range)`) so the model sees the cap regardless of whether it inspects the Truncated metadata. - - -### Runtime & providers - -Audit of internal/zeroruntime, internal/providers (openai/anthropic/gemini/providerio + factory), internal/providercatalog, and internal/modelregistry in the zero CLI. All files were read in full; package tests and go vet pass, so all findings are latent defects. Highest-impact: (1) reasoning effort is a fully modeled, user-facing feature (mode presets, --reasoning-effort, registry effort data) that no provider adapter ever serializes — smart vs precise modes send byte-identical requests (high, wiring); (2) the shared SSE idle watchdog only resets on completed data payloads, so comment keep-alives (e.g. OpenRouter ': PROCESSING' heartbeats) are invisible and healthy long requests are aborted as idle after 90s (medium); (3) the registry caps gpt-4.1 — the default model — at 16,384 output tokens versus the API's 32,768 (its own mini/nano entries use 32,768), and that value is wired into max_completion_tokens, truncating long generations (medium); (4) the Gemini adapter never parses cachedContentTokenCount, so CachedInputTokens is structurally zero for Google models and the catalog's Gemini cached-input pricing is unreachable, overstating costs (medium); (5) the OpenAI adapter drops tool calls that have a complete name+arguments but no id (triggering the agent's retry loop), while zeroruntime's tested empty-ID collector machinery is unreachable from every adapter (medium, wiring). Lower severity: claude-haiku-3.5 wrongly flagged vision-capable (real model is text-only; reachable via config-pinned model since the factory resolves without deprecation fallback), HTTP 529 classified as rate-limit but excluded from the 429/503 retry policy, the dead isImplicitOpenAI condition in the provider factory, and the never-used Descriptor.Public field. Usage normalization (EffectiveInputTokens/NormalizeUsage/CollectStream accumulation), Anthropic cache accounting (input+cache_read+cache_creation summed with cached clamped as a subset), retry idempotency policy, SSE parsing, auth-header handling, and secret redaction were checked and found sound; remaining catalog data (context windows, prices, capabilities for active models) matches published provider values. - -#### [high/wiring] Reasoning effort is modeled, validated, and surfaced everywhere but never serialized into any provider request -`internal/providers/openai/types.go:3` - -modelregistry defines ReasoningEfforts/DefaultReasoningEffort per model, EffectiveReasoningEffort resolves requested efforts, modes.go ships presets that differ ONLY by effort (smart = sonnet-4.5/medium vs precise = sonnet-4.5/high, 'Careful, high-effort reasoning'), the CLI accepts --reasoning-effort and prints coercion notices ('using high instead'), and the TUI shows the effective effort — but no provider wire type carries it: chatCompletionRequest has no reasoning_effort field, messagesRequest has no thinking/budget block, and generateContentRequest's generationConfig has only MaxOutputTokens. zeroruntime.CompletionRequest itself has no effort field, and the factory never forwards options. So /mode precise and /mode smart produce byte-identical provider requests, and --reasoning-effort high is a silent no-op for every model — the user-facing feature advertises behavior the wire layer cannot deliver. A code comment in cli/exec.go acknowledges the gap, but nothing user-facing does (the notice text implies the effort takes effect). - -``` -openai/types.go:3-10 chatCompletionRequest fields are Model/Messages/Tools/MaxCompletionTokens/Stream/StreamOptions only; anthropic/types.go:3-13 messagesRequest has no thinking field; gemini/types.go:10-12 `type generationConfig struct { MaxOutputTokens int }`; grep for reasoning/thinking/budget across internal/providers returns nothing; cli/exec.go:848-850 'NOTE: the effective effort is not yet forwarded to the provider request — the zeroruntime.CompletionRequest / provider wire schemas carry no effort field.'; modes.go:57-62 the precise preset differs from smart only by Effort. -``` - -**Suggested fix:** Add a ReasoningEffort field to zeroruntime.CompletionRequest, plumb it through providers.Options/the factory, and map it per adapter (OpenAI: reasoning_effort; Anthropic: thinking budget_tokens derived from effort; Gemini: generationConfig.thinkingConfig.thinkingBudget). Until then, gate the modes/--reasoning-effort UX with an explicit 'effort is advisory only' warning so users are not misled. - -#### [medium/bug] SSE comment keep-alives never reset the 90s idle watchdog, so heartbeating upstreams are aborted as idle -`internal/providers/providerio/providerio.go:184` - -ScanSSEDataWithContext resets the idle timer only when a completed data payload crosses the channel: resetIdle() is called solely in the `case item, ok := <-payloads` branch. But scanSSEPayloads silently discards SSE comment lines (`if strings.HasPrefix(line, ":") { continue }`) and blank lines flush with no payload. Gateways that keep the connection alive with comment heartbeats while the model is queued or thinking — OpenRouter (in providercatalog) emits `: OPENROUTER PROCESSING` comments precisely for this — therefore look 'idle' to the watchdog even though bytes are actively arriving. After defaultStreamIdleTimeout (90s, all three adapters) the stream is cancelled and surfaced as 'provider stream error: idle timeout ... (upstream stopped sending data)', killing a healthy long-running request mid-turn. - -``` -providerio.go:90-92 `if strings.HasPrefix(line, ":") { continue }` (comments dropped before producing a payload); providerio.go:172-184 `case item, ok := <-payloads: ... resetIdle(); if !handle(item.data)` — resetIdle is reachable only from a data payload, never from a comment/blank heartbeat line. -``` - -**Suggested fix:** Treat any received line as liveness: have scanSSEPayloads invoke an onActivity callback (or send a zero-value tick on the payloads channel) for comment and blank lines so the consumer calls resetIdle(), or wrap response.Body in a reader that resets the idle timer on every successful Read. - -#### [medium/bug] Registry caps gpt-4.1 output at 16,384 tokens; the API max is 32,768 (the file itself uses 32,768 for mini/nano) -`internal/modelregistry/catalog.go:27` - -The catalog entry for gpt-4.1 — the DefaultModelID — declares MaxOutputTokens 16,384, while OpenAI's published limit for the whole GPT-4.1 family is 32,768 output tokens (and lines 28-29 of this same file correctly use 32,768 for gpt-4.1-mini and gpt-4.1-nano, making the flagship entry internally inconsistent). This is not display-only data: providers/factory.go resolveProfile copies entry.ContextLimits.MaxOutputTokens into openai.Options.MaxTokens, and openai/provider.go openAIRequest serializes it as max_completion_tokens. Every gpt-4.1 request is therefore hard-capped at half the model's real output budget, so long generations (big file writes, large diffs) are truncated with finish_reason "length" at 16,384 tokens when the model could have produced 32,768. - -``` -catalog.go:27 `openAIModel("gpt-4.1", "GPT-4.1", "gpt-4.1", ..., ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 16_384}, ...)` vs catalog.go:28 `"gpt-4.1-mini" ... MaxOutputTokens: 32_768` ; factory.go:133 `maxOutputTokens: entry.ContextLimits.MaxOutputTokens` ; openai/provider.go:325-327 `if provider.maxTokens > 0 { mapped.MaxCompletionTokens = provider.maxTokens }`. -``` - -**Suggested fix:** Change the gpt-4.1 entry to ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768} to match the API limit and the sibling entries. - -#### [medium/bug] Gemini adapter never reads cachedContentTokenCount, so CachedInputTokens is always 0 and the catalog's Gemini cached pricing is unreachable -`internal/providers/gemini/types.go:88` - -Gemini's usageMetadata includes cachedContentTokenCount (populated by the implicit prompt caching that Gemini 2.5 models perform automatically), but the adapter's usageMetadata struct only parses promptTokenCount/candidatesTokenCount/thoughtsTokenCount, and emitDone builds the TokenUsage with no CachedInputTokens. Meanwhile modelregistry/catalog.go defines CachedInputPerMillion for every Google model (0.125/0.25 tiers for 2.5-pro, 0.03 for flash, 0.01 for flash-lite) and CalculateCost (cost.go:67-74) only discounts tokens reported in usage.CachedInputTokens. Since that field is structurally always zero for Gemini, every cached prompt token is billed at the full input rate in cost estimates — agent sessions (which re-send a stable system prompt and tool defs each turn, exactly what implicit caching hits) systematically overstate Gemini costs, and the catalog's cached rates are dead data on this path. - -``` -types.go:88-93 `type usageMetadata struct { PromptTokenCount int ...; CandidatesTokenCount int ...; ThoughtsTokenCount int ...; TotalTokenCount int ... }` (no cachedContentTokenCount) ; provider.go:254-258 `zeroruntime.TokenUsage{ InputTokens: state.inputTokens, OutputTokens: state.outputTokens, ReasoningTokens: state.reasoningTokens }` (CachedInputTokens never set) ; catalog.go:38 `ModelCost{InputPerMillion: 0.3, CachedInputPerMillion: 0.03, ...}` for gemini-2.5-flash. -``` - -**Suggested fix:** Add `CachedContentTokenCount int \`json:"cachedContentTokenCount"\`` to usageMetadata, track it in streamState alongside the other counters, and pass it as CachedInputTokens in emitDone's NormalizeUsage call (Gemini's promptTokenCount already includes cached tokens, matching the runtime's cached-is-a-subset model). - -#### [medium/wiring] OpenAI adapter drops fully-formed tool calls that lack an id instead of synthesizing one; zeroruntime's empty-ID collector support is unreachable -`internal/providers/openai/tool_state.go:95` - -toolState refuses to dispatch any tool call without BOTH id and name: applyDelta returns early (`if call.id == "" || call.name == "" || call.ended { return }`) and closeOpen emits StreamEventToolCallDropped for a call that has a complete name and complete JSON arguments but no id. The agent loop responds to DroppedToolCalls by appending a retry nudge (loop.go:347-352), so an OpenAI-compatible backend that legally omits tool_call ids loops forever on 'retry the tool call' — every retry is dropped again. This directly contradicts the runtime layer, which built and tested dedicated machinery for empty-ID tool calls (helpers.go:146-235 synthetic keys; order_test.go TestCollectStreamDoesNotMergeDistinctEmptyIDCalls, empty_toolcall_test.go TestCollectStreamEmptyIDDeltaBeforeStartIsAdopted): no adapter can ever emit an empty ToolCallID (Anthropic requires id, Gemini synthesizes `gemini_tool_%d`), so that collector machinery is unreachable dead wiring while the one adapter that encounters id-less calls discards them. - -``` -tool_state.go:95-100 `if call.id == "" || call.name == "" { if call.id != "" || call.name != "" || call.arguments != "" { call.ended = true; sendEvent(..., StreamEventToolCallDropped) } continue }` — a call with name+arguments but empty id is dropped; contrast gemini/provider.go:268-271 `if id == "" { id = fmt.Sprintf("gemini_tool_%d", syntheticIndex) }`. -``` - -**Suggested fix:** In closeOpen (and at start-emission time in applyDelta), synthesize an id when name is present but id is empty — e.g. `call.id = fmt.Sprintf("openai_tool_%d", index)` mirroring the Gemini adapter — so the call dispatches and the echoed tool_call_id round-trips; keep the drop only for genuinely nameless calls. - -#### [low/bug] claude-haiku-3.5 is flagged vision-capable, but claude-3-5-haiku-20241022 does not support image input -`internal/modelregistry/catalog.go:36` - -The claude-haiku-3.5 entry includes ModelCapabilityVision, but Anthropic's claude-3-5-haiku-20241022 is a text-only model (Anthropic's model table lists Claude 3.5 Haiku without vision; image input on it returns a 400 invalid_request_error). The vision gates (modelregistry.SupportsVision via cli/exec.go:262 and tui/image_attach.go) will therefore approve attaching images, and the Anthropic adapter will serialize them as image blocks (anthropic/provider.go:391-399), producing a hard API error for the whole request instead of the intended drop-and-warn behavior. Reachability is narrow — ResolveWithFallback redirects the deprecated model in the --model and /model flows — but providers.resolveProfile uses registry.Get with no deprecation redirect, so a config-profile pinned to claude-haiku-3.5 still runs the real model with the wrong capability flag. - -``` -catalog.go:36 `anthropicModel("claude-haiku-3.5", ..., []ModelCapability{ModelCapabilityVision, ModelCapabilityPromptCache}, nil, ...)` ; vision gate: vision.go:13-15 `return registry.SupportsCapability(modelID, ModelCapabilityVision)` ; no-fallback path: providers/factory.go:113 `if entry, ok := registry.Get(model); ok {` (Get, per resolve.go:9-10, "does NOT apply deprecation fallbacks"). -``` - -**Suggested fix:** Remove ModelCapabilityVision from the claude-haiku-3.5 entry so SupportsVision returns false and the existing drop-and-warn path handles images for that model. - -#### [low/dead-code] isImplicitOpenAI is provably unreachable at its only call site (dead condition in profile resolution) -`internal/providers/factory.go:118` - -In resolveProfile, the registry-hit branch does `if !explicitProvider || isImplicitOpenAI(profile, providerKind)`. explicitProviderKind returns ("", false) exactly when both profile.ProviderKind and profile.Provider are blank after trimming; isImplicitOpenAI requires those same two fields to be blank. So whenever explicitProvider is true (the only case where the second disjunct is evaluated), isImplicitOpenAI's `strings.TrimSpace(string(profile.ProviderKind)) == "" && strings.TrimSpace(profile.Provider) == ""` conjuncts are necessarily false — the function can never return true where it is called, and the condition reduces to `!explicitProvider`. The apparent intent (let a defaulted kind=openai profile be re-pointed at a registry model from another provider) never fires: an env-derived OpenAI profile (config/resolver.go:426 sets ProviderKind explicitly) combined with -m claude-sonnet-4.5 hits the 'belongs to anthropic, not openai' error at line 127 instead. - -``` -factory.go:118 `if !explicitProvider || isImplicitOpenAI(profile, providerKind) {` ; factory.go:147-157 explicitProviderKind returns false only when both ProviderKind and Provider trim to "" ; factory.go:159-164 `return providerKind == config.ProviderKindOpenAI && strings.TrimSpace(string(profile.ProviderKind)) == "" && strings.TrimSpace(profile.Provider) == "" && strings.TrimSpace(profile.BaseURL) == ""` — mutually exclusive with explicitProvider==true. -``` - -**Suggested fix:** Either delete isImplicitOpenAI and simplify the condition to `if !explicitProvider`, or implement the apparent intent by testing the default-profile shape directly (e.g. providerKind==openai && profile.BaseURL=="") without the always-false ProviderKind/Provider emptiness conjuncts. - -#### [low/dead-code] Descriptor.Public is declared but never written or read anywhere -`internal/providercatalog/catalog.go:46` - -The Descriptor struct exposes a `Public bool` field, but no constructor (openAI/anthropic/google/localOpenAI/openAICompat/anthropicCompat/transportDescriptor) sets it and a repo-wide grep finds no reader — unlike the neighboring RequiresAuth/Local/UsesAmbientAuth fields, which are consumed by zerocommands/contracts.go, cli/command_center.go, tui/picker.go, and providerhealth. Every descriptor therefore reports Public=false, and any future consumer keying off it would silently treat all providers as non-public. - -``` -catalog.go:46 `Public bool` — sole occurrence of `.Public`/`Public:` for this type across internal/ and cmd/ (grep over non-test Go files returns only this declaration). -``` - -**Suggested fix:** Delete the Public field, or wire it: set it in the constructor helpers for the intended descriptors and consume it where the catalog is surfaced (contracts/picker). - -#### [low/bug] HTTP 529 (Anthropic overloaded) is classified as a rate-limit error but excluded from the retry policy that retries 429/503 -`internal/providers/providerio/retry.go:107` - -ClassifiedError treats 529 as a rate-limit-class status (`case http.StatusTooManyRequests, http.StatusServiceUnavailable, 529: prefix = "rate limit error: "`), and Anthropic documents 529 overloaded_error as its transient backpressure signal — semantically identical to 503 ('the server explicitly did NOT accept the request', the package's own stated criterion for safe replay). Yet ShouldRetryStatus only matches 429 and 503, so Anthropic overload responses bypass SendWithRetry's backoff and surface immediately as a turn-ending 'rate limit error', while the equivalent condition on OpenAI (429) is retried up to 3 times. Anthropic users get strictly worse resilience for the same class of transient failure the codebase already labels retryable. - -``` -retry.go:107-109 `func ShouldRetryStatus(code int) bool { return code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable }` vs providerio.go:200-203 `case http.StatusTooManyRequests, http.StatusServiceUnavailable, 529: prefix = "rate limit error: "`. -``` - -**Suggested fix:** Add 529 to ShouldRetryStatus (`|| code == 529`) — it satisfies the same not-accepted/no-duplicate-work invariant as 503 — and update the policy comment. - - -### Specialist, spec mode, verify - -Audited internal/specialist, specmode, review, selfverify, verify, and testrunner plus their wiring (cli exec/app, tui spec_mode/model, background manager, sessions store, streamjson, PR-review workflow). All packages build, vet clean, and pass tests. Eight concrete defects found. Highest impact: (1) specialist child sessions launched without a Task `description` get a garbage/empty AgentName (derived from --session-title, which BuildArgs only emits when description is non-empty), permanently breaking Task resume for those sessions; (2) specialist children are always spawned with `--auto high` → PermissionModeUnsafe, so in the TUI's Ask mode a single approved Task prompt silently grants the worker specialist unprompted bash/write access — undisclosed in the permission card and asymmetric with the exec surface, which gates Task registration behind parent unsafety; (3) a check-then-append dedupe race lets the background onExit goroutine and TaskOutput polls double-record specialist stop/usage events (double-counted tokens), aggravated by app.go constructing the Executor with a nil SessionStore so each accounting call uses a fresh Store; (4) ParseStream's 1 MiB scanner cap fails the whole (successful) specialist run when the child emits one oversized stream-json line — reachable because tool_call events embed untruncated write_file arguments. Plus dead code (verify.RunLoop duplicated by selfverify.Run; specialist formatTaskOutput), an inconsistent SetPID-failure cleanup path that deletes the prompt file under a running child and skips terminal accounting, and byte-index title truncation that can split UTF-8 runes. Spec-mode draft/review lifecycle (submit_spec control meta → StopReasonSpecReviewRequired → TUI/CLI approve/reject), review-pipeline markdown/env parsing, and testrunner command construction/result parsing otherwise checked out: meta-control spoofing is closed (MCP meta is fixed-key), spec path containment is correct and symlink-safe, and the go/bun/node/pytest/cargo parsers match the commands testrunner constructs. - -#### [high/wiring] Specialist sessions launched without a description are unresumable (AgentName never recorded / recorded as garbage) -`internal/specialist/exec.go:166` - -Task resume depends on the child session's AgentName, which the child exec derives exclusively from --session-title via specialistAgentName(title) (internal/cli/exec.go:363, internal/cli/exec_sessions.go:103-112). BuildArgs only passes --session-title when the optional Task `description` argument is non-empty. When description is omitted (it is not in the tool schema's Required list — task_tool.go:55 requires only "prompt"), the child falls back to execSessionTitle(prompt), which is the first 80 bytes of the WRAPPED prompt: "# Specialist Invocation Specialist: worker ...". specialistAgentName then cuts at the first ':' and stores AgentName = "# Specialist Invocation Specialist". A later Task{resume: } run hits runResume → loadManifest("# Specialist Invocation Specialist") → "specialist ... not found" (or, for titles without ':', the "does not identify a specialist" error at exec.go:241-244). So every specialist spawned without a description produces a child session that can never be resumed, and `zero sessions` shows the wrapped-prompt garbage as the title. - -``` -internal/specialist/exec.go:166-168: `if description := strings.TrimSpace(input.Description); description != "" { args = append(args, "--session-title", strings.TrimSpace(input.Manifest.Metadata.Name)+": "+description) }` — no else branch. internal/cli/exec.go:363: `AgentName: specialistAgentName(options.sessionTitle)`. internal/cli/exec_sessions.go:96-100 falls back to createSessionTitle(prompt) when --session-title is absent, and the prompt is always WrapSystemPrompt output beginning "# Specialist Invocation\n\nSpecialist: ..." (envelope.go:10-11). internal/specialist/exec.go:241-247 then rejects resume: `specialistName := strings.TrimSpace(session.AgentName); if specialistName == "" { return ... "does not identify a specialist" }` / loadManifest(specialistName) fails for the garbage name. -``` - -**Suggested fix:** In BuildArgs, always emit --session-title: when description is empty, pass the manifest name alone (e.g. `args = append(args, "--session-title", name)`), so specialistAgentName resolves to the real specialist name. Alternatively add a dedicated --agent-name flag set from input.Manifest.Metadata.Name and stop deriving AgentName from the display title. - -#### [medium/security] Specialist children always run with --auto high (PermissionModeUnsafe): one Task approval silently grants unprompted shell/write access -`internal/specialist/exec.go:150` - -BuildArgs and BuildResumeArgs hard-code `--auto high` for every specialist child, regardless of the parent's permission mode. In the child exec, "high" resolves to agent.PermissionModeUnsafe (internal/cli/exec_tools.go:84-85), which sets permissionGranted=true for every tool (internal/agent/loop.go:439) — and headless exec wires no OnPermissionRequest, so no sandbox/permission prompt can ever fire in the child. The TUI registers the Task tool unconditionally (internal/cli/app.go:325/409), so in the default Ask mode a user who approves a single "Task" prompt (whose Safety.Reason only says "Spawns a Zero specialist sub-agent process.") has actually authorized the built-in `worker` specialist (tools: read-only, edit, execute — includes bash, write_file, apply_patch; builtin.go:9-13) to run arbitrary shell commands and file writes with zero per-action prompting, while the same bash call in the parent TUI session would prompt every time. The exec surface partially acknowledges this by only registering Task when the parent itself is high/unsafe (app.go:412-420), but the TUI has no such gate and no disclosure. - -``` -internal/specialist/exec.go:150: `args = append(args, "--auto", "high", "--output-format", "stream-json")` (same at line 195 for resume). internal/cli/exec_tools.go:84-85: `case "high": mode = agent.PermissionModeUnsafe`. internal/agent/loop.go:439: `permissionGranted := permissionMode == PermissionModeUnsafe`. internal/cli/app.go:404-409 registers specialist tools for the interactive TUI with no autonomy gate, vs shouldRegisterExecSpecialistTools (app.go:412-420) which requires `options.skipPermissionsUnsafe || autonomy == "high"` for exec. -``` - -**Suggested fix:** Derive the child's --auto level from the parent run's permission mode (e.g. pass "medium" unless the parent is already unsafe), or at minimum change TaskTool.Safety().Reason to disclose that the sub-agent executes its allowlisted tools (including bash for worker) without further prompts, so the TUI permission card tells the truth about what is being approved. - -#### [medium/race] Race: duplicate specialist stop/usage accounting events — check-then-append dedupe is not atomic and runs concurrently from onExit and TaskOutput -`internal/specialist/accounting.go:72` - -recordBackgroundTaskAccounting is invoked from two concurrent paths: the background process's onExit goroutine (internal/specialist/exec.go:325-339) and every TaskOutput poll of a finished task (internal/specialist/output_tool.go:148-151, which runs on the agent-loop goroutine). Both paths dedupe via specialistEventExists() — a full ReadEvents scan — followed by a separate AppendEvent. Nothing holds the session lock across the check and the append (sessions.Store.AppendEvent locks only for the append itself, store.go:464-478), so the two goroutines can both observe "no stop/usage event yet" and both append, double-counting the child's token usage in the parent session and duplicating specialist_stop events. The race is even wider in production because app.go:409 builds the Executor with SessionStore=nil, so each call constructs a fresh Store via accountingStore() (accounting.go:186-191) and even the per-Store in-process mutex offers no serialization — only the per-append flock. As a side note, readOutput re-runs this accounting (two full event-log reads) on every poll of a completed task. - -``` -internal/specialist/accounting.go:80-91: `if specialistEventExists(store, input.ParentSessionID, sessions.EventUsage, input.ChildSessionID, summary.RunID) { return false, nil } ... appendSpecialistSessionEvent(store, ...)` — same pattern in recordSpecialistStop (accounting.go:31-47). Concurrent callers: exec.go:330-338 (onExit closure: `executor.recordBackgroundTaskAccounting(task, summary)`) and output_tool.go:149-151 (`if task.Status != background.StatusRunning { Executor{SessionStore: tool.SessionStore}.recordBackgroundTaskAccounting(task, summary) }`). -``` - -**Suggested fix:** Add a Store-level atomic "append if not exists" operation that holds lockSession across the ReadEvents check and appendEventLocked, and route specialist accounting through it. Simpler alternative: make onExit the sole writer of stop/usage accounting and drop the recordBackgroundTaskAccounting call from OutputTool.readOutput (or guard it with a sync.Once keyed by task id on the Runtime). - -#### [medium/bug] Foreground specialist run fails entirely when any child stream-json line exceeds 1 MiB (untruncated tool_call args) -`internal/specialist/streamer.go:43` - -ParseStream uses a bufio.Scanner capped at 1 MiB per line, and runChildProcess treats any ParseStream error as a failure of the whole specialist run (exec.go:624-627 returns the error; runBuiltArgs then returns ExecResult{}, err and the Task tool reports an error). The child's stream-json writer truncates tool_result output to 10 KiB, but tool_call events embed the FULL parsed arguments with no truncation (internal/cli/exec_writer.go:92-100: `Args: parseToolCallArgs(call.Arguments)`), and the final event embeds the full final answer. A worker specialist that calls write_file with >1 MiB of content emits a single tool_call JSON line larger than the scanner limit, so the parent gets "bufio.Scanner: token too long" and reports the entire (actually successful) child run as failed, discarding its final answer. - -``` -internal/specialist/streamer.go:42-43: `scanner := bufio.NewScanner(reader); scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`. internal/specialist/exec.go:624-627: `events, err := ParseStream(...); if err != nil { return ChildRunResult{...}, err }`. internal/cli/exec_writer.go:98: tool_call event carries `Args: parseToolCallArgs(call.Arguments)` untruncated (contrast toolResult at line 158: `output, truncated := truncateForStreamJSONOutput(result.Output)`). -``` - -**Suggested fix:** Either raise/remove the scanner cap (use bufio.Reader.ReadString or json.Decoder over the stream) and skip-with-warning on individually unparsable lines instead of failing the whole run, or truncate tool_call Args in execEventWriter.toolCall the same way tool_result output is truncated. - -#### [low/dead-code] verify.RunLoop / LoopOptions / LoopReport are dead code — production retry loop is reimplemented in selfverify -`internal/verify/verify.go:215` - -verify.RunLoop (with LoopOptions.OnFailure, Attempt, LoopReport) is referenced nowhere in production code; the only caller is its own test (internal/verify/verify_test.go:196-207). The CLI's `zero verify --attempts N` path goes through selfverify.Run (internal/cli/workflows.go:113, internal/cli/app.go:124), which duplicates the same attempt loop with its own Attempt/Report types. Two parallel implementations of the retry loop invite divergence (they already differ: RunLoop has no StopReason and a differently-shaped failure callback). - -``` -grep over the repo: RunLoop/LoopOptions/LoopReport appear only in internal/verify/verify.go:83-111,215-244 and internal/verify/verify_test.go:196-207. Production wiring: internal/cli/app.go:124 `runSelfVerify: selfverify.Run` and internal/cli/workflows.go:113 `deps.runSelfVerify(context.Background(), plan, selfverify.Options{...})`. -``` - -**Suggested fix:** Delete RunLoop, LoopOptions, LoopReport, and Attempt from internal/verify (moving the one test to selfverify), or refactor selfverify.Run to delegate to verify.RunLoop so there is a single loop implementation. - -#### [low/dead-code] formatTaskOutput is dead code -`internal/specialist/output_tool.go:244` - -formatTaskOutput(task, data) has zero callers anywhere in the repo (including tests); the live path is summarizeTaskData + formatTaskOutputSummary called directly from readOutput. It silently drops the second return value of summarizeTaskData's StreamResult/exit handling duplication and will rot. - -``` -`grep -rn "formatTaskOutput\b" .` returns only the definition at internal/specialist/output_tool.go:244: `func formatTaskOutput(task background.Task, data string) string { summary, rawLines := summarizeTaskData(data, task.ExitCode); return formatTaskOutputSummary(task, summary, rawLines) }`. -``` - -**Suggested fix:** Delete the function. - -#### [low/bug] SetPID failure path deletes the prompt file out from under a running child and records no terminal accounting/status -`internal/specialist/exec.go:346` - -In runBackground, after the child process has been successfully launched, a SetPID persistence failure makes the tool return an error AND immediately calls cleanupBackgroundPromptFile, which deletes the temp prompt file (via Runtime.UntrackPromptFile → cleanupPromptFile) while the just-launched child may not yet have read it (`zero exec --file ` fails with "prompt file not found"). Unlike every other failure branch in this function, it also skips manager.UpdateStatus(StatusError) and recordSpecialistStop, so the task stays "running" in the background manager with the in-memory PID lost on restart, and the parent session has a specialist_start event with no matching stop. - -``` -internal/specialist/exec.go:346-350: `if pid > 0 { if err := manager.SetPID(built.SessionID, pid); err != nil { executor.cleanupBackgroundPromptFile(built.SessionID, built.PromptFile); return ExecResult{}, err } }` — contrast the launch-failure branch at lines 340-345 which calls UpdateStatus(StatusError) and recordSpecialistStop before returning. -``` - -**Suggested fix:** On SetPID failure, do not delete the prompt file (the onExit callback already cleans it up when the child exits); instead log/record the error, call recordSpecialistStop(...,"error",...), and either kill the orphaned child or leave the task running but report the degraded state in the tool output. - -#### [low/bug] Spec/session titles are truncated by byte index, splitting multi-byte UTF-8 runes -`internal/tui/spec_mode.go:301` - -specImplementationTitle truncates the model-supplied spec title with `title[:tuiSessionTitleLimit]` — a byte slice on a string that can contain multi-byte UTF-8 (spec titles are free text from submit_spec). A title longer than 80 bytes whose 80th byte falls inside a rune produces invalid UTF-8 in the stored session title, which is then persisted in session metadata and rendered in the TUI/sessions list. The same byte-slicing pattern exists in tuiSessionTitle (internal/tui/session.go:92-93) and createSessionTitle (internal/cli/exec_sessions.go:87-89), both fed by arbitrary user prompts. - -``` -internal/tui/spec_mode.go:300-304: `if len(title) > tuiSessionTitleLimit { title = title[:tuiSessionTitleLimit] } return title + " implementation"`. internal/tui/session.go:92-93 and internal/cli/exec_sessions.go:87-89 contain the identical `title[:80]` byte slice. -``` - -**Suggested fix:** Truncate on a rune boundary, e.g. `if utf8.RuneCountInString(title) > limit { title = string([]rune(title)[:limit]) }`, or walk back from the byte limit with utf8.RuneStart; apply the same helper in all three sites. - - -### Agent loop (internal/agent) - -Audited internal/agent (loop.go, types.go, compaction.go, compaction_preserve.go, context_measurement.go, guardrails.go, system_prompt*.go) plus the zeroruntime stream collector, providerio/Anthropic streaming, and the TUI/exec call sites they wire into. The run loop's core mechanics are solid: tool_use/tool_result pairing is preserved on every abort path (appendAbortedToolResults), compaction widens the preserved suffix to an assistant boundary so strict providers accept the result, ask_user cancellation correctly aborts with typed context errors, deferred-tool loading and the repeated-failure guard are coherent, and no goroutine leaks were found (providers select on ctx in SendEvent; channels are buffered and closed). Eleven concrete defects: (1) HIGH — the truncation/content-filter pipeline (FinishReason/Truncated) is produced by all three providers and collected by zeroruntime but never consumed by the loop or any surface, so max_tokens-clipped answers are silently returned as complete final answers and truncated tool-call JSON is dispatched; (2) MEDIUM — an 'always allow' permission decision is converted into a denial whenever grant persistence fails, and persistence always fails with a nil Sandbox; (3) MEDIUM — estimateTokens ignores image attachments (and the compaction trigger ignores tool-definition tokens), undercounting the context budget so proactive compaction fires late or never on image-heavy runs; (4) MEDIUM — compaction summarizer provider calls bypass OnUsage, making token/cost telemetry wrong; plus LOW findings: flattened context.Canceled identity on mid-stream cancel (the loop's own ctx.Err() check is unreachable for that path), dead OnContext/MeasureContext wiring with zero consumers, a duplicated items-schema mapping, dead requestEvent stores and an unreachable fallbackPermissionEvent, a UTF-8 rune-splitting byte truncation of project guidelines in the system prompt, retried text after mid-stream compaction never reaching streaming surfaces (transcript diverges from model context), and O(n^2) string accumulation in the stream collector hot path. - -#### [high/wiring] Truncated/filtered responses (FinishReason) are produced by every provider but never consumed by the agent loop -`internal/agent/loop.go:233` - -All three providers normalize abnormal stop reasons (OpenAI finish_reason=length/content_filter, Anthropic stop_reason=max_tokens, Gemini MAX_TOKENS/SAFETY) onto StreamEvent.FinishReason, and zeroruntime.CollectedStream carries it with a dedicated Truncated() helper. The agent loop never reads either: a response clipped at the output-token cap or withheld by a safety filter is appended to the transcript and returned as result.FinalAnswer exactly like a normal completion, with no warning to the user or nudge to the model. Worse, when a tool call's streamed JSON arguments are cut mid-stream by max_tokens, the collector still flushes the partial call, and the loop dispatches it, producing a misleading 'Failed to parse arguments' tool error and burning retry turns. grep confirms no consumer of FinishReason/Truncated() exists in internal/agent, internal/tui, internal/cli, or internal/sessions — the entire normalization pipeline is dead at the loop boundary. - -``` -loop.go:141 collects the stream (`collected := zeroruntime.CollectStreamWithOptions(...)`) and the only fields ever read are Error, Text, ToolCalls, DroppedToolCalls; line 233 then does `result.FinalAnswer = collected.Text` unconditionally. Meanwhile zeroruntime/helpers.go:24 defines `func (collected CollectedStream) Truncated() bool { return collected.FinishReason != "" }` and e.g. internal/providers/anthropic/provider.go:281 emits `StreamEventDone, FinishReason: state.finishReason`. `grep -rn "FinishReason|Truncated()" internal/agent internal/tui internal/cli internal/sessions` (non-test) returns nothing. -``` - -**Suggested fix:** After collection, check `collected.Truncated()`. Minimal: when FinishReason==length and the turn had no tool calls, append a user-role notice ('your previous reply was truncated at the token cap; continue') and continue the loop instead of returning it as FinalAnswer; when it must return, surface the truncation on Result (e.g. Result.FinishReason) so the TUI/exec writer can warn. Also skip dispatching tool calls from a length-truncated turn. - -#### [medium/ux] "Always allow" permission decision is converted into a denial when grant persistence fails (always, when Options.Sandbox is nil) -`internal/agent/loop.go:468` - -When the user answers a permission prompt with PermissionDecisionAlwaysAllow, the loop sets permissionGranted=true but then requires persistPermissionGrant to succeed; on any error it emits a deny event and returns a denied tool result — the tool the user just explicitly approved does not run. persistPermissionGrant unconditionally errors when options.Sandbox is nil ('sandbox engine is not configured'), so any embedder that wires OnPermissionRequest without a sandbox engine gets a guaranteed denial for every always-allow answer, and even in the TUI/exec (which do wire an engine) a transient grant-store write failure turns an explicit user approval into a refusal fed back to the model as 'Permission denied'. - -``` -loop.go:466-471: `case PermissionDecisionAlwaysAllow: permissionGranted = true; grant, err := persistPermissionGrant(call.Name, decisionReason, options); if err != nil { emitDeniedPermission(options, call, requestEvent, "failed to persist permission grant: "+err.Error()); return deniedPermissionResult(call, ...), nil }` and loop.go:721-723: `if options.Sandbox == nil { return sandbox.Grant{}, errors.New("sandbox engine is not configured") }`. -``` - -**Suggested fix:** On persistPermissionGrant failure, keep permissionGranted=true and run the tool for this call (the user approved it), recording the persistence failure as a non-fatal note (e.g. in the permission event's DecisionReason) instead of returning deniedPermissionResult. - -#### [medium/bug] estimateTokens ignores image attachments (and the compaction trigger ignores tool definitions), so the context budget undercounts -`internal/agent/compaction.go:58` - -estimateTokens counts only Content and tool-call name/argument characters. Message.Images — which the TUI seeds onto the first user turn and which cost on the order of 1k-1.6k tokens per image on real providers — contribute ~0 (just the 4-token per-message overhead). maybeCompact compares this estimate against 0.8×ContextWindow, so an image-heavy conversation crosses the real window well before the estimate crosses the threshold; proactive compaction never fires and the run instead hits the provider's context-limit error, relying on the one-shot reactive recover (which is consumed after a single use per run). The same trigger also excludes advertised tool-definition tokens (MeasureContext counts them separately via estimateToolTokens, but maybeCompact only calls estimateTokens(messages)), further delaying the trigger on MCP-heavy registries. - -``` -compaction.go:58-70: `for _, message := range messages { total += len(message.Content) / 4; for _, call := range message.ToolCalls { ... } total += 4 }` — no reference to message.Images; compaction.go:234: `size := estimateTokens(messages)` is the entire trigger input, while context_measurement.go:53 has a separate estimateToolTokens that the trigger never uses. -``` - -**Suggested fix:** Add a per-image constant to estimateTokens (e.g. `total += 1500 * len(message.Images)` or a bytes-derived heuristic), and include the advertised-tools estimate in the maybeCompact threshold comparison (pass the partitioned definitions' estimateToolTokens into the trigger). - -#### [medium/wiring] Compaction summarizer provider calls bypass OnUsage — their token spend is invisible to usage accounting -`internal/agent/compaction.go:376` - -summarizeMessagesOnce performs a real provider call whose input is the entire elided middle of the conversation (potentially tens of thousands of tokens, and summarizeWithFallback can recurse into multiple such calls), but it collects via CollectStream with no OnUsage callback. The TUI records usage rows and session EventUsage entries exclusively from Options.OnUsage, and exec's writer does the same, so every compaction's token consumption is silently dropped from telemetry and cost reporting. This contradicts the loop's own stated invariant on the reactive retry path ('OnUsage IS kept so token telemetry/budgeting still counts the successful retry', loop.go:180-181): the retry is counted but the compaction that enabled it is not. - -``` -compaction.go:376: `collected := zeroruntime.CollectStream(ctx, stream)` inside summarizeMessagesOnce, and the deliberate comment at compaction.go:314-315: 'The summary stream intentionally does NOT forward OnText / OnUsage callbacks, so compaction stays invisible on the user-facing surface.' OnText suppression is correct for display, but OnUsage suppression makes billing/telemetry wrong. -``` - -**Suggested fix:** Thread Options.OnUsage into summarizeClosure/summarizeMessagesOnce (use CollectStreamWithOptions(ctx, stream, CollectOptions{OnUsage: onUsage})) while continuing to omit OnText, so summarizer token counts land in the same usage stream as every other provider call. - -#### [low/bug] Mid-stream context cancellation is returned as a flattened errors.New string; the ctx.Err() identity check is unreachable for that path -`internal/agent/loop.go:188` - -When the run context is canceled while a response is streaming, CollectStreamWithOptions sets collected.Error to ctx.Err().Error() ('context canceled'). The loop returns errors.New(collected.Error) at line 188-191 BEFORE the `if ctx.Err() != nil` check at line 192, so the returned error loses its identity: errors.Is(err, context.Canceled) is false. Today's callers compensate (exec.go:500 and exec_spec.go:164 add `|| runCtx.Err() != nil`; the TUI discards the message via runID mismatch), but the loop's own cancellation contract is inconsistent — the ask_user abort path carefully preserves context.Canceled identity (loop.go:602) while the much more common mid-stream cancel does not, and any future caller relying on errors.Is will misclassify a user cancel as a provider error. - -``` -zeroruntime/helpers.go:84-86: `case <-ctx.Done(): collected.Error = ctx.Err().Error()`; loop.go:188-195: `if collected.Error != "" { result.Messages = copyMessages(messages); return result, errors.New(collected.Error) }` followed only afterwards by `if ctx.Err() != nil { ... return result, ctx.Err() }`. -``` - -**Suggested fix:** Check `ctx.Err()` before the collected.Error return (return result, ctx.Err() when non-nil), or have CollectStreamWithOptions carry the typed error so the loop can return the original context error instead of a re-stringified copy. - -#### [low/dead-code] OnContext / MeasureContext context-utilization pipeline has no consumer on any surface -`internal/agent/types.go:183` - -Options.OnContext is documented as the hook 'so a surface (TUI/CLI) can show context utilization', and the loop dutifully computes MeasureContext(messages, request.Tools, options.ContextWindow) once per turn when it is set. No caller in the repository ever sets OnContext — not the TUI (model.go's runAgentWithOptions), not exec.go, not exec_spec.go — so the callback never fires, ContextBreakdown is never displayed anywhere, and the per-turn measurement code is effectively dead. The only external user of this package's measurement surface is contextreport, which calls BuildSystemPromptPreview and re-implements its own token estimation rather than using MeasureContext. - -``` -types.go:183 `OnContext func(ContextBreakdown)` and loop.go:101-103 `if options.OnContext != nil { options.OnContext(MeasureContext(...)) }`; `grep -rn "OnContext" --include=*.go .` (non-test) matches only these two sites — no surface assigns it. -``` - -**Suggested fix:** Wire it: have the TUI set OnContext and render UsedFraction in the status line (the data is already computed), or delete the field, MeasureContext's per-turn call, and ContextBreakdown until a consumer exists. - -#### [low/dead-code] Duplicate items-schema assignment in propertyToRuntimeMap -`internal/agent/loop.go:1072` - -propertyToRuntimeMap maps property.Items into schema["items"] twice — at lines 1063-1064 and again identically at lines 1072-1074. The second block recomputes propertyToRuntimeMap(*property.Items) (a recursive walk) and overwrites the same key with an equal value, so it is pure waste executed for every array-typed property of every advertised tool on every turn. - -``` -loop.go:1063-1064 `if property.Items != nil { schema["items"] = propertyToRuntimeMap(*property.Items) }` followed at loop.go:1072-1074 by the byte-identical `if property.Items != nil { schema["items"] = propertyToRuntimeMap(*property.Items) }`. -``` - -**Suggested fix:** Delete the second `if property.Items != nil { ... }` block (lines 1072-1074). - -#### [low/dead-code] Dead stores to requestEvent after always-allow, and unreachable fallbackPermissionEvent -`internal/agent/loop.go:473` - -In the PermissionDecisionAlwaysAllow success branch the loop sets requestEvent.GrantMatched = true and requestEvent.Grant = &grant, but requestEvent is never read after the switch — the post-run OnPermission event is rebuilt from scratch via buildPermissionEvent(call, tool, args, ..., sandboxDecision) at line 501, so the grant annotation never reaches any consumer. Separately, fallbackPermissionEvent (line 861) is unreachable in practice: it only runs when buildPermissionEvent returns ok=false at line 452-455, which requires decision==nil AND safety.Permission==PermissionAllow, but the guarding shouldRequestPermission (line 694-702) already requires Permission==PermissionPrompt, making the !ok branch impossible at that call site. - -``` -loop.go:473-474 `requestEvent.GrantMatched = true; requestEvent.Grant = &grant` with no subsequent read of requestEvent; loop.go:451 gate `shouldRequestPermission(tool, permissionGranted, preflightDecision)` requires `tool.Safety().Permission == tools.PermissionPrompt` (line 695), while buildPermissionEvent's only ok=false return for a nil decision is the `default:` branch (line 830-831) reached when Permission is neither Deny nor Prompt. -``` - -**Suggested fix:** Either propagate the grant into the post-run event (e.g. attach decision/grant to the rebuilt event) or remove the two dead assignments; remove fallbackPermissionEvent and the !ok branch, or replace with a direct construction if defensive coverage is wanted. - -#### [low/bug] Project-guidelines truncation can split a multibyte UTF-8 rune in the system prompt -`internal/agent/system_prompt.go:89` - -workspaceContext caps an oversized AGENTS.md/ZERO.md at maxProjectContextBytes with a raw byte slice: content[:maxProjectContextBytes]. If the 8 KiB boundary lands inside a multibyte rune (non-ASCII guidelines: CJK text, emoji, box-drawing characters), the system prompt ends with an invalid UTF-8 sequence that json.Marshal will replace with U+FFFD on the provider wire. The sibling code in compaction_preserve.go (capBody, line 156-169) does this correctly by walking back to utf8.RuneStart — this path simply doesn't. - -``` -system_prompt.go:88-90: `if len(content) > maxProjectContextBytes { content = content[:maxProjectContextBytes] + "\n… (truncated)" }` versus capBody's `for limit > 0 && !utf8.RuneStart(body[limit]) { limit-- }` in compaction_preserve.go:165-166. -``` - -**Suggested fix:** Walk the cut point back to a rune boundary before slicing, mirroring capBody: `limit := maxProjectContextBytes; for limit > 0 && !utf8.RuneStart(content[limit]) { limit-- }; content = content[:limit] + "\n… (truncated)"` (same fix applies to internal/contextreport/contextreport.go:236). - -#### [low/ux] Reactive mid-stream retry never forwards the retried assistant text to OnText, so streaming surfaces show the aborted partial text instead -`internal/agent/loop.go:183` - -When a context-limit error surfaces mid-stream, the loop compacts and retries the turn, deliberately collecting the retry without OnText to avoid duplicating the already-streamed prefix. But for an intermediate (tool-calling) turn, the TUI renders assistant text only from OnText deltas (agentTextMsg) — result.FinalAnswer is rendered only for the run's final turn. So after a mid-stream retry, the user's transcript permanently shows the first attempt's truncated partial text while the model's actual context (messages, line 197-201) contains the different, complete retried text; the two can disagree arbitrarily. The code comments acknowledge the duplicate-avoidance choice but the result is a transcript that doesn't match what the model said. - -``` -loop.go:177-185: 'Omit OnText on the reactive retry: when the original error surfaced MID-stream, partial text was already forwarded ... The retried text is captured in collected.Text and becomes the turn's assistant message.' — `collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{OnUsage: options.OnUsage})` with no OnText; the TUI streams rows solely from OnText for non-final turns (model.go:1136-1141, 1398-1403). -``` - -**Suggested fix:** Track how many bytes of text were already emitted before the failure and, on the retry, forward collected text through OnText after suppressing/communicating the replacement (e.g. emit a marker delta like '\n[retrying after compaction]\n' followed by the retried text), so surfaces display the text the model actually produced. - -#### [low/perf] Streamed text accumulation is O(n^2): collected.Text += delta copies the whole buffer on every text event -`internal/zeroruntime/helpers.go:104` - -CollectStreamWithOptions accumulates assistant text with string concatenation per delta. Providers emit deltas of a few bytes to a few dozen bytes, so a long response of N bytes arriving in k deltas copies O(N*k) bytes total (quadratic in response length for fixed delta size). For a 64 KB response in ~4k deltas that is ~130 MB of memcpy and garbage per turn, on the hot path of every agent turn and every compaction summarization. Tool-call argument accumulation has the same pattern (`collector.calls[key].Arguments += fragment`, line 202). - -``` -helpers.go:104: `case StreamEventText: collected.Text += event.Content` inside the per-event loop, and helpers.go:202: `collector.calls[key].Arguments += fragment`. -``` - -**Suggested fix:** Accumulate into a strings.Builder (one per stream for text, one per in-flight call for arguments) and materialize the string once at flush/return. - - -### CLI subcommands - -Audited the zero CLI subcommand surfaces in internal/cli (changes, usage, doctor, cron/cron run, repo-info, sessions/rewind/resume, serve, config/models/providers command center, specialist, spec, search, update) plus their backing packages (internal/cron, internal/sessions, internal/search, internal/zerogit, internal/update, internal/doctor, internal/repoinfo, internal/redaction). Found 11 concrete defects, 5 of them verified by running targeted tests or the built binary: (1) CRITICAL — `zero search` computes match byte offsets on strings.ToLower(text) but slices the original text, producing invalid-UTF-8/misaligned context and a reproducible slice-bounds panic (verified: \"slice bounds out of range [221:206]\"); (2) HIGH — `zero cron add \"\" --recipe X` silently discards the explicit cron expression in favor of the recipe's (verified: stored */30 instead of the requested 0 6 * * *), so jobs fire on the wrong schedule with exit 0; (3) HIGH — `zero usage` aborts entirely (exit 2) outside a git repository because the supplemental net-LOC git inspection error is fatal (verified live); (4) MEDIUM — `zero update --check` can never succeed on a `go build` binary (\"invalid semantic version: dev\", verified live); (5) MEDIUM — `zero cron run` startup reconcile silently cancels --run-now jobs instead of firing them (verified by test); (6) MEDIUM perf — `zero sessions tree` re-reads every session's metadata.json per tree node (O(nodes x sessions) disk I/O). Plus five low findings: cron run records drop failure reasons (stream-json errors go to discarded stdout), mid-rune byte truncation in cron list/run records/session titles, exec bypassing the injected deps.newSessionStore (half-wired dependency), `zero search --session-id ` silently succeeding with 0 results, and `zero doctor` printing apiKey: [REDACTED] whether or not a key is set (verified live). No findings fabricated from style preferences; each includes the exact code evidence and a minimal fix. - -#### [critical/bug] zero search panics (slice out of range) and emits misaligned/invalid-UTF-8 context due to ToLower byte-offset mismatch -`internal/search/search.go:346` - -findMatch computes byte offsets on `strings.ToLower(text)` but buildContext (and the Match{Start,End} reported in --json output) applies those offsets to the ORIGINAL text. Unicode simple case mapping changes byte length (U+0130 'İ' 2B → 'i' 1B shrinks; U+023A 'Ⱥ' 2B → U+2C65 'ⱥ' 3B grows), so offsets diverge. Verified two failure modes with tests: (1) shrink direction — context window is misaligned and starts mid-rune, producing invalid UTF-8 ("\xb0İİİ…") that does not even contain the match; (2) grow direction — `buildContext` panics `slice bounds out of range [221:206]` for text containing ~100 'Ⱥ' before the match, crashing `zero search` (caught only by the top-level observability.Recover, which writes a crash report and exits 1). Session event text is arbitrary user/model/tool content, so non-ASCII triggering text is realistic. - -``` -func findMatch(text string, query string, terms []string) (Match, bool) { - normalizedText := strings.ToLower(text) - if index := strings.Index(normalizedText, query); index >= 0 { - return Match{Start: index, End: index + len(query)}, true -... -func buildContext(text string, start int, end int, contextChars int) string { - left := start - contextChars ... return strings.TrimSpace(text[left:right]) - -Test output: "BUG CONFIRMED: buildContext panicked: runtime error: slice bounds out of range [221:206]" for strings.Repeat("Ⱥ",100)+" hello" -``` - -**Suggested fix:** Make offsets refer to one string: search over the lowered text and also slice the lowered text for context, or (better) clamp `left`/`right` into [0,len(text)] with `left = min(left,right)` AND compute match offsets on the original string (e.g. use a case-folding search such as scanning with strings.EqualFold over windows, or build the index entries lowercased once at index time so offsets are self-consistent). - -#### [high/bug] cron add silently ignores explicit cron expression when --recipe is also given -`internal/cli/cron.go:112` - -In cronAdd, the recipe block runs BEFORE the positional expression is consumed. With both a positional and --recipe (a combination the help text explicitly documents: `zero cron add [--prompt P | --recipe R]`), the recipe's expression is assigned first (`if expr == "" { expr = r.Expr }`), so the later `if len(positional) == 1 && expr == ""` guard is false and the user's explicit schedule is silently dropped. Verified with a test: `zero cron add "0 6 * * *" --recipe git-recap` stores Expr="*/30 * * * *" and exits 0 — the job fires every 30 minutes instead of daily at 06:00, with no warning. - -``` -if recipe != "" { ... if expr == "" { expr = r.Expr } ... } -if len(positional) == 1 && expr == "" { expr = positional[0] } // never runs when a recipe set expr - -Test output: add exit=0; stored job expr="*/30 * * * *" for args {"add", "0 6 * * *", "--recipe", "git-recap"} -``` - -**Suggested fix:** Assign the positional expression before applying recipe defaults: move `if len(positional) == 1 && expr == "" { expr = positional[0] }` (and the extra-args check) above the `if recipe != ""` block, so the recipe only fills expr when the user did not supply one. - -#### [high/bug] zero usage hard-fails outside a git repository (entire token report aborted by the net-LOC helper) -`internal/cli/usage.go:121` - -runUsage unconditionally calls deps.inspectChanges (zerogit.Inspect) to compute the supplemental net-LOC estimate, and returns a usage error if it fails. zerogit.Inspect returns `not a git repository: ...` whenever the cwd is not inside a git work tree, so the command's PRIMARY function — summarizing token usage and estimated cost from persisted sessions — is completely unavailable outside a git repo. Verified live: running `zero usage` in a non-git directory prints `[zero] not a git repository: fatal: not a git repository...` and exits 2 (a usage-error code for an environmental condition). The help text frames net LOC as a supplemental "working-tree diff proxy" estimate, not a prerequisite. - -``` -summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{Cwd: workspaceRoot}) -if err != nil { - return writeExecUsageError(stderr, err.Error()) -} - -Live run in /tmp (non-git): "[zero] not a git repository: fatal: not a git repository (or any of the parent directories): .git" exit=2 -``` - -**Suggested fix:** Degrade gracefully: on inspectChanges error, proceed with diff = zerogit.DiffStat{} (net LOC 0 / "n/a") and optionally print a one-line notice to stderr, instead of aborting the report; reserve exit 2 for real argument errors. - -#### [medium/bug] zero update --check always fails on source/dev builds: "invalid semantic version: dev" -`internal/update/update.go:135` - -update.Check normalizes Options.CurrentVersion through normalizeVersionTag and returns an error when it is not semver. The CLI passes the package-level `version` variable (internal/cli/app.go:33 `var version = "dev"`), which is only overridden by -ldflags in release builds (internal/release/release.go:258). For anyone who built with `go build`/`go install` (the normal contributor path), `zero update --check` can never succeed: verified live, it prints `[zero] Could not check for updates: invalid semantic version: dev` and exits 1. The fallback `firstNonEmpty(options.CurrentVersion, "0.0.0")` only catches the empty string, not the actual default "dev". - -``` -currentVersion, err := normalizeVersionTag(strings.TrimSpace(firstNonEmpty(options.CurrentVersion, "0.0.0"))) -if err != nil { - return Result{}, err -} - -Live run of a `go build` binary: "zero dev" / "[zero] Could not check for updates: invalid semantic version: dev" -``` - -**Suggested fix:** Treat a non-semver current version as "0.0.0" instead of failing: e.g. `cv, err := normalizeVersionTag(...); if err != nil { cv = "0.0.0" }` (optionally note "current version unknown (dev build)" in Format output) so the latest-release lookup still works. - -#### [medium/bug] cron run start-up reconcile silently cancels --run-now jobs -`internal/cli/cron_run.go:115` - -`zero cron add ... --run-now` persists NextRunAt = now and prints "next run " to the user. But when the foreground scheduler is started later with plain `zero cron run` (no --catch-up), reconcileOverdue classifies any NextRunAt before the current minute as "strictly overdue" and reschedules it to the next cron slot WITHOUT firing. So a --run-now job added more than a minute before the scheduler starts never gets its promised immediate run. Verified with a test: job added 08:00 with --run-now, reconcile at 08:05 moved NextRunAt to 09:00 with FireCount=0 and no output. The skip-backlog behavior is reasonable for ordinary overdue schedules, but it contradicts the explicit --run-now request and the message `cron add` printed. - -``` -// cronAdd: if runNow { next = now() } ... fmt.Fprintf(stdout, "Added cron job %s (%s); next run %s.\n", ...) -// cronRun (forever mode): if !catchUp { reconcileOverdue(store, now, ids, stderr) } -// reconcileOverdue: if !j.NextRunAt.Before(nowMin) { continue } ... j.NextRunAt = nxt (no fire) - -Test output: after add: next=2026-06-09 08:00:00; after reconcile(08:05): next=2026-06-09 09:00:00 fired=0 -``` - -**Suggested fix:** Persist a flag (e.g. Job.RunOnce or a sentinel) for --run-now jobs and have reconcileOverdue skip them (let fireDue fire them once), or bound the reconcile to jobs whose NextRunAt matches their schedule (a NextRunAt that is not a valid slot of the expression is a pending run-now request and should fire). - -#### [medium/perf] zero sessions tree is O(nodes x sessions) disk reads (full store re-list per tree node) -`internal/sessions/lineage.go:140` - -Store.tree recurses over the child tree and calls ListChildren for every node; ListChildren calls store.List(), which os.ReadDir()s the entire session root and ReadFile+json.Unmarshal's EVERY session's metadata.json, then filters by ParentSessionID. For a store with S sessions and a tree of T nodes, `zero sessions tree` performs S*T metadata file reads and JSON parses (plus a Get per node). Session stores grow unbounded over time (every exec stream-json run creates one), so this hot path degrades quadratically; with a few thousand accumulated sessions a single tree command does millions of file reads. - -``` -func (store *Store) tree(sessionID string, seen map[string]bool) (TreeNode, error) { - ... - children, err := store.ListChildren(sessionID) // ListChildren -> store.List() -> read EVERY metadata.json - ... - for _, child := range children { - childNode, err := store.tree(child.SessionID, seen) // recurses, re-listing the whole store each time -``` - -**Suggested fix:** Load the store once: call store.List() a single time in Tree, build a map[parentID][]Metadata index, and recurse over that in-memory index instead of calling ListChildren (and Get) per node. - -#### [low/bug] cron run records lose the failure reason: exec errors go to the discarded stdout stream -`internal/cli/cron_run.go:150` - -fireJob forces the child run to `--output-format stream-json`. In that mode runExec emits error events to STDOUT (writeStreamJSONError / writer.errorEvent write stream-json lines to stdout) and prints nothing to stderr. fireJob captures both streams but records `rec.Error` only from errBuf and discards outBuf entirely, so for the common failure class (provider errors, usage errors surfaced as stream-json) the persisted RunRecord has a non-zero ExitCode but an empty Error, and the run's output is lost. `zero cron` therefore cannot tell the operator why a scheduled job failed. - -``` -var outBuf, errBuf strings.Builder -code := exec(args, &outBuf, &errBuf) -rec := cron.RunRecord{...} -if code != 0 { - rec.Error = cronTruncate(strings.TrimSpace(errBuf.String()), 500) -} -// outBuf is never read again; runExec stream-json errors are written to stdout (writeStreamJSONError(stdout, ...)) -``` - -**Suggested fix:** On non-zero exit, fall back to extracting the last stream-json error event (or the tail of outBuf) when errBuf is empty, e.g. rec.Error = firstNonEmpty(trim(errBuf), lastErrorEventMessage(outBuf), tail(outBuf)). - -#### [low/bug] promptExcerpt/cronTruncate byte-slice strings mid-rune, emitting invalid UTF-8 -`internal/cli/cron.go:275` - -promptExcerpt truncates the prompt for `zero cron list` with a raw byte slice `p[:47]`, and cronTruncate (cron_run.go:183) does the same with `s[:500]` for stored run errors. For multibyte prompts (any non-ASCII text — accented words, CJK, emoji) the cut can land mid-rune, printing a replacement-garbage byte sequence in the list output and persisting invalid UTF-8 into runs.jsonl. Same defect class exists in createSessionTitle (internal/cli/exec_sessions.go:88, `title[:80]`), which stores a potentially rune-split session title in metadata.json. - -``` -func promptExcerpt(p string) string { - p = strings.TrimSpace(strings.ReplaceAll(p, "\n", " ")) - if len(p) > 48 { - return p[:47] + "…" - } -... -func cronTruncate(s string, max int) string { if len(s) <= max { return s } return s[:max] + "…" } -``` - -**Suggested fix:** Truncate on rune boundaries: convert to []rune (or walk with utf8.DecodeRuneInString) before slicing, e.g. `r := []rune(p); if len(r) > 48 { return string(r[:47]) + "…" }`; apply the same to cronTruncate and createSessionTitle. - -#### [low/wiring] exec resume/fork bypasses the injected session store (deps.newSessionStore never used by exec) -`internal/cli/exec_sessions.go:55` - -appDeps.newSessionStore is the designated injection point for the session store and is honored by sessions, search, usage, spec and the TUI (tui.Options.SessionStore). But the exec path ignores it twice: preflightExecSession constructs `sessions.NewStore(sessions.StoreOptions{})` directly, and runExec calls sessions.PrepareExec without setting PrepareExecOptions.Store (exec.go:353), so PrepareExec also falls back to `NewStore(StoreOptions{})`. Any test or future configuration that swaps newSessionStore (custom root, fakes) gets inconsistent behavior: `zero sessions list` reads one store while `zero exec --resume` validates and writes against another. Today both default to the same XDG path, so this is latent, but it makes the dep a half-wired option. - -``` -// preflightExecSession (exec_sessions.go:55) -store := sessions.NewStore(sessions.StoreOptions{}) -// runExec (exec.go:353) — no Store field: -preparedSession, err = sessions.PrepareExec(sessions.PrepareExecOptions{ SessionID: options.initSessionID, Title: sessionTitle, ... }) -// vs every other command: store := deps.newSessionStore() -``` - -**Suggested fix:** Pass the injected store through: in runExec use `store := deps.newSessionStore()`, hand it to preflightExecSession (change its signature to accept *sessions.Store) and set `Store: store` in PrepareExecOptions. - -#### [low/ux] search --session-id with a nonexistent session silently succeeds with 0 results -`internal/search/search.go:309` - -resolveSessions returns `([]sessions.Metadata{}, err)` when store.Get errors OR returns nil; for a well-formed but nonexistent session id, Get returns (nil, nil), so the search proceeds against zero sessions and `zero search --session-id zero_does_not_exist foo` prints `No local session events matched "foo". Searched 0 sessions.` and exits 0. The user's filter target not existing is an error condition (a typo'd id looks identical to "no matches"), and every other session-id-taking command (sessions children/lineage/tree, exec --resume) reports "Zero session not found". - -``` -func resolveSessions(store *sessions.Store, sessionID string) ([]sessions.Metadata, error) { - if sessionID == "" { - return store.List() - } - session, err := store.Get(sessionID) - if err != nil || session == nil { - return []sessions.Metadata{}, err // session==nil, err==nil -> empty result, success - } -``` - -**Suggested fix:** Return an explicit error when the session is missing: `if session == nil { return nil, fmt.Errorf("zero session not found: %s", sessionID) }` so the CLI surfaces it instead of reporting an empty successful search. - -#### [low/ux] doctor always prints apiKey: [REDACTED] whether or not a key is configured -`internal/doctor/doctor.go:134` - -providerConfigCheck puts the raw key value under the details key "apiKey"; the check() redaction pass replaces the VALUE with "[REDACTED]" purely because the key NAME is sensitive (redaction.redactReflect: `if IsSensitiveKey(key) { out[key] = replacement }`), regardless of whether the value is empty. Verified live: `zero doctor` prints `apiKey: [REDACTED]` identically with OPENAI_API_KEY set and unset. For a health-check command whose whole purpose is diagnosing provider setup, this is actively misleading — an operator with a missing key sees output implying one is configured. The command-center surface already solved this with a boolean ("api key: set/not set"). - -``` -return check("provider.config", ..., map[string]any{ - "name": profile.Name, "provider": profile.ProviderKind, "baseURL": profile.BaseURL, "model": profile.Model, - "apiKey": profile.APIKey, -}) - -Live output with OPENAI_API_KEY="" AND with a key set, both: "apiKey: [REDACTED]" -``` - -**Suggested fix:** Report presence, not the value: replace the detail with `"apiKeySet": strings.TrimSpace(profile.APIKey) != ""` (key name not in the sensitive list, boolean value), matching providers' apiKeyState() output. - - -### Config, hooks, plugins, skills - -Audited internal/config, internal/hooks, internal/plugins, internal/skills, and internal/zerocommands by reading every source file in full and tracing each public API to its production callers.\n\nMost significant findings:\n- Wiring (high): The entire hooks execution surface (Select, AuditStore append, ConfigStore write methods) has no production caller — hooks are loadable and shown as 'enabled' by `zero hooks list` but never fire on any event, and there is no CLI command to create/edit them.\n- Security (high): `zero mcp list --json` marshals raw config.MCPServerConfig (Env/Headers) through key-name-based redaction, leaking opaque secret values under non-standard keys; the purpose-built zerocommands.MCPServerSnapshot (which strips env/headers to counts) is never used.\n- Wiring (medium): ResolvedConfig.MCP is computed but never read, and MCP servers emitted by a provider command are merged by Resolve() yet dropped by ResolveMCP() (the path the runtime actually registers from).\n- Perf (medium): hooks AuditStore.append re-reads and re-parses the full audit JSONL on every write (O(n^2)).\n\nPlus dead-code/validation/durability items: the bulk of the zerocommands snapshot library (sandbox + hook/plugin/MCP snapshots) is unused, config/contracts.go is unused, skills.Get/Duplicates are unused (duplicate-name collisions never warned), plugin vs standalone hook-event validators disagree, maxTurns<=0 is silently ignored, and the config writer overwrites in place (non-atomic) unlike the hooks writer.\n\nKey files: /Users/kratos/Downloads/zero-main 2/internal/hooks/hooks.go, /Users/kratos/Downloads/zero-main 2/internal/config/resolver.go, /Users/kratos/Downloads/zero-main 2/internal/cli/extensions.go, /Users/kratos/Downloads/zero-main 2/internal/zerocommands/backend_snapshots.go, /Users/kratos/Downloads/zero-main 2/internal/config/writer.go. - -#### [high/wiring] Hooks subsystem is loaded and listed but never executed (no event dispatch, no edit command) -`internal/hooks/hooks.go:372` - -The hooks package implements a full execution/audit/store-write surface — Select() (event/matcher dispatch), AuditStore.AppendStarted/AppendCompleted, and ConfigStore.Upsert/Remove/SetEnabled — but none of these are ever called from production code. The only non-test consumer of the package is hooks.LoadConfig (wired into `zero hooks list` and the backend snapshots). There is no agent-loop hook runner, and `runHooks` only registers a `list` subcommand. As a result a user can create ~/.config/zero/hooks.json or .zero/hooks.json, see them reported as "enabled" by `zero hooks list`, yet the configured beforeTool/afterTool/sessionStart/etc. commands never fire on any event, and there is no CLI path to add/enable/disable a hook (ConfigStore is unreachable). The feature appears functional but is inert. - -``` -`func Select(config Config, input SelectInput) []Definition` (hooks.go:372) plus NewAuditStore/NewConfigStore have zero non-test callers (verified by grep across the repo). The CLI wires only `loadHooks: hooks.LoadConfig` (internal/cli/app.go:107) and `runHooks` exposes only `case "list":` (internal/cli/extensions.go:94). No code in internal/agent, internal/tools, or internal/cli ever calls hooks.Select or AuditStore.Append*. -``` - -**Suggested fix:** Wire hooks.Select into the agent tool-execution loop (beforeTool/afterTool) and session lifecycle, executing selected commands with a bounded timeout and recording AuditStore events; add `zero hooks add/enable/disable/remove` subcommands that call ConfigStore. If execution is intentionally deferred, mark the package and `zero hooks list` output as 'discovery only / not yet executed' (as plugins.go already documents) so the enabled/disabled state is not misleading. - -#### [high/security] `zero mcp list --json` leaks MCP server env/header secret values instead of using the redacting MCPServerSnapshot -`internal/cli/extensions.go:186` - -The headless `zero mcp list --json` command serializes the raw config.MCPServerConfig map (including Env and Headers, which commonly carry auth tokens for the spawned server) through the generic reflect-based redaction.RedactValue. That redactor only masks a map value when its KEY normalizes to one of a fixed allow-list (api_key, token, auth_token, ...) or when the value matches a known token FORMAT regex (sk-, github_pat_, AIza, JWT, AWS). An opaque secret under a non-standard key — e.g. {"env":{"NOTION_TOKEN":"secret_abc123xyz"}} or {"headers":{"X-Custom-Auth":"abc123def456"}} — is neither key-matched (normalizes to notion_token / x_custom_auth, not in the set) nor format-matched, so it is emitted in clear. The zerocommands.MCPServerSnapshot type was purpose-built to prevent exactly this (its doc states env/headers are summarized as redacted key COUNTS so 'a token in MCP_AUTH_TOKEN never reaches the headless JSON output'), but the CLI never uses it. The doc explicitly targets PR/CI automation, where this output may be shared. - -``` -internal/cli/extensions.go:186 `Servers map[string]config.MCPServerConfig` then :188 `writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{}))`. config.MCPServerConfig has `Env map[string]string` / `Headers map[string]string` (internal/config/types.go:144-146). redaction sensitiveKeys is a fixed exact-match set (internal/redaction/redaction.go:33) and IsSensitiveKey does exact normalized lookup only (redaction.go:91). The unused safe alternative MCPServerSnapshot records only EnvKeyCount/HeaderCount (internal/zerocommands/backend_snapshots.go:30-31). -``` - -**Suggested fix:** Build the `zero mcp list --json` payload from zerocommands.MCPServerSnapshots(...) (which strips env/header values to counts and runs URL through stripURLCredentials) instead of marshaling raw config.MCPServerConfig, or omit Env/Headers from the JSON entirely. - -#### [medium/wiring] ResolvedConfig.MCP is computed but never read; provider-command MCP servers are silently dropped -`internal/config/resolver.go:95` - -Resolve() merges MCP configuration from user, project, the provider command, and overrides into ResolvedConfig.MCP, but no caller ever reads resolved.MCP. The runtime registers MCP tools via deps.resolveMCPConfig → config.ResolveMCP, a separate function that merges only user + project + overrides and never invokes the provider command. Consequently (a) the resolved.MCP field is dead, and (b) any `mcp.servers` returned by a ZERO_PROVIDER_COMMAND are merged by Resolve yet never actually started, because the registration path uses ResolveMCP which omits the provider command. The two resolution functions therefore disagree on the effective MCP server set. - -``` -resolver.go:90-99 returns ResolvedConfig{... MCP: cfg.MCP ...}; provider-command config is merged at resolver.go:49 via mergeConfig which merges MCP at resolver.go:142. ResolveMCP (resolver.go:102-117) iterates only UserConfigPath/ProjectConfigPath + overrides.MCP and never calls LoadProviderCommand. The runtime uses ResolveMCP via internal/cli/app.go:92-98 (resolveMCPConfig) and internal/cli/mcp_tools.go:22. Grep finds zero reads of `resolved.MCP` / a Resolve-result `.MCP` field anywhere outside the config package. -``` - -**Suggested fix:** Either drop the MCP field from ResolvedConfig (and its merge in Resolve) to avoid the misleading dead value, or have ResolveMCP run the provider command (and its MCP merge) so the inspected and registered MCP server sets match the agent's resolved config. - -#### [medium/perf] AuditStore.append is O(n^2): it re-reads and re-parses the entire audit JSONL on every append -`internal/hooks/hooks.go:503` - -Each AppendStarted/AppendCompleted call invokes store.append, which calls ReadEvents() to read the whole audit file, JSON-unmarshal every line, and scan for the max Sequence just to assign the next sequence number, before appending one line. For an append-only, unbounded audit log this makes writing N events O(N^2) total work and O(file size) per write. Because two hook-execution records (started + completed) are written per tool call, a long session would re-parse the entire growing log on every tool invocation once hooks are wired. - -``` -hooks.go:499-535: `func (store *AuditStore) append(...)` calls `events, err := store.ReadEvents()` (line 503), loops `for _, existing := range events { if existing.Sequence > highest ...}` (508-512) to compute `event.Sequence = highest + 1`, then opens the file O_APPEND and writes a single line. ReadEvents (476-497) reads the whole file and json.Unmarshals every non-blank line. -``` - -**Suggested fix:** Track the last sequence number in the AuditStore struct (read once at construction) and increment it under the mutex, or read only the final line of the file, instead of reparsing the entire log on every append. - -#### [low/dead-code] zerocommands snapshot library is largely dead: sandbox plan/decision/policy + hook/plugin/MCP snapshots have no production consumer -`internal/zerocommands/sandbox_snapshots.go:104` - -A substantial part of the zerocommands snapshot surface is never used outside tests: the entire sandbox_snapshots.go (SandboxPolicySnapshot/Risk/Violation/Backend/Plan/Decision builders) and, in backend_snapshots.go, HookSnapshots, PluginSnapshots, MCPServerSnapshots and NewBackendLifecycleSnapshot. The CLI `zero hooks list`/`zero plugins list`/`zero mcp list` build their own ad-hoc payloads from raw types and the CLI `zero sandbox` formats policy fields by hand, while the TUI exposes no hooks/plugins/MCP views at all. The package presents itself as the shared data layer for TUI + headless + CI, but only ConfigSnapshot, ProviderSnapshot, ModelSnapshot, ProviderCatalogSnapshot, SessionSnapshot(s)/Tree, and SandboxGrantSnapshots are actually wired; the redaction-aware snapshots that would prevent leaks (see the MCP finding) are bypassed. - -``` -Grep for SandboxPlanSnapshot/SandboxDecisionSnapshot/SandboxPolicySnapshot/SandboxBackendSnapshot/SandboxRiskSnapshot/SandboxViolationSnapshot/BackendLifecycleSnapshot/MCPServerSnapshot/PluginSnapshot/HookSnapshot across internal/cli, internal/tui, internal/doctor, cmd (excluding tests) returns no matches. CLI sandbox output is hand-built, e.g. internal/cli/sandbox.go:155 `"max_autonomy: " + string(policy.MaxAutonomy)`. -``` - -**Suggested fix:** Route the headless hooks/plugins/mcp/sandbox commands (and any TUI backend views) through the corresponding zerocommands snapshot builders so the redaction/shape guarantees are actually enforced, or remove the unused builders to avoid a false sense of a shared, redacting data layer. - -#### [low/dead-code] internal/config/contracts.go (ContractGap API) is unused dead code -`internal/config/contracts.go:12` - -DefaultContractGaps, FindContractGapsByMilestone, and the ContractGap type are exported and tested but have no production consumer anywhere in the repository. The file enumerates 'contract gaps' (env keys, provider timeout/retry/maxOutputTokens, permissions.mode, sandbox.policy) that nothing reads, so it carries no behavior and risks drifting out of sync with the real config surface. - -``` -Grep for DefaultContractGaps / FindContractGapsByMilestone / ContractGap across the repo (excluding tests and contracts.go itself) returns 0 references. config/contracts.go:12 `func DefaultContractGaps() []ContractGap`. -``` - -**Suggested fix:** Remove contracts.go (and its test) or wire the gap list into `zero doctor`/config validation so it is actually surfaced; an unconsumed contract list provides no guarantees. - -#### [low/dead-code] skills.Duplicates is never surfaced and skills.Get is unused; duplicate-name collisions are silently dropped -`internal/skills/skills.go:91` - -skills.go documents that when two skills declare the same frontmatter name the loser is dropped (first-directory-wins) and that callers should use Duplicates() to warn the user. But neither Duplicates nor Get has any production caller — the skill tool and `zero skills` both call Load/List, which discard the duplicate report. So a shadowed skill is silently dropped with no warning anywhere, and the documented mitigation is dead. - -``` -skills.go:91 `func Duplicates(dir string) ([]DuplicateName, error)` and skills.go:217 `func Get(...)` have no non-test callers (grep). Production consumers use only skills.Load (internal/tools/skill.go:64) and skills.List (internal/cli/skills.go:56); the load() return value `duplicates` is never propagated to the user. -``` - -**Suggested fix:** Have `zero skills list` (and/or the skill tool) call Duplicates and print a warning for shadowed skills, or remove Get/Duplicates if the collision behavior is intended to stay silent. - -#### [low/bug] Plugin and standalone hook validators disagree on the allowed hook event set -`internal/plugins/plugins.go:893` - -plugins.parseHookEvent accepts only beforeTool, afterTool, sessionStart, sessionEnd, whereas hooks.parseEvent additionally accepts specialistStart and specialistStop. A plugin manifest that declares a specialistStart/specialistStop hook (a valid hook event for a standalone hooks.json) is rejected with a misleading 'Expected beforeTool, afterTool, sessionStart, or sessionEnd' error, even though the hooks subsystem defines those events. The two hook-event enumerations have drifted. - -``` -internal/plugins/plugins.go:893 `case HookBeforeTool, HookAfterTool, HookSessionStart, HookSessionEnd:` and :896 error text omit specialist events; internal/hooks/hooks.go:812 `case EventBeforeTool, EventAfterTool, EventSessionStart, EventSessionEnd, EventSpecialistStart, EventSpecialistStop:` includes them. -``` - -**Suggested fix:** Add HookSpecialistStart/HookSpecialistStop to the plugins HookEvent constants and parseHookEvent (and the error message) so plugin-declared hooks accept the same event set as standalone hooks.json. - -#### [low/bug] config maxTurns <= 0 is silently ignored rather than validated -`internal/config/resolver.go:136` - -mergeConfig, mergeProjectConfig, and applyOverrides only apply MaxTurns when the source value is > 0. A user who writes "maxTurns": 0 or a negative value in config (or passes a zero/negative override) gets no error and silently keeps the default of 12, with no feedback that their setting was discarded. This is a 'value accepted but ignored' gap; unlike deferThreshold (which errors on negative) and notify/sandbox (which validate), maxTurns swallows out-of-range input. - -``` -resolver.go:136-138 `if src.MaxTurns > 0 { dst.MaxTurns = src.MaxTurns }` (also resolver.go:162-164 and applyOverrides resolver.go:484-486). There is no error branch for MaxTurns <= 0 in Resolve, in contrast to the deferThreshold check at resolver.go:57-59. -``` - -**Suggested fix:** Reject negative maxTurns with an explicit error in Resolve (mirroring the deferThreshold guard) and decide/document the meaning of 0, instead of silently falling back to the default. - -#### [low/bug] config writer performs a non-atomic in-place write of the user config -`internal/config/writer.go:53` - -writeConfigFile marshals the whole config and calls os.WriteFile, which truncates the destination then writes. A crash or full disk between truncate and the final write leaves the user's config.json truncated/corrupted. The hooks package writer for the analogous file already does the safe thing (write to a temp file then os.Rename), so this is an inconsistent and avoidable durability risk for the primary provider/credentials config. - -``` -writer.go:48-55 `data, _ := json.MarshalIndent(...); data = append(data, '\n'); os.WriteFile(path, data, 0o600)` — direct overwrite, no temp+rename. Compare hooks.WriteConfig (internal/hooks/hooks.go:256-264) which writes `tempPath` then `os.Rename(tempPath, resolved)`. -``` - -**Suggested fix:** Write to a sibling temp file (same dir, 0600) and os.Rename onto the target, matching hooks.WriteConfig, so a partial write can never clobber an existing valid config. diff --git a/docs/audit/2026-06-13-reverification.md b/docs/audit/2026-06-13-reverification.md deleted file mode 100644 index 815ffca9..00000000 --- a/docs/audit/2026-06-13-reverification.md +++ /dev/null @@ -1,117 +0,0 @@ -# Audit re-verification against current `main` (2026-06-13) - -Every pending (open/partial) row from `2026-06-10-deep-audit-status.md` was re-checked -against the **actual current code** by an independent agent (the ledger was verified on a -since-diverged branch). Every `already_fixed` verdict was then adversarially re-checked by a -second agent trying to prove the defect was still present; **none were refuted** (all 9 hold). - -| Verdict | Count | -|---|---| -| still_open | 66 | -| partial | 12 | -| already_fixed (ledger over-counted) | 9 | -| **genuinely pending (still_open + partial)** | **78** | - -Pending by severity: high 2, medium 42, low 34. - -## Already fixed on `main` (reclassify to fixed) (9) - -| Sev | Location (current) | Rationale | -|---|---|---| -| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). | -| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. | -| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. | -| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. | -| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. | -| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). | -| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. | -| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. | -| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. | - -## Genuinely partial (a real gap remains) (12) - -| Sev | Location (current) | Rationale | -|---|---|---| -| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. | -| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. | -| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. | -| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. | -| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). | -| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). | -| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). | -| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. | -| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. | -| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). | -| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. | -| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). | - -## Genuinely still open (66) - -| Sev | Location (current) | Rationale | -|---|---|---| -| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. | -| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). | -| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. | -| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. | -| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. | -| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. | -| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. | -| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). | -| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. | -| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. | -| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. | -| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 * * *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. | -| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. | -| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. | -| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. | -| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. | -| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). | -| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). | -| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. | -| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. | -| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. | -| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. | -| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. | -| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. | -| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. | -| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. | -| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. | -| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. | -| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. | -| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. | -| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. | -| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. | -| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. | -| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). | -| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. | -| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. | -| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. | -| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. | -| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. | -| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. | -| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. | -| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). | -| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. | -| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. | -| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. | -| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. | -| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. | -| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. | -| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. | -| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. | -| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. | -| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. | -| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. | -| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. | -| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. | -| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. | -| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). | -| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. | -| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. | -| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. | -| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. | -| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. | -| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. | -| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. | -| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. | -| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. | diff --git a/docs/audit/2026-06-20-deep-audit.md b/docs/audit/2026-06-20-deep-audit.md deleted file mode 100644 index 93a0e0f8..00000000 --- a/docs/audit/2026-06-20-deep-audit.md +++ /dev/null @@ -1,649 +0,0 @@ -# Zero — Deep Adversarial Code Audit (2026-06-20) - -- **Target:** `github.com/Gitlawb/zero` @ `origin/main` commit `bfbdbb1` (a terminal coding agent: Go, Bubble Tea v2 TUI, surface-agnostic core feeding TUI / headless `exec` / MCP server / cron). -- **Toolchain:** Go 1.25.0 (toolchain go1.26.4). 72 packages. -- **Method:** Fresh checkout of `origin/main` (the audit-batch worktree lags main). Recon → build/vet/`-race` gates → 13-subsystem deep read (each reconciling its slice of the prior audit docs) → adversarial re-verification of every finding by independent skeptics (2 for high, 1 otherwise) that re-read guards/locks/callers to try to *disprove* it; only survivors are reported. Findings are grounded in current code with `path:line` + quoted evidence; several were empirically reproduced with throwaway tests. -- **Scope note:** Audit only — no source was modified. - -## 1. Executive summary - -The tree is in markedly better shape than the 2026-06-10 audit: of ~150 prior findings, **64 are fully fixed and 14 partially fixed** in current `main` (atomic config write, durable session metadata, cross-process cron/hooks/oauth locks, MCP framing + alloc cap, SSRF DNS-rebind pinning, sandbox quote-aware interactive detection, FinishReason consumption, transcript O(n²) + per-frame re-render, UTF-8 rune-safe truncation across the board, secret-pattern anchoring, and more). Build and vet are clean; the race detector found **zero data races** across 67/68 packages. - -The residue clusters into four recurring root causes (§5). The single most important new item is **one security finding (M10):** the provider health-probe forwards `x-api-key`/`x-goog-api-key`/custom headers across an HTTP redirect to a public host — a credential-exfil vector the recently-added DNS-rebind/internal-redirect hardening does not cover. The lone high-severity item (**H1**) is a trust/correctness gap: four of six documented, user-configurable hook events are never dispatched. The remaining mediums are durability (events.jsonl not fsync'd; PTY last-chunk drop), cron edge cases (DST double-fire, claim double-grant, `--run-now` cancelled), connection-lifecycle gaps (remote-bridge idle deadline, Shutdown can't close bridge conns, MCP SSE never reconnects), a sandbox over-block false-positive, a stream-json prose-mangling regex, and a TUI streaming O(n²). - -### Severity rollup - -| Severity | Count | -|---|---| -| Critical | 0 | -| High | 1 | -| Medium | 15 | -| Low | 17 | -| Info | 2 | -| **Total (surviving)** | **35** | - -Prior-audit reconciliation: **64 fixed · 14 partial · 12 still-open** (§4). Two drafted findings were dropped in adversarial review (§4.4). - -## 2. Build / vet / test results (verbatim) - -Run in the fresh `origin/main` checkout, Go-native only (per AGENTS.md), with test isolation `HOME`/`XDG_CONFIG_HOME` pointed at temp dirs and `CI=1`. - -``` -$ go build ./... -(no output) — exit 0 ✅ clean - -$ go vet ./... -(no output) — exit 0 ✅ clean - -$ go test ./... -race -count=1 -ok <67 packages> ✅ ---- FAIL: TestIndependentExecCommandConstructorsShareDefaultManager (1.01s) - exec_command_test.go:43: expected shared manager to find completed session, got ... session_id:"1000" ... ---- FAIL: TestExecCommandReturnsSessionAndWriteStdinPollsCompletion (1.02s) - exec_command_test.go:82: expected exit_code 0, got ... session_id:"1000" ... ---- FAIL: TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield (1.01s) - exec_command_test.go:99: completed command must not return session_id, got ... session_id:"1000" ... -FAIL github.com/Gitlawb/zero/internal/tools 13.664s -exit 1 -``` - -**Race detector: 0 data races reported in any package.** The 3 `internal/tools` failures are **`-race`-only timing flakes, not product defects**: they pass cleanly 3× without `-race` (`go test ./internal/tools -run -count=3` → `ok`), and fail under `-race` only because the test's fixed 10ms yield window is too short when the binary runs ~5–10× slower under race instrumentation — the command is still running when the window elapses, so the tool *correctly* returns a still-running `session_id` instead of an `exit_code`. This is a test-robustness gap (the suite is not `-race`-clean) recorded under Confidence notes (§7); it is the same exec-session timing family that recently churned in PRs #270/#273. (A genuine, separate `-race`-detectable data race in this package — `lastUsedAt` — is reported as **L15**, found by inspection because no test exercises it.) - -## 3. Findings by severity - -> Each finding was re-verified against current code by independent skeptics; the evidence below is what withstood that challenge. Locations are `path:line` relative to the repo root. - -### H1 · Four of six hook events (sessionStart/sessionEnd/specialistStart/specialistStop) are validated and user-configurable but NEVER dispatched -- **Severity / category:** high / dead-inert · **Subsystem:** hooks -- **Location:** `internal/hooks/dispatch.go:112; internal/agent/loop.go:914,937` -- **Evidence:** -``` -KnownEvents() returns all six (hooks.go:888) and IsValidEvent gates `zero hooks add` (hooks_manage.go:297-298) advertising all six in help (hooks_manage.go:315 "beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, specialistStop"). But the only two production call sites of Dispatcher.Dispatch are loop.go:915 (`Event: hooks.EventBeforeTool`) and loop.go:938 (`Event: hooks.EventAfterTool`). grep over internal/+cmd/ for `.Dispatch(` finds exactly those two; no dispatch ever passes EventSessionStart/EventSessionEnd/EventSpecialistStart/EventSpecialistStop (the specialist lifecycle in internal/specialist/accounting.go:38,60 emits sessions.EventSpecialistStart/Stop to the SESSION store, never to hooks.Dispatcher). -``` -- **Impact:** Across a whole session a user (or plugin) who configures a sessionStart/sessionEnd/specialistStart/specialistStop hook — e.g. a sessionStart hook that loads secrets/env, or a sessionEnd hook that flushes/cleans up, or a specialist audit hook — gets a green `zero hooks add` and a hook that `zero hooks list` shows as enabled, yet the command is silently never executed for the entire run. This is a correctness/trust gap: lifecycle automation users believe is wired (cleanup, audit, env priming) just does not happen, with no error surfaced. This is the same class as the prior 2026-06-10 high finding 'loaded but never executed' — the beforeTool/afterTool half was fixed; the other four events remai -- **Fix:** Either (a) actually dispatch these events from their lifecycle points: call options.Hooks.Dispatch with EventSessionStart at session/agent-run start, EventSessionEnd at run teardown, and EventSpecialistStart/Stop alongside the sessions events in internal/specialist/accounting.go; or (b) if only beforeTool/afterTool are intended to be runnable, remove the other four from KnownEvents()/IsValidEvent and the CLI help so users cannot configure dead hooks. Option (a) is preferable since the events are already a documented surface. - -### M1 · streamjson api_key secret pattern still mangles ordinary prose/output in every stream-json event -- **Severity / category:** medium / robustness · **Subsystem:** cli-usage-streamjson -- **Location:** `internal/streamjson/streamjson.go:314` -- **Evidence:** -``` -regexp.MustCompile(`(?i)(api[_-]?key["'=:\s]+)[^"',\s)]+`) — the ["'=:\s]+ class lets a plain space follow apikey, so the next word is captured. Verified: redactString("The function apikey: foo is documented here") -> "...[REDACTED] is documented..."; redactString("The user's apiKey value spans...") -> "...apiKey [REDACTED] spans...". The sibling sk-/bearer patterns were anchored, but this one was not. -``` -- **Impact:** FormatEvent runs redactValue->redactString over EVERY string field of EVERY emitted stream-json event (text-event Delta = model output, tool_result Output, final Text, warning/error Message). Any time the model's answer, a tool's output, a diff, a log line, or documentation contains the literal token "api_key:"/"apiKey "/"api-key=" followed by a word, a chunk of that non-secret content is silently replaced with [REDACTED] in the machine-readable protocol a downstream consumer parses. Over a session that discusses configs/auth (common for a coding agent) this corrupts arbitrary output with no warning. This is the unfinished half of prior finding M48. -- **Fix:** Require an actual credential shape after the key marker rather than 'any next word': drop bare whitespace as a value delimiter (use `(api[_-]?key)\s*[=:]\s*["']?` for the prefix) and require a credential-length body, e.g. `[A-Za-z0-9._-]{12,}` (mirror the sk-/bearer anchoring already applied). Add a prose regression case ("the api_key: foo setting" must survive) to TestRedactStringDoesNotOverMatchProse. - -### M2 · ResolvedConfig.MCP is computed but never read; ZERO_PROVIDER_COMMAND MCP servers are silently dropped -- **Severity / category:** medium / correctness · **Subsystem:** config-plugins-skills -- **Location:** `internal/config/resolver.go:106` -- **Evidence:** -``` -Resolve() merges provider-command output (LoadProviderCommand at resolver.go:54 + mergeConfig at :58, which calls mergeMCPConfig) and sets `MCP: cfg.MCP` (resolver.go:106). But `grep -rn 'resolved.MCP' internal/cli` returns nothing — no caller reads ResolvedConfig.MCP. Runtime MCP registration goes through the separate ResolveMCP (resolver.go:115-133), which only seeds DefaultMCPServers + merges UserConfigPath/ProjectConfigPath/Overrides.MCP and never calls LoadProviderCommand or touches options.ProviderCommand. -``` -- **Impact:** A provider command (ZERO_PROVIDER_COMMAND) that emits an mcp.servers block has those servers folded into Resolve()'s result but the runtime registers from ResolveMCP, so provider-command-supplied MCP servers are never registered for the whole session — the user sees no MCP tools from them and no error. ResolvedConfig.MCP is also dead weight. -- **Fix:** Have ResolveMCP also merge LoadProviderCommand(options.ProviderCommand).MCP (between the file merges and the overrides merge), or make the runtime read resolved.MCP from the single Resolve() path. Drop ResolvedConfig.MCP if it stays unread. - -### M3 · Project-checked-in slash commands auto-load and expand-to-prompt with no provenance marker or confirmation; Command.Project is dead -- **Severity / category:** medium / robustness · **Subsystem:** config-plugins-skills -- **Location:** `internal/tui/user_commands.go:29` -- **Evidence:** -``` -model.go:473 `usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))` auto-loads `/.zero/commands/*.md` at TUI startup. handleUserCommand (user_commands.go:29-33) does `prompt := usercommands.Expand(cmd.Template, args)` then `m.launchPrompt(prompt)` — no confirmation, no display of whether the command is project- or user-sourced. The provenance field exists (usercommands.go:120 `Project: project`) but `grep -rn '\.Project' internal/tui internal/usercommands` shows it is only ever WRITTEN, never read. Project commands shadow user commands of the same name (Load merges project after user, byName overwrite). -``` -- **Impact:** Opening Zero inside a cloned/untrusted repo silently loads that repo's `.zero/commands/*.md`. A repo can name a command after a common one (`/test`, `/fix`, `/pr`) so the user invoking it expands attacker-authored instructions and submits them to the model verbatim, driving the tool-enabled agent — with no indication the command body came from the repo rather than the user's own config. The Project flag built to distinguish this is never surfaced. -- **Fix:** Surface provenance: in the command list/help and on first invocation of a project-sourced command, show it is repo-sourced (and ideally one-time confirm), using the already-present Command.Project flag; or scope auto-load of project commands behind a trust prompt like other workspace-trust gates. - -### M4 · DST fall-back double-fire: the collapse guard is dead on the real scheduler path (sub-minute `fired`) -- **Severity / category:** medium / correctness · **Subsystem:** cron -- **Location:** `internal/cron/schedule.go:205` -- **Evidence:** -``` -Guard: `if sameWallClockMinute(t, after) && after.Truncate(time.Minute).Equal(after) { t = t.Add(time.Minute) } else { return t }`. But fireJob feeds raw wall-clock time: `fired := now()` (cron_run.go:136) then `sched.Next(fired)` (cron_run.go:190), and reconcileOverdue uses `sched.Next(now())` (cron_run.go:122) — none truncate to the minute. With real time.Now() the second clause `after.Truncate(time.Minute).Equal(after)` is false, so the collapse never engages. Reproduced: Next(2026-11-01 01:30:15 EDT) for `30 1 * * *` = 2026-11-01 01:30:00 EST (06:30 UTC) — same day, one absolute hour later. -``` -- **Impact:** On the annual US/EU DST fall-back day, a daily job scheduled in the repeated wall-clock hour (e.g. `30 1 * * *` in a DST zone, NextRunAt stored in that location) runs twice that day: after firing at 01:30 EDT the post-exec advance computes NextRunAt=01:30 EST one hour later, and the next tick fires it again (claimFire's `current.NextRunAt.After(fired)` does not block it because the second 01:30 EST instant is not after the stored 01:30 EST). Duplicate scheduled agent run = duplicate model-token spend. The unit tests pass because they pass a minute-aligned `after`; the production caller never does. -- **Fix:** Truncate the fire instant to the minute before computing the next slot, so the guard's precondition holds for the real caller: in fireJob set `fired := now().Truncate(time.Minute)` (or pass a truncated value into sched.Next at cron_run.go:122/190 and claimFire). Alternatively make Next collapse the fall-back repeat whenever the candidate shares wall-clock minute with `after` AND is a later absolute time, independent of after's sub-minute precision, adjusting the strictly-after contract accordingly. - -### M5 · claimFire grants the slot to BOTH concurrent schedulers when the schedule cannot advance (invalid/exhausted spec) -- **Severity / category:** medium / concurrency · **Subsystem:** cron -- **Location:** `internal/cli/cron_run.go:256` -- **Evidence:** -``` -claimFire only advances NextRunAt when the expr parses AND Next is non-zero: `if sched, perr := cron.Parse(current.Expr); perr == nil { if nxt := sched.Next(fired); !nxt.IsZero() { current.NextRunAt = nxt } }` but unconditionally leaves `claimed := true` for an active, due job. For an unadvanceable schedule NextRunAt is left unchanged, so a second caller still sees Status==active and `!NextRunAt.After(fired)` and also returns claimed=true. Reproduced by calling claimFire twice with no fire in between: `claim1=true claim2=true`. -``` -- **Impact:** Two concurrent schedulers (the documented `cron run --once` under system cron overlapping a forever-mode `cron run`, cron_run.go:61-63) both claim and both run `zero exec` for the same fire of a job whose Expr is impossible/exhausted (e.g. `0 0 30 2 *` stored directly per TestCronRunPausesUnadvanceableJob, a hand-edited metadata.json, or a finite spec that has run out near the 9-year search cap). Both spawn an agent before either pauses the job in its post-exec Mutate. This is the exact double-fire the claim mechanism exists to prevent; the prior concurrent test masks it only because the fake exec returns instantly so the first fire pauses the job before the second claims — a real minutes-lo -- **Fix:** Make claimFire fail the claim for an unadvanceable schedule: if Parse errors or Next(fired) is zero, set `claimed = false` (let a single deterministic caller pick it up) or advance NextRunAt to a sentinel/pause it inside the same locked Mutate so the loser observes the change. The winner's post-exec block already pauses it; the claim just needs to not hand the same un-advanced slot to a second runner. - -### M6 · `cron add --run-now` job is silently cancelled by forever-mode startup reconcile instead of firing -- **Severity / category:** medium / correctness · **Subsystem:** cron -- **Location:** `internal/cli/cron_run.go:118` -- **Evidence:** -``` -cronAdd --run-now sets `next = now()` (cron.go:159) and prints `next run `. reconcileOverdue (run at forever-startup when !catchUp, cron_run.go:71) treats any NextRunAt before the current minute as strictly overdue and reschedules without firing: `nowMin := now().Truncate(time.Minute) ... if !j.NextRunAt.Before(nowMin) { continue } ... j.NextRunAt = nxt; store.Update(j)`. The Job struct carries no run-now marker, so the immediate request is indistinguishable from an ordinary backlog. Reproduced: --run-now job added at 08:00, `cron run` started at 08:05 → NextRunAt moved to 09:00, FireCount=0, no output. -``` -- **Impact:** A user who runs `zero cron add ... --run-now` and then starts the foreground scheduler more than a minute later never gets the promised immediate run; the job silently waits until its next regular slot. Contradicts both the --run-now contract and the message cronAdd printed. Still open from the prior audit (docs/audit/2026-06-10-deep-audit.md:2208). -- **Fix:** Persist a one-shot intent (e.g. a RunNow bool on Job cleared on first fire, or set NextRunAt to a future-safe sentinel that reconcile fires rather than skips). In reconcileOverdue, exempt jobs explicitly marked run-now (fire them in the first fireDue pass) instead of treating their now() NextRunAt as stale backlog. - -### M7 · Remote bridge clears the connection deadline before the daemon handshake, so an authenticated-but-idle peer pins a connection slot forever -- **Severity / category:** medium / resource-perf · **Subsystem:** daemon -- **Location:** `internal/daemon/remote/bridge.go:223` -- **Evidence:** -``` -// Clear the handshake deadline: a session may stream... -_ = conn.SetDeadline(time.Time{}) -... -b.server.ServeConn(conn) // performs the daemon handshake + one command ---- server.go:225-231 (reached via ServeConn) --- -func (s *Server) handleConn(conn net.Conn) { - defer conn.Close() - hello, err := ReadControl(conn) // no deadline -``` -- **Impact:** The bridge bounds only the auth handshake (line 181) and then fully clears the conn deadline. After a peer passes auth it is handed to ServeConn -> handleConn, which does ReadControl(conn) for the daemon hello and again for the command with NO read deadline and no per-command timeout. A peer that completes the auth handshake (valid token) but then never sends the daemon hello (or sends hello but never the command) blocks its handler goroutine indefinitely while holding one of the b.sem slots (default MaxConnections=32). 32 such stalled connections exhaust the bridge: every other remote client is refused 'at capacity' (bridge.go:164-168) until process restart. This is an authenticated-client -- **Fix:** Do not fully clear the deadline before ServeConn. Either keep a (larger) per-command deadline for the daemon hello+command exchange in the session path, or set a fresh deadline covering the daemon handshake and clear it only once streaming begins. Simplest: in bridge.go before ServeConn, set conn.SetDeadline(now+handshakeTimeout) for the session path and let streamToClient clear it, OR add a read deadline inside handleConn around the two ReadControl handshake calls. - -### M8 · Server.Shutdown cannot close remote bridge connections (ServeConn bypasses trackConn), so a stalled remote handshake-read survives Shutdown -- **Severity / category:** medium / robustness · **Subsystem:** daemon -- **Location:** `internal/daemon/server.go:222` -- **Evidence:** -``` -func (s *Server) ServeConn(conn net.Conn) { s.handleConn(conn) } ---- vs the local accept loop (server.go:136-141) --- -s.trackConn(conn) -s.wg.Add(1) -go func() { defer s.wg.Done(); defer s.untrackConn(conn); s.handleConn(conn) }() ---- Shutdown only closes tracked conns (server.go:181-183) --- -for c := range s.conns { _ = c.Close() } -``` -- **Impact:** The D3 fix (Shutdown closes every open connection so a handler blocked on a read returns) only covers connections accepted by the LOCAL Serve loop, which calls trackConn. The remote bridge enters via ServeConn, which calls handleConn directly and never registers the conn in s.conns. The streaming phase is still interruptible (streamToClient selects on <-s.done), but a remote connection blocked in the pre-stream ReadControl handshake (combined with the no-deadline gap above) is NOT closed by Shutdown and is NOT covered by s.wg, so it leaks until process exit and keeps occupying a bridge slot through the entire graceful-drain window. The asymmetry means the carefully-built D3/D4/D5 shutdown gu -- **Fix:** Have ServeConn register the conn for shutdown closure too: call s.trackConn(conn)/defer s.untrackConn(conn) inside ServeConn (or expose a tracked variant the bridge uses), so Shutdown's conns-close loop unblocks a stalled remote handshake read as it does for local connections. Pair with a handshake read deadline (previous finding) for defense in depth. - -### M9 · MCP SSE client never reconnects: any stream close permanently kills the session -- **Severity / category:** medium / robustness · **Subsystem:** mcp -- **Location:** `internal/mcp/network_client.go:517` -- **Evidence:** -``` -readStream end: `client.failPending(fmt.Errorf("MCP SSE stream closed for server %s", client.server.Name))`; failPending sets `client.streamErr = err` (line 593); request() gate: `if client.streamErr != nil { ... return err }` (lines 243-246). openStream is invoked exactly once, only from connectRemoteSSE at connect (line 77) — grep finds no other caller and no reconnect loop. registry.go connects each server once at startup and reuses the client for the whole session with no reconnect-on-error. -``` -- **Impact:** For an SSE-transport MCP server, the persistent GET stream closing for ANY reason during a session — clean server-side EOF (the normal `MCP SSE stream closed` path), an idle/proxy timeout, a network blip, or an over-8MiB SSE line tripping bufio.ErrTooLong — sets a permanent streamErr. Every subsequent tools/call and tools/list for that server then returns that error for the rest of the process lifetime; the only recovery is restarting Zero (or `zero mcp enable` per tui hint). A long agent session that idles past a server keep-alive window silently loses all of that server's tools. -- **Fix:** On a non-cancelled stream termination, attempt a bounded re-open: clear streamErr/endpointURL, call openStream again with backoff (e.g. mirror agent/reconnect.go's maxStreamReconnects + exponential backoff), and only set a permanent streamErr after retries are exhausted or ctx is done. Re-send `notifications/initialized` after a successful re-open so the server re-establishes session state. - -### M10 · providerhealth probe re-sends x-api-key / x-goog-api-key / CustomHeaders to a cross-host redirect target (credential exfil to a public attacker host) -- **Severity / category:** medium / security · **Subsystem:** oauth-providers -- **Location:** `internal/providerhealth/providerhealth.go:430` -- **Evidence:** -``` -CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= maxConnectivityRedirects {...}; return validateEndpoint(req.Context(), req.URL.String(), resolver) } — re-validates the redirect HOST against the private/special-use blocklist but never strips auth headers. applyAuth (line 529-561) sets DefaultAuthHeader "x-api-key" (Anthropic), "x-goog-api-key" (Google), and arbitrary profile.CustomHeaders (lines 539/548/558). -``` -- **Impact:** Go's stdlib only auto-strips Authorization/Cookie/WWW-Authenticate when a redirect crosses hosts; it does NOT strip x-api-key, x-goog-api-key, or arbitrary custom headers. The DNS-rebinding/internal-redirect half of the prior high finding is now fixed (safeDialContext pins validated IPs; CheckRedirect blocks internal targets), but a configured baseURL that returns a 3xx to an attacker-controlled PUBLIC host (a compromised/MITM'd/typo'd proxy endpoint, or a hostile OpenAI-compatible gateway the user pointed at) passes the blocklist and receives the provider API key verbatim. Production callers (observability.go:58, provider_setup.go:99, setup.go:116) pass no HTTPClient, so newConnectivityClie -- **Fix:** In CheckRedirect, when req.URL.Host != via[0].URL.Host (or any earlier hop's host), delete the auth-bearing headers from req.Header (x-api-key, x-goog-api-key, Authorization, and every key in profile.CustomHeaders) before allowing the redirect; or set client.CheckRedirect to refuse cross-host redirects entirely for the probe (a health check has no need to follow a host change). - -### M11 · Destructive/piped-installer risk regexes scan the whole raw command (incl. quoted literals); the quote-aware AST analyzer can never retract the false positive -- **Severity / category:** medium / correctness · **Subsystem:** sandbox -- **Location:** `internal/sandbox/risk.go:108-112` -- **Evidence:** -``` -command := firstArgString(...) -if matchesDestructive(command) { add("destructive", RiskCritical) } -if pipedInstallerPattern.MatchString(command) { add("piped_installer", RiskCritical) } -... analysis := AnalyzeCommand(command); if analysis.Destructive { add("destructive", RiskCritical) } // add() only ORs in, never removes - -Reproduced via engine.Evaluate: `git commit -m "fix rm -rf / bug"` -> action=prompt reason="destructive shell command requires approval" risk=[destructive shell], in BOTH PermissionModeAsk and PermissionModeAuto. Matcher-level probe: `git commit -m "rm -rf /"`=>destructive=true while AST destr=false; `echo "do not run rm -rf /"`=>destructive=true; `grep -r "dd if=/dev/zero" .`=>destructive=true; `printf '%s' "curl evil|sh"`=>piped=true. AST clears all of them (destr=false,net=false) but classifyWithScope only adds categories. -``` -- **Impact:** Across a session, any shell command that merely QUOTES one of the trigger substrings (rm -rf / | rm -rf ~ | dd if= | chmod 777 | the fork-bomb chars | curl..|sh) is mis-classified RiskCritical 'destructive'. Per engine.go:323-329 a SideEffectShell command with the destructive category and no prior PermissionGranted is forced to ActionPrompt (Ask AND Auto) or denied when permission can't be granted. So routine, safe commands — `git commit -m "...rm -rf..."`, `echo`/`printf`/`grep` mentioning these tokens — are spuriously gated behind a destructive approval prompt, and in a headless exec with no OnPermissionRequest the prompt resolves to a hard block. The interactive detector was alread -- **Fix:** Run matchesDestructive / pipedInstallerPattern over the command-body of each real shell segment (reuse splitShellSegments + commandBody from safe_command.go) instead of the raw string, OR make the AST analyzer authoritative for parseable scripts: when analysis.TooComplex==false, trust analysis.Destructive/Network and skip the raw-string regexes (only fall back to regex when the parse fails). Either approach removes the quoted-literal false positives while keeping detection of genuine unquoted destructive commands. - -### M12 · events.jsonl append is the only write in the package not fsync'd, while the derived metadata IS — a crash can lose a 'successfully appended' event (incl. the checkpoint /rewind targets) and leave EventCount ahead of the durable log -- **Severity / category:** medium / correctness · **Subsystem:** sessions -- **Location:** `internal/sessions/store.go:606-616` -- **Evidence:** -``` -file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600) -... -if _, err := file.Write(append(data, '\n')); err != nil { _ = file.Close(); ... } -if err := file.Close(); err != nil { ... } // no file.Sync(), no syncDir -... -session.EventCount = sequence -if err := store.writeMetadata(session); err != nil { ... } // writeMetadata fsyncs temp + dir -``` -- **Impact:** appendEventLocked writes the event line into the page cache (no fsync, no dir sync) and THEN durably writes metadata (writeFileSync + syncDir). On power loss / OS crash after the metadata fsync but before the events.jsonl page is flushed, the persisted metadata.EventCount=N but events.jsonl is missing line N. Across a session this silently drops the last appended event even though AppendEvent returned success: a just-recorded EventSessionCheckpoint vanishes (its blob, written via writeFileAtomicSync, IS durable but is now unreferenced, so /rewind cannot target it and the file content is orphaned), and the user's last turn (message/tool_call/usage) is lost on resume. It also inverts the seque -- **Fix:** Make the append durable like every other write: after file.Write, call file.Sync() before Close (and ideally syncDir(sessionPath) on first create of events.jsonl). Cheapest: replace the open/append with an fsync'd append helper, e.g. f.Write(...); if err := f.Sync(); err != nil {...}; f.Close(). This restores the ordering guarantee that the log is at least as durable as the metadata derived from it. - -### M13 · Mailbox stale-lock break compares the lock file to itself, not to the snapshot observed as stale — can delete a healthy fresh lock (split-brain writers) -- **Severity / category:** medium / concurrency · **Subsystem:** specialist-swarm -- **Location:** `internal/swarm/mailbox.go:371` -- **Evidence:** -``` -if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter { - stale, _ := os.ReadFile(lockPath) - if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) { - os.Remove(lockPath) - } - continue -} -``` -- **Impact:** The comment claims the break is 'conditional on the file's content being unchanged since we observed it,' but `stale` and `data` are two consecutive reads of the SAME file microseconds apart, so the equality check is essentially always true and provides no protection. If a holder crashes and leaves a stale lock, two (or more) waiters can each observe it stale and each call os.Remove(); meanwhile a third writer may legitimately re-create the lock via O_EXCL between the two removes. The second waiter's Remove then deletes that fresh, validly-held lock, after which both that holder and the waiter (on its next O_EXCL create) hold the lock simultaneously — two concurrent writers to the same per-a -- **Fix:** Capture the lock content at Stat time and only remove if it is byte-identical AND the ModTime is unchanged at remove time; better, make the break ownership-aware like release(): read the token, then remove via an atomic rename-then-unlink or re-stat-and-verify-mtime guard. Simplest robust fix: snapshot (content, modTime) when first seen stale, sleep a short grace, re-stat, and only Remove if both still match — so a freshly rotated lock (new mtime/content) is never deleted. - -### M14 · PTY exec session can drop its final output chunk on exit (copy goroutine unsynchronized with markDone), then the session is removed -- **Severity / category:** medium / concurrency · **Subsystem:** tools -- **Location:** `internal/tools/exec_command.go:515-526 (with exec_pty_linux.go:34-37)` -- **Evidence:** -``` -startSession's Wait goroutine: `err := command.Wait(); ... if session.cleanup != nil { session.cleanup() }; plan.Cleanup(); cancel(); session.markDone(...)`. For a TTY session the only thing copying PTY output into the buffer is the fire-and-forget goroutine in exec_pty_linux.go:34 `go func() { _, _ = io.Copy(output, master) }()`. command.Stdout/Stderr are the *os.File slave (exec starts NO copy goroutine and WaitDelay does not cover our own goroutine), so command.Wait() returns as soon as the process exits — before io.Copy has necessarily drained the master FD. markDone then closes `done`. collect (line 541-545) does a final drainString after doneClosed, but if the copy goroutine has not yet written the tail, that drain misses it; run()/RunWithOptions then call `tool.manager.remove(session.id)` (lines 465, 676) so the session is gone and no later poll can recover the lost tail. -``` -- **Impact:** Across a session, the last lines a TTY (tty:true) command prints right before it exits can be silently dropped from the tool result the model sees — e.g. a test runner's final PASS/FAIL summary or a build's last error line — and because the exited session is immediately removed, the model cannot poll for it. Pipe-mode (tty:false) is unaffected because exec's own Wait drains its os.Pipe. Linux-only (other platforms fall back to pipe via exec_pty_fallback.go). -- **Fix:** Make the PTY copy goroutine observable: have startPTYProcess return a `copied <-chan struct{}` (closed when io.Copy returns), and in the Wait goroutine block on it (with a bounded timeout matching bashWaitDelay) AFTER cleanup() closes master and BEFORE markDone, so all buffered PTY output is in the buffer before `done` is closed and the session is eligible for removal. - -### M15 · Streaming live block re-parses the entire growing answer markdown on every spinner/fade tick (cumulative O(N^2) per turn) -- **Severity / category:** medium / resource-perf · **Subsystem:** tui-render -- **Location:** `internal/tui/model.go:2079` -- **Evidence:** -``` -interimBlock: `lines := renderAssistantMarkdownText(text, assistantMeasure(width), width)` where `text := strings.TrimRight(m.streamingText, "\n")`. streamingText only grows (model.go:1185 `m.streamingText += msg.delta`) and is cleared solely at turn end (1411/1522/3208). interimBlock is called from the per-frame body item closure (transcript_selection.go:245 `lines: viewLines(m.interimBlock(width))`). The spinner self-reschedules while pending (model.go:1212-1238, gated on m.pending) and the fade ticks every 150ms (streaming_fade.go:79), so the full markdown parse+wrap runs ~12x/sec. -``` -- **Impact:** Settled transcript rows are memoized via defaultRenderCache (rendering.go:190-197), but the streaming block is NOT cached. For a long single answer (e.g. a model emitting a multi-hundred/thousand-line file or table as prose), each ~80-150ms tick re-runs renderAssistantMarkdownText over the whole accumulated buffer — fence scanning, table parsing, per-line inline wrap with lipgloss.Width. Cost per frame is O(current length); over the turn the work is O(N^2/delta). On a slow/large stream the TUI visibly chews CPU and the frame rate drops the longer the answer gets, exactly when responsiveness matters most. -- **Fix:** Memoize the rendered streaming lines keyed by (streamingText length or a content hash, width); only re-render when streamingText or width actually changed since the last frame. The fade is applied per-line AFTER the markdown render (styleStreamingLine, model.go:2087), so the expensive markdown step can be cached while the cheap per-line color pass still runs each tick. Alternatively bound the markdown render to the visible tail (last height lines) since the viewport clips anyway. - -### L1 · Reactive-compaction retry and max-turns final-answer call bypass streamWithReconnect (no transient-disconnect retry) -- **Severity / category:** low / resource-perf · **Subsystem:** agent-loop -- **Location:** `internal/agent/loop.go:205,253,518` -- **Evidence:** -``` -stream, err = provider.StreamCompletion(ctx, request) // reactive connect-failure retry -retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest) // reactive mid-stream retry -stream, err := provider.StreamCompletion(ctx, ...) // finalAnswerAfterMaxTurns -``` -- **Impact:** The main turn connect goes through streamWithReconnect (2 retries with backoff on EOF/reset/502/503/timeout), but the post-compaction retry calls and the max-turns final-answer call call provider.StreamCompletion directly. A single transient upstream hiccup on exactly those calls (e.g. the final summary after a long autonomous/cron run, or the retried turn right after a context-limit compaction) fails the whole run with no reconnect, re-burning the entire session's tokens — the precise failure mode streamWithReconnect was added to prevent. -- **Fix:** Route these three calls through streamWithReconnect(ctx, provider, request, reconnectNoticeFor(options)) as well; they are pre-content connects so no OnText duplication risk, matching the existing reconnect contract. - -### L2 · OnContext / MeasureContext / estimateToolTokens context-utilization pipeline has no production consumer -- **Severity / category:** low / dead-inert · **Subsystem:** agent-loop -- **Location:** `internal/agent/loop.go:164-166` -- **Evidence:** -``` -if options.OnContext != nil { - options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow)) -} -``` -- **Impact:** Repo-wide grep shows zero `OnContext:` assignments outside agent tests (cli/tui/exec never set it), so MeasureContext and its private estimateToolTokens (a near-duplicate of compaction.go's estimateToolDefTokens) are computed for nobody in production and ship as permanently inert plumbing. Carries a maintenance hazard (two divergent tool-token estimators) and a tiny per-turn branch. Matches the prior low finding — still open. -- **Fix:** Either wire OnContext from the TUI/exec to drive a context-utilization bar, or delete OnContext + MeasureContext + ContextBreakdown + estimateToolTokens and keep the single estimateToolDefTokens used by compaction. - -### L3 · zero --skip-permissions-unsafe still silently drops trailing positional args (e.g. a prompt) -- **Severity / category:** low / robustness · **Subsystem:** cli-usage-streamjson -- **Location:** `internal/cli/app.go:234` -- **Evidence:** -``` -moreDirs, rest, err := splitLeadingAddDirFlags(args[1:]) ... for _, arg := range rest { if arg == "--add-dir"... { return error } } ... return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...)). `rest` is iterated only to reject a misplaced --add-dir; every other trailing arg is discarded and the interactive TUI (which takes no positional prompt) is launched. -``` -- **Impact:** `zero --skip-permissions-unsafe "fix the bug"` silently discards the prompt and drops the user into the interactive TUI with no error or warning, so a scripted/one-shot unsafe invocation appears to hang/ignore input. Documented in-code as intentional ('were ignored on this path... and still are'), but it is still a silent UX drop and unchanged since the prior audit (prior M81 / app.go:149). -- **Fix:** If `rest` contains any non-flag tokens, fail loudly ("--skip-permissions-unsafe launches the interactive TUI and takes no prompt; use `zero --skip-permissions-unsafe exec -p ...` or pipe input") instead of discarding them, matching the loud-rejection style already used for misplaced --add-dir two lines above. - -### L4 · usercommands frontmatter model:/agent:/mode: are parsed and documented but never applied -- **Severity / category:** low / dead-inert · **Subsystem:** config-plugins-skills -- **Location:** `internal/usercommands/usercommands.go:125` -- **Evidence:** -``` -parseCommand fills cmd.Model (usercommands.go:125) and cmd.Agent (usercommands.go:126-129 from `agent:`/`mode:`), and the package doc (lines 8-9) advertises `model:` as a real override. But the sole invocation path, handleUserCommand (tui/user_commands.go:29-33), calls launchPrompt(prompt) — and `launchPrompt(prompt string)` (model.go:3020) takes only the prompt; cmd.Model/cmd.Agent are never read by any caller (`grep` for the fields outside the struct/parse shows only the parse assignment). -``` -- **Impact:** A user who writes `model: ` or `agent: ` in a `.zero/commands/foo.md` frontmatter, per the documented feature, gets it silently ignored — the command always runs on the current model/agent. The fields are inert surface that mislead users into thinking per-command model routing works. -- **Fix:** Either honor cmd.Model/cmd.Agent in handleUserCommand (route the launched prompt through the model/agent switch before launchPrompt) or remove the fields and the package-doc `model:` line. - -### L5 · Runtime skill tool discards duplicate-name collisions (no user-facing warning) -- **Severity / category:** low / dead-inert · **Subsystem:** config-plugins-skills -- **Location:** `internal/plugins/activate.go:376` -- **Evidence:** -``` -skillTool.Run does `merged, _ := MergedSkillsLoaded(tool.defaultDir, tool.pluginRoots)` (activate.go:376), discarding the DuplicateName slice that mergeSkills computes (activate.go:311). The CLI `zero skills list` path now DOES surface dups (cli/skills.go:69-81), but the agent-facing skill tool silently drops a shadowed same-named skill. -``` -- **Impact:** When a plugin skill and a user skill (or two plugin roots) share a frontmatter name, the loser is silently dropped at agent runtime and the model/user is never told which skill body it actually got — a shadowed skill disappears with no diagnostic during a session. -- **Fix:** When the skill tool resolves a name that had a collision, append a one-line note to the tool Result (or emit a startup warning) listing the shadowed Loser path, reusing the already-returned DuplicateName slice. - -### L6 · pause/resume do an unlocked Get then a locked Update (non-atomic read-modify-write) -- **Severity / category:** low / concurrency · **Subsystem:** cron -- **Location:** `internal/cli/cron.go:215` -- **Evidence:** -``` -cronSetStatus: `job, err := store.Get(args[0])` (Get takes no lock, store.go:150) ... `job.Status = status; store.Update(job)` (Update takes the per-job lock, store.go:170-183). cronResume (cron.go:234-253) is the same Get-then-Update shape, overwriting the whole struct including FireCount/NextRunAt from the pre-read snapshot. -``` -- **Impact:** If a fireJob post-exec Mutate lands between resume's unlocked Get and resume's locked Update, the fire's FireCount++ and advanced NextRunAt are clobbered by resume's stale snapshot (resume recomputes NextRunAt from now() so scheduling stays sane, but FireCount silently regresses). The pause race is benign because fireJob re-reads Status under its lock and honors an external pause; resume has no such re-read. Low frequency (user-driven), cosmetic FireCount loss, no double-fire. -- **Fix:** Route pause/resume through store.Mutate so the read and the field update happen under the same per-job lock: read current, set Status (and for resume recompute NextRunAt from current.Expr), preserving current.FireCount, and write — instead of Get+Update on a snapshot. - -### L7 · Single-instance lock refuses startup on PID reuse (bare-PID lock file, no identity/start-time) -- **Severity / category:** low / robustness · **Subsystem:** daemon -- **Location:** `internal/daemon/lock.go:58` -- **Evidence:** -``` -pid, perr := readPidFile(path) -if perr == nil && pid > 0 && isAlive(pid) { - return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid) -} -``` -- **Impact:** The lock file stores only a bare PID. If a daemon dies uncleanly and the OS later recycles its exact PID for an unrelated live process, acquireLock sees isAlive(pid)==true and refuses to start with ErrAlreadyRunning, even though no zero daemon is running. The reclaim path (reclaimStaleLock, D6) is sound for the genuinely-dead case and the rename-aside race is well handled, but it never triggers here because the recycled PID reads as alive. Recovery requires manual lock removal. Low probability (exact PID reuse) and self-inflicted only after an unclean crash, but it is a real availability edge the bare-PID format cannot disambiguate. -- **Fix:** Record more than the PID in the lock file (e.g. PID + process start time, or a daemon-specific marker / boot id) and treat a live PID as the holder only when the recorded identity matches. On mismatch, treat as stale and reclaim via the existing rename-aside path. - -### L8 · Plugin hook event set disagrees with hooks package: plugins reject specialist events and cannot register them -- **Severity / category:** low / robustness · **Subsystem:** hooks -- **Location:** `internal/plugins/plugins.go:38-42,893-899; internal/plugins/activate.go:412-423` -- **Evidence:** -``` -hooks.go parseEvent (hooks.go:901-911) accepts six events. plugins.go HookEvent consts (lines 38-42) declare only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd, and parseHookEvent (plugins.go:893-898) switches on exactly those four, rejecting specialist* with "Expected beforeTool, afterTool, sessionStart, or sessionEnd." mapHookEvent (activate.go:412-423) likewise maps only those four and returns ok=false otherwise. -``` -- **Impact:** A plugin manifest declaring a specialistStart/specialistStop hook is rejected as a schema error even though hooks.json (user/project) accepts the same value. The two config surfaces for the identical Dispatcher have divergent valid-event sets, so plugin authors hit an inconsistent contract. Low because (per the finding above) those events do not actually fire from any surface today, so the practical effect is a confusing validation error rather than lost execution. This is the still-open prior reverification low (2026-06-13 line 97). -- **Fix:** Once the dead events are resolved (finding above), make the two sets agree: drive plugin validation/mapping off hooks.KnownEvents()/IsValidEvent (or add HookSpecialistStart/Stop consts + cases) so a single source of truth defines which events are valid, eliminating the drift. - -### L9 · Stdio request write blocks under client.mu with no ctx awareness -- **Severity / category:** low / robustness · **Subsystem:** mcp -- **Location:** `internal/mcp/client.go:282` -- **Evidence:** -``` -request() locks client.mu at line 264 and, still holding it, calls `client.writer.write(rpcMessage{...})` at line 282 before unlocking at 291. writer.write (protocol.go:148-163) does a blocking bufio Write+Flush to the child's stdin pipe with no context. Only the response wait (select at 293) is ctx-aware. -``` -- **Impact:** If a misbehaving child stops draining its stdin, the OS pipe buffer (~64 KiB) fills and write() blocks while holding client.mu, serializing and stalling every other in-flight/queued request on that server with no ctx-cancel escape. Mitigated because Close() uses a separate closeMu (not mu) and closes stdin, which unblocks the stuck write — so it is recoverable rather than a hard deadlock; impact is limited to a pathological non-reading child. -- **Fix:** Keep id allocation + pending registration under mu but perform the write outside mu (or write under a dedicated writeMu), and bound it: run the write in a goroutine and select on ctx.Done() so a stalled pipe surfaces ctx.Err() to the caller instead of blocking peers. - -### L10 · Child stderr capture keeps the head, dropping the likely-relevant tail on crash -- **Severity / category:** low / robustness · **Subsystem:** mcp -- **Location:** `internal/mcp/client.go:106` -- **Evidence:** -``` -boundedBuffer.Write: `if remaining := b.cap - b.buf.Len(); remaining > 0 { ... b.buf.Write(p[:remaining]) ... }` — once cap (64 KiB) is reached, all further bytes are discarded; only the earliest bytes are retained. connectStdio reads stderr only on initialize failure (lines 150-154). -``` -- **Impact:** Diagnostics quality: a server that prints a verbose startup banner (>64 KiB) and then dies with an error message at the end shows only the banner in the wrapped initialize error; the actual fatal line is dropped. Not a correctness/security issue, but it can make MCP startup failures hard to diagnose. -- **Fix:** Prefer a ring buffer that retains the LAST cap bytes (or head+tail split) so the terminal error is preserved; the bound is unchanged. - -### L11 · ChatGPT account-id silently omitted (no warning) when the id_token claim exists but is the wrong shape -- **Severity / category:** low / robustness · **Subsystem:** oauth-providers -- **Location:** `internal/provideroauth/chatgpt.go:227` -- **Evidence:** -``` -if ns, ok := claims[chatgptAuthClaimNamespace].(map[string]any); ok { if value, ok := ns[chatgptAccountClaim].(string); ok { return strings.TrimSpace(value), nil } } ... return "", nil — a present-but-non-string nested claim (or a present namespace with a missing key) falls through to `return "", nil`, the same as 'no id_token'. The caller (line 154-162) only warns on claimErr!=nil and only sets Account when account!="". -``` -- **Impact:** If OpenAI ever changes the claim's JSON shape (e.g. nests it one level deeper, or makes it a number), extraction returns ("", nil) with no error: token.Account stays empty, the chatgpt-account-id header is omitted on every Codex call (codex.go:172 sets it only when ok&&account!=""), and the user gets Cloudflare 401s on every request with zero diagnostic — neither the login warning nor a parse error fires. The current 401 is then indistinguishable from an expired token. -- **Fix:** Distinguish 'claim absent' from 'claim present but unusable': when claims[ns] exists but yields no non-empty string account id, return a non-nil error (e.g. 'account-id claim present but not a string') so the existing warning path at chatgpt.go:159 fires and the user is told to re-auth. - -### L12 · Latest()/List() order by second-granularity UpdatedAt with a SessionID lexical tiebreak; same-second updates with user-supplied IDs can resume the wrong session -- **Severity / category:** low / correctness · **Subsystem:** sessions -- **Location:** `internal/sessions/store.go:332-337,708-710` -- **Evidence:** -``` -func (store *Store) timestamp() string { return store.now().UTC().Format(time.RFC3339) } // second precision -... -if sessions[left].UpdatedAt == sessions[right].UpdatedAt { return sessions[left].SessionID < sessions[right].SessionID } -return sessions[left].UpdatedAt > sessions[right].UpdatedAt -``` -- **Impact:** UpdatedAt is RFC3339 (1s resolution). When two sessions are touched in the same wall-clock second (rapid back-to-back exec runs, or cron firing several jobs), the tiebreak is lexical SessionID, not real recency. exec_sessions.go:76 store.Latest() (used to resume the most recent session) and session.go:170 LatestResumable() can therefore land on the wrong session. For store-generated IDs the ID embeds UnixNano so lexical order roughly tracks recency, but for caller-supplied SessionIDs (Create/Fork/Child accept an explicit SessionID) the order is arbitrary, so `--continue`-style resume can attach to a stale session. Low: narrow timing window and mostly mitigated for auto-generated IDs. -- **Fix:** Persist a higher-resolution UpdatedAt (RFC3339Nano) so same-second ties are broken by true time, and keep SessionID only as a final deterministic tiebreak; or carry a monotonically increasing per-store update counter into the sort key. - -### L13 · Scheduler leaks a per-job context when a bounded job completes via MaxRuns -- **Severity / category:** low / resource-perf · **Subsystem:** specialist-swarm -- **Location:** `internal/swarm/scheduler.go:280` -- **Evidence:** -``` -runs := job.incRuns() -if max := job.schedule.MaxRuns; max > 0 && runs >= max { - return -} -``` -- **Impact:** Each scheduled job gets its own ctx, cancel := context.WithCancel(s.ctx) (Add, line 182). On the MaxRuns-completion path run() returns without ever calling job.cancel(); only Cancel() and Close() invoke it. The derived context (and the goroutine the runtime spawns to propagate parent cancellation) is retained until the whole scheduler/Swarm context is cancelled at session end. A long session that adds many bounded daily/interval jobs accumulates one leaked context per completed job. Bounded by session lifetime and small, but it is a real, avoidable leak the codebase elsewhere is careful about. -- **Fix:** Add `defer job.cancel()` at the top of run() (alongside the existing `defer s.wg.Done()` / `defer s.forget(job.id)`), so the job's context is released on every exit path including MaxRuns completion. - -### L14 · terminateProcess returns success without killing surviving group members when the leader PID was already reaped -- **Severity / category:** low / robustness · **Subsystem:** specialist-swarm -- **Location:** `internal/background/process_posix.go:55` -- **Evidence:** -``` -target := pid -if pgid, err := syscall.Getpgid(pid); err == nil { - if pgid == pid { - target = -pid - } -} else if processGoneError(err) { - return nil // already gone -} -``` -- **Impact:** launchBackgroundProcess (specialist/exec.go:797) runs a command.Wait() goroutine that reaps the background specialist leader as soon as it exits. If the leader exits on its own but had forked a detached grandchild into the same process group, a later TaskStop calls terminateProcess(pid): Getpgid(reaped pid) returns ESRCH, the code treats it as 'already gone' and returns nil — never signalling the surviving group. TaskStop reports success while grandchildren (e.g. a dev server the sub-agent started and the parent zero exec exited without reaping) keep running. The full-tree kill the M6 fix added only works while the leader is still alive to identify the group. -- **Fix:** Persist the child's pgid at launch (it equals the pid under Setpgid) and, when the leader is gone, fall back to syscall.Kill(-pgid, ...) to sweep any survivors instead of returning nil immediately on a reaped leader. - -### L15 · Data race on execSession.lastUsedAt: prune reads it under manager.mu while touch writes it under session.mu -- **Severity / category:** low / concurrency · **Subsystem:** tools -- **Location:** `internal/tools/exec_command.go:123 vs :278` -- **Evidence:** -``` -sessionToPruneLocked (called from store under manager.mu) sorts with `sessions[i].lastUsedAt.Before(sessions[j].lastUsedAt)` (line 123), reading lastUsedAt with only manager.mu held. touch() writes it under a DIFFERENT lock: `session.mu.Lock(); session.lastUsedAt = time.Now(); session.mu.Unlock()` (lines 277-279). snapshot() correctly reads it under session.mu (line 291), but the prune path does not. The two paths use disjoint mutexes, so there is no happens-before between the write and the read. -``` -- **Impact:** When a new exec_command at the session cap triggers eviction (store -> sessionToPruneLocked) concurrently with a write_stdin poll calling touch() on another live session (or with removeCompletedLater/stopAll churn), this is a genuine data race on a time.Time (a multi-word struct), which `go -race` would flag and which can yield a torn/garbage timestamp that mis-selects the prune victim. Unexercised by current tests (no test drives concurrent touch+prune), so it is latent rather than observed. -- **Fix:** Read lastUsedAt through the session lock in the prune comparator (snapshot a `[]struct{id int; last time.Time}` by calling a small locked getter per session before sorting), or store lastUsedAt as an atomic.Int64 of UnixNano accessed the same way from both touch and prune. - -### L16 · truncateRunes/middleTruncate truncate by rune COUNT but are used with display-WIDTH budgets — wide/CJK content overshoots its budget -- **Severity / category:** low / correctness · **Subsystem:** tui-render -- **Location:** `internal/tui/view.go:918` -- **Evidence:** -``` -truncateRunes: `runes := []rune(text); if len(runes) <= limit { return text }; ... return string(runes[:limit-1]) + "…"` — purely rune-count based. middleTruncate (startup.go:138-153) likewise slices `runes[:front]`/`runes[len-back:]`. Both are called with display-width budgets: diffCardBody `truncateRunes(strings.TrimPrefix(line, "+"), textBudget)` (rendering.go:1511), then diffBodyLine pads `pad := textBudget - lipgloss.Width(text)` (rendering.go:1536); toolCardHead `truncateRunes(arg, maxInt(12, width/3))` (1336) and `middleTruncate(shown, maxInt(16, width/2))` (1328); grep/read/bash card budgets (1594/1628/1711). -``` -- **Impact:** For a diff/grep/tool line of double-width (CJK/emoji) characters, truncateRunes keeps up to `limit` runes each ~2 cells wide, so the kept text can be ~2x its display budget. In diffBodyLine the band pad then computes negative and is skipped, so the add/del tint band no longer fills to the card edge and rows of CJK code render with uneven/short solid bands. Overflow past the card width is contained because toolCard re-clamps every body line with the width-aware fitStyledLine (rendering.go:1387) and the head with fitStyledLine (1380); the residual impact is cosmetic misalignment of the diff/tool band for wide-char content, not layout breakage. -- **Fix:** Add a display-width-aware truncate (loop accumulating lipgloss.Width per rune until budget, reserving 1 cell for the ellipsis) and use it where the argument is a width budget; or route these unstyled budgets through the existing width-aware fitStyledLine/truncateStyledLine primitives instead of rune-count truncateRunes. - -### L17 · Cancelled run's usage events/rows are applied to whatever session is active when the goroutine returns, even after /resume switched sessions -- **Severity / category:** low / correctness · **Subsystem:** tui-render -- **Location:** `internal/tui/model.go:1319` -- **Evidence:** -``` -In the agentResponseMsg `msg.runID != m.activeRunID` (flushing) branch, the session-EVENT flush is correctly routed to the recorded session (`if flushSessionID == m.activeSession.SessionID { appendSessionEvents } else { appendSessionEventsTo(flushSessionID, ...) }`, model.go:1335-1338). But the usage events just above are applied unconditionally to the current model: `for _, event := range msg.usageEvents { m, usageRows = m.recordUsageEvent(...); for _, row := range usageRows { m.transcript = appendTranscriptRow(m.transcript, row) } }` (1319-1325) — no flushSessionID gating. -``` -- **Impact:** If the user cancels an in-flight run and then /resume's a DIFFERENT session before the cancelled run's goroutine returns its accumulated response, the cancelled run's usage tracker delta and any usage transcript rows land in the now-active different session's in-memory transcript/usage readout. Narrow timing window, in-memory display only (the durable session-event flush is already correctly routed), so impact is a transient mis-attributed usage line, not persisted corruption. -- **Fix:** Only record usage events and append usage rows when `flushSessionID == m.activeSession.SessionID`; when the run was recording into another session, persist its usage to that session's tracker (or drop the display rows) rather than mutating the active model. - -### I1 · Local control socket handleConn has no read deadline (owner-only mitigates) -- **Severity / category:** info / robustness · **Subsystem:** daemon -- **Location:** `internal/daemon/server.go:228` -- **Evidence:** -``` -hello, err := ReadControl(conn) -if err != nil { return } -... -cmd, err := ReadControl(conn) // both reads unbounded -``` -- **Impact:** The locally-accepted control connection performs the hello + command reads with no deadline. Unlike the remote bridge this is gated by an owner-only Unix socket (secureSocketParent/hardenSocketFile), so the only peer able to stall it is the socket owner — the same principal that controls the daemon — making it a non-threat in practice. Noted because the same unbounded-read code is shared with the remote path (where it IS exploitable, see the medium findings); a defensive deadline here would make handleConn safe regardless of how the conn was obtained. -- **Fix:** Optionally add a modest read deadline around the two handshake ReadControl calls in handleConn so the protection is intrinsic to the function rather than relying on the caller (local socket perms / remote bridge handshake bound). - -### I2 · Audit append failures are silently swallowed by the dispatcher -- **Severity / category:** info / robustness · **Subsystem:** hooks -- **Location:** `internal/hooks/dispatch.go:223-249` -- **Evidence:** -``` -recordStarted (line 227 `_, _ = dispatcher.audit.AppendStarted(...)`) and recordCompleted (line 240 `_, _ = dispatcher.audit.AppendCompleted(...)`) discard the error. AppendStarted/AppendCompleted can fail on lock-acquire timeout (lock.go:86 "timed out acquiring audit lock" after 10s) or any I/O/marshal error. -``` -- **Impact:** Over a session, if the cross-process audit lock is contended/stranded (a crashed holder's lock not yet 60s stale, or a permissions issue under XDG_DATA_HOME), every hook still runs and the tool proceeds normally, but the audit JSONL silently gains no record — there is no log line, metric, or diagnostic that the security/compliance audit trail dropped events. For a feature whose stated purpose is an auditable record of policy-hook executions, silent gaps undermine the trail. Informational because the dispatcher fails OPEN by design and the audit store is optional. -- **Fix:** Surface a one-time/rate-limited warning (or a diagnostic on DispatchOutcome) when an audit append errors, so an operator can tell the audit trail is incomplete rather than assuming the absence of a record means the hook did not run. - -## 4. Reconciliation with prior audit docs - -Reconciled against `docs/audit/2026-06-10-deep-audit.md`, `2026-06-10-deep-audit-status.md`, and `2026-06-13-reverification.md`. Each prior item for an audited area was re-checked against current code. Summary: **64 fixed, 14 partial, 12 still-open** (deduplicated across overlapping subsystem reports). The still-open and partial items are the ones that matter for remediation; the 12 still-open are all re-derived above as current findings (or noted as out-of-scope dead code). - -#### Still open (12) - -| Subsystem | Prior finding | Location | Evidence | -|---|---|---|---| -| agent-loop | Dead stores to requestEvent after always-allow, and unreachable fallbackPermissionEvent | `internal/agent/loop.go:473` | In the AlwaysAllow case requestEvent.GrantMatched/Grant are still set at loop.go:776-778 but the model-visible event is rebuilt fresh from the sandbox decision at 830-840, and fallbackPermissionEvent (loop.go:1694) is st | -| agent-loop | Reactive mid-stream retry never forwards retried assistant text to OnText | `internal/agent/loop.go:183` | The reactive retry still collects with only OnUsage (loop.go:264-266), deliberately omitting OnText to avoid duplicating the already-forwarded partial; documented at 258-263. Behavior unchanged (intentional trade-off). | -| agent-loop | OnContext / MeasureContext context-utilization pipeline has no consumer on any surface | `internal/agent/types.go:183` | Grep finds no `OnContext:` assignment outside agent tests; MeasureContext is only called at loop.go:165 behind the never-set OnContext guard. Reported again above as a current low finding. | -| cli-usage-streamjson | zero --skip-permissions-unsafe silently discards all trailing arguments (M81) | `internal/cli/app.go:149` | app.go:234-243 — `rest` is only scanned for misplaced --add-dir; all other trailing args are dropped and the TUI is launched. Confirmed still-open at 2026-06-13 reverification and unchanged in current code. | -| config-plugins-skills | ResolvedConfig.MCP is computed but never read; provider-command MCP servers are silently dropped | `internal/config/resolver.go:95` | resolver.go:106 still sets MCP: cfg.MCP with zero readers in internal/cli (grep 'resolved.MCP' empty); ResolveMCP (resolver.go:115-133) still seeds defaults+file+overrides only and never calls LoadProviderCommand, so ZER | -| config-plugins-skills | maxTurns<=0 is silently ignored | `internal/config/resolver.go:144,177,506` | All three merge sites still gate on `> 0` (resolver.go:152,191,539) and Resolve() still has no MaxTurns validation, unlike deferThreshold (:66-68) and maxTeamSize (:70-72); a 0/negative value is silently replaced by defa | -| config-plugins-skills | plugin vs standalone hook-event validators disagree | `internal/plugins/plugins.go:894` | plugins.parseHookEvent (plugins.go:894) + mapHookEvent (activate.go:412) still accept only the four non-specialist events; hooks.parseEvent/IsValidEvent (hooks.go:888,906) still accept six. The sets still disagree. | -| config-plugins-skills | config/contracts.go (ContractGap API) is unused dead code | `internal/config/contracts.go:12` | Per the 2026-06-13 reverification (line 95), ContractGap/DefaultContractGaps/FindContractGapsByMilestone are referenced only by contracts_test.go; no production change in this area since (outside my named focus but noted | -| cron | cron run start-up reconcile silently cancels --run-now jobs | `internal/cli/cron_run.go:115` | reconcileOverdue (cron_run.go:105-130) still reschedules any NextRunAt before nowMin without firing, with no run-now exemption, and Job has no run-now marker. Reproduced: --run-now job (NextRunAt=now at add) pushed to ne | -| hooks | Plugin/hooks event-set disagreement (plugin parseHookEvent accepts only 4 events; hooks accepts 6) | `internal/plugins/plugins.go:894 (2026-06-13 reverification l` | plugins.go:38-42 still declares only 4 HookEvent consts and parseHookEvent (plugins.go:893-898) still switches on those 4, while hooks.go parseEvent (hooks.go:901-911) accepts all 6. Re-derived above as a low finding. | -| sandbox | OnSandboxDecision callback option is set up to fire but never wired by any caller (dead code) | `internal/tools/registry.go:108` | This field lives in internal/tools/registry.go, outside the sandbox package's audited scope; the 2026-06-13 reverification (line 107) confirms repo-wide grep still finds OnSandboxDecision only at the declaration, nil-che | -| sessions | Second-granularity RFC3339 timestamps make Latest()/list ordering pick the wrong session on same-second ties | `internal/sessions/store.go:555` | store.go:709 still formats with time.RFC3339 (1s) and List tiebreak (332-336) is lexical SessionID; re-reported as the low finding above. | - -#### Partially fixed (14) - -| Subsystem | Prior finding | Location | Evidence | -|---|---|---|---| -| cli-usage-streamjson | streamjson secret patterns mangle ordinary text in every stream-json output event (M48) | `internal/streamjson/streamjson.go:310` | sk- and bearer patterns are now word-boundary + length anchored (streamjson.go:313,317) and TestRedactStringDoesNotOverMatchProse covers task-list/bearer; but the api[_-]?key pattern at streamjson.go:314 still over-match | -| config-plugins-skills | Hooks subsystem is loaded and listed but never executed (no event dispatch, no edit command) | `internal/hooks/hooks.go:372` | Now partially wired: beforeTool/afterTool DO dispatch from the agent loop (internal/agent/loop.go:914,937) and a CLI edit surface exists (internal/cli/hooks_manage.go `zero hooks add/...`). But the other four events (ses | -| config-plugins-skills | skills.Get/Duplicates unused; duplicate-name collisions never warned | `internal/skills/skills.go:91 + consumers` | skills.Duplicates is now consumed by the CLI list path (internal/cli/skills.go:69-81 emits per-collision warnings to stderr), so collisions ARE surfaced for `zero skills list`. But the agent-facing skill tool still drops | -| cron | Cron store has no inter-process locking: paused/edited jobs clobbered; concurrent schedulers double-fire | `internal/cli/cron_run.go:173` | A real cross-process lock now exists (internal/cron/lock.go: O_EXCL sibling .lock with atomic stale reclaim) and is taken by Update/Mutate/Remove/AppendRun (store.go:174,196,219,266). fireJob now claims-before-fire u | -| cron | Cron Next double-fires wall-clock schedules on DST fall-back (repeated hour) | `internal/cron/schedule.go:171` | A fall-back collapse guard was added (schedule.go:191-209, sameWallClockMinute) and is covered by TestNextDSTFallBackDoesNotRepeatHour. But the guard only engages when `after` is exactly minute-aligned (schedule.go:205 ` | -| hooks | Hooks subsystem is loaded and listed but never executed (no event dispatch) | `internal/hooks/hooks.go:372 (2026-06-10 deep audit)` | The core gap is closed for the two tool events: Dispatcher.Dispatch is wired into the agent loop at internal/agent/loop.go:914 (beforeTool, with veto routed through blockedByHookResult at loop.go:955) and loop.go:937 (af | -| mcp | Client readLoop misroutes server-to-client requests as responses (id collision) and never answers ping | `internal/mcp/client.go:302` | client.go now has a single readLoop (331-355) that dispatches strictly by `client.pending[id]` and DROPS unmatched id-bearing messages (responses==nil) instead of corrupting a waiter, and skips id==nil notifications (338 | -| mcp | Stdio request write blocks indefinitely while holding client.mu and ignores ctx cancellation | `internal/mcp/client.go:246` | The unbounded RESPONSE wait was fixed: mu is released at client.go:291 before the ctx-aware select on the response channel (293-311), so a hung server no longer holds the lock during the wait. But the WRITE itself (line | -| mcp | 1 MiB SSE scanner token cap permanently kills the SSE session on large messages | `internal/mcp/network_client.go:637` | maxSSEEventBytes raised to 8MiB (network_client.go:658) and scanner.Buffer uses it (662), plus an aggregate multi-line data cap (707-713), so the common large-message case no longer trips ErrTooLong. But a single SSE lin | -| oauth-providers | Health probe SSRF blocklist and credentials defeated by redirects and DNS rebinding | `internal/providerhealth/providerhealth.go:285` | DNS rebinding TOCTOU is fixed: safeDialContext (providerhealth.go:442-474) re-resolves at dial time, validates every resolved addr, and dials the validated IP literal via dialValidatedAddrs. Internal-redirect escape is f | -| oauth-providers | OpenAI adapter drops tool calls lacking an id; agent loops forever on retry | `internal/providers/openai/tool_state.go:95` | tool_state.go:235-240 closeOpen now emits StreamEventToolCallDropped for an id-less/name-less call that streamed an id/name/arguments, and loop.go:300-306 converts a dropped-call-with-no-other-calls turn into a single re | -| sessions | No fsync before rename/append: metadata.json can be empty after a crash, silently hiding the session | `internal/sessions/store.go:632` | writeMetadata (store.go:779-804) now writes a temp file via writeFileSync (fsyncs data), os.Rename, then syncDir(parent) — metadata is fully crash-safe. BUT the sibling events.jsonl append (store.go:606-616) still has no | -| specialist-swarm | Post-SIGTERM grace polling can SIGKILL a recycled PID | `internal/background/process_posix.go:38-50` | terminateProcess now resolves pgid up front and signals the group (-pid) only when pid leads its own group, returning nil early if Getpgid reports the leader already gone (process_posix.go:55-61); group-leader targeting | -| tools | escalate_model tool is implemented and registry-aware but never included in any Core* tool set | `internal/tools/registry.go:224` | Still absent from CoreReadOnly/Write/Shell/Network/CoreTools sets (registry.go:204-266) and from the TUI registry (cli/app.go:660 newCoreRegistryScoped only iterates CoreToolsScoped). It IS now registered, but only in he | - -#### Fixed (64) - -| Subsystem | Prior finding | Location | Evidence | -|---|---|---|---| -| agent-loop | Truncated/filtered responses (FinishReason) are produced by every provider but never consumed by the agent loop | `internal/agent/loop.go:233` | FinishReason is now consumed at loop.go:284 (result.FinishReason = collected.FinishReason) and loop.go:502/539, exposed via Result.TruncationNotice() (types.go:278-292), and surfaced to users in both internal/tui/model.g | -| agent-loop | "Always allow" permission decision is converted into a denial when grant persistence fails (always, when Options.Sandbox | `internal/agent/loop.go:468` | PermissionDecisionAlwaysAllow case (loop.go:763-779) now sets permissionGranted=true unconditionally and only attempts persistPermissionGrant when options.Sandbox != nil, ignoring a persist error (the call is honored reg | -| agent-loop | estimateTokens ignores image attachments and the compaction trigger ignores tool definitions, so the context budget unde | `internal/agent/compaction.go:58` | estimateTokens adds len(message.Images)*imageTokenEstimate (compaction.go:114); maybeCompact now takes the exposed tools and counts estimateToolDefTokens(tools) into both the threshold and shrink checks (compaction.go:33 | -| agent-loop | Compaction summarizer provider calls bypass OnUsage — their token spend is invisible to usage accounting | `internal/agent/compaction.go:376` | summarizeMessagesOnce now calls CollectStreamWithOptions(ctx, stream, CollectOptions{OnUsage: onUsage}) (compaction.go:501); onUsage is threaded from options.OnUsage via newCompactionState -> summarizeClosure. | -| agent-loop | Mid-stream context cancellation is returned as a flattened errors.New string; the ctx.Err() identity check is unreachabl | `internal/agent/loop.go:188` | loop.go:272-275 now checks `if ctx.Err() != nil { return result, ctx.Err() }` BEFORE the `if collected.Error != ""` return at 276-279, so a canceled stream returns the wrapped context.Canceled and errors.Is succeeds. | -| agent-loop | Duplicate items-schema assignment in propertyToRuntimeMap | `internal/agent/loop.go:1072` | propertyToRuntimeMap (loop.go:1978-1980) assigns schema["items"] exactly once under `if property.Items != nil`. | -| agent-loop | Project-guidelines truncation can split a multibyte UTF-8 rune in the system prompt | `internal/agent/system_prompt.go:89` | projectGuidelines walks back to a rune boundary before cutting: `for cut > 0 && !utf8.RuneStart(content[cut]) { cut-- }` (system_prompt.go:255-258); compaction_preserve.go capBody does the same (304-307). | -| cli-usage-streamjson | Ctrl+C with -o json ends the JSON event stream with no error/done terminal event (M82) | `internal/cli/exec.go:500` | exec.go:567-595 emits a terminal error + done event for both execOutputStreamJSON (errorEvent+runEnd) and execOutputJSON (writeJSONLine type=error then type=done, exit_code=130) on context cancellation. | -| cli-usage-streamjson | zero exec --list-tools -o json ignores the requested JSON format and prints plain text (M83) | `internal/cli/exec.go:182` | exec.go:206-219 branches on outputFormat: stream-json -> writeExecStreamJSONFinal, json -> writeExecToolListJSON (exec_tools.go:103), else plain text. | -| cli-usage-streamjson | Per-event 'model' field persisted under --allow-escalation is never read by the usage report, mispricing escalated runs | `internal/usage/report.go:17` | report.go:138-141 prefers payload.Model (set on escalation runs) before falling back to modelBySession; exec.go:555-556 writes payload["model"]=currentModel only under options.allowEscalation, and currentModel is reassig | -| cli-usage-streamjson | One corrupt session aborts the entire zero usage report (M101) | `internal/cli/usage.go:46` | usage.go:47-51 — a ReadEvents error increments set.skipped and continues instead of returning, so a single unreadable session no longer aborts the report. (Minor: set.skipped is never surfaced to the user — info-level on | -| cli-usage-streamjson | Fork duplicates provider_usage events, double-counting usage in zero usage report (M100) | `internal/sessions/store.go:387` | store.go:431-436 — Fork explicitly skips EventUsage when copying parent events into the fork ('Do NOT copy usage accounting into the fork'), so report aggregation over parent+fork no longer double-counts. | -| cli-usage-streamjson | zero usage hard-fails outside a git repository (M72) | `internal/cli/usage.go:121` | usage.go:122-131 — net-LOC is best-effort: resolveWorkspaceRoot/inspectChanges errors are swallowed and DiffStat degrades to zero, so the token report still renders outside a git repo. | -| cli-usage-streamjson | zero mcp list --json leaks MCP server env/header secret values (M74) | `internal/cli/extensions.go:186` | extensions.go:220-223 redacts via redactMCPServerConfigs (env/header values -> [REDACTED], URL creds via redactMCPURL at 236-256) and then RedactValue before writePrettyJSON; the plain-text path also runs RedactString (2 | -| cli-usage-streamjson | redactURLPasswords recompiles its regexp on every RedactString call (M93) | `internal/redaction/redaction.go:362` | urlWithCredsPattern is now a package-level var (redaction.go:424) compiled once; redactURLPasswords (426) reuses it. | -| cli-usage-streamjson | redaction opaque/custom-scheme Authorization not redacted (M12) | `internal/redaction/redaction.go:92` | headerPattern makes the scheme optional and captures the whole value to EOL (redaction.go:92, 186-194); opaque_auth_test.go verifies opaque tokens, custom schemes, and Proxy-Authorization are redacted while a known schem | -| cli-usage-streamjson | secrets.Redact leaves a private key un-redacted when an inner pattern matches inside it (M90) | `internal/secrets/scanner.go:81` | scanner.go:91-99 replaces longest matches first (order sorted by len desc) so the whole PEM PRIVATE KEY block is redacted before any shorter nested match corrupts it. | -| cli-usage-streamjson | Secret scanner misses modern OpenAI key formats / private key block header (M158) | `internal/secrets/scanner.go:35` | scanner.go:41 openai_key body is `sk-[A-Za-z0-9_-]{20,}` (covers sk-proj-/sk-svcacct- and -/_), and scanner.go:44 matches the full BEGIN..END PRIVATE KEY block. | -| cli-usage-streamjson | Stream-json / status / tool-output truncation slices strings mid-rune (M153/M155/M165) | `internal/cli/exec_writer.go:412` | exec_writer.go:401-431 — truncateForStatus and truncateForStreamJSONOutput both call cutRuneBoundary, which walks back to the last UTF-8 rune boundary at or before n. | -| config-plugins-skills | config writer performs a non-atomic in-place write of the user config | `internal/config/writer.go:53` | writeConfigFile (writer.go:149-184) now uses os.CreateTemp in the target dir, tmp.Chmod(0o600), Write, Close, then os.Rename(tmpPath, path) with a deferred os.Remove cleanup (lines 161-182) — the documented write-to-temp | -| config-plugins-skills | parseThinkTags setting silently dropped on every profile merge (L17) | `internal/config/resolver.go (mergeProfile)` | mergeProfile now preserves it: resolver.go:420-422 `if next.ParseThinkTags != nil { base.ParseThinkTags = next.ParseThinkTags }`, and it is read end-to-end by providers/factory.go:97-98 (parseThinkTagsForProfile) into op | -| config-plugins-skills | Plugin-declared skill paths are not merged into skill lookup (inert) | `internal/skills/skills.go (Load single-root)` | Plugin skills are now merged at runtime: plugins.NewSkillTool (activate.go:340) overlays default dir + plugin SkillRoots via mergeSkills, and it is registered when SkillRoots are present (cli/plugin_activate.go:53-54), i | -| cron | cron add --recipe R silently discards the user's cron expression | `internal/cli/cron.go:112` | cronAdd now consumes the positional expression BEFORE applying recipe defaults: `if len(positional) == 1 && expr == "" { expr = positional[0] }` (cron.go:113-115) precedes the recipe block whose guard is `if expr == "" { | -| cron | cron run records lose the failure reason: exec errors go to the discarded stdout stream | `internal/cli/cron_run.go:150` | fireJob now recovers the failure detail from the stream-json error event on stdout: `if streamErr := extractStreamJSONError(outBuf.String()); streamErr != "" { detail = streamErr }` then `rec.Error = cronTruncate(detail, | -| cron | promptExcerpt/cronTruncate byte-slice strings mid-rune (invalid UTF-8) | `internal/cli/cron.go:275` | promptExcerpt now cuts on a rune boundary: `cutRuneBoundary(p, 47) + "…"` (cron.go:278), and cronTruncate does `cutRuneBoundary(s, max) + "…"` (cron_run.go:272) instead of the prior raw byte slice. | -| hooks | AuditStore.append is O(n^2): re-reads and re-parses the entire audit JSONL on every append | `internal/hooks/hooks.go:503 (2026-06-10) / reverified still-` | append() no longer calls ReadEvents(); it calls lastSequence() (hooks.go:515) which os.Open + Stat + ReadAt only the trailing 8KB window (hooks.go:550-602: `const tailWindow = 8 * 1024`, `file.ReadAt(buf, start)`), parse | -| hooks | Cross-process sequence collision on the shared audit JSONL (read-then-append not atomic across processes) | `internal/hooks/hooks.go append (implied by 2026-06-13 line 6` | append() now wraps the lastSequence()+write in a cross-process O_EXCL file lock via store.lockAudit() (hooks.go:509-513 -> lock.go:33-90), with stale-lock reclaim (lock.go:97-108) mirroring cron/oauth. TestAuditStoreSequ | -| mcp | Unbounded Content-Length allocation lets a peer crash the whole process | `internal/mcp/protocol.go:69` | protocol.go:17 defines maxMessageBytes=64MiB; readHeaderFramed caps Content-Length (lines 121-123: `if parsed > maxMessageBytes { return ... exceeds ... limit }`) before `body := make([]byte, contentLength)` (138); readL | -| mcp | MCP stdio transport uses LSP Content-Length framing instead of MCP newline-delimited JSON | `internal/mcp/protocol.go:42` | read() (protocol.go:54-76) now reads newline-delimited JSON by default (isJSONStart -> decodeMessage) and only falls back to LSP Content-Length framing when the first line is not JSON; write() (148-163) emits one JSON ob | -| mcp | zero mcp list --json leaks MCP server env/header secret values | `internal/cli/extensions.go:186` | extensions.go:222 wraps cfg.Servers in redactMCPServerConfigs (236-255), which replaces every Env and Header value with `[REDACTED]` and runs redactMCPURL on the URL (252) before writePrettyJSON; redactMCPURL (mcp_tools. | -| mcp | Child stderr captured into an unbounded bytes.Buffer for the entire server lifetime | `internal/mcp/client.go:98` | stderr is now `&boundedBuffer{cap: maxStderrCapture}` (client.go:134, cap=64KiB at line 91); boundedBuffer.Write (103-115) discards bytes past cap. (Tail-vs-head retention is a separate low finding, not unboundedness.) | -| mcp | Streamable-HTTP SSE response decoding takes the first event instead of the matching response | `internal/mcp/network_client.go:621` | decodeSSERPCMessage (network_client.go:612-652) now skips events whose decoded message has a non-empty Method (server-initiated notifications/requests) — `if candidate.Method != "" { return true }` (636-638) — and return | -| oauth-providers | Gemini adapter never reads cachedContentTokenCount; CachedInputTokens always 0 | `internal/providers/gemini/types.go:88` | gemini/types.go:107 now declares `CachedContentTokenCount int \`json:"cachedContentTokenCount"\``; provider.go:225 `state.cachedTokens = payload.UsageMetadata.CachedContentTokenCount` and provider.go:283 emits `CachedInp | -| oauth-providers | HTTP 529 classified as rate-limit but excluded from the 429/503 retry policy | `internal/providers/providerio/retry.go:107` | retry.go:108-110 `func ShouldRetryStatus(code int) bool { return code == http.StatusTooManyRequests // code == http.StatusServiceUnavailable // code == 529 }` — 529 now retried; doc comment updated to include Anthropic ' | -| sandbox | macOS sandbox-exec profile blocks /tmp and /dev/null, breaking most wrapped shell commands | `internal/sandbox/runner.go:262` | runner.go:412-436 sandboxWritableDevices (/dev/null,/dev/zero,/dev/stdin,...) and sandboxWritableSubpaths (/tmp,/private/tmp,/var/tmp,/var/folders,/dev/fd) are now emitted by seatbeltWriteRule (runner.go:559-581, AllowTe | -| sandbox | apply_patch misparses unified-diff body lines starting with '--- '/'+++ ' as file paths | `internal/tools/apply_patch.go:161` | risk.go:291-327 patchHeaderPaths now tracks hunk state (inHunk, oldRemaining/newRemaining from parsePatchHunkCounts) and decrements per body line, so '--- '/'+++ ' header parsing at risk.go:319-324 is only reached OUTSID | -| sandbox | Interactive-command detector flags pager/REPL names inside quoted arguments (splitShellSegments not quote-aware) | `internal/sandbox/safe_command.go:138` | safe_command.go:474-583 splitShellSegments is now a full quote/substitution-aware scanner (inSingle/inDouble state, substStack for $()/backtick, backslash escapes); operators inside quotes are literal. Detection also anc | -| sandbox | Grant store prefix/substring safety: Lookup trims while stored writer key may not be normalized (trim asymmetry => load | `internal/sandbox/grants.go:146` | grants.go:556-568 readState rebuilds a `normalized` map keyed by `key := strings.TrimSpace(name)` and re-keys every bucket; normalizeStoredGrant(key, grant) compares against the trimmed key (grants.go:614). Lookup/Revoke | -| sandbox | Destructive/network/installer classification recompiles N regexes per shell call | `internal/sandbox/risk.go:53` | risk.go:12-55 destructiveCommandPattern, pipedInstallerPattern, unparseableNetworkPattern, and destructiveExtraPatterns are all package-level regexp.MustCompile vars compiled once at init, not per call. | -| sessions | Torn append (crash mid-write) permanently bricks a session: ReadEvents hard-fails on any malformed JSONL line | `internal/sessions/store.go:547` | store.go:677-704 now computes tornTailPossible (trailing byte != newline) and lastNonEmpty, and breaks (drops the partial record) only when the malformed line is the last non-empty line AND the file lacks a trailing newl | -| sessions | Fork duplicates provider_usage events and rewrites their timestamps, double-counting and re-dating usage in `zero usage | `internal/sessions/store.go:387` | Fork (store.go:430-442) explicitly `if event.Type == EventUsage { continue }` so usage events are never copied into the fork; usage/report.go BuildReport iterates every session's events (cli/usage.go collectUsageData), s | -| sessions | `zero sessions tree` is O(nodes x sessions) disk reads (full store re-list per tree node) | `internal/sessions/lineage.go:140` | Tree (lineage.go:127-167) now does a single store.List() up front, indexes byID and childrenByParent in memory, and treeFrom (169-189) recurses over the in-memory maps with no per-node disk scan. | -| sessions | Naive byte-slice truncation can split multi-byte UTF-8 runes in prompts and titles (replay) | `internal/sessions/replay.go:237` | replay.go:382-393 cutPromptRuneBoundary backs up while !utf8.RuneStart(s[n]) before slicing; it is used at the prompt-truncation sites (310-314) and payloadPreview (376), so replay/preview truncation is rune-safe. | -| sessions | execSessionRecorder.append latches AppendEvent errors into recorder.err but no caller reads it (silent session-persisten | `internal/cli/exec_sessions.go:116-124` | warnIfRecordingFailed (exec_sessions.go:128-132) now surfaces recorder.err to stderr and is deferred by both runExec (exec.go:464) and the spec-draft path (exec_spec.go:90). | -| specialist-swarm | Specialist sessions launched without a description are unresumable (AgentName never recorded / recorded as garbage) | `internal/specialist/exec.go:166` | exec.go:250-255 now always emits --session-title: when Description is empty it passes the bare manifest name (`args = append(args, "--session-title", name)`), so specialistAgentName resolves to the real specialist name a | -| specialist-swarm | Specialist children always run with --auto high (PermissionModeUnsafe): one Task approval silently grants unprompted she | `internal/specialist/exec.go:150` | specialistAutonomy() (exec.go:125-132) maps only an explicit 'unsafe' parent mode to 'high' and everything else (including empty) to 'low'; BuildArgs/BuildResumeArgs (exec.go:230,282) call it with input.PermissionMode. T | -| specialist-swarm | Race: duplicate specialist stop/usage accounting events — check-then-append dedupe is not atomic | `internal/specialist/accounting.go:72` | recordSpecialistStop and appendSpecialistUsageRollup now route through appendSpecialistEventOnce → Store.AppendEventUnlessExists (accounting.go:60,105,160-172), which performs the existence check and the append atomicall | -| specialist-swarm | Foreground specialist run fails entirely when a child stream-json line exceeds 1 MiB | `internal/specialist/streamer.go:43` | ParseStream (streamer.go:41-73) now uses bufio.NewReader + ReadString('\n') with no per-line cap, and the live foreground path runChildProcess (exec.go:735-759) likewise uses bufio.NewReader.ReadString. The 1 MiB Scanner | -| specialist-swarm | SetPID failure path deletes the prompt file under a running child and records no terminal accounting/status | `internal/specialist/exec.go:346` | runBackground SetPID-failure branch (exec.go:451-466) now terminates the orphaned child (background.TerminateProcess(pid), joining any kill error), calls manager.UpdateStatus(StatusError) and recordSpecialistStop, in add | -| specialist-swarm | POSIX background-task kill terminates only the leader PID; the specialist's subprocess tree leaks on SIGKILL escalation | `internal/background/process_posix.go:50` | launchBackgroundProcess now calls background.ConfigureChildProcessGroup(command) before Start (exec.go:791), which sets Setpgid (process_posix.go:24-29), and terminateProcess signals the negative PID (the whole group) wh | -| specialist-swarm | formatTaskOutput is dead code | `internal/specialist/output_tool.go:244` | grep across the repo finds no `formatTaskOutput(` definition or call; the only formatter in use is formatTaskOutputSummary, called from readOutput (output_tool.go:154). The dead function was removed. | -| tools | bash tool timeout does not kill the command when a child inherits the output pipes | `internal/tools/bash.go:104` | bash.go:119 now calls hardenProcessLifetime(command); bash_proc_unix.go:28-44 sets Setpgid + WaitDelay=bashWaitDelay(2s) + Cancel that SIGKILLs the whole negative-pid process group, so a backgrounded child can no longer | -| tools | grep glob filter is applied to root-relative paths, silently dropping matches under a subdirectory search | `internal/tools/grep.go:224` | grepFiles now matches the glob against the path relative to the SEARCH directory: grep.go:307-311 computes `globPath = filepath.ToSlash(rel)` via `filepath.Rel(target, path)` and matches globMatcher against that, so `glo | -| tools | OnSandboxDecision callback option is set up to fire but is never wired by any caller (dead code) | `internal/tools/registry.go:108` | registry.go no longer declares or invokes OnSandboxDecision anywhere; RunOptions (registry.go:15-43) has no such field and RunWithOptions (registry.go:93-171) propagates decisions via res.SandboxDecision instead. The dea | -| tools | read_file returns Truncated but no truncation marker in output; rune-width line numbering can misalign | `internal/tools/read_file.go:89` | read_file.go:118-123 now appends an explicit marker on truncation: `[truncated: N more line(s) in the requested range not shown; set start_line=... to continue]`. Line-number padding uses ASCII strconv.Itoa width (read_f | -| tools | lsp_navigate workspace confinement (recently fixed) — verify | `internal/tools/lsp_navigate.go` | lsp_navigate.go:85 resolves the model path via resolveScopedReadPath BEFORE reading or handing it to the LSP manager (line 90 readWorkspaceFile(absPath), line 97 req.Path=absPath), so a '..'/absolute path cannot open a f | -| tui-render | wrapPlainText collapses internal whitespace, destroying code indentation/alignment in assistant answers | `internal/tui/rendering.go:261 (also 2026-06-13 reverify rend` | rendering.go:399-409 now detects preformatted/columnar bodies (an internal run of >=2 spaces) and emits them verbatim via splitPreservingWidth (459-479) instead of strings.Fields, preserving every space; leading indent i | -| tui-render | appendTranscriptRow O(n) copy + O(n) dedup scan per row → O(n^2) append and /resume rehydration | `internal/tui/transcript.go:113/117 (multiple rows)` | appendTranscriptRow now appends in place (transcript.go:99-108, comment notes the old O(n^2) is gone). The prior reverification gap — hasTranscriptRow still linear-scanning per keyed row during rehydration — is closed fo | -| tui-render | Full transcript re-rendered from scratch every frame, including per-line regex parsing | `internal/tui/model.go:610/124` | renderRowMode/renderRowDetailed now memoize per-row output via the LRU defaultRenderCache (rendering.go:172-197) keyed on width/flush/bodyCap/cwd/row fields/rc hints (render_cache.go:126-174); only rows tied to the activ | -| tui-render | looksLikeDiff false-positives on any output containing a line starting with '---' | `internal/tui/view.go:394` | view.go:897-916 now requires a real hunk header (hunkHeaderPattern) OR BOTH '+++ ' and '--- ' file headers before returning true; a lone '---' line no longer hijacks bash/generic output into the diff renderer. | -| tui-render | truncateTUIOutput byte-slices UTF-8 mid-rune, emitting invalid UTF-8 | `internal/tui/transcript.go:303` | truncateTUIOutput (transcript.go:436-445) now calls cutRunes (449-460), which walks back to the nearest UTF-8 rune boundary via utf8.RuneStart before slicing, so the transcript/session log can no longer receive a split m | -| tui-render | Cancelled-run event flush appends to whichever session is active at flush time (cross-session contamination) | `internal/tui/model.go:512` | flushRunIDs now maps runID -> recording sessionID (model.go:161, set at cancel 3177-3180), and agentResponseMsg routes the durable flush to that session: `if flushSessionID == m.activeSession.SessionID { appendSessionEve | -| tui-render | Transcript dedupe key omits runID — repeated provider tool-call IDs silently drop later tool rows | `internal/tui/transcript.go:135/137/139` | transcriptRowKey bakes runID into every key, e.g. tool rows return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id) (transcript.go:157-181), with a comment explaining Gemini's repeating synthetic gemini_tool_N ids; w | -| tui-render | Resize unhandled beyond storing width/height; composer not width-bounded (blind typing) | `internal/tui/model.go:456/227` | tea.WindowSizeMsg (model.go:1249-1267) stores width/height, resets the streaming fade line-age mapping that a re-wrap would invalidate (1256-1257), sizes the composer for horizontal scroll `m.input.SetWidth(maxInt(20, ch | -### 4.4 Findings dropped in adversarial review - -Two drafted findings did not survive re-verification and are explicitly withdrawn: - -| Subsystem | Claimed | Why dropped | -|---|---|---| -| agent-loop | Compactor context-window budget frozen at run start, not updated on mid-run model escalation | Mechanics accurate, but the user-visible failure mode is unreachable today — the escalation path that would change the window does not feed back into the frozen budget in a way that produces wrong behavior. Not a defect. | -| config-plugins-skills | `maxTurns <= 0` silently dropped with no validation error | Disproven: a 0/negative `maxTurns` is replaced by the documented default (30) by design, not silently mis-handled; there is no incorrect behavior. (The "no explicit validation error" observation is retained only as the low-priority still-open consistency note, not as a defect.) | - -## 5. Root-cause synthesis - -The 35 surviving findings cluster into four underlying causes: - -**A. Pattern/substring matching on un-structured text where a structure-aware path exists but isn't authoritative.** The sandbox destructive/piped-installer classifier scans the *raw* command string (M11) even though a quote-aware AST analyzer runs alongside — but the analyzer can only *add* risk categories, never retract a regex false positive. The stream-json `api_key` redactor (M1) matches "any next word" after the marker. Both over-match real content (a `git commit -m "…rm -rf…"`, prose containing `apiKey foo`). Fix posture: make the structured parser authoritative when the parse succeeds; anchor patterns to credential/command shape. - -**B. "Success" returned before the durable/drain step completes.** `AppendEvent` returns success after a page-cache write with no `fsync`, while the *derived* metadata is fsync'd — so a crash can leave `EventCount` ahead of the durable log and lose the last event, including the checkpoint `/rewind` targets (M12). A PTY exec session's `command.Wait()` returns and the session is removed before the fire-and-forget copy goroutine has drained the master FD, dropping the command's final output (M14). The hook dispatcher discards audit-append errors (I2). Fix posture: tie the success signal to completion of the durable write / output drain. - -**C. Surfaces shipped ahead of (or drifting from) their wiring.** Four hook events are validated, documented, and `zero hooks add`-able but never dispatched (H1); `ResolvedConfig.MCP` is computed but unread so provider-command MCP servers are dropped (M2); usercommands `model:`/`agent:` frontmatter is parsed/documented but ignored (L4); project slash-commands auto-load with a built-but-unused provenance flag (M3); `OnContext`/`MeasureContext` has no consumer (L2); plugin vs hooks event-set validators disagree (L8); the runtime skill tool drops dup-name collisions silently (L5). Fix posture: a single source of truth for valid events; either wire the surface or remove it from config/CLI/docs. - -**D. Lifecycle-edge handling asymmetric with the happy path.** Idle/close/exhausted/redirect edges are under-handled: the remote bridge clears the conn deadline before the daemon handshake so an authenticated-idle peer pins a slot forever (M7) and `Shutdown` can't even close bridge conns because `ServeConn` bypasses `trackConn` (M8); the MCP SSE client never reconnects after any stream close (M9); cron double-fires on DST fall-back (M4), double-grants a claim when the schedule can't advance (M5), and silently cancels `--run-now` on reconcile (M6); the swarm scheduler leaks a per-job context on `MaxRuns` completion (L13); the mailbox stale-lock break compares the file to itself instead of the observed-stale snapshot (M13); and the health probe forwards credentials across a cross-host redirect (M10 — the security item, an edge of the same "redirect handling" family the DNS-rebind fix addressed for the internal case but not the cross-host-public-credential case). - -## 6. Prioritized remediation order - -1. **M10 — strip auth headers on cross-host redirect in the health probe** (only security finding; credential exfil to a public host). -2. **H1 — wire or remove the four un-dispatched hook events** (trust/correctness; silent no-op of a documented automation surface). -3. **M12 — `fsync` the events.jsonl append** (data-loss of the last event, including `/rewind` checkpoints). -4. **M11 — make the sandbox AST authoritative / segment the destructive regexes** (false-positive hard-blocks safe commands in headless exec). -5. **M14 — synchronize the PTY copy goroutine with session exit** (model loses a command's final output). -6. **M4 / M5 / M6 — cron: DST double-fire, claim double-grant on unadvanceable spec, `--run-now` reconcile** (duplicate/again missed scheduled agent runs = token spend / broken contract). -7. **M7 / M8 / M9 — connection lifecycle: bridge handshake deadline, `ServeConn` tracking for Shutdown, MCP SSE bounded reconnect** (slot exhaustion / lost MCP tools mid-session). -8. **M1 — anchor the stream-json `api_key` redactor** (corrupts machine-readable output). -9. **M2 / M3 / M13 / M15 — provider-command MCP drop, project-command provenance, mailbox stale-break, TUI streaming O(n²)**. -10. **Lows / infos** as hygiene — notably **L15** (genuine `lastUsedAt` data race; add a `-race` test), **L1** (route the three remaining stream connects through `streamWithReconnect`), and the dead/inert cleanups (L2/L4/L5/L8). - -## 7. Confidence notes (what could not be fully verified) - -- **`-race` suite is not green:** 3 `internal/tools` exec-session tests fail under `-race` due to fixed-timing yield-window assumptions (characterized in §2 as flakes, confirmed by passing 3× without `-race`). I could not make those tests deterministic without modifying source (audit only). No data race underlies them; the one real race in that package (**L15**) was found by inspection because no test exercises concurrent `touch()`+prune. -- **Platform-specific backends only skimmed:** Windows sandbox/process files (`*_windows.go`), `landlock_linux.go`, `seccomp_*.go`, `log_monitor_darwin.go`, and the OS keyring backends (`secret_access_*.go`) were read shallowly — outside the symlink/parsing/risk/locking focus. A dedicated Windows/Linux-LSM pass is advisable. -- **Not deep-read (out of stated focus):** `internal/tools/web_fetch.go` & `web_search.go` (network tools), Anthropic provider internals, the plugin/skill **install/checksum/lockfile** paths, and TUI overlays (`mouse.go`, picker/wizard/onboarding, `command_center.go` specifics). Build/vet cover them; behavior was not audited. -- **Daemon prior-audit reconciliation is necessarily empty:** the `D1–D11` labels referenced in current daemon code comments come from a review not present in the three on-disk `docs/audit/*.md`, so "fixed since" for the daemon is against code comments, not those docs. The daemon findings here (M7/M8/L7/I1) are re-derived against current code. -- **Reproductions:** M4, M5, M6, M11 were empirically reproduced with throwaway tests against the current tree (then removed). M10, M12, M14, M9, H1 are verified by code reading + caller/grep tracing, not a live exploit. Severity calibration for M10 is *medium* (requires a malicious/compromised/typo'd `baseURL` that 3xx-redirects to an attacker host) rather than high; if a deployment commonly points providers at untrusted gateways, treat it as high. - ---- - -## 8. Remediation status — `fix/audit-2026-06-20` - -Implemented in staged commits on branch `fix/audit-2026-06-20` (one finding/tight-cluster per commit; each gated build/vet/gofmt + `-race` on the affected package; full `-race` suite green on the final tree). Each finding was re-confirmed against current code before fixing. - -### Fixed (15 findings + 1 test-robustness) - -| ID | Sev | Status | Note | -|---|---|---|---| -| M10 | medium | ✅ Fixed | strip auth headers (x-api-key/x-goog-api-key/custom) on cross-host redirect in the health probe | -| M14 | medium | ✅ Fixed | join the PTY copy goroutine before markDone/remove so a command's final output isn't dropped on exit (also fixes the flaky `TestExecCommandTTYSessionAcceptsInputOnLinux`) | -| M12 | medium | ✅ Fixed | fsync events.jsonl append so the log is as durable as its metadata | -| M4 | medium | ✅ Fixed | feed `sched.Next` a minute-aligned instant so the DST fall-back collapse guard engages | -| M5 | medium | ✅ Fixed | pause an unadvanceable job inside the claim so two schedulers can't both fire it | -| M7 | medium | ✅ Fixed | bound the daemon control handshake with a read deadline (idle remote peer can't pin a slot) | -| M8 | medium | ✅ Fixed | `ServeConn` tracks the conn so `Shutdown` can close a stalled remote handshake | -| M13 | medium | ✅ Fixed | atomic rename-with-verify mailbox stale-lock break (no self-compare; no split-brain) | -| M1 | medium | ✅ Fixed | anchor the streamjson `api_key` redaction so prose isn't mangled | -| I1 | info | ✅ Fixed | local control socket handshake now deadline-bounded (same fix as M7) | -| L15 | low | ✅ Fixed | snapshot exec-session `lastUsedAt` under its lock — fixes a real `-race` data race | -| L13 | low | ✅ Fixed | `defer job.cancel()` releases the scheduler's per-job context on MaxRuns completion | -| L1 | low | ✅ Fixed | route the post-compaction / max-turns connects through `streamWithReconnect` | -| L3 | low | ✅ Fixed | `--skip-permissions-unsafe` rejects trailing positional args instead of dropping them | -| L11 | low | ✅ Fixed | error on a present-but-wrong-shape chatgpt account-id claim so the login warning fires | -| (report §2) | — | ✅ Fixed | de-flaked the 3 exec-session timing tests so `go test ./... -race` is green | - -### Deferred (larger/riskier than a clean stage — rationale) - -| ID | Sev | Why deferred | -|---|---|---| -| H1 | high | Wiring `sessionStart/End` + `specialistStart/Stop` correctly is a cross-surface design change: the dispatcher lives only in `agent.Options` (fires per-tool), so session boundaries differ across exec/TUI/daemon and the specialist events need the parent dispatcher threaded through the child boundary. The audit's alternative (delete the events) would disable a documented feature (against the guardrails). Needs a focused design pass, not a surgical edit. | -| M9 | medium | MCP SSE reconnect is risky concurrency (re-open + re-`initialize`d session state) and not deterministically unit-testable; warrants its own change with a fault-injection harness. | -| M11 | medium | Making the sandbox AST authoritative is security-sensitive — a wrong call under-blocks a genuinely destructive command. Needs an extensive "still blocks real destructive commands" matrix before flipping the precedence; the guardrails forbid weakening confinement to land a fix. | -| M2 | medium | Merging provider-command MCP into `ResolveMCP` is a real wiring decision (which resolve path the runtime should read); deferred to avoid guessing the intended single source of truth. | -| M3, M6 | medium | Project-command provenance marker (UX) and cron `--run-now` one-shot marker (schema field) are design choices better made deliberately. | -| M15 | medium | Streaming-block memoization is perf-only (no correctness risk) and fiddly in the TUI render hot path. | -| L2, L4, L5, L6, L7, L8, L9, L10, L12, L14, L16, L17, I2 | low/info | Lower-value hygiene / cross-cutting (dead-code removal decisions, cross-platform lock identity, RFC3339Nano format change, TUI width math, minor concurrency hardening). Batchable in a follow-up. | - -### Skipped (not a real defect) -- The two findings the audit itself dropped in §4.4 (compactor frozen-budget; `maxTurns<=0`) were already withdrawn and not implemented. diff --git a/docs/audit/2026-06-21-product-ux-audit.md b/docs/audit/2026-06-21-product-ux-audit.md deleted file mode 100644 index 5f873662..00000000 --- a/docs/audit/2026-06-21-product-ux-audit.md +++ /dev/null @@ -1,444 +0,0 @@ -# Zero — Product / UX / TUI Audit (2026-06-21) - -- **Target:** `github.com/Gitlawb/zero` @ `origin/main` commit `f7ee189` — a terminal coding agent (Go, Bubble Tea v2 TUI; one event stream feeding TUI / headless `exec` / MCP server / cron). -- **Lens:** product, UX, TUI/visual correctness, and open-source-release readiness, judged from a **stranger who just cloned the repo**. The code/security audit is separate (`docs/audit/2026-06-20-deep-audit.md`) and is **not** repeated here. -- **Scope note:** audit only — no source changed. - -## 1. Executive summary - -Zero is a **functionally mature** agent with a thoughtfully-built TUI (memoized rendering, width tiers, light/dark themes, a real reduce-motion opt-out, color-independent diff/permission encoding, and a genuinely good guided setup wizard). The gap is the **release/packaging and last-mile UX layer**, and it is not yet shippable as open source. - -Two **release blockers**: there is **no `LICENSE` file** (the repo is legally all-rights-reserved despite being marketed as open source), and the **headline binary-install path is dead** — `scripts/install.sh` / `install.ps1` resolve `Gitlawb/zero`'s GitHub Releases, which 404 (no release has ever been published), so the only working install is `go run` — which then trips a **Go-version mismatch** (README says 1.24, `go.mod` requires 1.25). A new user on a machine without Go 1.25 cannot get to first run. - -Beyond that, the recurring themes are **docs/claims drifting from the code** (a `/usage` slash command and a `--theme` flag that don't exist; a keyless local-model on-ramp the health probe blocks), **error messages that don't guide recovery** (no-key errors dump a raw OpenAI URL instead of pointing at `zero setup`; `doctor` reports "Overall: pass" with no credential and leaks Go `map[...]` syntax), and **low-contrast theme tokens carrying real content** (faint/faintest fail WCAG while holding line numbers, diff headers, and help text). - -### Severity rollup (35 findings, post-dedup) - -| Severity | Count | -|---|---| -| Critical | 2 | -| High | 11 | -| Medium | 11 | -| Low | 5 | -| Info | 6 | - -(Counts merge cross-area duplicates — LICENSE, install-404, Go-version, and the no-provider-setup-pointer cluster each appeared from multiple area finders.) - -## 2. Environment & method - -- **Build:** `go build ./cmd/zero` → clean on go1.26.4; binary run as `/tmp/zero` with **isolated config** (`HOME`/`XDG_CONFIG_HOME` → temp dirs) so findings reflect a fresh machine, not local state. -- **Ran (real output captured):** `zero --help`, `doctor`, `exec` (no provider / bad key / unreachable URL / empty prompt), `setup --verify` (no key / wrong key, openai + ollama), `providers check --connectivity`, `config`, `version`; bad-flag / bad-mode / bad-effort / malformed-config error paths; the existing TUI visual tests (`command_card_contrast`, `theme_select`/luminance, `width_tiers`, `wrap_whitespace`, `theme_reprobe`) — all pass; throwaway Go tests measuring display-cell widths of the truncation helpers with CJK input; WCAG contrast ratios computed from the theme hex tokens; a `creack/pty` capture of a monochrome onboarding frame to diff SGR codes across `NO_COLOR` values. -- **Terminal profiles:** color/`NO_COLOR`/profile behavior was checked via the `colorprofile` dependency + one pty capture; **truecolor/256/16-color and a physically light terminal were not driven frame-by-frame** (see Confidence notes). -- **Could not capture:** live interactive TUI frames — Bubble Tea v2's alternate-screen buffer yields only escape setup/teardown to `script`/`expect`. Rendered first-run wizard, resize redraw, streaming fade, and narrow-tier painting are assessed **from code + the passing visual tests** and marked unverified where relevant. -- **Adversarial pass:** every finding faced 1–2 skeptics that re-ran/re-read to disprove it; 2 were dropped (see §A.dropped). Several of my own first-pass guesses were corrected (exit codes are correct; meaning never depends on color alone; a reduce-motion opt-out does exist). - -## 3. "First 5 minutes" — new-user walkthrough & verdict - -1. **Clone, read README.** Strong pitch ("terminal coding agent you fully own", "no telemetry"). Two install options offered as peers: a binary one-liner and `go run ./cmd/zero`. -2. **Try the binary one-liner** (`scripts/install.sh`) — the natural choice without Go. → **Dead-ends** with a curl 404 / "could not read tag_name" (no GitHub Release exists). [C1] -3. **Fall back to `go run ./cmd/zero`** per the README ("requires Go 1.24+"). On Go 1.24 → **build error** `go.mod requires go >= 1.25.0`. [H2] (Works on 1.25+.) -4. **First launch with no key.** Headless `exec` prints a clear, exit-coded error — but it names only `OPENAI_MODEL`/`OPENAI_API_KEY` and a raw `platform.openai.com` URL, **never `zero setup` or `zero auth`** (the commands built for this moment). [H/M cluster] The guided `zero setup` wizard itself is excellent [I1] — if the user discovers it. -5. **Verify setup** (`zero setup openai --verify`) with no key yet → "**The provider rejected the API key**" — but no key was sent. [M1] Or try the README's headlined **keyless local-model path** (Ollama at localhost) and run `doctor`/`--verify` → "**localhost hosts are blocked**" + "make sure the server is running" (self-contradictory). [H1] -6. **Run `zero doctor`** to sanity-check → "**Overall: pass**" even with no provider credential [H], and an unreadable `missing: map[gopls:...]` Go-map blob [H]. - -**Verdict: a stranger cannot reliably reach a working first session in 5 minutes.** Not because the agent is weak — it's the install/license/version/onboarding-error plumbing around it. Once a user is past setup with a real key, the core loop and TUI are solid. - -## 4. Findings by area - -### A. First-run & onboarding - -#### C1 · Both README binary-install paths point at a repo that 404s — `scripts/install.sh` and `install.ps1` dead-end for every new user - -**Status (2026-06-21):** Fixed (in-repo) — `622e4b4`. README/INSTALL demote the binary path to "coming soon" and lead with `go run`; release-artifacts.yml now publishes a GitHub Release on a `v*` tag. Publishing the release + making the repo public are MANUAL maintainer steps (repo owner/name already matched go.mod). -- **Severity:** critical · also: G-oss-readiness -- **Where:** `README.md:55-57, docs/INSTALL.md:52, scripts/install.sh:4 (ZERO_REPO default Gitlawb/zero)` -- **Evidence:** curl -s -o /dev/null -w '%{http_code}' https://api.github.com/repos/Gitlawb/zero -> 404. /releases, /releases/latest, /tags all return {"message":"Not Found","status":"404"}. install.sh:106 resolves api_url="${ZERO_GITHUB_API%/}/repos/${ZERO_REPO}/releases/latest" then install.sh:111 `[ -n "$tag" ] || fail "could not read tag_name from $api_url"`. With curl --fail (install.sh:80) the API call itself errors out first. README presents this as the 'or install a release binary' path alongside go run. -- **Impact:** A stranger who picks the README's headline binary-install path (the natural choice on a machine without Go) hits an immediate, opaque failure ('could not read tag_name' or a curl --fail HTTP error). There is no published release, so the documented one-liner cannot succeed for anyone. Only the `go run ./cmd/zero` path works. -- **Fix:** Publish at least one GitHub release with the documented archive+.sha256 assets under the correct public repo, and fix the ZERO_REPO default / README links to that repo's real owner/name. Until releases exist, demote the binary-install path in README/INSTALL to 'coming soon' and lead with `go run ./cmd/zero`. - -#### C2 · No LICENSE file; README says license 'being finalized' — OSS-release blocker (confirming established fact, still true @ f7ee189) - -**Status (2026-06-21):** Skipped — license choice deferred by the maintainer ("leave the license portion now"). LICENSE / README §License / package.json license remain a MANUAL step. -- **Severity:** critical · also: G-oss-readiness -- **Where:** `README.md:330-332; repo root (no LICENSE* file)` -- **Evidence:** `ls LICENSE*` -> no matches. README.md:332: 'License is being finalized; a `LICENSE` file will be added before the public release.' -- **Impact:** A new user cloning the repo has no legal grant to use, modify, or redistribute. For an 'open-source' project this blocks adoption/redistribution and is a release blocker. -- **Fix:** Add a LICENSE file (e.g. Apache-2.0/MIT) and update README §License to name it. - -#### H1 · Local-model verification/health commands fail with a misleading 'localhost hosts are blocked' error — breaks the README's headlined keyless path - -**Status (2026-06-21):** Fixed — `24c8de3`. Loopback allowed only for a user-configured local base_url; redirects + other private ranges stay blocked. -- **Severity:** high -- **Where:** `internal/providerhealth/providerhealth.go:374-377 (also :81,:94 loopback prefixes); surfaced by `zero setup --verify`, `zero providers check --connectivity`, `zero doctor`` -- **Evidence:** `zero setup ollama --verify` -> `[zero] setup verification failed: Could not reach the provider endpoint. Check the base URL ... and that the server is running. (provider connectivity URL is unsafe: localhost hosts are blocked)`. `zero providers check ollama --connectivity` -> status: fail / `provider.connectivity: provider connectivity URL is unsafe: localhost hosts are blocked` / exit 3. validateEndpoint() hard-rejects normalized=='localhost' and 127.0.0.0/8 + ::1. Guard is confined to providerhealth probe path (grep shows blockedAddrReason/endpointSafetyError only in providerhealth + unrelated web_fetch.go) so actual `zero exec` chat again -- **Impact:** README §Why Zero and Quick start headline 'Local models need no key at all' as the easiest on-ramp. A new user who runs Ollama/LM Studio at localhost:11434 and then runs setup --verify / doctor / providers check (all suggested) sees FAIL with a self-contradictory message ('make sure the server is running' + 'localhost is blocked'), and the 'next' hint tells them to re-run the exact command that will keep failing. The keyless p -- **Fix:** Allow loopback/localhost in the provider connectivity probe for explicitly user-configured local providers (the SSRF guard is meant for fetched/remote URLs, not the user's own configured base_url). At minimum, special-case local provider kinds to skip the loopback block, and drop the contradictory 'server is running' wrapper when the real cause is the loopback policy. - -#### H2 · go.mod requires `go 1.25.0` (+ toolchain go1.26.4) but README badge and Quick start say Go 1.24 — a Go 1.24 user cannot build from source - -**Status (2026-06-21):** Fixed — `622e4b4`. README badge + quick start now say Go 1.25+. -- **Severity:** high · also: G-oss-readiness -- **Where:** `go.mod:3 (`go 1.25.0`), go.mod:4 (`toolchain go1.26.4`); README.md:16 badge 'Go-1.24', README.md:52 'requires Go 1.24+'` -- **Evidence:** go.mod: `go 1.25.0` / `toolchain go1.26.4`. README.md:16 `![go](https://img.shields.io/badge/Go-1.24-...)`, README.md:52 `# run from source (requires Go 1.24+)`. A Go 1.24.x toolchain refuses a module declaring `go 1.25.0` unless auto-toolchain download is enabled (GOTOOLCHAIN=auto + network); offline/pinned 1.24 users get a 'go.mod requires go >= 1.25.0' build error. -- **Impact:** The README explicitly invites Go 1.24 users to `go run ./cmd/zero` (the ONLY working install path given the missing releases). They hit a build failure on the documented version, with no hint that 1.25 is actually required. Builds confirmed working only on go1.26.4 in this env. -- **Fix:** Make the docs match go.mod: change the badge and Quick-start note to 'requires Go 1.25+'. (Or, if 1.24 support is intended, lower the go.mod directive to 1.24 and verify it compiles.) - -#### M1 · `zero setup --verify` with NO key set reports 'The provider rejected the API key' — but no key was ever sent - -**Status (2026-06-21):** Fixed — `42b7123`. `--verify` distinguishes "no API key found" from "rejected". -- **Severity:** medium -- **Where:** `internal/cli/setup.go:59-77 (verify branch); verified via runtime` -- **Evidence:** With OPENAI_API_KEY unset: `zero setup openai --verify` -> '... setup verification failed: The provider rejected the API key. Double-check the key value (and that it is for this provider), then re-run setup. (Provider openai requires API credentials.)' — identical message to the WRONG-key case (OPENAI_API_KEY=sk-wrongkey123). -- **Impact:** Confusing for a first-timer who ran setup without exporting a key yet (a very common ordering): the tool says the key was 'rejected'/'double-check the key value' when the real problem is there is no key at all. Sends them debugging a key they never set. -- **Fix:** Distinguish the missing-credentials case from the rejected-credentials case: when no key/env is present, say 'No API key found — export OPENAI_API_KEY (or pass --api-key-env) then re-run', and reserve 'rejected the key' for actual 401s. - -#### I1 · The guided setup wizard copy itself is well-written and self-explanatory (source-level; live frames unverified) - -**Status (2026-06-21):** Info — no action (positive finding). -- **Severity:** info -- **Where:** `internal/tui/onboarding.go:1191-1650` -- **Evidence:** Strings include 'Welcome to Zero' / 'A terminal agent for changing real code.' (1202-1204), a Safety panel 'Zero asks before running shell commands or changing files. Unsafe mode stays off unless you explicitly enable it. Default: ask before risky work.' (1191-1196), clear step headers ('Choose a provider', 'Choose a model', 'Paste your API key' with 'Saved keys stay in your user config' / 'Blank uses from your shell'), model-ID examples (1311), and a consistent footer ('↑/↓ choose Enter continue q quit'). Non-interactive `zero setup

` output is also good: prints config path + concrete 'next:' steps + a runnable 'try this:' -- **Impact:** Positive: the wizard and non-interactive setup are a strong on-ramp; the onboarding problems above are in the surrounding install/license/version/verify plumbing, not the wizard copy. -- **Fix:** None — keep. (Recommend capturing real wizard frames via a pty harness in CI since alt-screen content isn't visible to script/expect, to guard the rendering that could not be verified here.) - - -### B. Interaction flows & UX - -#### H3 · /input-style is a fully inert command — registered, in /help, autocompletes, but does nothing - -**Status (2026-06-21):** Fixed — `1b2e453`. Command + kind + handler + dead helper removed. -- **Severity:** high -- **Where:** `internal/tui/commands.go:288-296 (registry) + internal/tui/model.go:2964-2969 (handler) + internal/tui/rendering.go:43-53 (output)` -- **Evidence:** Handler: `case commandInputStyle: ... text: shellOnlyCommandText(command.name)`. shellOnlyCommandText renders a warning card whose only body line is "This control is available in the TUI but does not have a backend setting yet." Captured from a test I ran: `command-card input-style / status: warning / State / This control is available in the TUI but does not have a backend setting yet. / hint: use /help to inspect active commands`. grep confirms NO other input-style logic exists: `grep -rn 'input-style|inputStyle|InputStyle' internal --include='*.go' | grep -v _test` returns only the registry entry + this handler. Yet it is listed in /help (S -- **Impact:** A new user discovers /input-style via `/` autocomplete or /help (where its description, "Show input style state", implies it surfaces real configuration), runs it, and gets a warning card admitting it's a no-op stub. This is the one remaining surfaced-but-inert slash command (the sibling stubs /style, /compact, /theme, /effort flagged in the earlier code audit are now actually wired on current main). It erodes trust in the com -- **Fix:** Remove the /input-style entry from commandDefinitions (commands.go) and drop the commandInputStyle kind + its case in model.go, so /help and autocomplete stop advertising it. Re-add it only when a backend setting exists. - -#### H4 · README's TUI slash-command table advertises /usage, which is not a slash command (resolves to 'unknown command') - -**Status (2026-06-21):** Fixed — `e68d6cb`. `/usage` removed from the README slash table. -- **Severity:** high -- **Where:** `README.md:89 (`| \`/doctor\` \`/usage\` \`/config\` | health, cost, and config without leaving the chat |`) under the "## The TUI" slash-command table` -- **Evidence:** The registry has no /usage (full name+alias dump from commands.go contains /doctor, /config, but no /usage). I ran a test: `parseCommand("/usage").kind == commandUnknown` (PASS), and `formatGroupedCommandHelp()` does NOT contain /usage (`HELP has ... /usage=false`). `zero --help` shows `usage Summarize token usage and estimated cost` exists ONLY as a CLI subcommand. So a user who types `/usage` in-chat (per the README) hits the commandUnknown path (model.go:2976) -> "unknown command: /usage" (unless they happen to have authored .zero/commands/usage.md, which a new user has not). The table's caption literally promises "...cost...without leavi -- **Impact:** A brand-new user reads the only slash-command reference in the README, types /usage to check token cost mid-session, and gets an error. The advertised in-chat cost readout does not exist as a slash command; the real feature is the separate `zero usage` CLI subcommand, which contradicts the table's 'without leaving the chat' promise. -- **Fix:** Either add a /usage slash command (kind+handler showing the same token/cost summary as the `zero usage` subcommand) or remove `/usage` from the README:89 table and document `zero usage` as a CLI command instead. - -#### L1 · Ctrl+E in the composer is hijacked by mouse-release toggle, breaking the emacs end-of-line binding (while Ctrl+A=home still works) - -**Status (2026-06-21):** Deferred — low (Ctrl+E composer binding). -- **Severity:** low -- **Where:** `internal/tui/model.go:818-825 (top-level Ctrl+E intercept) vs internal/tui/composer.go:223-225 (composer Ctrl+E = end-of-line)` -- **Evidence:** The top-level key switch handles `case keyCtrl(msg, 'e'):` and unconditionally toggles m.mouseReleased then `return`s, before the composer dispatch (model.go:1140 `applyComposerKey`) is ever reached. composer.go:223 declares `case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'): state.cursor = composerLineEnd(state)` — that Ctrl+E arm is therefore unreachable from the composer. By contrast Ctrl+A/K/U/W are NOT intercepted at top level (`grep -n "keyCtrl(msg, 'a'|'e'|'k'|'u'|'w')" model.go` returns ONLY line 818 = Ctrl+E), so they reach the composer normally. The `?`-help overlay (keybinding_help.go:53) documents Ctrl+E only as 'release the mouse -- **Impact:** A user with emacs/readline muscle memory: Ctrl+A correctly jumps to line start, but Ctrl+E (expected: jump to line end) instead toggles mouse-capture mode and prints a 'Mouse released' system notice — a surprising, asymmetric behavior in the input editor. -- **Fix:** Either remove the dead Ctrl+E arm from composer.go:223 (leaving only tea.KeyEnd), or gate the top-level Ctrl+E mouse-toggle so it only fires on an empty/inactive composer (like the `?` overlay does at model.go:982) and let Ctrl+E reach the composer when editing text; then update keybinding_help.go accordingly. - -#### I2 · Two permission decisions share hotkey 'y' but are mutually exclusive by tool type (no actual collision) - -**Status (2026-06-21):** Info — no action (no real collision). -- **Severity:** info -- **Where:** `internal/tui/permission_prompt.go:45-48 + internal/agent/loop.go:1737-1742,1798-1805` -- **Evidence:** permissionOptions maps both AlwaysAllowPrefix and AlwaysAllow to hotkey "y". The dedup in permissionOptions is keyed by decision action, not hotkey, so if both appeared in one prompt both 'y' rows would render. However availablePermissionDecisions only appends AlwaysAllowPrefix for shell tools with a command prefix (loop.go:1738), and AlwaysAllow only when permissionSupportsPersistentDecision(toolName) is true — which returns false for `bash/exec_command/write_stdin/apply_patch` (loop.go:1800). So a shell prompt gets AlwaysAllowPrefix=y and a non-shell prompt gets AlwaysAllow=y; they never co-occur. -- **Impact:** None today — verified mutually exclusive by tool type. Noting it because the safety relies on a non-local invariant (the loop.go gating); a future tool that is both a shell tool AND persistent-decision-eligible would surface two 'y' rows where the first match wins, silently shadowing the second. -- **Fix:** Optional defensive measure: de-duplicate by hotkey in permissionOptions (or assign distinct hotkeys) so the invariant is enforced at the render layer rather than depending on the agent's decision-set gating. - - -### C. TUI rendering & visual correctness - -#### M5 · middleTruncate (diff card file paths) overflows the budget on wide-rune paths - -**Status (2026-06-21):** Deferred — explicitly out of scope for this PR (wide-rune truncation budget; outer width clip already prevents frame breakage per I4). -- **Severity:** medium -- **Where:** `internal/tui/startup.go:138 middleTruncate (used for the diff card path head at internal/tui/rendering.go:1471 with budget innerWidth/2)` -- **Evidence:** middleTruncate is also rune-count based (`if len(runes) <= limit`, then string(runes[:front])+"…"+string(runes[len-back:])). Ran the real fn: `middleTruncate(strings.Repeat("路",40), 20)` returns 39 cells for a 20-cell budget (test log: `middleTruncate(20) -> cells=39 (want <=20)`). -- **Impact:** A file path containing CJK characters in the diff card header renders ~2x its budget; the outer fitStyledLine re-clips it so the card edge survives, but the head/right-aligned counts (+N/-N) collide or the path is cut harder than intended — the path becomes harder to read for non-Latin filenames. -- **Fix:** Make middleTruncate measure with lipgloss.Width and cut front/back halves with splitAtWidth on the reversed/forward string, or fall back to the width-aware truncateRunes once that is fixed. - -#### I4 · Wide-rune handling is correct in prose/markdown/table/user-prompt wrap and at the outer card fit — overflow does NOT break the card frame - -**Status (2026-06-21):** Info — no action (verified good). -- **Severity:** info -- **Where:** `internal/tui/rendering.go:381 wrapPlainText / :442 splitAtWidth / :463 splitPreservingWidth; internal/tui/startup.go:213 fitStyledLine / :223 truncateStyledLine` -- **Evidence:** These all measure via lipgloss.Width / per-glyph glyphWidth (e.g. truncateStyledLine line 260 accumulates glyphWidth and also tracks OSC-8 link state so truncation can't leak a hyperlink). toolCard re-fits every body line to innerWidth (rendering.go:1387) so even the buggy truncateRunes output cannot push the card past the terminal width — verified: a 100-col CJK diff card produced all lines at exactly 100 cells. Assistant prose is also capped at 96 cols (assistantMeasureCap, rendering.go:360) for readability on wide terminals. WindowSizeMsg (model.go:1249) resizes composer and resets fade state cleanly. -- **Impact:** Positive: the layout is robust against frame-breaking; the wide-rune bug manifests as silent content loss inside the card rather than a corrupted UI. Documents why the high-severity finding is content-loss, not layout-break. -- **Fix:** No change needed; noted so the truncateRunes fix is scoped to content fidelity, and to record that interactive frames themselves remain UNVERIFIED (no pty). - - -### C/E. Color, theme & accessibility - -#### H5 · faintest fails WCAG even the 3.0 UI floor in BOTH themes; it carries functional content (line numbers, diff @@/+++/--- headers, tool args, separators) - -**Status (2026-06-21):** Fixed — `cc3b5e3`. faintest raised to WCAG AA (>=4.5) in both themes. -- **Severity:** high -- **Where:** `internal/tui/theme.go:135 (dark faintest #3a3a40), :168 (light faintest #9b9ba3); consumed as zeroTheme.faintest / diffMeta / addLineNum / delLineNum / toolArg` -- **Evidence:** Computed WCAG ratios: light faintest #9b9ba3 on panel #ececed = 2.34; dark faintest #3a3a40 on panel #0e0e10 = 1.71, and on the unpainted canvas #070708 = 1.78. AA-normal needs 4.5, the relaxed UI/large floor is 3.0 — these are below even 3.0. faintest renders diffMeta (theme.go:223: '@@ hunks, +++/--- headers') and the diff gutter line numbers (theme.go:226-227), which are functional, not decorative. -- **Impact:** On a brand-new user's terminal, diff hunk headers, line numbers, and tool argument hints are near-invisible to anyone with even mild low vision, and washed out for everyone on a bright screen. The existing test passes because TestLightPaletteContrastAndHierarchy only asserts a luminance DELTA (panelL-inkL>=0.5) and monotonic ordering — it never checks a WCAG ratio, so these sub-floor values ship green. -- **Fix:** Darken light faintest toward ~#6e6e76 (>=3.0 on #ececed) and lighten dark faintest toward ~#5a5a62 (>=3.0 on #0e0e10/#070708); then add a test asserting actual WCAG ratio >= 3.0 for every token used to carry text, not just a luminance delta. - -#### H6 · faint is sub-AA in both themes yet carries instructional/navigation text (help footer, MCP wizard hints, composer placeholder, working timer) - -**Status (2026-06-21):** Fixed — `cc3b5e3`. faint raised to WCAG AA in both themes. -- **Severity:** high -- **Where:** `internal/tui/theme.go:134 (dark faint #5b5b63), :167 (light faint #78787f); used at keybinding_help.go:89, mcp_add_wizard_view.go:26-160, mcp_manager.go:362/443, model.go:2111/2151/2231` -- **Evidence:** WCAG: dark faint #5b5b63 on panel = 2.87 (below the 3.0 UI floor); light faint #78787f on panel = 3.71 (below AA-normal 4.5). It renders real instructions: keybinding_help.go:70 'keybindingHelpFooter = "? or Esc to close · /help for slash commands"' shown via zeroTheme.faint (the user's only on-screen cue for how to exit the help overlay), mcp_manager.go:362 the nav legend 'type search up/down navigate Enter action Esc close', and the composer placeholder (model.go:2231). -- **Impact:** The dismiss hint, navigation legends, MCP setup guidance, and input placeholder are hard to read for low-vision users and dim for everyone — a new user trying to learn the keys is reading the help in the lowest-contrast color in the UI. -- **Fix:** Raise faint to clear 4.5 (light) / >=3.0 (dark) on its panel, e.g. dark #6f6f78, light #5f5f66. Reserve sub-AA grays strictly for non-text decoration (rules/borders), which already have separate tokens (line/line2). - -#### M2 · Light-theme accent (4.34) is below AA for normal text, despite a test that claims to verify light accent contrast - -**Status (2026-06-21):** Fixed — `cc3b5e3`. Light accent #4d7a08->#477006 (AA); theme test now asserts a true WCAG ratio. -- **Severity:** medium -- **Where:** `internal/tui/theme.go:169 (light accent #4d7a08); test internal/tui/theme_select_test.go:83` -- **Evidence:** WCAG: light accent #4d7a08 on panel #ececed = 4.34 (AA-normal needs 4.5). accent renders the user gutter '❯' (theme.go:38/210), bash prompt (theme.go:48/218), spinner, and focus. The test at theme_select_test.go:83 only asserts 'panelL - relLum(accent) < 0.25' (a luminance delta, not a contrast ratio), so 4.34 passes. Dark accent #caff3f is fine (16.4). -- **Impact:** On a light terminal the brand prompt glyph and bash gutter sit just under the readability bar for small text. Borderline rather than broken, but it is the most-repeated colored glyph on screen. -- **Fix:** Darken light accent slightly (e.g. #436a07 → ~4.7) to clear AA-normal, and change the test to assert a real WCAG ratio (>=4.5 for text-bearing accent) instead of a luminance delta. - -#### M3 · NO_COLOR honored only for strconv.ParseBool-style values; NO_COLOR=yes / NO_COLOR=anything leaves the UI in full color, violating the no-color.org spec - -**Status (2026-06-21):** Fixed — `7fc296b`. NO_COLOR with any non-empty value forces the Ascii profile. -- **Severity:** medium -- **Where:** `dependency github.com/charmbracelet/colorprofile@v0.4.3 env.go:115-117 (envNoColor uses strconv.ParseBool); zero never sets WithColorProfile (internal/tui/run.go:25-35), so it inherits this` -- **Evidence:** Drove /tmp/zero under a pty across NO_COLOR variants and diffed emitted SGR: 'NO_COLOR=1' -> colorSGR=false (306 bytes, monochrome), 'NO_COLOR=true' -> false, but 'NO_COLOR=yes' -> colorSGR=true (676 bytes, full color) and 'NO_COLOR=foo' -> colorSGR=true. Source: colorprofile env.go:116 'noColor, _ := strconv.ParseBool(env.get("NO_COLOR"))'. The no-color.org spec says NO_COLOR present with ANY non-empty value must disable color. (NO_COLOR=1 correctly produced a readable bold-only frame — color degrades cleanly when the value is recognized.) -- **Impact:** A user who follows the common convention 'export NO_COLOR=yes' (or any non-bool truthy value) still gets a full-color TUI, defeating their accessibility/preference setting. Silent and surprising. -- **Fix:** Don't rely on the dependency's ParseBool: detect NO_COLOR yourself (present and non-empty => force colorprofile.Ascii via tea.WithColorProfile) in run.go, matching no-color.org. At minimum document that only NO_COLOR=1/true work today. - -#### M4 · Documented-in-code --theme flag does not exist; Options.Theme is read but never populated, so the only non-interactive theme control is the undocumented ZERO_THEME env var - -**Status (2026-06-21):** Fixed — `3b73074`. Real `--theme {auto|dark|light}` flag wired to Options.Theme; stale comment corrected. -- **Severity:** medium -- **Where:** `internal/tui/theme.go:12 and theme_select.go:18 (both claim a '--theme flag'); internal/tui/options.go:52-54 (Theme field); internal/tui/model.go:559 (reads it)` -- **Evidence:** Binary: 'zero --theme light -p hi' -> 'unknown command "--theme"'. 'zero --help' global Flags list has no theme flag. grep for assignments to Options.Theme / '.Theme =' across cmd/ and internal/ returns ZERO hits — the field is only ever READ (model.go:559 resolveThemeMode(options.Theme, ...)). Yet theme_select.go:18 comments 'the --theme flag, threaded via Options.Theme' and theme.go:12 says startup selection happens via '--theme'. -- **Impact:** A user reading the code (or expecting the documented flag) cannot pick a theme at launch via --theme. They must discover ZERO_THEME (documented nowhere) or use /theme after the TUI is already up. The stale comments actively mislead. -- **Fix:** Either wire a real --theme {auto|dark|light} flag that sets Options.Theme, or delete the --theme references from theme.go:12 and theme_select.go:18 and document ZERO_THEME + /theme as the supported controls. - -#### L2 · Accessibility/customization controls (NO_COLOR, ZERO_THEME, ZERO_NO_FADE, motion opt-out) are completely undocumented - -**Status (2026-06-21):** Fixed — `3cd0a50`. README "Accessibility & appearance" section documents NO_COLOR/ZERO_THEME/--theme//theme/ZERO_NO_FADE. -- **Severity:** low -- **Where:** `README.md / docs/ (grep for NO_COLOR|ZERO_THEME|ZERO_NO_FADE|--theme|reduce.motion returns nothing); controls exist at streaming_fade.go:28-44 and theme_select.go:19` -- **Evidence:** grep -rni 'NO_COLOR|ZERO_THEME|ZERO_NO_FADE|--theme|reduce.motion' README.md docs/ => no matches. The controls themselves are real and tested: streaming_fade.go:28-44 honors ZERO_NO_FADE and auto-disables fade over SSH/tmux/16-color/no-TTY; /theme and ZERO_THEME switch palettes. -- **Impact:** A user who finds the streaming-text fade or color distracting (motion sensitivity) has no way to know ZERO_NO_FADE exists; a NO_COLOR/light-terminal user can't discover ZERO_THEME. The a11y story is decent in code but invisible to the new user it would help. -- **Fix:** Add a short 'Accessibility / Appearance' section to the README listing NO_COLOR (note the value caveat), ZERO_THEME=auto|dark|light, /theme, and ZERO_NO_FADE (reduce-motion opt-out). - -#### I3 · Meaning does not depend on color alone, and there IS a reduce-motion opt-out — both verified - -**Status (2026-06-21):** Info — no action (verified good). -- **Severity:** info -- **Where:** `internal/tui/rendering.go:1512/1516 (diff signs), :1200-1203 (permission ✓/✗), view.go:277-285 (mode labels), :996 (PERMISSION text badge); streaming_fade.go:28-44 (ZERO_NO_FADE)` -- **Evidence:** Diffs emit explicit sign columns: rendering.go:1512 diffBodyLine(...,"+",...) for adds and :1516 "−" for dels, so add/del survive color-stripping. Permission outcome uses glyphs ✓ (rendering.go:1200) / ✗ (:1203), the card is gated by a text 'PERMISSION' badge (:996), and permission MODE shows a text label 'auto-approve'/'ask'/'unsafe' (view.go:277-285) not color-only. Streaming fade has a reduce-motion path: ZERO_NO_FADE plus auto-disable on SSH/tmux/ANSI/no-TTY (streaming_fade.go:29-43); the spinner has no separate opt-out but is a single glyph, not a fade. NO_COLOR=1 pty frame rendered fully readable in bold-only. -- **Impact:** Positive: the core surfaces (diffs, permissions, modes) remain interpretable for colorblind users and under NO_COLOR. Recorded so it's not re-flagged; the only motion gap is the spinner having no independent disable. -- **Fix:** Optional: extend the reduce-motion opt-out to also still the spinner glyph when ZERO_NO_FADE (or a NO_COLOR/reduce-motion signal) is set, for full motion-sensitivity coverage. - - -### D. Consistency & polish - -#### H7 · Bad/missing API key produces a mangled, self-contradicting error paragraph (Redact corrupts the provider's own help text) - -**Status (2026-06-21):** Fixed — `8b9b479`. 401/403 lead with an actionable curated message; Redact only scrubs token-shaped Bearer words. -- **Severity:** high -- **Where:** `internal/providers/providerio/providerio.go:234-249 (Redact); surfaced via exec/-p with no/invalid key` -- **Evidence:** Live: `/tmp/zero -p "hi" --model not-a-real-model` -> `[zero] auth error: You didn't provide an API key. You need to provide your API key in an Authorization header using authorization [REDACTED] (i.e. Authorization: authorization [REDACTED] or as the password field (with blank username) if you're accessing the API from your browser and are prompted for a username and password. You can obtain an API key from https://platform.openai.com/account/api-keys.` The raw 401 prose contains the literal word 'Bearer' twice as INSTRUCTION text; Redact() (line 242-247: `if EqualFold(TrimRight(words[index],":"),"Bearer")` -> rewrite this+next word) treats -- **Impact:** A brand-new user whose only mistake is a missing/typo'd key gets an incomprehensible, grammatically-broken paragraph that (a) reads like a bug, (b) gives browser-username/password advice irrelevant to a CLI, (c) names platform.openai.com even when they aren't trying to use OpenAI, and (d) never tells them to run `zero auth` / `zero setup`. This is the single most likely error a first-run user hits. -- **Fix:** Don't pass raw upstream auth-error bodies through verbatim. For 401/403 emit a curated message: `auth error: your API key is missing or invalid. Run \`zero auth\` or set =...`. Keep the raw body only behind a --verbose/debug flag. Also tighten Redact so the 'Bearer' heuristic only fires on token-shaped following words (e.g. matches sk-/long base64), not arbitrary prose. - -#### H8 · doctor leaks raw Go map[...] syntax for ANY nested-map detail — confirmed on lsp.servers AND config.validation (shared formatDetails bug) - -**Status (2026-06-21):** Fixed — `bb4b348`. formatDetailValue renders nested maps as "k: v"; no more raw map[...]. -- **Severity:** high -- **Where:** `internal/doctor/doctor.go:277 (formatDetails uses %v on the value)` -- **Evidence:** lsp.servers: `missing: map[gopls:install with \`go install golang.org/x/tools/gopls@latest\` ... pyright-langserver:install with \`npm install -g pyright\` ...] | present: map[rust-analyzer:on PATH]`. config.validation (with a malformed config.json): `/tmp/zbad/.zero/config.json: map[col:4 error:invalid config JSON: invalid character 't' looking for beginning of object key string line:1]` — the actual error sentence is buried inside a Go map dump. Root cause line 277: `fmt.Sprintf("%s: %v", ..., redaction.RedactValue(value, ...))` where value is a map[string]any. -- **Impact:** Builds on the established lsp leak: this is not isolated to one check — every doctor detail whose value is a nested map renders as unreadable Go syntax. The MOST useful diagnostics (which LSP to install, what's wrong with your config + the line/col) are exactly the ones mangled into `map[...]`. New users running `zero doctor` to fix a problem get gibberish. -- **Fix:** In formatDetails, special-case map[string]any values: render them as indented `key: value` sub-lines (or `k=v, k=v`) instead of `%v`. For config.validation specifically, hoist line/col/error into a one-line human string (`line 1, col 4: invalid character 't' ...`). - -#### M6 · Inconsistent validation of enum-like flags: --mode rejects bad values with a great message, --reasoning-effort silently ignores garbage - -**Status (2026-06-21):** Deferred — medium not in this PR's scope (crit/high + OSS files + contrast). Tracked for a follow-up (validate --reasoning-effort like --mode). -- **Severity:** medium -- **Where:** `internal/cli/exec.go:902 (mode) vs exec.go:965-998 (reasoning effort)` -- **Evidence:** `zero -p hi --mode bogus` -> `[zero] unknown mode "bogus". Valid modes: smart, deep, fast, large, precise.` (exit 2 — exemplary microcopy). But `zero -p hi --reasoning-effort wat` -> `gpt-4.1 does not support reasoning effort; ignoring --reasoning-effort wat` then proceeds; `wat` is never validated as a value (the message only fires because gpt-4.1 lacks the capability — on a model that DOES support effort, an invalid value like `wat` would pass through unchecked). -- **Impact:** Inconsistent error contract across sibling flags. A typo in --reasoning-effort is silently swallowed instead of corrected with the valid set (low/medium/high), so users think their setting applied when it didn't. -- **Fix:** Validate --reasoning-effort against the allowed enum the same way --mode does, with a `Valid efforts: low, medium, high.` message; only fall back to the capability-skip notice when the value itself is valid. - -#### L3 · Brand capitalization inconsistent across one CLI surface (ZERO / Zero / zero) and `zero version` prints no real version - -**Status (2026-06-21):** Deferred — low (brand capitalization + real version string). -- **Severity:** low -- **Where:** ``zero --help` header vs body; `zero version`` -- **Evidence:** `zero --help` first line: `ZERO terminal coding agent`; the 10 command descriptions all say `Zero` (e.g. 'List Zero model registry entries'); `zero version` -> `zero dev`. So one help screen shows three casings, and the release binary reports its version as the literal word `dev`. -- **Impact:** Cosmetic inconsistency a design-conscious user notices immediately; `zero dev` on a fresh clone gives no way to tell which build they have when filing issues. -- **Fix:** Pick one brand casing (recommend 'Zero') and use it everywhere including the --help header. Stamp a real version (git describe / tag) into the build via ldflags so `zero version` is meaningful. - -#### I5 · Empty states and existing polish tests are genuinely clean - -**Status (2026-06-21):** Info — no action (verified good). -- **Severity:** info -- **Where:** `providers/sessions/skills/plugins/usage commands; internal/tui/*_test.go` -- **Evidence:** Empty-state copy is clear and path-aware: `No Zero skills found in /tmp/.../skills.`, `No local Zero plugins loaded.`, `No Zero sessions found.`, and `usage` renders a tidy zero table with `n/a (net LOC <= 0)` guards. `providers` even flags `api key: not set`. Visual tests TestLightPaletteContrastAndHierarchy / TestWidthTierSegments / TestViewNeverExceedsTerminalWidth / TestWrapPlainTextPreservesAlignedWhitespace / TestThemeAutoReProbesBackground / TestHelpRoutesThroughStyledCard / TestStyleCommandCardContentRowTwoTonesCommands all PASS. -- **Impact:** Positive: card alignment, width tiers, light/dark contrast, whitespace-preserving wrap, and theme re-probe are regression-guarded and look correct. The polish problems are concentrated in error/warning microcopy, not layout or empty states. -- **Fix:** No change needed; keep these tests. Minor terminology nit to consider later: `config` says `active provider: none` while `providers` lists openai as the default keyless profile — reconcile the two so 'active' vs 'default' is unambiguous. - - -### F. Product completeness & robustness - -#### H9 · `zero doctor` reports "Overall: pass" with NO provider credential — the one tool meant to verify keys gives a false all-clear - -**Status (2026-06-21):** Fixed — `bb4b348`. provider.config fails (not pass) for a remote provider with no credential; keyless local stays pass. -- **Severity:** high -- **Where:** `internal/doctor/doctor.go:136-156 (providerConfigCheck) + :83-84 (report.OK only flips on StatusFail) + :188 (connectivity is warn/skipped). Reproduced via `zero doctor`.` -- **Evidence:** With unset OPENAI_API_KEY and empty config: `Overall: pass` ... `[pass] provider.config - Provider config loaded for openai.\n ... credentialConfigured: not set ... model: gpt-4.1` ... `[warn] provider.connectivity - Connectivity probe skipped. Run zero doctor --connectivity`. In code, providerConfigCheck returns StatusPass whenever the profile is non-empty (the built-in openai/gpt-4.1 default is non-empty), regardless of `credential := "not set"`; report.OK is false ONLY if some check is StatusFail (line 83-84). -- **Impact:** README says `zero doctor` will "verify config, keys, and connectivity." A brand-new user who runs it before their first prompt sees a green "Overall: pass", concludes setup is done, then `zero exec` immediately fails with a raw upstream auth error. The diagnostic actively misleads instead of catching the single most common new-user misconfiguration. -- **Fix:** In providerConfigCheck, when credential=="not set" AND the provider kind requires a key (i.e. not a local/keyless runtime like ollama/lmstudio), return StatusWarn (or StatusFail) with help text pointing to `zero setup` / `zero auth`. That makes Overall reflect "not actually usable yet" and gives the user the next action. - -#### H10 · No-provider / missing-key error never names zero's own onboarding (`zero setup`/`zero auth`) — it dumps a raw OpenAI HTTP message telling the user to visit platform.openai.com - -**Status (2026-06-21):** Fixed — `42b7123`. exec + TUI no-provider errors point at `zero setup` / `zero auth`. -- **Severity:** high · also: A-onboarding, D-polish -- **Where:** `internal/providers/providerio/providerio.go:218-232 (ClassifiedError) + internal/config/resolver.go:740 (silent default to openai/gpt-4.1). Reproduced via `zero exec "hello"` with no config.` -- **Evidence:** `zero exec "hello"` (no key) → stderr: `[zero] auth error: You didn't provide an API key. You need to provide your API key in an Authorization header ... You can obtain an API key from https://platform.openai.com/account/api-keys.` (exit 3). The README's literal first example is `zero exec "fix the failing test in ./pkg"`. grep for `zero setup|zero auth|no provider configured` across internal/cli, internal/agent, internal/providers error paths returns nothing in this path — the only zero-added text is the `auth error: ` prefix. -- **Impact:** An exec-first / CI user who copy-pastes the README's headline command before running the wizard gets a wall of OpenAI-branded text steering them to OpenAI's website, with zero mention that zero has a `zero setup` wizard or `zero auth`, and no hint that it silently defaulted to OpenAI. Onboarding dead-ends for anyone not using the interactive TUI. -- **Fix:** When the resolved profile has no credential (credential not set) and the request fails auth, prepend a zero-owned line before the upstream text, e.g.: `No provider credential found. Run \`zero setup\` (interactive) or set OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY. Local models need no key — see \`zero providers list\`.` Detect the no-credential case at the agent/exec boundary rather than relying on the upstream 401 b - -#### M7 · Provider errors (auth / rate limit) are classified but give the user no recovery action - -**Status (2026-06-21):** Largely addressed by H7 (`8b9b479`) — auth errors now name a recovery action (`zero auth`). Remaining per-class recovery hints DEFERRED. -- **Severity:** medium -- **Where:** `internal/providers/providerio/providerio.go:218-231 (ClassifiedError); rendered raw in TUI via internal/tui/model.go:1403-1409 (rowError text = msg.err.Error()) and to exec stderr.` -- **Evidence:** ClassifiedError only adds a category prefix: `auth error: `, `rate limit error: `, `provider error: `. The TUI error row is `transcriptRow{kind: rowError, text: msg.err.Error(), final: true}` — the verbatim classified string, no appended next-step. Confirmed live: rate-limit/auth bodies are the raw upstream message with only the prefix. -- **Impact:** On a 429 the user sees `rate limit error: ` with no guidance (wait? `/model` to a cheaper or different provider? it already retried?). On auth failure, no pointer to `/provider`/`zero auth`. The failure is legible as a *category* but not *recoverable* without the user already knowing zero's surface. (Reconnect for transient disconnects in reconnect.go DOES surface a `[connection lost — reconnecting N/2…]` notice -- **Fix:** Append a short, category-specific recovery hint when rendering rowError / exec error: auth → `Run \`zero auth\` or check your API key, or /provider to switch.`; rate limit → `Zero already retried with backoff; wait or use /model to switch provider.` Keep it one line; the classifier already knows the category. - -#### M8 · TUI has no slash command to discover or manage subagents/cron/skills/plugins — features the README markets prominently - -**Status (2026-06-21):** Deferred — larger feature (discovery slash commands for subagents/cron/skills/plugins); out of this PR's scope. -- **Severity:** medium -- **Where:** `internal/tui/commands.go (full registry: /spec and /mcp exist; no /specialist, /cron, /skills, /plugins, /subagent). CLI subcommands exist (zero specialist|cron|skills|plugins per `zero --help`).` -- **Evidence:** grep over commands.go for `spec|specialist|cron|mcp|skill|plugin|subagent` matches only `/mcp` (:174) and `/spec` (:197). README §Why Zero headlines "Subagents", "Spec mode", "Scheduled agents", and "Extensible — skills, plugins, hooks"; the TUI command table only lists /spec /plan. The ? overlay (keybinding_help.go:58-61) references specialist *cards* but only as a reactive drill-in, not a way to list/create them. -- **Impact:** A user living in the interactive TUI (the default `zero` with no args) can type `/` and will never surface cron, skills, plugins, or specialist management. These read as CLI-only features even though README presents them as first-class. Discoverability of half the headline feature set is gated on reading the README's Commands section and dropping out of the TUI. -- **Fix:** Add thin TUI slash commands that shell into the existing CLI surfaces (or at least informational `/specialist`, `/cron`, `/skills`, `/plugins` that print status + the CLI command to manage them), and list them in `/help`. Even read-only listing closes the discoverability gap. - -#### I6 · Strong robustness wins worth preserving: network-down framing, mid-stream reconnect, output truncation, and width-safe rendering - -**Status (2026-06-21):** Info — no action (verified good). -- **Severity:** info -- **Where:** `internal/providers (upstream-unreachable message), internal/agent/reconnect.go, internal/tools/exec_command.go:802-812, internal/tui/rendering.go:1161-1233; tests in internal/tui.` -- **Evidence:** Unreachable base URL → `[zero] upstream unreachable: the model server could not connect to 127.0.0.1:9 (connection refused). The request never reached the model — this is a network failure ... Verify the host is reachable (DNS/proxy/VPN/firewall) ...` (exit 3). reconnect.go retries connect-time disconnects (eof/reset/timeout/502/503) twice with backoff and surfaces `[connection lost — reconnecting N/2…]`. exec output truncates head+tail with `\n[zero] output truncated\n`. Tool cards cap at 16 live / 400 flushed lines and collapse noisy output. Visual tests PASS: TestViewNeverExceedsTerminalWidth, TestTinyTierSingleSegmentAndRailLessCards, Tes -- **Impact:** These are genuinely good for a new user: network failures are legible and actionable, transient hiccups self-heal, and huge tool output won't flood the transcript or overflow narrow terminals. Listed so they are not regressed by fixes to the items above. -- **Fix:** None — keep. The findings above should reuse this same quality bar (the unreachable-host message is the model for what auth/rate-limit/no-key errors should look like). - - -### G. Open-source readiness - -#### H11 · No SECURITY.md and no private vulnerability-reporting path - -**Status (2026-06-21):** Fixed — `634f23f`. SECURITY.md with a private GitHub-advisory reporting path. -- **Severity:** high -- **Where:** `/tmp/zero-ux/ (no SECURITY.md at root or .github); CONTRIBUTING.md (no disclosure section)` -- **Evidence:** find for security.md => none. grep -niE 'vulnerab|disclos|CVE|report.*privately' across CONTRIBUTING.md/README.md/INSTALL.md => only an unrelated 'security issue' mention in CONTRIBUTING:92 and a firecrawl privacy note. For a coding agent that executes shell, edits files, and handles API keys/secrets, there is no documented way to report a vuln. -- **Impact:** A security researcher who finds a sandbox-escape or secret-leak in an agent that runs arbitrary commands has no private channel and will either drop a public issue (0-day exposure) or go silent. GitHub also won't surface a 'Security policy' for the repo. -- **Fix:** Add SECURITY.md (root or .github/) with a private reporting address or GitHub Security Advisories link and a supported-versions/response-time note. - -#### M9 · No issue templates and no PR template (.github/) despite CONTRIBUTING mandating their use - -**Status (2026-06-21):** Fixed — `634f23f`. .github issue (bug/feature/config) + PR templates added. -- **Severity:** medium -- **Where:** `/tmp/zero-ux/.github/ (only workflows/); CONTRIBUTING.md:49 ('Open an issue using the appropriate issue template')` -- **Evidence:** find .github -type f => only the 4 workflow YAMLs. No .github/ISSUE_TEMPLATE/, no PULL_REQUEST_TEMPLATE. Yet CONTRIBUTING.md:49 says 'Open an issue using the appropriate issue template' and the whole policy hinges on an `issue-approved` label workflow. -- **Impact:** A newcomer told to 'use the appropriate issue template' finds none — bug reports arrive unstructured (the exact low-signal reports CONTRIBUTING says will be closed), and the documented contribution process is self-contradictory on first contact. -- **Fix:** Add .github/ISSUE_TEMPLATE/bug_report.yml + feature_request.yml (matching the fields CONTRIBUTING already enumerates at lines 122-145) and a PULL_REQUEST_TEMPLATE.md that prompts for the `Fixes #123` issue link. - -#### M10 · No CODE_OF_CONDUCT.md - -**Status (2026-06-21):** Fixed — `634f23f`. CODE_OF_CONDUCT.md (Contributor Covenant 2.1) added. -- **Severity:** medium -- **Where:** `/tmp/zero-ux/ (none at root or .github)` -- **Evidence:** find for code_of_conduct* => none. -- **Impact:** Standard OSS-readiness/community-health item missing; GitHub's community profile flags it and some users/orgs treat its absence as a maturity signal. Low functional impact but expected for a public release. -- **Fix:** Add CODE_OF_CONDUCT.md (e.g. Contributor Covenant) with a contact for enforcement. - -#### M11 · No CHANGELOG and source builds report version 'dev' — `zero update` has nothing to compare against - -**Status (2026-06-21):** Partially fixed — `634f23f` adds CHANGELOG.md; build-time version stamping (still `dev`) is DEFERRED. -- **Severity:** medium -- **Where:** `/tmp/zero-ux/ (no CHANGELOG); internal/update/update.go:138-141; `/tmp/zero --version` => 'zero dev'` -- **Evidence:** `/tmp/zero --version` => `zero dev`. update.go:138-141: 'Source/dev builds carry a non-semver version ("dev"); ... currentVersion = "0.0.0"'. No CHANGELOG file anywhere. README.md:147 advertises `zero update` and INSTALL.md:91-98 documents `zero update --check`. -- **Impact:** A user running a source build sees version 'dev' and `zero update` treats it as 0.0.0 (will always claim an update is available once any release exists). With no CHANGELOG, a user can't see what changed between versions before updating. Weakens the documented update flow. -- **Fix:** Add a CHANGELOG.md (Keep-a-Changelog style) and document the release/versioning process; consider stamping the real version via -ldflags in the release builder so installed binaries report a semver. - -#### L4 · README has no screenshot/GIF for a TUI-first product - -**Status (2026-06-21):** Deferred — MANUAL maintainer step (add a screenshot/GIF; not auto-generated). -- **Severity:** low -- **Where:** `/tmp/zero-ux/README.md:1-21 (only ASCII logo + shields.io badges)` -- **Evidence:** grep -niE '!\[|\.png|\.gif|demo|screenshot|asciinema' README.md => only the 4 shields.io badge images; no product screenshot or terminal recording. README.md:38 promises 'A TUI that feels premium'. -- **Impact:** A newcomer evaluating a 'premium TUI' coding agent on the README/repo page can't see what it looks like before investing in a build — significant conversion/first-impression cost for a terminal-UI product. -- **Fix:** Add a screenshot or asciinema/GIF of the TUI (setup wizard + a chat turn) near the top of the README. - -#### L5 · npm package name is the un-scoped generic 'zero' — almost certainly unpublishable as-is - -**Status (2026-06-21):** Deferred — low (npm package name). -- **Severity:** low -- **Where:** `/tmp/zero-ux/package.json:2` -- **Evidence:** package.json: `"name": "zero"`. README.md:279 calls the npm wrapper a real install path ('the npm wrapper just delegates to it'). bin/zero.js exists as the wrapper. -- **Impact:** `npm publish` of a bare common word like `zero` will collide with an existing package / be rejected, so the advertised npm distribution can't ship under this name. Minor since npm isn't the primary documented path, but it's a release blocker for that channel. -- **Fix:** Scope the package (e.g. @gitlawb/zero) or pick an available name, and document the actual npm install command in the README (currently none is shown). - -### Dropped in adversarial verification - -| Claim | Why dropped | -|---|---| -| Wide-rune (CJK/emoji) diff/code/grep content truncated by rune-count, "half the line silently lost" | The mechanism (rune-count budget in `truncateRunes`) is real, but the **outer `fitStyledLine(line, innerWidth)` clip** (`rendering.go:1387`) is display-width-aware and absorbs the over-selection: an end-to-end width sweep showed **zero** content loss vs a width-correct budget and the ellipsis is preserved. No user-visible harm → invalid as a UX defect (kept only as a minor `middleTruncate` budget note, M). | -| Default `firecrawl` MCP server 401s on "every first run", breaking the keyless web-search promise | **Split verdict** — one verifier reproduced an **intermittent ~20%** keyless 401 (Firecrawl-side flakiness, surfaced as a mangled Bearer-redacted warning); another got HTTP 200 + 25 tools registered and the warning not emitted by `zero setup`. Not reproducible enough to assert → recorded as Info below, not a finding. | - -> **Info (intermittent, unverified):** the keyless `firecrawl` default endpoint returned a 401 on a minority of identical requests in one run; if it flakes, the warning is rendered mangled (the Bearer-redaction eats the message tail). Worth a real-world soak test of the "no API key" web-search claim before launch. - -## 5. Open-source readiness checklist - -| Item | Status | Note | -|---|---|---| -| **LICENSE** | ❌ FAIL | Missing; README says "being finalized." **Release blocker.** No SPDX headers, no `package.json` license field either. (C2) | -| Working install path | ❌ FAIL | Binary install (`install.sh`/`.ps1`) 404s — no GitHub Release; CI never creates one. `go run` works but needs Go 1.25. (C1) | -| README — newcomer quality | ⚠️ PARTIAL | Good what-it-is/quickstart/config prose + telemetry/privacy statement; but stale Go-version, a `/usage` slash + `--theme` flag that don't exist, and **no screenshot/GIF** for a TUI-first product. | -| CONTRIBUTING.md | ⚠️ PARTIAL | Present and detailed — but mandates issue/PR templates that don't exist. | -| CODE_OF_CONDUCT.md | ❌ FAIL | Missing. | -| SECURITY.md + vuln path | ❌ FAIL | Missing; no private disclosure channel — a baseline expectation for a tool that runs shell/edits files. (H) | -| Issue templates / PR template | ❌ FAIL | `.github/` has only workflows. | -| CHANGELOG + versioning | ❌ FAIL | No CHANGELOG; binaries report `dev` → `zero update` always claims an update. | -| CI coverage | ✅ PASS | Strong: 3-OS matrix, gofmt/vet/govulncheck/deadcode. | -| Telemetry / privacy statement | ✅ PASS | "No telemetry" stated; Firecrawl keyless-routing disclosed. | -| Dependency-license compatibility | ✅ PASS | No copyleft deps linked into the binary; Firecrawl AGPL is network-only and README's reasoning is correct. | -| Reproducible build | ⚠️ PARTIAL | `go build` reproducible on Go 1.25+; binary distribution non-functional. | - -## 6. Root-cause synthesis - -1. **The release/packaging layer was never finished.** The product is mature but the OSS scaffolding isn't: no LICENSE, no published release (install scripts dead-end), no SECURITY/CoC/CHANGELOG/templates, version pinned at `dev`. Code shipped ahead of distribution. → C1, C2, and most of area G. -2. **Docs/marketing copy drifted from the implementation.** README claims Go 1.24 (needs 1.25), a `/usage` slash command and a `--theme` flag that don't exist, and a keyless local-model on-ramp the SSRF probe blocks; accessibility env vars are undocumented. The pitch outran the code. → H1, H2, H4, M (theme flag), L (a11y env docs). -3. **Raw core errors reach users without a "what to do next" UX layer.** The surface-agnostic core's classified/raw errors surface verbatim: no-key dumps an OpenAI URL instead of `zero setup`; "rejected the key" when none was sent; `doctor` says "pass" with no credential and prints Go `map[...]`; provider errors carry no recovery action. → the no-provider cluster, M1, doctor findings, provider-error finding. -4. **Theme aesthetics undercut the readability bar.** `faint`/`faintest` fail WCAG yet carry functional content (line numbers, diff `@@`/`+++`/`---`, help footer, placeholders); light `accent` is sub-AA. → the contrast findings. - -## 7. Prioritized fix order for the OSS launch - -**MUST ship (launch blockers):** -1. **Add a `LICENSE`** and name it in README. (C2) -2. **Make an install path actually work** — publish a GitHub Release with the documented assets under the real repo (and fix `ZERO_REPO`/links), or demote binary-install to "coming soon" and lead with `go run`. (C1) -3. **Fix the Go-version claim** (one-line: README → "Go 1.25+"). (H2) -4. **Add `SECURITY.md`** with a private vuln-reporting path. (H) -5. **Stop misleading on first contact:** remove the inert `/input-style` (H3) and the README's nonexistent `/usage` slash + `--theme` flag (H4, M); or wire them. -6. **Unblock the keyless local-model on-ramp** — allow loopback in the provider connectivity probe for user-configured local providers. (H1) -7. **Onboarding errors guide recovery:** no-provider/bad-key → point to `zero setup`/`zero auth`, distinguish *missing* key from *rejected* key, and don't dump a raw OpenAI URL. (no-provider cluster, M1) -8. **`doctor` must not say "Overall: pass" with no credential**, and must stop leaking Go `map[...]`. (H, H) - -**SHOULD (strongly recommended before a public launch):** -- Lift `faint`/`faintest` (and light `accent`) to meet a contrast bar — they carry real content. (H, H, M) -- Add `CODE_OF_CONDUCT.md`, `CHANGELOG.md`, issue/PR templates; honor `NO_COLOR=` per spec; document `NO_COLOR`/`ZERO_THEME`/`ZERO_NO_FADE`. (G + M/L cluster) -- Add a README screenshot/GIF (TUI-first product). (L) -- Give the TUI discoverability of cron/specialists/skills/plugins (slash commands or a `/help` pointer). (M) -- Provider errors: append a recovery action; pre-soak-test the keyless Firecrawl path. - -**NICE-TO-HAVE:** scope the npm package name; fix the `Ctrl+E` composer binding shadowed by mouse-toggle; normalize brand capitalization + stamp a real `version`; `middleTruncate` wide-rune budget. (L cluster) - -## 8. Confidence notes (what could not be fully verified) - -- **Live interactive TUI frames are UNVERIFIED.** Bubble Tea v2's alt-screen defeats `script`/`expect` (only escape setup/teardown captured). The rendered first-run wizard, resize redraw, streaming fade, and narrow-tier painting are assessed from render code + the **passing** visual tests, not captured frames. One color check did succeed via `creack/pty` + an OSC-11 reply (monochrome onboarding frame). **Recommend a pty golden-frame harness in CI** to lock the rendering this audit could only read. -- **Contrast** ratios are computed deterministically from the theme hex tokens (solid), but "readable on a *physically* light terminal" was exercised with a dark OSC-11 reply, so the light-palette verdict rests on the hex values + the luminance tests, not a real light terminal. -- **The Go 1.24 build failure** is reasoned from the `go.mod` directive (only go1.26.4 present in this env), not reproduced on a real 1.24 toolchain. -- **Truecolor / 256 / 16-color downsampling** is library-handled (`colorprofile` writer) and verified by code, not by per-profile pty captures. -- **GitHub Release existence** for `Gitlawb/zero` was probed via the public API (404 for repo/releases/tags) but the environment has no authenticated GitHub access; the repo may be private/unpublished rather than absent — either way the documented install path cannot succeed for a public stranger today. -- **Firecrawl keyless 401** could not be settled (intermittent; see §dropped) — needs a real soak test. diff --git a/docs/design/ZERO_LIME_TUI_REBUILD_PROMPT.md b/docs/design/ZERO_LIME_TUI_REBUILD_PROMPT.md deleted file mode 100644 index 7d87be02..00000000 --- a/docs/design/ZERO_LIME_TUI_REBUILD_PROMPT.md +++ /dev/null @@ -1,281 +0,0 @@ -# ZERO · Lime TUI rebuild — staged prompt - -You are working in a fresh checkout of `github.com/Gitlawb/zero` (Go 1.24, -Bubble Tea v1.3.10, Lip Gloss, Bubbles v1.0.0, Glamour — all in go.mod; add NO -new dependencies). - -Mission: **remove both existing TUI designs entirely** — the default cyan -shell (`internal/tui/theme.go` palette + the centered splash in `startup.go`) -AND the whole `zeroline` skin system — and replace them with ONE design: -**Lime**, the near-black, lime-accent chat surface prototyped in -`docs/design/zero_tui_lime.html` (commit the prototype there first; it is the -visual source of truth). The prototype is a web mock — this spec is the -authoritative terminal translation of it. Behavior must not regress: every -slash command, prompt flow, overlay, and safety property that works today -must work after. - -This is a fresh clone: file names below were verified against a recent -snapshot, but ALWAYS recon before editing. If a path or symbol has moved, -adapt — do not blindly apply. - -**Gate (run after EVERY stage, commit + tag before continuing):** -``` -go vet ./... -go test ./... -go run ./cmd/zero-release build -``` -Stage 4 additionally runs `go run ./cmd/zero-release smoke`. Never tag a red -tree. AGENTS.MD applies: Go-native commands only. - ---- - -## Design spec — Lime - -### Palette — `internal/tui/theme.go` becomes the ONLY place colors exist -| Token | Hex | Use | -|------------|-----------|-----| -| bg | `#070708` | canvas (terminal default bg; do not paint full-bleed) | -| panel | `#0e0e10` | card backgrounds (tool/diff/read/grep/sessions rows) | -| panel2 | `#121215` | card header rows, picker rows | -| panel3 | `#17171b` | selected/hovered row bg | -| line | `#242429` | default borders, rules, status separators | -| line2 | `#2e2e34` | emphasized borders | -| ink | `#ececee` | primary text | -| muted | `#8b8b93` | secondary text, assistant interim prose | -| faint | `#5b5b63` | hints, metadata | -| faintest | `#3a3a40` | line numbers, separators, tool args | -| accent | `#caff3f` | brand lime: prompts, badge, spinner, focus, final rail | -| green | `#5dd1a4` | success, diff add sign, ✓ | -| red | `#ff7a7a` | errors, diff del sign, ✗, deny | -| amber | `#ffc25c` | permission surfaces, warnings, auto badge | -| blue | `#7db4ff` | grep file locations, local-model dot | -| addBg | `#15201d` | diff added-line bg (solid stand-in for green @9% over panel) | -| delBg | `#241819` | diff deleted-line bg (solid stand-in for red @9%) | -| permBg | `#1c1915` | permission card bg (amber @6% over panel) | -| selBg | `#1d2114` | selected picker/suggestion row (accent @8%) | -| addInk | `#bdeed7` | added-line text | -| delInk | `#f2c4c4` | deleted-line text | -| onAccent | `#000000` | text on accent or amber fills | - -Terminals cannot alpha-blend or glow — the four solid tints above ARE the -translation of the prototype's rgba/glow effects; do not attempt gradients, -shadows, or blur. Truecolor hex throughout; lipgloss downsamples on limited -terminals (keep the existing explanatory comment style in theme.go). - -Keep the `tuiTheme` single-value pattern but rebuild the field set around -these tokens (style per role: badge, userPrompt, sayText, finalRail, toolName, -toolTarget, toolArg, autoTag, statusOk/Err, diffAdd/Del/Meta, permBadge, -permRisk, grepLoc, bashPrompt, stGroup, etc.). No hex outside theme.go. - -### Layout — single-column chat surface (matches the existing architecture) -``` -zero / ~/dev/zero anthropic/claude-sonnet-4-5 · 200K -──────────────────────────────────────────────────────────────────────── -❯ add a --version flag to the cli (user) -I'll add a --version flag…▌ (interim, muted) -╭ grep internal/cli flag\.|RegisterFlag ⠋ ──────────╮ -│ internal/cli/root.go:41 fs := flag.NewFlagSet("zero"… │ -╰ 3 matches ───────────────────────────────────────────────────────╯ -╭ edit internal/cli/root.go auto ✓ ────────────╮ -│ internal/cli/root.go new file +6 −1 │ -│ 41 │ fs.BoolVar(&o.Version, "version", false, … (addBg) │ -╰──────────────────────────────────────────────────────────────────╯ -│ Done — the CLI now prints its version. (final: accent rail) -● done · 2 tools · 8.4s (done line) - -❯ describe a task for zero… (composer) -──────────────────────────────────────────────────────────────────────── -● anthropic │ claude-sonnet-4-5 │ │ 12.4K tok │ interactive │ ⏵⏵ auto -``` -Render order stays: title line → rule → transcript → composer → rule → -status line. NO alt-screen change, NO panes, NO mouse — keep `run.go` -exactly as-is including its no-mouse-capture comment. - -### Title bar (replaces `headerBar`) -Left: badge — `lipgloss` style bg=accent fg=onAccent bold, content ` 0 ` — -then `zero` (ink, bold, lowercase), ` / ` (faintest), cwd via existing -`shortenPath` (muted), git branch (faint) when present. Right: -`provider/model` (ink) + ` · ctx` (faint) where ctx comes from -`modelregistry.ContextLimits.ContextWindow` formatted like `200K`. Reuse the -existing width-candidate fallback pattern (`startupHeaderLine`) so segments -drop gracefully: full → drop ctx → drop cwd → badge+model only. Below it, -one rule line in `line`. - -### Empty state (DELETE the splash entirely) -Remove `startupView`, `startupHeader`, `commandChips`, the figlet -`zeroLogoLines`, gap math, and the `showSplash` field/branch. The app boots -into the chat surface; when the transcript is empty, the stream area shows, -vertically centered: -- a 5–6 line block-art `0` in accent (derive the glyph from the `O` columns - of the old figlet constant before deleting it), -- tagline (muted): `a std-lib-first coding agent · bring your own key · no lock-in`, -- hint (faint): `running zero against ` with the model in ink, -- three suggestion chips, one per line: `❯` (accent bold) + suggestion (ink) - inside a `line`-bordered rounded box; pressing `1`–`3` while the composer - is empty inserts that suggestion. Define suggestions as a string slice in - the tui package (e.g. add a --version flag / explain internal/agent/loop.go / - fix the failing test in internal/tools). - -### Stream blocks (restyle `transcript.go` / `rendering.go` rows) -- **user** (`rowUser`): `❯ ` accent bold + ink text. Blank line above turns. -- **assistant interim** (`rowAssistant` while streaming): muted text, wrapped - to `min(width-4, 74)` columns, trailing `▌` cursor in accent while the - turn is pending (drives off existing pending state — no new ticker). -- **final answer** (last assistant row of a turn): a `│` accent rail gutter - on every wrapped line + ink text, same 74-col measure. If distinguishing - interim vs final is not already possible from row data, mark the row at - append time — do not re-parse text. -- **done line** (end of a turn): `●` green (red on error) + faint - `done · N tools · Xs` with faintest `·` separators, derived from data the - model already has (tool rows this turn + existing usage/timing if present; - omit segments that don't exist rather than inventing counters). -- **system / error notes** (`rowSystem`/`rowError` equivalents): one-line - bordered note — system: faint on panel with `line` border; denial/error: - red text, red-tinted border. Keep content unchanged. - -### Tool cards (replaces `titledCard`/tool rows for `rowToolCall`+`rowToolResult`) -Rounded border card on panel bg. Head row on panel2 with a bottom rule: -status glyph + tool name (ink bold) + target (muted, middle-truncated) + -one-line arg via the existing `argHint` (faintest, right side) + optional -`auto` tag (amber text in amber-bordered mini box) when the call was -auto-approved by mode or a stored grant. Status glyph: running = -`bubbles/spinner` MiniDot in accent · ok = `✓` green · error = `✗` red. -Border tint follows state: line → accent-ish `#5a6b2e` while running → -red-mix on error (define both as theme tokens). Bodies by result shape, -reusing the existing detection where present: -- **diff** (`looksLikeDiff`): head row = path (ink) + `NEW FILE` tag (green - on addBg) when applicable + right-aligned `+N` green / `−N` red counts; - body lines = 4-col right-aligned line number (faintest) + sign col - (green/red) + text — added rows on addBg in addInk, deleted on delBg in - delInk, context muted. Keep the 16-line cap + `… N more lines` footer. -- **read**: same gutter, muted text, head shows path + `Lstart–Lend` (faint). -- **bash**: cmd row `❯` accent bold + command (ink) above a rule; output - muted (stderr-ish in delInk), indented 2; footer `exit 0` green / - `exit N` red when an exit code is in the result. -- **grep**: rows `file:line` (blue) + text (muted, truncated); footer faint - `N matches`. -- Anything else: current generic output rendering, restyled to muted on panel. - -### Permission card (restyle the existing `pendingPermission` prompt — keys unchanged) -Card with amber-mixed border on permBg. Top row: `PERMISSION` badge (onAccent -on amber, bold) + `risk: ` (amber) from the request's -existing risk data. Body: `` (amber bold) + target (ink) + reason -(muted). Action row of bordered key-chips: `[a] allow once` (onAccent on -accent), `[y] always` (ink, accent border), `[d] deny` (ink, red on hover is -web-only — just red-bordered), `[esc]` hint faint. After resolution collapse -to one faint line: `allowed once · ` green / `always · ` green / -`denied · ` red. Ask-user and spec-review prompts get the same card -language with `line` borders. Do NOT alter `handlePermissionKey`, -`nextPermissionMode`, or grant-store semantics. - -### Composer + status line -Composer: `❯` accent bold + the existing `bubbles/textinput`, borderless -(remove the bordered input block), placeholder `describe a task for zero…`, -switching to `running… ctrl+c to interrupt` while pending; right-aligned -faint hint `run ↵` (idle, only when input is non-empty) / `esc stop` -(pending). Rules above composer and above status line in `line`. -Status line groups separated by ` │ ` (line color): `●` accent + provider · -model id · flexible gap · ` tok` (existing usage segment data, -keep cost when priced) · `interactive` · permission mode (reuse -`modeLabel()`: `⏵⏵ auto-approve` green / `ask` amber / `unsafe` red) · -optional green `always: grants` when the sandbox grant store exposes a -cheap count (omit otherwise — do not add plumbing). - -### Overlays (restyle in place, identical key handling) -- Autocomplete: rows on panel, selected row on selBg with accent `❯` marker, - name ink / desc faint. -- Picker (`/model`, `/provider`, etc.): bordered panel; rows = provider dot - (accent remote, blue local) + id (ink) + right meta `ctx · KEY_ENV` - (faint/faintest) when the registry/provider catalog already exposes them; - selected row selBg. Title row + `↑/↓ · ⏎ · esc` hints faint. -- Sessions: `/resume` (alias `/sessions`) keeps its existing flow; restyle - its list as stacked cards — id (accent) + age (faint) top row, title (ink), - meta line (faint with faintest dots). No new persistence behavior. - -### Explicitly OUT of scope -The prototype's `text/json` toggle visualizes the headless -`--output-format stream-json` CLI mode (see docs/STREAM_JSON_PROTOCOL.md) — -not a TUI feature. The TweaksPanel in the HTML is a design harness — ignore -it. No mouse support, no model dropdown (keyboard picker is the translation). - ---- - -## Adaptive requirements (treat as acceptance criteria) -- Width tiers, re-evaluated on every `WindowSizeMsg`: - **≥100** full spec · **80–99** drop tool-arg column, header ctx, status - `interactive` group · **60–79** drop diff/read line-number gutters, badge - renders as `0` without padding, status keeps provider+tokens+mode only · - **<58** (current `minStartupWidth`) single-segment header, cards lose - side borders (keep top/bottom rules), composer + mode line only. -- Text measure: say/final wrap at `min(width-4, 74)`; paths middle-truncate - (`internal/…/root.go`); never emit a styled line wider than the terminal - (extend the existing `fitStyledLine`/`truncateRunes` helpers — reuse, don't - fork). -- Height: when the terminal is shorter than the frame, transcript trims from - the top (existing inline behavior) — verify no overlap of composer/status. -- Color: nothing may rely on truecolor to be legible — check every pairing - at 256 colors mentally; the four tint tokens must remain darker than ink. -- Tests for the tier logic: table-driven across widths {58, 70, 80, 100, 120} - asserting which segments render, using the existing ANSI-stripping helpers. - ---- - -## Stages - -### Stage 0 — recon + delete the `zeroline` design system *(tag: `tui-lime-s0`)* -Recon first: `grep -rni zeroline --include='*.go' .` and read -`internal/tui/options.go`, `internal/cli/app.go`. Then remove completely: -`internal/zeroline/` (whole package + tests), `internal/tui/zeroline_view.go` -(+ test), `internal/cli/zeroline.go`, the `case "zeroline":` dispatch and the -zeroline help-text line in `internal/cli/app.go`, the skin parameter on -`runInteractiveTUIWithSkin` (rename to `runInteractiveTUI`), the `Skin`, -`ThemeVariant`, `ThemeDark` fields in `tui.Options`, the model's `skin` -field and every `m.skin == "zeroline"` branch (View dispatch, `/theme` -special-case, update paths, `picker.go`, `image_attach.go`). Update or -delete affected tests; the zeroline grep must return zero hits. Gate, tag. - -### Stage 1 — Lime palette on the existing layout *(tag: `tui-lime-s1`)* -Rewrite `theme.go` to the token table. Restyle every current render site -(header, transcript rows, titledCard/diffCard, overlays, status line, -prompts) to the new tokens WITHOUT changing structure yet — this isolates -the string-assertion churn in `model_test.go` / `session_test.go` / -`command_polish_test.go` from the layout work. Prefer asserting plain -content via the ANSI-stripping helpers over styled bytes. Gate, tag. - -### Stage 2 — chat-surface components *(tag: `tui-lime-s2`)* -Title bar + rule, empty state replacing the splash (splash code deleted), -stream blocks (user/interim/final/done/notes), tool cards with all four -result bodies, MiniDot spinner, composer + status line redesign. Add -rendering tests per block type with synthetic rows. Gate, tag. - -### Stage 3 — interactive surfaces *(tag: `tui-lime-s3`)* -Permission card + resolved collapse + auto tag, ask-user and spec-review -cards, autocomplete/picker restyle with model-row metadata, `/resume` -sessions card list, suggestion-chip `1–3` insertion. Key handling and -semantics byte-identical to before — assert that in tests where they -already exist. Gate, tag. - -### Stage 4 — adaptive polish + docs *(tag: `tui-lime-s4`)* -Implement + test the width tiers, sweep remaining render sites (spec mode, -command center, doctor output if styled) for stray old-palette styles, -update `internal/cli/app.go` help text and README's TUI section, manual run -`go run ./cmd/zero` at 58/80/100/120 cols (no wrapped rules, no orphan ANSI, -prompts render correctly). Full gate + `go run ./cmd/zero-release smoke`. -Gate, tag. - ---- - -## Hard constraints -- No new module dependencies; charmbracelet stack only. No mouse capture. -- Zero behavior change to permissions, grants, ask-user, spec review, - sessions, slash commands, autocomplete, image attach, compaction, rewind. -- All colors in `theme.go`; renderers consume named styles only. -- Reuse existing helpers (`fitStyledLine`, `truncateRunes`, `argHint`, - `shortenPath`, header candidates) instead of writing parallel ones. -- Comment style matches the repo: explain WHY at non-obvious decisions. - -## Definition of done -One design in the tree (no zeroline references, no splash, no cyan tokens), -the Lime chat surface matching this spec at ≥100 cols and degrading per the -width tiers below that, every pre-existing flow keyboard-reachable and -visually consistent, `go test ./...` + release build + smoke fully green. diff --git a/docs/design/zero_tui_lime.html b/docs/design/zero_tui_lime.html deleted file mode 100644 index dc74c0d8..00000000 --- a/docs/design/zero_tui_lime.html +++ /dev/null @@ -1,1774 +0,0 @@ - - - - - -ZERO — CLI coding agent - - - - - - -

- - - - - - - - - - - - - - - - - - diff --git a/docs/superpowers/plans/2026-06-10-additional-write-roots.md b/docs/superpowers/plans/2026-06-10-additional-write-roots.md deleted file mode 100644 index 85419a1c..00000000 --- a/docs/superpowers/plans/2026-06-10-additional-write-roots.md +++ /dev/null @@ -1,1479 +0,0 @@ -# Additional Write Roots Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let users grant zero write access to directories outside the workspace via a repeatable `--add-dir` flag, a `sandbox.additionalWriteRoots` config key, and a session-only `/add-dir` TUI command. - -**Architecture:** A new concurrency-safe `sandbox.Scope` (workspace root + extra write roots) is the single source of truth, shared by all four enforcement layers: the policy engine's path validation, the macOS seatbelt profile, the Linux bubblewrap binds, and the Go file tools' path resolution. A mid-session `Add` is immediately visible everywhere because every layer reads from the same pointer. - -**Tech Stack:** Go stdlib only (`sync`, `path/filepath`, `os`). No new dependencies. Spec: `docs/superpowers/specs/2026-06-10-additional-write-roots-design.md`. - -**Conventions:** Run all commands from the repo root. Tests use stdlib `testing` only, matching the existing suite. Every commit must leave `go build ./...` green. - ---- - -## Task 1: `sandbox.Scope` core type - -**Files:** -- Create: `internal/sandbox/scope.go` -- Create: `internal/sandbox/scope_test.go` - -The Scope owns root normalization and multi-root validation. `validateWorkspacePath` (already in `internal/sandbox/paths.go`) stays as the per-root primitive. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/sandbox/scope_test.go`: - -```go -package sandbox - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - - scope, err := NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - roots := scope.Roots() - if len(roots) != 2 { - t.Fatalf("Roots()=%v want workspace + 1 extra", roots) - } - if roots[0] != scope.WorkspaceRoot() { - t.Fatalf("Roots()[0]=%q want workspace root %q", roots[0], scope.WorkspaceRoot()) - } -} - -func TestNewScopeRejectsBadExtraRoots(t *testing.T) { - workspace := t.TempDir() - missing := filepath.Join(t.TempDir(), "does-not-exist") - file := filepath.Join(t.TempDir(), "file.txt") - if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - for name, root := range map[string]string{ - "missing directory": missing, - "regular file": file, - "filesystem root": string(filepath.Separator), - "empty": " ", - } { - if _, err := NewScope(workspace, []string{root}); err == nil { - t.Fatalf("NewScope(%s root %q) = nil error, want rejection", name, root) - } - } -} - -func TestScopeAddIsIdempotentAndRejectsContainedPaths(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := NewScope(workspace, nil) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - - added, err := scope.Add(extra) - if err != nil { - t.Fatalf("Add: %v", err) - } - if _, err := scope.Add(extra); err != nil { - t.Fatalf("Add (repeat): %v", err) - } - nested := filepath.Join(extra, "nested") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if _, err := scope.Add(nested); err != nil { - t.Fatalf("Add (nested in existing root): %v", err) - } - if _, err := scope.Add(workspace); err != nil { - t.Fatalf("Add (workspace itself): %v", err) - } - if got := scope.Roots(); len(got) != 2 { - t.Fatalf("Roots()=%v want exactly workspace + %q (idempotent adds)", got, added) - } -} - -func TestScopeValidateAllowsAnyRootButRelativeOnlyWorkspace(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - - if violation := scope.validate(filepath.Join(extra, "out.txt")); violation != nil { - t.Fatalf("validate(extra-root path) = %v, want nil", violation) - } - if violation := scope.validate(filepath.Join(workspace, "in.txt")); violation != nil { - t.Fatalf("validate(workspace path) = %v, want nil", violation) - } - if violation := scope.validate("nested/in.txt"); violation != nil { - t.Fatalf("validate(relative path) = %v, want nil (resolves against workspace)", violation) - } - - outside := filepath.Join(t.TempDir(), "elsewhere.txt") - violation := scope.validate(outside) - if violation == nil { - t.Fatal("validate(outside all roots) = nil, want violation") - } - if violation.Code != ViolationOutsideWorkspace { - t.Fatalf("violation.Code=%q want %q", violation.Code, ViolationOutsideWorkspace) - } - if !strings.Contains(violation.Reason, "--add-dir") { - t.Fatalf("violation.Reason=%q want actionable --add-dir hint", violation.Reason) - } -} - -func TestScopeValidateKeepsSymlinkTraversalProtection(t *testing.T) { - workspace := t.TempDir() - outside := t.TempDir() - link := filepath.Join(workspace, "link") - if err := os.Symlink(outside, link); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - scope, err := NewScope(workspace, nil) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - violation := scope.validate(filepath.Join(link, "escape.txt")) - if violation == nil { - t.Fatal("validate(symlink escape) = nil, want violation") - } - if violation.Code != ViolationSymlinkTraversal { - t.Fatalf("violation.Code=%q want %q", violation.Code, ViolationSymlinkTraversal) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/sandbox/ -run 'TestNewScope|TestScope' -v` -Expected: FAIL — `undefined: NewScope` (compile error). - -- [ ] **Step 3: Implement `internal/sandbox/scope.go`** - -```go -package sandbox - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" -) - -// Scope is the shared set of directories the sandbox allows writes in: the -// workspace root plus zero or more user-granted extra roots. One instance is -// created per run and shared by the policy engine, the OS command runners, and -// the file tools, so a mid-session Add is immediately visible to every layer. -type Scope struct { - mu sync.RWMutex - workspaceRoot string - extraRoots []string -} - -// NewScope builds a scope for workspaceRoot plus the given extra roots. The -// workspace root is normalized best-effort (it may not exist in tests); every -// extra root must normalize strictly via Add and an invalid one fails the -// whole construction so a bad --add-dir/config entry surfaces at startup. -func NewScope(workspaceRoot string, extras []string) (*Scope, error) { - scope := &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)} - for _, extra := range extras { - if _, err := scope.Add(extra); err != nil { - return nil, fmt.Errorf("write root %q: %w", extra, err) - } - } - return scope, nil -} - -func (s *Scope) WorkspaceRoot() string { - return s.workspaceRoot -} - -// Roots returns the workspace root first, then the extra roots, as a copy. -func (s *Scope) Roots() []string { - s.mu.RLock() - defer s.mu.RUnlock() - roots := make([]string, 0, 1+len(s.extraRoots)) - roots = append(roots, s.workspaceRoot) - roots = append(roots, s.extraRoots...) - return roots -} - -// Add grants write access under path. The path must be an existing directory; -// it is home-expanded, made absolute, and symlink-resolved before being -// trusted, and the filesystem root is rejected outright. Adding a path already -// covered by an existing root is an idempotent success. -func (s *Scope) Add(path string) (string, error) { - root, err := normalizeScopeRoot(path) - if err != nil { - return "", err - } - s.mu.Lock() - defer s.mu.Unlock() - for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) { - if pathWithinRoot(existing, root) { - return root, nil - } - } - s.extraRoots = append(s.extraRoots, root) - return root, nil -} - -// validate reports whether requestedPath is allowed by any scope root. -// Relative paths resolve against the workspace root only; absolute paths are -// accepted if they validate (including per-segment symlink checks) under ANY -// root. The returned violation is the workspace root's, with an actionable -// hint appended for plain outside-workspace denials. -func (s *Scope) validate(requestedPath string) *pathViolation { - roots := s.Roots() - if !filepath.IsAbs(requestedPath) { - return validateWorkspacePath(roots[0], requestedPath) - } - var first *pathViolation - for _, root := range roots { - violation := validateWorkspacePath(root, requestedPath) - if violation == nil { - return nil - } - if first == nil { - first = violation - } - } - if first != nil && first.Code == ViolationOutsideWorkspace { - first.Reason += " (use /add-dir or --add-dir to allow writes there)" - } - return first -} - -func normalizeWorkspaceRootBestEffort(workspaceRoot string) string { - trimmed := strings.TrimSpace(workspaceRoot) - if trimmed == "" { - return "" - } - absolute, err := filepath.Abs(trimmed) - if err != nil { - return filepath.Clean(trimmed) - } - if resolved, err := filepath.EvalSymlinks(absolute); err == nil { - return resolved - } - return filepath.Clean(absolute) -} - -func normalizeScopeRoot(path string) (string, error) { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return "", errors.New("write root path is empty") - } - if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("expand ~: %w", err) - } - trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator))) - } - absolute, err := filepath.Abs(trimmed) - if err != nil { - return "", err - } - resolved, err := filepath.EvalSymlinks(absolute) - if err != nil { - return "", fmt.Errorf("write root must exist: %w", err) - } - info, err := os.Stat(resolved) - if err != nil { - return "", err - } - if !info.IsDir() { - return "", fmt.Errorf("write root %s is not a directory", resolved) - } - if filepath.Dir(resolved) == resolved { - return "", fmt.Errorf("refusing filesystem root %s as a write root", resolved) - } - return resolved, nil -} - -func pathWithinRoot(root string, candidate string) bool { - if root == "" { - return false - } - relative, err := filepath.Rel(root, candidate) - if err != nil { - return false - } - return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/sandbox/ -run 'TestNewScope|TestScope' -v` -Expected: PASS (symlink test may SKIP on platforms without symlink support). - -- [ ] **Step 5: Commit** - -```bash -git add internal/sandbox/scope.go internal/sandbox/scope_test.go -git commit -m "sandbox: add Scope type for multi-root write access" -``` - ---- - -## Task 2: Engine integration (policy layer + risk) - -**Files:** -- Modify: `internal/sandbox/engine.go` (EngineOptions, Engine struct, Evaluate at line 88) -- Modify: `internal/sandbox/risk.go` (Classify path loop at lines 107-124) -- Test: `internal/sandbox/engine_test.go` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `internal/sandbox/engine_test.go`: - -```go -func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - engine := NewEngine(EngineOptions{ - WorkspaceRoot: workspace, - Policy: DefaultPolicy(), - Scope: scope, - }) - - inside := engine.Evaluate(context.Background(), Request{ - ToolName: "write_file", - SideEffect: SideEffectWrite, - Permission: PermissionAllow, - Args: map[string]any{"path": filepath.Join(extra, "report.txt")}, - }) - if inside.Action != ActionAllow { - t.Fatalf("extra-root write Action=%q (%s), want allow", inside.Action, inside.Reason) - } - if HasRiskCategory(inside.Risk, "out_of_workspace") { - t.Fatalf("extra-root write risk=%v, must not be out_of_workspace", inside.Risk) - } - - outside := engine.Evaluate(context.Background(), Request{ - ToolName: "write_file", - SideEffect: SideEffectWrite, - Permission: PermissionAllow, - Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")}, - }) - if outside.Action != ActionDeny || outside.Violation == nil { - t.Fatalf("outside write Action=%q, want deny with violation", outside.Action) - } - if !strings.Contains(outside.Violation.Reason, "--add-dir") { - t.Fatalf("outside violation reason=%q, want --add-dir hint", outside.Violation.Reason) - } -} -``` - -Add `"path/filepath"` and `"strings"` to the test file imports if not present. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/sandbox/ -run TestEvaluateAllowsWritesInsideExtraScopeRoot -v` -Expected: FAIL — `unknown field Scope in struct literal` (compile error). - -- [ ] **Step 3: Implement engine + risk changes** - -In `internal/sandbox/engine.go`: - -(a) Add `Scope *Scope` to `EngineOptions` and `scope *Scope` to `Engine`. In `NewEngine`, after the existing fields, default the scope: - -```go -func NewEngine(options EngineOptions) *Engine { - policy := options.Policy - if policy.Mode == "" { - policy = DefaultPolicy() - } - scope := options.Scope - workspaceRoot := strings.TrimSpace(options.WorkspaceRoot) - if scope == nil && workspaceRoot != "" { - scope = &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)} - } - return &Engine{ - workspaceRoot: workspaceRoot, - policy: policy, - store: options.Store, - backend: options.Backend, - scope: scope, - } -} - -// Scope returns the engine's shared write scope (nil when the engine was -// built without a workspace root). The TUI uses it for /add-dir. -func (engine *Engine) Scope() *Scope { - if engine == nil { - return nil - } - return engine.scope -} - -// scopeFor returns the scope to validate request paths against. The engine's -// shared scope applies only when the request targets the engine's own -// workspace root; a per-request override root gets an ad-hoc single-root scope -// so subagent-style overrides keep today's exact semantics. -func (engine *Engine) scopeFor(requestRoot string) *Scope { - if engine.scope != nil && requestRoot == engine.workspaceRoot { - return engine.scope - } - return &Scope{workspaceRoot: requestRoot} -} -``` - -(b) In `Evaluate`, replace the workspace gate (currently `if policy.EnforceWorkspace && request.WorkspaceRoot != "" { if violation := validateWorkspacePaths(...)`) with: - -```go - if policy.EnforceWorkspace && request.WorkspaceRoot != "" { - scope := engine.scopeFor(request.WorkspaceRoot) - for _, requested := range requestPaths(request) { - if requested == "" { - continue - } - if violation := scope.validate(requested); violation != nil { - return deny(request, risk, violation.Code, violation.Path, violation.Reason, false) - } - } - } -``` - -(c) Still in `Evaluate`, change `risk := Classify(request)` to `risk := classifyWithScope(request, engine.scopeFor(request.WorkspaceRoot))` — but only after the `request.WorkspaceRoot = firstNonEmpty(...)` line, where it already sits. Guard for the nil-engine path at the top of Evaluate: that early return already calls `Classify(request)` directly and stays unchanged. - -In `internal/sandbox/risk.go`: - -(d) Add a scope-aware variant and keep `Classify` as the public single-root wrapper: - -```go -func Classify(request Request) Risk { - return classifyWithScope(request, nil) -} - -func classifyWithScope(request Request, scope *Scope) Risk { -``` - -(e) Inside the per-path loop (lines 107-124), replace the `validateWorkspacePath` call with the scope when present: - -```go - if request.WorkspaceRoot != "" { - var violation *pathViolation - if scope != nil { - violation = scope.validate(path) - } else { - violation = validateWorkspacePath(request.WorkspaceRoot, path) - } - if violation != nil { - switch violation.Code { - case ViolationSymlinkTraversal: - add("symlink_traversal", RiskCritical) - default: - add("out_of_workspace", RiskCritical) - } - } - } -``` - -- [ ] **Step 4: Run the sandbox suite** - -Run: `go test ./internal/sandbox/ -v 2>&1 | tail -20` -Expected: PASS, including all pre-existing engine/risk tests (the nil-scope and ad-hoc paths must behave exactly as before). - -- [ ] **Step 5: Commit** - -```bash -git add internal/sandbox/engine.go internal/sandbox/risk.go internal/sandbox/engine_test.go -git commit -m "sandbox: make engine path validation and risk scope-aware" -``` - ---- - -## Task 3: OS runners (seatbelt profile, bubblewrap binds, command cwd) - -**Files:** -- Modify: `internal/sandbox/runner.go` (`BuildCommandPlan`, `resolveCommandDir`, `bubblewrapCommandPlan`, `sandboxExecCommandPlan`, `sandboxExecProfile`) -- Test: `internal/sandbox/runner_test.go` (append) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/sandbox/runner_test.go`: - -```go -func TestSandboxExecProfileIncludesExtraWriteRoots(t *testing.T) { - profile := sandboxExecProfile([]string{"/ws", "/extra root"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}) - if !strings.Contains(profile, `(allow file-write* (subpath "/ws") (subpath "/extra root"))`) { - t.Fatalf("profile missing multi-root write rule:\n%s", profile) - } -} - -func TestBubblewrapPlanBindsExtraWriteRoots(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - engine := NewEngine(EngineOptions{ - WorkspaceRoot: workspace, - Policy: DefaultPolicy(), - Scope: scope, - Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"}, - }) - plan, err := engine.BuildCommandPlan(CommandSpec{Name: "true"}) - if err != nil { - t.Fatalf("BuildCommandPlan: %v", err) - } - joined := strings.Join(plan.Args, " ") - resolvedExtra := scope.Roots()[1] - if !strings.Contains(joined, "--bind "+resolvedExtra+" "+resolvedExtra) { - t.Fatalf("bubblewrap args missing rw bind for extra root %q:\n%s", resolvedExtra, joined) - } -} - -func TestResolveCommandDirAllowsExtraRootCwd(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - engine := NewEngine(EngineOptions{WorkspaceRoot: workspace, Policy: DefaultPolicy(), Scope: scope}) - if _, _, _, err := engine.resolveCommandDir(extra, engine.policy); err != nil { - t.Fatalf("resolveCommandDir(extra root) = %v, want nil", err) - } - if _, _, _, err := engine.resolveCommandDir(t.TempDir(), engine.policy); err == nil { - t.Fatal("resolveCommandDir(outside all roots) = nil error, want violation") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/sandbox/ -run 'TestSandboxExecProfileIncludesExtra|TestBubblewrapPlanBindsExtra|TestResolveCommandDirAllowsExtraRoot' -v` -Expected: FAIL — `sandboxExecProfile` signature mismatch (compile error). - -- [ ] **Step 3: Implement runner changes** - -In `internal/sandbox/runner.go`: - -(a) Add a helper on Engine: - -```go -// writeRoots returns the full ordered write-root list for command plans: -// the workspace root plus any granted extra roots. -func (engine *Engine) writeRoots(workspaceRoot string) []string { - if engine.scope != nil { - return engine.scope.Roots() - } - return []string{workspaceRoot} -} -``` - -(b) In `BuildCommandPlan`, thread the roots into both backends. Replace the two backend cases: - -```go - switch backend.Name { - case BackendBubblewrap: - if backend.Available && backend.Executable != "" { - return bubblewrapCommandPlan(spec, workspaceRoot, relativeDir, engine.writeRoots(workspaceRoot), policy, backend), nil - } - case BackendSandboxExec: - if backend.Available && backend.Executable != "" { - return sandboxExecCommandPlan(spec, workspaceRoot, engine.writeRoots(workspaceRoot), policy, backend), nil - } - } -``` - -(c) In `resolveCommandDir`, replace the `validateWorkspacePath(workspaceRoot, commandDir)` call (line 133) with the engine scope, keeping the Violation construction identical: - -```go - if policy.EnforceWorkspace { - if violation := engine.scopeFor(engine.workspaceRoot).validate(commandDir); violation != nil { -``` - -Note `resolveCommandDir` is already an Engine method, so `engine` is in scope. The `relativeDir` computation below it stays, but a cwd inside an extra root yields a `..`-prefixed relativeDir — handled in (d). - -(d) `bubblewrapCommandPlan` gains a `writeRoots []string` parameter (after `relativeDir`). After the workspace `--bind`, add extra binds, and fall back to the real path for an extra-root cwd: - -```go -func bubblewrapCommandPlan(spec CommandSpec, workspaceRoot string, relativeDir string, writeRoots []string, policy Policy, backend Backend) CommandPlan { - sandboxDir := bubblewrapWorkspace - if relativeDir != "" { - sandboxDir = filepath.ToSlash(filepath.Join(bubblewrapWorkspace, relativeDir)) - } - // A cwd inside an extra write root is outside the /workspace remount; the - // extra root is bound at its real host path, so chdir there directly. - if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) { - sandboxDir = filepath.ToSlash(spec.Dir) - } - args := []string{ - "--die-with-parent", - "--unshare-pid", - "--unshare-ipc", - "--unshare-uts", - "--proc", "/proc", - "--dev", "/dev", - "--tmpfs", "/tmp", - "--bind", workspaceRoot, bubblewrapWorkspace, - } - for _, root := range writeRoots { - if root == workspaceRoot { - continue - } - args = append(args, "--bind", root, root) - } - args = append(args, "--chdir", sandboxDir) -``` - -The rest of the function (network, ro-binds, env, trailing `--`) is unchanged — keep the existing lines after `--chdir` exactly as they are today (the `--chdir` pair moves from the initial literal into this appended position). - -(e) `sandboxExecCommandPlan` gains `writeRoots []string` (after `workspaceRoot`) and passes it to the profile: - -```go -func sandboxExecCommandPlan(spec CommandSpec, workspaceRoot string, writeRoots []string, policy Policy, backend Backend) CommandPlan { - args := []string{"-p", sandboxExecProfile(writeRoots, policy), spec.Name} -``` - -(f) `sandboxExecProfile` takes the roots list: - -```go -func sandboxExecProfile(writeRoots []string, policy Policy) string { - networkRule := "(deny network*)" - if policy.Network == NetworkAllow { - networkRule = "(allow network*)" - } - writeRule := "(allow file-write*)" - if policy.EnforceWorkspace { - subpaths := make([]string, 0, len(writeRoots)) - for _, root := range writeRoots { - subpaths = append(subpaths, `(subpath "`+sandboxProfileString(root)+`")`) - } - writeRule = "(allow file-write* " + strings.Join(subpaths, " ") + ")" - } -``` - -The trailing `strings.Join([...])` block is unchanged. - -(g) Fix any existing callers of the old signatures surfaced by the compiler (tests in `runner_test.go` that call `sandboxExecProfile(workspaceRoot, policy)` become `sandboxExecProfile([]string{workspaceRoot}, policy)`). - -- [ ] **Step 4: Run the sandbox suite** - -Run: `go test ./internal/sandbox/ 2>&1 | tail -5` -Expected: `ok github.com/Gitlawb/zero/internal/sandbox`. - -- [ ] **Step 5: Commit** - -```bash -git add internal/sandbox/runner.go internal/sandbox/runner_test.go -git commit -m "sandbox: widen seatbelt/bubblewrap profiles and command cwd to scope roots" -``` - ---- - -## Task 4: File tools honor the scope - -**Files:** -- Modify: `internal/tools/workspace.go` (scoped resolver variants) -- Modify: `internal/tools/registry.go` (scoped Core*Tools) -- Modify: `internal/tools/read_file.go`, `list_directory.go`, `glob.go`, `grep.go`, `write_file.go`, `edit_file.go`, `apply_patch.go`, `bash.go` (scope field + scoped constructor + resolver call sites) -- Test: `internal/tools/file_tools_test.go` (append) - -- [ ] **Step 1: Write the failing tests** - -Append to `internal/tools/file_tools_test.go`: - -```go -func TestScopedToolsAllowExtraRootWrites(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := sandbox.NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - target := filepath.Join(extra, "saved.txt") - - res := NewScopedWriteFileTool(workspace, scope).Run(context.Background(), map[string]any{ - "path": target, - "content": "hello", - }) - if res.Status != StatusOK { - t.Fatalf("scoped write_file status=%s output=%s", res.Status, res.Output) - } - read := NewScopedReadFileTool(workspace, scope).Run(context.Background(), map[string]any{"path": target}) - if read.Status != StatusOK || !strings.Contains(read.Output, "hello") { - t.Fatalf("scoped read_file status=%s output=%s", read.Status, read.Output) - } -} - -func TestScopedToolsKeepRelativePathsInWorkspace(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := sandbox.NewScope(workspace, []string{extra}) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - res := NewScopedWriteFileTool(workspace, scope).Run(context.Background(), map[string]any{ - "path": "rel.txt", - "content": "workspace", - }) - if res.Status != StatusOK { - t.Fatalf("relative write status=%s output=%s", res.Status, res.Output) - } - if _, err := os.Stat(filepath.Join(workspace, "rel.txt")); err != nil { - t.Fatalf("relative path must land in workspace: %v", err) - } -} - -func TestUnscopedToolsStillRejectOutsideWrites(t *testing.T) { - workspace := t.TempDir() - outside := filepath.Join(t.TempDir(), "escape.txt") - res := NewWriteFileTool(workspace).Run(context.Background(), map[string]any{ - "path": outside, - "content": "nope", - }) - if res.Status == StatusOK { - t.Fatalf("unscoped write outside workspace must fail, got OK: %s", res.Output) - } -} -``` - -Add `"github.com/Gitlawb/zero/internal/sandbox"` to the test imports. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/tools/ -run 'TestScopedTools|TestUnscopedToolsStillReject' -v` -Expected: FAIL — `undefined: NewScopedWriteFileTool` (compile error). - -- [ ] **Step 3: Add the scoped resolvers to `internal/tools/workspace.go`** - -Append: - -```go -// PathScope is the multi-root write scope shared with the sandbox engine. -// *sandbox.Scope satisfies it; nil means workspace-only (today's behavior). -type PathScope interface { - Roots() []string -} - -// scopedRoots returns the ordered roots to try for an absolute path: -// the scope's roots when present, else just the workspace root. -func scopedRoots(workspaceRoot string, scope PathScope) []string { - if scope == nil { - return []string{workspaceRoot} - } - return scope.Roots() -} - -// resolveScopedPath is resolveWorkspacePath generalized to a scope: relative -// paths resolve against the workspace root only; an absolute path resolves -// against the first root that contains it. The workspace root's error is -// returned when no root matches so messages stay stable. -func resolveScopedPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) { - if requestedPath == "" || !filepath.IsAbs(requestedPath) { - return resolveWorkspacePath(workspaceRoot, requestedPath) - } - var firstErr error - for _, root := range scopedRoots(workspaceRoot, scope) { - absolute, relative, err := resolveWorkspacePath(root, requestedPath) - if err == nil { - return absolute, relative, nil - } - if firstErr == nil { - firstErr = err - } - } - return "", "", firstErr -} - -// resolveScopedTargetPath mirrors resolveWorkspaceTargetPath for write targets -// (the target may not exist yet) across all scope roots. -func resolveScopedTargetPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) { - if requestedPath == "" || !filepath.IsAbs(requestedPath) { - return resolveWorkspaceTargetPath(workspaceRoot, requestedPath) - } - var firstErr error - for _, root := range scopedRoots(workspaceRoot, scope) { - absolute, relative, err := resolveWorkspaceTargetPath(root, requestedPath) - if err == nil { - return absolute, relative, nil - } - if firstErr == nil { - firstErr = err - } - } - return "", "", firstErr -} - -// recheckScopedWriteTarget mirrors recheckWorkspaceWriteTarget across roots. -func recheckScopedWriteTarget(workspaceRoot string, scope PathScope, requestedPath string) error { - if requestedPath == "" || !filepath.IsAbs(requestedPath) { - return recheckWorkspaceWriteTarget(workspaceRoot, requestedPath) - } - var firstErr error - for _, root := range scopedRoots(workspaceRoot, scope) { - err := recheckWorkspaceWriteTarget(root, requestedPath) - if err == nil { - return nil - } - if firstErr == nil { - firstErr = err - } - } - return firstErr -} -``` - -- [ ] **Step 4: Thread the scope through the eight tools** - -For each tool the change is mechanical and identical in shape. Shown in full for `write_file.go`; repeat for the others with their own type/constructor names. - -(a) `internal/tools/write_file.go` — add the field and scoped constructor (adapt to the actual struct/constructor names in the file; the struct currently holds `workspaceRoot string`): - -```go -type WriteFileTool struct { - workspaceRoot string - scope PathScope -} - -func NewWriteFileTool(workspaceRoot string) WriteFileTool { - return NewScopedWriteFileTool(workspaceRoot, nil) -} - -func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) WriteFileTool { - return WriteFileTool{workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), scope: scope} -} -``` - -If the existing constructor does extra setup, keep it and move the body into the scoped variant. Then replace every `resolveWorkspacePath(tool.workspaceRoot, X)` with `resolveScopedPath(tool.workspaceRoot, tool.scope, X)`, every `resolveWorkspaceTargetPath(tool.workspaceRoot, X)` with `resolveScopedTargetPath(tool.workspaceRoot, tool.scope, X)`, and every `recheckWorkspaceWriteTarget(tool.workspaceRoot, X)` with `recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, X)` inside the tool's methods. - -(b) Repeat (a) for: -- `read_file.go` → `NewScopedReadFileTool` (resolver at read_file.go:56) -- `list_directory.go` → `NewScopedListDirectoryTool` (list_directory.go:59) -- `glob.go` → `NewScopedGlobTool` (glob.go:63) -- `grep.go` → `NewScopedGrepTool` (grep.go:96) -- `edit_file.go` → `NewScopedEditFileTool` (edit_file.go:55 and the recheck at :82) -- `apply_patch.go` → `NewScopedApplyPatchTool` (apply_patch.go:47 and the recheck at :146 — the recheck takes a local `root`; pass `tool.scope` alongside) -- `bash.go` → `NewScopedBashTool` (cwd resolution at bash.go:80) - -Where a method passes `tool.workspaceRoot` into package helpers like `mutation_targets.go`, leave those untouched — checkpoint/rewind stays workspace-only by design (extra roots are not checkpointed). - -(c) `internal/tools/registry.go` — add scoped variants, with the old ones delegating: - -```go -func CoreReadOnlyTools(workspaceRoot string) []Tool { - return CoreReadOnlyToolsScoped(workspaceRoot, nil) -} - -func CoreReadOnlyToolsScoped(workspaceRoot string, scope PathScope) []Tool { - return []Tool{ - NewScopedReadFileTool(workspaceRoot, scope), - NewScopedListDirectoryTool(workspaceRoot, scope), - NewScopedGlobTool(workspaceRoot, scope), - NewScopedGrepTool(workspaceRoot, scope), - NewSkillTool(""), - NewAskUserTool(), - } -} - -func CoreWriteTools(workspaceRoot string) []Tool { - return CoreWriteToolsScoped(workspaceRoot, nil) -} - -func CoreWriteToolsScoped(workspaceRoot string, scope PathScope) []Tool { - return []Tool{ - NewScopedWriteFileTool(workspaceRoot, scope), - NewScopedEditFileTool(workspaceRoot, scope), - NewScopedApplyPatchTool(workspaceRoot, scope), - NewUpdatePlanTool(), - } -} - -func CoreShellTools(workspaceRoot string) []Tool { - return CoreShellToolsScoped(workspaceRoot, nil) -} - -func CoreShellToolsScoped(workspaceRoot string, scope PathScope) []Tool { - return []Tool{ - NewScopedBashTool(workspaceRoot, scope), - } -} - -func CoreTools(workspaceRoot string) []Tool { - return CoreToolsScoped(workspaceRoot, nil) -} - -func CoreToolsScoped(workspaceRoot string, scope PathScope) []Tool { - tools := append([]Tool{}, CoreReadOnlyToolsScoped(workspaceRoot, scope)...) - tools = append(tools, CoreWriteToolsScoped(workspaceRoot, scope)...) - tools = append(tools, CoreShellToolsScoped(workspaceRoot, scope)...) - tools = append(tools, CoreNetworkTools()...) - return tools -} -``` - -Keep the existing comments on the skill tool. Preserve the original `NewSkillTool`/`NewAskUserTool`/`NewUpdatePlanTool`/`CoreNetworkTools` lines verbatim. - -- [ ] **Step 5: Run the tools suite** - -Run: `go test ./internal/tools/ 2>&1 | tail -5` -Expected: `ok github.com/Gitlawb/zero/internal/tools` — both the new tests and all pre-existing confinement tests (nil scope must be byte-identical behavior). - -- [ ] **Step 6: Commit** - -```bash -git add internal/tools/ -git commit -m "tools: resolve paths against the shared write scope" -``` - ---- - -## Task 5: Config key - -**Files:** -- Modify: `internal/config/types.go:57` (SandboxConfig) -- Modify: `internal/config/resolver.go` (merge, near lines 150-155) -- Test: `internal/config/resolver_test.go` or the existing SandboxConfig test file (locate with `grep -rn "Sandbox.Network" internal/config/*_test.go`) - -- [ ] **Step 1: Write the failing test** - -Append next to the existing Sandbox.Network merge test (same file, same style — note `mergeConfig` takes `*FileConfig`): - -```go -func TestMergeConfigUnionsSandboxAdditionalWriteRoots(t *testing.T) { - dst := FileConfig{} - dst.Sandbox.AdditionalWriteRoots = []string{"/global/one"} - src := FileConfig{} - src.Sandbox.AdditionalWriteRoots = []string{"/extra/one", "/global/one"} - mergeConfig(&dst, src) - want := []string{"/global/one", "/extra/one"} - if !reflect.DeepEqual(dst.Sandbox.AdditionalWriteRoots, want) { - t.Fatalf("AdditionalWriteRoots=%v want union %v (append + dedupe, not replace)", dst.Sandbox.AdditionalWriteRoots, want) - } -} - -func TestMergeProjectConfigIgnoresAdditionalWriteRoots(t *testing.T) { - dst := FileConfig{} - dst.Sandbox.AdditionalWriteRoots = []string{"/global/one"} - src := FileConfig{} - src.Sandbox.AdditionalWriteRoots = []string{"/repo/sneaky"} - if err := mergeProjectConfig(&dst, src); err != nil { - t.Fatalf("mergeProjectConfig: %v", err) - } - if !reflect.DeepEqual(dst.Sandbox.AdditionalWriteRoots, []string{"/global/one"}) { - t.Fatalf("AdditionalWriteRoots=%v — project config must NOT be able to add write roots", dst.Sandbox.AdditionalWriteRoots) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/config/ -run TestResolveMergesSandboxAdditionalWriteRoots -v` -Expected: FAIL — `AdditionalWriteRoots undefined` (compile error). - -- [ ] **Step 3: Implement** - -In `internal/config/types.go`, extend SandboxConfig: - -```go -type SandboxConfig struct { - MaxAutonomy string `json:"maxAutonomy,omitempty"` - // Network controls whether shell commands classified as network-touching - // (curl, git push, package installs, …) are allowed: "allow" or "deny". - // Empty keeps the built-in default (deny). Without this knob the engine's - // hard-coded NetworkDeny was unreachable from any config surface. - Network string `json:"network,omitempty"` - // AdditionalWriteRoots lists directories outside the workspace the sandbox - // allows writes in. Each entry must be an existing directory; entries are - // normalized (~-expanded, absolutized, symlink-resolved) at startup and an - // invalid entry fails the run. Session-only grants use /add-dir instead. - AdditionalWriteRoots []string `json:"additionalWriteRoots,omitempty"` -} -``` - -In `internal/config/resolver.go`, inside `mergeConfig` next to the `src.Sandbox.Network` block (~line 153), add a UNION merge (append + dedupe — a later config layer must add to, not replace, the global grants): - -```go - dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots) -``` - -with the helper (place near the other small helpers in resolver.go): - -```go -// unionStrings appends the values of extra that are not already present in -// base, preserving order. Used for additive config keys like -// sandbox.additionalWriteRoots where a later layer must not erase earlier -// grants. -func unionStrings(base []string, extra []string) []string { - seen := make(map[string]struct{}, len(base)) - for _, value := range base { - seen[value] = struct{}{} - } - for _, value := range extra { - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - base = append(base, value) - } - return base -} -``` - -Do NOT touch `mergeProjectConfig`: it is an explicit allowlist of project-settable keys, and `additionalWriteRoots` must stay off it — a repo-controlled `.zero/config.json` must not be able to widen write access outside the workspace. Add this comment above `mergeProjectConfig`'s Sandbox block so the omission reads as deliberate: - -```go - // Sandbox.AdditionalWriteRoots is intentionally NOT merged from project - // config: a cloned repo's .zero/config.json must not be able to grant - // itself write access outside the workspace. Global config and CLI flags - // are the only config sources for write roots. -``` - -`Resolve` copies the whole Sandbox struct into the resolved view (`Sandbox: cfg.Sandbox` ~line 103), so no further change is needed there. - -- [ ] **Step 4: Run config tests** - -Run: `go test ./internal/config/ 2>&1 | tail -3` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/config/ -git commit -m "config: add sandbox.additionalWriteRoots" -``` - ---- - -## Task 6: CLI wiring (`--add-dir` on `zero` and `zero exec`) - -**Files:** -- Modify: `internal/cli/app.go` (`runWithDeps` arg handling at lines 138-155, `runInteractiveTUI` at 310-389, `newCoreRegistry` at 398) -- Modify: `internal/cli/exec.go` (`execOptions` struct at :50, `buildExecSandboxEngine` and the exec registry construction — locate with `grep -n "newCoreRegistry\|buildExecSandboxEngine\|CoreTools" internal/cli/exec.go`) -- Modify: `internal/cli/exec_parse.go` (flag cases, mirror `--image` at lines 80-92) -- Modify: help text (`writeHelp` in app.go's help file and `writeExecHelp` — locate with `grep -rn "func writeHelp\|func writeExecHelp" internal/cli/`) -- Test: `internal/cli/exec_parse_image_test.go` pattern → new `internal/cli/exec_parse_add_dir_test.go`; app-level test next to existing `runWithDeps` tests - -- [ ] **Step 1: Write the failing parse tests** - -Create `internal/cli/exec_parse_add_dir_test.go`: - -```go -package cli - -import "testing" - -func TestParseExecArgsCollectsAddDirs(t *testing.T) { - options, _, err := parseExecArgs([]string{ - "--prompt", "hi", - "--add-dir", "/one", - "--add-dir=/two", - }) - if err != nil { - t.Fatalf("parseExecArgs: %v", err) - } - if len(options.addDirs) != 2 || options.addDirs[0] != "/one" || options.addDirs[1] != "/two" { - t.Fatalf("addDirs=%v want [/one /two]", options.addDirs) - } -} - -func TestParseExecArgsAddDirRequiresValue(t *testing.T) { - if _, _, err := parseExecArgs([]string{"--add-dir"}); err == nil { - t.Fatal("bare --add-dir must error") - } -} - -func TestSplitLeadingAddDirFlags(t *testing.T) { - addDirs, rest, err := splitLeadingAddDirFlags([]string{"--add-dir", "/one", "--add-dir=/two", "exec", "--prompt", "x"}) - if err != nil { - t.Fatalf("splitLeadingAddDirFlags: %v", err) - } - if len(addDirs) != 2 || addDirs[0] != "/one" || addDirs[1] != "/two" { - t.Fatalf("addDirs=%v want [/one /two]", addDirs) - } - if len(rest) != 3 || rest[0] != "exec" { - t.Fatalf("rest=%v want [exec --prompt x]", rest) - } - if _, _, err := splitLeadingAddDirFlags([]string{"--add-dir"}); err == nil { - t.Fatal("trailing bare --add-dir must error") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/cli/ -run 'TestParseExecArgsCollectsAddDirs|TestParseExecArgsAddDirRequiresValue|TestSplitLeadingAddDirFlags' -v` -Expected: FAIL — `options.addDirs undefined`, `undefined: splitLeadingAddDirFlags`. - -- [ ] **Step 3: Implement parsing** - -(a) `internal/cli/exec.go` — add `addDirs []string` to `execOptions` (after `cwd`). - -(b) `internal/cli/exec_parse.go` — add cases mirroring the `--image` pair at lines 80-92: - -```go - case arg == "--add-dir": - value, next, err := nextFlagValue(args, index, arg) - if err != nil { - return options, false, err - } - options.addDirs = append(options.addDirs, value) - index = next - case strings.HasPrefix(arg, "--add-dir="): - value, err := requiredInlineFlagValue(arg, "--add-dir") - if err != nil { - return options, false, err - } - options.addDirs = append(options.addDirs, value) -``` - -(c) `internal/cli/app.go` — add the leading-flag splitter: - -```go -// splitLeadingAddDirFlags strips leading --add-dir flags from the root -// argument list (zero --add-dir [--add-dir ] [subcommand …]). -// Subcommands like exec parse their own --add-dir occurrences. -func splitLeadingAddDirFlags(args []string) ([]string, []string, error) { - addDirs := []string{} - for len(args) > 0 { - switch { - case args[0] == "--add-dir": - if len(args) < 2 || strings.TrimSpace(args[1]) == "" { - return nil, nil, errors.New("--add-dir requires a directory path") - } - addDirs = append(addDirs, args[1]) - args = args[2:] - case strings.HasPrefix(args[0], "--add-dir="): - value := strings.TrimSpace(strings.TrimPrefix(args[0], "--add-dir=")) - if value == "" { - return nil, nil, errors.New("--add-dir requires a directory path") - } - addDirs = append(addDirs, value) - args = args[1:] - default: - return addDirs, args, nil - } - } - return addDirs, args, nil -} -``` - -In `runWithDeps` (line 138), immediately after `deps = fillAppDeps(deps)`: - -```go - addDirs, args, err := splitLeadingAddDirFlags(args) - if err != nil { - return writeAppError(stderr, err.Error(), 1) - } -``` - -Then pass `addDirs` into the two `runInteractiveTUI` calls (lines 145 and 154); `runInteractiveTUI` gains a final `addDirs []string` parameter. - -- [ ] **Step 4: Wire the scope in `runInteractiveTUI` and exec** - -(a) In `runInteractiveTUI` (app.go:310), after `resolved, err := deps.resolveConfig(...)` succeeds, build the scope and use it for both the registry and the engine: - -```go - scope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), addDirs...)) - if err != nil { - return writeAppError(stderr, err.Error(), 1) - } -``` - -Change `registry := newCoreRegistry(workspaceRoot)` to `registry := newCoreRegistryScoped(workspaceRoot, scope)` and add `Scope: scope,` to the `sandbox.EngineOptions{...}` literal at line 362. - -(b) Update `newCoreRegistry` (app.go:398): - -```go -func newCoreRegistry(workspaceRoot string) *tools.Registry { - return newCoreRegistryScoped(workspaceRoot, nil) -} - -func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.Registry { - registry := tools.NewRegistry() - for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) { - registry.Register(tool) - } - return registry -} -``` - -(c) In `internal/cli/exec.go`: `buildExecSandboxEngine` gains a `scope *sandbox.Scope` parameter and sets `Scope: scope` in its `EngineOptions`. In the exec run path (`grep -n "buildExecSandboxEngine\|newCoreRegistry" internal/cli/exec.go` to find both call sites), build the scope first from the exec workspace root, `resolved.Sandbox.AdditionalWriteRoots`, and `options.addDirs` (same `sandbox.NewScope` call as (a), erroring out via exec's existing error path), then pass it to both the registry construction and `buildExecSandboxEngine`. - -(d) Help text: add to the root help (`writeHelp`) flag list: -```text - --add-dir Allow writes in an extra directory (repeatable) -``` -and the same line to `writeExecHelp`'s flag section, matching surrounding formatting. - -- [ ] **Step 5: Run CLI tests and build** - -Run: `go build ./... && go test ./internal/cli/ 2>&1 | tail -5` -Expected: build OK, `ok github.com/Gitlawb/zero/internal/cli`. - -- [ ] **Step 6: Commit** - -```bash -git add internal/cli/ -git commit -m "cli: add repeatable --add-dir flag wiring scope into registry and engine" -``` - ---- - -## Task 7: TUI `/add-dir` command - -**Files:** -- Modify: `internal/tui/commands.go` (kind const block at lines 9-37, definitions table at 64-230) -- Modify: `internal/tui/model.go` (command dispatch switch, next to `case commandImage:` at ~line 1096) -- Create: `internal/tui/add_dir.go` -- Test: `internal/tui/add_dir_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/tui/add_dir_test.go`: - -```go -package tui - -import ( - "strings" - "testing" - - "github.com/Gitlawb/zero/internal/agent" - "github.com/Gitlawb/zero/internal/sandbox" -) - -func TestParseCommandRecognizesAddDir(t *testing.T) { - parsed := parseCommand("/add-dir /tmp/extra") - if parsed.kind != commandAddDir { - t.Fatalf("kind=%v want commandAddDir", parsed.kind) - } - if parsed.text != "/tmp/extra" { - t.Fatalf("text=%q want path argument", parsed.text) - } -} - -func TestHandleAddDirCommand(t *testing.T) { - workspace := t.TempDir() - extra := t.TempDir() - scope, err := sandbox.NewScope(workspace, nil) - if err != nil { - t.Fatalf("NewScope: %v", err) - } - engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy(), Scope: scope}) - m := model{agentOptions: agent.Options{Sandbox: engine}} - - m = m.handleAddDirCommand(extra) - if len(scope.Roots()) != 2 { - t.Fatalf("Roots()=%v want workspace + extra after /add-dir", scope.Roots()) - } - last := m.transcript[len(m.transcript)-1] - if !strings.Contains(last.text, "write access added") || !strings.Contains(last.text, "session only") { - t.Fatalf("confirmation line=%q want added + session-only notice", last.text) - } - - m = m.handleAddDirCommand("") - last = m.transcript[len(m.transcript)-1] - if !strings.Contains(last.text, workspace) { - t.Fatalf("bare /add-dir output=%q want root listing", last.text) - } - - m = m.handleAddDirCommand("/definitely/not/a/real/dir") - last = m.transcript[len(m.transcript)-1] - if !strings.Contains(last.text, "add-dir:") { - t.Fatalf("invalid path output=%q want add-dir error", last.text) - } -} -``` - -Adapt the transcript access (`m.transcript[len(m.transcript)-1].text`) to the actual transcript entry shape — check how existing TUI tests read appended system lines (`grep -rn "actionAppendSystem" internal/tui/*_test.go | head -3`) and mirror that accessor. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/tui/ -run 'TestParseCommandRecognizesAddDir|TestHandleAddDirCommand' -v` -Expected: FAIL — `undefined: commandAddDir`. - -- [ ] **Step 3: Implement** - -(a) `internal/tui/commands.go`: add `commandAddDir` to the kind const block (before `commandUnknown`), and this entry to `commandDefinitions` (after the `/image` entry to keep session-group commands together): - -```go - { - name: "/add-dir", - usage: "/add-dir [path]", - group: commandGroupRuntime, - description: "Grant write access to a directory outside the workspace for this session; bare form lists current write roots.", - kind: commandAddDir, - }, -``` - -(b) Create `internal/tui/add_dir.go`: - -```go -package tui - -import ( - "strings" -) - -// handleAddDirCommand processes "/add-dir [path]". A bare "/add-dir" lists the -// current write roots; with a path it grants session-scoped write access via -// the sandbox engine's shared scope, so the policy gate, the OS profile of the -// next bash command, and the file tools all widen immediately. -func (m model) handleAddDirCommand(arg string) model { - engine := m.agentOptions.Sandbox - if engine == nil || engine.Scope() == nil { - return m.appendSystemNotice("add-dir: sandbox scope is unavailable in this session.") - } - scope := engine.Scope() - trimmed := strings.TrimSpace(arg) - if trimmed == "" { - return m.appendSystemNotice("Write roots:\n " + strings.Join(scope.Roots(), "\n ") + "\nUsage: /add-dir (grants are session-only; use sandbox.additionalWriteRoots in config to persist)") - } - root, err := scope.Add(trimmed) - if err != nil { - return m.appendSystemNotice("add-dir: " + err.Error()) - } - return m.appendSystemNotice("write access added: " + root + " (this session only)") -} - -func (m model) appendSystemNotice(text string) model { - m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text}) - return m -} -``` - -If `appendImageNotice` in `image_attach.go` is byte-identical to `appendSystemNotice`, refactor `image_attach.go` to call the new shared helper instead of keeping two copies. - -(c) `internal/tui/model.go`: in the command dispatch switch, next to `case commandImage:` (~line 1096): - -```go - case commandAddDir: - m = m.handleAddDirCommand(command.text) - return m, nil -``` - -- [ ] **Step 4: Run TUI tests** - -Run: `go test ./internal/tui/ 2>&1 | tail -3` -Expected: PASS (including any command-table snapshot/help tests — update their expected lists if they enumerate commands). - -- [ ] **Step 5: Commit** - -```bash -git add internal/tui/ -git commit -m "tui: add /add-dir command for session write-root grants" -``` - ---- - -## Task 8: Observability (`zero sandbox` status + snapshots) - -**Files:** -- Modify: `internal/cli/sandbox.go` (`runSandboxPolicyEffective` at :117, `formatEffectiveSandboxPolicy` at :146; find the caller with `grep -n "runSandboxPolicyEffective(" internal/cli/sandbox.go`) -- Modify: `internal/zerocommands/sandbox_snapshots.go` (`SandboxPlanSnapshot` at :75; find builders with `grep -rn "SandboxPlanSnapshot{" internal/`) -- Test: the existing tests for these surfaces (`grep -rn "formatEffectiveSandboxPolicy\|SandboxPlanSnapshot" internal/cli/*_test.go internal/zerocommands/*_test.go`) - -- [ ] **Step 1: Write the failing test** - -Append to the test file that already covers `formatEffectiveSandboxPolicy`: - -```go -func TestEffectiveSandboxPolicyListsWriteRoots(t *testing.T) { - out := formatEffectiveSandboxPolicy("/ws", zeroSandbox.DefaultPolicy(), zeroSandbox.Backend{}, zeroSandbox.BackendPlan{}, resolveSandboxGuards(zeroSandbox.DefaultPolicy()), "/grants", []string{"/ws", "/extra"}) - if !strings.Contains(out, "write_roots: /ws, /extra") { - t.Fatalf("missing write_roots line:\n%s", out) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/cli/ -run TestEffectiveSandboxPolicyListsWriteRoots -v` -Expected: FAIL — signature mismatch (compile error). - -- [ ] **Step 3: Implement** - -(a) `formatEffectiveSandboxPolicy` gains a trailing `writeRoots []string` parameter; insert after the `enforce_workspace` line: - -```go - "write_roots: " + strings.Join(writeRoots, ", "), -``` - -(b) `runSandboxPolicyEffective` gains the same trailing parameter, adds `WriteRoots []string \`json:"writeRoots"\`` to its inline JSON payload struct (populated with the parameter), and forwards it to `formatEffectiveSandboxPolicy`. - -(c) The caller of `runSandboxPolicyEffective` has the resolved config and workspace root; compute the roots the same way the engine does (workspace first, then `resolved.Sandbox.AdditionalWriteRoots` normalized via `sandbox.NewScope(workspaceRoot, roots)` then `scope.Roots()`; on NewScope error, fall back to `[]string{workspaceRoot}` and let the run continue — status must not crash on a stale config entry; append the error to the output line instead: `"write_roots_error: " + err.Error()`). - -(d) `internal/zerocommands/sandbox_snapshots.go`: add to `SandboxPlanSnapshot`: - -```go - WriteRoots []string `json:"writeRoots,omitempty"` -``` - -Update each `SandboxPlanSnapshot{` builder found by the grep to populate `WriteRoots` from the engine scope where one is reachable (`engine.Scope().Roots()`); where no engine/scope exists in the builder's inputs, leave it empty (omitempty keeps the JSON shape stable). - -- [ ] **Step 4: Run both suites** - -Run: `go test ./internal/cli/ ./internal/zerocommands/ 2>&1 | tail -4` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/cli/sandbox.go internal/zerocommands/ internal/cli/*_test.go -git commit -m "sandbox: surface write roots in zero sandbox status and plan snapshots" -``` - ---- - -## Task 9: Full verification + docs - -**Files:** -- Modify: `docs/` CLI/flag documentation if it exists (`grep -rln "image\b\|--autonomy" docs/ | head -5` — add `--add-dir` and `/add-dir` wherever flags/commands are enumerated) - -- [ ] **Step 1: Full build, vet, test** - -Run: `go build ./... && go vet ./... && go test ./... 2>&1 | grep -v "^ok" | head -20` -Expected: no FAIL lines. (`internal/config` provider-command tests are known to time out under full-suite load on this machine; re-run that package in isolation if it fails: `go test ./internal/config/`.) - -- [ ] **Step 2: Manual smoke test (macOS seatbelt — the original bug)** - -```bash -mkdir -p /tmp/zero-adddir-demo -go run ./cmd/zero exec --add-dir /tmp/zero-adddir-demo --autonomy high --prompt 'run: echo hello > /tmp/zero-adddir-demo/out.txt, then run: cat /tmp/zero-adddir-demo/out.txt' -cat /tmp/zero-adddir-demo/out.txt -``` -Expected: `hello` — the write outside the workspace succeeds. Then confirm the deny path still works and is actionable: - -```bash -go run ./cmd/zero exec --autonomy high --prompt 'run: echo hi > /tmp/zero-adddir-demo/denied.txt' 2>&1 | grep -i "add-dir" -``` -Expected: the failure output mentions `--add-dir`. - -- [ ] **Step 3: Update docs found by the grep, then commit** - -```bash -git add docs/ -git commit -m "docs: document --add-dir and /add-dir write-root grants" -``` - ---- - -## Self-review notes (already applied) - -- Spec coverage: Scope (Task 1), engine+risk (Task 2), seatbelt/bubblewrap/cwd (Task 3), file tools incl. bash cwd (Task 4), config key (Task 5), `--add-dir` on both entrypoints + fail-fast (Task 6), `/add-dir` session-only + listing (Task 7), status/snapshots (Task 8), security invariants are encoded in Task 1's rejections and Task 2/3's tests. -- Known judgment calls an implementer must preserve: relative paths NEVER resolve against extra roots; nil scope must be byte-identical to today's behavior; checkpoint/rewind (`mutation_targets.go`) stays workspace-only. -- Line numbers reference commit `6fb0231`; re-grep the named anchors if the tree has moved. diff --git a/docs/superpowers/plans/2026-06-12-agent-eval-benchmark-harness.md b/docs/superpowers/plans/2026-06-12-agent-eval-benchmark-harness.md deleted file mode 100644 index 6a6fbc34..00000000 --- a/docs/superpowers/plans/2026-06-12-agent-eval-benchmark-harness.md +++ /dev/null @@ -1,304 +0,0 @@ -# Agent Eval Benchmark Harness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results. - -**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace. - -**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines. - ---- - -## File Structure - -- Create `internal/agenteval/materialize.go`: fixture path resolution, directory copy, Git init/baseline helpers. -- Create `internal/agenteval/materialize_test.go`: fixture copy and Git baseline tests. -- Create `internal/agenteval/agent_command.go`: command-template based agent runner with `{prompt}`, `{workspace}`, `{task_id}` placeholders. -- Create `internal/agenteval/agent_command_test.go`: placeholder substitution, cwd, exit/error behavior. -- Create `internal/agenteval/benchmark.go`: benchmark orchestration and aggregate report. -- Create `internal/agenteval/benchmark_test.go`: end-to-end harness with fake agent runner and sample fixtures. -- Modify `internal/cli/agent_eval.go`: parse `zero eval bench` and wire `Harness`. -- Modify `internal/cli/agent_eval_test.go`: bench parsing, JSON/text output, report writing. -- Modify `docs/AGENT_EVALS.md`: document `bench` mode and real local command examples. - -## Task 1: Fixture Materializer - -**Files:** -- Create: `internal/agenteval/materialize.go` -- Create: `internal/agenteval/materialize_test.go` - -- [ ] **Step 1: Write failing tests** - -```go -func TestMaterializeTaskCopiesFixtureAndInitializesGit(t *testing.T) { - suitePath := filepath.Join("testdata", "sample_suite.json") - suite, err := LoadSuite(suitePath) - if err != nil { - t.Fatal(err) - } - task := suite.Tasks[0] - workRoot := t.TempDir() - - workspace, err := Materializer{}.MaterializeTask(context.Background(), suitePath, task, MaterializeInput{WorkRoot: workRoot}) - if err != nil { - t.Fatalf("MaterializeTask: %v", err) - } - if _, err := os.Stat(filepath.Join(workspace.Path, "go.mod")); err != nil { - t.Fatalf("fixture was not copied: %v", err) - } - if output, err := exec.Command("git", "-C", workspace.Path, "status", "--porcelain").CombinedOutput(); err != nil || strings.TrimSpace(string(output)) != "" { - t.Fatalf("workspace baseline is dirty: err=%v output=%s", err, output) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/agenteval -run TestMaterializeTask -count=1` -Expected: FAIL because `Materializer` is undefined. - -- [ ] **Step 3: Implement minimal materializer** - -Create a `Materializer` with: -- `MaterializeTask(ctx, suitePath string, task Task, input MaterializeInput) (Workspace, error)` -- `MaterializeInput{WorkRoot string}` -- `Workspace{Path string, TaskID string, FixturePath string}` -- Resolve relative `task.WorkspaceFixture` from `filepath.Dir(suitePath)`. -- Copy directories recursively using stdlib only. -- Skip `.git` while copying. -- Run `git init`, `git add .`, and `git commit -m "baseline"` with local user config/env. -- Return clear errors for missing fixture, absolute fixture escapes, and empty work root. - -- [ ] **Step 4: Verify** - -Run: -```bash -go test ./internal/agenteval -run 'TestMaterializeTask|TestMaterializer' -count=1 -``` - -## Task 2: Agent Command Runner - -**Files:** -- Create: `internal/agenteval/agent_command.go` -- Create: `internal/agenteval/agent_command_test.go` - -- [ ] **Step 1: Write failing tests** - -```go -func TestCommandAgentRunnerExpandsPlaceholders(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "agent.bat") - if err := os.WriteFile(script, []byte("@echo %1|%2|%3> out.txt\r\n"), 0o755); err != nil { - t.Fatal(err) - } - runner := CommandAgentRunner{Command: []string{script, "{task_id}", "{workspace}", "{prompt}"}} - result := runner.Run(context.Background(), AgentRunInput{TaskID: "task-a", WorkspacePath: dir, Prompt: "fix bug"}) - if result.ExitCode != 0 || result.Error != "" { - t.Fatalf("Run = %#v", result) - } - data, err := os.ReadFile(filepath.Join(dir, "out.txt")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "task-a") || !strings.Contains(string(data), dir) || !strings.Contains(string(data), "fix bug") { - t.Fatalf("placeholders not expanded: %q", data) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1` -Expected: FAIL because `CommandAgentRunner` is undefined. - -- [ ] **Step 3: Implement runner** - -Add: -- `type AgentRunInput struct { TaskID, Prompt, WorkspacePath string }` -- `type AgentRunResult struct { ExitCode int; Stdout, Stderr, Error string }` -- `type AgentRunner interface { Run(context.Context, AgentRunInput) AgentRunResult }` -- `type CommandAgentRunner struct { Command []string }` -- `Run` executes without shell interpolation, with `cmd.Dir = WorkspacePath`. -- Replace `{prompt}`, `{workspace}`, `{task_id}` in every arg. -- Empty command returns `ExitCode:-1` and `Error:"agent command is required"`. - -- [ ] **Step 4: Verify** - -Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1` - -## Task 3: Benchmark Harness - -**Files:** -- Create: `internal/agenteval/benchmark.go` -- Create: `internal/agenteval/benchmark_test.go` - -- [ ] **Step 1: Write failing tests** - -```go -func TestHarnessRunsTaskFromFixtureAndScoresResult(t *testing.T) { - suitePath := filepath.Join("testdata", "sample_suite.json") - suite, err := LoadSuite(suitePath) - if err != nil { - t.Fatal(err) - } - harness := Harness{ - Materializer: Materializer{}, - Agent: agentRunnerFunc(func(ctx context.Context, input AgentRunInput) AgentRunResult { - target := filepath.Join(input.WorkspacePath, "docs", "STREAM_JSON_PROTOCOL.md") - err := os.WriteFile(target, []byte("updated"), 0o644) - if err != nil { - return AgentRunResult{ExitCode: -1, Error: err.Error()} - } - return AgentRunResult{ExitCode: 0} - }), - Runner: Runner{}, - } - report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{ - TaskID: "document-stream-json-verify-events", - WorkRoot: t.TempDir(), - }) - if report.Summary.TotalTasks != 1 || report.Summary.PassedTasks != 1 || !report.OK { - t.Fatalf("report = %#v", report) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/agenteval -run TestHarness -count=1` -Expected: FAIL because `Harness` is undefined. - -- [ ] **Step 3: Implement harness** - -Add: -- `BenchmarkInput{TaskID, WorkRoot string; KeepWorkspaces bool}` -- `BenchmarkReport{Contract, SuiteID, OK, Summary, Tasks}` -- `BenchmarkTaskReport{TaskID, WorkspacePath, FixturePath, Agent AgentRunResult, Report Report}` -- `BenchmarkSummary{TotalTasks, PassedTasks, FailedTasks, BlockedTasks, ErrorTasks int}` -- `Harness{Materializer Materializer; Agent AgentRunner; Runner Runner}` -- If no `Agent`, produce blocked task reports with message `agent command is required`. -- Select one task when `TaskID` set, otherwise all tasks. -- For each task: materialize, run agent, if agent fails mark blocked; otherwise score with `Runner.Run`. -- Remove each materialized task workspace after scoring unless `BenchmarkInput.KeepWorkspaces` is true. - -- [ ] **Step 4: Verify** - -Run: `go test ./internal/agenteval -run 'TestHarness|TestBenchmark' -count=1` - -## Task 4: CLI `zero eval bench` - -**Files:** -- Modify: `internal/cli/agent_eval.go` -- Modify: `internal/cli/agent_eval_test.go` - -- [ ] **Step 1: Write failing tests** - -```go -func TestRunEvalBenchJSONModePassesHarnessOptions(t *testing.T) { - var stdout, stderr bytes.Buffer - exitCode := runWithDeps([]string{ - "eval", "bench", - "--suite", "evals/context.json", - "--task", "edit-reader", - "--work-root", "D:\\tmp\\zero-evals", - "--agent-command", "zero", "exec", "{prompt}", - "--json", - }, &stdout, &stderr, appDeps{ - runAgentEval: func(ctx context.Context, options agentEvalOptions) (agentEvalReport, error) { - if options.Mode != "bench" || options.TaskID != "edit-reader" || options.WorkRoot == "" || len(options.AgentCommand) != 3 { - t.Fatalf("unexpected bench options: %#v", options) - } - return agentEvalReport{Suite: "quality-context", Status: "pass", OK: true, Total: 1, Passed: 1}, nil - }, - }) - if exitCode != exitSuccess { - t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String()) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/cli -run TestRunEvalBench -count=1` -Expected: FAIL because bench mode is not parsed. - -- [ ] **Step 3: Implement CLI** - -Add parser support: -- Modes: `validate`, `run`, `bench` -- Bench flags: `--work-root `, `--agent-command `, `--keep-workspaces` -- `--task`, `--report-dir`, `--json` work in bench mode. -- `--workspace` remains run-only. -- Default work root for bench: temp dir with prefix `zero-eval-`. -- Default `AgentCommand` empty -> harness report blocked rather than usage error. -- Convert benchmark aggregate to existing CLI report shape. - -- [ ] **Step 4: Verify** - -Run: -```bash -go test ./internal/cli -run 'TestRunEvalBench|TestRunEvalRun' -count=1 -``` - -## Task 5: Docs and Manual Examples - -**Files:** -- Modify: `docs/AGENT_EVALS.md` - -- [ ] **Step 1: Document modes** - -Add a `bench` subsection: -- `validate`: schema only -- `run`: score an already-mutated worktree -- `bench`: copy fixture, run agent command, score result - -- [ ] **Step 2: Include examples** - -Document: -```bash -go run ./cmd/zero eval bench \ - --suite internal/agenteval/testdata/sample_suite.json \ - --task document-stream-json-verify-events \ - --work-root /tmp/zero-evals \ - --agent-command zero exec --cwd {workspace} {prompt} -``` - -Also document a deterministic fake-agent example for local testing. - -- [ ] **Step 3: Verify** - -Run: -```bash -go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json -go test ./... -``` - -## Task 6: Integration Validation - -**Files:** -- Modify as needed only in files above. - -- [ ] **Step 1: Run focused validation** - -```bash -go test -count=1 ./internal/agenteval ./internal/cli -``` - -- [ ] **Step 2: Run fixture validation** - -```bash -go test ./... -``` - -from `internal/agenteval/testdata/fixtures/zero-mini`. - -- [ ] **Step 3: Run full repo validation** - -```bash -gofmt -l internal/agenteval internal/cli -git diff --check -go test ./... -go run ./cmd/zero-release build -go run ./cmd/zero-release smoke -``` diff --git a/docs/superpowers/plans/2026-06-12-agent-eval-d-level-upgrades.md b/docs/superpowers/plans/2026-06-12-agent-eval-d-level-upgrades.md deleted file mode 100644 index af639941..00000000 --- a/docs/superpowers/plans/2026-06-12-agent-eval-d-level-upgrades.md +++ /dev/null @@ -1,181 +0,0 @@ -# Agent Eval D-Level Upgrades Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts. - -**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work. - -**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation. - ---- - -### Task 1: Suite Schema And Rubric Checks - -**Files:** -- Modify: `internal/agenteval/suite.go` -- Modify: `internal/agenteval/score.go` -- Test: `internal/agenteval/agenteval_test.go` - -- [ ] **Step 1: Write failing schema/rubric tests** - -Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched. - -- [ ] **Step 2: Run tests to verify failure** - -Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1` - -- [ ] **Step 3: Implement minimal schema and scoring** - -Add these fields: - -```go -Tags []string `json:"tags,omitempty"` -Difficulty string `json:"difficulty,omitempty"` -ForbiddenChangedFiles []string `json:"forbiddenChangedFiles,omitempty"` -``` - -Normalize file lists, validate duplicate/malformed entries, and add a `forbidden_changed_files` result. - -- [ ] **Step 4: Verify green** - -Run: `go test ./internal/agenteval -count=1` - -### Task 2: Agent Trace Scoring - -**Files:** -- Create: `internal/agenteval/trace.go` -- Test: `internal/agenteval/trace_test.go` -- Modify: `internal/agenteval/benchmark.go` - -- [ ] **Step 1: Write failing trace tests** - -Cover JSONL events such as: - -```json -{"type":"tool","name":"read_file"} -{"event":"verify","name":"go-test"} -``` - -Require deterministic event keys like `tool:read_file` and `verify:go-test`. - -- [ ] **Step 2: Run tests to verify failure** - -Run: `go test ./internal/agenteval -run Trace -count=1` - -- [ ] **Step 3: Implement parser and benchmark scoring** - -Parse agent stdout line by line, ignore non-JSON noise, and append a `trace` result when a task declares `requiredTraceEvents`. - -- [ ] **Step 4: Verify green** - -Run: `go test ./internal/agenteval -count=1` - -### Task 3: Context Quality Checks - -**Files:** -- Create: `internal/agenteval/context_checks.go` -- Test: `internal/agenteval/context_checks_test.go` -- Modify: `internal/agenteval/benchmark.go` - -- [ ] **Step 1: Write failing context tests** - -Cover a task that declares: - -```json -"contextChecks": { - "requiredFiles": ["docs/STREAM_JSON_PROTOCOL.md"], - "forbiddenFiles": ["node_modules/cache.txt"] -} -``` - -- [ ] **Step 2: Run tests to verify failure** - -Run: `go test ./internal/agenteval -run Context -count=1` - -- [ ] **Step 3: Implement fixture/workspace context checks** - -Check required files exist under the materialized workspace and forbidden files do not. Append a `context` result before summary finalization. - -- [ ] **Step 4: Verify green** - -Run: `go test ./internal/agenteval -count=1` - -### Task 4: Model Matrix Benchmarking - -**Files:** -- Modify: `internal/agenteval/agent_command.go` -- Modify: `internal/agenteval/benchmark.go` -- Test: `internal/agenteval/agent_command_test.go` -- Test: `internal/agenteval/benchmark_test.go` - -- [ ] **Step 1: Write failing model-matrix tests** - -Assert `{model}` expands in agent command argv and `BenchmarkInput{Models: []string{"a","b"}}` runs every task for each model. - -- [ ] **Step 2: Run tests to verify failure** - -Run: `go test ./internal/agenteval -run "Model|Harness" -count=1` - -- [ ] **Step 3: Implement model propagation** - -Add `Model` to `AgentRunInput` and `BenchmarkTaskReport`; add `Models []string` to `BenchmarkInput`; loop task/model pairs deterministically. - -- [ ] **Step 4: Verify green** - -Run: `go test ./internal/agenteval -count=1` - -### Task 5: CLI Flags And Regression Artifacts - -**Files:** -- Modify: `internal/cli/agent_eval.go` -- Test: `internal/cli/agent_eval_test.go` - -- [ ] **Step 1: Write failing CLI tests** - -Cover repeated `--model`, comma-separated `--models`, help text, and report-dir JSON containing nested benchmark detail. - -- [ ] **Step 2: Run tests to verify failure** - -Run: `go test ./internal/cli -run Eval -count=1` - -- [ ] **Step 3: Implement CLI wiring** - -Pass models into `agenteval.BenchmarkInput`, include benchmark detail in `agentEvalReport`, and prefix failure IDs with model when present. - -- [ ] **Step 4: Verify green** - -Run: `go test ./internal/cli -count=1` - -### Task 6: Suite Expansion And Docs - -**Files:** -- Modify: `internal/agenteval/testdata/sample_suite.json` -- Modify: `docs/AGENT_EVALS.md` - -- [ ] **Step 1: Write failing suite expectation** - -Update the sample suite test to require richer coverage count and metadata fields. - -- [ ] **Step 2: Run tests to verify failure** - -Run: `go test ./internal/agenteval -run SampleSuite -count=1` - -- [ ] **Step 3: Expand the fixture suite and docs** - -Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts. - -- [ ] **Step 4: Verify green** - -Run: `go test ./internal/agenteval ./internal/cli -count=1` - -### Final Verification - -- [ ] Run `gofmt -l internal/agenteval internal/cli` -- [ ] Run `git diff --check` -- [ ] Run `go vet ./...` -- [ ] Run `go test ./...` -- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json` -- [ ] Run `go run ./cmd/zero eval bench --suite internal/agenteval/testdata/sample_suite.json --task document-stream-json-verify-events --model test-model --json --agent-command powershell -NoProfile -Command "& { param(`$ws) Set-Content -LiteralPath (Join-Path `$ws 'docs/STREAM_JSON_PROTOCOL.md') -Value updated; Write-Output '{\"type\":\"tool\",\"name\":\"read_file\"}' }" "{workspace}"` -- [ ] Run `go run ./cmd/zero-release build` -- [ ] Commit and push to `feat/agent-eval-harness` diff --git a/docs/superpowers/specs/2026-06-10-additional-write-roots-design.md b/docs/superpowers/specs/2026-06-10-additional-write-roots-design.md deleted file mode 100644 index 8af9fbc4..00000000 --- a/docs/superpowers/specs/2026-06-10-additional-write-roots-design.md +++ /dev/null @@ -1,175 +0,0 @@ -# Additional write roots (`--add-dir` / `/add-dir`) - -**Date:** 2026-06-10 -**Status:** Approved - -## Problem - -Zero's sandbox confines all writes to the workspace root, with no escape hatch. -`EnforceWorkspace` is hardcoded `true` in `DefaultPolicy()` -(`internal/sandbox/types.go`) and `SandboxConfig` exposes only `maxAutonomy` -and `network`. When a user asks the agent to save output outside the workspace -(e.g. "save this to ~/Desktop/dev/untitled folder"), every layer denies it: - -1. **Policy engine** — `Engine.Evaluate` → `validateWorkspacePaths` - (`internal/sandbox/paths.go`) denies tool requests whose paths fall outside - the workspace root. -2. **macOS seatbelt** — `sandboxExecProfile` (`internal/sandbox/runner.go`) - emits `(allow file-write* (subpath ""))`, so shell commands - fail with `Operation not permitted`. -3. **Linux bubblewrap** — `bubblewrapCommandPlan` binds only the workspace - read-write; system paths are read-only binds. -4. **File tools** — `resolveWorkspacePath` / `recheckWorkspaceWriteTarget` - (`internal/tools/workspace.go`) confine `write_file`, `edit`, `read_file`, - `list_directory`, `glob`, `grep`, and the bash tool's `cwd`. - -The agent's only recourse is workarounds (zip in workspace, ask the user to -move it), and the denial surfaces as a raw OS error with no guidance. - -## Goal - -Let users grant zero write access to specific directories outside the -workspace — at launch (flag/config) and mid-session (TUI command) — without -weakening the sandbox elsewhere. Decided UX: **launch flag + runtime command** -(prompt-on-denial may layer on later). - -## Non-goals - -- No interactive prompt-on-denial flow in this iteration. -- No persistence of `/add-dir` grants across sessions (config is the durable - surface). -- No widening of network or destructive-shell policy. -- No `enforceWorkspace: false` config knob. - -## Design - -### 1. Core type: `sandbox.Scope` - -New concurrency-safe type in `internal/sandbox` (e.g. `scope.go`): - -```go -type Scope struct { - mu sync.RWMutex - workspaceRoot string // normalized at construction - extraRoots []string // normalized, deduplicated -} - -func NewScope(workspaceRoot string, extras []string) (*Scope, error) -func (s *Scope) Roots() []string // workspace first, then extras -func (s *Scope) Add(path string) (string, error) // returns normalized root -func (s *Scope) validate(path string) *pathViolation // package-internal -``` - -`Add` normalization and guardrails: - -- Expand a leading `~` to the user home directory. -- Make absolute, `filepath.Clean`, then `filepath.EvalSymlinks`. -- The path must exist and be a directory (no auto-create). -- Reject the filesystem root `/` (and Windows volume roots) — granting it - would disable confinement entirely. -- Adding the workspace root or a path inside any existing root is a - no-op (idempotent success, not an error). - -`Validate` generalizes today's `validateWorkspacePath`: a path is allowed if -it validates under **any** root, including the per-segment symlink-traversal -check against that root. The existing single-root function remains as the -per-root helper. - -One `*Scope` is constructed at startup and shared by the engine, the command -runner, and the tool registry, so a mid-session `Add` is immediately visible -to all layers. - -### 2. Policy engine - -- `EngineOptions` gains `Scope *Scope`; `NewEngine` builds a workspace-only - scope when nil (back-compat for tests/embedders). -- `Evaluate`'s workspace check (engine.go) calls `scope.Validate` per - requested path instead of `validateWorkspacePaths(root, request)`. -- Risk classification (`risk.go`) treats paths inside any scope root as - in-workspace, so writes to granted roots are not classified - `out_of_workspace`/critical. -- Denial reason becomes actionable: - `" is outside the workspace (use /add-dir or --add-dir to allow writes there)"`. - -### 3. OS runners - -- `sandboxExecProfile` emits one `(subpath "...")` per scope root inside the - single `(allow file-write* ...)` rule. -- `bubblewrapCommandPlan` adds `--bind ` (read-write, real path) - for each extra root. The workspace keeps its `/workspace` remount; extra - roots keep their host paths so absolute paths in commands work unchanged. -- `resolveCommandDir` accepts a command `cwd` inside any scope root. -- Profiles are built per command plan from `scope.Roots()`, so commands run - after `/add-dir` get the widened profile with no restart. - -### 4. File tools - -- New narrow interface in `internal/tools`: - `type PathScope interface { Roots() []string }` (satisfied by - `*sandbox.Scope`) to avoid a hard dependency direction problem. -- `resolveWorkspacePath` and `recheckWorkspaceWriteTarget` gain scope-aware - variants: absolute paths are resolved against the first root that contains - them; **relative paths still resolve against the workspace root only**. -- Tool constructors (`CoreReadOnlyTools`, `CoreWriteTools`, `CoreShellTools`, - `CoreTools` in `registry.go`) accept the scope (workspace-only default kept - via a convenience wrapper so existing call sites/tests keep compiling). -- Result: `write_file`, `edit`, `read_file`, `list_directory`, `glob`, `grep`, - and bash `cwd` all work consistently inside extra roots. Write access - implies read access within a granted root. - -### 5. Config, CLI, TUI - -- **Config:** `SandboxConfig` gains - `AdditionalWriteRoots []string \`json:"additionalWriteRoots,omitempty"\``. - The key is honored from the **global user config** - (`os.UserConfigDir()/zero/config.json`) so a grant can apply to every - project, and from CLI flags/overrides. It is **deliberately excluded from - the project config allowlist** (`mergeProjectConfig`): a repo-controlled - `.zero/config.json` must not be able to widen write access outside the - workspace. Sources are merged as a union (append + dedupe), not replace. -- **CLI:** repeatable `--add-dir ` flag on `zero` (TUI) and `zero exec`. - Effective set = union(flag, config), normalized through `Scope` at startup; - an invalid root fails fast with a clear error. -- **TUI:** new `/add-dir` command (`internal/tui/commands.go`): - - `/add-dir` (bare) — lists current write roots. - - `/add-dir ` — calls `scope.Add`; on success appends a system line: - `write access added: (this session only)`; on failure shows the - normalization error. -- **Observability:** `zero sandbox` status output and sandbox snapshots - (`internal/zerocommands/sandbox_snapshots.go`) list extra roots. - -### 6. Security invariants - -- Extra roots only widen *file-write* (and file-tool read) access under the - named directories; network and destructive-shell policy are untouched. -- Symlink-traversal protection applies per root exactly as it does for the - workspace today. -- `/` and volume roots are rejected; roots are symlink-resolved before being - trusted so a symlinked grant cannot silently point elsewhere later. -- Runtime grants are session-only; durable grants require editing config. - -### 7. Testing - -- **Scope unit tests:** normalization (`~`, relative, symlink), rejection of - `/`, missing dirs, files; idempotent adds; multi-root `Validate` including - symlink-traversal escapes from one root into another denied area. -- **Runner tests:** seatbelt profile string contains one `subpath` per root; - bubblewrap args contain the extra `--bind`s; `resolveCommandDir` accepts - extra-root cwd (extend `runner_test.go`). -- **Engine tests:** out-of-workspace write allowed when inside an extra root; - still denied (with the new actionable message) when outside all roots. -- **Tool tests:** `write_file`/`edit`/`read_file` in an extra root succeed; - relative paths never resolve against extra roots (extend - `file_tools_test.go`). -- **TUI tests:** `parseCommand` recognizes `/add-dir`; handler list/add/error - paths. -- **Config/CLI tests:** flag-config union, fail-fast on invalid root. -- Platform-gated integration tests follow the existing sandbox test patterns. - -## Implementation order - -1. `sandbox.Scope` + tests. -2. Engine + risk + runner integration (flag-less, scope seeded empty). -3. File-tool scope plumbing. -4. Config key + CLI flags. -5. TUI `/add-dir` command + status output. diff --git a/docs/superpowers/specs/2026-06-11-permission-scope-grants-design.md b/docs/superpowers/specs/2026-06-11-permission-scope-grants-design.md deleted file mode 100644 index a3be6d05..00000000 --- a/docs/superpowers/specs/2026-06-11-permission-scope-grants-design.md +++ /dev/null @@ -1,107 +0,0 @@ -# Permission-UX Phase 1 — scope-enforced grants - -Status: approved (2026-06-11). Branch: `feat/permission-ux` (continues Phase 0, commit `742ff48`). - -## Problem - -Phase 0 derives a human-readable *scope* for a tool call (the file, directory, or -cwd it touches) and shows it on the permission card and the persisted decision -row. But the grant that an "always allow" writes is still **tool-wide**: - -- `persistPermissionGrant` (agent/loop.go) stores only `ToolName`. -- the grant file is keyed by tool name: `state.Grants[toolName]`. -- enforcement (`engine.Decide` → `store.Lookup(toolName, autonomy)`, engine.go:100) - matches on tool name alone. - -So clicking "always allow" on a write to `src/main.go` silently authorizes *every* -future write — the blind-yes the Phase-0 card was written to warn against. - -## Goal - -Persist and enforce the scope so an "always" grant covers exactly what the card -showed: that file, or that directory subtree — nothing else. - -## Decisions - -1. **Match semantics:** a `file` scope re-matches only its exact path; a `dir` - scope matches that directory and any descendant; an empty scope (a call with no - path-like arg, e.g. `bash` with no `cwd`) stays tool-wide. A tool-wide *request* - is **not** covered by a narrower grant — it re-prompts (fail-safe). -2. **Path anchoring:** scopes are normalized to an absolute, cleaned path at grant - time, anchored to the workspace root. Matching compares absolute paths, so - `./src/main.go` == `src/main.go` == `/proj/src/main.go`, and a grant made in - `/proj-a` never matches `/proj-b`. The card still displays the short relative form. -3. **Storage:** `grants` becomes `map[toolName][]Grant`, one record per - (scope, decision). Schema bumps `1 → 2`; a v1 file is migrated by reading each - entry as a single tool-wide grant, so existing grants keep working unchanged. -4. **Precedence:** among grants that cover a request, a covering **deny wins** - (regardless of autonomy); otherwise the most-specific covering **allow** - (file > dir > tool-wide; ties broken by longer path, then newest) whose - `MaxAutonomy` satisfies the request. - -## Design - -### Scope derivation — single source of truth (`internal/sandbox/grant_scope.go`) - -`DeriveScope(toolName, args) (raw string, kind ScopeKind)` is the one place that -knows which args are path-like, reusing Phase 0's key priority (most specific -first): `path`,`file` → `file`; `directory`,`dir`,`cwd` → `dir`. Empty / `.` → -tool-wide. `resolveScopeAbs(raw, workspaceRoot)` cleans and absolutizes (relative -anchored to the workspace root; falls back to `filepath.Abs` when the root is -empty). `agent.permissionScope` (Phase-0 display) is refactored to call -`DeriveScope`, so the displayed scope and the stored/matched scope can never diverge. - -`ScopeKind`: `""` (tool-wide) | `"file"` | `"dir"`. - -### Data model (`internal/sandbox/grants.go`) - -`Grant` gains `Scope` (absolute path, `omitempty`) and `ScopeKind` (`omitempty`). -`grantFile.Grants` becomes `map[string][]Grant`; `grantSchemaVersion` = 2. -`GrantInput` gains `Scope` (raw) + `ScopeKind`. - -### Grant creation - -The agent derives `(raw, kind)` via `DeriveScope` and passes them in `GrantInput`. -`engine.Grant` resolves `Scope` to absolute using its configured `workspaceRoot` -(the engine already holds one — engine.go:33) before delegating to `store.Grant`, -which appends to the tool's slice (replacing a same-scope entry). CLI grants -(`zero sandbox grants allow|deny `) leave the scope empty → tool-wide, as today. - -### Matching (`engine.Decide` → `store.Lookup`) - -`Decide` derives the request's absolute scope from `request.Args` + -`request.WorkspaceRoot` (already populated at engine.go:63) and passes it to a -scope-aware `Lookup(toolName, reqScopeAbs, autonomy)`. `grantCovers` implements the -match semantics above; `Lookup` applies the deny-wins / most-specific-allow -precedence and the per-grant autonomy check. The surrounding allow/deny/ceiling -flow (engine.go:99-126) is unchanged. - -### Migration & a fixed bug - -`readState` peeks at `schemaVersion`: v1 (`map[string]Grant`) entries become single -tool-wide grants; v2 reads `map[string][]Grant`; anything else still errors with -"unsupported schemaVersion". Keys are canonicalized (trimmed) on read so a -whitespace-padded tool name matches at lookup — closing audit finding #90 -(grants.go:146). `writeState` always emits v2. - -### CLI surface - -`List()` flattens to `[]Grant` (sorted by tool, then scope). `FormatGrantList` -shows the scope (`*` for tool-wide). `Revoke(toolName)` removes all of a tool's -grants and returns the count. Per-scope revoke is out of scope. - -## Testing - -- `grant_scope_test` / `grants_test`: `DeriveScope` kinds; `resolveScopeAbs` (relative - anchored, `./` equivalence, absolute passthrough, cross-project non-match); - `grantCovers` matrix (tool-wide/file/dir × equal/descendant/sibling/parent/ - empty-request); deny-precedence; per-grant + policy autonomy; v1→v2 migration - round-trip; whitespace-key canonicalization; `FormatGrantList` scope rendering. -- `engine_test`: covered scoped request auto-allows; uncovered sibling re-prompts; - dir deny blocks a subtree request; legacy tool-wide grant still allows. -- `agent`: `persistPermissionGrant` forwards scope+kind; `TestPermissionScope` - stays green (display unchanged). - -## Out of scope - -Card UI changes (Phase 0), per-scope revoke, glob/pattern scopes. diff --git a/internal/tui/theme.go b/internal/tui/theme.go index e0bced3f..b37720b6 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -6,8 +6,7 @@ import ( "charm.land/lipgloss/v2" ) -// tuiTheme is the resolved terminal palette Zero renders with — the Lime design -// (the terminal translation of docs/design/zero_tui_lime.html). It is produced by +// tuiTheme is the resolved terminal palette Zero renders with. It is produced by // buildTheme from a palette, so the same renderers serve both the dark default // and the light variant; the active theme lives in the package var zeroTheme and // may be swapped at startup (background detection / ZERO_THEME / --theme) or live diff --git a/internal/workspaceseed/workspaceseed.go b/internal/workspaceseed/workspaceseed.go index 1bb42814..14062037 100644 --- a/internal/workspaceseed/workspaceseed.go +++ b/internal/workspaceseed/workspaceseed.go @@ -19,11 +19,12 @@ const ( ) var detectedProjectFileOrder = []string{ + "README.md", "go.mod", "package.json", "AGENTS.md", - "docs/PRD.md", - "docs/WORK_SPLIT_PRD.md", + "docs/INSTALL.md", + "docs/STREAM_JSON_PROTOCOL.md", } // Input is the pure builder input. Callers provide paths and git metadata; the diff --git a/internal/workspaceseed/workspaceseed_test.go b/internal/workspaceseed/workspaceseed_test.go index 10634862..a7c04c21 100644 --- a/internal/workspaceseed/workspaceseed_test.go +++ b/internal/workspaceseed/workspaceseed_test.go @@ -17,11 +17,12 @@ func TestBuildOrdersAndClassifiesWorkspaceSeed(t *testing.T) { GitSummary: "3 modified, 1 untracked", Paths: []string{ "internal/agent/loop.go", - "docs/WORK_SPLIT_PRD.md", + "docs/STREAM_JSON_PROTOCOL.md", "package.json", "cmd/zero/main.go", "AGENTS.md", - "docs/PRD.md", + "docs/INSTALL.md", + "README.md", "ZERO.md", "go.mod", }, @@ -36,10 +37,10 @@ func TestBuildOrdersAndClassifiesWorkspaceSeed(t *testing.T) { if got.GitSummary != "dirty: 3 modified, 1 untracked" { t.Fatalf("GitSummary=%q", got.GitSummary) } - if want := []string{"AGENTS.md", "ZERO.md", "cmd/", "docs/", "go.mod", "internal/", "package.json"}; !reflect.DeepEqual(got.Layout, want) { + if want := []string{"AGENTS.md", "README.md", "ZERO.md", "cmd/", "docs/", "go.mod", "internal/", "package.json"}; !reflect.DeepEqual(got.Layout, want) { t.Fatalf("Layout=%v want %v", got.Layout, want) } - if want := []string{"go.mod", "package.json", "AGENTS.md", "docs/PRD.md", "docs/WORK_SPLIT_PRD.md"}; !reflect.DeepEqual(got.ProjectFiles, want) { + if want := []string{"README.md", "go.mod", "package.json", "AGENTS.md", "docs/INSTALL.md", "docs/STREAM_JSON_PROTOCOL.md"}; !reflect.DeepEqual(got.ProjectFiles, want) { t.Fatalf("ProjectFiles=%v want %v", got.ProjectFiles, want) } if want := []string{"AGENTS.md", "ZERO.md"}; !reflect.DeepEqual(got.MemoryFiles, want) { @@ -56,7 +57,7 @@ func TestBuildKeepsPathsRelativeToCWD(t *testing.T) { GitDirty: &dirty, Paths: []string{ filepath.Join(root, "go.mod"), - filepath.Join(root, "docs", "PRD.md"), + filepath.Join(root, "docs", "INSTALL.md"), filepath.Join(filepath.Dir(root), "outside", "AGENTS.md"), }, }) @@ -92,7 +93,7 @@ func TestBuildRejectsAbsolutePathsWithoutCWD(t *testing.T) { func TestBuildNormalizesBackslashPathsBeforeTraversalChecks(t *testing.T) { got := Build(Input{ Paths: []string{ - `docs\PRD.md`, + `docs\INSTALL.md`, `..\..\etc\passwd`, `\\server\share\secret.txt`, }, @@ -101,7 +102,7 @@ func TestBuildNormalizesBackslashPathsBeforeTraversalChecks(t *testing.T) { if want := []string{"docs/"}; !reflect.DeepEqual(got.Layout, want) { t.Fatalf("Layout=%v want %v", got.Layout, want) } - if want := []string{"docs/PRD.md"}; !reflect.DeepEqual(got.ProjectFiles, want) { + if want := []string{"docs/INSTALL.md"}; !reflect.DeepEqual(got.ProjectFiles, want) { t.Fatalf("ProjectFiles=%v want %v", got.ProjectFiles, want) } } @@ -113,8 +114,8 @@ func TestRenderHonorsLineAndWidthBudgets(t *testing.T) { Paths: []string{ "averyveryveryverylongdirectoryname/averyveryveryverylongfilename.go", "cmd/zero/main.go", - "docs/PRD.md", - "docs/WORK_SPLIT_PRD.md", + "docs/INSTALL.md", + "docs/STREAM_JSON_PROTOCOL.md", "go.mod", "package.json", "AGENTS.md", @@ -152,7 +153,7 @@ func TestBuildFromWorkspaceUsesWorkspaceIndexWithoutGit(t *testing.T) { root := t.TempDir() writeSeedFile(t, root, "go.mod", "module example.test/zero\n") writeSeedFile(t, root, "cmd/zero/main.go", "package main\n") - writeSeedFile(t, root, "docs/PRD.md", "# PRD\n") + writeSeedFile(t, root, "docs/INSTALL.md", "# Install\n") writeSeedFile(t, root, "node_modules/pkg/index.js", "ignored") got, err := BuildFromWorkspace(root, GitInfo{Branch: "main", Summary: "clean"}) @@ -163,7 +164,7 @@ func TestBuildFromWorkspaceUsesWorkspaceIndexWithoutGit(t *testing.T) { if got.GitSummary != "clean" { t.Fatalf("GitSummary=%q want clean", got.GitSummary) } - if want := []string{"go.mod", "docs/PRD.md"}; !reflect.DeepEqual(got.ProjectFiles, want) { + if want := []string{"go.mod", "docs/INSTALL.md"}; !reflect.DeepEqual(got.ProjectFiles, want) { t.Fatalf("ProjectFiles=%v want %v", got.ProjectFiles, want) } if contains(got.Layout, "node_modules/") {