okf-pro 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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/contract/containment-directions.md +107 -0
  3. data/.okf/contract/exit-codes.md +42 -0
  4. data/.okf/contract/gates-only-at-the-hook-door.md +39 -0
  5. data/.okf/contract/index.md +10 -0
  6. data/.okf/contract/silent-skips.md +78 -0
  7. data/.okf/contract/telemetry-does-not-lie.md +218 -0
  8. data/.okf/contract/the-contract.md +43 -0
  9. data/.okf/design/a-comment-is-not-an-implementation.md +85 -0
  10. data/.okf/design/a-rule-you-can-walk-past.md +68 -0
  11. data/.okf/design/derivation-that-writes.md +161 -0
  12. data/.okf/design/failure-modes.md +43 -0
  13. data/.okf/design/index.md +18 -0
  14. data/.okf/design/lineage.md +95 -0
  15. data/.okf/design/structure-laws.md +46 -0
  16. data/.okf/design/the-residue.md +44 -0
  17. data/.okf/design/three-laws.md +71 -0
  18. data/.okf/design/three-pillars.md +48 -0
  19. data/.okf/index.md +31 -0
  20. data/.okf/log.md +74 -0
  21. data/.okf/scaffold/collisions-and-refusals.md +76 -0
  22. data/.okf/scaffold/index.md +8 -0
  23. data/.okf/scaffold/no-date-ships.md +41 -0
  24. data/.okf/scaffold/ownership-not-subject.md +63 -0
  25. data/.okf/scaffold/the-adopters-manual.md +114 -0
  26. data/.okf/seam/bundler-scoping.md +45 -0
  27. data/.okf/seam/identity-not-existence.md +67 -0
  28. data/.okf/seam/index.md +8 -0
  29. data/.okf/seam/the-wrapper.md +42 -0
  30. data/.okf/seam/three-fail-opens.md +44 -0
  31. data/.okf/testing/drills-over-units.md +41 -0
  32. data/.okf/testing/fixture-is-a-client.md +35 -0
  33. data/.okf/testing/index.md +4 -0
  34. data/.okf/trust/index.md +7 -0
  35. data/.okf/trust/read-owed-rule.md +45 -0
  36. data/.okf/trust/scalar-verified.md +48 -0
  37. data/CHANGELOG.md +187 -0
  38. data/LICENSE.txt +201 -0
  39. data/NOTICE +10 -0
  40. data/README.md +383 -0
  41. data/lib/okf/plugin.rb +148 -0
  42. data/lib/okf/pro/attestation.rb +62 -0
  43. data/lib/okf/pro/audit.rb +161 -0
  44. data/lib/okf/pro/board/edit.rb +201 -0
  45. data/lib/okf/pro/board.rb +291 -0
  46. data/lib/okf/pro/budget.rb +144 -0
  47. data/lib/okf/pro/bundle_root.rb +241 -0
  48. data/lib/okf/pro/cli.rb +914 -0
  49. data/lib/okf/pro/closing.rb +206 -0
  50. data/lib/okf/pro/conformance.rb +127 -0
  51. data/lib/okf/pro/conserve.rb +94 -0
  52. data/lib/okf/pro/event.rb +95 -0
  53. data/lib/okf/pro/friction.rb +262 -0
  54. data/lib/okf/pro/guards.rb +92 -0
  55. data/lib/okf/pro/log/edit.rb +68 -0
  56. data/lib/okf/pro/log.rb +162 -0
  57. data/lib/okf/pro/pairing.rb +271 -0
  58. data/lib/okf/pro/reconcile.rb +76 -0
  59. data/lib/okf/pro/records.rb +90 -0
  60. data/lib/okf/pro/scaffold.rb +258 -0
  61. data/lib/okf/pro/shell_guard.rb +164 -0
  62. data/lib/okf/pro/snapshot.rb +156 -0
  63. data/lib/okf/pro/state.rb +174 -0
  64. data/lib/okf/pro/target.rb +91 -0
  65. data/lib/okf/pro/template/gem/.claude/hooks/run +119 -0
  66. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/SKILL.md +233 -0
  67. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/attribution.md +46 -0
  68. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/board.md +55 -0
  69. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/closing.md +21 -0
  70. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/frontmatter.md +73 -0
  71. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/okf-rules.md +25 -0
  72. data/lib/okf/pro/template/gem/.githooks/pre-commit +68 -0
  73. data/lib/okf/pro/template/gem/.github/workflows/okf-pro.yml +69 -0
  74. data/lib/okf/pro/template/seed/.claude/settings.json +24 -0
  75. data/lib/okf/pro/template/seed/.okf/areas/corpus.md +73 -0
  76. data/lib/okf/pro/template/seed/.okf/areas/index.md +6 -0
  77. data/lib/okf/pro/template/seed/.okf/board.md +21 -0
  78. data/lib/okf/pro/template/seed/.okf/glossary/index.md +13 -0
  79. data/lib/okf/pro/template/seed/.okf/index.md +30 -0
  80. data/lib/okf/pro/template/seed/.okf/journal/index.md +11 -0
  81. data/lib/okf/pro/template/seed/.okf/learnings/index.md +13 -0
  82. data/lib/okf/pro/template/seed/.okf/log.md +10 -0
  83. data/lib/okf/pro/template/seed/.okf/projects/index.md +23 -0
  84. data/lib/okf/pro/template/seed/.okf/reference/index.md +16 -0
  85. data/lib/okf/pro/template/seed/.okf/roadmap.md +33 -0
  86. data/lib/okf/pro/template/seed/.tmp/.gitkeep +0 -0
  87. data/lib/okf/pro/template/seed/CLAUDE.md +61 -0
  88. data/lib/okf/pro/template/seed/README.md +488 -0
  89. data/lib/okf/pro/template/seed/gitignore +17 -0
  90. data/lib/okf/pro/version.rb +7 -0
  91. data/lib/okf/pro/writes.rb +560 -0
  92. data/lib/okf/pro.rb +198 -0
  93. metadata +169 -0
