okf 1.10.0 → 1.11.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.
@@ -30,12 +30,57 @@ module OKF
30
30
  # GET /log the §7 history for the Log panel: { logs: [ {path,
31
31
  # dir, content} ] } (JSON; content read live from disk,
32
32
  # like a body — the log is the file that changes most)
33
+ # GET /search?q=… ranked concepts in this bundle, for the ⌘K palette:
34
+ # { query, total, truncated, results: [ …rows… ] } (JSON)
33
35
  class App
36
+ # How many rows /search answers with. The palette shows a handful and the
37
+ # rest is scroll nobody reaches, but the count is reported alongside so a
38
+ # capped answer never reads as a complete one.
39
+ SEARCH_LIMIT = 50
40
+
41
+ # The engine /search runs on, named rather than inferred. `fuzzy: true`
42
+ # would route here on its own today — the index is the only registered
43
+ # engine that offers it — but that is correctness by coincidence, and an
44
+ # addon declaring :fuzzy would silently take the route.
45
+ #
46
+ # It is also the *right* engine here for a reason the CLI's default does
47
+ # not share: `okf search` is one-shot and cannot amortize an index build,
48
+ # while this is a long-lived server answering keystroke after keystroke.
49
+ # And the page's own MiniSearch is what minifts is a port of, so a palette
50
+ # hit and an in-page search rank alike instead of nearly alike.
51
+ SEARCH_ENGINE = :index
52
+
53
+ # The /search payload, defined once because two hosts answer it: this one
54
+ # bundle, or every bundle the hub hosts. The only difference is the corpus
55
+ # handed in — and a nil slug drops the `slug` key from a row, so a
56
+ # standalone server never answers as if it were a set.
57
+ #
58
+ # It takes a prepared corpus rather than the pairs, because that is what
59
+ # makes the index survive the request that built it.
60
+ def self.search_payload(corpus, query)
61
+ terms = query.to_s.split(/\s+/).reject(&:empty?)
62
+ rows = terms.empty? ? [] : OKF::Bundle::Search.with(corpus, terms, fuzzy: true, engine: SEARCH_ENGINE)
63
+ {
64
+ "query" => query.to_s.strip,
65
+ "total" => rows.length,
66
+ "truncated" => rows.length > SEARCH_LIMIT,
67
+ "results" => rows.first(SEARCH_LIMIT)
68
+ }
69
+ end
70
+
34
71
  # +siblings+/+self_slug+/+hub_path+ are set only when this app is hosted under
35
72
  # a hub (OKF::Server::Hub): the other bundles the in-page switcher offers, this
36
73
  # bundle's own mount slug, the hub root, and the hub's cross-bundle search
37
74
  # route. They stay nil for a standalone server and for `okf render`, so a
38
75
  # static file never advertises a switcher or a search it cannot answer.
76
+ #
77
+ # +search_endpoint+ belongs to that group for the same reason, even though
78
+ # this app now answers /search itself: the page resolves it *relative to the
79
+ # URL the reader is on*, so only whoever mounted the app knows what to call
80
+ # it. `okf server` mounts at the root and passes "search"; a host doing
81
+ # `mount App.new(folder) => "/knowledge"` needs its own spelling, and a
82
+ # default would have pointed its palette at the host's root instead. The
83
+ # route answers either way — advertising it is the caller's call.
39
84
  def initialize(folder, title: nil, link: nil, layout: "cose", siblings: nil, self_slug: nil, hub_path: nil,
40
85
  search_endpoint: nil, manage_root: nil, manage_token: nil)
41
86
  @folder = folder
@@ -50,6 +95,14 @@ module OKF
50
95
  @manage_token = manage_token
51
96
  end
52
97
 
98
+ # Build the search index now rather than on the first reader's keystroke.
99
+ # `okf server` calls this after the bundle is loaded, so the cost lands in
100
+ # boot — where it is expected and attributable — instead of in a request.
101
+ def warm_search
102
+ search_corpus
103
+ self
104
+ end
105
+
53
106
  def call(env)
54
107
  request = Rack::Request.new(env)
55
108
  return not_found unless request.get?
@@ -63,6 +116,7 @@ module OKF
63
116
  when "/types" then respond_json(graph.type_index)
64
117
  when "/index" then respond_json(directory_index)
65
118
  when "/log" then respond_json(logs)
119
+ when "/search" then respond_json(self.class.search_payload(search_corpus, request.params["q"]))
66
120
  else not_found
67
121
  end
68
122
  end
