AGENTS.md@gems/okf · git:20260820.246ce59 · 2026-08-20 · sha256 a907831bc139b90a

AGENTS.md@gems/okf git:20260820.246ce59A

Immutable. This exact content is served forever at /api/v1/blob/a907831bc139b90a.

# AGENTS.md

Maintainer guide for okf — `okf` on RubyGems. The baseline gem: it reads,
validates, lints, searches and serves Open Knowledge Format (OKF) v0.2 bundles —
directories of Markdown + YAML frontmatter that humans and agents both read — and
ships the companion skill that teaches an agent to write them. This file
documents how to change the code without breaking its contracts.

This gem is the baseline of the okf-gem monorepo, one directory per gem under
`gems/`, and the three siblings all depend on it.
[`../../AGENTS.md`](../../AGENTS.md) is the repo-level guide and owns everything
above a single gem — the layout, the distribution surfaces, the PR shape, the
release-title convention, the Git attribution rule. What is here is okf's own:
its floor, its dependency limits, its contracts. Where the two overlap, the root
is the general rule and this is the instance.

## Read the bundle first

**`.okf/` is this gem's knowledge, and this file no longer restates it.** What
the code is, what every verb already answers, why each rule is a rule, and how a
change is proven all live there — once, in the concept that owns them:

| you want | read |
| --- | --- |
| the gem at a glance | [`.okf/overview.md`](.okf/overview.md) |
| what a file under `lib/` does | [`.okf/structure/`](.okf/structure/) — one concept per layer, naming every one of the fifty files |
| what a verb answers | [`.okf/cli.md`](.okf/cli.md) and [`.okf/capabilities/`](.okf/capabilities/) — before you add a verb that already exists |
| what the format's own nouns mean here | [`.okf/model/`](.okf/model/) |
| why a rule is a rule | [`.okf/design/`](.okf/design/) |
| how a change is proven | [`.okf/testing/`](.okf/testing/) |

`okf server .okf` from this directory reads it as a graph; `okf search @okf
<term>` searches it from anywhere in the checkout, and `okf search @all <term>`
reaches every bundle in the repository at once.

`structure/` is the half a test holds to the tree:
`test/unit/bundle_catalog_test.rb` fails when a file under `lib/` is named by no
concept, when a concept names a file that is gone, or when `cli.md`'s group
table disagrees with `OKF::CLI.builtins`. That table was code-derived and
unchecked for its whole life.

## The contract

Nine rules. Where a concept carries the argument, this is the short form a
reviewer checks against and the link is the rest; two of them have no concept
and are stated here in full.

1. **Ruby >= 2.4** — rack's own floor, so the gem runs on the Ruby an OS already
   ships. RuboCop parses at 2.4 and catches syntax, but **not APIs**: the
   forbidden list, broken out by the version that introduced each name, and the
   container command that actually proves the floor, are
   [`.okf/design/ruby-floor.md`](.okf/design/ruby-floor.md). It binds `test/` too.
2. **Runtime dependencies are exactly `rack`, `webrick` and `minifts`.** No
   ActiveSupport — `OKF.blank?` and `Markdown::Frontmatter.stringify_keys` exist
   precisely so it is not needed. A fourth is a design decision, not a
   convenience; the bar the third one cleared, and the honest note that its case
   has since weakened, are
   [`.okf/design/runtime-dependencies.md`](.okf/design/runtime-dependencies.md).
3. **YAML only through `Markdown::Frontmatter`** — `safe_load`, `Date`/`Time`
   permitted, no aliases. The Psych <3.1 positional-argument shim lives there;
   do not call `YAML.safe_load`/`YAML.load` anywhere else.
   [`.okf/structure/format-layer.md`](.okf/structure/format-layer.md).
4. **`validate` and `lint` stay separate.** The spec forbids the validator from
   rejecting broken cross-links or missing optional fields (warnings only); lint
   owns curation findings and never emits conformance errors. New checks go to
   the right side, and exit codes keep the contract: 0 ok, 1 failing bundle, 2
   usage error. [`.okf/capabilities/validator.md`](.okf/capabilities/validator.md)
   and [`.okf/capabilities/linter.md`](.okf/capabilities/linter.md).
5. **The server page stays self-contained** — one ERB template, inline CSS/JS,
   no bundler and no build step. Two XSS defenses hold the line: inlined data
   goes through `json_for_script`, and every fetched body through
   `DOMPurify.sanitize(marked.parse(...))` before it reaches `innerHTML`. Keep
   both — a new render path that skips the sanitizer reopens the hole. The
   boundary in full, including the realpath containment on every read, is
   [`.okf/design/server-trust-boundary.md`](.okf/design/server-trust-boundary.md).
