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,560 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
module Pro
|
|
5
|
+
# The mechanical writers — the shapes with exactly one correct form.
|
|
6
|
+
#
|
|
7
|
+
# WHAT THESE ARE FOR. A dated Inbox line, a promotion, the day's journal
|
|
8
|
+
# file, the three mechanical moves of closure: each has one right answer,
|
|
9
|
+
# and each was being reconstructed from prose on every use — a third of a
|
|
10
|
+
# measured session's tool output was the skill's guides, read to learn the
|
|
11
|
+
# grammar of a line. Grammar a Ruby function knows is grammar nobody has to
|
|
12
|
+
# re-read.
|
|
13
|
+
#
|
|
14
|
+
# THE WRITE CONTRACT, and it is the whole design:
|
|
15
|
+
#
|
|
16
|
+
# Additive and targeted, never regenerative. A write verb may append a
|
|
17
|
+
# line or edit the line it was given. No verb rewrites a file it did not
|
|
18
|
+
# fully derive from that file's own prior contents. A writer never
|
|
19
|
+
# satisfies its own gate.
|
|
20
|
+
#
|
|
21
|
+
# Every method below is the same four steps: read, transform PURELY (in
|
|
22
|
+
# Board::Edit or Log::Edit, which cannot touch the disk), state the delta
|
|
23
|
+
# to `Conserve` and refuse if the actual delta differs, then write. The
|
|
24
|
+
# refusal is exit 2 and nothing lands — fail-closed applied to writes.
|
|
25
|
+
#
|
|
26
|
+
# TWO SAFETY PROPERTIES, held by construction rather than by guarding —
|
|
27
|
+
# necessary, because a verb invoked through Bash is seen by neither
|
|
28
|
+
# `guard-verified` (Edit/Write only) nor `shell-guard` (no mutator pattern
|
|
29
|
+
# in `okf pro capture`):
|
|
30
|
+
#
|
|
31
|
+
# 1. No verb interpolates agent-supplied text into frontmatter, ever.
|
|
32
|
+
# Agent text reaches a board line body or a journal entry body and
|
|
33
|
+
# nowhere else — and a newline in it is refused rather than escaped,
|
|
34
|
+
# because a text that spans lines is a text that can carry a `---`.
|
|
35
|
+
# 2. Selectors are keyed, never positional, and refuse on ambiguity.
|
|
36
|
+
#
|
|
37
|
+
# And one thing they deliberately do NOT do: `snapshot` gains no `--write`.
|
|
38
|
+
# A writer and a checker sharing a code path agree trivially and prove
|
|
39
|
+
# nothing, so the stop gate keeps its independent read.
|
|
40
|
+
module Writes
|
|
41
|
+
# What every verb hands back: whether it may exit 0, and the lines to
|
|
42
|
+
# print. Failures print to stderr and exit 2; the caller does not have to
|
|
43
|
+
# know which message means which.
|
|
44
|
+
Result = Struct.new(:ok, :messages)
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def ok(*messages)
|
|
49
|
+
Result.new(true, messages.flatten)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def refuse(*messages)
|
|
53
|
+
Result.new(false, messages.flatten)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ── capture ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
# `BundleRoot.resolve` accepts an index beside EITHER core file, so a
|
|
59
|
+
# bundle can legitimately resolve with no board at all. Every verb below
|
|
60
|
+
# edits one, and "could not run (Errno::ENOENT)" is a true answer to the
|
|
61
|
+
# wrong question.
|
|
62
|
+
def no_board(root, verb)
|
|
63
|
+
refuse("okf pro #{verb} — #{root} has no board.md to edit. `okf pro audit` reports the " \
|
|
64
|
+
"missing core file; this verb will not create one, because a board written by a " \
|
|
65
|
+
"verb is a board nobody decided the shape of.")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def board?(root)
|
|
69
|
+
File.file?(File.join(root, "board.md"))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def capture(root, text, today: Date.today)
|
|
73
|
+
return no_board(root, "capture") unless board?(root)
|
|
74
|
+
|
|
75
|
+
escape = escaping(root, "capture", "board.md")
|
|
76
|
+
return escape if escape
|
|
77
|
+
|
|
78
|
+
body = text.to_s
|
|
79
|
+
return refuse("okf pro capture — takes the words to capture: `okf pro capture \"what you heard\"`.") if body.strip.empty?
|
|
80
|
+
|
|
81
|
+
if body.include?("\n")
|
|
82
|
+
return refuse("okf pro capture — the text spans lines, and a capture is one line: the Inbox " \
|
|
83
|
+
"counter reads a line at a time, and a multi-line write is the one shape that " \
|
|
84
|
+
"could carry a `---` into a file this verb is not allowed to restructure.")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
board_path = File.join(root, "board.md")
|
|
88
|
+
before = Pro.read_text(board_path)
|
|
89
|
+
line = ::OKF::Pro::Board::Edit.capture_line(body, today)
|
|
90
|
+
after, error = ::OKF::Pro::Board::Edit.append_to_section(before, "Inbox", line)
|
|
91
|
+
return refuse("okf pro capture — #{error}") if error
|
|
92
|
+
|
|
93
|
+
commit(board_path, before, after, "capture", added: [ line ]) do
|
|
94
|
+
[ "okf pro capture — one line added to Inbox:", " #{line}" ]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ── promote / demote ─────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
PROMOTE_FROM = %w[Inbox Backlog].freeze
|
|
101
|
+
|
|
102
|
+
def promote(root, selector)
|
|
103
|
+
return no_board(root, "promote") unless board?(root)
|
|
104
|
+
|
|
105
|
+
escape = escaping(root, "promote", "board.md")
|
|
106
|
+
return escape if escape
|
|
107
|
+
|
|
108
|
+
board_path = File.join(root, "board.md")
|
|
109
|
+
before = Pro.read_text(board_path)
|
|
110
|
+
row, error = ::OKF::Pro::Board::Edit.select(Board.rows(before), selector, sections: PROMOTE_FROM)
|
|
111
|
+
return refuse("okf pro promote — #{error}") if error
|
|
112
|
+
|
|
113
|
+
budget = Board.budget(before)
|
|
114
|
+
return refuse(no_header("promote")) if budget.nil?
|
|
115
|
+
|
|
116
|
+
count = Board.count(before, "In flight")
|
|
117
|
+
if count + 1 > budget.cap
|
|
118
|
+
return refuse("okf pro promote — RULE 3: #{count} in flight against a cap of #{budget.cap}, " \
|
|
119
|
+
"so promoting makes #{count + 1}. Promotion requires demotion (`okf pro demote`), " \
|
|
120
|
+
"or a visible renegotiation of the cap — which is journal-worthy, and yours to make.")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
move(board_path, before, row, "In flight", count + 1, "promote")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def demote(root, selector)
|
|
127
|
+
return no_board(root, "demote") unless board?(root)
|
|
128
|
+
|
|
129
|
+
escape = escaping(root, "demote", "board.md")
|
|
130
|
+
return escape if escape
|
|
131
|
+
|
|
132
|
+
board_path = File.join(root, "board.md")
|
|
133
|
+
before = Pro.read_text(board_path)
|
|
134
|
+
row, error = ::OKF::Pro::Board::Edit.select(Board.rows(before), selector, sections: [ "In flight" ])
|
|
135
|
+
return refuse("okf pro demote — #{error}") if error
|
|
136
|
+
|
|
137
|
+
return refuse(no_header("demote")) if Board.budget(before).nil?
|
|
138
|
+
|
|
139
|
+
move(board_path, before, row, "Backlog", Board.count(before, "In flight") - 1, "demote")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# One line between two sections, and the budget face kept truthful in the
|
|
143
|
+
# same write. The face is not decoration: `cap-check` refuses a header
|
|
144
|
+
# that disagrees with the section under it, so a move that left it stale
|
|
145
|
+
# would hand the next edit somebody else's refusal.
|
|
146
|
+
def move(board_path, before, row, to_section, declared, verb)
|
|
147
|
+
moved, error = ::OKF::Pro::Board::Edit.move_line(before, row.text, to_section)
|
|
148
|
+
return refuse("okf pro #{verb} — #{error}") if error
|
|
149
|
+
|
|
150
|
+
after, old_header, new_header = ::OKF::Pro::Board::Edit.set_declared(moved, declared)
|
|
151
|
+
return refuse(no_header(verb)) if after.nil?
|
|
152
|
+
|
|
153
|
+
commit(board_path, before, after, verb,
|
|
154
|
+
moved: [ row.text ],
|
|
155
|
+
added: [ new_header ].compact,
|
|
156
|
+
removed: [ old_header ].compact) do
|
|
157
|
+
[ "okf pro #{verb} — moved from #{row.section} to #{to_section}:",
|
|
158
|
+
" #{row.text}",
|
|
159
|
+
new_header ? " header now reads #{new_header.strip}" : nil,
|
|
160
|
+
*dormancy_note(to_section) ].compact
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def no_header(verb)
|
|
165
|
+
"okf pro #{verb} — board.md has lost its 'In flight: k/CAP' header, which is Rule 3's " \
|
|
166
|
+
"visible budget and this verb's only source for the cap. Restore it first; guessing a " \
|
|
167
|
+
"cap is how a budget stops meaning anything."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The judgment half, said out loud rather than done. A promoted demand
|
|
171
|
+
# owes a next action and a journal line; neither is mechanical, and a
|
|
172
|
+
# verb that wrote either would be writing prose.
|
|
173
|
+
def dormancy_note(to_section)
|
|
174
|
+
return [] unless to_section == "In flight"
|
|
175
|
+
|
|
176
|
+
[ " Owed, and yours: a next-action line and a journal entry linking it — a promotion nobody",
|
|
177
|
+
" journaled is a slot the dormancy question cannot see moving." ]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ── journal open ─────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
# The seeded index's own sentence. Removed only when it is still exactly
|
|
183
|
+
# that — an adopter who reworded it owns their words, and a verb that
|
|
184
|
+
# pattern-matched near-misses would be editing prose it did not write.
|
|
185
|
+
EMPTY_JOURNAL_NOTE = "Nothing recorded yet. The first entry is the first day this bundle is used,\n" \
|
|
186
|
+
"dated by you, not by the template.\n"
|
|
187
|
+
|
|
188
|
+
# `no_board`'s policy, for the other seeded file a verb edits. An index
|
|
189
|
+
# rebuilt from the one line this verb knows how to write comes back
|
|
190
|
+
# holding only that line — the `# Journal` heading and the seeded prose
|
|
191
|
+
# gone — which is regeneration wearing an append's clothes, and the shape
|
|
192
|
+
# constraint 8 forbids outright.
|
|
193
|
+
def no_index(root)
|
|
194
|
+
refuse("okf pro journal open — #{root}/journal/index.md does not exist, and this verb " \
|
|
195
|
+
"adds a line to that index rather than writing one: an index rebuilt from the " \
|
|
196
|
+
"single line it knows would lose the heading and the prose around it. Restore it " \
|
|
197
|
+
"first — a `# Journal` heading is all this verb needs — because every day already " \
|
|
198
|
+
"written is an orphan until it is back, which is what `okf pro audit` reports.")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def journal_open(root, today: Date.today)
|
|
202
|
+
day = today.to_s
|
|
203
|
+
entry_path = File.join(root, "journal", "#{day}.md")
|
|
204
|
+
index_path = File.join(root, "journal", "index.md")
|
|
205
|
+
return refuse("okf pro journal open — #{root}/journal/ does not exist.") unless File.directory?(File.dirname(entry_path))
|
|
206
|
+
return no_index(root) unless File.file?(index_path)
|
|
207
|
+
|
|
208
|
+
escape = escaping(root, "journal open", "journal/index.md", "journal/#{day}.md")
|
|
209
|
+
return escape if escape
|
|
210
|
+
|
|
211
|
+
before = Pro.read_text(index_path)
|
|
212
|
+
indexed = before.include?("(#{day}.md)")
|
|
213
|
+
exists = File.exist?(entry_path)
|
|
214
|
+
|
|
215
|
+
if exists && indexed
|
|
216
|
+
return ok("okf pro journal open — journal/#{day}.md is already open. Write the day into it " \
|
|
217
|
+
"with Edit; this verb only creates the file and its index line.")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
unless indexed
|
|
221
|
+
after, added, removed = journal_index(before, day)
|
|
222
|
+
findings = Conserve.check(before, after, added: added, removed: removed)
|
|
223
|
+
return refuse(conservation_refusal("journal open", index_path, findings)) unless findings.empty?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A file that did not exist has nothing to conserve — the guard
|
|
227
|
+
# compares a delta against a prior text, and there is none. The
|
|
228
|
+
# conservation that matters here is the INDEX's, which did exist.
|
|
229
|
+
#
|
|
230
|
+
# Two writes, and atomicity stated honestly, exactly as `Scaffold` does:
|
|
231
|
+
# each file is written atomically and the PAIR is not. The residue of a
|
|
232
|
+
# half-done pair is a day with no index line, which is why the two
|
|
233
|
+
# conditions above are asked SEPARATELY rather than off `File.exist?`
|
|
234
|
+
# alone: re-running the verb finishes the half that did not land, and
|
|
235
|
+
# a fixed-up state repairs itself instead of needing a maintainer.
|
|
236
|
+
Scaffold.write_atomically(entry_path, journal_entry(day), false) unless exists
|
|
237
|
+
Scaffold.write_atomically(index_path, after, false) unless indexed
|
|
238
|
+
ok("okf pro journal open — journal/#{day}.md #{exists ? "was already there and is now" : "created and"} indexed.",
|
|
239
|
+
" The day itself is yours to write: what happened, what it meant, what was decided.")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The delta is declared NET, because that is the only delta `Conserve`
|
|
243
|
+
# can see: it compares line multisets, so a blank line removed and then
|
|
244
|
+
# added back is invisible to it and undeclarable. Hence no tidying pass —
|
|
245
|
+
# the note comes out, the entry goes on the end, and a separator is added
|
|
246
|
+
# only where there is not one already.
|
|
247
|
+
def journal_index(before, day)
|
|
248
|
+
lines = Pro.newline_terminated(before).lines
|
|
249
|
+
note = EMPTY_JOURNAL_NOTE.lines
|
|
250
|
+
at = sublist_index(lines, note)
|
|
251
|
+
removed = at ? lines.slice!(at, note.size) : []
|
|
252
|
+
|
|
253
|
+
entry = "* [#{day}](#{day}.md) - the day's record.\n"
|
|
254
|
+
added = []
|
|
255
|
+
added << "\n" unless lines.empty? || lines.last.strip.empty? || lines.last.start_with?("*")
|
|
256
|
+
added << entry
|
|
257
|
+
lines.concat(added)
|
|
258
|
+
[ lines.join, added, removed ]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def sublist_index(lines, sub)
|
|
262
|
+
return nil if sub.empty?
|
|
263
|
+
|
|
264
|
+
(0..(lines.size - sub.size)).find { |i| lines[i, sub.size] == sub }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def journal_entry(day)
|
|
268
|
+
<<~ENTRY
|
|
269
|
+
---
|
|
270
|
+
type: Journal Entry
|
|
271
|
+
title: "#{day}"
|
|
272
|
+
description: What happened on #{day}, what it meant, and what was decided.
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
# #{day}
|
|
276
|
+
|
|
277
|
+
<!-- What happened, what it meant, what was decided. Delete this line. -->
|
|
278
|
+
ENTRY
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# ── close ────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
# WHAT IT WRITES IS DECIDED BEFORE ANYTHING IS WRITTEN.
|
|
284
|
+
#
|
|
285
|
+
# Three files change, and `Scaffold.write_atomically` makes each write
|
|
286
|
+
# atomic and the SET not. So the ordering is the safety: every check runs
|
|
287
|
+
# and every new text is computed — purely, in `Board::Edit` and
|
|
288
|
+
# `Log::Edit`, which cannot touch the disk — and only then does anything
|
|
289
|
+
# land. Checking as it went left a real half-closed bundle: a board that
|
|
290
|
+
# had lost its budget header refused at step two with the index already
|
|
291
|
+
# marked, so the project read as closed while its board line survived and
|
|
292
|
+
# no log entry existed, and the message said nothing had happened.
|
|
293
|
+
#
|
|
294
|
+
# The residue of a crash mid-sequence is still real and still visible —
|
|
295
|
+
# `okf pro audit` reports the pairing failure — but a REFUSAL now writes
|
|
296
|
+
# nothing at all, which is what the message claims.
|
|
297
|
+
Plan = Struct.new(:path, :text)
|
|
298
|
+
|
|
299
|
+
def close(root, project, today: Date.today)
|
|
300
|
+
return no_board(root, "close") unless board?(root)
|
|
301
|
+
|
|
302
|
+
# The three spellings `Board::Edit.by_target` treats as one commitment
|
|
303
|
+
# are one commitment here too — `alpha`, `/projects/alpha/`, and the
|
|
304
|
+
# `/projects/alpha/index.md` a board line actually carries, which is
|
|
305
|
+
# what `okf pro board` prints. A verb that refuses the string it just
|
|
306
|
+
# showed you is a verb you stop trusting.
|
|
307
|
+
#
|
|
308
|
+
# Dropping the index filename is not the normalisation the check below
|
|
309
|
+
# forbids: that one is about a path that LEAVES `projects/`, and this
|
|
310
|
+
# runs before it, so `/projects/../../etc/index.md` still arrives at
|
|
311
|
+
# `../../etc` and is still refused.
|
|
312
|
+
slug = project.to_s.sub(%r{\A/?projects/}, "").chomp("/").sub(%r{/index\.md\z}, "")
|
|
313
|
+
|
|
314
|
+
# One directory name, and nothing that can leave `projects/`. This verb
|
|
315
|
+
# marks a file's first line and removes board lines, and `File.join`
|
|
316
|
+
# resolves `..` happily — `okf pro close ../../somewhere` would put a
|
|
317
|
+
# closure marker on a stranger's index. A project is one segment by the
|
|
318
|
+
# structure's own rule (`projects/<slug>/`), so anything else is a
|
|
319
|
+
# refusal rather than a normalisation: quietly rewriting a path the
|
|
320
|
+
# caller gave is how a traversal becomes an edit nobody sees.
|
|
321
|
+
unless slug.match?(%r{\A[^/\\]+\z}) && ![ ".", ".." ].include?(slug)
|
|
322
|
+
return refuse("okf pro close — '#{project}' is not a project name. A project is one " \
|
|
323
|
+
"directory under projects/, so its name carries no slash and is not a " \
|
|
324
|
+
"relative path. `okf pro state` lists the open ones.")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
dir = File.join(root, "projects", slug)
|
|
328
|
+
return refuse("okf pro close — #{root}/projects/#{slug}/ does not exist. `okf pro state` lists the open projects.") unless File.directory?(dir)
|
|
329
|
+
|
|
330
|
+
index_path = File.join(dir, "index.md")
|
|
331
|
+
return refuse("okf pro close — projects/#{slug}/index.md does not exist, and closure is a marker on its first line.") unless File.file?(index_path)
|
|
332
|
+
|
|
333
|
+
escape = escaping(root, "close", "projects/#{slug}/index.md", "board.md", "log.md")
|
|
334
|
+
return escape if escape
|
|
335
|
+
|
|
336
|
+
board_path = File.join(root, "board.md")
|
|
337
|
+
log_path = File.join(root, "log.md")
|
|
338
|
+
already = Pairing.closed?(root, index_path)
|
|
339
|
+
lines = Board.rows(Pro.read_text(board_path)).select { |row| ::OKF::Pro::Board::Edit.by_target([ row ], slug).any? }
|
|
340
|
+
|
|
341
|
+
return ok("okf pro close — projects/#{slug} is already closed and carries no board line. Nothing to do.") if already && lines.empty?
|
|
342
|
+
|
|
343
|
+
plans = []
|
|
344
|
+
unless already
|
|
345
|
+
plan, refusal = plan_marker(index_path, slug, today)
|
|
346
|
+
return refusal if refusal
|
|
347
|
+
|
|
348
|
+
plans << plan
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
board_plan, refusal = plan_board(board_path, lines)
|
|
352
|
+
return refusal if refusal
|
|
353
|
+
|
|
354
|
+
plans << board_plan if board_plan
|
|
355
|
+
|
|
356
|
+
log_plan, refusal = plan_log(log_path, slug, lines.size, today)
|
|
357
|
+
return refusal if refusal
|
|
358
|
+
|
|
359
|
+
plans << log_plan
|
|
360
|
+
plans.each { |plan| Scaffold.write_atomically(plan.path, plan.text, false) }
|
|
361
|
+
|
|
362
|
+
ok([ "okf pro close — projects/#{slug}:",
|
|
363
|
+
already ? " index.md already carried the closure marker" : " index.md marked closed #{today}",
|
|
364
|
+
" #{lines.size} board line(s) removed",
|
|
365
|
+
" log.md entry added under #{today}",
|
|
366
|
+
*snapshot_owed(log_path, today),
|
|
367
|
+
"Owed, and yours: the durable part of this work belongs in learnings/ or glossary/ —",
|
|
368
|
+
"extract it now, or it is archived with the project and relearned later at full price." ])
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Said here because this verb is what creates the obligation. Writing a
|
|
372
|
+
# log entry gives the day a `## <date>` heading, and from that moment the
|
|
373
|
+
# audit — which the pre-commit door runs — asks that day for its Snapshot
|
|
374
|
+
# line. A verb that quietly turned the next commit into a refusal without
|
|
375
|
+
# naming the fix would be teaching its user that the gates are arbitrary.
|
|
376
|
+
def snapshot_owed(log_path, today)
|
|
377
|
+
return [] unless File.exist?(log_path)
|
|
378
|
+
return [] if Log.snapshot_line(Pro.read_text(log_path), today.to_s)
|
|
379
|
+
|
|
380
|
+
[ " #{today} now has a log heading and owes its Snapshot line — the audit the pre-commit",
|
|
381
|
+
" door runs asks the newest day for one. `okf pro snapshot` computes it." ]
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# THE DECISION THE CHECKER ALREADY MAKES, MADE AGAIN AT THE WRITE DOOR.
|
|
385
|
+
#
|
|
386
|
+
# `Pairing.closed?` reads this same index through `Pro.read_contained` and
|
|
387
|
+
# answers "open" when the link leaves the bundle. Nothing carried that
|
|
388
|
+
# answer across to the writer, which read and rewrote the path with the
|
|
389
|
+
# uncontained pair — so `okf pro close escapee` marked a stranger's
|
|
390
|
+
# `index.md`, exited 0, and left `okf pro audit` still reporting the
|
|
391
|
+
# project as neither on the board nor closed. Run twice it appended a
|
|
392
|
+
# second marker, so it was unbounded as well as wrong.
|
|
393
|
+
#
|
|
394
|
+
# `.okf/contract/containment-directions.md` is the rule and it decides
|
|
395
|
+
# this direction too: pick the one that leaves the demand visible. A
|
|
396
|
+
# refusal keeps the board line and keeps the project being asked about;
|
|
397
|
+
# a marker written outside the bundle is a stranger's file edited to
|
|
398
|
+
# satisfy a checker that will not read it back.
|
|
399
|
+
#
|
|
400
|
+
# Asked of the INDEX, not of the directory holding it, because containing
|
|
401
|
+
# the directory does not contain the file: an `index.md` symlinked out of
|
|
402
|
+
# a perfectly contained `projects/<slug>/` is read by `Pro.read_text`
|
|
403
|
+
# (which follows the link) and written back by `File.rename` (which does
|
|
404
|
+
# not), so the marker came from a stranger's title and landed as a real
|
|
405
|
+
# file inside the bundle. Containing the leaf contains both, because a
|
|
406
|
+
# directory that escapes takes every path under it with it.
|
|
407
|
+
#
|
|
408
|
+
# Unrescued, deliberately. `File.file?` above has already resolved the
|
|
409
|
+
# whole path, so a `SystemCallError` here is the ground moving under the
|
|
410
|
+
# check — and `write_verb` runs every writer inside `guarded`, which
|
|
411
|
+
# answers exactly that with exit 2 and "nothing was read or written". A
|
|
412
|
+
# rescue returning false would say the same thing in a message about
|
|
413
|
+
# symlinks, and would be a branch no fixture can reach.
|
|
414
|
+
def contained?(root, path)
|
|
415
|
+
::OKF::Path.under?(File.realpath(root), File.realpath(path))
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# EVERY file a verb reads and rewrites, not just the one that was found
|
|
419
|
+
# first. Containing `projects/<slug>/index.md` and leaving `board.md`,
|
|
420
|
+
# `log.md` and `journal/index.md` open left the identical hazard three
|
|
421
|
+
# files over: `okf pro capture` read a stranger's board through the link,
|
|
422
|
+
# appended, and renamed a temp over it — so the link became a real file
|
|
423
|
+
# inside the bundle carrying content the bundle never owned.
|
|
424
|
+
#
|
|
425
|
+
# Checked before anything is read, and only for a file that exists: a
|
|
426
|
+
# missing one is each verb's own refusal to make, with its own message.
|
|
427
|
+
def escaping(root, verb, *rels)
|
|
428
|
+
rels.each do |rel|
|
|
429
|
+
path = File.join(root, rel)
|
|
430
|
+
next unless File.exist?(path)
|
|
431
|
+
next if contained?(root, path)
|
|
432
|
+
|
|
433
|
+
return escape_refusal(verb, root, rel)
|
|
434
|
+
end
|
|
435
|
+
nil
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def escape_refusal(verb, root, rel)
|
|
439
|
+
refuse("okf pro #{verb} — #{rel} resolves outside #{root}, so this verb will not write " \
|
|
440
|
+
"there. `Pro.read_text` follows a symlink and the atomic rename does not, so the " \
|
|
441
|
+
"read would come from a file this bundle does not own and the write would replace " \
|
|
442
|
+
"the link with it. Every gate reads this path contained (`Pairing.closed?` answers " \
|
|
443
|
+
"\"open\" rather than read one), and a writer that did not would be the loosest " \
|
|
444
|
+
"door on the strictest file. Edit it by hand if the link is deliberate.")
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A markdown heading, and nothing else. The marker goes on the TITLE —
|
|
448
|
+
# that is the spelling the skill teaches and the only place `closed?`
|
|
449
|
+
# looks, since it reads three lines from the top.
|
|
450
|
+
#
|
|
451
|
+
# The check is the shape of the line, not merely whether the result
|
|
452
|
+
# satisfies `Pairing::MARKER`, and that distinction is the finding: an
|
|
453
|
+
# index carrying YAML frontmatter starts with `---`, and
|
|
454
|
+
# `Pairing.marker?("--- — closed 2026-08-17")` is TRUE — the regex needs
|
|
455
|
+
# only the word and a date. So the satisfied-marker check passed, the
|
|
456
|
+
# fence was destroyed, and the concept silently lost its `type`, `title`
|
|
457
|
+
# and `description` while `okf validate` still exited 0. `Conserve`
|
|
458
|
+
# cannot see it either: the mangling was the declared edit.
|
|
459
|
+
HEADING = /\A\#{1,6}[[:blank:]]+\S/.freeze
|
|
460
|
+
|
|
461
|
+
def plan_marker(index_path, slug, today)
|
|
462
|
+
before = Pro.read_text(index_path)
|
|
463
|
+
first = before.lines.first
|
|
464
|
+
return [ nil, refuse("okf pro close — projects/#{slug}/index.md is empty; there is no first line to mark.") ] if first.nil?
|
|
465
|
+
|
|
466
|
+
unless first.match?(HEADING)
|
|
467
|
+
return [ nil, refuse("okf pro close — projects/#{slug}/index.md does not open with a heading " \
|
|
468
|
+
"(#{first.strip.inspect}). Closure marks the title, and this verb will not " \
|
|
469
|
+
"restructure a file to find one: an index carrying YAML frontmatter opens " \
|
|
470
|
+
"with `---`, and a marker appended there would destroy the fence while " \
|
|
471
|
+
"still reading as closed. Give it a `# Title` first line, or mark it by hand.") ]
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
marked = closure_marker(first, today)
|
|
475
|
+
unless Pairing.marker?(marked)
|
|
476
|
+
return [ nil, refuse("okf pro close — marking projects/#{slug}/index.md would not produce a closure " \
|
|
477
|
+
"marker the checker accepts (`#{marked}`). Fix its first line first.") ]
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Block form: a title carrying `\1` or `\0` would otherwise be read as a
|
|
481
|
+
# backreference by the replacement string and splice the match back in.
|
|
482
|
+
after = before.sub(first) { "#{marked}\n" }
|
|
483
|
+
plan(index_path, before, after, "close", added: [ marked ], removed: [ first.chomp ])
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# The one place this gem WRITES the closure marker, and it is separate so
|
|
487
|
+
# the grammar can be pinned in all three directions at once: what the
|
|
488
|
+
# skill teaches, what `Pairing::MARKER` accepts, and what this emits. Two
|
|
489
|
+
# of those were already pinned to each other; a writer that emitted a
|
|
490
|
+
# fourth spelling would leave a project the checker still reads as open,
|
|
491
|
+
# with a sentence about closure in it.
|
|
492
|
+
def closure_marker(first_line, today)
|
|
493
|
+
"#{first_line.to_s.chomp.rstrip} — closed #{today}"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def plan_board(board_path, rows)
|
|
497
|
+
return [ nil, nil ] if rows.empty?
|
|
498
|
+
|
|
499
|
+
before = Pro.read_text(board_path)
|
|
500
|
+
after = before
|
|
501
|
+
rows.each { |row| after = ::OKF::Pro::Board::Edit.remove_line(after, row.text)[0] }
|
|
502
|
+
|
|
503
|
+
# The face follows the section, and ONLY when the section moved. A
|
|
504
|
+
# header that was already lying about a count this verb did not change
|
|
505
|
+
# is Rule 3's finding to raise, not this verb's to quietly correct —
|
|
506
|
+
# a writer that tidies what it was not asked about is regenerating.
|
|
507
|
+
added = []
|
|
508
|
+
removed = rows.map(&:text)
|
|
509
|
+
if rows.any? { |row| row.section == "In flight" }
|
|
510
|
+
after, old_header, new_header = ::OKF::Pro::Board::Edit.set_declared(after, Board.count(after, "In flight"))
|
|
511
|
+
return [ nil, refuse(no_header("close")) ] if after.nil?
|
|
512
|
+
|
|
513
|
+
if new_header
|
|
514
|
+
added << new_header
|
|
515
|
+
removed << old_header
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
plan(board_path, before, after, "close", removed: removed, added: added)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def plan_log(log_path, slug, removed, today)
|
|
522
|
+
before = File.exist?(log_path) ? Pro.read_text(log_path) : ""
|
|
523
|
+
line = "* Closed [/projects/#{slug}/](/projects/#{slug}/index.md) — #{removed} board line(s) removed."
|
|
524
|
+
after, added = ::OKF::Pro::Log::Edit.add_entry(before, today, line)
|
|
525
|
+
plan(log_path, before, after, "close", added: added)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# ── the one write ────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
# Guard, then write, in that order and nowhere else. Every verb above
|
|
531
|
+
# funnels through here, so "refuses rather than writing" is a property of
|
|
532
|
+
# one method rather than a habit five of them share.
|
|
533
|
+
def commit(path, before, after, verb, added: [], removed: [], moved: [])
|
|
534
|
+
planned, refusal = plan(path, before, after, verb, added: added, removed: removed, moved: moved)
|
|
535
|
+
return refusal if refusal
|
|
536
|
+
|
|
537
|
+
Scaffold.write_atomically(planned.path, planned.text, false)
|
|
538
|
+
ok(yield)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# The guard, without the write — for a verb that changes more than one
|
|
542
|
+
# file and must decide about all of them before it changes any.
|
|
543
|
+
# Returns `[Plan, nil]` or `[nil, Result]`.
|
|
544
|
+
def plan(path, before, after, verb, added: [], removed: [], moved: [])
|
|
545
|
+
findings = Conserve.check(before, after, added: added, removed: removed, moved: moved)
|
|
546
|
+
return [ nil, refuse(conservation_refusal(verb, path, findings)) ] unless findings.empty?
|
|
547
|
+
|
|
548
|
+
[ Plan.new(path, after), nil ]
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def conservation_refusal(verb, path, findings)
|
|
552
|
+
[ "okf pro #{verb} — refused, and #{path} is untouched. The edit this verb computed does not " \
|
|
553
|
+
"match the change it declared, which is the one failure a mechanical writer must not " \
|
|
554
|
+
"commit: a view rewritten with a line quietly gone is failure mode 07, and this check is " \
|
|
555
|
+
"why this verb cannot be it.",
|
|
556
|
+
*findings.map { |f| " #{f}" } ]
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|