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,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The board↔work invariants, in both directions: everything open is on the
|
|
6
|
+
# board, everything the board links is still real, and everything owed is
|
|
7
|
+
# still owed. These are what an editor-made commit bypasses entirely, which
|
|
8
|
+
# is why they are also the CI door's payload.
|
|
9
|
+
#
|
|
10
|
+
# Pairing is by link target, never by substring. The first cut asked
|
|
11
|
+
# `board.include?(slug)`, which a project name mentioned in any unrelated
|
|
12
|
+
# capture line satisfied — a false pass, in the fail-open direction this
|
|
13
|
+
# checker exists to close.
|
|
14
|
+
module Pairing
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# `board:` and `concepts:` let a caller that already paid for the read
|
|
18
|
+
# hand it over — the audit reads the board and parses the bundle for
|
|
19
|
+
# its other checks, and a second parse of every concept on the
|
|
20
|
+
# pre-commit hot path buys nothing.
|
|
21
|
+
def failures(root, board: nil, concepts: nil)
|
|
22
|
+
board ||= Pro.read_text(File.join(root, "board.md"))
|
|
23
|
+
targets = board_targets(board)
|
|
24
|
+
concepts ||= ::OKF::Bundle::Reader.read(root).concepts
|
|
25
|
+
|
|
26
|
+
unpaired_projects(root, targets) +
|
|
27
|
+
unpaired_briefings(concepts, targets) +
|
|
28
|
+
broken_targets(root, board) +
|
|
29
|
+
closed_project_links(root, board) +
|
|
30
|
+
stale_read_lines(concepts, board)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Forward: every open project is linked from some board line.
|
|
34
|
+
def unpaired_projects(root, targets)
|
|
35
|
+
open_projects(root).map do |name|
|
|
36
|
+
prefix = "/projects/#{name.downcase}"
|
|
37
|
+
next if targets.any? { |t| t == prefix || t.start_with?("#{prefix}/") }
|
|
38
|
+
|
|
39
|
+
"— pairing: projects/#{name} has no board line and no closed marker."
|
|
40
|
+
end.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Forward: every generated-without-verified briefing is linked from some
|
|
44
|
+
# board line — the To-read line it was born paired with, or the conflict
|
|
45
|
+
# line that is also a claim on attention.
|
|
46
|
+
def unpaired_briefings(concepts, targets)
|
|
47
|
+
unverified_ids(concepts).map do |id|
|
|
48
|
+
next if targets.include?("/#{id}.md".downcase)
|
|
49
|
+
|
|
50
|
+
"— pairing: unverified briefing #{id}.md has no read-owed board line — " \
|
|
51
|
+
"it silently left the attention system."
|
|
52
|
+
end.compact
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reverse: every bundle link the board carries lands on something real.
|
|
56
|
+
# `okf lint` checks links inside concepts, but the board's capture lines
|
|
57
|
+
# use the bare `[/path]` form lint never parses — rot here is invisible
|
|
58
|
+
# to everything but this.
|
|
59
|
+
def broken_targets(root, board)
|
|
60
|
+
entries = bundle_entries(root)
|
|
61
|
+
board_lines(board).flat_map do |line|
|
|
62
|
+
Board.targets(line).map do |target|
|
|
63
|
+
next if entries.include?(target.downcase.chomp("/"))
|
|
64
|
+
|
|
65
|
+
"— pairing: board line links #{target}, which does not exist in the bundle."
|
|
66
|
+
end.compact
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reverse: closure removes board lines wholesale, so a line still linking
|
|
71
|
+
# a closed project is the ritual left half-done.
|
|
72
|
+
def closed_project_links(root, board)
|
|
73
|
+
closed = closed_projects(root)
|
|
74
|
+
return [] if closed.empty?
|
|
75
|
+
|
|
76
|
+
board_lines(board).flat_map do |line|
|
|
77
|
+
Board.targets(line).map do |target|
|
|
78
|
+
slug = target[%r{\A/projects/([^/\s]+)}i, 1]
|
|
79
|
+
next unless slug && closed.include?(slug.downcase)
|
|
80
|
+
|
|
81
|
+
"— pairing: board line still links projects/#{slug}, which is closed — " \
|
|
82
|
+
"closure removes its board lines."
|
|
83
|
+
end.compact
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Reverse: a read-owed line whose briefing is already verified. Reading
|
|
88
|
+
# is the one event that removes the line, and it happened. A To-read line
|
|
89
|
+
# with no bundle link at all is fine — documents live outside bundles too,
|
|
90
|
+
# and the check speaks only about what it can see.
|
|
91
|
+
def stale_read_lines(concepts, board)
|
|
92
|
+
verified = verified_reference_targets(concepts)
|
|
93
|
+
Board.section_lines(board, "To read").flat_map do |line|
|
|
94
|
+
Board.targets(line).map do |target|
|
|
95
|
+
next unless verified.include?(target.downcase)
|
|
96
|
+
|
|
97
|
+
"— pairing: read-owed line links #{target}, which is already verified — " \
|
|
98
|
+
"reading removes the line, and the reading happened."
|
|
99
|
+
end.compact
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Visible lines only — a commented-out sample line must not be a
|
|
104
|
+
# pairing target or a broken-link finding, for the same reason the
|
|
105
|
+
# counters do not count it.
|
|
106
|
+
def board_lines(board)
|
|
107
|
+
Board.visible(board).each_line.select { |l| l.start_with?("- ") }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def board_targets(board)
|
|
111
|
+
board_lines(board).flat_map { |l| Board.targets(l) }.map(&:downcase)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Downcased so pairing behaves the same on a case-sensitive filesystem
|
|
115
|
+
# as on the case-insensitive one the bundle was probably written on.
|
|
116
|
+
def bundle_entries(root)
|
|
117
|
+
Dir.glob(File.join(root, "**", "*"))
|
|
118
|
+
.map { |p| "/#{p[(root.size + 1)..-1]}".downcase.chomp("/") }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def open_projects(root)
|
|
122
|
+
project_dirs(root).map do |dir|
|
|
123
|
+
File.basename(dir) unless closed?(root, File.join(dir, "index.md"))
|
|
124
|
+
end.compact
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def closed_projects(root)
|
|
128
|
+
project_dirs(root).map do |dir|
|
|
129
|
+
File.basename(dir).downcase if closed?(root, File.join(dir, "index.md"))
|
|
130
|
+
end.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def project_dirs(root)
|
|
134
|
+
Dir.glob(File.join(root, "projects", "*", "")).sort
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Closure is one marker, matched as one pattern: the word "closed" —
|
|
138
|
+
# not hyphen-joined into a name — then its date, with only marker
|
|
139
|
+
# punctuation between, read across the top of the index (three lines,
|
|
140
|
+
# the old tolerance), never the body. Every weaker reading failed in
|
|
141
|
+
# practice: bare /closed/i made titles and verbs into closures;
|
|
142
|
+
# word-plus-date-anywhere closed dated titles; a forty-character gap
|
|
143
|
+
# admitted prose; whitelisting "on" re-admitted "The office closed on
|
|
144
|
+
# 2026-01-05"; and an unguarded comma admitted "we closed, 2026-01-05,
|
|
145
|
+
# the books". The accepted spellings are enumerated ONCE, in the
|
|
146
|
+
# skill's closing ritual; MARKER is that enumeration, and the grammar
|
|
147
|
+
# tests pin both lists to each other.
|
|
148
|
+
MARKER = /\bclosed\b(?!-)[[:blank:]*(\u2014\u2013:-]*\d{4}-\d{2}-\d{2}/i.freeze
|
|
149
|
+
|
|
150
|
+
# A negation must not close the thing it denies. This is a blacklist
|
|
151
|
+
# of negators, and honestly so — a two-literal lookbehind briefly
|
|
152
|
+
# posed as "not negated" while "**not** closed", a double space, or
|
|
153
|
+
# "wasn't" walked straight past it. Markup and punctuation may sit
|
|
154
|
+
# between the negator and the verb, exactly as they may sit between
|
|
155
|
+
# the verb and its date.
|
|
156
|
+
NEGATED = /\b(?:not|never|no longer|isn['\u2019]?t|wasn['\u2019]?t|hasn['\u2019]?t)[\W_]*closed\b/i.freeze
|
|
157
|
+
|
|
158
|
+
def marker?(line)
|
|
159
|
+
line.match?(MARKER) && !line.match?(NEGATED)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Read through the kernel's containment primitive rather than File.read:
|
|
163
|
+
# this holds a root, and `SafeRead.read!` resolves the path and refuses
|
|
164
|
+
# one whose symlinks escape it. Byte-identical output for every real
|
|
165
|
+
# file — the `.scrub` is still this gem's, because `read!` tags the
|
|
166
|
+
# encoding without validating it and one invalid byte anywhere raises out
|
|
167
|
+
# of the first regex, which the hook protocol reads as non-blocking.
|
|
168
|
+
#
|
|
169
|
+
# NOT applied at `BundleRoot.root_index?`, which has no root to contain
|
|
170
|
+
# against — it is deciding what the root is — and where a raise would be a
|
|
171
|
+
# permanent lockout and a rescue would answer "not a root", mis-rooting
|
|
172
|
+
# the bundle and disarming the journal guard.
|
|
173
|
+
# Contained against the BUNDLE root, which every caller already holds.
|
|
174
|
+
# Deriving it as `dirname(dirname(index_path))` gave `<bundle>/projects`,
|
|
175
|
+
# so `SafeRead` refused any project index whose realpath left `projects/`
|
|
176
|
+
# — including one that never leaves the bundle. A project legitimately
|
|
177
|
+
# archived behind a symlink (`projects/beta -> ../archive/beta`) read as
|
|
178
|
+
# open forever, and the audit demanded a board line for work closed
|
|
179
|
+
# months ago. The containment rule is about the bundle, and a root
|
|
180
|
+
# invented from the path being checked is not the bundle.
|
|
181
|
+
def closed?(root, index_path)
|
|
182
|
+
return false unless File.exist?(index_path)
|
|
183
|
+
|
|
184
|
+
Pro.read_contained(root, index_path).lines.first(3).any? { |line| marker?(line) }
|
|
185
|
+
rescue ::OKF::Path::Error, ::Errno::ENOENT
|
|
186
|
+
# A project index reached only through a symlink out of the bundle is
|
|
187
|
+
# not a closure marker anyone may rely on. Open is the safe answer: an
|
|
188
|
+
# open project keeps its board line, a closed one loses it.
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ── the read-owed rule, in the kernel's vocabulary ─────────────────────
|
|
193
|
+
#
|
|
194
|
+
# These two are exact opposites and MUST stay so, or a briefing falls
|
|
195
|
+
# through both: `unverified_ids` says "the board still owes this a To-read
|
|
196
|
+
# line", `verified_reference_targets` says "the line can go". They are
|
|
197
|
+
# read by `unpaired_briefings` and `stale_read_lines` respectively, plus
|
|
198
|
+
# `Attestation.report` and the snapshot counter — four call sites, one
|
|
199
|
+
# rule, and the rule is:
|
|
200
|
+
#
|
|
201
|
+
# a briefing is owed a read until a HUMAN has verified it.
|
|
202
|
+
#
|
|
203
|
+
# `okf` 2.0 answers that directly. `declared_generated?` is raw-key
|
|
204
|
+
# detection, so it distinguishes hand-written (no provenance at all) from
|
|
205
|
+
# v0.1-with-a-timestamp, which the old `frontmatter["generated"]` test
|
|
206
|
+
# conflated with the fallback. And `trust_tier` reads §5.3: no `verified`
|
|
207
|
+
# is unverified, a `process:` or agent actor is machine-confirmed, a
|
|
208
|
+
# `human:<id>` actor is human-reviewed.
|
|
209
|
+
#
|
|
210
|
+
# The tier is what makes these opposites rather than merely different.
|
|
211
|
+
# Testing `frontmatter["verified"]` for truthiness made a nightly
|
|
212
|
+
# `process:`-verified briefing simultaneously *awaiting the owner's read*
|
|
213
|
+
# to one caller and *verified, drop the line* to the other — the board
|
|
214
|
+
# line vanished and the read was never owed to anyone again. Moving one
|
|
215
|
+
# site and not the other manufactures exactly that state, which is why
|
|
216
|
+
# this comment sits over both of them.
|
|
217
|
+
def awaiting_read?(concept)
|
|
218
|
+
concept.declared_generated? && concept.trust_tier != :human_reviewed
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def owner_read?(concept)
|
|
222
|
+
concept.trust_tier == :human_reviewed
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def briefing?(concept)
|
|
226
|
+
concept.id.start_with?("reference/")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def unverified_ids(concepts)
|
|
230
|
+
concepts.map do |concept|
|
|
231
|
+
next unless briefing?(concept)
|
|
232
|
+
next unless awaiting_read?(concept)
|
|
233
|
+
|
|
234
|
+
concept.id
|
|
235
|
+
end.compact
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def verified_reference_targets(concepts)
|
|
239
|
+
concepts.map do |concept|
|
|
240
|
+
next unless briefing?(concept)
|
|
241
|
+
next unless owner_read?(concept)
|
|
242
|
+
|
|
243
|
+
"/#{concept.id}.md".downcase
|
|
244
|
+
end.compact
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The one shell-out in the whole checker. `--` plus the pathspec keeps it
|
|
248
|
+
# to markdown, so a change confined to hooks or CI does not summon Rule 2's
|
|
249
|
+
# end-of-day questions.
|
|
250
|
+
def dirty_markdown?(root)
|
|
251
|
+
out = IO.popen(
|
|
252
|
+
[ "git", "-C", root, "status", "--porcelain", "--", "*.md" ],
|
|
253
|
+
err: File::NULL, &:read
|
|
254
|
+
)
|
|
255
|
+
# Only a git that ran and answered may disengage the gate. A git that
|
|
256
|
+
# failed — dubious ownership, a corrupted .git, a root outside any
|
|
257
|
+
# repository — is not "no work happened"; it is "nobody can tell",
|
|
258
|
+
# and a gate that cannot tell must not wave the session through.
|
|
259
|
+
# Returning false here was the silent-disarm hole: stderr was nulled,
|
|
260
|
+
# the empty read looked clean, and the stop gate asked no questions.
|
|
261
|
+
return true unless $?.success?
|
|
262
|
+
|
|
263
|
+
!out.to_s.strip.empty?
|
|
264
|
+
rescue Errno::ENOENT
|
|
265
|
+
# No git at all is the same state as a failing one — nobody can tell
|
|
266
|
+
# whether work happened — and gets the same answer.
|
|
267
|
+
true
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# Rule 1 — writing is reconciliation.
|
|
6
|
+
#
|
|
7
|
+
# Fires on Write, not Edit: a new concept is the moment a claim enters the
|
|
8
|
+
# corpus, and the moment both it and whatever it collides with are cheapest
|
|
9
|
+
# to hold in one head. Its reach is bounded by the filename's vocabulary,
|
|
10
|
+
# which is exactly the limit the rule states about itself — it catches
|
|
11
|
+
# collisions as well as your search words do, and no better.
|
|
12
|
+
module Reconcile
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def search(target, event)
|
|
16
|
+
return [] if target.nil?
|
|
17
|
+
return [] unless event.tool_name == "Write"
|
|
18
|
+
return [] if target.rel.start_with?("journal/")
|
|
19
|
+
return [] if NO_RECONCILE.include?(target.basename)
|
|
20
|
+
|
|
21
|
+
hits = terms(target).map { |term| block_for(target, term) }.compact
|
|
22
|
+
return [] if hits.empty?
|
|
23
|
+
|
|
24
|
+
[ "RULE 1 — reconciliation. Existing concepts share this new concept's vocabulary. " \
|
|
25
|
+
"Read them now: contradiction, supersession, or duplicate? Deprecate the loser at this " \
|
|
26
|
+
"moment, or file a conflict line on board.md. Do not simply continue.\n#{hits.join("\n")}" ]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def terms(target)
|
|
30
|
+
File.basename(target.rel, ".md").split("-")
|
|
31
|
+
.reject { |t| t.empty? || STOP_WORDS.include?(t.downcase) }
|
|
32
|
+
.first(4)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def block_for(target, term)
|
|
36
|
+
rows = matches(target, term)
|
|
37
|
+
return nil if rows.empty?
|
|
38
|
+
|
|
39
|
+
statuses = status_index(target.bundle)
|
|
40
|
+
body = rows.map do |r|
|
|
41
|
+
" #{r[:id]}#{settled(statuses[r[:id]])} · #{r[:type]} · #{r[:title]}\n #{r[:snippet]}"
|
|
42
|
+
end
|
|
43
|
+
"— '#{term}':\n#{body.join("\n")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# §5.4's `status`, made operative. The skill teaches `status: deprecated`
|
|
47
|
+
# as the machine-readable half of Rule 1's supersession move — correct the
|
|
48
|
+
# loser NOW, and mark it — and this is what makes that teaching worth
|
|
49
|
+
# anything: a collision with a concept that has already been deprecated is
|
|
50
|
+
# a collision somebody already settled, and re-litigating it is the cost
|
|
51
|
+
# the marking exists to avoid.
|
|
52
|
+
#
|
|
53
|
+
# It annotates rather than filters. A deprecated concept is "kept for
|
|
54
|
+
# links and history, no longer current" (§5.4), so it is still a real hit
|
|
55
|
+
# and still worth reading — what changes is what the reader does about it.
|
|
56
|
+
# Filtering it out would hide the evidence that the question was answered.
|
|
57
|
+
def settled(status)
|
|
58
|
+
status == "deprecated" ? " [deprecated — this collision was already settled]" : ""
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def status_index(bundle)
|
|
62
|
+
bundle.concepts.each_with_object({}) { |concept, index| index[concept.id] = concept.status }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The concept being written is excluded (it is not its own collision), and
|
|
66
|
+
# so are the structural files: a hit in README or board.md means they quote
|
|
67
|
+
# a concept, not that they assert against one.
|
|
68
|
+
def matches(target, term)
|
|
69
|
+
::OKF::Bundle::Search.call(target.bundle, term)
|
|
70
|
+
.reject { |r| r[:id] == target.id }
|
|
71
|
+
.reject { |r| NO_RECONCILE.include?("#{File.basename(r[:id])}.md") }
|
|
72
|
+
.first(5)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The append-only record, checked against git rather than against a tool
|
|
6
|
+
# event.
|
|
7
|
+
#
|
|
8
|
+
# `Guards.journal_guard` refuses an edit to a past day at the agent's
|
|
9
|
+
# tool boundary, which is the right place to catch it and the wrong place
|
|
10
|
+
# to rely on: it reads a PreToolUse event, so it sees Edit and Write and
|
|
11
|
+
# nothing else. A shell redirect, an editor, a patch — none of them
|
|
12
|
+
# produce the event, and the record they rewrite is exactly the artefact
|
|
13
|
+
# nobody can reconstruct afterwards.
|
|
14
|
+
#
|
|
15
|
+
# This asks the same question of the index, where the answer does not
|
|
16
|
+
# depend on which tool made the change: does this commit modify or delete
|
|
17
|
+
# a journal day that already exists in HEAD, and is that day in the past?
|
|
18
|
+
# Only additions and today's entry survive. It runs at the commit door,
|
|
19
|
+
# which every path to history goes through.
|
|
20
|
+
module Records
|
|
21
|
+
JOURNAL_ENTRY = %r{journal/(\d{4}-\d{2}-\d{2})\.md\z}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Findings, or []. `root` is the repository — this reads the index, not
|
|
26
|
+
# a materialised tree, because the question is about a change rather
|
|
27
|
+
# than a state.
|
|
28
|
+
def staged_violations(root, today: Date.today)
|
|
29
|
+
status = staged_status(root)
|
|
30
|
+
return status[:findings] unless status[:findings].empty?
|
|
31
|
+
|
|
32
|
+
status[:entries].map do |mode, path|
|
|
33
|
+
match = path.match(JOURNAL_ENTRY)
|
|
34
|
+
next unless match
|
|
35
|
+
|
|
36
|
+
day = match[1]
|
|
37
|
+
next unless day < today.to_s
|
|
38
|
+
|
|
39
|
+
verb = VERBS.fetch(mode[0, 1], "modifies")
|
|
40
|
+
" records this commit #{verb} #{path}, a past day that already exists in HEAD. " \
|
|
41
|
+
"Records are append-only; corrections go in today's entry, pointing back."
|
|
42
|
+
end.compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# What the status letter did to the day, in the words a refusal needs. A
|
|
46
|
+
# typechange is its own verb because it is its own act: the day's content
|
|
47
|
+
# is not edited, it is replaced wholesale by a link somewhere else.
|
|
48
|
+
VERBS = { "D" => "deletes", "T" => "replaces" }.freeze
|
|
49
|
+
|
|
50
|
+
# Modified, deleted, renamed and TYPECHANGED paths in the index, against
|
|
51
|
+
# HEAD. Additions are absent by construction — a day being written for
|
|
52
|
+
# the first time is the reconstruction the journal guard already routes
|
|
53
|
+
# to the owner, and this door does not second-guess an approval it cannot
|
|
54
|
+
# see.
|
|
55
|
+
#
|
|
56
|
+
# `T` was absent and is the worst omission of the four: replacing a
|
|
57
|
+
# committed past day with a symlink stages as a typechange, never as `M`
|
|
58
|
+
# or `D`, so the gate reported "append-only" and the commit destroyed the
|
|
59
|
+
# one artefact this module exists to protect. The filter is a whitelist,
|
|
60
|
+
# so every letter it does not name fails OPEN — which is why it is named
|
|
61
|
+
# here with its reason rather than left to be re-derived.
|
|
62
|
+
def staged_status(root)
|
|
63
|
+
out = IO.popen(
|
|
64
|
+
[ "git", "-C", root, "diff", "--cached", "--name-status", "--diff-filter=MDRT" ],
|
|
65
|
+
err: File::NULL, &:read
|
|
66
|
+
)
|
|
67
|
+
# A git that could not answer is not an empty answer. The commit door
|
|
68
|
+
# refuses what it cannot check, exactly as dirty_markdown? does.
|
|
69
|
+
unless $?.success?
|
|
70
|
+
return { findings: [ " records git could not report the staged changes, so the " \
|
|
71
|
+
"append-only record could not be checked. The commit is refused " \
|
|
72
|
+
"rather than waved through." ], entries: [] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
entries = out.to_s.each_line.map do |line|
|
|
76
|
+
mode, *paths = line.strip.split("\t")
|
|
77
|
+
next if mode.nil? || paths.empty?
|
|
78
|
+
|
|
79
|
+
# A rename reports source and destination; the source is the one
|
|
80
|
+
# leaving its place.
|
|
81
|
+
[ mode, paths.first ]
|
|
82
|
+
end.compact
|
|
83
|
+
{ findings: [], entries: entries }
|
|
84
|
+
rescue Errno::ENOENT
|
|
85
|
+
{ findings: [ " records git is not available, so the append-only record could not be " \
|
|
86
|
+
"checked. The commit is refused rather than waved through." ], entries: [] }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|