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,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# Rule 3 — in flight is a budget.
|
|
6
|
+
#
|
|
7
|
+
# Two refusals, and the second matters as much as the first. Over the cap is
|
|
8
|
+
# overload. A header that disagrees with the section under it is worse than
|
|
9
|
+
# no header: the board's whole job is to be the one page you can trust at a
|
|
10
|
+
# glance, and a budget face that lies costs more than an absent one.
|
|
11
|
+
module Budget
|
|
12
|
+
# Five working days without a journal link is the dormancy window — the
|
|
13
|
+
# budget's own question, not a verdict.
|
|
14
|
+
#
|
|
15
|
+
# It is NOT tunable, and the comment here used to say it was ("tuned in the
|
|
16
|
+
# skill's Rule 3"), which was false in the direction that costs the most:
|
|
17
|
+
# the skill states the number in prose, an adopter who changed it there
|
|
18
|
+
# would see nothing happen, and the gate would keep asking on the old
|
|
19
|
+
# window while the written rule said otherwise. Nobody has asked for a
|
|
20
|
+
# knob; what the situation needed was for the two statements to be one
|
|
21
|
+
# fact. `test/unit/dormancy_window_test.rb` pins the skill's number to this
|
|
22
|
+
# constant, so changing it here fails until the skill follows.
|
|
23
|
+
DORMANCY_DAYS = 5
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def cap_check(target)
|
|
28
|
+
return [] if target.nil?
|
|
29
|
+
return [] unless target.rel == "board.md"
|
|
30
|
+
return [] unless target.exist?("board.md")
|
|
31
|
+
|
|
32
|
+
board = target.read("board.md")
|
|
33
|
+
# Grammar rides along at the write-time door on purpose: the stray
|
|
34
|
+
# bullet and the unreadable date are exactly the lines the counters
|
|
35
|
+
# below cannot see, and a cap check that stays quiet while its own
|
|
36
|
+
# input is partly invisible has already lost the argument. Audit
|
|
37
|
+
# and stop gate run the same pass; this door ran only the counters,
|
|
38
|
+
# so the one moment the writer was still holding the pen was the
|
|
39
|
+
# one moment nothing spoke.
|
|
40
|
+
check_text(board) + Board.grammar(board)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_text(board)
|
|
44
|
+
board = Board.visible(board)
|
|
45
|
+
budget = Board.budget(board)
|
|
46
|
+
unless budget
|
|
47
|
+
return [ "board.md lost its 'In flight: k/CAP' header — Rule 3's visible budget. Restore it first." ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
count = Board.count(board, "In flight")
|
|
51
|
+
|
|
52
|
+
if count > budget.cap
|
|
53
|
+
return [ "RULE 3 — #{count} in flight against a cap of #{budget.cap}. Promotion requires demotion, " \
|
|
54
|
+
"or a visible renegotiation (new cap in the header; renegotiations are journal-worthy)." ]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if budget.declared != count
|
|
58
|
+
return [ "Header claims #{budget.declared} in flight; the section holds #{count}. " \
|
|
59
|
+
"Fix the header — a wrong budget face is worse than none." ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The dormancy questions: in-flight demands no journal entry has linked
|
|
66
|
+
# in DORMANCY_DAYS working days. Questions, never verdicts — "still in
|
|
67
|
+
# flight, or backlog pretending? Either answer is fine; holding a slot
|
|
68
|
+
# without moving is not."
|
|
69
|
+
#
|
|
70
|
+
# Two silences are deliberate. A journal younger than the window stays
|
|
71
|
+
# quiet — a bundle in its first week cannot be dormant, only new. And a
|
|
72
|
+
# demand whose promotion was journaled (promotions are journal-worthy)
|
|
73
|
+
# is linked from day one, so the fresh-promotion false positive is
|
|
74
|
+
# already covered by the discipline this question serves.
|
|
75
|
+
def dormancy_questions(root, today: Date.today, board: nil)
|
|
76
|
+
board_path = File.join(root, "board.md")
|
|
77
|
+
return [] if board.nil? && !File.exist?(board_path)
|
|
78
|
+
|
|
79
|
+
entries = journal_entries(root)
|
|
80
|
+
return [] if entries.empty?
|
|
81
|
+
|
|
82
|
+
start = window_start(today)
|
|
83
|
+
return [] if entries.map(&:first).min > start
|
|
84
|
+
|
|
85
|
+
# Contained: the root is in hand, and a journal entry symlinked in from
|
|
86
|
+
# outside the bundle is not this bundle's record of a day. One that
|
|
87
|
+
# escapes is dropped rather than raising — dormancy is a question, not a
|
|
88
|
+
# gate, and an unreadable entry means "no evidence of work", which is
|
|
89
|
+
# the answer that keeps the question being asked.
|
|
90
|
+
recent = entries.select { |day, _| day >= start }
|
|
91
|
+
.map { |_, path| safely(root, path) }
|
|
92
|
+
.join("\n").downcase
|
|
93
|
+
|
|
94
|
+
lines = Board.section_lines(board || safely(root, board_path), "In flight")
|
|
95
|
+
questions = lines.map do |line|
|
|
96
|
+
slugs = Snapshot.project_slugs(line)
|
|
97
|
+
next if slugs.empty?
|
|
98
|
+
next if slugs.any? { |slug| recent.include?("/projects/#{slug}") }
|
|
99
|
+
|
|
100
|
+
"Rule 3, dormancy — no journal entry has linked /projects/#{slugs.first}/ in " \
|
|
101
|
+
"#{DORMANCY_DAYS} working days: still in flight, or backlog pretending?"
|
|
102
|
+
end.compact
|
|
103
|
+
|
|
104
|
+
# Law 2: the check confesses its own blind spot rather than skipping it
|
|
105
|
+
# in silence — a demand with no project link is one dormancy cannot see.
|
|
106
|
+
unlinked = lines.count { |line| Snapshot.project_slugs(line).empty? }
|
|
107
|
+
questions << "Note: #{unlinked} in-flight line(s) carry no /projects/ link — dormancy cannot see them." if unlinked.positive?
|
|
108
|
+
|
|
109
|
+
questions
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def safely(root, path)
|
|
113
|
+
Pro.read_contained(root, path)
|
|
114
|
+
rescue ::OKF::Path::Error, ::Errno::ENOENT
|
|
115
|
+
""
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def journal_entries(root)
|
|
119
|
+
Dir.glob(File.join(root, "journal", "*.md")).map do |path|
|
|
120
|
+
match = File.basename(path).match(/\A(\d{4}-\d{2}-\d{2})\.md\z/)
|
|
121
|
+
next unless match
|
|
122
|
+
|
|
123
|
+
day = Board.parse_date(match[1])
|
|
124
|
+
[ day, path ] if day
|
|
125
|
+
end.compact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Walk back from today until the window holds DORMANCY_DAYS working days.
|
|
129
|
+
# Weekends extend the window rather than counting against it — legitimate
|
|
130
|
+
# stillness is what keeps the dormancy alarm honest.
|
|
131
|
+
def window_start(today, days = DORMANCY_DAYS)
|
|
132
|
+
date = today
|
|
133
|
+
counted = 0
|
|
134
|
+
loop do
|
|
135
|
+
counted += 1 if (1..5).cover?(date.wday)
|
|
136
|
+
break if counted >= days
|
|
137
|
+
|
|
138
|
+
date -= 1
|
|
139
|
+
end
|
|
140
|
+
date
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# Where the bundle is, given where the agent is.
|
|
6
|
+
#
|
|
7
|
+
# The hook event carries a working directory, which is the repository root
|
|
8
|
+
# — not the bundle. They stopped being the same thing when the knowledge
|
|
9
|
+
# moved into `.okf/` and the repository kept README, CLAUDE.md, the
|
|
10
|
+
# checker and CI at the top. Every check that reads the corpus has to make
|
|
11
|
+
# that hop, and making it in one place is the difference between one rule
|
|
12
|
+
# and six copies of it drifting apart.
|
|
13
|
+
#
|
|
14
|
+
# Both layouts resolve, nested first. An adopter who prefers the repository
|
|
15
|
+
# root to *be* the bundle — the shape this template shipped with — is not
|
|
16
|
+
# broken by the change, and neither are the fixtures.
|
|
17
|
+
module BundleRoot
|
|
18
|
+
DIR = ".okf"
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# The bundle root, or nil when `start` is not a repository holding one.
|
|
23
|
+
# Nil is a real answer: a check that cannot find a bundle has nothing to
|
|
24
|
+
# say about it, which is different from finding one and approving of it.
|
|
25
|
+
def resolve(start)
|
|
26
|
+
base = File.expand_path(start.to_s)
|
|
27
|
+
|
|
28
|
+
nested = File.join(base, DIR)
|
|
29
|
+
return nested if bundle?(nested)
|
|
30
|
+
return base if bundle?(base)
|
|
31
|
+
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The bundle that contains `path`, found by walking up from the file
|
|
36
|
+
# itself — or nil. The discriminator is level_root/root_kind below:
|
|
37
|
+
# the innermost strong or anchored root wins outright, and plain weak
|
|
38
|
+
# candidates resolve by fencing, so journal/index.md and friends do
|
|
39
|
+
# not stop the walk. This exists because scoping trust
|
|
40
|
+
# guards through the event's cwd was a hole: cwd is wherever the
|
|
41
|
+
# session happens to sit, resolve() never walks up, and a session
|
|
42
|
+
# parked in a subdirectory made the guards find no bundle and
|
|
43
|
+
# silently disarm.
|
|
44
|
+
# Fenced at the file's own repository, exactly as enclosing() is: a
|
|
45
|
+
# bundle above the session's repo is somebody else's, and a file
|
|
46
|
+
# inside a nested repository must not summon the guards of the tree
|
|
47
|
+
# above it. Outside any repository the walk runs to the filesystem
|
|
48
|
+
# root — a file physically inside a bundle's tree is governed by it,
|
|
49
|
+
# which is the walk's whole claim; the directory walk cannot say the
|
|
50
|
+
# same of a cwd, which is why enclosing() refuses to walk unfenced.
|
|
51
|
+
#
|
|
52
|
+
# ONE deliberate divergence from enclosing(): `probe_nested: false`.
|
|
53
|
+
# A root that does not contain the file is no root of it, so this
|
|
54
|
+
# walk never adopts a sibling `.okf`. In the one layout where that
|
|
55
|
+
# matters — a directory that is BOTH a flat root and the parent of
|
|
56
|
+
# an `.okf` root — this door governs a file outside `.okf` by the
|
|
57
|
+
# flat root while the stop gate and the audit govern `.okf`. That
|
|
58
|
+
# layout is ambiguous at its source (two bundles, one directory),
|
|
59
|
+
# the alternative was guards that skipped the file in silence, and
|
|
60
|
+
# Audit.ambiguous_layout reports it rather than leaving the doors to
|
|
61
|
+
# disagree quietly.
|
|
62
|
+
def containing(path)
|
|
63
|
+
return nil if path.to_s.empty?
|
|
64
|
+
|
|
65
|
+
dir = File.dirname(File.expand_path(path))
|
|
66
|
+
top = repo_root(dir)
|
|
67
|
+
weak = nil
|
|
68
|
+
until dir == File.dirname(dir)
|
|
69
|
+
kind, found = level_root(dir, probe_nested: false)
|
|
70
|
+
case kind
|
|
71
|
+
when :strong, :anchored
|
|
72
|
+
return found
|
|
73
|
+
when :weak
|
|
74
|
+
# Fenced, the outermost weak directory inside the fence is the
|
|
75
|
+
# bundle — a real root contains its directories. Unfenced there
|
|
76
|
+
# is no "outermost inside" to speak of, and climbing toward the
|
|
77
|
+
# filesystem root adopted a stranger's index+log over the
|
|
78
|
+
# bundle the file actually lives in — so the nearest weak root
|
|
79
|
+
# wins and the walk stops guessing upward.
|
|
80
|
+
return found if top.nil?
|
|
81
|
+
|
|
82
|
+
weak = found
|
|
83
|
+
end
|
|
84
|
+
break if dir == top
|
|
85
|
+
|
|
86
|
+
dir = File.dirname(dir)
|
|
87
|
+
end
|
|
88
|
+
weak
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The bundle governing a working directory, found by walking up from
|
|
92
|
+
# it. Same classifier, same tiers as containing(); the only
|
|
93
|
+
# difference is the no-repository rule — a cwd, unlike a file, is
|
|
94
|
+
# not "inside" anything in a way that justifies climbing, so with no
|
|
95
|
+
# fence the look is single-level and cannot adopt anything above the
|
|
96
|
+
# directory it was handed.
|
|
97
|
+
def enclosing(start)
|
|
98
|
+
dir = File.expand_path(start.to_s)
|
|
99
|
+
top = repo_root(dir)
|
|
100
|
+
if top.nil?
|
|
101
|
+
kind, found = level_root(dir)
|
|
102
|
+
return kind ? found : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
weak = nil
|
|
106
|
+
loop do
|
|
107
|
+
kind, found = level_root(dir)
|
|
108
|
+
case kind
|
|
109
|
+
when :strong, :anchored
|
|
110
|
+
return found
|
|
111
|
+
when :weak
|
|
112
|
+
weak = found
|
|
113
|
+
end
|
|
114
|
+
break if dir == top
|
|
115
|
+
|
|
116
|
+
dir = File.dirname(dir)
|
|
117
|
+
end
|
|
118
|
+
weak
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The first ancestor (inclusive) holding a .git entry — a directory
|
|
122
|
+
# for ordinary repositories, a file for worktrees and submodules.
|
|
123
|
+
def repo_root(dir)
|
|
124
|
+
until dir == File.dirname(dir)
|
|
125
|
+
return dir if File.exist?(File.join(dir, ".git"))
|
|
126
|
+
|
|
127
|
+
dir = File.dirname(dir)
|
|
128
|
+
end
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The strongest proof at one level, ONCE — both walks consume this,
|
|
133
|
+
# so the ordering cannot drift between them (it did, and the doors
|
|
134
|
+
# governed different bundles). `probe_nested:` is the walks' one
|
|
135
|
+
# legitimate difference: enclosing roots a CWD, and the bundle
|
|
136
|
+
# governing a session may sit beside it at any ancestor level, so
|
|
137
|
+
# each level's `.okf` is probed. containing roots a FILE, and a
|
|
138
|
+
# root that does not contain the file is no root of it — the
|
|
139
|
+
# sibling probe adopted repo/.okf for repo/reference/x.md, the rel
|
|
140
|
+
# path nil'd out, and every write-time guard skipped the edit in
|
|
141
|
+
# silence. A file actually inside `.okf` meets it as the dir itself
|
|
142
|
+
# on the way up, so containing loses nothing by not probing.
|
|
143
|
+
# Three tiers, nested .okf (when probed) before the level itself:
|
|
144
|
+
# :strong — okf_version in the index frontmatter. Innermost wins,
|
|
145
|
+
# immediately: only a root carries it.
|
|
146
|
+
# :anchored — a weak proof in a directory literally named `.okf`.
|
|
147
|
+
# The name is the convention's own marker, so an inner
|
|
148
|
+
# nested bundle beats outer coincidence — outer-weak-
|
|
149
|
+
# wins let a stray index+log at the repo root shadow a
|
|
150
|
+
# real frontmatterless .okf and silently disarm every
|
|
151
|
+
# write-time guard.
|
|
152
|
+
# :weak — index.md plus any core file (board.md or log.md) in
|
|
153
|
+
# an ordinarily named directory. Resolution differs by
|
|
154
|
+
# fencing; see the walks.
|
|
155
|
+
def level_root(dir, probe_nested: true)
|
|
156
|
+
nested = File.join(dir, DIR)
|
|
157
|
+
nested_kind = probe_nested ? root_kind(nested) : nil
|
|
158
|
+
return [ :strong, nested ] if nested_kind == :strong
|
|
159
|
+
|
|
160
|
+
dir_kind = root_kind(dir)
|
|
161
|
+
return [ :strong, dir ] if dir_kind == :strong
|
|
162
|
+
return [ :anchored, nested ] if nested_kind == :weak
|
|
163
|
+
|
|
164
|
+
case dir_kind
|
|
165
|
+
when :weak
|
|
166
|
+
File.basename(dir) == DIR ? [ :anchored, dir ] : [ :weak, dir ]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# One stat ladder per directory: :strong, :weak, or nil. The weak
|
|
171
|
+
# proof accepts ONE core file, not the whole skeleton — demanding
|
|
172
|
+
# both left a mid-bootstrap bundle (index and board down, log.md not
|
|
173
|
+
# yet written) unguarded while its first concepts landed. An
|
|
174
|
+
# index.md truly alone remains ambiguous with a directory index and
|
|
175
|
+
# is never adopted: the scaffold writes the skeleton in one move,
|
|
176
|
+
# and a walk that guessed would re-open the bogus-adoption hole.
|
|
177
|
+
def root_kind(dir)
|
|
178
|
+
return nil unless File.file?(File.join(dir, "index.md"))
|
|
179
|
+
return :strong if root_index?(dir)
|
|
180
|
+
|
|
181
|
+
return unless File.file?(File.join(dir, "board.md")) || File.file?(File.join(dir, "log.md"))
|
|
182
|
+
|
|
183
|
+
:weak
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def bundle?(dir)
|
|
187
|
+
File.file?(File.join(dir, "index.md"))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A fence exactly as the okf gem parses one — dashes at column zero,
|
|
191
|
+
# trailing blanks tolerated, nothing else. One rule, not two that
|
|
192
|
+
# drift: strip-based matching briefly accepted indented fences here,
|
|
193
|
+
# which ended the scan at a "---" inside a YAML block scalar (real
|
|
194
|
+
# root unrecognised, guards re-rooted through the cwd fallback) and
|
|
195
|
+
# recognised roots the gem itself refuses to parse. No BOM tolerance
|
|
196
|
+
# for the same reason — the gem raises on one, and a "root" whose
|
|
197
|
+
# corpus every later check crashes on is not a root.
|
|
198
|
+
FENCE = /\A---[[:blank:]]*\z/.freeze
|
|
199
|
+
|
|
200
|
+
# The root index declares `okf_version:` in its frontmatter. Matching
|
|
201
|
+
# the bare token anywhere in the file was a hole: directory indexes are
|
|
202
|
+
# free-form prose, this bundle is *about* OKF, and a journal index that
|
|
203
|
+
# mentioned okf_version in a sentence would have stopped the walk early,
|
|
204
|
+
# mis-rooted the bundle, and disarmed the journal guard through a rel
|
|
205
|
+
# path that no longer started with journal/. Only the key, only inside
|
|
206
|
+
# a COMPLETE leading frontmatter block, counts — the closing fence is
|
|
207
|
+
# required, because a mangled block that never closes must not let a
|
|
208
|
+
# column-zero mention in prose adopt the directory. The block is read
|
|
209
|
+
# once (no re-enumeration racing an agent's write between two opens)
|
|
210
|
+
# through Pro.read_text, and bounded: frontmatter whose closing fence
|
|
211
|
+
# sits past line 101 is not recognised, a stated limit rather than a
|
|
212
|
+
# silent one, and pinned by a test.
|
|
213
|
+
def root_index?(dir)
|
|
214
|
+
index = File.join(dir, "index.md")
|
|
215
|
+
return false unless File.file?(index)
|
|
216
|
+
|
|
217
|
+
# Streamed, not slurped: this runs per ancestor on every tool-use
|
|
218
|
+
# event, and read_text pulled the whole file in before the 101-line
|
|
219
|
+
# bound was applied. The per-line scrub matches read_text's
|
|
220
|
+
# defense — bytes in, forced UTF-8, invalid sequences replaced.
|
|
221
|
+
lines = []
|
|
222
|
+
File.foreach(index, mode: "rb") do |l|
|
|
223
|
+
lines << l.force_encoding(Encoding::UTF_8).scrub.chomp
|
|
224
|
+
break if lines.size >= 101
|
|
225
|
+
end
|
|
226
|
+
return false unless lines.first&.match?(FENCE)
|
|
227
|
+
|
|
228
|
+
body = lines.drop(1)
|
|
229
|
+
close = body.index { |l| l.match?(FENCE) }
|
|
230
|
+
return false if close.nil?
|
|
231
|
+
|
|
232
|
+
body.first(close).any? { |l| l.match?(/\Aokf_version[[:blank:]]*:/) }
|
|
233
|
+
rescue Errno::ENOENT
|
|
234
|
+
# The file passed File.file? and vanished before the read — an agent
|
|
235
|
+
# write or checkout mid-hook. Not a root anybody can prove; keep
|
|
236
|
+
# walking.
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|