update AGENTS.md

This commit is contained in:
Emil Lerch 2026-06-02 12:49:58 -07:00
parent d7a86cd639
commit b7b492e494
Signed by: lobo
GPG key ID: A7B62D657EF764F8

387
AGENTS.md
View file

@ -1,45 +1,13 @@
# AGENTS.md # AGENTS.md
This file is zfin's source-of-truth for assistant agents. It
holds project-specific architecture, conventions, and gotchas
plus the universal hard rules that apply on top.
Read the ABSOLUTE PROHIBITIONS section first.
## ⛔ ABSOLUTE PROHIBITIONS — READ FIRST ⛔ ## ⛔ ABSOLUTE PROHIBITIONS — READ FIRST ⛔
### Zig 0.16.0 reference — read the release notes
This codebase is on Zig 0.16.0. The 0.16 release was a major
I/O-as-an-interface refactor that reshaped the standard library.
Before making non-trivial changes (especially anything touching
`std.Io`, `std.fs`, `std.process`, `std.http`, `std.Thread`,
allocators, or `std.time`), **read the release notes** at:
https://ziglang.org/download/0.16.0/release-notes.html
Key migrations that bit us repeatedly during the 0.16 upgrade and
will bite future work too:
- `std.io``std.Io` (namespace rename, no deprecation alias).
- `std.fs.cwd()``std.Io.Dir.cwd()`. All file ops take `io`.
- `std.process.Child.init(argv, alloc)``std.process.spawn(io, .{...})`
or `std.process.run(gpa, io, .{...})`.
- `std.time.timestamp()``std.Io.Timestamp.now(io, .real).toSeconds()`.
- `std.Thread.sleep(ns)``std.Io.sleep(io, duration, clock)`.
- `pub fn main` gains a `std.process.Init` parameter ("Juicy Main");
provides pre-built `gpa`, `io`, `arena`, `environ_map`.
- `std.heap.GeneralPurposeAllocator``std.heap.DebugAllocator`.
- `std.heap.ThreadSafeAllocator` removed; `ArenaAllocator` is now
lock-free thread-safe, `DebugAllocator` is thread-safe by default.
- `std.mem.trimRight`/`trimLeft``trimEnd`/`trimStart`.
- `std.mem.indexOf*``find*` (deprecation aliases still present,
so old names work but warn).
- `std.testing.refAllDeclsRecursive` removed. Only `refAllDecls`
remains. We use the bare `std.testing.refAllDecls(@This())` in
`src/main.zig`'s test block — it sema-touches every top-level decl,
which transitively pulls in every imported file's `test` blocks.
No local reimplementation is needed. See "Test discovery" below.
- `std.fs.File.readToEndAlloc(alloc, N)` → two-step:
`file.reader(io, &.{})` then `.interface.allocRemaining(alloc, .limited(N))`.
For anything not on this list, **read the release notes first.**
The notes are long but they're organized by section; searching for
the specific symbol you're migrating is fast.
### `io` vs `today` / `now_s` — design rule ### `io` vs `today` / `now_s` — design rule
@ -203,143 +171,15 @@ Add tests in the same file. Money helpers belong next to
the other tests in `Money.zig`; date helpers belong next to the other tests in `Money.zig`; date helpers belong next to
`yearsBetween`'s tests. `yearsBetween`'s tests.
### NEVER invoke ripgrep. EVER.
**Do not run `rg` in the Bash tool.** Not for open-ended search, not for
counting matches, not for "just this one quick check", not ever. Running
ripgrep on this machine hammers the filesystem badly enough to degrade the
whole system — this is a recurring, reproducible problem, not a hunch.
**Use instead:**
- **Grep tool** (built-in) for content search. It handles regex, file
globs, and output shaping without spawning `rg`.
- **Glob tool** (built-in) for finding files by name pattern.
- **Read tool** for reading files (with `offset`/`limit` for large ones).
- Plain `grep` via the Bash tool is acceptable when the built-in Grep
tool can't express what you need — but prefer the built-in first.
**If you catch yourself typing `rg` in a Bash command:** stop, delete it,
use the Grep tool instead. The fact that `rg` is faster in the abstract
does NOT matter here. This machine's filesystem + ripgrep's parallelism
is a bad combination, full stop.
**This applies to every variant:** `rg`, `ripgrep`, piping through
`rg`, backgrounded `rg`, `rg --files`, etc. All banned.
### NEVER delete or modify build caches. EVER.
**This means:**
- **NEVER** run `rm -rf .zig-cache` or `rm -rf .zig-cache/*` or any variant.
- **NEVER** run `rm -rf ~/.cache/zig` or touch anything under `~/.cache/zig/`.
- **NEVER** touch `~/.cache/zls/` or any other tool cache.
- **NEVER** suggest deleting the cache as a "fix" — it is not a fix, it is
damage. Deleting `.zig-cache` while ZLS or another `zig build` is running
creates a corrupt state where the build runner's expected cache entry
(`.zig-cache/o/<hash>/build`) references a path that no longer exists,
producing the error `failed to spawn build runner .zig-cache/o/<hash>/build:
FileNotFound`. Recovering from this on the affected machine requires
killing every concurrent `zig` process (including ZLS) and waiting for
filesystem state to re-stabilize — it is NOT a simple retry.
- **NEVER** touch the cache "just to force a rebuild". Zig's cache is
content-addressed. It does not get stale in a way that deletion fixes.
If a build result looks wrong, the bug is in the source, not the cache.
Use `touch src/somefile.zig` if you truly need to invalidate one file's
cache line. Do not nuke the whole directory.
- **NEVER** suggest a "different cache directory" as a workaround without
an explicit, specific reason and explicit user approval. `--cache-dir`
and `--global-cache-dir` flags exist; they are not toys.
**If a test result seems wrong or cached incorrectly:** the answer is
ALWAYS to investigate the source code or build graph, not to delete
cache. See "Test discovery" below — 99% of the time the "cached wrong
result" is actually a test discovery problem, not a cache problem.
**If you find yourself typing `rm -rf` anywhere near a cache path: STOP.
Ask the user instead.**
### NEVER run destructive git operations without explicit permission.
- No `git reset --hard`, `git clean -fdx`, `git push --force`, `git checkout .`
on files with uncommitted work, unless the user asks for that specific
operation by name.
### NEVER run `git stash`. EVER.
- **`git stash` is banned outright.** Not `git stash push`, not `git stash --keep-index`,
not `git stash pop`, not `git stash apply`, not even read-only `git stash list`.
No variant. No "just to test something." No "I'll pop it right back."
- The reason: `git stash pop` can conflict on overlapping lines and leave
unresolved conflict markers in the working tree. The recovery requires
hand-resolving the markers, which trashes whatever curated index state
the user had in flight. This has bitten the user before and it's bitten
them again because of me. The rule is absolute now.
- If you think you need `git stash` to verify something (e.g. "does the
staged-only state build cleanly in isolation?"), the answer is: **don't
verify it that way.** Either:
- Read `git diff --cached` and reason about whether the staged hunks
are coherent on their own, OR
- Ask the user to verify after they commit, OR
- If verification is critical, ask the user to do the stash themselves
with their tools — but recommend against it because of this rule.
- **There is no exception clause for `git stash`.** Not even "the user
said it's OK this once" — the previous "one-time exception" for git
staging operations is what led to the stash incident. Direct exceptions
for `git add -p` / `git restore --staged` for staging-management remain
permitted with explicit user approval, but `git stash` is permanently
off-limits regardless of consent.
### NEVER run `git add`, `git commit`, or `git push`. EVER.
- **The user commits. You do not.** Do not stage files. Do not create commits.
Do not amend commits. Do not push. Do not suggest running these commands
yourself "to save a step". This includes `git add -p`, `git add .`,
`git add <file>`, `git commit -m ...`, `git commit --amend`, `git push`,
and any `gh pr create` that would auto-stage or auto-commit.
- If you are tempted to run any of these because "the work is done and it
seems logical to commit" — STOP. The user has a review-and-commit workflow.
Your job ends at a clean working tree with the changes ready to review.
- The ONLY exception is when the user says, verbatim in the current turn,
"commit this" / "make a commit" / "push it" / similar direct imperative.
Do not extrapolate from earlier intent, a plan that mentioned milestones,
or any indirect signal. If in doubt, ask — don't commit.
- When a milestone plan says "STOP POINT — user reviews and commits": you
stop. You do not commit. You do not prepare a commit. You hand off the
working tree and wait.
### NEVER write to `/tmp`. Use `$(cwd)/.tmp` instead.
- **`/tmp` is off-limits for any temporary file the assistant creates.**
Not for scratch scripts, not for redirected stdout, not for `cp`
backups, not for "just for a second" experiments. The user's workflow
treats `/tmp` as their own space; assistant-created files there
pollute it across sessions and hide from `git status`.
- Use `$(cwd)/.tmp/` (a `.tmp` directory at the repository root) for
every transient file. `mkdir -p .tmp` if it doesn't exist;
`.gitignore` it (or rely on a project-level `.gitignore` rule that
catches it). Files in `.tmp` show up in `ls` next to the work,
surface in `git status` if the ignore rule is wrong, and get
cleaned up at the end of the task with a single `rm -rf .tmp/*`.
- This applies to:
- Bash redirections: `cmd > .tmp/out` not `cmd > /tmp/out`.
- Test fixture scratch files.
- `cp file /tmp/file.bak` for "I'll restore in a sec" backups —
use `cp file .tmp/file.bak` instead.
- Heredoc-created throwaway scripts to test a Zig snippet
(`zig run /tmp/foo.zig``zig run .tmp/foo.zig`).
- The ONE exception: temp files created by the system / by other
tools (mise installations, zig's own cache symlinks, etc.) that
legitimately use `/tmp`. Don't try to redirect those.
### Documentation-file conventions ### Documentation-file conventions
- **`TODO.md` holds only what's still open.** Git already tracks what was - **`TODO.md` holds only what's still open.** Git already tracks
done and when. Do NOT add "DONE" markers, completion status, strikethrough, what was done and when. Do NOT add "DONE" markers, completion
or "shipped in …" blurbs to TODO entries — just delete the section when status, strikethrough, or "shipped in ..." blurbs to TODO
the work is finished. If follow-up work came out of the finished task, entries. Just delete the section when the work is finished. If
add it as a new top-level section; don't leave the parent entry around follow-up work came out of the finished task, add it as a new
as a historical wrapper. top-level section; don't leave the parent entry around as a
historical wrapper.
- **`REPORT.md` is untracked on purpose.** It's a personal workflow doc - **`REPORT.md` is untracked on purpose.** It's a personal workflow doc
living in the repo root only until it moves to the user's portfolio living in the repo root only until it moves to the user's portfolio
directory. Edit it freely when asked; don't treat it as part of the directory. Edit it freely when asked; don't treat it as part of the
@ -347,140 +187,84 @@ Ask the user instead.**
### Lint warnings — there are no "pre-existing" warnings ### Lint warnings — there are no "pre-existing" warnings
**zlint warnings get fixed, period.** Do NOT excuse a warning by **Lint warnings get fixed, period.** Do NOT excuse a warning by
saying it was "pre-existing in the file" or "inherited from a copy" saying it was "pre-existing in the file" or "inherited from a
or "the same style as elsewhere." That's how lint debt accumulates copy" or "the same style as elsewhere." That's how lint debt
to the point where the pre-commit hook (`zlint --deny-warnings`) accumulates to the point where the deny-warnings flag on the
becomes a tripwire that everyone routes around. pre-commit hook becomes a tripwire that everyone routes around.
The rule: The rule:
1. **Before any commit, run zlint on every file you touched in 1. **Before any commit, run zlint on every file you touched
the change.** If zlint reports any warnings on those files, in the change.** If zlint reports any warnings on those
fix them in that change. There is no "I didn't introduce it, files, fix them in that change. There is no "I didn't
not my problem" — once you've touched the file, the warnings introduce it, not my problem." Once you've touched the file,
are yours. the warnings are yours.
2. **If you find pre-existing warnings in a file you didn't 2. **If you find pre-existing warnings in a file you didn't
intend to touch (e.g. you ran zlint across the whole tree intend to touch** (e.g. you ran zlint across the whole
for a sanity check), fix them in a SEPARATE commit and call tree for a sanity check), fix them in a SEPARATE commit and
that out to the user explicitly so they can keep the commits call that out to the user explicitly so they can keep the
clean.** Do not silently bundle drive-by lint fixes into a commits clean. Do not silently bundle drive-by lint fixes
feature commit; the diff becomes harder to review and the into a feature commit.
lint-debt origin gets buried.
3. **Common warning kinds and the right fix:** 3. **Never report a lint result by saying "0 errors, N warnings,
- `suppressed-errors` (`catch {}` / `catch "fallback"`): but they're all pre-existing."** Either fix the warnings in
replace with `try` (propagate to the caller). If the call this change OR report "0 errors, 0 warnings on the files I
genuinely cannot propagate (e.g. an stderr write inside an touched. The wider tree has N warnings I haven't addressed
error-reporting path where the secondary error doesn't in this change; flagging for follow-up" — and only after
matter), use `catch |err| std.log.debug(...)` or rewrite to you've confirmed by file that none of the wider-tree warnings
not need the catch. The `catch {}` form is almost never the are in files you modified.
right answer.
- `unsafe-undefined`: add a `// SAFETY: <reason>` comment on
the line with `undefined` explaining why it's safe (e.g.
"buffer immediately overwritten by bufPrint below").
- `unused-decls`: delete the decl. Don't leave dead imports
or constants around "in case."
4. **Never report a lint result by saying "0 errors, N warnings, zfin uses `zlint --deny-warnings` on the pre-commit hook. The
but they're all pre-existing."** Either: common zlint warning kinds and the right fix:
- Fix the warnings in this change (preferred when N is small
or the file is yours), OR
- Report "0 errors, 0 warnings on the files I touched. The
wider tree has N warnings I haven't addressed in this
change; flagging for follow-up" — and only after you've
confirmed by file that none of the wider-tree warnings
are in files you modified.
The `--deny-warnings` flag is on the pre-commit hook for a - `suppressed-errors` (`catch {}` / `catch "fallback"`):
reason: every warning is a real signal that the codebase asked replace with `try` (propagate to the caller). If the call
the linter to flag and someone hasn't dealt with. Treat them as genuinely cannot propagate (e.g. an stderr write inside an
errors at write-time, not as background noise to ignore. error-reporting path where the secondary error doesn't
matter), use `catch |err| std.log.debug(...)` or rewrite to
not need the catch. The `catch {}` form is almost never the
right answer.
- `unsafe-undefined`: add a `// SAFETY: <reason>` comment on
the line with `undefined` explaining why it's safe (e.g.
"buffer immediately overwritten by bufPrint below").
- `unused-decls`: delete the decl. Don't leave dead imports
or constants around "in case."
### Em-dash usage — ASK FIRST
If you're about to write an em-dash (`—`) anywhere (code, tests, doc
comments, commit messages, AGENTS.md prose), **stop and check whether a
regular ASCII hyphen (`-`) would do.** Most of the time it would. Em-dashes
look nice in prose but they create real problems:
- **In tabular output / TUI cells**, `—` is 3 bytes / 1 display column.
Zig's `{s:>N}` formatter pads by byte count, so any column containing
em-dashes will be 2 visual columns short per em-dash. We've fixed this
bug at least twice; don't reintroduce it. If you genuinely need a
multibyte sentinel for "no data", use `fmt.padRightToCols` /
`fmt.centerDash` (display-column-aware) — or just hard-code the cell
as a literal const string when the cell width is fixed and the dash
position is static (no point computing what you already know). Add
an alignment test that compares the multibyte row's `displayCols`
against an ASCII row.
- **In code identifiers and string literals**, em-dashes look fine in your
editor and break grep on the user's machine when they're searching with
ASCII `-`. If a future grep for "expected-return" or "as-of" silently
misses your "expected—return" string, that's a bug surface.
- **In commit messages and prose docs**, em-dashes are an AI tell. The
user reads commit messages and won't appreciate the codebase looking
like it was written by ChatGPT.
**Rule of thumb:**
- Use `-` (ASCII hyphen) for: ranges (`1-5y`), compound modifiers
(`forecast-vs-actual`), CLI flag names (`--return-backtest`), code
identifiers, and string concatenation in tables.
- Use `—` (em-dash) only when you're displaying it to the user as a
meaningful sentinel (e.g. "no data" cell in a table), AND you've handled
the display-column padding correctly.
- If you find yourself reaching for `—` in prose for a parenthetical
aside, **switch to a regular dash, comma, or parens.** Em-dashes have
become a stylistic AI tic and the user has explicitly asked to keep
them out unless they earn their place.
When in doubt, **ask**. A one-line "I'm about to use `—` here for X, OK?"
is much cheaper than reverting after the user notices.
### Errors carry information — never throw it away ### Errors carry information — never throw it away
When you catch an error, the caller's first question is **"why did When you catch an error, the caller's first question is **"why
this fail?"** A user-facing error message that says only did this fail?"** A user-facing error message that says only
`"FetchFailed"` or `"could not parse portfolio file"` is failing `"FetchFailed"` or `"could not parse portfolio file"` is failing
the user — they have to read source code to figure out what the user — they have to read source code to figure out what
happened. Three habits cause this: happened. Three habits cause this:
**(1) Bare `catch {}` and `catch return error.X`.** The error **(1) Bare `catch {}` and `catch return error.X`.** Capture as
value carries the upstream's classification (`RateLimited`, `catch |err|` and at minimum include `@errorName(err)` (or `{t}`
`Unauthorized`, `NotFound`, `ParseError`, etc.). Discarding it format) in any user-visible message. Better: re-raise a more
reduces N distinct conditions to one opaque "something went specific error variant from the caller's error set.
wrong." Fix: capture as `catch |err|` and at minimum include
`@errorName(err)` (or `{t}` format) in any user-visible message.
Better: re-raise a more specific error variant from the caller's
error set so the next layer up can decide what to do.
**(2) Generic `error.X` collapse points in service-layer code.** **(2) Generic `DataError.FetchFailed` collapse in service.zig.**
When `service.zig:getX` does `provider.fetchX(...) catch return When `service.zig:getX` does `provider.fetchX(...) catch return
DataError.FetchFailed`, the provider's specific error DataError.FetchFailed`, the provider's specific error
(`RateLimited` vs `Unauthorized` vs `NotFound`) is permanently (`RateLimited` vs `Unauthorized` vs `NotFound`) is permanently
erased. Fix: either expand the service-layer error set erased. Fix: either expand `DataError` to mirror the provider
(`DataError`) to mirror the provider distinctions, OR pass the distinctions, OR pass the inner error through via `catch |err| {
inner error through via `catch |err| { log.warn(... log.warn(... @errorName(err) ...); return ...; }` so stderr
@errorName(err) ...); return ...; }` so the stderr path tells tells the user something useful even when the typed return value
the user something useful even if the typed return value is is collapsed.
collapsed.
**(3) User-facing stderr messages that don't say what the actual **(3) User-facing stderr messages must name the error.** A CLI
error name was.** A CLI command that catches an error MUST print command that catches an error MUST print `@errorName(err)` (or a
`@errorName(err)` (or a deliberate human translation per error deliberate human translation per error kind) in the stderr
kind) in the stderr message. `"Error: Failed to fetch data for message. `"Error: Failed to fetch data for symbol"` is
symbol"` is unhelpful. `"Error fetching AAPL: RateLimited (free unhelpful. `"Error fetching AAPL: RateLimited (free tier 5/min,
tier 5/min — wait 60s)"` is helpful. The error name alone is wait 60s)"` is helpful.
enough for the user to look it up; the human translation is a
nice-to-have on top.
**The grep test:** if you're catching an error in user-facing **The grep test:** `cli.stderrPrint` calls with hardcoded "Error:
code, the message should mention either `@errorName` / `{t}` or failed to ..." strings and no error context fail this test.
reference a specific error variant by name. `cli.stderrPrint`
calls with hardcoded "Error: failed to ..." strings and no error
context fail this test.
### NEVER put PII in tests, fixtures, comments, or docs ### NEVER put PII in tests, fixtures, comments, or docs
@ -879,26 +663,27 @@ shape with example signatures.
### `anytype` is almost never the right answer — pause and ask first ### `anytype` is almost never the right answer — pause and ask first
Empirically, every time `anytype` looked necessary in this codebase Empirically, every time `anytype` looked necessary in this
it turned out not to be. Concrete-typed parameters always worked codebase it turned out not to be. Concrete-typed parameters
once we actually tried them. **Before adding any new `anytype` always worked once we actually tried them. **Before adding any
parameter, stop and reconsider.** The discussion that surfaces "no, new `anytype` parameter, stop and reconsider.** The discussion
this can be `*App` after all" is short and worth having every time. that surfaces "no, this can be `*App` after all" is short and
worth having every time.
Common reasons people reach for `anytype` and what to do instead: Common reasons people reach for `anytype` and what to do instead:
- **"I want to avoid a circular import."** Test the assumption. - **"I want to avoid a circular import."** Test the assumption.
Zig resolves `a.zig ↔ b.zig` cycles fine in most cases — file Zig resolves `a.zig ↔ b.zig` cycles fine in most cases — file
structs are evaluated lazily, and the cycle only fails if structs are evaluated lazily, and the cycle only fails if
evaluation actually loops. Just write the concrete type and run evaluation actually loops. Just write the concrete type and
`zig build`. If it fails, the answer is usually to extract the run `zig build`. If it fails, the answer is usually to extract
shared type to a third file, not to weaken the contract with the shared type to a third file, not to weaken the contract
`anytype`. (See: the `tab_framework.zig``tui.zig` cycle with `anytype`. (See: the `tab_framework.zig``tui.zig` cycle
caught me once; turned out Zig handled it cleanly.) caught me once; turned out Zig handled it cleanly.)
- **"This function is genuinely polymorphic over many types."** - **"This function is genuinely polymorphic over many types."**
In Zig, the right shape for runtime polymorphism is usually In Zig, the right shape for runtime polymorphism is usually
`*anyopaque` + an explicit cast at the boundary, paired with `*anyopaque` + an explicit cast at the boundary, paired with a
a vtable struct of fn pointers. That's harder to abuse than vtable struct of fn pointers. That's harder to abuse than
`anytype` and the cast site documents the type contract `anytype` and the cast site documents the type contract
unambiguously. Compile-time polymorphism over a known set of unambiguously. Compile-time polymorphism over a known set of
types is better expressed with `comptime T: type` parameters. types is better expressed with `comptime T: type` parameters.
@ -910,8 +695,8 @@ Common reasons people reach for `anytype` and what to do instead:
the method. the method.
- **"It's a test helper that takes any struct."** This is the - **"It's a test helper that takes any struct."** This is the
one case where `anytype` is sometimes OK — generic test one case where `anytype` is sometimes OK — generic test
utilities like `std.testing.expectEqual`. But check whether utilities like `std.testing.expectEqual`. But check whether a
a concrete type would do. concrete type would do.
The real cost of `anytype` is that it punches a hole in the type The real cost of `anytype` is that it punches a hole in the type
contract: the compiler can't catch wrong-shape arguments at the contract: the compiler can't catch wrong-shape arguments at the
@ -977,7 +762,3 @@ command.
| [SRF](https://git.lerch.org/lobo/srf) | Cache file format, portfolio/watchlist parsing, serialization | | [SRF](https://git.lerch.org/lobo/srf) | Cache file format, portfolio/watchlist parsing, serialization |
| [libvaxis](https://github.com/rockorager/libvaxis) (v0.6.0) | Terminal UI rendering | | [libvaxis](https://github.com/rockorager/libvaxis) (v0.6.0) | Terminal UI rendering |
| [z2d](https://github.com/vancluever/z2d) (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) | | [z2d](https://github.com/vancluever/z2d) (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
## Build system rules
- **Never use `addAnonymousImport`** in `build.zig`. Always use `b.addModule()` + `addImport()`. Anonymous imports cause "file belongs to multiple modules" errors and make dependency wiring opaque.