okf 2.1.0 → 2.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.okf/capabilities/agent-skill.md +112 -0
  3. data/.okf/capabilities/bundles-manager.md +144 -0
  4. data/.okf/capabilities/graph-server.md +678 -0
  5. data/.okf/capabilities/index.md +26 -0
  6. data/.okf/capabilities/library-api.md +82 -0
  7. data/.okf/capabilities/linter.md +83 -0
  8. data/.okf/capabilities/read-views.md +228 -0
  9. data/.okf/capabilities/render.md +66 -0
  10. data/.okf/capabilities/search.md +297 -0
  11. data/.okf/capabilities/validator.md +60 -0
  12. data/.okf/cli.md +214 -0
  13. data/.okf/design/browser-tests.md +211 -0
  14. data/.okf/design/core-shell-split.md +73 -0
  15. data/.okf/design/index.md +17 -0
  16. data/.okf/design/integration-first.md +140 -0
  17. data/.okf/design/packaging.md +65 -0
  18. data/.okf/design/ruby-floor.md +53 -0
  19. data/.okf/design/runtime-dependencies.md +82 -0
  20. data/.okf/design/search-engines.md +154 -0
  21. data/.okf/design/server-trust-boundary.md +139 -0
  22. data/.okf/index.md +40 -0
  23. data/.okf/log.md +724 -0
  24. data/.okf/model/bundle.md +47 -0
  25. data/.okf/model/concept.md +75 -0
  26. data/.okf/model/graph.md +59 -0
  27. data/.okf/model/index.md +9 -0
  28. data/.okf/model/skeleton.md +76 -0
  29. data/.okf/overview.md +87 -0
  30. data/.okf/registry.md +432 -0
  31. data/.okf/structure/format-layer.md +59 -0
  32. data/.okf/structure/index.md +22 -0
  33. data/.okf/structure/search.md +53 -0
  34. data/.okf/structure/the-analysers.md +60 -0
  35. data/.okf/structure/the-cli.md +99 -0
  36. data/.okf/structure/the-disk-shell.md +76 -0
  37. data/.okf/structure/the-model.md +81 -0
  38. data/.okf/structure/the-server.md +74 -0
  39. data/.okf/structure/the-skill.md +52 -0
  40. data/.okf/testing/adding-a-verb.md +76 -0
  41. data/.okf/testing/index.md +12 -0
  42. data/.okf/testing/the-harness.md +45 -0
  43. data/CHANGELOG.md +161 -16
  44. data/README.md +226 -17
  45. data/lib/okf/cli/command.rb +8 -3
  46. data/lib/okf/cli/registry.rb +306 -47
  47. data/lib/okf/cli.rb +1 -1
  48. data/lib/okf/registry.rb +448 -22
  49. data/lib/okf/render/graph/template.html.erb +6 -2
  50. data/lib/okf/server/hub.rb +6 -2
  51. data/lib/okf/skill/reference/cli/registry.md +49 -6
  52. data/lib/okf/skill/reference/cli.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. metadata +46 -5
