okf 1.10.0 → 1.12.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.
@@ -45,8 +45,10 @@ module OKF
45
45
  # `okf render`: the whole page as one self-contained file, the bundle baked
46
46
  # in, so it hosts where no server answers a fetch. Takes any bundle handle
47
47
  # (an OKF::Bundle::Folder) and returns the HTML string.
48
- def self.static(folder, title: nil, link: nil, layout: "cose")
49
- new(folder.graph(minimal: true), title: title || folder.name, link: link, layout: layout, embed: payload(folder)).render
48
+ def self.static(folder, title: nil, link: nil, layout: "cose", map: false)
49
+ graph = folder.graph(minimal: true)
50
+ new(graph, title: title || folder.name, link: link, layout: layout, embed: payload(folder),
51
+ cuts: folder.skeleton.cuts_for(graph.edges), map: map).render
50
52
  end
51
53
 
52
54
  # What the baked page carries in place of the endpoints a live server would
@@ -73,10 +75,17 @@ module OKF
73
75
  # default — injects an empty SIBLINGS, so the switcher never appears in a
74
76
  # single bundle or a static file. +search_endpoint+ rides along with them:
75
77
  # the hub's cross-bundle /search, which only a hub can answer.
78
+ # +cuts+ is one integer per edge, in @graph.edges order: the cut that edge
79
+ # survives (OKF::Bundle::Skeleton). It rides inline rather than being
80
+ # fetched because its only consumer needs it *before* the first layout
81
+ # runs, which is earlier than any request could answer. nil is allowed and
82
+ # simply turns the reduced first layout off.
76
83
  def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil,
77
84
  siblings: nil, self_slug: nil, hub_path: nil, search_endpoint: nil,
78
- manage_root: nil, manage_token: nil)
85
+ manage_root: nil, manage_token: nil, cuts: nil, map: false)
79
86
  @graph = graph
87
+ @cuts = cuts
88
+ @map = map
80
89
  @title = title
81
90
  @link = link
82
91
  @layout = layout
@@ -133,6 +142,21 @@ module OKF
133
142
  json_for_script(@graph.edges)
134
143
  end
135
144
 
145
+ # Index-aligned with EDGES: `EDGE_CUT[i]` is the cut `EDGES[i]` survives.
146
+ # An array of small integers rather than a keyed map, because keying it by
147
+ # "source target" would cost more bytes than the edge list it annotates.
148
+ def edge_cuts_json
149
+ json_for_script(@cuts)
150
+ end
151
+
152
+ # Whether the page opens in the Map view — every concept, boxed by the
153
+ # directory it lives in, with the cross-links undrawn until one is
154
+ # selected. A boot state rather than a different page: the toggle is the
155
+ # same one the reader can press, so `--map` only decides where they start.
156
+ def map_json
157
+ json_for_script(@map ? true : false)
158
+ end
159
+
136
160
  # { type => [id, …] } — the client builds an id→type map for node colour.
137
161
  def types_json
138
162
  json_for_script(@graph.type_index)
@@ -21,7 +21,7 @@ module OKF
21
21
  # GET /node/meta?id=… its description, as an escaped HTML fragment
22
22
  # GET /catalog rich per-concept metadata for the catalog/files/stats
23
23
  # views: { concepts: [ {id, title, type, description,
24
- # tags, timestamp, status, area, dir, links_*} ] } (JSON)
24
+ # tags, timestamp, status, top_dir, dir, links_*} ] } (JSON)
25
25
  # GET /tags the tag index { tag => [id, …] } (JSON)
26
26
  # GET /types the type index { type => [id, …] } (JSON)
27
27
  # GET /index the §6 progressive-disclosure map for the Index panel:
@@ -30,18 +30,64 @@ 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
- search_endpoint: nil, manage_root: nil, manage_token: nil)
85
+ search_endpoint: nil, manage_root: nil, manage_token: nil, map: false)
41
86
  @folder = folder
42
87
  @title = title
43
88
  @link = link
44
89
  @layout = layout
90
+ @map = map
45
91
  @siblings = siblings
46
92
  @self_slug = self_slug
47
93
  @hub_path = hub_path