@@ -147,6 +201,13 @@ module OKF
147
201
  [ 200, { "content-type" => content_type }, [ body.to_s ] ]
148
202
  end
149
203
 
204
+ # Built on the first search and held for the life of the app, the way the
205
+ # graph is. `okf server` warms it at boot so the first reader does not pay
206
+ # for the whole corpus.
207
+ def search_corpus
208
+ @search_corpus ||= OKF::Bundle::Search.prepare([ [ nil, @folder.bundle ] ], engine: SEARCH_ENGINE)
209
+ end
210
+
150
211
  def respond_json(object)
151
212
  respond("application/json; charset=utf-8", JSON.generate(object))
152
213
  end
@@ -45,22 +45,9 @@ module OKF
45
45
  # names" is how a router becomes an eval.
46
46
  WRITES = %w[default rename remove add].freeze
47
47
 
48
- # How many rows /search answers with. The palette shows a handful and the
49
- # rest is scroll nobody reaches, but the count is reported alongside so a
50
- # capped answer never reads as a complete one.
51
- SEARCH_LIMIT = 50
52
-
53
- # The engine /search runs on, named rather than inferred. `fuzzy: true`
54
- # would route here on its own today — the index is the only registered
55
- # engine that offers it — but that is correctness by coincidence, and an
56
- # addon declaring :fuzzy would silently take the route.
57
- #
58
- # It is also the *right* engine here for a reason the CLI's default does
59
- # not share: `okf search` is one-shot and cannot amortize an index build,
60
- # while this is a long-lived server answering keystroke after keystroke.
61
- # And the page's own MiniSearch is what minifts is a port of, so a palette
62
- # hit and an in-page search rank alike instead of nearly alike.
63
- SEARCH_ENGINE = :index
48
+ # The /search cap and engine live on App, which now defines the payload both
49
+ # hosts answer with (App.search_payload). Two copies of a constant is two
50
+ # places to raise the cap and one of them silently losing.
64
51
 
65
52
  # One hosted bundle: its +slug+ (unique mount key), the on-disk +folder+, and
66
53
  # its display +title+.
@@ -205,6 +192,12 @@ module OKF
205
192
  end
206
193
  private_class_method :load_entry
207
194
 
195
+ # See App#warm_search — same reason, one corpus over every hosted bundle.
196
+ def warm_search
197
+ search_corpus
198
+ self
199
+ end
200
+
208
201
  def call(env)
209
202
  request = Rack::Request.new(env)
210
203
  # Everything this class *emits* must carry the prefix a host mounted it
@@ -339,6 +332,9 @@ module OKF
339
332
  @apps = build_apps(@layout)
340
333
  @counts = nil
341
334
  @health = nil
335
+ # The corpus is a snapshot of the set, so a write that changes the set
336
+ # invalidates it. Without this a removed bundle keeps answering /search.
337
+ @search_corpus = nil
342
338
  end
343
339
 
344
340
  # Same-origin *and* the token. Neither alone is enough: the token lives in
@@ -374,14 +370,14 @@ module OKF
374
370
  # keystroke, and the box starts empty. `fuzzy: true` matches both the TUI
375
371
  # and the page's own MiniSearch, so all three forgive the same typos.
376
372
  def search(query)
377
- terms = query.to_s.split(/\s+/).reject(&:empty?)
378
- rows = terms.empty? ? [] : OKF::Bundle::Search.across(pairs, terms, fuzzy: true, engine: SEARCH_ENGINE)
379
- json(
380
- "query" => query.to_s.strip,
381
- "total" => rows.length,
382
- "truncated" => rows.length > SEARCH_LIMIT,
383
- "results" => rows.first(SEARCH_LIMIT)
384
- )
373
+ json(App.search_payload(search_corpus, query))
374
+ end
375
+
376
+ # One index over every hosted bundle — one corpus, so BM25 weighs a term
377
+ # against the whole set exactly as .across intends. Dropped whenever the
378
+ # bundle set changes, which is the only thing that can invalidate it.
379
+ def search_corpus
380
+ @search_corpus ||= OKF::Bundle::Search.prepare(pairs, engine: App::SEARCH_ENGINE)
385
381
  end
386
382
 
387
383
  # The /b/ manager's own rows, as JSON — what the graph page's Bundles panel
@@ -533,14 +529,26 @@ module OKF
533
529
  count: nil, health: "missing", word: word, default: false }
534
530
  end
535
531
 