@@ -0,0 +1,914 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hand-rolled where okf's Command already has `json_flags`/`emit_json`, and
4
+ # deliberately: those are PRIVATE instance methods on a class this module is not
5
+ # — `OKF::Pro::CLI` is a module, and the suite drives `CLI.run` directly rather
6
+ # than through `okf/plugin.rb`, so a parser inherited from the kernel would be
7
+ # untested at the one door every test goes through. The same argument the
8
+ # duplicated `help_rows` already carries.
9
+ require "optparse"
10
+
11
+ module OKF
12
+ module Pro
13
+ # Dispatch, and the exit codes the hook protocol reads.
14
+ #
15
+ # Every check has the same signature — event in, messages out, empty means
16
+ # pass — so this table is the only place a check name is bound to behaviour,
17
+ # and the only place an exit code is chosen. A drill runs exactly what a
18
+ # session runs because both arrive here.
19
+ module CLI
20
+ CHECKS = {
21
+ "guard-verified" => ->(event) { Guards.guard_verified(event) },
22
+ "journal-guard" => ->(event) { Guards.journal_guard(event) },
23
+ "shell-guard" => ->(event) { ShellGuard.check(event) },
24
+ "check-okf" => ->(event) { Conformance.check(Target.for(event)) },
25
+ "cap-check" => ->(event) { Budget.cap_check(Target.for(event)) },
26
+ "reconcile-search" => ->(event) { Reconcile.search(Target.for(event), event) },
27
+ "post-edit" => ->(event) { CLI.post_edit(event) },
28
+ "stop-gate" => ->(event) { Closing.stop_gate(event) }
29
+ }.freeze
30
+
31
+ # Every name this module answers to. The hook door accepts a strictly
32
+ # narrower set — CHECKS.keys plus session-context — enforced one layer up,
33
+ # in OKF::CLI::Pro, because `run` dispatches the CI verbs off the same
34
+ # first element: a settings.json typo spelling `hook audit` would otherwise
35
+ # install a gate that reads no stdin, never blocks, and reports clean.
36
+ # The generator's verbs. They take a destination rather than a bundle, so
37
+ # they do not go through `dir_argument` — `setup` into an empty directory
38
+ # is the whole point, and refusing one that holds no bundle would refuse
39
+ # every first run.
40
+ SCAFFOLD = %w[setup upgrade skill].freeze
41
+
42
+ # The readers: one call answers what nine calls answered. No new logic —
43
+ # every aggregation they print already existed and was consumed only by a
44
+ # gate, which is why an agent working in a seeded bundle rediscovered the
45
+ # board by reading raw markdown while the stop gate was computing it.
46
+ READERS = %w[audit records snapshot unverified state board friction].freeze
47
+
48
+ # The writers: the shapes with exactly one correct form. Each one is
49
+ # additive and targeted, and each refuses through `Conserve` rather than
50
+ # promising to be careful — see `writes.rb` for the contract.
51
+ WRITERS = %w[capture promote demote journal close].freeze
52
+
53
+ NAMES = (CHECKS.keys + [ "session-context" ] + READERS + WRITERS + SCAFFOLD).freeze
54
+
55
+ # The names the hook door accepts, and the only ones. `run` dispatches the
56
+ # CI verbs off the same first element, so without this the adapter would
57
+ # forward `hook audit` into a verb that reads no stdin and cannot block.
58
+ HOOK_NAMES = (CHECKS.keys + [ "session-context" ]).freeze
59
+
60
+ # Written to stderr immediately before a check runs, and read by the
61
+ # scaffold's `.claude/hooks/run`. It is the whole of the wrapper's identity
62
+ # proof: a stray `okf` on PATH that exits 0 is indistinguishable from a
63
+ # clean gate by status alone, and that is a gate silently switched off.
64
+ # The wrapper strips this line before passing stderr on.
65
+ #
66
+ # Emitted here rather than at the door, so that its presence means the
67
+ # check was actually reached — not merely that the plugin loaded. The
68
+ # prototype proved the difference: its `okf pro contract` handshake
69
+ # answered fine while the library was missing.
70
+ MARKER = "okf-pro-enforcer v1"
71
+
72
+ # What `dir_argument` returns instead of a path. Distinct from nil, which
73
+ # is the legitimate "no argument given, use the working directory".
74
+ REFUSED = :refused
75
+
76
+ # The command list, and the single place it is described. `okf help`
77
+ # prints a second copy through OKF::CLI::Pro.help_rows, and it has to:
78
+ # reading this one would make every `okf help` load the whole library,
79
+ # which is the cost deferring the require exists to avoid. The two are
80
+ # joined by a test instead — the arrangement the closure grammar and the
81
+ # dormancy window already use.
82
+ USAGE = [
83
+ [ "setup", "[DIR]", "create or complete an agent's brain in DIR (default .)" ],
84
+ [ "upgrade", "[DIR]", "rewrite the gem-owned governance files; stage the rest" ],
85
+ [ "state", "[DIR]", "what is on the board, in one call — add --full for the corpus" ],
86
+ [ "board", "[DIR]", "one row per board line: section, dates, age, links" ],
87
+ [ "capture", "TEXT", "append a dated Inbox line" ],
88
+ [ "promote", "SEL", "Inbox or Backlog to In flight, refusing over the cap" ],
89
+ [ "demote", "SEL", "In flight back to Backlog" ],
90
+ [ "journal", "open", "create today's journal day and index it" ],
91
+ [ "close", "SLUG", "the three mechanical closing moves for a project" ],
92
+ [ "audit", "[DIR]", "every invariant at once — the CI door" ],
93
+ [ "records", "[DIR]", "does the staged commit rewrite a past journal day?" ],
94
+ [ "snapshot", "[DIR]", "compute the day's counter line (prints, never writes)" ],
95
+ [ "unverified", "[DIR]", "generated concepts still awaiting the owner's read" ],
96
+ [ "friction", "[DIR]", "what was done by hand that a verb could do" ],
97
+ [ "skill", "DEST", "(re)install okf-pro's agent skill on its own" ],
98
+ [ "hook", "CHECK", "run one gate against a hook event on stdin" ]
99
+ ].freeze
100
+
101
+ # Which flags each verb accepts, declared rather than discovered: an
102
+ # undeclared flag is a usage error the parser names, instead of a
103
+ # positional that `dir_argument` then reports as a second directory.
104
+ #
105
+ # Absence from this table means "accepts none", not "is exempt from it":
106
+ # `parse_flags` reads `FLAGS.fetch(verb, [])`, so a verb that routes
107
+ # through it refuses an undeclared flag whether or not it is listed. The
108
+ # verbs that skipped the parser entirely are how `okf pro audit --json`
109
+ # came to hand `--json` to `BundleRoot.resolve` and report "holds no OKF
110
+ # bundle" as a FINDING — exit 1, which a pipeline reads as a broken
111
+ # bundle rather than as its own typo.
112
+ FLAGS = {
113
+ "state" => %i[json pretty full],
114
+ "board" => %i[json pretty section],
115
+ "snapshot" => %i[json pretty],
116
+ "unverified" => %i[json pretty],
117
+ "friction" => %i[json pretty issue clear]
118
+ }.freeze
119
+
120
+ # How each flag is spelled in the usage. Read FROM `FLAGS` rather than
121
+ # typed beside it, so a flag added to one verb cannot be missing from the
122
+ # help — `parse_flags` tells a user that `okf pro --help` lists what each
123
+ # verb takes, and for a while that was simply untrue.
124
+ FLAG_HELP = {
125
+ json: "--json", pretty: "--pretty", full: "--full",
126
+ section: "--section NAME", issue: "--issue", clear: "--clear"
127
+ }.freeze
128
+
129
+ # Where a friction report goes. It is about okf-pro, not about the
130
+ # adopter's knowledge base, so it is this repository regardless of whose
131
+ # tree the bundle lives in. Nothing is ever filed automatically.
132
+ ISSUE_REPO = "serradura/okf-gem"
133
+
134
+ HELP = %w[--help -h help].freeze
135
+
136
+ # The bare semantic version, matching `okf --version` and
137
+ # `okf mcp --version`. No gem name: the caller has already named the
138
+ # extension on the command line, so a name in the output buys nothing and
139
+ # costs a script a `cut`. (`okf tui --version` prints `okf-tui 1.0.0` and
140
+ # is the odd one out; changing a released gem's output is not this gem's
141
+ # to do.)
142
+ VERSION_FLAGS = %w[--version -v version].freeze
143
+
144
+ module_function
145
+
146
+ # The three PostToolUse checks in one process, sharing one bundle read —
147
+ # the read is the expensive part, and three separate invocations paid for
148
+ # it three times over.
149
+ def post_edit(event)
150
+ target = Target.for(event)
151
+ # The second friction point, and the same argument as the first: this
152
+ # runs on every Edit/Write already, so recording here costs no hook
153
+ # registration an adopter would never receive. Only the files a verb
154
+ # covers count — an Edit to a concept body is judgment and always will
155
+ # be, and counting it would report the system working as friction.
156
+ Friction.record(target.root, "edit", target.rel) if target && Friction.covered_path?(target.rel)
157
+ Conformance.check(target) + Budget.cap_check(target) + Reconcile.search(target, event)
158
+ end
159
+
160
+ def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
161
+ check = argv.shift.to_s
162
+
163
+ # Asking for help is not an error, so it goes to stdout and exits 0.
164
+ # Asking for nothing is: `okf pro` alone did nothing anyone requested,
165
+ # and a 0 there would say it did.
166
+ return usage(stdout, PASS) if HELP.include?(check)
167
+
168
+ if VERSION_FLAGS.include?(check)
169
+ stdout.puts VERSION
170
+ return PASS
171
+ end
172
+
173
+ return usage(stderr, BLOCK) if check.empty?
174
+
175
+ # Past this line the answer can only be PASS-with-a-check-run or BLOCK,
176
+ # which is exactly what the marker claims. It is not emitted for the CI
177
+ # verbs: they are read by a person and a pipeline, not by the wrapper.
178
+ stderr.puts MARKER if HOOK_NAMES.include?(check)
179
+
180
+ if READERS.include?(check) || WRITERS.include?(check)
181
+ # Whitelisted before it is sent: the list above is the composition
182
+ # table, and dispatching off an unvalidated name is how `hook audit`
183
+ # became a gate that always said fine.
184
+ return send(check, argv, stdout: stdout, stderr: stderr)
185
+ end
186
+ return scaffold(check, argv, stdout: stdout, stderr: stderr) if SCAFFOLD.include?(check)
187
+ return session_context(stdin, stdout: stdout) if check == "session-context"
188
+
189
+ handler = CHECKS[check]
190
+ unless handler
191
+ # An unknown check name is enforcement that did not run. The shell
192
+ # dispatcher this replaces fell off the end of its `case` and exited 0
193
+ # on a typo — silence in the one place the contract names.
194
+ stderr.puts "ENFORCEMENT MISCONFIGURED — no check named '#{check}'. Known: #{NAMES.join(", ")}."
195
+ return BLOCK
196
+ end
197
+
198
+ event = Event.from_stdin(stdin)
199
+ if event.parse_error?
200
+ stderr.puts "ENFORCEMENT DEGRADED — '#{check}' could not read its input: #{event.parse_error}. " \
201
+ "No check ran, so nothing here has been checked."
202
+ return BLOCK
203
+ end
204
+
205
+ # The hook protocol reads every exit but 2 as NON-BLOCKING, so an
206
+ # exception that escapes a check is not an error report — it is the
207
+ # edit sailing through while the gate lies on the floor. Refusing on
208
+ # any crash is the contract's floor: a gate that cannot check must
209
+ # not wave things through.
210
+ begin
211
+ result = handler.call(event)
212
+ rescue StandardError => e
213
+ stderr.puts "ENFORCEMENT ERROR — '#{check}' crashed (#{e.class}: #{e.message}); " \
214
+ "nothing was checked, so the call is refused. A crash that passed " \
215
+ "would be indistinguishable from a clean bundle."
216
+ return BLOCK
217
+ end
218
+ return PASS if result.empty?
219
+
220
+ # A check that returns {"ask" => reason} is routing the decision to the
221
+ # owner instead of refusing: the hook protocol reads exit 0 plus this
222
+ # JSON as "prompt the user". Interactive, the owner approves or denies
223
+ # in the moment; unattended, there is no approver and the write fails
224
+ # closed at the permission layer. Arrays stay refusals, as ever.
225
+ if result.is_a?(Hash)
226
+ stdout.puts JSON.generate(
227
+ "hookSpecificOutput" => {
228
+ "hookEventName" => "PreToolUse",
229
+ "permissionDecision" => "ask",
230
+ "permissionDecisionReason" => result.fetch("ask")
231
+ }
232
+ )
233
+ return PASS
234
+ end
235
+
236
+ stderr.puts result.join("\n")
237
+ BLOCK
238
+ end
239
+
240
+ def audit(argv, stdout: $stdout, stderr: $stderr)
241
+ options = parse_flags(argv, "audit", stdout, stderr)
242
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
243
+
244
+ root = dir_argument(argv, "audit", stderr)
245
+ return BLOCK if root == REFUSED
246
+
247
+ begin
248
+ msgs = Audit.call(root || Dir.pwd)
249
+ rescue StandardError => e
250
+ stderr.puts "okf pro audit — could not run (#{e.class}: #{e.message}); nothing was " \
251
+ "checked. This is exit 2, not 1: 1 means findings, and a pipeline that " \
252
+ "cannot tell a broken bundle from a broken checker learns to ignore both."
253
+ return BLOCK
254
+ end
255
+ if msgs.empty?
256
+ stdout.puts "okf pro audit — clean."
257
+ return PASS
258
+ end
259
+
260
+ stderr.puts "okf pro audit — #{msgs.size} finding(s):\n#{msgs.join("\n")}"
261
+ FAIL
262
+ end
263
+
264
+ # The append-only record, asked of the index. Separate from `audit`
265
+ # because it reads a CHANGE (git's staged diff) rather than a STATE,
266
+ # and the pre-commit door materialises a tree where that change is no
267
+ # longer visible as one.
268
+ def records(argv, stdout: $stdout, stderr: $stderr)
269
+ options = parse_flags(argv, "records", stdout, stderr)
270
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
271
+
272
+ root = dir_argument(argv, "records", stderr)
273
+ return BLOCK if root == REFUSED
274
+
275
+ begin
276
+ msgs = Records.staged_violations(root || Dir.pwd)
277
+ rescue StandardError => e
278
+ stderr.puts "okf pro records — could not run (#{e.class}: #{e.message}); the staged " \
279
+ "diff was never read. Exit 2, for the reason `audit` gives."
280
+ return BLOCK
281
+ end
282
+ if msgs.empty?
283
+ stdout.puts "okf pro records — append-only."
284
+ return PASS
285
+ end
286
+
287
+ stderr.puts "okf pro records — #{msgs.size} finding(s):\n#{msgs.join("\n")}"
288
+ FAIL
289
+ end
290
+
291
+ # The checker-first half of derivation: computes the mechanical line for
292
+ # a person to append. It prints and never writes — Agent Drift killed the
293
+ # generator, and this is not one.
294
+ def snapshot(argv, stdout: $stdout, stderr: $stderr)
295
+ options = parse_flags(argv, "snapshot", stdout, stderr)
296
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
297
+
298
+ root = bundle_for(argv, "snapshot", stderr)
299
+ return BLOCK if root.nil?
300
+
301
+ unless File.exist?(File.join(root, "board.md"))
302
+ stderr.puts "okf pro snapshot — #{root} has no board.md to count."
303
+ return BLOCK
304
+ end
305
+
306
+ counters = guarded("snapshot", stderr) { Snapshot.counters(root) }
307
+ return BLOCK if counters.nil?
308
+
309
+ # The rendered line travels WITH the counters rather than instead of
310
+ # them. It is what a person appends to `log.md`, and a consumer that
311
+ # had to re-render it from the twelve numbers would be a second
312
+ # implementation of the one shape the stop gate verifies.
313
+ line = Snapshot.render(counters)
314
+ emit(stdout, options, { "line" => line, "counters" => counters }) { line }
315
+ PASS
316
+ end
317
+
318
+ # Report-only, and for a shape of reason worth stating: absent
319
+ # attestation is the truth, and a gate here would pressure toward the
320
+ # one lie the system guards against.
321
+ def unverified(argv, stdout: $stdout, stderr: $stderr)
322
+ options = parse_flags(argv, "unverified", stdout, stderr)
323
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
324
+
325
+ root = bundle_for(argv, "unverified", stderr)
326
+ return BLOCK if root.nil?
327
+
328
+ rows = guarded("unverified", stderr) { Attestation.rows(root) }
329
+ return BLOCK if rows.nil?
330
+
331
+ emit(stdout, options, rows) do
332
+ if rows.empty?
333
+ [ "okf pro unverified — nothing awaits a read." ]
334
+ else
335
+ [ "okf pro unverified — #{rows.size} concept(s) awaiting the owner's read " \
336
+ "(the state is the truth, not a defect):", *Attestation.render(rows) ]
337
+ end
338
+ end
339
+ PASS
340
+ end
341
+
342
+ # ── the readers ──────────────────────────────────────────────────────
343
+
344
+ def state(argv, stdout: $stdout, stderr: $stderr)
345
+ options = parse_flags(argv, "state", stdout, stderr)
346
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
347
+
348
+ root = bundle_with_board(argv, "state", stderr)
349
+ return BLOCK if root.nil?
350
+
351
+ payload = guarded("state", stderr) { State.call(root, full: options[:full]) }
352
+ return BLOCK if payload.nil?
353
+
354
+ emit(stdout, options, payload) { State.render(payload) }
355
+ PASS
356
+ end
357
+
358
+ def board(argv, stdout: $stdout, stderr: $stderr)
359
+ options = parse_flags(argv, "board", stdout, stderr)
360
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
361
+
362
+ root = bundle_with_board(argv, "board", stderr)
363
+ return BLOCK if root.nil?
364
+
365
+ text = Pro.read_text(File.join(root, "board.md"))
366
+ rows = Board.rows(text)
367
+ if options[:section]
368
+ # An empty section and a misspelled one look identical in the output
369
+ # and mean opposite things, so the heading is asked for by name. This
370
+ # is the quiet-zero class the board's own grammar check exists for.
371
+ unless section?(text, options[:section])
372
+ stderr.puts "okf pro board — the board has no '## #{options[:section]}' section. " \
373
+ "An empty answer here would be indistinguishable from a section that is simply empty."
374
+ return BLOCK
375
+ end
376
+ rows = rows.select { |row| row.section.casecmp(options[:section]).zero? }
377
+ end
378
+
379
+ today = Date.today
380
+ payload = rows.map { |row| board_row(row, today) }
381
+ emit(stdout, options, payload) { render_board(payload) }
382
+ PASS
383
+ end
384
+
385
+ def section?(text, name)
386
+ heading = "## #{name}"
387
+ Board.visible(text).each_line.any? { |line| line.start_with?("## ") && line.chomp.rstrip.casecmp(heading).zero? }
388
+ end
389
+
390
+ def board_row(row, today)
391
+ date = row.line_date
392
+ {
393
+ "section" => row.section,
394
+ "text" => row.text,
395
+ "date" => date&.to_s,
396
+ "age" => date && (today - date).to_i,
397
+ "chase" => row.chase_date&.to_s,
398
+ "targets" => row.targets
399
+ }
400
+ end
401
+
402
+ def render_board(rows)
403
+ return [ "okf pro board — no lines." ] if rows.empty?
404
+
405
+ rows.map do |row|
406
+ age = row["age"] ? " (#{row["age"]}d)" : ""
407
+ chase = row["chase"] ? " [chase #{row["chase"]}]" : ""
408
+ "[#{row["section"]}]#{age}#{chase} #{row["text"]}"
409
+ end
410
+ end
411
+
412
+ def friction(argv, stdout: $stdout, stderr: $stderr)
413
+ options = parse_flags(argv, "friction", stdout, stderr)
414
+ return options == :handled ? PASS : BLOCK unless options.is_a?(Hash)
415
+
416
+ root = bundle_for(argv, "friction", stderr)
417
+ return BLOCK if root.nil?
418
+
419
+ return clear_friction(stdout, root) if options[:clear]
420
+
421
+ report = Friction.report(root)
422
+ rows = friction_rows(report)
423
+ return issue_report(stdout, root, report, rows) if options[:issue]
424
+
425
+ payload = { "available" => report.available, "recorded" => report.events.size,
426
+ "unreadable" => report.unreadable, "by" => rows }
427
+ emit(stdout, options, payload) { render_friction(report, rows) }
428
+ PASS
429
+ end
430
+
431
+ # Grouped by the DOOR as well as the file. An Edit to the board and a
432
+ # shell redirect at it are different findings about the same path: one
433
+ # says a verb went unused, the other says the trust guards were bypassed
434
+ # entirely, and what covers them is not the same answer.
435
+ def friction_rows(report)
436
+ counts = report.events.each_with_object({}) do |event, acc|
437
+ key = [ event["via"].to_s, event["what"].to_s ]
438
+ acc[key] = (acc[key] || 0) + 1
439
+ end
440
+ counts.sort_by { |(via, what), n| [ -n, via, what ] }.map do |(via, what), n|
441
+ { "via" => via, "what" => what, "count" => n, "covered by" => Friction.covered_by(via, what) }
442
+ end
443
+ end
444
+
445
+ # The way out of a sticky marker, and the way to stop a lifetime total
446
+ # nagging about a week nobody can change. It touches `.tmp/` and nothing
447
+ # else — this is telemetry, not knowledge, and no bundle file is involved.
448
+ def clear_friction(stdout, root)
449
+ removed = Friction.clear(root)
450
+ if removed.nil?
451
+ stdout.puts "okf pro friction — could not clear #{File.dirname(Friction.log_path(root))}. " \
452
+ "Remove the files by hand: #{Friction.log_path(root)} and #{Friction.marker_path(root)}."
453
+ return PASS
454
+ end
455
+
456
+ stdout.puts "okf pro friction — cleared (#{removed} file(s) removed). The count starts again from here."
457
+ PASS
458
+ end
459
+
460
+ # Report-only, exit 0 — the family `unverified` belongs to. It measures
461
+ # this gem, not the adopter's bundle, and a measurement that gated
462
+ # something would start being gamed the day someone noticed.
463
+ # Nothing readable and nothing recorded are different states, and this is
464
+ # the surface a person meets first. `--issue` already treats them apart —
465
+ # it declines to file for one and files for the other — so a default that
466
+ # called an unparseable log "nothing recorded" made the human output the
467
+ # one that lied.
468
+ def unreadable_note(report)
469
+ "okf pro friction — #{report.unreadable} recorded line(s) will not parse and nothing else " \
470
+ "is recorded, so this is a corrupted log rather than a quiet one. Nothing here is a " \
471
+ "zero. `okf pro friction --clear` starts the count again."
472
+ end
473
+
474
+ def render_friction(report, rows)
475
+ return [ UNAVAILABLE_NOTE ] unless report.available
476
+
477
+ if rows.empty?
478
+ return [ unreadable_note(report) ] if report.unreadable.positive?
479
+
480
+ return [ "okf pro friction — nothing recorded. Either the verbs covered it, or nothing was written by hand." ]
481
+ end
482
+
483
+ lines = [ "okf pro friction — #{report.events.size} bundle edit(s) recorded so far that a " \
484
+ "command could have done:" ]
485
+ rows.each do |row|
486
+ label = "#{row["via"]} #{row["what"]}"
487
+ lines << " #{label.ljust(16)} #{row["count"]}#{" now: #{row["covered by"]}" if row["covered by"]}"
488
+ end
489
+ lines << " #{report.unreadable} recorded line(s) could not be parsed and are not counted above." if report.unreadable.positive?
490
+ lines << "If one of these should be an `okf pro` verb, please tell the maintainer — " \
491
+ "`okf pro friction --issue` prints a ready-to-paste report. It helps more than you think."
492
+ lines << "The count is cumulative; `okf pro friction --clear` starts it again."
493
+ lines
494
+ end
495
+
496
+ # Names the file, because "check that .tmp/ is writable" is not actionable
497
+ # once the marker is the thing keeping the answer unknown — the directory
498
+ # may be perfectly writable now and the report still says unknown, which
499
+ # is correct and infuriating without the next sentence.
500
+ UNAVAILABLE_NOTE = "okf pro friction — the recorder could not write at some point, so this is " \
501
+ "not zero, it is unknown. Check that .tmp/ is writable at the repository " \
502
+ "root, then `okf pro friction --clear` to start counting again."
503
+
504
+ # Printed, never run. Filing an issue is outward-facing and irreversible,
505
+ # and a hook that did it unattended would be both without anyone asking.
506
+ def issue_report(stdout, root, report, rows)
507
+ # Nothing counted, and the recorder says it counted honestly — so there
508
+ # is nothing to file. The report drafted here was complete and
509
+ # ready-to-paste and said "Recorded over 0 event(s)", which asks the
510
+ # maintainer to act on an empty list.
511
+ #
512
+ # Two states are NOT that, and both still file. "The recorder could not
513
+ # write" is something that happened; so is a log whose every line was
514
+ # unparseable, which is a corrupted recorder rather than a quiet one —
515
+ # zero rows with a positive `unreadable` is the shape that would
516
+ # otherwise be reported as nothing at all.
517
+ if rows.empty? && report.available && report.unreadable.zero?
518
+ stdout.puts "okf pro friction — nothing recorded, so there is no report to file. " \
519
+ "Either the verbs covered it or nothing was written by hand; an issue " \
520
+ "whose body is an empty list asks the maintainer to act on nothing."
521
+ return PASS
522
+ end
523
+
524
+ # Says what was counted, and claims nothing about what covers it. The
525
+ # banner had just established that a shell redirect's answer is Edit or
526
+ # Write rather than a verb, and a title reading "that a verb could
527
+ # cover" over a body whose only row says otherwise asks the maintainer
528
+ # for a verb this gem decided not to want. The `covered by` note on
529
+ # each row is where that question is actually answered.
530
+ title = "okf-pro: #{report.events.size} bundle edit(s) recorded by hand"
531
+ body = issue_body(root, report, rows)
532
+ if which("gh")
533
+ stdout.puts "Run this — it is not run for you, because filing an issue is yours to decide:"
534
+ stdout.puts
535
+ stdout.puts "gh issue create --repo #{ISSUE_REPO} \\"
536
+ stdout.puts " --title #{shell_quote(title)} \\"
537
+ stdout.puts " --body #{shell_quote(body)}"
538
+ else
539
+ stdout.puts "`gh` is not on PATH, so here is the issue to paste:"
540
+ stdout.puts
541
+ stdout.puts " https://github.com/#{ISSUE_REPO}/issues/new"
542
+ stdout.puts
543
+ stdout.puts "Title: #{title}"
544
+ stdout.puts
545
+ stdout.puts body
546
+ end
547
+ PASS
548
+ end
549
+
550
+ def issue_body(_root, report, rows)
551
+ lines = [ "`okf pro friction` recorded these while working in a bundle. Each is an edit made",
552
+ "by hand inside it — some to a file a command already covers, some through a door",
553
+ "the trust guards cannot see at all. The `covered by` note says which.", "" ]
554
+ # The evidence is printed whatever the recorder's state. Branching the
555
+ # whole body on `available` dropped every row it had and then said "the
556
+ # counts above are unknown" over a body with nothing above it — a
557
+ # sentence about text that was not there, under a title that still
558
+ # counted the rows. The marker is sticky by design, so one failed write
559
+ # weeks ago made every later report evidence-free.
560
+ rows.each do |row|
561
+ covered = row["covered by"]
562
+ lines << "* `#{row["via"]} #{row["what"]}` — #{row["count"]} time(s)#{" (covered by: #{covered})" if covered}"
563
+ end
564
+ lines << "" << "Recorded over #{report.events.size} event(s)."
565
+ lines << "#{report.unreadable} recorded line(s) were unparseable and are excluded." if report.unreadable.positive?
566
+ unless report.available
567
+ lines << "The recorder could not write at some point, so this is a floor rather than a " \
568
+ "total: the real count is higher by an unknown amount."
569
+ end
570
+ lines << "" << "okf-pro #{VERSION}, ruby #{RUBY_VERSION}."
571
+ lines.join("\n")
572
+ end
573
+
574
+ # Single-quoted with the one escape a POSIX shell accepts inside them.
575
+ # The body is assembled from recorded `what` values, which are this gem's
576
+ # own vocabulary — but printing a command a reader will paste is exactly
577
+ # the place not to assume that.
578
+ def shell_quote(text)
579
+ # Block form, and it is load-bearing: in a replacement STRING, `\'` is
580
+ # gsub's post-match special, so the string form of this substitution
581
+ # spliced the rest of the text back in after every apostrophe. A block
582
+ # return is taken literally.
583
+ "'#{text.to_s.gsub("'") { "'\\''" }}'"
584
+ end
585
+
586
+ def which(name)
587
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
588
+ path = File.join(dir, name)
589
+ File.file?(path) && File.executable?(path)
590
+ end
591
+ end
592
+
593
+ # ── the writers ──────────────────────────────────────────────────────
594
+
595
+ def capture(argv, stdout: $stdout, stderr: $stderr)
596
+ answered = writer_flags(argv, "capture", stdout, stderr)
597
+ return answered == :handled ? PASS : BLOCK unless answered == :ok
598
+
599
+ text = argv.shift
600
+ if text.nil?
601
+ stderr.puts "okf pro capture — takes the words to capture: `okf pro capture \"what you heard\"`."
602
+ return BLOCK
603
+ end
604
+
605
+ write_verb("capture", argv, stdout, stderr) { |root| Writes.capture(root, text) }
606
+ end
607
+
608
+ def promote(argv, stdout: $stdout, stderr: $stderr)
609
+ selector_verb("promote", argv, stdout, stderr) { |root, selector| Writes.promote(root, selector) }
610
+ end
611
+
612
+ def demote(argv, stdout: $stdout, stderr: $stderr)
613
+ selector_verb("demote", argv, stdout, stderr) { |root, selector| Writes.demote(root, selector) }
614
+ end
615
+
616
+ def journal(argv, stdout: $stdout, stderr: $stderr)
617
+ answered = writer_flags(argv, "journal", stdout, stderr)
618
+ return answered == :handled ? PASS : BLOCK unless answered == :ok
619
+
620
+ sub = argv.shift
621
+ unless sub == "open"
622
+ stderr.puts "okf pro journal — takes one subcommand, `open`, and was given " \
623
+ "#{sub.nil? ? "none" : "'#{sub}'"}. `okf pro journal open` creates today's day " \
624
+ "file and its index line; what goes in it is yours to write."
625
+ return BLOCK
626
+ end
627
+
628
+ write_verb("journal open", argv, stdout, stderr) { |root| Writes.journal_open(root) }
629
+ end
630
+
631
+ # `close` takes a PROJECT, not a board-line selector, and the difference
632
+ # is not pedantry: `Writes.close` requires one directory segment, so the
633
+ # `/projects/<slug>/index.md` form a board line actually carries — the
634
+ # one `okf pro board` prints — is a refusal, and so is a substring. The
635
+ # shared message offered both, which sends the reader to try what the
636
+ # verb rejects.
637
+ CLOSE_ARGUMENT = "takes the project to close: its directory name under projects/, or the " \
638
+ "`/projects/<slug>/` link a board line carries (with or without its " \
639
+ "`index.md`). It names one directory segment either way, so a substring " \
640
+ "of a board line is not one. `okf pro state` lists the open ones."
641
+
642
+ def close(argv, stdout: $stdout, stderr: $stderr)
643
+ selector_verb("close", argv, stdout, stderr, missing: CLOSE_ARGUMENT) { |root, slug| Writes.close(root, slug) }
644
+ end
645
+
646
+ SELECTOR_ARGUMENT = "takes what to act on: a `/projects/<slug>` link, a slug, or a " \
647
+ "substring only one board line carries. `okf pro board` lists them."
648
+
649
+ def selector_verb(verb, argv, stdout, stderr, missing: SELECTOR_ARGUMENT)
650
+ answered = writer_flags(argv, verb, stdout, stderr)
651
+ return answered == :handled ? PASS : BLOCK unless answered == :ok
652
+
653
+ selector = argv.shift
654
+ if selector.nil?
655
+ stderr.puts "okf pro #{verb} — #{missing}"
656
+ return BLOCK
657
+ end
658
+
659
+ write_verb(verb, argv, stdout, stderr) { |root| yield(root, selector) }
660
+ end
661
+
662
+ # The writers take no flags, and that is precisely why they need this.
663
+ #
664
+ # Their first positional is CONTENT — the words to capture, the line to
665
+ # move — so a mistyped or misremembered flag is not an error, it is data:
666
+ # `okf pro capture --help` appended `- <date> — --help` to the Inbox and
667
+ # exited 0. A verb whose failure mode is committing a garbage board line
668
+ # must refuse a leading dash rather than swallow it.
669
+ #
670
+ # `--help` and `--version` are answered because every other verb answers
671
+ # them, and `--` is the POSIX escape for the rare legitimate case of
672
+ # content that really does begin with a dash.
673
+ #
674
+ # Returns `:ok` to carry on, `:handled` when the caller has been answered,
675
+ # or nil on a refusal already reported.
676
+ def writer_flags(argv, verb, stdout, stderr)
677
+ first = argv.first.to_s
678
+ return help_answer(stdout) if HELP.include?(first)
679
+ return version_answer(stdout) if VERSION_FLAGS.include?(first)
680
+
681
+ if first == "--"
682
+ argv.shift
683
+ return :ok
684
+ end
685
+ return :ok unless first.start_with?("-")
686
+
687
+ stderr.puts "okf pro #{verb} — '#{first}' looks like a flag, and this verb takes none: its " \
688
+ "first argument is content, so a flag swallowed here becomes a board line " \
689
+ "nobody meant to write. If you really meant to #{verb} something starting with " \
690
+ "a dash, put `--` in front of it."
691
+ nil
692
+ end
693
+
694
+ # The shape every writer shares: resolve, run, print, choose the code.
695
+ # The write itself never happens here — `Writes` runs the conservation
696
+ # guard and refuses before touching the disk, so this method cannot make
697
+ # a partial write even by getting the ordering wrong.
698
+ def write_verb(verb, argv, stdout, stderr)
699
+ root = bundle_for(argv, verb, stderr)
700
+ return BLOCK if root.nil?
701
+
702
+ result = guarded(verb, stderr) { yield(root) }
703
+ return BLOCK if result.nil?
704
+
705
+ if result.ok
706
+ stdout.puts result.messages unless result.messages.empty?
707
+ return PASS
708
+ end
709
+
710
+ stderr.puts result.messages
711
+ BLOCK
712
+ end
713
+
714
+ # ── the shared plumbing ──────────────────────────────────────────────
715
+
716
+ # Parsed BEFORE `dir_argument`, which refuses any second positional and
717
+ # would otherwise report `okf pro state . --json` as two directories.
718
+ #
719
+ # `--help` and `--version` are declared rather than left to OptionParser's
720
+ # "officious" defaults, which call `exit` from inside the parse — a
721
+ # process exit out of a library call, in a gem whose whole subject is
722
+ # which exit code a gate returns.
723
+ #
724
+ # Returns the options Hash, `:help` when the caller asked for the usage,
725
+ # or nil on a parse error already reported.
726
+ def parse_flags(argv, verb, stdout, stderr)
727
+ options = { json: false, pretty: false, full: false, issue: false, clear: false, section: nil }
728
+ allowed = FLAGS.fetch(verb, [])
729
+ parser = OptionParser.new do |o|
730
+ o.banner = "Usage: okf pro #{verb} [DIR]"
731
+ o.on("--json") { options[:json] = true } if allowed.include?(:json)
732
+ o.on("--pretty") { options[:json] = options[:pretty] = true } if allowed.include?(:pretty)
733
+ o.on("--full") { options[:full] = true } if allowed.include?(:full)
734
+ o.on("--issue") { options[:issue] = true } if allowed.include?(:issue)
735
+ o.on("--clear") { options[:clear] = true } if allowed.include?(:clear)
736
+ o.on("--section NAME") { |value| options[:section] = value } if allowed.include?(:section)
737
+ o.on("-h", "--help") { options[:help] = true }
738
+ o.on("-v", "--version") { options[:version] = true }
739
+ end
740
+ parser.parse!(argv)
741
+ return version_answer(stdout) if options[:version]
742
+ return help_answer(stdout) if options[:help]
743
+
744
+ options
745
+ rescue OptionParser::ParseError => e
746
+ stderr.puts "okf pro #{verb} — #{e.message}. `okf pro --help` lists what each verb takes."
747
+ nil
748
+ end
749
+
750
+ # Not the options Hash and not nil: the caller has already been answered,
751
+ # and there is nothing left to do but exit 0.
752
+ def help_answer(stdout)
753
+ usage(stdout, PASS)
754
+ :handled
755
+ end
756
+
757
+ def version_answer(stdout)
758
+ stdout.puts VERSION
759
+ :handled
760
+ end
761
+
762
+ def emit(stdout, options, payload)
763
+ if options[:json]
764
+ stdout.puts(options[:pretty] ? JSON.pretty_generate(payload) : JSON.generate(payload))
765
+ else
766
+ stdout.puts yield
767
+ end
768
+ end
769
+
770
+ # A verb that cannot run exits 2, never 1 — 1 means findings, and a
771
+ # pipeline that cannot tell a broken bundle from a broken checker learns
772
+ # to ignore both. The same reasoning `audit` states at length.
773
+ def guarded(verb, stderr)
774
+ yield
775
+ rescue StandardError => e
776
+ stderr.puts "okf pro #{verb} — could not run (#{e.class}: #{e.message}); nothing was " \
777
+ "read or written. Exit 2, for the reason `audit` gives."
778
+ nil
779
+ end
780
+
781
+ def bundle_for(argv, verb, stderr)
782
+ start = dir_argument(argv, verb, stderr)
783
+ return nil if start == REFUSED
784
+
785
+ resolve_or_complain(start, verb, stderr)
786
+ end
787
+
788
+ def bundle_with_board(argv, verb, stderr)
789
+ root = bundle_for(argv, verb, stderr)
790
+ return nil if root.nil?
791
+ return root if File.exist?(File.join(root, "board.md"))
792
+
793
+ stderr.puts "okf pro #{verb} — #{root} has no board.md to read."
794
+ nil
795
+ end
796
+
797
+ def scaffold(verb, argv, stdout: $stdout, stderr: $stderr)
798
+ dest = argv.shift
799
+
800
+ unless argv.empty?
801
+ stderr.puts "okf pro #{verb} — takes one directory, and #{argv.size + 1} were given."
802
+ return BLOCK
803
+ end
804
+
805
+ if verb == "skill"
806
+ if dest.nil?
807
+ stderr.puts "okf pro skill — needs a destination directory: `okf pro skill .claude/skills/okf-pro`."
808
+ return BLOCK
809
+ end
810
+ else
811
+ dest ||= Dir.pwd
812
+ end
813
+
814
+ if dest.to_s.start_with?("@")
815
+ stderr.puts "okf pro #{verb} — '#{dest}' is a registry ref, and this verb takes a path. " \
816
+ "The registry names where a bundle is; this writes a repository around one."
817
+ return BLOCK
818
+ end
819
+
820
+ Scaffold.public_send(verb, dest, out: stdout, err: stderr)
821
+ end
822
+
823
+ # The command list, plus the two things that surprise people — a ref is not
824
+ # a path here, and `hook` does not speak this repo's exit codes. Both cost
825
+ # something when they surprise someone, which is why they are in the usage
826
+ # rather than only in the guide.
827
+ def usage(stream, status)
828
+ width = USAGE.map { |verb, arg, _| "#{verb} #{arg}".length }.max
829
+ stream.puts "Usage: okf pro <command> [DIR]"
830
+ stream.puts
831
+ USAGE.each do |verb, arg, description|
832
+ stream.puts format(" %-#{width}s %s", "#{verb} #{arg}", description)
833
+ end
834
+ stream.puts
835
+ stream.puts " hook checks: #{HOOK_NAMES.first(5).join(", ")},"
836
+ stream.puts " #{HOOK_NAMES.drop(5).join(", ")}"
837
+ stream.puts
838
+ stream.puts "Flags:"
839
+ FLAGS.each do |verb, flags|
840
+ stream.puts format(" %-#{width}s %s", verb, flags.map { |flag| FLAG_HELP.fetch(flag) }.join(" "))
841
+ end
842
+ stream.puts " --pretty implies --json. `audit` and `records` take none — what they answer"
843
+ stream.puts " with is the exit code. The writers take no flags at all either: their first"
844
+ stream.puts " argument is content, so `--` is what escapes content that starts with a dash."
845
+ stream.puts
846
+ stream.puts "DIR takes a path, not an @slug: the registry names where a bundle is, and a"
847
+ stream.puts "brain is the repository around one — the hooks, the git hooks, the workflow."
848
+ stream.puts
849
+ stream.puts "Exit codes: `hook` speaks the agent hook protocol — 0 pass, 2 block, and never"
850
+ stream.puts "1, which that protocol reads as non-blocking. Every other command: 0 clean,"
851
+ stream.puts "1 findings, 2 could not run."
852
+ stream.puts
853
+ stream.puts "okf pro --version"
854
+ status
855
+ end
856
+
857
+ # Every bundle-taking verb reads one directory and nothing else.
858
+ #
859
+ # A leading `@` is refused by name rather than treated as a path. `okf
860
+ # help` promises "Anywhere a `<dir>` goes, an `@slug` goes", and that
861
+ # promise is the kernel's; this gem does not keep it yet, and okf-tui
862
+ # already shipped the failure of pretending otherwise — a ref reached the
863
+ # filesystem and came back "not a directory", which tells the user nothing
864
+ # about what is actually missing.
865
+ #
866
+ # A second positional is refused for the reason ../AGENTS.md records
867
+ # against `okf lint a b`: silently reading the first and ignoring the rest
868
+ # is a wrong answer with a green exit code.
869
+ def dir_argument(argv, verb, stderr)
870
+ start = argv.shift
871
+
872
+ unless argv.empty?
873
+ stderr.puts "okf pro #{verb} — takes one directory, and #{argv.size + 1} were given " \
874
+ "(#{([ start ] + argv).join(" ")}). Reading the first and ignoring the rest " \
875
+ "would be a wrong answer with a clean exit code."
876
+ return REFUSED
877
+ end
878
+
879
+ if start.to_s.start_with?("@")
880
+ stderr.puts "okf pro #{verb} — '#{start}' is a registry ref, and this verb takes a " \
881
+ "path. The registry answers where a bundle is; a brain is the repository " \
882
+ "around one, which a ref does not name. Give the directory."
883
+ return REFUSED
884
+ end
885
+
886
+ start
887
+ end
888
+
889
+ def resolve_or_complain(start, verb, stderr)
890
+ root = BundleRoot.resolve(start || Dir.pwd)
891
+ if root.nil?
892
+ stderr.puts "okf pro #{verb} — #{File.expand_path((start || Dir.pwd).to_s)} holds no OKF bundle."
893
+ end
894
+ root
895
+ end
896
+
897
+ # The one check that does not refuse. Its output is context, not a verdict,
898
+ # and SessionStart has no blocking channel — so it says what it could not
899
+ # do and lets the session start.
900
+ def session_context(stdin, stdout: $stdout)
901
+ event = Event.from_stdin(stdin)
902
+ if event.parse_error?
903
+ stdout.puts "Bundle state unavailable — #{event.parse_error}. " \
904
+ "The gates still run; only this banner is blind."
905
+ return PASS
906
+ end
907
+
908
+ lines = Closing.session_context(event)
909
+ stdout.puts lines if lines
910
+ PASS
911
+ end
912
+ end
913
+ end
914
+ end