AGENTS.md · git:20260930.eb28122 · 2026-09-30 · sha256 807a088a1fb40fa0

AGENTS.md git:20260930.eb28122A

Immutable. This exact content never changes and is served at /api/v1/blob/807a088a1fb40fa0.

# AGENTS.md — vaultmem

Cold-agent map for this repo. `CLAUDE.md` is a symlink to this file; Codex,
Cursor, Copilot, and Gemini read `AGENTS.md` directly.

## What this is

`vaultmem` is a single POSIX/bash script (`./vaultmem`, ~2800 lines) that
turns a plain-markdown [Obsidian](https://obsidian.md) vault into agent
memory: search (ripgrep-backed), a curated Agent Index, a wikilink graph, and
a Project→Session lifecycle tier with a Task backlog feeding it. No daemon, no
database, no build step — the script *is* the artifact. This repo also ships:

- `install.sh` — copies `vaultmem` to `~/.local/bin` (or symlinks
  `skills/` into a harness skills dir with `--skills <dir>`).
- `skills/` — four Claude-Code-flavored agent skills, split by agent job:
  `vault-recall` (check the vault before re-deriving), `session` (cross-session
  working memory), `vault-capture` (write durable knowledge, incl. repo
  onboarding), and `vault-curate` (what to write next / vault health). They
  drive the memory workflow on top of the CLI.
- `.claude-plugin/` — projects `skills/` into an installable Claude Code
  plugin/marketplace.
- `ext/judge/` — the optional `judge` extension (`vaultmem-judge`, bash 3.2 +
  `curl` + `jq`): typed yes/no, choice, and score answers from a remote
  evaluation model. A separate executable, never sourced by core. See
  § The extension model.
- `tests/` — bats unit tests + a skills↔CLI drift lint.

Read the script top-to-bottom before making non-trivial changes — that's the
point of it being one file (see README § Design notes).

## Build / test / validate

No build step (bash script, nothing to compile). Before committing:

```bash
bats tests/vaultmem.bats            # unit tests over the CLI (registry, search, graph, lifecycle)
d=$(mktemp -d) && ln -s /bin/bash "$d/bash" && PATH="$d:$PATH" bats tests/vaultmem.bats   # …again under macOS system bash 3.2 (see below)
./tests/subcommand-lint.sh          # every `vaultmem <cmd>` referenced in skills/**/*.md must exist in the dispatch table
./tests/bash32-lint.sh              # no bash-4-only constructs (shellcheck cannot see these)
shellcheck vaultmem install.sh tests/subcommand-lint.sh tests/bash32-lint.sh
shfmt --diff vaultmem install.sh tests/subcommand-lint.sh tests/bash32-lint.sh   # must produce no diff
```

This is exactly what CI (`.github/workflows/ci.yml`) runs, split into four jobs:
`lint` (shellcheck + shfmt + the bash-3.2 construct lint), `test` (bats, on
Ubuntu + macOS), `bash32` (bats under macOS `/bin/bash` 3.2), and
`subcommand-lint`. All four must pass before merge.

**Write for bash 3.2, not for the bash on your `$PATH`.** macOS ships bash
3.2.57 as `/bin/bash` (frozen 2007; bash 4+ is GPLv3) and that is what the
`SessionStart` hooks run under, but most contributors have Homebrew bash 5.x —
so `declare -A`/`local -A`, `mapfile`/`readarray`, `${x^^}`, `|&`, `&>>`, and
`coproc` pass locally and break on macOS. Both instances that reached `main`
failed *silently* (a `doctor --deep` that exited 0 finding nothing; a lint that
passed vacuously). `shellcheck` cannot detect this — it targets a dialect, not a
bash version. Full rationale, the banned list, and portable (faster) substitutes:
[docs/development.md](docs/development.md).

Tests never touch a real vault — `tests/vaultmem.bats` builds a throwaway
two-vault registry per test via `VAULTMEM_CONFIG` pointed at a `bats`
tempdir. Follow that pattern (isolated `VAULTMEM_CONFIG` + fixture vaults
under `$BATS_TEST_TMPDIR`) for new tests; never point a test at
`~/.config/vaultmem/config.toml` or a real vault path.

## The CLI subcommand surface

`vaultmem -h` (or the top-of-file comment block, lines ~12–40) is the
authoritative usage summary — read it before adding or renaming a
subcommand. Broad shape: search/index (`<query>`, `index`, `mocs`), the
wikilink graph (`resolve`/`links`/`backlinks`/`neighbors`/`dangling`), the
router (`vaults`/`path`/`which`), the lifecycle tier
(`sessions`/`projects`/`project`/`next`/`task`/`groom`/`status`), hygiene
(`doctor`), setup (`init`), and the extension shim (`judge`). `groom --judge`
annotates its cold-parked/stale-active rows with a `groom-triage` judgment and
is advisory only: judging runs after the scan, so it never reaches a move.
`nudge --judge` adds a second Stop-hook line when `capture-worthy` finds a
durable decision/root cause in the transcript tail. `search --rerank` reorders
content hits (never curated rows) by the extension's `rerank` score; any
failure prints plain ripgrep order. `doctor --judge` adds a `DRIFT` section
from the `index-drift` judge (Agent-Index rows that no longer describe their
note).

**Adding usage lines? Bump the `sed` range.** The usage block is printed by a
hardcoded `sed -n '4,Np' "$0"` in *two* places (the `-h` branch and the no-args
branch). Add lines to the comment block without bumping `N` and the help output
is silently truncated — no error, no test failure unless you pin it. A bats test
("usage output ends with the final usage comment line") asserts the last line
still renders; keep it pointed at whatever the real last line is.

Subcommands dispatch from the final `case "${ARGS[0]:-}" in` block at the
bottom of the script. **Any subcommand a skill's markdown references by name
must exist there** — `tests/subcommand-lint.sh` enforces this in CI, so a
renamed or removed subcommand that skills still mention fails the build, not
a user's session.

## The config/registry model

Two related but distinct contracts — don't conflate them:

- **Registry** (`~/.config/vaultmem/config.toml`, a restricted TOML subset
  parsed by hand-rolled awk) — *which vaults exist* and how `vaultmem which`
  routes a repo/cwd to one. Full key reference, accepted/rejected TOML shape,
  and the routing algorithm: [docs/config.md](docs/config.md).
- **Schema** ([SCHEMA.md](SCHEMA.md)) — what a compliant vault looks like
  *inside*: folder layout, frontmatter fields the tool reads, the status
  vocabulary (`active`/`parked`/`done`), status-glyph filename convention,
  and the Agent-Index row grammar.

The config parser is deliberately not full TOML — `vaultmem doctor` hard-errors
on anything outside the accepted subset (arrays, nested tables, unquoted
strings, etc.) so a malformed config fails loudly instead of silently
mis-routing. `groom` moves files between folders on disk; a silent mis-route
there is the failure mode the lint exists to prevent. If you touch
`_parse_config`/`_lint_config` in `vaultmem`, keep both docs and the bats
`doctor hard-errors on …` tests in sync with the accepted-subset rules.

## The hooks model

`vaultmem status` and `vaultmem sessions` are designed to run from an agent
harness's `SessionStart` hook (Claude Code `settings.json`, Codex
`hooks.json`) and are **fail-quiet by contract**: an unmounted/unconfigured
vault makes them exit `0` printing nothing, so a hook wired with a
`command -v vaultmem >/dev/null &&` guard can never break a session start.
Full wiring examples for both harnesses, and how to customize the printed
`directive_file` line: [docs/hooks.md](docs/hooks.md). Never change
`cmd_status`/`cmd_sessions` to error on a missing vault — that contract is
load-bearing for every downstream hook config. `nudge` (Stop hook) holds the
same contract, and `nudge --judge` keeps it on every judge failure path: it
prints nothing extra and exits `0`.

