okf 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +615 -38
  3. data/README.md +109 -15
  4. data/lib/okf/bundle/folder.rb +20 -0
  5. data/lib/okf/bundle/search/index.rb +65 -0
  6. data/lib/okf/bundle/search/scan.rb +89 -0
  7. data/lib/okf/bundle/search.rb +262 -66
  8. data/lib/okf/bundle.rb +27 -3
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +495 -0
  11. data/lib/okf/cli/files.rb +68 -0
  12. data/lib/okf/cli/graph.rb +82 -0
  13. data/lib/okf/cli/index.rb +127 -0
  14. data/lib/okf/cli/lint.rb +139 -0
  15. data/lib/okf/cli/loose.rb +78 -0
  16. data/lib/okf/cli/registry.rb +229 -0
  17. data/lib/okf/cli/render.rb +66 -0
  18. data/lib/okf/cli/search.rb +285 -0
  19. data/lib/okf/cli/server.rb +179 -0
  20. data/lib/okf/cli/skill.rb +57 -0
  21. data/lib/okf/cli/stats.rb +88 -0
  22. data/lib/okf/cli/tags.rb +122 -0
  23. data/lib/okf/cli/types.rb +37 -0
  24. data/lib/okf/cli/validate.rb +66 -0
  25. data/lib/okf/cli.rb +418 -1633
  26. data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
  27. data/lib/okf/{server → render}/graph.rb +85 -9
  28. data/lib/okf/server/app.rb +17 -48
  29. data/lib/okf/server/hub/not_found.rb +663 -0
  30. data/lib/okf/server/hub.rb +504 -38
  31. data/lib/okf/skill/SKILL.md +41 -26
  32. data/lib/okf/skill/playbooks/consume.md +5 -3
  33. data/lib/okf/skill/playbooks/curate.md +3 -1
  34. data/lib/okf/skill/playbooks/maintain.md +4 -3
  35. data/lib/okf/skill/playbooks/menu.md +5 -0
  36. data/lib/okf/skill/playbooks/refine.md +92 -0
  37. data/lib/okf/skill/playbooks/search.md +47 -7
  38. data/lib/okf/skill/reference/authoring.md +3 -2
  39. data/lib/okf/skill/reference/cli.md +98 -21
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +8 -0
  42. metadata +37 -3
