okf-pro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/contract/containment-directions.md +107 -0
  3. data/.okf/contract/exit-codes.md +42 -0
  4. data/.okf/contract/gates-only-at-the-hook-door.md +39 -0
  5. data/.okf/contract/index.md +10 -0
  6. data/.okf/contract/silent-skips.md +78 -0
  7. data/.okf/contract/telemetry-does-not-lie.md +218 -0
  8. data/.okf/contract/the-contract.md +43 -0
  9. data/.okf/design/a-comment-is-not-an-implementation.md +85 -0
  10. data/.okf/design/a-rule-you-can-walk-past.md +68 -0
  11. data/.okf/design/derivation-that-writes.md +161 -0
  12. data/.okf/design/failure-modes.md +43 -0
  13. data/.okf/design/index.md +18 -0
  14. data/.okf/design/lineage.md +95 -0
  15. data/.okf/design/structure-laws.md +46 -0
  16. data/.okf/design/the-residue.md +44 -0
  17. data/.okf/design/three-laws.md +71 -0
  18. data/.okf/design/three-pillars.md +48 -0
  19. data/.okf/index.md +31 -0
  20. data/.okf/log.md +74 -0
  21. data/.okf/scaffold/collisions-and-refusals.md +76 -0
  22. data/.okf/scaffold/index.md +8 -0
  23. data/.okf/scaffold/no-date-ships.md +41 -0
  24. data/.okf/scaffold/ownership-not-subject.md +63 -0
  25. data/.okf/scaffold/the-adopters-manual.md +114 -0
  26. data/.okf/seam/bundler-scoping.md +45 -0
  27. data/.okf/seam/identity-not-existence.md +67 -0
  28. data/.okf/seam/index.md +8 -0
  29. data/.okf/seam/the-wrapper.md +42 -0
  30. data/.okf/seam/three-fail-opens.md +44 -0
  31. data/.okf/testing/drills-over-units.md +41 -0
  32. data/.okf/testing/fixture-is-a-client.md +35 -0
  33. data/.okf/testing/index.md +4 -0
  34. data/.okf/trust/index.md +7 -0
  35. data/.okf/trust/read-owed-rule.md +45 -0
  36. data/.okf/trust/scalar-verified.md +48 -0
  37. data/CHANGELOG.md +187 -0
  38. data/LICENSE.txt +201 -0
  39. data/NOTICE +10 -0
  40. data/README.md +383 -0
  41. data/lib/okf/plugin.rb +148 -0
  42. data/lib/okf/pro/attestation.rb +62 -0
  43. data/lib/okf/pro/audit.rb +161 -0
  44. data/lib/okf/pro/board/edit.rb +201 -0
  45. data/lib/okf/pro/board.rb +291 -0
  46. data/lib/okf/pro/budget.rb +144 -0
  47. data/lib/okf/pro/bundle_root.rb +241 -0
  48. data/lib/okf/pro/cli.rb +914 -0
  49. data/lib/okf/pro/closing.rb +206 -0
  50. data/lib/okf/pro/conformance.rb +127 -0
  51. data/lib/okf/pro/conserve.rb +94 -0
  52. data/lib/okf/pro/event.rb +95 -0
  53. data/lib/okf/pro/friction.rb +262 -0
  54. data/lib/okf/pro/guards.rb +92 -0
  55. data/lib/okf/pro/log/edit.rb +68 -0
  56. data/lib/okf/pro/log.rb +162 -0
  57. data/lib/okf/pro/pairing.rb +271 -0
  58. data/lib/okf/pro/reconcile.rb +76 -0
  59. data/lib/okf/pro/records.rb +90 -0
  60. data/lib/okf/pro/scaffold.rb +258 -0
  61. data/lib/okf/pro/shell_guard.rb +164 -0
  62. data/lib/okf/pro/snapshot.rb +156 -0
  63. data/lib/okf/pro/state.rb +174 -0
  64. data/lib/okf/pro/target.rb +91 -0
  65. data/lib/okf/pro/template/gem/.claude/hooks/run +119 -0
  66. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/SKILL.md +233 -0
  67. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/attribution.md +46 -0
  68. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/board.md +55 -0
  69. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/closing.md +21 -0
  70. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/frontmatter.md +73 -0
  71. data/lib/okf/pro/template/gem/.claude/skills/okf-pro/guides/okf-rules.md +25 -0
  72. data/lib/okf/pro/template/gem/.githooks/pre-commit +68 -0
  73. data/lib/okf/pro/template/gem/.github/workflows/okf-pro.yml +69 -0
  74. data/lib/okf/pro/template/seed/.claude/settings.json +24 -0
  75. data/lib/okf/pro/template/seed/.okf/areas/corpus.md +73 -0
  76. data/lib/okf/pro/template/seed/.okf/areas/index.md +6 -0
  77. data/lib/okf/pro/template/seed/.okf/board.md +21 -0
  78. data/lib/okf/pro/template/seed/.okf/glossary/index.md +13 -0
  79. data/lib/okf/pro/template/seed/.okf/index.md +30 -0
  80. data/lib/okf/pro/template/seed/.okf/journal/index.md +11 -0
  81. data/lib/okf/pro/template/seed/.okf/learnings/index.md +13 -0
  82. data/lib/okf/pro/template/seed/.okf/log.md +10 -0
  83. data/lib/okf/pro/template/seed/.okf/projects/index.md +23 -0
  84. data/lib/okf/pro/template/seed/.okf/reference/index.md +16 -0
  85. data/lib/okf/pro/template/seed/.okf/roadmap.md +33 -0
  86. data/lib/okf/pro/template/seed/.tmp/.gitkeep +0 -0
  87. data/lib/okf/pro/template/seed/CLAUDE.md +61 -0
  88. data/lib/okf/pro/template/seed/README.md +488 -0
  89. data/lib/okf/pro/template/seed/gitignore +17 -0
  90. data/lib/okf/pro/version.rb +7 -0
  91. data/lib/okf/pro/writes.rb +560 -0
  92. data/lib/okf/pro.rb +198 -0
  93. metadata +169 -0
@@ -0,0 +1,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