hay · manual

hay — the manual

A ranked grep for coding agents. Same matches as ripgrep, reordered so the declaration comes first. This manual covers every flag, every output format, and the reasoning behind the sharp edges.

Nothing matches. Try a shorter fragment — the search looks at headings, flag names, and body text.

Start here

Homebrew is the supported install path on macOS and Linux. It installs the exact archives the release workflow built, whose checksums entered the formula only after their build-provenance attestations verified:

brew install mneves75/tap/hay

Or build from source — one line, or inspect first (Rust edition 2024, MSRV 1.88):

curl -fsSL https://raw.githubusercontent.com/mneves75/hay/v0.3.0/install.sh | HAY_REF=v0.3.0 bash

# inspect-first form:
git clone --depth 1 --branch v0.3.0 https://github.com/mneves75/hay.git && cd hay && HAY_REF=v0.3.0 ./install.sh

The script gates on the toolchain version, clones, builds release, installs via cargo install, and smoke-tests the installed binary. Set CARGO_INSTALL_ROOT to choose its destination; the script passes that root to Cargo and verifies the same path. Then search exactly like you would with ripgrep:

hay validateSession src/          # ranked results, best first
hay -t ts -w fetchUser            # TypeScript only, whole words
hay -C 3 -m 20 'parseConfig'      # context, capped result count

The deal hay offers is narrow and honest: each complete search returns the exact match set from an equivalently configured ripgrep invocation, verified continuously by a differential test that normalizes traversal inputs on both sides — but ordered by likelihood of being the answer instead of by file path. If the corresponding orderings differ in which lines match, that is a bug; the project treats it as such.

One-sentence mental model: ripgrep answers “where does this string appear?”; hay answers “where is the thing I’m asking about — most likely first?”

The mental model

hay is built around one opinion, borrowed from how coding agents read: a line that declares a thing is worth more than a line that merely mentions it. When an agent searches validateSession, the function definition is the answer; the forty call sites are supporting material; the dead planning document that mentions the name is noise.

Mechanically, hay uses ripgrep’s matching and walking crates with a deterministic ignore policy: repository .gitignore rules apply, while global gitignore, .git/info/exclude, .ignore, and .rgignore do not. The differential harness disables those same non-.gitignore sources for ripgrep. hay then retains the 20,000 strongest-by-prescore candidates, scores them, sorts them, and prints. Scoring never changes which retained lines match — only their order.

If you want the full story — why ranking exists at all, what a bootstrap interval is, and the C bug the benchmark itself caught — read BENCHMARK_FEYNMAN.html. It assumes no information-retrieval background.

How results are ranked

Every matching line gets a score from four signals. The defaults:

