Changelog
What shipped in each okf release.
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.13.0 - 2026-08-07
Added
Bundle#directories— the bundle's own answer to "does this bundle have directory X?", public because every surface now asks it (the CLI's concept views,okf-mcp'sdirrefusal) and the API this version's minor bump exists for. Introduced by, and argued in, therootfix under Fixed below.
Changed
- The repository became a monorepo, and the gem moved into
okf/. Nothing about the published gem changes in kind — same name, same library, samev*tag series, same image, byte-identical in size to a pre-move build — but a checkout is laid out differently: one directory per gem, named for the gem it ships, sookf-mcp/and the rest can land beside the baseline without reshaping anything.plugin/,.claude-plugin/,.okf/and theDockerfilestay at the root; the last of those because its build context must be the repo root, where the.gitthe gemspec'sgit ls-filesneeds lives. From a checkout it iscd okffor everything about the gem, and plainrakeat the root to run every gem's suite or to validate this repo's own bundle. CODE_OF_CONDUCT.mdno longer ships in the gem (70 files, from 71). It is a repository document and was packaged only because nothing rejected it. The gem now carries its own README instead of the project's — which also fixes a long-standing wart, since the project README opens with images under.github/that the gemspec has never packaged, so the page on rubygems.org has been rendering a broken hero.changelog_uripoints atokf/CHANGELOG.md, where this file now lives.
Fixed
-
A directory really named
rootis nameable again.--dirand--areaacceptrootas a spelling of the bundle root, which is worth a shell quote right up until a bundle actually has aroot/directory — and then the alias made it unaddressable, answering for the bundle root instead: the wrong concepts, exit 0, nothing said. The alias now yields to a real directory, since a convenience is worth less than a directory being reachable at all; where no such directory exists nothing changes, which is every bundle that has ever relied on the spelling.okf dirscarried the same fold into itssubtreecolumn, where it computed therootrow's subtree against the bundle root — so the number on the row disagreed with what--diron that row returns, the one thing it promises never to do."Does this bundle have a
rootdirectory?" is now asked of the bundle (Bundle#directories, under Added above) rather than of whichever list a view had to hand. The concept views were reading it off the catalog, which knows only directories holding concepts, so aroot/carrying nothing but anindex.mdstayed folded incatalog/files/tags/types/searchwhiledirsandindexnamed it correctly — two answers to one question about one bundle. And a multi-bundleokf search @a @b --dir rootresolved per bundle inside the loop, so one flag meant theroot/subtree in the bundle that has one and the bundle root in the bundle that does not, merged into a single ranking with nothing in the output saying so; the served set resolves it once.Bundle#directoriescounts every file kind that makes a directory real — concepts, anindex.md, and now a scopedlog.md, which the first cut left out: aroot/holding only its history was invisible to the set, so the alias won again for exactly the shape the fix was named after, one file kind over. The same seed feedsdirectory_index, sodirs,indexandstatscount a log-only directory too, and every surface answers "does this bundle have directory X?" from one list. -
filter_entriesregained its 1.12.0 arity. The dirs-source fix above added a required third parameter to a helper onCLI::Command— the base every verb inherits, plugin gems' verbs included — so an out-of-tree verb calling the released two-argument shape raisedArgumentErrorat runtime. The parameter defaults now, and the default is 1.12.0's resolution — the helper is handed rows, not the folder, so it has nothing to resolve a realroot/against, and therootalias folds exactly as it did; the plugin suite drives a 1.12.0-era verb through the two-argument shape to pin it. Its siblingfilter_idsmakes the opposite choice on purpose: it is handed the folder, and its two-argument shape resolves throughdir_scope, because the unconditional fold there was the bug this entry fixes, not a contract — an old caller gets the corrected answer rather than the familiar wrong one. In-tree callers pass the set throughdir_scope, which also derives it only when a--diror--areaflag will actually consult it — a plain listing no longer pays a per-path ancestor walk for a set nothing reads (andBundle#directoriesis memoized, so the verbs that ask twice pay once). -
Coverage stopped measuring the plugin's curation hook for one commit, and said so by going up: SimpleCov's root defaults to the working directory, so moving the gem down a level dropped ~100 tested lines out of the report and line coverage read 98.63% against 98.47%. Its root is the repository now, with the report still written inside the gem.
Security
- A symlinked file in a bundle can no longer read past the bundle root. The
traversal guard
Path.join_under!was lexical — it expands the path string, andFile.expand_pathdoes not resolve a symlink — so a link whose name sat inside the root but whose target did not passed the check, and the read followed it. Every read now realpath-resolves and re-checks against the real root:Path.under?is the shared pure predicate,Bundle::Readerquarantines an escaping file into the unparseable bucket (a planted symlink is one bad file, not grounds to fail the whole bundle read, which would hand any writer of a served directory a denial of service), andConcept::Fileguards its own read. A symlink that stays inside the bundle still resolves as before. The gap reached every reader; serving a bundle's contents to an agent — asokf-mcpdoes — is what surfaced it.
1.12.0 - 2026-07-24
Added
okf graph --traffic— the link graph read one grain coarser.--hubsmeasures concepts, but the refine playbook's directory judgements ("does this directory prune? a concern, or a container?") had nothing measured at their grain.--trafficcollapses each concept into its directory and the links between two directories into one weighted arc, so every row carries its internal / out / in traffic and a cohesion — its internal share of that total. That is cohesion versus coupling on a knowledge tree: near-zero cohesion under heavy inbound is a shared vocabulary doing its job, heavy outbound with nothing back is a projection wearing a directory. The rows sort by cohesion ascending, so the directories with a case to answer come first, and a directory with no traffic at all prints—rather than a0%it did not earn. The arc cut is fitted to the bundle, not fixed — at weight 3, ten bundles ranged from 2 arcs to 136 — and--cut Noverrides it; cohesion is computed over every arc regardless, so narrowing the drawn picture never moves the evidence. JSON:{ bundle, cut, fitted, dirs, arcs, total_arcs }. The pure model underneath isBundle::Skeleton— concepts folded to directories, links to weighted arcs, every edge tagged with the cut it survives (a local-degree sparsifier, union rule, so no linked concept is ever stranded) — and it does no I/O and draws nothing: it names a cut rather than taking one, so both the graph page and the CLI narrow the same emission their own way.- The graph page draws links in three amounts, and opens dense on its spine.
227 links over 47 concepts at degree 9.7 is unreadable because of its arrows,
not its dots — so links become a layer: every link, the spine (each
concept's strongest edge — the Skeleton's
keep_at===0set, chosen so it touches every linked concept), or none, with a selected concept's own links always shown in full. A dense bundle now opens on its spine rather than greeting the reader with the thicket; the trigger is undirected degree above a floor set between a tree's ~2 and the 9.7 that drove it, and--mapoverrides to none with the directories boxed. Above 800 edges the first layout runs on the spine alone and the rest arrive a frame later with no re-layout — 6.25 s → 3.29 s on 414 concepts, nothing on screen moving. Proven in both render modes on a committed 110-concept / 880-link fixture, the only one over both floors. - Registry groups — a named, recursive set of bundles. A group is a registry
slug that names a list of members (bundle or group slugs, so they nest) and
resolves recursively, path-deduped, to its bundle leaves.
okf registry group <slug> <@member…>creates one or adds to it,ungroupremoves members (and emptying a group deletes it), anddel/renamenow span a group slug too — one name cascades across every member list, onedelcascade-drops the slug and deletes any group it empties. Groups live in their own list in the registry JSON, so the first-is-default rule and theFile.directory?guards never meet a pathless entry.okf search @backendmerges the members into one ranking andokf server @backendmounts each (the first at/), both skipping a vanished member with a note, exactly as@alldoes. Every single-bundle verb refuses a@group(exit 2) — the same rule that refuses a second bundle, through the sameresolve_registeredseam. okf registry init— create a project-local.okf-registry.jsonin the current directory. Once it exists, okf discovers it by walking up from the working directory, and every registry operation — and every@slug— resolves through it in place of the global$OKF_HOMEregistry, so a bareokf serverinside a repo serves that repo's bundles with no global setup. The nearest one on the path wins (nested registries resolve nearest-first),okf registry listnames the local file it found, andOKF_NO_DISCOVERY=1forces the global one — the escape hatch for a fixed-cwd caller (CI, a tool).- A local registry stores portable, relative paths. A bundle inside the
registry's own tree is written relative to the
.okf-registry.json, so the file can be committed and travels with the repo — a checkout on another machine, or a container that mounts it, resolves the same bundles unchanged. A bundle outside the tree keeps an absolute path (it cannot travel). Paths still read back absolute everywhere the CLI reports them; the relative form lives only on disk, and an existing absolute local entry migrates to relative on its next write. The global$OKF_HOMEregistry is unchanged — it stores absolute paths as before.
Changed
- The derived
areafield is renamedtop_dir— the first-path-segment rollup the catalog, search,stats, andgraph --hubscarry.areawas never the OKF spec's word (the spec speaks only ofdir), so the rollup now names itself in the spec's vocabulary: it is thedirat the top level. The--jsonkeys move with it — catalog/search rows carrytop_dirin place ofarea,statsemitstop_dirs/by_top_dir, andgraph --hubsemitstop_dir/by_top_dir. The deprecated--area/--by areainput flags are unchanged — they still warn and map to--dir/--by dir, and now source the renamed field internally. No behavior changes; the well-homed-hub numbers are identical.
Fixed
- A local-registry server preserves its relative-path anchor across a
re-open. The hub re-read its boot registry with
OKF::Registry.new(path), which drops therelative_basea discovered.okf-registry.jsoncarries — so on a project-local server the Bundles panel matched each mounted bundle's absolute root against the re-read entry's relative path, missed, and drew every in-tree bundle as "folder is gone", while a browser add flattened the new bundle to an absolute path, undoing the portability relative storage exists for.Registry#reopenre-reads the same file anchored the same way, and both hub re-open sites use it.
1.11.0 - 2026-07-22
Added
okf dirs <dir|@slug> [--json]— the bundle's directories (its clusters) with the number of concepts living directly in each, root first and the total last. Every dir the tree has, including the empty intermediates that exist only to connect it — a dir holding nothing but sub-directories reads0, not a hidden rollup, so the column sums to the bundle's concept count. JSON:{ bundle, total, count, dirs: [{ dir, count, subdirs }] }.--depth Nonindexanddirs— how many directory levels below the starting point to keep, where the starting point is the--dirwhen one is given and the bundle root otherwise. Relative rather than absolute, so--dir a/b --depth 1reads "a/b and one level under it" without first working out how deepa/bis, and the two flags walk a tree a level at a time.--depth 0is the starting point alone; anything but a whole number is a usage error (exit 2). This is what makesindexusable at scale — every directory in it is a section, so a few hundred concepts is a map nobody reads whole. On one 414-concept bundleindex --no-bodywent 12.5 KB → 1.3 KB at--depth 1, andindex --json313 KB → 2.8 KB with--depth 1 --except body,listing.--dironindexanddirsbrings the chain up to the root with it, so a branch is never shown adrift of the authored context that says what it is — the rootindex.md's prose first among it. Those rows print with a leading↑, carryancestor: true, and stay out oftotal;--no-ancestorsdrops them. Ascent and descent are separate axes, so--depthnever bounds the chain:--dir X --depth 0is X alone, plus how you get to X. A--dirthat names nothing gains no chain, since a lone root row would read as a partial answer to a query that in fact matched nothing.dirsgains--dir(repeatable) and asubtreecount per row: the concepts at or below that directory, defined as exactly what--diron the row returns, so the number and the flag can never disagree. Without it a truncated listing is all zeroes at the top of a deep tree — which is where "where is the mass?" is actually asked. The human table shows the column only where some directory nests;--jsonalways carries it.--dir PATHjoins the shared filter set onsearch,catalog,files,typesandtags, andindexgains it as a repeatable selector. One rule: a concept matches when its dir is the path or sits below it — so--dir platformreachesplatform/services/api,--dir platform/servicesnarrows, and--dir .means the root alone with no special case.rootis the unquoted spelling of.; matching folds case.tags --by dircuts the tag index by the whole directory path, where--by areaonly ever saw the first segment.statsgainsdirsandby_dir(the full-path cut, direct counts), and its human breakdown now reads By dir. Both are read off the same mapokf dirslists and--diris answered against, so the two verbs cannot report a different number of directories — a directory holding nothing directly appears at0rather than being dropped, since it is still one--diraddresses.- Search rows carry
dir— the full path,.at the root — beside the first-segmentareathey already had. dirstakes--fields/--exceptlike the other list views, over the row shape it already declared.- A single-bundle
okf serveranswersGET /search?q=. The route was the hub's alone — conceived as the cross-bundle one — which leftokf server ./docswith a ⌘K palette that could find nothing, though one bundle is a legal one-element set.Appowns the payload and the hub calls it, so the shape is defined once; a row from a single-bundle server carries noslug, which is how it avoids answering as if it were a set. A staticokf renderstill advertises no endpoint: there is no server behind it to ask.- The route always answers; advertising it is the caller's call. The page
resolves the endpoint relative to the URL the reader is on, so only whoever
mounted the app knows what to call it:
okf servermounts at the root and passessearch, whileApp.new(folder)on its own advertises nothing, since a default would point an app mounted at/knowledgeback at its host's root.
- The route always answers; advertising it is the caller's call. The page
resolves the endpoint relative to the URL the reader is on, so only whoever
mounted the app knows what to call it:
- The search index is built once and held. Every request used to rebuild the
whole corpus — measured 1.45 s per search on a 414-concept bundle, flat across
repeats, with the build ~95% of it.
Search.prepareholds a corpus (documents, the key→concept map, the built index) andSearch.withqueries it: 0.016 – 0.052 s per search, with the 1.39 s build moved into boot, whereokf serverwarms it deliberately. An engine opts in by exposingprepare; the scan declares none and is handed none, so no engine or addon had to change. The trade is staleness — a corpus is a snapshot, like the graph — and the hub drops its corpus on any registry write, since a held index outliving the set it was built from is a wrong answer rather than a slow one. ⌥ dragmoves a cluster box, and is listed in the?sheet.
Changed
-
A bundle is named by its slug, everywhere it is chosen. The ⌘K switcher, the Bundles panel and the hub's
/b/page all led with the derivedparent/dirlabel and left the slug in muted grey beside it — the address in the name's place, when a bundle is addressed by@okf-gemand/b/okf-gem/. Rows now carry@slugas the name and the folder as the fact under it, shown only where it is not the name repeated./b/drops the short label outright: it only ever stood in for the full path, which is on the row already. -
A
.okfdirectory is labelled by the project that holds it.Bundle::Folder.labelreadsrepo/.okfasrepo. Theparent/dirpair exists because a bundle directory's own name is rarely unique — except when it is.okf, the conventional container, and then a registry of eight projects was eight rows all saying.okf. This also reachesokf registry listand the defaultokf servertitle. (A.okfwith no parent to borrow keeps its own name; that case used to compose into//.okf.) -
A force layout settles, then moves once.
animate:truereads to these engines as "render every tick of the simulation" — the visible bounce, and hundreds of full re-renders for one settle. It is'end'now: the same simulation run headless, the nodes moved once into the same final positions. Past 250 nodes even that transition is dropped, because at that size the move itself is the jank. -
A cluster box is scenery, not a handle. Its empty interior is the largest drag target on the canvas, so dragging to look around dragged the directory instead of the view — worse the bigger the cluster. It takes
grabbable:falseandpannable:true: ungrabbable alone stops the box moving, but the node still swallows the drag, and it ispannablethat hands the gesture to the canvas the way empty background does.Alt+drag gives the handle back, since moving a box is a real gesture, just not the constant one —Altrather thanCtrl, which on macOS is the system secondary click. A tap still opens the directory's map. -
fis no longer a shortcut. A bare letter bound globally fires on every keystroke the page did not route into an input, and fullscreen is not a mode to enter by accident. The button stays and is now the only way in; the shortcut sheet no longer advertises a key nothing is bound to. -
The skill names one first move. It had prescribed three different ones across seven places — SKILL.md, four playbooks and the CLI reference — and that disagreement is the deliberation an agent pays for on every retrieval. Every site now says
okf dirsfirst, thenokf index --dir <branch>to descend, chosen structurally:dirsemits one row per directory whereindexemits one listing row per concept even under--no-body, so the two scale with different things. -
Cluster mode nests. The graph page grouped concepts into one flat row of boxes, one per first path segment — the same lossy projection
--areawas. A cluster is a directory now, and the boxes nest as the directories do, to a depth picked from a select beside the layout one. Depth 1 is the default and draws exactly the old view; a flat bundle is offered no control at all. At depth N every directory of depth ≤ N gets a box (intermediates that hold no concepts of their own included, since they hold sub-boxes), and a concept attaches to its own directory's box truncated to N. The root box still holds direct-root concepts and never nests another. Box ids carry the directory verbatim (box::platform/services,box::.), so a tap opens that directory's map with no label to unmangle. -
The page speaks
dirtoo: the filter group is Dirs, listing every directory (not just first segments) and filtering by the same directory-and-below rule--diruses — in the graph, catalog and tags views — and the Stats panel's breakdown is By dir, keyed by the whole path.
Fixed
- The rail marks Index while the root map is open. Index is a shortcut into
Files, so the two share one
data-view— and the rail read only that, lighting Files on the one screen a reader reached by clicking Index. The open file is what distinguishes them, so it is what the rail reads; a nestedindex.mdis still Files. --diraccepts the label the views print.fold_dirnever stripped a trailing slash, whileokf indexlabels a rowtables/— so pasting a printed row back into the flag matched nothing and exited 0, an empty result under a count that agreed with it.- The
--dirchain keeps its case. It was walked over case-folded paths and then matched against the map withinclude?, which does not fold, so every ancestor of a directory spelled with a capital vanished from the chain that exists to place the branch. --areawith--depthor--diris refused (exit 2) instead of unioning the area with what the other flag selects. The deprecated flag is exact: with--depthit names no starting point to be relative to, and with--dirone side is exact where the other is a prefix, so the map came back with the area and the subtree — an answer to neither question. A deprecated flag that quietly widens is worse than one that is merely old.- A cleared filter no longer leaves a cluster unlaid. The tiling runs over
the visible elements only (fcose throws on a node whose label went
display:nonemid-run), but nothing re-tiled when a filter was later loosened — so concepts hidden when clustering began came back at their pre-cluster coordinates and stretched their box across the canvas. Worst case the filter matched nothing, the layout returned early, and clearing it showed a view nothing had laid out at all. - A palette hit in a single-bundle server no longer 404s or reloads the page.
A row with no
slugwas read as naming a foreign bundle, so the href became../undefined/, the row rendered an "undefined" chip, and the click took the page-load branch — reloading the whole index to reach a node already on screen. Three sites, one absent field. - A focused form field no longer zooms the page on iOS. Safari zooms whenever
a focused control is under 16px and never zooms back out, so on a phone every
/left the reader pinching to recover. Keyed on(max-width:768px)or(pointer:coarse), because neither covers the other — a phone is narrow, a tablet in landscape is not and zooms just the same. - A nested cluster no longer throws when a filter empties it mid-layout. fcose measures every node it is handed, so hiding nodes while its tiling animation ran threw on a label it could no longer measure. The layout is handed the visible elements only — which is also the right answer, since a hidden concept has no business influencing where the visible ones land.
- An intermediate directory box no longer takes its branch off the canvas. The empty-box rule read a compound's direct children, and a box holding only sub-boxes has none, so it always counted as empty. It reads leaf descendants now.
Removed
OKF::Server::Hub::SEARCH_LIMITandHub::SEARCH_ENGINE. Both moved toOKF::Server::App, which now defines the/searchpayload both hosts answer with (App.search_payload). The hub's copies were left behind unreferenced — two constants for one cap is two places to raise it and one of them silently losing. UseOKF::Server::App::SEARCH_LIMIT/App::SEARCH_ENGINE.
Deprecated
--area, andtags --by area. OKF's own vocabulary for grouping is directories (grep -ci area SPEC.md→ 0); "area" was this gem's invention, and defining it as a concept id's first path segment threw away every level below it.diris now the only machine word — full path,.at the root, rendered(root)for humans — and "cluster" stays prose for what a dir groups. Both deprecated spellings keep their old behavior exactly and warn once per run on stderr (--jsonon stdout is unaffected); they go in a later release.
1.10.0 - 2026-07-21
Added
-
The skill gains a
refineverb (playbooks/refine.md): restructure a bundle to get the most from OKF's capabilities — progressive disclosure, the emergent graph, cross-cutting tags, capture-once-link-many. It is the third authoring boundary:curatekeeps the structure sound as it stands,maintainkeeps the content true,refinechanges where knowledge lives — evidence-first (tag locality, the hub origin test, a fatness alarm), cohesion-over-balance, free levers before file moves, and it proposes (a report plus a frozen execution prompt), never auto-applies. -
okf graph --hubs— the inbound ranking: every concept with at least one inbound link, ranked by inbound degree, each with its links grouped by source area (core/status ×3 flows 2, billing 1). This is the refine playbook's hub origin test made mechanical: a hub whose inbound majority is foreign to its own area is a move candidate. JSON:{ bundle, count, hubs: [{ id, area, inbound, by_area }] }. -
The registry has a browser surface. A meeting with non-technical readers settled what the TUI could not:
okf registry set/del/default/renameis the right surface for the people who write bundles and the wrong one for the people who read them. The graph page's rail grows a Bundles panel behind ⚙ — every bundle the server knows about, with its title,@slug, folder, concept count and a health verdict — and four routes behind it:POST /registry/{default,rename,remove,add}, the only non-GET routes the server has.- Management is the default, and
--read-onlydeclines it. The flag names the restriction rather than the capability, because the audience this was built for should not need a command line to use the page they were pointed at. A loopback bind is writable without a flag; any other address is refused outright, with no flag that opens it —--bind 0.0.0.0is how a personal tool becomes a public one, and a write surface does not follow it there. - Four gates on every write: is this server writable at all; is there a
registry to write to (an ephemeral
okf server ./a ./banswers409rather than leaving the missing controls a mystery); is the verb one of the four (a frozen list — "call whatever method the path names" is how a router becomes aneval); and did this come from this page (same-origin and a per-boot token, since the token lives in a page another site can get a reader to submit, and Origin alone would trust every tab open on the host). A read-only server hides the controls and refuses the request that skipped them: hiding a button is a UI, refusing the request is the boundary. - A write rebuilds the hub's bundles from disk before it answers. That is the step easy to skip and impossible to skip safely — a write leaving the running server on the old set is a lie the next click believes.
/b/stops managing and keeps the page. Both surfaces carried the same four verbs for a while, and two implementations of one contract is the thing that drifts, so the forms came out and the routes stayed./b/answers which bundles are there — and remains the empty state a hub with zero bundles still needs — while the panel answers change this one where the reader already is. With nothing to post it holds no token either.- There is no Add on either surface. A browser cannot hand over a filesystem path — the File System Access API yields an opaque handle, and is Chromium-only besides — so registering stays the agent's act. The route exists for other callers; nothing in the UI reaches it.
- "Workspace" is retired from the docs and the UI. The things are Bundles and the thing holding them is the registry; a page saying one word while the CLI says another is two products wearing one name.
- Management is the default, and
-
The hub searches every bundle it hosts.
GET /search?q=is the only route in the server that knows about more than one bundle:Search.acrossover one shared index, so BM25 weighs a term against the whole corpus instead of stapling per-bundle lists together. Capped at 50 with the total reported, because a silent cap reads as a complete answer. The engine is named:indexoutright rather than left to route offfuzzy: true— that reached the right engine only because nothing else declares the capability, which is correctness by coincidence, and an addon declaring:fuzzywould have taken the route silently. A long-lived server also amortizes an index build over every keystroke where a one-shot CLI cannot, and minifts is a port of the browser's own MiniSearch, so a palette hit and an in-page search rank alike.- The palette's Concepts group comes last, and not because it matters least: it is the only group that arrives asynchronously, and a group landing above the cursor moves the row under the reader's fingers between the keystroke and the Enter.
-
The topbar search box says what it filters, and where to go when it finds nothing. It and ⌘K looked alike and meant different things — the box filters what is on screen, the palette finds across every bundle a hub hosts — and the box carried neither fact: it emptied the graph in silence and never mentioned the palette, so a reader whose word lived in another bundle got a blank canvas and no way out. Three additions, all inside the box: a chip naming the chord (⌘K / Ctrl-K, OS-aware) that opens the palette, a live count (
7/8) that makes an empty result a number which reached zero rather than a view that went blank, and on zero a panel naming the bundle and the query — ⏎ hands it to the palette prefilled and already searching, esc clears. -
okfis extensible. Any gem that putsokf/plugin.rbon its load path can register a verb, and it answers tookf— listed inokf helpunderinstalled extensions:, dispatched like a built-in. There is no list of known addons in this gem and no configuration step for the user: installing the gem is the whole installation. It is the same seamSearch.registeropened for search engines, and the same idiom — append-only, idempotent by id, so an addon can never quietly displace a built-in.- Discovery is lazy, which is what makes it affordable. A built-in verb
resolves against the registry and dispatches without scanning at all; only an
unknown verb or
okf help— which has to know everything by definition — pays the ~11msGem.find_latest_filescosts on the 2.4 floor. A one-shot CLI that will not build a search index for a single query should not pay for discovery to answer a verb it shipped with. - Extensions must come from gems named
okf-*, the convention Jekyll and Vagrant use for the same job: it makes what counts as an okf extension explicit and stops an unrelated gem claiming theokf/plugin.rbpath by accident. One that is not so named is discovered, skipped, and reported on stderr. A path belonging to no gem — a checkout,ruby -I, a Gemfilepath:— stays trusted, because someone put it there deliberately. It is a mild guard too — loading a plugin runs its code — but the naming convention is the reason, not the threat model, which is thin: under Bundler discovery is bundle-scoped anyway, so the Gemfile is already an allowlist. - The rule holds when it cannot get an answer, which is a separate promise from the rule itself. A gem name that cannot be read — one corrupt gemspec anywhere on the machine — is refused rather than treated as "belongs to no gem", and the refusal names the exception that caused it. A discovery that fails outright is reported too, since an empty list and no message is indistinguishable from a machine with nothing installed.
- A broken addon is skipped and reported, never fatal — the same
best-effort posture the reader takes with an unparseable file. The note goes
to stderr, so a
--jsonrun's stdout stays a clean machine substrate.
- Discovery is lazy, which is what makes it affordable. A built-in verb
resolves against the registry and dispatches without scanning at all; only an
unknown verb or
-
The graph page is proven in a real browser.
test/browser/drives the pageokf serverandokf rendershare in Chromium, asserting DOM state and computed CSS at real viewport widths, and failing any test where the page threw. Every spec runs twice — once served, once against afile://static render — because the two modes diverge on fetched endpoints vs. a bakedEMBED, and a pass in one proves nothing about the other. It is opt-in (rake test:browser, outside the default task) and non-blocking in CI, since the page boots against a CDN and a slow jsdelivr must not gate a merge. The three fixes below are what writing it turned up: shipped defects invisible to a string assertion over the rendered HTML, each reproduced red and pinned green.- Coverage is mapped per-contract, not guessed.
test/browser/COVERAGE.mdenumerates every behavioral contract the page introduced across its history and marks each covered / partial / uncovered against a named spec — 176 of 181 net-live (97%). The five that remain are each a documented blocker, not a missing test: an absence-proof with no line to break, a node-overlap check no cytoscape layout makes both deterministic and mutation-sensitive, a map-visibility observable another contract already owns, a palette scroll whose observable is a tautology, and an unbuilt focus-trap. Reaching the branches the flat 8-concept fixture cannot took four further purpose-built bundles beside the hostile one, each served on its own port and baked to its own static page so the main fixture's count assertions stay put — nested directories, forty-five tags, a five-directory-deep reserved path, and a hundred-node ring that drives the graph past its own fit box. Every new spec is mutation-checked: break the code it covers, confirm it goes red for the predicted reason, restore. The map also caught one of its own stale rows — a note listed as an uncovered gap had in fact been deleted from the page, and is now marked superseded rather than owed. - The page's CDN libraries are served from a local cache — a read-through
cache keyed on the request URL, so a warm run needs no network and a version
bump is a miss rather than a stale hit;
OKF_NO_VENDOR_CACHE=1bypasses it, which is how you check the pins still resolve. It buys robustness, not speed: measured at one worker, 28.7s without and 29.0s with, because the suite is CPU-bound and Chromium already reused those files across contexts.
- Coverage is mapped per-contract, not guessed.
Changed
-
okf tags --byrows carry each tag's total. The grouped view printed only within-group counts, so a tag's spread meant cross-referencing groups by hand; each row now showscount/totalwhen they differ (async 2/3) and the plain count when the tag is wholly local — locality at a glance, the domain-vs-concern read. The JSON rows gain atotalkey; filters recompute it over the narrowed set. -
A wrong turn at the hub lands on a directory, not an apology. The 404 is rebuilt on the app shell, and it reads as what it is: the asked path is the heading, set in mono where a dropped slash is legible as a shape, with "not found" demoted to the eyebrow above it, since a reader arrives already knowing they are lost. A near-miss slug is a row wearing the same anatomy as the list under it, already marked, with ⏎ pointed at it; rows carry the folder that actually distinguishes
site/.okffromminifts/.okf; and colour marks exceptions only, so a healthy row draws no verdict edge at all. Moving through the list is Tab's job — every row is an<a href>, and a hand-rolled ↑↓ cursor was tried and deleted as a second focus model beside the real one. A query matching no bundle is offered the cross-bundle search that would match it, the same escalation the graph page's box makes. -
On a touch screen a tap opens a card, not the whole viewport. At ≤768px the inspector is
grid-template-columns:0 1fr, so tapping a dot measured the stage at 0px wide: the graph was not covered, it was gone. Exploring on a phone became open → read → close → tap the next dot, and you could never see a concept and its neighbourhood at once, which is the one thing a graph is for. A preview card now rises at the bottom edge over a graph that keeps every pixel and stays live — drag it up for the neighbourhood and the body, tap a row and it swaps in place while the camera walks. Folder and index taps fill the card too; they used to write into an invisible panel, so tree and cluster modes were silently dead on touch. The branch is wider than the chrome's (≤768px, or ≤1024px portrait), because a portrait tablet has the same bug and wants the same gesture. -
Type chips select instead of deselecting. Three chip groups carried two grammars: areas and tags were additive — nothing selected means everything, a click narrows, a second click undoes — while types were subtractive, every type showing until you clicked one away. Same component, same panel, opposite meaning, and the catalog's and tags view's own type chips were already additive, so the rule a reader learned in one view was wrong in the next. Types now select, and two of them compound into a union the old model could not express at all — it could say "not the other four", never "Services and Charters". The change is a net deletion.
-
The CLI is one file per verb.
lib/okf/cli.rbwas 1,794 lines and a 15-armcase; it is now a registry and a dispatcher, with the verbs underlib/okf/cli/and the shared surface on aCommandbase class. Behaviour is unchanged — every existing test passes untouched — butokf helpis now composed from what the commands say about themselves rather than from a heredoc that had to be remembered separately.
Fixed
-
Selecting a node in cluster mode faded the entire graph rather than emphasising the selection. Dimming set opacity on the unrelated elements, but in cluster mode those include the compound area boxes — and a parent's opacity cascades to the nodes inside it, so dimming the boxes faded the very leaves being highlighted. The highlight was real in each node's own opacity and invisible on screen: measured parent-inclusive, the selection sat at 0.1 against an unrelated node's 0.01.
focusNodenow dims the leaves and edges and never the:parentboxes, which is a no-op outside cluster mode where there are no parents. After: selection and neighbours at 1, the rest at 0.1. -
A log's "Open in graph" button stayed visible, carrying a stale
onclickfrom the last map or concept — the "answers about a different file" symptom, returning through CSS. The code hides it correctly (#fp-graph.hidden = true), but.btn.text{display:inline-flex}outranks.btn[hidden]{display:none}at equal specificity (0,2,0), so the later rule won and the button rendered 143px wide with the attribute present. A.btn.text[hidden]rule settles it, the same fix the sibling.fp-head[hidden]already carried. -
Leaving the graph for another view and returning redrew it at a fraction of its size, and stayed that way. The cause was misdiagnosed as a resize race for months; tracing
cy.animate's caller showed the one animation running was a fit.fitGraphcomputes zoom from the container's own width, and the one-shot boot fit (setTimeout(fitGraph, 400)) fires on whatever view is up by then — so leaving the graph inside that window fits a hidden 0×0 canvas,(w-2*pad)/bb.wgoes negative, and the zoom clamps tominZoom.fitGraphnow returns early on a zero-size canvas and the graph keeps its last good zoom. The hazard was already known at the other end: the boot fit is not registered at all for a?view=/?select=/#hashdeep link, whose comment names this same min-zoom clamp — it was the navigate-away case that went uncovered. -
okf help's map advertisedsearch … [-e|--fuzzy], pairing a shorthand it never expanded with an unrelated flag, so the one hint that an engine is selectable read as though-emight be the engine switch. The row now says[--regexp|--fuzzy], which is what the command's own banner says;-estill works andsearch --helpstill spells it out. -
okf skill <a> <b>installed into<a>and exited 0. It hand-rolled its own argument handling instead of using the shared pair every<dir>verb goes through, so a second destination was silently dropped — the user named two places and the tool wrote one, saying nothing. It is a usage error now (exit 2), refused before anything is written.
1.9.0 - 2026-07-19
Added
-
okf searchgains an opt-in full-text index engine.--engine index— and--fuzzy, which implies it — routes to minifts, the pure-Ruby port of the same MiniSearch build the graph page loads. It is the gem's third runtime dependency, admitted because it costs the footprint nothing the first two were chosen to protect: no native extension, no dependency tree of its own, the same Ruby 2.4 floor. Three things it adds, and nothing else does:- BM25+ relevance ranking, where the default scores by summed field weight;
--fuzzy— typo tolerance at edit distance0.2 × term length, the browser's own setting. Search stays exact unless you ask;- parity with the graph page, which runs the same MiniSearch build, so the two rank identically when the index is named.
-
--engine NAMEpicks the engine outright, for the case a capability flag cannot express: a matching model requires nothing, so no flag selects one. Naming an engine that cannot do what was also asked is a usage error naming one that can (--engine index -e→ try --engine scan), and an unknown name lists what is available.--helpreads the registry, so an addon's engine appears without the CLI knowing it exists. -
The graph can draw the index layer, under any layout. The §6 map was visible only inside file-tree mode, where a folder node stood in for a directory's
index.md. Show indexes makes it a layer: each map is a tile edged to the concepts it lists and the maps below it, dressed by the same selector as file-tree mode's folder node, because the two are the same thing twice over — clicking either opens that directory'sindex.md. Both are accent squares with dashed edges into them, so colour separates kinds rather than modes: a directory is not a concept and no longer reads as one. Authorship shows as form — solid where an author wrote a map, hollow and dashed where the bundle only implies one — so the toggle reads as curation as much as navigation.- Moving between the modes lands in one click. Tearing the layer down ran
its own layout while file-tree mode ran
breadthfirsta beat later, two layouts racing the same canvas; and because the layer is fetched, a promise resolving after a mode change could land inside file-tree mode. Arelayoutflag settles the first, a per-toggle ticket the second. - File-tree mode disables the toggle rather than doubling the folders it already draws.
- One label on every file's graph button. It read "Explore the knowledge graph" on the root index and "Open core/ in graph" on a nested one, which made a single action look like three. The question is the same whatever is open, so the label is too — and it lives in the markup, where it cannot go stale.
- Opening a map from the reader keeps the reader's graph. It forced file-tree mode, discarding whatever layout was running, and dimmed the canvas to the map's immediate neighbours. It now switches the layer on rather than the mode and leaves the layout alone. A reader already in file-tree mode stays there.
- Selecting anything emphasises it the same way. A concept dimmed the graph
to its neighbourhood, a map did nothing at all, and a folder node did nothing
either — three meanings for one gesture. One
focusNodenow serves all three. - Drawn, never modelled.
index.mdis reserved, so these nodes are built from/indexstraight onto the canvas;NODES,/catalogand the type and tag indexes never learn they exist. Filters pass them over — a map has no type or tags — but a map whose concepts are all filtered away leaves with them.
- Moving between the modes lands in one click. Tearing the layer down ran
its own layout while file-tree mode ran
-
A first-visit note tells a newcomer the index exists. The
index.mdan author wrote to be read first was reachable only by finding the Indexes tab and clicking a row, so a reader meeting a bundle for the first time met unlabelled dots with no way in. The page still opens on the graph — it is what makes a bundle legible at a glance, at every width — and a dismissible note at the bottom now says what the picture is, how to touch it, and where the index is. Read the index goes straight there; the dismissal is remembered.- It absorbed the old mobile-only tip rather than stacking a second banner under it, and it is written for a finger throughout, since a phone is where a first-time reader is least oriented.
- The wording follows the device on two gates, not one. What a reader does
follows
(pointer:coarse)— a touch tablet in landscape is wider than 768px and still taps; a narrow desktop window is narrower and still clicks. What a reader can reach follows(max-width:768px), because that is when the rail collapses behind☰. Short viewports tighten; short and wide puts the question beside the button, taking a landscape phone from half the screen to under a third. - A second note points at
☰on compact layouts only, anchored under the button it names rather than at the bottom of the screen. It fires on leaving the graph by any route, so dismissing the first note does not cost it, and opening☰answers it — but only once it is on screen, since☰is the only way off the graph there and the first tap always comes first. - Deep links are unaffected, and
?select=/#hashnow switch to the graph before selecting, since the page can be standing elsewhere when they are read.
-
The graph page's search box grows a full-text index. One MiniSearch index — lazy-loaded from the CDN on first search, pinned to the
7.2.0the Ruby MiniSearch port tracks so anokf search --engine indexresult and the browser's rank identically — now backs the graph, catalog and files views: ranked, multi-term (AND), prefix (as-you-type) and typo-tolerant, over title, id, type, tags and description — plus each concept's body wherever the page already holds it (okf renderbakes every body in, so a static file searches bodies offline; the live server keeps bodies lazy, so its index stays metadata-only until a backend body index arrives). The graph could not be searched by a leaf's description before; now it can. The Files view's Indexes tab gets its own full-text index too, over eachindex.md/log.md's body — not just its filename. Until an index loads — or if the CDN is unreachable — each view falls back to its own substring filter, so the box is never dead. -
Escclears the graph selection. A dense graph leaves almost no empty canvas to click for deselecting;Escnow drops the highlight (and lets the URL hash forget the node) the same way tapping empty canvas does.
Changed
-
The default search is unchanged — literal, case-insensitive substring matching over the same fields with the same weights as 1.8.0. The index is opt-in rather than default because a one-shot CLI builds an index, asks one question, and exits: end to end, 3.00 s against 0.24 s at 1,000 concepts, the build accounting for ~95% of that. The ~44–56× per-query throughput that recommends minifts is the right measure for a long-lived index — a page, a server — and the wrong one for a process that exits. A cached prebuilt index is what would change that arithmetic.
- Know what the index costs before naming it. Its tokenizer splits on
punctuation, so
customer_idbecomescustomer+idand7.2.0becomes7,2,0; an infix (ustomer) finds nothing; and a backtick is UnicodeSkrather than punctuation, so a word inside a code span indexes as`minifts`and the queryminiftsdoes not match it — 409 such tokens on this repo's own bundle. Ranking does not rescue it: BM25 normalizes by field length, so a short concept dense in7,2and0can outrank the one that actually says7.2.0. The default has none of these, because raw-text matching has no tokenizer.
- Know what the index costs before naming it. Its tokenizer splits on
punctuation, so
-
Search engines are adapters.
OKF::Bundle::Searchbecame a facade over N engines instead of one class with aregexp ? scan : indexbranch. The facade keeps everything that defines a result — documents, the row and its key order, the snippet, the sort — and an engine answers only which documents match, how well, and where. The built-ins areSearch::Scan(raw text, the default,regexp) andSearch::Index(minifts,fuzzy/prefix).- Selection is by capability when the query requires one —
--fuzzyrequires:fuzzy, so it routes to the index without naming it — and that routing prints nothing: no note, no header change, no new JSON key. Search.registeris a published extension point — append-only, idempotent by id, capabilities checked against a fixed vocabulary. This is the seam a future SQLite/FTS5 addon plugs into; no addon code ships here.- A shared conformance suite replaces the "kernel is the oracle" rule, which multiple engines made impossible: the index and the scan disagree about match sets by design, so neither can be the oracle. Every registered engine runs the same contract, with capability-gated blocks for its own semantics, and a registered engine with no conformance class fails the suite.
- Selection is by capability when the query requires one —
-
Cross-bundle search ranks one corpus under
--engine index. BM25 prices a term by how rare it is, so ranking each bundle separately and interleaving the lists would produce a ranking that looks sorted and compares nothing; the searched bundles are indexed together instead. The visible consequence, under that engine only: a score is relative to the whole answer, so the same concept scores lower searched beside other bundles than alone. The default's scores are absolute and need no such treatment. -
Collapsing the root folds the file list away on phones and tablets, where the list is stacked on top of the reader and closing the root otherwise left a single row above a column of nothing. Reopening the list undoes that collapse, so it is one gesture rather than two states to dig out of — the fold remembers why it happened, and a list folded because a file was opened comes back exactly as it was left.
-
The bundle names its own root.
(root)and/are what a filesystem calls it, not what a reader does. The tree's root row, file-tree mode's root node, the index layer's root map and the inspector's directory map now all carry the name the page header already shows,--titleincluded.areaOfkeeps its own(root): that is the area vocabularyokf stats --by areaandtags --by areaprint, not a UI label. -
The Indexes tab dissolves into the file tree. The authored layer lived on a second tab as a flat list of paths, which put a directory's own map somewhere other than the directory.
index.mdandlog.mdare rows now, at the top of the folder they document, and Indexes only is a toggle over the same tree — same rows, fewer of them, structure intact. The toggle yields only when it would hide what was just opened — a map stays under it, a concept releases it — so browsing the authored layer no longer destroys the list being browsed. A log offers no graph button at all: it is a chronology, not a place in the graph, and the button had been opening the root index's node. Narrowed, a folder owns exactly one row, so the row stands where the folder header stood — at that folder's depth, carrying the path — rather than nesting a single child under a header.- The rail's Index becomes an action, not a fake view. It had no
#view-indexbehind it — the files view showing its other tab — soactiveRail()answered a question of view and tab. The shortcut stays, opening the root map through the samereadIndex()the first-visit note uses;activeRail()answers with the view it lands on, so Files highlights and nothing invents a place for Index to be.?view=indexresolves to the same action. - Fixed on the way: the reader header rendered empty — an unlabelled badge
and a graph button pointing nowhere — whenever no file was open, because
.fp-head{display:flex}outranks the UA sheet's[hidden]{display:none}.
- The rail's Index becomes an action, not a fake view. It had no
-
The file tree nests. Directories were a sorted list of full paths, which made
coreandcore/configurationsread as two unrelated folders and left the shape of a bundle invisible. Each row is now one path segment indented by depth, folders before files, and collapsing a folder takes its subtree with it. A directory holding nothing but directories still renders, so the chain to its children never breaks.- "Collapse all" folds into the root, not over it — everything inside the
root closes and the root stays open, so the click leaves the top-level
folders standing instead of a single
(root)row. Unfolding clears the whole set, root included, so a root closed by hand is still reversible from there.
- "Collapse all" folds into the root, not over it — everything inside the
root closes and the root stays open, so the click leaves the top-level
folders standing instead of a single
-
okf renderstops baking a redundant description map. The static page derived its/node/metafragments from a separatemetapayload that held nothing but each concept's description, HTML-escaped — data the embeddedcatalogalready carries raw. The page now escapes the catalog's description on the client (the same escape the server applies at/node/meta), so themetakey leaves the baked payload and the description lives in one place. Both XSS guards are unchanged;okf serveris untouched. -
The bare not-a-directory error now teaches the registry grammar. A verb given a target that is neither a directory nor an
@refmoved fromerror: <arg> is not a directoryto… is not a directory or a registry ref (@slug names a registered bundle, @ the default; okf registry list), so a consumer who typed a query or a bad path meets@slugaddressing at the error instead of hunting for it. (@allstays out of the message — it issearch's alone, and the error seam is shared by every verb.) -
The bundled skill teaches
@slugas a first-class target and stops probing for the CLI.SKILL.md's "Which directory?" is now "Which target?" — a leading@is a registry ref routed straight tookf <verb> @slug, with the fallback "no bundle in the cwd →okf registry list" — and the consume/search playbooks name@slugin their orientation steps. The per-runcommand -v okfpresence probe is gone: run the verb, and treat a shellcommand not foundas the only signal to install, so the common case pays no guard round.
Fixed
-
The Files tree's folder collapse works during a search. An active search or type/tag filter used to force every folder open, so fold clicks did nothing; folders now honor their collapsed state always (a collapsed group still shows its header, so a match is never hidden). A fold/unfold-all control in the Files tab header collapses or expands every visible group at once.
-
Clustering no longer leaves phantom empty boxes. When a filter or a search hid every concept in an area, the cluster's labelled box lingered as an empty rectangle; the box now hides when no child survives and returns when one does — the same rule the fit already used to leave stale boxes out of view, now applied to what is drawn.
-
A title-less concept now wears one name in every view.
catalogand the §6 index listing fell back a concept with notitleto its full id —area/thing— while the graph node fell back blank-aware to the basename —thing— so the same concept answered to two labels across two views of one bundle, and atitle: ""slipped past the nil-only||to catalog as an empty string. Both now fall back the graph's way (File.basename, blank-aware), so the label is the same wherever the concept appears.
1.8.0 - 2026-07-17
Added
-
A persistent bundle registry and a multi-bundle hub.
okf registry(list / set / del / default / rename) keeps a per-user list in a plain JSON file at$OKF_HOME/registry.json(default~/.okf), andokf serverreads its mode from its arguments: one dir is the classic single bundle at/, several mount ephemerally behind a hub at/b/<slug>/, none serves the whole registry with its default at/. Behind a hub the page gains a bundle switcher (⌘/Ctrl-K, or the rail button),/b/is a browsable index, and an unknown slug 404s as a page with a way home. The hub reads its bundles at boot — restart after registry changes. -
The registry is ordered, and the first entry still on disk is the default — the bundle a bare
okf serveropens at/.okf registry default @slugmoves that entry to the front, andokf registry set --defaultregisters straight to it; until you do either, the first bundle you registered is the default. Nothing else has to be maintained: a rename keeps its position, adelpromotes whatever is next, and the file cannot name a default that is not there. A vanished directory is stepped over rather than starred —registry list's*always names the bundle/opens — andregistry default @slugrefuses one, just asregistry setrefuses to register a directory that is not there. -
$OKF_HOMEis the single lever on which registry a command reads: set it and every verb follows, fromokf registry listto an@slugonokf lint. It names exactly one registry, with no fallback to~/.okfbehind it, and an empty value counts as unset rather than plantingregistry.jsonin the current directory. -
@slug: wherever a command takes a<dir>,@slugnames a registered bundle and bare@the registry default —okf lint @handbook,okf render @ -o graph.html. A slug is normalized like registration was (@Onefinds the bundle from dirOne) but never to a placeholder, so@***is a bad ref rather than a silent hit. An unknown slug, a registered-but-gone directory, or a malformed registry file is a usage error naming the registry file and the next move. A hub built from@slugs (okf server @a @b) mounts each bundle under its registered slug, the first at/, and a registered slug reserves its mount ahead of any plain directory that shares the name. -
okf searchspans bundles: several leading@slugs, or@allfor every registered one. Rankings merge across bundles with every row labeled by its bundle's slug (abundleslist and a per-matchslugkey in the JSON). Asking for everything tolerates gaps —@allskips a bundle whose directory has vanished, with a note — while naming one insists on it, and@all @docssimply dedupes.allis reserved in the registry, on all three ways in: a directory namedall/registers asall-2,--as allis refused, and a row already claiming the name in the registry file — hand-typed, or written before the name was reserved — is read asall-2, so the reservation never strands a registry it inherited. An ephemeralokf server ./allstill mounts at/b/all/— no registry, no refs, nothing to reserve. -
The inspector's type and tags are filter handles: clicking one focuses the graph on that facet — the same jump the stats bars make — and clicking it again clears it. The chip lights while its facet is the only filter in play, which is exactly when a second click is an undo, so what you see and what the next click does are the same question. With another filter set it re-focuses instead, rather than throwing away more than the click put there.
-
The graph page answers
?with a sheet of every keyboard shortcut, reachable from a rail button too — a shortcut list you can only open with a shortcut helps whoever needs it least./focuses the current view's search where it has one, skipping the view that only reads; the sheet is written against the key handler it documents, since a shortcut list that has drifted is worse than none.
Changed
-
@slugis spelled where it is used, not just where it is explained, and it is the one token —okf help's map (lint <dir|@slug>) and each command's own banner show it the same way, with a note under the map defining it: the slug fromokf registry set, or bare@for the default. It was documented once, in prose at the foot ofokf help, past where a reader who already knows the verb ever looks, so seventeen surfaces took a registered bundle while advertising a bare<dir>. The registry-editing verbs —del,default,rename— take the slug bare (no@) or as an@slug; the read verbs need the@, since a bare word there is a path. -
okf <command> -hprints that command's own banner and flags. Help now answers on stdout with an exit code like every other command: it was OptionParser's officious handler, which printed past the caller's injected streams and ended the process withexitinstead of returning a status. -
The registry validates its file's shape, not just its JSON syntax: a hand-edited entry missing
pathis a usage error naming the file instead of aTypeError, andokf registry --json set <dir>— a subcommand behind a flag — is a usage error rather than silently listing and exiting 0. -
The graph page's ⌘/Ctrl-K palette opens in every mode and reaches a view as well as a bundle. It was wired only behind a hub, so a standalone
okf server ./docsand everyokf renderpage — the two modes most people meet first — had no palette at all. Bundles still lead it and own the empty box, because switching bundles is what it is for; views wait until what you type reaches one, arrive underneath, and stay muted until the cursor does. Each view carries the rail's own icon and label, read from the rail so the two cannot drift. Where there is no hub there is no bundle to switch to, and views become the whole list. -
The inspector's Links to / Linked from rows read as concepts rather than a wall of accent-coloured text. Each carries its type's dot — the colour that node already wears in the graph beside it — with the type named and the section counted, so the column answers what kind of neighbourhood a concept has before a title is read. The rows share one panel with hairline dividers and a hover fill: the container carries the click affordance, which leaves colour free to mean type and nothing else.
-
The inspector's widen chevron splits the screen instead of taking 70% of it. The panel drag-resizes, so the chevron is a preset rather than a maximum, and burying the graph to read one concept was the wrong thing to default to.
Fixed
-
A file the reader could not open (permissions) threw its errno out of the read, so a single locked file took the whole bundle down through every verb that reads one —
lint,validate,catalog,server,registry set— as a backtrace, under an exit code claiming the bundle was non-conformant. §9's best-effort promise covers it now, the same as frontmatter that will not parse: the file is skipped, noted on stderr, and reported byvalidateunder §9.1 naming the file and the errno. One bad file never breaks the rest. The stderr note readsskipped N unusable file(s)— it counts two kinds now, so it names neither and points atvalidate, which names both. -
okf registry del <path>could delete the wrong bundle. A path that matched no registered directory fell through to a normalized slug lookup, sodel ./notes— naming a local directory — removed whichever entry happened to be sluggednotes, wherever it pointed, and reportedremoved noteswith exit 0. An argument with a/in it now names a location and only a location. -
The registry read trusted stored slugs verbatim while both write paths normalized, so a hand-typed
"slug": "My Docs"listed fine but could not be named by@my-docs,registry rename, orregistry default— the verbs that could repair it were the ones that could not see it. The read normalizes now, leaving an already-usable slug untouched. This also removes the only way a quote could reach a slug, and with it a DOM XSS in the server's bundle switcher, whose JS escape covered& < >but not quotes; the escape now covers quotes too, so the page does not depend on a guarantee three layers away. -
okf lintbucketed a whitespace-onlytypeunder its own literal heading whiletypes/graph/statsbucketed it asUntyped, so two verbs reported type inventories for the same bundle that would not reconcile. §9.2 makes a blank type as non-conformant as a missing one; both sides say so now. -
okf search <dir> --fields slugpassed the field guard and returned one empty object per match with exit 0. Only registry mode labels rows with a slug, so the two modes now declare the shape each actually emits and a path-named search names the fields it does have. -
okf registry setreported(0 concepts)for a bundle whose files it could not read; it notes the skipped files like every other reading verb.okf registry rename DOCS handbookechoed the argv rather than the slug it renamed, naming a bundle that never existed. An unwritable$OKF_HOMEraised a bareErrno::EACCESat exit 1 instead of a usage error at exit 2. -
A long path in the Files view's Indexes tab pushed its
map/logbadge past the right edge of the list. The badge was already pinned and unshrinkable; the filename beside it was the problem — a bare text node, and an anonymous flex item's automatic minimum size is the full width of its text, so it refused to give way and drove the badge out instead. It truncates now, with the full path on hover. Only a wide enough path in a narrow enough pane reached it, which is why it never showed on mobile, where the list runs full width. -
The bundle switcher scrolled its own first row out of view as it opened. It rendered, and scrolled the active row into view, while the dialog was still hidden — and a list that is not being displayed measures zero, so the scroll landed arbitrarily.
-
The
3(Files) shortcut did nothing once the Indexes tab was open, and the palette's Index row would have blanked the page. "Index" is not a view — there is no#view-index, only the Files view showing its Indexes tab — sosetView('files')from that tab early-returned, andsetView('index')named a view that does not exist. The keyboard and the palette each re-implemented what the rail button already did right; both now click the rail item, so the one correct path is the only one, and the palette'scurrentbadge reads the same active-tab answer the rail's own highlight does.
1.7.0 - 2026-07-16
Added
-
okf server: responses are gzipped when the client accepts it (Rack::Deflaterat the boot seam). Lossless and transparent — the browser decompresses automatically — and no new dependency, sinceRack::Deflaterships inside rack. Clients that send noAccept-Encodingkeep getting identity responses.okf render's static HTML is untouched. -
The agent skill gains a
migrateverb (playbooks/migrate.md): convert existing documentation into a conformant bundle in place — frontmatter and reserved files added, bodies kept verbatim (producekeeps distillation). The verb is routed from SKILL.md's Commands table and intent inference, the menu playbook now leads with it when a target already holds markdown docs, and pointing any verb at a directory that is not a bundle now suggestsmigrateinstead of grinding through the validate errors.
Changed
-
The graph page's link-preview image points at the renamed
okfgem.com/og-demo-v3.png. The site's OG art was refreshed to drop "Live Graph" from the package formula (it isAgent Skill + CLI/Lib + Graphnow thatokf rendermakes the graph live or static), and the filename carries the version so social scrapers pick the new art up. -
The plugin's
/okf:gemcommand is now a pass-through shim: it hands its arguments to the okf skill unchanged, makingSKILL.mdthe single router for every channel. The routing prose the command used to duplicate had no drift guard (the sync test covers only the generated skill copy), and the not-a-bundlemigratesuggestion now lives inSKILL.md, so standalone skill installs get it too.
1.6.0 - 2026-07-15
Added
-
New CLI verb:
okf render <dir> [-o FILE]— the live graph as one static, self-contained HTML file, so it hosts where a server can't (GitHub Pages, an object store, an attachment). It is the same pageokf serverserves, one switch apart: the browser's five on-demand reads — bodies, descriptions, catalog, index, logs — now route through named getters that resolve from an injectedEMBEDpayload instead of the network, so the whole bundle rides inside the file with no server and no build step. Prints to stdout (okf render docs > public/index.html) or writes-o FILE. The embedded data is</script>-escaped exactly like the boot payload and every body still renders throughDOMPurify.sanitize(marked.parse(...)), so the trust boundary holds; the trade-off is weight — each body is inlined, so a big bundle makes a big file, andokf serverstays the choice at scale. -
Official Docker image:
ghcr.io/serradura/okf, a portable CLI that runs everyokfcommand (the graph server included) with no Ruby on the host. It is built from source and published multi-arch (linux/amd64,linux/arm64) to the GitHub Container Registry on each release tag, so the image always matches the gem. Mount a bundle at/data; forserver, add--bind 0.0.0.0and publish-p 8808:8808. See the README's Docker section.
1.5.0 - 2026-07-13
Added
-
New CLI verb:
okf search <dir> <term…>— deterministic ranked retrieval over concept metadata and bodies, the browser page's search brought to the CLI. Terms AND together as case-insensitive substrings, or as Ruby regexps with--regexp/-e;--inrestricts the searched fields; the shared--type/--area/--tagfilters and--fields/--exceptprojections apply. Matches rank by where they hit (title > id > tags > type/description > body) and carry a bounded context snippet, so "which concept covers X?" costs a few rows instead of a body read. Advisory read: exit 0 even with no matches. Deliberately not fuzzy — the consuming agent is the fuzzy layer. -
The skill learns retrieval as a first-class verb: a new
searchplaybook (progressive disclosure end to end: ingestokf index, decide where to look, cut across withokf search, read only the winning bodies), search-aware routing in SKILL.md and the menu/consume playbooks, and/okf:gem search <query>first in the Claude Code plugin's routing. -
Retrieval eval in the suite: the progressive path (index skeleton → search → one body) must answer a planted question in under 25% of the bytes of the full graph dump, so the playbook's economics stay true by construction.
-
Graph server: the authored layer joins the UI. The Files view carries two tabs — Files (the per-directory concept groups, foldable) and Indexes (the log first, as the chronological index, then every
index.md, root before nested) — with the files filters moved up into the top bar. The rail's Index item, the2key, and?view=indexare shortcuts straight to the Indexes tab. Folder nodes in file-tree mode and area boxes in cluster mode are clickable and open that directory's §6 map in the inspector (authored, or the synthesized listing when none exists). Links to anindex.md, alog.md, or a bare directory (model/) navigate everywhere a body renders instead of striking through as dead, and the log is fetched fresh on every read, so a just-appended entry shows without a restart. A reserved file's "Open in graph" jumps to its folder in the file tree, map in the inspector. New/indexand/logendpoints back it all. -
Graph server: Mermaid diagrams in concept bodies are click-to-inspect. A click (or tap) opens the diagram full screen — drag to pan, wheel or pinch to zoom, buttons and double-click reset, Esc closes — powered by Panzoom, lazy-loaded from the CDN exactly like Mermaid itself.
Changed
-
The Claude Code plugin's
/okf:gemcommand now weighs the shape of a free-form ask: a question about what the bundle knows routes through the search playbook and answers from retrieved concepts instead of guessing. -
Skill efficiency audit: every playbook now takes the CLI's lean paths.
maintainhunts affected concepts withokf searchand pulls edges viagraph --json --minimalinstead of the full-body dump,menureads the plain-text reports it only scans, and SKILL.md pins the discipline as a rule: skeleton first, bodies last.
Fixed
- Docs: the CLI reference's server section now reflects the DOMPurify
sanitization that landed in 1.1.0 (it still said bodies render unsanitized),
and the server page's link-preview image points at the renamed
okfgem.com/og-demo-v2.png.
1.4.0 - 2026-07-12
Changed
- Graph server UX round. Selecting a node now makes one camera move instead of
two (the pan used to race the opening panel and the debounced canvas resize,
a dizzying double movement; rapid clicks also queued animations — both fixed).
Relative markdown links inside the inspector and the files preview resolve
against the open concept and navigate in-app — clicking
../model/graph.mdselects that concept instead of 404ing the page; external links open in a new tab; links that leave the bundle are disabled, never a 404. Nodes are smaller (14–44px, was 24–70) and layouts keep a real gap between them (nodeOverlapfor cose,avoidOverlap/spacingFactorelsewhere). The inspector and the files list are drag-resizable (persisted, double-click resets), and the files reader now uses the full pane width. New file-tree mode on the graph toolbar: folders become nodes and the only edges are folder→child, an acyclic layered tree of the bundle's files. On small screens (≤900px) the inspector starts hidden and opens on the first node tap; camera moves are gentler (450ms, ease-in-out).
1.3.0 - 2026-07-12
Added
-
The graph server page now emits link-preview metadata: Open Graph and Twitter Card tags with a social image, plus
theme-colorandcolor-scheme, so a sharedokf serverURL unfurls as a proper card in chat and social apps. -
Docs: a themed README hero (light and dark), a GitHub social preview image, and Website / Live demo / Claude Code plugin links.
1.2.0 - 2026-07-12
Added
- Claude Code plugin. The repository now doubles as a plugin marketplace:
/plugin marketplace add serradura/okf-gem, then/plugin install okf@okfgem. The plugin carries the canonical skill (a generated copy;rake plugin:synckeeps it in lockstep withlib/okf/skill, and a test fails on drift), one front-door command (/okf:gem: no arguments orients on the CLI, the bundle, and whatvalidate/lintreport and recommends the highest-value next move without running one,doctorinstalls the gem and doctors the repo's bundle,curateruns the full validate + lint + loose cycle, anything else hands the task to the skill), and a PostToolUse hook that runsokf validate+okf lintafter every edit inside a bundle and hands the relevant findings back as context: every conformance error, plus the warnings and lint findings that concern the edited file. The checks are the CLI's own, so the feedback is deterministic. The hook stays silent outside bundles, and when the CLI is missing it suggests/okf:gemonce per session instead of erroring on each edit. It is config-free to silence:OKF_CURATE_DISABLED=1turns it off,OKF_CURATE_QUIET=1keeps the findings but drops that suggestion, and an<!-- okf-disable -->comment in a file skips curation for that one. The skill routes through per-verb playbooks (playbooks/), and its signature guidance lines carry stable<!-- check:… -->/<!-- rule:okf-… -->markers. Nothing underplugin/ships in the gem.
1.1.0 - 2026-07-12
Changed
require "okf"now loads the pure library only. The two argv-facing shells —OKF::CLIand theOKF::Skillinstaller — load on demand, fromexe/okfor an explicitrequire "okf/cli"/require "okf/skill".optparsemoves with the CLI, so an embedding app (e.g. a Rails store) that only reaches for the in-memory model and on-disk handles no longer pulls in the command-line machinery. The CLI itself is unchanged.
Security
- The graph server now sanitizes every concept body before rendering it. The
page runs marked's HTML output through DOMPurify
(loaded from the same CDN as Cytoscape and marked) on the way to the DOM, so a
bundle carrying active content in a Markdown body can no longer script the
viewer. Inlined graph data was already escaped through
json_for_script; this closes the other path.
1.0.0 - 2026-07-12
Initial release.
Added
-
OKF::Concept/OKF::Bundle: pure in-memory model of an OKF v0.1 bundle, buildable straight from data (no disk) with link, citation, and markdown round-trip primitives. -
OKF::Bundle::Validator: the spec §9 conformance gate (hard errors) with the spec's soft guidance reported as warnings — broken cross-links are tolerated, as §5.3 requires. -
OKF::Bundle::Linter: advisory curation-quality report across reachability, backlog, completeness, freshness, provenance, and hygiene, with--jsonas a machine substrate. -
OKF::Bundle::Graph: the knowledge graph (nodes, edges, type/tag indexes) at selectable fidelity. -
On-disk handles:
OKF::Bundle::Folder,OKF::Bundle::Reader,OKF::Bundle::Writer(atomic, validate-before-publish), andOKF::Concept::File. -
OKF::Server::App: the interactive graph as a mountable Rack app — five views (graph, catalog, files, tags, stats) with type/area/tag filtering throughout, bodies fetched live from disk — served by a built-in WEBrick runner (okf server). -
okfCLI:validate,lint,loose, andgraph, plus the read views as text —index,catalog,files,tags,types,stats— at full parity with the browser: every list view narrows with--type/--area/--tag(case-insensitive; the bundle root is area(root), accepted asroot), andtags --by type|arearegroups the tag index per concept dimension with within-group counts — the tag-curation view.serverboots the graph page;skillinstalls the companion skill. -
okf index: a read view over the progressive-disclosure layer (spec §6) — one entry per directory that holds concepts or carries anindex.md, root first, with its authored index body (frontmatter stripped), a type/tag rollup over the concepts that live there, its child directories, and the concept listing. A directory with concepts but noindex.mdhas its listing synthesized (§6 permits it) and is flagged.--area(repeatable),--no-body, and--json; advisory, always exit 0. Backed by the pureOKF::Bundle#directory_index. -
JSON output is compact by default across every emitting verb (the token-efficient machine substrate, matching the server);
--prettyindents it for reading and implies--json. JSON semantics are identical either way — only whitespace differs — so any parser is unaffected. -
JSON property projection on the list views:
index,catalog, andfilestake--fields a,b(emit only these properties) or--except a,b(emit all but these), so an agent never pays tokens for fields it will not read. The flags are mutually exclusive, imply--json, match property names case-insensitively, and reject an unknown name (exit 2) listing the valid ones;okf index --no-bodyis shorthand for dropping thebodyfield. -
Bundled companion agent skill (
okf skill <dest>): SKILL.md carrying the judgment (the CLI surface stays self-describing via--help) — including the orient-before-you-read protocol and the CLI/judgment boundary — the OKF v0.1 spec, authoring and CLI references (tag-vocabulary curation, the SPEC-section map, the closeout gate), and concept/index/log templates. -
Runs on Ruby >= 2.4 with two runtime dependencies: rack and webrick.