Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ See [README §Configuration](README.md#configuration) and [`ash.toml.example`](a
### Error codes touching this

- `path_denied` — verb path arg fell outside the active jail policy.
- `not_implemented` — reserved for future ops a backend genuinely cannot perform. No live verb returns this today.
- `not_implemented` — a backend cannot perform an op. `git --op blame` returns it under `[git].backend = "shellout"` (blame requires the go-git backend).

## When to prefer ash over bash

Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ This project is two experiments at once: the shell itself, and a deliberate stud
- **`ash help [--verb <name>]`** is the authoritative per-verb arg schema — never duplicated in markdown.
- **[docs/vocab/inventory.md](docs/vocab/inventory.md)** is the authoritative inventory of every stable string ash emits — error codes, status enums, pretty-form headers, labels — with cl100k token costs. Regenerated by `make vocab`; CI lint (`make vocab-check`) fails on drift.

Agents in repos that ran `ash init` see only the hook's deny messages today; no CLAUDE.md is propagated to target repos yet (tracked as a follow-up). The design rationale and maintenance ritual for these surfaces live in [docs/agent-guidance.md](docs/agent-guidance.md).
Agents in repos that ran `ash init` get a CLAUDE.md section with the switch criteria and gotchas, alongside the hook's deny messages. The design rationale and maintenance ritual for these surfaces live in [docs/agent-guidance.md](docs/agent-guidance.md).

**Native adoption via MCP.** The companion binary `ashmcp` exposes every read-side verb as a typed Model Context Protocol tool (`ash_read`, `ash_grep`, `ash_lang_def`, …) over stdio. MCP-aware harnesses see them alongside their built-ins from session start, so adoption no longer depends on the hook's block-and-nudge loop. Copy-paste snippets and verification recipes for Claude Code and Claude Desktop, plus the migration path off the hook, live in [docs/adoption/](docs/adoption/).
**Native adoption via MCP.** The companion binary `ashmcp` exposes the read- and write-side verbs as typed Model Context Protocol tools (`ash_read`, `ash_grep`, `ash_edit`, …) over stdio. MCP-aware harnesses see them alongside their built-ins from session start, so adoption no longer depends on the hook's block-and-nudge loop. Copy-paste snippets and verification recipes for Claude Code and Claude Desktop, plus the migration path off the hook, live in [docs/adoption/](docs/adoption/).

**Layering vocabulary.** [docs/architecture/layers.md](docs/architecture/layers.md) is the one-pager naming the four tiers (protocol / verb library / dispatch / clients) and showing which tier a given change belongs in. Start there when proposing structural work.

Expand Down Expand Up @@ -243,14 +243,14 @@ Instrumentation was wired in from the first verb, not retrofitted. A tool that c

## Benchmarks

`ash bench` runs 19 canonical cases covering every measurable verb — `read`, `write`, `edit`, `diff`, `find`, `grep`, `git`, `stat` — and compares ash against the bash equivalent the agent would otherwise have used. Both sides tokenize with the same `cl100k_base` encoder. Every run persists to `.ash/ledger.db`, so regressions show up in the diff, not the incident report.
`ash bench` runs 21 canonical cases covering every measurable verb — `read`, `write`, `edit`, `diff`, `find`, `grep`, `git`, `stat` — and compares ash against the bash equivalent the agent would otherwise have used. Both sides tokenize with the same `cl100k_base` encoder. Every run persists to `.ash/ledger.db`, so regressions show up in the diff, not the incident report.

**Current baseline (2026-05-10):** **−54.8% tokens** overall (31,196 ash vs 69,013 bash across 19 cases). Per-case breakdown: [bench/baseline.md](bench/baseline.md).
**Current baseline (2026-05-18):** **−63.8% tokens** overall (41,767 ash vs 115,410 bash across 21 cases). Per-case breakdown: [bench/baseline.md](bench/baseline.md).

### Running

```sh
ash bench # one-shot, all 19 cases
ash bench # one-shot, all 21 cases
make bench # repeat=5, warmup=2, writes bench/latest.json (gitignored)
make bench-baseline # stable run + update bench/baseline.json + bench/baseline.md
```
Expand All @@ -266,7 +266,7 @@ ash bench --baseline 7d --regress-tokens 0.05 # tighter 5% threshold

### Regression contract

`bench/baseline.json` is the checked-in token budget for all 19 cases. `--compare baseline,latest` diffs your working state against it — zero regressions on a no-op change is the bar. Latency is informational only (machine-dependent) and lives separately in `bench/latency-snapshot.json`.
`bench/baseline.json` is the checked-in token budget for all 21 cases. `--compare baseline,latest` diffs your working state against it — zero regressions on a no-op change is the bar. Latency is informational only (machine-dependent) and lives separately in `bench/latency-snapshot.json`.

Design rationale: [docs/bench.md](docs/bench.md). Implementation: [docs/bench-2.md](docs/bench-2.md).

Expand Down Expand Up @@ -307,9 +307,9 @@ Go daemon, UDS transport, MessagePack with versioned schema dictionary, three re

### Phase 2 — coding-agent core — current (ship 14)

Live: `write`, `edit`, `stat`, `diff`, `git` (status/log/diff/show), `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.
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: `build`, `fmt`, `run`, `proc`, `obj`, persistent session/object store, job ledger for async operations, reference Go client library.
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

Expand Down
10 changes: 5 additions & 5 deletions cmd/ash/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,17 @@ func main() {

verb := os.Args[1]

if verb == "--version" || verb == "-V" {
fmt.Printf("ash %s — use 'ash help' for the verb list\n", version)
return
}

// `ash hook` is a client-only fast path for the Claude Code PreToolUse
// hook. It reads the harness payload from stdin, computes the decision
// in-process, writes the Claude-format response to stdout, and best-
// effort fires a normal ash request to the daemon for ledger
// instrumentation. It never auto-starts the daemon — hook latency is
// on the agent's critical path.
if verb == "--version" || verb == "-V" {
fmt.Printf("ash %s — use 'ash help' for the verb list\n", version)
return
}

if verb == "hook" {
runHook()
return
Expand Down
23 changes: 14 additions & 9 deletions cmd/ashd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ import (
// one-shot startup config (ledger cleanup, daemon concurrency cap)
// stay restart-required — see CLAUDE.md gotcha #1.
//
// jail.SetPolicy is a package-global pointer swap and is goroutine-safe;
// concurrent handler goroutines either see the old or the new policy
// for any given verb call, matching the "next request sees new config"
// semantic the hot-reload contract promises.
// jail.SetPolicy swaps a package-global policy pointer under an RWMutex
// and is goroutine-safe; concurrent handler goroutines see either the
// old or the new policy for any given verb call, matching the "next
// request sees new config" semantic the hot-reload contract promises.
func applyEnforcementConfig(rootFlag string, cfg *config.Config) {
jail.SetPolicy(jail.FromConfig(cfg.Jail.Enabled, rootFlag, cfg.Jail.AllowPaths, cfg.Jail.DenyPaths))
}
Expand Down Expand Up @@ -169,7 +169,13 @@ func main() {
log.Fatalf("ashd: %v", err)
}
_ = os.Remove(sockFlag)
// Create the socket 0600 from the start: net.Listen otherwise
// applies the process umask, leaving a brief window where the
// socket is world-connectable before the Chmod below. Umask is
// process-global but daemon startup here is single-threaded.
oldMask := syscall.Umask(0o177)
listener, err := net.Listen("unix", sockFlag)
syscall.Umask(oldMask)
if err != nil {
log.Fatalf("ashd: listen %s: %v", sockFlag, err)
}
Expand Down Expand Up @@ -497,7 +503,7 @@ func handle(conn net.Conn, led *ledger.Ledger, runners map[string]verbs.Runner,
RequestID: req.ID,
Timestamp: time.Now(),
Verb: req.Verb,
ArgsMsgpack: argsBlob(reqBuf),
ArgsMsgpack: argsBlob(req),
OK: rsp.OK,
ErrCode: errCode,
ErrMsg: errMsg,
Expand Down Expand Up @@ -649,10 +655,9 @@ const argsMaxStrBytes = 1024
// String values longer than argsMaxStrBytes are replaced with "<truncated:N>".
// Env is intentionally excluded — secrets in req.Env (forwarded to test
// subprocesses, ASH-132) must not leak into args_msgpack.
// Returns nil when the request has no args or cannot be decoded.
func argsBlob(reqBuf []byte) []byte {
req, err := proto.DecodeRequest(reqBuf)
if err != nil || len(req.Args) == 0 {
// Returns nil when the request has no args.
func argsBlob(req *proto.Request) []byte {
if req == nil || len(req.Args) == 0 {
return nil
}
sanitized := make(map[string]any, len(req.Args))
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ max_concurrent_handlers = 32
backend = "go-git"
EOF
bin/ash stop && bin/ash git --op status
# git --op status returns not_implemented because go-git path is stubbed.
# git --op status returns a structured StatusResult; go-git is the default backend.
# daemon log line shows config=ash.toml.

rm ash.toml hosts-link # restore clean state
Expand Down
186 changes: 0 additions & 186 deletions docs/install.md

This file was deleted.

10 changes: 9 additions & 1 deletion docs/vocab/inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,7 @@
"sites": [
{
"file": "internal/verbs/git/git.go",
"line": 206
"line": 231
}
],
"hints": [
Expand Down Expand Up @@ -1199,6 +1199,10 @@
"file": "internal/verbs/git/blame.go",
"line": 89
},
{
"file": "internal/verbs/git/git.go",
"line": 181
},
{
"file": "internal/verbs/git/gogit_blame.go",
"line": 32
Expand Down Expand Up @@ -1361,6 +1365,10 @@
}
],
"hints": [
{
"text": "a git revision, date, author, or pathspec never starts with '-'",
"cl100k_tokens": 15
},
{
"text": "valid ops: outline, def, refs, callers, impl",
"cl100k_tokens": 12
Expand Down
2 changes: 1 addition & 1 deletion docs/vocab/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ This file is regenerated by `make vocab`; CI runs `make vocab-check` to fail on
| `too_large` | 2 | 1 | | |
| `unknown_op` | 2 | 1 | ✓ | |
| `ambiguous` | 1 | 1 | ✓ | |
| `args` | 1 | 71 | ✓ | |
| `args` | 1 | 72 | ✓ | |
| `config` | 1 | 9 | | |
| `exists` | 1 | 1 | ✓ | |
| `io` | 1 | 7 | | |
Expand Down
10 changes: 3 additions & 7 deletions internal/jail/relpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package jail
import (
"fmt"
"path/filepath"
"sort"
"strings"
)

Expand Down Expand Up @@ -103,12 +102,9 @@ func PrettyPath(p string) string {
return "."
}
}
// PathPrefixes is already longest-first; iterate in order and stop at
// the first hit so we strip the longest matching prefix.
sorted := make([]string, len(prefixes))
copy(sorted, prefixes)
sort.Slice(sorted, func(i, j int) bool { return len(sorted[i]) > len(sorted[j]) })
for _, pref := range sorted {
// PathPrefixes is already longest-first (its contract), so the first
// prefix that matches is the longest — strip it and return.
for _, pref := range prefixes {
if strings.HasPrefix(p, pref+"/") {
return p[len(pref)+1:]
}
Expand Down
Loading