@@ -50,6 +96,14 @@ module OKF
50
96
  @manage_token = manage_token
51
97
  end
52
98
 
99
+ # Build the search index now rather than on the first reader's keystroke.
100
+ # `okf server` calls this after the bundle is loaded, so the cost lands in
101
+ # boot — where it is expected and attributable — instead of in a request.
102
+ def warm_search
103
+ search_corpus
104
+ self
105
+ end
106
+
53
107
  def call(env)
54
108
  request = Rack::Request.new(env)
55
109
  return not_found unless request.get?
@@ -63,6 +117,7 @@ module OKF
63
117
  when "/types" then respond_json(graph.type_index)
64
118
  when "/index" then respond_json(directory_index)
65
119
  when "/log" then respond_json(logs)
120
+ when "/search" then respond_json(self.class.search_payload(search_corpus, request.params["q"]))
66
121
  else not_found
67
122
  end
68
123
  end
@@ -102,11 +157,20 @@ module OKF
102
157
  { logs: @folder.log_entries }
103
158
  end
104
159
 
160
+ # Only one thing is read off it here — #cuts_for, the per-link cut the page
161
+ # lays a large bundle out on. No endpoint serves it: the page needs those
162
+ # numbers before its first layout, which is earlier than a request could
163
+ # answer, so they ride inline with the graph instead. Built over the boot
164
+ # snapshot and held, like the graph itself.
165
+ def skeleton
166
+ @skeleton ||= @folder.skeleton
167
+ end
168
+
105
169
  def page
106
170
  @page ||= OKF::Render::Graph.new(
107
171
  graph, title: @title || @folder.name, link: @link, layout: @layout,
108
172
  siblings: @siblings, self_slug: @self_slug, hub_path: @hub_path, search_endpoint: @search_endpoint,
109
- manage_root: @manage_root, manage_token: @manage_token
173
+ manage_root: @manage_root, manage_token: @manage_token, cuts: skeleton.cuts_for(graph.edges), map: @map
110
174
  ).render
111
175
  end
112
176
 
@@ -147,6 +211,13 @@ module OKF
147
211
  [ 200, { "content-type" => content_type }, [ body.to_s ] ]
148
212
  end
149
213
 
214
+ # Built on the first search and held for the life of the app, the way the
215
+ # graph is. `okf server` warms it at boot so the first reader does not pay
216
+ # for the whole corpus.
217
+ def search_corpus
218
+ @search_corpus ||= OKF::Bundle::Search.prepare([ [ nil, @folder.bundle ] ], engine: SEARCH_ENGINE)
219
+ end
220
+
150
221
  def respond_json(object)
151
222
  respond("application/json; charset=utf-8", JSON.generate(object))
152
223
  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+.
@@ -171,12 +158,13 @@ module OKF
171
158
  # is refused outright with no flag that says otherwise — `--bind 0.0.0.0`
172
159
  # turns a personal tool into a public one, and the write surface does not
173
160
  # follow it there at all.
174
- def initialize(bundles, layout: "cose", registry: nil, writable: false)
161
+ def initialize(bundles, layout: "cose", registry: nil, writable: false, map: false)
175
162
  @bundles = bundles
176
163
  @default = bundles.first
177
164
  @boot_registry = registry
178
165
  @layout = layout
179
166
  @writable = writable
167
+ @map = map
180
168
  @apps = build_apps(layout)
181
169
  end
182
170
 
@@ -205,6 +193,12 @@ module OKF
205
193
  end
206
194
  private_class_method :load_entry
207
195
 
196
+ # See App#warm_search — same reason, one corpus over every hosted bundle.
197
+ def warm_search
198
+ search_corpus
199
+ self
200
+ end
201
+
208
202
  def call(env)
209
203
  request = Rack::Request.new(env)
210
204
  # Everything this class *emits* must carry the prefix a host mounted it
@@ -259,7 +253,7 @@ module OKF
259
253
  end
260
254
 
261
255
  def apply(verb, params)
262
- registry = OKF::Registry.new(@boot_registry.path)
256
+ registry = @boot_registry.reopen
263
257
  message = mutate(verb, registry, params)
264
258
  reload(registry)
265
259
  json("ok" => true, "message" => message)