signalweightwhat it rewards
definition+6.0 The line declares the query rather than mentioning it: a declaration keyword before it (fn, function, class, def, const, typed-declaration shapes like static int foo(), or key-style shapes (query:, "query":). The match must sit at a word boundary — a hit inside SemanticFingerprint is not a declaration of fingerprint.
path−1.0 … +1.0 A prior on where answers live. Source directories (/src/, /lib/, …) score up; tests, fixtures/prose/data, and buried trees (/vendor/, /dist/, /archive/, /node_modules/, …) score down. A vendored test is buried first: buried wins over everything.
word+0.5 … +1.0 How exactly the match sits inside the line’s identifiers: +1.0 for a whole identifier (auth), +0.5 when the query starts one (authenticate), nothing when it is buried inside a longer name (oauthToken). Pre-registered in the design document and finally measured in 0.2.0: +0.010 to +0.022 MRR across the public corpora.
tf0.5 max Gentle term frequency within the line — a line naming the symbol twice outranks a drive-by mention, deliberately damped so repetition cannot buy a win.

Tie-breaks fall back to file path, so output is deterministic for a given tree.

Results are interleaved by file

After scoring, hay round-robins the ranked list by file: the first pass carries each file’s strongest line, the second its next-strongest, and so on. An agent opens files, so a first page of ten files is worth more than ten lines of one module — measured on the behavioural corpus, this alone lifted the answer-in-top-10 rate from 59.2% to 78.0% without changing a single score. The sequence of distinct files is unchanged, so -l output is identical.

hay --no-diversify config .   # strict score order instead

Modes that do not rank

-c, --count-matches, -v, -o and --stream have nothing to order. They run ripgrep's way — its parallel traversal, its output, its per-file -m, and no candidate cap, so a pattern matching more than 20,000 lines is answered exhaustively rather than exiting 2. Until 0.3.0 the first four exited 2 telling you to go and use rg; a search tool you can only reach for once you already know the question ranks is a decision you have to make before you can use it.

hay --stream config .      # everything rg would print, streaming, no cap
hay -c config .            # matching lines per file
hay -o config .            # just the matched substrings
hay -v config .            # the lines that did not match

These are the only modes whose order is not deterministic, because the order is ripgrep's. A sorted walk was measured at 8.0 s to the first line of a Linux-kernel search against ripgrep's 1.1 s; the parallel walk brings that to 2.3 s. Every mode that ranks is deterministic. --explain is refused here — there is no ranking to explain.

One divergence survives: -m 0 means no limit everywhere in hay, where ripgrep treats it as print nothing. hay's meaning is the documented one and the measurement kit depends on it, so it stays — stated here rather than discovered.

Turning signals off

Each signal has an ablation switch, because a contribution you cannot switch off is a belief, not a measurement:

hay --no-definition validateSession .
hay --no-path        validateSession .
hay --no-word        validateSession .
hay --no-tf          validateSession .
hay --no-diversify   validateSession .

Four other signals were built, measured and deleted: exact-case matching, a comment penalty, binary whole-word matching, a filename match, and (in 0.3.0) a markdown-heading signal that reversed hay's documentation deficit and still did not ship, because the benchmark that liked it defines its ground truth as the thing the signal detects.

This is also how several published claims here were proven: the definition signal’s value on C was established by turning it off and watching the kernel score collapse to ripgrep’s.

Flags

The parser is lexopt — the same crate ripgrep moved to — so combined short flags (-in), --flag=value, attached values (-C3) and -- all work as you expect.

Picking the pattern
-e, --regexpAdd a pattern; repeat for the union of all patterns.
-i, --ignore-caseCase-insensitive matching.
-w, --word-regexpMatch whole words only.
-F, --fixed-stringsTreat the pattern as a literal string — no regex metacharacters. Agents searching identifiers should default to this.
Narrowing the tree
-g, --globInclude/exclude files by glob; repeatable. Leading ! excludes.
-t, --typeOnly search a named file type (ts, rust, …); repeatable. --type-list prints them all.
-T, --type-notNever search a named file type; repeatable.
--hiddenAlso search hidden files and directories.
--no-ignoreDo not respect .gitignore. Like ripgrep, ignore rules only apply inside a git repository.
Shaping the output
-l, --files-with-matchesPrint only file paths, best-ranked file first. Wins over --json: under both flags you get plain paths, because hay’s JSON contract has no files-only message (see Output).
-n / --no-line-numberLine numbers are on by default; this turns them off.
-A/-B/-C <N>After/before/either-side context lines. Partial overrides behave like ripgrep 14+: -C1 -A2 means -B1 -A2. Context never reorders results and never duplicates a line inside one window block; a real gap between windows is marked --.
-m, --max-count <N>Stop after N ranked results (default 50; 0 = no limit). Unlike ripgrep, where -m bounds matches per file, this bounds the total output — see the cap.
--jsonRipgrep-shaped JSON Lines: match and context messages only. No begin/end/summary — those are file-scoped and meaningless once output is rank-ordered.
--explainPrefix each result with its score and per-signal breakdown (see Explain).
--type-listPrint known file types and exit.
-h, -VHelp and version.
Deliberately absent

Three ripgrep staples are refused — loudly, not silently — because ranking and the flag are mutually meaningless. Each refusal says why and names the ripgrep equivalent:

-vInverting the match leaves nothing to rank: every non-matching line would score identically. Use rg -v.
-chay ranks lines, it does not count them. Use rg -c.
-oOnly-matching prints substrings, not lines — no line, nothing to rank. Use rg -o.

Output formats

Plain

Ripgrep-compatible path:line:text, rank-ordered, with -- marking a true discontinuity between context windows:

$ hay -C1 -F validateSession src/
src/auth.ts:2:export function validateSession(t: string) {
src/auth.ts-3-  return t
--
src/session.ts:17:// delegates to validateSession

JSON Lines (--json)

Ripgrep-shaped objects, one per line, match and context messages only:

{"type":"match","data":{"path":{"text":"src/auth.ts"},
 "lines":{"text":"export function validateSession(t: string) {\n"},
 "line_number":2,"absolute_offset":31,
 "submatches":[{"match":{"text":"validateSession"},"start":16,"end":30}]}}
  • No begin/end/summary records: those describe a file-scoped stream, and rank order interleaves files by design.
  • Under -l --json you get plain paths — the files-only question has no match record to carry it, and a lone begin would be neither the documented contract nor rg-shaped.
  • Every line is one valid JSON object, so jq -c 'select(.type=="match")' composes cleanly (recipe below).

--explain: reading a score

Each result is prefixed with its total score and the per-signal breakdown:

$ hay --explain -F validateSession src/ | head -1
   9.00 [def +6.0 path +1.0 word +1.0 tf +1.00]  src/auth.ts:2:export function validateSession(t: string) {
fieldmeaning
totalSum of the weighted signals. Higher ranks first; equal totals tie-break by path.
defDefinition signal: +6.0 when the line declares the query, +0.0 otherwise.
pathPath-class prior for the file’s directory class (source up, tests down, buried furthest down).
wordMatch exactness: +1.0 whole identifier, +0.5 prefix of one, +0.0 buried inside a longer name.
tfDamped term frequency within the line.

Use it to audit, not to tune blindly. The weights are hand-set, and fitting them on the corpus they are published against is the exact mistake this project exists to warn about. --explain answers “why did this line rank here?”; it does not certify that the weights are optimal.

Differences from ripgrep

differencewhy
Rank-ordered resultsThe entire product. Path order is shelf order; agents need answer order.
-m bounds total results, not matches-per-fileAn agent wants “the twenty best lines”, not “twenty per file”.
--json emits only match/contextbegin/end/summary assume a file-scoped stream; rank order interleaves files.
-l wins over --jsonPlain paths beat a bare begin with no end.
Deterministic ignore inputsRepository .gitignore rules apply; global gitignore, .git/info/exclude, .ignore, and .rgignore do not.
A 20,000-candidate cap (see the cap)Memory bound with a loud stderr notice instead of unbounded buffering.

For equivalent flags and normalized traversal inputs, match semantics are parity by construction. The differential test fails the build if hay and the corresponding ripgrep invocation disagree on which lines match.

Exit codes

codemeaning
0Results were found (or help/version/type-list printed).
1The search ran fine and found nothing. Not an error.
2Something is wrong or incomplete: bad flags, unreadable paths, the candidate cap truncated results. The reason is always on stderr.

The rule this repo lives by: a serious defect is a quiet wrong answer, never a crash. Exit 2 exists so “here are some results” can never silently mean “here are all the results”. Scripts should treat 2 as “retry narrower”, not as success.

The candidate cap

hay buffers up to 20,000 matching lines to score them. A pattern broader than that (say, -e e on the Linux kernel) ranks only the 20,000 strongest-by-prescore candidates — and says so on stderr while still exiting 2, because the printed list is not every match in the tree. For exhaustive counting or completeness checks on very broad patterns, use rg. That is not a limitation hay apologises for; it is the trade that makes ranking affordable, stated where you can see it.

Recipes for agent loops

Claude Code, Codex CLI, pi, Aider, Cursor — they all read search results the same way: first page, act on it. That is exactly the surface hay improves, so wiring it into an agent is one paragraph of instructions plus the right defaults.

The universal snippet — paste into AGENTS.md. Codex CLI and pi read it natively. Claude Code reads only CLAUDE.md — point it at the same source with a one-line CLAUDE.md containing @AGENTS.md, or ln -s AGENTS.md CLAUDE.md (both are Anthropic's documented patterns). Aider and Cursor need their own wiring — see "Make hay the default" below.

## Code search

Use `hay` instead of `rg` for concept and symbol searches:

    hay -F -m 10 "<symbol>" src/

- always `-F` when searching identifiers (they are literals, not regexes)
- exit 0 = results ranked best-first; 1 = no match; 2 = INCOMPLETE — read stderr,
  then narrow (add -g/-t) rather than trusting the output wholesale
- need exhaustive lists or match counts? fall back to `rg`
- structured: `hay --json -F "sym" . | jq 'select(.type=="match")'
agentinstructions live innotes
Claude CodeCLAUDE.md (share one source via @AGENTS.md or a symlink — it does not read AGENTS.md itself)snippet works verbatim; Claude narrows well on exit 2 if told to read stderr
Codex CLIAGENTS.md (repo root, then ~/.codex/AGENTS.md)same snippet; state the `-F` default explicitly and it holds
piAGENTS.md (project root)pi reads it before acting; the jq recipe fits its bash-tool loop directly
Cursor / Aider / others.cursor/rules/*.mdc; CONVENTIONS.md via --readneither loads prose automatically — wiring below; any agent that shells out can run the same commands

Make hay the default, not just available

The reliable way to make an agent reach for hay first is an instruction-level default: a rule that names hay as the default and rg as the explicit fallback. Agents follow tool rules far more reliably than they discover tools. Strengthen the universal snippet's first line to a default:

## Code search — defaults

`hay` is the DEFAULT search tool in this repo. Reach for it first for every
"where is X / what defines X / how does X work" search:

    hay -F -m 10 "<symbol>" .

Use `rg` only when the task needs every match, an exact count, `-v`, or
path-ordered output — and say so when you switch.
  • Claude Code — put the rule in CLAUDE.md (or keep it in AGENTS.md and add a CLAUDE.md containing just @AGENTS.md — Claude Code does not read AGENTS.md directly). Pre-approve the binary so the default costs no permission prompts: /permissions → allow Bash(hay:*). Claude Code's built-in Grep tool is ripgrep internally and cannot be rewired; the rule should therefore say "prefer running hay in the shell over the Grep tool for concept searches", which Claude honors.
  • Codex CLI — the same rule in the repo's AGENTS.md holds per project; for every project, put it in ~/.codex/AGENTS.md. State the rg fallback explicitly or Codex will treat hay as a synonym and lose the exhaustiveness distinction.
  • pi — the repo AGENTS.md is read natively; nothing else needed.
  • Aider — a CONVENTIONS.md file is inert on its own: load it with aider --read CONVENTIONS.md, or persist it in .aider.conf.yml as read: [CONVENTIONS.md].
  • Cursor — rules are .mdc files under .cursor/rules/ with a rule type; make this one always-on (alwaysApply: true in the frontmatter, or the "Always" type in the UI), otherwise it may be absent exactly when the agent picks its search tool.
  • Verify it took — ask the agent "where is <some symbol> defined?" and check the transcript ran hay. A default that was never observed firing is a claim, not a configuration.

Do not shim or alias rghay. It is tempting and it is wrong twice over. Agents and editor tooling call rg expecting exact ripgrep semantics — file-grouped order, -v/-c/-o, begin/end JSON events — and hay deliberately declines or reshapes all of those. And a shim converts every exhaustive search (renames, security sweeps, error-string hunts) into a ranked, capped one without telling anyone — exactly the quiet wrong answer this tool's exit codes exist to prevent. The default belongs in instructions, where the agent knows which tool it chose and why.

Find the definition, read it

# fixed-string, top hit only — the common agent case
hay -F -m 1 "$symbol" "$root"

Structured consumption

# every match as TSV: path, line number, trimmed text
hay --json -F "$symbol" "$root" \
| jq -r 'select(.type=="match")
         | [.data.path.text, .data.line_number,
            (.data.lines.text | gsub("\\n$";"") | .[0:160])] | @tsv'

Context without reordering surprises

hay -C 3 -m 10 -F "$symbol" "$root"   # blocks stay grouped, gaps show as --

Honesty check on a big tree

hay -m 0 -F "$symbol" "$root" 2>err.log || true
grep -q "ranked the .* strongest" err.log && echo "truncated — switch to rg for exhaustiveness"

Install on an agent's machine

brew install mneves75/tap/hay   # macOS and Linux

# or from source:
curl -fsSL https://raw.githubusercontent.com/mneves75/hay/v0.3.0/install.sh | HAY_REF=v0.3.0 bash   # inspect first!
# inspect-first form:
git clone --depth 1 --branch v0.3.0 https://github.com/mneves75/hay.git && cd hay && HAY_REF=v0.3.0 ./install.sh

FAQ

Is it faster than ripgrep? Roughly comparable on typical queries; ranking adds work, measured honestly on the benchmark page. Read the timing chart there — ratios, not absolutes, survive machine noise.

Why did my test file rank low? The path prior downgrades tests deliberately: in agent transcripts, tests are rarely the answer to “where is X defined”. Overriding is a one-flag matter (--no-path), and the ablation switches exist precisely so you never have to trust the prior.

Can I use it as a drop-in in scripts? Yes, wherever scripts consume path:line:text and tolerate rank order plus the three declined flags above. Where scripts need exhaustiveness guarantees, keep rg.

Does the ranking ever change which lines match? Never. That is invariant #1, policed by a differential test against ripgrep on every change to walking, matching, or output.

Where do the numbers on the website come from? BENCHMARK.md / benchmark.html: public corpora, ground truth from a parser (ast-grep), paired bootstrap intervals cross-checked by Fisher randomization. The honest headline is also there: the task favours hay by construction, so read margins, and weigh them against the behavioural evaluation described in README.md, whose ground truth nobody designed around the tool.

The measurement kit

hay ships inside a repository that is mostly instrument: measure-mrr.ts turns local agent transcripts into a behavioural test collection (queries paired with the files the agent opened next), benchmark.ts runs the public-corpus comparison, and BENCHMARK_FEYNMAN.md explains both from zero. Developer-facing commands live in HOWTO.md; the negative result that started it all is the top of README.md. One rule for visitors: grep-hygiene.ts is the falsified metric, kept reproducible — do not extend it, do not quote it.