diff --git a/CLAUDE.md b/CLAUDE.md
index 4861e3a..409d84d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,26 +38,25 @@ Any `ash` invocation auto-starts the daemon. Use `bin/ash` from the repo root, o
**Switch criteria — what to actually use ash for now:**
+1. **Searching for a pattern** — use `ash grep` instead of `grep`/`rg`. Canonical: `ash grep --pattern 'func Run' --path internal/`. RE2 regex; `--path` defaults to `.` (ASH-206); `--glob` scopes by filename; `--lit true` for literal text; binary and >16 MiB files skipped. `ash help --verb grep` for the full schema.
+2. **Finding files** — use `ash find` instead of `find`. Canonical: `ash find --path . --glob '**/*.go'`. Respects `.gitignore`; a relative `--path` yields relative output paths, an absolute one yields absolute (see Gotchas). `ash help --verb find` for the full schema.
+3. **Reads of files in this repo** — use `ash read` deliberately, even when the harness Read tool would suffice. Canonical: `ash read --path
--range 100:200`. Default cap 256 KiB; UTF-8 returned as-is; binary base64-encoded. We need ledger data to compare. `ash help --verb read` for the full schema.
-4. **Reads of files in this repo** — use `ash read` deliberately, even when the harness Read tool would suffice. Canonical: `ash read --path
--range 100:200`. Default cap 256 KiB; UTF-8 returned as-is; binary base64-encoded. We need ledger data to compare. `ash help --verb read` for the full schema.
+4. **Writing files in this repo** — use `ash write` instead of the harness Write tool. (Do not fall back to harness `Write`: it requires a prior harness `Read`, which the hook also denies — go straight to `ash write`.) Canonical: `ash write --path
--content - << 'EOF' … EOF` for non-trivial content; `--content '…'` only for short ASCII-only writes. Atomic via temp-file+rename. `ash help --verb write` for the full schema.
+5. **Multi-line two-sided edits** (ASH-152) — use `ash edit --patch -` instead of the `--old @/tmp/x --new @/tmp/y` tmpfile dance. Canonical: `ash edit --path foo.go --patch - << 'EOF' … unified diff … EOF`. The `--fuzz N` arg (default 3, cap 10) means hunk header line numbers are hints — context anchoring lets the patch land within ±N lines of the authored position, so you don't have to count lines exactly. Per-line context matching within the hunk remains strict. Result reports `fuzz_applied` when scan moved any hunk. The two-tmpfile `--old @/tmp/x --new @/tmp/y` pattern stays as a fallback for cases where the diff can't produce stable context lines.
+6. **Diffing content in this repo** — use `ash diff`. Canonical: `ash diff --path a.go --other b.go` or `ash diff --path f.go --content - < new.go`. Add `--stat true` for token-cheap counts only. Both inputs capped at 4000 lines. `ash help --verb diff` for the full schema.
-7. **Writing files in this repo** — use `ash write` instead of the harness Write tool. (Do not fall back to harness `Write`: it requires a prior harness `Read`, which the hook also denies — go straight to `ash write`.) Canonical: `ash write --path
--content - << 'EOF' … EOF` for non-trivial content; `--content '…'` only for short ASCII-only writes. Atomic via temp-file+rename. `ash help --verb write` for the full schema.
+7. **Running Go tests** — use `ash test` instead of `go test`. Canonical: `ash test` (defaults to `./...`, `count=1` to bypass cache, 60s timeout). Add `--packages internal/walker` for one package, `--run TestX` for name filter, `--race true` for race detector, `--short true` for `-short` mode, `--timeout 10m` for big suites. Failures arrive as a structured `Tests []Test` slice with `file:line` extracted; build failures land as `Status=build_failed`. `ash help --verb test` for the full schema.
-8. **Multi-line two-sided edits** (ASH-152) — use `ash edit --patch -` instead of the `--old @/tmp/x --new @/tmp/y` tmpfile dance. Canonical: `ash edit --path foo.go --patch - << 'EOF' … unified diff … EOF`. The `--fuzz N` arg (default 3, cap 10) means hunk header line numbers are hints — context anchoring lets the patch land within ±N lines of the authored position, so you don't have to count lines exactly. Per-line context matching within the hunk remains strict. Result reports `fuzz_applied` when scan moved any hunk. The two-tmpfile `--old @/tmp/x --new @/tmp/y` pattern stays as a fallback for cases where the diff can't produce stable context lines.
+8. **Blaming a file in this repo** — use `ash git --op blame --path
` instead of `git blame`. Canonical: `ash git --op blame --path internal/foo.go --lines 100:200 --rev HEAD~3`. Output is run-compacted hunks (consecutive lines sharing a commit collapse into one record); follow up with `ash git --op show --ref ` to inspect any hunk's commit in full. Default cap 8000 lines per file, 256 KiB serialized output. go-git backend only — rename-following blame falls back to system git (rare; ASH-190). `ash help --verb git --op blame` for the schema.
-9. **Diffing content in this repo** — use `ash diff`. Canonical: `ash diff --path a.go --other b.go` or `ash diff --path f.go --content - < new.go`. Add `--stat true` for token-cheap counts only. Both inputs capped at 4000 lines. `ash help --verb diff` for the full schema.
+9. **Bash equivalents to retire** — `find`, `cat`, `head`, `tail`, `ls -R`, `grep`, `rg`, `git status`, `git log`, `git diff`, `git show`, `git blame`, `go test`, `stat`, `sed`, `patch`, `git apply` should be replaced by their `ash` equivalents in this repo. The PreToolUse hook (next subsection) enforces this. (`sed` routes to `ash edit` for in-place edits and `ash read --range` for line-range reads; pure pipeline `cmd | sed …` is allowed through. `patch` and `git apply` route to `ash edit --patch` for single-file diffs; multi-file diffs pass through since `ash edit --patch` is single-file only — ASH-152 deferred.)
-10. **Running Go tests** — use `ash test` instead of `go test`. Canonical: `ash test` (defaults to `./...`, `count=1` to bypass cache, 60s timeout). Add `--packages internal/walker` for one package, `--run TestX` for name filter, `--race true` for race detector, `--short true` for `-short` mode, `--timeout 10m` for big suites. Failures arrive as a structured `Tests []Test` slice with `file:line` extracted; build failures land as `Status=build_failed`. `ash help --verb test` for the full schema.
-
-11. **Blaming a file in this repo** — use `ash git --op blame --path ` instead of `git blame`. Canonical: `ash git --op blame --path internal/foo.go --lines 100:200 --rev HEAD~3`. Output is run-compacted hunks (consecutive lines sharing a commit collapse into one record); follow up with `ash git --op show --ref ` to inspect any hunk's commit in full. Default cap 8000 lines per file, 256 KiB serialized output. go-git backend only — rename-following blame falls back to system git (rare; ASH-190). `ash help --verb git --op blame` for the schema.
-
-12. **Bash equivalents to retire** — `find`, `cat`, `head`, `tail`, `ls -R`, `grep`, `rg`, `git status`, `git log`, `git diff`, `git show`, `git blame`, `go test`, `stat`, `sed`, `patch`, `git apply` should be replaced by their `ash` equivalents in this repo. The PreToolUse hook (next subsection) enforces this. (`sed` routes to `ash edit` for in-place edits and `ash read --range` for line-range reads; pure pipeline `cmd | sed …` is allowed through. `patch` and `git apply` route to `ash edit --patch` for single-file diffs; multi-file diffs pass through since `ash edit --patch` is single-file only — ASH-152 deferred.)
-
-13. **Restarting the daemon** (after editing `ash.toml`, or after a rebuild) — use `ash stop`. The next `ash` invocation auto-starts a fresh daemon. Don't reach for `pkill ashd`.
+10. **Restarting the daemon** (after editing `ash.toml`, or after a rebuild) — use `ash stop`. The next `ash` invocation auto-starts a fresh daemon. Don't reach for `pkill ashd`.
**The whole point** is that you are the first user. If a verb errors, hangs, or feels heavier than the bash equivalent, that's a bug or design gap — investigate, don't paper over. Write the session note.
diff --git a/README.md b/README.md
index a4a0574..896a0eb 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ Agents in repos that ran `ash init` get a CLAUDE.md section with the switch crit
## The verb surface
-The verb surface as of phase 2 ship 14 is below. Every verb returns a structured response over a MessagePack-with-schema-dictionary protocol; the same data renders as a token-lean pretty form for human (or LLM) consumption. Run `ash help` for the full schema of every verb, or `ash help --verb ` for one.
+The verb surface as of phase 2 is below. Every verb returns a structured response over a MessagePack-with-schema-dictionary protocol; the same data renders as a token-lean pretty form for human (or LLM) consumption. Run `ash help` for the full schema of every verb, or `ash help --verb ` for one.
### File system
@@ -104,21 +104,33 @@ The verb surface as of phase 2 ship 14 is below. Every verb returns a structured
| Verb | Purpose |
|---|---|
-| `git --op status\|log\|diff\|show` | Git as structured calls — not text-scraped. Default backend is in-process go-git; `shellout` opt-in for `--staged` / unstaged patch text. |
+| `git --op status\|log\|diff\|show\|blame` | Git as structured calls — not text-scraped. Default backend is in-process go-git; `shellout` opt-in for `--staged` / unstaged patch text. |
### Build / test
| Verb | Purpose |
|---|---|
+| `build` | Run `go build`; structured per-package errors with `file:line:col`. |
| `test` | Run Go tests via `go test -json`. Structured per-package/per-test results; build failures land as records, not raw stderr. |
+### Semantic
+
+| Verb | Purpose |
+|---|---|
+| `lang` | Outline / definition / references / callers / impl via a language-server broker. Currently in a usage-validation freeze (ASH-197). |
+
### Observability
| Verb | Purpose |
|---|---|
| `metrics` | Raw recent ledger rows. |
| `report` | Aggregated per-verb summary: n, ok%, p50/p95 latency, p50/p95 tokens_out, truncation rate, top error histograms, top truncation hotspots. Cross-repo via `--root` / `--all_roots`. |
+| `recap` | Compact session summary — files touched, patterns searched, edits made. |
+| `workspace` | Re-orientation snapshot — relevant files, recent searches, branch + status, last error. |
+| `replay` | Re-run prior ledger calls and report per-verb token deltas vs the originals. |
| `bench` | Run canonical cases against ash and the bash equivalent the agent would otherwise have used; tokenize both with the same encoder; report Δtokens / Δlatency per case. |
+| `usage` | Estimate cache-friendliness of recent calls from arg-repetition counts. |
+| `turn` | Record an Anthropic API turn's usage/cache numbers; fed by the Stop hook. |
### Lifecycle
@@ -160,7 +172,7 @@ Full reference: [docs/configuration.md](docs/configuration.md).
5. **Token-aware.** Every response reports its real token cost. The agent can self-budget.
6. **Platform-uniform.** `ash grep` behaves identically on macOS, Linux, and Windows. No GNU-vs-BSD divergence, no missing utilities, no per-platform conditionals in agent prompts.
7. **Persistent context.** The shell is a daemon, not a per-command process. Sessions, objects, and jobs survive across invocations.
-8. **Semantic when possible.** The forthcoming `lang` verb gives agents callers/definitions/references via tree-sitter, not regex approximations.
+8. **Semantic when possible.** The `lang` verb answers outline / definition / references / callers / impl queries through a language-server broker, not regex approximations.
9. **Instrumented by default.** Every verb call records latency, token cost, output size, truncation events, error class, and sanitized args to a session-scoped ledger. Performance and ergonomics claims are evaluated against the ledger, not against intuition.
## Constraints
@@ -190,10 +202,10 @@ These are hard rules. A change that violates one is a stop-and-discuss.
┌──────────┴──────────┐
│ │
┌───────▼───────┐ ┌────────▼────────┐
- │ builtins │ │ registered │
- │ (find, grep, │ │ tools │
- │ read, ...) │ │ (cargo, npm, │
- └───────────────┘ │ pytest, ...) │
+ │ builtins │ │ tool registry │
+ │ (find, grep, │ │ (Phase 5 — │
+ │ read, ...) │ │ not yet │
+ └───────────────┘ │ shipped) │
└─────────────────┘
```
@@ -305,17 +317,17 @@ Surface locked, wire protocol drafted, switch-criteria doc ([CLAUDE.md](CLAUDE.m
### Phase 1 — walking skeleton — done
Go daemon, UDS transport, MessagePack with versioned schema dictionary, three read-side verbs (`find` / `grep` / `read`), instrumentation ledger from day one, self-hosting on this repo.
-### Phase 2 — coding-agent core — current (ship 14)
+### Phase 2 — coding-agent core — current
Live: `write`, `edit`, `stat`, `diff`, `git` (status/log/diff/show/blame), `build`, `test`, `bench`, `metrics`, `report`, `hook`, `help`, `init`, `uninit`, `stop`. Plus: configuration substrate (`ash.toml`), jail policy, ledger retention, in-process go-git backend, cross-repo report aggregation, PreToolUse hook with per-verb exclusions.
Upcoming for phase 2: `fmt`, `run`, `proc`, `obj`, persistent session/object store, job ledger for async operations, reference Go client library.
-### Phase 3 — semantic layer
+### Phase 3 — semantic layer — shipped, under evaluation
-- `lang` verb backed by tree-sitter
-- Symbol search, callers, definitions, references
-- File outlines without bodies (token-efficient orientation)
+The `lang` verb — outline, definition, references, callers, impl — shipped
+behind a language-server broker. It is currently in a usage-validation
+freeze (ASH-197) while real demand is assessed.
### Phase 4 — adoption
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..693abfd
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,52 @@
+# ash documentation
+
+A map of this directory. For the project pitch and verb-surface overview see
+the top-level [README.md](../README.md); for the live, authoritative per-verb
+schema run `ash help` (never duplicated in markdown).
+
+## Getting started — [`adoption/`](adoption/)
+
+- [adoption/install.md](adoption/install.md) — install via Homebrew or from source.
+- [adoption/claude-code.md](adoption/claude-code.md) — wire `ashmcp` into Claude Code.
+- [adoption/claude-desktop.md](adoption/claude-desktop.md) — wire `ashmcp` into Claude Desktop.
+- [adoption/migration-from-hook.md](adoption/migration-from-hook.md) — moving from the PreToolUse hook to MCP.
+
+## Reference — how ash works
+
+- [configuration.md](configuration.md) — `ash.toml`: jail, git backend, runner, ledger retention.
+- [PreToolUse.md](PreToolUse.md) — the `ash hook` deny/redirect behavior.
+- [architecture/layers.md](architecture/layers.md) — the four-tier vocabulary (protocol / verb library / dispatch / clients).
+- [agent-guidance.md](agent-guidance.md) — the doc surfaces that drive ash usage and how they are maintained.
+- [optimization-tiers.md](optimization-tiers.md) — the framework for deciding what to optimize.
+- [ledger-cleanup.md](ledger-cleanup.md) — ledger retention and cleanup.
+
+## Design notes — rationale for shipped subsystems
+
+- [bench.md](bench.md), [bench-2.md](bench-2.md) — the `ash bench` measurement framework.
+- [report.md](report.md) — `ash report` ledger synthesis.
+- [replay.md](replay.md) — `ash replay`, the ledger as a regression suite.
+- [streaming.md](streaming.md) — the streaming (Chunk/Final) protocol.
+- [cache-shape.md](cache-shape.md) — the cache-aware response envelope (ASH-108).
+- [path-prefixes.md](path-prefixes.md) — path-prefix token optimization (ASH-71).
+- [mcp/design-decisions.md](mcp/design-decisions.md) — the `ashmcp` adapter.
+- [vocab/design.md](vocab/design.md) — the vocab inventory generator.
+- [library-audit.md](library-audit.md) — the dependency-reuse audit (ASH-103).
+
+## Measurements — empirical results
+
+- [performance-baselines.md](performance-baselines.md) — latency baselines and where the time goes.
+- [encoding-results.md](encoding-results.md) — measured token costs and the encoding decisions they drove.
+- [cli-tokens.md](cli-tokens.md) — token-efficiency evaluation of the CLI surface.
+- [cache-prefix-measurement.md](cache-prefix-measurement.md), [cache-telemetry.md](cache-telemetry.md) — prompt-cache validation.
+- [mcp/wire-cost.md](mcp/wire-cost.md) — CLI vs MCP wire cost.
+- [lsp-pilot-validation.md](lsp-pilot-validation.md) — the `lang`/LSP pilot evaluation (ASH-141).
+
+## Generated artifacts — checked in, regenerated by `make`
+
+- [vocab/inventory.md](vocab/inventory.md), `vocab/inventory.json` — every stable agent-facing string (`make vocab`).
+- `mcp/tools.json` — the MCP tool schema (`make schema`).
+
+## Historical & speculative
+
+- [value-assessment/](value-assessment/) — a point-in-time go/no-go evaluation (May 2026); see its README.
+- [revolutionary-directions.md](revolutionary-directions.md) — an ideas document, **not** a roadmap.
diff --git a/docs/revolutionary-directions.md b/docs/revolutionary-directions.md
index fb83f6b..d3e6e13 100644
--- a/docs/revolutionary-directions.md
+++ b/docs/revolutionary-directions.md
@@ -1,5 +1,9 @@
# Revolutionary directions for ash
+> **Status: speculative.** An ideas document — not a commitment or roadmap.
+> Some parts have since shipped (e.g. the MCP server, ASH-104..107); others
+> remain unevaluated. The Linear backlog is the source of truth for plans.
+
## Context
ash today is well-shaped for *evolutionary* improvement: the ledger captures every call, the vocab inventory ([ASH-102](https://linear.app/stazelabs/issue/ASH-102)) will close the static-protocol surface, Tier 1/2 token wins from [cli-tokens.md](cli-tokens.md) are queued. The encoding-substitution measurement on 2026-05-13 proved non-ASCII glyphs are a dead end and refocused effort on structural changes.
diff --git a/docs/value-assessment/README.md b/docs/value-assessment/README.md
new file mode 100644
index 0000000..ba962d3
--- /dev/null
+++ b/docs/value-assessment/README.md
@@ -0,0 +1,10 @@
+# Value assessment — historical snapshot
+
+A point-in-time go/no-go evaluation of ash and ashmcp, run in mid-May 2026.
+The six sweeps (`01`–`06`) gather the evidence; [decision.md](decision.md) is
+the conclusion that closed the two adoption-gate questions.
+
+Kept for the record — these are **not** living documents. The numbers reflect
+the surface as of that date; current benchmark figures live in
+[../../bench/baseline.md](../../bench/baseline.md). The `06-mcp-table-*.md`
+files are raw generated tables retained only as the inputs to `06-mcp.md`.
diff --git a/internal/atomicwrite/atomicwrite_test.go b/internal/atomicwrite/atomicwrite_test.go
new file mode 100644
index 0000000..ad84ec9
--- /dev/null
+++ b/internal/atomicwrite/atomicwrite_test.go
@@ -0,0 +1,86 @@
+package atomicwrite
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestWrite_CreatesNewFile(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "new.txt")
+ if err := Write(p, []byte("hello"), Options{}); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ if string(got) != "hello" {
+ t.Errorf("content = %q, want hello", got)
+ }
+}
+
+func TestWrite_ReplacesExisting(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "f.txt")
+ if err := os.WriteFile(p, []byte("old"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := Write(p, []byte("new contents"), Options{}); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got, _ := os.ReadFile(p)
+ if string(got) != "new contents" {
+ t.Errorf("content = %q, want new contents", got)
+ }
+}
+
+func TestWrite_NoLeftoverTempFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := Write(filepath.Join(dir, "f.txt"), []byte("data"), Options{}); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ entries, _ := os.ReadDir(dir)
+ if len(entries) != 1 || entries[0].Name() != "f.txt" {
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Errorf("dir contents = %v, want exactly [f.txt]", names)
+ }
+}
+
+func TestWrite_PreserveMode(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "f.txt")
+ if err := os.WriteFile(p, []byte("old"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := os.Stat(p)
+ if err := Write(p, []byte("new"), Options{PreserveMode: true}); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ after, _ := os.Stat(p)
+ if after.Mode() != before.Mode() {
+ t.Errorf("mode = %v, want preserved %v", after.Mode(), before.Mode())
+ }
+}
+
+func TestWrite_WithoutPreserveModeResetsTo0600(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "f.txt")
+ if err := os.WriteFile(p, []byte("old"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := Write(p, []byte("new"), Options{}); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ info, _ := os.Stat(p)
+ if info.Mode().Perm() != 0o600 {
+ t.Errorf("mode = %v, want 0600 (os.CreateTemp default, no PreserveMode)", info.Mode().Perm())
+ }
+}
+
+func TestWrite_ErrorWhenParentMissing(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "does-not-exist", "f.txt")
+ if err := Write(p, []byte("x"), Options{}); err == nil {
+ t.Error("expected an error when the parent directory is missing")
+ }
+}
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
new file mode 100644
index 0000000..9c7f96f
--- /dev/null
+++ b/internal/runner/runner_test.go
@@ -0,0 +1,87 @@
+package runner
+
+import (
+ "os/exec"
+ "testing"
+)
+
+func requireSh(t *testing.T) {
+ t.Helper()
+ if _, err := exec.LookPath("sh"); err != nil {
+ t.Skip("sh not on PATH")
+ }
+}
+
+func TestRun_BinaryNotFound(t *testing.T) {
+ _, perr := Run("ash-no-such-binary-xyzzy", nil, Opts{})
+ if perr == nil {
+ t.Fatal("expected a proto.Error for a missing binary")
+ }
+ if perr.Code != "ash-no-such-binary-xyzzy_not_found" {
+ t.Errorf("code = %q, want ...-xyzzy_not_found", perr.Code)
+ }
+}
+
+func TestRun_Success(t *testing.T) {
+ requireSh(t)
+ res, perr := Run("sh", []string{"-c", "printf hello"}, Opts{})
+ if perr != nil {
+ t.Fatalf("Run: %+v", perr)
+ }
+ if string(res.Stdout) != "hello" {
+ t.Errorf("stdout = %q, want hello", res.Stdout)
+ }
+ if res.ExitCode != 0 {
+ t.Errorf("exit = %d, want 0", res.ExitCode)
+ }
+}
+
+func TestRun_NonZeroExitIsNotAnError(t *testing.T) {
+ requireSh(t)
+ res, perr := Run("sh", []string{"-c", "exit 3"}, Opts{})
+ if perr != nil {
+ t.Fatalf("Run returned a proto.Error for a non-zero exit: %+v", perr)
+ }
+ if res.ExitCode != 3 {
+ t.Errorf("exit = %d, want 3", res.ExitCode)
+ }
+}
+
+func TestRun_StderrCaptured(t *testing.T) {
+ requireSh(t)
+ res, perr := Run("sh", []string{"-c", "printf oops 1>&2"}, Opts{})
+ if perr != nil {
+ t.Fatalf("Run: %+v", perr)
+ }
+ if string(res.Stderr) != "oops" {
+ t.Errorf("stderr = %q, want oops", res.Stderr)
+ }
+}
+
+func TestRun_MaxStdoutTruncates(t *testing.T) {
+ requireSh(t)
+ res, perr := Run("sh", []string{"-c", "printf aaaaaaaaaa"}, Opts{MaxStdout: 4})
+ if perr != nil {
+ t.Fatalf("Run: %+v", perr)
+ }
+ if !res.Truncated {
+ t.Error("Truncated = false, want true")
+ }
+ if len(res.Stdout) != 4 {
+ t.Errorf("len(stdout) = %d, want 4 (capped)", len(res.Stdout))
+ }
+}
+
+func TestRun_MaxStdoutExactlyAtCapNotTruncated(t *testing.T) {
+ requireSh(t)
+ res, perr := Run("sh", []string{"-c", "printf aaaa"}, Opts{MaxStdout: 4})
+ if perr != nil {
+ t.Fatalf("Run: %+v", perr)
+ }
+ if res.Truncated {
+ t.Error("Truncated = true for output exactly at the cap")
+ }
+ if string(res.Stdout) != "aaaa" {
+ t.Errorf("stdout = %q, want aaaa", res.Stdout)
+ }
+}
diff --git a/internal/session/paths_test.go b/internal/session/paths_test.go
new file mode 100644
index 0000000..1f23843
--- /dev/null
+++ b/internal/session/paths_test.go
@@ -0,0 +1,77 @@
+package session
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRoot_FindsGoModMarker(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sub := filepath.Join(dir, "a", "b")
+ if err := os.MkdirAll(sub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Root(sub)
+ if err != nil {
+ t.Fatalf("Root: %v", err)
+ }
+ if got != dir {
+ t.Errorf("Root = %q, want %q (go.mod marker)", got, dir)
+ }
+}
+
+func TestRoot_NoMarkerReturnsStart(t *testing.T) {
+ sub := filepath.Join(t.TempDir(), "deep")
+ if err := os.MkdirAll(sub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Root(sub)
+ if err != nil {
+ t.Fatalf("Root: %v", err)
+ }
+ if got != sub {
+ t.Errorf("Root = %q, want %q (no marker -> start)", got, sub)
+ }
+}
+
+func TestSocketPath_StableAndScoped(t *testing.T) {
+ a := SocketPath("/project/one")
+ if a != SocketPath("/project/one") {
+ t.Error("SocketPath is not stable for the same root")
+ }
+ if a == SocketPath("/project/two") {
+ t.Error("distinct roots produced the same socket path")
+ }
+ if !strings.HasSuffix(a, ".sock") || !strings.Contains(filepath.Base(a), "ash-") {
+ t.Errorf("unexpected socket path shape: %q", a)
+ }
+}
+
+func TestProjectPaths(t *testing.T) {
+ root := "/x/y"
+ for _, c := range []struct{ name, got, want string }{
+ {"ledger", LedgerPath(root), filepath.Join(root, ".ash", "ledger.db")},
+ {"langcache", LangCachePath(root), filepath.Join(root, ".ash", "lang-cache.db")},
+ {"pid", PIDPath(root), filepath.Join(root, ".ash", "ashd.pid")},
+ {"log", LogPath(root), filepath.Join(root, ".ash", "ashd.log")},
+ } {
+ if c.got != c.want {
+ t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
+ }
+ }
+}
+
+func TestEnsureRuntimeDirs(t *testing.T) {
+ root := t.TempDir()
+ if err := EnsureRuntimeDirs(root); err != nil {
+ t.Fatalf("EnsureRuntimeDirs: %v", err)
+ }
+ if fi, err := os.Stat(filepath.Join(root, ".ash")); err != nil || !fi.IsDir() {
+ t.Errorf(".ash dir not created: err=%v", err)
+ }
+}