@@ -339,6 +333,9 @@ module OKF
339
333
  @apps = build_apps(@layout)
340
334
  @counts = nil
341
335
  @health = nil
336
+ # The corpus is a snapshot of the set, so a write that changes the set
337
+ # invalidates it. Without this a removed bundle keeps answering /search.
338
+ @search_corpus = nil
342
339
  end
343
340
 
344
341
  # Same-origin *and* the token. Neither alone is enough: the token lives in
@@ -374,14 +371,14 @@ module OKF
374
371
  # keystroke, and the box starts empty. `fuzzy: true` matches both the TUI
375
372
  # and the page's own MiniSearch, so all three forgive the same typos.
376
373
  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
- )
374
+ json(App.search_payload(search_corpus, query))
375
+ end
376
+
377
+ # One index over every hosted bundle — one corpus, so BM25 weighs a term
378
+ # against the whole set exactly as .across intends. Dropped whenever the
379
+ # bundle set changes, which is the only thing that can invalidate it.
380
+ def search_corpus
381
+ @search_corpus ||= OKF::Bundle::Search.prepare(pairs, engine: App::SEARCH_ENGINE)
385
382
  end
386
383
 
387
384
  # The /b/ manager's own rows, as JSON — what the graph page's Bundles panel
@@ -452,6 +449,7 @@ module OKF
452
449
  bundle.folder,
453
450
  title: bundle.title,
454
451
  layout: layout,
452
+ map: @map,
455
453
  siblings: siblings_of(bundle),
456
454
  self_slug: bundle.slug,
457
455
  hub_path: "/",
@@ -533,14 +531,26 @@ module OKF
533
531
  count: nil, health: "missing", word: word, default: false }
534
532
  end
535
533
 
534
+ # A bundle is addressed by its slug — `@orders` on the CLI, `/b/orders/`
535
+ # here — so the slug is its name and the folder is a fact about it. The row
536
+ # led with Folder.label instead, which put the address where the name goes;
537
+ # in a real registry that label is `…/.okf` on nearly every line, so the
538
+ # loudest column repeated the one word that tells no two bundles apart.
539
+ #
540
+ # The short label is gone rather than demoted: it only ever stood in for the
541
+ # path, and the path is right here on the row's second line.
542
+ #
543
+ # The name keeps its `@`, which the ref line used to carry: it is the exact
544
+ # spelling `okf lint @orders` takes, so the row teaches the CLI for free —
545
+ # and one element now does the whole job two were splitting.
536
546
  def manager_row(base, row)
537
547
  name = if row[:mount]
