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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # The CI door: the same invariants the hooks enforce, minus the ones that
6
+ # need a tool event.
7
+ #
8
+ # It exists because hooks fire only at the agent's tool boundary — an edit
9
+ # made in a text editor reaches the repo unchecked. The snapshot check asks
10
+ # whether the *newest* day in the log carries one, not today's: CI runs on a
11
+ # push, which can land on a day nobody worked, and a gate that fails on the
12
+ # calendar teaches people to ignore it.
13
+ module Audit
14
+ module_function
15
+
16
+ # Takes a repository root or a bundle root; both resolve. CI runs it as
17
+ # `okf pro audit .` from the top of the checkout, which is neither
18
+ # more nor less correct than naming `.okf` — the caller should not have
19
+ # to know the layout to ask whether the bundle is sound.
20
+ def call(start)
21
+ root = BundleRoot.resolve(start)
22
+ if root.nil?
23
+ return [ "#{File.expand_path(start.to_s)} holds no OKF bundle — no index.md, and none in .okf/." ]
24
+ end
25
+
26
+ bundle = ::OKF::Bundle::Reader.read(root)
27
+ findings = ambiguous_layout(start, root).map { |m| " layout #{m}" } +
28
+ structure(root).map { |m| " structure #{m}" } +
29
+ conformance(bundle) + curation(bundle) + snapshot(root)
30
+ # Budget and pairing read the board; with the board gone the structure
31
+ # finding is the whole story, and a crash would bury it. The board is
32
+ # read once and the bundle parsed once, here — Rule 3 goes through
33
+ # Budget.check_text (the same code the cap-check hook runs, so the
34
+ # doors cannot drift apart), and pairing reuses both reads.
35
+ board_path = File.join(root, "board.md")
36
+ if File.exist?(board_path)
37
+ board = Pro.read_text(board_path)
38
+ findings += Budget.check_text(board).map { |m| " budget #{m}" }
39
+ findings += Board.grammar(board).map { |m| " board #{m}" }
40
+ findings += pairing(root, board: board, concepts: bundle.concepts)
41
+ end
42
+ findings
43
+ end
44
+
45
+ # Two bundles in one directory — a flat root that also parents an
46
+ # `.okf` root. The doors disagree there by construction: the trust
47
+ # guards govern a file outside `.okf` by the flat root (a root that
48
+ # does not contain the file is no root of it), while this audit and
49
+ # the stop gate govern `.okf`. Neither choice is wrong; the LAYOUT
50
+ # is, and a disagreement nobody is told about is the silent failure
51
+ # the contract forbids. Mid-migration is the honest case, and the
52
+ # answer is the same: finish the move.
53
+ def ambiguous_layout(start, root)
54
+ base = File.expand_path(start.to_s)
55
+ return [] unless root == File.join(base, BundleRoot::DIR)
56
+ return [] if BundleRoot.root_kind(base).nil?
57
+
58
+ [ "#{base} is a bundle AND holds a bundle at #{BundleRoot::DIR}/ — two roots, one " \
59
+ "directory. Files outside #{BundleRoot::DIR}/ are guarded against the outer one and " \
60
+ "audited against the inner: the doors disagree until one of the two is retired." ]
61
+ end
62
+
63
+ # The closed core. `okf validate` knows nothing of these — they are the
64
+ # method's skeleton, not the format's — and a bundle missing one is not a
65
+ # leaner bundle, it is a broken one: without them the gates disengage in
66
+ # silence, which is the one failure the contract forbids. roadmap.md is
67
+ # deliberately absent from this list — it is the one file the design lets
68
+ # an adopter delete, by its own standing accusation.
69
+ CORE = [
70
+ [ "board.md", "the one page of state" ],
71
+ [ "log.md", "delta memory, where the snapshot lands" ],
72
+ [ "journal/", "the backward record" ],
73
+ [ "areas/corpus.md", "the standard this corpus is kept at" ]
74
+ ].freeze
75
+
76
+ def structure(root)
77
+ CORE.map do |rel, why|
78
+ path = File.join(root, rel)
79
+ present = rel.end_with?("/") ? File.directory?(path) : File.file?(path)
80
+ next if present
81
+
82
+ "#{rel} is missing — #{why}. The core is closed; only roadmap.md is deletable."
83
+ end.compact
84
+ end
85
+
86
+ # Errors AND warnings. Returning early on `valid?` dropped every soft
87
+ # finding the validator made — and `Conformance`, the hook door, reports
88
+ # them (`warn_lines`) for a reason it states at length: a scalar
89
+ # `verified: human:rod` is conformant, so the attestation guard asks, the
90
+ # owner approves, and the reader then drops the malformed value while the
91
+ # trust tier stays `unverified` forever. Caught at the agent's tool
92
+ # boundary and waved through here, which is the door an edit made in an
93
+ # editor takes, and the door `pre-commit` runs.
94
+ #
95
+ # A warning is a finding at this door, the same as a linter warning
96
+ # already is: `curation` below reads `.warnings` and this verb exits 1 on
97
+ # them. The two channels answering differently about the same bundle was
98
+ # the inconsistency, not the severity.
99
+ def conformance(bundle)
100
+ result = ::OKF::Bundle::Validator.call(bundle)
101
+ result.errors.map { |e| " validate #{e[:path]}: #{e[:message]}" } +
102
+ result.warnings.map { |w| " validate #{w[:path]}: #{w[:message]} (#{w[:check]})" }
103
+ end
104
+
105
+ # The linter, run the way `Conformance` runs it, and for the same reason
106
+ # stated there at length: a bare `Linter.call(bundle)` cannot run
107
+ # `expired` (no clock) or `stale` (no cutoff), and reports `healthy?`
108
+ # anyway. Measured on this door before this line existed:
109
+ # `skipped_checks: [:expired, :stale]`, and `okf pro audit — clean.` The
110
+ # hook door supplied the clock and excluded `stale` in source; this one
111
+ # did neither, so one clause was kept at one door and broken at the other.
112
+ #
113
+ # Separate from `curation` so the residue can be asserted directly rather
114
+ # than inferred from the findings — the check that this check ran.
115
+ def linted(bundle)
116
+ ::OKF::Bundle::Linter.call(bundle, today: Date.today, except: [ :stale ])
117
+ end
118
+
119
+ def curation(bundle)
120
+ report = linted(bundle)
121
+ report.warnings.map { |w| " lint #{w[:path]}: #{w[:message]} (#{w[:check]})" } +
122
+ confession(report)
123
+ end
124
+
125
+ # The guard on the guard. `stale` is excluded in source with its reason,
126
+ # so this is empty in normal operation — and stays a live guard for the
127
+ # day okf adds a third clock-gated check, which would otherwise go
128
+ # unchecked here in silence.
129
+ def confession(report)
130
+ skipped = Array(report.stats[:skipped_checks])
131
+ return [] if skipped.empty?
132
+
133
+ [ " lint okf lint could not run #{skipped.join(", ")} on this bundle, so those are " \
134
+ "unchecked rather than clean." ]
135
+ end
136
+
137
+ def snapshot(root)
138
+ path = File.join(root, "log.md")
139
+ return [] unless File.exist?(path)
140
+
141
+ text = Pro.read_text(path)
142
+ # A heading the calendar rejects is a day no check will ever look
143
+ # under — said here, because days() skipping it in silence is how a
144
+ # one-digit typo would orphan an entry forever.
145
+ findings = Log.malformed_days(text).map do |day|
146
+ " snapshot log.md heading '## #{day}' is not a calendar date — nothing will ever check under it."
147
+ end
148
+ newest = Log.newest_day(text)
149
+ unless newest.nil? || Log.snapshot_under?(text, newest)
150
+ findings << " snapshot log.md's newest day (#{newest}) carries no Snapshot line."
151
+ end
152
+ findings
153
+ end
154
+
155
+ def pairing(root, board:, concepts:)
156
+ Pairing.failures(root, board: board, concepts: concepts)
157
+ .map { |m| " pairing #{m.sub(/\A— pairing:/, "")}" }
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ module Board
6
+ # The board's text transforms — text in, text out, and nothing else.
7
+ #
8
+ # Every write verb's whole computation lives here, so that the shell
9
+ # around it is a read, a conservation check and a rename. That split is
10
+ # what makes `Conserve` worth having: a transform that also wrote its own
11
+ # result could satisfy its own guard, and a writer that satisfies its own
12
+ # gate proves nothing.
13
+ #
14
+ # The transforms return `[text, delta]`, where delta is the claim the
15
+ # caller hands to `Conserve` — never a promise this module keeps by
16
+ # being careful.
17
+ module Edit
18
+ # The budget face, as one line, with the declared count isolated. The
19
+ # anchoring is `Board.budget`'s, deliberately: a header this cannot
20
+ # rewrite is a header that one cannot read, and the two disagreeing is
21
+ # how a promotion leaves the face lying about the section under it.
22
+ BUDGET_LINE = %r{^(\*{0,2}[Ii]n [Ff]light:\*{0,2}[^\S\n]*)(\d+)([^\S\n]*/)}.freeze
23
+
24
+ module_function
25
+
26
+ # A dated capture line, in the one shape the Inbox counter reads.
27
+ def capture_line(text, today)
28
+ "- #{today} — #{text.to_s.strip}"
29
+ end
30
+
31
+ # Appended after the section's LAST `- ` line, or immediately after the
32
+ # heading when the section is empty. Not at the end of the block: a
33
+ # section can carry prose or a stack of comments below its lines, and
34
+ # an append that landed under those would read as belonging to them.
35
+ #
36
+ # Walks the RAW text, not the visible text — a comment stripped here
37
+ # would be a comment deleted, which is the regeneration this whole
38
+ # module exists to make impossible. The two agree on what a heading and
39
+ # a board line are, because a balanced single-line comment starts with
40
+ # `<`, and so does neither.
41
+ def append_to_section(text, section, line)
42
+ lines = Pro.newline_terminated(text).lines
43
+ at = insert_point(lines, section)
44
+ return [ nil, "board has no '## #{section}' section to append to" ] if at.nil?
45
+
46
+ lines.insert(at, "#{line}\n")
47
+ [ lines.join, nil ]
48
+ end
49
+
50
+ def insert_point(lines, section)
51
+ heading = "## #{section}"
52
+ start = lines.index { |l| l.start_with?("## ") && l.chomp.rstrip == heading }
53
+ return nil if start.nil?
54
+
55
+ at = start + 1
56
+ last = at
57
+ index = at
58
+ while index < lines.size
59
+ break if lines[index].start_with?("## ")
60
+
61
+ last = index + 1 if lines[index].start_with?("- ")
62
+ index += 1
63
+ end
64
+ last
65
+ end
66
+
67
+ # The line, gone, and nothing else. Matched by exact content so a
68
+ # near-duplicate elsewhere on the board survives; every occurrence is
69
+ # removed, because a board carrying the same line twice is carrying one
70
+ # commitment twice and the caller's delta says how many.
71
+ def remove_line(text, line)
72
+ wanted = line.to_s.chomp
73
+ body = Pro.newline_terminated(text)
74
+ kept = body.lines.reject { |l| l.chomp == wanted }
75
+ [ kept.join, body.lines.size - kept.size ]
76
+ end
77
+
78
+ # Out of one section and into another, as one transform, so the
79
+ # intended delta is a MOVE and the guard can hold it to that. Doing it
80
+ # as a remove and an append would state two deltas that a dropped line
81
+ # satisfies just as well.
82
+ def move_line(text, line, to_section)
83
+ removed, count = remove_line(text, line)
84
+ return [ nil, no_such_line ] if count.zero?
85
+
86
+ append_to_section(removed, to_section, line.to_s.chomp)
87
+ end
88
+
89
+ # The one way a line `okf pro board` just listed can fail to match: the
90
+ # selectors read the board's VISIBLE text and this walks the raw text,
91
+ # so a line carrying a trailing `<!-- ... -->` is listed without its
92
+ # comment and looked up with it still there. Refusing is the right
93
+ # answer — moving the visible half would drop the comment, and dropping
94
+ # anything is what `Conserve` exists to prevent — but a message saying
95
+ # only "not on the board" sends the reader looking for the wrong thing.
96
+ def no_such_line
97
+ "that exact line is not on the board. If it carries a trailing " \
98
+ "`<!-- ... -->` comment, `okf pro board` lists it without the comment and this " \
99
+ "verb will not move it: take the comment off the line first, or move it by hand."
100
+ end
101
+
102
+ # The declared half of `In flight: k/CAP`. Returns the new text and the
103
+ # two lines the caller must declare, or nil when the board has lost its
104
+ # header — which is Rule 3's own refusal and not this module's to fix.
105
+ def set_declared(text, count)
106
+ lines = Pro.newline_terminated(text).lines
107
+ at = lines.index { |l| l.match?(BUDGET_LINE) }
108
+ return [ nil, nil, nil ] if at.nil?
109
+
110
+ old = lines[at]
111
+ # Block form: a header carrying `\\1` in its trailing prose would
112
+ # otherwise be read as a backreference by the replacement string.
113
+ fresh = old.sub(BUDGET_LINE) { "#{Regexp.last_match(1)}#{count}#{Regexp.last_match(3)}" }
114
+ return [ lines.join, nil, nil ] if fresh == old
115
+
116
+ # Spliced by INDEX. The header is found per line, and putting it back
117
+ # with `String#sub` searched the whole text for that line's characters
118
+ # — so any earlier line whose tail happened to equal the header
119
+ # matched first, and the prose was rewritten while the header stayed
120
+ # stale. `Conserve` caught the mismatch, which made the verb refuse
121
+ # while naming a line nobody had touched.
122
+ lines[at] = fresh
123
+ [ lines.join, old.chomp, fresh.chomp ]
124
+ end
125
+
126
+ # ── selectors ────────────────────────────────────────────────────────
127
+ #
128
+ # Keyed, never positional. okf-principles forbids a positional index
129
+ # for exactly the reason this file exists: agents rewrite, and "the
130
+ # third line under Backlog" names a different commitment after any edit
131
+ # anyone makes. A `/projects/<slug>` link is the board's own key; a
132
+ # substring is the fallback, and both REFUSE on ambiguity rather than
133
+ # picking — a verb that guesses which commitment you meant is a verb
134
+ # that moves the wrong one and reports success.
135
+ # Selectors that identify no single target. An empty one was refused
136
+ # from the start, because the target pass builds the prefix
137
+ # `/projects` out of it and selects every project line on the board —
138
+ # a selector that names nothing selecting everything, in a verb that
139
+ # then moves it.
140
+ #
141
+ # `/` and `/projects` are that same hazard reached by another spelling,
142
+ # and the guard missed them: both chomp to a prefix that is a proper
143
+ # ANCESTOR of every linked line, so `start_with?` turns the name into a
144
+ # wildcard. `select` only refuses on MORE than one match, so with
145
+ # exactly one linked line in range the verb moved it and reported
146
+ # success. A directory every project sits under names none of them.
147
+ NAMES_NOTHING = [ "", "/projects" ].freeze
148
+
149
+ def names_nothing?(selector)
150
+ NAMES_NOTHING.include?(selector.to_s.strip.chomp("/"))
151
+ end
152
+
153
+ def select(rows, selector, sections: nil)
154
+ return [ nil, no_match(selector, sections) ] if names_nothing?(selector)
155
+
156
+ candidates = sections ? rows.select { |r| sections.include?(r.section) } : rows
157
+ matches = by_target(candidates, selector)
158
+ matches = by_substring(candidates, selector) if matches.empty?
159
+
160
+ return [ nil, no_match(selector, sections) ] if matches.empty?
161
+ return [ nil, ambiguous(selector, matches) ] if matches.size > 1
162
+
163
+ [ matches.first, nil ]
164
+ end
165
+
166
+ # `alpha` and `/projects/alpha` and `/projects/alpha/index.md` all name
167
+ # the same commitment, because a board line links the project directory
168
+ # and a concept inside it interchangeably.
169
+ def by_target(rows, selector)
170
+ prefix = selector.to_s.start_with?("/") ? selector.to_s : "/projects/#{selector}"
171
+ prefix = prefix.chomp("/").downcase
172
+ rows.select do |row|
173
+ row.targets.any? do |target|
174
+ t = target.downcase.chomp("/")
175
+ t == prefix || t.start_with?("#{prefix}/")
176
+ end
177
+ end
178
+ end
179
+
180
+ def by_substring(rows, selector)
181
+ needle = selector.to_s.strip.downcase
182
+ return [] if needle.empty?
183
+
184
+ rows.select { |row| row.text.downcase.include?(needle) }
185
+ end
186
+
187
+ def no_match(selector, sections)
188
+ where = sections ? " under #{sections.join(" or ")}" : ""
189
+ "no board line#{where} matches '#{selector}' — neither as a /projects/ link nor as a " \
190
+ "substring of a line. `okf pro board` lists what is there."
191
+ end
192
+
193
+ def ambiguous(selector, matches)
194
+ "'#{selector}' matches #{matches.size} board lines, and picking one would move a " \
195
+ "commitment nobody named:\n#{matches.map { |r| " [#{r.section}] #{r.text}" }.join("\n")}\n" \
196
+ "Name the project link, or a substring only one line carries."
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Pro
5
+ # board.md as data. Pure text in, numbers out — no disk, no bundle, so the
6
+ # counting rules that Rule 3 enforces can be tested without a fixture.
7
+ module Board
8
+ Budget = Struct.new(:declared, :cap)
9
+
10
+ # Comments, as the BOARD defines them — one line, balanced:
11
+ # `<!-- ... -->` is stripped wherever it sits, and a longer note is a
12
+ # stack of single-line comments. Every richer model failed open in a
13
+ # review round: character-granular regions let a `<!--` in one
14
+ # capture's prose swallow real lines up to a `-->` in another's; and
15
+ # line-start regions were position-dependent, so splicing the text
16
+ # after a closer changed what a second strip saw and the counters
17
+ # read a different board than the grammar. Balanced-on-one-line is
18
+ # the shape that survives ALMOST any splice — nested fragments can
19
+ # still juxtapose into a span no single reading of the source
20
+ # contains, which is why strip_comments runs to a fixpoint and
21
+ # confesses when it needed more than one pass. The rule is narrow
22
+ # enough to be predictable, not narrow enough to be inviolable, and
23
+ # the difference is a finding rather than a comment's promise.
24
+ INLINE_COMMENT = /<!--.*?-->/.freeze
25
+
26
+ module_function
27
+
28
+ # Board text with comments removed — THE definition of "visible", and
29
+ # every reader consumes it: the counters, the budget face, the date
30
+ # checks, the stray-bullet pass, pairing's link scan. Comment-awareness
31
+ # bolted onto one pass split the module against itself: a commented
32
+ # sample line was invisible to the stray check but counted by the cap
33
+ # and flagged by the date check that read it anyway.
34
+ #
35
+ # The strip runs to a FIXPOINT, so re-stripping visible text is a
36
+ # no-op by construction rather than by hope — pathological splices
37
+ # can juxtapose fragments into a new balanced span, and a strip that
38
+ # stopped after one pass let the second reader see a different board.
39
+ # Returns [visible, unclosed?, respliced?]: any `<!--` still standing has no
40
+ # closer on its line — an intended comment gone wrong, and it is NOT
41
+ # hidden. The caller confesses it, because comment-intended text read
42
+ # as board content (a stray chase date, a sample capture) must not
43
+ # drive counters in silence.
44
+ def strip_comments(text)
45
+ visible = text.to_s
46
+ passes = 0
47
+ loop do
48
+ stripped = visible.gsub(INLINE_COMMENT, "")
49
+ break if stripped == visible
50
+
51
+ visible = stripped
52
+ passes += 1
53
+ end
54
+ # A second effective pass means the first strip spliced fragments
55
+ # into a comment no single reading of the source contains — text
56
+ # was swallowed that the writer never wrapped. Rare and contrived,
57
+ # but silent was exactly how the docstring's old "splicing cannot
58
+ # break this" claim failed, so convergence beyond one pass is a
59
+ # confession, not a curiosity.
60
+ [ visible, visible.include?("<!--"), passes > 1 ]
61
+ end
62
+
63
+ def visible(text)
64
+ strip_comments(text)[0]
65
+ end
66
+
67
+ # Lines under a `## <name>` heading, up to the next `## `. The board's
68
+ # sections are the unit Rule 3 counts, so one reader serves the cap check
69
+ # and the session banner alike.
70
+ def section_lines(text, name)
71
+ visible_section_lines(visible(text), name)
72
+ end
73
+
74
+ # The section walk over text a caller already made visible — grammar()
75
+ # strips once and shares; the public section_lines strips for everyone
76
+ # else, and the fixpoint strip makes the double path harmless.
77
+ def visible_section_lines(visible_text, name)
78
+ heading = "## #{name}"
79
+ inside = false
80
+ visible_text.each_line.with_object([]) do |line, acc|
81
+ if line.start_with?("## ")
82
+ # Exact, not a prefix — the same defect log.rb already paid
83
+ # for: start_with? folded "## In flight — parked" into
84
+ # "In flight", inflating the count and turning a correct
85
+ # budget header into a refusal.
86
+ inside = (line.chomp.rstrip == heading)
87
+ next
88
+ end
89
+ acc << line if inside && line.start_with?("- ")
90
+ end
91
+ end
92
+
93
+ def count(text, name)
94
+ section_lines(text, name).size
95
+ end
96
+
97
+ # One row per visible board line, in file order, with everything a
98
+ # reader would otherwise re-derive by hand: which section it sits under,
99
+ # the two date shapes the counters read, and its bundle links.
100
+ #
101
+ # Section is carried on the row rather than looked up per line, because
102
+ # "which section is this line in" is the question every board edit asks
103
+ # first and the one an agent answers today by counting headings in a
104
+ # `cat`. Pure, like everything else here — the age in days is the
105
+ # caller's, since it needs a calendar and this module has none.
106
+ Row = Struct.new(:section, :text, :line_date, :chase_date, :targets)
107
+
108
+ def rows(text)
109
+ section = nil
110
+ visible(text).each_line.with_object([]) do |line, acc|
111
+ if line.start_with?("## ")
112
+ section = line.chomp.rstrip.sub(/\A## /, "")
113
+ next
114
+ end
115
+ next unless section && line.start_with?("- ")
116
+
117
+ stripped = line.chomp
118
+ acc << Row.new(section, stripped, line_date(stripped), chase_date(stripped), targets(stripped))
119
+ end
120
+ end
121
+
122
+ # The `In flight: k/CAP` header, or nil when the board has lost it. Matched
123
+ # by its shape rather than by the first line containing the words, so a
124
+ # sentence mentioning "in flight" elsewhere cannot be mistaken for the
125
+ # budget face. The whole shape must sit on one line that IS the header:
126
+ # anchored at line start (a capture line's "- deck in flight: 3/4" is
127
+ # not the budget), digits and slash on that same line (`\D*` crawled
128
+ # across newlines and fabricated a cap from the next N/M below; `\s*`
129
+ # around the slash did the same one token later, reading a date on the
130
+ # following line as a cap of 2026).
131
+ def budget(text)
132
+ match = visible(text).match(%r{^\*{0,2}[Ii]n [Ff]light:\*{0,2}[^\S\n]*(\d+)[^\S\n]*/[^\S\n]*(\d+)})
133
+ return nil unless match
134
+
135
+ Budget.new(match[1].to_i, match[2].to_i)
136
+ end
137
+
138
+ # Every bundle-absolute link target on a line. Two spellings, both real:
139
+ # the markdown form `[text](/path)` project lines use, and the bare
140
+ # `[/path]` form capture and conflict lines use — five-second capture
141
+ # does not stop to type a link twice. Anything not starting with `/` is
142
+ # not a bundle link and is not this module's business.
143
+ def targets(line)
144
+ text = line.to_s
145
+ markdown = text.scan(%r{\]\((/[^)\s]+)\)}).flatten
146
+ bare = text.scan(%r{\[(/[^\]\s]+)\](?!\()}).flatten
147
+ # Fragments are for readers; the file they resolve against has none.
148
+ # Kept, `/reference/x.md#tiers` failed the existence check against an
149
+ # entry list that only ever holds `/reference/x.md`.
150
+ (markdown + bare).map { |t| t.sub(/#.*/, "") }
151
+ end
152
+
153
+ # The leading `- YYYY-MM-DD` date a capture line carries, or nil. A date
154
+ # the calendar rejects (month 13) is treated as absent rather than
155
+ # guessed at — these feed counters, and a counter must not invent.
156
+ def line_date(line)
157
+ match = line.to_s.match(/\A-\s+(\d{4}-\d{2}-\d{2})/)
158
+ return nil unless match
159
+
160
+ parse_date(match[1])
161
+ end
162
+
163
+ # The `chase YYYY-MM-DD` date on a Waiting line, or nil. The chase date
164
+ # is the only thing standing between a dependency and a month of drift,
165
+ # which is why the snapshot counts the ones already past. The word is
166
+ # boundary-anchored: without it, "purchase 2026-09-01" read as a chase
167
+ # date, satisfied the grammar check built to demand one, and reported
168
+ # an overdue chase nobody ever set.
169
+ def chase_date(line)
170
+ match = line.to_s.match(/\bchase\s+(\d{4}-\d{2}-\d{2})/)
171
+ return nil unless match
172
+
173
+ parse_date(match[1])
174
+ end
175
+
176
+ def parse_date(text)
177
+ Date.parse(text)
178
+ rescue ArgumentError
179
+ nil
180
+ end
181
+
182
+ # A board line the counters can see starts `- ` at column zero —
183
+ # section_lines reads exactly that, everywhere. Any other bullet is a
184
+ # line every counter is blind to, including the cap: `* 2026-08-18 —
185
+ # file the claim` under Deadlines counted as zero lines, and so did an
186
+ # in-flight demand behind an indented dash. The counters' own bullet
187
+ # rule, flagged instead of silently skipped.
188
+ STRAY_BULLET = /\A(?:[*+][[:blank:]]|[[:blank:]]+[-*+][[:blank:]])/.freeze
189
+
190
+ # The counters read three shapes, exactly — a leading `- YYYY-MM-DD` on
191
+ # Inbox and Deadlines lines, a literal `chase YYYY-MM-DD` on Waiting
192
+ # lines — and a dated line in any other spelling parses to nil and is
193
+ # silently compacted out of every count. Silent is the problem:
194
+ # "- Due Aug 18: filing" made "deadlines within 7d" say 0, the stop
195
+ # gate agreed with itself, and the deadline landed unclaimed. So the
196
+ # unreadable line is a finding, raised at the audit and the stop gate,
197
+ # instead of a zero nobody can distinguish from a quiet board. The
198
+ # same goes for the bullet itself: a check that only read the lines
199
+ # the counters read would have inherited their blindness, so the
200
+ # stray-bullet pass walks the raw section text instead.
201
+ def grammar(text)
202
+ visible_text, unclosed, respliced = strip_comments(text)
203
+ findings = []
204
+ if unclosed
205
+ findings << "a '<!--' has no '-->' on its own line. Nothing is hidden — only a " \
206
+ "balanced single-line comment is a comment — but finish it or remove it: " \
207
+ "comment-intended text is being read as board content. For longer notes, " \
208
+ "stack single-line comments."
209
+ end
210
+ if respliced
211
+ findings << "comment stripping only settled after a second pass — fragments spliced " \
212
+ "into a comment no single reading of the board contains, and text may " \
213
+ "have been swallowed with them. Rewrite the line without nested '<!--' " \
214
+ "fragments."
215
+ end
216
+ findings.concat(missing_sections(visible_text))
217
+ findings.concat(stray_bullets(visible_text))
218
+ findings.concat(date_findings(visible_text))
219
+ findings
220
+ end
221
+
222
+ # The six sections the counters read, exactly as the skill names them.
223
+ SECTIONS = [ "In flight", "Backlog", "Waiting", "Inbox", "To read", "Deadlines" ].freeze
224
+
225
+ # The section invariant, checked directly: each of the six canonical
226
+ # sections is present as an EXACT heading. A near-miss heuristic at
227
+ # this spot was at the wrong depth — it missed the commonest
228
+ # quiet-zero typos ('## inbox', '## In Flight', '## Inbox' all
229
+ # disengaged every counter in silence) while permanently nagging the
230
+ # legitimate sibling pattern ('## In flight — parked' beside the
231
+ # real section). The claim was never "no heading resembles a
232
+ # section"; it is "every section exists", and checking that subsumes
233
+ # every typo, decoration, and deletion class at once.
234
+ def missing_sections(visible_text)
235
+ present = visible_text.each_line.map do |line|
236
+ line.chomp.rstrip.sub(/\A## /, "") if line.start_with?("## ")
237
+ end.compact
238
+ (SECTIONS - present).map do |name|
239
+ "board has no '## #{name}' section — the counters match that heading exactly, so " \
240
+ "its class of lines has nowhere to land. Restore it; a re-spelled or decorated " \
241
+ "variant does not count."
242
+ end
243
+ end
244
+
245
+ # The three date shapes, checked over text the caller already made
246
+ # visible — grammar() strips once and every pass here reads that one
247
+ # strip, through visible_section_lines.
248
+ def date_findings(visible_text)
249
+ findings = []
250
+ visible_section_lines(visible_text, "Inbox").each do |line|
251
+ next if line_date(line)
252
+
253
+ findings << "Inbox line carries no leading '- YYYY-MM-DD' date, so the oldest-capture " \
254
+ "counter cannot see it: #{line.strip}"
255
+ end
256
+ visible_section_lines(visible_text, "Deadlines").each do |line|
257
+ next if line_date(line)
258
+
259
+ findings << "Deadlines line carries no leading '- YYYY-MM-DD' date, so the 7-day " \
260
+ "warning cannot see it: #{line.strip}"
261
+ end
262
+ visible_section_lines(visible_text, "Waiting").each do |line|
263
+ next if chase_date(line)
264
+
265
+ findings << "Waiting line carries no 'chase YYYY-MM-DD' date, so the past-chase " \
266
+ "counter cannot see it: #{line.strip}"
267
+ end
268
+ findings
269
+ end
270
+
271
+ # `past_first_heading` is exactly what it says: the pass covers the
272
+ # whole board body from the first section on — the sections' order is
273
+ # the board's own, and a stray bullet between two sections is as
274
+ # invisible to the counters as one inside them. Callers hand this the
275
+ # board's VISIBLE text; grammar() strips comments once for the whole
276
+ # pass, because a bulleted example inside documentation is not a
277
+ # board line, and flagging one turned a comment into a refusal at
278
+ # every Stop until the documentation was deleted.
279
+ def stray_bullets(text)
280
+ past_first_heading = false
281
+ text.to_s.each_line.with_object([]) do |line, acc|
282
+ past_first_heading = true if line.start_with?("## ")
283
+ next unless past_first_heading && line.match?(STRAY_BULLET)
284
+
285
+ acc << "board line does not start with '- ' at column zero, so no counter " \
286
+ "can see it: #{line.strip}"
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end