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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OKF
6
+ module Pro
7
+ # What the verbs did not cover — recorded, never enforced.
8
+ #
9
+ # Every verb in this gem is a gate or a report. This one is neither: it
10
+ # writes down the moments an agent did by hand something a command could
11
+ # have done, so the question "which verb is missing?" is answered by
12
+ # evidence instead of by the maintainer's imagination. Two points feed it,
13
+ # and both are code paths that already run — no new hook event, because
14
+ # `settings.json` is SEEDED and a new registration would never reach an
15
+ # adopter through `upgrade`.
16
+ #
17
+ # It records INTENT, not outcome. `shell-guard` fires at PreToolUse, before
18
+ # the owner may deny, so a recorded line means "this was attempted by
19
+ # hand", which is exactly the question being asked.
20
+ #
21
+ # THE THIRD CLAUSE, APPLIED TO TELEMETRY. This is not a check: it neither
22
+ # refuses nor blocks, and a recorder that crashed a gate would be a
23
+ # measurement worth less than the thing it measures. But it does not lie
24
+ # about having counted. A write that fails leaves a marker beside the log,
25
+ # and an unwritable scratch directory is the same state — both make
26
+ # `report` answer `available == false`, and the banner says the data is
27
+ # unavailable rather than printing a zero nobody can distinguish from a
28
+ # session that used the verbs.
29
+ module Friction
30
+ # The scratch directory the seeded `.gitignore` already ignores, at the
31
+ # REPOSITORY root rather than inside the bundle: this is telemetry about
32
+ # the bundle, not knowledge in it, and `okf validate` walks every `.md`
33
+ # under the root.
34
+ SCRATCH = ".tmp"
35
+ LOG = "okf-pro-friction.log"
36
+ MARKER = "okf-pro-friction.unavailable"
37
+
38
+ # What `report` hands back. `available` is the honest half: false means
39
+ # the count is not zero, it is unknown.
40
+ # `events`, not `entries`: a Struct member of that name overrides
41
+ # `Struct#entries` — Enumerable's alias for `to_a` — and a reader that
42
+ # got the Struct's own fields back instead of the recorded lines would
43
+ # be a silently wrong count, which is the one thing this must not be.
44
+ Report = Struct.new(:events, :unreadable, :available)
45
+
46
+ module_function
47
+
48
+ # The repository the bundle sits in. `.okf/board.md` is a bundle under a
49
+ # repository; a flat root IS the repository. Nothing else distinguishes
50
+ # them, and the scratch directory belongs to the outer one either way.
51
+ def scratch_root(root)
52
+ base = File.expand_path(root.to_s)
53
+ File.basename(base) == BundleRoot::DIR ? File.dirname(base) : base
54
+ end
55
+
56
+ def log_path(root)
57
+ File.join(scratch_root(root), SCRATCH, LOG)
58
+ end
59
+
60
+ def marker_path(root)
61
+ File.join(scratch_root(root), SCRATCH, MARKER)
62
+ end
63
+
64
+ # One line appended, or a marker set. Never raises: the callers are a
65
+ # PreToolUse guard and a PostToolUse check, and an exception out of
66
+ # either is an edit sailing through while the gate lies on the floor.
67
+ def record(root, via, what, detail = nil, today: Date.today)
68
+ path = log_path(root)
69
+ FileUtils.mkdir_p(File.dirname(path))
70
+ entry = { "at" => today.to_s, "via" => via, "what" => what }
71
+ entry["detail"] = detail if detail
72
+ File.open(path, "a") { |file| file.puts JSON.generate(entry) }
73
+ true
74
+ rescue StandardError
75
+ mark_unavailable(root)
76
+ false
77
+ end
78
+
79
+ def mark_unavailable(root)
80
+ path = marker_path(root)
81
+ FileUtils.mkdir_p(File.dirname(path))
82
+ File.open(path, "a") { |file| file.puts Date.today.to_s }
83
+ nil
84
+ rescue StandardError
85
+ # Nothing left to try. `report` reaches the same verdict from the
86
+ # unwritable directory itself, which is why this may give up quietly.
87
+ nil
88
+ end
89
+
90
+ # Entries in file order, plus the two things a reader must not be told
91
+ # by omission: how many lines would not parse, and whether the recorder
92
+ # was able to run at all.
93
+ def report(root)
94
+ events = []
95
+ unreadable = 0
96
+ path = log_path(root)
97
+ if File.exist?(path)
98
+ Pro.read_text(path).each_line do |line|
99
+ next if line.strip.empty?
100
+
101
+ parsed = parse(line)
102
+ parsed ? events << parsed : unreadable += 1
103
+ end
104
+ end
105
+ Report.new(events, unreadable, available?(root))
106
+ rescue StandardError
107
+ Report.new([], 0, false)
108
+ end
109
+
110
+ def parse(line)
111
+ value = JSON.parse(line)
112
+ value.is_a?(Hash) ? value : nil
113
+ rescue JSON::ParserError
114
+ nil
115
+ end
116
+
117
+ # Writable, and never marked. An unwritable scratch directory has
118
+ # recorded nothing and cannot say so in a marker either, so it is asked
119
+ # directly rather than inferred from an empty log.
120
+ def available?(root)
121
+ return false if File.exist?(marker_path(root))
122
+
123
+ dir = File.join(scratch_root(root), SCRATCH)
124
+ return File.writable?(dir) if File.directory?(dir)
125
+ # Something is there and it is not a directory, so `mkdir_p` will raise
126
+ # every time and the marker cannot be written either — which is the
127
+ # case that proves the marker alone is not enough to answer this.
128
+ return false if File.exist?(dir)
129
+
130
+ File.writable?(scratch_root(root))
131
+ rescue StandardError
132
+ false
133
+ end
134
+
135
+ # The good path must not count as friction, or the recorder reports the
136
+ # verbs being used as evidence that they are not.
137
+ #
138
+ # Anchored to the START of the segment, because the question is "is this
139
+ # an invocation of the gem?" and not "is the gem mentioned?". Matched
140
+ # anywhere, a board line's own TEXT excused the hand-write that appended
141
+ # it — `echo "- see okf pro docs" >> .okf/board.md` was never recorded,
142
+ # and board lines routinely name these verbs. `ShellGuard.own_write?`
143
+ # does the splitting; this is asked of one segment.
144
+ #
145
+ # An assignment prefix is part of the invocation and not a command of its
146
+ # own, so it is skipped rather than breaking the anchor: `$OKF_HOME` is a
147
+ # variable this ecosystem actually uses, and `OKF_HOME=/tmp okf pro
148
+ # snapshot >> .okf/log.md` is the prescribed move with a prefix on it.
149
+ #
150
+ # So is a wrapper, and the list is short and named rather than open:
151
+ # `bundle exec okf pro snapshot >> .okf/log.md` is *this repo's own*
152
+ # invocation of the prescribed move, and an anchor that missed it counted
153
+ # a contributor's first command as friction. An open rule would be a way
154
+ # back to matching a mention.
155
+ WRAPPERS = /(?:bundle\s+exec|env|time|nice|nohup|sudo|command)\s+/.freeze
156
+ OWN_COMMAND = /\A\s*(?:[A-Za-z_]\w*=\S*\s+)*(?:#{WRAPPERS})*okf\s+pro\b/.freeze
157
+
158
+ def own_command?(command)
159
+ command.to_s.match?(OWN_COMMAND)
160
+ end
161
+
162
+ # The board, and ONLY the board.
163
+ #
164
+ # The obvious wider list — board, log, journal day — was wrong in the
165
+ # direction that destroys the measurement: it counted the PRESCRIBED path
166
+ # as friction. `snapshot` deliberately has no `--write`, so appending the
167
+ # Snapshot line to `log.md` by hand is exactly what this gem tells you to
168
+ # do; `journal open` says in as many words that the day's content is
169
+ # yours to write. Recording either inflates the banner's request and
170
+ # points the maintainer at verbs that already exist and already declined
171
+ # to do that job — which is the same mistake `own_command?` exists to
172
+ # prevent, made one layer up.
173
+ #
174
+ # An Edit to a concept body is judgment and always will be. What is left
175
+ # is `board.md`: a shape with exactly one correct form, edited by hand
176
+ # while three verbs cover it.
177
+ # The path is bundle-relative, so this is an equality rather than a
178
+ # basename test: `projects/x/board.md` is somebody's notes, not the one
179
+ # page Rule 3 counts, and the verbs do not touch it.
180
+ #
181
+ # One covered path also means the class a row records as IS the path, so
182
+ # the call site passes it straight through. The `classify` that used to
183
+ # fold three paths into three classes had two answers nothing could reach
184
+ # from this door, and a unit test calling it directly kept them green.
185
+ def covered_path?(rel)
186
+ rel.to_s == "board.md"
187
+ end
188
+
189
+ # The two answers `covered_by` can give, as constants — so a reader may be
190
+ # COMPARED against one instead of pattern-matched. `verb_covered?` below
191
+ # asks a narrower question than `covered_by` answers, and a prefix test on
192
+ # the prose would have coupled it to wording nobody would think to
193
+ # preserve.
194
+ SHELL_ANSWER = "Edit or Write — the trust guards read a tool event, and a shell redirect is none"
195
+ BOARD_ANSWER = "okf pro capture / promote / demote"
196
+
197
+ # What a recorded row maps to, where something already covers it — so the
198
+ # report says "this is a verb now" rather than making the reader guess,
199
+ # and phase 3's question keeps being answered after phase 3.
200
+ #
201
+ # Keyed on `via` as well as `what`, because the two doors mean different
202
+ # things about the same file. An Edit to the board is a verb's job. A
203
+ # SHELL write is a bypass whatever it touched — the trust guards read a
204
+ # tool event and a redirect produces none — so what covers it is Edit or
205
+ # Write, not an `okf pro` verb.
206
+ def covered_by(via, what)
207
+ return SHELL_ANSWER if via == "shell"
208
+ return BOARD_ANSWER if what == "board.md"
209
+
210
+ nil
211
+ end
212
+
213
+ # Whether an `okf pro` VERB covers it — which is not the same question as
214
+ # whether anything does. A shell redirect at the board is covered, by Edit
215
+ # or Write, and there is no verb to ask for; a banner counting it told the
216
+ # adopter a verb could have done something no verb does.
217
+ def verb_covered?(via, what)
218
+ answer = covered_by(via, what)
219
+ !answer.nil? && answer != SHELL_ANSWER
220
+ end
221
+
222
+ # Everything the log holds, cleared — the log itself and the marker.
223
+ #
224
+ # The marker is deliberately sticky: one failed write means the count is
225
+ # short by an unknown amount forever after, and a recorder that quietly
226
+ # forgave itself would be back to reporting a zero it did not count. But
227
+ # sticky with no way out is a report that nags permanently about a full
228
+ # disk from three weeks ago, so the way out is explicit, named in the
229
+ # message, and belongs to the reader.
230
+ def clear(root)
231
+ [ log_path(root), marker_path(root) ].count do |path|
232
+ next false unless File.exist?(path)
233
+
234
+ File.unlink(path)
235
+ true
236
+ end
237
+ rescue StandardError
238
+ nil
239
+ end
240
+
241
+ # The covered classes, read out of a shell COMMAND instead of a path. A
242
+ # command has no `file_path` — that is the whole reason `shell-guard`
243
+ # exists — so this reads the names it mentions, and answers "unclassified"
244
+ # when it cannot tell. That is not a failure: the report's question is which
245
+ # verb is missing, and a write it cannot attribute is exactly the row that
246
+ # says "we do not know yet".
247
+ #
248
+ # Wider than `covered_path?`, deliberately, and the two doors are asking
249
+ # different questions: the edit door asks "is this a shape a verb covers?",
250
+ # and a redirect is a bypass whatever it touched. `journal` collapses a year
251
+ # of days into one row rather than three hundred.
252
+ def classify_command(command)
253
+ text = command.to_s
254
+ return "board.md" if text.include?("board.md")
255
+ return "log.md" if text.include?("log.md")
256
+ return "journal" if text.match?(%r{journal/\d{4}-\d{2}-\d{2}\.md})
257
+
258
+ "unclassified"
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # The trust rules. Both are PreToolUse, so they deny the write rather than
6
+ # complain after it, and both scope through Target first: `verified:` and
7
+ # the journal calendar are bundle vocabulary, and a guard that fired on
8
+ # them anywhere — the README quoting the frontmatter format, a scratch
9
+ # file under .tmp/ — was a false block that trains people to switch the
10
+ # guard off.
11
+ module Guards
12
+ # The block-style spelling, and the fast path. It is NOT the whole
13
+ # rule: YAML frontmatter may be a flow mapping — `{type: Briefing,
14
+ # verified: 2026-08-01}` — which the okf reader parses as a real
15
+ # attestation and this pattern never sees. A gate that reads a
16
+ # narrower grammar than the parser it defends is a gate with a door
17
+ # cut into it, so the flow spelling gets its own pattern below.
18
+ # Patterns rather than a YAML parse, because the added text of an
19
+ # Edit is a FRAGMENT — it need not be valid YAML, or even contain
20
+ # the fences — and a parser that failed on a fragment would decline
21
+ # to see the very attestation it was called to catch.
22
+ ATTESTATION = /^[[:blank:]]*verified[[:blank:]]*:/.freeze
23
+
24
+ # `verified` as a key ANYWHERE in a flow mapping — after `{`, after a
25
+ # comma, at any nesting. Matched against the added text only when it
26
+ # actually contains a flow mapping, so prose that happens to say
27
+ # "verified:" in a sentence does not trip it.
28
+ FLOW_ATTESTATION = /[{,][[:blank:]]*['"]?verified['"]?[[:blank:]]*:/.freeze
29
+
30
+ JOURNAL_ENTRY = %r{\Ajournal/(\d{4}-\d{2}-\d{2})\.md\z}.freeze
31
+
32
+ module_function
33
+
34
+ # Every spelling of the attestation the okf reader would accept. The
35
+ # test is deliberately generous: this gate ASKS, so a false positive
36
+ # costs one prompt and a miss costs a forged signature.
37
+ def attests?(text)
38
+ text = text.to_s
39
+ ATTESTATION.match?(text) || FLOW_ATTESTATION.match?(text)
40
+ end
41
+
42
+ # `verified:` is the owner's signature, and an agent that can type it
43
+ # unchecked can forge it. Everything downstream — the To-read line, the
44
+ # briefing's standing, the audit — rests on that block meaning a person
45
+ # actually read the source. This is the most load-bearing gate in the repo.
46
+ #
47
+ # It asks rather than denies, because the invariant was never who holds
48
+ # the pen — it is who decided. The owner reviews with the agent and the
49
+ # agent promotes the change; this gate turns that write into an explicit
50
+ # owner approval, and the approval IS the attestation. Where nobody can
51
+ # approve — an unattended session — "ask" has no approver and the write
52
+ # is refused, which is the fail-closed floor the old outright denial had.
53
+ def guard_verified(event)
54
+ return [] if Target.for(event).nil?
55
+ return [] unless attests?(event.added_text)
56
+
57
+ { "ask" => "This edit writes 'verified:' — owner attestation. The agent is the scribe; " \
58
+ "your approval is the attestation. Approve ONLY if you have actually reviewed " \
59
+ "this content yourself. If you have not, deny — unverified is the truth." }
60
+ end
61
+
62
+ # A past day is a record. Editing it does not correct history, it destroys
63
+ # the one artefact that says what was known at the time — that stays a
64
+ # flat refusal. Creating a missing past day is different, and it is the
65
+ # owner's call, not the agent's: Rule 2's fallback is reconstructing
66
+ # yesterday's missing entry, declared as reconstructed — but "the file
67
+ # does not exist" alone would let an agent fabricate any date in the
68
+ # past as quietly as filing a note, and the same guard would then defend
69
+ # the forgery as history. So creation asks, exactly like `verified:`:
70
+ # interactive, the owner approves the reconstruction; unattended, no
71
+ # approver, fail closed.
72
+ def journal_guard(event, today: Date.today)
73
+ target = Target.for(event)
74
+ return [] if target.nil?
75
+
76
+ match = target.rel.match(JOURNAL_ENTRY)
77
+ return [] unless match
78
+ return [] unless match[1] < today.to_s
79
+
80
+ unless target.exist?(target.rel)
81
+ return { "ask" => "This creates journal/#{match[1]}.md — a PAST day that has no entry. " \
82
+ "Approve ONLY if this is a declared reconstruction of a day you " \
83
+ "actually worked (Rule 2's fallback). A fabricated record is worse " \
84
+ "than a gap: this guard will defend whatever you approve as history." }
85
+ end
86
+
87
+ [ "BLOCKED — journal/#{match[1]}.md is a past day. Records are append-only; " \
88
+ "corrections go in today's entry, pointing back." ]
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ module Log
6
+ # log.md's one text transform — a dated line, appended under its day.
7
+ #
8
+ # The counterpart of Board::Edit, and separate from it for the reason
9
+ # `Log` is separate from `Board`: the log's newest-first convention is a
10
+ # rule of this file alone, and it is the rule every earlier defect here
11
+ # turned on — the audit interrogating the oldest day, the banner printing
12
+ # day one's counters forever. A new day's heading therefore goes at the
13
+ # TOP of the day sequence, not at the end of the file.
14
+ #
15
+ # Returns `[text, added]`, where `added` is the exact list of lines
16
+ # spliced in — the caller's claim to `Conserve`, blank lines included,
17
+ # because a blank line is a line and a guard that ignored them could be
18
+ # walked past with one.
19
+ module Edit
20
+ module_function
21
+
22
+ def add_entry(text, day, line)
23
+ lines = Pro.newline_terminated(text).lines
24
+ heading = "## #{day}"
25
+ at = lines.index { |l| l.start_with?("## ") && l.chomp.rstrip == heading }
26
+ at ? into_existing_day(lines, at, line) : as_a_new_day(lines, heading, line)
27
+ end
28
+
29
+ # After the day's last non-blank line, so the entry joins the day
30
+ # rather than floating below it — and before whatever blank line
31
+ # separates this day from the next.
32
+ def into_existing_day(lines, at, line)
33
+ index = at + 1
34
+ last = at + 1
35
+ while index < lines.size
36
+ break if lines[index].start_with?("## ")
37
+
38
+ last = index + 1 unless lines[index].strip.empty?
39
+ index += 1
40
+ end
41
+ added = [ "#{line}\n" ]
42
+ lines.insert(last, *added)
43
+ [ lines.join, added ]
44
+ end
45
+
46
+ # Before the first existing day, which in a newest-first log is where
47
+ # a newer day belongs. With no day at all yet, at the end — a brand-new
48
+ # bundle ships an undated log whose whole body is its own preamble.
49
+ def as_a_new_day(lines, heading, line)
50
+ first = lines.index { |l| l.start_with?("## ") }
51
+ if first
52
+ added = [ "#{heading}\n", "\n", "#{line}\n", "\n" ]
53
+ lines.insert(first, *added)
54
+ else
55
+ # The separator counts. It is a line the file did not have, and a
56
+ # guard that let an unstated blank through is a guard an edit can
57
+ # be walked past one blank at a time.
58
+ added = []
59
+ added << "\n" unless lines.empty? || lines.last.strip.empty?
60
+ added.push("#{heading}\n", "\n", "#{line}\n")
61
+ lines.concat(added)
62
+ end
63
+ [ lines.join, added ]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # log.md as data. Pure, for the same reason Board is.
6
+ module Log
7
+ module_function
8
+
9
+ # The shape of the line Rule 2 appends: a list bullet, then the word,
10
+ # then its colon — bolding optional, spacing free. Substring matching
11
+ # ("any line containing Snapshot") was a hole in both directions: a
12
+ # prose bullet like "- Rewrote the Snapshot checker" under today's
13
+ # heading posed as the day's snapshot, so the stop gate skipped its
14
+ # accurate missing-line refusal and reported twelve bogus
15
+ # disagreements instead — and the audit passed CI on a mention with
16
+ # no real snapshot anywhere.
17
+ SNAPSHOT_LINE = /\A[[:blank:]]*[*+-][[:blank:]]+\*{0,2}Snapshot\*{0,2}[[:blank:]]*:/.freeze
18
+
19
+ # Under *that* day's heading and no further. The shell version set its flag
20
+ # at the day heading and never cleared it, so yesterday's snapshot
21
+ # satisfied today — which is exactly the check being asked for.
22
+ def snapshot_under?(text, day)
23
+ !snapshot_line(text, day).nil?
24
+ end
25
+
26
+ # The one walk over the log's day blocks — every public reader below
27
+ # consumes it, so the heading rule, the calendar rule, and the
28
+ # last-line-wins rule cannot drift apart again (they did, twice: a
29
+ # hand-rolled heading compare beside DAY_HEADING, then a calendar
30
+ # check present in one loop and absent from the other). Yields
31
+ # [day, line] per block in file order: day is the heading's captured,
32
+ # calendar-valid date; line is the LAST snapshot line within that
33
+ # block — a corrected line is appended rather than history rewritten,
34
+ # and the last word is the current one — or nil when the block has
35
+ # none.
36
+ def each_day_block(text)
37
+ day = nil
38
+ line = nil
39
+ text.to_s.each_line do |l|
40
+ if l.start_with?("## ")
41
+ yield [ day, line ] if day
42
+ day = accepted_day(l)
43
+ line = nil
44
+ next
45
+ end
46
+ line = l if day && l.match?(SNAPSHOT_LINE)
47
+ end
48
+ yield [ day, line ] if day
49
+ end
50
+
51
+ # The heading acceptance rule, ONCE: the captured date when the line
52
+ # is a calendar-valid day heading, else nil. Both walks — the block
53
+ # iterator above and the audit's partition below — call this, so a
54
+ # tolerance change cannot land in one and leave the other silently
55
+ # disagreeing about which days exist.
56
+ def accepted_day(line)
57
+ match = line.match(DAY_HEADING)
58
+ match && Board.parse_date(match[1]) ? match[1] : nil
59
+ end
60
+
61
+ # The day's Snapshot line. The same date can head two blocks — two
62
+ # sessions in one day, or a merge — and every block counts, but the
63
+ # file runs newest-first, so the FIRST block holding a snapshot wins:
64
+ # taking the last block in file order handed the stale bottom copy to
65
+ # the very gate that verifies counters against the board.
66
+ def snapshot_line(text, day)
67
+ each_day_block(text) do |d, line|
68
+ return line if line && d == day.to_s
69
+ end
70
+ nil
71
+ end
72
+
73
+ # The full shape, anchored at both ends: unanchored, `## 2026-08-123`
74
+ # captured a valid-looking date out of a typo and adopted the block,
75
+ # while `## 2026-8-12` (missing zero-pad) matched nothing and fell
76
+ # out of BOTH buckets — neither checked nor reported.
77
+ DAY_HEADING = /^## (\d{4}-\d{2}-\d{2})[[:blank:]\r]*$/.freeze
78
+
79
+ # What counts as an ATTEMPT at a day heading: `## ` then a digit.
80
+ # "## Notes" is a legal non-day heading; "## 2026-8-12" is a day
81
+ # heading gone wrong, and the difference is what keeps malformed_days
82
+ # from flagging prose while still catching every typo class.
83
+ DAYISH_HEADING = /^## [[:blank:]]*\d/.freeze
84
+
85
+ # One scan, one partition: every heading that attempts a date lands in
86
+ # exactly one bucket, so a rule change cannot open a gap where a
87
+ # heading is neither checked nor reported. days() is the calendar's
88
+ # accepted list — a typo'd `## 2026-08-32` string-sorts above every
89
+ # real date, and unvalidated it became the "newest day" the audit
90
+ # interrogated while the genuine newest day carried none.
91
+ def day_partition(text)
92
+ good = []
93
+ bad = []
94
+ text.to_s.each_line do |line|
95
+ next unless line.match?(DAYISH_HEADING)
96
+
97
+ day = accepted_day(line)
98
+ if day
99
+ good << day
100
+ else
101
+ bad << line.chomp.rstrip.sub(/\A## /, "")
102
+ end
103
+ end
104
+ [ good, bad ]
105
+ end
106
+
107
+ def days(text)
108
+ day_partition(text)[0]
109
+ end
110
+
111
+ # The headings days() refused, for the audit to say out loud — a
112
+ # heading no check will ever look under must not rot in silence.
113
+ def malformed_days(text)
114
+ day_partition(text)[1]
115
+ end
116
+
117
+ # The maximum date, not the first heading. The file's own convention is
118
+ # newest-first, but prose is the only thing that ever said so — an entry
119
+ # appended at the bottom made the audit interrogate the *oldest* day,
120
+ # which had its snapshot, and CI passed while the real newest day had
121
+ # none. What CI asks about is the newest day rather than today, because
122
+ # a push can land on a day nobody worked and a gate that fails on the
123
+ # calendar is a gate people learn to ignore.
124
+ def newest_day(text)
125
+ days(text).max
126
+ end
127
+
128
+ # The most recent snapshot in the file, by date, WITH the day it was found
129
+ # under. The session banner and `okf pro state` both label the parsed
130
+ # counters "as of" that day rather than as live, and a label re-derived
131
+ # by a second scan would be a second chance to disagree with the line it
132
+ # labels. (A line-only sibling stood here until `state` became the one
133
+ # source both surfaces read; it was a two-line delegation to this, and
134
+ # only a unit test still called it.)
135
+ #
136
+ # By DATE, not by position. Grepping the whole file and taking the last
137
+ # hit read the file's *bottom*, which in a newest-first log is the oldest
138
+ # entry: from day two onward the banner reported the first-ever counters,
139
+ # inverting the delta it exists to show.
140
+ #
141
+ # One pass, O(filesize), because this runs at every session start on a
142
+ # file that only grows: the composed version cost a full rescan per
143
+ # candidate day, which was seconds on a years-old log exactly when
144
+ # discipline had lapsed and the banner mattered most. Two scalars, not
145
+ # per-day hashes — the strict greater-than keeps the first block of a
146
+ # repeated day, the newest-first winner, for free, and there is no second
147
+ # copy of the walk left to drift.
148
+ def latest_snapshot_entry(text)
149
+ best_day = nil
150
+ best_line = nil
151
+ each_day_block(text) do |day, line|
152
+ next unless line
153
+ next unless best_day.nil? || day > best_day
154
+
155
+ best_day = day
156
+ best_line = line
157
+ end
158
+ [ best_day, best_line ]
159
+ end
160
+ end
161
+ end
162
+ end