okf-maintain — Adopt and Maintain an OKF Documentation Bundle
The Open Knowledge Format is a directory of markdown files with YAML frontmatter. That is the whole format: no schema registry, no central authority, no runtime. Its value is not the frontmatter — it is that a corpus becomes enumerable, so an agent answers "which document covers X" from one generated index read instead of grepping a tree and guessing from prose.
This skill operates on the bundle: structure, frontmatter, indexes, and the entry points routing
agents into it. It never writes a document's body — authorship belongs to the repo's own templates,
and to the adr skill for decision records. Keeping that line sharp is what makes it safe to run
over documentation it did not write.
Target: OKF v0.2. Read references/frontmatter.md for the field families and
references/index-format.md for the frozen index grammar before writing either.
A declared profile is reported, not obeyed
docs/okf.yaml is not part of OKF v0.2 — it is a convention some toolchains use to declare a
dialect, and a profile: key in it names which documents must carry which keys. Read it if it
exists and say what it declares, because it changes how a conformance verdict should be read.
It does not change what gets indexed, and this skill no longer refuses a repository for carrying one. The refusal it replaces rested on two claims nothing ever checked: that a profile ships its own index generator, and that a commit gate somewhere compares the index byte-for-byte and would reject v0.2 output. Where those hold, they are worth respecting — so check them rather than assume them: is there a generator, and is a gate actually armed? Where they do not hold, refusing means the repositories most likely to want an index are the ones guaranteed not to have one, and a guard whose condition nothing can satisfy is a defect wearing a guard's clothes.
If a repository really does regenerate its index from another tool, that path belongs in
.okfignore, which is the mechanism for "another tool owns this" and states it per path instead of
per repository. Full argument: references/adoption.md.
What this skill owns
| Owns | Delegates |
|---|---|
| Bundle layout and the chained root indexes | A concept document's body and its claims |
| Frontmatter presence, shape, and repair | What type a new kind of document should be |
| Index generation, everywhere, bottom-up | Requirement text, acceptance criteria, ADR decisions |
Which paths are in scope (.okfignore) |
Anything another tool generates or owns |
Removing log.md and in-document history |
The repo's existing document templates |
Agent-entry wiring in CLAUDE.md / AGENTS.md / GEMINI.md |
Navigation and lookup at read time |
| §11 conformance verdicts | Any profile-specific dialect |
Frontmatter — one required key, and it is type
OKF v0.2 §11 requires exactly one thing of a concept document: a parseable YAML frontmatter block
containing a non-empty type. A document carrying only type is fully conformant.
---
type: Playbook
title: "Incident response: data freshness alert"
description: Steps to triage a freshness alert on the orders pipeline.
tags: [oncall, incident]
---
type is a free string — no central registry — so pick self-explanatory values and reuse them
consistently, because type is what the index groups by. Near-synonyms (Runbook and
Playbook) fracture one section into two.
title and description are optional to the spec but load-bearing here: they are the two fields
the index projects. A document with no description contributes a bare link and answers nothing
before it is opened. Derive it from the document's own opening sentence — never invent a summary of
content you have not read.
Stamp the optional families only when there is a real fact to record — generated, verified,
status (draft/stable/deprecated, absent means stable). Field families, the actor
convention, and why a date-only stale_after is ignored: references/frontmatter.md.
Indexes are generated — never hand-written
Every directory containing markdown gets an index.md, up to and including the repo root, listing
every markdown file in it — unless .okfignore excludes it, or it is plugin payload (both
below). A document is listed
because it exists, not because its folder was registered anywhere: a reader hunting for the
contributing guide or a stray plan cannot know the corpus filed it as furniture, and an index that
confidently omits it sends them back to ls.
Listing costs the document nothing. The row's title degrades — frontmatter title, then the first
body heading, then the filename stem — so a file with no frontmatter is listed exactly as well as
one with, and being listed never obliges it to carry keys. What must carry keys is a separate
question, answered by check and unchanged.
Generate them with the bundled script, always:
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs index <bundle-root>
Pass the repo root as the bundle root. It walks deepest-first, so a subdirectory's description exists by the time its parent is written, and it is idempotent.
Two things it will not touch, both reported rather than done quietly:
- Another repository's working tree. A directory holding a
.gitentry is a submodule or a nested clone, and the repo you invoked on only pins it. Writing there edits someone else's repository, shows up in agit statusnobody was looking at, andcoveragecannot catch it because git reports a submodule as a single gitlink. The walk stops at the boundary and saysseparate-repo: <path>/. If that tree needs an index, generate it from inside that repository. - An
index.mdit did not write. Every generated index carries the marker in its first content line; one without it is hand-maintained, or another tool's output, and its rows may carry an id, a status or a shape v0.2 does not project. Overwriting is a silent lossy downgrade of the exact catalog the index exists to be. It is left alone and reported asforeign-index: <path>. Read it, then either delete it to hand this tool the directory, or name it in.okfignoreto leave it with its owner. Hand-rendering is the most reliable way to introduce drift — sort order, separator and trailing newline vary between one writing and the next, and nothing fails when they do. A stale-looking index is a regeneration task, never a reason to grep the folder.
A description over 160 characters is dropped rather than truncated, leaving the bare link
an absent one would leave, and named on stderr as long-description: <path>. A machine-cut
half-sentence would be a summary no author wrote, planted in the field consumers trust most.
The script cannot summarise a subdirectory, so it reports needs-description: <path>. Read
enough of it to write one honest line and supply it:
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs index docs --describe requirements="Functional and non-functional requirements."
It then persists by round-tripping through the generated index, so it is written once. A directory holding exactly one described document inherits that description and is not reported.
Plugin payload is refused, not configured
At a Claude Code plugin root — a directory holding plugin.json or marketplace.json, either at
the package root or under .claude-plugin/, both of which the loader accepts — the commands/,
agents/ and skills/ children belong to the loader. Every
.md under commands/ is a slash command, every .md under agents/ is an agent definition,
and a skill folder's entry point is SKILL.md, carrying Claude Code's frontmatter schema rather
than OKF's. Writing an index.md there puts a document where the loader expects payload; demanding
type there asks a SKILL.md for keys that are not its schema and makes a progressively-disclosed
reference file pay context for keys nobody reads.
So the walk never descends into one, check never scans one, coverage never demands one, and each
pruned directory is reported as plugin-payload: <path>/. This is refused structurally rather than
left to an .okfignore line, for the same reason another repository's work tree is: the line can
only be written after the first run has already done the damage.
The anchor is the manifest, never the directory name — a docs/commands/ folder documenting a
CLI is ordinary knowledge and stays indexed. Delete the manifest and every one of those files is
enumerated again.
.okfignore — the paths this skill does not own
A real bundle root holds paths another tool owns: delivery logs, generated projections, vendored
docs. List them at the bundle root — one path per line, trailing / for a directory, # to comment.
docs/plans/
docs/TRACEABILITY.md
A listed path is not enumerated, not checked, and never stamped — three consequences of the one fact that it is not yours. Matching is exact, with no globs, so a blank line matches nothing rather than everything.
Exclusion fails green, so it is reported: every skip as ignored: <path> (.okfignore:N), every
line matching nothing as unused-ignore:, and a line broad enough to empty the corpus drops both
commands to 77 instead of a quiet success.
Prefer fixing the owner. A generated file whose generator stamps type needs no line here — it is
an ordinary concept document with a real description, which is where an important one belongs.
.okfignore is for what you cannot make conformant.
History belongs to git, not to markdown
Never create log.md (§9), and git rm any that exist. Strip ## History, ## Changelog,
## Revision history, version tables, and Last updated: lines from concept bodies.
One reason covers both: each is a hand-maintained copy of what git already stores, they disagree the
first time someone commits without updating them, and nothing fails when they do — so the drift
is found by a reader who trusted the wrong one. Deleting loses nothing; the content is in history.
The spec's answer to "when" is generated.at and verified[].at (§5.2): point-in-time facts, not
an accumulating record. This is the one place the skill is more opinionated than the spec — say so,
and point at git log --follow.
Wire the agent-entry files
An index nobody is told to read saves nothing.
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs wire <bundle-root>
That writes a marker-delimited block into CLAUDE.md and AGENTS.md, creating either if absent and
leaving existing content alone:
<!-- okf:entry -->
## Documentation
Start at [index.md](index.md). Every documentation folder carries a generated `index.md` listing
each document's title and one-line description — answer "which doc covers X" and "does a doc for Y
exist" from that index in one read, and open a document only after the index names it. Do not grep
`docs/` for a document's identity; grep stays correct only for a literal phrase inside a body that
the index cannot carry.
<!-- /okf:entry -->
The markers make the block idempotently updatable: a later run replaces what lies between them
rather than appending a second copy. GEMINI.md uses import lines, so it gets @index.md instead.
wire exits 1 until an index.md exists — pointing agents at a missing file is worse than not
pointing them anywhere.
It writes those files into the bundle root, so the bundle root must be the repo root: wire docs produces a docs/CLAUDE.md nothing reads. Chained roots are the design — ./index.md is the
address you give agents, and it links docs/ onward.
Keep the block's second half. Without the stated grep exception the rule reads as overreach and is ignored wholesale, costing more index reads than it buys.
Workflow — adopting OKF in a repo
- Manifest check. Read
docs/okf.yamlif it exists and report what it declares. Aprofile:key scopes required keys; it is not a reason to stop. - Survey. List the markdown present and how it is grouped. Do not restructure directories that
already make sense; OKF is agnostic about layout. Put anything another tool writes into
.okfignorenow, before it produces violations you would try to fix by hand. - Triage.
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs check .before writing anything. It prints onenote:perdescriptionthe index will refuse — that is the worklist, shorten each to a sentence. A corpus that already has frontmatter needs this, not a pass that overwrites it. - Frontmatter pass. For documents with none, add
type,title, and adescriptiondrawn from the document's own opening. Read each file — one invented from a filename is a fabricated claim in a machine-readable field. - History pass.
git rmanylog.md; strip history sections andLast updated:lines. - Generate.
okf.mjs indexat the repo root, so./index.md,docs/index.mdand every folder index are written as one chain. Supply--describefor each reported directory. - Wire.
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs wire . - Verify.
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs check .must exit 0, andokf.mjs coverage .must exit 0 too.checkalone cannot tell you a document was never walked — it and the index are the same projection. - Report what was created, what was deleted, every
.okfignoreline and why, and anydescriptionyou could not derive without guessing — leave it blank and say so.
Step 3 may follow step 6 instead. An index of titles and links with few descriptions is a correct intermediate state: every title still routes, where an index of paragraphs outgrows the documents it enumerates and sends readers back to grepping.
Workflow — maintaining an existing bundle
Run after any change to the corpus: a document added, renamed, retyped, or re-described.
check .— fix what it names; act onunused-ignore:, which means a declared path moved or was deleted and the line now protects nothing.index .— regenerate, and commit it with the content change, never as a follow-up commit someone forgets.coverage .— the added document is the one most likely to be orphaned, and it is the only check that can say so.wire .— a no-op unless the block's wording changed, and safe to run every time for that reason.
Delete a removed document's file and regenerate; leave no tombstone entry. A superseded one gets
status: deprecated and stays — §5.4 exists so links do not break.
Conformance
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs check <bundle-root>
It checks §11 and nothing stricter: parseable frontmatter, non-empty type, reserved filenames used
only for their reserved purpose. It does not fail a bundle for missing optional fields, unknown
type values, extra keys, broken links, missing indexes, or an over-long description — the spec
forbids rejecting on those, and a check that invents its own strictness trains people to ignore it.
Over-long descriptions come back as note: lines: the repair worklist.
Exit codes: 0 conformant, 1 violations named, 77 nothing was evaluated, 64 usage error.
Treat 77 as a failure to verify, never as a pass. The scan found no concept document at all —
a wrong path, an empty tree, or an .okfignore broader than intended. A clean bill issued over zero
files is the most convincing wrong answer a checker can give.
Frontmatter is parsed by a bundled reader — no YAML library, so the verdict is identical on every
machine. It fails closed: an unterminated quote, flow sequence or flow mapping, a duplicate key,
or nested content it cannot parse is a violation, because silent tolerance is how a corpus rots
behind a green light. Block scalars (>, |) are folded into their text — the danger in a
hand-rolled reader is never the syntax it rejects, which is loud, but the syntax it misreads.
Coverage — the check check cannot perform
node ${CLAUDE_PLUGIN_ROOT}/skills/okf-maintain/scripts/okf.mjs coverage <bundle-root>
check and "regenerate, then diff against the committed index" both read the corpus through the
same walk. A document that walk never reaches is therefore missing from the committed index and
from the regenerated one, the two agree perfectly, and the run is green. A projection compared
against itself cannot report a missing input — not because the comparison is sloppy, but because
the missing input is absent from both sides by construction.
coverage gets a second, independent enumerator: git ls-files --cached --others --exclude-standard, resolved from the repository root. Every markdown file it lists that no
index.md links to is named:
unindexed: .github/CONTRIBUTING.md
okf.mjs: 113 tracked document(s) in 44 index file(s), 1 reachable only by ls
Those three flags are the contract, not defaults. Plain git ls-files reads the index, so a
document written but not yet staged is invisible to it — and that is exactly the document at risk,
the one being added right now. A check blind to it passes, the commit lands with no row, and the
next commit belatedly adds the previous document's row with nobody the wiser. --others covers the
working tree; --exclude-standard keeps the repo's own ignore rules authoritative so build output
stays out. Indexes are likewise read from disk, so running straight after index and before git add tells you the truth about what you are about to commit.
Exit 0 when every document is reachable and no index is stranded, 1 naming each finding, 77
outside a git work tree — which is a refusal to verify, never a pass.
Two ways to clear a finding, and the choice is the whole point: index the document, or declare in
.okfignore that something else owns it. There is no third option where it stays invisible, which
is what the old behaviour amounted to.
Only an index the root index reaches may vouch for a document. index writes but never
deletes, so anything that narrows what gets indexed — a new .okfignore line, a pruned plugin
payload directory, the last document leaving a folder — strands the index.md it stops maintaining
on disk with its rows intact. Counting those rows credits a document that nothing a reader can
follow leads to, and the debris is invisible to every other check: an orphan is not an unreached
document, it is a reached nothing, so the totals read identically before and after it is removed.
Generated strays are therefore named as orphan-index: and their rows do not count. Delete
them — the generation marker is the evidence they are this tool's own leavings. A stray with no
marker is somebody else's file and is never named, though its rows do not count either.
A row whose document git will not commit is named too. index reads the working tree, so a
document written and not yet added is listed on purpose — that is what lets the regeneration hook
index it on the edit that created it. What happens next is the hazard: git commit -a stages
modifications to tracked files, so the updated index goes into the commit and the document it now
vouches for does not. The result is self-consistent and wrong, and every other enumerator here reads
the working tree, where both files are present, so nothing else can see it. Such rows are reported
as dangling-row: naming the index that carries them; git add the document, or delete it and
regenerate. Scoped to indexes that are themselves tracked — an untracked index is not going into
that commit either, so there is no half-commit to warn about.
Dot-directories are the one place index will not go, and coverage says so when a finding lands
there. The walk skips them because they hold tooling — descending reaches .git, .venv, and every
editor's cache — so no amount of regenerating produces an index inside one. That leaves two honest
answers and one trap. Move the document out of the dot-directory if it is real documentation; name
the path in .okfignore if it is machine output. Do not hand-write the missing index.md: it is
outside the walk, so nothing regenerates it, and it rots unseen — which is the exact failure this
command exists to surface, reappearing at the one place the tool declines to reach.
Regeneration happens on edit, not on memory
The plugin ships a PostToolUse hook (hooks/okf-index-regen.mjs) that runs index whenever a
markdown document is written or edited inside an adopted bundle. A projection refreshed only by hand
is stale from the first time somebody forgets, and nothing reports it until coverage runs — so
every OKF repository is a mechanical client of the generator rather than a manual one.
What it refuses, all of it before any write:
- A repository with no
okf.yaml(at the root or underdocs/). Adoption is the opt-in; without it the hook would stamp indexes into every repository you happen to edit markdown in. - An index edit, which would otherwise react to its own output.
- A path named in
.okfignore, and a file that is not markdown. - An index carrying no generation marker — the same
ownsIndexruleindexitself applies. - A repository declaring a newer
okf_versionthan the installed generator writes. The marker is versionless, so an older plugin would silently regenerate a richer catalog into a poorer one. The declaration is read fromokf.yamland from the root index's own stamp, and the higher wins.
The root is resolved from the edited file — git -C <dir of the file> rev-parse --show-toplevel,
with the file required to be inside the answer — never from the session's working directory. A cwd
default is how an edit aimed into a linked worktree or a submodule rewrites the session repository's
indexes while the edited one stays stale: the wrong tree written, the right tree not, and no error
on either side.
It is never gated on check. A repository can carry frontmatter violations and still owe its
readers an accurate index; the two questions are independent. It writes only indexes whose bytes
differ, and it fails open — every path exits 0, because a hook that can block an edit trades a
stale index for a stuck session.
The hook does not retire the manual command: it fires only on edits made through Claude Code, so a
human editor, a merge or a rebase still leaves the projection stale, and coverage is still what
proves it.
Anti-patterns
- Hand-editing a generated
index.md. The next regeneration silently discards it. Change the source frontmatter instead. - Deleting a
foreign-index:file to make the report go away. That is the one action that destroys what the report was protecting. Read the file first: if its rows carry an id or a status, the directory belongs to whatever produces them, and the answer is an.okfignoreline. - Inventing a
descriptionto fill a column. An empty one is a visible gap; a fabricated one is a false claim in the field consumers trust most. - Adding a
## Changelogback because a reviewer asked. Point atgit log --follow. - Treating a
77fromcheckorcoverageas green. Both mean nothing was evaluated, and a clean bill issued over zero files is the most convincing wrong answer a checker can give. - Reading a green
checkas "everything is indexed". It cannot mean that: it walks the same tree the index came from. Onlycoveragecompares against a list the walk did not produce. - Stamping frontmatter into a file a generator rewrites. It is erased on that tool's next run
and nothing reports it. Fix the generator, or declare the path in
.okfignore. - Reaching for
.okfignoreto silence a violation. It declares who owns a path, not which complaints you would rather not see. A document you own with notypeis a document to fix.