okf 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/capabilities/agent-skill.md +46 -0
  3. data/.okf/capabilities/graph-server.md +60 -0
  4. data/.okf/capabilities/index.md +20 -0
  5. data/.okf/capabilities/library-api.md +67 -0
  6. data/.okf/capabilities/linter.md +49 -0
  7. data/.okf/capabilities/read-views.md +84 -0
  8. data/.okf/capabilities/validator.md +40 -0
  9. data/.okf/cli.md +52 -0
  10. data/.okf/design/core-shell-split.md +58 -0
  11. data/.okf/design/index.md +10 -0
  12. data/.okf/design/ruby-floor.md +45 -0
  13. data/.okf/design/runtime-dependencies.md +44 -0
  14. data/.okf/design/server-trust-boundary.md +35 -0
  15. data/.okf/format/citations.md +33 -0
  16. data/.okf/format/cross-links.md +52 -0
  17. data/.okf/format/frontmatter.md +38 -0
  18. data/.okf/format/index.md +9 -0
  19. data/.okf/format/okf-format.md +43 -0
  20. data/.okf/index.md +18 -0
  21. data/.okf/log.md +9 -0
  22. data/.okf/model/bundle.md +38 -0
  23. data/.okf/model/concept.md +44 -0
  24. data/.okf/model/graph.md +44 -0
  25. data/.okf/model/index.md +8 -0
  26. data/.okf/overview.md +66 -0
  27. data/CHANGELOG.md +54 -0
  28. data/CODE_OF_CONDUCT.md +10 -0
  29. data/LICENSE.txt +201 -0
  30. data/NOTICE +10 -0
  31. data/README.md +276 -0
  32. data/exe/okf +6 -0
  33. data/lib/okf/bundle/folder.rb +94 -0
  34. data/lib/okf/bundle/graph.rb +118 -0
  35. data/lib/okf/bundle/linter/report.rb +56 -0
  36. data/lib/okf/bundle/linter.rb +416 -0
  37. data/lib/okf/bundle/reader.rb +60 -0
  38. data/lib/okf/bundle/validator/result.rb +35 -0
  39. data/lib/okf/bundle/validator.rb +131 -0
  40. data/lib/okf/bundle/writer.rb +137 -0
  41. data/lib/okf/bundle.rb +216 -0
  42. data/lib/okf/cli.rb +910 -0
  43. data/lib/okf/concept/file.rb +63 -0
  44. data/lib/okf/concept.rb +101 -0
  45. data/lib/okf/markdown/citations.rb +49 -0
  46. data/lib/okf/markdown/frontmatter.rb +55 -0
  47. data/lib/okf/markdown/links.rb +98 -0
  48. data/lib/okf/path.rb +34 -0
  49. data/lib/okf/server/app.rb +120 -0
  50. data/lib/okf/server/graph.rb +112 -0
  51. data/lib/okf/server/runner.rb +78 -0
  52. data/lib/okf/server/templates/graph.html.erb +803 -0
  53. data/lib/okf/skill/SKILL.md +133 -0
  54. data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
  55. data/lib/okf/skill/reference/SPEC.md +460 -0
  56. data/lib/okf/skill/reference/authoring.md +218 -0
  57. data/lib/okf/skill/reference/cli.md +196 -0
  58. data/lib/okf/skill/templates/concept.md +24 -0
  59. data/lib/okf/skill/templates/index.md +8 -0
  60. data/lib/okf/skill/templates/log.md +6 -0
  61. data/lib/okf/skill/templates/root-index.md +12 -0
  62. data/lib/okf/skill.rb +82 -0
  63. data/lib/okf/version.rb +5 -0
  64. data/lib/okf.rb +55 -0
  65. metadata +142 -0