data/.okf/registry.md ADDED
@@ -0,0 +1,432 @@
1
+ ---
2
+ type: Component
3
+ title: The bundle registry
4
+ description: An ordered list of bundle references persisted as JSON — global under $OKF_HOME, or project-local via `okf registry init` and discovered from the working directory, composable through links to other registry files and able to import rows out of them — the kernel behind a bare `okf server`.
5
+ resource: gems/okf/lib/okf/registry.rb
6
+ tags: [cli, shell, registry]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-07-24T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/registry.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/registry.rb
13
+ ---
14
+
15
+ # Overview
16
+
17
+ `OKF::Registry` is the gem's only piece of *durable user state*: an ordered list
18
+ of bundle references, so `okf registry set` today and a bare `okf server`
19
+ tomorrow share one list. It is a plain JSON file — `$OKF_HOME/registry.json`,
20
+ `$OKF_HOME` defaulting to `~/.okf` — and that is a design choice, not a stopgap: a
21
+ database would break the [two-dependency rule](design/runtime-dependencies.md),
22
+ and the file is per-user, hand-editable, and greppable. It is part of the
23
+ [shell](design/core-shell-split.md); it reads and writes a file. That is the
24
+ *global* registry; a project can keep its own, discovered from the working
25
+ directory — see [Global by default, project-local by discovery](#global-by-default-project-local-by-discovery).
26
+
27
+ The registry stores *references*, never content. It holds a path, a slug, and a
28
+ title — the bundles themselves stay where they are on disk, owned by the repos
29
+ they document. Nothing is copied, so nothing can go stale except the path itself.
30
+
31
+ # Slugs: implicit is forgiving, explicit is strict
32
+
33
+ A slug is the bundle's mount key (`/b/<slug>/`) and its name in the
34
+ [switcher](capabilities/graph-server.md). Where it comes from decides what a
35
+ collision means:
36
+
37
+ | Source | On collision | Why |
38
+ |--------|--------------|-----|
39
+ | the directory basename (`registry set ./docs`) | silently suffixed — `docs-2`, `docs-3` | you never asked for a name; the gem picks a free one |
40
+ | an explicit `--as SLUG`, or `registry rename` | raises | you *did* ask for that name, so quietly serving a different one is a lie |
41
+
42
+ That asymmetry is the whole rule: **the gem may invent a name, but it may never
43
+ substitute one you chose.** Both paths run the same normalization the ephemeral
44
+ (unregistered) bundles use, so a directory mounts under the same slug whether it
45
+ was registered or passed straight to `okf server` — with exactly one exception,
46
+ the reserved `all/` below, which the registry alone has a reason to rename.
47
+
48
+ The rule extends cleanly to a name the grammar has already spoken for. `@all`
49
+ means *every registered bundle* to [`search`](capabilities/search.md), so no one
50
+ bundle may answer to `all` — it is reserved. A directory named `all/` therefore
51
+ registers as `all-2` (the basename was only a guess, so a suffix is right), while
52
+ `--as all` is refused (the ask was deliberate, so substituting `all-2` would be
53
+ the lie).
54
+
55
+ The reservation is the *registry's*, not the slug helper's, and that boundary is
56
+ load-bearing in both directions. Inward, all three ways a slug enters this list
57
+ are covered — minting, an explicit ask, and **reading the file**. The third is the
58
+ one that cannot refuse. `all` reaches the file two ways nothing can take back: a
59
+ release from before the name was reserved wrote it (a directory named `all/`
60
+ slugged exactly that), or a hand typed it into the file the format invites you to
61
+ edit. So the read *mints around it* — the entry lists, mounts, and answers to
62
+ `all-2`, and the next write persists the name.
63
+
64
+ **The read normalizes for the same reason**, and it is the same bug one step
65
+ wider: two of the three ways in normalized and the third did not. A hand-typed
66
+ `"slug": "My Docs"` listed perfectly well while `@my-docs` missed it — and so did
67
+ `rename` and `default`, which look an entry up through the very normalization the
68
+ read had skipped. The two verbs that could repair the entry were the two that
69
+ could not see it. A slug registration would have handed back untouched is left
70
+ alone (including one already suffixed, so fixing a sick entry never renames a
71
+ healthy one); everything else is minted around what the other entries hold.
72
+ <!-- rule:okf-registry-read-normalizes -->
73
+
74
+ That asymmetry was also the [graph server](capabilities/graph-server.md)'s XSS
75
+ trigger: slugs reach the bundle switcher's HTML, and the only way one could carry
76
+ a quote was to arrive un-normalized through this read. Normalizing closes it at
77
+ the source — the escape is hardened too, because a page whose safety depends on a
78
+ guarantee three layers away is not one you can reason about locally.
79
+
80
+ Refusing the file was the first answer here, and it was worth the correction it
81
+ took. A name the grammar has taken makes **one entry** unnameable; rejecting the
82
+ registry makes **every** entry unreachable — and takes `del` and `rename`, the two
83
+ verbs that could fix it, down on the very read they need to survive, leaving
84
+ hand-editing JSON as the only way out. A guard whose failure mode is worse than
85
+ what it guards against is not a guard. Minting is also simply the rule already
86
+ stated above, read one line further: the gem may invent a name, and here the name
87
+ on disk cannot be used, so inventing one is the only move that is not a lie.
88
+ <!-- rule:okf-registry-reserved-mint -->
89
+
90
+ Outward, it stops there: an ephemeral `okf server ./all` has no
91
+ registry and no refs, so there is no name to protect, and it mounts at `/b/all/` —
92
+ suffixing it would invent a `/b/all-2/` whose `/b/all/` does not exist. `all/` is
93
+ therefore the one directory whose registered slug and ephemeral slug differ, and
94
+ they differ because only one of the two worlds has a grammar that spells `all`.
95
+
96
+ The rule cuts one layer deeper, at the empty string. Minting a slug from a
97
+ basename must *produce* something, so `slugify` falls back to a placeholder when
98
+ nothing survives normalization. Looking one up must not: a lookup that inherits
99
+ that fallback makes `@***` resolve to whatever bundle happens to be slugged
100
+ `bundle` — the gem substituting a name you never chose, which is the one thing
101
+ the rule forbids. So `normalize` (no fallback) backs every lookup and every
102
+ explicit ask, and `slugify` (placeholder) backs only basename minting.
103
+
104
+ # The default is a position, not a stored name
105
+
106
+ The first entry still on disk is the bundle a bare `okf server` opens at `/`, and
107
+ `registry default <slug>` moves that entry to the front. That is the whole
108
+ feature.
109
+
110
+ "Still on disk" is the one qualifier position needs, and it is not a fallback in
111
+ disguise. The hub drops a bundle whose directory has vanished rather than serving
112
+ a hole, so a default that ignored the gap would put `registry list`'s `*` on a
113
+ bundle `/` never opens — the star names what `/` opens, so it has to skip what `/`
114
+ skips. The rule stays derivable from the file plus the disk, with nothing stored
115
+ and nothing to reconcile. Its mirror is that `registry default <slug>` *refuses* a
116
+ vanished directory, exactly as `registry set` refuses to register one: both are
117
+ explicit asks, and a move the default would then skip would answer with a slug the
118
+ user did not type.
119
+
120
+ The alternative — storing the chosen slug — looks simpler and is not. A stored
121
+ slug is a *foreign key* into the same list it lives in, and a foreign key demands
122
+ referential integrity from every operation that touches the list: carry it
123
+ through a `rename`, re-point it when `add --as` renames in place, clear it on a
124
+ `remove`, and fall back when it dangles anyway. Four obligations, each a place to
125
+ forget. Position owes nothing: a rename touches the name and leaves the row where
126
+ it is, a `del` lets the next row become first, and a default that is not in the
127
+ list is *unrepresentable* rather than merely handled. The registry was already
128
+ documented as ordered, so this is state it kept for free.
129
+
130
+ What it costs is a file that visibly reorders, which is why `registry default`
131
+ says so in its own help — the JSON is meant to be read and hand-edited, and a
132
+ reordering write should never be a surprise.
133
+
134
+ Identity is the **path**, not the slug: re-registering a directory already in the
135
+ registry refreshes its title in place instead of adding a twin.
136
+
137
+ # It names bundles for the whole CLI, not just the server
138
+
139
+ The registry began as the server's boot list and grew into the
140
+ [CLI](cli.md)'s name-resolution layer: wherever a verb takes a `<dir>`,
141
+ `@slug` resolves through it and bare `@` picks the default — the same slugs the
142
+ hub mounts at `/b/<slug>/`, so the name you click is the name you type. That is
143
+ what turns registering from "tell the server" into "give this bundle a name":
144
+ [`search`](capabilities/search.md) crosses several of them in one query, and no
145
+ verb needs a path once the bundle has a slug.
146
+
147
+ It is also no longer terminal-only. The graph page's ⚙ Bundles panel drives
148
+ `default`, `rename` and `remove` from a browser, through
149
+ [this class and its messages](capabilities/bundles-manager.md) rather than around
150
+ them. `add` stays terminal-only, because a browser cannot hand over a filesystem
151
+ path. The file stays the record: every write goes through here, and the hub
152
+ re-reads it per request rather than trusting a snapshot, so an `okf registry
153
+ rename` in another terminal shows on a refresh.
154
+
155
+ # Groups: a named set of bundles
156
+
157
+ A **group** is a slug that names not one bundle but a *list* of members — bundle
158
+ or group slugs, so groups nest — and resolves, recursively and path-deduped, to
159
+ the bundle leaves underneath. It is the durable form of typing `@a @b @c`: once
160
+ several bundles earn a name together (`okf registry group backend @orders
161
+ @billing`), `@backend` stands in for the set. `group`/`ungroup` add and remove
162
+ members; emptying a group deletes it, since an empty set resolves to nothing.
163
+
164
+ Groups live in **their own list** (`{ bundles: […], groups: […] }`), not among
165
+ the entries — a deliberate separation. The first-is-default rule and every
166
+ `File.directory?` guard assume an entry has a path, and a group has none;
167
+ threading a nil path through all of them to host a pathless member would be the
168
+ foreign-key tax the default rule already refused. A separate list leaves the
169
+ bundle invariants untouched and makes a group exactly what it is: a view over
170
+ them.
171
+
172
+ **One namespace, two kinds.** A slug names a bundle *or* a group, never both, so
173
+ `@backend` is unambiguous — the collision check that already spanned entries and
174
+ the reserved `all` now spans groups too, in both directions (`registry set --as
175
+ backend` is refused while a group holds it, and vice versa). And because a member
176
+ list stores slugs, the two lifecycle verbs keep those references live: `rename`
177
+ **cascades** the new name across every group that named the slug, and `del`
178
+ **cascade-drops** it (a group emptied that way is deleted). Skipping either would
179
+ orphan a member silently — the same drift the path-not-slug identity rule avoids
180
+ for the default.
181
+
182
+ **Only a set-taking verb consumes one.** [`search`](capabilities/search.md) and
183
+ [`server`](capabilities/graph-server.md) are the two verbs that already take
184
+ several bundles; a group feeds exactly them (`okf search @backend …` merges the
185
+ members into one ranking, `okf server @backend` mounts each). Every single-bundle
186
+ verb refuses a `@group` with exit 2 — the same second-bundle rule that stops
187
+ `okf lint a b` from linting `a` and ignoring `b`, because a group resolving to
188
+ three bundles is that ambiguity by another spelling. `@all` is unchanged: it
189
+ still names every registered *bundle*, and a group is a named subset of what it
190
+ already covers. A cycle is refused at write time and guarded again at resolution,
191
+ since the file is hand-editable.
192
+ <!-- rule:okf-registry-groups-cascade -->
193
+
194
+ # It tolerates a world that changes underneath it
195
+
196
+ A registry entry is a bet that a directory still exists, and the registry never
197
+ prunes on its own — deleting a bundle from disk must not silently rewrite a list
198
+ the user curated. Instead `listing` marks the entry `missing`, so
199
+ [`registry list`](cli.md) shows the gap and the user decides. Reads are equally
200
+ forgiving: the original bare-array file shape still parses, while a *corrupt*
201
+ file raises with the fix — "fix or delete the file" —
202
+ rather than starting from an empty list and silently dropping every bundle.
203
+
204
+ That message invites a hand-edit, which is why the shape is checked and not just
205
+ the syntax: valid JSON is not a valid registry, and an entry missing its `path`
206
+ must fail here, as a usage error naming the file, rather than surviving to crash
207
+ a `File.directory?` three frames away. The [CLI](cli.md) does its half by
208
+ loading through a guard, so a broken file reaches the user as an error from
209
+ whatever verb they ran — not a backtrace from a verb that never rescued one.
210
+
211
+ Writes go to a temp file and are promoted with `rename`, the same atomic
212
+ promotion the [bundle writer](capabilities/library-api.md) uses, so a booting
213
+ server never reads a torn file. Two racing writers stay last-writer-wins: this is
214
+ a per-user file, and locking would buy nothing worth the complexity.
215
+
216
+ # Global by default, project-local by discovery
217
+
218
+ The registry has two homes, and which one answers is decided by *where you stand*,
219
+ not by a flag. The global one is the `$OKF_HOME/registry.json` above — one per
220
+ user, shared across every repo. The project-local one is a `.okf.json`
221
+ that `okf registry init` drops in a directory; okf finds it by walking up from the
222
+ working directory, and while you are inside its tree it **replaces** the global one
223
+ — every registry op, and every [`@slug`](cli.md), resolves through it. So a bare
224
+ `okf server` inside a repo serves that repo's bundles with no `$OKF_HOME` setup,
225
+ and a project carries its own named set without touching the user's global list.
226
+
227
+ **The file's presence is the whole state.** There is no stored "local mode", the
228
+ same way the [default is a position, not a stored name](#the-default-is-a-position-not-a-stored-name):
229
+ a mode flag would be one more thing to set, dangle, and reconcile, where the file
230
+ being *there* is self-evident and self-cleaning. The nearest one on the path up
231
+ wins, so nested registries resolve nearest-first, and `okf registry list` names the
232
+ file it found so which one is answering is never a guess.
233
+
234
+ `$OKF_HOME` still names *where the global registry lives*; it does **not** veto a
235
+ nearer local one. That direction is deliberate: `$OKF_HOME` is commonly exported
236
+ once and left, so letting it win would silently defeat the feature for exactly the
237
+ users who set up a project registry. The escape hatch is therefore a per-invocation
238
+ signal, not a second sticky variable — `OKF_NO_DISCOVERY=1`, set inline, forces the
239
+ global registry for a fixed-cwd caller (CI, a tool) that cannot just `cd` out.
240
+
241
+ That file was called `.okf-registry.json` first, and both names are still
242
+ discovered — a local registry is *committed*, so retiring the old one outright
243
+ would break every repository carrying it to save eight characters. The two are
244
+ checked **per directory** on the way up, not one name swept to the root and then
245
+ the other: otherwise a legacy file at a repo's root would beat a `.okf.json` two
246
+ levels down, and "the nearest one wins" would quietly mean something else. Inside
247
+ one directory the short name wins.
248
+
249
+ The deprecation is said **once, by the `registry` umbrella, and nowhere else**.
250
+ That verb is the one whose subject *is* a registry file and the one nobody runs in
251
+ a loop or pipes into something, which is exactly what `lint` and `search` are — a
252
+ note there is noise people learn to redirect away rather than act on. A legacy
253
+ file that is in force gets the one move that retires it; a legacy file sitting
254
+ beside the `.okf.json` that beat it gets named too, because reading one while the
255
+ other lies there unread is a silent wrong answer unless somebody says so.
256
+
257
+ `-g`/`--global` is that same signal spelled as an argument, and it is the
258
+ `registry` umbrella's alone (see [the CLI](cli.md#one-lever-not-two)). It exists
259
+ because a lever reachable only through an env var is a lever most users never
260
+ find — and the umbrella is the one verb whose *subject* is a registry file, so
261
+ naming which file to act on is an argument to it rather than a flag bolted onto
262
+ fourteen unrelated verbs. `init` is the exception that proves it: its whole job is
263
+ to create a *local* file, so `-g` there names nothing and is refused.
264
+
265
+ # A project-local registry stores portable paths
266
+
267
+ The global registry stores absolute paths — correct for `~/.okf`, whose bundles
268
+ are scattered across the disk with no shared anchor. A committed project registry
269
+ needs the opposite: a bundle **inside** the registry's own tree is stored *relative*
270
+ to the `.okf.json`, so the file travels with the repo — a checkout on
271
+ another machine, or a container mounting it, resolves the same bundles unchanged. A
272
+ bundle **outside** the tree keeps an absolute path, because a relative path that
273
+ climbs out cannot be re-anchored anywhere useful, and being honest that it will not
274
+ travel beats a `../../..` that breaks on the first move.
275
+
276
+ The relative form lives **only on disk**. A path resolves to absolute the moment it
277
+ is read, so `entry.path`, [`registry list`](cli.md), and the server mount all go on
278
+ seeing the absolute paths they always did — the portability is a property of the
279
+ file, invisible to every consumer. And because only the write side relativizes, an
280
+ existing absolute local entry migrates to relative on its next write: a registry
281
+ written before this existed heals itself the first time it changes.
282
+ <!-- rule:okf-registry-local-discovery -->
283
+
284
+ # Links: the global registry composes other registry files
285
+
286
+ A **link** is a pointer from the global registry to another registry file. That
287
+ file's bundles resolve through the pointer at read time, under their own slugs,
288
+ and nothing is copied — the same "stores references, never content" rule the
289
+ entries keep, one level up. `okf registry link onm ~/ONM/registry.json` and
290
+ `@onm-central` (or whatever slugs that file holds) answer here; the link name
291
+ itself resolves as a group over exactly its bundles, so `@onm` is the set.
292
+
293
+ The case it exists for is **a repository that already curates its own bundles**.
294
+ This repo commits a `.okf.json` naming five; before links, using them
295
+ from `~/.okf` meant registering all five again by hand and re-syncing whenever
296
+ the repo's list changed. A link points at the file the repo already maintains, so
297
+ the curation is composed rather than duplicated, and it keeps resolving its own
298
+ relative paths because the target is anchored on its own directory — exactly as
299
+ it would from inside that checkout. Two registry files stop being two worlds you
300
+ switch `$OKF_HOME` between and become one view.
301
+
302
+ **Only the global registry follows links.** A project-local one parses them and
303
+ preserves them across a write, but never resolves them — and that single
304
+ restriction is the whole depth rule. A linked file's own links are not read, so
305
+ no chain forms, no prefix compounds, and there is no cycle to detect. The
306
+ alternative was transitive resolution, and it fails on ownership rather than on
307
+ effort: the target is a file this registry does not own, so a cycle-check at
308
+ write time goes stale the moment someone adds a link back on the other side.
309
+ Transitivity would move cycle detection from write time — cheap, one file,
310
+ refusable — to read time, across N files, with nobody to blame. The groups above
311
+ nest safely for exactly the reason links do not: every member lives in one file,
312
+ behind one guard.
313
+ <!-- rule:okf-registry-links-global-only -->
314
+
315
+ # A linked name is minted around a collision, never refused
316
+
317
+ A slug arriving from a linked file answers to itself when the name is free, and
318
+ to `<link>-<slug>` when it is not (`-2` beyond that, through the same `dedupe` a
319
+ basename goes through). That is the [implicit is forgiving, explicit is
320
+ strict](#slugs-implicit-is-forgiving-explicit-is-strict) table one row wider, and
321
+ the row falls on the forgiving side for the reason the table gives: a name in
322
+ someone else's file was never *chosen* here, so inventing around it is licensed —
323
+ while refusing would let one foreign row take down an entire link. The link
324
+ *name* is the strict half, and is refused on collision like any `--as`: you typed
325
+ that one.
326
+
327
+ Precedence is fixed so the derivation is reproducible: the registry's own bundles
328
+ always win the bare name, and between links the file's order decides. Both are
329
+ position, which is state the registry already keeps — the same reason the
330
+ [default](#the-default-is-a-position-not-a-stored-name) is one.
331
+
332
+ What this costs, and it is the design's one genuinely computed name: a slug can
333
+ *move* when an unrelated link is added. Everything else in this file is stable in
334
+ the file. The mitigation is disclosure rather than a mechanism — `registry link`
335
+ says what it moved as it writes, and `registry list` prints the moved row with the
336
+ slug it carries in its source file (`onm-central … [central]`), which is the only
337
+ place a shifted ref is visible. Linked entries also append **after** the local
338
+ ones, so while this registry owns any bundle at all the default stays local.
339
+
340
+ **A linked group is listed with the rest, not beside them.** `groups_listing`
341
+ returns this registry's own groups first, then the linked ones, each carrying the
342
+ `link` it came from — one list, because `group?` *resolves* a linked group and a
343
+ listing that named only the local half would answer about a smaller set than the
344
+ same object can resolve. That gap is invisible at the call site and inherited by
345
+ every consumer: `okf-mcp`'s `list_bundles` and the TUI's groups view both read
346
+ this one method, and both would have hidden a group they could already open. A
347
+ caller that wants only the groups it may edit filters on `link` — which is the
348
+ question they are actually asking, and it is now askable.
349
+
350
+ # A link is read-only, and the refusal lives in the model
351
+
352
+ `rename`, `del`, `default`, `set --as` and `group` all refuse a slug a link owns,
353
+ with a message naming the file that does own it and the `unlink` that would drop
354
+ it. Two of those are worth their own line. A **group** may not hold a linked slug:
355
+ a group stores names, and a linked name lives only while its link resolves, so
356
+ holding one would dangle the group the moment the link goes — the foreign key the
357
+ default rule already refused. And **`registry set` on a directory a link already
358
+ carries** is refused rather than quietly adding a twin, because entries are
359
+ identified by path and the path is already spoken for.
360
+
361
+ The third was a hole this rule had left open, and it failed in the worst
362
+ available way. A group's slug is its *update* path everywhere else — `registry
363
+ group backend @more` adds to the existing one — so `group onm @alpha`, naming a
364
+ link or a group that came with one, took that path: it merged the member, printed
365
+ `grouped onm → …`, and lost it, because `write` persists only the groups this
366
+ registry owns. A refusal is the fix, but the shape is what matters: a write that
367
+ reports success and does not happen is worse than one that raises, and it was
368
+ reachable from the CLI, the TUI and the browser panel alike.
369
+
370
+ The refusals live in this class, not in the [CLI](cli.md), and that placement is
371
+ load-bearing: the graph page's ⚙ Bundles panel posts into these same methods
372
+ ([bundles manager](capabilities/bundles-manager.md)), so a guard one layer up
373
+ would leave the browser doing what the terminal refuses. A link whose target has
374
+ gone or cannot be parsed is *reported* — `(missing)`, `(unreadable)` — and
375
+ resolves to nothing, the same tolerance a vanished bundle directory gets, because
376
+ one dead pointer must not take down the registry that holds it.
377
+
378
+ # Import: the copy that owns what it takes
379
+
380
+ `okf registry import <@slug…>` copies chosen bundles — and the groups that hold
381
+ them — out of another registry file into the one in force. It is the **opposite
382
+ trade** from a link, and the pair is the point:
383
+
384
+ | | link | import |
385
+ | --- | --- | --- |
386
+ | what moves | nothing; a pointer | the reference, copied |
387
+ | scope | the whole target file | the slugs you name |
388
+ | ownership | the target keeps it, read-only | yours, editable |
389
+ | freshness | live on every read | a snapshot |
390
+
391
+ Copying a *reference* is not copying content, so the rule at the top of this file
392
+ still holds: the bundle stays in the repository that owns it, and what lands here
393
+ is the same path-and-name row `registry set` would have written. What import saves
394
+ is the laundering. The scenario is standing inside a repo, running `okf registry
395
+ list -g` to see what the global registry holds, and wanting one of them here — the
396
+ path is already on screen, and without this verb the only way to move it is
397
+ through the clipboard.
398
+
399
+ The source is `--from`, defaulting to the global registry, which inside a repo is
400
+ the only other one you have. It is a second flag rather than a second meaning for
401
+ `-g` because [`-g` names the registry acted *on*](cli.md#one-lever-not-two) on
402
+ every other subcommand; this verb names two files, so the second gets its own
403
+ name rather than inverting the first.
404
+
405
+ **A collision refuses.** This is where import and link disagree, and the
406
+ disagreement is the slug rule, not an inconsistency: a linked name was never
407
+ chosen here, so it is [minted around](#a-linked-name-is-minted-around-a-collision-never-refused);
408
+ an imported name lands in *this* file because you typed it, so it is refused
409
+ exactly as `rename` refuses. The gem may invent a name it made up; it may not
410
+ substitute one you chose. A bundle already registered here under a different name
411
+ refuses too, and *first* — when both are true, "that bundle is already here as
412
+ @docs" is the answer and "the name is taken" is only the symptom.
413
+
414
+ **A group brings everything it reaches**, including a group nested inside it.
415
+ Recreating a name here that resolved to a larger set there would be that same
416
+ quiet substitution, with nothing on screen to reveal it. Members are stored
417
+ verbatim, which is the payoff of preserving slugs: a name means the same thing on
418
+ both sides, so there is nothing to remap — the work `fold_linked_bundles` must do
419
+ precisely because a link mints its names.
420
+
421
+ **Nothing is applied until everything is checked.** Every ask is resolved and
422
+ refused against the current state first, then one `write` publishes the lot. Half
423
+ an import is a registry the user has to unpick by hand, reported as a success —
424
+ and a refusal that already moved three of four rows is not a refusal.
425
+ <!-- rule:okf-registry-import-all-or-nothing -->
426
+
427
+ # It costs an embedding app nothing
428
+
429
+ `require "okf"` does not load it. The registry is reached only from the
430
+ [CLI](cli.md), which requires it at the moment a registry verb or a bare `server`
431
+ runs — the same on-demand rule the [library API](capabilities/library-api.md)
432
+ keeps for the command-line machinery.
@@ -0,0 +1,59 @@
1
+ ---
2
+ type: Component
3
+ title: The Format Layer
4
+ description: "Pure, and the bottom of everything: path normalisation with a root-escape guard, the one YAML door, and the link and citation grammars §5 and §8 are written in."
5
+ tags: [structure, format, pure, yaml, links]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/path.rb
10
+ ---
11
+
12
+ # The files
13
+
14
+ | file | what it owns |
15
+ |---|---|
16
+ | `lib/okf.rb` | `OKF::Error`, `OKF.blank?`, `OKF.iso8601`, `OKF.dir_of`, `SPEC_VERSION` |
17
+ | `lib/okf/version.rb` | `OKF::VERSION` |
18
+ | `lib/okf/path.rb` | `Path.normalize_relative!`, `join_under!`, `under?` — and `Path::Error` |
19
+ | `lib/okf/safe_read.rb` | `SafeRead.contained_path!`, `read!` — containment for a caller that already holds a root |
20
+ | `lib/okf/markdown/frontmatter.rb` | parse, dump, `stringify_keys` — **the only** YAML door |
21
+ | `lib/okf/markdown/links.rb` | §6: inline, reference and footnote links, and how a raw target resolves |
22
+ | `lib/okf/markdown/citations.rb` | §8: the `# Citations` section and its entries |
23
+
24
+ # Containment is a primitive here, not a habit
25
+
26
+ `Path.normalize_relative!` rejects any `..` segment outright, which is what makes
27
+ "a concept cannot link out of its own bundle" a property of the format rather
28
+ than a convention. `join_under!` and `under?` are the two questions every writer
29
+ and every reader asks before touching a path, and `SafeRead.read!` is the pair of
30
+ them plus the read, for callers that already hold a root — it resolves symlinks
31
+ and refuses one that leaves.
32
+
33
+ Every layer above reaches for these rather than composing its own check. A second
34
+ containment implementation is the shape this class of bug takes.
35
+
36
+ # One YAML door
37
+
38
+ **All YAML goes through `Markdown::Frontmatter`** — `safe_load`, with `Date` and
39
+ `Time` permitted and aliases off. `PSYCH_KEYWORDS` is the Psych < 3.1
40
+ positional-argument shim, and it lives here precisely so that
41
+ `YAML.safe_load`/`YAML.load` is called in exactly one place in the gem.
42
+
43
+ `stringify_keys` exists so that ActiveSupport does not, which is the same reason
44
+ `OKF.blank?` does.
45
+
46
+ # The link grammar is where the graph comes from
47
+
48
+ `Links.extract` walks prose lines only — `FENCE` and `CODE_SPAN` take fenced
49
+ blocks and code spans out first, because a link inside a code sample is a
50
+ document about a link, not an edge. `INLINE_LINK`, `REFERENCE_LINK` and
51
+ `DEFINITION` cover the three markdown spellings; `FOOTNOTE_REFERENCE` and
52
+ `FOOTNOTE_DEFINITION` are §8's keying. `SCHEME` and `MAILTO` are what keep an
53
+ external URL from being read as a relative path.
54
+
55
+ What is built on top of these is [the-model](/structure/the-model.md).
56
+
57
+ `Links.resolve` is the one that turns a raw target into a bundle-relative path,
58
+ and it is why a link split across a newline produces no edge — the extractor
59
+ reads a line at a time.
@@ -0,0 +1,22 @@
1
+ # Structure
2
+
3
+ Every file under `lib/`, grouped by the layer that owns it. One concept owns
4
+ each file, and `test/unit/bundle_catalog_test.rb` fails if that stops being true
5
+ in either direction — a file no concept names, or a concept naming a file that
6
+ is gone.
7
+
8
+ Fifty files, eight layers, and one rule underneath all of them: **the core is
9
+ pure and the shell does the I/O.** `test/unit/boundary_test.rb` fails if a pure
10
+ file names a shell class or touches `File`, `Dir`, `FileUtils` or stdio. Put new
11
+ logic in the core; put new I/O in the shell.
12
+
13
+ Read it bottom-up — each layer depends only on the ones below it.
14
+
15
+ * [The Format Layer](format-layer.md) - `lib/okf.rb`, `path.rb`, `safe_read.rb`, `version.rb`, `markdown/` — pure: paths, containment, frontmatter, links, citations.
16
+ * [The Model](the-model.md) - `concept.rb`, `bundle.rb`, `bundle/graph.rb`, `references.rb`, `row_filter.rb`, `skeleton.rb` — pure: a bundle in memory, and every derived view of it.
17
+ * [The Analysers](the-analysers.md) - `bundle/validator*.rb`, `bundle/linter*.rb` — pure: §11 conformance, and curation quality, kept deliberately apart.
18
+ * [Search](search.md) - `bundle/search.rb` and its two engines — pure: the facade owns the rows, the engines own the matching.
19
+ * [The Disk Shell](the-disk-shell.md) - `concept/file.rb`, `bundle/reader.rb`, `writer.rb`, `folder.rb`, `registry.rb` — where directories become bundles and back.
20
+ * [The Server and the Page](the-server.md) - `server/app.rb`, `hub.rb`, `hub/not_found.rb`, `runner.rb`, `render/graph.rb` — one ERB template, served or baked.
21
+ * [The CLI](the-cli.md) - `cli.rb`, `cli/command.rb`, and the seventeen verb files — the only layer that parses argv, prints, and exits.
22
+ * [The Skill](the-skill.md) - `skill.rb` — the companion agent skill and its installer.
@@ -0,0 +1,53 @@
1
+ ---
2
+ type: Component
3
+ title: Search — One Facade, Two Engines
4
+ description: The facade owns the rows, the snippets, the ranking fields and the engine registry; an engine owns only matching, declares its capabilities, and is chosen by what the query needs.
5
+ tags: [structure, search, pure, extension-point]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/bundle/search.rb
10
+ ---
11
+
12
+ # The files
13
+
14
+ | file | what it owns |
15
+ |---|---|
16
+ | `lib/okf/bundle/search.rb` | the facade: rows, snippets, weights, the `Corpus`, and the engine registry |
17
+ | `lib/okf/bundle/search/scan.rb` | the default engine — raw-text scan, the only one that does `regexp` |
18
+ | `lib/okf/bundle/search/index.rb` | the minifts engine — BM25+, the only one that does `fuzzy` and `prefix` |
19
+
20
+ # The split
21
+
22
+ The facade decides **what a result is**: `WEIGHTS` (which field counts how much),
23
+ `FIELDS`, `SNIPPET_FIELDS`, `SNIPPET_RADIUS`, and the row shape every caller
24
+ reads. An engine decides only **which documents matched**, and says what it can
25
+ do through `CAPABILITIES`.
26
+
27
+ `Search.engine_for(required)` picks by capability, not by name: `ROUTABLE` is the
28
+ set a query can *demand* (`regexp`, `fuzzy`), `DEFAULT_ENGINE` is `:scan`, and
29
+ `UnsupportedQuery` / `UnknownEngine` are the two honest refusals. `available?`
30
+ lets an engine decline at runtime — the index engine needs `minifts` present.
31
+
32
+ `Corpus` is the cross-bundle form, behind `Search.across`.
33
+
34
+ # Why scan leads
35
+
36
+ The scan is the default because a one-shot CLI cannot amortise an index build:
37
+ 3.00 s versus 0.24 s at 1,000 concepts. That is a real argument against the
38
+ `minifts` dependency and it is recorded as such — but `--fuzzy` and parity with
39
+ the graph page's browser-side ranking both still need it, and a cached index
40
+ would restore the case outright.
41
+
42
+ Parity is the subtler half: the Ruby index engine is a bit-for-bit port of the
43
+ browser's MiniSearch, pinned to the same version the page lazy-loads, so an
44
+ `--engine index` result and a search typed into the page rank identically.
45
+
46
+ The verb that drives it is in [the-cli](/structure/the-cli.md).
47
+
48
+ # `Search.register` is an extension point
49
+
50
+ Append-only, idempotent by id, duck-type checked at registration —
51
+ **deliberately the same shape as `CLI.register`**. An engine is a module
52
+ answering `id`, `capabilities`, `available?`, `call`, and optionally `prepare`.
53
+ Adding one is a registration, not an edit to the facade.
@@ -0,0 +1,60 @@
1
+ ---
2
+ type: Component
3
+ title: The Analysers — Validate and Lint, Kept Apart
4
+ description: Two pure analysers with a boundary that is a spec requirement rather than a preference — conformance may not reject curation problems, and curation may not emit conformance errors.
5
+ tags: [structure, validate, lint, pure, spec]
6
+ generated:
7
+ by: human:maintainer
8
+ at: 2026-08-19T12:00:00Z
9
+ resource: lib/okf/bundle/validator.rb
10
+ ---
11
+
12
+ # The files
13
+
14
+ | file | what it owns |
15
+ |---|---|
16
+ | `lib/okf/bundle/validator.rb` | §11 conformance: hard errors, plus soft convention warnings |
17
+ | `lib/okf/bundle/validator/result.rb` | the errors/warnings collection and `valid?` |
18
+ | `lib/okf/bundle/linter.rb` | the curation report — every check, and their severities |
19
+ | `lib/okf/bundle/linter/report.rb` | findings by severity, the stats, and `healthy?` |
20
+
21
+ # The boundary is the point
22
+
23
+ **`validate` and `lint` stay separate**, and the line is the spec's, not taste.
24
+ §11 forbids the validator from *rejecting* a broken cross-link or a missing
25
+ optional field — those are warnings at most. Curation findings belong to lint,
26
+ which never emits a conformance error.
27
+
28
+ A new check goes to one side or the other, and getting it wrong changes what
29
+ exit code a user's CI sees: 0 ok, 1 a failing bundle, 2 a usage error.
30
+
31
+ `Validator::CONVENTION_CHECKS` is the soft half — the warnings that are about
32
+ convention rather than conformance, so a caller can tell them apart.
33
+
34
+ # Validator: one method per spec clause
35
+
36
+ The private methods are named for what they check — `validate_families`,
37
+ `validate_generated`, `validate_verified`, `validate_sources`,
38
+ `validate_usage_window`, `validate_lifecycle`, `validate_computation`,
39
+ `validate_parameters`, `validate_contract_mapping`, `validate_okf_version`,
40
+ `validate_index`, `validate_log`, `validate_reserved`, `validate_unparseable`.
41
+ A new §5 family is a new one of these; that is the shape to follow.
42
+
43
+ `validate_unparseable` is the one worth noticing: a file the reader could not
44
+ parse is an error, not an absence. A validator that skipped it would report
45
+ clean over the file most likely to be broken.
46
+
47
+ # Linter: severities are data, and downstream depends on them
48
+
49
+ `SEVERITIES` is the whole check list with each check's level, and `CHECKS` is
50
+ its keys. It is a public fact rather than an implementation detail — okf-pro
51
+ pins a frozen snapshot of it, because a released kernel that reclassified a
52
+ check would change what that gem's gate blocks on.
53
+
54
+ `only:` and `except:` select checks; `stale_before:`/`today:` supply the clock
55
+ that `expired` and `stale` need. **A check that could not run is reported, not
56
+ dropped**: `Report#to_h` carries `skipped_checks`, and `healthy?` over a silent
57
+ skip would be the same lie in a smaller box.
58
+
59
+ `DEFAULT_MIN_BODY` and `HUB_LIMIT` are the two tunables; `ACTOR_FORMS` is the
60
+ `generated.by` grammar, shared with the validator through `Concept`.