okf 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -225,10 +225,15 @@ docker run --rm -v "$PWD:/data" ghcr.io/serradura/okf validate .
225
225
  docker run --rm -v "$PWD:/data" -p 8808:8808 ghcr.io/serradura/okf server . --bind 0.0.0.0
226
226
  ```
227
227
 
228
+ A container has to bind `0.0.0.0` for the host to reach it at all, which is
229
+ exactly the bind that makes the registry read-only. Reading the graph is
230
+ unaffected, and there is no flag that opens the registry on that bind — it is a
231
+ per-user file, managed from the machine that owns it or from `okf registry`.
232
+
228
233
  Then open <http://127.0.0.1:8808>. Images are published for `linux/amd64` and
229
234
  `linux/arm64` on
230
235
  [ghcr.io](https://github.com/serradura/okf-gem/pkgs/container/okf): `:latest`
231
- tracks the newest release, or pin a version like `:1.9.0`.
236
+ tracks the newest release, or pin a version like `:1.10.0`.
232
237
 
233
238
  Tired of the long line? Install a Docker-backed [`okf` command](https://docker.okfgem.com),
234
239
  so every verb drops the `docker run` prefix and reads exactly like the native CLI
@@ -246,13 +251,20 @@ instead: `irm https://docker.okfgem.com/install.ps1 | iex`.
246
251
 
247
252
  ## Command line
248
253
 
254
+ These verbs are written to be read by an **agent first and a person second** —
255
+ that is what the skill drives, with no wrapper in between. Every read verb takes
256
+ `--json`, `index`/`catalog`/`files` project down to the fields you ask for
257
+ (`--fields`/`--except`), so nothing pays for output it will not read, and the
258
+ exit codes are stable enough to branch on in CI. The same commands render as
259
+ scannable plain text when a human is the one looking.
260
+
249
261
  ```bash
250
262
  okf validate <dir|@slug> [--json] # check OKF v0.1 conformance (§9)
251
263
  okf lint <dir|@slug> [--json] [--fail-on warn] [...] # report curation-quality issues
252
264
  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
265
+ okf search <dir|@slug…|@all> <term…> [--regexp|--fuzzy] # ranked retrieval; @slugs or @all span bundles
254
266
  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)
267
+ okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] # serve one bundle, or many behind a hub (⌘K searches every one)
256
268
  okf render <dir|@slug> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
257
269
  okf registry list [--json] # list registered bundles (* marks the default)
258
270
  okf registry set <dir|@slug> [--as SLUG] [--default] # add or update a bundle (a bare `server` serves it)
@@ -269,8 +281,11 @@ okf registry default <@slug> | rename <@slug> <new> # move a bundle to th
269
281
  # set`, or bare @ for the default. Anywhere a <dir> goes, an @slug goes:
270
282
  # okf lint @handbook works from anywhere. Set $OKF_HOME to point every verb
271
283
  # at another registry.
272
- okf graph <dir|@slug> [--json] [--minimal] [--no-body] # print the knowledge graph
284
+ okf graph <dir|@slug> [--json] [--minimal] [--hubs] # print the knowledge graph (--hubs ranks by inbound links)
273
285
  okf catalog | files | tags | types | stats <dir|@slug> [--json] # the browser views, on the CLI
286
+ # `tags --by type|area` regroups the tag index per concept dimension; each row
287
+ # shows count/total, so a tag confined to one area (a domain) reads differently
288
+ # from one spread across several (a cross-cutting concern).
274
289
  okf skill <dest> [--here] [--force] # install the companion agent skill
275
290
  okf --version