data/lib/okf/cli.rb ADDED
@@ -0,0 +1,910 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ # Command-line front end: `okf graph|validate|lint|loose|index|catalog|files|tags|types|stats|server <dir>`.
5
+ # This is the
6
+ # only layer that parses argv, prints, writes files, and decides exit codes — the
7
+ # lib classes below it just return data. Streams are injectable for testing.
8
+ #
9
+ # Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
10
+ class CLI
11
+ # Lint findings grouped for display, in category order.
12
+ LINT_CATEGORIES = {
13
+ "Reachability" => %i[orphan not_in_index disconnected_component unlinked],
14
+ "Backlog" => %i[missing_concept broken_index_entry],
15
+ "Completeness" => %i[stub missing_title missing_description missing_timestamp],
16
+ "Freshness" => %i[stale],
17
+ "Provenance" => %i[uncited_external broken_citation],
18
+ "Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
19
+ }.freeze
20
+
21
+ # Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
22
+ # tests can drive `server` without opening a socket; the runner loads here
23
+ # (not at require time) so `require "okf"` and a Rails mount of the server stay
24
+ # light.
25
+ WEBRICK = lambda do |app, host, port|
26
+ require "okf/server/runner"
27
+ OKF::Server::Runner.run(app, host: host, port: port)
28
+ end
29
+
30
+ def self.start(argv, out: $stdout, err: $stderr)
31
+ new(out: out, err: err).run(argv)
32
+ end
33
+
34
+ def initialize(out: $stdout, err: $stderr, runner: WEBRICK)
35
+ @out = out
36
+ @err = err
37
+ @runner = runner
38
+ @pretty = false
39
+ end
40
+
41
+ def run(argv)
42
+ argv = argv.dup
43
+ case (command = argv.shift)
44
+ when "graph" then graph(argv)
45
+ when "validate" then validate(argv)
46
+ when "lint" then lint(argv)
47
+ when "loose" then loose(argv)
48
+ when "index" then index(argv)
49
+ when "catalog" then catalog(argv)
50
+ when "files" then files(argv)
51
+ when "tags" then tags(argv)
52
+ when "types" then types(argv)
53
+ when "stats" then stats(argv)
54
+ when "server" then server(argv)
55
+ when "skill" then skill(argv)
56
+ when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
57
+ when "help", "--help", "-h" then usage(@out); 0
58
+ when nil then usage(@err); 2
59
+ else
60
+ @err.puts "okf: unknown command '#{command}'"
61
+ usage(@err)
62
+ 2
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def validate(argv)
69
+ options = { json: false }
70
+ parser = OptionParser.new do |o|
71
+ o.banner = "Usage: okf validate <bundle-dir> [--json]"
72
+ json_flags(o, options, "emit a JSON report")
73
+ end
74
+ dir = positional_dir(parser, argv) or return 2
75
+
76
+ result = OKF::Bundle::Folder.load(dir).validate
77
+ options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
78
+ result.valid? ? 0 : 1
79
+ end
80
+
81
+ def lint(argv)
82
+ options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
83
+ parser = OptionParser.new do |o|
84
+ o.banner = "Usage: okf lint <bundle-dir> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
85
+ json_flags(o, options, "emit a JSON report")
86
+ o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
87
+ o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
88
+ o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
89
+ o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
90
+ o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
91
+ end
92
+ dir = positional_dir(parser, argv) or return 2
93
+
94
+ unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
95
+ unless unknown.empty?
96
+ @err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
97
+ return 2
98
+ end
99
+
100
+ stale_before = parse_stale_after(options[:stale_after])
101
+ if stale_before == :invalid
102
+ @err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
103
+ return 2
104
+ end
105
+
106
+ folder = OKF::Bundle::Folder.load(dir)
107
+ report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
108
+ note_skipped(report.stats[:skipped])
109
+ options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
110
+ options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
111
+ end
112
+
113
+ # List the "loose" files — concepts with graph degree 0 (no cross-links in or
114
+ # out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
115
+ # for the common "which files float in the graph?" question. Advisory (exit 0):
116
+ # a terminal leaf can be loose by design. `--json` for a machine substrate.
117
+ def loose(argv)
118
+ options = { json: false }
119
+ parser = OptionParser.new do |o|
120
+ o.banner = "Usage: okf loose <bundle-dir> [--json]"
121
+ json_flags(o, options, "emit the loose files as JSON")
122
+ end
123
+ dir = positional_dir(parser, argv) or return 2
124
+
125
+ folder = OKF::Bundle::Folder.load(dir)
126
+ report_skipped(folder)
127
+ files = loose_files(folder.graph(minimal: true))
128
+ options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
129
+ 0
130
+ end
131
+
132
+ def server(argv)
133
+ require "okf/server/app"
134
+
135
+ options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
136
+ parser = OptionParser.new do |o|
137
+ o.banner = "Usage: okf server <bundle-dir> [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
138
+ o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
139
+ o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
140
+ o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
141
+ o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
142
+ o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
143
+ end
144
+ dir = positional_dir(parser, argv) or return 2
145
+
146
+ folder = OKF::Bundle::Folder.load(dir)
147
+ report_skipped(folder)
148
+ run_server(folder, options)
149
+ 0
150
+ end
151
+
152
+ # Build the Rack app and hand it to the runner (WEBrick by default, injected so
153
+ # tests drive this without a socket).
154
+ def run_server(folder, options)
155
+ app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
156
+ @out.puts "serving #{folder.graph.nodes.size} concepts at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
157
+ @runner.call(app, options[:bind], options[:port])
158
+ end
159
+
160
+ def graph(argv)
161
+ options = { json: false, minimal: false, body: true }
162
+ parser = OptionParser.new do |o|
163
+ o.banner = "Usage: okf graph <bundle-dir> [--json] [--minimal] [--no-body]"
164
+ json_flags(o, options, "emit nodes and edges as JSON")
165
+ o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
166
+ o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
167
+ end
168
+ dir = positional_dir(parser, argv) or return 2
169
+
170
+ folder = OKF::Bundle::Folder.load(dir)
171
+ graph = folder.graph(minimal: options[:minimal], body: options[:body])
172
+ report_skipped(folder)
173
+ if options[:json]
174
+ payload = graph.to_h
175
+ payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
176
+ emit_json(payload)
177
+ else
178
+ @out.puts "#{graph.nodes.size} concepts, #{graph.edges.size} links"
179
+ end
180
+ 0
181
+ end
182
+
183
+ # The progressive-disclosure map (spec §6): every directory that holds concepts
184
+ # or carries an index.md, with its authored index body, a type/tag rollup, its
185
+ # child directories, and — for a directory with no index.md — the listing
186
+ # synthesized from the concepts there. The "orient before you read" view. `--area`
187
+ # is repeatable (one or many directories; `root` is the bundle root); `--no-body`
188
+ # drops the prose to a skeleton; advisory, exit 0.
189
+ def index(argv)
190
+ options = { json: false, body: true, areas: nil }
191
+ parser = OptionParser.new do |o|
192
+ o.banner = "Usage: okf index <bundle-dir> [--area AREA] [--no-body] [--json]"
193
+ json_flags(o, options, "emit the index map as JSON")
194
+ projection_flags(o, options)
195
+ o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
196
+ o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
197
+ end
198
+ dir = positional_dir(parser, argv) or return 2
199
+
200
+ folder = OKF::Bundle::Folder.load(dir)
201
+ report_skipped(folder)
202
+ entries = folder.directory_index
203
+ selected = select_directories(entries, options[:areas])
204
+ if options[:json]
205
+ options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
206
+ return print_index_map_json(dir, selected, options)
207
+ end
208
+ print_index_map(dir, selected, options[:body])
209
+ 0
210
+ end
211
+
212
+ # Narrow the map to the named directories/areas — case-insensitive, `root`
213
+ # matching the bundle root (".") so no shell quoting is needed. No --area passed
214
+ # keeps the whole map.
215
+ def select_directories(entries, areas)
216
+ return entries if areas.nil? || areas.empty?
217
+
218
+ wanted = areas.map { |area| area.downcase == "root" ? "." : area.downcase }
219
+ entries.select { |entry| wanted.include?(entry[:dir].downcase) }
220
+ end
221
+
222
+ def print_index_map(dir, entries, body)
223
+ noun = entries.size == 1 ? "directory" : "directories"
224
+ @out.puts "Index map — #{dir} (#{entries.size} #{noun})"
225
+ entries.each do |entry|
226
+ @out.puts
227
+ @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
228
+ subdirs = entry[:subdirs]
229
+ @out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
230
+ if entry[:present]
231
+ print_index_body(entry[:body]) if body
232
+ else
233
+ print_synthesized_listing(entry[:listing])
234
+ end
235
+ end
236
+ end
237
+
238
+ def index_dir_label(entry)
239
+ base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
240
+ entry[:present] ? base : "#{base} (no index.md)"
241
+ end
242
+
243
+ def index_dir_meta(entry)
244
+ count = "#{entry[:count]} #{entry[:count] == 1 ? "concept" : "concepts"}"
245
+ types = entry[:types].map { |type, n| "#{type.empty? ? "Untyped" : type} #{n}" }.join(", ")
246
+ types.empty? ? " · #{count}" : " · #{count} · #{types}"
247
+ end
248
+
249
+ def print_index_body(body)
250
+ text = body.to_s.strip
251
+ return if text.empty?
252
+
253
+ text.each_line { |line| @out.puts " #{line.chomp}" }
254
+ end
255
+
256
+ def print_synthesized_listing(listing)
257
+ listing.each do |item|
258
+ suffix = item[:description].empty? ? "" : " — #{truncate(item[:description], 72)}"
259
+ @out.puts " • #{item[:title]}#{suffix}"
260
+ end
261
+ end
262
+
263
+ def print_index_map_json(dir, entries, options)
264
+ emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
265
+ end
266
+
267
+ def index_map_entry_json(entry)
268
+ {
269
+ "dir" => entry[:dir], "index_path" => entry[:index_path],
270
+ "present" => entry[:present], "synthesized" => entry[:synthesized],
271
+ "count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
272
+ "subdirs" => entry[:subdirs], "body" => entry[:body],
273
+ "listing" => entry[:listing].map { |item| stringify(item) }
274
+ }
275
+ end
276
+
277
+ # The Catalog / Files / Tags / Stats views the server renders in the browser,
278
+ # reproduced on the CLI so an agent can read the same knowledge without one.
279
+ # Each prints a scannable human view by default and machine JSON with --json;
280
+ # all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
281
+ # and (with `types`) narrow through the same --type/--area/--tag filters the
282
+ # server UI offers, so browser and CLI can answer the same questions.
283
+
284
+ def catalog(argv)
285
+ options = { json: false }
286
+ parser = OptionParser.new do |o|
287
+ o.banner = "Usage: okf catalog <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
288
+ json_flags(o, options, "emit the catalog as JSON")
289
+ projection_flags(o, options)
290
+ filter_flags(o, options, :type, :area, :tag)
291
+ end
292
+ dir = positional_dir(parser, argv) or return 2
293
+
294
+ folder = OKF::Bundle::Folder.load(dir)
295
+ report_skipped(folder)
296
+ entries = folder.catalog
297
+ selected = filter_entries(entries, options)
298
+ return print_catalog_json(dir, selected, options) if options[:json]
299
+
300
+ print_catalog(dir, selected, entries.size)
301
+ 0
302
+ end
303
+
304
+ def files(argv)
305
+ options = { json: false }
306
+ parser = OptionParser.new do |o|
307
+ o.banner = "Usage: okf files <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
308
+ json_flags(o, options, "emit the file tree as JSON")
309
+ projection_flags(o, options)
310
+ filter_flags(o, options, :type, :area, :tag)
311
+ end
312
+ dir = positional_dir(parser, argv) or return 2
313
+
314
+ folder = OKF::Bundle::Folder.load(dir)
315
+ report_skipped(folder)
316
+ entries = folder.catalog
317
+ selected = filter_entries(entries, options)
318
+ return print_files_json(dir, selected, options) if options[:json]
319
+
320
+ print_files(dir, selected, entries.size)
321
+ 0
322
+ end
323
+
324
+ def tags(argv)
325
+ options = { json: false, by: nil }
326
+ parser = OptionParser.new do |o|
327
+ o.banner = "Usage: okf tags <bundle-dir> [--by type|area] [--type T] [--area A] [--json]"
328
+ json_flags(o, options, "emit the tag index as JSON")
329
+ o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
330
+ filter_flags(o, options, :type, :area)
331
+ end
332
+ dir = positional_dir(parser, argv) or return 2
333
+
334
+ return grouped_tags(dir, options) if options[:by]
335
+
336
+ print_inverted_index(dir, "Tags", :tag, "tags", options)
337
+ end
338
+
339
+ def types(argv)
340
+ options = { json: false }
341
+ parser = OptionParser.new do |o|
342
+ o.banner = "Usage: okf types <bundle-dir> [--area A] [--tag T] [--json]"
343
+ json_flags(o, options, "emit the type index as JSON")
344
+ filter_flags(o, options, :area, :tag)
345
+ end
346
+ dir = positional_dir(parser, argv) or return 2
347
+
348
+ print_inverted_index(dir, "Types", :type, "types", options)
349
+ end
350
+
351
+ # The shared back half of `tags` and `types`: load, narrow, print.
352
+ def print_inverted_index(dir, label, key, plural, options)
353
+ folder = OKF::Bundle::Folder.load(dir)
354
+ report_skipped(folder)
355
+ graph = folder.graph(minimal: true)
356
+ index = key == :tag ? graph.tag_index : graph.type_index
357
+ rows = index_rows(index, key, folder, options)
358
+ if options[:json]
359
+ print_index_json(dir, plural, key, rows)
360
+ else
361
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
362
+ print_index(dir, label, key, rows, titles)
363
+ end
364
+ 0
365
+ end
366
+
367
+ # `tags --by type|area`: the tag index re-cut per concept type or top-level
368
+ # area, with within-group counts — the curation view. A tag confined to one
369
+ # group at count 1 is scattered; one recurring across groups is connective.
370
+ # The --type/--area filters narrow the concepts first, then the grouping cuts.
371
+ def grouped_tags(dir, options)
372
+ folder = OKF::Bundle::Folder.load(dir)
373
+ report_skipped(folder)
374
+ graph = folder.graph(minimal: true)
375
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
376
+ groups = tag_groups(graph.tag_index, folder, options)
377
+ options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
378
+ 0
379
+ end
380
+
381
+ # [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'.
382
+ # A tag carried in several groups appears in each, counted per group.
383
+ def tag_groups(tag_index, folder, options)
384
+ by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
385
+ groups = {}
386
+ tag_index.each do |tag, ids|
387
+ ids.each do |id|
388
+ entry = by_id[id]
389
+ next if entry.nil?
390
+
391
+ key = options[:by] == :type ? entry_type(entry) : entry[:area]
392
+ ((groups[key] ||= {})[tag] ||= []) << id
393
+ end
394
+ end
395
+ groups.map do |key, tags|
396
+ rows = tags.map { |tag, ids| { tag: tag, count: ids.length, concepts: ids } }
397
+ .sort_by { |row| [ -row[:count], row[:tag] ] }
398
+ [ key, rows ]
399
+ end.sort_by(&:first)
400
+ end
401
+
402
+ # A catalog entry's type for display — "Untyped" when blank, matching the graph.
403
+ def entry_type(entry)
404
+ entry[:type].empty? ? "Untyped" : entry[:type]
405
+ end
406
+
407
+ def print_grouped_tags(dir, dim, groups, titles)
408
+ @out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
409
+ groups.each do |key, rows|
410
+ label = dim == :area && key != "(root)" ? "#{key}/" : key
411
+ @out.puts
412
+ @out.puts " #{label} (#{rows.size} tags)"
413
+ width = rows.map { |row| row[:tag].length }.max || 0
414
+ rows.each do |row|
415
+ names = row[:concepts].map { |id| titles[id] || id }.join(", ")
416
+ @out.puts " #{row[:tag].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 76)}"
417
+ end
418
+ end
419
+ end
420
+
421
+ def print_grouped_tags_json(dir, dim, groups)
422
+ emit_json(
423
+ "bundle" => dir, "count" => distinct_tags(groups), "by" => dim.to_s,
424
+ "groups" => groups.map do |key, rows|
425
+ { dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
426
+ end
427
+ )
428
+ end
429
+
430
+ def distinct_tags(groups)
431
+ groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
432
+ end
433
+
434
+ def stats(argv)
435
+ options = { json: false }
436
+ parser = OptionParser.new do |o|
437
+ o.banner = "Usage: okf stats <bundle-dir> [--json]"
438
+ json_flags(o, options, "emit the stats as JSON")
439
+ end
440
+ dir = positional_dir(parser, argv) or return 2
441
+
442
+ folder = OKF::Bundle::Folder.load(dir)
443
+ report_skipped(folder)
444
+ stats = bundle_stats(folder)
445
+ options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
446
+ 0
447
+ end
448
+
449
+ # Bundle-level rollups derived from the catalog and the graph indexes.
450
+ def bundle_stats(folder)
451
+ graph = folder.graph(minimal: true)
452
+ entries = folder.catalog
453
+ by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
454
+ by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
455
+ {
456
+ concepts: entries.size,
457
+ areas: by_area.size,
458
+ types: by_type.size,
459
+ cross_links: graph.edges.size,
460
+ tags: graph.tag_index.size,
461
+ by_type: by_type,
462
+ by_area: by_area
463
+ }
464
+ end
465
+
466
+ # ── the read views' shared --type/--area/--tag narrowing ──
467
+ # Each view takes the filters orthogonal to it (tags can't filter by tag).
468
+ # Matching is case-insensitive and exact; a concept at the bundle root lives in
469
+ # the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
470
+
471
+ # The --json / --pretty pair every emitting verb shares. --json is the compact
472
+ # machine substrate (the default JSON form, aligned with the server); --pretty
473
+ # indents it for a human and implies --json. Both route through emit_json.
474
+ def json_flags(parser, options, desc)
475
+ parser.on("--json", desc) { options[:json] = true }
476
+ parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
477
+ end
478
+
479
+ # --fields/--except project the JSON down to the properties an agent wants, so it
480
+ # never pays tokens for fields it will not read. --fields is an allowlist,
481
+ # --except a denylist (mutually exclusive); both imply --json and apply per item
482
+ # in a list view (catalog, files, index). Names are the JSON keys, matched
483
+ # case-insensitively.
484
+ def projection_flags(parser, options)
485
+ parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
486
+ parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
487
+ end
488
+
489
+ def filter_flags(parser, options, *keys)
490
+ parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
491
+ parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
492
+ parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
493
+ end
494
+
495
+ def filter_entries(entries, options)
496
+ entries.select do |entry|
497
+ (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
498
+ (options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
499
+ (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
500
+ end
501
+ end
502
+
503
+ def fold(value)
504
+ value.to_s.downcase
505
+ end
506
+
507
+ def fold_area(value)
508
+ folded = fold(value)
509
+ folded == "root" ? "(root)" : folded
510
+ end
511
+
512
+ # Turn an inverted index ({ value => [id, …] }) into display rows ordered by
513
+ # count, narrowed to the concepts the active filters select; rows the narrowing
514
+ # empties drop. With no filters the index passes through whole.
515
+ def index_rows(index, key, folder, options)
516
+ keep = filter_ids(folder, options)
517
+ index.each_with_object([]) do |(value, ids), rows|
518
+ ids = ids.select { |id| keep.include?(id) } unless keep.nil?
519
+ rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
520
+ end.sort_by { |row| [ -row[:count], row[key] ] }
521
+ end
522
+
523
+ # The ids the filters select, resolved through the catalog metadata — or nil
524
+ # when no filter is active, meaning keep everything.
525
+ def filter_ids(folder, options)
526
+ return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
527
+
528
+ filter_entries(folder.catalog, options).map { |entry| entry[:id] }
529
+ end
530
+
531
+ # Install this gem's companion agent skill into a destination directory. The
532
+ # destination is required (no magic default) so the user always decides where
533
+ # their agent picks the skill up. By default the skill lands in a skills/okf/
534
+ # folder under it — point at a project or skills dir (.claude, .agents/skills)
535
+ # and it settles in its own folder, never loose among the others — so the
536
+ # resolved path is echoed back. --here installs straight into <dest-dir>.
537
+ def skill(argv)
538
+ options = { force: false, nest: true }
539
+ parser = OptionParser.new do |o|
540
+ o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
541
+ o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
542
+ o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
543
+ end
544
+ parser.parse!(argv)
545
+ dest = argv.shift
546
+ if dest.nil?
547
+ @err.puts parser.banner
548
+ return 2
549
+ end
550
+
551
+ skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
552
+ files = skill.install
553
+ @out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
554
+ files.each { |f| @out.puts " #{f}" }
555
+ @out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
556
+ 0
557
+ rescue OptionParser::ParseError => e
558
+ @err.puts e.message
559
+ 2
560
+ rescue OKF::Skill::Error => e
561
+ @err.puts "error: #{e.message}"
562
+ 2
563
+ end
564
+
565
+ # §9 best-effort: the graph is built from concepts that parse. Surface any that
566
+ # the reader could not parse (to stderr, so JSON on stdout stays clean) rather
567
+ # than dropping them silently.
568
+ def report_skipped(folder)
569
+ note_skipped(folder.bundle.unparseable.size)
570
+ end
571
+
572
+ def note_skipped(count)
573
+ return if count.nil? || count <= 0
574
+
575
+ @err.puts "note: skipped #{count} file(s) with invalid frontmatter (run `okf validate` for details)"
576
+ end
577
+
578
+ # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
579
+ # Time so the pure Linter never reads the clock. nil when unset, :invalid on a
580
+ # bad value.
581
+ def parse_stale_after(value)
582
+ return nil if value.nil?
583
+
584
+ if (match = value.match(/\A(\d+)([dw])\z/))
585
+ days = match[1].to_i * (match[2] == "w" ? 7 : 1)
586
+ Time.now - (days * 86_400)
587
+ else
588
+ Date.iso8601(value).to_time
589
+ end
590
+ rescue ArgumentError
591
+ :invalid
592
+ end
593
+
594
+ # Parse options, then require a single existing-directory positional argument.
595
+ # Returns the directory, or nil (after reporting) so the caller returns 2.
596
+ def positional_dir(parser, argv)
597
+ parser.parse!(argv)
598
+ dir = argv.shift
599
+ if dir.nil?
600
+ @err.puts parser.banner
601
+ return nil
602
+ end
603
+ unless File.directory?(dir)
604
+ @err.puts "error: #{dir} is not a directory"
605
+ return nil
606
+ end
607
+ dir
608
+ rescue OptionParser::ParseError => e
609
+ @err.puts e.message
610
+ nil
611
+ end
612
+
613
+ def print_validation(dir, result)
614
+ counts = result.counts
615
+ @out.puts "OKF v0.1 conformance — #{dir}"
616
+ @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
617
+ result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
618
+ result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
619
+ if result.valid? && result.warnings.empty?
620
+ @out.puts " #{paint("✓ conformant — no issues", 32)}"
621
+ elsif result.valid?
622
+ @out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
623
+ else
624
+ @out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
625
+ end
626
+ end
627
+
628
+ def print_validation_json(dir, result)
629
+ emit_json(
630
+ "bundle" => dir,
631
+ "conformant" => result.valid?,
632
+ "counts" => result.counts,
633
+ "errors" => result.errors,
634
+ "warnings" => result.warnings
635
+ )
636
+ end
637
+
638
+ def print_lint(dir, report)
639
+ stats = report.stats
640
+ @out.puts "OKF lint — #{dir}"
641
+ @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
642
+ summary = lint_summary(stats)
643
+ @out.puts " #{summary}" unless summary.empty?
644
+
645
+ LINT_CATEGORIES.each do |name, checks|
646
+ findings = report.findings.select { |finding| checks.include?(finding[:check]) }
647
+ next if findings.empty?
648
+
649
+ @out.puts
650
+ @out.puts " #{name}"
651
+ findings.each do |finding|
652
+ @out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
653
+ end
654
+ end
655
+
656
+ @out.puts
657
+ @out.puts " #{lint_verdict(report)}"
658
+ end
659
+
660
+ def print_lint_json(dir, report)
661
+ emit_json(
662
+ "bundle" => dir,
663
+ "healthy" => report.healthy?,
664
+ "stats" => report.stats,
665
+ "findings" => report.findings
666
+ )
667
+ end
668
+
669
+ # Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
670
+ # `unlinked` check reports, resolved to titles/folders for display.
671
+ def loose_files(graph)
672
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
673
+ graph.unlinked_ids
674
+ .map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
675
+ .sort_by { |file| file[:id] }
676
+ end
677
+
678
+ def print_loose(dir, files)
679
+ @out.puts "Loose files — #{dir} (#{files.size})"
680
+ if files.empty?
681
+ @out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
682
+ return
683
+ end
684
+
685
+ files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
686
+ width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
687
+ @out.puts
688
+ @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
689
+ group.each do |file|
690
+ @out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
691
+ end
692
+ end
693
+ end
694
+
695
+ def print_loose_json(dir, files)
696
+ emit_json(
697
+ "bundle" => dir,
698
+ "count" => files.size,
699
+ "loose" => files.map { |file| stringify(file) }
700
+ )
701
+ end
702
+
703
+ def print_catalog(dir, entries, total)
704
+ @out.puts "Catalog — #{dir} (#{counted(entries.size, total, "concepts")})"
705
+ entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
706
+ @out.puts
707
+ @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
708
+ group.each do |entry|
709
+ links = entry[:links_out] + entry[:links_in]
710
+ meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
711
+ @out.puts " #{entry[:title]} · #{meta}"
712
+ @out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
713
+ end
714
+ end
715
+ end
716
+
717
+ def print_catalog_json(dir, entries, options)
718
+ emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
719
+ end
720
+
721
+ def print_files(dir, entries, total)
722
+ @out.puts "Files — #{dir} (#{counted(entries.size, total, "files")})"
723
+ entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
724
+ width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
725
+ @out.puts
726
+ @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
727
+ group.each do |entry|
728
+ @out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
729
+ end
730
+ end
731
+ end
732
+
733
+ def print_files_json(dir, entries, options)
734
+ files = entries.map do |entry|
735
+ { "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
736
+ "description" => entry[:description] }
737
+ end
738
+ emit_list_json(dir, "files", files, options)
739
+ end
740
+
741
+ def print_index(dir, label, key, rows, titles)
742
+ @out.puts "#{label} — #{dir} (#{rows.size} distinct)"
743
+ @out.puts
744
+ width = rows.map { |row| row[key].length }.max || 0
745
+ rows.each do |row|
746
+ names = row[:concepts].map { |id| titles[id] || id }.join(", ")
747
+ @out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
748
+ end
749
+ end
750
+
751
+ def print_index_json(dir, plural, key, rows)
752
+ emit_json("bundle" => dir, "count" => rows.size, plural => index_rows_json(key, rows))
753
+ end
754
+
755
+ def index_rows_json(key, rows)
756
+ rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
757
+ end
758
+
759
+ def counted(size, total, noun)
760
+ size == total ? "#{size} #{noun}" : "#{size} of #{total} #{noun}"
761
+ end
762
+
763
+ def print_stats(dir, stats)
764
+ @out.puts "Stats — #{dir}"
765
+ @out.puts
766
+ @out.puts " concepts #{stats[:concepts]}"
767
+ @out.puts " areas #{stats[:areas]}"
768
+ @out.puts " concept types #{stats[:types]}"
769
+ @out.puts " cross-links #{stats[:cross_links]}"
770
+ @out.puts " distinct tags #{stats[:tags]}"
771
+ print_stat_breakdown("By type", stats[:by_type])
772
+ print_stat_breakdown("By area", stats[:by_area])
773
+ end
774
+
775
+ def print_stat_breakdown(title, counts)
776
+ return if counts.empty?
777
+
778
+ width = counts.keys.map(&:length).max
779
+ @out.puts
780
+ @out.puts " #{title}"
781
+ counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
782
+ end
783
+
784
+ def print_stats_json(dir, stats)
785
+ emit_json(
786
+ "bundle" => dir, "concepts" => stats[:concepts], "areas" => stats[:areas],
787
+ "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
788
+ "by_type" => stats[:by_type], "by_area" => stats[:by_area]
789
+ )
790
+ end
791
+
792
+ # The single JSON writer. Compact by default — the token-efficient substrate an
793
+ # agent consumes; --pretty indents it for a human. JSON semantics are identical
794
+ # either way, so a parser never cares which was emitted.
795
+ def emit_json(payload)
796
+ @out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
797
+ end
798
+
799
+ # Emit a list view's JSON envelope with --fields/--except projection applied to
800
+ # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
801
+ # both flags at once, or a field name no item carries).
802
+ def emit_list_json(dir, key, items, options)
803
+ return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
804
+
805
+ unknown = unknown_fields(items, options)
806
+ return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
807
+
808
+ emit_json("bundle" => dir, "count" => items.size, key => project(items, options))
809
+ 0
810
+ end
811
+
812
+ # Keep only --fields (allowlist) or drop --except (denylist) from each item's
813
+ # top-level properties; unset flags pass the items through whole.
814
+ def project(items, options)
815
+ return items if options[:fields].nil? && options[:except].nil?
816
+
817
+ fields = options[:fields]&.map(&:downcase)
818
+ except = options[:except]&.map(&:downcase)
819
+ items.map do |item|
820
+ fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
821
+ end
822
+ end
823
+
824
+ def available_fields(items)
825
+ items.first ? items.first.keys.map(&:to_s) : []
826
+ end
827
+
828
+ # Requested field names that no item actually carries — a typo guard (exit 2),
829
+ # matching how lint rejects unknown check names.
830
+ def unknown_fields(items, options)
831
+ requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
832
+ return [] if requested.empty? || items.empty?
833
+
834
+ known = available_fields(items).map(&:downcase)
835
+ requested.reject { |field| known.include?(field) }.uniq
836
+ end
837
+
838
+ def usage_error(message)
839
+ @err.puts "error: #{message}"
840
+ 2
841
+ end
842
+
843
+ def stringify(hash)
844
+ hash.map { |key, value| [ key.to_s, value ] }.to_h
845
+ end
846
+
847
+ def truncate(str, max)
848
+ str.length > max ? "#{str[0, max - 1]}…" : str
849
+ end
850
+
851
+ def lint_summary(stats)
852
+ parts = []
853
+ hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
854
+ types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
855
+ parts << "hubs: #{hubs}" unless hubs.empty?
856
+ parts << "types: #{types}" unless types.empty?
857
+ parts.join(" ")
858
+ end
859
+
860
+ def lint_glyph(finding)
861
+ finding[:severity] == :warn ? paint("! warn", 33) : "· info"
862
+ end
863
+
864
+ def lint_verdict(report)
865
+ warnings = report.warnings.size
866
+ infos = report.info.size
867
+ return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
868
+
869
+ marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
870
+ "#{marker} #{warnings} warn, #{infos} info"
871
+ end
872
+
873
+ def paint(text, code)
874
+ return text unless @out.respond_to?(:tty?) && @out.tty?
875
+
876
+ "\e[#{code}m#{text}\e[0m"
877
+ end
878
+
879
+ def usage(io)
880
+ io.puts <<~USAGE
881
+ okf <command> [options]
882
+
883
+ skill <dest> [--here] [--force] install the companion agent skill
884
+ server <dir> [-p PORT] [--bind ADDR] [...] serve an interactive HTML graph
885
+
886
+ lint <dir> [--json] [--fail-on warn] [...] report curation-quality issues
887
+ loose <dir> [--json] list files with no graph links, by folder
888
+ validate <dir> [--json] check OKF v0.1 conformance
889
+
890
+ index <dir> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
891
+ stats <dir> [--json] bundle rollups (concepts, types, areas, links, tags)
892
+ types <dir> [--json] [filters] list types with their concepts, by count
893
+ tags <dir> [--json] [--by DIM] [filters] list tags with their concepts, by count
894
+ files <dir> [--json] [filters] list files with titles, by folder
895
+ catalog <dir> [--json] [filters] list concepts with metadata, by area
896
+
897
+ graph <dir> [--json] [--minimal] [--no-body] print the knowledge graph
898
+
899
+ [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
900
+ (each view takes the ones orthogonal to it; matching is case-insensitive).
901
+ tags --by DIM regroups the tags per concept dimension — type or area — with
902
+ within-group counts, the view for curating a tag vocabulary.
903
+ --json emits compact JSON (the machine substrate); add --pretty to indent it.
904
+ --fields / --except project the JSON to the properties you want (index/catalog/files).
905
+
906
+ okf --version
907
+ USAGE
908
+ end
909
+ end
910
+ end