532
+ # A bundle is addressed by its slug — `@orders` on the CLI, `/b/orders/`
533
+ # here — so the slug is its name and the folder is a fact about it. The row
534
+ # led with Folder.label instead, which put the address where the name goes;
535
+ # in a real registry that label is `…/.okf` on nearly every line, so the
536
+ # loudest column repeated the one word that tells no two bundles apart.
537
+ #
538
+ # The short label is gone rather than demoted: it only ever stood in for the
539
+ # path, and the path is right here on the row's second line.
540
+ #
541
+ # The name keeps its `@`, which the ref line used to carry: it is the exact
542
+ # spelling `okf lint @orders` takes, so the row teaches the CLI for free —
543
+ # and one element now does the whole job two were splitting.
536
544
  def manager_row(base, row)
537
545
  name = if row[:mount]
538
- %(<a class="name" href="#{escape(base)}#{MOUNT}/#{escape(row[:mount])}/">#{escape(row[:title])}</a>)
546
+ %(<a class="name" href="#{escape(base)}#{MOUNT}/#{escape(row[:mount])}/">@#{escape(row[:slug])}</a>)
539
547
  else
540
- %(<span class="name off">#{escape(row[:title])}</span>)
548
+ %(<span class="name off">@#{escape(row[:slug])}</span>)
541
549
  end
542
550
  %(<li class="row" data-health="#{escape(row[:health])}">) +
543
- %(<div class="who">#{name}<div class="ref"><span class="slug">@#{escape(row[:slug])}</span>) +
551
+ %(<div class="who">#{name}<div class="ref">) +
544
552
  # The row shows the tail; the tooltip is where the whole path stays
545
553
  # reachable, since nothing else on the page carries it.
546
554
  %(<span class="dir" title="#{escape(row[:dir])}"><bdi>#{escape(row[:dir])}</bdi></span></div></div>) +
@@ -83,10 +83,10 @@ flags. The division of labour is the whole game:
83
83
 
84
84
  - **Shell out — never eyeball —** anything a verb computes: conformance (§9), what
85
85
  exists, what links where, where a term lives, what's stale, the map. Every read
86
- verb takes `--json` and the list views filter by type/area/tag, so ask the narrow
86
+ verb takes `--json` and the list views filter by type/dir/tag, so ask the narrow
87
87
  question instead of paging the bundle.
88
- - **Skeleton first, bodies last.** `index --no-body`, `search`, `graph --minimal`,
89
- and `--fields` projections each answer for a fraction of a dump's bytes; full
88
+ - **Skeleton first, bodies last.** `dirs`, `search`, `graph --minimal`, and
89
+ `--fields` projections each answer for a fraction of a dump's bytes; full
90
90
  bodies are the final step of a retrieval, never the first. <!-- rule:okf-skeleton-first -->
91
91
  - **You judge — the CLI can't —** meaning: contradictions, semantic staleness
92
92
  (parses fine, no longer true), whether a loose file is terminal-by-design, whether
@@ -102,12 +102,15 @@ shapes, the tag-curation views, the server's trust boundary.
102
102
 
103
103
  ## Orient before you touch anything
104
104
 
105
- Picking up a bundle you don't already know — to consume or maintain — run `okf
106
- index <dir|@slug>` (the §6 map: every directory's index body, rollups, and listings) and
107
- read `log.md` (the §7 baseline of what changed last) **before** greping or opening
108
- leaves. It is the cheapest high-signal context, and the only reliable way to catch
109
- enumeration drift: **grep cannot find an index entry that is missing** — you can't
110
- search for the word that should be there but isn't. <!-- rule:okf-orient-index -->
105
+ Picking up a bundle you don't already know — to consume or maintain — start with
106
+ `okf dirs <dir|@slug>`: one row per *directory*, so it stays small on a bundle of
107
+ any size and it names the branches every other view narrows to. Then open the one
108
+ you want with `okf index <dir|@slug> --dir <branch>` (the §6 map: that directory's
109
+ index body, rollups, and listing), and read `log.md` (the §7 baseline of what
110
+ changed last) — all of it **before** greping or opening leaves. Reach for `index`
111
+ rather than grep for the one reason that outranks convenience: **grep cannot find
112
+ an index entry that is missing**, so enumeration drift is invisible to it — you
113
+ can't search for the word that should be there but isn't. <!-- rule:okf-orient-index -->
111
114
  Per-verb steps are in the
112
115
  playbooks (the Commands table below; no `okf` installed? read the root
113
116
  `index.md` plus each area's `index.md`).
@@ -1,8 +1,8 @@
1
1
  # Playbook: consume — use a bundle as context
2
2
 
3
- 1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf index <dir|@slug>` maps
4
- the whole bundle in one pass — every directory's index body, rollups, and listings —
5
- and `log.md` gives recent history. Address a registered bundle by `@slug` (bare
3
+ 1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf dirs <dir|@slug>`
4
+ gives the shape, `okf index <dir|@slug> --dir <branch>` opens the branch you
5
+ want — its index body, rollups and listing — and `log.md` gives recent history. Address a registered bundle by `@slug` (bare
6
6
  `@` = the default); if the cwd carries no bundle, `okf registry list` finds one
7
7
  instead of a directory hunt. Then follow links only into the concepts the
8
8
  task needs. For a *pointed question* rather than broad context, switch to the
@@ -5,8 +5,9 @@ up. Restructuring the bundle itself — moving concepts, adding areas — is
5
5
  [refine](refine.md), not maintain. The modelling craft behind steps 3 and 7
6
6
  lives in [authoring.md](../reference/authoring.md).
7
7
 
8
- 1. **Orient before hunting.** Run `okf index <dir>` (the §6 map — every directory's
9
- index body, rollups, and listings), read `log.md` (the §7 baseline: what changed
8
+ 1. **Orient before hunting.** Run `okf dirs <dir>` (the shape), then `okf index
9
+ <dir> --dir <branch>` on the branch the change touches (the §6 map: its index
10
+ body, rollups and listing), read `log.md` (the §7 baseline: what changed
10
11
  last), and `okf stats <dir>` (size and shape) *before* you grep. It is the
11
12
  cheapest context and it primes the hunt — and it is the only reliable way to
12
13
  catch enumeration drift, because **grep cannot find an index entry that is
@@ -45,7 +46,7 @@ lives in [authoring.md](../reference/authoring.md).
45
46
  defect.** Loose ≠ orphan: an index listing makes a file *reachable* (not an
46
47
  orphan) but is not a graph edge, so an indexed file can still float here.
47
48
  7. **Curate the tag vocabulary** when the pass
48
- touched tags, or when `okf tags <dir>` shows a long tail of singletons. Run `okf tags <dir> --by area` and
49
+ touched tags, or when `okf tags <dir>` shows a long tail of singletons. Run `okf tags <dir> --by dir` and
49
50
  `--by type` — the grouped view is the analysis; read each group top-down:
50
51
  - **twins** — two tags riding the exact same concepts (equal counts sort them
51
52
  adjacent). Merge into one unless each genuinely names a different theme.
@@ -34,7 +34,7 @@ is the lede.
34
34
  (`git status`), prefer **`maintain`**: that is exactly the drift it exists
35
35
  to close.
36
36
  - **clean, but the shape strains** — one area dwarfing the rest in
37
- `okf stats`, tags spread thin across areas in `okf tags --by area`, hubs
37
+ `okf stats`, tags spread thin across dirs in `okf tags --by dir`, hubs
38
38
  whose inbound links are mostly foreign in `okf graph --hubs` → offer
39
39
  **`refine`** (evidence-driven restructuring; it proposes before it
40
40
  touches anything).
@@ -19,14 +19,15 @@ relationship rides links and tags, never new directories. And cohesion outranks
19
19
  balance — a move has semantic cost, so balance is a tiebreaker and a fatness
20
20
  alarm, never the objective. <!-- rule:okf-cohesion-over-balance -->
21
21
 
22
- 1. **Orient.** `okf index <dir|@slug> --no-body` (areas, fan-out, depth),
23
- `log.md` (how the bundle grew), `okf stats` (totals). Additive growth
22
+ 1. **Orient.** `okf dirs <dir|@slug>` (directories, fan-out, depth — and
23
+ `subtree` says where the weight sits), `log.md` (how the bundle grew), `okf
24
+ stats` (totals). Additive growth
24
25
  optimizes each pass locally, never the whole — that is the drift this
25
26
  playbook corrects.
26
27
  2. **Measure — the CLI is the evidence.** Baseline `validate` / `lint
27
28
  --stale-after` / `loose` first: refine assumes a sound bundle, and hard
28
29
  errors are [curate](curate.md)'s job. Then the two structural reads:
29
- - `okf tags <dir> --by area` — each row carries `count/total`, so a tag's
30
+ - `okf tags <dir> --by dir` — each row carries `count/total`, so a tag's
30
31
  **locality** reads directly: a tag wholly inside one area names a *domain*
31
32
  (the directories are right); one spread across areas names a *concern*.
32
33
  - `okf graph <dir> --hubs` — concepts ranked by inbound links, each with
@@ -12,12 +12,12 @@ reads, and full bodies are read last, and only the winners.
12
12
  the root `index.md` then each relevant area's `index.md` by hand. No bundle in
13
13
  the cwd? `okf registry list` names the registered ones — address them by
14
14
  `@slug`, don't hunt sibling directories.
15
- 2. **Ingest the map and decide where to look.** `okf index <dir|@slug> --no-body` is
16
- the skeleton: every directory with its concept count, types, tags, children.
17
- *You* do the semantic matching here — the question names a meaning, the map
18
- names areas; connect them by judgment, not string equality. When an area
19
- looks right, `okf index <dir> --area <name>` buys its authored index body and
20
- listing (titles + descriptions) for the price of one directory.
15
+ 2. **Ingest the map and decide where to look.** `okf dirs <dir|@slug>` is the
16
+ skeleton: every directory with what lives under it. *You* do the semantic
17
+ matching here — the question names a meaning, the map names directories;
18
+ connect them by judgment, not string equality. When one looks right, `okf
19
+ index <dir|@slug> --dir <name>` buys its authored index body and listing
20
+ (titles + descriptions) for the price of one directory.
21
21
  <!-- rule:okf-search-map-first -->
22
22
  3. **Cut across with the finder when the question is lexical.** An exact
23
23
  symbol, an error code, a column name, a phrase — things structure won't
@@ -52,7 +52,7 @@ reads, and full bodies are read last, and only the winners.
52
52
  default when you can. <!-- rule:okf-search-fuzzy-is-a-switch -->
53
53
 
54
54
  Scope any of them with what the map taught you:
55
- `--area billing`, `--type Decision`, `--tag idempotency`, `--in body`.
55
+ `--dir billing`, `--type Decision`, `--tag idempotency`, `--in body`.
56
56
  Matches rank by where they hit, and the snippet often *is* the answer.
57
57
  When the answer may live in another registered bundle, span them — leading
58
58
  @slugs (`okf search @handbook @notes <terms>`) or `@all` for every registered
@@ -150,7 +150,7 @@ as Ruby regular expressions with `--regexp`/`-e` (an invalid pattern is a usage
150
150
  error, exit 2). `--fuzzy` forgives typos; pairing it with `-e` is a usage error,
151
151
  since a pattern is matched literally rather than by edit distance.
152
152
  `--in a,b` restricts the searched fields (title, id, tags, type, description,
153
- body); the shared `--type/--area/--tag` filters narrow the candidates *first*,
153
+ body); the shared `--type/--dir/--tag` filters narrow the candidates *first*,
154
154
  so a search scoped by what `index` taught you stays surgical.
155
155
 
156
156
  **The default is exact, so an exact query means what it looks like.** A phrase in
@@ -273,8 +273,30 @@ there, its child directories, and the concept listing. Run it first when picking
273
273
  an existing bundle: it is the cheapest high-signal orientation, and it surfaces
274
274
  enumeration drift a grep can't (you can't grep for a listing entry that is *missing*).
275
275
 
276
- `--area A` narrows to a directory and is **repeatable** — `--area model --area
277
- format` shows both; `root` names the bundle root. `--no-body` drops the prose to a
276
+ `--dir PATH` narrows to a directory **and everything below it**, and is
277
+ **repeatable** — `--dir model --dir format` shows both; `root` (or `.`) names the
278
+ bundle root. A `--dir` also brings the **chain from the root down to it**, so a branch is
279
+ never shown adrift of the authored context that says what it is — the root
280
+ `index.md`'s prose first among it. Those rows print with a leading `↑` and carry
281
+ `ancestor: true`; `--no-ancestors` drops them. Ascent and descent are separate
282
+ axes, so `--depth` never bounds the chain: `--dir X --depth 0` is X alone, plus
283
+ how you get to X. A `--dir` that names nothing gains no chain — a lone root row
284
+ would read as a partial answer to a query that matched nothing.
285
+
286
+ `--depth N` bounds how far below the starting point the map reaches
287
+ (the `--dir` when one is given, else the bundle root), counted **relatively**:
288
+ `--depth 1` is the top of the tree, `--dir X --depth 1` is one branch of it, and
289
+ the pair walks down a level at a time.
290
+
291
+ **On a bundle of any size the map is unreadable whole** — every directory is a
292
+ section, and even `--no-body` keeps one listing row per *concept* — so narrow
293
+ rather than paging it: `okf dirs` is the orientation, `--dir <branch> --depth 1`
294
+ is the step down into it,
295
+ and `--except body,listing` on top of either is the lean JSON skeleton. Full
296
+ `index` output on a few hundred concepts runs to hundreds of KB; the same map at
297
+ `--depth 1` is a couple of KB.
298
+
299
+ `--no-body` drops the prose to a
278
300
  skeleton (headers, rollups, child pointers). For a directory that has concepts but
279
301
  **no `index.md`**, the listing is **synthesized** from the concepts' descriptions
280
302
  and tagged `(no index.md)` — §6 explicitly permits synthesizing a map on the fly.
@@ -283,7 +305,49 @@ It is a **read view**: advisory, always exit 0. A synthesized directory is a
283
305
  *signal* (a map worth writing), never a defect — `index` emits no lint findings and
284
306
  never fails a bundle. JSON: `{ bundle, count, directories: [{ dir, index_path,
285
307
  present, synthesized, count, types, tags, subdirs, body, listing: [{ id, title,
286
- description, type, tags }] }] }`.
308
+ description, type, tags }] }] }` — `ancestor` marks a row that is there to place
309
+ the branch rather than to answer about it.
310
+
311
+ ## dirs — the bundle's clusters and their sizes
312
+
313
+ `okf dirs <dir>` lists every directory the bundle has — the ones holding
314
+ concepts, the ones carrying an `index.md`, and the empty intermediates that only
315
+ exist to connect the tree — with the number of concepts living **directly** in
316
+ each and the number in its **subtree**. A cluster *is* a directory here, so this
317
+ is the view that tells you what `--dir` can be pointed at and how much sits
318
+ behind each choice.
319
+
320
+ Two numbers, because one cannot answer the question. `count` is direct, so the
321
+ column sums to the bundle's concept total and a dir holding only sub-directories
322
+ reads `0` rather than a hidden rollup. `subtree` is defined as *exactly what
323
+ `--dir <that row>` returns*, so the row and the flag can never disagree — which
324
+ is also why the root's subtree is its own direct count (`.` is a prefix of
325
+ nothing). Without it a truncated listing is all zeroes at the top of a deep tree,
326
+ which is where you most need to know where the mass is. The human table shows the
327
+ second column only where some dir actually nests.
328
+
329
+ `--dir PATH` (repeatable) narrows to a directory and its subtree, and brings the
330
+ **chain up to the root** with it so the branch is placed rather than shown
331
+ adrift — those rows are marked `↑`, carry `ancestor: true`, and stay out of
332
+ `total` (`--no-ancestors` drops them). `--depth N` keeps only N levels below the
333
+ starting point — the `--dir` when one is given,
334
+ the bundle root otherwise. Relative, not absolute, so `--dir a/b --depth 1`
335
+ reads "a/b and one level under it" without your first working out how deep `a/b`
336
+ is. `--depth 0` is the starting point alone. A `--depth` that is not a whole
337
+ number is a usage error (exit 2).
338
+
339
+ **This is the first command to run on a bundle you do not know** — the same first
340
+ move [SKILL.md](../SKILL.md) prescribes. `okf dirs <dir>` is one row per
341
+ directory, so its size tracks the tree rather than the concept count: it tells
342
+ you the shape and where the weight sits, `--depth 1` trims it further on a deep
343
+ bundle, and you then descend with `okf index --dir`, one level at a time.
344
+
345
+ The root prints `(root)` and stores `.` — the split every grouped view keeps, so
346
+ a table and its `--json` never disagree about which spelling is the data. JSON:
347
+ `{ bundle, total, count, dirs: [{ dir, ancestor, count, subtree, subdirs }] }`,
348
+ root first. `count` is rows printed, chain included; `total` sums the direct
349
+ counts of the rows you actually asked for, which is what keeps a row's `subtree`
350
+ equal to the `total` that `--dir` on that row returns.
287
351
 
288
352
  ## catalog / files / tags / types / stats — the server views, as text
289
353
 
@@ -293,7 +357,8 @@ All are advisory reads (exit 0) sharing one data source (per-concept metadata pl
293
357
  in/out link degree). Add `--json` to any for a machine substrate.
294
358
 
295
359
  - **`catalog`** — every concept with its metadata (type, status, tags, timestamp,
296
- in/out link degree, description), grouped by top-level area. The "what's here, in
360
+ in/out link degree, description), grouped by top-level area (`dir` on every row
361
+ carries the full path). The "what's here, in
297
362
  detail" view. JSON: `{ bundle, count, concepts: [{ id, title, type, description,
298
363
  tags, timestamp, status, backlog_ref, dir, area, links_out, links_in }] }`.
299
364
  - **`files`** — the folder tree: each concept's filename + title, grouped by
@@ -301,7 +366,7 @@ in/out link degree). Add `--json` to any for a machine substrate.
301
366
  id, dir, type, title, description }] }`.
302
367
  - **`tags`** — every tag with the concepts that carry it, ordered by count
303
368
  descending. The "what themes dominate" view. JSON: `{ bundle, count, tags: [{ tag,
304
- count, concepts: [id, …] }] }`. `--by type|area` regroups the list per concept
369
+ count, concepts: [id, …] }] }`. `--by type|dir` regroups the list per concept
305
370
  dimension with **within-group** counts (a tag spanning groups appears in each);
306
371
  each row also carries the tag's **total** across the narrowed set, printed
307
372
  `count/total` when they differ — so a tag's locality reads per row (a plain
@@ -313,18 +378,32 @@ in/out link degree). Add `--json` to any for a machine substrate.
313
378
  - **`types`** — every type with the concepts that carry it, ordered by count
314
379
  descending. The "what kinds of knowledge" view. JSON: `{ bundle, count, types:
315
380
  [{ type, count, concepts: [id, …] }] }`.
316
- - **`stats`** — bundle rollups: concept / area / type / cross-link / distinct-tag
317
- totals plus per-type and per-area breakdowns. The "shape at a glance" view. JSON:
318
- `{ bundle, concepts, areas, concept_types, cross_links, distinct_tags, by_type, by_area }`.
381
+ - **`stats`** — bundle rollups: concept / dir / type / cross-link / distinct-tag
382
+ totals plus per-type and per-dir breakdowns. The "shape at a glance" view. JSON:
383
+ `{ bundle, concepts, dirs, areas, concept_types, cross_links, distinct_tags,
384
+ by_type, by_dir, by_area }` (`areas`/`by_area` are the deprecated first-segment
385
+ cut, kept for one release). `dirs`/`by_dir` cover every directory `okf dirs`
386
+ lists — counts are direct, so a directory holding nothing itself is present at
387
+ `0` rather than missing, and `by_dir.keys` is a complete list of what `--dir`
388
+ can address.
319
389
 
320
390
  The four list views narrow with the same filters the browser panels offer —
321
- `--type TYPE`, `--area AREA`, `--tag TAG`; each takes the ones orthogonal to
322
- itself (`tags` can't filter by tag). Matching is case-insensitive and exact; a
323
- concept at the bundle root lives in the `(root)` area, which `--area` also accepts
324
- as plain `root` (no shell quoting). A filter that matches nothing is an empty view,
325
- not an error: `okf tags <dir> --area billing --json` answers "which tags does the
326
- billing area use?", `okf catalog <dir> --tag auth` answers "what carries the auth
327
- tag?".
391
+ `--type TYPE`, `--dir PATH`, `--tag TAG`; each takes the ones orthogonal to
392
+ itself (`tags` can't filter by tag). Matching is case-insensitive; `--type` and
393
+ `--tag` are exact, `--dir` takes the named directory **and everything below it**
394
+ (`--dir platform` reaches `platform/services/api`). A concept at the bundle root
395
+ lives in `.`, which `--dir` also accepts as plain `root` (no shell quoting). A
396
+ filter that matches nothing is an empty view, not an error: `okf tags <dir> --dir
397
+ billing --json` answers "which tags does the billing cluster use?",
398
+ `okf catalog <dir> --tag auth` answers "what carries the auth tag?".
399
+
400
+ **`--area` is deprecated.** It still works — matching the *first path segment*
401
+ only, its old behavior unchanged — and prints `warning: --area is deprecated, use
402
+ --dir` on stderr (stdout stays clean, so a `--json` consumer is unaffected). Same
403
+ for `tags --by area`. Both go in a later release; write `--dir` in anything new.
404
+ On `index` it combines with neither `--depth` nor `--dir` — exit 2, because it is
405
+ *exact* and both of those select a range, so the pair used to return the area
406
+ plus whatever the other flag selected: an answer to neither question.
328
407
 
329
408
  Reach for `stats` first to size a bundle, `catalog`/`files` to enumerate it, `tags`
330
409
  to find thematic clusters — all without standing up the server.
@@ -340,10 +419,13 @@ in a body render as diagrams, and a click (or tap) opens the diagram full
340
419
  screen with drag-to-pan and wheel/pinch zoom. Concepts render as nodes
341
420
  coloured by `type` and sized by degree, links as edges, with a detail panel
342
421
  (rendered markdown, "Links to" / "Linked from" backlinks), layout switching,
343
- type/area/tag filters on every view, and search. The authored layer is in the
422
+ type/dir/tag filters on every view (the dir chips take a directory *and* its
423
+ subtree, the same rule `--dir` uses), and search. Cluster mode groups the
424
+ concepts into one box per directory, nested to a depth picked beside the layout
425
+ select — depth 1 is the flat view, and a flat bundle is offered no control. The authored layer is in the
344
426
  UI too: the Files view carries **Files | Indexes** tabs — the Indexes tab
345
427
  lists the log first (the chronological index), then every `index.md` — and
346
- folder nodes in file-tree mode and area boxes in cluster mode open a
428
+ folder nodes in file-tree mode and directory boxes in cluster mode open a
347
429
  directory's §6 map in the inspector (authored, or synthesized when none
348
430
  exists). Links to an `index.md`, `log.md`, or bare directory navigate instead
349
431
  of dead-ending, and the log is fetched fresh on every read, so a
data/lib/okf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OKF
4
- VERSION = "1.10.0"
4
+ VERSION = "1.11.0"
5
5
  end
data/lib/okf.rb CHANGED
@@ -25,6 +25,15 @@ module OKF
25
25
  value.respond_to?(:empty?) ? value.empty? : false
26
26
  end
27
27
 
28
+ # The directory a concept lives in, derived from its §2 id: the id *is* the
29
+ # path minus `.md`, so putting the suffix back and taking the dirname is the
30
+ # definition rather than a parse of it. One home for it because three views
31
+ # answer with a `dir` — the catalog, the search rows, the linter's per-directory
32
+ # checks — and a rule spelled three times is three things to keep in step.
33
+ def self.dir_of(id)
34
+ File.dirname("#{id}.md")
35
+ end
36
+
28
37
  require "okf/version"
29
38
 
30
39
  # ── kernel: cross-cutting primitives ──
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -54,11 +54,22 @@ dependencies:
54
54
  description: |
55
55
  OKF (Open Knowledge Format) is portable knowledge: Markdown files with YAML
56
56
  frontmatter that both humans and agents read from one source. This gem is the
57
- Ruby-native way to work with it. Its companion agent skill authors and curates
58
- a bundle; the `okf` command-line tool validates the result for v0.1 (§9)
59
- conformance, lints its curation quality, and serves it as an interactive graph
60
- (a mountable Rack app). The same validate, lint, and graph run in-process
61
- through a library API (OKF::Bundle and friends).
57
+ Ruby-native way to work with it.
58
+
59
+ Its companion agent skill authors and curates a bundle. The `okf` command-line
60
+ tool validates the result for v0.1 (§9) conformance, lints its curation
61
+ quality, and answers questions about it: ranked full-text search, and a
62
+ progressive-disclosure map that reads a large bundle a directory at a time
63
+ rather than loading it whole. `okf server` opens it as an interactive
64
+ knowledge graph and `okf render` bakes that same page into one self-contained
65
+ HTML file you can host anywhere. A per-user registry names your bundles, so
66
+ every verb reaches them by @slug from any directory and one search can span
67
+ them all.
68
+
69
+ Everything the CLI does also runs in-process through a library API
70
+ (OKF::Bundle and friends), and the graph server is a mountable Rack app. It
71
+ adds no service to your stack: rack, webrick and minifts are the only runtime
72
+ dependencies, and it runs on every Ruby since 2.4.
62
73
  email:
63
74
  - rodrigo.serradura@gmail.com
64
75
  executables:
@@ -88,6 +99,7 @@ files:
88
99
  - lib/okf/cli.rb
89
100
  - lib/okf/cli/catalog.rb
90
101
  - lib/okf/cli/command.rb
102
+ - lib/okf/cli/dirs.rb
91
103
  - lib/okf/cli/files.rb
92
104
  - lib/okf/cli/graph.rb
93
105
  - lib/okf/cli/index.rb
@@ -160,6 +172,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
160
172
  requirements: []
161
173
  rubygems_version: 4.0.16
162
174
  specification_version: 4
163
- summary: 'The complete toolkit for the Open Knowledge Format: an agent skill, a CLI
164
- and library, and a live knowledge graph. 100% local.'
175
+ summary: 'The complete Open Knowledge Format toolkit: an agent skill, a CLI and library,
176
+ ranked search, and a live knowledge graph. 100% local.'
165
177
  test_files: []