276
291
  ```
@@ -303,12 +318,20 @@ _The graph server on this repo's own [`.okf`](.okf) bundle, with the
303
318
 
304
319
  The page is one template from a phone to a desktop: on small screens the
305
320
  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
321
+ panels go full-bleed — rotate a tablet and the layout re-evaluates. On a touch
322
+ screen a tap opens a preview card at the bottom edge rather than a panel over
323
+ the whole viewport, so the graph stays on screen and live while you read: drag
324
+ the card up for the neighbourhood, tap a link in it and it walks there in place.
325
+ It is keyboard-first too: `⌘/Ctrl-K` opens a command palette in every mode (views
308
326
  always; bundles too when a [hub](#one-registry-many-bundles) is serving), `/`
309
327
  jumps to the current view's search, and `?` answers with a sheet of every
310
328
  shortcut.
311
329
 
330
+ The search box says what it is doing while it does it: a live `7/8` count of
331
+ what the filter kept, and — when a word matches nothing here — a panel naming
332
+ the bundle and the query, offering to search every other bundle instead. The
333
+ query carries over, so nothing is typed twice.
334
+
312
335
  To skip the server entirely, **`okf render <dir>`** writes that same page as one
313
336
  self-contained HTML file, the whole bundle baked in, so you can publish the
314
337
  graph on GitHub Pages or any static host.
@@ -329,10 +352,32 @@ okf server # no args: the whole registry behind one
329
352
 
330
353
  The first entry still on disk is the **default** — the bundle a bare `okf
331
354
  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.
355
+ entry to the front. Behind the hub each bundle mounts at `/b/<slug>/`, and the
356
+ `⌘/Ctrl-K` palette both switches bundles and **searches every one of them at
357
+ once** type a few words and the matching concepts appear with their bundle and
358
+ a snippet, from wherever you are.
359
+
360
+ The ⚙ in the rail opens **Bundles** — the registry on the graph page itself, so
361
+ switching the default, renaming an entry or dropping one no longer means finding
362
+ `/b/` first. Each row carries its size, its health as a *word*, and which one `/`
363
+ opens. There is no Add: registering a bundle means naming a filesystem path,
364
+ which a browser cannot hand over and an agent can (`okf registry set <dir>`), and
365
+ the panel says so instead of leaving the absence to be noticed.
366
+
367
+ `/b/` is the **bundles list**: every bundle with its size, its health
368
+ (conformance plus curation, so a warning is a warning and not a failure), the
369
+ default marked, and any entry whose folder has gone missing shown rather than
370
+ quietly dropped.
371
+
372
+ Managing the registry without a terminal happens on the graph page instead, in
373
+ the ⚙ **Bundles** panel — make default, rename, remove, where you are already
374
+ reading. Those controls are the one thing that does not follow you onto a
375
+ network: bind anywhere but loopback and they are refused outright, with no flag
376
+ that says otherwise, since `--bind 0.0.0.0` is how a personal tool becomes a
377
+ public one. `--read-only` declines them on loopback too, and is the only switch
378
+ there is. Writes rebuild the hub's set as they go, so a rename takes effect on
379
+ the next click; a registry change made *elsewhere* while it runs still wants a
380
+ restart to be served. Set `$OKF_HOME` to point every verb at a different registry.
336
381
 
337
382
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
338
383
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
@@ -362,6 +407,22 @@ OKF lint — docs
362
407
  ⚠ 3 warn, 31 info
363
408
  ```
364
409
 
410
+ That `hubs:` line is the bundle's centre of gravity — the concepts the most links
411
+ point *at*, with their inbound count. **`okf graph <dir> --hubs`** expands it into
412
+ the full ranking and, for each hub, the areas those links come *from*:
413
+
414
+ ```bash
415
+ $ okf graph docs --hubs
416
+ Hubs — docs (38 of 40 concepts with inbound links)
417
+
418
+ storage/reconcile ×16 storage 6, shape 4, exploration 3, (root) 1, …
419
+ ```
420
+
421
+ That second half is what decides whether a concept sits where it belongs: a hub
422
+ pulled in mostly from outside its own folder is usually homed by history rather
423
+ than by meaning. Together with `okf tags <dir> --by area`, it is the evidence the
424
+ skill's [`refine`](#agent-skill) verb reads before proposing anything.
425
+
365
426
  `loose` lists the files that float in the graph: concepts with no cross-links
366
427
  in or out (graph degree 0), grouped by folder. It is a curation lens over
367
428
  `lint`'s `unlinked` check, distinct from `orphan`. An `index.md` listing makes a
@@ -391,11 +452,21 @@ The skill routes a small set of verbs. In Claude Code they run as `/okf:gem
391
452
  | `produce` | Create or extend a bundle from code, docs, or knowledge in people's heads |