## The extension model

**Core opens no socket.** `./vaultmem` never makes a network call and never gains
a dependency; anything that needs either lives in an extension, a separate
executable under `ext/<name>/vaultmem-<name>`. `judge` is the only one, and
`judge` is the only dispatch word extensions take: do not add git-style "any
`vaultmem-<x>` on `$PATH` is a subcommand", because search is the default
dispatch case and that makes every query ambiguous.

- `_ext_exec <name> [args]` resolves the executable in a fixed order:
  `$VAULTMEM_EXT_DIR/<name>/`, then
  `${XDG_DATA_HOME:-~/.local/share}/vaultmem/ext/<name>/`, then `ext/<name>/`
  beside the symlink-resolved script. Never `$PATH`. Not found: one stderr line,
  exit 3. It exports `VAULTMEM_BIN` (absolute, symlink-resolved) and
  `VAULTMEM_CONFIG`; the extension reads vault data only by calling
  `"$VAULTMEM_BIN"`, never by parsing the registry.
- Args after `judge` pass to the extension verbatim: the arg loop stops parsing
  core flags (`--vault`, `--format`, `-n`, `-h`) once the first positional is
  `judge`.
- `_judge <judge-name> <vault-id> [args]` is the helper integrated commands
  will call (no caller yet). State on stdin. It returns 3 **without forking**
  when `[ext.judge] enabled` is not `true` or the vault lacks `judge = true`, so
  a disabled judge costs a hook nothing. Callers treat anything but 0/1/2 as
  "no opinion" and print what they print today. The undocumented `_judge`
  dispatch word exists only so bats can reach the helper.
