okf 1.7.0 → 1.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64b163bdb5b0e234c879f6d5424279b8b329a797292b6322af0379741e06565c
4
- data.tar.gz: 816d6456345e3cd6e507be229b757f8143987ba2c98ffb1bcc26d45c19bdfba2
3
+ metadata.gz: 24f81d695477c347d78e44d89131ebdb7c5eec4f380c2f8ba7596292bf043e39
4
+ data.tar.gz: 14bc57ccda962eb9abfbcb9906e95c74b9fb808d2e46850dbfec234aafb933f7
5
5
  SHA512:
6
- metadata.gz: 9ee06f232154bf609ce24882447de92f26e8a33ebddd593ff462bb83ad993d4f79c797a0c88f7647cbea9bba05b190a0b1da127e01e53d99a9b5e99d952eb76b
7
- data.tar.gz: 69a26c944a1a393f7bcb58cdfd554ed1991c0617fe3fe9d7e138af7dd9847763df947d393a9cea1ff99182430b5f79b488bde78a79f0915740beab088501c4bd
6
+ metadata.gz: d746db4e46fd17179f8eb82275749a1a2d1077c58e0d892d2e694dcd15f052e40dfe5526e17656ba2b21adb1fc63d1fa4885d06dce44c9edb584028445703444
7
+ data.tar.gz: 5f41214c5b0829c6d50a99cde236d5b768256d062ea6a6faa13270bc5ab3c2416fc6ff562be34c5c7067157da36fbd91f547db4120950dc2e9a8b6376a551f8c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,373 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] - 2026-07-19
4
+
5
+ - **`okf search` gains an opt-in full-text index engine.** `--engine index` — and
6
+ `--fuzzy`, which implies it — routes to
7
+ [minifts](https://github.com/serradura/minifts), the pure-Ruby port of the same
8
+ MiniSearch build the graph page loads. It is the gem's third runtime
9
+ dependency, admitted because it costs the footprint nothing the first two were
10
+ chosen to protect: no native extension, no dependency tree of its own, the same
11
+ Ruby 2.4 floor. Three things it adds, and nothing else does:
12
+ - **BM25+ relevance ranking**, where the default scores by summed field weight;
13
+ - **`--fuzzy`** — typo tolerance at edit distance `0.2 × term length`, the
14
+ browser's own setting. Search stays exact unless you ask;
15
+ - **parity with the graph page**, which runs the same MiniSearch build, so the
16
+ two rank identically when the index is named.
17
+ - **The default search is unchanged** — literal, case-insensitive substring
18
+ matching over the same fields with the same weights as 1.8.0. The index is
19
+ opt-in rather than default because a one-shot CLI builds an index, asks one
20
+ question, and exits: end to end, **3.00 s against 0.24 s at 1,000 concepts**,
21
+ the build accounting for ~95% of that. The ~44–56× per-query throughput that
22
+ recommends minifts is the right measure for a long-lived index — a page, a
23
+ server — and the wrong one for a process that exits. A cached prebuilt index is
24
+ what would change that arithmetic.
25
+ - **Know what the index costs before naming it.** Its tokenizer splits on
26
+ punctuation, so `customer_id` becomes `customer` + `id` and `7.2.0` becomes
27
+ `7`, `2`, `0`; an infix (`ustomer`) finds nothing; and a backtick is Unicode
28
+ `Sk` rather than punctuation, so a word inside a code span indexes as
29
+ `` `minifts` `` and the query `minifts` does not match it — 409 such tokens
30
+ on this repo's own bundle. Ranking does not rescue it: BM25 normalizes by
31
+ field length, so a short concept dense in `7`, `2` and `0` can outrank the one
32
+ that actually says `7.2.0`. The default has none of these, because raw-text
33
+ matching has no tokenizer.
34
+ - **`--engine NAME` picks the engine outright**, for the case a capability flag
35
+ cannot express: a matching *model* requires nothing, so no flag selects one.
36
+ Naming an engine that cannot do what was also asked is a usage error naming one
37
+ that can (`--engine index -e` → *try --engine scan*), and an unknown name lists
38
+ what is available. `--help` reads the registry, so an addon's engine appears
39
+ without the CLI knowing it exists.
40
+ - **Search engines are adapters.** `OKF::Bundle::Search` became a facade over N
41
+ engines instead of one class with a `regexp ? scan : index` branch. The facade
42
+ keeps everything that defines a result — documents, the row and its key order,
43
+ the snippet, the sort — and an engine answers only which documents match, how
44
+ well, and where. The built-ins are `Search::Scan` (raw text, the default,
45
+ `regexp`) and `Search::Index` (minifts, `fuzzy`/`prefix`).
46
+ - **Selection is by capability when the query requires one** — `--fuzzy`
47
+ requires `:fuzzy`, so it routes to the index without naming it — and that
48
+ routing prints **nothing**: no note, no header change, no new JSON key.
49
+ - **`Search.register` is a published extension point** — append-only,
50
+ idempotent by id, capabilities checked against a fixed vocabulary. This is
51
+ the seam a future SQLite/FTS5 addon plugs into; no addon code ships here.
52
+ - **A shared conformance suite replaces the "kernel is the oracle" rule**,
53
+ which multiple engines made impossible: the index and the scan disagree about
54
+ match sets by design, so neither can be the oracle. Every registered engine
55
+ runs the same contract, with capability-gated blocks for its own semantics,
56
+ and a registered engine with no conformance class fails the suite.
57
+ - **Cross-bundle search ranks one corpus under `--engine index`.** BM25 prices a
58
+ term by how rare it is, so ranking each bundle separately and interleaving the
59
+ lists would produce a ranking that looks sorted and compares nothing; the
60
+ searched bundles are indexed together instead. The visible consequence, under
61
+ that engine only: a score is relative to the whole answer, so the same concept
62
+ scores lower searched beside other bundles than alone. The default's scores are
63
+ absolute and need no such treatment.
64
+
65
+ - **The graph can draw the index layer, under any layout.** The §6 map was
66
+ visible only inside file-tree mode, where a folder node stood in for a
67
+ directory's `index.md`. **Show indexes** makes it a layer: each map is a tile
68
+ edged to the concepts it lists and the maps below it, dressed by the same
69
+ selector as file-tree mode's folder node, because the two are the same thing
70
+ twice over — clicking either opens that directory's `index.md`. Both are accent
71
+ squares with dashed edges into them, so colour separates *kinds* rather than
72
+ modes: a directory is not a concept and no longer reads as one. Authorship shows
73
+ as form — solid where an author wrote a map, hollow and dashed where the bundle
74
+ only implies one — so the toggle reads as curation as much as navigation.
75
+ - **Moving between the modes lands in one click.** Tearing the layer down ran
76
+ its own layout while file-tree mode ran `breadthfirst` a beat later, two
77
+ layouts racing the same canvas; and because the layer is fetched, a promise
78
+ resolving after a mode change could land inside file-tree mode. A `relayout`
79
+ flag settles the first, a per-toggle ticket the second.
80
+ - **File-tree mode disables the toggle** rather than doubling the folders it
81
+ already draws.
82
+ - **One label on every file's graph button.** It read "Explore the knowledge
83
+ graph" on the root index and "Open core/ in graph" on a nested one, which made
84
+ a single action look like three. The question is the same whatever is open, so
85
+ the label is too — and it lives in the markup, where it cannot go stale.
86
+ - **Opening a map from the reader keeps the reader's graph.** It forced
87
+ file-tree mode, discarding whatever layout was running, and dimmed the canvas
88
+ to the map's immediate neighbours. It now switches the *layer* on rather than
89
+ the *mode* and leaves the layout alone. A reader already in file-tree mode
90
+ stays there.
91
+ - **Selecting anything emphasises it the same way.** A concept dimmed the graph
92
+ to its neighbourhood, a map did nothing at all, and a folder node did nothing
93
+ either — three meanings for one gesture. One `focusNode` now serves all three.
94
+ - **Drawn, never modelled.** `index.md` is reserved, so these nodes are built
95
+ from `/index` straight onto the canvas; `NODES`, `/catalog` and the type and
96
+ tag indexes never learn they exist. Filters pass them over — a map has no type
97
+ or tags — but a map whose concepts are all filtered away leaves with them.
98
+ - **Collapsing the root folds the file list away** on phones and tablets, where
99
+ the list is stacked on top of the reader and closing the root otherwise left a
100
+ single row above a column of nothing. Reopening the list undoes that collapse,
101
+ so it is one gesture rather than two states to dig out of — the fold remembers
102
+ *why* it happened, and a list folded because a file was opened comes back
103
+ exactly as it was left.
104
+ - **The bundle names its own root.** `(root)` and `/` are what a filesystem calls
105
+ it, not what a reader does. The tree's root row, file-tree mode's root node, the
106
+ index layer's root map and the inspector's directory map now all carry the name
107
+ the page header already shows, `--title` included. `areaOf` keeps its own
108
+ `(root)`: that is the area vocabulary `okf stats --by area` and `tags --by area`
109
+ print, not a UI label.
110
+ - **The Indexes tab dissolves into the file tree.** The authored layer lived on a
111
+ second tab as a flat list of paths, which put a directory's own map somewhere
112
+ other than the directory. `index.md` and `log.md` are rows now, at the top of
113
+ the folder they document, and **Indexes only** is a toggle over the same tree —
114
+ same rows, fewer of them, structure intact. The toggle yields only when it would
115
+ hide what was just opened — a map stays under it, a concept releases it — so
116
+ browsing the authored layer no longer destroys the list being browsed. A log
117
+ offers no graph button at all: it is a chronology, not a place in the graph, and
118
+ the button had been opening the root index's node.
119
+ Narrowed, a folder owns exactly one row, so the row stands where the folder
120
+ header stood — at that folder's depth, carrying the path — rather than nesting
121
+ a single child under a header.
122
+ - **The rail's Index becomes an action, not a fake view.** It had no
123
+ `#view-index` behind it — the files view showing its other tab — so
124
+ `activeRail()` answered a question of view *and* tab. The shortcut stays,
125
+ opening the root map through the same `readIndex()` the first-visit note
126
+ uses; `activeRail()` answers with the view it lands on, so Files highlights
127
+ and nothing invents a place for Index to be. `?view=index` resolves to the
128
+ same action.
129
+ - **Fixed on the way:** the reader header rendered empty — an unlabelled badge
130
+ and a graph button pointing nowhere — whenever no file was open, because
131
+ `.fp-head{display:flex}` outranks the UA sheet's `[hidden]{display:none}`.
132
+ - **A first-visit note tells a newcomer the index exists.** The `index.md` an
133
+ author wrote to be read first was reachable only by finding the Indexes tab and
134
+ clicking a row, so a reader meeting a bundle for the first time met unlabelled
135
+ dots with no way in. The page still opens on the graph — it is what makes a
136
+ bundle legible at a glance, at every width — and a dismissible note at the
137
+ bottom now says what the picture is, how to touch it, and where the index is.
138
+ **Read the index** goes straight there; the dismissal is remembered.
139
+ - **It absorbed the old mobile-only tip** rather than stacking a second banner
140
+ under it, and it is written for a finger throughout, since a phone is where a
141
+ first-time reader is least oriented.
142
+ - **The wording follows the device on two gates, not one.** What a reader does
143
+ follows `(pointer:coarse)` — a touch tablet in landscape is wider than 768px
144
+ and still taps; a narrow desktop window is narrower and still clicks. What a
145
+ reader can reach follows `(max-width:768px)`, because that is when the rail
146
+ collapses behind `☰`. Short viewports tighten; short *and* wide puts the
147
+ question beside the button, taking a landscape phone from half the screen to
148
+ under a third.
149
+ - **A second note points at `☰`** on compact layouts only, anchored under the
150
+ button it names rather than at the bottom of the screen. It fires on leaving
151
+ the graph by any route, so dismissing the first note does not cost it, and
152
+ opening `☰` answers it — but only once it is on screen, since `☰` is the only
153
+ way off the graph there and the first tap always comes first.
154
+ - **Deep links are unaffected**, and `?select=`/`#hash` now switch to the graph
155
+ before selecting, since the page can be standing elsewhere when they are read.
156
+ - **The file tree nests.** Directories were a sorted list of full paths, which
157
+ made `core` and `core/configurations` read as two unrelated folders and left
158
+ the shape of a bundle invisible. Each row is now one path segment indented by
159
+ depth, folders before files, and collapsing a folder takes its subtree with it.
160
+ A directory holding nothing but directories still renders, so the chain to its
161
+ children never breaks.
162
+ - **"Collapse all" folds into the root, not over it** — everything inside the
163
+ root closes and the root stays open, so the click leaves the top-level
164
+ folders standing instead of a single `(root)` row. Unfolding clears the whole
165
+ set, root included, so a root closed by hand is still reversible from there.
166
+
167
+ - The graph page's search box grows a full-text index. One MiniSearch index —
168
+ lazy-loaded from the CDN on first search, pinned to the `7.2.0` the Ruby
169
+ MiniSearch port tracks so an `okf search --engine index` result and the
170
+ browser's rank identically — now backs the graph, catalog and files views: ranked, multi-term
171
+ (`AND`), prefix (as-you-type) and typo-tolerant, over title, id, type, tags and
172
+ **description** — plus each concept's **body** wherever the page already holds
173
+ it (`okf render` bakes every body in, so a static file searches bodies offline;
174
+ the live server keeps bodies lazy, so its index stays metadata-only until a
175
+ backend body index arrives). The graph could not be searched by a leaf's
176
+ description before; now it can. The Files view's **Indexes** tab gets its own
177
+ full-text index too, over each `index.md`/`log.md`'s body — not just its
178
+ filename. Until an index loads — or if the CDN is unreachable — each view falls
179
+ back to its own substring filter, so the box is never dead.
180
+ - The Files tree's folder collapse works during a search. An active search or
181
+ type/tag filter used to force every folder open, so fold clicks did nothing;
182
+ folders now honor their collapsed state always (a collapsed group still shows
183
+ its header, so a match is never hidden). A **fold/unfold-all** control in the
184
+ Files tab header collapses or expands every visible group at once.
185
+ - Clustering no longer leaves phantom empty boxes. When a filter or a search hid
186
+ every concept in an area, the cluster's labelled box lingered as an empty
187
+ rectangle; the box now hides when no child survives and returns when one does —
188
+ the same rule the fit already used to leave stale boxes out of view, now
189
+ applied to what is drawn.
190
+ - `Esc` clears the graph selection. A dense graph leaves almost no empty canvas
191
+ to click for deselecting; `Esc` now drops the highlight (and lets the URL hash
192
+ forget the node) the same way tapping empty canvas does.
193
+ - A title-less concept now wears one name in every view. `catalog` and the §6
194
+ index listing fell back a concept with no `title` to its full id — `area/thing`
195
+ — while the graph node fell back blank-aware to the basename — `thing` — so the
196
+ same concept answered to two labels across two views of one bundle, and a
197
+ `title: ""` slipped past the nil-only `||` to catalog as an empty string. Both
198
+ now fall back the graph's way (`File.basename`, blank-aware), so the label is
199
+ the same wherever the concept appears.
200
+ - `okf render` stops baking a redundant description map. The static page derived
201
+ its `/node/meta` fragments from a separate `meta` payload that held nothing but
202
+ each concept's description, HTML-escaped — data the embedded `catalog` already
203
+ carries raw. The page now escapes the catalog's description on the client (the
204
+ same escape the server applies at `/node/meta`), so the `meta` key leaves the
205
+ baked payload and the description lives in one place. Both XSS guards are
206
+ unchanged; `okf server` is untouched.
207
+ - The bare not-a-directory error now teaches the registry grammar. A verb given
208
+ a target that is neither a directory nor an `@ref` moved from
209
+ `error: <arg> is not a directory` to
210
+ `… is not a directory or a registry ref (@slug names a registered bundle, @ the default; okf registry list)`,
211
+ so a consumer who typed a query or a bad path meets `@slug` addressing at the
212
+ error instead of hunting for it. (`@all` stays out of the message — it is
213
+ `search`'s alone, and the error seam is shared by every verb.)
214
+ - The bundled skill teaches `@slug` as a first-class target and stops probing for
215
+ the CLI. `SKILL.md`'s "Which directory?" is now "Which target?" — a leading `@`
216
+ is a registry ref routed straight to `okf <verb> @slug`, with the fallback
217
+ "no bundle in the cwd → `okf registry list`" — and the consume/search playbooks
218
+ name `@slug` in their orientation steps. The per-run `command -v okf` presence
219
+ probe is gone: run the verb, and treat a shell `command not found` as the only
220
+ signal to install, so the common case pays no guard round.
221
+
222
+ ## [1.8.0] - 2026-07-17
223
+
224
+ - A persistent bundle registry and a multi-bundle hub. `okf registry`
225
+ (list / set / del / default / rename) keeps a per-user list in a plain JSON
226
+ file at `$OKF_HOME/registry.json` (default `~/.okf`), and `okf server` reads
227
+ its mode from its arguments: one dir is the classic single bundle at `/`,
228
+ several mount ephemerally behind a hub at `/b/<slug>/`, none serves the whole
229
+ registry with its default at `/`. Behind a hub the page gains a bundle
230
+ switcher (⌘/Ctrl-K, or the rail button), `/b/` is a browsable index, and an
231
+ unknown slug 404s as a page with a way home. The hub reads its bundles at
232
+ boot — restart after registry changes.
233
+ - The registry is **ordered, and the first entry still on disk is the default** —
234
+ the bundle a bare `okf server` opens at `/`. `okf registry default @slug` moves
235
+ that entry to the front, and `okf registry set --default` registers straight to
236
+ it; until you do either, the first bundle you registered is the default. Nothing
237
+ else has to be maintained: a rename keeps its position, a `del` promotes
238
+ whatever is next, and the file cannot name a default that is not there. A
239
+ vanished directory is stepped over rather than starred — `registry list`'s `*`
240
+ always names the bundle `/` opens — and `registry default @slug` refuses one,
241
+ just as `registry set` refuses to register a directory that is not there.
242
+ - `$OKF_HOME` is the single lever on which registry a command reads: set it and
243
+ every verb follows, from `okf registry list` to an `@slug` on `okf lint`. It
244
+ names exactly one registry, with no fallback to `~/.okf` behind it, and an
245
+ empty value counts as unset rather than planting `registry.json` in the
246
+ current directory.
247
+ - `@slug`: wherever a command takes a `<dir>`, `@slug` names a registered
248
+ bundle and bare `@` the registry default — `okf lint @handbook`,
249
+ `okf render @ -o graph.html`. A slug is normalized like registration was
250
+ (`@One` finds the bundle from dir `One`) but never to a placeholder, so
251
+ `@***` is a bad ref rather than a silent hit. An unknown slug, a
252
+ registered-but-gone directory, or a malformed registry file is a usage error
253
+ naming the registry file and the next move. A hub built from `@slug`s
254
+ (`okf server @a @b`) mounts each bundle under its registered slug, the first
255
+ at `/`, and a registered slug reserves its mount ahead of any plain
256
+ directory that shares the name.
257
+ - `@slug` is spelled where it is used, not just where it is explained, and it is
258
+ the one token — `okf help`'s map (`lint <dir|@slug>`) and each command's own
259
+ banner show it the same way, with a note under the map defining it: the slug
260
+ from `okf registry set`, or bare `@` for the default. It was documented once,
261
+ in prose at the foot of `okf help`, past where a reader who already knows the
262
+ verb ever looks, so seventeen surfaces took a registered bundle while
263
+ advertising a bare `<dir>`. The registry-editing verbs — `del`, `default`,
264
+ `rename` — take the slug bare (no `@`) or as an `@slug`; the read verbs need
265
+ the `@`, since a bare word there is a path.
266
+ - `okf <command> -h` prints that command's own banner and flags. Help now answers
267
+ on stdout with an exit code like every other command: it was OptionParser's
268
+ officious handler, which printed past the caller's injected streams and ended
269
+ the process with `exit` instead of returning a status.
270
+ - `okf search` spans bundles: several leading `@slug`s, or `@all` for every
271
+ registered one. Rankings merge across bundles with every row labeled by its
272
+ bundle's slug (a `bundles` list and a per-match `slug` key in the JSON).
273
+ Asking for everything tolerates gaps — `@all` skips a bundle whose directory
274
+ has vanished, with a note — while naming one insists on it, and `@all @docs`
275
+ simply dedupes. `all` is reserved *in the registry*, on all three ways in: a
276
+ directory named `all/` registers as `all-2`, `--as all` is refused, and a row
277
+ already claiming the name in the registry file — hand-typed, or written before
278
+ the name was reserved — is read as `all-2`, so the reservation never strands a
279
+ registry it inherited. An ephemeral `okf server ./all` still mounts at
280
+ `/b/all/` — no registry, no refs, nothing to reserve.
281
+ - The registry validates its file's shape, not just its JSON syntax: a
282
+ hand-edited entry missing `path` is a usage error naming the file instead of
283
+ a `TypeError`, and `okf registry --json set <dir>` — a subcommand behind a
284
+ flag — is a usage error rather than silently listing and exiting 0.
285
+ - The graph page's ⌘/Ctrl-K palette opens in **every** mode and reaches a view as
286
+ well as a bundle. It was wired only behind a hub, so a standalone
287
+ `okf server ./docs` and every `okf render` page — the two modes most people meet
288
+ first — had no palette at all. Bundles still lead it and own the empty box,
289
+ because switching bundles is what it is for; views wait until what you type
290
+ reaches one, arrive underneath, and stay muted until the cursor does. Each view
291
+ carries the rail's own icon and label, read from the rail so the two cannot
292
+ drift. Where there is no hub there is no bundle to switch to, and views become
293
+ the whole list.
294
+ - The inspector's type and tags are filter handles: clicking one focuses the
295
+ graph on that facet — the same jump the stats bars make — and clicking it again
296
+ clears it. The chip lights while its facet is the only filter in play, which is
297
+ exactly when a second click is an undo, so what you see and what the next click
298
+ does are the same question. With another filter set it re-focuses instead,
299
+ rather than throwing away more than the click put there.
300
+ - The inspector's *Links to* / *Linked from* rows read as concepts rather than a
301
+ wall of accent-coloured text. Each carries its type's dot — the colour that node
302
+ already wears in the graph beside it — with the type named and the section
303
+ counted, so the column answers what kind of neighbourhood a concept has before a
304
+ title is read. The rows share one panel with hairline dividers and a hover fill:
305
+ the container carries the click affordance, which leaves colour free to mean type
306
+ and nothing else.
307
+ - The inspector's widen chevron splits the screen instead of taking 70% of it. The
308
+ panel drag-resizes, so the chevron is a preset rather than a maximum, and burying
309
+ the graph to read one concept was the wrong thing to default to.
310
+ - The graph page answers `?` with a sheet of every keyboard shortcut, reachable
311
+ from a rail button too — a shortcut list you can only open with a shortcut helps
312
+ whoever needs it least. `/` focuses the current view's search where it has one,
313
+ skipping the view that only reads; the sheet is written against the key handler
314
+ it documents, since a shortcut list that has drifted is worse than none.
315
+ - Fixed: a file the reader could not **open** (permissions) threw its errno out
316
+ of the read, so a single locked file took the whole bundle down through every
317
+ verb that reads one — `lint`, `validate`, `catalog`, `server`, `registry set`
318
+ — as a backtrace, under an exit code claiming the bundle was non-conformant.
319
+ §9's best-effort promise covers it now, the same as frontmatter that will not
320
+ parse: the file is skipped, noted on stderr, and reported by `validate` under
321
+ §9.1 naming the file and the errno. One bad file never breaks the rest.
322
+ The stderr note reads `skipped N unusable file(s)` — it counts two kinds now,
323
+ so it names neither and points at `validate`, which names both.
324
+
325
+ - Fixed: `okf registry del <path>` could delete the wrong bundle. A path that
326
+ matched no registered directory fell through to a normalized *slug* lookup, so
327
+ `del ./notes` — naming a local directory — removed whichever entry happened to
328
+ be slugged `notes`, wherever it pointed, and reported `removed notes` with exit
329
+ 0. An argument with a `/` in it now names a location and only a location.
330
+ - Fixed: the registry read trusted stored slugs verbatim while both write paths
331
+ normalized, so a hand-typed `"slug": "My Docs"` listed fine but could not be
332
+ named by `@my-docs`, `registry rename`, or `registry default` — the verbs that
333
+ could repair it were the ones that could not see it. The read normalizes now,
334
+ leaving an already-usable slug untouched. This also removes the only way a
335
+ quote could reach a slug, and with it a DOM XSS in the server's bundle
336
+ switcher, whose JS escape covered `& < >` but not quotes; the escape now covers
337
+ quotes too, so the page does not depend on a guarantee three layers away.
338
+ - Fixed: `okf lint` bucketed a whitespace-only `type` under its own literal
339
+ heading while `types`/`graph`/`stats` bucketed it as `Untyped`, so two verbs
340
+ reported type inventories for the same bundle that would not reconcile. §9.2
341
+ makes a blank type as non-conformant as a missing one; both sides say so now.
342
+ - Fixed: `okf search <dir> --fields slug` passed the field guard and returned one
343
+ empty object per match with exit 0. Only registry mode labels rows with a slug,
344
+ so the two modes now declare the shape each actually emits and a path-named
345
+ search names the fields it does have.
346
+ - Fixed: `okf registry set` reported `(0 concepts)` for a bundle whose files it
347
+ could not read; it notes the skipped files like every other reading verb.
348
+ `okf registry rename DOCS handbook` echoed the argv rather than the slug it
349
+ renamed, naming a bundle that never existed. An unwritable `$OKF_HOME` raised
350
+ a bare `Errno::EACCES` at exit 1 instead of a usage error at exit 2.
351
+ - Fixed: a long path in the Files view's Indexes tab pushed its `map`/`log` badge
352
+ past the right edge of the list. The badge was already pinned and unshrinkable;
353
+ the filename beside it was the problem — a bare text node, and an anonymous flex
354
+ item's automatic minimum size is the full width of its text, so it refused to
355
+ give way and drove the badge out instead. It truncates now, with the full path on
356
+ hover. Only a wide enough path in a narrow enough pane reached it, which is why
357
+ it never showed on mobile, where the list runs full width.
358
+ - Fixed: the bundle switcher scrolled its own first row out of view as it opened.
359
+ It rendered, and scrolled the active row into view, while the dialog was still
360
+ hidden — and a list that is not being displayed measures zero, so the scroll
361
+ landed arbitrarily.
362
+ - Fixed: the `3` (Files) shortcut did nothing once the Indexes tab was open, and
363
+ the palette's Index row would have blanked the page. "Index" is not a view —
364
+ there is no `#view-index`, only the Files view showing its Indexes tab — so
365
+ `setView('files')` from that tab early-returned, and `setView('index')` named a
366
+ view that does not exist. The keyboard and the palette each re-implemented what
367
+ the rail button already did right; both now click the rail item, so the one
368
+ correct path is the only one, and the palette's `current` badge reads the same
369
+ active-tab answer the rail's own highlight does.
370
+
3
371
  ## [1.7.0] - 2026-07-16
4
372
 
5
373
  - `okf server`: responses are gzipped when the client accepts it
data/README.md CHANGED
@@ -35,7 +35,7 @@ The package, end to end:
35
35
  <p align="center">
36
36
  <picture>
37
37
  <source media="(prefers-color-scheme: dark)" srcset=".github/overview-dark.png">
38
- <img src=".github/overview-light.png" width="760" alt="The package: the Agent Skill (your coding agent authors and curates, you stay the editor) writes and maintains the bundle, a folder of Markdown + YAML in your repo where one concept is one file and links between files are the knowledge graph. The bundle is read by the CLI/Lib (validate: legal OKF per section 9; lint: well-curated and fresh; search: ranked retrieval; require okf for Ruby objects) and by the Graph, in three modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), and OKF::Server::App (the Rack app mounted in a Rails route). One gem, 100% local, Ruby 2.4 or newer, only rack and webrick as dependencies.">
38
+ <img src=".github/overview-light.png" width="760" alt="The package: the Agent Skill (your coding agent authors and curates, you stay the editor) writes and maintains the bundle, a folder of Markdown + YAML in your repo where one concept is one file and links between files are the knowledge graph. The bundle is read by the CLI/Lib (validate: legal OKF per section 9; lint: well-curated and fresh; search: ranked retrieval; require okf for Ruby objects) and by the Graph, in four modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), okf registry (every registered bundle behind one hub), and OKF::Server::App (the Rack app mounted in a Rails route). One gem, 100% local, Ruby 2.4 or newer, only rack, webrick and minifts as dependencies.">
39
39
  </picture>
40
40
  </p>
41
41
 
@@ -44,13 +44,20 @@ command-line tool (the library API is also usable in-process). Each capability
44
44
  below links to the concept that documents it: this gem's own knowledge is an OKF
45
45
  bundle, so you can read its design in the format it defends.
46
46
 
47
- | Capability | What it answers | Verb |
48
- | --------------------------------------------------------- | --------------------------------- | ------------------- |
49
- | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
50
- | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
51
- | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
52
- | [Interactive graph](.okf/capabilities/graph-server.md) | Explore it live or static? | `server` / `render` |
53
- | [Library API](.okf/capabilities/library-api.md) | Can my Ruby program use it? | in-process |
47
+ | Capability | What it answers | Verb |
48
+ | ------------------------------------------------------------- | --------------------------------- | ---------------- |
49
+ | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
50
+ | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
51
+ | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
52
+ | [Ranked text search](.okf/capabilities/search.md) | Which concept covers X? | `search` |
53
+ | [Interactive graph server](.okf/capabilities/graph-server.md) | Can I explore it visually? | `server` |
54
+ | [Static render](.okf/capabilities/render.md) | Can I ship a serverless snapshot? | `render` |
55
+ | [Library API](.okf/capabilities/library-api.md) | Can my Ruby program use it? | in-process |
56
+
57
+ And because knowledge rarely lives in one bundle, a per-user
58
+ [registry](.okf/registry.md) gives each bundle a name: `okf registry set ./docs`
59
+ once, then `@docs` works anywhere a `<dir>` does — from any directory — and a
60
+ bare `okf server` hosts every registered bundle behind one hub.
54
61
 
55
62
  > [!TIP]
56
63
  > **Browse the gem as knowledge, not just docs.** This README is the front door;
@@ -65,10 +72,13 @@ It is deliberately light so it runs on the Ruby your OS already ships:
65
72
 
66
73
  - works on every Ruby since 2.4, the same floor as [rack](https://github.com/rack/rack),
67
74
  its core dependency;
68
- - only two runtime dependencies: `rack` (the server is a mountable Rack app)
69
- and `webrick` (unbundled from Ruby in 3.0);
70
- - no ActiveSupport, no build step, no JavaScript toolchain the
71
- [design constraints](.okf/design/) that hold this line are enforced by tests.
75
+ - only three runtime dependencies: `rack` (the server is a mountable Rack app),
76
+ `webrick` (unbundled from Ruby in 3.0), and
77
+ [`minifts`](https://github.com/serradura/minifts) (the search enginepure
78
+ Ruby, no dependencies of its own, same 2.4 floor);
79
+ - no ActiveSupport, no native extension, no build step, no JavaScript
80
+ toolchain — the [design constraints](.okf/design/) that hold this line are
81
+ enforced by tests.
72
82
 
73
83
  That range is not aspirational: CI runs the full test suite and RuboCop on every
74
84
  one of these on each push.
@@ -218,7 +228,7 @@ docker run --rm -v "$PWD:/data" -p 8808:8808 ghcr.io/serradura/okf server . --bi
218
228
  Then open <http://127.0.0.1:8808>. Images are published for `linux/amd64` and
219
229
  `linux/arm64` on
220
230
  [ghcr.io](https://github.com/serradura/okf-gem/pkgs/container/okf): `:latest`
221
- tracks the newest release, or pin a version like `:1.5.0`.
231
+ tracks the newest release, or pin a version like `:1.9.0`.
222
232
 
223
233
  Tired of the long line? Install a Docker-backed [`okf` command](https://docker.okfgem.com),
224
234
  so every verb drops the `docker run` prefix and reads exactly like the native CLI
@@ -237,15 +247,30 @@ instead: `irm https://docker.okfgem.com/install.ps1 | iex`.
237
247
  ## Command line
238
248
 
239
249
  ```bash
240
- okf validate <dir> [--json] # check OKF v0.1 conformance (§9)
241
- okf lint <dir> [--json] [--fail-on warn] [...] # report curation-quality issues
242
- okf loose <dir> [--json] # list files with no graph links, by folder
243
- okf search <dir> <term…> [-e] [--in a,b] [...] # ranked text retrieval across metadata + bodies
244
- okf index <dir> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
245
- okf server <dir> [-p PORT] [--bind ADDR] [...] # serve the interactive graph over HTTP
246
- okf render <dir> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
247
- okf graph <dir> [--json] [--minimal] [--no-body] # print the knowledge graph
248
- okf catalog | files | tags | types | stats <dir> [--json] # the browser views, on the CLI
250
+ okf validate <dir|@slug> [--json] # check OKF v0.1 conformance (§9)
251
+ okf lint <dir|@slug> [--json] [--fail-on warn] [...] # report curation-quality issues
252
+ okf loose <dir|@slug> [--json] # list files with no graph links, by folder
253
+ okf search <dir|@slug…|@all> <term…> [-e|--fuzzy] [...] # ranked retrieval; @slugs or @all span bundles
254
+ okf index <dir|@slug> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
255
+ okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] # serve one bundle, or many behind a hub (⌘K to switch)
256
+ okf render <dir|@slug> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
257
+ okf registry list [--json] # list registered bundles (* marks the default)
258
+ okf registry set <dir|@slug> [--as SLUG] [--default] # add or update a bundle (a bare `server` serves it)
259
+ okf registry del <dir|@slug> # remove a bundle from the registry
260
+ okf registry default <@slug> | rename <@slug> <new> # move a bundle to the front (the default) / rename it
261
+
262
+ # The registry is a plain JSON file at $OKF_HOME/registry.json (default ~/.okf).
263
+ # It is ordered, and the first entry is the default: `registry default @slug`
264
+ # moves that entry to the front. The subcommand leads and flags follow it:
265
+ # `registry set <dir> --as X`, never `registry --json set <dir>`.
266
+ # A running server reads it at boot — restart after changes.
267
+ # Behind a multi-bundle server, /b/ lists every bundle and ⌘/Ctrl-K switches.
268
+ # @slug names a registered bundle instead of a path — the slug from `registry
269
+ # set`, or bare @ for the default. Anywhere a <dir> goes, an @slug goes:
270
+ # okf lint @handbook works from anywhere. Set $OKF_HOME to point every verb
271
+ # at another registry.
272
+ okf graph <dir|@slug> [--json] [--minimal] [--no-body] # print the knowledge graph
273
+ okf catalog | files | tags | types | stats <dir|@slug> [--json] # the browser views, on the CLI
249
274
  okf skill <dest> [--here] [--force] # install the companion agent skill
250
275
  okf --version
251
276
  ```
@@ -276,10 +301,39 @@ _The graph server on this repo's own [`.okf`](.okf) bundle, with the
276
301
  `capabilities/graph-server` concept selected. Try it live at
277
302
  **[demo.okfgem.com](https://demo.okfgem.com)**._
278
303
 
304
+ The page is one template from a phone to a desktop: on small screens the
305
+ navigation rail becomes a drawer, the toolbar folds into a `⚙` sheet, and the
306
+ panels go full-bleed — rotate a tablet and the layout re-evaluates. It is
307
+ keyboard-first too: `⌘/Ctrl-K` opens a command palette in every mode (views
308
+ always; bundles too when a [hub](#one-registry-many-bundles) is serving), `/`
309
+ jumps to the current view's search, and `?` answers with a sheet of every
310
+ shortcut.
311
+
279
312
  To skip the server entirely, **`okf render <dir>`** writes that same page as one
280
313
  self-contained HTML file, the whole bundle baked in, so you can publish the
281
314
  graph on GitHub Pages or any static host.
282
315
 
316
+ ### One registry, many bundles
317
+
318
+ The [registry](.okf/registry.md) is a per-user, ordered list of bundles in one
319
+ plain JSON file (`$OKF_HOME/registry.json`, default `~/.okf`) — hand-editable,
320
+ greppable, no database. It stores references, never content: the bundles stay in
321
+ the repos that own them.
322
+
323
+ ```bash
324
+ okf registry set ./docs --as handbook # give the bundle a name
325
+ okf lint @handbook # @slug works wherever a <dir> does, from anywhere
326
+ okf search @all rate limit # ranked retrieval across every registered bundle
327
+ okf server # no args: the whole registry behind one hub
328
+ ```
329
+
330
+ The first entry still on disk is the **default** — the bundle a bare `okf
331
+ server` opens at `/` and a bare `@` names; `okf registry default @slug` moves an
332
+ entry to the front. Behind the hub each bundle mounts at `/b/<slug>/`, `/b/` is
333
+ a browsable index, and the `⌘/Ctrl-K` palette switches bundles without leaving
334
+ the page. The hub reads the registry at boot, so restart it after registry
335
+ changes; set `$OKF_HOME` to point every verb at a different registry.
336
+
283
337
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
284
338
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
285
339
  The [graph server](.okf/capabilities/graph-server.md) concept walks the request
@@ -519,8 +573,8 @@ ruby -Ilib exe/okf validate <dir> # run the CLI from a checkout
519
573
  The suite runs on every supported Ruby; to check the 2.4 floor locally:
520
574
 
521
575
  ```bash
522
- docker run --rm -v "$PWD":/app -w /app ruby:2.4 \
523
- bash -c "bundle install && bundle exec rake test"
576
+ docker run --rm -v "$PWD":/src:ro ruby:2.4 bash -c \
577
+ "cp -a /src /build && cd /build && rm -f Gemfile.lock && bundle install --quiet && bundle exec rake test"
524
578
  ```
525
579
 
526
580
  ## Contributing
@@ -50,12 +50,29 @@ module OKF
50
50
  @bundle.directory_index
51
51
  end
52
52
 
53
+ # Every log.md with its content, root scope first — read live from disk so a
54
+ # just-appended entry shows without a reload; the reserved snapshot is the
55
+ # fallback if the file has since vanished. Shared by `okf render`'s bake
56
+ # (OKF::Render::Graph.payload) and OKF::Server::App's /log endpoint.
57
+ def log_entries
58
+ @bundle.log_files.sort_by { |path| [ path == "log.md" ? 0 : 1, path ] }.map do |path|
59
+ { path: path, dir: File.dirname(path), content: log_content(path) }
60
+ end
61
+ end
62
+
53
63
  # Human-readable "parent/dir" name — the default HTML title.
54
- def name
55
- pathname = Pathname.new(@root)
64
+ # The bundle's display label, "parent/dir" — path arithmetic, no disk. It
65
+ # is a class method so a caller that only wants the label (the registry
66
+ # naming an entry) can have it without a Reader.read of every file.
67
+ def self.label(root)
68
+ pathname = Pathname.new(root)
56
69
  "#{pathname.parent.basename}/#{pathname.basename}"
57
70
  end
58
71
 
72
+ def name
73
+ self.class.label(@root)
74
+ end
75
+
59
76
  # A single-file handle for one concept id (read live from disk), or nil when no
60
77
  # concept in the loaded bundle has that id. The id may be a frontmatter `id`, so
61
78
  # it is resolved to a path through the bundle rather than assumed to be "id.md".
@@ -89,6 +106,12 @@ module OKF
89
106
  .select { |entry| File.basename(entry.path) == basename }
90
107
  .each_with_object({}) { |entry, hash| hash[entry.path] = entry.content }
91
108
  end
109
+
110
+ def log_content(path)
111
+ File.read(File.join(@root, path), encoding: "UTF-8")
112
+ rescue SystemCallError
113
+ @bundle.reserved_content(path)
114
+ end
92
115
  end
93
116
  end
94
117
  end
@@ -86,8 +86,12 @@ module OKF
86
86
  concept.tags.is_a?(Array) ? concept.tags : []
87
87
  end
88
88
 
89
+ # Blank, not just nil: §9.2 makes a whitespace-only `type` as non-conformant
90
+ # as a missing one (the validator says so with the same OKF.blank?), so the
91
+ # index must not sort them into different buckets. Otherwise `type: " "`
92
+ # earns its own row, labelled with spaces, next to Untyped.
89
93
  def self.default(value, fallback)
90
- value.nil? ? fallback : value.to_s
94
+ OKF.blank?(value) ? fallback : value.to_s
91
95
  end
92
96
 
93
97
  def initialize(nodes:, edges:, type_index: {}, tag_index: {})
@@ -290,7 +290,12 @@ module OKF
290
290
  @report.stat(:backlog, count_findings(:missing_concept))
291
291
  @report.stat(:components, components.size)
292
292
  @report.stat(:hubs, hubs)
293
- @report.stat(:types, frequency(@concepts.map { |c| c.type || "Untyped" }))
293
+ # Through Graph.default, not a second `|| "Untyped"`: §9.2 makes a
294
+ # whitespace-only type as non-conformant as a missing one, so the two must
295
+ # land in one bucket. Spelling the rule twice is how lint came to report a
296
+ # `" "` bucket that `types` and `graph` had never heard of — the same
297
+ # concepts counted by both verbs, into inventories that will not reconcile.
298
+ @report.stat(:types, frequency(@concepts.map { |c| Graph.default(c.type, "Untyped") }))
294
299
  @report.stat(:tags, frequency(@concepts.flat_map { |c| c.tags.is_a?(Array) ? c.tags : [] }))
295
300
  end
296
301