data/CHANGELOG.md CHANGED
@@ -1,9 +1,502 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.10.0] - 2026-07-21
9
+
10
+ ### Added
11
+
12
+ - **The skill gains a `refine` verb** (`playbooks/refine.md`): restructure a
13
+ bundle to get the most from OKF's capabilities — progressive disclosure, the
14
+ emergent graph, cross-cutting tags, capture-once-link-many. It is the third
15
+ authoring boundary: `curate` keeps the structure sound as it stands,
16
+ `maintain` keeps the content true, `refine` changes where knowledge lives —
17
+ evidence-first (tag locality, the hub origin test, a fatness alarm),
18
+ cohesion-over-balance, free levers before file moves, and it *proposes* (a
19
+ report plus a frozen execution prompt), never auto-applies.
20
+ - **`okf graph --hubs`** — the inbound ranking: every concept with at least one
21
+ inbound link, ranked by inbound degree, each with its links grouped by
22
+ *source area* (`core/status ×3 flows 2, billing 1`). This is the refine
23
+ playbook's hub origin test made mechanical: a hub whose inbound majority is
24
+ foreign to its own area is a move candidate. JSON: `{ bundle, count, hubs:
25
+ [{ id, area, inbound, by_area }] }`.
26
+ - **The registry has a browser surface.** A meeting with non-technical readers
27
+ settled what the TUI could not: `okf registry set/del/default/rename` is the
28
+ right surface for the people who *write* bundles and the wrong one for the
29
+ people who read them. The graph page's rail grows a **Bundles panel** behind
30
+ ⚙ — every bundle the server knows about, with its title, `@slug`, folder,
31
+ concept count and a health verdict — and four routes behind it:
32
+ `POST /registry/{default,rename,remove,add}`, the only non-GET routes the
33
+ server has.
34
+ - **Management is the default, and `--read-only` declines it.** The flag names
35
+ the restriction rather than the capability, because the audience this was
36
+ built for should not need a command line to use the page they were pointed
37
+ at. A loopback bind is writable without a flag; any other address is refused
38
+ outright, with no flag that opens it — `--bind 0.0.0.0` is how a personal
39
+ tool becomes a public one, and a write surface does not follow it there.
40
+ - **Four gates on every write**: is this server writable at all; is there a
41
+ registry to write to (an ephemeral `okf server ./a ./b` answers `409` rather
42
+ than leaving the missing controls a mystery); is the verb one of the four (a
43
+ frozen list — "call whatever method the path names" is how a router becomes
44
+ an `eval`); and did this come from this page (same-origin *and* a per-boot
45
+ token, since the token lives in a page another site can get a reader to
46
+ submit, and Origin alone would trust every tab open on the host). A
47
+ read-only server hides the controls *and* refuses the request that skipped
48
+ them: hiding a button is a UI, refusing the request is the boundary.
49
+ - **A write rebuilds the hub's bundles from disk** before it answers. That is
50
+ the step easy to skip and impossible to skip safely — a write leaving the
51
+ running server on the old set is a lie the next click believes.
52
+ - **`/b/` stops managing and keeps the page.** Both surfaces carried the same
53
+ four verbs for a while, and two implementations of one contract is the thing
54
+ that drifts, so the forms came out and the routes stayed. `/b/` answers
55
+ *which bundles are there* — and remains the empty state a hub with zero
56
+ bundles still needs — while the panel answers *change this one* where the
57
+ reader already is. With nothing to post it holds no token either.
58
+ - **There is no Add on either surface.** A browser cannot hand over a
59
+ filesystem path — the File System Access API yields an opaque handle, and is
60
+ Chromium-only besides — so registering stays the agent's act. The route
61
+ exists for other callers; nothing in the UI reaches it.
62
+ - **"Workspace" is retired** from the docs and the UI. The things are Bundles
63
+ and the thing holding them is the registry; a page saying one word while the
64
+ CLI says another is two products wearing one name.
65
+ - **The hub searches every bundle it hosts.** `GET /search?q=` is the only route
66
+ in the server that knows about more than one bundle: `Search.across` over one
67
+ shared index, so BM25 weighs a term against the whole corpus instead of
68
+ stapling per-bundle lists together. Capped at 50 with the total reported,
69
+ because a silent cap reads as a complete answer. The engine is named `:index`
70
+ outright rather than left to route off `fuzzy: true` — that reached the right
71
+ engine only because nothing else declares the capability, which is correctness
72
+ by coincidence, and an addon declaring `:fuzzy` would have taken the route
73
+ silently. A long-lived server also amortizes an index build over every
74
+ keystroke where a one-shot CLI cannot, and minifts is a port of the browser's
75
+ own MiniSearch, so a palette hit and an in-page search rank alike.
76
+ - **The palette's Concepts group comes last**, and not because it matters
77
+ least: it is the only group that arrives asynchronously, and a group landing
78
+ above the cursor moves the row under the reader's fingers between the
79
+ keystroke and the Enter.
80
+ - **The topbar search box says what it filters, and where to go when it finds
81
+ nothing.** It and ⌘K looked alike and meant different things — the box
82
+ *filters* what is on screen, the palette *finds* across every bundle a hub
83
+ hosts — and the box carried neither fact: it emptied the graph in silence and
84
+ never mentioned the palette, so a reader whose word lived in another bundle
85
+ got a blank canvas and no way out. Three additions, all inside the box: a
86
+ **chip** naming the chord (⌘K / Ctrl-K, OS-aware) that opens the palette, a
87
+ **live count** (`7/8`) that makes an empty result a number which reached zero
88
+ rather than a view that went blank, and on zero a **panel** naming the bundle
89
+ and the query — ⏎ hands it to the palette prefilled and already searching, esc
90
+ clears.
91
+ - **`okf` is extensible.** Any gem that puts `okf/plugin.rb` on its load path can
92
+ register a verb, and it answers to `okf` — listed in `okf help` under
93
+ `installed extensions:`, dispatched like a built-in. There is no list of known
94
+ addons in this gem and no configuration step for the user: installing the gem
95
+ is the whole installation. It is the same seam `Search.register` opened for
96
+ search engines, and the same idiom — append-only, idempotent by id, so an addon
97
+ can never quietly displace a built-in.
98
+ - **Discovery is lazy**, which is what makes it affordable. A built-in verb
99
+ resolves against the registry and dispatches without scanning at all; only an
100
+ unknown verb or `okf help` — which has to know everything by definition —
101
+ pays the ~11ms `Gem.find_latest_files` costs on the 2.4 floor. A one-shot CLI
102
+ that will not build a search index for a single query should not pay for
103
+ discovery to answer a verb it shipped with.
104
+ - **Extensions must come from gems named `okf-*`**, the convention Jekyll and
105
+ Vagrant use for the same job: it makes what counts as an okf extension
106
+ explicit and stops an unrelated gem claiming the `okf/plugin.rb` path by
107
+ accident. One that is not so named is discovered, skipped, and reported on
108
+ stderr. A path belonging to *no* gem — a checkout, `ruby -I`, a Gemfile
109
+ `path:` — stays trusted, because someone put it there deliberately. It is a
110
+ mild guard too — loading a plugin runs its code — but the naming convention
111
+ is the reason, not the threat model, which is thin: under Bundler discovery
112
+ is bundle-scoped anyway, so the Gemfile is already an allowlist.
113
+ - **The rule holds when it cannot get an answer**, which is a separate promise
114
+ from the rule itself. A gem name that cannot be read — one corrupt gemspec
115
+ anywhere on the machine — is refused rather than treated as "belongs to no
116
+ gem", and the refusal names the exception that caused it. A discovery that
117
+ fails outright is reported too, since an empty list and no message is
118
+ indistinguishable from a machine with nothing installed.
119
+ - **A broken addon is skipped and reported, never fatal** — the same
120
+ best-effort posture the reader takes with an unparseable file. The note goes
121
+ to stderr, so a `--json` run's stdout stays a clean machine substrate.
122
+
123
+ - **The graph page is proven in a real browser.** `test/browser/` drives the
124
+ page `okf server` and `okf render` share in Chromium, asserting DOM state and
125
+ computed CSS at real viewport widths, and failing any test where the page
126
+ threw. Every spec runs twice — once served, once against a `file://` static
127
+ render — because the two modes diverge on fetched endpoints vs. a baked
128
+ `EMBED`, and a pass in one proves nothing about the other. It is opt-in
129
+ (`rake test:browser`, outside the default task) and non-blocking in CI, since
130
+ the page boots against a CDN and a slow jsdelivr must not gate a merge. The
131
+ three fixes below are what writing it turned up: shipped defects invisible to
132
+ a string assertion over the rendered HTML, each reproduced red and pinned
133
+ green.
134
+ - **Coverage is mapped per-contract, not guessed.** `test/browser/COVERAGE.md`
135
+ enumerates every behavioral contract the page introduced across its history
136
+ and marks each covered / partial / uncovered against a named spec — **176 of
137
+ 181 net-live (97%)**. The five that remain are each a documented blocker, not
138
+ a missing test: an absence-proof with no line to break, a node-overlap check
139
+ no cytoscape layout makes both deterministic *and* mutation-sensitive, a
140
+ map-visibility observable another contract already owns, a palette scroll
141
+ whose observable is a tautology, and an unbuilt focus-trap. Reaching the
142
+ branches the flat 8-concept fixture cannot took four further purpose-built
143
+ bundles beside the hostile one, each served on its own port and baked to its
144
+ own static page so the main fixture's count assertions stay put — nested
145
+ directories, forty-five tags, a five-directory-deep reserved path, and a
146
+ hundred-node ring that drives the graph past its own fit box. Every new spec
147
+ is mutation-checked: break the code it covers, confirm it goes red for the
148
+ predicted reason, restore. The map also caught one of its own stale rows — a
149
+ note listed as an uncovered gap had in fact been deleted from the page, and is
150
+ now marked superseded rather than owed.
151
+ - **The page's CDN libraries are served from a local cache** — a read-through
152
+ cache keyed on the request URL, so a warm run needs no network and a version
153
+ bump is a miss rather than a stale hit; `OKF_NO_VENDOR_CACHE=1` bypasses it,
154
+ which is how you check the pins still resolve. It buys robustness, not
155
+ speed: measured at one worker, 28.7s without and 29.0s with, because the
156
+ suite is CPU-bound and Chromium already reused those files across contexts.
157
+
158
+ ### Changed
159
+
160
+ - **`okf tags --by` rows carry each tag's total.** The grouped view printed only
161
+ within-group counts, so a tag's spread meant cross-referencing groups by
162
+ hand; each row now shows `count/total` when they differ (`async 2/3`) and
163
+ the plain count when the tag is wholly local — locality at a glance, the
164
+ domain-vs-concern read. The JSON rows gain a `total` key; filters recompute
165
+ it over the narrowed set.
166
+
167
+ - **A wrong turn at the hub lands on a directory, not an apology.** The 404 is
168
+ rebuilt on the app shell, and it reads as what it is: the **asked path is the
169
+ heading**, set in mono where a dropped slash is legible as a shape, with "not
170
+ found" demoted to the eyebrow above it, since a reader arrives already knowing
171
+ they are lost. A near-miss slug is a **row** wearing the same anatomy as the
172
+ list under it, already marked, with ⏎ pointed at it; rows carry the folder
173
+ that actually distinguishes `site/.okf` from `minifts/.okf`; and colour marks
174
+ exceptions only, so a healthy row draws no verdict edge at all. Moving through
175
+ the list is **Tab's** job — every row is an `<a href>`, and a hand-rolled ↑↓
176
+ cursor was tried and deleted as a second focus model beside the real one. A
177
+ query matching no bundle is offered the cross-bundle search that would match
178
+ it, the same escalation the graph page's box makes.
179
+
180
+ - **On a touch screen a tap opens a card, not the whole viewport.** At ≤768px
181
+ the inspector is `grid-template-columns:0 1fr`, so tapping a dot measured the
182
+ stage at 0px wide: the graph was not covered, it was gone. Exploring on a
183
+ phone became open → read → close → tap the next dot, and you could never see a
184
+ concept and its neighbourhood at once, which is the one thing a graph is for.
185
+ A preview card now rises at the bottom edge over a graph that keeps every
186
+ pixel and stays live — drag it up for the neighbourhood and the body, tap a
187
+ row and it swaps in place while the camera walks. Folder and index taps fill
188
+ the card too; they used to write into an invisible panel, so tree and cluster
189
+ modes were silently dead on touch. The branch is wider than the chrome's
190
+ (≤768px, or ≤1024px portrait), because a portrait tablet has the same bug and
191
+ wants the same gesture.
192
+
193
+ - **Type chips select instead of deselecting.** Three chip groups carried two
194
+ grammars: areas and tags were additive — nothing selected means everything, a
195
+ click narrows, a second click undoes — while types were subtractive, every
196
+ type showing until you clicked one away. Same component, same panel, opposite
197
+ meaning, and the catalog's and tags view's own type chips were already
198
+ additive, so the rule a reader learned in one view was wrong in the next.
199
+ Types now select, and two of them compound into a union the old model could
200
+ not express at all — it could say "not the other four", never "Services and
201
+ Charters". The change is a net deletion.
202
+
203
+ - **The CLI is one file per verb.** `lib/okf/cli.rb` was 1,794 lines and a
204
+ 15-arm `case`; it is now a registry and a dispatcher, with the verbs under
205
+ `lib/okf/cli/` and the shared surface on a `Command` base class. Behaviour is
206
+ unchanged — every existing test passes untouched — but `okf help` is now
207
+ composed from what the commands say about themselves rather than from a
208
+ heredoc that had to be remembered separately.
209
+
210
+ ### Fixed
211
+
212
+ - Selecting a node in cluster mode faded the entire graph rather than
213
+ emphasising the selection. Dimming set opacity on the unrelated elements, but
214
+ in cluster mode those include the compound area boxes — and a parent's opacity
215
+ cascades to the nodes inside it, so dimming the boxes faded the very leaves
216
+ being highlighted. The highlight was real in each node's own opacity and
217
+ invisible on screen: measured parent-inclusive, the selection sat at 0.1
218
+ against an unrelated node's 0.01. `focusNode` now dims the leaves and edges
219
+ and never the `:parent` boxes, which is a no-op outside cluster mode where
220
+ there are no parents. After: selection and neighbours at 1, the rest at 0.1.
221
+
222
+ - A log's "Open in graph" button stayed visible, carrying a stale
223
+ `onclick` from the last map or concept — the "answers about a different file"
224
+ symptom, returning through CSS. The code hides it correctly
225
+ (`#fp-graph.hidden = true`), but `.btn.text{display:inline-flex}` outranks
226
+ `.btn[hidden]{display:none}` at equal specificity (0,2,0), so the later rule
227
+ won and the button rendered 143px wide with the attribute present. A
228
+ `.btn.text[hidden]` rule settles it, the same fix the sibling `.fp-head[hidden]`
229
+ already carried.
230
+
231
+ - Leaving the graph for another view and returning redrew it at a
232
+ fraction of its size, and stayed that way. The cause was misdiagnosed as a
233
+ resize race for months; tracing `cy.animate`'s caller showed the one animation
234
+ running was a *fit*. `fitGraph` computes zoom from the container's own width,
235
+ and the one-shot boot fit (`setTimeout(fitGraph, 400)`) fires on whatever view
236
+ is up by then — so leaving the graph inside that window fits a hidden 0×0
237
+ canvas, `(w-2*pad)/bb.w` goes negative, and the zoom clamps to `minZoom`.
238
+ `fitGraph` now returns early on a zero-size canvas and the graph keeps its last
239
+ good zoom. The hazard was already known at the other end: the boot fit is not
240
+ registered at all for a `?view=`/`?select=`/`#hash` deep link, whose comment
241
+ names this same min-zoom clamp — it was the navigate-away case that went
242
+ uncovered.
243
+
244
+ - `okf help`'s map advertised `search … [-e|--fuzzy]`, pairing a shorthand
245
+ it never expanded with an unrelated flag, so the one hint that an engine is
246
+ selectable read as though `-e` might *be* the engine switch. The row now says
247
+ `[--regexp|--fuzzy]`, which is what the command's own banner says; `-e` still
248
+ works and `search --help` still spells it out.
249
+ - **`okf skill <a> <b>` installed into `<a>` and exited 0.** It hand-rolled
250
+ its own argument handling instead of using the shared pair every `<dir>` verb
251
+ goes through, so a second destination was silently dropped — the user named two
252
+ places and the tool wrote one, saying nothing. It is a usage error now (exit 2),
253
+ refused before anything is written.
254
+
255
+ ## [1.9.0] - 2026-07-19
256
+
257
+ ### Added
258
+
259
+ - **`okf search` gains an opt-in full-text index engine.** `--engine index` — and
260
+ `--fuzzy`, which implies it — routes to
261
+ [minifts](https://github.com/serradura/minifts), the pure-Ruby port of the same
262
+ MiniSearch build the graph page loads. It is the gem's third runtime
263
+ dependency, admitted because it costs the footprint nothing the first two were
264
+ chosen to protect: no native extension, no dependency tree of its own, the same
265
+ Ruby 2.4 floor. Three things it adds, and nothing else does:
266
+ - **BM25+ relevance ranking**, where the default scores by summed field weight;
267
+ - **`--fuzzy`** — typo tolerance at edit distance `0.2 × term length`, the
268
+ browser's own setting. Search stays exact unless you ask;
269
+ - **parity with the graph page**, which runs the same MiniSearch build, so the
270
+ two rank identically when the index is named.
271
+
272
+ - **`--engine NAME` picks the engine outright**, for the case a capability flag
273
+ cannot express: a matching *model* requires nothing, so no flag selects one.
274
+ Naming an engine that cannot do what was also asked is a usage error naming one
275
+ that can (`--engine index -e` → *try --engine scan*), and an unknown name lists
276
+ what is available. `--help` reads the registry, so an addon's engine appears
277
+ without the CLI knowing it exists.
278
+
279
+ - **The graph can draw the index layer, under any layout.** The §6 map was
280
+ visible only inside file-tree mode, where a folder node stood in for a
281
+ directory's `index.md`. **Show indexes** makes it a layer: each map is a tile
282
+ edged to the concepts it lists and the maps below it, dressed by the same
283
+ selector as file-tree mode's folder node, because the two are the same thing
284
+ twice over — clicking either opens that directory's `index.md`. Both are accent
285
+ squares with dashed edges into them, so colour separates *kinds* rather than
286
+ modes: a directory is not a concept and no longer reads as one. Authorship shows
287
+ as form — solid where an author wrote a map, hollow and dashed where the bundle
288
+ only implies one — so the toggle reads as curation as much as navigation.
289
+ - **Moving between the modes lands in one click.** Tearing the layer down ran
290
+ its own layout while file-tree mode ran `breadthfirst` a beat later, two
291
+ layouts racing the same canvas; and because the layer is fetched, a promise
292
+ resolving after a mode change could land inside file-tree mode. A `relayout`
293
+ flag settles the first, a per-toggle ticket the second.
294
+ - **File-tree mode disables the toggle** rather than doubling the folders it
295
+ already draws.
296
+ - **One label on every file's graph button.** It read "Explore the knowledge
297
+ graph" on the root index and "Open core/ in graph" on a nested one, which made
298
+ a single action look like three. The question is the same whatever is open, so
299
+ the label is too — and it lives in the markup, where it cannot go stale.
300
+ - **Opening a map from the reader keeps the reader's graph.** It forced
301
+ file-tree mode, discarding whatever layout was running, and dimmed the canvas
302
+ to the map's immediate neighbours. It now switches the *layer* on rather than
303
+ the *mode* and leaves the layout alone. A reader already in file-tree mode
304
+ stays there.
305
+ - **Selecting anything emphasises it the same way.** A concept dimmed the graph
306
+ to its neighbourhood, a map did nothing at all, and a folder node did nothing
307
+ either — three meanings for one gesture. One `focusNode` now serves all three.
308
+ - **Drawn, never modelled.** `index.md` is reserved, so these nodes are built
309
+ from `/index` straight onto the canvas; `NODES`, `/catalog` and the type and
310
+ tag indexes never learn they exist. Filters pass them over — a map has no type
311
+ or tags — but a map whose concepts are all filtered away leaves with them.
312
+
313
+ - **A first-visit note tells a newcomer the index exists.** The `index.md` an
314
+ author wrote to be read first was reachable only by finding the Indexes tab and
315
+ clicking a row, so a reader meeting a bundle for the first time met unlabelled
316
+ dots with no way in. The page still opens on the graph — it is what makes a
317
+ bundle legible at a glance, at every width — and a dismissible note at the
318
+ bottom now says what the picture is, how to touch it, and where the index is.
319
+ **Read the index** goes straight there; the dismissal is remembered.
320
+ - **It absorbed the old mobile-only tip** rather than stacking a second banner
321
+ under it, and it is written for a finger throughout, since a phone is where a
322
+ first-time reader is least oriented.
323
+ - **The wording follows the device on two gates, not one.** What a reader does
324
+ follows `(pointer:coarse)` — a touch tablet in landscape is wider than 768px
325
+ and still taps; a narrow desktop window is narrower and still clicks. What a
326
+ reader can reach follows `(max-width:768px)`, because that is when the rail
327
+ collapses behind `☰`. Short viewports tighten; short *and* wide puts the
328
+ question beside the button, taking a landscape phone from half the screen to
329
+ under a third.
330
+ - **A second note points at `☰`** on compact layouts only, anchored under the
331
+ button it names rather than at the bottom of the screen. It fires on leaving
332
+ the graph by any route, so dismissing the first note does not cost it, and
333
+ opening `☰` answers it — but only once it is on screen, since `☰` is the only
334
+ way off the graph there and the first tap always comes first.
335
+ - **Deep links are unaffected**, and `?select=`/`#hash` now switch to the graph
336
+ before selecting, since the page can be standing elsewhere when they are read.
337
+
338
+ - The graph page's search box grows a full-text index. One MiniSearch index —
339
+ lazy-loaded from the CDN on first search, pinned to the `7.2.0` the Ruby
340
+ MiniSearch port tracks so an `okf search --engine index` result and the
341
+ browser's rank identically — now backs the graph, catalog and files views: ranked, multi-term
342
+ (`AND`), prefix (as-you-type) and typo-tolerant, over title, id, type, tags and
343
+ **description** — plus each concept's **body** wherever the page already holds
344
+ it (`okf render` bakes every body in, so a static file searches bodies offline;
345
+ the live server keeps bodies lazy, so its index stays metadata-only until a
346
+ backend body index arrives). The graph could not be searched by a leaf's
347
+ description before; now it can. The Files view's **Indexes** tab gets its own
348
+ full-text index too, over each `index.md`/`log.md`'s body — not just its
349
+ filename. Until an index loads — or if the CDN is unreachable — each view falls
350
+ back to its own substring filter, so the box is never dead.
351
+
352
+ - `Esc` clears the graph selection. A dense graph leaves almost no empty canvas
353
+ to click for deselecting; `Esc` now drops the highlight (and lets the URL hash
354
+ forget the node) the same way tapping empty canvas does.
355
+
356
+ ### Changed
357
+
358
+ - **The default search is unchanged** — literal, case-insensitive substring
359
+ matching over the same fields with the same weights as 1.8.0. The index is
360
+ opt-in rather than default because a one-shot CLI builds an index, asks one
361
+ question, and exits: end to end, **3.00 s against 0.24 s at 1,000 concepts**,
362
+ the build accounting for ~95% of that. The ~44–56× per-query throughput that
363
+ recommends minifts is the right measure for a long-lived index — a page, a
364
+ server — and the wrong one for a process that exits. A cached prebuilt index is
365
+ what would change that arithmetic.
366
+ - **Know what the index costs before naming it.** Its tokenizer splits on
367
+ punctuation, so `customer_id` becomes `customer` + `id` and `7.2.0` becomes
368
+ `7`, `2`, `0`; an infix (`ustomer`) finds nothing; and a backtick is Unicode
369
+ `Sk` rather than punctuation, so a word inside a code span indexes as
370
+ `` `minifts` `` and the query `minifts` does not match it — 409 such tokens
371
+ on this repo's own bundle. Ranking does not rescue it: BM25 normalizes by
372
+ field length, so a short concept dense in `7`, `2` and `0` can outrank the one
373
+ that actually says `7.2.0`. The default has none of these, because raw-text
374
+ matching has no tokenizer.
375
+
376
+ - **Search engines are adapters.** `OKF::Bundle::Search` became a facade over N
377
+ engines instead of one class with a `regexp ? scan : index` branch. The facade
378
+ keeps everything that defines a result — documents, the row and its key order,
379
+ the snippet, the sort — and an engine answers only which documents match, how
380
+ well, and where. The built-ins are `Search::Scan` (raw text, the default,
381
+ `regexp`) and `Search::Index` (minifts, `fuzzy`/`prefix`).
382
+ - **Selection is by capability when the query requires one** — `--fuzzy`
383
+ requires `:fuzzy`, so it routes to the index without naming it — and that
384
+ routing prints **nothing**: no note, no header change, no new JSON key.
385
+ - **`Search.register` is a published extension point** — append-only,
386
+ idempotent by id, capabilities checked against a fixed vocabulary. This is
387
+ the seam a future SQLite/FTS5 addon plugs into; no addon code ships here.
388
+ - **A shared conformance suite replaces the "kernel is the oracle" rule**,
389
+ which multiple engines made impossible: the index and the scan disagree about
390
+ match sets by design, so neither can be the oracle. Every registered engine
391
+ runs the same contract, with capability-gated blocks for its own semantics,
392
+ and a registered engine with no conformance class fails the suite.
393
+
394
+ - **Cross-bundle search ranks one corpus under `--engine index`.** BM25 prices a
395
+ term by how rare it is, so ranking each bundle separately and interleaving the
396
+ lists would produce a ranking that looks sorted and compares nothing; the
397
+ searched bundles are indexed together instead. The visible consequence, under
398
+ that engine only: a score is relative to the whole answer, so the same concept
399
+ scores lower searched beside other bundles than alone. The default's scores are
400
+ absolute and need no such treatment.
401
+
402
+ - **Collapsing the root folds the file list away** on phones and tablets, where
403
+ the list is stacked on top of the reader and closing the root otherwise left a
404
+ single row above a column of nothing. Reopening the list undoes that collapse,
405
+ so it is one gesture rather than two states to dig out of — the fold remembers
406
+ *why* it happened, and a list folded because a file was opened comes back
407
+ exactly as it was left.
408
+
409
+ - **The bundle names its own root.** `(root)` and `/` are what a filesystem calls
410
+ it, not what a reader does. The tree's root row, file-tree mode's root node, the
411
+ index layer's root map and the inspector's directory map now all carry the name
412
+ the page header already shows, `--title` included. `areaOf` keeps its own
413
+ `(root)`: that is the area vocabulary `okf stats --by area` and `tags --by area`
414
+ print, not a UI label.
415
+
416
+ - **The Indexes tab dissolves into the file tree.** The authored layer lived on a
417
+ second tab as a flat list of paths, which put a directory's own map somewhere
418
+ other than the directory. `index.md` and `log.md` are rows now, at the top of
419
+ the folder they document, and **Indexes only** is a toggle over the same tree —
420
+ same rows, fewer of them, structure intact. The toggle yields only when it would
421
+ hide what was just opened — a map stays under it, a concept releases it — so
422
+ browsing the authored layer no longer destroys the list being browsed. A log
423
+ offers no graph button at all: it is a chronology, not a place in the graph, and
424
+ the button had been opening the root index's node.
425
+ Narrowed, a folder owns exactly one row, so the row stands where the folder
426
+ header stood — at that folder's depth, carrying the path — rather than nesting
427
+ a single child under a header.
428
+ - **The rail's Index becomes an action, not a fake view.** It had no
429
+ `#view-index` behind it — the files view showing its other tab — so
430
+ `activeRail()` answered a question of view *and* tab. The shortcut stays,
431
+ opening the root map through the same `readIndex()` the first-visit note
432
+ uses; `activeRail()` answers with the view it lands on, so Files highlights
433
+ and nothing invents a place for Index to be. `?view=index` resolves to the
434
+ same action.
435
+ - **Fixed on the way:** the reader header rendered empty — an unlabelled badge
436
+ and a graph button pointing nowhere — whenever no file was open, because
437
+ `.fp-head{display:flex}` outranks the UA sheet's `[hidden]{display:none}`.
438
+
439
+ - **The file tree nests.** Directories were a sorted list of full paths, which
440
+ made `core` and `core/configurations` read as two unrelated folders and left
441
+ the shape of a bundle invisible. Each row is now one path segment indented by
442
+ depth, folders before files, and collapsing a folder takes its subtree with it.
443
+ A directory holding nothing but directories still renders, so the chain to its
444
+ children never breaks.
445
+ - **"Collapse all" folds into the root, not over it** — everything inside the
446
+ root closes and the root stays open, so the click leaves the top-level
447
+ folders standing instead of a single `(root)` row. Unfolding clears the whole
448
+ set, root included, so a root closed by hand is still reversible from there.
449
+
450
+ - `okf render` stops baking a redundant description map. The static page derived
451
+ its `/node/meta` fragments from a separate `meta` payload that held nothing but
452
+ each concept's description, HTML-escaped — data the embedded `catalog` already
453
+ carries raw. The page now escapes the catalog's description on the client (the
454
+ same escape the server applies at `/node/meta`), so the `meta` key leaves the
455
+ baked payload and the description lives in one place. Both XSS guards are
456
+ unchanged; `okf server` is untouched.
457
+
458
+ - The bare not-a-directory error now teaches the registry grammar. A verb given
459
+ a target that is neither a directory nor an `@ref` moved from
460
+ `error: <arg> is not a directory` to
461
+ `… is not a directory or a registry ref (@slug names a registered bundle, @ the default; okf registry list)`,
462
+ so a consumer who typed a query or a bad path meets `@slug` addressing at the
463
+ error instead of hunting for it. (`@all` stays out of the message — it is
464
+ `search`'s alone, and the error seam is shared by every verb.)
465
+
466
+ - The bundled skill teaches `@slug` as a first-class target and stops probing for
467
+ the CLI. `SKILL.md`'s "Which directory?" is now "Which target?" — a leading `@`
468
+ is a registry ref routed straight to `okf <verb> @slug`, with the fallback
469
+ "no bundle in the cwd → `okf registry list`" — and the consume/search playbooks
470
+ name `@slug` in their orientation steps. The per-run `command -v okf` presence
471
+ probe is gone: run the verb, and treat a shell `command not found` as the only
472
+ signal to install, so the common case pays no guard round.
473
+
474
+ ### Fixed
475
+
476
+ - The Files tree's folder collapse works during a search. An active search or
477
+ type/tag filter used to force every folder open, so fold clicks did nothing;
478
+ folders now honor their collapsed state always (a collapsed group still shows
479
+ its header, so a match is never hidden). A **fold/unfold-all** control in the
480
+ Files tab header collapses or expands every visible group at once.
481
+
482
+ - Clustering no longer leaves phantom empty boxes. When a filter or a search hid
483
+ every concept in an area, the cluster's labelled box lingered as an empty
484
+ rectangle; the box now hides when no child survives and returns when one does —
485
+ the same rule the fit already used to leave stale boxes out of view, now
486
+ applied to what is drawn.
487
+
488
+ - A title-less concept now wears one name in every view. `catalog` and the §6
489
+ index listing fell back a concept with no `title` to its full id — `area/thing`
490
+ — while the graph node fell back blank-aware to the basename — `thing` — so the
491
+ same concept answered to two labels across two views of one bundle, and a
492
+ `title: ""` slipped past the nil-only `||` to catalog as an empty string. Both
493
+ now fall back the graph's way (`File.basename`, blank-aware), so the label is
494
+ the same wherever the concept appears.
4
495
 
5
496
  ## [1.8.0] - 2026-07-17
6
497
 
498
+ ### Added
499
+
7
500
  - A persistent bundle registry and a multi-bundle hub. `okf registry`
8
501
  (list / set / del / default / rename) keeps a per-user list in a plain JSON
9
502
  file at `$OKF_HOME/registry.json` (default `~/.okf`), and `okf server` reads
@@ -13,6 +506,7 @@
13
506
  switcher (⌘/Ctrl-K, or the rail button), `/b/` is a browsable index, and an
14
507
  unknown slug 404s as a page with a way home. The hub reads its bundles at
15
508
  boot — restart after registry changes.
509
+
16
510
  - The registry is **ordered, and the first entry still on disk is the default** —
17
511
  the bundle a bare `okf server` opens at `/`. `okf registry default @slug` moves
18
512
  that entry to the front, and `okf registry set --default` registers straight to
@@ -22,11 +516,13 @@
22
516
  vanished directory is stepped over rather than starred — `registry list`'s `*`
23
517
  always names the bundle `/` opens — and `registry default @slug` refuses one,
24
518
  just as `registry set` refuses to register a directory that is not there.
519
+
25
520
  - `$OKF_HOME` is the single lever on which registry a command reads: set it and
26
521
  every verb follows, from `okf registry list` to an `@slug` on `okf lint`. It
27
522
  names exactly one registry, with no fallback to `~/.okf` behind it, and an
28
523
  empty value counts as unset rather than planting `registry.json` in the
29
524
  current directory.
525
+
30
526
  - `@slug`: wherever a command takes a `<dir>`, `@slug` names a registered
31
527
  bundle and bare `@` the registry default — `okf lint @handbook`,
32
528
  `okf render @ -o graph.html`. A slug is normalized like registration was
@@ -37,6 +533,34 @@
37
533
  (`okf server @a @b`) mounts each bundle under its registered slug, the first
38
534
  at `/`, and a registered slug reserves its mount ahead of any plain
39
535
  directory that shares the name.
536
+
537
+ - `okf search` spans bundles: several leading `@slug`s, or `@all` for every
538
+ registered one. Rankings merge across bundles with every row labeled by its
539
+ bundle's slug (a `bundles` list and a per-match `slug` key in the JSON).
540
+ Asking for everything tolerates gaps — `@all` skips a bundle whose directory
541
+ has vanished, with a note — while naming one insists on it, and `@all @docs`
542
+ simply dedupes. `all` is reserved *in the registry*, on all three ways in: a
543
+ directory named `all/` registers as `all-2`, `--as all` is refused, and a row
544
+ already claiming the name in the registry file — hand-typed, or written before
545
+ the name was reserved — is read as `all-2`, so the reservation never strands a
546
+ registry it inherited. An ephemeral `okf server ./all` still mounts at
547
+ `/b/all/` — no registry, no refs, nothing to reserve.
548
+
549
+ - The inspector's type and tags are filter handles: clicking one focuses the
550
+ graph on that facet — the same jump the stats bars make — and clicking it again
551
+ clears it. The chip lights while its facet is the only filter in play, which is
552
+ exactly when a second click is an undo, so what you see and what the next click
553
+ does are the same question. With another filter set it re-focuses instead,
554
+ rather than throwing away more than the click put there.
555
+
556
+ - The graph page answers `?` with a sheet of every keyboard shortcut, reachable
557
+ from a rail button too — a shortcut list you can only open with a shortcut helps
558
+ whoever needs it least. `/` focuses the current view's search where it has one,
559
+ skipping the view that only reads; the sheet is written against the key handler
560
+ it documents, since a shortcut list that has drifted is worse than none.
561
+
562
+ ### Changed
563
+
40
564
  - `@slug` is spelled where it is used, not just where it is explained, and it is
41
565
  the one token — `okf help`'s map (`lint <dir|@slug>`) and each command's own
42
566
  banner show it the same way, with a note under the map defining it: the slug
@@ -46,25 +570,17 @@
46
570
  advertising a bare `<dir>`. The registry-editing verbs — `del`, `default`,
47
571
  `rename` — take the slug bare (no `@`) or as an `@slug`; the read verbs need
48
572
  the `@`, since a bare word there is a path.
573
+
49
574
  - `okf <command> -h` prints that command's own banner and flags. Help now answers
50
575
  on stdout with an exit code like every other command: it was OptionParser's
51
576
  officious handler, which printed past the caller's injected streams and ended
52
577
  the process with `exit` instead of returning a status.
53
- - `okf search` spans bundles: several leading `@slug`s, or `@all` for every
54
- registered one. Rankings merge across bundles with every row labeled by its
55
- bundle's slug (a `bundles` list and a per-match `slug` key in the JSON).
56
- Asking for everything tolerates gaps — `@all` skips a bundle whose directory
57
- has vanished, with a note — while naming one insists on it, and `@all @docs`
58
- simply dedupes. `all` is reserved *in the registry*, on all three ways in: a
59
- directory named `all/` registers as `all-2`, `--as all` is refused, and a row
60
- already claiming the name in the registry file — hand-typed, or written before
61
- the name was reserved — is read as `all-2`, so the reservation never strands a
62
- registry it inherited. An ephemeral `okf server ./all` still mounts at
63
- `/b/all/` — no registry, no refs, nothing to reserve.
578
+
64
579
  - The registry validates its file's shape, not just its JSON syntax: a
65
580
  hand-edited entry missing `path` is a usage error naming the file instead of
66
581
  a `TypeError`, and `okf registry --json set <dir>` — a subcommand behind a
67
582
  flag — is a usage error rather than silently listing and exiting 0.
583
+
68
584
  - The graph page's ⌘/Ctrl-K palette opens in **every** mode and reaches a view as
69
585
  well as a bundle. It was wired only behind a hub, so a standalone
70
586
  `okf server ./docs` and every `okf render` page — the two modes most people meet
@@ -74,12 +590,7 @@
74
590
  carries the rail's own icon and label, read from the rail so the two cannot
75
591
  drift. Where there is no hub there is no bundle to switch to, and views become
76
592
  the whole list.
77
- - The inspector's type and tags are filter handles: clicking one focuses the
78
- graph on that facet — the same jump the stats bars make — and clicking it again
79
- clears it. The chip lights while its facet is the only filter in play, which is
80
- exactly when a second click is an undo, so what you see and what the next click
81
- does are the same question. With another filter set it re-focuses instead,
82
- rather than throwing away more than the click put there.
593
+
83
594
  - The inspector's *Links to* / *Linked from* rows read as concepts rather than a
84
595
  wall of accent-coloured text. Each carries its type's dot — the colour that node
85
596
  already wears in the graph beside it — with the type named and the section
@@ -87,15 +598,14 @@
87
598
  title is read. The rows share one panel with hairline dividers and a hover fill:
88
599
  the container carries the click affordance, which leaves colour free to mean type
89
600
  and nothing else.
601
+
90
602
  - The inspector's widen chevron splits the screen instead of taking 70% of it. The
91
603
  panel drag-resizes, so the chevron is a preset rather than a maximum, and burying
92
604
  the graph to read one concept was the wrong thing to default to.
93
- - The graph page answers `?` with a sheet of every keyboard shortcut, reachable
94
- from a rail button too — a shortcut list you can only open with a shortcut helps
95
- whoever needs it least. `/` focuses the current view's search where it has one,
96
- skipping the view that only reads; the sheet is written against the key handler
97
- it documents, since a shortcut list that has drifted is worse than none.
98
- - Fixed: a file the reader could not **open** (permissions) threw its errno out
605
+
606
+ ### Fixed
607
+
608
+ - A file the reader could not **open** (permissions) threw its errno out
99
609
  of the read, so a single locked file took the whole bundle down through every
100
610
  verb that reads one — `lint`, `validate`, `catalog`, `server`, `registry set`
101
611
  — as a backtrace, under an exit code claiming the bundle was non-conformant.
@@ -105,12 +615,13 @@
105
615
  The stderr note reads `skipped N unusable file(s)` — it counts two kinds now,
106
616
  so it names neither and points at `validate`, which names both.
107
617
 
108
- - Fixed: `okf registry del <path>` could delete the wrong bundle. A path that
618
+ - `okf registry del <path>` could delete the wrong bundle. A path that
109
619
  matched no registered directory fell through to a normalized *slug* lookup, so
110
620
  `del ./notes` — naming a local directory — removed whichever entry happened to
111
621
  be slugged `notes`, wherever it pointed, and reported `removed notes` with exit
112
622
  0. An argument with a `/` in it now names a location and only a location.
113
- - Fixed: the registry read trusted stored slugs verbatim while both write paths
623
+
624
+ - The registry read trusted stored slugs verbatim while both write paths
114
625
  normalized, so a hand-typed `"slug": "My Docs"` listed fine but could not be
115
626
  named by `@my-docs`, `registry rename`, or `registry default` — the verbs that
116
627
  could repair it were the ones that could not see it. The read normalizes now,
@@ -118,31 +629,37 @@
118
629
  quote could reach a slug, and with it a DOM XSS in the server's bundle
119
630
  switcher, whose JS escape covered `& < >` but not quotes; the escape now covers
120
631
  quotes too, so the page does not depend on a guarantee three layers away.
121
- - Fixed: `okf lint` bucketed a whitespace-only `type` under its own literal
632
+
633
+ - `okf lint` bucketed a whitespace-only `type` under its own literal
122
634
  heading while `types`/`graph`/`stats` bucketed it as `Untyped`, so two verbs
123
635
  reported type inventories for the same bundle that would not reconcile. §9.2
124
636
  makes a blank type as non-conformant as a missing one; both sides say so now.
125
- - Fixed: `okf search <dir> --fields slug` passed the field guard and returned one
637
+
638
+ - `okf search <dir> --fields slug` passed the field guard and returned one
126
639
  empty object per match with exit 0. Only registry mode labels rows with a slug,
127
640
  so the two modes now declare the shape each actually emits and a path-named
128
641
  search names the fields it does have.
129
- - Fixed: `okf registry set` reported `(0 concepts)` for a bundle whose files it
642
+
643
+ - `okf registry set` reported `(0 concepts)` for a bundle whose files it
130
644
  could not read; it notes the skipped files like every other reading verb.
131
645
  `okf registry rename DOCS handbook` echoed the argv rather than the slug it
132
646
  renamed, naming a bundle that never existed. An unwritable `$OKF_HOME` raised
133
647
  a bare `Errno::EACCES` at exit 1 instead of a usage error at exit 2.
134
- - Fixed: a long path in the Files view's Indexes tab pushed its `map`/`log` badge
648
+
649
+ - A long path in the Files view's Indexes tab pushed its `map`/`log` badge
135
650
  past the right edge of the list. The badge was already pinned and unshrinkable;
136
651
  the filename beside it was the problem — a bare text node, and an anonymous flex
137
652
  item's automatic minimum size is the full width of its text, so it refused to
138
653
  give way and drove the badge out instead. It truncates now, with the full path on
139
654
  hover. Only a wide enough path in a narrow enough pane reached it, which is why
140
655
  it never showed on mobile, where the list runs full width.
141
- - Fixed: the bundle switcher scrolled its own first row out of view as it opened.
656
+
657
+ - The bundle switcher scrolled its own first row out of view as it opened.
142
658
  It rendered, and scrolled the active row into view, while the dialog was still
143
659
  hidden — and a list that is not being displayed measures zero, so the scroll
144
660
  landed arbitrarily.
145
- - Fixed: the `3` (Files) shortcut did nothing once the Indexes tab was open, and
661
+
662
+ - The `3` (Files) shortcut did nothing once the Indexes tab was open, and
146
663
  the palette's Index row would have blanked the page. "Index" is not a view —
147
664
  there is no `#view-index`, only the Files view showing its Indexes tab — so
148
665
  `setView('files')` from that tab early-returned, and `setView('index')` named a
@@ -153,11 +670,14 @@
153
670
 
154
671
  ## [1.7.0] - 2026-07-16
155
672
 
673
+ ### Added
674
+
156
675
  - `okf server`: responses are gzipped when the client accepts it
157
676
  (`Rack::Deflater` at the boot seam). Lossless and transparent — the browser
158
677
  decompresses automatically — and no new dependency, since `Rack::Deflater`
159
678
  ships inside rack. Clients that send no `Accept-Encoding` keep getting
160
679
  identity responses. `okf render`'s static HTML is untouched.
680
+
161
681
  - The agent skill gains a `migrate` verb (`playbooks/migrate.md`): convert
162
682
  existing documentation into a conformant bundle **in place** — frontmatter
163
683
  and reserved files added, bodies kept verbatim (`produce` keeps
@@ -165,11 +685,15 @@
165
685
  inference, the menu playbook now leads with it when a target already holds
166
686
  markdown docs, and pointing any verb at a directory that is not a bundle now
167
687
  suggests `migrate` instead of grinding through the validate errors.
688
+
689
+ ### Changed
690
+
168
691
  - The graph page's link-preview image points at the renamed
169
692
  `okfgem.com/og-demo-v3.png`. The site's OG art was refreshed to drop "Live
170
693
  Graph" from the package formula (it is `Agent Skill + CLI/Lib + Graph` now
171
694
  that `okf render` makes the graph live *or* static), and the filename carries
172
695
  the version so social scrapers pick the new art up.
696
+
173
697
  - The plugin's `/okf:gem` command is now a pass-through shim: it hands its
174
698
  arguments to the okf skill unchanged, making `SKILL.md` the single router
175
699
  for every channel. The routing prose the command used to duplicate had no
@@ -179,6 +703,8 @@
179
703
 
180
704
  ## [1.6.0] - 2026-07-15
181
705
 
706
+ ### Added
707
+
182
708
  - New CLI verb: `okf render <dir> [-o FILE]` — the live graph as one static,
183
709
  self-contained HTML file, so it hosts where a server can't (GitHub Pages, an
184
710
  object store, an attachment). It is the same page `okf server` serves, one