- **`vaultmem judge config` is a frozen contract, answered by core**, never
  forwarded. One line each, values unquoted, defaults filled in, in this order:
  `ext.judge.enabled`, `.model`, `.base_url`, `.zdr`, `.timeout_ms`,
  `.key_file` (`~` expanded), `.log`, `.rerank`, `.hook_judges`, then any other
  `[ext.judge]` key in file order, then `vault.<id>.judge=true|false` for every
  registry vault with a path. It must work with no config file and no vault
  (`judge` is in the pre-dispatch allowlist). The extension's tests stub
  `VAULTMEM_BIN` with a script printing this format, so changing it breaks the
  extension silently. A bats test pins it byte for byte.
- Core lints `[ext.<name>]` for subset shape only; key names belong to the
  extension (`vaultmem judge doctor`). `[vault.<id>] judge` must be a bare
  boolean.

Design and rationale: [docs/design/judge-extension.md](docs/design/judge-extension.md).

## Skills → Claude Code plugin

`skills/` ships three agent skills; `.claude-plugin/{marketplace.json,plugin.json}`
project them into an installable Claude Code plugin (`/plugin marketplace add
jayantak/vaultmem`) where each skill appears namespaced (`vaultmem:session`,
etc.). Non-Claude-Code harnesses skip the manifests and use
`install.sh --skills <dir>` to symlink `skills/<name>/` directly. How
discovery works, what each manifest file is for, and how skill content stays
version-locked to the CLI: [docs/plugin.md](docs/plugin.md).

## Non-obvious gotchas

- **The bash floor is 3.2 (macOS), and violations fail SILENTLY.** bash 3.2
  errors on `local -A`, then parses the next `arr[$path]=1` as an *indexed*
  assignment with an arithmetic subscript — so `doctor --deep` sprayed syntax
  errors per note and still exited 0. `mapfile` inside a process substitution
  doesn't trip `set -e`, which left `subcommand-lint.sh` passing on an empty
  list. Neither is visible on Homebrew bash 5, and neither is detectable by
  `shellcheck`. Run the suite with a `bash` → `/bin/bash` symlink first on
  `PATH` (the command above) + `./tests/bash32-lint.sh`. `BASH=/bin/bash bats …`
  does nothing: bash resets `$BASH` at startup, so the suite still runs on
  bash 5. See [docs/development.md](docs/development.md).
- **`doctor --judge` is informational and must never affect the exit code.**
  `DRIFT` rows print under their own heading; `doctor --judge` exits exactly
  what `doctor` exits, so a probabilistic lint never breaks CI or a hook.
- **`shellcheck` disables at the top of `vaultmem` are load-bearing, not
  boilerplate** — SC2016 (backticks in the usage/help text are literal, not
  command substitution) and SC2012 (the MOC listing intentionally uses `ls`
  over plain filenames). Don't blanket-remove them to "clean up" a lint pass.
- **`shfmt --diff` is part of CI**, separate from shellcheck — a change that
  passes shellcheck can still fail CI on formatting. Run `shfmt -w vaultmem`
  (or the specific file) before committing if unsure.
- **Status glyphs are presentation, never identity.** A Session's `project:`
  frontmatter field and a Project's session-index `[[wikilink]]` are always
  the *plain* name; the leading emoji (🟢/💤/✅/🔴/🟡) lives only on
  filenames and H1s. Matching logic strips the glyph before comparing — see
  SCHEMA.md § Status-glyph invariants.
- **`updated:` frontmatter, not file mtime, drives staleness math** (cold-parked
  detection, sort order) — file mtime gets rewritten by cloud-drive re-sync
  and would corrupt it. Mtime is only a fallback when `updated:` is
  missing/unparseable.
- **This repo is generated payload for some consumers.** `skills/*/SKILL.md`
  files carry a `<!-- GENERATED from the private dotfiles source repo -->`
  marker — they're synced in from an external private source, not
  hand-authored here. Edit them here for this repo's purposes (they must
  stay accurate and pass `subcommand-lint.sh`), but know a downstream sync
  process — not this repo — is their canonical origin.

## Where deeper truth lives

| Topic | Doc |
|---|---|
| Registry/config keys, TOML subset, routing algorithm | [docs/config.md](docs/config.md) |
| Vault folder layout, frontmatter contract, Agent-Index grammar | [SCHEMA.md](SCHEMA.md) |
| SessionStart hook wiring (Claude Code + Codex), directive customization | [docs/hooks.md](docs/hooks.md) |
| Claude Code plugin packaging, skill discovery, version sync | [docs/plugin.md](docs/plugin.md) |
| Install paths, quickstart, full command reference, design rationale | [README.md](README.md) |
| Judge extension: setup, egress rules, judge files, exit codes | [docs/judge.md](docs/judge.md) |
| Judge extension design, gateway findings, build order | [docs/design/judge-extension.md](docs/design/judge-extension.md) |
| Skill content itself (workflows, conventions each skill teaches) | `skills/<name>/SKILL.md` |
| bash 3.2 floor, banned constructs, portable substitutes, test conventions | [docs/development.md](docs/development.md) |