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,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# Rule 2 — the day ends with a snapshot, and the delta is the signal.
|
|
6
|
+
#
|
|
7
|
+
# One of these refuses and one only informs, and that asymmetry is the rule
|
|
8
|
+
# itself: the snapshot is written at the end (stop-gate refuses without it,
|
|
9
|
+
# and refuses one that disagrees with the board), and read at the beginning
|
|
10
|
+
# (session-context puts yesterday's line in front of you before anything
|
|
11
|
+
# else). A counter nobody compares is wallpaper.
|
|
12
|
+
#
|
|
13
|
+
# Both root through BundleRoot.enclosing — the ancestor walk — because
|
|
14
|
+
# their only input is the session's cwd, and a session parked in a bundle
|
|
15
|
+
# subdirectory must not be a session whose stop gate silently disengaged
|
|
16
|
+
# or mistook a directory index for a broken core. resolve() does neither
|
|
17
|
+
# walk nor discriminate, and was both of those bugs at once.
|
|
18
|
+
module Closing
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def stop_gate(event, today: Date.today)
|
|
22
|
+
return [] if event.stop_hook_active?
|
|
23
|
+
|
|
24
|
+
root = BundleRoot.enclosing(event.cwd)
|
|
25
|
+
return [] if root.nil?
|
|
26
|
+
|
|
27
|
+
# The precondition is "work happened". A session that read and changed
|
|
28
|
+
# nothing owes no snapshot, and asking for one would train people to
|
|
29
|
+
# write a line that means nothing.
|
|
30
|
+
return [] unless Pairing.dirty_markdown?(root)
|
|
31
|
+
|
|
32
|
+
# A bundle missing its skeleton used to slip this gate in silence —
|
|
33
|
+
# no board, no log, no questions asked. Broken core is the first
|
|
34
|
+
# refusal, and the only one, because every later check reads the core.
|
|
35
|
+
broken = Audit.structure(root)
|
|
36
|
+
return [ "RULE 2 — before stopping:\n#{broken.map { |m| "— #{m}" }.join("\n")}" ] unless broken.empty?
|
|
37
|
+
|
|
38
|
+
# One read of the board, one parse of the bundle, shared by every
|
|
39
|
+
# check below — this gate runs on every Stop, and it used to pay for
|
|
40
|
+
# both twice.
|
|
41
|
+
board = Pro.read_text(File.join(root, "board.md"))
|
|
42
|
+
concepts = ::OKF::Bundle::Reader.read(root).concepts
|
|
43
|
+
|
|
44
|
+
msgs = []
|
|
45
|
+
snap = Log.snapshot_line(Pro.read_text(File.join(root, "log.md")), today.to_s)
|
|
46
|
+
if snap.nil?
|
|
47
|
+
msgs << "— log.md has no Snapshot line under #{today}. Append it before stopping — " \
|
|
48
|
+
"computed from the bundle as it stands:\n #{Snapshot.line(root, today: today, board: board, concepts: concepts)}"
|
|
49
|
+
else
|
|
50
|
+
# Presence was never the point; agreement is. This runs at stop time,
|
|
51
|
+
# which is the one moment the calendar cannot make it lie — the same
|
|
52
|
+
# check in CI would fail on a push nobody made that day.
|
|
53
|
+
msgs.concat(Snapshot.verify(root, snap, today: today, board: board, concepts: concepts))
|
|
54
|
+
end
|
|
55
|
+
# A dated line the counters cannot parse counts as zero, and a zero
|
|
56
|
+
# is indistinguishable from a quiet board — so the snapshot the gate
|
|
57
|
+
# just verified would agree with itself about a deadline it cannot
|
|
58
|
+
# see. The unreadable line is a refusal, not a rounding error.
|
|
59
|
+
msgs.concat(Board.grammar(board).map { |m| "— board: #{m}" })
|
|
60
|
+
msgs.concat(Pairing.failures(root, board: board, concepts: concepts))
|
|
61
|
+
return [] if msgs.empty?
|
|
62
|
+
|
|
63
|
+
[ "RULE 2 — before stopping:\n#{msgs.join("\n")}" ]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def session_context(event, today: Date.today)
|
|
67
|
+
root = BundleRoot.enclosing(event.cwd)
|
|
68
|
+
return nil if root.nil?
|
|
69
|
+
|
|
70
|
+
# This channel cannot refuse, so a broken core is said out loud rather
|
|
71
|
+
# than shown as a suspiciously quiet banner.
|
|
72
|
+
broken = Audit.structure(root)
|
|
73
|
+
unless broken.empty?
|
|
74
|
+
return [ "Bundle at #{root} has a broken core:",
|
|
75
|
+
*broken.map { |m| " — #{m}" },
|
|
76
|
+
"The gates cannot read a bundle missing its skeleton; restore it, then `okf pro audit .`." ]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
lines = state_block(State.call(root, today: today), today)
|
|
80
|
+
lines.concat(Budget.dormancy_questions(root, today: today,
|
|
81
|
+
board: Pro.read_text(File.join(root, "board.md"))))
|
|
82
|
+
lines << REFRESH
|
|
83
|
+
lines.concat(friction_line(root))
|
|
84
|
+
lines
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The line that actually saves the turns: without it the banner is state an
|
|
88
|
+
# agent re-reads out of the files the moment it writes anything.
|
|
89
|
+
REFRESH = "Current as of session start. After you write, refresh with `okf pro state` — " \
|
|
90
|
+
"do not re-read the files. `okf pro board` lists the lines; `okf pro capture`, " \
|
|
91
|
+
"`promote`, `demote`, `journal open` and `close` write the mechanical shapes."
|
|
92
|
+
|
|
93
|
+
# THE WHOLE BANNER, and there is only one of it.
|
|
94
|
+
#
|
|
95
|
+
# The bill is turns × context, not payload size. A measured session spent
|
|
96
|
+
# nine tool calls and 40.9% of its tool output rediscovering state the gates
|
|
97
|
+
# already compute — and state delivered here costs no turn at all, because
|
|
98
|
+
# SessionStart runs whether anyone asks or not.
|
|
99
|
+
#
|
|
100
|
+
# Which is exactly why nothing may be said twice. This began as a structured
|
|
101
|
+
# block appended UNDER three prose lines, and the two overlapped: in flight
|
|
102
|
+
# and inbox were counted in both, and the snapshot was rendered in both. That
|
|
103
|
+
# spends the saving the block exists to make, and leaves a reader working out
|
|
104
|
+
# which of the two numbers is live.
|
|
105
|
+
#
|
|
106
|
+
# CHEAP SOURCES ONLY, and that is a constraint rather than a preference.
|
|
107
|
+
# `State.call`'s default payload reads `board.md`, `log.md` and two directory
|
|
108
|
+
# globs, and parses no concept. The two counters that would need
|
|
109
|
+
# `Bundle::Reader.read` are never recomputed here — they arrive inside the
|
|
110
|
+
# logged Snapshot line, printed whole and labelled by its day.
|
|
111
|
+
#
|
|
112
|
+
# Staleness is already covered elsewhere: the stop gate recomputes
|
|
113
|
+
# independently and refuses a snapshot line that disagrees with the board.
|
|
114
|
+
def state_block(payload, today)
|
|
115
|
+
log = payload["log"]
|
|
116
|
+
open_projects = payload["projects"]["open"]
|
|
117
|
+
# The board's counters are formatted by `State`, not here: the banner's
|
|
118
|
+
# whole promise is that it says what `okf pro state` would say, and two
|
|
119
|
+
# renderings of the same eleven numbers drift into a reader having to
|
|
120
|
+
# check which one they are looking at.
|
|
121
|
+
lines = [ State.board_line(payload["board"], "Bundle state at session start —"),
|
|
122
|
+
"Log — newest day #{log["newest day"] || "none yet"} · journal for #{today} " \
|
|
123
|
+
"#{log["journal today"] ? "open" : "not opened"} · open projects #{open_projects.size}" \
|
|
124
|
+
"#{" (#{open_projects.join(", ")})" unless open_projects.empty?}",
|
|
125
|
+
snapshot_line(payload["last snapshot"]),
|
|
126
|
+
"Read the delta, not the status: a number that moved the wrong way is today's first signal." ]
|
|
127
|
+
|
|
128
|
+
# Crack 2's other half: the collision made visible days before it lands.
|
|
129
|
+
risk = payload["deadlines at risk"]
|
|
130
|
+
unless risk.empty?
|
|
131
|
+
lines << "Deadlines within 7d with nothing in flight against them:"
|
|
132
|
+
risk.each { |line| lines << " #{line}" }
|
|
133
|
+
end
|
|
134
|
+
lines
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The logged line WHOLE, with the day it was logged under and the label that
|
|
138
|
+
# stops a reader taking it for today's. Both halves earn their place: the
|
|
139
|
+
# twelve counters are what "read the delta" is read against, and two of them
|
|
140
|
+
# — `unverified briefings` and `projects with 0 concepts` — can only be
|
|
141
|
+
# recomputed by parsing the corpus, which this banner never does. A second
|
|
142
|
+
# line restating those two said nothing this one does not.
|
|
143
|
+
#
|
|
144
|
+
# Latest by date, not last in the file: `State` reads the log newest-first,
|
|
145
|
+
# because grepping the whole file for the last hit found its bottom — the
|
|
146
|
+
# oldest entry — and from day two onward the banner reported the first-ever
|
|
147
|
+
# counters, inverting the delta it exists to show.
|
|
148
|
+
def snapshot_line(snap)
|
|
149
|
+
return "Last snapshot: none yet" if snap.nil?
|
|
150
|
+
|
|
151
|
+
"Last snapshot (#{snap["day"]}, not live): #{snap["line"]}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Addressed to the ADOPTER, not the maintainer, and that is the whole of
|
|
155
|
+
# its tone: they are the ones paying for a missing verb, and they are the
|
|
156
|
+
# only ones who can say which one it is. Nothing is ever filed
|
|
157
|
+
# automatically — `--issue` prints a command a person decides to run.
|
|
158
|
+
#
|
|
159
|
+
# A recorder that could not write says so rather than printing a zero. A
|
|
160
|
+
# zero and "unknown" are different states, and this is the one line in the
|
|
161
|
+
# banner where confusing them would quietly retire the measurement.
|
|
162
|
+
def friction_line(root)
|
|
163
|
+
report = Friction.report(root)
|
|
164
|
+
unless report.available
|
|
165
|
+
return [ "Friction: the recorder could not write to .tmp/ at some point, so the count is " \
|
|
166
|
+
"unknown rather than zero. `okf pro friction --clear` starts it again." ]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Only what a VERB covers, and that is the difference between a request and
|
|
170
|
+
# a nag. Shell redirects are recorded too, and `covered_by` answers "Edit or
|
|
171
|
+
# Write" for them — the trust guards read a tool event and a redirect
|
|
172
|
+
# produces none — so counting them here claimed a verb could have done
|
|
173
|
+
# something no verb covers. Worse: `shell-guard` records INTENT, at
|
|
174
|
+
# PreToolUse, so the system working exactly as designed (guard fires, owner
|
|
175
|
+
# denies, agent uses Edit) still incremented a lifetime counter that then
|
|
176
|
+
# nagged every session until `--clear`. `okf pro friction` shows every row;
|
|
177
|
+
# this line asks for a verb, so it counts only what a verb would answer.
|
|
178
|
+
# A log that will not parse is a SHORT count, not a quiet one, and this
|
|
179
|
+
# is the line where a zero and an unknown must never be confused. Only
|
|
180
|
+
# the unwritable case was reported; an unreadable one said nothing at
|
|
181
|
+
# all, which is the same lie one door along.
|
|
182
|
+
lines = []
|
|
183
|
+
if report.unreadable.positive?
|
|
184
|
+
lines << "Friction: #{report.unreadable} recorded line(s) will not parse, so anything " \
|
|
185
|
+
"counted here is short by an unknown amount. `okf pro friction --clear` starts " \
|
|
186
|
+
"it again."
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
covered = report.events.count { |e| Friction.verb_covered?(e["via"], e["what"]) }
|
|
190
|
+
return lines if covered.zero?
|
|
191
|
+
|
|
192
|
+
# "so far", not "last session", and the difference is not pedantry: the
|
|
193
|
+
# log is append-only and nothing prunes it, so this is a lifetime total.
|
|
194
|
+
# A line that framed a cumulative number as this session's would nag
|
|
195
|
+
# about a week nobody can change, and would keep nagging — which is the
|
|
196
|
+
# standing-warning failure the whole design is built to avoid. Naming
|
|
197
|
+
# the reset is what makes the number actionable.
|
|
198
|
+
lines << "#{covered} bundle edit(s) so far were done by hand that an `okf pro` verb " \
|
|
199
|
+
"could do. If one of them should be a verb, please tell the maintainer — `okf pro " \
|
|
200
|
+
"friction --issue` prints a ready-to-paste report, and `--clear` resets the count. " \
|
|
201
|
+
"It helps more than you think."
|
|
202
|
+
lines
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# `okf validate` and `okf lint --fail-on warn`, in process. The CLI would
|
|
6
|
+
# answer the same questions at the cost of two interpreter boots per edit;
|
|
7
|
+
# the analyzers are pure and take the bundle directly.
|
|
8
|
+
module Conformance
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def check(target)
|
|
12
|
+
return [] if target.nil?
|
|
13
|
+
|
|
14
|
+
result = ::OKF::Bundle::Validator.call(target.bundle)
|
|
15
|
+
unless result.valid?
|
|
16
|
+
lines = result.errors.map { |e| " #{e[:path]}: #{e[:message]}" }
|
|
17
|
+
return [ "okf validate failed after your edit:\n#{lines.join("\n")}" ]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# §9 forbids the validator from REJECTING a soft problem, so these are
|
|
21
|
+
# warnings rather than errors — and this gate used to read `errors` only
|
|
22
|
+
# and drop every one of them on the floor.
|
|
23
|
+
#
|
|
24
|
+
# That is not a cosmetic loss. It is where the trust family goes wrong
|
|
25
|
+
# in the worst direction: `verified: human:rod` written as a scalar
|
|
26
|
+
# fires the attestation guard (the word is there), so the owner is asked
|
|
27
|
+
# and approves — and then the reader drops the malformed value, the tier
|
|
28
|
+
# stays `unverified`, and the To-read line is demanded forever. The
|
|
29
|
+
# validator says exactly what is wrong — *verified should be a mapping
|
|
30
|
+
# or a list of mappings* — and surfacing it fixes the whole class in one
|
|
31
|
+
# line, where an okf-pro-side re-implementation of the grammar would be a
|
|
32
|
+
# second parser to keep in step with okf's.
|
|
33
|
+
#
|
|
34
|
+
# They warn rather than refuse: §9's separation is the kernel's, and a
|
|
35
|
+
# gate that turned the validator's warnings into errors would be
|
|
36
|
+
# overruling it from outside.
|
|
37
|
+
soft = warn_lines(result.warnings)
|
|
38
|
+
|
|
39
|
+
# WHAT THIS GATE RUNS, AND WHY IT SAYS SO.
|
|
40
|
+
#
|
|
41
|
+
# Two of the linter's checks are clock-gated, and a default
|
|
42
|
+
# `Linter.call(bundle)` runs neither. Measured on this gate's own path
|
|
43
|
+
# before this line existed: `skipped_checks: [:expired, :stale]`,
|
|
44
|
+
# `healthy?: true`. The gate reported clean over two checks it never
|
|
45
|
+
# ran, and nothing said so — the contract's third clause broken by the
|
|
46
|
+
# checker itself, in the one place nobody can notice.
|
|
47
|
+
#
|
|
48
|
+
# okf 2.0 confesses it in `stats[:skipped_checks]`, a field added for a
|
|
49
|
+
# reader exactly like this one. The answer is not to relay the
|
|
50
|
+
# confession as a refusal — a skipped check is not a finding, and a gate
|
|
51
|
+
# that blocked every edit until someone supplied a cutoff would be off
|
|
52
|
+
# within a day. It is to leave nothing skipped:
|
|
53
|
+
#
|
|
54
|
+
# `today:` the clock `expired` needs. It is `:info`, so this
|
|
55
|
+
# adds no refusal — it removes a silence.
|
|
56
|
+
# `except: stale` a decision, stated. `stale` asks "untouched since
|
|
57
|
+
# when?", and this gem has no cutoff policy and no
|
|
58
|
+
# business inventing one — that is curation backlog,
|
|
59
|
+
# which `okf lint` answers when a person runs it and
|
|
60
|
+
# picks a date. An excluded check is excluded here in
|
|
61
|
+
# source, which is the opposite of silent.
|
|
62
|
+
#
|
|
63
|
+
# `skipped_checks` is then empty, and `confession` below is a live guard
|
|
64
|
+
# rather than a formality: the day okf adds a third clock-gated check,
|
|
65
|
+
# this gate reports it instead of quietly not running it.
|
|
66
|
+
report = ::OKF::Bundle::Linter.call(target.bundle, today: Date.today, except: [ :stale ])
|
|
67
|
+
notes = confession(report) + soft
|
|
68
|
+
return notes if report.healthy? && notes.empty?
|
|
69
|
+
|
|
70
|
+
# Warnings only. Lint's `:info` findings are observations, and a gate
|
|
71
|
+
# that refuses on an observation stops being read as a refusal.
|
|
72
|
+
#
|
|
73
|
+
# And the lint is bundle-WIDE while this gate fires on one edit, so
|
|
74
|
+
# the report says which is which. Everything is still reported —
|
|
75
|
+
# dropping the other files' warnings would hide the edit that
|
|
76
|
+
# breaks a NEIGHBOUR (delete a concept its index still links, and
|
|
77
|
+
# the warning lands on the index, not on the file you touched) —
|
|
78
|
+
# but only the edited file's warnings are called yours. A single
|
|
79
|
+
# pre-existing warning elsewhere used to arrive under the heading
|
|
80
|
+
# "your edit" on every edit in the bundle, and a gate that blames
|
|
81
|
+
# you for what you did not do is a gate people switch off.
|
|
82
|
+
mine, theirs = report.warnings.partition { |w| same_file?(w[:path], target.rel) }
|
|
83
|
+
parts = notes
|
|
84
|
+
parts << "okf lint flagged your edit:\n#{format_warnings(mine)}" unless mine.empty?
|
|
85
|
+
unless theirs.empty?
|
|
86
|
+
parts << "okf lint findings elsewhere in the bundle — pre-existing, or a neighbour " \
|
|
87
|
+
"your edit affected:\n#{format_warnings(theirs)}"
|
|
88
|
+
end
|
|
89
|
+
[ parts.join("\n") ]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# What the linter did not run, said out loud. Empty in normal operation
|
|
93
|
+
# (see the call above); non-empty means okf grew a check this gate does
|
|
94
|
+
# not know how to supply, and the right answer then IS to stop — an
|
|
95
|
+
# unchecked bundle reported as clean is the failure this whole file
|
|
96
|
+
# exists to prevent, and a loud unknown beats a quiet one.
|
|
97
|
+
def confession(report)
|
|
98
|
+
skipped = Array(report.stats[:skipped_checks])
|
|
99
|
+
return [] if skipped.empty?
|
|
100
|
+
|
|
101
|
+
[ "okf lint could not run #{skipped.join(", ")} on this bundle (no cutoff supplied), " \
|
|
102
|
+
"so those are unchecked rather than clean." ]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The validator's soft findings, which are advice rather than a verdict.
|
|
106
|
+
def warn_lines(warnings)
|
|
107
|
+
return [] if warnings.empty?
|
|
108
|
+
|
|
109
|
+
[ "okf validate accepted the bundle with #{warnings.size} warning(s) — conformant, but not " \
|
|
110
|
+
"read the way you meant:\n" +
|
|
111
|
+
warnings.map { |w| " #{w[:path]}: #{w[:message]}" }.join("\n") ]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def format_warnings(warnings)
|
|
115
|
+
warnings.map { |w| " #{w[:path]}: #{w[:message]} (#{w[:check]})" }.join("\n")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Lint reports a concept id, a bundle-absolute link or a path; the
|
|
119
|
+
# target carries a bundle-relative one. Compared on the stem so the
|
|
120
|
+
# three spellings of the same file agree.
|
|
121
|
+
def same_file?(path, rel)
|
|
122
|
+
stem = ->(s) { s.to_s.downcase.sub(/\A\//, "").sub(/\.md\z/, "") }
|
|
123
|
+
stem.call(path) == stem.call(rel)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The write contract, made enforceable.
|
|
6
|
+
#
|
|
7
|
+
# Additive and targeted, never regenerative. A write verb may append a
|
|
8
|
+
# line or edit the line it was given. No verb rewrites a file it did not
|
|
9
|
+
# fully derive from that file's own prior contents.
|
|
10
|
+
#
|
|
11
|
+
# Failure mode 07 — Agent Drift — is an LLM regenerating a view and dropping
|
|
12
|
+
# a task, silently: no error, no diff anyone reads, one commitment gone. The
|
|
13
|
+
# verdict that closed it was "derivation exists as a checker and never as a
|
|
14
|
+
# generator", and the status quo it was written against is the failure
|
|
15
|
+
# rather than a defence: today the agent rewrites `board.md` with a heredoc,
|
|
16
|
+
# which is exactly the silent-drop hazard performed by exactly the actor the
|
|
17
|
+
# record names.
|
|
18
|
+
#
|
|
19
|
+
# So a write verb does not promise to be careful. It states the delta it
|
|
20
|
+
# intends, computes the new text purely, and hands both to this module,
|
|
21
|
+
# which compares the LINE MULTISETS and refuses when the actual delta is not
|
|
22
|
+
# the intended one. A Ruby function that provably cannot drop a line is the
|
|
23
|
+
# remedy for a heredoc that can, and the difference between the two is that
|
|
24
|
+
# this one is checked.
|
|
25
|
+
#
|
|
26
|
+
# Pure: text in, findings out. No disk, no bundle, no dates.
|
|
27
|
+
module Conserve
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# `added:` / `removed:` are the lines the caller means to add and take
|
|
31
|
+
# away. `moved:` is the third shape, and it is invisible to a multiset by
|
|
32
|
+
# construction — a line that changed sections is neither added nor
|
|
33
|
+
# removed — so it is asserted directly: still there before, still there
|
|
34
|
+
# after. Without that, "move it" and "delete it and add it back" and "do
|
|
35
|
+
# nothing" are the same claim.
|
|
36
|
+
#
|
|
37
|
+
# Returns findings; empty means the file may be written.
|
|
38
|
+
def check(before, after, added: [], removed: [], moved: [])
|
|
39
|
+
was = counts(before)
|
|
40
|
+
now = counts(after)
|
|
41
|
+
|
|
42
|
+
findings = delta_findings(subtract(now, was), counts_of(added), "added") +
|
|
43
|
+
delta_findings(subtract(was, now), counts_of(removed), "removed")
|
|
44
|
+
|
|
45
|
+
Array(moved).each do |line|
|
|
46
|
+
key = normalize(line)
|
|
47
|
+
findings << "conservation: the line to move is not in the file as it stands: #{key}" unless was[key]
|
|
48
|
+
findings << "conservation: the line to move is gone from the result: #{key}" unless now[key]
|
|
49
|
+
end
|
|
50
|
+
findings
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One direction of the comparison, both ways round: what happened and
|
|
54
|
+
# was not intended, and what was intended and did not happen. Both are
|
|
55
|
+
# refusals — an edit that did less than it said is as much a lie as one
|
|
56
|
+
# that did more, and the second is how a promotion silently no-ops.
|
|
57
|
+
def delta_findings(actual, intended, verb)
|
|
58
|
+
findings = subtract(actual, intended).map do |line, n|
|
|
59
|
+
"conservation: #{n} line(s) #{verb} that the edit did not intend: #{line}"
|
|
60
|
+
end
|
|
61
|
+
subtract(intended, actual).each do |line, n|
|
|
62
|
+
findings << "conservation: #{n} line(s) the edit meant to have #{verb} are not: #{line}"
|
|
63
|
+
end
|
|
64
|
+
findings
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Trailing newline stripped, nothing else. Whitespace inside a line is
|
|
68
|
+
# content on a board — the leading `- ` is the counters' own grammar —
|
|
69
|
+
# and normalising it away would let an edit reindent the file past a
|
|
70
|
+
# guard whose whole job is to notice.
|
|
71
|
+
def normalize(line)
|
|
72
|
+
line.to_s.chomp
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def counts(text)
|
|
76
|
+
counts_of(text.to_s.lines)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def counts_of(lines)
|
|
80
|
+
Array(lines).each_with_object({}) do |line, acc|
|
|
81
|
+
key = normalize(line)
|
|
82
|
+
acc[key] = (acc[key] || 0) + 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def subtract(left, right)
|
|
87
|
+
left.each_with_object({}) do |(line, n), acc|
|
|
88
|
+
surplus = n - (right[line] || 0)
|
|
89
|
+
acc[line] = surplus if surplus.positive?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The hook event: the only place this checker parses input it did not write.
|
|
6
|
+
#
|
|
7
|
+
# An event it cannot read is enforcement that did not run, so the failure is
|
|
8
|
+
# recorded rather than swallowed. The first cut rescued the parse error into
|
|
9
|
+
# an empty hash, every guard read an empty path, and the check passed — the
|
|
10
|
+
# same shape as the `jq` hole this checker exists to close, rebuilt in Ruby.
|
|
11
|
+
# Whoever calls it decides what to do with the failure; nobody gets to not
|
|
12
|
+
# know about it.
|
|
13
|
+
class Event
|
|
14
|
+
attr_reader :parse_error
|
|
15
|
+
|
|
16
|
+
def self.from_stdin(io = $stdin)
|
|
17
|
+
new(io.read)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(raw)
|
|
21
|
+
@data = {}
|
|
22
|
+
@parse_error = nil
|
|
23
|
+
|
|
24
|
+
# The same defense Pro.read_text gives files, applied to the one
|
|
25
|
+
# input that arrives as a stream: forced to UTF-8 and scrubbed. Stdin
|
|
26
|
+
# under a bare locale (common in CI and git hooks) arrives tagged
|
|
27
|
+
# US-ASCII, and a clean em dash in an edit's new_string then raised
|
|
28
|
+
# out of the very first string operation — above the dispatch rescue,
|
|
29
|
+
# so ruby exited 1, which the hook protocol reads as NON-BLOCKING.
|
|
30
|
+
# The guard's own input was the bypass.
|
|
31
|
+
text = raw.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
32
|
+
if text.strip.empty?
|
|
33
|
+
@parse_error = "the hook received no event on stdin"
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
parsed = JSON.parse(text)
|
|
39
|
+
if parsed.is_a?(Hash)
|
|
40
|
+
@data = parsed
|
|
41
|
+
else
|
|
42
|
+
@parse_error = "the event was #{parsed.class}, not a JSON object"
|
|
43
|
+
end
|
|
44
|
+
rescue JSON::ParserError => e
|
|
45
|
+
@parse_error = "the event was not parseable JSON (#{e.message.split("\n").first})"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_error?
|
|
50
|
+
!@parse_error.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tool_name
|
|
54
|
+
@data["tool_name"].to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop_hook_active?
|
|
58
|
+
@data["stop_hook_active"] == true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tool_input
|
|
62
|
+
@data["tool_input"].is_a?(Hash) ? @data["tool_input"] : {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def file_path
|
|
66
|
+
tool_input["file_path"].to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Bash's payload. Named for what it is rather than reached for through
|
|
70
|
+
# tool_input at three call sites.
|
|
71
|
+
def command
|
|
72
|
+
tool_input["command"].to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cwd
|
|
76
|
+
raw = @data["cwd"].to_s
|
|
77
|
+
raw.empty? ? Dir.pwd : raw
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Both spellings are read on purpose. Claude Code's Edit/MultiEdit send
|
|
81
|
+
# `new_string`; the shell shims this replaces only ever looked for
|
|
82
|
+
# `new_str`, so the `verified:` guard passed every real edit it was built
|
|
83
|
+
# to stop and only ever refused the synthetic JSON in its own drill. A gate
|
|
84
|
+
# tested exclusively by the shape it was written against is not tested.
|
|
85
|
+
def added_text
|
|
86
|
+
parts = [ tool_input["new_string"], tool_input["new_str"], tool_input["content"] ]
|
|
87
|
+
edits = tool_input["edits"]
|
|
88
|
+
if edits.is_a?(Array)
|
|
89
|
+
parts.concat(edits.map { |e| e.is_a?(Hash) ? [ e["new_string"], e["new_str"] ] : nil })
|
|
90
|
+
end
|
|
91
|
+
parts.flatten.compact.join("\n")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|