@@ -191,6 +717,7 @@
191
717
  through `DOMPurify.sanitize(marked.parse(...))`, so the trust boundary holds;
192
718
  the trade-off is weight — each body is inlined, so a big bundle makes a big
193
719
  file, and `okf server` stays the choice at scale.
720
+
194
721
  - Official Docker image: `ghcr.io/serradura/okf`, a portable CLI that runs every
195
722
  `okf` command (the graph server included) with no Ruby on the host. It is built
196
723
  from source and published multi-arch (`linux/amd64`, `linux/arm64`) to the
@@ -200,6 +727,8 @@
200
727
 
201
728
  ## [1.5.0] - 2026-07-13
202
729
 
730
+ ### Added
731
+
203
732
  - New CLI verb: `okf search <dir> <term…>` — deterministic ranked retrieval
204
733
  over concept metadata *and bodies*, the browser page's search brought to the
205
734
  CLI. Terms AND together as case-insensitive substrings, or as Ruby regexps
@@ -209,14 +738,17 @@
209
738
  and carry a bounded context snippet, so "which concept covers X?" costs a
210
739
  few rows instead of a body read. Advisory read: exit 0 even with no matches.
211
740
  Deliberately not fuzzy — the consuming agent is the fuzzy layer.
741
+
212
742
  - The skill learns retrieval as a first-class verb: a new `search` playbook
