okf 2.1.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee1290010aa1d32e2f38408b2c630666cec67265f41e7c7186297274748120fa
4
- data.tar.gz: a6b6218572fe7a74c38d0445940954c0fdbe591adab3e820d7de5aed71809828
3
+ metadata.gz: 0aee1cdfc7b13016c423050f98de4994431fa68ae4afe3869b5555b5408c3315
4
+ data.tar.gz: '058369e3e6131e6af6de0b3e599bdc7716e3296f0ff1caacd59f905088e5c4ce'
5
5
  SHA512:
6
- metadata.gz: a7d0af2ca85fd75d51587f513c53f667e77c13b3f407da10fb6463d921e3796ba5b6746fb77e5c5c11c26a700a54e00ee3e805310f98179cf328a858d7496195
7
- data.tar.gz: c3a0276b516320e16f54cf3f647e3c6d6ea94625fd2341df6640903a7e5fc64b20c2a9e2b82065cf64519852db0cf2aec35cdc79ddad783852890a123c8e4911
6
+ metadata.gz: 7d91fe567c067d175cfa3274773e984720384315d2eb8fb790da247ee256706b48e079ba4a4d9e5b909a4e6f480f968574d4526e6d6b80b4e443b57785d9ac36
7
+ data.tar.gz: e075baf062df84f4567200cfe4d375280dcf5cd9b76aad7d9802d1ae28cc00245982c6604e7e5fea080b15c47cee0644c5f75b7e2468379fc386de985ad1fe45
@@ -53,6 +53,17 @@ red. The verdict keeps the [validate/lint separation](validator.md): a curation
53
53
  finding is a warning and the bundle stays open, and only a non-conformant bundle
54
54
  reads as broken.
55
55
 
56
+ A bundle that arrived through a registry [link](../registry.md) is listed,
57
+ mounted and served like any other, and is **read-only**: the file that owns it is
58
+ another registry, so the model refuses `rename`, `remove` and `default` against
59
+ it. The page carries that in both halves, because one without the other is a
60
+ worse page than neither. The row shows `via @onm` and **offers no actions menu** —
61
+ three buttons that can only fail are not a menu — and the request is refused
62
+ anyway if it arrives, since the guard lives in `Registry` rather than here. That
63
+ is the same "hiding a button is a UI, refusing the request is the boundary" split
64
+ the read-only gate above keeps, reached from the other direction: there the
65
+ server declines to write at all, here one row does.
66
+
56
67
  Rows are matched to entries by **directory**, not by slug. A rename changes the
57
68
  slug and nothing else, and a row that lost its identity over a rename is the bug
58
69
  that avoids.
@@ -225,7 +225,9 @@ even with zero matches — only an invalid `--regexp` pattern, `-e` paired with
225
225
  Knowledge rarely lives in one bundle, so search is the one verb that spans the
226
226
  [registry](../registry.md): leading @slugs pick bundles explicitly
227
227
  (`okf search @handbook @notes auth`), and `@all` is the ref that means every
228
- registered one. Every row is labeled by its bundle's slug.
228
+ registered one including the bundles a [link](../registry.md) folds in, since
229
+ those are registered here in every sense that matters to a reader looking for an
230
+ answer. Every row is labeled by its bundle's slug.
229
231
 
230
232
  **Merged rows are comparable by construction, and each engine earns that
231
233
  differently.** The scan's score is absolute — summed field weights, with no
data/.okf/cli.md CHANGED
@@ -74,21 +74,42 @@ side should not enrol them in the user's durable list. Registering is always the
74
74
  explicit act of `okf registry set`.
75
75
 
76
76
  `registry` is an umbrella verb — `init`, `list`, `set`, `del`, `default`,