6. **The skill ships only from `lib/okf/skill/**`** — that tree is the single
   canonical copy, so edit it there and nowhere else. `plugin/skills/okf` and
   `skills/okf` at the repo root are *generated*: run `bundle exec rake
   skill:sync` after touching the skill or bumping the version. Two guards fail
   on drift, and `build` depends on one of them.
   [`.okf/capabilities/agent-skill.md`](.okf/capabilities/agent-skill.md); why
   there are two destinations is the ecosystem bundle's `@okf-eco skills/okf-skill`.
7. **Tests use `OKF::TestCase`** (`test/test_helper.rb`): plain Minitest plus
   `test "..."` / block `setup`/`teardown` sugar. Nothing else is the base
   class, and the suite runs on 2.4, so rule 1 applies to `test/` unchanged.
8. **Integration first — `test/integration/cli/` is the critical layer.** It is
   the only place the gem is exercised the way it is actually used: real argv,
   real streams, real exit codes, real files. When a unit test and an
   integration test compete for effort, integration wins. See
   [Testing](#testing) below for what that obliges, and
   [`.okf/design/integration-first.md`](.okf/design/integration-first.md) for
   how the layer is organised.
9. **`.dockerignore` implies the gemspec's reject list, one way only.** Anything
   `.dockerignore` drops from under this directory must also be rejected by the
   gemspec (or be gitignored): `git ls-files` reads the *index*, so a path
   excluded from the Docker build context is still in `spec.files` and
   `gem build` then fails on a file that is not there. **The converse does not
   hold** and must not be "restored" for symmetry — `bin/`, `Gemfile` and
   `Rakefile` are rejected from the gem and stay in the build context on purpose.
   Paths outside this directory need no pairing at all; the gemspec runs with
   `chdir:` here and never sees them.

   The same section's other rule: **nothing in `spec.files` may be a symlink.**
   `gem build` does not resolve one — it writes a symlink into the package,
   warns, and succeeds. RubyGems >= 3.2 then refuses to extract a link pointing
   outside the gem, and **RubyGems < 3.2 has no guard at all**, so on Ruby 2.7
   (RubyGems 3.1.6, inside the supported range) `gem install` exits 0 and
   installs a *dangling* file. The old half of the matrix is the dangerous one:
   the gem installs cleanly and carries no licence. So `LICENSE.txt` and
   `NOTICE` are real duplicates of the root's, and `test/unit/packaging_test.rb`
   pins that they are not symlinks, are byte-identical to the root's, and are
   actually in `spec.files`.

## Testing

`test/integration/cli/` is the critical layer. How it is organised — one file
per command and subcommand, three folders for the three ways a user names a
bundle, what `across_bundles/` obliges even for the verbs that take one, and why
fixtures are the cheap part — is
[`.okf/design/integration-first.md`](.okf/design/integration-first.md). The
step-by-step walk a new verb owes is
[`.okf/testing/adding-a-verb.md`](.okf/testing/adding-a-verb.md).

Four obligations stay in this file, because a reviewer checks them:

- **Test first, and at this level.** A change starts with a failing integration
  test, not with the fix. Run it and read the failure: it must fail for the
  reason you predicted, not because a fixture is missing or a regex has a typo.
  Then write the code and re-run; the same test passes, **unedited**. A test
  written after the fix certifies only the code it was read off, and editing
  test and code together in one pass is how a bug and its test come to agree
  with each other and stay wrong together. A bug report earns a red test before
  a patch.
- **Pure refactors are the exception, not a licence.** They change no behavior,
  so the existing suite is the test and a green run is the proof. If a change is
  too small to fail visibly first, say so — never skip the step quietly.
- **Assertions must be able to fail for a real reason.** Run the CLI, read what
  it actually prints, then assert *that*. Never assert what you assume the code
  does — that is how a green suite certifies a bug.
- **Do not skimp on fixtures.** When a path is unreachable from the existing
  ones, add the fixture; never bend a test toward what the fixtures happen to
  make easy. A branch no fixture can reach is a branch nobody has ever proven.

### The graph page

`lib/okf/render/graph/template.html.erb` is ~1,300 lines of inline JS and CSS,
and its regressions are the kind a string assertion cannot see.
`test/integration/render/` proves the page is *emitted* correctly;
`test/browser/` — Playwright driving real Chromium, every spec run twice — is
what proves it *works*.

It does not run in CI, deliberately and on the evidence, so the obligation is
unhedged: **a change to the template is not done until `rake test:browser` is
green**, and a bug in the page earns a red spec there before it earns a patch.
Nothing enforces it. Run it and say what it said. The argument, the two modes
and the measurement that ended the CI job are
[`.okf/design/browser-tests.md`](.okf/design/browser-tests.md); the three seams
that couple the file's sections are
[`.okf/structure/the-server.md`](.okf/structure/the-server.md).

## Commands

From this directory — everything about the gem, and what CI actually runs:

```bash
bin/setup                          # install dependencies
bundle exec rake                   # test + rubocop — the default task, what CI runs
bundle exec rake test              # just the suite (SimpleCov report in coverage/)
bundle exec rake test:integration  # the critical layer alone + coverage/integration/
bundle exec rake test:browser      # the graph page in a real Chromium (needs browser:setup)
bundle exec rake browser:ui        # the same suite, interactive — pick specs and watch
bundle exec rake serve             # the browser fixture bundle, served for poking by hand
ruby -Ilib exe/okf <cmd> <dir>     # the CLI from the checkout, no install
bundle exec rake skill:sync        # regenerate every generated skill copy + version stamp
```

The 2.4 floor is proven in a container, run from the repo root because it copies
the whole tree and steps in here:

```bash
docker run --rm -v "$PWD":/src:ro ruby:2.4 bash -c \
  "cp -a /src /build && cd /build/gems/okf && rm -f Gemfile.lock && bundle install --quiet && bundle exec rake test"
```

CI (`../../.github/workflows/main.yml`) runs the default task on every supported
Ruby, 2.4 through the current stable, as its own job with `working-directory:
gems/okf`. A change is not done until it is green.

## Releasing

A release is cut **from this directory** — `cd gems/okf` first. Bundler reads
the gemspec in its working directory and derives the tag from it, so the root
`rake release` refuses rather than doing something plausible. The PR that
carries a version bump is a release PR and takes the repo-level shape — the
`release` label, the title `Release X.Y.Z — <summary>`, the body skeleton — all
three owned by [`../../AGENTS.md`](../../AGENTS.md).

1. Bump `lib/okf/version.rb`, then `bundle exec rake skill:sync` — the plugin
   versions with the gem, so `plugin/.claude-plugin/plugin.json` must follow
   every bump, and both generated skill copies with it. Move the `Unreleased`
   notes in `CHANGELOG.md` under the new version.
2. `bundle exec rake release` — tags `vX.Y.Z`, pushes commits + tag, pushes the
   gem to RubyGems (MFA required). `release` runs `build`, and `build` aborts if
   a generated skill copy has drifted or the plugin manifest lags the gem
   version (`rake skill:verify`), so a forgotten sync stops the release instead
   of shipping.

**The bare `v*` tag series belongs to this gem** and keeps doing so; a sibling
tags `<gem>/vX.Y.Z`. The asymmetry is deliberate and the argument is the
ecosystem bundle's `@okf-eco decisions/release-and-tags`.

`release:guard_clean` is **repo-wide** — Bundler runs `git diff` with no
pathspec, so a half-finished sibling gem or an edited file two levels up blocks
a release of this one, and all Bundler says is "There are files that need to be
committed first." `release:preflight` runs ahead of it and names the paths,
separating this gem's from the rest; it aborts, because guard_clean was going to
anyway and the only question is which message you get. Verified by running, not
pinned by a test: it is release tooling, and nothing in the suite drives rake
tasks.

Gem packaging detail: `spec.files` comes from `git ls-files` run with `chdir:`
here, minus `test/`, `bin/`, the Gemfile, the Rakefile, `.gitignore`,
`.rubocop.yml`, `AGENTS.md`, `CLAUDE.md` and the gemspec itself. Everything at
the repo root is invisible to it, so a new *root* file needs no reject — but a
new top-level file **here** ships unless the gemspec rejects it, so check
`gem build` output when adding one. Rule 9 above is the other half of this.

## Its own bundle

`.okf/` ships inside the gem — the reject list does not name it, deliberately —
so an installed okf carries a real bundle, its own, for a reader to open with
the very tool they just installed. `test/unit/packaging_test.rb` pins that it
ships; `rake okf` at the repo root keeps it validated and lint-clean.

Maintain it in the same commit as the code it documents. A new file under `lib/`
without a line in the concept that owns its layer is a red suite, not a stale
document — and so is a verb added to `CLI.builtins` without its row in `cli.md`.

Which bundle a fact belongs in — this one or the repository's `@okf-eco` — is
the ecosystem bundle's `@okf-eco design/where-knowledge-lives`. The test is
whether the fact survives deleting this gem; if it does, it is not this bundle's.
A concept cannot link out of its own bundle, so a reference across the line
names the other bundle in prose: `` `@okf-eco format/frontmatter` ``.