213
743
  (progressive disclosure end to end: ingest `okf index`, decide where to
214
744
  look, cut across with `okf search`, read only the winning bodies),
215
745
  search-aware routing in SKILL.md and the menu/consume playbooks, and
216
746
  `/okf:gem search <query>` first in the Claude Code plugin's routing.
747
+
217
748
  - Retrieval eval in the suite: the progressive path (index skeleton → search →
218
749
  one body) must answer a planted question in under 25% of the bytes of the
219
750
  full graph dump, so the playbook's economics stay true by construction.
751
+
220
752
  - Graph server: the authored layer joins the UI. The Files view carries two
221
753
  tabs — **Files** (the per-directory concept groups, foldable) and
222
754
  **Indexes** (the log first, as the chronological index, then every
@@ -230,19 +762,27 @@
230
762
  is fetched fresh on every read, so a just-appended entry shows without a
231
763
  restart. A reserved file's "Open in graph" jumps to its folder in the file
232
764
  tree, map in the inspector. New `/index` and `/log` endpoints back it all.
765
+
233
766
  - Graph server: Mermaid diagrams in concept bodies are click-to-inspect. A
234
767
  click (or tap) opens the diagram full screen — drag to pan, wheel or pinch
235
768
  to zoom, buttons and double-click reset, Esc closes — powered by
236
769
  [Panzoom](https://github.com/timmywil/panzoom), lazy-loaded from the CDN
237
770
  exactly like Mermaid itself.
771
+
772
+ ### Changed
773
+
238
774
  - The Claude Code plugin's `/okf:gem` command now weighs the shape of a
239
775
  free-form ask: a question about what the bundle knows routes through the
240
776
  search playbook and answers from retrieved concepts instead of guessing.
777
+
241
778
  - Skill efficiency audit: every playbook now takes the CLI's lean paths.
242
779
  `maintain` hunts affected concepts with `okf search` and pulls edges via
243
780
  `graph --json --minimal` instead of the full-body dump, `menu` reads the
244
781
  plain-text reports it only scans, and SKILL.md pins the discipline as a
245
782
  rule: skeleton first, bodies last.
783
+
784
+ ### Fixed
785
+
246
786
  - Docs: the CLI reference's server section now reflects the DOMPurify
247
787
  sanitization that landed in 1.1.0 (it still said bodies render unsanitized),
248
788
  and the server page's link-preview image points at the renamed
@@ -250,6 +790,8 @@
250
790
 
251
791
  ## [1.4.0] - 2026-07-12
252
792
 
793
+ ### Changed
794
+
253
795
  - Graph server UX round. Selecting a node now makes one camera move instead of
254
796
  two (the pan used to race the opening panel and the debounced canvas resize,
255
797
  a dizzying double movement; rapid clicks also queued animations — both fixed).
@@ -268,14 +810,19 @@
268
810
 
269
811
  ## [1.3.0] - 2026-07-12
270
812
 
813
+ ### Added
814
+
271
815
  - The graph server page now emits link-preview metadata: Open Graph and Twitter
272
816
  Card tags with a social image, plus `theme-color` and `color-scheme`, so a
273
817
  shared `okf server` URL unfurls as a proper card in chat and social apps.
818
+
274
819
  - Docs: a themed README hero (light and dark), a GitHub social preview image,
275
820
  and Website / Live demo / Claude Code plugin links.
276
821
 
277
822
  ## [1.2.0] - 2026-07-12
278
823
 
824
+ ### Added
825
+
279
826
  - Claude Code plugin. The repository now doubles as a plugin marketplace:
280
827
  `/plugin marketplace add serradura/okf-gem`, then `/plugin install okf@okfgem`.
281
828
  The plugin carries the canonical skill (a generated copy; `rake plugin:sync`
@@ -299,12 +846,8 @@
299
846
 
300
847
  ## [1.1.0] - 2026-07-12
301
848
 
302
- - The graph server now sanitizes every concept body before rendering it. The
303
- page runs marked's HTML output through [DOMPurify](https://github.com/cure53/DOMPurify)
304
- (loaded from the same CDN as Cytoscape and marked) on the way to the DOM, so a
305
- bundle carrying active content in a Markdown body can no longer script the
306
- viewer. Inlined graph data was already escaped through `json_for_script`; this
307
- closes the other path.
849
+ ### Changed
850
+
308
851
  - `require "okf"` now loads the pure library only. The two argv-facing shells —
309
852
  `OKF::CLI` and the `OKF::Skill` installer — load on demand, from `exe/okf` or
310
853
  an explicit `require "okf/cli"` / `require "okf/skill"`. `optparse` moves with
@@ -312,28 +855,45 @@
312
855
  in-memory model and on-disk handles no longer pulls in the command-line
313
856
  machinery. The CLI itself is unchanged.
314
857
 
858
+ ### Security
859
+
860
+ - The graph server now sanitizes every concept body before rendering it. The
861
+ page runs marked's HTML output through [DOMPurify](https://github.com/cure53/DOMPurify)
862
+ (loaded from the same CDN as Cytoscape and marked) on the way to the DOM, so a
863
+ bundle carrying active content in a Markdown body can no longer script the
864
+ viewer. Inlined graph data was already escaped through `json_for_script`; this
865
+ closes the other path.
866
+
315
867
  ## [1.0.0] - 2026-07-12
316
868
 
317
869
  Initial release.
318
870
 
871
+ ### Added
872
+
319
873
  - `OKF::Concept` / `OKF::Bundle`: pure in-memory model of an OKF v0.1 bundle,
320
874
  buildable straight from data (no disk) with link, citation, and markdown
321
875
  round-trip primitives.
876
+
322
877
  - `OKF::Bundle::Validator`: the spec §9 conformance gate (hard errors) with the
323
878
  spec's soft guidance reported as warnings — broken cross-links are tolerated,
324
879
  as §5.3 requires.
880
+
325
881
  - `OKF::Bundle::Linter`: advisory curation-quality report across reachability,
326
882
  backlog, completeness, freshness, provenance, and hygiene, with `--json` as a
327
883
  machine substrate.
884
+
328
885
  - `OKF::Bundle::Graph`: the knowledge graph (nodes, edges, type/tag indexes) at
329
886
  selectable fidelity.
887
+
330
888
  - On-disk handles: `OKF::Bundle::Folder`, `OKF::Bundle::Reader`,
331
889
  `OKF::Bundle::Writer` (atomic, validate-before-publish), and
332
890
  `OKF::Concept::File`.
891
+
333
892
  - `OKF::Server::App`: the interactive graph as a mountable Rack app — five views
334
893
  (graph, catalog, files, tags, stats) with type/area/tag filtering throughout,
335
894
  bodies fetched live from disk — served by a built-in WEBrick runner
336
895
  (`okf server`).
896
+
337
897
  - `okf` CLI: `validate`, `lint`, `loose`, and `graph`, plus the read views as
338
898
  text — `index`, `catalog`, `files`, `tags`, `types`, `stats` — at full parity
339
899
  with the browser: every list view narrows with `--type`/`--area`/`--tag`
@@ -341,6 +901,7 @@ Initial release.
341
901
  `tags --by type|area` regroups the tag index per concept dimension with
342
902
  within-group counts — the tag-curation view. `server` boots the graph page;
343
903
  `skill` installs the companion skill.
904
+
344
905
  - `okf index`: a read view over the progressive-disclosure layer (spec §6) — one
345
906
  entry per directory that holds concepts or carries an `index.md`, root first,
346
907
  with its authored index body (frontmatter stripped), a type/tag rollup over the
@@ -348,19 +909,35 @@ Initial release.
348
909
  directory with concepts but no `index.md` has its listing synthesized (§6 permits
349
910
  it) and is flagged. `--area` (repeatable), `--no-body`, and `--json`; advisory,
350
911
  always exit 0. Backed by the pure `OKF::Bundle#directory_index`.
912
+
351
913
  - JSON output is **compact by default** across every emitting verb (the
352
914
  token-efficient machine substrate, matching the server); `--pretty` indents it
353
915
  for reading and implies `--json`. JSON semantics are identical either way — only
354
916
  whitespace differs — so any parser is unaffected.
917
+
355
918
  - JSON property projection on the list views: `index`, `catalog`, and `files`
356
919
  take `--fields a,b` (emit only these properties) or `--except a,b` (emit all but
357
920
  these), so an agent never pays tokens for fields it will not read. The flags are
358
921
  mutually exclusive, imply `--json`, match property names case-insensitively, and
359
922
  reject an unknown name (exit 2) listing the valid ones; `okf index --no-body` is
360
923
  shorthand for dropping the `body` field.
924
+
361
925
  - Bundled companion agent skill (`okf skill <dest>`): SKILL.md carrying the
362
926
  judgment (the CLI surface stays self-describing via `--help`) — including the
363
927
  orient-before-you-read protocol and the CLI/judgment boundary — the OKF v0.1
364
928
  spec, authoring and CLI references (tag-vocabulary curation, the SPEC-section
365
929
  map, the closeout gate), and concept/index/log templates.
930
+
366
931
  - Runs on Ruby >= 2.4 with two runtime dependencies: rack and webrick.
932
+
933
+ [1.10.0]: https://github.com/serradura/okf-gem/compare/v1.9.0...v1.10.0
934
+ [1.9.0]: https://github.com/serradura/okf-gem/compare/v1.8.0...v1.9.0
935
+ [1.8.0]: https://github.com/serradura/okf-gem/compare/v1.7.0...v1.8.0
936
+ [1.7.0]: https://github.com/serradura/okf-gem/compare/v1.6.0...v1.7.0
937
+ [1.6.0]: https://github.com/serradura/okf-gem/compare/v1.5.0...v1.6.0
938
+ [1.5.0]: https://github.com/serradura/okf-gem/compare/v1.4.0...v1.5.0
939
+ [1.4.0]: https://github.com/serradura/okf-gem/compare/v1.3.0...v1.4.0
940
+ [1.3.0]: https://github.com/serradura/okf-gem/compare/v1.2.0...v1.3.0
941
+ [1.2.0]: https://github.com/serradura/okf-gem/compare/v1.1.0...v1.2.0
942
+ [1.1.0]: https://github.com/serradura/okf-gem/compare/v1.0.0...v1.1.0
943
+ [1.0.0]: https://github.com/serradura/okf-gem/releases/tag/v1.0.0