77
- `rename`, `group`, `ungroup` — over one persistent file. `$OKF_HOME` points every
77
+ `rename`, `group`, `ungroup`, `link`, `unlink`, `import` — over one persistent
78
+ file, and it
79
+ is **one row in the map**. It carried ten for a while, which made a third of `okf
80
+ help` about registry management when registry management is not a third of what
81
+ okf does; the row now points at `okf registry --help`, which prints the ten with
82
+ their grammar and the `-g` they share. The map's job is to name the verbs. A verb
83
+ whose own help is the manual is the same progressive disclosure the bundles are
84
+ built on, applied to the CLI's front page — and the row has to *say* the help
85
+ exists, since nothing else tells a reader an umbrella has subcommands at all. `$OKF_HOME` points every
78
86
  one of them at a different global registry, which is what keeps the tests off the
79
87
  real `~/.okf`; `registry init` and discovery add a [project-local](registry.md#global-by-default-project-local-by-discovery)
80
88
  one that replaces it while you stand in its tree, with `OKF_NO_DISCOVERY=1` to
81
89
  force the global one.
82
90
 
83
- One lever, not two — and the levers that exist are env vars, never flags. An
84
- earlier design also carried a `--home DIR` flag, which had to be remembered on the
85
- three verbs that offered it and forgotten on every other one a flag whose whole
86
- job was to name a location the env var already named. `$OKF_HOME` composes where a
87
- flag cannot: it reaches every verb at once without being typed, it survives into a
88
- subprocess, and if the directory ever holds more than `registry.json` it keeps
89
- meaning the same thing. `OKF_NO_DISCOVERY` earns its keep the same way one signal
90
- that reaches every verb, so project-local resolution has no per-verb `--global` to
91
- thread and forget.
91
+ ## One lever, not two
92
+
93
+ A *cross-cutting* lever is an env var, never a flag. An earlier design carried a
94
+ `--home DIR` flag, which had to be remembered on the three verbs that offered it
95
+ and forgotten on every other one a flag whose whole job was to name a location
96
+ the env var already named. `$OKF_HOME` composes where a flag cannot: it reaches
97
+ every verb at once without being typed, it survives into a subprocess, and if the
98
+ directory ever holds more than `registry.json` it keeps meaning the same thing.
99
+ `OKF_NO_DISCOVERY` earns its keep the same way, and this is still why there is no
100
+ `--global` on `lint`, on `search`, or on the eleven others: a per-verb flag for
101
+ resolution would be fourteen places to thread it and fourteen to forget.
102
+
103
+ The `registry` umbrella is the exception, and the line is *subject*, not
104
+ convenience. Every other verb takes a bundle and merely happens to resolve a name
105
+ through a registry; `registry`'s argument **is** a registry file. Saying which one
106
+ is therefore an argument to that verb in the way `--as` is, not a lever bolted
107
+ across the CLI — so `-g`/`--global` lives on its subcommands (all but `init`,
108
+ whose whole job is to create a *local* file) and nowhere else. The env var is
109
+ still the right answer for a whole session or a CI job; the flag is the right
110
+ answer for one command, and for the plain fact that a capability reachable only
111
+ through an env var is one most people never discover. Both name the same
112
+ behavior, so neither can drift from the other.
92
113
 
93
114
  # Every output names its bundle, in the identity the caller used
94
115
 
data/.okf/log.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Update Log
2
2
 
3
+ ## 2026-08-22
4
+
5
+ * **The registry can now take a row out of another registry file, and the
6
+ project-local file has a shorter name.** [`registry
7
+ import`](/registry.md#import-the-copy-that-owns-what-it-takes) is the opposite
8
+ trade from `link`: a link holds a live pointer to a whole file and the other
9
+ side keeps owning what it lends, while an import copies chosen references and
10
+ hands over ownership. Both are needed because they answer different questions —
11
+ "compose that repository's curation" against "I want *that one bundle*, here,
12
+ as mine". Without it the second had one answer: read the path out of `okf
13
+ registry list -g` and retype it into `registry set`, laundering through the
14
+ clipboard information already on screen.
15
+
16
+ Three decisions did the work. The source is `--from`, not a second meaning for
17
+ `-g`, because `-g` names the registry acted *on* across all ten sibling
18
+ subcommands and a verb that names two files should name the second rather than
19
+ invert the first. Slugs are preserved and a collision **refuses**, which is
20
+ where import and link deliberately disagree: a linked name was never chosen
21
+ here so `link_slug` mints around it, an imported one was typed so the "never
22
+ substitute a name you chose" rule applies in full. And a group brings the
23
+ groups nested inside it, because recreating a name here that resolved to a
24
+ larger set there is that same substitution with nothing on screen to reveal it.
25
+ Everything is validated before anything is written, so an import lands whole or
26
+ leaves the file byte-for-byte alone — a refusal that already moved three of four
27
+ rows is not a refusal.
28
+
29
+ * **`.okf.json` is the project-local registry's name; `.okf-registry.json` is
30
+ still discovered.** The file is committed, so retiring the old name outright
31
+ would break every repository carrying one to save eight characters. Both are
32
+ checked *per directory* on the way up rather than one name swept to the root
33
+ and then the other, or a legacy file at a repo root would beat a `.okf.json`
34
+ two levels down and "the nearest one wins" would quietly mean something else.
35
+ The deprecation note is the `registry` umbrella's alone — the one verb whose
36
+ subject is a registry file, and the one nobody runs in a loop or pipes into
37
+ something else, which is exactly what `lint` and `search` are.
38
+
39
+ ## 2026-08-21
40
+
41
+ * **The [registry](/registry.md) composes other registry files, and its umbrella
42
+ grew a `-g`.** A repository that curates its own bundles — this one commits a
43
+ `.okf-registry.json` naming five — could not lend that curation to `~/.okf`
44
+ without registering every row again by hand and re-syncing forever. `okf
45
+ registry link <name> <file>` points the global registry at the file instead:
46
+ its bundles resolve here at read time under their own slugs, `@<name>` is the
47
+ set, and nothing is copied, so the target goes on owning its rows. Two
48
+ restrictions carry the whole design. Only the *global* registry follows links,
49
+ which makes depth one structural rather than enforced — a linked file's own
50
+ links are never read, so no chain forms and there is no cycle to detect. And a
51
+ link is read-only: `rename`, `del`, `default`, `set --as` and `group` all
52
+ refuse a slug it owns, from the `⚙ Bundles` panel as from the terminal, because
53
+ the guard lives in the model rather than in the [CLI](/cli.md).
54
+
55
+ A linked name is minted around a collision rather than refused (`central` →
56
+ `onm-central`), which is the "implicit is forgiving" rule reaching a name this
57
+ registry did not choose — and it is the design's one *computed* slug, so
58
+ `registry list` prints the moved row with the name it carries in its source
59
+ file. `-g`/`--global` is the smaller half: `OKF_NO_DISCOVERY=1` already forced
60
+ the global registry, but a lever reachable only through an env var is one most
61
+ people never find. It is the `registry` umbrella's alone — the one verb whose
62
+ subject *is* a registry file — so [the env-var rule](/cli.md#one-lever-not-two)
63
+ keeps its scope rather than losing its edge.
64
+
3
65
  ## 2026-08-19
4
66
 
5
67
  * **This bundle became okf's own, and holds everything about okf.** It began the
data/.okf/registry.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Component
3
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 — the kernel behind a bare `okf server`.
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
5
  resource: gems/okf/lib/okf/registry.rb
6
6
  tags: [cli, shell, registry]
7
7
  generated:
@@ -217,7 +217,7 @@ a per-user file, and locking would buy nothing worth the complexity.
217
217
 
218
218
  The registry has two homes, and which one answers is decided by *where you stand*,
219
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-registry.json`
220
+ user, shared across every repo. The project-local one is a `.okf.json`
221
221
  that `okf registry init` drops in a directory; okf finds it by walking up from the
222
222
  working directory, and while you are inside its tree it **replaces** the global one
223
223
  — every registry op, and every [`@slug`](cli.md), resolves through it. So a bare
@@ -238,12 +238,36 @@ users who set up a project registry. The escape hatch is therefore a per-invocat
238
238
  signal, not a second sticky variable — `OKF_NO_DISCOVERY=1`, set inline, forces the
239
239
  global registry for a fixed-cwd caller (CI, a tool) that cannot just `cd` out.
240
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
+
241
265
  # A project-local registry stores portable paths
242
266
 
243
267
  The global registry stores absolute paths — correct for `~/.okf`, whose bundles
244
268
  are scattered across the disk with no shared anchor. A committed project registry
245
269
  needs the opposite: a bundle **inside** the registry's own tree is stored *relative*
246
- to the `.okf-registry.json`, so the file travels with the repo — a checkout on
270
+ to the `.okf.json`, so the file travels with the repo — a checkout on
247
271
  another machine, or a container mounting it, resolves the same bundles unchanged. A
248
272
  bundle **outside** the tree keeps an absolute path, because a relative path that
249
273
  climbs out cannot be re-anchored anywhere useful, and being honest that it will not
@@ -257,6 +281,149 @@ existing absolute local entry migrates to relative on its next write: a registry
257
281
  written before this existed heals itself the first time it changes.
258
282
  <!-- rule:okf-registry-local-discovery -->
259
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
+
260
427
  # It costs an embedding app nothing
261
428
 
262
429
  `require "okf"` does not load it. The registry is reached only from the
@@ -23,7 +23,7 @@ And one file per verb, each registering itself at load:
23
23
  | `lib/okf/cli/skill.rb` | `skill` |
24
24
  | `lib/okf/cli/server.rb` | `server` |
25
25
  | `lib/okf/cli/render.rb` | `render` |
26
- | `lib/okf/cli/registry.rb` | `registry` and its eight subcommands |
26
+ | `lib/okf/cli/registry.rb` | `registry` and its eleven subcommands |
27
27
  | `lib/okf/cli/lint.rb` | `lint` |
28
28
  | `lib/okf/cli/loose.rb` | `loose` |
29
29
  | `lib/okf/cli/validate.rb` | `validate` |
@@ -50,17 +50,26 @@ logic belongs instead.
50
50
 
51
51
  `Registry` is the `@slug` layer. `HOME_ENV`/`DEFAULT_HOME` is the global
52
52
  registry under `$OKF_HOME` (default `~/.okf`); `LOCAL_FILE` is the project-local
53
- `.okf-registry.json` that `discover` finds by walking up from the working
53
+ `.okf.json` (or the legacy `.okf-registry.json`) that `discover` finds by walking up from the working
54
54
  directory. **A discovered local registry replaces the global one outright** — it
55
55
  does not merge — and `NO_DISCOVERY_ENV` (`OKF_NO_DISCOVERY=1`) is the escape
56
56
  hatch that forces the global one, which is what a test that must not see the
57
- developer's registry sets.
57
+ developer's registry sets. `Registry.load`'s `follow_links:` is the same
58
+ decision one step further: it is true only for the global registry, so a local
59
+ one parses links and never resolves them.
58
60
 
59
61
  `relative_base` is why a local registry's paths stay relative and the file
60
62
  travels with the repository. `Registry#reopen` preserves it; `Registry.new(path)`
61
63
  does not, and reaching for the latter is how a reload comes to resolve every
62
64
  bundle against the wrong base.
63
65
 
66
+ `Link` points the global registry at another registry file; `resolve_links`
67
+ folds that file's bundles in (through `link_slug`, which prefixes only on a
68
+ collision) and `refuse_linked` is the single guard every write goes through —
69
+ here rather than in the CLI, because the server's bundles panel calls these same
70
+ methods. `open_linked` constructs the target with `follow_links: false`, which is
71
+ the whole of the depth rule.
72
+
64
73
  `Group` is a named, recursive set of bundles. `RESERVED_SLUGS` is `all`, because
65
74
  `@all` means every bundle and may not be shadowed. `slugify`, `dedupe`,
66
75
  `normalize` and `path_shaped?` are the naming rules a `registry set` goes
@@ -11,7 +11,7 @@ resource: lib/okf/cli/command.rb
11
11
 
12
12
  # Before adding one, check it is not already there
13
13
 
14
- Seventeen commands and eight subcommands already exist, catalogued in
14
+ Seventeen commands and eleven subcommands already exist, catalogued in
15
15
  [cli](/cli.md) and [read-views](/capabilities/read-views.md), and `okf help`
16
16
  prints the same list. Most of what a new verb would want is a *flag* on an
17
17
  existing view rather than a view of its own: the shared filters and projections
data/CHANGELOG.md CHANGED
@@ -5,6 +5,111 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.2.0] - 2026-08-22
9
+
10
+ ### Added
11
+
12
+ - **`okf registry import <@slug…> [--from FILE]` — copy chosen bundles out of
13
+ another registry file into the one in force, where they become yours.** It is
14
+ the opposite trade from `link` and the pair is the point: a link holds a live
15
+ pointer to a whole file and the other side keeps owning what it lends, while an
16
+ import copies chosen references and hands over ownership, so what lands renames,
17
+ defaults and groups like anything you registered by hand. The case it exists for
18
+ is standing in a repo, running `okf registry list -g`, and wanting one of the
19
+ bundles it shows — before this the only way to move it was to read the path off
20
+ the screen and retype it into `registry set`.
21
+
22
+ The source is `--from`, defaulting to the global registry, because `-g` goes on
23
+ meaning the registry acted *on*, as it does on every sibling subcommand. A group
24
+ ask brings its members and any group nested inside it, recreated under the same
25
+ names. Slugs are preserved, so a collision **refuses** (`--as` renames a single
26
+ ask) rather than being minted around the way a linked name is — a linked name was
27
+ never chosen here, an imported one was typed. A bundle already registered here
28
+ under another name refuses first, naming that name. Every ask is validated before
29
+ anything is written, so an import lands whole or leaves the file byte-for-byte
30
+ alone.
31
+
32
+ - **`.okf.json` is the project-local registry's filename.** `okf registry init`
33
+ writes it, and it is what the docs now name. The older `.okf-registry.json` is
34
+ **still discovered**, so no committed registry breaks: both names are checked in
35
+ each directory on the way up — per directory, not one name swept to the root and
36
+ then the other, or a legacy file at a repo's root would beat a `.okf.json` two
37
+ levels down and "the nearest one wins" would mean something else. Within one
38
+ directory the short name wins, and a legacy file left beside it is named on
39
+ stderr rather than silently ignored.
40
+
41
+ The deprecation note is the `registry` umbrella's alone, and it is one stderr
42
+ line: that verb's subject *is* a registry file, and it is not the one people run
43
+ in loops or pipe into something else, which is exactly what `lint` and `search`
44
+ are. Renaming is `git mv .okf-registry.json .okf.json`, and the note says so. One
45
+ transitional cost worth naming: a checkout renamed to `.okf.json` is invisible to
46
+ an installed okf older than 2.2.0, which will fall back to the global registry
47
+ until that install is upgraded.
48
+
49
+ - **`okf registry link <name> <file>` — the global registry points at another
50
+ registry file, and that file's bundles resolve here.** Nothing is copied: the
51
+ target keeps owning its rows, so an edit there shows on the next read. The case
52
+ it exists for is a repository that already curates its own bundles in a
53
+ committed `.okf-registry.json` — a link composes that curation instead of
54
+ duplicating it, and the target resolves its own relative paths exactly as it
55
+ would from inside that checkout. A linked bundle answers to its own slug, or to
56
+ `<name>-<slug>` when the name is already taken here; `@<name>` resolves as a
57
+ group over the set; `okf registry unlink <name>` drops it.
58
+
59
+ Links are the **global** registry's alone. A project-local one parses them and
60
+ never resolves them, which is what makes depth one structural — a linked file's
61
+ own links are never followed, so no chain forms and there is no cycle to guard.
62
+ A link is read-only: `rename`, `del`, `default`, `set --as` and `group` refuse a
63
+ slug it owns, naming the file that does, from the browser's ⚙ Bundles panel as
64
+ from the terminal. A target that has gone or cannot be parsed is listed
65
+ `(missing)`/`(unreadable)` and resolves to nothing, rather than taking down the
66
+ registry holding it.
67
+
68
+ - `registry group` and `registry ungroup` refuse a group a link brought in —
69
+ the link's own set, or one the linked file curates. Naming one took the
70
+ update path, reported `grouped …`, and discarded the change on the next read,
71
+ because only the groups this registry owns are written. A write that reports
72
+ success and does not happen is worse than one that raises.
73
+
74
+ - **`-g`/`--global` on every `registry` subcommand but `init`.** It forces the
75
+ `$OKF_HOME` registry for one command, so `okf registry list -g` reads the global
76
+ one from inside a repo carrying its own, and `okf registry set <dir> -g`
77
+ registers there without leaving. `OKF_NO_DISCOVERY=1` already did this for a
78
+ whole session and still does; the flag exists because a lever reachable only
79
+ through an env var is one most people never find. It stays on the `registry`
80
+ umbrella — the one verb whose subject *is* a registry file — and nowhere else.
81
+
82
+ - The graph page's ⚙ Bundles panel marks a linked row `via @onm` and **offers no
83
+ actions menu** on it. Rename, Remove and Default are all refused for a linked
84
+ bundle, so the menu could only have produced three errors. The request is
85
+ refused whether or not the button exists — the guard is in `Registry`, not in
86
+ the page — which is the same split the read-only server already keeps: hiding
87
+ a control is a UI, refusing the request is the boundary.
88
+
89
+ - **`okf help` shows one `registry` row instead of ten, and `okf registry --help`
90
+ prints the subcommands.** Ten rows for one verb made a third of the map about
91
+ registry management, and the map is there to name the verbs rather than to be
92
+ every verb's manual. The row names the help that replaces it, since nothing
93
+ else tells a reader an umbrella has subcommands. A bare `okf registry` still
94
+ lists the registry, and `okf registry <command> --help` still answers for that
95
+ command.
96
+
97
+ ### Changed
98
+
99
+ - `okf registry list --json` groups now carry a `link` key — `null` for a group
100
+ this registry owns, the link's name for one that arrived through a link.
101
+ `Registry#groups_listing` returns both kinds in one list, own groups first,
102
+ because `#group?` resolves a linked group and a listing that named only the
103
+ local half would answer about a smaller set than the same object can resolve —
104
+ the drift every library consumer would inherit silently. A caller that wants
105
+ only the editable groups filters on `link`.
106
+
107
+ - `okf registry list --json` gains a `links` array, and each bundle row gains
108
+ `link` (the link it arrived through, `null` when the registry owns it) and
109
+ `origin` (its slug in that file, differing only where a collision moved the
110
+ name). The on-disk registry gains a `"links"` key, written empty when there are
111
+ none; an older file with no such key reads unchanged.
112
+
8
113
  ## [2.1.1] - 2026-08-20
