@zalom/plastic 2.0.0-alpha.18 → 2.0.0-alpha.19

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 (45) hide show
  1. package/package.json +2 -2
  2. package/scripts/dashboard.rb +20 -0
  3. package/scripts/doctor.rb +120 -2
  4. package/scripts/end-intent +134 -8
  5. package/scripts/lib/action_graph_shim.rb +277 -0
  6. package/scripts/lib/atomic_write.rb +31 -0
  7. package/scripts/lib/graph_edges.rb +121 -0
  8. package/scripts/lib/graph_file.rb +246 -0
  9. package/scripts/lib/guarded_append.rb +155 -0
  10. package/scripts/lib/installer_core.rb +32 -0
  11. package/scripts/lib/node_file.rb +214 -0
  12. package/scripts/lib/node_ids.rb +99 -0
  13. package/scripts/lib/node_ledger.rb +377 -0
  14. package/scripts/lib/node_packet.rb +873 -0
  15. package/scripts/lib/outcome_report.rb +440 -0
  16. package/scripts/lib/packet_wrapper.rb +132 -0
  17. package/scripts/lib/ready_set.rb +462 -0
  18. package/scripts/lib/release_guard.rb +16 -0
  19. package/scripts/lib/report_screen.rb +122 -12
  20. package/scripts/lib/roadmap_queue.rb +161 -3
  21. package/scripts/lib/roadmap_savepoint.rb +26 -5
  22. package/scripts/lib/savepoint.rb +123 -12
  23. package/scripts/lib/work_graph_validator.rb +201 -0
  24. package/scripts/node-packet +92 -0
  25. package/scripts/node-transition +291 -0
  26. package/scripts/outcome-report +74 -0
  27. package/scripts/ready-set +126 -0
  28. package/scripts/release-check +118 -0
  29. package/scripts/report-screen +8 -1
  30. package/scripts/roadmap-savepoint +7 -0
  31. package/scripts/validate-work-graph +39 -0
  32. package/skills/auto/SKILL.md +2 -3
  33. package/skills/auto/references/human-report-contract.md +3 -2
  34. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  35. package/skills/intent-ending/SKILL.md +30 -19
  36. package/skills/intent-executing/SKILL.md +1 -1
  37. package/skills/releasing/SKILL.md +39 -0
  38. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  39. package/skills/releasing/references/release-lines.md +1 -1
  40. package/templates/graph.md +16 -0
  41. package/templates/node-decision.md +11 -0
  42. package/templates/node-research.md +11 -0
  43. package/templates/node-verify.md +13 -0
  44. package/templates/node-work.md +22 -0
  45. package/templates/outcome.md +8 -6