538
- %(<a class="name" href="#{escape(base)}#{MOUNT}/#{escape(row[:mount])}/">#{escape(row[:title])}</a>)
548
+ %(<a class="name" href="#{escape(base)}#{MOUNT}/#{escape(row[:mount])}/">@#{escape(row[:slug])}</a>)
539
549
  else
540
- %(<span class="name off">#{escape(row[:title])}</span>)
550
+ %(<span class="name off">@#{escape(row[:slug])}</span>)
541
551
  end
542
552
  %(<li class="row" data-health="#{escape(row[:health])}">) +
543
- %(<div class="who">#{name}<div class="ref"><span class="slug">@#{escape(row[:slug])}</span>) +
553
+ %(<div class="who">#{name}<div class="ref">) +
544
554
  # The row shows the tail; the tooltip is where the whole path stays
545
555
  # reachable, since nothing else on the page carries it.
546
556
  %(<span class="dir" title="#{escape(row[:dir])}"><bdi>#{escape(row[:dir])}</bdi></span></div></div>) +
@@ -585,7 +595,7 @@ module OKF
585
595
  # `okf registry rename` in another terminal shows on the next refresh
586
596
  # instead of waiting for a restart.
587
597
  def registry
588
- @boot_registry && OKF::Registry.new(@boot_registry.path)
598
+ @boot_registry&.reopen
589
599
  end
590
600
 
591
601
  # ok / warn / error, with the word that carries the same message for a
@@ -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`).
@@ -133,7 +136,11 @@ ambiguous, ask.
133
136
  **Which target?** A leading `@` is a *registry ref*, not a path: `@slug` names a
134
137
  bundle registered with `okf registry set`, bare `@` the default — route it
135
138
  straight to `okf <verb> @slug` and skip the directory hunt (`okf search` spans
136
- several: `@a @b`, or `@all`). A plain path is used as given. Given no target and a
139
+ several: `@a @b`, or `@all`). A `@slug` may instead name a **group** — a saved set
140
+ of bundles (`okf registry group backend @a @b`, members nest); it resolves like
141
+ any ref for the two set-taking verbs (`okf search @backend`, `okf server
142
+ @backend`) and every single-bundle verb refuses it with exit 2, the message
143
+ saying which two take a group. A plain path is used as given. Given no target and a
137
144
  cwd that carries no bundle, `okf registry list` is the next move, not a hunt
138
145
  across sibling directories. Producing a *new* bundle with no path? Default to
139
146
  `.okf/` at the repo root, but first detect whether the project already keeps its
@@ -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,8 +34,9 @@ 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
38
- whose inbound links are mostly foreign in `okf graph --hubs` → offer
37
+ `okf stats`, tags spread thin across dirs in `okf tags --by dir`, hubs
38
+ whose inbound links are mostly foreign in `okf graph --hubs`, a directory
39
+ with almost no internal traffic in `okf graph --traffic` → offer
39
40
  **`refine`** (evidence-driven restructuring; it proposes before it
40
41
  touches anything).
41
42
  4. **Freshness is off by default.** If the bundle carries timestamps, note that a
@@ -19,18 +19,25 @@ 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
33
34
  the areas those links come from: the **origin test** for every hub.
35
+ - `okf graph <dir> --traffic` — the same question one level up. Every
36
+ directory's link traffic split three ways (internal / out / in) with the
37
+ **cohesion** ratio, and the weighted arcs between directories. `--hubs`
38
+ measures concepts; step 3 decides about *directories*, and this is the
39
+ only read at that grain. Rows lead with the lowest cohesion, so the
40
+ directories with a case to answer are at the top.
34
41
  3. **Diagnose — you are the judgment.** The measurements are evidence, never
35
42
  verdicts:
36
43
  - **Concerns never become containers.** A directory built around a spread
@@ -42,6 +49,26 @@ alarm, never the objective. <!-- rule:okf-cohesion-over-balance -->
42
49
  exclusive, and roughly comparable in size. And small is not merge-worthy
43
50
  on its own — a two-concept area that is a genuinely distinct domain
44
51
  stays. <!-- rule:okf-directory-prunes -->
52
+ - **Cohesion discriminates the low rows, which are the ones the view floats
53
+ to the top.** High cohesion sorts to the bottom and means the directory
54
+ holds together — the shape the tree exists to express, nothing to do. The
55
+ rows that need reading are the near-zero ones, and two shapes land there
56
+ together. Heavy *outbound* with almost nothing coming back is the finding: a
57
+ directory that points at the bundle rather than holding a part of it is
58
+ behaving like a projection, and this playbook's own frame says a projection
59
+ rides an `index.md` or a tag, never a directory — ask what would be lost if
60
+ its concepts moved to the areas they point at and its listing became a map.
61
+ Heavy *inbound* with nothing outbound is its benign twin, a **shared
62
+ vocabulary**: everyone cites it, it cites nobody, a reference area doing its
63
+ job, not a mis-homing. Same low number, opposite verdicts — the direction of
64
+ the traffic is which one you are looking at.
65
+ <!-- rule:okf-cohesion-reads-the-container -->
66
+ Two cautions, both of which will otherwise generate false findings. A
67
+ directory holding one or two concepts has too little traffic for a ratio
68
+ to mean anything — read the raw counts, not the percentage. And in a
69
+ design bundle the central `decisions/` will read low for exactly the
70
+ reason its concepts fail the hub origin test: that is centrality, and the
71
+ measurement is confirming the bundle works, not that it is broken.
45
72
  - **The hub origin test.** Inbound majority from the hub's own area:
46
73
  well-homed, leave it. A dominant *foreign* area: that area is the better
47
74
  home. Foreign majority with *no* dominant area: a shared primitive — the
@@ -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