392
453
  | `migrate` | Adopt existing Markdown docs in place: frontmatter and reserved files added, bodies kept verbatim |
393
454
  | `maintain` | Sync the bundle's content with reality after the code or docs change |
455
+ | `refine` | Restructure it for retrieval: evidence-first, cohesion over balance — proposes, never applies |
394
456
  | `consume` | Use the bundle as context for a task, writing back what you learn |
395
457
  | `curate` | Structural upkeep as it stands: `validate` + `lint` + `loose` |
396
458
  | `doctor` | Install and verify the CLI, then doctor the bundle |
397
459
  | `<okf-cli-verb>` | Run any CLI verb (`validate`, `lint`, `search`, `index`, `server`, the read views) and interpret its output |
398
460
 
461
+ Three of those look alike and are not, which is the distinction worth learning
462
+ first: **`curate`** keeps the bundle *sound* (the structure as it stands),
463
+ **`maintain`** keeps it *true* (the code changed, so the content must catch up),
464
+ and **`refine`** changes *where knowledge lives* — the folder a concept sits in,
465
+ a fact re-explained in three overviews. Reach for `refine` when nothing is wrong
466
+ and everything is hard to find: a folder grown to twenty concepts, a hub homed by
467
+ history, tags that neither connect nor mark. It reads the evidence, then hands
468
+ you a proposal — it never rearranges your bundle on its own.
469
+
399
470
  Point it at your agent's config directory (or its skills directory) and the tree
400
471
  settles in its own `skills/okf/` folder, so a shared skills directory never gets
401
472
  the files loose:
@@ -513,6 +584,26 @@ Two loop concerns from the format's own guidance, _contradictions_ and _semantic
513
584
  staleness, need to understand meaning and are not computed here; `lint --json`
514
585
  is the structured input an agent consumes to reason about those.
515
586
 
587
+ ## Extending okf
588
+
589
+ This is the other kind of plugin, and it is worth separating from the
590
+ [Claude Code plugin](#claude-code-plugin) below: that one teaches an *agent* to
591
+ use okf, while this one adds behavior to the `okf` command everyone runs.
592
+
593
+ Publish a gem named `okf-*` that carries an `okf/plugin.rb`, and installing it is
594
+ the whole installation: your verb answers to `okf`, appears in `okf help` under
595
+ `installed extensions:`, and behaves like a built-in — nothing to register,
596
+ nothing to configure, no list of known addons in this gem. The same seam takes
597
+ retrieval backends, which is what `okf search --engine NAME` chooses between.
598
+
599
+ Two promises make that safe to install: nothing an addon registers can displace a
600
+ built-in, and a broken addon is skipped and reported rather than taking the CLI
601
+ down with it.
602
+
603
+ The [extension points](.okf/design/extension-points.md) concept has the contract
604
+ and the threat model; `test/integration/cli/cli_plugin_test.rb` is a working
605
+ example to copy.
606
+
516
607
  ## Server trust boundary
517
608
 
518
609
  > [!NOTE]
@@ -46,6 +46,10 @@ module OKF
46
46
  @bundle.catalog
47
47
  end
48
48
 
49
+ def hubs
50
+ @bundle.hubs
51
+ end
52
+
49
53
  def directory_index
50
54
  @bundle.directory_index
51
55
  end
data/lib/okf/bundle.rb CHANGED
@@ -119,13 +119,31 @@ module OKF
119
119
  status: concept.frontmatter["status"]&.to_s,
120
120
  backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
121
121
  dir: File.dirname("#{id}.md"),
122
- area: id.include?("/") ? id.split("/").first : "(root)",
122
+ area: area_of(id),
123
123
  links_out: out_degree[id],
124
124
  links_in: in_degree[id]
125
125
  }
