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,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