9
114
 
10
115
  ### Fixed
data/README.md CHANGED
@@ -131,6 +131,8 @@ okf graph <dir|@slug> [--hubs] [--traffic] # the raw graph; --hubs ranks c
131
131
  okf server [DIR|@slug…] [-p PORT] [--bind ADDR] # the live graph: one bundle, or all of them
132
132
  okf render <dir|@slug> [-o FILE] # the same page as one static, self-contained file
133
133
  okf registry init | list | set | del | default | rename | group | ungroup # name & group your bundles
134
+ okf registry link | unlink <name> <file> # fold another registry file's bundles in
135
+ okf registry import <@slug…> [--from FILE] # copy bundles out of another registry into this one
134
136
  okf skill <dest> # install the companion agent skill
135
137
  okf --version
136
138
  ```
@@ -173,17 +175,44 @@ at once — a durable subset for the two verbs that take several bundles.
173
175
 
174
176
  The registry lives under `$OKF_HOME` (default `~/.okf`) — one per user. For one
175
177
  scoped to a single project instead, `okf registry init` drops a
176
- `.okf-registry.json` in the current directory; okf then discovers it by walking up
177
- from wherever you run, and every registry op and every `@slug` resolves through
178
+ `.okf.json` in the current directory (the older `.okf-registry.json` is still
179
+ discovered, and `okf registry` says so once so you can `git mv` it); okf then
180
+ discovers it by walking up from wherever you run, and every registry op — and every `@slug` — resolves through
178
181
  it in place of the global one. So a bare `okf server` inside that repo serves *its*
179
- bundles with no `$OKF_HOME` setup. The nearest registry wins, and
180
- `OKF_NO_DISCOVERY=1` forces the global one.
182
+ bundles with no `$OKF_HOME` setup. The nearest registry wins; `-g` on any
183
+ `registry` subcommand reaches the global one for a single command (`okf registry
184
+ list -g`, `okf registry set ./docs -g`), and `OKF_NO_DISCOVERY=1` does it for a
185
+ whole shell.
181
186
 
182
187
  Commit that file and it travels with the repo: a bundle under the project root is
183
188
  stored relative to the registry, so a checkout on another machine — or a container
184
189
  that mounts the repo — resolves the same bundles unchanged. (Bundles outside the
185
190
  tree keep absolute paths, which do not travel.)
186
191
 
192
+ A repository that already curates its own bundles can lend that list rather than
193
+ have it copied. `okf registry link onm ~/ONM/.okf.json` points the global
194
+ registry at another registry file: its bundles resolve under their own slugs,
195
+ `@onm` names the set, and nothing is duplicated — edit the other file and the
196
+ change shows on the next read. They are read-only from here, since the file that
197
+ owns them is elsewhere.
198
+
199
+ ```bash
200
+ okf registry link okf ~/code/okf/.okf.json # that repo's own curation, composed
201
+ okf search @all rate limit # now spans both files
202
+ ```
203
+
204
+ When you want one bundle rather than a whole file, `import` is the opposite
205
+ trade — it copies the reference, and what lands is yours to rename, default and
206
+ group. The source is `--from`, defaulting to the global registry, which inside a
207
+ repo is the only other one you have. A group ask brings its members with it,
208
+ slugs are preserved (a collision refuses rather than being renamed behind your
209
+ back), and nothing is written unless every ask checks out.
210
+
211
+ ```bash
212
+ okf registry list -g # what does the global registry hold?
213
+ okf registry import handbook specs # take those two, here, as mine
214
+ ```
215
+
187
216
  Behind the hub each bundle mounts at `/b/<slug>/`, `/b/` lists them all, and the
188
217
  `⌘/Ctrl-K` palette both switches bundles and **searches every one at once** — type
189
218
  a few words and the matching concepts appear with their bundle and a snippet, from
@@ -577,12 +577,17 @@ module OKF
577
577
 
578
578
  # The registry a verb resolves against — the single seam that opts the CLI
579
579
  # into discovery. `cwd: Dir.pwd` is what makes OKF::Registry.load walk up for
580
- # a project-local .okf-registry.json; a library caller passing no cwd stays
580
+ # a project-local .okf.json; a library caller passing no cwd stays
581
581
  # global-only. The registry subcommands and `server` open through here too,
582
582
  # so a bare `okf server` inside a repo serves that repo's bundles.
583
- def open_registry
583
+ # +global+ forces the $OKF_HOME registry by withholding the cwd discovery
584
+ # needs — the per-command form of OKF_NO_DISCOVERY, and the only thing
585
+ # `okf registry -g` does. It is an argument to the `registry` verb rather
586
+ # than a flag on all fourteen, because that verb's *subject* is a registry
587
+ # file; every other verb keeps inheriting the env var's one signal.
588
+ def open_registry(global: false)
584
589
  require "okf/registry"
585
- OKF::Registry.load(cwd: Dir.pwd)
590
+ OKF::Registry.load(cwd: global ? nil : Dir.pwd)
586
591
  end
587
592
 
588
593
  # Resolve one @ref through the active registry — a discovered project-local