126
126
  end.sort_by { |entry| entry[:id] }
127
127
  end
128
128
 
129
+ # Concepts ranked by inbound link degree, each with the areas its inbound
130
+ # links come from — the evidence for "is this hub well-homed?": a hub whose
131
+ # inbound majority is foreign to its own area is a move candidate, one with
132
+ # a single dominant foreign area already names its better home. Only
133
+ # concepts with at least one inbound link appear. Pure: derived from the
134
+ # graph edges. Shared by the `okf graph --hubs` view.
135
+ def hubs
136
+ inbound = {}
137
+ graph(minimal: true).edges.each do |edge|
138
+ (inbound[edge[:target]] ||= Hash.new(0))[area_of(edge[:source])] += 1
139
+ end
140
+
141
+ inbound.map do |id, sources|
142
+ by_area = sources.sort_by { |area, count| [ -count, area ] }.to_h
143
+ { id: id, area: area_of(id), inbound: by_area.values.reduce(0, :+), by_area: by_area }
144
+ end.sort_by { |row| [ -row[:inbound], row[:id] ] }
145
+ end
146
+
129
147
  # The progressive-disclosure map (spec §6): one entry per directory that holds
130
148
  # concepts or carries an index.md, sorted with the root (".") first. Each entry
131
149
  # gives the authored index body (frontmatter stripped) when an index.md is
@@ -171,6 +189,12 @@ module OKF
171
189
 
172
190
  private
173
191
 
192
+ # A concept's top-level area, derived from its id — the same derivation the
193
+ # catalog exposes, so every grouped view labels the bundle root "(root)".
194
+ def area_of(id)
195
+ id.include?("/") ? id.split("/").first : "(root)"
196
+ end
197
+
174
198
  # Every directory to show: those holding concepts or an index.md, plus each of
175
199
  # their ancestors up to the root, so the subdir tree stays connected even when
176
200
  # an intermediate directory holds nothing directly. Sorted with "." first.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Every concept with its metadata, grouped by area. The widest of the read
6
+ # views, and the one the others narrow down from.
7
+ class Catalog < Command
8
+ def self.id
9
+ :catalog
10
+ end
11
+
12
+ def self.group
13
+ :read
14
+ end
15
+
16
+ def self.help_rows
17
+ [
18
+ [ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by area" ]
19
+ ]
20
+ end
21
+
22
+ def call(argv)
23
+ options = { json: false }
24
+ parser = OptionParser.new do |o|
25
+ o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
26
+ json_flags(o, options, "emit the catalog as JSON")
27
+ projection_flags(o, options)
28
+ filter_flags(o, options, :type, :area, :tag)
29
+ help_flag(o)
30
+ end
31
+ dir = positional_dir(parser, argv) or return 2
32
+
33
+ folder = OKF::Bundle::Folder.load(dir)
34
+ report_skipped(folder)
35
+ entries = folder.catalog
36
+ selected = filter_entries(entries, options)
37
+ return print_catalog_json(dir, selected, options) if options[:json]
38
+
39
+ print_catalog(dir, selected, entries.size)
40
+ 0
41
+ end
42
+
43
+ private
44
+
45
+ def print_catalog(dir, entries, total)
46
+ @out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
47
+ entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
48
+ @out.puts
49
+ @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
50
+ group.each do |entry|
51
+ links = entry[:links_out] + entry[:links_in]
52
+ meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
53
+ @out.puts " #{entry[:title]} · #{meta}"
54
+ @out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
55
+ end
56
+ end
57
+ end
58
+
59
+ def print_catalog_json(dir, entries, options)
60
+ emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
61
+ end
62
+ end
63
+
64
+ register(Catalog)
65
+ end
66
+ end