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.
- checksums.yaml +7 -0
- data/.okf/contract/containment-directions.md +107 -0
- data/.okf/contract/exit-codes.md +42 -0
- data/.okf/contract/gates-only-at-the-hook-door.md +39 -0
- data/.okf/contract/index.md +10 -0
- data/.okf/contract/silent-skips.md +78 -0
- data/.okf/contract/telemetry-does-not-lie.md +218 -0
- data/.okf/contract/the-contract.md +43 -0
- data/.okf/design/a-comment-is-not-an-implementation.md +85 -0
- data/.okf/design/a-rule-you-can-walk-past.md +68 -0
- data/.okf/design/derivation-that-writes.md +161 -0
- data/.okf/design/failure-modes.md +43 -0
- data/.okf/design/index.md +18 -0
- data/.okf/design/lineage.md +95 -0
- data/.okf/design/structure-laws.md +46 -0
- data/.okf/design/the-residue.md +44 -0
- data/.okf/design/three-laws.md +71 -0
- data/.okf/design/three-pillars.md +48 -0
- data/.okf/index.md +31 -0
- data/.okf/log.md +74 -0
- data/.okf/scaffold/collisions-and-refusals.md +76 -0
- data/.okf/scaffold/index.md +8 -0
- data/.okf/scaffold/no-date-ships.md +41 -0
- data/.okf/scaffold/ownership-not-subject.md +63 -0
- data/.okf/scaffold/the-adopters-manual.md +114 -0
- data/.okf/seam/bundler-scoping.md +45 -0
- data/.okf/seam/identity-not-existence.md +67 -0
- data/.okf/seam/index.md +8 -0
- data/.okf/seam/the-wrapper.md +42 -0
- data/.okf/seam/three-fail-opens.md +44 -0
- data/.okf/testing/drills-over-units.md +41 -0
- data/.okf/testing/fixture-is-a-client.md +35 -0
- data/.okf/testing/index.md +4 -0
- data/.okf/trust/index.md +7 -0
- data/.okf/trust/read-owed-rule.md +45 -0
- data/.okf/trust/scalar-verified.md +48 -0
- data/CHANGELOG.md +187 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +383 -0
- data/lib/okf/plugin.rb +148 -0
- data/lib/okf/pro/attestation.rb +62 -0
- data/lib/okf/pro/audit.rb +161 -0
- data/lib/okf/pro/board/edit.rb +201 -0
- data/lib/okf/pro/board.rb +291 -0
- data/lib/okf/pro/budget.rb +144 -0
- data/lib/okf/pro/bundle_root.rb +241 -0
- data/lib/okf/pro/cli.rb +914 -0
- data/lib/okf/pro/closing.rb +206 -0
- data/lib/okf/pro/conformance.rb +127 -0
- data/lib/okf/pro/conserve.rb +94 -0
- data/lib/okf/pro/event.rb +95 -0
- data/lib/okf/pro/friction.rb +262 -0
- data/lib/okf/pro/guards.rb +92 -0
- data/lib/okf/pro/log/edit.rb +68 -0
- data/lib/okf/pro/log.rb +162 -0
- data/lib/okf/pro/pairing.rb +271 -0
- data/lib/okf/pro/reconcile.rb +76 -0
- data/lib/okf/pro/records.rb +90 -0
- data/lib/okf/pro/scaffold.rb +258 -0
- data/lib/okf/pro/shell_guard.rb +164 -0
- data/lib/okf/pro/snapshot.rb +156 -0
- data/lib/okf/pro/state.rb +174 -0
- data/lib/okf/pro/target.rb +91 -0
- data/lib/okf/pro/template/gem/.claude/hooks/run +119 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/SKILL.md +233 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/attribution.md +46 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/board.md +55 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/closing.md +21 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/frontmatter.md +73 -0
- data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/okf-rules.md +25 -0
- data/lib/okf/pro/template/gem/.githooks/pre-commit +68 -0
- data/lib/okf/pro/template/gem/.github/workflows/okf-pro.yml +69 -0
- data/lib/okf/pro/template/seed/.claude/settings.json +24 -0
- data/lib/okf/pro/template/seed/.okf/areas/corpus.md +73 -0
- data/lib/okf/pro/template/seed/.okf/areas/index.md +6 -0
- data/lib/okf/pro/template/seed/.okf/board.md +21 -0
- data/lib/okf/pro/template/seed/.okf/glossary/index.md +13 -0
- data/lib/okf/pro/template/seed/.okf/index.md +30 -0
- data/lib/okf/pro/template/seed/.okf/journal/index.md +11 -0
- data/lib/okf/pro/template/seed/.okf/learnings/index.md +13 -0
- data/lib/okf/pro/template/seed/.okf/log.md +10 -0
- data/lib/okf/pro/template/seed/.okf/projects/index.md +23 -0
- data/lib/okf/pro/template/seed/.okf/reference/index.md +16 -0
- data/lib/okf/pro/template/seed/.okf/roadmap.md +33 -0
- data/lib/okf/pro/template/seed/.tmp/.gitkeep +0 -0
- data/lib/okf/pro/template/seed/CLAUDE.md +61 -0
- data/lib/okf/pro/template/seed/README.md +488 -0
- data/lib/okf/pro/template/seed/gitignore +17 -0
- data/lib/okf/pro/version.rb +7 -0
- data/lib/okf/pro/writes.rb +560 -0
- data/lib/okf/pro.rb +198 -0
- 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
|