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,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # The mechanical writers — the shapes with exactly one correct form.
6
+ #
7
+ # WHAT THESE ARE FOR. A dated Inbox line, a promotion, the day's journal
8
+ # file, the three mechanical moves of closure: each has one right answer,
9
+ # and each was being reconstructed from prose on every use — a third of a
10
+ # measured session's tool output was the skill's guides, read to learn the
11
+ # grammar of a line. Grammar a Ruby function knows is grammar nobody has to
12
+ # re-read.
13
+ #
14
+ # THE WRITE CONTRACT, and it is the whole design:
15
+ #
16
+ # Additive and targeted, never regenerative. A write verb may append a
17
+ # line or edit the line it was given. No verb rewrites a file it did not
18
+ # fully derive from that file's own prior contents. A writer never
19
+ # satisfies its own gate.
20
+ #
21
+ # Every method below is the same four steps: read, transform PURELY (in
22
+ # Board::Edit or Log::Edit, which cannot touch the disk), state the delta
23
+ # to `Conserve` and refuse if the actual delta differs, then write. The
24
+ # refusal is exit 2 and nothing lands — fail-closed applied to writes.
25
+ #
26
+ # TWO SAFETY PROPERTIES, held by construction rather than by guarding —
27
+ # necessary, because a verb invoked through Bash is seen by neither
28
+ # `guard-verified` (Edit/Write only) nor `shell-guard` (no mutator pattern
29
+ # in `okf pro capture`):
30
+ #
31
+ # 1. No verb interpolates agent-supplied text into frontmatter, ever.
32
+ # Agent text reaches a board line body or a journal entry body and
33
+ # nowhere else — and a newline in it is refused rather than escaped,
34
+ # because a text that spans lines is a text that can carry a `---`.
35
+ # 2. Selectors are keyed, never positional, and refuse on ambiguity.
36
+ #
37
+ # And one thing they deliberately do NOT do: `snapshot` gains no `--write`.
38
+ # A writer and a checker sharing a code path agree trivially and prove
39
+ # nothing, so the stop gate keeps its independent read.
40
+ module Writes
41
+ # What every verb hands back: whether it may exit 0, and the lines to
42
+ # print. Failures print to stderr and exit 2; the caller does not have to
43
+ # know which message means which.
44
+ Result = Struct.new(:ok, :messages)
45
+
46
+ module_function
47
+
48
+ def ok(*messages)
49
+ Result.new(true, messages.flatten)
50
+ end
51
+
52
+ def refuse(*messages)
53
+ Result.new(false, messages.flatten)
54
+ end
55
+
56
+ # ── capture ──────────────────────────────────────────────────────────
57
+
58
+ # `BundleRoot.resolve` accepts an index beside EITHER core file, so a
59
+ # bundle can legitimately resolve with no board at all. Every verb below
60
+ # edits one, and "could not run (Errno::ENOENT)" is a true answer to the
61
+ # wrong question.
62
+ def no_board(root, verb)
63
+ refuse("okf pro #{verb} — #{root} has no board.md to edit. `okf pro audit` reports the " \
64
+ "missing core file; this verb will not create one, because a board written by a " \
65
+ "verb is a board nobody decided the shape of.")
66
+ end
67
+
68
+ def board?(root)
69
+ File.file?(File.join(root, "board.md"))
70
+ end
71
+
72
+ def capture(root, text, today: Date.today)
73
+ return no_board(root, "capture") unless board?(root)
74
+
75
+ escape = escaping(root, "capture", "board.md")
76
+ return escape if escape
77
+
78
+ body = text.to_s
79
+ return refuse("okf pro capture — takes the words to capture: `okf pro capture \"what you heard\"`.") if body.strip.empty?
80
+
81
+ if body.include?("\n")
82
+ return refuse("okf pro capture — the text spans lines, and a capture is one line: the Inbox " \
83
+ "counter reads a line at a time, and a multi-line write is the one shape that " \
84
+ "could carry a `---` into a file this verb is not allowed to restructure.")
85
+ end
86
+
87
+ board_path = File.join(root, "board.md")
88
+ before = Pro.read_text(board_path)
89
+ line = ::OKF::Pro::Board::Edit.capture_line(body, today)
90
+ after, error = ::OKF::Pro::Board::Edit.append_to_section(before, "Inbox", line)
91
+ return refuse("okf pro capture — #{error}") if error
92
+
93
+ commit(board_path, before, after, "capture", added: [ line ]) do
94
+ [ "okf pro capture — one line added to Inbox:", " #{line}" ]
95
+ end
96
+ end
97
+
98
+ # ── promote / demote ─────────────────────────────────────────────────
99
+
100
+ PROMOTE_FROM = %w[Inbox Backlog].freeze
101
+
102
+ def promote(root, selector)
103
+ return no_board(root, "promote") unless board?(root)
104
+
105
+ escape = escaping(root, "promote", "board.md")
106
+ return escape if escape
107
+
108
+ board_path = File.join(root, "board.md")
109
+ before = Pro.read_text(board_path)
110
+ row, error = ::OKF::Pro::Board::Edit.select(Board.rows(before), selector, sections: PROMOTE_FROM)
111
+ return refuse("okf pro promote — #{error}") if error
112
+
113
+ budget = Board.budget(before)
114
+ return refuse(no_header("promote")) if budget.nil?
115
+
116
+ count = Board.count(before, "In flight")
117
+ if count + 1 > budget.cap
118
+ return refuse("okf pro promote — RULE 3: #{count} in flight against a cap of #{budget.cap}, " \
119
+ "so promoting makes #{count + 1}. Promotion requires demotion (`okf pro demote`), " \
120
+ "or a visible renegotiation of the cap — which is journal-worthy, and yours to make.")
121
+ end
122
+
123
+ move(board_path, before, row, "In flight", count + 1, "promote")
124
+ end
125
+
126
+ def demote(root, selector)
127
+ return no_board(root, "demote") unless board?(root)
128
+
129
+ escape = escaping(root, "demote", "board.md")
130
+ return escape if escape
131
+
132
+ board_path = File.join(root, "board.md")
133
+ before = Pro.read_text(board_path)
134
+ row, error = ::OKF::Pro::Board::Edit.select(Board.rows(before), selector, sections: [ "In flight" ])
135
+ return refuse("okf pro demote — #{error}") if error
136
+
137
+ return refuse(no_header("demote")) if Board.budget(before).nil?
138
+
139
+ move(board_path, before, row, "Backlog", Board.count(before, "In flight") - 1, "demote")
140
+ end
141
+
142
+ # One line between two sections, and the budget face kept truthful in the
143
+ # same write. The face is not decoration: `cap-check` refuses a header
144
+ # that disagrees with the section under it, so a move that left it stale
145
+ # would hand the next edit somebody else's refusal.
146
+ def move(board_path, before, row, to_section, declared, verb)
147
+ moved, error = ::OKF::Pro::Board::Edit.move_line(before, row.text, to_section)
148
+ return refuse("okf pro #{verb} — #{error}") if error
149
+
150
+ after, old_header, new_header = ::OKF::Pro::Board::Edit.set_declared(moved, declared)
151
+ return refuse(no_header(verb)) if after.nil?
152
+
153
+ commit(board_path, before, after, verb,
154
+ moved: [ row.text ],
155
+ added: [ new_header ].compact,
156
+ removed: [ old_header ].compact) do
157
+ [ "okf pro #{verb} — moved from #{row.section} to #{to_section}:",
158
+ " #{row.text}",
159
+ new_header ? " header now reads #{new_header.strip}" : nil,
160
+ *dormancy_note(to_section) ].compact
161
+ end
162
+ end
163
+
164
+ def no_header(verb)
165
+ "okf pro #{verb} — board.md has lost its 'In flight: k/CAP' header, which is Rule 3's " \
166
+ "visible budget and this verb's only source for the cap. Restore it first; guessing a " \
167
+ "cap is how a budget stops meaning anything."
168
+ end
169
+
170
+ # The judgment half, said out loud rather than done. A promoted demand
171
+ # owes a next action and a journal line; neither is mechanical, and a
172
+ # verb that wrote either would be writing prose.
173
+ def dormancy_note(to_section)
174
+ return [] unless to_section == "In flight"
175
+
176
+ [ " Owed, and yours: a next-action line and a journal entry linking it — a promotion nobody",
177
+ " journaled is a slot the dormancy question cannot see moving." ]
178
+ end
179
+
180
+ # ── journal open ─────────────────────────────────────────────────────
181
+
182
+ # The seeded index's own sentence. Removed only when it is still exactly
183
+ # that — an adopter who reworded it owns their words, and a verb that
184
+ # pattern-matched near-misses would be editing prose it did not write.
185
+ EMPTY_JOURNAL_NOTE = "Nothing recorded yet. The first entry is the first day this bundle is used,\n" \
186
+ "dated by you, not by the template.\n"
187
+
188
+ # `no_board`'s policy, for the other seeded file a verb edits. An index
189
+ # rebuilt from the one line this verb knows how to write comes back
190
+ # holding only that line — the `# Journal` heading and the seeded prose
191
+ # gone — which is regeneration wearing an append's clothes, and the shape
192
+ # constraint 8 forbids outright.
193
+ def no_index(root)
194
+ refuse("okf pro journal open — #{root}/journal/index.md does not exist, and this verb " \
195
+ "adds a line to that index rather than writing one: an index rebuilt from the " \
196
+ "single line it knows would lose the heading and the prose around it. Restore it " \
197
+ "first — a `# Journal` heading is all this verb needs — because every day already " \
198
+ "written is an orphan until it is back, which is what `okf pro audit` reports.")
199
+ end
200
+
201
+ def journal_open(root, today: Date.today)
202
+ day = today.to_s
203
+ entry_path = File.join(root, "journal", "#{day}.md")
204
+ index_path = File.join(root, "journal", "index.md")
205
+ return refuse("okf pro journal open — #{root}/journal/ does not exist.") unless File.directory?(File.dirname(entry_path))
206
+ return no_index(root) unless File.file?(index_path)
207
+
208
+ escape = escaping(root, "journal open", "journal/index.md", "journal/#{day}.md")
209
+ return escape if escape
210
+
211
+ before = Pro.read_text(index_path)
212
+ indexed = before.include?("(#{day}.md)")
213
+ exists = File.exist?(entry_path)
214
+
215
+ if exists && indexed
216
+ return ok("okf pro journal open — journal/#{day}.md is already open. Write the day into it " \
217
+ "with Edit; this verb only creates the file and its index line.")
218
+ end
219
+
220
+ unless indexed
221
+ after, added, removed = journal_index(before, day)
222
+ findings = Conserve.check(before, after, added: added, removed: removed)
223
+ return refuse(conservation_refusal("journal open", index_path, findings)) unless findings.empty?
224
+ end
225
+
226
+ # A file that did not exist has nothing to conserve — the guard
227
+ # compares a delta against a prior text, and there is none. The
228
+ # conservation that matters here is the INDEX's, which did exist.
229
+ #
230
+ # Two writes, and atomicity stated honestly, exactly as `Scaffold` does:
231
+ # each file is written atomically and the PAIR is not. The residue of a
232
+ # half-done pair is a day with no index line, which is why the two
233
+ # conditions above are asked SEPARATELY rather than off `File.exist?`
234
+ # alone: re-running the verb finishes the half that did not land, and
235
+ # a fixed-up state repairs itself instead of needing a maintainer.
236
+ Scaffold.write_atomically(entry_path, journal_entry(day), false) unless exists
237
+ Scaffold.write_atomically(index_path, after, false) unless indexed
238
+ ok("okf pro journal open — journal/#{day}.md #{exists ? "was already there and is now" : "created and"} indexed.",
239
+ " The day itself is yours to write: what happened, what it meant, what was decided.")
240
+ end
241
+
242
+ # The delta is declared NET, because that is the only delta `Conserve`
243
+ # can see: it compares line multisets, so a blank line removed and then
244
+ # added back is invisible to it and undeclarable. Hence no tidying pass —
245
+ # the note comes out, the entry goes on the end, and a separator is added
246
+ # only where there is not one already.
247
+ def journal_index(before, day)
248
+ lines = Pro.newline_terminated(before).lines
249
+ note = EMPTY_JOURNAL_NOTE.lines
250
+ at = sublist_index(lines, note)
251
+ removed = at ? lines.slice!(at, note.size) : []
252
+
253
+ entry = "* [#{day}](#{day}.md) - the day's record.\n"
254
+ added = []
255
+ added << "\n" unless lines.empty? || lines.last.strip.empty? || lines.last.start_with?("*")
256
+ added << entry
257
+ lines.concat(added)
258
+ [ lines.join, added, removed ]
259
+ end
260
+
261
+ def sublist_index(lines, sub)
262
+ return nil if sub.empty?
263
+
264
+ (0..(lines.size - sub.size)).find { |i| lines[i, sub.size] == sub }
265
+ end
266
+
267
+ def journal_entry(day)
268
+ <<~ENTRY
269
+ ---
270
+ type: Journal Entry
271
+ title: "#{day}"
272
+ description: What happened on #{day}, what it meant, and what was decided.
273
+ ---
274
+
275
+ # #{day}
276
+
277
+ <!-- What happened, what it meant, what was decided. Delete this line. -->
278
+ ENTRY
279
+ end
280
+
281
+ # ── close ────────────────────────────────────────────────────────────
282
+
283
+ # WHAT IT WRITES IS DECIDED BEFORE ANYTHING IS WRITTEN.
284
+ #
285
+ # Three files change, and `Scaffold.write_atomically` makes each write
286
+ # atomic and the SET not. So the ordering is the safety: every check runs
287
+ # and every new text is computed — purely, in `Board::Edit` and
288
+ # `Log::Edit`, which cannot touch the disk — and only then does anything
289
+ # land. Checking as it went left a real half-closed bundle: a board that
290
+ # had lost its budget header refused at step two with the index already
291
+ # marked, so the project read as closed while its board line survived and
292
+ # no log entry existed, and the message said nothing had happened.
293
+ #
294
+ # The residue of a crash mid-sequence is still real and still visible —
295
+ # `okf pro audit` reports the pairing failure — but a REFUSAL now writes
296
+ # nothing at all, which is what the message claims.
297
+ Plan = Struct.new(:path, :text)
298
+
299
+ def close(root, project, today: Date.today)
300
+ return no_board(root, "close") unless board?(root)
301
+
302
+ # The three spellings `Board::Edit.by_target` treats as one commitment
303
+ # are one commitment here too — `alpha`, `/projects/alpha/`, and the
304
+ # `/projects/alpha/index.md` a board line actually carries, which is
305
+ # what `okf pro board` prints. A verb that refuses the string it just
306
+ # showed you is a verb you stop trusting.
307
+ #
308
+ # Dropping the index filename is not the normalisation the check below
309
+ # forbids: that one is about a path that LEAVES `projects/`, and this
310
+ # runs before it, so `/projects/../../etc/index.md` still arrives at
311
+ # `../../etc` and is still refused.
312
+ slug = project.to_s.sub(%r{\A/?projects/}, "").chomp("/").sub(%r{/index\.md\z}, "")
313
+
314
+ # One directory name, and nothing that can leave `projects/`. This verb
315
+ # marks a file's first line and removes board lines, and `File.join`
316
+ # resolves `..` happily — `okf pro close ../../somewhere` would put a
317
+ # closure marker on a stranger's index. A project is one segment by the
318
+ # structure's own rule (`projects/<slug>/`), so anything else is a
319
+ # refusal rather than a normalisation: quietly rewriting a path the
320
+ # caller gave is how a traversal becomes an edit nobody sees.
321
+ unless slug.match?(%r{\A[^/\\]+\z}) && ![ ".", ".." ].include?(slug)
322
+ return refuse("okf pro close — '#{project}' is not a project name. A project is one " \
323
+ "directory under projects/, so its name carries no slash and is not a " \
324
+ "relative path. `okf pro state` lists the open ones.")
325
+ end
326
+
327
+ dir = File.join(root, "projects", slug)
328
+ return refuse("okf pro close — #{root}/projects/#{slug}/ does not exist. `okf pro state` lists the open projects.") unless File.directory?(dir)
329
+
330
+ index_path = File.join(dir, "index.md")
331
+ return refuse("okf pro close — projects/#{slug}/index.md does not exist, and closure is a marker on its first line.") unless File.file?(index_path)
332
+
333
+ escape = escaping(root, "close", "projects/#{slug}/index.md", "board.md", "log.md")
334
+ return escape if escape
335
+
336
+ board_path = File.join(root, "board.md")
337
+ log_path = File.join(root, "log.md")
338
+ already = Pairing.closed?(root, index_path)
339
+ lines = Board.rows(Pro.read_text(board_path)).select { |row| ::OKF::Pro::Board::Edit.by_target([ row ], slug).any? }
340
+
341
+ return ok("okf pro close — projects/#{slug} is already closed and carries no board line. Nothing to do.") if already && lines.empty?
342
+
343
+ plans = []
344
+ unless already
345
+ plan, refusal = plan_marker(index_path, slug, today)
346
+ return refusal if refusal
347
+
348
+ plans << plan
349
+ end
350
+
351
+ board_plan, refusal = plan_board(board_path, lines)
352
+ return refusal if refusal
353
+
354
+ plans << board_plan if board_plan
355
+
356
+ log_plan, refusal = plan_log(log_path, slug, lines.size, today)
357
+ return refusal if refusal
358
+
359
+ plans << log_plan
360
+ plans.each { |plan| Scaffold.write_atomically(plan.path, plan.text, false) }
361
+
362
+ ok([ "okf pro close — projects/#{slug}:",
363
+ already ? " index.md already carried the closure marker" : " index.md marked closed #{today}",
364
+ " #{lines.size} board line(s) removed",
365
+ " log.md entry added under #{today}",
366
+ *snapshot_owed(log_path, today),
367
+ "Owed, and yours: the durable part of this work belongs in learnings/ or glossary/ —",
368
+ "extract it now, or it is archived with the project and relearned later at full price." ])
369
+ end
370
+
371
+ # Said here because this verb is what creates the obligation. Writing a
372
+ # log entry gives the day a `## <date>` heading, and from that moment the
373
+ # audit — which the pre-commit door runs — asks that day for its Snapshot
374
+ # line. A verb that quietly turned the next commit into a refusal without
375
+ # naming the fix would be teaching its user that the gates are arbitrary.
376
+ def snapshot_owed(log_path, today)
377
+ return [] unless File.exist?(log_path)
378
+ return [] if Log.snapshot_line(Pro.read_text(log_path), today.to_s)
379
+
380
+ [ " #{today} now has a log heading and owes its Snapshot line — the audit the pre-commit",
381
+ " door runs asks the newest day for one. `okf pro snapshot` computes it." ]
382
+ end
383
+
384
+ # THE DECISION THE CHECKER ALREADY MAKES, MADE AGAIN AT THE WRITE DOOR.
385
+ #
386
+ # `Pairing.closed?` reads this same index through `Pro.read_contained` and
387
+ # answers "open" when the link leaves the bundle. Nothing carried that
388
+ # answer across to the writer, which read and rewrote the path with the
389
+ # uncontained pair — so `okf pro close escapee` marked a stranger's
390
+ # `index.md`, exited 0, and left `okf pro audit` still reporting the
391
+ # project as neither on the board nor closed. Run twice it appended a
392
+ # second marker, so it was unbounded as well as wrong.
393
+ #
394
+ # `.okf/contract/containment-directions.md` is the rule and it decides
395
+ # this direction too: pick the one that leaves the demand visible. A
396
+ # refusal keeps the board line and keeps the project being asked about;
397
+ # a marker written outside the bundle is a stranger's file edited to
398
+ # satisfy a checker that will not read it back.
399
+ #
400
+ # Asked of the INDEX, not of the directory holding it, because containing
401
+ # the directory does not contain the file: an `index.md` symlinked out of
402
+ # a perfectly contained `projects/<slug>/` is read by `Pro.read_text`
403
+ # (which follows the link) and written back by `File.rename` (which does
404
+ # not), so the marker came from a stranger's title and landed as a real
405
+ # file inside the bundle. Containing the leaf contains both, because a
406
+ # directory that escapes takes every path under it with it.
407
+ #
408
+ # Unrescued, deliberately. `File.file?` above has already resolved the
409
+ # whole path, so a `SystemCallError` here is the ground moving under the
410
+ # check — and `write_verb` runs every writer inside `guarded`, which
411
+ # answers exactly that with exit 2 and "nothing was read or written". A
412
+ # rescue returning false would say the same thing in a message about
413
+ # symlinks, and would be a branch no fixture can reach.
414
+ def contained?(root, path)
415
+ ::OKF::Path.under?(File.realpath(root), File.realpath(path))
416
+ end
417
+
418
+ # EVERY file a verb reads and rewrites, not just the one that was found
419
+ # first. Containing `projects/<slug>/index.md` and leaving `board.md`,
420
+ # `log.md` and `journal/index.md` open left the identical hazard three
421
+ # files over: `okf pro capture` read a stranger's board through the link,
422
+ # appended, and renamed a temp over it — so the link became a real file
423
+ # inside the bundle carrying content the bundle never owned.
424
+ #
425
+ # Checked before anything is read, and only for a file that exists: a
426
+ # missing one is each verb's own refusal to make, with its own message.
427
+ def escaping(root, verb, *rels)
428
+ rels.each do |rel|
429
+ path = File.join(root, rel)
430
+ next unless File.exist?(path)
431
+ next if contained?(root, path)
432
+
433
+ return escape_refusal(verb, root, rel)
434
+ end
435
+ nil
436
+ end
437
+
438
+ def escape_refusal(verb, root, rel)
439
+ refuse("okf pro #{verb} — #{rel} resolves outside #{root}, so this verb will not write " \
440
+ "there. `Pro.read_text` follows a symlink and the atomic rename does not, so the " \
441
+ "read would come from a file this bundle does not own and the write would replace " \
442
+ "the link with it. Every gate reads this path contained (`Pairing.closed?` answers " \
443
+ "\"open\" rather than read one), and a writer that did not would be the loosest " \
444
+ "door on the strictest file. Edit it by hand if the link is deliberate.")
445
+ end
446
+
447
+ # A markdown heading, and nothing else. The marker goes on the TITLE —
448
+ # that is the spelling the skill teaches and the only place `closed?`
449
+ # looks, since it reads three lines from the top.
450
+ #
451
+ # The check is the shape of the line, not merely whether the result
452
+ # satisfies `Pairing::MARKER`, and that distinction is the finding: an
453
+ # index carrying YAML frontmatter starts with `---`, and
454
+ # `Pairing.marker?("--- — closed 2026-08-17")` is TRUE — the regex needs
455
+ # only the word and a date. So the satisfied-marker check passed, the
456
+ # fence was destroyed, and the concept silently lost its `type`, `title`
457
+ # and `description` while `okf validate` still exited 0. `Conserve`
458
+ # cannot see it either: the mangling was the declared edit.
459
+ HEADING = /\A\#{1,6}[[:blank:]]+\S/.freeze
460
+
461
+ def plan_marker(index_path, slug, today)
462
+ before = Pro.read_text(index_path)
463
+ first = before.lines.first
464
+ return [ nil, refuse("okf pro close — projects/#{slug}/index.md is empty; there is no first line to mark.") ] if first.nil?
465
+
466
+ unless first.match?(HEADING)
467
+ return [ nil, refuse("okf pro close — projects/#{slug}/index.md does not open with a heading " \
468
+ "(#{first.strip.inspect}). Closure marks the title, and this verb will not " \
469
+ "restructure a file to find one: an index carrying YAML frontmatter opens " \
470
+ "with `---`, and a marker appended there would destroy the fence while " \
471
+ "still reading as closed. Give it a `# Title` first line, or mark it by hand.") ]
472
+ end
473
+
474
+ marked = closure_marker(first, today)
475
+ unless Pairing.marker?(marked)
476
+ return [ nil, refuse("okf pro close — marking projects/#{slug}/index.md would not produce a closure " \
477
+ "marker the checker accepts (`#{marked}`). Fix its first line first.") ]
478
+ end
479
+
480
+ # Block form: a title carrying `\1` or `\0` would otherwise be read as a
481
+ # backreference by the replacement string and splice the match back in.
482
+ after = before.sub(first) { "#{marked}\n" }
483
+ plan(index_path, before, after, "close", added: [ marked ], removed: [ first.chomp ])
484
+ end
485
+
486
+ # The one place this gem WRITES the closure marker, and it is separate so
487
+ # the grammar can be pinned in all three directions at once: what the
488
+ # skill teaches, what `Pairing::MARKER` accepts, and what this emits. Two
489
+ # of those were already pinned to each other; a writer that emitted a
490
+ # fourth spelling would leave a project the checker still reads as open,
491
+ # with a sentence about closure in it.
492
+ def closure_marker(first_line, today)
493
+ "#{first_line.to_s.chomp.rstrip} — closed #{today}"
494
+ end
495
+
496
+ def plan_board(board_path, rows)
497
+ return [ nil, nil ] if rows.empty?
498
+
499
+ before = Pro.read_text(board_path)
500
+ after = before
501
+ rows.each { |row| after = ::OKF::Pro::Board::Edit.remove_line(after, row.text)[0] }
502
+
503
+ # The face follows the section, and ONLY when the section moved. A
504
+ # header that was already lying about a count this verb did not change
505
+ # is Rule 3's finding to raise, not this verb's to quietly correct —
506
+ # a writer that tidies what it was not asked about is regenerating.
507
+ added = []
508
+ removed = rows.map(&:text)
509
+ if rows.any? { |row| row.section == "In flight" }
510
+ after, old_header, new_header = ::OKF::Pro::Board::Edit.set_declared(after, Board.count(after, "In flight"))
511
+ return [ nil, refuse(no_header("close")) ] if after.nil?
512
+
513
+ if new_header
514
+ added << new_header
515
+ removed << old_header
516
+ end
517
+ end
518
+ plan(board_path, before, after, "close", removed: removed, added: added)
519
+ end
520
+
521
+ def plan_log(log_path, slug, removed, today)
522
+ before = File.exist?(log_path) ? Pro.read_text(log_path) : ""
523
+ line = "* Closed [/projects/#{slug}/](/projects/#{slug}/index.md) — #{removed} board line(s) removed."
524
+ after, added = ::OKF::Pro::Log::Edit.add_entry(before, today, line)
525
+ plan(log_path, before, after, "close", added: added)
526
+ end
527
+
528
+ # ── the one write ────────────────────────────────────────────────────
529
+
530
+ # Guard, then write, in that order and nowhere else. Every verb above
531
+ # funnels through here, so "refuses rather than writing" is a property of
532
+ # one method rather than a habit five of them share.
533
+ def commit(path, before, after, verb, added: [], removed: [], moved: [])
534
+ planned, refusal = plan(path, before, after, verb, added: added, removed: removed, moved: moved)
535
+ return refusal if refusal
536
+
537
+ Scaffold.write_atomically(planned.path, planned.text, false)
538
+ ok(yield)
539
+ end
540
+
541
+ # The guard, without the write — for a verb that changes more than one
542
+ # file and must decide about all of them before it changes any.
543
+ # Returns `[Plan, nil]` or `[nil, Result]`.
544
+ def plan(path, before, after, verb, added: [], removed: [], moved: [])
545
+ findings = Conserve.check(before, after, added: added, removed: removed, moved: moved)
546
+ return [ nil, refuse(conservation_refusal(verb, path, findings)) ] unless findings.empty?
547
+
548
+ [ Plan.new(path, after), nil ]
549
+ end
550
+
551
+ def conservation_refusal(verb, path, findings)
552
+ [ "okf pro #{verb} — refused, and #{path} is untouched. The edit this verb computed does not " \
553
+ "match the change it declared, which is the one failure a mechanical writer must not " \
554
+ "commit: a view rewritten with a line quietly gone is failure mode 07, and this check is " \
555
+ "why this verb cannot be it.",
556
+ *findings.map { |f| " #{f}" } ]
557
+ end
558
+ end
559
+ end
560
+ end