@@ -0,0 +1,277 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "node_file"
7
+ require_relative "savepoint"
8
+ require_relative "work_graph_validator"
9
+
10
+ # ActionGraphShim (intent 342, G9): a read-time backward shim that presents
11
+ # any intent directory's actions/*.md as a node graph, in the record shape
12
+ # NodeFile.parse returns, so 336, 338 and 339 read a legacy intent through
13
+ # the same call they already use for an authored graph.md (spec.md D1-D18).
14
+ #
15
+ # Writes nothing, anywhere, ever (D1). An authored graph.md always wins over
16
+ # the synthetic chain (D3) - the reverse of 334's forward shim, which
17
+ # resolves content and prefers the older, richer source. This one resolves
18
+ # structure, where an authored graph is the only real graph.
19
+ module ActionGraphShim
20
+ module_function
21
+
22
+ # D10's heading test: unchanged, four tokens, verified correct on the live
23
+ # store. Governs only whether a "## Files ..." heading itself is negated
24
+ # (e.g. "## Files you must NOT change"), never the section body.
25
+ FILES_NEGATION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't/i.freeze
26
+
27
+ # D19's body-truncation vocabulary: the four heading tokens plus the
28
+ # exclusion phrases the live corpus actually uses ("Out of bounds, owned
29
+ # by leads running in parallel right now:" matched none of the original
30
+ # four). Governs whether a body LINE truncates the files harvest -
31
+ # never the heading test above, which stays on FILES_NEGATION_RE.
32
+ FILES_EXCLUSION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't|\bout of bounds\b|\bout of scope\b|\bhands off\b|\bleave alone\b|\bexcluded\b/i.freeze
33
+
34
+ BACKTICK_RE = /`([^`]+)`/.freeze
35
+ PROVEN_BY_LABEL_RE = /\AS\d+\z/.freeze
36
+
37
+ # :authored when graph.md exists, :actions when it does not but the intent
38
+ # holds at least one real action file (D7's exact realness test, reused
39
+ # rather than reimplemented so the shim can never disagree with
40
+ # Savepoint.has_real_action? about whether an intent has work in it), :none
41
+ # otherwise. A directory that does not exist returns :none and never
42
+ # raises.
43
+ def shape(intent_dir)
44
+ return :none unless intent_dir && File.directory?(intent_dir.to_s)
45
+ return :authored if File.exist?(File.join(intent_dir, "graph.md"))
46
+ return :actions if Savepoint.has_real_files_in?("actions", intent_dir)
47
+
48
+ :none
49
+ end
50
+
51
+ # The seven-key hash GraphFile.parse returns (ok:, goal:, decisions:,
52
+ # graph:, status:, verify:, errors:), for whichever shape the directory
53
+ # is in. On :authored this is literally GraphFile.parse, unmodified (D3).
54
+ # On :actions the same hash is built from the action files, with goal:,
55
+ # decisions: and status: nil (a 2024 action file was never asked for
56
+ # them) and verify: a fixed reason so a trivial-bar check the caller may
57
+ # run stays self-consistent. On :none it is GraphFile.parse's own
58
+ # not-found failure, produced by delegating to it rather than
59
+ # reformatting the message ourselves.
60
+ def view(intent_dir)
61
+ case shape(intent_dir)
62
+ when :authored
63
+ GraphFile.parse(File.join(intent_dir, "graph.md"))
64
+ when :actions
65
+ synth = synthetic_graph(intent_dir)
66
+ {
67
+ ok: synth[:errors].empty?,
68
+ goal: nil,
69
+ decisions: nil,
70
+ graph: synth,
71
+ status: nil,
72
+ verify: { reason: "backward shim: legacy actions/ carry no verify node" },
73
+ errors: synth[:errors],
74
+ }
75
+ else
76
+ # 342 post-execution review, should-fix 5: a nil intent_dir must never
77
+ # raise. shape(nil) is already nil-guarded, so `view` and `needs`
78
+ # honor the same contract rather than raising a File.join TypeError.
79
+ return GraphFile.failure(["graph file not found: #{intent_dir.inspect}"]) unless intent_dir
80
+
81
+ GraphFile.parse(File.join(intent_dir, "graph.md"))
82
+ end
83
+ end
84
+
85
+ # Node records for either shape (D8), always carrying NodeFile.parse's own
86
+ # key set (ok:, node:, kind:, files:, budget:, body:, errors:) plus
87
+ # needs:, path: and proven_by:. [] on :none, never a raise on a real
88
+ # directory.
89
+ def nodes(intent_dir)
90
+ case shape(intent_dir)
91
+ when :authored
92
+ authored_nodes(intent_dir)
93
+ when :actions
94
+ synthetic_nodes(intent_dir)
95
+ else
96
+ []
97
+ end
98
+ end
99
+
100
+ # The needs targets for one node id, under either shape; [] for an id the
101
+ # graph does not declare, and [] for a directory with no graph at all.
102
+ def needs(intent_dir, node_id)
103
+ graph = view(intent_dir)[:graph]
104
+ return [] unless graph
105
+
106
+ (graph[:edges] || {})[node_id] || []
107
+ end
108
+
109
+ # --- authored shape -------------------------------------------------------
110
+
111
+ def authored_nodes(intent_dir)
112
+ parsed = GraphFile.parse(File.join(intent_dir, "graph.md"))
113
+ edges = parsed.dig(:graph, :edges) || {}
114
+
115
+ # 342 post-execution review, nit 8: sort_key numerically over the
116
+ # basename, the same key the synthetic chain uses, so an authored
117
+ # intent with ten or more nodes orders n10 after n9 rather than
118
+ # lexically between n1 and n2.
119
+ Dir.glob(File.join(intent_dir, "nodes", "*.md"))
120
+ .sort_by { |path| sort_key(File.basename(path)) }
121
+ .map do |path|
122
+ nf = NodeFile.parse(path)
123
+ nf.merge(
124
+ needs: edges[nf[:node]] || [],
125
+ path: path,
126
+ proven_by: proven_by_labels(nf[:body].to_s)
127
+ )
128
+ end
129
+ end
130
+
131
+ # --- actions shape ----------------------------------------------------------
132
+
133
+ # {nodes:, edges:, errors:} built directly over the minted chain, in the
134
+ # same shape GraphEdges.parse returns, rather than rendering a "## Graph"
135
+ # section and reparsing it.
136
+ def synthetic_graph(intent_dir)
137
+ ids = real_action_files(intent_dir).each_index.map { |i| "n#{i + 1}" }
138
+ edges = {}
139
+ ids.each_with_index { |id, i| edges[id] = i.zero? ? [] : [ids[i - 1]] }
140
+ { nodes: ids, edges: edges, errors: [] }
141
+ end
142
+
143
+ def synthetic_nodes(intent_dir)
144
+ files = real_action_files(intent_dir)
145
+ files.each_with_index.map do |path, i|
146
+ id = "n#{i + 1}"
147
+ needs_targets = i.zero? ? [] : ["n#{i}"]
148
+ begin
149
+ text = File.read(path)
150
+ {
151
+ ok: true,
152
+ node: id,
153
+ kind: "work",
154
+ files: files_section_paths(text),
155
+ budget: nil,
156
+ body: text,
157
+ errors: [],
158
+ needs: needs_targets,
159
+ path: path,
160
+ proven_by: proven_by_labels(text),
161
+ }
162
+ rescue StandardError => e
163
+ {
164
+ ok: false,
165
+ node: id,
166
+ kind: "work",
167
+ files: [],
168
+ budget: nil,
169
+ body: nil,
170
+ errors: ["could not read #{path}: #{e.message}"],
171
+ needs: needs_targets,
172
+ path: path,
173
+ proven_by: [],
174
+ }
175
+ end
176
+ end
177
+ end
178
+
179
+ # Real action files (File.file?, non-zero size, Savepoint.stage_file_present?
180
+ # - D7's own three-part predicate, applied per file since has_real_files_in?
181
+ # only answers true/false), ordered by the integer trailing the basename,
182
+ # falling back to the basename itself so the key is a total order over
183
+ # every basename shape the live store holds (D4).
184
+ def real_action_files(intent_dir)
185
+ Dir.glob(File.join(intent_dir.to_s, "actions", "*.md"))
186
+ .select { |f| File.file?(f) && File.size(f) > 0 && Savepoint.stage_file_present?(f) }
187
+ .sort_by { |f| sort_key(File.basename(f)) }
188
+ end
189
+
190
+ def sort_key(basename)
191
+ m = basename.match(/\d+/)
192
+ m ? [0, m[0].to_i, basename] : [1, 0, basename]
193
+ end
194
+
195
+ # The first heading whose leading token is "Files" (case-insensitively),
196
+ # unless the heading text carries a negation, in which case it is not a
197
+ # files section and the search continues (D10). Paths come out of that
198
+ # section's BODY through extract_files_paths, which is itself
199
+ # negation-aware and fence-aware (D17, D18). No qualifying heading gives
200
+ # [] (D9).
201
+ def files_section_paths(text)
202
+ NodeFile.split_by_headings(text).each do |heading, section|
203
+ next unless files_heading?(heading)
204
+
205
+ return extract_files_paths(section.to_s)
206
+ end
207
+ []
208
+ end
209
+
210
+ # Fence-aware (D18) and negation-aware over the section body, not only
211
+ # its heading (D17). D10 already stops a heading like "## Files you must
212
+ # NOT change" from being read as a files section at all; this handles
213
+ # the equally common case where a legitimate "## Files to touch" heading
214
+ # is followed, inside the same section, by an out-of-bounds paragraph -
215
+ # live on this intent's own dogfood fixture. A fenced block is dropped
216
+ # before anything else runs, reusing NodeFile.each_fence_line rather than
217
+ # a bare backtick scan, which is exactly what that helper exists to
218
+ # prevent.
219
+ #
220
+ # Lines are scanned in document order; harvesting stops at the first line
221
+ # an exclusion GOVERNS (D19), not merely mentions - a negation is a false
222
+ # positive as often as a real exclusion clause, since an action file
223
+ # routinely annotates an in-bounds path with a "do NOT rename it" aside.
224
+ # governs_exclusion? draws the line: the match governs when it begins
225
+ # before the line's first backtick (an exclusion clause introducing
226
+ # paths) or when the line carries no backtick at all (a bare warning
227
+ # sentence); it annotates, and the line is kept, when the match falls
228
+ # after the first backtick (a parenthetical about a path already named).
229
+ # Any span that still holds a newline is rejected as a backstop: a path
230
+ # is never more than one line.
231
+ def extract_files_paths(section)
232
+ kept = +""
233
+ NodeFile.each_fence_line(section) do |line, fenced|
234
+ next if fenced
235
+ break if governs_exclusion?(line)
236
+
237
+ kept << line
238
+ end
239
+ kept.scan(BACKTICK_RE).flatten.reject { |s| s.include?("\n") }.uniq
240
+ end
241
+
242
+ # True when a FILES_EXCLUSION_RE match on this line governs the paths on
243
+ # it rather than merely annotating one (D19).
244
+ def governs_exclusion?(line)
245
+ match = line.match(FILES_EXCLUSION_RE)
246
+ return false unless match
247
+
248
+ first_backtick = line.index("`")
249
+ first_backtick.nil? || match.begin(0) < first_backtick
250
+ end
251
+
252
+ def files_heading?(heading)
253
+ stripped = heading.to_s.sub(/\A#+\s*/, "")
254
+ first_token = stripped.split(/\s+/, 2).first.to_s.sub(/[^A-Za-z]+\z/, "")
255
+ return false unless first_token.casecmp?("Files")
256
+
257
+ !heading.to_s.match?(FILES_NEGATION_RE)
258
+ end
259
+
260
+ # The S\d+ tokens of a file's headings, in heading order, taking a
261
+ # heading only when it owns at least one table data row (D5) - the same
262
+ # table-owning rule WorkGraphValidator.has_valid_matrix? and ReportScreen
263
+ # already apply, reused unmodified rather than adapted, so a prose
264
+ # mention of an S-label never manufactures a Proven-by source (334's
265
+ # post-execution review defect, in the other direction).
266
+ def proven_by_labels(text)
267
+ labels = []
268
+ NodeFile.split_by_headings(text).each do |heading, section|
269
+ next unless NodeFile.table_rows(section).any?
270
+
271
+ WorkGraphValidator.heading_tokens(heading).each do |token|
272
+ labels << token if token.match?(PROVEN_BY_LABEL_RE)
273
+ end
274
+ end
275
+ labels
276
+ end
277
+ end
@@ -0,0 +1,31 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # AtomicWrite (intent 334, n2, D19r): a sibling-temp-plus-rename write, the
5
+ # same shape as Lock#write (scripts/lib/lock.rb) - a content write is never
6
+ # an in-place truncate, so a crash mid-write can never leave an empty or
7
+ # half-written target on disk. The temp file is a SIBLING in the same
8
+ # directory as the target, never a system tmpdir, because File.rename can
9
+ # raise EXDEV when the temp and the target live on different filesystems.
10
+ # The temp name ends in .lock (post-execution review, non-blocking 5), the
11
+ # same property Lock#write_temp_path documents: an orphan left by a crash
12
+ # between the write and the rename is covered by the store's existing
13
+ # *.lock gitignore rule rather than swept into its git add -A auto-commit.
14
+ #
15
+ # The renamer is injectable so the interrupted-write case (a rename that
16
+ # raises) is testable with dependency injection, never eval or a global
17
+ # (D19r).
18
+ module AtomicWrite
19
+ module_function
20
+
21
+ def write(path, content, renamer: File.method(:rename))
22
+ dir = File.dirname(path)
23
+ temp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{Time.now.to_f}.#{rand(0xFFFFFF)}.lock")
24
+ File.write(temp, content)
25
+ renamer.call(temp, path)
26
+ true
27
+ rescue StandardError
28
+ File.delete(temp) if temp && File.exist?(temp)
29
+ raise
30
+ end
31
+ end
@@ -0,0 +1,121 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # GraphEdges (intent 334, n1): the shared `needs` syntax and cycle-path check
5
+ # (327 D2r-D4r, D12r). Parses a "## Graph" section's edge lines into a node
6
+ # set and an edge map, the one parser an intent's graph.md and a roadmap's
7
+ # own ## Graph section both use unchanged (C1, fold A5): the same list-item
8
+ # grammar, the same root keyword, the same cycle walk. Deliberately loose
9
+ # about what an id looks like - a roadmap id is numeric ("334", "340a"), a
10
+ # node id carries a kind prefix ("n1") - the kind-prefix rule belongs to
11
+ # NodeFile and WorkGraphValidator, never here (D12r, fold A6).
12
+ #
13
+ # Grammar (D2r/D3r): a list item shaped "- <id> needs <target> [<target>
14
+ # ...]", ending at end of line. The single literal target "nothing" declares
15
+ # a root and must be the ONLY target on the line. Any line that does not
16
+ # start with "- <token> needs" is prose and is skipped in silence. A line
17
+ # that DOES look like an edge is held to the grammar strictly: no target at
18
+ # all, "nothing" alongside other targets, or a target token carrying
19
+ # sentence punctuation (a period, a colon, ...) rather than the loose
20
+ # id-token charset, is an error naming the line, never a silently
21
+ # mis-parsed edge or an invented node (fold A7).
22
+ #
23
+ # Pure and side-effect-free; never raises across the boundary, matching the
24
+ # Result-hash convention every library in scripts/lib/ follows.
25
+ module GraphEdges
26
+ module_function
27
+
28
+ EDGE_LEAD_RE = /\A-\s*(\S+)\s+needs\b(.*)\z/.freeze
29
+ TOKEN_RE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
30
+ ROOT_TARGET = "nothing"
31
+
32
+ # {nodes:, edges:, errors:} - nodes is the declared ids together with every
33
+ # id any edge targets (fold A5), in first-seen order; edges maps a declared
34
+ # id to its target ids (empty for a root); errors names each malformed
35
+ # edge-shaped line. Prose lines contribute nothing and raise nothing.
36
+ def parse(section_text)
37
+ declared_order = []
38
+ edges = {}
39
+ targeted = []
40
+ errors = []
41
+
42
+ section_text.to_s.each_line do |raw_line|
43
+ line = raw_line.chomp("\n").chomp("\r")
44
+ stripped = line.strip
45
+ next if stripped.empty?
46
+
47
+ m = stripped.match(EDGE_LEAD_RE)
48
+ next unless m
49
+
50
+ id = m[1]
51
+ raw_targets = m[2].to_s.strip.split(/\s+/)
52
+
53
+ if raw_targets.empty?
54
+ errors << "malformed edge line, no target: #{stripped.inspect}"
55
+ next
56
+ end
57
+
58
+ if raw_targets.include?(ROOT_TARGET) && raw_targets.length > 1
59
+ errors << "root target #{ROOT_TARGET.inspect} mixed with a real target, root must be the only target: #{stripped.inspect}"
60
+ next
61
+ end
62
+
63
+ bad_token = raw_targets.find { |t| t != ROOT_TARGET && !t.match?(TOKEN_RE) }
64
+ if bad_token
65
+ errors << "prose tail after targets: #{stripped.inspect}"
66
+ next
67
+ end
68
+
69
+ if edges.key?(id)
70
+ errors << "duplicate node declaration for #{id.inspect}: #{stripped.inspect}"
71
+ next
72
+ end
73
+
74
+ targets = raw_targets == [ROOT_TARGET] ? [] : raw_targets
75
+ declared_order << id
76
+ edges[id] = targets
77
+ targeted.concat(targets)
78
+ end
79
+
80
+ nodes = declared_order.dup
81
+ targeted.each { |t| nodes << t unless nodes.include?(t) }
82
+
83
+ { nodes: nodes, edges: edges, errors: errors }
84
+ end
85
+
86
+ # The cycle as a path with the entry id repeated (D4r), or nil when the
87
+ # graph is acyclic. A node reachable by two distinct paths (a diamond) is
88
+ # never mistaken for a cycle: once a node's whole subtree is walked clean
89
+ # it is marked visited and never re-examined.
90
+ def cycle(edges)
91
+ visiting = {}
92
+ visited = {}
93
+ path = []
94
+
95
+ edges.each_key do |node|
96
+ next if visited[node]
97
+ found = walk(node, edges, visiting, visited, path)
98
+ return found if found
99
+ end
100
+ nil
101
+ end
102
+
103
+ def walk(node, edges, visiting, visited, path)
104
+ if visiting[node]
105
+ idx = path.index(node)
106
+ return path[idx..] + [node]
107
+ end
108
+ return nil if visited[node]
109
+
110
+ visiting[node] = true
111
+ path.push(node)
112
+ (edges[node] || []).each do |target|
113
+ found = walk(target, edges, visiting, visited, path)
114
+ return found if found
115
+ end
116
+ path.pop
117
+ visiting.delete(node)
118
+ visited[node] = true
119
+ nil
120
+ end
121
+ end
@@ -0,0 +1,246 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_edges"
5
+ require_relative "atomic_write"
6
+
7
+ # GraphFile (intent 334, n2): the four graph.md sections (## Goal,
8
+ # ## Decisions, ## Graph, ## Status), the verify:-none directive, and the two
9
+ # writers - write_status and append_decision. Both writers refuse a cyclic
10
+ # graph and both go through AtomicWrite (fold D19r). Fence-aware everywhere a
11
+ # heading is located, so a fenced example carrying a fake "## " line never
12
+ # splits or ends a real section (fold A8's sibling concern, applied to
13
+ # section boundaries rather than edge lines).
14
+ module GraphFile
15
+ module_function
16
+
17
+ FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
18
+ DIRECTIVE_RE = /\A-\s*verify:\s*none(?:\s+reason=(.*))?\z/i.freeze
19
+
20
+ # {ok:, goal:, decisions:, graph:, status:, verify:, errors:}. `graph` is
21
+ # GraphEdges.parse's own Result hash, over the Graph section text with any
22
+ # verify:-none directive line stripped first (fold A8). A missing ## Graph
23
+ # section, or one that declares no nodes, is an error naming which (fold
24
+ # A10).
25
+ def parse(path)
26
+ return failure(["graph file not found: #{path}"]) unless File.exist?(path)
27
+
28
+ content = File.read(path)
29
+ goal = section_body(content, "## Goal")
30
+ decisions = section_body(content, "## Decisions")
31
+ graph_section = section_body(content, "## Graph")
32
+ status = section_body(content, "## Status")
33
+
34
+ errors = []
35
+ if graph_section.nil?
36
+ errors << "missing ## Graph section"
37
+ return { ok: false, goal: goal, decisions: decisions, graph: nil, status: status, verify: nil, errors: errors }
38
+ end
39
+
40
+ directive, directive_errors, remaining = extract_verify_directive(graph_section)
41
+ errors.concat(directive_errors)
42
+
43
+ parsed_graph = GraphEdges.parse(strip_fenced_blocks(remaining))
44
+ errors.concat(parsed_graph[:errors])
45
+ errors << "## Graph declares no nodes" if parsed_graph[:nodes].empty?
46
+
47
+ { ok: errors.empty?, goal: goal, decisions: decisions, graph: parsed_graph, status: status, verify: directive, errors: errors }
48
+ end
49
+
50
+ def failure(errors)
51
+ { ok: false, goal: nil, decisions: nil, graph: nil, status: nil, verify: nil, errors: errors }
52
+ end
53
+
54
+ # Rows shaped {node:, state:, detail:} parsed back from the ## Status
55
+ # table (D8r's round-trip guarantee).
56
+ def status_rows(path)
57
+ parsed = parse(path)
58
+ rows_from_table(parsed[:status])
59
+ end
60
+
61
+ # Replace the whole ## Status section with rows, creating the section if
62
+ # absent. Refuses a cyclic graph (D8r); goes through AtomicWrite (D19r).
63
+ def write_status(path, rows, renamer: File.method(:rename))
64
+ guard = refuse_if_cyclic(path)
65
+ return guard if guard
66
+
67
+ content = File.read(path)
68
+ new_content = replace_or_append_section(content, "## Status", render_status_table(rows))
69
+ AtomicWrite.write(path, new_content, renamer: renamer)
70
+ { ok: true, errors: [] }
71
+ end
72
+
73
+ # Append one answered decision as a list item to ## Decisions (C26),
74
+ # leaving every other section byte-identical. Refuses a cyclic graph.
75
+ def append_decision(path, text, renamer: File.method(:rename))
76
+ guard = refuse_if_cyclic(path)
77
+ return guard if guard
78
+
79
+ content = File.read(path)
80
+ bounds = section_bounds(content, "## Decisions")
81
+ return { ok: false, errors: ["missing ## Decisions section"] } unless bounds
82
+
83
+ start_idx, end_idx = bounds
84
+ heading_line_end = line_end(content, start_idx)
85
+ body = content[heading_line_end...end_idx]
86
+ trimmed = body.rstrip
87
+ trailing = body[trimmed.length..]
88
+ new_body = trimmed.empty? ? "- #{text}#{trailing}" : "#{trimmed}\n- #{text}#{trailing}"
89
+ new_content = content[0...heading_line_end] + new_body + content[end_idx..]
90
+ AtomicWrite.write(path, new_content, renamer: renamer)
91
+ { ok: true, errors: [] }
92
+ end
93
+
94
+ def refuse_if_cyclic(path)
95
+ parsed = parse(path)
96
+ return { ok: false, errors: parsed[:errors] } if parsed[:graph].nil?
97
+
98
+ cyc = GraphEdges.cycle(parsed[:graph][:edges])
99
+ return { ok: false, errors: ["cyclic graph, refusing to write: #{cyc.join(' > ')}"] } if cyc
100
+
101
+ nil
102
+ end
103
+
104
+ # --- verify: none directive --------------------------------------------------
105
+
106
+ def extract_verify_directive(graph_section)
107
+ return [nil, [], graph_section] if graph_section.nil?
108
+
109
+ reason = nil
110
+ present = false
111
+ errors = []
112
+ remaining_lines = []
113
+
114
+ graph_section.each_line do |line|
115
+ stripped = line.strip
116
+ m = stripped.match(DIRECTIVE_RE)
117
+ if m
118
+ present = true
119
+ captured = m[1].to_s.strip
120
+ if captured.empty?
121
+ errors << "verify: none requires reason=<text>: #{stripped.inspect}"
122
+ else
123
+ reason = captured
124
+ end
125
+ else
126
+ remaining_lines << line
127
+ end
128
+ end
129
+
130
+ directive = present ? { reason: reason } : nil
131
+ [directive, errors, remaining_lines.join]
132
+ end
133
+
134
+ # D18: drop every fenced line from `text`, so a fenced example edge inside a
135
+ # hand-written graph.md or roadmap ## Graph section is never handed to
136
+ # GraphEdges as real edge text. Non-fenced lines pass through unchanged.
137
+ def strip_fenced_blocks(text)
138
+ lines = []
139
+ each_fence_line(text) { |line, fenced| lines << line unless fenced }
140
+ lines.join
141
+ end
142
+
143
+ # --- fence-aware section location ---------------------------------------------
144
+
145
+ def each_fence_line(text)
146
+ return enum_for(:each_fence_line, text) unless block_given?
147
+
148
+ marker = nil
149
+ text.to_s.each_line do |line|
150
+ if marker
151
+ yield line, true
152
+ m = line.match(FENCE_LINE_RE)
153
+ next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
154
+ next unless line.sub(FENCE_LINE_RE, "").strip.empty?
155
+
156
+ marker = nil
157
+ else
158
+ m = line.match(FENCE_LINE_RE)
159
+ if m
160
+ marker = [m[1][0], m[1].length]
161
+ yield line, true
162
+ else
163
+ yield line, false
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ # [start_of_heading_line, start_of_next_top_level_heading_or_EOF] byte
170
+ # offsets for the FIRST line, outside any fence, whose stripped text
171
+ # exactly equals heading_text. nil when no such line exists.
172
+ def section_bounds(content, heading_text)
173
+ offset = 0
174
+ heading_start = nil
175
+
176
+ each_fence_line(content) do |line, fenced|
177
+ if !fenced && heading_start.nil? && line.strip == heading_text
178
+ heading_start = offset
179
+ elsif !fenced && heading_start && offset > heading_start && line.match?(/\A##[^#]/)
180
+ return [heading_start, offset]
181
+ end
182
+ offset += line.length
183
+ end
184
+
185
+ heading_start ? [heading_start, offset] : nil
186
+ end
187
+
188
+ def section_body(content, heading_text)
189
+ bounds = section_bounds(content, heading_text)
190
+ return nil unless bounds
191
+
192
+ start_idx, end_idx = bounds
193
+ content[(line_end(content, start_idx))...end_idx].to_s
194
+ end
195
+
196
+ def line_end(content, start_idx)
197
+ idx = content.index("\n", start_idx)
198
+ idx ? idx + 1 : content.length
199
+ end
200
+
201
+ def replace_or_append_section(content, heading_text, body_text)
202
+ rendered_body = body_text.to_s
203
+ rendered_body += "\n" unless rendered_body.end_with?("\n")
204
+ section = "#{heading_text}\n#{rendered_body}"
205
+
206
+ bounds = section_bounds(content, heading_text)
207
+ if bounds
208
+ start_idx, end_idx = bounds
209
+ tail = content[end_idx..].to_s
210
+ section += "\n" unless tail.empty?
211
+ content[0...start_idx] + section + tail
212
+ else
213
+ head = content.dup
214
+ head += "\n" unless head.end_with?("\n")
215
+ head += "\n" unless head.end_with?("\n\n")
216
+ head + section
217
+ end
218
+ end
219
+
220
+ # --- table rendering and reading -----------------------------------------------
221
+
222
+ def render_status_table(rows)
223
+ lines = ["| Node | State | Detail |", "| --- | --- | --- |"]
224
+ rows.each { |r| lines << "| #{r[:node]} | #{r[:state]} | #{r[:detail]} |" }
225
+ "#{lines.join("\n")}\n"
226
+ end
227
+
228
+ def rows_from_table(text)
229
+ return [] if text.nil?
230
+
231
+ lines = []
232
+ each_fence_line(text) do |line, fenced|
233
+ next if fenced
234
+
235
+ stripped = line.strip
236
+ lines << stripped if stripped.start_with?("|")
237
+ end
238
+ sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
239
+ return [] unless sep_idx
240
+
241
+ lines[(sep_idx + 1)..].map do |l|
242
+ cells = l.split("|", -1).map(&:strip)[1..-2].to_a
243
+ { node: cells[0], state: cells[1], detail: cells[2] }
244
+ end
245
+ end
246
+ end